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}