Skip to main content

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