Skip to main content

ogeom_algo/
mass.rs

1//! Mass properties: how much there is, where its centre is, and how it resists
2//! being spun.
3//!
4//! Three measures, one for each dimension a shape can have (the length of its
5//! edges, the area of its faces, the volume it encloses), each with the centre
6//! of that measure and the inertia tensor about that centre.
7//!
8//! # Integrated on the surfaces where possible, meshed where not
9//!
10//! Area and volume are integrated on the exact surfaces first. A face on a
11//! plane, cylinder, cone, sphere or torus bounded by a chart rectangle or a
12//! full circle has a closed form; any other face with pcurves is integrated
13//! round its chart boundary by Green's theorem. Either way the result
14//! reports a deflection of zero.
15//!
16//! A face's placement may scale: it is integrated on its surface placed
17//! rigidly, and each sample is carried out by the scale.
18//!
19//! A shape with a face neither can take (no pcurves, a boundary that does
20//! not close in the chart) is measured on its tessellation instead, and
21//! the result carries the deflection it was computed at. Halving the
22//! deflection and seeing the answer move tells a caller how much to trust
23//! it; [`MassProperties::deflection`] is what makes that check possible.
24//! Lengths are always measured on a discretization.
25//!
26//! # The one formula
27//!
28//! Length, area and volume all reduce to summing over simplices (segments,
29//! triangles, tetrahedra), and the second moment of a simplex has the same
30//! shape in every dimension:
31//!
32//! ```text
33//! ∫ x_i x_j  =  m / (n(n+1)) · [ Σ_k p_k p_kᵀ + (Σ_k p_k)(Σ_k p_k)ᵀ ]
34//! ```
35//!
36//! for `n` vertices and measure `m`. Barycentric integration gives it: the
37//! integral of `λ_a λ_b` over a simplex is `m·d!·(1+δ_ab)/(d+2)!`, and
38//! `n(n+1)` is what that collapses to. One function serves all three, which is
39//! also why the three agree with each other rather than drifting apart.
40
41use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
42use ogeom_geom::Transformable as _;
43use ogeom_math::{Direction, Matrix3, Point, Vector};
44use ogeom_mesh::{Deflection, discretize};
45use ogeom_topo::{EdgeRepr, Filter, Model, NodeData, Shape, ShapeType, explore, explore_unique};
46
47/// How much of something there is, and how it is distributed.
48#[derive(Debug, Clone, Copy, PartialEq)]
49pub struct MassProperties {
50    /// The measure: length, area or volume, depending on what was asked for.
51    ///
52    /// Never negative. A volume computed from an inward-wound shell would come
53    /// out negative, which says the shell is inside out rather than that the
54    /// solid has negative volume, so that case is an error instead.
55    pub mass: f64,
56    /// The centre of the measure: the centroid, or centre of mass at uniform
57    /// density.
58    pub centre: Point,
59    /// The inertia tensor about [`MassProperties::centre`], at unit density.
60    ///
61    /// About the centre, not the origin: an inertia about the origin says as
62    /// much about where the part happens to sit as about the part.
63    /// [`MassProperties::inertia_about`] moves it elsewhere.
64    pub inertia: Matrix3,
65    /// The chord deflection the tessellation was built to.
66    ///
67    /// The honest statement of accuracy. For a shape with only planar faces
68    /// and straight edges the result is exact whatever this says, because the
69    /// tessellation is exact.
70    pub deflection: f64,
71}
72
73impl MassProperties {
74    /// Nothing: no mass, at the origin, resisting nothing.
75    #[must_use]
76    pub const fn none(deflection: f64) -> Self {
77        Self {
78            mass: 0.0,
79            centre: Point::ORIGIN,
80            inertia: Matrix3::ZERO,
81            deflection,
82        }
83    }
84
85    /// The inertia tensor about some other point, by the parallel axis theorem.
86    #[must_use]
87    pub fn inertia_about(&self, point: Point) -> Matrix3 {
88        let d = self.centre - point;
89        // Moving *away* from the centre can only increase inertia, which is the
90        // sign convention here: the centre is the minimum.
91        add(self.inertia, displacement_term(self.mass, d))
92    }
93
94    /// The radius of gyration about an axis through the centre.
95    ///
96    /// The distance at which a point of the same mass would have the same
97    /// inertia. Zero mass has no such distance, so this returns `None` rather
98    /// than dividing by it.
99    #[must_use]
100    pub fn radius_of_gyration(&self, axis: Direction) -> Option<f64> {
101        if self.mass <= 0.0 {
102            return None;
103        }
104        let v = axis.vector();
105        let i = quadratic_form(self.inertia, v);
106        Some((i / self.mass).max(0.0).sqrt())
107    }
108
109    /// The principal moments, smallest first, with the axes they act about.
110    ///
111    /// The eigenvectors of a symmetric tensor, so the axes are orthogonal. A
112    /// shape with rotational symmetry has repeated moments and the axes in that
113    /// plane are arbitrary but still orthogonal, which is correct, not a
114    /// failure: any pair of perpendicular axes in that plane is principal.
115    ///
116    /// # Errors
117    ///
118    /// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if the eigen-solver does
119    /// not converge, which for a symmetric 3×3 means the tensor was not finite.
120    pub fn principal_axes(&self, tol: Tolerances) -> OgeomResult<[(f64, Direction); 3]> {
121        let m = nalgebra::Matrix3::from_row_slice(&[
122            self.inertia.rows[0][0],
123            self.inertia.rows[0][1],
124            self.inertia.rows[0][2],
125            self.inertia.rows[1][0],
126            self.inertia.rows[1][1],
127            self.inertia.rows[1][2],
128            self.inertia.rows[2][0],
129            self.inertia.rows[2][1],
130            self.inertia.rows[2][2],
131        ]);
132        if !m.iter().all(|x| x.is_finite()) {
133            ogeom_bail!(NotDone, "the inertia tensor is not finite");
134        }
135        // Symmetric by construction, so the eigenvalues are real and this
136        // always converges; the general solver would return complex pairs.
137        let eigen = nalgebra::SymmetricEigen::new(m);
138
139        let mut out: Vec<(f64, Direction)> = Vec::with_capacity(3);
140        for i in 0..3 {
141            let column = eigen.eigenvectors.column(i);
142            let axis = Direction::new(Vector::new(column[0], column[1], column[2]), tol)?;
143            out.push((eigen.eigenvalues[i], axis));
144        }
145        out.sort_by(|a, b| a.0.total_cmp(&b.0));
146        Ok([out[0], out[1], out[2]])
147    }
148}
149
150/// The length of a shape's edges, and how it is distributed.
151///
152/// Every distinct edge counts once, however many faces it bounds: the wire
153/// frame of the shape, not a tally weighted by use.
154///
155/// # Errors
156///
157/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the deflection
158/// settings are unusable, or a curve is missing from the model.
159pub fn linear_properties(
160    model: &Model,
161    shape: &Shape,
162    deflection: Deflection,
163    tol: Tolerances,
164) -> OgeomResult<MassProperties> {
165    deflection.validate()?;
166    let mut acc = Accumulator::new();
167
168    for edge in explore_unique(model, shape, ShapeType::Edge)? {
169        let Some(node) = model.node(&edge) else {
170            ogeom_bail!(Dangling, "edge is not in this model");
171        };
172        let NodeData::Edge(data) = node.data() else {
173            ogeom_bail!(Construction, "edge node holds no edge data");
174        };
175        let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
176            continue;
177        };
178        let Some(geometry) = model.geometry().curve(*curve) else {
179            ogeom_bail!(Dangling, "curve is not in this model");
180        };
181        let placement = edge.transform(model.datums())?;
182        let line = discretize(geometry, *range, deflection, tol)?;
183        for w in line.points.windows(2) {
184            let (a, b) = (placement.apply(w[0]), placement.apply(w[1]));
185            acc.add(&[a, b], a.distance(b));
186        }
187    }
188    Ok(acc.finish(deflection.chord))
189}
190
191/// The area of a shape's faces, and how it is distributed.
192///
193/// # Errors
194///
195/// As [`ogeom_mesh::triangulate_face`].
196pub fn surface_properties(
197    model: &Model,
198    shape: &Shape,
199    deflection: Deflection,
200    tol: Tolerances,
201) -> OgeomResult<MassProperties> {
202    deflection.validate()?;
203    if let Some(exact) = exact_surface_properties(model, shape, tol)? {
204        return Ok(exact);
205    }
206    let mut acc = Accumulator::new();
207
208    for face in explore(model, shape, Filter::OfType(ShapeType::Face))? {
209        let mesh = ogeom_mesh::triangulate_face(model, &face, deflection, tol)?;
210        for triangle in &mesh.triangles {
211            let [a, b, c] = triangle.map(|i| mesh.positions[i as usize]);
212            // Unsigned: a reversed face still has the same area, and summing
213            // signed areas would cancel a solid's own surface to nothing.
214            let area = (b - a).cross(c - a).magnitude() * 0.5;
215            acc.add(&[a, b, c], area);
216        }
217    }
218    Ok(acc.finish(deflection.chord))
219}
220
221/// The volume a shape encloses, and how it is distributed.
222///
223/// Each shell of a solid with several is weighed facing out of the solid's
224/// material, as a probe off its faces finds: a void whose faces all point
225/// into the material (turned inside out as a whole, which no edge between
226/// its faces shows) is still taken away, not added. A face whose flag
227/// points into the material, against the neighbours it shares edges
228/// with, is counted turned back the same way.
229///
230/// # Errors
231///
232/// As [`surface_properties`], plus
233/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the boundary is
234/// not closed, or is wound inward so that the volume comes out negative. Both
235/// mean the answer would be meaningless rather than merely inaccurate: the
236/// divergence theorem needs a closed, outward-oriented boundary, and without
237/// one the sum is a number with no interpretation.
238pub fn volume_properties(
239    model: &Model,
240    shape: &Shape,
241    deflection: Deflection,
242    tol: Tolerances,
243) -> OgeomResult<MassProperties> {
244    volume_with(model, shape, deflection, Turning::Probed, None, tol)
245}
246
247/// The volume a shape encloses with no face turned against its neighbours
248/// turned back: such a face leaves the closed form to the mesh, which
249/// counts it as the mesh's own repair does. For a builder asking whether
250/// its faces face the way it meant, where a probe that turns them back
251/// would hide the fault the volume is asked to show.
252///
253/// # Errors
254///
255/// As [`volume_properties`].
256pub(crate) fn volume_as_flagged(
257    model: &Model,
258    shape: &Shape,
259    deflection: Deflection,
260    kept: Option<&VolumeKept>,
261    tol: Tolerances,
262) -> OgeomResult<MassProperties> {
263    volume_with(model, shape, deflection, Turning::WholeShells, kept, tol)
264}
265
266/// What measuring volumes keeps for the next shape asked about, for a
267/// caller measuring a run of shapes in which most faces recur unchanged:
268/// each face's mesh, each face's regions as the closed forms take them, and
269/// each region's closed-form integrals about a reference point. A face is
270/// known by its content (see [`ogeom_topo::face_content`]), which is
271/// everything any of these reads.
272#[derive(Default)]
273pub(crate) struct VolumeKept {
274    meshes: ogeom_mesh::FaceMeshCache,
275    /// By the face's content and the tolerances: its regions, or `None`
276    /// where the closed forms cannot take it.
277    regions: std::sync::Mutex<ogeom_core::FastMap<Vec<u8>, Option<Vec<ExactFace>>>>,
278    /// By the face's content, the region's place among the face's regions
279    /// and the reference's bits: the region's moments, or `None` where its
280    /// integral did not settle.
281    integrals: std::sync::Mutex<ogeom_core::FastMap<Vec<u8>, Option<Moments>>>,
282}
283
284impl VolumeKept {
285    /// The face meshes kept, for a caller meshing the same faces.
286    pub(crate) const fn meshes(&self) -> &ogeom_mesh::FaceMeshCache {
287        &self.meshes
288    }
289
290    fn regions(&self, key: &[u8]) -> Option<Option<Vec<ExactFace>>> {
291        self.regions
292            .lock()
293            .unwrap_or_else(std::sync::PoisonError::into_inner)
294            .get(key)
295            .cloned()
296    }
297
298    fn keep_regions(&self, key: Vec<u8>, found: Option<Vec<ExactFace>>) {
299        self.regions
300            .lock()
301            .unwrap_or_else(std::sync::PoisonError::into_inner)
302            .insert(key, found);
303    }
304
305    fn integral(&self, key: &[u8]) -> Option<Option<Moments>> {
306        self.integrals
307            .lock()
308            .unwrap_or_else(std::sync::PoisonError::into_inner)
309            .get(key)
310            .cloned()
311    }
312
313    fn keep_integral(&self, key: Vec<u8>, found: Option<Moments>) {
314        self.integrals
315            .lock()
316            .unwrap_or_else(std::sync::PoisonError::into_inner)
317            .insert(key, found);
318    }
319}
320
321/// Which faces the closed form turns over where the probe finds them
322/// facing into the material.
323#[derive(Clone, Copy, PartialEq, Eq)]
324enum Turning {
325    /// Any face the probe settles.
326    Probed,
327    /// Only a whole shell of a solid with several, turned inside out; a
328    /// face turned against its neighbours, or any other set facing in,
329    /// leaves the closed form to the mesh.
330    WholeShells,
331}
332
333fn volume_with(
334    model: &Model,
335    shape: &Shape,
336    deflection: Deflection,
337    turning: Turning,
338    kept: Option<&VolumeKept>,
339    tol: Tolerances,
340) -> OgeomResult<MassProperties> {
341    deflection.validate()?;
342    if let Some(exact) = exact_volume_properties(model, shape, turning, kept, deflection, tol)? {
343        return Ok(exact);
344    }
345    if let Some(probed) = probed_mesh_volume(model, shape, deflection, tol)? {
346        return Ok(probed);
347    }
348    let (mut mesh, _, faces) = ogeom_mesh::triangulate_with_face_meshes(
349        model,
350        shape,
351        deflection,
352        kept.map(VolumeKept::meshes),
353        tol,
354    )?;
355    if mesh.is_empty() {
356        return Ok(MassProperties::none(deflection.chord));
357    }
358    if !mesh.is_closed() {
359        // The flux through a boundary is the sum of the flux through each
360        // face, and a face's share needs only that face's own mesh. The
361        // welded mesh can stay open where faces meet on edges looser than
362        // the weld dares reach, a mesh converted back holding threaded
363        // curves a few hundredths off; closure is then asked of the
364        // topology, which is what the divergence theorem is about.
365        let shells = explore_unique(model, shape, ShapeType::Shell)?;
366        let mut closed = !shells.is_empty();
367        for shell in &shells {
368            closed &= crate::build::is_shell_closed(model, shell)?;
369        }
370        if !closed {
371            ogeom_bail!(
372                Construction,
373                "the boundary is not closed, so it encloses no volume to measure"
374            );
375        }
376        mesh = ogeom_topo::Triangulation::new();
377        for (_, face_mesh) in faces {
378            mesh.append(&face_mesh?);
379        }
380    }
381
382    // The apex every tetrahedron is built on. Any point serves (the signs
383    // cancel outside the enclosed region wherever it sits), so it is a point on
384    // the mesh, which keeps the tetrahedra the size of the shape instead of the
385    // size of its distance from the world origin.
386    let apex = mesh.positions[0];
387    let mut acc = Accumulator::new();
388    for triangle in &mesh.triangles {
389        let [a, b, c] = triangle.map(|i| mesh.positions[i as usize]);
390        // The signed volume of the tetrahedron on the apex. The cancellation is
391        // the divergence theorem doing the work, and why the winding has to be
392        // outward.
393        let volume = (a - apex).dot((b - apex).cross(c - apex)) / 6.0;
394        acc.add(&[apex, a, b, c], volume);
395    }
396
397    if acc.mass < 0.0 {
398        ogeom_bail!(
399            Construction,
400            "the boundary is wound inward, so the volume came out negative"
401        );
402    }
403    Ok(acc.finish(deflection.chord))
404}
405
406/// The meshed volume of a shape holding a solid with more than one shell,
407/// each face turned out by probing it against its own solid; `None` where
408/// no solid of `shape` has more than one shell.
409///
410/// The whole-shape mesh mends a minority of turned faces within each
411/// connected piece, and a void is a piece of its own: turned inside out as
412/// a whole it agrees with itself and is weighed as material. So every face
413/// under a solid is asked which way it faces, as `check` asks, and one
414/// facing in is counted turned over. A face the probe cannot settle is
415/// counted as its flag says.
416fn probed_mesh_volume(
417    model: &Model,
418    shape: &Shape,
419    deflection: Deflection,
420    tol: Tolerances,
421) -> OgeomResult<Option<MassProperties>> {
422    let solids = explore(model, shape, Filter::OfType(ShapeType::Solid))?;
423    let mut several = false;
424    for solid in &solids {
425        several |= explore(model, solid, Filter::OfType(ShapeType::Shell))?.len() > 1;
426    }
427    if !several {
428        return Ok(None);
429    }
430    for shell in explore_unique(model, shape, ShapeType::Shell)? {
431        if !crate::build::is_shell_closed(model, &shell)? {
432            ogeom_bail!(
433                Construction,
434                "the boundary is not closed, so it encloses no volume to measure"
435            );
436        }
437    }
438    // Every face with the solid it bounds, then any face under none.
439    let mut faces: Vec<(Shape, Option<usize>)> = Vec::new();
440    let mut seen: ogeom_core::FastSet<Occurrence> = ogeom_core::FastSet::default();
441    for (at, solid) in solids.iter().enumerate() {
442        for face in explore(model, solid, Filter::OfType(ShapeType::Face))? {
443            seen.insert((face.node(), face.location().clone()));
444            faces.push((face, Some(at)));
445        }
446    }
447    for face in explore(model, shape, Filter::OfType(ShapeType::Face))? {
448        if !seen.contains(&(face.node(), face.location().clone())) {
449            faces.push((face, None));
450        }
451    }
452    let mut boundaries: Vec<Option<crate::SolidBoundary>> = Vec::with_capacity(solids.len());
453    for solid in &solids {
454        boundaries.push(or_mesh(
455            crate::check::probe_boundary(model, solid, tol).map(Some),
456            None,
457        )?);
458    }
459    let chords = ogeom_mesh::edge_chords_for(model, shape, deflection, tol)?;
460    let meshes = ogeom_core::parallel::map_ordered(&faces, |_, (face, at)| {
461        let mesh = ogeom_mesh::triangulate_face_with(model, face, deflection, &chords, tol)?;
462        let boundary = at.and_then(|at| boundaries[at].as_ref());
463        let inward = match boundary {
464            Some(boundary) => {
465                or_mesh(crate::check::faces_inward(model, face, boundary, tol), None)?
466            }
467            None => None,
468        };
469        Ok((mesh, inward == Some(true)))
470    });
471    let mut acc = Accumulator::new();
472    let mut apex: Option<Point> = None;
473    for one in meshes {
474        let (mesh, turned) = one?;
475        let Some(&first) = mesh.positions.first() else {
476            continue;
477        };
478        // Any apex serves, as for the whole-shape mesh.
479        let apex = *apex.get_or_insert(first);
480        let sign = if turned { -1.0 } else { 1.0 };
481        for triangle in &mesh.triangles {
482            let [a, b, c] = triangle.map(|i| mesh.positions[i as usize]);
483            let volume = (a - apex).dot((b - apex).cross(c - apex)) / 6.0;
484            acc.add(&[apex, a, b, c], volume * sign);
485        }
486    }
487    if apex.is_none() {
488        return Ok(Some(MassProperties::none(deflection.chord)));
489    }
490    if acc.mass < 0.0 {
491        ogeom_bail!(
492            Construction,
493            "the boundary is wound inward, so the volume came out negative"
494        );
495    }
496    Ok(Some(acc.finish(deflection.chord)))
497}
498
499// --- exact properties on the exact surfaces ----------------------------------
500
501/// A face whose trim the exact integrator can walk: an analytic surface
502/// trimmed to a chart rectangle, or a plane trimmed to a full disc.
503///
504/// A rectangle or a disc stands on its surface placed as the face is less
505/// the placement's scale, which `split` applies to every sample.
506#[derive(Clone)]
507enum ExactFace {
508    /// `[u0, u1] x [v0, v1]` on the (placed) surface.
509    ChartRectangle {
510        surface: ogeom_geom::SurfaceGeometry,
511        rect: (f64, f64, f64, f64),
512        sign: f64,
513        share: f64,
514        split: crate::mass_chart::Split,
515    },
516    /// A full circular disc on a plane.
517    Disc {
518        centre: Point,
519        e1: Vector,
520        e2: Vector,
521        normal: Vector,
522        radius: f64,
523        sign: f64,
524        share: f64,
525        split: crate::mass_chart::Split,
526    },
527    /// Any other face, integrated round its chart loops.
528    Chart(Box<crate::mass_chart::ChartFace>),
529}
530
531impl ExactFace {
532    /// Whether this region is part of its face or taken out of it: `1` for
533    /// the outer boundary, `-1` for a hole. The volume integral could carry
534    /// it in the normal's sign, but the area integral takes a magnitude and
535    /// would hand a hole's area back as more face.
536    const fn share(&self) -> f64 {
537        match self {
538            Self::ChartRectangle { share, .. } | Self::Disc { share, .. } => *share,
539            Self::Chart(_) => 1.0,
540        }
541    }
542
543    /// How much of its chart the region covers, for telling a face's outer
544    /// boundary from its holes. A wire's place in the face's list does not
545    /// say which it is (a ring's annulus arrives inner ring first), and a
546    /// hole is inside the boundary it is a hole in, so it covers less.
547    fn chart_area(&self) -> f64 {
548        match self {
549            Self::Disc { radius, .. } => core::f64::consts::PI * radius * radius,
550            Self::ChartRectangle { rect, .. } => (rect.1 - rect.0) * (rect.3 - rect.2),
551            Self::Chart(_) => 0.0,
552        }
553    }
554
555    fn take_away(&mut self) {
556        match self {
557            Self::ChartRectangle { share, .. } | Self::Disc { share, .. } => *share = -1.0,
558            Self::Chart(_) => {}
559        }
560    }
561}
562
563/// Mass properties integrated on the exact surfaces, when every face allows.
564///
565/// The integrands over an analytic surface's chart are trigonometric
566/// polynomials, and panels no wider than a quarter turn under the ten-point
567/// Gauss rule integrate them to rounding, exact in every sense that
568/// matters, with `deflection` reported as zero. The first face that resists
569/// (a non-analytic surface, a trim that is not a chart rectangle or a disc)
570/// returns `None`, and the caller falls back to the tessellation with its
571/// stated chord.
572fn exact_volume_properties(
573    model: &Model,
574    shape: &Shape,
575    turning: Turning,
576    kept: Option<&VolumeKept>,
577    deflection: Deflection,
578    tol: Tolerances,
579) -> OgeomResult<Option<MassProperties>> {
580    let faces = explore(model, shape, Filter::OfType(ShapeType::Face))?;
581    if faces.is_empty() {
582        return Ok(None);
583    }
584    let mut exact = Vec::with_capacity(faces.len());
585    // The face each region of `exact` belongs to.
586    let mut region_of: Vec<usize> = Vec::with_capacity(faces.len());
587    // The faces no closed form speaks for, measured on their own meshes.
588    let mut unspoken: Vec<usize> = Vec::new();
589    // Where anything is kept, each face is known by its content.
590    let contents: Vec<Option<Vec<u8>>> = match kept {
591        Some(_) => faces
592            .iter()
593            .map(|face| ogeom_topo::face_content(model, face))
594            .collect(),
595        None => vec![None; faces.len()],
596    };
597    // Each face prepared on its own, and taken in the faces' order. A face
598    // with no wires has no edge for the shell's closure to be read off, so
599    // the divergence theorem is not told its boundary closes: its mesh is.
600    let prepared = ogeom_core::parallel::map_ordered(&faces, |at, face| {
601        if model.children_of(face)?.is_empty() {
602            return Ok(None);
603        }
604        let key = contents[at].as_ref().map(|content| {
605            let mut key = content.clone();
606            key.extend_from_slice(format!("{tol:?}").as_bytes());
607            key
608        });
609        if let (Some(kept), Some(key)) = (kept, &key)
610            && let Some(found) = kept.regions(key)
611        {
612            return Ok(found);
613        }
614        let found = integrable_face(model, face, tol);
615        if let (Some(kept), Some(key), Ok(found)) = (kept, key, &found) {
616            kept.keep_regions(key, found.clone());
617        }
618        found
619    });
620    for (at, (face, found)) in faces.iter().zip(prepared).enumerate() {
621        // A face the closed forms cannot evaluate (a chart point a hair off
622        // its surface's domain) is left to its mesh, like one they do not
623        // speak at all; the rest keep their closed forms.
624        match or_mesh(found, None)? {
625            Some(found) => {
626                region_of.extend(std::iter::repeat_n(at, found.len()));
627                exact.extend(found);
628            }
629            None => {
630                if *DEBUG_MASS {
631                    eprintln!(
632                        "MASS face {} is not exactly integrable",
633                        face.node().index()
634                    );
635                }
636                unspoken.push(at);
637            }
638        }
639    }
640    // With no closed form anywhere there is nothing to keep: the mesh
641    // measures the whole.
642    if exact.is_empty() {
643        return Ok(None);
644    }
645    // And the faces must agree with each other about which way is out. A
646    // boundary that cannot be walked to ask (a pcurve whose domain falls
647    // short of its edge's range) is left to the mesh.
648    let Some(flags) = or_mesh(flags_agree(model, shape, tol).map(Some), None)? else {
649        return Ok(None);
650    };
651    if !flags.agree {
652        return Ok(None);
653    }
654    // The divergence theorem needs a closed boundary; topology says whether
655    // it has one. A shape with no shell at all (a bare face) has nothing
656    // to close, and falls back to the mesh path, which refuses it properly.
657    let shells = explore_unique(model, shape, ShapeType::Shell)?;
658    if shells.is_empty() {
659        return Ok(None);
660    }
661    for shell in shells {
662        if !crate::build::is_shell_closed(model, &shell)? {
663            ogeom_bail!(
664                Construction,
665                "the boundary is not closed, so it encloses no volume to measure"
666            );
667        }
668    }
669    // Each set of faces the walks tied together is asked of the solid
670    // itself, a face of it probed off both its sides, and every face the
671    // answer finds facing in is turned over: a face turned against its
672    // neighbours, or a whole shell turned inside out. A set no face of
673    // which the probe can settle is left to the mesh, and so is any face
674    // facing in that `turning` does not turn.
675    let mut turned: ogeom_core::FastSet<Occurrence> = ogeom_core::FastSet::default();
676    if !flags.sets.is_empty() {
677        if turning == Turning::WholeShells
678            && flags.sets.iter().flatten().any(|(_, against)| *against)
679        {
680            return Ok(None);
681        }
682        let Some(inward) = sets_facing_in(model, shape, &flags.sets, tol)? else {
683            return Ok(None);
684        };
685        let shells = match turning {
686            Turning::Probed => Vec::new(),
687            Turning::WholeShells => shells_of_several(model, shape)?,
688        };
689        for (set, inward) in flags.sets.iter().zip(inward) {
690            let facing_in: ogeom_core::FastSet<Occurrence> = set
691                .iter()
692                .filter(|(_, against)| *against != inward)
693                .map(|(f, _)| (f.node(), f.location().clone()))
694                .collect();
695            if turning == Turning::WholeShells
696                && !facing_in.is_empty()
697                && !shells.contains(&facing_in)
698            {
699                return Ok(None);
700            }
701            turned.extend(facing_in);
702        }
703    }
704    // A face no closed form speaks for is drawn as its flag says, and one
705    // the probe finds facing in has no walk of its own to say how its
706    // drawing stands against its neighbours': the whole-shape mesh, which
707    // mends the turns within each piece, measures it instead.
708    if unspoken
709        .iter()
710        .any(|&at| turned.contains(&(faces[at].node(), faces[at].location().clone())))
711    {
712        return Ok(None);
713    }
714    let sign: Vec<f64> = region_of
715        .iter()
716        .map(|&at| {
717            let face = &faces[at];
718            if turned.contains(&(face.node(), face.location().clone())) {
719                -1.0
720            } else {
721                1.0
722            }
723        })
724        .collect();
725
726    let reference = reference_point(&exact, tol)?;
727    // Where integrals are kept, each region is known by its face's content,
728    // its place among the face's regions, and the reference.
729    let keys: Vec<Option<Vec<u8>>> = match kept {
730        Some(_) => {
731            let mut place = 0_u64;
732            region_of
733                .iter()
734                .enumerate()
735                .map(|(i, &at)| {
736                    place = if i > 0 && region_of[i - 1] == at {
737                        place + 1
738                    } else {
739                        0
740                    };
741                    contents[at].as_ref().map(|content| {
742                        let mut key = content.clone();
743                        key.extend_from_slice(&place.to_le_bytes());
744                        for x in [reference.x, reference.y, reference.z] {
745                            key.extend_from_slice(&x.to_bits().to_le_bytes());
746                        }
747                        key
748                    })
749                })
750                .collect()
751        }
752        None => vec![None; exact.len()],
753    };
754    let held = |i: usize| {
755        let key = keys[i].as_ref()?;
756        kept?.integral(key)
757    };
758    // Each face's moments are summed on their own, and added in the faces'
759    // order.
760    let summed = ogeom_core::parallel::map_ordered(&exact, |i, face| {
761        if let Some(found) = held(i) {
762            return Ok(found);
763        }
764        let found = integrate_face(
765            face,
766            crate::mass_chart::Measure::Volume,
767            reference,
768            tol,
769            Moments::zero,
770            |sums, p, n_da, share| {
771                let n_da = n_da * share;
772                let q = p - reference;
773                sums.mass += q.dot(n_da) / 3.0;
774                sums.first += Vector::new(
775                    q.x * q.x * n_da.x / 2.0,
776                    q.y * q.y * n_da.y / 2.0,
777                    q.z * q.z * n_da.z / 2.0,
778                );
779                let d = [q.x, q.y, q.z];
780                let nd = [n_da.x, n_da.y, n_da.z];
781                for i in 0..3 {
782                    // Diagonal: int q_i^2 dV = surface int q_i^3 n_i / 3.
783                    sums.second.rows[i][i] += d[i] * d[i] * d[i] * nd[i] / 3.0;
784                    // Off-diagonal: int q_i q_j dV = surface int q_i^2 q_j n_i / 2.
785                    for j in 0..3 {
786                        if i != j {
787                            sums.second.rows[i][j] += d[i] * d[i] * d[j] * nd[i] / 2.0;
788                        }
789                    }
790                }
791            },
792            Moments::add,
793        );
794        if let (Ok(found), Some(kept), Some(key)) = (&found, kept, &keys[i]) {
795            kept.keep_integral(key.clone(), found.clone());
796        }
797        found
798    });
799    // A region whose integral does not settle (a sliver its rim meets
800    // tangentially, whose panels never agree) leaves its face to the face's
801    // own mesh: the flux through a closed boundary is the sum of each
802    // face's, whatever measures it. Every other face keeps its closed form.
803    let mut settled: Vec<Option<Moments>> = Vec::with_capacity(exact.len());
804    for found in summed {
805        settled.push(or_mesh(found, None)?);
806    }
807    let mut meshed: Vec<usize> = (0..exact.len())
808        .filter(|&i| settled[i].is_none())
809        .map(|i| region_of[i])
810        .chain(unspoken)
811        .collect();
812    meshed.sort_unstable();
813    meshed.dedup();
814    let mut total = Moments::zero();
815    for (i, found) in settled.iter().enumerate() {
816        if let Some(sums) = found
817            && !meshed.contains(&region_of[i])
818        {
819            total.add_signed(sums, sign[i]);
820        }
821    }
822    for &at in &meshed {
823        // A face's mesh stands off its surface by up to the chord all over,
824        // and the flux through it off by the chord over its area; the faces
825        // round it are exact, so it alone is drawn a tenth as coarse.
826        let finer = Deflection {
827            chord: deflection.chord * MESHED_FACE_CHORD,
828            ..deflection
829        };
830        // The face's own mesh, ending on its own edges' chords. The
831        // whole-shape mesh welds and seals across openings narrower than
832        // its chord, and asked of one face it closes a sliver thinner than
833        // that across its rims. A face that draws nothing alone leaves the
834        // whole shape to the mesh.
835        let Some(mesh) = or_mesh(
836            ogeom_mesh::triangulate_face(model, &faces[at], finer, tol).map(Some),
837            None,
838        )?
839        else {
840            return Ok(None);
841        };
842        let mut acc = Accumulator::new();
843        for triangle in &mesh.triangles {
844            let [a, b, c] = triangle.map(|i| mesh.positions[i as usize]);
845            let volume = (a - reference).dot((b - reference).cross(c - reference)) / 6.0;
846            acc.add(&[reference, a, b, c], volume);
847        }
848        rim_slivers(model, &faces[at], &mesh, reference, &mut acc, tol)?;
849        let face = &faces[at];
850        let face_sign = if turned.contains(&(face.node(), face.location().clone())) {
851            -1.0
852        } else {
853            1.0
854        };
855        total.add_signed(
856            &Moments {
857                mass: acc.mass,
858                first: acc.first,
859                second: acc.second,
860            },
861            face_sign,
862        );
863    }
864    let Moments {
865        mass,
866        first,
867        mut second,
868    } = total;
869    // The off-diagonal identity fills each pair twice, once from each axis;
870    // average them, which also symmetrizes rounding.
871    for i in 0..3 {
872        for j in (i + 1)..3 {
873            let mean = f64::midpoint(second.rows[i][j], second.rows[j][i]);
874            second.rows[i][j] = mean;
875            second.rows[j][i] = mean;
876        }
877    }
878    if mass < 0.0 {
879        ogeom_bail!(
880            Construction,
881            "the boundary is wound inward, so the volume came out negative"
882        );
883    }
884    let acc = Accumulator {
885        reference: Some(reference),
886        mass,
887        first,
888        second,
889    };
890    Ok(Some(acc.finish(if meshed.is_empty() {
891        0.0
892    } else {
893        deflection.chord
894    })))
895}
896
897/// Surface area and its distribution, on the exact surfaces.
898fn exact_surface_properties(
899    model: &Model,
900    shape: &Shape,
901    tol: Tolerances,
902) -> OgeomResult<Option<MassProperties>> {
903    let faces = explore(model, shape, Filter::OfType(ShapeType::Face))?;
904    if faces.is_empty() {
905        return Ok(None);
906    }
907    let mut exact = Vec::with_capacity(faces.len());
908    let prepared =
909        ogeom_core::parallel::map_ordered(&faces, |_, face| integrable_face(model, face, tol));
910    for found in prepared {
911        match or_mesh(found, None)? {
912            Some(found) => exact.extend(found),
913            None => return Ok(None),
914        }
915    }
916    let reference = reference_point(&exact, tol)?;
917    let mut total = Moments::zero();
918    for face in &exact {
919        let found = integrate_face(
920            face,
921            crate::mass_chart::Measure::Area,
922            reference,
923            tol,
924            Moments::zero,
925            |sums, p, n_da, share| {
926                let da = n_da.magnitude() * share;
927                let q = p - reference;
928                sums.mass += da;
929                sums.first += q * da;
930                for (i, qi) in [q.x, q.y, q.z].iter().enumerate() {
931                    for (j, qj) in [q.x, q.y, q.z].iter().enumerate() {
932                        sums.second.rows[i][j] += qi * qj * da;
933                    }
934                }
935            },
936            Moments::add,
937        );
938        match or_mesh(found, None)? {
939            Some(sums) => total.add(&sums),
940            None => return Ok(None),
941        }
942    }
943    let Moments {
944        mass,
945        first,
946        second,
947    } = total;
948    let acc = Accumulator {
949        reference: Some(reference),
950        mass,
951        first,
952        second,
953    };
954    Ok(Some(acc.finish(0.0)))
955}
956
957/// A measure and its first and second moments about the reference, summed
958/// over one face's samples or over the faces.
959#[derive(Clone)]
960struct Moments {
961    mass: f64,
962    first: Vector,
963    second: Matrix3,
964}
965
966impl Moments {
967    const fn zero() -> Self {
968        Self {
969            mass: 0.0,
970            first: Vector::ZERO,
971            second: Matrix3::ZERO,
972        }
973    }
974
975    fn add(&mut self, other: &Self) {
976        self.add_signed(other, 1.0);
977    }
978
979    /// Add `other` times `sign`: `-1` for a face turned over.
980    fn add_signed(&mut self, other: &Self, sign: f64) {
981        self.mass += other.mass * sign;
982        self.first += other.first * sign;
983        self.second = add(self.second, scale_matrix(other.second, sign));
984    }
985}
986
987/// An exact path's answer, or `fallback` where the closed forms could not
988/// evaluate (a chart point a hair off its surface's domain): the mesh then
989/// answers, for volumes and areas alike. A cancelled watch and a broken
990/// model are errors whichever path meets them.
991fn or_mesh<T>(result: OgeomResult<T>, fallback: T) -> OgeomResult<T> {
992    match result {
993        Ok(value) => Ok(value),
994        Err(e @ (ogeom_core::OgeomError::Cancelled | ogeom_core::OgeomError::Dangling(_))) => {
995            Err(e)
996        }
997        Err(_) => Ok(fallback),
998    }
999}
1000
1001/// Whether the exact path says which faces it leaves to the mesh. Read once.
1002static DEBUG_MASS: std::sync::LazyLock<bool> =
1003    std::sync::LazyLock::new(|| std::env::var_os("OGEOM_DEBUG_MASS").is_some());
1004
1005/// The slivers between a face's mesh and its edges, added to `acc`.
1006///
1007/// A face measured on its own mesh ends on its boundary's chords, and its
1008/// neighbours, measured in closed form, end on the edges themselves: the
1009/// flux through the strip between each chord and its arc belongs to the
1010/// face and is not in its triangles. Each chord along an edge is closed
1011/// against its arc by a fan from its start, the arc drawn fine enough
1012/// that what is left between them is below the confusion distance.
1013fn rim_slivers(
1014    model: &Model,
1015    face: &Shape,
1016    mesh: &ogeom_topo::Triangulation,
1017    reference: Point,
1018    acc: &mut Accumulator,
1019    tol: Tolerances,
1020) -> OgeomResult<()> {
1021    let bits = |p: Point| [p.x.to_bits(), p.y.to_bits(), p.z.to_bits()];
1022    let mut sides: ogeom_core::FastSet<([u64; 3], [u64; 3])> = ogeom_core::FastSet::default();
1023    for t in &mesh.triangles {
1024        for k in 0..3 {
1025            let (a, b) = (
1026                mesh.positions[t[k] as usize],
1027                mesh.positions[t[(k + 1) % 3] as usize],
1028            );
1029            sides.insert((bits(a), bits(b)));
1030        }
1031    }
1032    let mut curves = Vec::new();
1033    for edge in explore_unique(model, face, ShapeType::Edge)? {
1034        let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
1035            continue;
1036        };
1037        if data.degenerate {
1038            continue;
1039        }
1040        let Some(EdgeRepr::Curve3d { curve, .. }) = data.curve3d() else {
1041            continue;
1042        };
1043        let Some(geometry) = model.geometry().curve(*curve) else {
1044            continue;
1045        };
1046        let placement = edge.transform(model.datums())?;
1047        // A chord ends on its edge's vertices, which may stand off the
1048        // curve by as much as the edge's tolerance says.
1049        let near = (data.tolerance.get() * 2.0).max(tol.confusion() * 10.0);
1050        curves.push((geometry.clone().transformed(&placement, tol)?, near));
1051    }
1052    let mut corners = Vec::new();
1053    for vertex in explore_unique(model, face, ShapeType::Vertex)? {
1054        if let Some(data) = model.node(&vertex).and_then(|n| n.data().as_vertex()) {
1055            corners.push(vertex.transform(model.datums())?.apply(data.point));
1056        }
1057    }
1058    for t in &mesh.triangles {
1059        for k in 0..3 {
1060            let (a, b) = (
1061                mesh.positions[t[k] as usize],
1062                mesh.positions[t[(k + 1) % 3] as usize],
1063            );
1064            // A side whose twin runs the other way is inside the face, or on
1065            // a seam it meets itself across.
1066            if a.distance(b) <= tol.confusion() || sides.contains(&(bits(b), bits(a))) {
1067                continue;
1068            }
1069            // Where a rim meets another tangentially, both ends can lie on
1070            // two curves: the arc is the one hugging the chord.
1071            let Some(arc) = curves
1072                .iter()
1073                .filter_map(|(curve, near)| arc_of(curve, a, b, *near, tol))
1074                .min_by(|x, y| x.0.total_cmp(&y.0))
1075                .map(|(_, arc)| arc)
1076                .or_else(|| round_a_corner(&curves, &corners, a, b, tol))
1077            else {
1078                continue;
1079            };
1080            for pair in arc.windows(2) {
1081                let (p, q) = (pair[0], pair[1]);
1082                let volume = (a - reference).dot((p - reference).cross(q - reference)) / 6.0;
1083                acc.add(&[reference, a, p, q], volume);
1084            }
1085        }
1086    }
1087    Ok(())
1088}
1089
1090/// The points of `curve` from `a` to `b`, both of which lie on it, drawn
1091/// so finely that no stretch between them stands off its chord by more
1092/// than the confusion distance, with how far the arc's middle stands off
1093/// the chord's; `None` where `a` or `b` is off it, or the arc between
1094/// them strays further from the chord than the chord is long.
1095fn arc_of(
1096    curve: &ogeom_geom::Curve,
1097    a: Point,
1098    b: Point,
1099    near: f64,
1100    tol: Tolerances,
1101) -> Option<(f64, Vec<Point>)> {
1102    use ogeom_geom::Curve3d as _;
1103    let at = |p: Point| {
1104        crate::project_on_curve(curve, p, 64, tol)
1105            .ok()
1106            .filter(|found| found.distance <= near)
1107            .map(|found| found.parameter)
1108    };
1109    let (ta, tb) = (at(a)?, at(b)?);
1110    // Of the two ways round a closed curve, the one whose middle is
1111    // nearer the chord's. The way past either end of a closed curve runs
1112    // on from the other, which a curve that does not repeat is read at.
1113    let mut tb = tb;
1114    let closed = curve.is_closed(tol);
1115    let (lo, hi) = curve.domain();
1116    let period = hi - lo;
1117    let point_at = |t: f64| {
1118        let t = if closed && period > 0.0 && !curve.is_periodic() {
1119            lo + (t - lo).rem_euclid(period)
1120        } else {
1121            t
1122        };
1123        curve.point_at(t, tol)
1124    };
1125    if closed {
1126        let middle = a.midpoint(b);
1127        let other = if tb > ta { tb - period } else { tb + period };
1128        let off =
1129            |t: f64| point_at(f64::midpoint(ta, t)).map_or(f64::INFINITY, |p| p.distance(middle));
1130        if off(other) < off(tb) {
1131            tb = other;
1132        }
1133    }
1134    let sag = point_at(f64::midpoint(ta, tb))
1135        .ok()?
1136        .distance(a.midpoint(b));
1137    if sag > a.distance(b) {
1138        return None;
1139    }
1140    // A chord's sag falls as the square of its share of the arc.
1141    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1142    let pieces = ((sag / tol.confusion()).sqrt().ceil() as usize).clamp(1, MAX_RIM_PIECES);
1143    let mut points = Vec::with_capacity(pieces + 1);
1144    points.push(a);
1145    for k in 1..pieces {
1146        #[allow(clippy::cast_precision_loss)]
1147        let f = k as f64 / pieces as f64;
1148        points.push(point_at(ta + (tb - ta) * f).ok()?);
1149    }
1150    points.push(b);
1151    Some((sag, points))
1152}
1153
1154/// The face's boundary from `a` on one curve round a corner to `b` on
1155/// another: a chord the mesh drew across the corner where the two meet,
1156/// as it may at the tip of a sliver whose sides meet tangentially.
1157fn round_a_corner(
1158    curves: &[(ogeom_geom::Curve, f64)],
1159    corners: &[Point],
1160    a: Point,
1161    b: Point,
1162    tol: Tolerances,
1163) -> Option<Vec<Point>> {
1164    for (first, near_first) in curves {
1165        for (second, near_second) in curves {
1166            for &corner in corners {
1167                let Some((_, to)) = arc_of(first, a, corner, *near_first, tol) else {
1168                    continue;
1169                };
1170                let Some((_, on)) = arc_of(second, corner, b, *near_second, tol) else {
1171                    continue;
1172                };
1173                return Some(to.into_iter().chain(on.into_iter().skip(1)).collect());
1174            }
1175        }
1176    }
1177    None
1178}
1179
1180/// The share of the caller's chord a face measured on its own mesh, among
1181/// faces measured exactly, is drawn to.
1182const MESHED_FACE_CHORD: f64 = 0.1;
1183
1184/// How many pieces [`arc_of`] draws one chord's arc in at most.
1185const MAX_RIM_PIECES: usize = 4096;
1186
1187/// Somewhere on the shape to measure moments from.
1188fn reference_point(faces: &[ExactFace], tol: Tolerances) -> OgeomResult<Point> {
1189    use ogeom_geom::Surface as _;
1190    match &faces[0] {
1191        ExactFace::ChartRectangle {
1192            surface,
1193            rect,
1194            split,
1195            ..
1196        } => Ok(split.point(surface.point_at(rect.0, rect.2, tol)?)),
1197        ExactFace::Disc { centre, split, .. } => Ok(split.point(*centre)),
1198        ExactFace::Chart(chart) => chart.anchor(tol),
1199    }
1200}
1201
1202/// Sum every quadrature sample of a face into an accumulator from `fresh`;
1203/// `None` where the face's rule did not settle on what `measure` sums.
1204///
1205/// `contribute` receives the world point, the outward-signed `n dA`
1206/// already weighted, and the region's share; summing those contributions
1207/// *is* the integral.
1208fn integrate_face<A: Clone>(
1209    face: &ExactFace,
1210    measure: crate::mass_chart::Measure,
1211    reference: Point,
1212    tol: Tolerances,
1213    fresh: impl Fn() -> A,
1214    contribute: impl Fn(&mut A, Point, Vector, f64),
1215    merge: impl Fn(&mut A, &A),
1216) -> OgeomResult<Option<A>> {
1217    let share = face.share();
1218    use ogeom_geom::Surface as _;
1219    const QUARTER: f64 = core::f64::consts::FRAC_PI_2;
1220    let mut sums = fresh();
1221    match face {
1222        ExactFace::Chart(chart) => {
1223            Ok(chart.integrate(measure, reference, tol, fresh, contribute, merge))
1224        }
1225        ExactFace::ChartRectangle {
1226            surface,
1227            rect,
1228            sign,
1229            split,
1230            ..
1231        } => {
1232            let (u0, u1, v0, v1) = *rect;
1233            // Panels no wider than a quarter turn along a parameter that may
1234            // be an angle, and a spline's also cut at its knots. A length
1235            // parameter (a plane's, a drum's, cone's or extrusion's height)
1236            // carries a polynomial integrand the rule takes in one panel
1237            // however long it runs.
1238            let (angular_u, angular_v) = match surface {
1239                ogeom_geom::SurfaceGeometry::Plane(_) => (false, false),
1240                ogeom_geom::SurfaceGeometry::Cylinder(_)
1241                | ogeom_geom::SurfaceGeometry::Cone(_)
1242                | ogeom_geom::SurfaceGeometry::Extrusion(_) => (true, false),
1243                _ => (true, true),
1244            };
1245            let breaks =
1246                |lo: f64, hi: f64, angular: bool, knots: Option<&ogeom_math::KnotVector>| {
1247                    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1248                    let panels = if angular {
1249                        ((hi - lo) / QUARTER).ceil().clamp(1.0, 64.0) as usize
1250                    } else {
1251                        1
1252                    };
1253                    #[allow(clippy::cast_precision_loss)]
1254                    let mut out: Vec<f64> = (0..=panels)
1255                        .map(|i| lo + (hi - lo) * i as f64 / panels as f64)
1256                        .collect();
1257                    if let Some(knots) = knots {
1258                        out.extend(
1259                            knots
1260                                .distinct()
1261                                .into_iter()
1262                                .map(|(k, _)| k)
1263                                .filter(|k| *k > lo && *k < hi),
1264                        );
1265                        out.sort_by(f64::total_cmp);
1266                        out.dedup_by(|a, b| (*a - *b).abs() <= 1e-14);
1267                    }
1268                    out
1269                };
1270            // A swept curve's knots stand across its sweep: in `u` for an
1271            // extrusion, in `v` for a revolution.
1272            fn curve_knots(curve: &ogeom_geom::Curve) -> Option<&ogeom_math::KnotVector> {
1273                match curve {
1274                    ogeom_geom::Curve::BSpline(b) => Some(b.knots()),
1275                    ogeom_geom::Curve::Trimmed(t) => curve_knots(t.basis()),
1276                    _ => None,
1277                }
1278            }
1279            let (u_knots, v_knots) = match surface {
1280                ogeom_geom::SurfaceGeometry::BSpline(b) => (Some(b.u_knots()), Some(b.v_knots())),
1281                ogeom_geom::SurfaceGeometry::Extrusion(e) => (curve_knots(e.curve()), None),
1282                ogeom_geom::SurfaceGeometry::Revolution(r) => (None, curve_knots(r.curve())),
1283                _ => (None, None),
1284            };
1285            let (u_breaks, v_breaks) = (
1286                breaks(u0, u1, angular_u, u_knots),
1287                breaks(v0, v1, angular_v, v_knots),
1288            );
1289            let mut failure = None;
1290            for uw in u_breaks.windows(2) {
1291                let (ua, ub) = (uw[0], uw[1]);
1292                for vw in v_breaks.windows(2) {
1293                    let (va, vb) = (vw[0], vw[1]);
1294                    // Nested Gauss with the callback fed directly: the outer
1295                    // integrand returns 0 and the samples carry the payload,
1296                    // with the weights recovered from unit integrands.
1297                    gauss2(ua, ub, va, vb, &mut |u, v, weight| {
1298                        if failure.is_some() {
1299                            return;
1300                        }
1301                        let sample = (|| -> OgeomResult<()> {
1302                            let (p, du, dv) = surface.point_d1_at(u, v, tol)?;
1303                            let n_da = du.cross(dv) * (sign * weight);
1304                            contribute(&mut sums, split.point(p), split.area(n_da), share);
1305                            Ok(())
1306                        })();
1307                        if let Err(e) = sample {
1308                            failure = Some(e);
1309                        }
1310                    });
1311                }
1312            }
1313            match failure {
1314                Some(e) => Err(e),
1315                None => Ok(Some(sums)),
1316            }
1317        }
1318        ExactFace::Disc {
1319            centre,
1320            e1,
1321            e2,
1322            normal,
1323            radius,
1324            sign,
1325            split,
1326            ..
1327        } => {
1328            let failure: Option<ogeom_core::OgeomError> = None;
1329            let turns = 4;
1330            for k in 0..turns {
1331                #[allow(clippy::cast_precision_loss)]
1332                let (ta, tb) = (
1333                    core::f64::consts::TAU * k as f64 / turns as f64,
1334                    core::f64::consts::TAU * (k + 1) as f64 / turns as f64,
1335                );
1336                gauss2(0.0, *radius, ta, tb, &mut |rho, theta, weight| {
1337                    if failure.is_some() {
1338                        return;
1339                    }
1340                    let p = *centre + (*e1 * theta.cos() + *e2 * theta.sin()) * rho;
1341                    let n_da = *normal * (sign * rho * weight);
1342                    contribute(&mut sums, split.point(p), split.area(n_da), share);
1343                });
1344            }
1345            match failure {
1346                Some(e) => Err(e),
1347                None => Ok(Some(sums)),
1348            }
1349        }
1350    }
1351}
1352
1353/// A tensor-product ten-by-ten Gauss rule over `[a,b] x [c,d]`, feeding each
1354/// sample and its weight to the callback.
1355fn gauss2(a: f64, b: f64, c: f64, d: f64, f: &mut dyn FnMut(f64, f64, f64)) {
1356    let us = ogeom_math::gauss_legendre_rule(a, b);
1357    let vs = ogeom_math::gauss_legendre_rule(c, d);
1358    for &(u, wu) in &us {
1359        for &(v, wv) in &vs {
1360            f(u, v, wu * wv);
1361        }
1362    }
1363}
1364
1365/// A face's regions in closed form where its surface and trim allow, and
1366/// otherwise its chart loops for integrating round; `None` where neither
1367/// can be had.
1368fn integrable_face(
1369    model: &Model,
1370    face: &Shape,
1371    tol: Tolerances,
1372) -> OgeomResult<Option<Vec<ExactFace>>> {
1373    // A chart rectangle or disc takes its region from the pcurves alone,
1374    // so a face with a pcurve standing wide of its edge's curve goes round
1375    // its chart loops, which close the strip between them.
1376    if !crate::mass_chart::runs_wide_of_an_edge(model, face, tol)?
1377        && let Some(found) = exact_face(model, face, tol)?
1378    {
1379        return Ok(Some(found));
1380    }
1381    Ok(crate::mass_chart::chart_face(model, face, tol)
1382        .map(|chart| vec![ExactFace::Chart(Box::new(chart))]))
1383}
1384
1385/// The exact-integrable regions of one face, or `None` where there are
1386/// none.
1387///
1388/// One region per wire, and the integral is their sum: a face's outer
1389/// boundary carries its own sign and every inner one the opposite, which
1390/// is what a hole *is* under the divergence theorem. So a plate with a
1391/// bore in it is a rectangle less a disc, and a tube's end face a disc
1392/// less a disc, neither of which needs meshing.
1393fn exact_face(model: &Model, face: &Shape, tol: Tolerances) -> OgeomResult<Option<Vec<ExactFace>>> {
1394    let Some(node) = model.node(face) else {
1395        return Ok(None);
1396    };
1397    let NodeData::Face(data) = node.data() else {
1398        return Ok(None);
1399    };
1400    let Some(surface) = model.geometry().surface(data.surface) else {
1401        return Ok(None);
1402    };
1403    let analytic = matches!(
1404        surface,
1405        ogeom_geom::SurfaceGeometry::Plane(_)
1406            | ogeom_geom::SurfaceGeometry::Cylinder(_)
1407            | ogeom_geom::SurfaceGeometry::Cone(_)
1408            | ogeom_geom::SurfaceGeometry::Sphere(_)
1409            | ogeom_geom::SurfaceGeometry::Torus(_)
1410            // A spline trimmed by its chart's own borders: a rectangle,
1411            // integrated knot span by knot span, where each span is one
1412            // polynomial piece the Gauss rule takes exactly.
1413            | ogeom_geom::SurfaceGeometry::BSpline(_)
1414            | ogeom_geom::SurfaceGeometry::Extrusion(_)
1415            | ogeom_geom::SurfaceGeometry::Revolution(_)
1416    );
1417    if !analytic {
1418        return Ok(None);
1419    }
1420    // The chart rectangle comes from the pcurves, whose windows are the
1421    // unplaced surface's: a rigid placement keeps them, and the scale is
1422    // applied to the samples. A reflecting placement turns the placed
1423    // chart's normal against the face's.
1424    let Some(split) = crate::mass_chart::Split::of(&face.transform(model.datums())?) else {
1425        return Ok(None);
1426    };
1427    let placed = surface.clone().transformed(split.rigid(), tol)?;
1428    let sign = split.handedness()
1429        * if face.orientation() == ogeom_topo::Orientation::Reversed {
1430            -1.0
1431        } else {
1432            1.0
1433        };
1434
1435    let wires = model.ordered_children_of(face)?;
1436    // A face with no wires covers its surface's whole domain, as the mesh
1437    // draws it: the domain rectangle where the domain is bounded, and
1438    // nothing to integrate where it is not.
1439    if wires.is_empty() {
1440        use ogeom_geom::Surface as _;
1441        let ((u0, u1), (v0, v1)) = surface.domain();
1442        if ![u0, u1, v0, v1].iter().all(|x| x.is_finite()) {
1443            return Ok(None);
1444        }
1445        return Ok(Some(vec![ExactFace::ChartRectangle {
1446            surface: placed,
1447            rect: (u0, u1, v0, v1),
1448            sign,
1449            share: 1.0,
1450            split,
1451        }]));
1452    }
1453    // One region per wire, and the integral is their sum: a face's outer
1454    // boundary carries its own sign and every inner one the opposite, which
1455    // is what a hole *is* under the divergence theorem. So a plate with a
1456    // bore is a rectangle less a disc, and a tube's end face a disc less a
1457    // disc. Which wire is the boundary and which the holes is settled by
1458    // the chart each covers: a hole is inside the boundary it is a hole in,
1459    // so it covers less.
1460    let mut regions = Vec::with_capacity(wires.len());
1461    for wire in &wires {
1462        let Some(region) = exact_wire(model, data, &placed, split, wire, sign, 1.0, tol)? else {
1463            return Ok(None);
1464        };
1465        regions.push(region);
1466    }
1467    let Some(outer) = (0..regions.len()).max_by(|a, b| {
1468        regions[*a]
1469            .chart_area()
1470            .total_cmp(&regions[*b].chart_area())
1471    }) else {
1472        return Ok(None);
1473    };
1474    for (index, region) in regions.iter_mut().enumerate() {
1475        if index != outer {
1476            region.take_away();
1477        }
1478    }
1479    Ok(Some(regions))
1480}
1481
1482/// An edge or face occurrence: its node at its placement.
1483type Occurrence = (ogeom_topo::TShapeId, ogeom_topo::Location);
1484
1485/// Edge curves placed once for [`flags_agree`], with their ranges, by edge
1486/// occurrence: each is read at several stations of each face it bounds.
1487type PlacedCurves = ogeom_core::FastMap<Occurrence, (ogeom_geom::Curve, (f64, f64))>;
1488
1489/// Whether the faces agree with each other about which way is out.
1490///
1491/// The flag on a face is the only thing that says which side of its surface
1492/// the material is on; no winding in this kernel says it, and the wires
1493/// are wound however their builder wound them. But the flags can be asked
1494/// *about each other*: an edge between two faces is walked by one of them
1495/// with the material on its left and by the other with the material on its
1496/// left too, so the two walks run opposite ways along it. Each face's walk
1497/// is its outward normal crossed into the direction the material lies from
1498/// the edge, and both of those are had for the asking: the normal from the
1499/// flag, the material's direction from the chart, since a face's region
1500/// lies around the middle of the boundary that encloses it.
1501///
1502/// A face whose flag points into the solid (a bore wall turned over, a
1503/// lid sewn to walls it was not built with) walks its edges the same way
1504/// as its neighbours. Taken at its word, the closed-form integral would
1505/// count its share of the volume backwards. So two faces that walk an edge
1506/// the same way are tied as turned against each other, and the set they
1507/// fall into is listed for the caller to ask which way each face of it
1508/// faces. A loop of ties whose turns do not cancel cannot be one boundary
1509/// however its faces are turned, and is reported as disagreeing.
1510///
1511/// The comparison ties faces into sets, and says nothing about one set
1512/// against another: a face the walks cannot be read on (a station at a
1513/// cone's apex, where the surface has no normal, a trim that is no closed
1514/// loop) is a set of its own, and so is each closed shell. Turning a whole
1515/// set over turns its share of the volume over without any edge noticing,
1516/// so where there is more than one set, or a set holds faces turned
1517/// against each other, [`Flags::sets`] lists the faces of each, for the
1518/// caller to ask which way the set faces. A single set agreeing throughout
1519/// and turned over as a whole is the whole boundary wound inward, and the
1520/// volume's sign says so.
1521///
1522/// An edge occurrence is its node at its placement: the top and bottom of
1523/// a prism are one edge moved, and each is compared only with the faces
1524/// that meet it where it stands.
1525pub(crate) fn flags_agree(model: &Model, shape: &Shape, tol: Tolerances) -> OgeomResult<Flags> {
1526    // Moving the whole shape moves every face with it and changes nothing
1527    // about whether they agree.
1528    let unplaced = shape.located(ogeom_topo::Location::default());
1529    let faces = explore(model, &unplaced, Filter::OfType(ShapeType::Face))?;
1530    let mut walks: ogeom_core::FastMap<Occurrence, Vec<(bool, usize, bool)>> =
1531        ogeom_core::FastMap::default();
1532    // Each face walked on its own, with the edge curves it places, and
1533    // taken in the faces' order.
1534    let walked = ogeom_core::parallel::map_ordered(&faces, |_, face| {
1535        let mut curves: PlacedCurves = ogeom_core::FastMap::default();
1536        face_walks(model, face, &mut curves, tol)
1537    });
1538    for (index, (face, found)) in faces.iter().zip(walked).enumerate() {
1539        match found? {
1540            Some(found) => {
1541                for (edge, ahead, outer) in found {
1542                    walks.entry(edge).or_default().push((ahead, index, outer));
1543                }
1544            }
1545            None => {
1546                if *DEBUG_MASS {
1547                    eprintln!("MASS face {:?} cannot be walked", face.node());
1548                }
1549            }
1550        }
1551    }
1552    if *DEBUG_MASS {
1553        eprintln!("MASS flags_agree walked {} edges", walks.len());
1554    }
1555    // Which set each face is in, as a forest of parents, and whether each
1556    // face is turned against its parent.
1557    let mut parent: Vec<(usize, bool)> = (0..faces.len()).map(|at| (at, false)).collect();
1558    for ((edge, _), uses) in &walks {
1559        // An edge one face walks twice is that face's own seam, however it
1560        // is written down (a canonicalised drum keeps its as an ordinary
1561        // pcurve used twice), and one face's seam says nothing about
1562        // whether two faces agree.
1563        if uses.iter().all(|(_, owner, _)| *owner == uses[0].1) {
1564            continue;
1565        }
1566        // An edge between two faces that each walk it one way only ties
1567        // them: as they stand where they walk it opposite ways, one turned
1568        // against the other where they walk it the same way.
1569        let mut owners: Vec<(usize, bool)> = Vec::with_capacity(2);
1570        let mut uniform = true;
1571        for &(ahead, owner, _) in uses {
1572            match owners.iter().find(|(held, _)| *held == owner) {
1573                Some(&(_, way)) => uniform &= way == ahead,
1574                None => owners.push((owner, ahead)),
1575            }
1576        }
1577        let tie: Vec<(usize, bool)> = if uniform && owners.len() == 2 {
1578            vec![owners[0], (owners[1].0, owners[0].1 == owners[1].1)]
1579        } else {
1580            // Anything else (a face walking it both ways, three faces or
1581            // more round it) must walk it each way equally often.
1582            let ahead = uses.iter().filter(|(ahead, ..)| *ahead).count();
1583            if ahead * 2 != uses.len() {
1584                if *DEBUG_MASS {
1585                    eprintln!("MASS edge {} is walked {uses:?}", edge.index());
1586                }
1587                return Ok(Flags {
1588                    agree: false,
1589                    sets: Vec::new(),
1590                });
1591            }
1592            uses.iter().map(|&(_, owner, _)| (owner, false)).collect()
1593        };
1594        let (first, first_turned) = set_of(&mut parent, tie[0].0);
1595        for &(owner, against) in &tie[1..] {
1596            let (other, other_turned) = set_of(&mut parent, owner);
1597            // `owner` must stand turned against the first face by `against`.
1598            let turned = first_turned ^ other_turned ^ against;
1599            if other == first {
1600                if turned {
1601                    if *DEBUG_MASS {
1602                        eprintln!(
1603                            "MASS edge {} closes a loop of faces turned oddly",
1604                            edge.index()
1605                        );
1606                    }
1607                    return Ok(Flags {
1608                        agree: false,
1609                        sets: Vec::new(),
1610                    });
1611                }
1612                continue;
1613            }
1614            parent[other] = (first, turned);
1615        }
1616    }
1617    // Each set's faces in the order they were explored, named as the
1618    // caller's shape holds them, placement and all, each with whether it
1619    // is turned against the set's first face.
1620    let mut sets: Vec<Vec<(Shape, bool)>> = Vec::new();
1621    let mut set_at: ogeom_core::FastMap<usize, (usize, bool)> = ogeom_core::FastMap::default();
1622    let mut mixed = false;
1623    for (index, face) in faces.iter().enumerate() {
1624        let (root, turned) = set_of(&mut parent, index);
1625        let (at, first_turned) = *set_at.entry(root).or_insert_with(|| {
1626            sets.push(Vec::new());
1627            (sets.len() - 1, turned)
1628        });
1629        let against = turned != first_turned;
1630        mixed |= against;
1631        sets[at].push((face.moved(shape.location()), against));
1632    }
1633    if sets.len() < 2 && !mixed {
1634        sets.clear();
1635    }
1636    Ok(Flags { agree: true, sets })
1637}
1638
1639/// The face standing for the set `at` is in, and whether `at` is turned
1640/// against it, shortening the way there.
1641fn set_of(parent: &mut [(usize, bool)], at: usize) -> (usize, bool) {
1642    let mut path = Vec::new();
1643    let mut here = at;
1644    while parent[here].0 != here {
1645        path.push(here);
1646        here = parent[here].0;
1647    }
1648    let root = here;
1649    // From the root down, each face's turn against the root.
1650    for &face in path.iter().rev() {
1651        let (up, turned) = parent[face];
1652        let up_turned = if up == root { false } else { parent[up].1 };
1653        parent[face] = (root, turned ^ up_turned);
1654    }
1655    (root, if at == root { false } else { parent[at].1 })
1656}
1657
1658/// What [`flags_agree`] found.
1659pub(crate) struct Flags {
1660    /// Whether the walks tie every face consistently: no loop of faces
1661    /// round which the turns between neighbours fail to cancel, and no
1662    /// edge walked unevenly by three faces or more.
1663    pub agree: bool,
1664    /// Where the faces fall into more than one set, or a set holds faces
1665    /// turned against each other, the faces of each set, each with whether
1666    /// it is turned against the set's first face; empty where they are one
1667    /// set agreeing throughout.
1668    pub sets: Vec<Vec<(Shape, bool)>>,
1669}
1670
1671/// One station of a face's walk round its boundary: the edge occurrence,
1672/// whether the walk runs with the edge's own direction there, and whether
1673/// the station is on the face's outer boundary.
1674type EdgeWalk = (Occurrence, bool, bool);
1675
1676/// Each station of `face`'s walk along its edges; `None` where the walk
1677/// cannot be read on it.
1678fn face_walks(
1679    model: &Model,
1680    face: &Shape,
1681    curves: &mut PlacedCurves,
1682    tol: Tolerances,
1683) -> OgeomResult<Option<Vec<EdgeWalk>>> {
1684    use ogeom_geom::Curve2d as _;
1685    use ogeom_geom::Surface as _;
1686    let placed_at = ogeom_topo::Location::default();
1687    let mut walks = Vec::new();
1688    let Some(data) = model.node(face).and_then(|n| n.data().as_face()).cloned() else {
1689        return Ok(None);
1690    };
1691    let Some(surface) = model.geometry().surface(data.surface) else {
1692        return Ok(None);
1693    };
1694    let placement = face.transform(model.datums())?;
1695    let placed = surface.clone().transformed(&placement, tol)?;
1696    // A reflecting placement turns the placed chart's own normal against
1697    // the image of the face's, so the flag is read through it.
1698    let flag = if face.orientation() == ogeom_topo::Orientation::Reversed {
1699        -1.0
1700    } else {
1701        1.0
1702    } * if placement.preserves_handedness() {
1703        1.0
1704    } else {
1705        -1.0
1706    };
1707    // Where the boundary walks into closed chart loops, their windings
1708    // say which side the face lies on at every point, concave or not.
1709    if let Some(stations) = crate::mass_chart::material_sides(model, face, tol) {
1710        for (edge, at, toward) in stations {
1711            let at = crate::mass_chart::into_domain(&placed, at);
1712            let (du, dv) = placed.d1_at(at.x, at.y, tol)?;
1713            let raw = du.cross(dv);
1714            let inward = du * toward.x + dv * toward.y;
1715            if raw.magnitude() <= tol.angular() || inward.magnitude() <= tol.angular() {
1716                return Ok(None);
1717            }
1718            let out = raw / raw.magnitude() * flag;
1719            let walk = out.cross(inward / inward.magnitude());
1720            let station = placed.point_at(at.x, at.y, tol)?;
1721            let Some(along) = edge_heading(model, &edge, station, curves, tol)? else {
1722                return Ok(None);
1723            };
1724            walks.push((
1725                (edge.node(), edge.location().clone()),
1726                walk.dot(along) > 0.0,
1727                true,
1728            ));
1729        }
1730        return Ok(Some(walks));
1731    }
1732    // The pcurves read below are the unplaced face's.
1733    if face.location() != &placed_at {
1734        return Ok(None);
1735    }
1736    // Otherwise each wire's middle in the chart stands in for the side
1737    // the face lies on, and which wire is the boundary:
1738    // the one covering the most of it, since a hole is inside what it
1739    // is a hole in. The middle lies inside its wire's region only where
1740    // that region is convex. It is here: the closed form integrates any
1741    // other face round chart loops that close, which the windings above
1742    // read, so a face reaching this point is one it takes as a chart
1743    // rectangle or a disc, less rectangles and discs inside.
1744    let wires = model.ordered_children_of(face)?;
1745    let mut middles: Vec<(ogeom_math::Point2, f64)> = Vec::with_capacity(wires.len());
1746    let mut stations: Vec<Vec<(Shape, ogeom_math::Point2)>> = Vec::with_capacity(wires.len());
1747    // How often each edge bounds this face: a seam the face uses once
1748    // (a half band, cut along its seam) bounds it down one column only.
1749    let mut uses: ogeom_core::FastMap<ogeom_topo::TShapeId, usize> = ogeom_core::FastMap::default();
1750    for wire in &wires {
1751        for edge in model.ordered_children_of(wire)? {
1752            *uses.entry(edge.node()).or_default() += 1;
1753        }
1754    }
1755    for wire in &wires {
1756        let mut here = Vec::new();
1757        let mut sum = ogeom_math::Vector2::new(0.0, 0.0);
1758        let (mut lo, mut hi) = (
1759            ogeom_math::Point2::new(f64::INFINITY, f64::INFINITY),
1760            ogeom_math::Point2::new(f64::NEG_INFINITY, f64::NEG_INFINITY),
1761        );
1762        // Seams used once, their column chosen once the rest of the
1763        // wire says where the face lies: by the ends of the other
1764        // pieces, which meet the used column and not the other.
1765        let mut once: Vec<(Shape, [ogeom_topo::PCurveId; 2], (f64, f64))> = Vec::new();
1766        let mut ends: Vec<ogeom_math::Point2> = Vec::new();
1767        for edge in model.ordered_children_of(wire)? {
1768            if edge.location() != &placed_at {
1769                return Ok(None);
1770            }
1771            let Some(repr) = model
1772                .node(&edge)
1773                .and_then(|n| n.data().as_edge())
1774                .and_then(|d| d.pcurve_for(data.surface, edge.location()))
1775            else {
1776                return Ok(None);
1777            };
1778            // A seam bounds its face twice, once down each column.
1779            let sides: Vec<(ogeom_topo::PCurveId, (f64, f64))> = match repr {
1780                EdgeRepr::PCurve { curve, range, .. } => vec![(*curve, *range)],
1781                EdgeRepr::Seam {
1782                    forward,
1783                    reversed,
1784                    range,
1785                    ..
1786                } if uses.get(&edge.node()) == Some(&1) => {
1787                    once.push((edge.clone(), [*forward, *reversed], *range));
1788                    continue;
1789                }
1790                EdgeRepr::Seam {
1791                    forward,
1792                    reversed,
1793                    range,
1794                    ..
1795                } => vec![(*forward, *range), (*reversed, *range)],
1796                _ => return Ok(None),
1797            };
1798            for (id, range) in sides {
1799                let Some(pcurve) = model.geometry().pcurve(id) else {
1800                    return Ok(None);
1801                };
1802                ends.push(pcurve.point_at(range.0, tol)?);
1803                ends.push(pcurve.point_at(range.1, tol)?);
1804                // Several stations along each edge, not one: a wire of
1805                // a single closed edge has its own midpoint for a
1806                // middle, and nothing lies from a point toward itself.
1807                const STATIONS: usize = 4;
1808                for step in 1..=STATIONS {
1809                    #[allow(clippy::cast_precision_loss)]
1810                    let t = range.0 + (range.1 - range.0) * (step as f64 / (STATIONS + 1) as f64);
1811                    let at = pcurve.point_at(t, tol)?;
1812                    sum += at.to_vector();
1813                    lo = ogeom_math::Point2::new(lo.x.min(at.x), lo.y.min(at.y));
1814                    hi = ogeom_math::Point2::new(hi.x.max(at.x), hi.y.max(at.y));
1815                    here.push((edge.clone(), at));
1816                }
1817            }
1818        }
1819        if here.is_empty() && once.is_empty() {
1820            return Ok(None);
1821        }
1822        // A seam used once runs down the column its neighbours meet.
1823        for (edge, sides, range) in once {
1824            let mut best: Option<(f64, ogeom_topo::PCurveId)> = None;
1825            for id in sides {
1826                let Some(pcurve) = model.geometry().pcurve(id) else {
1827                    return Ok(None);
1828                };
1829                let mut d = f64::INFINITY;
1830                for t in [range.0, range.1] {
1831                    let at = pcurve.point_at(t, tol)?;
1832                    for end in &ends {
1833                        d = d.min(at.distance(*end));
1834                    }
1835                }
1836                if best.is_none_or(|(held, _)| d < held) {
1837                    best = Some((d, id));
1838                }
1839            }
1840            let Some((_, id)) = best else {
1841                return Ok(None);
1842            };
1843            let Some(pcurve) = model.geometry().pcurve(id) else {
1844                return Ok(None);
1845            };
1846            const STATIONS: usize = 4;
1847            for step in 1..=STATIONS {
1848                #[allow(clippy::cast_precision_loss)]
1849                let t = range.0 + (range.1 - range.0) * (step as f64 / (STATIONS + 1) as f64);
1850                let at = pcurve.point_at(t, tol)?;
1851                sum += at.to_vector();
1852                lo = ogeom_math::Point2::new(lo.x.min(at.x), lo.y.min(at.y));
1853                hi = ogeom_math::Point2::new(hi.x.max(at.x), hi.y.max(at.y));
1854                here.push((edge.clone(), at));
1855            }
1856        }
1857        #[allow(clippy::cast_precision_loss)]
1858        let middle = ogeom_math::Point2::ORIGIN + sum / here.len() as f64;
1859        middles.push((middle, (hi.x - lo.x) * (hi.y - lo.y)));
1860        stations.push(here);
1861    }
1862    let Some(outer) = (0..middles.len()).max_by(|a, b| middles[*a].1.total_cmp(&middles[*b].1))
1863    else {
1864        return Ok(None);
1865    };
1866    for (index, here) in stations.into_iter().enumerate() {
1867        let (middle, _) = middles[index];
1868        for (edge, at) in here {
1869            let at = crate::mass_chart::into_domain(&placed, at);
1870            let (du, dv) = placed.d1_at(at.x, at.y, tol)?;
1871            let raw = du.cross(dv);
1872            if raw.magnitude() <= tol.angular() {
1873                return Ok(None);
1874            }
1875            let out = raw / raw.magnitude() * flag;
1876            // Which way the material lies from this point of the
1877            // boundary: toward the wire's middle for the face's outer
1878            // wire, away from it for a hole.
1879            let toward = middle - at;
1880            let toward = if index == outer { toward } else { -toward };
1881            let inward = du * toward.x + dv * toward.y;
1882            if inward.magnitude() <= tol.angular() {
1883                return Ok(None);
1884            }
1885            let walk = out.cross(inward / inward.magnitude());
1886            // Against the edge's own direction, so the two faces'
1887            // answers can be compared without comparing vectors. The
1888            // direction is read where the edge's curve passes the
1889            // station, since neither a pcurve's parameter nor its sense
1890            // need be its curve's.
1891            let station = placed.point_at(at.x, at.y, tol)?;
1892            let Some(along) = edge_heading(model, &edge, station, curves, tol)? else {
1893                return Ok(None);
1894            };
1895            walks.push((
1896                (edge.node(), edge.location().clone()),
1897                walk.dot(along) > 0.0,
1898                index == outer,
1899            ));
1900        }
1901    }
1902    Ok(Some(walks))
1903}
1904
1905/// Whether the first face of each of `sets` faces into the solid it
1906/// bounds; `None` where no face of some set settles it.
1907///
1908/// A set agrees within itself, so any one of its faces answers for all of
1909/// them, and a face the probe cannot settle hands the question to the
1910/// next: the top of a sheet thinner than the probe's shortest step has
1911/// nothing but outside either side of it, while the sheet's rim answers.
1912/// One face of each set is asked first and more of each still unsettled
1913/// at every round after, in parallel. Each face is probed against its own
1914/// solid, not the whole shape: a compound's lumps may overlap, and a face
1915/// of one inside another has material on both its sides.
1916fn sets_facing_in(
1917    model: &Model,
1918    shape: &Shape,
1919    sets: &[Vec<(Shape, bool)>],
1920    tol: Tolerances,
1921) -> OgeomResult<Option<Vec<bool>>> {
1922    let solids = explore(model, shape, Filter::OfType(ShapeType::Solid))?;
1923    // Which solid each face bounds, by occurrence.
1924    let mut owner: ogeom_core::FastMap<Occurrence, usize> = ogeom_core::FastMap::default();
1925    let solids = if solids.is_empty() {
1926        vec![shape.clone()]
1927    } else {
1928        solids
1929    };
1930    for (at, solid) in solids.iter().enumerate() {
1931        for face in explore(model, solid, Filter::OfType(ShapeType::Face))? {
1932            owner
1933                .entry((face.node(), face.location().clone()))
1934                .or_insert(at);
1935        }
1936    }
1937    let mut boundaries: Vec<Option<crate::SolidBoundary>> = solids.iter().map(|_| None).collect();
1938    // Each unsettled set with its faces still to be asked: each face's
1939    // solid, the face, and whether it is turned against the set's first.
1940    type Asking<'a> = std::collections::VecDeque<(usize, &'a Shape, bool)>;
1941    let mut pending: Vec<(usize, Asking)> = Vec::with_capacity(sets.len());
1942    for (set, faces_of) in sets.iter().enumerate() {
1943        let mut faces = std::collections::VecDeque::with_capacity(faces_of.len());
1944        for (face, against) in faces_of {
1945            let Some(&at) = owner.get(&(face.node(), face.location().clone())) else {
1946                return Ok(None);
1947            };
1948            faces.push_back((at, face, *against));
1949        }
1950        pending.push((set, faces));
1951    }
1952    let mut inward = vec![false; sets.len()];
1953    let mut take = 1;
1954    loop {
1955        let mut asked: Vec<(usize, usize, &Shape, bool)> = Vec::new();
1956        for (set, faces) in &mut pending {
1957            for _ in 0..take {
1958                let Some((at, face, against)) = faces.pop_front() else {
1959                    break;
1960                };
1961                asked.push((*set, at, face, against));
1962            }
1963        }
1964        for &(_, at, ..) in &asked {
1965            if boundaries[at].is_none() {
1966                let Some(boundary) = or_mesh(
1967                    crate::check::probe_boundary(model, &solids[at], tol).map(Some),
1968                    None,
1969                )?
1970                else {
1971                    return Ok(None);
1972                };
1973                boundaries[at] = Some(boundary);
1974            }
1975        }
1976        let facing = ogeom_core::parallel::map_ordered(&asked, |_, &(_, at, face, _)| {
1977            let Some(boundary) = boundaries[at].as_ref() else {
1978                return Ok(None);
1979            };
1980            crate::check::faces_inward(model, face, boundary, tol)
1981        });
1982        let mut settled = vec![false; sets.len()];
1983        for (&(set, _, _, against), facing) in asked.iter().zip(facing) {
1984            let facing = or_mesh(facing, None)?;
1985            if *DEBUG_MASS {
1986                eprintln!("MASS set {set} probed {facing:?}");
1987            }
1988            if let Some(facing) = facing {
1989                // A face turned against the set's first answers for it
1990                // turned back.
1991                inward[set] = facing != against;
1992                settled[set] = true;
1993            }
1994        }
1995        // A set out of faces with none settled is left to the mesh.
1996        for (set, faces) in &pending {
1997            if !settled[*set] && faces.is_empty() {
1998                if *DEBUG_MASS {
1999                    eprintln!("MASS set {set}: no face settles it");
2000                }
2001                return Ok(None);
2002            }
2003        }
2004        pending.retain(|(set, _)| !settled[*set]);
2005        if pending.is_empty() {
2006            return Ok(Some(inward));
2007        }
2008        take *= 2;
2009    }
2010}
2011
2012/// The faces of every shell of a solid with more than one shell, each
2013/// shell's faces by occurrence.
2014fn shells_of_several(
2015    model: &Model,
2016    shape: &Shape,
2017) -> OgeomResult<Vec<ogeom_core::FastSet<Occurrence>>> {
2018    let mut out = Vec::new();
2019    for solid in explore(model, shape, Filter::OfType(ShapeType::Solid))? {
2020        let shells = explore(model, &solid, Filter::OfType(ShapeType::Shell))?;
2021        if shells.len() < 2 {
2022            continue;
2023        }
2024        for shell in shells {
2025            out.push(
2026                explore(model, &shell, Filter::OfType(ShapeType::Face))?
2027                    .iter()
2028                    .map(|f| (f.node(), f.location().clone()))
2029                    .collect(),
2030            );
2031        }
2032    }
2033    Ok(out)
2034}
2035
2036/// An edge's own direction in space where its curve passes nearest `at`:
2037/// the best of a sampling over the edge's range, narrowed by golden
2038/// sections, and the curve's tangent there.
2039fn edge_heading(
2040    model: &Model,
2041    edge: &Shape,
2042    at: Point,
2043    curves: &mut PlacedCurves,
2044    tol: Tolerances,
2045) -> OgeomResult<Option<Vector>> {
2046    use ogeom_geom::Curve3d as _;
2047    let key = (edge.node(), edge.location().clone());
2048    if !curves.contains_key(&key) {
2049        let Some((curve, range)) = model
2050            .node(edge)
2051            .and_then(|n| n.data().as_edge())
2052            .and_then(|d| match d.curve3d()? {
2053                EdgeRepr::Curve3d { curve, range, .. } => Some((*curve, *range)),
2054                _ => None,
2055            })
2056            .and_then(|(id, range)| Some((model.geometry().curve(id)?.clone(), range)))
2057        else {
2058            return Ok(None);
2059        };
2060        let placed = curve.transformed(&edge.transform(model.datums())?, tol)?;
2061        curves.insert(key.clone(), (placed, range));
2062    }
2063    let Some((curve, range)) = curves.get(&key) else {
2064        return Ok(None);
2065    };
2066    let range = *range;
2067    let gap = |t: f64| -> OgeomResult<f64> { Ok(curve.point_at(t, tol)?.distance(at)) };
2068    const SAMPLES: u32 = 32;
2069    let step = (range.1 - range.0) / f64::from(SAMPLES);
2070    let mut best = (range.0, gap(range.0)?);
2071    for k in 1..=SAMPLES {
2072        let t = range.0 + step * f64::from(k);
2073        let d = gap(t)?;
2074        if d < best.1 {
2075            best = (t, d);
2076        }
2077    }
2078    let (mut a, mut b) = (
2079        (best.0 - step.abs()).max(range.0.min(range.1)),
2080        (best.0 + step.abs()).min(range.0.max(range.1)),
2081    );
2082    // Golden section, each round keeping one of its two interior points.
2083    let ratio = (5.0_f64.sqrt() - 1.0) / 2.0;
2084    let (mut c, mut d) = (b - (b - a) * ratio, a + (b - a) * ratio);
2085    let (mut fc, mut fd) = (gap(c)?, gap(d)?);
2086    for _ in 0..60 {
2087        if fc < fd {
2088            b = d;
2089            (d, fd) = (c, fc);
2090            c = b - (b - a) * ratio;
2091            fc = gap(c)?;
2092        } else {
2093            a = c;
2094            (c, fc) = (d, fd);
2095            d = a + (b - a) * ratio;
2096            fd = gap(d)?;
2097        }
2098    }
2099    let along = curve.d1_at(f64::midpoint(a, b), tol)?;
2100    Ok((along.magnitude() > tol.angular()).then(|| along / along.magnitude()))
2101}
2102
2103/// The region one of a face's wires bounds, read off its pcurves.
2104#[allow(clippy::too_many_arguments)]
2105fn exact_wire(
2106    model: &Model,
2107    data: &ogeom_topo::FaceData,
2108    placed: &ogeom_geom::SurfaceGeometry,
2109    split: crate::mass_chart::Split,
2110    wire: &Shape,
2111    sign: f64,
2112    share: f64,
2113    tol: Tolerances,
2114) -> OgeomResult<Option<ExactFace>> {
2115    use ogeom_geom::Surface as _;
2116    // Gather each boundary edge's chart segments on this face.
2117    let mut segments: Vec<(ogeom_math::Point2, ogeom_math::Point2)> = Vec::new();
2118    // Where a seam bounds the chart: a column at that `u`, a row at that `v`.
2119    let mut columns: Vec<f64> = Vec::new();
2120    let mut rows: Vec<f64> = Vec::new();
2121    let mut circle: Option<(ogeom_geom::Circle2d, f64)> = None;
2122    // Where the circle's arcs start and stop, for asking whether they tile
2123    // its turn or merely add up to one.
2124    let mut arc_ends: Vec<ogeom_math::Point2> = Vec::new();
2125    let mut pieces = 0_usize;
2126    // A seam bounds the face twice; its two chart sides are gathered once.
2127    let mut seams_seen: Vec<ogeom_topo::TShapeId> = Vec::new();
2128    for edge in model.ordered_children_of(wire)? {
2129        let Some(edge_data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
2130            return Ok(None);
2131        };
2132        let Some(repr) = edge_data.pcurve_for(data.surface, edge.location()) else {
2133            return Ok(None);
2134        };
2135        pieces += 1;
2136        match repr {
2137            EdgeRepr::PCurve { curve, range, .. } => {
2138                let Some(pcurve) = model.geometry().pcurve(*curve) else {
2139                    return Ok(None);
2140                };
2141                match pcurve {
2142                    ogeom_geom::PlanarCurve::Line(_) => {
2143                        use ogeom_geom::Curve2d as _;
2144                        let a = pcurve.point_at(range.0, tol)?;
2145                        let b = pcurve.point_at(range.1, tol)?;
2146                        segments.push((a, b));
2147                    }
2148                    // A trim fitted along a chart column or row (a rail a
2149                    // strip adopted from its neighbour): every control
2150                    // point on the one line makes it that segment.
2151                    ogeom_geom::PlanarCurve::BSpline(spline)
2152                        if along_one_chart_line(spline.control_points()) =>
2153                    {
2154                        use ogeom_geom::Curve2d as _;
2155                        let a = pcurve.point_at(range.0, tol)?;
2156                        let b = pcurve.point_at(range.1, tol)?;
2157                        segments.push((a, b));
2158                    }
2159                    ogeom_geom::PlanarCurve::Circle(arc) => {
2160                        use ogeom_geom::Curve2d as _;
2161                        // One circle's arcs, however many pieces the
2162                        // boundary arrives in. A boolean splits a closed rim
2163                        // to give the arrangement's walker somewhere to
2164                        // start, even a rim it never touched, and the disc
2165                        // those arcs bound is the same disc the whole turn
2166                        // bounded. The spans are summed and the total asked
2167                        // for a turn, so a fan of arcs that does not close
2168                        // is still no disc.
2169                        let span = (range.1 - range.0).abs();
2170                        arc_ends.push(pcurve.point_at(range.0, tol)?);
2171                        arc_ends.push(pcurve.point_at(range.1, tol)?);
2172                        match &mut circle {
2173                            None => circle = Some((*arc, span)),
2174                            Some((held, total)) => {
2175                                let (a, b) = (held.circle(), arc.circle());
2176                                if a.centre().distance(b.centre()) > tol.confusion()
2177                                    || (a.radius() - b.radius()).abs() > tol.confusion()
2178                                {
2179                                    return Ok(None);
2180                                }
2181                                *total += span;
2182                            }
2183                        }
2184                    }
2185                    _ => return Ok(None),
2186                }
2187            }
2188            EdgeRepr::Seam {
2189                forward,
2190                reversed,
2191                range,
2192                ..
2193            } => {
2194                use ogeom_geom::Curve2d as _;
2195                if seams_seen.contains(&edge.node()) {
2196                    pieces -= 1;
2197                    continue;
2198                }
2199                seams_seen.push(edge.node());
2200                // A seam says where the chart's edge stands, not how far
2201                // along it the face reaches. A reader pads its pcurve's
2202                // domain and a boolean shortens the face without shortening
2203                // the seam, so its own extent is worth nothing; the rims
2204                // are what say how tall the chart is, and they are ordinary
2205                // edges with ordinary ranges.
2206                for id in [forward, reversed] {
2207                    let Some(pcurve) = model.geometry().pcurve(*id) else {
2208                        return Ok(None);
2209                    };
2210                    // A chart column or row, stated as a line or as a
2211                    // spline whose control points all stand on one (a
2212                    // converted face's seams).
2213                    let straight = match pcurve {
2214                        ogeom_geom::PlanarCurve::Line(_) => true,
2215                        ogeom_geom::PlanarCurve::BSpline(spline) => {
2216                            along_one_chart_line(spline.control_points())
2217                        }
2218                        _ => false,
2219                    };
2220                    if !straight {
2221                        return Ok(None);
2222                    }
2223                    let (lo, hi) = pcurve.domain();
2224                    let at = pcurve.point_at(range.0.clamp(lo, hi), tol)?;
2225                    let far = pcurve.point_at(range.1.clamp(lo, hi), tol)?;
2226                    if (at.x - far.x).abs() <= (at.y - far.y).abs() {
2227                        columns.push(at.x);
2228                    } else {
2229                        rows.push(at.y);
2230                    }
2231                }
2232            }
2233            _ => return Ok(None),
2234        }
2235    }
2236
2237    if let Some((arc, span)) = circle {
2238        // The disc: one circle's arcs and nothing else, closing a turn, on
2239        // a plane.
2240        let _ = pieces;
2241        if !segments.is_empty()
2242            || !columns.is_empty()
2243            || !rows.is_empty()
2244            || (span - core::f64::consts::TAU).abs() > tol.parametric().max(1e-9)
2245        {
2246            return Ok(None);
2247        }
2248        // And the arcs must *tile* the turn rather than add up to one. A
2249        // reader that re-bases each edge's range onto its own curve can
2250        // leave two arcs both starting at the circle's zero, one a quarter
2251        // of it and one three quarters, which sums to a turn while
2252        // covering a quarter of the circle twice and half of it never. Each
2253        // arc end meets exactly one other where they genuinely chain.
2254        let reach = tol.confusion() * 10.0;
2255        for (index, at) in arc_ends.iter().enumerate() {
2256            let met = arc_ends
2257                .iter()
2258                .enumerate()
2259                .filter(|(other, q)| *other != index && q.distance(*at) <= reach)
2260                .count();
2261            if met != 1 {
2262                return Ok(None);
2263            }
2264        }
2265        let ogeom_geom::SurfaceGeometry::Plane(plane) = placed else {
2266            return Ok(None);
2267        };
2268        let frame = plane.plane().frame();
2269        let centre2 = arc.circle().centre();
2270        let centre = placed.point_at(centre2.x, centre2.y, tol)?;
2271        let radius = arc.circle().radius();
2272        // The chart's own normal, as every other region takes it: under a
2273        // reflection the frame's `z` is the image of the unplaced normal,
2274        // and the sign already carries the turn.
2275        let normal = frame.x().vector().cross(frame.y().vector());
2276        return Ok(Some(ExactFace::Disc {
2277            centre,
2278            e1: frame.x().vector(),
2279            e2: frame.y().vector(),
2280            normal,
2281            radius,
2282            sign,
2283            share,
2284            split,
2285        }));
2286    }
2287
2288    // A chart rectangle: every segment axis-aligned and on the hull's edge.
2289    // A torus's face is all seam and has no segments at all: two columns
2290    // and two rows, which are the rectangle.
2291    if segments.is_empty() && columns.is_empty() && rows.is_empty() {
2292        return Ok(None);
2293    }
2294    let (mut u0, mut u1) = (f64::INFINITY, f64::NEG_INFINITY);
2295    let (mut v0, mut v1) = (f64::INFINITY, f64::NEG_INFINITY);
2296    for (a, b) in &segments {
2297        for p in [a, b] {
2298            u0 = u0.min(p.x);
2299            u1 = u1.max(p.x);
2300            v0 = v0.min(p.y);
2301            v1 = v1.max(p.y);
2302        }
2303    }
2304    for u in &columns {
2305        u0 = u0.min(*u);
2306        u1 = u1.max(*u);
2307    }
2308    for v in &rows {
2309        v0 = v0.min(*v);
2310        v1 = v1.max(*v);
2311    }
2312    if !(u0.is_finite() && u1.is_finite() && v0.is_finite() && v1.is_finite()) {
2313        return Ok(None);
2314    }
2315    if u1 - u0 <= tol.confusion() || v1 - v0 <= tol.confusion() {
2316        return Ok(None);
2317    }
2318    let eps = tol.confusion().max(1e-9 * (u1 - u0).max(v1 - v0));
2319    let on_side =
2320        |value: f64, lo: f64, hi: f64| (value - lo).abs() <= eps || (value - hi).abs() <= eps;
2321    let mut perimeter = 0.0;
2322    for (a, b) in &segments {
2323        let horizontal = (a.y - b.y).abs() <= eps;
2324        let vertical = (a.x - b.x).abs() <= eps;
2325        if !(horizontal ^ vertical) {
2326            return Ok(None);
2327        }
2328        if horizontal && !on_side(a.y, v0, v1) {
2329            return Ok(None);
2330        }
2331        if vertical && !on_side(a.x, u0, u1) {
2332            return Ok(None);
2333        }
2334        perimeter += a.distance(*b);
2335    }
2336    // A seam cut into pieces stands each piece on the same chart side; the
2337    // side is one side of the rectangle however many pieces it took.
2338    for values in [&mut columns, &mut rows] {
2339        values.sort_by(f64::total_cmp);
2340        values.dedup_by(|a, b| (*a - *b).abs() <= eps);
2341    }
2342    #[allow(clippy::cast_precision_loss)]
2343    for (values, lo, hi, span) in [(&columns, u0, u1, v1 - v0), (&rows, v0, v1, u1 - u0)] {
2344        for value in values {
2345            if !on_side(*value, lo, hi) {
2346                return Ok(None);
2347            }
2348            perimeter += span;
2349        }
2350    }
2351    let expected = 2.0 * ((u1 - u0) + (v1 - v0));
2352    if (perimeter - expected).abs() > 1e-6 * expected {
2353        return Ok(None);
2354    }
2355    Ok(Some(ExactFace::ChartRectangle {
2356        surface: placed.clone(),
2357        rect: (u0, u1, v0, v1),
2358        sign,
2359        share,
2360        split,
2361    }))
2362}
2363
2364/// Running totals over simplices, measured from a fixed reference point.
2365///
2366/// The moments are accumulated about a *reference near the shape*, not about
2367/// the world origin, and that is a numerical decision rather than a stylistic
2368/// one. The inertia about the centre is a difference of two second moments, so
2369/// for a part sitting a million units from the origin the two terms agree to
2370/// twelve digits and their difference keeps four. Referencing the shape's own
2371/// bounding box keeps every intermediate the size of the shape.
2372///
2373/// The moments are about a fixed point rather than a running centre because the
2374/// centre is not known until the last simplex is in, and a moment about a
2375/// moving point is not a sum of anything.
2376struct Accumulator {
2377    /// Where the moments are measured from: the first point seen, so it is
2378    /// always somewhere on the shape.
2379    reference: Option<Point>,
2380    mass: f64,
2381    /// The first moment `∫ (x − r)`, which divided by the mass gives the centre
2382    /// relative to the reference.
2383    first: Vector,
2384    /// The second moment `∫ (x − r)(x − r)ᵀ`.
2385    second: Matrix3,
2386}
2387
2388impl Accumulator {
2389    const fn new() -> Self {
2390        Self {
2391            reference: None,
2392            mass: 0.0,
2393            first: Vector::ZERO,
2394            second: Matrix3::ZERO,
2395        }
2396    }
2397
2398    /// Add one simplex: 2 points for a segment, 3 for a triangle, 4 for a
2399    /// tetrahedron, with its signed or unsigned measure.
2400    fn add(&mut self, points: &[Point], measure: f64) {
2401        if points.is_empty() || measure == 0.0 || !measure.is_finite() {
2402            return;
2403        }
2404        let n = points.len();
2405        #[allow(clippy::cast_precision_loss)]
2406        let count = n as f64;
2407        let reference = *self.reference.get_or_insert(points[0]);
2408        // A simplex has at most four corners: no heap for them, once per
2409        // simplex of a whole mesh.
2410        let local: smallvec::SmallVec<[Vector; 4]> =
2411            points.iter().map(|p| *p - reference).collect();
2412        let sum: Vector = local.iter().fold(Vector::ZERO, |a, v| a + *v);
2413
2414        self.mass += measure;
2415        self.first += sum * (measure / count);
2416
2417        // ∫ x_i x_j = m/(n(n+1)) · [ Σ p p_ᵀ + (Σ p)(Σ p)ᵀ ]; see the module
2418        // docs. The n(n+1) is the barycentric integral collapsing.
2419        let scale = measure / (count * (count + 1.0));
2420        let mut term = outer(sum, sum);
2421        for v in &local {
2422            term = add(term, outer(*v, *v));
2423        }
2424        self.second = add(self.second, scale_matrix(term, scale));
2425    }
2426
2427    /// Turn the running totals into the answer.
2428    fn finish(self, deflection: f64) -> MassProperties {
2429        if self.mass.abs() <= f64::MIN_POSITIVE {
2430            return MassProperties::none(deflection);
2431        }
2432        let offset = self.first / self.mass;
2433        let centre = self.reference.unwrap_or(Point::ORIGIN) + offset;
2434
2435        // Inertia about the reference from the second moment: I = tr(S)·1 − S.
2436        let trace = self.second.rows[0][0] + self.second.rows[1][1] + self.second.rows[2][2];
2437        let about_reference = add(
2438            scale_matrix(Matrix3::IDENTITY, trace),
2439            scale_matrix(self.second, -1.0),
2440        );
2441        // Then shift to the centre, the reverse of `inertia_about`.
2442        let inertia = add(
2443            about_reference,
2444            scale_matrix(displacement_term(self.mass, offset), -1.0),
2445        );
2446
2447        MassProperties {
2448            mass: self.mass.abs(),
2449            centre,
2450            inertia,
2451            deflection,
2452        }
2453    }
2454}
2455
2456/// The parallel-axis contribution of a mass displaced by `d`.
2457fn displacement_term(mass: f64, d: Vector) -> Matrix3 {
2458    let squared = d.dot(d);
2459    add(
2460        scale_matrix(Matrix3::IDENTITY, mass * squared),
2461        scale_matrix(outer(d, d), -mass),
2462    )
2463}
2464
2465/// The outer product `a bᵀ`.
2466fn outer(a: Vector, b: Vector) -> Matrix3 {
2467    Matrix3::new([
2468        [a.x * b.x, a.x * b.y, a.x * b.z],
2469        [a.y * b.x, a.y * b.y, a.y * b.z],
2470        [a.z * b.x, a.z * b.y, a.z * b.z],
2471    ])
2472}
2473
2474/// Element-wise sum.
2475fn add(a: Matrix3, b: Matrix3) -> Matrix3 {
2476    let mut rows = a.rows;
2477    for (row, other) in rows.iter_mut().zip(b.rows) {
2478        for (value, addend) in row.iter_mut().zip(other) {
2479            *value += addend;
2480        }
2481    }
2482    Matrix3::new(rows)
2483}
2484
2485/// Element-wise scaling.
2486fn scale_matrix(m: Matrix3, s: f64) -> Matrix3 {
2487    let mut rows = m.rows;
2488    for row in &mut rows {
2489        for value in row {
2490            *value *= s;
2491        }
2492    }
2493    Matrix3::new(rows)
2494}
2495
2496/// `vᵀ M v`.
2497fn quadratic_form(m: Matrix3, v: Vector) -> f64 {
2498    let c = [v.x, v.y, v.z];
2499    let mut sum = 0.0;
2500    for (i, ci) in c.iter().enumerate() {
2501        for (j, cj) in c.iter().enumerate() {
2502            sum += ci * m.rows[i][j] * cj;
2503        }
2504    }
2505    sum
2506}
2507
2508/// Whether a chart curve's control points all stand on one column or one
2509/// row of the chart, to rounding against its extent.
2510fn along_one_chart_line(control: &[ogeom_math::Weighted<ogeom_math::Point2>]) -> bool {
2511    let points: Vec<ogeom_math::Point2> = control.iter().map(|w| w.point()).collect();
2512    let Some(first) = points.first() else {
2513        return false;
2514    };
2515    let extent = points
2516        .iter()
2517        .map(|p| p.distance(*first))
2518        .fold(0.0_f64, f64::max);
2519    let eps = 1e-9 * extent.max(1.0);
2520    points.iter().all(|p| (p.x - first.x).abs() <= eps)
2521        || points.iter().all(|p| (p.y - first.y).abs() <= eps)
2522}
2523
2524#[cfg(test)]
2525#[allow(clippy::unwrap_used)]
2526mod tests {
2527    use super::*;
2528    use crate::make_box;
2529    use approx::assert_relative_eq;
2530    use ogeom_math::{Frame, Transform};
2531
2532    const T: Tolerances = Tolerances::millimetres();
2533
2534    fn fine() -> Deflection {
2535        Deflection {
2536            chord: 1e-3,
2537            angular: 0.05,
2538            ..Deflection::default()
2539        }
2540    }
2541
2542    #[test]
2543    fn analytic_primitives_measure_exactly_on_their_own_surfaces() {
2544        // The exact path reports zero deflection and machine-precision
2545        // numbers: no chord band, no inscribed deficit.
2546        let mut model = Model::new();
2547        let pi = core::f64::consts::PI;
2548
2549        let cylinder = crate::make_cylinder(&mut model, Frame::WORLD, 2.0, 5.0, T).unwrap();
2550        let props = volume_properties(&model, &cylinder.shape, fine(), T).unwrap();
2551        assert_eq!(props.deflection, 0.0, "the exact path was taken");
2552        assert_relative_eq!(props.mass, pi * 4.0 * 5.0, epsilon = 1e-10);
2553        assert!(props.centre.is_equal(Point::new(0.0, 0.0, 2.5), T));
2554        // I_zz of a solid cylinder: m r^2 / 2.
2555        let m = pi * 4.0 * 5.0;
2556        assert_relative_eq!(props.inertia.rows[2][2], m * 4.0 / 2.0, epsilon = 1e-8);
2557
2558        let sphere = crate::make_sphere(&mut model, Frame::WORLD, 3.0, T).unwrap();
2559        let props = volume_properties(&model, &sphere.shape, fine(), T).unwrap();
2560        assert_eq!(props.deflection, 0.0);
2561        assert_relative_eq!(props.mass, 4.0 / 3.0 * pi * 27.0, epsilon = 1e-10);
2562        // I = 2/5 m r^2 about any axis through the centre.
2563        let m = 4.0 / 3.0 * pi * 27.0;
2564        assert_relative_eq!(props.inertia.rows[0][0], 0.4 * m * 9.0, epsilon = 1e-8);
2565
2566        let torus = crate::make_torus(&mut model, Frame::WORLD, 5.0, 1.5, T).unwrap();
2567        let props = volume_properties(&model, &torus.shape, fine(), T).unwrap();
2568        assert_eq!(props.deflection, 0.0);
2569        assert_relative_eq!(props.mass, 2.0 * pi * pi * 5.0 * 1.5 * 1.5, epsilon = 1e-10);
2570
2571        let cone = crate::make_cone(&mut model, Frame::WORLD, 3.0, 1.0, 4.0, T).unwrap();
2572        let props = volume_properties(&model, &cone.shape, fine(), T).unwrap();
2573        assert_eq!(props.deflection, 0.0);
2574        // A frustum: pi h (R^2 + R r + r^2) / 3.
2575        assert_relative_eq!(
2576            props.mass,
2577            pi * 4.0 * (9.0 + 3.0 + 1.0) / 3.0,
2578            epsilon = 1e-10
2579        );
2580
2581        // Areas ride the same path: a sphere's is 4 pi r^2, exactly.
2582        let props = surface_properties(&model, &sphere.shape, fine(), T).unwrap();
2583        assert_eq!(props.deflection, 0.0);
2584        assert_relative_eq!(props.mass, 4.0 * pi * 9.0, epsilon = 1e-10);
2585    }
2586
2587    #[test]
2588    fn a_box_has_the_volume_centre_and_inertia_a_box_has() {
2589        // Every number here is one a textbook states, which is the point: the
2590        // simplex formula is general, and a general formula that gets the one
2591        // case everybody knows wrong is worth nothing.
2592        let (dx, dy, dz) = (2.0, 3.0, 4.0);
2593        let mut model = Model::new();
2594        let built = make_box(&mut model, Frame::WORLD, (dx, dy, dz), T).unwrap();
2595
2596        let props = volume_properties(&model, &built.shape, fine(), T).unwrap();
2597        assert_relative_eq!(props.mass, dx * dy * dz, epsilon = 1e-9);
2598        assert!(
2599            props
2600                .centre
2601                .is_equal(Point::new(dx / 2.0, dy / 2.0, dz / 2.0), T),
2602            "the centre of a box is its middle, got {:?}",
2603            props.centre
2604        );
2605
2606        // I_xx = m(dy² + dz²)/12, and so round.
2607        let m = dx * dy * dz;
2608        assert_relative_eq!(
2609            props.inertia.rows[0][0],
2610            m * dz.mul_add(dz, dy * dy) / 12.0,
2611            epsilon = 1e-9
2612        );
2613        assert_relative_eq!(
2614            props.inertia.rows[1][1],
2615            m * dz.mul_add(dz, dx * dx) / 12.0,
2616            epsilon = 1e-9
2617        );
2618        assert_relative_eq!(
2619            props.inertia.rows[2][2],
2620            m * dy.mul_add(dy, dx * dx) / 12.0,
2621            epsilon = 1e-9
2622        );
2623        // A box is symmetric about its own axes, so the products vanish.
2624        for (i, j) in [(0, 1), (0, 2), (1, 2)] {
2625            assert_relative_eq!(props.inertia.rows[i][j], 0.0, epsilon = 1e-9);
2626            assert_relative_eq!(props.inertia.rows[j][i], 0.0, epsilon = 1e-9);
2627        }
2628    }
2629
2630    #[test]
2631    fn a_box_has_the_area_and_edge_length_a_box_has() {
2632        let (dx, dy, dz) = (2.0, 3.0, 4.0);
2633        let mut model = Model::new();
2634        let built = make_box(&mut model, Frame::WORLD, (dx, dy, dz), T).unwrap();
2635
2636        let area = surface_properties(&model, &built.shape, fine(), T).unwrap();
2637        assert_relative_eq!(
2638            area.mass,
2639            2.0 * dz.mul_add(dx, dx.mul_add(dy, dy * dz)),
2640            epsilon = 1e-9
2641        );
2642        assert!(
2643            area.centre
2644                .is_equal(Point::new(dx / 2.0, dy / 2.0, dz / 2.0), T)
2645        );
2646
2647        // Four edges in each direction, counted once each however many faces
2648        // they bound.
2649        let length = linear_properties(&model, &built.shape, fine(), T).unwrap();
2650        assert_relative_eq!(length.mass, 4.0 * (dx + dy + dz), epsilon = 1e-9);
2651        assert!(
2652            length
2653                .centre
2654                .is_equal(Point::new(dx / 2.0, dy / 2.0, dz / 2.0), T)
2655        );
2656    }
2657
2658    #[test]
2659    fn the_answer_does_not_depend_on_where_the_shape_sits() {
2660        // The inertia is about the centre, so translating the box must leave it
2661        // alone and move only the centre. An inertia accidentally left about
2662        // the origin would grow with the distance.
2663        let mut model = Model::new();
2664        let here = make_box(&mut model, Frame::WORLD, (1.0, 2.0, 3.0), T).unwrap();
2665        let far = Frame::new(
2666            Point::new(100.0, -50.0, 25.0),
2667            Direction::Z,
2668            Direction::X,
2669            T,
2670        )
2671        .unwrap();
2672        let there = make_box(&mut model, far, (1.0, 2.0, 3.0), T).unwrap();
2673
2674        let a = volume_properties(&model, &here.shape, fine(), T).unwrap();
2675        let b = volume_properties(&model, &there.shape, fine(), T).unwrap();
2676
2677        assert_relative_eq!(a.mass, b.mass, epsilon = 1e-9);
2678        assert!(
2679            b.centre
2680                .is_equal(a.centre + Vector::new(100.0, -50.0, 25.0), T)
2681        );
2682        for i in 0..3 {
2683            for j in 0..3 {
2684                assert_relative_eq!(a.inertia.rows[i][j], b.inertia.rows[i][j], epsilon = 1e-6);
2685            }
2686        }
2687    }
2688
2689    #[test]
2690    fn a_scaling_placement_scales_the_exact_measures_by_their_powers() {
2691        // A similarity `p -> s L p + t` carries a volume by `|s|^3` and an
2692        // area by `s^2`, the centre by the placement itself, and the inertia
2693        // about it to `|s|^5 L I L^T` (`s^4` for an area). Each shape is
2694        // still measured on its exact surfaces, so all of it holds to
2695        // rounding against the closed forms.
2696        let mut model = Model::new();
2697        let pi = core::f64::consts::PI;
2698        let ball = crate::make_sphere(&mut model, Frame::WORLD, 10.0, T)
2699            .unwrap()
2700            .shape;
2701        let block = make_box(&mut model, Frame::WORLD, (1.0, 2.0, 3.0), T)
2702            .unwrap()
2703            .shape;
2704        let drum = crate::make_cylinder(&mut model, Frame::WORLD, 2.0, 5.0, T)
2705            .unwrap()
2706            .shape;
2707        // Volume, area and centre of each, as a textbook states them.
2708        let shapes = [
2709            (
2710                &ball,
2711                4.0 / 3.0 * pi * 1000.0,
2712                4.0 * pi * 100.0,
2713                Point::ORIGIN,
2714            ),
2715            (&block, 6.0, 22.0, Point::new(0.5, 1.0, 1.5)),
2716            (&drum, 20.0 * pi, 28.0 * pi, Point::new(0.0, 0.0, 2.5)),
2717        ];
2718        let half = Transform::scaling(Point::ORIGIN, 0.5, T).unwrap();
2719        let double = Transform::scaling(Point::new(1.0, -2.0, 3.0), 2.0, T).unwrap();
2720        let turned = Transform::translation(Vector::new(7.0, -3.0, 11.0))
2721            * Transform::rotation(ogeom_math::Axis::X, 0.7)
2722            * half;
2723        let mirrored = Transform::plane_mirror(Point::new(0.0, 4.0, 0.0), Direction::Y) * double;
2724        let inverted = Transform::scaling(Point::new(2.0, 0.0, 0.0), -0.5, T).unwrap();
2725
2726        for (shape, volume, area, centre) in shapes {
2727            let unplaced = [
2728                volume_properties(&model, shape, fine(), T).unwrap(),
2729                surface_properties(&model, shape, fine(), T).unwrap(),
2730            ];
2731            for placement in [half, double, turned, mirrored, inverted] {
2732                let placed = crate::place::transformed(&mut model, shape, placement)
2733                    .unwrap()
2734                    .shape;
2735                let s = placement.scale_factor().abs();
2736                let l = placement.linear();
2737                let measured = [
2738                    volume_properties(&model, &placed, fine(), T).unwrap(),
2739                    surface_properties(&model, &placed, fine(), T).unwrap(),
2740                ];
2741                for ((found, before), (power, exact)) in
2742                    measured.iter().zip(&unplaced).zip([(3, volume), (2, area)])
2743                {
2744                    assert_eq!(found.deflection, 0.0, "the exact path was taken");
2745                    assert_relative_eq!(found.mass, exact * s.powi(power), max_relative = 1e-11);
2746                    let at = placement.apply(centre);
2747                    assert!(
2748                        found.centre.distance(at) <= 1e-10 * (1.0 + at.to_vector().magnitude()),
2749                        "centre {:?} against {at:?}",
2750                        found.centre
2751                    );
2752                    let expected = l * before.inertia * l.transposed() * s.powi(power + 2);
2753                    let size = (0..3).map(|i| expected.rows[i][i]).fold(0.0, f64::max);
2754                    for i in 0..3 {
2755                        for j in 0..3 {
2756                            assert!(
2757                                (found.inertia.rows[i][j] - expected.rows[i][j]).abs()
2758                                    <= 1e-10 * size,
2759                                "inertia [{i}][{j}] {} against {}",
2760                                found.inertia.rows[i][j],
2761                                expected.rows[i][j]
2762                            );
2763                        }
2764                    }
2765                }
2766            }
2767        }
2768    }
2769
2770    #[test]
2771    fn a_part_a_long_way_from_the_origin_keeps_its_precision() {
2772        // The reason the moments are accumulated about a point on the shape.
2773        // About the world origin the two terms of the inertia agree to twelve
2774        // digits at this distance and their difference keeps four, so the answer
2775        // would come back with a few percent of noise in it, or negative.
2776        let mut model = Model::new();
2777        let far = Frame::new(
2778            Point::new(1.0e6, -2.0e6, 5.0e5),
2779            Direction::Z,
2780            Direction::X,
2781            T,
2782        )
2783        .unwrap();
2784        let built = make_box(&mut model, far, (2.0, 3.0, 4.0), T).unwrap();
2785        let props = volume_properties(&model, &built.shape, fine(), T).unwrap();
2786
2787        assert_relative_eq!(props.mass, 24.0, epsilon = 1e-6);
2788        assert_relative_eq!(
2789            props.inertia.rows[0][0],
2790            24.0 * 4.0_f64.mul_add(4.0, 3.0 * 3.0) / 12.0,
2791            epsilon = 1e-6
2792        );
2793        for (i, j) in [(0, 1), (0, 2), (1, 2)] {
2794            assert_relative_eq!(props.inertia.rows[i][j], 0.0, epsilon = 1e-6);
2795        }
2796    }
2797
2798    #[test]
2799    fn moving_the_inertia_off_the_centre_agrees_with_the_parallel_axis_theorem() {
2800        let mut model = Model::new();
2801        let built = make_box(&mut model, Frame::WORLD, (2.0, 2.0, 2.0), T).unwrap();
2802        let props = volume_properties(&model, &built.shape, fine(), T).unwrap();
2803
2804        // A cube of side a about a face-centre axis: I = m(a²/6 + a²/4).
2805        let m = 8.0;
2806        let corner = props.inertia_about(Point::ORIGIN);
2807        assert_relative_eq!(
2808            corner.rows[0][0],
2809            2.0_f64.mul_add(2.0, 2.0 * 2.0).mul_add(m / 12.0, m * 2.0),
2810            epsilon = 1e-9
2811        );
2812        // And about its own centre it is the smallest it can be.
2813        assert!(corner.rows[0][0] > props.inertia.rows[0][0]);
2814    }
2815
2816    #[test]
2817    fn a_cubes_principal_moments_are_all_the_same() {
2818        // Full rotational symmetry: every axis is principal, so the three
2819        // moments must agree. Axes that came back non-orthogonal would mean the
2820        // solver was handed a non-symmetric tensor, which would itself be a bug.
2821        let mut model = Model::new();
2822        let built = make_box(&mut model, Frame::WORLD, (2.0, 2.0, 2.0), T).unwrap();
2823        let props = volume_properties(&model, &built.shape, fine(), T).unwrap();
2824
2825        let axes = props.principal_axes(T).unwrap();
2826        let expected = 8.0 * 2.0_f64.mul_add(2.0, 2.0 * 2.0) / 12.0;
2827        for (moment, _) in &axes {
2828            assert_relative_eq!(*moment, expected, epsilon = 1e-6);
2829        }
2830        for (i, j) in [(0, 1), (0, 2), (1, 2)] {
2831            assert_relative_eq!(
2832                axes[i].1.vector().dot(axes[j].1.vector()),
2833                0.0,
2834                epsilon = 1e-9
2835            );
2836        }
2837    }
2838
2839    #[test]
2840    fn a_long_box_spins_most_easily_about_its_length() {
2841        let mut model = Model::new();
2842        let built = make_box(&mut model, Frame::WORLD, (10.0, 1.0, 1.0), T).unwrap();
2843        let props = volume_properties(&model, &built.shape, fine(), T).unwrap();
2844
2845        let axes = props.principal_axes(T).unwrap();
2846        // The smallest moment is about the long axis.
2847        assert!(axes[0].1.vector().x.abs() > 0.99, "got {:?}", axes[0].1);
2848        assert!(axes[0].0 < axes[1].0 && axes[1].0 <= axes[2].0);
2849
2850        let along = props.radius_of_gyration(Direction::X).unwrap();
2851        let across = props.radius_of_gyration(Direction::Y).unwrap();
2852        assert!(along < across, "{along} should be less than {across}");
2853    }
2854
2855    #[test]
2856    fn a_sphere_converges_on_the_volume_a_sphere_has() {
2857        // The case a planar-exact implementation would get quietly wrong. The
2858        // tessellation inscribes the sphere, so the volume comes in under the
2859        // truth and climbs as the deflection tightens, and the deflection is
2860        // reported, so a caller can see how far under.
2861        use crate::build::make_natural_face;
2862        use ogeom_geom::SphereSurface;
2863        use ogeom_math::Sphere;
2864
2865        let radius = 5.0_f64;
2866        let exact = 4.0 / 3.0 * std::f64::consts::PI * radius.powi(3);
2867        let mut previous = 0.0;
2868
2869        for chord in [0.5_f64, 0.1, 0.02] {
2870            let mut model = Model::new();
2871            let surface = SphereSurface::new(Sphere::new(Frame::WORLD, radius, T).unwrap());
2872            let face = make_natural_face(&mut model, surface.into()).unwrap().shape;
2873            let shell = crate::build::make_shell(&mut model, std::slice::from_ref(&face))
2874                .unwrap()
2875                .shape;
2876
2877            let deflection = Deflection {
2878                chord,
2879                ..Deflection::default()
2880            };
2881            let props = volume_properties(&model, &shell, deflection, T).unwrap();
2882            assert_relative_eq!(props.deflection, chord);
2883            assert!(props.mass < exact, "an inscribed volume cannot exceed it");
2884            assert!(
2885                props.mass > previous,
2886                "tightening the chord lost volume: {} after {previous}",
2887                props.mass
2888            );
2889            // The mesh keeps within the chord of the sphere but need not be
2890            // symmetric where triangles are added to keep it there: its
2891            // centre is the sphere's to a hundredth of the chord.
2892            assert!(
2893                props.centre.distance(Point::ORIGIN) < chord * 0.01,
2894                "a sphere's centre is its centre, got {:?}",
2895                props.centre
2896            );
2897            previous = props.mass;
2898        }
2899        assert!(
2900            previous > exact * 0.99,
2901            "{previous} should be within a percent of {exact}"
2902        );
2903    }
2904
2905    #[test]
2906    fn an_open_shell_is_refused_rather_than_measured() {
2907        // Half a boundary encloses nothing, and the divergence theorem applied
2908        // to it returns a number that looks like a volume and is not one.
2909        let mut model = Model::new();
2910        let built = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
2911        let face = explore_unique(&model, &built.shape, ShapeType::Face).unwrap()[0].clone();
2912
2913        assert!(volume_properties(&model, &face, fine(), T).is_err());
2914        // Its area, though, is perfectly well defined.
2915        let area = surface_properties(&model, &face, fine(), T).unwrap();
2916        assert_relative_eq!(area.mass, 1.0, epsilon = 1e-9);
2917    }
2918
2919    #[test]
2920    fn an_inward_shell_is_refused_rather_than_reported_as_negative() {
2921        let mut model = Model::new();
2922        let built = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
2923        assert!(volume_properties(&model, &built.shape.reversed(), fine(), T).is_err());
2924    }
2925
2926    #[test]
2927    fn a_shape_with_nothing_to_measure_says_so_rather_than_dividing_by_zero() {
2928        let mut model = Model::new();
2929        let vertex = model.add_point(Point::ORIGIN);
2930
2931        for props in [
2932            volume_properties(&model, &vertex, fine(), T).unwrap(),
2933            surface_properties(&model, &vertex, fine(), T).unwrap(),
2934            linear_properties(&model, &vertex, fine(), T).unwrap(),
2935        ] {
2936            assert_relative_eq!(props.mass, 0.0);
2937            assert!(props.centre.is_equal(Point::ORIGIN, T));
2938            assert!(props.radius_of_gyration(Direction::Z).is_none());
2939        }
2940    }
2941
2942    #[test]
2943    fn an_unusable_deflection_is_refused() {
2944        let mut model = Model::new();
2945        let built = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
2946        let bad = Deflection {
2947            chord: -1.0,
2948            ..Deflection::default()
2949        };
2950        assert!(volume_properties(&model, &built.shape, bad, T).is_err());
2951        assert!(surface_properties(&model, &built.shape, bad, T).is_err());
2952        assert!(linear_properties(&model, &built.shape, bad, T).is_err());
2953    }
2954}