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**: 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}