ifc_lite_processing/scan_segmentation/
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//! Every tunable of scan segmentation, with documented defaults and bounds.
6//!
7//! All coordinates (`scanner_position`, `region`) are in the frame of the
8//! supplied positions, in metres; `origin` is added to every output
9//! coordinate, so f32 decode-relative positions can report in their
10//! f64 source frame.
11use serde::{Deserialize, Serialize};
12
13/// Hard bounds, not options: they keep the integer voxel sums exact.
14pub(crate) const MAX_POINTS: u64 = 100_000_000;
15/// Points farther than this from the positions' frame origin are rejected
16/// (f32 positions lose centimetre precision beyond about 1e5 m anyway).
17pub(crate) const MAX_ABS_COORDINATE: f64 = 1e6;
18/// The voxel budget may double the voxel size at most this many times.
19pub(crate) const MAX_COARSENINGS: u32 = 16;
20
21/// Axis-aligned crop box in the positions' frame (inclusive).
22#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)]
23#[serde(rename_all = "camelCase", deny_unknown_fields)]
24pub struct ScanRegion {
25    pub min: [f64; 3],
26    pub max: [f64; 3],
27}
28
29#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
30#[serde(rename_all = "camelCase", deny_unknown_fields, default)]
31pub struct ScanSegmentationOptions {
32    /// Edge of the averaging voxel. Default 0.03 m; 0.005..=1.
33    pub voxel_size_metres: f64,
34    /// Voxel budget: past it the voxel size doubles and existing voxels fold
35    /// into the coarser lattice (exactly). Default 1,500,000; 1,000..=8,000,000.
36    /// Working memory is roughly 180 bytes per voxel: the 8,000,000 maximum
37    /// with two normal rings measured 1.42 GB native RSS, too much for a
38    /// browser tab, so wasm callers should stay near the default.
39    pub max_voxels: u32,
40    /// Neighbourhood for voxel normals: 1 = the 26 neighbours, 2 = 124.
41    /// Default 1.
42    pub normal_neighbor_rings: u32,
43    /// Occupied voxels (including the voxel itself) a normal needs. Default 6.
44    pub min_neighbors: u32,
45    /// Seeds are voxels whose curvature (smallest eigenvalue / eigenvalue sum
46    /// of the neighbourhood scatter) is at most this, or at most the curvature
47    /// of the flattest 5 % of voxels when that is larger (noise of 0.4 voxel
48    /// edges lifts even flat surfaces above 0.02). Default 0.02.
49    pub max_seed_curvature: f64,
50    /// A voxel joins a region when its normal is within this of the region's
51    /// plane; also the coplanar-merge angle. Default 10 degrees; (0, 45].
52    pub max_normal_angle_degrees: f64,
53    /// ... and its mean lies within this of the region's plane; also the
54    /// coplanar-merge offset. Default 0.02 m.
55    pub max_plane_distance_metres: f64,
56    /// Robust refit keeps voxels within `mad_scale * 1.4826 * MAD` of the
57    /// median residual, never tighter than half `max_plane_distance_metres`.
58    /// Default 2.5.
59    pub mad_scale: f64,
60    /// A region whose voxel normals turn faster than this across it (1 /
61    /// radius of curvature) is a curved surface such as a column, not a plane.
62    /// Default 1.0 per metre. Wider columns grow as strips of planes, which
63    /// rings of planes about a common axis reassemble (#6893).
64    pub max_bend_per_metre: f64,
65    /// Planes with a smaller estimated area are dropped. Default 0.25 m^2.
66    pub min_plane_area_square_metres: f64,
67    /// Horizontal within this of `up_axis`, vertical within this of
68    /// perpendicular, sloped otherwise. Default 10 degrees.
69    pub classification_angle_degrees: f64,
70    /// Up direction of the positions' frame. Default +Z ([0, 0, 1]); the
71    /// viewer's Y-up reservoir sample passes [0, 1, 0].
72    pub up_axis: [f64; 3],
73    /// Scanner station: when given, every plane normal faces it.
74    pub scanner_position: Option<[f64; 3]>,
75    /// Added to every output coordinate. Default [0, 0, 0].
76    pub origin: [f64; 3],
77    /// Only points inside this box take part.
78    pub region: Option<ScanRegion>,
79    /// At most this many planes are reported, largest first. Default 10,000.
80    pub max_planes: u32,
81    /// Look for cylinders (columns, pipes) among the non-planar voxels.
82    /// Default true.
83    pub detect_cylinders: bool,
84    /// Accepted radius range. The minimum defaults to two voxel edges (after
85    /// any coarsening; 0.06 m at the default voxel): below that a
86    /// circumference has too few voxels to carry its curvature and radii come
87    /// out biased (a half-visible r 0.05 m pipe fitted r 0.0685). The maximum
88    /// defaults to 2 m (1.5 m before #6893, which refused about half the fits
89    /// of a column of exactly 1.5 m for a millimetre over the bound).
90    pub min_cylinder_radius_metres: Option<f64>,
91    pub max_cylinder_radius_metres: f64,
92    /// A group must fit a candidate with at least this share of its voxels.
93    /// Default 0.6.
94    pub min_cylinder_inlier_fraction: f64,
95    /// Refuse cylinders whose inliers cover less of the circumference.
96    /// Default 90 degrees.
97    pub min_cylinder_arc_degrees: f64,
98    /// Refuse cylinders shorter than this along the axis. Default 0.3 m.
99    pub min_cylinder_length_metres: f64,
100    /// RANSAC draws per candidate. Default 256; 1..=4,096.
101    pub cylinder_draws: u32,
102    /// Voxels each draw is scored on (an evenly strided subsample).
103    /// Default 2,048; 16..=65,536.
104    pub cylinder_score_sample: u32,
105    /// Largest non-planar groups examined for cylinders. Default 1,024.
106    pub max_cylinder_groups: u32,
107}
108
109impl Default for ScanSegmentationOptions {
110    fn default() -> Self {
111        Self {
112            voxel_size_metres: 0.03,
113            max_voxels: 1_500_000,
114            normal_neighbor_rings: 1,
115            min_neighbors: 6,
116            max_seed_curvature: 0.02,
117            max_normal_angle_degrees: 10.,
118            max_plane_distance_metres: 0.02,
119            mad_scale: 2.5,
120            max_bend_per_metre: 1.,
121            min_plane_area_square_metres: 0.25,
122            classification_angle_degrees: 10.,
123            up_axis: [0., 0., 1.],
124            scanner_position: None,
125            origin: [0.; 3],
126            region: None,
127            max_planes: 10_000,
128            detect_cylinders: true,
129            min_cylinder_radius_metres: None,
130            max_cylinder_radius_metres: 2.,
131            min_cylinder_inlier_fraction: 0.6,
132            min_cylinder_arc_degrees: 90.,
133            min_cylinder_length_metres: 0.3,
134            cylinder_draws: 256,
135            cylinder_score_sample: 2_048,
136            max_cylinder_groups: 1_024,
137        }
138    }
139}
140
141/// Validated options with derived constants.
142#[derive(Debug, Clone)]
143pub(crate) struct Params {
144    pub base_size_quanta: i64,
145    pub max_voxels: usize,
146    pub rings: i32,
147    pub min_support: usize,
148    pub max_seed_curvature: f64,
149    pub cos_angle: f64,
150    pub distance: f64,
151    pub mad_scale: f64,
152    pub max_bend: f64,
153    pub min_area: f64,
154    pub cos_class: f64,
155    pub sin_class: f64,
156    pub up: [f64; 3],
157    pub scanner: Option<[f64; 3]>,
158    pub origin: [f64; 3],
159    pub region: Option<ScanRegion>,
160    pub max_planes: usize,
161    pub cylinders: Option<CylinderParams>,
162}
163
164#[derive(Debug, Clone)]
165pub(crate) struct CylinderParams {
166    /// None: two voxel edges.
167    pub min_radius: Option<f64>,
168    pub max_radius: f64,
169    pub min_fraction: f64,
170    pub min_arc: f64,
171    pub min_length: f64,
172    pub draws: usize,
173    pub sample: usize,
174    pub max_groups: usize,
175}
176
177fn within(value: f64, lo: f64, hi: f64) -> bool {
178    value.is_finite() && value >= lo && value <= hi
179}
180
181/// `lo < value <= hi`; false for NaN.
182fn above(value: f64, lo: f64, hi: f64) -> bool {
183    value > lo && value <= hi
184}
185
186impl ScanSegmentationOptions {
187    pub(crate) fn validate(&self) -> Result<Params, String> {
188        let o = self;
189        let fail = |what: &str| Err(format!("Scan segmentation option {what}"));
190        if !within(o.voxel_size_metres, 0.005, 1.) {
191            return fail("voxelSizeMetres must be within 0.005..=1");
192        }
193        if !(1_000..=8_000_000).contains(&o.max_voxels) {
194            return fail("maxVoxels must be within 1,000..=8,000,000");
195        }
196        let rings = o.normal_neighbor_rings;
197        let neighbourhood = (2 * rings + 1).pow(3);
198        if !(1..=2).contains(&rings) || o.min_neighbors < 4 || o.min_neighbors > neighbourhood {
199            return fail("normalNeighborRings must be 1 or 2 and minNeighbors 4..=(2*rings+1)^3");
200        }
201        if !above(o.max_seed_curvature, 0., 1. / 3.) {
202            return fail("maxSeedCurvature must be within (0, 1/3]");
203        }
204        if !above(o.max_normal_angle_degrees, 0., 45.) || !above(o.classification_angle_degrees, 0., 45.) {
205            return fail("angles must be within (0, 45] degrees");
206        }
207        if !above(o.max_plane_distance_metres, 0., 1.)
208            || !above(o.mad_scale, 0., 10.)
209            || !above(o.max_bend_per_metre, 0., 1e3)
210            || !within(o.min_plane_area_square_metres, 0., 1e6)
211        {
212            return fail("distance (0, 1] m, madScale (0, 10], maxBendPerMetre (0, 1000] and area 0..=1e6 m^2 are required");
213        }
214        let up_length = o.up_axis.iter().map(|v| v * v).sum::<f64>().sqrt();
215        if !up_length.is_finite() || up_length < 1e-9 {
216            return fail("upAxis must be a finite non-zero vector");
217        }
218        let bounded = |p: &[f64; 3], limit: f64| p.iter().all(|v| within(*v, -limit, limit));
219        if o.scanner_position.is_some_and(|p| !bounded(&p, MAX_ABS_COORDINATE)) || !bounded(&o.origin, 1e12) {
220            return fail("scannerPosition must lie within 1e6 m and origin within 1e12 m");
221        }
222        if let Some(r) = o.region {
223            if !bounded(&r.min, 1e12) || !bounded(&r.max, 1e12) || (0..3).any(|a| r.min[a] > r.max[a]) {
224                return fail("region needs finite min <= max");
225            }
226        }
227        if !(1..=100_000).contains(&o.max_planes) {
228            return fail("maxPlanes must be within 1..=100,000");
229        }
230        let cylinders = o.cylinder_params()?;
231        let class = o.classification_angle_degrees.to_radians();
232        Ok(Params {
233            base_size_quanta: (o.voxel_size_metres * super::voxel::QUANTA_PER_METRE).round() as i64,
234            max_voxels: o.max_voxels as usize,
235            rings: rings as i32,
236            min_support: o.min_neighbors as usize,
237            max_seed_curvature: o.max_seed_curvature,
238            cos_angle: o.max_normal_angle_degrees.to_radians().cos(),
239            distance: o.max_plane_distance_metres,
240            mad_scale: o.mad_scale,
241            max_bend: o.max_bend_per_metre,
242            min_area: o.min_plane_area_square_metres,
243            cos_class: class.cos(),
244            sin_class: class.sin(),
245            up: o.up_axis.map(|v| v / up_length),
246            scanner: o.scanner_position,
247            origin: o.origin,
248            region: o.region,
249            max_planes: o.max_planes as usize,
250            cylinders,
251        })
252    }
253
254    fn cylinder_params(&self) -> Result<Option<CylinderParams>, String> {
255        let o = self;
256        if !o.detect_cylinders {
257            return Ok(None);
258        }
259        let min = o.min_cylinder_radius_metres.unwrap_or(0.005);
260        if !within(min, 0.005, 10.) || !within(o.max_cylinder_radius_metres, min, 10.) {
261            return Err("Scan segmentation option cylinder radii must satisfy 0.005 <= min <= max <= 10 m".into());
262        }
263        if !above(o.min_cylinder_inlier_fraction, 0., 1.)
264            || !within(o.min_cylinder_arc_degrees, 0., 360.)
265            || !within(o.min_cylinder_length_metres, 0., 1e3)
266        {
267            return Err("Scan segmentation option cylinder fraction (0, 1], arc 0..=360 and length 0..=1000 m are required".into());
268        }
269        if !(1..=4_096).contains(&o.cylinder_draws)
270            || !(16..=65_536).contains(&o.cylinder_score_sample)
271            || !(1..=100_000).contains(&o.max_cylinder_groups)
272        {
273            return Err("Scan segmentation option cylinderDraws 1..=4,096, cylinderScoreSample 16..=65,536 and maxCylinderGroups 1..=100,000 are required".into());
274        }
275        Ok(Some(CylinderParams {
276            min_radius: o.min_cylinder_radius_metres,
277            max_radius: o.max_cylinder_radius_metres,
278            min_fraction: o.min_cylinder_inlier_fraction,
279            min_arc: o.min_cylinder_arc_degrees,
280            min_length: o.min_cylinder_length_metres,
281            draws: o.cylinder_draws as usize,
282            sample: o.cylinder_score_sample as usize,
283            max_groups: o.max_cylinder_groups as usize,
284        }))
285    }
286}