diff --git a/nhf/primitive.py b/nhf/primitive.py deleted file mode 100644 index 35e1565..0000000 --- a/nhf/primitive.py +++ /dev/null @@ -1,12 +0,0 @@ -import cadquery as Cq - -def mystery(): - return ( - Cq.Workplane("XY") - .box(10, 5, 5) - .faces(">Z") - .workplane() - .hole(1) - .edges("|Z") - .fillet(2) - ) diff --git a/nhf/touhou/__init__.py b/nhf/touhou/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nhf/touhou/houjuu_nue/README.org b/nhf/touhou/houjuu_nue/README.org new file mode 100644 index 0000000..1649107 --- /dev/null +++ b/nhf/touhou/houjuu_nue/README.org @@ -0,0 +1,14 @@ +#+title: Cosplay: Houjuu Nue + +* Controller + +This part describes the electrical connections and the microcontroller code. + +* Structure + +This part describes the 3d printed and laser cut structures. ~structure.blend~ +is an overall sketch of the shapes and looks of the wing. + +* Pattern + +This part describes the sewing patterns. diff --git a/nhf/touhou/houjuu_nue/__init__.py b/nhf/touhou/houjuu_nue/__init__.py new file mode 100644 index 0000000..dc759b3 --- /dev/null +++ b/nhf/touhou/houjuu_nue/__init__.py @@ -0,0 +1,204 @@ +""" +To build, execute +``` +python3 nhf/touhou/houjuu_nue/__init__.py +``` + +This cosplay consists of 3 components: + +## Trident + +The trident is composed of individual segments, made of acrylic, and a 3D +printed head (convention rule prohibits metal) with a metallic paint. To ease +transportation, the trident handle has individual segments with threads and can +be assembled on site. + +## Snake + +A 3D printed snake with a soft material so it can wrap around and bend + +## Wings + +This is the crux of the cosplay and the most complex component. The wings mount +on a wearable harness. Each wing consists of 4 segments with 3 joints. Parts of +the wing which demands transluscency are created from 1/16" acrylic panels. +These panels serve double duty as the exoskeleton. + +The wings are labeled r1,r2,r3,l1,l2,l3. The segments of the wings are labeled +from root to tip s0 (root), +s1, s2, s3. The joints are named (from root to tip) +shoulder, elbow, wrist in analogy with human anatomy. +""" +from dataclasses import dataclass, field +from typing import Optional +import cadquery as Cq +from nhf.build import Model, TargetKind, target, assembly, submodel +import nhf.touhou.houjuu_nue.wing as MW +import nhf.touhou.houjuu_nue.trident as MT +import nhf.touhou.houjuu_nue.joints as MJ +import nhf.touhou.houjuu_nue.harness as MH +import nhf.touhou.houjuu_nue.electronics as ME +from nhf.parts.item import Item +import nhf.utils + +WING_DEFLECT_ODD = 0.0 +WING_DEFLECT_EVEN = 25.0 +@dataclass +class Parameters(Model): + """ + Defines dimensions for the Houjuu Nue cosplay + """ + + harness: MH.Harness = field(default_factory=lambda: MH.Harness()) + + wing_r1: MW.WingR = field(default_factory=lambda: MW.WingR( + name="r1", + root_joint=MJ.RootJoint( + parent_substrate_cull_corners=(0,1,1,1), + parent_substrate_cull_edges=(0,0,1,0), + ), + shoulder_angle_bias=WING_DEFLECT_ODD, + s0_top_hole=False, + s0_bot_hole=True, + arrow_height=350.0 + )) + wing_r2: MW.WingR = field(default_factory=lambda: MW.WingR( + name="r2", + root_joint=MJ.RootJoint( + parent_substrate_cull_corners=(1,1,1,1), + parent_substrate_cull_edges=(0,0,1,0), + ), + electronic_board=ME.ElectronicBoardControl(), + shoulder_angle_bias=WING_DEFLECT_EVEN, + s0_top_hole=True, + s0_bot_hole=True, + )) + wing_r3: MW.WingR = field(default_factory=lambda: MW.WingR( + name="r3", + root_joint=MJ.RootJoint( + parent_substrate_cull_corners=(1,1,1,0), + parent_substrate_cull_edges=(0,0,1,0), + ), + shoulder_angle_bias=WING_DEFLECT_ODD, + s0_top_hole=True, + s0_bot_hole=False, + )) + wing_l1: MW.WingL = field(default_factory=lambda: MW.WingL( + name="l1", + root_joint=MJ.RootJoint( + parent_substrate_cull_corners=(1,0,1,1), + parent_substrate_cull_edges=(1,0,0,0), + ), + shoulder_angle_bias=WING_DEFLECT_EVEN, + wrist_angle=-60.0, + s0_top_hole=False, + s0_bot_hole=True, + )) + wing_l2: MW.WingL = field(default_factory=lambda: MW.WingL( + name="l2", + root_joint=MJ.RootJoint( + parent_substrate_cull_corners=(1,1,1,1), + parent_substrate_cull_edges=(1,0,0,0), + ), + wrist_angle=-30.0, + shoulder_angle_bias=WING_DEFLECT_ODD, + s0_top_hole=True, + s0_bot_hole=True, + )) + wing_l3: MW.WingL = field(default_factory=lambda: MW.WingL( + name="l3", + root_joint=MJ.RootJoint( + parent_substrate_cull_corners=(1,1,0,1), + parent_substrate_cull_edges=(1,0,0,0), + ), + shoulder_angle_bias=WING_DEFLECT_EVEN, + wrist_angle=-0.0, + s0_top_hole=True, + s0_bot_hole=False, + )) + + trident: MT.Trident = field(default_factory=lambda: MT.Trident()) + + def __post_init__(self): + super().__init__(name="houjuu-nue") + + @submodel(name="harness") + def submodel_harness(self) -> Model: + return self.harness + + @submodel(name="wing-r1") + def submodel_wing_r1(self) -> Model: + return self.wing_r1 + @submodel(name="wing-r2") + def submodel_wing_r2(self) -> Model: + return self.wing_r2 + @submodel(name="wing-r3") + def submodel_wing_r3(self) -> Model: + return self.wing_r3 + @submodel(name="wing-l1") + def submodel_wing_l1(self) -> Model: + return self.wing_l1 + @submodel(name="wing-l2") + def submodel_wing_l2(self) -> Model: + return self.wing_l2 + @submodel(name="wing-l3") + def submodel_wing_l3(self) -> Model: + return self.wing_l3 + + @assembly() + def wings_harness_assembly(self, + parts: Optional[list[str]] = None, + **kwargs) -> Cq.Assembly: + """ + Assembly of harness with all the wings + """ + result = ( + Cq.Assembly() + .add(self.harness.assembly(), name="harness", loc=Cq.Location((0, 0, 0))) + .add(self.wing_r1.assembly(parts, root_offset=9, **kwargs), name="wing_r1") + .add(self.wing_r2.assembly(parts, root_offset=7, **kwargs), name="wing_r2") + .add(self.wing_r3.assembly(parts, root_offset=6, **kwargs), name="wing_r3") + .add(self.wing_l1.assembly(parts, root_offset=19, **kwargs), name="wing_l1") + .add(self.wing_l2.assembly(parts, root_offset=20, **kwargs), name="wing_l2") + .add(self.wing_l3.assembly(parts, root_offset=21, **kwargs), name="wing_l3") + ) + for tag in ["r1", "r2", "r3", "l1", "l2", "l3"]: + self.harness.add_root_joint_constraint( + result, + "harness/base", + f"wing_{tag}/root", + tag + ) + return result.solve() + + @submodel(name="trident") + def submodel_trident(self) -> Model: + return self.trident + + def stat(self) -> dict[str, float]: + a = self.wings_harness_assembly() + bbox = a.toCompound().BoundingBox() + return { + "wing-span": bbox.xlen, + "wing-depth": bbox.ylen, + "wing-height": bbox.zlen, + "wing-mass": a.total_mass(), + "wing-centre-of-mass": a.centre_of_mass().toTuple(), + "items": Item.count(a), + } + + +if __name__ == '__main__': + import sys + + p = Parameters() + if len(sys.argv) == 1: + p.build_all() + sys.exit(0) + + if sys.argv[1] == 'stat': + print(p.stat()) + elif sys.argv[1] == 'model': + file_name = sys.argv[2] + a = p.wings_harness_assembly() + a.save(file_name, exportType='STEP') diff --git a/nhf/touhou/houjuu_nue/common.py b/nhf/touhou/houjuu_nue/common.py new file mode 100644 index 0000000..5d3d0a0 --- /dev/null +++ b/nhf/touhou/houjuu_nue/common.py @@ -0,0 +1,18 @@ +from nhf.parts.fasteners import FlatHeadBolt, HexNut, ThreaddedKnob + +NUT_COMMON = HexNut( + # FIXME: measure + mass=0.0, + diam_thread=4.0, + pitch=0.7, + thickness=3.2, + width=7.0, +) +BOLT_COMMON = FlatHeadBolt( + # FIXME: measure + mass=0.0, + diam_head=8.0, + height_head=2.0, + diam_thread=4.0, + height_thread=20.0, +) diff --git a/nhf/touhou/houjuu_nue/controller/controller.ino b/nhf/touhou/houjuu_nue/controller/controller.ino new file mode 100644 index 0000000..245d39d --- /dev/null +++ b/nhf/touhou/houjuu_nue/controller/controller.ino @@ -0,0 +1,68 @@ +#include + +// Main LED strip setup +#define LED_PIN 5 +#define NUM_LEDS 100 +#define LED_PART 50 +#define BRIGHTNESS 250 +#define LED_TYPE WS2811 +CRGB leds[NUM_LEDS]; + +CRGB color_red; +CRGB color_blue; +CRGB color_green; + +#define DIAG_PIN 6 + + +void setup() { + // Calculate colors + hsv2rgb_spectrum(CHSV(4, 255, 100), color_red); + hsv2rgb_spectrum(CHSV(170, 255, 100), color_blue); + hsv2rgb_spectrum(CHSV(90, 255, 100), color_green); + pinMode(LED_BUILTIN, OUTPUT); + pinMode(LED_PIN, OUTPUT); + pinMode(DIAG_PIN, OUTPUT); + + // Main LED strip + FastLED.addLeds(leds, NUM_LEDS); +} + +void loop() { + fill_segmented(CRGB::Green, CRGB::Orange); + delay(500); + + flash(leds, NUM_LEDS, color_red, 10, 20); + delay(500); + flash(leds, NUM_LEDS, color_blue, 10, 20); + delay(500); +} + +void fill_segmented(CRGB c1, CRGB c2) +{ + //fill_solid(leds, LED_PART, c1); + fill_gradient_RGB(leds, LED_PART, CRGB::Black ,c1); + fill_gradient_RGB(leds + LED_PART, NUM_LEDS - LED_PART, CRGB::Black, c2); + FastLED.show(); +} +void flash(CRGB *ptr, uint16_t num, CRGB const& lead, int steps, int step_time) +{ + digitalWrite(LED_BUILTIN, LOW); + + //fill_solid(leds, NUM_LEDS, CRGB::Black); + for (int i = 0; i < steps; ++i) + { + uint8_t factor = 255 * i / steps; + analogWrite(DIAG_PIN, factor); + CRGB tail = blend(lead, CRGB::Black, factor); + uint16_t front = factor * (int) num / 255; + fill_solid(ptr, front, tail); + //fill_gradient_RGB(ptr, front, tail, lead); + //fill_solid(leds + front, NUM_LEDS - front, CRGB::Black); + FastLED.show(); + delay(step_time); + } + fill_gradient_RGB(ptr, num, CRGB::Black, lead); + FastLED.show(); + analogWrite(DIAG_PIN, LOW); +} \ No newline at end of file diff --git a/nhf/touhou/houjuu_nue/electronics.py b/nhf/touhou/houjuu_nue/electronics.py new file mode 100644 index 0000000..1b42c54 --- /dev/null +++ b/nhf/touhou/houjuu_nue/electronics.py @@ -0,0 +1,540 @@ +""" +Electronic components +""" +from dataclasses import dataclass, field +from typing import Optional, Tuple +import math +import cadquery as Cq +from nhf.build import Model, TargetKind, target, assembly, submodel +from nhf.materials import Role, Material +from nhf.parts.box import MountingBox, Hole +from nhf.parts.fibre import tension_fibre +from nhf.parts.item import Item +from nhf.parts.fasteners import FlatHeadBolt, HexNut +from nhf.parts.electronics import ArduinoUnoR3, BatteryBox18650 +from nhf.touhou.houjuu_nue.common import NUT_COMMON, BOLT_COMMON +import nhf.utils + +@dataclass(frozen=True) +class LinearActuator(Item): + stroke_length: float + shaft_diam: float = 9.04 + + front_hole_ext: float = 4.41 + front_hole_diam: float = 4.41 + front_length: float = 9.55 + front_width: float = 9.24 + front_height: float = 5.98 + + segment1_length: float = 37.54 + segment1_width: float = 15.95 + segment1_height: float = 11.94 + + segment2_length: float = 37.37 + segment2_width: float = 20.03 + segment2_height: float = 15.03 + + back_hole_ext: float = 4.58 + back_hole_diam: float = 4.18 + back_length: float = 9.27 + back_width: float = 10.16 + back_height: float = 8.12 + + @property + def name(self) -> str: + return f"LinearActuator {self.stroke_length}mm" + + @property + def role(self) -> Role: + return Role.MOTION + + @property + def conn_length(self): + return self.segment1_length + self.segment2_length + self.front_hole_ext + self.back_hole_ext + + def generate(self, pos: float=0) -> Cq.Assembly: + assert -1e-6 <= pos <= 1 + 1e-6, f"Illegal position: {pos}" + stroke_x = pos * self.stroke_length + front = ( + Cq.Workplane('XZ') + .cylinder( + radius=self.front_width / 2, + height=self.front_height, + centered=True, + ) + .box( + length=self.front_hole_ext, + width=self.front_width, + height=self.front_height, + combine=True, + centered=(False, True, True) + ) + .copyWorkplane(Cq.Workplane('XZ')) + .cylinder( + radius=self.front_hole_diam / 2, + height=self.front_height, + centered=True, + combine='cut', + ) + ) + front.copyWorkplane(Cq.Workplane('XZ')).tagPlane('conn') + if stroke_x > 0: + shaft = ( + Cq.Workplane('YZ') + .cylinder( + radius=self.shaft_diam / 2, + height=stroke_x, + centered=(True, True, False) + ) + ) + else: + shaft = None + segment1 = ( + Cq.Workplane() + .box( + length=self.segment1_length, + height=self.segment1_width, + width=self.segment1_height, + centered=(False, True, True), + ) + ) + segment2 = ( + Cq.Workplane() + .box( + length=self.segment2_length, + height=self.segment2_width, + width=self.segment2_height, + centered=(False, True, True), + ) + ) + back = ( + Cq.Workplane('XZ') + .cylinder( + radius=self.back_width / 2, + height=self.back_height, + centered=True, + ) + .box( + length=self.back_hole_ext, + width=self.back_width, + height=self.back_height, + combine=True, + centered=(False, True, True) + ) + .copyWorkplane(Cq.Workplane('XZ')) + .cylinder( + radius=self.back_hole_diam / 2, + height=self.back_height, + centered=True, + combine='cut', + ) + ) + back.faces(">X").tag("dir") + back.copyWorkplane(Cq.Workplane('XZ')).tagPlane('conn') + result = ( + Cq.Assembly() + .add(front, name="front", + loc=Cq.Location((-self.front_hole_ext, 0, 0))) + .add(segment1, name="segment1", + loc=Cq.Location((stroke_x, 0, 0))) + .add(segment2, name="segment2", + loc=Cq.Location((stroke_x + self.segment1_length, 0, 0))) + .add(back, name="back", + loc=Cq.Location((stroke_x + self.segment1_length + self.segment2_length + self.back_hole_ext, 0, 0), (0, 1, 0), 180)) + ) + if shaft: + result.add(shaft, name="shaft") + return result + +@dataclass(frozen=True) +class MountingBracket(Item): + """ + Mounting bracket for a linear actuator + """ + mass: float = 1.6 + hole_diam: float = 4.0 + width: float = 8.0 + height: float = 12.20 + thickness: float = 0.98 + length: float = 13.00 + hole_to_side_ext: float = 8.25 + + def __post_init__(self): + assert self.hole_to_side_ext - self.hole_diam / 2 > 0 + + @property + def name(self) -> str: + return f"MountingBracket M{int(self.hole_diam)}" + + @property + def role(self) -> Role: + return Role.MOTION + + def generate(self) -> Cq.Workplane: + result = ( + Cq.Workplane('XY') + .box( + length=self.hole_to_side_ext, + width=self.width, + height=self.height, + centered=(False, True, True) + ) + .copyWorkplane(Cq.Workplane('XY')) + .cylinder( + height=self.height, + radius=self.width / 2, + combine=True, + ) + .copyWorkplane(Cq.Workplane('XY')) + .box( + length=2 * (self.hole_to_side_ext - self.thickness), + width=self.width, + height=self.height - self.thickness * 2, + combine='cut', + ) + .copyWorkplane(Cq.Workplane('XY')) + .cylinder( + height=self.height, + radius=self.hole_diam / 2, + combine='cut' + ) + .copyWorkplane(Cq.Workplane('YZ')) + .cylinder( + height=self.hole_to_side_ext * 2, + radius=self.hole_diam / 2, + combine='cut' + ) + ) + result.copyWorkplane(Cq.Workplane('YZ', origin=(self.hole_to_side_ext, 0, 0))).tagPlane("conn_side") + result.copyWorkplane(Cq.Workplane('XY', origin=(0, 0, self.height/2))).tagPlane("conn_top") + result.copyWorkplane(Cq.Workplane('YX', origin=(0, 0, -self.height/2))).tagPlane("conn_bot") + result.copyWorkplane(Cq.Workplane('XY')).tagPlane("conn_mid") + return result + + +LINEAR_ACTUATOR_50 = LinearActuator( + mass=40.8, + stroke_length=50, + shaft_diam=9.05, + front_hole_ext=4.32, + back_hole_ext=4.54, + segment1_length=57.35, + segment1_width=15.97, + segment1_height=11.95, + segment2_length=37.69, + segment2_width=19.97, + segment2_height=14.96, + + front_length=9.40, + front_width=9.17, + front_height=6.12, + back_length=9.18, + back_width=10.07, + back_height=8.06, +) +LINEAR_ACTUATOR_30 = LinearActuator( + mass=34.0, + stroke_length=30, +) +LINEAR_ACTUATOR_21 = LinearActuator( + # FIXME: Measure + mass=0.0, + stroke_length=21, + front_hole_ext=4, + back_hole_ext=4, + segment1_length=34, + segment2_length=34, +) +LINEAR_ACTUATOR_10 = LinearActuator( + mass=41.3, + stroke_length=10, + front_hole_ext=4.02, + back_hole_ext=4.67, + segment1_length=13.29, + segment1_width=15.88, + segment1_height=12.07, + segment2_length=42.52, + segment2_width=20.98, + segment2_height=14.84, +) +LINEAR_ACTUATOR_HEX_NUT = HexNut( + mass=0.8, + diam_thread=4, + pitch=0.7, + thickness=4.16, + width=6.79, +) +LINEAR_ACTUATOR_BOLT = FlatHeadBolt( + mass=1.7, + diam_head=6.68, + height_head=2.98, + diam_thread=4.0, + height_thread=15.83, +) +LINEAR_ACTUATOR_BRACKET = MountingBracket() + +BATTERY_BOX = BatteryBox18650() + +# Acrylic hex nut +ELECTRONIC_MOUNT_HEXNUT = HexNut( + mass=0.8, + diam_thread=4, + pitch=0.7, + thickness=3.57, + width=6.81, +) + +@dataclass(kw_only=True, frozen=True) +class Winch: + linear_motion_span: float + + actuator: LinearActuator = LINEAR_ACTUATOR_21 + nut: HexNut = LINEAR_ACTUATOR_HEX_NUT + bolt: FlatHeadBolt = LINEAR_ACTUATOR_BOLT + bracket: MountingBracket = LINEAR_ACTUATOR_BRACKET + +@dataclass(kw_only=True) +class Flexor: + """ + Actuator assembly which flexes, similar to biceps + """ + motion_span: float + arm_radius: Optional[float] = None + pos_smaller: bool = True + + actuator: LinearActuator = LINEAR_ACTUATOR_50 + nut: HexNut = LINEAR_ACTUATOR_HEX_NUT + bolt: FlatHeadBolt = LINEAR_ACTUATOR_BOLT + bracket: MountingBracket = LINEAR_ACTUATOR_BRACKET + # Length of line attached to the flexor + line_length: float = 0.0 + line_thickness: float = 0.5 + # By how much is the line permitted to slack. This reduces the effective stroke length + line_slack: float = 0.0 + + def __post_init__(self): + assert self.line_slack <= self.line_length, f"Insufficient length: {self.line_slack} >= {self.line_length}" + assert self.line_slack < self.actuator.stroke_length + + @property + def mount_height(self): + return self.bracket.hole_to_side_ext + + @property + def d_open(self): + return self.actuator.conn_length + self.actuator.stroke_length + self.line_length - self.line_slack + @property + def d_closed(self): + return self.actuator.conn_length + self.line_length + + def open_pos(self) -> Tuple[float, float, float]: + r, phi, r_ = nhf.geometry.contraction_span_pos_from_radius( + d_open=self.d_open, + d_closed=self.d_closed, + theta=math.radians(self.motion_span), + r=self.arm_radius, + smaller=self.pos_smaller, + ) + return r, math.degrees(phi), r_ + + def target_length_at_angle( + self, + angle: float = 0.0 + ) -> float: + """ + Length of the actuator at some angle + """ + assert 0 <= angle <= self.motion_span + r, phi, rp = self.open_pos() + th = math.radians(phi - angle) + + result = math.sqrt(r * r + rp * rp - 2 * r * rp * math.cos(th)) + #result = math.sqrt((r * math.cos(th) - rp) ** 2 + (r * math.sin(th)) ** 2) + assert self.d_closed -1e-6 <= result <= self.d_open + 1e-6,\ + f"Illegal length: {result} not in [{self.d_closed}, {self.d_open}]" + return result + + + def add_to( + self, + a: Cq.Assembly, + target_length: float, + tag_prefix: Optional[str] = None, + tag_hole_front: Optional[str] = None, + tag_hole_back: Optional[str] = None, + tag_dir: Optional[str] = None): + """ + Adds the necessary mechanical components to this assembly. Does not + invoke `a.solve()`. + """ + draft = max(0, target_length - self.d_closed - self.line_length) + pos = draft / self.actuator.stroke_length + line_l = target_length - draft - self.actuator.conn_length + if tag_prefix: + tag_prefix = tag_prefix + "_" + else: + tag_prefix = "" + name_actuator = f"{tag_prefix}actuator" + name_bracket_front = f"{tag_prefix}bracket_front" + name_bracket_back = f"{tag_prefix}bracket_back" + name_bolt_front = f"{tag_prefix}front_bolt" + name_bolt_back = f"{tag_prefix}back_bolt" + name_nut_front = f"{tag_prefix}front_nut" + name_nut_back = f"{tag_prefix}back_nut" + ( + a + .add(self.actuator.assembly(pos=pos), name=name_actuator) + .add(self.bracket.assembly(), name=name_bracket_front) + .add(self.bolt.assembly(), name=name_bolt_front) + .add(self.nut.assembly(), name=name_nut_front) + .constrain(f"{name_bolt_front}?root", f"{name_bracket_front}?conn_top", + "Plane", param=0) + .constrain(f"{name_nut_front}?bot", f"{name_bracket_front}?conn_bot", + "Plane") + .add(self.bracket.assembly(), name=name_bracket_back) + .add(self.bolt.assembly(), name=name_bolt_back) + .add(self.nut.assembly(), name=name_nut_back) + .constrain(f"{name_actuator}/back?conn", f"{name_bracket_back}?conn_mid", + "Plane", param=0) + .constrain(f"{name_bolt_back}?root", f"{name_bracket_back}?conn_top", + "Plane", param=0) + .constrain(f"{name_nut_back}?bot", f"{name_bracket_back}?conn_bot", + "Plane") + ) + if self.line_length == 0.0: + a.constrain( + f"{name_actuator}/front?conn", + f"{name_bracket_front}?conn_mid", + "Plane", param=0) + else: + ( + a + .addS(tension_fibre( + length=line_l, + hole_diam=self.nut.diam_thread, + thickness=self.line_thickness, + ), name="fibre", role=Role.CONNECTION) + .constrain( + f"{name_actuator}/front?conn", + "fibre?male", + "Plane" + ) + .constrain( + f"{name_bracket_front}?conn_mid", + "fibre?female", + "Plane" + ) + ) + if tag_hole_front: + a.constrain(tag_hole_front, f"{name_bracket_front}?conn_side", "Plane") + if tag_hole_back: + a.constrain(tag_hole_back, f"{name_bracket_back}?conn_side", "Plane") + if tag_dir: + a.constrain(tag_dir, f"{name_bracket_front}?conn_mid", "Axis", param=0) + + +@dataclass +class ElectronicBoard(Model): + + name: str = "electronic-board" + nut: HexNut = NUT_COMMON + bolt: FlatHeadBolt = BOLT_COMMON + length: float = 70.0 + width: float = 170.0 + mount_holes: list[Hole] = field(default_factory=lambda: [ + Hole(x=25, y=75), + Hole(x=25, y=-75), + Hole(x=-25, y=75), + Hole(x=-25, y=-75), + ]) + panel_thickness: float = 25.4 / 16 + mount_panel_thickness: float = 25.4 / 4 + material: Material = Material.WOOD_BIRCH + + @property + def mount_hole_diam(self) -> float: + return self.bolt.diam_thread + + def __post_init__(self): + super().__init__(name=self.name) + + def panel(self) -> MountingBox: + return MountingBox( + holes=self.mount_holes, + hole_diam=self.mount_hole_diam, + length=self.length, + width=self.width, + centred=(True, True), + thickness=self.panel_thickness, + generate_reverse_tags=True, + ) + + def assembly(self) -> Cq.Assembly: + panel = self.panel() + result = ( + Cq.Assembly() + .addS(panel.generate(), name="panel", + role=Role.ELECTRONIC | Role.STRUCTURE, material=self.material) + ) + for hole in self.mount_holes: + bolt_name = f"{hole.tag}_bolt" + ( + result + .add(self.bolt.assembly(), name=bolt_name) + .constrain( + f"{bolt_name}?root", + f"panel?{hole.tag}", + "Plane", param=0 + ) + ) + return result.solve() + +@dataclass +class ElectronicBoardBattery(ElectronicBoard): + name: str = "electronic-board-battery" + battery_box: BatteryBox18650 = BATTERY_BOX + + @submodel(name="panel") + def panel_out(self) -> MountingBox: + return self.panel() + +@dataclass +class ElectronicBoardControl(ElectronicBoard): + name: str = "electronic-board-control" + + controller_datum: Cq.Location = Cq.Location.from2d(-25, 23, -90) + + controller: ArduinoUnoR3 = ArduinoUnoR3() + + def panel(self) -> MountingBox: + box = super().panel() + def transform(i, x, y): + pos = self.controller_datum * Cq.Location.from2d(x, self.controller.width - y) + x, y = pos.to2d_pos() + return Hole( + x=x, y=y, + diam=self.controller.hole_diam, + tag=f"controller_conn{i}", + ) + box.holes = box.holes.copy() + [ + transform(i, x, y) + for i, (x, y) in enumerate(self.controller.holes) + ] + return box + + @submodel(name="panel") + def panel_out(self) -> MountingBox: + return self.panel() + + def assembly(self) -> Cq.Assembly: + result = super().assembly() + result.add(self.controller.assembly(), name="controller") + for i in range(len(self.controller.holes)): + result.constrain(f"controller?conn{i}", f"panel?controller_conn{i}", "Plane") + return result.solve() + +@dataclass(frozen=True) +class LightStrip: + + width: float = 10.0 + height: float = 4.5 diff --git a/nhf/touhou/houjuu_nue/harness.py b/nhf/touhou/houjuu_nue/harness.py new file mode 100644 index 0000000..516715a --- /dev/null +++ b/nhf/touhou/houjuu_nue/harness.py @@ -0,0 +1,204 @@ +from dataclasses import dataclass, field +import cadquery as Cq +from nhf.parts.joints import HirthJoint +from nhf import Material, Role +from nhf.build import Model, TargetKind, target, assembly, submodel +from nhf.touhou.houjuu_nue.joints import RootJoint +from nhf.parts.box import MountingBox +import nhf.utils + +@dataclass(frozen=True, kw_only=True) +class Mannequin: + """ + A mannequin for calibration + """ + + shoulder_width: float = 400 + shoulder_to_waist: float = 440 + waist_width: float = 250 + head_height: float = 220.0 + neck_height: float = 105.0 + neck_diam: float = 140 + head_diam: float = 210 + torso_thickness: float = 150 + + def generate(self) -> Cq.Workplane: + head_neck = ( + Cq.Workplane("XY") + .cylinder( + radius=self.neck_diam/2, + height=self.neck_height, + centered=(True, True, False)) + .faces(">Z") + .workplane() + .cylinder( + radius=self.head_diam/2, + height=self.head_height, + combine=True, centered=(True, True, False)) + ) + result = ( + Cq.Workplane("XY") + .rect(self.waist_width, self.torso_thickness) + .workplane(offset=self.shoulder_to_waist) + .rect(self.shoulder_width, self.torso_thickness) + .loft(combine=True) + .union(head_neck.translate((0, 0, self.shoulder_to_waist))) + ) + return result.translate((0, self.torso_thickness / 2, 0)) + + +BASE_POS_X = 70.0 +BASE_POS_Y = 100.0 + +@dataclass(kw_only=True) +class Harness(Model): + thickness: float = 25.4 / 8 + width: float = 200.0 + height: float = 304.8 + fillet: float = 10.0 + + wing_base_pos: list[tuple[str, float, float]] = field(default_factory=lambda: [ + ("r1", BASE_POS_X, BASE_POS_Y), + ("l1", -BASE_POS_X, BASE_POS_Y), + ("r2", BASE_POS_X, 0), + ("l2", -BASE_POS_X, 0), + ("r3", BASE_POS_X, -BASE_POS_Y), + ("l3", -BASE_POS_X, -BASE_POS_Y), + ]) + + root_joint: RootJoint = field(default_factory=lambda: RootJoint()) + + mannequin: Mannequin = Mannequin() + + def __post_init__(self): + super().__init__(name="harness") + + @submodel(name="bridge-pair-horizontal") + def bridge_pair_horizontal(self) -> MountingBox: + return self.root_joint.bridge_pair_horizontal(centre_dx=BASE_POS_X * 2) + @submodel(name="bridge-pair-vertical") + def bridge_pair_vertical(self) -> MountingBox: + return self.root_joint.bridge_pair_vertical(centre_dy=BASE_POS_Y) + + @target(name="profile", kind=TargetKind.DXF) + def profile(self) -> Cq.Sketch: + """ + Creates the harness shape + """ + w, h = self.width / 2, self.height / 2 + sketch = ( + Cq.Sketch() + .polygon([ + (w, h), + (w, -h), + (-w, -h), + (-w, h), + #(0.7 * w, h), + #(w, 0), + #(0.7 * w, -h), + #(0.7 * -w, -h), + #(-w, 0), + #(0.7 * -w, h), + ]) + #.rect(self.harness_width, self.harness_height) + .vertices() + .fillet(self.fillet) + ) + for tag, x, y in self.wing_base_pos: + conn = [(px + x, py + y) for px, py in self.root_joint.corner_pos()] + sketch = ( + sketch + .push(conn) + .tag(tag) + .circle(self.root_joint.corner_hole_diam / 2, mode='s') + .reset() + ) + return sketch + + def surface(self) -> Cq.Workplane: + """ + Creates the harness shape + """ + result = ( + Cq.Workplane('XZ') + .placeSketch(self.profile()) + .extrude(self.thickness) + ) + result.faces(">Y").tag("mount") + plane = result.faces(">Y").workplane() + for tag, x, y in self.wing_base_pos: + conn = [(px + x, py + y) for px, py + in self.root_joint.corner_pos()] + for i, (px, py) in enumerate(conn): + plane.moveTo(px, py).tagPlane(f"{tag}_{i}") + return result + + def add_root_joint_constraint( + self, + a: Cq.Assembly, + harness_tag: str, + joint_tag: str, + mount_tag: str): + for i in range(4): + a.constrain(f"{harness_tag}?{mount_tag}_{i}", f"{joint_tag}/parent?h{i}", "Point") + + + @assembly() + def assembly(self, with_root_joint: bool = False) -> Cq.Assembly: + harness = self.surface() + mannequin_z = self.mannequin.shoulder_to_waist * 0.6 + + result = ( + Cq.Assembly() + .addS( + harness, name="base", + material=Material.WOOD_BIRCH, + role=Role.STRUCTURE) + .constrain("base", "Fixed") + .addS( + self.mannequin.generate(), + name="mannequin", + role=Role.FIXTURE, + loc=Cq.Location((0, -self.thickness, -mannequin_z), (0, 0, 1), 180)) + .constrain("mannequin", "Fixed") + ) + bridge_h = self.bridge_pair_horizontal().generate() + for i in [1,2,3]: + name = f"r{i}l{i}_bridge" + ( + result + .addS( + bridge_h, name=name, + role=Role.FIXTURE, + material=Material.WOOD_BIRCH, + ) + .constrain(f"{name}?conn0_rev", f"base?r{i}_1", "Point") + .constrain(f"{name}?conn1_rev", f"base?l{i}_0", "Point") + .constrain(f"{name}?conn2_rev", f"base?l{i}_3", "Point") + .constrain(f"{name}?conn3_rev", f"base?r{i}_2", "Point") + ) + bridge_v = self.bridge_pair_vertical().generate() + ( + result + .addS(bridge_v, name="r1_bridge", role=Role.FIXTURE, material=Material.WOOD_BIRCH) + .constrain("r1_bridge?conn0_rev", "base?r1_3", 'Plane') + .constrain("r1_bridge?conn1_rev", "base?r2_0", 'Plane') + .addS(bridge_v, name="r2_bridge", role=Role.FIXTURE, material=Material.WOOD_BIRCH) + .constrain("r2_bridge?conn0_rev", "base?r2_3", 'Plane') + .constrain("r2_bridge?conn1_rev", "base?r3_0", 'Plane') + .addS(bridge_v, name="l1_bridge", role=Role.FIXTURE, material=Material.WOOD_BIRCH) + .constrain("l1_bridge?conn0_rev", "base?l1_2", 'Plane') + .constrain("l1_bridge?conn1_rev", "base?l2_1", 'Plane') + .addS(bridge_v, name="l2_bridge", role=Role.FIXTURE, material=Material.WOOD_BIRCH) + .constrain("l2_bridge?conn0_rev", "base?l2_2", 'Plane') + .constrain("l2_bridge?conn1_rev", "base?l3_1", 'Plane') + ) + if with_root_joint: + for name in ["l1", "l2", "l3", "r1", "r2", "r3"]: + result.addS( + self.root_joint.assembly(), name=name, + role=Role.PARENT, + material=Material.PLASTIC_PLA) + self.add_root_joint_constraint(result, "base", name, name) + result.solve() + return result diff --git a/nhf/touhou/houjuu_nue/joints.py b/nhf/touhou/houjuu_nue/joints.py new file mode 100644 index 0000000..c3df763 --- /dev/null +++ b/nhf/touhou/houjuu_nue/joints.py @@ -0,0 +1,1555 @@ +import math +from dataclasses import dataclass, field +from typing import Optional, Tuple +import cadquery as Cq +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, 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 ( + Winch, Flexor, LinearActuator, LINEAR_ACTUATOR_21, +) +import nhf.geometry +import nhf.utils + +TOL = 1e-6 + +# Parts used + +# uxcell 2 Pcs Star Knobs Grips M12 x 30mm Male Thread Steel Zinc Stud Replacement PP +HS_JOINT_KNOB = ThreaddedKnob( + mass=77.3, + diam_thread=12.0, + height_thread=30.0, + diam_knob=50.0, + diam_neck=25.0, + height_neck=12.5, + height_knob=15.0, +) +# Tom's world 8Pcs M12-1.75 Hex Nut Assortment Set Stainless Steel 304(18-8) +# Metric Hexagon Nut for Bolts, Bright Finish, Full Thread (M12) +HS_JOINT_HEX_NUT = HexNut( + mass=14.9, + diam_thread=12.0, + pitch=1.75, + thickness=9.7, + width=18.9, +) +SHOULDER_AXIS_BOLT = FlatHeadBolt( + # FIXME: measure + mass=0.0, + diam_head=10.0, + height_head=3.0, + diam_thread=6.0, + height_thread=20.0, +) +# Hoypeyfiy 10 Pieces Torsion Spring Woodworking DIY 90 Degrees Torsional +# Springs Repair Maintenance Spring +SHOULDER_TORSION_SPRING = TorsionSpring( + mass=2.2, + # inner diameter = 9 + radius=9/2 + 1.2, + thickness=1.3, + height=7.5, +) +# KALIONE 10 Pieces Torsion Spring, Stainless Steel Small Torsion Springs, Tiny +# Torsional Spring, 90° Deflection Compression Spring Kit for Repair Tools +# Woodworking DIY, 50mm +ELBOW_TORSION_SPRING = TorsionSpring( + mass=1.7, + radius=9 / 2, + thickness=1.3, + height=6.5, + tail_length=45.0, + right_handed=False, +) + +ELBOW_AXLE_BOLT = FlatHeadBolt( + mass=0.0, + diam_head=6.87, + height_head=3.06, + diam_thread=4.0, + height_thread=15.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): + """ + The Houjuu-Scarlett Mechanism + """ + knob: ThreaddedKnob = HS_JOINT_KNOB + hex_nut: HexNut = HS_JOINT_HEX_NUT + hirth_joint: HirthJoint = field(default_factory=lambda: HirthJoint( + radius=25.0, + radius_inner=15.0, + tooth_height=5.60, + base_height=4.0, + n_tooth=24, + )) + parent_width: float = 60 + parent_thickness: float = 10 + parent_corner_fillet: float = 5 + parent_corner_cbore_diam: float = 12.6 + parent_corner_cbore_depth: float = 2 + parent_corner_inset: float = 7.5 + parent_mount_thickness: float = 25.4 / 16 + + parent_substrate_thickness: float = 25.4 / 16 + parent_substrate_cull_corners: Tuple[bool, bool, bool, bool] = (False, False, True, False) + parent_substrate_cull_edges: Tuple[bool, bool, bool, bool] = (False, False, True, False) + + child_corner_dx: float = 17.0 + child_corner_dz: float = 24.0 + + axis_diam: float = 12.0 + axis_cbore_diam: float = 20 + axis_cbore_depth: float = 3 + corner_hole_diam: float = 6.0 + + child_height: float = 60.0 + child_width: float = 50.0 + child_mount_thickness: float = 25.4 / 4 + + def __post_init__(self): + assert self.child_extra_thickness > 0.0 + assert self.parent_thickness >= self.hex_nut.thickness + # twice the buffer allocated for substratum + assert self.parent_width >= 4 * self.parent_corner_inset + + def corner_pos(self) -> list[tuple[int, int]]: + """ + Generates a set of points corresponding to the connectorss + """ + dx = self.parent_width / 2 - self.parent_corner_inset + return [ + (dx, dx), + (-dx, dx), + (-dx, -dx), + (dx, -dx), + ] + + @property + def child_extra_thickness(self) -> float: + """ + Extra thickness allocated to child for padding + """ + return self.knob.height_thread - self.hirth_joint.joint_height - self.child_mount_thickness - self.parent_thickness + + @property + def base_to_surface_thickness(self) -> float: + return self.hirth_joint.joint_height + self.child_extra_thickness + + @property + def substrate_inset(self) -> float: + return self.parent_corner_inset * 2 + + def bridge_pair_horizontal(self, centre_dx: float) -> MountingBox: + hole_dx = centre_dx / 2 - self.parent_width / 2 + self.parent_corner_inset + hole_dy = self.parent_width / 2 - self.parent_corner_inset + holes = [ + Hole(x=hole_dx, y=hole_dy), + Hole(x=-hole_dx, y=hole_dy), + Hole(x=-hole_dx, y=-hole_dy), + Hole(x=hole_dx, y=-hole_dy), + ] + return MountingBox( + length=centre_dx - self.parent_width + self.substrate_inset * 2, + width=self.parent_width, + thickness=self.parent_substrate_thickness, + hole_diam=self.corner_hole_diam, + holes=holes, + centred=(True, True), + generate_reverse_tags=True, + ) + def bridge_pair_vertical(self, centre_dy: float) -> MountingBox: + hole_dy = centre_dy / 2 - self.parent_width / 2 + self.parent_corner_inset + holes = [ + Hole(x=0, y=hole_dy), + Hole(x=0, y=-hole_dy), + ] + return MountingBox( + length=self.substrate_inset, + width=centre_dy - self.parent_width + self.substrate_inset * 2, + thickness=self.parent_substrate_thickness, + hole_diam=self.corner_hole_diam, + holes=holes, + centred=(True, True), + generate_reverse_tags=True, + ) + + + @target(name="parent") + def parent(self): + """ + Parent part of the Houjuu-Scarlett joint, which is composed of a Hirth + coupling, a cylindrical base, and a mounting base. + """ + hirth = self.hirth_joint.generate() + conn = self.corner_pos() + result = ( + Cq.Workplane('XY') + .box( + length=self.parent_width, + width=self.parent_width, + height=self.parent_thickness, + centered=(True, True, False)) + .edges("|Z") + .fillet(self.parent_corner_fillet) + .faces(">Z") + .workplane() + .pushPoints(conn) + .cboreHole( + diameter=self.corner_hole_diam, + cboreDiameter=self.parent_corner_cbore_diam, + cboreDepth=self.parent_corner_cbore_depth) + ) + sso = self.parent_width / 2 - self.substrate_inset + loc_corner = Cq.Location((sso, sso, 0)) + loc_edge= Cq.Location((sso, -sso, 0)) + cut_corner = Cq.Solid.makeBox( + length=self.parent_width, + width=self.parent_width, + height=self.parent_substrate_thickness, + ) + cut_edge = Cq.Solid.makeBox( + length=self.parent_width, + width=self.parent_width - self.substrate_inset * 2, + height=self.parent_substrate_thickness, + ) + step = 90 + for i, flag in enumerate(self.parent_substrate_cull_corners): + if not flag: + continue + loc = Cq.Location((0,0,0),(0,0,1), i * step) * loc_corner + result = result.cut(cut_corner.located(loc)) + for i, flag in enumerate(self.parent_substrate_cull_edges): + if not flag: + continue + loc = Cq.Location((0,0,0),(0,0,1), i * step) * loc_edge + result = result.cut(cut_edge.located(loc)) + + result = result.translate((0, 0, -self.parent_thickness)) + # Creates a plane parallel to the holes but shifted to the base + plane = result.faces(">Z").workplane(offset=-self.parent_thickness) + + for i, (px, py) in enumerate(conn): + plane.moveTo(px, py).tagPoint(f"h{i}") + result = ( + result + .faces(">Z") + .workplane() + .union(hirth, tol=0.1) + .clean() + ) + result = ( + result.faces(" Cq.Workplane: + hirth = self.hirth_joint.generate(is_mated=True) + dy = self.child_corner_dx + dx = self.child_corner_dz + conn = [ + (-dx, -dy), + (dx, -dy), + (dx, dy), + (-dx, dy), + ] + result = ( + Cq.Workplane('XY') + .box( + length=self.child_height, + width=self.child_width, + height=self.child_extra_thickness + self.hirth_joint.base_height, + centered=(True, True, False)) + .translate((0, 0, -self.child_extra_thickness)) + #.edges("|Z") + #.fillet(self.hs_joint_corner_fillet) + .faces(">Z") + .workplane() + .pushPoints(conn) + .hole(self.corner_hole_diam) + ) + # Creates a plane parallel to the holes but shifted to the base + plane = result.copyWorkplane(Cq.Workplane('XY', origin=(0, 0, -self.child_extra_thickness))) + + for i, (px, py) in enumerate(conn): + plane.moveTo(px, py).tagPlane(f"conn{i}", direction="-Z") + result = ( + result + .faces(">Z") + .workplane() + .union(hirth, tol=0.1) + .clean() + ) + result = ( + result.faces(" Cq.Assembly: + """ + Specify knob position to determine the position of the knob from fully + inserted (0) or fully uninserted (1) + """ + knob_h = self.hex_nut.thickness + result = ( + Cq.Assembly() + .addS(self.parent(), name="parent", + material=Material.PLASTIC_PLA, + role=Role.PARENT) + .constrain("parent", "Fixed") + .addS(self.child(), name="child", + material=Material.PLASTIC_PLA, + role=Role.CHILD) + ) + if not ignore_fasteners: + ( + result + .addS(self.hex_nut.assembly(), name="hex_nut") + .addS(self.knob.assembly(), name="knob", + loc=Cq.Location((0, 0, knob_h * fastener_pos - self.parent_thickness))) + .constrain("knob/thread", "Fixed") + .constrain("hex_nut?bot", "parent?base", "Plane", param=0) + .constrain("hex_nut?dirX", "parent@faces@>X", "Axis", param=0) + ) + self.hirth_joint.add_constraints( + result, + "parent", + "child", + offset=offset + ) + return result.solve() + +@dataclass +class ShoulderJoint(Model): + + bolt: FlatHeadBolt = SHOULDER_AXIS_BOLT + + height: float = 70.0 + torsion_joint: TorsionJoint = field(default_factory=lambda: TorsionJoint( + radius_track=18, + radius_rider=18, + groove_depth=4.8, + groove_radius_outer=16, + groove_radius_inner=13, + track_disk_height=5.0, + rider_disk_height=5.0, + radius_axle=3.0, + spring=SHOULDER_TORSION_SPRING, + rider_slot_begin=0, + rider_n_slots=1, + rider_slot_span=0, + )) + + # On the parent side, drill vertical holes + + 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), + ]) + + # 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 + + # The parent side has arms which connect to the lips + parent_arm_width: float = 25.0 + parent_arm_height: float = 12.0 + # remove a bit of material from the base so it does not interfere with gluing + parent_arm_base_shift: float = 1.0 + + # Generates a child guard which covers up the internals. The lip length is + # relative to the +X surface of the guard. + child_guard_ext: float = 20.0 + child_guard_width: float = 25.0 + # guard length measured from axle + child_lip_ext: float = 50.0 + child_lip_width: float = 20.0 + child_lip_height: float = 40.0 + child_lip_thickness: float = 5.0 + child_conn_hole_diam: float = 4.0 + # Measured from centre of axle + child_conn_hole_pos: list[float] = field(default_factory=lambda: [-15, -5, 5, 15]) + child_core_thickness: float = 3.0 + + # Rotates the torsion joint to avoid collisions or for some other purpose + axis_rotate_bot: float = 90 + axis_rotate_top: float = 0 + + directrix_id: int = 0 + angle_neutral: float = -15.0 + angle_max_deflection: float = 65.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 + winch: Optional[Winch] = None # Initialized later + actuator: LinearActuator = LINEAR_ACTUATOR_21 + + 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_radius < self.spool_inner_radius < self.spool_outer_radius + assert self.child_lip_height < self.height + assert self.draft_length <= self.actuator.stroke_length + self.winch = Winch( + actuator=self.actuator, + linear_motion_span=self.draft_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 + + @property + def draft_length(self): + """ + Amount of wires that need to draft on the spool + """ + return self.spool_inner_radius * math.radians(self.angle_max_deflection) + + @property + def draft_height(self): + """ + Position of the middle of the spool measured from the middle + """ + return 0 + + @property + def parent_lip_gap(self): + return self.height - self.parent_lip_length * 2 + + def parent_lip_loc(self, left: bool=True) -> Cq.Location: + """ + 2d location of the arm surface on the parent side, relative to axle + """ + 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 + def child_lip_loc(self) -> Cq.Location: + """ + 2d location to middle of lip + """ + return Cq.Location.from2d(self.child_lip_ext - self.child_guard_ext, 0, 180) + + @property + def _max_contraction_angle(self) -> float: + return 180 - self.angle_max_deflection + self.angle_neutral + + 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 + 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_arm_width <= joint.radius_track * 2 + assert self.parent_lip_ext > joint.radius_track + + cut_arm = Cq.Solid.makeBox( + self.parent_lip_ext + self.parent_lip_width / 2, + self.parent_arm_width, + self.parent_arm_base_shift, + ) + arm = ( + Cq.Solid.makeBox( + self.parent_lip_ext + self.parent_lip_width / 2, + self.parent_arm_width, + self.parent_arm_height) + .cut(cut_arm) + .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)) + ) + t = self.parent_arm_base_shift + lip_args = dict( + length=self.parent_lip_length - t, + 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 - t, y=-y) + for x, y in self.parent_conn_hole_pos + ], + ) + lip2 = MountingBox( + **lip_args, + holes=[ + Hole(x=self.height / 2 - x - t, y=y) + for x, y in self.parent_conn_hole_pos + ], + ) + lip_dy = self.parent_arm_width / 2 - self.parent_lip_thickness + # Flip so the lip's holes point to -X + loc_shift = Cq.Location((self.parent_arm_base_shift, 0, 0)) + loc_axis = Cq.Location((0,0,0), (0, 1, 0), -90) + 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(arm, name="arm") + .add(lip1.generate(), name=lip_p_tag, loc=loc_pos * loc_dir1 * loc_axis * loc_shift) + .add(lip2.generate(), name=lip_n_tag, loc=loc_pos * loc_dir2 * loc_axis * loc_shift) + ) + return result + + @target(name="parent-bot") + def parent_bot(self) -> Cq.Assembly: + return self.parent(top=False) + @target(name="parent-top") + def parent_top(self) -> Cq.Assembly: + return self.parent(top=True) + + @property + def child_height(self) -> float: + """ + Calculates the y distance between two joint surfaces on the child side + of the shoulder joint. + """ + joint = self.torsion_joint + return self.height - 2 * joint.total_height + 2 * joint.rider_disk_height + + def _spool(self) -> Cq.Compound: + """ + Generates the spool piece which holds the line in tension + """ + t = self.spool_line_thickness + spindle = Cq.Solid.makeCone( + radius1=self.spool_inner_radius, + radius2=self.spool_outer_radius, + height=self.spool_core_height, + ) + cap = Cq.Solid.makeCylinder( + radius=self.spool_outer_radius, + height=self.spool_cap_height + ).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_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=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))) + ) + + @target(name="child") + def child(self) -> Cq.Assembly: + """ + Creates the top/bottom shoulder child joint + """ + + joint = self.torsion_joint + + # Half of the height of the bridging cylinder + dh = self.height / 2 - joint.total_height + core_start_angle = 30 + + radius_core_inner = joint.radius_rider - self.child_core_thickness + core_profile1 = ( + Cq.Sketch() + .arc((0, 0), joint.radius_rider, core_start_angle, self.angle_max_deflection) + .segment((0, 0)) + .close() + .assemble() + .circle(radius_core_inner, mode='s') + ) + core_profile2 = ( + Cq.Sketch() + .arc((0, 0), joint.radius_rider, -core_start_angle, -(90 - self.angle_neutral)) + .segment((0, 0)) + .close() + .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, + width=self.child_lip_width, + height=self.child_lip_height, + ).cut(Cq.Solid.makeBox( + length=self.child_lip_ext - self.child_guard_ext, + width=self.child_lip_width - self.child_lip_thickness * 2, + height=self.child_lip_height, + ).located(Cq.Location((0, self.child_lip_thickness, 0)))) + .cut(Cq.Solid.makeBox( + length=self.child_lip_ext - self.child_guard_ext - self.child_lip_thickness, + width=self.child_lip_width, + height=self.child_lip_height - self.child_lip_thickness * 2, + ).located(Cq.Location((0, 0, self.child_lip_thickness)))) + .located(Cq.Location(( + self.child_guard_ext, + -self.child_lip_width / 2, + -self.child_lip_height / 2, + ))) + ) + core_guard = ( + Cq.Workplane('XY') + .box( + length=self.child_guard_ext, + width=self.child_guard_width, + height=self.height, + centered=(False, True, True), + ) + #.copyWorkplane(Cq.Workplane('XY')) + #.box( + # length=self.child_lip_ext, + # width=self.child_guard_width, + # height=self.child_lip_height, + # combine=True, + # centered=(False, True, True), + #) + .copyWorkplane(Cq.Workplane('XY')) + .cylinder( + radius=self.radius, + height=self.height, + combine='cut', + centered=True, + ) + .copyWorkplane(Cq.Workplane('XY')) + .box( + length=self.child_lip_ext, + width=self.child_lip_width - 2 * self.child_lip_thickness, + height=self.height - self.torsion_joint.total_height * 2, + combine='cut', + centered=(False, True, True), + ) + .union(lip_extension) + .cut(self._contraction_cut_geometry(parent=False)) + ) + core = ( + Cq.Workplane('XY') + .placeSketch(core_profile1) + .toPending() + .extrude(dh * 2) + .copyWorkplane(Cq.Workplane('XY')) + .placeSketch(core_profile2) + .toPending() + .extrude(dh * 2) + .translate(Cq.Vector(0, 0, -dh)) + .cut(spool_cut) + .union(core_guard, tol=TOL) + ) + assert self.child_lip_width / 2 <= joint.radius_rider + sign = 1 if self.flip else -1 + holes = [Hole(x=sign * x) for x in self.child_conn_hole_pos] + lip_obj = MountingBox( + length=self.child_lip_height, + width=self.child_lip_width, + thickness=self.child_lip_thickness, + holes=holes, + hole_diam=self.child_conn_hole_diam, + centred=(True, True), + generate_side_tags=False, + generate_reverse_tags=False, + ) + theta = self.torsion_joint.spring.angle_neutral - self.torsion_joint.rider_slot_span + 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 = 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() + .add(core, name="core", loc=Cq.Location()) + .add(joint.rider(rider_slot_begin=-90, reverse_directrix_label=True), name="rider_top", + loc=loc_axis_rotate_top * Cq.Location((0, 0, dh), (0, 0, 1), -90) * Cq.Location((0, 0, 0), (0, 0, 1), theta)) + .add(joint.rider(rider_slot_begin=180), name="rider_bot", + loc=loc_axis_rotate_bot * Cq.Location((0, 0, -dh), (0, 0, 1), -90) * loc_rotate) + .add(lip_obj.generate(), name="lip", + loc=Cq.Location((self.child_lip_ext - self.child_lip_thickness,0,0), (0,1,0), 90)) + .add(self._spool(), name="spool", + loc=loc_spool_flip * Cq.Location((0, 0, -spool_dz), (0, 0, 1), spool_angle)) + ) + return result + + @assembly() + def assembly( + self, + fastener_pos: float = 0.0, + deflection: float = 0.0, + ignore_fasteners: bool = False, + ) -> Cq.Assembly: + assert deflection <= self.angle_max_deflection + directrix = self.directrix_id + mat = Material.RESIN_TRANSPERENT + mat_spring = Material.STEEL_SPRING + bolt_z = self.height / 2 + self.bolt.height_thread * (fastener_pos - 1) + result = ( + Cq.Assembly() + .addS(self.child(), name="child", + role=Role.CHILD, material=mat) + .constrain("child/core", "Fixed") + .addS(self.torsion_joint.spring.assembly(deflection=-deflection), name="spring_top", + role=Role.DAMPING, material=mat_spring) + .addS(self.parent_top(), + name="parent_top", + role=Role.PARENT, material=mat) + .addS(self.torsion_joint.spring.assembly(deflection=deflection), name="spring_bot", + role=Role.DAMPING, material=mat_spring) + .addS(self.parent_bot(), + name="parent_bot", + role=Role.PARENT, material=mat) + ) + if not ignore_fasteners: + ( + result + # Fasteners + .addS(self.bolt.assembly(), name="bolt_top", + loc=Cq.Location((0, 0, bolt_z))) + .constrain("bolt_top?root", 'Fixed') + .addS(self.bolt.assembly(), name="bolt_bot", + loc=Cq.Location((0, 0, -bolt_z), (1,0,0), 180)) + .constrain("bolt_bot?root", 'Fixed') + ) + TorsionJoint.add_constraints( + result, + rider="child/rider_top", + track="parent_top/track", + spring="spring_top", + directrix=directrix) + TorsionJoint.add_constraints( + result, + rider="child/rider_bot", + track="parent_bot/track", + spring="spring_bot", + directrix=directrix) + return result.solve() + + +@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 = 6.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 generate(self, flip: bool = False) -> Cq.Assembly: + beam = ( + Cq.Workplane('XZ') + .box(self.spine_length, self.spine_thickness, self.spine_height) + ) + h = self.spine_height / 2 + self.foot_height + + tag_p, tag_n = "top", "bot" + if flip: + tag_p, tag_n = tag_n, tag_p + result = ( + Cq.Assembly() + .add(beam, name="beam") + .add(self.foot(), name=tag_p, + loc=Cq.Location((0, h, 0))) + .add(self.foot(), name=tag_n, + loc=Cq.Location((0, -h, 0), (1, 0, 0), 180)) + ) + return result + + +@dataclass +class DiskJoint(Model): + """ + Sandwiched disk joint for the wrist and elbow + + We embed a spring inside the joint, with one leg in the disk and one leg in + the housing. This provides torsion resistance. + """ + spring: TorsionSpring = ELBOW_TORSION_SPRING + + radius_housing: float = 22.0 + radius_disk: float = 20.0 + + housing_thickness: float = 2.0 + disk_thickness: float = 6.0 + tongue_thickness: float = 12.0 + + # Amount by which the wall carves in + wall_inset: float = 2.0 + # Height of the spring hole; if you make it too short the spring can't enter + spring_tail_hole_height: float = 2.0 + + # Spring angle at 0 degrees of movement + spring_angle_at_0: float = 90.0 + spring_slot_offset: float = 5.0 + + # Angular span of movement + movement_angle: float = 120.0 + # leave some gap for cushion + movement_gap: float = 5.0 + # Angular span of tongue on disk + tongue_span: float = 25.0 + tongue_length: float = 10.0 + + 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") + assert self.radius_housing > self.radius_disk > self.radius_axle + assert self.spring.height < self.housing_thickness + self.disk_thickness + + assert self.housing_upper_carve_offset > 0 + assert self.spring_tail_hole_height > self.spring.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]: + a = self.spring.angle_neutral - self.spring_angle_at_0 + if 0 <= a and a <= self.movement_angle: + return a + return None + + @property + def radius_axle(self) -> float: + return self.axle_bolt.diam_thread / 2 + + @property + def total_thickness(self) -> float: + return self.housing_thickness * 2 + self.disk_thickness + @property + def disk_bot_thickness(self) -> float: + """ + Pads the bottom of the disk up to spring height + """ + return max(0, self.disk_thickness + self.spring.thickness - self.spring.height) + + @property + def opening_span(self) -> float: + return self.movement_angle + self.tongue_span + + @property + def housing_upper_carve_offset(self) -> float: + """ + Distance between the spring track and the outside of the upper housing + """ + return self.spring_tail_hole_height + (self.disk_thickness - self.disk_bot_thickness) - self.spring.height + + @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 + + def _disk_cut(self) -> Cq.Workplane: + rl = Cq.Location((0, 0, 0), (0, 0, 1), self.spring_slot_offset) + r = ( + Cq.Solid.makeBox( + length=self.spring.tail_length, + width=self.spring.thickness, + height=self.disk_thickness - self.disk_bot_thickness, + ) + .moved(rl * Cq.Location((0, self.spring.radius_inner, self.disk_bot_thickness))) + #.rotate((0, 0, 0), (0, 0, 1), self.spring_slot_offset) + ) + return ( + Cq.Workplane() + .union(r) + .val() + ) + + @target(name="disk") + def disk(self) -> Cq.Workplane: + radius_tongue = self.radius_disk + self.tongue_length + 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=self.radius_housing, + angleDegrees=self.tongue_span, + ).cut(Cq.Solid.makeCylinder( + height=self.disk_thickness, + radius=self.radius_disk, + )) + ) + result = ( + Cq.Workplane('XY') + .cylinder( + height=self.disk_thickness, + radius=self.radius_disk, + centered=(True, True, False) + ) + .union(inner_tongue, tol=TOL) + .union(outer_tongue, tol=TOL) + .copyWorkplane(Cq.Workplane('XY')) + .cylinder( + height=self.disk_thickness, + radius=self.spring.radius, + centered=(True, True, False), + combine='cut', + ) + .cut(self._disk_cut()) + ) + plane = result.copyWorkplane(Cq.Workplane('XY')) + theta = math.radians(self.spring_slot_offset) + plane.tagPlane("dir", direction=(math.cos(theta), math.sin(theta), 0)) + plane.workplane(offset=self.disk_thickness).tagPlane("mate_top") + plane.workplane(offset=self.disk_bot_thickness).tagPlane("mate_spring") + result.copyWorkplane(Cq.Workplane('YX')).tagPlane("mate_bot") + + 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 - self.movement_gap*2, + ).cut(Cq.Solid.makeCylinder( + radius=self.radius_disk, + height=height, + )).rotate((0, 0, 0), (0, 0, 1), self.opening_span+self.movement_gap) + 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("dirX", direction="+X") + result = result.cut( + self + .wall() + .located(Cq.Location((0, 0, self.housing_thickness - self.wall_inset))) + #.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_angle = -(self.spring_angle_at_0 - self.spring_slot_offset) + carve = ( + Cq.Solid.makeCylinder( + radius=self.spring.radius, + height=self.spring_tail_hole_height, + ).fuse(Cq.Solid.makeBox( + length=self.spring.tail_length, + width=self.spring.thickness, + height=self.spring_tail_hole_height, + ).located(Cq.Location((0, -self.spring.radius, 0)))) + ).rotate((0, 0, 0), (0, 0, 1), carve_angle) + result = ( + Cq.Workplane('XY') + .cylinder( + radius=self.radius_housing, + height=self.housing_thickness, + centered=(True, True, False), + ) + ) + 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)) + 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() + .located(Cq.Location((0, 0, -self.disk_thickness-self.wall_inset))) + ) + result = ( + result + .union(wall, tol=TOL) + #.cut(carve) + .cut(carve.located(Cq.Location((0, 0, -self.housing_upper_carve_offset)))) + ) + return result.clean() + + def add_constraints(self, + assembly: Cq.Assembly, + housing_lower: str, + 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) + spring_name = disk.replace("/", "__Z") + "_spring" + ( + assembly + .addS( + self.spring.assembly(deflection=-deflection), + name=spring_name, + role=Role.DAMPING, + material=Material.STEEL_SPRING) + .constrain(f"{disk}?mate_bot", f"{housing_lower}?mate", "Plane") + .constrain(f"{disk}?mate_top", f"{housing_upper}?mate", "Plane") + .constrain(f"{housing_lower}?dirX", f"{housing_upper}?dirX", "Axis", param=0) + .constrain(f"{housing_upper}?dir", f"{spring_name}?dir_top", "Axis", param=0) + .constrain(f"{spring_name}?dir_bot", f"{disk}?dir", "Axis", param=0) + .constrain(f"{disk}?mate_spring", f"{spring_name}?bot", "Plane") + #.constrain(f"{housing_lower}?dirX", f"{housing_upper}?dir", "Axis", param=0) + #.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 + ) + + + def assembly(self, angle: Optional[float] = 0) -> Cq.Assembly: + if angle is None: + angle = self.neutral_movement_angle + if angle is None: + angle = 0 + else: + assert 0 <= angle <= self.movement_angle + result = ( + Cq.Assembly() + .addS(self.disk(), name="disk", role=Role.CHILD) + .addS(self.housing_lower(), name="housing_lower", role=Role.PARENT) + .addS(self.housing_upper(), name="housing_upper", role=Role.CASING) + .constrain("housing_lower", "Fixed") + ) + result = self.add_constraints( + result, + housing_lower="housing_lower", + housing_upper="housing_upper", + disk="disk", + angle=angle, + ) + return result.solve() + + +@dataclass(kw_only=True) +class ElbowJoint(Model): + """ + 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 + + lip_thickness: float = 5.0 + lip_length: float = 40.0 + # Carve which allows light to go through + lip_side_depression_width: float = 10.0 + hole_pos: list[float] = field(default_factory=lambda: [12.5]) + parent_arm_width: float = 10.0 + # Angle of the beginning of the parent arm + parent_arm_angle: float = 180.0 + + # Size of the mounting holes + hole_diam: float = 4.0 + + material: Material = Material.RESIN_TRANSPERENT + + # If set to true, the joint is flipped upside down. + flip: bool = False + angle_neutral: float = 30.0 + + actuator: Optional[LinearActuator] = None + flexor: Optional[Flexor] = None + # Rotates the entire flexor + flexor_offset_angle: float = 0 + # Rotates the surface of the mount relative to radially inwards + flexor_mount_angle_parent: float = 0 + flexor_mount_angle_child: float = -90 + flexor_pos_smaller: bool = True + flexor_child_arm_radius: Optional[float] = None + flexor_line_length: float = 0.0 + flexor_line_slack: float = 0.0 + flexor_parent_angle_fix: Optional[float] = 180.0 + flexor_child_angle_fix: Optional[float] = None + + 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 - self.lip_thickness / 2 + if self.actuator: + self.flexor = Flexor( + actuator=self.actuator, + motion_span=self.motion_span, + pos_smaller=self.flexor_pos_smaller, + arm_radius=self.flexor_child_arm_radius, + line_length=self.flexor_line_length, + line_slack=self.flexor_line_slack, + ) + + def hole_loc_tags(self): + """ + An iterator which iterates through positions of the hole and tags + """ + for i, x in enumerate(self.hole_pos): + yield x, f"conn_top{i}" + yield -x, f"conn_bot{i}" + + @property + def total_thickness(self) -> float: + candidate1 = self.disk_joint.axle_bolt.height_thread + candidate2 = self.disk_joint.total_thickness + self.disk_joint.axle_hex_nut.thickness + head_thickness = self.disk_joint.axle_bolt.height_head + return head_thickness + max(candidate1, candidate2) + + @property + def motion_span(self) -> float: + return self.disk_joint.movement_angle + + def parent_arm_loc(self) -> Cq.Location: + """ + 2d Location of the centre of the arm surface on the parent side, assuming + axle is at position 0, and parent direction is -X + """ + return Cq.Location.from2d(-self.parent_arm_radius, 0, 0) + def child_arm_loc(self, angle: float = 0.0) -> Cq.Location: + """ + 2d Location of the centre of the arm surface on the child side, assuming + axle is at position 0, and parent direction is -X + """ + result = Cq.Location.rot2d(self.angle_neutral + angle) * Cq.Location.from2d(self.child_arm_radius, 0, 180) + return result.flip_y() if self.flip else result + def actuator_mount(self) -> Cq.Workplane: + holes = [ + Hole(x=0, y=0, tag="mount"), + ] + mbox = MountingBox( + length=self.disk_joint.total_thickness, + width=self.disk_joint.total_thickness, + thickness=self.lip_thickness, + holes=holes, + hole_diam=self.hole_diam, + centred=(True, True), + generate_side_tags=False, + ) + return mbox.generate() + def actuator_mount_loc( + self, + child: bool = False, + # If set to true, use the local coordinate system + unflip: bool = False, + ) -> Cq.Location: + # Moves the hole so the axle of the mount is perpendicular to it + loc_mount = Cq.Location.from2d(self.flexor.mount_height, 0) * Cq.Location.rot2d(180) + mount_angle = self.flexor_mount_angle_child if child else self.flexor_mount_angle_parent + loc_mount_orient = Cq.Location.rot2d(mount_angle) + # Moves the hole to be some distance apart from 0 + mount_r, mount_loc_angle, mount_parent_r = self.flexor.open_pos() + loc_span = Cq.Location.from2d(mount_r if child else mount_parent_r, 0) + if self.flexor_parent_angle_fix is not None: + alpha = (-mount_loc_angle if child else 0) + self.flexor_parent_angle_fix - self.flexor_offset_angle + elif self.flexor_child_angle_fix is not None: + alpha = self.flexor_child_angle_fix + (0 if child else mount_loc_angle) + else: + raise ValueError("One of flexor_{parent,child}_angle_fix must be set") + loc_rot = Cq.Location.rot2d(alpha) + loc = loc_rot * loc_span * loc_mount_orient * loc_mount + return loc.flip_y() if self.flip and not unflip else loc + + def lip(self) -> Cq.Workplane: + sign = -1 if self.flip else 1 + holes = [ + h + for i, x in enumerate(self.hole_pos) + for h in [ + Hole(x=sign * x, tag=f"conn_top{i}"), + 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 + if y_outer <= y_inner: + return sketch + 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, + thickness=self.lip_thickness, + holes=holes, + hole_diam=self.hole_diam, + centred=(True, True), + generate_side_tags=False, + generate_reverse_tags=True, + profile_callback=post, + ) + return mbox.generate() + + def child_joint(self, generate_mount: bool=False, generate_tags=True) -> Cq.Assembly: + angle = -self.disk_joint.tongue_span / 2 + dz = self.disk_joint.disk_thickness / 2 + # We need to ensure the disk is on the "other" side so + flip_x = Cq.Location((0, 0, 0), (1, 0, 0), 180) + flip_z = Cq.Location((0, 0, 0), (0, 0, 1), 180) + lip_dz = self.lip_thickness + loc_lip = ( + Cq.Location((0, 0, 0), (0, 1, 0), 180) * + Cq.Location((-lip_dz, 0, 0), (1, 0, 0), 90) * + Cq.Location((0, 0, 0), (0, 1, 0), 90) + ) + loc_rot_neutral = Cq.Location.rot2d(self.angle_neutral) + loc_disk = flip_x * flip_z * Cq.Location((-self.child_arm_radius, 0, 0)) + loc_cut_rel = Cq.Location((0, self.disk_joint.spring.radius_inner, -self.disk_joint.disk_bot_thickness)) + loc_disk_orient = Cq.Location((0, 0, -dz), (0,0,1), angle) + disk_cut = self.disk_joint._disk_cut().moved( + #Cq.Location(0,0,0)) + loc_lip.inverse * loc_disk * loc_disk_orient) + #lip_extra = Cq.Solid.makeBox( + # length=self.child_lip_extra_length, + # width=self.total_thickness, + # height=self.lip_thickness, + #).located(Cq.Location(( + # self.lip_length / 2, + # -self.total_thickness / 2, + # 0, + #))) + result = ( + Cq.Assembly() + .add(self.disk_joint.disk(), name="disk", + loc=loc_rot_neutral * loc_disk_orient) + .add(self.lip().cut(disk_cut), name="lip", + loc=loc_rot_neutral * loc_disk.inverse * loc_lip) + #.add(lip_extra, name="lip_extra", + # loc=loc_rot_neutral * loc_disk.inverse * loc_lip) + ) + # Orientes the hole surface so it faces +X + loc_thickness = Cq.Location((-self.lip_thickness, 0, 0), (0, 1, 0), 90) + if self.flexor and generate_tags: + loc_mount = self.actuator_mount_loc(child=True, unflip=True) + result.add( + Cq.Edge.makeLine((-1,0,0), (1,0,0)), + name="act", + loc=loc_mount) + if generate_mount: + # Orientes the hole surface so it faces +X + loc_thickness = Cq.Location((-self.lip_thickness, 0, 0), (0, 1, 0), 90) + result.add( + self.actuator_mount(), + name="act_mount", + loc=loc_mount * loc_thickness, + ) + return result + + @target(name="child") + def target_child(self) -> Cq.Assembly: + return self.child_joint(generate_tags=False) + + @target(name="parent-lower") + def parent_joint_lower(self) -> Cq.Workplane: + return self.disk_joint.housing_lower() + + 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.tongue_thickness + conn_w = self.parent_arm_width + connector = ( + Cq.Solid.makeBox( + length=self.parent_arm_radius, + width=conn_w, + height=conn_h, + ).located(Cq.Location((0, -conn_w/2, 0))) + #Cq.Solid.makeCylinder( + # height=conn_h, + # radius=self.parent_arm_radius - self.lip_thickness / 2, + # 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) + #.rotate((0,0,0), (0,0,1), 180-self.parent_arm_span / 2) + ) + housing = self.disk_joint.housing_upper() + housing_loc = Cq.Location( + (0, 0, housing_dz), + (0, 0, 1), + -self.disk_joint.tongue_span / 2 + self.angle_neutral + ) + lip_dz = self.lip_thickness + result = ( + Cq.Assembly() + # rotate so 0 degree is at +X + .add(housing, name="housing", loc=housing_loc) + .add(self.lip(), name="lip", loc= + axial_offset.inverse * + Cq.Location((0, 0, 0), (0, 1, 0), 180) * + Cq.Location((-lip_dz, 0, 0), (1, 0, 0), 90) * + Cq.Location((0, 0, 0), (0, 1, 0), 90)) + .add(connector, name="connector") + #.constrain("housing", "Fixed") + #.constrain("connector", "Fixed") + #.solve() + ) + if self.flexor and generate_tags: + loc_mount = self.actuator_mount_loc(child=False, unflip=True) + result.add( + Cq.Edge.makeLine((-1,0,0), (1,0,0)), + name="act", + loc=loc_mount) + if generate_mount: + # Orientes the hole surface so it faces +X + loc_thickness = Cq.Location((-self.lip_thickness, 0, 0), (0, 1, 0), 90) + result.add( + self.actuator_mount(), + name="act_mount", + loc=loc_mount * loc_thickness + ) + return result + + @target(name="parent-upper") + def target_parent_upper(self) -> Cq.Assembly: + return self.parent_joint_upper(generate_tags=False) + + @assembly() + def assembly(self, + angle: float = 0, + generate_mount: bool = False, + ignore_actuators: bool = False) -> Cq.Assembly: + assert 0 <= angle <= self.motion_span + result = ( + Cq.Assembly() + .addS(self.child_joint(generate_mount=generate_mount), name="child", + role=Role.CHILD, material=self.material) + .addS(self.parent_joint_lower(), name="parent_lower", + role=Role.CASING, material=self.material) + .addS(self.parent_joint_upper(generate_mount=generate_mount), name="parent_upper", + role=Role.PARENT, material=self.material) + #.constrain("child/disk?mate_bot", "Fixed") + ) + result = self.disk_joint.add_constraints( + result, + housing_lower="parent_lower", + housing_upper="parent_upper/housing", + disk="child/disk", + angle=angle, + ) + if not ignore_actuators and self.flexor: + target_length = self.flexor.target_length_at_angle( + angle=angle, + ) + self.flexor.add_to( + result, + target_length=target_length, + tag_hole_back="parent_upper/act", + tag_hole_front="child/act?mount", + tag_dir="parent_lower?mate", + ) + return result.solve() + +if __name__ == '__main__': + p = ShoulderJoint() + p.build_all() + p = DiskJoint() + p.build_all() diff --git a/nhf/touhou/houjuu_nue/test.py b/nhf/touhou/houjuu_nue/test.py new file mode 100644 index 0000000..6e8fd7e --- /dev/null +++ b/nhf/touhou/houjuu_nue/test.py @@ -0,0 +1,130 @@ +import unittest +import cadquery as Cq +import nhf.touhou.houjuu_nue as M +import nhf.touhou.houjuu_nue.joints as MJ +import nhf.touhou.houjuu_nue.electronics as ME +from nhf.checks import pairwise_intersection + +class TestElectronics(unittest.TestCase): + + def test_actuator_length(self): + self.assertAlmostEqual( + ME.LINEAR_ACTUATOR_50.conn_length, 103.9 + ) + self.assertAlmostEqual( + ME.LINEAR_ACTUATOR_30.conn_length, 83.9 + ) + self.assertAlmostEqual( + ME.LINEAR_ACTUATOR_10.conn_length, 64.5 + ) + self.assertAlmostEqual( + ME.LINEAR_ACTUATOR_21.conn_length, 76.0 + ) + + def test_flexor(self): + flexor = ME.Flexor( + motion_span=60, + ) + self.assertAlmostEqual( + flexor.target_length_at_angle(0), + flexor.actuator.stroke_length + flexor.actuator.conn_length) + self.assertAlmostEqual( + flexor.target_length_at_angle(flexor.motion_span), + flexor.actuator.conn_length) + + +class TestJoints(unittest.TestCase): + + def test_shoulder_collision_of_torsion_joint(self): + j = MJ.ShoulderJoint() + assembly = j.torsion_joint.rider_track_assembly() + self.assertEqual(pairwise_intersection(assembly), []) + + def test_shoulder_collision_0(self): + j = MJ.ShoulderJoint() + assembly = j.assembly() + self.assertEqual(pairwise_intersection(assembly), []) + + def test_shoulder_align(self): + j = MJ.ShoulderJoint() + a = j.assembly() + l_t_c0 = a.get_abs_location("parent_top/lip?conn0") + l_b_c0 = a.get_abs_location("parent_bot/lip?conn0") + v = l_t_c0 - l_b_c0 + self.assertAlmostEqual(v.x, 0) + self.assertAlmostEqual(v.y, 0) + + def test_shoulder_joint_dist(self): + """ + Tests the arm radius + """ + j = MJ.ShoulderJoint() + for deflection in [0, 40, j.angle_max_deflection]: + with self.subTest(deflection=deflection): + a = j.assembly(deflection=deflection) + # Axle + o = a.get_abs_location("parent_top/track?spring") + l_c1 = a.get_abs_location("parent_top/lip?conn0") + l_c2= a.get_abs_location("parent_top/lip?conn1") + v_c = 0.5 * ((l_c1 - o) + (l_c2 - o)) + v_c.z = 0 + self.assertAlmostEqual(v_c.Length, j.parent_lip_ext) + + def test_disk_collision_0(self): + j = MJ.DiskJoint() + assembly = j.assembly(angle=0) + self.assertEqual(pairwise_intersection(assembly), []) + def test_disk_collision_mid(self): + j = MJ.DiskJoint() + assembly = j.assembly(angle=j.movement_angle / 2) + self.assertEqual(pairwise_intersection(assembly), []) + def test_disk_collision_max(self): + j = MJ.DiskJoint() + assembly = j.assembly(angle=j.movement_angle) + self.assertEqual(pairwise_intersection(assembly), []) + + def test_elbow_joint_dist(self): + """ + Tests the arm radius + """ + j = MJ.ElbowJoint() + for angle in [0, 10, 20, j.disk_joint.movement_angle]: + with self.subTest(angle=angle): + a = j.assembly(angle=angle) + o = a.get_abs_location("child/disk?mate_bot") + l_c1 = a.get_abs_location("child/lip?conn_top0") + l_c2 = a.get_abs_location("child/lip?conn_bot0") + v_c = 0.5 * ((l_c1 - o) + (l_c2 - o)) + v_c.z = 0 + self.assertAlmostEqual(v_c.Length, j.child_arm_radius) + + l_p1 = a.get_abs_location("parent_upper/lip?conn_top0") + l_p2 = a.get_abs_location("parent_upper/lip?conn_bot0") + v_p = 0.5 * ((l_p1 - o) + (l_p2 - o)) + v_p.z = 0 + self.assertAlmostEqual(v_p.Length, j.parent_arm_radius) + + +class Test(unittest.TestCase): + + def test_hs_joint_parent(self): + p = M.Parameters() + obj = p.harness.hs_joint_parent() + self.assertIsInstance(obj.val().solids(), Cq.Solid, msg="H-S joint must be in one piece") + + def test_wings_assembly(self): + p = M.Parameters() + p.wings_harness_assembly() + def test_trident_assembly(self): + p = M.Parameters() + assembly = p.trident.assembly() + bbox = assembly.toCompound().BoundingBox() + length = bbox.zlen + self.assertGreater(length, 1300) + self.assertLess(length, 1700) + #def test_assemblies(self): + # p = M.Parameters() + # p.check_all() + +if __name__ == '__main__': + unittest.main() diff --git a/nhf/touhou/houjuu_nue/trident.py b/nhf/touhou/houjuu_nue/trident.py new file mode 100644 index 0000000..bfc435f --- /dev/null +++ b/nhf/touhou/houjuu_nue/trident.py @@ -0,0 +1,88 @@ +import math +from dataclasses import dataclass, field +import cadquery as Cq +from nhf import Material, Role +from nhf.parts.handle import Handle, BayonetMount +from nhf.build import Model, target, assembly +import nhf.utils + +@dataclass +class Trident(Model): + handle: Handle = field(default_factory=lambda: Handle( + diam=38, + diam_inner=38-2 * 25.4/8, + diam_connector_internal=18, + simplify_geometry=False, + mount=BayonetMount(n_pin=3), + )) + terminal_height: float = 80 + terminal_hole_diam: float = 24 + terminal_bottom_thickness: float = 10 + segment_length: float = 24 * 25.4 + + @target(name="handle-connector") + def handle_connector(self): + return self.handle.connector() + @target(name="handle-insertion") + def handle_insertion(self): + return self.handle.insertion() + @target(name="proto-handle-terminal-connector", prototype=True) + def proto_handle_connector(self): + return self.handle.one_side_connector(height=15) + + @target(name="handle-terminal-connector") + def handle_terminal_connector(self): + result = self.handle.one_side_connector(height=self.terminal_height) + #result.faces("Z").hole(self.terminal_hole_diam, depth=h) + return result + + @assembly() + def assembly(self): + def segment(): + return self.handle.segment(self.segment_length) + + terminal = ( + self.handle + .one_side_connector(height=self.terminal_height) + .faces(">Z") + .hole(15, self.terminal_height + self.handle.insertion_length - 10) + ) + mat_c = Material.PLASTIC_PLA + mat_i = Material.RESIN_TOUGH_1500 + mat_s = Material.ACRYLIC_BLACK + role_i = Role.CONNECTION + role_c = Role.CONNECTION + role_s = Role.STRUCTURE + a = ( + Cq.Assembly() + .addS(self.handle.insertion(), name="i0", + material=mat_i, role=role_i) + .constrain("i0", "Fixed") + .addS(segment(), name="s1", + material=mat_s, role=role_s) + .constrain("i0?rim", "s1?mate1", "Plane", param=0) + .addS(self.handle.insertion(), name="i1", + material=mat_i, role=role_i) + .addS(self.handle.connector(), name="c1", + material=mat_c, role=role_c) + .addS(self.handle.insertion(), name="i2", + material=mat_i, role=role_i) + .constrain("s1?mate2", "i1?rim", "Plane", param=0) + .constrain("i1?mate", "c1?mate1", "Plane") + .constrain("i2?mate", "c1?mate2", "Plane") + .addS(segment(), name="s2", + material=mat_s, role=role_s) + .constrain("i2?rim", "s2?mate1", "Plane", param=0) + .addS(self.handle.insertion(), name="i3", + material=mat_i, role=role_i) + .constrain("s2?mate2", "i3?rim", "Plane", param=0) + .addS(self.handle.one_side_connector(), name="head", + material=mat_c, role=role_c) + .constrain("i3?mate", "head?mate", "Plane") + .addS(terminal, name="terminal", + material=mat_c, role=role_c) + .constrain("i0?mate", "terminal?mate", "Plane") + ) + return a.solve() diff --git a/nhf/touhou/houjuu_nue/wing.py b/nhf/touhou/houjuu_nue/wing.py new file mode 100644 index 0000000..3b7ba65 --- /dev/null +++ b/nhf/touhou/houjuu_nue/wing.py @@ -0,0 +1,1693 @@ +""" +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, + ElectronicBoardBattery, + LightStrip, + ElectronicBoard, + ELECTRONIC_MOUNT_HEXNUT, +) +import nhf.utils + +ELBOW_PARAMS = dict( + hole_diam=4.0, + actuator=LINEAR_ACTUATOR_50, + parent_arm_width=15, +) +ELBOW_DISK_PARAMS = dict( + housing_thickness=2.5, + disk_thickness=6.8, + tongue_thickness=2.5 * 2 + 6.8, +) +WRIST_DISK_PARAMS = dict( + movement_angle=30, + radius_disk=13.0, + radius_housing=15.0, + tongue_thickness=8.0, +) +WRIST_PARAMS = dict( +) + +@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 + # s0 is armoured + panel_thickness_s0: float = 25.4 / 8 + # 1/4" acrylic for the spacer. Anything thinner would threathen structural + # strength + spacer_thickness: float = 25.4 / 4 + rod_width: float = 10.0 + panel_s0_inner_trunc = 0.05 + + light_strip: LightStrip = LightStrip() + + 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_width: float = 40.0 + s0_hole_height: float = 10.0 + s0_top_hole: bool = False + s0_bot_hole: bool = True + + electronic_board: ElectronicBoard = field(default_factory=lambda: ElectronicBoardBattery()) + + s1_thickness: float = 25.0 + + elbow_joint: ElbowJoint + # Distance between the two spacers on the elbow, halved + elbow_h2: float = 5.0 + + wrist_joint: ElbowJoint + # 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 + 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 + wrist_axle_pos: float + elbow_joint_overlap_median: float + wrist_joint_overlap_median: float + + # 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) + self.elbow_axle_loc = self.elbow_bot_loc * \ + Cq.Location.from2d(0, self.elbow_height * self.elbow_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="root-joint") + def submodel_root_joint(self) -> Model: + return self.root_joint + @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 + @submodel(name="electronic-board") + def submodel_electronic_board(self) -> Model: + return self.electronic_board + + @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.Edge: + """ + The outer boundary of s0 top/bottom slots + """ + tip_x = self.shoulder_tip_x + tip_y = self.shoulder_tip_y + return Cq.Edge.makeSpline( + [ + Cq.Vector(*p) + for p in [ + (0, 0), + (-30.0, 80.0), + (tip_x, tip_y), + ] + ] + ) + def inner_profile_s0(self, trunc: float=0.0) -> 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 + points = [ + (tip_x, tip_y - sw), + (tip_x + dx2, tip_y - sw + dy2), + (-self.base_width + dx1, dy1), + (-self.base_width, 0), + ] + bezier = Cq.Edge.makeBezier( + [Cq.Vector(x, y) for x, y in points] + ) + if trunc == 0.0: + return bezier + + tip = bezier.positionAt(d=trunc, mode='parameter') + tangent = bezier.tangentAt(locationParam=trunc, mode='parameter') + points = [ + tip, + tip + tangent, + Cq.Vector(-self.base_width + dx1, dy1), + Cq.Vector(-self.base_width, 0), + ] + return Cq.Edge.makeBezier(points) + + + @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 + sw = self.shoulder_width + sketch = ( + Cq.Sketch() + .edge(self.outer_profile_s0()) + .segment((-self.base_width, 0), (0, 0)) + .segment( + (tip_x, tip_y), + (tip_x, tip_y - sw), + ) + .edge(self.inner_profile_s0()) + .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): + assert self.base_width > self.s0_hole_width + x = (self.base_width - self.s0_hole_width) / 2 + sketch = ( + sketch + .reset() + .polygon([ + (-x, 0), + (-x, self.s0_hole_height), + (-self.base_width + x, self.s0_hole_height), + (-self.base_width + x, 0), + ], mode='s') + ) + return sketch + + def outer_shell_s0(self) -> Cq.Workplane: + t = self.panel_thickness_s0 + profile = self.outer_profile_s0() + 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_s0 + profile = self.inner_profile_s0(trunc=self.panel_s0_inner_trunc) + result = ( + Cq.Workplane('XZ') + .moveTo(-t, 0) + .rect(t, self.root_height + t*2, centered=(False, False)) + .sweep(profile, normal=(0,-1,0)) + ) + plane = result.copyWorkplane(Cq.Workplane('XZ')) + plane.moveTo(0, 0).tagPlane("bot") + plane.moveTo(0, self.root_height + t*2).tagPlane("top") + return result + + @target(name="profile-s0-outer-shell", kind=TargetKind.DXF) + def outer_shell_s0_profile(self) -> Cq.Sketch: + """ + This part should be laser cut and then bent on a falsework to create the required shape. + """ + length = self.outer_profile_s0().Length() + height = self.root_height + self.panel_thickness_s0 * 2 + return Cq.Sketch().rect(length, height) + @target(name="profile-s0-inner-shell", kind=TargetKind.DXF) + def inner_shell_s0_profile(self) -> Cq.Sketch: + """ + This part should be laser cut and then bent on a falsework to create the required shape. + """ + length = self.inner_profile_s0(trunc=self.panel_s0_inner_trunc).Length() + height = self.root_height + self.panel_thickness_s0 * 2 + return Cq.Sketch().rect(length, height) + + @submodel(name="spacer-s0-shoulder-inner") + 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}"), + ] + ] + def post(sketch: Cq.Sketch) -> Cq.Sketch: + """ + Carve out the middle if this is closer to the front + """ + if left: + return sketch + return ( + sketch + .push([(0,0)]) + .rect( + w=self.shoulder_joint.parent_lip_gap, + h=self.shoulder_joint.parent_lip_width, + mode='s' + ) + ) + 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, + profile_callback=post, + ) + @submodel(name="spacer-s0-shoulder-outer") + def spacer_s0_shoulder_outer(self) -> MountingBox: + return self.spacer_s0_shoulder(left=False) + + @submodel(name="spacer-s0-base") + 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), + Hole(x=0, y=0, diam=self.root_joint.axis_diam, tag="axle"), + ] + 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, + ) + + def spacer_s0_electronic_mount(self, circle: bool = False) -> MountingBox: + """ + This one has hexagonal holes + """ + face = self.electronic_board.nut.cutting_face() + diam = self.electronic_board.bolt.diam_thread + holes = [ + Hole( + x=h.x, y=h.y, tag=h.tag, + face=None if circle else face, + diam=diam if circle else None) + for h in self.electronic_board.mount_holes + ] + return MountingBox( + holes=holes, + length=self.root_height, + width=self.electronic_board.width, + centred=(True, True), + thickness=self.spacer_thickness, + flip_y=False,#self.flip, + generate_reverse_tags=True, + ) + @submodel(name="spacer-s0-electronic-hex") + def spacer_s0_electronic_mount_hex(self) -> MountingBox: + return self.spacer_s0_electronic_mount(False) + @submodel(name="spacer-s0-electronic-circle") + def spacer_s0_electronic_mount_circle(self) -> MountingBox: + return self.spacer_s0_electronic_mount(True) + @submodel(name="spacer-s0-electronic2") + def spacer_s0_electronic_mount2(self) -> MountingBox: + """ + This one has circular holes + """ + def post(sketch: Cq.Sketch) -> Cq.Sketch: + return ( + sketch + .push([(0,0)]) + .rect(70, 130, mode='s') + ) + 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=False,#self.flip, + generate_reverse_tags=True, + profile_callback=post, + ) + @submodel(name="spacer-s0-shoulder-act") + def spacer_s0_shoulder_act(self) -> MountingBox: + return MountingBox( + holes=[Hole(x=0)], + 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(110, -40, -25)), + ("base", Cq.Location.from2d(base_dx, base_dy, 90)), + ("electronic_mount", Cq.Location.from2d(-35, 65, 60)), + ] + result = extrude_with_markers( + self.profile_s0(top=top), + self.panel_thickness_s0, + tags, + reverse=top, + ) + h = 0 if top else self.panel_thickness_s0 + result.copyWorkplane(Cq.Workplane('XZ')).moveTo(0, h).tagPlane("corner") + result.copyWorkplane(Cq.Workplane('XZ')).moveTo(-self.base_width, self.panel_thickness_s0 - 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=False), name="bot", + material=self.mat_panel, role=self.role_panel) + .addS(self.surface_s0(top=True), 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)) + if self.flip: + axle_pos = 1 - axle_pos + 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_from_disk_joint( + self, + joint: ElbowJoint, + segment_thickness: float, + child: bool=False, + ) -> MountingBox: + sign = 1 if child else -1 + holes = [ + 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 + tongue_thickness) / 4 + return ( + profile + .push([(0,-dy), (0,dy)]) + .rect(carve_width, carve_height, mode='s') + ) + # FIXME: Carve out the sides so light can pass through + 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, + profile_callback=carve_sides, + ) + return mbox + def _actuator_mount(self, thickness: float, joint: ElbowJoint) -> MountingBox: + def post(sketch: Cq.Sketch) -> Cq.Sketch: + x = thickness / 2 - self.light_strip.height / 2 + w = self.light_strip.height + h = self.light_strip.width + return ( + sketch + .push([(x, 0), (-x, 0)]) + .rect(w, h, mode='s') + #.push([(0, x), (0, -x)]) + #.rect(h, w, mode='s') + ) + + return MountingBox( + length=thickness, + width=thickness, + thickness=self.spacer_thickness, + holes=[Hole(x=0,y=0)], + centred=(True, True), + hole_diam=joint.hole_diam, + centre_left_right_tags=True, + profile_callback=post, + ) + + @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-rod") + def spacer_s1_rod(self) -> MountingBox: + return MountingBox( + length=self.s1_thickness, + width=self.rod_width, + thickness=self.panel_thickness, + ) + @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 self._actuator_mount( + thickness=self.s1_thickness, + joint=self.elbow_joint + ) + @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: + rot_elbow = Cq.Location.rot2d(self.elbow_rotate) + loc_elbow = rot_elbow * 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), + ("elbow_act", self.elbow_axle_loc * rot_elbow * + self.elbow_joint.actuator_mount_loc(child=True)), + ("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-rod") + def spacer_s2_rod(self) -> MountingBox: + return MountingBox( + length=self.s2_thickness, + width=self.rod_width, + thickness=self.panel_thickness, + ) + @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, + child=True, + ) + @submodel(name="spacer-s2-elbow-act") + def spacer_s2_elbow_act(self) -> MountingBox: + return self._actuator_mount( + thickness=self.s2_thickness, + joint=self.elbow_joint + ) + @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 self._actuator_mount( + thickness=self.s2_thickness, + joint=self.wrist_joint + ) + @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: + rot_wrist = Cq.Location.rot2d(self.wrist_rotate) + loc_wrist = rot_wrist * self.wrist_joint.child_arm_loc() + tags = [ + ("wrist", self.wrist_axle_loc * loc_wrist), + ("wrist_act", self.wrist_axle_loc * rot_wrist * + self.wrist_joint.actuator_mount_loc(child=True)), + ("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-rod") + def spacer_s3_rod(self) -> MountingBox: + return MountingBox( + length=self.s3_thickness, + width=self.rod_width, + thickness=self.panel_thickness, + ) + @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, + child=True, + ) + @submodel(name="spacer-s3-wrist-act") + def spacer_s3_wrist_act(self) -> MountingBox: + return self._actuator_mount( + thickness=self.s3_thickness, + joint=self.wrist_joint + ) + @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(): + assert 0 <= elbow_wrist_deflection <= 1 + assert 0 <= shoulder_deflection <= 1 + assert 0 <= fastener_pos <= 1 + 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 not ignore_electronics: + tag_act = "shoulder_act" + tag_bolt = "shoulder_act_bolt" + tag_nut = "shoulder_act_nut" + tag_bracket = "shoulder_act_bracket" + winch = self.shoulder_joint.winch + ( + result + .add(winch.actuator.assembly(pos=0), name=tag_act) + .add(winch.bracket.assembly(), name=tag_bracket) + .add(winch.bolt.assembly(), name=tag_bolt) + .add(winch.nut.assembly(), name=tag_nut) + .constrain(f"{tag_bolt}?root", f"{tag_bracket}?conn_top", + "Plane", param=0) + .constrain(f"{tag_nut}?bot", f"{tag_bracket}?conn_bot", + "Plane") + .constrain(f"{tag_act}/back?conn", f"{tag_bracket}?conn_mid", + "Plane", param=0) + .constrain("s0/shoulder_act?conn0", f"{tag_bracket}?conn_side", + "Plane") + # Directional constraints should be provided by the line + .constrain(f"{tag_bracket}?conn_mid", "s0/shoulder_act?top", "Axis", param=0) + .constrain(f"{tag_act}/back?dir", "s0/shoulder_act?conn0", "Axis", param=0) + ) + 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 + elbow_rotate: float = 10.0 + elbow_joint: ElbowJoint = field(default_factory=lambda: ElbowJoint( + disk_joint=DiskJoint( + movement_angle=55, + spring_angle_at_0=75, + **ELBOW_DISK_PARAMS, + ), + flexor_offset_angle=15, + flexor_mount_angle_child=-75, + flexor_child_arm_radius=None, + angle_neutral=10.0, + flip=False, + **ELBOW_PARAMS + )) + + wrist_bot_loc: Cq.Location = Cq.Location.from2d(403.0, 289.0, 45.0) + wrist_height: float = 60.0 + wrist_joint: ElbowJoint = field(default_factory=lambda: ElbowJoint( + disk_joint=DiskJoint( + spring_angle_at_0=120, + **WRIST_DISK_PARAMS, + ), + flip=True, + angle_neutral=-20.0, + child_arm_radius=23.0, + parent_arm_radius=30.0, + flexor_line_length=50.0, + flexor_line_slack=3.0, + flexor_offset_angle=45.0, + flexor_child_arm_radius=None, + flexor_mount_angle_parent=20, + flexor_mount_angle_child=-40, + hole_pos=[10], + lip_length=30, + actuator=LINEAR_ACTUATOR_10, + #flexor_pos_smaller=False, + **WRIST_PARAMS + )) + + # 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 + elbow_axle_pos: float = 0.4 + wrist_axle_pos: float = 0.0 + elbow_joint_overlap_median: float = 0.35 + wrist_joint_overlap_median: float = 0.5 + + 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, + core_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) + core_start = axle_loc * Cq.Location.rot2d(-angle_span) * Cq.Location.from2d(0, core_radius) + core_end = axle_loc * Cq.Location.rot2d(180) * Cq.Location.from2d(0, core_radius) + 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( + core_start.to2d_pos(), + start.to2d_pos(), + ) + .arc( + start.to2d_pos(), + mid.to2d_pos(), + end.to2d_pos(), + ) + .segment( + end.to2d_pos(), + core_end.to2d_pos(), + ) + .segment( + core_start.to2d_pos(), + axle_loc.to2d_pos(), + ) + .segment( + axle_loc.to2d_pos(), + core_end.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 + """ + profile = self._child_joint_extension_profile( + axle_loc=self.wrist_axle_loc, + radius=self.wrist_height, + core_radius=3, + angle_span=self.wrist_joint.motion_span, + bot=False, + ) + return profile + + @target(name="profile-s3-extra", kind=TargetKind.DXF) + def profile_s3_extra(self) -> Cq.Sketch: + """ + Implements the blade part on Nue's wing + """ + margin = 5 + blade_margin = 10 + + left_top_loc = self.wrist_axle_loc * Cq.Location.rot2d(-15) * Cq.Location.from2d(margin, 0) + 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) + + # 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 + blade_margin) + return ( + Cq.Sketch() + .segment( + self.arrow_bot_loc.to2d_pos(), + left_top_loc.to2d_pos(), + ) + .segment( + self.wrist_axle_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, 105.0, 0.0) + elbow_height: float = 95.0 + elbow_rotate: float = 15.0 + elbow_joint: ElbowJoint = field(default_factory=lambda: ElbowJoint( + disk_joint=DiskJoint( + spring_angle_at_0=100, + movement_angle=50, + **ELBOW_DISK_PARAMS, + ), + angle_neutral=30.0, + flexor_mount_angle_child=220, + flexor_mount_angle_parent=0, + flexor_line_length=50.0, + flexor_line_slack=10.0, + #flexor_line_length=0.0, + #flexor_line_slack=0.0, + flexor_offset_angle=0, + flexor_child_angle_fix=85, + flexor_parent_angle_fix=None, + flexor_child_arm_radius=50.0, + parent_arm_radius=50.0, + child_arm_radius=40.0, + flexor_pos_smaller=False, + flip=True, + **ELBOW_PARAMS + )) + elbow_axle_pos: float = 0.53 + elbow_joint_overlap_median: float = 0.5 + + wrist_angle: float = 0.0 + wrist_bot_loc: Cq.Location = Cq.Location.from2d(460.0, -10.0, -45.0) + wrist_height: float = 43.0 + wrist_joint: ElbowJoint = field(default_factory=lambda: ElbowJoint( + disk_joint=DiskJoint( + **WRIST_DISK_PARAMS, + ), + flip=False, + hole_pos=[10], + lip_length=30, + child_arm_radius=23.0, + parent_arm_radius=30.0, + flexor_offset_angle=0.0, + flexor_child_arm_radius=None, + flexor_line_length=50.0, + flexor_line_slack=1.0, + actuator=LINEAR_ACTUATOR_10, + **WRIST_PARAMS + )) + + 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 + wrist_axle_pos: 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.wrist_joint.angle_neutral = self.wrist_bot_loc.to2d_rot() * 0.7 + 30.0 + self.wrist_rotate = -self.wrist_joint.angle_neutral + 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), + ]