feat: unify CAD workflows on DesignIR 3.0

This commit is contained in:
Jerry
2026-07-28 14:41:26 +08:00
parent 6e33bc0e11
commit b5738e9109
36 changed files with 9690 additions and 1305 deletions
+17 -1
View File
@@ -6,7 +6,7 @@ STEP-first CAD engine used by CadSet.
1. Generate an editable model from natural language.
2. Generate or modify an editable model from text plus reference images.
3. Reconstruct an uploaded STEP into DesignIR 2.0, then modify and rebuild it
3. Reconstruct an uploaded STEP into DesignIR 3.0 / SurfaceIR, then modify and rebuild it
without reading the teacher STEP.
`cad-router` chooses one of two execution backends:
@@ -34,6 +34,22 @@ reconstruction runner rejects source STEP imports, embedded B-Rep, full mesh
substitutes, source face references, and paths into the protected teacher
workspace. Acceptance runs separately against the rebuilt STEP.
## Uploaded STEP execution
Run the full upload path through `cad-router`:
```bash
.venv/bin/python skills/cad-router/scripts/route.py \
"重建上传的 STEP,并返回可修改参数" \
--edit-source /absolute/path/to/part.step \
--task-dir /absolute/path/to/task \
--execute
```
The task returns an independently rebuilt STEP, its DesignIR 3.0 source,
geometry acceptance measurements, parameter perturbation results, and a catalog
containing only validated editable parameters.
## Development
Use the repository-local Python environment for checks:
+28 -12
View File
@@ -1,6 +1,6 @@
---
name: cad-router
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.
description: Route natural-language or image-guided CAD generation and modification, plus uploaded STEP/STP reconstruction and modification, across text-to-cad and SimpleCADAPI. Use unified executable DesignIR 3.0 in semantic-feature mode for authored models and SurfaceIR mode for uploaded STEP reconstruction, without teacher geometry dependencies, with independent validation and CAD Viewer review.
---
# CAD Router
@@ -9,7 +9,7 @@ Choose a backend before modeling, keep its source as the editable authority, and
## Required workflow
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.
1. Convert the natural-language or image-guided request into a brief: part type, dimensions, units, assembly relationships, editability, and validation targets. Every model uses DesignIR 3.0 plus STEP geometry. New text/image models use `fully_semantic_parametric`; uploaded STEP reconstruction uses `surface_parametric`. A generated semantic model may include a compiled SurfaceIR snapshot and then uses `hybrid_semantic_surface_parametric`, while its semantic layer remains authoritative.
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.
@@ -31,22 +31,38 @@ Choose a backend before modeling, keep its source as the editable authority, and
dimensionless distributions second. Use `--experience-library`,
`--experience-family`, or repeated `--experience-feature` only to override
those defaults.
For one uploaded STEP, execute the route rather than stopping after the
routing decision:
```bash
../../.venv/bin/python scripts/route.py \
"重建上传的 STEP,并返回可修改参数" \
--edit-source /absolute/path/to/upload.step \
--task-dir /absolute/path/to/task \
--execute
```
This command must finish extraction, source-independent reconstruction,
geometry acceptance, parameter perturbation acceptance, and artifact
publication. A route decision alone is not a completed reconstruction.
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, 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 `*.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.
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 3.0 `*.designir.json` per part and treat it as the editable geometry contract; keep `cad-task.json` as the execution record. Read `references/designir-3.0.md` for both semantic authoring and uploaded STEP reconstruction. `references/designir-2.0.md` is migration-only. Never change backend during an edit unless conversion is explicitly requested; modify the recorded authoritative layer.
For a supplied STEP/STP, use `scripts/route.py --execute`; do not manually
approximate the model when the executable adapter is available. The
deterministic ingestion service extracts private teacher evidence into
DesignIR 3.0. The independent rebuild subprocess receives the DesignIR and
output path, never the STEP path. The acceptance stage alone receives both
teacher and reconstruction.
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.
Only expose parameters listed by `parameters.json`; every listed parameter
has passed an isolated perturbation test. Other inferred names remain
evidence, not a promised edit interface.
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
@@ -70,6 +86,6 @@ Choose a backend before modeling, keep its source as the editable authority, and
- 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/designir-2.0.md` when generating, reconstructing, or
modifying a per-part executable design.
- Read `references/designir-3.0.md` for semantic authoring, uploaded STEP reconstruction, and edits.
- Read `references/designir-2.0.md` only when migrating legacy artifacts.
- `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 DesignIR 2.0, validate the result independently, and open the STEP in CAD Viewer."
default_prompt: "Use $cad-router to select the best CAD backend, create or modify the part through unified DesignIR 3.0, validate the result independently, and open the STEP in CAD Viewer."
@@ -70,14 +70,17 @@ 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 `*.designir.json` that owns its coordinate system, datums,
parameters, expressions, features, constraints, edit interface, and validation
contract. See `designir-2.0.md`.
have a sibling `*.designir.json` as its editable source of truth. Authored
feature models use semantic DesignIR 3.0; uploaded STEP reconstruction uses
DesignIR 3.0 / SurfaceIR. See `designir-3.0.md`; `designir-2.0.md` is retained
only for legacy migration.
For an uploaded STEP, keep teacher geometry in `designir-pipeline/input/` and
all private evidence, DesignIR candidates, rebuilds, and acceptance reports in
`designir-pipeline/runs/`. The reconstruction task receives evidence but no
teacher STEP or imported B-Rep.
For an uploaded STEP, use `scripts/route.py --execute` with an explicit source
and task directory. Do not move an ordinary upload into or scan the
distillation corpus. The task publishes the independent DesignIR, reconstructed
STEP, validated parameter catalog, geometry report, edit report, and
`cad-task.json`. The reconstruction subprocess receives no teacher STEP path;
only extraction and acceptance may read the explicit uploaded source.
## Backend adapter requirements
@@ -9,7 +9,7 @@ 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 output compiled from executable DesignIR 2.0.
- STEP output compiled from executable semantic DesignIR 3.0.
Weak signals: a specialized mechanical family already implemented by SimpleCADAPI or a browser-only execution requirement.
@@ -1,5 +1,9 @@
# DesignIR 2.0 contract
> Legacy migration reference only. New text, image, and STEP workflows must
> emit DesignIR 3.0. Use `designir_pipeline.py migrate` to preserve an existing
> 2.0 semantic feature program inside the DesignIR 3.0 envelope.
Every generated, reconstructed, or modified part owns one executable
`*.designir.json`. It records design intent rather than source faces or a copied
solid.
@@ -0,0 +1,129 @@
# DesignIR 3.0 unified contract
DesignIR 3.0 is the only editable part contract. It has three explicit modes:
- `fully_semantic_parametric`: authored from text or images. `semantic_layer`
contains design intent, named parameters, constraints and executable
features.
- `surface_parametric`: inferred from an uploaded STEP. `surface_layer`
contains analytic/B-spline surfaces, boundary curves, pcurves, vertices,
tolerances, shells and solids in CadSet's vocabulary.
- `hybrid_semantic_surface_parametric`: a semantic feature program plus a
compiled SurfaceIR snapshot extracted from its generated STEP. The semantic
layer remains authoritative.
The two authoring paths share one envelope and edit vocabulary, but they do not
claim equivalent evidence. Text/image generation authors design intent. STEP
ingestion infers semantics from final B-Rep and never claims to recover the
source CAD application's sketch or feature history.
## Text and image authoring
Author these top-level values:
```json
{
"schema_version": "3.0",
"designir_kind": "independent_parametric_cad",
"model_id": "part_id",
"family": "semantic_part_family",
"units": "mm",
"document_status": "geometry_present",
"reconstruction_mode": "fully_semantic_parametric",
"authoring_mode": "semantic_feature_program",
"backend_hint": "build123d",
"semantic_layer": {
"reconstruction_status": "ready",
"coordinate_system": {
"origin": [0, 0, 0],
"x_axis": [1, 0, 0],
"y_axis": [0, 1, 0],
"z_axis": [0, 0, 1]
},
"datums": {},
"parameters": {},
"expressions": {},
"sketches": [],
"constraints": [],
"features": [],
"patterns": [],
"attachments": [],
"construction_stages": []
},
"edit_interface": {
"semantic_parameters": [],
"surface_parameter_groups": [],
"modification_levels": ["semantic_feature"],
"preserved_interfaces": []
},
"validation_contract": {
"source_independence": true,
"geometry_checks": ["solid_count", "bounding_box", "volume"],
"edit_checks": ["parameter_perturbation"],
"invariants": [],
"perturbations": [],
"thresholds": {}
}
}
```
Every name in `semantic_parameters` must identify an editable parameter, drive
at least one feature, and have a perturbation test. Prefer intent names such as
`flange_thickness`, `shelf_depth`, `hole_spacing`, `rib_count`,
`bore_diameter`, and `bolt_circle_diameter`.
After isolated compilation, attach SurfaceIR only from the generated STEP and
switch the mode to `hybrid_semantic_surface_parametric`. Never use a teacher
STEP to fill the compiled snapshot.
## Uploaded STEP execution
Run an uploaded file through the router's executable adapter:
```bash
../../.venv/bin/python scripts/route.py \
"重建上传的 STEP,并返回可修改参数" \
--edit-source /absolute/path/to/part.step \
--task-dir /absolute/path/to/task \
--execute
```
The task directory contains:
- `<part>.designir.json`: independent editable source of truth;
- `<part>.reconstructed.step`: reconstructed geometry;
- `parameters.json`: only parameters that passed perturbation acceptance;
- `validation/geometry.json`: teacher-versus-reconstruction measurements;
- `validation/edits.json`: parameter acceptance outcomes;
- `cad-task.json`: route, artifacts, isolation and validation state.
## Isolation boundary
Extraction and acceptance may read the uploaded STEP. The reconstruction
subprocess receives only DesignIR and an output path. DesignIR must not contain
a teacher path, an import operation, STEP text, embedded B-Rep, or a mesh
substitute.
The runtime performs an anti-cheat scan before rebuilding. Geometry acceptance
requires valid reconstructed topology and the thresholds recorded in
`validation/geometry.json`.
The adapter loads only formally promoted experiences whose scope is
`surfaceir_reconstruction` or `surfaceir_semantic_editing`. These methods are
applied conditionally by the runtime; `cad-task.json` records both the available
method count and the exact method IDs used for the task.
## Parameter boundary
STEP does not contain authoritative feature names or modeling history. The
semantic layer therefore records inferred canonical parameters separately from
validated editable parameters.
Only parameters in `parameters.json` are public edit controls. Each was changed
in isolation, rebuilt, and checked for target response, preserved validity and
non-target stability. Use the `edit_command_template` in `cad-task.json` to
produce an edited DesignIR and STEP.
An accepted baseline reconstruction does not imply that every inferred
parameter is editable. An empty `parameters.json` is valid for an empty source
document and must not be presented as editable geometry.
@@ -0,0 +1,661 @@
#!/usr/bin/env python3
"""Execute one uploaded STEP -> DesignIR 3.0 reconstruction task.
The teacher STEP is passed only to deterministic extraction and acceptance
commands. The independent rebuild subprocess receives the DesignIR path and
output path only.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any
SCRIPT_PATH = Path(__file__).resolve()
SKILL_ROOT = SCRIPT_PATH.parents[1]
WORKSPACE_ROOT = SKILL_ROOT.parents[2]
SURFACEIR_PIPELINE = (
WORKSPACE_ROOT / "designir-pipeline" / "scripts" / "surfaceir_pipeline.py"
)
EXPERIENCE_LIBRARY = (
WORKSPACE_ROOT / "designir-pipeline" / "output" / "library.json"
)
DESIGNIR_SCHEMA = (
WORKSPACE_ROOT
/ "designir-pipeline"
/ "contracts"
/ "designir-3.0.schema.json"
)
EXECUTABLE_EDIT_STATES = {
"executable_enlarge_cut_binding",
"executable_axis_affine_binding",
"executable_uniform_scale_binding",
"validated_executable_binding",
}
FORBIDDEN_DESIGNIR_TOKENS = (
'"source_path"',
'"teacher_step"',
"teacher.step",
"import_step",
"stepcontrol_reader",
'"brep"',
'"mesh"',
)
class ReconstructionError(RuntimeError):
"""Raised when a reconstruction stage cannot produce an accepted result."""
def _runtime_python() -> Path:
configured = os.environ.get("CAD_ROUTER_PYTHON", "").strip()
candidates = [
Path(configured).expanduser() if configured else None,
WORKSPACE_ROOT / "text-to-cad" / ".venv" / "bin" / "python",
Path(sys.executable),
]
for candidate in candidates:
if candidate is not None and candidate.is_file():
# Preserve a virtualenv launcher symlink. Resolving it to the base
# interpreter discards the virtualenv's package search path.
return candidate.absolute()
raise ReconstructionError(
"No CAD runtime Python found; set CAD_ROUTER_PYTHON or create "
"text-to-cad/.venv."
)
def _safe_stem(source: Path) -> str:
stem = re.sub(r"[^a-zA-Z0-9_.-]+", "_", source.stem).strip("._")
return stem or "uploaded_part"
def _write_json(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
def _run_json(
command: list[str],
*,
timeout_seconds: int,
stage: str,
) -> dict[str, Any]:
completed = subprocess.run(
command,
capture_output=True,
text=True,
timeout=timeout_seconds,
check=False,
)
if completed.returncode != 0:
detail = completed.stderr.strip() or completed.stdout.strip()
raise ReconstructionError(
f"{stage} failed with exit code {completed.returncode}: "
f"{detail[-2000:]}"
)
lines = [line for line in completed.stdout.splitlines() if line.strip()]
if not lines:
return {}
try:
return json.loads(lines[-1])
except json.JSONDecodeError as exc:
raise ReconstructionError(
f"{stage} returned non-JSON output: {lines[-1][-1000:]}"
) from exc
def _validate_designir_contract(payload: dict[str, Any]) -> dict[str, Any]:
required = {
"schema_version",
"designir_kind",
"model_id",
"units",
"document_status",
"reconstruction_mode",
"semantic_layer",
"surface_layer",
"edit_interface",
"validation_contract",
}
missing = sorted(required - set(payload))
if missing:
raise ReconstructionError(
f"DesignIR 3.0 is missing required fields: {', '.join(missing)}"
)
if payload["schema_version"] != "3.0":
raise ReconstructionError("Uploaded STEP did not produce DesignIR 3.0")
if payload["designir_kind"] != "independent_parametric_cad":
raise ReconstructionError("DesignIR is not source-independent")
if payload["validation_contract"].get("source_independence") is not True:
raise ReconstructionError("DesignIR source-independence contract is false")
serialized = json.dumps(payload, ensure_ascii=False).lower()
violations = [
token for token in FORBIDDEN_DESIGNIR_TOKENS if token in serialized
]
if violations:
raise ReconstructionError(
"DesignIR anti-cheat scan failed: " + ", ".join(violations)
)
return {
"schema_contract": "designir-3.0",
"schema_path": str(DESIGNIR_SCHEMA),
"required_fields_present": True,
"source_independence_declared": True,
"anti_cheat_pass": True,
"forbidden_token_matches": 0,
}
def _candidate_parameters(
payload: dict[str, Any], maximum: int
) -> list[tuple[str, float]]:
parameters = payload.get("semantic_layer", {}).get("parameters", {})
names = payload.get("edit_interface", {}).get("semantic_parameters", [])
feature_candidates: list[tuple[str, float]] = []
fallback: tuple[str, float] | None = None
for name in names:
parameter = parameters.get(name)
if not isinstance(parameter, dict):
continue
if parameter.get("edit_state") not in EXECUTABLE_EDIT_STATES:
continue
try:
old_value = float(parameter["value"])
except (KeyError, TypeError, ValueError):
continue
candidate = (name, old_value * 1.1)
if name == "overall_scale":
fallback = candidate
else:
feature_candidates.append(candidate)
selected = feature_candidates[: max(0, maximum)]
if fallback is not None:
selected.append(fallback)
return selected
def _surfaceir_experience_ids() -> set[str]:
if not EXPERIENCE_LIBRARY.is_file():
return set()
try:
payload = json.loads(EXPERIENCE_LIBRARY.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return set()
policy = payload.get("policy", {})
if (
payload.get("library_kind") != "generalized_cad_experience"
or policy.get("surfaceir_consumable") is not True
or policy.get("instance_parameters_allowed") is not False
):
return set()
return {
item["id"]
for item in payload.get("experiences", [])
if isinstance(item, dict)
and isinstance(item.get("id"), str)
and set(item.get("scope", [])).intersection(
{"surfaceir_reconstruction", "surfaceir_semantic_editing"}
)
}
def _applied_experience_ids(
available: set[str], accepted_parameters: list[dict[str, Any]]
) -> list[str]:
applied = {
experience_id
for experience_id in (
"failure_repair.surface_topology.bind_face_pcurves_and_group_shells",
"failure_repair.surface_topology.preserve_vertex_tolerance",
)
if experience_id in available
}
names = {parameter["name"] for parameter in accepted_parameters}
if any("hole_diameter" in name or "bore_diameter" in name for name in names):
applied.update(
experience_id
for experience_id in (
"edit_strategy.semantic_cylindrical_cut.enlarge_diameter",
"failure_repair.semantic_edit.merge_duplicate_cylindrical_targets",
"edit_strategy.semantic_edit.preserve_validated_geometry_strategy",
)
if experience_id in available
)
if "overall_scale" in names:
experience_id = (
"edit_strategy.semantic_parameter.source_independent_overall_scale"
)
if experience_id in available:
applied.add(experience_id)
if names.intersection(
{"outer_diameter", "body_length", "plate_thickness"}
):
experience_id = (
"edit_strategy.semantic_parameter.primary_axis_affine_dimensions"
)
if experience_id in available:
applied.add(experience_id)
if any(name.startswith("overall_size_") for name in names):
experience_id = (
"edit_strategy.semantic_parameter.canonical_directional_envelope"
)
if experience_id in available:
applied.add(experience_id)
return sorted(applied)
def _persist_edit_results(
designir_path: Path,
attempts: list[dict[str, Any]],
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
payload = json.loads(designir_path.read_text(encoding="utf-8"))
parameters = payload.get("semantic_layer", {}).get("parameters", {})
accepted_parameters: list[dict[str, Any]] = []
attempted_names = set()
for attempt in attempts:
name = attempt["parameter"]
attempted_names.add(name)
parameter = parameters[name]
old_value = float(parameter["value"])
requested_value = float(attempt["requested_value"])
if attempt["accepted"]:
parameter["editable"] = True
parameter["edit_state"] = "validated_executable_binding"
parameter["validated_range"] = {
"direction": "increase",
"minimum_tested_inclusive": old_value,
"maximum_tested_inclusive": requested_value,
"acceptance_contract": "designir_3_semantic_edit_acceptance",
}
accepted_parameters.append(
{
"name": name,
"semantic_role": parameter.get("semantic_role", name),
"value": parameter["value"],
"unit": parameter.get("unit", "mm"),
"validated_range": parameter["validated_range"],
"confidence": parameter.get("confidence"),
}
)
else:
parameter["editable"] = False
parameter["edit_state"] = "rejected_by_runtime_validation"
parameter.pop("validated_range", None)
payload["edit_interface"]["semantic_parameters"] = sorted(
parameter["name"] for parameter in accepted_parameters
)
payload["reconstruction_strategy"] = {
"boundary_strategy": "exact_3d_pcurve",
"selection_status": "geometry_validated",
}
_write_json(designir_path, payload)
return payload, accepted_parameters
def reconstruct_uploaded_step(
source: Path,
task_dir: Path,
*,
request: str,
maximum_parameters: int = 8,
timeout_seconds: int = 180,
) -> dict[str, Any]:
source = source.expanduser().resolve()
task_dir = task_dir.expanduser().resolve()
if not source.is_file():
raise ReconstructionError(f"Uploaded STEP does not exist: {source}")
if source.suffix.lower() not in {".step", ".stp"}:
raise ReconstructionError("Uploaded reconstruction source must be STEP/STP")
if source == task_dir or task_dir == source.parent:
raise ReconstructionError(
"Task directory must be separate from the uploaded STEP directory"
)
if source == task_dir or task_dir in source.parents:
raise ReconstructionError(
"Task directory cannot contain the uploaded teacher STEP"
)
if not SURFACEIR_PIPELINE.is_file():
raise ReconstructionError(
f"DesignIR 3.0 runtime is missing: {SURFACEIR_PIPELINE}"
)
task_dir.mkdir(parents=True, exist_ok=True)
stem = _safe_stem(source)
designir_path = task_dir / f"{stem}.designir.json"
rebuilt_path = task_dir / f"{stem}.reconstructed.step"
validation_dir = task_dir / "validation"
geometry_report_path = validation_dir / "geometry.json"
edit_report_path = validation_dir / "edits.json"
parameters_path = task_dir / "parameters.json"
task_manifest_path = task_dir / "cad-task.json"
collisions = [
path
for path in (
designir_path,
rebuilt_path,
parameters_path,
task_manifest_path,
)
if path.exists()
]
if collisions:
raise ReconstructionError(
"Refusing to overwrite existing task artifacts: "
+ ", ".join(str(path) for path in collisions)
)
runtime = _runtime_python()
common = [str(runtime), str(SURFACEIR_PIPELINE)]
available_experience_ids = _surfaceir_experience_ids()
boundary_strategy = (
"exact_3d_pcurve"
if (
"failure_repair.surface_topology.bind_face_pcurves_and_group_shells"
in available_experience_ids
)
else "exact_3d"
)
extraction = _run_json(
common
+ [
"extract",
str(source),
"--output",
str(designir_path),
],
timeout_seconds=timeout_seconds,
stage="DesignIR 3.0 extraction",
)
payload = json.loads(designir_path.read_text(encoding="utf-8"))
contract_checks = _validate_designir_contract(payload)
# The rebuild worker receives no teacher path. It can regenerate the model
# from the DesignIR artifact alone.
_run_json(
common
+ [
"rebuild",
str(designir_path),
"--output",
str(rebuilt_path),
"--boundary-strategy",
boundary_strategy,
],
timeout_seconds=timeout_seconds,
stage="source-independent SurfaceIR rebuild",
)
manifest_path = task_dir / "manifest.json"
_write_json(
manifest_path,
{
"schema_version": "1.0",
"input_count": 1,
"rows": [
{
"source_name": source.name,
"output_name": designir_path.name,
"state": "extracted",
}
],
},
)
validation_dir.mkdir(parents=True, exist_ok=True)
geometry_work_dir = validation_dir / ".geometry-work"
geometry_result = _run_json(
common
+ [
"validate-folder",
str(source.parent),
"--designir-dir",
str(task_dir),
"--rebuilt-dir",
str(geometry_work_dir),
"--limit",
"1",
"--boolean",
"--boundary-strategy",
boundary_strategy,
],
timeout_seconds=timeout_seconds,
stage="Acceptance-Agent geometry validation",
)
raw_geometry_report = geometry_work_dir / "validation-report.json"
geometry_report = json.loads(raw_geometry_report.read_text(encoding="utf-8"))
_write_json(geometry_report_path, geometry_report)
geometry_pass = geometry_report.get("geometry_pass_count") == 1
if not geometry_pass:
raise ReconstructionError(
"Independent reconstruction did not satisfy the geometry contract; "
f"see {geometry_report_path}"
)
shutil.rmtree(geometry_work_dir)
attempts: list[dict[str, Any]] = []
if payload["document_status"] == "geometry_present":
for name, requested_value in _candidate_parameters(
payload, maximum_parameters
):
safe_name = _safe_stem(Path(name))
attempt_dir = validation_dir / "parameter-tests" / safe_name
try:
result = _run_json(
common
+ [
"validate-edit",
str(source),
str(designir_path),
"--parameter",
name,
"--value",
str(requested_value),
"--output-dir",
str(attempt_dir),
],
timeout_seconds=timeout_seconds,
stage=f"parameter validation ({name})",
)
report = json.loads(
(attempt_dir / "acceptance-report.json").read_text(
encoding="utf-8"
)
)
attempts.append(
{
"parameter": name,
"requested_value": requested_value,
"accepted": bool(report.get("accepted")),
"state": report.get("state"),
"report": str(
attempt_dir / "acceptance-report.json"
),
"worker": result,
}
)
except (ReconstructionError, subprocess.TimeoutExpired) as exc:
attempts.append(
{
"parameter": name,
"requested_value": requested_value,
"accepted": False,
"state": "worker_failed",
"error": str(exc),
}
)
payload, accepted_parameters = _persist_edit_results(
designir_path, attempts
)
payload["reconstruction_strategy"] = {
"boundary_strategy": boundary_strategy,
"selection_status": "geometry_validated",
}
_write_json(designir_path, payload)
applied_experience_ids = _applied_experience_ids(
available_experience_ids, accepted_parameters
)
edit_report = {
"schema_version": "1.0",
"report_kind": "cad_router_runtime_parameter_acceptance",
"attempt_count": len(attempts),
"accepted_count": sum(row["accepted"] for row in attempts),
"attempts": attempts,
}
_write_json(edit_report_path, edit_report)
parameter_catalog = {
"schema_version": "1.0",
"model_id": payload["model_id"],
"document_status": payload["document_status"],
"parameter_count": len(accepted_parameters),
"parameters": accepted_parameters,
"policy": "Only parameters that passed an isolated perturbation test are exposed.",
}
_write_json(parameters_path, parameter_catalog)
task_manifest = {
"schema_version": "1.0",
"request": request,
"operation": "uploaded_step_reconstruction",
"route": {
"router": "cad-router",
"runtime": "DesignIR 3.0 / SurfaceIR",
"selected_backend": "build123d",
"execution_adapter": "surfaceir_occt",
"source_of_truth": designir_path.name,
},
"source": {
"format": source.suffix.lower().lstrip("."),
"role": "teacher_and_acceptance_truth_only",
"embedded_in_output": False,
},
"artifacts": [
{"path": rebuilt_path.name, "role": "primary_step"},
{"path": designir_path.name, "role": "editable_designir"},
{"path": parameters_path.name, "role": "validated_parameters"},
{
"path": str(geometry_report_path.relative_to(task_dir)),
"role": "geometry_acceptance",
},
{
"path": str(edit_report_path.relative_to(task_dir)),
"role": "parameter_acceptance",
},
],
"experience": {
"library": (
str(EXPERIENCE_LIBRARY)
if EXPERIENCE_LIBRARY.is_file()
else None
),
"runtime_policy": "promoted_generalized_experience_only",
"available_method_count": len(available_experience_ids),
"methods_applied": applied_experience_ids,
},
"validation": {
"geometry_accepted": True,
"source_independent_rebuild": True,
"anti_cheat_pass": True,
"validated_parameter_count": len(accepted_parameters),
},
"edit_command_template": [
str(runtime),
str(SURFACEIR_PIPELINE),
"edit",
designir_path.name,
"--parameter",
"<validated-parameter-name>",
"--value",
"<value-within-validated-range>",
"--output-designir",
"<edited.designir.json>",
"--output-step",
"<edited.step>",
],
}
_write_json(task_manifest_path, task_manifest)
manifest_path.unlink(missing_ok=True)
return {
"schema_version": "1.0",
"status": (
"accepted"
if payload["document_status"] == "geometry_present"
else "accepted_empty_source"
),
"task_dir": str(task_dir),
"designir": str(designir_path),
"rebuilt_step": str(rebuilt_path),
"parameters": str(parameters_path),
"task_manifest": str(task_manifest_path),
"geometry_report": str(geometry_report_path),
"edit_report": str(edit_report_path),
"geometry_accepted": True,
"document_status": payload["document_status"],
"validated_parameter_count": len(accepted_parameters),
"validated_parameter_names": [
parameter["name"] for parameter in accepted_parameters
],
"experience": {
"available_method_count": len(available_experience_ids),
"methods_applied": applied_experience_ids,
},
"source_independence": {
"rebuild_command_received_teacher_path": False,
**contract_checks,
},
"stages": {
"extraction": extraction,
"geometry_validation": geometry_result,
},
}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", type=Path, required=True)
parser.add_argument("--task-dir", type=Path, required=True)
parser.add_argument("--request", default="重建上传的 STEP")
parser.add_argument("--maximum-parameters", type=int, default=8)
parser.add_argument("--timeout-seconds", type=int, default=180)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
result = reconstruct_uploaded_step(
args.source,
args.task_dir,
request=args.request,
maximum_parameters=args.maximum_parameters,
timeout_seconds=args.timeout_seconds,
)
except (ReconstructionError, subprocess.TimeoutExpired) as exc:
print(
json.dumps(
{"status": "failed", "error": str(exc)},
ensure_ascii=False,
),
file=sys.stderr,
)
return 1
print(json.dumps(result, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+126 -7
View File
@@ -5,6 +5,7 @@ import argparse
import importlib.util
import json
import os
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
@@ -12,6 +13,7 @@ from pathlib import Path
SKILL_ROOT = Path(__file__).resolve().parents[1]
REGISTRY_PATH = Path(__file__).with_name("capabilities.json")
RECONSTRUCTION_ADAPTER = Path(__file__).with_name("reconstruct_step.py")
@dataclass
@@ -335,8 +337,10 @@ def load_experience_library(
path: Path | None,
family: str | None = None,
features: list[str] | None = None,
consumer: str = "router",
) -> dict[str, object] | None:
"""Load promoted generalized methods; reject case parameters and coordinates."""
explicit_library = path is not None
candidate = path or default_experience_library()
if candidate is None:
return None
@@ -358,15 +362,26 @@ def load_experience_library(
or policy.get("single_case_promotion_allowed") is not False
):
raise ValueError("CAD experience library policy permits instance-answer leakage")
if (
policy.get("llm_semantic_review_required") is not True
or policy.get("draft_evidence_verified") is not True
or policy.get("router_consumable") is not True
):
reviewed = (
policy.get("llm_semantic_review_required") is True
and policy.get("draft_evidence_verified") is True
)
consumer_flag = (
"surfaceir_consumable"
if consumer == "surfaceir"
else "router_consumable"
)
if not reviewed:
raise ValueError(
"CAD experience library was not produced by LLM semantic review "
"plus deterministic evidence verification"
)
if policy.get(consumer_flag) is not True:
if not explicit_library:
return None
raise ValueError(
f"CAD experience library is not approved for {consumer}"
)
forbidden = _find_forbidden_experience_key(payload)
if forbidden:
raise ValueError(f"CAD experience library contains forbidden instance key: {forbidden}")
@@ -388,6 +403,12 @@ def load_experience_library(
if not isinstance(item, dict):
continue
scope = set(item.get("scope", []))
if consumer == "surfaceir":
if scope.intersection(
{"surfaceir_reconstruction", "surfaceir_semantic_editing"}
):
selected.append(item)
continue
if "global" not in scope:
if not compatible_scopes or scope.isdisjoint(compatible_scopes):
continue
@@ -402,6 +423,11 @@ def load_experience_library(
"compatible_scopes": sorted(compatible_scopes),
"requested_features": sorted(requested_features),
"experiences": selected,
"selection_policy": (
"surfaceir_runtime_conditioned"
if consumer == "surfaceir"
else "request_family_and_feature_match"
),
"policy": {
"contains_instance_parameters": False,
"contains_absolute_coordinates": False,
@@ -429,6 +455,7 @@ def route(args: argparse.Namespace) -> dict[str, object]:
args.experience_library,
experience_family,
experience_features,
"surfaceir" if teacher_step_reconstruction else "router",
)
needs_source_classification = bool(edit_context and not experience_family)
scores = {
@@ -480,7 +507,12 @@ def route(args: argparse.Namespace) -> dict[str, object]:
registry = json.loads(REGISTRY_PATH.read_text(encoding="utf-8"))
backend_info = registry["backends"][selected.name]
source_of_truth = "per-part DesignIR 2.0 with an isolated backend compiler"
editable_contract = "DesignIR 3.0"
source_of_truth = (
"per-part DesignIR 3.0 with an isolated SurfaceIR compiler"
if teacher_step_reconstruction
else "per-part semantic DesignIR 3.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] = {
@@ -499,7 +531,7 @@ def route(args: argparse.Namespace) -> dict[str, object]:
"workflow_profiles": profiles,
"artifact_contract": {
"primary_format": "step",
"editable_contract": "DesignIR 2.0",
"editable_contract": editable_contract,
},
"source_of_truth": source_of_truth,
"experience_context": experience_context,
@@ -604,9 +636,72 @@ def route(args: argparse.Namespace) -> dict[str, object]:
},
"availability": probe_backends() if args.probe else None,
}
if teacher_step_reconstruction:
result["execution_adapter"] = {
"path": str(RECONSTRUCTION_ADAPTER),
"runtime": "DesignIR 3.0 / SurfaceIR",
"automatic_geometry_acceptance": True,
"automatic_parameter_acceptance": True,
}
return result
def execute_reconstruction(
args: argparse.Namespace, result: dict[str, object]
) -> dict[str, object]:
edit_context = result.get("design_plan", {}).get("edit_context")
if not isinstance(edit_context, dict) or edit_context.get(
"modification_mode"
) != "private_evidence_to_isolated_designir_reconstruction":
raise ValueError(
"--execute currently requires --edit-source with one STEP/STP file"
)
if args.edit_source is None or args.task_dir is None:
raise ValueError(
"--execute requires both --edit-source and --task-dir"
)
runtime = os.environ.get("CAD_ROUTER_PYTHON", "").strip()
workspace_python = (
SKILL_ROOT.parents[2] / "text-to-cad" / ".venv" / "bin" / "python"
)
interpreter = (
Path(runtime).expanduser()
if runtime
else workspace_python
if workspace_python.is_file()
else Path(sys.executable)
)
command = [
str(interpreter),
str(RECONSTRUCTION_ADAPTER),
"--source",
str(args.edit_source),
"--task-dir",
str(args.task_dir),
"--request",
args.request,
"--maximum-parameters",
str(args.maximum_parameters),
"--timeout-seconds",
str(args.timeout_seconds),
]
completed = subprocess.run(
command,
capture_output=True,
text=True,
check=False,
)
if completed.returncode != 0:
detail = completed.stderr.strip() or completed.stdout.strip()
raise RuntimeError(
"cad-router STEP reconstruction failed: " + detail[-3000:]
)
lines = [line for line in completed.stdout.splitlines() if line.strip()]
if not lines:
raise RuntimeError("cad-router reconstruction returned no result")
return json.loads(lines[-1])
def explain(result: dict[str, object]) -> str:
lines = [
f"Selected backend: {result['selected_backend']}",
@@ -665,12 +760,36 @@ def build_parser() -> argparse.ArgumentParser:
)
parser.add_argument("--explain", action="store_true", help="Print a human-readable decision instead of JSON.")
parser.add_argument("--manifest", type=Path, help="Also write the route decision as JSON to this explicit path.")
parser.add_argument(
"--execute",
action="store_true",
help="Execute an uploaded STEP reconstruction after routing.",
)
parser.add_argument(
"--task-dir",
type=Path,
help="Explicit output directory for --execute.",
)
parser.add_argument(
"--maximum-parameters",
type=int,
default=8,
help="Maximum feature-specific edit candidates to validate; overall_scale is also tested as fallback.",
)
parser.add_argument(
"--timeout-seconds",
type=int,
default=180,
help="Timeout for each reconstruction or acceptance worker.",
)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
result = route(args)
if args.execute:
result["execution"] = execute_reconstruction(args, result)
payload = json.dumps(result, ensure_ascii=False, indent=2) + "\n"
if args.manifest:
args.manifest.parent.mkdir(parents=True, exist_ok=True)
@@ -0,0 +1,82 @@
from __future__ import annotations
import importlib.util
import json
import sys
import tempfile
import unittest
from pathlib import Path
from build123d import Box, export_step
ADAPTER_PATH = (
Path(__file__).resolve().parents[4]
/ "skills"
/ "cad-router"
/ "scripts"
/ "reconstruct_step.py"
)
SPEC = importlib.util.spec_from_file_location(
"cad_router_reconstruct_step", ADAPTER_PATH
)
assert SPEC is not None and SPEC.loader is not None
adapter = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = adapter
SPEC.loader.exec_module(adapter)
class UploadedStepRuntimeTests(unittest.TestCase):
def test_uploaded_step_reconstructs_and_publishes_validated_parameter(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
upload_dir = root / "upload"
task_dir = root / "task"
upload_dir.mkdir()
source = upload_dir / "box.step"
export_step(Box(10, 20, 30), source)
result = adapter.reconstruct_uploaded_step(
source,
task_dir,
request="重建上传的 STEP",
maximum_parameters=0,
timeout_seconds=60,
)
self.assertEqual("accepted", result["status"])
self.assertTrue(result["geometry_accepted"])
self.assertFalse(
result["source_independence"][
"rebuild_command_received_teacher_path"
]
)
self.assertTrue(
result["source_independence"]["anti_cheat_pass"]
)
self.assertIn(
"failure_repair.surface_topology.bind_face_pcurves_and_group_shells",
result["experience"]["methods_applied"],
)
self.assertTrue(Path(result["rebuilt_step"]).is_file())
self.assertTrue(Path(result["designir"]).is_file())
self.assertFalse(
(task_dir / "validation" / ".geometry-work").exists()
)
parameters = json.loads(
Path(result["parameters"]).read_text(encoding="utf-8")
)
self.assertEqual(1, parameters["parameter_count"])
self.assertEqual(
"overall_scale", parameters["parameters"][0]["name"]
)
geometry = json.loads(
Path(result["geometry_report"]).read_text(encoding="utf-8")
)
self.assertEqual(1, geometry["geometry_pass_count"])
self.assertEqual(0, geometry["geometry_fail_count"])
if __name__ == "__main__":
unittest.main()
@@ -37,7 +37,7 @@ class CadRouterTests(unittest.TestCase):
self.assertEqual(
{
"primary_format": "step",
"editable_contract": "DesignIR 2.0",
"editable_contract": "DesignIR 3.0",
},
result["artifact_contract"],
)
@@ -345,6 +345,26 @@ class CadRouterTests(unittest.TestCase):
self.assertTrue(
plan["experience_query_plan"]["requires_source_inspection"]
)
self.assertEqual(
"DesignIR 3.0", result["artifact_contract"]["editable_contract"]
)
self.assertEqual(
"DesignIR 3.0 / SurfaceIR",
result["execution_adapter"]["runtime"],
)
self.assertEqual(
"surfaceir_runtime_conditioned",
result["experience_context"]["selection_policy"],
)
self.assertGreater(
len(result["experience_context"]["experiences"]), 0
)
def test_execute_requires_step_source_and_task_directory(self) -> None:
args = self.parse("生成一个法兰", "--execute")
result = route_module.route(args)
with self.assertRaisesRegex(ValueError, "requires --edit-source"):
route_module.execute_reconstruction(args, result)
def test_native_generator_is_preferred_for_modification(self) -> None:
result = route_module.route(