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