ifc_lite_geometry/scan_outline/
options.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//! Options (one struct, every tunable, with defaults) and diagnostics of the
6//! scan outline tracer.
7
8use serde::{Deserialize, Serialize};
9
10/// Largest cell budget accepted: about 64 M cells, a few hundred MB of
11/// working grids, which is what a 32-bit wasm heap can still afford.
12pub const MAX_CELLS_LIMIT: usize = 1 << 26;
13/// Finest cell accepted (metres). Finer cells only grow the radii (in cells)
14/// that the closing and the support check work in.
15pub const MIN_CELL_SIZE_LIMIT: f64 = 0.001;
16/// The snap band gathers `(2r + 3)²` cells per step along an edge.
17pub const MAX_SNAP_DISTANCE_CELLS: f64 = 16.0;
18/// The support check searches a disk of about this radius per edge sample.
19pub const MAX_VERTEX_MOVE_CELLS: f64 = 32.0;
20
21/// Every tunable of the tracer, with defaults sized for building scans in
22/// metres. Deserialises from camelCase with every field optional (absent
23/// means the default) and unknown fields refused, which is the shape the wasm
24/// binding accepts.
25#[derive(Clone, Debug, PartialEq, Deserialize)]
26#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
27pub struct ScanOutlineOptions {
28    /// Fixed cell edge length (metres). `None` picks it from the point density
29    /// between `min_cell_size` and `max_cell_size`.
30    pub cell_size: Option<f64>,
31    /// Finest cell the adaptive choice starts from.
32    pub min_cell_size: f64,
33    /// Coarsest cell the adaptive choice grows to.
34    pub max_cell_size: f64,
35    /// The adaptive choice grows the cell until the median occupied cell holds
36    /// at least this many points.
37    pub target_points_per_cell: f64,
38    /// Total cell budget for the padded grid. When the extent needs more, the
39    /// cell grows to fit and [`ScanOutlineDiagnostics::cell_cap_hit`] is set.
40    pub max_cells: usize,
41    /// A cell needs at least this many points to count as occupied.
42    pub min_points_per_cell: u32,
43    /// Noise threshold relative to density: a cell is occupied when its count
44    /// reaches `noise_fraction` × the median occupied-cell count (and
45    /// `min_points_per_cell`).
46    pub noise_fraction: f64,
47    /// Widest gap (metres) the closing bridges, about one wall thickness.
48    /// Clamped to [`MAX_GAP_LIMIT`](super::MAX_GAP_LIMIT).
49    pub max_gap: f64,
50    /// Radius (cells) of the speckle-removing opening. A connected blob that
51    /// cannot hold a disk of this radius anywhere is dropped; anything that can
52    /// is kept whole. `0` disables it.
53    pub open_radius_cells: u32,
54    /// Solid components smaller than this (m²) are dropped.
55    pub min_component_area: f64,
56    /// Enclosed empty regions smaller than this (m²) are filled.
57    pub min_hole_area: f64,
58    /// Douglas-Peucker tolerance in cells.
59    pub simplify_tolerance_cells: f64,
60    /// Refit simplified edges to the point evidence.
61    pub snap: bool,
62    /// How far (cells) beside an edge points count as its evidence.
63    pub snap_distance_cells: f64,
64    /// Fewest points an edge needs before it is refitted.
65    pub min_snap_points: usize,
66    /// A squared vertex may move at most this far (cells); a larger move is
67    /// undone. It also widens how far a moved edge may sit from the traced
68    /// boundary (the support check). Snapping bounds its own moves by the
69    /// closing radius (rebuilt corners reach about `2 × (radius + 3)` cells).
70    pub max_vertex_move_cells: f64,
71    /// Square edges against the dominant building direction.
72    pub square: bool,
73    /// Edges within this angle (degrees) of the dominant direction, or its
74    /// perpendicular, are squared.
75    pub square_angle_tolerance_deg: f64,
76    /// An edge whose ends would move at most this far (metres) when squared is
77    /// squared too, up to four times the angle tolerance.
78    pub square_offset_tolerance: f64,
79}
80
81impl Default for ScanOutlineOptions {
82    fn default() -> Self {
83        Self {
84            cell_size: None,
85            min_cell_size: 0.02,
86            max_cell_size: 0.05,
87            target_points_per_cell: 6.0,
88            max_cells: 16 * 1024 * 1024,
89            min_points_per_cell: 1,
90            noise_fraction: 0.25,
91            max_gap: 0.3,
92            open_radius_cells: 1,
93            min_component_area: 0.02,
94            min_hole_area: 0.5,
95            simplify_tolerance_cells: 1.5,
96            snap: true,
97            snap_distance_cells: 3.0,
98            min_snap_points: 8,
99            max_vertex_move_cells: 4.0,
100            square: true,
101            square_angle_tolerance_deg: 3.0,
102            square_offset_tolerance: 0.03,
103        }
104    }
105}
106
107impl ScanOutlineOptions {
108    /// Smallest `max_cells` these options accept: even at the coarsest cell
109    /// (one cell over the whole extent, closing radius one cell) the grid
110    /// keeps its padding on every side, so it needs `(2 * pad + 1)²` cells.
111    pub fn min_cells(&self) -> usize {
112        let pad = usize::from(self.max_gap > 0.0) + self.open_radius_cells as usize + 2;
113        (2 * pad + 1).pow(2).max(64)
114    }
115
116    /// Reject options no run could honour (non-finite, non-positive sizes,
117    /// inverted ranges). `max_gap` above [`MAX_GAP_LIMIT`](super::MAX_GAP_LIMIT) is not an error; it
118    /// is clamped and reported.
119    pub fn validate(&self) -> Result<(), String> {
120        let positive = |name: &str, v: f64| -> Result<(), String> {
121            if v.is_finite() && v > 0.0 {
122                Ok(())
123            } else {
124                Err(format!("{name} must be a finite number > 0, got {v}"))
125            }
126        };
127        let non_negative = |name: &str, v: f64| -> Result<(), String> {
128            if v.is_finite() && v >= 0.0 {
129                Ok(())
130            } else {
131                Err(format!("{name} must be a finite number >= 0, got {v}"))
132            }
133        };
134        if let Some(c) = self.cell_size {
135            positive("cellSize", c)?;
136        }
137        positive("minCellSize", self.min_cell_size)?;
138        if self.min_cell_size < MIN_CELL_SIZE_LIMIT {
139            return Err(format!("minCellSize must be >= {MIN_CELL_SIZE_LIMIT}, got {}", self.min_cell_size));
140        }
141        if let Some(c) = self.cell_size.filter(|c| *c < MIN_CELL_SIZE_LIMIT) {
142            return Err(format!("cellSize must be >= {MIN_CELL_SIZE_LIMIT}, got {c}"));
143        }
144        positive("maxCellSize", self.max_cell_size)?;
145        if self.max_cell_size < self.min_cell_size {
146            return Err(format!(
147                "maxCellSize ({}) must be >= minCellSize ({})",
148                self.max_cell_size, self.min_cell_size
149            ));
150        }
151        positive("targetPointsPerCell", self.target_points_per_cell)?;
152        if self.open_radius_cells > 64 {
153            return Err(format!("openRadiusCells must be <= 64, got {}", self.open_radius_cells));
154        }
155        let min_cells = self.min_cells();
156        if !(min_cells..=MAX_CELLS_LIMIT).contains(&self.max_cells) {
157            return Err(format!("maxCells must be in {min_cells}..={MAX_CELLS_LIMIT}, got {}", self.max_cells));
158        }
159        non_negative("noiseFraction", self.noise_fraction)?;
160        non_negative("maxGap", self.max_gap)?;
161        non_negative("minComponentArea", self.min_component_area)?;
162        non_negative("minHoleArea", self.min_hole_area)?;
163        non_negative("simplifyToleranceCells", self.simplify_tolerance_cells)?;
164        positive("snapDistanceCells", self.snap_distance_cells)?;
165        if self.snap_distance_cells > MAX_SNAP_DISTANCE_CELLS {
166            return Err(format!("snapDistanceCells must be <= {MAX_SNAP_DISTANCE_CELLS}, got {}", self.snap_distance_cells));
167        }
168        non_negative("maxVertexMoveCells", self.max_vertex_move_cells)?;
169        if self.max_vertex_move_cells > MAX_VERTEX_MOVE_CELLS {
170            return Err(format!("maxVertexMoveCells must be <= {MAX_VERTEX_MOVE_CELLS}, got {}", self.max_vertex_move_cells));
171        }
172        non_negative("squareAngleToleranceDeg", self.square_angle_tolerance_deg)?;
173        if self.square_angle_tolerance_deg >= 45.0 {
174            return Err(format!(
175                "squareAngleToleranceDeg must be < 45, got {}",
176                self.square_angle_tolerance_deg
177            ));
178        }
179        non_negative("squareOffsetTolerance", self.square_offset_tolerance)?;
180        Ok(())
181    }
182}
183
184/// What the run did, for the UI and for judging a result. Serialises to
185/// camelCase for the wasm binding.
186/// `#[non_exhaustive]`: diagnostics grow as the tracer does, and only this
187/// crate builds them.
188#[derive(Clone, Debug, Default, PartialEq, Serialize)]
189#[serde(rename_all = "camelCase")]
190#[non_exhaustive]
191pub struct ScanOutlineDiagnostics {
192    /// Points passed in (pairs of coordinates).
193    pub input_points: usize,
194    /// Points binned: finite and inside the robust extent.
195    pub used_points: usize,
196    /// Non-finite points skipped.
197    pub non_finite_points: usize,
198    /// Finite points outside the robust extent (far outliers) skipped.
199    pub outlier_points: usize,
200    /// Cell edge length used (metres).
201    pub cell_size: f64,
202    /// Grid size in cells, padding included.
203    pub grid_width: usize,
204    pub grid_height: usize,
205    /// The cell budget forced a coarser cell than asked for.
206    pub cell_cap_hit: bool,
207    /// Spacing of adjacent f32 values at the largest input coordinate: the
208    /// input cannot resolve anything finer (same idea and name as the scan
209    /// segmentation report, #6876).
210    pub coordinate_spacing_metres: f64,
211    /// That spacing exceeds a tenth of a cell: the outline follows f32 steps,
212    /// not the scan. Pass coordinates local to the slab.
213    pub coordinate_precision_degraded: bool,
214    /// `max_gap` was above [`MAX_GAP_LIMIT`](super::MAX_GAP_LIMIT) and was clamped.
215    pub max_gap_clamped: bool,
216    /// Points a cell needed to count as occupied.
217    pub count_threshold: u32,
218    /// Cells occupied after thresholding, before morphology.
219    pub occupied_cells: usize,
220    /// Cells solid after morphology and filtering.
221    pub solid_cells: usize,
222    /// Solid components dropped as too small.
223    pub components_dropped: usize,
224    /// Enclosed holes filled as too small.
225    pub holes_filled: usize,
226    pub ring_count: usize,
227    pub outer_ring_count: usize,
228    pub hole_ring_count: usize,
229    pub vertex_count: usize,
230    /// Vertices put back by the simplifier to keep rings disjoint.
231    pub simplify_reinsertions: usize,
232    /// Edges refitted to the point evidence.
233    pub snapped_edges: usize,
234    /// Dominant building direction, degrees in `[0, 90)`, when one was found.
235    #[serde(skip_serializing_if = "Option::is_none")]
236    pub dominant_angle_deg: Option<f64>,
237    /// Edges rotated onto the dominant direction or its perpendicular.
238    pub squared_edges: usize,
239    /// Vertex moves (snap or square) undone because they broke a ring.
240    pub reverted_moves: usize,
241}