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}