ifc-lite-wasm 4.2.1

WebAssembly bindings for IFC-Lite
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

use super::void_index::reconstruct_void_index;
use crate::api::IfcAPI;
use crate::zero_copy::{MeshCollection, MeshDataJs};
use wasm_bindgen::prelude::*;

/// Per-element output of [`IfcAPI::produce_batch`] — the canonical producer's
/// meshes (with `instance` metadata intact, BEFORE the MeshDataJs Z-up→Y-up
/// swap) plus the element's geometry hash. The flat path converts each to
/// MeshDataJs; the instanced path collates them into an IFNS shard.
struct ElementMeshOutput {
    id: u32,
    meshes: Vec<ifc_lite_processing::MeshData>,
    geometry_hash: Option<u64>,
}

/// Session-constant style lookups shared across batches: colour map plus
/// per-style `GeometryStyleInfo` index (see the #1097 cache note below).
type StyleMaps = std::sync::Arc<(
    rustc_hash::FxHashMap<u32, [f32; 4]>,
    rustc_hash::FxHashMap<u32, ifc_lite_processing::style::GeometryStyleInfo>,
)>;

impl IfcAPI {
    /// Shared core for both batch outputs: run the canonical per-element
    /// producer over `jobs_flat` (setup + loop + CSG/layer diagnostics),
    /// returning each element's meshes (instance metadata intact) + geometry
    /// hash. `process_geometry_batch` (→ MeshCollection, flat) and
    /// `process_geometry_batch_instanced` (→ IFNS shard) both call this so the
    /// hot path is written once. The web path stays serial (no rayon in wasm);
    /// the entity-index Arc, warm router, and per-worker style/void/material
    /// caches are reused exactly as before.
    #[allow(clippy::too_many_arguments)]
    fn produce_batch(
        &self,
        data: &[u8],
        jobs_flat: &[u32],
        unit_scale: f64,
        rtc_x: f64,
        rtc_y: f64,
        rtc_z: f64,
        needs_shift: bool,
        void_keys: &[u32],
        void_counts: &[u32],
        void_values: &[u32],
        style_ids: &[u32],   // geometry style entity IDs
        style_colors: &[u8], // [r, g, b, a, r, g, b, a, ...] (0-255)
        // Trailing optional wire fields (additive — older callers omit them):
        // the prepass-resolved plane-angle scale (falls back to the per-worker
        // cache when absent), and the #407 per-element material colour lists
        // in `flat_material_colors` encoding.
        plane_angle_to_radians: Option<f64>,
        material_element_ids: Option<Vec<u32>>,
        material_color_counts: Option<Vec<u32>>,
        material_colors_rgba: Option<Vec<u8>>,
        // #1623 Phase 3: when `true` AND a mapped-instance plan is installed AND the
        // model is unshifted AND geometry hashing is off, arm the batch router in
        // BATCH-LOCAL don't-bake mode so a repeated single-solid mapped source
        // materializes once per batch and the rest ride the IFNS shard. Only the
        // partitioned entry point (which holds a flat MeshCollection for the
        // sub-threshold recovery) passes `true`; the flat / instanced-only entry
        // points pass `false`, keeping their output byte-identical.
        arm_instancing: bool,
    ) -> (
        Vec<ElementMeshOutput>,
        ifc_lite_geometry::GeometryDiagnostics,
        Vec<super::instancing::ShardOccurrence>,
    ) {
        use crate::api::styling::resolve_element_color;
        use ifc_lite_core::EntityDecoder;
        use ifc_lite_geometry::GeometryRouter;
        use ifc_lite_processing::element::{
            plan_type_geometry, produce_element_meshes, ElementJobKind, ElementMeshJob,
            GeometryHashConfig, MeshProductionContext, MeshProductionOptions, TypeGeometryMode,
        };
        use ifc_lite_processing::style::GeometryStyleInfo;

        // Batch wall-clock for the PipelineDiagnostics channel. std::time::Instant
        // traps on wasm32, so use the JS clock (two Date.now() reads per batch —
        // negligible, always on).
        let batch_started_ms = js_sys::Date::now();

        let content = data;

        // Geometry fingerprinting for the viewer's revision-diff feature.
        // When enabled we hash each entity's meshes *before* MeshDataJs::new
        // applies the Z-up→Y-up swap, in the native IFC frame, reconstructing
        // world coordinates as `local + rtc` so the file's RTC choice never
        // registers as a change. Disabled (None) => zero overhead.
        let hash_tolerance = self.geometry_hash_tolerance();
        let hash_world_rtc: [f64; 3] = if needs_shift {
            [rtc_x, rtc_y, rtc_z]
        } else {
            [0.0, 0.0, 0.0]
        };

        // Reuse the cached Arc<EntityIndex> across calls so we don't
        // re-clone the 14 M-entry HashMap on every batch. On streaming
        // paths this turns ~36 calls/worker into 1 build + 35 Arc::clone()
        // (a single refcount bump) instead of 36 full HashMap clones.
        //
        // If the cache is empty (which happens on every process worker
        // because they're separate WASM realms from the pre-pass worker),
        // build once here and store under Arc so subsequent calls hit
        // the fast path.
        let entity_index_arc: std::sync::Arc<ifc_lite_core::ColumnarEntityIndex> = {
            // Mutex briefly held: peek at cache, build-if-empty, clone Arc.
            // The clone is what gets handed to rayon — no lock contention
            // on the per-job hot path that follows. Poison panics here
            // (an earlier panic-with-lock-held has corrupted the cache).
            let mut slot = self
                .cached_entity_index
                .lock()
                .unwrap_or_else(std::sync::PoisonError::into_inner);
            if let Some(existing) = slot.as_ref() {
                std::sync::Arc::clone(existing)
            } else {
                // No setEntityIndex was delivered (non-streaming path): scan the
                // file straight into sorted columns, skipping the FxHashMap.
                let built =
                    std::sync::Arc::new(ifc_lite_core::ColumnarEntityIndex::from_scan(content));
                *slot = Some(std::sync::Arc::clone(&built));
                built
            }
        };
        let mut decoder = EntityDecoder::with_arc_columnar_index(content, entity_index_arc);
        // Seed the unit-scale caches so curve/arc tessellation never re-pays the
        // O(file) IFCPROJECT scan: this decoder is fresh on every batch call,
        // and `plane_angle_to_radians()` would otherwise walk the whole DATA
        // section per batch on files whose IFCPROJECT sits near the end
        // (IfcOpenShell exports) — the geometry-stream stall on large models.
        let plane_angle_to_radians = plane_angle_to_radians
            .unwrap_or_else(|| self.get_or_resolve_plane_angle(&mut decoder));
        decoder.seed_unit_scales(unit_scale, plane_angle_to_radians);

        // Create geometry router with unit scale and the consumer-selected
        // tessellation quality (issue #976) — Medium unless JS called
        // `setTessellationQuality`, so default output is byte-for-byte
        // identical to the pre-quality pipeline.
        let mut router =
            GeometryRouter::with_scale_and_quality(unit_scale, self.tessellation_quality());

        // Apply the consumer-selected small-cut skip (#1286) for this batch.
        // Independent of the tessellation tier so the viewer can skip tiny steel
        // cuts while keeping full-density curves. Scoped to this router (not a
        // process-wide flag), so an export's router, which never enables it,
        // keeps every cut regardless of a concurrent display build.
        router.set_skip_small_cuts(self.skip_small_cuts());

        // Arm content-dedup against the per-worker shared cache so byte-identical
        // geometry (e.g. Tekla parts the exporter failed to share via
        // IfcMappedItem) is meshed ONCE across batches, not once per batch.
        {
            let mut slot = self
                .cached_item_dedup
                .lock()
                .unwrap_or_else(std::sync::PoisonError::into_inner);
            let cache = slot
                .get_or_insert_with(GeometryRouter::new_dedup_cache)
                .clone();
            router.enable_content_dedup_shared(cache);
        }

        // Arm the shared IfcMappedItem source cache (#1623) against the per-worker
        // cache so a RepresentationMap source shared across owning elements is
        // meshed ONCE across batches, not once per batch. Held on the IfcApi like
        // the item-dedup cache above (one per worker session). Keep a clone for the
        // Phase 3 don't-bake finalize below (sub-threshold occurrences recover flat
        // from this registry).
        let mapped_item_cache = {
            let mut slot = self
                .cached_mapped_item
                .lock()
                .unwrap_or_else(std::sync::PoisonError::into_inner);
            let cache = slot
                .get_or_insert_with(GeometryRouter::new_mapped_item_cache)
                .clone();
            router.enable_shared_mapped_item_cache(cache.clone());
            cache
        };

        // #1623 Phase 3 "don't-bake": arm the batch router in BATCH-LOCAL mode when
        // the partitioned path asked for it AND a plan is installed. Gated on
        // `!needs_shift` (unshifted models only): the browser shard math is
        // coordinate-space-agnostic (collate reduces to the post-RTC frame and the
        // renderer applies any site rotation uniformly), but georef/site-local
        // byte-identity needs a headed-browser check, so — mirroring the native
        // `coord_space != site_local` guard — georef routes to flat here for now
        // (loosen this predicate once the coord space is verified in-browser). Also
        // gated on hashing being OFF: the #924 per-element geometry-diff fingerprint
        // is computed during the per-occurrence materialize, which the don't-bake
        // path skips — so when the diff feature needs those hashes, every occurrence
        // materializes exactly as before. `None` ⇒ router unarmed ⇒ every occurrence
        // materializes (byte-identical to the pre-Phase-3 partitioned path).
        let instancing_armed = arm_instancing && !needs_shift && hash_tolerance.is_none();
        let instance_plan = if instancing_armed {
            self.mapped_instance_plan()
        } else {
            None
        };
        if let Some(plan) = instance_plan.as_ref() {
            router.enable_output_instancing(plan.clone());
            router.set_instancing_batch_local(true);
        }

        // Attach the per-content material-layer index so single-solid walls and
        // slabs carrying an IfcMaterialLayerSetUsage slice into one coloured
        // sub-mesh per layer (#563). Built once per load and Arc-shared across
        // batches; #874 dropped this wiring, silently disabling layered-wall
        // rendering for the entire browser stream. Cheap on files with no layer
        // set (substring bail-out inside the index builder).
        //
        // "Merge Multilayer Walls" (the merge_layers toggle, #540) means exactly
        // "render walls as ONE solid": NOT attaching the index leaves each wall as
        // its single swept solid (no per-layer slice). So gate the index on the
        // flag — off (default) ⇒ slice into layers; on ⇒ one solid. The separate
        // part-skip path below keeps its own index, so IfcBuildingElementPart
        // merging is unaffected.
        if !self.merge_layers() {
            router.set_material_layer_index(self.get_or_build_material_layer_index(content, &mut decoder));
        }

        // Set RTC offset if needed
        if needs_shift {
            router.set_rtc_offset((rtc_x, rtc_y, rtc_z));
        }

        // Reconstruct void_index from the flat wire arrays. Length-guarded so
        // upstream drift drops the index instead of trapping the whole wasm
        // instance under panic=abort (see reconstruct_void_index).
        let void_index = reconstruct_void_index(void_keys, void_counts, void_values);

        // #1097: the wire styles are session-constant, so build the colour map
        // AND the GeometryStyleInfo index the producer consumes ONCE per worker
        // and reuse across batches (was ~18 M HashMap inserts each on a 140 K-
        // styled model). Keyed by a cheap (len, first_id, last_id) signature.
        let style_maps: StyleMaps = {
            let sig_len = style_ids.len();
            let sig_first = style_ids.first().copied().unwrap_or(0);
            let sig_last = style_ids.last().copied().unwrap_or(0);
            let mut slot = self
                .cached_geometry_styles
                .lock()
                .unwrap_or_else(std::sync::PoisonError::into_inner);
            match slot.as_ref() {
                Some((l, f, la, arc)) if *l == sig_len && *f == sig_first && *la == sig_last => {
                    std::sync::Arc::clone(arc)
                }
                _ => {
                    let mut colors: rustc_hash::FxHashMap<u32, [f32; 4]> =
                        rustc_hash::FxHashMap::with_capacity_and_hasher(sig_len, Default::default());
                    for (i, &style_id) in style_ids.iter().enumerate() {
                        let base = i * 4;
                        if base + 3 < style_colors.len() {
                            colors.insert(
                                style_id,
                                [
                                    style_colors[base] as f32 / 255.0,
                                    style_colors[base + 1] as f32 / 255.0,
                                    style_colors[base + 2] as f32 / 255.0,
                                    style_colors[base + 3] as f32 / 255.0,
                                ],
                            );
                        }
                    }
                    let index: rustc_hash::FxHashMap<u32, GeometryStyleInfo> = colors
                        .iter()
                        .map(|(&id, &c)| (id, GeometryStyleInfo::from_color(c)))
                        .collect();
                    let arc = std::sync::Arc::new((colors, index));
                    *slot = Some((sig_len, sig_first, sig_last, std::sync::Arc::clone(&arc)));
                    arc
                }
            }
        };
        let geometry_styles = &style_maps.0;
        // #1097: element colours were resolved in a separate pre-pass that
        // re-decoded every job entity (a second full decode + deep-clone pass).
        // That resolution is now folded into the main loop below — each entity
        // is decoded ONCE (as an Arc, no deep clone), so we no longer build an
        // `element_styles` map up front.

        // Pre-allocate
        let num_jobs = jobs_flat.len() / 3;
        decoder.reserve_cache(num_jobs * 2);
        let mut outputs: Vec<ElementMeshOutput> = Vec::with_capacity(num_jobs);

        // When merge-layers is on, fetch (or lazily build) the set of
        // IfcBuildingElementPart express IDs to skip. Built once per worker
        // and reused across every subsequent batch on the same content via
        // the cached_parts_to_skip slot on IfcAPI.
        let parts_to_skip: std::sync::Arc<rustc_hash::FxHashSet<u32>> = if self.merge_layers() {
            self.get_or_build_parts_to_skip(content, &mut decoder)
        } else {
            std::sync::Arc::new(rustc_hash::FxHashSet::default())
        };

        // IfcIndexedColourMap index (geometry id → full per-triangle palette),
        // built once per worker (#858) — the canonical producer splits face
        // sets per palette group so multi-coloured triangles don't collapse to
        // the single dominant colour the prepass `geometry_styles` carries.
        let indexed_colour_full = self.get_or_build_indexed_colour_maps(content, &mut decoder);

        // The canonical styled-item index the shared producer consumes — built
        // once per worker alongside `geometry_styles` above (#1097).
        let geometry_style_index = &style_maps.1;
        // Surface textures + UV maps (#961), built once per worker (cheap
        // substring bail-out for untextured files).
        let texture_index = self.get_or_build_texture_index(content, &mut decoder);
        // #407/#913 §2.3: per-element material colour lists from the prepass
        // wire, so the canonical producer's transparent/opaque sub-mesh
        // alternation fires in the browser exactly like on the server.
        // Absent (older callers) ⇒ empty map ⇒ alternation never fires.
        let element_material_colors: rustc_hash::FxHashMap<u32, Vec<[f32; 4]>> = match (
            material_element_ids.as_deref(),
            material_color_counts.as_deref(),
            material_colors_rgba.as_deref(),
        ) {
            (Some(ids), Some(counts), Some(rgba)) => {
                ifc_lite_processing::prepass::material_colors_from_flat(ids, counts, rgba)
            }
            _ => rustc_hash::FxHashMap::default(),
        };

        let ctx = MeshProductionContext {
            void_index: &void_index,
            geometry_style_index,
            indexed_colour_full: &indexed_colour_full,
            element_material_colors: &element_material_colors,
            texture_index: &texture_index,
            // The browser's axis change (IFC Z-up → WebGL Y-up) happens at the
            // FFI boundary in `MeshDataJs::from_mesh_data`, not here.
            site_local_rotation: None,
        };
        let opts = MeshProductionOptions {
            geometry_hash: hash_tolerance.map(|tolerance| GeometryHashConfig {
                tolerance,
                world_rtc: hash_world_rtc,
            }),
        };

        // CSG diagnostics, aggregated across the batch: the canonical producer
        // drains the (warm, batch-shared) router per element so one element's
        // failures never bleed into the next; we collect them here and hand
        // them to the logger below.
        let mut batch_csg_failures: rustc_hash::FxHashMap<
            u32,
            Vec<ifc_lite_geometry::BoolFailure>,
        > = rustc_hash::FxHashMap::default();

        // PipelineDiagnostics tallies for this batch (elements actually run
        // through the producer, degenerate-backstop drops).
        let mut batch_elements: u64 = 0;
        let mut batch_backstop: u64 = 0;

        // #1623 Phase 3: don't-bake occurrences collected across the batch's
        // elements (empty when the router is unarmed). Resolved after the loop into
        // shard instances (kept) + recovered flat meshes (sub-threshold).
        let mut all_occurrences: Vec<ifc_lite_processing::RawInstanceOccurrence> = Vec::new();

        // Process only the entities specified in jobs_flat — every job runs
        // THE canonical per-element producer (`ifc_lite_processing::element`),
        // the same code the native pipeline runs.
        for chunk in jobs_flat.chunks(3) {
            if chunk.len() < 3 {
                break;
            }
            let id = chunk[0];
            let start = chunk[1] as usize;
            let end = chunk[2] as usize;

            if parts_to_skip.contains(&id) {
                continue;
            }

            // #1097: decode_and_cache returns the cached Arc (cheap Arc::clone),
            // not a deep clone of the DecodedEntity — was the dominant per-job
            // marshalling cost across ~60-110 K jobs. produce_element_meshes
            // takes `&DecodedEntity`, so we deref the Arc at the call site.
            let Ok(entity) = decoder.decode_and_cache(id, start, end) else {
                continue;
            };
            let ifc_type = entity.ifc_type;

            // Resolve the element-level colour inline (folded from the deleted
            // pre-pass) so the entity is decoded exactly once.
            let element_color = if !geometry_styles.is_empty()
                && entity.get(6).map(|a| !a.is_null()).unwrap_or(false)
            {
                resolve_element_color(entity.as_ref(), geometry_styles, &mut decoder)
            } else {
                None
            };

            // #957: type products render their planned RepresentationMaps. The
            // viewer emits BOTH orphan (class 1) and instanced (class 2) maps —
            // `EmitTagged` — so the Model/Types switch can filter at render
            // time; the native pipeline plans the same jobs with
            // `SuppressInstanced` (an export must not duplicate geometry).
            let kind = if ifc_type.is_subtype_of(ifc_lite_core::IfcType::IfcTypeProduct) {
                let rep_map_ids: Vec<u32> = entity
                    .get(6)
                    .and_then(|a| a.as_list())
                    .map(|list| list.iter().filter_map(|v| v.as_entity_ref()).collect())
                    .unwrap_or_default();
                if rep_map_ids.is_empty() {
                    continue;
                }
                let referenced = self.get_or_build_referenced_repmaps(content, &mut decoder);
                let instantiated = self.get_or_build_instantiated_type_ids(content, &mut decoder);
                let rep_maps = plan_type_geometry(
                    &rep_map_ids,
                    &referenced,
                    instantiated.contains(&id),
                    TypeGeometryMode::EmitTagged,
                );
                if rep_maps.is_empty() {
                    continue;
                }
                ElementJobKind::TypeProduct { rep_maps }
            } else {
                ElementJobKind::Product
            };

            let produced = produce_element_meshes(
                &ElementMeshJob {
                    id,
                    ifc_type,
                    entity: entity.as_ref(),
                    kind,
                    element_color,
                    // The viewer gets element metadata from the parser worker.
                    metadata: None,
                },
                &ctx,
                &opts,
                &mut decoder,
                &router,
            );

            for (product_id, fails) in produced.csg_failures {
                batch_csg_failures.entry(product_id).or_default().extend(fails);
            }
            batch_elements += 1;
            batch_backstop += produced.degenerate_triangles_dropped;
            if !produced.instance_occurrences.is_empty() {
                all_occurrences.extend(produced.instance_occurrences);
            }
            outputs.push(ElementMeshOutput {
                id,
                meshes: produced.meshes,
                geometry_hash: produced.geometry_hash,
            });
        }

        // Surface the opening / CSG diagnostics. The viewer's large-file path
        // goes processAdaptive -> processParallel -> Web Workers ->
        // `processGeometryBatch`, so the log has to fire here or the
        // diagnostic helper never runs for real-world files.
        let csg_diag = crate::api::drain_and_log_csg_diagnostics(&router, batch_csg_failures);

        // Layered-wall slicing diagnostics (#563): a quiet success summary, but a
        // per-element warning (id + reason) when a sliceable wall fails to slice
        // — so future regressions surface without spamming healthy loads. Reasons:
        // not-single-unshifted-item / thin-layers-collapsed-to-1 /
        // placement-unresolved / cut-produced-<2 / base-mesh-error.
        let layer_diag = router.take_layer_slice_diag();
        if !layer_diag.is_empty() {
            let sliced = layer_diag.iter().filter(|(_, r)| r.starts_with("ok:")).count();
            let not_sliced = layer_diag.len() - sliced;
            if not_sliced == 0 {
                web_sys::console::info_1(
                    &format!("[ifc-lite layers] batch: sliced {} wall(s) into layers", sliced)
                        .into(),
                );
            } else {
                let detail: Vec<String> = layer_diag
                    .iter()
                    .filter(|(_, r)| !r.starts_with("ok:"))
                    .map(|(id, r)| format!("#{}={}", id, r))
                    .collect();
                web_sys::console::warn_1(
                    &format!(
                        "[ifc-lite layers] batch: sliced {}, {} NOT sliced — {}",
                        sliced,
                        not_sliced,
                        detail.join(", ")
                    )
                    .into(),
                );
            }
        }

        // #1623 Phase 3: resolve the batch's don't-bake occurrences into shard
        // instances (kept) + recovered flat meshes (sub-threshold / ineligible
        // template). Build the per-rep template facts from the retained instanceable
        // meshes (batch-local mode materialized one template per source), then split.
        // Empty (unarmed / no occurrences) ⇒ nothing changes, byte-identical output.
        let shard_occurrences = if all_occurrences.is_empty() {
            Vec::new()
        } else {
            let mut template_by_rep: rustc_hash::FxHashMap<u128, super::instancing::TemplateInfo> =
                rustc_hash::FxHashMap::default();
            for out in &outputs {
                for m in &out.meshes {
                    if m.positions.is_empty() {
                        continue;
                    }
                    if let Some(im) = m.instance.as_ref() {
                        if im.instanceable {
                            // Shard-eligible = the partition's candidate gate: opaque,
                            // untextured, ordinary-occurrence (class 0) geometry.
                            let eligible = m.color[3] >= INSTANCED_ALPHA_CUTOFF
                                && m.texture.is_none()
                                && m.geometry_class == 0;
                            template_by_rep
                                .entry(im.rep_identity)
                                .or_insert(super::instancing::TemplateInfo { eligible });
                        }
                    }
                }
            }
            let rtc = if needs_shift {
                [rtc_x, rtc_y, rtc_z]
            } else {
                [0.0, 0.0, 0.0]
            };
            let mut recovered_flats: Vec<ifc_lite_processing::MeshData> = Vec::new();
            let shard = super::instancing::resolve_batch_occurrences(
                std::mem::take(&mut all_occurrences),
                &template_by_rep,
                &mapped_item_cache,
                rtc,
                INSTANCE_MIN_OCCURRENCES as usize,
                &mut recovered_flats,
            );
            // Recovered occurrences ride as their own single-mesh outputs; the
            // partition routes them to the flat MeshCollection (instance meta is None
            // ⇒ never instanced), byte-identical to the flat baseline for that element.
            for m in recovered_flats {
                let id = m.express_id;
                outputs.push(ElementMeshOutput {
                    id,
                    meshes: vec![m],
                    geometry_hash: None,
                });
            }
            shard
        };

        // Fold this batch into the per-worker PipelineDiagnostics accumulator
        // (read by JS via getPipelineDiagnostics). Counts + two Date.now()
        // reads — cheap enough to stay on the normal load path unconditionally.
        let batch_meshes: u64 = outputs.iter().map(|o| o.meshes.len() as u64).sum();
        let batch_triangles: u64 = outputs
            .iter()
            .flat_map(|o| o.meshes.iter())
            .map(|m| (m.indices.len() / 3) as u64)
            .sum();
        let batch_ms = (js_sys::Date::now() - batch_started_ms).max(0.0) as u64;
        // The batch reuses ONE decoder across every element (the point cache is
        // already hoisted here), so its cumulative point-cache stats ARE this
        // batch's faceted-brep memoization tally.
        let (point_cache_hits, point_cache_misses) = decoder.point_cache_stats();
        self.record_pipeline_batch(
            batch_elements,
            batch_meshes,
            batch_triangles,
            batch_backstop,
            point_cache_hits,
            point_cache_misses,
            batch_ms,
            &csg_diag,
        );

        (outputs, csg_diag, shard_occurrences)
    }
}

