#!/usr/bin/env python3 from __future__ import annotations import argparse import importlib.util import json import os import sys from dataclasses import dataclass, field from pathlib import Path SKILL_ROOT = Path(__file__).resolve().parents[1] REGISTRY_PATH = Path(__file__).with_name("capabilities.json") CADAM_RUNTIME_FILES = ( Path("src/vendor/openscad-wasm/openscad.js"), Path("src/vendor/openscad-wasm/openscad.wasm"), ) @dataclass class BackendScore: name: str score: int reasons: list[str] = field(default_factory=list) def add(self, points: int, reason: str) -> None: self.score += points self.reasons.append(f"{points:+d} {reason}") KEYWORD_GROUPS: dict[str, tuple[str, ...]] = { "special_mechanical": ( "gear", "gears", "齿轮", "齿条", "rack", "ring gear", "内齿圈", "cycloidal", "摆线", "bearing", "轴承", "planetary", "行星", "reducer", "gearbox", "减速器", "减速箱", "joint actuator", "关节执行器", ), "general_mechanical": ( "bracket", "支架", "connecting rod", "连杆", "crank", "曲柄", "lever", "摇臂", "shaft", "轴", "bushing", "轴套", "flange", "法兰", "housing", "壳体", "enclosure", "外壳", "fixture", "夹具", "plate", "安装板", ), "printable": ( "3d print", "3d-print", "打印", "printable", "vase", "花瓶", "knob", "旋钮", "container", "容器", "adapter", "适配器", "honeycomb", "蜂窝", "lattice", "晶格", "pattern", "阵列", "decorative", "装饰", "openscad", "scad", "bosl", ), "replay": ( "replay", "重放", "operation graph", "model json", "模型图", "semantic tag", "语义标签", "freecad", "fcstd", ), "visual_complexity": ( "image", "photo", "sketch", "图片", "照片", "草图", "复杂", "complex", "像", "参考图", "reference", ), } FOCUSED_SKILLS: tuple[tuple[str, tuple[str, ...]], ...] = ( ("dxf", ("dxf", "laser cut", "激光切割", "waterjet", "水刀", "flat pattern", "展开")), ("urdf", ("urdf", "robot description", "机器人描述")), ("srdf", ("srdf", "moveit", "planning group", "规划组")), ("sdf", ("sdformat", "gazebo", "simulation world", "仿真世界", ".sdf")), ("gcode", ("g-code", "gcode", "slice", "切片")), ("sendcutsend", ("sendcutsend",)), ("bambu-labs", ("bambu", "拓竹")), ) def contains_any(text: str, terms: tuple[str, ...]) -> bool: return any(term in text for term in terms) def normalize_formats(formats: list[str]) -> list[str]: result: list[str] = [] for value in formats: normalized = value.lower().lstrip(".") if normalized not in result: result.append(normalized) return result def cadam_root() -> Path: configured = os.environ.get("CADAM_ROOT", "").strip() if configured: return Path(configured).expanduser().resolve() return (SKILL_ROOT.parents[2] / "CADAM").resolve() def cadam_runtime_available(root: Path | None = None) -> bool: candidate = root or cadam_root() return all((candidate / relative_path).is_file() for relative_path in CADAM_RUNTIME_FILES) def probe_backends() -> dict[str, dict[str, object]]: return { "build123d": { "available": importlib.util.find_spec("build123d") is not None, "probe": f"python={sys.executable}", }, "simplecadapi": { "available": importlib.util.find_spec("simplecadapi") is not None, "probe": f"python={sys.executable}", }, "cadam": { "available": cadam_runtime_available(), "probe": str(cadam_root()), }, } def route(args: argparse.Namespace) -> dict[str, object]: request = args.request.strip() text = request.lower() outputs = normalize_formats(args.output) scores = { "build123d": BackendScore("build123d", 35, ["+35 general STEP-first default"]), "simplecadapi": BackendScore("simplecadapi", 20, ["+20 specialist mechanical baseline"]), "cadam": BackendScore("cadam", 10, ["+10 CADAM printable CSG baseline"]), } if contains_any(text, KEYWORD_GROUPS["special_mechanical"]): scores["simplecadapi"].add(65, "supported standard mechanical family") scores["build123d"].add(8, "can model the family generically") if contains_any(text, KEYWORD_GROUPS["general_mechanical"]): scores["build123d"].add(35, "general mechanical part with direct parametric features") scores["simplecadapi"].add(10, "general OCP modeling is available") if contains_any(text, KEYWORD_GROUPS["printable"]): scores["cadam"].add(55, "printable, pattern-heavy, or SCAD-native request") if contains_any(text, KEYWORD_GROUPS["replay"]): scores["simplecadapi"].add(60, "replay, semantic graph, or FreeCAD requirement") if args.existing_model: scores["build123d"].add(55, "existing model inspection or source modification") scores["cadam"].add(-50, "poor fit for imported editable STEP") if args.assembly: scores["build123d"].add(15, "source-level assembly workflow") scores["simplecadapi"].add(18, "mechanism and constraint workflow") scores["cadam"].add(-15, "weak fit for engineering assembly relationships") if args.browser_controls: scores["cadam"].add(45, "CADAM-style parameter controls requested") if args.manufacturing == "machining": scores["build123d"].add(25, "machining favors STEP-first B-Rep") scores["simplecadapi"].add(18, "OCP STEP geometry suits machining") scores["cadam"].add(-25, "mesh/CSG route is not preferred for machining") elif args.manufacturing == "printing": scores["cadam"].add(30, "printing favors fast parametric CSG") scores["build123d"].add(8, "can export validated printable meshes") elif args.manufacturing == "laser-cutting": scores["build123d"].add(12, "can own projected source geometry") if any(fmt in {"step", "stp"} for fmt in outputs): scores["build123d"].add(35, "editable STEP requested") scores["simplecadapi"].add(30, "OCP STEP export requested") scores["cadam"].add(-70, "no native editable STEP output") if "fcstd" in outputs: scores["simplecadapi"].add(70, "FreeCAD output requested") scores["cadam"].add(-40, "FreeCAD output is unsupported") if "scad" in outputs: scores["cadam"].add(80, "SCAD source requested") if any(fmt in {"stl", "3mf"} for fmt in outputs): scores["cadam"].add(12, "print mesh requested") scores["build123d"].add(5, "secondary mesh export is available") forced_backend = args.backend if args.backend != "auto" else None ranked = sorted(scores.values(), key=lambda item: (-item.score, item.name)) if forced_backend: selected = scores[forced_backend] selected.reasons.append("explicit backend override") fallback = [item.name for item in ranked if item.name != forced_backend] else: selected = ranked[0] fallback = [item.name for item in ranked[1:]] profiles: list[str] = ["caddesigner_ecip"] if contains_any(text, KEYWORD_GROUPS["visual_complexity"]): profiles.extend(["requirement_refinement", "visual_repair"]) if not any(char.isdigit() for char in request) and contains_any( text, KEYWORD_GROUPS["general_mechanical"] + KEYWORD_GROUPS["special_mechanical"], ): profiles.append("requirement_refinement") profiles = list(dict.fromkeys(profiles)) downstream = [name for name, terms in FOCUSED_SKILLS if contains_any(text, terms)] if args.manufacturing == "laser-cutting" and "dxf" not in downstream: downstream.append("dxf") if any(fmt in {"stl", "3mf", "gcode"} for fmt in outputs) and "cad-viewer" not in downstream: downstream.append("cad-viewer") elif selected.name in {"build123d", "simplecadapi"}: downstream.append("cad-viewer") registry = json.loads(REGISTRY_PATH.read_text(encoding="utf-8")) backend_info = registry["backends"][selected.name] gap = selected.score - max((scores[name].score for name in scores if name != selected.name), default=0) confidence = "high" if gap >= 40 else "medium" if gap >= 15 else "low" result: dict[str, object] = { "schema_version": "1.0", "request": request, "selected_backend": selected.name, "runner_skill": backend_info["runner_skill"], "project": backend_info["project"], "adapter": backend_info.get("adapter"), "confidence": confidence, "backend_scores": [ {"backend": item.name, "score": item.score, "reasons": item.reasons} for item in sorted(scores.values(), key=lambda item: (-item.score, item.name)) ], "fallback_order": fallback, "workflow_profiles": profiles, "downstream_skills": downstream, "requested_outputs": outputs, "source_of_truth": backend_info["source_of_truth"], "project_contributions": { "text-to-cad": "core Skill host, build123d CAD backend, validation, artifacts, and CAD Viewer handoff", "SimpleCADAPI": "specialized mechanical backend with replayable graphs and semantic modeling", "CADAM": "OpenSCAD-WASM execution for printable and pattern-heavy SCAD models", "CADDesigner-Code": "ECIP brief expansion, execution feedback, and bounded visual repair workflow", }, "availability": probe_backends() if args.probe else None, } return result def explain(result: dict[str, object]) -> str: lines = [ f"Selected backend: {result['selected_backend']}", f"Runner skill: ${result['runner_skill']}", f"Project: {result['project']}", f"Confidence: {result['confidence']}", f"Source of truth: {result['source_of_truth']}", ] profiles = result["workflow_profiles"] if profiles: lines.append(f"Workflow profiles: {', '.join(profiles)}") downstream = result["downstream_skills"] if downstream: lines.append(f"Downstream skills: {', '.join('$' + name for name in downstream)}") lines.append("Ranking:") for item in result["backend_scores"]: lines.append(f" {item['backend']}: {item['score']}") for reason in item["reasons"]: lines.append(f" - {reason}") return "\n".join(lines) def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Route a CAD request to a V1 modeling backend.") parser.add_argument("request", help="Natural-language CAD request.") parser.add_argument( "--backend", choices=("auto", "build123d", "simplecadapi", "cadam"), default="auto", help="Override automatic routing.", ) parser.add_argument("--output", action="append", default=[], help="Required output format; repeatable.") parser.add_argument( "--manufacturing", choices=("unspecified", "machining", "printing", "laser-cutting", "concept"), default="unspecified", ) parser.add_argument("--existing-model", action="store_true", help="The task modifies or inspects an existing model.") parser.add_argument("--assembly", action="store_true", help="The requested result is an assembly.") parser.add_argument("--browser-controls", action="store_true", help="Interactive browser parameter controls are required.") parser.add_argument("--probe", action="store_true", help="Probe current-interpreter and OpenSCAD availability.") parser.add_argument("--explain", action="store_true", help="Print a human-readable decision instead of JSON.") parser.add_argument("--manifest", type=Path, help="Also write the route decision as JSON to this explicit path.") return parser def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) result = route(args) payload = json.dumps(result, ensure_ascii=False, indent=2) + "\n" if args.manifest: args.manifest.parent.mkdir(parents=True, exist_ok=True) args.manifest.write_text(payload, encoding="utf-8") print(explain(result) if args.explain else payload, end="\n" if args.explain else "") return 0 if __name__ == "__main__": raise SystemExit(main())