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/// Color-aware geometry. The existing `ExportedElement` remains unchanged for callers.
51#[derive(Debug, Clone, Serialize)]
52pub struct ColoredExportedElement {
53    #[serde(flatten)]
54    pub geometry: ExportedElement,
55    /// Distinct RGBA values in first-submesh order; omitted when `color` suffices.
56    #[serde(skip_serializing_if = "Vec::is_empty")]
57    pub palette: Vec<[f32; 4]>,
58    /// One palette index per surviving face; omitted with `palette` when `color` suffices.
59    #[serde(skip_serializing_if = "Vec::is_empty")]
60    pub face_colors: Vec<u64>,
61}
62
63/// Top-level geometry-data document. Serializes to the `ifc-lite-geometry-data`
64/// JSON contract.
65#[derive(Debug, Clone, Serialize)]
66pub struct GeometryDataExport {
67    pub schema: &'static str,
68    pub version: u32,
69    /// Vertical axis convention of `vertices`. Always `"Z"` (IFC native).
70    pub up_axis: &'static str,
71    /// Length unit of `vertices`. Always `"m"` (SI metres).
72    pub units: &'static str,
73    /// The RTC offset already folded into `vertices`. `[0,0,0]` for models near
74    /// the origin; non-zero for geo-referenced models (so a consumer can choose
75    /// to re-localise by subtracting it for f32-friendly local coordinates).
76    pub rtc_offset: [f64; 3],
77    pub element_count: usize,
78    /// Per-entity geometry, keyed by IFC STEP/express id (JSON object key is the
79    /// id as a string).
80    pub elements: BTreeMap<u32, ExportedElement>,
81}
82
83/// Additive color-aware document. Uniform JSON stays compatible with version 1.
84#[derive(Debug, Clone, Serialize)]
85pub struct ColoredGeometryDataExport {
86    pub schema: &'static str,
87    pub version: u32,
88    pub up_axis: &'static str,
89    pub units: &'static str,
90    pub rtc_offset: [f64; 3],
91    pub element_count: usize,
92    pub elements: BTreeMap<u32, ColoredExportedElement>,
93}
94
95/// Build the geometry-data export from a processed model's meshes.
96///
97/// `rtc_offset` is `ProcessingResult.metadata.coordinate_info.origin_shift`.
98///
99/// `site_rotation` is the IfcSite placement (column-major 4x4) **only when the
100/// model was processed into the `site_local` coordinate space** — there the
101/// pipeline inverse-rotates positions + origin into site-local axes, so to emit
102/// true IFC world coordinates we reapply the forward 3x3 rotation:
103/// `world = R * (position + origin) + rtc_offset`. Pass `None` for the
104/// `model_rtc` / `raw_ifc` spaces (R = identity), which is the common case.
105pub fn build_geometry_data_export(
106    meshes: &[MeshData],
107    rtc_offset: [f64; 3],
108    site_rotation: Option<&[f64]>,
109) -> GeometryDataExport {
110    let (elements, _) = build_elements::<false>(meshes, rtc_offset, site_rotation);
111    GeometryDataExport {
112        schema: "ifc-lite-geometry-data",
113        version: 1,
114        up_axis: "Z",
115        units: "m",
116        rtc_offset,
117        element_count: elements.len(),
118        elements,
119    }
120}
121
122/// Build geometry with optional palette indices, preserving face materials through welding.
123pub fn build_colored_geometry_data_export(
124    meshes: &[MeshData],
125    rtc_offset: [f64; 3],
126    site_rotation: Option<&[f64]>,
127) -> ColoredGeometryDataExport {
128    let (geometry, mut colors) = build_elements::<true>(meshes, rtc_offset, site_rotation);
129    let elements: BTreeMap<u32, ColoredExportedElement> = geometry
130        .into_iter()
131        .map(|(id, geometry)| {
132            let metadata = colors.remove(&id).unwrap_or_default();
133            (id, ColoredExportedElement {
134                geometry,
135                palette: metadata.palette,
136                face_colors: metadata.face_colors,
137            })
138        })
139        .collect();
140    ColoredGeometryDataExport {
141        schema: "ifc-lite-geometry-data",
142        version: 1,
143        up_axis: "Z",
144        units: "m",
145        rtc_offset,
146        element_count: elements.len(),
147        elements,
148    }
149}
150
151type ElementMap = BTreeMap<u32, ExportedElement>;
152type ColorMap = BTreeMap<u32, ColorMetadata>;
153
154fn build_elements<const COLORS: bool>(
155    meshes: &[MeshData],
156    rtc_offset: [f64; 3],
157    site_rotation: Option<&[f64]>,
158) -> (ElementMap, ColorMap) {
159    // Keep the legacy map's allocation and node size; material state lives only
160    // in the opt-in sidecar, allocated once an element becomes nonuniform.
161    let mut elements = ElementMap::new();
162    let mut colors = ColorMap::new();
163    let rot = match site_rotation {
164        Some(m) if m.len() >= 16 => Some(m),
165        _ => None,
166    };
167
168    for m in meshes {
169        // Occurrences only: skip type-product RepresentationMap geometry. Class 3
170        // is a layered wall's body, which has no class-0 mesh to fall back on.
171        if matches!(m.geometry_class, 1 | 2) || m.indices.is_empty() {
172            continue;
173        }
174
175        let o = m.origin;
176        let verts: Vec<[f64; 3]> = m
177            .positions
178            .chunks_exact(3)
179            .map(|p| {
180                // World point in (possibly site-local) axes: position + origin.
181                let (x, y, z) = (p[0] as f64 + o[0], p[1] as f64 + o[1], p[2] as f64 + o[2]);
182                match rot {
183                    // Reapply the site forward rotation (column-major R), then RTC.
184                    Some(r) => [
185                        r[0] * x + r[4] * y + r[8] * z + rtc_offset[0],
186                        r[1] * x + r[5] * y + r[9] * z + rtc_offset[1],
187                        r[2] * x + r[6] * y + r[10] * z + rtc_offset[2],
188                    ],
189                    None => [x + rtc_offset[0], y + rtc_offset[1], z + rtc_offset[2]],
190                }
191            })
192            .collect();
193
194        let entry = elements
195            .entry(m.express_id)
196            .or_insert_with(|| ExportedElement {
197                ifc_type: m.ifc_type.clone(),
198                global_id: m.global_id.clone(),
199                name: m.name.clone(),
200                vertices: Vec::new(),
201                faces: Vec::new(),
202                color: m.color,
203            });
204        if COLORS {
205            if let Some(metadata) = colors.get_mut(&m.express_id) {
206                metadata.append_color(m.color, m.indices.len() / 3);
207            } else if entry.color != m.color {
208                let mut metadata = ColorMetadata::new(entry.color, entry.faces.len());
209                metadata.append_color(m.color, m.indices.len() / 3);
210                colors.insert(m.express_id, metadata);
211            }
212        }
213
214        // Merge this submesh: rebase its face indices onto the element's
215        // accumulated vertex list.
216        let base = entry.vertices.len() as u32;
217        entry.vertices.extend_from_slice(&verts);
218        entry.faces.extend(
219            m.indices
220                .chunks_exact(3)
221                .map(|t| [t[0] + base, t[1] + base, t[2] + base]),
222        );
223    }
224
225    // Weld in place so the legacy path returns its original map directly.
226    for (id, element) in &mut elements {
227        let metadata = if COLORS { colors.get_mut(id) } else { None };
228        let face_colors = metadata
229            .as_ref()
230            .map_or(&[][..], |m| m.face_colors.as_slice());
231        let (vertices, faces, kept) = weld_positions::<COLORS>(
232            &element.vertices,
233            &element.faces,
234            face_colors,
235            1e-6,
236        );
237        element.vertices = vertices;
238        element.faces = faces;
239        if let Some(metadata) = metadata {
240            metadata.face_colors = kept;
241        }
242    }
243    if COLORS {
244        // Index 0 is the existing fallback. Preserve an index-1-only survivor:
245        // its appearance differs from the first submesh's fallback color.
246        colors.retain(|_, m| !m.face_colors.iter().all(|&index| index == 0));
247    }
248    (elements, colors)
249}
250
251/// Allocated only for elements whose submeshes have different colors.
252#[derive(Default)]
253struct ColorMetadata {
254    palette: Vec<[f32; 4]>,
255    face_colors: Vec<u64>,
256    indices: BTreeMap<[u32; 4], u64>,
257}
258
259impl ColorMetadata {
260    fn new(fallback: [f32; 4], prior_faces: usize) -> Self {
261        let mut indices = BTreeMap::new();
262        indices.insert(color_key(fallback), 0);
263        Self {
264            palette: vec![fallback],
265            face_colors: vec![0; prior_faces],
266            indices,
267        }
268    }
269
270    fn append_color(&mut self, color: [f32; 4], face_count: usize) {
271        let next = self.palette.len() as u64;
272        let index = *self.indices.entry(color_key(color)).or_insert_with(|| {
273            self.palette.push(color);
274            next
275        });
276        self.face_colors.resize(self.face_colors.len() + face_count, index);
277    }
278}
279
280fn color_key(color: [f32; 4]) -> [u32; 4] {
281    // Match IEEE equality for signed zero while retaining deterministic bit keys.
282    color.map(|value| if value == 0.0 { 0 } else { value.to_bits() })
283}
284
285/// Merge coincident vertices on a `1/eps` grid and remap faces, dropping any
286/// triangle that collapses to a degenerate after the merge.
287fn weld_positions<const COLORS: bool>(
288    verts: &[[f64; 3]],
289    faces: &[[u32; 3]],
290    colors: &[u64],
291    eps: f64,
292) -> (Vec<[f64; 3]>, Vec<[u32; 3]>, Vec<u64>) {
293    let inv = 1.0 / eps;
294    let key = |v: &[f64; 3]| -> (i64, i64, i64) {
295        (
296            (v[0] * inv).round() as i64,
297            (v[1] * inv).round() as i64,
298            (v[2] * inv).round() as i64,
299        )
300    };
301    let mut map: BTreeMap<(i64, i64, i64), u32> = BTreeMap::new();
302    let mut out_verts: Vec<[f64; 3]> = Vec::new();
303    let mut remap: Vec<u32> = Vec::with_capacity(verts.len());
304    for v in verts {
305        let k = key(v);
306        let idx = *map.entry(k).or_insert_with(|| {
307            out_verts.push(*v);
308            (out_verts.len() - 1) as u32
309        });
310        remap.push(idx);
311    }
312    let mut out_faces: Vec<[u32; 3]> = Vec::with_capacity(faces.len());
313    let mut kept = Vec::with_capacity(colors.len());
314    for (face_index, f) in faces.iter().enumerate() {
315        let (a, b, c) = (
316            remap[f[0] as usize],
317            remap[f[1] as usize],
318            remap[f[2] as usize],
319        );
320        if a != b && b != c && a != c {
321            out_faces.push([a, b, c]);
322            if COLORS && !colors.is_empty() {
323                kept.push(colors[face_index]);
324            }
325        }
326    }
327    (out_verts, out_faces, kept)
328}
329
330impl GeometryDataExport {
331    /// Serialize to pretty JSON.
332    pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
333        serde_json::to_string_pretty(self)
334    }
335
336    /// Serialize to compact JSON.
337    pub fn to_json(&self) -> Result<String, serde_json::Error> {
338        serde_json::to_string(self)
339    }
340}
341
342impl ColoredGeometryDataExport {
343    /// Serialize color-aware elements as the version-1 JSON document with optional fields.
344    pub fn to_json(&self) -> Result<String, serde_json::Error> {
345        serde_json::to_string(self)
346    }
347}