Skip to main content

phoxal_model/structure/
mod.rs

1//! Canonical normalized structure facts.
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use std::collections::{HashMap, HashSet};
6
7use crate::{AssetId, ModelError};
8
9const MIN_AXIS_NORM_SQUARED: f64 = 1.0e-16;
10const PSD_RELATIVE_TOLERANCE: f64 = 1.0e-12;
11
12/// A normalized robot structure without authored URDF or filesystem state.
13#[derive(Clone, Debug)]
14pub struct Structure {
15    document: Value,
16    links: Vec<Link>,
17    joints: Vec<Joint>,
18    materials: Vec<Material>,
19}
20
21/// A canonical structural link.
22#[derive(Clone, Debug)]
23pub struct Link {
24    name: String,
25    inertial: Inertial,
26    visuals: Vec<Visual>,
27    collisions: Vec<Collision>,
28}
29
30/// A canonical structural joint.
31#[derive(Clone, Debug)]
32pub struct Joint {
33    name: String,
34    kind: JointKind,
35    origin: Pose,
36    parent: String,
37    child: String,
38    axis: [f64; 3],
39    limit: JointLimit,
40    calibration: Option<Calibration>,
41    dynamics: Option<Dynamics>,
42    mimic: Option<Mimic>,
43    safety: Option<Safety>,
44}
45
46/// A normalized rigid transform.
47#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
48#[serde(deny_unknown_fields)]
49pub struct Pose {
50    xyz: [f64; 3],
51    rpy: [f64; 3],
52}
53
54/// Canonical mass properties for one link.
55#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
56#[serde(deny_unknown_fields)]
57pub struct Inertial {
58    origin: Pose,
59    mass_kg: f64,
60    inertia: Inertia,
61}
62
63/// Canonical symmetric inertia tensor.
64#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
65#[serde(deny_unknown_fields)]
66pub struct Inertia {
67    ixx: f64,
68    ixy: f64,
69    ixz: f64,
70    iyy: f64,
71    iyz: f64,
72    izz: f64,
73}
74
75/// One canonical visual shape.
76#[derive(Clone, Debug, Deserialize, Serialize)]
77#[serde(deny_unknown_fields)]
78pub struct Visual {
79    name: Option<String>,
80    origin: Pose,
81    geometry: Geometry,
82    material: Option<Material>,
83}
84
85/// One canonical collision shape.
86#[derive(Clone, Debug, Deserialize, Serialize)]
87#[serde(deny_unknown_fields)]
88pub struct Collision {
89    name: Option<String>,
90    origin: Pose,
91    geometry: Geometry,
92}
93
94/// Canonical render material.
95#[derive(Clone, Debug, Deserialize, Serialize)]
96#[serde(deny_unknown_fields)]
97pub struct Material {
98    name: String,
99    color: Option<[f64; 4]>,
100    texture: Option<AssetId>,
101}
102
103/// Complete canonical geometry vocabulary.
104#[derive(Clone, Debug, Deserialize, Serialize)]
105#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
106pub enum Geometry {
107    Box {
108        size: [f64; 3],
109    },
110    Cylinder {
111        radius: f64,
112        length: f64,
113    },
114    Capsule {
115        radius: f64,
116        length: f64,
117    },
118    Sphere {
119        radius: f64,
120    },
121    Mesh {
122        #[serde(rename = "filename")]
123        asset: AssetId,
124        scale: Option<[f64; 3]>,
125    },
126}
127
128/// Canonical joint limits.
129#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
130#[serde(deny_unknown_fields)]
131pub struct JointLimit {
132    lower: f64,
133    upper: f64,
134    effort: f64,
135    velocity: f64,
136}
137
138/// Canonical joint calibration.
139#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
140#[serde(deny_unknown_fields)]
141pub struct Calibration {
142    rising: Option<f64>,
143    falling: Option<f64>,
144}
145
146/// Canonical joint damping and friction.
147#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
148#[serde(deny_unknown_fields)]
149pub struct Dynamics {
150    damping: f64,
151    friction: f64,
152}
153
154/// Canonical mimic relationship.
155#[derive(Clone, Debug, Deserialize, Serialize)]
156#[serde(deny_unknown_fields)]
157pub struct Mimic {
158    joint: String,
159    multiplier: Option<f64>,
160    offset: Option<f64>,
161}
162
163/// Canonical software joint safety limits.
164#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
165#[serde(deny_unknown_fields)]
166pub struct Safety {
167    soft_lower_limit: f64,
168    soft_upper_limit: f64,
169    k_position: f64,
170    k_velocity: f64,
171}
172
173/// Supported structural joint kinds.
174#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
175#[serde(rename_all = "snake_case")]
176pub enum JointKind {
177    Revolute,
178    Continuous,
179    Prismatic,
180    Fixed,
181    Floating,
182    Planar,
183    Spherical,
184}
185
186impl Structure {
187    /// Canonical links in deterministic document order.
188    pub fn links(&self) -> impl ExactSizeIterator<Item = &Link> {
189        self.links.iter()
190    }
191
192    /// Canonical joints in deterministic document order.
193    pub fn joints(&self) -> impl ExactSizeIterator<Item = &Joint> {
194        self.joints.iter()
195    }
196
197    /// Canonical materials in deterministic document order.
198    pub fn materials(&self) -> impl ExactSizeIterator<Item = &Material> {
199        self.materials.iter()
200    }
201
202    /// Distinct logical asset identities referenced by this structure.
203    pub fn asset_ids(&self) -> impl Iterator<Item = &AssetId> {
204        let mut seen = HashSet::new();
205        self.links
206            .iter()
207            .flat_map(|link| {
208                link.visuals()
209                    .filter_map(|visual| visual.geometry().asset_id())
210                    .chain(
211                        link.collisions()
212                            .filter_map(|collision| collision.geometry().asset_id()),
213                    )
214                    .chain(
215                        link.visuals()
216                            .filter_map(|visual| visual.material()?.texture()),
217                    )
218            })
219            .chain(self.materials.iter().filter_map(Material::texture))
220            .filter(move |id| seen.insert(id.as_str()))
221    }
222
223    /// Find a canonical link by identity.
224    #[must_use]
225    pub fn link(&self, id: &str) -> Option<&Link> {
226        self.links.iter().find(|link| link.name == id)
227    }
228
229    /// Find a canonical joint by identity.
230    #[must_use]
231    pub fn joint(&self, id: &str) -> Option<&Joint> {
232        self.joints.iter().find(|joint| joint.name == id)
233    }
234
235    /// The validated single root link.
236    #[must_use]
237    pub fn root_link(&self) -> &Link {
238        self.links
239            .iter()
240            .find(|link| !self.joints.iter().any(|joint| joint.child() == link.name()))
241            .expect("validated structure has one root link")
242    }
243
244    #[must_use]
245    pub fn parent_joint(&self, link: &str) -> Option<&Joint> {
246        self.joints.iter().find(|joint| joint.child() == link)
247    }
248
249    pub fn child_joints<'a>(&'a self, link: &'a str) -> impl Iterator<Item = &'a Joint> {
250        self.joints
251            .iter()
252            .filter(move |joint| joint.parent() == link)
253    }
254
255    pub(crate) fn validate(&self) -> Result<(), ModelError> {
256        self.validate_leaf_values()?;
257        validate_unique(self.links.iter().map(Link::name), "link")?;
258        validate_unique(self.joints.iter().map(Joint::name), "joint")?;
259        let link_names = self.links.iter().map(Link::name).collect::<HashSet<_>>();
260        let mut children = HashSet::new();
261        let mut parent_by_child = HashMap::new();
262        for joint in &self.joints {
263            if !link_names.contains(joint.parent()) {
264                return Err(ModelError::Invalid(format!(
265                    "joint '{}' references unknown parent link '{}'",
266                    joint.name(),
267                    joint.parent()
268                )));
269            }
270            if !link_names.contains(joint.child()) {
271                return Err(ModelError::Invalid(format!(
272                    "joint '{}' references unknown child link '{}'",
273                    joint.name(),
274                    joint.child()
275                )));
276            }
277            if !children.insert(joint.child()) {
278                return Err(ModelError::Invalid(format!(
279                    "link '{}' is the child of multiple joints",
280                    joint.child()
281                )));
282            }
283            if joint.parent() == joint.child() {
284                return Err(ModelError::Invalid(format!(
285                    "joint '{}' cannot use '{}' as both parent and child",
286                    joint.name(),
287                    joint.parent()
288                )));
289            }
290            parent_by_child.insert(joint.child(), joint.parent());
291        }
292        let roots = self
293            .links
294            .iter()
295            .map(Link::name)
296            .filter(|link| !children.contains(link))
297            .collect::<Vec<_>>();
298        if roots.len() != 1 {
299            return Err(ModelError::Invalid(format!(
300                "structure must have exactly one root link, found {}",
301                roots.len()
302            )));
303        }
304        for link in &self.links {
305            let mut seen = HashSet::new();
306            let mut current = Some(link.name());
307            while let Some(link_id) = current {
308                if !seen.insert(link_id) {
309                    return Err(ModelError::Invalid(format!(
310                        "structure contains a joint cycle involving '{link_id}'"
311                    )));
312                }
313                current = parent_by_child.get(link_id).copied();
314            }
315        }
316        Ok(())
317    }
318
319    fn validate_leaf_values(&self) -> Result<(), ModelError> {
320        for link in &self.links {
321            let inertial = link.inertial();
322            validate_pose_values(
323                inertial.origin(),
324                &format!("link '{}' inertial", link.name()),
325            )?;
326            validate_nonnegative(inertial.mass_kg(), &format!("link '{}' mass", link.name()))?;
327            validate_inertia(inertial.inertia(), link.name())?;
328            for visual in link.visuals() {
329                validate_pose_values(visual.origin(), &format!("link '{}' visual", link.name()))?;
330                validate_geometry_values(visual.geometry(), link.name())?;
331            }
332            for collision in link.collisions() {
333                validate_pose_values(
334                    collision.origin(),
335                    &format!("link '{}' collision", link.name()),
336                )?;
337                validate_geometry_values(collision.geometry(), link.name())?;
338            }
339        }
340        let joint_names = self.joints.iter().map(Joint::name).collect::<HashSet<_>>();
341        for joint in &self.joints {
342            validate_pose_values(joint.origin(), &format!("joint '{}'", joint.name()))?;
343            let axis = joint.axis();
344            if !axis.iter().all(|value| value.is_finite()) {
345                return Err(ModelError::Invalid(format!(
346                    "joint '{}' axis must be finite",
347                    joint.name()
348                )));
349            }
350            if joint.kind() != JointKind::Fixed
351                && axis.iter().map(|value| value * value).sum::<f64>() <= MIN_AXIS_NORM_SQUARED
352            {
353                return Err(ModelError::Invalid(format!(
354                    "joint '{}' axis must be non-zero",
355                    joint.name()
356                )));
357            }
358            let limit = joint.limit();
359            let limits = [
360                limit.lower(),
361                limit.upper(),
362                limit.effort(),
363                limit.velocity(),
364            ];
365            if !limits.iter().all(|value| value.is_finite()) || limit.lower() > limit.upper() {
366                return Err(ModelError::Invalid(format!(
367                    "joint '{}' limits must be finite with lower <= upper",
368                    joint.name()
369                )));
370            }
371            if let Some(dynamics) = joint.dynamics()
372                && (!dynamics.damping().is_finite()
373                    || dynamics.damping() < 0.0
374                    || !dynamics.friction().is_finite()
375                    || dynamics.friction() < 0.0)
376            {
377                return Err(ModelError::Invalid(format!(
378                    "joint '{}' dynamics must be finite and non-negative",
379                    joint.name()
380                )));
381            }
382            if let Some(mimic) = joint.mimic()
383                && !joint_names.contains(mimic.joint())
384            {
385                return Err(ModelError::Invalid(format!(
386                    "joint '{}' mimics unknown joint '{}'",
387                    joint.name(),
388                    mimic.joint()
389                )));
390            }
391            if let Some(safety) = joint.safety()
392                && (![
393                    safety.soft_lower_limit(),
394                    safety.soft_upper_limit(),
395                    safety.k_position(),
396                    safety.k_velocity(),
397                ]
398                .iter()
399                .all(|value| value.is_finite())
400                    || safety.soft_lower_limit() > safety.soft_upper_limit())
401            {
402                return Err(ModelError::Invalid(format!(
403                    "joint '{}' safety limits must be finite with lower <= upper",
404                    joint.name()
405                )));
406            }
407        }
408        Ok(())
409    }
410
411    pub(crate) fn validate_robot_frames(&self) -> Result<(), ModelError> {
412        if self.root_link().name() != "base_footprint" {
413            return Err(ModelError::Invalid(format!(
414                "structure root link must be 'base_footprint', found '{}'",
415                self.root_link().name()
416            )));
417        }
418        let base_joint = self
419            .joints
420            .iter()
421            .find(|joint| joint.child() == "base_link")
422            .ok_or_else(|| {
423                ModelError::Invalid(
424                    "structure must attach 'base_link' under 'base_footprint' with a fixed joint"
425                        .to_string(),
426                )
427            })?;
428        if base_joint.parent() != "base_footprint" || base_joint.kind() != JointKind::Fixed {
429            return Err(ModelError::Invalid(
430                "structure must attach 'base_link' directly under 'base_footprint' with a fixed joint"
431                    .to_string(),
432            ));
433        }
434        Ok(())
435    }
436
437    pub(crate) fn from_compiler_value(document: Value) -> Result<Self, ModelError> {
438        let summary: Summary = serde_json::from_value(document)
439            .map_err(|error| ModelError::Invalid(error.to_string()))?;
440        Self::from_summary(summary)
441    }
442
443    fn from_summary(summary: Summary) -> Result<Self, ModelError> {
444        let document = serde_json::to_value(&summary)
445            .map_err(|error| ModelError::Invalid(error.to_string()))?;
446        let structure = Self {
447            document,
448            links: summary
449                .links
450                .into_iter()
451                .map(|link| Link {
452                    name: link.name,
453                    inertial: link.inertial,
454                    visuals: link.visuals,
455                    collisions: link.collisions,
456                })
457                .collect(),
458            joints: summary
459                .joints
460                .into_iter()
461                .map(|joint| Joint {
462                    name: joint.name,
463                    kind: joint.kind,
464                    origin: Pose {
465                        xyz: joint.origin.xyz,
466                        rpy: joint.origin.rpy,
467                    },
468                    parent: joint.parent,
469                    child: joint.child,
470                    axis: joint.axis,
471                    limit: joint.limit,
472                    calibration: joint.calibration,
473                    dynamics: joint.dynamics,
474                    mimic: joint.mimic,
475                    safety: joint.safety,
476                })
477                .collect(),
478            materials: summary.materials,
479        };
480        structure.validate()?;
481        Ok(structure)
482    }
483}
484
485impl Link {
486    #[must_use]
487    pub fn name(&self) -> &str {
488        &self.name
489    }
490
491    #[must_use]
492    pub const fn inertial(&self) -> Inertial {
493        self.inertial
494    }
495
496    pub fn visuals(&self) -> impl ExactSizeIterator<Item = &Visual> {
497        self.visuals.iter()
498    }
499
500    pub fn collisions(&self) -> impl ExactSizeIterator<Item = &Collision> {
501        self.collisions.iter()
502    }
503}
504
505impl Joint {
506    #[must_use]
507    pub fn name(&self) -> &str {
508        &self.name
509    }
510    #[must_use]
511    pub const fn kind(&self) -> JointKind {
512        self.kind
513    }
514    #[must_use]
515    pub const fn origin(&self) -> Pose {
516        self.origin
517    }
518    #[must_use]
519    pub fn parent(&self) -> &str {
520        &self.parent
521    }
522    #[must_use]
523    pub fn child(&self) -> &str {
524        &self.child
525    }
526    #[must_use]
527    pub const fn axis(&self) -> [f64; 3] {
528        self.axis
529    }
530
531    #[must_use]
532    pub const fn limit(&self) -> JointLimit {
533        self.limit
534    }
535
536    #[must_use]
537    pub const fn calibration(&self) -> Option<Calibration> {
538        self.calibration
539    }
540
541    #[must_use]
542    pub const fn dynamics(&self) -> Option<Dynamics> {
543        self.dynamics
544    }
545
546    #[must_use]
547    pub fn mimic(&self) -> Option<&Mimic> {
548        self.mimic.as_ref()
549    }
550
551    #[must_use]
552    pub const fn safety(&self) -> Option<Safety> {
553        self.safety
554    }
555}
556
557impl Pose {
558    #[must_use]
559    pub const fn xyz(self) -> [f64; 3] {
560        self.xyz
561    }
562    #[must_use]
563    pub const fn rpy(self) -> [f64; 3] {
564        self.rpy
565    }
566}
567
568impl Inertial {
569    #[must_use]
570    pub const fn origin(self) -> Pose {
571        self.origin
572    }
573    #[must_use]
574    pub const fn mass_kg(self) -> f64 {
575        self.mass_kg
576    }
577    #[must_use]
578    pub const fn inertia(self) -> Inertia {
579        self.inertia
580    }
581}
582
583impl Inertia {
584    #[must_use]
585    pub const fn values(self) -> [f64; 6] {
586        [self.ixx, self.ixy, self.ixz, self.iyy, self.iyz, self.izz]
587    }
588}
589
590impl Visual {
591    #[must_use]
592    pub fn name(&self) -> Option<&str> {
593        self.name.as_deref()
594    }
595    #[must_use]
596    pub const fn origin(&self) -> Pose {
597        self.origin
598    }
599    #[must_use]
600    pub fn geometry(&self) -> &Geometry {
601        &self.geometry
602    }
603    #[must_use]
604    pub fn material(&self) -> Option<&Material> {
605        self.material.as_ref()
606    }
607}
608
609impl Collision {
610    #[must_use]
611    pub fn name(&self) -> Option<&str> {
612        self.name.as_deref()
613    }
614    #[must_use]
615    pub const fn origin(&self) -> Pose {
616        self.origin
617    }
618    #[must_use]
619    pub fn geometry(&self) -> &Geometry {
620        &self.geometry
621    }
622}
623
624impl Material {
625    #[must_use]
626    pub fn name(&self) -> &str {
627        &self.name
628    }
629    #[must_use]
630    pub const fn color(&self) -> Option<[f64; 4]> {
631        self.color
632    }
633    #[must_use]
634    pub fn texture(&self) -> Option<&AssetId> {
635        self.texture.as_ref()
636    }
637}
638
639impl Geometry {
640    #[must_use]
641    pub fn asset_id(&self) -> Option<&AssetId> {
642        match self {
643            Self::Mesh { asset, .. } => Some(asset),
644            _ => None,
645        }
646    }
647}
648
649impl JointLimit {
650    #[must_use]
651    pub const fn lower(self) -> f64 {
652        self.lower
653    }
654    #[must_use]
655    pub const fn upper(self) -> f64 {
656        self.upper
657    }
658    #[must_use]
659    pub const fn effort(self) -> f64 {
660        self.effort
661    }
662    #[must_use]
663    pub const fn velocity(self) -> f64 {
664        self.velocity
665    }
666}
667
668impl Calibration {
669    #[must_use]
670    pub const fn rising(self) -> Option<f64> {
671        self.rising
672    }
673    #[must_use]
674    pub const fn falling(self) -> Option<f64> {
675        self.falling
676    }
677}
678
679impl Dynamics {
680    #[must_use]
681    pub const fn damping(self) -> f64 {
682        self.damping
683    }
684    #[must_use]
685    pub const fn friction(self) -> f64 {
686        self.friction
687    }
688}
689
690impl Mimic {
691    #[must_use]
692    pub fn joint(&self) -> &str {
693        &self.joint
694    }
695    #[must_use]
696    pub const fn multiplier(&self) -> Option<f64> {
697        self.multiplier
698    }
699    #[must_use]
700    pub const fn offset(&self) -> Option<f64> {
701        self.offset
702    }
703}
704
705impl Safety {
706    #[must_use]
707    pub const fn soft_lower_limit(self) -> f64 {
708        self.soft_lower_limit
709    }
710    #[must_use]
711    pub const fn soft_upper_limit(self) -> f64 {
712        self.soft_upper_limit
713    }
714    #[must_use]
715    pub const fn k_position(self) -> f64 {
716        self.k_position
717    }
718    #[must_use]
719    pub const fn k_velocity(self) -> f64 {
720        self.k_velocity
721    }
722}
723
724impl Serialize for Structure {
725    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
726        self.document.serialize(serializer)
727    }
728}
729
730impl<'de> Deserialize<'de> for Structure {
731    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
732        let summary = Summary::deserialize(deserializer)?;
733        Self::from_summary(summary).map_err(serde::de::Error::custom)
734    }
735}
736
737#[derive(Deserialize, Serialize)]
738#[serde(deny_unknown_fields)]
739struct Summary {
740    #[serde(rename = "name")]
741    _name: String,
742    links: Vec<LinkSummary>,
743    joints: Vec<JointSummary>,
744    materials: Vec<Material>,
745}
746
747#[derive(Deserialize, Serialize)]
748#[serde(deny_unknown_fields)]
749struct LinkSummary {
750    name: String,
751    inertial: Inertial,
752    visuals: Vec<Visual>,
753    collisions: Vec<Collision>,
754}
755
756#[derive(Deserialize, Serialize)]
757#[serde(deny_unknown_fields)]
758struct JointSummary {
759    name: String,
760    kind: JointKind,
761    origin: PoseSummary,
762    parent: String,
763    child: String,
764    axis: [f64; 3],
765    #[serde(rename = "limit")]
766    limit: JointLimit,
767    calibration: Option<Calibration>,
768    dynamics: Option<Dynamics>,
769    mimic: Option<Mimic>,
770    safety: Option<Safety>,
771}
772
773#[derive(Deserialize, Serialize)]
774#[serde(deny_unknown_fields)]
775struct PoseSummary {
776    xyz: [f64; 3],
777    rpy: [f64; 3],
778}
779
780fn validate_pose_values(pose: Pose, label: &str) -> Result<(), ModelError> {
781    if pose.xyz().into_iter().chain(pose.rpy()).all(f64::is_finite) {
782        Ok(())
783    } else {
784        Err(ModelError::Invalid(format!("{label} pose must be finite")))
785    }
786}
787
788fn validate_nonnegative(value: f64, label: &str) -> Result<(), ModelError> {
789    if value.is_finite() && value >= 0.0 {
790        Ok(())
791    } else {
792        Err(ModelError::Invalid(format!(
793            "{label} must be finite and non-negative"
794        )))
795    }
796}
797
798fn validate_inertia(inertia: Inertia, link: &str) -> Result<(), ModelError> {
799    let [ixx, ixy, ixz, iyy, iyz, izz] = inertia.values();
800    let finite = [ixx, ixy, ixz, iyy, iyz, izz]
801        .into_iter()
802        .all(f64::is_finite);
803    let scale = [ixx, ixy, ixz, iyy, iyz, izz]
804        .into_iter()
805        .map(f64::abs)
806        .fold(0.0, f64::max)
807        .max(f64::MIN_POSITIVE);
808    let diagonal_tolerance = PSD_RELATIVE_TOLERANCE * scale;
809    let minor_tolerance = PSD_RELATIVE_TOLERANCE * scale.powi(2);
810    let determinant_tolerance = PSD_RELATIVE_TOLERANCE * scale.powi(3);
811    let principal_xy = ixx * iyy - ixy * ixy;
812    let principal_xz = ixx * izz - ixz * ixz;
813    let principal_yz = iyy * izz - iyz * iyz;
814    let determinant = ixx * (iyy * izz - iyz * iyz) - ixy * (ixy * izz - iyz * ixz)
815        + ixz * (ixy * iyz - iyy * ixz);
816    if finite
817        && ixx >= -diagonal_tolerance
818        && iyy >= -diagonal_tolerance
819        && izz >= -diagonal_tolerance
820        && principal_xy >= -minor_tolerance
821        && principal_xz >= -minor_tolerance
822        && principal_yz >= -minor_tolerance
823        && determinant >= -determinant_tolerance
824    {
825        Ok(())
826    } else {
827        Err(ModelError::Invalid(format!(
828            "link '{link}' inertia must be finite and positive semidefinite"
829        )))
830    }
831}
832
833fn validate_geometry_values(geometry: &Geometry, link: &str) -> Result<(), ModelError> {
834    let values = match geometry {
835        Geometry::Box { size } => size.to_vec(),
836        Geometry::Cylinder { radius, length } | Geometry::Capsule { radius, length } => {
837            vec![*radius, *length]
838        }
839        Geometry::Sphere { radius } => vec![*radius],
840        Geometry::Mesh { scale, .. } => scale.map_or_else(Vec::new, |values| values.to_vec()),
841    };
842    if values.iter().all(|value| value.is_finite() && *value > 0.0) {
843        Ok(())
844    } else {
845        Err(ModelError::Invalid(format!(
846            "link '{link}' geometry dimensions must be finite and positive"
847        )))
848    }
849}
850
851fn validate_unique<'a>(
852    names: impl Iterator<Item = &'a str>,
853    label: &str,
854) -> Result<(), ModelError> {
855    let mut seen = HashSet::new();
856    for name in names {
857        if !seen.insert(name) {
858            return Err(ModelError::Invalid(format!(
859                "duplicate {label} identity '{name}'"
860            )));
861        }
862    }
863    Ok(())
864}