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