|
|
|
@ -6,7 +6,7 @@ from nhf import Material, Role
|
|
|
|
|
from nhf.build import Model, target, assembly
|
|
|
|
|
from nhf.parts.box import MountingBox
|
|
|
|
|
from nhf.parts.springs import TorsionSpring
|
|
|
|
|
from nhf.parts.fasteners import FlatHeadBolt, HexNut, ThreaddedKnob
|
|
|
|
|
from nhf.parts.fasteners import FlatHeadBolt, HexNut, ThreaddedKnob, Washer
|
|
|
|
|
from nhf.parts.joints import TorsionJoint, HirthJoint
|
|
|
|
|
from nhf.parts.box import Hole, MountingBox, box_with_centre_holes
|
|
|
|
|
from nhf.touhou.houjuu_nue.electronics import (
|
|
|
|
@ -67,6 +67,28 @@ ELBOW_TORSION_SPRING = TorsionSpring(
|
|
|
|
|
right_handed=False,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
ELBOW_AXLE_BOLT = FlatHeadBolt(
|
|
|
|
|
mass=0.0,
|
|
|
|
|
diam_head=6.87,
|
|
|
|
|
height_head=3.06,
|
|
|
|
|
diam_thread=4.0,
|
|
|
|
|
height_thread=20.0,
|
|
|
|
|
)
|
|
|
|
|
ELBOW_AXLE_WASHER = Washer(
|
|
|
|
|
mass=0.0,
|
|
|
|
|
diam_outer=8.96,
|
|
|
|
|
diam_thread=4.0,
|
|
|
|
|
thickness=1.02,
|
|
|
|
|
material_name="Nylon"
|
|
|
|
|
)
|
|
|
|
|
ELBOW_AXLE_HEX_NUT = HexNut(
|
|
|
|
|
mass=0.0,
|
|
|
|
|
diam_thread=4.0,
|
|
|
|
|
pitch=0.7,
|
|
|
|
|
thickness=3.6, # or 2.64 for metal
|
|
|
|
|
width=6.89,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class RootJoint(Model):
|
|
|
|
|
"""
|
|
|
|
@ -390,12 +412,13 @@ class ShoulderJoint(Model):
|
|
|
|
|
angle_neutral: float = -15.0
|
|
|
|
|
angle_max_deflection: float = 65.0
|
|
|
|
|
|
|
|
|
|
spool_radius: float = 12.0
|
|
|
|
|
spool_groove_depth: float = 1.0
|
|
|
|
|
spool_base_height: float = 3.0
|
|
|
|
|
spool_height: float = 5.0
|
|
|
|
|
spool_cap_height: float = 1.0
|
|
|
|
|
spool_groove_inset: float = 3.0
|
|
|
|
|
spool_radius_diff: float = 2.0
|
|
|
|
|
# All the heights here are mirrored for the bottom as well
|
|
|
|
|
spool_cap_height: float = 3.0
|
|
|
|
|
spool_core_height: float = 2.0
|
|
|
|
|
|
|
|
|
|
spool_line_thickness: float = 1.2
|
|
|
|
|
spool_groove_radius: float = 10.0
|
|
|
|
|
|
|
|
|
|
flip: bool = False
|
|
|
|
|
actuator: LinearActuator = LINEAR_ACTUATOR_21
|
|
|
|
@ -403,11 +426,17 @@ class ShoulderJoint(Model):
|
|
|
|
|
def __post_init__(self):
|
|
|
|
|
assert self.parent_lip_length * 2 < self.height
|
|
|
|
|
assert self.child_guard_ext > self.torsion_joint.radius_rider
|
|
|
|
|
assert self.spool_groove_depth < self.spool_radius < self.torsion_joint.radius_rider - self.child_core_thickness
|
|
|
|
|
assert self.spool_base_height > self.spool_groove_depth
|
|
|
|
|
assert self.spool_groove_radius < self.spool_inner_radius < self.spool_outer_radius
|
|
|
|
|
assert self.child_lip_height < self.height
|
|
|
|
|
assert self.draft_length <= self.actuator.stroke_length
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def spool_outer_radius(self):
|
|
|
|
|
return self.torsion_joint.radius_rider - self.child_core_thickness
|
|
|
|
|
@property
|
|
|
|
|
def spool_inner_radius(self):
|
|
|
|
|
return self.spool_outer_radius - self.spool_radius_diff
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def radius(self):
|
|
|
|
|
return self.torsion_joint.radius
|
|
|
|
@ -417,7 +446,7 @@ class ShoulderJoint(Model):
|
|
|
|
|
"""
|
|
|
|
|
Amount of wires that need to draft on the spool
|
|
|
|
|
"""
|
|
|
|
|
return (self.spool_radius - self.spool_groove_depth / 2) * math.radians(self.angle_max_deflection)
|
|
|
|
|
return self.spool_inner_radius * math.radians(self.angle_max_deflection)
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def draft_height(self):
|
|
|
|
@ -444,16 +473,20 @@ class ShoulderJoint(Model):
|
|
|
|
|
def _max_contraction_angle(self) -> float:
|
|
|
|
|
return 180 - self.angle_max_deflection + self.angle_neutral
|
|
|
|
|
|
|
|
|
|
def _contraction_cut_geometry(self, parent: bool = False, mirror: bool=False) -> Cq.Solid:
|
|
|
|
|
"""
|
|
|
|
|
Generates a cylindrical sector which cuts away overlapping regions of the child and parent
|
|
|
|
|
"""
|
|
|
|
|
def _contraction_cut_angle(self) -> float:
|
|
|
|
|
aspect = self.child_guard_width / self.parent_arm_width
|
|
|
|
|
theta = math.radians(self._max_contraction_angle)
|
|
|
|
|
#theta_p = math.atan(math.sin(theta) / (math.cos(theta) + aspect))
|
|
|
|
|
theta_p = math.atan2(math.sin(theta), math.cos(theta) + aspect)
|
|
|
|
|
angle = math.degrees(theta_p)
|
|
|
|
|
assert 0 <= angle <= 90
|
|
|
|
|
return angle
|
|
|
|
|
|
|
|
|
|
def _contraction_cut_geometry(self, parent: bool = False, mirror: bool=False) -> Cq.Solid:
|
|
|
|
|
"""
|
|
|
|
|
Generates a cylindrical sector which cuts away overlapping regions of the child and parent
|
|
|
|
|
"""
|
|
|
|
|
angle = self._contraction_cut_angle()
|
|
|
|
|
# outer radius of the cut, overestimated
|
|
|
|
|
cut_radius = math.sqrt(self.child_guard_width ** 2 + self.parent_arm_width ** 2)
|
|
|
|
|
span = 180
|
|
|
|
@ -550,37 +583,39 @@ class ShoulderJoint(Model):
|
|
|
|
|
joint = self.torsion_joint
|
|
|
|
|
return self.height - 2 * joint.total_height + 2 * joint.rider_disk_height
|
|
|
|
|
|
|
|
|
|
def _spool(self) -> Cq.Workplane:
|
|
|
|
|
def _spool(self) -> Cq.Compound:
|
|
|
|
|
"""
|
|
|
|
|
Generates the spool piece which holds the line in tension
|
|
|
|
|
"""
|
|
|
|
|
t = self.spool_groove_depth
|
|
|
|
|
radius_core_inner = self.torsion_joint.radius_rider - self.child_core_thickness
|
|
|
|
|
t = self.spool_line_thickness
|
|
|
|
|
spindle = Cq.Solid.makeCone(
|
|
|
|
|
radius1=self.spool_radius,
|
|
|
|
|
radius2=radius_core_inner,
|
|
|
|
|
height=self.spool_height,
|
|
|
|
|
radius1=self.spool_inner_radius,
|
|
|
|
|
radius2=self.spool_outer_radius,
|
|
|
|
|
height=self.spool_core_height,
|
|
|
|
|
)
|
|
|
|
|
cap = Cq.Solid.makeCylinder(
|
|
|
|
|
radius=radius_core_inner,
|
|
|
|
|
radius=self.spool_outer_radius,
|
|
|
|
|
height=self.spool_cap_height
|
|
|
|
|
).located(Cq.Location((0,0,self.spool_height)))
|
|
|
|
|
hole_x = radius_core_inner - self.spool_groove_inset
|
|
|
|
|
slot = Cq.Solid.makeBox(
|
|
|
|
|
length=t,
|
|
|
|
|
).moved(Cq.Location((0,0,self.spool_core_height)))
|
|
|
|
|
cut_height = self.spool_cap_height + self.spool_core_height
|
|
|
|
|
cut_hole = Cq.Solid.makeCylinder(
|
|
|
|
|
radius=t / 2,
|
|
|
|
|
height=cut_height,
|
|
|
|
|
).moved(Cq.Location((self.spool_groove_radius, 0, 0)))
|
|
|
|
|
cut_slot = Cq.Solid.makeBox(
|
|
|
|
|
length=self.spool_outer_radius - self.spool_groove_radius,
|
|
|
|
|
width=t,
|
|
|
|
|
height=self.spool_base_height,
|
|
|
|
|
).located(Cq.Location((hole_x, -t/2, 0)))
|
|
|
|
|
hole = Cq.Solid.makeBox(
|
|
|
|
|
length=t,
|
|
|
|
|
width=t,
|
|
|
|
|
height=self.spool_height + self.spool_base_height,
|
|
|
|
|
).located(Cq.Location((hole_x, -t/2, 0)))
|
|
|
|
|
centre_hole = Cq.Solid.makeCylinder(
|
|
|
|
|
height=self.spool_core_height,
|
|
|
|
|
).moved(Cq.Location((self.spool_groove_radius, -t/2, 0)))
|
|
|
|
|
cut_centre_hole = Cq.Solid.makeCylinder(
|
|
|
|
|
radius=self.torsion_joint.radius_axle,
|
|
|
|
|
height=self.spool_height + self.spool_base_height,
|
|
|
|
|
height=cut_height,
|
|
|
|
|
)
|
|
|
|
|
top = spindle.fuse(cap).cut(cut_hole, cut_centre_hole, cut_slot)
|
|
|
|
|
return (
|
|
|
|
|
top
|
|
|
|
|
.fuse(top.located(Cq.Location((0,0,0), (1,0, 0), 180)))
|
|
|
|
|
)
|
|
|
|
|
return spindle.fuse(cap).cut(slot, hole, centre_hole)
|
|
|
|
|
|
|
|
|
|
@target(name="child")
|
|
|
|
|
def child(self) -> Cq.Assembly:
|
|
|
|
@ -611,6 +646,14 @@ class ShoulderJoint(Model):
|
|
|
|
|
.assemble()
|
|
|
|
|
.circle(radius_core_inner, mode='s')
|
|
|
|
|
)
|
|
|
|
|
angle_line_span = -self.angle_neutral + self.angle_max_deflection + 90
|
|
|
|
|
angle_line = 180 - angle_line_span
|
|
|
|
|
# leave space for the line to rotate
|
|
|
|
|
spool_cut = Cq.Solid.makeCylinder(
|
|
|
|
|
radius=joint.radius_rider * 2,
|
|
|
|
|
height=self.spool_core_height * 2,
|
|
|
|
|
angleDegrees=angle_line_span,
|
|
|
|
|
).moved(Cq.Location((0,0,-self.spool_core_height), (0,0,1), angle_line))
|
|
|
|
|
lip_extension = (
|
|
|
|
|
Cq.Solid.makeBox(
|
|
|
|
|
length=self.child_lip_ext - self.child_guard_ext,
|
|
|
|
@ -676,7 +719,8 @@ class ShoulderJoint(Model):
|
|
|
|
|
.toPending()
|
|
|
|
|
.extrude(dh * 2)
|
|
|
|
|
.translate(Cq.Vector(0, 0, -dh))
|
|
|
|
|
.union(core_guard)
|
|
|
|
|
.cut(spool_cut)
|
|
|
|
|
.union(core_guard, tol=TOL)
|
|
|
|
|
)
|
|
|
|
|
assert self.child_lip_width / 2 <= joint.radius_rider
|
|
|
|
|
sign = 1 if self.flip else -1
|
|
|
|
@ -695,8 +739,8 @@ class ShoulderJoint(Model):
|
|
|
|
|
loc_rotate = Cq.Location((0, 0, 0), (1, 0, 0), 180)
|
|
|
|
|
loc_axis_rotate_bot = Cq.Location((0, 0, 0), (0, 0, 1), self.axis_rotate_bot + self.angle_neutral)
|
|
|
|
|
loc_axis_rotate_top = Cq.Location((0, 0, 0), (0, 0, 1), self.axis_rotate_top + self.angle_neutral)
|
|
|
|
|
spool_dz = self.height / 2 - self.torsion_joint.total_height
|
|
|
|
|
spool_angle = 180 + self.angle_neutral
|
|
|
|
|
spool_dz = 0
|
|
|
|
|
spool_angle = -self.angle_neutral
|
|
|
|
|
loc_spool_flip = Cq.Location((0,0,0),(0,1,0),180) if self.flip else Cq.Location()
|
|
|
|
|
result = (
|
|
|
|
|
Cq.Assembly()
|
|
|
|
@ -848,6 +892,7 @@ class DiskJoint(Model):
|
|
|
|
|
|
|
|
|
|
housing_thickness: float = 4.0
|
|
|
|
|
disk_thickness: float = 8.0
|
|
|
|
|
tongue_thickness: float = 10.0
|
|
|
|
|
|
|
|
|
|
# Amount by which the wall carves in
|
|
|
|
|
wall_inset: float = 2.0
|
|
|
|
@ -868,6 +913,9 @@ class DiskJoint(Model):
|
|
|
|
|
|
|
|
|
|
generate_inner_wall: bool = False
|
|
|
|
|
|
|
|
|
|
axle_bolt: FlatHeadBolt = ELBOW_AXLE_BOLT
|
|
|
|
|
axle_washer: Washer = ELBOW_AXLE_WASHER
|
|
|
|
|
axle_hex_nut: HexNut = ELBOW_AXLE_HEX_NUT
|
|
|
|
|
|
|
|
|
|
def __post_init__(self):
|
|
|
|
|
super().__init__(name="disk-joint")
|
|
|
|
@ -876,6 +924,11 @@ class DiskJoint(Model):
|
|
|
|
|
|
|
|
|
|
assert self.housing_upper_carve_offset > 0
|
|
|
|
|
assert self.spring_tail_hole_height > self.spring.thickness
|
|
|
|
|
assert self.tongue_thickness <= self.total_thickness
|
|
|
|
|
|
|
|
|
|
assert self.axle_bolt.diam_thread == self.axle_washer.diam_thread
|
|
|
|
|
assert self.axle_bolt.diam_thread == self.axle_hex_nut.diam_thread
|
|
|
|
|
assert self.axle_bolt.height_thread > self.total_thickness, "Bolt is not long enough"
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def neutral_movement_angle(self) -> Optional[float]:
|
|
|
|
@ -932,10 +985,21 @@ class DiskJoint(Model):
|
|
|
|
|
@target(name="disk")
|
|
|
|
|
def disk(self) -> Cq.Workplane:
|
|
|
|
|
radius_tongue = self.radius_disk + self.tongue_length
|
|
|
|
|
tongue = (
|
|
|
|
|
outer_tongue = (
|
|
|
|
|
Cq.Solid.makeCylinder(
|
|
|
|
|
height=self.tongue_thickness,
|
|
|
|
|
radius=radius_tongue,
|
|
|
|
|
angleDegrees=self.tongue_span,
|
|
|
|
|
).cut(Cq.Solid.makeCylinder(
|
|
|
|
|
height=self.tongue_thickness,
|
|
|
|
|
radius=self.radius_housing,
|
|
|
|
|
))
|
|
|
|
|
.moved(Cq.Location((0,0,(self.disk_thickness - self.tongue_thickness) / 2)))
|
|
|
|
|
)
|
|
|
|
|
inner_tongue = (
|
|
|
|
|
Cq.Solid.makeCylinder(
|
|
|
|
|
height=self.disk_thickness,
|
|
|
|
|
radius=radius_tongue,
|
|
|
|
|
radius=self.radius_housing,
|
|
|
|
|
angleDegrees=self.tongue_span,
|
|
|
|
|
).cut(Cq.Solid.makeCylinder(
|
|
|
|
|
height=self.disk_thickness,
|
|
|
|
@ -949,7 +1013,8 @@ class DiskJoint(Model):
|
|
|
|
|
radius=self.radius_disk,
|
|
|
|
|
centered=(True, True, False)
|
|
|
|
|
)
|
|
|
|
|
.union(tongue, tol=TOL)
|
|
|
|
|
.union(inner_tongue, tol=TOL)
|
|
|
|
|
.union(outer_tongue, tol=TOL)
|
|
|
|
|
.copyWorkplane(Cq.Workplane('XY'))
|
|
|
|
|
.cylinder(
|
|
|
|
|
height=self.disk_thickness,
|
|
|
|
@ -995,6 +1060,7 @@ class DiskJoint(Model):
|
|
|
|
|
))
|
|
|
|
|
)
|
|
|
|
|
result.faces(">Z").tag("mate")
|
|
|
|
|
result.faces("<Z").tag("bot")
|
|
|
|
|
result.faces(">Z").workplane().tagPlane("dirX", direction="+X")
|
|
|
|
|
result = result.cut(
|
|
|
|
|
self
|
|
|
|
@ -1028,6 +1094,7 @@ class DiskJoint(Model):
|
|
|
|
|
)
|
|
|
|
|
theta = math.radians(carve_angle)
|
|
|
|
|
result.faces("<Z").tag("mate")
|
|
|
|
|
result.faces(">Z").tag("top")
|
|
|
|
|
p_xy = result.copyWorkplane(Cq.Workplane('XY'))
|
|
|
|
|
p_xy.tagPlane("dirX", direction="+X")
|
|
|
|
|
p_xy.tagPlane("dir", direction=(math.cos(theta), math.sin(theta), 0))
|
|
|
|
@ -1063,6 +1130,8 @@ class DiskJoint(Model):
|
|
|
|
|
housing_upper: str,
|
|
|
|
|
disk: str,
|
|
|
|
|
angle: float = 0.0,
|
|
|
|
|
fasteners: bool = True,
|
|
|
|
|
fastener_prefix: str = "fastener",
|
|
|
|
|
) -> Cq.Assembly:
|
|
|
|
|
assert 0 <= angle <= self.movement_angle
|
|
|
|
|
deflection = angle - (self.spring.angle_neutral - self.spring_angle_at_0)
|
|
|
|
@ -1084,6 +1153,16 @@ class DiskJoint(Model):
|
|
|
|
|
#.constrain(f"{housing_lower}?dirX", f"{disk}?dir", "Axis", param=angle)
|
|
|
|
|
#.constrain(f"{housing_lower}?dirY", f"{disk}?dir", "Axis", param=angle - 90)
|
|
|
|
|
)
|
|
|
|
|
if fasteners:
|
|
|
|
|
tag_bolt = f"{fastener_prefix}_bolt"
|
|
|
|
|
tag_nut = f"{fastener_prefix}_nut"
|
|
|
|
|
(
|
|
|
|
|
assembly
|
|
|
|
|
.add(self.axle_bolt.assembly(), name=tag_bolt)
|
|
|
|
|
.add(self.axle_hex_nut.assembly(), name=tag_nut)
|
|
|
|
|
.constrain(f"{housing_lower}?bot", f"{tag_nut}?bot", "Plane")
|
|
|
|
|
.constrain(f"{housing_upper}?top", f"{tag_bolt}?root", "Plane", param=0)
|
|
|
|
|
)
|
|
|
|
|
return (
|
|
|
|
|
assembly
|
|
|
|
|
)
|
|
|
|
@ -1136,7 +1215,9 @@ class ElbowJoint(Model):
|
|
|
|
|
|
|
|
|
|
lip_thickness: float = 5.0
|
|
|
|
|
lip_length: float = 60.0
|
|
|
|
|
hole_pos: list[float] = field(default_factory=lambda: [12, 24])
|
|
|
|
|
# Carve which allows light to go through
|
|
|
|
|
lip_side_depression_width: float = 10.0
|
|
|
|
|
hole_pos: list[float] = field(default_factory=lambda: [15, 24])
|
|
|
|
|
parent_arm_width: float = 10.0
|
|
|
|
|
# Angle of the beginning of the parent arm
|
|
|
|
|
parent_arm_angle: float = 180.0
|
|
|
|
@ -1254,6 +1335,17 @@ class ElbowJoint(Model):
|
|
|
|
|
Hole(x=-sign * x, tag=f"conn_bot{i}")
|
|
|
|
|
]
|
|
|
|
|
]
|
|
|
|
|
def post(sketch: Cq.Sketch) -> Cq.Sketch:
|
|
|
|
|
y_outer = self.disk_joint.total_thickness / 2
|
|
|
|
|
y_inner = self.disk_joint.tongue_thickness / 2
|
|
|
|
|
y = (y_outer + y_inner) / 2
|
|
|
|
|
width = self.lip_side_depression_width
|
|
|
|
|
height = y_outer - y_inner
|
|
|
|
|
return (
|
|
|
|
|
sketch
|
|
|
|
|
.push([(0, y), (0, -y)])
|
|
|
|
|
.rect(width, height, mode='s')
|
|
|
|
|
)
|
|
|
|
|
mbox = MountingBox(
|
|
|
|
|
length=self.lip_length,
|
|
|
|
|
width=self.disk_joint.total_thickness,
|
|
|
|
@ -1263,6 +1355,7 @@ class ElbowJoint(Model):
|
|
|
|
|
centred=(True, True),
|
|
|
|
|
generate_side_tags=False,
|
|
|
|
|
generate_reverse_tags=True,
|
|
|
|
|
profile_callback=post,
|
|
|
|
|
)
|
|
|
|
|
return mbox.generate()
|
|
|
|
|
|
|
|
|
@ -1332,7 +1425,7 @@ class ElbowJoint(Model):
|
|
|
|
|
def parent_joint_upper(self, generate_mount: bool=False, generate_tags=True):
|
|
|
|
|
axial_offset = Cq.Location((self.parent_arm_radius, 0, 0))
|
|
|
|
|
housing_dz = self.disk_joint.housing_upper_dz
|
|
|
|
|
conn_h = self.disk_joint.total_thickness
|
|
|
|
|
conn_h = self.disk_joint.tongue_thickness
|
|
|
|
|
conn_w = self.parent_arm_width
|
|
|
|
|
connector = (
|
|
|
|
|
Cq.Solid.makeBox(
|
|
|
|
|