Files
cadSet/designir-pipeline/scripts/robot_description_export.py
T

283 lines
9.4 KiB
Python

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