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>,
26 pub parameters: Vec<(f64, f64)>,
29 pub triangles: Vec<[u32; 3]>,
32 pub deflection_met: bool,
34}
35
36impl Triangulation {
37 #[must_use]
39 pub fn new() -> Self {
40 Self {
41 deflection_met: true,
42 ..Self::default()
43 }
44 }
45
46 #[must_use]
48 pub fn vertex_count(&self) -> usize {
49 self.positions.len()
50 }
51
52 #[must_use]
54 pub fn triangle_count(&self) -> usize {
55 self.triangles.len()
56 }
57
58 #[must_use]
60 pub fn is_empty(&self) -> bool {
61 self.triangles.is_empty()
62 }
63
64 #[must_use]
66 pub fn bounds(&self) -> Aabb {
67 Aabb::of_points(&self.positions)
68 }
69
70 #[must_use]
76 pub fn area(&self) -> f64 {
77 self.triangles
78 .iter()
79 .map(|t| {
80 let [a, b, c] = t.map(|i| self.positions[i as usize]);
81 (b - a).cross(c - a).magnitude() * 0.5
82 })
83 .sum()
84 }
85
86 #[must_use]
99 pub fn volume(&self) -> f64 {
100 let Some(&apex) = self.positions.first() else {
101 return 0.0;
102 };
103 self.triangles
104 .iter()
105 .map(|t| {
106 let [a, b, c] = t.map(|i| self.positions[i as usize] - apex);
107 a.dot(b.cross(c)) / 6.0
108 })
109 .sum()
110 }
111
112 #[must_use]
142 pub fn is_closed(&self) -> bool {
143 use ogeom_core::FastMap;
144 let mut balance: FastMap<(u32, u32), i64> = FastMap::default();
145 for t in &self.triangles {
146 for i in 0..3 {
147 let (a, b) = (t[i], t[(i + 1) % 3]);
148 let (key, step) = if a <= b { ((a, b), 1) } else { ((b, a), -1) };
150 *balance.entry(key).or_default() += step;
151 }
152 }
153 !balance.is_empty() && balance.values().all(|&n| n == 0)
154 }
155
156 #[must_use]
166 pub fn border_welded(&self, reach: f64) -> Self {
167 use ogeom_core::FastMap;
168 if !reach.is_finite() || reach <= 0.0 {
169 return self.clone();
170 }
171 let uses = edge_uses(&self.triangles);
174 let mut border: Vec<u32> = uses
175 .iter()
176 .filter(|&(_, &n)| n % 2 == 1)
177 .flat_map(|(&(a, b), _)| [a, b])
178 .collect();
179 border.sort_unstable();
180 border.dedup();
181 if border.is_empty() {
182 return self.clone();
183 }
184
185 let cell = reach.max(f64::MIN_POSITIVE);
188 let key = |p: Point| {
189 #[allow(clippy::cast_possible_truncation)]
190 (
191 (p.x / cell).round() as i64,
192 (p.y / cell).round() as i64,
193 (p.z / cell).round() as i64,
194 )
195 };
196 let mut buckets: FastMap<(i64, i64, i64), Vec<u32>> = FastMap::default();
197 #[allow(clippy::cast_possible_truncation)]
198 let mut remap: Vec<u32> = (0..self.positions.len() as u32).collect();
199 for &v in &border {
200 let p = self.positions[v as usize];
201 let (kx, ky, kz) = key(p);
202 let mut found = None;
203 'search: for dx in -1..=1 {
204 for dy in -1..=1 {
205 for dz in -1..=1 {
206 for &candidate in buckets
207 .get(&(
208 kx.saturating_add(dx),
209 ky.saturating_add(dy),
210 kz.saturating_add(dz),
211 ))
212 .map_or(&[][..], Vec::as_slice)
213 {
214 if self.positions[candidate as usize].distance(p) <= reach {
215 found = Some(candidate);
216 break 'search;
217 }
218 }
219 }
220 }
221 }
222 match found {
223 Some(rep) => remap[v as usize] = rep,
224 None => buckets.entry((kx, ky, kz)).or_default().push(v),
225 }
226 }
227
228 let mut out = Self::new();
229 out.deflection_met = self.deflection_met;
230 out.positions = self.positions.clone();
233 out.normals = self.normals.clone();
234 out.parameters = self.parameters.clone();
235 for t in &self.triangles {
236 let mapped = t.map(|i| remap[i as usize]);
237 if mapped[0] != mapped[1] && mapped[1] != mapped[2] && mapped[2] != mapped[0] {
238 out.triangles.push(mapped);
239 }
240 }
241 out
242 }
243
244 #[must_use]
253 pub fn border_stitched(&self, reach: f64) -> Self {
254 self.border_stitched_with_sources(reach).0
255 }
256
257 #[must_use]
259 pub fn border_stitched_with_sources(&self, reach: f64) -> (Self, Vec<usize>) {
260 use ogeom_core::FastMap;
261 if !reach.is_finite() || reach <= 0.0 {
262 return (self.clone(), (0..self.triangles.len()).collect());
263 }
264 let uses = edge_uses(&self.triangles);
265 let border_edges: Vec<(u32, u32)> = uses
266 .iter()
267 .filter(|&(_, &n)| n % 2 == 1)
268 .map(|(&e, _)| e)
269 .collect();
270 if border_edges.is_empty() {
271 return (self.clone(), (0..self.triangles.len()).collect());
272 }
273 let mut border_vertices: Vec<u32> =
274 border_edges.iter().flat_map(|&(a, b)| [a, b]).collect();
275 border_vertices.sort_unstable();
276 border_vertices.dedup();
277 #[allow(clippy::cast_precision_loss, reason = "a mean over a count")]
279 let spacing = border_edges
280 .iter()
281 .map(|&(a, b)| self.positions[a as usize].distance(self.positions[b as usize]))
282 .sum::<f64>()
283 / border_edges.len() as f64;
284 let grid = PointGrid::over(&self.positions, &border_vertices, spacing, reach);
285
286 let mut splits: FastMap<(u32, u32), Vec<u32>> = FastMap::default();
289 let mut near: Vec<u32> = Vec::new();
290 for &(a, b) in &border_edges {
291 let (pa, pb) = (self.positions[a as usize], self.positions[b as usize]);
292 let d = pb - pa;
293 let l2 = d.dot(d);
294 if l2 <= 0.0 {
295 continue;
296 }
297 near.clear();
298 match &grid {
299 Some(grid) => grid.within(pa, pb, reach, &mut near),
300 None => near.extend_from_slice(&border_vertices),
301 }
302 let mut on: Vec<(f64, u32)> = near
303 .iter()
304 .filter(|&&v| v != a && v != b)
305 .filter_map(|&v| {
306 let p = self.positions[v as usize];
307 let t = (p - pa).dot(d) / l2;
308 if !(0.001..=0.999).contains(&t) {
309 return None;
310 }
311 ((pa + d * t).distance(p) <= reach).then_some((t, v))
312 })
313 .collect();
314 if on.is_empty() {
315 continue;
316 }
317 on.sort_by(|x, y| x.0.total_cmp(&y.0).then(x.1.cmp(&y.1)));
318 splits.insert((a, b), on.into_iter().map(|(_, v)| v).collect());
319 }
320 if splits.is_empty() {
321 return (self.clone(), (0..self.triangles.len()).collect());
322 }
323
324 let mut out = Self::new();
325 out.deflection_met = self.deflection_met;
326 out.positions = self.positions.clone();
327 out.normals = self.normals.clone();
328 out.parameters = self.parameters.clone();
329 let mut sources = Vec::new();
330 for (source, t) in self.triangles.iter().enumerate() {
331 let sides = std::array::from_fn(|i| {
335 let (a, b) = (t[i], t[(i + 1) % 3]);
336 splits
337 .get(&(a.min(b), a.max(b)))
338 .map_or(&[][..], Vec::as_slice)
339 });
340 let nearest = nearest_split_sides(t, sides, |i, v| {
341 let (a, b) = (t[i], t[(i + 1) % 3]);
342 let (pa, pb, p) = (
343 self.positions[a as usize],
344 self.positions[b as usize],
345 self.positions[v as usize],
346 );
347 let d = pb - pa;
348 let along = (p - pa).dot(d) / d.dot(d);
349 (pa + d * along).distance(p)
350 });
351 let mut pieces = vec![*t];
352 for i in 0..3 {
353 let (a, b) = (t[i], t[(i + 1) % 3]);
354 if let Some(vs) = splits.get(&(a.min(b), a.max(b))) {
355 let vs: Vec<_> = vs
359 .iter()
360 .copied()
361 .filter(|v| nearest.get(v) == Some(&i))
362 .collect();
363 if vs.is_empty() {
364 continue;
365 }
366 let mut chain = vec![a];
367 if a < b {
368 chain.extend(vs.iter().copied());
369 } else {
370 chain.extend(vs.iter().rev().copied());
371 }
372 chain.push(b);
373 let Some(at) = pieces
374 .iter()
375 .position(|piece| (0..3).any(|k| piece[k] == a && piece[(k + 1) % 3] == b))
376 else {
377 panic!(
378 "source triangle {source}: original side {a}->{b} is missing from current pieces {pieces:?}"
379 );
380 };
381 let piece = pieces.remove(at);
382 let Some(&opposite) = piece.iter().find(|&&v| v != a && v != b) else {
383 panic!(
384 "source triangle {source}: piece {piece:?} has no corner opposite side {a}->{b}"
385 );
386 };
387 pieces.extend(chain.windows(2).map(|side| [side[0], side[1], opposite]));
388 }
389 }
390 for tri in pieces {
391 out.triangles.push(tri);
392 sources.push(source);
393 }
394 }
395 (out, sources)
396 }
397
398 #[must_use]
411 pub fn sealed(&self, width: f64) -> Self {
412 use ogeom_core::FastMap;
413 let mut out = self.without_folds_with_sources().0;
414 if !width.is_finite() || width <= 0.0 {
415 return out;
416 }
417 let uses = edge_uses(&out.triangles);
419 let mut onward: FastMap<u32, Vec<u32>> = FastMap::default();
420 for t in &out.triangles {
421 for k in 0..3 {
422 let (a, b) = (t[k], t[(k + 1) % 3]);
423 if uses[&(a.min(b), a.max(b))] == 1 {
424 onward.entry(b).or_default().push(a);
425 }
426 }
427 }
428 let mut done: ogeom_core::FastSet<u32> = ogeom_core::FastSet::default();
429 let mut starts: Vec<u32> = onward.keys().copied().collect();
430 starts.sort_unstable();
431 let mut on_walk: Vec<u32> = vec![0; out.positions.len()];
433 #[allow(clippy::cast_possible_truncation)]
434 for (walk, start) in starts.into_iter().enumerate() {
435 let walk = walk as u32 + 1;
436 if done.contains(&start) {
437 continue;
438 }
439 let mut ring = vec![start];
440 if let Some(mark) = on_walk.get_mut(start as usize) {
441 *mark = walk;
442 }
443 let mut at = start;
444 let closed = loop {
445 let Some(next) = onward.get(&at) else {
446 break false;
447 };
448 let [next] = next[..] else {
449 break false;
450 };
451 if next == start {
452 break true;
453 }
454 let seen = match on_walk.get(next as usize) {
455 Some(&mark) => mark == walk,
456 None => ring.contains(&next),
457 };
458 if seen || ring.len() > onward.len() {
459 break false;
460 }
461 ring.push(next);
462 if let Some(mark) = on_walk.get_mut(next as usize) {
463 *mark = walk;
464 }
465 at = next;
466 };
467 for &v in &ring {
468 done.insert(v);
469 }
470 if !closed || ring.len() < 3 {
471 continue;
472 }
473 let points: Vec<Point> = ring.iter().map(|&v| out.positions[v as usize]).collect();
474 let anchor = points[0];
478 let mut normal = Vector::ZERO;
479 let mut perimeter = 0.0;
480 for (i, p) in points.iter().enumerate() {
481 let q = points[(i + 1) % points.len()];
482 normal += (*p - anchor).cross(q - anchor);
483 perimeter += p.distance(q);
484 }
485 let area = normal.magnitude() / 2.0;
486 if perimeter <= 0.0 || 2.0 * area / perimeter > width {
487 continue;
488 }
489 for i in 1..ring.len() - 1 {
490 out.triangles.push([ring[0], ring[i], ring[i + 1]]);
491 }
492 }
493 out
494 }
495
496 #[must_use]
499 pub fn without_folds_with_sources(&self) -> (Self, Vec<usize>) {
500 let mut keyed: Vec<([u32; 3], usize)> = self
501 .triangles
502 .iter()
503 .enumerate()
504 .map(|(i, t)| {
505 let mut key = *t;
506 key.sort_unstable();
507 (key, i)
508 })
509 .collect();
510 keyed.sort_unstable();
511 let mut drop = vec![false; self.triangles.len()];
512 let mut open: Vec<usize> = Vec::new();
513 for group in keyed.chunk_by(|x, y| x.0 == y.0) {
514 if group.len() < 2 {
515 continue;
516 }
517 open.clear();
518 for &(_, i) in group {
519 let t = self.triangles[i];
520 let reverse = open.iter().position(|&j| {
521 let u = self.triangles[j];
522 (0..3).any(|k| [u[k], u[(k + 2) % 3], u[(k + 1) % 3]] == t)
523 });
524 match reverse {
525 Some(at) => {
526 drop[open.remove(at)] = true;
527 drop[i] = true;
528 }
529 None => open.push(i),
530 }
531 }
532 }
533 let sources: Vec<_> = (0..self.triangles.len()).filter(|&i| !drop[i]).collect();
534 let mut out = self.clone();
535 out.triangles = sources.iter().map(|&i| self.triangles[i]).collect();
536 (out, sources)
537 }
538
539 pub fn append(&mut self, other: &Self) {
541 #[allow(clippy::cast_possible_truncation)]
542 let offset = self.positions.len() as u32;
543 self.positions.extend_from_slice(&other.positions);
544 self.normals.extend_from_slice(&other.normals);
545 self.parameters.extend_from_slice(&other.parameters);
546 self.triangles
547 .extend(other.triangles.iter().map(|t| t.map(|i| i + offset)));
548 self.deflection_met &= other.deflection_met;
549 }
550
551 #[must_use]
559 pub fn welded(&self, tol: Tolerances) -> Self {
560 use ogeom_core::FastMap;
561
562 let cell = tol.confusion().max(f64::MIN_POSITIVE);
566 let key = |p: Point| {
567 #[allow(clippy::cast_possible_truncation)]
568 (
569 (p.x / cell).round() as i64,
570 (p.y / cell).round() as i64,
571 (p.z / cell).round() as i64,
572 )
573 };
574
575 let mut buckets: FastMap<(i64, i64, i64), (u32, u32)> = FastMap::default();
578 let mut next_in_cell: Vec<u32> = Vec::with_capacity(self.positions.len());
579 let mut remap = vec![0_u32; self.positions.len()];
580 let mut out = Self::new();
581 out.deflection_met = self.deflection_met;
582
583 for (index, position) in self.positions.iter().enumerate() {
584 let (kx, ky, kz) = key(*position);
585 let mut found = None;
586 'search: for dx in -1..=1 {
587 for dy in -1..=1 {
588 for dz in -1..=1 {
589 let Some(&(first, _)) = buckets.get(&(
590 kx.saturating_add(dx),
591 ky.saturating_add(dy),
592 kz.saturating_add(dz),
593 )) else {
594 continue;
595 };
596 let mut candidate = first;
597 while candidate != u32::MAX {
598 if out.positions[candidate as usize].is_equal(*position, tol) {
599 found = Some(candidate);
600 break 'search;
601 }
602 candidate = next_in_cell[candidate as usize];
603 }
604 }
605 }
606 }
607
608 let target = found.unwrap_or_else(|| {
609 #[allow(clippy::cast_possible_truncation)]
610 let fresh = out.positions.len() as u32;
611 out.positions.push(*position);
612 if let Some(&normal) = self.normals.get(index)
615 && self.normals.len() == self.positions.len()
616 {
617 out.normals.push(normal);
618 }
619 if let Some(&uv) = self.parameters.get(index)
620 && self.parameters.len() == self.positions.len()
621 {
622 out.parameters.push(uv);
623 }
624 next_in_cell.push(u32::MAX);
625 if let Some((_, last)) = buckets.get_mut(&(kx, ky, kz)) {
626 next_in_cell[*last as usize] = fresh;
627 *last = fresh;
628 } else {
629 buckets.insert((kx, ky, kz), (fresh, fresh));
630 }
631 fresh
632 });
633 remap[index] = target;
634 }
635
636 for t in &self.triangles {
637 let mapped = t.map(|i| remap[i as usize]);
638 if mapped[0] != mapped[1] && mapped[1] != mapped[2] && mapped[2] != mapped[0] {
641 out.triangles.push(mapped);
642 }
643 }
644 out
645 }
646}
647
648fn nearest_split_sides<V: Copy + Eq + std::hash::Hash>(
651 corners: &[V; 3],
652 sides: [&[V]; 3],
653 mut distance: impl FnMut(usize, V) -> f64,
654) -> ogeom_core::FastMap<V, usize> {
655 let mut nearest: ogeom_core::FastMap<V, (usize, f64)> = ogeom_core::FastMap::default();
656 for (side, vertices) in sides.into_iter().enumerate() {
657 for &v in vertices {
658 if corners.contains(&v) {
659 continue;
660 }
661 let d = distance(side, v);
662 let entry = nearest.entry(v).or_insert((side, d));
663 if d.total_cmp(&entry.1).is_lt() {
664 *entry = (side, d);
665 }
666 }
667 }
668 nearest
669 .into_iter()
670 .map(|(v, (side, _))| (v, side))
671 .collect()
672}
673
674fn edge_uses(triangles: &[[u32; 3]]) -> ogeom_core::FastMap<(u32, u32), usize> {
676 let mut uses: ogeom_core::FastMap<(u32, u32), usize> = ogeom_core::FastMap::default();
677 uses.reserve(triangles.len() * 3 / 2 + 3);
680 for t in triangles {
681 for i in 0..3 {
682 let (a, b) = (t[i], t[(i + 1) % 3]);
683 *uses.entry((a.min(b), a.max(b))).or_default() += 1;
684 }
685 }
686 uses
687}
688
689struct PointGrid {
692 cell: f64,
693 cells: ogeom_core::FastMap<(i64, i64, i64), Vec<u32>>,
695 all: Vec<u32>,
698}
699
700impl PointGrid {
701 fn over(positions: &[Point], vertices: &[u32], spacing: f64, reach: f64) -> Option<Self> {
704 let cell = spacing.max(reach);
705 if !cell.is_finite() || cell <= 0.0 {
706 return None;
707 }
708 let mut cells: ogeom_core::FastMap<(i64, i64, i64), Vec<u32>> =
709 ogeom_core::FastMap::default();
710 for &v in vertices {
711 cells
712 .entry(Self::key(positions[v as usize], cell))
713 .or_default()
714 .push(v);
715 }
716 Some(Self {
717 cell,
718 cells,
719 all: vertices.to_vec(),
720 })
721 }
722
723 #[allow(clippy::cast_possible_truncation)]
724 fn key(p: Point, cell: f64) -> (i64, i64, i64) {
725 (
726 (p.x / cell).floor() as i64,
727 (p.y / cell).floor() as i64,
728 (p.z / cell).floor() as i64,
729 )
730 }
731
732 fn within(&self, a: Point, b: Point, reach: f64, out: &mut Vec<u32>) {
736 let low = Point::new(
737 a.x.min(b.x) - reach,
738 a.y.min(b.y) - reach,
739 a.z.min(b.z) - reach,
740 );
741 let high = Point::new(
742 a.x.max(b.x) + reach,
743 a.y.max(b.y) + reach,
744 a.z.max(b.z) + reach,
745 );
746 let (lo, hi) = (Self::key(low, self.cell), Self::key(high, self.cell));
747 let span = |l: i64, h: i64| h.saturating_sub(l).saturating_add(3).unsigned_abs();
748 let boxed = span(lo.0, hi.0)
749 .saturating_mul(span(lo.1, hi.1))
750 .saturating_mul(span(lo.2, hi.2));
751 if boxed > self.cells.len() as u64 {
752 out.extend_from_slice(&self.all);
753 return;
754 }
755 for x in lo.0.saturating_sub(1)..=hi.0.saturating_add(1) {
756 for y in lo.1.saturating_sub(1)..=hi.1.saturating_add(1) {
757 for z in lo.2.saturating_sub(1)..=hi.2.saturating_add(1) {
758 if let Some(found) = self.cells.get(&(x, y, z)) {
759 out.extend_from_slice(found);
760 }
761 }
762 }
763 }
764 }
765}
766
767#[cfg(test)]
768#[allow(clippy::unwrap_used)]
769mod tests {
770 use super::*;
771 use approx::assert_relative_eq;
772
773 const T: Tolerances = Tolerances::millimetres();
774
775 #[test]
776 fn a_mesh_of_positions_alone_welds() {
777 let mut mesh = Triangulation::new();
778 mesh.positions = vec![
779 Point::new(0.0, 0.0, 0.0),
780 Point::new(1.0, 0.0, 0.0),
781 Point::new(0.0, 1.0, 0.0),
782 Point::new(1.0, 0.0, 0.0),
783 Point::new(1.0, 1.0, 0.0),
784 Point::new(0.0, 1.0, 0.0),
785 ];
786 mesh.triangles = vec![[0, 1, 2], [3, 4, 5]];
787 let welded = mesh.welded(T);
788 assert_eq!(welded.positions.len(), 4);
789 assert!(welded.normals.is_empty() && welded.parameters.is_empty());
790 assert_eq!(welded.triangles, vec![[0, 1, 2], [1, 3, 2]]);
791 }
792
793 #[test]
794 fn an_empty_mesh_answers_sensibly() {
795 let mesh = Triangulation::new();
796 assert!(mesh.is_empty());
797 assert_eq!(mesh.triangle_count(), 0);
798 assert_relative_eq!(mesh.area(), 0.0);
799 assert_relative_eq!(mesh.volume(), 0.0);
800 assert!(!mesh.is_closed(), "nothing is not closed");
801 assert!(mesh.bounds().is_empty());
802 }
803
804 #[test]
808 fn a_closed_mesh_far_from_the_origin_encloses_its_own_volume() {
809 for offset in [0.0, 1.0e6, 1.0e7, 1.0e8] {
810 let o = Vector::new(offset, -offset, offset);
811 let mut mesh = Triangulation::new();
812 for k in 0..8 {
813 let corner =
814 Point::new(f64::from(k & 1), f64::from((k >> 1) & 1), f64::from(k >> 2));
815 mesh.positions.push(corner + o);
816 }
817 mesh.triangles = vec![
820 [0, 2, 1],
821 [1, 2, 3], [4, 5, 6],
823 [5, 7, 6], [0, 1, 4],
825 [1, 5, 4], [2, 6, 3],
827 [3, 6, 7], [0, 4, 2],
829 [2, 4, 6], [1, 3, 5],
831 [3, 7, 5], ];
833 assert!(mesh.is_closed(), "the cube closes at {offset:e}");
834 assert_relative_eq!(mesh.volume(), 1.0, epsilon = 1e-9);
837 }
838 }
839
840 fn over(points: &[Point], triangles: &[[u32; 3]]) -> Triangulation {
842 let mut mesh = Triangulation::new();
843 for p in points {
844 mesh.positions.push(*p);
845 mesh.normals.push(Vector::Z);
846 mesh.parameters.push((0.0, 0.0));
847 }
848 mesh.triangles.extend_from_slice(triangles);
849 mesh
850 }
851
852 #[test]
860 fn a_closed_mesh_is_one_crossed_as_often_each_way() {
861 let corners = [
862 Point::new(0.0, 0.0, 0.0),
863 Point::new(1.0, 0.0, 0.0),
864 Point::new(0.0, 1.0, 0.0),
865 Point::new(0.0, 0.0, 1.0),
866 ];
867 let solid = over(&corners, &[[0, 2, 1], [0, 1, 3], [0, 3, 2], [1, 2, 3]]);
869 assert!(solid.is_closed(), "a tetrahedron closes");
870 assert!(solid.volume() > 0.0, "and wound outward");
871
872 let mut flipped = solid.clone();
876 flipped.triangles[3] = [1, 3, 2];
877 assert!(
878 !flipped.is_closed(),
879 "a face inside out is not a closed mesh"
880 );
881
882 let mut holed = solid.clone();
884 holed.triangles.pop();
885 assert!(!holed.is_closed(), "three edges left dangling");
886
887 let mut pair = solid.clone();
894 let mirrored = Point::new(0.0, -1.0, 0.0);
895 #[allow(clippy::cast_possible_truncation)]
896 let m = pair.positions.len() as u32;
897 pair.positions.push(mirrored);
898 pair.normals.push(Vector::Z);
899 pair.parameters.push((0.0, 0.0));
900 let apex = 3;
901 pair.triangles
902 .extend_from_slice(&[[0, 1, m], [0, m, apex], [0, apex, 1], [1, apex, m]]);
903 let four = pair
904 .triangles
905 .iter()
906 .flat_map(|t| (0..3).map(move |i| (t[i], t[(i + 1) % 3])))
907 .filter(|(a, b)| (*a == 0 && *b == 1) || (*a == 1 && *b == 0))
908 .count();
909 assert_eq!(four, 4, "the shared edge carries four triangles");
910 assert!(pair.is_closed(), "and the pair is still closed");
911 }
912
913 #[test]
914 fn welding_drops_triangles_that_collapse() {
915 let mut mesh = Triangulation::new();
918 for _ in 0..3 {
919 mesh.positions.push(Point::ORIGIN);
920 mesh.normals.push(Vector::Z);
921 mesh.parameters.push((0.0, 0.0));
922 }
923 mesh.triangles.push([0, 1, 2]);
924 let welded = mesh.welded(T);
925 assert_eq!(welded.vertex_count(), 1);
926 assert_eq!(welded.triangle_count(), 0);
927 }
928
929 #[test]
930 fn appending_shifts_indices_rather_than_overlapping_them() {
931 let mut a = Triangulation::new();
932 a.positions.push(Point::ORIGIN);
933 a.normals.push(Vector::Z);
934 a.parameters.push((0.0, 0.0));
935
936 let mut b = Triangulation::new();
937 b.positions.push(Point::new(1.0, 0.0, 0.0));
938 b.normals.push(Vector::Z);
939 b.parameters.push((1.0, 0.0));
940 b.triangles.push([0, 0, 0]);
941
942 a.append(&b);
943 assert_eq!(a.vertex_count(), 2);
944 assert_eq!(
945 a.triangles[0],
946 [1, 1, 1],
947 "b's index moved past a's vertices"
948 );
949 }
950
951 #[test]
952 fn deflection_failure_propagates_through_the_whole_mesh() {
953 let mut a = Triangulation::new();
957 let mut b = Triangulation::new();
958 b.deflection_met = false;
959 a.append(&b);
960 assert!(!a.deflection_met);
961 }
962
963 #[test]
968 fn sealing_closes_cracks_narrower_than_asked_and_cancels_folds() {
969 let corners = [
970 Point::new(0.0, 0.0, 0.0),
971 Point::new(1.0, 0.0, 0.0),
972 Point::new(0.5, 0.01, 0.0),
973 Point::new(0.5, 0.5, 1.0),
974 ];
975 let solid = over(&corners, &[[0, 2, 1], [0, 1, 3], [1, 2, 3], [2, 0, 3]]);
976 assert!(solid.is_closed());
977 assert!(solid.volume() > 0.0);
978 let mut cracked = solid.clone();
979 cracked.triangles.remove(0);
980 assert!(!cracked.is_closed());
981 let sealed = cracked.sealed(0.05);
982 assert!(sealed.is_closed(), "a crack under the width is sealed");
983 assert_relative_eq!(sealed.volume(), solid.volume(), epsilon = 1e-12);
984 assert!(
985 !cracked.sealed(1e-3).is_closed(),
986 "a crack wider than asked stays open"
987 );
988 let mut folded = solid.clone();
989 folded.triangles.push([0, 1, 3]);
990 folded.triangles.push([0, 3, 1]);
991 let unfolded = folded.sealed(0.0);
992 assert_eq!(
993 unfolded.triangle_count(),
994 solid.triangle_count(),
995 "the fold is cancelled"
996 );
997 assert!(unfolded.is_closed());
998 let (unfolded, sources) = folded.without_folds_with_sources();
999 assert_eq!(sources.len(), unfolded.triangles.len());
1000 for (triangle, source) in unfolded.triangles.iter().zip(sources) {
1001 assert_eq!(*triangle, folded.triangles[source]);
1002 }
1003 }
1004
1005 #[test]
1006 fn dense_border_assigns_each_candidate_once_per_side() {
1007 use std::cell::Cell;
1008 use std::hash::{Hash, Hasher};
1009
1010 #[derive(Clone, Copy)]
1011 struct Vertex<'a>(u32, &'a Cell<usize>);
1012 impl PartialEq for Vertex<'_> {
1013 fn eq(&self, other: &Self) -> bool {
1014 self.1.set(self.1.get() + 1);
1015 self.0 == other.0
1016 }
1017 }
1018 impl Eq for Vertex<'_> {}
1019 impl Hash for Vertex<'_> {
1020 fn hash<H: Hasher>(&self, state: &mut H) {
1021 self.0.hash(state);
1022 }
1023 }
1024
1025 let comparisons = Cell::new(0);
1026 let vertices: Vec<_> = (3..10_003).map(|v| Vertex(v, &comparisons)).collect();
1027 let corners = [
1028 Vertex(0, &comparisons),
1029 Vertex(1, &comparisons),
1030 Vertex(2, &comparisons),
1031 ];
1032 let mut visits = 0;
1033 let nearest =
1034 nearest_split_sides(&corners, [&vertices, &vertices[..20], &[]], |side, v| {
1035 visits += 1;
1036 if side == 1 && v.0 < 23 { 0.0 } else { 1.0 }
1038 });
1039 assert!(
1042 comparisons.get() < 8 * vertices.len(),
1043 "{} comparisons",
1044 comparisons.get()
1045 );
1046 assert_eq!(visits, vertices.len() + 20);
1047 assert_eq!(nearest.len(), vertices.len());
1048 for v in vertices {
1049 assert_eq!(nearest[&v], usize::from(v.0 < 23));
1050 }
1051 }
1052
1053 #[test]
1056 fn stitching_splits_each_side_and_retains_triangle_sources() {
1057 for [a, b, c] in [[0, 1, 2], [1, 2, 0], [2, 0, 1]] {
1058 let mut mesh = over(
1059 &[
1060 Point::new(0.0, 0.0, 0.0),
1061 Point::new(2.0, 0.0, 0.0),
1062 Point::new(0.0, 2.0, 0.0),
1063 ],
1064 &[[0, 1, 2]],
1065 );
1066 mesh.positions.push(Point::midpoint(
1067 mesh.positions[a as usize],
1068 mesh.positions[b as usize],
1069 ));
1070 mesh.positions.push(Point::new(0.0, 0.0, 2.0));
1072 mesh.triangles.extend([[b, 3, 4], [3, a, 4]]);
1073 let (split, sources) = mesh.border_stitched_with_sources(1e-9);
1074 assert_eq!(split.triangles.len(), 4);
1075 assert_eq!(sources.iter().filter(|&&i| i == 0).count(), 2);
1076 for (t, source) in split.triangles.iter().zip(sources) {
1077 let [pa, pb, pc] = t.map(|i| split.positions[i as usize]);
1078 assert!((pb - pa).cross(pc - pa).magnitude() > 0.0);
1079 if source == 0 {
1080 assert!(t.contains(&3) && t.contains(&c));
1081 assert!((pb - pa).cross(pc - pa).z > 0.0);
1082 }
1083 }
1084 }
1085 }
1086
1087 #[test]
1091 fn sealing_far_from_the_origin_reads_a_crack_by_its_own_width() {
1092 for offset in [0.0, 1.0e6, 5.0e6, 1.0e7, 2.0e7] {
1093 let o = Vector::new(offset, -offset, offset);
1094 let corners = [
1095 Point::new(0.0, 0.0, 0.0) + o,
1096 Point::new(1.0, 0.0, 0.0) + o,
1097 Point::new(0.5, 0.01, 0.0) + o,
1098 Point::new(0.5, 0.5, 1.0) + o,
1099 ];
1100 let cracked = over(&corners, &[[0, 1, 3], [1, 2, 3], [2, 0, 3]]);
1101 assert!(
1102 cracked.sealed(0.05).is_closed(),
1103 "a crack under the width is sealed at {offset:e}"
1104 );
1105 assert!(
1106 !cracked.sealed(1e-3).is_closed(),
1107 "a crack wider than asked stays open at {offset:e}"
1108 );
1109 }
1110 }
1111
1112 #[test]
1116 fn a_coarse_border_is_split_at_its_neighbours_vertices() {
1117 const N: u32 = 60;
1118 let mut mesh = Triangulation::new();
1119 for y in [0.0, 1.0] {
1121 for x in 0..=N {
1122 mesh.positions.push(Point::new(f64::from(x), y, 0.0));
1123 }
1124 }
1125 let top = |x: u32| N + 1 + x;
1126 for x in 0..N {
1127 mesh.triangles.push([x, x + 1, top(x + 1)]);
1128 mesh.triangles.push([x, top(x + 1), top(x)]);
1129 }
1130 let apex = top(N) + 1;
1132 for x in (0..=N).step_by(2) {
1133 mesh.positions.push(Point::new(f64::from(x), 2.0, 0.0));
1134 }
1135 for (i, x) in (0..N).step_by(2).enumerate() {
1136 let i = u32::try_from(i).unwrap();
1137 let (a, b) = (apex + i, apex + i + 1);
1138 mesh.triangles.push([top(x), top(x + 2), b]);
1139 mesh.triangles.push([top(x), b, a]);
1140 }
1141
1142 let stitched = mesh.border_stitched(1e-6);
1143 let uses = edge_uses(&stitched.triangles);
1144 for x in 0..N {
1145 assert_eq!(
1146 uses.get(&(top(x), top(x + 1))),
1147 Some(&2),
1148 "the shared row's segment at {x} is used from both sides"
1149 );
1150 }
1151 assert_eq!(stitched.positions, mesh.positions, "no vertex moves");
1152 }
1153}