Cosplay/nhf/touhou/houjuu_nue/parts.py

429 lines
14 KiB
Python
Raw Normal View History

2024-07-11 22:29:05 -07:00
from dataclasses import dataclass, field
2024-07-11 08:42:13 -07:00
from typing import Optional, Tuple
2024-07-10 16:21:11 -07:00
import cadquery as Cq
from nhf import Role
from nhf.build import Model, target
import nhf.parts.springs as springs
import nhf.utils
TOL = 1e-6
2024-07-11 22:29:05 -07:00
@dataclass
class Beam:
"""
A I-shaped spine with two feet
"""
foot_length: float = 40.0
foot_width: float = 20.0
foot_height: float = 5.0
spine_thickness: float = 4.0
spine_length: float = 10.0
total_height: float = 50.0
hole_diam: float = 8.0
# distance between the centres of the two holes
hole_dist: float = 24.0
def __post_init__(self):
assert self.spine_height > 0
assert self.hole_diam + self.hole_dist < self.foot_length
assert self.hole_dist - self.hole_diam >= self.spine_length
@property
def spine_height(self):
return self.total_height - self.foot_height * 2
def foot(self) -> Cq.Workplane:
"""
just one foot
"""
dx = self.hole_dist / 2
result = (
Cq.Workplane('XZ')
.box(self.foot_length, self.foot_width, self.foot_height,
centered=(True, True, False))
.faces(">Y")
.workplane()
.pushPoints([(dx, 0), (-dx, 0)])
.hole(self.hole_diam)
)
plane = result.faces(">Y").workplane()
plane.moveTo(dx, 0).tagPlane("conn1")
plane.moveTo(-dx, 0).tagPlane("conn0")
return result
def beam(self) -> Cq.Assembly:
beam = (
Cq.Workplane('XZ')
.box(self.spine_length, self.spine_thickness, self.spine_height)
)
h = self.spine_height / 2 + self.foot_height
result = (
Cq.Assembly()
.add(beam, name="beam")
.add(self.foot(), name="top",
loc=Cq.Location((0, h, 0)))
.add(self.foot(), name="bot",
loc=Cq.Location((0, -h, 0), (0, 0, 1), 180))
)
return result
2024-07-10 16:21:11 -07:00
@dataclass
class DiskJoint(Model):
"""
Sandwiched disk joint for the wrist and elbow
"""
radius_housing: float = 22.0
radius_disk: float = 20.0
radius_spring: float = 9 / 2
radius_axle: float = 3.0
housing_thickness: float = 5.0
disk_thickness: float = 5.0
# Gap between disk and the housing
#disk_thickness_gap: float = 0.1
spring_thickness: float = 1.3
spring_height: float = 6.5
spring_tail_length: float = 45.0
2024-07-11 08:42:13 -07:00
# Spring angle at 0 degrees of movement
spring_angle: float = 30.0
# Angle at which the spring exerts no torque
spring_angle_neutral: float = 90.0
spring_angle_shift: float = 30
2024-07-10 16:21:11 -07:00
wall_inset: float = 2.0
# Angular span of movement
movement_angle: float = 120.0
# Angular span of tongue on disk
tongue_span: float = 30.0
tongue_length: float = 10.0
generate_inner_wall: bool = False
def __post_init__(self):
super().__init__(name="disk-joint")
assert self.housing_thickness > self.wall_inset
assert self.radius_housing > self.radius_disk
assert self.radius_disk > self.radius_axle
assert self.housing_upper_carve_offset > 0
def spring(self):
return springs.torsion_spring(
radius=self.radius_spring,
height=self.spring_height,
thickness=self.spring_thickness,
tail_length=self.spring_tail_length,
right_handed=False,
)
@property
2024-07-11 08:42:13 -07:00
def neutral_movement_angle(self) -> Optional[float]:
a = self.spring_angle_neutral - self.spring_angle
if 0 <= a and a <= self.movement_angle:
return a
return None
@property
def total_thickness(self) -> float:
2024-07-10 16:21:11 -07:00
return self.housing_thickness * 2 + self.disk_thickness
@property
2024-07-11 08:42:13 -07:00
def opening_span(self) -> float:
2024-07-10 16:21:11 -07:00
return self.movement_angle + self.tongue_span
@property
2024-07-11 08:42:13 -07:00
def housing_upper_carve_offset(self) -> float:
2024-07-11 22:29:05 -07:00
"""
Distance between the spring track and the outside of the upper housing
"""
2024-07-10 16:21:11 -07:00
return self.housing_thickness + self.disk_thickness - self.spring_height
2024-07-11 22:29:05 -07:00
@property
def housing_upper_dz(self) -> float:
"""
Distance between the default upper housing location and the median line
"""
return self.total_thickness / 2 - self.housing_thickness
2024-07-10 16:21:11 -07:00
@property
def radius_spring_internal(self):
return self.radius_spring - self.spring_thickness
@target(name="disk")
def disk(self) -> Cq.Workplane:
cut = (
Cq.Solid.makeBox(
length=self.spring_tail_length,
width=self.spring_thickness,
height=self.disk_thickness,
)
.located(Cq.Location((0, self.radius_spring_internal, 0)))
.rotate((0, 0, 0), (0, 0, 1), self.spring_angle_shift)
)
result = (
Cq.Workplane('XY')
.cylinder(
height=self.disk_thickness,
radius=self.radius_disk,
centered=(True, True, False)
)
.copyWorkplane(Cq.Workplane('XY'))
.cylinder(
height=self.disk_thickness,
radius=self.radius_spring,
centered=(True, True, False),
combine='cut',
)
.cut(cut)
)
plane = result.copyWorkplane(Cq.Workplane('XY'))
plane.tagPlane("dir", direction="+X")
plane.workplane(offset=self.disk_thickness).tagPlane("mate_top")
result.copyWorkplane(Cq.Workplane('YX')).tagPlane("mate_bot")
radius_tongue = self.radius_disk + self.tongue_length
tongue = (
Cq.Solid.makeCylinder(
height=self.disk_thickness,
radius=radius_tongue,
angleDegrees=self.tongue_span,
).cut(Cq.Solid.makeCylinder(
height=self.disk_thickness,
radius=self.radius_disk,
))
)
result = result.union(tongue, tol=TOL)
return result
def wall(self) -> Cq.Compound:
height = self.disk_thickness + self.wall_inset
wall = Cq.Solid.makeCylinder(
radius=self.radius_housing,
height=height,
angleDegrees=360 - self.opening_span,
).cut(Cq.Solid.makeCylinder(
radius=self.radius_disk,
height=height,
)).rotate((0, 0, 0), (0, 0, 1), self.opening_span)
return wall
@target(name="housing-lower")
def housing_lower(self) -> Cq.Workplane:
result = (
Cq.Workplane('XY')
.cylinder(
radius=self.radius_housing,
height=self.housing_thickness,
centered=(True, True, False),
)
.cut(Cq.Solid.makeCylinder(
radius=self.radius_axle,
height=self.housing_thickness,
))
)
result.faces(">Z").tag("mate")
result.faces(">Z").workplane().tagPlane("dir", direction="+X")
result = result.cut(
self
.wall()
2024-07-11 08:42:13 -07:00
.located(Cq.Location((0, 0, self.disk_thickness - self.wall_inset)))
2024-07-10 16:21:11 -07:00
#.rotate((0, 0, 0), (1, 0, 0), 180)
#.located(Cq.Location((0, 0, self.disk_thickness + self.housing_thickness)))
)
return result
@target(name="housing-upper")
def housing_upper(self) -> Cq.Workplane:
carve = (
Cq.Solid.makeCylinder(
radius=self.radius_spring,
height=self.housing_thickness
).fuse(Cq.Solid.makeBox(
length=self.spring_tail_length,
width=self.spring_thickness,
height=self.housing_thickness
2024-07-11 22:29:05 -07:00
).located(Cq.Location((0, -self.radius_spring, 0))))
).rotate((0, 0, 0), (0, 0, 1), self.spring_angle - self.spring_angle_shift)
2024-07-10 16:21:11 -07:00
result = (
Cq.Workplane('XY')
.cylinder(
radius=self.radius_housing,
height=self.housing_thickness,
centered=(True, True, False),
)
)
2024-07-11 22:29:05 -07:00
result.faces("<Z").tag("mate")
result.copyWorkplane(Cq.Workplane('XY')).tagPlane("dir", direction="-X")
2024-07-10 16:21:11 -07:00
result = result.faces(">Z").hole(self.radius_axle * 2)
# tube which holds the spring interior
if self.generate_inner_wall:
tube = (
Cq.Solid.makeCylinder(
radius=self.radius_spring_internal,
height=self.disk_thickness + self.housing_thickness,
).cut(Cq.Solid.makeCylinder(
radius=self.radius_axle,
height=self.disk_thickness + self.housing_thickness,
))
)
result = result.union(tube)
wall = (
self.wall()
2024-07-11 22:29:05 -07:00
.located(Cq.Location((0, 0, -self.disk_thickness-self.wall_inset)))
2024-07-10 16:21:11 -07:00
)
result = (
result
2024-07-11 22:29:05 -07:00
.cut(carve.located(Cq.Location((0, 0, -self.housing_upper_carve_offset))))
2024-07-10 16:21:11 -07:00
.union(wall, tol=TOL)
)
return result
2024-07-11 08:42:13 -07:00
def add_constraints(self,
assembly: Cq.Assembly,
housing_lower: str,
housing_upper: str,
disk: str,
angle: Tuple[float, float, float] = (0, 0, 0),
) -> Cq.Assembly:
"""
The angle supplied must be perpendicular to the disk normal.
"""
(
assembly
.constrain(f"{disk}?mate_bot", f"{housing_lower}?mate", "Plane")
.constrain(f"{disk}?mate_top", f"{housing_upper}?mate", "Plane")
.constrain(f"{housing_lower}?dir", f"{housing_upper}?dir", "Axis")
.constrain(f"{disk}?dir", "FixedRotation", angle)
)
2024-07-10 16:21:11 -07:00
2024-07-11 08:42:13 -07:00
def assembly(self, angle: Optional[float] = 0) -> Cq.Assembly:
if angle is None:
angle = self.movement_angle
if angle is None:
angle = 0
else:
assert 0 <= angle <= self.movement_angle
2024-07-10 16:21:11 -07:00
result = (
Cq.Assembly()
.add(self.disk(), name="disk", color=Role.CHILD.color)
.add(self.housing_lower(), name="housing_lower", color=Role.PARENT.color)
2024-07-11 08:42:13 -07:00
.add(self.housing_upper(), name="housing_upper", color=Role.CASING.color)
.constrain("housing_lower", "Fixed")
2024-07-10 16:21:11 -07:00
)
2024-07-11 08:42:13 -07:00
self.add_constraints(
result,
housing_lower="housing_lower",
housing_upper="housing_upper",
disk="disk",
angle=(0, 0, angle),
)
return result.solve()
2024-07-10 16:21:11 -07:00
2024-07-11 22:29:05 -07:00
@dataclass
class ElbowJoint:
"""
Creates the elbow and wrist joints.
This consists of a disk joint, where each side of the joint has mounting
holes for connection to the exoskeleton. Each side 2 mounting feet on the
top and bottom, and each foot has 2 holes.
On the parent side, additional bolts are needed to clamp the two sides of
the housing together.
"""
disk_joint: DiskJoint = field(default_factory=lambda: DiskJoint(
movement_angle=60,
))
# Distance between the child/parent arm to the centre
child_arm_radius: float = 40.0
parent_arm_radius: float = 40.0
child_beam: Beam = field(default_factory=lambda: Beam())
parent_beam: Beam = field(default_factory=lambda: Beam(
spine_thickness=8.0,
))
parent_arm_span: float = 40.0
# Angle of the beginning of the parent arm
parent_arm_angle: float = 180.0
parent_binding_hole_radius: float = 30.0
# Size of the mounting holes
hole_diam: float = 8.0
def __post_init__(self):
assert self.child_arm_radius > self.disk_joint.radius_housing
assert self.parent_arm_radius > self.disk_joint.radius_housing
self.disk_joint.tongue_length = self.child_arm_radius - self.disk_joint.radius_disk
assert self.disk_joint.movement_angle < self.parent_arm_angle < 360 - self.parent_arm_span
assert self.parent_binding_hole_radius - self.hole_diam / 2 > self.disk_joint.radius_housing
def child_joint(self) -> Cq.Assembly:
angle = -self.disk_joint.tongue_span / 2
dz = self.disk_joint.disk_thickness / 2
result = (
self.child_beam.beam()
.add(self.disk_joint.disk(), name="disk",
loc=Cq.Location((-self.child_arm_radius, 0, -dz), (0, 0, 1), angle))
)
return result
def parent_joint_bot(self) -> Cq.Workplane:
return self.disk_joint.housing_lower()
def parent_joint_top(self):
axial_offset = Cq.Location((self.parent_arm_radius, 0, 0))
housing_dz = self.disk_joint.housing_upper_dz
conn_h = self.parent_beam.spine_thickness
connector = (
Cq.Solid.makeCylinder(
height=conn_h,
radius=self.parent_arm_radius,
angleDegrees=self.parent_arm_span)
.cut(Cq.Solid.makeCylinder(
height=conn_h,
radius=self.disk_joint.radius_housing,
))
.located(Cq.Location((0, 0, -conn_h / 2)))
.rotate((0,0,0), (0,0,1), 180-self.parent_arm_span / 2)
)
housing = self.disk_joint.housing_upper()
result = (
Cq.Assembly()
.add(housing, name="housing",
loc=axial_offset * Cq.Location((0, 0, housing_dz)))
.add(connector, name="connector",
loc=axial_offset)
.add(self.parent_beam.beam(), name="beam")
)
return result
def assembly(self, angle: float = 0) -> Cq.Assembly:
da = self.disk_joint.tongue_span / 2
result = (
Cq.Assembly()
.add(self.child_joint(), name="child", color=Role.CHILD.color)
.add(self.parent_joint_bot(), name="parent_bot", color=Role.CASING.color)
.add(self.parent_joint_top(), name="parent_top", color=Role.PARENT.color)
.constrain("parent_bot", "Fixed")
)
self.disk_joint.add_constraints(
result,
housing_lower="parent_bot",
housing_upper="parent_top/housing",
disk="child/disk",
angle=(0, 0, angle + da),
)
return result.solve()
2024-07-10 16:21:11 -07:00
if __name__ == '__main__':
p = DiskJoint()
p.build_all()