1use std::cmp::Reverse;
42use std::collections::BinaryHeap;
43
44use ogeom_core::{FastMap, FastSet};
45use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
46use ogeom_math::Point;
47use ogeom_topo::Triangulation;
48
49mod checked;
50mod verified;
51
52#[derive(Debug, Clone, Copy, PartialEq)]
54pub enum Target {
55 Triangles(usize),
57 Error(f64),
63 Verified {
81 tolerance: f64,
83 share: f64,
86 max_triangles: Option<usize>,
88 },
89}
90
91#[derive(Debug, Clone, Copy, PartialEq)]
97pub struct Deviation {
98 pub within_share: f64,
101 pub distance_at_share: f64,
104 pub furthest: (Point, f64),
107 pub rms: f64,
109 pub pinned: usize,
112 pub rounds: usize,
114}
115
116#[derive(Debug, Clone)]
118pub struct Simplified {
119 pub mesh: Triangulation,
121 pub error: f64,
126 pub collapsed: usize,
128 pub target_met: bool,
135 pub deviation: Option<Deviation>,
139 pub result_vertex: Vec<Option<u32>>,
144}
145
146pub fn simplify(mesh: &Triangulation, target: Target, tol: Tolerances) -> OgeomResult<Simplified> {
158 match target {
159 Target::Verified { tolerance, .. } if !tolerance.is_finite() || tolerance <= 0.0 => {
160 ogeom_bail!(Construction, "a tolerance of {tolerance} is not a distance");
161 }
162 Target::Verified { share, .. } if !(share > 0.0 && share <= 1.0) => {
163 ogeom_bail!(
164 Construction,
165 "a share of {share} is not a fraction of the points"
166 );
167 }
168 Target::Verified {
169 max_triangles: Some(0),
170 ..
171 } => {
172 ogeom_bail!(Construction, "a mesh of no triangles describes nothing");
173 }
174 Target::Triangles(0) => {
175 ogeom_bail!(Construction, "a mesh of no triangles describes nothing");
176 }
177 Target::Error(e) if !e.is_finite() || e <= 0.0 => {
178 ogeom_bail!(Construction, "an error budget of {e} is not a distance");
179 }
180 _ => {}
181 }
182 if u32::try_from(mesh.triangles.len()).is_err() {
183 ogeom_bail!(Range, "a mesh of more than four billion triangles");
184 }
185 for triangle in &mesh.triangles {
186 for index in triangle {
187 if *index as usize >= mesh.positions.len() {
188 ogeom_bail!(
189 Construction,
190 "a triangle names vertex {index}, and the mesh has {}",
191 mesh.positions.len()
192 );
193 }
194 }
195 }
196
197 let (budget, floor) = match target {
198 Target::Error(e) => (e * e, 1),
199 Target::Triangles(n) => (f64::MAX, n),
200 Target::Verified {
201 tolerance,
202 share,
203 max_triangles,
204 } => return verified::simplify(mesh, tolerance, share, max_triangles, tol),
205 };
206 let mut state = State::new(mesh, tol);
207
208 let (worst, collapsed) = if mesh.positions.iter().all(|p| within_reach(*p)) {
210 state.decimate_by_queue(budget, floor)
211 } else {
212 state.decimate_by_scan(budget, floor)
213 };
214
215 let target_met = match target {
216 Target::Triangles(n) => state.live_triangles() <= n,
217 Target::Error(_) | Target::Verified { .. } => true,
220 };
221 let (mesh, renumbered) = state.harvest();
222 Ok(Simplified {
223 mesh,
224 error: worst.max(0.0).sqrt(),
225 collapsed,
226 target_met,
227 deviation: None,
228 result_vertex: state.result_vertex(&renumbered),
229 })
230}
231
232fn within_reach(p: Point) -> bool {
237 const REACH: f64 = 1_267_650_600_228_229_401_496_703_205_376.0; [p.x, p.y, p.z].iter().all(|c| c.abs() <= REACH)
239}
240
241#[derive(Debug, Clone, Copy)]
245struct Candidate {
246 cost: f64,
247 place: u64,
250 from: u32,
252 to: u32,
253}
254
255impl Candidate {
256 fn same(&self, other: &Self) -> bool {
258 self.cost.to_bits() == other.cost.to_bits()
259 && self.place == other.place
260 && self.from == other.from
261 && self.to == other.to
262 }
263}
264
265impl PartialEq for Candidate {
266 fn eq(&self, other: &Self) -> bool {
267 self.cmp(other) == core::cmp::Ordering::Equal
268 }
269}
270
271impl Eq for Candidate {}
272
273impl PartialOrd for Candidate {
274 fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
275 Some(self.cmp(other))
276 }
277}
278
279impl Ord for Candidate {
280 fn cmp(&self, other: &Self) -> core::cmp::Ordering {
283 self.cost
284 .partial_cmp(&other.cost)
285 .unwrap_or(core::cmp::Ordering::Equal)
286 .then(self.place.cmp(&other.place))
287 }
288}
289
290#[derive(Debug, Clone, Copy, Default)]
296struct Quadric([f64; 10]);
297
298impl Quadric {
299 fn of_plane(a: f64, b: f64, c: f64, d: f64) -> Self {
301 Self([
302 a * a,
303 a * b,
304 a * c,
305 a * d,
306 b * b,
307 b * c,
308 b * d,
309 c * c,
310 c * d,
311 d * d,
312 ])
313 }
314
315 fn add(&mut self, other: &Self) {
316 for (a, b) in self.0.iter_mut().zip(other.0) {
317 *a += b;
318 }
319 }
320
321 fn weight(&self) -> f64 {
324 self.0[0] + self.0[4] + self.0[7]
325 }
326
327 fn at(&self, p: Point) -> f64 {
329 let [q00, q01, q02, q03, q11, q12, q13, q22, q23, q33] = self.0;
330 let (x, y, z) = (p.x, p.y, p.z);
331 q00 * x * x
332 + 2.0 * q01 * x * y
333 + 2.0 * q02 * x * z
334 + 2.0 * q03 * x
335 + q11 * y * y
336 + 2.0 * q12 * y * z
337 + 2.0 * q13 * y
338 + q22 * z * z
339 + 2.0 * q23 * z
340 + q33
341 }
342}
343
344struct State {
346 positions: Vec<Point>,
347 triangles: Vec<[u32; 3]>,
348 live: Vec<bool>,
350 quadrics: Vec<Quadric>,
351 pinned: Vec<bool>,
353 held: Vec<bool>,
356 merged_into: Vec<u32>,
358 around: Vec<Vec<u32>>,
361 tol: Tolerances,
362 remaining: usize,
363}
364
365impl State {
366 fn new(mesh: &Triangulation, tol: Tolerances) -> Self {
367 let mut quadrics = vec![Quadric::default(); mesh.positions.len()];
368 let mut around: Vec<Vec<u32>> = vec![Vec::new(); mesh.positions.len()];
369 let mut uses: FastMap<(u32, u32), usize> = FastMap::default();
370
371 for (i, triangle) in mesh.triangles.iter().enumerate() {
372 let [a, b, c] = triangle.map(|v| mesh.positions[v as usize]);
373 let normal = (b - a).cross(c - a);
374 let length = normal.magnitude();
375 if length > tol.confusion() {
376 let unit = normal * (1.0 / length);
377 let plane = Quadric::of_plane(unit.x, unit.y, unit.z, -unit.dot(a.to_vector()));
380 let mut weighted = plane;
381 for value in &mut weighted.0 {
382 *value *= length;
383 }
384 for v in triangle {
385 quadrics[*v as usize].add(&weighted);
386 }
387 }
388 for v in triangle {
389 #[allow(clippy::cast_possible_truncation)]
390 around[*v as usize].push(i as u32);
391 }
392 for k in 0..3 {
393 let (x, y) = (triangle[k], triangle[(k + 1) % 3]);
394 *uses.entry((x.min(y), x.max(y))).or_default() += 1;
395 }
396 }
397
398 let mut pinned = vec![false; mesh.positions.len()];
400 for ((a, b), count) in uses {
401 if count != 2 {
402 pinned[a as usize] = true;
403 pinned[b as usize] = true;
404 }
405 }
406
407 Self {
408 positions: mesh.positions.clone(),
409 triangles: mesh.triangles.clone(),
410 live: vec![true; mesh.triangles.len()],
411 quadrics,
412 pinned,
413 held: vec![false; mesh.positions.len()],
414 #[allow(clippy::cast_possible_truncation)]
415 merged_into: (0..mesh.positions.len() as u32).collect(),
416 around,
417 tol,
418 remaining: mesh.triangles.len(),
419 }
420 }
421
422 const fn live_triangles(&self) -> usize {
423 self.remaining
424 }
425
426 fn cheapest(&self, budget: f64) -> Option<(f64, u32, u32, Point)> {
432 let mut best: Option<(f64, u32, u32, Point)> = None;
433 let mut seen: FastSet<(u32, u32)> = FastSet::default();
434 for (i, triangle) in self.triangles.iter().enumerate() {
435 if !self.live[i] {
436 continue;
437 }
438 for k in 0..3 {
439 let (a, b) = (triangle[k], triangle[(k + 1) % 3]);
440 let key = (a.min(b), a.max(b));
441 if !seen.insert(key) {
442 continue;
443 }
444 if self.pinned[a as usize] || self.pinned[b as usize] {
447 continue;
448 }
449 let at = Point::from_vector(
450 (self.positions[a as usize].to_vector()
451 + self.positions[b as usize].to_vector())
452 * 0.5,
453 );
454 let mut merged = self.quadrics[a as usize];
455 merged.add(&self.quadrics[b as usize]);
456 let cost = merged.at(at).max(0.0);
457 if cost > budget {
458 continue;
459 }
460 if best.is_some_and(|(current, ..)| cost >= current) {
461 continue;
462 }
463 if self.would_fold(a, b, at) || !self.keeps_manifold(a, b) {
464 continue;
465 }
466 best = Some((cost, a, b, at));
467 }
468 }
469 best
470 }
471
472 fn decimate_by_scan(&mut self, budget: f64, floor: usize) -> (f64, usize) {
476 let mut worst = 0.0_f64;
477 let mut collapsed = 0;
478 while self.live_triangles() > floor {
479 let Some((cost, from, to, at)) = self.cheapest(budget) else {
480 break;
481 };
482 self.collapse(from, to, at);
483 worst = worst.max(cost);
484 collapsed += 1;
485 }
486 (worst, collapsed)
487 }
488
489 fn decimate_by_queue(&mut self, budget: f64, floor: usize) -> (f64, usize) {
506 let mut worst = 0.0_f64;
507 let mut collapsed = 0;
508 let mut queue = self.queue(budget);
509 let mut fresh: FastSet<(u32, u32)> = FastSet::default();
510 while self.live_triangles() > floor {
511 let Some(Reverse(taken)) = queue.pop() else {
512 break;
513 };
514 let Some((now, at)) = self.candidate(taken.from, taken.to, budget) else {
515 continue;
516 };
517 if !now.same(&taken) {
518 continue;
519 }
520 let mut near = self.ring(now.from);
521 near.extend(self.ring(now.to));
522 self.collapse(now.from, now.to, at);
523 near.extend(self.ring(now.to));
524 near.push(now.to);
525 near.sort_unstable();
526 near.dedup();
527 fresh.clear();
528 for &v in &near {
529 for w in self.ring(v) {
530 if fresh.insert((v.min(w), v.max(w)))
531 && let Some((again, _)) = self.candidate(v, w, budget)
532 {
533 queue.push(Reverse(again));
534 }
535 }
536 }
537 worst = worst.max(now.cost);
538 collapsed += 1;
539 if queue.len() > 3 * self.live_triangles() {
542 queue = self.queue(budget);
543 }
544 }
545 (worst, collapsed)
546 }
547
548 fn queue(&self, budget: f64) -> BinaryHeap<Reverse<Candidate>> {
550 let mut queue = BinaryHeap::new();
551 let mut seen: FastSet<(u32, u32)> = FastSet::default();
552 for (i, triangle) in self.triangles.iter().enumerate() {
553 if !self.live[i] {
554 continue;
555 }
556 for k in 0..3 {
557 let (a, b) = (triangle[k], triangle[(k + 1) % 3]);
558 if seen.insert((a.min(b), a.max(b)))
559 && let Some((c, _)) = self.candidate(a, b, budget)
560 {
561 queue.push(Reverse(c));
562 }
563 }
564 }
565 queue
566 }
567
568 fn candidate(&self, a: u32, b: u32, budget: f64) -> Option<(Candidate, Point)> {
573 if self.pinned[a as usize] || self.pinned[b as usize] {
574 return None;
575 }
576 let mut first: Option<(u64, u32, u32)> = None;
577 for &index in &self.around[a as usize] {
578 if !self.live[index as usize] {
579 continue;
580 }
581 let t = self.triangles[index as usize];
582 for k in 0..3 {
583 let (x, y) = (t[k], t[(k + 1) % 3]);
584 if (x == a && y == b) || (x == b && y == a) {
585 let place = 3 * u64::from(index) + k as u64;
586 if first.is_none_or(|f| place < f.0) {
587 first = Some((place, x, y));
588 }
589 }
590 }
591 }
592 let (place, from, to) = first?;
593 let at = Point::from_vector(
594 (self.positions[from as usize].to_vector() + self.positions[to as usize].to_vector())
595 * 0.5,
596 );
597 let mut merged = self.quadrics[from as usize];
598 merged.add(&self.quadrics[to as usize]);
599 let cost = merged.at(at).max(0.0);
600 if cost > budget || self.would_fold(from, to, at) || !self.keeps_manifold(from, to) {
601 return None;
602 }
603 Some((
604 Candidate {
605 cost,
606 place,
607 from,
608 to,
609 },
610 at,
611 ))
612 }
613
614 fn would_fold(&self, from: u32, to: u32, at: Point) -> bool {
620 for vertex in [from, to] {
621 for &index in &self.around[vertex as usize] {
622 if !self.live[index as usize] {
623 continue;
624 }
625 let triangle = self.triangles[index as usize];
626 if triangle.contains(&from) && triangle.contains(&to) {
628 continue;
629 }
630 let before = self.normal_of(triangle, None);
631 let after = self.normal_of(triangle, Some((from, to, at)));
632 let (Some(before), Some(after)) = (before, after) else {
633 return true;
634 };
635 if before.dot(after) <= 0.0 {
636 return true;
637 }
638 }
639 }
640 false
641 }
642
643 fn keeps_manifold(&self, a: u32, b: u32) -> bool {
651 let mut across = Vec::new();
652 for &index in &self.around[a as usize] {
653 let t = self.triangles[index as usize];
654 if self.live[index as usize] && t.contains(&b) {
655 across.extend(t.iter().filter(|&&w| w != a && w != b));
656 }
657 }
658 across.sort_unstable();
659 across.dedup();
660 let (ring_a, ring_b) = (self.ring(a), self.ring(b));
661 let common = ring_a.iter().filter(|w| ring_b.binary_search(w).is_ok());
662 if !common.copied().eq(across.iter().copied()) {
663 return false;
664 }
665 let face = |v: u32, x: u32, y: u32| {
666 self.around[v as usize].iter().any(|&index| {
667 let t = self.triangles[index as usize];
668 self.live[index as usize] && t.contains(&x) && t.contains(&y)
669 })
670 };
671 !(across.len() == 2 && face(a, across[0], across[1]) && face(b, across[0], across[1]))
672 }
673
674 fn normal_of(
676 &self,
677 triangle: [u32; 3],
678 collapse: Option<(u32, u32, Point)>,
679 ) -> Option<ogeom_math::Vector> {
680 let at = |v: u32| match collapse {
681 Some((from, to, p)) if v == from || v == to => p,
682 _ => self.positions[v as usize],
683 };
684 let (a, b, c) = (at(triangle[0]), at(triangle[1]), at(triangle[2]));
685 let normal = (b - a).cross(c - a);
686 if normal.magnitude() <= self.tol.confusion() {
687 return None;
688 }
689 Some(normal)
690 }
691
692 fn collapse(&mut self, from: u32, to: u32, at: Point) {
694 self.positions[to as usize] = at;
695 self.merged_into[from as usize] = to;
696 let mut merged = self.quadrics[from as usize];
697 merged.add(&self.quadrics[to as usize]);
698 self.quadrics[to as usize] = merged;
699
700 let touching = std::mem::take(&mut self.around[from as usize]);
701 let mut emptied = Vec::new();
702 for index in touching {
703 if !self.live[index as usize] {
704 continue;
705 }
706 let triangle = &mut self.triangles[index as usize];
707 for v in triangle.iter_mut() {
708 if *v == from {
709 *v = to;
710 }
711 }
712 let [a, b, c] = *triangle;
714 if a == b || b == c || c == a {
715 self.live[index as usize] = false;
716 self.remaining -= 1;
717 emptied.extend([a, b, c]);
718 } else {
719 self.around[to as usize].push(index);
720 }
721 }
722 emptied.sort_unstable();
726 emptied.dedup();
727 for v in emptied {
728 let live = &self.live;
729 self.around[v as usize].retain(|&t| live[t as usize]);
730 }
731 }
732
733 fn harvest(&self) -> (Triangulation, FastMap<u32, u32>) {
736 let mut out = Triangulation::new();
737 let mut moved: FastMap<u32, u32> = FastMap::default();
738 for (index, triangle) in self.triangles.iter().enumerate() {
739 if !self.live[index] {
740 continue;
741 }
742 let mut mapped = [0_u32; 3];
743 for (slot, v) in mapped.iter_mut().zip(triangle) {
744 *slot = *moved.entry(*v).or_insert_with(|| {
745 #[allow(clippy::cast_possible_truncation)]
746 let fresh = out.positions.len() as u32;
747 out.positions.push(self.positions[*v as usize]);
748 fresh
749 });
750 }
751 out.triangles.push(mapped);
752 }
753 out.normals = vec![ogeom_math::Vector::ZERO; out.positions.len()];
757 for triangle in &out.triangles {
758 let [a, b, c] = triangle.map(|v| out.positions[v as usize]);
759 let normal = (b - a).cross(c - a);
760 for v in triangle {
761 out.normals[*v as usize] += normal;
762 }
763 }
764 for normal in &mut out.normals {
765 let length = normal.magnitude();
766 if length > self.tol.confusion() {
767 *normal *= 1.0 / length;
768 }
769 }
770 out.parameters = vec![(0.0, 0.0); out.positions.len()];
771 out.deflection_met = false;
772 (out, moved)
773 }
774
775 fn survivor(&self, mut v: u32) -> u32 {
777 while self.merged_into[v as usize] != v {
778 v = self.merged_into[v as usize];
779 }
780 v
781 }
782
783 fn result_vertex(&self, renumbered: &FastMap<u32, u32>) -> Vec<Option<u32>> {
786 let mut root = self.merged_into.clone();
787 for v in 0..root.len() {
788 let mut r = root[v];
789 while root[r as usize] != r {
790 r = root[r as usize];
791 }
792 let mut w = v;
793 while root[w] != r {
794 let up = root[w] as usize;
795 root[w] = r;
796 w = up;
797 }
798 }
799 root.iter().map(|r| renumbered.get(r).copied()).collect()
800 }
801
802 fn free(&self, a: u32, b: u32) -> bool {
804 !(self.pinned[a as usize]
805 || self.pinned[b as usize]
806 || self.held[a as usize]
807 || self.held[b as usize])
808 }
809
810 fn mean_cost(&self, a: u32, b: u32) -> (f64, Point) {
819 let at = Point::from_vector(
820 (self.positions[a as usize].to_vector() + self.positions[b as usize].to_vector()) * 0.5,
821 );
822 let mut merged = self.quadrics[a as usize];
823 merged.add(&self.quadrics[b as usize]);
824 let cost = merged.at(at).max(0.0);
825 let weight = merged.weight();
826 (if weight > 0.0 { cost / weight } else { cost }, at)
827 }
828
829 fn ring(&self, v: u32) -> Vec<u32> {
831 let mut out = Vec::new();
832 for &index in &self.around[v as usize] {
833 if self.live[index as usize] {
834 out.extend(self.triangles[index as usize].iter().filter(|&&w| w != v));
835 }
836 }
837 out.sort_unstable();
838 out.dedup();
839 out
840 }
841}
842
843#[cfg(test)]
844#[allow(clippy::unwrap_used)]
845mod tests {
846 use super::*;
847 use crate::{Deflection, triangulate};
848 use ogeom_algo::{make_box, make_sphere};
849 use ogeom_math::Frame;
850 use ogeom_topo::Model;
851
852 const T: Tolerances = Tolerances::millimetres();
853
854 fn sphere(chord: f64) -> Triangulation {
855 let mut model = Model::new();
856 let built = make_sphere(&mut model, Frame::WORLD, 10.0, T).unwrap();
857 triangulate(
858 &model,
859 &built.shape,
860 Deflection {
861 chord,
862 ..Deflection::default()
863 },
864 T,
865 )
866 .unwrap()
867 }
868
869 #[test]
870 fn decimating_a_sphere_keeps_it_a_sphere_to_the_error_it_reports() {
871 let mesh = sphere(0.02);
874 let before = mesh.triangle_count();
875 let done = simplify(&mesh, Target::Triangles(before / 4), T).unwrap();
876
877 assert!(done.collapsed > 0);
878 assert!(
879 done.mesh.triangle_count() < before,
880 "nothing was removed: {} of {before}",
881 done.mesh.triangle_count()
882 );
883 for p in &done.mesh.positions {
884 let off = (p.to_vector().magnitude() - 10.0).abs();
885 assert!(
886 off <= done.error + 1e-9,
887 "a vertex is {off} off the sphere, but the reported error is {}",
888 done.error
889 );
890 }
891 }
892
893 #[test]
894 fn a_tighter_error_budget_removes_less() {
895 let mesh = sphere(0.02);
896 let loose = simplify(&mesh, Target::Error(0.5), T).unwrap();
897 let tight = simplify(&mesh, Target::Error(0.01), T).unwrap();
898
899 assert!(
900 tight.mesh.triangle_count() >= loose.mesh.triangle_count(),
901 "a tighter budget should keep more: {} against {}",
902 tight.mesh.triangle_count(),
903 loose.mesh.triangle_count()
904 );
905 assert!(
906 tight.error <= 0.01 + 1e-12,
907 "over budget at {}",
908 tight.error
909 );
910 assert!(loose.error <= 0.5 + 1e-12);
911 assert!(tight.target_met && loose.target_met);
912 }
913
914 #[test]
915 fn the_mesh_stays_closed() {
916 let mesh = sphere(0.05);
919 assert!(mesh.is_closed());
920 let done = simplify(&mesh, Target::Triangles(mesh.triangle_count() / 2), T).unwrap();
921 assert!(
922 done.mesh.is_closed(),
923 "decimation opened the mesh after {} collapses",
924 done.collapsed
925 );
926 assert!(done.mesh.volume() > 0.0, "and it turned inside out");
927 }
928
929 #[test]
930 fn a_collapse_that_would_join_two_edges_into_one_is_not_made() {
931 let mut mesh = Triangulation::new();
935 mesh.positions = vec![
936 Point::new(0.0, 0.0, 0.0),
937 Point::new(0.01, 0.0, 0.0),
938 Point::new(0.0, 1.0, 0.0),
939 Point::new(0.0, 0.3, 1.0),
940 Point::new(0.0, 0.3, -1.0),
941 ];
942 mesh.triangles = vec![
943 [3, 0, 1],
944 [3, 1, 2],
945 [3, 2, 0],
946 [4, 1, 0],
947 [4, 2, 1],
948 [4, 0, 2],
949 ];
950 for target in [Target::Triangles(4), Target::Error(1.0)] {
951 let done = simplify(&mesh, target, T).unwrap();
952 let mut uses: FastMap<(u32, u32), usize> = FastMap::default();
953 let mut sets = FastSet::default();
954 for t in &done.mesh.triangles {
955 let mut set = *t;
956 set.sort_unstable();
957 assert!(sets.insert(set), "two faces on {set:?} after {target:?}");
958 for k in 0..3 {
959 let (a, b) = (t[k], t[(k + 1) % 3]);
960 *uses.entry((a.min(b), a.max(b))).or_default() += 1;
961 }
962 }
963 assert!(
964 uses.values().all(|&n| n == 2),
965 "an edge without two faces after {target:?}: {uses:?}"
966 );
967 assert!(done.mesh.volume() > 0.0);
968 }
969 }
970
971 #[test]
972 fn a_boundary_is_held_exactly() {
973 let mut model = Model::new();
976 let solid = make_box(&mut model, Frame::WORLD, (4.0, 4.0, 4.0), T).unwrap();
977 let face = ogeom_topo::explore_unique(&model, &solid.shape, ogeom_topo::ShapeType::Face)
978 .unwrap()[0]
979 .clone();
980 let sheet = crate::triangulate_face(
981 &model,
982 &face,
983 Deflection {
984 chord: 0.05,
985 ..Deflection::default()
986 },
987 T,
988 )
989 .unwrap();
990
991 let outline = |m: &Triangulation| {
992 let mut low = f64::MAX;
993 let mut high = f64::MIN;
994 for p in &m.positions {
995 low = low.min(p.x);
996 high = high.max(p.x);
997 }
998 (low, high)
999 };
1000 let before = outline(&sheet);
1001 let done = simplify(&sheet, Target::Triangles(2), T).unwrap();
1002 let after = outline(&done.mesh);
1003 assert!(
1004 (before.0 - after.0).abs() < 1e-12 && (before.1 - after.1).abs() < 1e-12,
1005 "the outline moved from {before:?} to {after:?}"
1006 );
1007 }
1008
1009 #[test]
1010 fn a_target_that_cannot_be_reached_is_reported_rather_than_claimed() {
1011 let mut mesh = Triangulation::new();
1015 mesh.positions = vec![
1016 Point::ORIGIN,
1017 Point::new(1.0, 0.0, 0.0),
1018 Point::new(0.0, 1.0, 0.0),
1019 ];
1020 mesh.triangles = vec![[0, 1, 2]];
1021 let done = simplify(&mesh, Target::Triangles(1), T).unwrap();
1022 assert_eq!(done.collapsed, 0);
1023 assert_eq!(done.mesh.triangle_count(), 1);
1024 }
1025
1026 #[test]
1027 fn a_target_that_describes_nothing_is_refused() {
1028 let mesh = sphere(0.2);
1029 assert!(simplify(&mesh, Target::Triangles(0), T).is_err());
1030 assert!(simplify(&mesh, Target::Error(0.0), T).is_err());
1031 assert!(simplify(&mesh, Target::Error(-1.0), T).is_err());
1032 assert!(simplify(&mesh, Target::Error(f64::NAN), T).is_err());
1033
1034 let mut broken = Triangulation::new();
1035 broken.positions = vec![Point::ORIGIN];
1036 broken.triangles = vec![[0, 1, 2]];
1037 assert!(simplify(&broken, Target::Triangles(1), T).is_err());
1038 }
1039
1040 fn torus_grid(n: usize, m: usize, wave: f64, open: bool) -> Triangulation {
1044 use core::f64::consts::TAU;
1045 let mut grid = Triangulation::new();
1046 for i in 0..n {
1047 for j in 0..m {
1048 #[allow(clippy::cast_precision_loss)]
1049 let (u, v) = (TAU * i as f64 / n as f64, TAU * j as f64 / m as f64);
1050 let r = wave.mul_add((3.0 * u).sin() * (2.0 * v).cos(), 5.0);
1051 let rho = r.mul_add(v.cos(), 20.0);
1052 grid.positions
1053 .push(Point::new(rho * u.cos(), rho * u.sin(), r * v.sin()));
1054 }
1055 }
1056 #[allow(clippy::cast_possible_truncation)]
1057 let at = |i: usize, j: usize| ((i % n) * m + (j % m)) as u32;
1058 for i in 0..(if open { n - 1 } else { n }) {
1059 for j in 0..m {
1060 let (a, b, c, d) = (at(i, j), at(i + 1, j), at(i + 1, j + 1), at(i, j + 1));
1061 grid.triangles.push([a, b, c]);
1062 grid.triangles.push([a, c, d]);
1063 }
1064 }
1065 grid
1066 }
1067
1068 #[test]
1069 fn the_queue_makes_the_collapses_the_scan_makes() {
1070 let meshes = [
1075 torus_grid(40, 20, 0.0, false),
1076 torus_grid(40, 20, 0.5, false),
1077 torus_grid(40, 20, 0.5, true),
1078 sphere(0.05),
1079 ];
1080 for mesh in &meshes {
1081 let halved = State::new(mesh, T).decimate_by_scan(f64::MAX, mesh.triangles.len() / 2);
1084 for (budget, floor) in [
1085 (f64::MAX, mesh.triangles.len() / 2),
1086 (f64::MAX, mesh.triangles.len() / 10),
1087 (halved.0, 1),
1088 ] {
1089 let mut scan = State::new(mesh, T);
1090 let mut queue = State::new(mesh, T);
1091 let by_scan = scan.decimate_by_scan(budget, floor);
1092 let by_queue = queue.decimate_by_queue(budget, floor);
1093 assert!(by_scan.1 > 0);
1094 assert_eq!(by_scan.0.to_bits(), by_queue.0.to_bits());
1095 assert_eq!(by_scan.1, by_queue.1);
1096 assert_eq!(scan.triangles, queue.triangles);
1097 assert_eq!(scan.live, queue.live);
1098 assert_eq!(scan.merged_into, queue.merged_into);
1099 for (a, b) in scan.positions.iter().zip(&queue.positions) {
1100 assert_eq!(
1101 [a.x, a.y, a.z].map(f64::to_bits),
1102 [b.x, b.y, b.z].map(f64::to_bits)
1103 );
1104 }
1105 }
1106 }
1107 }
1108}