Skip to main content

teamcraft/
massprops.rs

1//! Mass properties of closed triangle meshes and their combination.
2//!
3//! * [`compute`] integrates a [`Mesh`] (the cached 3D preview of a file):
4//!   volume, centroid and the inertia tensor about the centroid **per unit
5//!   density, in model units** (Eberly, *Polyhedral Mass Properties
6//!   (Revisited)*; the divergence theorem turns the volume integrals of
7//!   1, x, y, z, x², y², z², xy, yz, zx into exact sums over the triangles).
8//!   The integrals are taken relative to the centre of the bounding box, so a
9//!   thin part far from the origin (a vehicle frame) keeps its digits.
10//! * An edge check counts open, non-manifold and inconsistently oriented
11//!   edges (vertices welded on a grid, like the shape signatures); a fully
12//!   inverted mesh (negative volume) is flipped and reported.
13//! * [`Geometry::body`] applies a density and a length unit and gives a
14//!   [`Body`] in SI units; [`combine`] adds bodies by the parallel axis
15//!   theorem and [`Body::inertia_about`] shifts the tensor to any point.
16//!
17//! Conventions: right-handed axes of the file (the common vehicle frame);
18//! the inertia tensor is stored in tensor form – the off-diagonal entries
19//! are the *negative* products of inertia (`ixy = −∫ xy dm`).
20//!
21//! Untrusted input: the server runs [`compute`] in the isolated preview
22//! process (see `preview`); the function itself is bounded (one pass over the
23//! triangles plus a sort of the edges, the edge check stops at
24//! [`EDGE_CHECK_LIMIT`]) and returns errors, never panics.
25#![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
40/// Bump when the computation changes: cached results of another version are
41/// ignored and computed again (the cache file name carries it).
42pub const VERSION: u32 = 1;
43/// Larger meshes get no edge check (their closedness is then unknown).
44pub const EDGE_CHECK_LIMIT: usize = 2_000_000;
45/// Vertices closer than this share of the bounding box diagonal are one for
46/// the edge check (B-rep tessellations repeat edge vertices per face).
47const WELD: f64 = 1e-6;
48/// Coordinates beyond this are treated as broken (products must stay finite).
49const MAX_COORD: f64 = 1e12;
50/// A volume below this share of the bounding box volume is "no volume".
51const MIN_RELATIVE_VOLUME: f64 = 1e-12;
52
53pub type V3 = [f64; 3];
54/// Symmetric 3×3 tensor `[xx, yy, zz, xy, yz, xz]`.
55pub type Sym = [f64; 6];
56
57/// The geometry of one mesh, per unit density in model units.
58#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
59pub struct Geometry {
60    pub version: u32,
61    /// Enclosed volume (model units³), positive.
62    pub volume: f64,
63    /// Centroid (model units).
64    pub centroid: V3,
65    /// ∫ (|r|² E − r rᵀ) dV about the centroid (model units⁵), tensor form.
66    pub inertia: Sym,
67    /// Surface area (model units²).
68    pub area: f64,
69    /// Bounding box `[min x, min y, min z, max x, max y, max z]`.
70    pub bounds: [f64; 6],
71    pub triangles: u64,
72    /// Every edge shared by exactly two consistently oriented triangles.
73    /// `false` also when the mesh was too large to check.
74    pub closed: bool,
75    /// The edge check ran (at most [`EDGE_CHECK_LIMIT`] triangles).
76    pub checked: bool,
77    /// Edges used by one triangle only (holes).
78    pub open_edges: u64,
79    /// Edges used by three or more triangles.
80    pub non_manifold_edges: u64,
81    /// Edges whose two triangles run the same way (a flipped triangle).
82    pub misoriented_edges: u64,
83    /// Triangles without area (skipped).
84    pub degenerate_triangles: u64,
85    /// The mesh was inside out (negative volume) and was flipped.
86    pub inverted_fixed: bool,
87}
88
89/// What the preview process writes: the geometry or why there is none.
90#[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    /// Plausible values (e.g. read back from the cache).
99    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    /// Short reasons why the values may be wrong (empty = trustworthy).
112    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    /// The body at `density` (kg/m³) with `metres` per model unit.
148    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/// A rigid body in SI units: mass (kg), centre of gravity (m) and inertia
168/// tensor about the CG (kg·m², tensor form).
169#[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    /// A point mass.
178    pub fn point(mass: f64, cg: V3) -> Body {
179        Body {
180            mass,
181            cg,
182            inertia: [0.0; 6],
183        }
184    }
185
186    /// `factor` copies at the same place (a BOM quantity).
187    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    /// The inertia tensor about `point` (parallel axis theorem).
196    pub fn inertia_about(&self, point: V3) -> Sym {
197        add(self.inertia, steiner(self.mass, sub(self.cg, point)))
198    }
199
200    /// All values finite.
201    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
208/// `m (|d|² E − d dᵀ)` in tensor form.
209fn 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
236/// Adds bodies by the parallel axis theorem: total mass, common CG and the
237/// tensor about it. Bodies without mass are skipped; no mass at all gives
238/// the empty body (mass 0 at the origin).
239pub 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/// Principal moments of a tensor, largest first (cyclic Jacobi).
264// The indices are the pairs (0, 1), (0, 2), (1, 2) of a 3 × 3 array.
265#[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/// Eberly's per-triangle sub-expressions of one coordinate.
302#[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/// The ten volume integrals of a closed surface, before the final factors.
317#[derive(Default)]
318struct Integrals([f64; 10]);
319
320impl Integrals {
321    /// Adds the triangle `p0 p1 p2` (counter-clockwise seen from outside).
322    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    /// ∫1, ∫x, ∫y, ∫z, ∫x², ∫y², ∫z², ∫xy, ∫yz, ∫zx.
348    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
369/// Volume, centroid and inertia about the centroid from finished integrals
370/// taken relative to `origin`; `None` without volume. A negative volume
371/// (inside out) is flipped first; the flag says so.
372fn 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
393/// Mass properties of a triangle mesh (per unit density, model units).
394/// Point clouds, meshes without volume and broken coordinates are errors.
395pub 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    // Pass 1: bounding box of the used vertices.
425    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    // Pass 2: the integrals relative to the box centre, and the area.
458    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
530/// Counts the edges by how many triangles use them and in which direction.
531fn 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; // collapsed sliver
556        }
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/// Length units of mesh files and their size in metres.
580#[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    /// Metres per unit.
609    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    /// The unit a format means when nothing else is said: glTF is metres by
618    /// specification, everything else is taken as millimetres.
619    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    /// A mesh from triangles given as corner coordinates.
632    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    /// An axis-aligned box from `lo` to `hi`, outward normals; welded.
645    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        // faces as quads (counter-clockwise from outside)
654        let quads = [
655            [0, 2, 3, 1], // z = lo
656            [4, 5, 7, 6], // z = hi
657            [0, 1, 5, 4], // y = lo
658            [2, 6, 7, 3], // y = hi
659            [0, 4, 6, 2], // x = lo
660            [1, 3, 7, 5], // x = hi
661        ];
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        // weld exact duplicates like the STL reader does
672        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    /// Box inertia per unit density about its centre.
705    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        // a 2 × 1 × 0.5 m slab, in mm, 3.2 km from the origin
736        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        // f32 positions are exact here; the tensor must be too
745        assert_sym(g.inertia, box_inertia(a, b, c), 1e-9);
746        // and in SI: aluminium 2700 kg/m³, mm
747        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        // Tonon (2004), the worked example: vertices in metres, density 1
756        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        // orient the faces outward: check the sign afterwards
763        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        // volume |det| / 6 and the published results (f32 input: ~1e-6)
772        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        // Tonon: a = 43520.33257, b = 194711.28938, c = 191168.76173,
782        // a' = 4417.66150, b' = −46343.16662, c' = 11996.20119 (with his
783        // products of inertia defined positive)
784        // his tensor is [[a, −c', −b'], [−c', b, −a'], [−b', −a', c]], so
785        // [xx, yy, zz, xy, yz, xz] = [a, b, c, −c', −a', −b']
786        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        // rotate about z by 30° and about x by 40°, then shift
802        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        // rotated: the products of inertia are not zero any more
826        assert!(g.inertia[3].abs() > 0.1);
827        // the trace is invariant
828        let trace = g.inertia[0] + g.inertia[1] + g.inertia[2];
829        assert!(close(trace, want.iter().sum(), 1e-6));
830    }
831
832    /// Icosphere of radius `r` with `level` subdivisions (outward).
833    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            // inscribed: always a little too small, and better each level
921            assert!(ev > 0.0 && ev < last.0, "level {level}: {ev}");
922            assert!(ei > 0.0 && ei < last.1, "level {level}: {ei}");
923            // the tensor stays isotropic
924            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        // quadratic convergence: each level quarters the error (roughly)
931        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        // 1 280 triangles: 0.9 % / 1.4 %; 20 480 triangles: volume within
936        // 0.06 %, inertia within 0.1 %
937        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        // a sphere shell too: (2/5) m (R⁵ − r⁵) / (R³ − r³)
956        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(); // one triangle missing: three open edges
978        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        // per-face vertices (like a B-rep tessellation) are still closed
994        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        // empty, a point cloud, a flat sheet, NaN, huge, broken indices
1010        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        // all triangles collapsed
1032        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        // a partial STEP preview
1035        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        // a cube and an offset box, computed apart and combined, against
1068        // one mesh with both
1069        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        // same density: identical to the merged mesh
1084        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        // the CG lies on the line between the two
1091        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        // about a reference point: parallel axis again
1094        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        // four point masses on a ring: CG on the axis, Izz = m r²
1105        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        // the same mass stacked four times at one point: no ring inertia
1117        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}