Skip to main content

axiolid_measure/
exact.rs

1//! Mass properties of an exact B-rep, without tessellating it.
2//!
3//! # Why this exists separately
4//!
5//! [`crate::mesh_measure::MeshMeasure`] measures a `TriMesh`. An exact B-rep
6//! has no triangles, so measuring one previously meant tessellating first --
7//! which converts an exact solid into an approximation before measuring it,
8//! and then reports the approximation's volume as though it were the solid's.
9//!
10//! This path measures the exact representation directly. For a planar face
11//! bounded by straight edges the divergence theorem is exact over the
12//! polygonal boundary, so a prism's volume comes back at machine precision
13//! rather than at tessellation fidelity.
14//!
15//! A curved face, or a planar face with a curved edge, is integrated over its
16//! own parameter domain by Green's theorem round the face's pcurves
17//! (module `exact_face`): the exact surface and the exact trimming curves,
18//! with adaptive Gauss-Kronrod quadrature held to a relative error near
19//! machine precision. Nothing is faceted.
20//!
21//! # Deliberate refusal
22//!
23//! An unknown surface family, a face whose boundary does not enclose a domain
24//! in its parameters, and an integral that does not converge are refused by
25//! name. Approximating any of them would silently reintroduce exactly the
26//! tessellation error this path exists to avoid.
27
28use axiolid_brep::ExactBRep;
29use axiolid_core::{Point3, Scalar, Tolerance, Transform3, Vec3};
30use axiolid_curve::Curve3;
31use axiolid_surface::Surface;
32use axiolid_topology::{Face, LoopId, Orientation};
33use core::fmt;
34
35use crate::MassProperties;
36
37/// Components integrated per face: area, volume, three first moments and
38/// three second moments.
39pub(crate) const COMPONENTS: usize = 8;
40
41/// One value per component, in [`COMPONENTS`] order.
42pub(crate) type Sums = [Scalar; COMPONENTS];
43
44/// Why an exact B-rep could not be measured.
45///
46/// Kept exactly as published (exhaustive, four variants): a face this
47/// module cannot integrate -- a surface family it has no integral for, a
48/// face whose pcurves bound no domain, a surface it cannot evaluate there,
49/// an integral short of its error bound -- is `NonPlanarFace`, whose name
50/// predates curved faces, with the reason. Richer variants wait for a
51/// coordinated breaking release.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum ExactMeasureError {
54    /// A face cannot be integrated; the reason says why (originally only
55    /// "its support surface is not planar", hence the name).
56    NonPlanarFace(&'static str),
57    /// A face has no support surface attached.
58    MissingSurface,
59    /// An edge referenced geometry the B-rep does not contain.
60    DanglingReference,
61    /// The boundary enclosed no volume.
62    Degenerate,
63}
64
65/// A surface or pcurve could not be evaluated where the face needs it.
66pub(crate) const EVALUATION: ExactMeasureError =
67    ExactMeasureError::NonPlanarFace("surface or pcurve not evaluable on the face");
68
69/// A face integral did not reach its error bound.
70pub(crate) const NOT_CONVERGED: ExactMeasureError = ExactMeasureError::NonPlanarFace(
71    "curved face integral short of its error bound; tessellate and use MeshMeasure",
72);
73
74impl fmt::Display for ExactMeasureError {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        match self {
77            Self::NonPlanarFace(why) => write!(
78                f,
79                "exact measurement cannot integrate a face ({why}). \
80                 Tessellate and use MeshMeasure for an approximate answer."
81            ),
82            Self::MissingSurface => f.write_str("a face has no support surface"),
83            Self::DanglingReference => {
84                f.write_str("a boundary element references missing geometry")
85            }
86            Self::Degenerate => f.write_str("the boundary encloses no volume"),
87        }
88    }
89}
90
91impl std::error::Error for ExactMeasureError {}
92
93/// Name the surface family for a refusal that tells the caller what to add.
94pub(crate) fn family(surface: &Surface) -> &'static str {
95    match surface {
96        Surface::Plane(_) => "plane",
97        Surface::Cylinder(_) => "cylindrical",
98        Surface::Cone(_) => "conical",
99        Surface::Sphere(_) => "spherical",
100        Surface::EllipticalCylinder(_) => "elliptical-cylindrical",
101        Surface::Torus(_) => "toroidal",
102        _ => "non-planar",
103    }
104}
105
106/// Mass properties of an exact B-rep.
107///
108/// # Method
109///
110/// A planar face bounded by straight edges is a polygon in 3-space. Fanning
111/// it into triangles about its first vertex and applying the divergence
112/// theorem gives volume, centroid and second moments in one pass -- the same
113/// closed forms [`crate::mesh`] uses, but over the B-rep's own boundary
114/// polygons rather than over a tessellation of them. For planar faces the fan
115/// is not an approximation: a planar polygon is exactly the union of its fan
116/// triangles.
117///
118/// Every other face -- cylinders, cones, spheres, tori, elliptical
119/// cylinders, B-spline surfaces, and planar faces with an arc or ellipse on
120/// their boundary -- contributes the same cone integrals over its own
121/// parameter domain, by Green's theorem round its pcurves (see
122/// `exact_face`). Both paths use the same fields, so a solid mixing
123/// them sums consistently.
124///
125/// Every field is integrated about a point of the solid -- the centre of
126/// its vertices' bounding box -- not about the world origin, and the
127/// centroid and second moments are shifted back by the parallel-axis
128/// terms (#343). The cone of a face about the world origin grows with the
129/// cube of the distance to it: a 0.57 m^3 box on a national grid at
130/// (6e5, 5.6e6) summed terms near 1e14 whose cancellation had to produce
131/// the volume, and measured up to 18 % wrong with no error. About its own
132/// centre the same box measures its closed form up to the grid its corners
133/// were placed on, wherever it sits.
134///
135/// Orientation is honoured at every level: a face marked
136/// [`Orientation::Reversed`], a reversed use of a face in its shell, and a
137/// reversed bound each flip the loop's winding, exactly as `audit_brep`
138/// reads them. A correctly built solid yields a positive volume wherever it
139/// sits, without the caller pre-normalising anything. Every shell is summed,
140/// so a void shell, used reversed, subtracts its volume.
141///
142/// # Errors
143///
144/// Refuses an unknown surface family, a face whose pcurves do not bound a
145/// domain, an integral that does not converge, and a boundary that encloses
146/// nothing -- never approximating any of them.
147pub fn exact_properties(
148    brep: &ExactBRep,
149    tolerance: Tolerance,
150) -> Result<MassProperties, ExactMeasureError> {
151    let origin = reference_point(brep);
152    // Integrate a copy moved to `origin`, so the surfaces are evaluated
153    // near their own coordinates too: subtracting `origin` from a point
154    // evaluated on a national grid keeps that grid's rounding, about 1e-9 m,
155    // which no curved-face integral can then hold to its relative bound. A
156    // B-rep the translation refuses (an elevated or banked alignment curve)
157    // is integrated in place about `origin`: planar faces just as well; a
158    // curved face far from the world origin may then be refused as not
159    // converged, never measured looser.
160    let moved = if origin == Point3::ZERO {
161        None
162    } else {
163        brep.transformed(&Transform3::from_translation(-origin))
164            .ok()
165    };
166    match &moved {
167        Some(moved) => measure_about(moved, Point3::ZERO, origin, tolerance),
168        None => measure_about(brep, origin, origin, tolerance),
169    }
170}
171
172/// [`exact_properties`] of `brep` with every field integrated about
173/// `about`, in `brep`'s coordinates, and the centroid and second moments
174/// shifted home by `home`: `brep` is the solid moved by `-(home - about)`.
175fn measure_about(
176    brep: &ExactBRep,
177    about: Point3,
178    home: Point3,
179    tolerance: Tolerance,
180) -> Result<MassProperties, ExactMeasureError> {
181    let topology = brep.topology();
182    let scale = characteristic_length(brep, about);
183    // Consecutive pcurves must meet on the surface. The caller's tolerance
184    // governs, with a floor for `Tolerance::ZERO` so rounding in the pcurve
185    // evaluation itself is not read as a gap.
186    let linear = tolerance.linear().max(1e-9 * scale);
187    let mut area = 0.0;
188    let mut volume = 0.0;
189    let mut volume_weighted = Point3::ZERO;
190    let mut moments = Vec3::ZERO;
191
192    // How each face is used by the shell that holds it. A face outside
193    // every shell (a bare face table) is taken as used forward.
194    let mut shell_sense = vec![Orientation::Forward; topology.faces().len()];
195    for shell in topology.shells() {
196        for &(face_id, sense) in &shell.faces {
197            if let Some(slot) = shell_sense.get_mut(face_id.index()) {
198                *slot = sense;
199            }
200        }
201    }
202
203    for (face_index, face) in topology.faces().iter().enumerate() {
204        let surface_id = face.surface.ok_or(ExactMeasureError::MissingSurface)?;
205        let surface = brep
206            .surfaces()
207            .get(surface_id.index())
208            .ok_or(ExactMeasureError::DanglingReference)?;
209        let flip_face = (shell_sense[face_index] == Orientation::Reversed)
210            ^ (face.orientation == Orientation::Reversed);
211
212        if matches!(surface, Surface::Plane(_)) && straight_edged(brep, face)? {
213            let mut vector_area = Vec3::ZERO;
214            for bound in &face.bounds {
215                let mut ring = ring_positions(brep, bound.loop_id)?;
216                for point in &mut ring {
217                    *point -= about;
218                }
219                if ring.len() < 3 {
220                    continue;
221                }
222                // Loops are wound in the support surface's own frame; the
223                // face, its use in the shell, and the bound each flip that.
224                // This is the convention `audit_brep` checks when it pairs
225                // edge uses, so any audited closed solid measures correctly
226                // under it.
227                //
228                // Ignoring the flips looked right for years because every
229                // tested solid had its reversed faces in the plane z = 0,
230                // where `int z n_z dA` is zero whichever way the face is
231                // wound. A solid lifted off that plane exposes it
232                // (`a_raised_solid_measures_the_same_as_one_on_the_ground`).
233                if flip_face ^ (bound.orientation == Orientation::Reversed) {
234                    ring.reverse();
235                }
236                accumulate_fan(
237                    &ring,
238                    &mut vector_area,
239                    &mut volume,
240                    &mut volume_weighted,
241                    &mut moments,
242                );
243            }
244            // Summed as vectors, a hole's opposite winding subtracts its
245            // area, and a non-convex fan's overlapping triangles cancel.
246            area += vector_area.length();
247            continue;
248        }
249
250        let sums = crate::exact_face::face_sums(brep, face, surface, about, linear, scale)?;
251        let sign = if flip_face { -1.0 } else { 1.0 };
252        area += sums[0].abs();
253        volume += sign * sums[1];
254        volume_weighted += Vec3::new(sums[2], sums[3], sums[4]) * sign;
255        moments += Vec3::new(sums[5], sums[6], sums[7]) * sign;
256    }
257
258    if !volume.is_finite() || volume.abs() < Scalar::EPSILON {
259        return Err(ExactMeasureError::Degenerate);
260    }
261
262    // The sums ran about `home` in world coordinates: `volume_weighted` is
263    // the first moment and `moments` the second moments about it. Shift
264    // both home; with `x = x' + o`,
265    // `int x_i^2 = int x'_i^2 + 2 o_i int x'_i + o_i^2 V`.
266    let second_moment_diagonal = moments + 2.0 * home * volume_weighted + home * home * volume;
267    Ok(MassProperties {
268        area,
269        signed_volume: volume,
270        centroid: home + volume_weighted / volume,
271        second_moment_diagonal,
272    })
273}
274
275/// The point every field is integrated about: the centre of the vertices'
276/// bounding box, else the frame origin of the first analytic surface (a
277/// solid bounded by vertex-free faces), else the world origin.
278///
279/// Any point near the solid conditions the sums; the centre keeps every
280/// fan term within the solid's own extent of it.
281fn reference_point(brep: &ExactBRep) -> Point3 {
282    let mut vertices = brep.topology().vertices().iter().map(|v| v.position);
283    if let Some(first) = vertices.next() {
284        let (low, high) = vertices.fold((first, first), |(low, high), p| (low.min(p), high.max(p)));
285        let centre = (low + high) * 0.5;
286        if centre.is_finite() {
287            return centre;
288        }
289    }
290    brep.surfaces()
291        .iter()
292        .find_map(|surface| {
293            let origin = match surface {
294                Surface::Cylinder(c) => c.frame.origin,
295                Surface::EllipticalCylinder(c) => c.frame.origin,
296                Surface::Cone(c) => c.frame.origin,
297                Surface::Sphere(s) => s.frame.origin,
298                Surface::Torus(t) => t.frame.origin,
299                _ => return None,
300            };
301            origin.is_finite().then_some(origin)
302        })
303        .unwrap_or(Point3::ZERO)
304}
305
306/// Whether every edge on the face's boundary is a straight line, so its
307/// vertices alone state the boundary.
308fn straight_edged(
309    brep: &ExactBRep,
310    face: &Face<axiolid_brep::SurfaceId>,
311) -> Result<bool, ExactMeasureError> {
312    let topology = brep.topology();
313    for bound in &face.bounds {
314        let wire = topology
315            .loops()
316            .get(bound.loop_id.index())
317            .ok_or(ExactMeasureError::DanglingReference)?;
318        for use_ in &wire.edges {
319            let edge = topology
320                .edges()
321                .get(use_.edge.index())
322                .ok_or(ExactMeasureError::DanglingReference)?;
323            let curve = edge
324                .curve
325                .and_then(|id| brep.curves3().get(id.index()))
326                .ok_or(ExactMeasureError::DanglingReference)?;
327            if !matches!(curve, Curve3::Line(_)) {
328                return Ok(false);
329            }
330        }
331    }
332    Ok(true)
333}
334
335/// A length on the solid's own scale, measured from `origin`, the point the
336/// cone fields are taken about: the noise floor of every integral is set
337/// from it. Measured from the world origin it would be the solid's distance
338/// from it, and a floor that loose at a national grid accepts any integral.
339fn characteristic_length(brep: &ExactBRep, origin: Point3) -> Scalar {
340    let reach = brep
341        .topology()
342        .vertices()
343        .iter()
344        .map(|vertex| (vertex.position - origin).length())
345        .fold(0.0, Scalar::max);
346    let extent = brep
347        .surfaces()
348        .iter()
349        .map(|surface| match surface {
350            Surface::Cylinder(c) => (c.frame.origin - origin).length() + c.radius,
351            Surface::EllipticalCylinder(c) => {
352                (c.frame.origin - origin).length() + c.semi_axis_x.max(c.semi_axis_y)
353            }
354            Surface::Cone(c) => (c.frame.origin - origin).length() + c.radius.abs(),
355            Surface::Sphere(s) => (s.frame.origin - origin).length() + s.radius,
356            Surface::Torus(t) => {
357                (t.frame.origin - origin).length() + t.major_radius + t.minor_radius
358            }
359            _ => 0.0,
360        })
361        .filter(|value| value.is_finite())
362        .fold(0.0, Scalar::max);
363    let length = reach.max(extent);
364    if length > 0.0 {
365        length
366    } else {
367        1.0
368    }
369}
370
371/// Accumulate one polygon's fan into the running sums.
372fn accumulate_fan(
373    ring: &[Point3],
374    area: &mut Vec3,
375    volume: &mut Scalar,
376    volume_weighted: &mut Point3,
377    moments: &mut Vec3,
378) {
379    let anchor = ring[0];
380    for window in ring[1..].windows(2) {
381        let (a, b, c) = (anchor, window[0], window[1]);
382
383        *area += (b - a).cross(c - a) * 0.5;
384
385        let six_v = a.dot(b.cross(c));
386        *volume += six_v / 6.0;
387        *volume_weighted += (a + b + c) * (six_v / 6.0 / 4.0);
388
389        for axis in 0..3 {
390            let (pa, pb, pc) = (a[axis], b[axis], c[axis]);
391            let quadratic = pa * pa + pb * pb + pc * pc + pa * pb + pa * pc + pb * pc;
392            moments[axis] += six_v * quadratic / 60.0;
393        }
394    }
395}
396
397/// Ordered vertex positions around one loop.
398///
399/// Each edge use carries its own traversal direction, so a loop is walked by
400/// taking the START vertex of every oriented use: consecutive uses share a
401/// vertex, and taking one endpoint per use yields the ring exactly once
402/// without duplicating the shared corners.
403fn ring_positions(brep: &ExactBRep, loop_id: LoopId) -> Result<Vec<Point3>, ExactMeasureError> {
404    let topology = brep.topology();
405    let wire = topology
406        .loops()
407        .get(loop_id.index())
408        .ok_or(ExactMeasureError::DanglingReference)?;
409
410    let mut ring = Vec::with_capacity(wire.edges.len());
411    for use_ in &wire.edges {
412        let edge = topology
413            .edges()
414            .get(use_.edge.index())
415            .ok_or(ExactMeasureError::DanglingReference)?;
416        let vertex_id = match use_.orientation {
417            Orientation::Forward => edge.start,
418            Orientation::Reversed => edge.end,
419        };
420        let vertex = topology
421            .vertices()
422            .get(vertex_id.index())
423            .ok_or(ExactMeasureError::DanglingReference)?;
424        ring.push(vertex.position);
425    }
426    Ok(ring)
427}