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}