#[wasm_bindgen]
impl IfcAPI {
    /// Process geometry for a subset of pre-scanned entities → flat
    /// MeshCollection. Takes raw bytes + pre-pass data from buildPrePassOnce.
    /// Thin wrapper over [`IfcAPI::produce_batch`]; converts each produced mesh
    /// to MeshDataJs (the IFC Z-up→WebGL Y-up swap + winding reversal happen
    /// there). Output is byte-for-byte what the pre-refactor method produced.
    #[wasm_bindgen(js_name = processGeometryBatch)]
    #[allow(clippy::too_many_arguments)]
    pub fn process_geometry_batch(
        &self,
        data: &[u8],
        jobs_flat: &[u32],
        unit_scale: f64,
        rtc_x: f64,
        rtc_y: f64,
        rtc_z: f64,
        needs_shift: bool,
        void_keys: &[u32],
        void_counts: &[u32],
        void_values: &[u32],
        style_ids: &[u32],
        style_colors: &[u8],
        plane_angle_to_radians: Option<f64>,
        material_element_ids: Option<Vec<u32>>,
        material_color_counts: Option<Vec<u32>>,
        material_colors_rgba: Option<Vec<u8>>,
    ) -> MeshCollection {
        let num_jobs = jobs_flat.len() / 3;
        // arm_instancing = false: the flat path has no IFNS shard to hold don't-bake
        // occurrences, so every occurrence must materialize (byte-identical output).
        let (outputs, csg_diag, _shard_occurrences) = self.produce_batch(
            data, jobs_flat, unit_scale, rtc_x, rtc_y, rtc_z, needs_shift, void_keys,
            void_counts, void_values, style_ids, style_colors, plane_angle_to_radians,
            material_element_ids, material_color_counts, material_colors_rgba, false,
        );
        let mut mesh_collection = MeshCollection::with_capacity(num_jobs);
        if needs_shift {
            mesh_collection.set_rtc_offset(rtc_x, rtc_y, rtc_z);
        }
        for out in outputs {
            for mesh_data in out.meshes {
                mesh_collection.add(MeshDataJs::from_mesh_data(mesh_data));
            }
            if let Some(hash) = out.geometry_hash {
                mesh_collection.push_geometry_hash(out.id, hash);
            }
        }
        mesh_collection.set_diagnostics(csg_diag);
        mesh_collection
    }

