Skip to main content

phoxal_model/structure/
mod.rs

1//! Canonical normalized structure facts.
2
3use serde::{Deserialize, Serialize};
4use serde_json::{Map, Value};
5use std::collections::{HashMap, HashSet};
6
7use crate::ModelError;
8
9/// A normalized robot structure without authored URDF or filesystem state.
10#[derive(Clone, Debug)]
11pub struct Structure {
12    document: Value,
13    links: Vec<Link>,
14    joints: Vec<Joint>,
15}
16
17/// A canonical structural link.
18#[derive(Clone, Debug)]
19pub struct Link {
20    name: String,
21}
22
23/// A canonical structural joint.
24#[derive(Clone, Debug)]
25pub struct Joint {
26    name: String,
27    kind: JointKind,
28    origin: Pose,
29    parent: String,
30    child: String,
31    axis: [f64; 3],
32}
33
34/// A normalized rigid transform.
35#[derive(Clone, Copy, Debug)]
36pub struct Pose {
37    xyz: [f64; 3],
38    rpy: [f64; 3],
39}
40
41/// Supported structural joint kinds.
42#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
43#[serde(rename_all = "snake_case")]
44pub enum JointKind {
45    Revolute,
46    Continuous,
47    Prismatic,
48    Fixed,
49    Floating,
50    Planar,
51    Spherical,
52}
53
54impl Structure {
55    /// Canonical links in deterministic document order.
56    pub fn links(&self) -> impl ExactSizeIterator<Item = &Link> {
57        self.links.iter()
58    }
59
60    /// Canonical joints in deterministic document order.
61    pub fn joints(&self) -> impl ExactSizeIterator<Item = &Joint> {
62        self.joints.iter()
63    }
64
65    /// Find a canonical link by identity.
66    #[must_use]
67    pub fn link(&self, id: &str) -> Option<&Link> {
68        self.links.iter().find(|link| link.name == id)
69    }
70
71    /// Find a canonical joint by identity.
72    #[must_use]
73    pub fn joint(&self, id: &str) -> Option<&Joint> {
74        self.joints.iter().find(|joint| joint.name == id)
75    }
76
77    pub(crate) fn validate(&self) -> Result<(), ModelError> {
78        validate_unique(self.links.iter().map(Link::name), "link")?;
79        validate_unique(self.joints.iter().map(Joint::name), "joint")?;
80        let link_names = self.links.iter().map(Link::name).collect::<HashSet<_>>();
81        let mut children = HashSet::new();
82        let mut parent_by_child = HashMap::new();
83        for joint in &self.joints {
84            if !link_names.contains(joint.parent()) {
85                return Err(ModelError::Invalid(format!(
86                    "joint '{}' references unknown parent link '{}'",
87                    joint.name(),
88                    joint.parent()
89                )));
90            }
91            if !link_names.contains(joint.child()) {
92                return Err(ModelError::Invalid(format!(
93                    "joint '{}' references unknown child link '{}'",
94                    joint.name(),
95                    joint.child()
96                )));
97            }
98            if !children.insert(joint.child()) {
99                return Err(ModelError::Invalid(format!(
100                    "link '{}' is the child of multiple joints",
101                    joint.child()
102                )));
103            }
104            if joint.parent() == joint.child() {
105                return Err(ModelError::Invalid(format!(
106                    "joint '{}' cannot use '{}' as both parent and child",
107                    joint.name(),
108                    joint.parent()
109                )));
110            }
111            parent_by_child.insert(joint.child(), joint.parent());
112        }
113        let roots = self
114            .links
115            .iter()
116            .map(Link::name)
117            .filter(|link| !children.contains(link))
118            .collect::<Vec<_>>();
119        if roots.len() != 1 {
120            return Err(ModelError::Invalid(format!(
121                "structure must have exactly one root link, found {}",
122                roots.len()
123            )));
124        }
125        for link in &self.links {
126            let mut seen = HashSet::new();
127            let mut current = Some(link.name());
128            while let Some(link_id) = current {
129                if !seen.insert(link_id) {
130                    return Err(ModelError::Invalid(format!(
131                        "structure contains a joint cycle involving '{link_id}'"
132                    )));
133                }
134                current = parent_by_child.get(link_id).copied();
135            }
136        }
137        if roots[0] != "base_footprint" {
138            return Err(ModelError::Invalid(format!(
139                "structure root link must be 'base_footprint', found '{}'",
140                roots[0]
141            )));
142        }
143        let base_joint = self
144            .joints
145            .iter()
146            .find(|joint| joint.child() == "base_link")
147            .ok_or_else(|| {
148                ModelError::Invalid(
149                    "structure must attach 'base_link' under 'base_footprint' with a fixed joint"
150                        .to_string(),
151                )
152            })?;
153        if base_joint.parent() != "base_footprint" || base_joint.kind() != JointKind::Fixed {
154            return Err(ModelError::Invalid(
155                "structure must attach 'base_link' directly under 'base_footprint' with a fixed joint"
156                    .to_string(),
157            ));
158        }
159        Ok(())
160    }
161
162    pub(crate) fn from_compiler_value(document: Value) -> Result<Self, ModelError> {
163        validate_document(&document)?;
164        let summary: Summary = serde_json::from_value(document.clone())
165            .map_err(|error| ModelError::Invalid(error.to_string()))?;
166        let structure = Self {
167            document,
168            links: summary
169                .links
170                .into_iter()
171                .map(|link| Link { name: link.name })
172                .collect(),
173            joints: summary
174                .joints
175                .into_iter()
176                .map(|joint| Joint {
177                    name: joint.name,
178                    kind: joint.kind,
179                    origin: Pose {
180                        xyz: joint.origin.xyz,
181                        rpy: joint.origin.rpy,
182                    },
183                    parent: joint.parent,
184                    child: joint.child,
185                    axis: joint.axis,
186                })
187                .collect(),
188        };
189        structure.validate()?;
190        Ok(structure)
191    }
192}
193
194impl Link {
195    #[must_use]
196    pub fn name(&self) -> &str {
197        &self.name
198    }
199}
200
201impl Joint {
202    #[must_use]
203    pub fn name(&self) -> &str {
204        &self.name
205    }
206    #[must_use]
207    pub const fn kind(&self) -> JointKind {
208        self.kind
209    }
210    #[must_use]
211    pub const fn origin(&self) -> Pose {
212        self.origin
213    }
214    #[must_use]
215    pub fn parent(&self) -> &str {
216        &self.parent
217    }
218    #[must_use]
219    pub fn child(&self) -> &str {
220        &self.child
221    }
222    #[must_use]
223    pub const fn axis(&self) -> [f64; 3] {
224        self.axis
225    }
226}
227
228impl Pose {
229    #[must_use]
230    pub const fn xyz(self) -> [f64; 3] {
231        self.xyz
232    }
233    #[must_use]
234    pub const fn rpy(self) -> [f64; 3] {
235        self.rpy
236    }
237}
238
239impl Serialize for Structure {
240    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
241        self.document.serialize(serializer)
242    }
243}
244
245impl<'de> Deserialize<'de> for Structure {
246    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
247        let document = Value::deserialize(deserializer)?;
248        Self::from_compiler_value(document).map_err(serde::de::Error::custom)
249    }
250}
251
252#[derive(Deserialize)]
253#[serde(deny_unknown_fields)]
254struct Summary {
255    #[serde(rename = "name")]
256    _name: String,
257    links: Vec<LinkSummary>,
258    joints: Vec<JointSummary>,
259    #[serde(rename = "materials")]
260    _materials: Vec<Value>,
261}
262
263#[derive(Deserialize)]
264#[serde(deny_unknown_fields)]
265struct LinkSummary {
266    name: String,
267    #[serde(rename = "inertial")]
268    _inertial: Value,
269    #[serde(rename = "visuals")]
270    _visuals: Vec<Value>,
271    #[serde(rename = "collisions")]
272    _collisions: Vec<Value>,
273}
274
275#[derive(Deserialize)]
276#[serde(deny_unknown_fields)]
277struct JointSummary {
278    name: String,
279    kind: JointKind,
280    origin: PoseSummary,
281    parent: String,
282    child: String,
283    axis: [f64; 3],
284    #[serde(rename = "limit")]
285    _limit: Value,
286    #[serde(rename = "calibration")]
287    _calibration: Option<Value>,
288    #[serde(rename = "dynamics")]
289    _dynamics: Option<Value>,
290    #[serde(rename = "mimic")]
291    _mimic: Option<Value>,
292    #[serde(rename = "safety")]
293    _safety: Option<Value>,
294}
295
296#[derive(Deserialize)]
297#[serde(deny_unknown_fields)]
298struct PoseSummary {
299    xyz: [f64; 3],
300    rpy: [f64; 3],
301}
302
303fn validate_document(value: &Value) -> Result<(), ModelError> {
304    let root = object(value, "structure")?;
305    exact(root, &["name", "links", "joints", "materials"], "structure")?;
306    for link in array(root, "links")? {
307        validate_link(link)?;
308    }
309    for joint in array(root, "joints")? {
310        validate_joint(joint)?;
311    }
312    for material in array(root, "materials")? {
313        validate_material(material)?;
314    }
315    Ok(())
316}
317
318fn validate_link(value: &Value) -> Result<(), ModelError> {
319    let map = object(value, "link")?;
320    exact(map, &["name", "inertial", "visuals", "collisions"], "link")?;
321    validate_inertial(required(map, "inertial")?)?;
322    for visual in array(map, "visuals")? {
323        let visual = object(visual, "visual")?;
324        exact(
325            visual,
326            &["name", "origin", "geometry", "material"],
327            "visual",
328        )?;
329        validate_pose(required(visual, "origin")?)?;
330        validate_geometry(required(visual, "geometry")?)?;
331        if let Some(material) = visual.get("material").filter(|value| !value.is_null()) {
332            validate_material(material)?;
333        }
334    }
335    for collision in array(map, "collisions")? {
336        let collision = object(collision, "collision")?;
337        exact(collision, &["name", "origin", "geometry"], "collision")?;
338        validate_pose(required(collision, "origin")?)?;
339        validate_geometry(required(collision, "geometry")?)?;
340    }
341    Ok(())
342}
343
344fn validate_inertial(value: &Value) -> Result<(), ModelError> {
345    let map = object(value, "inertial")?;
346    exact(map, &["origin", "mass_kg", "inertia"], "inertial")?;
347    validate_pose(required(map, "origin")?)?;
348    exact(
349        object(required(map, "inertia")?, "inertia")?,
350        &["ixx", "ixy", "ixz", "iyy", "iyz", "izz"],
351        "inertia",
352    )
353}
354
355fn validate_joint(value: &Value) -> Result<(), ModelError> {
356    let map = object(value, "joint")?;
357    exact(
358        map,
359        &[
360            "name",
361            "kind",
362            "origin",
363            "parent",
364            "child",
365            "axis",
366            "limit",
367            "calibration",
368            "dynamics",
369            "mimic",
370            "safety",
371        ],
372        "joint",
373    )?;
374    validate_pose(required(map, "origin")?)?;
375    exact(
376        object(required(map, "limit")?, "joint limit")?,
377        &["lower", "upper", "effort", "velocity"],
378        "joint limit",
379    )?;
380    optional_exact(map, "calibration", &["rising", "falling"])?;
381    optional_exact(map, "dynamics", &["damping", "friction"])?;
382    optional_exact(map, "mimic", &["joint", "multiplier", "offset"])?;
383    optional_exact(
384        map,
385        "safety",
386        &[
387            "soft_lower_limit",
388            "soft_upper_limit",
389            "k_position",
390            "k_velocity",
391        ],
392    )
393}
394
395fn validate_material(value: &Value) -> Result<(), ModelError> {
396    exact(
397        object(value, "material")?,
398        &["name", "color", "texture"],
399        "material",
400    )
401}
402
403fn validate_pose(value: &Value) -> Result<(), ModelError> {
404    exact(object(value, "pose")?, &["xyz", "rpy"], "pose")
405}
406
407fn validate_geometry(value: &Value) -> Result<(), ModelError> {
408    let map = object(value, "geometry")?;
409    let kind = map
410        .get("kind")
411        .and_then(Value::as_str)
412        .ok_or_else(|| ModelError::Invalid("geometry.kind must be a string".into()))?;
413    let fields: &[&str] = match kind {
414        "box" => &["kind", "size"],
415        "cylinder" | "capsule" => &["kind", "radius", "length"],
416        "sphere" => &["kind", "radius"],
417        "mesh" => &["kind", "filename", "scale"],
418        _ => {
419            return Err(ModelError::Invalid(format!(
420                "unsupported geometry kind '{kind}'"
421            )));
422        }
423    };
424    exact(map, fields, "geometry")
425}
426
427fn optional_exact(map: &Map<String, Value>, key: &str, fields: &[&str]) -> Result<(), ModelError> {
428    if let Some(value) = map.get(key).filter(|value| !value.is_null()) {
429        exact(object(value, key)?, fields, key)?;
430    }
431    Ok(())
432}
433
434fn object<'a>(value: &'a Value, label: &str) -> Result<&'a Map<String, Value>, ModelError> {
435    value
436        .as_object()
437        .ok_or_else(|| ModelError::Invalid(format!("{label} must be an object")))
438}
439
440fn required<'a>(map: &'a Map<String, Value>, key: &str) -> Result<&'a Value, ModelError> {
441    map.get(key)
442        .ok_or_else(|| ModelError::Invalid(format!("missing required field '{key}'")))
443}
444
445fn array<'a>(map: &'a Map<String, Value>, key: &str) -> Result<&'a [Value], ModelError> {
446    required(map, key)?
447        .as_array()
448        .map(Vec::as_slice)
449        .ok_or_else(|| ModelError::Invalid(format!("'{key}' must be an array")))
450}
451
452fn exact(map: &Map<String, Value>, allowed: &[&str], label: &str) -> Result<(), ModelError> {
453    if let Some(key) = map.keys().find(|key| !allowed.contains(&key.as_str())) {
454        return Err(ModelError::Invalid(format!(
455            "{label} contains unknown field '{key}'"
456        )));
457    }
458    Ok(())
459}
460
461fn validate_unique<'a>(
462    names: impl Iterator<Item = &'a str>,
463    label: &str,
464) -> Result<(), ModelError> {
465    let mut seen = HashSet::new();
466    for name in names {
467        if !seen.insert(name) {
468            return Err(ModelError::Invalid(format!(
469                "duplicate {label} identity '{name}'"
470            )));
471        }
472    }
473    Ok(())
474}