ifc_lite_geometry/instancing/
wire.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5use super::collate::{Collated, InstanceMeshRef};
6use super::group::collate_refs;
7use crate::mesh::Mesh;
8
9// The instanced wire format — layout, the append-only field rule, and why header
10// word 7 carries a STRIDE rather than flags — is documented once, at this
11// module's front door in `instancing/mod.rs`. This file is that document's
12// executable half and the spec the TS decoder
13// (`packages/geometry/src/packed-instanced-decoder.ts`) mirrors.
14
15/// `"IFNS"` little-endian — the instanced-shard magic the TS decoder validates.
16pub const INSTANCED_MAGIC: u32 = 0x4946_4E53;
17/// Instanced format version this encoder writes for a record that CARRIES
18/// trailing field 1 (`item_id`). Decoders accept v1 and any version at or above
19/// 2 that declares a valid stride, so this bumps only when a trailing field is
20/// APPENDED — never to gate a read. Keep in lockstep with the TS decoder.
21pub const INSTANCED_VERSION: u32 = 2;
22/// Version written when the derived stride is the bare 88-byte base record: with
23/// no trailing field such a shard IS a v1 shard byte for byte, header word 7's
24/// literal `0` included, so it stays readable by a pre-#2985 build whose decoder
25/// refuses every version but 1. Belt and braces beside the cache-key bump
26/// (`@ifc-lite/cache` FORMAT_VERSION 15 → 16): bytes travel by other routes.
27const INSTANCED_VERSION_BASE_RECORD: u32 = 1;
28
29/// Instance record bytes BEFORE any trailing field: templateIndex(4) +
30/// entityId(4) + color(16) + transform(64). Also the stride of a v1 shard, and
31/// the floor every declared stride is validated against.
32pub(super) const INSTANCE_RECORD_BASE_BYTES: usize = 88;
33/// Byte offset of trailing field 1, `item_id`, within an instance record.
34pub(super) const INSTANCE_ITEM_ID_OFFSET: usize = INSTANCE_RECORD_BASE_BYTES;
35/// Stride of a record carrying trailing field 1 (`item_id`) and nothing after it.
36pub(super) const INSTANCE_RECORD_ITEM_ID_BYTES: usize = INSTANCE_ITEM_ID_OFFSET + 4;
37/// Version written when a record carries trailing field 2, the finish (#5984).
38pub(super) const INSTANCED_VERSION_FINISH: u32 = 3;
39/// Byte offset of trailing field 2, `[metallic, roughness]` (2× f32, NaN =
40/// unauthored), and the stride of a record carrying it. Field 1 precedes it
41/// on such a record (fields are append-only), so its `item_id` is written too.
42pub(super) const INSTANCE_FINISH_OFFSET: usize = INSTANCE_RECORD_ITEM_ID_BYTES;
43const INSTANCE_RECORD_FINISH_BYTES: usize = INSTANCE_FINISH_OFFSET + 8;
44/// Bytes a template record occupies (6× u32 + 3× f64).
45pub(super) const TEMPLATE_RECORD_BYTES: usize = 48;
46/// Bytes the fixed header occupies (8× u32).
47pub(super) const HEADER_BYTES: usize = 32;
48
49const INST_IDENTITY_F32: [f32; 16] = [
50    1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
51];
52
53/// A unique geometry decoded from an instanced shard.
54#[derive(Debug, Clone)]
55pub struct DecodedTemplate {
56    pub positions: Vec<f32>,
57    pub normals: Vec<f32>,
58    pub indices: Vec<u32>,
59    /// Per-template local origin (f64); world vertex = transform · (origin + position).
60    pub origin: [f64; 3],
61}
62
63/// One occurrence of a decoded template.
64#[derive(Debug, Clone)]
65pub struct DecodedInstance {
66    pub template_index: u32,
67    pub entity_id: u32,
68    pub color: [f32; 4],
69    /// Row-major mat4 mapping the template's world geometry onto this occurrence.
70    pub transform: [f32; 16],
71    /// The `IfcRepresentationItem` this occurrence's geometry was tessellated
72    /// from, so a host can drill from a rendered instanced piece back to the
73    /// entity in the IFC source. `None` when the shard's stride declares no
74    /// trailing item-id field (a v1 shard, or a model whose producer named no
75    /// item at all) and when this record's own id is the `0` sentinel.
76    pub item_id: Option<u32>,
77}
78
79/// A decoded instanced shard.
80#[derive(Debug, Clone, Default)]
81pub struct DecodedInstanced {
82    pub templates: Vec<DecodedTemplate>,
83    pub instances: Vec<DecodedInstance>,
84}
85
86/// Encode a [`Collated`] result + its source mesh views into an instanced shard.
87/// Per-occurrence entity id + colour come from each `InstanceMeshRef`.
88pub fn encode_refs(meshes: &[InstanceMeshRef], collated: &Collated) -> Vec<u8> {
89    encode_refs_with_finishes(meshes, collated, &[])
90}
91
92/// [`encode_refs`] plus each occurrence's IFC-authored finish (#5984):
93/// `finishes[i]` is `meshes[i]`'s `[metallic, roughness]`, NaN where
94/// unauthored, and a missing entry is unauthored. `InstanceMeshRef` is
95/// published with public fields, so the finish travels beside it rather than
96/// in it. Only a shard where some written occurrence authors a finish widens
97/// its records to trailing field 2 (stride 100, version 3); every other shard
98/// is byte-identical to [`encode_refs`]'s, so a model without finishes pays
99/// nothing. Read back with [`super::decode_instance_finishes`].
100pub fn encode_refs_with_finishes(meshes: &[InstanceMeshRef], collated: &Collated, finishes: &[[f32; 2]]) -> Vec<u8> {
101    // (template mesh index, [(occurrence mesh index, rel transform)]).
102    struct TSpec {
103        mesh_idx: usize,
104        instances: Vec<(usize, [f32; 16])>,
105    }
106    let mut tspecs: Vec<TSpec> = Vec::with_capacity(collated.templates.len() + collated.flat_indices.len());
107    for t in &collated.templates {
108        tspecs.push(TSpec {
109            mesh_idx: t.template_index,
110            instances: t.occurrences.iter().map(|o| (o.mesh_index, o.transform)).collect(),
111        });
112    }
113    for &f in &collated.flat_indices {
114        tspecs.push(TSpec {
115            mesh_idx: f,
116            instances: vec![(f, INST_IDENTITY_F32)],
117        });
118    }
119
120    let template_count = tspecs.len();
121    let instance_count: usize = tspecs.iter().map(|t| t.instances.len()).sum();
122    let positions_len: usize = tspecs.iter().map(|t| meshes[t.mesh_idx].positions.len()).sum();
123    let normals_len: usize = tspecs.iter().map(|t| meshes[t.mesh_idx].normals.len()).sum();
124    let indices_len: usize = tspecs.iter().map(|t| meshes[t.mesh_idx].indices.len()).sum();
125
126    // Wire offsets/lengths are u32 (header + template records). A pool exceeding
127    // u32::MAX elements (>16GB of positions in ONE shard) would wrap SILENTLY and
128    // corrupt template lookups. Fail loudly instead — the caller must chunk shards
129    // below this (real instanced shards are <<1GB; this is an impossible-scale
130    // backstop, not a normal limit).
131    assert!(
132        positions_len <= u32::MAX as usize
133            && normals_len <= u32::MAX as usize
134            && indices_len <= u32::MAX as usize
135            && template_count <= u32::MAX as usize
136            && instance_count <= u32::MAX as usize,
137        "instanced shard exceeds u32 wire limits (pos={positions_len} idx={indices_len}); chunk it"
138    );
139
140    // The stride is derived from the DATA, not fixed at compile time. A model
141    // whose producer named no representation item writes 88-byte records instead
142    // of paying 4 bytes of zeros on every occurrence — roughly 800 KB on a 200k-
143    // occurrence model, written, cached to IndexedDB verbatim, and re-read on
144    // every load. It also closes a hole: `InstanceMeshRef::from_mesh` leaves
145    // `item_id: None`, so a caller going through it can no longer produce a
146    // shard that DECLARES the field and fills every record with 0 —
147    // indistinguishable from "this model has no representation items".
148    // Over the occurrence indices the instance loop below actually WALKS, not
149    // over `meshes`: `collate_refs` drops members (an empty non-instanceable
150    // mesh, an all-empty rep group), so a batch whose only id-bearing entry is
151    // a dropped one would declare 92 and write 0 into every record — the exact
152    // hole this predicate exists to close.
153    let finish_of = |i: usize| finishes.get(i).copied().unwrap_or([f32::NAN; 2]);
154    let carries_finish = tspecs
155        .iter()
156        .flat_map(|t| t.instances.iter())
157        .any(|(occ_idx, _)| finish_of(*occ_idx).iter().any(|v| v.is_finite()));
158    // Field 2 sits after field 1, so carrying it carries field 1 as well.
159    let carries_item_id = carries_finish
160        || tspecs
161            .iter()
162            .flat_map(|t| t.instances.iter())
163            .any(|(occ_idx, _)| meshes[*occ_idx].item_id.is_some());
164    // A base-record shard is declared v1, word 7 at the literal `0` v1 wrote
165    // there: byte-identical to a pre-#2985 shard. Only a widened record is v2.
166    let (version, instance_stride, stride_word) = if carries_finish {
167        (INSTANCED_VERSION_FINISH, INSTANCE_RECORD_FINISH_BYTES, INSTANCE_RECORD_FINISH_BYTES as u32)
168    } else if carries_item_id {
169        (INSTANCED_VERSION, INSTANCE_RECORD_ITEM_ID_BYTES, INSTANCE_RECORD_ITEM_ID_BYTES as u32)
170    } else {
171        (INSTANCED_VERSION_BASE_RECORD, INSTANCE_RECORD_BASE_BYTES, 0u32)
172    };
173
174    let mut buf: Vec<u8> = Vec::with_capacity(
175        HEADER_BYTES
176            + template_count * TEMPLATE_RECORD_BYTES
177            + instance_count * instance_stride
178            + (positions_len + normals_len + indices_len) * 4,
179    );
180    let pu32 = |b: &mut Vec<u8>, v: u32| b.extend_from_slice(&v.to_le_bytes());
181    let pf32 = |b: &mut Vec<u8>, v: f32| b.extend_from_slice(&v.to_le_bytes());
182    let pf64 = |b: &mut Vec<u8>, v: f64| b.extend_from_slice(&v.to_le_bytes());
183
184    // Header.
185    pu32(&mut buf, INSTANCED_MAGIC);
186    pu32(&mut buf, version);
187    pu32(&mut buf, template_count as u32);
188    pu32(&mut buf, instance_count as u32);
189    pu32(&mut buf, positions_len as u32);
190    pu32(&mut buf, normals_len as u32);
191    pu32(&mut buf, indices_len as u32);
192    pu32(&mut buf, stride_word);
193
194    // Template table (running element offsets into the pooled data arrays).
195    let (mut pos_off, mut nrm_off, mut idx_off) = (0u32, 0u32, 0u32);
196    for t in &tspecs {
197        let m = &meshes[t.mesh_idx];
198        pu32(&mut buf, pos_off);
199        pu32(&mut buf, m.positions.len() as u32);
200        pu32(&mut buf, nrm_off);
201        pu32(&mut buf, m.normals.len() as u32);
202        pu32(&mut buf, idx_off);
203        pu32(&mut buf, m.indices.len() as u32);
204        pf64(&mut buf, m.origin[0]);
205        pf64(&mut buf, m.origin[1]);
206        pf64(&mut buf, m.origin[2]);
207        pos_off += m.positions.len() as u32;
208        nrm_off += m.normals.len() as u32;
209        idx_off += m.indices.len() as u32;
210    }
211
212    // Instance table.
213    for (ti, t) in tspecs.iter().enumerate() {
214        for (occ_idx, transform) in &t.instances {
215            pu32(&mut buf, ti as u32);
216            pu32(&mut buf, meshes[*occ_idx].entity_id);
217            for c in meshes[*occ_idx].color {
218                pf32(&mut buf, c);
219            }
220            for v in transform {
221                pf32(&mut buf, *v);
222            }
223            // Trailing field 1 (v2): the originating representation item, `0`
224            // where this occurrence has none. The declared stride is what tells
225            // a reader the field is here at all.
226            //
227            // The assert guards the direction that LOSES data: a record holding
228            // an id the header made no room for would be dropped silently. The
229            // other direction (declared ⇒ some record carries one) needs no
230            // assert — `carries_item_id` IS that predicate, over exactly the
231            // occurrences written here.
232            debug_assert!(
233                carries_item_id || meshes[*occ_idx].item_id.is_none(),
234                "instance record carries an item id the declared stride has no room for"
235            );
236            if carries_item_id {
237                pu32(&mut buf, meshes[*occ_idx].item_id.unwrap_or(0));
238            }
239            // Trailing field 2 (v3, #5984): the occurrence's finish.
240            if carries_finish {
241                for v in finish_of(*occ_idx) {
242                    pf32(&mut buf, if v.is_finite() { v } else { f32::NAN });
243                }
244            }
245        }
246    }
247
248    // Data pools.
249    for t in &tspecs {
250        for &p in meshes[t.mesh_idx].positions {
251            pf32(&mut buf, p);
252        }
253    }
254    for t in &tspecs {
255        for &n in meshes[t.mesh_idx].normals {
256            pf32(&mut buf, n);
257        }
258    }
259    for t in &tspecs {
260        for &i in meshes[t.mesh_idx].indices {
261            pu32(&mut buf, i);
262        }
263    }
264    buf
265}
266
267/// `encode_refs` over geometry `Mesh` values, with id/colour accessor closures
268/// (thin wrapper, no geometry clone).
269///
270/// PREFER [`encode_refs`]. This wrapper cannot express the per-occurrence
271/// `item_id` — a `Mesh` does not carry one and there is no closure for it — so
272/// every shard it writes declares the 88-byte stride and no host can drill from
273/// a rendered piece back to its representation item. It is kept because it is
274/// published crate API; nothing in this repo calls it outside the tests.
275pub fn encode_instanced(
276    meshes: &[Mesh],
277    collated: &Collated,
278    entity_id: impl Fn(usize) -> u32,
279    color: impl Fn(usize) -> [f32; 4],
280) -> Vec<u8> {
281    let refs: Vec<InstanceMeshRef> = meshes
282        .iter()
283        .enumerate()
284        .map(|(i, m)| {
285            let mut r = InstanceMeshRef::from_mesh(m);
286            r.entity_id = entity_id(i);
287            r.color = color(i);
288            r
289        })
290        .collect();
291    encode_refs(&refs, collated)
292}
293
294/// One-shot producer: collate the mesh views into templates + instances and
295/// encode them as an instanced shard. The caller (e.g. the native helper) builds
296/// `InstanceMeshRef`s borrowing its own mesh storage — no geometry is cloned.
297pub fn collate_and_encode(meshes: &[InstanceMeshRef], min_group: usize, rtc: [f64; 3]) -> Vec<u8> {
298    let collated = collate_refs(meshes, min_group, rtc);
299    encode_refs(meshes, &collated)
300}