    /// Like [`IfcAPI::process_geometry_batch`] but collates the batch's meshes
    /// into a GPU-instancing shard (IFNS wire format) instead of a flat
    /// MeshCollection. Repeated geometry collapses to one template + per-
    /// occurrence transforms; non-instanceable meshes ride as flat singleton
    /// templates so nothing is dropped. The shard stays in the producer-native
    /// (IFC Z-up) frame — the renderer composes the constant Z-up→Y-up swap at
    /// upload. Each batch shard renders independently: affinity routing already
    /// co-locates identical geometry on one worker, so per-batch collation
    /// captures ~all the dedup and no cross-batch merge is needed. Returns empty
    /// bytes only when the batch produced zero non-empty meshes.
    #[wasm_bindgen(js_name = processGeometryBatchInstanced)]
    #[allow(clippy::too_many_arguments)]
    pub fn process_geometry_batch_instanced(
        &self,
        data: &[u8],
        jobs_flat: &[u32],
        unit_scale: f64,
        rtc_x: f64,
        rtc_y: f64,
        rtc_z: f64,
        needs_shift: bool,
        void_keys: &[u32],
        void_counts: &[u32],
        void_values: &[u32],
        style_ids: &[u32],
        style_colors: &[u8],
        plane_angle_to_radians: Option<f64>,
        material_element_ids: Option<Vec<u32>>,
        material_color_counts: Option<Vec<u32>>,
        material_colors_rgba: Option<Vec<u8>>,
    ) -> Vec<u8> {
        // NB: this instanced-only export returns raw shard bytes with no
        // MeshCollection carrier, so CSG diagnostics are intentionally dropped. It
        // is not the worker's default path (partitioned/flat carry the counts).
        // arm_instancing = false: with no flat carrier there is nowhere to route the
        // don't-bake sub-threshold recovery, so every occurrence materializes here
        // and this export stays byte-identical (it collates the baked meshes as
        // before). Only `process_geometry_batch_partitioned` arms the don't-bake path.
        let (outputs, _, _shard_occurrences) = self.produce_batch(
            data, jobs_flat, unit_scale, rtc_x, rtc_y, rtc_z, needs_shift, void_keys,
            void_counts, void_values, style_ids, style_colors, plane_angle_to_radians,
            material_element_ids, material_color_counts, material_colors_rgba, false,
        );
        let meshes: Vec<ifc_lite_processing::MeshData> =
            outputs.into_iter().flat_map(|o| o.meshes).collect();
        // `refs` borrows the geometry in `meshes`; both live to the end of this
        // method and collate_and_encode consumes them synchronously below.
        //
        // ONLY ordinary occurrences (geometry_class == 0) are instanced. Type-
        // product geometry — orphan type maps (class 1) and instanced type maps
        // (class 2) — is left to the flat path, which the viewer's Model/Types
        // view-mode filter gates (ViewportContainer drops class 2 in Model mode,
        // class 0 in Types mode). The instanced path has no view-mode filter, so
        // including class 1/2 here would render type geometry unconditionally
        // (the opaque type-template shapes drawing over the real occurrences —
        // the "blue windows/roof" + type geometry showing in Model mode).
        let refs: Vec<ifc_lite_geometry::InstanceMeshRef> = meshes
            .iter()
            .filter(|m| m.geometry_class == 0)
            .map(|m| ifc_lite_geometry::InstanceMeshRef {
                positions: &m.positions,
                normals: &m.normals,
                indices: &m.indices,
                origin: m.origin,
                instance_meta: m.instance.as_ref(),
                entity_id: m.express_id,
                color: m.color,
            })
            .collect();
        // min_group = 2: instance any repeat; singletons + non-instanceable flat.
        // Pass the applied RTC so per-occurrence transforms are reduced to the
        // post-RTC frame (matches the small baked origins; without it a rotated
        // occurrence lands at 2× the georef offset and collapses GLB exports).
        let rtc = if needs_shift { [rtc_x, rtc_y, rtc_z] } else { [0.0, 0.0, 0.0] };
        ifc_lite_geometry::collate_and_encode(&refs, 2, rtc)
    }

