1use crate::numerical;
2use crate::{RecognitionError, SurfaceHint, Vec3};
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5
6#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
7pub struct Mesh {
9 pub vertices: Vec<Vec3>,
11 pub triangles: Vec<[u32; 3]>,
13 #[serde(default, skip_serializing_if = "Option::is_none")]
16 pub vertex_normals: Option<Vec<Vec3>>,
17 #[serde(default)]
18 pub source_metadata: Vec<SourceMetadata>,
20}
21
22impl Mesh {
23 pub fn new(vertices: Vec<Vec3>, triangles: Vec<[u32; 3]>) -> Self {
25 Self {
26 vertices,
27 triangles,
28 vertex_normals: None,
29 source_metadata: Vec::new(),
30 }
31 }
32
33 pub fn with_vertex_normals(mut self, normals: Vec<Vec3>) -> Self {
36 self.vertex_normals = Some(normals);
37 self
38 }
39 pub fn analyze(&self, options: &MeshAnalysisOptions) -> Result<AnalyzedMesh, RecognitionError> {
41 AnalyzedMesh::new(self, options)
42 }
43}
44
45#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
48pub struct SourceMetadata {
49 pub version: u32,
51 pub triangle_indices: Vec<usize>,
53 pub hint: SurfaceHint,
55 pub source_face_id: Option<u64>,
57 pub source_face_name: Option<String>,
59 pub source_surface_id: Option<String>,
61 pub orientation: Option<i8>,
63 pub source_tolerance: Option<f64>,
65}
66
67#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)]
68#[serde(default)]
69pub struct MeshAnalysisOptions {
71 pub minimum_triangle_area: f64,
73 pub feature_angle: f64,
75}
76impl Default for MeshAnalysisOptions {
77 fn default() -> Self {
78 Self {
79 minimum_triangle_area: 0.0,
80 feature_angle: 30_f64.to_radians(),
81 }
82 }
83}
84
85#[derive(Clone, Debug, PartialEq)]
86pub struct TriangleData {
88 pub vertices: [usize; 3],
90 pub centroid: Vec3,
92 pub normal: Vec3,
94 pub area: f64,
96 pub neighbors: [Option<usize>; 3],
98 pub feature_edges: [bool; 3],
100}
101
102#[derive(Clone, Debug)]
103pub struct AnalyzedMesh {
105 pub vertices: Vec<Vec3>,
107 pub vertex_normals: Option<Vec<Vec3>>,
109 pub triangles: Vec<TriangleData>,
111 pub bbox_min: Vec3,
113 pub bbox_max: Vec3,
115 pub diagonal: f64,
117 pub vertex_area_weights: Vec<f64>,
119 pub source_metadata: Vec<SourceMetadata>,
121 pub degenerate_triangles: Vec<usize>,
123}
124
125impl AnalyzedMesh {
126 pub fn new(mesh: &Mesh, options: &MeshAnalysisOptions) -> Result<Self, RecognitionError> {
128 if !options.minimum_triangle_area.is_finite() || options.minimum_triangle_area < 0.0 {
129 return Err(RecognitionError::InvalidOptions(
130 "minimum_triangle_area must be finite and non-negative".into(),
131 ));
132 }
133 if !(0.0..=std::f64::consts::PI).contains(&options.feature_angle) {
134 return Err(RecognitionError::InvalidOptions(
135 "feature_angle must be finite and in [0, pi]".into(),
136 ));
137 }
138 if mesh.vertices.is_empty() {
139 return Err(RecognitionError::InvalidMesh("no vertices".into()));
140 }
141 if mesh.triangles.is_empty() {
142 return Err(RecognitionError::InvalidMesh("no triangles".into()));
143 }
144 if mesh.vertices.iter().any(|v| !v.is_finite()) {
145 return Err(RecognitionError::InvalidMesh(
146 "vertex coordinates must be finite".into(),
147 ));
148 }
149 let vertex_normals = match &mesh.vertex_normals {
150 None => None,
151 Some(normals) => {
152 if normals.len() != mesh.vertices.len() {
153 return Err(RecognitionError::InvalidMesh(format!(
154 "vertex-normal buffer has {} entries for {} vertices",
155 normals.len(),
156 mesh.vertices.len()
157 )));
158 }
159 let mut normalized = Vec::with_capacity(normals.len());
160 for (index, &normal) in normals.iter().enumerate() {
161 if !normal.is_finite() {
162 return Err(RecognitionError::InvalidMesh(format!(
163 "vertex normal {index} is not finite"
164 )));
165 }
166 normalized.push(normal.normalized().ok_or_else(|| {
167 RecognitionError::InvalidMesh(format!(
168 "vertex normal {index} has zero length"
169 ))
170 })?);
171 }
172 Some(normalized)
173 }
174 };
175 let mut bbox_min = mesh.vertices[0];
176 let mut bbox_max = mesh.vertices[0];
177 for p in &mesh.vertices[1..] {
178 bbox_min.x = bbox_min.x.min(p.x);
179 bbox_min.y = bbox_min.y.min(p.y);
180 bbox_min.z = bbox_min.z.min(p.z);
181 bbox_max.x = bbox_max.x.max(p.x);
182 bbox_max.y = bbox_max.y.max(p.y);
183 bbox_max.z = bbox_max.z.max(p.z);
184 }
185 let diagonal = (bbox_max - bbox_min).length();
186 if diagonal <= 0.0 {
187 return Err(RecognitionError::InvalidMesh(
188 "zero-size bounding box".into(),
189 ));
190 }
191 let mut triangles = Vec::with_capacity(mesh.triangles.len());
192 let mut degenerate = Vec::new();
193 let mut vertex_area_weights = vec![0.0; mesh.vertices.len()];
194 for (id, raw) in mesh.triangles.iter().enumerate() {
195 let vi = raw.map(|x| x as usize);
196 if vi.iter().any(|&x| x >= mesh.vertices.len()) {
197 return Err(RecognitionError::InvalidMesh(format!(
198 "triangle {id} has an out-of-range vertex"
199 )));
200 }
201 if vi[0] == vi[1] || vi[1] == vi[2] || vi[2] == vi[0] {
202 degenerate.push(id);
203 triangles.push(TriangleData {
204 vertices: vi,
205 centroid: Vec3::ZERO,
206 normal: Vec3::ZERO,
207 area: 0.0,
208 neighbors: [None; 3],
209 feature_edges: [true; 3],
210 });
211 continue;
212 }
213 let [a, b, c] = vi.map(|i| mesh.vertices[i]);
214 let ab = b - a;
215 let ac = c - a;
216 let bc = c - b;
217 let cross = ab.cross(ac);
218 let area = 0.5 * cross.length();
219 let edge_scale = ab.length().max(ac.length()).max(bc.length());
227 let coordinate_scale = [a, b, c]
228 .into_iter()
229 .map(|point| point.x.abs().max(point.y.abs()).max(point.z.abs()))
230 .fold(edge_scale, f64::max);
231 let subtraction_error = f64::EPSILON * coordinate_scale;
232 let machine_area_floor = numerical::mesh::MACHINE_AREA_FLOOR_MULTIPLIER
233 * (edge_scale * subtraction_error + subtraction_error * subtraction_error);
234 let area_floor = options.minimum_triangle_area.max(machine_area_floor);
235 if area <= area_floor {
236 degenerate.push(id);
237 triangles.push(TriangleData {
238 vertices: vi,
239 centroid: (a + b + c) / 3.0,
240 normal: Vec3::ZERO,
241 area: 0.0,
244 neighbors: [None; 3],
245 feature_edges: [true; 3],
246 });
247 continue;
248 }
249 let normal = cross / (2.0 * area);
250 for &v in &vi {
251 vertex_area_weights[v] += area / 3.0;
252 }
253 triangles.push(TriangleData {
254 vertices: vi,
255 centroid: (a + b + c) / 3.0,
256 normal,
257 area,
258 neighbors: [None; 3],
259 feature_edges: [true; 3],
260 });
261 }
262 if degenerate.len() == mesh.triangles.len() {
263 return Err(RecognitionError::DegenerateData(
264 "all triangles are degenerate".into(),
265 ));
266 }
267 let mut edges: BTreeMap<(usize, usize), Vec<(usize, usize)>> = BTreeMap::new();
268 for (tid, t) in triangles.iter().enumerate() {
269 if t.area <= 0.0 {
270 continue;
271 }
272 for edge in 0..3 {
273 let a = t.vertices[edge];
274 let b = t.vertices[(edge + 1) % 3];
275 edges
276 .entry(if a < b { (a, b) } else { (b, a) })
277 .or_default()
278 .push((tid, edge));
279 }
280 }
281 for incidents in edges.values() {
282 if incidents.len() == 2 {
283 let (ta, ea) = incidents[0];
284 let (tb, eb) = incidents[1];
285 triangles[ta].neighbors[ea] = Some(tb);
286 triangles[tb].neighbors[eb] = Some(ta);
287 let cosine = triangles[ta]
288 .normal
289 .dot(triangles[tb].normal)
290 .clamp(-1.0, 1.0);
291 let feature = cosine.acos() > options.feature_angle;
292 triangles[ta].feature_edges[ea] = feature;
293 triangles[tb].feature_edges[eb] = feature;
294 }
295 }
296 for metadata in &mesh.source_metadata {
297 if metadata.version != 1 {
298 return Err(RecognitionError::InvalidMesh(format!(
299 "unsupported metadata version {}",
300 metadata.version
301 )));
302 }
303 if metadata
304 .triangle_indices
305 .iter()
306 .any(|&t| t >= mesh.triangles.len())
307 {
308 return Err(RecognitionError::InvalidMesh(
309 "metadata references an out-of-range triangle".into(),
310 ));
311 }
312 if metadata
313 .orientation
314 .is_some_and(|sense| !matches!(sense, -1 | 1))
315 {
316 return Err(RecognitionError::InvalidMesh(
317 "metadata orientation must be -1 or +1".into(),
318 ));
319 }
320 if metadata
321 .source_tolerance
322 .is_some_and(|tolerance| !tolerance.is_finite() || tolerance <= 0.0)
323 {
324 return Err(RecognitionError::InvalidMesh(
325 "metadata source_tolerance must be finite and positive".into(),
326 ));
327 }
328 }
329 Ok(Self {
330 vertices: mesh.vertices.clone(),
331 vertex_normals,
332 triangles,
333 bbox_min,
334 bbox_max,
335 diagonal,
336 vertex_area_weights,
337 source_metadata: mesh.source_metadata.clone(),
338 degenerate_triangles: degenerate,
339 })
340 }
341 pub fn all_non_degenerate(&self) -> Vec<usize> {
343 self.triangles
344 .iter()
345 .enumerate()
346 .filter_map(|(i, t)| (t.area > 0.0).then_some(i))
347 .collect()
348 }
349 pub fn validate_selection(&self, ids: &[usize]) -> Result<(), RecognitionError> {
351 if ids.is_empty() {
352 return Err(RecognitionError::InvalidSelection(
353 "selection is empty".into(),
354 ));
355 }
356 if ids.iter().any(|&i| i >= self.triangles.len()) {
357 return Err(RecognitionError::InvalidSelection(
358 "triangle index out of range".into(),
359 ));
360 }
361 if ids.iter().all(|&i| self.triangles[i].area == 0.0) {
362 return Err(RecognitionError::DegenerateData(
363 "selection contains no usable triangles".into(),
364 ));
365 }
366 Ok(())
367 }
368 pub fn validate_vertex_selection(&self, ids: &[usize]) -> Result<(), RecognitionError> {
371 if ids.is_empty() {
372 return Err(RecognitionError::InvalidSelection(
373 "vertex selection is empty".into(),
374 ));
375 }
376 if ids.iter().any(|&i| i >= self.vertices.len()) {
377 return Err(RecognitionError::InvalidSelection(
378 "vertex index out of range".into(),
379 ));
380 }
381 let mut unique = ids.to_vec();
382 unique.sort_unstable();
383 if unique.windows(2).any(|pair| pair[0] == pair[1]) {
384 return Err(RecognitionError::InvalidSelection(
385 "vertex selection contains duplicate indices".into(),
386 ));
387 }
388 if ids.iter().any(|&i| self.vertex_area_weights[i] == 0.0) {
389 return Err(RecognitionError::DegenerateData(
390 "vertex selection contains a vertex with no usable incident geometry".into(),
391 ));
392 }
393 Ok(())
394 }
395 pub fn selected_vertex_weights(&self, ids: &[usize]) -> Vec<(usize, f64)> {
397 let mut weights = BTreeMap::<usize, f64>::new();
398 for &tid in ids {
399 let t = &self.triangles[tid];
400 if t.area > 0.0 {
401 for &v in &t.vertices {
402 *weights.entry(v).or_default() += t.area / 3.0;
403 }
404 }
405 }
406 weights.into_iter().collect()
407 }
408 pub fn selection_centroid(&self, ids: &[usize]) -> Vec3 {
412 let mut sum = Vec3::ZERO;
413 let mut area = 0.0;
414 for &i in ids {
415 let t = &self.triangles[i];
416 sum += t.centroid * t.area;
417 area += t.area;
418 }
419 if area > 0.0 {
420 sum / area
421 } else {
422 Vec3::ZERO
423 }
424 }
425 pub fn connected_components(&self, ids: &[usize], respect_features: bool) -> Vec<Vec<usize>> {
429 let mut allowed = vec![false; self.triangles.len()];
430 for &i in ids {
431 if i < allowed.len() {
432 allowed[i] = true;
433 }
434 }
435 let mut seen = vec![false; self.triangles.len()];
436 let mut result = Vec::new();
437 for &seed in ids {
438 if seen[seed] || self.triangles[seed].area == 0.0 {
439 continue;
440 }
441 let mut stack = vec![seed];
442 seen[seed] = true;
443 let mut part = Vec::new();
444 while let Some(i) = stack.pop() {
445 part.push(i);
446 for edge in 0..3 {
447 if respect_features && self.triangles[i].feature_edges[edge] {
448 continue;
449 }
450 if let Some(n) = self.triangles[i].neighbors[edge] {
451 if allowed[n] && !seen[n] {
452 seen[n] = true;
453 stack.push(n);
454 }
455 }
456 }
457 }
458 part.sort_unstable();
459 result.push(part);
460 }
461 result
462 }
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468 #[test]
469 fn adjacency_and_features() {
470 let mesh = Mesh::new(
471 vec![
472 Vec3::new(0., 0., 0.),
473 Vec3::new(1., 0., 0.),
474 Vec3::new(1., 1., 0.),
475 Vec3::new(0., 1., 0.),
476 ],
477 vec![[0, 1, 2], [0, 2, 3]],
478 );
479 let a = mesh.analyze(&Default::default()).unwrap();
480 assert_eq!(a.triangles[0].neighbors.iter().flatten().count(), 1);
481 assert_eq!(a.connected_components(&[0, 1], true).len(), 1);
482 assert!((a.triangles.iter().map(|t| t.area).sum::<f64>() - 1.0).abs() < 1e-12);
483 }
484
485 fn triangle_mesh() -> Mesh {
486 Mesh::new(vec![Vec3::ZERO, Vec3::X, Vec3::Y], vec![[0, 1, 2]])
487 }
488
489 #[test]
490 fn supplied_vertex_normals_are_validated_and_normalized() {
491 let mesh = triangle_mesh().with_vertex_normals(vec![Vec3::Z * 2.0; 3]);
492 let analyzed = mesh.analyze(&Default::default()).unwrap();
493 assert_eq!(analyzed.vertex_normals, Some(vec![Vec3::Z; 3]));
494
495 assert!(triangle_mesh()
496 .with_vertex_normals(vec![Vec3::Z; 2])
497 .analyze(&Default::default())
498 .is_err());
499 assert!(triangle_mesh()
500 .with_vertex_normals(vec![Vec3::Z, Vec3::ZERO, Vec3::Z])
501 .analyze(&Default::default())
502 .is_err());
503 assert!(triangle_mesh()
504 .with_vertex_normals(vec![Vec3::Z, Vec3::new(f64::NAN, 0.0, 1.0), Vec3::Z,])
505 .analyze(&Default::default())
506 .is_err());
507 }
508
509 #[test]
510 fn legacy_mesh_json_without_normals_remains_compatible() {
511 let mesh: Mesh = serde_json::from_str(
512 r#"{"vertices":[{"x":0.0,"y":0.0,"z":0.0},{"x":1.0,"y":0.0,"z":0.0},{"x":0.0,"y":1.0,"z":0.0}],"triangles":[[0,1,2]]}"#,
513 )
514 .unwrap();
515 assert_eq!(mesh.vertex_normals, None);
516 mesh.analyze(&Default::default()).unwrap();
517 }
518
519 #[test]
520 fn mesh_analysis_options_are_validated_before_mesh_processing() {
521 for value in [-1.0, f64::INFINITY, f64::NAN] {
522 let error = triangle_mesh()
523 .analyze(&MeshAnalysisOptions {
524 minimum_triangle_area: value,
525 ..MeshAnalysisOptions::default()
526 })
527 .unwrap_err();
528 assert_eq!(
529 error,
530 RecognitionError::InvalidOptions(
531 "minimum_triangle_area must be finite and non-negative".into()
532 )
533 );
534 }
535
536 for value in [-f64::EPSILON, std::f64::consts::PI + 1.0e-12, f64::NAN] {
537 let error = triangle_mesh()
538 .analyze(&MeshAnalysisOptions {
539 feature_angle: value,
540 ..MeshAnalysisOptions::default()
541 })
542 .unwrap_err();
543 assert_eq!(
544 error,
545 RecognitionError::InvalidOptions(
546 "feature_angle must be finite and in [0, pi]".into()
547 )
548 );
549 }
550
551 for value in [0.0, std::f64::consts::PI] {
552 triangle_mesh()
553 .analyze(&MeshAnalysisOptions {
554 feature_angle: value,
555 ..MeshAnalysisOptions::default()
556 })
557 .unwrap();
558 }
559 }
560
561 #[test]
562 fn triangle_degeneracy_is_local_to_component_scale_and_coordinate_precision() {
563 let mixed = Mesh::new(
564 vec![
565 Vec3::ZERO,
566 Vec3::new(1.0e-3, 0.0, 0.0),
567 Vec3::new(0.0, 1.0e-3, 0.0),
568 Vec3::new(1.0e6, 0.0, 0.0),
569 Vec3::new(1.0e6 + 1.0, 0.0, 0.0),
570 Vec3::new(1.0e6, 1.0, 0.0),
571 ],
572 vec![[0, 1, 2], [3, 4, 5]],
573 )
574 .analyze(&Default::default())
575 .unwrap();
576 assert!(mixed.degenerate_triangles.is_empty());
577 assert_eq!(mixed.all_non_degenerate(), vec![0, 1]);
578
579 let precision_limited = Mesh::new(
580 vec![
581 Vec3::ZERO,
582 Vec3::X,
583 Vec3::Y,
584 Vec3::new(1.0e16, 0.0, 0.0),
585 Vec3::new(1.0e16 + 2.0, 0.0, 0.0),
586 Vec3::new(1.0e16, 2.0, 0.0),
587 ],
588 vec![[0, 1, 2], [3, 4, 5]],
589 )
590 .analyze(&Default::default())
591 .unwrap();
592 assert_eq!(precision_limited.degenerate_triangles, vec![1]);
593 assert_eq!(precision_limited.triangles[1].area, 0.0);
594 assert_eq!(precision_limited.all_non_degenerate(), vec![0]);
595 }
596}