diff --git a/README.md b/README.md index 1ade17c..e254ea7 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,9 @@ This is the design repository for NorCal Hakkero Factory No. 1, where we use parametric CAD to make cosplay props. +> NorCal Hakkero Factory № 1 +> 北加国営八卦炉第一工場 + ## Development Most cosplay schematics are created with Blender, CadQuery, and Inkscape. To diff --git a/nhf/materials.py b/nhf/materials.py index e931403..7477e62 100644 --- a/nhf/materials.py +++ b/nhf/materials.py @@ -25,13 +25,16 @@ class Role(Flag): PARENT = auto() CHILD = auto() CASING = auto() + STATOR = auto() + ROTOR = auto() + BEARING = auto() # Springs, cushions DAMPING = auto() # Main structural support STRUCTURE = auto() DECORATION = auto() ELECTRONIC = auto() - MOTION = auto() + MOTOR = auto() # Fasteners, etc. CONNECTION = auto() @@ -59,11 +62,14 @@ ROLE_COLOR_MAP = { Role.PARENT: _color('blue4', 0.6), Role.CASING: _color('dodgerblue3', 0.6), Role.CHILD: _color('darkorange2', 0.6), + Role.STATOR: _color('gray', 0.5), + Role.ROTOR: _color('blue3', 0.5), + Role.BEARING: _color('green3', 0.8), Role.DAMPING: _color('springgreen', 1.0), Role.STRUCTURE: _color('gray', 0.4), Role.DECORATION: _color('lightseagreen', 0.4), Role.ELECTRONIC: _color('mediumorchid', 0.7), - Role.MOTION: _color('thistle3', 0.7), + Role.MOTOR: _color('thistle3', 0.7), Role.CONNECTION: _color('steelblue3', 0.8), Role.HANDLE: _color('tomato4', 0.8), } @@ -84,6 +90,8 @@ class Material(Enum): ACRYLIC_TRANSLUSCENT = 1.18, _color('ivory2', 0.8) ACRYLIC_TRANSPARENT = 1.18, _color('ghostwhite', 0.5) STEEL_SPRING = 7.8, _color('gray', 0.8) + STEEL_STAINLESS = 7.8, _color('gray', 0.9) + METAL_AL = 2.7, _color('gray', 0.6) METAL_BRASS = 8.5, _color('gold1', 0.8) def __init__(self, density: float, color: Cq.Color): diff --git a/nhf/parts/electronics.py b/nhf/parts/electronics.py index 0a145b3..8bb03e8 100644 --- a/nhf/parts/electronics.py +++ b/nhf/parts/electronics.py @@ -83,11 +83,16 @@ class BatteryBox18650(Item): battery_dist: float = 20.18 height: float = 19.66 # space from bottom to battery begin - thickness: float = 1.66 + thickness: float = 2.28 battery_diam: float = 18.48 battery_height: float = 68.80 n_batteries: int = 3 + battery_gap: float = 2.0 + + diam_thread: float = 3.0 + hole_dy: float = 39.50 / 2 + def __post_init__(self): assert 2 * self.thickness < min(self.length, self.height) @@ -95,13 +100,20 @@ class BatteryBox18650(Item): def name(self) -> str: return f"BatteryBox 18650*{self.n_batteries}" + @property + def holes(self) -> list[Cq.Location]: + return [ + Cq.Location.from2d(0, self.hole_dy), + Cq.Location.from2d(0, -self.hole_dy), + ] + @property def role(self) -> Role: return Role.ELECTRONIC def generate(self) -> Cq.Workplane: width = self.width_base + self.battery_dist * (self.n_batteries - 1) + self.battery_diam - return ( + result = ( Cq.Workplane('XY') .box( length=self.length, @@ -117,7 +129,7 @@ class BatteryBox18650(Item): centered=(True, True, False), combine='cut', ) - .copyWorkplane(Cq.Workplane('XY', origin=(-self.battery_height/2, 0, self.thickness + self.battery_diam/2))) + .copyWorkplane(Cq.Workplane('XY', origin=(-self.battery_height/2, 0, self.thickness + self.battery_diam/2 + self.battery_gap))) .rarray( xSpacing=1, ySpacing=self.battery_dist, @@ -132,4 +144,16 @@ class BatteryBox18650(Item): centered=(True, True, False), combine=True, ) + .copyWorkplane(Cq.Workplane('XY')) ) + hole = Cq.Solid.makeCylinder( + radius=self.diam_thread/2, + height=self.thickness, + ) + result -= hole.moved(0, self.hole_dy) + result -= hole.moved(0, -self.hole_dy) + result.tagAbsolute("holeT0", (0, self.hole_dy, self.thickness), direction="+Z") + result.tagAbsolute("holeT1", (0, -self.hole_dy, self.thickness), direction="+Z") + result.tagAbsolute("holeB0", (0, self.hole_dy, 0), direction="-Z") + result.tagAbsolute("holeB1", (0, -self.hole_dy, 0), direction="-Z") + return result diff --git a/nhf/parts/fasteners.py b/nhf/parts/fasteners.py index ab79802..492331f 100644 --- a/nhf/parts/fasteners.py +++ b/nhf/parts/fasteners.py @@ -11,6 +11,7 @@ class FlatHeadBolt(Item): height_head: float diam_thread: float height_thread: float + pitch: float = 1.0 @property def name(self) -> str: @@ -34,7 +35,7 @@ class FlatHeadBolt(Item): centered=(True, True, False)) ) rod.faces("Z").tag("root") + rod.tagAbsolute("root", (0, 0, self.height_thread), direction="-Z") rod = rod.union(head.located(Cq.Location((0, 0, self.height_thread)))) return rod diff --git a/nhf/touhou/yasaka_kanako/README.md b/nhf/touhou/yasaka_kanako/README.md new file mode 100644 index 0000000..1e4ff0c --- /dev/null +++ b/nhf/touhou/yasaka_kanako/README.md @@ -0,0 +1,3 @@ +# Yasaka Kanako + +This cosplay won a Judge's favourite award at TouhouFest 2025. diff --git a/nhf/touhou/yasaka_kanako/__init__.py b/nhf/touhou/yasaka_kanako/__init__.py new file mode 100644 index 0000000..63364f7 --- /dev/null +++ b/nhf/touhou/yasaka_kanako/__init__.py @@ -0,0 +1,37 @@ +import nhf.touhou.yasaka_kanako.mirror as MM +import nhf.touhou.yasaka_kanako.onbashira as MO +import nhf.touhou.yasaka_kanako.shimenawa as MS +from nhf.build import Model, TargetKind, target, assembly, submodel +import nhf.utils + +from dataclasses import dataclass, field +import cadquery as Cq + +@dataclass +class Parameters(Model): + + mirror: MM.Mirror = field(default_factory=lambda: MM.Mirror()) + onbashira: MO.Onbashira = field(default_factory=lambda: MO.Onbashira()) + shimenawa: MS.Shimenawa = field(default_factory=lambda: MS.Shimenawa()) + + def __post_init__(self): + super().__init__(name="yasaka-kanako") + + @submodel(name="mirror") + def submodel_mirror(self) -> Model: + return self.mirror + @submodel(name="onbashira") + def submodel_onbashira(self) -> Model: + return self.onbashira + @submodel(name="shimenawa") + def submodel_shimenawa(self) -> Model: + return self.shimenawa + + +if __name__ == '__main__': + import sys + + p = Parameters() + if len(sys.argv) == 1: + p.build_all() + sys.exit(0) diff --git a/nhf/touhou/yasaka_kanako/controller/controller.ino b/nhf/touhou/yasaka_kanako/controller/controller.ino new file mode 100644 index 0000000..7618e29 --- /dev/null +++ b/nhf/touhou/yasaka_kanako/controller/controller.ino @@ -0,0 +1,211 @@ +#define USE_MOTOR 1 +#define USE_LED 1 +#define USE_DISPLAY 0 + +// The mode switch button should be wired to the ground with an internal pullup resistor. +#define pinButtonMode 9 +#define pinDiag 6 + +// Main LED strip setup +#define pinLED 3 +#define NUM_LEDS 20 +#define LED_PART 10 +#define BRIGHTNESS 250 +#define LED_TYPE WS2811 + +// Relay controlled motor +#define pinMotor 7 + +#if USE_LED +#include +int cycles = 100; +int cycle_duration = 100; +CRGB leds[NUM_LEDS]; + +CRGB color_red; +CRGB color_blue; +CRGB color_green; +#endif + +#if USE_DISPLAY +#include + +#include +#include + +#define SCREEN_WIDTH 128 +#define SCREEN_HEIGHT 32 + +#define OLED_RESET -1 +Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET); + +#endif + +// Program state + +bool stateButtonMode = false; +int programId = 0; +bool programChanged = true; +#define MAX_PROGRAMS 3 +bool flag_motor = false; +bool flag_lighting = false; + + +void setup() { + pinMode(LED_BUILTIN, OUTPUT); + pinMode(pinButtonMode, INPUT_PULLUP); +#if USE_LED + // Calculate colours + 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(pinLED, OUTPUT); +#endif + pinMode(pinDiag, OUTPUT); +#if USE_MOTOR + pinMode(pinMotor, OUTPUT); +#endif + +#if USE_LED + // Main LED strip + FastLED.addLeds(leds, NUM_LEDS); + fill_solid(leds, NUM_LEDS, CRGB::White); + delay(500); + FastLED.show(); + fill_solid(leds, NUM_LEDS, CRGB::Black); + delay(500); +#endif +#if USE_DISPLAY + Serial.begin(9600); + // SSD1306_SWITCHCAPVCC = generate display voltage from 3.3V internally + if(!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) { // Address 0x3D for 128x64 + Serial.println(F("SSD1306 allocation failed")); + for(;;); // Don't proceed, loop forever + pinMode(pinLED, OUTPUT); + digitalWrite(pinLED, HIGH); + } +#endif + digitalWrite(LED_BUILTIN, HIGH); +} + +void loop() { + // Detect a rising edge + int buttonState = !digitalRead(pinButtonMode); + if (buttonState && !stateButtonMode) { + programId = (programId + 1) % MAX_PROGRAMS; + programChanged = true; + stateButtonMode = true; + } + if (!buttonState) { + stateButtonMode = false; + } + switch (programId) + { + case 0: + program_off(); + break; + case 1: + program_still(); + break; + case 2: + program_rotate(); + break; + default: + break; + } + + if (programChanged) { + update_screen(); + + digitalWrite(LED_BUILTIN, LOW); + } + programChanged = false; +} + +// Utility for updating LEDs +void fill_segmented(CRGB c1, CRGB c2) +{ +#if USE_LED + //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(); +#endif +} +void set_motor(bool flag) +{ +#if USE_MOTOR + if (flag) { + digitalWrite(pinMotor, HIGH); + flag_motor = true; + } + else { + digitalWrite(pinMotor, LOW); + flag_motor = false; + } +#endif +} + +// Update current display status +void update_screen() +{ +#if USE_DISPLAY + display.clearDisplay(); + display.setTextSize(2); + display.setTextColor(SSD1306_WHITE); + display.setCursor(0,0); + display.println("Yasaka K."); + display.print("P"); + display.print(programId); + display.print(" "); + if (flag_motor) { + display.print("M"); + } + if (flag_lighting) { + display.print("L"); + } + display.display(); +#endif +} + +void program_off() +{ + if (programChanged) + { + set_motor(false); +#if USE_LED + flag_lighting = false; + fill_solid(leds, NUM_LEDS, CRGB::Black); + FastLED.show(); +#endif + } + delay(cycle_duration); +} +void program_still() +{ + if (programChanged) + { + set_motor(false); +#if USE_LED + flag_lighting = true; + fill_segmented(CRGB::Green, CRGB::Orange); + FastLED.show(); +#endif + } + delay(cycle_duration); +} +void program_rotate() +{ + if (programChanged) + { + set_motor(true); + } +#if USE_LED + flag_lighting = true; + fill_segmented(CRGB::Green, CRGB::Orange); + delay(cycle_duration/2); + fill_solid(leds, NUM_LEDS, CRGB::Black); + FastLED.show(); + delay(cycle_duration/2); +#endif +} diff --git a/nhf/touhou/yasaka_kanako/mirror.py b/nhf/touhou/yasaka_kanako/mirror.py new file mode 100644 index 0000000..905d056 --- /dev/null +++ b/nhf/touhou/yasaka_kanako/mirror.py @@ -0,0 +1,186 @@ +from dataclasses import dataclass, field +import cadquery as Cq +from nhf.build import Model, TargetKind, target, assembly, submodel +from nhf.materials import Role, Material +import nhf.touhou.yasaka_kanako.onbashira as MO +import nhf.utils + +@dataclass +class Mirror(Model): + """ + Kanako's mirror, made of three levels. + + The mirror suface is sandwiched between two layers of wood. As such, its + dimensions have to sit in between that of the aperature on the surface, and + the outer walls. The width/height here refers to the outer edge's width and height + """ + width: float = 100.0 + height: float = 120.0 + + inner_gap: float = 3.0 + outer_gap: float = 3.0 + + core_thickness: float = 25.4 / 8 + casing_thickness: float = 25.4 / 8 + + flange_r0: float = 8.0 + flange_r1: float = 20.0 + flange_y1: float = 12.0 + flange_y2: float = 25.0 + flange_hole_r: float = 8.0 + wing_x1: float = 15.0 + wing_x2: float = 24.0 + wing_r1: float = 10.0 + wing_r2: float = 16.0 + tail_r0: float = 8.0 + tail_r1: float = 13.0 + tail_y1: float = 16.0 + tail_y2: float = 29.0 + + # Necklace hole + hole_diam: float = 5.0 + + material_mirror: Material = Material.ACRYLIC_TRANSPARENT + material_casing: Material = Material.WOOD_BIRCH + + @target(name="core", kind=TargetKind.DXF) + def profile_core(self) -> Cq.Sketch: + rx = self.width/2 - self.outer_gap + ry = self.height/2 - self.outer_gap + return Cq.Sketch().ellipse(rx, ry) + + def core(self) -> Cq.Workplane: + return ( + Cq.Workplane() + .placeSketch(self.profile_core()) + .extrude(self.core_thickness) + ) + + @target(name="casing-bot", kind=TargetKind.DXF) + def profile_casing_bot(self) -> Cq.Sketch: + """ + Base of the casing with no holes carved out + """ + yt = self.height / 2 - self.outer_gap + yh = (self.flange_y1 + self.flange_y2) / 2 + flange = ( + Cq.Sketch() + .polygon([ + (self.flange_r0, yt), + (self.flange_r0, yt + self.flange_y1), + (self.flange_r1, yt + self.flange_y1), + (self.flange_r1, yt + self.flange_y2), + (-self.flange_r1, yt + self.flange_y2), + (-self.flange_r1, yt + self.flange_y1), + (-self.flange_r0, yt + self.flange_y1), + (-self.flange_r0, yt), + ]) + .push([ + (self.flange_hole_r, yt+yh), + (-self.flange_hole_r, yt+yh), + ]) + .circle(self.hole_diam/2, mode="s") + ) + tail = ( + Cq.Sketch() + .polygon([ + (+self.tail_r0, -yt), + (+self.tail_r0, -yt - self.tail_y1), + (+self.tail_r1, -yt - self.tail_y1), + (+self.tail_r1, -yt - self.tail_y2), + (-self.tail_r1, -yt - self.tail_y2), + (-self.tail_r1, -yt - self.tail_y1), + (-self.tail_r0, -yt - self.tail_y1), + (-self.tail_r0, -yt), + ]) + ) + return ( + Cq.Sketch() + .ellipse(self.width/2, self.height/2) + .boolean(flange, mode="a") + .boolean(tail, mode="a") + .boolean(self.profile_wing(-1), mode="a") + .boolean(self.profile_wing(1), mode="a") + ) + def casing_bot(self) -> Cq.Workplane: + return ( + Cq.Workplane() + .placeSketch(self.profile_casing_bot()) + .extrude(self.casing_thickness) + ) + def profile_wing(self, sign: float=1) -> Cq.Sketch: + xt = self.width / 2 - self.outer_gap + return ( + Cq.Sketch() + .polygon([ + (sign*xt, self.wing_r1), + (sign*(xt+self.wing_x1), self.wing_r1), + (sign*(xt+self.wing_x1), self.wing_r2), + (sign*(xt+self.wing_x2), self.wing_r2), + (sign*(xt+self.wing_x2), -self.wing_r2), + (sign*(xt+self.wing_x1), -self.wing_r2), + (sign*(xt+self.wing_x1), -self.wing_r1), + (sign*xt, -self.wing_r1), + ]) + ) + @target(name="casing-mid", kind=TargetKind.DXF) + def profile_casing_mid(self) -> Cq.Sketch: + rx = self.width/2 - self.outer_gap + ry = self.height/2 - self.outer_gap + return ( + self.profile_casing_bot() + .ellipse(rx, ry, mode="s") + ) + def casing_mid(self) -> Cq.Workplane: + return ( + Cq.Workplane() + .placeSketch(self.profile_casing_mid()) + .extrude(self.core_thickness) + ) + @target(name="casing-top", kind=TargetKind.DXF) + def profile_casing_top(self) -> Cq.Sketch: + rx = self.width/2 - self.outer_gap - self.inner_gap + ry = self.height/2 - self.outer_gap - self.inner_gap + return ( + self.profile_casing_bot() + .ellipse(rx, ry, mode="s") + ) + def casing_top(self) -> Cq.Workplane: + return ( + Cq.Workplane() + .placeSketch(self.profile_casing_top()) + .extrude(self.casing_thickness) + ) + + @assembly() + def assembly(self) -> Cq.Assembly: + return ( + Cq.Assembly() + .addS( + self.core(), + name="core", + material=self.material_mirror, + role=Role.DECORATION, + loc=Cq.Location(0, 0, self.casing_thickness) + ) + .addS( + self.casing_bot(), + name="casing_bot", + material=self.material_casing, + role=Role.CASING, + ) + .addS( + self.casing_mid(), + name="casing_mid", + material=self.material_casing, + role=Role.CASING, + loc=Cq.Location(0, 0, self.casing_thickness) + ) + .addS( + self.casing_top(), + name="casing_top", + material=self.material_casing, + role=Role.CASING, + loc=Cq.Location(0, 0, self.core_thickness + self.casing_thickness) + ) + ) diff --git a/nhf/touhou/yasaka_kanako/onbashira.py b/nhf/touhou/yasaka_kanako/onbashira.py new file mode 100644 index 0000000..7525663 --- /dev/null +++ b/nhf/touhou/yasaka_kanako/onbashira.py @@ -0,0 +1,2561 @@ +from nhf.build import Model, TargetKind, target, assembly, submodel +from nhf.materials import Role, Material +import nhf.utils +from nhf.parts.fasteners import FlatHeadBolt, HexNut, Washer +from nhf.parts.electronics import ArduinoUnoR3, BatteryBox18650 + +from typing import Optional, Union +import math +from dataclasses import dataclass, field +import cadquery as Cq + +def has_part(li: Optional[list[str]], name: Union[str, list[str]]) -> bool: + """ + Check if a part exists in a name list + """ + if li: + if isinstance(name, list): + return all(n in li for n in name) + else: + return name in li + else: + return True + +NUT_COMMON = HexNut( + # FIXME: weigh + mass=0.0, + diam_thread=6.0, + pitch=1.0, + thickness=5.0, + width=9.89, +) +WASHER_COMMON = Washer( + # FIXME: weigh + mass=0.0, + diam_thread=6.0, + diam_outer=11.68, + thickness=1.5, +) +BOLT_COMMON = FlatHeadBolt( + # FIXME: weigh + mass=0.0, + diam_head=12.8, + height_head=2.8, + diam_thread=6.0, + height_thread=30.0, + pitch=1.0, +) +BOLT_LONG = FlatHeadBolt( + # FIXME: weigh + mass=0.0, + diam_head=12.8, + height_head=2.8, + diam_thread=6.0, + height_thread=50.0, + pitch=1.0, +) +BOLT_BEARING = FlatHeadBolt( + # FIXME: weigh + mass=0.0, + diam_head=12.8, + height_head=2.8, + diam_thread=4.0, + height_thread=30.0, + pitch=1.0, +) + +@dataclass(frozen=True) +class Display(Model): + thickness: float = 2.5 + length: float = 38.0 + width: float = 12.0 + +@dataclass(frozen=True) +class FlangeCoupler(Model): + + diam_thread: float = 8.0 + diam_inner: float = 16.0 + diam_outer: float = 32.0 + height: float = 12.0 + height_flange: float = 2.0 + height_hole: float = 7.0 + + diam_thread_flange: float = 4.0 + n_hole_flange: int = 4 + r_hole_flange: float = 12.0 # FIXME: Measure! + + def generate(self) -> Cq.Workplane: + result = ( + Cq.Workplane() + .cylinder( + radius=self.diam_outer/2, + height=self.height_flange, + centered=(True, True, False), + ) + .faces(">Z") + .cylinder( + radius=self.diam_inner/2, + height=self.height - self.height_flange, + centered=(True, True, False), + ) + .faces(">Z") + .hole(self.diam_thread) + ) + holes = ( + Cq.Workplane() + .sketch() + .regularPolygon( + self.r_hole_flange, + self.n_hole_flange, + mode="c", + tag="holes", + ) + .vertices(tag="holes") + .circle(self.diam_thread_flange/2) + .finalize() + .extrude(self.height_flange) + ) + hole_subtractor = Cq.Solid.makeCylinder( + radius=self.diam_thread_flange/2, + height=self.diam_inner, + pnt=(-self.diam_inner/2, 0, self.height_hole), + dir=(1, 0, 0) + ) + result -= holes + result -= hole_subtractor + result.tagAbsolute("top", (0, 0, self.height), direction="+Z") + result.tagAbsolute("bot", (0, 0, 9), direction="-Z") + for i in range(self.n_hole_flange): + loc = Cq.Location.rot2d(i * 360 / self.n_hole_flange) * Cq.Location(self.r_hole_flange, 0) + result.tagAbsolute(f"holeT{i}", loc * Cq.Location(0, 0, self.height_flange), direction="+Z") + result.tagAbsolute(f"holeB{i}", loc, direction="-Z") + result.tagAbsolute("dir", (0, 0, self.height_hole), direction="+X") + return result + + +@dataclass(frozen=True) +class Motor(Model): + """ + Drive motor for the main barrel + """ + + mass: float = 589.7 + voltage: float = 12.0 # V + power: float = 30.0 # watts + + diam_thread: float = 4.0 + diam_shaft: float = 8.0 + diam_body: float = 51.0 + height_body: float = 83.5 + diam_ring: float = 25.93 + height_ring: float = 6.55 + height_hole: float = 10.0 + height_shaft: float = 13.0 + height_base_shaft: float = 24.77 + # Distance between anchor and the body + dx_anchor: float = 20.0 + height_anchor: float = 10.4 + + def __post_init__(self): + assert self.diam_ring < self.diam_body + assert self.height_ring < self.height_body + assert self.dx_anchor < self.diam_body / 2 + pass + + @property + def dist_mount_hole(self): + """ + Distance between mount point and shaft + """ + return self.height_hole + self.height_ring + @property + def dist_mount_rotor(self): + """ + Distance between mount point and shaft + """ + return self.height_base_shaft + self.height_ring + + def generate(self) -> Cq.Workplane: + result = ( + Cq.Workplane() + .cylinder( + radius=self.diam_body/2, + height=self.height_body - self.height_ring, + centered=(True, True, False) + ) + .faces(">Z") + .cylinder( + radius=self.diam_ring/2, + height=self.height_ring, + centered=(True, True, False) + ) + ) + hole_subtractor = Cq.Solid.makeCylinder( + radius=self.diam_thread/2, + height=self.diam_shaft, + pnt=(-self.diam_shaft/2, 0, self.height_body + self.height_hole), + dir=(1, 0, 0) + ) + base_shaft = Cq.Solid.makeCylinder( + radius=self.diam_shaft/2, + height=self.height_base_shaft, + pnt=(0, 0, self.height_body), + ) + shaft = Cq.Solid.makeCylinder( + radius=self.diam_shaft/2 * 0.9, + height=self.height_shaft, + pnt=(0, 0, self.height_body + self.height_base_shaft) + ) + z_anchor = self.height_body - self.height_ring + anchor = Cq.Solid.makeCylinder( + radius=self.diam_thread/2, + height=self.height_anchor, + pnt=(0, 0, z_anchor) + ) + result = result + base_shaft + shaft + anchor.moved(self.dx_anchor, 0, 0) + anchor.moved(-self.dx_anchor, 0, 0) - hole_subtractor + result.tagAbsolute("anchor1", (self.dx_anchor, 0, z_anchor), direction="+Z") + result.tagAbsolute("anchor2", (-self.dx_anchor, 0, z_anchor), direction="+Z") + result.tagAbsolute("shaft", (0, 0, self.height_body + self.height_base_shaft), direction="+Z") + result.tagAbsolute("dir", (0, 0, self.height_body + self.height_hole), direction="+X") + return result + + +@dataclass +class Onbashira(Model): + + n_side: int = 6 + # Dimensions of each side panel + side_width: float = 150.0 + + # Side panels have different lengths + side_length1: float = 200.0 + side_length2: float = 350.0 + side_length3: float = 400.0 + + side_thickness: float = 25.4 / 8 + + section1_gohei_loc: float = 30.0 + gohei_bolt_diam: float = 6.0 + # Extension from each gohei bolt's centre + front_bracket_ext: float = 6.0 + front_bracket_depth: float = 15.0 + front_bracket_thickness: float = 6.0 + + magnet_size: float = 6.0 + + # The angle joint bridges between two sets of side panels. + + # Extra thickness beyond the onbashira's body + angle_joint_thickness: float = 10.0 + # Z-axis size of each angle joint + angle_joint_depth: float = 50.0 + # Gap of each angle joint to connect the outside to the inside + angle_joint_gap: float = 8.0 + angle_joint_bolt_length: float = 50.0 + angle_joint_bolt_diam: float = BOLT_COMMON.diam_thread + angle_joint_bolt_head_diam: float = 13.0 + angle_joint_bolt_head_depth: float = 3.0 + # Position of the holes, with (0, 0) being the centre of each side + angle_joint_bolt_position: list[float] = field(default_factory=lambda: [ + (40, 10), + ]) + angle_joint_flange_thickness: float = 7.8 + angle_joint_flange_radius: float = 23.0 + angle_joint_flange_extension: float = 23.0 + + # Mating structure on the angle joint + angle_joint_conn_thickness: float = 4.0 + angle_joint_conn_depth: float = 15.0 + angle_joint_conn_width: float = 15.0 + angle_joint_bind_radius: float = 135.0 + + chamber_side_length: float = 400.0 + chamber_side_width_ex: float = 20.0 + # Circular hole to hold a switch + chamber_front_switch_diam: float = 20.0 + + # Dimensions of gun barrels + barrel_diam: float = 25.4 * 1.5 + barrel_wall_thickness: float = 25.4 / 8 + barrel_length: float = 25.4 * 12 + # Longitudinal shift + barrel_shift: float = 30.0 + + # Gap between the stator edge and the inner face of the barrel + stator_gap: float = 3.0 + # Radius from barrel centre to axis + rotation_radius: float = 64.0 + n_bearing_balls: int = 12 + # Thickness of bearing disks + bearing_thickness: float = 20.0 + bearing_track_radius: float = 97.0 + # Gap between the inner and outer bearing disks + bearing_gap: float = 10.0 + bearing_disk_gap: float = 10.0 + bearing_spindle_max_diam: float = 16.0 + bearing_spindle_ext: float = 5.0 + bearing_spindle_exterior_gap: float = 0.2 + bearing_spindle_gap: float = 0.5 + bearing_spindle_tail: float = 4.0 + bearing_spindle_tail_diam: float = 7.0 + bearing_gasket_extend: float = 12.0 + bearing_disk_thickness: float = 25.4 / 16 + + # Coupling mechanism onto the chassis + stator_coupler_width: float = 14.0 + stator_coupler_thickness: float = 30.0 + stator_coupler_thickness_inner: float = 10.0 + + stator_bind_radius: float = 117.0 + # Extra bind sites for stator to prevent warping + stator_bind_extra: int = 2 + rotor_inner_radius: float = 36.0 + rotor_bind_bolt_diam: float = BOLT_BEARING.diam_thread + rotor_bind_radius: float = 82.0 + rotor_bind_extra: int = 1 + stator_spacer_outer_diam: float = 15.0 + rotor_spacer_outer_diam: float = 12.0 + + handle_base_height: float = 10.0 + handle_thickness_x: float = 20.0 + handle_thickness_y: float = 10.0 + handle_radius: float = 20.0 + handle_height: float = 50.0 + + motor: Motor = Motor() + flange_coupler: FlangeCoupler = FlangeCoupler() + auxiliary_thickness: float = 25.4 / 8 + + battery_box: BatteryBox18650 = BatteryBox18650() + controller: ArduinoUnoR3 = ArduinoUnoR3() + controller_loc: Cq.Location = Cq.Location.from2d(-30, -35, 90) + battery_box_locs: list[Cq.Location] = field(default_factory=lambda: [ + Cq.Location.from2d(70, 0, 90), + Cq.Location.from2d(140, 0, 90), + Cq.Location.from2d(-70, 0, 90), + Cq.Location.from2d(-140, 0, 90), + ]) + + # Distance between bind point and motor's mount points + motor_driver_radius: float = 110.0 + motor_seat_depth: float = 95.0 + motor_seat_radius: float = 50.0 + motor_coupler_flange_thickness: float = 10.0 + motor_coupler_flange_radius: float = 8.0 + motor_coupler_height: float = 120.0 + motor_coupler_conn_dx: float = 30.0 + motor_coupler_wall_thickness: float = 5.0 + motor_coupler_inner_gap: float = 1.0 + turning_bar_width: float = 15.0 + electronic_mount_dx: float = 50.0 + + material_side: Material = Material.WOOD_BIRCH + material_bearing: Material = Material.PLASTIC_PLA + material_spacer: Material = Material.PLASTIC_PLA + material_bearing_ball: Material = Material.PLASTIC_PLA + material_barrel: Material = Material.ACRYLIC_BLACK + material_brace: Material = Material.PLASTIC_PLA + material_fastener: Material = Material.STEEL_STAINLESS + material_auxiliary: Material = Material.WOOD_BIRCH + + def __post_init__(self): + assert self.n_side >= 3 + # Bulk must be large enough for the barrel + bearing to rotate + assert self.bulk_radius - self.side_thickness - self.bearing_thickness > self.rotation_radius + self.barrel_diam / 2 + assert BOLT_COMMON.diam_thread < self.rotor_bind_radius < self.bearing_track_radius + assert self.rotor_inner_radius < self.bearing_track_radius < self.angle_joint_bind_radius + assert self.angle_joint_thickness > self.side_thickness + + for (x, y) in self.angle_joint_bolt_position: + assert y < self.angle_joint_depth / 2 + assert self.angle_joint_depth / 2 > self.angle_joint_conn_depth + assert self.angle_joint_thickness > self.angle_joint_conn_thickness + + # Ensure the stator could be printed on a 12x12in board + assert self.side_width * 2 < 12 * 25.4 + + assert self.barrel_wall_thickness * 2 < self.barrel_diam + + @property + def angle_side(self) -> float: + return 360 / self.n_side + @property + def ratio_side_width(self) -> float: + """ + Difference between interior and exterior side width due to side thickness + """ + theta = math.pi / self.n_side + return math.tan(theta) + @property + def delta_side_width(self) -> float: + """ + Difference between interior and exterior side width due to side thickness + """ + dt = self.side_thickness * self.ratio_side_width + return dt * 2 + @property + def side_width_inner(self) -> float: + """ + Interior side width + + If outer width is `wi`, inner width is `wo`, each side's cross section + is a trapezoid with sides `wi`, `wo`, and height `h` (side thickness) + """ + return self.side_width - self.delta_side_width + @property + def angle_joint_extra_width(self) -> float: + theta = math.pi / self.n_side + dt = self.angle_joint_thickness * math.tan(theta) + return dt * 2 + @property + def angle_joint_bind_pos(self) -> Cq.Location: + """ + Planar position of the joint bind position + """ + th = math.pi / self.n_side + x = self.angle_joint_bind_radius * math.cos(th) + y = self.angle_joint_bind_radius * math.sin(th) + return Cq.Location.from2d(x, y) + + @property + def angle_dihedral(self) -> float: + return 180 - self.angle_side + @property + def bulk_radius(self) -> float: + """ + Radius of the bulk (surface of each side) to the centre + """ + return self.side_width / 2 / math.tan(math.radians(self.angle_side / 2)) + @property + def chamber_side_width(self) -> float: + return self.side_width + self.chamber_side_width_ex + @property + def chamber_bulk_radius(self) -> float: + """ + Radius of the bulk (surface of each side) to the centre + """ + return self.chamber_side_width / 2 / math.tan(math.radians(self.angle_side / 2)) + + @target(name="sanding-block") + def sanding_block(self) -> Cq.Workplane: + # Dihedral angle / 2 + angle = math.radians(180 / self.n_side) + r = math.sin(angle) + x = 50.0 + return ( + Cq.Workplane() + .sketch() + .polygon([(0,0), (0, x), (x, (1-r) * x), (x, 0)]) + .finalize() + .extrude(self.side_width * 1.5) + ) + + @target(name="magnet-holder") + def magnet_holder(self) -> Cq.Workplane: + magnet_size = self.magnet_size + gap = 1.0 + length1 = 10.0 + width = magnet_size * 3 + height = gap + magnet_size + 1 + result = ( + Cq.Workplane() + .box( + length=length1 + magnet_size, + width=width, + height=height, + centered=(False, True, False) + ) + ) + corner_cut = Cq.Solid.makeBox( + length=height, + width=width, + height=height, + ).moved(0, -width/2, 0) - Cq.Solid.makeCylinder( + radius=height, + height=width, + pnt=(height, -width/2, 0), + dir=(0, 1, 0) + ) + box_cut = Cq.Solid.makeBox( + length=magnet_size, + width=magnet_size, + height=magnet_size + gap, + ).moved(length1, -magnet_size/2, 0) + return result - box_cut - corner_cut + + ### Motor ### + + @target(name="motor-coupler") + def motor_coupler(self) -> Cq.Workplane: + """ + Coupler which connects to each barrel to move them. + """ + x = self.motor_coupler_conn_dx + y0 = self.barrel_diam/2 + self.motor_coupler_wall_thickness + y = self.motor_coupler_flange_radius + t = self.motor_coupler_flange_thickness + flange = ( + Cq.Workplane() + .sketch() + .polygon([ + (x, y), + (0, y0), + (-x, y), + (-x, -y), + (0, -y0), + (x, -y), + ]) + .reset() + .push([ + (x, 0), (-x, 0) + ]) + .circle(y, mode="a") + .circle(BOLT_BEARING.diam_thread/2, mode="s") + .reset() + .circle(self.barrel_diam/2, mode="s") + .finalize() + .extrude(t) + ) + body = ( + Cq.Workplane() + .cylinder( + radius=self.barrel_diam/2 + self.motor_coupler_wall_thickness, + height=self.motor_coupler_height, + centered=(True, True, False) + ) + .faces(">Z") + .hole(self.barrel_diam + self.motor_coupler_inner_gap*2) + ) + result = body + flange + result.tagAbsolute("holeT1", (x, 0, t), direction="+Z") + result.tagAbsolute("holeT2", (-x, 0, t), direction="+Z") + result.tagAbsolute("holeB1", (x, 0, 0), direction="-Z") + result.tagAbsolute("holeB2", (-x, 0, 0), direction="-Z") + return result + + @target(name="motor-driver-disk", kind=TargetKind.DXF) + def profile_motor_driver_disk(self) -> Cq.Sketch: + """ + A drive disk mounts onto the motor, and extends into gun barrels to turn them. + """ + hole_diam = self.barrel_diam - self.barrel_wall_thickness * 2 + + coupler_holes = [ + Cq.Location.rot2d(i * 360 / self.n_side) * + Cq.Location.from2d(self.rotation_radius + sx * self.motor_coupler_conn_dx, 0) + for i in range(self.n_side) + for sx in (-1, 1) + ] + return ( + Cq.Sketch() + .circle(self.motor_driver_radius) + # Drill out the centre which will accomodate the motor shaft + .circle(self.motor.diam_shaft/2, mode="s") + # Drill out couplers + .reset() + .regularPolygon( + self.flange_coupler.r_hole_flange, + self.flange_coupler.n_hole_flange, + mode="c", + tag="hole", + ) + .vertices(tag="hole") + .circle(self.flange_coupler.diam_thread_flange/2, mode="s") + .reset() + .regularPolygon( + self.rotation_radius, + self.n_side, + angle=180 / self.n_side, + mode="c", + tag="const", + ) + .vertices(tag="const") + .circle(hole_diam/2, mode="s") + .reset() + # Create coupler holes + .push([ + loc.to2d_pos() + for loc in coupler_holes + ]) + .circle(BOLT_BEARING.diam_thread /2, mode="s") + ) + def motor_driver_disk(self) -> Cq.Workplane: + result = ( + Cq.Workplane() + .placeSketch(self.profile_motor_driver_disk()) + .extrude(self.auxiliary_thickness) + ) + n = self.flange_coupler.n_hole_flange + for i in range(n): + loc = Cq.Location.rot2d(i * 360 / n) * Cq.Location(self.flange_coupler.r_hole_flange, 0) + result.tagAbsolute(f"holeT{i}", loc * Cq.Location(0, 0, self.auxiliary_thickness), direction="+Z") + result.tagAbsolute(f"holeB{i}", loc, direction="-Z") + loc_z = Cq.Location(0, 0, self.auxiliary_thickness) + loc_outer = Cq.Location.from2d(self.rotation_radius + self.motor_coupler_conn_dx, 0) + loc_inner = Cq.Location.from2d(self.rotation_radius - self.motor_coupler_conn_dx, 0) + for i in range(self.n_side): + loc_rot = Cq.Location.rot2d(i * 360 / self.n_side) + p_outer, _ = (loc_z * loc_rot * loc_outer).toTuple() + p_inner, _ = (loc_z * loc_rot * loc_inner).toTuple() + result.tagAbsolute(f"holeCOF{i}", p_outer, direction="+Z") + result.tagAbsolute(f"holeCIF{i}", p_inner, direction="+Z") + return result + + @target(name="motor-mount-plate", kind=TargetKind.DXF) + def profile_motor_mount_plate(self) -> Cq.Sketch: + r = self.motor_seat_radius + gap = 10.0 + hole_dx = self.motor.dx_anchor + return ( + Cq.Sketch() + .circle(r + gap) + .circle(self.motor.diam_ring/2, mode="s") + .regularPolygon(r, self.n_side, mode="c", tag="corner") + .vertices(tag="corner") + .circle(BOLT_COMMON.diam_thread/2, mode="s") + .reset() + .push([ + (hole_dx, 0), + (-hole_dx, 0), + ]) + .circle(self.motor.diam_thread/2, mode="s") + ) + def motor_mount_plate(self) -> Cq.Workplane: + result = ( + Cq.Workplane() + .placeSketch(self.profile_motor_mount_plate()) + .extrude(self.auxiliary_thickness) + ) + result.tagAbsolute("anchor1", (self.motor.dx_anchor, 0, 0), direction="-Z") + result.tagAbsolute("anchor2", (-self.motor.dx_anchor, 0, 0), direction="-Z") + r = self.motor_seat_radius + for i in range(self.n_side): + angle = i * 360 / self.n_side + x, y = (Cq.Location.rot2d(angle) * Cq.Location.from2d(0, r)).to2d_pos() + result.tagAbsolute(f"holeF{i}", (x, y, self.auxiliary_thickness), direction="+Z") + result.tagAbsolute(f"holeB{i}", (x, -y, 0), direction="-Z") + return result + + @target(name="stator-coupler") + def stator_coupler(self) -> Cq.Workplane: + """ + Couples the stator to the chassis + """ + r1 = self.angle_joint_bind_radius + r2 = self.stator_bind_radius + assert r1 > r2 + + l = r1 - r2 + w = self.stator_coupler_width + h = self.stator_coupler_thickness + h_step = h - self.stator_coupler_thickness_inner + + intersector = Cq.Solid.makeBox( + length=l + w, + width=w, + height=h_step, + ).moved(0, -w/2, 0) + + profile = ( + Cq.Sketch() + .rect(l, w) + .push([ + (-l/2, 0), + (l/2, 0), + ]) + .circle(w/2, mode="a") + .push([ + (-l/2, 0), + ]) + .circle(BOLT_COMMON.diam_thread/2, mode="s") + .reset() + .push([ + (l/2, 0), + ]) + .circle(BOLT_BEARING.diam_thread/2, mode="s") + ) + result = ( + Cq.Workplane() + .placeSketch(profile) + .extrude(h) + ) + dx = l / 2 + result = result - intersector + result.tagAbsolute(f"holeOB", (-dx, 0, 0), direction="-Z") + result.tagAbsolute(f"holeIB", (+dx, 0, h_step), direction="-Z") + result.tagAbsolute(f"holeOF", (-dx, 0, h), direction="+Z") + result.tagAbsolute(f"holeIF", (+dx, 0, h), direction="+Z") + return result + + @target(name="bearing-stator", kind=TargetKind.DXF) + def profile_bearing_stator(self) -> Cq.Sketch: + assert self.stator_bind_radius < self.angle_joint_bind_radius + return ( + Cq.Sketch() + .circle(self.bulk_radius - self.side_thickness - self.stator_gap) + #.regularPolygon(self.side_width - self.side_thickness - self.stator_gap, self.n_side*2) + .circle(self.bearing_track_radius + self.bearing_gap/2, mode="s") + .reset() + .regularPolygon( + self.stator_bind_radius, self.n_side * (1 + self.stator_bind_extra), + mode="c", tag="bolt") + .vertices(tag="bolt") + .circle(self.rotor_bind_bolt_diam/2, mode="s") + ) + def bearing_stator(self) -> Cq.Workplane: + result = ( + Cq.Workplane() + .placeSketch(self.profile_bearing_stator()) + .extrude(self.bearing_disk_thickness) + ) + br = self.stator_bind_radius + th1 = math.radians(360 / self.n_side) + th2 = math.radians(360 / (self.n_side * (1 + self.stator_bind_extra))) + for i in range(self.n_side): + angle = (i+0.5) * th1 + result.faces(">Z").moveTo( + br * math.cos(-angle), + br * math.sin(-angle), + ).tagPlane(f"holeF{i}") + result.faces(" float: + return self.bearing_track_radius - self.bearing_gap/2 + @target(name="bearing-rotor", kind=TargetKind.DXF) + def profile_bearing_rotor(self) -> Cq.Sketch: + bolt_angle = (180 / self.n_side) * 1.5 + n_binds = 1 + self.rotor_bind_extra + return ( + Cq.Sketch() + .circle(self.rotor_radius) + .circle(self.rotor_inner_radius, mode="s") + .reset() + .regularPolygon( + self.rotation_radius, self.n_side, + mode="c", tag="corners") + .vertices(tag="corners") + .circle(self.barrel_diam/2, mode="s") + .reset() + .regularPolygon( + r=self.rotor_bind_radius, + n=self.n_side * n_binds, + mode="c", tag="bolt", + angle=bolt_angle) + .vertices(tag="bolt") + .circle(self.rotor_bind_bolt_diam/2, mode="s") + ) + def bearing_rotor(self) -> Cq.Workplane: + return ( + Cq.Workplane() + .placeSketch(self.profile_bearing_rotor()) + .extrude(self.bearing_disk_thickness) + ) + @target(name="bearing-gasket", kind=TargetKind.DXF) + def profile_bearing_gasket(self) -> Cq.Sketch: + dr = self.bearing_gasket_extend + eps = 0.05 + return ( + Cq.Sketch() + .circle(self.bearing_track_radius + dr) + .circle(self.bearing_track_radius - dr, mode="s") + .reset() + .regularPolygon( + self.bearing_track_radius, self.n_bearing_balls, + mode="c", tag="corners") + .vertices(tag="corners") + .circle(BOLT_BEARING.diam_thread/2, mode="s") + ) + def bearing_gasket(self) -> Cq.Workplane: + return ( + Cq.Workplane() + .placeSketch(self.profile_bearing_gasket()) + .extrude(self.bearing_disk_thickness) + ) + + + @target(name="stator-spacer") + def stator_spacer(self) -> Cq.Solid: + outer = Cq.Solid.makeCylinder( + radius=self.stator_spacer_outer_diam/2, + height=self.bearing_disk_gap, + ) + inner = Cq.Solid.makeCylinder( + radius=self.rotor_bind_bolt_diam/2, + height=self.bearing_disk_gap + ) + return outer - inner + @target(name="rotor-spacer") + def rotor_spacer(self) -> Cq.Solid: + outer = Cq.Solid.makeCylinder( + radius=self.rotor_spacer_outer_diam/2, + height=self.bearing_disk_gap, + ) + inner = Cq.Solid.makeCylinder( + radius=BOLT_BEARING.diam_thread/2, + height=self.bearing_disk_gap + ) + return outer - inner + + @property + def bearing_spindle_height(self) -> float: + h = self.bearing_disk_gap + 2 * self.bearing_disk_thickness + return h + self.bearing_spindle_ext * 2 + + @target(name="bearing-spindle") + def bearing_spindle(self) -> Cq.Solid: + r1 = (self.bearing_gap - self.bearing_spindle_exterior_gap) / 2 + r2 = self.bearing_spindle_max_diam / 2 + h = self.bearing_disk_gap + h2 = self.bearing_spindle_ext + self.bearing_disk_thickness - self.bearing_spindle_tail + cone = Cq.Solid.makeCone( + radius1=r2, + radius2=r1, + height=h/2, + ) + cyl = Cq.Solid.makeCylinder( + radius=r1, + height=h2, + pnt=(0, 0, h/2) + ) + tail = Cq.Solid.makeCone( + radius1=r1, + radius2=self.bearing_spindle_tail_diam/2, + height=self.bearing_spindle_tail, + pnt=(0, 0, h/2 + h2), + ) + assert self.bearing_spindle_tail < self.bearing_spindle_ext + assert self.bearing_spindle_tail_diam > BOLT_BEARING.diam_thread + hole = Cq.Solid.makeCylinder( + radius=(BOLT_BEARING.diam_thread + self.bearing_spindle_gap)/2, + height=self.bearing_spindle_height + ).moved(0, 0, -self.bearing_spindle_height/2) + top = cone + cyl + tail - hole + return top + top.mirror("XY") + + def barrel(self) -> Cq.Compound: + """ + One gun barrel + """ + outer = Cq.Solid.makeCylinder( + radius=self.barrel_diam/2, + height=self.barrel_length, + ) + inner = Cq.Solid.makeCylinder( + radius=self.barrel_diam/2-self.barrel_wall_thickness, + height=self.barrel_length + ) + return outer - inner + + @assembly() + def assembly_machine(self) -> Cq.Assembly: + """ + The assembly with gun barrels + """ + z_lower = -self.bearing_disk_gap/2 - self.bearing_disk_thickness + gasket_h = self.bearing_spindle_height / 2 + a = ( + Cq.Assembly() + .addS( + self.bearing_stator(), + name="stator1", + material=self.material_bearing, + role=Role.STATOR, + loc=Cq.Location(0, 0, self.bearing_disk_gap/2) + ) + .addS( + self.bearing_rotor(), + name="rotor1", + material=self.material_bearing, + role=Role.ROTOR, + loc=Cq.Location(0, 0, self.bearing_disk_gap/2) + ) + .addS( + self.bearing_stator(), + name="stator2", + material=self.material_bearing, + role=Role.STATOR, + loc=Cq.Location(0, 0, z_lower) + ) + .addS( + self.bearing_rotor(), + name="rotor2", + material=self.material_bearing, + role=Role.ROTOR, + loc=Cq.Location(0, 0, z_lower) + ) + .addS( + self.bearing_gasket(), + name="gasket_bot", + material=self.material_bearing, + role=Role.ROTOR, + loc=Cq.Location(0, 0, -gasket_h-self.bearing_disk_thickness) + ) + .addS( + self.bearing_gasket(), + name="gasket_top", + material=self.material_bearing, + role=Role.ROTOR, + loc=Cq.Location(0, 0, gasket_h) + ) + ) + z = -self.bearing_disk_gap/2 + da_bind_stator = 360 / self.n_side + da_bind_rotor = 360 / self.n_side + da_bind_stator_minor = 360 / self.n_side / (1 + self.stator_bind_extra) + da_bind_rotor_minor = 360 / self.n_side / (1 + self.rotor_bind_extra) + for i in range(self.n_side): + loc_barrel = Cq.Location.rot2d((i+1/2) * 360/self.n_side) * \ + Cq.Location(self.rotation_radius, 0, self.barrel_shift-self.barrel_length/2) + a = a.addS( + self.barrel(), + name=f"barrel{i}", + material=self.material_barrel, + role=Role.DECORATION, + loc=loc_barrel, + ) + for j in range(1 + self.rotor_bind_extra): + angle = i * da_bind_rotor + (j+0.5) * da_bind_rotor_minor + loc = Cq.Location.rot2d(angle) * Cq.Location(self.rotor_bind_radius, 0, z) + a = a.addS( + self.rotor_spacer(), + name=f"spacer_rotor{i}_{j}", + material=self.material_spacer, + role=Role.STRUCTURE, + loc=loc + ) + for j in range(1 + self.stator_bind_extra): + angle = i * da_bind_stator + (j+0.5) * da_bind_stator_minor + loc = Cq.Location.rot2d(angle) * Cq.Location(self.stator_bind_radius, 0, z) + a = a.addS( + self.stator_spacer(), + name=f"spacer_stator{i}_{j}", + material=self.material_spacer, + role=Role.STRUCTURE, + loc=loc + ) + for i in range(self.n_bearing_balls): + ball = self.bearing_spindle() + loc = Cq.Location.rot2d(i * 360/self.n_bearing_balls) * Cq.Location(self.bearing_track_radius, 0, 0) + a = a.addS( + ball, + name=f"bearing_spindle{i}", + material=self.material_bearing_ball, + role=Role.BEARING, + loc=loc, + ) + return a + + ### Motor ### + + @target(name="motor-seat") + def motor_seat(self) -> Cq.Workplane: + """ + Create new longitudinal mount points closer to the centre axis, and a + ring for mounting lights + """ + bx, by = self.angle_joint_bind_pos.to2d_pos() + gap = 7 + t1 = 10 + base_w = 17.0 + theta = math.pi / self.n_side + theta2 = theta * 0.7 + theta1 = theta * 1.3 + cover_thickness = 4.0 + track_width = 7.0 + r0 = self.bulk_radius + r1 = self.rotation_radius + gap + r2 = self.rotation_radius - gap + profile_arc = ( + Cq.Sketch() + .circle(r1) + .circle(r2, mode="s") + .polygon([ + (0, 0), + (r0 * math.cos(theta1), r0 * math.sin(theta1)), + (r0 * math.cos(theta1), -r0 * math.sin(theta1)), + ], mode="i") + ) + profile_base = ( + profile_arc + .reset() + .polygon([ + (r1 * math.cos(theta1), r1 * math.sin(theta1)), + (r1 * math.cos(theta2), r1 * math.sin(theta2)), + (bx, by - base_w/2), + (bx, by + base_w/2), + ]) + .polygon([ + (r1 * math.cos(theta1), -r1 * math.sin(theta1)), + (r1 * math.cos(theta2), -r1 * math.sin(theta2)), + (bx, -by + base_w/2), + (bx, -by - base_w/2), + ]) + .reset() + .push([ + (bx, by), (bx, -by), + ]) + .circle(base_w/2, mode="a") + .reset() + .push([ + (bx, by), (bx, -by), + ]) + .circle(BOLT_COMMON.diam_thread/2, mode="s") + ) + base = ( + Cq.Workplane() + .placeSketch(profile_base) + .extrude(t1) + ) + r3 = self.motor_seat_radius + r2_5 = r3 + BOLT_COMMON.diam_thread/2 + mount_x = r3 * math.cos(theta) + mount_y = r3 * math.sin(theta) + front = ( + Cq.Workplane() + .sketch() + .circle(r1) + .circle(r2_5, mode="s") + .polygon([ + (0, 0), + (r0 * math.cos(theta1), r0 * math.sin(theta1)), + (r0 * math.cos(theta1), -r0 * math.sin(theta1)), + ], mode="i") + .push([ + (mount_x, mount_y), + (mount_x, -mount_y), + ]) + .circle(base_w/2) + .circle(BOLT_COMMON.diam_thread/2, mode="s") + .finalize() + .extrude(t1) + ) + channel = ( + Cq.Workplane() + .sketch() + .circle(self.rotation_radius+track_width/2) + .circle(self.rotation_radius-track_width/2, mode="s") + .finalize() + .extrude(t1) + .translate((0, 0, self.motor_seat_depth - t1)) + ) + channel_cover = ( + Cq.Workplane() + .sketch() + .circle(self.rotation_radius+track_width/2) + .circle(self.rotation_radius-track_width/2, mode="s") + .polygon([ + (0, 0), + (r0 * math.cos(theta1), r0 * math.sin(theta1)), + (r0 * math.cos(theta2), r0 * math.sin(theta2)), + ], mode="i") + .finalize() + .extrude(cover_thickness) + .translate((0, 0, self.motor_seat_depth - cover_thickness)) + ) + + # Construct the connection between the front and back + + x11 = r1 * math.cos(theta1) + y11 = r1 * math.sin(theta1) + x21 = r1 * math.cos(theta2) + y21 = r1 * math.sin(theta2) + x12 = bx + base_w/2 * math.sin(-math.pi * 0.3) + y12 = by + base_w/2 * math.cos(-math.pi * 0.3) + x22 = bx + y22 = by - base_w/2 + a1 = .8 + a2 = .95 + profile_bridge_outer_base = ( + Cq.Sketch() + .polygon([ + ((1 - a1) * x11 + a1 * x12, (1 - a1) * y11 + a1 * y12), + ((1 - a1) * x21 + a1 * x22, (1 - a1) * y21 + a1 * y22), + ((1 - a2) * x21 + a2 * x22, (1 - a2) * y21 + a2 * y22), + ((1 - a2) * x11 + a2 * x12, (1 - a2) * y11 + a2 * y12), + ]) + .wires() + .val() + .moved(0, 0, t1) + ) + profile_bridge_outer_top = ( + Cq.Sketch() + .circle(r1) + .circle(self.rotation_radius, mode="s") + .polygon([ + (0, 0), + (r0 * math.cos(theta1), r0 * math.sin(theta1)), + (r0 * math.cos(theta2), r0 * math.sin(theta2)), + ], mode="i") + .wires() + .val() + .moved(0, 0, self.motor_seat_depth) + ) + profile_bridge_inner_base = ( + Cq.Sketch() + .circle(r1) + .circle(r2, mode="s") + .polygon([ + (0, 0), + (r0 * math.cos(theta1), r0 * math.sin(theta1)), + (r0 * math.cos(theta2), r0 * math.sin(theta2)), + ], mode="i") + .wires() + .val() + ) + profile_bridge_inner_top = ( + Cq.Sketch() + .circle(r1) + .circle(r2, mode="s") + .polygon([ + (0, 0), + (r0 * math.cos(theta1), r0 * math.sin(theta1)), + (r0 * math.cos(theta2), r0 * math.sin(theta2)), + ], mode="i") + .wires() + .val() + .moved(0, 0, self.motor_seat_depth - t1) + ) + bridge_outer = Cq.Solid.makeLoft([profile_bridge_outer_base, profile_bridge_outer_top]) + bridge_inner = Cq.Solid.makeLoft([profile_bridge_inner_base, profile_bridge_inner_top]) + hole_subtractor = Cq.Solid.makeCylinder( + radius=BOLT_COMMON.diam_thread/2, + height=t1, + pnt=((r1+r2)/2, 0, 0) + ) + result = ( + base + + front.translate((0, 0, self.motor_seat_depth - t1)) + + bridge_outer + + bridge_outer.mirror("XZ") + + bridge_inner + + bridge_inner.mirror("XZ") + - hole_subtractor + - channel + + channel_cover + + channel_cover.mirror("XZ") + ) + + # Mark the mount points + result.tagAbsolute("holeBB1", (bx, +by, 0), direction="-Z") + result.tagAbsolute("holeBB2", (bx, -by, 0), direction="-Z") + result.tagAbsolute("holeMF1", (mount_x, +mount_y, self.motor_seat_depth), direction="+Z") + result.tagAbsolute("holeMF2", (mount_x, -mount_y, self.motor_seat_depth), direction="+Z") + + return result + + @assembly() + def assembly_motor(self) -> Cq.Assembly: + a = ( + Cq.Assembly() + .addS( + self.motor.generate(), + name="motor", + role=Role.MOTOR, + ) + .addS( + self.flange_coupler.generate(), + name="flange_coupler", + role=Role.CONNECTION | Role.STRUCTURE, + material=self.material_fastener, + ) + .addS( + self.motor_driver_disk(), + name="driver_disk", + role=Role.CONNECTION | Role.STRUCTURE, + material=self.material_auxiliary, + ) + .addS( + self.motor_mount_plate(), + name="mount_plate", + role=Role.CONNECTION | Role.STRUCTURE, + material=self.material_auxiliary, + ) + .constrain( + "mount_plate?anchor1", + "motor?anchor1", + "Plane", + ) + .constrain( + "mount_plate?anchor2", + "motor?anchor2", + "Plane", + ) + .constrain( + "flange_coupler?top", + "motor?shaft", + "Axis" + ) + .constrain( + "flange_coupler?dir", + "motor?dir", + "Plane", + param=0, + ) + ) + for i in range(self.flange_coupler.n_hole_flange): + j = self.flange_coupler.n_hole_flange - i - 1 + a = a.constrain( + f"flange_coupler?holeB{i}", + f"driver_disk?holeB{j}", + "Plane", + ) + + # Add the motor seats + assert self.n_side % 2 == 0 + for i in range(self.n_side // 2): + name_seat = f"seat{i}" + a = ( + a.addS( + self.motor_seat(), + name=name_seat, + role=Role.STRUCTURE, + material=self.material_brace + ) + .constrain( + f"{name_seat}?holeMF1", + f"mount_plate?holeB{i*2}", + "Plane" + ) + .constrain( + f"{name_seat}?holeMF2", + f"mount_plate?holeB{i*2+1}", + "Plane" + ) + ) + for i in range(self.n_side): + name_coupler = f"coupler{i}" + a = ( + a.addS( + self.motor_coupler(), + name=name_coupler, + role=Role.CONNECTION, + material=self.material_brace, + ) + .constrain( + f"{name_coupler}?holeB1", + f"driver_disk?holeCOF{i}", + "Plane", + ) + .constrain( + f"{name_coupler}?holeB2", + f"driver_disk?holeCIF{i}", + "Plane", + ) + ) + return a.solve() + + ### Electronics ### + + @property + def turning_bar_hole_dy(self) -> float: + """ + Distance between centre of mounting holes in the turning bar and top of + the side panels. + """ + panel_to_mount = self.angle_joint_flange_thickness / 2 - self.angle_joint_gap / 2 + return panel_to_mount + self.turning_bar_width / 2 + + @target(name="turning-bar") + def turning_bar(self) -> Cq.Workplane: + """ + Converts the longitudinal/axial mount points on angle joints to + transverse mount points to make them more suitable for electronics. + """ + _, dx = self.angle_joint_bind_pos.to2d_pos() + t = 8 + w = self.turning_bar_width + result = ( + Cq.Workplane() + .box( + length=dx*2 + w, + width=w, + height=t, + centered=(True, True, False) + ) + ) + flange = Cq.Solid.makeBox( + length=w, + width=t, + height=w/2, + ).moved(-w/2, -t, -w/2) + Cq.Solid.makeCylinder( + radius=w/2, + height=t, + pnt=(0, -t, -w/2), + dir=(0, 1, 0), + ) + remover = Cq.Solid.makeCylinder( + radius=BOLT_COMMON.diam_thread/2, + height=w, + ) + removerf = Cq.Solid.makeCylinder( + radius=BOLT_COMMON.diam_thread/2, + height=w*2, + pnt=(0, -w, -w/2), + dir=(0, 1, 0), + ) + dxe = self.electronic_mount_dx + result = ( + result + + flange.moved(dx, w/2, 0) + + flange.moved(-dx, w/2, 0) + - remover.moved(dxe, 0, 0) + - remover.moved(-dxe, 0, 0) + - removerf.moved(dx, 0, 0) + - removerf.moved(-dx, 0, 0) + ) + result.tagAbsolute("holeBO1", (dx, w/2, -w/2), direction="+Y") + result.tagAbsolute("holeBO2", (-dx, w/2, -w/2), direction="+Y") + result.tagAbsolute("holeMO1", (dxe, 0, t)) + result.tagAbsolute("holeMO2", (-dxe, 0, t)) + return result + + @target(name="electronics-panel1", kind=TargetKind.DXF) + def profile_electronics_panel1(self) -> Cq.Sketch: + hole_dy = self.turning_bar_hole_dy + hole_dx = self.electronic_mount_dx + l = self.side_length3 - hole_dy * 2 + 12 + y = self.side_length3 - hole_dy * 2 + w = self.side_width + controller_holes = [ + self.controller_loc * Cq.Location.from2d(*h).flip_y() + for h in self.controller.holes + ] + battery_box_holes = [ + loc * h + for h in self.battery_box.holes + for loc in self.battery_box_locs + ] + profile = ( + Cq.Sketch() + .rect(l, w) + .rect(y, hole_dx * 2, mode="c", tag="corner") + .vertices(tag="corner") + .circle(BOLT_COMMON.diam_thread/2, mode="s") + .reset() + .push([ + h.to2d_pos() for h in controller_holes + ] + [ + h.to2d_pos() for h in battery_box_holes + ]) + .circle(self.controller.hole_diam/2, mode="s") + ) + return profile + + def electronics_panel1(self) -> Cq.Workplane: + hole_dy = self.turning_bar_hole_dy + hole_dx = self.electronic_mount_dx + l = self.side_length3 + t = self.side_thickness + result = ( + Cq.Workplane() + .placeSketch(self.profile_electronics_panel1()) + .extrude(t) + ) + x = l/2 - hole_dy + for side, z, d in [("T", t, "+Z"), ("B", 0, "-Z")]: + result.tagAbsolute(f"holeLP{side}", (-x, hole_dx, z), direction=d) + result.tagAbsolute(f"holeLS{side}", (-x, -hole_dx, z), direction=d) + result.tagAbsolute(f"holeRP{side}", (x, -hole_dx, z), direction=d) + result.tagAbsolute(f"holeRS{side}", (x, hole_dx, z), direction=d) + for (i, h) in enumerate(self.controller.holes): + loc = self.controller_loc * Cq.Location.from2d(*h).flip_y() + hx, hy = loc.to2d_pos() + result.tagAbsolute(f"holeController{i}", (hx, hy, t), direction="+Z") + for (j, loc) in enumerate(self.battery_box_locs): + for (i, h) in enumerate(self.battery_box.holes): + loch = loc * h + hx, hy = loch.to2d_pos() + result.tagAbsolute(f"holeBB{j}_{i}", (hx, hy, t), direction="+Z") + return result + + @assembly() + def assembly_electronics1(self) -> Cq.Assembly: + name_barL = "barL" + name_barR = "barR" + name_panel = "panel" + name_controller = "controller" + a = ( + Cq.Assembly() + .addS( + self.turning_bar(), + name=name_barL, + material=self.material_brace, + role=Role.STRUCTURE, + ) + .addS( + self.turning_bar(), + name=name_barR, + material=self.material_brace, + role=Role.STRUCTURE, + ) + .addS( + self.electronics_panel1(), + name=name_panel, + material=self.material_auxiliary, + role=Role.STRUCTURE, + ) + .add( + self.controller.assembly(), + name=name_controller, + ) + .constrain( + f"{name_panel}?holeLPB", + f"{name_barL}?holeMO1", + "Plane" + ) + .constrain( + f"{name_panel}?holeLSB", + f"{name_barL}?holeMO2", + "Plane" + ) + .constrain( + f"{name_panel}?holeRPB", + f"{name_barR}?holeMO1", + "Plane" + ) + .constrain( + f"{name_panel}?holeRSB", + f"{name_barR}?holeMO2", + "Plane" + ) + ) + for i in range(len(self.controller.holes)): + a = a.constrain( + f"{name_panel}?holeController{i}", + f"{name_controller}?conn{i}", + "Plane", + ) + for j in range(len(self.battery_box_locs)): + name_box = f"battery_box{j}" + a = a.add( + self.battery_box.assembly(), + name=name_box + ) + for i in range(len(self.battery_box.holes)): + a = a.constrain( + f"{name_panel}?holeBB{j}_{i}", + f"{name_box}?holeB{i}", + "Plane", + ) + return a.solve() + + ### Side Panels + + @target(name="front-bracket") + def front_bracket(self) -> Cq.Workplane: + assert self.front_bracket_ext > self.gohei_bolt_diam / 2 + assert self.front_bracket_depth > self.gohei_bolt_diam + x0 = self.bulk_radius + y0 = x0 * math.tan(2 * math.pi / self.n_side) + s1 = self.side_width_inner + s2 = s1 - self.front_bracket_thickness * self.ratio_side_width + result = ( + Cq.Workplane() + .sketch() + .regularPolygon(s1, self.n_side) + .regularPolygon(s2, self.n_side, mode="s") + .polygon([ + (0, 0), + (x0, 0), + (x0, y0), + ], mode="i") + .finalize() + .extrude(self.front_bracket_depth) + .translate((0, 0, -self.front_bracket_depth/2)) + ) + hole_subtractor = Cq.Solid.makeCylinder( + radius=BOLT_COMMON.diam_thread/2, + height=self.bulk_radius, + dir=(1, 0, 0) + ) + result -= hole_subtractor + angle = 360 / self.n_side + result -= hole_subtractor.rotate((0,0,0), (0, 0, 1), angle) + loc_rot = Cq.Location.rot2d(angle) + r1 = self.bulk_radius - self.side_thickness + result.tagAbsolute("holeT1", (r1, 0, 0), direction="+X") + loc_ht1 = (loc_rot * Cq.Location(r1, 0, 0)).toTuple()[0] + result.tagAbsolute("holeT2", loc_ht1, direction=loc_ht1) + return result + + @target(name="front-bracket-large") + def front_bracket_large(self) -> Cq.Workplane: + """ + Optional alternative that is a bit bigger + """ + result = self.front_bracket() + return result + result.mirror("XZ") + + def profile_side_panel( + self, + length: float, + hasFrontHole: bool = False, + hasBackHole: bool = True) -> Cq.Sketch: + assert hasFrontHole or hasBackHole + signs = ([1] if hasFrontHole else []) + ([-1] if hasBackHole else []) + return ( + Cq.Sketch() + .rect(self.side_width, length) + .push([ + (sx * x, sy * (length/2 - y)) + for (x, y) in self.angle_joint_bolt_position + for sx in [1, -1] + for sy in signs + ]) + .circle(self.angle_joint_bolt_diam/2, mode="s") + ) + + def side_panel( + self, + length: float, + hasFrontHole: bool = True, + hasBackHole: bool = True, + ) -> Cq.Workplane: + w = self.side_width + sketch = self.profile_side_panel( + length=length, + hasFrontHole=hasFrontHole, + hasBackHole=hasBackHole, + ) + result = ( + Cq.Workplane() + .placeSketch(sketch) + .extrude(self.side_thickness) + ) + # Bevel the edges + intersector = ( + Cq.Workplane('XZ') + .polyline([ + (-w/2, 0), + (w/2, 0), + (0, self.bulk_radius), + ]) + .close() + .extrude(length) + .translate(Cq.Vector(0, length/2, 0)) + ) + # Intersect the side panel + result = result * intersector + + # Mark all attachment points + t = self.side_thickness + for i, (x, y) in enumerate(self.angle_joint_bolt_position): + px = x + py = length / 2 - y + result.tagAbsolute(f"holeFPI{i}", (+px, py, t), direction="+Z") + result.tagAbsolute(f"holeFSI{i}", (-px, py, t), direction="+Z") + result.tagAbsolute(f"holeFPO{i}", (+px, py, 0), direction="-Z") + result.tagAbsolute(f"holeFSO{i}", (-px, py, 0), direction="-Z") + result.tagAbsolute(f"holeBPI{i}", (+px, -py, t), direction="+Z") + result.tagAbsolute(f"holeBSI{i}", (-px, -py, t), direction="+Z") + result.tagAbsolute(f"holeBPO{i}", (+px, -py, 0), direction="-Z") + result.tagAbsolute(f"holeBSO{i}", (-px, -py, 0), direction="-Z") + + return result + + @target(name="side-panel1", kind=TargetKind.DXF) + def profile_side_panel1(self) -> Cq.Sketch: + return ( + self.profile_side_panel( + length=self.side_length1, + hasFrontHole=False, + hasBackHole=True, + ) + .push([ + (0, self.side_length1/2 - self.section1_gohei_loc) + ]) + .circle(self.gohei_bolt_diam/2, mode="s") + ) + def side_panel1(self) -> Cq.Workplane: + l = self.side_length1 + w = self.side_width + sketch = self.profile_side_panel1() + result = ( + Cq.Workplane() + .placeSketch(sketch) + .extrude(self.side_thickness) + ) + # Bevel the edges + intersector = ( + Cq.Workplane('XZ') + .polyline([ + (-w/2, 0), + (w/2, 0), + (0, self.bulk_radius), + ]) + .close() + .extrude(l) + .translate(Cq.Vector(0, l/2, 0)) + ) + # Intersect the side panel + result = result * intersector + + # Mark all attachment points + t = self.side_thickness + for i, (x, y) in enumerate(self.angle_joint_bolt_position): + px = x + py = l / 2 - y + result.tagAbsolute(f"holeFPI{i}", (+px, py, t), direction="+Z") + result.tagAbsolute(f"holeFSI{i}", (-px, py, t), direction="+Z") + result.tagAbsolute(f"holeFPO{i}", (+px, py, 0), direction="-Z") + result.tagAbsolute(f"holeFSO{i}", (-px, py, 0), direction="-Z") + result.tagAbsolute(f"holeBPI{i}", (+px, -py, t), direction="+Z") + result.tagAbsolute(f"holeBSI{i}", (-px, -py, t), direction="+Z") + result.tagAbsolute(f"holeBPO{i}", (+px, -py, 0), direction="-Z") + result.tagAbsolute(f"holeBSO{i}", (-px, -py, 0), direction="-Z") + # Mark the gohei attachment points + y_gohei = self.side_length1/2 - self.section1_gohei_loc + result.tagAbsolute(f"holeGoheiB", (0, y_gohei, t), direction="+Z") + result.tagAbsolute(f"holeGoheiF", (0, y_gohei, 0), direction="-Z") + return result + + @target(name="side-panel2", kind=TargetKind.DXF) + def profile_side_panel2(self) -> Cq.Sketch: + return ( + self.profile_side_panel( + length=self.side_length2, + hasFrontHole=True, + hasBackHole=True, + ) + ) + @target(name="side-panel3", kind=TargetKind.DXF) + def profile_side_panel3(self) -> Cq.Sketch: + return ( + self.profile_side_panel( + length=self.side_length3, + hasFrontHole=True, + hasBackHole=True, + ) + ) + + def assembly_section1(self) -> Cq.Assembly: + a = Cq.Assembly() + side = self.side_panel1() + r = self.bulk_radius + for i in range(self.n_side): + a = a.addS( + side, + name=f"side{i}", + material=self.material_side, + role=Role.STRUCTURE | Role.DECORATION, + loc=Cq.Location.rot2d(i*360/self.n_side) * Cq.Location(-r,0,0,90,0,90), + ) + a = a.constrain(f"side{i}", "Fixed") + for i in range(self.n_side): + i1 = (i + 1) % self.n_side + name_bracket = f"front_bracket{i}" + a = a.addS( + self.front_bracket(), + name=name_bracket, + role=Role.STRUCTURE, + ) + a = a.constrain( + f"side{i1}?holeGoheiB", + f"{name_bracket}?holeT1", + "Plane", + ) + a = a.constrain( + f"side{i}?holeGoheiB", + f"{name_bracket}?holeT2", + "Plane", + ) + return a.solve() + def assembly_section(self, **kwargs) -> Cq.Assembly: + a = Cq.Assembly() + side = self.side_panel(**kwargs) + r = self.bulk_radius + for i in range(self.n_side): + a = a.addS( + side, + name=f"side{i}", + material=self.material_side, + role=Role.STRUCTURE | Role.DECORATION, + loc=Cq.Location.rot2d(i*360/self.n_side) * Cq.Location(-r,0,0,90,0,90), + ) + return a + + @target(name="chamber-side-panel", kind=TargetKind.DXF) + def profile_chamber_side_panel(self) -> Cq.Sketch: + l = self.chamber_side_length + w = self.chamber_side_width + return ( + Cq.Sketch() + .rect(w, l) + .push([ + (sx * x, sy * (l/2 - y)) + for (x, y) in self.angle_joint_bolt_position + for sx in [1, -1] + for sy in [1, -1] + ]) + .circle(self.angle_joint_bolt_diam/2, mode="s") + ) + + def chamber_side_panel(self) -> Cq.Workplane: + w = self.chamber_side_width + l = self.chamber_side_length + sketch = self.profile_chamber_side_panel() + result = ( + Cq.Workplane() + .placeSketch(sketch) + .extrude(self.side_thickness) + ) + # Bevel the edges + intersector = ( + Cq.Workplane('XZ') + .polyline([ + (-w/2, 0), + (w/2, 0), + (0, self.chamber_bulk_radius), + ]) + .close() + .extrude(l) + .translate(Cq.Vector(0, l/2, 0)) + ) + # Intersect the side panel + result = result * intersector + + # Mark all attachment points + t = self.side_thickness + for i, (x, y) in enumerate(self.angle_joint_bolt_position): + px = x + py = l / 2 - y + result.tagAbsolute(f"holeFPI{i}", (+px, py, t), direction="+Z") + result.tagAbsolute(f"holeFSI{i}", (-px, py, t), direction="+Z") + result.tagAbsolute(f"holeFPO{i}", (+px, py, 0), direction="-Z") + result.tagAbsolute(f"holeFSO{i}", (-px, py, 0), direction="-Z") + result.tagAbsolute(f"holeBPI{i}", (+px, -py, t), direction="+Z") + result.tagAbsolute(f"holeBSI{i}", (-px, -py, t), direction="+Z") + result.tagAbsolute(f"holeBPO{i}", (+px, -py, 0), direction="-Z") + result.tagAbsolute(f"holeBSO{i}", (-px, -py, 0), direction="-Z") + + return result + + @target(name="chamber-back", kind=TargetKind.DXF) + def profile_chamber_back(self) -> Cq.Sketch: + shift = 180 / self.n_side + return ( + Cq.Sketch() + .regularPolygon( + self.side_width - self.side_thickness, + self.n_side, + angle=shift) + .reset() + .regularPolygon( + self.angle_joint_bind_radius, self.n_side, + angle=shift, + mode="c", tag="bolt") + .vertices(tag="bolt") + .circle(self.rotor_bind_bolt_diam/2, mode="s") + ) + def chamber_back(self) -> Cq.Workplane: + sketch = self.profile_chamber_back() + result = ( + Cq.Workplane() + .placeSketch(sketch) + .extrude(self.side_thickness) + ) + # Mark all attachment points + for i in range(self.n_side): + angle = i * math.radians(360 / self.n_side) + x = self.angle_joint_bind_radius * math.cos(angle) + y = self.angle_joint_bind_radius * math.sin(angle) + result.tagAbsolute(f"holeF{i}", (x, y, self.side_thickness), direction="+Z") + result.tagAbsolute(f"holeB{i}", (x, -y, 0), direction="-Z") + return result + + def assembly_chamber(self) -> Cq.Assembly: + a = Cq.Assembly() + side = self.chamber_side_panel() + r = self.chamber_bulk_radius + for i in range(self.n_side): + a = a.addS( + side, + name=f"side{i}", + material=self.material_side, + role=Role.STRUCTURE | Role.DECORATION, + loc=Cq.Location.rot2d(i*360/self.n_side) * Cq.Location(-r,0,0,90,0,90), + ) + return a + + @target(name="chamber-front", kind=TargetKind.DXF) + def profile_chamber_front(self) -> Cq.Sketch: + """ + Front chamber must allow access to the electronics section + """ + l = self.side_width + h = self.side_width + gap = 20 + return ( + self.profile_chamber_back() + .reset() + .rect(l, h, mode="s") + .push([ + (l/2 + gap + self.chamber_front_switch_diam/2, 0) + ]) + .circle(self.chamber_front_switch_diam/2, mode="s") + .reset() + .push([ + (0, h/2 + gap), + (0, -h/2 - gap), + ]) + .rect(l/4, gap, mode="s") + ) + def chamber_front(self) -> Cq.Sketch: + sketch = self.profile_chamber_front() + result = ( + Cq.Workplane() + .placeSketch(sketch) + .extrude(self.side_thickness) + ) + # Mark all attachment points + for i in range(self.n_side): + angle = i * math.radians(360 / self.n_side) + x = self.angle_joint_bind_radius * math.cos(angle) + y = self.angle_joint_bind_radius * math.sin(angle) + result.tagAbsolute(f"holeF{i}", (x, y, self.side_thickness), direction="+Z") + result.tagAbsolute(f"holeB{i}", (x, -y, 0), direction="-Z") + return result + + def angle_joint_flange(self) -> Cq.Workplane: + th = math.pi / self.n_side + r = self.bulk_radius + flange = ( + Cq.Workplane() + .sketch() + .push([ + (r, r * math.tan(th)) + ]) + .circle(self.angle_joint_flange_radius) + .reset() + .regularPolygon(self.side_width_inner, self.n_side, mode="i") + .finalize() + .extrude(self.angle_joint_flange_thickness) + .translate((0, 0, -self.angle_joint_flange_thickness/2)) + ) + ri = self.angle_joint_bind_radius + h = self.angle_joint_flange_thickness + # drill hole + cyl = Cq.Solid.makeCylinder( + radius=self.rotor_bind_bolt_diam/2, + height=h, + pnt=(ri * math.cos(th), ri * math.sin(th), -h/2), + ) + return flange - cyl + + @target(name="angle-joint-chamber-back") + def angle_joint_chamber_back(self) -> Cq.Workplane: + slot = ( + Cq.Workplane() + .sketch() + .regularPolygon( + self.side_width, + self.n_side + ) + .finalize() + .extrude(self.angle_joint_depth) + ) + thickness = self.chamber_bulk_radius - self.bulk_radius + + h = (self.bulk_radius + self.angle_joint_extra_width) * 2 + # Intersector for 1/n of the ring + intersector = ( + Cq.Workplane() + .sketch() + .polygon([ + (0, 0), + (h, 0), + (h, h * math.tan(2 * math.pi / self.n_side)) + ]) + .finalize() + .extrude(self.angle_joint_depth*4) + .translate((0, 0, -self.angle_joint_depth*2)) + ) + # The mating structure + z1 = self.bulk_radius + (thickness - self.angle_joint_conn_thickness) / 2 + z2 = z1 + self.angle_joint_conn_thickness + mating1n = ( + Cq.Workplane() + .sketch() + .polygon([ + (z1, 0), + (z1, self.angle_joint_conn_width), + (z2, self.angle_joint_conn_width), + (z2, 0), + ]) + .finalize() + .extrude(self.angle_joint_conn_depth) + ) + mating1p = mating1n.rotate((0,0,0), (1,0,0), 180) + angle = 360 / self.n_side + + chamber_intersector = ( + Cq.Workplane() + .sketch() + .regularPolygon(self.chamber_side_width, self.n_side) + .regularPolygon(self.chamber_side_width - self.delta_side_width, self.n_side, mode="s") + .finalize() + .extrude(self.angle_joint_depth) + .translate((0,0,self.angle_joint_gap/2)) + ) + result = ( + Cq.Workplane() + .sketch() + .regularPolygon( + self.chamber_side_width, + self.n_side + ) + .regularPolygon( + self.side_width_inner, + self.n_side, mode="s" + ) + .finalize() + .extrude(self.angle_joint_depth) + .translate((0, 0, -self.angle_joint_depth/2)) + .cut(slot.translate((0, 0, self.angle_joint_gap/2))) + .intersect(intersector) + .cut(chamber_intersector) + .cut(mating1n) + .union(mating1p) + .union(mating1n.rotate((0,0,0),(0,0,1),angle)) + .cut(mating1p.rotate((0,0,0),(0,0,1),angle)) + ) + h = self.chamber_bulk_radius + hole_negative = Cq.Solid.makeCylinder( + radius=self.angle_joint_bolt_diam/2, + height=h, + pnt=(0,0,0), + dir=(1,0,0), + ) + Cq.Solid.makeCylinder( + radius=self.angle_joint_bolt_head_diam/2, + height=self.angle_joint_bolt_head_depth, + pnt=(h,0,0), + dir=(-1,0,0), + ) + dy = self.angle_joint_gap / 2 + locrot = Cq.Location(0, 0, 0, 0, 0, 360/self.n_side) + for (x, y) in self.angle_joint_bolt_position: + p1 = Cq.Location((0, x, dy+y)) + p1r = locrot * Cq.Location((0, -x, dy+y)) + result = result \ + - hole_negative.moved(p1) \ + - hole_negative.moved(p1r) + # Mark the absolute locations of the mount points + dr = self.chamber_bulk_radius - self.side_thickness + locrot = Cq.Location(0, 0, 0, 0, 0, 360/self.n_side) + dr = self.chamber_bulk_radius - self.side_thickness + dy = self.angle_joint_gap / 2 + for i, (x, y) in enumerate(self.angle_joint_bolt_position): + py = dy + y + #result.tagAbsolute(f"holeLPO{i}", (dr, x, py), direction="+X") + result.tagAbsolute(f"holeLPO{i}", (dr, x, py), direction="+X") + #result.tagAbsolute(f"holeLSO{i}", locrot * Cq.Location(dr, -x, py), direction="+X") + result.tagAbsolute(f"holeLSO{i}", locrot * Cq.Location(dr, -x, py), direction="+X") + + th = math.pi / self.n_side + r = self.bulk_radius + flange_z = self.angle_joint_depth / 2 - self.side_thickness + flange = ( + Cq.Workplane() + .sketch() + .push([ + (r, r * math.tan(th)) + ]) + .circle(self.angle_joint_flange_extension) + .reset() + .regularPolygon(self.side_width_inner, self.n_side, mode="i") + .finalize() + .extrude(self.angle_joint_gap) + .translate((0, 0, -flange_z)) + ) + ri = self.angle_joint_bind_radius + h = self.angle_joint_gap + # Drill holes for connectors + cyl = Cq.Solid.makeCylinder( + radius=self.rotor_bind_bolt_diam/2, + height=h, + pnt=(0, 0, -flange_z), + ) + result = ( + result + + flange + - cyl.moved(ri * math.cos(th), ri * math.sin(th), 0) + ) + result.tagAbsolute("holeStatorO", (ri * math.cos(th), ri * math.sin(th), -flange_z), direction="-Z") + result.tagAbsolute("holeStatorI", (ri * math.cos(th), ri * math.sin(th), -flange_z+h), direction="+Z") + return result + + + @target(name="angle-joint-chamber-front") + def angle_joint_chamber_front(self) -> Cq.Workplane: + """ + Angle joint for connecting the chamber to the chassis of the barrel + """ + # This slot cuts the interior of the joint + slot = ( + Cq.Workplane() + .sketch() + .regularPolygon( + self.side_width, + self.n_side + ) + .finalize() + .extrude(self.angle_joint_depth) + ) + thickness = self.chamber_bulk_radius - self.bulk_radius + + h = (self.bulk_radius + self.angle_joint_extra_width) * 2 + # Intersector for 1/n of the ring + intersector = ( + Cq.Workplane() + .sketch() + .polygon([ + (0, 0), + (h, 0), + (h, h * math.tan(2 * math.pi / self.n_side)) + ]) + .finalize() + .extrude(self.angle_joint_depth*4) + .translate((0, 0, -self.angle_joint_depth*2)) + ) + # The mating structure + z1 = self.bulk_radius + (thickness - self.angle_joint_conn_thickness) / 2 + z2 = z1 + self.angle_joint_conn_thickness + mating1n = ( + Cq.Workplane() + .sketch() + .polygon([ + (z1, 0), + (z1, self.angle_joint_conn_width), + (z2, self.angle_joint_conn_width), + (z2, 0), + ]) + .finalize() + .extrude(self.angle_joint_conn_depth) + ) + mating1p = mating1n.rotate((0,0,0), (1,0,0), 180) + angle = 360 / self.n_side + + chamber_intersector = ( + Cq.Workplane() + .sketch() + .regularPolygon(self.chamber_side_width, self.n_side) + .regularPolygon(self.chamber_side_width - self.delta_side_width, self.n_side, mode="s") + .finalize() + .extrude(self.angle_joint_depth) + .translate((0,0,-self.angle_joint_depth-self.angle_joint_gap/2)) + ) + result = ( + Cq.Workplane() + .sketch() + .regularPolygon( + self.chamber_side_width, + self.n_side + ) + .regularPolygon( + self.side_width_inner, + self.n_side, mode="s" + ) + .finalize() + .extrude(self.angle_joint_depth) + .translate((0, 0, -self.angle_joint_depth/2)) + .cut(slot.translate((0, 0, self.angle_joint_gap/2))) + .cut(slot.translate((0, 0, -self.angle_joint_depth-self.angle_joint_gap/2))) + .intersect(intersector) + .cut(chamber_intersector) + .cut(mating1n) + .union(mating1p) + .union(mating1n.rotate((0,0,0),(0,0,1),angle)) + .cut(mating1p.rotate((0,0,0),(0,0,1),angle)) + ) + h = self.chamber_bulk_radius + hole_negative = Cq.Solid.makeCylinder( + radius=self.angle_joint_bolt_diam/2, + height=h, + pnt=(0,0,0), + dir=(1,0,0), + ) + Cq.Solid.makeCylinder( + radius=self.angle_joint_bolt_head_diam/2, + height=self.angle_joint_bolt_head_depth, + pnt=(h,0,0), + dir=(-1,0,0), + ) + dy = self.angle_joint_gap / 2 + locrot = Cq.Location(0, 0, 0, 0, 0, 360/self.n_side) + for (x, y) in self.angle_joint_bolt_position: + p1 = Cq.Location((0, x, dy+y)) + p2 = Cq.Location((0, x, -dy-y)) + p1r = locrot * Cq.Location((0, -x, dy+y)) + p2r = locrot * Cq.Location((0, -x, -dy-y)) + result = result \ + - hole_negative.moved(p1) \ + - hole_negative.moved(p2) \ + - hole_negative.moved(p1r) \ + - hole_negative.moved(p2r) + # Mark the absolute locations of the mount points + dr = self.bulk_radius + self.angle_joint_thickness + dr0 = self.bulk_radius + for i, (x, y) in enumerate(self.angle_joint_bolt_position): + py = dy + y + result.tagAbsolute(f"holeLPO{i}", (dr, x, py), direction="+X") + result.tagAbsolute(f"holeLPM{i}", (dr0, x, py), direction="-X") + result.tagAbsolute(f"holeRPM{i}", (dr0, x, -py), direction="-X") + result.tagAbsolute(f"holeLSO{i}", locrot * Cq.Location(dr, -x, py), direction="+X") + result.tagAbsolute(f"holeLSM{i}", locrot * Cq.Location(dr0, -x, py), direction="-X") + result.tagAbsolute(f"holeRSM{i}", locrot * Cq.Location(dr0, -x, -py), direction="-X") + locrot = Cq.Location(0, 0, 0, 0, 0, 360/self.n_side) + dr = self.chamber_bulk_radius - self.side_thickness + dy = self.angle_joint_gap / 2 + for i, (x, y) in enumerate(self.angle_joint_bolt_position): + py = dy + y + #result.tagAbsolute(f"holeLPO{i}", (dr, x, py), direction="+X") + result.tagAbsolute(f"holeRPO{i}", (dr, x, -py), direction="+X") + #result.tagAbsolute(f"holeLSO{i}", locrot * Cq.Location(dr, -x, py), direction="+X") + result.tagAbsolute(f"holeRSO{i}", locrot * Cq.Location(dr, -x, -py), direction="+X") + + # Generate the flange geometry + flange = self.angle_joint_flange() + result = result + self.angle_joint_flange() + th = math.pi / self.n_side + ri = self.angle_joint_bind_radius + h = self.angle_joint_gap + result.tagAbsolute("holeStatorL", (ri * math.cos(th), ri * math.sin(th), h/2), direction="+Z") + result.tagAbsolute("holeStatorR", (ri * math.cos(th), ri * math.sin(th), -h/2), direction="-Z") + return result + + @target(name="angle-joint") + def angle_joint(self) -> Cq.Workplane: + """ + Angular joint between two side panels (excluding chamber). This sits at the intersection of + 4 side panels to provide compressive, shear, and tensile strength. + + To provide tensile strength along the Z-axis, the panels must be bolted + onto the angle joint. + + The holes are marked hole(L/R)(P/S)(O/I)(i), where L/R corresponds to the two + sections being joined, and P/S corresponds to the two facets + (primary/secondary) being joined. O/I corresponds to the outside/inside + """ + + # This slot cuts the interior of the joint + slot = ( + Cq.Workplane() + .sketch() + .regularPolygon( + self.side_width, + self.n_side + ) + .finalize() + .extrude(self.angle_joint_depth) + ) + + h = (self.bulk_radius + self.angle_joint_extra_width) * 2 + # Intersector for 1/n of the ring + intersector = ( + Cq.Workplane() + .sketch() + .polygon([ + (0, 0), + (h, 0), + (h, h * math.tan(2 * math.pi / self.n_side)) + ]) + .finalize() + .extrude(self.angle_joint_depth*4) + .translate((0, 0, -self.angle_joint_depth*2)) + ) + # The mating structure + z1 = self.bulk_radius + (self.angle_joint_thickness - self.angle_joint_conn_thickness) / 2 + z2 = z1 + self.angle_joint_conn_thickness + mating1n = ( + Cq.Workplane() + .sketch() + .polygon([ + (z1, 0), + (z1, self.angle_joint_conn_width), + (z2, self.angle_joint_conn_width), + (z2, 0), + ]) + .finalize() + .extrude(self.angle_joint_conn_depth) + ) + mating1p = mating1n.rotate((0,0,0), (1,0,0), 180) + angle = 360 / self.n_side + result = ( + Cq.Workplane() + .sketch() + .regularPolygon( + self.side_width + self.angle_joint_extra_width, + self.n_side + ) + .regularPolygon( + self.side_width_inner, + self.n_side, mode="s" + ) + .finalize() + .extrude(self.angle_joint_depth) + .translate((0, 0, -self.angle_joint_depth/2)) + .cut(slot.translate((0, 0, self.angle_joint_gap/2))) + .cut(slot.translate((0, 0, -self.angle_joint_depth-self.angle_joint_gap/2))) + .intersect(intersector) + .cut(mating1n) + .union(mating1p) + .union(mating1n.rotate((0,0,0),(0,0,1),angle)) + .cut(mating1p.rotate((0,0,0),(0,0,1),angle)) + ) + h = self.bulk_radius + self.angle_joint_thickness + hole_negative = Cq.Solid.makeCylinder( + radius=self.angle_joint_bolt_diam/2, + height=h, + pnt=(0,0,0), + dir=(1,0,0), + ) + Cq.Solid.makeCylinder( + radius=self.angle_joint_bolt_head_diam/2, + height=self.angle_joint_bolt_head_depth, + pnt=(h,0,0), + dir=(-1,0,0), + ) + dy = self.angle_joint_gap / 2 + locrot = Cq.Location(0, 0, 0, 0, 0, 360/self.n_side) + for (x, y) in self.angle_joint_bolt_position: + p1 = Cq.Location((0, x, dy+y)) + p2 = Cq.Location((0, x, -dy-y)) + p1r = locrot * Cq.Location((0, -x, dy+y)) + p2r = locrot * Cq.Location((0, -x, -dy-y)) + result = result \ + - hole_negative.moved(p1) \ + - hole_negative.moved(p2) \ + - hole_negative.moved(p1r) \ + - hole_negative.moved(p2r) + # Mark the absolute locations of the mount points + dr = self.bulk_radius + self.angle_joint_thickness + dr0 = self.bulk_radius + for i, (x, y) in enumerate(self.angle_joint_bolt_position): + py = dy + y + result.tagAbsolute(f"holeLPO{i}", (dr, x, py), direction="+X") + result.tagAbsolute(f"holeRPO{i}", (dr, x, -py), direction="+X") + result.tagAbsolute(f"holeLPM{i}", (dr0, x, py), direction="-X") + result.tagAbsolute(f"holeRPM{i}", (dr0, x, -py), direction="-X") + result.tagAbsolute(f"holeLSO{i}", locrot * Cq.Location(dr, -x, py), direction="+X") + result.tagAbsolute(f"holeRSO{i}", locrot * Cq.Location(dr, -x, -py), direction="+X") + result.tagAbsolute(f"holeLSM{i}", locrot * Cq.Location(dr0, -x, py), direction="-X") + result.tagAbsolute(f"holeRSM{i}", locrot * Cq.Location(dr0, -x, -py), direction="-X") + + # Generate the flange geometry + flange = self.angle_joint_flange() + result = result + self.angle_joint_flange() + th = math.pi / self.n_side + ri = self.angle_joint_bind_radius + h = self.angle_joint_gap + result.tagAbsolute("holeStatorL", (ri * math.cos(th), ri * math.sin(th), h/2), direction="+Z") + result.tagAbsolute("holeStatorR", (ri * math.cos(th), ri * math.sin(th), -h/2), direction="-Z") + return result + + @assembly() + def assembly_ring(self, base) -> Cq.Assembly: + a = Cq.Assembly() + r = self.bulk_radius + for i in range(self.n_side): + a = a.addS( + base, + name=f"side{i}", + material=self.material_brace, + role=Role.CASING | Role.DECORATION, + loc=Cq.Location.rot2d(i*360/self.n_side), + ) + return a + + @target(name="handle") + def handle(self) -> Cq.Workplane: + w = self.side_width * 0.7 + base = ( + Cq.Workplane( + origin=(0, 0, -self.handle_base_height) + ) + .box( + length=w, + width=self.angle_joint_depth, + height=self.handle_base_height, + centered=(True, True, False) + ) + .faces(">Z") + .workplane() + .pushPoints([ + (x * sx, (y + self.angle_joint_gap/2) * sy) + for (x, y) in self.angle_joint_bolt_position + for sx in (-1, 1) + for sy in (-1, 1) + ]) + .cboreHole( + self.angle_joint_bolt_diam, + self.angle_joint_bolt_head_diam, + self.angle_joint_bolt_head_depth, + depth=None, + ) + ) + # Construct the handle + bar = ( + Cq.Workplane(origin=(0, self.handle_radius, 0)) + .rect(self.handle_thickness_x, self.handle_thickness_y) + .revolve(180, (0, -self.handle_radius, 0), (1, -self.handle_radius, 0)) + ) + result = ( + base + + bar + ) + t = self.handle_base_height + for i, (x, yi) in enumerate(self.angle_joint_bolt_position): + y = yi + self.angle_joint_gap/2 + result.tagAbsolute(f"holeLPO{i}", (+x, y, 0), direction="+Z") + result.tagAbsolute(f"holeLSO{i}", (-x, y, 0), direction="+Z") + result.tagAbsolute(f"holeLPI{i}", (+x, y, -t), direction="-Z") + result.tagAbsolute(f"holeLSI{i}", (-x, y, -t), direction="-Z") + result.tagAbsolute(f"holeRPO{i}", (+x, -y, 0), direction="+Z") + result.tagAbsolute(f"holeRSO{i}", (-x, -y, 0), direction="+Z") + result.tagAbsolute(f"holeRPI{i}", (+x, -y, -t), direction="-Z") + result.tagAbsolute(f"holeRSI{i}", (-x, -y, -t), direction="-Z") + + return result + + @assembly() + def assembly(self, parts: Optional[list[str]] = None) -> Cq.Assembly: + a = Cq.Assembly() + if has_part(parts, "section1"): + a = a.add( + self.assembly_section1(), + name="section1", + ) + if has_part(parts, "ring1"): + a = a.add( + self.assembly_ring(self.angle_joint()), + name="ring1", + ) + if has_part(parts, "section2"): + a = a.add( + self.assembly_section(length=self.side_length2, hasFrontHole=True, hasBackHole=True), + name="section2", + ) + if has_part(parts, "ring2"): + a = a.add( + self.assembly_ring(self.angle_joint()), + name="ring2", + ) + + name_handle1 = "handle2_1" + a = a.addS( + self.handle(), + name=name_handle1, + material=self.material_brace, + role=Role.HANDLE, + ) + name_handle2 = "handle2_2" + a = a.addS( + self.handle(), + name=name_handle2, + material=self.material_brace, + role=Role.HANDLE, + ) + # Handle constrain + for ih, (x, y) in enumerate(self.angle_joint_bolt_position): + a = a.constrain( + f"{name_handle1}?holeLPI{ih}", + f"ring2/side2?holeLPO{ih}", + "Plane", + ) + a = a.constrain( + f"{name_handle1}?holeRPI{ih}", + f"ring2/side2?holeRPO{ih}", + "Plane", + ) + a = a.constrain( + f"{name_handle2}?holeLPI{ih}", + f"ring2/side4?holeLPO{ih}", + "Plane", + ) + a = a.constrain( + f"{name_handle2}?holeRPI{ih}", + f"ring2/side4?holeRPO{ih}", + "Plane", + ) + if has_part(parts, "section3"): + a = a.add( + self.assembly_section(length=self.side_length3, hasFrontHole=True, hasBackHole=True), + name="section3", + ) + if has_part(parts, "ring3"): + a = a.add( + self.assembly_ring(self.angle_joint_chamber_front()), + name="ring3", + ) + if has_part(parts, "chamber"): + a = a.add( + self.assembly_chamber(), + name="chamber", + ) + if has_part(parts, "ring4"): + a = a.add( + self.assembly_ring(self.angle_joint_chamber_back()), + name="ring4", + ) + if has_part(parts, "chamber_back"): + a = a.addS( + self.chamber_back(), + name="chamber_back", + material=self.material_side, + role=Role.STRUCTURE | Role.DECORATION, + ) + if has_part(parts, "chamber_front"): + a = a.addS( + self.chamber_front(), + name="chamber_front", + material=self.material_side, + role=Role.STRUCTURE | Role.DECORATION, + ) + if has_part(parts, "motor"): + a = a.add(self.assembly_motor(), name="motor") + if has_part(parts, "machine"): + a = a.add(self.assembly_machine(), name="machine") + if has_part(parts, "electronics1"): + a = a.add(self.assembly_electronics1(), name="electronics1") + a = a.constrain("electronics1/controller", "Fixed") + if has_part(parts, ["electronics1", "ring3"]): + a = a.constrain( + f"electronics1/barL?holeBO2", + f"ring3/side1?holeStatorL", + "Plane", + ) + a = a.constrain( + f"electronics1/barL?holeBO1", + f"ring3/side2?holeStatorL", + "Plane", + ) + + # FIXME: Filter + if has_part(parts, ["motor", "ring2"]): + for i in range(self.n_side // 2): + j = self.n_side // 2 - 1 - i + a = a.constrain( + f"motor/seat{j}?holeBB1", + f"ring2/side{i*2}?holeStatorL", + "Plane", + ) + #a = a.constrain( + # f"motor/seat{j}?holeBB2", + # f"ring2/side{i*2+1}?holeStatorL", + # "Plane", + #) + for i in range(self.n_side): + j = (i + 1) % self.n_side + ir = (self.n_side - i) % self.n_side + + coupler_name = f"stator_coupler{i}" + a = a.addS( + self.stator_coupler(), + name=coupler_name, + material=self.material_brace, + role=Role.STRUCTURE, + ) + a = a.constrain( + f"{coupler_name}?holeOB", + f"ring1/side{i}?holeStatorR", + "Plane", + ) + a = a.constrain( + f"{coupler_name}?holeIF", + f"machine/stator1?holeF{ir}", + "Plane", + ) + + #name_bolt =f"stator_outer_bolt{i}" + #a = a.addS( + # BOLT_LONG.generate(), + # name=name_bolt, + # material=self.material_fastener, + # role=Role.CONNECTION, + #) + #a = a.constrain( + # f"{coupler_name}?holeOF", + # f"{name_bolt}?root", + # "Plane", + #) + + name_bolt =f"chamber_back{i}boltFPI{i}" + a = a.addS( + BOLT_COMMON.generate(), + name=name_bolt, + material=self.material_fastener, + role=Role.CONNECTION, + ) + a = a.constrain( + f"chamber_back?holeF{i}", + f"{name_bolt}?root", + "Plane", + ) + + name_bolt =f"chamber_front{i}boltFPI{i}" + a = a.addS( + BOLT_COMMON.generate(), + name=name_bolt, + material=self.material_fastener, + role=Role.CONNECTION, + ) + a = a.constrain( + f"chamber_front?holeF{i}", + f"{name_bolt}?root", + "Plane", + ) + for ih in range(len(self.angle_joint_bolt_position)): + a = a.constrain( + f"chamber/side{i}?holeFPI{ih}", + f"ring3/side{i}?holeRSO{ih}", + "Plane", + ) + a = a.constrain( + f"chamber/side{i}?holeBPI{ih}", + f"ring4/side{i}?holeLSO{ih}", + "Plane", + ) + a = a.constrain( + f"ring4/side{i}?holeStatorO", + f"chamber_back?holeB{i}", + "Plane", + ) + a = a.constrain( + f"ring3/side{i}?holeStatorR", + f"chamber_front?holeB{i}", + "Plane", + ) + + #a = a.constrain( + # f"barrel/stator2?holeB{i}", + # f"ring1/side{i}?holeStatorR", + # "Plane", + #) + + # Generate bolts for the chamber back + for (nl, nc, nr) in [ + ("section1", "ring1", "section2"), + ("section2", "ring2", "section3"), + ("section3", "ring3", None), + ]: + a = a.constrain( + f"{nl}/side{i}?holeBSO{ih}", + f"{nc}/side{i}?holeLPM{ih}", + "Plane", + ) + if nr: + a = a.constrain( + f"{nr}/side{i}?holeFPO{ih}", + f"{nc}/side{i}?holeRSM{ih}", + "Plane", + ) + + return a.solve() diff --git a/nhf/touhou/yasaka_kanako/shimenawa.py b/nhf/touhou/yasaka_kanako/shimenawa.py new file mode 100644 index 0000000..5b457c3 --- /dev/null +++ b/nhf/touhou/yasaka_kanako/shimenawa.py @@ -0,0 +1,264 @@ +from nhf.build import Model, TargetKind, target, assembly, submodel +from nhf.materials import Role, Material +import nhf.utils +from nhf.parts.fasteners import FlatHeadBolt, HexNut, Washer +from nhf.parts.electronics import ArduinoUnoR3, BatteryBox18650 + +from typing import Optional, Union +import math +from dataclasses import dataclass, field +import cadquery as Cq + +NUT_COMMON = HexNut( + # FIXME: weigh + mass=0.0, + diam_thread=6.0, + pitch=1.0, + thickness=5.0, + width=9.89, +) +BOLT_COMMON = FlatHeadBolt( + # FIXME: weigh + mass=0.0, + diam_head=12.8, + height_head=2.8, + diam_thread=6.0, + height_thread=30.0, + pitch=1.0, +) + +@dataclass +class Shimenawa(Model): + """ + The ring + """ + + diam_inner: float = 43.0 + diam_outer: float = 43.0 + 9 * 2 + + diam_hole_outer: float = 8.0 + hole_ext: float = 2.0 + hole_z: float = 15.0 + + pipe_fitting_angle_span: float = 6.0 + + pipe_joint_length: float = 120.0 + pipe_joint_outer_thickness: float = 5.0 + pipe_joint_inner_thickness: float = 4.0 + + pipe_joint_inner_angle_span: float = 120.0 + pipe_joint_taper: float = 5.0 + pipe_joint_taper_length: float = 10.0 + + ear_dr: float = 6.0 + ear_hole_diam: float = 10.0 + ear_radius: float = 15.0 + ear_thickness: float = 10.0 + + main_circumference: float = 3600.0 + + material_fitting: Material = Material.PLASTIC_PLA + + def __post_init__(self): + assert self.diam_inner < self.diam_outer + + @property + def main_radius(self) -> float: + return self.main_circumference / (2 * math.pi) + + @target(name="pipe-fitting-curved") + def pipe_fitting_curved(self) -> Cq.Workplane: + r_minor = self.diam_outer/2 + self.pipe_joint_outer_thickness + a1 = self.pipe_fitting_angle_span + outer = Cq.Solid.makeTorus( + radius1=self.main_radius, + radius2=r_minor, + ) + inner = Cq.Solid.makeTorus( + radius1=self.main_radius, + radius2=self.diam_outer/2, + ) + angle_intersector = Cq.Solid.makeCylinder( + radius=self.main_radius + r_minor, + height=r_minor*2, + angleDegrees=a1, + pnt=(0,0,-r_minor) + ).rotate((0,0,0),(0,0,1),-a1/2) + result = (outer - inner) * angle_intersector + + ear_outer = Cq.Solid.makeCylinder( + radius=self.ear_radius, + height=self.ear_thickness, + pnt=(0,-self.ear_thickness/2,0), + dir=(0,1,0), + ) + ear_hole = Cq.Solid.makeCylinder( + radius=self.ear_hole_diam/2, + height=self.ear_thickness, + pnt=(-self.ear_dr,-self.ear_thickness/2,0), + dir=(0,1,0), + ) + ear = (ear_outer - ear_hole).moved(self.main_radius - r_minor, 0, 0) + result += ear - inner + return result + @target(name="pipe-joint-outer") + def pipe_joint_outer(self) -> Cq.Workplane: + """ + Used to joint two pipes together (outside) + """ + r1 = self.diam_outer / 2 + self.pipe_joint_outer_thickness + h = self.pipe_joint_length + result = ( + Cq.Workplane() + .cylinder( + radius=r1, + height=self.pipe_joint_length, + ) + ) + cut_interior = Cq.Solid.makeCylinder( + radius=self.diam_outer/2, + height=h, + pnt=(0, 0, -h/2) + ) + rh = r1 + self.hole_ext + add_hole = Cq.Solid.makeCylinder( + radius=self.diam_hole_outer/2, + height=rh*2, + pnt=(-rh, 0, 0), + dir=(1, 0, 0), + ) + cut_hole = Cq.Solid.makeCylinder( + radius=BOLT_COMMON.diam_thread/2, + height=rh*2, + pnt=(-rh, 0, 0), + dir=(1, 0, 0), + ) + z = self.hole_z + result = ( + result + + add_hole.moved(0, 0, -z) + + add_hole.moved(0, 0, z) + - cut_hole.moved(0, 0, -z) + - cut_hole.moved(0, 0, z) + - cut_interior + ) + ear_outer = Cq.Solid.makeCylinder( + radius=self.ear_radius, + height=self.ear_thickness, + pnt=(0, r1, -self.ear_thickness/2), + ) + ear_hole = Cq.Solid.makeCylinder( + radius=self.ear_hole_diam/2, + height=self.ear_thickness, + pnt=(0,r1+self.ear_dr,-self.ear_thickness/2), + ) + ear = ear_outer - ear_hole - cut_interior + return result + ear + + @target(name="pipe-joint-inner") + def pipe_joint_inner(self) -> Cq.Workplane: + """ + Used to joint two pipes together (inside) + """ + r1 = self.diam_inner / 2 + r2 = r1 - self.pipe_joint_taper + r3 = r2 - self.pipe_joint_inner_thickness + h = self.pipe_joint_length + h0 = h - self.pipe_joint_taper_length*2 + core = Cq.Solid.makeCylinder( + radius=r2, + height=h0/2, + ) + centre_cut = Cq.Solid.makeCylinder( + radius=r3, + height=h0/2, + ) + taper = Cq.Solid.makeCone( + radius1=r2, + radius2=r1, + height=(h - h0) / 2, + pnt=(0, 0, h0/2), + ) + centre_cut_taper = Cq.Solid.makeCone( + radius1=r3, + radius2=r3 + self.pipe_joint_taper, + height=(h - h0) / 2, + pnt=(0, 0, h0/2), + ) + angle_intersector = Cq.Solid.makeCylinder( + radius=r1, + height=h, + angleDegrees=self.pipe_joint_inner_angle_span + ).rotate((0,0,0), (0,0,1), -self.pipe_joint_inner_angle_span/2) + result = (taper + core - centre_cut - centre_cut_taper) * angle_intersector + + result += result.mirror("XY") + + add_hole = Cq.Solid.makeCylinder( + radius=self.diam_hole_outer/2, + height=self.hole_ext, + pnt=(r3, 0, 0), + dir=(-1, 0, 0), + ) + cut_hole = Cq.Solid.makeCylinder( + radius=BOLT_COMMON.diam_thread/2, + height=r1, + pnt=(0, 0, 0), + dir=(r1, 0, 0), + ) + z = self.hole_z + # avoid collisions + nut_x = r3 - self.hole_ext - NUT_COMMON.thickness + nut = NUT_COMMON.generate().val().rotate((0,0,0),(0,1,0),90) + result = ( + result + + add_hole.moved(0, 0, z) + + add_hole.moved(0, 0, -z) + - cut_hole.moved(0, 0, z) + - cut_hole.moved(0, 0, -z) + - nut.moved(nut_x, 0, z) + - nut.moved(nut_x, 0, -z) + ) + return result + @assembly() + def assembly_pipe_joint(self) -> Cq.Assembly: + a = ( + Cq.Assembly() + .addS( + self.pipe_joint_outer(), + name="joint_outer", + material=self.material_fitting, + role=Role.STRUCTURE, + ) + .addS( + self.pipe_joint_inner(), + name="joint_inner1", + material=self.material_fitting, + role=Role.STRUCTURE, + ) + .addS( + self.pipe_joint_inner(), + name="joint_inner2", + material=self.material_fitting, + role=Role.STRUCTURE, + loc=Cq.Location.rot2d(180), + ) + ) + return a + + @assembly() + def assembly(self) -> Cq.Assembly: + a = ( + Cq.Assembly() + .addS( + self.pipe_fitting_curved(), + name="fitting1", + material=self.material_fitting, + role=Role.STRUCTURE, + ) + .add( + self.assembly_pipe_joint(), + name="pipe_joint", + ) + ) + return a diff --git a/nhf/utils.py b/nhf/utils.py index 0c83db7..1234796 100644 --- a/nhf/utils.py +++ b/nhf/utils.py @@ -162,6 +162,15 @@ def tagPlane(self, tag: str, Cq.Workplane.tagPlane = tagPlane +def tag_absolute( + self, + tag: str, + loc: Union[Cq.Location, Tuple[float, float, float]], + direction: Union[str, Cq.Vector, Tuple[float, float, float]] = '+Z'): + return self.pushPoints([loc]).tagPlane(tag, direction=direction) + +Cq.Workplane.tagAbsolute = tag_absolute + def make_sphere(r: float = 2) -> Cq.Solid: """ Makes a full sphere. The default function makes a hemisphere