axiolid_measure/mesh.rs
1//! Deterministic raw triangle-mesh measures.
2use axiolid_core::{Point3, Tolerance, Vec3};
3use axiolid_mesh::{audit_mesh, audit_mesh_cycle, MeshCycleHealth, MeshHealth, TriangleMeshView};
4use core::fmt;
5
6#[derive(Debug, Clone, Copy, PartialEq)]
7pub struct SurfaceProperties {
8 pub area: f64,
9 pub centroid: Point3,
10}
11#[derive(Debug, Clone, Copy, PartialEq)]
12pub struct VolumeProperties {
13 pub signed_volume: f64,
14 pub centroid: Point3,
15}
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum MeshMeasureError {
18 MeshNotSurfaceUsable(MeshHealth),
19 MeshNotVolumeUsable(MeshHealth),
20 ZeroSurfaceArea,
21 ZeroSignedVolume,
22}
23impl fmt::Display for MeshMeasureError {
24 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25 f.write_str("mesh is not suitable for requested raw measure")
26 }
27}
28impl std::error::Error for MeshMeasureError {}
29
30/// Area of one triangle given its corners about the mesh's local origin.
31///
32/// Shared by [`surface_properties`] and face selection so both sum the same
33/// per-triangle values bit for bit.
34pub(crate) fn triangle_area([a, b, c]: [Vec3; 3]) -> f64 {
35 (b - a).cross(c - a).length() * 0.5
36}
37
38pub fn surface_properties<M: TriangleMeshView + ?Sized>(
39 mesh: &M,
40 tolerance: Tolerance,
41) -> Result<SurfaceProperties, MeshMeasureError> {
42 let health = audit_mesh(mesh, tolerance);
43 if !health.is_surface_usable() || health.degenerate_triangles != 0 {
44 return Err(MeshMeasureError::MeshNotSurfaceUsable(health));
45 }
46 // Area uses edge differences, which are already local and well
47 // conditioned. The centroid is not: it accumulates absolute positions,
48 // so at large coordinates it loses the same precision `volume_properties`
49 // did. Re-base it the same way.
50 let base = mesh.position(0);
51 let (mut area, mut weighted) = (0.0f64, Vec3::ZERO);
52 for i in 0..mesh.triangle_count() {
53 let [a, b, c] = mesh.triangle(i).map(|j| mesh.position(j as usize) - base);
54 let weight = triangle_area([a, b, c]);
55 area += weight;
56 weighted += (a + b + c) * (weight / 3.0);
57 }
58 if !area.is_finite() || area == 0.0 {
59 return Err(MeshMeasureError::ZeroSurfaceArea);
60 }
61 Ok(SurfaceProperties {
62 area,
63 // Shift the local-frame centroid back to world coordinates.
64 centroid: base + (weighted / area),
65 })
66}
67
68pub fn volume_properties<M: TriangleMeshView + ?Sized>(
69 mesh: &M,
70 tolerance: Tolerance,
71) -> Result<VolumeProperties, MeshMeasureError> {
72 let health = audit_mesh(mesh, tolerance);
73 if !health.is_closed_two_manifold() {
74 return Err(MeshMeasureError::MeshNotVolumeUsable(health));
75 }
76 divergence_volume(mesh).ok_or(MeshMeasureError::ZeroSignedVolume)
77}
78
79/// Divergence-theorem sum shared by [`volume_properties`] and
80/// [`cycle_volume_properties`]; `None` when the sum is zero or not finite.
81fn divergence_volume<M: TriangleMeshView + ?Sized>(mesh: &M) -> Option<VolumeProperties> {
82 // Sum about a local origin rather than the world origin.
83 //
84 // Volume and centroid are translation-invariant, so re-basing the operands
85 // changes nothing mathematically -- but it changes the conditioning
86 // completely. Evaluating `a . (b x c)` about the world origin scales every
87 // term with the CUBE of the distance to it, so a 0.1 m box on a national
88 // grid at 1e7 forms terms of order 1e21 whose cancellation has to produce
89 // 1e-3. That measured 25% volume error and a centroid 8160 km off the
90 // solid: a rule check reading either got a confidently wrong number.
91 //
92 // The first vertex is the local origin. Any point near the geometry works;
93 // this one is free and always on the mesh.
94 let base = mesh.position(0);
95 let (mut volume, mut weighted) = (0.0f64, Vec3::ZERO);
96 for i in 0..mesh.triangle_count() {
97 let [a, b, c] = mesh.triangle(i).map(|j| mesh.position(j as usize) - base);
98 let tetra = a.dot(b.cross(c)) / 6.0;
99 volume += tetra;
100 weighted += (a + b + c) * (tetra / 4.0);
101 }
102 if !volume.is_finite() || volume == 0.0 {
103 return None;
104 }
105 Some(VolumeProperties {
106 signed_volume: volume,
107 // The sum ran in local coordinates, so the centroid comes back
108 // relative to `base`. Shift it home.
109 centroid: base + (weighted / volume),
110 })
111}
112
113/// What [`cycle_volume_properties`] refuses.
114#[non_exhaustive]
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub enum CycleVolumeError {
117 /// The mesh is not a closed oriented cycle: an index or position is
118 /// invalid, no triangle is usable, a triangle is degenerate, or an edge
119 /// is used unequally often in each direction. The audit says which.
120 NotClosedOrientedCycle(Box<MeshCycleHealth>),
121 /// The sum is zero or not finite.
122 ZeroSignedVolume,
123}
124
125impl fmt::Display for CycleVolumeError {
126 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127 match self {
128 Self::NotClosedOrientedCycle(_) => f.write_str("mesh is not a closed oriented cycle"),
129 Self::ZeroSignedVolume => f.write_str("mesh has zero or non-finite signed volume"),
130 }
131 }
132}
133
134impl std::error::Error for CycleVolumeError {}
135
136/// Signed volume and centroid of a mesh whose triangles form a closed
137/// oriented cycle, the weaker input [`volume_properties`] does not accept.
138///
139/// A cycle is a mesh in which every undirected edge is used equally often in
140/// each direction (see `axiolid_mesh::MeshCycleHealth`). It may hold shells
141/// that touch along an edge, or an edge shared by four triangles. The
142/// divergence sum of such a mesh does not depend on its base point and equals
143/// the integral of the integer winding number `w` of the surface; on
144/// two-manifold input the result is bit-identical to [`volume_properties`].
145///
146/// The result is the enclosed volume only when `w` is 0 or 1 everywhere,
147/// which is the contract [`volume_properties`] has always had: overlapping or
148/// duplicated shells count twice and an inverted shell subtracts. No edge
149/// audit can see that; `axiolid_inspect::certify_unit_winding` certifies it
150/// (ADR 0099 D6), bounding the volume where `w` is neither 0 nor 1 by the winding excess.
151///
152/// A T-junction leaves three unbalanced edges and is refused. Run
153/// `axiolid_heal::split_t_junctions` (or `RepairAction::SplitTJunctions`)
154/// first; it is opt-in because it only splits edges and creates no vertex.
155/// A mesh whose vector area vanishes without being closed, such as a box
156/// without top and bottom, is refused by the same audit.
157pub fn cycle_volume_properties<M: TriangleMeshView + ?Sized>(
158 mesh: &M,
159 tolerance: Tolerance,
160) -> Result<VolumeProperties, CycleVolumeError> {
161 let audit = audit_mesh_cycle(mesh, tolerance);
162 if !audit.is_closed_oriented_cycle() {
163 return Err(CycleVolumeError::NotClosedOrientedCycle(Box::new(audit)));
164 }
165 divergence_volume(mesh).ok_or(CycleVolumeError::ZeroSignedVolume)
166}
167
168/// Second moments of the enclosed solid about the origin.
169///
170/// The `x`, `y`, `z` components are the integrals of `x^2`, `y^2` and
171/// `z^2` over the enclosed volume, i.e. the diagonal of the second-moment
172/// tensor for unit density. The classical inertia tensor diagonal is
173/// `(Iyy + Izz, Ixx + Izz, Ixx + Iyy)` from these, so callers can derive
174/// either convention without the provider guessing which one they meant.
175///
176/// # Method
177///
178/// The divergence theorem again, one order higher than the volume sum:
179/// for a closed oriented triangulation the integral of `x^2` over the
180/// enclosed region reduces to a sum over triangles of terms in the
181/// vertices' coordinates. Each triangle contributes
182/// `(nx / 60) * sum over the 10 symmetric monomials`, the standard
183/// closed-form tetrahedral moment about the origin.
184///
185/// The sum runs about the mesh's first vertex, as [`volume_properties`]'s
186/// does, and is shifted back to the origin by the parallel-axis terms: with
187/// `x = x' + o`, `int x_i^2 = int x'_i^2 + 2 o_i int x'_i + o_i^2 V`
188/// (#343). About the world origin each term grew with the fifth power of
189/// the distance to it, and a box on a national grid lost its central
190/// moments entirely to the cancellation.
191///
192/// Requires the same closed two-manifold input as [`volume_properties`]:
193/// an open shell has no enclosed region and the integral is meaningless,
194/// so it is refused rather than summed into a plausible number.
195pub fn second_moments<M: TriangleMeshView + ?Sized>(
196 mesh: &M,
197 tolerance: Tolerance,
198) -> Result<Vec3, MeshMeasureError> {
199 let health = audit_mesh(mesh, tolerance);
200 if !health.is_closed_two_manifold() {
201 return Err(MeshMeasureError::MeshNotVolumeUsable(health));
202 }
203 let base = mesh.position(0);
204 let (mut moments, mut first, mut volume) = (Vec3::ZERO, Vec3::ZERO, 0.0);
205 for i in 0..mesh.triangle_count() {
206 let [a, b, c] = mesh.triangle(i).map(|j| mesh.position(j as usize) - base);
207 // Signed volume of the tetrahedron on `base`, times 6.
208 let six_v = a.dot(b.cross(c));
209 volume += six_v / 6.0;
210 first += (a + b + c) * (six_v / 24.0);
211 for axis in 0..3 {
212 let (pa, pb, pc) = (a[axis], b[axis], c[axis]);
213 // Integral of t^2 over the tetrahedron, in barycentric closed
214 // form: (a^2 + b^2 + c^2 + ab + ac + bc) / 60 per unit 6V.
215 let quadratic = pa * pa + pb * pb + pc * pc + pa * pb + pa * pc + pb * pc;
216 moments[axis] += six_v * quadratic / 60.0;
217 }
218 }
219 // Shift the moments about `base` home to the origin.
220 let moments = moments + 2.0 * base * first + base * base * volume;
221 if !moments.x.is_finite() || !moments.y.is_finite() || !moments.z.is_finite() {
222 return Err(MeshMeasureError::ZeroSignedVolume);
223 }
224 Ok(moments)
225}