ifc_lite_wasm/api/
export_glb.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//! WASM API: export_glb — IFC render geometry → binary glTF (GLB) bytes.
6
7use super::IfcAPI;
8use wasm_bindgen::prelude::*;
9
10/// Map the optional KML altitude-mode string from the JS boundary to the
11/// exporter enum. `None` (or any unrecognised value) ⇒ `ClampToGround` so the
12/// safe, non-floating default (#1427) is preserved and existing callers that
13/// omit the argument are unchanged. The UI exposes only `"clampToGround"`
14/// ("Rest on ground") and `"absolute"` ("True elevation (MSL)"); the literal
15/// `"relativeToGround"` is accepted for completeness.
16fn kmz_altitude_mode(mode: Option<String>) -> ifc_lite_export::AltitudeMode {
17    use ifc_lite_export::AltitudeMode;
18    match mode.as_deref() {
19        Some("absolute") => AltitudeMode::Absolute,
20        Some("relativeToGround") => AltitudeMode::RelativeToGround,
21        _ => AltitudeMode::ClampToGround,
22    }
23}
24
25#[wasm_bindgen]
26impl IfcAPI {
27    /// Export the render geometry in `content` as a binary **GLB** (`Uint8Array`).
28    ///
29    /// `hidden` / `isolated` are express-id visibility filters; `hidden_types_csv` is a
30    /// comma-separated list of IFC type names whose class toggle is off (e.g.
31    /// `"IfcOpeningElement,IfcSpace"`). `isolated` carries the isolation allowlist's
32    /// null-vs-empty distinction across the wasm boundary: omit it (`undefined`) for
33    /// "no isolation filter" (every mesh is a candidate); pass an empty `Uint32Array`
34    /// for "isolation is ACTIVE and currently matches nothing" (every mesh is
35    /// excluded). Collapsing the two — as a bare `Uint32Array` parameter would force a
36    /// caller to do — silently exports the whole model when a filter matches nothing
37    /// (#4328 follow-up: reachable by filtering the Class tab to a type present only
38    /// in a federated model's secondary member, then exporting "Visible Only"). A
39    /// non-empty `Uint32Array` is the ordinary allowlist. `include_metadata` attaches
40    /// counts + per-node `expressId`. Per-mesh RTC origin rides the node translation
41    /// (precision-safe).
42    /// `lit` emits standard PBR materials that shade from normals; omitted or
43    /// `true` ⇒ lit (the default), `false` ⇒ flat `KHR_materials_unlit` (the
44    /// historical look — #1321). Optional at the boundary so older 5-arg callers
45    /// keep lit-by-default behaviour.
46    /// `emissive` self-illuminates each material at its base colour (core glTF
47    /// `emissiveFactor`) so renderers without ambient/IBL — Google Earth — don't
48    /// render the model near-black (#1427); omitted or `false` ⇒ off.
49    ///
50    /// Fails CLOSED: when the visible mesh set is empty this throws an `Error`
51    /// whose message starts with `NO_RENDER_GEOMETRY`, instead of returning a
52    /// structurally valid but empty GLB. #1438 put that guard only in the TS
53    /// CLI/MCP wrappers; making the boundary itself refuse means SDK/viewer/
54    /// direct callers inherit it too (the TS guards stay as defense-in-depth).
55    #[wasm_bindgen(js_name = exportGlb)]
56    #[allow(clippy::too_many_arguments)]
57    pub fn export_glb(
58        &self,
59        content: &[u8],
60        include_metadata: bool,
61        hidden: &[u32],
62        isolated: Option<Vec<u32>>,
63        hidden_types_csv: String,
64        lit: Option<bool>,
65        emissive: Option<bool>,
66    ) -> Result<Vec<u8>, JsValue> {
67        let hidden_types = hidden_types_csv
68            .split(',')
69            .map(|s| s.trim().to_string())
70            .filter(|s| !s.is_empty())
71            .collect();
72        let opts = ifc_lite_export::GltfOptions::default()
73            .with_include_metadata(include_metadata)
74            .with_hidden(hidden.to_vec())
75            .with_isolated(isolated)
76            .with_hidden_types(hidden_types)
77            .with_lit(lit.unwrap_or(true))
78            .with_emissive(emissive.unwrap_or(false))
79            // Federation (modelId stamping) is a server-side concern; the viewer's
80            // wasm export path is single-model. Add a parameter here if/when the
81            // browser needs to federate.
82            .with_model_id(None)
83            // The viewer loads the GLB directly; quantization is a server/export-pipeline
84            // concern (KHR_mesh_quantization needs loader support the viewer doesn't wire).
85            .with_quantize(false)
86            // Stated rather than inherited from `setTessellationQuality`, so this
87            // export keeps emitting exactly what it emitted before. Whether an
88            // export should follow the density the viewer is displaying at is a
89            // separate question from whether a caller can name one.
90            .with_tessellation_quality(ifc_lite_export::TessellationQuality::Medium);
91        ifc_lite_export::try_export_glb(content, &opts)
92            .map_err(|e| JsValue::from(js_sys::Error::new(&e.to_string())))
93    }
94
95    /// Assemble a **GLB** from already-produced meshes (the viewer's `MeshData`, flattened)
96    /// — no re-meshing. Per mesh `i`: `vertex_counts[i]` verts + `index_counts[i]` indices
97    /// taken in order from the concatenated `positions`/`normals`/`indices`; `colors` is
98    /// RGBA per mesh, `origins` xyz per mesh, `express_ids` labels each mesh (indices are
99    /// per-mesh local). The caller passes exactly the meshes it wants emitted.
100    ///
101    /// Fails CLOSED: if the declared vertex/index counts run past the flattened
102    /// `positions` / `indices`, there are fewer `index_counts` than meshes, `normals`
103    /// is empty or too short to cover every vertex, a mesh's index block is not a
104    /// whole number of triangles, or an index names a vertex its own mesh does not
105    /// have (glTF 2.0 3.7.2.1), this throws an `Error` whose message starts with
106    /// `MALFORMED_MESH_INPUT` — instead of silently emitting a GLB with those meshes
107    /// dropped, a TRIANGLES primitive whose count is not a multiple of 3, or the
108    /// out-of-range index copied into the BIN chunk. (The viewer always passes
109    /// fully-backed, normal-covered, whole-triangle, in-range arrays, so this only
110    /// fires on a caller bug.)
111    #[wasm_bindgen(js_name = exportGlbFromMeshes)]
112    #[allow(clippy::too_many_arguments)]
113    pub fn export_glb_from_meshes(
114        &self,
115        positions: &[f32],
116        normals: &[f32],
117        indices: &[u32],
118        vertex_counts: &[u32],
119        index_counts: &[u32],
120        colors: &[f32],
121        origins: &[f64],
122        express_ids: &[u32],
123        include_metadata: bool,
124        lit: Option<bool>,
125        emissive: Option<bool>,
126    ) -> Result<Vec<u8>, JsValue> {
127        ifc_lite_export::try_export_glb_from_meshes(
128            positions,
129            normals,
130            indices,
131            vertex_counts,
132            index_counts,
133            colors,
134            origins,
135            express_ids,
136            include_metadata,
137            lit.unwrap_or(true),
138            emissive.unwrap_or(false),
139        )
140        .map(|(glb, _)| glb)
141        .map_err(|e| JsValue::from(js_sys::Error::new(&e.to_string())))
142    }
143
144    /// Build a Google-Earth-ready **KMZ** (`Uint8Array`) straight from the viewer's
145    /// already-produced meshes — the working path (#1427). The model is embedded as
146    /// **COLLADA** (`model.dae`), the only `<Model>` format Google Earth loads (a GLB
147    /// raises "Unsupported element: Model"), with emission-lit double-sided materials
148    /// placement. Mesh arrays match `exportGlbFromMeshes`;
149    /// `latitude`/`longitude`/`altitude` + `x_axis_abscissa`/`x_axis_ordinate`
150    /// (grid-north, `undefined` ⇒ heading 0) place + orient the model.
151    /// `altitude_mode` (`"clampToGround"` default ⇒ rest on terrain, ignoring
152    /// `altitude`; `"absolute"` ⇒ place at `altitude` metres MSL) selects the
153    /// KML vertical placement (#1427).
154    ///
155    /// Fails CLOSED: when no triangle survives (an empty visible set, or meshes
156    /// whose only triangles are degenerate and collapse in the vertex dedup)
157    /// this throws an `Error` whose message starts with `NO_RENDER_GEOMETRY`;
158    /// a declared vertex/index count running past its buffer, an index block
159    /// that is not whole triangles, or an index outside its mesh throws
160    /// `MALFORMED_MESH_INPUT`, exactly as `exportGlbFromMeshes` does. These used
161    /// to ship as a "successful" archive: an empty one around a COLLADA
162    /// document the 1.4.1 schema rejects, or one with meshes or faces missing.
163    #[wasm_bindgen(js_name = exportKmzFromMeshes)]
164    #[allow(clippy::too_many_arguments)]
165    pub fn export_kmz_from_meshes(
166        &self,
167        positions: &[f32],
168        normals: &[f32],
169        indices: &[u32],
170        vertex_counts: &[u32],
171        index_counts: &[u32],
172        colors: &[f32],
173        origins: &[f64],
174        latitude: f64,
175        longitude: f64,
176        altitude: f64,
177        x_axis_abscissa: Option<f64>,
178        x_axis_ordinate: Option<f64>,
179        name: String,
180        altitude_mode: Option<String>,
181    ) -> Result<Vec<u8>, JsValue> {
182        let opts = ifc_lite_export::KmzOptions {
183            latitude,
184            longitude,
185            altitude,
186            altitude_mode: kmz_altitude_mode(altitude_mode),
187            x_axis_abscissa,
188            x_axis_ordinate,
189            name: if name.is_empty() { None } else { Some(name) },
190        };
191        ifc_lite_export::try_export_kmz_collada_from_meshes(
192            positions,
193            normals,
194            indices,
195            vertex_counts,
196            index_counts,
197            colors,
198            origins,
199            &opts,
200        )
201        .map_err(|e| JsValue::from(js_sys::Error::new(&e.to_string())))
202    }
203}