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
27fn default_import_collision() -> CuCollisionModelParams {
31 CuCollisionModelParams {
32 safety_margin: Length::new::<meter>(0.0),
33 skip_adjacent_links: false,
34 }
35}
36
37#[derive(Debug, Clone, Copy, PartialEq)]
46pub struct CuUrdfImport<'a> {
47 pub base_link: &'a str,
49 pub tip_link: &'a str,
51 pub collision: CuCollisionModelParams,
53}
54
55impl<'a> CuUrdfImport<'a> {
56 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 pub const fn with_collision(mut self, collision: CuCollisionModelParams) -> Self {
67 self.collision = collision;
68 self
69 }
70}
71
72#[derive(Debug, Clone, Copy, PartialEq)]
74pub struct CuSrdfImport<'a> {
75 pub group: &'a str,
77 pub collision: CuCollisionModelParams,
79}
80
81impl<'a> CuSrdfImport<'a> {
82 pub fn new(group: &'a str) -> Self {
84 Self {
85 group,
86 collision: default_import_collision(),
87 }
88 }
89
90 pub const fn with_collision(mut self, collision: CuCollisionModelParams) -> Self {
92 self.collision = collision;
93 self
94 }
95}
96
97#[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 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 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 (-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 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 pub const fn chain(&self) -> &CuKinematicChain<N> {
358 &self.chain
359 }
360 pub const fn collision_model(&self) -> &CuRobotCollisionModel<N, M> {
362 &self.collision_model
363 }
364 pub fn into_parts(self) -> (CuKinematicChain<N>, CuRobotCollisionModel<N, M>) {
366 (self.chain, self.collision_model)
367 }
368 pub fn joint_names(&self) -> &[String; N] {
370 &self.joint_names
371 }
372 pub fn collision_links(&self) -> &[String] {
374 &self.shape_links[..self.collision_model.shape_count()]
375 }
376
377 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 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 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 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 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}