ifc_lite_processing/geometry_export.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//! Analysis-ready geometry-data export.
6//!
7//! A per-entity geometry dump distinct from the render-oriented GLB. Where the
8//! GLB is glTF Y-up, recentred, and vertex-duplicated for flat shading, this
9//! export is what an *analysis* consumer wants:
10//!
11//! - **IFC Z-up** (no Y-up rotation — we read [`MeshData`] before the wasm
12//! boundary applies it),
13//! - **absolute world coordinates** in metres: `vertex = position + origin +
14//! rtc_offset` (the per-element local-frame `origin` and the model `rtc_offset`
15//! are folded back in, and the offset is recorded so geo-referenced consumers
16//! can recover or re-localise),
17//! - **welded / indexed** triangles straight from the kernel mesh (the GLB's
18//! per-face duplication happens later, in the glTF exporter),
19//! - **occurrences only**: type-product RepresentationMap geometry
20//! (`geometry_class` 1 and 2) is omitted, matching what occurrence-based
21//! tessellators emit. A material-layer wall's slices (class 3,
22//! `GEOM_CLASS_LAYER_SLICE`) are that occurrence's own body and are kept.
23//!
24//! Keyed by IFC STEP/express id. Submeshes of one element (per-material splits)
25//! are merged into a single triangle soup per id. f64 throughout so building- and
26//! geo-referenced-scale coordinates keep full precision.
27
28use std::collections::BTreeMap;
29
30use serde::Serialize;
31
32use crate::MeshData;
33
34/// One IFC entity's merged geometry, in IFC Z-up absolute-world metres.
35#[derive(Debug, Clone, Serialize)]
36pub struct ExportedElement {
37 pub ifc_type: String,
38 #[serde(skip_serializing_if = "Option::is_none")]
39 pub global_id: Option<String>,
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub name: Option<String>,
42 /// Welded vertices, `[x, y, z]` triplets, IFC Z-up absolute world (metres).
43 pub vertices: Vec<[f64; 3]>,
44 /// Triangle indices into `vertices`.
45 pub faces: Vec<[u32; 3]>,
46 /// RGBA in 0..1 (first submesh's colour when an element has several).
47 pub color: [f32; 4],
48}
49
50/// Top-level geometry-data document. Serializes to the `ifc-lite-geometry-data`
51/// JSON contract.
52#[derive(Debug, Clone, Serialize)]
53pub struct GeometryDataExport {
54 pub schema: &'static str,
55 pub version: u32,
56 /// Vertical axis convention of `vertices`. Always `"Z"` (IFC native).
57 pub up_axis: &'static str,
58 /// Length unit of `vertices`. Always `"m"` (SI metres).
59 pub units: &'static str,
60 /// The RTC offset already folded into `vertices`. `[0,0,0]` for models near
61 /// the origin; non-zero for geo-referenced models (so a consumer can choose
62 /// to re-localise by subtracting it for f32-friendly local coordinates).
63 pub rtc_offset: [f64; 3],
64 pub element_count: usize,
65 /// Per-entity geometry, keyed by IFC STEP/express id (JSON object key is the
66 /// id as a string).
67 pub elements: BTreeMap<u32, ExportedElement>,
68}
69
70/// Build the geometry-data export from a processed model's meshes.
71///
72/// `rtc_offset` is `ProcessingResult.metadata.coordinate_info.origin_shift`.
73///
74/// `site_rotation` is the IfcSite placement (column-major 4x4) **only when the
75/// model was processed into the `site_local` coordinate space** — there the
76/// pipeline inverse-rotates positions + origin into site-local axes, so to emit
77/// true IFC world coordinates we reapply the forward 3x3 rotation:
78/// `world = R * (position + origin) + rtc_offset`. Pass `None` for the
79/// `model_rtc` / `raw_ifc` spaces (R = identity), which is the common case.
80pub fn build_geometry_data_export(
81 meshes: &[MeshData],
82 rtc_offset: [f64; 3],
83 site_rotation: Option<&[f64]>,
84) -> GeometryDataExport {
85 let mut elements: BTreeMap<u32, ExportedElement> = BTreeMap::new();
86 let rot = match site_rotation {
87 Some(m) if m.len() >= 16 => Some(m),
88 _ => None,
89 };
90
91 for m in meshes {
92 // Occurrences only: skip type-product RepresentationMap geometry. Class 3
93 // is a layered wall's body, which has no class-0 mesh to fall back on.
94 if matches!(m.geometry_class, 1 | 2) || m.indices.is_empty() {
95 continue;
96 }
97
98 let o = m.origin;
99 let verts: Vec<[f64; 3]> = m
100 .positions
101 .chunks_exact(3)
102 .map(|p| {
103 // World point in (possibly site-local) axes: position + origin.
104 let (x, y, z) = (p[0] as f64 + o[0], p[1] as f64 + o[1], p[2] as f64 + o[2]);
105 match rot {
106 // Reapply the site forward rotation (column-major R), then RTC.
107 Some(r) => [
108 r[0] * x + r[4] * y + r[8] * z + rtc_offset[0],
109 r[1] * x + r[5] * y + r[9] * z + rtc_offset[1],
110 r[2] * x + r[6] * y + r[10] * z + rtc_offset[2],
111 ],
112 None => [x + rtc_offset[0], y + rtc_offset[1], z + rtc_offset[2]],
113 }
114 })
115 .collect();
116
117 let entry = elements
118 .entry(m.express_id)
119 .or_insert_with(|| ExportedElement {
120 ifc_type: m.ifc_type.clone(),
121 global_id: m.global_id.clone(),
122 name: m.name.clone(),
123 vertices: Vec::new(),
124 faces: Vec::new(),
125 color: m.color,
126 });
127
128 // Merge this submesh: rebase its face indices onto the element's
129 // accumulated vertex list.
130 let base = entry.vertices.len() as u32;
131 entry.vertices.extend_from_slice(&verts);
132 entry.faces.extend(
133 m.indices
134 .chunks_exact(3)
135 .map(|t| [t[0] + base, t[1] + base, t[2] + base]),
136 );
137 }
138
139 // Position-weld each element. The kernel mesh splits vertices per face (for
140 // flat-shading normals), so coincident corners aren't shared and the mesh
141 // reads as "open". Merging by position (1 um grid) yields a properly
142 // indexed solid so closed-mesh consumers (volume, watertightness) work.
143 for el in elements.values_mut() {
144 let (v, f) = weld_positions(&el.vertices, &el.faces, 1.0e-6);
145 el.vertices = v;
146 el.faces = f;
147 }
148
149 let element_count = elements.len();
150 GeometryDataExport {
151 schema: "ifc-lite-geometry-data",
152 version: 1,
153 up_axis: "Z",
154 units: "m",
155 rtc_offset,
156 element_count,
157 elements,
158 }
159}
160
161/// Merge coincident vertices on a `1/eps` grid and remap faces, dropping any
162/// triangle that collapses to a degenerate after the merge.
163fn weld_positions(
164 verts: &[[f64; 3]],
165 faces: &[[u32; 3]],
166 eps: f64,
167) -> (Vec<[f64; 3]>, Vec<[u32; 3]>) {
168 let inv = 1.0 / eps;
169 let key = |v: &[f64; 3]| -> (i64, i64, i64) {
170 (
171 (v[0] * inv).round() as i64,
172 (v[1] * inv).round() as i64,
173 (v[2] * inv).round() as i64,
174 )
175 };
176 let mut map: BTreeMap<(i64, i64, i64), u32> = BTreeMap::new();
177 let mut out_verts: Vec<[f64; 3]> = Vec::new();
178 let mut remap: Vec<u32> = Vec::with_capacity(verts.len());
179 for v in verts {
180 let k = key(v);
181 let idx = *map.entry(k).or_insert_with(|| {
182 out_verts.push(*v);
183 (out_verts.len() - 1) as u32
184 });
185 remap.push(idx);
186 }
187 let mut out_faces: Vec<[u32; 3]> = Vec::with_capacity(faces.len());
188 for f in faces {
189 let (a, b, c) = (
190 remap[f[0] as usize],
191 remap[f[1] as usize],
192 remap[f[2] as usize],
193 );
194 if a != b && b != c && a != c {
195 out_faces.push([a, b, c]);
196 }
197 }
198 (out_verts, out_faces)
199}
200
201impl GeometryDataExport {
202 /// Serialize to pretty JSON.
203 pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
204 serde_json::to_string_pretty(self)
205 }
206
207 /// Serialize to compact JSON.
208 pub fn to_json(&self) -> Result<String, serde_json::Error> {
209 serde_json::to_string(self)
210 }
211}