feat: add per-part CAD model specs

This commit is contained in:
Jerry
2026-07-23 15:12:28 +08:00
parent 304911a69f
commit a2f3ba6a90
35 changed files with 1351 additions and 85 deletions
+21 -76
View File
@@ -1,7 +1,7 @@
{
"schema_version": "2.0",
"library_kind": "generalized_cad_experience",
"generated_at": "2026-07-23T05:52:54+00:00",
"generated_at": "2026-07-23T06:43:51+00:00",
"induction_mode": "llm_proposals_with_deterministic_evidence_verification",
"status": "collecting_evidence",
"policy": {
@@ -21,15 +21,15 @@
"family_count": 1
},
"experience_summary": {
"llm_proposal_count": 5,
"llm_proposal_count": 3,
"promoted_experience_count": 0,
"candidate_experience_count": 5,
"candidate_experience_count": 3,
"by_kind": [],
"by_scope": [],
"candidate_by_kind": [
{
"kind": "constraint",
"count": 3
"count": 1
},
{
"kind": "feature_motif",
@@ -44,10 +44,10 @@
"experiences": [],
"candidate_experiences": [
{
"id": "constraint.flanged_hub_adapter.pattern_radius_consistency",
"id": "constraint.patterned_prismatic_part.repeated_hole_radius_consistency",
"kind": "constraint",
"scope": [
"flanged_hub_adapter"
"patterned_prismatic_part"
],
"when": {
"features": [
@@ -58,9 +58,10 @@
]
},
"guidance": "Treat repeated axial holes as one radius-consistent semantic pattern rather than unrelated cuts.",
"semantic_rationale": "A single pattern relationship supports coherent count, placement, and interface changes.",
"semantic_rationale": "A shared pattern relationship makes count, placement, and hole-role edits coherent across dimensional variants.",
"support": 1,
"confidence": 1.0,
"check": "Confirm that all members intended to belong to the repeated axial hole pattern retain a common hole-radius role.",
"promotion_state": "candidate",
"required_support": 20,
"remaining_support": 19,
@@ -68,76 +69,22 @@
"consumer_policy": "visible_for_review_but_not_available_to_cad_router"
},
{
"id": "constraint.flanged_hub_adapter.shared_dominant_axis",
"kind": "constraint",
"scope": [
"flanged_hub_adapter"
],
"when": {
"features": [
"central_passage",
"coaxial_cylindrical_stack",
"rotational_body"
],
"relations": [
"coaxial_stack",
"dominant_axis_alignment"
]
},
"guidance": "Preserve a shared dominant axis across the rotational body, coaxial stack, and central passage.",
"semantic_rationale": "A stable common axis protects the primary rotational relationship when surrounding features are modified.",
"support": 1,
"confidence": 1.0,
"promotion_state": "candidate",
"required_support": 20,
"remaining_support": 19,
"required_confidence": 0.8,
"consumer_policy": "visible_for_review_but_not_available_to_cad_router"
},
{
"id": "constraint.flanged_hub_adapter.transition_axis_continuity",
"kind": "constraint",
"scope": [
"flanged_hub_adapter"
],
"when": {
"features": [
"coaxial_cylindrical_stack",
"conical_transition"
],
"relations": [
"transition_axis_continuity"
]
},
"guidance": "Keep transition geometry aligned with the axis of the adjoining coaxial feature stack.",
"semantic_rationale": "Axis continuity makes the transition behave as part of the rotational structure instead of an independent decorative feature.",
"support": 1,
"confidence": 1.0,
"promotion_state": "candidate",
"required_support": 20,
"remaining_support": 19,
"required_confidence": 0.8,
"consumer_policy": "visible_for_review_but_not_available_to_cad_router"
},
{
"id": "motif.flanged_hub_adapter.rotational_core_with_patterned_mounting",
"id": "motif.patterned_prismatic_part.passage_with_repeated_axial_holes",
"kind": "feature_motif",
"scope": [
"flanged_hub_adapter"
"patterned_prismatic_part"
],
"when": {
"features": [
"coaxial_cylindrical_stack",
"repeated_axial_hole_pattern",
"rotational_body"
"multi_axis_passage",
"repeated_axial_hole_pattern"
],
"relations": [
"coaxial_stack",
"repeated_radius_group"
]
},
"guidance": "Represent the rotational core and the mounting pattern as separate semantic feature groups.",
"semantic_rationale": "Separating the coaxial load path from repeated mounting features makes later edits and validation more localized.",
"guidance": "Represent the passage system and repeated axial holes as distinct semantic feature groups within the same prismatic part.",
"semantic_rationale": "Separating passage roles from the repeated mounting pattern allows either group to change while preserving the other groups geometric intent.",
"support": 1,
"confidence": 1.0,
"promotion_state": "candidate",
@@ -147,27 +94,25 @@
"consumer_policy": "visible_for_review_but_not_available_to_cad_router"
},
{
"id": "validation.flanged_hub_adapter.rotational_relationships",
"id": "validation.patterned_prismatic_part.feature_group_integrity",
"kind": "validation_rule",
"scope": [
"flanged_hub_adapter"
"patterned_prismatic_part"
],
"when": {
"features": [
"central_passage",
"repeated_axial_hole_pattern",
"rotational_body"
"multi_axis_passage",
"repeated_axial_hole_pattern"
],
"relations": [
"dominant_axis_alignment",
"repeated_radius_group"
]
},
"guidance": "Validate the rotational axis and mounting pattern as independent relationship groups after regeneration.",
"semantic_rationale": "Checking semantic relationships catches structural drift that isolated dimensional checks may miss.",
"guidance": "Validate the passage system and repeated axial hole pattern as separate relationship groups after regeneration.",
"semantic_rationale": "Relationship-group validation can detect unintended coupling or pattern drift without relying on one source parts dimensions.",
"support": 1,
"confidence": 1.0,
"check": "Confirm that coaxial features share their intended axis and repeated mounting features remain one coherent pattern.",
"check": "Confirm that passage features remain distinct from the repeated axial hole group and that the repeated group remains internally coherent.",
"promotion_state": "candidate",
"required_support": 20,
"remaining_support": 19,
@@ -0,0 +1,86 @@
{
"schema_version": "1.0",
"request": "Regenerate the current compact guided-cylinder housing using only promoted generalized CAD experience while preserving the existing parameterized geometry and enlarged central bore.",
"route": {
"selected_backend": "build123d",
"runner_skill": "cad",
"project": "text-to-cad",
"fallback_order": [
"simplecadapi",
"cadam"
],
"workflow_profiles": [
"requirement_refinement",
"visual_repair"
]
},
"source": {
"model_spec": "model-spec.json",
"path": "compact_guided_cylinder.py",
"format": "python",
"backend": "build123d"
},
"artifacts": [
{
"path": "compact_guided_cylinder.step",
"role": "primary"
},
{
"path": "compact_guided_cylinder.stl",
"role": "secondary"
}
],
"parameter_source": "model-spec.json",
"experience": {
"library": "../../../../cad-experience-library/library.json",
"context_kind": "generalized_cad_experience_query",
"query_scope": "global",
"promoted_methods_found": 0,
"methods_applied": [],
"result": "No promoted generalized methods matched; candidate experiences were intentionally excluded from generation."
},
"assumptions": [
"The image has no absolute dimensions; millimetre dimensions preserve visible proportions only.",
"The product is represented as a three-body compound: main extrusion plus two end caps.",
"The four top holes are modeled through the main body because the bottom view shows matching circles.",
"The four large circles on each end are treated as protruding tie-rod ends.",
"The central bore radius was doubled from 4.5 mm to 9 mm; the collar outer diameter was increased from 17 mm to 26 mm to retain a 4 mm radial wall.",
"This is concept geometry, not a pressure-rated or manufacturing-certified pneumatic cylinder."
],
"validation": [
{
"check": "STEP generation",
"result": "passed",
"detail": "Generated a labeled three-body assembly and STL sidecar from the parameterized Python source."
},
{
"check": "Bounding box",
"result": "passed",
"detail": "138 x 60 x 39 mm; dominant axis X."
},
{
"check": "Topology",
"result": "passed",
"detail": "61 faces and 156 edges after the side-groove repair."
},
{
"check": "Visual review",
"result": "passed",
"detail": "Reviewed regenerated isometric and top snapshots. Geometry remains visually consistent with the current parameterized source, including the enlarged central bore and four-hole pattern."
},
{
"check": "Experience isolation",
"result": "passed",
"detail": "The router reported zero generalized methods. No candidate experience, parser input, parser output, private case JSON, source dimensions, or coordinates were used."
},
{
"check": "JSON parameter rebuild",
"result": "passed",
"detail": "The STEP and STL were regenerated from the part-local model-spec.json through the native Python generator."
}
],
"viewer_links": [
"http://127.0.0.1:4179/?dir=%2FUsers%2Fjerry%2Flinkhand%2FCadSet%2Ftext-to-cad%2Fmodels%2Fcad-router-v1%2Fcompact_guided_cylinder_v1&file=compact_guided_cylinder.step",
"http://127.0.0.1:4179/?dir=%2FUsers%2Fjerry%2Flinkhand%2FCadSet%2Ftext-to-cad%2Fmodels%2Fcad-router-v1%2Fcompact_guided_cylinder_v1&file=compact_guided_cylinder.stl"
]
}
@@ -0,0 +1,197 @@
"""Image-inferred compact guided-cylinder housing.
The reference contains no dimensions, so the named dimensions below preserve
its visible proportions rather than claiming a production drawing.
Coordinate convention:
- X: long axis
- Y: width
- Z: height, with the lowest end-cap face at Z = 0
"""
import json
from pathlib import Path
from build123d import Align, Box, Compound, Cylinder, Pos
def _model_parameters():
spec_path = Path(__file__).with_name("model-spec.json")
payload = json.loads(spec_path.read_text(encoding="utf-8"))
return {
name: float(entry["value"])
for name, entry in payload["parameters"].items()
}
PARAMETERS = _model_parameters()
def _parameter(name: str) -> float:
return PARAMETERS[name]
# Overall proportions inferred from the reference image (millimetres).
CORE_LENGTH = _parameter("core_length")
CORE_WIDTH = _parameter("core_width")
CORE_BOTTOM_Z = _parameter("core_bottom_z")
CORE_HEIGHT = _parameter("core_height")
END_CAP_THICKNESS = _parameter("end_cap_thickness")
END_CAP_WIDTH = _parameter("end_cap_width")
END_CAP_HEIGHT = _parameter("end_cap_height")
# Two shallow longitudinal relief grooves on both long side faces.
SIDE_GROOVE_DEPTH = _parameter("side_groove_depth")
SIDE_GROOVE_HEIGHT = _parameter("side_groove_height")
SIDE_GROOVE_Z = (12.0, 24.0)
# Four inferred through mounting holes on the top/bottom pattern.
MOUNT_HOLE_DIAMETER = _parameter("mount_hole_diameter")
MOUNT_HOLE_X = _parameter("mount_hole_x")
MOUNT_HOLE_Y = _parameter("mount_hole_y")
# Central top port and raised collar.
PORT_BORE_DIAMETER = _parameter("port_bore_diameter")
PORT_COLLAR_OUTER_DIAMETER = _parameter("port_collar_outer_diameter")
PORT_COLLAR_HEIGHT = _parameter("port_collar_height")
# Four visible tie-rod ends on each end cap.
TIE_ROD_END_DIAMETER = _parameter("tie_rod_end_diameter")
TIE_ROD_END_PROTRUSION = _parameter("tie_rod_end_protrusion")
TIE_ROD_Y = _parameter("tie_rod_y")
TIE_ROD_Z = (7.0, 31.0)
# Small blind pilot/port visible on both end faces.
END_FACE_HOLE_DIAMETER = _parameter("end_face_hole_diameter")
END_FACE_HOLE_DEPTH = _parameter("end_face_hole_depth")
END_FACE_HOLE_Z = _parameter("end_face_hole_z")
END_CAP_CENTER_X = CORE_LENGTH / 2.0 + END_CAP_THICKNESS / 2.0
TOTAL_LENGTH = CORE_LENGTH + 2.0 * END_CAP_THICKNESS
VISIBLE_LENGTH = TOTAL_LENGTH + 2.0 * TIE_ROD_END_PROTRUSION
TOTAL_HEIGHT = END_CAP_HEIGHT + PORT_COLLAR_HEIGHT
def _x_cylinder(
length: float,
diameter: float,
center_x: float,
center_y: float,
center_z: float,
):
"""Create an X-axis cylinder centered at the requested location."""
return Pos(center_x, center_y, center_z) * Cylinder(
diameter / 2.0,
length,
align=(Align.CENTER, Align.CENTER, Align.CENTER),
rotation=(0.0, 90.0, 0.0),
)
def _make_core():
"""Main extrusion with side grooves, top pattern, and central through port."""
core = Pos(0.0, 0.0, CORE_BOTTOM_Z) * Box(
CORE_LENGTH,
CORE_WIDTH,
CORE_HEIGHT,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
# Cut two crisp grooves into each long side, stopping at the end-cap seams.
for groove_z in SIDE_GROOVE_Z:
groove_center_y = CORE_WIDTH / 2.0 - SIDE_GROOVE_DEPTH / 2.0 + 0.2
front_tool = Pos(0.0, groove_center_y, groove_z) * Box(
CORE_LENGTH + 1.0,
SIDE_GROOVE_DEPTH + 0.4,
SIDE_GROOVE_HEIGHT,
align=(Align.CENTER, Align.CENTER, Align.CENTER),
)
back_tool = Pos(0.0, -groove_center_y, groove_z) * Box(
CORE_LENGTH + 1.0,
SIDE_GROOVE_DEPTH + 0.4,
SIDE_GROOVE_HEIGHT,
align=(Align.CENTER, Align.CENTER, Align.CENTER),
)
core = core - front_tool - back_tool
# Through mounting pattern visible from top and bottom in the reference.
for x_pos in (-MOUNT_HOLE_X, MOUNT_HOLE_X):
for y_pos in (-MOUNT_HOLE_Y, MOUNT_HOLE_Y):
hole = Pos(x_pos, y_pos, -1.0) * Cylinder(
MOUNT_HOLE_DIAMETER / 2.0,
TOTAL_HEIGHT + 2.0,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
core = core - hole
port_hole = Pos(0.0, 0.0, -1.0) * Cylinder(
PORT_BORE_DIAMETER / 2.0,
TOTAL_HEIGHT + 2.0,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
core = core - port_hole
collar = Pos(0.0, 0.0, CORE_BOTTOM_Z + CORE_HEIGHT - 0.1) * Cylinder(
PORT_COLLAR_OUTER_DIAMETER / 2.0,
PORT_COLLAR_HEIGHT + 0.1,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
collar = collar - Pos(0.0, 0.0, CORE_BOTTOM_Z + CORE_HEIGHT - 1.0) * Cylinder(
PORT_BORE_DIAMETER / 2.0,
PORT_COLLAR_HEIGHT + 2.0,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
core = core + collar
core.label = "extruded_cylinder_body"
return core
def _make_end_cap(side: int):
"""End plate with four protruding tie-rod ends and one blind face hole."""
center_x = side * END_CAP_CENTER_X
cap = Pos(center_x, 0.0, 0.0) * Box(
END_CAP_THICKNESS,
END_CAP_WIDTH,
END_CAP_HEIGHT,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
boss_center_x = side * (
TOTAL_LENGTH / 2.0 + TIE_ROD_END_PROTRUSION / 2.0 - 0.5
)
for y_pos in (-TIE_ROD_Y, TIE_ROD_Y):
for z_pos in TIE_ROD_Z:
cap = cap + _x_cylinder(
TIE_ROD_END_PROTRUSION + 1.0,
TIE_ROD_END_DIAMETER,
boss_center_x,
y_pos,
z_pos,
)
# Blind end-face pilot: overshoot the outside face but stop inside the cap.
hole_center_x = side * (
TOTAL_LENGTH / 2.0 - END_FACE_HOLE_DEPTH / 2.0 + 0.5
)
cap = cap - _x_cylinder(
END_FACE_HOLE_DEPTH + 1.0,
END_FACE_HOLE_DIAMETER,
hole_center_x,
0.0,
END_FACE_HOLE_Z,
)
cap.label = "right_end_cap" if side > 0 else "left_end_cap"
return cap
def gen_step():
"""Return a labeled three-body STEP-ready compound."""
core = _make_core()
left_cap = _make_end_cap(-1)
right_cap = _make_end_cap(1)
model = Compound(children=[core, left_cap, right_cap])
model.label = "compact_guided_cylinder_v1"
return model
@@ -0,0 +1,149 @@
{
"schema_version": "1.0",
"model_spec_kind": "parametric_cad_model",
"model_id": "compact_guided_cylinder",
"units": "mm",
"reconstruction": {
"mode": "native_generator",
"generator": {
"path": "compact_guided_cylinder.py",
"entrypoint": "gen_step"
}
},
"parameters": {
"core_length": {
"value": 116.0,
"unit": "mm",
"role": "main_body_length"
},
"core_width": {
"value": 56.0,
"unit": "mm",
"role": "main_body_width"
},
"core_bottom_z": {
"value": 2.0,
"unit": "mm",
"role": "main_body_base_offset"
},
"core_height": {
"value": 34.0,
"unit": "mm",
"role": "main_body_height"
},
"end_cap_thickness": {
"value": 8.0,
"unit": "mm",
"role": "end_cap_thickness"
},
"end_cap_width": {
"value": 60.0,
"unit": "mm",
"role": "end_cap_width"
},
"end_cap_height": {
"value": 38.0,
"unit": "mm",
"role": "end_cap_height"
},
"side_groove_depth": {
"value": 1.8,
"unit": "mm",
"role": "longitudinal_groove_depth"
},
"side_groove_height": {
"value": 2.0,
"unit": "mm",
"role": "longitudinal_groove_height"
},
"mount_hole_diameter": {
"value": 4.5,
"unit": "mm",
"role": "mounting_hole_diameter"
},
"mount_hole_x": {
"value": 32.0,
"unit": "mm",
"role": "mounting_pattern_half_spacing_x"
},
"mount_hole_y": {
"value": 16.0,
"unit": "mm",
"role": "mounting_pattern_half_spacing_y"
},
"port_bore_diameter": {
"value": 18.0,
"unit": "mm",
"role": "central_port_bore_diameter"
},
"port_collar_outer_diameter": {
"value": 26.0,
"unit": "mm",
"role": "central_port_collar_outer_diameter"
},
"port_collar_height": {
"value": 3.0,
"unit": "mm",
"role": "central_port_collar_height"
},
"tie_rod_end_diameter": {
"value": 12.0,
"unit": "mm",
"role": "tie_rod_end_diameter"
},
"tie_rod_end_protrusion": {
"value": 3.0,
"unit": "mm",
"role": "tie_rod_end_protrusion"
},
"tie_rod_y": {
"value": 24.0,
"unit": "mm",
"role": "tie_rod_pattern_half_spacing_y"
},
"end_face_hole_diameter": {
"value": 5.5,
"unit": "mm",
"role": "end_face_blind_hole_diameter"
},
"end_face_hole_depth": {
"value": 6.0,
"unit": "mm",
"role": "end_face_blind_hole_depth"
},
"end_face_hole_z": {
"value": 14.0,
"unit": "mm",
"role": "end_face_blind_hole_height"
}
},
"feature_groups": [
{
"id": "main_body",
"kind": "prismatic_body"
},
{
"id": "mounting_pattern",
"kind": "repeated_axial_hole_pattern",
"parameters": [
"mount_hole_diameter",
"mount_hole_x",
"mount_hole_y"
]
},
{
"id": "central_port",
"kind": "raised_collar_with_passage",
"parameters": [
"port_bore_diameter",
"port_collar_outer_diameter",
"port_collar_height"
]
}
],
"modifications": [],
"outputs": {
"step": "compact_guided_cylinder.step",
"stl": "compact_guided_cylinder.stl"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

@@ -0,0 +1,37 @@
{
"input": "compact_guided_cylinder.step",
"mode": "view",
"outputs": [
{
"path": "review/iso.png",
"camera": "iso"
},
{
"path": "review/iso_opposite.png",
"camera": {
"direction": [
-1,
1,
-0.8
]
}
},
{
"path": "review/top.png",
"camera": "top"
},
{
"path": "review/front.png",
"camera": "front"
}
],
"display": {
"mode": "solid",
"projection": "orthographic"
},
"render": {
"viewLabels": true,
"padding": 0.12,
"sizeProfile": "diagnostic"
}
}
+10 -2
View File
@@ -1,6 +1,6 @@
---
name: cad-router
description: Route natural-language CAD, mechanical-part, assembly, 3D-printing, and robot-description requests across text-to-cad, SimpleCADAPI, and CADAM, then enforce shared generation, validation, requirement refinement, 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 the best modeling backend and preserve editable sources.
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.
---
# CAD Router
@@ -33,7 +33,13 @@ Choose a backend before modeling, keep its source as the editable authority, and
- `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.
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. Never change backend during an edit unless conversion is explicitly requested; modify the recorded source of truth.
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
@@ -67,4 +73,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/model-spec.md` when generating per-part parameter JSON,
converting STEP to a JSON-controlled model, or modifying an existing part.
- `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 work across four complementary projects."
default_prompt: "Use $cad-router to select and run the best CAD backend, validate the result, and open only the task outputs in CAD Viewer."
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."
@@ -72,6 +72,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`.
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.
## Backend adapter requirements
@@ -0,0 +1,95 @@
# 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 authors 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 or parameter references:
```json
{
"id": "central_bore",
"operation": "cut_cylinder",
"axis": "z",
"center": [0, 0, 0],
"diameter": {"parameter": "bore_diameter"},
"length": {"parameter": "bore_length"},
"enabled": true
}
```
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`.
@@ -0,0 +1,614 @@
#!/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 set(value) == {"parameter"}:
result = _parameter_value(spec, str(value["parameter"]))
else:
raise ModelSpecError(
f"{field} must be a number or {{\"parameter\": \"name\"}}"
)
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())
+14 -5
View File
@@ -263,7 +263,10 @@ def classify_edit_context(
suffix = source.suffix.lower() if source else ""
text = request.lower()
if suffix in {".py", ".scad", ".js", ".mjs"}:
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"}:
mode = "native_parameter_edit"
reason = "An editable generator exists; update its named parameters or feature source."
elif contains_any(text, MAJOR_EDIT_TERMS):
@@ -304,7 +307,7 @@ def classify_edit_context(
"preservation_contract": [
"Preserve the source coordinate frame unless the request explicitly changes it.",
"Preserve unmodified interfaces, datums, and feature relationships.",
"Prefer the native generator over inferred STEP reconstruction when both exist.",
"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.",
],
}
@@ -480,6 +483,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 = 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"
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] = {
@@ -498,7 +507,7 @@ def route(args: argparse.Namespace) -> dict[str, object]:
"workflow_profiles": profiles,
"downstream_skills": downstream,
"requested_outputs": outputs,
"source_of_truth": backend_info["source_of_truth"],
"source_of_truth": source_of_truth,
"experience_context": experience_context,
"design_plan": {
"plan_kind": "backend_neutral_parametric_design_plan",
@@ -506,7 +515,7 @@ def route(args: argparse.Namespace) -> dict[str, object]:
"part_family": experience_family or "unclassified",
"requested_feature_roles": sorted(set(experience_features)),
"selected_backend": selected.name,
"source_of_truth": backend_info["source_of_truth"],
"source_of_truth": source_of_truth,
"generalized_methods": [
{
key: item[key]
@@ -631,7 +640,7 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument(
"--edit-source",
type=Path,
help="Existing generator, STEP/STP, or private case JSON to modify.",
help="Existing model-spec.json, generator, or explicit STEP/STP to modify; private case JSON 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.")
@@ -0,0 +1,109 @@
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_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()