diff --git a/nhf/parts/fasteners.py b/nhf/parts/fasteners.py index 02bd598..ab79802 100644 --- a/nhf/parts/fasteners.py +++ b/nhf/parts/fasteners.py @@ -1,6 +1,7 @@ from dataclasses import dataclass import math import cadquery as Cq +from typing import Optional from nhf import Item, Role import nhf.utils @@ -127,3 +128,35 @@ class HexNut(Item): .regularPolygon(r=self.radius, n=6) ._faces ) + +@dataclass(frozen=True) +class Washer(Item): + diam_thread: float + diam_outer: float + thickness: float + material_name: Optional[float] = None + + def __post_init__(self): + assert self.diam_outer > self.diam_thread + @property + def name(self): + suffix = (" " + self.material_name) if self.material_name else "" + return f"Washer M{int(self.diam_thread)}{suffix}" + + @property + def role(self) -> Role: + return Role.CONNECTION + + def generate(self) -> Cq.Workplane: + result = ( + Cq.Workplane('XY') + .cylinder( + radius=self.diam_outer/2, + height=self.thickness, + ) + .faces(">Z") + .hole(self.diam_thread) + ) + result.faces("Z").tag("top") + return result diff --git a/nhf/touhou/houjuu_nue/electronics.py b/nhf/touhou/houjuu_nue/electronics.py index 8d8120d..79a351d 100644 --- a/nhf/touhou/houjuu_nue/electronics.py +++ b/nhf/touhou/houjuu_nue/electronics.py @@ -547,3 +547,9 @@ class ElectronicBoard(Model): ) ) return result.solve() + +@dataclass(frozen=True) +class LightStrip: + + width: float = 10.0 + height: float = 4.5 diff --git a/nhf/touhou/houjuu_nue/joints.py b/nhf/touhou/houjuu_nue/joints.py index 77be185..5ceac76 100644 --- a/nhf/touhou/houjuu_nue/joints.py +++ b/nhf/touhou/houjuu_nue/joints.py @@ -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").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("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( diff --git a/nhf/touhou/houjuu_nue/wing.py b/nhf/touhou/houjuu_nue/wing.py index 3a3ff62..7e6262c 100644 --- a/nhf/touhou/houjuu_nue/wing.py +++ b/nhf/touhou/houjuu_nue/wing.py @@ -19,6 +19,7 @@ from nhf.touhou.houjuu_nue.electronics import ( LINEAR_ACTUATOR_21, LINEAR_ACTUATOR_50, ElectronicBoard, + LightStrip, ELECTRONIC_MOUNT_HEXNUT, ) import nhf.utils @@ -52,6 +53,8 @@ class WingProfile(Model): spacer_thickness: float = 25.4 / 4 rod_width: float = 10.0 + light_strip: LightStrip = LightStrip() + shoulder_joint: ShoulderJoint = field(default_factory=lambda: ShoulderJoint( )) shoulder_angle_bias: float = 0.0 @@ -370,9 +373,8 @@ class WingProfile(Model): ) @submodel(name="spacer-s0-shoulder-act") def spacer_s0_shoulder_act(self) -> MountingBox: - dx = self.shoulder_joint.draft_height return MountingBox( - holes=[Hole(x=dx), Hole(x=-dx)], + holes=[Hole(x=0)], hole_diam=self.shoulder_joint.actuator.back_hole_diam, length=self.root_height, width=10.0, @@ -616,16 +618,17 @@ class WingProfile(Model): Hole(sign * x, tag=tag) for x, tag in joint.hole_loc_tags() ] + tongue_thickness = joint.disk_joint.tongue_thickness + carve_width = joint.lip_side_depression_width + assert carve_width >= self.light_strip.width + carve_height = (segment_thickness - tongue_thickness) / 2 + assert carve_height >= self.light_strip.height def carve_sides(profile): - dy = (segment_thickness + joint.total_thickness) / 4 + dy = (segment_thickness + tongue_thickness) / 4 return ( profile .push([(0,-dy), (0,dy)]) - .rect( - joint.parent_arm_width, - (segment_thickness - joint.total_thickness) / 2, - mode='s', - ) + .rect(carve_width, carve_height, mode='s') ) # FIXME: Carve out the sides so light can pass through mbox = MountingBox(