Skip to main content

repose_robot_description/
import.rs

1use cu_spatial_payloads::Transform3D;
2use cu29::prelude::*;
3use cu29::units::si::angle::radian;
4use cu29::units::si::angular_velocity::radian_per_second;
5use cu29::units::si::f64::Angle;
6use cu29::units::si::f64::AngularVelocity;
7use cu29::units::si::f64::Length;
8use cu29::units::si::f64::Velocity;
9use cu29::units::si::length::meter;
10use cu29::units::si::velocity::meter_per_second;
11use repose_collision::CuCapsule;
12use repose_collision::CuCollisionModelParams;
13use repose_collision::CuCollisionShape;
14use repose_collision::CuLinkShape;
15use repose_collision::CuRobotCollisionModel;
16use repose_collision::CuSphere;
17use repose_kinematics::CuJointAxis;
18use repose_kinematics::CuJointLimits;
19use repose_kinematics::CuJointPosition;
20use repose_kinematics::CuJointSpec;
21use repose_kinematics::CuKinematicChain;
22use roxmltree::Document;
23use roxmltree::Node;
24use std::collections::BTreeMap;
25use std::collections::BTreeSet;
26
27/// Collision parameters used when an import does not override them: zero safety margin and
28/// **no** adjacent-link skipping. URDF import relies on the SRDF `disable_collisions` list to
29/// disable pairs, so every pair of shapes on different frames starts enabled.
30fn default_import_collision() -> CuCollisionModelParams {
31    CuCollisionModelParams {
32        safety_margin: Length::new::<meter>(0.0),
33        skip_adjacent_links: false,
34    }
35}
36
37/// Named inputs of [`CuRobotDescription::from_urdf`].
38///
39/// ```ignore
40/// CuUrdfImport::new("base", "tool").with_collision(CuCollisionModelParams {
41///     safety_margin: Length::new::<meter>(0.005),
42///     skip_adjacent_links: false,
43/// })
44/// ```
45#[derive(Debug, Clone, Copy, PartialEq)]
46pub struct CuUrdfImport<'a> {
47    /// URDF link where the serial chain starts.
48    pub base_link: &'a str,
49    /// URDF link where the serial chain ends (the tool frame).
50    pub tip_link: &'a str,
51    /// Collision model parameters. Default: zero margin, `skip_adjacent_links: false`.
52    pub collision: CuCollisionModelParams,
53}
54
55impl<'a> CuUrdfImport<'a> {
56    /// Selects the `base_link` to `tip_link` path with default collision parameters.
57    pub fn new(base_link: &'a str, tip_link: &'a str) -> Self {
58        Self {
59            base_link,
60            tip_link,
61            collision: default_import_collision(),
62        }
63    }
64
65    /// Replaces the collision model parameters.
66    pub const fn with_collision(mut self, collision: CuCollisionModelParams) -> Self {
67        self.collision = collision;
68        self
69    }
70}
71
72/// Named inputs of [`CuRobotDescription::from_urdf_srdf`].
73#[derive(Debug, Clone, Copy, PartialEq)]
74pub struct CuSrdfImport<'a> {
75    /// SRDF planning group containing exactly one serial chain.
76    pub group: &'a str,
77    /// Collision model parameters. Default: zero margin, `skip_adjacent_links: false`.
78    pub collision: CuCollisionModelParams,
79}
80
81impl<'a> CuSrdfImport<'a> {
82    /// Selects SRDF `group` with default collision parameters.
83    pub fn new(group: &'a str) -> Self {
84        Self {
85            group,
86            collision: default_import_collision(),
87        }
88    }
89
90    /// Replaces the collision model parameters.
91    pub const fn with_collision(mut self, collision: CuCollisionModelParams) -> Self {
92        self.collision = collision;
93        self
94    }
95}
96
97/// A startup robot description for a single base-to-tip serial chain.
98/// `N` counts movable joints; `M` bounds collision shapes, including fixed and base links.
99/// Names and semantic state storage allocate only during import and lookup.
100///
101/// The runtime models are read through [`chain`](Self::chain) and
102/// [`collision_model`](Self::collision_model) (both `Copy`), or taken with
103/// [`into_parts`](Self::into_parts).
104#[derive(Debug)]
105pub struct CuRobotDescription<const N: usize, const M: usize> {
106    chain: CuKinematicChain<N>,
107    collision_model: CuRobotCollisionModel<N, M>,
108    joint_names: [String; N],
109    shape_links: [String; M],
110    links: BTreeSet<String>,
111    all_links: BTreeSet<String>,
112    robot_name: String,
113    base: String,
114    tip: String,
115    named_states: BTreeMap<String, [CuJointPosition; N]>,
116}
117
118impl<const N: usize, const M: usize> CuRobotDescription<N, M> {
119    /// Imports an explicit URDF base-to-tip path. Other branches are not part of this model.
120    /// Rejects unsupported selected joints and geometry rather than silently omitting them.
121    pub fn from_urdf(xml: &str, import: CuUrdfImport<'_>) -> CuResult<Self> {
122        let CuUrdfImport {
123            base_link: base,
124            tip_link: tip,
125            collision,
126        } = import;
127        collision.validate()?;
128        let doc =
129            Document::parse(xml).map_err(|e| CuError::from(format!("Invalid URDF XML: {e}")))?;
130        let robot = robot_root(&doc)?;
131        let mut links = BTreeMap::new();
132        let mut joints = BTreeMap::new();
133        let mut parents = BTreeMap::new();
134        for node in robot.children().filter(Node::is_element) {
135            if node.has_tag_name("link") {
136                let name = attr(node, "name")?;
137                if links.insert(name, node).is_some() {
138                    return Err(CuError::from("Duplicate URDF link name"));
139                }
140            } else if node.has_tag_name("joint") {
141                let name = attr(node, "name")?;
142                if joints.insert(name, node).is_some() {
143                    return Err(CuError::from("Duplicate URDF joint name"));
144                }
145                let child = attr(child(node, "child")?, "link")?;
146                if parents.insert(child, node).is_some() {
147                    return Err(CuError::from("URDF link has multiple parent joints"));
148                }
149            }
150        }
151        if !links.contains_key(base) || !links.contains_key(tip) || base == tip {
152            return Err(CuError::from(
153                "Expected distinct existing base and tip links",
154            ));
155        }
156        for node in joints.values() {
157            let p = attr(child(*node, "parent")?, "link")?;
158            let c = attr(child(*node, "child")?, "link")?;
159            if !links.contains_key(p) || !links.contains_key(c) {
160                return Err(CuError::from("URDF joint references unknown link"));
161            }
162        }
163        if links
164            .keys()
165            .filter(|name| !parents.contains_key(**name))
166            .count()
167            != 1
168        {
169            return Err(CuError::from("URDF must have exactly one root link"));
170        }
171        // Validate tree ancestry, including the ancestors of the requested base.
172        for name in links.keys() {
173            let mut seen = BTreeSet::new();
174            let mut current = *name;
175            while let Some(joint) = parents.get(current) {
176                if !seen.insert(current) {
177                    return Err(CuError::from("Cycle in URDF joint topology"));
178                }
179                current = attr(child(*joint, "parent")?, "link")?;
180            }
181        }
182        let mut path = Vec::new();
183        let mut current = tip;
184        while current != base {
185            let joint = *parents
186                .get(current)
187                .ok_or_else(|| CuError::from("Tip is not a descendant of base"))?;
188            path.push(joint);
189            current = attr(child(joint, "parent")?, "link")?;
190        }
191        path.reverse();
192        let mut specs = [CuJointSpec::default(); N];
193        let mut joint_names = core::array::from_fn(|_| String::new());
194        let mut pending = Transform3D::<f64>::identity();
195        let mut count = 0;
196        let mut owner = None;
197        let mut link_frames = vec![(base, owner, pending)];
198        for node in path {
199            if node.children().any(|n| n.has_tag_name("mimic")) {
200                return Err(CuError::from(
201                    "Mimic joints are unsupported in a serial chain",
202                ));
203            }
204            pending = pending.compose(origin(node)?);
205            let kind = attr(node, "type")?;
206            if !matches!(kind, "fixed" | "revolute" | "continuous" | "prismatic") {
207                return Err(CuError::from(
208                    "Unsupported URDF joint type in selected serial chain",
209                ));
210            }
211            if kind != "fixed" {
212                if count >= N {
213                    return Err(CuError::from("URDF movable joint count exceeds N"));
214                }
215                let axis = if let Some(axis) = optional_child(node, "axis")? {
216                    vector(attr(axis, "xyz")?)?
217                } else {
218                    [1.0, 0.0, 0.0]
219                };
220                let norm = axis.iter().map(|v| v * v).sum::<f64>().sqrt();
221                if norm <= 1e-12 || !norm.is_finite() {
222                    return Err(CuError::from("URDF joint axis must be nonzero and finite"));
223                }
224                let limits_node = child(node, "limit")?;
225                let velocity = number(attr(limits_node, "velocity")?)?;
226                if velocity <= 0.0 {
227                    return Err(CuError::from("URDF joint velocity limit must be positive"));
228                }
229                let (lower, upper) = if kind == "continuous" {
230                    // A finite canonical sampling interval; FK accepts angles outside it.
231                    (-core::f64::consts::PI, core::f64::consts::PI)
232                } else {
233                    (
234                        number(attr(limits_node, "lower")?)?,
235                        number(attr(limits_node, "upper")?)?,
236                    )
237                };
238                if lower > upper {
239                    return Err(CuError::from("URDF lower limit exceeds upper limit"));
240                }
241                let (xyz, rpy) = decompose(pending);
242                let xyz = xyz.map(Length::new::<meter>);
243                let rpy = rpy.map(Angle::new::<radian>);
244                let axis = CuJointAxis::Arbitrary(axis.map(|v| v / norm));
245                specs[count] = match kind {
246                    "revolute" => CuJointSpec::revolute(
247                        xyz,
248                        rpy,
249                        axis,
250                        CuJointLimits::revolute(
251                            Angle::new::<radian>(lower),
252                            Angle::new::<radian>(upper),
253                            AngularVelocity::new::<radian_per_second>(velocity),
254                        ),
255                    ),
256                    "continuous" => CuJointSpec::revolute(
257                        xyz,
258                        rpy,
259                        axis,
260                        CuJointLimits::Continuous {
261                            max_velocity: AngularVelocity::new::<radian_per_second>(velocity),
262                        },
263                    ),
264                    "prismatic" => CuJointSpec::prismatic(
265                        xyz,
266                        rpy,
267                        axis,
268                        CuJointLimits::prismatic(
269                            Length::new::<meter>(lower),
270                            Length::new::<meter>(upper),
271                            Velocity::new::<meter_per_second>(velocity),
272                        ),
273                    ),
274                    _ => {
275                        return Err(CuError::from(
276                            "Only fixed, revolute, continuous, and prismatic URDF joints are supported",
277                        ));
278                    }
279                };
280                joint_names[count] = attr(node, "name")?.to_owned();
281                owner = Some(count);
282                count += 1;
283                pending = Transform3D::<f64>::identity();
284            }
285            link_frames.push((attr(child(node, "child")?, "link")?, owner, pending));
286        }
287        if count != N {
288            return Err(CuError::from("URDF movable joint count does not match N"));
289        }
290        let chain = CuKinematicChain::new(specs).with_tool_offset(pending);
291        let mut shapes = [CuLinkShape::default(); M];
292        let mut shape_links = core::array::from_fn(|_| String::new());
293        let mut len = 0;
294        for (link, frame, offset) in &link_frames {
295            for collision in links[link]
296                .children()
297                .filter(|n| n.has_tag_name("collision"))
298            {
299                if len >= M {
300                    return Err(CuError::from("URDF collision shape count exceeds M"));
301                }
302                let geometry = child(collision, "geometry")?;
303                let mut elements = geometry.children().filter(Node::is_element);
304                let shape = elements
305                    .next()
306                    .ok_or_else(|| CuError::from("Empty URDF collision geometry"))?;
307                if elements.next().is_some() {
308                    return Err(CuError::from(
309                        "Expected exactly one URDF geometry primitive",
310                    ));
311                }
312                let m = offset.compose(origin(collision)?).to_matrix();
313                let shape = if shape.has_tag_name("sphere") {
314                    let radius = positive(attr(shape, "radius")?)?;
315                    CuCollisionShape::Sphere(CuSphere::new(
316                        point(&m, [0.0; 3]),
317                        Length::new::<meter>(radius),
318                    )?)
319                } else if shape.has_tag_name("cylinder") {
320                    let radius = positive(attr(shape, "radius")?)?;
321                    let half = positive(attr(shape, "length")?)? * 0.5;
322                    // Conservative capsule encloses the full cylinder, including its end faces.
323                    CuCollisionShape::Capsule(CuCapsule::new(
324                        point(&m, [0.0, 0.0, -half]),
325                        point(&m, [0.0, 0.0, half]),
326                        Length::new::<meter>(radius),
327                    )?)
328                } else {
329                    return Err(CuError::from(
330                        "Unsupported collision geometry: provide spheres/cylinders; preprocess boxes and meshes explicitly",
331                    ));
332                };
333                shapes[len] = CuLinkShape::new(shape, *frame);
334                shape_links[len] = (*link).to_owned();
335                len += 1;
336            }
337        }
338        let collision_model = CuRobotCollisionModel::new(&shapes[..len], collision)?;
339        Ok(Self {
340            chain,
341            collision_model,
342            joint_names,
343            shape_links,
344            links: link_frames
345                .iter()
346                .map(|(name, _, _)| (*name).to_owned())
347                .collect(),
348            all_links: links.keys().map(|name| (*name).to_owned()).collect(),
349            robot_name: attr(robot, "name")?.to_owned(),
350            base: base.to_owned(),
351            tip: tip.to_owned(),
352            named_states: BTreeMap::new(),
353        })
354    }
355
356    /// Serial kinematic chain from the base link to the tip link.
357    pub const fn chain(&self) -> &CuKinematicChain<N> {
358        &self.chain
359    }
360    /// Collision model (including SRDF-disabled pairs once applied).
361    pub const fn collision_model(&self) -> &CuRobotCollisionModel<N, M> {
362        &self.collision_model
363    }
364    /// Consumes the description and returns the runtime chain and collision model.
365    pub fn into_parts(self) -> (CuKinematicChain<N>, CuRobotCollisionModel<N, M>) {
366        (self.chain, self.collision_model)
367    }
368    /// Joint names in the exact order used by runtime payloads.
369    pub fn joint_names(&self) -> &[String; N] {
370        &self.joint_names
371    }
372    /// URDF link name for each active collision shape, in contact-index order.
373    pub fn collision_links(&self) -> &[String] {
374        &self.shape_links[..self.collision_model.shape_count()]
375    }
376
377    /// Selects an SRDF group containing exactly one serial base-to-tip chain.
378    pub fn from_urdf_srdf(urdf: &str, srdf: &str, import: CuSrdfImport<'_>) -> CuResult<Self> {
379        let CuSrdfImport { group, collision } = import;
380        let doc =
381            Document::parse(srdf).map_err(|e| CuError::from(format!("Invalid SRDF XML: {e}")))?;
382        let robot = robot_root(&doc)?;
383        let selected = named_child(robot, "group", group)?;
384        let chain = serial_group(selected)?;
385        let mut out = Self::from_urdf(
386            urdf,
387            CuUrdfImport::new(attr(chain, "base_link")?, attr(chain, "tip_link")?)
388                .with_collision(collision),
389        )?;
390        out.apply_srdf(srdf, group)?;
391        Ok(out)
392    }
393
394    /// Applies disabled collision pairs and complete named joint states for the selected serial group.
395    /// Unsupported selected-group semantics fail explicitly; other groups are not imported.
396    pub fn apply_srdf(&mut self, xml: &str, group: &str) -> CuResult<()> {
397        let doc =
398            Document::parse(xml).map_err(|e| CuError::from(format!("Invalid SRDF XML: {e}")))?;
399        let robot = robot_root(&doc)?;
400        if attr(robot, "name")? != self.robot_name {
401            return Err(CuError::from("URDF and SRDF robot names differ"));
402        }
403        let chain = serial_group(named_child(robot, "group", group)?)?;
404        if attr(chain, "base_link")? != self.base || attr(chain, "tip_link")? != self.tip {
405            return Err(CuError::from(
406                "SRDF group does not match imported serial chain",
407            ));
408        }
409        // Stage changes so invalid semantics do not partially modify the model.
410        let mut collision_model = self.collision_model;
411        let mut states = BTreeMap::new();
412        for node in robot.children().filter(Node::is_element) {
413            if node.has_tag_name("disable_collisions") {
414                let a = attr(node, "link1")?;
415                let b = attr(node, "link2")?;
416                if !self.all_links.contains(a) || !self.all_links.contains(b) || a == b {
417                    return Err(CuError::from("Invalid SRDF disabled collision pair"));
418                }
419                // Pairs involving other URDF branches have no effect on this selected model.
420                if self.links.contains(a) && self.links.contains(b) {
421                    for i in 0..self.collision_model.shape_count() {
422                        for j in i + 1..self.collision_model.shape_count() {
423                            if (self.shape_links[i] == a && self.shape_links[j] == b)
424                                || (self.shape_links[i] == b && self.shape_links[j] == a)
425                            {
426                                collision_model.set_pair_enabled(i, j, false)?;
427                            }
428                        }
429                    }
430                }
431            } else if node.has_tag_name("group_state") && attr(node, "group")? == group {
432                let name = attr(node, "name")?;
433                let mut positions = core::array::from_fn(|i| self.chain.joints()[i].limits.lower());
434                let mut seen = [false; N];
435                for joint in node.children().filter(Node::is_element) {
436                    if !joint.has_tag_name("joint") {
437                        return Err(CuError::from("Unsupported SRDF group state element"));
438                    }
439                    let joint_name = attr(joint, "name")?;
440                    let index = self
441                        .joint_names
442                        .iter()
443                        .position(|n| n == joint_name)
444                        .ok_or_else(|| {
445                            CuError::from("SRDF state references joint outside selected chain")
446                        })?;
447                    if seen[index] {
448                        return Err(CuError::from("Duplicate joint in SRDF state"));
449                    }
450                    let value = number(attr(joint, "value")?)?;
451                    positions[index] = positions[index].with_si_value(value);
452                    if !self.chain.joints()[index].limits.contains(positions[index]) {
453                        return Err(CuError::from("SRDF state violates joint limits"));
454                    }
455                    seen[index] = true;
456                }
457                if seen.iter().any(|v| !v) {
458                    return Err(CuError::from(
459                        "SRDF named state must specify every movable joint",
460                    ));
461                }
462                if states.insert(name.to_owned(), positions).is_some() {
463                    return Err(CuError::from("Duplicate SRDF named state"));
464                }
465            } else if node.has_tag_name("passive_joint")
466                && self
467                    .joint_names
468                    .iter()
469                    .any(|n| Some(n.as_str()) == node.attribute("name"))
470            {
471                return Err(CuError::from(
472                    "Passive joints in selected chain are unsupported",
473                ));
474            }
475        }
476        self.collision_model = collision_model;
477        self.named_states = states;
478        Ok(())
479    }
480
481    /// Copies a startup SRDF state into caller-provided typed joint storage.
482    pub fn write_named_state(&self, name: &str, out: &mut [CuJointPosition; N]) -> CuResult<()> {
483        *out = *self
484            .named_states
485            .get(name)
486            .ok_or_else(|| CuError::from("Unknown SRDF named state"))?;
487        Ok(())
488    }
489}
490
491fn robot_root<'a>(doc: &'a Document<'_>) -> CuResult<Node<'a, 'a>> {
492    let root = doc.root_element();
493    if !root.has_tag_name("robot") {
494        return Err(CuError::from("Expected robot XML root"));
495    }
496    Ok(root)
497}
498fn attr<'a>(node: Node<'a, '_>, key: &str) -> CuResult<&'a str> {
499    node.attribute(key)
500        .filter(|v| !v.is_empty())
501        .ok_or_else(|| CuError::from(format!("Missing XML attribute {key}")))
502}
503fn optional_child<'a>(node: Node<'a, 'a>, tag: &str) -> CuResult<Option<Node<'a, 'a>>> {
504    let mut found = node.children().filter(|n| n.has_tag_name(tag));
505    let first = found.next();
506    if found.next().is_some() {
507        return Err(CuError::from(format!("Duplicate XML element {tag}")));
508    }
509    Ok(first)
510}
511fn child<'a>(node: Node<'a, 'a>, tag: &str) -> CuResult<Node<'a, 'a>> {
512    optional_child(node, tag)?.ok_or_else(|| CuError::from(format!("Missing XML element {tag}")))
513}
514fn named_child<'a>(node: Node<'a, 'a>, tag: &str, name: &str) -> CuResult<Node<'a, 'a>> {
515    let mut found = node
516        .children()
517        .filter(|n| n.has_tag_name(tag) && n.attribute("name") == Some(name));
518    let first = found
519        .next()
520        .ok_or_else(|| CuError::from("Unknown SRDF group"))?;
521    if found.next().is_some() {
522        return Err(CuError::from("Duplicate SRDF group"));
523    }
524    Ok(first)
525}
526fn serial_group<'a>(node: Node<'a, 'a>) -> CuResult<Node<'a, 'a>> {
527    let chain = child(node, "chain")?;
528    if node.children().filter(Node::is_element).count() != 1 {
529        return Err(CuError::from(
530            "Only a single serial chain per SRDF group is supported",
531        ));
532    }
533    Ok(chain)
534}
535fn number(text: &str) -> CuResult<f64> {
536    let value: f64 = text
537        .parse()
538        .map_err(|_| CuError::from("Invalid numeric XML attribute"))?;
539    if !value.is_finite() {
540        return Err(CuError::from("Non-finite numeric XML attribute"));
541    }
542    Ok(value)
543}
544fn positive(text: &str) -> CuResult<f64> {
545    let value = number(text)?;
546    if value <= 0.0 {
547        return Err(CuError::from("Geometry dimensions must be positive"));
548    }
549    Ok(value)
550}
551fn vector(text: &str) -> CuResult<[f64; 3]> {
552    let mut parts = text.split_whitespace();
553    let mut out = [0.0; 3];
554    for v in &mut out {
555        *v = number(
556            parts
557                .next()
558                .ok_or_else(|| CuError::from("Expected three vector components"))?,
559        )?;
560    }
561    if parts.next().is_some() {
562        return Err(CuError::from("Expected three vector components"));
563    }
564    Ok(out)
565}
566fn origin(node: Node<'_, '_>) -> CuResult<Transform3D<f64>> {
567    let Some(origin) = optional_child(node, "origin")? else {
568        return Ok(Transform3D::<f64>::identity());
569    };
570    let xyz = origin
571        .attribute("xyz")
572        .map(vector)
573        .transpose()?
574        .unwrap_or([0.0; 3]);
575    let rpy = origin
576        .attribute("rpy")
577        .map(vector)
578        .transpose()?
579        .unwrap_or([0.0; 3]);
580    Ok(Transform3D::<f64>::from_translation_euler_xyz(
581        xyz.map(Length::new::<meter>),
582        rpy.map(Angle::new::<radian>),
583    ))
584}
585fn point(m: &[[f64; 4]; 4], p: [f64; 3]) -> [Length; 3] {
586    core::array::from_fn(|r| {
587        Length::new::<meter>(m[r][0] * p[0] + m[r][1] * p[1] + m[r][2] * p[2] + m[r][3])
588    })
589}
590fn decompose(transform: Transform3D<f64>) -> ([f64; 3], [f64; 3]) {
591    let m = transform.to_matrix();
592    let pitch = (-m[2][0]).clamp(-1.0, 1.0).asin();
593    let (roll, yaw) = if pitch.cos().abs() > 1e-10 {
594        (m[2][1].atan2(m[2][2]), m[1][0].atan2(m[0][0]))
595    } else {
596        (0.0, (-m[0][1]).atan2(m[1][1]))
597    };
598    ([m[0][3], m[1][3], m[2][3]], [roll, pitch, yaw])
599}