feat: integrate SimpleCADAPI 2.0.2 CAD workflows
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user