From 4613247e1b2453d3c46d18025598769c02d32ec8 Mon Sep 17 00:00:00 2001 From: Leni Aniva Date: Wed, 19 Jun 2024 15:54:09 -0700 Subject: [PATCH 01/38] feat: Hirth Joint for wing root --- nhf/diag.py | 42 +++++++ nhf/joints.py | 106 ++++++++++++++++++ nhf/touhou/__init__.py | 0 nhf/touhou/houjuu_nue/README.org | 14 +++ nhf/touhou/houjuu_nue/__init__.py | 17 +++ .../houjuu_nue/controller/controller.ino | 68 +++++++++++ 6 files changed, 247 insertions(+) create mode 100644 nhf/diag.py create mode 100644 nhf/joints.py create mode 100644 nhf/touhou/__init__.py create mode 100644 nhf/touhou/houjuu_nue/README.org create mode 100644 nhf/touhou/houjuu_nue/__init__.py create mode 100644 nhf/touhou/houjuu_nue/controller/controller.ino diff --git a/nhf/diag.py b/nhf/diag.py new file mode 100644 index 0000000..1648383 --- /dev/null +++ b/nhf/diag.py @@ -0,0 +1,42 @@ +import cadquery as Cq + +def tidy_repr(obj): + """Shortens a default repr string""" + return repr(obj).split(".")[-1].rstrip(">") + + +def _ctx_str(self): + return ( + tidy_repr(self) + + ":\n" + + f" pendingWires: {self.pendingWires}\n" + + f" pendingEdges: {self.pendingEdges}\n" + + f" tags: {self.tags}" + ) + + +Cq.cq.CQContext.__str__ = _ctx_str + + +def _plane_str(self): + return ( + tidy_repr(self) + + ":\n" + + f" origin: {self.origin.toTuple()}\n" + + f" z direction: {self.zDir.toTuple()}" + ) + + +Cq.occ_impl.geom.Plane.__str__ = _plane_str + + +def _wp_str(self): + out = tidy_repr(self) + ":\n" + out += f" parent: {tidy_repr(self.parent)}\n" if self.parent else " no parent\n" + out += f" plane: {self.plane}\n" + out += f" objects: {self.objects}\n" + out += f" modelling context: {self.ctx}" + return out + + +Cq.Workplane.__str__ = _wp_str diff --git a/nhf/joints.py b/nhf/joints.py new file mode 100644 index 0000000..805d89e --- /dev/null +++ b/nhf/joints.py @@ -0,0 +1,106 @@ +import cadquery as Cq +import math +import unittest + +def hirth_joint(radius=60, + radius_inner=40, + radius_centre=30, + base_height=20, + n_tooth=16, + tooth_height=16, + tooth_height_inner=2): + """ + Creates a cylindrical Hirth Joint + """ + # ensures secant doesn't blow up + assert n_tooth >= 5 + + # angle of half of a single tooth + theta = math.pi / n_tooth + + # Generate a tooth by lofting between two curves + + inner_raise = (tooth_height - tooth_height_inner) / 2 + # Outer tooth triangle spans a curve of length `2 pi r / n_tooth`. This + # creates the side profile (looking radially inwards) of each of the + # triangles. + outer = [ + (radius * math.tan(theta), 0), + (0, tooth_height), + (-radius * math.tan(theta), 0), + ] + inner = [ + (radius_inner * math.sin(theta), 0), + (radius_inner * math.sin(theta), inner_raise - tooth_height_inner / 2), + (0, inner_raise + tooth_height_inner / 2), + (-radius_inner * math.sin(theta), inner_raise - tooth_height_inner / 2), + (-radius_inner * math.sin(theta), 0), + ] + tooth = ( + Cq.Workplane('YZ') + .polyline(inner) + .close() + .workplane(offset=radius - radius_inner) + .polyline(outer) + .close() + .loft(combine=True) + .val() + ) + tooth_centre_radius = radius_inner * math.cos(theta) + teeth = ( + Cq.Workplane('XY') + .polarArray(radius=radius_inner, startAngle=0, angle=360, count=n_tooth) + .eachpoint(lambda loc: tooth.located(loc)) + .intersect(Cq.Solid.makeCylinder( + height=base_height + tooth_height, + radius=radius, + )) + ) + base = ( + Cq.Workplane('XY') + .cylinder( + height=base_height, + radius=radius, + centered=(True, True, False)) + .faces(">Z").tag("bore") + .union(teeth.val().move(Cq.Location((0,0,base_height)))) + .clean() + ) + #base.workplane(offset=tooth_height/2).circle(radius=radius,forConstruction=True).tag("mate") + base.polyline([(0, 0, 0), (0, 0, 1)], forConstruction=True).tag("mate") + return base + +def hirth_assembly(): + """ + Example assembly of two Hirth joints + """ + rotate = 180 / 16 + obj1 = hirth_joint().faces(tag="bore").cboreHole( + diameter=10, + cboreDiameter=20, + cboreDepth=3) + obj2 = ( + hirth_joint() + .rotate( + axisStartPoint=(0,0,0), + axisEndPoint=(0,0,1), + angleDegrees=rotate + ) + ) + result = ( + Cq.Assembly() + .add(obj1, name="obj1", color=Cq.Color(0.8,0.8,0.5,0.3)) + .add(obj2, name="obj2", color=Cq.Color(0.5,0.5,0.5,0.3), loc=Cq.Location((0,0,80))) + .constrain("obj1?mate", "obj2?mate", "Axis") + .solve() + ) + return result + +class TestJoints(unittest.TestCase): + + def test_hirth_assembly(self): + print(Cq.__version__) + hirth_assembly() + +if __name__ == '__main__': + unittest.main() 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..f91d538 --- /dev/null +++ b/nhf/touhou/houjuu_nue/__init__.py @@ -0,0 +1,17 @@ +import cadquery as Cq + +def mystery(): + return ( + Cq.Workplane() + .box(1, 1, 1) + .tag("base") + .wires(">Z") + .toPending() + .translate((0.1, 0.1, 1.0)) + .toPending() + .loft() + .faces(">>X", tag="base") + .workplane(centerOption="CenterOfMass") + .circle(0.2) + .extrude(3) + ) 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 -- 2.44.1 From a3f2b01b8c783976c4f7aed842531573e619c90e Mon Sep 17 00:00:00 2001 From: Leni Aniva Date: Wed, 19 Jun 2024 15:54:42 -0700 Subject: [PATCH 02/38] fix: Extraneous printing --- nhf/joints.py | 1 - 1 file changed, 1 deletion(-) diff --git a/nhf/joints.py b/nhf/joints.py index 805d89e..b066c77 100644 --- a/nhf/joints.py +++ b/nhf/joints.py @@ -99,7 +99,6 @@ def hirth_assembly(): class TestJoints(unittest.TestCase): def test_hirth_assembly(self): - print(Cq.__version__) hirth_assembly() if __name__ == '__main__': -- 2.44.1 From 75c06585ed2a8050cabecca237bd9823c3039b13 Mon Sep 17 00:00:00 2001 From: Leni Aniva Date: Wed, 19 Jun 2024 16:14:49 -0700 Subject: [PATCH 03/38] fix: Hirth joint mating line --- nhf/joints.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/nhf/joints.py b/nhf/joints.py index b066c77..9cdd0ad 100644 --- a/nhf/joints.py +++ b/nhf/joints.py @@ -8,7 +8,8 @@ def hirth_joint(radius=60, base_height=20, n_tooth=16, tooth_height=16, - tooth_height_inner=2): + tooth_height_inner=2, + tol=0.01): """ Creates a cylindrical Hirth Joint """ @@ -26,14 +27,16 @@ def hirth_joint(radius=60, # triangles. outer = [ (radius * math.tan(theta), 0), + (radius * math.tan(theta) - tol, 0), (0, tooth_height), + (-radius * math.tan(theta) + tol, 0), (-radius * math.tan(theta), 0), ] inner = [ (radius_inner * math.sin(theta), 0), - (radius_inner * math.sin(theta), inner_raise - tooth_height_inner / 2), - (0, inner_raise + tooth_height_inner / 2), - (-radius_inner * math.sin(theta), inner_raise - tooth_height_inner / 2), + (radius_inner * math.sin(theta), inner_raise), + (0, inner_raise + tooth_height_inner), + (-radius_inner * math.sin(theta), inner_raise), (-radius_inner * math.sin(theta), 0), ] tooth = ( @@ -43,13 +46,13 @@ def hirth_joint(radius=60, .workplane(offset=radius - radius_inner) .polyline(outer) .close() - .loft(combine=True) + .loft(ruled=True, combine=True) .val() ) tooth_centre_radius = radius_inner * math.cos(theta) teeth = ( Cq.Workplane('XY') - .polarArray(radius=radius_inner, startAngle=0, angle=360, count=n_tooth) + .polarArray(radius=tooth_centre_radius, startAngle=0, angle=360, count=n_tooth) .eachpoint(lambda loc: tooth.located(loc)) .intersect(Cq.Solid.makeCylinder( height=base_height + tooth_height, @@ -67,7 +70,7 @@ def hirth_joint(radius=60, .clean() ) #base.workplane(offset=tooth_height/2).circle(radius=radius,forConstruction=True).tag("mate") - base.polyline([(0, 0, 0), (0, 0, 1)], forConstruction=True).tag("mate") + base.polyline([(0, 0, base_height), (0, 0, base_height+tooth_height)], forConstruction=True).tag("mate") return base def hirth_assembly(): @@ -90,8 +93,8 @@ def hirth_assembly(): result = ( Cq.Assembly() .add(obj1, name="obj1", color=Cq.Color(0.8,0.8,0.5,0.3)) - .add(obj2, name="obj2", color=Cq.Color(0.5,0.5,0.5,0.3), loc=Cq.Location((0,0,80))) - .constrain("obj1?mate", "obj2?mate", "Axis") + .add(obj2, name="obj2", color=Cq.Color(0.5,0.5,0.5,0.3)) + .constrain("obj1?mate", "obj2?mate", "Plane") .solve() ) return result -- 2.44.1 From 8ad5eb9fe6c6668b420013b13d1a2f064166df2d Mon Sep 17 00:00:00 2001 From: Leni Aniva Date: Wed, 19 Jun 2024 21:23:41 -0700 Subject: [PATCH 04/38] feat: Comma joint, Nue wing root stub --- nhf/joints.py | 129 ++++++++++++++++++++++++++++++ nhf/touhou/houjuu_nue/__init__.py | 47 +++++++---- 2 files changed, 161 insertions(+), 15 deletions(-) diff --git a/nhf/joints.py b/nhf/joints.py index 9cdd0ad..02c7527 100644 --- a/nhf/joints.py +++ b/nhf/joints.py @@ -99,10 +99,139 @@ def hirth_assembly(): ) return result +def comma_joint(radius=30, + shaft_radius=10, + height=10, + flange=10, + flange_thickness=25, + n_serration=16, + serration_angle_offset=0, + serration_height=5, + serration_inner_radius=20, + serration_theta=2 * math.pi / 48, + serration_tilt=-30, + right_handed=False): + """ + Produces a "o_" shaped joint, with serrations to accomodate a torsion spring + """ + assert flange_thickness <= radius + flange_poly = [ + (0, radius - flange_thickness), + (0, radius), + (flange + radius, radius), + (flange + radius, radius - flange_thickness) + ] + if right_handed: + flange_poly = [(x, -y) for x,y in flange_poly] + sketch = ( + Cq.Sketch() + .circle(radius) + .polygon(flange_poly, mode='a') + .circle(shaft_radius, mode='s') + ) + serration_poly = [ + (0, 0), (radius, 0), + (radius, radius * math.tan(serration_theta)) + ] + serration = ( + Cq.Workplane('XY') + .sketch() + .polygon(serration_poly) + .circle(radius, mode='i') + .circle(serration_inner_radius, mode='s') + .finalize() + .extrude(serration_height) + .translate(Cq.Vector((-serration_inner_radius, 0, height))) + .rotate( + axisStartPoint=(0, 0, 0), + axisEndPoint=(0, 0, height), + angleDegrees=serration_tilt) + .val() + ) + serrations = ( + Cq.Workplane('XY') + .polarArray(radius=serration_inner_radius, + startAngle=0+serration_angle_offset, + angle=360+serration_angle_offset, + count=n_serration) + .eachpoint(lambda loc: serration.located(loc)) + ) + result = ( + Cq.Workplane() + .add(sketch) + .extrude(height) + .union(serrations) + .clean() + ) + + result.polyline([ + (0, 0, height - serration_height), + (0, 0, height + serration_height)], + forConstruction=True).tag("serrated") + result.polyline([ + (0, radius, 0), + (flange + radius, radius, 0)], + forConstruction=True).tag("tail") + result.faces('>X').tag("tail_end") + return result + +def torsion_spring(radius=12, + height=20, + thickness=2, + omega=90, + tail_length=25): + """ + Produces a torsion spring with abridged geometry since sweep is very slow in + cq-editor. + """ + base = ( + Cq.Workplane('XY') + .cylinder(height=height, radius=radius, + centered=(True, True, False)) + ) + base.faces(">Z").tag("mate_top") + base.faces("Z") - .toPending() - .translate((0.1, 0.1, 1.0)) - .toPending() - .loft() - .faces(">>X", tag="base") - .workplane(centerOption="CenterOfMass") - .circle(0.2) - .extrude(3) - ) +@dataclass(frozen=True) +class Parameters: + + """ + Thickness of the exoskeleton panel in millimetres + """ + panel_thickness: float = 25.4 / 16 + + # Wing root properties + """ + Radius of the mounting mechanism of the wing root. This is constrained by + the size of the harness. + """ + root_radius: float = 60 + + def wing_root(self, + side_width=30, + side_height=100): + """ + Generate the wing root which contains a Hirth joint at its base and a + rectangular opening on its side, with the necessary interfaces. + """ + result = ( + Cq.Workplane("XY") + .circle(self.root_radius) + .transformed(offset=Cq.Vector(80, 0, 80), + rotate=Cq.Vector(0, 45, 0)) + .rect(side_width, side_height) + .loft() + ) + return result -- 2.44.1 From 133c69b846d77133a2edac792f9a90a801b2aaa1 Mon Sep 17 00:00:00 2001 From: Leni Aniva Date: Thu, 20 Jun 2024 23:29:18 -0700 Subject: [PATCH 05/38] feat: Wing profile, unit testing --- README.md | 7 +++++ nhf/joints.py | 11 -------- nhf/test.py | 12 +++++++++ nhf/touhou/houjuu_nue/__init__.py | 44 +++++++++++++++++++++++++++++-- nhf/touhou/houjuu_nue/test.py | 14 ++++++++++ 5 files changed, 75 insertions(+), 13 deletions(-) create mode 100644 nhf/test.py create mode 100644 nhf/touhou/houjuu_nue/test.py diff --git a/README.md b/README.md index 282f834..ae83ed0 100644 --- a/README.md +++ b/README.md @@ -15,3 +15,10 @@ and this should succeed python3 -c "import nhf" ``` +## Testing + +Run all tests with +``` sh +python3 -m unittest +``` + diff --git a/nhf/joints.py b/nhf/joints.py index 02c7527..f809989 100644 --- a/nhf/joints.py +++ b/nhf/joints.py @@ -1,10 +1,8 @@ import cadquery as Cq import math -import unittest def hirth_joint(radius=60, radius_inner=40, - radius_centre=30, base_height=20, n_tooth=16, tooth_height=16, @@ -226,12 +224,3 @@ def comma_assembly(): ) return result -class TestJoints(unittest.TestCase): - - def test_hirth_assembly(self): - hirth_assembly() - def test_comma_assembly(self): - comma_assembly() - -if __name__ == '__main__': - unittest.main() diff --git a/nhf/test.py b/nhf/test.py new file mode 100644 index 0000000..03015ad --- /dev/null +++ b/nhf/test.py @@ -0,0 +1,12 @@ +import unittest +import nhf.joints + +class TestJoints(unittest.TestCase): + + def test_joints_hirth_assembly(self): + nhf.joints.hirth_assembly() + def test_joints_comma_assembly(self): + nhf.joints.comma_assembly() + +if __name__ == '__main__': + unittest.main() diff --git a/nhf/touhou/houjuu_nue/__init__.py b/nhf/touhou/houjuu_nue/__init__.py index 8eac42c..f9ee0ee 100644 --- a/nhf/touhou/houjuu_nue/__init__.py +++ b/nhf/touhou/houjuu_nue/__init__.py @@ -1,5 +1,6 @@ -import cadquery as Cq from dataclasses import dataclass +import unittest +import cadquery as Cq @dataclass(frozen=True) class Parameters: @@ -16,9 +17,46 @@ class Parameters: """ root_radius: float = 60 + """ + Heights for various wing joints, where the numbers start from the first joint. + """ + wing_r1_height = 100 + wing_r1_width = 400 + wing_r2_height = 100 + wing_r3_height = 100 + + def wing_r1_profile(self) -> Cq.Sketch: + """ + Generates the first wing segment profile, with the wing root pointing in + the positive x axis. + """ + # Depression of the wing middle + bend = 200 + factor = 0.7 + result = ( + Cq.Sketch() + .segment((0, 0), (0, self.wing_r1_height)) + .spline([ + (0, self.wing_r1_height), + (0.5 * self.wing_r1_width, self.wing_r1_height - factor * bend), + (self.wing_r1_width, self.wing_r1_height - bend), + ]) + .segment( + (self.wing_r1_width, self.wing_r1_height - bend), + (self.wing_r1_width, -bend), + ) + .spline([ + (self.wing_r1_width, - bend), + (0.5 * self.wing_r1_width, - factor * bend), + (0, 0), + ]) + .assemble() + ) + return result + def wing_root(self, side_width=30, - side_height=100): + side_height=100) -> Cq.Shape: """ Generate the wing root which contains a Hirth joint at its base and a rectangular opening on its side, with the necessary interfaces. @@ -30,5 +68,7 @@ class Parameters: rotate=Cq.Vector(0, 45, 0)) .rect(side_width, side_height) .loft() + .val() ) return result + diff --git a/nhf/touhou/houjuu_nue/test.py b/nhf/touhou/houjuu_nue/test.py new file mode 100644 index 0000000..0a9f148 --- /dev/null +++ b/nhf/touhou/houjuu_nue/test.py @@ -0,0 +1,14 @@ +import unittest +import nhf.touhou.houjuu_nue as M + +class Test(unittest.TestCase): + + def test_wing_root(self): + p = M.Parameters() + p.wing_root() + def test_wing_profiles(self): + p = M.Parameters() + p.wing_r1_profile() + +if __name__ == '__main__': + unittest.main() -- 2.44.1 From 0e5445ebb52a0ac8a5f6b0226b6c8e6135477af1 Mon Sep 17 00:00:00 2001 From: Leni Aniva Date: Thu, 20 Jun 2024 23:45:24 -0700 Subject: [PATCH 06/38] feat: Nue wing R1 --- nhf/touhou/houjuu_nue/__init__.py | 10 ++++++++++ nhf/touhou/houjuu_nue/test.py | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/nhf/touhou/houjuu_nue/__init__.py b/nhf/touhou/houjuu_nue/__init__.py index f9ee0ee..c4813ee 100644 --- a/nhf/touhou/houjuu_nue/__init__.py +++ b/nhf/touhou/houjuu_nue/__init__.py @@ -54,6 +54,16 @@ class Parameters: ) return result + def wing_r1(self) -> Cq.Solid: + profile = self.wing_r1_profile() + result = ( + Cq.Workplane("XY") + .placeSketch(profile) + .extrude(self.panel_thickness) + .val() + ) + return result + def wing_root(self, side_width=30, side_height=100) -> Cq.Shape: diff --git a/nhf/touhou/houjuu_nue/test.py b/nhf/touhou/houjuu_nue/test.py index 0a9f148..7124936 100644 --- a/nhf/touhou/houjuu_nue/test.py +++ b/nhf/touhou/houjuu_nue/test.py @@ -6,9 +6,9 @@ class Test(unittest.TestCase): def test_wing_root(self): p = M.Parameters() p.wing_root() - def test_wing_profiles(self): + def test_wings(self): p = M.Parameters() - p.wing_r1_profile() + p.wing_r1() if __name__ == '__main__': unittest.main() -- 2.44.1 From 376580003eb33e1fbf08d353e5b2ae04303cb0d0 Mon Sep 17 00:00:00 2001 From: Leni Aniva Date: Sat, 22 Jun 2024 13:40:06 -0700 Subject: [PATCH 07/38] feat: Base of Houjuu-Scarlett joint --- nhf/joints.py | 3 +- nhf/touhou/houjuu_nue/__init__.py | 70 +++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/nhf/joints.py b/nhf/joints.py index f809989..554d7b6 100644 --- a/nhf/joints.py +++ b/nhf/joints.py @@ -13,6 +13,7 @@ def hirth_joint(radius=60, """ # ensures secant doesn't blow up assert n_tooth >= 5 + assert radius > radius_inner # angle of half of a single tooth theta = math.pi / n_tooth @@ -64,7 +65,7 @@ def hirth_joint(radius=60, radius=radius, centered=(True, True, False)) .faces(">Z").tag("bore") - .union(teeth.val().move(Cq.Location((0,0,base_height)))) + .union(teeth.val().move(Cq.Location((0,0,base_height))), tol=tol) .clean() ) #base.workplane(offset=tooth_height/2).circle(radius=radius,forConstruction=True).tag("mate") diff --git a/nhf/touhou/houjuu_nue/__init__.py b/nhf/touhou/houjuu_nue/__init__.py index c4813ee..8d2c0c8 100644 --- a/nhf/touhou/houjuu_nue/__init__.py +++ b/nhf/touhou/houjuu_nue/__init__.py @@ -1,6 +1,7 @@ from dataclasses import dataclass import unittest import cadquery as Cq +import nhf.joints @dataclass(frozen=True) class Parameters: @@ -25,6 +26,75 @@ class Parameters: wing_r2_height = 100 wing_r3_height = 100 + """ + The Houjuu-Scarlett joint mechanism at the base of the wing + """ + hs_joint_base_width = 85 + hs_joint_base_thickness = 10 + hs_joint_ring_thickness = 10 + hs_joint_tooth_height = 10 + hs_joint_radius = 30 + hs_joint_radius_inner = 20 + hs_joint_corner_fillet = 5 + hs_joint_corner_cbore_diam = 12 + hs_joint_corner_cbore_depth = 12 + hs_joint_corner_diam = 15 + hs_joint_corner_inset = 15 + + def print_geometries(self): + return [ + self.hs_joint_parent() + ] + + def hs_joint_parent(self): + """ + Parent part of the Houjuu-Scarlett joint, which is composed of a Hirth + coupling, a cylindrical base, and a mounting base. + """ + hirth = nhf.joints.hirth_joint( + radius=self.hs_joint_radius, + radius_inner=self.hs_joint_radius_inner, + tooth_height=self.hs_joint_tooth_height, + base_height=self.hs_joint_ring_thickness) + hole_rect_width = self.hs_joint_base_width - 2 * self.hs_joint_corner_inset + hirth = ( + hirth + .faces("Z") + .workplane() + .rect(hole_rect_width, hole_rect_width, forConstruction=True) + .vertices() + .cboreHole( + diameter=self.hs_joint_corner_diam, + cboreDiameter=self.hs_joint_corner_cbore_diam, + cboreDepth=self.hs_joint_corner_cbore_depth) + .faces(">Z") + .workplane() + .union(hirth.move((0, 0, self.hs_joint_base_thickness)), tol=0.1) + .clean() + ) + return result + + + def wing_r1_profile(self) -> Cq.Sketch: """ Generates the first wing segment profile, with the wing root pointing in -- 2.44.1 From eb8a48fe7789b57d03a32ce7ff9de2e2f0087111 Mon Sep 17 00:00:00 2001 From: Leni Aniva Date: Sun, 23 Jun 2024 22:27:15 -0700 Subject: [PATCH 08/38] feat: Harness assembly --- nhf/__init__.py | 1 + nhf/materials.py | 18 ++++ nhf/touhou/houjuu_nue/__init__.py | 152 ++++++++++++++++++++++++++---- nhf/touhou/houjuu_nue/test.py | 3 + 4 files changed, 155 insertions(+), 19 deletions(-) create mode 100644 nhf/materials.py diff --git a/nhf/__init__.py b/nhf/__init__.py index e69de29..2175f24 100644 --- a/nhf/__init__.py +++ b/nhf/__init__.py @@ -0,0 +1 @@ +from nhf.materials import Material diff --git a/nhf/materials.py b/nhf/materials.py new file mode 100644 index 0000000..7ca621b --- /dev/null +++ b/nhf/materials.py @@ -0,0 +1,18 @@ +""" +A catalog of material properties +""" +from enum import Enum +import cadquery as Cq + +def _color(name: str, alpha: float) -> Cq.Color: + r, g, b, _ = Cq.Color(name).toTuple() + return Cq.Color(r, g, b, alpha) + +class Material(Enum): + + WOOD_BIRCH = 0.8, _color('bisque', 0.9) + PLASTIC_PLA = 0.5, _color('azure3', 0.6) + + def __init__(self, density: float, color: Cq.Color): + self.density = density + self.color = color diff --git a/nhf/touhou/houjuu_nue/__init__.py b/nhf/touhou/houjuu_nue/__init__.py index 8d2c0c8..7fd08e7 100644 --- a/nhf/touhou/houjuu_nue/__init__.py +++ b/nhf/touhou/houjuu_nue/__init__.py @@ -2,6 +2,7 @@ from dataclasses import dataclass import unittest import cadquery as Cq import nhf.joints +from nhf import Material @dataclass(frozen=True) class Parameters: @@ -11,8 +12,39 @@ class Parameters: """ panel_thickness: float = 25.4 / 16 + # Harness + harness_thickness: float = 25.4 / 8 + harness_width = 300 + harness_height = 400 + harness_fillet = 10 + + harness_wing_base_pos = [ + ("r1", 70, 150), + ("l1", -70, 150), + ("r2", 100, 0), + ("l2", -100, 0), + ("r3", 70, -150), + ("l3", -70, -150), + ] + + # Holes drilled onto harness for attachment with HS joint + harness_to_wing_base_hole_diam = 6 + # Wing root properties """ + The Houjuu-Scarlett joint mechanism at the base of the wing + """ + hs_joint_base_width = 85 + hs_joint_base_thickness = 10 + hs_joint_ring_thickness = 5 + hs_joint_tooth_height = 10 + hs_joint_radius = 30 + hs_joint_radius_inner = 20 + hs_joint_corner_fillet = 5 + hs_joint_corner_cbore_diam = 12 + hs_joint_corner_cbore_depth = 12 + hs_joint_corner_inset = 15 + """ Radius of the mounting mechanism of the wing root. This is constrained by the size of the harness. """ @@ -26,26 +58,77 @@ class Parameters: wing_r2_height = 100 wing_r3_height = 100 - """ - The Houjuu-Scarlett joint mechanism at the base of the wing - """ - hs_joint_base_width = 85 - hs_joint_base_thickness = 10 - hs_joint_ring_thickness = 10 - hs_joint_tooth_height = 10 - hs_joint_radius = 30 - hs_joint_radius_inner = 20 - hs_joint_corner_fillet = 5 - hs_joint_corner_cbore_diam = 12 - hs_joint_corner_cbore_depth = 12 - hs_joint_corner_diam = 15 - hs_joint_corner_inset = 15 def print_geometries(self): return [ self.hs_joint_parent() ] + def harness_profile(self) -> Cq.Sketch: + """ + Creates the harness shape + """ + w, h = self.harness_width / 2, self.harness_height / 2 + sketch = ( + Cq.Sketch() + .polygon([ + (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.harness_fillet) + ) + for tag, x, y in self.harness_wing_base_pos: + conn = [(px + x, py + y) for px, py in self.hs_joint_harness_conn()] + sketch = ( + sketch + .push(conn) + .tag(tag) + .circle(self.harness_to_wing_base_hole_diam / 2, mode='s') + .reset() + ) + return sketch + + def harness(self) -> Cq.Shape: + """ + Creates the harness shape + """ + result = ( + Cq.Workplane('XZ') + .placeSketch(self.harness_profile()) + .extrude(self.harness_thickness) + ) + result.faces(">Y").tag("mount") + plane = result.faces(">Y").workplane() + for tag, x, y in self.harness_wing_base_pos: + conn = [(px + x, py + y) for px, py in self.hs_joint_harness_conn()] + for i, (px, py) in enumerate(conn): + ( + plane + .moveTo(px, py) + .circle(1, forConstruction='True') + .edges() + .tag(f"{tag}_{i}") + ) + return result + + def hs_joint_harness_conn(self) -> list[tuple[int, int]]: + """ + Generates a set of points corresponding to the connectorss + """ + dx = self.hs_joint_base_width / 2 - self.hs_joint_corner_inset + return [ + (dx, dx), + (dx, -dx), + (-dx, -dx), + (-dx, dx), + ] + def hs_joint_parent(self): """ Parent part of the Houjuu-Scarlett joint, which is composed of a Hirth @@ -56,7 +139,6 @@ class Parameters: radius_inner=self.hs_joint_radius_inner, tooth_height=self.hs_joint_tooth_height, base_height=self.hs_joint_ring_thickness) - hole_rect_width = self.hs_joint_base_width - 2 * self.hs_joint_corner_inset hirth = ( hirth .faces("Z") .workplane() - .rect(hole_rect_width, hole_rect_width, forConstruction=True) - .vertices() + .pushPoints(conn) .cboreHole( - diameter=self.hs_joint_corner_diam, + diameter=self.harness_to_wing_base_hole_diam, cboreDiameter=self.hs_joint_corner_cbore_diam, cboreDepth=self.hs_joint_corner_cbore_depth) + ) + plane = result.faces(">Z").workplane(offset=-self.hs_joint_base_thickness) + for i, (px, py) in enumerate(conn): + ( + plane + .moveTo(px, py) + .circle(1, forConstruction='True') + .edges() + .tag(f"h{i}") + ) + result = ( + result .faces(">Z") .workplane() - .union(hirth.move((0, 0, self.hs_joint_base_thickness)), tol=0.1) + .union(hirth.move(Cq.Location((0, 0, self.hs_joint_base_thickness))), tol=0.1) .clean() ) + result.faces(" Date: Mon, 24 Jun 2024 11:05:03 -0700 Subject: [PATCH 09/38] feat: Use M12 centre hole for H-S joint --- nhf/touhou/houjuu_nue/__init__.py | 46 +++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/nhf/touhou/houjuu_nue/__init__.py b/nhf/touhou/houjuu_nue/__init__.py index 7fd08e7..ede9d9f 100644 --- a/nhf/touhou/houjuu_nue/__init__.py +++ b/nhf/touhou/houjuu_nue/__init__.py @@ -42,8 +42,14 @@ class Parameters: hs_joint_radius_inner = 20 hs_joint_corner_fillet = 5 hs_joint_corner_cbore_diam = 12 - hs_joint_corner_cbore_depth = 12 - hs_joint_corner_inset = 15 + hs_joint_corner_cbore_depth = 2 + hs_joint_corner_inset = 12 + + hs_joint_axis_diam = 12 + hs_joint_axis_cbore_diam = 20 + hs_joint_axis_cbore_depth = 3 + + """ Radius of the mounting mechanism of the wing root. This is constrained by the size of the harness. @@ -138,19 +144,19 @@ class Parameters: radius=self.hs_joint_radius, radius_inner=self.hs_joint_radius_inner, tooth_height=self.hs_joint_tooth_height, - base_height=self.hs_joint_ring_thickness) - hirth = ( - hirth - .faces(" Date: Mon, 24 Jun 2024 11:13:11 -0700 Subject: [PATCH 10/38] test: Joint integrity --- nhf/test.py | 5 +++++ nhf/touhou/houjuu_nue/test.py | 8 +++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/nhf/test.py b/nhf/test.py index 03015ad..9c215ff 100644 --- a/nhf/test.py +++ b/nhf/test.py @@ -1,8 +1,13 @@ import unittest +import cadquery as Cq import nhf.joints class TestJoints(unittest.TestCase): + def test_joint_hirth(self): + j = nhf.joints.hirth_joint() + assert isinstance(j.val().solids(), Cq.Solid),\ + "Hirth joint must be in one piece" def test_joints_hirth_assembly(self): nhf.joints.hirth_assembly() def test_joints_comma_assembly(self): diff --git a/nhf/touhou/houjuu_nue/test.py b/nhf/touhou/houjuu_nue/test.py index 435d7e8..91808a7 100644 --- a/nhf/touhou/houjuu_nue/test.py +++ b/nhf/touhou/houjuu_nue/test.py @@ -1,11 +1,17 @@ import unittest +import cadquery as Cq import nhf.touhou.houjuu_nue as M class Test(unittest.TestCase): + def test_hs_joint_parent(self): + p = M.Parameters() + obj = p.hs_joint_parent() + assert isinstance(obj.val().solids(), Cq.Solid), "H-S joint must be in one piece" def test_wing_root(self): p = M.Parameters() - p.wing_root() + obj = p.wing_root() + assert isinstance(obj.solids(), Cq.Solid), "Wing root must be in one piece" def test_wings(self): p = M.Parameters() p.wing_r1() -- 2.44.1 From 32e5f543d905e09b4c85e34c8c6648d080bd6ec0 Mon Sep 17 00:00:00 2001 From: Leni Aniva Date: Mon, 24 Jun 2024 16:13:15 -0700 Subject: [PATCH 11/38] feat: 2 segment wing root --- nhf/touhou/houjuu_nue/__init__.py | 75 ++++++++------ nhf/touhou/houjuu_nue/wing.py | 162 ++++++++++++++++++++++++++++++ 2 files changed, 209 insertions(+), 28 deletions(-) create mode 100644 nhf/touhou/houjuu_nue/wing.py diff --git a/nhf/touhou/houjuu_nue/__init__.py b/nhf/touhou/houjuu_nue/__init__.py index ede9d9f..8f2d8a3 100644 --- a/nhf/touhou/houjuu_nue/__init__.py +++ b/nhf/touhou/houjuu_nue/__init__.py @@ -1,15 +1,42 @@ +""" +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 import unittest import cadquery as Cq import nhf.joints from nhf import Material +import nhf.touhou.houjuu_nue.wing as MW @dataclass(frozen=True) class Parameters: + """ + Defines dimensions for the Houjuu Nue cosplay + """ - """ - Thickness of the exoskeleton panel in millimetres - """ + # Thickness of the exoskeleton panel in millimetres panel_thickness: float = 25.4 / 16 # Harness @@ -28,12 +55,11 @@ class Parameters: ] # Holes drilled onto harness for attachment with HS joint - harness_to_wing_base_hole_diam = 6 + harness_to_root_conn_diam = 6 # Wing root properties - """ - The Houjuu-Scarlett joint mechanism at the base of the wing - """ + # + # The Houjuu-Scarlett joint mechanism at the base of the wing hs_joint_base_width = 85 hs_joint_base_thickness = 10 hs_joint_ring_thickness = 5 @@ -49,12 +75,8 @@ class Parameters: hs_joint_axis_cbore_diam = 20 hs_joint_axis_cbore_depth = 3 - - """ - Radius of the mounting mechanism of the wing root. This is constrained by - the size of the harness. - """ - root_radius: float = 60 + # Exterior radius of the wing root assembly + wing_root_radius = 40 """ Heights for various wing joints, where the numbers start from the first joint. @@ -64,6 +86,10 @@ class Parameters: wing_r2_height = 100 wing_r3_height = 100 + def __post_init__(self): + assert self.wing_root_radius > self.hs_joint_radius,\ + "Wing root must be large enough to accomodate joint" + def print_geometries(self): return [ @@ -95,7 +121,7 @@ class Parameters: sketch .push(conn) .tag(tag) - .circle(self.harness_to_wing_base_hole_diam / 2, mode='s') + .circle(self.harness_to_root_conn_diam / 2, mode='s') .reset() ) return sketch @@ -171,7 +197,7 @@ class Parameters: .workplane() .pushPoints(conn) .cboreHole( - diameter=self.harness_to_wing_base_hole_diam, + diameter=self.harness_to_root_conn_diam, cboreDiameter=self.hs_joint_corner_cbore_diam, cboreDepth=self.hs_joint_corner_cbore_depth) ) @@ -245,23 +271,16 @@ class Parameters: ) return result - def wing_root(self, - side_width=30, - side_height=100) -> Cq.Shape: + def wing_root(self) -> Cq.Shape: """ Generate the wing root which contains a Hirth joint at its base and a rectangular opening on its side, with the necessary interfaces. """ - result = ( - Cq.Workplane("XY") - .circle(self.root_radius) - .transformed(offset=Cq.Vector(80, 0, 80), - rotate=Cq.Vector(0, 45, 0)) - .rect(side_width, side_height) - .loft() - .val() - ) - return result + return MW.wing_base().val()#self.wing_root_radius) + + ###################### + # Assemblies # + ###################### def harness_assembly(self): harness = self.harness() diff --git a/nhf/touhou/houjuu_nue/wing.py b/nhf/touhou/houjuu_nue/wing.py new file mode 100644 index 0000000..a6fa011 --- /dev/null +++ b/nhf/touhou/houjuu_nue/wing.py @@ -0,0 +1,162 @@ +import math +import cadquery as Cq + +def wing_root_profiles( + base_sweep=150, + wall_thickness=8, + base_radius=60, + middle_offset=30, + conn_width=40, + conn_height=100) -> tuple[Cq.Wire, Cq.Wire]: + assert base_sweep < 180 + assert middle_offset > 0 + theta = math.pi * base_sweep / 180 + c, s = math.cos(theta), math.sin(theta) + c_1, s_1 = math.cos(theta * 0.75), math.sin(theta * 0.75) + c_2, s_2 = math.cos(theta / 2), math.sin(theta / 2) + r1 = base_radius + r2 = base_radius - wall_thickness + base = ( + Cq.Sketch() + .arc( + (c * r1, s * r1), + (c_1 * r1, s_1 * r1), + (c_2 * r1, s_2 * r1), + ) + .arc( + (c_2 * r1, s_2 * r1), + (r1, 0), + (c_2 * r1, -s_2 * r1), + ) + .arc( + (c_2 * r1, -s_2 * r1), + (c_1 * r1, -s_1 * r1), + (c * r1, -s * r1), + ) + .segment( + (c * r1, -s * r1), + (c * r2, -s * r2), + ) + .arc( + (c * r2, -s * r2), + (c_1 * r2, -s_1 * r2), + (c_2 * r2, -s_2 * r2), + ) + .arc( + (c_2 * r2, -s_2 * r2), + (r2, 0), + (c_2 * r2, s_2 * r2), + ) + .arc( + (c_2 * r2, s_2 * r2), + (c_1 * r2, s_1 * r2), + (c * r2, s * r2), + ) + .segment( + (c * r2, s * r2), + (c * r1, s * r1), + ) + .assemble(tag="wire") + .wires().val() + ) + assert isinstance(base, Cq.Wire) + + # The interior sweep is given by theta, but the exterior sweep exceeds the + # interior sweep so the wall does not become thinner towards the edges. + # If the exterior sweep is theta', it has to satisfy + # + # sin(theta) * r2 + wall_thickness = sin(theta') * r1 + x, y = conn_width / 2, conn_height / 2 + t = wall_thickness + dx = middle_offset + middle = ( + Cq.Sketch() + # Interior arc, top point + .arc( + (x - t, y - t), + (x - t + dx, 0), + (x - t, -y + t), + ) + .segment( + (x - t, -y + t), + (-x, -y+t) + ) + .segment((-x, -y)) + .segment((x, -y)) + # Outer arc, bottom point + .arc( + (x, -y), + (x + dx, 0), + (x, y), + ) + .segment( + (x, y), + (-x, y) + ) + .segment((-x, y-t)) + #.segment((x2, a)) + .close() + .assemble(tag="wire") + .wires().val() + ) + assert isinstance(middle, Cq.Wire) + + x, y = conn_width / 2, conn_height / 2 + t = wall_thickness + tip = ( + Cq.Sketch() + .segment((-x, y), (x, y)) + .segment((x, -y)) + .segment((-x, -y)) + .segment((-x, -y+t)) + .segment((x-t, -y+t)) + .segment((x-t, y-t)) + .segment((-x, y-t)) + .close() + .assemble(tag="wire") + .wires().val() + ) + return base, middle, tip +def wing_base(): + root_profile, middle_profile, tip_profile = wing_root_profiles() + + rotate_centre = Cq.Vector(-200, 0, -25) + rotate_axis = Cq.Vector(0, 1, 0) + terminal_offset = Cq.Vector(-80, 0, 80) + terminal_rotate = Cq.Vector(0, -45, 0) + + #middle_profile = middle_profile.moved(Cq.Location((0, 0, -100))) + #tip_profile = tip_profile.moved(Cq.Location((0, 0, -200))) + middle_profile = middle_profile.rotate( + startVector=rotate_centre, + endVector=rotate_centre + rotate_axis, + angleDegrees = 35, + ) + tip_profile = tip_profile.rotate( + startVector=rotate_centre, + endVector=rotate_centre + rotate_axis, + angleDegrees = 70, + ) + seg1 = ( + Cq.Workplane('XY') + .add(root_profile) + .toPending() + .transformed( + offset=terminal_offset, + rotate=terminal_rotate) + #.add(middle_profile.moved(Cq.Location((-15, 0, 15)))) + .add(middle_profile) + .toPending() + .loft() + ) + seg2 = ( + Cq.Workplane('XY') + .add(middle_profile) + .toPending() + .workplane() + .add(tip_profile) + .toPending() + .loft() + ) + seg1 = seg1.union(seg2) + return seg1 -- 2.44.1 From a41906d13011b2fcfbaa9fa0693cf884243d9cf3 Mon Sep 17 00:00:00 2001 From: Leni Aniva Date: Tue, 25 Jun 2024 09:11:48 -0400 Subject: [PATCH 12/38] feat: Trident handle --- nhf/handle.py | 156 ++++++++++++++++++++++++++++++ nhf/materials.py | 1 + nhf/test.py | 9 ++ nhf/touhou/houjuu_nue/__init__.py | 10 +- nhf/touhou/houjuu_nue/test.py | 3 + nhf/touhou/houjuu_nue/trident.py | 30 ++++++ 6 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 nhf/handle.py create mode 100644 nhf/touhou/houjuu_nue/trident.py diff --git a/nhf/handle.py b/nhf/handle.py new file mode 100644 index 0000000..0509897 --- /dev/null +++ b/nhf/handle.py @@ -0,0 +1,156 @@ +""" +This schematics file contains all designs related to tool handles +""" +from dataclasses import dataclass +import cadquery as Cq + +@dataclass(frozen=True) +class Handle: + """ + Characteristic of a tool handle + """ + + # Outer radius for the handle + radius: float = 38 / 2 + + # Inner radius + radius_inner: float = 33 / 2 + + # Wall thickness for the connector + insertion_thickness: float = 4 + + # The connector goes in the insertion + connector_thickness: float = 4 + + # Length for the rim on the female connector + rim_length: float = 5 + + insertion_length: float = 60 + + connector_length: float = 60 + + def __post_init__(self): + assert self.radius > self.radius_inner + assert self.radius_inner > self.insertion_thickness + self.connector_thickness + assert self.insertion_length > self.rim_length + + @property + def _r1(self): + """ + Radius of inside of insertion + """ + return self.radius_inner - self.insertion_thickness + @property + def _r2(self): + """ + Radius of inside of connector + """ + return self._r1 - self.connector_thickness + + def segment(self, length: float): + result = ( + Cq.Workplane() + .cylinder(radius=self.radius, height=length) + ) + result.faces("Z").tag("mate2") + return result + + def insertion(self): + """ + This type of joint is used to connect two handlebar pieces. Each handlebar + piece is a tube which cannot be machined, so the joint connects to the + handle by glue. + + Tags: + * lip: Co-planar Mates to the rod + * mate: Mates to the connector + """ + result = ( + Cq.Workplane('XY') + .cylinder( + radius=self.radius_inner, + height=self.insertion_length - self.rim_length, + centered=[True, True, False]) + ) + result.faces(">Z").tag("lip") + result = ( + result.faces(">Z") + .workplane() + .circle(self.radius) + .extrude(self.rim_length) + .faces(">Z") + .hole(2 * self._r1) + ) + result.faces(">Z").tag("mate") + return result + + def connector(self, solid: bool = False): + """ + Tags: + * mate{1,2}: Mates to the connector + """ + result = ( + Cq.Workplane('XY') + .cylinder( + radius=self.radius, + height=self.connector_length, + ) + ) + for (tag, selector) in [("mate1", "Z")]: + result.faces(selector).tag(tag) + r1 = self.radius_inner + result = ( + result + .faces(selector) + .workplane() + .circle(self._r1) + .extrude(self.insertion_length) + ) + if not solid: + result = result.faces(">Z").hole(2 * self._r2) + return result + + def one_side_connector(self): + result = ( + Cq.Workplane('XY') + .cylinder( + radius=self.radius, + height=self.rim_length, + ) + ) + result.faces("Z").tag("base") + result = ( + result + .faces(" self.hs_joint_radius,\ "Wing root must be large enough to accomodate joint" @@ -301,3 +306,6 @@ class Parameters: result.constrain(f"harness?{name}_{i}", f"hs_{name}p?h{i}", "Point") result.solve() return result + + def trident_assembly(self): + return MT.trident_assembly(self.trident_handle) diff --git a/nhf/touhou/houjuu_nue/test.py b/nhf/touhou/houjuu_nue/test.py index 91808a7..2eb8cd7 100644 --- a/nhf/touhou/houjuu_nue/test.py +++ b/nhf/touhou/houjuu_nue/test.py @@ -18,6 +18,9 @@ class Test(unittest.TestCase): def test_harness(self): p = M.Parameters() p.harness_assembly() + def test_trident(): + p = M.Parameters() + p.trident_assembly() 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..e270e74 --- /dev/null +++ b/nhf/touhou/houjuu_nue/trident.py @@ -0,0 +1,30 @@ +import math +import cadquery as Cq +from nhf import Material +from nhf.handle import Handle + +def trident_assembly( + handle: Handle, + handle_segment_length: float = 24*25.4): + def segment(): + return handle.segment(handle_segment_length) + mat_i = Material.PLASTIC_PLA + mat_s = Material.ACRYLIC_BLACK + assembly = ( + Cq.Assembly() + .add(handle.insertion(), name="i0", color=mat_i.color) + .constrain("i0", "Fixed") + .add(segment(), name="s1", color=mat_s.color) + .constrain("i0?lip", "s1?mate1", "Plane", param=0) + .add(handle.insertion(), name="i1", color=mat_i.color) + .add(handle.connector(), name="c1", color=mat_i.color) + .add(handle.insertion(), name="i2", color=mat_i.color) + .constrain("s1?mate2", "i1?lip", "Plane", param=0) + .constrain("i1?mate", "c1?mate1", "Plane") + .constrain("i2?mate", "c1?mate2", "Plane") + .add(segment(), name="s2", color=mat_s.color) + .constrain("i2?lip", "s2?mate1", "Plane", param=0) + .add(handle.insertion(), name="i3", color=mat_i.color) + .constrain("s2?mate2", "i3?lip", "Plane", param=0) + ) + return assembly.solve() -- 2.44.1 From cec2f4da5527a5e9ca955aac50db893794d533b6 Mon Sep 17 00:00:00 2001 From: Leni Aniva Date: Wed, 26 Jun 2024 09:42:50 -0400 Subject: [PATCH 13/38] test: Trident length --- nhf/touhou/houjuu_nue/test.py | 12 ++++++++---- nhf/touhou/houjuu_nue/trident.py | 2 ++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/nhf/touhou/houjuu_nue/test.py b/nhf/touhou/houjuu_nue/test.py index 2eb8cd7..1f6265a 100644 --- a/nhf/touhou/houjuu_nue/test.py +++ b/nhf/touhou/houjuu_nue/test.py @@ -7,20 +7,24 @@ class Test(unittest.TestCase): def test_hs_joint_parent(self): p = M.Parameters() obj = p.hs_joint_parent() - assert isinstance(obj.val().solids(), Cq.Solid), "H-S joint must be in one piece" + self.assertIsInstance(obj.val().solids(), Cq.Solid, msg="H-S joint must be in one piece") def test_wing_root(self): p = M.Parameters() obj = p.wing_root() - assert isinstance(obj.solids(), Cq.Solid), "Wing root must be in one piece" + self.assertIsInstance(obj.solids(), Cq.Solid, msg="Wing root must be in one piece") def test_wings(self): p = M.Parameters() p.wing_r1() def test_harness(self): p = M.Parameters() p.harness_assembly() - def test_trident(): + def test_trident(self): p = M.Parameters() - p.trident_assembly() + assembly = p.trident_assembly() + bbox = assembly.toCompound().BoundingBox() + length = bbox.zlen + self.assertGreater(length, 1300) + self.assertLess(length, 1700) if __name__ == '__main__': unittest.main() diff --git a/nhf/touhou/houjuu_nue/trident.py b/nhf/touhou/houjuu_nue/trident.py index e270e74..75e4d73 100644 --- a/nhf/touhou/houjuu_nue/trident.py +++ b/nhf/touhou/houjuu_nue/trident.py @@ -26,5 +26,7 @@ def trident_assembly( .constrain("i2?lip", "s2?mate1", "Plane", param=0) .add(handle.insertion(), name="i3", color=mat_i.color) .constrain("s2?mate2", "i3?lip", "Plane", param=0) + .add(handle.one_side_connector(), name="head", color=mat_i.color) + .constrain("i3?mate", "head?mate", "Plane") ) return assembly.solve() -- 2.44.1 From 0c42f71c9fa3f05c91c3ede704bce7e9536a60d3 Mon Sep 17 00:00:00 2001 From: Leni Aniva Date: Wed, 26 Jun 2024 09:44:02 -0400 Subject: [PATCH 14/38] fix: Don't user `assert` in unit tests --- nhf/test.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/nhf/test.py b/nhf/test.py index 37a750d..5695c41 100644 --- a/nhf/test.py +++ b/nhf/test.py @@ -7,8 +7,9 @@ class TestJoints(unittest.TestCase): def test_joint_hirth(self): j = nhf.joints.hirth_joint() - assert isinstance(j.val().solids(), Cq.Solid),\ - "Hirth joint must be in one piece" + self.assertIsInstance( + j.val().solids(), Cq.Solid, + msg="Hirth joint must be in one piece") def test_joints_hirth_assembly(self): nhf.joints.hirth_assembly() def test_joints_comma_assembly(self): -- 2.44.1 From 57e1bce3e7fea7365beab1e898c4e9935657695b Mon Sep 17 00:00:00 2001 From: Leni Aniva Date: Wed, 26 Jun 2024 09:48:32 -0400 Subject: [PATCH 15/38] feat: Material list --- README.md | 7 +++++++ nhf/materials.py | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 nhf/materials.py diff --git a/README.md b/README.md index 282f834..ae83ed0 100644 --- a/README.md +++ b/README.md @@ -15,3 +15,10 @@ and this should succeed python3 -c "import nhf" ``` +## Testing + +Run all tests with +``` sh +python3 -m unittest +``` + diff --git a/nhf/materials.py b/nhf/materials.py new file mode 100644 index 0000000..c752cd4 --- /dev/null +++ b/nhf/materials.py @@ -0,0 +1,19 @@ +""" +A catalog of material properties +""" +from enum import Enum +import cadquery as Cq + +def _color(name: str, alpha: float) -> Cq.Color: + r, g, b, _ = Cq.Color(name).toTuple() + return Cq.Color(r, g, b, alpha) + +class Material(Enum): + + WOOD_BIRCH = 0.8, _color('bisque', 0.9) + PLASTIC_PLA = 0.5, _color('azure3', 0.6) + ACRYLIC_BLACK = 0.5, _color('gray50', 0.6) + + def __init__(self, density: float, color: Cq.Color): + self.density = density + self.color = color -- 2.44.1 From d823a58d8865a5a0e6ee9a43ba39ef799785bfea Mon Sep 17 00:00:00 2001 From: Leni Aniva Date: Wed, 26 Jun 2024 11:28:25 -0400 Subject: [PATCH 16/38] feat: Metric threads on handle --- nhf/handle.py | 119 ++++++--- nhf/metric_threads.py | 422 ++++++++++++++++++++++++++++++ nhf/test.py | 22 +- nhf/touhou/houjuu_nue/__init__.py | 10 +- nhf/touhou/houjuu_nue/trident.py | 8 +- 5 files changed, 537 insertions(+), 44 deletions(-) create mode 100644 nhf/metric_threads.py diff --git a/nhf/handle.py b/nhf/handle.py index 0509897..fbd8c56 100644 --- a/nhf/handle.py +++ b/nhf/handle.py @@ -3,59 +3,89 @@ This schematics file contains all designs related to tool handles """ from dataclasses import dataclass import cadquery as Cq +import nhf.metric_threads as NMt @dataclass(frozen=True) class Handle: """ Characteristic of a tool handle + + This assumes the handle segment material does not have threads. Each segment + attaches to two insertions, which have threads on the inside. A connector + has threads on the outside and joints two insertions. + + Note that all the radial sizes are diameters (in mm). """ - # Outer radius for the handle - radius: float = 38 / 2 + # Outer and inner radius for the handle usually come in standard sizes + diam: float = 38 + diam_inner: float = 33 - # Inner radius - radius_inner: float = 33 / 2 + # Major diameter of the internal threads, following ISO metric screw thread + # standard. This determines the wall thickness of the insertion. + diam_threading: float = 27.0 - # Wall thickness for the connector - insertion_thickness: float = 4 + thread_pitch: float = 3.0 - # The connector goes in the insertion - connector_thickness: float = 4 + # Internal cavity diameter. This determines the wall thickness of the connector + diam_connector_internal: float = 18.0 + + # If set to true, do not generate threads + simplify_geometry: bool = True # Length for the rim on the female connector rim_length: float = 5 insertion_length: float = 60 + # Amount by which the connector goes into the segment connector_length: float = 60 def __post_init__(self): - assert self.radius > self.radius_inner - assert self.radius_inner > self.insertion_thickness + self.connector_thickness + assert self.diam > self.diam_inner, "Material thickness cannot be <= 0" + assert self.diam_inner > self.diam_insertion_internal, "Threading radius is too big" + assert self.diam_insertion_internal > self.diam_connector_external + assert self.diam_connector_external > self.diam_connector_internal, "Internal diameter is too large" assert self.insertion_length > self.rim_length @property - def _r1(self): - """ - Radius of inside of insertion - """ - return self.radius_inner - self.insertion_thickness + def diam_insertion_internal(self): + r = NMt.metric_thread_major_radius( + self.diam_threading, + self.thread_pitch, + internal=True) + return r * 2 + @property - def _r2(self): - """ - Radius of inside of connector - """ - return self._r1 - self.connector_thickness + def diam_connector_external(self): + r = NMt.metric_thread_minor_radius( + self.diam_threading, + self.thread_pitch) + return r * 2 def segment(self, length: float): result = ( Cq.Workplane() - .cylinder(radius=self.radius, height=length) + .cylinder( + radius=self.diam / 2, + height=length) ) result.faces("Z").tag("mate2") return result + def _external_thread(self): + return NMt.external_metric_thread( + self.diam_threading, + self.thread_pitch, + self.insertion_length, + top_lead_in=True) + def _internal_thread(self): + return NMt.internal_metric_thread( + self.diam_threading, + self.thread_pitch, + self.insertion_length) + def insertion(self): """ This type of joint is used to connect two handlebar pieces. Each handlebar @@ -69,20 +99,24 @@ class Handle: result = ( Cq.Workplane('XY') .cylinder( - radius=self.radius_inner, + radius=self.diam_inner / 2, height=self.insertion_length - self.rim_length, centered=[True, True, False]) ) - result.faces(">Z").tag("lip") - result = ( - result.faces(">Z") - .workplane() - .circle(self.radius) - .extrude(self.rim_length) - .faces(">Z") - .hole(2 * self._r1) - ) + result.faces(">Z").tag("rim") + if self.rim_length > 0: + result = ( + result.faces(">Z") + .workplane() + .circle(self.diam / 2) + .extrude(self.rim_length) + .faces(">Z") + .hole(self.diam_insertion_internal) + ) result.faces(">Z").tag("mate") + if not self.simplify_geometry: + thread = self._internal_thread().val() + result = result.union(thread) return result def connector(self, solid: bool = False): @@ -93,29 +127,40 @@ class Handle: result = ( Cq.Workplane('XY') .cylinder( - radius=self.radius, + radius=self.diam / 2, height=self.connector_length, ) ) for (tag, selector) in [("mate1", "Z")]: result.faces(selector).tag(tag) - r1 = self.radius_inner result = ( result .faces(selector) .workplane() - .circle(self._r1) + .circle(self.diam_connector_external / 2) .extrude(self.insertion_length) ) if not solid: - result = result.faces(">Z").hole(2 * self._r2) + result = result.faces(">Z").hole(self.diam_connector_internal) + if not self.simplify_geometry: + thread = self._external_thread().val() + result = ( + result + .union( + thread + .moved(Cq.Vector(0, 0, self.connector_length / 2))) + .union( + thread + .rotate((0,0,0), (1,0,0), angleDegrees=90) + .moved(Cq.Vector(0, 0, -self.connector_length / 2))) + ) return result def one_side_connector(self): result = ( Cq.Workplane('XY') .cylinder( - radius=self.radius, + radius=self.diam / 2, height=self.rim_length, ) ) @@ -125,7 +170,7 @@ class Handle: result .faces(" radians. +def __deg2rad(degrees): + return degrees * math.pi / 180 + +# In the absence of flat thread valley and flattened thread tip, returns the +# amount by which the thread "triangle" protrudes outwards (radially) from base +# cylinder in the case of external thread, or the amount by which the thread +# "triangle" protrudes inwards from base tube in the case of internal thread. +def metric_thread_perfect_height(pitch): + return pitch / (2 * math.tan(__deg2rad(metric_thread_angle()))) + +# Up the radii of internal (female) thread in order to provide a little bit of +# wiggle room around male thread. Right now input parameter 'diameter' is +# ignored. This function is only used for internal/female threads. Currently +# there is no practical way to adjust the male/female thread clearance besides +# to manually edit this function. This design route was chosen for the sake of +# code simplicity. +def __metric_thread_internal_radius_increase(diameter, pitch): + return 0.1 * metric_thread_perfect_height(pitch) + +# Returns the major radius of thread, which is always the greater of the two. +def metric_thread_major_radius(diameter, pitch, internal=False): + return (__metric_thread_internal_radius_increase(diameter, pitch) if + internal else 0.0) + (diameter / 2) + +# What portion of the total pitch is taken up by the angled thread section (and +# not the squared off valley and tip). The remaining portion (1 minus ratio) +# will be divided equally between the flattened valley and flattened tip. +def __metric_thread_effective_ratio(): + return 0.7 + +# Returns the minor radius of thread, which is always the lesser of the two. +def metric_thread_minor_radius(diameter, pitch, internal=False): + return (metric_thread_major_radius(diameter, pitch, internal) + - (__metric_thread_effective_ratio() * + metric_thread_perfect_height(pitch))) + +# What the major radius would be if the cuts were perfectly triangular, without +# flat spots in the valleys and without flattened tips. +def metric_thread_perfect_major_radius(diameter, pitch, internal=False): + return (metric_thread_major_radius(diameter, pitch, internal) + + ((1.0 - __metric_thread_effective_ratio()) * + metric_thread_perfect_height(pitch) / 2)) + +# What the minor radius would be if the cuts were perfectly triangular, without +# flat spots in the valleys and without flattened tips. +def metric_thread_perfect_minor_radius(diameter, pitch, internal=False): + return (metric_thread_perfect_major_radius(diameter, pitch, internal) + - metric_thread_perfect_height(pitch)) + +# Returns the lead-in and/or chamfer distance along the z axis of rotation. +# The lead-in/chamfer only depends on the pitch and is made with the same angle +# as the thread, that being 30 degrees offset from radial. +def metric_thread_lead_in(pitch, internal=False): + return (math.tan(__deg2rad(metric_thread_angle())) + * (metric_thread_major_radius(256.0, pitch, internal) + - metric_thread_minor_radius(256.0, pitch, internal))) + +# Returns the width of the flat spot in thread valley of a standard thread. +# This is also equal to the width of the flat spot on thread tip, on a standard +# thread. +def metric_thread_relief(pitch): + return (1.0 - __metric_thread_effective_ratio()) * pitch / 2 + + +############################################################################### +# A few words on modules external_metric_thread() and internal_metric_thread(). +# The parameter 'z_start' is added as a convenience in order to make the male +# and female threads align perfectly. When male and female threads are created +# having the same diameter, pitch, and n_starts (usually 1), then so long as +# they are not translated or rotated (or so long as they are subjected to the +# same exact translation and rotation), they will intermesh perfectly, +# regardless of the value of 'z_start' used on each. This is in order that +# assemblies be able to depict perfectly aligning threads. + +# Generates threads with base cylinder unless 'base_cylinder' is overridden. +# Please note that 'use_epsilon' is activated by default, which causes a slight +# budge in the minor radius, inwards, so that overlaps would be created with +# inner cylinders. (Does not affect thread profile outside of cylinder.) +############################################################################### +def external_metric_thread(diameter, # Required parameter, e.g. 3.0 for M3x0.5 + pitch, # Required parameter, e.g. 0.5 for M3x0.5 + length, # Required parameter, e.g. 2.0 + z_start=0.0, + n_starts=1, + bottom_lead_in=False, # Lead-in is at same angle as + top_lead_in =False, # thread, namely 30 degrees. + bottom_relief=False, # Add relief groove to start or + top_relief =False, # end of threads (shorten). + force_outer_radius=-1.0, # Set close to diameter/2. + use_epsilon=True, # For inner cylinder overlap. + base_cylinder=True, # Whether to include base cyl. + cyl_extend_bottom=-1.0, + cyl_extend_top=-1.0, + envelope=False): # Draw only envelope, don't cut. + + cyl_extend_bottom = max(0.0, cyl_extend_bottom) + cyl_extend_top = max(0.0, cyl_extend_top) + + z_off = (1.0 - __metric_thread_effective_ratio()) * pitch / 4 + t_start = z_start + t_length = length + if bottom_relief: + t_start = t_start + (2 * z_off) + t_length = t_length - (2 * z_off) + if top_relief: + t_length = t_length - (2 * z_off) + outer_r = (force_outer_radius if (force_outer_radius > 0.0) else + metric_thread_major_radius(diameter,pitch)) + inner_r = metric_thread_minor_radius(diameter,pitch) + epsilon = 0 + inner_r_adj = inner_r + inner_z_budge = 0 + if use_epsilon: + epsilon = (z_off/3) / math.tan(__deg2rad(metric_thread_angle())) + inner_r_adj = inner_r - epsilon + inner_z_budge = math.tan(__deg2rad(metric_thread_angle())) * epsilon + + if envelope: + threads = cq.Workplane("XZ") + threads = threads.moveTo(inner_r_adj, -pitch) + threads = threads.lineTo(outer_r, -pitch) + threads = threads.lineTo(outer_r, t_length + pitch) + threads = threads.lineTo(inner_r_adj, t_length + pitch) + threads = threads.close() + threads = threads.revolve() + + else: # Not envelope, cut the threads. + wire = cq.Wire.makeHelix(pitch=pitch*n_starts, + height=t_length+pitch, + radius=inner_r) + wire = wire.translate((0,0,-pitch/2)) + wire = wire.rotate(startVector=(0,0,0), endVector=(0,0,1), + angleDegrees=360*(-pitch/2)/(pitch*n_starts)) + d_mid = ((metric_thread_major_radius(diameter,pitch) - outer_r) + * math.tan(__deg2rad(metric_thread_angle()))) + thread = cq.Workplane("XZ") + thread = thread.moveTo(inner_r_adj, -pitch/2 + z_off - inner_z_budge) + thread = thread.lineTo(outer_r, -(z_off + d_mid)) + thread = thread.lineTo(outer_r, z_off + d_mid) + thread = thread.lineTo(inner_r_adj, pitch/2 - z_off + inner_z_budge) + thread = thread.close() + thread = thread.sweep(wire, isFrenet=True) + threads = thread + for addl_start in range(1, n_starts): + # TODO: Incremental/cumulative rotation may not be as accurate as + # keeping 'thread' intact and rotating it by correct amount + # on each iteration. However, changing the code in that + # regard may disrupt the delicate nature of workarounds + # with repsect to quirks in the underlying B-rep library. + thread = thread.rotate(axisStartPoint=(0,0,0), + axisEndPoint=(0,0,1), + angleDegrees=360/n_starts) + threads = threads.union(thread) + + square_shave = cq.Workplane("XY") + square_shave = square_shave.box(length=outer_r*3, width=outer_r*3, + height=pitch*2, centered=True) + square_shave = square_shave.translate((0,0,-pitch)) # Because centered. + # Always cut the top and bottom square. Otherwise things don't play nice. + threads = threads.cut(square_shave) + + if bottom_lead_in: + delta_r = outer_r - inner_r + rise = math.tan(__deg2rad(metric_thread_angle())) * delta_r + lead_in = cq.Workplane("XZ") + lead_in = lead_in.moveTo(inner_r - delta_r, -rise) + lead_in = lead_in.lineTo(outer_r + delta_r, 2 * rise) + lead_in = lead_in.lineTo(outer_r + delta_r, -pitch - rise) + lead_in = lead_in.lineTo(inner_r - delta_r, -pitch - rise) + lead_in = lead_in.close() + lead_in = lead_in.revolve() + threads = threads.cut(lead_in) + + # This was originally a workaround to the anomalous B-rep computation where + # the top of base cylinder is flush with top of threads, without the use of + # lead-in. It turns out that preferring the use of the 'render_cyl_early' + # strategy alleviates other problems as well. + render_cyl_early = (base_cylinder and ((not top_relief) and + (not (cyl_extend_top > 0.0)) and + (not envelope))) + render_cyl_late = (base_cylinder and (not render_cyl_early)) + if render_cyl_early: + cyl = cq.Workplane("XY") + cyl = cyl.circle(radius=inner_r) + cyl = cyl.extrude(until=length+pitch+cyl_extend_bottom) + # Make rotation of cylinder consistent with non-workaround case. + cyl = cyl.rotate(axisStartPoint=(0,0,0), axisEndPoint=(0,0,1), + angleDegrees=-(360*t_start/(pitch*n_starts))) + cyl = cyl.translate((0,0,-t_start+(z_start-cyl_extend_bottom))) + threads = threads.union(cyl) + + # Next, make cuts at the top. + square_shave = square_shave.translate((0,0,pitch*2+t_length)) + threads = threads.cut(square_shave) + + if top_lead_in: + delta_r = outer_r - inner_r + rise = math.tan(__deg2rad(metric_thread_angle())) * delta_r + lead_in = cq.Workplane("XZ") + lead_in = lead_in.moveTo(inner_r - delta_r, t_length + rise) + lead_in = lead_in.lineTo(outer_r + delta_r, t_length - (2 * rise)) + lead_in = lead_in.lineTo(outer_r + delta_r, t_length + pitch + rise) + lead_in = lead_in.lineTo(inner_r - delta_r, t_length + pitch + rise) + lead_in = lead_in.close() + lead_in = lead_in.revolve() + threads = threads.cut(lead_in) + + # Place the threads into position. + threads = threads.translate((0,0,t_start)) + if (not envelope): + threads = threads.rotate(axisStartPoint=(0,0,0), axisEndPoint=(0,0,1), + angleDegrees=360*t_start/(pitch*n_starts)) + + if render_cyl_late: + cyl = cq.Workplane("XY") + cyl = cyl.circle(radius=inner_r) + cyl = cyl.extrude(until=length+cyl_extend_bottom+cyl_extend_top) + cyl = cyl.translate((0,0,z_start-cyl_extend_bottom)) + threads = threads.union(cyl) + + return threads + + +############################################################################### +# Generates female threads without a base tube, unless 'base_tube_od' is set to +# something which is sufficiently greater than 'diameter' parameter. Please +# note that 'use_epsilon' is activated by default, which causes a slight budge +# in the major radius, outwards, so that overlaps would be created with outer +# tubes. (Does not affect thread profile inside of tube or beyond extents.) +############################################################################### +def internal_metric_thread(diameter, # Required parameter, e.g. 3.0 for M3x0.5 + pitch, # Required parameter, e.g. 0.5 for M3x0.5 + length, # Required parameter, e.g. 2.0. + z_start=0.0, + n_starts=1, + bottom_chamfer=False, # Chamfer is at same angle as + top_chamfer =False, # thread, namely 30 degrees. + bottom_relief=False, # Add relief groove to start or + top_relief =False, # end of threads (shorten). + use_epsilon=True, # For outer cylinder overlap. + # The base tube outer diameter must be sufficiently + # large for tube to be rendered. Otherwise ignored. + base_tube_od=-1.0, + tube_extend_bottom=-1.0, + tube_extend_top=-1.0, + envelope=False): # Draw only envelope, don't cut. + + tube_extend_bottom = max(0.0, tube_extend_bottom) + tube_extend_top = max(0.0, tube_extend_top) + + z_off = (1.0 - __metric_thread_effective_ratio()) * pitch / 4 + t_start = z_start + t_length = length + if bottom_relief: + t_start = t_start + (2 * z_off) + t_length = t_length - (2 * z_off) + if top_relief: + t_length = t_length - (2 * z_off) + outer_r = metric_thread_major_radius(diameter,pitch, + internal=True) + inner_r = metric_thread_minor_radius(diameter,pitch, + internal=True) + epsilon = 0 + outer_r_adj = outer_r + outer_z_budge = 0 + if use_epsilon: + # High values of 'epsilon' sometimes cause entire starts to disappear. + epsilon = (z_off/5) / math.tan(__deg2rad(metric_thread_angle())) + outer_r_adj = outer_r + epsilon + outer_z_budge = math.tan(__deg2rad(metric_thread_angle())) * epsilon + + if envelope: + threads = cq.Workplane("XZ") + threads = threads.moveTo(outer_r_adj, -pitch) + threads = threads.lineTo(inner_r, -pitch) + threads = threads.lineTo(inner_r, t_length + pitch) + threads = threads.lineTo(outer_r_adj, t_length + pitch) + threads = threads.close() + threads = threads.revolve() + + else: # Not envelope, cut the threads. + wire = cq.Wire.makeHelix(pitch=pitch*n_starts, + height=t_length+pitch, + radius=inner_r) + wire = wire.translate((0,0,-pitch/2)) + wire = wire.rotate(startVector=(0,0,0), endVector=(0,0,1), + angleDegrees=360*(-pitch/2)/(pitch*n_starts)) + thread = cq.Workplane("XZ") + thread = thread.moveTo(outer_r_adj, -pitch/2 + z_off - outer_z_budge) + thread = thread.lineTo(inner_r, -z_off) + thread = thread.lineTo(inner_r, z_off) + thread = thread.lineTo(outer_r_adj, pitch/2 - z_off + outer_z_budge) + thread = thread.close() + thread = thread.sweep(wire, isFrenet=True) + threads = thread + for addl_start in range(1, n_starts): + # TODO: Incremental/cumulative rotation may not be as accurate as + # keeping 'thread' intact and rotating it by correct amount + # on each iteration. However, changing the code in that + # regard may disrupt the delicate nature of workarounds + # with repsect to quirks in the underlying B-rep library. + thread = thread.rotate(axisStartPoint=(0,0,0), + axisEndPoint=(0,0,1), + angleDegrees=360/n_starts) + threads = threads.union(thread) + # Rotate so that the external threads would align. + threads = threads.rotate(axisStartPoint=(0,0,0), axisEndPoint=(0,0,1), + angleDegrees=180/n_starts) + + square_len = max(outer_r*3, base_tube_od*1.125) + square_shave = cq.Workplane("XY") + square_shave = square_shave.box(length=square_len, width=square_len, + height=pitch*2, centered=True) + square_shave = square_shave.translate((0,0,-pitch)) # Because centered. + # Always cut the top and bottom square. Otherwise things don't play nice. + threads = threads.cut(square_shave) + + if bottom_chamfer: + delta_r = outer_r - inner_r + rise = math.tan(__deg2rad(metric_thread_angle())) * delta_r + chamfer = cq.Workplane("XZ") + chamfer = chamfer.moveTo(inner_r - delta_r, 2 * rise) + chamfer = chamfer.lineTo(outer_r + delta_r, -rise) + chamfer = chamfer.lineTo(outer_r + delta_r, -pitch - rise) + chamfer = chamfer.lineTo(inner_r - delta_r, -pitch - rise) + chamfer = chamfer.close() + chamfer = chamfer.revolve() + threads = threads.cut(chamfer) + + # This was originally a workaround to the anomalous B-rep computation where + # the top of base tube is flush with top of threads w/o the use of chamfer. + # This is now being made consistent with the 'render_cyl_early' strategy in + # external_metric_thread() whereby we prefer the "render early" plan of + # action even in cases where a top chamfer or lead-in is used. + render_tube_early = ((base_tube_od > (outer_r * 2)) and + (not top_relief) and + (not (tube_extend_top > 0.0)) and + (not envelope)) + render_tube_late = ((base_tube_od > (outer_r * 2)) and + (not render_tube_early)) + if render_tube_early: + tube = cq.Workplane("XY") + tube = tube.circle(radius=base_tube_od/2) + tube = tube.circle(radius=outer_r) + tube = tube.extrude(until=length+pitch+tube_extend_bottom) + # Make rotation of cylinder consistent with non-workaround case. + tube = tube.rotate(axisStartPoint=(0,0,0), axisEndPoint=(0,0,1), + angleDegrees=-(360*t_start/(pitch*n_starts))) + tube = tube.translate((0,0,-t_start+(z_start-tube_extend_bottom))) + threads = threads.union(tube) + + # Next, make cuts at the top. + square_shave = square_shave.translate((0,0,pitch*2+t_length)) + threads = threads.cut(square_shave) + + if top_chamfer: + delta_r = outer_r - inner_r + rise = math.tan(__deg2rad(metric_thread_angle())) * delta_r + chamfer = cq.Workplane("XZ") + chamfer = chamfer.moveTo(inner_r - delta_r, t_length - (2 * rise)) + chamfer = chamfer.lineTo(outer_r + delta_r, t_length + rise) + chamfer = chamfer.lineTo(outer_r + delta_r, t_length + pitch + rise) + chamfer = chamfer.lineTo(inner_r - delta_r, t_length + pitch + rise) + chamfer = chamfer.close() + chamfer = chamfer.revolve() + threads = threads.cut(chamfer) + + # Place the threads into position. + threads = threads.translate((0,0,t_start)) + if (not envelope): + threads = threads.rotate(axisStartPoint=(0,0,0), axisEndPoint=(0,0,1), + angleDegrees=360*t_start/(pitch*n_starts)) + + if render_tube_late: + tube = cq.Workplane("XY") + tube = tube.circle(radius=base_tube_od/2) + tube = tube.circle(radius=outer_r) + tube = tube.extrude(until=length+tube_extend_bottom+tube_extend_top) + tube = tube.translate((0,0,z_start-tube_extend_bottom)) + threads = threads.union(tube) + + return threads diff --git a/nhf/test.py b/nhf/test.py index 5695c41..ce795bd 100644 --- a/nhf/test.py +++ b/nhf/test.py @@ -2,6 +2,7 @@ import unittest import cadquery as Cq import nhf.joints import nhf.handle +import nhf.metric_threads as NMt class TestJoints(unittest.TestCase): @@ -20,8 +21,25 @@ class TestHandle(unittest.TestCase): def test_handle_assembly(self): h = nhf.handle.Handle() - h.connector_insertion_assembly() - h.connector_one_side_insertion_assembly() + assembly = h.connector_insertion_assembly() + bbox = assembly.toCompound().BoundingBox() + self.assertAlmostEqual(bbox.xlen, h.diam) + self.assertAlmostEqual(bbox.ylen, h.diam) + assembly = h.connector_one_side_insertion_assembly() + bbox = assembly.toCompound().BoundingBox() + self.assertAlmostEqual(bbox.xlen, h.diam) + self.assertAlmostEqual(bbox.ylen, h.diam) + + +class TestMetricThreads(unittest.TestCase): + + def test_major_radius(self): + major = 3.0 + t = NMt.external_metric_thread(major, 0.5, 4.0, z_start= -0.85, top_lead_in=True) + bbox = t.val().BoundingBox() + self.assertAlmostEqual(bbox.xlen, major, places=3) + self.assertAlmostEqual(bbox.ylen, major, places=3) + if __name__ == '__main__': unittest.main() diff --git a/nhf/touhou/houjuu_nue/__init__.py b/nhf/touhou/houjuu_nue/__init__.py index 6aad524..fbdc328 100644 --- a/nhf/touhou/houjuu_nue/__init__.py +++ b/nhf/touhou/houjuu_nue/__init__.py @@ -89,7 +89,15 @@ class Parameters: wing_r2_height = 100 wing_r3_height = 100 - trident_handle: nhf.handle.Handle = nhf.handle.Handle() + trident_handle: nhf.handle.Handle = nhf.handle.Handle( + diam=38, + diam_inner=33, + # M27-3 + diam_threading=27, + thread_pitch=3, + diam_connector_internal=18, + simplify_geometry=False, + ) def __post_init__(self): assert self.wing_root_radius > self.hs_joint_radius,\ diff --git a/nhf/touhou/houjuu_nue/trident.py b/nhf/touhou/houjuu_nue/trident.py index 75e4d73..769213c 100644 --- a/nhf/touhou/houjuu_nue/trident.py +++ b/nhf/touhou/houjuu_nue/trident.py @@ -15,17 +15,17 @@ def trident_assembly( .add(handle.insertion(), name="i0", color=mat_i.color) .constrain("i0", "Fixed") .add(segment(), name="s1", color=mat_s.color) - .constrain("i0?lip", "s1?mate1", "Plane", param=0) + .constrain("i0?rim", "s1?mate1", "Plane", param=0) .add(handle.insertion(), name="i1", color=mat_i.color) .add(handle.connector(), name="c1", color=mat_i.color) .add(handle.insertion(), name="i2", color=mat_i.color) - .constrain("s1?mate2", "i1?lip", "Plane", param=0) + .constrain("s1?mate2", "i1?rim", "Plane", param=0) .constrain("i1?mate", "c1?mate1", "Plane") .constrain("i2?mate", "c1?mate2", "Plane") .add(segment(), name="s2", color=mat_s.color) - .constrain("i2?lip", "s2?mate1", "Plane", param=0) + .constrain("i2?rim", "s2?mate1", "Plane", param=0) .add(handle.insertion(), name="i3", color=mat_i.color) - .constrain("s2?mate2", "i3?lip", "Plane", param=0) + .constrain("s2?mate2", "i3?rim", "Plane", param=0) .add(handle.one_side_connector(), name="head", color=mat_i.color) .constrain("i3?mate", "head?mate", "Plane") ) -- 2.44.1 From 9fda02ed9dc5e7b3b3941a4e782452411cf78623 Mon Sep 17 00:00:00 2001 From: Leni Aniva Date: Wed, 26 Jun 2024 12:01:01 -0400 Subject: [PATCH 17/38] feat: Thread on handle terminal piece --- nhf/handle.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/nhf/handle.py b/nhf/handle.py index fbd8c56..3aef3bb 100644 --- a/nhf/handle.py +++ b/nhf/handle.py @@ -164,15 +164,23 @@ class Handle: height=self.rim_length, ) ) - result.faces("Z").tag("base") + result.faces(">Z").tag("mate") + result.faces("Z") .workplane() .circle(self.diam_connector_external / 2) .extrude(self.insertion_length) ) + if not self.simplify_geometry: + thread = self._external_thread().val() + result = ( + result + .union( + thread + .moved(Cq.Vector(0, 0, self.connector_length / 2))) + ) return result def connector_insertion_assembly(self): -- 2.44.1 From 53c204eb208a835ba2d3d3a72e370897d33cda7e Mon Sep 17 00:00:00 2001 From: Leni Aniva Date: Wed, 26 Jun 2024 15:57:22 -0400 Subject: [PATCH 18/38] feat: Torsion joint --- nhf/joints.py | 229 +++++++++++++++++++++++++++++++++++++++++-------- nhf/springs.py | 42 +++++++++ nhf/test.py | 5 ++ 3 files changed, 238 insertions(+), 38 deletions(-) create mode 100644 nhf/springs.py diff --git a/nhf/joints.py b/nhf/joints.py index 554d7b6..e7df7e9 100644 --- a/nhf/joints.py +++ b/nhf/joints.py @@ -1,5 +1,7 @@ -import cadquery as Cq +from dataclasses import dataclass import math +import cadquery as Cq +import nhf.springs as NS def hirth_joint(radius=60, radius_inner=40, @@ -174,54 +176,205 @@ def comma_joint(radius=30, result.faces('>X').tag("tail_end") return result -def torsion_spring(radius=12, - height=20, - thickness=2, - omega=90, - tail_length=25): - """ - Produces a torsion spring with abridged geometry since sweep is very slow in - cq-editor. - """ - base = ( - Cq.Workplane('XY') - .cylinder(height=height, radius=radius, - centered=(True, True, False)) - ) - base.faces(">Z").tag("mate_top") - base.faces(" self.spring_hole_depth + assert self.radius > self.groove_radius_outer + assert self.groove_radius_outer > self.groove_radius_inner + assert self.groove_radius_inner > self.radius_spring + assert self.spring_height > self.groove_depth, "Groove is too deep" + + @property + def total_height(self): + return 2 * self.disk_height + self.spring_height + + @property + def _radius_spring_internal(self): + return self.radius_spring - self.spring_thickness + + def _slot_polygon(self, flip: bool=False): + r1 = self.radius_spring - self.spring_thickness + r2 = self.radius_spring + flip = flip != self.right_handed + if flip: + r1 = -r1 + r2 = -r2 + return [ + (0, r2), + (self.spring_tail_length, r2), + (self.spring_tail_length, r1), + (0, r1), + ] + def _directrix(self, height, theta=0): + c, s = math.cos(theta), math.sin(theta) + r2 = self.radius_spring + l = self.spring_tail_length + if self.right_handed: + r2 = -r2 + # This is (0, r2) and (l, r2) transformed by rotation matrix + # [[c, s], [-s, c]] + return [ + (s * r2, -s * l + c * r2, height), + (c * l + s * r2, -s * l + c * r2, height), + ] + + + def spring(self): + return NS.torsion_spring( + radius=self.radius_spring, + height=self.spring_height, + thickness=self.spring_thickness, + tail_length=self.spring_tail_length, + ) + + def track(self): + groove_profile = ( + Cq.Sketch() + .circle(self.radius) + .circle(self.groove_radius_outer, mode='s') + .circle(self.groove_radius_inner, mode='a') + .circle(self.radius_spring, mode='s') + ) + spring_hole_profile = ( + Cq.Sketch() + .circle(self.radius) + .polygon(self._slot_polygon(flip=False), mode='s') + .circle(self.radius_spring, mode='s') + ) + result = ( + Cq.Workplane('XY') + .cylinder(radius=self.radius, height=self.disk_height) + .faces('>Z') + .tag("spring") + .placeSketch(spring_hole_profile) + .extrude(self.spring_thickness) + # If the spring hole profile is not simply connected, this workplane + # will have to be created from the `spring-mate` face. + .faces('>Z') + .placeSketch(groove_profile) + .extrude(self.groove_depth) + .faces('>Z') + .hole(self.radius_axle) + ) + # Insert directrix` + result.polyline(self._directrix(self.disk_height), + forConstruction=True).tag("directrix") + return result + + def rider(self): + def slot(loc): + wire = Cq.Wire.makePolygon(self._slot_polygon(flip=False)) + face = Cq.Face.makeFromWires(wire) + return face.located(loc) + wall_profile = ( + Cq.Sketch() + .circle(self.radius, mode='a') + .circle(self.radius_spring, mode='s') + .parray( + r=0, + a1=0, + da=360, + n=self.n_slots) + .each(slot, mode='s') + #.circle(self._radius_wall, mode='a') + ) + contact_profile = ( + Cq.Sketch() + .circle(self.groove_radius_outer, mode='a') + .circle(self.groove_radius_inner, mode='s') + #.circle(self._radius_wall, mode='a') + .parray( + r=0, + a1=0, + da=360, + n=self.n_slots) + .each(slot, mode='s') + ) + middle_height = self.spring_height - self.groove_depth - self.rider_gap + result = ( + Cq.Workplane('XY') + .cylinder(radius=self.radius, height=self.disk_height) + .faces('>Z') + .tag("spring") + .placeSketch(wall_profile) + .extrude(middle_height) + # The top face might not be in one piece. + #.faces('>Z') + .workplane(offset=middle_height) + .placeSketch(contact_profile) + .extrude(self.groove_depth + self.rider_gap) + .faces(tag="spring") + .circle(self._radius_spring_internal) + .extrude(self.spring_height) + .faces('>Z') + .hole(self.radius_axle) + ) + for i in range(self.n_slots): + theta = 2 * math.pi * i / self.n_slots + result.polyline(self._directrix(self.disk_height, theta), + forConstruction=True).tag(f"directrix{i}") + return result + + def rider_track_assembly(self): + rider = self.rider() + track = self.track() + spring = self.spring() + result = ( + Cq.Assembly() + .add(spring, name="spring", color=Cq.Color(0.5,0.5,0.5,1)) + .add(track, name="track", color=Cq.Color(0.5,0.5,0.8,0.3)) + .constrain("track?spring", "spring?top", "Plane") + .add(rider, name="rider", color=Cq.Color(0.8,0.8,0.5,0.3)) + .constrain("rider?spring", "spring?bot", "Plane") + .constrain("track?directrix", "spring?directrix_bot", "Axis") + .constrain("rider?directrix0", "spring?directrix_top", "Axis") + .solve() + ) + return result diff --git a/nhf/springs.py b/nhf/springs.py new file mode 100644 index 0000000..06e14b5 --- /dev/null +++ b/nhf/springs.py @@ -0,0 +1,42 @@ +import math +import cadquery as Cq + +def torsion_spring(radius=12, + height=20, + thickness=2, + omega=90, + tail_length=25): + """ + Produces a torsion spring with abridged geometry since sweep is very slow in + cq-editor. + """ + base = ( + Cq.Workplane('XY') + .cylinder(height=height, radius=radius, + centered=(True, True, False)) + ) + base.faces(">Z").tag("top") + base.faces(" Date: Wed, 26 Jun 2024 19:27:36 -0400 Subject: [PATCH 19/38] fix: use `.located` to move threads --- nhf/handle.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nhf/handle.py b/nhf/handle.py index 3aef3bb..0424569 100644 --- a/nhf/handle.py +++ b/nhf/handle.py @@ -148,11 +148,11 @@ class Handle: result .union( thread - .moved(Cq.Vector(0, 0, self.connector_length / 2))) + .located(Cq.Location((0, 0, self.connector_length / 2)))) .union( thread .rotate((0,0,0), (1,0,0), angleDegrees=90) - .moved(Cq.Vector(0, 0, -self.connector_length / 2))) + .located(Cq.Location((0, 0, -self.connector_length / 2)))) ) return result @@ -179,7 +179,7 @@ class Handle: result .union( thread - .moved(Cq.Vector(0, 0, self.connector_length / 2))) + .located(Cq.Location((0, 0, self.connector_length / 2)))) ) return result -- 2.44.1 From 4dd43f7151d497270b22352d9312eeb9ce981d37 Mon Sep 17 00:00:00 2001 From: Leni Aniva Date: Thu, 27 Jun 2024 23:22:54 -0400 Subject: [PATCH 20/38] refactor: Separate H-S joint component --- nhf/touhou/houjuu_nue/__init__.py | 16 ++++++++++------ nhf/touhou/houjuu_nue/test.py | 6 ++++++ nhf/touhou/houjuu_nue/wing.py | 26 +++++++++++++++++++++----- 3 files changed, 37 insertions(+), 11 deletions(-) diff --git a/nhf/touhou/houjuu_nue/__init__.py b/nhf/touhou/houjuu_nue/__init__.py index fbdc328..38e4f81 100644 --- a/nhf/touhou/houjuu_nue/__init__.py +++ b/nhf/touhou/houjuu_nue/__init__.py @@ -174,16 +174,20 @@ class Parameters: (-dx, dx), ] + def hs_joint_component(self): + hirth = nhf.joints.hirth_joint( + radius=self.hs_joint_radius, + radius_inner=self.hs_joint_radius_inner, + tooth_height=self.hs_joint_tooth_height, + base_height=self.hs_joint_ring_thickness) + return hirth + def hs_joint_parent(self): """ Parent part of the Houjuu-Scarlett joint, which is composed of a Hirth coupling, a cylindrical base, and a mounting base. """ - hirth = nhf.joints.hirth_joint( - radius=self.hs_joint_radius, - radius_inner=self.hs_joint_radius_inner, - tooth_height=self.hs_joint_tooth_height, - base_height=self.hs_joint_ring_thickness).val() + hirth = self.hs_joint_component().val() #hirth = ( # hirth # .faces("X").tag("conn") + return result -- 2.44.1 From 1c7f218a2bdbf9772e7bdd35894b4d448568e0a4 Mon Sep 17 00:00:00 2001 From: Leni Aniva Date: Thu, 27 Jun 2024 23:26:21 -0400 Subject: [PATCH 21/38] feat: Add role for components --- nhf/materials.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/nhf/materials.py b/nhf/materials.py index c752cd4..7ac4c26 100644 --- a/nhf/materials.py +++ b/nhf/materials.py @@ -8,7 +8,25 @@ def _color(name: str, alpha: float) -> Cq.Color: r, g, b, _ = Cq.Color(name).toTuple() return Cq.Color(r, g, b, alpha) +class Role(Enum): + """ + Describes the role of a part + """ + + # Parent and child components in a load bearing joint + PARENT = _color('blue4', 0.6) + CHILD = _color('darkorange2', 0.6) + STRUCTURE = _color('gray', 0.4) + DECORATION = _color('lightseagreen', 0.4) + ELECTRONIC = _color('mediumorchid', 0.5) + + def __init__(self, color: Cq.Color): + self.color = color + class Material(Enum): + """ + A catalog of common material properties + """ WOOD_BIRCH = 0.8, _color('bisque', 0.9) PLASTIC_PLA = 0.5, _color('azure3', 0.6) -- 2.44.1 From 914bc23582f6c23344e3cc8a6513a89e95b283ff Mon Sep 17 00:00:00 2001 From: Leni Aniva Date: Fri, 28 Jun 2024 17:21:30 -0400 Subject: [PATCH 22/38] feat: Add directrix tag to hirth joint --- nhf/__init__.py | 2 +- nhf/joints.py | 72 ++++++++++++++++++++++++++++++++++++------------- 2 files changed, 54 insertions(+), 20 deletions(-) diff --git a/nhf/__init__.py b/nhf/__init__.py index 2175f24..b582e6a 100644 --- a/nhf/__init__.py +++ b/nhf/__init__.py @@ -1 +1 @@ -from nhf.materials import Material +from nhf.materials import Material, Role diff --git a/nhf/joints.py b/nhf/joints.py index e7df7e9..7c227b4 100644 --- a/nhf/joints.py +++ b/nhf/joints.py @@ -2,6 +2,13 @@ from dataclasses import dataclass import math import cadquery as Cq import nhf.springs as NS +from nhf import Role + +def hirth_tooth_angle(n_tooth): + """ + Angle of one whole tooth + """ + return 360 / n_tooth def hirth_joint(radius=60, radius_inner=40, @@ -9,9 +16,13 @@ def hirth_joint(radius=60, n_tooth=16, tooth_height=16, tooth_height_inner=2, - tol=0.01): + tol=0.01, + tag_prefix="", + is_mated=False): """ Creates a cylindrical Hirth Joint + + is_mated: If set to true, rotate the teeth so they line up at 0 degrees. """ # ensures secant doesn't blow up assert n_tooth >= 5 @@ -51,9 +62,14 @@ def hirth_joint(radius=60, .val() ) tooth_centre_radius = radius_inner * math.cos(theta) + angle_offset = hirth_tooth_angle(n_tooth) / 2 if is_mated else 0 teeth = ( Cq.Workplane('XY') - .polarArray(radius=tooth_centre_radius, startAngle=0, angle=360, count=n_tooth) + .polarArray( + radius=tooth_centre_radius, + startAngle=angle_offset, + angle=360, + count=n_tooth) .eachpoint(lambda loc: tooth.located(loc)) .intersect(Cq.Solid.makeCylinder( height=base_height + tooth_height, @@ -66,36 +82,54 @@ def hirth_joint(radius=60, height=base_height, radius=radius, centered=(True, True, False)) - .faces(">Z").tag("bore") + .faces(">Z").tag(f"{tag_prefix}bore") .union(teeth.val().move(Cq.Location((0,0,base_height))), tol=tol) .clean() ) #base.workplane(offset=tooth_height/2).circle(radius=radius,forConstruction=True).tag("mate") - base.polyline([(0, 0, base_height), (0, 0, base_height+tooth_height)], forConstruction=True).tag("mate") + ( + base + .polyline([(0, 0, base_height), (0, 0, base_height+tooth_height)], forConstruction=True) + .tag(f"{tag_prefix}mate") + ) + ( + base + .polyline([(0, 0, 0), (1, 0, 0)], forConstruction=True) + .tag(f"{tag_prefix}directrix") + ) return base -def hirth_assembly(): +def hirth_assembly(n_tooth=12): """ Example assembly of two Hirth joints """ - rotate = 180 / 16 - obj1 = hirth_joint().faces(tag="bore").cboreHole( - diameter=10, - cboreDiameter=20, - cboreDepth=3) - obj2 = ( - hirth_joint() - .rotate( - axisStartPoint=(0,0,0), - axisEndPoint=(0,0,1), - angleDegrees=rotate - ) + #rotate = 180 / 16 + + tab = ( + Cq.Workplane('XY') + .box(100, 10, 2, centered=False) ) + obj1 = ( + hirth_joint(n_tooth=n_tooth) + .faces(tag="bore") + .cboreHole( + diameter=10, + cboreDiameter=20, + cboreDepth=3) + .union(tab) + ) + obj2 = ( + hirth_joint(n_tooth=n_tooth, is_mated=True) + .union(tab) + ) + angle = hirth_tooth_angle(n_tooth) result = ( Cq.Assembly() - .add(obj1, name="obj1", color=Cq.Color(0.8,0.8,0.5,0.3)) - .add(obj2, name="obj2", color=Cq.Color(0.5,0.5,0.5,0.3)) + .add(obj1, name="obj1", color=Role.PARENT.color) + .add(obj2, name="obj2", color=Role.CHILD.color) + .constrain("obj1", "Fixed") .constrain("obj1?mate", "obj2?mate", "Plane") + .constrain("obj1?directrix", "obj2?directrix", "Axis", param=angle) .solve() ) return result -- 2.44.1 From 87e99ac4ceab6d6daecb600d1c25d208934379e7 Mon Sep 17 00:00:00 2001 From: Leni Aniva Date: Fri, 28 Jun 2024 21:36:33 -0400 Subject: [PATCH 23/38] fix: Collision problem with Hirth joints --- nhf/joints.py | 65 ++++++++++++++++++++++++++++++++++++--------------- nhf/test.py | 6 ++++- 2 files changed, 51 insertions(+), 20 deletions(-) diff --git a/nhf/joints.py b/nhf/joints.py index 7c227b4..43c5169 100644 --- a/nhf/joints.py +++ b/nhf/joints.py @@ -23,50 +23,69 @@ def hirth_joint(radius=60, Creates a cylindrical Hirth Joint is_mated: If set to true, rotate the teeth so they line up at 0 degrees. + + FIXME: The curves don't mate perfectly. See if non-planar lofts can solve + this issue. """ - # ensures secant doesn't blow up + # ensures tangent doesn't blow up assert n_tooth >= 5 assert radius > radius_inner + assert tooth_height >= tooth_height_inner # angle of half of a single tooth theta = math.pi / n_tooth - # Generate a tooth by lofting between two curves - + c, s, t = math.cos(theta), math.sin(theta), math.tan(theta) + span = radius * t + radius_proj = radius / c + span_inner = radius_inner * s + # 2 * raise + (inner tooth height) = (tooth height) inner_raise = (tooth_height - tooth_height_inner) / 2 - # Outer tooth triangle spans a curve of length `2 pi r / n_tooth`. This - # creates the side profile (looking radially inwards) of each of the - # triangles. + # Outer tooth triangle spans 2*theta radians. This profile is the radial + # profile projected onto a plane `radius` away from the centre of the + # cylinder. The y coordinates on the edge must drop to compensate. + + # The drop is equal to, via similar triangles + drop = inner_raise * (radius_proj - radius) / (radius - radius_inner) outer = [ - (radius * math.tan(theta), 0), - (radius * math.tan(theta) - tol, 0), + (span, -tol - drop), + (span, -drop), (0, tooth_height), - (-radius * math.tan(theta) + tol, 0), - (-radius * math.tan(theta), 0), + (-span, -drop), + (-span, -tol - drop), ] + adj = radius_inner * c + # In the case of the inner triangle, it is projected onto a plane `adj` away + # from the centre. The apex must extrapolate + + # Via similar triangles + # + # (inner_raise + tooth_height_inner) - + # (tooth_height - inner_raise - tooth_height_inner) * ((radius_inner - adj) / (radius - radius_inner)) + apex = (inner_raise + tooth_height_inner) - \ + inner_raise * (radius_inner - adj) / (radius - radius_inner) inner = [ - (radius_inner * math.sin(theta), 0), - (radius_inner * math.sin(theta), inner_raise), - (0, inner_raise + tooth_height_inner), - (-radius_inner * math.sin(theta), inner_raise), - (-radius_inner * math.sin(theta), 0), + (span_inner, -tol), + (span_inner, inner_raise), + (0, apex), + (-span_inner, inner_raise), + (-span_inner, -tol), ] tooth = ( Cq.Workplane('YZ') .polyline(inner) .close() - .workplane(offset=radius - radius_inner) + .workplane(offset=radius - adj) .polyline(outer) .close() - .loft(ruled=True, combine=True) + .loft(ruled=False, combine=True) .val() ) - tooth_centre_radius = radius_inner * math.cos(theta) angle_offset = hirth_tooth_angle(n_tooth) / 2 if is_mated else 0 teeth = ( Cq.Workplane('XY') .polarArray( - radius=tooth_centre_radius, + radius=adj, startAngle=angle_offset, angle=360, count=n_tooth) @@ -75,6 +94,14 @@ def hirth_joint(radius=60, height=base_height + tooth_height, radius=radius, )) + .intersect(Cq.Solid.makeCylinder( + height=base_height + tooth_height, + radius=radius, + )) + .cut(Cq.Solid.makeCylinder( + height=base_height + tooth_height, + radius=radius_inner, + )) ) base = ( Cq.Workplane('XY') diff --git a/nhf/test.py b/nhf/test.py index e29b781..b0ab29a 100644 --- a/nhf/test.py +++ b/nhf/test.py @@ -3,6 +3,7 @@ import cadquery as Cq import nhf.joints import nhf.handle import nhf.metric_threads as NMt +from nhf.checks import binary_intersection class TestJoints(unittest.TestCase): @@ -12,7 +13,10 @@ class TestJoints(unittest.TestCase): j.val().solids(), Cq.Solid, msg="Hirth joint must be in one piece") def test_joints_hirth_assembly(self): - nhf.joints.hirth_assembly() + assembly = nhf.joints.hirth_assembly() + isect = binary_intersection(assembly) + self.assertLess(isect.Volume(), 1e-6, + "Hirth joint assembly must not have intersection") def test_joints_comma_assembly(self): nhf.joints.comma_assembly() def test_torsion_joint(self): -- 2.44.1 From 3170a025a1acde9fc4d2b08bb84389c5174be470 Mon Sep 17 00:00:00 2001 From: Leni Aniva Date: Fri, 28 Jun 2024 23:07:37 -0400 Subject: [PATCH 24/38] refactor: Combine Hirth Joint into one class --- nhf/checks.py | 6 + nhf/joints.py | 299 +++++++++++++++--------------- nhf/materials.py | 1 + nhf/test.py | 8 +- nhf/touhou/houjuu_nue/__init__.py | 91 ++++----- nhf/touhou/houjuu_nue/test.py | 2 +- nhf/touhou/houjuu_nue/wing.py | 25 ++- 7 files changed, 237 insertions(+), 195 deletions(-) create mode 100644 nhf/checks.py diff --git a/nhf/checks.py b/nhf/checks.py new file mode 100644 index 0000000..468cc8e --- /dev/null +++ b/nhf/checks.py @@ -0,0 +1,6 @@ +import cadquery as Cq + +def binary_intersection(a: Cq.Assembly) -> Cq.Shape: + objs = [s.toCompound() for _, s in a.traverse() if isinstance(s, Cq.Assembly)] + obj1, obj2 = objs[:2] + return obj1.intersect(obj2) diff --git a/nhf/joints.py b/nhf/joints.py index 43c5169..3327307 100644 --- a/nhf/joints.py +++ b/nhf/joints.py @@ -10,156 +10,165 @@ def hirth_tooth_angle(n_tooth): """ return 360 / n_tooth -def hirth_joint(radius=60, - radius_inner=40, - base_height=20, - n_tooth=16, - tooth_height=16, - tooth_height_inner=2, - tol=0.01, - tag_prefix="", - is_mated=False): +@dataclass(frozen=True) +class HirthJoint: """ - Creates a cylindrical Hirth Joint - - is_mated: If set to true, rotate the teeth so they line up at 0 degrees. - - FIXME: The curves don't mate perfectly. See if non-planar lofts can solve - this issue. + A Hirth joint attached to a cylindrical base """ - # ensures tangent doesn't blow up - assert n_tooth >= 5 - assert radius > radius_inner - assert tooth_height >= tooth_height_inner + radius: float = 60 + radius_inner: float = 40 + base_height: float = 20 + n_tooth: float = 16 + tooth_height: float = 16 + tooth_height_inner: float = 2 - # angle of half of a single tooth - theta = math.pi / n_tooth + def __post_init__(self): + # Ensures tangent doesn't blow up + assert self.n_tooth >= 5 + assert self.radius > self.radius_inner + assert self.tooth_height >= self.tooth_height_inner - c, s, t = math.cos(theta), math.sin(theta), math.tan(theta) - span = radius * t - radius_proj = radius / c - span_inner = radius_inner * s - # 2 * raise + (inner tooth height) = (tooth height) - inner_raise = (tooth_height - tooth_height_inner) / 2 - # Outer tooth triangle spans 2*theta radians. This profile is the radial - # profile projected onto a plane `radius` away from the centre of the - # cylinder. The y coordinates on the edge must drop to compensate. + @property + def _theta(self): + return math.pi / self.n_tooth - # The drop is equal to, via similar triangles - drop = inner_raise * (radius_proj - radius) / (radius - radius_inner) - outer = [ - (span, -tol - drop), - (span, -drop), - (0, tooth_height), - (-span, -drop), - (-span, -tol - drop), - ] - adj = radius_inner * c - # In the case of the inner triangle, it is projected onto a plane `adj` away - # from the centre. The apex must extrapolate + @property + def tooth_angle(self): + return hirth_tooth_angle(self.n_tooth) - # Via similar triangles - # - # (inner_raise + tooth_height_inner) - - # (tooth_height - inner_raise - tooth_height_inner) * ((radius_inner - adj) / (radius - radius_inner)) - apex = (inner_raise + tooth_height_inner) - \ - inner_raise * (radius_inner - adj) / (radius - radius_inner) - inner = [ - (span_inner, -tol), - (span_inner, inner_raise), - (0, apex), - (-span_inner, inner_raise), - (-span_inner, -tol), - ] - tooth = ( - Cq.Workplane('YZ') - .polyline(inner) - .close() - .workplane(offset=radius - adj) - .polyline(outer) - .close() - .loft(ruled=False, combine=True) - .val() - ) - angle_offset = hirth_tooth_angle(n_tooth) / 2 if is_mated else 0 - teeth = ( - Cq.Workplane('XY') - .polarArray( - radius=adj, - startAngle=angle_offset, - angle=360, - count=n_tooth) - .eachpoint(lambda loc: tooth.located(loc)) - .intersect(Cq.Solid.makeCylinder( - height=base_height + tooth_height, - radius=radius, - )) - .intersect(Cq.Solid.makeCylinder( - height=base_height + tooth_height, - radius=radius, - )) - .cut(Cq.Solid.makeCylinder( - height=base_height + tooth_height, - radius=radius_inner, - )) - ) - base = ( - Cq.Workplane('XY') - .cylinder( - height=base_height, - radius=radius, - centered=(True, True, False)) - .faces(">Z").tag(f"{tag_prefix}bore") - .union(teeth.val().move(Cq.Location((0,0,base_height))), tol=tol) - .clean() - ) - #base.workplane(offset=tooth_height/2).circle(radius=radius,forConstruction=True).tag("mate") - ( - base - .polyline([(0, 0, base_height), (0, 0, base_height+tooth_height)], forConstruction=True) - .tag(f"{tag_prefix}mate") - ) - ( - base - .polyline([(0, 0, 0), (1, 0, 0)], forConstruction=True) - .tag(f"{tag_prefix}directrix") - ) - return base -def hirth_assembly(n_tooth=12): - """ - Example assembly of two Hirth joints - """ - #rotate = 180 / 16 + def generate(self, tag_prefix="", is_mated=False, tol=0.01): + """ + is_mated: If set to true, rotate the teeth so they line up at 0 degrees. - tab = ( - Cq.Workplane('XY') - .box(100, 10, 2, centered=False) - ) - obj1 = ( - hirth_joint(n_tooth=n_tooth) - .faces(tag="bore") - .cboreHole( - diameter=10, - cboreDiameter=20, - cboreDepth=3) - .union(tab) - ) - obj2 = ( - hirth_joint(n_tooth=n_tooth, is_mated=True) - .union(tab) - ) - angle = hirth_tooth_angle(n_tooth) - result = ( - Cq.Assembly() - .add(obj1, name="obj1", color=Role.PARENT.color) - .add(obj2, name="obj2", color=Role.CHILD.color) - .constrain("obj1", "Fixed") - .constrain("obj1?mate", "obj2?mate", "Plane") - .constrain("obj1?directrix", "obj2?directrix", "Axis", param=angle) - .solve() - ) - return result + FIXME: The curves don't mate perfectly. See if non-planar lofts can solve + this issue. + """ + c, s, t = math.cos(self._theta), math.sin(self._theta), math.tan(self._theta) + span = self.radius * t + radius_proj = self.radius / c + span_inner = self.radius_inner * s + # 2 * raise + (inner tooth height) = (tooth height) + inner_raise = (self.tooth_height - self.tooth_height_inner) / 2 + # Outer tooth triangle spans 2*theta radians. This profile is the radial + # profile projected onto a plane `radius` away from the centre of the + # cylinder. The y coordinates on the edge must drop to compensate. + + # The drop is equal to, via similar triangles + drop = inner_raise * (radius_proj - self.radius) / (self.radius - self.radius_inner) + outer = [ + (span, -tol - drop), + (span, -drop), + (0, self.tooth_height), + (-span, -drop), + (-span, -tol - drop), + ] + adj = self.radius_inner * c + # In the case of the inner triangle, it is projected onto a plane `adj` away + # from the centre. The apex must extrapolate + + # Via similar triangles + # + # (inner_raise + tooth_height_inner) - + # (tooth_height - inner_raise - tooth_height_inner) * ((radius_inner - adj) / (radius - radius_inner)) + apex = (inner_raise + self.tooth_height_inner) - \ + inner_raise * (self.radius_inner - adj) / (self.radius - self.radius_inner) + inner = [ + (span_inner, -tol), + (span_inner, inner_raise), + (0, apex), + (-span_inner, inner_raise), + (-span_inner, -tol), + ] + tooth = ( + Cq.Workplane('YZ') + .polyline(inner) + .close() + .workplane(offset=self.radius - adj) + .polyline(outer) + .close() + .loft(ruled=False, combine=True) + .val() + ) + angle_offset = hirth_tooth_angle(self.n_tooth) / 2 if is_mated else 0 + h = self.base_height + self.tooth_height + teeth = ( + Cq.Workplane('XY') + .polarArray( + radius=adj, + startAngle=angle_offset, + angle=360, + count=self.n_tooth) + .eachpoint(lambda loc: tooth.located(loc)) + .intersect(Cq.Solid.makeCylinder( + height=h, + radius=self.radius, + )) + .cut(Cq.Solid.makeCylinder( + height=h, + radius=self.radius_inner, + )) + ) + base = ( + Cq.Workplane('XY') + .cylinder( + height=self.base_height, + radius=self.radius, + centered=(True, True, False)) + .faces(">Z").tag(f"{tag_prefix}bore") + .union( + teeth.val().move(Cq.Location((0,0,self.base_height))), + tol=tol) + .clean() + ) + #base.workplane(offset=tooth_height/2).circle(radius=radius,forConstruction=True).tag("mate") + ( + base + .polyline([ + (0, 0, self.base_height), + (0, 0, self.base_height + self.tooth_height) + ], forConstruction=True) + .tag(f"{tag_prefix}mate") + ) + ( + base + .polyline([(0, 0, 0), (1, 0, 0)], forConstruction=True) + .tag(f"{tag_prefix}directrix") + ) + return base + + def assembly(self): + """ + Generate an example assembly + """ + tab = ( + Cq.Workplane('XY') + .box(100, 10, 2, centered=False) + ) + obj1 = ( + self.generate() + .faces(tag="bore") + .cboreHole( + diameter=10, + cboreDiameter=20, + cboreDepth=3) + .union(tab) + ) + obj2 = ( + self.generate(is_mated=True) + .union(tab) + ) + angle = 1 * self.tooth_angle + result = ( + Cq.Assembly() + .add(obj1, name="obj1", color=Role.PARENT.color) + .add(obj2, name="obj2", color=Role.CHILD.color) + .constrain("obj1", "Fixed") + .constrain("obj1?mate", "obj2?mate", "Plane") + .constrain("obj1?directrix", "obj2?directrix", "Axis", param=angle) + .solve() + ) + return result def comma_joint(radius=30, shaft_radius=10, @@ -429,10 +438,10 @@ class TorsionJoint: spring = self.spring() result = ( Cq.Assembly() - .add(spring, name="spring", color=Cq.Color(0.5,0.5,0.5,1)) - .add(track, name="track", color=Cq.Color(0.5,0.5,0.8,0.3)) + .add(spring, name="spring", color=Role.DAMPING.color) + .add(track, name="track", color=Role.PARENT.color) .constrain("track?spring", "spring?top", "Plane") - .add(rider, name="rider", color=Cq.Color(0.8,0.8,0.5,0.3)) + .add(rider, name="rider", color=Role.CHILD.color) .constrain("rider?spring", "spring?bot", "Plane") .constrain("track?directrix", "spring?directrix_bot", "Axis") .constrain("rider?directrix0", "spring?directrix_top", "Axis") diff --git a/nhf/materials.py b/nhf/materials.py index 7ac4c26..c0adbde 100644 --- a/nhf/materials.py +++ b/nhf/materials.py @@ -16,6 +16,7 @@ class Role(Enum): # Parent and child components in a load bearing joint PARENT = _color('blue4', 0.6) CHILD = _color('darkorange2', 0.6) + DAMPING = _color('springgreen', 0.5) STRUCTURE = _color('gray', 0.4) DECORATION = _color('lightseagreen', 0.4) ELECTRONIC = _color('mediumorchid', 0.5) diff --git a/nhf/test.py b/nhf/test.py index b0ab29a..ed85cfd 100644 --- a/nhf/test.py +++ b/nhf/test.py @@ -8,12 +8,14 @@ from nhf.checks import binary_intersection class TestJoints(unittest.TestCase): def test_joint_hirth(self): - j = nhf.joints.hirth_joint() + j = nhf.joints.HirthJoint() + obj = j.generate() self.assertIsInstance( - j.val().solids(), Cq.Solid, + obj.val().solids(), Cq.Solid, msg="Hirth joint must be in one piece") def test_joints_hirth_assembly(self): - assembly = nhf.joints.hirth_assembly() + j = nhf.joints.HirthJoint() + assembly = j.assembly() isect = binary_intersection(assembly) self.assertLess(isect.Volume(), 1e-6, "Hirth joint assembly must not have intersection") diff --git a/nhf/touhou/houjuu_nue/__init__.py b/nhf/touhou/houjuu_nue/__init__.py index 38e4f81..f059eca 100644 --- a/nhf/touhou/houjuu_nue/__init__.py +++ b/nhf/touhou/houjuu_nue/__init__.py @@ -30,10 +30,12 @@ import cadquery as Cq import nhf.joints import nhf.handle from nhf import Material +from nhf.joints import HirthJoint +from nhf.handle import Handle import nhf.touhou.houjuu_nue.wing as MW import nhf.touhou.houjuu_nue.trident as MT -@dataclass(frozen=True) +@dataclass class Parameters: """ Defines dimensions for the Houjuu Nue cosplay @@ -44,9 +46,9 @@ class Parameters: # Harness harness_thickness: float = 25.4 / 8 - harness_width = 300 - harness_height = 400 - harness_fillet = 10 + harness_width: float = 300 + harness_height: float = 400 + harness_fillet: float = 10 harness_wing_base_pos = [ ("r1", 70, 150), @@ -60,36 +62,39 @@ class Parameters: # Holes drilled onto harness for attachment with HS joint harness_to_root_conn_diam = 6 + hs_hirth_joint: HirthJoint = HirthJoint( + radius=30, + radius_inner=20, + tooth_height=10, + base_height=5 + ) + # Wing root properties # # The Houjuu-Scarlett joint mechanism at the base of the wing - hs_joint_base_width = 85 - hs_joint_base_thickness = 10 - hs_joint_ring_thickness = 5 - hs_joint_tooth_height = 10 - hs_joint_radius = 30 - hs_joint_radius_inner = 20 - hs_joint_corner_fillet = 5 - hs_joint_corner_cbore_diam = 12 - hs_joint_corner_cbore_depth = 2 - hs_joint_corner_inset = 12 + hs_joint_base_width: float = 85 + hs_joint_base_thickness: float = 10 + hs_joint_corner_fillet: float = 5 + hs_joint_corner_cbore_diam: float = 12 + hs_joint_corner_cbore_depth: float = 2 + hs_joint_corner_inset: float = 12 - hs_joint_axis_diam = 12 - hs_joint_axis_cbore_diam = 20 - hs_joint_axis_cbore_depth = 3 + hs_joint_axis_diam: float = 12 + hs_joint_axis_cbore_diam: float = 20 + hs_joint_axis_cbore_depth: float = 3 # Exterior radius of the wing root assembly - wing_root_radius = 40 + wing_root_radius: float = 40 """ Heights for various wing joints, where the numbers start from the first joint. """ - wing_r1_height = 100 - wing_r1_width = 400 - wing_r2_height = 100 - wing_r3_height = 100 + wing_r1_height: float = 100 + wing_r1_width: float = 400 + wing_r2_height: float = 100 + wing_r3_height: float = 100 - trident_handle: nhf.handle.Handle = nhf.handle.Handle( + trident_handle: Handle = Handle( diam=38, diam_inner=33, # M27-3 @@ -100,7 +105,7 @@ class Parameters: ) def __post_init__(self): - assert self.wing_root_radius > self.hs_joint_radius,\ + assert self.wing_root_radius > self.hs_hirth_joint.radius,\ "Wing root must be large enough to accomodate joint" @@ -174,20 +179,12 @@ class Parameters: (-dx, dx), ] - def hs_joint_component(self): - hirth = nhf.joints.hirth_joint( - radius=self.hs_joint_radius, - radius_inner=self.hs_joint_radius_inner, - tooth_height=self.hs_joint_tooth_height, - base_height=self.hs_joint_ring_thickness) - return hirth - def hs_joint_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.hs_joint_component().val() + hirth = self.hs_hirth_joint.generate() #hirth = ( # hirth # .faces("Z") .workplane() - .union(hirth.move(Cq.Location((0, 0, self.hs_joint_base_thickness))), tol=0.1) + .union(hirth.translate((0, 0, self.hs_joint_base_thickness)), tol=0.1) .clean() ) result = ( @@ -247,7 +244,12 @@ class Parameters: result.faces(" Cq.Shape: + """ + Generate the wing root which contains a Hirth joint at its base and a + rectangular opening on its side, with the necessary interfaces. + """ + return MW.wing_root(joint=self.hs_hirth_joint) def wing_r1_profile(self) -> Cq.Sketch: """ @@ -288,13 +290,6 @@ class Parameters: ) return result - def wing_root(self) -> Cq.Shape: - """ - Generate the wing root which contains a Hirth joint at its base and a - rectangular opening on its side, with the necessary interfaces. - """ - return MW.wing_root() - ###################### # Assemblies # ###################### @@ -310,12 +305,18 @@ class Parameters: j = self.hs_joint_parent() ( result - .add(j, name=f"hs_{name}p", color=Material.PLASTIC_PLA.color) + .add(j, name=f"hs_{name}", color=Material.PLASTIC_PLA.color) #.constrain(f"harness?{name}", f"hs_{name}p?mate", "Point") - .constrain("harness?mount", f"hs_{name}p?base", "Axis") + .constrain("harness?mount", f"hs_{name}?base", "Axis") ) for i in range(4): - result.constrain(f"harness?{name}_{i}", f"hs_{name}p?h{i}", "Point") + result.constrain(f"harness?{name}_{i}", f"hs_{name}?h{i}", "Point") + angle = 6 * self.hs_hirth_joint.tooth_angle + ( + result.add(self.wing_root(), name="w0_r1", color=Material.PLASTIC_PLA.color) + .constrain("w0_r1?mate", "hs_r1?mate", "Plane") + .constrain("w0_r1?directrix", "hs_r1?directrix", "Axis", param=angle) + ) result.solve() return result diff --git a/nhf/touhou/houjuu_nue/test.py b/nhf/touhou/houjuu_nue/test.py index 7b22e46..42b3006 100644 --- a/nhf/touhou/houjuu_nue/test.py +++ b/nhf/touhou/houjuu_nue/test.py @@ -20,7 +20,7 @@ class Test(unittest.TestCase): self.assertLess(bbox.zlen, 255, msg=msg) def test_wings(self): p = M.Parameters() - p.wing_r1() + p.wing_root() def test_harness(self): p = M.Parameters() p.harness_assembly() diff --git a/nhf/touhou/houjuu_nue/wing.py b/nhf/touhou/houjuu_nue/wing.py index 8d36658..90ffb47 100644 --- a/nhf/touhou/houjuu_nue/wing.py +++ b/nhf/touhou/houjuu_nue/wing.py @@ -1,5 +1,10 @@ +""" +This file describes the shapes of the wing shells. The joints are defined in +`__init__.py`. +""" import math import cadquery as Cq +from nhf.joints import HirthJoint def wing_root_profiles( base_sweep=150, @@ -117,7 +122,13 @@ def wing_root_profiles( .wires().val() ) return base, middle, tip -def wing_root(): + + +def wing_root(joint: HirthJoint, + bolt_diam: int = 12): + """ + Generate the contiguous components of the root wing segment + """ root_profile, middle_profile, tip_profile = wing_root_profiles() rotate_centre = Cq.Vector(-200, 0, -25) @@ -175,4 +186,16 @@ def wing_root(): result = seg1.union(seg2).union(seg3) result.faces("X").tag("conn") + + j = ( + joint.generate(is_mated=True) + .faces(" Date: Sun, 30 Jun 2024 14:28:42 -0700 Subject: [PATCH 25/38] fix: Hirth joint mating --- nhf/joints.py | 132 ++++++++++++++++++-------------------------------- nhf/test.py | 13 +++-- 2 files changed, 54 insertions(+), 91 deletions(-) diff --git a/nhf/joints.py b/nhf/joints.py index 3327307..91ae4c2 100644 --- a/nhf/joints.py +++ b/nhf/joints.py @@ -4,126 +4,86 @@ import cadquery as Cq import nhf.springs as NS from nhf import Role -def hirth_tooth_angle(n_tooth): - """ - Angle of one whole tooth - """ - return 360 / n_tooth - @dataclass(frozen=True) class HirthJoint: """ A Hirth joint attached to a cylindrical base """ + + # r radius: float = 60 + # r_i radius_inner: float = 40 base_height: float = 20 n_tooth: float = 16 + # h_o tooth_height: float = 16 - tooth_height_inner: float = 2 def __post_init__(self): # Ensures tangent doesn't blow up assert self.n_tooth >= 5 assert self.radius > self.radius_inner - assert self.tooth_height >= self.tooth_height_inner - - @property - def _theta(self): - return math.pi / self.n_tooth @property def tooth_angle(self): - return hirth_tooth_angle(self.n_tooth) + return 360 / self.n_tooth def generate(self, tag_prefix="", is_mated=False, tol=0.01): """ is_mated: If set to true, rotate the teeth so they line up at 0 degrees. - FIXME: The curves don't mate perfectly. See if non-planar lofts can solve - this issue. + FIXME: Mate is not exact when number of tooth is low """ - c, s, t = math.cos(self._theta), math.sin(self._theta), math.tan(self._theta) - span = self.radius * t - radius_proj = self.radius / c - span_inner = self.radius_inner * s - # 2 * raise + (inner tooth height) = (tooth height) - inner_raise = (self.tooth_height - self.tooth_height_inner) / 2 - # Outer tooth triangle spans 2*theta radians. This profile is the radial - # profile projected onto a plane `radius` away from the centre of the - # cylinder. The y coordinates on the edge must drop to compensate. - - # The drop is equal to, via similar triangles - drop = inner_raise * (radius_proj - self.radius) / (self.radius - self.radius_inner) - outer = [ - (span, -tol - drop), - (span, -drop), - (0, self.tooth_height), - (-span, -drop), - (-span, -tol - drop), - ] - adj = self.radius_inner * c - # In the case of the inner triangle, it is projected onto a plane `adj` away - # from the centre. The apex must extrapolate - - # Via similar triangles - # - # (inner_raise + tooth_height_inner) - - # (tooth_height - inner_raise - tooth_height_inner) * ((radius_inner - adj) / (radius - radius_inner)) - apex = (inner_raise + self.tooth_height_inner) - \ - inner_raise * (self.radius_inner - adj) / (self.radius - self.radius_inner) - inner = [ - (span_inner, -tol), - (span_inner, inner_raise), - (0, apex), - (-span_inner, inner_raise), - (-span_inner, -tol), - ] - tooth = ( + phi = math.radians(self.tooth_angle) + alpha = 2 * math.atan(self.radius / self.tooth_height * math.tan(phi/2)) + #alpha = math.atan(self.radius * math.radians(180 / self.n_tooth) / self.tooth_height) + gamma = math.radians(90 / self.n_tooth) + # Tooth half height + l = self.radius * math.cos(gamma) + a = self.radius * math.sin(gamma) + t = a / math.tan(alpha / 2) + beta = math.asin(t / l) + dx = self.tooth_height * math.tan(alpha / 2) + profile = ( Cq.Workplane('YZ') - .polyline(inner) + .polyline([ + (0, 0), + (dx, self.tooth_height), + (-dx, self.tooth_height), + ]) .close() - .workplane(offset=self.radius - adj) - .polyline(outer) - .close() - .loft(ruled=False, combine=True) + .extrude(-self.radius) .val() + .rotate((0, 0, 0), (0, 1, 0), math.degrees(beta)) + .moved(Cq.Location((0, 0, self.base_height))) ) - angle_offset = hirth_tooth_angle(self.n_tooth) / 2 if is_mated else 0 - h = self.base_height + self.tooth_height - teeth = ( + core = Cq.Solid.makeCylinder( + radius=self.radius_inner, + height=self.tooth_height, + pnt=(0, 0, self.base_height), + ) + angle_offset = self.tooth_angle / 2 if is_mated else 0 + result = ( Cq.Workplane('XY') + .cylinder( + radius=self.radius, + height=self.base_height + self.tooth_height, + centered=(True, True, False)) + .faces(">Z") + .tag(f"{tag_prefix}bore") + .cut(core) .polarArray( - radius=adj, + radius=self.radius, startAngle=angle_offset, angle=360, count=self.n_tooth) - .eachpoint(lambda loc: tooth.located(loc)) - .intersect(Cq.Solid.makeCylinder( - height=h, - radius=self.radius, - )) - .cut(Cq.Solid.makeCylinder( - height=h, - radius=self.radius_inner, - )) + .cutEach( + lambda loc: profile.moved(loc), + ) ) - base = ( - Cq.Workplane('XY') - .cylinder( - height=self.base_height, - radius=self.radius, - centered=(True, True, False)) - .faces(">Z").tag(f"{tag_prefix}bore") - .union( - teeth.val().move(Cq.Location((0,0,self.base_height))), - tol=tol) - .clean() - ) - #base.workplane(offset=tooth_height/2).circle(radius=radius,forConstruction=True).tag("mate") ( - base + result .polyline([ (0, 0, self.base_height), (0, 0, self.base_height + self.tooth_height) @@ -131,11 +91,11 @@ class HirthJoint: .tag(f"{tag_prefix}mate") ) ( - base + result .polyline([(0, 0, 0), (1, 0, 0)], forConstruction=True) .tag(f"{tag_prefix}directrix") ) - return base + return result def assembly(self): """ diff --git a/nhf/test.py b/nhf/test.py index ed85cfd..4f97602 100644 --- a/nhf/test.py +++ b/nhf/test.py @@ -13,12 +13,15 @@ class TestJoints(unittest.TestCase): self.assertIsInstance( obj.val().solids(), Cq.Solid, msg="Hirth joint must be in one piece") + def test_joints_hirth_assembly(self): - j = nhf.joints.HirthJoint() - assembly = j.assembly() - isect = binary_intersection(assembly) - self.assertLess(isect.Volume(), 1e-6, - "Hirth joint assembly must not have intersection") + for n_tooth in [16, 20, 24]: + with self.subTest(n_tooth=n_tooth): + j = nhf.joints.HirthJoint() + assembly = j.assembly() + isect = binary_intersection(assembly) + self.assertLess(isect.Volume(), 1e-6, + "Hirth joint assembly must not have intersection") def test_joints_comma_assembly(self): nhf.joints.comma_assembly() def test_torsion_joint(self): -- 2.44.1 From 59bcc9914cdf7eda5bd5bf7cce90e4df5b8249e4 Mon Sep 17 00:00:00 2001 From: Leni Aniva Date: Sun, 30 Jun 2024 19:03:16 -0700 Subject: [PATCH 26/38] fix: Remove tag prefix in favour of subassembly --- nhf/joints.py | 24 +++++++++++++++--------- nhf/springs.py | 12 ++++++++++-- nhf/touhou/houjuu_nue/test.py | 2 +- nhf/touhou/houjuu_nue/wing.py | 3 ++- 4 files changed, 28 insertions(+), 13 deletions(-) diff --git a/nhf/joints.py b/nhf/joints.py index 91ae4c2..29ff40b 100644 --- a/nhf/joints.py +++ b/nhf/joints.py @@ -29,7 +29,7 @@ class HirthJoint: return 360 / self.n_tooth - def generate(self, tag_prefix="", is_mated=False, tol=0.01): + def generate(self, is_mated=False, tol=0.01): """ is_mated: If set to true, rotate the teeth so they line up at 0 degrees. @@ -71,7 +71,7 @@ class HirthJoint: height=self.base_height + self.tooth_height, centered=(True, True, False)) .faces(">Z") - .tag(f"{tag_prefix}bore") + .tag("bore") .cut(core) .polarArray( radius=self.radius, @@ -88,12 +88,12 @@ class HirthJoint: (0, 0, self.base_height), (0, 0, self.base_height + self.tooth_height) ], forConstruction=True) - .tag(f"{tag_prefix}mate") + .tag("mate") ) ( result .polyline([(0, 0, 0), (1, 0, 0)], forConstruction=True) - .tag(f"{tag_prefix}directrix") + .tag("directrix") ) return result @@ -235,7 +235,7 @@ class TorsionJoint: disk_height = 10 radius_spring = 15 - radius_axle = 10 + radius_axle = 6 # Offset of the spring hole w.r.t. surface spring_hole_depth = 4 @@ -320,7 +320,10 @@ class TorsionJoint: ) result = ( Cq.Workplane('XY') - .cylinder(radius=self.radius, height=self.disk_height) + .cylinder( + radius=self.radius, + height=self.disk_height, + centered=(True, True, False)) .faces('>Z') .tag("spring") .placeSketch(spring_hole_profile) @@ -331,7 +334,7 @@ class TorsionJoint: .placeSketch(groove_profile) .extrude(self.groove_depth) .faces('>Z') - .hole(self.radius_axle) + .hole(self.radius_axle * 2) ) # Insert directrix` result.polyline(self._directrix(self.disk_height), @@ -370,7 +373,10 @@ class TorsionJoint: middle_height = self.spring_height - self.groove_depth - self.rider_gap result = ( Cq.Workplane('XY') - .cylinder(radius=self.radius, height=self.disk_height) + .cylinder( + radius=self.radius, + height=self.disk_height, + centered=(True, True, False)) .faces('>Z') .tag("spring") .placeSketch(wall_profile) @@ -384,7 +390,7 @@ class TorsionJoint: .circle(self._radius_spring_internal) .extrude(self.spring_height) .faces('>Z') - .hole(self.radius_axle) + .hole(self.radius_axle * 2) ) for i in range(self.n_slots): theta = 2 * math.pi * i / self.n_slots diff --git a/nhf/springs.py b/nhf/springs.py index 06e14b5..31fcf51 100644 --- a/nhf/springs.py +++ b/nhf/springs.py @@ -24,13 +24,21 @@ def torsion_spring(radius=12, .transformed( offset=(0, radius-thickness), rotate=(0, 0, 0)) - .box(length=tail_length, width=thickness, height=thickness, centered=False) + .box( + length=tail_length, + width=thickness, + height=thickness, + centered=False) .copyWorkplane(Cq.Workplane('XY')) .transformed( offset=(0, 0, height - thickness), rotate=(0, 0, omega)) .center(-tail_length, radius-thickness) - .box(length=tail_length, width=thickness, height=thickness, centered=False) + .box( + length=tail_length, + width=thickness, + height=thickness, + centered=False) ) result.polyline([(0, radius, 0), (tail_length, radius, 0)], forConstruction=True).tag("directrix_bot") diff --git a/nhf/touhou/houjuu_nue/test.py b/nhf/touhou/houjuu_nue/test.py index 42b3006..cd0d17b 100644 --- a/nhf/touhou/houjuu_nue/test.py +++ b/nhf/touhou/houjuu_nue/test.py @@ -11,7 +11,7 @@ class Test(unittest.TestCase): def test_wing_root(self): p = M.Parameters() obj = p.wing_root() - self.assertIsInstance(obj.solids(), Cq.Solid, msg="Wing root must be in one piece") + #self.assertIsInstance(obj.solids(), Cq.Solid, msg="Wing root must be in one piece") bbox = obj.BoundingBox() msg = "Must fix 256^3 bbox" diff --git a/nhf/touhou/houjuu_nue/wing.py b/nhf/touhou/houjuu_nue/wing.py index 90ffb47..04de65a 100644 --- a/nhf/touhou/houjuu_nue/wing.py +++ b/nhf/touhou/houjuu_nue/wing.py @@ -195,7 +195,8 @@ def wing_root(joint: HirthJoint, result = ( result .union(j.translate((0, 0, -10))) - .union(Cq.Solid.makeCylinder(57, 5).moved(Cq.Location((0, 0, -10)))) + #.union(Cq.Solid.makeCylinder(57, 5).moved(Cq.Location((0, 0, -10)))) + .union(Cq.Solid.makeCylinder(20, 5).moved(Cq.Location((0, 0, -10)))) .clean() ) return result -- 2.44.1 From 1710f0db36b49533807efdc2965a6b45a08e010f Mon Sep 17 00:00:00 2001 From: Leni Aniva Date: Mon, 1 Jul 2024 17:59:42 -0700 Subject: [PATCH 27/38] feat: Improve model for printing --- nhf/handle.py | 50 +++++++++++++++++++++++++++---- nhf/touhou/houjuu_nue/__init__.py | 2 +- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/nhf/handle.py b/nhf/handle.py index 0424569..5bdd311 100644 --- a/nhf/handle.py +++ b/nhf/handle.py @@ -74,11 +74,13 @@ class Handle: result.faces(">Z").tag("mate2") return result - def _external_thread(self): + def _external_thread(self, length=None): + if length is None: + length = self.insertion_length return NMt.external_metric_thread( self.diam_threading, self.thread_pitch, - self.insertion_length, + length, top_lead_in=True) def _internal_thread(self): return NMt.internal_metric_thread( @@ -86,7 +88,7 @@ class Handle: self.thread_pitch, self.insertion_length) - def insertion(self): + def insertion(self, holes=[]): """ This type of joint is used to connect two handlebar pieces. Each handlebar piece is a tube which cannot be machined, so the joint connects to the @@ -95,6 +97,12 @@ class Handle: Tags: * lip: Co-planar Mates to the rod * mate: Mates to the connector + + WARNING: A tolerance lower than the defualt (maybe 5e-4) is required for + STL export. + + Set `holes` to the heights for drilling holes into the model for resin + to flow out. """ result = ( Cq.Workplane('XY') @@ -117,12 +125,22 @@ class Handle: if not self.simplify_geometry: thread = self._internal_thread().val() result = result.union(thread) + for h in holes: + cyl = Cq.Solid.makeCylinder( + radius=2, + height=self.diam * 2, + pnt=(-self.diam, 0, h), + dir=(1, 0, 0)) + result = result.cut(cyl) return result - def connector(self, solid: bool = False): + def connector(self, solid: bool = True): """ Tags: * mate{1,2}: Mates to the connector + + WARNING: A tolerance lower than the defualt (maybe 2e-4) is required for + STL export. """ result = ( Cq.Workplane('XY') @@ -151,7 +169,7 @@ class Handle: .located(Cq.Location((0, 0, self.connector_length / 2)))) .union( thread - .rotate((0,0,0), (1,0,0), angleDegrees=90) + .rotate((0,0,0), (1,0,0), angleDegrees=180) .located(Cq.Location((0, 0, -self.connector_length / 2)))) ) return result @@ -183,6 +201,28 @@ class Handle: ) return result + def threaded_core(self, length): + """ + Generates a threaded core for unioning with other components + """ + result = ( + Cq.Workplane('XY') + .cylinder( + radius=self.diam_connector_external / 2, + height=length, + centered=(True, True, False), + ) + ) + result.faces(">Z").tag("mate") + result.faces(" Date: Tue, 2 Jul 2024 19:59:09 -0700 Subject: [PATCH 28/38] fix: One sided connector --- nhf/handle.py | 11 +++++++---- nhf/joints.py | 3 ++- nhf/touhou/houjuu_nue/trident.py | 12 +++++++++++- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/nhf/handle.py b/nhf/handle.py index 5bdd311..36462b8 100644 --- a/nhf/handle.py +++ b/nhf/handle.py @@ -36,7 +36,7 @@ class Handle: # Length for the rim on the female connector rim_length: float = 5 - insertion_length: float = 60 + insertion_length: float = 30 # Amount by which the connector goes into the segment connector_length: float = 60 @@ -174,12 +174,15 @@ class Handle: ) return result - def one_side_connector(self): + def one_side_connector(self, height=None): + if height is None: + height = self.rim_length result = ( Cq.Workplane('XY') .cylinder( radius=self.diam / 2, - height=self.rim_length, + height=height, + centered=(True, True, False) ) ) result.faces(">Z").tag("mate") @@ -197,7 +200,7 @@ class Handle: result .union( thread - .located(Cq.Location((0, 0, self.connector_length / 2)))) + .located(Cq.Location((0, 0, height)))) ) return result diff --git a/nhf/joints.py b/nhf/joints.py index 29ff40b..1fb938b 100644 --- a/nhf/joints.py +++ b/nhf/joints.py @@ -223,7 +223,7 @@ def comma_assembly(): ) return result -@dataclass(frozen=True) +@dataclass class TorsionJoint: """ This jonit consists of a rider puck on a track puck. IT is best suited if @@ -260,6 +260,7 @@ class TorsionJoint: assert self.groove_radius_outer > self.groove_radius_inner assert self.groove_radius_inner > self.radius_spring assert self.spring_height > self.groove_depth, "Groove is too deep" + assert self.radius_spring > self.radius_axle @property def total_height(self): diff --git a/nhf/touhou/houjuu_nue/trident.py b/nhf/touhou/houjuu_nue/trident.py index 769213c..71901ab 100644 --- a/nhf/touhou/houjuu_nue/trident.py +++ b/nhf/touhou/houjuu_nue/trident.py @@ -5,9 +5,17 @@ from nhf.handle import Handle def trident_assembly( handle: Handle, - handle_segment_length: float = 24*25.4): + handle_segment_length: float = 24*25.4, + terminal_height=100): def segment(): return handle.segment(handle_segment_length) + + terminal = ( + handle + .one_side_connector(height=terminal_height) + .faces(">Z") + .hole(15, terminal_height + handle.insertion_length - 10) + ) mat_i = Material.PLASTIC_PLA mat_s = Material.ACRYLIC_BLACK assembly = ( @@ -28,5 +36,7 @@ def trident_assembly( .constrain("s2?mate2", "i3?rim", "Plane", param=0) .add(handle.one_side_connector(), name="head", color=mat_i.color) .constrain("i3?mate", "head?mate", "Plane") + .add(terminal, name="terminal", color=mat_i.color) + .constrain("i0?mate", "terminal?mate", "Plane") ) return assembly.solve() -- 2.44.1 From e75e640623faa8a0939d8db333f9f91c4b9bb8b0 Mon Sep 17 00:00:00 2001 From: Leni Aniva Date: Wed, 3 Jul 2024 18:45:16 -0700 Subject: [PATCH 29/38] fix: Type missing in dataclass --- nhf/handle.py | 2 +- nhf/joints.py | 28 ++++++++++++++-------------- nhf/touhou/houjuu_nue/__init__.py | 16 ++++++++-------- nhf/touhou/houjuu_nue/test.py | 2 +- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/nhf/handle.py b/nhf/handle.py index 36462b8..36c41a7 100644 --- a/nhf/handle.py +++ b/nhf/handle.py @@ -5,7 +5,7 @@ from dataclasses import dataclass import cadquery as Cq import nhf.metric_threads as NMt -@dataclass(frozen=True) +@dataclass class Handle: """ Characteristic of a tool handle diff --git a/nhf/joints.py b/nhf/joints.py index 1fb938b..f8e5f8f 100644 --- a/nhf/joints.py +++ b/nhf/joints.py @@ -4,7 +4,7 @@ import cadquery as Cq import nhf.springs as NS from nhf import Role -@dataclass(frozen=True) +@dataclass class HirthJoint: """ A Hirth joint attached to a cylindrical base @@ -231,26 +231,26 @@ class TorsionJoint: """ # Radius limit for rotating components - radius = 40 - disk_height = 10 + radius: float = 40 + disk_height: float = 10 - radius_spring = 15 - radius_axle = 6 + radius_spring: float = 15 + radius_axle: float = 6 # Offset of the spring hole w.r.t. surface - spring_hole_depth = 4 + spring_hole_depth: float = 4 # Also used for the height of the hole for the spring - spring_thickness = 2 - spring_height = 15 + spring_thickness: float = 2 + spring_height: float = 15 - spring_tail_length = 40 + spring_tail_length: float = 40 - groove_radius_outer = 35 - groove_radius_inner = 20 - groove_depth = 5 - rider_gap = 2 - n_slots = 8 + groove_radius_outer: float = 35 + groove_radius_inner: float = 20 + groove_depth: float = 5 + rider_gap: float = 2 + n_slots: float = 8 right_handed: bool = False diff --git a/nhf/touhou/houjuu_nue/__init__.py b/nhf/touhou/houjuu_nue/__init__.py index 43d8b44..55f3614 100644 --- a/nhf/touhou/houjuu_nue/__init__.py +++ b/nhf/touhou/houjuu_nue/__init__.py @@ -24,7 +24,7 @@ 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 +from dataclasses import dataclass, field import unittest import cadquery as Cq import nhf.joints @@ -50,24 +50,24 @@ class Parameters: harness_height: float = 400 harness_fillet: float = 10 - harness_wing_base_pos = [ + harness_wing_base_pos: list[tuple[str, float, float]] = field(default_factory=lambda: [ ("r1", 70, 150), ("l1", -70, 150), ("r2", 100, 0), ("l2", -100, 0), ("r3", 70, -150), ("l3", -70, -150), - ] + ]) # Holes drilled onto harness for attachment with HS joint - harness_to_root_conn_diam = 6 + harness_to_root_conn_diam: float = 6 - hs_hirth_joint: HirthJoint = HirthJoint( + hs_hirth_joint: HirthJoint = field(default_factory=lambda: HirthJoint( radius=30, radius_inner=20, tooth_height=10, base_height=5 - ) + )) # Wing root properties # @@ -94,7 +94,7 @@ class Parameters: wing_r2_height: float = 100 wing_r3_height: float = 100 - trident_handle: Handle = Handle( + trident_handle: Handle = field(default_factory=lambda: Handle( diam=38, diam_inner=38-2 * 25.4/8, # M27-3 @@ -102,7 +102,7 @@ class Parameters: thread_pitch=3, diam_connector_internal=18, simplify_geometry=False, - ) + )) def __post_init__(self): assert self.wing_root_radius > self.hs_hirth_joint.radius,\ diff --git a/nhf/touhou/houjuu_nue/test.py b/nhf/touhou/houjuu_nue/test.py index cd0d17b..57d1aaa 100644 --- a/nhf/touhou/houjuu_nue/test.py +++ b/nhf/touhou/houjuu_nue/test.py @@ -12,7 +12,7 @@ class Test(unittest.TestCase): p = M.Parameters() obj = p.wing_root() #self.assertIsInstance(obj.solids(), Cq.Solid, msg="Wing root must be in one piece") - bbox = obj.BoundingBox() + bbox = obj.val().BoundingBox() msg = "Must fix 256^3 bbox" self.assertLess(bbox.xlen, 255, msg=msg) -- 2.44.1 From 6201683c00a21ec3d1e2650ebf3429c5700f7e5b Mon Sep 17 00:00:00 2001 From: Leni Aniva Date: Wed, 3 Jul 2024 23:15:39 -0700 Subject: [PATCH 30/38] feat: Add build system --- .gitignore | 3 ++ nhf/build.py | 81 +++++++++++++++++++++++++++++++ nhf/touhou/houjuu_nue/__init__.py | 14 +++++- poetry.lock | 2 +- pyproject.toml | 1 + 5 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 nhf/build.py diff --git a/.gitignore b/.gitignore index 3226ba4..182aa47 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ result # Python __pycache__ *.py[cod] + +# Model build output +/build diff --git a/nhf/build.py b/nhf/build.py new file mode 100644 index 0000000..5ac9bd2 --- /dev/null +++ b/nhf/build.py @@ -0,0 +1,81 @@ +""" +The NHF build system + +Usage: For any parametric assembly, inherit the `Model` class, and mark the +output objects with the `@target` decorator +""" +from pathlib import Path +from typing import Union +import cadquery as Cq +from colorama import Fore, Style + +class Target: + + def __init__(self, + method, + name: str): + self._method = method + self.name = name + def __call__(self, obj, *args, **kwargs): + return self._method(obj, *args, **kwargs) + + @classmethod + def methods(cls, subject): + """ + List of all methods of a class or objects annotated with this decorator. + """ + def g(): + for name in dir(subject): + method = getattr(subject, name) + if isinstance(method, Target): + yield name, method + return {name: method for name, method in g()} + + +def target(name, **kwargs): + """ + Decorator for annotating a build output + """ + def f(method): + return Target(method, name=name, **kwargs) + return f + +def _to_shape(x: Union[Cq.Workplane, Cq.Shape, Cq.Compound, Cq.Assembly]) -> Cq.Shape: + if isinstance(x, Cq.Workplane): + x = x.val() + if isinstance(x, Cq.Assembly): + x = x.toCompound() + return x + + +class Model: + """ + Base class for a parametric assembly + """ + + def target_names(self) -> list[str]: + """ + List of decorated target functions + """ + return list(Target.methods(self).keys()) + + def build_all(self, output_dir: Union[Path, str] ="build", verbose=1): + """ + Build all targets in this model + """ + output_dir = Path(output_dir) + output_dir.mkdir(exist_ok=True, parents=True) + for k, f in Target.methods(self).items(): + output_file = output_dir / f"{k}.stl" + if output_file.is_file(): + if verbose >= 1: + print(f"{Fore.GREEN}Skipping{Style.RESET_ALL} {output_file}") + continue + + model = f(self) + if verbose >= 1: + print(f"{Fore.BLUE}Building{Style.RESET_ALL} {output_file}", end="") + _to_shape(model).exportStl(str(output_file)) + if verbose >= 1: + print("\r", end="") + print(f"{Fore.GREEN}Built{Style.RESET_ALL} {output_file}") diff --git a/nhf/touhou/houjuu_nue/__init__.py b/nhf/touhou/houjuu_nue/__init__.py index 55f3614..3d00652 100644 --- a/nhf/touhou/houjuu_nue/__init__.py +++ b/nhf/touhou/houjuu_nue/__init__.py @@ -1,4 +1,9 @@ """ +To build, execute +``` +python3 nhf/touhou/houjuu_nue/__init__.py +``` + This cosplay consists of 3 components: ## Trident @@ -32,11 +37,12 @@ import nhf.handle from nhf import Material from nhf.joints import HirthJoint from nhf.handle import Handle +from nhf.build import Model, target import nhf.touhou.houjuu_nue.wing as MW import nhf.touhou.houjuu_nue.trident as MT @dataclass -class Parameters: +class Parameters(Model): """ Defines dimensions for the Houjuu Nue cosplay """ @@ -179,6 +185,7 @@ class Parameters: (-dx, dx), ] + @target(name="hs_joint_parent") def hs_joint_parent(self): """ Parent part of the Houjuu-Scarlett joint, which is composed of a Hirth @@ -322,3 +329,8 @@ class Parameters: def trident_assembly(self): return MT.trident_assembly(self.trident_handle) + + +if __name__ == '__main__': + p = Parameters() + p.build_all() diff --git a/poetry.lock b/poetry.lock index 69302dd..756343c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -857,4 +857,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "caf46b526858dbf2960b0204782140ad072d96f7b3f161ac7e9db0d9b709b25a" +content-hash = "ec47ccffd60fbda610a5c3725fc064a08b1b794f23084672bd62beb20b1b19f7" diff --git a/pyproject.toml b/pyproject.toml index 3431c0e..8ca0117 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ python = "^3.10" cadquery = "^2.4.0" build123d = "^0.5.0" numpy = "^1.26.4" +colorama = "^0.4.6" [build-system] requires = ["poetry-core"] -- 2.44.1 From 46161ba82ecf19b90c54bba1e61426e5a4b30a14 Mon Sep 17 00:00:00 2001 From: Leni Aniva Date: Thu, 4 Jul 2024 00:24:14 -0700 Subject: [PATCH 31/38] fix: Decorated target not directly callable --- nhf/build.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/nhf/build.py b/nhf/build.py index 5ac9bd2..f11b9c8 100644 --- a/nhf/build.py +++ b/nhf/build.py @@ -6,8 +6,9 @@ output objects with the `@target` decorator """ from pathlib import Path from typing import Union -import cadquery as Cq +from functools import wraps from colorama import Fore, Style +import cadquery as Cq class Target: @@ -16,6 +17,8 @@ class Target: name: str): self._method = method self.name = name + def __str__(self): + return f"" def __call__(self, obj, *args, **kwargs): return self._method(obj, *args, **kwargs) @@ -27,17 +30,21 @@ class Target: def g(): for name in dir(subject): method = getattr(subject, name) - if isinstance(method, Target): - yield name, method - return {name: method for name, method in g()} + if hasattr(method, 'target'): + yield method.target + return {method.name: method for method in g()} -def target(name, **kwargs): +def target(name, **deco_kwargs): """ Decorator for annotating a build output """ def f(method): - return Target(method, name=name, **kwargs) + @wraps(method) + def wrapper(self, *args, **kwargs): + return method(self, *args, **kwargs) + wrapper.target = Target(method, name, **deco_kwargs) + return wrapper return f def _to_shape(x: Union[Cq.Workplane, Cq.Shape, Cq.Compound, Cq.Assembly]) -> Cq.Shape: @@ -74,8 +81,7 @@ class Model: model = f(self) if verbose >= 1: - print(f"{Fore.BLUE}Building{Style.RESET_ALL} {output_file}", end="") + print(f"{Fore.BLUE}Building{Style.RESET_ALL} {output_file}") _to_shape(model).exportStl(str(output_file)) if verbose >= 1: - print("\r", end="") print(f"{Fore.GREEN}Built{Style.RESET_ALL} {output_file}") -- 2.44.1 From 5bceb6180e31b86b2397d78a642e69f557224c08 Mon Sep 17 00:00:00 2001 From: Leni Aniva Date: Thu, 4 Jul 2024 00:42:14 -0700 Subject: [PATCH 32/38] refactor: Move parts into nhf.parts --- nhf/build.py | 16 ++++++-- nhf/parts/__init__.py | 0 nhf/{ => parts}/handle.py | 10 ++--- nhf/{ => parts}/joints.py | 6 +-- nhf/{ => parts}/metric_threads.py | 0 nhf/{ => parts}/springs.py | 0 nhf/parts/test.py | 59 ++++++++++++++++++++++++++++++ nhf/primitive.py | 12 ------ nhf/test.py | 61 +++++++------------------------ nhf/touhou/houjuu_nue/__init__.py | 6 +-- nhf/touhou/houjuu_nue/trident.py | 2 +- nhf/touhou/houjuu_nue/wing.py | 2 +- 12 files changed, 97 insertions(+), 77 deletions(-) create mode 100644 nhf/parts/__init__.py rename nhf/{ => parts}/handle.py (96%) rename nhf/{ => parts}/joints.py (99%) rename nhf/{ => parts}/metric_threads.py (100%) rename nhf/{ => parts}/springs.py (100%) create mode 100644 nhf/parts/test.py delete mode 100644 nhf/primitive.py diff --git a/nhf/build.py b/nhf/build.py index f11b9c8..5da3d01 100644 --- a/nhf/build.py +++ b/nhf/build.py @@ -29,9 +29,11 @@ class Target: """ def g(): for name in dir(subject): + if name == 'target_names': + continue method = getattr(subject, name) - if hasattr(method, 'target'): - yield method.target + if hasattr(method, '_target'): + yield method._target return {method.name: method for method in g()} @@ -43,7 +45,7 @@ def target(name, **deco_kwargs): @wraps(method) def wrapper(self, *args, **kwargs): return method(self, *args, **kwargs) - wrapper.target = Target(method, name, **deco_kwargs) + wrapper._target = Target(method, name, **deco_kwargs) return wrapper return f @@ -60,12 +62,20 @@ class Model: Base class for a parametric assembly """ + @property def target_names(self) -> list[str]: """ List of decorated target functions """ return list(Target.methods(self).keys()) + def check_all(self) -> int: + total = 0 + for k, f in Target.methods(self).items(): + f(self) + total += 1 + return total + def build_all(self, output_dir: Union[Path, str] ="build", verbose=1): """ Build all targets in this model diff --git a/nhf/parts/__init__.py b/nhf/parts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nhf/handle.py b/nhf/parts/handle.py similarity index 96% rename from nhf/handle.py rename to nhf/parts/handle.py index 36c41a7..c6845ef 100644 --- a/nhf/handle.py +++ b/nhf/parts/handle.py @@ -3,7 +3,7 @@ This schematics file contains all designs related to tool handles """ from dataclasses import dataclass import cadquery as Cq -import nhf.metric_threads as NMt +import nhf.parts.metric_threads as metric_threads @dataclass class Handle: @@ -50,7 +50,7 @@ class Handle: @property def diam_insertion_internal(self): - r = NMt.metric_thread_major_radius( + r = metric_threads.metric_thread_major_radius( self.diam_threading, self.thread_pitch, internal=True) @@ -58,7 +58,7 @@ class Handle: @property def diam_connector_external(self): - r = NMt.metric_thread_minor_radius( + r = metric_threads.metric_thread_minor_radius( self.diam_threading, self.thread_pitch) return r * 2 @@ -77,13 +77,13 @@ class Handle: def _external_thread(self, length=None): if length is None: length = self.insertion_length - return NMt.external_metric_thread( + return metric_threads.external_metric_thread( self.diam_threading, self.thread_pitch, length, top_lead_in=True) def _internal_thread(self): - return NMt.internal_metric_thread( + return metric_threads.internal_metric_thread( self.diam_threading, self.thread_pitch, self.insertion_length) diff --git a/nhf/joints.py b/nhf/parts/joints.py similarity index 99% rename from nhf/joints.py rename to nhf/parts/joints.py index f8e5f8f..286776d 100644 --- a/nhf/joints.py +++ b/nhf/parts/joints.py @@ -1,7 +1,7 @@ from dataclasses import dataclass import math import cadquery as Cq -import nhf.springs as NS +import nhf.parts.springs as springs from nhf import Role @dataclass @@ -209,7 +209,7 @@ def comma_joint(radius=30, def comma_assembly(): joint1 = comma_joint() joint2 = comma_joint() - spring = NS.torsion_spring() + spring = springs.torsion_spring() result = ( Cq.Assembly() .add(joint1, name="joint1", color=Cq.Color(0.8,0.8,0.5,0.3)) @@ -298,7 +298,7 @@ class TorsionJoint: def spring(self): - return NS.torsion_spring( + return springs.torsion_spring( radius=self.radius_spring, height=self.spring_height, thickness=self.spring_thickness, diff --git a/nhf/metric_threads.py b/nhf/parts/metric_threads.py similarity index 100% rename from nhf/metric_threads.py rename to nhf/parts/metric_threads.py diff --git a/nhf/springs.py b/nhf/parts/springs.py similarity index 100% rename from nhf/springs.py rename to nhf/parts/springs.py diff --git a/nhf/parts/test.py b/nhf/parts/test.py new file mode 100644 index 0000000..d1e1768 --- /dev/null +++ b/nhf/parts/test.py @@ -0,0 +1,59 @@ +import unittest +import cadquery as Cq +from nhf.checks import binary_intersection +import nhf.parts.joints as joints +import nhf.parts.handle as handle +import nhf.parts.metric_threads as metric_threads + +class TestJoints(unittest.TestCase): + + def test_joint_hirth(self): + j = joints.HirthJoint() + obj = j.generate() + self.assertIsInstance( + obj.val().solids(), Cq.Solid, + msg="Hirth joint must be in one piece") + + def test_joints_hirth_assembly(self): + for n_tooth in [16, 20, 24]: + with self.subTest(n_tooth=n_tooth): + j = joints.HirthJoint() + assembly = j.assembly() + isect = binary_intersection(assembly) + self.assertLess(isect.Volume(), 1e-6, + "Hirth joint assembly must not have intersection") + def test_joints_comma_assembly(self): + joints.comma_assembly() + def test_torsion_joint(self): + j = joints.TorsionJoint() + assembly = j.rider_track_assembly() + bbox = assembly.toCompound().BoundingBox() + self.assertAlmostEqual(bbox.zlen, j.total_height) + + +class TestHandle(unittest.TestCase): + + def test_handle_assembly(self): + h = handle.Handle() + assembly = h.connector_insertion_assembly() + bbox = assembly.toCompound().BoundingBox() + self.assertAlmostEqual(bbox.xlen, h.diam) + self.assertAlmostEqual(bbox.ylen, h.diam) + assembly = h.connector_one_side_insertion_assembly() + bbox = assembly.toCompound().BoundingBox() + self.assertAlmostEqual(bbox.xlen, h.diam) + self.assertAlmostEqual(bbox.ylen, h.diam) + + +class TestMetricThreads(unittest.TestCase): + + def test_major_radius(self): + major = 3.0 + t = metric_threads.external_metric_thread(major, 0.5, 4.0, z_start= -0.85, top_lead_in=True) + bbox = t.val().BoundingBox() + self.assertAlmostEqual(bbox.xlen, major, places=3) + self.assertAlmostEqual(bbox.ylen, major, places=3) + + +if __name__ == '__main__': + unittest.main() 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/test.py b/nhf/test.py index 4f97602..c6e94a6 100644 --- a/nhf/test.py +++ b/nhf/test.py @@ -1,58 +1,23 @@ import unittest import cadquery as Cq -import nhf.joints -import nhf.handle -import nhf.metric_threads as NMt -from nhf.checks import binary_intersection +from nhf.build import Model, target -class TestJoints(unittest.TestCase): +class BuildScaffold(Model): - def test_joint_hirth(self): - j = nhf.joints.HirthJoint() - obj = j.generate() - self.assertIsInstance( - obj.val().solids(), Cq.Solid, - msg="Hirth joint must be in one piece") + @target(name="obj1") + def o1(self): + return Cq.Solid.makeBox(10, 10, 10) - def test_joints_hirth_assembly(self): - for n_tooth in [16, 20, 24]: - with self.subTest(n_tooth=n_tooth): - j = nhf.joints.HirthJoint() - assembly = j.assembly() - isect = binary_intersection(assembly) - self.assertLess(isect.Volume(), 1e-6, - "Hirth joint assembly must not have intersection") - def test_joints_comma_assembly(self): - nhf.joints.comma_assembly() - def test_torsion_joint(self): - j = nhf.joints.TorsionJoint() - assembly = j.rider_track_assembly() - bbox = assembly.toCompound().BoundingBox() - self.assertAlmostEqual(bbox.zlen, j.total_height) + def o2(self): + return Cq.Solid.makeCylinder(10, 20) +class TestBuild(unittest.TestCase): -class TestHandle(unittest.TestCase): - - def test_handle_assembly(self): - h = nhf.handle.Handle() - assembly = h.connector_insertion_assembly() - bbox = assembly.toCompound().BoundingBox() - self.assertAlmostEqual(bbox.xlen, h.diam) - self.assertAlmostEqual(bbox.ylen, h.diam) - assembly = h.connector_one_side_insertion_assembly() - bbox = assembly.toCompound().BoundingBox() - self.assertAlmostEqual(bbox.xlen, h.diam) - self.assertAlmostEqual(bbox.ylen, h.diam) - - -class TestMetricThreads(unittest.TestCase): - - def test_major_radius(self): - major = 3.0 - t = NMt.external_metric_thread(major, 0.5, 4.0, z_start= -0.85, top_lead_in=True) - bbox = t.val().BoundingBox() - self.assertAlmostEqual(bbox.xlen, major, places=3) - self.assertAlmostEqual(bbox.ylen, major, places=3) + def test_build_scaffold(self): + s = BuildScaffold() + names = ["obj1"] + self.assertEqual(s.target_names, names) + self.assertEqual(s.check_all(), len(names)) if __name__ == '__main__': diff --git a/nhf/touhou/houjuu_nue/__init__.py b/nhf/touhou/houjuu_nue/__init__.py index 3d00652..37a91a4 100644 --- a/nhf/touhou/houjuu_nue/__init__.py +++ b/nhf/touhou/houjuu_nue/__init__.py @@ -32,12 +32,10 @@ shoulder, elbow, wrist in analogy with human anatomy. from dataclasses import dataclass, field import unittest import cadquery as Cq -import nhf.joints -import nhf.handle from nhf import Material -from nhf.joints import HirthJoint -from nhf.handle import Handle from nhf.build import Model, target +from nhf.parts.joints import HirthJoint +from nhf.parts.handle import Handle import nhf.touhou.houjuu_nue.wing as MW import nhf.touhou.houjuu_nue.trident as MT diff --git a/nhf/touhou/houjuu_nue/trident.py b/nhf/touhou/houjuu_nue/trident.py index 71901ab..312ff85 100644 --- a/nhf/touhou/houjuu_nue/trident.py +++ b/nhf/touhou/houjuu_nue/trident.py @@ -1,7 +1,7 @@ import math import cadquery as Cq from nhf import Material -from nhf.handle import Handle +from nhf.parts.handle import Handle def trident_assembly( handle: Handle, diff --git a/nhf/touhou/houjuu_nue/wing.py b/nhf/touhou/houjuu_nue/wing.py index 04de65a..224183d 100644 --- a/nhf/touhou/houjuu_nue/wing.py +++ b/nhf/touhou/houjuu_nue/wing.py @@ -4,7 +4,7 @@ This file describes the shapes of the wing shells. The joints are defined in """ import math import cadquery as Cq -from nhf.joints import HirthJoint +from nhf.parts.joints import HirthJoint def wing_root_profiles( base_sweep=150, -- 2.44.1 From 66fc02ef44dd7c36fb1398d408d516f56198f7af Mon Sep 17 00:00:00 2001 From: Leni Aniva Date: Thu, 4 Jul 2024 01:11:16 -0700 Subject: [PATCH 33/38] feat: Export DXF in build system --- nhf/build.py | 64 ++++++++++++++++++++++++------- nhf/touhou/houjuu_nue/__init__.py | 11 ++---- 2 files changed, 54 insertions(+), 21 deletions(-) diff --git a/nhf/build.py b/nhf/build.py index 5da3d01..c3b0e4a 100644 --- a/nhf/build.py +++ b/nhf/build.py @@ -2,25 +2,69 @@ The NHF build system Usage: For any parametric assembly, inherit the `Model` class, and mark the -output objects with the `@target` decorator +output objects with the `@target` decorator. Each marked function should only +take `self` as an argument. +```python +class BuildScaffold(Model): + + @target(name="obj1") + def o1(self): + return Cq.Solid.makeBox(10, 10, 10) + + def o2(self): + return Cq.Solid.makeCylinder(10, 20) +``` """ +from enum import Enum from pathlib import Path from typing import Union from functools import wraps from colorama import Fore, Style import cadquery as Cq +class TargetKind(Enum): + + STL = "stl", + DXF = "dxf", + + def __init__(self, ext: str): + self.ext = ext + class Target: def __init__(self, method, - name: str): + name: str, + kind: TargetKind = TargetKind.STL, + **kwargs): self._method = method self.name = name + self.kind = kind + self.kwargs = kwargs def __str__(self): return f"" def __call__(self, obj, *args, **kwargs): + """ + Raw call function which passes arguments directly to `_method` + """ return self._method(obj, *args, **kwargs) + def file_name(self, file_name): + return f"{file_name}.{self.kind.ext}" + def write_to(self, obj, path: str): + x = self._method(obj) + if self.kind == TargetKind.STL: + assert isinstance(x, Union[ + Cq.Workplane, Cq.Shape, Cq.Compound, Cq.Assembly]) + if isinstance(x, Cq.Workplane): + x = x.val() + if isinstance(x, Cq.Assembly): + x = x.toCompound() + x.exportStl(path, **self.kwargs) + elif self.kind == TargetKind.DXF: + assert isinstance(x, Cq.Workplane) + Cq.exporters.exportDXF(x, path, **self.kwargs) + else: + assert False, f"Invalid kind: {self.kind}" @classmethod def methods(cls, subject): @@ -49,13 +93,6 @@ def target(name, **deco_kwargs): return wrapper return f -def _to_shape(x: Union[Cq.Workplane, Cq.Shape, Cq.Compound, Cq.Assembly]) -> Cq.Shape: - if isinstance(x, Cq.Workplane): - x = x.val() - if isinstance(x, Cq.Assembly): - x = x.toCompound() - return x - class Model: """ @@ -76,22 +113,21 @@ class Model: total += 1 return total - def build_all(self, output_dir: Union[Path, str] ="build", verbose=1): + def build_all(self, output_dir: Union[Path, str] = "build", verbose=1): """ Build all targets in this model """ output_dir = Path(output_dir) output_dir.mkdir(exist_ok=True, parents=True) - for k, f in Target.methods(self).items(): - output_file = output_dir / f"{k}.stl" + for k, target in Target.methods(self).items(): + output_file = output_dir / target.file_name(k) if output_file.is_file(): if verbose >= 1: print(f"{Fore.GREEN}Skipping{Style.RESET_ALL} {output_file}") continue - model = f(self) if verbose >= 1: print(f"{Fore.BLUE}Building{Style.RESET_ALL} {output_file}") - _to_shape(model).exportStl(str(output_file)) + target.write_to(self, str(output_file)) if verbose >= 1: print(f"{Fore.GREEN}Built{Style.RESET_ALL} {output_file}") diff --git a/nhf/touhou/houjuu_nue/__init__.py b/nhf/touhou/houjuu_nue/__init__.py index 37a91a4..ba99b00 100644 --- a/nhf/touhou/houjuu_nue/__init__.py +++ b/nhf/touhou/houjuu_nue/__init__.py @@ -33,7 +33,7 @@ from dataclasses import dataclass, field import unittest import cadquery as Cq from nhf import Material -from nhf.build import Model, target +from nhf.build import Model, TargetKind, target from nhf.parts.joints import HirthJoint from nhf.parts.handle import Handle import nhf.touhou.houjuu_nue.wing as MW @@ -113,11 +113,6 @@ class Parameters(Model): "Wing root must be large enough to accomodate joint" - def print_geometries(self): - return [ - self.hs_joint_parent() - ] - def harness_profile(self) -> Cq.Sketch: """ Creates the harness shape @@ -148,6 +143,7 @@ class Parameters(Model): ) return sketch + @target(name="harness", kind=TargetKind.DXF) def harness(self) -> Cq.Shape: """ Creates the harness shape @@ -249,7 +245,8 @@ class Parameters(Model): result.faces(" Cq.Shape: + @target(name="wing_root") + def wing_root(self) -> Cq.Assembly: """ Generate the wing root which contains a Hirth joint at its base and a rectangular opening on its side, with the necessary interfaces. -- 2.44.1 From 80fb2e997d0e8122cb96999c323ba6433bf9b7e5 Mon Sep 17 00:00:00 2001 From: Leni Aniva Date: Thu, 4 Jul 2024 01:13:22 -0700 Subject: [PATCH 34/38] feat: Build trident handle --- nhf/build.py | 2 +- nhf/touhou/houjuu_nue/__init__.py | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/nhf/build.py b/nhf/build.py index c3b0e4a..7b2531d 100644 --- a/nhf/build.py +++ b/nhf/build.py @@ -118,13 +118,13 @@ class Model: Build all targets in this model """ output_dir = Path(output_dir) - output_dir.mkdir(exist_ok=True, parents=True) for k, target in Target.methods(self).items(): output_file = output_dir / target.file_name(k) if output_file.is_file(): if verbose >= 1: print(f"{Fore.GREEN}Skipping{Style.RESET_ALL} {output_file}") continue + output_file.parent.mkdir(exist_ok=True, parents=True) if verbose >= 1: print(f"{Fore.BLUE}Building{Style.RESET_ALL} {output_file}") diff --git a/nhf/touhou/houjuu_nue/__init__.py b/nhf/touhou/houjuu_nue/__init__.py index ba99b00..8f5679e 100644 --- a/nhf/touhou/houjuu_nue/__init__.py +++ b/nhf/touhou/houjuu_nue/__init__.py @@ -112,6 +112,13 @@ class Parameters(Model): assert self.wing_root_radius > self.hs_hirth_joint.radius,\ "Wing root must be large enough to accomodate joint" + @target(name="trident/handle-connector") + def handle_connector(self): + return self.trident_handle.connector() + @target(name="trident/handle-insertion") + def handle_insertion(self): + return self.trident_handle.insertion() + def harness_profile(self) -> Cq.Sketch: """ -- 2.44.1 From d69cf014a10998c4691bc20fe88ff7d9ddd756a6 Mon Sep 17 00:00:00 2001 From: Leni Aniva Date: Thu, 4 Jul 2024 01:16:01 -0700 Subject: [PATCH 35/38] chore: Clean up import --- nhf/parts/test.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/nhf/parts/test.py b/nhf/parts/test.py index d1e1768..1e70ba2 100644 --- a/nhf/parts/test.py +++ b/nhf/parts/test.py @@ -1,9 +1,7 @@ import unittest import cadquery as Cq from nhf.checks import binary_intersection -import nhf.parts.joints as joints -import nhf.parts.handle as handle -import nhf.parts.metric_threads as metric_threads +from nhf.parts import joints, handle, metric_threads class TestJoints(unittest.TestCase): -- 2.44.1 From 89c6a39c2f92ee4716d42266aecc010f3273fcf3 Mon Sep 17 00:00:00 2001 From: Leni Aniva Date: Thu, 4 Jul 2024 10:02:58 -0700 Subject: [PATCH 36/38] feat: Name in target --- nhf/build.py | 37 ++++++++++++++++++++++--------- nhf/touhou/houjuu_nue/__init__.py | 1 + 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/nhf/build.py b/nhf/build.py index 7b2531d..4435508 100644 --- a/nhf/build.py +++ b/nhf/build.py @@ -42,14 +42,20 @@ class Target: self.kind = kind self.kwargs = kwargs def __str__(self): - return f"" + return f"" def __call__(self, obj, *args, **kwargs): """ Raw call function which passes arguments directly to `_method` """ return self._method(obj, *args, **kwargs) - def file_name(self, file_name): - return f"{file_name}.{self.kind.ext}" + + @property + def file_name(self): + """ + Output file name + """ + return f"{self.name}.{self.kind.ext}" + def write_to(self, obj, path: str): x = self._method(obj) if self.kind == TargetKind.STL: @@ -98,6 +104,8 @@ class Model: """ Base class for a parametric assembly """ + def __init__(self, name: str): + self.name = name @property def target_names(self) -> list[str]: @@ -107,19 +115,22 @@ class Model: return list(Target.methods(self).keys()) def check_all(self) -> int: + """ + Builds all targets but do not output them + """ total = 0 - for k, f in Target.methods(self).items(): - f(self) + for t in Target.methods(self).values(): + t(self) total += 1 return total def build_all(self, output_dir: Union[Path, str] = "build", verbose=1): """ - Build all targets in this model + Build all targets in this model and write the results to file """ output_dir = Path(output_dir) - for k, target in Target.methods(self).items(): - output_file = output_dir / target.file_name(k) + for t in Target.methods(self).values(): + output_file = output_dir / self.name / t.file_name if output_file.is_file(): if verbose >= 1: print(f"{Fore.GREEN}Skipping{Style.RESET_ALL} {output_file}") @@ -128,6 +139,10 @@ class Model: if verbose >= 1: print(f"{Fore.BLUE}Building{Style.RESET_ALL} {output_file}") - target.write_to(self, str(output_file)) - if verbose >= 1: - print(f"{Fore.GREEN}Built{Style.RESET_ALL} {output_file}") + + try: + t.write_to(self, str(output_file)) + if verbose >= 1: + print(f"{Fore.GREEN}Built{Style.RESET_ALL} {output_file}") + except Exception as e: + print(f"{Fore.RED}Failed to build{Style.RESET_ALL} {output_file}: {e}") diff --git a/nhf/touhou/houjuu_nue/__init__.py b/nhf/touhou/houjuu_nue/__init__.py index 8f5679e..b90eb8c 100644 --- a/nhf/touhou/houjuu_nue/__init__.py +++ b/nhf/touhou/houjuu_nue/__init__.py @@ -109,6 +109,7 @@ class Parameters(Model): )) def __post_init__(self): + super().__init__(name="houjuu-nue") assert self.wing_root_radius > self.hs_hirth_joint.radius,\ "Wing root must be large enough to accomodate joint" -- 2.44.1 From 1794729890e587e5aad693e1e774d6cbcbc3d044 Mon Sep 17 00:00:00 2001 From: Leni Aniva Date: Thu, 4 Jul 2024 12:03:38 -0700 Subject: [PATCH 37/38] fix: Use subassemblies for wings and harnesses --- nhf/parts/joints.py | 8 +++- nhf/touhou/houjuu_nue/__init__.py | 64 +++++++++++++++++-------------- nhf/touhou/houjuu_nue/test.py | 9 +++-- nhf/touhou/houjuu_nue/wing.py | 14 ++++--- 4 files changed, 55 insertions(+), 40 deletions(-) diff --git a/nhf/parts/joints.py b/nhf/parts/joints.py index 286776d..036a4f4 100644 --- a/nhf/parts/joints.py +++ b/nhf/parts/joints.py @@ -28,6 +28,10 @@ class HirthJoint: def tooth_angle(self): return 360 / self.n_tooth + @property + def total_height(self): + return self.base_height + self.tooth_height + def generate(self, is_mated=False, tol=0.01): """ @@ -97,7 +101,7 @@ class HirthJoint: ) return result - def assembly(self): + def assembly(self, offset: int = 1): """ Generate an example assembly """ @@ -118,7 +122,7 @@ class HirthJoint: self.generate(is_mated=True) .union(tab) ) - angle = 1 * self.tooth_angle + angle = offset * self.tooth_angle result = ( Cq.Assembly() .add(obj1, name="obj1", color=Role.PARENT.color) diff --git a/nhf/touhou/houjuu_nue/__init__.py b/nhf/touhou/houjuu_nue/__init__.py index b90eb8c..fe26116 100644 --- a/nhf/touhou/houjuu_nue/__init__.py +++ b/nhf/touhou/houjuu_nue/__init__.py @@ -32,7 +32,7 @@ shoulder, elbow, wrist in analogy with human anatomy. from dataclasses import dataclass, field import unittest import cadquery as Cq -from nhf import Material +from nhf import Material, Role from nhf.build import Model, TargetKind, target from nhf.parts.joints import HirthJoint from nhf.parts.handle import Handle @@ -194,18 +194,6 @@ class Parameters(Model): coupling, a cylindrical base, and a mounting base. """ hirth = self.hs_hirth_joint.generate() - #hirth = ( - # hirth - # .faces("Z") @@ -224,11 +213,13 @@ class Parameters(Model): cboreDiameter=self.hs_joint_corner_cbore_diam, cboreDepth=self.hs_joint_corner_cbore_depth) ) + # Creates a plane parallel to the holes but shifted to the base plane = result.faces(">Z").workplane(offset=-self.hs_joint_base_thickness) + for i, (px, py) in enumerate(conn): ( plane - .moveTo(px, py) + .pushPoints([(px, py)]) .circle(1, forConstruction='True') .edges() .tag(f"h{i}") @@ -237,7 +228,7 @@ class Parameters(Model): result .faces(">Z") .workplane() - .union(hirth.translate((0, 0, self.hs_joint_base_thickness)), tol=0.1) + .union(hirth, tol=0.1) .clean() ) result = ( @@ -304,34 +295,49 @@ class Parameters(Model): # Assemblies # ###################### + def trident_assembly(self): + return MT.trident_assembly(self.trident_handle) + def harness_assembly(self): harness = self.harness() result = ( Cq.Assembly() - .add(harness, name="harness", color=Material.WOOD_BIRCH.color) - .constrain("harness", "Fixed") + .add(harness, name="base", color=Material.WOOD_BIRCH.color) + .constrain("base", "Fixed") ) for name in ["l1", "l2", "l3", "r1", "r2", "r3"]: j = self.hs_joint_parent() ( result - .add(j, name=f"hs_{name}", color=Material.PLASTIC_PLA.color) - #.constrain(f"harness?{name}", f"hs_{name}p?mate", "Point") - .constrain("harness?mount", f"hs_{name}?base", "Axis") + .add(j, name=name, color=Role.PARENT.color) + .constrain("base?mount", f"{name}?base", "Axis") ) for i in range(4): - result.constrain(f"harness?{name}_{i}", f"hs_{name}?h{i}", "Point") - angle = 6 * self.hs_hirth_joint.tooth_angle - ( - result.add(self.wing_root(), name="w0_r1", color=Material.PLASTIC_PLA.color) - .constrain("w0_r1?mate", "hs_r1?mate", "Plane") - .constrain("w0_r1?directrix", "hs_r1?directrix", "Axis", param=angle) - ) + result.constrain(f"base?{name}_{i}", f"{name}?h{i}", "Point") result.solve() return result - def trident_assembly(self): - return MT.trident_assembly(self.trident_handle) + def wings_assembly(self): + """ + Assembly of harness with all the wings + """ + a_tooth = self.hs_hirth_joint.tooth_angle + + result = ( + Cq.Assembly() + .add(self.harness_assembly(), name="harness", loc=Cq.Location((0, 0, 0))) + .add(self.wing_root(), name="w0_r1") + .add(self.wing_root(), name="w0_l1") + .constrain("harness/base", "Fixed") + .constrain("w0_r1/joint?mate", "harness/r1?mate", "Plane") + .constrain("w0_r1/joint?directrix", "harness/r1?directrix", + "Axis", param=7 * a_tooth) + .constrain("w0_l1/joint?mate", "harness/l1?mate", "Plane") + .constrain("w0_l1/joint?directrix", "harness/l1?directrix", + "Axis", param=-1 * a_tooth) + .solve() + ) + return result if __name__ == '__main__': diff --git a/nhf/touhou/houjuu_nue/test.py b/nhf/touhou/houjuu_nue/test.py index 57d1aaa..0b63ecd 100644 --- a/nhf/touhou/houjuu_nue/test.py +++ b/nhf/touhou/houjuu_nue/test.py @@ -18,13 +18,16 @@ class Test(unittest.TestCase): self.assertLess(bbox.xlen, 255, msg=msg) self.assertLess(bbox.ylen, 255, msg=msg) self.assertLess(bbox.zlen, 255, msg=msg) - def test_wings(self): + def test_wing_root(self): p = M.Parameters() p.wing_root() - def test_harness(self): + def test_wings_assembly(self): + p = M.Parameters() + p.wings_assembly() + def test_harness_assembly(self): p = M.Parameters() p.harness_assembly() - def test_trident(self): + def test_trident_assembly(self): p = M.Parameters() assembly = p.trident_assembly() bbox = assembly.toCompound().BoundingBox() diff --git a/nhf/touhou/houjuu_nue/wing.py b/nhf/touhou/houjuu_nue/wing.py index 224183d..5f9daf9 100644 --- a/nhf/touhou/houjuu_nue/wing.py +++ b/nhf/touhou/houjuu_nue/wing.py @@ -4,6 +4,7 @@ This file describes the shapes of the wing shells. The joints are defined in """ import math import cadquery as Cq +from nhf import Material, Role from nhf.parts.joints import HirthJoint def wing_root_profiles( @@ -125,7 +126,7 @@ def wing_root_profiles( def wing_root(joint: HirthJoint, - bolt_diam: int = 12): + bolt_diam: int = 12) -> Cq.Assembly: """ Generate the contiguous components of the root wing segment """ @@ -192,11 +193,12 @@ def wing_root(joint: HirthJoint, .faces(" Date: Thu, 4 Jul 2024 17:50:11 -0700 Subject: [PATCH 38/38] feat: Connectors on wing root --- nhf/touhou/houjuu_nue/wing.py | 134 ++++++++++++++++++++-------------- 1 file changed, 78 insertions(+), 56 deletions(-) diff --git a/nhf/touhou/houjuu_nue/wing.py b/nhf/touhou/houjuu_nue/wing.py index 5f9daf9..ea3a5c3 100644 --- a/nhf/touhou/houjuu_nue/wing.py +++ b/nhf/touhou/houjuu_nue/wing.py @@ -10,8 +10,9 @@ from nhf.parts.joints import HirthJoint def wing_root_profiles( base_sweep=150, wall_thickness=8, - base_radius=60, + base_radius=40, middle_offset=30, + middle_height=80, conn_width=40, conn_height=100) -> tuple[Cq.Wire, Cq.Wire]: assert base_sweep < 180 @@ -72,7 +73,7 @@ def wing_root_profiles( # If the exterior sweep is theta', it has to satisfy # # sin(theta) * r2 + wall_thickness = sin(theta') * r1 - x, y = conn_width / 2, conn_height / 2 + x, y = conn_width / 2, middle_height / 2 t = wall_thickness dx = middle_offset middle = ( @@ -126,65 +127,86 @@ def wing_root_profiles( def wing_root(joint: HirthJoint, - bolt_diam: int = 12) -> Cq.Assembly: + bolt_diam: int = 12, + union_tol=1e-4, + attach_diam=8, + conn_width=40, + conn_height=100, + wall_thickness=8) -> Cq.Assembly: """ Generate the contiguous components of the root wing segment """ - root_profile, middle_profile, tip_profile = wing_root_profiles() + tip_centre = Cq.Vector((-150, 0, -80)) + attach_points = [ + (15, 0), + (40, 0), + ] + root_profile, middle_profile, tip_profile = wing_root_profiles( + conn_width=conn_width, + conn_height=conn_height, + wall_thickness=8, + ) + middle_profile = middle_profile.located(Cq.Location( + (-40, 0, -40), (0, 30, 0) + )) + antetip_profile = tip_profile.located(Cq.Location( + (-95, 0, -75), (0, 60, 0) + )) + tip_profile = tip_profile.located(Cq.Location( + tip_centre, (0, 90, 0) + )) + profiles = [ + root_profile, + middle_profile, + antetip_profile, + tip_profile, + ] + result = None + for p1, p2 in zip(profiles[:-1], profiles[1:]): + seg = ( + Cq.Workplane('XY') + .add(p1) + .toPending() + .workplane() # This call is necessary + .add(p2) + .toPending() + .loft() + ) + if result: + result = result.union(seg, tol=union_tol) + else: + result = seg + result = ( + result + # Create connector holes + .copyWorkplane( + Cq.Workplane('bottom', origin=tip_centre + Cq.Vector((0, -50, 0))) + ) + .pushPoints(attach_points) + .hole(attach_diam) + ) + # Generate attach point tags - rotate_centre = Cq.Vector(-200, 0, -25) - rotate_axis = Cq.Vector(0, 1, 0) - terminal_offset = Cq.Vector(-80, 0, 80) - terminal_rotate = Cq.Vector(0, -45, 0) + for sign in [False, True]: + y = conn_height / 2 - wall_thickness + side = "bottom" if sign else "top" + y = y if sign else -y + plane = ( + result + # Create connector holes + .copyWorkplane( + Cq.Workplane(side, origin=tip_centre + + Cq.Vector((0, y, 0))) + ) + ) + for i, (px, py) in enumerate(attach_points): + ( + plane + .moveTo(px, py) + .eachpoint(Cq.Vertex.makeVertex(0, 0, 0)) + .tag(f"conn_{side}{i}") + ) - #middle_profile = middle_profile.moved(Cq.Location((0, 0, -100))) - #tip_profile = tip_profile.moved(Cq.Location((0, 0, -200))) - middle_profile = middle_profile.rotate( - startVector=rotate_centre, - endVector=rotate_centre + rotate_axis, - angleDegrees = 30, - ) - antetip_profile = tip_profile.rotate( - startVector=rotate_centre, - endVector=rotate_centre + rotate_axis, - angleDegrees = 60, - ) - tip_profile = tip_profile.rotate( - startVector=rotate_centre, - endVector=rotate_centre + rotate_axis, - angleDegrees = 90, - ) - seg1 = ( - Cq.Workplane('XY') - .add(root_profile) - .toPending() - .transformed( - offset=terminal_offset, - rotate=terminal_rotate) - #.add(middle_profile.moved(Cq.Location((-15, 0, 15)))) - .add(middle_profile) - .toPending() - .loft() - ) - seg2 = ( - Cq.Workplane('XY') - .add(middle_profile) - .toPending() - .workplane() - .add(antetip_profile) - .toPending() - .loft() - ) - seg3 = ( - Cq.Workplane('XY') - .add(antetip_profile) - .toPending() - .workplane() - .add(tip_profile) - .toPending() - .loft() - ) - result = seg1.union(seg2).union(seg3) result.faces("X").tag("conn") -- 2.44.1