    /// Produce a batch ONCE and PARTITION it (the instanced-ONLY path): opaque
    /// ordinary occurrences (colour alpha >= 0.99 AND geometry_class == 0) are
    /// collated into the instanced shard; everything else (transparent glass,
    /// type-product geometry) goes to the flat MeshCollection. Each mesh takes
    /// exactly ONE route, so produce_batch runs once (no emit-both 2× meshing)
    /// and the renderer draws opaque occurrences via instancing instead of flat.
    /// Partition mirrors the renderer gates: INSTANCED_ALPHA_CUTOFF (0.99 =
    /// OPAQUE_ALPHA_CUTOFF) for transparency, geometry_class for the Model/Types
    /// split.
    ///
    /// NOTE: the renderer must be instanced-feature-complete (picking / selection
    /// / lens overlays on instanced geometry) before the worker calls this in
    /// place of processGeometryBatch — otherwise those features break for the
    /// opaque bulk. See the instanced-only follow-ups.
    #[wasm_bindgen(js_name = processGeometryBatchPartitioned)]
    #[allow(clippy::too_many_arguments)]
    pub fn process_geometry_batch_partitioned(
        &self,
        data: &[u8],
        jobs_flat: &[u32],
        unit_scale: f64,
        rtc_x: f64,
        rtc_y: f64,
        rtc_z: f64,
        needs_shift: bool,
        void_keys: &[u32],
        void_counts: &[u32],
        void_values: &[u32],
        style_ids: &[u32],
        style_colors: &[u8],
        plane_angle_to_radians: Option<f64>,
        material_element_ids: Option<Vec<u32>>,
        material_color_counts: Option<Vec<u32>>,
        material_colors_rgba: Option<Vec<u8>>,
    ) -> PartitionedBatch {
        let num_jobs = jobs_flat.len() / 3;
        // arm_instancing = true (#1623 Phase 3): the partitioned path holds a flat
        // MeshCollection, so it CAN route the don't-bake sub-threshold recovery to
        // flat — the only entry point that arms it. `shard_occurrences` are the kept
        // don't-bake occurrences (pose-only, no baked vertices); their template
        // materialized once in `outputs`, and any recovered flats are already
        // appended to `outputs`.
        let (outputs, csg_diag, shard_occurrences) = self.produce_batch(
            data, jobs_flat, unit_scale, rtc_x, rtc_y, rtc_z, needs_shift, void_keys,
            void_counts, void_values, style_ids, style_colors, plane_angle_to_radians,
            material_element_ids, material_color_counts, material_colors_rgba, true,
        );
        let mut mesh_collection = MeshCollection::with_capacity(num_jobs);
        if needs_shift {
            mesh_collection.set_rtc_offset(rtc_x, rtc_y, rtc_z);
        }
        // Route opaque + untextured + class-0 occurrences by per-batch REPETITION.
        // Instancing trades 1 consolidated, frustum-culled flat draw for 1 drawIndexed
        // per template. That only pays off when geometry repeats enough that the saved
        // upload/memory is real and the per-template draw is amortized over many
        // instances. Singleton / low-count geometry encoded as 1-instance templates was
        // the orbit-FPS regression: it replaced the flat path's ~3-15 consolidated draws
        // with O(unique-geometry) per-frame draws (e.g. an 8 MB-geom architectural model
        // where memory was never the constraint). So: only rep_identity groups occurring
        // >= INSTANCE_MIN_OCCURRENCES times in this batch go to the instanced shard;
        // everything else (singletons, low-count, non-instanceable, no-meta) joins the
        // flat MeshCollection and is consolidated + culled exactly as before the flip.
        //
        // Transparent (alpha < cutoff), textured (no UV slot in the instanced pipeline),
        // and type-product (class 1/2) geometry are never instancing candidates — they
        // must stay on the flat pipelines for correct blending / texturing / view-mode
        // gating.
        let mut candidates: Vec<ifc_lite_processing::MeshData> = Vec::new();
        let mut counts: rustc_hash::FxHashMap<u128, u32> = rustc_hash::FxHashMap::default();
        for out in outputs {
            for mesh_data in out.meshes {
                let opaque = mesh_data.color[3] >= INSTANCED_ALPHA_CUTOFF;
                let untextured = mesh_data.texture.is_none();
                if opaque && untextured && mesh_data.geometry_class == 0 {
                    // Count only instanceable metas — mirror collate_refs's match arm:
                    // a None meta or instanceable==false (void-cut walls, multi-item
                    // merges) can never instance, so it must not inflate a count.
                    if let Some(im) = mesh_data.instance.as_ref() {
                        if im.instanceable {
                            *counts.entry(im.rep_identity).or_insert(0) += 1;
                        }
                    }
                    candidates.push(mesh_data);
                } else {
                    mesh_collection.add(MeshDataJs::from_mesh_data(mesh_data));
                }
            }
            // The element-level geometry-diff hash is path-independent metadata;
            // keep it on the collection regardless of which path the meshes took.
            if let Some(hash) = out.geometry_hash {
                mesh_collection.push_geometry_hash(out.id, hash);
            }
        }
        // #1623 Phase 3: fold the kept don't-bake occurrence counts into the per-rep
        // tally so a batch-local template (materialized ONCE, so count 1 among the
        // candidates) plus its N shard occurrences clears INSTANCE_MIN_OCCURRENCES
        // and routes to the instanced shard (the finalize already applied that gate,
        // so this only confirms it). The template's geometry rides the shard once;
        // the occurrences ride as pose-only instances against it.
        for occ in &shard_occurrences {
            *counts.entry(occ.rep_identity).or_insert(0) += 1;
        }
        let mut instanced: Vec<ifc_lite_processing::MeshData> = Vec::new();
        for mesh_data in candidates {
            let instance_it = mesh_data.instance.as_ref().is_some_and(|im| {
                im.instanceable
                    && counts.get(&im.rep_identity).copied().unwrap_or(0)
                        >= INSTANCE_MIN_OCCURRENCES
            });
            if instance_it {
                instanced.push(mesh_data);
            } else {
                mesh_collection.add(MeshDataJs::from_mesh_data(mesh_data));
            }
        }
        // Each materialized instanced mesh is one shard instance; each kept don't-bake
        // occurrence adds one more. Report the sum so the viewer's mesh total reflects
        // ALL rendered geometry (flat + instanced), not just the flat MeshCollection.
        let instanced_occurrences = instanced.len() + shard_occurrences.len();
        // #1623 Phase 3: back the kept don't-bake occurrences with InstanceMeta storage
        // that outlives `refs` — `collate_refs` reads each placeholder's PRE-RTC world
        // transform (local/canonical identity) to derive its `rel_k` against the
        // batch-local template, exactly as a materialized occurrence would. Building
        // these EMPTY-geometry refs is the whole Phase 3 win: the occurrence vertices
        // were never materialized.
        let occ_metas: Vec<ifc_lite_geometry::InstanceMeta> = shard_occurrences
            .iter()
            .map(|o| ifc_lite_geometry::InstanceMeta {
                transform: o.world_transform,
                local_transform: None,
                canonical_transform: None,
                rep_identity: o.rep_identity,
                instanceable: true,
            })
            .collect();
        let mut refs: Vec<ifc_lite_geometry::InstanceMeshRef> = instanced
            .iter()
            .map(|m| ifc_lite_geometry::InstanceMeshRef {
                positions: &m.positions,
                normals: &m.normals,
                indices: &m.indices,
                origin: m.origin,
                instance_meta: m.instance.as_ref(),
                entity_id: m.express_id,
                color: m.color,
            })
            .collect();
        for (o, meta) in shard_occurrences.iter().zip(occ_metas.iter()) {
            refs.push(ifc_lite_geometry::InstanceMeshRef {
                positions: &[],
                normals: &[],
                indices: &[],
                origin: [0.0, 0.0, 0.0],
                instance_meta: Some(meta),
                entity_id: o.entity_id,
                color: o.color,
            });
        }
        // min_group == the routing threshold so collate_refs never re-flattens a group
        // that already passed the count gate; only its own try_inverse / shape-mismatch
        // safety net can still drop a (rare, degenerate) group to a singleton template.
        // Reduce occurrence transforms to the post-RTC frame (see the other call
        // site) so rotated occurrences don't fly out to 2× the georef offset.
        let rtc = if needs_shift { [rtc_x, rtc_y, rtc_z] } else { [0.0, 0.0, 0.0] };
        let shard =
            ifc_lite_geometry::collate_and_encode(&refs, INSTANCE_MIN_OCCURRENCES as usize, rtc);
        mesh_collection.set_diagnostics(csg_diag);
        PartitionedBatch {
            meshes: Some(mesh_collection),
            shard,
            instanced_occurrences,
        }
    }
}

