ifc_lite_processing/scan_proposals/
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 element proposals, with documented defaults and
6//! bounds. Lengths are metres in the model frame (after `scanToModel`).
7use serde::{Deserialize, Serialize};
8
9/// Schema of the model the proposals are for: it decides the pipe class.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
11pub enum ProposalSchema {
12    #[serde(rename = "IFC2X3")]
13    Ifc2x3,
14    #[serde(rename = "IFC4")]
15    Ifc4,
16    #[serde(rename = "IFC4X3")]
17    Ifc4x3,
18}
19
20#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
21#[serde(rename_all = "camelCase", deny_unknown_fields, default)]
22pub struct ScanProposalOptions {
23    /// Row-major 4x4 similarity (rotation, uniform scale, translation) from
24    /// the frame the segmentation report is in to the IFC model frame
25    /// (Z up, metres). Default identity: the report is already in it.
26    pub scan_to_model: [f64; 16],
27    /// Default `IFC4`. Non-vertical cylinders become `IfcFlowSegment` in
28    /// IFC2X3 (no `IfcPipeSegment` there) and `IfcPipeSegment` otherwise.
29    pub schema: ProposalSchema,
30    /// Horizontal / vertical tolerance in the model frame. Default 10 degrees.
31    pub classification_angle_degrees: f64,
32    /// Two wall faces pair when parallel within this. Default 5 degrees.
33    pub pairing_angle_degrees: f64,
34    /// Paired faces make one wall when at least this far apart (a smaller gap is
35    /// refused). Default 0.05 m.
36    pub min_wall_thickness_metres: f64,
37    /// ... and at most this far apart. Default 0.6 m.
38    pub max_wall_thickness_metres: f64,
39    /// Thickness of a wall seen from one side only. Default 0.2 m.
40    pub default_wall_thickness_metres: f64,
41    /// Faces must overlap along the wall by at least this share of the
42    /// shorter face to pair. Default 0.5.
43    pub min_wall_overlap_fraction: f64,
44    /// Vertical faces shorter (horizontally) are not walls. Default 0.5 m.
45    pub min_wall_length_metres: f64,
46    /// Vertical faces lower than this are not walls. Default 1 m.
47    pub min_wall_height_metres: f64,
48    /// Horizontal planes smaller than this are not slabs. Default 1 m^2.
49    pub min_slab_area_square_metres: f64,
50    /// Thickness of a slab seen from one side only. Default 0.2 m.
51    pub default_slab_thickness_metres: f64,
52    /// A ceiling and the floor above pair when this close (and at least
53    /// `minWallThicknessMetres` apart). Default 0.6 m.
54    pub max_slab_thickness_metres: f64,
55    /// Wall and column ends within this of a floor or ceiling extend to it;
56    /// half of it decides which side of a horizontal plane walls stand on.
57    /// Default 0.3 m.
58    pub level_snap_metres: f64,
59    /// A point inside the scanned space (model frame). Places single wall
60    /// faces whose normal does not face the scanner, and decides floor or
61    /// ceiling when no wall touches a horizontal plane. Default: the
62    /// area-weighted mean of the plane centroids.
63    pub interior_point: Option<[f64; 3]>,
64}
65
66impl Default for ScanProposalOptions {
67    fn default() -> Self {
68        Self {
69            scan_to_model: [1., 0., 0., 0., 0., 1., 0., 0., 0., 0., 1., 0., 0., 0., 0., 1.],
70            schema: ProposalSchema::Ifc4,
71            classification_angle_degrees: 10.,
72            pairing_angle_degrees: 5.,
73            min_wall_thickness_metres: 0.05,
74            max_wall_thickness_metres: 0.6,
75            default_wall_thickness_metres: 0.2,
76            min_wall_overlap_fraction: 0.5,
77            min_wall_length_metres: 0.5,
78            min_wall_height_metres: 1.,
79            min_slab_area_square_metres: 1.,
80            default_slab_thickness_metres: 0.2,
81            max_slab_thickness_metres: 0.6,
82            level_snap_metres: 0.3,
83            interior_point: None,
84        }
85    }
86}
87
88/// Validated options with derived constants.
89#[derive(Debug, Clone)]
90pub(crate) struct Params {
91    pub schema: ProposalSchema,
92    pub sin_class: f64,
93    pub cos_class: f64,
94    pub cos_pair: f64,
95    pub min_thickness: f64,
96    pub max_thickness: f64,
97    pub wall_thickness: f64,
98    pub min_overlap: f64,
99    pub min_wall_length: f64,
100    pub min_wall_height: f64,
101    pub min_slab_area: f64,
102    pub slab_thickness: f64,
103    pub max_slab_thickness: f64,
104    pub snap: f64,
105    pub interior: Option<[f64; 3]>,
106}
107
108fn positive(value: f64, hi: f64) -> bool {
109    value > 0. && value <= hi
110}
111
112/// `lo <= value <= hi`; false for NaN.
113fn between(value: f64, lo: f64, hi: f64) -> bool {
114    value >= lo && value <= hi
115}
116
117impl ScanProposalOptions {
118    pub(crate) fn validate(&self) -> Result<Params, String> {
119        let o = self;
120        let fail = |what: &str| Err(format!("Scan proposal option {what}"));
121        if !positive(o.classification_angle_degrees, 45.) || !positive(o.pairing_angle_degrees, 45.) {
122            return fail("angles must be within (0, 45] degrees");
123        }
124        if !positive(o.min_wall_thickness_metres, 10.)
125            || !between(o.max_wall_thickness_metres, o.min_wall_thickness_metres, 10.)
126            || !positive(o.default_wall_thickness_metres, 10.)
127            || !positive(o.default_slab_thickness_metres, 10.)
128            || !between(o.max_slab_thickness_metres, o.min_wall_thickness_metres, 10.)
129        {
130            return fail("thicknesses must be within (0, 10] m with min <= max");
131        }
132        if !positive(o.min_wall_overlap_fraction, 1.) {
133            return fail("minWallOverlapFraction must be within (0, 1]");
134        }
135        if !between(o.min_wall_length_metres, 0., 1e3)
136            || !between(o.min_wall_height_metres, 0., 1e3)
137            || !between(o.min_slab_area_square_metres, 0., 1e6)
138            || !between(o.level_snap_metres, 0., 10.)
139        {
140            return fail("minimum sizes and levelSnapMetres must be finite and non-negative");
141        }
142        if o.interior_point.is_some_and(|p| p.iter().any(|v| !v.is_finite() || v.abs() > 1e12)) {
143            return fail("interiorPoint must lie within 1e12 m");
144        }
145        let class = o.classification_angle_degrees.to_radians();
146        Ok(Params {
147            schema: o.schema,
148            sin_class: class.sin(),
149            cos_class: class.cos(),
150            cos_pair: o.pairing_angle_degrees.to_radians().cos(),
151            min_thickness: o.min_wall_thickness_metres,
152            max_thickness: o.max_wall_thickness_metres,
153            wall_thickness: o.default_wall_thickness_metres,
154            min_overlap: o.min_wall_overlap_fraction,
155            min_wall_length: o.min_wall_length_metres,
156            min_wall_height: o.min_wall_height_metres,
157            min_slab_area: o.min_slab_area_square_metres,
158            slab_thickness: o.default_slab_thickness_metres,
159            max_slab_thickness: o.max_slab_thickness_metres,
160            snap: o.level_snap_metres,
161            interior: o.interior_point,
162        })
163    }
164}