""" This file describes the shapes of the wing shells. The joints are defined in `__init__.py`. """ import math from enum import Enum from dataclasses import dataclass, field from typing import Mapping, Tuple, Optional import cadquery as Cq from nhf import Material, Role from nhf.build import Model, TargetKind, target, assembly, submodel from nhf.parts.box import box_with_centre_holes, MountingBox, Hole from nhf.parts.joints import HirthJoint from nhf.parts.planar import extrude_with_markers from nhf.touhou.houjuu_nue.joints import RootJoint, ShoulderJoint, ElbowJoint, DiskJoint from nhf.touhou.houjuu_nue.electronics import ( LINEAR_ACTUATOR_10, LINEAR_ACTUATOR_21, LINEAR_ACTUATOR_50, ElectronicBoard ) import nhf.utils @dataclass(kw_only=True) class WingProfile(Model): name: str = "wing" base_width: float = 80.0 root_joint: RootJoint = field(default_factory=lambda: RootJoint()) panel_thickness: float = 25.4 / 16 # 1/4" acrylic for the spacer. Anything thinner would threathen structural # strength spacer_thickness: float = 25.4 / 4 shoulder_joint: ShoulderJoint = field(default_factory=lambda: ShoulderJoint( )) shoulder_angle_bias: float = 0.0 shoulder_width: float = 36.0 shoulder_tip_x: float = -260.0 shoulder_tip_y: float = 165.0 shoulder_tip_bezier_x: float = 100.0 shoulder_tip_bezier_y: float = -50.0 shoulder_base_bezier_x: float = -30.0 shoulder_base_bezier_y: float = 30.0 s0_hole_loc: Cq.Location = Cq.Location.from2d(-25, 33) s0_hole_diam: float = 15.0 s0_top_hole: bool = False s0_bot_hole: bool = True electronic_board: ElectronicBoard = field(default_factory=lambda: ElectronicBoard()) s1_thickness: float = 25.0 elbow_joint: ElbowJoint = field(default_factory=lambda: ElbowJoint( disk_joint=DiskJoint( movement_angle=55, ), hole_diam=4.0, angle_neutral=10.0, actuator=LINEAR_ACTUATOR_50, flexor_offset_angle=30, parent_arm_width=15, flip=False, )) # Distance between the two spacers on the elbow, halved elbow_h2: float = 5.0 wrist_joint: ElbowJoint = field(default_factory=lambda: ElbowJoint( disk_joint=DiskJoint( movement_angle=30, radius_disk=13.0, radius_housing=15.0, ), hole_pos=[10], lip_length=30, child_arm_radius=23.0, parent_arm_radius=30.0, hole_diam=4.0, angle_neutral=0.0, actuator=LINEAR_ACTUATOR_10, flexor_offset_angle=30.0, flip=True, )) # Distance between the two spacers on the elbow, halved wrist_h2: float = 5.0 mat_panel: Material = Material.ACRYLIC_TRANSLUSCENT mat_bracket: Material = Material.ACRYLIC_TRANSPARENT mat_hs_joint: Material = Material.PLASTIC_PLA role_panel: Role = Role.STRUCTURE # Subclass must populate elbow_bot_loc: Cq.Location elbow_height: float wrist_bot_loc: Cq.Location wrist_height: float elbow_rotate: float = 10.0 elbow_joint_overlap_median: float = 0.3 wrist_joint_overlap_median: float = 0.5 wrist_rotate: float = -30.0 # Position of the elbow axle with 0 being bottom and 1 being top (flipped on the left side) elbow_axle_pos: float = 0.5 wrist_axle_pos: float = 0.0 # False for the right side, True for the left side flip: bool def __post_init__(self): super().__init__(name=self.name) assert self.electronic_board.length == self.shoulder_height self.elbow_top_loc = self.elbow_bot_loc * Cq.Location.from2d(0, self.elbow_height) self.wrist_top_loc = self.wrist_bot_loc * Cq.Location.from2d(0, self.wrist_height) if self.flip: self.elbow_axle_pos = 1 - self.elbow_axle_pos self.elbow_axle_loc = self.elbow_bot_loc * Cq.Location.from2d(0, self.elbow_height * self.elbow_axle_pos) if self.flip: self.wrist_axle_pos = 1 - self.wrist_axle_pos self.wrist_axle_loc = self.wrist_bot_loc * Cq.Location.from2d(0, self.wrist_height * self.wrist_axle_pos) assert self.elbow_joint.total_thickness < min(self.s1_thickness, self.s2_thickness) 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, 0) self.shoulder_joint.child_guard_width = self.s1_thickness + self.panel_thickness * 2 assert self.spacer_thickness == self.root_joint.child_mount_thickness @property def s2_thickness(self) -> float: """ s2 needs to duck under s1, so its thinner """ return self.s1_thickness - 2 * self.panel_thickness @property def s3_thickness(self) -> float: """ s3 does not need to duck under s2 """ extra = 2 * self.panel_thickness if self.flip else 0 return self.s1_thickness - 2 * self.panel_thickness - extra @submodel(name="shoulder-joint") def submodel_shoulder_joint(self) -> Model: return self.shoulder_joint @submodel(name="elbow-joint") def submodel_elbow_joint(self) -> Model: return self.elbow_joint @submodel(name="wrist-joint") def submodel_wrist_joint(self) -> Model: return self.wrist_joint @property def root_height(self) -> float: return self.shoulder_joint.height @property def shoulder_height(self) -> float: return self.shoulder_joint.height def outer_profile_s0(self) -> Cq.Sketch: """ The outer boundary of s0 top/bottom slots """ tip_x = self.shoulder_tip_x tip_y = self.shoulder_tip_y return ( Cq.Sketch() .spline([ (0, 0), (-30.0, 80.0), (tip_x, tip_y) ]) #.segment( # (tip_x, tip_y), # (tip_x - 10, tip_y), #) ) def inner_profile_s0(self) -> Cq.Edge: """ The inner boundary of s0 """ tip_x = self.shoulder_tip_x tip_y = self.shoulder_tip_y dx2 = self.shoulder_tip_bezier_x dy2 = self.shoulder_tip_bezier_y dx1 = self.shoulder_base_bezier_x dy1 = self.shoulder_base_bezier_y sw = self.shoulder_width return Cq.Edge.makeBezier( [ Cq.Vector(*p) for p in [ (tip_x, tip_y - sw), (tip_x + dx2, tip_y - sw + dy2), (-self.base_width + dx1, dy1), (-self.base_width, 0), ] ] ) @property def shoulder_angle_neutral(self) -> float: """ Returns the neutral angle of the shoulder """ result = math.degrees(math.atan2(-self.shoulder_tip_bezier_y, self.shoulder_tip_bezier_x)) assert result >= 0 return result / 2 @target(name="profile-s0", kind=TargetKind.DXF) def profile_s0(self, top: bool = True) -> Cq.Sketch: tip_x = self.shoulder_tip_x tip_y = self.shoulder_tip_y dx2 = self.shoulder_tip_bezier_x dy2 = self.shoulder_tip_bezier_y dx1 = self.shoulder_base_bezier_x dy1 = self.shoulder_base_bezier_y sw = self.shoulder_width sketch = ( self.outer_profile_s0() .segment((-self.base_width, 0), (0, 0)) .segment( (tip_x, tip_y), (tip_x, tip_y - sw), ) .bezier([ (tip_x, tip_y - sw), (tip_x + dx2, tip_y - sw + dy2), (-self.base_width + dx1, dy1), (-self.base_width, 0), ]) .assemble() .push([self.shoulder_axle_loc.to2d_pos()]) .circle(self.shoulder_joint.radius, mode='a') .circle(self.shoulder_joint.bolt.diam_head / 2, mode='s') ) top = top == self.flip if (self.s0_top_hole and top) or (self.s0_bot_hole and not top): sketch = ( sketch .reset() .push([self.s0_hole_loc.to2d_pos()]) .circle(self.s0_hole_diam / 2, mode='s') ) return sketch def outer_shell_s0(self) -> Cq.Workplane: t = self.panel_thickness profile = Cq.Wire.assembleEdges(self.outer_profile_s0().edges().vals()) result = ( Cq.Workplane('XZ') .rect(t, self.root_height + t*2, centered=(False, False)) .sweep(profile) ) plane = result.copyWorkplane(Cq.Workplane('XZ')) plane.moveTo(0, 0).tagPlane("bot") plane.moveTo(0, self.root_height + t*2).tagPlane("top") return result def inner_shell_s0(self) -> Cq.Workplane: t = self.panel_thickness #profile = Cq.Wire.assembleEdges(self.inner_profile_s0()) result = ( Cq.Workplane('XZ') .rect(t, self.root_height + t*2, centered=(False, False)) .sweep(self.inner_profile_s0()) ) plane = result.copyWorkplane(Cq.Workplane('XZ')) plane.moveTo(t, 0).tagPlane("bot") plane.moveTo(t, self.root_height + t*2).tagPlane("top") return result @submodel(name="spacer-s0-shoulder") 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=sign * y, tag=f"conn_top{i}"), Hole(x=-x, y=sign * y, tag=f"conn_bot{i}"), ] ] return MountingBox( length=self.shoulder_joint.height, width=self.shoulder_joint.parent_lip_width, thickness=self.spacer_thickness, holes=holes, 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: """ Base side connects to H-S joint """ assert self.root_joint.child_width < self.base_width assert self.root_joint.child_corner_dx * 2 < self.base_width assert self.root_joint.child_corner_dz * 2 < self.root_height dy = self.root_joint.child_corner_dx dx = self.root_joint.child_corner_dz holes = [ Hole(x=-dx, y=-dy), Hole(x=dx, y=-dy), Hole(x=dx, y=dy), Hole(x=-dx, y=dy), ] return MountingBox( length=self.root_height, width=self.root_joint.child_width, thickness=self.spacer_thickness, holes=holes, hole_diam=self.root_joint.corner_hole_diam, centred=(True, True), flip_y=self.flip, ) @submodel(name="spacer-s0-electronic") def spacer_s0_electronic_mount(self) -> MountingBox: return MountingBox( holes=self.electronic_board.mount_holes, hole_diam=self.electronic_board.mount_hole_diam, length=self.root_height, width=self.electronic_board.width, centred=(True, True), thickness=self.spacer_thickness, flip_y=self.flip, generate_reverse_tags=True, ) @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)], hole_diam=self.shoulder_joint.actuator.back_hole_diam, length=self.root_height, width=10.0, centred=(True, True), thickness=self.spacer_thickness, flip_y=self.flip, generate_reverse_tags=True, ) 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.base_to_surface_thickness #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_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)), ("shoulder_act", self.shoulder_axle_loc * axle_rotate * Cq.Location.from2d(150, -40)), ("base", Cq.Location.from2d(base_dx, base_dy, 90)), ("electronic_mount", Cq.Location.from2d(-45, 75, 64)), ] result = extrude_with_markers( self.profile_s0(top=top), self.panel_thickness, tags, reverse=not top, ) h = self.panel_thickness if top else 0 result.copyWorkplane(Cq.Workplane('XZ')).moveTo(0, h).tagPlane("corner") result.copyWorkplane(Cq.Workplane('XZ')).moveTo(-self.base_width, h).tagPlane("corner_left") return result @assembly() def assembly_s0( self, ignore_electronics: bool=False) -> Cq.Assembly: result = ( Cq.Assembly() .addS(self.surface_s0(top=True), name="bot", material=self.mat_panel, role=self.role_panel) .addS(self.surface_s0(top=False), name="top", material=self.mat_panel, role=self.role_panel, loc=Cq.Location((0, 0, self.root_height + self.panel_thickness))) .constrain("bot", "Fixed") .constrain("top", "Fixed") .constrain("bot@faces@>Z", "top@faces@ Cq.Sketch: """ Generates profile from shoulder and above. Subclass should implement """ @target(name="profile-s2-bridge", kind=TargetKind.DXF) def profile_s2_bridge(self) -> Optional[Cq.Sketch]: return None @target(name="profile-s3-extra", kind=TargetKind.DXF) def profile_s3_extra(self) -> Optional[Cq.Sketch]: """ Extra element to be glued on s3. Not needed for left side """ return None def _wrist_joint_retract_cut_polygon(self, loc: Cq.Location) -> Optional[Cq.Sketch]: """ Creates a cutting polygon for removing the contraction part of a joint """ if not self.flip: """ No cutting needed on RHS """ return None theta = math.radians(self.wrist_joint.motion_span) dx = self.wrist_height * math.tan(theta) dy = self.wrist_height sign = -1 if self.flip else 1 points = [ (0, 0), (0, -sign * dy), (-dx, -sign * dy), ] return ( Cq.Sketch() .polygon([ (loc * Cq.Location.from2d(*p)).to2d_pos() for p in points ]) ) def _joint_extension_cut_polygon( self, loc_bot: Cq.Location, loc_top: Cq.Location, height: float, angle_span: float, axle_pos: float, bot: bool = True, child: bool = False, overestimate: float = 1.2, median: float = 0.5, ) -> Cq.Sketch: """ A cut polygon to accomodate for joint extensions """ loc_ext = loc_bot if bot else loc_top loc_tip = loc_top if bot else loc_bot theta = math.radians(angle_span * (median if child else 1 - median)) y_sign = -1 if bot else 1 sign = -1 if child else 1 dh = axle_pos * height * (overestimate - 1) loc_left = loc_ext * Cq.Location.from2d(0, y_sign * dh) loc_right = loc_left * Cq.Location.from2d(sign * height * overestimate * axle_pos * math.tan(theta), 0) return ( Cq.Sketch() .segment( loc_tip.to2d_pos(), loc_left.to2d_pos(), ) .segment( loc_left.to2d_pos(), loc_right.to2d_pos(), ) .segment( loc_right.to2d_pos(), loc_tip.to2d_pos(), ) .assemble() ) def _assembly_insert_spacer( self, a: Cq.Assembly, spacer: Cq.Workplane, point_tag: str, front_tag: str = "front", back_tag: str = "back", flipped: bool = False, rotate: bool = False, ): """ For a child joint facing up, front panel should be on the right, back panel on the left """ site_front, site_back = "right", "left" if flipped: site_front, site_back = site_back, site_front angle = 180 if rotate else 0 ( a .addS( spacer, name=point_tag, material=self.mat_bracket, role=self.role_panel) .constrain(f"{front_tag}?{point_tag}", f"{point_tag}?{site_front}", "Plane") .constrain(f"{back_tag}?{point_tag}", f"{point_tag}?{site_back}", "Plane") .constrain(f"{point_tag}?dir", f"{front_tag}?{point_tag}_dir", "Axis", param=angle) ) def _mask_elbow(self) -> list[Tuple[float, float]]: """ Polygon shape to mask out parts above the elbow """ def _mask_wrist(self) -> list[Tuple[float, float]]: """ Polygon shape to mask wrist """ def spacer_of_joint( self, joint: ElbowJoint, segment_thickness: float, dx: float) -> MountingBox: length = joint.lip_length / 2 - dx holes = [ Hole(x - dx) for x in joint.hole_pos ] mbox = MountingBox( length=length, width=segment_thickness, thickness=self.spacer_thickness, holes=holes, hole_diam=joint.hole_diam, centred=(False, True), ) return mbox def _spacer_from_disk_joint( self, joint: ElbowJoint, segment_thickness: float, ) -> MountingBox: holes = [ Hole(x, tag=tag) for x, tag in joint.hole_loc_tags() ] mbox = MountingBox( length=joint.lip_length, width=segment_thickness, thickness=self.spacer_thickness, holes=holes, hole_diam=joint.hole_diam, centred=(True, True), centre_left_right_tags=True, ) return mbox @target(name="profile-s1", kind=TargetKind.DXF) def profile_s1(self) -> Cq.Sketch: cut_poly = self._joint_extension_cut_polygon( loc_bot=self.elbow_bot_loc, loc_top=self.elbow_top_loc, height=self.elbow_height, angle_span=self.elbow_joint.motion_span, axle_pos=self.elbow_axle_pos, bot=not self.elbow_joint.flip, median=self.elbow_joint_overlap_median, child=False, ).reset().polygon(self._mask_elbow(), mode='a') profile = ( self.profile() .reset() .push([self.elbow_axle_loc.to2d_pos()]) .each(lambda _: cut_poly, mode='i') #.polygon(self._mask_elbow(), mode='i') ) return profile def surface_s1(self, front: bool = True) -> Cq.Workplane: rot_elbow = Cq.Location.rot2d(self.elbow_rotate) loc_elbow = rot_elbow * self.elbow_joint.parent_arm_loc() tags = [ ("shoulder", Cq.Location((0, self.shoulder_height / 2, 0)) * self.shoulder_joint.child_lip_loc()), ("elbow", self.elbow_axle_loc * loc_elbow), ("elbow_act", self.elbow_axle_loc * rot_elbow * self.elbow_joint.actuator_mount_loc()), ] profile = self.profile_s1() return extrude_with_markers( profile, self.panel_thickness, tags, reverse=front) @submodel(name="spacer-s1-shoulder") def spacer_s1_shoulder(self) -> MountingBox: sign = 1#-1 if self.flip else 1 holes = [ Hole(x=sign * x) for x in self.shoulder_joint.child_conn_hole_pos ] return MountingBox( length=self.shoulder_joint.child_lip_height, width=self.s1_thickness, thickness=self.spacer_thickness, holes=holes, centred=(True, True), hole_diam=self.shoulder_joint.child_conn_hole_diam, centre_left_right_tags=True, centre_bot_top_tags=True, ) @submodel(name="spacer-s1-elbow") def spacer_s1_elbow(self) -> MountingBox: return self._spacer_from_disk_joint( joint=self.elbow_joint, segment_thickness=self.s1_thickness, ) @submodel(name="spacer-s1-elbow-act") def spacer_s1_elbow_act(self) -> MountingBox: return MountingBox( length=self.s1_thickness, width=self.s1_thickness, thickness=self.spacer_thickness, holes=[Hole(x=0,y=0)], centred=(True, True), hole_diam=self.elbow_joint.hole_diam, centre_left_right_tags=True, ) @assembly() def assembly_s1(self) -> Cq.Assembly: result = ( Cq.Assembly() .addS(self.surface_s1(front=True), name="front", material=self.mat_panel, role=self.role_panel) .constrain("front", "Fixed") .addS(self.surface_s1(front=False), name="back", material=self.mat_panel, role=self.role_panel) .constrain("front@faces@>Z", "back@faces@ Cq.Sketch: # Calculates `(profile - (E - JE)) * (W + JW)` cut_elbow = ( Cq.Sketch() .polygon(self._mask_elbow()) .reset() .boolean(self._joint_extension_cut_polygon( loc_bot=self.elbow_bot_loc, loc_top=self.elbow_top_loc, height=self.elbow_height, angle_span=self.elbow_joint.motion_span, axle_pos=self.elbow_axle_pos, bot=not self.elbow_joint.flip, median=self.elbow_joint_overlap_median, child=True, ), mode='s') ) cut_wrist = ( Cq.Sketch() .polygon(self._mask_wrist()) ) if self.flip: poly = self._joint_extension_cut_polygon( loc_bot=self.wrist_bot_loc, loc_top=self.wrist_top_loc, height=self.wrist_height, angle_span=self.wrist_joint.motion_span, axle_pos=self.wrist_axle_pos, bot=not self.wrist_joint.flip, median=self.wrist_joint_overlap_median, child=False, ) cut_wrist = ( cut_wrist .reset() .boolean(poly, mode='a') ) profile = ( self.profile() .reset() .boolean(cut_elbow, mode='s') .boolean(cut_wrist, mode='i') ) return profile def surface_s2(self, front: bool = True) -> Cq.Workplane: loc_elbow = Cq.Location.rot2d(self.elbow_rotate) * self.elbow_joint.child_arm_loc() rot_wrist = Cq.Location.rot2d(self.wrist_rotate) loc_wrist = rot_wrist * self.wrist_joint.parent_arm_loc() tags = [ ("elbow", self.elbow_axle_loc * loc_elbow), ("wrist", self.wrist_axle_loc * loc_wrist), ("wrist_act", self.wrist_axle_loc * rot_wrist * self.wrist_joint.actuator_mount_loc()), # for mounting the bridge only ("wrist_bot", self.wrist_axle_loc * loc_wrist * Cq.Location.from2d(0, -self.wrist_h2)), ("wrist_top", self.wrist_axle_loc * loc_wrist * Cq.Location.from2d(0, self.wrist_h2)), ] profile = self.profile_s2() return extrude_with_markers(profile, self.panel_thickness, tags, reverse=front) def surface_s2_bridge(self, front: bool = True) -> Optional[Cq.Workplane]: profile = self.profile_s2_bridge() if profile is None: return None loc_wrist = Cq.Location.rot2d(self.wrist_rotate) * self.wrist_joint.parent_arm_loc() tags = [ ("wrist_bot", self.wrist_axle_loc * loc_wrist * Cq.Location.from2d(0, -self.wrist_h2)), ("wrist_top", self.wrist_axle_loc * loc_wrist * Cq.Location.from2d(0, self.wrist_h2)), ] return extrude_with_markers( profile, self.panel_thickness, tags, reverse=not front) @submodel(name="spacer-s2-elbow") def spacer_s2_elbow(self) -> MountingBox: return self._spacer_from_disk_joint( joint=self.elbow_joint, segment_thickness=self.s2_thickness, ) @submodel(name="spacer-s2-wrist") def spacer_s2_wrist(self) -> MountingBox: return self._spacer_from_disk_joint( joint=self.wrist_joint, segment_thickness=self.s2_thickness, ) @submodel(name="spacer-s2-wrist-act") def spacer_s2_wrist_act(self) -> MountingBox: return MountingBox( length=self.s2_thickness, width=self.s2_thickness, thickness=self.spacer_thickness, holes=[Hole(x=0,y=0)], centred=(True, True), hole_diam=self.wrist_joint.hole_diam, centre_left_right_tags=True, ) @assembly() def assembly_s2(self) -> Cq.Assembly: result = ( Cq.Assembly() .addS(self.surface_s2(front=True), name="front", material=self.mat_panel, role=self.role_panel) .constrain("front", "Fixed") .addS(self.surface_s2(front=False), name="back", material=self.mat_panel, role=self.role_panel) .constrain("front@faces@>Z", "back@faces@ Cq.Sketch: cut_wrist = ( Cq.Sketch() .polygon(self._mask_wrist()) ) if self.flip: poly = self._joint_extension_cut_polygon( loc_bot=self.wrist_bot_loc, loc_top=self.wrist_top_loc, height=self.wrist_height, angle_span=self.wrist_joint.motion_span, axle_pos=self.wrist_axle_pos, bot=not self.wrist_joint.flip, median=self.wrist_joint_overlap_median, child=True, ) cut_wrist = ( cut_wrist .boolean(poly, mode='s') ) profile = ( self.profile() .boolean(cut_wrist, mode='s') ) return profile def surface_s3(self, front: bool = True) -> Cq.Workplane: loc_wrist = Cq.Location.rot2d(self.wrist_rotate) * self.wrist_joint.child_arm_loc() tags = [ ("wrist", self.wrist_axle_loc * loc_wrist), ("wrist_bot", self.wrist_axle_loc * loc_wrist * Cq.Location.from2d(0, self.wrist_h2)), ("wrist_top", self.wrist_axle_loc * loc_wrist * Cq.Location.from2d(0, -self.wrist_h2)), ] profile = self.profile_s3() return extrude_with_markers(profile, self.panel_thickness, tags, reverse=front) def surface_s3_extra(self, front: bool = True) -> Optional[Cq.Workplane]: profile = self.profile_s3_extra() if profile is None: return None loc_wrist = Cq.Location.rot2d(self.wrist_rotate) * self.wrist_joint.child_arm_loc() tags = [ ("wrist_bot", self.wrist_axle_loc * loc_wrist * Cq.Location.from2d(0, self.wrist_h2)), ("wrist_top", self.wrist_axle_loc * loc_wrist * Cq.Location.from2d(0, -self.wrist_h2)), ] return extrude_with_markers(profile, self.panel_thickness, tags, reverse=not front) @submodel(name="spacer-s3-wrist") def spacer_s3_wrist(self) -> MountingBox: return self._spacer_from_disk_joint( joint=self.wrist_joint, segment_thickness=self.s3_thickness, ) @assembly() def assembly_s3(self) -> Cq.Assembly: result = ( Cq.Assembly() .addS(self.surface_s3(front=True), name="front", material=self.mat_panel, role=self.role_panel) .constrain("front", "Fixed") .addS(self.surface_s3(front=False), name="back", material=self.mat_panel, role=self.role_panel) .constrain("front@faces@>Z", "back@faces@ Cq.Assembly(): if parts is None: parts = [ "root", "s0", "shoulder", "s1", "elbow", "s2", "wrist", "s3", ] result = ( Cq.Assembly() ) tag_top, tag_bot = "top", "bot" if self.flip: tag_top, tag_bot = tag_bot, tag_top if "s0" in parts: result.add(self.assembly_s0( ignore_electronics=ignore_electronics ), name="s0") if "root" in parts: result.addS(self.root_joint.assembly( offset=root_offset, fastener_pos=fastener_pos, ignore_fasteners=ignore_fasteners, ), name="root") result.constrain("root/parent", "Fixed") if "s0" in parts and "root" in parts: ( result .constrain("s0/base?conn0", "root/child?conn0", "Point") .constrain("s0/base?conn1", "root/child?conn1", "Point") .constrain("s0/base?conn2", "root/child?conn2", "Point") #.constrain("s0/base?conn3", "root/child?conn3", "Point") ) if "shoulder" in parts: angle = shoulder_deflection * self.shoulder_joint.angle_max_deflection result.add(self.shoulder_joint.assembly( fastener_pos=fastener_pos, deflection=angle, ignore_fasteners=ignore_fasteners), name="shoulder") if "s0" in parts and "shoulder" in parts: 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: for i in range(len(self.shoulder_joint.child_conn_hole_pos)): result.constrain(f"s1/shoulder?conn{i}", f"shoulder/child/lip?conn{i}", "Plane") if "elbow" in parts: angle = self.elbow_joint.motion_span * elbow_wrist_deflection result.add(self.elbow_joint.assembly( angle=angle, ignore_actuators=ignore_actuators), name="elbow") if "s1" in parts and "elbow" in parts: for _, tag in self.elbow_joint.hole_loc_tags(): result.constrain( f"s1/elbow?{tag}", f"elbow/parent_upper/lip?{tag}", "Plane") if not ignore_actuators: result.constrain( "elbow/bracket_back?conn_side", "s1/elbow_act?conn0", "Plane") if "s2" in parts: result.add(self.assembly_s2(), name="s2") if "s2" in parts and "elbow" in parts: for _, tag in self.elbow_joint.hole_loc_tags(): result.constrain( f"s2/elbow?{tag}", f"elbow/child/lip?{tag}", "Plane") if "wrist" in parts: angle = self.wrist_joint.motion_span * elbow_wrist_deflection result.add(self.wrist_joint.assembly( angle=angle, ignore_actuators=ignore_actuators), name="wrist") if "s2" in parts and "wrist" in parts: for _, tag in self.wrist_joint.hole_loc_tags(): result.constrain( f"s2/wrist?{tag}", f"wrist/parent_upper/lip?{tag}", "Plane") if "s3" in parts: result.add(self.assembly_s3(), name="s3") if "s3" in parts and "wrist" in parts: for _, tag in self.wrist_joint.hole_loc_tags(): result.constrain( f"s3/wrist?{tag}", f"wrist/child/lip?{tag}", "Plane") if not ignore_actuators: result.constrain( "wrist/bracket_back?conn_side", "s2/wrist_act?conn0", "Plane") if len(parts) > 1: result.solve() return result @dataclass(kw_only=True) class WingR(WingProfile): """ Right side wings """ elbow_bot_loc: Cq.Location = Cq.Location.from2d(290.0, 30.0, 27.0) elbow_height: float = 111.0 wrist_bot_loc: Cq.Location = Cq.Location.from2d(403.0, 289.0, 45.0) wrist_height: float = 60.0 # Extends from the wrist to the tip of the arrow arrow_height: float = 300 arrow_angle: float = -8 # Underapproximate the wrist tangent angle to leave no gaps on the blade blade_wrist_approx_tangent_angle: float = 40.0 # Some overlap needed to glue the two sides blade_overlap_angle: float = -1 blade_hole_angle: float = 3 blade_hole_diam: float = 12.0 blade_hole_heights: list[float] = field(default_factory=lambda: [230, 260]) blade_angle: float = 7 # Relative (in wrist coordinate) centre of the ring ring_rel_loc: Cq.Location = Cq.Location.from2d(45.0, 25.0) ring_radius_inner: float = 22.0 flip: bool = False def __post_init__(self): super().__post_init__() assert self.arrow_angle < 0, "Arrow angle cannot be positive" self.arrow_bot_loc = self.wrist_bot_loc \ * Cq.Location.from2d(0, -self.arrow_height) self.arrow_other_loc = self.arrow_bot_loc \ * Cq.Location.rot2d(self.arrow_angle) \ * Cq.Location.from2d(0, self.arrow_height + self.wrist_height) self.ring_loc = self.wrist_top_loc * self.ring_rel_loc assert self.ring_radius > self.ring_radius_inner assert 0 > self.blade_overlap_angle > self.arrow_angle assert 0 < self.blade_hole_angle < self.blade_angle assert self.blade_wrist_approx_tangent_angle <= self.wrist_bot_loc.to2d_rot() @property def ring_radius(self) -> float: (dx, dy), _ = self.ring_rel_loc.to2d() return (dx * dx + dy * dy) ** 0.5 def profile(self) -> Cq.Sketch: """ Net profile of the wing starting from the wing root with no divisions """ result = ( Cq.Sketch() .segment( (0, 0), (0, self.shoulder_joint.height), tag="shoulder") .spline([ (0, self.shoulder_joint.height), self.elbow_top_loc.to2d_pos(), self.wrist_top_loc.to2d_pos(), ], tag="s1_top") #.segment( # (self.wrist_x, self.wrist_y), # (wrist_top_x, wrist_top_y), # tag="wrist") .spline([ (0, 0), self.elbow_bot_loc.to2d_pos(), self.wrist_bot_loc.to2d_pos(), ], tag="s1_bot") ) result = ( result .segment( self.wrist_bot_loc.to2d_pos(), self.arrow_bot_loc.to2d_pos(), ) .segment( self.arrow_bot_loc.to2d_pos(), self.arrow_other_loc.to2d_pos(), ) .segment( self.arrow_other_loc.to2d_pos(), self.wrist_top_loc.to2d_pos(), ) ) # Carve out the ring result = result.assemble() result = ( result .push([self.ring_loc.to2d_pos()]) .circle(self.ring_radius, mode='a') .circle(self.ring_radius_inner, mode='s') .clean() ) return result def _child_joint_extension_profile( self, axle_loc: Cq.Location, radius: float, angle_span: float, bot: bool = False) -> Cq.Sketch: """ Creates a sector profile which accomodates extension """ # leave some margin for gluing margin = 5 sign = -1 if bot else 1 axle_loc = axle_loc * Cq.Location.rot2d(-90 if bot else 90) loc_h = Cq.Location.from2d(radius, 0) loc_offset = axle_loc * Cq.Location.from2d(0, margin) start = axle_loc * loc_h mid = axle_loc * Cq.Location.rot2d(-sign * angle_span/2) * loc_h end = axle_loc * Cq.Location.rot2d(-sign * angle_span) * loc_h return ( Cq.Sketch() .segment( loc_offset.to2d_pos(), start.to2d_pos(), ) .arc( start.to2d_pos(), mid.to2d_pos(), end.to2d_pos(), ) .segment( end.to2d_pos(), axle_loc.to2d_pos(), ) .segment( axle_loc.to2d_pos(), loc_offset.to2d_pos(), ) .assemble() ) @target(name="profile-s2-bridge", kind=TargetKind.DXF) def profile_s2_bridge(self) -> Cq.Sketch: """ This extension profile is required to accomodate the awkward shaped joint next to the scythe """ # Generates the extension profile, which is required on both sides profile = self._child_joint_extension_profile( axle_loc=self.wrist_axle_loc, radius=self.wrist_height, angle_span=self.wrist_joint.motion_span, bot=self.flip, ) # Generates the contraction (cut) profile. only required on the left if self.flip: extra = ( self.profile() .reset() .push([self.wrist_axle_loc]) .each(self._wrist_joint_retract_cut_polygon, mode='i') ) profile = ( profile .push([self.wrist_axle_loc]) .each(lambda _: extra, mode='a') ) return profile def profile_s3_extra(self) -> Cq.Sketch: """ Implements the blade part on Nue's wing """ left_bot_loc = self.arrow_bot_loc * Cq.Location.rot2d(-1) hole_bot_loc = self.arrow_bot_loc * Cq.Location.rot2d(self.blade_hole_angle) right_bot_loc = self.arrow_bot_loc * Cq.Location.rot2d(self.blade_angle) h_loc = Cq.Location.from2d(0, self.arrow_height) # Law of sines, uses the triangle of (wrist_bot_loc, arrow_bot_loc, ?) theta_wp = math.radians(90 - self.blade_wrist_approx_tangent_angle) theta_b = math.radians(self.blade_angle) h_blade = math.sin(theta_wp) / math.sin(math.pi - theta_b - theta_wp) * self.arrow_height h_blade_loc = Cq.Location.from2d(0, h_blade) return ( Cq.Sketch() .segment( self.arrow_bot_loc.to2d_pos(), (left_bot_loc * h_loc).to2d_pos(), ) .segment( (self.arrow_bot_loc * h_loc).to2d_pos(), ) .segment( (right_bot_loc * h_blade_loc).to2d_pos(), ) .close() .assemble() .reset() .push([ (hole_bot_loc * Cq.Location.from2d(0, h)).to2d_pos() for h in self.blade_hole_heights ]) .circle(self.blade_hole_diam / 2, mode='s') ) def _mask_elbow(self) -> list[Tuple[float, float]]: l = 200 elbow_x, _ = self.elbow_bot_loc.to2d_pos() elbow_top_x, _ = self.elbow_top_loc.to2d_pos() return [ (0, -l), (elbow_x, -l), self.elbow_bot_loc.to2d_pos(), self.elbow_top_loc.to2d_pos(), (elbow_top_x, l), (0, l) ] def _mask_wrist(self) -> list[Tuple[float, float]]: l = 200 wrist_x, _ = self.wrist_bot_loc.to2d_pos() _, wrist_top_y = self.wrist_top_loc.to2d_pos() return [ (0, -l), (wrist_x, -l), self.wrist_bot_loc.to2d_pos(), self.wrist_top_loc.to2d_pos(), #(self.wrist_top_x, self.wrist_top_y), (0, wrist_top_y), ] @dataclass(kw_only=True) class WingL(WingProfile): elbow_bot_loc: Cq.Location = Cq.Location.from2d(260.0, 110.0, 0.0) elbow_height: float = 80.0 wrist_angle: float = -45.0 wrist_bot_loc: Cq.Location = Cq.Location.from2d(460.0, -10.0, -45.0) wrist_height: float = 43.0 shoulder_bezier_ext: float = 120.0 shoulder_bezier_drop: float = 15.0 elbow_bezier_ext: float = 80.0 wrist_bezier_ext: float = 30.0 arrow_length: float = 135.0 arrow_height: float = 120.0 flip: bool = True elbow_axle_pos: float = 0.4 wrist_axle_pos: float = 0.5 elbow_joint_overlap_median: float = 0.5 wrist_joint_overlap_median: float = 0.5 def __post_init__(self): assert self.wrist_height <= self.shoulder_joint.height self.wrist_bot_loc = self.wrist_bot_loc.with_angle_2d(self.wrist_angle) self.elbow_joint.angle_neutral = 15.0 self.elbow_joint.flip = True self.elbow_rotate = 5.0 self.wrist_joint.angle_neutral = self.wrist_bot_loc.to2d_rot() + 30.0 self.wrist_rotate = -self.wrist_joint.angle_neutral self.wrist_joint.flip = False self.shoulder_joint.flip = True super().__post_init__() def arrow_to_abs(self, x, y) -> Tuple[float, float]: rel = Cq.Location.from2d(x * self.arrow_length, y * self.arrow_height / 2 + self.wrist_height / 2) return (self.wrist_bot_loc * rel).to2d_pos() def profile(self) -> Cq.Sketch: result = ( Cq.Sketch() .segment( (0,0), (0, self.shoulder_height) ) .bezier([ (0, 0), (self.shoulder_bezier_ext, -self.shoulder_bezier_drop), (self.elbow_bot_loc * Cq.Location.from2d(-self.elbow_bezier_ext, 0)).to2d_pos(), self.elbow_bot_loc.to2d_pos(), ]) .bezier([ (0, self.shoulder_joint.height), (self.shoulder_bezier_ext, self.shoulder_joint.height), (self.elbow_top_loc * Cq.Location.from2d(-self.elbow_bezier_ext, 0)).to2d_pos(), self.elbow_top_loc.to2d_pos(), ]) .bezier([ self.elbow_bot_loc.to2d_pos(), (self.elbow_bot_loc * Cq.Location.from2d(self.elbow_bezier_ext, 0)).to2d_pos(), (self.wrist_bot_loc * Cq.Location.from2d(-self.wrist_bezier_ext, 0)).to2d_pos(), self.wrist_bot_loc.to2d_pos(), ]) .bezier([ self.elbow_top_loc.to2d_pos(), (self.elbow_top_loc * Cq.Location.from2d(self.elbow_bezier_ext, 0)).to2d_pos(), (self.wrist_top_loc * Cq.Location.from2d(-self.wrist_bezier_ext, 0)).to2d_pos(), self.wrist_top_loc.to2d_pos(), ]) ) # arrow base positions base_u, base_v = 0.3, 0.3 result = ( result .bezier([ self.wrist_top_loc.to2d_pos(), (self.wrist_top_loc * Cq.Location.from2d(self.wrist_bezier_ext, 0)).to2d_pos(), self.arrow_to_abs(base_u, base_v), ]) .bezier([ self.wrist_bot_loc.to2d_pos(), (self.wrist_bot_loc * Cq.Location.from2d(self.wrist_bezier_ext, 0)).to2d_pos(), self.arrow_to_abs(base_u, -base_v), ]) ) # Create the arrow arrow_beziers = [ [ (0, 1), (0.3, 1), (0.8, .2), (1, 0), ], [ (0, 1), (0.1, 0.8), (base_u, base_v), ] ] arrow_beziers = [ l2 for l in arrow_beziers for l2 in [l, [(x, -y) for x,y in l]] ] for line in arrow_beziers: result = result.bezier([self.arrow_to_abs(x, y) for x,y in line]) return result.assemble() def _mask_elbow(self) -> list[Tuple[float, float]]: l = 200 elbow_bot_x, _ = self.elbow_bot_loc.to2d_pos() elbow_top_x, _ = self.elbow_top_loc.to2d_pos() return [ (0, -l), (elbow_bot_x, -l), self.elbow_bot_loc.to2d_pos(), self.elbow_top_loc.to2d_pos(), (elbow_top_x, l), (0, l) ] def _mask_wrist(self) -> list[Tuple[float, float]]: l = 200 elbow_bot_x, _ = self.elbow_bot_loc.to2d_pos() elbow_top_x, elbow_top_y = self.elbow_top_loc.to2d_pos() _, wrist_bot_y = self.wrist_bot_loc.to2d_pos() wrist_top_x, wrist_top_y = self.wrist_top_loc.to2d_pos() return [ (0, -l), (elbow_bot_x, wrist_bot_y), self.wrist_bot_loc.to2d_pos(), self.wrist_top_loc.to2d_pos(), (wrist_top_x, wrist_top_y + l), (elbow_top_x, elbow_top_y + l), (0, l), ]