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;