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 .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
161pub 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 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 #[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 #[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 #[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}