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 shape with a face neither can take (no pcurves, a scaling placement,
17//! a boundary that does not close in the chart) is measured on its
18//! tessellation instead, and the result carries the deflection it was
19//! computed at. Halving the deflection and seeing the answer move tells a
20//! caller how much to trust it; [`MassProperties::deflection`] is what
21//! makes that check possible. Lengths are always measured on a
22//! discretization.
23//!
24//! # The one formula
25//!
26//! Length, area and volume all reduce to summing over simplices (segments,
27//! triangles, tetrahedra), and the second moment of a simplex has the same
28//! shape in every dimension:
29//!
30//! ```text
31//! ∫ x_i x_j  =  m / (n(n+1)) · [ Σ_k p_k p_kᵀ + (Σ_k p_k)(Σ_k p_k)ᵀ ]
32//! ```
33//!
34//! for `n` vertices and measure `m`. Barycentric integration gives it: the
35//! integral of `λ_a λ_b` over a simplex is `m·d!·(1+δ_ab)/(d+2)!`, and
36//! `n(n+1)` is what that collapses to. One function serves all three, which is
37//! also why the three agree with each other rather than drifting apart.
38
39use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
40use ogeom_geom::Transformable as _;
41use ogeom_math::{Direction, Matrix3, Point, Vector};
42use ogeom_mesh::{Deflection, discretize};
43use ogeom_topo::{EdgeRepr, Filter, Model, NodeData, Shape, ShapeType, explore, explore_unique};
44
45/// How much of something there is, and how it is distributed.
46#[derive(Debug, Clone, Copy, PartialEq)]
47pub struct MassProperties {
48    /// The measure: length, area or volume, depending on what was asked for.
49    ///
50    /// Never negative. A volume computed from an inward-wound shell would come
51    /// out negative, which says the shell is inside out rather than that the
52    /// solid has negative volume, so that case is an error instead.
53    pub mass: f64,
54    /// The centre of the measure: the centroid, or centre of mass at uniform
55    /// density.
56    pub centre: Point,
57    /// The inertia tensor about [`MassProperties::centre`], at unit density.
58    ///
59    /// About the centre, not the origin: an inertia about the origin says as
60    /// much about where the part happens to sit as about the part.
61    /// [`MassProperties::inertia_about`] moves it elsewhere.
62    pub inertia: Matrix3,
63    /// The chord deflection the tessellation was built to.
64    ///
65    /// The honest statement of accuracy. For a shape with only planar faces
66    /// and straight edges the result is exact whatever this says, because the
67    /// tessellation is exact.
68    pub deflection: f64,
69}
70
71impl MassProperties {
72    /// Nothing: no mass, at the origin, resisting nothing.
73    #[must_use]
74    pub const fn none(deflection: f64) -> Self {
75        Self {
76            mass: 0.0,
77            centre: Point::ORIGIN,
78            inertia: Matrix3::ZERO,
79            deflection,
80        }
81    }
82
83    /// The inertia tensor about some other point, by the parallel axis theorem.
84    #[must_use]
85    pub fn inertia_about(&self, point: Point) -> Matrix3 {
86        let d = self.centre - point;
87        // Moving *away* from the centre can only increase inertia, which is the
88        // sign convention here: the centre is the minimum.
89        add(self.inertia, displacement_term(self.mass, d))
90    }
91
92    /// The radius of gyration about an axis through the centre.
93    ///
94    /// The distance at which a point of the same mass would have the same
95    /// inertia. Zero mass has no such distance, so this returns `None` rather
96    /// than dividing by it.
97    #[must_use]
98    pub fn radius_of_gyration(&self, axis: Direction) -> Option<f64> {
99        if self.mass <= 0.0 {
100            return None;
101        }
102        let v = axis.vector();
103        let i = quadratic_form(self.inertia, v);
104        Some((i / self.mass).max(0.0).sqrt())
105    }
106
107    /// The principal moments, smallest first, with the axes they act about.
108    ///
109    /// The eigenvectors of a symmetric tensor, so the axes are orthogonal. A
110    /// shape with rotational symmetry has repeated moments and the axes in that
111    /// plane are arbitrary but still orthogonal, which is correct, not a
112    /// failure: any pair of perpendicular axes in that plane is principal.
113    ///
114    /// # Errors
115    ///
116    /// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if the eigen-solver does
117    /// not converge, which for a symmetric 3×3 means the tensor was not finite.
118    pub fn principal_axes(&self, tol: Tolerances) -> OgeomResult<[(f64, Direction); 3]> {
119        let m = nalgebra::Matrix3::from_row_slice(&[
120            self.inertia.rows[0][0],
121            self.inertia.rows[0][1],
122            self.inertia.rows[0][2],
123            self.inertia.rows[1][0],
124            self.inertia.rows[1][1],
125            self.inertia.rows[1][2],
126            self.inertia.rows[2][0],
127            self.inertia.rows[2][1],
128            self.inertia.rows[2][2],
129        ]);
130        if !m.iter().all(|x| x.is_finite()) {
131            ogeom_bail!(NotDone, "the inertia tensor is not finite");
132        }
133        // Symmetric by construction, so the eigenvalues are real and this
134        // always converges; the general solver would return complex pairs.
135        let eigen = nalgebra::SymmetricEigen::new(m);
136
137        let mut out: Vec<(f64, Direction)> = Vec::with_capacity(3);
138        for i in 0..3 {
139            let column = eigen.eigenvectors.column(i);
140            let axis = Direction::new(Vector::new(column[0], column[1], column[2]), tol)?;
141            out.push((eigen.eigenvalues[i], axis));
142        }
143        out.sort_by(|a, b| a.0.total_cmp(&b.0));
144        Ok([out[0], out[1], out[2]])
145    }
146}
147
148/// The length of a shape's edges, and how it is distributed.
149///
150/// Every distinct edge counts once, however many faces it bounds: the wire
151/// frame of the shape, not a tally weighted by use.
152///
153/// # Errors
154///
155/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the deflection
156/// settings are unusable, or a curve is missing from the model.
157pub fn linear_properties(
158    model: &Model,
159    shape: &Shape,
160    deflection: Deflection,
161    tol: Tolerances,
162) -> OgeomResult<MassProperties> {
163    deflection.validate()?;
164    let mut acc = Accumulator::new();
165
166    for edge in explore_unique(model, shape, ShapeType::Edge)? {
167        let Some(node) = model.node(&edge) else {
168            ogeom_bail!(Dangling, "edge is not in this model");
169        };
170        let NodeData::Edge(data) = node.data() else {
171            ogeom_bail!(Construction, "edge node holds no edge data");
172        };
173        let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
174            continue;
175        };
176        let Some(geometry) = model.geometry().curve(*curve) else {
177            ogeom_bail!(Dangling, "curve is not in this model");
178        };
179        let placement = edge.transform(model.datums())?;
180        let line = discretize(geometry, *range, deflection, tol)?;
181        for w in line.points.windows(2) {
182            let (a, b) = (placement.apply(w[0]), placement.apply(w[1]));
183            acc.add(&[a, b], a.distance(b));
184        }
185    }
186    Ok(acc.finish(deflection.chord))
187}
188
189/// The area of a shape's faces, and how it is distributed.
190///
191/// # Errors
192///
193/// As [`ogeom_mesh::triangulate_face`].
194pub fn surface_properties(
195    model: &Model,
196    shape: &Shape,
197    deflection: Deflection,
198    tol: Tolerances,
199) -> OgeomResult<MassProperties> {
200    deflection.validate()?;
201    if let Some(exact) = exact_surface_properties(model, shape, tol)? {
202        return Ok(exact);
203    }
204    let mut acc = Accumulator::new();
205
206    for face in explore(model, shape, Filter::OfType(ShapeType::Face))? {
207        let mesh = ogeom_mesh::triangulate_face(model, &face, deflection, tol)?;
208        for triangle in &mesh.triangles {
209            let [a, b, c] = triangle.map(|i| mesh.positions[i as usize]);
210            // Unsigned: a reversed face still has the same area, and summing
211            // signed areas would cancel a solid's own surface to nothing.
212            let area = (b - a).cross(c - a).magnitude() * 0.5;
213            acc.add(&[a, b, c], area);
214        }
215    }
216    Ok(acc.finish(deflection.chord))
217}
218
219/// The volume a shape encloses, and how it is distributed.
220///
221/// # Errors
222///
223/// As [`surface_properties`], plus
224/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the boundary is
225/// not closed, or is wound inward so that the volume comes out negative. Both
226/// mean the answer would be meaningless rather than merely inaccurate: the
227/// divergence theorem needs a closed, outward-oriented boundary, and without
228/// one the sum is a number with no interpretation.
229pub fn volume_properties(
230    model: &Model,
231    shape: &Shape,
232    deflection: Deflection,
233    tol: Tolerances,
234) -> OgeomResult<MassProperties> {
235    deflection.validate()?;
236    if let Some(exact) = exact_volume_properties(model, shape, tol)? {
237        return Ok(exact);
238    }
239    let mut mesh = ogeom_mesh::triangulate(model, shape, deflection, tol)?;
240    if mesh.is_empty() {
241        return Ok(MassProperties::none(deflection.chord));
242    }
243    if !mesh.is_closed() {
244        // The flux through a boundary is the sum of the flux through each
245        // face, and a face's share needs only that face's own mesh. The
246        // welded mesh can stay open where faces meet on edges looser than
247        // the weld dares reach, a mesh converted back holding threaded
248        // curves a few hundredths off; closure is then asked of the
249        // topology, which is what the divergence theorem is about.
250        let shells = explore_unique(model, shape, ShapeType::Shell)?;
251        let mut closed = !shells.is_empty();
252        for shell in &shells {
253            closed &= crate::build::is_shell_closed(model, shell)?;
254        }
255        if !closed {
256            ogeom_bail!(
257                Construction,
258                "the boundary is not closed, so it encloses no volume to measure"
259            );
260        }
261        let chords = ogeom_mesh::edge_chords_for(model, shape, deflection, tol)?;
262        mesh = ogeom_topo::Triangulation::new();
263        for face in explore(model, shape, Filter::OfType(ShapeType::Face))? {
264            mesh.append(&ogeom_mesh::triangulate_face_with(
265                model, &face, deflection, &chords, tol,
266            )?);
267        }
268    }
269
270    // The apex every tetrahedron is built on. Any point serves (the signs
271    // cancel outside the enclosed region wherever it sits), so it is a point on
272    // the mesh, which keeps the tetrahedra the size of the shape instead of the
273    // size of its distance from the world origin.
274    let apex = mesh.positions[0];
275    let mut acc = Accumulator::new();
276    for triangle in &mesh.triangles {
277        let [a, b, c] = triangle.map(|i| mesh.positions[i as usize]);
278        // The signed volume of the tetrahedron on the apex. The cancellation is
279        // the divergence theorem doing the work, and why the winding has to be
280        // outward.
281        let volume = (a - apex).dot((b - apex).cross(c - apex)) / 6.0;
282        acc.add(&[apex, a, b, c], volume);
283    }
284
285    if acc.mass < 0.0 {
286        ogeom_bail!(
287            Construction,
288            "the boundary is wound inward, so the volume came out negative"
289        );
290    }
291    Ok(acc.finish(deflection.chord))
292}
293
294// --- exact properties on the exact surfaces ----------------------------------
295
296/// A face whose trim the exact integrator can walk: an analytic surface
297/// trimmed to a chart rectangle, or a plane trimmed to a full disc.
298enum ExactFace {
299    /// `[u0, u1] x [v0, v1]` on the (placed) surface.
300    ChartRectangle {
301        surface: ogeom_geom::SurfaceGeometry,
302        rect: (f64, f64, f64, f64),
303        sign: f64,
304        share: f64,
305    },
306    /// A full circular disc on a plane.
307    Disc {
308        centre: Point,
309        e1: Vector,
310        e2: Vector,
311        normal: Vector,
312        radius: f64,
313        sign: f64,
314        share: f64,
315    },
316    /// Any other face, integrated round its chart loops.
317    Chart(Box<crate::mass_chart::ChartFace>),
318}
319
320impl ExactFace {
321    /// Whether this region is part of its face or taken out of it: `1` for
322    /// the outer boundary, `-1` for a hole. The volume integral could carry
323    /// it in the normal's sign, but the area integral takes a magnitude and
324    /// would hand a hole's area back as more face.
325    const fn share(&self) -> f64 {
326        match self {
327            Self::ChartRectangle { share, .. } | Self::Disc { share, .. } => *share,
328            Self::Chart(_) => 1.0,
329        }
330    }
331
332    /// How much of its chart the region covers, for telling a face's outer
333    /// boundary from its holes. A wire's place in the face's list does not
334    /// say which it is (a ring's annulus arrives inner ring first), and a
335    /// hole is inside the boundary it is a hole in, so it covers less.
336    fn chart_area(&self) -> f64 {
337        match self {
338            Self::Disc { radius, .. } => core::f64::consts::PI * radius * radius,
339            Self::ChartRectangle { rect, .. } => (rect.1 - rect.0) * (rect.3 - rect.2),
340            Self::Chart(_) => 0.0,
341        }
342    }
343
344    fn take_away(&mut self) {
345        match self {
346            Self::ChartRectangle { share, .. } | Self::Disc { share, .. } => *share = -1.0,
347            Self::Chart(_) => {}
348        }
349    }
350}
351
352/// Mass properties integrated on the exact surfaces, when every face allows.
353///
354/// The integrands over an analytic surface's chart are trigonometric
355/// polynomials, and panels no wider than a quarter turn under the ten-point
356/// Gauss rule integrate them to rounding, exact in every sense that
357/// matters, with `deflection` reported as zero. The first face that resists
358/// (a non-analytic surface, a trim that is not a chart rectangle or a disc)
359/// returns `None`, and the caller falls back to the tessellation with its
360/// stated chord.
361fn exact_volume_properties(
362    model: &Model,
363    shape: &Shape,
364    tol: Tolerances,
365) -> OgeomResult<Option<MassProperties>> {
366    let faces = explore(model, shape, Filter::OfType(ShapeType::Face))?;
367    if faces.is_empty() {
368        return Ok(None);
369    }
370    let mut exact = Vec::with_capacity(faces.len());
371    for face in &faces {
372        match integrable_face(model, face, tol)? {
373            Some(found) => exact.extend(found),
374            None => {
375                if std::env::var_os("OGEOM_DEBUG_MASS").is_some() {
376                    eprintln!(
377                        "MASS face {} is not exactly integrable",
378                        face.node().index()
379                    );
380                }
381                return Ok(None);
382            }
383        }
384    }
385    // And the faces must agree with each other about which way is out. A
386    // boundary that cannot be walked to ask (a pcurve whose domain falls
387    // short of its edge's range) is left to the mesh.
388    if !flags_agree(model, shape, tol).unwrap_or(false) {
389        return Ok(None);
390    }
391    // The divergence theorem needs a closed boundary; topology says whether
392    // it has one. A shape with no shell at all (a bare face) has nothing
393    // to close, and falls back to the mesh path, which refuses it properly.
394    let shells = explore_unique(model, shape, ShapeType::Shell)?;
395    if shells.is_empty() {
396        return Ok(None);
397    }
398    for shell in shells {
399        if !crate::build::is_shell_closed(model, &shell)? {
400            ogeom_bail!(
401                Construction,
402                "the boundary is not closed, so it encloses no volume to measure"
403            );
404        }
405    }
406
407    let reference = reference_point(&exact, tol)?;
408    let mut mass = 0.0;
409    let mut first = Vector::ZERO;
410    let mut second = Matrix3::ZERO;
411    for face in &exact {
412        let settled = integrate_face(face, reference, tol, &mut |p, n_da, share| {
413            let n_da = n_da * share;
414            let q = p - reference;
415            mass += q.dot(n_da) / 3.0;
416            first += Vector::new(
417                q.x * q.x * n_da.x / 2.0,
418                q.y * q.y * n_da.y / 2.0,
419                q.z * q.z * n_da.z / 2.0,
420            );
421            let d = [q.x, q.y, q.z];
422            let nd = [n_da.x, n_da.y, n_da.z];
423            for i in 0..3 {
424                // Diagonal: int q_i^2 dV = surface int q_i^3 n_i / 3.
425                second.rows[i][i] += d[i] * d[i] * d[i] * nd[i] / 3.0;
426                // Off-diagonal: int q_i q_j dV = surface int q_i^2 q_j n_i / 2.
427                for j in 0..3 {
428                    if i != j {
429                        second.rows[i][j] += d[i] * d[i] * d[j] * nd[i] / 2.0;
430                    }
431                }
432            }
433        })?;
434        if !settled {
435            return Ok(None);
436        }
437    }
438    // The off-diagonal identity fills each pair twice, once from each axis;
439    // average them, which also symmetrizes rounding.
440    for i in 0..3 {
441        for j in (i + 1)..3 {
442            let mean = f64::midpoint(second.rows[i][j], second.rows[j][i]);
443            second.rows[i][j] = mean;
444            second.rows[j][i] = mean;
445        }
446    }
447    if mass < 0.0 {
448        ogeom_bail!(
449            Construction,
450            "the boundary is wound inward, so the volume came out negative"
451        );
452    }
453    let acc = Accumulator {
454        reference: Some(reference),
455        mass,
456        first,
457        second,
458    };
459    Ok(Some(acc.finish(0.0)))
460}
461
462/// Surface area and its distribution, on the exact surfaces.
463fn exact_surface_properties(
464    model: &Model,
465    shape: &Shape,
466    tol: Tolerances,
467) -> OgeomResult<Option<MassProperties>> {
468    let faces = explore(model, shape, Filter::OfType(ShapeType::Face))?;
469    if faces.is_empty() {
470        return Ok(None);
471    }
472    let mut exact = Vec::with_capacity(faces.len());
473    for face in &faces {
474        match integrable_face(model, face, tol)? {
475            Some(found) => exact.extend(found),
476            None => return Ok(None),
477        }
478    }
479    let reference = reference_point(&exact, tol)?;
480    let mut mass = 0.0;
481    let mut first = Vector::ZERO;
482    let mut second = Matrix3::ZERO;
483    for face in &exact {
484        let settled = integrate_face(face, reference, tol, &mut |p, n_da, share| {
485            let da = n_da.magnitude() * share;
486            let q = p - reference;
487            mass += da;
488            first += q * da;
489            for (i, qi) in [q.x, q.y, q.z].iter().enumerate() {
490                for (j, qj) in [q.x, q.y, q.z].iter().enumerate() {
491                    second.rows[i][j] += qi * qj * da;
492                }
493            }
494        })?;
495        if !settled {
496            return Ok(None);
497        }
498    }
499    let acc = Accumulator {
500        reference: Some(reference),
501        mass,
502        first,
503        second,
504    };
505    Ok(Some(acc.finish(0.0)))
506}
507
508/// Somewhere on the shape to measure moments from.
509fn reference_point(faces: &[ExactFace], tol: Tolerances) -> OgeomResult<Point> {
510    use ogeom_geom::Surface as _;
511    match &faces[0] {
512        ExactFace::ChartRectangle { surface, rect, .. } => surface.point_at(rect.0, rect.2, tol),
513        ExactFace::Disc { centre, .. } => Ok(*centre),
514        ExactFace::Chart(chart) => chart.anchor(tol),
515    }
516}
517
518/// Drive the callback over every quadrature sample of a face.
519///
520/// The callback receives the world point and the outward-signed `n dA`
521/// already weighted; summing the callback's contributions *is* the
522/// integral.
523fn integrate_face(
524    face: &ExactFace,
525    _reference: Point,
526    tol: Tolerances,
527    contribute: &mut dyn FnMut(Point, Vector, f64),
528) -> OgeomResult<bool> {
529    let share = face.share();
530    use ogeom_geom::Surface as _;
531    const QUARTER: f64 = core::f64::consts::FRAC_PI_2;
532    match face {
533        ExactFace::Chart(chart) => Ok(chart.integrate(_reference, tol, contribute)),
534        ExactFace::ChartRectangle {
535            surface,
536            rect,
537            sign,
538            ..
539        } => {
540            let (u0, u1, v0, v1) = *rect;
541            // Panels no wider than a quarter turn, and a spline's also cut
542            // at its knots.
543            let breaks = |lo: f64, hi: f64, knots: Option<&ogeom_math::KnotVector>| {
544                #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
545                let panels = (((hi - lo) / QUARTER).ceil() as usize).max(1);
546                #[allow(clippy::cast_precision_loss)]
547                let mut out: Vec<f64> = (0..=panels)
548                    .map(|i| lo + (hi - lo) * i as f64 / panels as f64)
549                    .collect();
550                if let Some(knots) = knots {
551                    out.extend(
552                        knots
553                            .distinct()
554                            .into_iter()
555                            .map(|(k, _)| k)
556                            .filter(|k| *k > lo && *k < hi),
557                    );
558                    out.sort_by(f64::total_cmp);
559                    out.dedup_by(|a, b| (*a - *b).abs() <= 1e-14);
560                }
561                out
562            };
563            // A swept curve's knots stand across its sweep: in `u` for an
564            // extrusion, in `v` for a revolution.
565            fn curve_knots(curve: &ogeom_geom::Curve) -> Option<&ogeom_math::KnotVector> {
566                match curve {
567                    ogeom_geom::Curve::BSpline(b) => Some(b.knots()),
568                    ogeom_geom::Curve::Trimmed(t) => curve_knots(t.basis()),
569                    _ => None,
570                }
571            }
572            let (u_knots, v_knots) = match surface {
573                ogeom_geom::SurfaceGeometry::BSpline(b) => (Some(b.u_knots()), Some(b.v_knots())),
574                ogeom_geom::SurfaceGeometry::Extrusion(e) => (curve_knots(e.curve()), None),
575                ogeom_geom::SurfaceGeometry::Revolution(r) => (None, curve_knots(r.curve())),
576                _ => (None, None),
577            };
578            let (u_breaks, v_breaks) = (breaks(u0, u1, u_knots), breaks(v0, v1, v_knots));
579            let mut failure = None;
580            for uw in u_breaks.windows(2) {
581                let (ua, ub) = (uw[0], uw[1]);
582                for vw in v_breaks.windows(2) {
583                    let (va, vb) = (vw[0], vw[1]);
584                    // Nested Gauss with the callback fed directly: the outer
585                    // integrand returns 0 and the samples carry the payload,
586                    // with the weights recovered from unit integrands.
587                    gauss2(ua, ub, va, vb, &mut |u, v, weight| {
588                        if failure.is_some() {
589                            return;
590                        }
591                        let sample = (|| -> OgeomResult<()> {
592                            let p = surface.point_at(u, v, tol)?;
593                            let (du, dv) = surface.d1_at(u, v, tol)?;
594                            contribute(p, du.cross(dv) * (sign * weight), share);
595                            Ok(())
596                        })();
597                        if let Err(e) = sample {
598                            failure = Some(e);
599                        }
600                    });
601                }
602            }
603            match failure {
604                Some(e) => Err(e),
605                None => Ok(true),
606            }
607        }
608        ExactFace::Disc {
609            centre,
610            e1,
611            e2,
612            normal,
613            radius,
614            sign,
615            ..
616        } => {
617            let failure: Option<ogeom_core::OgeomError> = None;
618            let turns = 4;
619            for k in 0..turns {
620                #[allow(clippy::cast_precision_loss)]
621                let (ta, tb) = (
622                    core::f64::consts::TAU * k as f64 / turns as f64,
623                    core::f64::consts::TAU * (k + 1) as f64 / turns as f64,
624                );
625                gauss2(0.0, *radius, ta, tb, &mut |rho, theta, weight| {
626                    if failure.is_some() {
627                        return;
628                    }
629                    let p = *centre + (*e1 * theta.cos() + *e2 * theta.sin()) * rho;
630                    contribute(p, *normal * (sign * rho * weight), share);
631                });
632            }
633            match failure {
634                Some(e) => Err(e),
635                None => Ok(true),
636            }
637        }
638    }
639}
640
641/// A tensor-product ten-by-ten Gauss rule over `[a,b] x [c,d]`, feeding each
642/// sample and its weight to the callback.
643fn gauss2(a: f64, b: f64, c: f64, d: f64, f: &mut dyn FnMut(f64, f64, f64)) {
644    // The rule's nodes recovered through the public one-dimensional
645    // integrator: integrating a delta-free payload is not possible, so the
646    // nodes are collected by integrating an indicator that records them.
647    let mut us: Vec<(f64, f64)> = Vec::with_capacity(10);
648    ogeom_math::gauss_legendre(
649        |u| {
650            us.push((u, 0.0));
651            1.0
652        },
653        a,
654        b,
655    );
656    // Weight of node i: integrate a basis that is 1 at that sample order.
657    // Simpler: the rule is linear, so the weight is the integral of the
658    // indicator sequence, recovered by a second pass per node.
659    for (i, entry) in us.iter_mut().enumerate() {
660        let mut k = 0;
661        let w = ogeom_math::gauss_legendre(
662            |_| {
663                let value = if k == i { 1.0 } else { 0.0 };
664                k += 1;
665                value
666            },
667            a,
668            b,
669        );
670        entry.1 = w;
671    }
672    let mut vs: Vec<(f64, f64)> = Vec::with_capacity(10);
673    ogeom_math::gauss_legendre(
674        |v| {
675            vs.push((v, 0.0));
676            1.0
677        },
678        c,
679        d,
680    );
681    for (j, entry) in vs.iter_mut().enumerate() {
682        let mut k = 0;
683        let w = ogeom_math::gauss_legendre(
684            |_| {
685                let value = if k == j { 1.0 } else { 0.0 };
686                k += 1;
687                value
688            },
689            c,
690            d,
691        );
692        entry.1 = w;
693    }
694    for &(u, wu) in &us {
695        for &(v, wv) in &vs {
696            f(u, v, wu * wv);
697        }
698    }
699}
700
701/// A face's regions in closed form where its surface and trim allow, and
702/// otherwise its chart loops for integrating round; `None` where neither
703/// can be had.
704fn integrable_face(
705    model: &Model,
706    face: &Shape,
707    tol: Tolerances,
708) -> OgeomResult<Option<Vec<ExactFace>>> {
709    if let Some(found) = exact_face(model, face, tol)? {
710        return Ok(Some(found));
711    }
712    Ok(crate::mass_chart::chart_face(model, face, tol)
713        .map(|chart| vec![ExactFace::Chart(Box::new(chart))]))
714}
715
716/// The exact-integrable regions of one face, or `None` where there are
717/// none.
718///
719/// One region per wire, and the integral is their sum: a face's outer
720/// boundary carries its own sign and every inner one the opposite, which
721/// is what a hole *is* under the divergence theorem. So a plate with a
722/// bore in it is a rectangle less a disc, and a tube's end face a disc
723/// less a disc, neither of which had to be meshed, and both of which
724/// were.
725fn exact_face(model: &Model, face: &Shape, tol: Tolerances) -> OgeomResult<Option<Vec<ExactFace>>> {
726    let Some(node) = model.node(face) else {
727        return Ok(None);
728    };
729    let NodeData::Face(data) = node.data() else {
730        return Ok(None);
731    };
732    let Some(surface) = model.geometry().surface(data.surface) else {
733        return Ok(None);
734    };
735    let analytic = matches!(
736        surface,
737        ogeom_geom::SurfaceGeometry::Plane(_)
738            | ogeom_geom::SurfaceGeometry::Cylinder(_)
739            | ogeom_geom::SurfaceGeometry::Cone(_)
740            | ogeom_geom::SurfaceGeometry::Sphere(_)
741            | ogeom_geom::SurfaceGeometry::Torus(_)
742            // A spline trimmed by its chart's own borders: a rectangle,
743            // integrated knot span by knot span, where each span is one
744            // polynomial piece the Gauss rule takes exactly.
745            | ogeom_geom::SurfaceGeometry::BSpline(_)
746            | ogeom_geom::SurfaceGeometry::Extrusion(_)
747            | ogeom_geom::SurfaceGeometry::Revolution(_)
748    );
749    if !analytic {
750        return Ok(None);
751    }
752    let placement = face.transform(model.datums())?;
753    // The chart rectangle comes from the pcurves, whose windows are the
754    // *unscaled* surface's; a scaling placement changes the chart's metric
755    // and the windows with it, so only rigid placements take the exact path.
756    if !matches!(
757        placement.kind(),
758        ogeom_math::TransformKind::Identity
759            | ogeom_math::TransformKind::Translation
760            | ogeom_math::TransformKind::Rotation
761    ) {
762        return Ok(None);
763    }
764    let placed = surface.clone().transformed(&placement, tol)?;
765    let sign = if face.orientation() == ogeom_topo::Orientation::Reversed {
766        -1.0
767    } else {
768        1.0
769    };
770
771    let wires = model.ordered_children_of(face)?;
772    // One region per wire, and the integral is their sum: a face's outer
773    // boundary carries its own sign and every inner one the opposite, which
774    // is what a hole *is* under the divergence theorem. So a plate with a
775    // bore is a rectangle less a disc, and a tube's end face a disc less a
776    // disc. Which wire is the boundary and which the holes is settled by
777    // the chart each covers: a hole is inside the boundary it is a hole in,
778    // so it covers less.
779    let mut regions = Vec::with_capacity(wires.len());
780    for wire in &wires {
781        let Some(region) = exact_wire(model, data, &placed, wire, sign, 1.0, tol)? else {
782            return Ok(None);
783        };
784        regions.push(region);
785    }
786    let Some(outer) = (0..regions.len()).max_by(|a, b| {
787        regions[*a]
788            .chart_area()
789            .total_cmp(&regions[*b].chart_area())
790    }) else {
791        return Ok(None);
792    };
793    for (index, region) in regions.iter_mut().enumerate() {
794        if index != outer {
795            region.take_away();
796        }
797    }
798    Ok(Some(regions))
799}
800
801/// Whether the faces agree with each other about which way is out.
802///
803/// The flag on a face is the only thing that says which side of its surface
804/// the material is on; no winding in this kernel says it, and the wires
805/// are wound however their builder wound them. But the flags can be asked
806/// *about each other*: an edge between two faces is walked by one of them
807/// with the material on its left and by the other with the material on its
808/// left too, so the two walks run opposite ways along it. Each face's walk
809/// is its outward normal crossed into the direction the material lies from
810/// the edge, and both of those are had for the asking: the normal from the
811/// flag, the material's direction from the chart, since a face's region
812/// lies around the middle of the boundary that encloses it.
813///
814/// A part in the corpus has a bore wall whose flag points into the solid.
815/// The tessellator repairs such a shell, flipping whichever side of the
816/// disagreement is in the minority, and the closed-form integral cannot:
817/// it would hand the bore back as material, a third of that part's volume.
818/// So where the flags disagree this says so and the mesh is asked instead.
819///
820/// An instanced solid says nothing here: one edge stands in several places
821/// and nothing in a name tells them apart, so its flags are taken as they
822/// come, which is what they were before there was anything to ask.
823fn flags_agree(model: &Model, shape: &Shape, tol: Tolerances) -> OgeomResult<bool> {
824    use ogeom_geom::Curve2d as _;
825    use ogeom_geom::Surface as _;
826    let placed_at = ogeom_topo::Location::default();
827    let mut walks: std::collections::HashMap<
828        ogeom_topo::TShapeId,
829        Vec<(bool, ogeom_topo::TShapeId, bool)>,
830    > = std::collections::HashMap::new();
831    for face in explore(model, shape, Filter::OfType(ShapeType::Face))? {
832        if face.location() != &placed_at {
833            return Ok(true);
834        }
835        let Some(data) = model.node(&face).and_then(|n| n.data().as_face()).cloned() else {
836            return Ok(true);
837        };
838        let Some(surface) = model.geometry().surface(data.surface) else {
839            return Ok(true);
840        };
841        let placed = surface
842            .clone()
843            .transformed(&face.transform(model.datums())?, tol)?;
844        let flag = if face.orientation() == ogeom_topo::Orientation::Reversed {
845            -1.0
846        } else {
847            1.0
848        };
849        // Where the boundary walks into closed chart loops, their windings
850        // say which side the face lies on at every point, concave or not.
851        if let Some(stations) = crate::mass_chart::material_sides(model, &face, tol) {
852            for (edge, at, toward) in stations {
853                let (du, dv) = placed.d1_at(at.x, at.y, tol)?;
854                let raw = du.cross(dv);
855                let inward = du * toward.x + dv * toward.y;
856                if raw.magnitude() <= tol.angular() || inward.magnitude() <= tol.angular() {
857                    return Ok(true);
858                }
859                let out = raw / raw.magnitude() * flag;
860                let walk = out.cross(inward / inward.magnitude());
861                let station = placed.point_at(at.x, at.y, tol)?;
862                let Some(along) = edge_heading(model, &edge, station, tol)? else {
863                    return Ok(true);
864                };
865                walks.entry(edge.node()).or_default().push((
866                    walk.dot(along) > 0.0,
867                    face.node(),
868                    true,
869                ));
870            }
871            continue;
872        }
873        // Otherwise each wire's middle in the chart stands in for the side
874        // the face lies on, and which wire is the boundary:
875        // the one covering the most of it, since a hole is inside what it
876        // is a hole in.
877        let wires = model.ordered_children_of(&face)?;
878        let mut middles: Vec<(ogeom_math::Point2, f64)> = Vec::with_capacity(wires.len());
879        let mut stations: Vec<Vec<(Shape, ogeom_math::Point2)>> = Vec::with_capacity(wires.len());
880        // How often each edge bounds this face: a seam the face uses once
881        // (a half band, cut along its seam) bounds it down one column only.
882        let mut uses: std::collections::HashMap<ogeom_topo::TShapeId, usize> =
883            std::collections::HashMap::new();
884        for wire in &wires {
885            for edge in model.ordered_children_of(wire)? {
886                *uses.entry(edge.node()).or_default() += 1;
887            }
888        }
889        for wire in &wires {
890            let mut here = Vec::new();
891            let mut sum = ogeom_math::Vector2::new(0.0, 0.0);
892            let (mut lo, mut hi) = (
893                ogeom_math::Point2::new(f64::INFINITY, f64::INFINITY),
894                ogeom_math::Point2::new(f64::NEG_INFINITY, f64::NEG_INFINITY),
895            );
896            // Seams used once, their column chosen once the rest of the
897            // wire says where the face lies: by the ends of the other
898            // pieces, which meet the used column and not the other.
899            let mut once: Vec<(Shape, [ogeom_topo::PCurveId; 2], (f64, f64))> = Vec::new();
900            let mut ends: Vec<ogeom_math::Point2> = Vec::new();
901            for edge in model.ordered_children_of(wire)? {
902                if edge.location() != &placed_at {
903                    return Ok(true);
904                }
905                let Some(repr) = model
906                    .node(&edge)
907                    .and_then(|n| n.data().as_edge())
908                    .and_then(|d| d.pcurve_for(data.surface, edge.location()))
909                else {
910                    return Ok(true);
911                };
912                // A seam bounds its face twice, once down each column.
913                let sides: Vec<(ogeom_topo::PCurveId, (f64, f64))> = match repr {
914                    EdgeRepr::PCurve { curve, range, .. } => vec![(*curve, *range)],
915                    EdgeRepr::Seam {
916                        forward,
917                        reversed,
918                        range,
919                        ..
920                    } if uses.get(&edge.node()) == Some(&1) => {
921                        once.push((edge.clone(), [*forward, *reversed], *range));
922                        continue;
923                    }
924                    EdgeRepr::Seam {
925                        forward,
926                        reversed,
927                        range,
928                        ..
929                    } => vec![(*forward, *range), (*reversed, *range)],
930                    _ => return Ok(true),
931                };
932                for (id, range) in sides {
933                    let Some(pcurve) = model.geometry().pcurve(id) else {
934                        return Ok(true);
935                    };
936                    ends.push(pcurve.point_at(range.0, tol)?);
937                    ends.push(pcurve.point_at(range.1, tol)?);
938                    // Several stations along each edge, not one: a wire of
939                    // a single closed edge has its own midpoint for a
940                    // middle, and nothing lies from a point toward itself.
941                    const STATIONS: usize = 4;
942                    for step in 1..=STATIONS {
943                        #[allow(clippy::cast_precision_loss)]
944                        let t =
945                            range.0 + (range.1 - range.0) * (step as f64 / (STATIONS + 1) as f64);
946                        let at = pcurve.point_at(t, tol)?;
947                        sum += at.to_vector();
948                        lo = ogeom_math::Point2::new(lo.x.min(at.x), lo.y.min(at.y));
949                        hi = ogeom_math::Point2::new(hi.x.max(at.x), hi.y.max(at.y));
950                        here.push((edge.clone(), at));
951                    }
952                }
953            }
954            if here.is_empty() && once.is_empty() {
955                return Ok(true);
956            }
957            // A seam used once runs down the column its neighbours meet.
958            for (edge, sides, range) in once {
959                let mut best: Option<(f64, ogeom_topo::PCurveId)> = None;
960                for id in sides {
961                    let Some(pcurve) = model.geometry().pcurve(id) else {
962                        return Ok(true);
963                    };
964                    let mut d = f64::INFINITY;
965                    for t in [range.0, range.1] {
966                        let at = pcurve.point_at(t, tol)?;
967                        for end in &ends {
968                            d = d.min(at.distance(*end));
969                        }
970                    }
971                    if best.is_none_or(|(held, _)| d < held) {
972                        best = Some((d, id));
973                    }
974                }
975                let Some((_, id)) = best else {
976                    return Ok(true);
977                };
978                let Some(pcurve) = model.geometry().pcurve(id) else {
979                    return Ok(true);
980                };
981                const STATIONS: usize = 4;
982                for step in 1..=STATIONS {
983                    #[allow(clippy::cast_precision_loss)]
984                    let t = range.0 + (range.1 - range.0) * (step as f64 / (STATIONS + 1) as f64);
985                    let at = pcurve.point_at(t, tol)?;
986                    sum += at.to_vector();
987                    lo = ogeom_math::Point2::new(lo.x.min(at.x), lo.y.min(at.y));
988                    hi = ogeom_math::Point2::new(hi.x.max(at.x), hi.y.max(at.y));
989                    here.push((edge.clone(), at));
990                }
991            }
992            #[allow(clippy::cast_precision_loss)]
993            let middle = ogeom_math::Point2::ORIGIN + sum / here.len() as f64;
994            middles.push((middle, (hi.x - lo.x) * (hi.y - lo.y)));
995            stations.push(here);
996        }
997        let Some(outer) = (0..middles.len()).max_by(|a, b| middles[*a].1.total_cmp(&middles[*b].1))
998        else {
999            return Ok(true);
1000        };
1001        for (index, here) in stations.into_iter().enumerate() {
1002            let (middle, _) = middles[index];
1003            for (edge, at) in here {
1004                let (du, dv) = placed.d1_at(at.x, at.y, tol)?;
1005                let raw = du.cross(dv);
1006                if raw.magnitude() <= tol.angular() {
1007                    return Ok(true);
1008                }
1009                let out = raw / raw.magnitude() * flag;
1010                // Which way the material lies from this point of the
1011                // boundary: toward the wire's middle for the face's outer
1012                // wire, away from it for a hole.
1013                let toward = middle - at;
1014                let toward = if index == outer { toward } else { -toward };
1015                let inward = du * toward.x + dv * toward.y;
1016                if inward.magnitude() <= tol.angular() {
1017                    return Ok(true);
1018                }
1019                let walk = out.cross(inward / inward.magnitude());
1020                // Against the edge's own direction, so the two faces'
1021                // answers can be compared without comparing vectors. The
1022                // direction is read where the edge's curve passes the
1023                // station, since neither a pcurve's parameter nor its sense
1024                // need be its curve's.
1025                let station = placed.point_at(at.x, at.y, tol)?;
1026                let Some(along) = edge_heading(model, &edge, station, tol)? else {
1027                    return Ok(true);
1028                };
1029                walks.entry(edge.node()).or_default().push((
1030                    walk.dot(along) > 0.0,
1031                    face.node(),
1032                    index == outer,
1033                ));
1034            }
1035        }
1036    }
1037    if std::env::var_os("OGEOM_DEBUG_MASS").is_some() {
1038        eprintln!("MASS flags_agree walked {} edges", walks.len());
1039    }
1040    for (edge, uses) in &walks {
1041        // An edge one face walks twice is that face's own seam, however it
1042        // is written down (a canonicalised drum keeps its as an ordinary
1043        // pcurve used twice), and one face's seam says nothing about
1044        // whether two faces agree.
1045        if uses.iter().all(|(_, owner, _)| *owner == uses[0].1) {
1046            continue;
1047        }
1048        let ahead = uses.iter().filter(|(ahead, ..)| *ahead).count();
1049        if ahead * 2 != uses.len() {
1050            if std::env::var_os("OGEOM_DEBUG_MASS").is_some() {
1051                eprintln!("MASS edge {} is walked {uses:?}", edge.index());
1052            }
1053            return Ok(false);
1054        }
1055    }
1056    Ok(true)
1057}
1058
1059/// An edge's own direction in space where its curve passes nearest `at`:
1060/// the best of a sampling over the edge's range, narrowed by golden
1061/// sections, and the curve's tangent there.
1062fn edge_heading(
1063    model: &Model,
1064    edge: &Shape,
1065    at: Point,
1066    tol: Tolerances,
1067) -> OgeomResult<Option<Vector>> {
1068    use ogeom_geom::Curve3d as _;
1069    let Some((curve, range)) = model
1070        .node(edge)
1071        .and_then(|n| n.data().as_edge())
1072        .and_then(|d| match d.curve3d()? {
1073            EdgeRepr::Curve3d { curve, range, .. } => Some((*curve, *range)),
1074            _ => None,
1075        })
1076        .and_then(|(id, range)| Some((model.geometry().curve(id)?.clone(), range)))
1077    else {
1078        return Ok(None);
1079    };
1080    let curve = curve.transformed(&edge.transform(model.datums())?, tol)?;
1081    let gap = |t: f64| -> OgeomResult<f64> { Ok(curve.point_at(t, tol)?.distance(at)) };
1082    const SAMPLES: u32 = 32;
1083    let step = (range.1 - range.0) / f64::from(SAMPLES);
1084    let mut best = (range.0, gap(range.0)?);
1085    for k in 1..=SAMPLES {
1086        let t = range.0 + step * f64::from(k);
1087        let d = gap(t)?;
1088        if d < best.1 {
1089            best = (t, d);
1090        }
1091    }
1092    let (mut a, mut b) = (
1093        (best.0 - step.abs()).max(range.0.min(range.1)),
1094        (best.0 + step.abs()).min(range.0.max(range.1)),
1095    );
1096    let ratio = (5.0_f64.sqrt() - 1.0) / 2.0;
1097    for _ in 0..60 {
1098        let (c, d) = (b - (b - a) * ratio, a + (b - a) * ratio);
1099        if gap(c)? < gap(d)? {
1100            b = d;
1101        } else {
1102            a = c;
1103        }
1104    }
1105    let along = curve.d1_at(f64::midpoint(a, b), tol)?;
1106    Ok((along.magnitude() > tol.angular()).then(|| along / along.magnitude()))
1107}
1108
1109/// The region one of a face's wires bounds, read off its pcurves.
1110fn exact_wire(
1111    model: &Model,
1112    data: &ogeom_topo::FaceData,
1113    placed: &ogeom_geom::SurfaceGeometry,
1114    wire: &Shape,
1115    sign: f64,
1116    share: f64,
1117    tol: Tolerances,
1118) -> OgeomResult<Option<ExactFace>> {
1119    use ogeom_geom::Surface as _;
1120    // Gather each boundary edge's chart segments on this face.
1121    let mut segments: Vec<(ogeom_math::Point2, ogeom_math::Point2)> = Vec::new();
1122    // Where a seam bounds the chart: a column at that `u`, a row at that `v`.
1123    let mut columns: Vec<f64> = Vec::new();
1124    let mut rows: Vec<f64> = Vec::new();
1125    let mut circle: Option<(ogeom_geom::Circle2d, f64)> = None;
1126    // Where the circle's arcs start and stop, for asking whether they tile
1127    // its turn or merely add up to one.
1128    let mut arc_ends: Vec<ogeom_math::Point2> = Vec::new();
1129    let mut pieces = 0_usize;
1130    // A seam bounds the face twice; its two chart sides are gathered once.
1131    let mut seams_seen: Vec<ogeom_topo::TShapeId> = Vec::new();
1132    for edge in model.ordered_children_of(wire)? {
1133        let Some(edge_data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
1134            return Ok(None);
1135        };
1136        let Some(repr) = edge_data.pcurve_for(data.surface, edge.location()) else {
1137            return Ok(None);
1138        };
1139        pieces += 1;
1140        match repr {
1141            EdgeRepr::PCurve { curve, range, .. } => {
1142                let Some(pcurve) = model.geometry().pcurve(*curve) else {
1143                    return Ok(None);
1144                };
1145                match pcurve {
1146                    ogeom_geom::PlanarCurve::Line(_) => {
1147                        use ogeom_geom::Curve2d as _;
1148                        let a = pcurve.point_at(range.0, tol)?;
1149                        let b = pcurve.point_at(range.1, tol)?;
1150                        segments.push((a, b));
1151                    }
1152                    // A trim fitted along a chart column or row (a rail a
1153                    // strip adopted from its neighbour): every control
1154                    // point on the one line makes it that segment.
1155                    ogeom_geom::PlanarCurve::BSpline(spline)
1156                        if along_one_chart_line(spline.control_points()) =>
1157                    {
1158                        use ogeom_geom::Curve2d as _;
1159                        let a = pcurve.point_at(range.0, tol)?;
1160                        let b = pcurve.point_at(range.1, tol)?;
1161                        segments.push((a, b));
1162                    }
1163                    ogeom_geom::PlanarCurve::Circle(arc) => {
1164                        use ogeom_geom::Curve2d as _;
1165                        // One circle's arcs, however many pieces the
1166                        // boundary arrives in. A boolean splits a closed rim
1167                        // to give the arrangement's walker somewhere to
1168                        // start, even a rim it never touched, and the disc
1169                        // those arcs bound is the same disc the whole turn
1170                        // bounded. The spans are summed and the total asked
1171                        // for a turn, so a fan of arcs that does not close
1172                        // is still no disc.
1173                        let span = (range.1 - range.0).abs();
1174                        arc_ends.push(pcurve.point_at(range.0, tol)?);
1175                        arc_ends.push(pcurve.point_at(range.1, tol)?);
1176                        match &mut circle {
1177                            None => circle = Some((*arc, span)),
1178                            Some((held, total)) => {
1179                                let (a, b) = (held.circle(), arc.circle());
1180                                if a.centre().distance(b.centre()) > tol.confusion()
1181                                    || (a.radius() - b.radius()).abs() > tol.confusion()
1182                                {
1183                                    return Ok(None);
1184                                }
1185                                *total += span;
1186                            }
1187                        }
1188                    }
1189                    _ => return Ok(None),
1190                }
1191            }
1192            EdgeRepr::Seam {
1193                forward,
1194                reversed,
1195                range,
1196                ..
1197            } => {
1198                use ogeom_geom::Curve2d as _;
1199                if seams_seen.contains(&edge.node()) {
1200                    pieces -= 1;
1201                    continue;
1202                }
1203                seams_seen.push(edge.node());
1204                // A seam says where the chart's edge stands, not how far
1205                // along it the face reaches. A reader pads its pcurve's
1206                // domain and a boolean shortens the face without shortening
1207                // the seam, so its own extent is worth nothing; the rims
1208                // are what say how tall the chart is, and they are ordinary
1209                // edges with ordinary ranges.
1210                for id in [forward, reversed] {
1211                    let Some(pcurve) = model.geometry().pcurve(*id) else {
1212                        return Ok(None);
1213                    };
1214                    let ogeom_geom::PlanarCurve::Line(_) = pcurve else {
1215                        return Ok(None);
1216                    };
1217                    let (lo, hi) = pcurve.domain();
1218                    let at = pcurve.point_at(range.0.clamp(lo, hi), tol)?;
1219                    let far = pcurve.point_at(range.1.clamp(lo, hi), tol)?;
1220                    if (at.x - far.x).abs() <= (at.y - far.y).abs() {
1221                        columns.push(at.x);
1222                    } else {
1223                        rows.push(at.y);
1224                    }
1225                }
1226            }
1227            _ => return Ok(None),
1228        }
1229    }
1230
1231    if let Some((arc, span)) = circle {
1232        // The disc: one circle's arcs and nothing else, closing a turn, on
1233        // a plane.
1234        let _ = pieces;
1235        if !segments.is_empty()
1236            || !columns.is_empty()
1237            || !rows.is_empty()
1238            || (span - core::f64::consts::TAU).abs() > tol.parametric().max(1e-9)
1239        {
1240            return Ok(None);
1241        }
1242        // And the arcs must *tile* the turn rather than add up to one. A
1243        // reader that re-bases each edge's range onto its own curve can
1244        // leave two arcs both starting at the circle's zero, one a quarter
1245        // of it and one three quarters, which sums to a turn while
1246        // covering a quarter of the circle twice and half of it never. Each
1247        // arc end meets exactly one other where they genuinely chain.
1248        let reach = tol.confusion() * 10.0;
1249        for (index, at) in arc_ends.iter().enumerate() {
1250            let met = arc_ends
1251                .iter()
1252                .enumerate()
1253                .filter(|(other, q)| *other != index && q.distance(*at) <= reach)
1254                .count();
1255            if met != 1 {
1256                return Ok(None);
1257            }
1258        }
1259        let ogeom_geom::SurfaceGeometry::Plane(plane) = placed else {
1260            return Ok(None);
1261        };
1262        let frame = plane.plane().frame();
1263        let centre2 = arc.circle().centre();
1264        let centre = placed.point_at(centre2.x, centre2.y, tol)?;
1265        let radius = arc.circle().radius();
1266        let normal = frame.z().vector();
1267        return Ok(Some(ExactFace::Disc {
1268            centre,
1269            e1: frame.x().vector(),
1270            e2: frame.y().vector(),
1271            normal,
1272            radius,
1273            sign,
1274            share,
1275        }));
1276    }
1277
1278    // A chart rectangle: every segment axis-aligned and on the hull's edge.
1279    // A torus's face is all seam and has no segments at all: two columns
1280    // and two rows, which are the rectangle.
1281    if segments.is_empty() && columns.is_empty() && rows.is_empty() {
1282        return Ok(None);
1283    }
1284    let (mut u0, mut u1) = (f64::INFINITY, f64::NEG_INFINITY);
1285    let (mut v0, mut v1) = (f64::INFINITY, f64::NEG_INFINITY);
1286    for (a, b) in &segments {
1287        for p in [a, b] {
1288            u0 = u0.min(p.x);
1289            u1 = u1.max(p.x);
1290            v0 = v0.min(p.y);
1291            v1 = v1.max(p.y);
1292        }
1293    }
1294    for u in &columns {
1295        u0 = u0.min(*u);
1296        u1 = u1.max(*u);
1297    }
1298    for v in &rows {
1299        v0 = v0.min(*v);
1300        v1 = v1.max(*v);
1301    }
1302    if !(u0.is_finite() && u1.is_finite() && v0.is_finite() && v1.is_finite()) {
1303        return Ok(None);
1304    }
1305    if u1 - u0 <= tol.confusion() || v1 - v0 <= tol.confusion() {
1306        return Ok(None);
1307    }
1308    let eps = tol.confusion().max(1e-9 * (u1 - u0).max(v1 - v0));
1309    let on_side =
1310        |value: f64, lo: f64, hi: f64| (value - lo).abs() <= eps || (value - hi).abs() <= eps;
1311    let mut perimeter = 0.0;
1312    for (a, b) in &segments {
1313        let horizontal = (a.y - b.y).abs() <= eps;
1314        let vertical = (a.x - b.x).abs() <= eps;
1315        if !(horizontal ^ vertical) {
1316            return Ok(None);
1317        }
1318        if horizontal && !on_side(a.y, v0, v1) {
1319            return Ok(None);
1320        }
1321        if vertical && !on_side(a.x, u0, u1) {
1322            return Ok(None);
1323        }
1324        perimeter += a.distance(*b);
1325    }
1326    #[allow(clippy::cast_precision_loss)]
1327    for (values, lo, hi, span) in [(&columns, u0, u1, v1 - v0), (&rows, v0, v1, u1 - u0)] {
1328        for value in values {
1329            if !on_side(*value, lo, hi) {
1330                return Ok(None);
1331            }
1332            perimeter += span;
1333        }
1334    }
1335    let expected = 2.0 * ((u1 - u0) + (v1 - v0));
1336    if (perimeter - expected).abs() > 1e-6 * expected {
1337        return Ok(None);
1338    }
1339    Ok(Some(ExactFace::ChartRectangle {
1340        surface: placed.clone(),
1341        rect: (u0, u1, v0, v1),
1342        sign,
1343        share,
1344    }))
1345}
1346
1347/// Running totals over simplices, measured from a fixed reference point.
1348///
1349/// The moments are accumulated about a *reference near the shape*, not about
1350/// the world origin, and that is a numerical decision rather than a stylistic
1351/// one. The inertia about the centre is a difference of two second moments, so
1352/// for a part sitting a million units from the origin the two terms agree to
1353/// twelve digits and their difference keeps four. Referencing the shape's own
1354/// bounding box keeps every intermediate the size of the shape.
1355///
1356/// The moments are about a fixed point rather than a running centre because the
1357/// centre is not known until the last simplex is in, and a moment about a
1358/// moving point is not a sum of anything.
1359struct Accumulator {
1360    /// Where the moments are measured from: the first point seen, so it is
1361    /// always somewhere on the shape.
1362    reference: Option<Point>,
1363    mass: f64,
1364    /// The first moment `∫ (x − r)`, which divided by the mass gives the centre
1365    /// relative to the reference.
1366    first: Vector,
1367    /// The second moment `∫ (x − r)(x − r)ᵀ`.
1368    second: Matrix3,
1369}
1370
1371impl Accumulator {
1372    const fn new() -> Self {
1373        Self {
1374            reference: None,
1375            mass: 0.0,
1376            first: Vector::ZERO,
1377            second: Matrix3::ZERO,
1378        }
1379    }
1380
1381    /// Add one simplex: 2 points for a segment, 3 for a triangle, 4 for a
1382    /// tetrahedron, with its signed or unsigned measure.
1383    fn add(&mut self, points: &[Point], measure: f64) {
1384        if points.is_empty() || measure == 0.0 || !measure.is_finite() {
1385            return;
1386        }
1387        let n = points.len();
1388        #[allow(clippy::cast_precision_loss)]
1389        let count = n as f64;
1390        let reference = *self.reference.get_or_insert(points[0]);
1391        let local: Vec<Vector> = points.iter().map(|p| *p - reference).collect();
1392        let sum: Vector = local.iter().fold(Vector::ZERO, |a, v| a + *v);
1393
1394        self.mass += measure;
1395        self.first += sum * (measure / count);
1396
1397        // ∫ x_i x_j = m/(n(n+1)) · [ Σ p p_ᵀ + (Σ p)(Σ p)ᵀ ]; see the module
1398        // docs. The n(n+1) is the barycentric integral collapsing.
1399        let scale = measure / (count * (count + 1.0));
1400        let mut term = outer(sum, sum);
1401        for v in &local {
1402            term = add(term, outer(*v, *v));
1403        }
1404        self.second = add(self.second, scale_matrix(term, scale));
1405    }
1406
1407    /// Turn the running totals into the answer.
1408    fn finish(self, deflection: f64) -> MassProperties {
1409        if self.mass.abs() <= f64::MIN_POSITIVE {
1410            return MassProperties::none(deflection);
1411        }
1412        let offset = self.first / self.mass;
1413        let centre = self.reference.unwrap_or(Point::ORIGIN) + offset;
1414
1415        // Inertia about the reference from the second moment: I = tr(S)·1 − S.
1416        let trace = self.second.rows[0][0] + self.second.rows[1][1] + self.second.rows[2][2];
1417        let about_reference = add(
1418            scale_matrix(Matrix3::IDENTITY, trace),
1419            scale_matrix(self.second, -1.0),
1420        );
1421        // Then shift to the centre, the reverse of `inertia_about`.
1422        let inertia = add(
1423            about_reference,
1424            scale_matrix(displacement_term(self.mass, offset), -1.0),
1425        );
1426
1427        MassProperties {
1428            mass: self.mass.abs(),
1429            centre,
1430            inertia,
1431            deflection,
1432        }
1433    }
1434}
1435
1436/// The parallel-axis contribution of a mass displaced by `d`.
1437fn displacement_term(mass: f64, d: Vector) -> Matrix3 {
1438    let squared = d.dot(d);
1439    add(
1440        scale_matrix(Matrix3::IDENTITY, mass * squared),
1441        scale_matrix(outer(d, d), -mass),
1442    )
1443}
1444
1445/// The outer product `a bᵀ`.
1446fn outer(a: Vector, b: Vector) -> Matrix3 {
1447    Matrix3::new([
1448        [a.x * b.x, a.x * b.y, a.x * b.z],
1449        [a.y * b.x, a.y * b.y, a.y * b.z],
1450        [a.z * b.x, a.z * b.y, a.z * b.z],
1451    ])
1452}
1453
1454/// Element-wise sum.
1455fn add(a: Matrix3, b: Matrix3) -> Matrix3 {
1456    let mut rows = a.rows;
1457    for (row, other) in rows.iter_mut().zip(b.rows) {
1458        for (value, addend) in row.iter_mut().zip(other) {
1459            *value += addend;
1460        }
1461    }
1462    Matrix3::new(rows)
1463}
1464
1465/// Element-wise scaling.
1466fn scale_matrix(m: Matrix3, s: f64) -> Matrix3 {
1467    let mut rows = m.rows;
1468    for row in &mut rows {
1469        for value in row {
1470            *value *= s;
1471        }
1472    }
1473    Matrix3::new(rows)
1474}
1475
1476/// `vᵀ M v`.
1477fn quadratic_form(m: Matrix3, v: Vector) -> f64 {
1478    let c = [v.x, v.y, v.z];
1479    let mut sum = 0.0;
1480    for (i, ci) in c.iter().enumerate() {
1481        for (j, cj) in c.iter().enumerate() {
1482            sum += ci * m.rows[i][j] * cj;
1483        }
1484    }
1485    sum
1486}
1487
1488/// Whether a chart curve's control points all stand on one column or one
1489/// row of the chart, to rounding against its extent.
1490fn along_one_chart_line(control: &[ogeom_math::Weighted<ogeom_math::Point2>]) -> bool {
1491    let points: Vec<ogeom_math::Point2> = control.iter().map(|w| w.point()).collect();
1492    let Some(first) = points.first() else {
1493        return false;
1494    };
1495    let extent = points
1496        .iter()
1497        .map(|p| p.distance(*first))
1498        .fold(0.0_f64, f64::max);
1499    let eps = 1e-9 * extent.max(1.0);
1500    points.iter().all(|p| (p.x - first.x).abs() <= eps)
1501        || points.iter().all(|p| (p.y - first.y).abs() <= eps)
1502}
1503
1504#[cfg(test)]
1505#[allow(clippy::unwrap_used)]
1506mod tests {
1507    use super::*;
1508    use crate::make_box;
1509    use approx::assert_relative_eq;
1510    use ogeom_math::Frame;
1511
1512    const T: Tolerances = Tolerances::millimetres();
1513
1514    fn fine() -> Deflection {
1515        Deflection {
1516            chord: 1e-3,
1517            angular: 0.05,
1518            ..Deflection::default()
1519        }
1520    }
1521
1522    #[test]
1523    fn analytic_primitives_measure_exactly_on_their_own_surfaces() {
1524        // The exact path reports zero deflection and machine-precision
1525        // numbers: no chord band, no inscribed deficit.
1526        let mut model = Model::new();
1527        let pi = core::f64::consts::PI;
1528
1529        let cylinder = crate::make_cylinder(&mut model, Frame::WORLD, 2.0, 5.0, T).unwrap();
1530        let props = volume_properties(&model, &cylinder.shape, fine(), T).unwrap();
1531        assert_eq!(props.deflection, 0.0, "the exact path was taken");
1532        assert_relative_eq!(props.mass, pi * 4.0 * 5.0, epsilon = 1e-10);
1533        assert!(props.centre.is_equal(Point::new(0.0, 0.0, 2.5), T));
1534        // I_zz of a solid cylinder: m r^2 / 2.
1535        let m = pi * 4.0 * 5.0;
1536        assert_relative_eq!(props.inertia.rows[2][2], m * 4.0 / 2.0, epsilon = 1e-8);
1537
1538        let sphere = crate::make_sphere(&mut model, Frame::WORLD, 3.0, T).unwrap();
1539        let props = volume_properties(&model, &sphere.shape, fine(), T).unwrap();
1540        assert_eq!(props.deflection, 0.0);
1541        assert_relative_eq!(props.mass, 4.0 / 3.0 * pi * 27.0, epsilon = 1e-10);
1542        // I = 2/5 m r^2 about any axis through the centre.
1543        let m = 4.0 / 3.0 * pi * 27.0;
1544        assert_relative_eq!(props.inertia.rows[0][0], 0.4 * m * 9.0, epsilon = 1e-8);
1545
1546        let torus = crate::make_torus(&mut model, Frame::WORLD, 5.0, 1.5, T).unwrap();
1547        let props = volume_properties(&model, &torus.shape, fine(), T).unwrap();
1548        assert_eq!(props.deflection, 0.0);
1549        assert_relative_eq!(props.mass, 2.0 * pi * pi * 5.0 * 1.5 * 1.5, epsilon = 1e-10);
1550
1551        let cone = crate::make_cone(&mut model, Frame::WORLD, 3.0, 1.0, 4.0, T).unwrap();
1552        let props = volume_properties(&model, &cone.shape, fine(), T).unwrap();
1553        assert_eq!(props.deflection, 0.0);
1554        // A frustum: pi h (R^2 + R r + r^2) / 3.
1555        assert_relative_eq!(
1556            props.mass,
1557            pi * 4.0 * (9.0 + 3.0 + 1.0) / 3.0,
1558            epsilon = 1e-10
1559        );
1560
1561        // Areas ride the same path: a sphere's is 4 pi r^2, exactly.
1562        let props = surface_properties(&model, &sphere.shape, fine(), T).unwrap();
1563        assert_eq!(props.deflection, 0.0);
1564        assert_relative_eq!(props.mass, 4.0 * pi * 9.0, epsilon = 1e-10);
1565    }
1566
1567    #[test]
1568    fn a_box_has_the_volume_centre_and_inertia_a_box_has() {
1569        // Every number here is one a textbook states, which is the point: the
1570        // simplex formula is general, and a general formula that gets the one
1571        // case everybody knows wrong is worth nothing.
1572        let (dx, dy, dz) = (2.0, 3.0, 4.0);
1573        let mut model = Model::new();
1574        let built = make_box(&mut model, Frame::WORLD, (dx, dy, dz), T).unwrap();
1575
1576        let props = volume_properties(&model, &built.shape, fine(), T).unwrap();
1577        assert_relative_eq!(props.mass, dx * dy * dz, epsilon = 1e-9);
1578        assert!(
1579            props
1580                .centre
1581                .is_equal(Point::new(dx / 2.0, dy / 2.0, dz / 2.0), T),
1582            "the centre of a box is its middle, got {:?}",
1583            props.centre
1584        );
1585
1586        // I_xx = m(dy² + dz²)/12, and so round.
1587        let m = dx * dy * dz;
1588        assert_relative_eq!(
1589            props.inertia.rows[0][0],
1590            m * dz.mul_add(dz, dy * dy) / 12.0,
1591            epsilon = 1e-9
1592        );
1593        assert_relative_eq!(
1594            props.inertia.rows[1][1],
1595            m * dz.mul_add(dz, dx * dx) / 12.0,
1596            epsilon = 1e-9
1597        );
1598        assert_relative_eq!(
1599            props.inertia.rows[2][2],
1600            m * dy.mul_add(dy, dx * dx) / 12.0,
1601            epsilon = 1e-9
1602        );
1603        // A box is symmetric about its own axes, so the products vanish.
1604        for (i, j) in [(0, 1), (0, 2), (1, 2)] {
1605            assert_relative_eq!(props.inertia.rows[i][j], 0.0, epsilon = 1e-9);
1606            assert_relative_eq!(props.inertia.rows[j][i], 0.0, epsilon = 1e-9);
1607        }
1608    }
1609
1610    #[test]
1611    fn a_box_has_the_area_and_edge_length_a_box_has() {
1612        let (dx, dy, dz) = (2.0, 3.0, 4.0);
1613        let mut model = Model::new();
1614        let built = make_box(&mut model, Frame::WORLD, (dx, dy, dz), T).unwrap();
1615
1616        let area = surface_properties(&model, &built.shape, fine(), T).unwrap();
1617        assert_relative_eq!(
1618            area.mass,
1619            2.0 * dz.mul_add(dx, dx.mul_add(dy, dy * dz)),
1620            epsilon = 1e-9
1621        );
1622        assert!(
1623            area.centre
1624                .is_equal(Point::new(dx / 2.0, dy / 2.0, dz / 2.0), T)
1625        );
1626
1627        // Four edges in each direction, counted once each however many faces
1628        // they bound.
1629        let length = linear_properties(&model, &built.shape, fine(), T).unwrap();
1630        assert_relative_eq!(length.mass, 4.0 * (dx + dy + dz), epsilon = 1e-9);
1631        assert!(
1632            length
1633                .centre
1634                .is_equal(Point::new(dx / 2.0, dy / 2.0, dz / 2.0), T)
1635        );
1636    }
1637
1638    #[test]
1639    fn the_answer_does_not_depend_on_where_the_shape_sits() {
1640        // The inertia is about the centre, so translating the box must leave it
1641        // alone and move only the centre. An inertia accidentally left about
1642        // the origin would grow with the distance.
1643        let mut model = Model::new();
1644        let here = make_box(&mut model, Frame::WORLD, (1.0, 2.0, 3.0), T).unwrap();
1645        let far = Frame::new(
1646            Point::new(100.0, -50.0, 25.0),
1647            Direction::Z,
1648            Direction::X,
1649            T,
1650        )
1651        .unwrap();
1652        let there = make_box(&mut model, far, (1.0, 2.0, 3.0), T).unwrap();
1653
1654        let a = volume_properties(&model, &here.shape, fine(), T).unwrap();
1655        let b = volume_properties(&model, &there.shape, fine(), T).unwrap();
1656
1657        assert_relative_eq!(a.mass, b.mass, epsilon = 1e-9);
1658        assert!(
1659            b.centre
1660                .is_equal(a.centre + Vector::new(100.0, -50.0, 25.0), T)
1661        );
1662        for i in 0..3 {
1663            for j in 0..3 {
1664                assert_relative_eq!(a.inertia.rows[i][j], b.inertia.rows[i][j], epsilon = 1e-6);
1665            }
1666        }
1667    }
1668
1669    #[test]
1670    fn a_part_a_long_way_from_the_origin_keeps_its_precision() {
1671        // The reason the moments are accumulated about a point on the shape.
1672        // About the world origin the two terms of the inertia agree to twelve
1673        // digits at this distance and their difference keeps four, so the answer
1674        // would come back with a few percent of noise in it, or negative.
1675        let mut model = Model::new();
1676        let far = Frame::new(
1677            Point::new(1.0e6, -2.0e6, 5.0e5),
1678            Direction::Z,
1679            Direction::X,
1680            T,
1681        )
1682        .unwrap();
1683        let built = make_box(&mut model, far, (2.0, 3.0, 4.0), T).unwrap();
1684        let props = volume_properties(&model, &built.shape, fine(), T).unwrap();
1685
1686        assert_relative_eq!(props.mass, 24.0, epsilon = 1e-6);
1687        assert_relative_eq!(
1688            props.inertia.rows[0][0],
1689            24.0 * 4.0_f64.mul_add(4.0, 3.0 * 3.0) / 12.0,
1690            epsilon = 1e-6
1691        );
1692        for (i, j) in [(0, 1), (0, 2), (1, 2)] {
1693            assert_relative_eq!(props.inertia.rows[i][j], 0.0, epsilon = 1e-6);
1694        }
1695    }
1696
1697    #[test]
1698    fn moving_the_inertia_off_the_centre_agrees_with_the_parallel_axis_theorem() {
1699        let mut model = Model::new();
1700        let built = make_box(&mut model, Frame::WORLD, (2.0, 2.0, 2.0), T).unwrap();
1701        let props = volume_properties(&model, &built.shape, fine(), T).unwrap();
1702
1703        // A cube of side a about a face-centre axis: I = m(a²/6 + a²/4).
1704        let m = 8.0;
1705        let corner = props.inertia_about(Point::ORIGIN);
1706        assert_relative_eq!(
1707            corner.rows[0][0],
1708            2.0_f64.mul_add(2.0, 2.0 * 2.0).mul_add(m / 12.0, m * 2.0),
1709            epsilon = 1e-9
1710        );
1711        // And about its own centre it is the smallest it can be.
1712        assert!(corner.rows[0][0] > props.inertia.rows[0][0]);
1713    }
1714
1715    #[test]
1716    fn a_cubes_principal_moments_are_all_the_same() {
1717        // Full rotational symmetry: every axis is principal, so the three
1718        // moments must agree. Axes that came back non-orthogonal would mean the
1719        // solver was handed a non-symmetric tensor, which would itself be a bug.
1720        let mut model = Model::new();
1721        let built = make_box(&mut model, Frame::WORLD, (2.0, 2.0, 2.0), T).unwrap();
1722        let props = volume_properties(&model, &built.shape, fine(), T).unwrap();
1723
1724        let axes = props.principal_axes(T).unwrap();
1725        let expected = 8.0 * 2.0_f64.mul_add(2.0, 2.0 * 2.0) / 12.0;
1726        for (moment, _) in &axes {
1727            assert_relative_eq!(*moment, expected, epsilon = 1e-6);
1728        }
1729        for (i, j) in [(0, 1), (0, 2), (1, 2)] {
1730            assert_relative_eq!(
1731                axes[i].1.vector().dot(axes[j].1.vector()),
1732                0.0,
1733                epsilon = 1e-9
1734            );
1735        }
1736    }
1737
1738    #[test]
1739    fn a_long_box_spins_most_easily_about_its_length() {
1740        let mut model = Model::new();
1741        let built = make_box(&mut model, Frame::WORLD, (10.0, 1.0, 1.0), T).unwrap();
1742        let props = volume_properties(&model, &built.shape, fine(), T).unwrap();
1743
1744        let axes = props.principal_axes(T).unwrap();
1745        // The smallest moment is about the long axis.
1746        assert!(axes[0].1.vector().x.abs() > 0.99, "got {:?}", axes[0].1);
1747        assert!(axes[0].0 < axes[1].0 && axes[1].0 <= axes[2].0);
1748
1749        let along = props.radius_of_gyration(Direction::X).unwrap();
1750        let across = props.radius_of_gyration(Direction::Y).unwrap();
1751        assert!(along < across, "{along} should be less than {across}");
1752    }
1753
1754    #[test]
1755    fn a_sphere_converges_on_the_volume_a_sphere_has() {
1756        // The case a planar-exact implementation would get quietly wrong. The
1757        // tessellation inscribes the sphere, so the volume comes in under the
1758        // truth and climbs as the deflection tightens, and the deflection is
1759        // reported, so a caller can see how far under.
1760        use crate::build::make_natural_face;
1761        use ogeom_geom::SphereSurface;
1762        use ogeom_math::Sphere;
1763
1764        let radius = 5.0_f64;
1765        let exact = 4.0 / 3.0 * std::f64::consts::PI * radius.powi(3);
1766        let mut previous = 0.0;
1767
1768        for chord in [0.5_f64, 0.1, 0.02] {
1769            let mut model = Model::new();
1770            let surface = SphereSurface::new(Sphere::new(Frame::WORLD, radius, T).unwrap());
1771            let face = make_natural_face(&mut model, surface.into()).unwrap().shape;
1772            let shell = crate::build::make_shell(&mut model, std::slice::from_ref(&face))
1773                .unwrap()
1774                .shape;
1775
1776            let deflection = Deflection {
1777                chord,
1778                ..Deflection::default()
1779            };
1780            let props = volume_properties(&model, &shell, deflection, T).unwrap();
1781            assert_relative_eq!(props.deflection, chord);
1782            assert!(props.mass < exact, "an inscribed volume cannot exceed it");
1783            assert!(
1784                props.mass > previous,
1785                "tightening the chord lost volume: {} after {previous}",
1786                props.mass
1787            );
1788            assert!(
1789                props
1790                    .centre
1791                    .is_equal(Point::ORIGIN, Tolerances::with_scale(1e4).unwrap()),
1792                "a sphere's centre is its centre, got {:?}",
1793                props.centre
1794            );
1795            previous = props.mass;
1796        }
1797        assert!(
1798            previous > exact * 0.99,
1799            "{previous} should be within a percent of {exact}"
1800        );
1801    }
1802
1803    #[test]
1804    fn an_open_shell_is_refused_rather_than_measured() {
1805        // Half a boundary encloses nothing, and the divergence theorem applied
1806        // to it returns a number that looks like a volume and is not one.
1807        let mut model = Model::new();
1808        let built = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
1809        let face = explore_unique(&model, &built.shape, ShapeType::Face).unwrap()[0].clone();
1810
1811        assert!(volume_properties(&model, &face, fine(), T).is_err());
1812        // Its area, though, is perfectly well defined.
1813        let area = surface_properties(&model, &face, fine(), T).unwrap();
1814        assert_relative_eq!(area.mass, 1.0, epsilon = 1e-9);
1815    }
1816
1817    #[test]
1818    fn an_inward_shell_is_refused_rather_than_reported_as_negative() {
1819        let mut model = Model::new();
1820        let built = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
1821        assert!(volume_properties(&model, &built.shape.reversed(), fine(), T).is_err());
1822    }
1823
1824    #[test]
1825    fn a_shape_with_nothing_to_measure_says_so_rather_than_dividing_by_zero() {
1826        let mut model = Model::new();
1827        let vertex = model.add_point(Point::ORIGIN);
1828
1829        for props in [
1830            volume_properties(&model, &vertex, fine(), T).unwrap(),
1831            surface_properties(&model, &vertex, fine(), T).unwrap(),
1832            linear_properties(&model, &vertex, fine(), T).unwrap(),
1833        ] {
1834            assert_relative_eq!(props.mass, 0.0);
1835            assert!(props.centre.is_equal(Point::ORIGIN, T));
1836            assert!(props.radius_of_gyration(Direction::Z).is_none());
1837        }
1838    }
1839
1840    #[test]
1841    fn an_unusable_deflection_is_refused() {
1842        let mut model = Model::new();
1843        let built = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
1844        let bad = Deflection {
1845            chord: -1.0,
1846            ..Deflection::default()
1847        };
1848        assert!(volume_properties(&model, &built.shape, bad, T).is_err());
1849        assert!(surface_properties(&model, &built.shape, bad, T).is_err());
1850        assert!(linear_properties(&model, &built.shape, bad, T).is_err());
1851    }
1852}