Files
cadSet/designir-pipeline/scripts/designir_pipeline.py

1237 lines
48 KiB
Python

#!/usr/bin/env python3
"""Compile and validate semantic DesignIR 3.0 without teacher geometry.
Legacy DesignIR 2.0 documents remain accepted and can be migrated explicitly.
Surface-parametric DesignIR 3.0 documents are handled by surfaceir_pipeline.py.
"""
from __future__ import annotations
import argparse
import ast
import copy
import hashlib
import importlib.util
import json
import math
import re
import shutil
import sys
import tempfile
from pathlib import Path
from typing import Any
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
from designir_codec import (
DesignIRCodecError,
read_designir,
write_designir,
)
from build123d import (
Align,
Box,
CenterOf,
Cylinder,
Part,
Plane,
Pos,
export_step,
import_step,
)
SCHEMA_VERSION = "3.0"
DESIGNIR_KIND = "independent_parametric_cad"
LEGACY_SCHEMA_VERSION = "2.0"
LEGACY_DESIGNIR_KIND = "executable_parametric_design"
SUPPORTED_OPERATIONS = {
"extrude_circle",
"extrude_rectangle",
"add_cylinder",
"through_hole",
"polar_hole_pattern",
}
REQUIRED_TOP_LEVEL = {
"schema_version",
"designir_kind",
"model_id",
"family",
"units",
"reconstruction_status",
"coordinate_system",
"datums",
"parameters",
"expressions",
"sketches",
"constraints",
"features",
"patterns",
"attachments",
"construction_stages",
"edit_interface",
"validation_contract",
}
FORBIDDEN_KEYS = {
"source_step",
"teacher_step",
"source_path",
"brep",
"b_rep",
"step_text",
"mesh",
"triangles",
"vertices",
"face_refs",
"surface_ids",
"topology_refs",
"base_step",
}
FORBIDDEN_TEXT = (
"source.step",
"teacher.step",
".stp",
".step",
"import_step",
"read_step",
"stepcontrol_reader",
)
SAFE_EXPRESSION_NODES = (
ast.Expression,
ast.BinOp,
ast.UnaryOp,
ast.Add,
ast.Sub,
ast.Mult,
ast.Div,
ast.Pow,
ast.Mod,
ast.USub,
ast.UAdd,
ast.Constant,
ast.Name,
ast.Load,
)
class DesignIRError(ValueError):
pass
def read_json(path: Path) -> dict[str, Any]:
try:
value = read_designir(path)
except (OSError, json.JSONDecodeError, DesignIRCodecError) as exc:
raise DesignIRError(f"Cannot read JSON {path}: {exc}") from exc
if not isinstance(value, dict):
raise DesignIRError("DesignIR must be a JSON object")
return value
def write_json(path: Path, value: dict[str, Any]) -> None:
if path.name.endswith(".designir.json") and "surface_layer" in value:
write_designir(path, value)
return
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def find_forbidden(value: Any, trail: str = "$") -> str | None:
if isinstance(value, dict):
for key, child in value.items():
normalized = str(key).lower()
if normalized in FORBIDDEN_KEYS:
return f"{trail}.{key}"
found = find_forbidden(child, f"{trail}.{key}")
if found:
return found
elif isinstance(value, list):
for index, child in enumerate(value):
found = find_forbidden(child, f"{trail}[{index}]")
if found:
return found
elif isinstance(value, str):
lowered = value.lower()
if any(token in lowered for token in FORBIDDEN_TEXT):
return trail
return None
def _vector3(value: Any, label: str) -> None:
if (
not isinstance(value, list)
or len(value) != 3
or any(not isinstance(item, (int, float)) for item in value)
):
raise DesignIRError(f"{label} must be a three-number vector")
def _validate_compiler_payload(payload: dict[str, Any]) -> dict[str, Any]:
missing = sorted(REQUIRED_TOP_LEVEL - set(payload))
if missing:
raise DesignIRError("Missing DesignIR fields: " + ", ".join(missing))
if payload["schema_version"] != LEGACY_SCHEMA_VERSION:
raise DesignIRError("DesignIR schema_version must be 2.0")
if payload["designir_kind"] != LEGACY_DESIGNIR_KIND:
raise DesignIRError(f"designir_kind must be {LEGACY_DESIGNIR_KIND}")
if payload["units"] != "mm":
raise DesignIRError("Only millimetres are supported")
if payload["reconstruction_status"] not in {
"ready",
"partial",
"unsupported_feature_vocabulary",
}:
raise DesignIRError("Invalid reconstruction_status")
forbidden = find_forbidden(payload)
if forbidden:
raise DesignIRError(f"Teacher geometry dependency is forbidden at {forbidden}")
coordinate_system = payload["coordinate_system"]
if not isinstance(coordinate_system, dict):
raise DesignIRError("coordinate_system must be an object")
for name in ("origin", "x_axis", "y_axis", "z_axis"):
_vector3(coordinate_system.get(name), f"coordinate_system.{name}")
parameters = payload["parameters"]
if not isinstance(parameters, dict):
raise DesignIRError("parameters must be an object")
for name, parameter in parameters.items():
if not isinstance(parameter, dict) or not isinstance(
parameter.get("value"), (int, float)
):
raise DesignIRError(f"Parameter {name} requires a numeric value")
if parameter.get("unit") not in {"mm", "deg", "count", "ratio"}:
raise DesignIRError(f"Parameter {name} has an unsupported unit")
if not isinstance(parameter.get("editable"), bool):
raise DesignIRError(f"Parameter {name} requires editable=true/false")
features = payload["features"]
if not isinstance(features, list) or not features:
raise DesignIRError("features must be a non-empty array")
feature_ids: set[str] = set()
for feature in features:
if not isinstance(feature, dict):
raise DesignIRError("Every feature must be an object")
feature_id = str(feature.get("id", "")).strip()
if not feature_id or feature_id in feature_ids:
raise DesignIRError("Every feature requires a unique id")
feature_ids.add(feature_id)
operation = feature.get("operation")
if operation not in SUPPORTED_OPERATIONS:
raise DesignIRError(f"Unsupported DesignIR operation: {operation}")
editable = payload["edit_interface"].get("editable_parameters", [])
unknown = sorted(set(editable) - set(parameters))
if unknown:
raise DesignIRError("Unknown editable parameters: " + ", ".join(unknown))
disconnected = sorted(
name
for name in editable
if not parameters[name].get("editable")
or _parameter_reference_count(features, name) == 0
)
if disconnected:
raise DesignIRError(
"Editable parameters must drive at least one feature: "
+ ", ".join(disconnected)
)
for test in payload["validation_contract"].get("perturbations", []):
if test.get("parameter") not in parameters:
raise DesignIRError(
f"Perturbation references unknown parameter {test.get('parameter')}"
)
if not str(test.get("expected_change") or "").strip():
raise DesignIRError("Every perturbation requires expected_change")
return payload
def migrate_designir_2_to_3(payload: dict[str, Any]) -> dict[str, Any]:
"""Convert the legacy flat semantic contract into unified DesignIR 3.0."""
legacy = _validate_compiler_payload(copy.deepcopy(payload))
editable = list(legacy["edit_interface"].get("editable_parameters", []))
validation = legacy["validation_contract"]
semantic_layer = {
"reconstruction_status": legacy["reconstruction_status"],
"coordinate_system": legacy["coordinate_system"],
"datums": legacy["datums"],
"parameters": legacy["parameters"],
"expressions": legacy["expressions"],
"sketches": legacy["sketches"],
"constraints": legacy["constraints"],
"features": legacy["features"],
"patterns": legacy["patterns"],
"attachments": legacy["attachments"],
"construction_stages": legacy["construction_stages"],
"missing_capabilities": legacy.get("missing_capabilities", []),
"applied_experience_ids": legacy.get("applied_experience_ids", []),
}
return {
"schema_version": SCHEMA_VERSION,
"designir_kind": DESIGNIR_KIND,
"model_id": legacy["model_id"],
"family": legacy["family"],
"units": legacy["units"],
"document_status": "geometry_present",
"reconstruction_mode": "fully_semantic_parametric",
"authoring_mode": "semantic_feature_program",
"backend_hint": legacy.get("backend_hint", "build123d"),
"semantic_layer": semantic_layer,
"edit_interface": {
"semantic_parameters": editable,
"surface_parameter_groups": [],
"modification_levels": ["semantic_feature"],
"preserved_interfaces": legacy["edit_interface"].get(
"preserved_interfaces", []
),
},
"validation_contract": {
"source_independence": True,
"geometry_checks": validation.get(
"geometry_checks",
["solid_count", "bounding_box", "volume", "center_of_mass"],
),
"edit_checks": validation.get(
"edit_checks",
["parameter_perturbation", "constraint_preservation"],
),
"invariants": validation.get("invariants", []),
"perturbations": validation.get("perturbations", []),
"thresholds": validation.get("thresholds", {}),
},
}
def compiler_payload(payload: dict[str, Any]) -> dict[str, Any]:
"""Return the stable flat representation consumed by both CAD backends."""
version = payload.get("schema_version")
if version == LEGACY_SCHEMA_VERSION:
return _validate_compiler_payload(copy.deepcopy(payload))
if version != SCHEMA_VERSION or payload.get("designir_kind") != DESIGNIR_KIND:
raise DesignIRError(
"DesignIR must be legacy 2.0 or independent_parametric_cad 3.0"
)
mode = payload.get("reconstruction_mode")
if mode == "surface_parametric":
raise DesignIRError(
"Surface-parametric DesignIR must be rebuilt with surfaceir_pipeline.py"
)
if mode not in {
"fully_semantic_parametric",
"hybrid_semantic_surface_parametric",
}:
raise DesignIRError(f"Unsupported DesignIR 3.0 reconstruction_mode: {mode}")
if payload.get("units") != "mm":
raise DesignIRError("Only millimetres are supported")
forbidden = find_forbidden(payload.get("semantic_layer", {}))
if forbidden:
raise DesignIRError(f"Teacher geometry dependency is forbidden at {forbidden}")
semantic = payload.get("semantic_layer")
if not isinstance(semantic, dict):
raise DesignIRError("semantic_layer must be an object")
edit_interface = payload.get("edit_interface", {})
validation = payload.get("validation_contract", {})
flat = {
"schema_version": LEGACY_SCHEMA_VERSION,
"designir_kind": LEGACY_DESIGNIR_KIND,
"model_id": payload.get("model_id"),
"family": payload.get("family", "general_mechanical_part"),
"units": payload.get("units"),
"reconstruction_status": semantic.get("reconstruction_status", "ready"),
"coordinate_system": semantic.get("coordinate_system"),
"datums": semantic.get("datums", {}),
"parameters": semantic.get("parameters", {}),
"expressions": semantic.get("expressions", {}),
"sketches": semantic.get("sketches", []),
"constraints": semantic.get("constraints", []),
"features": semantic.get("features", []),
"patterns": semantic.get("patterns", []),
"attachments": semantic.get("attachments", []),
"construction_stages": semantic.get("construction_stages", []),
"edit_interface": {
"editable_parameters": edit_interface.get("semantic_parameters", []),
"preserved_interfaces": edit_interface.get("preserved_interfaces", []),
},
"validation_contract": {
"invariants": validation.get("invariants", []),
"perturbations": validation.get("perturbations", []),
"thresholds": validation.get("thresholds", {}),
},
"backend_hint": payload.get("backend_hint", "build123d"),
"missing_capabilities": semantic.get("missing_capabilities", []),
"applied_experience_ids": semantic.get("applied_experience_ids", []),
}
return _validate_compiler_payload(flat)
def validate_designir(payload: dict[str, Any]) -> dict[str, Any]:
"""Validate either supported version and return its compiler representation."""
return compiler_payload(payload)
def _safe_expression(expression: str, values: dict[str, float]) -> float:
try:
tree = ast.parse(expression, mode="eval")
except SyntaxError as exc:
raise DesignIRError(f"Invalid expression {expression!r}") from exc
if not all(isinstance(node, SAFE_EXPRESSION_NODES) for node in ast.walk(tree)):
raise DesignIRError(f"Unsafe expression {expression!r}")
unknown = {
node.id
for node in ast.walk(tree)
if isinstance(node, ast.Name) and node.id not in values
}
if unknown:
raise DesignIRError("Unknown expression symbols: " + ", ".join(sorted(unknown)))
return float(eval(compile(tree, "<designir-expression>", "eval"), {"__builtins__": {}}, values))
def parameter_values(payload: dict[str, Any]) -> dict[str, float]:
values = {
name: float(parameter["value"])
for name, parameter in payload["parameters"].items()
}
pending = dict(payload.get("expressions", {}))
while pending:
progressed = False
for name, expression in list(pending.items()):
try:
values[name] = _safe_expression(str(expression), values)
except DesignIRError as exc:
if "Unknown expression symbols" in str(exc):
continue
raise
del pending[name]
progressed = True
if not progressed:
raise DesignIRError(
"Unresolved or cyclic expressions: " + ", ".join(sorted(pending))
)
return values
def resolve_value(value: Any, values: dict[str, float], label: str) -> float:
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, dict) and set(value) == {"parameter"}:
name = str(value["parameter"])
if name not in values:
raise DesignIRError(f"{label} references unknown parameter {name}")
return values[name]
if isinstance(value, dict) and set(value) == {"expression"}:
name = str(value["expression"])
if name not in values:
raise DesignIRError(f"{label} references unknown expression {name}")
return values[name]
raise DesignIRError(f"{label} must be a number, parameter, or expression")
def resolve_center(
value: Any, values: dict[str, float], label: str
) -> list[float]:
if not isinstance(value, list) or len(value) != 3:
raise DesignIRError(f"{label} must contain three values")
return [
resolve_value(component, values, f"{label}[{index}]")
for index, component in enumerate(value)
]
def feature_axis(payload: dict[str, Any], feature: dict[str, Any]) -> list[float]:
reference = feature.get("axis", "primary_axis")
if isinstance(reference, list):
axis = reference
elif reference in {"x", "y", "z"}:
axis = {
"x": [1.0, 0.0, 0.0],
"y": [0.0, 1.0, 0.0],
"z": [0.0, 0.0, 1.0],
}[reference]
else:
datum = payload.get("datums", {}).get(str(reference), {})
axis = datum.get("axis")
if isinstance(axis, str) and axis in {"x", "y", "z"}:
axis = {
"x": [1.0, 0.0, 0.0],
"y": [0.0, 1.0, 0.0],
"z": [0.0, 0.0, 1.0],
}[axis]
_vector3(axis, f"{feature.get('id', 'feature')}.axis")
length = math.sqrt(sum(float(value) ** 2 for value in axis))
if length <= 1e-12:
raise DesignIRError(f"{feature.get('id', 'feature')}.axis is zero")
return [float(value) / length for value in axis]
def _axis_is_z(axis: list[float]) -> bool:
return abs(axis[0]) <= 1e-9 and abs(axis[1]) <= 1e-9 and axis[2] > 0.0
def feature_base_center(
feature: dict[str, Any],
center: list[float],
axis: list[float],
height: float,
) -> list[float]:
"""Resolve the primitive's bottom-face center from an explicit anchor."""
anchor = str(feature.get("anchor", "base_center"))
if anchor == "base_center":
return center
if anchor == "geometric_center":
return [
float(center[index]) - float(axis[index]) * height / 2.0
for index in range(3)
]
raise DesignIRError(
f"{feature.get('id', 'feature')}.anchor must be base_center or geometric_center"
)
def build_shape(payload: dict[str, Any]) -> Part:
payload = validate_designir(payload)
if payload["reconstruction_status"] != "ready":
missing = ", ".join(payload.get("missing_capabilities", [])) or "unspecified"
raise DesignIRError(
f"DesignIR is not independently rebuildable; missing capabilities: {missing}"
)
values = parameter_values(payload)
shape: Any = None
for feature in payload["features"]:
operation = feature["operation"]
axis = feature_axis(payload, feature)
if operation in {"extrude_circle", "add_cylinder"}:
radius = resolve_value(feature["radius"], values, f"{feature['id']}.radius")
height = resolve_value(feature["height"], values, f"{feature['id']}.height")
center = resolve_center(
feature.get("center", [0, 0, 0]),
values,
f"{feature['id']}.center",
)
base_center = feature_base_center(feature, center, axis, height)
tool = Plane(origin=base_center, z_dir=axis) * Cylinder(
radius, height, align=(Align.CENTER, Align.CENTER, Align.MIN)
)
shape = tool if shape is None else shape + tool
elif operation == "extrude_rectangle":
width = resolve_value(feature["width"], values, f"{feature['id']}.width")
depth = resolve_value(feature["depth"], values, f"{feature['id']}.depth")
height = resolve_value(feature["height"], values, f"{feature['id']}.height")
center = resolve_center(
feature.get("center", [0, 0, 0]),
values,
f"{feature['id']}.center",
)
base_center = feature_base_center(feature, center, axis, height)
tool = Plane(origin=base_center, z_dir=axis) * Box(
width, depth, height, align=(Align.CENTER, Align.CENTER, Align.MIN)
)
shape = tool if shape is None else shape + tool
elif operation in {"through_hole", "polar_hole_pattern"}:
if not _axis_is_z(axis):
raise DesignIRError(
f"{feature['id']} hole cutting currently requires positive Z axis"
)
if shape is None:
raise DesignIRError(f"{feature['id']} requires a host solid")
diameter = resolve_value(
feature["diameter"], values, f"{feature['id']}.diameter"
)
bounds = shape.bounding_box()
cutter_height = float(bounds.size.Z) + 2.0
cutter_z = float(bounds.min.Z) - 1.0
centers: list[tuple[float, float]]
if operation == "through_hole":
center = resolve_center(
feature.get("center", [0, 0, 0]),
values,
f"{feature['id']}.center",
)
centers = [(float(center[0]), float(center[1]))]
else:
count = int(
round(resolve_value(feature["count"], values, f"{feature['id']}.count"))
)
pitch = resolve_value(
feature["pitch_diameter"],
values,
f"{feature['id']}.pitch_diameter",
)
if count < 1:
raise DesignIRError(f"{feature['id']}.count must be positive")
centers = [
(
math.cos(2.0 * math.pi * index / count) * pitch / 2.0,
math.sin(2.0 * math.pi * index / count) * pitch / 2.0,
)
for index in range(count)
]
for x, y in centers:
cutter = Pos(x, y, cutter_z) * Cylinder(
diameter / 2.0,
cutter_height,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
shape = shape - cutter
if shape is None:
raise DesignIRError("DesignIR produced no geometry")
result = Part(shape.wrapped)
if not list(result.solids()) or result.volume <= 0:
raise DesignIRError("DesignIR did not produce a valid solid")
return result
def build_simplecad_shape(payload: dict[str, Any]) -> tuple[Any, str]:
payload = validate_designir(payload)
if payload["reconstruction_status"] != "ready":
missing = ", ".join(payload.get("missing_capabilities", [])) or "unspecified"
raise DesignIRError(
f"DesignIR is not independently rebuildable; missing capabilities: {missing}"
)
simplecad_src = Path(__file__).resolve().parents[2] / "SimpleCADAPI" / "src"
if str(simplecad_src) not in sys.path:
sys.path.insert(0, str(simplecad_src))
try:
import simplecadapi as scad
except ImportError as exc:
raise DesignIRError("SimpleCADAPI backend is unavailable") from exc
values = parameter_values(payload)
def semantic_tag(value: object, *, prefix: str = "feature") -> str:
token = re.sub(r"[^a-z0-9]+", ".", str(value).lower()).strip(".")
return f"{prefix}.{token or 'unnamed'}"
graph_id = re.sub(
r"[^a-zA-Z0-9_.-]+",
"_",
str(payload.get("model_id") or payload.get("family") or "designir_model"),
).strip("._-") or "designir_model"
@scad.model(graph_id=graph_id)
def compile_model() -> Any:
shape: Any = None
z_min: float | None = None
z_max: float | None = None
for feature in payload["features"]:
operation = feature["operation"]
axis = feature_axis(payload, feature)
feature_tag = semantic_tag(feature["id"])
if operation in {"extrude_circle", "add_cylinder"}:
radius = resolve_value(
feature["radius"], values, f"{feature['id']}.radius"
)
height = resolve_value(
feature["height"], values, f"{feature['id']}.height"
)
center = resolve_center(
feature.get("center", [0, 0, 0]),
values,
f"{feature['id']}.center",
)
point = tuple(feature_base_center(feature, center, axis, height))
tool = scad.make_cylinder_rsolid(
radius=radius,
height=height,
bottom_face_center=point,
axis=tuple(axis),
tag_prefix=feature_tag,
result_tag=f"{feature_tag}.primitive",
)
shape = (
tool
if shape is None
else scad.union_rsolid(shape, tool, clean=True)
)
z_min = point[2] if z_min is None else min(z_min, point[2])
z_max = (
point[2] + height
if z_max is None
else max(z_max, point[2] + height)
)
elif operation == "extrude_rectangle":
if not _axis_is_z(axis):
raise DesignIRError(
f"{feature['id']} SimpleCAD box currently requires positive Z axis"
)
width = resolve_value(
feature["width"], values, f"{feature['id']}.width"
)
depth = resolve_value(
feature["depth"], values, f"{feature['id']}.depth"
)
height = resolve_value(
feature["height"], values, f"{feature['id']}.height"
)
center = resolve_center(
feature.get("center", [0, 0, 0]),
values,
f"{feature['id']}.center",
)
point = tuple(feature_base_center(feature, center, axis, height))
tool = scad.make_box_rsolid(
width=width,
height=depth,
depth=height,
bottom_face_center=point,
tag_prefix=feature_tag,
result_tag=f"{feature_tag}.primitive",
)
shape = (
tool
if shape is None
else scad.union_rsolid(shape, tool, clean=True)
)
z_min = point[2] if z_min is None else min(z_min, point[2])
z_max = (
point[2] + height
if z_max is None
else max(z_max, point[2] + height)
)
elif operation in {"through_hole", "polar_hole_pattern"}:
if not _axis_is_z(axis):
raise DesignIRError(
f"{feature['id']} SimpleCAD hole currently requires positive Z axis"
)
if shape is None or z_min is None or z_max is None:
raise DesignIRError(f"{feature['id']} requires a host solid")
diameter = resolve_value(
feature["diameter"], values, f"{feature['id']}.diameter"
)
if operation == "through_hole":
center = resolve_center(
feature.get("center", [0, 0, 0]),
values,
f"{feature['id']}.center",
)
centers = [(float(center[0]), float(center[1]))]
else:
count = int(
round(
resolve_value(
feature["count"], values, f"{feature['id']}.count"
)
)
)
pitch = resolve_value(
feature["pitch_diameter"],
values,
f"{feature['id']}.pitch_diameter",
)
if count < 1:
raise DesignIRError(
f"{feature['id']}.count must be positive"
)
centers = [
(
math.cos(2.0 * math.pi * index / count) * pitch / 2.0,
math.sin(2.0 * math.pi * index / count) * pitch / 2.0,
)
for index in range(count)
]
cutter_height = z_max - z_min + 2.0
cutters = [
scad.make_cylinder_rsolid(
radius=diameter / 2.0,
height=cutter_height,
bottom_face_center=(x, y, z_min - 1.0),
axis=(0.0, 0.0, 1.0),
tag_prefix=f"{feature_tag}.tool.item{index + 1}",
result_tag=f"{feature_tag}.tool.item{index + 1}.solid",
)
for index, (x, y) in enumerate(centers)
]
shape = scad.cut_rsolid(
shape, *cutters, skip_non_intersecting=False
)
shape = scad.apply_tag(shape=shape, tag=f"{feature_tag}.result")
print(
f"simplecad grounding {feature['id']}: volume={shape.get_volume():.6f}",
file=sys.stderr,
)
return scad.capture_result(value=shape)
result = compile_model()
shape = result.value
model_json = result.model_json
if shape is None or shape.get_volume() <= 0:
raise DesignIRError("DesignIR did not produce a valid SimpleCAD solid")
return shape, model_json
def geometry_facts(shape: Any) -> dict[str, Any]:
bounds = shape.bounding_box()
center = shape.center(CenterOf.MASS)
return {
"solid_count": len(list(shape.solids())),
"face_count": len(list(shape.faces())),
"edge_count": len(list(shape.edges())),
"vertex_count": len(list(shape.vertices())),
"bounds_min_mm": [float(bounds.min.X), float(bounds.min.Y), float(bounds.min.Z)],
"bounds_max_mm": [float(bounds.max.X), float(bounds.max.Y), float(bounds.max.Z)],
"size_mm": [float(bounds.size.X), float(bounds.size.Y), float(bounds.size.Z)],
"volume_mm3": float(shape.volume),
"center_of_mass_mm": [float(center.X), float(center.Y), float(center.Z)],
}
def compile_designir(
payload: dict[str, Any], output: Path, backend: str | None = None
) -> dict[str, Any]:
selected_backend = backend or str(payload.get("backend_hint", "build123d"))
output = output.expanduser().resolve()
output.parent.mkdir(parents=True, exist_ok=True)
model_json_path: Path | None = None
if selected_backend == "build123d":
shape = build_shape(payload)
export_step(shape, output)
elif selected_backend == "simplecadapi":
shape, model_json = build_simplecad_shape(payload)
from simplecadapi import export_step as simplecad_export_step
simplecad_export_step(shapes=shape, filename=str(output))
model_json_path = output.with_suffix(".simplecad.model.json")
model_json_path.write_text(model_json, encoding="utf-8")
else:
raise DesignIRError(f"Unsupported DesignIR compiler backend: {selected_backend}")
rebuilt = import_step(output)
return {
"output": str(output),
"backend": selected_backend,
"model_json": str(model_json_path) if model_json_path else None,
"facts": geometry_facts(rebuilt),
}
def materialize_hybrid_designir(
payload: dict[str, Any], generated_step: Path
) -> dict[str, Any]:
"""Attach a compiled SurfaceIR snapshot to a semantic DesignIR 3.0 model.
The semantic feature program remains authoritative for future edits. The
SurfaceIR snapshot is extracted only from the newly generated STEP, never
from teacher geometry.
"""
semantic = (
migrate_designir_2_to_3(payload)
if payload.get("schema_version") == LEGACY_SCHEMA_VERSION
else copy.deepcopy(payload)
)
compiler_payload(semantic)
surface_script = Path(__file__).with_name("surfaceir_pipeline.py")
spec = importlib.util.spec_from_file_location(
"_designir_surfaceir_pipeline", surface_script
)
if spec is None or spec.loader is None:
raise DesignIRError("Cannot load SurfaceIR compiler")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
extracted = module.extract_surfaceir(generated_step.expanduser().resolve())
semantic["surface_layer"] = extracted["surface_layer"]
semantic["reconstruction_strategy"] = extracted.get(
"reconstruction_strategy",
{
"boundary_strategy": "exact_3d",
"selection_status": "default",
},
)
semantic["document_status"] = extracted.get(
"document_status", "geometry_present"
)
semantic["reconstruction_mode"] = "hybrid_semantic_surface_parametric"
edit_interface = semantic["edit_interface"]
edit_interface["surface_parameter_groups"] = extracted.get(
"edit_interface", {}
).get("surface_parameter_groups", [])
edit_interface["modification_levels"] = list(
dict.fromkeys(
list(edit_interface.get("modification_levels", []))
+ ["semantic_feature", "analytic_surface", "spline_control"]
)
)
semantic["compiled_surface_provenance"] = {
"source": "generated_step",
"teacher_geometry_used": False,
"semantic_layer_authoritative": True,
}
return semantic
def anti_cheat(payload: dict[str, Any], generator: Path | None = None) -> dict[str, Any]:
validate_designir(payload)
violations: list[str] = []
inspected = (
payload.get("semantic_layer", {})
if payload.get("schema_version") == SCHEMA_VERSION
else payload
)
forbidden = find_forbidden(inspected)
if forbidden:
violations.append(f"DesignIR teacher dependency at {forbidden}")
if generator:
text = generator.read_text(encoding="utf-8").lower()
for token in FORBIDDEN_TEXT + ("breptools", "stl", "trimesh"):
if token in text:
violations.append(f"Generator contains forbidden token {token}")
return {"valid": not violations, "violations": violations}
def isolated_rebuild(
designir_path: Path, output: Path, backend: str | None = None
) -> dict[str, Any]:
payload = read_json(designir_path)
guard = anti_cheat(payload)
if not guard["valid"]:
raise DesignIRError("; ".join(guard["violations"]))
with tempfile.TemporaryDirectory(prefix="designir-isolated-") as temporary:
isolated_root = Path(temporary)
isolated_designir = isolated_root / "model.designir.json"
write_json(isolated_designir, payload)
isolated_payload = read_json(isolated_designir)
result = compile_designir(
isolated_payload, isolated_root / "rebuilt.step", backend=backend
)
final_output = output.expanduser().resolve()
final_output.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(result["output"], final_output)
final_model_json: Path | None = None
if result.get("model_json"):
final_model_json = final_output.with_suffix(".simplecad.model.json")
shutil.copy2(str(result["model_json"]), final_model_json)
return {
"valid": True,
"teacher_step_available": False,
"designir_only": True,
"backend": result["backend"],
"output": str(final_output),
"model_json": str(final_model_json) if final_model_json else None,
"facts": geometry_facts(import_step(final_output)),
}
def _max_axis_error(first: list[float], second: list[float]) -> float:
return max(abs(a - b) for a, b in zip(first, second))
def _distance(first: list[float], second: list[float]) -> float:
return math.sqrt(sum((a - b) ** 2 for a, b in zip(first, second)))
def _symmetric_difference_volume(first: Part, second: Part) -> float:
try:
return float((first - second).volume + (second - first).volume)
except Exception as exc: # OpenCascade failures become an explicit metric state.
raise DesignIRError(f"Symmetric-difference boolean failed: {exc}") from exc
def _parameter_reference_count(value: Any, parameter: str) -> int:
if isinstance(value, dict):
direct = 1 if value.get("parameter") == parameter else 0
return direct + sum(
_parameter_reference_count(child, parameter)
for child in value.values()
)
if isinstance(value, list):
return sum(_parameter_reference_count(child, parameter) for child in value)
return 0
def acceptance_report(
teacher_path: Path,
rebuilt_path: Path,
payload: dict[str, Any],
) -> dict[str, Any]:
payload = validate_designir(payload)
teacher_resolved = teacher_path.expanduser().resolve()
rebuilt_resolved = rebuilt_path.expanduser().resolve()
if teacher_resolved == rebuilt_resolved:
raise DesignIRError("Teacher and rebuilt STEP must be different files")
teacher = import_step(teacher_resolved)
rebuilt = import_step(rebuilt_resolved)
teacher_facts = geometry_facts(teacher)
rebuilt_facts = geometry_facts(rebuilt)
volume_base = max(teacher_facts["volume_mm3"], 1e-12)
symmetric_difference = _symmetric_difference_volume(teacher, rebuilt)
geometry = {
"teacher": teacher_facts,
"rebuilt": rebuilt_facts,
"relative_volume_error": abs(
rebuilt_facts["volume_mm3"] - teacher_facts["volume_mm3"]
)
/ volume_base,
"center_of_mass_error_mm": _distance(
rebuilt_facts["center_of_mass_mm"],
teacher_facts["center_of_mass_mm"],
),
"bounding_box_axis_error_mm": _max_axis_error(
rebuilt_facts["size_mm"], teacher_facts["size_mm"]
),
"symmetric_difference_volume_mm3": symmetric_difference,
"symmetric_difference_ratio": symmetric_difference / volume_base,
"topology_count_delta": {
key: rebuilt_facts[key] - teacher_facts[key]
for key in ("solid_count", "face_count", "edge_count", "vertex_count")
},
}
perturbations: list[dict[str, Any]] = []
with tempfile.TemporaryDirectory(prefix="designir-edits-") as temporary:
for index, test in enumerate(
payload["validation_contract"].get("perturbations", [])
):
modified = copy.deepcopy(payload)
name = test["parameter"]
original = float(modified["parameters"][name]["value"])
modified["parameters"][name]["value"] = original * float(test["scale"])
try:
output = Path(temporary) / f"perturbation-{index}.step"
result = compile_designir(modified, output)
modified_shape = import_step(output)
changed_volume = _symmetric_difference_volume(
rebuilt, modified_shape
)
change_ratio = changed_volume / max(
rebuilt_facts["volume_mm3"], 1e-12
)
reference_count = _parameter_reference_count(
payload.get("features", []), name
)
preserved = {
preserved_name: (
preserved_name in payload["parameters"]
and preserved_name in modified["parameters"]
and payload["parameters"][preserved_name]["value"]
== modified["parameters"][preserved_name]["value"]
)
for preserved_name in test.get("preserve", [])
}
expected_change = str(test.get("expected_change") or "")
expected_change_observed = change_ratio > 1e-9
if expected_change == "overall_height_increases":
expected_change_observed = (
result["facts"]["size_mm"][2]
> rebuilt_facts["size_mm"][2] + 1e-9
)
success = (
result["facts"]["solid_count"] > 0
and reference_count > 0
and expected_change_observed
and all(preserved.values())
)
perturbations.append(
{
"parameter": name,
"original_value": original,
"modified_value": modified["parameters"][name]["value"],
"expected_change": test.get("expected_change"),
"preserve": test.get("preserve", []),
"parameter_reference_count": reference_count,
"geometry_change_ratio": change_ratio,
"expected_change_observed": expected_change_observed,
"preserved_parameter_checks": preserved,
"success": success,
"facts": result["facts"],
}
)
except Exception as exc:
perturbations.append(
{"parameter": name, "success": False, "error": str(exc)}
)
success_rate = (
sum(1 for item in perturbations if item["success"]) / len(perturbations)
if perturbations
else 1.0
)
thresholds = payload["validation_contract"].get("thresholds", {})
threshold_checks = {
"relative_volume_error": geometry["relative_volume_error"]
<= float(thresholds.get("relative_volume_error_max", 0.01)),
"center_of_mass_error_mm": geometry["center_of_mass_error_mm"]
<= float(thresholds.get("center_of_mass_error_mm_max", 0.1)),
"bounding_box_axis_error_mm": geometry["bounding_box_axis_error_mm"]
<= float(thresholds.get("bounding_box_axis_error_mm_max", 0.1)),
"symmetric_difference_ratio": geometry["symmetric_difference_ratio"]
<= float(thresholds.get("symmetric_difference_ratio_max", 0.02)),
"parameter_perturbation_success_rate": success_rate
>= float(thresholds.get("parameter_perturbation_success_rate_min", 1.0)),
}
passed = all(threshold_checks.values())
return {
"schema_version": "1.0",
"report_kind": "independent_designir_acceptance",
"model_id": payload["model_id"],
"teacher_sha256": hashlib.sha256(teacher_resolved.read_bytes()).hexdigest(),
"applied_experience_ids": sorted(
{
str(item)
for item in payload.get("applied_experience_ids", [])
if str(item).strip()
}
),
"geometry": geometry,
"feature_semantics": {
"declared_feature_count": len(payload["features"]),
"declared_constraint_count": len(payload["constraints"]),
"unsupported_capabilities": payload.get("missing_capabilities", []),
},
"editability": {
"editable_parameter_count": len(
payload["edit_interface"]["editable_parameters"]
),
"perturbations": perturbations,
"parameter_perturbation_success_rate": success_rate,
},
"threshold_checks": threshold_checks,
"verdict": "pass" if passed else "fail",
"next_state": "replay_validated" if passed else "quarantine",
}
def promotion_check(reports: list[Path], policy_path: Path) -> dict[str, Any]:
policy = read_json(policy_path)
loaded = [read_json(path) for path in reports]
passing = [item for item in loaded if item.get("next_state") == "replay_validated"]
minimum = int(policy["minimum_independent_cases"])
methods: dict[str, set[str]] = {}
for item in passing:
source_hash = str(item.get("teacher_sha256") or "")
if not source_hash:
continue
for experience_id in item.get("applied_experience_ids", []):
methods.setdefault(str(experience_id), set()).add(source_hash)
validated_methods = sorted(
experience_id
for experience_id, source_hashes in methods.items()
if len(source_hashes) >= minimum
)
independent_sources = len(
{
source_hash
for source_hashes in methods.values()
for source_hash in source_hashes
}
)
eligible = bool(validated_methods)
return {
"eligible": eligible,
"independent_case_count": independent_sources,
"passing_case_count": len(passing),
"required_case_count": minimum,
"validated_experience_ids": validated_methods,
"next_state": "promoted" if eligible else "candidate",
}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
validate = commands.add_parser("validate")
validate.add_argument("designir", type=Path)
migrate = commands.add_parser("migrate")
migrate.add_argument("designir", type=Path)
migrate.add_argument("--output", type=Path, required=True)
guard = commands.add_parser("anti-cheat")
guard.add_argument("designir", type=Path)
guard.add_argument("--generator", type=Path)
compile_command = commands.add_parser("compile")
compile_command.add_argument("designir", type=Path)
compile_command.add_argument("--output", type=Path, required=True)
compile_command.add_argument(
"--backend", choices=("build123d", "simplecadapi")
)
isolated = commands.add_parser("isolated-rebuild")
isolated.add_argument("designir", type=Path)
isolated.add_argument("--output", type=Path, required=True)
isolated.add_argument("--backend", choices=("build123d", "simplecadapi"))
materialize = commands.add_parser("materialize-hybrid")
materialize.add_argument("designir", type=Path)
materialize.add_argument("--step", type=Path, required=True)
materialize.add_argument("--output", type=Path, required=True)
accept = commands.add_parser("accept")
accept.add_argument("--teacher", type=Path, required=True)
accept.add_argument("--designir", type=Path, required=True)
accept.add_argument("--rebuilt", type=Path, required=True)
accept.add_argument("--report", type=Path, required=True)
promote = commands.add_parser("promotion-check")
promote.add_argument("reports", type=Path, nargs="+")
promote.add_argument("--policy", type=Path, required=True)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
if args.command == "validate":
original = read_json(args.designir)
payload = validate_designir(original)
result = {
"valid": True,
"model_id": payload["model_id"],
"input_schema_version": original.get("schema_version"),
"supported_operations": sorted(SUPPORTED_OPERATIONS),
}
elif args.command == "migrate":
original = read_json(args.designir)
migrated = (
migrate_designir_2_to_3(original)
if original.get("schema_version") == LEGACY_SCHEMA_VERSION
else original
)
compiler_payload(migrated)
write_json(args.output, migrated)
result = {
"valid": True,
"output": str(args.output.expanduser().resolve()),
"schema_version": migrated["schema_version"],
"reconstruction_mode": migrated["reconstruction_mode"],
}
elif args.command == "anti-cheat":
result = anti_cheat(read_json(args.designir), args.generator)
elif args.command == "compile":
result = compile_designir(
read_json(args.designir), args.output, backend=args.backend
)
elif args.command == "isolated-rebuild":
result = isolated_rebuild(
args.designir, args.output, backend=args.backend
)
elif args.command == "materialize-hybrid":
materialized = materialize_hybrid_designir(
read_json(args.designir), args.step
)
write_json(args.output, materialized)
result = {
"valid": True,
"output": str(args.output.expanduser().resolve()),
"schema_version": materialized["schema_version"],
"reconstruction_mode": materialized["reconstruction_mode"],
"semantic_layer_authoritative": True,
}
elif args.command == "accept":
result = acceptance_report(
args.teacher, args.rebuilt, read_json(args.designir)
)
write_json(args.report, result)
else:
result = promotion_check(args.reports, args.policy)
except (DesignIRError, OSError) as exc:
print(json.dumps({"valid": False, "error": str(exc)}, ensure_ascii=False, indent=2))
return 2
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if result.get("valid", result.get("eligible", True)) is not False else 1
if __name__ == "__main__":
raise SystemExit(main())