ifc_lite_export/dfjson/
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//! Serde structs + builder for the Dragonfly DFJSON model schema.
6//!
7//! Dragonfly (Ladybug Tools) represents a building as extruded 2D floor plates: each
8//! `Room2D` is a horizontal `floor_boundary` polygon plus a `floor_height` and a
9//! `floor_to_ceiling_height`. That maps directly onto an `IfcSpace`'s extruded-area
10//! profile, so for the common case of vertical walls it is a simpler, lossless target than
11//! the full Honeybee solid (recommended by Ladybug for mostly-vertical models).
12//!
13//! The floor footprint + heights come from the SAME analytic extraction the HBJSON room
14//! builder uses ([`crate::rooms::floor_profiles`]), so the two exports agree on where a
15//! footprint lands.
16//!
17//! They do NOT cover the same set of spaces, and that is by design rather than drift.
18//! Downstream of the shared extraction each builder applies its own admissibility rules,
19//! so measured on real models the counts differ in both directions:
20//!
21//! - DFJSON drops a space whose extrusion has (near-)zero vertical component — it fails the
22//!   `ftc <= tol` check below — because a tilted prism has no faithful `Room2D`. HBJSON
23//!   still emits a real solid for it (duplex.ifc: 19 HBJSON rooms vs 17 here).
24//! - DFJSON keeps a space that HBJSON's watertightness gate rejects, since a 2D plate has no
25//!   watertightness requirement to fail (rvt01.ifc: 46 HBJSON rooms vs 47 here).
26//!
27//! The duplicate-space pass IS shared in behaviour: [`plates::dedupe_colliding`] uses the
28//! same thresholds as [`crate::rooms`]'s, so a model carrying duplicated `IfcSpace`
29//! geometry (Revit does this) drops the same copies in both exports rather than
30//! double-counting floor area here.
31
32mod plates;
33mod schema;
34mod spatial;
35mod stories;
36
37use schema::{Building, Model, TypedProps};
38
39use ifc_lite_geometry::ExtractedProfile;
40
41use plates::{build_plates, dedupe_colliding};
42use schema::DF_VERSION;
43pub(crate) use spatial::spatial_index;
44pub(crate) use spatial::SpatialIndex;
45use stories::{build_stories, group_plates};
46
47/// Coverage stats for a DFJSON export.
48pub struct DfjsonStats {
49    /// `IfcSpace` profiles seen in the model.
50    pub spaces: usize,
51    /// Room2Ds emitted.
52    pub rooms: usize,
53    /// Spaces skipped as degenerate (malformed footprint / holes / non-extrusion).
54    pub skipped: usize,
55    /// Stories grouped by floor level.
56    pub stories: usize,
57}
58
59/// Build a Dragonfly [`Model`] from the `IfcSpace` profiles in `profiles`.
60///
61/// `spatial` carries the file's `IfcBuilding` / `IfcBuildingStorey` containment (issue
62/// #1911). Pass `None` — or an empty index, for a file that declares no spatial
63/// structure — to fall back to grouping stories by floor elevation and collapsing
64/// everything into one synthetic building.
65pub fn build_model(
66    identifier: &str,
67    profiles: &[ExtractedProfile],
68    tol: f64,
69    spatial: Option<&SpatialIndex>,
70) -> (Model, DfjsonStats) {
71    let spaces = profiles.iter().filter(|p| p.ifc_type == "IfcSpace").count();
72    let (plates, mut skipped) = build_plates(profiles, tol);
73    // Same duplicate-space pass HBJSON runs. Dropped duplicates count as skipped, so
74    // `spaces == rooms + skipped` still holds for callers reporting coverage.
75    let (plates, dropped) = dedupe_colliding(plates);
76    skipped += dropped;
77    let room_count = plates.len();
78    let groups = group_plates(plates, spatial);
79
80    // Partition the ordered stories by building, preserving order within each. Stories
81    // with no building (unplaced plates that fell back to elevation clustering, or a
82    // model with no spatial structure at all) go to the first building, so a plate is
83    // never dropped for want of a parent.
84    let mut buckets: Vec<(Option<u32>, Vec<stories::StoryGroup>)> = Vec::new();
85    let ordered_buildings: Vec<u32> = match spatial.filter(|s| !s.is_empty()) {
86        Some(s) => s.building_order.clone(),
87        None => Vec::new(),
88    };
89    for b in &ordered_buildings {
90        buckets.push((Some(*b), Vec::new()));
91    }
92    let mut orphans: Vec<stories::StoryGroup> = Vec::new();
93    for g in groups {
94        match g.building.and_then(|b| buckets.iter().position(|(id, _)| *id == Some(b))) {
95            Some(i) => buckets[i].1.push(g),
96            None => orphans.push(g),
97        }
98    }
99    if !orphans.is_empty() {
100        match buckets.first_mut() {
101            Some((_, first)) => {
102                first.extend(orphans);
103                // The bucket's own order is by story elevation, which `group_plates`
104                // already established globally; re-sorting keeps appended orphans in
105                // place rather than stacked on the end.
106                first.sort_by(|a, b| {
107                    let ka = a.plates.iter().map(|p| p.floor_height).fold(f64::MAX, f64::min);
108                    let kb = b.plates.iter().map(|p| p.floor_height).fold(f64::MAX, f64::min);
109                    ka.partial_cmp(&kb).unwrap_or(std::cmp::Ordering::Equal)
110                });
111            }
112            None => buckets.push((None, orphans)),
113        }
114    }
115
116    let mut n_stories = 0usize;
117    let mut buildings = Vec::new();
118    for (bi, (id, groups)) in buckets.into_iter().enumerate() {
119        if groups.is_empty() {
120            // A building whose storeys hold no exportable space would otherwise emit an
121            // empty `Building`, which Dragonfly reads as a real but roomless building.
122            continue;
123        }
124        let identifier = format!("Building_{}", bi + 1);
125        let display_name = id
126            .and_then(|b| spatial.and_then(|s| s.building_names.get(&b)).cloned())
127            .unwrap_or_else(|| format!("Building {}", bi + 1));
128        let unique_stories = build_stories(groups, &format!("B{}_", bi + 1));
129        n_stories += unique_stories.len();
130        buildings.push(Building {
131            ty: "Building",
132            identifier,
133            display_name,
134            properties: TypedProps::new("BuildingPropertiesAbridged"),
135            unique_stories,
136        });
137    }
138
139    let model = Model {
140        ty: "Model",
141        identifier: identifier.to_string(),
142        display_name: identifier.to_string(),
143        units: "Meters",
144        tolerance: tol,
145        angle_tolerance: 1.0,
146        properties: TypedProps::new("ModelProperties"),
147        buildings,
148        version: DF_VERSION,
149    };
150    let stats = DfjsonStats { spaces, rooms: room_count, skipped, stories: n_stories };
151    (model, stats)
152}
153
154#[cfg(test)]
155mod tests;