Skip to main content

rscad_csg_bsp/
cleanup.rs

1//! Post-CSG polygon cleanup: remove degenerate polygons, collapse collinear
2//! vertices, and merge coplanar adjacent polygons.
3
4use crate::{Number, Plane, Polygon};
5use std::collections::HashMap;
6
7/// Remove collinear interior vertices from a polygon.
8///
9/// A vertex `b` between `a` and `c` is collinear when the cross product
10/// `(b - a) × (c - a)` has near-zero magnitude relative to the edge lengths.
11pub fn remove_collinear_vertices<T: Number>(polygon: Polygon<T, 3>) -> Option<Polygon<T, 3>> {
12    let verts = polygon.vertices();
13    let n = verts.len();
14    if n < 3 {
15        return None;
16    }
17
18    let eps = T::from(1e-10).expect("BUG: f64 must represent 1e-10");
19    let mut keep = Vec::with_capacity(n);
20
21    for i in 0..n {
22        let prev = if i == 0 { n - 1 } else { i - 1 };
23        let next = if i + 1 < n { i + 1 } else { 0 };
24        let a = verts[prev];
25        let b = verts[i];
26        let c = verts[next];
27        let ab = b - a;
28        let ac = c - a;
29        let cross = ab.cross(&ac);
30        if cross.norm_squared() > eps {
31            keep.push(verts[i]);
32        }
33    }
34
35    if keep.len() < 3 {
36        return None;
37    }
38
39    Some(polygon.clone_with_points(ndarray::Array1::from_vec(keep)))
40}
41
42/// Returns true if a polygon is degenerate (near-zero area or duplicate vertices).
43pub fn is_degenerate<T: Number>(polygon: &Polygon<T, 3>) -> bool {
44    let verts = polygon.vertices();
45    if verts.len() < 3 {
46        return true;
47    }
48
49    // Check area via cross products of edges from vertex 0.
50    let eps = T::from(1e-10).expect("BUG: f64 must represent 1e-10");
51    let mut area_sq = T::zero();
52    let v0 = verts[0];
53    for i in 1..verts.len() - 1 {
54        let e1 = verts[i] - v0;
55        let e2 = verts[i + 1] - v0;
56        let cross = e1.cross(&e2);
57        area_sq += cross.norm_squared();
58    }
59    area_sq < eps
60}
61
62/// Quantised vertex key for edge-matching. Rounds to 1e-5 grid.
63type VertexKey = [i64; 3];
64
65fn vertex_key<T: Number>(p: &nalgebra::Point3<T>) -> VertexKey {
66    let scale = T::from(1e5).expect("BUG: f64 must represent 1e5");
67    [
68        num_traits::cast::<T, f64>(num_traits::Float::round(p.x * scale)).unwrap_or(0.0) as i64,
69        num_traits::cast::<T, f64>(num_traits::Float::round(p.y * scale)).unwrap_or(0.0) as i64,
70        num_traits::cast::<T, f64>(num_traits::Float::round(p.z * scale)).unwrap_or(0.0) as i64,
71    ]
72}
73
74/// Quantised plane key for grouping coplanar polygons.
75/// Canonicalises the normal direction so that opposite-facing normals don't
76/// accidentally merge (they represent different face orientations).
77type PlaneKey = [i64; 4];
78
79fn plane_key<T: Number>(plane: &Plane<T, 3>) -> PlaneKey {
80    let scale = T::from(1e4).expect("BUG: f64 must represent 1e4");
81    let n = plane.normal();
82    let d = plane.distance();
83    [
84        num_traits::cast::<T, f64>(num_traits::Float::round(n.x * scale)).unwrap_or(0.0) as i64,
85        num_traits::cast::<T, f64>(num_traits::Float::round(n.y * scale)).unwrap_or(0.0) as i64,
86        num_traits::cast::<T, f64>(num_traits::Float::round(n.z * scale)).unwrap_or(0.0) as i64,
87        num_traits::cast::<T, f64>(num_traits::Float::round(d * scale)).unwrap_or(0.0) as i64,
88    ]
89}
90
91type DirectedEdge = (VertexKey, VertexKey);
92
93/// Merge coplanar adjacent polygons that share edges.
94///
95/// Algorithm:
96/// 1. Group polygons by quantised plane.
97/// 2. For each coplanar group, build a directed-edge map.
98/// 3. Greedily merge polygon pairs that share an edge (opposing directions).
99/// 4. Repeat until no more merges are possible in that group.
100pub fn merge_coplanar_polygons<T: Number>(polygons: Vec<Polygon<T, 3>>) -> Vec<Polygon<T, 3>> {
101    // Group by plane.
102    let mut groups: HashMap<PlaneKey, Vec<Polygon<T, 3>>> = HashMap::new();
103    for p in polygons {
104        groups.entry(plane_key(&p.plane())).or_default().push(p);
105    }
106
107    let mut result = Vec::new();
108    for (_, group) in groups {
109        if group.len() < 2 {
110            result.extend(group);
111            continue;
112        }
113        result.extend(merge_group(group));
114    }
115
116    result
117}
118
119/// Merge a group of coplanar polygons by greedily combining pairs that share edges.
120fn merge_group<T: Number>(mut polys: Vec<Polygon<T, 3>>) -> Vec<Polygon<T, 3>> {
121    // Iterate until no more merges happen.
122    let mut changed = true;
123    while changed {
124        changed = false;
125        // Build edge → polygon-index map for the reverse edge direction.
126        // For polygon i with directed edge (A→B), we want to find polygon j
127        // with directed edge (B→A).
128        let mut edge_to_poly: HashMap<DirectedEdge, usize> = HashMap::new();
129        for (idx, poly) in polys.iter().enumerate() {
130            let verts = poly.vertices();
131            let n = verts.len();
132            for i in 0..n {
133                let j = if i + 1 < n { i + 1 } else { 0 };
134                let a = vertex_key(&verts[i]);
135                let b = vertex_key(&verts[j]);
136                edge_to_poly.insert((a, b), idx);
137            }
138        }
139
140        // Find first mergeable pair.
141        let mut merge = None;
142        'outer: for (idx_a, poly_a) in polys.iter().enumerate() {
143            let verts_a = poly_a.vertices();
144            let na = verts_a.len();
145            for i in 0..na {
146                let j = if i + 1 < na { i + 1 } else { 0 };
147                let a_key = vertex_key(&verts_a[i]);
148                let b_key = vertex_key(&verts_a[j]);
149                // Look for a polygon with the reverse edge (B→A).
150                if let Some(&idx_b) = edge_to_poly.get(&(b_key, a_key))
151                    && idx_b != idx_a
152                {
153                    merge = Some((idx_a, i, idx_b));
154                    break 'outer;
155                }
156            }
157        }
158
159        if let Some((idx_a, edge_start_a, idx_b)) = merge {
160            // Remove both polygons (remove higher index first to keep lower valid).
161            let (ia, ib) = if idx_a < idx_b {
162                (idx_a, idx_b)
163            } else {
164                (idx_b, idx_a)
165            };
166            let poly_hi = polys.remove(ib);
167            let poly_lo = polys.remove(ia);
168            let (poly_a, edge_a, poly_b) = if idx_a < idx_b {
169                (poly_lo, edge_start_a, poly_hi)
170            } else {
171                (poly_hi, edge_start_a, poly_lo)
172            };
173            if let Some(merged) = merge_two_polygons(&poly_a, edge_a, &poly_b) {
174                polys.push(merged);
175                changed = true;
176            } else {
177                // Put them back if merge failed.
178                polys.push(poly_a);
179                polys.push(poly_b);
180            }
181        }
182    }
183    polys
184}
185
186/// Merge two polygons along a shared edge.
187///
188/// `poly_a` has a directed edge starting at vertex index `edge_a_start` (i.e.
189/// edge `verts_a[edge_a_start] → verts_a[edge_a_start+1]`).
190/// `poly_b` has the reverse of that edge somewhere.
191///
192/// The merged polygon walks along poly_a, and where the shared edge would be,
193/// detours through poly_b instead.
194fn merge_two_polygons<T: Number>(
195    poly_a: &Polygon<T, 3>,
196    edge_a_start: usize,
197    poly_b: &Polygon<T, 3>,
198) -> Option<Polygon<T, 3>> {
199    let va = poly_a.vertices();
200    let vb = poly_b.vertices();
201    let na = va.len();
202    let nb = vb.len();
203
204    let a_key = vertex_key(&va[edge_a_start]);
205    let b_key = vertex_key(&va[(edge_a_start + 1) % na]);
206
207    // Find the shared edge in poly_b: looking for B→A.
208    let edge_b_start = (0..nb).find(|&i| {
209        let j = (i + 1) % nb;
210        vertex_key(&vb[i]) == b_key && vertex_key(&vb[j]) == a_key
211    })?;
212
213    // Build merged vertex list:
214    // 1. poly_a vertices from 0 up to and including edge_a_start (= vertex A)
215    // 2. poly_b's non-shared vertices (the detour between A and B), excluding the
216    //    shared edge's endpoints in poly_b that duplicate A and B
217    // 3. poly_a vertices from edge_a_start + 1 (= vertex B) through the end
218    //
219    // Vertex B appears exactly once:
220    //   - when the shared edge is poly_a's wrap-around edge, edge_a_start == na - 1
221    //     so B == va[0] is already emitted by part 1 and part 3 is empty;
222    //   - otherwise B == va[edge_a_start + 1] and is emitted as part 3's first vertex.
223    // Part 2 never emits B, so neither case duplicates or drops it.
224
225    let mut merged = Vec::with_capacity(na + nb - 2);
226
227    // Part 1: poly_a up to and including A
228    for i in 0..=edge_a_start {
229        merged.push(va[i]);
230    }
231
232    // Part 2: poly_b detour (from after A to before B, exclusive of both shared edge endpoints)
233    let b_detour_count = nb - 2; // we skip the two shared-edge vertices
234    for k in 0..b_detour_count {
235        let idx = (edge_b_start + 2 + k) % nb;
236        merged.push(vb[idx]);
237    }
238
239    // Part 3: poly_a from B onward (B is at edge_a_start + 1), continuing to the end.
240    // In the wrap-around case (edge_a_start == na - 1) this range is empty and B was
241    // already emitted by part 1; otherwise B is this loop's first vertex.
242    for k in 0..na.saturating_sub(edge_a_start + 1) {
243        let idx = edge_a_start + 1 + k;
244        merged.push(va[idx]);
245    }
246
247    if merged.len() < 3 {
248        return None;
249    }
250
251    Some(poly_a.clone_with_points(ndarray::Array1::from_vec(merged)))
252}
253
254/// Full cleanup pass: remove degenerates, collapse collinear vertices, merge coplanars.
255pub fn cleanup_polygons<T: Number>(polygons: Vec<Polygon<T, 3>>) -> Vec<Polygon<T, 3>> {
256    // Step 1: Remove collinear vertices and degenerate polygons.
257    let cleaned: Vec<Polygon<T, 3>> = polygons
258        .into_iter()
259        .filter(|p| !is_degenerate(p))
260        .filter_map(remove_collinear_vertices)
261        .collect();
262
263    // Step 2: Merge coplanar adjacent polygons.
264    merge_coplanar_polygons(cleaned)
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use nalgebra::Point3;
271
272    fn total_vertices<T: Number>(polys: &[Polygon<T, 3>]) -> usize {
273        polys.iter().map(|p| p.vertices().len()).sum()
274    }
275
276    /// Shoelace area of a planar polygon, projected onto the xy-plane.
277    /// (All test polygons here live on z = 0.)
278    fn polygon_area_xy(poly: &Polygon<f64, 3>) -> f64 {
279        let v = poly.vertices();
280        let n = v.len();
281        let mut sum = 0.0;
282        for i in 0..n {
283            let j = (i + 1) % n;
284            sum += v[i].x * v[j].y - v[j].x * v[i].y;
285        }
286        0.5 * sum.abs()
287    }
288
289    /// Collect the quantised vertex keys of a polygon (order-independent identity).
290    fn vertex_keys(poly: &Polygon<f64, 3>) -> std::collections::BTreeSet<VertexKey> {
291        poly.vertices().iter().map(vertex_key).collect()
292    }
293
294    #[test]
295    fn collinear_vertex_removal() {
296        // Triangle with an extra collinear vertex on one edge.
297        let p = Polygon::from_points(vec![
298            Point3::new(0.0f64, 0.0, 0.0),
299            Point3::new(5.0, 0.0, 0.0),
300            Point3::new(10.0, 0.0, 0.0), // collinear with prev and next edge direction
301            Point3::new(5.0, 10.0, 0.0),
302        ])
303        .unwrap();
304        let cleaned = remove_collinear_vertices(p).unwrap();
305        assert_eq!(
306            cleaned.vertices().len(),
307            3,
308            "collinear vertex should be removed"
309        );
310    }
311
312    #[test]
313    fn degenerate_polygon_filtered() {
314        // Zero-area polygon (all points collinear).
315        let p = Polygon::from_points_and_plane(
316            vec![
317                Point3::new(0.0f64, 0.0, 0.0),
318                Point3::new(1.0, 0.0, 0.0),
319                Point3::new(2.0, 0.0, 0.0),
320            ],
321            Plane::xy_plane(),
322        )
323        .unwrap();
324        assert!(is_degenerate(&p));
325    }
326
327    #[test]
328    fn coplanar_merge_two_triangles() {
329        // Two triangles sharing edge B-C, forming a quad.
330        let a = Point3::new(0.0f64, 0.0, 0.0);
331        let b = Point3::new(1.0, 0.0, 0.0);
332        let c = Point3::new(1.0, 1.0, 0.0);
333        let d = Point3::new(0.0, 1.0, 0.0);
334
335        let p1 = Polygon::from_points(vec![a, b, c]).unwrap();
336        let p2 = Polygon::from_points(vec![a, c, d]).unwrap();
337
338        assert_eq!(total_vertices(&[p1.clone(), p2.clone()]), 6);
339
340        let merged = merge_coplanar_polygons(vec![p1, p2]);
341        let merged_verts = total_vertices(&merged);
342        assert!(
343            merged_verts <= 4,
344            "two triangles sharing an edge should merge to <= 4 vertices, got {merged_verts}"
345        );
346    }
347
348    #[test]
349    fn coplanar_merge_nonwraparound_edge_keeps_all_corners() {
350        // Two triangles sharing edge B-C, where the shared edge is NOT poly_a's
351        // wrap-around edge (regression for the dropped shared-edge endpoint).
352        //
353        // poly_a = [a, b, c]  -> shared edge is b->c (edge_a_start == 1, non-wrap)
354        // poly_b = [b, d, c]  -> reverse edge c->b
355        //
356        // The merge must produce the quad [a, b, d, c] with all four distinct
357        // corners; the pre-fix code dropped corner c and returned only [a, b, d].
358        let a = Point3::new(0.0f64, 0.0, 0.0);
359        let b = Point3::new(1.0, 0.0, 0.0);
360        let c = Point3::new(1.0, 1.0, 0.0);
361        let d = Point3::new(2.0, 0.5, 0.0);
362
363        let p1 = Polygon::from_points(vec![a, b, c]).unwrap();
364        let p2 = Polygon::from_points(vec![b, d, c]).unwrap();
365        let expected_area = polygon_area_xy(&p1) + polygon_area_xy(&p2);
366
367        let merged = merge_coplanar_polygons(vec![p1, p2]);
368
369        assert_eq!(
370            merged.len(),
371            1,
372            "the two triangles must merge into a single polygon"
373        );
374        let quad = &merged[0];
375        assert_eq!(
376            quad.vertices().len(),
377            4,
378            "merged polygon must be a quad (4 vertices), no dropped/duplicated vertex, got {}",
379            quad.vertices().len()
380        );
381
382        // All four distinct corners must survive the merge (c is the one the bug dropped).
383        let got = vertex_keys(quad);
384        for (name, p) in [("a", a), ("b", b), ("c", c), ("d", d)] {
385            assert!(
386                got.contains(&vertex_key(&p)),
387                "merged quad is missing corner {name}"
388            );
389        }
390
391        // Area must equal the sum of the two triangles (dropping c would halve it).
392        assert!(
393            (polygon_area_xy(quad) - expected_area).abs() < 1e-9,
394            "merged quad area {} should equal the summed triangle area {expected_area}",
395            polygon_area_xy(quad)
396        );
397    }
398
399    #[test]
400    fn coplanar_merge_wraparound_edge_keeps_all_corners() {
401        // Two triangles where the shared edge IS poly_a's wrap-around edge
402        // (edge_a_start == na - 1). This case worked before the fix and must
403        // keep working: verify all four corners and correct area.
404        //
405        // poly_a = [a, b, c]  -> shared edge c->a is the wrap-around edge (index 2->0)
406        // poly_b = [a, c, d]  -> reverse edge a->c
407        let a = Point3::new(0.0f64, 0.0, 0.0);
408        let b = Point3::new(1.0, 0.0, 0.0);
409        let c = Point3::new(1.0, 1.0, 0.0);
410        let d = Point3::new(0.0, 1.0, 0.0);
411
412        let p1 = Polygon::from_points(vec![a, b, c]).unwrap();
413        let p2 = Polygon::from_points(vec![a, c, d]).unwrap();
414        let expected_area = polygon_area_xy(&p1) + polygon_area_xy(&p2);
415
416        let merged = merge_coplanar_polygons(vec![p1, p2]);
417
418        assert_eq!(merged.len(), 1, "triangles must merge into one polygon");
419        let quad = &merged[0];
420        assert_eq!(
421            quad.vertices().len(),
422            4,
423            "wrap-around merge must stay a quad, got {}",
424            quad.vertices().len()
425        );
426
427        let got = vertex_keys(quad);
428        for (name, p) in [("a", a), ("b", b), ("c", c), ("d", d)] {
429            assert!(
430                got.contains(&vertex_key(&p)),
431                "merged quad is missing corner {name}"
432            );
433        }
434
435        assert!(
436            (polygon_area_xy(quad) - expected_area).abs() < 1e-9,
437            "merged quad area {} should equal the summed triangle area {expected_area}",
438            polygon_area_xy(quad)
439        );
440    }
441
442    #[test]
443    fn cube_union_reduces_polygon_count() {
444        // Two overlapping cubes — cleanup after union should produce fewer
445        // polygons than the raw polygon count.
446        let a = crate::cuboid(10.0, 10.0, 10.0);
447        let b = crate::cuboid(10.0, 10.0, 10.0).translate(nalgebra::Vector3::new(5.0, 0.0, 0.0));
448        let raw_total = a.polygons().len() + b.polygons().len();
449        let result = a.union(b).cleanup();
450        let result_count = result.polygons().len();
451        // After cleanup, the union must produce fewer polygons than just
452        // concatenating both cubes.
453        assert!(
454            result_count < raw_total,
455            "union+cleanup should reduce polygon count: {result_count} should be < {raw_total}"
456        );
457    }
458}