Skip to main content

phyz_model/
validate.rs

1//! Model sanity checks.
2//!
3//! These catch the class of authoring mistake that produces a *plausible*
4//! simulation rather than an obviously broken one. The motivating case: a joint
5//! whose `range` excludes zero. `Model::default_state()` starts every joint at
6//! `q = 0`, so such a model begins already violating its own limit, the limit
7//! spring fires at full strength on step one, and the robot catapults. Nothing
8//! about that failure points at the model file.
9
10use crate::{JointType, Model};
11
12/// A problem found in a model.
13#[derive(Debug, Clone, PartialEq)]
14pub struct Issue {
15    /// Which joint or body it concerns.
16    pub subject: String,
17    /// What is wrong.
18    pub message: String,
19    /// Whether the model is unusable (`true`) or merely suspect (`false`).
20    pub fatal: bool,
21}
22
23impl std::fmt::Display for Issue {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        let kind = if self.fatal { "error" } else { "warning" };
26        write!(f, "{kind}: {}: {}", self.subject, self.message)
27    }
28}
29
30impl Model {
31    /// Check the model for authoring mistakes.
32    ///
33    /// Returns every issue found, most serious first. An empty result means the
34    /// model is self-consistent as far as these checks can tell — not that it
35    /// is physically sensible.
36    pub fn validate(&self) -> Vec<Issue> {
37        let mut issues = Vec::new();
38        let rest = self.default_state();
39
40        for (ji, joint) in self.joints.iter().enumerate() {
41            let name = self
42                .bodies
43                .iter()
44                .find(|b| b.joint_idx == ji)
45                .map(|b| b.name.clone())
46                .unwrap_or_else(|| format!("joint_{ji}"));
47
48            if let Some([lo, hi]) = joint.limits {
49                if lo > hi {
50                    issues.push(Issue {
51                        subject: name.clone(),
52                        message: format!("limit range is inverted: [{lo}, {hi}]"),
53                        fatal: true,
54                    });
55                } else if joint.ndof() == 1 {
56                    let q0 = rest.q[self.q_offsets[ji]];
57                    if q0 < lo || q0 > hi {
58                        issues.push(Issue {
59                            subject: name.clone(),
60                            message: format!(
61                                "rest pose q = {q0} is outside the limit range [{lo}, {hi}]; \
62                                 the model starts in violation and will be flung by the limit \
63                                 force on the first step"
64                            ),
65                            fatal: true,
66                        });
67                    }
68                }
69            }
70
71            if joint.damping < 0.0 {
72                issues.push(Issue {
73                    subject: name.clone(),
74                    message: format!("negative damping ({}) injects energy", joint.damping),
75                    fatal: true,
76                });
77            }
78
79            if matches!(
80                joint.joint_type,
81                JointType::Revolute | JointType::Hinge | JointType::Prismatic | JointType::Slide
82            ) {
83                let a = joint.axis;
84                let n = (a.x * a.x + a.y * a.y + a.z * a.z).sqrt();
85                if n < 1e-9 {
86                    issues.push(Issue {
87                        subject: name.clone(),
88                        message: "joint axis is zero-length".to_string(),
89                        fatal: true,
90                    });
91                }
92            }
93        }
94
95        for body in &self.bodies {
96            if body.inertia.mass <= 0.0 {
97                issues.push(Issue {
98                    subject: body.name.clone(),
99                    message: format!("non-positive mass ({})", body.inertia.mass),
100                    fatal: true,
101                });
102            }
103            for k in 0..3 {
104                if body.inertia.inertia[(k, k)] < 0.0 {
105                    issues.push(Issue {
106                        subject: body.name.clone(),
107                        message: "negative inertia on the diagonal".to_string(),
108                        fatal: true,
109                    });
110                    break;
111                }
112            }
113        }
114
115        issues.extend(self.material_issues());
116
117        if self.dt <= 0.0 {
118            issues.push(Issue {
119                subject: "model".to_string(),
120                message: format!("non-positive timestep ({})", self.dt),
121                fatal: true,
122            });
123        }
124
125        issues.sort_by_key(|i| !i.fatal);
126        issues
127    }
128
129    /// Contact-material authoring mistakes.
130    ///
131    /// Split out from [`Model::validate`] (which calls it) so a caller that
132    /// only just attached materials can check them without re-walking the
133    /// whole model.
134    ///
135    /// The physically-impossible values are fatal. The rest are warnings,
136    /// because each has a legitimate use and only the author knows which case
137    /// they are in — a validator that cannot tell a mistake from a choice
138    /// should say so rather than pick.
139    fn material_issues(&self) -> Vec<Issue> {
140        let mut issues = Vec::new();
141
142        // Would-be grippiest body, used for the reach warning below.
143        let max_friction = self
144            .bodies
145            .iter()
146            .filter_map(|b| b.material.as_ref())
147            .map(|m| m.friction)
148            .fold(f64::NEG_INFINITY, f64::max);
149
150        for body in &self.bodies {
151            let Some(m) = &body.material else { continue };
152
153            if m.friction < 0.0 {
154                issues.push(Issue {
155                    subject: body.name.clone(),
156                    message: format!(
157                        "negative friction ({}); the Coulomb cone would invert and \
158                         friction would drive motion instead of resisting it",
159                        m.friction
160                    ),
161                    fatal: true,
162                });
163            }
164            if !(0.0..=1.0).contains(&m.restitution) {
165                issues.push(Issue {
166                    subject: body.name.clone(),
167                    message: format!(
168                        "restitution {} is outside [0, 1]; above 1 an impact returns more \
169                         energy than it received",
170                        m.restitution
171                    ),
172                    fatal: true,
173                });
174            }
175            if m.margin < 0.0 {
176                issues.push(Issue {
177                    subject: body.name.clone(),
178                    message: format!("negative contact margin ({})", m.margin),
179                    fatal: true,
180                });
181            }
182
183            // Dead material: set, but on a body the contact pipeline never
184            // looks at. Silent today — the value is simply never read — and
185            // the usual cause is naming the wrong link of a chain, e.g. an
186            // ankle rather than the foot that carries the collision shape.
187            if body.geometry.is_none() && body.collisions.is_empty() {
188                issues.push(Issue {
189                    subject: body.name.clone(),
190                    message: "has a contact material but no collision geometry, so the \
191                              material is never read; did you mean a child body?"
192                        .to_string(),
193                    fatal: false,
194                });
195            }
196
197            // The `max`-friction reach warning. A body's material applies to
198            // EVERY contact it takes part in, and friction combines by `max`,
199            // so the grippiest body in the model grips whatever it touches —
200            // including the ground. This is the skateboard deck: grip tape put
201            // on the deck instead of the shoes also grabbed the road and
202            // braked the board.
203            //
204            // Only the single grippiest body is flagged, and only when it is
205            // grippy enough to be a deliberate choice rather than a rounding
206            // of the default. Flagging every above-default body would fire on
207            // every correctly-authored robot foot.
208            let broad = body.collisions.len() > 1;
209            if m.friction >= 1.0 && m.friction >= max_friction && broad {
210                issues.push(Issue {
211                    subject: body.name.clone(),
212                    message: format!(
213                        "friction {} is the highest in the model and this body carries {} \
214                         collision shapes; because friction combines by max, all of them \
215                         grip — including against the ground. If only one surface should \
216                         be grippy, put the material on the body that touches it",
217                        m.friction,
218                        body.collisions.len()
219                    ),
220                    fatal: false,
221                });
222            }
223        }
224
225        issues
226    }
227
228    /// Issues that make the model unusable.
229    pub fn fatal_issues(&self) -> Vec<Issue> {
230        self.validate().into_iter().filter(|i| i.fatal).collect()
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use crate::ModelBuilder;
238    use phyz_math::{SpatialInertia, SpatialTransform, Vec3};
239
240    fn one_hinge() -> Model {
241        ModelBuilder::new()
242            .add_revolute_body(
243                "link",
244                -1,
245                SpatialTransform::identity(),
246                SpatialInertia::point_mass(1.0, Vec3::new(0.0, 0.0, -0.5)),
247            )
248            .build()
249    }
250
251    #[test]
252    fn a_clean_model_has_no_issues() {
253        assert!(one_hinge().validate().is_empty());
254    }
255
256    /// The bug this module exists for.
257    #[test]
258    fn rest_pose_outside_the_limit_range_is_fatal() {
259        let mut m = one_hinge();
260        m.joints[0].limits = Some([0.5, 1.0]); // q = 0 is outside
261        let issues = m.fatal_issues();
262        assert_eq!(issues.len(), 1, "{issues:?}");
263        assert!(issues[0].message.contains("outside the limit range"));
264    }
265
266    #[test]
267    fn rest_pose_on_the_boundary_is_accepted() {
268        let mut m = one_hinge();
269        m.joints[0].limits = Some([0.0, 1.0]);
270        assert!(m.validate().is_empty());
271    }
272
273    #[test]
274    fn inverted_range_is_fatal() {
275        let mut m = one_hinge();
276        m.joints[0].limits = Some([1.0, -1.0]);
277        assert!(m.fatal_issues()[0].message.contains("inverted"));
278    }
279
280    #[test]
281    fn zero_mass_is_fatal() {
282        let mut m = one_hinge();
283        m.bodies[0].inertia.mass = 0.0;
284        assert!(m.fatal_issues()[0].message.contains("non-positive mass"));
285    }
286
287    #[test]
288    fn negative_damping_is_fatal() {
289        let mut m = one_hinge();
290        m.joints[0].damping = -1.0;
291        assert!(m.fatal_issues()[0].message.contains("negative damping"));
292    }
293
294    // -----------------------------------------------------------------------
295    // Contact materials
296    // -----------------------------------------------------------------------
297
298    fn with_geometry(mut m: Model) -> Model {
299        for b in &mut m.bodies {
300            b.geometry = Some(crate::Geometry::Sphere { radius: 0.1 });
301            b.collisions.push(crate::GeomInstance::new(
302                crate::Geometry::Sphere { radius: 0.1 },
303                SpatialTransform::identity(),
304            ));
305        }
306        m
307    }
308
309    fn subjects_mentioning(issues: &[Issue], needle: &str) -> Vec<String> {
310        issues
311            .iter()
312            .filter(|i| i.message.contains(needle))
313            .map(|i| i.subject.clone())
314            .collect()
315    }
316
317    #[test]
318    fn a_model_with_no_materials_reports_nothing_new() {
319        let m = with_geometry(one_hinge());
320        assert!(m.bodies.iter().all(|b| b.material.is_none()));
321        assert!(m.material_issues().is_empty());
322    }
323
324    #[test]
325    fn physically_impossible_material_values_are_fatal() {
326        let mut m = with_geometry(one_hinge());
327        m.bodies[0].material = Some(crate::ContactMaterial {
328            friction: -0.5,
329            restitution: 1.4,
330            margin: -1e-3,
331            ..Default::default()
332        });
333        let issues = m.material_issues();
334        assert_eq!(
335            issues.iter().filter(|i| i.fatal).count(),
336            3,
337            "negative friction, restitution > 1 and negative margin are each fatal: {issues:?}"
338        );
339        assert!(!subjects_mentioning(&issues, "negative friction").is_empty());
340        assert!(!subjects_mentioning(&issues, "outside [0, 1]").is_empty());
341    }
342
343    /// The silent one: a material on a body the contact pipeline never looks
344    /// at is simply never read, and the usual cause is naming the wrong link.
345    #[test]
346    fn a_material_on_a_geometryless_body_is_flagged() {
347        let mut m = one_hinge();
348        m.bodies[0].material = Some(crate::ContactMaterial::default());
349        let issues = m.material_issues();
350        let dead = subjects_mentioning(&issues, "never read");
351        assert_eq!(dead.len(), 1, "{issues:?}");
352        assert!(issues.iter().all(|i| !i.fatal), "a warning, not fatal");
353
354        // Give it a shape and the warning goes away.
355        let fixed = with_geometry(m);
356        assert!(subjects_mentioning(&fixed.material_issues(), "never read").is_empty());
357    }
358
359    /// The deck-vs-shoe warning: the grippiest body in the model, when it
360    /// carries several collision shapes, grips through all of them.
361    #[test]
362    fn the_grippiest_multi_shape_body_gets_the_max_combine_warning() {
363        let mut m = with_geometry(one_hinge());
364        // A second shape on body 0 — a deck with a tail as well as a top.
365        m.bodies[0].collisions.push(crate::GeomInstance::new(
366            crate::Geometry::Sphere { radius: 0.1 },
367            SpatialTransform::identity(),
368        ));
369        m.bodies[0].material = Some(crate::ContactMaterial {
370            friction: 1.5,
371            ..Default::default()
372        });
373        let warned = subjects_mentioning(&m.material_issues(), "combines by max");
374        assert_eq!(warned, vec![m.bodies[0].name.clone()], "{warned:?}");
375
376        // The same friction on a body with a single shape is the ordinary,
377        // correct case — one surface, one material — and must stay quiet.
378        let mut single = with_geometry(one_hinge());
379        single.bodies[0].material = Some(crate::ContactMaterial {
380            friction: 1.5,
381            ..Default::default()
382        });
383        assert!(
384            subjects_mentioning(&single.material_issues(), "combines by max").is_empty(),
385            "a single-shape grippy body is the normal case and must not warn"
386        );
387
388        // Nor must a below-1.0 friction, which is not a deliberate grip.
389        let mut mild = with_geometry(one_hinge());
390        mild.bodies[0].collisions.push(crate::GeomInstance::new(
391            crate::Geometry::Sphere { radius: 0.1 },
392            SpatialTransform::identity(),
393        ));
394        mild.bodies[0].material = Some(crate::ContactMaterial {
395            friction: 0.7,
396            ..Default::default()
397        });
398        assert!(subjects_mentioning(&mild.material_issues(), "combines by max").is_empty());
399    }
400}