Skip to main content

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}