chatcad / cad_engine.py
Samarjithbiswas's picture
chat_cad live (Docker app + weights)
32c7c43 verified
Raw
History Blame Contribute Delete
30.1 kB
"""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 '<prefix>_0', '<prefix>_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 <name>.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)