Skip to main content

threecrate_simplification/
quadric_error.rs

1//! Quadric error decimation
2//!
3//! Implementation of the Garland-Heckbert algorithm for mesh simplification
4//! based on quadric error metrics.
5
6use crate::MeshSimplifier;
7use nalgebra::{Matrix4, Vector4};
8use std::cmp::Ordering;
9use std::collections::{BinaryHeap, HashMap, HashSet};
10use threecrate_core::{Error, Point3f, Result, TriangleMesh, Vector3f};
11
12/// Quadric error decimation simplifier
13pub struct QuadricErrorSimplifier {
14    /// Configuration parameters
15    pub max_edge_length: Option<f32>,
16    pub preserve_boundary: bool,
17    /// Not used by the current algorithm; kept for API compatibility.
18    pub feature_angle_threshold: f32,
19}
20
21impl Default for QuadricErrorSimplifier {
22    fn default() -> Self {
23        Self {
24            max_edge_length: None,
25            preserve_boundary: true,
26            feature_angle_threshold: 45.0_f32.to_radians(),
27        }
28    }
29}
30
31/// A candidate edge collapse. Ordered by cost, cheapest first in the heap.
32/// `stamps` are the endpoints' versions when the cost was computed; if either
33/// endpoint has changed since, the entry is stale and skipped.
34struct Candidate {
35    cost: f64,
36    keep: usize,
37    remove: usize,
38    position: Point3f,
39    stamps: [u32; 2],
40}
41
42impl PartialEq for Candidate {
43    fn eq(&self, other: &Self) -> bool {
44        self.cmp(other) == Ordering::Equal
45    }
46}
47impl Eq for Candidate {}
48impl PartialOrd for Candidate {
49    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
50        Some(self.cmp(other))
51    }
52}
53impl Ord for Candidate {
54    fn cmp(&self, other: &Self) -> Ordering {
55        other.cost.total_cmp(&self.cost) // min-heap
56    }
57}
58
59/// Mesh state while collapsing. `vertex_faces` may list faces that have since
60/// died or no longer use the vertex; `faces_of` filters them.
61struct Working {
62    positions: Vec<Point3f>,
63    quadrics: Vec<Matrix4<f64>>,
64    stamps: Vec<u32>,
65    alive: Vec<bool>,
66    faces: Vec<[usize; 3]>,
67    face_alive: Vec<bool>,
68    vertex_faces: Vec<Vec<usize>>,
69    live_faces: usize,
70}
71
72impl Working {
73    fn faces_of(&self, v: usize) -> impl Iterator<Item = usize> + '_ {
74        self.vertex_faces[v]
75            .iter()
76            .copied()
77            .filter(move |&f| self.face_alive[f] && self.faces[f].contains(&v))
78    }
79
80    fn neighbors(&self, v: usize) -> HashSet<usize> {
81        self.faces_of(v)
82            .flat_map(|f| self.faces[f])
83            .filter(|&u| u != v)
84            .collect()
85    }
86
87    /// Normal of `face`, with any vertex in `moved` placed at `to`.
88    fn normal(&self, face: [usize; 3], moved: &[usize], to: Point3f) -> Vector3f {
89        let p = face.map(|v| {
90            if moved.contains(&v) {
91                to
92            } else {
93                self.positions[v]
94            }
95        });
96        (p[1] - p[0]).cross(&(p[2] - p[0]))
97    }
98
99    /// Whether collapsing `keep`-`remove` into `position` keeps the mesh a
100    /// valid surface: the endpoints share exactly the neighbours of their
101    /// shared faces (link condition), and no surrounding face flips over.
102    fn can_collapse(&self, keep: usize, remove: usize, position: Point3f) -> bool {
103        let shared = self
104            .faces_of(keep)
105            .filter(|&f| self.faces[f].contains(&remove))
106            .count();
107        let common = self
108            .neighbors(keep)
109            .intersection(&self.neighbors(remove))
110            .count();
111        if shared == 0 || common != shared {
112            return false;
113        }
114        let ends = [keep, remove];
115        self.faces_of(keep)
116            .chain(self.faces_of(remove))
117            .filter(|&f| !(self.faces[f].contains(&keep) && self.faces[f].contains(&remove)))
118            .all(|f| {
119                let before = self.normal(self.faces[f], &[], position);
120                let after = self.normal(self.faces[f], &ends, position);
121                // Reject flips, and faces squashed to (almost) no area.
122                let (b, a) = (before.norm(), after.norm());
123                a > 1e-6 * b && before.dot(&after) > 0.01 * b * a
124            })
125    }
126
127    fn collapse(&mut self, keep: usize, remove: usize, position: Point3f) {
128        self.positions[keep] = position;
129        self.quadrics[keep] = self.quadrics[keep] + self.quadrics[remove];
130        let moved_faces: Vec<usize> = self.faces_of(remove).collect();
131        for f in moved_faces {
132            if self.faces[f].contains(&keep) {
133                self.face_alive[f] = false;
134                self.live_faces -= 1;
135            } else {
136                for v in self.faces[f].iter_mut() {
137                    if *v == remove {
138                        *v = keep;
139                    }
140                }
141                self.vertex_faces[keep].push(f);
142            }
143        }
144        self.alive[remove] = false;
145        self.stamps[keep] += 1;
146        self.stamps[remove] += 1;
147    }
148}
149
150impl QuadricErrorSimplifier {
151    /// Create new quadric error simplifier with default settings
152    pub fn new() -> Self {
153        Self::default()
154    }
155
156    /// Create with custom parameters
157    pub fn with_params(
158        max_edge_length: Option<f32>,
159        preserve_boundary: bool,
160        feature_angle_threshold: f32,
161    ) -> Self {
162        Self {
163            max_edge_length,
164            preserve_boundary,
165            feature_angle_threshold,
166        }
167    }
168
169    /// Compute plane equation from triangle vertices
170    fn compute_plane(&self, v0: &Point3f, v1: &Point3f, v2: &Point3f) -> Vector4<f64> {
171        let edge1 = v1 - v0;
172        let edge2 = v2 - v0;
173        let normal = edge1.cross(&edge2).normalize();
174
175        // Handle degenerate triangles
176        if !normal.iter().all(|x| x.is_finite()) {
177            return Vector4::new(0.0, 0.0, 1.0, 0.0);
178        }
179
180        let d = -normal.dot(&v0.coords);
181        Vector4::new(normal.x as f64, normal.y as f64, normal.z as f64, d as f64)
182    }
183
184    /// Compute quadric matrix from plane equation
185    fn plane_to_quadric(&self, plane: &Vector4<f64>) -> Matrix4<f64> {
186        plane * plane.transpose()
187    }
188
189    /// Find boundary edges in the mesh
190    fn find_boundary_edges(&self, mesh: &TriangleMesh) -> HashSet<(usize, usize)> {
191        let mut edge_count: HashMap<(usize, usize), usize> = HashMap::new();
192        for face in &mesh.faces {
193            for (a, b) in [(face[0], face[1]), (face[1], face[2]), (face[2], face[0])] {
194                *edge_count.entry((a.min(b), a.max(b))).or_insert(0) += 1;
195            }
196        }
197        edge_count
198            .into_iter()
199            .filter(|(_, count)| *count == 1)
200            .map(|(edge, _)| edge)
201            .collect()
202    }
203
204    /// Get all boundary vertices from boundary edges
205    fn get_boundary_vertices(&self, boundary_edges: &HashSet<(usize, usize)>) -> HashSet<usize> {
206        boundary_edges.iter().flat_map(|&(a, b)| [a, b]).collect()
207    }
208
209    /// Cost and best position for collapsing edge `a`-`b`, or `None` when the
210    /// edge may not be collapsed.
211    fn candidate(
212        &self,
213        w: &Working,
214        a: usize,
215        b: usize,
216        fixed: &HashSet<usize>,
217    ) -> Option<Candidate> {
218        if fixed.contains(&a) || fixed.contains(&b) {
219            return None;
220        }
221        let (pa, pb) = (w.positions[a], w.positions[b]);
222        let length = (pa - pb).magnitude();
223        if self.max_edge_length.is_some_and(|max| length > max) {
224            return None;
225        }
226        let q = w.quadrics[a] + w.quadrics[b];
227        let error = |p: &Point3f| {
228            let h = Vector4::new(p.x as f64, p.y as f64, p.z as f64, 1.0);
229            (h.transpose() * q * h)[0]
230        };
231        // Best point: where the quadric is smallest, when that is well defined
232        // and near the edge; otherwise the cheapest of the ends and midpoint.
233        let solved = q
234            .fixed_view::<3, 3>(0, 0)
235            .into_owned()
236            .try_inverse()
237            .map(|inv| -inv * q.fixed_view::<3, 1>(0, 3))
238            .map(|v| Point3f::new(v[0] as f32, v[1] as f32, v[2] as f32))
239            .filter(|p| {
240                p.coords.iter().all(|c| c.is_finite())
241                    && (p - pa).magnitude() <= 2.0 * length.max(f32::EPSILON)
242            });
243        let position = solved.unwrap_or_else(|| {
244            [pa, pb, Point3f::from((pa.coords + pb.coords) * 0.5)]
245                .into_iter()
246                .min_by(|x, y| error(x).total_cmp(&error(y)))
247                .unwrap()
248        });
249        Some(Candidate {
250            cost: error(&position),
251            keep: a,
252            remove: b,
253            position,
254            stamps: [w.stamps[a], w.stamps[b]],
255        })
256    }
257}
258
259impl MeshSimplifier for QuadricErrorSimplifier {
260    /// Simplify mesh with target reduction ratio
261    fn simplify(&self, mesh: &TriangleMesh, reduction_ratio: f32) -> Result<TriangleMesh> {
262        if mesh.is_empty() {
263            return Err(Error::InvalidData("Mesh is empty".to_string()));
264        }
265
266        if !(0.0..=1.0).contains(&reduction_ratio) {
267            return Err(Error::InvalidData(
268                "Reduction ratio must be between 0.0 and 1.0".to_string(),
269            ));
270        }
271
272        if reduction_ratio == 0.0 {
273            return Ok(mesh.clone());
274        }
275        mesh.validate_faces()?;
276
277        let target_face_count = ((1.0 - reduction_ratio) * mesh.faces.len() as f32) as usize;
278        let n = mesh.vertices.len();
279
280        let mut w = Working {
281            positions: mesh.vertices.clone(),
282            quadrics: vec![Matrix4::zeros(); n],
283            stamps: vec![0; n],
284            alive: vec![true; n],
285            faces: mesh.faces.clone(),
286            face_alive: vec![true; mesh.faces.len()],
287            vertex_faces: vec![Vec::new(); n],
288            live_faces: mesh.faces.len(),
289        };
290        for (f, face) in mesh.faces.iter().enumerate() {
291            let [a, b, c] = face.map(|v| mesh.vertices[v]);
292            let quadric = self.plane_to_quadric(&self.compute_plane(&a, &b, &c));
293            for &v in face {
294                w.quadrics[v] += quadric;
295                w.vertex_faces[v].push(f);
296            }
297        }
298
299        // Boundary vertices stay where they are when preserving the boundary.
300        let boundary_edges = self.find_boundary_edges(mesh);
301        let fixed = if self.preserve_boundary {
302            self.get_boundary_vertices(&boundary_edges)
303        } else {
304            HashSet::new()
305        };
306
307        let mut heap = BinaryHeap::new();
308        for face in &mesh.faces {
309            for (a, b) in [(face[0], face[1]), (face[1], face[2]), (face[2], face[0])] {
310                if a < b {
311                    heap.extend(self.candidate(&w, a, b, &fixed));
312                }
313            }
314        }
315
316        let mut collapses = 0;
317        while w.live_faces > target_face_count {
318            let Some(c) = heap.pop() else { break };
319            let current = c.stamps == [w.stamps[c.keep], w.stamps[c.remove]];
320            if !current || !w.alive[c.keep] || !w.alive[c.remove] {
321                continue;
322            }
323            if !w.can_collapse(c.keep, c.remove, c.position) {
324                continue;
325            }
326            w.collapse(c.keep, c.remove, c.position);
327            collapses += 1;
328            // Only edges at the kept vertex changed cost.
329            for neighbor in w.neighbors(c.keep) {
330                heap.extend(self.candidate(&w, c.keep, neighbor, &fixed));
331            }
332        }
333
334        if collapses == 0 && w.live_faces > target_face_count {
335            return Err(self.no_collapse_error(mesh, &boundary_edges));
336        }
337
338        // Compact: keep live faces and the vertices they use.
339        let mut new_index = vec![usize::MAX; n];
340        let mut vertices = Vec::new();
341        let faces = w
342            .faces
343            .iter()
344            .zip(&w.face_alive)
345            .filter(|(_, &alive)| alive)
346            .map(|(face, _)| {
347                face.map(|v| {
348                    if new_index[v] == usize::MAX {
349                        new_index[v] = vertices.len();
350                        vertices.push(w.positions[v]);
351                    }
352                    new_index[v]
353                })
354            })
355            .collect();
356        Ok(TriangleMesh::from_vertices_and_faces(vertices, faces))
357    }
358}
359
360impl QuadricErrorSimplifier {
361    /// Explain why not a single edge could be collapsed, instead of quietly
362    /// returning the mesh unchanged.
363    fn no_collapse_error(
364        &self,
365        mesh: &TriangleMesh,
366        boundary_edges: &HashSet<(usize, usize)>,
367    ) -> Error {
368        let on_boundary = self.get_boundary_vertices(boundary_edges).len();
369        if self.preserve_boundary && on_boundary > 0 {
370            Error::Algorithm(format!(
371                "Could not simplify: every edge touches an open edge of the mesh \
372                 ({on_boundary} of {} vertices are on a hole or border), and \
373                 preserve_boundary keeps those vertices fixed. Fill the holes or \
374                 set preserve_boundary to false.",
375                mesh.vertices.len()
376            ))
377        } else {
378            Error::Algorithm(
379                "Could not simplify: no edge can be collapsed (check max_edge_length)".to_string(),
380            )
381        }
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use nalgebra::Point3;
389
390    /// An n x n vertex grid on a tilted plane, wound so normals point up.
391    fn tilted_grid(n: usize) -> TriangleMesh {
392        let vertices = (0..n * n)
393            .map(|i| {
394                let (x, y) = (
395                    (i % n) as f32 / (n - 1) as f32,
396                    (i / n) as f32 / (n - 1) as f32,
397                );
398                Point3f::new(x, y, 0.37 * x + 0.21 * y)
399            })
400            .collect();
401        let mut faces = Vec::new();
402        for j in 0..n - 1 {
403            for i in 0..n - 1 {
404                let v = j * n + i;
405                faces.push([v, v + 1, v + n]);
406                faces.push([v + 1, v + n + 1, v + n]);
407            }
408        }
409        TriangleMesh::from_vertices_and_faces(vertices, faces)
410    }
411
412    /// Edges used by more than two faces, and edges used twice in the same
413    /// direction (two faces with opposite winding).
414    fn bad_edges(mesh: &TriangleMesh) -> (usize, usize) {
415        let mut uses: HashMap<(usize, usize), Vec<bool>> = HashMap::new();
416        for f in &mesh.faces {
417            for (a, b) in [(f[0], f[1]), (f[1], f[2]), (f[2], f[0])] {
418                uses.entry((a.min(b), a.max(b))).or_default().push(a < b);
419            }
420        }
421        let non_manifold = uses.values().filter(|u| u.len() > 2).count();
422        let same_direction = uses
423            .values()
424            .filter(|u| u.len() == 2 && u[0] == u[1])
425            .count();
426        (non_manifold, same_direction)
427    }
428
429    #[test]
430    fn test_simplified_tilted_grid_has_no_flipped_faces() {
431        let mesh = tilted_grid(30);
432        let simplifier = QuadricErrorSimplifier::with_params(None, false, 0.5);
433        let out = simplifier.simplify(&mesh, 0.5).unwrap();
434        // One collapse removes two faces, so the count can land one below.
435        assert!((mesh.faces.len() / 2 - 1..=mesh.faces.len() / 2).contains(&out.faces.len()));
436        let flipped = out
437            .faces
438            .iter()
439            .filter(|f| {
440                let [a, b, c] = f.map(|v| out.vertices[v]);
441                (b - a).cross(&(c - a)).z <= 0.0
442            })
443            .count();
444        assert_eq!(flipped, 0, "faces flipped over");
445        assert_eq!(
446            bad_edges(&out),
447            (0, 0),
448            "non-manifold or inconsistent edges"
449        );
450        // The grid is planar, so every kept vertex stays on the plane.
451        assert!(out
452            .vertices
453            .iter()
454            .all(|p| (p.z - 0.37 * p.x - 0.21 * p.y).abs() < 1e-4));
455    }
456
457    #[test]
458    fn test_closed_mesh_stays_closed() {
459        // Octahedron subdivided twice: a closed sphere-like mesh.
460        let mut mesh = TriangleMesh::from_vertices_and_faces(
461            vec![
462                Point3f::new(1.0, 0.0, 0.0),
463                Point3f::new(-1.0, 0.0, 0.0),
464                Point3f::new(0.0, 1.0, 0.0),
465                Point3f::new(0.0, -1.0, 0.0),
466                Point3f::new(0.0, 0.0, 1.0),
467                Point3f::new(0.0, 0.0, -1.0),
468            ],
469            vec![
470                [0, 2, 4],
471                [2, 1, 4],
472                [1, 3, 4],
473                [3, 0, 4],
474                [2, 0, 5],
475                [1, 2, 5],
476                [3, 1, 5],
477                [0, 3, 5],
478            ],
479        );
480        for _ in 0..3 {
481            let mut midpoint: HashMap<(usize, usize), usize> = HashMap::new();
482            let mut faces = Vec::new();
483            for f in mesh.faces.clone() {
484                let mut mid = |a: usize, b: usize| {
485                    *midpoint.entry((a.min(b), a.max(b))).or_insert_with(|| {
486                        let p = Point3f::from(
487                            (mesh.vertices[a].coords + mesh.vertices[b].coords).normalize(),
488                        );
489                        mesh.vertices.push(p);
490                        mesh.vertices.len() - 1
491                    })
492                };
493                let (ab, bc, ca) = (mid(f[0], f[1]), mid(f[1], f[2]), mid(f[2], f[0]));
494                faces.extend([[f[0], ab, ca], [ab, f[1], bc], [ca, bc, f[2]], [ab, bc, ca]]);
495            }
496            mesh.faces = faces;
497        }
498        for ratio in [0.5, 0.9] {
499            let out = QuadricErrorSimplifier::new()
500                .simplify(&mesh, ratio)
501                .unwrap();
502            let mut count: HashMap<(usize, usize), u32> = HashMap::new();
503            for f in &out.faces {
504                for (a, b) in [(f[0], f[1]), (f[1], f[2]), (f[2], f[0])] {
505                    *count.entry((a.min(b), a.max(b))).or_default() += 1;
506                }
507            }
508            assert!(
509                count.values().all(|&c| c == 2),
510                "ratio {ratio}: mesh opened up"
511            );
512            assert_eq!(bad_edges(&out), (0, 0), "ratio {ratio}");
513        }
514    }
515
516    #[test]
517    fn test_quadric_error_simplifier_creation() {
518        let simplifier = QuadricErrorSimplifier::new();
519        assert!(simplifier.preserve_boundary);
520        assert!(simplifier.max_edge_length.is_none());
521    }
522
523    #[test]
524    fn test_plane_computation() {
525        let simplifier = QuadricErrorSimplifier::new();
526        let v0 = Point3::new(0.0, 0.0, 0.0);
527        let v1 = Point3::new(1.0, 0.0, 0.0);
528        let v2 = Point3::new(0.0, 1.0, 0.0);
529
530        let plane = simplifier.compute_plane(&v0, &v1, &v2);
531
532        // Should be z = 0 plane: 0x + 0y + 1z + 0 = 0
533        assert!((plane[0]).abs() < 1e-6);
534        assert!((plane[1]).abs() < 1e-6);
535        assert!((plane[2] - 1.0).abs() < 1e-6);
536        assert!((plane[3]).abs() < 1e-6);
537    }
538
539    #[test]
540    fn test_empty_mesh() {
541        let simplifier = QuadricErrorSimplifier::new();
542        let mesh = TriangleMesh::new();
543
544        let result = simplifier.simplify(&mesh, 0.5);
545        assert!(result.is_err());
546    }
547
548    #[test]
549    fn test_zero_reduction() {
550        let simplifier = QuadricErrorSimplifier::new();
551        let vertices = vec![
552            Point3::new(0.0, 0.0, 0.0),
553            Point3::new(1.0, 0.0, 0.0),
554            Point3::new(0.5, 1.0, 0.0),
555        ];
556        let faces = vec![[0, 1, 2]];
557        let mesh = TriangleMesh::from_vertices_and_faces(vertices, faces);
558
559        let result = simplifier.simplify(&mesh, 0.0).unwrap();
560        assert_eq!(result.vertex_count(), 3);
561        assert_eq!(result.face_count(), 1);
562    }
563
564    #[test]
565    fn test_invalid_reduction_ratio() {
566        let simplifier = QuadricErrorSimplifier::new();
567        let vertices = vec![
568            Point3::new(0.0, 0.0, 0.0),
569            Point3::new(1.0, 0.0, 0.0),
570            Point3::new(0.5, 1.0, 0.0),
571        ];
572        let faces = vec![[0, 1, 2]];
573        let mesh = TriangleMesh::from_vertices_and_faces(vertices, faces);
574
575        assert!(simplifier.simplify(&mesh, -0.1).is_err());
576        assert!(simplifier.simplify(&mesh, 1.1).is_err());
577    }
578
579    #[test]
580    fn test_quadric_matrix_computation() {
581        let simplifier = QuadricErrorSimplifier::new();
582        let plane = Vector4::new(1.0, 0.0, 0.0, -1.0); // x = 1 plane
583
584        let quadric = simplifier.plane_to_quadric(&plane);
585
586        // Check diagonal elements
587        assert!((quadric[(0, 0)] - 1.0).abs() < 1e-10);
588        assert!((quadric[(1, 1)] - 0.0).abs() < 1e-10);
589        assert!((quadric[(2, 2)] - 0.0).abs() < 1e-10);
590        assert!((quadric[(3, 3)] - 1.0).abs() < 1e-10);
591    }
592
593    #[test]
594    fn test_tetrahedron_simplification() {
595        let simplifier = QuadricErrorSimplifier::new();
596        // Create a tetrahedron
597        let vertices = vec![
598            Point3::new(0.0, 0.0, 0.0),
599            Point3::new(1.0, 0.0, 0.0),
600            Point3::new(0.5, 1.0, 0.0),
601            Point3::new(0.5, 0.5, 1.0),
602        ];
603        let faces = vec![[0, 1, 2], [0, 1, 3], [0, 2, 3], [1, 2, 3]];
604        let mesh = TriangleMesh::from_vertices_and_faces(vertices, faces);
605        let simplified = simplifier.simplify(&mesh, 0.5).unwrap();
606        // Should have fewer faces
607        assert!(simplified.face_count() <= mesh.face_count());
608        assert!(simplified.vertex_count() <= mesh.vertex_count());
609    }
610
611    #[test]
612    fn test_grid_boundary_preservation() {
613        // Reproduce issue #52: 11x11 grid mesh simplification
614        let simplifier = QuadricErrorSimplifier::new();
615
616        // Create 11x11 grid at Z=-2000, spacing 400 units
617        let grid_size = 11;
618        let spacing = 400.0;
619        let z = -2000.0;
620
621        let mut vertices = Vec::new();
622        for y in 0..grid_size {
623            for x in 0..grid_size {
624                vertices.push(Point3::new(x as f32 * spacing, y as f32 * spacing, z));
625            }
626        }
627
628        // Generate triangle faces for the grid
629        let mut faces = Vec::new();
630        for y in 0..(grid_size - 1) {
631            for x in 0..(grid_size - 1) {
632                let top_left = y * grid_size + x;
633                let top_right = top_left + 1;
634                let bottom_left = (y + 1) * grid_size + x;
635                let bottom_right = bottom_left + 1;
636
637                // Two triangles per quad
638                faces.push([top_left, bottom_left, top_right]);
639                faces.push([top_right, bottom_left, bottom_right]);
640            }
641        }
642
643        let mesh = TriangleMesh::from_vertices_and_faces(vertices.clone(), faces.clone());
644
645        println!(
646            "Original mesh: {} vertices, {} faces",
647            mesh.vertex_count(),
648            mesh.face_count()
649        );
650
651        // Identify original boundary vertices (edges of the grid)
652        let mut original_boundary_verts = std::collections::HashSet::new();
653        for i in 0..grid_size {
654            // Top edge
655            original_boundary_verts.insert(i);
656            // Bottom edge
657            original_boundary_verts.insert((grid_size - 1) * grid_size + i);
658            // Left edge
659            original_boundary_verts.insert(i * grid_size);
660            // Right edge
661            original_boundary_verts.insert(i * grid_size + (grid_size - 1));
662        }
663
664        println!(
665            "Original boundary vertices: {}",
666            original_boundary_verts.len()
667        );
668
669        // Debug: Check boundary edge detection
670        let boundary_edges = simplifier.find_boundary_edges(&mesh);
671        println!("Detected boundary edges: {}", boundary_edges.len());
672
673        // Count unique vertices in boundary edges
674        let mut detected_boundary_verts = std::collections::HashSet::new();
675        for &(v1, v2) in &boundary_edges {
676            detected_boundary_verts.insert(v1);
677            detected_boundary_verts.insert(v2);
678        }
679        println!(
680            "Detected boundary vertices: {}",
681            detected_boundary_verts.len()
682        );
683
684        // Simplify with 50% reduction
685        let simplified = simplifier.simplify(&mesh, 0.5).unwrap();
686
687        println!(
688            "Simplified mesh: {} vertices, {} faces",
689            simplified.vertex_count(),
690            simplified.face_count()
691        );
692
693        // Check if boundary vertices are preserved
694        // We need to check if the boundary vertices' positions are still in the simplified mesh
695        let mut boundary_positions = std::collections::HashSet::new();
696        for &idx in &original_boundary_verts {
697            let pos = vertices[idx];
698            boundary_positions.insert((
699                (pos.x * 100.0) as i32,
700                (pos.y * 100.0) as i32,
701                (pos.z * 100.0) as i32,
702            ));
703        }
704
705        let mut simplified_positions = std::collections::HashSet::new();
706        for &pos in &simplified.vertices {
707            simplified_positions.insert((
708                (pos.x * 100.0) as i32,
709                (pos.y * 100.0) as i32,
710                (pos.z * 100.0) as i32,
711            ));
712        }
713
714        let preserved_boundary_count = boundary_positions
715            .intersection(&simplified_positions)
716            .count();
717        println!(
718            "Preserved boundary vertices: {}/{}",
719            preserved_boundary_count,
720            boundary_positions.len()
721        );
722
723        // Boundary vertices should be preserved when preserve_boundary is true
724        // We expect all or most boundary vertices to remain
725        assert!(preserved_boundary_count as f32 / boundary_positions.len() as f32 > 0.9,
726            "Expected at least 90% of boundary vertices to be preserved, but only {}% were preserved",
727            (preserved_boundary_count as f32 / boundary_positions.len() as f32 * 100.0));
728    }
729
730    /// A strip of triangles: every vertex lies on the outer border.
731    fn strip(n: usize) -> TriangleMesh {
732        let vertices = (0..n)
733            .flat_map(|i| {
734                [
735                    Point3::new(i as f32, 0.0, 0.0),
736                    Point3::new(i as f32, 1.0, 0.0),
737                ]
738            })
739            .collect();
740        let faces = (0..n - 1)
741            .flat_map(|i| {
742                let (a, b, c, d) = (2 * i, 2 * i + 1, 2 * i + 2, 2 * i + 3);
743                [[a, c, b], [b, c, d]]
744            })
745            .collect();
746        TriangleMesh::from_vertices_and_faces(vertices, faces)
747    }
748
749    #[test]
750    fn test_all_boundary_mesh_reports_why_it_cannot_simplify() {
751        let err = QuadricErrorSimplifier::new()
752            .simplify(&strip(20), 0.5)
753            .unwrap_err()
754            .to_string();
755        assert!(err.contains("preserve_boundary"), "{err}");
756        assert!(err.contains("40 of 40 vertices"), "{err}");
757        assert!(!err.contains("  "), "stray spaces: {err}");
758    }
759
760    #[test]
761    fn test_all_boundary_mesh_simplifies_without_boundary_preservation() {
762        let mesh = strip(20);
763        let simplifier = QuadricErrorSimplifier::with_params(None, false, 45.0_f32.to_radians());
764        let result = simplifier.simplify(&mesh, 0.5).unwrap();
765        assert!(result.faces.len() < mesh.faces.len());
766    }
767}