feat: add per-part CAD model specs
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 253 KiB |
|
After Width: | Height: | Size: 316 KiB |
|
After Width: | Height: | Size: 322 KiB |
|
After Width: | Height: | Size: 322 KiB |
|
After Width: | Height: | Size: 322 KiB |
|
After Width: | Height: | Size: 370 KiB |
|
After Width: | Height: | Size: 431 KiB |
|
After Width: | Height: | Size: 434 KiB |
|
After Width: | Height: | Size: 434 KiB |
|
After Width: | Height: | Size: 434 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
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"
|
||||
}
|
||||
}
|
||||
@@ -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 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`.
|
||||
@@ -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())
|
||||
@@ -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()
|
||||