/// Opaque-alpha cutoff for the instanced-only partition. Mirrors the renderer's
/// `OPAQUE_ALPHA_CUTOFF` (overlay-routing.ts) so the wasm partition and the
/// renderer's flat opaque/transparent split agree: alpha >= this is opaque.
const INSTANCED_ALPHA_CUTOFF: f32 = 0.99;

/// Minimum per-batch occurrence count for a rep_identity group to be GPU-instanced.
/// Below this, geometry rides the flat (consolidated, frustum-culled) path instead —
/// one drawIndexed per template only pays off when amortized over many instances, and
/// the saved upload/memory is negligible at low counts. Tuned for the draw-vs-memory
/// tradeoff: 8 kills the singleton/low-count tail that defeated flat consolidation
/// (the orbit-FPS regression) while leaving genuinely-repeated families (mullions,
/// fasteners, identical steel parts — co-located by affinity routing, so dozens-to-
/// hundreds per batch) instanced. Counting is PER-BATCH; a globally-repeated geometry
/// thinly split across batches may fall below the gate and render flat — a benign
/// missed optimization, never a correctness/FPS regression (flat IS the fast path for
/// low counts). Lower to 4 if a large model's memory regresses; raise to 16 if orbit
/// still drags.
const INSTANCE_MIN_OCCURRENCES: u32 = 8;

