from __future__ import annotations import re import xml.etree.ElementTree as ET from pathlib import Path from typing import Dict, Iterable, Optional, Tuple import numpy as np import pinocchio as pin from .dual_arm import default_dual_source_path, load_dual_arm_mounts from .kinematics import default_urdf_path from .types import physical_joint_limits def _numbers(text: str, expected: int) -> np.ndarray: values = np.fromstring(text, sep=" ", dtype=float) if values.shape != (expected,): raise ValueError(f"expected {expected} numeric values, got {text!r}") return values def _format(values: Iterable[float]) -> str: return " ".join(f"{float(value):.12g}" for value in values) def _quaternion_from_rpy(rpy: np.ndarray) -> np.ndarray: quaternion = pin.Quaternion(pin.rpy.rpyToMatrix(*rpy)) return np.array([quaternion.w, quaternion.x, quaternion.y, quaternion.z]) def _se3_attributes(transform: pin.SE3) -> Dict[str, str]: quaternion = pin.Quaternion(transform.rotation) return { "pos": _format(transform.translation), "quat": _format([quaternion.w, quaternion.x, quaternion.y, quaternion.z]), } def _origin_attributes(parent: ET.Element) -> Dict[str, str]: origin = parent.find("origin") if origin is None: return {"pos": "0 0 0", "quat": "1 0 0 0"} xyz = _numbers(origin.get("xyz", "0 0 0"), 3) rpy = _numbers(origin.get("rpy", "0 0 0"), 3) return {"pos": _format(xyz), "quat": _format(_quaternion_from_rpy(rpy))} def _mesh_asset_name(filename: str, prefix: str) -> str: stem = Path(filename).stem.lower() return f"{prefix}_{re.sub(r'[^a-z0-9_]+', '_', stem)}" def _dual_obj_directory(dual_source_path: Path) -> Path: source_candidate = dual_source_path.parent / "dual_arm_obj" if source_candidate.is_dir(): return source_candidate raise FileNotFoundError( f"dual-arm OBJ directory was not found beside {dual_source_path}" ) def _single_mesh_directory(single_urdf_path: Path) -> Path: candidates = ( single_urdf_path.parent / "meshes", single_urdf_path.parent / "rm75_meshes", ) for candidate in candidates: if candidate.is_dir(): return candidate raise FileNotFoundError( f"RM75-B STL mesh directory was not found beside {single_urdf_path}" ) def _add_inertial(body: ET.Element, link: ET.Element) -> None: inertial = link.find("inertial") if inertial is None: raise ValueError(f"link {link.get('name')!r} has no inertial data") mass_element = inertial.find("mass") inertia_element = inertial.find("inertia") if mass_element is None or inertia_element is None: raise ValueError(f"link {link.get('name')!r} has incomplete inertial data") origin = inertial.find("origin") xyz = _numbers(origin.get("xyz", "0 0 0"), 3) if origin is not None else np.zeros(3) rpy = _numbers(origin.get("rpy", "0 0 0"), 3) if origin is not None else np.zeros(3) inertia_matrix = np.array( [ [float(inertia_element.get("ixx")), float(inertia_element.get("ixy")), float(inertia_element.get("ixz"))], [float(inertia_element.get("ixy")), float(inertia_element.get("iyy")), float(inertia_element.get("iyz"))], [float(inertia_element.get("ixz")), float(inertia_element.get("iyz")), float(inertia_element.get("izz"))], ] ) rotation = pin.rpy.rpyToMatrix(*rpy) inertia_matrix = rotation @ inertia_matrix @ rotation.T full_inertia = [ inertia_matrix[0, 0], inertia_matrix[1, 1], inertia_matrix[2, 2], inertia_matrix[0, 1], inertia_matrix[0, 2], inertia_matrix[1, 2], ] ET.SubElement( body, "inertial", { "pos": _format(xyz), "mass": mass_element.get("value", "0"), "fullinertia": _format(full_inertia), }, ) def _add_link_visual( body: ET.Element, link: ET.Element, arm: str, single_mesh_keys: Dict[str, str], ) -> None: visual = link.find("visual") if visual is None: return mesh = visual.find("geometry/mesh") if mesh is None: return filename = Path(mesh.get("filename", "")).name try: mesh_name = single_mesh_keys[filename] except KeyError as exc: raise ValueError(f"missing packaged single-arm mesh {filename!r}") from exc ET.SubElement( body, "geom", { "name": f"{arm}_{link.get('name')}_visual", "type": "mesh", "mesh": mesh_name, "material": f"{arm}_arm", "contype": "0", "conaffinity": "0", "group": "1", **_origin_attributes(visual), }, ) def _add_arm( worldbody: ET.Element, arm: str, mount: pin.SE3, single_root: ET.Element, single_mesh_keys: Dict[str, str], gripper_mesh_name: str, ) -> None: links = {link.get("name"): link for link in single_root.findall("link")} joints_by_parent: Dict[str, list[ET.Element]] = {} for joint in single_root.findall("joint"): parent_element = joint.find("parent") if parent_element is None: continue joints_by_parent.setdefault(parent_element.get("link", ""), []).append(joint) limits = physical_joint_limits() base_link = links["base_link"] base_body = ET.SubElement( worldbody, "body", {"name": f"{arm}_base_link", **_se3_attributes(mount)}, ) _add_link_visual(base_body, base_link, arm, single_mesh_keys) def append_children(parent_body: ET.Element, parent_link_name: str) -> None: for joint in joints_by_parent.get(parent_link_name, []): child_element = joint.find("child") if child_element is None: raise ValueError(f"joint {joint.get('name')!r} has no child") child_name = child_element.get("link", "") child_link = links[child_name] body = ET.SubElement( parent_body, "body", { "name": f"{arm}_{child_name}", **_origin_attributes(joint), }, ) joint_index = int(joint.get("name", "joint_0").split("_")[-1]) - 1 axis_element = joint.find("axis") axis = "0 0 1" if axis_element is None else axis_element.get("xyz", "0 0 1") ET.SubElement( body, "joint", { "name": f"{arm}_joint_{joint_index + 1}", "type": "hinge", "axis": axis, "range": _format( [limits.lower[joint_index], limits.upper[joint_index]] ), "limited": "true", "damping": "0", }, ) _add_inertial(body, child_link) _add_link_visual(body, child_link, arm, single_mesh_keys) if child_name == "link_7": ET.SubElement( body, "site", { "name": f"{arm}_flange", "type": "sphere", "size": "0.008", "rgba": "0.1 0.9 0.2 1" if arm == "left" else "0.1 0.5 0.95 1", "group": "2", }, ) ET.SubElement( body, "geom", { "name": f"{arm}_gripper_visual", "type": "mesh", "mesh": gripper_mesh_name, "pos": "0 0 0.092", "quat": _format(_quaternion_from_rpy(np.array([-np.pi, np.pi, -np.pi]))), "material": f"{arm}_gripper", "contype": "0", "conaffinity": "0", "group": "1", }, ) append_children(body, child_name) append_children(base_body, "base_link") def build_normalized_dual_mjcf( single_urdf_path: Optional[Path | str] = None, dual_source_path: Optional[Path | str] = None, ) -> Tuple[str, Dict[str, bytes]]: """Build a canonical 14-DOF dual-arm MJCF and its in-memory mesh assets.""" single_path = ( Path(single_urdf_path) if single_urdf_path is not None else default_urdf_path() ) dual_path = ( Path(dual_source_path) if dual_source_path is not None else default_dual_source_path() ) single_root = ET.parse(single_path).getroot() dual_root = ET.parse(dual_path).getroot() single_mesh_dir = _single_mesh_directory(single_path) dual_obj_dir = _dual_obj_directory(dual_path) mujoco_root = ET.Element("mujoco", {"model": "rm75_normalized_dual_stage2"}) ET.SubElement( mujoco_root, "compiler", { "angle": "radian", "autolimits": "true", "inertiafromgeom": "false", "balanceinertia": "true", }, ) ET.SubElement( mujoco_root, "option", {"timestep": "0.002", "gravity": "0 0 -9.81", "integrator": "implicitfast"}, ) ET.SubElement(mujoco_root, "statistic", {"center": "0 0 0.65", "extent": "1.4"}) visual = ET.SubElement(mujoco_root, "visual") ET.SubElement( visual, "global", { "azimuth": "90", "elevation": "-18", "offwidth": "1280", "offheight": "720", }, ) ET.SubElement(visual, "rgba", {"haze": "0.15 0.18 0.2 1"}) assets_element = ET.SubElement(mujoco_root, "asset") ET.SubElement(assets_element, "material", {"name": "left_arm", "rgba": "0.82 0.84 0.86 1"}) ET.SubElement(assets_element, "material", {"name": "right_arm", "rgba": "0.7 0.74 0.78 1"}) ET.SubElement(assets_element, "material", {"name": "left_gripper", "rgba": "0.15 0.75 0.85 1"}) ET.SubElement(assets_element, "material", {"name": "right_gripper", "rgba": "0.9 0.48 0.18 1"}) ET.SubElement(assets_element, "material", {"name": "platform", "rgba": "0.28 0.31 0.34 1"}) assets: Dict[str, bytes] = {} single_mesh_keys: Dict[str, str] = {} for mesh_path in sorted(single_mesh_dir.glob("*.STL")): asset_key = f"rm75_meshes/{mesh_path.name}" mesh_name = _mesh_asset_name(mesh_path.name, "rm75") assets[asset_key] = mesh_path.read_bytes() single_mesh_keys[mesh_path.name] = mesh_name ET.SubElement(assets_element, "mesh", {"name": mesh_name, "file": asset_key}) dual_mesh_names: Dict[str, str] = {} for mesh_path in sorted(dual_obj_dir.glob("*.obj")): asset_key = f"dual_arm_obj/{mesh_path.name}" mesh_name = _mesh_asset_name(mesh_path.name, "dual") assets[asset_key] = mesh_path.read_bytes() dual_mesh_names[mesh_path.name] = mesh_name ET.SubElement(assets_element, "mesh", {"name": mesh_name, "file": asset_key}) if len(dual_mesh_names) != 19: raise ValueError(f"expected 19 dual-arm OBJ assets, found {len(dual_mesh_names)}") worldbody = ET.SubElement(mujoco_root, "worldbody") ET.SubElement( worldbody, "light", { "name": "key_light", "pos": "0 -1.2 2.8", "dir": "0 0.4 -1", "diffuse": "0.9 0.9 0.9", }, ) ET.SubElement( worldbody, "camera", { "name": "overview", "pos": "0 -2.6 1.25", "xyaxes": "1 0 0 0 0.3 0.953939", "fovy": "45", }, ) ET.SubElement( worldbody, "geom", { "name": "floor", "type": "plane", "size": "2.5 2.5 0.05", "rgba": "0.12 0.14 0.16 1", "contype": "0", "conaffinity": "0", "group": "0", }, ) robot_base = dual_root.find("./link[@name='robot_base']") if robot_base is None: raise ValueError("dual-arm source URDF has no robot_base link") for visual_element in robot_base.findall("visual"): mesh_element = visual_element.find("geometry/mesh") if mesh_element is None: continue filename = Path(mesh_element.get("filename", "")).name ET.SubElement( worldbody, "geom", { "name": f"platform_{visual_element.get('name', filename)}", "type": "mesh", "mesh": dual_mesh_names[filename], "material": "platform", "contype": "0", "conaffinity": "0", "group": "1", **_origin_attributes(visual_element), }, ) mounts = load_dual_arm_mounts(dual_path) _add_arm( worldbody, "left", mounts.left_base, single_root, single_mesh_keys, dual_mesh_names["dual_arm_gripper1_vis_1.obj"], ) _add_arm( worldbody, "right", mounts.right_base, single_root, single_mesh_keys, dual_mesh_names["dual_arm_gripper2_vis_1.obj"], ) for arm, color in ( ("left", "0.95 0.12 0.12 0.8"), ("right", "0.95 0.75 0.08 0.8"), ): marker = ET.SubElement( worldbody, "body", {"name": f"{arm}_target_marker", "mocap": "true", "pos": "0 0 0"}, ) ET.SubElement( marker, "geom", { "name": f"{arm}_target_marker_geom", "type": "sphere", "size": "0.018", "rgba": color, "contype": "0", "conaffinity": "0", "group": "2", }, ) ET.indent(mujoco_root, space=" ") return ET.tostring(mujoco_root, encoding="unicode"), assets