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}