1use crate::{JointType, Model};
11
12#[derive(Debug, Clone, PartialEq)]
14pub struct Issue {
15 pub subject: String,
17 pub message: String,
19 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 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 fn material_issues(&self) -> Vec<Issue> {
140 let mut issues = Vec::new();
141
142 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 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 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 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 #[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]); 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 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 #[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 let fixed = with_geometry(m);
356 assert!(subjects_mentioning(&fixed.material_issues(), "never read").is_empty());
357 }
358
359 #[test]
362 fn the_grippiest_multi_shape_body_gets_the_max_combine_warning() {
363 let mut m = with_geometry(one_hinge());
364 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 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 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}