refactor: add DesignIR distillation pipeline

This commit is contained in:
Jerry
2026-07-27 17:24:04 +08:00
parent 0adaac2b2a
commit 550b339bcc
1128 changed files with 10248 additions and 426666 deletions
@@ -1,107 +0,0 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
function usage() {
return "Usage: node cadam_compile.mjs <input.scad> <output.stl|output.dxf> [--cadam-root <path>]";
}
function parseArgs(argv) {
const positional = [];
let cadamRoot = String(process.env.CADAM_ROOT || "").trim();
for (let index = 0; index < argv.length; index += 1) {
const value = argv[index];
if (value === "--cadam-root") {
cadamRoot = String(argv[index + 1] || "").trim();
index += 1;
} else {
positional.push(value);
}
}
if (positional.length !== 2) {
throw new Error(usage());
}
return { inputPath: path.resolve(positional[0]), outputPath: path.resolve(positional[1]), cadamRoot };
}
function defaultCadamRoot() {
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
return path.resolve(scriptDirectory, "../../../../CADAM");
}
function runtimePaths(configuredRoot) {
const root = path.resolve(configuredRoot || defaultCadamRoot());
const runtimeDirectory = path.join(root, "src", "vendor", "openscad-wasm");
const modulePath = path.join(runtimeDirectory, "openscad.js");
const wasmPath = path.join(runtimeDirectory, "openscad.wasm");
if (!fs.existsSync(modulePath) || !fs.existsSync(wasmPath)) {
throw new Error(
`CADAM OpenSCAD WASM runtime not found under ${runtimeDirectory}. ` +
"Set CADAM_ROOT or pass --cadam-root to an installed CADAM checkout."
);
}
return { root, modulePath, wasmPath };
}
async function compile({ inputPath, outputPath, cadamRoot }) {
if (path.extname(inputPath).toLowerCase() !== ".scad") {
throw new Error("CADAM input must be an OpenSCAD .scad source file");
}
const extension = path.extname(outputPath).toLowerCase();
if (!new Set([".stl", ".dxf"]).has(extension)) {
throw new Error("CADAM output must be .stl or .dxf");
}
if (!fs.existsSync(inputPath) || !fs.statSync(inputPath).isFile()) {
throw new Error(`CADAM input file not found: ${inputPath}`);
}
const runtime = runtimePaths(cadamRoot);
const logs = { stdout: [], stderr: [] };
const runtimeModule = await import(pathToFileURL(runtime.modulePath).href);
const instance = await runtimeModule.default({
noInitialRun: true,
wasmBinary: fs.readFileSync(runtime.wasmPath),
print: (line) => logs.stdout.push(String(line)),
printErr: (line) => logs.stderr.push(String(line))
});
const virtualInput = "/input.scad";
const virtualOutput = `/out${extension}`;
instance.FS.writeFile(virtualInput, fs.readFileSync(inputPath, "utf8"));
const format = extension === ".stl" ? "binstl" : "dxf";
const exitCode = instance.callMain([
virtualInput,
"-o",
virtualOutput,
`--export-format=${format}`,
"--backend=manifold",
"--enable=lazy-union"
]);
if (exitCode !== 0) {
throw new Error(`CADAM OpenSCAD compilation failed with exit code ${exitCode}: ${logs.stderr.join("\n")}`);
}
const output = instance.FS.readFile(virtualOutput, { encoding: "binary" });
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, output);
return {
backend: "cadam",
runtime: "CADAM OpenSCAD WASM",
cadam_root: runtime.root,
input: inputPath,
output: outputPath,
bytes: output.length,
exit_code: exitCode,
stderr_tail: logs.stderr.slice(-8)
};
}
try {
const result = await compile(parseArgs(process.argv.slice(2)));
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
}
@@ -6,7 +6,6 @@
"runner_skill": "cad",
"source_formats": ["py"],
"primary_formats": ["step", "stp"],
"secondary_formats": ["stl", "3mf", "glb"],
"strengths": [
"general mechanical parts",
"machined features",
@@ -20,31 +19,14 @@
"project": "SimpleCADAPI",
"runner_skill": "simplecadapi",
"source_formats": ["py", "json"],
"primary_formats": ["step", "stp", "fcstd"],
"secondary_formats": ["stl", "json"],
"primary_formats": ["step", "stp"],
"strengths": [
"gears, ring gears, racks, and cycloidal discs",
"bearings, reducers, and joint actuators",
"replayable operation graphs",
"semantic tags and topology queries",
"FreeCAD interchange"
"semantic tags and topology queries"
],
"source_of_truth": "SimpleCADAPI Python generator and model JSON"
},
"cadam": {
"project": "CADAM",
"runner_skill": "cad-router",
"adapter": "scripts/cadam_compile.mjs",
"source_formats": ["scad"],
"primary_formats": ["scad", "stl"],
"secondary_formats": ["dxf"],
"strengths": [
"fast printable CSG models",
"patterns, lattices, and decorative geometry",
"printable threads and BOSL/BOSL2 libraries",
"browser-friendly parameter controls"
],
"source_of_truth": "CADAM-compatible OpenSCAD source"
}
}
}
@@ -1,632 +0,0 @@
#!/usr/bin/env python3
"""Create and execute per-part JSON model specifications.
Two reconstruction modes are supported:
* ``native_generator`` loads a task-local Python generator. The generator reads
the same model-spec.json, so named parameter edits remain the source of truth.
* ``exact_step_base`` imports an immutable task-local STEP file and applies a
parametric modification layer. With no modifications, the imported B-Rep is
preserved geometrically even though the exported STEP text may differ.
"""
from __future__ import annotations
import argparse
import hashlib
import importlib.util
import json
import math
import re
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any
from build123d import (
Align,
Box,
Cylinder,
Part,
Pos,
export_step,
export_stl,
import_step,
)
SCHEMA_VERSION = "1.0"
MODEL_SPEC_KIND = "parametric_cad_model"
MODEL_ID_RE = re.compile(r"[^a-zA-Z0-9._-]+")
SUPPORTED_OPERATIONS = {
"add_box",
"cut_box",
"add_cylinder",
"cut_cylinder",
}
class ModelSpecError(ValueError):
"""Raised when a model specification is invalid or unsafe to execute."""
def _read_json(path: Path) -> dict[str, Any]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ModelSpecError(f"Cannot read model spec {path}: {exc}") from exc
if not isinstance(value, dict):
raise ModelSpecError("Model spec must be a JSON object")
return value
def _write_json(path: Path, value: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(
json.dumps(value, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
temporary.replace(path)
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _safe_model_id(value: str) -> str:
result = MODEL_ID_RE.sub("_", value.strip()).strip("._-")
if not result:
raise ModelSpecError("model_id must contain a letter or number")
return result
def _resolve_relative(spec_path: Path, value: str, field: str) -> Path:
candidate = Path(value)
if candidate.is_absolute():
raise ModelSpecError(f"{field} must be task-relative, not absolute")
return (spec_path.parent / candidate).resolve()
def _parameter_value(spec: dict[str, Any], name: str) -> float:
parameters = spec.get("parameters", {})
entry = parameters.get(name) if isinstance(parameters, dict) else None
if not isinstance(entry, dict) or "value" not in entry:
raise ModelSpecError(f"Unknown parameter reference: {name}")
value = entry["value"]
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ModelSpecError(f"Parameter {name} must contain a numeric value")
if not math.isfinite(float(value)):
raise ModelSpecError(f"Parameter {name} must be finite")
return float(value)
def resolve_number(spec: dict[str, Any], value: Any, field: str) -> float:
if isinstance(value, bool):
raise ModelSpecError(f"{field} must be numeric")
if isinstance(value, (int, float)):
result = float(value)
elif isinstance(value, dict) and "parameter" in value:
unexpected = set(value) - {"parameter", "scale", "offset"}
if unexpected:
raise ModelSpecError(
f"{field} parameter expression has unsupported keys: "
+ ", ".join(sorted(unexpected))
)
scale = value.get("scale", 1.0)
offset = value.get("offset", 0.0)
if (
isinstance(scale, bool)
or not isinstance(scale, (int, float))
or isinstance(offset, bool)
or not isinstance(offset, (int, float))
):
raise ModelSpecError(f"{field} scale and offset must be numeric")
result = (
_parameter_value(spec, str(value["parameter"])) * float(scale)
+ float(offset)
)
else:
raise ModelSpecError(
f"{field} must be a number or a parameter expression"
)
if not math.isfinite(result):
raise ModelSpecError(f"{field} must be finite")
return result
def _vector3(spec: dict[str, Any], value: Any, field: str) -> tuple[float, float, float]:
if not isinstance(value, list) or len(value) != 3:
raise ModelSpecError(f"{field} must contain three values")
return tuple(
resolve_number(spec, item, f"{field}[{index}]")
for index, item in enumerate(value)
)
def validate_model_spec(spec: dict[str, Any]) -> None:
if spec.get("schema_version") != SCHEMA_VERSION:
raise ModelSpecError(f"Unsupported model spec schema: {spec.get('schema_version')}")
if spec.get("model_spec_kind") != MODEL_SPEC_KIND:
raise ModelSpecError("Not a parametric CAD model specification")
_safe_model_id(str(spec.get("model_id", "")))
if spec.get("units") != "mm":
raise ModelSpecError("V1 model specs use millimetres")
reconstruction = spec.get("reconstruction")
if not isinstance(reconstruction, dict):
raise ModelSpecError("reconstruction must be an object")
mode = reconstruction.get("mode")
if mode not in {"native_generator", "exact_step_base"}:
raise ModelSpecError(f"Unsupported reconstruction mode: {mode}")
if mode == "native_generator":
generator = reconstruction.get("generator")
if not isinstance(generator, dict) or not generator.get("path"):
raise ModelSpecError("native_generator requires generator.path")
else:
source = reconstruction.get("source")
if (
not isinstance(source, dict)
or not source.get("path")
or not source.get("sha256")
):
raise ModelSpecError("exact_step_base requires source.path and source.sha256")
parameters = spec.get("parameters", {})
if not isinstance(parameters, dict):
raise ModelSpecError("parameters must be an object")
for name, entry in parameters.items():
if not isinstance(entry, dict) or "value" not in entry:
raise ModelSpecError(f"Parameter {name} must be an object with value")
_parameter_value(spec, name)
modifications = spec.get("modifications", [])
if not isinstance(modifications, list):
raise ModelSpecError("modifications must be an array")
seen_ids: set[str] = set()
for index, modification in enumerate(modifications):
if not isinstance(modification, dict):
raise ModelSpecError(f"modifications[{index}] must be an object")
feature_id = str(modification.get("id", "")).strip()
if not feature_id or feature_id in seen_ids:
raise ModelSpecError("Every modification requires a unique id")
seen_ids.add(feature_id)
operation = modification.get("operation")
if operation not in SUPPORTED_OPERATIONS:
raise ModelSpecError(f"Unsupported modification operation: {operation}")
outputs = spec.get("outputs")
if not isinstance(outputs, dict) or not outputs.get("step"):
raise ModelSpecError("outputs.step is required")
def geometry_facts(shape: Any) -> dict[str, Any]:
box = shape.bounding_box()
solids = list(shape.solids())
volume = sum(float(solid.volume) for solid in solids)
return {
"size_mm": [
round(float(box.size.X), 9),
round(float(box.size.Y), 9),
round(float(box.size.Z), 9),
],
"center_mm": [
round(float(box.center().X), 9),
round(float(box.center().Y), 9),
round(float(box.center().Z), 9),
],
"solid_count": len(solids),
"face_count": len(shape.faces()),
"edge_count": len(shape.edges()),
"volume_mm3": round(volume, 6),
}
def _rotation_for_axis(axis: str) -> tuple[float, float, float]:
normalized = axis.lower()
if normalized == "z":
return (0.0, 0.0, 0.0)
if normalized == "x":
return (0.0, 90.0, 0.0)
if normalized == "y":
return (-90.0, 0.0, 0.0)
raise ModelSpecError(f"Unsupported cylinder axis: {axis}")
def _make_tool(spec: dict[str, Any], modification: dict[str, Any]) -> Any:
operation = str(modification["operation"])
center = _vector3(spec, modification.get("center", [0, 0, 0]), "center")
if operation.endswith("_cylinder"):
diameter = resolve_number(spec, modification.get("diameter"), "diameter")
length = resolve_number(spec, modification.get("length"), "length")
if diameter <= 0 or length <= 0:
raise ModelSpecError("Cylinder diameter and length must be positive")
tool = Cylinder(
diameter / 2.0,
length,
align=(Align.CENTER, Align.CENTER, Align.CENTER),
rotation=_rotation_for_axis(str(modification.get("axis", "z"))),
)
else:
size = _vector3(spec, modification.get("size"), "size")
if any(item <= 0 for item in size):
raise ModelSpecError("Box dimensions must be positive")
tool = Box(
*size,
align=(Align.CENTER, Align.CENTER, Align.CENTER),
)
return Pos(*center) * tool
def _apply_modifications(shape: Any, spec: dict[str, Any]) -> Any:
result = shape
for modification in spec.get("modifications", []):
if modification.get("enabled", True) is False:
continue
tool = _make_tool(spec, modification)
operation = modification["operation"]
result = result + tool if operation.startswith("add_") else result - tool
return result
def _load_generator(spec_path: Path, reconstruction: dict[str, Any]) -> Any:
generator = reconstruction["generator"]
source_path = _resolve_relative(spec_path, str(generator["path"]), "generator.path")
if not source_path.is_file():
raise ModelSpecError(f"Generator does not exist: {source_path}")
module_spec = importlib.util.spec_from_file_location(
f"cad_model_{hash(source_path)}",
source_path,
)
if module_spec is None or module_spec.loader is None:
raise ModelSpecError(f"Cannot load generator: {source_path}")
module = importlib.util.module_from_spec(module_spec)
module_spec.loader.exec_module(module)
entrypoint = str(generator.get("entrypoint", "gen_step"))
function = getattr(module, entrypoint, None)
if not callable(function):
raise ModelSpecError(f"Generator has no callable {entrypoint}: {source_path}")
return function()
def build_shape(spec_path: Path, spec: dict[str, Any] | None = None) -> Any:
resolved_spec = spec_path.expanduser().resolve()
payload = spec or _read_json(resolved_spec)
validate_model_spec(payload)
reconstruction = payload["reconstruction"]
if reconstruction["mode"] == "native_generator":
shape = _load_generator(resolved_spec, reconstruction)
else:
source = reconstruction["source"]
source_path = _resolve_relative(resolved_spec, str(source["path"]), "source.path")
if not source_path.is_file():
raise ModelSpecError(f"Exact STEP base does not exist: {source_path}")
actual_hash = _sha256(source_path)
if actual_hash != source["sha256"]:
raise ModelSpecError(
"Exact STEP base checksum changed; import it again instead of "
"silently rebuilding from a different source"
)
imported = import_step(source_path)
# build123d's STEP importer returns a topology wrapper that is readable
# and boolean-capable but is not always directly accepted by its STEP
# exporter. Normalize it to a Part while preserving the wrapped B-Rep.
shape = Part(imported.wrapped)
return _apply_modifications(shape, payload)
def build_model(spec_path: Path) -> dict[str, Any]:
resolved_spec = spec_path.expanduser().resolve()
payload = _read_json(resolved_spec)
validate_model_spec(payload)
outputs = payload["outputs"]
step_path = _resolve_relative(resolved_spec, str(outputs["step"]), "outputs.step")
step_path.parent.mkdir(parents=True, exist_ok=True)
reconstruction = payload["reconstruction"]
stl_value = outputs.get("stl")
stl_path = (
_resolve_relative(resolved_spec, str(stl_value), "outputs.stl")
if stl_value
else None
)
if reconstruction["mode"] == "native_generator":
generator_path = _resolve_relative(
resolved_spec,
str(reconstruction["generator"]["path"]),
"generator.path",
)
if generator_path.with_suffix(".step").resolve() != step_path:
raise ModelSpecError(
"V1 native generator output must use the generator basename"
)
cad_step = Path(__file__).resolve().parents[2] / "cad" / "scripts" / "step"
command = [sys.executable, str(cad_step), str(generator_path), "--force"]
if stl_path is not None:
if stl_path.parent != generator_path.parent:
raise ModelSpecError(
"V1 native generator STL must remain in the task directory"
)
command.extend(["--stl", stl_path.name])
completed = subprocess.run(
command,
cwd=resolved_spec.parent,
text=True,
capture_output=True,
)
if completed.returncode != 0:
raise ModelSpecError(
"CAD generator failed: "
+ (completed.stderr.strip() or completed.stdout.strip())
)
if not step_path.is_file():
raise ModelSpecError(f"CAD generator did not write {step_path}")
else:
shape = build_shape(resolved_spec, payload)
source_path = _resolve_relative(
resolved_spec,
str(reconstruction["source"]["path"]),
"source.path",
)
if not payload.get("modifications"):
# Empty exact-base rebuilds preserve the STEP byte-for-byte.
if source_path != step_path:
shutil.copy2(source_path, step_path)
else:
export_step(shape, step_path)
if stl_path is not None:
stl_path.parent.mkdir(parents=True, exist_ok=True)
export_stl(shape, stl_path)
exported_shape = Part(import_step(step_path).wrapped)
result: dict[str, Any] = {
"model_spec": str(resolved_spec),
"step": str(step_path),
"facts": geometry_facts(exported_shape),
}
if stl_path is not None:
result["stl"] = str(stl_path)
return result
def import_step_model(
source_step: Path,
task_dir: Path,
model_id: str | None = None,
) -> dict[str, Any]:
source = source_step.expanduser().resolve()
if not source.is_file() or source.suffix.lower() not in {".step", ".stp"}:
raise ModelSpecError(f"Input must be an existing STEP/STP file: {source}")
target_dir = task_dir.expanduser().resolve()
target_dir.mkdir(parents=True, exist_ok=True)
normalized_id = _safe_model_id(model_id or source.stem)
copied_source = target_dir / "source.step"
if copied_source.resolve() != source:
shutil.copy2(source, copied_source)
source_shape = import_step(copied_source)
source_facts = geometry_facts(source_shape)
spec_path = target_dir / "model-spec.json"
payload: dict[str, Any] = {
"schema_version": SCHEMA_VERSION,
"model_spec_kind": MODEL_SPEC_KIND,
"model_id": normalized_id,
"units": "mm",
"reconstruction": {
"mode": "exact_step_base",
"source": {
"path": "source.step",
"sha256": _sha256(copied_source),
"geometry_signature": source_facts,
},
"history_recovery": {
"status": "not_present_in_step",
"contract": (
"Preserve the imported B-Rep exactly as the immutable base; "
"represent later edits as named parametric modifications."
),
},
},
"parameters": {},
"modifications": [],
"outputs": {
"step": f"{normalized_id}.step",
"stl": f"{normalized_id}.stl",
},
"validation": {
"exact_base_required": True,
"geometry_signature_tolerance_mm": 1e-7,
},
}
_write_json(spec_path, payload)
result = build_model(spec_path)
result["source"] = str(copied_source)
result["source_sha256"] = payload["reconstruction"]["source"]["sha256"]
return result
def _numbers_close(first: Any, second: Any, tolerance: float) -> bool:
if isinstance(first, list) and isinstance(second, list):
return len(first) == len(second) and all(
_numbers_close(left, right, tolerance)
for left, right in zip(first, second)
)
if isinstance(first, (int, float)) and isinstance(second, (int, float)):
return math.isclose(
float(first),
float(second),
rel_tol=tolerance,
abs_tol=tolerance,
)
return first == second
def verify_model(spec_path: Path) -> dict[str, Any]:
resolved_spec = spec_path.expanduser().resolve()
payload = _read_json(resolved_spec)
shape = build_shape(resolved_spec, payload)
facts = geometry_facts(shape)
reconstruction = payload["reconstruction"]
result: dict[str, Any] = {
"model_spec": str(resolved_spec),
"valid": True,
"facts": facts,
"checks": [],
}
if reconstruction["mode"] == "exact_step_base" and not payload.get("modifications"):
expected = reconstruction["source"].get("geometry_signature", {})
source_path = _resolve_relative(
resolved_spec,
str(reconstruction["source"]["path"]),
"source.path",
)
output_path = _resolve_relative(
resolved_spec,
str(payload["outputs"]["step"]),
"outputs.step",
)
output_shape = (
Part(import_step(output_path).wrapped)
if output_path.is_file()
else None
)
output_facts = geometry_facts(output_shape) if output_shape is not None else {}
tolerance = float(
payload.get("validation", {}).get(
"geometry_signature_tolerance_mm",
1e-7,
)
)
signature_exact = all(
_numbers_close(expected.get(key), facts.get(key), tolerance)
for key in (
"size_mm",
"center_mm",
"solid_count",
"face_count",
"edge_count",
"volume_mm3",
)
)
exported_exact = (
output_path.is_file()
and _sha256(output_path) == _sha256(source_path)
and all(
_numbers_close(expected.get(key), output_facts.get(key), tolerance)
for key in (
"size_mm",
"center_mm",
"solid_count",
"face_count",
"edge_count",
"volume_mm3",
)
)
)
exact = signature_exact and exported_exact
result["checks"].append(
{
"check": "exact_base_geometry_signature",
"passed": exact,
"expected": expected,
"actual": facts,
"exported": output_facts,
"byte_identical_step": (
output_path.is_file()
and _sha256(output_path) == _sha256(source_path)
),
}
)
result["valid"] = exact
else:
result["checks"].append(
{
"check": "model_spec_execution",
"passed": True,
"detail": "The parameterized model spec executed successfully.",
}
)
return result
def set_parameter(spec_path: Path, name: str, value: float) -> dict[str, Any]:
resolved_spec = spec_path.expanduser().resolve()
payload = _read_json(resolved_spec)
validate_model_spec(payload)
parameters = payload["parameters"]
if name not in parameters:
raise ModelSpecError(
f"Unknown parameter {name}; add a named parameter and feature binding first"
)
parameters[name]["value"] = value
validate_model_spec(payload)
_write_json(resolved_spec, payload)
return {
"model_spec": str(resolved_spec),
"parameter": name,
"value": value,
}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Create, build, modify, and verify per-part model-spec.json files."
)
subparsers = parser.add_subparsers(dest="command", required=True)
import_parser = subparsers.add_parser(
"import-step",
help="Create an exact STEP-base model spec in a task-owned directory.",
)
import_parser.add_argument("source_step", type=Path)
import_parser.add_argument("--task-dir", type=Path, required=True)
import_parser.add_argument("--model-id")
build_command = subparsers.add_parser(
"build",
help="Regenerate STEP/STL from a model specification.",
)
build_command.add_argument("model_spec", type=Path)
verify_command = subparsers.add_parser(
"verify",
help="Validate a model specification and exact-base signature.",
)
verify_command.add_argument("model_spec", type=Path)
set_command = subparsers.add_parser(
"set",
help="Change one existing named parameter in a model specification.",
)
set_command.add_argument("model_spec", type=Path)
set_command.add_argument("name")
set_command.add_argument("value", type=float)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
if args.command == "import-step":
result = import_step_model(args.source_step, args.task_dir, args.model_id)
elif args.command == "build":
result = build_model(args.model_spec)
elif args.command == "verify":
result = verify_model(args.model_spec)
else:
result = set_parameter(args.model_spec, args.name, args.value)
except ModelSpecError as exc:
print(json.dumps({"error": str(exc)}, ensure_ascii=False, indent=2))
return 2
print(json.dumps(result, ensure_ascii=False, indent=2))
if args.command == "verify" and result.get("valid") is False:
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+90 -188
View File
@@ -12,10 +12,6 @@ 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
@@ -40,14 +36,9 @@ KEYWORD_GROUPS: dict[str, tuple[str, ...]] = {
"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",
@@ -56,16 +47,6 @@ KEYWORD_GROUPS: dict[str, tuple[str, ...]] = {
}
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", "拓竹")),
)
EXPERIENCE_FAMILY_RULES: tuple[tuple[str, tuple[str, ...]], ...] = (
(
"flanged_hub_adapter",
@@ -184,15 +165,6 @@ 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 infer_experience_query(request: str) -> tuple[str | None, list[str]]:
text = request.lower()
family = next(
@@ -211,18 +183,6 @@ def infer_experience_query(request: str) -> tuple[str | None, list[str]]:
return family, features
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": {
@@ -233,10 +193,6 @@ def probe_backends() -> dict[str, dict[str, object]]:
"available": importlib.util.find_spec("simplecadapi") is not None,
"probe": f"python={sys.executable}",
},
"cadam": {
"available": cadam_runtime_available(),
"probe": str(cadam_root()),
},
}
@@ -265,46 +221,6 @@ MAJOR_EDIT_TERMS = (
"major redesign",
"rebuild the whole",
)
LOCAL_FEATURE_EDIT_TERMS = (
"增加孔",
"添加孔",
"删除孔",
"增加槽",
"添加槽",
"删除槽",
"沉孔",
"倒角",
"圆角",
"add hole",
"remove hole",
"add slot",
"counterbore",
"fillet",
"chamfer",
)
PARAMETER_EDIT_TERMS = (
"孔径",
"直径",
"厚度",
"长度",
"宽度",
"高度",
"间距",
"齿数",
"模数",
"改为",
"调整",
"diameter",
"thickness",
"length",
"width",
"height",
"spacing",
"change",
"resize",
)
def _find_forbidden_experience_key(value: object) -> str | None:
if isinstance(value, dict):
for key, child in value.items():
@@ -325,7 +241,14 @@ def default_experience_library() -> Path | None:
configured = os.environ.get("CAD_EXPERIENCE_LIBRARY", "").strip()
if configured:
return Path(configured).expanduser()
workspace_candidate = SKILL_ROOT.parents[2] / "cad-experience-library" / "library.json"
workspace_candidate = (
SKILL_ROOT.parents[2]
/ "designir-pipeline"
/ "workspace"
/ "promotion"
/ "promoted"
/ "library.json"
)
return workspace_candidate if workspace_candidate.is_file() else None
@@ -338,40 +261,43 @@ def classify_edit_context(
return None
source = edit_source.expanduser().resolve() if edit_source else None
if source is not None:
parser_root = (
SKILL_ROOT.parents[2] / "cad-experience-plugin" / "parser"
).resolve()
if source == parser_root or parser_root in source.parents:
protected_roots = [
(
SKILL_ROOT.parents[2]
/ "designir-pipeline"
/ "workspace"
/ "teacher"
).resolve(),
(
SKILL_ROOT.parents[2]
/ "designir-pipeline"
/ "workspace"
/ "acceptance"
).resolve(),
]
if any(source == root or root in source.parents for root in protected_roots):
raise ValueError(
"cad-router cannot use cad-experience-plugin/parser input or "
"output as an edit source; provide the original model or "
"editable generator outside the distillation staging area"
"cad-router reconstruction cannot read the teacher or acceptance "
"vault; pass the ingestion job reference or an editable DesignIR"
)
suffix = source.suffix.lower() if source else ""
text = request.lower()
if suffix == ".json" and source and source.name == "model-spec.json":
mode = "model_spec_parameter_edit"
reason = "A per-part model specification exists; update its named JSON parameters or modification features."
elif suffix in {".py", ".scad", ".js", ".mjs"}:
if suffix == ".json" and source and source.name.endswith(".designir.json"):
mode = "designir_parameter_edit"
reason = "Executable DesignIR exists; update its named parameters, constraints, or features."
elif suffix == ".py":
mode = "native_parameter_edit"
reason = "An editable generator exists; update its named parameters or feature source."
elif source and suffix in {".step", ".stp"}:
mode = "private_evidence_to_isolated_designir_reconstruction"
reason = (
"The uploaded STEP is teacher truth: ingestion extracts private "
"evidence, then reconstruction authors DesignIR without STEP access."
)
elif contains_any(text, MAJOR_EDIT_TERMS):
mode = "experience_guided_semantic_reconstruction"
reason = "The requested topology change is large; rebuild from the specified source model using only promoted experience."
elif source and suffix in {".step", ".stp"} and contains_any(
text, LOCAL_FEATURE_EDIT_TERMS
):
mode = "direct_step_local_feature_edit"
reason = "Inspect the specified STEP and rebuild only the affected local feature."
elif source and suffix in {".step", ".stp"} and contains_any(
text, PARAMETER_EDIT_TERMS
):
mode = "direct_step_feature_rebuild"
reason = "STEP has no native feature history; inspect the source and reconstruct the affected dimensional feature."
elif source and suffix in {".step", ".stp"}:
mode = "inspect_existing_step_then_modify"
reason = "Inspect the explicitly supplied STEP before choosing local editing or reconstruction."
else:
mode = "existing_source_required"
reason = "An edit was requested without a resolvable editable source or private case."
@@ -381,21 +307,30 @@ def classify_edit_context(
"edit_source": str(source) if source else None,
"modification_mode": mode,
"reason": reason,
"allowed_knowledge_sources": [
"the explicitly supplied original model or editable generator",
"the promoted generalized CAD experience library",
"the current user request",
],
"allowed_knowledge_sources": (
[
"deterministically extracted private teacher evidence",
"the promoted generalized CAD experience library",
"the current user request",
]
if source and suffix in {".step", ".stp"}
else [
"the explicitly supplied DesignIR or editable generator",
"the promoted generalized CAD experience library",
"the current user request",
]
),
"forbidden_knowledge_sources": [
"cad-experience-plugin/parser/input",
"cad-experience-plugin/parser/output",
"private case JSON",
"teacher STEP during reconstruction",
"acceptance vault",
"embedded B-Rep or mesh geometry",
"private cases unrelated to the active teacher evidence",
],
"preservation_contract": [
"Preserve the source coordinate frame unless the request explicitly changes it.",
"Preserve unmodified interfaces, datums, and feature relationships.",
"Prefer the per-part model-spec.json and native generator over inferred STEP reconstruction when they exist.",
"Use generalized experience only as method guidance, never as replacement source geometry.",
"Prefer the per-part DesignIR and native generator when they exist.",
"Use generalized experience only as method guidance, never as teacher geometry.",
],
}
@@ -481,7 +416,6 @@ def load_experience_library(
def route(args: argparse.Namespace) -> dict[str, object]:
request = args.request.strip()
text = request.lower()
outputs = normalize_formats(args.output)
inferred_family, inferred_features = infer_experience_query(request)
experience_family = args.experience_family or inferred_family
experience_features = args.experience_feature or inferred_features
@@ -490,6 +424,11 @@ def route(args: argparse.Namespace) -> dict[str, object]:
args.edit_source,
args.existing_model,
)
teacher_step_reconstruction = bool(
edit_context
and edit_context.get("modification_mode")
== "private_evidence_to_isolated_designir_reconstruction"
)
experience_context = load_experience_library(
args.experience_library,
experience_family,
@@ -499,7 +438,6 @@ def route(args: argparse.Namespace) -> dict[str, object]:
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"]):
@@ -508,42 +446,18 @@ def route(args: argparse.Namespace) -> dict[str, object]:
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")
scores["simplecadapi"].add(60, "replay or semantic graph requirement")
if edit_context:
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")
scores["build123d"].add(35, "editable STEP is the fixed artifact contract")
scores["simplecadapi"].add(
30, "OCP STEP export satisfies the fixed artifact contract"
)
if experience_context and experience_context["experiences"]:
scores["build123d"].add(18, "generalized experience can guide parametric feature composition")
scores["simplecadapi"].add(18, "generalized experience can guide semantic feature composition")
@@ -568,22 +482,9 @@ def route(args: argparse.Namespace) -> dict[str, object]:
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]
source_of_truth = backend_info["source_of_truth"]
if (
edit_context
and edit_context.get("modification_mode") == "model_spec_parameter_edit"
):
source_of_truth = "per-part model-spec.json with a backend generator"
source_of_truth = "per-part DesignIR 2.0 with an isolated backend compiler"
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] = {
@@ -600,8 +501,10 @@ def route(args: argparse.Namespace) -> dict[str, object]:
],
"fallback_order": fallback,
"workflow_profiles": profiles,
"downstream_skills": downstream,
"requested_outputs": outputs,
"artifact_contract": {
"primary_format": "step",
"editable_contract": "DesignIR 2.0",
},
"source_of_truth": source_of_truth,
"experience_context": experience_context,
"design_plan": {
@@ -664,27 +567,37 @@ def route(args: argparse.Namespace) -> dict[str, object]:
else None
),
"knowledge_boundary": (
"Classify only from the explicitly supplied source model; "
"never from parser input/output or private case JSON."
"Classify from active-job private evidence only; the "
"reconstruction agent never reads teacher STEP."
),
},
"edit_context": edit_context,
"execution_steps": (
[
"Load only the explicitly supplied source model or native generator.",
"Inspect the source model to classify its family and feature roles when the request does not name them, then re-query promoted experience.",
"Run deterministic ingestion in the protected teacher zone.",
"Give Agent A only the active private evidence packet and promoted experience.",
"Author complete executable DesignIR with uncertainty and missing-capability declarations.",
"Run anti-cheat validation and compile in a sandbox without teacher STEP.",
"Give Agent B the teacher, DesignIR, rebuilt STEP, and edit contract.",
"Run geometry comparison and parameter perturbation acceptance.",
"Submit sanitized feedback to deterministic promotion policy.",
]
if teacher_step_reconstruction
else
[
"Load only the explicitly supplied DesignIR or native generator.",
"Map requested changes to named parameters or affected semantic feature roles.",
"Preserve unchanged interfaces and rebuild only the smallest responsible feature region.",
"Use promoted generalized experience to guide methods; never read parser input/output.",
"Use promoted generalized experience to guide methods.",
"Use semantic reconstruction for topology changes that cannot preserve the prior feature structure.",
"Validate changed dimensions plus invariants inherited from the previous model.",
]
if edit_context
else [
"Resolve explicit user dimensions and preserve them as authoritative parameters.",
"Instantiate matched reconstruction grammars as symbolic model-spec.json parameters, datums, and canonical feature stages; never copy teacher dimensions.",
"Instantiate matched reconstruction grammars as DesignIR parameters, datums, constraints, and canonical feature stages; never copy teacher geometry.",
"Translate requested feature roles and matched generalized methods into backend operations.",
"Generate the editable source of truth and STEP-first artifact when requested.",
"Generate the editable source of truth and STEP artifact.",
"Validate topology, dimensions, feature relationships, surface mix, and export integrity against the grammar validation roles.",
]
),
@@ -692,7 +605,6 @@ def route(args: argparse.Namespace) -> dict[str, object]:
"project_contributions": {
"text-to-cad": "core Skill host, build123d CAD backend, refinement, visual repair, 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",
},
"availability": probe_backends() if args.probe else None,
}
@@ -710,9 +622,6 @@ def explain(result: dict[str, object]) -> str:
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)}")
experience_context = result.get("experience_context")
if experience_context:
lines.append(
@@ -734,25 +643,18 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("request", help="Natural-language CAD request.")
parser.add_argument(
"--backend",
choices=("auto", "build123d", "simplecadapi", "cadam"),
choices=("auto", "build123d", "simplecadapi"),
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(
"--edit-source",
type=Path,
help="Existing model-spec.json, generator, or explicit STEP/STP to modify; private case JSON is forbidden.",
help="Existing *.designir.json, generator, or explicit STEP/STP teacher to reconstruct; private corpus access is forbidden.",
)
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("--probe", action="store_true", help="Probe current-interpreter backend availability.")
parser.add_argument(
"--experience-library",
type=Path,