Skip to main content

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}