283 lines
9.4 KiB
Python
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())
|