From 66b26fa056cc2d7839ab82b4bb2549d4215c55cc Mon Sep 17 00:00:00 2001 From: Leni Aniva Date: Tue, 16 Jul 2024 15:42:39 -0700 Subject: [PATCH] feat: Submodel in build system --- nhf/build.py | 106 ++++++++++++++++++++++-- nhf/parts/box.py | 4 +- nhf/touhou/houjuu_nue/__init__.py | 125 ++++++---------------------- nhf/touhou/houjuu_nue/harness.py | 9 +- nhf/touhou/houjuu_nue/trident.py | 133 +++++++++++++++++++----------- nhf/touhou/houjuu_nue/wing.py | 25 +++++- 6 files changed, 239 insertions(+), 163 deletions(-) diff --git a/nhf/build.py b/nhf/build.py index 9a16a1d..3db9c36 100644 --- a/nhf/build.py +++ b/nhf/build.py @@ -17,11 +17,13 @@ class BuildScaffold(Model): """ from enum import Enum from pathlib import Path -from typing import Union +from typing import Union, Optional from functools import wraps +import traceback from colorama import Fore, Style import cadquery as Cq import nhf.checks as NC +import nhf.utils TOL=1e-6 @@ -40,7 +42,7 @@ class Target: def __init__(self, method, - name: str, + name: Optional[str] = None, prototype: bool = False, kind: TargetKind = TargetKind.STL, **kwargs): @@ -58,11 +60,14 @@ class Target: return self._method(obj, *args, **kwargs) @property - def file_name(self): + def file_name(self) -> Optional[str]: """ Output file name """ - return f"{self.name}.{self.kind.ext}" + if self.name: + return f"{self.name}.{self.kind.ext}" + else: + return None def write_to(self, obj, path: str): x = self._method(obj) @@ -75,6 +80,14 @@ class Target: x = x.toCompound().fuse(tol=TOL) x.exportStl(path, **self.kwargs) elif self.kind == TargetKind.DXF: + if isinstance(x, Cq.Sketch): + # https://github.com/CadQuery/cadquery/issues/1575 + x = ( + Cq.Workplane() + .add(x._faces) + .add(x._wires) + .add(x._edges) + ) assert isinstance(x, Cq.Workplane) Cq.exporters.exportDXF(x, path, **self.kwargs) else: @@ -95,7 +108,7 @@ class Target: return {method.name: method for method in g()} -def target(name, **deco_kwargs): +def target(**deco_kwargs): """ Decorator for annotating a build output """ @@ -103,7 +116,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, **deco_kwargs) return wrapper return f @@ -161,11 +174,74 @@ def assembly(**deco_kwargs): return wrapper return f + + +class Submodel: + """ + Marks a function's output as a submodel + """ + + def __init__(self, + method, + name: str, + prototype: bool = False, + **kwargs): + self._method = method + self.name = name + self.prototype = prototype + 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) + + @property + def file_name(self): + """ + Output file name + """ + return self.name + + def write_to(self, obj, path: str): + x = self._method(obj) + assert isinstance(x, Model), f"Unexpected type: {type(x)}" + x.build_all(path) + + @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): + if name == 'target_names': + continue + method = getattr(subject, name) + if hasattr(method, '_submodel'): + yield method._submodel + return {method.name: method for method in g()} + + +def submodel(name, **deco_kwargs): + """ + Decorator for annotating a build output + """ + def f(method): + @wraps(method) + def wrapper(self, *args, **kwargs): + return method(self, *args, **kwargs) + wrapper._submodel = Submodel(method, name, **deco_kwargs) + return wrapper + return f + class Model: """ Base class for a parametric assembly """ - def __init__(self, name: str): + def __init__(self, name: Optional[str] = None): self.name = name @property @@ -193,8 +269,15 @@ class Model: Build all targets in this model and write the results to file """ output_dir = Path(output_dir) - for t in Target.methods(self).values(): - output_file = output_dir / self.name / t.file_name + targets = Target.methods(self) + for t in targets.values(): + file_name = t.file_name + if file_name is None: + assert len(targets) == 1, "Only one anonymous target is permitted" + output_file = output_dir.with_suffix('.' + t.kind.ext) + else: + output_file = output_dir / file_name + if output_file.is_file(): if verbose >= 1: print(f"{Fore.GREEN}Skipping{Style.RESET_ALL} {output_file}") @@ -210,3 +293,8 @@ class Model: 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}") + traceback.print_exc() + + for t in Submodel.methods(self).values(): + d = output_dir / t.name + t.write_to(self, str(d)) diff --git a/nhf/parts/box.py b/nhf/parts/box.py index a95f512..842cf8b 100644 --- a/nhf/parts/box.py +++ b/nhf/parts/box.py @@ -1,6 +1,7 @@ import cadquery as Cq from dataclasses import dataclass, field from typing import Tuple, Optional, Union +from nhf.build import Model, TargetKind, target import nhf.utils def box_with_centre_holes( @@ -34,7 +35,7 @@ class Hole: tag: Optional[str] = None @dataclass -class MountingBox: +class MountingBox(Model): """ Create a box with marked holes """ @@ -53,6 +54,7 @@ class MountingBox: generate_side_tags: bool = True + @target(kind=TargetKind.DXF) def profile(self) -> Cq.Sketch: bx, by = 0, 0 if not self.centred[0]: diff --git a/nhf/touhou/houjuu_nue/__init__.py b/nhf/touhou/houjuu_nue/__init__.py index d9b3f26..f628ede 100644 --- a/nhf/touhou/houjuu_nue/__init__.py +++ b/nhf/touhou/houjuu_nue/__init__.py @@ -32,9 +32,8 @@ shoulder, elbow, wrist in analogy with human anatomy. from dataclasses import dataclass, field from typing import Optional import cadquery as Cq -from nhf.build import Model, TargetKind, target, assembly +from nhf.build import Model, TargetKind, target, assembly, submodel from nhf.parts.joints import HirthJoint, TorsionJoint -from nhf.parts.handle import Handle, BayonetMount import nhf.touhou.houjuu_nue.wing as MW import nhf.touhou.houjuu_nue.trident as MT import nhf.touhou.houjuu_nue.joints as MJ @@ -47,9 +46,6 @@ class Parameters(Model): Defines dimensions for the Houjuu Nue cosplay """ - # Thickness of the exoskeleton panel in millimetres - panel_thickness: float = 25.4 / 16 - # Harness harness: MH.Harness = field(default_factory=lambda: MH.Harness()) @@ -61,120 +57,53 @@ class Parameters(Model): n_tooth=24 )) - wing_profile: MW.WingProfile = field(default_factory=lambda: MW.WingProfile( - shoulder_joint=MJ.ShoulderJoint( - height=100.0, - ), - elbow_height=110.0, - )) + wing_r1: MW.WingProfile = field(default_factory=lambda: MW.WingProfile(name="r1")) + wing_r2: MW.WingProfile = field(default_factory=lambda: MW.WingProfile(name="r2")) + wing_r3: MW.WingProfile = field(default_factory=lambda: MW.WingProfile(name="r3")) - # Exterior radius of the wing root assembly - wing_root_radius: float = 40 - wing_root_wall_thickness: float = 8 - - """ - Heights for various wing joints, where the numbers start from the first - joint. - """ - wing_s0_thickness: float = 40 - - # Length of the spacer - wing_s1_thickness: float = 20 - wing_s1_spacer_thickness: float = 25.4 / 8 - wing_s1_spacer_width: float = 20 - wing_s1_spacer_hole_diam: float = 8 - wing_s1_shoulder_spacer_hole_dist: float = 20 - wing_s1_shoulder_spacer_width: float = 60 - - trident_handle: Handle = field(default_factory=lambda: Handle( - diam=38, - diam_inner=38-2 * 25.4/8, - diam_connector_internal=18, - simplify_geometry=False, - mount=BayonetMount(n_pin=3), - )) - trident_terminal_height: float = 80 - trident_terminal_hole_diam: float = 24 - trident_terminal_bottom_thickness: float = 10 + trident: MT.Trident = field(default_factory=lambda: MT.Trident()) def __post_init__(self): super().__init__(name="houjuu-nue") self.harness.hs_hirth_joint = self.hs_hirth_joint - self.wing_profile.base_joint = self.hs_hirth_joint - assert self.wing_root_radius > self.hs_hirth_joint.radius, \ - "Wing root must be large enough to accomodate joint" - assert self.wing_s1_shoulder_spacer_hole_dist > self.wing_s1_spacer_hole_diam, \ - "Spacer holes are too close to each other" + self.wing_r1.base_joint = self.hs_hirth_joint + self.wing_r2.base_joint = self.hs_hirth_joint + self.wing_r3.base_joint = self.hs_hirth_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() - @target(name="trident/proto-handle-connector", prototype=True) - def proto_handle_connector(self): - return self.trident_handle.one_side_connector(height=15) - @target(name="trident/handle-terminal-connector") - def handle_terminal_connector(self): - result = self.trident_handle.one_side_connector(height=self.trident_terminal_height) - #result.faces("Z").hole(self.trident_terminal_hole_diam, depth=h) - return result + @submodel(name="harness") + def submodel_harness(self) -> Model: + return self.harness - @target(name="harness", kind=TargetKind.DXF) - def harness_profile(self) -> Cq.Sketch: - return self.harness.profile() - - def harness_surface(self) -> Cq.Workplane: - return self.harness.surface() - - def hs_joint_parent(self) -> Cq.Workplane: - return self.harness.hs_joint_parent() - - @assembly() - def harness_assembly(self) -> Cq.Assembly: - return self.harness.assembly() - - @target(name="wing/proto-shoulder-joint-parent", prototype=True) - def proto_shoulder_joint_parent(self): - return self.wing_profile.shoulder_joint.torsion_joint.track() - @target(name="wing/proto-shoulder-joint-child", prototype=True) - def proto_shoulder_joint_child(self): - return self.wing_profile.shoulder_joint.torsion_joint.rider() - - - @assembly() - def wing_r1_assembly(self, parts: Optional[list[str]] = None) -> Cq.Assembly: - return self.wing_profile.assembly(parts) + @submodel(name="wing-r1") + def submodel_wing_r1(self) -> Model: + return self.wing_r1 + @submodel(name="wing-r2") + def submodel_wing_r2(self) -> Model: + return self.wing_r2 + @submodel(name="wing-r3") + def submodel_wing_r3(self) -> Model: + return self.wing_r3 @assembly() def wings_harness_assembly(self, parts: Optional[list[str]] = None) -> Cq.Assembly: """ 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_r1_assembly(parts), name="wing_r1") - .add(self.wing_r1_assembly(parts), name="wing_r2") - .add(self.wing_r1_assembly(parts), name="wing_r3") + .add(self.harness.assembly(), name="harness", loc=Cq.Location((0, 0, 0))) + .add(self.wing_r1.assembly(parts), name="wing_r1") + .add(self.wing_r2.assembly(parts), name="wing_r2") + .add(self.wing_r3.assembly(parts), name="wing_r3") ) self.hs_hirth_joint.add_constraints(result, "harness/r1", "wing_r1/s0/hs", offset=9) self.hs_hirth_joint.add_constraints(result, "harness/r2", "wing_r2/s0/hs", offset=8) self.hs_hirth_joint.add_constraints(result, "harness/r3", "wing_r3/s0/hs", offset=7) return result.solve() - @assembly(collision_check=False) - def trident_assembly(self) -> Cq.Assembly: - """ - Disable collision check since the threads may not align. - """ - return MT.trident_assembly(self.trident_handle) - + @submodel(name="trident") + def submodel_trident(self) -> Model: + return self.trident if __name__ == '__main__': diff --git a/nhf/touhou/houjuu_nue/harness.py b/nhf/touhou/houjuu_nue/harness.py index 2bcba19..d5a35cb 100644 --- a/nhf/touhou/houjuu_nue/harness.py +++ b/nhf/touhou/houjuu_nue/harness.py @@ -2,10 +2,11 @@ from dataclasses import dataclass, field import cadquery as Cq from nhf.parts.joints import HirthJoint from nhf import Material, Role +from nhf.build import Model, TargetKind, target, assembly import nhf.utils @dataclass -class Harness: +class Harness(Model): thickness: float = 25.4 / 8 width: float = 300.0 height: float = 400.0 @@ -40,6 +41,10 @@ class Harness: hs_joint_axis_cbore_diam: float = 20 hs_joint_axis_cbore_depth: float = 3 + def __post_init__(self): + super().__init__(name="harness") + + @target(name="profile", kind=TargetKind.DXF) def profile(self) -> Cq.Sketch: """ Creates the harness shape @@ -100,6 +105,7 @@ class Harness: (-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 @@ -150,6 +156,7 @@ class Harness: result.faces(" Cq.Assembly: harness = self.surface() result = ( diff --git a/nhf/touhou/houjuu_nue/trident.py b/nhf/touhou/houjuu_nue/trident.py index bd5c5ea..bfc435f 100644 --- a/nhf/touhou/houjuu_nue/trident.py +++ b/nhf/touhou/houjuu_nue/trident.py @@ -1,55 +1,88 @@ import math +from dataclasses import dataclass, field import cadquery as Cq from nhf import Material, Role -from nhf.parts.handle import Handle +from nhf.parts.handle import Handle, BayonetMount +from nhf.build import Model, target, assembly +import nhf.utils -def trident_assembly( - handle: Handle, - handle_segment_length: float = 24*25.4, - terminal_height=100): - def segment(): - return handle.segment(handle_segment_length) +@dataclass +class Trident(Model): + handle: Handle = field(default_factory=lambda: Handle( + diam=38, + diam_inner=38-2 * 25.4/8, + diam_connector_internal=18, + simplify_geometry=False, + mount=BayonetMount(n_pin=3), + )) + terminal_height: float = 80 + terminal_hole_diam: float = 24 + terminal_bottom_thickness: float = 10 + segment_length: float = 24 * 25.4 - terminal = ( - handle - .one_side_connector(height=terminal_height) - .faces(">Z") - .hole(15, terminal_height + handle.insertion_length - 10) - ) - mat_c = Material.PLASTIC_PLA - mat_i = Material.RESIN_TOUGH_1500 - mat_s = Material.ACRYLIC_BLACK - role_i = Role.CONNECTION - role_c = Role.CONNECTION - role_s = Role.STRUCTURE - assembly = ( - Cq.Assembly() - .addS(handle.insertion(), name="i0", - material=mat_i, role=role_i) - .constrain("i0", "Fixed") - .addS(segment(), name="s1", - material=mat_s, role=role_s) - .constrain("i0?rim", "s1?mate1", "Plane", param=0) - .addS(handle.insertion(), name="i1", - material=mat_i, role=role_i) - .addS(handle.connector(), name="c1", - material=mat_c, role=role_c) - .addS(handle.insertion(), name="i2", - material=mat_i, role=role_i) - .constrain("s1?mate2", "i1?rim", "Plane", param=0) - .constrain("i1?mate", "c1?mate1", "Plane") - .constrain("i2?mate", "c1?mate2", "Plane") - .addS(segment(), name="s2", - material=mat_s, role=role_s) - .constrain("i2?rim", "s2?mate1", "Plane", param=0) - .addS(handle.insertion(), name="i3", - material=mat_i, role=role_i) - .constrain("s2?mate2", "i3?rim", "Plane", param=0) - .addS(handle.one_side_connector(), name="head", - material=mat_c, role=role_c) - .constrain("i3?mate", "head?mate", "Plane") - .addS(terminal, name="terminal", - material=mat_c, role=role_c) - .constrain("i0?mate", "terminal?mate", "Plane") - ) - return assembly.solve() + @target(name="handle-connector") + def handle_connector(self): + return self.handle.connector() + @target(name="handle-insertion") + def handle_insertion(self): + return self.handle.insertion() + @target(name="proto-handle-terminal-connector", prototype=True) + def proto_handle_connector(self): + return self.handle.one_side_connector(height=15) + + @target(name="handle-terminal-connector") + def handle_terminal_connector(self): + result = self.handle.one_side_connector(height=self.terminal_height) + #result.faces("Z").hole(self.terminal_hole_diam, depth=h) + return result + + @assembly() + def assembly(self): + def segment(): + return self.handle.segment(self.segment_length) + + terminal = ( + self.handle + .one_side_connector(height=self.terminal_height) + .faces(">Z") + .hole(15, self.terminal_height + self.handle.insertion_length - 10) + ) + mat_c = Material.PLASTIC_PLA + mat_i = Material.RESIN_TOUGH_1500 + mat_s = Material.ACRYLIC_BLACK + role_i = Role.CONNECTION + role_c = Role.CONNECTION + role_s = Role.STRUCTURE + a = ( + Cq.Assembly() + .addS(self.handle.insertion(), name="i0", + material=mat_i, role=role_i) + .constrain("i0", "Fixed") + .addS(segment(), name="s1", + material=mat_s, role=role_s) + .constrain("i0?rim", "s1?mate1", "Plane", param=0) + .addS(self.handle.insertion(), name="i1", + material=mat_i, role=role_i) + .addS(self.handle.connector(), name="c1", + material=mat_c, role=role_c) + .addS(self.handle.insertion(), name="i2", + material=mat_i, role=role_i) + .constrain("s1?mate2", "i1?rim", "Plane", param=0) + .constrain("i1?mate", "c1?mate1", "Plane") + .constrain("i2?mate", "c1?mate2", "Plane") + .addS(segment(), name="s2", + material=mat_s, role=role_s) + .constrain("i2?rim", "s2?mate1", "Plane", param=0) + .addS(self.handle.insertion(), name="i3", + material=mat_i, role=role_i) + .constrain("s2?mate2", "i3?rim", "Plane", param=0) + .addS(self.handle.one_side_connector(), name="head", + material=mat_c, role=role_c) + .constrain("i3?mate", "head?mate", "Plane") + .addS(terminal, name="terminal", + material=mat_c, role=role_c) + .constrain("i0?mate", "terminal?mate", "Plane") + ) + return a.solve() diff --git a/nhf/touhou/houjuu_nue/wing.py b/nhf/touhou/houjuu_nue/wing.py index c1e038f..fbb6c72 100644 --- a/nhf/touhou/houjuu_nue/wing.py +++ b/nhf/touhou/houjuu_nue/wing.py @@ -8,7 +8,7 @@ from dataclasses import dataclass, field from typing import Mapping, Tuple, Optional import cadquery as Cq from nhf import Material, Role -from nhf.build import Model, target, assembly +from nhf.build import Model, TargetKind, target, assembly, submodel from nhf.parts.box import box_with_centre_holes, MountingBox, Hole from nhf.parts.joints import HirthJoint from nhf.touhou.houjuu_nue.joints import ShoulderJoint, ElbowJoint, DiskJoint @@ -27,7 +27,9 @@ class WingProfile(Model): panel_thickness: float = 25.4 / 16 spacer_thickness: float = 25.4 / 8 - shoulder_joint: ShoulderJoint = field(default_factory=lambda: ShoulderJoint()) + shoulder_joint: ShoulderJoint = field(default_factory=lambda: ShoulderJoint( + height=100.0, + )) shoulder_width: float = 30.0 shoulder_tip_x: float = -200.0 shoulder_tip_y: float = 160.0 @@ -40,7 +42,7 @@ class WingProfile(Model): ), flip=False, )) - elbow_height: float = 100 + elbow_height: float = 110 elbow_x: float = 240 elbow_y: float = 30 # Tilt of elbow w.r.t. shoulder @@ -104,6 +106,7 @@ class WingProfile(Model): def root_height(self) -> float: return self.shoulder_joint.height + @target(name="profile-s0", kind=TargetKind.DXF) def profile_s0(self) -> Cq.Sketch: tip_x = self.shoulder_tip_x tip_y = self.shoulder_tip_y @@ -127,6 +130,7 @@ class WingProfile(Model): ) return sketch + @submodel(name="spacer-s0-shoulder") def spacer_s0_shoulder(self) -> MountingBox: """ Should be cut @@ -147,6 +151,7 @@ class WingProfile(Model): hole_diam=self.shoulder_joint.parent_conn_hole_diam, centred=(True, True), ) + @submodel(name="spacer-s0-shoulder") def spacer_s0_base(self) -> MountingBox: """ Should be cut @@ -179,6 +184,7 @@ class WingProfile(Model): reverse=not top, ) + @assembly() def assembly_s0(self) -> Cq.Assembly: result = ( Cq.Assembly() @@ -335,6 +341,7 @@ class WingProfile(Model): ] + @target(name="profile-s1", kind=TargetKind.DXF) def profile_s1(self) -> Cq.Sketch: profile = ( self.profile() @@ -365,6 +372,7 @@ class WingProfile(Model): profile = self.profile_s1() tags = tags_shoulder + tags_elbow return nhf.utils.extrude_with_markers(profile, self.panel_thickness, tags, reverse=front) + @submodel(name="spacer-s1-shoulder") def spacer_s1_shoulder(self) -> MountingBox: holes = [ Hole(x) @@ -377,6 +385,7 @@ class WingProfile(Model): holes=holes, hole_diam=self.shoulder_joint.child_conn_hole_diam, ) + @submodel(name="spacer-s1-elbow") def spacer_s1_elbow(self) -> MountingBox: holes = [ Hole(x) @@ -389,6 +398,7 @@ class WingProfile(Model): holes=holes, hole_diam=self.elbow_joint.hole_diam, ) + @assembly() def assembly_s1(self) -> Cq.Assembly: result = ( Cq.Assembly() @@ -412,7 +422,7 @@ class WingProfile(Model): ) return result.solve() - + @target(name="profile-s2", kind=TargetKind.DXF) def profile_s2(self) -> Cq.Sketch: profile = ( self.profile() @@ -450,6 +460,7 @@ class WingProfile(Model): profile = self.profile_s2() tags = tags_elbow + tags_wrist return nhf.utils.extrude_with_markers(profile, thickness, tags, reverse=front) + @submodel(name="spacer-s2-elbow") def spacer_s2_elbow(self) -> MountingBox: holes = [ Hole(x) @@ -462,6 +473,7 @@ class WingProfile(Model): holes=holes, hole_diam=self.elbow_joint.hole_diam, ) + @submodel(name="spacer-s2-wrist") def spacer_s2_wrist(self) -> MountingBox: holes = [ Hole(x) @@ -474,6 +486,7 @@ class WingProfile(Model): holes=holes, hole_diam=self.wrist_joint.hole_diam, ) + @assembly() def assembly_s2(self) -> Cq.Assembly: result = ( Cq.Assembly() @@ -497,6 +510,7 @@ class WingProfile(Model): ) return result.solve() + @target(name="profile-s3", kind=TargetKind.DXF) def profile_s3(self) -> Cq.Sketch: profile = ( self.profile() @@ -519,6 +533,7 @@ class WingProfile(Model): ] profile = self.profile_s3() return nhf.utils.extrude_with_markers(profile, self.panel_thickness, tags, reverse=front) + @submodel(name="spacer-s3-wrist") def spacer_s3_wrist(self) -> MountingBox: holes = [ Hole(x) @@ -531,6 +546,7 @@ class WingProfile(Model): holes=holes, hole_diam=self.wrist_joint.hole_diam ) + @assembly() def assembly_s3(self) -> Cq.Assembly: result = ( Cq.Assembly() @@ -554,6 +570,7 @@ class WingProfile(Model): ) return result.solve() + @assembly() def assembly(self, parts: Optional[list[str]] = None, angle_elbow_wrist: float = 0.0,