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}