"""Real CAD kernel wrapper around CadQuery (OpenCascade). Each operation mutates a named-part dictionary. After every op the caller should re-export the active scene to STL so the browser viewer can refresh. """ from __future__ import annotations import copy import math import os from typing import Any import cadquery as cq from sketch_engine import SketchEngine from assembly_engine import AssemblyEngine from library import LibraryEngine from materials import MaterialsEngine from profiles import ProfilesEngine from step_io import StepIOEngine from sheet_metal import SheetMetalEngine from knowledge import KnowledgeStore from assemblies_recipes import RecipesEngine from phononic import PhononicEngine from mechanical_lib_v2 import MechLibV2 from vehicle_lib import VehicleEngine class CadEngine: def __init__(self, output_dir: str): self.output_dir = output_dir os.makedirs(output_dir, exist_ok=True) self.parts: dict[str, cq.Workplane] = {} # feature tree: maps part name -> creation parser command string self.features: dict[str, str] = {} self.history: list[dict[str, cq.Workplane]] = [] self.sketches = SketchEngine(self.parts) self.assemblies = AssemblyEngine(self.parts) self.library = LibraryEngine(self) self.materials = MaterialsEngine(self) self.profiles = ProfilesEngine(self) self.step_io = StepIOEngine(self) self.sheet = SheetMetalEngine(self) self.knowledge = KnowledgeStore(os.path.join(output_dir, "..", "knowledge")) self.recipes = RecipesEngine(self) self.phononic = PhononicEngine(self) self.mv2 = MechLibV2(self) self.vehicle = VehicleEngine(self) # ---------- internal ---------- def _snapshot(self) -> None: self.history.append(copy.copy(self.parts)) if len(self.history) > 50: self.history.pop(0) def _require(self, name: str) -> cq.Workplane: if name not in self.parts: existing = list(self.parts) hint = (f" → '{name}' doesn't exist yet. Build it first, e.g. " f"'box {name} 20 20 20', 'cyl {name} 8 30', " f"or 'turbojet {name} 100 400' for a full engine.") if existing: hint += f"\n Existing parts: {existing}" raise KeyError(f"no part named '{name}'.\n{hint}") return self.parts[name] # ---------- primitives ---------- def box(self, name: str, length: float, width: float, height: float, x: float = 0, y: float = 0, z: float = 0) -> str: self._snapshot() p = cq.Workplane("XY").box(length, width, height).translate((x, y, z)) self.parts[name] = p return f"created box '{name}' {length}x{width}x{height} at ({x},{y},{z})" def cylinder(self, name: str, radius: float, height: float, x: float = 0, y: float = 0, z: float = 0) -> str: self._snapshot() p = cq.Workplane("XY").cylinder(height, radius).translate((x, y, z)) self.parts[name] = p return f"created cylinder '{name}' r={radius} h={height} at ({x},{y},{z})" def sphere(self, name: str, radius: float, x: float = 0, y: float = 0, z: float = 0) -> str: self._snapshot() p = cq.Workplane("XY").sphere(radius).translate((x, y, z)) self.parts[name] = p return f"created sphere '{name}' r={radius} at ({x},{y},{z})" def cone(self, name: str, radius1: float, radius2: float, height: float, x: float = 0, y: float = 0, z: float = 0) -> str: self._snapshot() pts = [(0, 0), (radius1, 0), (radius2, height), (0, height)] p = (cq.Workplane("XZ").polyline(pts).close().revolve(360) .translate((x, y, z))) self.parts[name] = p return f"created cone '{name}' r1={radius1} r2={radius2} h={height}" def torus(self, name: str, major_radius: float, minor_radius: float, x: float = 0, y: float = 0, z: float = 0) -> str: self._snapshot() solid = cq.Solid.makeTorus(major_radius, minor_radius) p = cq.Workplane(obj=solid).translate((x, y, z)) self.parts[name] = p return f"created torus '{name}' R={major_radius} r={minor_radius}" def wedge(self, name: str, dx: float, dy: float, dz: float, x: float = 0, y: float = 0, z: float = 0) -> str: self._snapshot() solid = cq.Solid.makeWedge(dx, dy, dz, 0, 0, dx, 0) p = cq.Workplane(obj=solid).translate((x, y, z)) self.parts[name] = p return f"created wedge '{name}' {dx}x{dy}x{dz}" def polygon(self, name: str, sides: int, radius: float, height: float, x: float = 0, y: float = 0, z: float = 0) -> str: """Regular n-gon prism extruded along Z.""" self._snapshot() p = (cq.Workplane("XY").polygon(int(sides), 2 * float(radius)) .extrude(float(height)).translate((x, y, z))) self.parts[name] = p return f"created {sides}-gon prism '{name}' r={radius} h={height}" def text_3d(self, name: str, text: str, size: float = 10, height: float = 2, font: str = "Arial", x: float = 0, y: float = 0, z: float = 0) -> str: self._snapshot() p = (cq.Workplane("XY") .text(text, float(size), float(height), font=font) .translate((x, y, z))) self.parts[name] = p return f"created 3D text '{name}' = {text!r}" def scale(self, name: str, sx: float, sy: float | None = None, sz: float | None = None) -> str: """Uniform scale if only sx is given; per-axis otherwise. Implemented via OpenCascade transform on the underlying shape. """ from OCP.gp import gp_GTrsf, gp_Mat, gp_XYZ from OCP.BRepBuilderAPI import BRepBuilderAPI_GTransform self._snapshot() sy = sx if sy is None else sy sz = sx if sz is None else sz gtrsf = gp_GTrsf() mat = gp_Mat(float(sx), 0, 0, 0, float(sy), 0, 0, 0, float(sz)) gtrsf.SetVectorialPart(mat) gtrsf.SetTranslationPart(gp_XYZ(0, 0, 0)) shape = self._require(name).val().wrapped new_shape = BRepBuilderAPI_GTransform(shape, gtrsf, True).Shape() self.parts[name] = cq.Workplane(obj=cq.Shape.cast(new_shape)) return f"scaled '{name}' by ({sx},{sy},{sz})" # ---------- transforms ---------- def translate(self, name: str, dx: float, dy: float, dz: float) -> str: self._snapshot() self.parts[name] = self._require(name).translate((dx, dy, dz)) return f"translated '{name}' by ({dx},{dy},{dz})" def rotate(self, name: str, axis: str, degrees: float) -> str: self._snapshot() axis = axis.upper() vec = {"X": (1, 0, 0), "Y": (0, 1, 0), "Z": (0, 0, 1)}[axis] self.parts[name] = self._require(name).rotate((0, 0, 0), vec, degrees) return f"rotated '{name}' {degrees}deg about {axis}" # ---------- booleans ---------- def union(self, out: str, a: str, b: str) -> str: self._snapshot() self.parts[out] = self._require(a).union(self._require(b)) return f"union '{out}' = '{a}' u '{b}'" def cut(self, out: str, a: str, b: str) -> str: self._snapshot() self.parts[out] = self._require(a).cut(self._require(b)) return f"cut '{out}' = '{a}' - '{b}'" def intersect(self, out: str, a: str, b: str) -> str: self._snapshot() self.parts[out] = self._require(a).intersect(self._require(b)) return f"intersect '{out}' = '{a}' n '{b}'" # ---------- features ---------- def fillet(self, name: str, radius: float) -> str: self._snapshot() self.parts[name] = self._require(name).edges().fillet(radius) return f"filleted all edges of '{name}' r={radius}" def chamfer(self, name: str, distance: float) -> str: self._snapshot() self.parts[name] = self._require(name).edges().chamfer(distance) return f"chamfered all edges of '{name}' d={distance}" def shell(self, name: str, thickness: float, face: str = "+Z") -> str: self._snapshot() self.parts[name] = self._require(name).faces(face).shell(-thickness) return f"shelled '{name}' t={thickness} (open face {face})" def hole(self, name: str, radius: float, depth: float | None = None) -> str: self._snapshot() p = self._require(name).faces("+Z").workplane().hole(radius * 2, depth) self.parts[name] = p return f"drilled hole r={radius} in top face of '{name}'" # ---------- selective fillet / chamfer ---------- # def fillet_edges(self, name: str, radius: float, selector: str = "all") -> str: """Fillet edges matching a CadQuery selector. Common selectors: 'all', '+Z' (top), '-Z' (bottom), '|Z' (vertical), '>>Z[-1]' (highest edge in Z), '%LINE'. """ self._snapshot() wp = self._require(name) edges = wp.edges() if selector == "all" else wp.edges(selector) if len(edges.vals()) == 0: raise RuntimeError(f"no edges matched selector '{selector}'") self.parts[name] = edges.fillet(float(radius)) return f"filleted {len(edges.vals())} edge(s) of '{name}' r={radius} ({selector})" def chamfer_edges(self, name: str, distance: float, selector: str = "all") -> str: self._snapshot() wp = self._require(name) edges = wp.edges() if selector == "all" else wp.edges(selector) if len(edges.vals()) == 0: raise RuntimeError(f"no edges matched selector '{selector}'") self.parts[name] = edges.chamfer(float(distance)) return f"chamfered {len(edges.vals())} edge(s) of '{name}' d={distance} ({selector})" # ---------- finished holes ---------- # def counterbore(self, name: str, diameter: float, cbore_diameter: float, cbore_depth: float, depth: float | None = None, face: str = "+Z") -> str: """Drill a counterbore on `face`. Through-hole if depth omitted.""" self._snapshot() wp = self._require(name).faces(face).workplane() self.parts[name] = wp.cboreHole(float(diameter), float(cbore_diameter), float(cbore_depth), None if depth is None else float(depth)) return (f"counterbore on '{name}' d={diameter} cbore={cbore_diameter} " f"cdepth={cbore_depth} ({face})") def countersink(self, name: str, diameter: float, csk_diameter: float, csk_angle: float = 82.0, depth: float | None = None, face: str = "+Z") -> str: self._snapshot() wp = self._require(name).faces(face).workplane() self.parts[name] = wp.cskHole(float(diameter), float(csk_diameter), float(csk_angle), None if depth is None else float(depth)) return (f"countersink on '{name}' d={diameter} csk={csk_diameter} " f"angle={csk_angle} ({face})") def tapped_hole(self, name: str, M_spec: str, depth: float, face: str = "+Z") -> str: """Threaded hole sized for an M-spec bolt. Visualisation only: the hole diameter is the tap-drill size; no helical thread geometry is cut (would multiply triangle count ~50x). """ from library import _m d, _ = _m(M_spec) # Tap-drill diameter ~ d * 0.8 (rough approximation; ISO has tables) tap_d = d * 0.8 self._snapshot() p = (self._require(name).faces(face).workplane() .hole(tap_d, float(depth))) self.parts[name] = p return f"tapped hole {M_spec} depth {depth} on '{name}' ({face})" # ---------- sketch-driven features on an existing part ---------- # def boss_extrude(self, base: str, sketch: str, depth: float, face: str = "+Z") -> str: """Extrude a sketch from a face of `base` and union it onto base.""" self._snapshot() sk = self.sketches._s(sketch) wp = self.sketches._build_workplane(sk) boss = wp.extrude(float(depth)) self.parts[base] = self._require(base).union(boss) return f"boss-extruded sketch '{sketch}' by {depth} onto '{base}'" def cut_extrude(self, base: str, sketch: str, depth: float, face: str = "+Z") -> str: """Extrude a sketch and CUT it from `base` (pocket / hole pattern).""" self._snapshot() sk = self.sketches._s(sketch) wp = self.sketches._build_workplane(sk) tool = wp.extrude(float(depth)) self.parts[base] = self._require(base).cut(tool) return f"cut-extruded sketch '{sketch}' by {depth} from '{base}'" # ---------- pattern along a sketch path ---------- # def pattern_along_curve(self, prefix: str, src: str, sketch: str, count: int) -> str: """Place `count` copies of `src` at evenly spaced points along the first chain of lines in `sketch`. Simple polyline approximation; great for bolt-circles defined by a sketch. """ self._snapshot() sk = self.sketches._s(sketch) # collect ordered points from line chain if not sk.lines and not sk.circles: raise RuntimeError(f"sketch '{sketch}' has no path to follow") if sk.circles: # use first circle's center + radius as a bolt-circle (c, r) = list(sk.circles.values())[0] cx, cy, _ = sk.points[c] else: # use centroid of all line endpoints as anchor; fall back to averaging pts = [sk.points[p][:2] for ln in sk.lines.values() for p in ln] xs = [p[0] for p in pts]; ys = [p[1] for p in pts] cx, cy = sum(xs) / len(xs), sum(ys) / len(ys) r = max(((p[0] - cx) ** 2 + (p[1] - cy) ** 2) ** 0.5 for p in pts) base = self._require(src) made = [] import math for i in range(int(count)): a = 2 * math.pi * i / int(count) x = cx + r * math.cos(a) y = cy + r * math.sin(a) nm = f"{prefix}_{i}" self.parts[nm] = base.translate((x, y, 0)) made.append(nm) return f"pattern-along-curve '{prefix}': {made}" # ---------- polish (commercial-product finish) ---------- # def polish(self, name: str, radius: float | None = None) -> str: """Apply small fillets to every edge of a part. If no radius given, auto-selects ~3% of the smallest bounding-box dimension so a hex bolt and a 200 mm panel both get appropriately-sized eased edges. """ self._snapshot() part = self._require(name) try: bb = part.val().BoundingBox() min_dim = min(bb.xlen, bb.ylen, bb.zlen) except Exception: min_dim = 10.0 r = float(radius) if radius is not None else max(0.2, min_dim * 0.03) try: self.parts[name] = part.edges().fillet(r) return f"polished '{name}': fillet r={r:.2f} mm on all edges" except Exception as e: return (f"polish '{name}' partially failed (r={r:.2f}): {e}. " "Try a smaller radius or run on a simpler part.") def polish_all(self, radius: float | None = None) -> str: """Polish every part in the scene. Each gets its own auto-radius based on its own size. Skips parts that fail (logs which). """ polished, skipped = [], [] # iterate over a snapshot of names to avoid mutation during the loop names = list(self.parts.keys()) for n in names: if n.startswith("_"): continue # internal / assembly compounds try: bb = self.parts[n].val().BoundingBox() min_dim = min(bb.xlen, bb.ylen, bb.zlen) r = float(radius) if radius is not None else max(0.2, min_dim * 0.03) self.parts[n] = self.parts[n].edges().fillet(r) polished.append(f"{n}(r={r:.2f})") except Exception: skipped.append(n) msg = f"polished {len(polished)}/{len(names)} parts" if polished: msg += "\n + " + ", ".join(polished[:10]) if len(polished) > 10: msg += f" + {len(polished)-10} more" if skipped: msg += "\n − skipped (complex geometry or unfilletable): " + ", ".join(skipped[:6]) return msg # ---------- patterns / mirror ---------- # def mirror(self, out: str, src: str, plane: str = "XY") -> str: """Mirror a part across XY, XZ, or YZ plane; result stored as 'out'.""" self._snapshot() plane = plane.upper() if plane not in ("XY", "XZ", "YZ"): raise ValueError("plane must be XY, XZ, or YZ") self.parts[out] = self._require(src).mirror(mirrorPlane=plane) return f"mirrored '{src}' across {plane} -> '{out}'" def linear_pattern(self, prefix: str, src: str, dx: float, dy: float, dz: float, count: int) -> str: """Stamp `count` copies of `src` at (i*dx, i*dy, i*dz) for i=0..count-1. Copies are named '_0', '_1', ... """ self._snapshot() base = self._require(src) made = [] for i in range(int(count)): n = f"{prefix}_{i}" self.parts[n] = base.translate((i * dx, i * dy, i * dz)) made.append(n) return f"linear pattern: {made}" def polar_pattern(self, prefix: str, src: str, count: int, total_angle: float = 360.0, axis: str = "Z") -> str: """Stamp `count` copies of `src` rotated around world axis. First copy is at 0 deg; copies are 'prefix_0' .. 'prefix_{N-1}'. """ self._snapshot() axis = axis.upper() vec = {"X": (1, 0, 0), "Y": (0, 1, 0), "Z": (0, 0, 1)}[axis] base = self._require(src) n = int(count) step = float(total_angle) / max(n, 1) if total_angle != 360 else 360.0 / n made = [] for i in range(n): nm = f"{prefix}_{i}" self.parts[nm] = base.rotate((0, 0, 0), vec, i * step) made.append(nm) return f"polar pattern around {axis}: {made}" # ---------- advanced 3D ops ---------- def sweep(self, part: str, profile_sketch: str, path_sketch: str) -> str: """Sweep a 2D profile sketch along a 2D path sketch.""" self._snapshot() prof_sk = self.sketches.sketches.get(profile_sketch) path_sk = self.sketches.sketches.get(path_sketch) if prof_sk is None: raise KeyError(f"no sketch '{profile_sketch}'") if path_sk is None: raise KeyError(f"no sketch '{path_sketch}'") try: prof_wp = self.sketches._build_workplane(prof_sk) path_wp = self.sketches._build_workplane(path_sk) solid = prof_wp.sweep(path_wp) except Exception as e: raise RuntimeError(f"sweep failed: {e}") self.parts[part] = solid return (f"swept profile '{profile_sketch}' along path '{path_sketch}' " f"-> part '{part}'") def loft(self, part: str, sketches: list[str]) -> str: """Loft through >= 2 sketch profiles.""" self._snapshot() if not sketches or len(sketches) < 2: raise ValueError("loft needs at least 2 sketches") try: wp = cq.Workplane("XY") # accumulate each profile's wires into a single workplane stack for sn in sketches: sk = self.sketches.sketches.get(sn) if sk is None: raise KeyError(f"no sketch '{sn}'") pw = self.sketches._build_workplane(sk) for w in pw.vals(): wp = wp.add(w) solid = wp.loft(combine=True) except Exception as e: raise RuntimeError(f"loft failed: {e}") self.parts[part] = solid return f"lofted through {sketches} -> part '{part}'" def helix(self, part: str, radius: float, pitch: float, height: float, x: float = 0, y: float = 0, z: float = 0) -> str: """Helical solid: sweep small circle along helical wire.""" self._snapshot() try: helix_wire = cq.Wire.makeHelix(float(pitch), float(height), float(radius)) path = cq.Workplane(obj=helix_wire) prof_r = float(pitch) / 4.0 profile = cq.Workplane("XY").circle(prof_r) solid = profile.sweep(path, isFrenet=True) solid = solid.translate((x, y, z)) except Exception as e: raise RuntimeError(f"helix failed: {e}") self.parts[part] = solid return (f"created helix '{part}' r={radius} pitch={pitch} h={height} " f"at ({x},{y},{z})") def thread(self, name: str, radius: float, pitch: float, length: float, x: float = 0, y: float = 0, z: float = 0) -> str: """External triangular thread (60-deg) along a helix.""" self._snapshot() try: helix_wire = cq.Wire.makeHelix(float(pitch), float(length), float(radius)) path = cq.Workplane(obj=helix_wire) # 60-deg included angle: half-angle 30, so base/2 = tri_h * tan(30) tri_h = float(pitch) * 0.6 base = 2 * tri_h * math.tan(math.radians(30)) # profile on XZ plane (X=radial outward, Z=axial); auto-attaches to path start pts = [(0, -base / 2), (tri_h, 0), (0, base / 2)] profile = cq.Workplane("XZ").polyline(pts).close() solid = profile.sweep(path, isFrenet=True) solid = solid.translate((x, y, z)) except Exception as e: raise RuntimeError(f"thread failed: {e}") self.parts[name] = solid return (f"created thread '{name}' r={radius} pitch={pitch} L={length} " f"at ({x},{y},{z})") # ---------- bookkeeping ---------- def delete(self, name: str) -> str: self._snapshot() self._require(name) del self.parts[name] if hasattr(self, "features"): self.features.pop(name, None) return f"deleted '{name}'" def duplicate(self, src: str, dst: str | None = None, dx: float = 5, dy: float = 0, dz: float = 0) -> str: """Copy a part to a new name, offset by (dx,dy,dz) to avoid overlap.""" self._snapshot() if dst is None: i = 2 while f"{src}_{i}" in self.parts: i += 1 dst = f"{src}_{i}" self.parts[dst] = self._require(src).translate((float(dx), float(dy), float(dz))) return f"duplicated '{src}' -> '{dst}' offset ({dx},{dy},{dz})" def list_parts(self) -> str: if not self.parts: return "scene is empty" lines = [] for n, p in self.parts.items(): try: bb = p.val().BoundingBox() lines.append(f" {n}: bbox {bb.xlen:.2f} x {bb.ylen:.2f} x {bb.zlen:.2f}") except Exception: lines.append(f" {n}: (no bbox)") return "parts:\n" + "\n".join(lines) def clear(self) -> str: self._snapshot() self.parts.clear() return "scene cleared" def undo(self) -> str: if not self.history: return "nothing to undo" self.parts = self.history.pop() return "undid last operation" # ---------- export ---------- def _combined(self) -> cq.Workplane | None: if not self.parts: return None objs = list(self.parts.values()) out = objs[0] for o in objs[1:]: try: out = out.add(o) except Exception: pass return out def export_stl(self, filename: str = "scene.stl") -> str: path = os.path.join(self.output_dir, filename) combined = self._combined() if combined is None: # write empty placeholder with open(path, "wb") as f: f.write(b"solid empty\nendsolid empty\n") return path cq.exporters.export(combined, path, exportType="STL") return path def export_step(self, filename: str = "scene.step") -> str: path = os.path.join(self.output_dir, filename) combined = self._combined() if combined is None: raise RuntimeError("scene is empty, nothing to export") cq.exporters.export(combined, path, exportType="STEP") return path def export_part_stl(self, name: str) -> str: """Export one part to .stl with fine tessellation (smoother curved surfaces in the viewer). Returns the absolute path. """ self._require(name) safe = "".join(c if c.isalnum() or c in "._-" else "_" for c in name) path = os.path.join(self.output_dir, f"part_{safe}.stl") # tolerance: lower = more triangles, smoother curves. # 0.05 mm linear + 0.1 rad angular is high-res but still fast. cq.exporters.export(self.parts[name], path, exportType="STL", tolerance=0.05, angularTolerance=0.1) return path def manifest(self) -> list[dict]: """Return a manifest of parts with a deterministic colour per name. Used by the viewer to render each part as its own mesh. """ import colorsys items = [] for n in self.parts: # SolidWorks-ish defaults: assemblies = silver, normal parts = hashed hue if n.startswith("_asm_"): col = "#b8bdc4" else: h = (abs(hash(n)) % 360) / 360.0 r, g, b = colorsys.hls_to_rgb(h, 0.58, 0.55) col = "#{:02x}{:02x}{:02x}".format( int(r * 255), int(g * 255), int(b * 255)) try: bb = self.parts[n].val().BoundingBox() bbox = [bb.xmin, bb.ymin, bb.zmin, bb.xmax, bb.ymax, bb.zmax] except Exception: bbox = None items.append({"name": n, "color": col, "bbox": bbox}) return items # ---------- dispatch table used by both Claude tool_use and parser ---------- def dispatch(engine: CadEngine, op: str, args: dict[str, Any]) -> str: """Route ops to the right sub-engine. Names prefixed `sketch_` go to engine.sketches (method name with prefix stripped). Names prefixed `asm_` go to engine.assemblies. Everything else is a 3D-part op on the main CadEngine. """ if op.startswith("sketch_"): sub = op[len("sketch_"):] fn = getattr(engine.sketches, sub, None) if fn is None or sub.startswith("_"): raise ValueError(f"unknown sketch op '{op}'") return fn(**args) if op.startswith("mv2_"): sub = op[len("mv2_"):] fn = getattr(engine.mv2, sub, None) if fn is None or sub.startswith("_"): raise ValueError(f"unknown mv2 op '{op}'") return fn(**args) if op.startswith("veh_"): sub = op[len("veh_"):] fn = getattr(engine.vehicle, sub, None) if fn is None or sub.startswith("_"): raise ValueError(f"unknown vehicle op '{op}'") return fn(**args) if op.startswith("pc_"): sub = op[len("pc_"):] fn = getattr(engine.phononic, sub, None) if fn is None or sub.startswith("_"): raise ValueError(f"unknown phononic op '{op}'") return fn(**args) if op.startswith("rcp_"): sub = op[len("rcp_"):] fn = getattr(engine.recipes, sub, None) if fn is None or sub.startswith("_"): raise ValueError(f"unknown recipe op '{op}'") return fn(**args) if op.startswith("lib_"): sub = op[len("lib_"):] fn = getattr(engine.library, sub, None) if fn is None or sub.startswith("_"): raise ValueError(f"unknown library op '{op}'") return fn(**args) if op.startswith("mat_"): sub = op[len("mat_"):] fn = getattr(engine.materials, sub, None) if fn is None or sub.startswith("_"): raise ValueError(f"unknown materials op '{op}'") return fn(**args) if op.startswith("prof_"): sub = op[len("prof_"):] fn = getattr(engine.profiles, sub, None) if fn is None or sub.startswith("_"): raise ValueError(f"unknown profile op '{op}'") return fn(**args) if op.startswith("sm_"): sub = op[len("sm_"):] fn = getattr(engine.sheet, sub, None) if fn is None or sub.startswith("_"): raise ValueError(f"unknown sheet-metal op '{op}'") return fn(**args) if op.startswith("step_"): sub = op fn = getattr(engine.step_io, sub, None) if fn is None or sub.startswith("_"): raise ValueError(f"unknown step-io op '{op}'") return fn(**args) if op.startswith("asm_"): sub = op[len("asm_"):] # special-case: export needs output_dir if sub == "export_step": return engine.assemblies.export_step(output_dir=engine.output_dir, **args) fn = getattr(engine.assemblies, sub, None) if fn is None or sub.startswith("_"): raise ValueError(f"unknown assembly op '{op}'") return fn(**args) fn = getattr(engine, op, None) if fn is None or op.startswith("_"): raise ValueError(f"unknown op '{op}'") return fn(**args)