Skip to main content

phoxal_model/
structure.rs

1//! Canonical normalized structure facts.
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use std::collections::{HashMap, HashSet};
6
7use crate::asset::AssetId;
8use crate::component::capability::StructuralKind;
9use crate::error::{LinkRole, PoseOwner, StructureError};
10use crate::identity::{JointId, LinkId};
11
12const MIN_AXIS_NORM_SQUARED: f64 = 1.0e-16;
13const PSD_RELATIVE_TOLERANCE: f64 = 1.0e-12;
14
15/// The ground-projection frame every robot structure is rooted at.
16pub(crate) const BASE_FOOTPRINT_LINK: &str = "base_footprint";
17/// The body frame every robot structure attaches rigidly beneath the root.
18pub(crate) const BASE_LINK: &str = "base_link";
19
20/// A normalized robot structure without authored URDF or filesystem state.
21#[derive(Clone, Debug)]
22pub struct Structure {
23    document: Value,
24    links: Vec<Link>,
25    joints: Vec<Joint>,
26    materials: Vec<Material>,
27    /// Resolved once, when the link graph is validated to be a single tree.
28    /// Storing it here is what makes "exactly one root" an invariant of the
29    /// type rather than something every later caller has to re-derive and then
30    /// assert about.
31    root: LinkId,
32}
33
34/// A canonical structural link.
35#[derive(Clone, Debug)]
36pub struct Link {
37    name: LinkId,
38    inertial: Inertial,
39    visuals: Vec<Visual>,
40    collisions: Vec<Collision>,
41}
42
43/// A canonical structural joint.
44#[derive(Clone, Debug)]
45pub struct Joint {
46    name: JointId,
47    kind: JointKind,
48    origin: Pose,
49    parent: LinkId,
50    child: LinkId,
51    axis: [f64; 3],
52    limit: JointLimit,
53    calibration: Option<Calibration>,
54    dynamics: Option<Dynamics>,
55    mimic: Option<Mimic>,
56    safety: Option<Safety>,
57}
58
59/// A normalized rigid transform.
60#[derive(phoxal_macros::DescribeWire, Clone, Copy, Debug, Deserialize, Serialize)]
61#[serde(deny_unknown_fields)]
62pub struct Pose {
63    xyz: [f64; 3],
64    rpy: [f64; 3],
65}
66
67/// Canonical mass properties for one link.
68#[derive(phoxal_macros::DescribeWire, Clone, Copy, Debug, Deserialize, Serialize)]
69#[serde(deny_unknown_fields)]
70pub struct Inertial {
71    origin: Pose,
72    mass_kg: f64,
73    inertia: Inertia,
74}
75
76/// Canonical symmetric inertia tensor.
77#[derive(phoxal_macros::DescribeWire, Clone, Copy, Debug, Deserialize, Serialize)]
78#[serde(deny_unknown_fields)]
79pub struct Inertia {
80    ixx: f64,
81    ixy: f64,
82    ixz: f64,
83    iyy: f64,
84    iyz: f64,
85    izz: f64,
86}
87
88/// One canonical visual shape.
89#[derive(phoxal_macros::DescribeWire, Clone, Debug, Deserialize, Serialize)]
90#[serde(deny_unknown_fields)]
91pub struct Visual {
92    name: Option<String>,
93    origin: Pose,
94    geometry: Geometry,
95    material: Option<Material>,
96}
97
98/// One canonical collision shape.
99#[derive(phoxal_macros::DescribeWire, Clone, Debug, Deserialize, Serialize)]
100#[serde(deny_unknown_fields)]
101pub struct Collision {
102    name: Option<String>,
103    origin: Pose,
104    geometry: Geometry,
105}
106
107/// Canonical render material.
108#[derive(phoxal_macros::DescribeWire, Clone, Debug, Deserialize, Serialize)]
109#[serde(deny_unknown_fields)]
110pub struct Material {
111    name: String,
112    color: Option<[f64; 4]>,
113    texture: Option<AssetId>,
114}
115
116/// Complete canonical geometry vocabulary.
117#[derive(phoxal_macros::DescribeWire, Clone, Debug, Deserialize, Serialize)]
118#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
119pub enum Geometry {
120    Box {
121        size: [f64; 3],
122    },
123    Cylinder {
124        radius: f64,
125        length: f64,
126    },
127    Capsule {
128        radius: f64,
129        length: f64,
130    },
131    Sphere {
132        radius: f64,
133    },
134    Mesh {
135        #[serde(rename = "filename")]
136        asset: AssetId,
137        scale: Option<[f64; 3]>,
138    },
139}
140
141/// Canonical joint limits.
142#[derive(phoxal_macros::DescribeWire, Clone, Copy, Debug, Deserialize, Serialize)]
143#[serde(deny_unknown_fields)]
144pub struct JointLimit {
145    lower: f64,
146    upper: f64,
147    effort: f64,
148    velocity: f64,
149}
150
151/// Canonical joint calibration.
152#[derive(phoxal_macros::DescribeWire, Clone, Copy, Debug, Deserialize, Serialize)]
153#[serde(deny_unknown_fields)]
154pub struct Calibration {
155    rising: Option<f64>,
156    falling: Option<f64>,
157}
158
159/// Canonical joint damping and friction.
160#[derive(phoxal_macros::DescribeWire, Clone, Copy, Debug, Deserialize, Serialize)]
161#[serde(deny_unknown_fields)]
162pub struct Dynamics {
163    damping: f64,
164    friction: f64,
165}
166
167/// Canonical mimic relationship.
168#[derive(phoxal_macros::DescribeWire, Clone, Debug, Deserialize, Serialize)]
169#[serde(deny_unknown_fields)]
170pub struct Mimic {
171    joint: JointId,
172    multiplier: Option<f64>,
173    offset: Option<f64>,
174}
175
176/// Canonical software joint safety limits.
177#[derive(phoxal_macros::DescribeWire, Clone, Copy, Debug, Deserialize, Serialize)]
178#[serde(deny_unknown_fields)]
179pub struct Safety {
180    soft_lower_limit: f64,
181    soft_upper_limit: f64,
182    k_position: f64,
183    k_velocity: f64,
184}
185
186/// Supported structural joint kinds.
187#[derive(
188    phoxal_macros::DescribeWire, Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq,
189)]
190#[serde(rename_all = "snake_case")]
191pub enum JointKind {
192    Revolute,
193    Continuous,
194    Prismatic,
195    Fixed,
196    Floating,
197    Planar,
198    Spherical,
199}
200
201impl Structure {
202    /// Canonical links in deterministic document order.
203    pub fn links(&self) -> impl ExactSizeIterator<Item = &Link> {
204        self.links.iter()
205    }
206
207    /// Canonical joints in deterministic document order.
208    pub fn joints(&self) -> impl ExactSizeIterator<Item = &Joint> {
209        self.joints.iter()
210    }
211
212    /// Canonical materials in deterministic document order.
213    pub fn materials(&self) -> impl ExactSizeIterator<Item = &Material> {
214        self.materials.iter()
215    }
216
217    /// Distinct logical asset identities referenced by this structure.
218    pub fn asset_ids(&self) -> impl Iterator<Item = &AssetId> {
219        let mut seen = HashSet::new();
220        self.links
221            .iter()
222            .flat_map(|link| {
223                link.visuals()
224                    .filter_map(|visual| visual.geometry().asset_id())
225                    .chain(
226                        link.collisions()
227                            .filter_map(|collision| collision.geometry().asset_id()),
228                    )
229                    .chain(
230                        link.visuals()
231                            .filter_map(|visual| visual.material()?.texture()),
232                    )
233            })
234            .chain(self.materials.iter().filter_map(Material::texture))
235            .filter(move |id| seen.insert(id.as_str()))
236    }
237
238    /// Find a canonical link by identity.
239    #[must_use]
240    pub fn link(&self, id: &str) -> Option<&Link> {
241        self.links.iter().find(|link| link.name == id)
242    }
243
244    /// Find a canonical joint by identity.
245    #[must_use]
246    pub fn joint(&self, id: &str) -> Option<&Joint> {
247        self.joints.iter().find(|joint| joint.name == id)
248    }
249
250    /// The single root link, resolved when the structure was validated.
251    #[must_use]
252    pub const fn root_link(&self) -> &LinkId {
253        &self.root
254    }
255
256    /// The joint that attaches `link` to its parent, absent only for the root.
257    #[must_use]
258    pub fn parent_joint(&self, link: &str) -> Option<&Joint> {
259        self.joints.iter().find(|joint| joint.child() == link)
260    }
261
262    /// Every joint that attaches a child directly beneath `link`.
263    pub fn child_joints<'a>(&'a self, link: &'a str) -> impl Iterator<Item = &'a Joint> {
264        self.joints
265            .iter()
266            .filter(move |joint| joint.parent() == link)
267    }
268
269    /// Check the link graph is a single tree, and return its root.
270    fn validate(links: &[Link], joints: &[Joint]) -> Result<LinkId, StructureError> {
271        Self::validate_unique(
272            links.iter().map(|link| link.name.as_str()),
273            StructuralKind::Link,
274        )?;
275        Self::validate_unique(
276            joints.iter().map(|joint| joint.name.as_str()),
277            StructuralKind::Joint,
278        )?;
279        for link in links {
280            link.validate()?;
281        }
282        let joint_names = joints
283            .iter()
284            .map(|joint| joint.name.as_str())
285            .collect::<HashSet<_>>();
286        for joint in joints {
287            joint.validate(&joint_names)?;
288        }
289        let link_names = links.iter().map(Link::name).collect::<HashSet<_>>();
290        let mut children = HashSet::new();
291        let mut parent_by_child = HashMap::new();
292        for joint in joints {
293            for (role, link) in [
294                (LinkRole::Parent, joint.parent()),
295                (LinkRole::Child, joint.child()),
296            ] {
297                if !link_names.contains(link) {
298                    return Err(StructureError::UnknownJointLink {
299                        joint: joint.name().clone(),
300                        role,
301                        link: link.clone(),
302                    });
303                }
304            }
305            if !children.insert(joint.child()) {
306                return Err(StructureError::MultipleParentJoints {
307                    link: joint.child().clone(),
308                });
309            }
310            if joint.parent() == joint.child() {
311                return Err(StructureError::SelfReferentialJoint {
312                    joint: joint.name().clone(),
313                    link: joint.parent().clone(),
314                });
315            }
316            parent_by_child.insert(joint.child(), joint.parent());
317        }
318        let roots = links
319            .iter()
320            .map(Link::name)
321            .filter(|link| !children.contains(link))
322            .collect::<Vec<_>>();
323        let [root] = roots.as_slice() else {
324            return Err(StructureError::RootLinkCount { found: roots.len() });
325        };
326        for link in links {
327            let mut seen = HashSet::new();
328            let mut current = Some(link.name());
329            while let Some(link_id) = current {
330                if !seen.insert(link_id) {
331                    return Err(StructureError::JointCycle {
332                        link: link_id.clone(),
333                    });
334                }
335                current = parent_by_child.get(link_id).copied();
336            }
337        }
338        Ok((*root).clone())
339    }
340
341    fn validate_unique<'a>(
342        names: impl Iterator<Item = &'a str>,
343        kind: StructuralKind,
344    ) -> Result<(), StructureError> {
345        let mut seen = HashSet::new();
346        for name in names {
347            if !seen.insert(name) {
348                return Err(StructureError::DuplicateIdentity {
349                    kind,
350                    name: name.to_string(),
351                });
352            }
353        }
354        Ok(())
355    }
356
357    /// Check the conventions every *robot* structure must follow: a
358    /// ground-projection root with the body frame rigidly beneath it.
359    pub(crate) fn validate_robot_frames(&self) -> Result<(), StructureError> {
360        if self.root != BASE_FOOTPRINT_LINK {
361            return Err(StructureError::RootLinkName {
362                expected: LinkId::new(BASE_FOOTPRINT_LINK),
363                found: self.root.clone(),
364            });
365        }
366        let base_joint = self
367            .joints
368            .iter()
369            .find(|joint| joint.child() == BASE_LINK)
370            .ok_or(StructureError::MissingBaseLink)?;
371        if base_joint.parent() != BASE_FOOTPRINT_LINK || base_joint.kind() != JointKind::Fixed {
372            return Err(StructureError::MisattachedBaseLink);
373        }
374        Ok(())
375    }
376
377    pub(crate) fn from_compiler_value(document: Value) -> Result<Self, StructureError> {
378        Self::from_summary(serde_json::from_value(document)?)
379    }
380
381    fn from_summary(summary: Summary) -> Result<Self, StructureError> {
382        let document = serde_json::to_value(&summary)?;
383        let links = summary
384            .links
385            .into_iter()
386            .map(|link| Link {
387                name: link.name,
388                inertial: link.inertial,
389                visuals: link.visuals,
390                collisions: link.collisions,
391            })
392            .collect::<Vec<_>>();
393        let joints = summary
394            .joints
395            .into_iter()
396            .map(|joint| Joint {
397                name: joint.name,
398                kind: joint.kind,
399                origin: Pose {
400                    xyz: joint.origin.xyz,
401                    rpy: joint.origin.rpy,
402                },
403                parent: joint.parent,
404                child: joint.child,
405                axis: joint.axis,
406                limit: joint.limit,
407                calibration: joint.calibration,
408                dynamics: joint.dynamics,
409                mimic: joint.mimic,
410                safety: joint.safety,
411            })
412            .collect::<Vec<_>>();
413        let root = Self::validate(&links, &joints)?;
414        Ok(Self {
415            document,
416            links,
417            joints,
418            materials: summary.materials,
419            root,
420        })
421    }
422}
423
424impl Link {
425    #[must_use]
426    pub const fn name(&self) -> &LinkId {
427        &self.name
428    }
429
430    #[must_use]
431    pub const fn inertial(&self) -> Inertial {
432        self.inertial
433    }
434
435    pub fn visuals(&self) -> impl ExactSizeIterator<Item = &Visual> {
436        self.visuals.iter()
437    }
438
439    pub fn collisions(&self) -> impl ExactSizeIterator<Item = &Collision> {
440        self.collisions.iter()
441    }
442
443    fn validate(&self) -> Result<(), StructureError> {
444        self.inertial.validate(&self.name)?;
445        for visual in &self.visuals {
446            visual
447                .origin
448                .validate(PoseOwner::LinkVisual(self.name.clone()))?;
449            visual.geometry.validate(&self.name)?;
450        }
451        for collision in &self.collisions {
452            collision
453                .origin
454                .validate(PoseOwner::LinkCollision(self.name.clone()))?;
455            collision.geometry.validate(&self.name)?;
456        }
457        Ok(())
458    }
459}
460
461impl Joint {
462    #[must_use]
463    pub const fn name(&self) -> &JointId {
464        &self.name
465    }
466    #[must_use]
467    pub const fn kind(&self) -> JointKind {
468        self.kind
469    }
470    #[must_use]
471    pub const fn origin(&self) -> Pose {
472        self.origin
473    }
474    #[must_use]
475    pub const fn parent(&self) -> &LinkId {
476        &self.parent
477    }
478    #[must_use]
479    pub const fn child(&self) -> &LinkId {
480        &self.child
481    }
482    #[must_use]
483    pub const fn axis(&self) -> [f64; 3] {
484        self.axis
485    }
486
487    #[must_use]
488    pub const fn limit(&self) -> JointLimit {
489        self.limit
490    }
491
492    #[must_use]
493    pub const fn calibration(&self) -> Option<Calibration> {
494        self.calibration
495    }
496
497    #[must_use]
498    pub const fn dynamics(&self) -> Option<Dynamics> {
499        self.dynamics
500    }
501
502    #[must_use]
503    pub fn mimic(&self) -> Option<&Mimic> {
504        self.mimic.as_ref()
505    }
506
507    #[must_use]
508    pub const fn safety(&self) -> Option<Safety> {
509        self.safety
510    }
511
512    fn validate(&self, joint_names: &HashSet<&str>) -> Result<(), StructureError> {
513        self.origin.validate(PoseOwner::Joint(self.name.clone()))?;
514        if !self.axis.iter().all(|value| value.is_finite()) {
515            return Err(StructureError::AxisNotFinite {
516                joint: self.name.clone(),
517            });
518        }
519        // A movable joint with a degenerate axis has no defined direction of
520        // motion, so it cannot be commanded or measured.
521        if self.kind != JointKind::Fixed
522            && self.axis.iter().map(|value| value * value).sum::<f64>() <= MIN_AXIS_NORM_SQUARED
523        {
524            return Err(StructureError::AxisNotOriented {
525                joint: self.name.clone(),
526            });
527        }
528        self.limit.validate(&self.name)?;
529        if let Some(dynamics) = self.dynamics {
530            dynamics.validate(&self.name)?;
531        }
532        if let Some(mimic) = &self.mimic
533            && !joint_names.contains(mimic.joint().as_str())
534        {
535            return Err(StructureError::UnknownMimicJoint {
536                joint: self.name.clone(),
537                mimicked: mimic.joint().clone(),
538            });
539        }
540        if let Some(safety) = self.safety {
541            safety.validate(&self.name)?;
542        }
543        Ok(())
544    }
545}
546
547impl Pose {
548    #[must_use]
549    pub const fn xyz(self) -> [f64; 3] {
550        self.xyz
551    }
552    #[must_use]
553    pub const fn rpy(self) -> [f64; 3] {
554        self.rpy
555    }
556
557    fn validate(self, owner: PoseOwner) -> Result<(), StructureError> {
558        if self.xyz.into_iter().chain(self.rpy).all(f64::is_finite) {
559            Ok(())
560        } else {
561            Err(StructureError::Pose { owner })
562        }
563    }
564}
565
566impl Inertial {
567    #[must_use]
568    pub const fn origin(self) -> Pose {
569        self.origin
570    }
571    #[must_use]
572    pub const fn mass_kg(self) -> f64 {
573        self.mass_kg
574    }
575    #[must_use]
576    pub const fn inertia(self) -> Inertia {
577        self.inertia
578    }
579
580    fn validate(self, link: &LinkId) -> Result<(), StructureError> {
581        self.origin
582            .validate(PoseOwner::LinkInertial(link.clone()))?;
583        if !(self.mass_kg.is_finite() && self.mass_kg >= 0.0) {
584            return Err(StructureError::Mass { link: link.clone() });
585        }
586        self.inertia.validate(link)
587    }
588}
589
590impl Inertia {
591    #[must_use]
592    pub const fn values(self) -> [f64; 6] {
593        [self.ixx, self.ixy, self.ixz, self.iyy, self.iyz, self.izz]
594    }
595
596    /// Check the tensor describes a physically realizable body.
597    ///
598    /// Positive semidefiniteness is tested through the leading principal
599    /// minors, with tolerances scaled by the largest entry so that a tensor
600    /// authored in kg*m^2 and the same tensor authored in g*mm^2 are judged
601    /// alike rather than by an absolute epsilon.
602    fn validate(self, link: &LinkId) -> Result<(), StructureError> {
603        let [ixx, ixy, ixz, iyy, iyz, izz] = self.values();
604        let finite = self.values().into_iter().all(f64::is_finite);
605        let scale = self
606            .values()
607            .into_iter()
608            .map(f64::abs)
609            .fold(0.0, f64::max)
610            .max(f64::MIN_POSITIVE);
611        let diagonal_tolerance = PSD_RELATIVE_TOLERANCE * scale;
612        let minor_tolerance = PSD_RELATIVE_TOLERANCE * scale.powi(2);
613        let determinant_tolerance = PSD_RELATIVE_TOLERANCE * scale.powi(3);
614        let principal_xy = ixx * iyy - ixy * ixy;
615        let principal_xz = ixx * izz - ixz * ixz;
616        let principal_yz = iyy * izz - iyz * iyz;
617        let determinant = ixx * (iyy * izz - iyz * iyz) - ixy * (ixy * izz - iyz * ixz)
618            + ixz * (ixy * iyz - iyy * ixz);
619        if finite
620            && ixx >= -diagonal_tolerance
621            && iyy >= -diagonal_tolerance
622            && izz >= -diagonal_tolerance
623            && principal_xy >= -minor_tolerance
624            && principal_xz >= -minor_tolerance
625            && principal_yz >= -minor_tolerance
626            && determinant >= -determinant_tolerance
627        {
628            Ok(())
629        } else {
630            Err(StructureError::Inertia { link: link.clone() })
631        }
632    }
633}
634
635impl Visual {
636    #[must_use]
637    pub fn name(&self) -> Option<&str> {
638        self.name.as_deref()
639    }
640    #[must_use]
641    pub const fn origin(&self) -> Pose {
642        self.origin
643    }
644    #[must_use]
645    pub fn geometry(&self) -> &Geometry {
646        &self.geometry
647    }
648    #[must_use]
649    pub fn material(&self) -> Option<&Material> {
650        self.material.as_ref()
651    }
652}
653
654impl Collision {
655    #[must_use]
656    pub fn name(&self) -> Option<&str> {
657        self.name.as_deref()
658    }
659    #[must_use]
660    pub const fn origin(&self) -> Pose {
661        self.origin
662    }
663    #[must_use]
664    pub fn geometry(&self) -> &Geometry {
665        &self.geometry
666    }
667}
668
669impl Material {
670    #[must_use]
671    pub fn name(&self) -> &str {
672        &self.name
673    }
674    #[must_use]
675    pub const fn color(&self) -> Option<[f64; 4]> {
676        self.color
677    }
678    #[must_use]
679    pub fn texture(&self) -> Option<&AssetId> {
680        self.texture.as_ref()
681    }
682}
683
684impl Geometry {
685    #[must_use]
686    pub fn asset_id(&self) -> Option<&AssetId> {
687        match self {
688            Self::Mesh { asset, .. } => Some(asset),
689            _ => None,
690        }
691    }
692
693    fn validate(&self, link: &LinkId) -> Result<(), StructureError> {
694        let dimensions: &[f64] = match self {
695            Self::Box { size } => size,
696            Self::Cylinder { radius, length } | Self::Capsule { radius, length } => {
697                &[*radius, *length]
698            }
699            Self::Sphere { radius } => &[*radius],
700            Self::Mesh { scale, .. } => scale.as_ref().map_or(&[], |values| values.as_slice()),
701        };
702        if dimensions
703            .iter()
704            .all(|value| value.is_finite() && *value > 0.0)
705        {
706            Ok(())
707        } else {
708            Err(StructureError::Geometry { link: link.clone() })
709        }
710    }
711}
712
713impl JointLimit {
714    #[must_use]
715    pub const fn lower(self) -> f64 {
716        self.lower
717    }
718    #[must_use]
719    pub const fn upper(self) -> f64 {
720        self.upper
721    }
722    #[must_use]
723    pub const fn effort(self) -> f64 {
724        self.effort
725    }
726    #[must_use]
727    pub const fn velocity(self) -> f64 {
728        self.velocity
729    }
730
731    fn validate(self, joint: &JointId) -> Result<(), StructureError> {
732        if [self.lower, self.upper, self.effort, self.velocity]
733            .iter()
734            .all(|value| value.is_finite())
735            && self.lower <= self.upper
736        {
737            Ok(())
738        } else {
739            Err(StructureError::JointLimits {
740                joint: joint.clone(),
741            })
742        }
743    }
744}
745
746impl Calibration {
747    #[must_use]
748    pub const fn rising(self) -> Option<f64> {
749        self.rising
750    }
751    #[must_use]
752    pub const fn falling(self) -> Option<f64> {
753        self.falling
754    }
755}
756
757impl Dynamics {
758    #[must_use]
759    pub const fn damping(self) -> f64 {
760        self.damping
761    }
762    #[must_use]
763    pub const fn friction(self) -> f64 {
764        self.friction
765    }
766
767    fn validate(self, joint: &JointId) -> Result<(), StructureError> {
768        if [self.damping, self.friction]
769            .iter()
770            .all(|value| value.is_finite() && *value >= 0.0)
771        {
772            Ok(())
773        } else {
774            Err(StructureError::JointDynamics {
775                joint: joint.clone(),
776            })
777        }
778    }
779}
780
781impl Mimic {
782    #[must_use]
783    pub const fn joint(&self) -> &JointId {
784        &self.joint
785    }
786    #[must_use]
787    pub const fn multiplier(&self) -> Option<f64> {
788        self.multiplier
789    }
790    #[must_use]
791    pub const fn offset(&self) -> Option<f64> {
792        self.offset
793    }
794}
795
796impl Safety {
797    #[must_use]
798    pub const fn soft_lower_limit(self) -> f64 {
799        self.soft_lower_limit
800    }
801    #[must_use]
802    pub const fn soft_upper_limit(self) -> f64 {
803        self.soft_upper_limit
804    }
805    #[must_use]
806    pub const fn k_position(self) -> f64 {
807        self.k_position
808    }
809    #[must_use]
810    pub const fn k_velocity(self) -> f64 {
811        self.k_velocity
812    }
813
814    fn validate(self, joint: &JointId) -> Result<(), StructureError> {
815        if [
816            self.soft_lower_limit,
817            self.soft_upper_limit,
818            self.k_position,
819            self.k_velocity,
820        ]
821        .iter()
822        .all(|value| value.is_finite())
823            && self.soft_lower_limit <= self.soft_upper_limit
824        {
825            Ok(())
826        } else {
827            Err(StructureError::JointSafety {
828                joint: joint.clone(),
829            })
830        }
831    }
832}
833
834impl Serialize for Structure {
835    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
836        self.document.serialize(serializer)
837    }
838}
839
840impl<'de> Deserialize<'de> for Structure {
841    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
842        let summary = Summary::deserialize(deserializer)?;
843        Self::from_summary(summary).map_err(serde::de::Error::custom)
844    }
845}
846
847impl phoxal_runtime_contract::wire_schema::DescribeWire for Structure {
848    // Invariant: the `Serialize` above re-emits the canonical document this
849    // type was built from, and that document is `Summary` serialized, so the
850    // summary's shape is exactly what goes on the wire.
851    fn wire_schema() -> phoxal_runtime_contract::wire_schema::WireSchema {
852        phoxal_runtime_contract::wire_schema::WireSchema::opaque(
853            "Structure",
854            <Summary as phoxal_runtime_contract::wire_schema::DescribeWire>::wire_schema(),
855        )
856    }
857}
858
859/// The canonical structure document, exactly as the compiler emits it.
860///
861/// `Structure` re-serializes this document verbatim, so every authored field
862/// has to survive the round trip - including `name`, which the runtime model
863/// itself never consults but must not drop from the document it forwards.
864#[derive(phoxal_macros::DescribeWire, Deserialize, Serialize)]
865#[serde(deny_unknown_fields)]
866struct Summary {
867    name: String,
868    links: Vec<LinkSummary>,
869    joints: Vec<JointSummary>,
870    materials: Vec<Material>,
871}
872
873#[derive(phoxal_macros::DescribeWire, Deserialize, Serialize)]
874#[serde(deny_unknown_fields)]
875struct LinkSummary {
876    name: LinkId,
877    inertial: Inertial,
878    visuals: Vec<Visual>,
879    collisions: Vec<Collision>,
880}
881
882#[derive(phoxal_macros::DescribeWire, Deserialize, Serialize)]
883#[serde(deny_unknown_fields)]
884struct JointSummary {
885    name: JointId,
886    kind: JointKind,
887    origin: PoseSummary,
888    parent: LinkId,
889    child: LinkId,
890    axis: [f64; 3],
891    limit: JointLimit,
892    calibration: Option<Calibration>,
893    dynamics: Option<Dynamics>,
894    mimic: Option<Mimic>,
895    safety: Option<Safety>,
896}
897
898#[derive(phoxal_macros::DescribeWire, Deserialize, Serialize)]
899#[serde(deny_unknown_fields)]
900struct PoseSummary {
901    xyz: [f64; 3],
902    rpy: [f64; 3],
903}
904
905#[cfg(test)]
906mod tests {
907    use super::*;
908    use serde_json::json;
909
910    fn inertial() -> Value {
911        json!({
912            "origin": { "xyz": [0.0, 0.0, 0.0], "rpy": [0.0, 0.0, 0.0] },
913            "mass_kg": 1.0,
914            "inertia": { "ixx": 1.0, "ixy": 0.0, "ixz": 0.0, "iyy": 1.0, "iyz": 0.0, "izz": 1.0 }
915        })
916    }
917
918    fn link(name: &str) -> Value {
919        json!({ "name": name, "inertial": inertial(), "visuals": [], "collisions": [] })
920    }
921
922    fn joint(name: &str, parent: &str, child: &str) -> Value {
923        json!({
924            "name": name,
925            "kind": "fixed",
926            "origin": { "xyz": [0.0, 0.0, 0.0], "rpy": [0.0, 0.0, 0.0] },
927            "parent": parent,
928            "child": child,
929            "axis": [0.0, 0.0, 1.0],
930            "limit": { "lower": 0.0, "upper": 0.0, "effort": 0.0, "velocity": 0.0 },
931            "calibration": null,
932            "dynamics": null,
933            "mimic": null,
934            "safety": null
935        })
936    }
937
938    fn document(links: Vec<Value>, joints: Vec<Value>) -> Value {
939        json!({ "name": "rover", "links": links, "joints": joints, "materials": [] })
940    }
941
942    fn tree() -> Value {
943        document(
944            vec![link("base_footprint"), link("base_link")],
945            vec![joint("base_joint", "base_footprint", "base_link")],
946        )
947    }
948
949    #[test]
950    fn the_document_survives_the_round_trip_unchanged() {
951        // The canonical structure is forwarded verbatim: typing the link and
952        // joint identities must not alter a single field of the wire form,
953        // including `name`, which nothing in the runtime model reads.
954        let source = tree();
955        let structure =
956            Structure::from_compiler_value(source.clone()).expect("a valid structure document");
957        assert_eq!(
958            serde_json::to_value(&structure).expect("the structure re-serializes"),
959            source
960        );
961    }
962
963    #[test]
964    fn the_root_is_resolved_once_and_stored() {
965        let structure = Structure::from_compiler_value(tree()).expect("a valid structure document");
966        assert_eq!(structure.root_link(), &LinkId::new("base_footprint"));
967        structure
968            .validate_robot_frames()
969            .expect("the conventional robot frames");
970    }
971
972    #[test]
973    fn a_structure_without_exactly_one_root_is_refused() {
974        let two_roots = document(vec![link("a"), link("b")], Vec::new());
975        assert!(matches!(
976            Structure::from_compiler_value(two_roots),
977            Err(StructureError::RootLinkCount { found: 2 })
978        ));
979
980        let no_root = document(
981            vec![link("a"), link("b")],
982            vec![joint("ab", "a", "b"), joint("ba", "b", "a")],
983        );
984        // Every link is somebody's child, so the graph is a cycle, not a tree.
985        assert!(matches!(
986            Structure::from_compiler_value(no_root),
987            Err(StructureError::RootLinkCount { found: 0 })
988        ));
989    }
990
991    #[test]
992    fn a_dangling_joint_names_the_end_that_dangles() {
993        let dangling = document(
994            vec![link("base_footprint")],
995            vec![joint("base_joint", "base_footprint", "base_link")],
996        );
997        let error =
998            Structure::from_compiler_value(dangling).expect_err("the child link does not exist");
999        assert!(matches!(
1000            error,
1001            StructureError::UnknownJointLink {
1002                role: LinkRole::Child,
1003                ..
1004            }
1005        ));
1006        assert_eq!(
1007            error.to_string(),
1008            "joint 'base_joint' references unknown child link 'base_link'"
1009        );
1010    }
1011
1012    /// Every structural fact the canonical model carries must be statable
1013    /// through [`crate::builder::RobotBuilder`]. A field the builder cannot
1014    /// state is a robot that can only be described by authored documents, and
1015    /// the builder silently substituting a default for it is worse still.
1016    ///
1017    /// This test lives beside the definitions rather than beside the builder
1018    /// because only this module may destructure [`Link`] and [`Joint`], whose
1019    /// fields are private. That is what makes the guarantee a compile-time one:
1020    /// adding a field to either stops this test compiling until the builder can
1021    /// state it and the assertions below read it back. The [`Geometry`] match
1022    /// is exhaustive for the same reason.
1023    #[test]
1024    fn every_structural_field_is_statable_through_the_builder() {
1025        use crate::asset::AssetId;
1026        use crate::builder;
1027
1028        let mesh = AssetId::new("meshes/mast.stl").expect("a normalized asset id");
1029        let texture = AssetId::new("textures/paint.png").expect("a normalized asset id");
1030        let robot = builder::RobotBuilder::new("rover")
1031            // Spelled field by field rather than with `..Joint::default()`, so
1032            // that a field added to the builder's own `Joint` fails here too.
1033            .joint(builder::Joint {
1034                name: "mast_joint",
1035                kind: JointKind::Revolute,
1036                parent: "base_link",
1037                child: "mast",
1038                xyz: [0.1, 0.2, 0.3],
1039                rpy: [0.4, 0.5, 0.6],
1040                axis: [0.0, 1.0, 0.0],
1041                limit: builder::JointLimit {
1042                    lower: -1.5,
1043                    upper: 1.5,
1044                    effort: 8.0,
1045                    velocity: 2.0,
1046                },
1047                calibration: Some(builder::Calibration {
1048                    rising: Some(0.1),
1049                    falling: Some(-0.1),
1050                }),
1051                dynamics: Some(builder::Dynamics {
1052                    damping: 0.7,
1053                    friction: 0.2,
1054                }),
1055                mimic: Some(builder::Mimic {
1056                    joint: "base_joint",
1057                    multiplier: Some(2.0),
1058                    offset: Some(0.25),
1059                }),
1060                safety: Some(builder::Safety {
1061                    soft_lower_limit: -1.4,
1062                    soft_upper_limit: 1.4,
1063                    k_position: 12.0,
1064                    k_velocity: 3.0,
1065                }),
1066            })
1067            .link(builder::Link {
1068                name: "mast",
1069                inertial: builder::Inertial {
1070                    xyz: [0.01, 0.02, 0.03],
1071                    rpy: [0.04, 0.05, 0.06],
1072                    mass_kg: 2.5,
1073                    inertia: builder::Inertia {
1074                        ixx: 2.0,
1075                        ixy: 0.1,
1076                        ixz: 0.2,
1077                        iyy: 3.0,
1078                        iyz: 0.3,
1079                        izz: 4.0,
1080                    },
1081                },
1082                // One visual per geometry variant, in the order the match
1083                // below expects them back.
1084                visuals: vec![
1085                    builder::Visual {
1086                        name: Some("hull"),
1087                        xyz: [1.0, 2.0, 3.0],
1088                        rpy: [0.7, 0.8, 0.9],
1089                        geometry: Geometry::Box {
1090                            size: [0.4, 0.3, 0.2],
1091                        },
1092                        material: Some(builder::Material {
1093                            name: "grey",
1094                            color: Some([0.5, 0.5, 0.5, 1.0]),
1095                            texture: Some(texture.clone()),
1096                        }),
1097                    },
1098                    builder::Visual::new(Geometry::Cylinder {
1099                        radius: 0.1,
1100                        length: 0.5,
1101                    }),
1102                    builder::Visual::new(Geometry::Capsule {
1103                        radius: 0.2,
1104                        length: 0.6,
1105                    }),
1106                    builder::Visual::new(Geometry::Sphere { radius: 0.3 }),
1107                    builder::Visual::new(Geometry::Mesh {
1108                        asset: mesh.clone(),
1109                        scale: Some([1.0, 2.0, 3.0]),
1110                    }),
1111                ],
1112                // A collision beneath the revolute mast is intentionally not
1113                // buildable: the source compiler must refuse an envelope it
1114                // cannot conservatively derive. The empty collection still
1115                // states the builder field without weakening that invariant.
1116                collisions: Vec::new(),
1117            })
1118            // Keep the full collision wire shape covered on a fixed link: it
1119            // contributes to the compiled envelope without depending on the
1120            // mast's runtime pose.
1121            .link(builder::Link {
1122                name: "base_link",
1123                collisions: vec![builder::Collision {
1124                    name: Some("hull_bounds"),
1125                    xyz: [4.0, 5.0, 6.0],
1126                    rpy: [0.11, 0.12, 0.13],
1127                    geometry: Geometry::Sphere { radius: 0.35 },
1128                }],
1129                ..builder::Link::default()
1130            })
1131            .material(builder::Material {
1132                name: "grey",
1133                color: Some([0.5, 0.5, 0.5, 1.0]),
1134                texture: Some(texture.clone()),
1135            })
1136            .build()
1137            .expect("every structural fact composes a valid robot");
1138
1139        let structure = robot.structure();
1140        let link = structure.link("mast").expect("the stated link");
1141        let Link {
1142            name,
1143            inertial,
1144            visuals,
1145            collisions,
1146        } = link;
1147        assert_eq!(name, &LinkId::new("mast"));
1148        assert_eq!(inertial.origin().xyz(), [0.01, 0.02, 0.03]);
1149        assert_eq!(inertial.origin().rpy(), [0.04, 0.05, 0.06]);
1150        assert_eq!(inertial.mass_kg(), 2.5);
1151        assert_eq!(inertial.inertia().values(), [2.0, 0.1, 0.2, 3.0, 0.3, 4.0]);
1152        assert_eq!(visuals.len(), 5);
1153        assert_eq!(collisions.len(), 0);
1154
1155        let mut shapes = Vec::new();
1156        for visual in link.visuals() {
1157            let Visual {
1158                name,
1159                origin,
1160                geometry,
1161                material,
1162            } = visual;
1163            shapes.push(match geometry {
1164                Geometry::Box { size } => {
1165                    assert_eq!(*size, [0.4, 0.3, 0.2]);
1166                    // Only the first visual states the rest; the others prove
1167                    // the shape alone is enough.
1168                    assert_eq!(name.as_deref(), Some("hull"));
1169                    assert_eq!(origin.xyz(), [1.0, 2.0, 3.0]);
1170                    assert_eq!(origin.rpy(), [0.7, 0.8, 0.9]);
1171                    let Material {
1172                        name,
1173                        color,
1174                        texture: painted,
1175                    } = material.as_ref().expect("the stated material");
1176                    assert_eq!(name, "grey");
1177                    assert_eq!(*color, Some([0.5, 0.5, 0.5, 1.0]));
1178                    assert_eq!(painted.as_ref(), Some(&texture));
1179                    "box"
1180                }
1181                Geometry::Cylinder { radius, length } => {
1182                    assert_eq!((*radius, *length), (0.1, 0.5));
1183                    "cylinder"
1184                }
1185                Geometry::Capsule { radius, length } => {
1186                    assert_eq!((*radius, *length), (0.2, 0.6));
1187                    "capsule"
1188                }
1189                Geometry::Sphere { radius } => {
1190                    assert_eq!(*radius, 0.3);
1191                    "sphere"
1192                }
1193                Geometry::Mesh { asset, scale } => {
1194                    assert_eq!(asset, &mesh);
1195                    assert_eq!(*scale, Some([1.0, 2.0, 3.0]));
1196                    "mesh"
1197                }
1198            });
1199        }
1200        assert_eq!(shapes, ["box", "cylinder", "capsule", "sphere", "mesh"]);
1201
1202        let collision = structure
1203            .link("base_link")
1204            .expect("the base link")
1205            .collisions()
1206            .next()
1207            .expect("the stated collision");
1208        let Collision {
1209            name,
1210            origin,
1211            geometry,
1212        } = collision;
1213        assert_eq!(name.as_deref(), Some("hull_bounds"));
1214        assert_eq!(origin.xyz(), [4.0, 5.0, 6.0]);
1215        assert_eq!(origin.rpy(), [0.11, 0.12, 0.13]);
1216        assert!(matches!(geometry, Geometry::Sphere { radius } if *radius == 0.35));
1217
1218        let joint = structure.joint("mast_joint").expect("the stated joint");
1219        let Joint {
1220            name,
1221            kind,
1222            origin,
1223            parent,
1224            child,
1225            axis,
1226            limit,
1227            calibration,
1228            dynamics,
1229            mimic,
1230            safety,
1231        } = joint;
1232        assert_eq!(name, &JointId::new("mast_joint"));
1233        assert_eq!(*kind, JointKind::Revolute);
1234        assert_eq!(origin.xyz(), [0.1, 0.2, 0.3]);
1235        assert_eq!(origin.rpy(), [0.4, 0.5, 0.6]);
1236        assert_eq!(parent, &LinkId::new("base_link"));
1237        assert_eq!(child, &LinkId::new("mast"));
1238        assert_eq!(*axis, [0.0, 1.0, 0.0]);
1239        assert_eq!(
1240            (
1241                limit.lower(),
1242                limit.upper(),
1243                limit.effort(),
1244                limit.velocity()
1245            ),
1246            (-1.5, 1.5, 8.0, 2.0)
1247        );
1248        let calibration = calibration.expect("the stated calibration");
1249        assert_eq!(
1250            (calibration.rising(), calibration.falling()),
1251            (Some(0.1), Some(-0.1))
1252        );
1253        let dynamics = dynamics.expect("the stated dynamics");
1254        assert_eq!((dynamics.damping(), dynamics.friction()), (0.7, 0.2));
1255        let mimic = mimic.as_ref().expect("the stated mimic");
1256        assert_eq!(mimic.joint(), &JointId::new("base_joint"));
1257        assert_eq!(
1258            (mimic.multiplier(), mimic.offset()),
1259            (Some(2.0), Some(0.25))
1260        );
1261        let safety = safety.expect("the stated safety");
1262        assert_eq!(
1263            (
1264                safety.soft_lower_limit(),
1265                safety.soft_upper_limit(),
1266                safety.k_position(),
1267                safety.k_velocity()
1268            ),
1269            (-1.4, 1.4, 12.0, 3.0)
1270        );
1271
1272        // The structure's own material catalogue, and the assets everything
1273        // above declares, come back too.
1274        let catalogue = structure.materials().collect::<Vec<_>>();
1275        assert_eq!(catalogue.len(), 1);
1276        assert_eq!(catalogue[0].name(), "grey");
1277        assert_eq!(catalogue[0].texture(), Some(&texture));
1278        let mut assets = structure
1279            .asset_ids()
1280            .map(AssetId::as_str)
1281            .collect::<Vec<_>>();
1282        assets.sort_unstable();
1283        assert_eq!(assets, ["meshes/mast.stl", "textures/paint.png"]);
1284    }
1285
1286    #[test]
1287    fn a_robot_structure_must_carry_the_conventional_base_frames() {
1288        let wrong_root = document(vec![link("chassis")], Vec::new());
1289        let structure =
1290            Structure::from_compiler_value(wrong_root).expect("a single-link structure is a tree");
1291        assert!(matches!(
1292            structure.validate_robot_frames(),
1293            Err(StructureError::RootLinkName { .. })
1294        ));
1295
1296        let no_base_link = document(
1297            vec![link("base_footprint"), link("mast")],
1298            vec![joint("mast_joint", "base_footprint", "mast")],
1299        );
1300        let structure =
1301            Structure::from_compiler_value(no_base_link).expect("a valid structure document");
1302        assert!(matches!(
1303            structure.validate_robot_frames(),
1304            Err(StructureError::MissingBaseLink)
1305        ));
1306    }
1307}