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