1#![deny(
26 clippy::unwrap_used,
27 clippy::expect_used,
28 clippy::indexing_slicing,
29 clippy::panic,
30 clippy::unreachable
31)]
32
33use std::collections::HashMap;
34
35use anyhow::{bail, Result};
36use serde::{Deserialize, Serialize};
37
38use crate::mesh::Mesh;
39
40pub const VERSION: u32 = 1;
43pub const EDGE_CHECK_LIMIT: usize = 2_000_000;
45const WELD: f64 = 1e-6;
48const MAX_COORD: f64 = 1e12;
50const MIN_RELATIVE_VOLUME: f64 = 1e-12;
52
53pub type V3 = [f64; 3];
54pub type Sym = [f64; 6];
56
57#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
59pub struct Geometry {
60 pub version: u32,
61 pub volume: f64,
63 pub centroid: V3,
65 pub inertia: Sym,
67 pub area: f64,
69 pub bounds: [f64; 6],
71 pub triangles: u64,
72 pub closed: bool,
75 pub checked: bool,
77 pub open_edges: u64,
79 pub non_manifold_edges: u64,
81 pub misoriented_edges: u64,
83 pub degenerate_triangles: u64,
85 pub inverted_fixed: bool,
87}
88
89#[derive(Clone, Debug, Serialize, Deserialize)]
91#[serde(rename_all = "snake_case")]
92pub enum Outcome {
93 Geometry(Geometry),
94 Error(String),
95}
96
97impl Geometry {
98 pub fn is_valid(&self) -> bool {
100 self.version == VERSION
101 && self.volume.is_finite()
102 && self.volume > 0.0
103 && self.centroid.iter().all(|v| v.is_finite())
104 && self.inertia.iter().all(|v| v.is_finite())
105 && self.inertia[0] >= 0.0
106 && self.inertia[1] >= 0.0
107 && self.inertia[2] >= 0.0
108 && self.area.is_finite()
109 }
110
111 pub fn warnings(&self) -> Vec<String> {
113 let mut out = Vec::new();
114 if !self.checked {
115 out.push(format!(
116 "mesh too large for the closedness check ({} triangles)",
117 self.triangles
118 ));
119 }
120 if self.open_edges > 0 {
121 out.push(format!(
122 "open mesh: {} edge{} with one triangle only – the volume depends on the holes",
123 self.open_edges,
124 plural(self.open_edges)
125 ));
126 }
127 if self.misoriented_edges > 0 {
128 out.push(format!(
129 "inconsistent orientation: {} edge{} between flipped triangles",
130 self.misoriented_edges,
131 plural(self.misoriented_edges)
132 ));
133 }
134 if self.non_manifold_edges > 0 {
135 out.push(format!(
136 "{} non-manifold edge{} (bodies touching or duplicated faces)",
137 self.non_manifold_edges,
138 plural(self.non_manifold_edges)
139 ));
140 }
141 if self.inverted_fixed {
142 out.push("the mesh was inside out (all normals inwards) and was flipped".into());
143 }
144 out
145 }
146
147 pub fn body(&self, density: f64, metres: f64) -> Body {
149 let s3 = metres * metres * metres;
150 let s5 = s3 * metres * metres;
151 Body {
152 mass: density * self.volume * s3,
153 cg: self.centroid.map(|c| c * metres),
154 inertia: self.inertia.map(|v| v * density * s5),
155 }
156 }
157}
158
159fn plural(n: u64) -> &'static str {
160 if n == 1 {
161 ""
162 } else {
163 "s"
164 }
165}
166
167#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
170pub struct Body {
171 pub mass: f64,
172 pub cg: V3,
173 pub inertia: Sym,
174}
175
176impl Body {
177 pub fn point(mass: f64, cg: V3) -> Body {
179 Body {
180 mass,
181 cg,
182 inertia: [0.0; 6],
183 }
184 }
185
186 pub fn times(&self, factor: f64) -> Body {
188 Body {
189 mass: self.mass * factor,
190 cg: self.cg,
191 inertia: self.inertia.map(|v| v * factor),
192 }
193 }
194
195 pub fn inertia_about(&self, point: V3) -> Sym {
197 add(self.inertia, steiner(self.mass, sub(self.cg, point)))
198 }
199
200 pub fn is_finite(&self) -> bool {
202 self.mass.is_finite()
203 && self.cg.iter().all(|v| v.is_finite())
204 && self.inertia.iter().all(|v| v.is_finite())
205 }
206}
207
208fn steiner(m: f64, d: V3) -> Sym {
210 let [x, y, z] = d;
211 [
212 m * (y * y + z * z),
213 m * (x * x + z * z),
214 m * (x * x + y * y),
215 -m * x * y,
216 -m * y * z,
217 -m * x * z,
218 ]
219}
220
221fn add(a: Sym, b: Sym) -> Sym {
222 [
223 a[0] + b[0],
224 a[1] + b[1],
225 a[2] + b[2],
226 a[3] + b[3],
227 a[4] + b[4],
228 a[5] + b[5],
229 ]
230}
231
232fn sub(a: V3, b: V3) -> V3 {
233 [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
234}
235
236pub fn combine<'a>(bodies: impl IntoIterator<Item = &'a Body> + Clone) -> Body {
240 let mut mass = 0.0;
241 let mut moment = [0.0; 3];
242 for b in bodies.clone() {
243 if b.mass > 0.0 {
244 mass += b.mass;
245 for (m, c) in moment.iter_mut().zip(b.cg) {
246 *m += b.mass * c;
247 }
248 }
249 }
250 if mass <= 0.0 || !mass.is_finite() {
251 return Body::default();
252 }
253 let cg = moment.map(|m| m / mass);
254 let mut inertia = [0.0; 6];
255 for b in bodies {
256 if b.mass > 0.0 {
257 inertia = add(inertia, b.inertia_about(cg));
258 }
259 }
260 Body { mass, cg, inertia }
261}
262
263#[allow(clippy::indexing_slicing)]
266pub fn principal_moments(t: Sym) -> V3 {
267 let mut a = [[t[0], t[3], t[5]], [t[3], t[1], t[4]], [t[5], t[4], t[2]]];
268 for _ in 0..64 {
269 let off = a[0][1].abs() + a[0][2].abs() + a[1][2].abs();
270 let scale = a[0][0].abs() + a[1][1].abs() + a[2][2].abs();
271 if off.is_nan() || off <= 1e-15 * scale.max(f64::MIN_POSITIVE) {
272 break;
273 }
274 for (p, q) in [(0usize, 1usize), (0, 2), (1, 2)] {
275 if a[p][q].abs() <= f64::MIN_POSITIVE {
276 continue;
277 }
278 let theta = (a[q][q] - a[p][p]) / (2.0 * a[p][q]);
279 let t = if theta == 0.0 {
280 1.0
281 } else {
282 theta.signum() / (theta.abs() + (theta * theta + 1.0).sqrt())
283 };
284 let c = 1.0 / (t * t + 1.0).sqrt();
285 let s = t * c;
286 for row in a.iter_mut() {
287 let (akp, akq) = (row[p], row[q]);
288 row[p] = c * akp - s * akq;
289 row[q] = s * akp + c * akq;
290 }
291 let (row_p, row_q) = (a[p], a[q]);
292 a[p] = [0, 1, 2].map(|k| c * row_p[k] - s * row_q[k]);
293 a[q] = [0, 1, 2].map(|k| s * row_p[k] + c * row_q[k]);
294 }
295 }
296 let mut d = [a[0][0], a[1][1], a[2][2]];
297 d.sort_by(|x, y| y.total_cmp(x));
298 d
299}
300
301#[inline]
303fn subexpressions(w0: f64, w1: f64, w2: f64) -> (f64, f64, f64, f64, f64, f64) {
304 let temp0 = w0 + w1;
305 let f1 = temp0 + w2;
306 let temp1 = w0 * w0;
307 let temp2 = temp1 + w1 * temp0;
308 let f2 = temp2 + w2 * f1;
309 let f3 = w0 * temp1 + w1 * temp2 + w2 * f2;
310 let g0 = f2 + w0 * (f1 + w0);
311 let g1 = f2 + w1 * (f1 + w1);
312 let g2 = f2 + w2 * (f1 + w2);
313 (f1, f2, f3, g0, g1, g2)
314}
315
316#[derive(Default)]
318struct Integrals([f64; 10]);
319
320impl Integrals {
321 fn add(&mut self, p0: V3, p1: V3, p2: V3) {
323 let [x0, y0, z0] = p0;
324 let [x1, y1, z1] = p1;
325 let [x2, y2, z2] = p2;
326 let (a1, b1, c1) = (x1 - x0, y1 - y0, z1 - z0);
327 let (a2, b2, c2) = (x2 - x0, y2 - y0, z2 - z0);
328 let d0 = b1 * c2 - b2 * c1;
329 let d1 = a2 * c1 - a1 * c2;
330 let d2 = a1 * b2 - a2 * b1;
331 let (f1x, f2x, f3x, g0x, g1x, g2x) = subexpressions(x0, x1, x2);
332 let (_, f2y, f3y, g0y, g1y, g2y) = subexpressions(y0, y1, y2);
333 let (_, f2z, f3z, g0z, g1z, g2z) = subexpressions(z0, z1, z2);
334 let s = &mut self.0;
335 s[0] += d0 * f1x;
336 s[1] += d0 * f2x;
337 s[2] += d1 * f2y;
338 s[3] += d2 * f2z;
339 s[4] += d0 * f3x;
340 s[5] += d1 * f3y;
341 s[6] += d2 * f3z;
342 s[7] += d0 * (y0 * g0x + y1 * g1x + y2 * g2x);
343 s[8] += d1 * (z0 * g0y + z1 * g1y + z2 * g2y);
344 s[9] += d2 * (x0 * g0z + x1 * g1z + x2 * g2z);
345 }
346
347 fn finish(&self) -> [f64; 10] {
349 const MULT: [f64; 10] = [
350 1.0 / 6.0,
351 1.0 / 24.0,
352 1.0 / 24.0,
353 1.0 / 24.0,
354 1.0 / 60.0,
355 1.0 / 60.0,
356 1.0 / 60.0,
357 1.0 / 120.0,
358 1.0 / 120.0,
359 1.0 / 120.0,
360 ];
361 let mut out = [0.0; 10];
362 for ((o, v), m) in out.iter_mut().zip(self.0).zip(MULT) {
363 *o = v * m;
364 }
365 out
366 }
367}
368
369fn moments(i: [f64; 10], origin: V3, min_volume: f64) -> Option<(f64, V3, Sym, bool)> {
373 let flip = i[0] < 0.0;
374 let i = if flip { i.map(|v| -v) } else { i };
375 let volume = i[0];
376 if !(volume.is_finite() && volume > min_volume) {
377 return None;
378 }
379 let c = [i[1] / volume, i[2] / volume, i[3] / volume];
380 let (xx, yy, zz) = (i[4], i[5], i[6]);
381 let inertia = [
382 yy + zz - volume * (c[1] * c[1] + c[2] * c[2]),
383 xx + zz - volume * (c[0] * c[0] + c[2] * c[2]),
384 xx + yy - volume * (c[0] * c[0] + c[1] * c[1]),
385 -(i[7] - volume * c[0] * c[1]),
386 -(i[8] - volume * c[1] * c[2]),
387 -(i[9] - volume * c[2] * c[0]),
388 ];
389 let centroid = [c[0] + origin[0], c[1] + origin[1], c[2] + origin[2]];
390 Some((volume, centroid, inertia, flip))
391}
392
393pub fn compute(mesh: &Mesh) -> Result<Geometry> {
396 if mesh.is_point_cloud() {
397 bail!("A point cloud has no volume.");
398 }
399 if mesh.positions.len() % 3 != 0 || mesh.indices.len() % 3 != 0 {
400 bail!("Incomplete vertices or triangles.");
401 }
402 if let Some((shown, total)) = mesh.faces {
403 bail!(
404 "The 3D preview shows only {shown} of {total} faces, so the volume cannot be computed."
405 );
406 }
407 let vertex = |i: u32| -> Option<V3> {
408 let at = (i as usize).checked_mul(3)?;
409 let &[x, y, z] = mesh.positions.get(at..at + 3)? else {
410 return None;
411 };
412 let v = [f64::from(x), f64::from(y), f64::from(z)];
413 v.iter()
414 .all(|c| c.is_finite() && c.abs() < MAX_COORD)
415 .then_some(v)
416 };
417 let triangle = |t: &[u32]| -> Option<[V3; 3]> {
418 match t {
419 [a, b, c] => Some([vertex(*a)?, vertex(*b)?, vertex(*c)?]),
420 _ => None,
421 }
422 };
423
424 let mut lo = [f64::INFINITY; 3];
426 let mut hi = [f64::NEG_INFINITY; 3];
427 let mut invalid = 0u64;
428 for t in mesh.indices.chunks_exact(3) {
429 let Some(tri) = triangle(t) else {
430 invalid += 1;
431 continue;
432 };
433 for p in tri {
434 for ((lo, hi), v) in lo.iter_mut().zip(hi.iter_mut()).zip(p) {
435 *lo = lo.min(v);
436 *hi = hi.max(v);
437 }
438 }
439 }
440 if invalid > 0 {
441 bail!("The mesh has {invalid} triangles with invalid vertices.");
442 }
443 if !lo.iter().chain(&hi).all(|v| v.is_finite()) {
444 bail!("The mesh has no triangles.");
445 }
446 let origin = [
447 0.5 * (lo[0] + hi[0]),
448 0.5 * (lo[1] + hi[1]),
449 0.5 * (lo[2] + hi[2]),
450 ];
451 let extent = sub(hi, lo);
452 let diagonal = (extent[0] * extent[0] + extent[1] * extent[1] + extent[2] * extent[2]).sqrt();
453 if !(diagonal.is_finite() && diagonal > 0.0) {
454 bail!("The model has no extent.");
455 }
456
457 let mut sums = Integrals::default();
459 let mut area = 0.0;
460 let mut degenerate = 0u64;
461 for t in mesh.indices.chunks_exact(3) {
462 let Some([a, b, c]) = triangle(t) else {
463 continue;
464 };
465 let (a, b, c) = (sub(a, origin), sub(b, origin), sub(c, origin));
466 let (u, v) = (sub(b, a), sub(c, a));
467 let n = [
468 u[1] * v[2] - u[2] * v[1],
469 u[2] * v[0] - u[0] * v[2],
470 u[0] * v[1] - u[1] * v[0],
471 ];
472 let w = 0.5 * (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
473 if w.is_nan() || w <= 0.0 {
474 degenerate += 1;
475 continue;
476 }
477 area += w;
478 sums.add(a, b, c);
479 }
480 let box_volume = extent[0].max(diagonal * 1e-9)
481 * extent[1].max(diagonal * 1e-9)
482 * extent[2].max(diagonal * 1e-9);
483 let Some((volume, centroid, inertia, inverted)) =
484 moments(sums.finish(), origin, box_volume * MIN_RELATIVE_VOLUME)
485 else {
486 bail!("The mesh encloses no volume (a surface or an open sheet).");
487 };
488 if !(area.is_finite() && inertia.iter().all(|v| v.is_finite())) {
489 bail!("The model coordinates are out of range.");
490 }
491
492 let triangles = mesh.triangle_count();
493 let checked = triangles <= EDGE_CHECK_LIMIT;
494 let edges = if checked {
495 check_edges(mesh, origin, diagonal * WELD)
496 } else {
497 EdgeCheck::default()
498 };
499 let closed = checked
500 && edges.open == 0
501 && edges.non_manifold == 0
502 && edges.misoriented == 0
503 && edges.used > 0;
504 Ok(Geometry {
505 version: VERSION,
506 volume,
507 centroid,
508 inertia,
509 area,
510 bounds: [lo[0], lo[1], lo[2], hi[0], hi[1], hi[2]],
511 triangles: triangles as u64,
512 closed,
513 checked,
514 open_edges: edges.open,
515 non_manifold_edges: edges.non_manifold,
516 misoriented_edges: edges.misoriented,
517 degenerate_triangles: degenerate,
518 inverted_fixed: inverted,
519 })
520}
521
522#[derive(Default)]
523struct EdgeCheck {
524 used: u64,
525 open: u64,
526 non_manifold: u64,
527 misoriented: u64,
528}
529
530fn check_edges(mesh: &Mesh, origin: V3, tolerance: f64) -> EdgeCheck {
532 let mut ids: HashMap<[i64; 3], u32> = HashMap::new();
533 let mut welded: Vec<u32> = vec![u32::MAX; mesh.vertex_count()];
534 let mut weld = |i: u32| -> Option<u32> {
535 let slot = welded.get_mut(i as usize)?;
536 if *slot == u32::MAX {
537 let at = i as usize * 3;
538 let &[x, y, z] = mesh.positions.get(at..at + 3)? else {
539 return None;
540 };
541 let p = [x, y, z].map(f64::from);
542 let key = sub(p, origin).map(|v| (v / tolerance).round().clamp(-9e15, 9e15) as i64);
543 let next = ids.len() as u32;
544 *slot = *ids.entry(key).or_insert(next);
545 }
546 Some(*slot)
547 };
548 let mut edges: Vec<(u64, i8)> = Vec::with_capacity(mesh.indices.len());
549 for t in mesh.indices.chunks_exact(3) {
550 let &[a, b, c] = t else { continue };
551 let (Some(a), Some(b), Some(c)) = (weld(a), weld(b), weld(c)) else {
552 continue;
553 };
554 if a == b || b == c || a == c {
555 continue; }
557 for (p, q) in [(a, b), (b, c), (c, a)] {
558 let (lo, hi, dir) = if p < q { (p, q, 1) } else { (q, p, -1) };
559 edges.push(((u64::from(lo) << 32) | u64::from(hi), dir));
560 }
561 }
562 edges.sort_unstable_by_key(|e| e.0);
563 let mut out = EdgeCheck::default();
564 for run in edges.chunk_by(|a, b| a.0 == b.0) {
565 out.used += 1;
566 match run.len() {
567 1 => out.open += 1,
568 2 => {
569 if run.iter().map(|e| i32::from(e.1)).sum::<i32>() != 0 {
570 out.misoriented += 1;
571 }
572 }
573 _ => out.non_manifold += 1,
574 }
575 }
576 out
577}
578
579#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
581#[serde(rename_all = "lowercase")]
582pub enum LengthUnit {
583 Mm,
584 M,
585 In,
586}
587
588impl LengthUnit {
589 pub const ALL: [LengthUnit; 3] = [LengthUnit::Mm, LengthUnit::M, LengthUnit::In];
590
591 pub fn as_str(self) -> &'static str {
592 match self {
593 LengthUnit::Mm => "mm",
594 LengthUnit::M => "m",
595 LengthUnit::In => "in",
596 }
597 }
598
599 pub fn parse(s: &str) -> Option<LengthUnit> {
600 match s.trim().to_ascii_lowercase().as_str() {
601 "mm" => Some(LengthUnit::Mm),
602 "m" => Some(LengthUnit::M),
603 "in" | "inch" => Some(LengthUnit::In),
604 _ => None,
605 }
606 }
607
608 pub fn metres(self) -> f64 {
610 match self {
611 LengthUnit::Mm => 1e-3,
612 LengthUnit::M => 1.0,
613 LengthUnit::In => 0.0254,
614 }
615 }
616
617 pub fn default_for(format: &str) -> LengthUnit {
620 match format {
621 "gltf" | "glb" => LengthUnit::M,
622 _ => LengthUnit::Mm,
623 }
624 }
625}
626
627#[cfg(test)]
628mod tests {
629 use super::*;
630
631 fn soup(tris: &[[V3; 3]]) -> Mesh {
633 let mut m = Mesh::default();
634 for t in tris {
635 for p in t {
636 let i = m.vertex(*p);
637 m.indices.push(i);
638 }
639 }
640 m.parts = 1;
641 m
642 }
643
644 fn cuboid(lo: V3, hi: V3) -> Vec<[V3; 3]> {
646 let c = |i: usize| -> V3 {
647 [
648 if i & 1 == 0 { lo[0] } else { hi[0] },
649 if i & 2 == 0 { lo[1] } else { hi[1] },
650 if i & 4 == 0 { lo[2] } else { hi[2] },
651 ]
652 };
653 let quads = [
655 [0, 2, 3, 1], [4, 5, 7, 6], [0, 1, 5, 4], [2, 6, 7, 3], [0, 4, 6, 2], [1, 3, 7, 5], ];
662 let mut out = Vec::new();
663 for q in quads {
664 out.push([c(q[0]), c(q[1]), c(q[2])]);
665 out.push([c(q[0]), c(q[2]), c(q[3])]);
666 }
667 out
668 }
669
670 fn welded(tris: &[[V3; 3]]) -> Mesh {
671 let mut m = Mesh::default();
673 let mut ids: HashMap<[u64; 3], u32> = HashMap::new();
674 for t in tris {
675 for p in t {
676 let key = p.map(|v| (v as f32).to_bits() as u64);
677 let next = m.vertex_count() as u32;
678 let i = *ids.entry(key).or_insert(next);
679 if i == next {
680 m.vertex(*p);
681 }
682 m.indices.push(i);
683 }
684 }
685 m.parts = 1;
686 m
687 }
688
689 fn flipped(tris: &[[V3; 3]]) -> Vec<[V3; 3]> {
690 tris.iter().map(|[a, b, c]| [*a, *c, *b]).collect()
691 }
692
693 fn close(a: f64, b: f64, rel: f64) -> bool {
694 (a - b).abs() <= rel * a.abs().max(b.abs()).max(1e-30)
695 }
696
697 fn assert_sym(got: Sym, want: Sym, rel: f64) {
698 let scale = want.iter().fold(0.0f64, |m, v| m.max(v.abs())).max(1e-30);
699 for (g, w) in got.iter().zip(want) {
700 assert!((g - w).abs() <= rel * scale, "{got:?} vs {want:?}");
701 }
702 }
703
704 fn box_inertia(a: f64, b: f64, c: f64) -> Sym {
706 let v = a * b * c;
707 [
708 v * (b * b + c * c) / 12.0,
709 v * (a * a + c * c) / 12.0,
710 v * (a * a + b * b) / 12.0,
711 0.0,
712 0.0,
713 0.0,
714 ]
715 }
716
717 #[test]
718 fn unit_cube() {
719 let g = compute(&welded(&cuboid([0.0; 3], [1.0; 3]))).unwrap();
720 assert!(close(g.volume, 1.0, 1e-12), "{}", g.volume);
721 assert!(g.centroid.iter().all(|c| close(*c, 0.5, 1e-12)));
722 assert_sym(g.inertia, box_inertia(1.0, 1.0, 1.0), 1e-12);
723 assert!(g.closed && g.checked && !g.inverted_fixed);
724 assert_eq!(
725 (g.open_edges, g.misoriented_edges, g.non_manifold_edges),
726 (0, 0, 0)
727 );
728 assert!(close(g.area, 6.0, 1e-12));
729 assert!(g.warnings().is_empty());
730 assert!(g.is_valid());
731 }
732
733 #[test]
734 fn box_far_from_the_origin_keeps_its_digits() {
735 let lo = [3_200_000.0, -40_000.0, 12_000.0];
737 let (a, b, c) = (2000.0, 1000.0, 500.0);
738 let g = compute(&welded(&cuboid(lo, [lo[0] + a, lo[1] + b, lo[2] + c]))).unwrap();
739 assert!(close(g.volume, a * b * c, 1e-9));
740 let want = [lo[0] + a / 2.0, lo[1] + b / 2.0, lo[2] + c / 2.0];
741 for (g, w) in g.centroid.iter().zip(want) {
742 assert!((g - w).abs() < 1e-6, "{g} vs {w}");
743 }
744 assert_sym(g.inertia, box_inertia(a, b, c), 1e-9);
746 let body = g.body(2700.0, 1e-3);
748 assert!(close(body.mass, 2700.0, 1e-9));
749 assert!(close(body.cg[0], 3201.0, 1e-12));
750 assert!(close(body.inertia[0], 2700.0 * (1.0 + 0.25) / 12.0, 1e-9));
751 }
752
753 #[test]
754 fn tetrahedron_matches_tonon() {
755 let p = [
757 [8.33220, -11.86875, 0.93355],
758 [0.75523, 5.00000, 16.37072],
759 [52.61236, 5.00000, -5.38580],
760 [2.00000, 5.00000, 3.00000],
761 ];
762 let faces = [[0, 1, 2], [0, 2, 3], [0, 3, 1], [1, 3, 2]];
764 let mut tris: Vec<[V3; 3]> = faces.iter().map(|f| [p[f[0]], p[f[1]], p[f[2]]]).collect();
765 let g = compute(&welded(&tris)).unwrap();
766 if g.inverted_fixed {
767 tris = flipped(&tris);
768 }
769 let g = compute(&welded(&tris)).unwrap();
770 assert!(!g.inverted_fixed);
771 let d = |a: V3, b: V3| sub(a, b);
773 let (u, v, w) = (d(p[1], p[0]), d(p[2], p[0]), d(p[3], p[0]));
774 let det = u[0] * (v[1] * w[2] - v[2] * w[1]) - u[1] * (v[0] * w[2] - v[2] * w[0])
775 + u[2] * (v[0] * w[1] - v[1] * w[0]);
776 assert!(close(g.volume, det.abs() / 6.0, 1e-6), "{}", g.volume);
777 let centroid = [15.92492, 0.78281, 3.72962];
778 for (g, w) in g.centroid.iter().zip(centroid) {
779 assert!((g - w).abs() < 1e-4, "{g} vs {w}");
780 }
781 let want = [
787 43520.33257,
788 194711.28938,
789 191168.76173,
790 -11996.20119,
791 -4417.66150,
792 46343.16662,
793 ];
794 assert_sym(g.inertia, want, 1e-5);
795 }
796
797 #[test]
798 fn rotated_box_has_the_same_principal_moments() {
799 let (a, b, c) = (4.0, 2.0, 1.0);
800 let base = cuboid([-a / 2.0, -b / 2.0, -c / 2.0], [a / 2.0, b / 2.0, c / 2.0]);
801 let (sz, cz) = 30f64.to_radians().sin_cos();
803 let (sx, cx) = 40f64.to_radians().sin_cos();
804 let rot = |p: V3| -> V3 {
805 let q = [cz * p[0] - sz * p[1], sz * p[0] + cz * p[1], p[2]];
806 [
807 q[0] + 10.0,
808 cx * q[1] - sx * q[2] - 5.0,
809 sx * q[1] + cx * q[2] + 2.0,
810 ]
811 };
812 let tris: Vec<[V3; 3]> = base.iter().map(|t| t.map(rot)).collect();
813 let g = compute(&welded(&tris)).unwrap();
814 assert!(close(g.volume, a * b * c, 1e-6));
815 for (g, w) in g.centroid.iter().zip([10.0, -5.0, 2.0]) {
816 assert!((g - w).abs() < 1e-5);
817 }
818 let want = box_inertia(a, b, c);
819 let mut want = [want[0], want[1], want[2]];
820 want.sort_by(|x, y| y.total_cmp(x));
821 let got = principal_moments(g.inertia);
822 for (g, w) in got.iter().zip(want) {
823 assert!(close(*g, w, 1e-5), "{got:?} vs {want:?}");
824 }
825 assert!(g.inertia[3].abs() > 0.1);
827 let trace = g.inertia[0] + g.inertia[1] + g.inertia[2];
829 assert!(close(trace, want.iter().sum(), 1e-6));
830 }
831
832 fn icosphere(r: f64, level: u32, centre: V3) -> Vec<[V3; 3]> {
834 let t = (1.0 + 5f64.sqrt()) / 2.0;
835 let norm = |p: V3| -> V3 {
836 let l = (p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt();
837 [p[0] / l, p[1] / l, p[2] / l]
838 };
839 let v: Vec<V3> = [
840 [-1.0, t, 0.0],
841 [1.0, t, 0.0],
842 [-1.0, -t, 0.0],
843 [1.0, -t, 0.0],
844 [0.0, -1.0, t],
845 [0.0, 1.0, t],
846 [0.0, -1.0, -t],
847 [0.0, 1.0, -t],
848 [t, 0.0, -1.0],
849 [t, 0.0, 1.0],
850 [-t, 0.0, -1.0],
851 [-t, 0.0, 1.0],
852 ]
853 .into_iter()
854 .map(norm)
855 .collect();
856 let f = [
857 [0, 11, 5],
858 [0, 5, 1],
859 [0, 1, 7],
860 [0, 7, 10],
861 [0, 10, 11],
862 [1, 5, 9],
863 [5, 11, 4],
864 [11, 10, 2],
865 [10, 7, 6],
866 [7, 1, 8],
867 [3, 9, 4],
868 [3, 4, 2],
869 [3, 2, 6],
870 [3, 6, 8],
871 [3, 8, 9],
872 [4, 9, 5],
873 [2, 4, 11],
874 [6, 2, 10],
875 [8, 6, 7],
876 [9, 8, 1],
877 ];
878 let mut tris: Vec<[V3; 3]> = f.iter().map(|f| [v[f[0]], v[f[1]], v[f[2]]]).collect();
879 let mid = |a: V3, b: V3| {
880 norm([
881 (a[0] + b[0]) / 2.0,
882 (a[1] + b[1]) / 2.0,
883 (a[2] + b[2]) / 2.0,
884 ])
885 };
886 for _ in 0..level {
887 tris = tris
888 .iter()
889 .flat_map(|&[a, b, c]| {
890 let (ab, bc, ca) = (mid(a, b), mid(b, c), mid(c, a));
891 [[a, ab, ca], [b, bc, ab], [c, ca, bc], [ab, bc, ca]]
892 })
893 .collect();
894 }
895 tris.iter()
896 .map(|t| {
897 t.map(|p| {
898 [
899 p[0] * r + centre[0],
900 p[1] * r + centre[1],
901 p[2] * r + centre[2],
902 ]
903 })
904 })
905 .collect()
906 }
907
908 #[test]
909 fn sphere_approximation_converges() {
910 let r = 50.0;
911 let exact_v = 4.0 / 3.0 * std::f64::consts::PI * r * r * r;
912 let exact_i = 0.4 * exact_v * r * r;
913 let mut last = (f64::INFINITY, f64::INFINITY);
914 let mut errors = Vec::new();
915 for level in 1..=5 {
916 let g = compute(&welded(&icosphere(r, level, [120.0, 0.0, -30.0]))).unwrap();
917 assert!(g.closed, "level {level}");
918 let ev = (exact_v - g.volume) / exact_v;
919 let ei = (exact_i - g.inertia[0]) / exact_i;
920 assert!(ev > 0.0 && ev < last.0, "level {level}: {ev}");
922 assert!(ei > 0.0 && ei < last.1, "level {level}: {ei}");
923 assert!(close(g.inertia[0], g.inertia[1], 1e-3));
925 assert!(close(g.inertia[0], g.inertia[2], 1e-3));
926 assert!(g.inertia[3].abs() < 1e-3 * g.inertia[0]);
927 last = (ev, ei);
928 errors.push((g.triangles, ev, ei));
929 }
930 for w in errors.windows(2) {
932 let ratio = w[0].1 / w[1].1;
933 assert!((3.0..5.0).contains(&ratio), "{errors:?}");
934 }
935 let (_, ev, ei) = errors[2];
938 assert!(ev < 1e-2 && ei < 1.5e-2, "{errors:?}");
939 let (_, ev, ei) = errors[errors.len() - 1];
940 assert!(ev < 6e-4 && ei < 1e-3, "{errors:?}");
941 eprintln!("sphere convergence (triangles, volume error, inertia error): {errors:?}");
942 }
943
944 #[test]
945 fn hollow_shell_from_an_outer_and_an_inverted_inner_surface() {
946 let (r_out, r_in) = (10.0, 8.0);
947 let mut tris = cuboid([-r_out; 3], [r_out; 3]);
948 tris.extend(flipped(&cuboid([-r_in; 3], [r_in; 3])));
949 let g = compute(&welded(&tris)).unwrap();
950 let (a, b) = (2.0 * r_out, 2.0 * r_in);
951 assert!(close(g.volume, a.powi(3) - b.powi(3), 1e-9));
952 let want = box_inertia(a, a, a)[0] - box_inertia(b, b, b)[0];
953 assert!(close(g.inertia[0], want, 1e-9));
954 assert!(g.closed && !g.inverted_fixed);
955 let mut s = icosphere(1.0, 4, [0.0; 3]);
957 s.extend(flipped(&icosphere(0.9, 4, [0.0; 3])));
958 let g = compute(&welded(&s)).unwrap();
959 let v = 4.0 / 3.0 * std::f64::consts::PI * (1.0 - 0.9f64.powi(3));
960 assert!(close(g.volume, v, 3e-3), "{} vs {v}", g.volume);
961 }
962
963 #[test]
964 fn inverted_mesh_is_flipped_and_reported() {
965 let tris = flipped(&cuboid([0.0; 3], [2.0, 1.0, 1.0]));
966 let g = compute(&welded(&tris)).unwrap();
967 assert!(g.inverted_fixed);
968 assert!(close(g.volume, 2.0, 1e-12));
969 assert_sym(g.inertia, box_inertia(2.0, 1.0, 1.0), 1e-12);
970 assert!(g.closed);
971 assert!(g.warnings().iter().any(|w| w.contains("inside out")));
972 }
973
974 #[test]
975 fn open_and_misoriented_meshes_are_flagged() {
976 let mut tris = cuboid([0.0; 3], [1.0; 3]);
977 tris.pop(); let g = compute(&welded(&tris)).unwrap();
979 assert!(!g.closed);
980 assert_eq!(g.open_edges, 3);
981 assert!(g.warnings().iter().any(|w| w.contains("open mesh")));
982
983 let mut tris = cuboid([0.0; 3], [1.0; 3]);
984 tris[0] = [tris[0][0], tris[0][2], tris[0][1]];
985 let g = compute(&welded(&tris)).unwrap();
986 assert!(!g.closed);
987 assert_eq!(g.misoriented_edges, 3);
988 assert!(g.warnings().iter().any(|w| w.contains("orientation")));
989 }
990
991 #[test]
992 fn unwelded_soup_is_welded_for_the_check() {
993 let g = compute(&soup(&cuboid([0.0; 3], [1.0; 3]))).unwrap();
995 assert!(g.closed, "{g:?}");
996 }
997
998 #[test]
999 fn two_bodies_touching_on_an_edge_are_non_manifold_but_correct() {
1000 let mut tris = cuboid([0.0; 3], [1.0; 3]);
1001 tris.extend(cuboid([1.0, 1.0, 0.0], [2.0, 2.0, 1.0]));
1002 let g = compute(&welded(&tris)).unwrap();
1003 assert!(close(g.volume, 2.0, 1e-12));
1004 assert!(g.non_manifold_edges > 0 && !g.closed);
1005 }
1006
1007 #[test]
1008 fn degenerate_input_is_an_error_not_a_panic() {
1009 assert!(compute(&Mesh::default()).is_err());
1011 let mut cloud = Mesh::default();
1012 cloud.vertex([1.0, 2.0, 3.0]);
1013 assert!(compute(&cloud).is_err());
1014 let sheet = welded(&[[[0.0; 3], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]]);
1015 assert!(compute(&sheet)
1016 .unwrap_err()
1017 .to_string()
1018 .contains("no volume"));
1019 let mut nan = welded(&cuboid([0.0; 3], [1.0; 3]));
1020 nan.positions[0] = f32::NAN;
1021 assert!(compute(&nan).is_err());
1022 let mut huge = welded(&cuboid([0.0; 3], [1.0; 3]));
1023 huge.positions[0] = 1e30;
1024 assert!(compute(&huge).is_err());
1025 let mut bad = welded(&cuboid([0.0; 3], [1.0; 3]));
1026 bad.indices[0] = 9999;
1027 assert!(compute(&bad).is_err());
1028 let mut odd = welded(&cuboid([0.0; 3], [1.0; 3]));
1029 odd.indices.pop();
1030 assert!(compute(&odd).is_err());
1031 let line = welded(&[[[0.0; 3], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]]]);
1033 assert!(compute(&line).is_err());
1034 let mut partial = welded(&cuboid([0.0; 3], [1.0; 3]));
1036 partial.faces = Some((5, 6));
1037 assert!(compute(&partial)
1038 .unwrap_err()
1039 .to_string()
1040 .contains("5 of 6"));
1041 }
1042
1043 #[test]
1044 fn units_scale_mass_cg_and_inertia() {
1045 let g = compute(&welded(&cuboid([0.0; 3], [1.0; 3]))).unwrap();
1046 let mm = g.body(1000.0, LengthUnit::Mm.metres());
1047 let inch = g.body(1000.0, LengthUnit::In.metres());
1048 assert!(close(mm.mass, 1e-6, 1e-12));
1049 assert!(close(inch.mass, 1000.0 * 0.0254f64.powi(3), 1e-12));
1050 assert!(close(inch.cg[0], 0.0127, 1e-12));
1051 assert!(close(
1052 inch.inertia[0] / mm.inertia[0],
1053 25.4f64.powi(5),
1054 1e-9
1055 ));
1056 assert_eq!(LengthUnit::parse(" IN "), Some(LengthUnit::In));
1057 assert_eq!(LengthUnit::parse("ft"), None);
1058 assert_eq!(LengthUnit::default_for("glb"), LengthUnit::M);
1059 assert_eq!(LengthUnit::default_for("stl"), LengthUnit::Mm);
1060 for u in LengthUnit::ALL {
1061 assert_eq!(LengthUnit::parse(u.as_str()), Some(u));
1062 }
1063 }
1064
1065 #[test]
1066 fn composite_by_parallel_axis_equals_the_merged_mesh() {
1067 let a = cuboid([0.0; 3], [100.0; 3]);
1070 let b = cuboid([300.0, 0.0, 0.0], [500.0, 50.0, 20.0]);
1071 let ga = compute(&welded(&a)).unwrap();
1072 let gb = compute(&welded(&b)).unwrap();
1073 let mut both = a.clone();
1074 both.extend(b.iter().copied());
1075 let gab = compute(&welded(&both)).unwrap();
1076 let (ba, bb, bab) = (
1077 ga.body(2700.0, 1e-3),
1078 gb.body(7850.0, 1e-3),
1079 gab.body(1.0, 1e-3),
1080 );
1081 let total = combine([&ba, &bb]);
1082 assert!(close(total.mass, 2700.0 * 1e-3 + 7850.0 * 2e-4, 1e-9));
1083 let same = combine([&ga.body(1.0, 1e-3), &gb.body(1.0, 1e-3)]);
1085 assert!(close(same.mass, bab.mass, 1e-12));
1086 for (x, y) in same.cg.iter().zip(bab.cg) {
1087 assert!((x - y).abs() < 1e-12);
1088 }
1089 assert_sym(same.inertia, bab.inertia, 1e-9);
1090 let w = 2.7 / (2.7 + 1.57);
1092 assert!(close(total.cg[0], w * 0.05 + (1.0 - w) * 0.4, 1e-9));
1093 let about = total.inertia_about([0.0; 3]);
1095 let direct = add(ba.inertia_about([0.0; 3]), bb.inertia_about([0.0; 3]));
1096 assert_sym(about, direct, 1e-12);
1097 }
1098
1099 #[test]
1100 fn combine_handles_empty_and_quantities() {
1101 assert_eq!(combine([] as [&Body; 0]), Body::default());
1102 let p = Body::point(0.0, [1.0, 2.0, 3.0]);
1103 assert_eq!(combine([&p]), Body::default());
1104 let ring: Vec<Body> = (0..4)
1106 .map(|k| {
1107 let a = k as f64 * std::f64::consts::FRAC_PI_2;
1108 Body::point(0.25, [a.cos(), a.sin(), 2.0])
1109 })
1110 .collect();
1111 let total = combine(ring.iter());
1112 assert!(close(total.mass, 1.0, 1e-12));
1113 assert!(total.cg[0].abs() < 1e-12 && close(total.cg[2], 2.0, 1e-12));
1114 assert!(close(total.inertia[2], 1.0, 1e-12));
1115 assert!(close(total.inertia[0], 0.5, 1e-12));
1116 let stacked = ring[0].times(4.0);
1118 assert!(close(stacked.mass, 1.0, 1e-12));
1119 assert_eq!(stacked.inertia, [0.0; 6]);
1120 }
1121
1122 #[test]
1123 fn outcome_round_trips_as_json() {
1124 let g = compute(&welded(&cuboid([0.0; 3], [1.0; 3]))).unwrap();
1125 let json = serde_json::to_string(&Outcome::Geometry(g.clone())).unwrap();
1126 match serde_json::from_str::<Outcome>(&json).unwrap() {
1127 Outcome::Geometry(back) => assert_eq!(back, g),
1128 Outcome::Error(e) => panic!("{e}"),
1129 }
1130 let mut stale = g;
1131 stale.version = VERSION + 1;
1132 assert!(!stale.is_valid());
1133 }
1134}