#!/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())