ifc_lite_export/gltf.rs
1// SPDX-License-Identifier: MPL-2.0
2//! glTF 2.0 / **GLB** exporter — triangulated render geometry as a binary glTF container.
3//!
4//! Source = `ifc_lite_processing::process_geometry` (the unified Rust mesh pipeline).
5//! Mirrors the structure of the prior `packages/export/src/gltf-exporter.ts`:
6//! KHR_materials_unlit, RGBA-deduped materials, one mesh+node per element, three
7//! bufferViews (positions / normals / indices) packed into a single binary buffer.
8//!
9//! Improvement over the TS exporter: the per-mesh `origin` (RTC offset) is emitted as a
10//! glTF **node translation** and positions stay LOCAL, so building/georef-scale placements
11//! keep f32 vertex precision (node translation carries the large offset). When `origin` is
12//! zero (local-frame feature off) the output is byte-equivalent to the old TS path.
13
14use std::sync::Arc;
15
16use rustc_hash::{FxHashMap, FxHashSet};
17// Only the test module uses std's SipHash-keyed `HashMap` (its own scratch maps); the
18// exporter's own dedup/material maps are all `FxHashMap`.
19#[cfg(test)]
20use std::collections::HashMap;
21
22// The GLB binary layout is little-endian, and several hot paths reinterpret f32/u32
23// slices as raw bytes via `bytemuck::cast_slice` (vertex/normal/index encoding and the
24// content-dedup hash) — only byte-equivalent to `to_le_bytes` on a LE target. Every
25// target this crate ships for (wasm32, x86_64, aarch64) is LE; make a big-endian build
26// fail HERE rather than silently emit corrupt GLBs.
27const _: () = assert!(
28 cfg!(target_endian = "little"),
29 "ifc-lite-export assumes a little-endian target (GLB is LE; cast_slice byte reinterpretation)",
30);
31
32use crate::color_space::srgba_to_linear;
33use crate::error::ExportError;
34use ifc_lite_core::EntityIndex;
35use ifc_lite_geometry::{
36 collate_refs_in_basis, InstanceMeshRef, InstanceMeta, InstanceTemplate, Matrix4,
37};
38use ifc_lite_processing::{
39 build_entity_index_parallel, process_geometry_filtered_with_quality,
40 process_geometry_streaming_filtered_with_options, MeshCoordinateSpace, MeshData,
41 OpeningFilterMode, ProcessingResult, StreamingOptions, TessellationQuality,
42};
43use serde::Serialize;
44use serde_json::{json, Value};
45
46// Split-out submodules (kept child modules so they can reach this module's private
47// assembler internals via `super`; the code moved verbatim, so output is unchanged).
48mod from_meshes;
49mod matrix;
50
51pub use from_meshes::{export_glb_from_meshes, try_export_glb_from_meshes};
52use matrix::{
53 affine_inverse, compose_world_meta, occurrence_node_matrix, occurrence_node_matrix_composed,
54};
55
56/// Options for glTF/GLB export.
57///
58/// ```
59/// # use ifc_lite_export::{GltfOptions, TessellationQuality};
60/// let opts = GltfOptions::default().with_tessellation_quality(TessellationQuality::Low);
61/// ```
62///
63/// `#[non_exhaustive]` plus builders, for the reason [`ModelOptions`] gives:
64/// this will grow, and `non_exhaustive` forbids EVERY struct expression from
65/// outside this crate, `..Default::default()` included. Builders are the shape
66/// that keeps an external caller compiling when a field is added. The fields
67/// stay public, so reading one or assigning to one still works.
68///
69/// [`ModelOptions`]: crate::ModelOptions
70#[non_exhaustive]
71pub struct GltfOptions {
72 /// Attach `asset.extras` (counts) and per-node `extras.expressId`.
73 pub include_metadata: bool,
74 /// Restrict to these express ids (isolation allowlist). `None` ⇒ no filter,
75 /// every mesh is a candidate; `Some(empty)` ⇒ the filter is ACTIVE and
76 /// matched nothing, so every mesh is excluded. Do not collapse the two —
77 /// an active isolation that matches zero elements must export nothing, not
78 /// silently fall back to the whole model (matches the null-vs-empty
79 /// convention already used by `packages/export/src/reference-collector.ts`
80 /// and `packages/renderer/src/entity-visibility.ts` on the TS side).
81 pub isolated: Option<Vec<u32>>,
82 /// Exclude these express ids (hidden in the viewer).
83 pub hidden: Vec<u32>,
84 /// Exclude meshes whose IFC type is in this set (class-level visibility toggle).
85 pub hidden_types: Vec<String>,
86 /// Emit standard (lit) PBR materials so external viewers shade the model from
87 /// its normals. When `false`, materials are tagged `KHR_materials_unlit` and
88 /// render flat with just the apparent base colour (the historical behaviour,
89 /// kept for colour-accurate exports). Default `true`. (#1321)
90 pub lit: bool,
91 /// Make every material self-illuminating by setting `emissiveFactor` to its
92 /// base colour. Targets renderers with no ambient/IBL and a single hard sun —
93 /// notably **Google Earth**, which ignores `KHR_materials_unlit` and lit the
94 /// model so dark that shadow-side faces went black (#1427). `emissiveFactor`
95 /// is core glTF 2.0 (not an extension), so every compliant renderer honours
96 /// it; the base colour is kept too, so a viewer that ignores emissive is no
97 /// worse than today (never blacker than the lit result). Default `false`.
98 pub emissive: bool,
99 /// Per-model id stamped into every node's `extras.modelId` (federation: lets a
100 /// host distinguish elements from different models that share express-id space).
101 /// `None` ⇒ single model, no `modelId` emitted. Requires `include_metadata`.
102 pub model_id: Option<String>,
103 /// Quantize geometry with `KHR_mesh_quantization`: 16-bit SHORT positions +
104 /// normals per-mesh over each mesh's own bbox, with the dequant on a node transform.
105 /// ~2x smaller, precision-safe (sub-2 mm per-mesh on the measured corpus). Default
106 /// `false` — the unquantized f32 output is byte-identical to before. three.js
107 /// `GLTFLoader` decodes it natively, but a loader without the extension cannot open
108 /// the file (it is `extensionsRequired`), so only enable when the consumer supports it.
109 pub quantize: bool,
110 /// Tessellation density. `Medium` is the golden-output identity; coarser
111 /// levels trade curve fidelity for vertex count on tube-heavy models.
112 pub tessellation_quality: TessellationQuality,
113}
114
115impl Default for GltfOptions {
116 fn default() -> Self {
117 Self {
118 include_metadata: false,
119 isolated: None,
120 hidden: Vec::new(),
121 hidden_types: Vec::new(),
122 lit: true,
123 emissive: false,
124 model_id: None,
125 quantize: false,
126 tessellation_quality: TessellationQuality::Medium,
127 }
128 }
129}
130
131impl GltfOptions {
132 /// See [`GltfOptions::include_metadata`].
133 #[must_use]
134 pub fn with_include_metadata(mut self, yes: bool) -> Self {
135 self.include_metadata = yes;
136 self
137 }
138
139 /// See [`GltfOptions::isolated`]. `None` ⇒ no filter; `Some(ids)` (empty or
140 /// not) ⇒ an active allowlist — pass `Some(vec![])` deliberately to export
141 /// nothing, never as a stand-in for "no filter".
142 #[must_use]
143 pub fn with_isolated(mut self, ids: Option<Vec<u32>>) -> Self {
144 self.isolated = ids;
145 self
146 }
147
148 /// See [`GltfOptions::hidden`].
149 #[must_use]
150 pub fn with_hidden(mut self, ids: Vec<u32>) -> Self {
151 self.hidden = ids;
152 self
153 }
154
155 /// See [`GltfOptions::hidden_types`].
156 #[must_use]
157 pub fn with_hidden_types(mut self, types: Vec<String>) -> Self {
158 self.hidden_types = types;
159 self
160 }
161
162 /// See [`GltfOptions::lit`].
163 #[must_use]
164 pub fn with_lit(mut self, yes: bool) -> Self {
165 self.lit = yes;
166 self
167 }
168
169 /// See [`GltfOptions::emissive`].
170 #[must_use]
171 pub fn with_emissive(mut self, yes: bool) -> Self {
172 self.emissive = yes;
173 self
174 }
175
176 /// See [`GltfOptions::model_id`].
177 #[must_use]
178 pub fn with_model_id(mut self, id: Option<String>) -> Self {
179 self.model_id = id;
180 self
181 }
182
183 /// See [`GltfOptions::quantize`].
184 #[must_use]
185 pub fn with_quantize(mut self, yes: bool) -> Self {
186 self.quantize = yes;
187 self
188 }
189
190 /// See [`GltfOptions::tessellation_quality`].
191 #[must_use]
192 pub fn with_tessellation_quality(mut self, quality: TessellationQuality) -> Self {
193 self.tessellation_quality = quality;
194 self
195 }
196}
197
198/// Coverage stats for a GLB export.
199#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
200pub struct GltfStats {
201 pub meshes: usize,
202 pub vertices: usize,
203 pub triangles: usize,
204 pub materials: usize,
205 /// Rep-identity groups that were instanced WITHOUT the #3666 reconstruction
206 /// check: the shared template's geometry is substituted at each occurrence's
207 /// pose on the strength of the identity hash alone, which a merged
208 /// multi-model file has been measured to collide (the occurrence still
209 /// renders, up to 2m from where it belongs).
210 ///
211 /// Always 0 from the in-memory assembler, which verifies every exact-tier
212 /// pairing. Non-zero only from [`try_export_glb_streaming_bounded`], which holds
213 /// a PLAN and not geometry (`retain_emitted_meshes: false` is the whole
214 /// reason it bounds memory), so it has no occurrence vertices to compare and
215 /// cannot run the check as written. That gap was a comment inside the
216 /// bounded assembler and invisible to its callers; this is it at the seam,
217 /// where a caller choosing between the two paths can read it.
218 pub unverified_instance_groups: usize,
219}
220
221// ── glTF 2.0 JSON schema (subset) ──────────────────────────────────────────
222
223#[derive(Serialize)]
224struct Gltf {
225 asset: Asset,
226 scene: u32,
227 scenes: Vec<Scene>,
228 nodes: Vec<Node>,
229 meshes: Vec<Mesh>,
230 #[serde(skip_serializing_if = "Option::is_none")]
231 materials: Option<Vec<Material>>,
232 accessors: Vec<Accessor>,
233 #[serde(rename = "bufferViews")]
234 buffer_views: Vec<BufferView>,
235 buffers: Vec<Buffer>,
236 #[serde(rename = "extensionsUsed", skip_serializing_if = "Option::is_none")]
237 extensions_used: Option<Vec<&'static str>>,
238 #[serde(rename = "extensionsRequired", skip_serializing_if = "Option::is_none")]
239 extensions_required: Option<Vec<&'static str>>,
240}
241
242#[derive(Serialize)]
243struct Asset {
244 version: &'static str,
245 generator: &'static str,
246 #[serde(skip_serializing_if = "Option::is_none")]
247 extras: Option<Value>,
248}
249
250#[derive(Serialize)]
251struct Scene {
252 nodes: Vec<u32>,
253}
254
255#[derive(Serialize)]
256struct Node {
257 #[serde(skip_serializing_if = "Option::is_none")]
258 mesh: Option<u32>,
259 #[serde(skip_serializing_if = "Option::is_none")]
260 children: Option<Vec<u32>>,
261 #[serde(skip_serializing_if = "Option::is_none")]
262 translation: Option<[f64; 3]>,
263 // Unit quaternion (x, y, z, w), glTF order. Carries a rotated `IfcSite`
264 // placement on the scene root. TRS rather than `matrix` because `matrix` is
265 // f32 here, and a megametre site translation in f32 lands on a 0.125 m
266 // grid, coarser than the defect it would be fixing. A unit quaternion's
267 // components are all within one, so f32 costs nothing there.
268 #[serde(skip_serializing_if = "Option::is_none")]
269 rotation: Option<[f32; 4]>,
270 // Per-mesh dequantization scale for the `KHR_mesh_quantization` path: maps the
271 // normalized SHORT positions back to the mesh's local bbox half-extent. Combined with
272 // `translation` it forms the dequant TRS; absent (and thus identity) on the f32 path.
273 #[serde(skip_serializing_if = "Option::is_none")]
274 scale: Option<[f64; 3]>,
275 // Column-major 4x4 (glTF convention) placing an instanced occurrence's shared
276 // template geometry at its world pose. Mutually exclusive with `translation`
277 // (glTF forbids both on one node); instanced occurrence nodes use `matrix`,
278 // flat/root nodes use `translation`.
279 #[serde(skip_serializing_if = "Option::is_none")]
280 matrix: Option<[f32; 16]>,
281 #[serde(skip_serializing_if = "Option::is_none")]
282 extras: Option<Value>,
283}
284
285#[derive(Serialize)]
286struct Mesh {
287 primitives: Vec<Primitive>,
288}
289
290#[derive(Serialize)]
291struct Primitive {
292 attributes: Attributes,
293 indices: u32,
294 #[serde(skip_serializing_if = "Option::is_none")]
295 material: Option<u32>,
296}
297
298#[derive(Serialize)]
299struct Attributes {
300 #[serde(rename = "POSITION")]
301 position: u32,
302 #[serde(rename = "NORMAL")]
303 normal: u32,
304}
305
306#[derive(Serialize)]
307struct Material {
308 #[serde(rename = "pbrMetallicRoughness")]
309 pbr: Pbr,
310 // `Some` only for emissive exports (#1427): RGB self-illumination equal to the
311 // base colour, so renderers without ambient/IBL (Google Earth) still show the
312 // true colour instead of a sun-shaded near-black. Core glTF 2.0, so universal.
313 #[serde(rename = "emissiveFactor", skip_serializing_if = "Option::is_none")]
314 emissive_factor: Option<[f32; 3]>,
315 // `Some` only for unlit exports (#1321); a lit material omits it entirely so
316 // the viewer applies standard PBR lighting from the mesh normals.
317 #[serde(skip_serializing_if = "Option::is_none")]
318 extensions: Option<Extensions>,
319 #[serde(rename = "alphaMode", skip_serializing_if = "Option::is_none")]
320 alpha_mode: Option<&'static str>,
321 // IFC face winding isn't reliably outward (the viewer renders cull-none /
322 // double-sided), so single-sided glTF consumers would cull inward-wound or
323 // coplanar faces → "missing geometry". Match the viewer: always double-sided.
324 #[serde(rename = "doubleSided")]
325 double_sided: bool,
326}
327
328#[derive(Serialize)]
329struct Pbr {
330 #[serde(rename = "baseColorFactor")]
331 base_color_factor: [f32; 4],
332 #[serde(rename = "metallicFactor")]
333 metallic_factor: f32,
334 #[serde(rename = "roughnessFactor")]
335 roughness_factor: f32,
336}
337
338#[derive(Serialize)]
339struct Extensions {
340 #[serde(rename = "KHR_materials_unlit")]
341 khr_materials_unlit: EmptyObj,
342}
343
344#[derive(Serialize)]
345struct EmptyObj {}
346
347#[derive(Serialize)]
348struct Accessor {
349 #[serde(rename = "bufferView")]
350 buffer_view: u32,
351 #[serde(rename = "byteOffset")]
352 byte_offset: u32,
353 #[serde(rename = "componentType")]
354 component_type: u32,
355 count: u32,
356 #[serde(rename = "type")]
357 ty: &'static str,
358 // `KHR_mesh_quantization`: marks SHORT/BYTE position+normal accessors as normalized
359 // (the renderer maps the integer range to [-1,1]). Omitted (None) on the f32 path.
360 #[serde(skip_serializing_if = "Option::is_none")]
361 normalized: Option<bool>,
362 #[serde(skip_serializing_if = "Option::is_none")]
363 min: Option<[f32; 3]>,
364 #[serde(skip_serializing_if = "Option::is_none")]
365 max: Option<[f32; 3]>,
366}
367
368#[derive(Serialize)]
369struct BufferView {
370 buffer: u32,
371 #[serde(rename = "byteOffset")]
372 byte_offset: u32,
373 #[serde(rename = "byteLength")]
374 byte_length: u32,
375 #[serde(rename = "byteStride", skip_serializing_if = "Option::is_none")]
376 byte_stride: Option<u32>,
377 target: u32,
378}
379
380#[derive(Serialize)]
381struct Buffer {
382 #[serde(rename = "byteLength")]
383 byte_length: u32,
384 // Relative path to an external `.bin` (multi-buffer glTF). `None` for the embedded
385 // GLB binary chunk (buffer 0, uri-less by spec) — omitted, so GLB output is unchanged.
386 #[serde(skip_serializing_if = "Option::is_none")]
387 uri: Option<String>,
388}
389
390// ── Build ───────────────────────────────────────────────────────────────────
391
392/// Precomputed visibility filter. The express-id allow/deny lists and the hidden-type
393/// set are hashed ONCE per export instead of linearly scanned per mesh — `mesh_visible`
394/// ran `Vec::contains` over `opts.hidden`/`opts.isolated`/`opts.hidden_types` for every
395/// mesh, i.e. O(meshes × filter_len), and on the bounded path it ran in BOTH passes.
396/// Same decisions as the old scan, so no output changes.
397struct VisibilityFilter {
398 hidden: FxHashSet<u32>,
399 isolated: FxHashSet<u32>,
400 isolated_active: bool,
401 hidden_types: FxHashSet<String>,
402}
403
404impl VisibilityFilter {
405 fn new(opts: &GltfOptions) -> Self {
406 Self {
407 hidden: opts.hidden.iter().copied().collect(),
408 isolated: opts.isolated.iter().flatten().copied().collect(),
409 isolated_active: opts.isolated.is_some(),
410 hidden_types: opts.hidden_types.iter().cloned().collect(),
411 }
412 }
413
414 fn visible(&self, mesh: &MeshData) -> bool {
415 if mesh.geometry_class == 2 {
416 return false; // instanced type library duplicates occurrence geometry
417 }
418 if self.hidden.contains(&mesh.express_id) {
419 return false;
420 }
421 if self.isolated_active && !self.isolated.contains(&mesh.express_id) {
422 return false;
423 }
424 if self.hidden_types.contains(&mesh.ifc_type) {
425 return false;
426 }
427 // Geometry sanity: matching, non-empty, triangulated.
428 !mesh.indices.is_empty()
429 && mesh.positions.len() >= 9
430 && mesh.positions.len().is_multiple_of(3)
431 && mesh.normals.len() == mesh.positions.len()
432 }
433}
434
435/// Convenience wrapper that builds a one-shot [`VisibilityFilter`] — used by tests that
436/// check a single mesh. Production hot loops build the filter once and call
437/// [`VisibilityFilter::visible`] directly, so this is test-only.
438#[cfg(test)]
439fn mesh_visible(mesh: &MeshData, opts: &GltfOptions) -> bool {
440 VisibilityFilter::new(opts).visible(mesh)
441}
442
443/// Material dedup key: RGBA rounded to 2 decimals (matches the TS exporter's key).
444fn color_key(c: [f32; 4]) -> (i32, i32, i32, i32) {
445 let r = |v: f32| (v * 100.0).round() as i32;
446 (r(c[0]), r(c[1]), r(c[2]), r(c[3]))
447}
448
449/// One material for a mesh colour: the single source of the lit / unlit / emissive
450/// rules, shared by every assembler so the paths cannot drift. `emissive` takes
451/// precedence over `unlit` because the KHR_materials_unlit spec mandates
452/// `emissiveFactor = 0`, making the two mutually exclusive; never emit a
453/// spec-violating material that declares unlit AND a non-zero emissiveFactor (#1427).
454fn make_material(color: [f32; 4], lit: bool, emissive: bool) -> Material {
455 let linear_rgb = srgba_to_linear(color);
456 Material {
457 pbr: Pbr {
458 base_color_factor: linear_rgb,
459 metallic_factor: 0.0,
460 roughness_factor: 1.0,
461 },
462 emissive_factor: emissive.then_some([linear_rgb[0], linear_rgb[1], linear_rgb[2]]),
463 extensions: if lit || emissive {
464 None
465 } else {
466 Some(Extensions { khr_materials_unlit: EmptyObj {} })
467 },
468 alpha_mode: if color[3] < 1.0 { Some("BLEND") } else { None },
469 double_sided: true,
470 }
471}
472
473/// 128-bit content key for the flat-remainder dedup: the mesh's LOCAL geometry
474/// (positions / normals / indices, hashed as raw bit patterns) folded with its
475/// colour. Two meshes the rep-identity collator did NOT flag instanceable but whose
476/// BAKED local buffers are nonetheless bit-identical (same shape, same orientation,
477/// same colour) share one emitted glTF mesh placed by a node translation. Colour is
478/// in the key because the glTF material rides the primitive, not the node.
479///
480/// One single-pass `xxh3_128` over the three attribute runs (reinterpreted as bytes,
481/// which is a no-op on the LE-only targets this crate builds for — the exact same bit
482/// patterns the old per-`f32::to_bits` fold hashed) plus a `u64` length frame before
483/// each run so concatenated buffers can't alias. The digest only ever gates key
484/// EQUALITY (dedup grouping) and never appears in the output, so bit-identical meshes
485/// still collapse together and the emitted GLB is byte-for-byte unchanged; the win is
486/// ~20-50x less hashing on large models (xxh3 bulk vs element-wise SipHash-1-3).
487///
488/// Because the digest never leaves this function, the IMPLEMENTATION is free: any
489/// sound 128-bit hash groups the same meshes. That is what makes `twox-hash` (MIT)
490/// substitutable for `xxhash-rust` (BSL-1.0) without touching a single output byte.
491fn geom_color_key(positions: &[f32], normals: &[f32], indices: &[u32], color: [f32; 4]) -> u128 {
492 use twox_hash::XxHash3_128;
493 let mut h = XxHash3_128::new();
494 h.write(&(positions.len() as u64).to_le_bytes());
495 h.write(bytemuck::cast_slice::<f32, u8>(positions));
496 h.write(&(normals.len() as u64).to_le_bytes());
497 h.write(bytemuck::cast_slice::<f32, u8>(normals));
498 h.write(&(indices.len() as u64).to_le_bytes());
499 h.write(bytemuck::cast_slice::<u32, u8>(indices));
500 let (r, g, b, a) = color_key(color);
501 h.write(&r.to_le_bytes());
502 h.write(&g.to_le_bytes());
503 h.write(&b.to_le_bytes());
504 h.write(&a.to_le_bytes());
505 h.finish_128()
506}
507
508/// Streams geometry into one or more glTF buffers. Each buffer holds three bufferViews
509/// (positions | normals | indices); a buffer is flushed when adding the next mesh would
510/// push it over `cap`. With `cap = usize::MAX` and no `sink` it is a single embedded
511/// buffer (the GLB path) and produces byte-identical output to writing the three Vecs
512/// directly. With a `cap` and a `sink` it is multi-buffer glTF: each finished buffer's
513/// `.bin` goes to the sink (kept out of memory) and gets an external `uri`.
514struct Chunker<'s> {
515 pos: Vec<u8>,
516 norm: Vec<u8>,
517 idx: Vec<u8>,
518 buffer_views: Vec<BufferView>,
519 buffers: Vec<Buffer>,
520 vec3_stride: u32, // 8 quantized SHORT (6 tight + 2 pad), 12 f32
521 cap: usize,
522 next_buffer: u32,
523 sink: Option<&'s mut dyn FnMut(String, Vec<u8>)>,
524}
525
526impl<'s> Chunker<'s> {
527 fn new(vec3_stride: u32, cap: usize, sink: Option<&'s mut dyn FnMut(String, Vec<u8>)>) -> Self {
528 Self {
529 pos: Vec::new(),
530 norm: Vec::new(),
531 idx: Vec::new(),
532 buffer_views: Vec::new(),
533 buffers: Vec::new(),
534 vec3_stride,
535 cap,
536 next_buffer: 0,
537 sink,
538 }
539 }
540
541 /// The bufferView index the current (not-yet-flushed) chunk's POSITION will take.
542 /// Normals are `+ 1`, indices `+ 2`. Stable until the next `flush`.
543 fn bv_base(&self) -> u32 {
544 self.buffer_views.len() as u32
545 }
546
547 /// Flush before pushing a mesh of `next_bytes` if it would overflow the current
548 /// (non-empty) chunk. No-op at `cap = usize::MAX`.
549 fn maybe_flush(&mut self, next_bytes: usize) {
550 let used = self.pos.len() + self.norm.len() + self.idx.len();
551 if used > 0 && used.saturating_add(next_bytes) > self.cap {
552 self.flush();
553 }
554 }
555
556 fn flush(&mut self) {
557 if self.pos.is_empty() {
558 // The single-buffer GLB path keeps exactly one (possibly empty) buffer to
559 // match the legacy output byte-for-byte; multi-buffer skips empty chunks.
560 if self.sink.is_none() && self.next_buffer == 0 {
561 self.buffers.push(Buffer { byte_length: 0, uri: None });
562 self.next_buffer += 1;
563 }
564 return;
565 }
566 // Lengths in usize; assert the 4 GiB limit BEFORE narrowing to u32, so an
567 // over-limit single buffer (GLB path, cap = usize::MAX) fails loudly with the
568 // message the worker's `OutputTooLarge` classifier matches, rather than silently
569 // wrapping `as u32` into a corrupt glTF (release builds set overflow-checks off).
570 // Multi-buffer chunks are < cap, so they never approach this.
571 let (pl, nl, il) = (self.pos.len(), self.norm.len(), self.idx.len());
572 let total = pl + nl + il;
573 assert!(
574 total <= u32::MAX as usize,
575 "GLB binary buffer is {total} bytes, over the glTF 32-bit buffer limit \
576 (4 GiB); the model is too large for a single GLB",
577 );
578 let buf = self.next_buffer;
579 self.buffer_views.push(BufferView {
580 buffer: buf, byte_offset: 0, byte_length: pl as u32,
581 byte_stride: Some(self.vec3_stride), target: 34962,
582 });
583 self.buffer_views.push(BufferView {
584 buffer: buf, byte_offset: pl as u32, byte_length: nl as u32,
585 byte_stride: Some(self.vec3_stride), target: 34962,
586 });
587 self.buffer_views.push(BufferView {
588 buffer: buf, byte_offset: (pl + nl) as u32, byte_length: il as u32,
589 byte_stride: None, target: 34963,
590 });
591 match self.sink.as_mut() {
592 Some(sink) => {
593 // Multi-buffer: concatenate this chunk's three runs, hand it to the sink,
594 // and reset the runs for the next chunk.
595 let mut bin = Vec::with_capacity(total);
596 bin.extend_from_slice(&self.pos);
597 bin.extend_from_slice(&self.norm);
598 bin.extend_from_slice(&self.idx);
599 self.pos.clear();
600 self.norm.clear();
601 self.idx.clear();
602 let name = format!("buffer{buf}.bin");
603 self.buffers.push(Buffer { byte_length: total as u32, uri: Some(name.clone()) });
604 sink(name, bin);
605 }
606 None => {
607 // Single embedded GLB buffer: leave pos/norm/idx in place so the packer
608 // writes them straight into the container — no intermediate concatenated
609 // copy. `cap == usize::MAX` on this path, so `flush` runs exactly once and
610 // the runs are never reset mid-stream. Pin that load-bearing invariant: a
611 // second non-empty flush here would push a duplicate buffer AND leave the
612 // now-unreset runs to be written twice.
613 debug_assert!(
614 self.next_buffer == 0,
615 "single-buffer GLB path must flush exactly once (cap == usize::MAX)",
616 );
617 self.buffers.push(Buffer { byte_length: total as u32, uri: None });
618 }
619 }
620 self.next_buffer += 1;
621 }
622}
623
624/// Emit one mesh's geometry (positions/normals/indices baked by `vertex_offset`),
625/// its three accessors, deduped material, and a glTF `Mesh`; returns the mesh
626/// index. `vertex_offset` is added to each local position before the f32 downcast:
627/// for a UNIQUE mesh it is `origin - scene_center` (the self-contained
628/// world-minus-center bake), for a SHARED mesh it is zero (pure local geometry,
629/// placed via the occurrence node's translation). Bumps the deduped `stats`.
630#[allow(clippy::too_many_arguments)]
631fn push_mesh(
632 ch: &mut Chunker,
633 accessors: &mut Vec<Accessor>,
634 meshes: &mut Vec<Mesh>,
635 materials: &mut Vec<Material>,
636 material_map: &mut FxHashMap<(i32, i32, i32, i32), u32>,
637 mesh: &MeshView,
638 vertex_offset: [f64; 3],
639 lit: bool,
640 emissive: bool,
641 stats: &mut GltfStats,
642) -> u32 {
643 let nverts = (mesh.positions.len() / 3) as u32;
644 // f32: 24 bytes/vertex (pos+normal) + 4/index. Flush before writing if needed.
645 ch.maybe_flush(mesh.positions.len() * 8 + mesh.indices.len() * 4);
646 let bv = ch.bv_base();
647 let pos_off = ch.pos.len() as u32;
648 let norm_off = ch.norm.len() as u32;
649 let idx_off = ch.idx.len() as u32;
650
651 // One reservation per run instead of amortized regrowth on every 4-byte push.
652 ch.pos.reserve(mesh.positions.len() * 4);
653 ch.norm.reserve(mesh.normals.len() * 4);
654 ch.idx.reserve(mesh.indices.len() * 4);
655
656 let mut min = [f32::INFINITY; 3];
657 let mut max = [f32::NEG_INFINITY; 3];
658 for p in mesh.positions.chunks_exact(3) {
659 // Bake each component (f64 add, monotone f32 downcast) into a 12-byte vertex
660 // and write it in one extend; the bytes equal three back-to-back
661 // `f32::to_le_bytes`, so the buffer is unchanged.
662 let mut vbuf = [0u8; 12];
663 for k in 0..3 {
664 let baked = (p[k] as f64 + vertex_offset[k]) as f32;
665 vbuf[k * 4..k * 4 + 4].copy_from_slice(&baked.to_le_bytes());
666 if baked < min[k] {
667 min[k] = baked;
668 }
669 if baked > max[k] {
670 max[k] = baked;
671 }
672 }
673 ch.pos.extend_from_slice(&vbuf);
674 }
675 // Normals + indices are copied verbatim (no per-element transform), so reinterpret
676 // each whole slice as LE bytes in one memcpy — byte-identical on LE targets (the
677 // only ones this crate builds for) to the per-element `to_le_bytes` loop.
678 ch.norm.extend_from_slice(bytemuck::cast_slice::<f32, u8>(mesh.normals));
679 ch.idx.extend_from_slice(bytemuck::cast_slice::<u32, u8>(mesh.indices));
680
681 let pos_acc = accessors.len() as u32;
682 accessors.push(Accessor {
683 buffer_view: bv,
684 byte_offset: pos_off,
685 component_type: 5126, // FLOAT
686 count: nverts,
687 ty: "VEC3",
688 normalized: None,
689 min: Some(min),
690 max: Some(max),
691 });
692 let norm_acc = accessors.len() as u32;
693 accessors.push(Accessor {
694 buffer_view: bv + 1,
695 byte_offset: norm_off,
696 component_type: 5126,
697 count: nverts,
698 ty: "VEC3",
699 normalized: None,
700 min: None,
701 max: None,
702 });
703 let idx_acc = accessors.len() as u32;
704 accessors.push(Accessor {
705 buffer_view: bv + 2,
706 byte_offset: idx_off,
707 component_type: 5125, // UNSIGNED_INT
708 count: mesh.indices.len() as u32,
709 ty: "SCALAR",
710 normalized: None,
711 min: None,
712 max: None,
713 });
714
715 let key = color_key(mesh.color);
716 let material = *material_map.entry(key).or_insert_with(|| {
717 let idx = materials.len() as u32;
718 materials.push(make_material(mesh.color, lit, emissive));
719 idx
720 });
721
722 let mesh_idx = meshes.len() as u32;
723 meshes.push(Mesh {
724 primitives: vec![Primitive {
725 attributes: Attributes { position: pos_acc, normal: norm_acc },
726 indices: idx_acc,
727 material: Some(material),
728 }],
729 });
730
731 stats.meshes += 1;
732 stats.vertices += nverts as usize;
733 stats.triangles += mesh.indices.len() / 3;
734 mesh_idx
735}
736
737/// Like [`push_mesh`] but emits `KHR_mesh_quantization` geometry: positions and
738/// normals as **normalized SHORT**, indices as **u16** when the mesh has <= 65535 verts
739/// (else u32). Positions are quantized per-mesh over the mesh's LOCAL bbox (no
740/// `vertex_offset` bake) — the returned `(center, half_extent)` is the dequant the caller
741/// folds onto the mesh's node (`local = center + half_extent * normalized`). Normals stay
742/// unit directions in local space; the renderer's normal matrix (inverse-transpose of the
743/// node's non-uniform dequant scale) restores world normals. Bumps the deduped `stats`.
744#[allow(clippy::too_many_arguments)]
745fn push_mesh_quantized(
746 ch: &mut Chunker,
747 accessors: &mut Vec<Accessor>,
748 meshes: &mut Vec<Mesh>,
749 materials: &mut Vec<Material>,
750 material_map: &mut FxHashMap<(i32, i32, i32, i32), u32>,
751 mesh: &MeshView,
752 lit: bool,
753 emissive: bool,
754 stats: &mut GltfStats,
755) -> (u32, [f64; 3], [f64; 3]) {
756 let nverts = (mesh.positions.len() / 3) as u32;
757 // 16 bytes/vertex: SHORT pos + SHORT normal, each padded to an 8-byte stride; plus up
758 // to 4 B/index. Used to decide whether to flush the chunk before writing this mesh.
759 ch.maybe_flush(nverts as usize * 16 + mesh.indices.len() * 4);
760 let bv = ch.bv_base();
761
762 // Per-mesh bbox -> center + half-extent. Guard degenerate (flat/zero) axes so the
763 // dequant scale is never zero (that axis quantizes to a constant 0).
764 let mut lo = [f64::INFINITY; 3];
765 let mut hi = [f64::NEG_INFINITY; 3];
766 for p in mesh.positions.chunks_exact(3) {
767 for k in 0..3 {
768 let v = p[k] as f64;
769 lo[k] = lo[k].min(v);
770 hi[k] = hi[k].max(v);
771 }
772 }
773 let mut center = [0.0f64; 3];
774 let mut half = [1.0f64; 3];
775 for k in 0..3 {
776 center[k] = (lo[k] + hi[k]) * 0.5;
777 let h = (hi[k] - lo[k]) * 0.5;
778 if h > 0.0 {
779 half[k] = h;
780 }
781 }
782
783 // Positions: SHORT normalized, per-axis to [-32767, 32767], then a 4th SHORT of
784 // padding. The pad makes the per-vertex stride 8 bytes: a bufferView shared by
785 // multiple accessors must declare a `byteStride`, which glTF requires to be a
786 // multiple of 4 (a tight SHORT VEC3 is 6).
787 ch.pos.reserve(nverts as usize * 8);
788 let pos_off = ch.pos.len() as u32;
789 let mut qmin = [i16::MAX; 3];
790 let mut qmax = [i16::MIN; 3];
791 for p in mesh.positions.chunks_exact(3) {
792 // 3 SHORT + 1 SHORT pad, built in a stack buffer and written once; the trailing
793 // two bytes stay zero (== `0i16` LE pad), so the stream is byte-identical.
794 let mut vbuf = [0u8; 8];
795 for k in 0..3 {
796 let n = ((p[k] as f64 - center[k]) / half[k]).clamp(-1.0, 1.0);
797 let q = (n * 32767.0).round() as i16;
798 vbuf[k * 2..k * 2 + 2].copy_from_slice(&q.to_le_bytes());
799 qmin[k] = qmin[k].min(q);
800 qmax[k] = qmax[k].max(q);
801 }
802 ch.pos.extend_from_slice(&vbuf);
803 }
804
805 // Normals: SHORT normalized. The mesh node carries the non-uniform dequant scale
806 // `half`, so the renderer applies its inverse-transpose `S(1/half)` to each stored
807 // normal. Pre-multiply by `half` and renormalize so that cancels and the rendered
808 // direction is the true normal. Padded to the same 8-byte stride as positions.
809 ch.norm.reserve(nverts as usize * 8);
810 let norm_off = ch.norm.len() as u32;
811 for nrm in mesh.normals.chunks_exact(3) {
812 let mut v = [
813 nrm[0] as f64 * half[0],
814 nrm[1] as f64 * half[1],
815 nrm[2] as f64 * half[2],
816 ];
817 let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
818 if len > 0.0 {
819 v = [v[0] / len, v[1] / len, v[2] / len];
820 }
821 // 3 SHORT + 1 SHORT pad in a stack buffer; trailing zero pad unchanged.
822 let mut vbuf = [0u8; 8];
823 for (k, c) in v.into_iter().enumerate() {
824 let q = (c.clamp(-1.0, 1.0) * 32767.0).round() as i16;
825 vbuf[k * 2..k * 2 + 2].copy_from_slice(&q.to_le_bytes());
826 }
827 ch.norm.extend_from_slice(&vbuf);
828 }
829
830 // Indices: u16 when every index fits (max index = nverts - 1 <= 65535, i.e.
831 // nverts <= 65536), else u32. Pad the section to 4 bytes so a following u32-index
832 // mesh stays 4-aligned regardless of this mesh's index width.
833 let small = nverts <= u16::MAX as u32 + 1;
834 let idx_off = ch.idx.len() as u32;
835 ch.idx.reserve(mesh.indices.len() * if small { 2 } else { 4 } + 3);
836 if small {
837 for &i in mesh.indices {
838 ch.idx.extend_from_slice(&(i as u16).to_le_bytes());
839 }
840 } else {
841 // u32 indices are copied verbatim — one memcpy of the whole run (LE targets).
842 ch.idx.extend_from_slice(bytemuck::cast_slice::<u32, u8>(mesh.indices));
843 }
844 while !ch.idx.len().is_multiple_of(4) {
845 ch.idx.push(0);
846 }
847
848 let qf = |a: [i16; 3]| [a[0] as f32, a[1] as f32, a[2] as f32];
849 let pos_acc = accessors.len() as u32;
850 accessors.push(Accessor {
851 buffer_view: bv,
852 byte_offset: pos_off,
853 component_type: 5122, // SHORT
854 count: nverts,
855 ty: "VEC3",
856 normalized: Some(true),
857 min: Some(qf(qmin)),
858 max: Some(qf(qmax)),
859 });
860 let norm_acc = accessors.len() as u32;
861 accessors.push(Accessor {
862 buffer_view: bv + 1,
863 byte_offset: norm_off,
864 component_type: 5122,
865 count: nverts,
866 ty: "VEC3",
867 normalized: Some(true),
868 min: None,
869 max: None,
870 });
871 let idx_acc = accessors.len() as u32;
872 accessors.push(Accessor {
873 buffer_view: bv + 2,
874 byte_offset: idx_off,
875 component_type: if small { 5123 } else { 5125 }, // UNSIGNED_SHORT / UNSIGNED_INT
876 count: mesh.indices.len() as u32,
877 ty: "SCALAR",
878 normalized: None,
879 min: None,
880 max: None,
881 });
882
883 let key = color_key(mesh.color);
884 let material = *material_map.entry(key).or_insert_with(|| {
885 let idx = materials.len() as u32;
886 materials.push(make_material(mesh.color, lit, emissive));
887 idx
888 });
889
890 let mesh_idx = meshes.len() as u32;
891 meshes.push(Mesh {
892 primitives: vec![Primitive {
893 attributes: Attributes { position: pos_acc, normal: norm_acc },
894 indices: idx_acc,
895 material: Some(material),
896 }],
897 });
898
899 stats.meshes += 1;
900 stats.vertices += nverts as usize;
901 stats.triangles += mesh.indices.len() / 3;
902 (mesh_idx, center, half)
903}
904
905/// Per-node `extras` (`expressId` / `ifcType`, plus `GlobalId` / `modelId` when
906/// available) when metadata is requested. `GlobalId` is the IFC EXPRESS attribute
907/// (PascalCase); the others are synthetic, hence camelCase.
908fn node_extras(
909 include_metadata: bool,
910 express_id: u32,
911 ifc_type: &str,
912 global_id: Option<&str>,
913 model_id: Option<&str>,
914) -> Option<Value> {
915 if !include_metadata {
916 return None;
917 }
918 let mut extras = json!({ "expressId": express_id, "ifcType": ifc_type });
919 let obj = extras.as_object_mut().expect("json! built an object");
920 if let Some(g) = global_id {
921 // EXPRESS PascalCase for the IFC attribute, per the export naming convention
922 // (AGENTS.md). `expressId`/`ifcType`/`modelId` are synthetic, hence camelCase.
923 obj.insert("GlobalId".to_string(), json!(g));
924 }
925 if let Some(m) = model_id {
926 obj.insert("modelId".to_string(), json!(m));
927 }
928 Some(extras)
929}
930
931/// A quantized mesh's dequantization frame: local-bbox (center, half extent).
932type Dequant = ([f64; 3], [f64; 3]);
933
934/// Push the node(s) for one instanced occurrence placed by `matrix`; returns the
935/// index its parent lists. With a quantized `dequant` (center, half), the dequant
936/// is a non-uniform scale, and folding it into the matrix would make three.js
937/// `Matrix4.decompose` mangle the rotation·scale. So the MESH node carries the
938/// dequant TRS and `extras` (a raycast pick hits the mesh) under a parent that
939/// carries the matrix. Both assemblers place occurrences through here.
940fn push_occurrence_node(
941 nodes: &mut Vec<Node>,
942 mesh: u32,
943 matrix: [f32; 16],
944 dequant: Option<Dequant>,
945 extras: Option<Value>,
946) -> u32 {
947 let mesh_node = nodes.len() as u32;
948 let (translation, scale) = dequant.map_or((None, None), |(c, h)| (Some(c), Some(h)));
949 nodes.push(Node {
950 rotation: None,
951 mesh: Some(mesh),
952 children: None,
953 translation,
954 scale,
955 matrix: dequant.is_none().then_some(matrix),
956 extras,
957 });
958 if dequant.is_none() {
959 return mesh_node;
960 }
961 nodes.push(Node {
962 rotation: None,
963 mesh: None,
964 children: Some(vec![mesh_node]),
965 translation: None,
966 scale: None,
967 matrix: Some(matrix),
968 extras: None,
969 });
970 mesh_node + 1
971}
972
973/// A minimal borrowed view of one renderable mesh for glTF assembly — lets the
974/// from-bytes path (`process_geometry`) and the from-meshes path (the viewer's already
975/// produced MeshData) share one assembler.
976pub struct MeshView<'a> {
977 pub express_id: u32,
978 pub ifc_type: &'a str,
979 /// IFC `GlobalId` (GUID) of this element, when known. `None` on the
980 /// from-meshes path, which carries only numeric express ids.
981 pub global_id: Option<&'a str>,
982 pub positions: &'a [f32],
983 pub normals: &'a [f32],
984 pub indices: &'a [u32],
985 pub color: [f32; 4],
986 pub origin: [f64; 3],
987 /// GPU-instancing side-channel (rep-identity + per-occurrence world transform),
988 /// in the IFC **Z-up** frame. Present only on the from-bytes path (`process_geometry`);
989 /// `None` on the from-meshes path (the viewer's MeshData drops it across the
990 /// worker boundary) and for non-instanceable geometry. When two or more views
991 /// share a `rep_identity`, the assembler emits the geometry once and places each
992 /// occurrence with a node matrix. See [`assemble_glb`].
993 pub instance: Option<&'a InstanceMeta>,
994}
995
996fn view_ok(v: &MeshView) -> bool {
997 !v.indices.is_empty()
998 && v.positions.len() >= 9
999 && v.positions.len().is_multiple_of(3)
1000 && v.normals.len() == v.positions.len()
1001}
1002
1003/// Core glTF/GLB assembler over pre-filtered mesh views.
1004///
1005/// Placement model (the fix for "all centre aligned"): each view's vertices are
1006/// LOCAL to its per-element `origin` (`world = origin + position`). We compute one
1007/// model-wide `scene_center`, bake `world - scene_center` into the f32 vertex
1008/// buffer, and ride the single large `scene_center` on ONE root-node translation
1009/// that parents every element node. This keeps vertices small (f32-precise even at
1010/// georef scale) AND self-contained: a consumer that ignores node transforms sees
1011/// the whole model uniformly offset, never each element collapsed onto the origin
1012/// (the failure mode of per-element `node.translation`).
1013///
1014/// `rtc_zup` is the model RTC / site-local offset (Z-up) that `process_geometry`
1015/// subtracted when baking vertices; the instancing path needs it to express each
1016/// occurrence's relative transform in the same POST-RTC frame the baked geometry
1017/// lives in. Pass `[0, 0, 0]` when geometry is already absolute (the from-meshes
1018/// path, which never instances anyway).
1019/// Build the glTF document, streaming geometry through `ch` (single embedded buffer for
1020/// GLB, or chunked external buffers for multi-buffer glTF). Returns the `Gltf` for the
1021/// caller to pack (GLB) or serialize (glTF); the binary lives in `ch` afterwards
1022/// (the `ch.pos`/`ch.norm`/`ch.idx` runs for the single-buffer case, which `pack_glb`
1023/// writes straight into the container, or already handed to the chunk sink).
1024// Cohesive builder: these are the orthogonal knobs of one glTF pass (metadata,
1025// model id, lit/emissive material, RTC origin, quantization) and packing them
1026// into a struct would not reduce the real coupling. #1427 added `emissive`.
1027/// The site placement to restore on a scene root, and the RTC offset that was
1028/// subtracted, read from a `ProcessingResult`.
1029///
1030/// Shared by all three export paths. They each build their own root, and fixing
1031/// only the plain one meant the same file came out kilometres apart depending
1032/// on whether it was large enough to stream. One reader is the cheapest way to
1033/// stop that recurring.
1034fn site_restore(result: &ProcessingResult) -> (MeshCoordinateSpace, [f64; 3], Option<Vec<f64>>) {
1035 let rtc_zup = result.metadata.coordinate_info.origin_shift;
1036 // Only the site-local space removed a rotation, so only there is there one
1037 // to put back on the scene ROOT. `model_rtc` subtracts a detected
1038 // translation with no rotation, and `raw_ifc` subtracts nothing — but both
1039 // still need the translation restored, which is why `rtc_zup` returns
1040 // unconditionally.
1041 let site_zup = if result.mesh_coordinate_space == MeshCoordinateSpace::SiteLocal {
1042 result.site_transform.clone()
1043 } else {
1044 None
1045 };
1046 // The tag travels with them. `baked_basis_yup` used to re-derive it from
1047 // `site_zup.is_some()`, which is this same `if` written a second time in a
1048 // second vocabulary (#4611); it now reads the tag itself.
1049 (result.mesh_coordinate_space, rtc_zup, site_zup)
1050}
1051
1052/// Build the scene root: the model centre, plus the site placement the baker
1053/// removed, so the exported scene is in world coordinates.
1054///
1055/// glTF has no notion of an IFC site frame. A scene is in its own world, and
1056/// someone loading two georeferenced models expects them to line up.
1057fn scene_root(
1058 scene_center: [f64; 3],
1059 rtc_zup: [f64; 3],
1060 site_zup: Option<&[f64]>,
1061) -> (Option<[f64; 3]>, Option<[f32; 4]>) {
1062 let center_nonzero = scene_center.iter().any(|c| c.abs() > 1e-9);
1063 let site_yup = site_zup.map(crate::frame::yup_matrix4);
1064 let rotation = site_yup.as_ref().and_then(|m| {
1065 let identity = (0..3).all(|c| {
1066 (0..3).all(|r| {
1067 let want = if r == c { 1.0 } else { 0.0 };
1068 (m[c * 4 + r] - want).abs() < 1e-9
1069 })
1070 });
1071 (!identity).then(|| quaternion_from_column_major(m))
1072 });
1073 // The centre the vertices were baked relative to travels through the site
1074 // rotation too, or the model lands rotated about the wrong point.
1075 let t = crate::frame::yup_f64(rtc_zup);
1076 let c = match (&site_yup, &rotation) {
1077 (Some(m), Some(_)) => [
1078 m[0] * scene_center[0] + m[4] * scene_center[1] + m[8] * scene_center[2],
1079 m[1] * scene_center[0] + m[5] * scene_center[1] + m[9] * scene_center[2],
1080 m[2] * scene_center[0] + m[6] * scene_center[1] + m[10] * scene_center[2],
1081 ],
1082 _ => scene_center,
1083 };
1084 let out = [t[0] + c[0], t[1] + c[1], t[2] + c[2]];
1085 let translation = (center_nonzero || out.iter().any(|v| v.abs() > 1e-9)).then_some(out);
1086 (translation, rotation)
1087}
1088
1089/// Unit quaternion (x, y, z, w) from the rotation part of a column-major 4x4.
1090///
1091/// Shepperd's method: pick the largest of the four diagonal combinations so the
1092/// square root is never taken of something near zero, which is where the naive
1093/// trace formula loses precision and, at 180 degrees, its sign.
1094fn quaternion_from_column_major(m: &[f64; 16]) -> [f32; 4] {
1095 let at = |r: usize, c: usize| m[c * 4 + r];
1096 let (m00, m11, m22) = (at(0, 0), at(1, 1), at(2, 2));
1097 let trace = m00 + m11 + m22;
1098 let (x, y, z, w) = if trace > 0.0 {
1099 let s = (trace + 1.0).sqrt() * 2.0;
1100 ((at(2, 1) - at(1, 2)) / s, (at(0, 2) - at(2, 0)) / s, (at(1, 0) - at(0, 1)) / s, 0.25 * s)
1101 } else if m00 > m11 && m00 > m22 {
1102 let s = (1.0 + m00 - m11 - m22).sqrt() * 2.0;
1103 (0.25 * s, (at(0, 1) + at(1, 0)) / s, (at(0, 2) + at(2, 0)) / s, (at(2, 1) - at(1, 2)) / s)
1104 } else if m11 > m22 {
1105 let s = (1.0 + m11 - m00 - m22).sqrt() * 2.0;
1106 ((at(0, 1) + at(1, 0)) / s, 0.25 * s, (at(1, 2) + at(2, 1)) / s, (at(0, 2) - at(2, 0)) / s)
1107 } else {
1108 let s = (1.0 + m22 - m00 - m11).sqrt() * 2.0;
1109 ((at(0, 2) + at(2, 0)) / s, (at(1, 2) + at(2, 1)) / s, 0.25 * s, (at(1, 0) - at(0, 1)) / s)
1110 };
1111 let n = (x * x + y * y + z * z + w * w).sqrt();
1112 if n < 1e-12 {
1113 return [0.0, 0.0, 0.0, 1.0];
1114 }
1115 [(x / n) as f32, (y / n) as f32, (z / n) as f32, (w / n) as f32]
1116}
1117
1118#[allow(clippy::too_many_arguments)]
1119fn build_gltf(
1120 views: &[MeshView],
1121 include_metadata: bool,
1122 model_id: Option<&str>,
1123 lit: bool,
1124 emissive: bool,
1125 space: MeshCoordinateSpace,
1126 rtc_zup: [f64; 3],
1127 site_zup: Option<&[f64]>,
1128 quantize: bool,
1129 ch: &mut Chunker,
1130) -> (Gltf, GltfStats) {
1131 // Pre-filter once so both passes (centre, then bake) see exactly the same set.
1132 let visible: Vec<&MeshView> = views.iter().filter(|v| view_ok(v)).collect();
1133
1134 // ── Pass 1: one model-wide WORLD AABB → scene centre ────────────────────
1135 let mut wmin = [f64::INFINITY; 3];
1136 let mut wmax = [f64::NEG_INFINITY; 3];
1137 for v in &visible {
1138 let o = v.origin;
1139 for p in v.positions.chunks_exact(3) {
1140 for k in 0..3 {
1141 let w = p[k] as f64 + o[k];
1142 if w < wmin[k] {
1143 wmin[k] = w;
1144 }
1145 if w > wmax[k] {
1146 wmax[k] = w;
1147 }
1148 }
1149 }
1150 }
1151 let scene_center = if visible.is_empty() {
1152 [0.0, 0.0, 0.0]
1153 } else {
1154 [
1155 (wmin[0] + wmax[0]) * 0.5,
1156 (wmin[1] + wmax[1]) * 0.5,
1157 (wmin[2] + wmax[2]) * 0.5,
1158 ]
1159 };
1160
1161 // Binary blobs, concatenated as [positions | normals | indices].
1162
1163 let mut materials: Vec<Material> = Vec::new();
1164 let mut material_map: FxHashMap<(i32, i32, i32, i32), u32> = FxHashMap::default();
1165
1166 let mut accessors: Vec<Accessor> = Vec::new();
1167 let mut meshes: Vec<Mesh> = Vec::new();
1168 let mut nodes: Vec<Node> = Vec::new();
1169 let mut element_node_indices: Vec<u32> = Vec::new();
1170
1171 let mut stats = GltfStats { meshes: 0, vertices: 0, triangles: 0, materials: 0, unverified_instance_groups: 0 };
1172
1173 // ── Pass 1.5: collate by representation identity ────────────────────────────
1174 // Group occurrences that share a representation (IfcMappedItem / repeated
1175 // geometry) so the geometry is emitted ONCE and each occurrence is placed with a
1176 // node matrix — the size win on repetitive models (50-85% fewer vertices). This
1177 // is the SAME rep-identity grouping the GPU/native instancing path uses;
1178 // content-hashing the BAKED f32 vertices cannot recover these repeats because
1179 // per-occurrence placement bakes distinct float positions. Meshes without usable
1180 // instance metadata (the from-meshes path, non-instanceable void-cut elements,
1181 // singletons) fall to `flat_indices` and keep the self-contained
1182 // world-minus-center bake above.
1183 let refs: Vec<InstanceMeshRef> = visible
1184 .iter()
1185 .map(|m| InstanceMeshRef {
1186 positions: m.positions,
1187 normals: m.normals,
1188 indices: m.indices,
1189 origin: m.origin,
1190 instance_meta: m.instance,
1191 entity_id: m.express_id,
1192 color: m.color,
1193 // Encoder-only field: this path calls `collate_refs`, never the IFNS
1194 // encoder, so nothing here reads it.
1195 item_id: None,
1196 })
1197 .collect();
1198 // rtc [0,0,0]: this path keeps the RAW pre-RTC relative transforms and applies
1199 // its own `T(-rtc)·rel·T(rtc)` conjugation per occurrence in
1200 // `occurrence_node_matrix` (it has the Z-up model rtc there). Passing the rtc
1201 // here too would conjugate twice. The wasm GPU-shard path, which consumes the
1202 // relative transform directly (no downstream conjugation), passes the real rtc.
1203 //
1204 // `baked_basis = S_YUP · Rᵀ · T(-rtc_zup)`: exactly the conjugation
1205 // `occurrence_node_matrix` applies to the same `rel` (see gltf/matrix.rs),
1206 // because the reconstruction check has to compare in the frame the BAKED
1207 // positions are actually in. Three conversions separate the two:
1208 // • Y-up: `visible`'s positions/origin were already converted Z-up→Y-up before
1209 // this function was entered — `with_result_views` (this file) runs
1210 // `crate::frame::to_yup_in_place` over every visible mesh in `result` and only
1211 // then hands the borrowed `MeshView`s here, so the conversion is NOT visible in
1212 // `build_gltf` itself. `InstanceMeta.transform` (hence `rel`) stays Z-up.
1213 // • RTC: `rel` is PRE-RTC here (this path passes `rtc = [0,0,0]` above), while
1214 // the baked positions are POST-RTC. The residual that leaves is
1215 // `(R_rel - I) · rtc` — zero for a translated-only sibling, but hundreds of
1216 // kilometres for a ROTATED one at national-grid magnitude, so `S_YUP` alone
1217 // rejected every rotated group on a georeferenced model.
1218 // • Site rotation: in the `site_local` tier the baker ALSO applied the site
1219 // placement's inverse rotation to every position (#4118). The residual that
1220 // leaves is `(I - Rᵀ) · d` for a sibling `d` metres away — 3.5 m for a 6 m
1221 // sibling under a 34 degree yaw, far over any tolerance — so a yawed site
1222 // instanced nothing at all. `site_zup` is `Some` exactly in that tier;
1223 // `baked_basis_yup` folds it in and is the identity's neighbour otherwise.
1224 // Without all three terms the check reads a frame mismatch as a #3666 collision
1225 // and drops the whole group to flat.
1226 let baked_basis = Matrix4::from_row_slice(&matrix::baked_basis_yup(space, rtc_zup, site_zup));
1227 let collated = collate_refs_in_basis(&refs, 2, [0.0, 0.0, 0.0], Some(&baked_basis));
1228
1229 // Partition into instanced templates (non-rigid, exact-bit) and a flat remainder.
1230 // Only EXACT-bit groups are instanced: the template's local geometry IS each
1231 // occurrence's, so exported per-occurrence geometry stays byte-faithful. Rigid-
1232 // tier groups (rotation-normalized, env-gated and OFF by default) substitute a
1233 // congruent-but-not-identical template, so they fall to the flat remainder.
1234 let mut flat: Vec<usize> = collated.flat_indices.clone();
1235 let mut instanced: Vec<(&InstanceTemplate, Vec<usize>, [f64; 16])> =
1236 Vec::with_capacity(collated.templates.len());
1237 for template in &collated.templates {
1238 // PER OCCURRENCE, matching the collator: a rigid-tier member is congruent
1239 // to the template but not bit-identical, so instancing it would change the
1240 // exported geometry — but one such member no longer costs its exact-tier
1241 // siblings their shared mesh. The template's own occurrence is exact
1242 // against itself whatever tier it was grouped under.
1243 let (keep, drop): (Vec<usize>, Vec<usize>) = (0..template.occurrences.len())
1244 .partition(|&oi| {
1245 let mi = template.occurrences[oi].mesh_index;
1246 mi == template.template_index
1247 || visible[mi].instance.and_then(|m| m.canonical_transform).is_none()
1248 });
1249 // Precompute the template's inverse world placement (f64) ONCE per group;
1250 // every occurrence's node matrix reuses it. A missing instance side-channel
1251 // or a singular/degenerate template placement routes the whole group to the
1252 // flat path (still correct, just not instanced), as does a group left with
1253 // fewer than two occurrences to share.
1254 let m_ref_inv = (keep.len() >= 2)
1255 .then(|| visible[template.template_index].instance)
1256 .flatten()
1257 .filter(|_| {
1258 keep.iter().all(|&oi| visible[template.occurrences[oi].mesh_index].instance.is_some())
1259 })
1260 .and_then(|ti| affine_inverse(&compose_world_meta(ti)));
1261 match m_ref_inv {
1262 Some(inv) => {
1263 flat.extend(drop.iter().map(|&oi| template.occurrences[oi].mesh_index));
1264 instanced.push((template, keep, inv));
1265 }
1266 None => flat.extend(template.occurrences.iter().map(|o| o.mesh_index)),
1267 }
1268 }
1269
1270 // ── Pass 2: flat remainder, content-hash deduped ────────────────────────────
1271 // The rep-identity collator only groups geometry it can prove shareable. Many
1272 // models also have byte-identical BAKED meshes it does not flag (e.g. unmapped
1273 // repeated parts). Dedup those by local-geometry+colour content hash so they
1274 // still share one mesh placed by a node translation — this guarantees the
1275 // instanced output never regresses below the plain content-hash baseline.
1276 let flat_keys: Vec<u128> = flat
1277 .iter()
1278 .map(|&i| geom_color_key(visible[i].positions, visible[i].normals, visible[i].indices, visible[i].color))
1279 .collect();
1280 let mut flat_counts: FxHashMap<u128, u32> = FxHashMap::default();
1281 for &k in &flat_keys {
1282 *flat_counts.entry(k).or_insert(0) += 1;
1283 }
1284 // Cache key -> (mesh_idx, dequant center, dequant half-extent). The dequant fields
1285 // are dummy on the f32 path (node scale stays `None`); on the quantized path they
1286 // are the per-mesh dequant the node folds in.
1287 let mut flat_cache: FxHashMap<u128, (u32, [f64; 3], [f64; 3])> = FxHashMap::default();
1288 for (j, &idx) in flat.iter().enumerate() {
1289 let mesh = visible[idx];
1290 let placement = [
1291 mesh.origin[0] - scene_center[0],
1292 mesh.origin[1] - scene_center[1],
1293 mesh.origin[2] - scene_center[2],
1294 ];
1295 let key = flat_keys[j];
1296 let shared = flat_counts.get(&key).copied().unwrap_or(1) >= 2;
1297 let mesh_idx;
1298 let translation;
1299 let scale;
1300 if quantize {
1301 // Quantized: never bake. Emit per-mesh-local SHORT geometry and place +
1302 // dequantize on the node. `placement` is pure translation, so it commutes
1303 // with the dequant translate: node = T(placement + center) · S(half).
1304 let (mi, center, half) = if shared {
1305 *flat_cache.entry(key).or_insert_with(|| {
1306 push_mesh_quantized(
1307 &mut *ch, &mut accessors, &mut meshes,
1308 &mut materials, &mut material_map, mesh, lit, emissive, &mut stats,
1309 )
1310 })
1311 } else {
1312 push_mesh_quantized(
1313 &mut *ch, &mut accessors, &mut meshes,
1314 &mut materials, &mut material_map, mesh, lit, emissive, &mut stats,
1315 )
1316 };
1317 mesh_idx = mi;
1318 translation = Some([
1319 placement[0] + center[0],
1320 placement[1] + center[1],
1321 placement[2] + center[2],
1322 ]);
1323 scale = Some(half);
1324 } else if shared {
1325 // Repeated baked geometry: emit LOCAL once, place via node translation.
1326 let (mi, _, _) = *flat_cache.entry(key).or_insert_with(|| {
1327 let mi = push_mesh(
1328 &mut *ch, &mut accessors, &mut meshes,
1329 &mut materials, &mut material_map, mesh, [0.0, 0.0, 0.0], lit, emissive, &mut stats,
1330 );
1331 (mi, [0.0; 3], [0.0; 3])
1332 });
1333 mesh_idx = mi;
1334 translation = placement.iter().any(|c| c.abs() > 1e-9).then_some(placement);
1335 scale = None;
1336 } else {
1337 // Singleton: bake world-minus-center into the vertices, identity node.
1338 mesh_idx = push_mesh(
1339 &mut *ch, &mut accessors, &mut meshes,
1340 &mut materials, &mut material_map, mesh, placement, lit, emissive, &mut stats,
1341 );
1342 translation = None;
1343 scale = None;
1344 }
1345 let node_idx = nodes.len() as u32;
1346 nodes.push(Node {
1347 rotation: None,
1348 mesh: Some(mesh_idx),
1349 children: None,
1350 translation,
1351 scale,
1352 matrix: None,
1353 extras: node_extras(include_metadata, mesh.express_id, mesh.ifc_type, mesh.global_id, model_id),
1354 });
1355 element_node_indices.push(node_idx);
1356 }
1357
1358 // ── Pass 2: instanced templates ─────────────────────────────────────────────
1359 for (template, keep, m_ref_inv) in instanced {
1360 // glTF materials ride the mesh primitive, not the node, but the collator
1361 // groups by geometry only (`rep_identity` excludes colour). Split the
1362 // occurrences by colour so same-shape/different-colour occurrences get
1363 // distinct materials — one shared template mesh per colour bucket.
1364 let t_view = visible[template.template_index];
1365 let t_origin_yup = t_view.origin;
1366 // First-seen colour-bucket order keeps the emitted mesh/material/node
1367 // ordering deterministic (HashMap iteration order is not).
1368 let mut bucket_order: Vec<(i32, i32, i32, i32)> = Vec::new();
1369 let mut by_color: FxHashMap<(i32, i32, i32, i32), Vec<usize>> = FxHashMap::default();
1370 for &oi in &keep {
1371 let ck = color_key(visible[template.occurrences[oi].mesh_index].color);
1372 by_color
1373 .entry(ck)
1374 .or_insert_with(|| {
1375 bucket_order.push(ck);
1376 Vec::new()
1377 })
1378 .push(oi);
1379 }
1380 for ck in &bucket_order {
1381 let bucket = &by_color[ck];
1382 let bucket_color = visible[template.occurrences[bucket[0]].mesh_index].color;
1383 // The shared mesh: the template's LOCAL geometry (vertex_offset = 0,
1384 // relative to the template origin) tinted with the bucket colour.
1385 let tmpl_mesh = MeshView {
1386 express_id: t_view.express_id,
1387 ifc_type: t_view.ifc_type,
1388 global_id: t_view.global_id,
1389 positions: t_view.positions,
1390 normals: t_view.normals,
1391 indices: t_view.indices,
1392 color: bucket_color,
1393 origin: t_view.origin,
1394 instance: None,
1395 };
1396 // Push the shared template once. Quantized returns the per-mesh dequant the
1397 // occurrence nodes need; f32 bakes nothing (`vertex_offset = 0`).
1398 let (mesh_idx, dequant) = if quantize {
1399 let (mi, center, half) = push_mesh_quantized(
1400 &mut *ch, &mut accessors,
1401 &mut meshes, &mut materials, &mut material_map, &tmpl_mesh, lit, emissive, &mut stats,
1402 );
1403 (mi, Some((center, half)))
1404 } else {
1405 let mi = push_mesh(
1406 &mut *ch, &mut accessors,
1407 &mut meshes, &mut materials, &mut material_map, &tmpl_mesh,
1408 [0.0, 0.0, 0.0], lit, emissive, &mut stats,
1409 );
1410 (mi, None)
1411 };
1412 for &oi in bucket {
1413 let occ = &template.occurrences[oi];
1414 let occ_view = visible[occ.mesh_index];
1415 // Safe: the partition only kept this group when every occurrence has
1416 // an instance side-channel and the template inverse exists.
1417 let occ_meta = occ_view.instance.expect("instanced occurrence has InstanceMeta");
1418 let matrix = occurrence_node_matrix(
1419 occ_meta, &m_ref_inv, space, rtc_zup, site_zup, t_origin_yup, scene_center,
1420 );
1421 let extras = node_extras(include_metadata, occ_view.express_id, occ_view.ifc_type, occ_view.global_id, model_id);
1422 let node_idx = push_occurrence_node(&mut nodes, mesh_idx, matrix, dequant, extras);
1423 element_node_indices.push(node_idx);
1424 }
1425 }
1426 }
1427 stats.materials = materials.len();
1428
1429 // Single root node carries the model-wide centre (omitted when ~zero) and
1430 // parents every element node, so the scene has exactly one top-level node.
1431 let (root_translation, site_rotation) = scene_root(scene_center, rtc_zup, site_zup);
1432 let scene_nodes = if element_node_indices.is_empty() {
1433 Vec::new()
1434 } else {
1435 let root_idx = nodes.len() as u32;
1436 nodes.push(Node {
1437 rotation: site_rotation,
1438 mesh: None,
1439 children: Some(element_node_indices),
1440 translation: root_translation,
1441 scale: None,
1442 matrix: None,
1443 extras: None,
1444 });
1445 vec![root_idx]
1446 };
1447
1448 // Flush the final (or only) chunk. The 4 GiB-per-buffer guard lives in `flush`; for
1449 // the single-buffer GLB path this is the same assert (and message) as before, so the
1450 // worker's `OutputTooLarge` classifier still matches. The container total (JSON +
1451 // framing) is guarded separately in `pack_glb`.
1452 ch.flush();
1453
1454 let asset_extras = if include_metadata {
1455 Some(json!({
1456 "meshCount": stats.meshes,
1457 "vertexCount": stats.vertices,
1458 "triangleCount": stats.triangles,
1459 }))
1460 } else {
1461 None
1462 };
1463
1464 let gltf = Gltf {
1465 asset: Asset { version: "2.0", generator: "IFC-Lite", extras: asset_extras },
1466 scene: 0,
1467 scenes: vec![Scene { nodes: scene_nodes }],
1468 nodes,
1469 meshes,
1470 materials: if materials.is_empty() { None } else { Some(materials) },
1471 accessors,
1472 buffer_views: std::mem::take(&mut ch.buffer_views),
1473 buffers: std::mem::take(&mut ch.buffers),
1474 extensions_used: {
1475 let mut ext: Vec<&'static str> = Vec::new();
1476 // Emissive suppresses unlit (mutually exclusive; see make_material).
1477 if !lit && !emissive && stats.materials > 0 {
1478 ext.push("KHR_materials_unlit");
1479 }
1480 if quantize {
1481 ext.push("KHR_mesh_quantization");
1482 }
1483 (!ext.is_empty()).then_some(ext)
1484 },
1485 // `KHR_mesh_quantization` is hard-required: a loader without it cannot read the
1486 // SHORT-normalized attributes at all.
1487 extensions_required: quantize.then(|| vec!["KHR_mesh_quantization"]),
1488 };
1489
1490 (gltf, stats)
1491}
1492
1493/// Export the render geometry in `content` as a binary **GLB**.
1494///
1495/// Fail-closed: an empty visible mesh set is [`ExportError::NoRenderGeometry`],
1496/// never a zero-mesh GLB. That GLB is not valid glTF (its `accessors`,
1497/// `bufferViews`, `meshes` and `nodes` are empty arrays where the schema says
1498/// `minItems: 1`, and `buffers[0].byteLength` is 0 where it says `minimum: 1`),
1499/// so success implies the artifact contains at least one mesh and every caller
1500/// (CLI, MCP, SDK, viewer, direct Rust) inherits the guard. Every from-bytes GLB
1501/// entry point refuses it the same way (#4685).
1502pub fn try_export_glb(content: &[u8], opts: &GltfOptions) -> Result<Vec<u8>, ExportError> {
1503 try_export_glb_with_stats(content, opts).map(|(glb, _)| glb)
1504}
1505
1506/// Like [`try_export_glb`] but also returns coverage stats. Meshes the model from bytes.
1507///
1508/// Inputs at or above the streaming threshold (default 64 MB, native override
1509/// `IFC_LITE_GLB_STREAM_THRESHOLD_MB`, `0` disables) route to the bounded
1510/// two-pass assembler ([`try_export_glb_streaming_bounded`]) so a large model
1511/// never materializes all of its `MeshData` at once (the wasm-OOM fix). Small
1512/// models keep the in-memory instanced assembler.
1513///
1514/// Beyond the [`ExportError::NoRenderGeometry`] guard, a large input that would
1515/// exceed the glTF 4 GiB single-GLB limit returns [`ExportError::TooLarge`]
1516/// instead of panicking: the bounded path fails fast after pass 1, so a caller
1517/// can fall back to [`export_gltf_streaming`] without catching a panic (#1516).
1518///
1519/// A SUB-THRESHOLD input (default < 64 MB) keeps the in-memory instanced
1520/// assembler, which retains the historical 4 GiB `pack_glb` assert. That bound is
1521/// only reachable if such a small file meshed to over 4 GiB of GLB (a ~64x
1522/// expansion, not observed in practice); a caller that must be panic-proof even
1523/// then can force the checked bounded path with
1524/// `IFC_LITE_GLB_STREAM_THRESHOLD_MB=1`.
1525pub fn try_export_glb_with_stats(
1526 content: &[u8],
1527 opts: &GltfOptions,
1528) -> Result<(Vec<u8>, GltfStats), ExportError> {
1529 if content.len() >= glb_stream_threshold_bytes() {
1530 return try_export_glb_streaming_bounded(content, opts);
1531 }
1532 try_export_glb_from_result(
1533 process_geometry_filtered_with_quality(
1534 content,
1535 OpeningFilterMode::Default,
1536 opts.tessellation_quality,
1537 ),
1538 opts,
1539 )
1540}
1541
1542/// Like [`try_export_glb_with_stats`] but reuses a pre-built entity index — for a
1543/// caller that also runs the attribute pass ([`crate::stream_export_model_with_index`])
1544/// over the same bytes, `build_entity_index` once and share it across both. `index`
1545/// MUST be built from the same `content`; output is byte-identical to
1546/// `try_export_glb_with_stats` below the streaming threshold. NOTE: this path always
1547/// uses the in-memory assembler (the bounded two-pass path rebuilds its own index per
1548/// pass and cannot reuse this one); a native caller that needs bounded memory on a
1549/// large model should call [`try_export_glb_streaming_bounded_with_index`] directly.
1550pub fn try_export_glb_with_stats_with_index(
1551 content: &[u8],
1552 opts: &GltfOptions,
1553 index: Arc<EntityIndex>,
1554) -> Result<(Vec<u8>, GltfStats), ExportError> {
1555 try_export_glb_from_result(
1556 process_geometry_streaming_filtered_with_options(
1557 content,
1558 OpeningFilterMode::Default,
1559 StreamingOptions {
1560 initial_batch_size: usize::MAX,
1561 throughput_batch_size: usize::MAX,
1562 entity_index: Some(index),
1563 tessellation_quality: opts.tessellation_quality,
1564 ..StreamingOptions::default()
1565 },
1566 |_, _, _| {},
1567 |_| {},
1568 |_| {},
1569 ),
1570 opts,
1571 )
1572}
1573
1574/// Build the Y-up `MeshView`s + RTC offset from a `ProcessingResult` and run `f` over
1575/// them. Shared by the GLB (`try_export_glb_from_result`) and multi-buffer
1576/// (`export_gltf_streaming_from_result`) paths; the views borrow scratch that lives only
1577/// for `f`'s duration.
1578fn with_result_views<R>(
1579 mut result: ProcessingResult,
1580 opts: &GltfOptions,
1581 f: impl FnOnce(&[MeshView], MeshCoordinateSpace, [f64; 3], Option<&[f64]>) -> R,
1582) -> R {
1583 // `process_geometry` emits the producer-native IFC **Z-up** frame (the Z-up→Y-up
1584 // swap normally happens at the wasm FFI, which this path never crosses). glTF
1585 // mandates +Y-up, so convert each visible mesh to Y-up — positions/normals
1586 // rotated, winding preserved, origin rotated — matching the viewer/legacy output.
1587 //
1588 // The visible indices are collected first so the immutable visibility borrow ends
1589 // before the in-place mutation; then each visible mesh is converted to Y-up IN PLACE
1590 // (no allocation of a full second copy of the model's geometry — the old `Vec<YUpMesh>`
1591 // held +1× the whole model resident for the entire assembly). `result` is owned and
1592 // dropped after `f`, so the mutation is invisible to any other consumer.
1593 let filter = VisibilityFilter::new(opts);
1594 let vis_idx: Vec<usize> = result
1595 .meshes
1596 .iter()
1597 .enumerate()
1598 .filter(|(_, m)| filter.visible(m))
1599 .map(|(i, _)| i)
1600 .collect();
1601 for &i in &vis_idx {
1602 let m = &mut result.meshes[i];
1603 crate::frame::to_yup_in_place(&mut m.positions, &mut m.normals, &mut m.indices, &mut m.origin);
1604 }
1605 let views: Vec<MeshView> = vis_idx
1606 .iter()
1607 .map(|&i| {
1608 let m = &result.meshes[i];
1609 MeshView {
1610 express_id: m.express_id,
1611 ifc_type: &m.ifc_type,
1612 global_id: m.global_id.as_deref(),
1613 positions: &m.positions,
1614 normals: &m.normals,
1615 indices: &m.indices,
1616 color: m.color,
1617 origin: m.origin,
1618 // Z-up instancing side-channel; rep-identity grouping is frame- and
1619 // bake-invariant (the assembler conjugates the transform into Y-up). Left
1620 // untouched by the in-place swap above, exactly as before.
1621 instance: m.instance.as_ref(),
1622 }
1623 })
1624 .collect();
1625 // RTC / site-local offset the baker subtracted (Z-up); the instancing path needs
1626 // it to place occurrences in the same POST-RTC frame the baked geometry lives in.
1627 let (space, rtc_zup, site_zup) = site_restore(&result);
1628 f(&views, space, rtc_zup, site_zup.as_deref())
1629}
1630
1631/// The one rule both GLB assemblers refuse on: a build with no mesh is not a
1632/// valid GLB (see [`try_export_glb`]), so it is an error rather than an artifact.
1633fn refuse_empty(stats: &GltfStats) -> Result<(), ExportError> {
1634 if stats.meshes == 0 {
1635 return Err(ExportError::NoRenderGeometry);
1636 }
1637 Ok(())
1638}
1639
1640/// The in-memory assembler behind both `try_export_glb_with_stats*` entry points.
1641/// The empty-set refusal is decided on the build's own stats, before packing.
1642fn try_export_glb_from_result(
1643 result: ProcessingResult,
1644 opts: &GltfOptions,
1645) -> Result<(Vec<u8>, GltfStats), ExportError> {
1646 with_result_views(result, opts, |views, space, rtc_zup, site_zup| {
1647 let mut ch = Chunker::new(if opts.quantize { 8 } else { 12 }, usize::MAX, None);
1648 let (gltf, stats) = build_gltf(
1649 views, opts.include_metadata, opts.model_id.as_deref(), opts.lit, opts.emissive,
1650 space, rtc_zup, site_zup, opts.quantize, &mut ch,
1651 );
1652 refuse_empty(&stats)?;
1653 let json = serde_json::to_vec(&gltf).expect("glTF JSON serializes");
1654 Ok((pack_glb(&json, &ch.pos, &ch.norm, &ch.idx), stats))
1655 })
1656}
1657
1658/// One finished external buffer of a multi-buffer glTF export.
1659pub struct GltfBuffer {
1660 /// The buffer's `uri` in the `.gltf` — write it as a sibling file / S3 object.
1661 pub name: String,
1662 /// The `.bin` payload. Dropped after the sink returns, so peak memory stays bounded.
1663 pub bytes: Vec<u8>,
1664}
1665
1666/// Export a model as a **multi-buffer glTF**: the `.gltf` JSON (returned) plus one
1667/// or more external `.bin` buffers, each kept under `chunk_cap` bytes (well below the
1668/// 4 GiB glTF limit), so a model of ANY size loads as one logical model. Each finished
1669/// buffer is handed to `sink` and dropped, so peak memory is ~one chunk, not the whole
1670/// model — this is the path for models too large for a single GLB (`try_export_glb*` stays
1671/// the smaller-model path). Compose with `GltfOptions.quantize` to shrink first.
1672pub fn export_gltf_streaming(
1673 content: &[u8],
1674 opts: &GltfOptions,
1675 chunk_cap: usize,
1676 sink: impl FnMut(GltfBuffer),
1677) -> Vec<u8> {
1678 export_gltf_streaming_impl(content, opts, None, chunk_cap, sink)
1679}
1680
1681/// Like [`export_gltf_streaming`] but reuses a pre-built entity index instead of
1682/// scanning `content` again on EACH of its two passes. A caller that already
1683/// built the index — e.g. to share with the attribute pass
1684/// ([`crate::stream_export_model_with_index`]) — passes it here to remove the
1685/// redundant SIMD scans on the large models this path targets (#1516). `index`
1686/// MUST come from [`build_entity_index`](crate::build_entity_index) over the same
1687/// `content`; output is byte-identical to [`export_gltf_streaming`].
1688pub fn export_gltf_streaming_with_index(
1689 content: &[u8],
1690 opts: &GltfOptions,
1691 index: Arc<EntityIndex>,
1692 chunk_cap: usize,
1693 sink: impl FnMut(GltfBuffer),
1694) -> Vec<u8> {
1695 export_gltf_streaming_impl(content, opts, Some(index), chunk_cap, sink)
1696}
1697
1698fn export_gltf_streaming_impl(
1699 content: &[u8],
1700 opts: &GltfOptions,
1701 index: Option<Arc<EntityIndex>>,
1702 chunk_cap: usize,
1703 mut sink: impl FnMut(GltfBuffer),
1704) -> Vec<u8> {
1705 // Bounded memory: drive the streaming geometry API with `retain_emitted_meshes: false`
1706 // so meshes are never accumulated — peak input is one batch, not the whole model.
1707 // Two passes over the same (deterministic) mesh stream:
1708 // pass 1 — the Y-up world AABB for `scene_center` (a precision-centering device, so
1709 // any value is correct; the exact one keeps baked f32 magnitudes small);
1710 // pass 2 — bake + encode each mesh as a flat node into the chunker, dropping it.
1711 // Instancing/content-dedup is skipped: this path pushes every mesh with no
1712 // cache. World geometry is identical either way (instancing is only a dedup
1713 // of repeated placements), so what it costs is size, not correctness.
1714 // `plan_bounded_glb` -- the single-GLB bounded path -- does both; the
1715 // decision needs a plan rather than co-resident vertices, and that path
1716 // keeps one. This one does not.
1717 // A shared `index` (when present) is injected into BOTH passes' StreamingOptions so
1718 // neither re-scans `content` for its entity index (#1516).
1719 let stream_opts = || StreamingOptions {
1720 retain_emitted_meshes: false,
1721 entity_index: index.clone(),
1722 tessellation_quality: opts.tessellation_quality,
1723 ..StreamingOptions::default()
1724 };
1725 let filter = VisibilityFilter::new(opts);
1726 // One reusable Y-up scratch for BOTH passes (they run sequentially, so the mutable
1727 // borrow never overlaps) instead of 3 fresh allocations per mesh per pass.
1728 let mut yscratch = crate::frame::YUpScratch::new();
1729
1730 let mut wmin = [f64::INFINITY; 3];
1731 let mut wmax = [f64::NEG_INFINITY; 3];
1732 // Pass 1's result carries `site_transform` and the RTC offset. Dropping it
1733 // is why this path kept emitting the site-local frame after the plain path
1734 // stopped, so the same file moved when it grew past the streaming
1735 // threshold.
1736 let meta_result = process_geometry_streaming_filtered_with_options(
1737 content,
1738 OpeningFilterMode::Default,
1739 stream_opts(),
1740 |batch, _, _| {
1741 for m in batch {
1742 if !filter.visible(m) {
1743 continue;
1744 }
1745 crate::frame::to_yup_into(&mut yscratch, &m.positions, &m.normals, &m.indices, m.origin);
1746 let y = &yscratch;
1747 for p in y.positions.chunks_exact(3) {
1748 for k in 0..3 {
1749 let w = p[k] as f64 + y.origin[k];
1750 wmin[k] = wmin[k].min(w);
1751 wmax[k] = wmax[k].max(w);
1752 }
1753 }
1754 }
1755 },
1756 |_| {},
1757 |_| {},
1758 );
1759 let scene_center = if wmin[0].is_finite() {
1760 [
1761 (wmin[0] + wmax[0]) * 0.5,
1762 (wmin[1] + wmax[1]) * 0.5,
1763 (wmin[2] + wmax[2]) * 0.5,
1764 ]
1765 } else {
1766 [0.0; 3]
1767 };
1768
1769 let mut accessors: Vec<Accessor> = Vec::new();
1770 let mut meshes: Vec<Mesh> = Vec::new();
1771 let mut nodes: Vec<Node> = Vec::new();
1772 let mut materials: Vec<Material> = Vec::new();
1773 let mut material_map: FxHashMap<(i32, i32, i32, i32), u32> = FxHashMap::default();
1774 let mut element_node_indices: Vec<u32> = Vec::new();
1775 let mut stats = GltfStats { meshes: 0, vertices: 0, triangles: 0, materials: 0, unverified_instance_groups: 0 };
1776 let mut adapt = |name: String, bytes: Vec<u8>| sink(GltfBuffer { name, bytes });
1777 let mut ch = Chunker::new(if opts.quantize { 8 } else { 12 }, chunk_cap, Some(&mut adapt));
1778
1779 process_geometry_streaming_filtered_with_options(
1780 content,
1781 OpeningFilterMode::Default,
1782 stream_opts(),
1783 |batch, _, _| {
1784 for m in batch {
1785 if !filter.visible(m) {
1786 continue;
1787 }
1788 crate::frame::to_yup_into(&mut yscratch, &m.positions, &m.normals, &m.indices, m.origin);
1789 let y = &yscratch;
1790 let view = MeshView {
1791 express_id: m.express_id,
1792 ifc_type: &m.ifc_type,
1793 global_id: m.global_id.as_deref(),
1794 positions: &y.positions,
1795 normals: &y.normals,
1796 indices: &y.indices,
1797 color: m.color,
1798 origin: y.origin,
1799 instance: None,
1800 };
1801 if !view_ok(&view) {
1802 continue;
1803 }
1804 let placement = [
1805 y.origin[0] - scene_center[0],
1806 y.origin[1] - scene_center[1],
1807 y.origin[2] - scene_center[2],
1808 ];
1809 let mesh_idx;
1810 let translation;
1811 let scale;
1812 if opts.quantize {
1813 let (mi, center, half) = push_mesh_quantized(
1814 &mut ch, &mut accessors, &mut meshes, &mut materials,
1815 &mut material_map, &view, opts.lit, opts.emissive, &mut stats,
1816 );
1817 mesh_idx = mi;
1818 translation = Some([
1819 placement[0] + center[0],
1820 placement[1] + center[1],
1821 placement[2] + center[2],
1822 ]);
1823 scale = Some(half);
1824 } else {
1825 mesh_idx = push_mesh(
1826 &mut ch, &mut accessors, &mut meshes, &mut materials,
1827 &mut material_map, &view, placement, opts.lit, opts.emissive, &mut stats,
1828 );
1829 translation = None;
1830 scale = None;
1831 }
1832 let node_idx = nodes.len() as u32;
1833 nodes.push(Node {
1834 rotation: None,
1835 mesh: Some(mesh_idx),
1836 children: None,
1837 translation,
1838 scale,
1839 matrix: None,
1840 extras: node_extras(
1841 opts.include_metadata, m.express_id, &m.ifc_type,
1842 m.global_id.as_deref(), opts.model_id.as_deref(),
1843 ),
1844 });
1845 element_node_indices.push(node_idx);
1846 }
1847 },
1848 |_| {},
1849 |_| {},
1850 );
1851 stats.materials = materials.len();
1852
1853 // Single root node carries the model-wide centre and parents every element node.
1854 let (root_translation, site_rotation) = {
1855 let (_, rtc_zup, site_zup) = site_restore(&meta_result);
1856 scene_root(scene_center, rtc_zup, site_zup.as_deref())
1857 };
1858 let scene_nodes = if element_node_indices.is_empty() {
1859 Vec::new()
1860 } else {
1861 let root_idx = nodes.len() as u32;
1862 nodes.push(Node {
1863 rotation: site_rotation,
1864 mesh: None,
1865 children: Some(element_node_indices),
1866 translation: root_translation,
1867 scale: None,
1868 matrix: None,
1869 extras: None,
1870 });
1871 vec![root_idx]
1872 };
1873 ch.flush();
1874
1875 let asset_extras = opts.include_metadata.then(|| {
1876 json!({
1877 "meshCount": stats.meshes,
1878 "vertexCount": stats.vertices,
1879 "triangleCount": stats.triangles,
1880 })
1881 });
1882 let gltf = Gltf {
1883 asset: Asset { version: "2.0", generator: "IFC-Lite", extras: asset_extras },
1884 scene: 0,
1885 scenes: vec![Scene { nodes: scene_nodes }],
1886 nodes,
1887 meshes,
1888 materials: if materials.is_empty() { None } else { Some(materials) },
1889 accessors,
1890 buffer_views: std::mem::take(&mut ch.buffer_views),
1891 buffers: std::mem::take(&mut ch.buffers),
1892 extensions_used: {
1893 let mut ext: Vec<&'static str> = Vec::new();
1894 // Emissive suppresses unlit (mutually exclusive; see make_material).
1895 if !opts.lit && !opts.emissive && stats.materials > 0 {
1896 ext.push("KHR_materials_unlit");
1897 }
1898 if opts.quantize {
1899 ext.push("KHR_mesh_quantization");
1900 }
1901 (!ext.is_empty()).then_some(ext)
1902 },
1903 extensions_required: opts.quantize.then(|| vec!["KHR_mesh_quantization"]),
1904 };
1905 serde_json::to_vec(&gltf).expect("glTF JSON serializes")
1906}
1907
1908// ── Bounded-memory single-GLB export ─────────────────────────────────────────
1909
1910/// Per-mesh record from the metadata streaming pass: everything the glTF JSON
1911/// needs, WITHOUT the vertex bytes (those are re-streamed and written directly
1912/// into the output on the second pass).
1913struct StreamedMeshMeta {
1914 express_id: u32,
1915 /// Interned per export (Arc<str>): IFC type names come from a tiny fixed set, so the
1916 /// bounded plan holds one shared allocation per distinct type instead of one String
1917 /// clone per mesh — the path that exists to bound memory keeps its metadata small.
1918 ifc_type: Arc<str>,
1919 global_id: Option<String>,
1920 color: [f32; 4],
1921 /// Y-up per-element origin (world = origin + position).
1922 origin: [f64; 3],
1923 nverts: u32,
1924 nidx: u32,
1925 /// Local (pre-bake) f32 position bbox. Because `x as f32` is monotonic, the
1926 /// baked accessor min/max equal `(local as f64 + vertex_offset) as f32`
1927 /// exactly — no second pass needed to fill the JSON.
1928 local_min: [f32; 3],
1929 local_max: [f32; 3],
1930 /// Content-dedup key (local geometry + colour), same as the in-memory flat path.
1931 key: u128,
1932 /// `Some(write)` when this occurrence emits geometry bytes on pass 2;
1933 /// `None` when it shares a previously emitted mesh (content-hash dedup).
1934 write: Option<StreamedWrite>,
1935}
1936
1937/// Byte destinations (offsets WITHIN each run) + bake parameters for one emitted mesh.
1938struct StreamedWrite {
1939 pos_off: u64,
1940 norm_off: u64,
1941 idx_off: u64,
1942 /// f32 path: added to each position (f64) before the f32 downcast:
1943 /// `origin - scene_center` for singletons, zero for shared meshes.
1944 vertex_offset: [f64; 3],
1945 /// Quantized path: `(center, half, small_indices)` for the SHORT encoding
1946 /// (the node carries the dequant); `vertex_offset` is unused when set.
1947 quant: Option<([f64; 3], [f64; 3], bool)>,
1948}
1949
1950/// Input-size threshold (bytes) above which `try_export_glb_with_stats` uses the
1951/// bounded streaming assembler instead of the in-memory instanced one.
1952/// `IFC_LITE_GLB_STREAM_THRESHOLD_MB` overrides on native (`0` disables
1953/// streaming entirely); wasm has no environment, so the default always applies
1954/// there — which is the point: the wasm path must never build the whole model
1955/// in memory for large inputs.
1956fn glb_stream_threshold_bytes() -> usize {
1957 // 64 MB: 2x under the smallest input reported to trap the wasm heap (131 MB).
1958 // The streaming path used to lose rep-identity dedup, which was a second
1959 // reason to keep mid-size models in memory; it no longer does.
1960 const DEFAULT_MB: usize = 64;
1961 let mb = std::env::var("IFC_LITE_GLB_STREAM_THRESHOLD_MB")
1962 .ok()
1963 .and_then(|v| v.parse::<usize>().ok())
1964 .unwrap_or(DEFAULT_MB);
1965 if mb == 0 {
1966 return usize::MAX;
1967 }
1968 mb.saturating_mul(1024 * 1024)
1969}
1970
1971/// GLB container size in bytes: the 12-byte file header + the JSON chunk (8-byte
1972/// chunk header + 4-aligned payload) + the BIN chunk (8-byte chunk header +
1973/// 4-aligned payload). Computed in u64 so an oversize model never truncates on
1974/// wasm32 (32-bit `usize`) — the projected size has to stay a reliable > 4 GiB
1975/// signal (#1516). Matches the layout `write_bounded_glb`/`pack_glb` emit.
1976fn glb_container_size(json_len: u64, bin_total: u64) -> u64 {
1977 let json_pad = (4 - (json_len % 4)) % 4;
1978 let bin_pad = (4 - (bin_total % 4)) % 4;
1979 12 + 8 + (json_len + json_pad) + 8 + (bin_total + bin_pad)
1980}
1981
1982/// Projected size of the single-GLB export for a model, computed from pass 1
1983/// only (no output allocation) — issue #1516. A caller uses it to pick single
1984/// GLB vs multi-buffer glTF ([`export_gltf_streaming`]) WITHOUT the historical
1985/// "attempt the GLB, catch the 4 GiB panic, re-run as multi-buffer" dance.
1986#[derive(Debug, Clone, Copy)]
1987pub struct GlbSizeProjection {
1988 /// Projected total GLB container size in bytes (padded JSON + padded BIN +
1989 /// chunk headers). Exact when it fits; a lower bound once oversize (the
1990 /// truncated buffer numbers in the discarded JSON make it marginally short).
1991 pub total_bytes: u64,
1992 /// Projected BIN payload (positions + normals + indices), the value checked
1993 /// against the glTF 4 GiB per-buffer limit. Always exact (u64, no truncation).
1994 pub bin_bytes: u64,
1995 /// `false` when the projected GLB would exceed the glTF 32-bit (4 GiB) limit
1996 /// — route to [`export_gltf_streaming`] (multi-buffer) instead.
1997 pub fits_single_glb: bool,
1998 /// Coverage of the projected export (post content-dedup mesh/vertex/tri counts).
1999 pub stats: GltfStats,
2000}
2001
2002/// Bounded-memory single-**GLB** export: two passes over the deterministic mesh
2003/// stream (`retain_emitted_meshes: false`, peak input = one batch).
2004///
2005/// Pass 1 ([`plan_bounded_glb`]) records per-mesh METADATA only (counts, local
2006/// bbox, colour, ids, content-hash) plus the world AABB; the complete glTF JSON
2007/// is then built and the final GLB `Vec` is preallocated at its exact container
2008/// size. Pass 2 ([`write_bounded_glb`]) re-streams the same meshes and bakes
2009/// their bytes straight into the output at precomputed offsets. Peak memory = the
2010/// final artifact + one batch + metadata — never the whole model's `MeshData`,
2011/// never a growing three-run scratch, and never a second full copy from a final
2012/// concatenation.
2013///
2014/// Fail-closed on both ends, decided after pass 1 so neither costs the second
2015/// meshing pass or the output allocation: an empty visible set returns
2016/// [`ExportError::NoRenderGeometry`] (see [`try_export_glb`] for why a zero-mesh
2017/// GLB is not a valid artifact, #4685), and a projected GLB over the glTF 4 GiB
2018/// limit returns [`ExportError::TooLarge`] carrying the projected byte size
2019/// (#1516). [`project_glb_size`] decides the size question up front.
2020///
2021/// Tradeoffs vs the in-memory assembler (`build_gltf`):
2022/// - rep-identity instancing is done here too, under the same policy
2023/// `collate_refs` applies. The vertex data needs every occurrence
2024/// co-resident; the grouping decision does not, so it is made from the plan.
2025/// Quantized, the dequant rides a child node under the placement node, as in
2026/// `build_gltf`.
2027/// - content-hash dedup is kept (the hash is computed batch-locally on pass 1).
2028/// - the model is meshed twice (the price of bounded memory).
2029/// - **the #3666 reconstruction check does not run here.** The in-memory
2030/// assembler reconstructs each exact-tier occurrence from its template and
2031/// relative transform and compares it against that occurrence's own baked
2032/// vertices, so a `rep_identity` COLLISION between unrelated geometry falls
2033/// back to flat. This path holds a plan, not geometry, so it has no
2034/// occurrence vertices to compare; its guard remains vertex/index counts,
2035/// which a same-shaped colliding pair passes. The returned
2036/// [`GltfStats::unverified_instance_groups`] counts the groups shipped on
2037/// that weaker guard (always 0 from the in-memory path).
2038///
2039/// Supports both the f32 and the `KHR_mesh_quantization` layouts; the quantized
2040/// accessor min/max come from the local bbox in closed form (the quantize map is
2041/// monotone per axis). Caveat: a NaN vertex coordinate quantizes to 0 in the
2042/// byte stream on both paths, but only the in-memory fold lets that 0 into the
2043/// accessor min/max hint; clean meshes are byte-identical.
2044pub fn try_export_glb_streaming_bounded(
2045 content: &[u8],
2046 opts: &GltfOptions,
2047) -> Result<(Vec<u8>, GltfStats), ExportError> {
2048 try_export_glb_streaming_bounded_impl(content, opts, None)
2049}
2050
2051/// Like [`try_export_glb_streaming_bounded`] but reuses a pre-built entity index
2052/// instead of scanning `content` again on EACH of the two passes — for a caller
2053/// that already built it (e.g. to share with [`crate::stream_export_model_with_index`]),
2054/// removing the redundant SIMD scans on the large models this path targets
2055/// (#1516). `index` MUST come from [`build_entity_index`](crate::build_entity_index)
2056/// over the same `content`; output is byte-identical.
2057pub fn try_export_glb_streaming_bounded_with_index(
2058 content: &[u8],
2059 opts: &GltfOptions,
2060 index: Arc<EntityIndex>,
2061) -> Result<(Vec<u8>, GltfStats), ExportError> {
2062 try_export_glb_streaming_bounded_impl(content, opts, Some(index))
2063}
2064
2065fn try_export_glb_streaming_bounded_impl(
2066 content: &[u8],
2067 opts: &GltfOptions,
2068 index: Option<Arc<EntityIndex>>,
2069) -> Result<(Vec<u8>, GltfStats), ExportError> {
2070 // Build the index ONCE in parallel and share it across plan + write. This is
2071 // the fail-closed large-model path, exactly where the parallel scan pays off;
2072 // with `index=None` it otherwise paid two internal serial scans.
2073 let index = index.or_else(|| Some(Arc::new(build_entity_index_parallel(content))));
2074 let plan = plan_bounded_glb(content, opts, index.clone());
2075 refuse_empty(&plan.stats)?;
2076 if plan.bin_total > u32::MAX as u64 || plan.total > u32::MAX as u64 {
2077 return Err(ExportError::TooLarge { bytes: plan.total });
2078 }
2079 Ok(write_bounded_glb(content, opts, index, plan))
2080}
2081
2082/// Project the single-GLB size for `content` from pass 1 only — the world AABB +
2083/// per-mesh byte sizes the bounded assembler already computes — WITHOUT meshing
2084/// twice or allocating the output (#1516). Lets a caller pick single GLB vs
2085/// multi-buffer glTF up front. Meshes the model once (the price of an exact size).
2086pub fn project_glb_size(content: &[u8], opts: &GltfOptions) -> GlbSizeProjection {
2087 project_glb_size_impl(content, opts, None)
2088}
2089
2090/// Shared-index [`project_glb_size`] (see [`try_export_glb_streaming_bounded_with_index`]).
2091pub fn project_glb_size_with_index(
2092 content: &[u8],
2093 opts: &GltfOptions,
2094 index: Arc<EntityIndex>,
2095) -> GlbSizeProjection {
2096 project_glb_size_impl(content, opts, Some(index))
2097}
2098
2099fn project_glb_size_impl(
2100 content: &[u8],
2101 opts: &GltfOptions,
2102 index: Option<Arc<EntityIndex>>,
2103) -> GlbSizeProjection {
2104 let index = index.or_else(|| Some(Arc::new(build_entity_index_parallel(content))));
2105 let plan = plan_bounded_glb(content, opts, index);
2106 GlbSizeProjection {
2107 total_bytes: plan.total,
2108 bin_bytes: plan.bin_total,
2109 fits_single_glb: plan.bin_total <= u32::MAX as u64 && plan.total <= u32::MAX as u64,
2110 stats: plan.stats,
2111 }
2112}
2113
2114/// Everything pass 2 ([`write_bounded_glb`]) needs from pass 1: the finished glTF
2115/// JSON, the per-mesh write plan (`metas`, each carrying its byte offsets), the
2116/// three run lengths, and the projected sizes — WITHOUT the vertex bytes (those
2117/// re-stream on pass 2). Holding this between passes is what lets the caller
2118/// fail fast on an empty or oversize model before any output is allocated.
2119struct BoundedGlbPlan {
2120 metas: Vec<StreamedMeshMeta>,
2121 json: Vec<u8>,
2122 /// Positions / normals run lengths; the index run is `bin_total - pos - norm`
2123 /// (its base offset is `pos_len + norm_len`, so `idx_len` need not be carried).
2124 pos_len: u64,
2125 norm_len: u64,
2126 /// BIN payload size (pos + norm + idx), exact (u64).
2127 bin_total: u64,
2128 /// Projected GLB container size (headers + padded JSON + padded BIN). u64 so
2129 /// an oversize model does not truncate on wasm32 (32-bit `usize`).
2130 total: u64,
2131 stats: GltfStats,
2132}
2133
2134/// Pass 1 of the bounded assembler: mesh the model once to gather per-mesh
2135/// metadata + the world AABB, build the complete glTF JSON, and compute the exact
2136/// output sizes — returning a [`BoundedGlbPlan`] the caller either writes
2137/// ([`write_bounded_glb`]) or inspects for size ([`project_glb_size`]). Does NOT
2138/// assert the 4 GiB limits; the caller decides (panic vs typed error).
2139fn plan_bounded_glb(
2140 content: &[u8],
2141 opts: &GltfOptions,
2142 index: Option<Arc<EntityIndex>>,
2143) -> BoundedGlbPlan {
2144 // A shared `index` (when present) is injected into BOTH passes' StreamingOptions
2145 // so neither re-scans `content` for its entity index (#1516).
2146 let stream_opts = || StreamingOptions {
2147 retain_emitted_meshes: false,
2148 entity_index: index.clone(),
2149 tessellation_quality: opts.tessellation_quality,
2150 ..StreamingOptions::default()
2151 };
2152
2153 // ── Pass 1: metadata + world AABB ────────────────────────────────────────
2154 let filter = VisibilityFilter::new(opts);
2155 let mut yscratch = crate::frame::YUpScratch::new();
2156 // Intern IFC type names so each distinct type is heap-allocated once, not per mesh.
2157 let mut type_intern: FxHashMap<String, Arc<str>> = FxHashMap::default();
2158 let mut metas: Vec<StreamedMeshMeta> = Vec::new();
2159 let mut reps: Vec<(u128, [f64; 16])> = Vec::new();
2160 let mut rep_of: FxHashMap<u32, u32> = FxHashMap::default();
2161 let mut wmin = [f64::INFINITY; 3];
2162 let mut wmax = [f64::NEG_INFINITY; 3];
2163 // Pass 1's result carries `site_transform` and the RTC offset. Dropping it
2164 // is why this path kept emitting the site-local frame after the plain path
2165 // stopped, so the same file moved when it grew past the streaming
2166 // threshold.
2167 let meta_result = process_geometry_streaming_filtered_with_options(
2168 content,
2169 OpeningFilterMode::Default,
2170 stream_opts(),
2171 |batch, _, _| {
2172 for m in batch {
2173 if !filter.visible(m) {
2174 continue;
2175 }
2176 crate::frame::to_yup_into(&mut yscratch, &m.positions, &m.normals, &m.indices, m.origin);
2177 let y = &yscratch;
2178 // Same geometry-sanity gate as `view_ok` on the in-memory path.
2179 if y.indices.is_empty()
2180 || y.positions.len() < 9
2181 || !y.positions.len().is_multiple_of(3)
2182 || y.normals.len() != y.positions.len()
2183 {
2184 continue;
2185 }
2186 let mut lmin = [f32::INFINITY; 3];
2187 let mut lmax = [f32::NEG_INFINITY; 3];
2188 for p in y.positions.chunks_exact(3) {
2189 for (k, &v) in p.iter().enumerate() {
2190 if v < lmin[k] {
2191 lmin[k] = v;
2192 }
2193 if v > lmax[k] {
2194 lmax[k] = v;
2195 }
2196 }
2197 }
2198 // World AABB from the local bbox: `x as f64` is exact and the fold
2199 // is order-independent, so this equals the in-memory per-vertex fold.
2200 for k in 0..3 {
2201 wmin[k] = wmin[k].min(lmin[k] as f64 + y.origin[k]);
2202 wmax[k] = wmax[k].max(lmax[k] as f64 + y.origin[k]);
2203 }
2204 // Intern: clone the String key only the first time a type is seen; every
2205 // later mesh of that type just bumps the shared Arc<str> refcount.
2206 let ifc_type = match type_intern.get(m.ifc_type.as_str()) {
2207 Some(a) => a.clone(),
2208 None => {
2209 let a: Arc<str> = Arc::from(m.ifc_type.as_str());
2210 type_intern.insert(m.ifc_type.clone(), a.clone());
2211 a
2212 }
2213 };
2214 // Shape identity and this occurrence's composed world
2215 // placement, for meshes the geometry engine says are provably
2216 // shareable. `canonical_transform` marks the rotation-
2217 // normalized tier, where a template is congruent rather than
2218 // identical; the in-memory path refuses those groups and so
2219 // does this one.
2220 //
2221 // Beside `metas` rather than in it. An `InstanceMeta` is 424
2222 // bytes and one entry here is 160, which is what makes
2223 // rep-identity instancing affordable on the path that exists to
2224 // bound memory. Measured on a 1.05 GB model: about 30 MB of
2225 // plan against 680 MB of geometry not written (glTF 1.82 GB ->
2226 // 1.14 GB, peak RSS 6.02 GB -> 5.36 GB, same wall time).
2227 //
2228 // Out of the per-mesh struct for two reasons: only instanceable
2229 // meshes pay, and nothing reads it after planning, so it drops
2230 // instead of sitting beside the whole output buffer while pass
2231 // 2 writes it. (That 160 is this entry, not the per-mesh struct
2232 // -- `the_streamed_mesh_plan_stays_small` pins that separately
2233 // at 240, and it is 240 *because* this moved out.)
2234 let instanceable = m
2235 .instance
2236 .as_ref()
2237 .filter(|i| i.instanceable && i.canonical_transform.is_none());
2238 if let Some(inst) = instanceable {
2239 rep_of.insert(metas.len() as u32, reps.len() as u32);
2240 reps.push((inst.rep_identity, compose_world_meta(inst)));
2241 }
2242 metas.push(StreamedMeshMeta {
2243 express_id: m.express_id,
2244 ifc_type,
2245 global_id: m.global_id.clone(),
2246 color: m.color,
2247 origin: y.origin,
2248 nverts: (y.positions.len() / 3) as u32,
2249 nidx: y.indices.len() as u32,
2250 local_min: lmin,
2251 local_max: lmax,
2252 key: geom_color_key(&y.positions, &y.normals, &y.indices, m.color),
2253 write: None,
2254 });
2255 }
2256 },
2257 |_| {},
2258 |_| {},
2259 );
2260 let scene_center = if metas.is_empty() {
2261 [0.0, 0.0, 0.0]
2262 } else {
2263 [
2264 (wmin[0] + wmax[0]) * 0.5,
2265 (wmin[1] + wmax[1]) * 0.5,
2266 (wmin[2] + wmax[2]) * 0.5,
2267 ]
2268 };
2269
2270 // ── Build the glTF JSON (mirrors build_gltf's flat branch exactly) ──────
2271
2272 // Rep-identity instancing, which this path used to give up on because it
2273 // "needs every occurrence co-resident". The vertex data does; the decision
2274 // does not. `collate_refs` reads nothing off a mesh's geometry but its
2275 // length, so what a group needs is an identity and a placement, and those
2276 // fit in the plan this path already keeps.
2277 //
2278 // Quantized too: the occurrence gets the nested dequant node
2279 // `push_occurrence_node` builds for both assemblers.
2280 let (space, rtc_zup, site_zup) = site_restore(&meta_result);
2281 // Rep identities whose occurrences disagree about shape size. Resolved
2282 // before any bucketing, because one disagreeing member refuses the whole
2283 // identity and it may be the last one seen.
2284 //
2285 // This is `collate_refs`' policy, arrived at the hard way. Keying the
2286 // bucket on size instead shares more -- on one 1 GB file, 532 rep groups of
2287 // 87,393 hold an occurrence clipped to a different vertex count, and
2288 // between them 22,343 of the 151,282 occurrences. But the collator's
2289 // refusal is a safety property rather than an oversight: `rep_identity` is
2290 // only the RepresentationMap entity id for a mapped item, so two
2291 // occurrences of one map clipped differently that land on the same vertex
2292 // and index counts are a group whose members are not one shape.
2293 // Sub-bucketing by size hands one of them the other's geometry, silently.
2294 // Refusing costs sharing; the other way costs correctness. What it costs,
2295 // measured on a 342 MB model: 34.5 MB of glTF instead of 25.0 MB, against
2296 // 108 MB with no instancing at all.
2297 //
2298 // This aligns the size rule with the collator, and it does not make the two
2299 // paths identical: the in-memory one refuses a whole group where any
2300 // occurrence has no instance side-channel, and this one drops that
2301 // occurrence and keeps the rest. See
2302 // `the_bounded_path_shares_at_least_as_much`, which pins that difference.
2303 //
2304 // The other difference, and it is a KNOWN GAP (#3666 follow-up): the
2305 // in-memory path additionally reconstructs each occurrence from
2306 // `(template, rel)` and compares it against that occurrence's own baked
2307 // vertices (`ifc_lite_geometry::instancing::verify_pairing`), so a
2308 // same-count rep_identity COLLISION is caught there and falls back to
2309 // flat. This path cannot run that check as written: it holds a plan, not
2310 // geometry — `retain_emitted_meshes: false` is the whole reason it bounds
2311 // memory — so nothing here has an occurrence's vertices to compare. Closing
2312 // it needs a streaming variant that retains one template's vertices per live
2313 // rep group and verifies each later occurrence as it streams past, which is
2314 // a memory-budget decision of its own, not a drop-in of the same call.
2315 let refused: FxHashSet<u128> = {
2316 let mut seen: FxHashMap<u128, (u32, u32)> = FxHashMap::default();
2317 let mut bad: FxHashSet<u128> = FxHashSet::default();
2318 for (i, meta) in metas.iter().enumerate() {
2319 let Some(&slot) = rep_of.get(&(i as u32)) else { continue };
2320 let rid = reps[slot as usize].0;
2321 match seen.get(&rid) {
2322 None => {
2323 seen.insert(rid, (meta.nverts, meta.nidx));
2324 }
2325 Some(&size) if size != (meta.nverts, meta.nidx) => {
2326 bad.insert(rid);
2327 }
2328 Some(_) => {}
2329 }
2330 }
2331 bad
2332 };
2333 /// Identity and colour. Colour because a glTF material rides the mesh
2334 /// primitive and not the node, so two colours of one shape are two meshes.
2335 type RepBucket = (u128, (i32, i32, i32, i32));
2336 let bucket_of = |mi: usize, m: &StreamedMeshMeta| -> Option<RepBucket> {
2337 let rid = reps[*rep_of.get(&(mi as u32))? as usize].0;
2338 if refused.contains(&rid) {
2339 return None;
2340 }
2341 Some((rid, color_key(m.color)))
2342 };
2343 /// What one shape does about being shared, decided before the emission loop
2344 /// so it can read the template's placement while holding the occurrence's
2345 /// mutably.
2346 ///
2347 /// Per bucket rather than per occurrence. The same plan indexed by mesh is
2348 /// 208 bytes on every member of every group: on the 1 GB file's 151,282
2349 /// occurrences that is 31 MB, which is what the whole plan costs in the
2350 /// first place, on the path that exists to bound memory. It also inverts
2351 /// the template's placement once per occurrence instead of once per shape.
2352 struct RepGroup {
2353 first: usize,
2354 /// `affine_inverse` of the template's world placement, which maps its
2355 /// baked geometry back into the shape's own frame.
2356 m_ref_inv: [f64; 16],
2357 template_origin: [f64; 3],
2358 }
2359 let rep_groups: FxHashMap<RepBucket, RepGroup> = {
2360 let mut counts: FxHashMap<RepBucket, (usize, u32)> = FxHashMap::default();
2361 for (i, meta) in metas.iter().enumerate() {
2362 let Some(bucket) = bucket_of(i, meta) else { continue };
2363 let e = counts.entry(bucket).or_insert((i, 0));
2364 e.1 += 1;
2365 }
2366 counts
2367 .into_iter()
2368 .filter(|&(_, (_, n))| n >= 2)
2369 .filter_map(|(bucket, (first, _))| {
2370 // A singular placement has no inverse, so the shape cannot be
2371 // recovered from its baked form and the bucket stays flat.
2372 let m_ref = reps[*rep_of.get(&(first as u32))? as usize].1;
2373 let m_ref_inv = affine_inverse(&m_ref)?;
2374 Some((
2375 bucket,
2376 RepGroup { first, m_ref_inv, template_origin: metas[first].origin },
2377 ))
2378 })
2379 .collect()
2380 };
2381 // Content-dedup over the flat remainder only. A rep-grouped mesh never
2382 // reads or writes `shared_cache`, so counting it here would make the sole
2383 // flat member of its key look shared: it would emit local geometry and a
2384 // node translation instead of the baked singleton, and write a cache entry
2385 // nothing reads. The in-memory twin counts the remainder, so this matches
2386 // rather than diverges from it.
2387 let mut key_counts: FxHashMap<u128, u32> = FxHashMap::default();
2388 for (i, meta) in metas.iter().enumerate() {
2389 if bucket_of(i, meta).is_some_and(|b| rep_groups.contains_key(&b)) {
2390 continue;
2391 }
2392 *key_counts.entry(meta.key).or_insert(0) += 1;
2393 }
2394 // bucket -> (mesh_idx, dequant center, dequant half) of the TEMPLATE: every
2395 // occurrence dequantizes the template's bytes, not its own bbox.
2396 let mut rep_cache: FxHashMap<RepBucket, (u32, [f64; 3], [f64; 3])> = FxHashMap::default();
2397 let mut accessors: Vec<Accessor> = Vec::new();
2398 let mut meshes: Vec<Mesh> = Vec::new();
2399 let mut nodes: Vec<Node> = Vec::new();
2400 let mut materials: Vec<Material> = Vec::new();
2401 let mut material_map: FxHashMap<(i32, i32, i32, i32), u32> = FxHashMap::default();
2402 let mut element_node_indices: Vec<u32> = Vec::new();
2403 let mut stats = GltfStats { meshes: 0, vertices: 0, triangles: 0, materials: 0, unverified_instance_groups: 0 };
2404 // key -> (mesh_idx, dequant center, dequant half). center/half are dummy
2405 // zeros/ones on the f32 path (node scale stays None), the per-mesh dequant
2406 // the node folds in on the quantized path (mirrors build_gltf's flat_cache).
2407 // Every group this path instances is instanced unverified; see the field's
2408 // docs and the KNOWN GAP note above `refused`. Counted per REP IDENTITY, not
2409 // per bucket: a bucket is (identity, colour) because a glTF material rides
2410 // the primitive, so one shape in two colours is two buckets — but it is one
2411 // identity, and the identity is the thing the unverified substitution is
2412 // made on the strength of. Counting buckets reported the same unchecked
2413 // hash more than once and inflated the figure against its own doc comment.
2414 stats.unverified_instance_groups =
2415 rep_groups.keys().map(|&(rid, _)| rid).collect::<FxHashSet<u128>>().len();
2416 let mut shared_cache: FxHashMap<u128, (u32, [f64; 3], [f64; 3])> = FxHashMap::default();
2417 let (mut pos_len, mut norm_len, mut idx_len) = (0u64, 0u64, 0u64);
2418 let quantize = opts.quantize;
2419
2420 // First simulation pass computes run lengths so accessor emission below can
2421 // reference stable bufferView indices (0/1/2) with per-run byte offsets.
2422 // Emission order = stream order, geometry emitted on a shared key's FIRST
2423 // occurrence only — identical to the in-memory flat pass.
2424 struct Emitted {
2425 mesh_idx: u32,
2426 translation: Option<[f64; 3]>,
2427 scale: Option<[f64; 3]>,
2428 /// An instanced occurrence's placement, and its template's dequant when quantized.
2429 occurrence: Option<([f32; 16], Option<Dequant>)>,
2430 }
2431 let mut per_meta: Vec<Emitted> = Vec::with_capacity(metas.len());
2432 for (mi, meta) in metas.iter_mut().enumerate() {
2433 // The bucket is the shape's identity, and it is also what the mesh
2434 // cache is keyed on, so carry both rather than looking it up twice.
2435 let rep = bucket_of(mi, meta).and_then(|b| rep_groups.get(&b).map(|g| (b, g)));
2436 let placement = [
2437 meta.origin[0] - scene_center[0],
2438 meta.origin[1] - scene_center[1],
2439 meta.origin[2] - scene_center[2],
2440 ];
2441 let shared = key_counts.get(&meta.key).copied().unwrap_or(1) >= 2;
2442 // Per-mesh dequant frame (quantized layout): centre + half-extent of the
2443 // LOCAL bbox, degenerate axes guarded to 1, exactly as push_mesh_quantized.
2444 let (q_center, q_half) = {
2445 let mut c = [0.0f64; 3];
2446 let mut h = [1.0f64; 3];
2447 for k in 0..3 {
2448 let lo = meta.local_min[k] as f64;
2449 let hi = meta.local_max[k] as f64;
2450 c[k] = (lo + hi) * 0.5;
2451 let hh = (hi - lo) * 0.5;
2452 if hh > 0.0 {
2453 h[k] = hh;
2454 }
2455 }
2456 (c, h)
2457 };
2458 // A shape's geometry goes out once, on its bucket's template. Every
2459 // other occurrence of it emits a node and no bytes.
2460 let emit = match rep {
2461 Some((_, group)) => mi == group.first,
2462 None => !(shared && shared_cache.contains_key(&meta.key)),
2463 };
2464 // f32 path only: singletons bake world-minus-centre into the vertices.
2465 // A shared shape stays in its own frame, because what places it is the
2466 // node, and for an instanced occurrence that node is a full matrix.
2467 let vertex_offset = if shared || quantize || rep.is_some() {
2468 [0.0, 0.0, 0.0]
2469 } else {
2470 placement
2471 };
2472 let (mesh_idx, center, half) = if emit {
2473 let (pos_acc, norm_acc, idx_acc) = if quantize {
2474 // Quantize is monotone per axis, so the accessor min/max are the
2475 // quantized local bbox corners in closed form.
2476 let q1 = |v: f32, k: usize| -> f32 {
2477 let n = ((v as f64 - q_center[k]) / q_half[k]).clamp(-1.0, 1.0);
2478 ((n * 32767.0).round() as i16) as f32
2479 };
2480 let qv = |v: [f32; 3]| [q1(v[0], 0), q1(v[1], 1), q1(v[2], 2)];
2481 let pos_acc = accessors.len() as u32;
2482 accessors.push(Accessor {
2483 buffer_view: 0,
2484 byte_offset: pos_len as u32,
2485 component_type: 5122, // SHORT
2486 count: meta.nverts,
2487 ty: "VEC3",
2488 normalized: Some(true),
2489 min: Some(qv(meta.local_min)),
2490 max: Some(qv(meta.local_max)),
2491 });
2492 let norm_acc = accessors.len() as u32;
2493 accessors.push(Accessor {
2494 buffer_view: 1,
2495 byte_offset: norm_len as u32,
2496 component_type: 5122,
2497 count: meta.nverts,
2498 ty: "VEC3",
2499 normalized: Some(true),
2500 min: None,
2501 max: None,
2502 });
2503 let small = meta.nverts <= u16::MAX as u32 + 1;
2504 let idx_acc = accessors.len() as u32;
2505 accessors.push(Accessor {
2506 buffer_view: 2,
2507 byte_offset: idx_len as u32,
2508 component_type: if small { 5123 } else { 5125 },
2509 count: meta.nidx,
2510 ty: "SCALAR",
2511 normalized: None,
2512 min: None,
2513 max: None,
2514 });
2515 (pos_acc, norm_acc, idx_acc)
2516 } else {
2517 let bake = |local: [f32; 3]| -> [f32; 3] {
2518 [
2519 (local[0] as f64 + vertex_offset[0]) as f32,
2520 (local[1] as f64 + vertex_offset[1]) as f32,
2521 (local[2] as f64 + vertex_offset[2]) as f32,
2522 ]
2523 };
2524 let pos_acc = accessors.len() as u32;
2525 accessors.push(Accessor {
2526 buffer_view: 0,
2527 byte_offset: pos_len as u32,
2528 component_type: 5126,
2529 count: meta.nverts,
2530 ty: "VEC3",
2531 normalized: None,
2532 min: Some(bake(meta.local_min)),
2533 max: Some(bake(meta.local_max)),
2534 });
2535 let norm_acc = accessors.len() as u32;
2536 accessors.push(Accessor {
2537 buffer_view: 1,
2538 byte_offset: norm_len as u32,
2539 component_type: 5126,
2540 count: meta.nverts,
2541 ty: "VEC3",
2542 normalized: None,
2543 min: None,
2544 max: None,
2545 });
2546 let idx_acc = accessors.len() as u32;
2547 accessors.push(Accessor {
2548 buffer_view: 2,
2549 byte_offset: idx_len as u32,
2550 component_type: 5125,
2551 count: meta.nidx,
2552 ty: "SCALAR",
2553 normalized: None,
2554 min: None,
2555 max: None,
2556 });
2557 (pos_acc, norm_acc, idx_acc)
2558 };
2559 let material = *material_map.entry(color_key(meta.color)).or_insert_with(|| {
2560 let idx = materials.len() as u32;
2561 materials.push(make_material(meta.color, opts.lit, opts.emissive));
2562 idx
2563 });
2564 let mesh_idx = meshes.len() as u32;
2565 meshes.push(Mesh {
2566 primitives: vec![Primitive {
2567 attributes: Attributes { position: pos_acc, normal: norm_acc },
2568 indices: idx_acc,
2569 material: Some(material),
2570 }],
2571 });
2572 stats.meshes += 1;
2573 stats.vertices += meta.nverts as usize;
2574 stats.triangles += meta.nidx as usize / 3;
2575 let small = meta.nverts <= u16::MAX as u32 + 1;
2576 meta.write = Some(StreamedWrite {
2577 pos_off: pos_len,
2578 norm_off: norm_len,
2579 idx_off: idx_len,
2580 vertex_offset,
2581 quant: quantize.then_some((q_center, q_half, small)),
2582 });
2583 if quantize {
2584 pos_len += meta.nverts as u64 * 8;
2585 norm_len += meta.nverts as u64 * 8;
2586 idx_len += meta.nidx as u64 * if small { 2 } else { 4 };
2587 // The in-memory chunker pads the index run to 4-byte alignment
2588 // after every mesh; mirror it so offsets and lengths agree.
2589 idx_len = idx_len.div_ceil(4) * 4;
2590 } else {
2591 pos_len += meta.nverts as u64 * 12;
2592 norm_len += meta.nverts as u64 * 12;
2593 idx_len += meta.nidx as u64 * 4;
2594 }
2595 if let Some((bucket, _)) = rep {
2596 rep_cache.insert(bucket, (mesh_idx, q_center, q_half));
2597 } else if shared {
2598 shared_cache.insert(meta.key, (mesh_idx, q_center, q_half));
2599 }
2600 (mesh_idx, q_center, q_half)
2601 } else if let Some((bucket, _)) = rep {
2602 rep_cache[&bucket]
2603 } else {
2604 shared_cache[&meta.key]
2605 };
2606 // An occurrence differs from its template by a rotation as often as by
2607 // a translation, so it needs the whole placement and not an offset.
2608 // `rep` is `Some` only where `bucket_of` was, which is only where
2609 // `meta.rep` was, so the placement is there by construction.
2610 let matrix = rep.map(|(_, group)| {
2611 // Indexed, not `?`. `emit` and `vertex_offset` above have already
2612 // committed to this mesh being in a group; falling back to `None`
2613 // here would place the template's geometry at this occurrence's
2614 // origin with no rotation, which is silent corruption. A miss is a
2615 // broken invariant and should say so.
2616 let slot = rep_of[&(mi as u32)] as usize;
2617 let (_, m_k) = reps[slot];
2618 occurrence_node_matrix_composed(
2619 m_k,
2620 &group.m_ref_inv,
2621 space,
2622 rtc_zup,
2623 site_zup.as_deref(),
2624 group.template_origin,
2625 scene_center,
2626 )
2627 });
2628 let occurrence = matrix.map(|m| (m, quantize.then_some((center, half))));
2629 let (translation, scale) = if matrix.is_some() {
2630 (None, None)
2631 } else if quantize {
2632 // Placement is pure translation, so it commutes with the dequant
2633 // translate: node = T(placement + center) · S(half).
2634 (
2635 Some([
2636 placement[0] + center[0],
2637 placement[1] + center[1],
2638 placement[2] + center[2],
2639 ]),
2640 Some(half),
2641 )
2642 } else if shared {
2643 (
2644 placement.iter().any(|c| c.abs() > 1e-9).then_some(placement),
2645 None,
2646 )
2647 } else {
2648 (None, None)
2649 };
2650 per_meta.push(Emitted { mesh_idx, translation, scale, occurrence });
2651 }
2652 for (meta, emitted) in metas.iter().zip(&per_meta) {
2653 let extras = node_extras(
2654 opts.include_metadata,
2655 meta.express_id,
2656 meta.ifc_type.as_ref(),
2657 meta.global_id.as_deref(),
2658 opts.model_id.as_deref(),
2659 );
2660 let node_idx = if let Some((matrix, dequant)) = emitted.occurrence {
2661 push_occurrence_node(&mut nodes, emitted.mesh_idx, matrix, dequant, extras)
2662 } else {
2663 nodes.push(Node {
2664 rotation: None,
2665 mesh: Some(emitted.mesh_idx),
2666 children: None,
2667 translation: emitted.translation,
2668 scale: emitted.scale,
2669 matrix: None,
2670 extras,
2671 });
2672 nodes.len() as u32 - 1
2673 };
2674 element_node_indices.push(node_idx);
2675 }
2676 stats.materials = materials.len();
2677
2678 let bin_total = pos_len + norm_len + idx_len;
2679 // NOTE: the 4 GiB buffer/container limits are NOT asserted here — the caller
2680 // decides (typed `ExportError::TooLarge` in `try_export_glb_streaming_bounded*`,
2681 // a `fits_single_glb` verdict in `project_glb_size*`). The `bin_total as
2682 // u32` casts below therefore truncate on an oversize model, but that JSON is
2683 // only ever emitted when the size fits (an oversize plan is discarded), so
2684 // every path that actually produces bytes stays correct.
2685 let (buffers, buffer_views) = if bin_total == 0 && stats.meshes == 0 {
2686 (vec![Buffer { byte_length: 0, uri: None }], Vec::new())
2687 } else {
2688 (
2689 vec![Buffer { byte_length: bin_total as u32, uri: None }],
2690 vec![
2691 BufferView {
2692 buffer: 0,
2693 byte_offset: 0,
2694 byte_length: pos_len as u32,
2695 byte_stride: Some(if quantize { 8 } else { 12 }),
2696 target: 34962,
2697 },
2698 BufferView {
2699 buffer: 0,
2700 byte_offset: pos_len as u32,
2701 byte_length: norm_len as u32,
2702 byte_stride: Some(if quantize { 8 } else { 12 }),
2703 target: 34962,
2704 },
2705 BufferView {
2706 buffer: 0,
2707 byte_offset: (pos_len + norm_len) as u32,
2708 byte_length: idx_len as u32,
2709 byte_stride: None,
2710 target: 34963,
2711 },
2712 ],
2713 )
2714 };
2715
2716 let (root_translation, site_rotation) =
2717 scene_root(scene_center, rtc_zup, site_zup.as_deref());
2718 let scene_nodes = if element_node_indices.is_empty() {
2719 Vec::new()
2720 } else {
2721 let root_idx = nodes.len() as u32;
2722 nodes.push(Node {
2723 rotation: site_rotation,
2724 mesh: None,
2725 children: Some(element_node_indices),
2726 translation: root_translation,
2727 scale: None,
2728 matrix: None,
2729 extras: None,
2730 });
2731 vec![root_idx]
2732 };
2733
2734 let asset_extras = opts.include_metadata.then(|| {
2735 json!({
2736 "meshCount": stats.meshes,
2737 "vertexCount": stats.vertices,
2738 "triangleCount": stats.triangles,
2739 })
2740 });
2741 let gltf = Gltf {
2742 asset: Asset { version: "2.0", generator: "IFC-Lite", extras: asset_extras },
2743 scene: 0,
2744 scenes: vec![Scene { nodes: scene_nodes }],
2745 nodes,
2746 meshes,
2747 materials: if materials.is_empty() { None } else { Some(materials) },
2748 accessors,
2749 buffer_views,
2750 buffers,
2751 extensions_used: {
2752 let mut ext: Vec<&'static str> = Vec::new();
2753 // Emissive suppresses unlit (mutually exclusive; see make_material).
2754 if !opts.lit && !opts.emissive && stats.materials > 0 {
2755 ext.push("KHR_materials_unlit");
2756 }
2757 if quantize {
2758 ext.push("KHR_mesh_quantization");
2759 }
2760 (!ext.is_empty()).then_some(ext)
2761 },
2762 extensions_required: quantize.then(|| vec!["KHR_mesh_quantization"]),
2763 };
2764 let json = serde_json::to_vec(&gltf).expect("glTF JSON serializes");
2765
2766 // Projected container size in u64 (see `glb_container_size`): this crate also
2767 // compiles to wasm32 (32-bit `usize`), so an oversize model must NOT truncate
2768 // here — the size has to stay a reliable > 4 GiB fail-fast signal.
2769 let total = glb_container_size(json.len() as u64, bin_total);
2770
2771 BoundedGlbPlan { metas, json, pos_len, norm_len, bin_total, total, stats }
2772}
2773
2774/// Pass 2 of the bounded assembler: preallocate the exact GLB container from
2775/// `plan`, write the header + JSON, then re-stream the (deterministic) meshes and
2776/// bake their bytes straight into the BIN region at the precomputed offsets.
2777/// Callers MUST have already validated `plan.total`/`plan.bin_total` fit the glTF
2778/// 4 GiB limits (panic or typed error) — this assumes they do.
2779fn write_bounded_glb(
2780 content: &[u8],
2781 opts: &GltfOptions,
2782 index: Option<Arc<EntityIndex>>,
2783 plan: BoundedGlbPlan,
2784) -> (Vec<u8>, GltfStats) {
2785 // Re-stream with the same (optionally shared) index so pass 2 never re-scans
2786 // for its entity index either (#1516).
2787 let stream_opts = || StreamingOptions {
2788 retain_emitted_meshes: false,
2789 entity_index: index.clone(),
2790 tessellation_quality: opts.tessellation_quality,
2791 ..StreamingOptions::default()
2792 };
2793 let BoundedGlbPlan { metas, json, pos_len, norm_len, bin_total, total, stats } = plan;
2794
2795 // ── Preallocate the exact GLB container, then pass 2 writes into it ─────
2796 // Safe to narrow to usize here: the caller validated `total`/`bin_total` fit
2797 // the 4 GiB glTF limit, so every value below is < u32::MAX on any target.
2798 let total = total as usize;
2799 let json_pad = (4 - (json.len() % 4)) % 4;
2800 let bin_pad = ((4 - (bin_total % 4)) % 4) as usize;
2801 let padded_json = json.len() + json_pad;
2802 let padded_bin = bin_total as usize + bin_pad;
2803 let mut out = Vec::with_capacity(total);
2804 out.extend_from_slice(b"glTF");
2805 out.extend_from_slice(&2u32.to_le_bytes());
2806 out.extend_from_slice(&(total as u32).to_le_bytes());
2807 out.extend_from_slice(&(padded_json as u32).to_le_bytes());
2808 out.extend_from_slice(b"JSON");
2809 out.extend_from_slice(&json);
2810 out.extend(std::iter::repeat_n(0x20, json_pad));
2811 out.extend_from_slice(&(padded_bin as u32).to_le_bytes());
2812 out.extend_from_slice(b"BIN\0");
2813 let bin_base = out.len();
2814 // Zero-fill the BIN region (+ its padding); pass 2 overwrites every emitted byte.
2815 out.resize(total, 0);
2816 let pos_base = bin_base;
2817 let norm_base = bin_base + pos_len as usize;
2818 let idx_base = bin_base + (pos_len + norm_len) as usize;
2819
2820 let filter = VisibilityFilter::new(opts);
2821 let mut yscratch = crate::frame::YUpScratch::new();
2822 let mut cursor = 0usize;
2823 process_geometry_streaming_filtered_with_options(
2824 content,
2825 OpeningFilterMode::Default,
2826 stream_opts(),
2827 |batch, _, _| {
2828 for m in batch {
2829 if !filter.visible(m) {
2830 continue;
2831 }
2832 crate::frame::to_yup_into(&mut yscratch, &m.positions, &m.normals, &m.indices, m.origin);
2833 let y = &yscratch;
2834 if y.indices.is_empty()
2835 || y.positions.len() < 9
2836 || !y.positions.len().is_multiple_of(3)
2837 || y.normals.len() != y.positions.len()
2838 {
2839 continue;
2840 }
2841 let meta = metas.get(cursor).unwrap_or_else(|| {
2842 panic!("GLB streaming pass 2 saw more meshes than pass 1 ({cursor}); the mesh stream is not deterministic")
2843 });
2844 assert!(
2845 meta.express_id == m.express_id
2846 && meta.nverts as usize * 3 == y.positions.len()
2847 && meta.nidx as usize == y.indices.len()
2848 // Content-exact: an element can emit multiple submeshes
2849 // with identical counts, so id+counts alone could let a
2850 // reordered stream write into the wrong offsets.
2851 && meta.key == geom_color_key(&y.positions, &y.normals, &y.indices, m.color),
2852 "GLB streaming pass 2 diverged from pass 1 at mesh {cursor} \
2853 (expected #{} {}v/{}i, got #{} {}v/{}i); the mesh stream is not deterministic",
2854 meta.express_id, meta.nverts, meta.nidx,
2855 m.express_id, y.positions.len() / 3, y.indices.len(),
2856 );
2857 if let Some(w) = &meta.write {
2858 if let Some((center, half, small)) = &w.quant {
2859 // SHORT-normalized encoding, identical to push_mesh_quantized;
2860 // the 2-byte stride pads and the index-run 4-alignment pads
2861 // are already zero from the container prefill.
2862 let mut po = pos_base + w.pos_off as usize;
2863 for p in y.positions.chunks_exact(3) {
2864 for (k, &v) in p.iter().enumerate() {
2865 let n = ((v as f64 - center[k]) / half[k]).clamp(-1.0, 1.0);
2866 let q = (n * 32767.0).round() as i16;
2867 out[po..po + 2].copy_from_slice(&q.to_le_bytes());
2868 po += 2;
2869 }
2870 po += 2; // 8-byte stride pad
2871 }
2872 let mut no = norm_base + w.norm_off as usize;
2873 for nrm in y.normals.chunks_exact(3) {
2874 let mut v = [
2875 nrm[0] as f64 * half[0],
2876 nrm[1] as f64 * half[1],
2877 nrm[2] as f64 * half[2],
2878 ];
2879 let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
2880 if len > 0.0 {
2881 v = [v[0] / len, v[1] / len, v[2] / len];
2882 }
2883 for c in v {
2884 let q = (c.clamp(-1.0, 1.0) * 32767.0).round() as i16;
2885 out[no..no + 2].copy_from_slice(&q.to_le_bytes());
2886 no += 2;
2887 }
2888 no += 2; // 8-byte stride pad
2889 }
2890 let mut io = idx_base + w.idx_off as usize;
2891 if *small {
2892 for &i in &y.indices {
2893 out[io..io + 2].copy_from_slice(&(i as u16).to_le_bytes());
2894 io += 2;
2895 }
2896 } else {
2897 for &i in &y.indices {
2898 out[io..io + 4].copy_from_slice(&i.to_le_bytes());
2899 io += 4;
2900 }
2901 }
2902 } else {
2903 let mut po = pos_base + w.pos_off as usize;
2904 for p in y.positions.chunks_exact(3) {
2905 for (&pv, &off) in p.iter().zip(&w.vertex_offset) {
2906 let baked = (pv as f64 + off) as f32;
2907 out[po..po + 4].copy_from_slice(&baked.to_le_bytes());
2908 po += 4;
2909 }
2910 }
2911 let mut no = norm_base + w.norm_off as usize;
2912 for &n in &y.normals {
2913 out[no..no + 4].copy_from_slice(&n.to_le_bytes());
2914 no += 4;
2915 }
2916 let mut io = idx_base + w.idx_off as usize;
2917 for &i in &y.indices {
2918 out[io..io + 4].copy_from_slice(&i.to_le_bytes());
2919 io += 4;
2920 }
2921 }
2922 }
2923 cursor += 1;
2924 }
2925 },
2926 |_| {},
2927 |_| {},
2928 );
2929 assert!(
2930 cursor == metas.len(),
2931 "GLB streaming pass 2 saw {cursor} meshes, pass 1 saw {}; the mesh stream is not deterministic",
2932 metas.len(),
2933 );
2934
2935 (out, stats)
2936}
2937
2938
2939/// Pack a glTF JSON document and the binary buffer's three runs (positions | normals |
2940/// indices) into a GLB container (little-endian). The runs are appended straight into the
2941/// output — no intermediate concatenated `bin` copy — so the single-buffer GLB path holds
2942/// only one full copy of the geometry at pack time instead of two.
2943fn pack_glb(json_bytes: &[u8], pos: &[u8], norm: &[u8], idx: &[u8]) -> Vec<u8> {
2944 let bin_len = pos.len() + norm.len() + idx.len();
2945 let json_pad = (4 - (json_bytes.len() % 4)) % 4;
2946 let bin_pad = (4 - (bin_len % 4)) % 4;
2947 let padded_json = json_bytes.len() + json_pad;
2948 let padded_bin = bin_len + bin_pad;
2949
2950 let total = 12 + 8 + padded_json + 8 + padded_bin;
2951 // The GLB container total and chunk lengths are u32 (little-endian). This is
2952 // the authoritative 4 GiB guard: it covers the JSON chunk + 28 bytes of
2953 // framing + padding on top of the binary buffer, which the assemble_glb check
2954 // (binary buffer only) does not. Fail loud instead of wrapping into a corrupt
2955 // container. (Reachable only for a ~4 GiB native export; wasm32 OOMs first.)
2956 assert!(
2957 total <= u32::MAX as usize,
2958 "GLB total size is {total} bytes, over the glTF 32-bit container limit (4 GiB)",
2959 );
2960 let mut out = Vec::with_capacity(total);
2961
2962 // GLB header
2963 out.extend_from_slice(b"glTF"); // magic 0x46546C67 little-endian
2964 out.extend_from_slice(&2u32.to_le_bytes()); // version
2965 out.extend_from_slice(&(total as u32).to_le_bytes());
2966
2967 // JSON chunk (space-padded)
2968 out.extend_from_slice(&(padded_json as u32).to_le_bytes());
2969 out.extend_from_slice(b"JSON");
2970 out.extend_from_slice(json_bytes);
2971 out.extend(std::iter::repeat_n(0x20, json_pad));
2972
2973 // BIN chunk (zero-padded): the three runs written back-to-back are byte-identical to
2974 // the old `positions ++ normals ++ indices` concatenation.
2975 out.extend_from_slice(&(padded_bin as u32).to_le_bytes());
2976 out.extend_from_slice(b"BIN\0");
2977 out.extend_from_slice(pos);
2978 out.extend_from_slice(norm);
2979 out.extend_from_slice(idx);
2980 out.extend(std::iter::repeat_n(0x00, bin_pad));
2981
2982 out
2983}
2984
2985#[cfg(test)]
2986#[path = "gltf_tests.rs"]
2987mod tests;
2988
2989#[cfg(test)]
2990#[path = "gltf_conformance_tests.rs"]
2991mod conformance_tests;
2992
2993#[cfg(test)]
2994mod site_placement_tests {
2995 use super::*;
2996
2997 /// A site rotated 90 degrees about Z and moved, with one wall.
2998 const ROTATED_SITE: &str = "\
2999ISO-10303-21;
3000HEADER;
3001FILE_DESCRIPTION((''),'2;1');
3002FILE_NAME('','',(''),(''),'','','');
3003FILE_SCHEMA(('IFC4'));
3004ENDSEC;
3005DATA;
3006#1=IFCPROJECT('0project00000000000001',$,'P',$,$,$,$,(#20),#30);
3007#30=IFCUNITASSIGNMENT((#31));
3008#31=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);
3009#20=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-5,#21,$);
3010#21=IFCAXIS2PLACEMENT3D(#22,$,$);
3011#22=IFCCARTESIANPOINT((0.,0.,0.));
3012#40=IFCSITE('0site000000000000000001',$,'S',$,$,#41,$,$,.ELEMENT.,$,$,$,$,$);
3013#41=IFCLOCALPLACEMENT($,#42);
3014#42=IFCAXIS2PLACEMENT3D(#43,#44,#45);
3015#43=IFCCARTESIANPOINT((1000.,2000.,0.));
3016#44=IFCDIRECTION((0.,0.,1.));
3017#45=IFCDIRECTION((0.,1.,0.));
3018#50=IFCWALL('0wall000000000000000001',$,'W',$,$,#51,#60,$);
3019#51=IFCLOCALPLACEMENT(#41,#52);
3020#52=IFCAXIS2PLACEMENT3D(#53,$,$);
3021#53=IFCCARTESIANPOINT((0.,0.,0.));
3022#60=IFCPRODUCTDEFINITIONSHAPE($,$,(#61));
3023#61=IFCSHAPEREPRESENTATION(#20,'Body','SweptSolid',(#62));
3024#62=IFCEXTRUDEDAREASOLID(#63,#66,#69,3.);
3025#63=IFCRECTANGLEPROFILEDEF(.AREA.,$,#64,4.,0.2);
3026#64=IFCAXIS2PLACEMENT2D(#65,$);
3027#65=IFCCARTESIANPOINT((2.,0.1));
3028#66=IFCAXIS2PLACEMENT3D(#67,$,$);
3029#67=IFCCARTESIANPOINT((0.,0.,0.));
3030#69=IFCDIRECTION((0.,0.,1.));
3031#70=IFCRELAGGREGATES('0agg0000000000000000001',$,$,$,#1,(#40));
3032#71=IFCRELCONTAINEDINSPATIALSTRUCTURE('0con0000000000000000001',$,$,$,(#50),#40);
3033ENDSEC;
3034END-ISO-10303-21;
3035";
3036
3037 /// The scene root's TRS, read back out of a glTF JSON document.
3038 fn root_trs(json: &[u8]) -> (Option<Vec<f64>>, Option<Vec<f64>>) {
3039 let v: serde_json::Value = serde_json::from_slice(json).expect("glTF JSON");
3040 let scene = v["scenes"][0]["nodes"][0].as_u64().expect("a scene root") as usize;
3041 let node = &v["nodes"][scene];
3042 let read = |k: &str| {
3043 node.get(k).and_then(|a| a.as_array()).map(|a| {
3044 a.iter()
3045 .map(|x| x.as_f64().expect("number"))
3046 .collect::<Vec<_>>()
3047 })
3048 };
3049 (read("translation"), read("rotation"))
3050 }
3051
3052 fn glb_json(glb: &[u8]) -> Vec<u8> {
3053 let len = u32::from_le_bytes(glb[12..16].try_into().expect("header")) as usize;
3054 glb[20..20 + len].to_vec()
3055 }
3056
3057 /// Every export path has to put the model in the same place.
3058 ///
3059 /// They did not. The plain path was fixed first and the streaming and
3060 /// bounded paths kept emitting the site-local frame, so the same file came
3061 /// out kilometres apart depending only on whether it was large enough to
3062 /// stream — which is the worst of the three possible states, because each
3063 /// path looked self-consistent.
3064 #[test]
3065 fn every_export_path_agrees_where_the_model_is() {
3066 let opts = GltfOptions::default();
3067 let bytes = ROTATED_SITE.as_bytes();
3068
3069 let plain = root_trs(&glb_json(&try_export_glb(bytes, &opts).expect("has geometry")));
3070 let bounded = root_trs(&glb_json(
3071 &try_export_glb_streaming_bounded(bytes, &opts).expect("has geometry").0,
3072 ));
3073 let streaming = root_trs(&export_gltf_streaming(bytes, &opts, usize::MAX, |_| {}));
3074
3075 assert_eq!(plain, bounded, "bounded disagrees with plain");
3076 assert_eq!(plain, streaming, "streaming disagrees with plain");
3077
3078 // And the placement is actually restored rather than all three being
3079 // uniformly wrong, which the equality above would also accept.
3080 let (t, r) = plain;
3081 let t = t.expect("the root carries the site translation");
3082 let r = r.expect("the root carries the site rotation");
3083 // Site at (1000, 2000, 0) in IFC Z-up is (1000, 0, -2000) in Y-up.
3084 assert!((t[0] - 1000.0).abs() < 1.0, "translation {t:?}");
3085 assert!((t[2] + 2000.0).abs() < 3.0, "translation {t:?}");
3086 // 90 degrees about the glTF up-axis.
3087 assert!(r[1].abs() > 0.7 && r[3].abs() > 0.7, "rotation {r:?}");
3088 assert!(r[0].abs() < 1e-6 && r[2].abs() < 1e-6, "rotation {r:?}");
3089 }
3090}