Files
cadSet/text-to-cad/skills/cad-router/scripts/route.py
T
2026-07-22 19:38:36 +08:00

284 lines
12 KiB
Python
Executable File

#!/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())