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, 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/// Orientation is honoured at every level: a face marked
126/// [`Orientation::Reversed`], a reversed use of a face in its shell, and a
127/// reversed bound each flip the loop's winding, exactly as `audit_brep`
128/// reads them. A correctly built solid yields a positive volume wherever it
129/// sits, without the caller pre-normalising anything. Every shell is summed,
130/// so a void shell, used reversed, subtracts its volume.
131///
132/// # Errors
133///
134/// Refuses an unknown surface family, a face whose pcurves do not bound a
135/// domain, an integral that does not converge, and a boundary that encloses
136/// nothing -- never approximating any of them.
137pub fn exact_properties(
138    brep: &ExactBRep,
139    tolerance: Tolerance,
140) -> Result<MassProperties, ExactMeasureError> {
141    let topology = brep.topology();
142    let scale = characteristic_length(brep);
143    // Consecutive pcurves must meet on the surface. The caller's tolerance
144    // governs, with a floor for `Tolerance::ZERO` so rounding in the pcurve
145    // evaluation itself is not read as a gap.
146    let linear = tolerance.linear().max(1e-9 * scale);
147    let mut area = 0.0;
148    let mut volume = 0.0;
149    let mut volume_weighted = Point3::ZERO;
150    let mut moments = Vec3::ZERO;
151
152    // How each face is used by the shell that holds it. A face outside
153    // every shell (a bare face table) is taken as used forward.
154    let mut shell_sense = vec![Orientation::Forward; topology.faces().len()];
155    for shell in topology.shells() {
156        for &(face_id, sense) in &shell.faces {
157            if let Some(slot) = shell_sense.get_mut(face_id.index()) {
158                *slot = sense;
159            }
160        }
161    }
162
163    for (face_index, face) in topology.faces().iter().enumerate() {
164        let surface_id = face.surface.ok_or(ExactMeasureError::MissingSurface)?;
165        let surface = brep
166            .surfaces()
167            .get(surface_id.index())
168            .ok_or(ExactMeasureError::DanglingReference)?;
169        let flip_face = (shell_sense[face_index] == Orientation::Reversed)
170            ^ (face.orientation == Orientation::Reversed);
171
172        if matches!(surface, Surface::Plane(_)) && straight_edged(brep, face)? {
173            let mut vector_area = Vec3::ZERO;
174            for bound in &face.bounds {
175                let mut ring = ring_positions(brep, bound.loop_id)?;
176                if ring.len() < 3 {
177                    continue;
178                }
179                // Loops are wound in the support surface's own frame; the
180                // face, its use in the shell, and the bound each flip that.
181                // This is the convention `audit_brep` checks when it pairs
182                // edge uses, so any audited closed solid measures correctly
183                // under it.
184                //
185                // Ignoring the flips looked right for years because every
186                // tested solid had its reversed faces in the plane z = 0,
187                // where `int z n_z dA` is zero whichever way the face is
188                // wound. A solid lifted off that plane exposes it
189                // (`a_raised_solid_measures_the_same_as_one_on_the_ground`).
190                if flip_face ^ (bound.orientation == Orientation::Reversed) {
191                    ring.reverse();
192                }
193                accumulate_fan(
194                    &ring,
195                    &mut vector_area,
196                    &mut volume,
197                    &mut volume_weighted,
198                    &mut moments,
199                );
200            }
201            // Summed as vectors, a hole's opposite winding subtracts its
202            // area, and a non-convex fan's overlapping triangles cancel.
203            area += vector_area.length();
204            continue;
205        }
206
207        let sums = crate::exact_face::face_sums(brep, face, surface, linear, scale)?;
208        let sign = if flip_face { -1.0 } else { 1.0 };
209        area += sums[0].abs();
210        volume += sign * sums[1];
211        volume_weighted += Vec3::new(sums[2], sums[3], sums[4]) * sign;
212        moments += Vec3::new(sums[5], sums[6], sums[7]) * sign;
213    }
214
215    if !volume.is_finite() || volume.abs() < Scalar::EPSILON {
216        return Err(ExactMeasureError::Degenerate);
217    }
218
219    Ok(MassProperties {
220        area,
221        signed_volume: volume,
222        centroid: volume_weighted / volume,
223        second_moment_diagonal: moments,
224    })
225}
226
227/// Whether every edge on the face's boundary is a straight line, so its
228/// vertices alone state the boundary.
229fn straight_edged(
230    brep: &ExactBRep,
231    face: &Face<axiolid_brep::SurfaceId>,
232) -> Result<bool, ExactMeasureError> {
233    let topology = brep.topology();
234    for bound in &face.bounds {
235        let wire = topology
236            .loops()
237            .get(bound.loop_id.index())
238            .ok_or(ExactMeasureError::DanglingReference)?;
239        for use_ in &wire.edges {
240            let edge = topology
241                .edges()
242                .get(use_.edge.index())
243                .ok_or(ExactMeasureError::DanglingReference)?;
244            let curve = edge
245                .curve
246                .and_then(|id| brep.curves3().get(id.index()))
247                .ok_or(ExactMeasureError::DanglingReference)?;
248            if !matches!(curve, Curve3::Line(_)) {
249                return Ok(false);
250            }
251        }
252    }
253    Ok(true)
254}
255
256/// A length on the solid's own scale, measured from the origin the cone
257/// fields are taken about: the noise floor of every integral is set from it.
258fn characteristic_length(brep: &ExactBRep) -> Scalar {
259    let reach = brep
260        .topology()
261        .vertices()
262        .iter()
263        .map(|vertex| vertex.position.length())
264        .fold(0.0, Scalar::max);
265    let extent = brep
266        .surfaces()
267        .iter()
268        .map(|surface| match surface {
269            Surface::Cylinder(c) => c.frame.origin.length() + c.radius,
270            Surface::EllipticalCylinder(c) => {
271                c.frame.origin.length() + c.semi_axis_x.max(c.semi_axis_y)
272            }
273            Surface::Cone(c) => c.frame.origin.length() + c.radius.abs(),
274            Surface::Sphere(s) => s.frame.origin.length() + s.radius,
275            Surface::Torus(t) => t.frame.origin.length() + t.major_radius + t.minor_radius,
276            _ => 0.0,
277        })
278        .filter(|value| value.is_finite())
279        .fold(0.0, Scalar::max);
280    let length = reach.max(extent);
281    if length > 0.0 {
282        length
283    } else {
284        1.0
285    }
286}
287
288/// Accumulate one polygon's fan into the running sums.
289fn accumulate_fan(
290    ring: &[Point3],
291    area: &mut Vec3,
292    volume: &mut Scalar,
293    volume_weighted: &mut Point3,
294    moments: &mut Vec3,
295) {
296    let anchor = ring[0];
297    for window in ring[1..].windows(2) {
298        let (a, b, c) = (anchor, window[0], window[1]);
299
300        *area += (b - a).cross(c - a) * 0.5;
301
302        let six_v = a.dot(b.cross(c));
303        *volume += six_v / 6.0;
304        *volume_weighted += (a + b + c) * (six_v / 6.0 / 4.0);
305
306        for axis in 0..3 {
307            let (pa, pb, pc) = (a[axis], b[axis], c[axis]);
308            let quadratic = pa * pa + pb * pb + pc * pc + pa * pb + pa * pc + pb * pc;
309            moments[axis] += six_v * quadratic / 60.0;
310        }
311    }
312}
313
314/// Ordered vertex positions around one loop.
315///
316/// Each edge use carries its own traversal direction, so a loop is walked by
317/// taking the START vertex of every oriented use: consecutive uses share a
318/// vertex, and taking one endpoint per use yields the ring exactly once
319/// without duplicating the shared corners.
320fn ring_positions(brep: &ExactBRep, loop_id: LoopId) -> Result<Vec<Point3>, ExactMeasureError> {
321    let topology = brep.topology();
322    let wire = topology
323        .loops()
324        .get(loop_id.index())
325        .ok_or(ExactMeasureError::DanglingReference)?;
326
327    let mut ring = Vec::with_capacity(wire.edges.len());
328    for use_ in &wire.edges {
329        let edge = topology
330            .edges()
331            .get(use_.edge.index())
332            .ok_or(ExactMeasureError::DanglingReference)?;
333        let vertex_id = match use_.orientation {
334            Orientation::Forward => edge.start,
335            Orientation::Reversed => edge.end,
336        };
337        let vertex = topology
338            .vertices()
339            .get(vertex_id.index())
340            .ok_or(ExactMeasureError::DanglingReference)?;
341        ring.push(vertex.position);
342    }
343    Ok(ring)
344}