Skip to main content

ogeom_mesh/
simplify.rs

1//! Decimating a triangulation.
2//!
3//! A mesh fine enough for a mass property is far finer than one needed to draw,
4//! and a mesh fine enough to draw a whole assembly is far finer than one needed
5//! for the bolt in the corner of it. Decimation is how one tessellation serves
6//! both without being computed twice.
7//!
8//! # The error is measured, not hoped for
9//!
10//! Collapsing an edge moves the surface. *How far* it moves is what decides
11//! whether the collapse is worth making, so every candidate carries the squared
12//! distance from the merged vertex to the planes of every face that met there:
13//! the quadric error metric of Garland and Heckbert. Summing plane distances
14//! this way costs one small symmetric matrix per vertex and makes the choice a
15//! comparison rather than a guess.
16//!
17//! The result reports the worst error it accepted. A decimation that returned
18//! only a smaller mesh would be one nothing downstream could decide to trust.
19//!
20//! # What it will not touch
21//!
22//! A vertex on a boundary stays. The alternative is a constraint plane that
23//! makes boundary collapses expensive but possible, and "expensive but
24//! possible" means the outline of a sheet body creeps inward as the mesh
25//! coarsens, which is exactly the thing a caller would not think to check.
26//! Holding the boundary exactly is a stronger promise and a simpler one.
27//!
28//! A collapse that would turn a triangle inside out is refused for the same
29//! reason: a fold is not a small error, it is a mesh that no longer bounds what
30//! it did.
31
32use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
33use ogeom_math::Point;
34use ogeom_topo::Triangulation;
35use std::collections::{HashMap, HashSet};
36
37/// How far to decimate.
38#[derive(Debug, Clone, Copy, PartialEq)]
39pub enum Target {
40    /// Stop once the mesh is down to this many triangles, whatever the error.
41    Triangles(usize),
42    /// Collapse only while the error stays within this distance.
43    ///
44    /// The honest option: the caller says how wrong the mesh may be and gets
45    /// however few triangles that allows, rather than naming a count and
46    /// discovering the error afterwards.
47    Error(f64),
48}
49
50/// What decimation produced.
51#[derive(Debug, Clone)]
52pub struct Simplified {
53    /// The decimated mesh.
54    pub mesh: Triangulation,
55    /// The worst error accepted, as a distance.
56    ///
57    /// Zero when nothing was collapsed. Every vertex of the result is within
58    /// this of the surface the original described.
59    pub error: f64,
60    /// How many edge collapses were made.
61    pub collapsed: usize,
62    /// Whether the target was reached.
63    ///
64    /// A mesh can run out of *valid* collapses before it runs out of triangles
65    /// (every remaining edge is on a boundary or would fold something), and
66    /// then the result is as small as it can safely be rather than as small as
67    /// was asked for. Reported rather than passed off as success.
68    pub target_met: bool,
69}
70
71/// Decimate a mesh.
72///
73/// # Errors
74///
75/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the target is
76/// not a positive count or a positive distance, or the mesh names a vertex it
77/// does not have.
78pub fn simplify(mesh: &Triangulation, target: Target, tol: Tolerances) -> OgeomResult<Simplified> {
79    match target {
80        Target::Triangles(0) => {
81            ogeom_bail!(Construction, "a mesh of no triangles describes nothing");
82        }
83        Target::Error(e) if !e.is_finite() || e <= 0.0 => {
84            ogeom_bail!(Construction, "an error budget of {e} is not a distance");
85        }
86        _ => {}
87    }
88    for triangle in &mesh.triangles {
89        for index in triangle {
90            if *index as usize >= mesh.positions.len() {
91                ogeom_bail!(
92                    Construction,
93                    "a triangle names vertex {index}, and the mesh has {}",
94                    mesh.positions.len()
95                );
96            }
97        }
98    }
99
100    let mut state = State::new(mesh, tol);
101    let budget = match target {
102        Target::Error(e) => e * e,
103        Target::Triangles(_) => f64::MAX,
104    };
105    let floor = match target {
106        Target::Triangles(n) => n,
107        Target::Error(_) => 1,
108    };
109
110    let mut worst = 0.0_f64;
111    let mut collapsed = 0;
112    while state.live_triangles() > floor {
113        let Some((cost, from, to, at)) = state.cheapest(budget) else {
114            break;
115        };
116        state.collapse(from, to, at);
117        worst = worst.max(cost);
118        collapsed += 1;
119    }
120
121    let target_met = match target {
122        Target::Triangles(n) => state.live_triangles() <= n,
123        // Every collapse made was inside the budget, and the loop stops only
124        // when no remaining one is.
125        Target::Error(_) => true,
126    };
127    Ok(Simplified {
128        mesh: state.harvest(),
129        error: worst.max(0.0).sqrt(),
130        collapsed,
131        target_met,
132    })
133}
134
135/// A 4x4 symmetric quadric, as its upper triangle.
136///
137/// `v^T Q v` is the sum of squared distances from `v` to a set of planes, which
138/// is what makes these addable: the error of merging two vertices is the error
139/// of one plus the error of the other.
140#[derive(Debug, Clone, Copy, Default)]
141struct Quadric([f64; 10]);
142
143impl Quadric {
144    /// The quadric of one plane `ax + by + cz + d = 0`, with a unit normal.
145    fn of_plane(a: f64, b: f64, c: f64, d: f64) -> Self {
146        Self([
147            a * a,
148            a * b,
149            a * c,
150            a * d,
151            b * b,
152            b * c,
153            b * d,
154            c * c,
155            c * d,
156            d * d,
157        ])
158    }
159
160    fn add(&mut self, other: &Self) {
161        for (a, b) in self.0.iter_mut().zip(other.0) {
162            *a += b;
163        }
164    }
165
166    /// The squared distance this quadric assigns to a point.
167    fn at(&self, p: Point) -> f64 {
168        let [q00, q01, q02, q03, q11, q12, q13, q22, q23, q33] = self.0;
169        let (x, y, z) = (p.x, p.y, p.z);
170        q00 * x * x
171            + 2.0 * q01 * x * y
172            + 2.0 * q02 * x * z
173            + 2.0 * q03 * x
174            + q11 * y * y
175            + 2.0 * q12 * y * z
176            + 2.0 * q13 * y
177            + q22 * z * z
178            + 2.0 * q23 * z
179            + q33
180    }
181}
182
183/// The mesh mid-decimation.
184struct State {
185    positions: Vec<Point>,
186    triangles: Vec<[u32; 3]>,
187    /// Whether each triangle is still there.
188    live: Vec<bool>,
189    quadrics: Vec<Quadric>,
190    /// Vertices that must not move: the ones on a boundary.
191    pinned: HashSet<u32>,
192    /// Which triangles touch each vertex.
193    around: HashMap<u32, Vec<usize>>,
194    tol: Tolerances,
195    remaining: usize,
196}
197
198impl State {
199    fn new(mesh: &Triangulation, tol: Tolerances) -> Self {
200        let mut quadrics = vec![Quadric::default(); mesh.positions.len()];
201        let mut around: HashMap<u32, Vec<usize>> = HashMap::new();
202        let mut uses: HashMap<(u32, u32), usize> = HashMap::new();
203
204        for (i, triangle) in mesh.triangles.iter().enumerate() {
205            let [a, b, c] = triangle.map(|v| mesh.positions[v as usize]);
206            let normal = (b - a).cross(c - a);
207            let length = normal.magnitude();
208            if length > tol.confusion() {
209                let unit = normal * (1.0 / length);
210                // Weighted by area, so a large flat region is not outvoted by a
211                // cluster of slivers describing the same plane.
212                let plane = Quadric::of_plane(unit.x, unit.y, unit.z, -unit.dot(a.to_vector()));
213                let mut weighted = plane;
214                for value in &mut weighted.0 {
215                    *value *= length;
216                }
217                for v in triangle {
218                    quadrics[*v as usize].add(&weighted);
219                }
220            }
221            for v in triangle {
222                around.entry(*v).or_default().push(i);
223            }
224            for k in 0..3 {
225                let (x, y) = (triangle[k], triangle[(k + 1) % 3]);
226                *uses.entry((x.min(y), x.max(y))).or_default() += 1;
227            }
228        }
229
230        // A boundary edge is used once. Both its ends are held.
231        let mut pinned = HashSet::new();
232        for ((a, b), count) in uses {
233            if count != 2 {
234                pinned.insert(a);
235                pinned.insert(b);
236            }
237        }
238
239        Self {
240            positions: mesh.positions.clone(),
241            triangles: mesh.triangles.clone(),
242            live: vec![true; mesh.triangles.len()],
243            quadrics,
244            pinned,
245            around,
246            tol,
247            remaining: mesh.triangles.len(),
248        }
249    }
250
251    const fn live_triangles(&self) -> usize {
252        self.remaining
253    }
254
255    /// The cheapest collapse still worth making, within a squared-error budget.
256    ///
257    /// Recomputed each round rather than kept in a heap. The mesh is small
258    /// enough that the difference is not what makes decimation slow, and a heap
259    /// of costs that go stale on every collapse needs invalidation logic that is
260    /// its own source of wrong answers.
261    fn cheapest(&self, budget: f64) -> Option<(f64, u32, u32, Point)> {
262        let mut best: Option<(f64, u32, u32, Point)> = None;
263        let mut seen: HashSet<(u32, u32)> = HashSet::new();
264        for (i, triangle) in self.triangles.iter().enumerate() {
265            if !self.live[i] {
266                continue;
267            }
268            for k in 0..3 {
269                let (a, b) = (triangle[k], triangle[(k + 1) % 3]);
270                let key = (a.min(b), a.max(b));
271                if !seen.insert(key) {
272                    continue;
273                }
274                // Either end pinned means the edge is on a boundary, or leads
275                // to one. Holding both ends holds the outline exactly.
276                if self.pinned.contains(&a) || self.pinned.contains(&b) {
277                    continue;
278                }
279                let at = Point::from_vector(
280                    (self.positions[a as usize].to_vector()
281                        + self.positions[b as usize].to_vector())
282                        * 0.5,
283                );
284                let mut merged = self.quadrics[a as usize];
285                merged.add(&self.quadrics[b as usize]);
286                let cost = merged.at(at).max(0.0);
287                if cost > budget {
288                    continue;
289                }
290                if best.is_some_and(|(current, ..)| cost >= current) {
291                    continue;
292                }
293                if self.would_fold(a, b, at) {
294                    continue;
295                }
296                best = Some((cost, a, b, at));
297            }
298        }
299        best
300    }
301
302    /// Whether merging two vertices would turn any surviving triangle over.
303    ///
304    /// A fold is not a small error. It is a mesh that no longer bounds what it
305    /// did, and no error metric measures that: the merged point can sit
306    /// exactly on every plane and still put the triangle back to front.
307    fn would_fold(&self, from: u32, to: u32, at: Point) -> bool {
308        for vertex in [from, to] {
309            for index in self.around.get(&vertex).into_iter().flatten() {
310                if !self.live[*index] {
311                    continue;
312                }
313                let triangle = self.triangles[*index];
314                // Triangles containing both vanish with the collapse.
315                if triangle.contains(&from) && triangle.contains(&to) {
316                    continue;
317                }
318                let before = self.normal_of(triangle, None);
319                let after = self.normal_of(triangle, Some((from, to, at)));
320                let (Some(before), Some(after)) = (before, after) else {
321                    return true;
322                };
323                if before.dot(after) <= 0.0 {
324                    return true;
325                }
326            }
327        }
328        false
329    }
330
331    /// A triangle's normal, optionally with one collapse applied.
332    fn normal_of(
333        &self,
334        triangle: [u32; 3],
335        collapse: Option<(u32, u32, Point)>,
336    ) -> Option<ogeom_math::Vector> {
337        let at = |v: u32| match collapse {
338            Some((from, to, p)) if v == from || v == to => p,
339            _ => self.positions[v as usize],
340        };
341        let (a, b, c) = (at(triangle[0]), at(triangle[1]), at(triangle[2]));
342        let normal = (b - a).cross(c - a);
343        if normal.magnitude() <= self.tol.confusion() {
344            return None;
345        }
346        Some(normal)
347    }
348
349    /// Merge two vertices at a point.
350    fn collapse(&mut self, from: u32, to: u32, at: Point) {
351        self.positions[to as usize] = at;
352        let mut merged = self.quadrics[from as usize];
353        merged.add(&self.quadrics[to as usize]);
354        self.quadrics[to as usize] = merged;
355
356        let touching: Vec<usize> = self
357            .around
358            .get(&from)
359            .into_iter()
360            .flatten()
361            .copied()
362            .collect();
363        for index in touching {
364            if !self.live[index] {
365                continue;
366            }
367            let triangle = &mut self.triangles[index];
368            for v in triangle.iter_mut() {
369                if *v == from {
370                    *v = to;
371                }
372            }
373            // A triangle naming one vertex twice has no area left.
374            let [a, b, c] = *triangle;
375            if a == b || b == c || c == a {
376                self.live[index] = false;
377                self.remaining -= 1;
378            } else {
379                self.around.entry(to).or_default().push(index);
380            }
381        }
382        self.around.remove(&from);
383    }
384
385    /// The surviving mesh, with unused vertices dropped.
386    fn harvest(self) -> Triangulation {
387        let mut out = Triangulation::new();
388        let mut moved: HashMap<u32, u32> = HashMap::new();
389        for (index, triangle) in self.triangles.iter().enumerate() {
390            if !self.live[index] {
391                continue;
392            }
393            let mut mapped = [0_u32; 3];
394            for (slot, v) in mapped.iter_mut().zip(triangle) {
395                *slot = *moved.entry(*v).or_insert_with(|| {
396                    #[allow(clippy::cast_possible_truncation)]
397                    let fresh = out.positions.len() as u32;
398                    out.positions.push(self.positions[*v as usize]);
399                    fresh
400                });
401            }
402            out.triangles.push(mapped);
403        }
404        // Normals are recomputed from the surviving geometry rather than
405        // carried over: a merged vertex's old normal described a surface that
406        // is no longer there.
407        out.normals = vec![ogeom_math::Vector::ZERO; out.positions.len()];
408        for triangle in &out.triangles {
409            let [a, b, c] = triangle.map(|v| out.positions[v as usize]);
410            let normal = (b - a).cross(c - a);
411            for v in triangle {
412                out.normals[*v as usize] += normal;
413            }
414        }
415        for normal in &mut out.normals {
416            let length = normal.magnitude();
417            if length > self.tol.confusion() {
418                *normal *= 1.0 / length;
419            }
420        }
421        out.parameters = vec![(0.0, 0.0); out.positions.len()];
422        out.deflection_met = false;
423        out
424    }
425}
426
427#[cfg(test)]
428#[allow(clippy::unwrap_used)]
429mod tests {
430    use super::*;
431    use crate::{Deflection, triangulate};
432    use ogeom_algo::{make_box, make_sphere};
433    use ogeom_math::Frame;
434    use ogeom_topo::Model;
435
436    const T: Tolerances = Tolerances::millimetres();
437
438    fn sphere(chord: f64) -> Triangulation {
439        let mut model = Model::new();
440        let built = make_sphere(&mut model, Frame::WORLD, 10.0, T).unwrap();
441        triangulate(
442            &model,
443            &built.shape,
444            Deflection {
445                chord,
446                ..Deflection::default()
447            },
448            T,
449        )
450        .unwrap()
451    }
452
453    #[test]
454    fn decimating_a_sphere_keeps_it_a_sphere_to_the_error_it_reports() {
455        // The property that matters: the result says how far it moved, and
456        // every vertex of it really is within that of the original surface.
457        let mesh = sphere(0.02);
458        let before = mesh.triangle_count();
459        let done = simplify(&mesh, Target::Triangles(before / 4), T).unwrap();
460
461        assert!(done.collapsed > 0);
462        assert!(
463            done.mesh.triangle_count() < before,
464            "nothing was removed: {} of {before}",
465            done.mesh.triangle_count()
466        );
467        for p in &done.mesh.positions {
468            let off = (p.to_vector().magnitude() - 10.0).abs();
469            assert!(
470                off <= done.error + 1e-9,
471                "a vertex is {off} off the sphere, but the reported error is {}",
472                done.error
473            );
474        }
475    }
476
477    #[test]
478    fn a_tighter_error_budget_removes_less() {
479        let mesh = sphere(0.02);
480        let loose = simplify(&mesh, Target::Error(0.5), T).unwrap();
481        let tight = simplify(&mesh, Target::Error(0.01), T).unwrap();
482
483        assert!(
484            tight.mesh.triangle_count() >= loose.mesh.triangle_count(),
485            "a tighter budget should keep more: {} against {}",
486            tight.mesh.triangle_count(),
487            loose.mesh.triangle_count()
488        );
489        assert!(
490            tight.error <= 0.01 + 1e-12,
491            "over budget at {}",
492            tight.error
493        );
494        assert!(loose.error <= 0.5 + 1e-12);
495        assert!(tight.target_met && loose.target_met);
496    }
497
498    #[test]
499    fn the_mesh_stays_closed() {
500        // A collapse that opened a hole would be a decimation that changed what
501        // the mesh bounds, which is a different thing from making it coarser.
502        let mesh = sphere(0.05);
503        assert!(mesh.is_closed());
504        let done = simplify(&mesh, Target::Triangles(mesh.triangle_count() / 2), T).unwrap();
505        assert!(
506            done.mesh.is_closed(),
507            "decimation opened the mesh after {} collapses",
508            done.collapsed
509        );
510        assert!(done.mesh.volume() > 0.0, "and it turned inside out");
511    }
512
513    #[test]
514    fn a_boundary_is_held_exactly() {
515        // A sheet body's outline must not creep inward as the mesh coarsens,
516        // and "expensive but possible" would let it.
517        let mut model = Model::new();
518        let solid = make_box(&mut model, Frame::WORLD, (4.0, 4.0, 4.0), T).unwrap();
519        let face = ogeom_topo::explore_unique(&model, &solid.shape, ogeom_topo::ShapeType::Face)
520            .unwrap()[0]
521            .clone();
522        let sheet = crate::triangulate_face(
523            &model,
524            &face,
525            Deflection {
526                chord: 0.05,
527                ..Deflection::default()
528            },
529            T,
530        )
531        .unwrap();
532
533        let outline = |m: &Triangulation| {
534            let mut low = f64::MAX;
535            let mut high = f64::MIN;
536            for p in &m.positions {
537                low = low.min(p.x);
538                high = high.max(p.x);
539            }
540            (low, high)
541        };
542        let before = outline(&sheet);
543        let done = simplify(&sheet, Target::Triangles(2), T).unwrap();
544        let after = outline(&done.mesh);
545        assert!(
546            (before.0 - after.0).abs() < 1e-12 && (before.1 - after.1).abs() < 1e-12,
547            "the outline moved from {before:?} to {after:?}"
548        );
549    }
550
551    #[test]
552    fn a_target_that_cannot_be_reached_is_reported_rather_than_claimed() {
553        // Every edge of a single triangle is a boundary edge, so there is
554        // nothing to collapse. Saying the target was met would be a lie that
555        // costs a caller the chance to try something else.
556        let mut mesh = Triangulation::new();
557        mesh.positions = vec![
558            Point::ORIGIN,
559            Point::new(1.0, 0.0, 0.0),
560            Point::new(0.0, 1.0, 0.0),
561        ];
562        mesh.triangles = vec![[0, 1, 2]];
563        let done = simplify(&mesh, Target::Triangles(1), T).unwrap();
564        assert_eq!(done.collapsed, 0);
565        assert_eq!(done.mesh.triangle_count(), 1);
566    }
567
568    #[test]
569    fn a_target_that_describes_nothing_is_refused() {
570        let mesh = sphere(0.2);
571        assert!(simplify(&mesh, Target::Triangles(0), T).is_err());
572        assert!(simplify(&mesh, Target::Error(0.0), T).is_err());
573        assert!(simplify(&mesh, Target::Error(-1.0), T).is_err());
574        assert!(simplify(&mesh, Target::Error(f64::NAN), T).is_err());
575
576        let mut broken = Triangulation::new();
577        broken.positions = vec![Point::ORIGIN];
578        broken.triangles = vec![[0, 1, 2]];
579        assert!(simplify(&broken, Target::Triangles(1), T).is_err());
580    }
581}