ifc_lite_processing/scan_proposals/
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//! Scan-to-BIM element proposals (#6894): planes and cylinders from
6//! `scan_segmentation` become proposed walls, slabs, columns and pipes in
7//! the IFC model frame, each with a confidence, its source detections and
8//! their fit statistics. Nothing is created here; the viewer reviews the
9//! proposals and creates the accepted ones through its modelling commands.
10//!
11//! 1. `frame`: every detection moves into the model frame (Z up, metres)
12//!    through the `scanToModel` similarity; planes are reclassified there.
13//! 2. `levels`: horizontal planes become floors or ceilings.
14//! 3. `walls`: opposite vertical faces pair into walls; single faces get a
15//!    default thickness behind their scanned side.
16//! 4. `slabs`: ceilings pair with the floor above; single ones get a
17//!    default thickness on their solid side.
18//! 5. `cylinders`: vertical axes are columns, others pipes.
19//!
20//! Confidence is described in `score`. Every stage is quadratic at worst in
21//! the plane count, which is bounded by `MAX_PLANES`.
22mod cylinders;
23mod frame;
24mod levels;
25mod options;
26mod report;
27mod score;
28mod slabs;
29mod walls;
30
31pub use options::{ProposalSchema, ScanProposalOptions};
32pub use report::{
33    DetectionKind, DetectionRef, ProposalBasis, ProposalClass, ProposalFit, ProposalGeometry,
34    ScanElementProposal, ScanProposalReport, ScanProposalStats,
35};
36
37use crate::scan_segmentation::ScanSegmentationReport;
38use frame::{Face, Similarity, V3};
39use score::Draft;
40
41const ALGORITHM: &str = "ifclite-scan-proposals-v1";
42/// Input bound: pairing is quadratic in the vertical plane count.
43pub const MAX_PLANES: usize = 20_000;
44pub const MAX_CYLINDERS: usize = 20_000;
45
46/// Propose IFC elements for the detections in `report`.
47pub fn propose_scan_elements(
48    report: &ScanSegmentationReport,
49    options: &ScanProposalOptions,
50) -> Result<ScanProposalReport, String> {
51    let params = options.validate()?;
52    if report.planes.len() > MAX_PLANES || report.cylinders.len() > MAX_CYLINDERS {
53        return Err(format!("Scan proposals accept at most {MAX_PLANES} planes and {MAX_CYLINDERS} cylinders"));
54    }
55    let transform = Similarity::from_row_major(&options.scan_to_model)?;
56    let detections = frame::to_model(report, &transform);
57    let faces = detections.faces;
58    let mut stats = ScanProposalStats::default();
59    let (mut vertical, mut horizontal) = (Vec::new(), Vec::new());
60    for (i, face) in faces.iter().enumerate() {
61        let up = face.normal[2].abs();
62        if up >= params.cos_class {
63            horizontal.push(i);
64        } else if up <= params.sin_class {
65            vertical.push(i);
66        } else {
67            stats.sloped_planes += 1;
68        }
69    }
70    stats.vertical_planes = vertical.len() as u32;
71    stats.horizontal_planes = horizontal.len() as u32;
72    let interior = params.interior.unwrap_or_else(|| area_weighted_centroid(&faces));
73    let levels = levels::classify(&faces, &horizontal, &vertical, interior, params.snap);
74
75    let walls = walls::propose(&faces, &vertical, &levels, interior, &params, &mut stats);
76    let slabs = slabs::propose(&faces, &levels, &params, &mut stats);
77    let tubes = cylinders::propose(&detections.tubes, &faces, &levels, &params, &mut stats);
78    let mut proposals = Vec::new();
79    number("wall", walls, &mut proposals);
80    number("slab", slabs, &mut proposals);
81    let (columns, pipes): (Vec<Draft>, Vec<Draft>) =
82        tubes.into_iter().partition(|d| d.class == ProposalClass::IfcColumn);
83    number("column", columns, &mut proposals);
84    number("pipe", pipes, &mut proposals);
85    Ok(ScanProposalReport { algorithm: ALGORITHM.into(), proposals, transform_scale: transform.scale, stats })
86}
87
88/// Order by descending support (ties by first source) and assign ids.
89fn number(prefix: &str, mut drafts: Vec<Draft>, out: &mut Vec<ScanElementProposal>) {
90    drafts.sort_by(|a, b| {
91        b.support.total_cmp(&a.support).then_with(|| a.sources.first().map(|s| s.index).cmp(&b.sources.first().map(|s| s.index)))
92    });
93    for (i, d) in drafts.into_iter().enumerate() {
94        out.push(ScanElementProposal {
95            id: format!("{prefix}-{i}"),
96            ifc_class: d.class,
97            confidence: d.confidence,
98            basis: d.basis,
99            sources: d.sources,
100            fit: d.fit,
101            geometry: d.geometry,
102        });
103    }
104}
105
106fn area_weighted_centroid(faces: &[Face]) -> V3 {
107    let total: f64 = faces.iter().map(|f| f.area).sum();
108    if total <= 0. {
109        return [0.; 3];
110    }
111    let mut sum = [0.; 3];
112    for f in faces {
113        for (s, c) in sum.iter_mut().zip(f.centroid) {
114            *s += c * f.area / total;
115        }
116    }
117    sum
118}
119
120#[cfg(test)]
121#[path = "proposals_tests.rs"]
122mod tests;