refactor: add DesignIR distillation pipeline
This commit is contained in:
@@ -1,15 +1,15 @@
|
||||
---
|
||||
name: cad-router
|
||||
description: Route natural-language CAD, mechanical-part, assembly, 3D-printing, and robot-description requests across text-to-cad, SimpleCADAPI, and CADAM; create one editable model-spec.json per part; convert supplied STEP/STP files into exact-base JSON-controlled models; and enforce generation, modification, validation, visual repair, and CAD Viewer handoff. Use for new or modified STEP/STP, SCAD, STL, 3MF, DXF, URDF, SRDF, SDF, or G-code work when the agent must choose a backend, preserve editable sources, edit JSON model parameters, or parameterize an imported STEP.
|
||||
description: Route natural-language or image-guided CAD generation and modification, plus uploaded STEP/STP reconstruction and modification, across text-to-cad and SimpleCADAPI. Create one executable DesignIR 2.0 model per part, produce STEP geometry without teacher geometry dependencies, and enforce independent validation, visual repair, and CAD Viewer review.
|
||||
---
|
||||
|
||||
# CAD Router
|
||||
|
||||
Choose a backend before modeling, keep its source as the editable authority, and normalize every result into one handoff contract. Use three projects by role: text-to-cad owns orchestration, general CAD, refinement, validation, repair, and viewing; SimpleCADAPI owns specialized mechanical families; CADAM owns SCAD execution.
|
||||
Choose a backend before modeling, keep its source as the editable authority, and normalize every result into one handoff contract. Use two projects by role: text-to-cad owns orchestration, general CAD, refinement, validation, repair, and viewing; SimpleCADAPI owns specialized mechanical families.
|
||||
|
||||
## Required workflow
|
||||
|
||||
1. Convert the request into a brief: part type, dimensions, units, manufacturing intent, required outputs, assembly relationships, editability, and validation targets.
|
||||
1. Convert the natural-language or image-guided request into a brief: part type, dimensions, units, assembly relationships, editability, and validation targets. The artifact contract is fixed to executable DesignIR 2.0 plus STEP geometry.
|
||||
Never treat a teacher STEP or a single-case DesignIR as experience. Batch case
|
||||
JSON belongs to the separate CAD Experience plugin. Generation may consume
|
||||
only its promoted schema `2.0` generalized library.
|
||||
@@ -21,63 +21,55 @@ Choose a backend before modeling, keep its source as the editable authority, and
|
||||
python3 scripts/route.py "<request>" --explain
|
||||
```
|
||||
|
||||
Add explicit signals when known: `--output step`, `--output stl`, `--manufacturing machining|printing|laser-cutting`, `--edit-source path/to/source`, `--existing-model`, `--assembly`, or `--browser-controls`.
|
||||
Add explicit signals when known: `--edit-source path/to/source`, `--existing-model`, or `--assembly`.
|
||||
The router auto-discovers the workspace `cad-experience-library/library.json`,
|
||||
infers an experience family and feature roles from the request, and emits a
|
||||
backend-neutral `design_plan`. When promoted `reconstruction_grammar`
|
||||
records match, instantiate their symbolic parameter roles, datum roles,
|
||||
canonical stages, and validation roles in the new part's
|
||||
`model-spec.json`; fill values from explicit user dimensions first and
|
||||
DesignIR; fill values from explicit user dimensions first and
|
||||
dimensionless distributions second. Use `--experience-library`,
|
||||
`--experience-family`, or repeated `--experience-feature` only to override
|
||||
those defaults.
|
||||
3. Follow the selected route unless a hard environmental constraint makes it unavailable. If overriding it, record the reason in the task manifest.
|
||||
4. Load only the selected backend reference from `references/capabilities.md` and the matching installed Skill:
|
||||
- `build123d`: use `$cad`; this is the default for general STEP-first mechanical parts, assemblies, and existing STEP modification.
|
||||
- `simplecadapi`: use an installed SimpleCADAPI Skill/runtime; prefer it for gears, racks, ring gears, bearings, cycloidal parts, reducers, replayable graphs, semantic tags, and FreeCAD interchange.
|
||||
- `cadam`: generate editable `.scad`, then execute `node scripts/cadam_compile.mjs <source.scad> <output.stl|output.dxf>`. Set `CADAM_ROOT` or pass `--cadam-root` when the CADAM checkout is not beside text-to-cad. Prefer it for fast parametric printable models, repeated patterns, decorative CSG, and parameter controls.
|
||||
- `simplecadapi`: use an installed SimpleCADAPI Skill/runtime; prefer it for gears, racks, ring gears, bearings, cycloidal parts, reducers, replayable graphs, and semantic tags.
|
||||
5. When the route reports `requirement_refinement` or `visual_repair`, refine the brief, generate, execute, inspect multiple views, and repair no more than three visual mismatch rounds before asking the user.
|
||||
6. Generate into a task-owned directory and start CAD Viewer with that task directory as `--dir`; do not expose the repository-wide fixture library for a task review. Write one `model-spec.json` per part and treat it as the editable geometry contract; keep `cad-task.json` as the execution record. Read `references/model-spec.md` before creating, importing, or modifying a model specification. Never change backend during an edit unless conversion is explicitly requested; modify the recorded source of truth.
|
||||
For a supplied STEP/STP without an editable generator, create an
|
||||
`exact_step_base` specification with `scripts/model_spec.py import-step`.
|
||||
Preserve the copied STEP as the immutable exact B-Rep base and express later
|
||||
edits as named JSON parameters and modification features. Do not claim that
|
||||
arbitrary STEP recovers its original sketches, constraints, or feature
|
||||
history.
|
||||
For modification, prefer a native Python/SCAD generator. With STEP only,
|
||||
inspect the explicitly supplied model and perform the smallest local feature
|
||||
rebuild possible. For major topology changes, reconstruct from that model
|
||||
under the promoted experience methods while preserving unchanged interfaces.
|
||||
6. Generate into a task-owned directory and start CAD Viewer with that task directory as `--dir`; do not expose the repository-wide fixture library for a task review. Write one `*.designir.json` per part and treat it as the editable geometry contract; keep `cad-task.json` as the execution record. Read `references/designir-2.0.md` before creating, reconstructing, or modifying a model. Never change backend during an edit unless conversion is explicitly requested; modify the recorded DesignIR source of truth.
|
||||
For a supplied STEP/STP, the deterministic ingestion service may extract
|
||||
private teacher evidence. The reconstruction agent receives that evidence,
|
||||
never the STEP itself, and must author complete executable DesignIR. Compile
|
||||
it in an isolated environment where the teacher STEP is absent.
|
||||
The DesignIR and generator must not import STEP, embed B-Rep or meshes, retain
|
||||
face references, or use the source model as a base feature. The acceptance
|
||||
agent alone compares the teacher and reconstructed STEP.
|
||||
If the request does not name the source part family, inspect the explicit
|
||||
source first and rerun the experience query with `--experience-family` and
|
||||
`--experience-feature`; do not guess across family-scoped experience.
|
||||
7. Validate against the brief. STEP-first routes must run geometry inspection and snapshot review. Mesh routes must check compilation, bounds, solidity/manifold facts when available, and snapshot review.
|
||||
7. Validate against the brief. Run STEP geometry inspection and snapshot review.
|
||||
For experience-informed generation, record which generalized methods were
|
||||
applied, including the reconstruction grammar ID and instantiated symbolic
|
||||
roles. Validate against the user's brief and held-out targets, never against
|
||||
parameters exposed from the same source case.
|
||||
8. Write a `cad-task.json` manifest using the contract in `references/backend-contract.md`. Hand supported artifacts to `$cad-viewer` and return its live links.
|
||||
8. Write a `cad-task.json` manifest using the contract in `references/backend-contract.md`. Hand the STEP artifact to `$cad-viewer` and return its live link.
|
||||
|
||||
## Routing rules
|
||||
|
||||
- Prefer an explicit user backend when it can produce the required primary format.
|
||||
- Prefer an explicit user backend when it can produce the editable STEP contract.
|
||||
- Prefer SimpleCADAPI when a supported standard mechanical factory or replay/semantic requirement materially reduces custom modeling.
|
||||
- Prefer build123d for general machined parts such as brackets, connecting rods, housings, shafts, flanges, fixtures, and source-driven assemblies.
|
||||
- Prefer CADAM for SCAD-native, fast printable, pattern-heavy, or interactive-parameter models. Do not choose it when editable STEP is mandatory.
|
||||
- Route DXF, URDF, SRDF, SDF, G-code, printer, or fabrication handoffs to their focused installed Skills after primary geometry generation.
|
||||
- Prefer source quality and future modification over the shortest first generation.
|
||||
|
||||
## V1 boundaries
|
||||
|
||||
- Do not promise lossless conversion of feature history between backends.
|
||||
- Do not treat STL as an editable CAD source when a parametric source or STEP exists.
|
||||
- Do not claim engineering certification, tolerance analysis, FEA, CAM toolpaths, or manufacturability without separate checks.
|
||||
- Keep GPL/AGPL backends independently installed and invoked; do not copy their implementation into an MIT-distributed Skill.
|
||||
|
||||
## References
|
||||
|
||||
- Read `references/capabilities.md` when comparing or invoking backends.
|
||||
- Read `references/backend-contract.md` before writing a task manifest or adding another backend.
|
||||
- Read `references/model-spec.md` when generating per-part parameter JSON,
|
||||
converting STEP to a JSON-controlled model, or modifying an existing part.
|
||||
- Read `references/designir-2.0.md` when generating, reconstructing, or
|
||||
modifying a per-part executable design.
|
||||
- `scripts/capabilities.json` is the machine-readable V1 registry used by `scripts/route.py`.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
interface:
|
||||
display_name: "CAD Router"
|
||||
short_description: "Route CAD and manage per-part JSON model specs."
|
||||
default_prompt: "Use $cad-router to select the best CAD backend, create or modify the part through its model-spec.json, validate the result, and open it in CAD Viewer."
|
||||
default_prompt: "Use $cad-router to select the best CAD backend, create or modify the part through DesignIR 2.0, validate the result independently, and open the STEP in CAD Viewer."
|
||||
|
||||
@@ -6,11 +6,10 @@ Capture these fields when known:
|
||||
|
||||
- Natural-language request and normalized part family.
|
||||
- Units and dimensions.
|
||||
- Manufacturing intent: machining, printing, laser cutting, simulation, or concept.
|
||||
- Required primary and secondary output formats.
|
||||
- STEP is the fixed geometry artifact format.
|
||||
- New model versus modification, and existing source/artifact paths.
|
||||
- Part versus assembly, standard components, mating requirements, and motion relationships.
|
||||
- Required parameter controls, replay, semantic tags, or FreeCAD interchange.
|
||||
- Required parameter controls, replay, or semantic tags.
|
||||
- Geometry and visual validation targets.
|
||||
- Optional schema `2.0` generalized experience library. Never pass a case
|
||||
DesignIR, teacher STEP, source dimensions, coordinates, or surface references
|
||||
@@ -23,13 +22,12 @@ Capture these fields when known:
|
||||
|
||||
Record:
|
||||
|
||||
- `selected_backend`: `build123d`, `simplecadapi`, or `cadam`.
|
||||
- `selected_backend`: `build123d` or `simplecadapi`.
|
||||
- Origin project and optional executable adapter.
|
||||
- `runner_skill`: installed Skill responsible for generation.
|
||||
- Ranked backend scores and human-readable reasons.
|
||||
- Fallback order.
|
||||
- Workflow profiles such as `requirement_refinement` and `visual_repair`.
|
||||
- Focused downstream Skills.
|
||||
- Any explicit override and its reason.
|
||||
- `operation`: create or modify; for modification also record the explicit
|
||||
source path and whether the route uses native parameter editing, local feature
|
||||
@@ -47,7 +45,7 @@ Write `cad-task.json` beside the task artifacts with this minimum shape:
|
||||
"selected_backend": "build123d",
|
||||
"runner_skill": "cad",
|
||||
"project": "text-to-cad",
|
||||
"fallback_order": ["simplecadapi", "cadam"],
|
||||
"fallback_order": ["simplecadapi"],
|
||||
"workflow_profiles": ["requirement_refinement", "visual_repair"]
|
||||
},
|
||||
"source": {
|
||||
@@ -56,12 +54,11 @@ Write `cad-task.json` beside the task artifacts with this minimum shape:
|
||||
"backend": "build123d"
|
||||
},
|
||||
"artifacts": [
|
||||
{"path": "connecting_rod.step", "role": "primary"},
|
||||
{"path": "connecting_rod.stl", "role": "secondary"}
|
||||
{"path": "connecting_rod.step", "role": "primary"}
|
||||
],
|
||||
"parameters": {},
|
||||
"experience": {
|
||||
"library": "../../../cad-experience-library/library.json",
|
||||
"library": "../../../designir-pipeline/workspace/promotion/promoted/library.json",
|
||||
"context_kind": "generalized_cad_experience_query",
|
||||
"methods_applied": []
|
||||
},
|
||||
@@ -73,13 +70,14 @@ Write `cad-task.json` beside the task artifacts with this minimum shape:
|
||||
|
||||
Paths are relative to the task directory unless an external input must remain absolute.
|
||||
Do not store authoritative model parameters in `cad-task.json`. Every part must
|
||||
have a sibling `model-spec.json` that owns its parameter values, reconstruction
|
||||
mode, parametric modification layer, and output paths. See `model-spec.md`.
|
||||
have a sibling `*.designir.json` that owns its coordinate system, datums,
|
||||
parameters, expressions, features, constraints, edit interface, and validation
|
||||
contract. See `designir-2.0.md`.
|
||||
|
||||
For an imported STEP, use `exact_step_base`: copy the explicitly supplied file
|
||||
into the task directory, record its checksum and geometry signature, and keep
|
||||
the imported B-Rep unchanged when the modification list is empty. This is an
|
||||
exact geometric base contract, not recovery of the original CAD feature tree.
|
||||
For an uploaded STEP, keep teacher geometry in the protected ingestion and
|
||||
acceptance workspace. The reconstruction task contains private evidence,
|
||||
DesignIR, backend source, and rebuilt STEP but no teacher STEP or imported
|
||||
B-Rep.
|
||||
|
||||
## Backend adapter requirements
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@ Best fit:
|
||||
- Brackets, connecting rods, cranks, levers, shafts, bushings, flanges, plates, housings, covers, enclosures, fixtures, and machined features.
|
||||
- Parametric Python sources with holes, pockets, slots, bosses, ribs, shells, fillets, chamfers, lofts, sweeps, and revolves.
|
||||
- Source-level assemblies, named datums, joints, imported components, measurements, alignment checks, and existing STEP inspection.
|
||||
- STEP primary output with STL, 3MF, or GLB sidecars.
|
||||
- STEP output compiled from executable DesignIR 2.0.
|
||||
|
||||
Weak signals: a specialized mechanical family already implemented by SimpleCADAPI, SCAD-native output, browser-only execution, or decorative repeated CSG where OpenSCAD is substantially shorter.
|
||||
Weak signals: a specialized mechanical family already implemented by SimpleCADAPI or a browser-only execution requirement.
|
||||
|
||||
## SimpleCADAPI
|
||||
|
||||
@@ -21,23 +21,11 @@ Best fit:
|
||||
|
||||
- Spur, helical, and herringbone gears; internal ring gears; racks; cycloidal discs; ball bearings.
|
||||
- Planetary and cycloidal reducers, joint actuators, and mechanism-heavy assemblies.
|
||||
- Parameter expressions, operation-graph JSON, replay, semantic tags, topology queries, and FreeCAD translation.
|
||||
- Parameter expressions, operation-graph JSON, replay, semantic tags, and topology queries.
|
||||
- Reusable mechanical product families whose formulas should become deterministic library functions.
|
||||
|
||||
Before invocation, inspect the installed package and Skill versions together. Do not mix the current single-`Solid` boolean API with older `*rsolidlist` documentation. If the installed runtime and docs disagree, stop that route and use build123d or repair the installation.
|
||||
|
||||
## CADAM
|
||||
|
||||
Use CADAM's separately installed OpenSCAD WASM runtime for fast CSG authoring and printable parameterized models. Execute generated SCAD through `scripts/cadam_compile.mjs`; do not copy CADAM's GPL runtime into this Skill.
|
||||
|
||||
Best fit:
|
||||
|
||||
- Vases, containers, knobs, adapters, printable threads, lattice/honeycomb patterns, repeated details, decorative models, and quick concept geometry.
|
||||
- SCAD source, STL output, simple projected DXF, and UI-facing parameter controls.
|
||||
- BOSL/BOSL2/MCAD-backed printable features.
|
||||
|
||||
Do not use as the primary route for editable STEP, stable face/edge references, fit-critical source-level assemblies, or modification of imported STEP.
|
||||
|
||||
## Router refinement and repair
|
||||
|
||||
Use text-to-cad's own requirement expansion and visual repair when requirements
|
||||
@@ -51,11 +39,4 @@ one-pass generation unreliable:
|
||||
5. Inspect a multi-view render against the brief.
|
||||
6. Repair visual mismatches, capped at three automatic visual rounds.
|
||||
|
||||
## Focused downstream Skills
|
||||
|
||||
- `$dxf`: 2D profiles, flat patterns, laser/plasma/waterjet drawings.
|
||||
- `$urdf`, `$srdf`, `$sdf`: robot structure, MoveIt semantics, and simulation models/worlds.
|
||||
- `$gcode`: slice validated meshes with a real slicer.
|
||||
- `$sendcutsend`: vendor-specific manufacturing preflight.
|
||||
- `$bambu-labs`: cautious local printer handoff.
|
||||
- `$cad-viewer`: visual review of supported generated artifacts.
|
||||
CAD Viewer reviews the generated or reconstructed STEP.
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# DesignIR 2.0 contract
|
||||
|
||||
Every generated, reconstructed, or modified part owns one executable
|
||||
`*.designir.json`. It records design intent rather than source faces or a copied
|
||||
solid.
|
||||
|
||||
Required sections:
|
||||
|
||||
- `coordinate_system`
|
||||
- `datums`
|
||||
- `parameters`
|
||||
- `expressions`
|
||||
- `sketches`
|
||||
- `constraints`
|
||||
- `features`
|
||||
- `patterns`
|
||||
- `attachments`
|
||||
- `construction_stages`
|
||||
- `edit_interface`
|
||||
- `validation_contract`
|
||||
|
||||
The compiler translates this backend-neutral representation to build123d or
|
||||
SimpleCADAPI and exports STEP.
|
||||
|
||||
## Uploaded STEP boundary
|
||||
|
||||
The uploaded STEP is teacher and acceptance truth only:
|
||||
|
||||
1. A deterministic ingestion process extracts private geometric evidence.
|
||||
2. The reconstruction agent sees evidence and promoted experience, not the
|
||||
teacher STEP.
|
||||
3. It authors complete DesignIR and records uncertainty or missing vocabulary.
|
||||
4. The compiler rebuilds STEP in a sandbox containing no teacher STEP.
|
||||
5. A separate acceptance agent compares the teacher and rebuilt STEP and runs
|
||||
parameter perturbations.
|
||||
|
||||
Reject DesignIR or generators that:
|
||||
|
||||
- import or reference the teacher STEP;
|
||||
- embed STEP text, B-Rep bytes, meshes, vertices, triangles, or topology refs;
|
||||
- retain teacher face, edge, or surface identifiers;
|
||||
- use the teacher as a base feature;
|
||||
- fail to rebuild after the teacher is removed.
|
||||
|
||||
If the operation vocabulary is insufficient, set
|
||||
`reconstruction_status` to `unsupported_feature_vocabulary` and enumerate
|
||||
`missing_capabilities`. Never hide the gap with teacher geometry.
|
||||
|
||||
## Acceptance
|
||||
|
||||
Keep geometry, semantic, editability, and stability metrics separate. At
|
||||
minimum report bounding-box error, volume error, center-of-mass error,
|
||||
symmetric-difference volume, topology deltas, declared feature/constraint
|
||||
coverage, editable parameter coverage, and perturbation rebuild success.
|
||||
|
||||
Only deterministic policy code may move a method from candidate to
|
||||
`replay_validated` and then promoted experience after several independent cases
|
||||
pass.
|
||||
@@ -1,111 +0,0 @@
|
||||
# Per-part model specification
|
||||
|
||||
Use one `model-spec.json` per generated or imported part. Treat it as the
|
||||
editable geometry contract. Keep `cad-task.json` separate for routing,
|
||||
experience use, validation results, and viewer links.
|
||||
|
||||
## Native generator
|
||||
|
||||
Use `native_generator` when the part has editable Python source:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"model_spec_kind": "parametric_cad_model",
|
||||
"model_id": "example_part",
|
||||
"units": "mm",
|
||||
"reconstruction": {
|
||||
"mode": "native_generator",
|
||||
"generator": {
|
||||
"path": "example_part.py",
|
||||
"entrypoint": "gen_step"
|
||||
}
|
||||
},
|
||||
"parameters": {
|
||||
"width": {
|
||||
"value": 40,
|
||||
"unit": "mm",
|
||||
"role": "overall_width"
|
||||
}
|
||||
},
|
||||
"modifications": [],
|
||||
"outputs": {
|
||||
"step": "example_part.step",
|
||||
"stl": "example_part.stl"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The generator must read its sibling `model-spec.json`. Do not duplicate
|
||||
authoritative parameter values as editable constants in both files.
|
||||
|
||||
## Imported STEP
|
||||
|
||||
Convert an explicitly supplied STEP into a task-local exact-base specification:
|
||||
|
||||
```bash
|
||||
python scripts/model_spec.py import-step input.step \
|
||||
--task-dir models/cad-router-v1/imported_part \
|
||||
--model-id imported_part
|
||||
```
|
||||
|
||||
This copies the source to `source.step`, records its SHA-256 and geometry
|
||||
signature, writes `model-spec.json`, and exports regenerated STEP/STL files.
|
||||
With an empty modification list, `verify` requires the imported B-Rep to retain
|
||||
the same bounds, center, topology counts, solid count, and volume.
|
||||
|
||||
STEP normally contains final B-Rep geometry, not the original sketches,
|
||||
constraints, names, or feature history. Therefore:
|
||||
|
||||
- `exact_step_base` guarantees an unchanged imported base, not recovered native
|
||||
design history.
|
||||
- Add later edits as named parametric modifications.
|
||||
- Use semantic native reconstruction only when the feature family can be
|
||||
reliably recognized and validate it independently against the source.
|
||||
- Never claim arbitrary STEP-to-JSON conversion recovers the author’s original
|
||||
parameters or construction order.
|
||||
|
||||
## Parametric modification layer
|
||||
|
||||
Supported V1 operations are `add_box`, `cut_box`, `add_cylinder`, and
|
||||
`cut_cylinder`. Values may be literals, direct parameter references, or affine
|
||||
parameter expressions:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "central_bore",
|
||||
"operation": "cut_cylinder",
|
||||
"axis": "z",
|
||||
"center": [0, 0, 0],
|
||||
"diameter": {"parameter": "bore_diameter"},
|
||||
"length": {"parameter": "bore_length"},
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
Use `scale` and `offset` when a tool center must stay anchored while its size
|
||||
changes. For example, a positive-Z face at `z = 14.1` can remain the lower face
|
||||
of an added box while `extension` changes:
|
||||
|
||||
```json
|
||||
{
|
||||
"center": [
|
||||
13.1,
|
||||
-1.0,
|
||||
{"parameter": "extension", "scale": 0.5, "offset": 14.1}
|
||||
],
|
||||
"size": [22.2, 2.0, {"parameter": "extension"}]
|
||||
}
|
||||
```
|
||||
|
||||
Change a named value and rebuild:
|
||||
|
||||
```bash
|
||||
python scripts/model_spec.py set path/to/model-spec.json bore_diameter 18
|
||||
python scripts/model_spec.py build path/to/model-spec.json
|
||||
python scripts/model_spec.py verify path/to/model-spec.json
|
||||
```
|
||||
|
||||
Use `apply_patch` when adding or structurally editing parameters and features so
|
||||
the modification remains reviewable. After every visible rebuild, run the CAD
|
||||
inspection and snapshot workflow and update `cad-task.json`.
|
||||
@@ -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())
|
||||
@@ -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,
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from build123d import Box, Cylinder, Pos, export_step, import_step
|
||||
|
||||
|
||||
SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "model_spec.py"
|
||||
MODULE_SPEC = importlib.util.spec_from_file_location("cad_router_model_spec", SCRIPT_PATH)
|
||||
assert MODULE_SPEC is not None and MODULE_SPEC.loader is not None
|
||||
MODEL_SPEC = importlib.util.module_from_spec(MODULE_SPEC)
|
||||
MODULE_SPEC.loader.exec_module(MODEL_SPEC)
|
||||
|
||||
|
||||
class ModelSpecTests(unittest.TestCase):
|
||||
def test_affine_parameter_expression(self) -> None:
|
||||
payload = {
|
||||
"parameters": {
|
||||
"extension": {
|
||||
"value": 2.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
self.assertEqual(
|
||||
MODEL_SPEC.resolve_number(
|
||||
payload,
|
||||
{
|
||||
"parameter": "extension",
|
||||
"scale": 0.5,
|
||||
"offset": 14.1,
|
||||
},
|
||||
"center[2]",
|
||||
),
|
||||
15.1,
|
||||
)
|
||||
|
||||
def test_import_step_exact_base_round_trip(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
source = root / "teacher.step"
|
||||
export_step(Box(30, 20, 10) - Pos(0, 0, -1) * Cylinder(3, 12), source)
|
||||
|
||||
result = MODEL_SPEC.import_step_model(
|
||||
source,
|
||||
root / "task",
|
||||
"round_trip",
|
||||
)
|
||||
verification = MODEL_SPEC.verify_model(
|
||||
Path(result["model_spec"])
|
||||
)
|
||||
|
||||
self.assertTrue(verification["valid"])
|
||||
self.assertEqual(
|
||||
verification["checks"][0]["check"],
|
||||
"exact_base_geometry_signature",
|
||||
)
|
||||
self.assertTrue((root / "task" / "round_trip.step").is_file())
|
||||
self.assertTrue((root / "task" / "round_trip.stl").is_file())
|
||||
|
||||
def test_parameterized_cut_rebuilds_from_json(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
source = root / "source.step"
|
||||
export_step(Box(40, 30, 12), source)
|
||||
imported = MODEL_SPEC.import_step_model(
|
||||
source,
|
||||
root / "task",
|
||||
"parameterized_cut",
|
||||
)
|
||||
spec_path = Path(imported["model_spec"])
|
||||
payload = json.loads(spec_path.read_text(encoding="utf-8"))
|
||||
payload["parameters"] = {
|
||||
"bore_diameter": {
|
||||
"value": 6,
|
||||
"unit": "mm",
|
||||
"role": "central_bore_diameter",
|
||||
},
|
||||
"bore_length": {
|
||||
"value": 16,
|
||||
"unit": "mm",
|
||||
"role": "through_cut_length",
|
||||
},
|
||||
}
|
||||
payload["modifications"] = [
|
||||
{
|
||||
"id": "central_bore",
|
||||
"operation": "cut_cylinder",
|
||||
"axis": "z",
|
||||
"center": [0, 0, 0],
|
||||
"diameter": {"parameter": "bore_diameter"},
|
||||
"length": {"parameter": "bore_length"},
|
||||
"enabled": True,
|
||||
}
|
||||
]
|
||||
spec_path.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
first = MODEL_SPEC.build_model(spec_path)
|
||||
first_volume = first["facts"]["volume_mm3"]
|
||||
MODEL_SPEC.set_parameter(spec_path, "bore_diameter", 12)
|
||||
second = MODEL_SPEC.build_model(spec_path)
|
||||
|
||||
self.assertLess(second["facts"]["volume_mm3"], first_volume)
|
||||
rebuilt = import_step(second["step"])
|
||||
self.assertGreater(len(rebuilt.faces()), 0)
|
||||
|
||||
def test_exact_base_rejects_changed_source(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
source = root / "source.step"
|
||||
export_step(Box(10, 10, 10), source)
|
||||
imported = MODEL_SPEC.import_step_model(
|
||||
source,
|
||||
root / "task",
|
||||
"checksum_guard",
|
||||
)
|
||||
export_step(Box(12, 10, 10), root / "task" / "source.step")
|
||||
|
||||
with self.assertRaises(MODEL_SPEC.ModelSpecError):
|
||||
MODEL_SPEC.build_model(Path(imported["model_spec"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user