From ac509a16251a70bdfcce8b4d4827e93d4c670aa1 Mon Sep 17 00:00:00 2001 From: Leni Aniva Date: Tue, 23 Jul 2024 16:49:25 -0700 Subject: [PATCH] feat: Anti-collision shoulder joint --- nhf/parts/box.py | 10 ++- nhf/touhou/houjuu_nue/__init__.py | 7 +- nhf/touhou/houjuu_nue/joints.py | 124 ++++++++++++++++++++++-------- nhf/touhou/houjuu_nue/wing.py | 42 +++++----- 4 files changed, 129 insertions(+), 54 deletions(-) diff --git a/nhf/parts/box.py b/nhf/parts/box.py index 36828fe..57a5d63 100644 --- a/nhf/parts/box.py +++ b/nhf/parts/box.py @@ -58,6 +58,8 @@ class MountingBox(Model): # Generate tags on the opposite side generate_reverse_tags: bool = False + centre_bot_top_tags: bool = False + # Determines the position of side tags flip_y: bool = False @@ -105,8 +107,12 @@ class MountingBox(Model): result.faces(">Y").workplane(origin=result.vertices("Y and >Z").val().Center()).tagPlane("right") c_y = ">Y" if self.flip_y else "Z").val().Center()).tagPlane("bot") - result.faces(">X").workplane(origin=result.vertices(f">X and {c_y} and >Z").val().Center()).tagPlane("top") + if self.centre_bot_top_tags: + result.faces("Z").val().Center()).tagPlane("bot") + result.faces(">X").workplane(origin=result.edges(f">X and >Z").val().Center()).tagPlane("top") + else: + result.faces("Z").val().Center()).tagPlane("bot") + result.faces(">X").workplane(origin=result.vertices(f">X and {c_y} and >Z").val().Center()).tagPlane("top") result.faces(">Z").tag("dir") return result diff --git a/nhf/touhou/houjuu_nue/__init__.py b/nhf/touhou/houjuu_nue/__init__.py index da2d1c1..c6b4d4c 100644 --- a/nhf/touhou/houjuu_nue/__init__.py +++ b/nhf/touhou/houjuu_nue/__init__.py @@ -41,6 +41,7 @@ import nhf.touhou.houjuu_nue.harness as MH from nhf.parts.item import Item import nhf.utils +WING_DEFLECT = 10.0 @dataclass class Parameters(Model): """ @@ -51,7 +52,7 @@ class Parameters(Model): wing_r1: MW.WingR = field(default_factory=lambda: MW.WingR( name="r1", - shoulder_angle_bias = 15.0, + shoulder_angle_bias = WING_DEFLECT, s0_top_hole=False, s0_bot_hole=True, )) @@ -62,7 +63,7 @@ class Parameters(Model): )) wing_r3: MW.WingR = field(default_factory=lambda: MW.WingR( name="r3", - shoulder_angle_bias = 15.0, + shoulder_angle_bias = WING_DEFLECT, s0_top_hole=True, s0_bot_hole=False, )) @@ -75,7 +76,7 @@ class Parameters(Model): wing_l2: MW.WingL = field(default_factory=lambda: MW.WingL( name="l2", wrist_angle=-30.0, - shoulder_angle_bias = 15.0, + shoulder_angle_bias = WING_DEFLECT, s0_top_hole=True, s0_bot_hole=True, )) diff --git a/nhf/touhou/houjuu_nue/joints.py b/nhf/touhou/houjuu_nue/joints.py index 56433fb..9cd530a 100644 --- a/nhf/touhou/houjuu_nue/joints.py +++ b/nhf/touhou/houjuu_nue/joints.py @@ -261,18 +261,23 @@ class ShoulderJoint(Model): # On the parent side, drill vertical holes - parent_conn_hole_diam: float = 6.0 - # Position of the holes relative + parent_conn_hole_diam: float = 4.0 + # Position of the holes relative centre line parent_conn_hole_pos: list[Tuple[float, float]] = field(default_factory=lambda: [ - (15, 8), - (15, -8), + (20, 8), + (20, -8), ]) + # Distance from centre of lips to the axis + parent_lip_ext: float = 40.0 + parent_lip_length: float = 25.0 parent_lip_width: float = 30.0 parent_lip_thickness: float = 5.0 - parent_lip_ext: float = 40.0 - parent_lip_guard_height: float = 8.0 + + # The parent side has arms which connect to the lips + parent_arm_width: float = 25.0 + parent_arm_height: float = 12.0 # Generates a child guard which covers up the internals. The lip length is # relative to the +X surface of the guard. @@ -281,19 +286,19 @@ class ShoulderJoint(Model): # guard length measured from axle child_lip_length: float = 40.0 child_lip_width: float = 20.0 - child_conn_hole_diam: float = 6.0 + child_conn_hole_diam: float = 4.0 # Measured from centre of axle - child_conn_hole_pos: list[float] = field(default_factory=lambda: [15, 25]) + child_conn_hole_pos: list[float] = field(default_factory=lambda: [8, 19, 30]) child_core_thickness: float = 3.0 # Rotates the torsion joint to avoid collisions or for some other purpose - axis_rotate_bot: float = 225.0 - axis_rotate_top: float = -225.0 + axis_rotate_bot: float = 90 + axis_rotate_top: float = 0 directrix_id: int = 0 - angle_neutral: float = 10.0 - angle_max_deflection: float = 80.0 + angle_neutral: float = -15.0 + angle_max_deflection: float = 90.0 def __post_init__(self): assert self.parent_lip_length * 2 < self.height @@ -302,51 +307,105 @@ class ShoulderJoint(Model): def radius(self): return self.torsion_joint.radius - def parent_arm_loc(self) -> Cq.Location: + def parent_lip_loc(self, left: bool=True) -> Cq.Location: """ 2d location of the arm surface on the parent side, relative to axle """ - return Cq.Location.rot2d(self.angle_neutral) * Cq.Location.from2d(self.parent_lip_ext, 0, 0) + dy = self.parent_arm_width / 2 + sign = 1 if left else -1 + loc_dir = Cq.Location((0,sign * dy,0), (0, 0, 1), sign * 90) + return Cq.Location.from2d(self.parent_lip_ext, 0, 0) * loc_dir + + @property + def _max_contraction_angle(self) -> float: + return 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 + """ + 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)) + angle = math.degrees(theta_p) + assert 0 <= angle <= 90 + # outer radius of the cut, overestimated + cut_radius = math.sqrt(self.child_guard_width ** 2 + self.parent_arm_width ** 2) + span = 180 + result = ( + Cq.Solid.makeCylinder( + height=self.height, + radius=cut_radius, + angleDegrees=span, + ).cut(Cq.Solid.makeCylinder( + height=self.height, + radius=self.torsion_joint.radius, + )) + ) + if parent: + angle = - span - angle + else: + angle = self._max_contraction_angle - angle + result = result.located(Cq.Location((0,0,-self.height/2), (0,0,1), angle)) + if mirror: + result = result.mirror('XZ') + return result def parent(self, top: bool = False) -> Cq.Assembly: joint = self.torsion_joint # Thickness of the lip connecting this joint to the wing root - assert self.parent_lip_width <= joint.radius_track * 2 + assert self.parent_arm_width <= joint.radius_track * 2 assert self.parent_lip_ext > joint.radius_track - lip_guard = ( + arm = ( Cq.Solid.makeBox( - self.parent_lip_ext, - self.parent_lip_width, - self.parent_lip_guard_height) - .located(Cq.Location((0, -self.parent_lip_width/2 , 0))) - .cut(Cq.Solid.makeCylinder(joint.radius_track, self.parent_lip_guard_height)) + self.parent_lip_ext + self.parent_lip_width / 2, + self.parent_arm_width, + self.parent_arm_height) + .located(Cq.Location((0, -self.parent_arm_width/2 , 0))) + .cut(Cq.Solid.makeCylinder(joint.radius_track, self.parent_arm_height)) + .cut(self._contraction_cut_geometry(parent=True, mirror=top)) ) - lip = MountingBox( + lip_args = dict( length=self.parent_lip_length, width=self.parent_lip_width, thickness=self.parent_lip_thickness, + hole_diam=self.parent_conn_hole_diam, + generate_side_tags=False, + ) + lip1 = MountingBox( + **lip_args, + holes=[ + Hole(x=self.height / 2 - x, y=-y) + for x, y in self.parent_conn_hole_pos + ], + ) + lip2 = MountingBox( + **lip_args, holes=[ Hole(x=self.height / 2 - x, y=y) for x, y in self.parent_conn_hole_pos ], - hole_diam=self.parent_conn_hole_diam, - generate_side_tags=False, ) + lip_dy = self.parent_arm_width / 2 - self.parent_lip_thickness # Flip so the lip's holes point to -X loc_axis = Cq.Location((0,0,0), (0, 1, 0), -90) - # so they point to +X - loc_dir = Cq.Location((0,0,0), (0, 0, 1), 180) - loc_pos = Cq.Location((self.parent_lip_ext - self.parent_lip_thickness, 0, 0)) + loc_dir1 = Cq.Location((0,lip_dy,0), (0, 0, 1), -90) + loc_dir2 = Cq.Location((0,-lip_dy,0), (0, 0, 1), 90) + loc_pos = Cq.Location((self.parent_lip_ext, 0, 0)) rot = -self.axis_rotate_top if top else self.axis_rotate_bot + lip_p_tag, lip_n_tag = "lip_right", "lip_left" + if not top: + lip_p_tag, lip_n_tag = lip_n_tag, lip_p_tag result = ( Cq.Assembly() .add(joint.track(), name="track", loc=Cq.Location((0, 0, 0), (0, 0, 1), rot)) - .add(lip_guard, name="lip_guard") - .add(lip.generate(), name="lip", loc=loc_pos * loc_dir * loc_axis) + .add(arm, name="arm") + .add(lip1.generate(), name=lip_p_tag, loc=loc_pos * loc_dir1 * loc_axis) + .add(lip2.generate(), name=lip_n_tag, loc=loc_pos * loc_dir2 * loc_axis) ) return result @@ -421,6 +480,7 @@ class ShoulderJoint(Model): combine='cut', centered=(False, True, True), ) + .cut(self._contraction_cut_geometry(parent=False)) ) core = ( Cq.Workplane('XY') @@ -462,7 +522,11 @@ class ShoulderJoint(Model): return result @assembly() - def assembly(self, fastener_pos: float = 0.0, deflection: float = 0) -> Cq.Assembly: + def assembly( + self, + fastener_pos: float = 0.0, + deflection: float = 0.0, + ) -> Cq.Assembly: assert deflection <= self.angle_max_deflection directrix = self.directrix_id mat = Material.RESIN_TRANSPERENT diff --git a/nhf/touhou/houjuu_nue/wing.py b/nhf/touhou/houjuu_nue/wing.py index 69cadaa..a548af8 100644 --- a/nhf/touhou/houjuu_nue/wing.py +++ b/nhf/touhou/houjuu_nue/wing.py @@ -116,7 +116,7 @@ class WingProfile(Model): assert self.wrist_joint.total_thickness < min(self.s2_thickness, self.s3_thickness) self.shoulder_joint.angle_neutral = -self.shoulder_angle_neutral - self.shoulder_angle_bias - self.shoulder_axle_loc = Cq.Location.from2d(self.shoulder_tip_x, self.shoulder_tip_y - self.shoulder_width / 2, self.shoulder_angle_bias) + self.shoulder_axle_loc = Cq.Location.from2d(self.shoulder_tip_x, self.shoulder_tip_y - self.shoulder_width / 2, 0) self.shoulder_joint.child_guard_width = self.s1_thickness + self.panel_thickness * 2 assert self.spacer_thickness == self.root_joint.child_mount_thickness @@ -201,7 +201,7 @@ class WingProfile(Model): """ result = math.degrees(math.atan2(-self.shoulder_tip_bezier_y, self.shoulder_tip_bezier_x)) assert result >= 0 - return result + return result / 2 @target(name="profile-s0", kind=TargetKind.DXF) def profile_s0(self, top: bool = True) -> Cq.Sketch: @@ -266,17 +266,18 @@ class WingProfile(Model): return result @submodel(name="spacer-s0-shoulder") - def spacer_s0_shoulder(self) -> MountingBox: + def spacer_s0_shoulder(self, left: bool=True) -> MountingBox: """ Shoulder side serves double purpose for mounting shoulder joint and structural support """ + sign = 1 if left else -1 holes = [ hole for i, (x, y) in enumerate(self.shoulder_joint.parent_conn_hole_pos) for hole in [ - Hole(x=x, y=y, tag=f"conn_top{i}"), - Hole(x=-x, y=y, tag=f"conn_bot{i}"), + Hole(x=x, y=sign * y, tag=f"conn_top{i}"), + Hole(x=-x, y=sign * y, tag=f"conn_bot{i}"), ] ] return MountingBox( @@ -287,6 +288,7 @@ class WingProfile(Model): hole_diam=self.shoulder_joint.parent_conn_hole_diam, centred=(True, True), flip_y=self.flip, + centre_bot_top_tags=True, ) @submodel(name="spacer-s0-shoulder") def spacer_s0_base(self) -> MountingBox: @@ -329,19 +331,19 @@ class WingProfile(Model): def surface_s0(self, top: bool = False) -> Cq.Workplane: base_dx = -(self.base_width - self.root_joint.child_width) / 2 - 10 base_dy = self.root_joint.hirth_joint.joint_height - loc_tip = Cq.Location(0, -self.shoulder_joint.parent_lip_width / 2) #mid_spacer_loc = ( # Cq.Location.from2d(0, -self.shoulder_width/2) * # self.shoulder_axle_loc * # Cq.Location.rot2d(self.shoulder_joint.angle_neutral) #) + axle_rotate = Cq.Location.rot2d(-self.shoulder_angle_neutral) tags = [ - ("shoulder", - self.shoulder_axle_loc * - self.shoulder_joint.parent_arm_loc() * - loc_tip), + ("shoulder_left", + self.shoulder_axle_loc * axle_rotate * self.shoulder_joint.parent_lip_loc(left=True)), + ("shoulder_right", + self.shoulder_axle_loc * axle_rotate * self.shoulder_joint.parent_lip_loc(left=False)), ("base", Cq.Location.from2d(base_dx, base_dy, 90)), - ("electronic_mount", Cq.Location.from2d(-55, 75, 64)), + ("electronic_mount", Cq.Location.from2d(-45, 75, 64)), ] result = extrude_with_markers( self.profile_s0(top=top), @@ -377,7 +379,8 @@ class WingProfile(Model): #.constrain("top?corner_left", "inner_shell?top", "Point") ) for o, tag in [ - (self.spacer_s0_shoulder().generate(), "shoulder"), + (self.spacer_s0_shoulder(left=True).generate(), "shoulder_left"), + (self.spacer_s0_shoulder(left=False).generate(), "shoulder_right"), (self.spacer_s0_base().generate(), "base"), (self.spacer_s0_electronic_mount().generate(), "electronic_mount"), ]: @@ -902,13 +905,14 @@ class WingProfile(Model): fastener_pos=fastener_pos, deflection=angle), name="shoulder") if "s0" in parts and "shoulder" in parts: - ( - result - .constrain(f"s0/shoulder?conn_top0", f"shoulder/parent_{tag_top}/lip?conn0", "Plane") - .constrain(f"s0/shoulder?conn_top1", f"shoulder/parent_{tag_top}/lip?conn1", "Plane") - .constrain(f"s0/shoulder?conn_bot0", f"shoulder/parent_{tag_bot}/lip?conn0", "Plane") - .constrain(f"s0/shoulder?conn_bot1", f"shoulder/parent_{tag_bot}/lip?conn1", "Plane") - ) + for i in range(len(self.shoulder_joint.parent_conn_hole_pos)): + ( + result + .constrain(f"s0/shoulder_left?conn_top{i}", f"shoulder/parent_{tag_top}/lip_left?conn{i}", "Plane") + .constrain(f"s0/shoulder_left?conn_bot{i}", f"shoulder/parent_{tag_bot}/lip_left?conn{i}", "Plane") + .constrain(f"s0/shoulder_right?conn_top{i}", f"shoulder/parent_{tag_top}/lip_right?conn{i}", "Plane") + .constrain(f"s0/shoulder_right?conn_bot{i}", f"shoulder/parent_{tag_bot}/lip_right?conn{i}", "Plane") + ) if "s1" in parts: result.add(self.assembly_s1(), name="s1") if "s1" in parts and "shoulder" in parts: