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;