ifc_lite_wasm/zero_copy/
mesh.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
5use super::frame_swap::{swap_zup_to_yup_aabb, swap_zup_to_yup_mat4};
6use ifc_lite_geometry::Mesh;
7use wasm_bindgen::prelude::*;
8
9/// Individual mesh data with express ID and color (matches MeshData interface)
10///
11/// `Clone` is derived so the three sites that used to enumerate all 21 fields
12/// by hand -- `get`, `takeMesh` and `MeshCollection`'s own `Clone` -- reduce to
13/// `.cloned()`, `mem::take` and `.clone()`. Adding a field to #3199 meant
14/// editing three literals in lockstep; the allowlist row for this file records
15/// exactly that cost.
16///
17/// To be precise about what this does and does not buy, because the obvious
18/// claim is wrong: an exhaustive struct literal that OMITS a field is a compile
19/// error, so those literals were never silently lossy. What they were is three
20/// places to edit for one field, and `..Default::default()` is the shortcut a
21/// hurried author reaches for when the compiler complains -- which WOULD be
22/// silently lossy. `Default` is what makes `takeMesh` a one-liner, so the rule
23/// is: TWO literals remain, `new` and `Default` below (both spelled
24/// `Self { .. }`, which is why a grep for `MeshDataJs {` finds neither). Both
25/// must stay exhaustive AND must agree on every field `new` does not take as an
26/// argument. The compiler catches an omitted field in either; it cannot catch
27/// the two DISAGREEING, which is the failure the pair exists to prevent, so
28/// `default_agrees_with_new_on_the_fields_new_does_not_take` covers that.
29/// Never spread `Default` into `new` -- `new` is the only place a field's
30/// initial value is decided, so a field defaulted there is inert everywhere.
31#[wasm_bindgen]
32#[derive(Clone)]
33pub struct MeshDataJs {
34    express_id: u32,
35    ifc_type: String, // IFC type name (e.g., "IfcWall", "IfcSpace")
36    positions: Vec<f32>,
37    normals: Vec<f32>,
38    indices: Vec<u32>,
39    /// Apparent rendering colour: IfcSurfaceStyleRendering.DiffuseColour
40    /// when authored, otherwise the SurfaceColour.
41    color: [f32; 4], // RGBA
42    /// SurfaceColour, populated only when the file authored a distinct
43    /// DiffuseColour (so the two would differ). Consumed by the GLB
44    /// exporter's "Shading" colour-source option; renderers ignore it.
45    shading_color: Option<[f32; 4]>,
46    /// IFC-authored metallic/roughness (#5582, see the getters below).
47    metallic: Option<f32>,
48    roughness: Option<f32>,
49    /// Per-vertex texture coordinates (u, v pairs, 1:1 with positions),
50    /// present only for textured meshes (#961). Empty otherwise.
51    uvs: Vec<f32>,
52    /// Decoded RGBA8 texture (`width*height*4`), present only for textured
53    /// meshes (#961). Empty otherwise. The browser uploads this verbatim to a
54    /// GPU texture — no image decoding happens in JS.
55    texture_rgba: Vec<u8>,
56    texture_width: u32,
57    texture_height: u32,
58    texture_repeat_s: bool,
59    texture_repeat_t: bool,
60    /// Stable texture dedup key: the `IfcSurfaceTexture` express id (#1781).
61    /// Every mesh sampling the same image carries the same id, so the consumer
62    /// creates ONE GPU texture per id. 0 when the mesh is untextured.
63    texture_id: u32,
64    /// `IfcImageTexture.URLReference` (#1781): an external image reference the
65    /// host resolves (typically a sibling file inside the `.ifcZIP`). `None`
66    /// for untextured meshes and for Rust-decoded blob/pixel textures.
67    texture_url: Option<String>,
68    /// Geometry provenance for the viewer's Model/Types view switch:
69    /// 0 = occurrence (a placed IfcProduct), 1 = orphan type geometry (an
70    /// IfcTypeProduct RepresentationMap with NO occurrence — buildingSMART
71    /// annex-E showcase files; part of "the model" since nothing else renders
72    /// it), 2 = instanced type geometry (an IfcTypeProduct that IS instantiated
73    /// via IfcRelDefinesByType — the type-library shape, hidden in Model mode to
74    /// avoid double-rendering, shown in Types mode). See #957 follow-up.
75    geometry_class: u8,
76    /// Source ids, DISJOINT — never both. See `ifc_lite_processing::MeshData`
77    /// for what each means and when each is absent (#3199).
78    geometry_item_id: Option<u32>,
79    material_id: Option<u32>,
80    /// Per-element local-frame origin (f64), in the SAME (WebGL Y-up) frame as
81    /// `positions`: world position of vertex i = `origin + positions[3i..]`.
82    /// Default `[0,0,0]` means positions are absolute (legacy). Carries the
83    /// per-element AABB-centre relativization so building-scale coordinates stay
84    /// f32-precise (no fan collapse). See `Mesh::origin`/transform_mesh_world_framed.
85    origin: [f64; 3],
86    /// Local (pre-placement, object-space) AABB, WebGL Y-up (issue #1474):
87    /// `[minX,minY,minZ,maxX,maxY,maxZ]`. `None` when not captured (e.g. a
88    /// synthetic/from-meshes mesh). See `Mesh::local_bounds`.
89    local_bounds: Option<[f32; 6]>,
90    /// The resolved placement (`local_to_world`), row-major, WebGL Y-up (issue
91    /// #1474). `None` when not captured. See `Mesh::local_to_world`.
92    local_to_world: Option<[f64; 16]>,
93}
94
95/// Hand-written rather than derived, and it must keep agreeing with `new`.
96///
97/// The derive gives `false` for the texture repeat flags while `new` sets them
98/// `true`, so a `MeshDataJs::default()` plus setters would report
99/// `textureRepeatS === false` and clamp a texture that should tile. Inert while
100/// every texture path goes through `set_texture`, but that was enforced by
101/// prose alone; this makes the two constructors agree structurally.
102impl Default for MeshDataJs {
103    fn default() -> Self {
104        Self {
105            express_id: 0,
106            ifc_type: String::new(),
107            positions: Vec::new(),
108            normals: Vec::new(),
109            indices: Vec::new(),
110            color: [0.0; 4],
111            shading_color: None,
112            metallic: None,
113            roughness: None,
114            uvs: Vec::new(),
115            texture_rgba: Vec::new(),
116            texture_width: 0,
117            texture_height: 0,
118            texture_repeat_s: true,
119            texture_repeat_t: true,
120            texture_id: 0,
121            texture_url: None,
122            geometry_class: 0,
123            geometry_item_id: None,
124            material_id: None,
125            origin: [0.0; 3],
126            local_bounds: None,
127            local_to_world: None,
128        }
129    }
130}
131
132#[wasm_bindgen]
133impl MeshDataJs {
134    /// Get express ID
135    #[wasm_bindgen(getter, js_name = expressId)]
136    pub fn express_id(&self) -> u32 {
137        self.express_id
138    }
139
140    /// Get IFC type name (e.g., "IfcWall", "IfcSpace")
141    #[wasm_bindgen(getter, js_name = ifcType)]
142    pub fn ifc_type(&self) -> String {
143        self.ifc_type.clone()
144    }
145
146    /// Get positions as Float32Array (copy to JS)
147    #[wasm_bindgen(getter)]
148    pub fn positions(&self) -> js_sys::Float32Array {
149        js_sys::Float32Array::from(&self.positions[..])
150    }
151
152    /// Get normals as Float32Array (copy to JS)
153    #[wasm_bindgen(getter)]
154    pub fn normals(&self) -> js_sys::Float32Array {
155        js_sys::Float32Array::from(&self.normals[..])
156    }
157
158    /// Get indices as Uint32Array (copy to JS)
159    #[wasm_bindgen(getter)]
160    pub fn indices(&self) -> js_sys::Uint32Array {
161        js_sys::Uint32Array::from(&self.indices[..])
162    }
163
164    /// Get color as [r, g, b, a] array
165    #[wasm_bindgen(getter)]
166    pub fn color(&self) -> Vec<f32> {
167        self.color.to_vec()
168    }
169
170    /// Optional SurfaceColour for the "Shading" GLB-export choice — only
171    /// present when the file authored a distinct DiffuseColour. JS sees
172    /// `undefined` when absent (most files).
173    #[wasm_bindgen(getter, js_name = shadingColor)]
174    pub fn shading_color(&self) -> Option<Vec<f32>> {
175        self.shading_color.map(|c| c.to_vec())
176    }
177
178    /// IFC-authored metallic/roughness (#5582). `undefined` when unauthored.
179    #[wasm_bindgen(getter)]
180    pub fn metallic(&self) -> Option<f32> { self.metallic }
181    #[wasm_bindgen(getter)]
182    pub fn roughness(&self) -> Option<f32> { self.roughness }
183
184    /// Get vertex count
185    #[wasm_bindgen(getter, js_name = vertexCount)]
186    pub fn vertex_count(&self) -> usize {
187        self.positions.len() / 3
188    }
189
190    /// Get triangle count
191    #[wasm_bindgen(getter, js_name = triangleCount)]
192    pub fn triangle_count(&self) -> usize {
193        self.indices.len() / 3
194    }
195
196    /// True when this mesh carries a surface texture (#961).
197    #[wasm_bindgen(getter, js_name = hasTexture)]
198    pub fn has_texture(&self) -> bool {
199        !self.texture_rgba.is_empty()
200    }
201
202    /// Per-vertex texture coordinates as Float32Array (u, v pairs). Empty when
203    /// the mesh is untextured.
204    #[wasm_bindgen(getter)]
205    pub fn uvs(&self) -> js_sys::Float32Array {
206        js_sys::Float32Array::from(&self.uvs[..])
207    }
208
209    /// Decoded RGBA8 texture bytes (`width*height*4`). Empty when untextured.
210    #[wasm_bindgen(getter, js_name = textureRgba)]
211    pub fn texture_rgba(&self) -> js_sys::Uint8Array {
212        js_sys::Uint8Array::from(&self.texture_rgba[..])
213    }
214
215    #[wasm_bindgen(getter, js_name = textureWidth)]
216    pub fn texture_width(&self) -> u32 {
217        self.texture_width
218    }
219
220    #[wasm_bindgen(getter, js_name = textureHeight)]
221    pub fn texture_height(&self) -> u32 {
222        self.texture_height
223    }
224
225    /// Sampler wrap for the S axis (`IfcSurfaceTexture.RepeatS`): true = repeat.
226    #[wasm_bindgen(getter, js_name = textureRepeatS)]
227    pub fn texture_repeat_s(&self) -> bool {
228        self.texture_repeat_s
229    }
230
231    /// Sampler wrap for the T axis (`IfcSurfaceTexture.RepeatT`): true = repeat.
232    #[wasm_bindgen(getter, js_name = textureRepeatT)]
233    pub fn texture_repeat_t(&self) -> bool {
234        self.texture_repeat_t
235    }
236
237    /// Stable texture dedup key (`IfcSurfaceTexture` express id, #1781).
238    /// 0 when the mesh is untextured.
239    #[wasm_bindgen(getter, js_name = textureId)]
240    pub fn texture_id(&self) -> u32 {
241        self.texture_id
242    }
243
244    /// External image reference (`IfcImageTexture.URLReference`, #1781) for the
245    /// host to resolve — e.g. a sibling image inside the `.ifcZIP`. `undefined`
246    /// for untextured meshes and Rust-decoded blob/pixel textures.
247    #[wasm_bindgen(getter, js_name = textureUrl)]
248    pub fn texture_url(&self) -> Option<String> {
249        self.texture_url.clone()
250    }
251
252    /// Geometry provenance for the viewer's Model/Types switch (#957 follow-up):
253    /// 0 = occurrence, 1 = orphan type geometry (no occurrence), 2 = instanced
254    /// type geometry (hidden in Model mode, shown in Types mode).
255    #[wasm_bindgen(getter, js_name = geometryClass)]
256    pub fn geometry_class(&self) -> u8 {
257        self.geometry_class
258    }
259
260    /// Source `IfcRepresentationItem`, or `undefined` where identity is merged
261    /// away. Never set alongside `materialId` (#3199).
262    #[wasm_bindgen(getter, js_name = geometryItemId)]
263    pub fn geometry_item_id(&self) -> Option<u32> {
264        self.geometry_item_id
265    }
266
267    /// `IfcMaterial` layer sliced, or `undefined` (#3199).
268    #[wasm_bindgen(getter, js_name = materialId)]
269    pub fn material_id(&self) -> Option<u32> {
270        self.material_id
271    }
272
273    /// Per-element local-frame origin (Float64Array[3], WebGL Y-up, metres):
274    /// world position of vertex i = `origin + positions[3i..3i+3]`. Returns
275    /// [0,0,0] when positions are absolute (legacy / local frame off).
276    #[wasm_bindgen(getter)]
277    pub fn origin(&self) -> js_sys::Float64Array {
278        js_sys::Float64Array::from(&self.origin[..])
279    }
280
281    /// Local (pre-placement, object-space) AABB (issue #1474), WebGL Y-up,
282    /// `[minX,minY,minZ,maxX,maxY,maxZ]`. `undefined` when not captured
283    /// (wasm-bindgen maps `Option::None` to `undefined`, not `null`).
284    #[wasm_bindgen(getter, js_name = localBounds)]
285    pub fn local_bounds(&self) -> Option<Vec<f32>> {
286        self.local_bounds.map(|b| b.to_vec())
287    }
288
289    /// The resolved `IfcLocalPlacement` chain for this mesh (issue #1474),
290    /// row-major 4×4, WebGL Y-up. `undefined` when not captured (see
291    /// `local_bounds` above).
292    #[wasm_bindgen(getter, js_name = localToWorld)]
293    pub fn local_to_world(&self) -> Option<Vec<f64>> {
294        self.local_to_world.map(|m| m.to_vec())
295    }
296}
297
298impl MeshDataJs {
299    /// Create new mesh data with IFC Z-up to WebGL Y-up conversion.
300    ///
301    /// Performs coordinate conversion in Rust
302    /// to avoid expensive per-vertex JS iteration (63.5M vertices for large files).
303    /// IFC Z-up → WebGL Y-up: swap Y/Z, negate new Z for right-handedness.
304    /// This rotation has determinant +1, so triangle winding is preserved (#4056).
305    pub fn new(express_id: u32, ifc_type: String, mut mesh: Mesh, color: [f32; 4]) -> Self {
306        // Convert positions: IFC Z-up → WebGL Y-up
307        for chunk in mesh.positions.chunks_exact_mut(3) {
308            let y = chunk[1];
309            let z = chunk[2];
310            chunk[1] = z; // New Y = old Z (vertical)
311            chunk[2] = -y; // New Z = -old Y (depth, negated for right-hand rule)
312        }
313
314        // Convert normals the same way
315        for chunk in mesh.normals.chunks_exact_mut(3) {
316            let y = chunk[1];
317            let z = chunk[2];
318            chunk[1] = z;
319            chunk[2] = -y;
320        }
321
322        // The per-element origin is a world-frame point and MUST undergo the
323        // identical IFC Z-up → WebGL Y-up swap as the positions above, or
324        // `world = origin + position` would mix axes (element renders mirrored
325        // / displaced). Default [0,0,0] swaps to [0,0,0] (no-op for legacy).
326        let origin = [mesh.origin[0], mesh.origin[2], -mesh.origin[1]];
327
328        // Local (pre-placement) AABB (issue #1474): same swap as positions, but
329        // an AABB corner can't be swapped component-wise — negating an axis
330        // flips which corner is min/max along it. See `swap_zup_to_yup_aabb`.
331        let local_bounds = mesh.local_bounds.map(swap_zup_to_yup_aabb);
332        // The placement transform (issue #1474) is conjugated by the same
333        // swap, since it's expressed in the IFC frame the positions were in
334        // before this conversion: M' = S · M · Sᵀ. See `swap_zup_to_yup_mat4`.
335        let local_to_world = mesh.local_to_world.map(|m| swap_zup_to_yup_mat4(&m));
336
337        Self {
338            express_id,
339            ifc_type,
340            positions: mesh.positions,
341            normals: mesh.normals,
342            indices: mesh.indices,
343            color,
344            shading_color: None,
345            metallic: None,
346            roughness: None,
347            uvs: Vec::new(),
348            texture_rgba: Vec::new(),
349            texture_width: 0,
350            texture_height: 0,
351            texture_repeat_s: true,
352            texture_repeat_t: true,
353            texture_id: 0,
354            texture_url: None,
355            geometry_class: 0,
356            geometry_item_id: None,
357            material_id: None,
358            origin,
359            local_bounds,
360            local_to_world,
361        }
362    }
363
364    /// Tag this mesh's geometry provenance for the Model/Types view switch
365    /// (0 = occurrence, 1 = orphan type, 2 = instanced type). Call after `new`.
366    pub fn set_geometry_class(&mut self, class: u8) {
367        self.geometry_class = class;
368    }
369
370    /// Both DISJOINT ids at once, so a caller cannot set one without
371    /// considering the other (#3199).
372    pub fn set_source_ids(&mut self, geometry_item_id: Option<u32>, material_id: Option<u32>) {
373        debug_assert!(
374            geometry_item_id.is_none() || material_id.is_none(),
375            "disjoint; never both"
376        );
377        self.geometry_item_id = geometry_item_id;
378        self.material_id = material_id;
379    }
380
381    /// Attach an optional SurfaceColour for the GLB exporter's "Shading"
382    /// colour source. Callers that have a `geometry_shading_styles` entry
383    /// for the mesh's source geometry id should invoke this after `new`.
384    pub fn set_shading_color(&mut self, shading: Option<[f32; 4]>) {
385        self.shading_color = shading;
386    }
387
388    /// Attach the IFC-authored metallic/roughness (#5582); the wasm batch stamps it
389    /// from `setStyleFinishes` by `geometry_item_id` (`MeshData` carries none).
390    pub fn set_material(&mut self, metallic: Option<f32>, roughness: Option<f32>) {
391        self.metallic = metallic;
392        self.roughness = roughness;
393    }
394
395    /// Attach per-vertex UVs + a decoded RGBA8 texture (#961). UVs are 1:1 with
396    /// `positions` and need no coordinate flip (they are 2D). The rotation in
397    /// `new` preserves vertex and index order, so per-vertex UVs stay aligned.
398    /// Call after `new`.
399    // Each arg is a distinct JS call parameter; a Rust struct would not reduce
400    // arity for JS callers. Matches the 21 other sites in this crate.
401    #[allow(clippy::too_many_arguments)]
402    pub fn set_texture(
403        &mut self,
404        uvs: Vec<f32>,
405        rgba: Vec<u8>,
406        width: u32,
407        height: u32,
408        repeat_s: bool,
409        repeat_t: bool,
410        texture_id: u32,
411    ) {
412        self.uvs = uvs;
413        self.texture_rgba = rgba;
414        self.texture_width = width;
415        self.texture_height = height;
416        self.texture_repeat_s = repeat_s;
417        self.texture_repeat_t = repeat_t;
418        self.texture_id = texture_id;
419    }
420
421    /// Attach per-vertex UVs + an EXTERNAL image reference (#1781): the host
422    /// resolves `url` (e.g. against the `.ifcZIP` siblings), decodes it once
423    /// per `texture_id`, and shares the GPU texture. Call after `new`.
424    pub fn set_texture_ref(
425        &mut self,
426        uvs: Vec<f32>,
427        url: String,
428        repeat_s: bool,
429        repeat_t: bool,
430        texture_id: u32,
431    ) {
432        self.uvs = uvs;
433        self.texture_url = Some(url);
434        self.texture_repeat_s = repeat_s;
435        self.texture_repeat_t = repeat_t;
436        self.texture_id = texture_id;
437    }
438
439    /// Build from the canonical per-element producer's [`MeshData`]
440    /// (`ifc_lite_processing::element`): wraps [`MeshDataJs::new`] (IFC Z-up →
441    /// WebGL Y-up rotation with preserved winding), copies the `geometry_class` tag and the
442    /// optional texture/UVs. Element metadata the browser doesn't carry
443    /// (global_id / name / presentation layer / material name / properties) is
444    /// dropped — the viewer gets it from the parser worker instead.
445    pub fn from_mesh_data(m: ifc_lite_processing::MeshData) -> Self {
446        let mesh = Mesh {
447            positions: m.positions,
448            normals: m.normals,
449            indices: m.indices,
450            // Positions are final here (the canonical producer already applied
451            // placement/RTC); the flag only guards upstream double-subtraction.
452            rtc_applied: true,
453            // Per-element local-frame origin from the producer (IFC frame); the
454            // Z-up→Y-up swap is applied in `new`. [0,0,0] when local frame off.
455            origin: m.origin,
456            // Instancing side-channel is not used on this wasm zero-copy path.
457            instance_meta: None,
458            // Local bounds/placement (issue #1474), still in the IFC frame —
459            // `new` applies the same Z-up→Y-up swap it applies to positions/origin.
460            local_bounds: m.local_bounds,
461            local_to_world: m.local_to_world,
462            welded_in_object_frame: false,
463            plane_tags: None,
464        };
465        let mut js = Self::new(m.express_id, m.ifc_type, mesh, m.color);
466        js.set_geometry_class(m.geometry_class);
467        js.set_source_ids(m.geometry_item_id, m.material_id);
468        if let (Some(uvs), Some(tex)) = (m.uvs, m.texture) {
469            if let Some(rgba) = tex.rgba {
470                // Rust-decoded blob/pixel texture (#961): the Arc is shared
471                // across meshes in Rust; this boundary copy is per-mesh but
472                // blob/pixel images are small by construction.
473                js.set_texture(
474                    uvs,
475                    rgba.as_ref().clone(),
476                    tex.width,
477                    tex.height,
478                    tex.repeat_s,
479                    tex.repeat_t,
480                    tex.texture_id,
481                );
482            } else if let Some(url) = tex.url {
483                // External image reference (#1781): ship the URL + repeat
484                // flags only; the host decodes once per texture_id.
485                js.set_texture_ref(uvs, url, tex.repeat_s, tex.repeat_t, tex.texture_id);
486            }
487        }
488        js
489    }
490}
491
492/// Collection of mesh data for returning multiple meshes
493#[wasm_bindgen]
494pub struct MeshCollection {
495    meshes: Vec<MeshDataJs>,
496    /// RTC (Relative-to-Center) offset applied to all positions
497    /// This is subtracted from world coordinates to improve Float32 precision
498    rtc_offset_x: f64,
499    rtc_offset_y: f64,
500    rtc_offset_z: f64,
501    /// Building rotation angle in radians (from IfcSite's top-level placement)
502    /// This is the rotation of the building's principal axes relative to world X/Y/Z
503    building_rotation: Option<f64>,
504    /// Per-entity geometry fingerprints for revision diffing, populated only
505    /// when `IfcAPI::set_compute_geometry_hashes` is enabled. Parallel arrays:
506    /// `geometry_hash_ids[i]` is the entity express id, `geometry_hash_values[i]`
507    /// its fingerprint (see `ifc_lite_geometry::geom_hash`). Empty otherwise.
508    geometry_hash_ids: Vec<u32>,
509    geometry_hash_values: Vec<u64>,
510    /// World-space AABBs from the SAME pass, 6 `f64` per entry
511    /// (minx,miny,minz,maxx,maxy,maxz) in `geometry_hash_ids` order. Populated
512    /// and emptied in lockstep with the two arrays above.
513    ///
514    /// Stored in the producer's IFC **Z-up** frame, the frame the hasher
515    /// reconstructed them in. The Z-up→Y-up swap happens once, in the
516    /// `geometryAabbValues` getter — the single point where these reach JS.
517    geometry_aabb_values: Vec<f64>,
518    /// Enclosed volume in m³ from the SAME pass, one `f64` per entry, `NaN`
519    /// where the geometry was not provably a single closed orientable solid
520    /// (#1891). Same NaN-means-absent convention as `geometry_aabb_values`.
521    geometry_volume_values: Vec<f64>,
522    /// Packed `GeometryClosure` verdict, one `u8` per entry: which clause of
523    /// the volume gate held. Lets a consumer name the reason a volume is
524    /// absent instead of guessing.
525    geometry_closure_flags: Vec<u8>,
526    /// Typed CSG / opening diagnostics for the batch that produced this collection
527    /// (the public `GeometryDiagnostics` contract). The worker merges these across
528    /// batches and the loader across workers, surfacing one per-load `diagnostics`
529    /// object on the streaming `complete` event. Both the flat and partitioned
530    /// batch paths set it; `None` when no diagnostics were recorded.
531    diagnostics: Option<ifc_lite_geometry::GeometryDiagnostics>,
532}
533
534#[wasm_bindgen]
535impl MeshCollection {
536    /// Get number of meshes
537    #[wasm_bindgen(getter)]
538    pub fn length(&self) -> usize {
539        self.meshes.len()
540    }
541
542    /// Get mesh at index (clones — non-destructive). Prefer `takeMesh` on the
543    /// hot streaming path; this stays for callers that read meshes more than once.
544    #[wasm_bindgen]
545    pub fn get(&self, index: usize) -> Option<MeshDataJs> {
546        self.meshes.get(index).cloned()
547    }
548
549    /// #1097 perf: MOVE the mesh at `index` out of the collection (the Vec
550    /// buffers are `std::mem::take`-n, leaving an empty stub). The streaming
551    /// worker reads each mesh exactly once, so moving avoids the full vertex-
552    /// data clone `get` pays — one fewer copy of positions/normals/indices/uvs/
553    /// texture per mesh (the JS getters still do the single Rust→JS copy). Calling
554    /// it twice for the same index yields the second call a DEFAULT mesh:
555    /// `expressId` 0 and every buffer empty, rather than the metadata-bearing
556    /// husk the hand-written copy used to leave. The method is read-once by
557    /// contract and the wasm-contract test pins this.
558    #[wasm_bindgen(js_name = takeMesh)]
559    pub fn take_mesh(&mut self, index: usize) -> Option<MeshDataJs> {
560        self.meshes.get_mut(index).map(std::mem::take)
561    }
562
563    /// Get total vertex count across all meshes
564    #[wasm_bindgen(getter, js_name = totalVertices)]
565    pub fn total_vertices(&self) -> usize {
566        self.meshes.iter().map(|m| m.positions.len() / 3).sum()
567    }
568
569    /// Get total triangle count across all meshes
570    #[wasm_bindgen(getter, js_name = totalTriangles)]
571    pub fn total_triangles(&self) -> usize {
572        self.meshes.iter().map(|m| m.indices.len() / 3).sum()
573    }
574
575    /// Get RTC offset X (for converting local coords back to world coords)
576    /// Add this to local X coordinates to get world X coordinates
577    #[wasm_bindgen(getter, js_name = rtcOffsetX)]
578    pub fn rtc_offset_x(&self) -> f64 {
579        self.rtc_offset_x
580    }
581
582    /// Get RTC offset Y
583    #[wasm_bindgen(getter, js_name = rtcOffsetY)]
584    pub fn rtc_offset_y(&self) -> f64 {
585        self.rtc_offset_y
586    }
587
588    /// Get RTC offset Z
589    #[wasm_bindgen(getter, js_name = rtcOffsetZ)]
590    pub fn rtc_offset_z(&self) -> f64 {
591        self.rtc_offset_z
592    }
593
594    /// Check if an RTC offset was applied to these meshes (any non-zero
595    /// component). It can be inside 10 km: the placement-bounds fallback
596    /// re-bases on the bbox centre when a corner is past 10 km (#4643).
597    #[wasm_bindgen(js_name = hasRtcOffset)]
598    pub fn has_rtc_offset(&self) -> bool {
599        self.rtc_offset_x != 0.0 || self.rtc_offset_y != 0.0 || self.rtc_offset_z != 0.0
600    }
601
602    /// Get building rotation angle in radians (from IfcSite placement)
603    /// Returns None if no rotation was detected
604    #[wasm_bindgen(getter, js_name = buildingRotation)]
605    pub fn building_rotation(&self) -> Option<f64> {
606        self.building_rotation
607    }
608
609    /// Number of per-entity geometry fingerprints recorded.
610    #[wasm_bindgen(getter, js_name = geometryHashCount)]
611    pub fn geometry_hash_count(&self) -> usize {
612        self.geometry_hash_ids.len()
613    }
614
615    /// The batch's typed CSG / opening diagnostics as a JS object (the
616    /// `GeometryDiagnostics` contract), or `undefined` if none were recorded. The
617    /// worker merges these across batches. One serialized value keeps the rich
618    /// nested shape as a single FFI crossing instead of dozens of getters.
619    #[wasm_bindgen(getter, js_name = diagnostics)]
620    pub fn diagnostics(&self) -> JsValue {
621        match self.diagnostics.as_ref() {
622            Some(d) => serde_wasm_bindgen::to_value(d).unwrap_or(JsValue::UNDEFINED),
623            None => JsValue::UNDEFINED,
624        }
625    }
626}
627
628impl MeshCollection {
629    /// Create new empty collection
630    pub fn new() -> Self {
631        Self {
632            meshes: Vec::new(),
633            rtc_offset_x: 0.0,
634            rtc_offset_y: 0.0,
635            rtc_offset_z: 0.0,
636            building_rotation: None,
637            geometry_hash_ids: Vec::new(),
638            geometry_hash_values: Vec::new(),
639            geometry_aabb_values: Vec::new(),
640            geometry_volume_values: Vec::new(),
641            geometry_closure_flags: Vec::new(),
642            diagnostics: None,
643        }
644    }
645
646    /// Create new collection with capacity hint
647    pub fn with_capacity(capacity: usize) -> Self {
648        Self {
649            meshes: Vec::with_capacity(capacity),
650            rtc_offset_x: 0.0,
651            rtc_offset_y: 0.0,
652            rtc_offset_z: 0.0,
653            building_rotation: None,
654            geometry_hash_ids: Vec::new(),
655            geometry_hash_values: Vec::new(),
656            geometry_aabb_values: Vec::new(),
657            geometry_volume_values: Vec::new(),
658            geometry_closure_flags: Vec::new(),
659            diagnostics: None,
660        }
661    }
662
663    /// Add a mesh to the collection
664    #[inline]
665    pub fn add(&mut self, mesh: MeshDataJs) {
666        self.meshes.push(mesh);
667    }
668
669    /// Attach the batch's typed CSG / opening diagnostics (the public
670    /// `GeometryDiagnostics` contract).
671    #[inline]
672    pub fn set_diagnostics(&mut self, diagnostics: ifc_lite_geometry::GeometryDiagnostics) {
673        // Skip all-zero diagnostics so the `diagnostics` getter stays `undefined`
674        // when no opening / CSG activity happened — lets a consumer gate on
675        // presence (`if event.diagnostics`) as well as on counts.
676        if !diagnostics.is_empty() {
677            self.diagnostics = Some(diagnostics);
678        }
679    }
680
681    /// Create from vec of meshes
682    pub fn from_vec(meshes: Vec<MeshDataJs>) -> Self {
683        Self {
684            meshes,
685            rtc_offset_x: 0.0,
686            rtc_offset_y: 0.0,
687            rtc_offset_z: 0.0,
688            building_rotation: None,
689            geometry_hash_ids: Vec::new(),
690            geometry_hash_values: Vec::new(),
691            geometry_aabb_values: Vec::new(),
692            geometry_volume_values: Vec::new(),
693            geometry_closure_flags: Vec::new(),
694            diagnostics: None,
695        }
696    }
697
698    /// Get number of meshes (internal)
699    pub fn len(&self) -> usize {
700        self.meshes.len()
701    }
702
703    /// Check if collection is empty
704    pub fn is_empty(&self) -> bool {
705        self.meshes.is_empty()
706    }
707
708    /// Set the RTC offset (called during parsing when large coordinates are detected)
709    pub fn set_rtc_offset(&mut self, x: f64, y: f64, z: f64) {
710        self.rtc_offset_x = x;
711        self.rtc_offset_y = y;
712        self.rtc_offset_z = z;
713    }
714
715    /// Set the building rotation angle in radians
716    pub fn set_building_rotation(&mut self, rotation: Option<f64>) {
717        self.building_rotation = rotation;
718    }
719}
720
721impl Clone for MeshCollection {
722    fn clone(&self) -> Self {
723        Self {
724            meshes: self.meshes.clone(),
725            rtc_offset_x: self.rtc_offset_x,
726            rtc_offset_y: self.rtc_offset_y,
727            rtc_offset_z: self.rtc_offset_z,
728            building_rotation: self.building_rotation,
729            geometry_hash_ids: self.geometry_hash_ids.clone(),
730            geometry_hash_values: self.geometry_hash_values.clone(),
731            geometry_aabb_values: self.geometry_aabb_values.clone(),
732            geometry_volume_values: self.geometry_volume_values.clone(),
733            geometry_closure_flags: self.geometry_closure_flags.clone(),
734            diagnostics: self.diagnostics.clone(),
735        }
736    }
737}
738
739impl Default for MeshCollection {
740    fn default() -> Self {
741        Self::new()
742    }
743}
744
745/// The per-entity geometry-fingerprint arrays and their push API. A CHILD
746/// module so it can touch this module's private `MeshCollection` fields.
747#[path = "mesh_fingerprint.rs"]
748mod fingerprint;
749pub use fingerprint::GeometryFingerprint;
750
751#[cfg(test)]
752#[path = "mesh_tests.rs"]
753mod tests;