Skip to main content

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        .as_chunks::<3>()
150        .0
151        .iter()
152        .map(|triangle| {
153            let a = shifted(triangle[0]);
154            let b = shifted(triangle[1]);
155            let c = shifted(triangle[2]);
156            a.dot(b.cross(c))
157        })
158        .sum()
159}
160
161/// Enclosed volume of a validated operand, used by conformance invariants.
162pub fn enclosed_volume(mesh: &TriMesh, _tolerance: Tolerance) -> f64 {
163    six_signed_volume(mesh) / 6.0
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use axiolid_core::Point3;
170
171    /// Unit cube at `origin`, outward-oriented.
172    fn cube(origin: [f64; 3], size: f64) -> TriMesh {
173        let (x, y, z) = (origin[0], origin[1], origin[2]);
174        let s = size;
175        let p = vec![
176            Point3::new(x, y, z),
177            Point3::new(x + s, y, z),
178            Point3::new(x + s, y + s, z),
179            Point3::new(x, y + s, z),
180            Point3::new(x, y, z + s),
181            Point3::new(x + s, y, z + s),
182            Point3::new(x + s, y + s, z + s),
183            Point3::new(x, y + s, z + s),
184        ];
185        let i = vec![
186            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,
187            6, 3, 0, 4, 3, 4, 7,
188        ];
189        TriMesh::new(p, i)
190    }
191
192    /// #99: a small solid far from the origin must not be called inside-out.
193    ///
194    /// About the origin the triple products are ~1e27 while the true answer
195    /// is ~1e-18, so the sign was rounding noise and a valid cube was
196    /// rejected. About the centroid every term is the size of the cube.
197    #[test]
198    fn a_small_solid_far_from_the_origin_is_not_inside_out() {
199        for (offset, size) in [(1e9, 1e-6), (1e6, 1e-3), (1e12, 1.0)] {
200            let far = cube([offset, offset, offset], size);
201            let six = six_signed_volume(&far);
202            assert!(
203                six > 0.0,
204                "cube of size {size} at {offset} must have positive signed volume, got {six}"
205            );
206            SolidRequirements::Oriented
207                .validate(&far, "subject")
208                .expect("a well-formed cube must validate wherever it sits");
209        }
210    }
211
212    /// The guard must still catch a genuinely inverted solid.
213    #[test]
214    fn a_genuinely_inside_out_solid_is_still_refused() {
215        let mut inverted = cube([1e9, 1e9, 1e9], 1e-6);
216        for t in inverted.indices.as_chunks_mut::<3>().0 {
217            t.swap(1, 2);
218        }
219        assert!(six_signed_volume(&inverted) < 0.0);
220        let err = SolidRequirements::Oriented
221            .validate(&inverted, "subject")
222            .expect_err("an inverted solid must still be refused");
223        assert!(format!("{err}").contains("inside-out"));
224    }
225
226    /// Signed volume is translation-invariant, so placement must not
227    /// change the measured magnitude beyond f64 noise.
228    #[test]
229    fn the_measured_volume_does_not_depend_on_placement() {
230        let near = six_signed_volume(&cube([0.0, 0.0, 0.0], 1.0));
231        let far = six_signed_volume(&cube([1e6, 1e6, 1e6], 1.0));
232        assert!(
233            (near - far).abs() <= 1e-6 * near.abs(),
234            "near {near} vs far {far}"
235        );
236    }
237}