1use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
33use ogeom_math::Point;
34use ogeom_topo::Triangulation;
35use std::collections::{HashMap, HashSet};
36
37#[derive(Debug, Clone, Copy, PartialEq)]
39pub enum Target {
40 Triangles(usize),
42 Error(f64),
48}
49
50#[derive(Debug, Clone)]
52pub struct Simplified {
53 pub mesh: Triangulation,
55 pub error: f64,
60 pub collapsed: usize,
62 pub target_met: bool,
69}
70
71pub 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 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#[derive(Debug, Clone, Copy, Default)]
141struct Quadric([f64; 10]);
142
143impl Quadric {
144 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 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
183struct State {
185 positions: Vec<Point>,
186 triangles: Vec<[u32; 3]>,
187 live: Vec<bool>,
189 quadrics: Vec<Quadric>,
190 pinned: HashSet<u32>,
192 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}