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}