1use axiolid_core::{Tolerance, Vec3};
17use axiolid_mesh::TriMesh;
18
19use axiolid_contracts::{GeomError, GeomResult};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
25#[non_exhaustive]
26pub enum SolidRequirements {
27 Structural,
31 Enclosing,
39 Oriented,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct SolidRejection {
52 pub role: String,
54 pub level: SolidRequirements,
56 pub detail: String,
58}
59
60impl SolidRequirements {
61 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 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
118pub(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
159pub 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 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 #[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 #[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 #[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}