diff --git a/cad-experience-library/library.json b/cad-experience-library/library.json index c13cfd5..066f90f 100644 --- a/cad-experience-library/library.json +++ b/cad-experience-library/library.json @@ -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 group’s 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 part’s 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, diff --git a/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/.compact_guided_cylinder.step.glb b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/.compact_guided_cylinder.step.glb new file mode 100644 index 0000000..67d3a66 --- /dev/null +++ b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/.compact_guided_cylinder.step.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dcd85e450d751ec65334e349eeb5b162c6e29bafb812a56f0de6031402e532d0 +size 781200 diff --git a/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/cad-task.json b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/cad-task.json new file mode 100644 index 0000000..52c9bf9 --- /dev/null +++ b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/cad-task.json @@ -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" + ] +} diff --git a/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/compact_guided_cylinder.py b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/compact_guided_cylinder.py new file mode 100644 index 0000000..2723bdc --- /dev/null +++ b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/compact_guided_cylinder.py @@ -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 diff --git a/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/compact_guided_cylinder.step b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/compact_guided_cylinder.step new file mode 100644 index 0000000..c497417 --- /dev/null +++ b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/compact_guided_cylinder.step @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ecddb63cecaa7ca0cda150f20d2550cabbbc518588567ed47c813d314fb3cf89 +size 193687 diff --git a/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/compact_guided_cylinder.stl b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/compact_guided_cylinder.stl new file mode 100644 index 0000000..0df4fea --- /dev/null +++ b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/compact_guided_cylinder.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:396d2374074670748c9992cbc551c463482c34aecd2b5b626acdaf9a64206acb +size 209284 diff --git a/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/model-spec.json b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/model-spec.json new file mode 100644 index 0000000..431093c --- /dev/null +++ b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/model-spec.json @@ -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" + } +} diff --git a/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/front_20260723T063333Z.png b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/front_20260723T063333Z.png new file mode 100644 index 0000000..d935447 Binary files /dev/null and b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/front_20260723T063333Z.png differ diff --git a/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/front_20260723T063457Z.png b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/front_20260723T063457Z.png new file mode 100644 index 0000000..076812c Binary files /dev/null and b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/front_20260723T063457Z.png differ diff --git a/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/front_20260723T064041Z.png b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/front_20260723T064041Z.png new file mode 100644 index 0000000..a2f04a1 Binary files /dev/null and b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/front_20260723T064041Z.png differ diff --git a/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/front_20260723T064740Z.png b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/front_20260723T064740Z.png new file mode 100644 index 0000000..a2f04a1 Binary files /dev/null and b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/front_20260723T064740Z.png differ diff --git a/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/front_20260723T070732Z.png b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/front_20260723T070732Z.png new file mode 100644 index 0000000..a2f04a1 Binary files /dev/null and b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/front_20260723T070732Z.png differ diff --git a/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/iso_20260723T063333Z.png b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/iso_20260723T063333Z.png new file mode 100644 index 0000000..d60e139 Binary files /dev/null and b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/iso_20260723T063333Z.png differ diff --git a/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/iso_20260723T063457Z.png b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/iso_20260723T063457Z.png new file mode 100644 index 0000000..982ce6b Binary files /dev/null and b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/iso_20260723T063457Z.png differ diff --git a/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/iso_20260723T064041Z.png b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/iso_20260723T064041Z.png new file mode 100644 index 0000000..f4d44a6 Binary files /dev/null and b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/iso_20260723T064041Z.png differ diff --git a/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/iso_20260723T064740Z.png b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/iso_20260723T064740Z.png new file mode 100644 index 0000000..f4d44a6 Binary files /dev/null and b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/iso_20260723T064740Z.png differ diff --git a/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/iso_20260723T070732Z.png b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/iso_20260723T070732Z.png new file mode 100644 index 0000000..f4d44a6 Binary files /dev/null and b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/iso_20260723T070732Z.png differ diff --git a/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/iso_opposite_20260723T063333Z.png b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/iso_opposite_20260723T063333Z.png new file mode 100644 index 0000000..93fb722 Binary files /dev/null and b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/iso_opposite_20260723T063333Z.png differ diff --git a/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/iso_opposite_20260723T063457Z.png b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/iso_opposite_20260723T063457Z.png new file mode 100644 index 0000000..51c7616 Binary files /dev/null and b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/iso_opposite_20260723T063457Z.png differ diff --git a/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/iso_opposite_20260723T064041Z.png b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/iso_opposite_20260723T064041Z.png new file mode 100644 index 0000000..df3f3c5 Binary files /dev/null and b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/iso_opposite_20260723T064041Z.png differ diff --git a/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/iso_opposite_20260723T064740Z.png b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/iso_opposite_20260723T064740Z.png new file mode 100644 index 0000000..df3f3c5 Binary files /dev/null and b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/iso_opposite_20260723T064740Z.png differ diff --git a/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/iso_opposite_20260723T070732Z.png b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/iso_opposite_20260723T070732Z.png new file mode 100644 index 0000000..df3f3c5 Binary files /dev/null and b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/iso_opposite_20260723T070732Z.png differ diff --git a/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/top_20260723T063333Z.png b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/top_20260723T063333Z.png new file mode 100644 index 0000000..64433de Binary files /dev/null and b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/top_20260723T063333Z.png differ diff --git a/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/top_20260723T063457Z.png b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/top_20260723T063457Z.png new file mode 100644 index 0000000..64433de Binary files /dev/null and b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/top_20260723T063457Z.png differ diff --git a/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/top_20260723T064041Z.png b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/top_20260723T064041Z.png new file mode 100644 index 0000000..fb24327 Binary files /dev/null and b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/top_20260723T064041Z.png differ diff --git a/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/top_20260723T064740Z.png b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/top_20260723T064740Z.png new file mode 100644 index 0000000..fb24327 Binary files /dev/null and b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/top_20260723T064740Z.png differ diff --git a/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/top_20260723T070732Z.png b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/top_20260723T070732Z.png new file mode 100644 index 0000000..fb24327 Binary files /dev/null and b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/review/top_20260723T070732Z.png differ diff --git a/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/snapshot-job.json b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/snapshot-job.json new file mode 100644 index 0000000..4e5e941 --- /dev/null +++ b/text-to-cad/models/cad-router-v1/compact_guided_cylinder_v1/snapshot-job.json @@ -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" + } +} diff --git a/text-to-cad/skills/cad-router/SKILL.md b/text-to-cad/skills/cad-router/SKILL.md index d8913af..9b9abe0 100644 --- a/text-to-cad/skills/cad-router/SKILL.md +++ b/text-to-cad/skills/cad-router/SKILL.md @@ -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 `. 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`. diff --git a/text-to-cad/skills/cad-router/agents/openai.yaml b/text-to-cad/skills/cad-router/agents/openai.yaml index 58fc528..eb8add3 100644 --- a/text-to-cad/skills/cad-router/agents/openai.yaml +++ b/text-to-cad/skills/cad-router/agents/openai.yaml @@ -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." diff --git a/text-to-cad/skills/cad-router/references/backend-contract.md b/text-to-cad/skills/cad-router/references/backend-contract.md index a300cc6..f90ff77 100644 --- a/text-to-cad/skills/cad-router/references/backend-contract.md +++ b/text-to-cad/skills/cad-router/references/backend-contract.md @@ -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 diff --git a/text-to-cad/skills/cad-router/references/model-spec.md b/text-to-cad/skills/cad-router/references/model-spec.md new file mode 100644 index 0000000..6d460db --- /dev/null +++ b/text-to-cad/skills/cad-router/references/model-spec.md @@ -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 author’s original + parameters or construction order. + +## Parametric modification layer + +Supported V1 operations are `add_box`, `cut_box`, `add_cylinder`, and +`cut_cylinder`. Values may be literals 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`. diff --git a/text-to-cad/skills/cad-router/scripts/model_spec.py b/text-to-cad/skills/cad-router/scripts/model_spec.py new file mode 100644 index 0000000..98d5f09 --- /dev/null +++ b/text-to-cad/skills/cad-router/scripts/model_spec.py @@ -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()) diff --git a/text-to-cad/skills/cad-router/scripts/route.py b/text-to-cad/skills/cad-router/scripts/route.py index d5e6ff7..0b9bb67 100755 --- a/text-to-cad/skills/cad-router/scripts/route.py +++ b/text-to-cad/skills/cad-router/scripts/route.py @@ -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.") diff --git a/text-to-cad/skills/cad-router/tests/test_model_spec.py b/text-to-cad/skills/cad-router/tests/test_model_spec.py new file mode 100644 index 0000000..86cbeba --- /dev/null +++ b/text-to-cad/skills/cad-router/tests/test_model_spec.py @@ -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()