1use ogeom_core::Tolerances;
12use ogeom_math::{Aabb, Point, Vector};
13
14#[derive(Debug, Clone, PartialEq, Default)]
20pub struct Triangulation {
21 pub positions: Vec<Point>,
23 pub normals: Vec<Vector>,
25 pub parameters: Vec<(f64, f64)>,
27 pub triangles: Vec<[u32; 3]>,
30 pub deflection_met: bool,
32}
33
34impl Triangulation {
35 #[must_use]
37 pub fn new() -> Self {
38 Self {
39 deflection_met: true,
40 ..Self::default()
41 }
42 }
43
44 #[must_use]
46 pub fn vertex_count(&self) -> usize {
47 self.positions.len()
48 }
49
50 #[must_use]
52 pub fn triangle_count(&self) -> usize {
53 self.triangles.len()
54 }
55
56 #[must_use]
58 pub fn is_empty(&self) -> bool {
59 self.triangles.is_empty()
60 }
61
62 #[must_use]
64 pub fn bounds(&self) -> Aabb {
65 Aabb::of_points(&self.positions)
66 }
67
68 #[must_use]
74 pub fn area(&self) -> f64 {
75 self.triangles
76 .iter()
77 .map(|t| {
78 let [a, b, c] = t.map(|i| self.positions[i as usize]);
79 (b - a).cross(c - a).magnitude() * 0.5
80 })
81 .sum()
82 }
83
84 #[must_use]
93 pub fn volume(&self) -> f64 {
94 self.triangles
95 .iter()
96 .map(|t| {
97 let [a, b, c] = t.map(|i| self.positions[i as usize].to_vector());
98 a.dot(b.cross(c)) / 6.0
99 })
100 .sum()
101 }
102
103 #[must_use]
134 pub fn is_closed(&self) -> bool {
135 use std::collections::HashMap;
136 let mut balance: HashMap<(u32, u32), i64> = HashMap::new();
137 for t in &self.triangles {
138 for i in 0..3 {
139 let (a, b) = (t[i], t[(i + 1) % 3]);
140 let (key, step) = if a <= b { ((a, b), 1) } else { ((b, a), -1) };
142 *balance.entry(key).or_default() += step;
143 }
144 }
145 !balance.is_empty() && balance.values().all(|&n| n == 0)
146 }
147
148 #[must_use]
158 pub fn border_welded(&self, reach: f64) -> Self {
159 use std::collections::HashMap;
160 if !reach.is_finite() || reach <= 0.0 {
161 return self.clone();
162 }
163 let mut uses: HashMap<(u32, u32), usize> = HashMap::new();
166 for t in &self.triangles {
167 for i in 0..3 {
168 let (a, b) = (t[i], t[(i + 1) % 3]);
169 *uses.entry((a.min(b), a.max(b))).or_default() += 1;
170 }
171 }
172 let mut border: Vec<u32> = uses
173 .iter()
174 .filter(|&(_, &n)| n % 2 == 1)
175 .flat_map(|(&(a, b), _)| [a, b])
176 .collect();
177 border.sort_unstable();
178 border.dedup();
179 if border.is_empty() {
180 return self.clone();
181 }
182
183 let cell = reach.max(f64::MIN_POSITIVE);
186 let key = |p: Point| {
187 #[allow(clippy::cast_possible_truncation)]
188 (
189 (p.x / cell).round() as i64,
190 (p.y / cell).round() as i64,
191 (p.z / cell).round() as i64,
192 )
193 };
194 let mut buckets: HashMap<(i64, i64, i64), Vec<u32>> = HashMap::new();
195 #[allow(clippy::cast_possible_truncation)]
196 let mut remap: Vec<u32> = (0..self.positions.len() as u32).collect();
197 for &v in &border {
198 let p = self.positions[v as usize];
199 let (kx, ky, kz) = key(p);
200 let mut found = None;
201 'search: for dx in -1..=1 {
202 for dy in -1..=1 {
203 for dz in -1..=1 {
204 for &candidate in buckets
205 .get(&(kx + dx, ky + dy, kz + dz))
206 .map_or(&[][..], Vec::as_slice)
207 {
208 if self.positions[candidate as usize].distance(p) <= reach {
209 found = Some(candidate);
210 break 'search;
211 }
212 }
213 }
214 }
215 }
216 match found {
217 Some(rep) => remap[v as usize] = rep,
218 None => buckets.entry((kx, ky, kz)).or_default().push(v),
219 }
220 }
221
222 let mut out = Self::new();
223 out.deflection_met = self.deflection_met;
224 out.positions = self.positions.clone();
227 out.normals = self.normals.clone();
228 out.parameters = self.parameters.clone();
229 for t in &self.triangles {
230 let mapped = t.map(|i| remap[i as usize]);
231 if mapped[0] != mapped[1] && mapped[1] != mapped[2] && mapped[2] != mapped[0] {
232 out.triangles.push(mapped);
233 }
234 }
235 out
236 }
237
238 #[must_use]
247 pub fn border_stitched(&self, reach: f64) -> Self {
248 use std::collections::HashMap;
249 if !reach.is_finite() || reach <= 0.0 {
250 return self.clone();
251 }
252 let mut uses: HashMap<(u32, u32), usize> = HashMap::new();
253 for t in &self.triangles {
254 for i in 0..3 {
255 let (a, b) = (t[i], t[(i + 1) % 3]);
256 *uses.entry((a.min(b), a.max(b))).or_default() += 1;
257 }
258 }
259 let border_edges: Vec<(u32, u32)> = uses
260 .iter()
261 .filter(|&(_, &n)| n % 2 == 1)
262 .map(|(&e, _)| e)
263 .collect();
264 if border_edges.is_empty() {
265 return self.clone();
266 }
267 let mut border_vertices: Vec<u32> =
268 border_edges.iter().flat_map(|&(a, b)| [a, b]).collect();
269 border_vertices.sort_unstable();
270 border_vertices.dedup();
271
272 let mut splits: HashMap<(u32, u32), Vec<u32>> = HashMap::new();
275 for &(a, b) in &border_edges {
276 let (pa, pb) = (self.positions[a as usize], self.positions[b as usize]);
277 let d = pb - pa;
278 let l2 = d.dot(d);
279 if l2 <= 0.0 {
280 continue;
281 }
282 let mut on: Vec<(f64, u32)> = border_vertices
283 .iter()
284 .filter(|&&v| v != a && v != b)
285 .filter_map(|&v| {
286 let p = self.positions[v as usize];
287 let t = (p - pa).dot(d) / l2;
288 if !(0.001..=0.999).contains(&t) {
289 return None;
290 }
291 ((pa + d * t).distance(p) <= reach).then_some((t, v))
292 })
293 .collect();
294 if on.is_empty() {
295 continue;
296 }
297 on.sort_by(|x, y| x.0.total_cmp(&y.0));
298 splits.insert((a, b), on.into_iter().map(|(_, v)| v).collect());
299 }
300 if splits.is_empty() {
301 return self.clone();
302 }
303
304 let mut out = Self::new();
305 out.deflection_met = self.deflection_met;
306 out.positions = self.positions.clone();
307 out.normals = self.normals.clone();
308 out.parameters = self.parameters.clone();
309 for t in &self.triangles {
310 let mut ring: Vec<u32> = Vec::with_capacity(6);
313 let mut any = false;
314 for i in 0..3 {
315 let (a, b) = (t[i], t[(i + 1) % 3]);
316 ring.push(a);
317 if let Some(vs) = splits.get(&(a.min(b), a.max(b))) {
318 any = true;
319 if a < b {
320 ring.extend(vs.iter().copied());
321 } else {
322 ring.extend(vs.iter().rev().copied());
323 }
324 }
325 }
326 if !any {
327 out.triangles.push(*t);
328 continue;
329 }
330 for i in 1..ring.len() - 1 {
331 let tri = [ring[0], ring[i], ring[i + 1]];
332 if tri[0] != tri[1] && tri[1] != tri[2] && tri[2] != tri[0] {
333 out.triangles.push(tri);
334 }
335 }
336 }
337 out
338 }
339
340 #[must_use]
353 pub fn sealed(&self, width: f64) -> Self {
354 use std::collections::HashMap;
355 let mut out = self.clone();
356 let mut seen: HashMap<[u32; 3], Vec<usize>> = HashMap::new();
358 for (i, t) in out.triangles.iter().enumerate() {
359 let mut key = *t;
360 key.sort_unstable();
361 seen.entry(key).or_default().push(i);
362 }
363 let mut drop = vec![false; out.triangles.len()];
364 for list in seen.values() {
365 let mut open: Vec<usize> = Vec::new();
366 for &i in list {
367 let t = out.triangles[i];
368 let reverse = open.iter().position(|&j| {
369 let u = out.triangles[j];
370 (0..3).any(|k| [u[k], u[(k + 2) % 3], u[(k + 1) % 3]] == t)
371 });
372 match reverse {
373 Some(at) => {
374 drop[open.remove(at)] = true;
375 drop[i] = true;
376 }
377 None => open.push(i),
378 }
379 }
380 }
381 let mut index = 0;
382 out.triangles.retain(|_| {
383 let keep = !drop[index];
384 index += 1;
385 keep
386 });
387 if !width.is_finite() || width <= 0.0 {
388 return out;
389 }
390 let mut uses: HashMap<(u32, u32), usize> = HashMap::new();
392 for t in &out.triangles {
393 for k in 0..3 {
394 let (a, b) = (t[k], t[(k + 1) % 3]);
395 *uses.entry((a.min(b), a.max(b))).or_default() += 1;
396 }
397 }
398 let mut onward: HashMap<u32, Vec<u32>> = HashMap::new();
399 for t in &out.triangles {
400 for k in 0..3 {
401 let (a, b) = (t[k], t[(k + 1) % 3]);
402 if uses[&(a.min(b), a.max(b))] == 1 {
403 onward.entry(b).or_default().push(a);
404 }
405 }
406 }
407 let mut done: std::collections::HashSet<u32> = std::collections::HashSet::new();
408 let mut starts: Vec<u32> = onward.keys().copied().collect();
409 starts.sort_unstable();
410 for start in starts {
411 if done.contains(&start) {
412 continue;
413 }
414 let mut ring = vec![start];
415 let mut at = start;
416 let closed = loop {
417 let Some(next) = onward.get(&at) else {
418 break false;
419 };
420 let [next] = next[..] else {
421 break false;
422 };
423 if next == start {
424 break true;
425 }
426 if ring.contains(&next) || ring.len() > onward.len() {
427 break false;
428 }
429 ring.push(next);
430 at = next;
431 };
432 for &v in &ring {
433 done.insert(v);
434 }
435 if !closed || ring.len() < 3 {
436 continue;
437 }
438 let points: Vec<Point> = ring.iter().map(|&v| out.positions[v as usize]).collect();
439 let mut normal = Vector::ZERO;
440 let mut perimeter = 0.0;
441 for (i, p) in points.iter().enumerate() {
442 let q = points[(i + 1) % points.len()];
443 normal += p.to_vector().cross(q.to_vector());
444 perimeter += p.distance(q);
445 }
446 let area = normal.magnitude() / 2.0;
447 if perimeter <= 0.0 || 2.0 * area / perimeter > width {
448 continue;
449 }
450 for i in 1..ring.len() - 1 {
451 out.triangles.push([ring[0], ring[i], ring[i + 1]]);
452 }
453 }
454 out
455 }
456
457 pub fn append(&mut self, other: &Self) {
459 #[allow(clippy::cast_possible_truncation)]
460 let offset = self.positions.len() as u32;
461 self.positions.extend_from_slice(&other.positions);
462 self.normals.extend_from_slice(&other.normals);
463 self.parameters.extend_from_slice(&other.parameters);
464 self.triangles
465 .extend(other.triangles.iter().map(|t| t.map(|i| i + offset)));
466 self.deflection_met &= other.deflection_met;
467 }
468
469 #[must_use]
477 pub fn welded(&self, tol: Tolerances) -> Self {
478 use std::collections::HashMap;
479
480 let cell = tol.confusion().max(f64::MIN_POSITIVE);
484 let key = |p: Point| {
485 #[allow(clippy::cast_possible_truncation)]
486 (
487 (p.x / cell).round() as i64,
488 (p.y / cell).round() as i64,
489 (p.z / cell).round() as i64,
490 )
491 };
492
493 let mut buckets: HashMap<(i64, i64, i64), Vec<u32>> = HashMap::new();
494 let mut remap = vec![0_u32; self.positions.len()];
495 let mut out = Self::new();
496 out.deflection_met = self.deflection_met;
497
498 for (index, position) in self.positions.iter().enumerate() {
499 let (kx, ky, kz) = key(*position);
500 let mut found = None;
501 'search: for dx in -1..=1 {
502 for dy in -1..=1 {
503 for dz in -1..=1 {
504 for &candidate in buckets
505 .get(&(kx + dx, ky + dy, kz + dz))
506 .map_or(&[][..], Vec::as_slice)
507 {
508 if out.positions[candidate as usize].is_equal(*position, tol) {
509 found = Some(candidate);
510 break 'search;
511 }
512 }
513 }
514 }
515 }
516
517 let target = found.unwrap_or_else(|| {
518 #[allow(clippy::cast_possible_truncation)]
519 let fresh = out.positions.len() as u32;
520 out.positions.push(*position);
521 out.normals.push(self.normals[index]);
522 out.parameters.push(self.parameters[index]);
523 buckets.entry((kx, ky, kz)).or_default().push(fresh);
524 fresh
525 });
526 remap[index] = target;
527 }
528
529 for t in &self.triangles {
530 let mapped = t.map(|i| remap[i as usize]);
531 if mapped[0] != mapped[1] && mapped[1] != mapped[2] && mapped[2] != mapped[0] {
534 out.triangles.push(mapped);
535 }
536 }
537 out
538 }
539}
540
541#[cfg(test)]
542#[allow(clippy::unwrap_used)]
543mod tests {
544 use super::*;
545 use approx::assert_relative_eq;
546
547 const T: Tolerances = Tolerances::millimetres();
548
549 #[test]
550 fn an_empty_mesh_answers_sensibly() {
551 let mesh = Triangulation::new();
552 assert!(mesh.is_empty());
553 assert_eq!(mesh.triangle_count(), 0);
554 assert_relative_eq!(mesh.area(), 0.0);
555 assert_relative_eq!(mesh.volume(), 0.0);
556 assert!(!mesh.is_closed(), "nothing is not closed");
557 assert!(mesh.bounds().is_empty());
558 }
559
560 fn over(points: &[Point], triangles: &[[u32; 3]]) -> Triangulation {
562 let mut mesh = Triangulation::new();
563 for p in points {
564 mesh.positions.push(*p);
565 mesh.normals.push(Vector::Z);
566 mesh.parameters.push((0.0, 0.0));
567 }
568 mesh.triangles.extend_from_slice(triangles);
569 mesh
570 }
571
572 #[test]
580 fn a_closed_mesh_is_one_crossed_as_often_each_way() {
581 let corners = [
582 Point::new(0.0, 0.0, 0.0),
583 Point::new(1.0, 0.0, 0.0),
584 Point::new(0.0, 1.0, 0.0),
585 Point::new(0.0, 0.0, 1.0),
586 ];
587 let solid = over(&corners, &[[0, 2, 1], [0, 1, 3], [0, 3, 2], [1, 2, 3]]);
589 assert!(solid.is_closed(), "a tetrahedron closes");
590 assert!(solid.volume() > 0.0, "and wound outward");
591
592 let mut flipped = solid.clone();
596 flipped.triangles[3] = [1, 3, 2];
597 assert!(
598 !flipped.is_closed(),
599 "a face inside out is not a closed mesh"
600 );
601
602 let mut holed = solid.clone();
604 holed.triangles.pop();
605 assert!(!holed.is_closed(), "three edges left dangling");
606
607 let mut pair = solid.clone();
614 let mirrored = Point::new(0.0, -1.0, 0.0);
615 #[allow(clippy::cast_possible_truncation)]
616 let m = pair.positions.len() as u32;
617 pair.positions.push(mirrored);
618 pair.normals.push(Vector::Z);
619 pair.parameters.push((0.0, 0.0));
620 let apex = 3;
621 pair.triangles
622 .extend_from_slice(&[[0, 1, m], [0, m, apex], [0, apex, 1], [1, apex, m]]);
623 let four = pair
624 .triangles
625 .iter()
626 .flat_map(|t| (0..3).map(move |i| (t[i], t[(i + 1) % 3])))
627 .filter(|(a, b)| (*a == 0 && *b == 1) || (*a == 1 && *b == 0))
628 .count();
629 assert_eq!(four, 4, "the shared edge carries four triangles");
630 assert!(pair.is_closed(), "and the pair is still closed");
631 }
632
633 #[test]
634 fn welding_drops_triangles_that_collapse() {
635 let mut mesh = Triangulation::new();
638 for _ in 0..3 {
639 mesh.positions.push(Point::ORIGIN);
640 mesh.normals.push(Vector::Z);
641 mesh.parameters.push((0.0, 0.0));
642 }
643 mesh.triangles.push([0, 1, 2]);
644 let welded = mesh.welded(T);
645 assert_eq!(welded.vertex_count(), 1);
646 assert_eq!(welded.triangle_count(), 0);
647 }
648
649 #[test]
650 fn appending_shifts_indices_rather_than_overlapping_them() {
651 let mut a = Triangulation::new();
652 a.positions.push(Point::ORIGIN);
653 a.normals.push(Vector::Z);
654 a.parameters.push((0.0, 0.0));
655
656 let mut b = Triangulation::new();
657 b.positions.push(Point::new(1.0, 0.0, 0.0));
658 b.normals.push(Vector::Z);
659 b.parameters.push((1.0, 0.0));
660 b.triangles.push([0, 0, 0]);
661
662 a.append(&b);
663 assert_eq!(a.vertex_count(), 2);
664 assert_eq!(
665 a.triangles[0],
666 [1, 1, 1],
667 "b's index moved past a's vertices"
668 );
669 }
670
671 #[test]
672 fn deflection_failure_propagates_through_the_whole_mesh() {
673 let mut a = Triangulation::new();
677 let mut b = Triangulation::new();
678 b.deflection_met = false;
679 a.append(&b);
680 assert!(!a.deflection_met);
681 }
682
683 #[test]
688 fn sealing_closes_cracks_narrower_than_asked_and_cancels_folds() {
689 let corners = [
690 Point::new(0.0, 0.0, 0.0),
691 Point::new(1.0, 0.0, 0.0),
692 Point::new(0.5, 0.01, 0.0),
693 Point::new(0.5, 0.5, 1.0),
694 ];
695 let solid = over(&corners, &[[0, 2, 1], [0, 1, 3], [1, 2, 3], [2, 0, 3]]);
696 assert!(solid.is_closed());
697 assert!(solid.volume() > 0.0);
698 let mut cracked = solid.clone();
699 cracked.triangles.remove(0);
700 assert!(!cracked.is_closed());
701 let sealed = cracked.sealed(0.05);
702 assert!(sealed.is_closed(), "a crack under the width is sealed");
703 assert_relative_eq!(sealed.volume(), solid.volume(), epsilon = 1e-12);
704 assert!(
705 !cracked.sealed(1e-3).is_closed(),
706 "a crack wider than asked stays open"
707 );
708 let mut folded = solid.clone();
709 folded.triangles.push([0, 1, 3]);
710 folded.triangles.push([0, 3, 1]);
711 let unfolded = folded.sealed(0.0);
712 assert_eq!(
713 unfolded.triangle_count(),
714 solid.triangle_count(),
715 "the fold is cancelled"
716 );
717 assert!(unfolded.is_closed());
718 }
719}