ifc_lite_geometry/scan_outline/
mod.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Vector outlines traced from a slab of scan points (#6871).
6//!
7//! The scan section layer selects the points within a band around a section
8//! plane and projects them into the plane. This module turns those 2D points
9//! into closed rings: the boundary of what the scan says is solid (walls,
10//! columns, furniture) at the cut, ready for a drawing or a DXF layer.
11//!
12//! Pipeline, every stage bounded:
13//! 1. **Count grid** (`grid`): bin the points into square cells (adaptive size
14//!    from the point density unless fixed, a total cell cap that is reported
15//!    when it forces coarser cells) and keep the cells whose count clears a
16//!    noise threshold relative to the median occupied-cell count.
17//! 2. **Morphology** (`morph`): a binary close with a disk sized to bridge gaps
18//!    up to `max_gap` (two faces of one wall merge into a solid band, a scan
19//!    shadow in a wall closes), then an opening by reconstruction that drops
20//!    speckle without rounding the corners of what survives. Diagonal-only
21//!    contacts are filled so the cell boundary is a set of disjoint simple
22//!    loops; small components are dropped and small holes filled.
23//! 3. **Contours** (`trace`): walk the cell edges between solid and empty
24//!    cells. Outer boundaries come out counter-clockwise, holes clockwise, and
25//!    every ring knows its container (`parents`).
26//! 4. **Simplification** (`simplify`): Douglas-Peucker per ring, then any edge
27//!    that touches or crosses another edge gets its farthest dropped vertex
28//!    back until nothing does (`topology`).
29//! 5. **Snapping** (`snap`): each simplified edge is refitted to the scan
30//!    points along it (total least squares), and vertices move to where
31//!    consecutive fitted lines meet.
32//! 6. **Squaring** (`square`): edges within a tolerance of the dominant
33//!    building direction (or its perpendicular) are rotated onto it.
34//!
35//! Steps 5 and 6 move vertices; any move that would make a ring touch or cross
36//! itself or another ring, or change which ring contains it, is undone
37//! (`topology::repair_moves`). The output therefore holds the same invariants
38//! as the cell-edge trace it started from: closed, simple, pairwise disjoint,
39//! correctly oriented and nested rings.
40
41mod fit;
42mod grid;
43mod morph;
44mod options;
45mod simplify;
46mod snap;
47mod square;
48mod support;
49mod topology;
50mod trace;
51
52#[cfg(test)]
53#[path = "synth_tests.rs"]
54mod synth_tests;
55#[cfg(test)]
56#[path = "scan_outline_tests.rs"]
57mod tests;
58
59use crate::geom2d::polygon_area;
60pub use options::{
61    ScanOutlineDiagnostics, ScanOutlineOptions, MAX_CELLS_LIMIT, MAX_SNAP_DISTANCE_CELLS, MAX_VERTEX_MOVE_CELLS,
62    MIN_CELL_SIZE_LIMIT,
63};
64
65/// Hard upper bound on [`ScanOutlineOptions::max_gap`]: a closing radius that
66/// bridges more than half a metre starts to fuse rooms and erase corridors.
67pub const MAX_GAP_LIMIT: f64 = 0.5;
68
69/// Traced outline in plane coordinates.
70///
71/// Rings carry no duplicated closing vertex. Outer boundaries wind
72/// counter-clockwise, holes clockwise, so NonZero and EvenOdd fill agree.
73/// Rings are grouped like `ContourSet`: `shape_offsets[s]` is shape `s`'s outer
74/// ring and the rings up to the next offset are its holes.
75/// `#[non_exhaustive]`: a result type only this crate builds.
76#[derive(Clone, Debug, Default, PartialEq)]
77#[non_exhaustive]
78pub struct ScanOutline {
79    pub rings: Vec<Vec<[f64; 2]>>,
80    pub shape_offsets: Vec<usize>,
81    /// The ring directly containing each ring: a hole's outer boundary, or for
82    /// an outer boundary the hole it sits in (an island). `None` at top level.
83    pub parents: Vec<Option<usize>>,
84    pub diagnostics: ScanOutlineDiagnostics,
85}
86
87/// A plane in 3D: `world = origin + u * u_axis + v * v_axis`.
88#[derive(Clone, Copy, Debug, PartialEq)]
89pub struct PlaneFrame {
90    pub origin: [f64; 3],
91    pub u_axis: [f64; 3],
92    pub v_axis: [f64; 3],
93}
94
95impl PlaneFrame {
96    pub fn to_world(&self, p: [f64; 2]) -> [f64; 3] {
97        let (o, u, v) = (self.origin, self.u_axis, self.v_axis);
98        [
99            o[0] + p[0] * u[0] + p[1] * v[0],
100            o[1] + p[0] * u[1] + p[1] * v[1],
101            o[2] + p[0] * u[2] + p[1] * v[2],
102        ]
103    }
104}
105
106impl ScanOutline {
107    /// Every ring mapped into 3D through `frame`.
108    pub fn world_rings(&self, frame: &PlaneFrame) -> Vec<Vec<[f64; 3]>> {
109        self.rings
110            .iter()
111            .map(|ring| ring.iter().map(|p| frame.to_world(*p)).collect())
112            .collect()
113    }
114}
115
116/// Trace outlines from plane points given as flat `[u0, v0, u1, v1, …]`.
117///
118/// An odd trailing coordinate is ignored. Non-finite points are skipped and
119/// counted. Errors only on invalid options.
120pub fn trace_scan_outline(xy: &[f32], opts: &ScanOutlineOptions) -> Result<ScanOutline, String> {
121    opts.validate()?;
122    let mut diag = ScanOutlineDiagnostics {
123        input_points: xy.len() / 2,
124        ..Default::default()
125    };
126    let max_gap = if opts.max_gap > MAX_GAP_LIMIT {
127        diag.max_gap_clamped = true;
128        MAX_GAP_LIMIT
129    } else {
130        opts.max_gap
131    };
132
133    let Some(binned) = grid::bin_points(xy, opts, max_gap, &mut diag) else {
134        return Ok(ScanOutline { diagnostics: diag, ..Default::default() });
135    };
136    let geo = binned.geometry;
137    let close_radius = grid::close_radius_cells(max_gap, geo.cell);
138    let mut mask = binned.occupied;
139    morph::close(&mut mask, geo.width, geo.height, close_radius);
140    morph::open_by_reconstruction(&mut mask, geo.width, geo.height, opts.open_radius_cells);
141    morph::fill_saddles(&mut mask, geo.width, geo.height);
142    let cell_area = geo.cell * geo.cell;
143    let labels = morph::filter_components(
144        &mut mask,
145        geo.width,
146        geo.height,
147        area_cells(opts.min_component_area, cell_area),
148        area_cells(opts.min_hole_area, cell_area),
149    );
150    diag.components_dropped = labels.dropped;
151    diag.holes_filled = labels.filled;
152    diag.solid_cells = mask.iter().filter(|&&m| m != 0).count();
153
154    let traced = trace::trace_rings(&mask, &labels, &geo);
155    let lattice = traced.rings;
156    let parents = traced.parents;
157
158    let eps = opts.simplify_tolerance_cells * geo.cell;
159    let (mut rings, reinsertions) = simplify::simplify_rings(&lattice, &parents, eps);
160    diag.simplify_reinsertions = reinsertions;
161
162    let max_move = opts.max_vertex_move_cells * geo.cell;
163    // Moved edges must stay near the traced boundary; a rebuilt corner sits
164    // up to about half the closing radius off the rounded lattice corner.
165    let support_cells = opts.max_vertex_move_cells.max(0.5 * close_radius as f64) + 1.0;
166    let support = support::Support::new(&mask, &geo, support_cells);
167    if opts.snap {
168        let index = grid::PointIndex::build(xy, &geo);
169        let ctx = snap::SnapContext { index: &index, xy, cell: geo.cell, close_radius_cells: close_radius, opts };
170        let snapped = snap::snap_rings(&rings, &ctx);
171        diag.snapped_edges = snapped.fitted_edges;
172        // Merging and corner rebuilding change the vertex count: first make
173        // the rebuilt topology valid ring by ring, then repair the moves
174        // vertex by vertex against it.
175        let mut references = snapped.references;
176        let mut moved = snapped.rings;
177        diag.reverted_moves += topology::restore_invalid_rings(&mut references, &mut moved, &rings, &parents, Some(&support));
178        diag.reverted_moves += topology::repair_moves(&mut moved, &references, &parents, Some(&support));
179        rings = moved;
180    }
181    if opts.square {
182        let before = rings.clone();
183        let squared = square::square_rings(&rings, geo.cell, opts);
184        diag.dominant_angle_deg = squared.dominant_angle_deg;
185        let mut moved = squared.rings.clone();
186        diag.reverted_moves += cap_moves(&mut moved, &before, max_move);
187        diag.reverted_moves += topology::repair_moves(&mut moved, &before, &parents, Some(&support));
188        // A squaring counts only if neither end was moved back.
189        diag.squared_edges = surviving_squared_edges(&moved, &squared.rings, &squared.squared);
190        rings = moved;
191    }
192    topology::drop_collinear_vertices(&mut rings, &parents);
193
194    let (rings, shape_offsets, parents) = group_shapes(rings, &parents);
195    diag.ring_count = rings.len();
196    diag.outer_ring_count = shape_offsets.len();
197    diag.hole_ring_count = rings.len() - shape_offsets.len();
198    diag.vertex_count = rings.iter().map(Vec::len).sum();
199    Ok(ScanOutline { rings, shape_offsets, parents, diagnostics: diag })
200}
201
202fn area_cells(area: f64, cell_area: f64) -> usize {
203    (area / cell_area).ceil() as usize
204}
205
206/// Undo any single vertex move longer than `max_move`; returns how many.
207/// Rings whose vertex count changed (snapping merged edges) bound their own
208/// moves.
209fn cap_moves(moved: &mut [Vec<[f64; 2]>], before: &[Vec<[f64; 2]>], max_move: f64) -> usize {
210    let mut undone = 0;
211    for (ring, old) in moved.iter_mut().zip(before) {
212        if ring.len() != old.len() {
213            continue;
214        }
215        for (p, q) in ring.iter_mut().zip(old) {
216            let d = ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2)).sqrt();
217            if *p != *q && (!d.is_finite() || d > max_move) {
218                *p = *q;
219                undone += 1;
220            }
221        }
222    }
223    undone
224}
225
226/// Squared edges whose two ends are still where squaring put them.
227fn surviving_squared_edges(rings: &[Vec<[f64; 2]>], squared: &[Vec<[f64; 2]>], flags: &[Vec<bool>]) -> usize {
228    let mut count = 0;
229    for ((ring, target), flags) in rings.iter().zip(squared).zip(flags) {
230        let n = ring.len();
231        for (k, &flag) in flags.iter().enumerate() {
232            if flag && ring[k] == target[k] && ring[(k + 1) % n] == target[(k + 1) % n] {
233                count += 1;
234            }
235        }
236    }
237    count
238}
239
240/// Order rings shape by shape (outer, then its holes) and remap `parents`.
241fn group_shapes(
242    rings: Vec<Vec<[f64; 2]>>,
243    parents: &[Option<usize>],
244) -> (Vec<Vec<[f64; 2]>>, Vec<usize>, Vec<Option<usize>>) {
245    let is_outer: Vec<bool> = rings.iter().map(|r| polygon_area(r) > 0.0).collect();
246    let mut order = Vec::with_capacity(rings.len());
247    let mut offsets = Vec::new();
248    let mut holes_of: Vec<Vec<usize>> = vec![Vec::new(); rings.len()];
249    for (i, p) in parents.iter().enumerate() {
250        if !is_outer[i] {
251            if let Some(p) = p {
252                holes_of[*p].push(i);
253            }
254        }
255    }
256    for i in 0..rings.len() {
257        if is_outer[i] {
258            offsets.push(order.len());
259            order.push(i);
260            order.extend_from_slice(&holes_of[i]);
261        }
262    }
263    let mut new_index = vec![usize::MAX; rings.len()];
264    for (new, old) in order.iter().enumerate() {
265        new_index[*old] = new;
266    }
267    let new_parents = order.iter().map(|old| parents[*old].map(|p| new_index[p])).collect();
268    let mut slots: Vec<Option<Vec<[f64; 2]>>> = rings.into_iter().map(Some).collect();
269    let new_rings = order.iter().map(|old| slots[*old].take().unwrap_or_default()).collect();
270    (new_rings, offsets, new_parents)
271}