axiolid_mesh_contracts/
solid.rs

1//! Solid admissibility: what a mesh must satisfy to be a boolean operand.
2//!
3//! # Why this lives in the kernel
4//!
5//! Before this module, the only provider validated its own inputs inside its
6//! adapter. That made the first backend the de-facto definition of "valid
7//! solid": a second provider would have brought a second, silently different
8//! definition, and callers would have seen admissibility change when dispatch
9//! picked a different backend.
10//!
11//! Axiolid owns admissibility. The registry validates *before* dispatch, so a
12//! provider never sees an operand the contract rejects. Providers must not
13//! widen the set (accepting what Axiolid rejects) or narrow it (rejecting what
14//! Axiolid accepts); the conformance suite checks both directions.
15
16use axiolid_core::{Tolerance, Vec3};
17use axiolid_mesh::TriMesh;
18
19use axiolid_contracts::{GeomError, GeomResult};
20
21/// How strictly an operand must be formed.
22///
23/// Levels are cumulative: each includes the checks below it.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
25#[non_exhaustive]
26pub enum SolidRequirements {
27    /// Indices in range, no NaN/infinite coordinates, at least one triangle.
28    ///
29    /// The floor. An operand failing this is malformed data, not geometry.
30    Structural,
31    /// Structural, plus a finite, non-zero enclosed signed volume.
32    ///
33    /// Volume accumulation that overflows or otherwise becomes non-finite is
34    /// rejected rather than treated as evidence of a valid interior.
35    ///
36    /// A flat or self-cancelling shell has no interior, so no set operation on
37    /// it has a defined meaning.
38    Enclosing,
39    /// Enclosing, plus outward orientation (positive signed volume).
40    ///
41    /// An inside-out operand would silently invert the operation, turning a
42    /// difference into an intersection without any error.
43    Oriented,
44}
45
46/// Why an operand was rejected, with the operand named.
47///
48/// `role` is `"subject"` or `"tool[3]"` so a caller learns *which* mesh was
49/// wrong, not merely that some mesh was.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct SolidRejection {
52    /// Which operand failed.
53    pub role: String,
54    /// The level it failed at.
55    pub level: SolidRequirements,
56    /// Human-readable detail.
57    pub detail: String,
58}
59
60impl SolidRequirements {
61    /// Validate one operand at this level.
62    pub fn validate(self, mesh: &TriMesh, role: &str) -> GeomResult<()> {
63        mesh.validate_structure()
64            .map_err(|error| GeomError::InvalidInput(format!("{role}: {error}")))?;
65        if mesh.indices.is_empty() {
66            return Err(GeomError::InvalidInput(format!(
67                "{role}: mesh has no triangles"
68            )));
69        }
70        if !mesh
71            .positions
72            .iter()
73            .all(|point| point.x.is_finite() && point.y.is_finite() && point.z.is_finite())
74        {
75            return Err(GeomError::InvalidInput(format!(
76                "{role}: mesh has a non-finite coordinate"
77            )));
78        }
79        if self == Self::Structural {
80            return Ok(());
81        }
82
83        let six_volume = six_signed_volume(mesh);
84        if !six_volume.is_finite() {
85            return Err(GeomError::InvalidInput(format!(
86                "{role}: mesh has non-finite signed volume"
87            )));
88        }
89        if six_volume == 0.0 {
90            return Err(GeomError::Degenerate(format!(
91                "{role}: mesh encloses zero signed volume, so it has no interior"
92            )));
93        }
94        if self == Self::Enclosing {
95            return Ok(());
96        }
97
98        if six_volume < 0.0 {
99            return Err(GeomError::InvalidInput(format!(
100                "{role}: mesh is inside-out (signed volume {:.6} < 0); \
101                 boolean operations would silently invert",
102                six_volume / 6.0
103            )));
104        }
105        Ok(())
106    }
107
108    /// Validate a subject and its tools, naming each operand by index.
109    pub fn validate_operands(self, subject: &TriMesh, tools: &[&TriMesh]) -> GeomResult<()> {
110        self.validate(subject, "subject")?;
111        for (index, tool) in tools.iter().enumerate() {
112            self.validate(tool, &format!("tool[{index}]"))?;
113        }
114        Ok(())
115    }
116}
117
118/// Six times the signed volume, computed about the mesh centroid.
119///
120/// Summing triple products of ABSOLUTE coordinates cancels catastrophically
121/// when a small solid sits far from the origin: the terms scale with the
122/// distance cubed while the answer stays the size of the solid, so the sign
123/// becomes rounding noise and a well-formed mesh is misread as inside-out
124/// (#99). Shifting to the centroid first makes every term the size of the
125/// solid itself. Exact in real arithmetic -- signed volume is
126/// translation-invariant -- and vastly better conditioned in f64.
127///
128/// The factor of six is left in deliberately: the sign and the zero test are
129/// what matter, and dividing introduces rounding for no benefit.
130pub(crate) fn six_signed_volume(mesh: &TriMesh) -> f64 {
131    let n = mesh.positions.len() as f64;
132    if n == 0.0 {
133        return 0.0;
134    }
135    let mut cx = 0.0;
136    let mut cy = 0.0;
137    let mut cz = 0.0;
138    for p in &mesh.positions {
139        cx += p.x;
140        cy += p.y;
141        cz += p.z;
142    }
143    let (cx, cy, cz) = (cx / n, cy / n, cz / n);
144    let shifted = |i: u32| {
145        let p = mesh.positions[i as usize];
146        Vec3::new(p.x - cx, p.y - cy, p.z - cz)
147    };
148    mesh.indices
149        .chunks_exact(3)
150        .map(|triangle| {
151            let a = shifted(triangle[0]);
152            let b = shifted(triangle[1]);
153            let c = shifted(triangle[2]);
154            a.dot(b.cross(c))
155        })
156        .sum()
157}
158
159/// Enclosed volume of a validated operand, used by conformance invariants.
160pub fn enclosed_volume(mesh: &TriMesh, _tolerance: Tolerance) -> f64 {
161    six_signed_volume(mesh) / 6.0
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use axiolid_core::Point3;
168
169    /// Unit cube at `origin`, outward-oriented.
170    fn cube(origin: [f64; 3], size: f64) -> TriMesh {
171        let (x, y, z) = (origin[0], origin[1], origin[2]);
172        let s = size;
173        let p = vec![
174            Point3::new(x, y, z),
175            Point3::new(x + s, y, z),
176            Point3::new(x + s, y + s, z),
177            Point3::new(x, y + s, z),
178            Point3::new(x, y, z + s),
179            Point3::new(x + s, y, z + s),
180            Point3::new(x + s, y + s, z + s),
181            Point3::new(x, y + s, z + s),
182        ];
183        let i = vec![
184            0, 2, 1, 0, 3, 2, 4, 5, 6, 4, 6, 7, 0, 1, 5, 0, 5, 4, 1, 2, 6, 1, 6, 5, 2, 3, 7, 2, 7,
185            6, 3, 0, 4, 3, 4, 7,
186        ];
187        TriMesh::new(p, i)
188    }
189
190    /// #99: a small solid far from the origin must not be called inside-out.
191    ///
192    /// About the origin the triple products are ~1e27 while the true answer
193    /// is ~1e-18, so the sign was rounding noise and a valid cube was
194    /// rejected. About the centroid every term is the size of the cube.
195    #[test]
196    fn a_small_solid_far_from_the_origin_is_not_inside_out() {
197        for (offset, size) in [(1e9, 1e-6), (1e6, 1e-3), (1e12, 1.0)] {
198            let far = cube([offset, offset, offset], size);
199            let six = six_signed_volume(&far);
200            assert!(
201                six > 0.0,
202                "cube of size {size} at {offset} must have positive signed volume, got {six}"
203            );
204            SolidRequirements::Oriented
205                .validate(&far, "subject")
206                .expect("a well-formed cube must validate wherever it sits");
207        }
208    }
209
210    /// The guard must still catch a genuinely inverted solid.
211    #[test]
212    fn a_genuinely_inside_out_solid_is_still_refused() {
213        let mut inverted = cube([1e9, 1e9, 1e9], 1e-6);
214        for t in inverted.indices.chunks_exact_mut(3) {
215            t.swap(1, 2);
216        }
217        assert!(six_signed_volume(&inverted) < 0.0);
218        let err = SolidRequirements::Oriented
219            .validate(&inverted, "subject")
220            .expect_err("an inverted solid must still be refused");
221        assert!(format!("{err}").contains("inside-out"));
222    }
223
224    /// Signed volume is translation-invariant, so placement must not
225    /// change the measured magnitude beyond f64 noise.
226    #[test]
227    fn the_measured_volume_does_not_depend_on_placement() {
228        let near = six_signed_volume(&cube([0.0, 0.0, 0.0], 1.0));
229        let far = six_signed_volume(&cube([1e6, 1e6, 1e6], 1.0));
230        assert!(
231            (near - far).abs() <= 1e-6 * near.abs(),
232            "near {near} vs far {far}"
233        );
234    }
235}