Upstream Snapshot bb87928bce Import upstream snapshot 80e710700aac9573a2230f74f7ce9e094833a0bc
Upstream: https://github.com/Rhoban/onshape-to-robot
Upstream-Commit: 80e710700aac9573a2230f74f7ce9e094833a0bc
Upstream-Branch: master
2026-08-28 15:42:31 +08:00

415 lines
15 KiB
Python

from __future__ import annotations
import numpy as np
import os
import fnmatch
from .message import success, warning, info
from .robot import Robot, Link, Part, Joint, Closure
from .config import Config
from .geometry import Box, Cylinder, Sphere, Mesh, Shape
from .exporter import Exporter
from .exporter_utils import xml_escape, rotation_matrix_to_rpy
from transforms3d.quaternions import mat2quat
class ExporterMuJoCo(Exporter):
def __init__(self, config: Config | None = None):
super().__init__()
self.config: Config = config
self.no_dynamics: bool = False
self.additional_xml: str = ""
self.meshes: list = []
self.materials: dict = {}
if config is not None:
self.equalities = self.config.get("equalities", {})
self.no_dynamics = config.no_dynamics
additional_xml_file = config.get("additional_xml", None, required=False)
if isinstance(additional_xml_file, str):
self.add_additional_xml(additional_xml_file)
elif isinstance(additional_xml_file, list):
for filename in additional_xml_file:
self.add_additional_xml(filename)
def add_additional_xml(self, xml_file: str):
self.additional_xml += f"<!-- Additional {xml_file} -->"
with open(self.config.output_directory + "/" + xml_file, "r") as file:
self.additional_xml += file.read()
def append(self, line: str):
self.xml += line
def build(self, robot: Robot):
self.xml = ""
self.append('<?xml version="1.0" ?>')
self.append("<!-- Generated using onshape-to-robot -->")
if self.config:
self.append(f"<!-- Onshape {self.config.printable_version()} -->")
self.append(f'<mujoco model="{robot.name}">')
self.append(
f'<compiler angle="radian" meshdir="{self.config.assets_directory}" autolimits="true" />'
)
# Boilerplate
self.default_class = robot.name
self.append("<default>")
self.append(f'<default class="{self.default_class}">')
self.append('<joint frictionloss="0.1" armature="0.005"/>')
self.append('<position kp="50" dampratio="1" />')
self.append('<default class="visual">')
self.append('<geom type="mesh" contype="0" conaffinity="0" group="2"/>')
self.append("</default>")
self.append('<default class="collision">')
self.append('<geom group="3"/>')
self.append("</default>")
self.append("</default>")
self.append("</default>")
if self.additional_xml:
self.append(self.additional_xml)
# Adding robot links
self.append("<worldbody>")
for base_link in robot.base_links:
self.add_link(robot, base_link)
self.append("</worldbody>")
# Asset (mesh & materials)
self.append("<asset>")
for mesh_file in set(self.meshes):
self.append(f'<mesh file="{mesh_file}" />')
for material_name, color in self.materials.items():
color_str = "%g %g %g %g" % self.config.round(tuple(color))
self.append(f'<material name="{material_name}" rgba="{color_str}" />')
self.append("</asset>")
# Adding actuators
self.add_actuators(robot)
# Adding equalities (loop closure)
self.add_equalities(robot)
self.append("</mujoco>")
return self.xml
def add_actuators(self, robot: Robot):
self.append("<actuator>")
for joint in robot.joints:
if joint.joint_type == "fixed":
continue
# Suppose joints with relation equality is not actuated, unless specified
guess_actuated = joint.relation is None
if (
joint.properties.get("actuated", guess_actuated)
and joint.joint_type != Joint.BALL
):
type = joint.properties.get("type", "position")
actuator_class = joint.properties.get("class", self.default_class)
actuator: str = f'<{type} class="{actuator_class}" name="{joint.name}" joint="{joint.name}" '
for key in "kp", "kv", "dampratio":
if key in joint.properties:
actuator += f'{key}="{joint.properties[key]}" '
if "forcerange" in joint.properties:
actuator += f'forcerange="-{joint.properties["forcerange"]} {joint.properties["forcerange"]}" '
joint_limits = joint.properties.get("limits", joint.limits)
limits_are_set = joint.properties.get("limits", False) != False
if joint_limits and (type == "position" or limits_are_set):
if joint.properties.get("range", True) and type == "position":
actuator += f'inheritrange="1" '
else:
actuator += f'ctrlrange="{joint_limits[0]} {joint_limits[1]}" '
actuator += "/>"
self.append(actuator)
self.append("</actuator>")
def get_equality_attributes(self, closure: Closure) -> str:
all_attributes = {}
for name, attributes in self.equalities.items():
if fnmatch.fnmatch(closure.frame1, name) and fnmatch.fnmatch(
closure.frame2, name
):
all_attributes.update(attributes)
if len(all_attributes) > 0:
return (
" ".join([f'{key}="{value}"' for key, value in all_attributes.items()])
+ " "
)
return ""
def add_equalities(self, robot: Robot):
self.append("<equality>")
for closure in robot.closures:
attributes = self.get_equality_attributes(closure)
if closure.closure_type == Closure.FIXED:
self.append(
f'<weld site1="{closure.frame1}" site2="{closure.frame2}" {attributes}/>'
)
elif closure.closure_type == Closure.REVOLUTE:
self.append(
f'<connect site1="{closure.frame1}" site2="{closure.frame2}" {attributes}/>'
)
elif closure.closure_type == Closure.BALL:
self.append(
f'<connect site1="{closure.frame1}" site2="{closure.frame2}" {attributes}/>'
)
else:
print(
warning(
f"Closure type: {closure.closure_type} is not supported with MuJoCo equality constraints"
)
)
for joint in robot.joints:
if joint.relation is not None:
self.append(
f'<joint joint1="{joint.name}" joint2="{joint.relation.source_joint}" polycoef="0 {joint.relation.ratio} 0 0 0" />'
)
self.append("</equality>")
def add_inertial(self, mass: float, com: np.ndarray, inertia: np.ndarray):
# Ensuring epsilon masses and inertias
mass = max(1e-9, mass)
inertia[0, 0] = max(1e-9, inertia[0, 0])
inertia[1, 1] = max(1e-9, inertia[1, 1])
inertia[2, 2] = max(1e-9, inertia[2, 2])
# Populating body inertial properties
# https://mujoco.readthedocs.io/en/stable/XMLreference.html#body-inertial
inertial: str = "<inertial "
inertial += 'pos="%g %g %g" ' % self.config.round(tuple(com))
inertial += 'mass="%g" ' % self.config.round(mass)
inertial += 'fullinertia="%g %g %g %g %g %g" ' % self.config.round(
(
inertia[0, 0],
inertia[1, 1],
inertia[2, 2],
inertia[0, 1],
inertia[0, 2],
inertia[1, 2],
)
)
inertial += " />"
self.append(inertial)
def add_mesh(self, part: Part, class_: str, T_world_link: np.ndarray, mesh: Mesh):
"""
Add a mesh node (e.g. STL) to the MuJoCo file
"""
# Retrieving mesh file and material name
mesh_file = os.path.relpath(self.config.output_directory + "/" + mesh.filename, self.config.asset_path(""))
mesh_file_no_ext = ".".join(os.path.basename(mesh_file).split(".")[:-1])
material_name = mesh_file_no_ext + "_material"
# Relative frame
T_link_part = np.linalg.inv(T_world_link) @ part.T_world_part
# Adding the geom node
geom = f'<geom type="mesh" class="{class_}" '
geom += self.pos_quat(T_link_part) + " "
geom += f'mesh="{xml_escape(mesh_file_no_ext)}" '
geom += f'material="{xml_escape(material_name)}" '
# Apply properties based on class (visual or collision)
properties = mesh.visual_properties if class_ == "visual" else mesh.collision_properties
for key, value in properties.items():
geom += f'{key}="{xml_escape(str(value))}" '
geom += " />"
# Adding the mesh and material to appear in the assets section
self.meshes.append(mesh_file)
self.materials[material_name] = mesh.color
self.append(geom)
def add_shape(
self, part: Part, class_: str, T_world_link: np.ndarray, shape: Shape
):
"""
Add pure shape geometry.
"""
geom = f'<geom class="{class_}" '
T_link_shape = (
np.linalg.inv(T_world_link) @ part.T_world_part @ shape.T_part_shape
)
geom += self.pos_quat(T_link_shape) + " "
if isinstance(shape, Box):
geom += 'type="box" size="%g %g %g" ' % self.config.round(tuple(shape.size / 2))
elif isinstance(shape, Cylinder):
geom += 'type="cylinder" size="%g %g" ' % (
shape.radius,
shape.length / 2,
)
elif isinstance(shape, Sphere):
geom += 'type="sphere" size="%g" ' % self.config.round(shape.radius)
if class_ == "visual":
material_name = f"{part.name}_material"
self.materials[material_name] = shape.color
geom += f'material="{xml_escape(material_name)}" '
# Apply properties based on class (visual or collision)
properties = shape.visual_properties if class_ == "visual" else shape.collision_properties
for key, value in properties.items():
geom += f'{key}="{xml_escape(str(value))}" '
geom += " />"
self.append(geom)
def add_geometries(self, part: Part, T_world_link: np.ndarray):
"""
Add a part geometries
"""
for shape in part.shapes:
if shape.visual:
self.add_shape(part, "visual", T_world_link, shape)
if shape.collision:
self.add_shape(part, "collision", T_world_link, shape)
for mesh in part.meshes:
if mesh.visual:
self.add_mesh(part, "visual", T_world_link, mesh)
if mesh.collision:
self.add_mesh(part, "collision", T_world_link, mesh)
def add_joint(self, joint: Joint):
self.append(f"<!-- Joint from {joint.parent.name} to {joint.child.name} -->")
if joint.joint_type == "fixed":
self.append(f'<!-- Joint is "fixed", skipping it -->')
return
joint_xml: str = "<joint "
joint_xml += 'axis="%g %g %g" ' % self.config.round(tuple(joint.axis))
joint_xml += f'name="{joint.name}" '
if joint.joint_type == Joint.REVOLUTE:
joint_xml += 'type="hinge" '
elif joint.joint_type == Joint.PRISMATIC:
joint_xml += 'type="slide" '
elif joint.joint_type == Joint.BALL:
joint_xml += 'type="ball" '
else:
print(warning(f"Unknown joint type: {joint.joint_type}"))
joint_limits = joint.properties.get("limits", joint.limits)
if joint_limits is not None and joint.properties.get("range", True):
joint_xml += f'range="{joint_limits[0]} {joint_limits[1]}" '
for key in (
"class",
"frictionloss",
"armature",
"damping",
"stiffness",
):
if key in joint.properties:
joint_xml += f'{key}="{joint.properties[key]}" '
joint_xml += " />"
self.append(joint_xml)
def add_frame(
self,
frame: str,
T_world_link: np.ndarray,
T_world_frame: np.ndarray,
group: int = 0,
):
self.append(f"<!-- Frame {frame} -->")
T_link_frame = np.linalg.inv(T_world_link) @ T_world_frame
site: str = f'<site group="{group}" name="{frame}" '
site += self.pos_quat(T_link_frame) + " "
site += " />"
self.append(site)
def add_link(
self,
robot: Robot,
link: Link,
parent_joint: Joint | None = None,
T_world_parent: np.ndarray = np.eye(4),
):
"""
Adds a link recursively to the URDF file
"""
if parent_joint is None:
T_world_link = np.eye(4)
else:
T_world_link = parent_joint.T_world_joint
childclass = ""
if parent_joint is None:
childclass = f'childclass="{self.default_class}" '
self.append(f"<!-- Link {link.name} -->")
T_parent_link = np.linalg.inv(T_world_parent) @ T_world_link
self.append(
f'<body name="{link.name}" {self.pos_quat(T_parent_link)} {childclass}>'
)
if parent_joint is None:
if not link.fixed:
self.append(f'<freejoint name="{link.name}_freejoint" />')
else:
self.add_joint(parent_joint)
# Adding inertial properties
mass, com, inertia = link.get_dynamics(T_world_link)
self.add_inertial(mass, com, inertia)
# Adding geometry objects
for part in link.parts:
self.append(f"<!-- Part {part.name} -->")
self.add_geometries(part, T_world_link)
# Adding frames attached to current link
for frame, T_world_frame in link.frames.items():
self.add_frame(frame, T_world_link, T_world_frame, group=3)
# Adding joints and children links
for joint in robot.get_link_joints(link):
self.add_link(robot, joint.child, joint, T_world_link)
self.append("</body>")
def pos_quat(self, matrix: np.ndarray) -> str:
"""
Turn a transformation matrix into 'pos="..." quat="..."' attributes
"""
pos = matrix[:3, 3]
quat = mat2quat(matrix[:3, :3])
xml = 'pos="%g %g %g" quat="%g %g %g %g"' % self.config.round((*pos, *quat))
return xml
def write_xml(self, robot: Robot, filename: str) -> str:
super().write_xml(robot, filename)
dirname = os.path.dirname(filename)
scene_filename = dirname + "/scene.xml"
if not os.path.exists(scene_filename):
scene_xml: str = (
os.path.dirname(os.path.realpath(__file__)) + "/assets/scene.xml"
)
scene_xml = open(scene_xml, "r").read()
scene_xml = scene_xml.format(robot_filename=os.path.basename(filename))
with open(scene_filename, "w") as file:
file.write(scene_xml)
print(success(f"* Writing scene.xml"))
else:
print(info(f"* scene.xml already exists, not over-writing it"))