feat: integrate SimpleCADAPI 2.0.2 CAD workflows

This commit is contained in:
Jerry
2026-08-03 11:17:05 +08:00
parent b5738e9109
commit c3a0f269b7
481 changed files with 110229 additions and 12826 deletions
@@ -0,0 +1,457 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://cadset.local/contracts/assembly-designir-1.0.schema.json",
"title": "Assembly DesignIR 1.0",
"description": "Coordinate-free semantic structure, mating, kinematics and transmission contract.",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version",
"designir_kind",
"model_id",
"family",
"units",
"parameters",
"modules",
"components",
"relations",
"kinematics",
"edit_interface",
"validation_contract"
],
"properties": {
"schema_version": {
"const": "assembly-1.0"
},
"designir_kind": {
"const": "coordinate_free_semantic_assembly"
},
"model_id": {
"$ref": "#/$defs/identifier"
},
"family": {
"$ref": "#/$defs/identifier"
},
"units": {
"enum": ["mm", "inch"]
},
"design_intent": {
"type": "string",
"minLength": 1
},
"parameters": {
"type": "object",
"additionalProperties": {
"$ref": "#/$defs/parameter"
}
},
"expressions": {
"type": "object",
"additionalProperties": {
"type": "string",
"minLength": 1
}
},
"modules": {
"type": "array",
"items": {
"$ref": "#/$defs/module"
}
},
"components": {
"type": "array",
"items": {
"$ref": "#/$defs/component"
}
},
"relations": {
"type": "array",
"items": {
"$ref": "#/$defs/relation"
}
},
"kinematics": {
"$ref": "#/$defs/kinematics"
},
"edit_interface": {
"$ref": "#/$defs/editInterface"
},
"validation_contract": {
"$ref": "#/$defs/validationContract"
}
},
"$defs": {
"identifier": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$"
},
"parameter": {
"type": "object",
"additionalProperties": false,
"required": ["kind", "value", "editable"],
"properties": {
"kind": {
"enum": ["length", "angle", "ratio", "count", "mass", "torque", "speed", "scalar", "choice", "boolean"]
},
"value": {},
"unit": {
"type": "string"
},
"editable": {
"type": "boolean"
},
"bounds": {
"type": "array",
"minItems": 2,
"maxItems": 2,
"items": {
"type": "number"
}
},
"choices": {
"type": "array",
"items": {}
},
"description": {
"type": "string"
},
"status": {
"enum": ["specified", "inferred", "estimated", "derived"]
}
}
},
"module": {
"type": "object",
"additionalProperties": false,
"required": ["id", "purpose", "members"],
"properties": {
"id": {
"$ref": "#/$defs/identifier"
},
"purpose": {
"type": "string",
"minLength": 1
},
"members": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/identifier"
}
},
"replaceable_as_unit": {
"type": "boolean"
}
}
},
"component": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"role",
"archetype",
"lifecycle",
"generation",
"interfaces"
],
"properties": {
"id": {
"$ref": "#/$defs/identifier"
},
"role": {
"$ref": "#/$defs/identifier"
},
"archetype": {
"$ref": "#/$defs/identifier"
},
"lifecycle": {
"enum": ["required", "optional", "replaceable", "generated_pattern"]
},
"quantity": {
"oneOf": [
{
"type": "integer",
"minimum": 1
},
{
"type": "string",
"minLength": 1
}
]
},
"generation": {
"type": "object",
"additionalProperties": false,
"required": ["strategy", "family", "parameter_bindings"],
"properties": {
"strategy": {
"enum": ["semantic_feature_program", "standard_library", "supplier_catalog", "subassembly_template"]
},
"family": {
"$ref": "#/$defs/identifier"
},
"parameter_bindings": {
"type": "object",
"additionalProperties": {
"type": ["string", "number", "integer", "boolean"]
}
}
}
},
"interfaces": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/interface"
}
},
"material_class": {
"$ref": "#/$defs/identifier"
},
"tags": {
"type": "array",
"items": {
"$ref": "#/$defs/identifier"
}
}
}
},
"interface": {
"type": "object",
"additionalProperties": false,
"required": ["id", "type", "construction"],
"properties": {
"id": {
"$ref": "#/$defs/identifier"
},
"type": {
"enum": ["axis", "plane", "cylinder", "hole_pattern", "shaft_seat", "bearing_seat", "fastener_seat", "torque_interface", "contact_envelope"]
},
"construction": {
"enum": ["primary_axis", "secondary_axis", "front_axial_face", "rear_axial_face", "inner_cylindrical_surface", "outer_cylindrical_surface", "pitch_circle_pattern", "shoulder_face", "center_plane", "generated_feature"]
},
"qualifiers": {
"type": "array",
"items": {
"type": "string"
}
},
"parameter_refs": {
"type": "array",
"items": {
"$ref": "#/$defs/identifier"
}
}
}
},
"endpoint": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*\\.[a-z][a-z0-9_]*$"
},
"relation": {
"type": "object",
"additionalProperties": false,
"required": ["id", "type", "a", "b", "intent"],
"properties": {
"id": {
"$ref": "#/$defs/identifier"
},
"type": {
"enum": ["coaxial", "face_mate", "face_offset", "parallel", "perpendicular", "insert", "bearing_support", "axial_capture", "fasten_pattern", "torque_lock", "radial_clearance", "axial_clearance", "tangent"]
},
"a": {
"$ref": "#/$defs/endpoint"
},
"b": {
"$ref": "#/$defs/endpoint"
},
"intent": {
"type": "string",
"minLength": 1
},
"value": {
"oneOf": [
{
"type": "number"
},
{
"type": "string",
"minLength": 1
}
]
},
"tolerance": {
"oneOf": [
{
"type": "number",
"minimum": 0
},
{
"type": "string",
"minLength": 1
}
]
},
"mobility": {
"enum": ["fixed", "allow_rotation", "allow_translation", "allow_rotation_and_translation"]
},
"confidence": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"evidence": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"kinematics": {
"type": "object",
"additionalProperties": false,
"required": ["anchor_component", "rigid_groups", "joints", "transmissions"],
"properties": {
"anchor_component": {
"$ref": "#/$defs/identifier"
},
"rigid_groups": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "members"],
"properties": {
"id": {
"$ref": "#/$defs/identifier"
},
"members": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/identifier"
}
}
}
}
},
"joints": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "type", "parent_group", "child_group", "axis_ref", "station_ref"],
"properties": {
"id": {
"$ref": "#/$defs/identifier"
},
"type": {
"enum": ["fixed", "revolute", "continuous", "prismatic", "cylindrical"]
},
"parent_group": {
"$ref": "#/$defs/identifier"
},
"child_group": {
"$ref": "#/$defs/identifier"
},
"axis_ref": {
"$ref": "#/$defs/endpoint"
},
"station_ref": {
"$ref": "#/$defs/endpoint"
},
"dynamics": {
"type": "object",
"additionalProperties": {
"type": ["number", "string"]
}
}
}
}
},
"transmissions": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "type", "input_joint", "output_joint", "ratio_expression"],
"properties": {
"id": {
"$ref": "#/$defs/identifier"
},
"type": {
"enum": ["direct", "gear_pair", "planetary", "harmonic", "cycloidal", "belt", "chain", "screw"]
},
"input_joint": {
"$ref": "#/$defs/identifier"
},
"output_joint": {
"$ref": "#/$defs/identifier"
},
"ratio_expression": {
"type": "string",
"minLength": 1
},
"fixed_member": {
"$ref": "#/$defs/identifier"
},
"compliance_model": {
"$ref": "#/$defs/identifier"
}
}
}
}
}
},
"editInterface": {
"type": "object",
"additionalProperties": false,
"required": ["editable_parameters", "structural_operations", "preserved_interfaces"],
"properties": {
"editable_parameters": {
"type": "array",
"items": {
"$ref": "#/$defs/identifier"
}
},
"structural_operations": {
"type": "array",
"items": {
"enum": ["add_component", "remove_component", "replace_component", "change_relation", "change_transmission", "add_module"]
}
},
"preserved_interfaces": {
"type": "array",
"items": {
"$ref": "#/$defs/endpoint"
}
}
}
},
"validationContract": {
"type": "object",
"additionalProperties": false,
"required": ["coordinate_free_source", "required_checks"],
"properties": {
"coordinate_free_source": {
"const": true
},
"required_checks": {
"type": "array",
"minItems": 1,
"items": {
"enum": ["schema_valid", "reference_integrity", "constraint_graph_connected", "assembly_solvable_from_scrambled_state", "no_overconstraint", "interface_compatibility", "clearance", "interference", "joint_mobility", "parameter_perturbation", "preserved_interface", "urdf_load", "mjcf_load"]
}
},
"acceptance_rules": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
}
}
@@ -56,8 +56,41 @@
},
"surface_layer": {
"type": "object",
"required": ["vertices", "solids", "free_shells", "surface_vocabulary", "curve_vocabulary"],
"oneOf": [
{
"required": [
"vertices",
"solids",
"free_shells",
"surface_vocabulary",
"curve_vocabulary"
]
},
{
"required": [
"format",
"encoding",
"uncompressed_bytes",
"compressed_bytes",
"sha256",
"data"
]
}
],
"properties": {
"format": {"const": "surfaceir-3.0"},
"encoding": {"const": "gzip+base64+json"},
"uncompressed_bytes": {"type": "integer", "minimum": 2},
"compressed_bytes": {"type": "integer", "minimum": 2},
"sha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"data": {
"type": "string",
"minLength": 1,
"contentEncoding": "base64"
},
"vertices": {
"type": "array",
"items": {
@@ -77,6 +110,7 @@
"required": ["id", "shells"],
"properties": {
"id": {"type": "string"},
"orientation": {"type": "integer"},
"shells": {
"type": "array",
"items": {
@@ -84,6 +118,7 @@
"required": ["id", "faces"],
"properties": {
"id": {"type": "string"},
"orientation": {"type": "integer"},
"faces": {
"type": "array",
"items": {"$ref": "#/$defs/face"}
@@ -286,6 +321,8 @@
"properties": {
"id": {"type": "string"},
"orientation": {"type": "integer"},
"area": {"type": "number", "minimum": 0},
"volume_contribution": {"type": "number"},
"surface": {"type": "object"},
"uv_bounds": {
"type": "array",
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Lossless storage codec for portable DesignIR 3.0 documents.
The semantic envelope stays ordinary JSON so an agent can inspect parameters,
features, constraints, and edit bindings without consuming the dense SurfaceIR
coordinate payload. SurfaceIR is compressed losslessly inside the same JSON
document, preserving single-file source-independent rebuilds.
"""
from __future__ import annotations
import base64
import copy
import gzip
import hashlib
import json
from pathlib import Path
from typing import Any
SURFACE_LAYER_ENCODING = "gzip+base64+json"
SURFACE_LAYER_FORMAT = "surfaceir-3.0"
class DesignIRCodecError(ValueError):
"""Raised when a compact DesignIR payload is corrupt or unsupported."""
def is_encoded_surface_layer(value: Any) -> bool:
return (
isinstance(value, dict)
and value.get("encoding") == SURFACE_LAYER_ENCODING
and value.get("format") == SURFACE_LAYER_FORMAT
and isinstance(value.get("data"), str)
)
def encode_surface_layer(payload: dict[str, Any]) -> dict[str, Any]:
"""Return a copy with SurfaceIR losslessly compressed in-place."""
surface_layer = payload.get("surface_layer")
if surface_layer is None or is_encoded_surface_layer(surface_layer):
return copy.deepcopy(payload)
if not isinstance(surface_layer, dict):
raise DesignIRCodecError("surface_layer must be a JSON object")
raw = json.dumps(
surface_layer,
ensure_ascii=False,
separators=(",", ":"),
).encode("utf-8")
compressed = gzip.compress(raw, compresslevel=9, mtime=0)
encoded = copy.copy(payload)
encoded["surface_layer"] = {
"format": SURFACE_LAYER_FORMAT,
"encoding": SURFACE_LAYER_ENCODING,
"uncompressed_bytes": len(raw),
"compressed_bytes": len(compressed),
"sha256": hashlib.sha256(raw).hexdigest(),
"data": base64.b64encode(compressed).decode("ascii"),
}
return encoded
def decode_surface_layer(payload: dict[str, Any]) -> dict[str, Any]:
"""Return a copy with an encoded SurfaceIR layer expanded and verified."""
envelope = payload.get("surface_layer")
if not is_encoded_surface_layer(envelope):
return copy.deepcopy(payload)
try:
compressed = base64.b64decode(envelope["data"], validate=True)
raw = gzip.decompress(compressed)
except (ValueError, OSError) as exc:
raise DesignIRCodecError(
f"Cannot decode compact SurfaceIR payload: {exc}"
) from exc
expected_compressed = envelope.get("compressed_bytes")
if expected_compressed is not None and len(compressed) != expected_compressed:
raise DesignIRCodecError("Compact SurfaceIR compressed size mismatch")
expected_uncompressed = envelope.get("uncompressed_bytes")
if expected_uncompressed is not None and len(raw) != expected_uncompressed:
raise DesignIRCodecError("Compact SurfaceIR uncompressed size mismatch")
expected_digest = envelope.get("sha256")
if expected_digest and hashlib.sha256(raw).hexdigest() != expected_digest:
raise DesignIRCodecError("Compact SurfaceIR SHA-256 mismatch")
try:
surface_layer = json.loads(raw)
except json.JSONDecodeError as exc:
raise DesignIRCodecError(
f"Decoded SurfaceIR is not valid JSON: {exc}"
) from exc
if not isinstance(surface_layer, dict):
raise DesignIRCodecError("Decoded SurfaceIR must be a JSON object")
decoded = copy.copy(payload)
decoded["surface_layer"] = surface_layer
return decoded
def read_designir(path: Path) -> dict[str, Any]:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise DesignIRCodecError(f"Cannot read DesignIR JSON {path}: {exc}") from exc
if not isinstance(payload, dict):
raise DesignIRCodecError("DesignIR must be a JSON object")
return decode_surface_layer(payload)
def write_designir(
path: Path,
payload: dict[str, Any],
*,
encode_surface: bool = True,
) -> None:
stored = encode_surface_layer(payload) if encode_surface else payload
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(stored, ensure_ascii=False, separators=(",", ":")) + "\n",
encoding="utf-8",
)
+48 -11
View File
@@ -21,6 +21,16 @@ 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,
@@ -113,8 +123,8 @@ class DesignIRError(ValueError):
def read_json(path: Path) -> dict[str, Any]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
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")
@@ -122,6 +132,9 @@ def read_json(path: Path) -> dict[str, Any]:
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")
@@ -587,13 +600,26 @@ def build_simplecad_shape(payload: dict[str, Any]) -> tuple[Any, str]:
raise DesignIRError("SimpleCADAPI backend is unavailable") from exc
values = parameter_values(payload)
shape: Any = None
z_min: float | None = None
z_max: float | None = None
with scad.GraphSession() as session:
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"
@@ -612,11 +638,13 @@ def build_simplecad_shape(payload: dict[str, Any]) -> tuple[Any, str]:
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)
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 = (
@@ -649,11 +677,13 @@ def build_simplecad_shape(payload: dict[str, Any]) -> tuple[Any, str]:
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)
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 = (
@@ -709,17 +739,24 @@ def build_simplecad_shape(payload: dict[str, Any]) -> tuple[Any, str]:
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 x, y in centers
for index, (x, y) in enumerate(centers)
]
shape = scad.cut_rsolid(
shape, cutters, skip_non_intersecting=False
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,
)
model_json = scad.export_model_json(session=session, indent=2)
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
@@ -0,0 +1,282 @@
#!/usr/bin/env python3
"""Export a portable single-link URDF or MJCF package from DesignIR 3.0.
The exporter rebuilds geometry from DesignIR alone, writes an STL mesh in
millimetres, and records the required 0.001 mesh scale in the robot
description. Uploaded teacher STEP files are never read.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import shutil
import sys
import tempfile
from pathlib import Path
from typing import Any
import xml.etree.ElementTree as ET
import zipfile
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 read_designir
import surfaceir_pipeline
from build123d import Shape, export_stl
EXPORT_SCHEMA_VERSION = "1.0"
MESH_SCALE = "0.001 0.001 0.001"
def _safe_name(value: str, fallback: str = "cad_model") -> str:
normalized = re.sub(r"[^a-zA-Z0-9_-]+", "_", value).strip("_")
return normalized or fallback
def _xml_bytes(root: ET.Element) -> bytes:
ET.indent(root, space=" ")
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def _urdf(model_name: str, mesh_name: str) -> bytes:
robot = ET.Element("robot", {"name": model_name})
robot.append(
ET.Comment(
"Generated from source-independent DesignIR 3.0. "
"No joint or material evidence was available, so this is one "
"fixed rigid link and inertial values are intentionally omitted."
)
)
link = ET.SubElement(robot, "link", {"name": "base_link"})
for role in ("visual", "collision"):
node = ET.SubElement(link, role)
ET.SubElement(node, "origin", {"xyz": "0 0 0", "rpy": "0 0 0"})
geometry = ET.SubElement(node, "geometry")
ET.SubElement(
geometry,
"mesh",
{
"filename": f"meshes/{mesh_name}",
"scale": MESH_SCALE,
},
)
return _xml_bytes(robot)
def _mjcf(model_name: str, mesh_name: str) -> bytes:
mujoco = ET.Element("mujoco", {"model": model_name})
mujoco.append(
ET.Comment(
"Generated from source-independent DesignIR 3.0. "
"No joint or material evidence was available, so this is one "
"world-fixed rigid body and density is disabled."
)
)
ET.SubElement(
mujoco,
"compiler",
{"angle": "radian", "meshdir": "meshes", "autolimits": "true"},
)
asset = ET.SubElement(mujoco, "asset")
ET.SubElement(
asset,
"mesh",
{
"name": "designir_mesh",
"file": mesh_name,
"scale": MESH_SCALE,
},
)
worldbody = ET.SubElement(mujoco, "worldbody")
body = ET.SubElement(worldbody, "body", {"name": "base_link"})
ET.SubElement(
body,
"geom",
{
"name": "base_link_geometry",
"type": "mesh",
"mesh": "designir_mesh",
"density": "0",
"contype": "1",
"conaffinity": "1",
},
)
return _xml_bytes(mujoco)
def _build_shape(payload: dict[str, Any]) -> tuple[Any, str]:
preferred = (
payload.get("reconstruction_strategy", {})
.get("boundary_strategy", "exact_3d_pcurve")
)
candidates = list(
dict.fromkeys(
[
preferred,
"exact_3d_pcurve",
"exact_3d",
"first_pcurve",
"analytic_uv_rect",
]
)
)
failures = []
for strategy in candidates:
try:
return surfaceir_pipeline.build_surfaceir(payload, strategy), strategy
except Exception as exc: # pragma: no cover - fallback diagnostics
failures.append(f"{strategy}: {type(exc).__name__}: {exc}")
raise ValueError("DesignIR geometry could not be rebuilt: " + " | ".join(failures))
def export_robot_package(
designir_path: Path,
output_dir: Path,
output_format: str,
*,
linear_tolerance: float = 0.05,
angular_tolerance: float = 0.1,
) -> dict[str, Any]:
output_format = output_format.lower()
if output_format not in {"urdf", "mjcf"}:
raise ValueError("format must be urdf or mjcf")
payload = read_designir(designir_path)
if payload.get("document_status") != "geometry_present":
raise ValueError("Cannot export a robot description without geometry")
if "surface_layer" not in payload:
raise ValueError(
"Robot export requires a materialized DesignIR SurfaceIR layer"
)
shape, strategy = _build_shape(payload)
model_name = _safe_name(str(payload.get("model_id") or designir_path.stem))
digest = hashlib.sha256(designir_path.read_bytes()).hexdigest()
package_stem = f"{model_name}.{output_format}"
output_dir.mkdir(parents=True, exist_ok=True)
package_path = output_dir / f"{package_stem}.zip"
description_suffix = ".urdf" if output_format == "urdf" else ".mjcf.xml"
description_name = f"{model_name}{description_suffix}"
mesh_name = f"{model_name}.stl"
with tempfile.TemporaryDirectory(prefix="designir-robot-export-") as temporary:
staging = Path(temporary) / package_stem
mesh_dir = staging / "meshes"
mesh_dir.mkdir(parents=True)
mesh_path = mesh_dir / mesh_name
export_stl(
Shape(shape),
mesh_path,
tolerance=linear_tolerance,
angular_tolerance=angular_tolerance,
)
description = (
_urdf(model_name, mesh_name)
if output_format == "urdf"
else _mjcf(model_name, mesh_name)
)
description_path = staging / description_name
description_path.write_bytes(description)
manifest = {
"schema_version": EXPORT_SCHEMA_VERSION,
"export_kind": f"designir_to_{output_format}",
"model_name": model_name,
"source": {
"kind": "designir-3.0",
"sha256": digest,
"teacher_step_used": False,
},
"robot_structure": {
"mode": "static_single_link",
"link_count": 1,
"joint_count": 0,
"root_link": "base_link",
},
"geometry": {
"mesh": f"meshes/{mesh_name}",
"mesh_units": "mm",
"description_mesh_scale": [0.001, 0.001, 0.001],
"linear_tolerance_mm": linear_tolerance,
"angular_tolerance_rad": angular_tolerance,
"reconstruction_strategy": strategy,
},
"dynamics": {
"status": "not_authored",
"reason": "DesignIR contains no authoritative material density or joint evidence.",
},
"assumptions": [
"All reconstructed solids belong to one rigid base_link.",
"The root link is fixed because no authoritative joint evidence exists.",
"Visual and collision geometry use the same high-resolution mesh.",
],
}
(staging / "robot-export.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
temporary_zip = package_path.with_suffix(package_path.suffix + ".tmp")
temporary_zip.unlink(missing_ok=True)
with zipfile.ZipFile(
temporary_zip,
"w",
compression=zipfile.ZIP_DEFLATED,
compresslevel=9,
) as archive:
for source in sorted(staging.rglob("*")):
if source.is_file():
archive.write(source, source.relative_to(staging))
temporary_zip.replace(package_path)
published_description = output_dir / description_name
published_mesh = output_dir / mesh_name
shutil.copyfile(description_path, published_description)
shutil.copyfile(mesh_path, published_mesh)
return {
"status": "generated",
"format": output_format,
"package": str(package_path.resolve()),
"description": str(published_description.resolve()),
"mesh": str(published_mesh.resolve()),
"source_sha256": digest,
"robot_structure": "static_single_link",
"reconstruction_strategy": strategy,
"warnings": [
"No authoritative joints were present; exported one fixed base_link.",
"No authoritative material density was present; inertial data was omitted.",
],
}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--designir", type=Path, required=True)
parser.add_argument("--output-dir", type=Path, required=True)
parser.add_argument("--format", choices=["urdf", "mjcf"], required=True)
parser.add_argument("--linear-tolerance", type=float, default=0.05)
parser.add_argument("--angular-tolerance", type=float, default=0.1)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
result = export_robot_package(
args.designir.resolve(),
args.output_dir.resolve(),
args.format,
linear_tolerance=args.linear_tolerance,
angular_tolerance=args.angular_tolerance,
)
print(json.dumps(result, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
@@ -178,7 +178,62 @@ def _semantic_parameter_candidates(
)
for item in repeated
}
if len(unique_centers) >= 3:
inferred_gear_kind = semantic_summary.get("inferred_gear_kind")
inferred_tooth_count = int(
semantic_summary.get("inferred_tooth_count", 0)
)
if inferred_gear_kind and inferred_tooth_count >= 3:
transverse_span = max(bbox_size[index] for index in transverse_axes)
if inferred_gear_kind == "internal_spur_gear" and coaxial:
internal_tip_diameter = 2.0 * min(
float(item["radius"]) for item in coaxial
)
inferred_module = internal_tip_diameter / (
inferred_tooth_count - 2.0
)
else:
inferred_module = transverse_span / (
inferred_tooth_count + 2.0
)
candidates.extend(
[
_parameter_candidate(
"tooth_count",
inferred_tooth_count,
"count",
0.9,
["equal_angular_tooth_pattern"],
"gear_tooth_count_changes",
"paired_equal_radius_cylindrical_tooth_flanks",
["gear_teeth"],
),
_parameter_candidate(
"gear_module",
inferred_module,
"mm",
0.72,
["spur_gear_body"],
"gear_pitch_and_tooth_scale_changes",
(
"internal_tip_diameter_and_tooth_count"
if inferred_gear_kind == "internal_spur_gear"
else "external_envelope_and_tooth_count"
),
["module"],
),
_parameter_candidate(
"gear_width",
bbox_size[{"x": 0, "y": 1, "z": 2}[dominant_axis]],
"mm",
0.96,
["spur_gear_body"],
"gear_axial_width_changes",
"dominant_axis_bounding_span",
["face_width"],
),
]
)
elif len(unique_centers) >= 3:
member_radius = float(repeated[0]["radius"])
offsets = [
math.hypot(
@@ -419,6 +474,41 @@ def _semantic_summary(
and pattern_spread / pattern_mean_offset <= 0.08
)
# Spur gears exported by common parametric generators often expose two
# equal-radius cylindrical flank faces per tooth. Without this guard the
# generic repeated-cylinder detector labels those flanks as a bolt-hole
# pattern and invents hole_count / bolt_circle_diameter parameters.
#
# This is intentionally a conservative final-B-Rep classifier. It does
# not claim that the source history contained a gear feature.
gear_like_surface_mix = bool(
has_rotational_stack
and has_circular_pattern
and len(largest_repeated_group) >= 12
and len(largest_repeated_group) % 2 == 0
and len(planes) <= 3
and not cones
and not toruses
and not spheres
and not other_surfaces
and dominant_span / max(transverse_span, 1e-9) <= 0.6
)
inferred_tooth_count = (
len(largest_repeated_group) // 2 if gear_like_surface_mix else 0
)
centered_radius_ratio = _ratio(
smallest_coaxial_radius, transverse_span
)
inferred_gear_kind = (
"internal_spur_gear"
if gear_like_surface_mix and centered_radius_ratio >= 0.35
else "external_spur_gear"
if gear_like_surface_mix
else None
)
if gear_like_surface_mix:
has_repeated_axial_holes = False
# Multiple radii sharing a transverse center are a robust final-B-Rep
# signal for stepped bores/counterbores, without claiming operation order.
transverse_groups: dict[tuple[int, int, int], set[float]] = defaultdict(set)
@@ -479,7 +569,22 @@ def _semantic_summary(
if other_surfaces:
features.append({"type": "freeform_surface_region"})
if has_circular_pattern:
features.append({"type": "circular_equal_radius_pattern"})
features.append(
{
"type": (
"equal_angular_tooth_pattern"
if gear_like_surface_mix
else "circular_equal_radius_pattern"
)
}
)
if inferred_gear_kind:
features.extend(
[
{"type": "spur_gear_body"},
{"type": inferred_gear_kind},
]
)
if has_stepped_cylindrical_passage:
features.append({"type": "stepped_cylindrical_passage"})
if has_rotational_stack:
@@ -515,7 +620,18 @@ def _semantic_summary(
)
if has_circular_pattern:
constraints.append(
{"id": "common_pattern_radius", "type": "radial_pattern"}
{
"id": (
"equal_angular_tooth_pattern"
if gear_like_surface_mix
else "common_pattern_radius"
),
"type": (
"gear_tooth_pattern"
if gear_like_surface_mix
else "radial_pattern"
),
}
)
if has_multi_axis_passages:
constraints.append(
@@ -526,7 +642,9 @@ def _semantic_summary(
{"id": "shared_passage_axis", "type": "coaxial"}
)
if has_rotational_stack and has_repeated_axial_holes:
if inferred_gear_kind:
family = inferred_gear_kind
elif has_rotational_stack and has_repeated_axial_holes:
family = "flanged_rotational_part"
elif has_rotational_stack and elongated and len(distinct_coaxial_radii) >= 3:
family = "hollow_or_stepped_shaft"
@@ -622,6 +740,8 @@ def _semantic_summary(
else 0,
"parallel_cone_surface_count": len(parallel_cones),
"has_multi_axis_passages": has_multi_axis_passages,
"inferred_gear_kind": inferred_gear_kind,
"inferred_tooth_count": inferred_tooth_count,
"shape_class": (
"elongated" if elongated else "plate_like" if plate_like else "compact"
if compact
@@ -0,0 +1,87 @@
from __future__ import annotations
import copy
import importlib.util
import json
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
MODEL_DIR = REPO / "models" / "lcd25-reducer" / "semantic-assembly"
SCHEMA_PATH = (
REPO
/ "designir-pipeline"
/ "contracts"
/ "assembly-designir-1.0.schema.json"
)
VALIDATOR_PATH = MODEL_DIR / "validate_assembly_design.py"
def load_validator():
spec = importlib.util.spec_from_file_location(
"assembly_design_validator", VALIDATOR_PATH
)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def load_json(path: Path):
return json.loads(path.read_text(encoding="utf-8"))
def test_lcd25_coordinate_free_base_model_is_valid():
validator = load_validator()
schema = load_json(SCHEMA_PATH)
payload = load_json(
MODEL_DIR / "lcd25-harmonic-reducer.assembly-designir.json"
)
report = validator.validate(payload, schema)
assert report["status"] == "pass"
assert report["coordinate_free"] is True
assert report["component_count"] == 10
assert report["relation_count"] == 15
def test_output_shaft_patch_preserves_coordinate_free_contract():
validator = load_validator()
schema = load_json(SCHEMA_PATH)
payload = load_json(
MODEL_DIR / "lcd25-harmonic-reducer.assembly-designir.json"
)
patch = load_json(MODEL_DIR / "variants" / "add-output-shaft.patch.json")
resolved = validator.apply_patch(payload, patch)
report = validator.validate(resolved, schema)
assert report["status"] == "pass"
assert report["coordinate_free"] is True
assert report["component_count"] == 11
assert "output_shaft" in {
component["id"] for component in resolved["components"]
}
assert "output_shaft" in next(
group["members"]
for group in resolved["kinematics"]["rigid_groups"]
if group["id"] == "output_group"
)
def test_authored_transform_is_rejected():
validator = load_validator()
schema = load_json(SCHEMA_PATH)
payload = load_json(
MODEL_DIR / "lcd25-harmonic-reducer.assembly-designir.json"
)
invalid = copy.deepcopy(payload)
invalid["components"][0]["transform"] = [
[1, 0, 0, 0],
[0, 1, 0, 0],
[0, 0, 1, 0],
]
report = validator.validate(invalid, schema)
assert report["status"] == "fail"
assert report["coordinate_free"] is False
assert any(
"coordinate placement key is forbidden" in failure
for failure in report["failures"]
)
@@ -81,7 +81,13 @@ class DesignIRPipelineTests(unittest.TestCase):
)
self.assertEqual("simplecadapi", result["backend"])
self.assertGreater(result["facts"]["volume_mm3"], 30_000)
self.assertTrue(Path(result["model_json"]).is_file())
model_json_path = Path(result["model_json"])
self.assertTrue(model_json_path.is_file())
model_json = json.loads(model_json_path.read_text(encoding="utf-8"))
serialized = json.dumps(model_json)
self.assertIn("feature.base.flange", serialized)
self.assertIn("feature.central.bore", serialized)
self.assertTrue(model_json.get("leaf_ids"))
def test_arbitrary_axis_geometric_center_preserves_pose(self) -> None:
payload = self.payload()
@@ -2,6 +2,7 @@ from __future__ import annotations
import importlib.util
import json
import math
import tempfile
import unittest
from pathlib import Path
@@ -226,6 +227,63 @@ class CadExperienceTest(unittest.TestCase):
self.assertFalse(normalized["reconstruction_eligible"])
self.assertIn("no_closed_solid", normalized["rejection_reasons"])
def test_spur_gear_flanks_are_not_mislabeled_as_hole_pattern(self):
surfaces = [
{
"type": "cylinder",
"axis": [1.0, 0.0, 0.0],
"location": [0.0, 0.0, 0.0],
"radius": radius,
"area": 100.0,
}
for radius in (3.0, 8.8)
]
for index in range(40):
angle = 2.0 * math.pi * index / 40.0
surfaces.append(
{
"type": "cylinder",
"axis": [1.0, 0.0, 0.0],
"location": [
0.0,
7.5 * math.cos(angle),
7.5 * math.sin(angle),
],
"radius": 3.2,
"area": 10.0,
}
)
surfaces.extend(
{"type": "plane", "area": 250.0} for _ in range(2)
)
family, features, constraints, summary = STEP_MODULE._semantic_summary(
surfaces, [4.0, 0.0, 0.0], [8.0, 17.6, 17.6]
)
feature_roles = {item["type"] for item in features}
self.assertEqual("external_spur_gear", family)
self.assertEqual(20, summary["inferred_tooth_count"])
self.assertIn("external_spur_gear", feature_roles)
self.assertIn("equal_angular_tooth_pattern", feature_roles)
self.assertNotIn("repeated_axial_hole_pattern", feature_roles)
self.assertIn(
"equal_angular_tooth_pattern",
{item["id"] for item in constraints},
)
parameters = STEP_MODULE._semantic_parameter_candidates(
surfaces,
[4.0, 0.0, 0.0],
[8.0, 17.6, 17.6],
features,
summary,
)
values = {item["name"]: item["value"] for item in parameters}
self.assertEqual(20, values["tooth_count"])
self.assertAlmostEqual(0.8, values["gear_module"], places=6)
self.assertEqual(8.0, values["gear_width"])
self.assertNotIn("repeated_feature_count", values)
def test_sha256_identity_deduplicates_renamed_sources(self):
digest = "0" * 64
with tempfile.TemporaryDirectory() as temporary:
@@ -0,0 +1,78 @@
from __future__ import annotations
import importlib.util
import tempfile
import unittest
from pathlib import Path
import xml.etree.ElementTree as ET
import zipfile
from build123d import Box, export_step
ROOT = Path(__file__).resolve().parents[1]
def load_module(name: str, path: Path):
spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(spec)
assert spec.loader
spec.loader.exec_module(module)
return module
SURFACEIR = load_module(
"surfaceir_pipeline_robot_export_test",
ROOT / "scripts" / "surfaceir_pipeline.py",
)
ROBOT_EXPORT = load_module(
"robot_description_export_test",
ROOT / "scripts" / "robot_description_export.py",
)
class RobotDescriptionExportTests(unittest.TestCase):
def test_portable_urdf_and_mjcf_packages_rebuild_from_designir(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
teacher = root / "teacher.step"
designir = root / "box.designir.json"
export_step(Box(10, 20, 30), teacher)
SURFACEIR.write_json(
designir,
SURFACEIR.extract_surfaceir(teacher),
)
teacher.unlink()
for output_format, description_suffix, xml_root in (
("urdf", ".urdf", "robot"),
("mjcf", ".mjcf.xml", "mujoco"),
):
result = ROBOT_EXPORT.export_robot_package(
designir,
root / output_format,
output_format,
)
package = Path(result["package"])
self.assertTrue(package.is_file())
with zipfile.ZipFile(package) as archive:
names = archive.namelist()
description = next(
name
for name in names
if name.endswith(description_suffix)
)
self.assertTrue(
any(name.startswith("meshes/") and name.endswith(".stl") for name in names)
)
self.assertIn("robot-export.json", names)
root_element = ET.fromstring(archive.read(description))
self.assertEqual(xml_root, root_element.tag)
self.assertEqual(
"static_single_link",
result["robot_structure"],
)
if __name__ == "__main__":
unittest.main()
@@ -6,7 +6,7 @@ import tempfile
import unittest
from pathlib import Path
from build123d import Plane, Rectangle
from build123d import Align, Box, Plane, Rectangle, Sphere, export_step
ROOT = Path(__file__).resolve().parents[1]
@@ -29,6 +29,57 @@ DESIGNIR = load_module(
class SurfaceIRPipelineTests(unittest.TestCase):
def test_closed_single_face_sphere_roundtrip(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
teacher = directory / "sphere.step"
rebuilt_path = directory / "sphere-rebuilt.step"
expected = Sphere(1)
export_step(expected, teacher)
payload = SURFACEIR.extract_surfaceir(teacher)
shell = payload["surface_layer"]["solids"][0]["shells"][0]
self.assertEqual(1, len(shell["faces"]))
rebuilt = SURFACEIR.build_surfaceir(
payload, "exact_3d_pcurve"
)
SURFACEIR.export_step_shape(rebuilt, rebuilt_path)
actual = DESIGNIR.import_step(rebuilt_path)
self.assertTrue(actual.is_valid)
self.assertEqual(1, len(actual.solids()))
self.assertAlmostEqual(expected.volume, actual.volume, places=9)
def test_multi_shell_solid_preserves_periodic_inner_shell(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
teacher = directory / "cavity.step"
rebuilt_path = directory / "cavity-rebuilt.step"
expected = Box(
10,
10,
10,
align=(Align.CENTER, Align.CENTER, Align.CENTER),
) - Sphere(1)
export_step(expected, teacher)
payload = SURFACEIR.extract_surfaceir(teacher)
shells = payload["surface_layer"]["solids"][0]["shells"]
self.assertEqual(2, len(shells))
self.assertTrue(any(len(shell["faces"]) == 1 for shell in shells))
rebuilt = SURFACEIR.build_surfaceir(
payload, "exact_3d_pcurve"
)
SURFACEIR.export_step_shape(rebuilt, rebuilt_path)
actual = DESIGNIR.import_step(rebuilt_path)
self.assertTrue(actual.is_valid)
self.assertEqual(1, len(actual.solids()))
self.assertEqual(2, len(actual.solids()[0].shells()))
self.assertLess(
abs(expected.volume - actual.volume) / expected.volume, 1e-10
)
def test_independent_json_roundtrip_preserves_exact_geometry(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
@@ -42,12 +93,27 @@ class SurfaceIRPipelineTests(unittest.TestCase):
teacher.unlink()
serialized = portable_json.read_text(encoding="utf-8").lower()
stored = json.loads(serialized)
self.assertEqual(
"gzip+base64+json",
stored["surface_layer"]["encoding"],
)
self.assertEqual(
"surfaceir-3.0",
stored["surface_layer"]["format"],
)
self.assertNotIn("teacher.step", serialized)
self.assertNotIn("source_path", serialized)
self.assertNotIn("import_step", serialized)
self.assertNotIn('"mesh"', serialized)
rebuilt_shape = SURFACEIR.build_surfaceir(surfaceir)
decoded = SURFACEIR.read_designir(portable_json)
self.assertEqual(surfaceir["surface_layer"], decoded["surface_layer"])
self.assertIn(
"overall_scale",
decoded["edit_interface"]["semantic_parameters"],
)
rebuilt_shape = SURFACEIR.build_surfaceir(decoded)
SURFACEIR.export_step_shape(rebuilt_shape, rebuilt)
self.assertTrue(rebuilt.is_file())