Files
OneCAD/crates/io/src/urdf.rs
T
Mateusz Sych 17db109c5d URDF export for robot simulators (#719) (#942)
A design with components and joints exports as a URDF robot description (file.export to .urdf, the MCP export tool, solidcraft-cli run --out, File > Export URDF...). It writes one binary STL per component in metres, links with mass, centre of mass and inertia from material density, and maps rigid, revolute, slider, cylindrical, pin-slot, planar and ball joints to URDF joints or short chains. Loops, broken joints and loose parts are reported as warnings. Options: mesh_prefix, effort, velocity. Docs in docs/urdf.md.
2026-10-13 21:52:30 -04:00

917 lines
38 KiB
Rust

//! URDF robot description of a design's components and joints (#719, docs/urdf.md): a link per
//! placement of a component (plus `base_link` for the root and what is grounded), a joint chain
//! per SolidCraft joint, meshes in metres next to the `.urdf`, and each link's mass, centre of
//! mass and inertia from its bodies and their materials.
//!
//! Frames follow the joint kinematics `T_B · F_b = T_A · F_a · M(q) · Flip` (`doc::joints`).
//! Every constant is taken from world transforms at the current state, so `Flip` never appears:
//! with the child on B its link frame is `L = T_A · F_a · M(q)`; with the child on A it is
//! `L = T_A · F_a` and the parent-side joint frame `L · M(q)`, the chain inverted (reversed, axes
//! negated, so each URDF value still equals SolidCraft's).
use std::collections::{BTreeMap, BTreeSet, HashSet, VecDeque};
use solidcraft_doc::joints::{self, Joint, JointKind};
use solidcraft_doc::{Document, IDENTITY, MAX_PLACEMENT_DEPTH, MAX_PLACEMENTS, Mat, ModelState, Occurrence, apply_point, mat_inverse, mat_mul};
use solidcraft_geom::{Mesh, Vec3};
use crate::{IoError, Result, export_mesh, stl_binary, weld, weld_eps};
/// Mass (kg) and inertia (kg m²) of a link with no massive body: Gazebo drops massless links.
const TOKEN_MASS: f64 = 1e-6;
const TOKEN_INERTIA: f64 = 1e-12;
/// Travel written for a prismatic step without a limit (mm): URDF requires one.
const UNLIMITED_TRAVEL_MM: f64 = 1e6;
const DEFAULT_EFFORT: f64 = 1000.0;
const DEFAULT_VELOCITY: f64 = 10.0;
/// Choices for [`urdf`] (`file.export` parameters).
#[derive(Clone, Debug, Default)]
pub struct UrdfOptions {
/// Put before each mesh file name (`package://robot/meshes/` for RViz; empty: relative).
pub mesh_prefix: String,
/// `<limit effort>` (N or N m) and `velocity` (m/s or rad/s); defaults with a warning.
pub effort: Option<f64>,
pub velocity: Option<f64>,
}
/// The URDF, its mesh files (name next to the `.urdf`, bytes) and what was approximated.
#[derive(Clone, Debug)]
pub struct UrdfPackage {
pub xml: String,
pub meshes: Vec<(String, Vec<u8>)>,
pub model: UrdfModel,
pub warnings: Vec<String>,
}
/// The robot before serialisation (lengths in mm): tests compose it to check the kinematics.
#[derive(Clone, Debug, Default)]
pub struct UrdfModel {
pub links: Vec<Link>,
pub joints: Vec<UrdfJoint>,
}
#[derive(Clone, Debug)]
pub struct Link {
pub name: String,
/// The placement it is (occurrence ids from the root, as `Document::placements`); empty for
/// `base_link` and a chain's virtual links.
pub path: Vec<u64>,
/// Mesh coordinates (the component's frame) to the link frame.
pub mesh_origin: Mat,
pub visual: Option<String>,
pub collision: Option<String>,
/// sRGB and opacity, 0..1.
pub color: Option<[f32; 4]>,
pub inertial: Option<Inertial>,
}
/// Mass (kg), centre of mass (mm, link frame) and inertia about it (kg m², link axes).
#[derive(Clone, Copy, Debug)]
pub struct Inertial {
pub mass: f64,
pub com: Vec3,
pub inertia: [[f64; 3]; 3],
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Step {
Fixed,
Revolute,
Prismatic,
}
#[derive(Clone, Debug)]
pub struct UrdfJoint {
pub name: String,
pub parent: usize,
pub child: usize,
/// Parent link frame to this joint's frame (mm).
pub origin: Mat,
pub step: Step,
pub axis: Vec3,
/// (lower, upper): radians or mm; `None` for a revolute without limit (`continuous`).
pub limit: Option<(f64, f64)>,
/// The SolidCraft joint and the index of its value this step follows.
pub source: Option<(u64, usize)>,
}
impl UrdfModel {
/// Every link's world frame (mm) for the given value of each step.
pub fn link_frames(&self, value: impl Fn(&UrdfJoint) -> f64) -> Vec<Mat> {
let mut frames = vec![IDENTITY; self.links.len()];
let mut done = vec![false; self.links.len()];
if let Some(d) = done.get_mut(0) {
*d = true;
}
// Joints are emitted parents first.
for j in &self.joints {
let (Some(parent), true) = (frames.get(j.parent).copied(), done.get(j.parent).copied().unwrap_or(false)) else { continue };
let q = value(j);
let m = match j.step {
Step::Fixed => IDENTITY,
Step::Revolute => rotation(j.axis, q),
Step::Prismatic => translation(j.axis * q),
};
if let (Some(f), Some(d)) = (frames.get_mut(j.child), done.get_mut(j.child)) {
*f = mat_mul(&mat_mul(&parent, &j.origin), &m);
*d = true;
}
}
frames
}
}
fn translation(v: Vec3) -> Mat {
let mut m = IDENTITY;
m[3] = [v.x, v.y, v.z, 1.0];
m
}
/// Rotation by `a` about the unit axis `u` (Rodrigues), column-major like `Mat`.
fn rotation(u: Vec3, a: f64) -> Mat {
let (s, c) = a.sin_cos();
let t = 1.0 - c;
let r = [
[t * u.x * u.x + c, t * u.x * u.y - s * u.z, t * u.x * u.z + s * u.y],
[t * u.x * u.y + s * u.z, t * u.y * u.y + c, t * u.y * u.z - s * u.x],
[t * u.x * u.z - s * u.y, t * u.y * u.z + s * u.x, t * u.z * u.z + c],
];
// r[row][col] -> m[col][row]
let mut m = IDENTITY;
for (row, cols) in r.iter().enumerate() {
for (col, v) in cols.iter().enumerate() {
m[col][row] = *v;
}
}
m
}
/// The joint's motion as URDF steps, in order from A's frame: (kind, axis, index of the value).
fn steps(kind: JointKind) -> Vec<(Step, Vec3, usize)> {
use Step::{Prismatic as P, Revolute as R};
match kind {
JointKind::Rigid => vec![],
JointKind::Revolute => vec![(R, Vec3::Z, 0)],
JointKind::Slider => vec![(P, Vec3::Z, 0)],
JointKind::Cylindrical => vec![(R, Vec3::Z, 0), (P, Vec3::Z, 1)],
JointKind::PinSlot => vec![(P, Vec3::X, 1), (R, Vec3::Z, 0)],
JointKind::Planar => vec![(P, Vec3::X, 0), (P, Vec3::Y, 1), (R, Vec3::Z, 2)],
JointKind::Ball => vec![(R, Vec3::Z, 0), (R, Vec3::Y, 1), (R, Vec3::Z, 2)],
}
}
/// `raw` with every character but `[A-Za-z0-9_]` (and `-` in file names) replaced by `_`, so it
/// is a valid name in URDF, SDFormat and XML.
fn sanitise(raw: &str, file: bool) -> String {
let s: String = raw.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' || (file && c == '-') { c } else { '_' }).collect();
if s.is_empty() { "part".into() } else { s }
}
/// Unique names over links, joints and files (SDFormat refuses a joint named like a link).
#[derive(Default)]
struct Names(HashSet<String>);
impl Names {
fn take(&mut self, raw: &str) -> String {
self.unique(sanitise(raw, false))
}
/// A mesh file name, unique without regard to case: Windows and macOS file systems ignore
/// it, so `t-base` and `t-Base` would be one file.
fn file(&mut self, raw: &str) -> String {
let base = sanitise(raw, true);
let mut name = base.clone();
let mut k = 2;
while !self.0.insert(name.to_ascii_lowercase()) {
name = format!("{base}_{k}");
k += 1;
}
format!("{name}.stl")
}
fn unique(&mut self, base: String) -> String {
let mut name = base.clone();
let mut k = 2;
while !self.0.insert(name.clone()) {
name = format!("{base}_{k}");
k += 1;
}
name
}
}
/// One body's mass properties in its component's frame: (mass kg, centre mm, inertia kg mm²).
type BodyMass = (f64, Vec3, [[f64; 3]; 3]);
/// A component's meshes and masses.
struct Part {
visual: Option<String>,
collision: Option<String>,
color: Option<[f32; 4]>,
masses: Vec<BodyMass>,
}
/// Mesh files and masses per component (0: the root's bodies, named `base`).
fn parts(doc: &Document, st: &ModelState, stem: &str, names: &mut Names, out: &mut UrdfPackage) -> Result<BTreeMap<u64, Part>> {
let mut used: BTreeSet<u64> = doc.occurrences.iter().map(|o| o.component).collect();
used.insert(0);
let mut parts = BTreeMap::new();
for c in used {
let label = if c == 0 { "base".to_string() } else { doc.components.iter().find(|x| x.id == c).map(|x| x.name.clone()).unwrap_or_default() };
let (mut visual, mut collision) = (Mesh::default(), Mesh::default());
let (mut masses, mut color, mut open) = (Vec::new(), None, false);
for b in st.bodies.iter().filter(|b| doc.model_body_component(b) == c) {
let mut m = export_mesh(&b.body)?;
if let Some(o) = doc.model_body_offset(b) {
for p in &mut m.positions {
*p = apply_point(o, *p);
}
}
let (positions, triangles) = weld(&m.positions, &m.triangles, weld_eps(&b.body));
let welded = Mesh { positions, triangles, ..Mesh::default() };
let opacity = b.body.paint().map(|p| p.opacity).filter(|o| o.is_finite()).map_or(1.0, |o| o.clamp(0.0, 1.0));
color = color.or(b.body.color().map(|[r, g, b]| [r, g, b, opacity]));
append(&mut visual, &welded);
match welded.mass_properties() {
Some(mp) => {
// g/cm³ to kg/mm³.
let rho = doc.density(&b.name) * 1e-6;
masses.push((rho * mp.volume, mp.centroid, mp.inertia.map(|r| r.map(|x| x * rho))));
append(&mut collision, &welded);
}
None => {
open = true;
out.warnings.push(format!("body `{}` is not a closed solid: it is drawn but adds no mass or collision", b.name));
}
}
}
let mut part = Part { visual: None, collision: None, color, masses };
if !visual.triangles.is_empty() {
let file = names.file(&format!("{stem}-{label}"));
out.meshes.push((file.clone(), stl_binary(&[metres(&visual)], &label)));
part.visual = Some(file.clone());
if !open {
part.collision = Some(file);
} else if !collision.triangles.is_empty() {
let file = names.file(&format!("{stem}-{label}-collision"));
out.meshes.push((file.clone(), stl_binary(&[metres(&collision)], &label)));
part.collision = Some(file);
}
}
parts.insert(c, part);
}
Ok(parts)
}
/// The placement `occurrence_world` resolves for an occurrence: through the first occurrence of
/// each parent component.
fn canonical(doc: &Document, occ: u64) -> Vec<u64> {
let mut path = Vec::new();
let mut cur = doc.occurrences.iter().find(|o| o.id == occ);
for _ in 0..1000 {
let Some(o) = cur else { break };
path.push(o.id);
if o.parent == 0 {
break;
}
cur = doc.occurrence_of(o.parent);
}
path.reverse();
path
}
/// The tree as it grows.
struct Tree<'a> {
doc: &'a Document,
parts: BTreeMap<u64, Part>,
names: Names,
model: UrdfModel,
warnings: Vec<String>,
/// Occurrence to its link and the link's world frame (0: `base_link`).
placed: BTreeMap<u64, (usize, Mat)>,
/// Each usable joint's name, taken before any link's so it keeps the name the user gave.
joint_names: BTreeMap<u64, String>,
}
impl<'a> Tree<'a> {
fn world(&self, occ: u64) -> Mat {
if occ == 0 { IDENTITY } else { joints::occurrence_world(self.doc, occ) }
}
fn occurrence(&self, occ: u64) -> Option<&'a Occurrence> {
let doc = self.doc;
doc.occurrences.iter().find(|o| o.id == occ)
}
/// A link for placement `path` of `o`: its frame is `frame`, its component's meshes sit at
/// `at` (both world). Named after the component when it is placed once, else the occurrence.
fn link(&mut self, o: &Occurrence, path: Vec<u64>, frame: &Mat, at: &Mat) -> Option<usize> {
let mesh_origin = mat_mul(&mat_inverse(frame)?, at);
let doc = self.doc;
let single = doc.occurrences.iter().filter(|x| x.component == o.component).count() == 1;
let raw = match doc.components.iter().find(|c| c.id == o.component) {
Some(c) if single => c.name.as_str(),
_ => o.name.as_str(),
};
let part = self.parts.get(&o.component);
let link = Link {
name: self.names.take(raw),
path,
mesh_origin,
visual: part.and_then(|p| p.visual.clone()),
collision: part.and_then(|p| p.collision.clone()),
color: part.and_then(|p| p.color),
inertial: part.and_then(|p| inertial(&p.masses, &mesh_origin)).or(Some(token())),
};
self.model.links.push(link);
Some(self.model.links.len() - 1)
}
/// `child` hangs on `parent` where it is (`origin`: parent link frame to child link frame).
fn fixed(&mut self, parent: usize, child: usize, origin: Mat) {
let n = self.model.links.get(child).map(|l| format!("{}_fixed", l.name)).unwrap_or_default();
let name = self.names.take(&n);
self.model.joints.push(UrdfJoint { name, parent, child, origin, step: Step::Fixed, axis: Vec3::Z, limit: None, source: None });
}
/// The link an occurrence fixed in place hangs on, and its frame: `base_link` for the top
/// level (or `to_base`), else its parent occurrence's link once that is in the tree.
fn parent_link(&self, o: &Occurrence, to_base: bool) -> Option<(usize, Mat)> {
if to_base || o.parent == 0 {
return Some((0, IDENTITY));
}
let p = self.doc.occurrence_of(o.parent)?;
self.placed.get(&p.id).copied()
}
/// Fix an occurrence where it is on its parent link.
fn fix(&mut self, occ: u64, to_base: bool) -> Option<usize> {
let o = self.occurrence(occ)?;
let (parent, parent_frame) = self.parent_link(o, to_base)?;
let w = self.world(occ);
let origin = mat_mul(&mat_inverse(&parent_frame)?, &w);
let i = self.link(o, canonical(self.doc, occ), &w, &w)?;
self.fixed(parent, i, origin);
self.placed.insert(occ, (i, w));
Some(i)
}
/// Breadth-first over the usable joints from the occurrences in `queue`.
fn traverse(&mut self, queue: &mut VecDeque<u64>, usable: &[(&Joint, Mat)], taken: &mut HashSet<u64>) {
let values = joints::joint_values(&self.doc.assembly);
while let Some(p) = queue.pop_front() {
for (j, fa) in usable {
if taken.contains(&j.id) || (j.a.occurrence != p && j.b.occurrence != p) {
continue;
}
taken.insert(j.id);
let child = if j.a.occurrence == p { j.b.occurrence } else { j.a.occurrence };
if self.placed.contains_key(&child) {
self.warnings.push(format!("joint `{}` closes a loop: skipped (URDF is a tree)", j.name));
continue;
}
if self.attach(j, fa, p, child, values.get(&j.id).map(Vec::as_slice).unwrap_or(&[])).is_some() {
queue.push_back(child);
}
}
}
}
/// `child` on joint `j` from `p` (already in the tree): the joint's chain and the child's link.
fn attach(&mut self, j: &Joint, fa: &Mat, p: u64, child: u64, q: &[f64]) -> Option<()> {
let m = joints::motion(j.kind, q);
let child_on_b = j.a.occurrence == p;
// (parent-side joint frame, child link frame) in the world.
let (jf, lf) = if child_on_b {
let jf = mat_mul(&self.world(p), fa);
(jf, mat_mul(&jf, &m))
} else {
let lf = mat_mul(&self.world(child), fa);
(mat_mul(&lf, &m), lf)
};
let (parent_index, parent_frame) = self.placed.get(&p).copied()?;
let origin = mat_mul(&mat_inverse(&parent_frame)?, &jf);
let mut chain = steps(j.kind);
if !child_on_b {
chain.reverse();
for s in &mut chain {
s.1 = -s.1;
}
}
let o = self.occurrence(child)?;
let child_index = self.link(o, canonical(self.doc, child), &lf, &self.world(child))?;
self.placed.insert(child, (child_index, lf));
let joint_name = self.joint_names.get(&j.id).cloned().unwrap_or_else(|| j.name.clone());
if chain.is_empty() {
let name = joint_name;
self.model.joints.push(UrdfJoint {
name,
parent: parent_index,
child: child_index,
origin,
step: Step::Fixed,
axis: Vec3::Z,
limit: None,
source: None,
});
return Some(());
}
let mut from = parent_index;
let last = chain.len() - 1;
for (k, (step, axis, dof)) in chain.into_iter().enumerate() {
let to = if k == last {
child_index
} else {
let name = self.names.take(&format!("{}_link{}", j.name, k + 1));
self.model.links.push(Link {
name,
path: Vec::new(),
mesh_origin: IDENTITY,
visual: None,
collision: None,
color: None,
inertial: Some(token()),
});
self.model.links.len() - 1
};
// A limit that is not finite (a hand-edited file) is no limit.
let limit =
j.limits.get(dof).copied().flatten().filter(|(lo, hi)| lo.is_finite() && hi.is_finite()).map(|(lo, hi)| (lo.min(hi), lo.max(hi)));
let limit = match (step, limit) {
(Step::Prismatic, None) => {
self.warnings.push(format!("joint `{}` slides without a limit: written as ±1000 m (URDF needs one)", j.name));
Some((-UNLIMITED_TRAVEL_MM, UNLIMITED_TRAVEL_MM))
}
(_, l) => l,
};
let name = if k == last { joint_name.clone() } else { self.names.take(&format!("{}_dof{}", j.name, k + 1)) };
self.model.joints.push(UrdfJoint {
name,
parent: from,
child: to,
origin: if k == 0 { origin } else { IDENTITY },
step,
axis,
limit,
source: Some((j.id, dof)),
});
from = to;
}
Some(())
}
/// Every other placement of a nested occurrence (its parent component is placed several
/// times): a copy fixed to its parent placement's link.
fn copies(&mut self, jointed: &HashSet<u64>) {
let mut copies: BTreeMap<Vec<u64>, (usize, Mat)> = BTreeMap::new();
let placements = self.doc.placements();
if placements.len() > MAX_PLACEMENTS || placements.iter().any(|p| p.1.len() > MAX_PLACEMENT_DEPTH) {
self.warnings.push(format!(
"the assembly is deeper than {MAX_PLACEMENT_DEPTH} levels or has more than {MAX_PLACEMENTS} placements: copies past that are left out"
));
}
for (_, path, w) in placements {
let Some((&occ, parent_path)) = path.split_last() else { continue };
if path == canonical(self.doc, occ) {
continue;
}
let parent = match parent_path.last() {
None => Some((0, IDENTITY)),
Some(&p) if parent_path == canonical(self.doc, p).as_slice() => self.placed.get(&p).copied(),
Some(_) => copies.get(parent_path).copied(),
};
let (Some((pi, pf)), Some(o)) = (parent, self.occurrence(occ)) else { continue };
let Some(origin) = mat_inverse(&pf).map(|inv| mat_mul(&inv, &w)) else { continue };
let Some(i) = self.link(o, path.clone(), &w, &w) else { continue };
self.fixed(pi, i, origin);
copies.insert(path, (i, w));
if jointed.contains(&occ)
&& let Some(l) = self.model.links.get(i)
{
self.warnings.push(format!(
"`{}` is a copy of a jointed occurrence: fixed where it is (the URDF moves only the first placement; copies would need <mimic>)",
l.name
));
}
}
}
}
/// Build the URDF of a design: `doc` with its occurrences placed (pending moves applied), `st`
/// with the bodies in their components' own frames, painted. `stem` names the robot and
/// prefixes the mesh files.
pub fn urdf(doc: &Document, st: &ModelState, stem: &str, options: &UrdfOptions) -> Result<UrdfPackage> {
// XML 1.0 has no way to write these, escaped or not.
let unwritable = |c: char| (c.is_control() && !matches!(c, '\t' | '\n' | '\r')) || matches!(c, '\u{FFFE}' | '\u{FFFF}');
if options.mesh_prefix.len() > 4096 {
return Err(IoError::Invalid("`mesh_prefix` is longer than 4096 bytes".into()));
}
if options.mesh_prefix.chars().any(unwritable) {
return Err(IoError::Invalid("`mesh_prefix` has a character XML cannot hold".into()));
}
let mut names = Names::default();
let mut out = UrdfPackage { xml: String::new(), meshes: Vec::new(), model: UrdfModel::default(), warnings: Vec::new() };
let base = names.take("base_link");
let robot = sanitise(stem, false);
let parts = parts(doc, st, stem, &mut names, &mut out)?;
let mut tree = Tree {
doc,
parts,
names,
model: UrdfModel::default(),
warnings: std::mem::take(&mut out.warnings),
placed: BTreeMap::from([(0, (0, IDENTITY))]),
joint_names: BTreeMap::new(),
};
let root = tree.parts.get(&0);
let base_link = Link {
name: base,
path: Vec::new(),
mesh_origin: IDENTITY,
visual: root.and_then(|p| p.visual.clone()),
collision: root.and_then(|p| p.collision.clone()),
color: root.and_then(|p| p.color),
inertial: root.and_then(|p| inertial(&p.masses, &IDENTITY)),
};
tree.model.links.push(base_link);
// Usable joints: unsuppressed (as many as the solver reads), both ends placed and distinct,
// both origins resolving.
let exists = |occ: u64| occ == 0 || doc.occurrences.iter().any(|o| o.id == occ);
let mut usable: Vec<(&Joint, Mat)> = Vec::new();
for j in doc.assembly.joints.iter().filter(|j| !j.suppressed).take(joints::MAX_JOINTS) {
let fa = joints::resolve_origin(doc, st, &j.a).ok().filter(|_| joints::resolve_origin(doc, st, &j.b).is_ok());
match fa {
Some(fa) if exists(j.a.occurrence) && exists(j.b.occurrence) && j.a.occurrence != j.b.occurrence => usable.push((j, fa)),
_ => tree.warnings.push(format!("joint `{}` is skipped: its origins don't resolve or it joins an occurrence to itself", j.name)),
}
}
let jointed: HashSet<u64> = usable.iter().flat_map(|(j, _)| [j.a.occurrence, j.b.occurrence]).collect();
for (j, _) in &usable {
let name = tree.names.take(&j.name);
tree.joint_names.insert(j.id, name);
}
// Grounded occurrences stay where they are in the world, nested or not.
let mut queue = VecDeque::from([0]);
// In id order, as the solver's queue, so both keep the same joint where two could reach a part.
let mut grounded: Vec<u64> = doc.occurrences.iter().filter(|o| o.grounded).map(|o| o.id).collect();
grounded.sort_unstable();
for id in grounded {
if tree.fix(id, true).is_some() {
queue.push_back(id);
}
}
let mut taken = HashSet::new();
let mut left_out: HashSet<u64> = HashSet::new();
loop {
tree.traverse(&mut queue, &usable, &mut taken);
let done = |t: &Tree, occ: u64| t.placed.contains_key(&occ) || left_out.contains(&occ);
let ready = |t: &Tree, occ: u64| t.occurrence(occ).is_some_and(|o| t.parent_link(o, false).is_some());
// A floating group: anchored at its first joint's A side, as the solver anchors one
// when nothing is grounded; then what no joint reaches, parents first.
let next = usable
.iter()
.find(|(j, _)| !done(&tree, j.a.occurrence) && !done(&tree, j.b.occurrence) && ready(&tree, j.a.occurrence))
.map(|(j, _)| (j.a.occurrence, false))
.or_else(|| doc.occurrences.iter().find(|o| !done(&tree, o.id) && ready(&tree, o.id)).map(|o| (o.id, false)))
.or_else(|| doc.occurrences.iter().find(|o| !done(&tree, o.id)).map(|o| (o.id, true)));
let Some((occ, to_base)) = next else { break };
let Some(i) = tree.fix(occ, to_base) else {
tree.warnings.push(format!("occurrence {occ} has a degenerate placement: left out"));
left_out.insert(occ);
continue;
};
queue.push_back(occ);
let nested = tree.occurrence(occ).is_some_and(|o| o.parent != 0);
let name = tree.model.links.get(i).map(|l| l.name.clone()).unwrap_or_default();
if jointed.contains(&occ) {
tree.warnings.push(format!("`{name}` is not grounded: fixed where it is"));
} else if !nested || to_base {
tree.warnings.push(format!("`{name}` has no joint: fixed where it is"));
}
}
tree.copies(&jointed);
let moving = tree.model.joints.iter().any(|j| j.step != Step::Fixed);
if moving && (options.effort.is_none() || options.velocity.is_none()) {
tree.warnings.push(format!(
"joint effort and velocity limits are not given: written as {DEFAULT_EFFORT} and {DEFAULT_VELOCITY} (pass effort and velocity)"
));
}
out.xml = write_xml(&tree.model, &robot, options);
out.model = tree.model;
out.warnings = tree.warnings;
Ok(out)
}
fn token() -> Inertial {
Inertial { mass: TOKEN_MASS, com: Vec3::ZERO, inertia: [[TOKEN_INERTIA, 0.0, 0.0], [0.0, TOKEN_INERTIA, 0.0], [0.0, 0.0, TOKEN_INERTIA]] }
}
/// Bodies' masses (component frame) combined in the link frame (`to_link`: component to link).
fn inertial(masses: &[BodyMass], to_link: &Mat) -> Option<Inertial> {
let mass: f64 = masses.iter().map(|m| m.0).sum();
if !(mass > 0.0) {
return None;
}
let com = masses.iter().fold(Vec3::ZERO, |acc, m| acc + apply_point(to_link, m.1) * m.0) * (1.0 / mass);
let r = |i: usize, j: usize| to_link.get(j).and_then(|c| c.get(i)).copied().unwrap_or(0.0);
let mut total = [[0.0f64; 3]; 3];
for (m, c, inertia) in masses {
// R I Rᵀ, then the parallel-axis term about the link's centre of mass.
let d = apply_point(to_link, *c) - com;
let dv = [d.x, d.y, d.z];
let d2 = d.dot(d);
for (i, (row_out, di)) in total.iter_mut().zip(dv).enumerate() {
for (j, (cell, dj)) in row_out.iter_mut().zip(dv).enumerate() {
let mut v = 0.0;
for (a, row) in inertia.iter().enumerate() {
for (b, x) in row.iter().enumerate() {
v += r(i, a) * x * r(j, b);
}
}
*cell += v + m * (if i == j { d2 } else { 0.0 } - di * dj);
}
}
}
// kg mm² to kg m².
let inertia = total.map(|row| row.map(|x| x * 1e-6));
let finite = mass.is_finite() && [com.x, com.y, com.z].iter().all(|x| x.is_finite()) && inertia.iter().flatten().all(|x| x.is_finite());
finite.then_some(Inertial { mass, com, inertia })
}
fn append(into: &mut Mesh, m: &Mesh) {
let base = u32::try_from(into.positions.len()).unwrap_or(u32::MAX);
into.positions.extend_from_slice(&m.positions);
into.triangles.extend(m.triangles.iter().map(|t| t.map(|i| i.saturating_add(base))));
}
fn metres(m: &Mesh) -> Mesh {
Mesh { positions: m.positions.iter().map(|p| *p * 1e-3).collect(), triangles: m.triangles.clone(), ..Mesh::default() }
}
/// `xyz` (m) and `rpy` (rad, fixed axes: R = Rz(yaw) Ry(pitch) Rx(roll)) of a frame.
fn xyz_rpy(m: &Mat) -> (Vec3, Vec3) {
let r = |row: usize, col: usize| m.get(col).and_then(|c| c.get(row)).copied().unwrap_or(0.0);
// atan2 over hypot keeps pitch precise next to +-90 degrees, where asin would not.
let cos_pitch = r(0, 0).hypot(r(1, 0));
let pitch = (-r(2, 0)).atan2(cos_pitch);
let (roll, yaw) = if cos_pitch > 1e-12 {
(r(2, 1).atan2(r(2, 2)), r(1, 0).atan2(r(0, 0)))
} else {
// Gimbal lock: yaw carries the whole turn about z.
(0.0, (-r(0, 1)).atan2(r(1, 1)))
};
let t = apply_point(m, Vec3::ZERO) * 1e-3;
(t, Vec3::new(roll, pitch, yaw))
}
fn attr(s: &str) -> String {
quick_xml::escape::escape(s).into_owned()
}
fn num(x: f64) -> String {
// No "-0" and no non-finite values in the XML. Shortest round-trip digits either way; very
// small or large values with an exponent rather than a run of zeros.
if !x.is_finite() || x == 0.0 {
"0".into()
} else if x.abs() < 1e-3 || x.abs() >= 1e7 {
format!("{x:e}")
} else {
format!("{x}")
}
}
fn triple(v: Vec3) -> String {
format!("{} {} {}", num(v.x), num(v.y), num(v.z))
}
fn origin_xml(m: &Mat) -> String {
let (xyz, rpy) = xyz_rpy(m);
format!("<origin xyz=\"{}\" rpy=\"{}\"/>", triple(xyz), triple(rpy))
}
fn write_xml(model: &UrdfModel, name: &str, options: &UrdfOptions) -> String {
let effort = options.effort.filter(|x| x.is_finite() && *x > 0.0).unwrap_or(DEFAULT_EFFORT);
let velocity = options.velocity.filter(|x| x.is_finite() && *x > 0.0).unwrap_or(DEFAULT_VELOCITY);
let prefix = attr(&options.mesh_prefix);
let mut s = String::from("<?xml version=\"1.0\"?>\n<!-- Exported by SolidCraft; units: m, kg, rad -->\n");
s += &format!("<robot name=\"{}\">\n", attr(name));
for l in &model.links {
s += &format!(" <link name=\"{}\">\n", attr(&l.name));
if let Some(i) = &l.inertial {
let c = i.com * 1e-3;
let t = &i.inertia;
s += &format!(
" <inertial>\n <origin xyz=\"{}\" rpy=\"0 0 0\"/>\n <mass value=\"{}\"/>\n <inertia ixx=\"{}\" ixy=\"{}\" ixz=\"{}\" iyy=\"{}\" iyz=\"{}\" izz=\"{}\"/>\n </inertial>\n",
triple(c),
num(i.mass),
num(t[0][0]),
num(t[0][1]),
num(t[0][2]),
num(t[1][1]),
num(t[1][2]),
num(t[2][2])
);
}
if let Some(file) = &l.visual {
s += &format!(
" <visual>\n {}\n <geometry><mesh filename=\"{prefix}{}\"/></geometry>\n",
origin_xml(&l.mesh_origin),
attr(file)
);
if let Some([r, g, b, a]) = l.color {
s += &format!(
" <material name=\"{}_color\"><color rgba=\"{} {} {} {}\"/></material>\n",
attr(&l.name),
num(r as f64),
num(g as f64),
num(b as f64),
num(a as f64)
);
}
s += " </visual>\n";
}
if let Some(file) = &l.collision {
s += &format!(
" <collision>\n {}\n <geometry><mesh filename=\"{prefix}{}\"/></geometry>\n </collision>\n",
origin_xml(&l.mesh_origin),
attr(file)
);
}
s += " </link>\n";
}
for j in &model.joints {
let (Some(parent), Some(child)) = (model.links.get(j.parent), model.links.get(j.child)) else { continue };
let kind = match (j.step, j.limit) {
(Step::Fixed, _) => "fixed",
(Step::Revolute, None) => "continuous",
(Step::Revolute, Some(_)) => "revolute",
(Step::Prismatic, _) => "prismatic",
};
s += &format!(" <joint name=\"{}\" type=\"{kind}\">\n", attr(&j.name));
s += &format!(" <parent link=\"{}\"/>\n <child link=\"{}\"/>\n {}\n", attr(&parent.name), attr(&child.name), origin_xml(&j.origin));
if j.step != Step::Fixed {
s += &format!(" <axis xyz=\"{}\"/>\n", triple(j.axis));
let scale = if j.step == Step::Prismatic { 1e-3 } else { 1.0 };
match j.limit {
Some((lo, hi)) => {
s += &format!(
" <limit lower=\"{}\" upper=\"{}\" effort=\"{}\" velocity=\"{}\"/>\n",
num(lo * scale),
num(hi * scale),
num(effort),
num(velocity)
);
}
None => s += &format!(" <limit effort=\"{}\" velocity=\"{}\"/>\n", num(effort), num(velocity)),
}
}
s += " </joint>\n";
}
s += "</robot>\n";
s
}
#[cfg(test)]
mod tests {
use super::*;
use std::f64::consts::FRAC_PI_2;
use quick_xml::events::Event;
/// Rotations that stress the decomposition: general ones, gimbal lock (pitch ±90°, which a
/// hinge whose axis lies along x gives) and next to it.
fn hard_rotations() -> Vec<Mat> {
let r = |axis: Vec3, a: f64| rotation(axis.normalized().unwrap_or(Vec3::Z), a);
let mut out = vec![r(Vec3::X, 0.3), r(Vec3::Y, -1.1), r(Vec3::Z, 2.5), r(Vec3::new(1.0, 2.0, 3.0), 0.7)];
for pitch in [FRAC_PI_2, -FRAC_PI_2, FRAC_PI_2 - 1e-7, -FRAC_PI_2 + 1e-9] {
for (yaw, roll) in [(0.0, 0.0), (0.7, 0.3), (-2.0, 1.4)] {
out.push(mat_mul(&mat_mul(&r(Vec3::Z, yaw), &r(Vec3::Y, pitch)), &r(Vec3::X, roll)));
}
}
out
}
/// The frame an `<origin xyz rpy>` stands for (xyz in m, the frame in mm).
fn compose(xyz: Vec3, rpy: Vec3) -> Mat {
mat_mul(&mat_mul(&mat_mul(&translation(xyz * 1e3), &rotation(Vec3::Z, rpy.z)), &rotation(Vec3::Y, rpy.y)), &rotation(Vec3::X, rpy.x))
}
fn close(a: &Mat, b: &Mat) -> bool {
a.iter().flatten().zip(b.iter().flatten()).all(|(x, y)| (x - y).abs() < 1e-9)
}
#[test]
fn rpy_round_trips_a_rotation() {
for (k, mut m) in hard_rotations().into_iter().enumerate() {
m[3] = [10.0, -20.0, 30.0, 1.0];
let (xyz, rpy) = xyz_rpy(&m);
assert!(close(&compose(xyz, rpy), &m), "{k}: {rpy:?}");
}
}
/// Every `<origin>` in the file parses back to the model's frame, so the forward kinematics
/// the engine tests check on [`UrdfModel`] are the ones a URDF reader gets.
#[test]
fn written_origins_parse_back_to_the_model() {
let link = |name: String, mesh_origin: Mat| Link {
name,
path: Vec::new(),
mesh_origin,
visual: Some("m.stl".into()),
collision: Some("m.stl".into()),
color: None,
inertial: None,
};
let mut model = UrdfModel { links: vec![link("base_link".into(), IDENTITY)], joints: Vec::new() };
for (k, mut m) in hard_rotations().into_iter().enumerate() {
m[3] = [k as f64, 2.0, -3.5, 1.0];
model.links.push(link(format!("l{k}"), m));
let step = if k % 2 == 0 { Step::Revolute } else { Step::Prismatic };
model.joints.push(UrdfJoint {
name: format!("j{k}"),
parent: 0,
child: k + 1,
origin: m,
step,
axis: -Vec3::Y,
limit: Some((-1.0, 2.0)),
source: None,
});
}
let xml = write_xml(&model, "r", &UrdfOptions::default());
let mut reader = quick_xml::Reader::from_str(&xml);
let mut origins = Vec::new();
loop {
match reader.read_event() {
Ok(Event::Empty(e)) if e.local_name().as_ref() == b"origin" => {
let triple = |key: &[u8]| -> Vec3 {
let v: Vec<f64> = e
.attributes()
.flatten()
.find(|a| a.key.local_name().as_ref() == key)
.map(|a| String::from_utf8_lossy(&a.value).split_whitespace().filter_map(|x| x.parse().ok()).collect())
.unwrap_or_default();
Vec3::new(
v.first().copied().unwrap_or(f64::NAN),
v.get(1).copied().unwrap_or(f64::NAN),
v.get(2).copied().unwrap_or(f64::NAN),
)
};
origins.push(compose(triple(b"xyz"), triple(b"rpy")));
}
Ok(Event::Eof) => break,
Err(e) => panic!("{e}"),
_ => {}
}
}
// Each link writes its visual's and collision's origin, then each joint its own.
let want: Vec<Mat> = model.links.iter().flat_map(|l| [l.mesh_origin, l.mesh_origin]).chain(model.joints.iter().map(|j| j.origin)).collect();
assert_eq!(origins.len(), want.len());
for (k, (got, want)) in origins.iter().zip(&want).enumerate() {
assert!(close(got, want), "origin {k}: {got:?} vs {want:?}");
}
}
/// XML cannot hold a control character, escaped or not: the prefix is refused here, for
/// every caller.
#[test]
fn a_mesh_prefix_xml_cannot_hold_is_refused() {
let doc = Document::new("t");
let st = ModelState::default();
for (prefix, why) in [("a\u{1}b".to_string(), "character"), ("a".repeat(5000), "longer than")] {
let options = UrdfOptions { mesh_prefix: prefix, ..UrdfOptions::default() };
let e = urdf(&doc, &st, "t", &options).err().map(|e| e.to_string()).unwrap_or_default();
assert!(e.contains("mesh_prefix") && e.contains(why), "{e}");
}
assert!(urdf(&doc, &st, "t", &UrdfOptions { mesh_prefix: "package://r/m/".into(), ..UrdfOptions::default() }).is_ok());
}
#[test]
fn numbers_are_short_and_exact() {
assert_eq!(num(0.01), "0.01");
assert_eq!(num(-1000.0), "-1000");
assert_eq!(num(2.1500000000000004e-8), "2.1500000000000004e-8");
assert_eq!(num(-8.881784197001253e-19), "-8.881784197001253e-19");
assert_eq!(num(-0.0), "0");
assert_eq!(num(f64::NAN), "0");
for x in [0.01, 1e-3, 9.99e-4, 123456.789, 3.0e12, -2.5e-7] {
assert_eq!(num(x).parse::<f64>().ok(), Some(x), "{x}");
}
}
#[test]
fn names_are_unique_and_sanitised() {
let mut n = Names::default();
assert_eq!(n.take("base_link"), "base_link");
assert_eq!(n.take("Leaf A:1"), "Leaf_A_1");
assert_eq!(n.take("Leaf_A_1"), "Leaf_A_1_2");
assert_eq!(n.take(""), "part");
// File systems that ignore case would make these one file.
assert_eq!(n.file("t-base"), "t-base.stl");
assert_eq!(n.file("t-Base"), "t-Base_2.stl");
}
}