/// Result of [`IfcAPI::process_geometry_batch_partitioned`]: the flat
/// MeshCollection (transparent + type geometry) and the instanced IFNS shard
/// (opaque ordinary occurrences) from ONE produce_batch. Take-once accessors so
/// the JS side moves each out without a clone.
#[wasm_bindgen]
pub struct PartitionedBatch {
    meshes: Option<MeshCollection>,
    shard: Vec<u8>,
    instanced_occurrences: usize,
}

#[wasm_bindgen]
impl PartitionedBatch {
    /// The flat MeshCollection (transparent glass + type-product geometry).
    /// Moves out — call once.
    #[wasm_bindgen(js_name = takeMeshes)]
    pub fn take_meshes(&mut self) -> Option<MeshCollection> {
        self.meshes.take()
    }

    /// The instanced IFNS shard bytes (opaque ordinary occurrences). Moves out.
    #[wasm_bindgen(js_name = takeShard)]
    pub fn take_shard(&mut self) -> Vec<u8> {
        std::mem::take(&mut self.shard)
    }

    /// Number of occurrences routed into the instanced shard this batch. The viewer
    /// folds this into its total mesh count so the count reflects ALL rendered
    /// geometry (flat + instanced), not just the flat MeshCollection.
    #[wasm_bindgen(getter, js_name = instancedOccurrences)]
    pub fn instanced_occurrences(&self) -> usize {
        self.instanced_occurrences
    }
}