Skip to main content

dynamics_parse/
urdf.rs

1//! Parser for the URDF (Unified Robot Description Format) file format.
2//!
3//! ## Overview
4//! This module provides functionality to parse URDF files and build corresponding [`Model`] and [`GeometryModel`] objects.
5//! The parser is implemented from scratch, and does not rely on any existing URDF parsing library.
6//! Is uses `roxmltree` for XML parsing, the extraction of parameters being done manually.
7//!
8//! ## Joints
9//! The following joint types are supported:
10//! - Fixed
11//! - Revolute
12//! - Continuous
13//! - Prismatic
14//!
15//! ## Meshes
16//! The parser supports mesh geometries in any format. The visualizers used might have limitations on the supported formats.
17//! Mesh paths can be resolved in three ways:
18//! - using absolute paths;
19//! - using ROS's `package://` (or `package:///`) syntax, as long as the package name is present in the environment variable `ROS_PACKAGE_PATH`;
20//! - using a user-provided package directory, passed as an argument to the parsing function.
21//!
22//! ## Tested models
23//! The parser has been tested at least on all models from [example-robot-data](https://github.com/Gepetto/example-robot-data/).
24//! However, some URDF features are still missing, hence some other models might not be parsed correctly yet.
25
26#![allow(clippy::too_many_arguments)] // TODO: refactor functions
27
28use crate::errors::ParseError;
29use collider_rs::mesh::Mesh;
30use collider_rs::shape::Cuboid;
31use collider_rs::shape::{Cylinder, ShapeWrapper, Sphere};
32use dynamics_inertia::inertia::Inertia;
33use dynamics_joint::continuous::JointModelContinuous;
34use dynamics_joint::joint::JointWrapper;
35use dynamics_joint::prismatic::JointModelPrismatic;
36use dynamics_joint::revolute::JointModelRevolute;
37use dynamics_model::frame::{Frame, FrameType};
38use dynamics_model::model::Model;
39use dynamics_model::{
40    geometry_model::GeometryModel, geometry_object::GeometryObject, model::WORLD_ID,
41};
42use dynamics_spatial::color::Color;
43use dynamics_spatial::motion::SpatialRotation;
44use dynamics_spatial::se3::{ActSE3, SE3};
45use dynamics_spatial::symmetric3::Symmetric3;
46use dynamics_spatial::vector3d::Vector3D;
47use nalgebra::Vector3;
48use roxmltree::{Document, Node};
49use std::collections::HashSet;
50use std::path::Path;
51use std::{collections::HashMap, fs, str::FromStr};
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54/// Type of geometry being parsed.
55enum GeometryType {
56    Collision,
57    Visual,
58}
59
60/// Parses a URDF file and builds the corresponding [`Model`] and [`GeometryModel`].
61///
62/// # Arguments
63///
64/// * `filepath` - The path to the URDF file.
65/// * `package_dir` - The path to the folder containing the mesh files referenced in the URDF.
66///
67/// # Returns
68///
69/// A 3-tuple containing the [`Model`], a collision [`GeometryModel`], and a visualization [`GeometryModel`] objects if successful.
70///
71/// Returns a [`ParseError`] if there is an error during parsing.
72pub fn build_models_from_urdf(
73    filepath: &str,
74    package_dir: Option<&str>,
75) -> Result<(Model, GeometryModel, GeometryModel), ParseError> {
76    let contents =
77        fs::read_to_string(filepath).map_err(|e| ParseError::IoError(e, filepath.to_string()))?;
78    let doc = Document::parse(&contents).map_err(ParseError::XmlError)?;
79
80    // identify the robot node
81    let robot_node = doc
82        .descendants()
83        .find(|n| n.tag_name().name() == "robot")
84        .ok_or(ParseError::NoRobotTag)?;
85    let robot_name = robot_node.attribute("name").unwrap_or("").to_string();
86
87    // elements will sequentially be added to the models
88    let mut model = Model::new(robot_name);
89    let mut coll_model = GeometryModel::new();
90    let mut viz_model = GeometryModel::new();
91
92    // start by parsing materials at the root
93    let mut materials = parse_root_materials(&robot_node)?;
94
95    // find root nodes (links without parents)
96    let root_nodes = find_root_nodes(&robot_node)?;
97
98    // sort root nodes in alphabetical order
99    let mut root_nodes: Vec<Node> = root_nodes.into_iter().collect();
100    root_nodes.sort_by_key(|n| n.attribute("name").unwrap_or(""));
101
102    // recursively parse from the root nodes
103    for node in root_nodes {
104        parse_node(
105            &robot_node,
106            node,
107            &robot_node,
108            WORLD_ID,
109            true,
110            &mut model,
111            &mut coll_model,
112            &mut viz_model,
113            &mut materials,
114            package_dir,
115        )?;
116    }
117
118    Ok((model, coll_model, viz_model))
119}
120
121/// Parse the given node and call itself recursively on its children.
122///
123/// The steps are:
124/// - Depending on the type of node (link or joint), call the corresponding parse function.
125/// - Find all children nodes (joints for links, child links for joints).
126/// - Sort the children nodes in alphabetical order.
127/// - Recursively call `parse_node` on each child node.
128///
129/// # Arguments:
130/// - `robot_node`: The root robot node containing all links and joints.
131/// - `node`: The current node to parse.
132/// - `parent_node`: The parent node of the current node.
133/// - `parent_joint_id`: The ID of the parent joint in the model.
134/// - `is_root`: Whether the current node is a root node.
135/// - `model`: The model being built.
136/// - `coll_model`: The collision geometry model being built.
137/// - `viz_model`: The visualization geometry model being built.
138/// - `materials`: The map of materials defined in the robot.
139/// - `package_dir`: The path to the folder containing the mesh files referenced in the URDF.
140///
141/// # Returns:
142/// - `Result<(), ParseError>`: `Ok` if successful, or a [`ParseError`] if an error occurs.
143fn parse_node(
144    robot_node: &Node,
145    node: Node,
146    parent_node: &Node,
147    parent_joint_id: usize,
148    is_root: bool,
149    model: &mut Model,
150    coll_model: &mut GeometryModel,
151    viz_model: &mut GeometryModel,
152    materials: &mut HashMap<String, Color>,
153    package_dir: Option<&str>,
154) -> Result<(), ParseError> {
155    let node_name = node.attribute("name").unwrap_or("");
156    let mut new_parent_joint_id = parent_joint_id;
157
158    // parse the current node and extract its children
159    let children = match node.tag_name().name() {
160        "link" => {
161            parse_link(
162                robot_node,
163                node,
164                parent_node,
165                parent_joint_id,
166                is_root,
167                model,
168                coll_model,
169                viz_model,
170                materials,
171                package_dir,
172            )?;
173
174            // find all joints that have this link as parent
175            let mut children = Vec::new();
176
177            for joint_node in robot_node.children().filter(|n| n.has_tag_name("joint")) {
178                // if this joint:
179                // - has a parent tag
180                // - which is a link
181                // - and this link matches the current node name
182                if let Some(parent_node) = joint_node.children().find(|n| n.has_tag_name("parent"))
183                    && let Ok(parent_link_name) = extract_parameter::<String>("link", &parent_node)
184                    && parent_link_name == node_name
185                {
186                    // then add this joint node to the children to be parsed next
187                    children.push(joint_node);
188                }
189            }
190            children
191        }
192
193        // parse joints
194        "joint" => {
195            new_parent_joint_id =
196                parse_joint(robot_node, node, parent_node, parent_joint_id, model)?;
197
198            // add the eventual child link to the children to be parsed next
199            if let Some(child_node) = node.children().find(|n| n.has_tag_name("child")) {
200                // find the name of the child link
201                let child_link_name = extract_parameter::<String>("link", &child_node)?;
202
203                // find the link node in the robot node
204                if let Some(link_node) = robot_node.children().find(|n| {
205                    n.has_tag_name("link") && n.attribute("name").unwrap_or("") == child_link_name
206                }) {
207                    vec![link_node]
208                } else {
209                    // child link not found in the robot description
210                    return Err(ParseError::UnknownLinkName(child_link_name));
211                }
212            } else {
213                vec![]
214            }
215        }
216
217        // we ignore empty lines and tags not used in simulation
218        "" | "gazebo" | "transmission" => {
219            vec![]
220        }
221
222        // unknown tag
223        _ => {
224            return Err(ParseError::UnknownTag(node.tag_name().name().to_string()));
225        }
226    };
227
228    // sort the children in alphabetical order
229    let mut children: Vec<Node> = children.into_iter().collect();
230    children.sort_by_key(|n| n.attribute("name").unwrap_or(""));
231
232    // recursively parse children
233    for child_node in children {
234        parse_node(
235            robot_node,
236            child_node,
237            &node,
238            new_parent_joint_id,
239            false,
240            model,
241            coll_model,
242            viz_model,
243            materials,
244            package_dir,
245        )?;
246    }
247    Ok(())
248}
249
250/// Finds the root nodes (links without parents) in the robot node.
251///
252/// It starts by collecting all link nodes, then removes those that are children of joints.
253///
254/// # Arguments:
255/// - `robot_node`: The root robot node containing all links and joints.
256///
257/// # Returns:
258/// - `Result<HashSet<Node>, ParseError>`: A set of root link nodes if successful, or a [`ParseError`] if an error occurs.
259fn find_root_nodes<'a>(robot_node: &'a Node) -> Result<HashSet<Node<'a, 'a>>, ParseError> {
260    // collect all link nodes
261    let mut parent_links: HashSet<Node> = robot_node
262        .children()
263        .filter(|n| n.has_tag_name("link"))
264        .collect();
265
266    // remove all links that are children of joints
267    for joint_node in robot_node.children().filter(|n| n.has_tag_name("joint")) {
268        // find the parent link
269        if let Some(child_node) = joint_node.children().find(|n| n.has_tag_name("child")) {
270            let child_link_name = extract_parameter::<String>("link", &child_node)?;
271            // remove the parent link from the set
272            parent_links.retain(|link_node| {
273                let link_name = link_node.attribute("name").unwrap_or("");
274                link_name != child_link_name
275            });
276        }
277    }
278
279    Ok(parent_links)
280}
281
282/// Parses a joint from the URDF file and adds it to the model.
283///
284/// It extracts the joint name, type, origin, axis, and limits, and creates the corresponding joint model.
285/// If the joint is fixed, no joint model is created, only a fixed frame is added.
286/// In any case, a frame (of type either fixed or joint) is added for the joint.
287///
288/// # Arguments:
289/// - `robot_node`: The root robot node containing all links and joints.
290/// - `joint_node`: The joint node to parse.
291/// - `parent_node`: The parent node of the joint.
292/// - `parent_joint_id`: The ID of the parent joint in the model.
293/// - `model`: The model being built.
294///
295/// # Returns:
296/// - `Result<usize, ParseError>`: The ID of the newly added joint if successful, or a [`ParseError`] if an error occurs.
297fn parse_joint(
298    robot_node: &Node,
299    joint_node: Node,
300    parent_node: &Node,
301    parent_joint_id: usize,
302    model: &mut Model,
303) -> Result<usize, ParseError> {
304    // extract the name and type of joint
305    let joint_name = joint_node
306        .attribute("name")
307        .ok_or(ParseError::NameMissing(format!("{joint_node:#?}")))?
308        .to_string();
309    let joint_type = joint_node
310        .attribute("type")
311        .ok_or(ParseError::MissingParameter("type".to_string()))?;
312
313    // get the parent frame
314    let parent_frame_id = get_parent_frame(parent_node, robot_node, model)?;
315    let parent_frame = &model.frames[parent_frame_id];
316
317    // compute the placement of the joint
318    let link_origin = parse_origin(&joint_node)?;
319    let placement = parent_frame.placement * link_origin;
320
321    let new_joint_id = match joint_type {
322        // if the joint is fixed, we create a fixed frame
323        "fixed" => {
324            let frame = Frame::new(
325                joint_name.clone(),
326                parent_joint_id,
327                parent_frame_id,
328                placement,
329                FrameType::Fixed,
330                Inertia::zeros(),
331            );
332            let _ = model.add_frame(frame, false);
333
334            // we return the parent joint id as there is no new joint
335            Ok(parent_joint_id)
336        }
337
338        // if the joint is continuous, we create a revolute joint without limits
339        "continuous" => {
340            let axis = parse_axis(joint_node)?;
341            let mut joint_model = JointModelContinuous::new(axis);
342
343            // we extract the limit of the joint
344            if let Some(limit_node) = joint_node.children().find(|n| n.has_tag_name("limit")) {
345                let effort = extract_parameter::<f64>("effort", &limit_node)?;
346                joint_model.limits.effort[0] = effort;
347
348                let velocity = extract_parameter::<f64>("velocity", &limit_node)?;
349                joint_model.limits.velocity[0] = velocity;
350            }
351
352            model.add_joint(
353                parent_joint_id,
354                JointWrapper::continuous(joint_model),
355                placement,
356                joint_name.clone(),
357            )
358        }
359
360        // if the joint is revolute, we create a revolute joint
361        "revolute" => {
362            let axis = parse_axis(joint_node)?;
363
364            // we extract the limit of the joint
365            let limit_node = joint_node
366                .children()
367                .find(|n| n.has_tag_name("limit"))
368                .ok_or(ParseError::MissingParameter("limit".to_string()))?;
369
370            // TODO: extract dynamics (damping, ...)
371
372            let mut joint_model = JointModelRevolute::new(axis);
373
374            // optional parameters
375            if let Ok(lower) = extract_parameter::<f64>("lower", &limit_node) {
376                joint_model.limits.min_configuration[0] = lower;
377            }
378            if let Ok(upper) = extract_parameter::<f64>("upper", &limit_node) {
379                joint_model.limits.max_configuration[0] = upper;
380            }
381
382            // required parameters
383            let effort = extract_parameter::<f64>("effort", &limit_node)?;
384            joint_model.limits.effort[0] = effort;
385
386            let velocity = extract_parameter::<f64>("velocity", &limit_node)?;
387            joint_model.limits.velocity[0] = velocity;
388
389            model.add_joint(
390                parent_joint_id,
391                JointWrapper::revolute(joint_model),
392                placement,
393                joint_name.clone(),
394            )
395        }
396
397        "prismatic" => {
398            let axis = parse_axis(joint_node)?;
399
400            // we extract the limit of the joint
401            let limit_node = joint_node
402                .children()
403                .find(|n| n.has_tag_name("limit"))
404                .ok_or(ParseError::MissingParameter("limit".to_string()))?;
405
406            // TODO: extract dynamics (damping, ...)
407
408            let mut joint_model = JointModelPrismatic::new(axis);
409
410            // optional parameters
411            if let Ok(lower) = extract_parameter::<f64>("lower", &limit_node) {
412                joint_model.limits.min_configuration[0] = lower;
413            }
414            if let Ok(upper) = extract_parameter::<f64>("upper", &limit_node) {
415                joint_model.limits.max_configuration[0] = upper;
416            }
417
418            // required parameters
419            let effort = extract_parameter::<f64>("effort", &limit_node)?;
420            joint_model.limits.effort[0] = effort;
421
422            let velocity = extract_parameter::<f64>("velocity", &limit_node)?;
423            joint_model.limits.velocity[0] = velocity;
424
425            model.add_joint(
426                parent_joint_id,
427                JointWrapper::prismatic(joint_model),
428                placement,
429                joint_name.clone(),
430            )
431        }
432
433        _ => return Err(ParseError::UnknownJointType(joint_type.to_string())),
434    }
435    .map_err(ParseError::ModelError)?;
436
437    // if the joint is not fixed, we also add a frame
438    if joint_type != "fixed" {
439        let frame = Frame::new(
440            joint_name,
441            new_joint_id,
442            parent_frame_id,
443            SE3::identity(),
444            FrameType::Joint,
445            Inertia::zeros(),
446        );
447        model
448            .add_frame(frame, true)
449            .map_err(ParseError::ModelError)?;
450    }
451
452    Ok(new_joint_id)
453}
454
455/// Parses an axis defined in the URDF file, given a joint node.
456///
457/// # Arguments:
458/// - `joint_node`: The joint node containing the axis definition.
459///
460/// # Returns:
461/// - `Result<Vector3D, ParseError>`: The parsed axis as a `Vector3D` if successful, or a [`ParseError`] if an error occurs.
462fn parse_axis(joint_node: Node) -> Result<Vector3D, ParseError> {
463    if let Some(axis_node) = joint_node.children().find(|n| n.has_tag_name("axis")) {
464        let axis_values = extract_parameter_list::<f64>("xyz", &axis_node, Some(3))?;
465        Ok(Vector3D::new(
466            axis_values[0],
467            axis_values[1],
468            axis_values[2],
469        ))
470    } else {
471        // default axis if not specified
472        Ok(Vector3D::new(1.0, 0.0, 0.0))
473    }
474}
475
476/// Parses all materials defined at the root of the robot node.
477///
478/// # Arguments:
479/// - `robot_node`: The root robot node containing material definitions.
480///
481/// # Returns:
482/// - A map from material names to their corresponding `Color` objects if successful, or a [`ParseError`] if an error occurs.
483fn parse_root_materials(robot_node: &Node) -> Result<HashMap<String, Color>, ParseError> {
484    let mut materials = HashMap::new();
485    for material_node in robot_node.children().filter(|n| n.has_tag_name("material")) {
486        parse_material(material_node, &mut materials)?;
487    }
488    Ok(materials)
489}
490
491/// Parses a link node.
492///
493/// This functions extracts the link's inertia, adds a frame for the link, and parses its collision and visual geometries.
494/// The geometries are added to the provided collision and visualization geometry models.
495/// To each link is associated exactly one frame in the model.
496///
497/// # Arguments:
498/// - `robot_node`: The root robot node containing all links and joints.
499/// - `node`: The link node to parse.
500/// - `parent_node`: The XML parent node of the link.
501/// - `parent_joint_id`: The ID of the parent joint in the model.
502/// - `is_root`: Whether the link is a root link.
503/// - `model`: The model being built.
504/// - `coll_model`: The collision geometry model being built.
505/// - `viz_model`: The visualization geometry model being built.
506/// - `materials`: The map of materials defined in the robot.
507/// - `package_dir`: The path to the folder containing the mesh files referenced in the URDF.
508///
509/// # Returns:
510/// - `Result<(), ParseError>`: `Ok` if successful, or a [`ParseError`] if an error occurs.
511fn parse_link(
512    robot_node: &Node,
513    node: Node,
514    parent_node: &Node,
515    parent_joint_id: usize,
516    is_root: bool,
517    model: &mut Model,
518    coll_model: &mut GeometryModel,
519    viz_model: &mut GeometryModel,
520    materials: &mut HashMap<String, Color>,
521    package_dir: Option<&str>,
522) -> Result<(), ParseError> {
523    let link_name = node.attribute("name").unwrap_or("").to_string();
524    let parent_frame_id = get_parent_frame(parent_node, robot_node, model)?;
525
526    // parse the inertial node
527    let link_inertia = parse_inertia(node, &link_name)?;
528
529    // inertia associated with the new frame
530    // if this is the root link, we put the inertia in the frame
531    // otherwise, we associate the inertia to the joint (in model.inertias)
532    let frame_inertia = if is_root {
533        link_inertia
534    }
535    // if the parent joint type is fixed, we put the inertia in the parent's frame
536    else if let Some(parent_joint_type) = parent_node.attribute("type")
537        && parent_joint_type == "fixed"
538    {
539        model.frames[parent_frame_id].inertia += link_inertia.clone(); // TODO: check if this clone can be avoided
540        model.inertias[parent_joint_id] +=
541            link_inertia.act(&model.frames[parent_frame_id].placement);
542        Inertia::zeros()
543    } else {
544        model.inertias[parent_joint_id] +=
545            link_inertia.act(&model.frames[parent_frame_id].placement);
546        Inertia::zeros()
547    };
548
549    // compute the placement of the link frame
550    let parent_frame = &model.frames[parent_frame_id];
551    let link_placement = parent_frame.placement * parse_origin(&node)?;
552
553    // add a frame for the link
554    let frame = Frame::new(
555        link_name.clone(),
556        parent_joint_id,
557        parent_frame_id,
558        link_placement,
559        FrameType::Body,
560        frame_inertia,
561    );
562    let new_frame_id = model
563        .add_frame(frame, true)
564        .map_err(ParseError::ModelError)?;
565
566    // parse the collision node
567    for (i, collision_node) in node
568        .children()
569        .filter(|n| n.has_tag_name("collision"))
570        .enumerate()
571    {
572        let geom_obj = parse_geometry(
573            format!("{link_name}_{i}"),
574            &collision_node,
575            parent_joint_id,
576            new_frame_id,
577            model,
578            materials,
579            package_dir,
580            GeometryType::Collision,
581        )?;
582        coll_model.add_geometry_object(geom_obj);
583    }
584
585    // parse the visual node
586    for (i, visual_node) in node
587        .children()
588        .filter(|n| n.has_tag_name("visual"))
589        .enumerate()
590    {
591        let geom_obj = parse_geometry(
592            format!("{link_name}_{i}"),
593            &visual_node,
594            parent_joint_id,
595            new_frame_id,
596            model,
597            materials,
598            package_dir,
599            GeometryType::Visual,
600        )?;
601        viz_model.add_geometry_object(geom_obj);
602    }
603
604    Ok(())
605}
606
607/// Retrieves the parent frame index from the parent node.
608fn get_parent_frame(
609    parent_node: &Node,
610    robot_node: &Node,
611    model: &Model,
612) -> Result<usize, ParseError> {
613    // extract the name of the parent link
614    let parent_name = parent_node
615        .attribute("name")
616        .ok_or(ParseError::NameMissing(format!("{parent_node:#?}")))?;
617    let robot_name = robot_node
618        .attribute("name")
619        .ok_or(ParseError::NameMissing(format!("{robot_node:#?}")))?;
620
621    // check if the parent is the world frame
622    if parent_name == robot_name {
623        Ok(WORLD_ID)
624    }
625    // else, retrieve the frame id from the model
626    else {
627        // compute the type of the parent node
628        let parent_type = parent_node.attribute("type").unwrap_or("");
629        let frame_type = match parent_type {
630            "fixed" => FrameType::Fixed,
631            "revolute" | "continuous" | "prismatic" => FrameType::Joint,
632            _ => FrameType::Body,
633        };
634        model
635            .get_frame_id(parent_name, Some(&frame_type))
636            .ok_or(ParseError::UnknownParent(parent_name.to_string()))
637    }
638}
639
640/// Parses a material from the URDF file.
641///
642/// # Arguments:
643/// - `node`: The material node to parse.
644/// - `materials`: The map of materials to update.
645///
646/// # Returns:
647/// - If successful, updates the `materials` map with the parsed material.
648/// - Otherwise, returns a [`ParseError`].
649fn parse_material(node: Node, materials: &mut HashMap<String, Color>) -> Result<(), ParseError> {
650    // extract name
651    let material_name = node
652        .attribute("name")
653        .ok_or(ParseError::NameMissing(format!("{node:#?}")))?
654        .to_string();
655
656    // extract and convert color
657    let color_node = node
658        .children()
659        .find(|n| n.has_tag_name("color"))
660        .ok_or(ParseError::MaterialWithoutColor(material_name.clone()))?;
661    let rgba = extract_parameter_list::<f64>("rgba", &color_node, Some(4))?;
662    let color = Color::new(rgba[0], rgba[1], rgba[2], rgba[3]);
663
664    // TODO: handle texture
665
666    materials.insert(material_name, color);
667    Ok(())
668}
669
670/// Parses the inertia of a link from the URDF file.
671///
672/// # Arguments:
673/// - `node`: The link node to parse.
674/// - `link_name`: The name of the link (for error reporting).
675///
676/// # Returns:
677/// - The parsed [`Inertia`] if successful.
678/// - A [`ParseError`] if an error occurs.
679fn parse_inertia(node: Node, link_name: &str) -> Result<Inertia, ParseError> {
680    if let Some(inertial_node) = node.children().find(|n| n.has_tag_name("inertial")) {
681        let mass_node = inertial_node
682            .children()
683            .find(|n| n.has_tag_name("mass"))
684            .ok_or(ParseError::InertialWithoutMass(link_name.to_string()))?;
685        let mass = extract_parameter::<f64>("value", &mass_node)?;
686
687        let inertia_node = inertial_node
688            .children()
689            .find(|n| n.has_tag_name("inertia"))
690            .ok_or(ParseError::InertialWithoutInertia(link_name.to_string()))?;
691
692        let ixx = extract_parameter::<f64>("ixx", &inertia_node)?;
693        let ixy = extract_parameter::<f64>("ixy", &inertia_node)?;
694        let ixz = extract_parameter::<f64>("ixz", &inertia_node)?;
695        let iyy = extract_parameter::<f64>("iyy", &inertia_node)?;
696        let iyz = extract_parameter::<f64>("iyz", &inertia_node)?;
697        let izz = extract_parameter::<f64>("izz", &inertia_node)?;
698        let inertia_mat = Symmetric3::new(ixx, iyy, izz, ixy, ixz, iyz);
699
700        let inertial_origin = if let Some(origin_node) =
701            inertial_node.children().find(|n| n.has_tag_name("origin"))
702        {
703            // extract rpy and xyz (default to zeros if not specified)
704            let rpy =
705                extract_parameter_list::<f64>("rpy", &origin_node, Some(3)).unwrap_or(vec![0.0; 3]);
706            let xyz =
707                extract_parameter_list::<f64>("xyz", &origin_node, Some(3)).unwrap_or(vec![0.0; 3]);
708
709            // construct the rotation and translation
710            let rotation = SpatialRotation::from_euler_angles(rpy[0], rpy[1], rpy[2]);
711            let translation = Vector3D::new(xyz[0], xyz[1], xyz[2]);
712
713            SE3::from_parts(translation, rotation)
714        } else {
715            // defaults to identity if no origin is specified
716            SE3::identity()
717        };
718
719        Ok(Inertia::new(
720            mass,
721            inertial_origin.translation(),
722            inertia_mat.rotate(&inertial_origin.rotation()),
723        ))
724    } else {
725        Ok(Inertia::zeros())
726    }
727}
728
729/// Parses the geometry of a link from the URDF file.
730/// Extracts the geometry shape and its parameters.
731///
732/// # Arguments:
733/// - `link_name`: The name of the link (for error reporting).
734/// - `node`: The geometry node to parse.
735/// - `parent_joint_id`: The ID of the parent joint in the model.
736/// - `parent_frame_id`: The ID of the parent frame in the model.
737/// - `model`: The model being built.
738/// - `materials`: The map of materials defined in the robot.
739/// - `package_dir`: The path to the folder containing the mesh files referenced in the URDF.
740/// - `geometry_type`: The type of geometry being parsed (collision or visual).
741///
742/// # Returns:
743/// - The parsed [`GeometryObject`] if successful.
744/// - A [`ParseError`] if an error occurs.
745fn parse_geometry(
746    link_name: String,
747    node: &Node,
748    parent_joint_id: usize,
749    parent_frame_id: usize,
750    model: &Model,
751    materials: &mut HashMap<String, Color>,
752    package_dir: Option<&str>,
753    geometry_type: GeometryType,
754) -> Result<GeometryObject, ParseError> {
755    let geometry_node = node
756        .children()
757        .find(|n| n.has_tag_name("geometry"))
758        .ok_or(ParseError::VisualWithoutGeometry(link_name.clone()))?;
759
760    // extract the shape from the geometry node
761    let shape: ShapeWrapper = if let Some(shape_node) =
762        geometry_node.children().find(|n| n.has_tag_name("box"))
763    {
764        let size = extract_parameter_list::<f32>("size", &shape_node, Some(3))?;
765        let half_extents = Vector3::new(size[0] / 2.0, size[1] / 2.0, size[2] / 2.0);
766        Box::new(Cuboid::new(half_extents))
767    } else if let Some(shape_node) = geometry_node
768        .children()
769        .find(|n| n.has_tag_name("cylinder"))
770    {
771        let radius = extract_parameter::<f32>("radius", &shape_node)?;
772        let length = extract_parameter::<f32>("length", &shape_node)?;
773        Box::new(Cylinder::new(radius, length / 2.0))
774    } else if let Some(shape_node) = geometry_node.children().find(|n| n.has_tag_name("sphere")) {
775        let radius = extract_parameter::<f32>("radius", &shape_node)?;
776        Box::new(Sphere::new(radius))
777    } else if let Some(mesh_node) = geometry_node.children().find(|n| n.has_tag_name("mesh")) {
778        let filename = mesh_node
779            .attribute("filename")
780            .ok_or(ParseError::MissingParameter("filename".to_string()))?;
781
782        let absolute_path = if let Some(filename) = filename.strip_prefix("package://") {
783            // if the filename starts with "package:///", we remove the first slash
784            let filename = filename.strip_prefix('/').unwrap_or(filename);
785
786            // retrieve the package path in between "package://" and the first "/"
787            let path_parts: Vec<&str> = filename.splitn(2, '/').collect();
788            if path_parts.len() != 2 {
789                return Err(ParseError::InvalidFilePath(format!(
790                    "Invalid package path: {filename}"
791                )));
792            }
793            let package_name = path_parts[0];
794            let relative_path = path_parts[1];
795
796            // retrieve the package path from the 'ROS_PACKAGE_PATH' environment variable
797            let ros_package_path = std::env::var("ROS_PACKAGE_PATH").map_err(|_| ParseError::InvalidFilePath(
798                "'package://' was used but the ROS_PACKAGE_PATH environment variable is not set".to_string(),
799            ))?;
800            let package_paths: Vec<&str> = ros_package_path
801                .split(':')
802                .filter(|s| !s.is_empty())
803                .collect();
804
805            // search for the package in the package paths
806            let mut package_path = None;
807            for path in package_paths {
808                // extract the last folder name in the path
809                let path_obj = Path::new(path);
810
811                // check if the folder name matches the package name
812                if let Some(folder_name) = path_obj.file_name().and_then(|s| s.to_str())
813                    && folder_name == package_name
814                {
815                    package_path = Some(path_obj.to_path_buf());
816                    break;
817                }
818            }
819
820            let package_path = package_path.ok_or(ParseError::InvalidFilePath(format!(
821                "Package '{package_name}' not found in ROS_PACKAGE_PATH",
822            )))?;
823
824            // construct the absolute path with the mesh relative path
825            package_path
826                .join(relative_path)
827                .to_str()
828                .ok_or(ParseError::InvalidFilePath(format!(
829                    "Invalide path: {}",
830                    package_path.join(relative_path).display()
831                )))?
832                .to_string()
833        } else if filename.starts_with('/') {
834            filename.to_string()
835        } else {
836            // treat path as relative path from package_dir
837            let package_dir = package_dir.ok_or(ParseError::InvalidFilePath(
838                "'package_dir' must be provided to resolve mesh file paths".to_string(),
839            ))?;
840            let package_dir = Path::new(package_dir);
841            package_dir
842                .join(filename)
843                .to_str()
844                .ok_or(ParseError::InvalidFilePath(format!(
845                    "Invalide path: {}",
846                    package_dir.join(filename).display()
847                )))?
848                .to_string()
849        };
850
851        // check if the file exists
852        if !std::path::Path::new(&absolute_path).exists() {
853            return Err(ParseError::InvalidFilePath(format!(
854                "Mesh file does not exist: {absolute_path}"
855            )));
856        }
857
858        Box::new(Mesh::new(absolute_path))
859    } else {
860        return Err(ParseError::GeometryWithoutShape(link_name.clone()));
861    };
862
863    // extract the origin from the visual node
864    let parent_frame = &model.frames[parent_frame_id];
865    let placement = parent_frame.placement * parse_origin(node)?;
866
867    // extract the material color
868    let mut color = Color::new(0.9, 0.9, 0.9, 1.0);
869    if geometry_type != GeometryType::Visual {
870        // default color for collision geometries (see above)
871    }
872    // if there is a material node
873    else if let Some(material_node) = node.children().find(|n| n.has_tag_name("material")) {
874        // if this material node has a name
875        // and this material was already defined in the robot node
876        if let Some(material_name) = material_node.attribute("name")
877            && let Some(material_color) = materials.get(material_name)
878        {
879            color = *material_color;
880        }
881        // else, check if it has a color node
882        else if let Some(color_node) = material_node.children().find(|n| n.has_tag_name("color"))
883            && let Ok(rgba) = extract_parameter_list::<f64>("rgba", &color_node, Some(4))
884        {
885            color = Color::new(rgba[0], rgba[1], rgba[2], rgba[3]);
886
887            // if the material has a name, we add it to the materials map
888            if let Some(material_name) = material_node.attribute("name")
889                && !material_name.is_empty()
890            {
891                materials.insert(material_name.to_string(), color);
892            }
893        }
894    }
895
896    let geom_obj = GeometryObject::new(
897        link_name,
898        parent_joint_id,
899        parent_frame_id,
900        shape,
901        color,
902        placement,
903    );
904    Ok(geom_obj)
905}
906
907/// Extracts a parameter from the XML node with the given name and converts it to the specified type.
908/// Returns an error if the parameter is missing or cannot be parsed.
909fn extract_parameter<T: FromStr>(name: &str, node: &roxmltree::Node) -> Result<T, ParseError> {
910    node.attribute(name)
911        .ok_or_else(|| ParseError::MissingParameter(name.to_string()))?
912        .parse::<T>()
913        .map_err(|_| ParseError::InvalidParameter(name.to_string()))
914}
915
916/// Extracts a list of parameters from the XML node with the given name and converts them to the specified type.
917/// Returns an error if the parameter is missing or any value cannot be parsed.
918fn extract_parameter_list<T: FromStr>(
919    name: &str,
920    node: &roxmltree::Node,
921    expected_length: Option<usize>,
922) -> Result<Vec<T>, ParseError> {
923    let vector = node
924        .attribute(name)
925        .ok_or_else(|| ParseError::MissingParameter(name.to_string()))?
926        .split_whitespace()
927        .map(|s| {
928            s.parse::<T>()
929                .map_err(|_| ParseError::InvalidParameter(name.to_string()))
930        })
931        .collect::<Result<Vec<T>, ParseError>>()?;
932    if let Some(expected_length) = expected_length
933        && vector.len() != expected_length
934    {
935        return Err(ParseError::InvalidParameter(name.to_string()));
936    }
937    Ok(vector)
938}
939
940fn parse_origin(node: &roxmltree::Node) -> Result<SE3, ParseError> {
941    let isometry = if let Some(origin_node) = node.children().find(|n| n.has_tag_name("origin")) {
942        let xyz = extract_parameter_list::<f64>("xyz", &origin_node, Some(3))?;
943        let rotation = match extract_parameter_list::<f64>("rpy", &origin_node, Some(3)) {
944            Ok(rpy) => SpatialRotation::from_euler_angles(rpy[0], rpy[1], rpy[2]),
945            Err(ParseError::MissingParameter(_)) => SpatialRotation::identity(),
946            Err(e) => return Err(e),
947        };
948        let translation = Vector3D::new(xyz[0], xyz[1], xyz[2]);
949
950        SE3::from_parts(translation, rotation)
951    } else {
952        SE3::identity()
953    };
954    Ok(isometry)
955}