Skip to main content

geop_core_topology/
mass.rs

1//! Mass properties of solids, and areas of faces and solids, integrated over
2//! the exact trimmed NURBS faces: [`Model::mass_properties`],
3//! [`Model::face_area`], [`Model::solid_area`].
4//!
5//! # How
6//!
7//! A volume integral is turned into a surface integral by the divergence
8//! theorem — `∫ div F dV = ∮ F · n dA` — with `F` chosen per quantity:
9//! `p / 3` for the volume, `(x²/2, 0, 0)` for `∫ x dV`, `(x³/3, 0, 0)` for
10//! `∫ x² dV`, `(x² y / 2, 0, 0)` for `∫ x y dV`, and so on. Each face is a
11//! surface `Scalar(u, v)` over a trimmed region `D` of its parameter plane, with
12//! `n dA = S_u × S_v du dv` pointing out of the solid. A double integral over
13//! `D` is turned into a line integral around it by Green's theorem:
14//!
15//! ```text
16//! ∬_D g du dv = ∮_∂D G dv,   G(u, v) = ∫_{u0}^{u} g(s, v) ds
17//! ```
18//!
19//! with `∂D` the face's loops as the kernel orients them — the outer one
20//! counter-clockwise, holes clockwise — and `u0` the start of the surface's
21//! `u` domain, which the untrimmed surface covers. (Or the same with the
22//! roles swapped: `∬_D g du dv = -∮_∂D H du`, `H` integrating along `v`.)
23//! So every face is integrated along its own pcurves, each piece of a
24//! pcurve by adaptive Gauss–Kronrod, and every point of those by an inner
25//! integral split at the surface's knots (see [`geop_core_math::quadrature`]).
26//!
27//! # The inner integral is exact where it can be
28//!
29//! The inner integral is evaluated at every point of the outer one, so it
30//! decides the cost. It runs along a direction in which the surface is a
31//! polynomial where there is one: where the weights do not change along
32//! it — any plane, an extrusion along its straight direction, a cylinder
33//! along its axis. For a fixed other parameter the point is then a
34//! polynomial spline of the surface's degree `p` there, `S_u × S_v` one of
35//! degree `2p - 1`, and the moments, at most cubic in the point, of degree
36//! `5p - 1`: the Gauss–Legendre rule of `⌈5p / 2⌉` points integrates every
37//! span exactly (three points on a plane, against the fifteen of a
38//! Gauss–Kronrod panel), and the inner integral has no truncation error at
39//! all. The weights must be the same numbers, not merely overlapping
40//! enclosures: the same interval along every row, which is what a sweep or
41//! a plane is built with. Elsewhere — a sphere, a torus, an area's square
42//! root — it is adaptive Gauss–Kronrod.
43//!
44//! # Why not a tessellation
45//!
46//! A triangle mesh of the faces gives exact integrals of the wrong shape:
47//! its error is the chord error of the mesh, which nothing bounds without
48//! curvature bounds the rasterizer does not compute, and it converges only
49//! quadratically. Integrating the exact surfaces converges spectrally, and
50//! what is uncertain about the result can be stated: the quadrature rule's
51//! own value is enclosed in interval arithmetic, and its truncation error
52//! is the rule's standard estimate (see [`geop_core_math::quadrature`] for
53//! exactly what is proven and what is estimated). Every quantity here is
54//! that enclosure, widened by that estimate.
55
56use std::cell::Cell;
57
58use geop_core_geometry::nurb_surface::NurbSurface3D;
59
60use geop_core_math::{
61    geop_error::{GeopError, GeopResult, WithContext},
62    matrix::{Matrix, symmetric_eigen3},
63    primitives::Pose,
64    quadrature::{Integral, MAX_POLYNOMIAL_DEGREE, Quadrature, integrate, integrate_polynomial},
65    scalars::Scalar,
66    vector::Vector3,
67};
68
69use crate::{FaceId, Model, SolidId, boundary::BoundaryType};
70
71/// How hard the outer integral, along a face's pcurves, tries.
72const OUTER: Quadrature = Quadrature {
73    relative_tolerance: 1e-10,
74    max_panels: 64,
75};
76
77/// How hard each inner integral, along `u`, tries. Tighter than the outer
78/// one: its error is integrated along the boundary.
79const INNER: Quadrature = Quadrature {
80    relative_tolerance: 1e-11,
81    max_panels: 32,
82};
83
84/// The mass properties of a body: its volume, its mass, where its centre of
85/// mass is and its inertia tensor about it. (The area of its boundary is no
86/// mass property, and costs more to integrate: see [`Model::solid_area`].)
87///
88/// Every value is an enclosure in the sense of [`geop_core_math::quadrature`]:
89/// proven as far as interval arithmetic goes, widened by the quadrature's
90/// estimate of its truncation error — `converged` says whether that
91/// estimate came within the tolerance asked.
92#[derive(Clone, Copy, Debug)]
93pub struct MassProperties {
94    pub volume: Scalar,
95    /// The volume times the density: the volume itself, until a density
96    /// is given (see [`MassProperties::with_density`]).
97    pub mass: Scalar,
98    pub center: Vector3,
99    /// The inertia tensor about `center`, along the world's axes:
100    /// `I_ij = ∫ ρ (|r|² δ_ij - r_i r_j) dV`, `r` measured from `center` —
101    /// with `ρ = 1` until a density is given.
102    pub inertia: [[Scalar; 3]; 3],
103    pub converged: bool,
104    /// How many surface points the integration took: the work it did.
105    pub evaluations: usize,
106}
107
108/// The principal moments of inertia, ascending, each enclosed, and the axis
109/// each is about (see [`symmetric_eigen3`]: where moments coincide, any axis
110/// of their plane is one).
111#[derive(Clone, Copy, Debug)]
112pub struct PrincipalAxes {
113    pub moments: [Scalar; 3],
114    pub axes: [Vector3; 3],
115}
116
117/// The components a solid's integrand has, in this order: volume, the
118/// three first moments `∫ x`, `∫ y`, `∫ z`, the three `∫ x²`, `∫ y²`, `∫ z²`
119/// and the three `∫ x y`, `∫ y z`, `∫ z x` — every coordinate measured from
120/// a reference point near the solid, so that the integrands do not cancel.
121const SOLID_COMPONENTS: usize = 10;
122
123/// A function of the point `Scalar(u, v)` and of `S_u × S_v`, of several
124/// components.
125type PointAndNormal<'a> = dyn Fn(&Vector3, &Vector3) -> GeopResult<Vec<Scalar>> + 'a;
126
127/// What is integrated over a face: `g`, of `components` components.
128#[derive(Clone, Copy)]
129struct FaceIntegrand<'a> {
130    components: usize,
131    g: &'a PointAndNormal<'a>,
132    /// The degree of `g` along a direction in which the surface is a
133    /// polynomial of degree `p`, as a function of `p` — `None` where it is
134    /// no polynomial there (see the module docs).
135    degree: Option<fn(usize) -> usize>,
136}
137
138impl Model {
139    /// The area of the face `face`, and whether its quadrature converged.
140    pub fn face_area(&self, face: FaceId) -> GeopResult<(Scalar, bool)> {
141        let ctx = |e: GeopError| e.with_context(format!("Model::face_area(face={face})"));
142        let area = FaceIntegrand {
143            components: 1,
144            g: &|_, n| Ok(vec![n.norm()]),
145            degree: None,
146        };
147        let integral = self.integrate_over_face(face, area).with_context(&ctx)?;
148        Ok((integral.value[0], integral.converged))
149    }
150
151    /// The area of the boundary of `solid`: the sum of its faces' areas,
152    /// and whether every face's quadrature converged.
153    pub fn solid_area(&self, solid: SolidId) -> GeopResult<(Scalar, bool)> {
154        let ctx = |e: GeopError| e.with_context(format!("Model::solid_area(solid={solid})"));
155        let mut total = (Scalar::ZERO, true);
156        for face in self.solid_faces(solid).with_context(&ctx)? {
157            let (area, converged) = self.face_area(face).with_context(&ctx)?;
158            total = (total.0.add(area), total.1 && converged);
159        }
160        Ok(total)
161    }
162
163    /// The mass properties of `solid`, of density one: what its shape
164    /// alone decides, and a material scales (see
165    /// [`MassProperties::with_density`]) — so they can be kept for a
166    /// shape, whatever it is made of.
167    ///
168    /// Fails for a solid whose faces do not enclose a volume definitely
169    /// greater than zero — its faces pointing into the material, or its
170    /// volume not resolved by the quadrature — naming it.
171    pub fn mass_properties(&self, solid: SolidId) -> GeopResult<MassProperties> {
172        let ctx = |e: GeopError| e.with_context(format!("Model::mass_properties(solid={solid})"));
173        let faces = self.solid_faces(solid).with_context(&ctx)?;
174        let reference = self.reference_point(&faces).with_context(&ctx)?;
175        let mut total = [Scalar::ZERO; SOLID_COMPONENTS];
176        let mut converged = true;
177        let mut evaluations = 0;
178        let g = |p: &Vector3, n: &Vector3| {
179            let r = p.sub(&reference);
180            let (x, y, z) = (r[0], r[1], r[2]);
181            let half = |a: Scalar| a.div(Scalar::TWO);
182            let third = |a: Scalar| a.div(Scalar::from_f64(3.0));
183            Ok(vec![
184                third(r.prod_dot(n))?,
185                half(x.mul(x).mul(n[0]))?,
186                half(y.mul(y).mul(n[1]))?,
187                half(z.mul(z).mul(n[2]))?,
188                third(x.mul(x).mul(x).mul(n[0]))?,
189                third(y.mul(y).mul(y).mul(n[1]))?,
190                third(z.mul(z).mul(z).mul(n[2]))?,
191                half(x.mul(x).mul(y).mul(n[0]))?,
192                half(y.mul(y).mul(z).mul(n[1]))?,
193                half(z.mul(z).mul(x).mul(n[2]))?,
194            ])
195        };
196        let moments = FaceIntegrand {
197            components: SOLID_COMPONENTS,
198            g: &g,
199            // At most cubic in the point, of degree `p`, times `S_u × S_v`,
200            // of degree `2p - 1`.
201            degree: Some(|p| 5 * p - 1),
202        };
203        for &face in &faces {
204            let integral = self.integrate_over_face(face, moments).with_context(&ctx)?;
205            converged &= integral.converged;
206            evaluations += integral.evaluations;
207            for (t, v) in total.iter_mut().zip(integral.value) {
208                *t = t.add(v);
209            }
210        }
211        let [volume, hx, hy, hz, jxx, jyy, jzz, jxy, jyz, jzx] = total[..] else {
212            unreachable!("{SOLID_COMPONENTS} components");
213        };
214        if !volume.definitely_greater(Scalar::ZERO) {
215            return Err(ctx(GeopError::new(format!(
216                "its faces enclose a volume of {volume:?}, not one definitely greater than zero: \
217                 they point into the material, or the solid is not closed"
218            ))));
219        }
220        // About the centre: `J_c = J - h hᵀ / V`, then `I = tr(J_c) E - J_c`.
221        let h = Vector3::from_array([hx, hy, hz]);
222        let offset = h.prod_scalar(Scalar::ONE.div(volume).with_context(&ctx)?);
223        let j = [[jxx, jxy, jzx], [jxy, jyy, jyz], [jzx, jyz, jzz]];
224        let mut jc = [[Scalar::ZERO; 3]; 3];
225        for a in 0..3 {
226            for b in 0..3 {
227                jc[a][b] = j[a][b].sub(h[a].mul(offset[b]));
228            }
229        }
230        let trace = jc[0][0].add(jc[1][1]).add(jc[2][2]);
231        let mut inertia = [[Scalar::ZERO; 3]; 3];
232        for a in 0..3 {
233            for b in 0..3 {
234                let delta = if a == b { trace } else { Scalar::ZERO };
235                inertia[a][b] = delta.sub(jc[a][b]);
236            }
237        }
238        Ok(MassProperties {
239            volume,
240            mass: volume,
241            center: reference.add(&offset),
242            inertia,
243            converged,
244            evaluations,
245        })
246    }
247
248    /// A point near the faces, sharp — where the moments are measured from
249    /// is a free choice — the mean of their surfaces' middles.
250    fn reference_point(&self, faces: &[FaceId]) -> GeopResult<Vector3> {
251        let mut sum = Vector3::zero();
252        for &face in faces {
253            let surface = &self.get_face(face)?.surface;
254            let ((u0, u1), (v0, v1)) = (surface.domain_u(), surface.domain_v());
255            let middle = surface.evaluate(
256                u0.add(u1).div(Scalar::TWO)?.sharpen(),
257                v0.add(v1).div(Scalar::TWO)?.sharpen(),
258            )?;
259            sum = sum.add(&middle);
260        }
261        let count = Scalar::from_i64(faces.len().max(1) as i64);
262        Ok(sum.prod_scalar(Scalar::ONE.div(count)?).sharpen())
263    }
264
265    /// `∬ g(Scalar, S_u × S_v) du dv` over the trimmed region of `face` — by
266    /// Green's theorem, along the face's pcurves, the inner integral along
267    /// the direction it is exact in where there is one (see the module
268    /// docs).
269    fn integrate_over_face(
270        &self,
271        face_id: FaceId,
272        integrand: FaceIntegrand<'_>,
273    ) -> GeopResult<Integral> {
274        let FaceIntegrand {
275            components,
276            g,
277            degree,
278        } = integrand;
279        let face = self.get_face(face_id)?;
280        let surface = &face.surface;
281        let (along_u, polynomial) = match (
282            polynomial_along(surface, true),
283            polynomial_along(surface, false),
284        ) {
285            (true, true) => (surface.degree_u <= surface.degree_v, true),
286            (true, false) => (true, true),
287            (false, true) => (false, true),
288            (false, false) => (true, false),
289        };
290        let (breaks, p) = if along_u {
291            (surface.breakpoints_u(), surface.degree_u)
292        } else {
293            (surface.breakpoints_v(), surface.degree_v)
294        };
295        let exact = degree
296            .filter(|_| polynomial)
297            .map(|degree| degree(p))
298            .filter(|&degree| degree <= MAX_POLYNOMIAL_DEGREE);
299        let start = breaks[0];
300        let converged = Cell::new(true);
301        let evaluations = Cell::new(0);
302        // From the start of the domain to `a` along the inner direction, at
303        // `b` along the other, split at every knot passed on the way.
304        let inner = |a: Scalar, b: Scalar| -> GeopResult<Vec<Scalar>> {
305            // On the domain's own start — along a seam or a boundary of the
306            // untrimmed surface there — the integral is over nothing.
307            if a.is_sharp() && a.could_be_equal(start) {
308                return Ok(vec![Scalar::ZERO; components]);
309            }
310            let mut cuts = vec![start];
311            cuts.extend(breaks[1..].iter().filter(|k| k.definitely_less(a)));
312            cuts.push(a);
313            let along = |s: Scalar| -> GeopResult<Vec<Scalar>> {
314                let (u, v) = if along_u { (s, b) } else { (b, s) };
315                let point = surface.evaluate(u, v)?;
316                let (su, sv) = surface.derivatives(u, v)?;
317                g(&point, &su.prod_cross(&sv))
318            };
319            let integral = match exact {
320                Some(degree) => integrate_polynomial(along, &cuts, components, degree)?,
321                None => integrate(along, &cuts, components, &INNER)?,
322            };
323            if !integral.converged {
324                converged.set(false);
325            }
326            evaluations.set(evaluations.get() + integral.evaluations);
327            Ok(integral.value)
328        };
329        let mut total = vec![Scalar::ZERO; components];
330        for boundary in face.boundaries() {
331            let BoundaryType::Loop(anchor) = boundary else {
332                // A bare vertex bounds nothing.
333                continue;
334            };
335            for coedge_id in self.iterate_loop_coedges(anchor) {
336                let pcurve = &self.get_coedge(coedge_id)?.pcurve;
337                let around = |t: Scalar| -> GeopResult<Vec<Scalar>> {
338                    let uv = pcurve.evaluate(t)?;
339                    let tangent = pcurve.tangent(t)?;
340                    // `∮ G dv`, or `-∮ H du`.
341                    let (a, b, db) = if along_u {
342                        (uv[0], uv[1], tangent[1])
343                    } else {
344                        (uv[1], uv[0], tangent[0].neg())
345                    };
346                    // Along an iso-line of the other direction nothing is
347                    // added.
348                    if db.is_sharp() && db.could_be_equal(Scalar::ZERO) {
349                        return Ok(vec![Scalar::ZERO; components]);
350                    }
351                    Ok(inner(a, b)?
352                        .into_iter()
353                        .map(|value| value.mul(db))
354                        .collect())
355                };
356                let integral = integrate(around, &pcurve.breakpoints(), components, &OUTER)
357                    .with_context(&|e: GeopError| {
358                        e.with_context(format!("along coedge {coedge_id} of face {face_id}"))
359                    })?;
360                converged.set(converged.get() && integral.converged);
361                for (t, v) in total.iter_mut().zip(integral.value) {
362                    *t = t.add(v);
363                }
364            }
365        }
366        Ok(Integral {
367            value: total,
368            converged: converged.get(),
369            evaluations: evaluations.get(),
370        })
371    }
372}
373
374/// Whether `surface` is a polynomial along `u` (`along_u`) or `v`: whether
375/// the weights of every row of control points along it are the same — the
376/// same enclosure, not merely overlapping ones, so the same number — so
377/// that for a fixed other parameter its denominator is constant.
378fn polynomial_along(surface: &NurbSurface3D, along_u: bool) -> bool {
379    let (nu, nv) = (surface.num_u, surface.num_v);
380    let weight = |i: usize, j: usize| surface.control_points[i * nv + j][3];
381    let same = |a: Scalar, b: Scalar| a.is_subset_of(b) && b.is_subset_of(a);
382    if along_u {
383        (0..nv).all(|j| (1..nu).all(|i| same(weight(i, j), weight(0, j))))
384    } else {
385        (0..nu).all(|i| (1..nv).all(|j| same(weight(i, j), weight(i, 0))))
386    }
387}
388
389impl MassProperties {
390    /// The body of density one (see [`Model::mass_properties`]) made of a
391    /// uniform `density`: its mass and inertia that many times.
392    pub fn with_density(&self, density: Scalar) -> Self {
393        Self {
394            mass: self.volume.mul(density),
395            inertia: self.inertia.map(|row| row.map(|i| i.mul(density))),
396            ..*self
397        }
398    }
399
400    /// The body moved by `pose`: its centre moved, its inertia turned with
401    /// it (`R I Rᵀ`).
402    pub fn placed(&self, pose: &Pose) -> GeopResult<Self> {
403        let motion = pose.motion();
404        let axes = [0, 1, 2].map(|k| motion.rotate(&Vector3::axis(k)));
405        // `R` has the turned axes as its columns.
406        let r = Matrix::<3, 3>::from_columns(axes);
407        let i = Matrix::from_rows(self.inertia);
408        let turned = r.mul_mat(&i).mul_mat(&r.transpose());
409        let mut inertia = [[Scalar::ZERO; 3]; 3];
410        for (a, row) in inertia.iter_mut().enumerate() {
411            for (b, value) in row.iter_mut().enumerate() {
412                *value = turned[(a, b)];
413            }
414        }
415        Ok(Self {
416            center: motion.apply(&self.center),
417            inertia,
418            ..*self
419        })
420    }
421
422    /// The bodies `parts` taken together: masses, volumes and areas added,
423    /// the centre their mass-weighted mean, and each inertia moved to it by
424    /// the parallel axis theorem. `None` for no parts.
425    pub fn combine(parts: &[Self]) -> GeopResult<Option<Self>> {
426        let Some(first) = parts.first() else {
427            return Ok(None);
428        };
429        let sum =
430            |f: &dyn Fn(&Self) -> Scalar| parts.iter().skip(1).fold(f(first), |a, p| a.add(f(p)));
431        let mass = sum(&|p| p.mass);
432        let mut weighted = Vector3::zero();
433        for p in parts {
434            weighted = weighted.add(&p.center.prod_scalar(p.mass));
435        }
436        let center = weighted.prod_scalar(Scalar::ONE.div(mass).map_err(|e| {
437            e.with_context(format!(
438                "MassProperties::combine: a total mass of {mass:?} is not definitely positive"
439            ))
440        })?);
441        let mut inertia = [[Scalar::ZERO; 3]; 3];
442        for p in parts {
443            let d = p.center.sub(&center);
444            let d2 = d.norm_sq();
445            for a in 0..3 {
446                for b in 0..3 {
447                    let delta = if a == b { d2 } else { Scalar::ZERO };
448                    let shift = delta.sub(d[a].mul(d[b])).mul(p.mass);
449                    inertia[a][b] = inertia[a][b].add(p.inertia[a][b]).add(shift);
450                }
451            }
452        }
453        Ok(Some(Self {
454            volume: sum(&|p| p.volume),
455            mass,
456            center,
457            inertia,
458            converged: parts.iter().all(|p| p.converged),
459            evaluations: parts.iter().map(|p| p.evaluations).sum(),
460        }))
461    }
462
463    /// The principal moments of inertia and their axes.
464    pub fn principal(&self) -> GeopResult<PrincipalAxes> {
465        let (moments, axes) = symmetric_eigen3(&Matrix::from_rows(self.inertia))?;
466        Ok(PrincipalAxes { moments, axes })
467    }
468}
469
470#[cfg(test)]
471mod tests {
472    use geop_core_math::scalars::Scalar;
473
474    use crate::{Model, test_fixtures::test_cube_solid};
475
476    /// The unit cube: volume 1, area 6, centre `(0.5, 0.5, 0.5)`, inertia
477    /// `1/6` about every axis through it, no products — each within its
478    /// enclosure.
479    #[test]
480    fn unit_cube() {
481        let mut model = Model::new();
482        let solid = test_cube_solid(&mut model);
483        let mass = model
484            .mass_properties(solid)
485            .unwrap()
486            .with_density(Scalar::TWO);
487        assert!(mass.converged);
488        assert!(mass.volume.could_be_equal(Scalar::ONE), "{:?}", mass.volume);
489        let (area, converged) = model.solid_area(solid).unwrap();
490        assert!(converged);
491        assert!(area.could_be_equal(Scalar::from_f64(6.0)), "{area:?}");
492        assert!(mass.mass.could_be_equal(Scalar::TWO));
493        let half = Scalar::from_f64(0.5);
494        for k in 0..3 {
495            assert!(mass.center[k].could_be_equal(half), "{:?}", mass.center);
496            for j in 0..3 {
497                let want = if j == k {
498                    Scalar::from_ratio(1, 3).unwrap()
499                } else {
500                    Scalar::ZERO
501                };
502                assert!(
503                    mass.inertia[k][j].could_be_equal(want),
504                    "{:?}",
505                    mass.inertia
506                );
507            }
508        }
509        let (area, converged) = model
510            .face_area(*model.faces.keys().next().unwrap())
511            .unwrap();
512        assert!(converged);
513        assert!(area.could_be_equal(Scalar::ONE));
514    }
515}