284 lines
12 KiB
Python
Executable File
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())
|