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;