Skip to main content

ifc_lite_processing/
determinism.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
5//! Mesh-output determinism manifest - the pipeline-level companion to the
6//! kernel's predicate sign manifest (`ifc_lite_geometry::kernel::manifest`).
7//!
8//! Runs the full `process_geometry` pipeline over a small synthetic fixture
9//! and FNV-1a-hashes the emitted wire bytes: per-mesh, in emit order, as three
10//! separate hashes - `positions_hash` (position f32 bits), `normals_hash`
11//! (normal f32 bits), and `indices_origin_hash` (express id, geometry class,
12//! indices, origin f64 bits) - so the cross-target guard can assert positions
13//! and topology byte-identical on every target while exempting only the curved
14//! mesh's normals for the libm trig gap. Plus the sorted `flat_voids`,
15//! `flat_material_colors`
16//! and `flat_styles_rgba8` wire arrays. The resulting [`MeshManifest`] is
17//! pinned in `rust/processing/tests/manifests/mesh_determinism.json`
18//! (asserted on x86_64 AND arm64) and in its wasm32 pair (identical except
19//! the documented libm-trig gap). The native test and the `wasm-bindings`
20//! wasm-bindgen-test leg both call [`compute_mesh_manifest`], so the fixture
21//! and hashing cannot drift between targets.
22//! Contract: `docs/architecture/mesh-determinism.md`.
23//!
24//! Shared library code (not a test util feature) for the same reason the
25//! kernel manifest is: the wasm leg lives in a different crate and must run
26//! the exact same battery.
27
28use crate::prepass::{
29    flat_material_colors, flat_styles_rgba8, flat_voids, resolve_prepass, PrepassSpans,
30    ResolveOptions,
31};
32use crate::processor::{process_geometry_filtered_with_quality, OpeningFilterMode};
33use ifc_lite_core::{build_entity_index, EntityDecoder, EntityScanner};
34use ifc_lite_geometry::TessellationQuality;
35use serde::{Deserialize, Serialize};
36
37/// Synthetic determinism fixture (house rule: no client data). Exercises the
38/// wire surfaces the manifest pins:
39/// - `#100` wall voided by opening `#200` and `#600` wall voided by `#700`
40///   (`flat_voids` with TWO hosts, so the sorted key order is load-bearing,
41///   plus the exact CSG cut),
42/// - `#400` proxy with a two-material `IfcMaterialList` appearance chain
43///   (transparent + opaque colours) and `#500` with a single material - TWO
44///   `flat_material_colors` entries, so that sort order is load-bearing too,
45/// - `#500` round column (`IfcCircleProfileDef` - Medium tessellation density),
46/// - `#530` geometry-attached `IfcStyledItem` on the column solid `#506`, so
47///   `flat_styles_rgba8` carries every precedence layer (geometry style +
48///   material colours + per-element fallback) and its sorted id order is
49///   load-bearing.
50pub const FIXTURE_IFC: &str = r#"ISO-10303-21;
51HEADER;
52FILE_DESCRIPTION(('mesh-output determinism manifest fixture'),'2;1');
53FILE_NAME('mesh_determinism.ifc','2026-07-01T00:00:00',(''),(''),'','','');
54FILE_SCHEMA(('IFC4'));
55ENDSEC;
56DATA;
57#1=IFCPROJECT('0DeterminismProject00A',$,'Determinism',$,$,$,$,(#10),#7);
58#7=IFCUNITASSIGNMENT((#8));
59#8=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);
60#10=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-5,#11,$);
61#11=IFCAXIS2PLACEMENT3D(#12,$,$);
62#12=IFCCARTESIANPOINT((0.,0.,0.));
63#13=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,#10,$,.MODEL_VIEW.,$);
64#20=IFCLOCALPLACEMENT($,#11);
65#30=IFCRECTANGLEPROFILEDEF(.AREA.,'WallProfile',#31,4.0,0.3);
66#31=IFCAXIS2PLACEMENT2D(#32,#33);
67#32=IFCCARTESIANPOINT((0.,0.));
68#33=IFCDIRECTION((1.,0.));
69#40=IFCEXTRUDEDAREASOLID(#30,#41,#42,2.5);
70#41=IFCAXIS2PLACEMENT3D(#12,$,$);
71#42=IFCDIRECTION((0.,0.,1.));
72#50=IFCSHAPEREPRESENTATION(#13,'Body','SweptSolid',(#40));
73#51=IFCPRODUCTDEFINITIONSHAPE($,$,(#50));
74#100=IFCWALL('0DeterminismWall0000A',$,'Wall',$,$,#20,#51,$,$);
75#110=IFCLOCALPLACEMENT(#20,#111);
76#111=IFCAXIS2PLACEMENT3D(#112,#113,#114);
77#112=IFCCARTESIANPOINT((0.,-0.5,1.25));
78#113=IFCDIRECTION((0.,1.,0.));
79#114=IFCDIRECTION((1.,0.,0.));
80#127=IFCRECTANGLEPROFILEDEF(.AREA.,'OpeningProfile',#128,1.2,1.5);
81#128=IFCAXIS2PLACEMENT2D(#32,#33);
82#131=IFCEXTRUDEDAREASOLID(#127,#132,#42,1.0);
83#132=IFCAXIS2PLACEMENT3D(#12,$,$);
84#140=IFCSHAPEREPRESENTATION(#13,'Body','SweptSolid',(#131));
85#141=IFCPRODUCTDEFINITIONSHAPE($,$,(#140));
86#200=IFCOPENINGELEMENT('0DeterminismOpening0A',$,'Opening',$,$,#110,#141,$,.OPENING.);
87#300=IFCRELVOIDSELEMENT('0DeterminismVoids000A',$,$,$,#100,#200);
88#400=IFCBUILDINGELEMENTPROXY('0DeterminismProxy000A',$,'MultiMaterial',$,$,#401,#402,$,$);
89#401=IFCLOCALPLACEMENT($,#403);
90#403=IFCAXIS2PLACEMENT3D(#404,$,$);
91#404=IFCCARTESIANPOINT((6.,0.,0.));
92#402=IFCPRODUCTDEFINITIONSHAPE($,$,(#405));
93#405=IFCSHAPEREPRESENTATION(#13,'Body','Tessellation',(#406));
94#406=IFCTRIANGULATEDFACESET(#407,$,.T.,((1,2,3),(1,2,4),(1,4,3),(2,3,4)),$);
95#407=IFCCARTESIANPOINTLIST3D(((0.,0.,0.),(1.,0.,0.),(0.,1.,0.),(0.,0.,1.)));
96#430=IFCSTYLEDITEM($,(#431),$);
97#431=IFCSURFACESTYLE('Glazing',.BOTH.,(#432));
98#432=IFCSURFACESTYLERENDERING(#433,0.5,$,$,$,$,$,$,.FLAT.);
99#433=IFCCOLOURRGB($,0.2,0.4,0.8);
100#434=IFCSTYLEDITEM($,(#435),$);
101#435=IFCSURFACESTYLE('Frame',.BOTH.,(#436));
102#436=IFCSURFACESTYLERENDERING(#437,$,$,$,$,$,$,$,.FLAT.);
103#437=IFCCOLOURRGB($,0.7,0.5,0.2);
104#440=IFCMATERIAL('Glazing',$,$);
105#441=IFCMATERIALDEFINITIONREPRESENTATION($,$,(#442),#440);
106#442=IFCSTYLEDREPRESENTATION(#10,'Style','Material',(#430));
107#445=IFCMATERIAL('Frame',$,$);
108#446=IFCMATERIALDEFINITIONREPRESENTATION($,$,(#447),#445);
109#447=IFCSTYLEDREPRESENTATION(#10,'Style','Material',(#434));
110#450=IFCMATERIALLIST((#440,#445));
111#460=IFCRELASSOCIATESMATERIAL('0DeterminismRelMat00A',$,$,$,(#400),#450);
112#500=IFCCOLUMN('0DeterminismColumn00A',$,'Column',$,$,#501,#502,$,$);
113#501=IFCLOCALPLACEMENT($,#503);
114#503=IFCAXIS2PLACEMENT3D(#504,$,$);
115#504=IFCCARTESIANPOINT((10.,0.,0.));
116#502=IFCPRODUCTDEFINITIONSHAPE($,$,(#505));
117#505=IFCSHAPEREPRESENTATION(#13,'Body','SweptSolid',(#506));
118#506=IFCEXTRUDEDAREASOLID(#507,#508,#42,3.0);
119#507=IFCCIRCLEPROFILEDEF(.AREA.,'ColumnProfile',#31,0.25);
120#508=IFCAXIS2PLACEMENT3D(#12,$,$);
121#530=IFCSTYLEDITEM(#506,(#531),$);
122#531=IFCSURFACESTYLE('Concrete',.BOTH.,(#532));
123#532=IFCSURFACESTYLERENDERING(#533,$,$,$,$,$,$,$,.FLAT.);
124#533=IFCCOLOURRGB($,0.62,0.6,0.55);
125#600=IFCWALL('0DeterminismWall0600A',$,'Wall2',$,$,#601,#651,$,$);
126#601=IFCLOCALPLACEMENT($,#602);
127#602=IFCAXIS2PLACEMENT3D(#603,$,$);
128#603=IFCCARTESIANPOINT((0.,3.,0.));
129#630=IFCRECTANGLEPROFILEDEF(.AREA.,'WallProfile2',#31,3.0,0.3);
130#640=IFCEXTRUDEDAREASOLID(#630,#41,#42,2.5);
131#650=IFCSHAPEREPRESENTATION(#13,'Body','SweptSolid',(#640));
132#651=IFCPRODUCTDEFINITIONSHAPE($,$,(#650));
133#700=IFCOPENINGELEMENT('0DeterminismOpening7A',$,'Opening2',$,$,#710,#741,$,.OPENING.);
134#710=IFCLOCALPLACEMENT(#601,#711);
135#711=IFCAXIS2PLACEMENT3D(#712,#113,#114);
136#712=IFCCARTESIANPOINT((0.5,-0.5,1.0));
137#727=IFCRECTANGLEPROFILEDEF(.AREA.,'OpeningProfile2',#128,0.9,1.2);
138#731=IFCEXTRUDEDAREASOLID(#727,#132,#42,1.0);
139#740=IFCSHAPEREPRESENTATION(#13,'Body','SweptSolid',(#731));
140#741=IFCPRODUCTDEFINITIONSHAPE($,$,(#740));
141#800=IFCRELVOIDSELEMENT('0DeterminismVoids800A',$,$,$,#600,#700);
142#820=IFCRELASSOCIATESMATERIAL('0DeterminismRelMat82A',$,$,$,(#500),#445);
143ENDSEC;
144END-ISO-10303-21;
145"#;
146
147const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
148const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
149
150fn fnv1a_bytes(h: &mut u64, bytes: &[u8]) {
151    for &b in bytes {
152        *h ^= b as u64;
153        *h = h.wrapping_mul(FNV_PRIME);
154    }
155}
156
157fn fnv1a_u32s(h: &mut u64, vals: &[u32]) {
158    for v in vals {
159        fnv1a_bytes(h, &v.to_le_bytes());
160    }
161}
162
163fn fnv1a_f32_bits(h: &mut u64, vals: &[f32]) {
164    for v in vals {
165        fnv1a_bytes(h, &v.to_bits().to_le_bytes());
166    }
167}
168
169fn hex(h: u64) -> String {
170    format!("0x{h:016x}")
171}
172
173/// Per-mesh manifest entry: enough to identify WHICH mesh diverged and how big
174/// it was, without committing the raw vertex data.
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
176pub struct MeshManifestEntry {
177    pub express_id: u32,
178    pub geometry_class: u8,
179    pub vertex_count: usize,
180    pub triangle_count: usize,
181    /// FNV-1a over the position f32 bits (little-endian). Split out from the
182    /// normals so the cross-target guard can assert it byte-identical for EVERY
183    /// mesh, including the curved-profile one: the circle-tessellation libm
184    /// sin/cos gap lands in the near-zero radial-normal components, not here.
185    pub positions_hash: String,
186    /// FNV-1a over the normal f32 bits. The ONLY per-mesh surface allowed to
187    /// differ across targets, and only for the curved-profile mesh (see the
188    /// wasm leg's trig-gap guard).
189    pub normals_hash: String,
190    /// FNV-1a over (express_id, geometry_class, indices u32, origin f64 bits) -
191    /// identity, topology and placement, byte-identical across all targets.
192    pub indices_origin_hash: String,
193}
194
195/// The pinned mesh-output determinism fingerprint.
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
197pub struct MeshManifest {
198    /// FNV-1a over every per-mesh hash in emit order, then the labelled
199    /// `flat_voids`, `flat_material_colors` and `flat_styles_rgba8` wire
200    /// arrays.
201    pub hash: String,
202    pub mesh_count: usize,
203    pub vertex_count: usize,
204    pub triangle_count: usize,
205    /// FNV-1a over the sorted `flat_voids` `(keys, counts, values)` arrays.
206    pub voids_hash: String,
207    /// Number of void hosts on the wire - must stay >= 2 or the sorted key
208    /// order stops being load-bearing (a one-entry array pins no order).
209    pub void_host_count: usize,
210    /// FNV-1a over the sorted `flat_material_colors` `(ids, counts, rgba8)` arrays.
211    pub material_colors_hash: String,
212    /// Number of material-coloured elements on the wire - same >= 2 rationale.
213    pub material_element_count: usize,
214    /// FNV-1a over the sorted `flat_styles_rgba8` `(ids, rgba8)` arrays - the
215    /// third flat wire surface, same cross-target contract as the other two.
216    pub styles_hash: String,
217    /// Number of style entries on the wire (geometry, material and element
218    /// ids across the layered precedence) - same >= 2 rationale.
219    pub style_entry_count: usize,
220    pub meshes: Vec<MeshManifestEntry>,
221}
222
223impl MeshManifest {
224    pub fn to_json(&self) -> String {
225        let mut json = serde_json::to_string_pretty(self)
226            .expect("MeshManifest serialization cannot fail");
227        json.push('\n');
228        json
229    }
230
231    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
232        serde_json::from_str(json)
233    }
234}
235
236/// The three flat prepass wire surfaces the manifest pins, computed over the
237/// fixture by [`resolve_fixture_wires`].
238struct FixtureWires {
239    void_keys: Vec<u32>,
240    void_counts: Vec<u32>,
241    void_values: Vec<u32>,
242    mat_ids: Vec<u32>,
243    mat_counts: Vec<u32>,
244    mat_rgba: Vec<u8>,
245    style_ids: Vec<u32>,
246    style_rgba: Vec<u8>,
247}
248
249/// Scan the fixture's prepass spans, resolve them - the same mechanical
250/// span-stash both production scan loops run (see the `crate::prepass` module
251/// doc), feeding THE shared resolver - and flatten every wire surface the
252/// manifest pins.
253fn resolve_fixture_wires(content: &[u8]) -> FixtureWires {
254    let entity_index = std::sync::Arc::new(build_entity_index(content));
255    let mut decoder = EntityDecoder::with_arc_index(content, entity_index);
256    let mut spans = PrepassSpans::default();
257    let mut scanner = EntityScanner::new(content);
258    while let Some((id, type_name, start, end)) = scanner.next_entity() {
259        spans.stash(type_name, id, start, end);
260    }
261    let resolved = resolve_prepass(&spans, &mut decoder, ResolveOptions::default());
262    let (void_keys, void_counts, void_values) = flat_voids(&resolved.void_index);
263    let (mat_ids, mat_counts, mat_rgba) = flat_material_colors(&resolved.element_material_colors);
264    let (style_ids, style_rgba) = flat_styles_rgba8(&resolved, &mut decoder);
265    FixtureWires {
266        void_keys,
267        void_counts,
268        void_values,
269        mat_ids,
270        mat_counts,
271        mat_rgba,
272        style_ids,
273        style_rgba,
274    }
275}
276
277/// Compute the mesh-output determinism manifest over [`FIXTURE_IFC`] at
278/// `TessellationQuality::Medium` (the byte-identity density).
279///
280/// Pins the LOCAL-FRAME output (per-element f64 `origin` + element-local f32
281/// positions - the shipping wasm viewer path) by forcing
282/// `local_frame_set_enabled_override(Some(true))` for the REST OF THE PROCESS:
283/// wasm already defaults ON, native defaults OFF, and equalizing the flag is
284/// what makes the two targets' bytes comparable. The override is deliberately
285/// not restored (a concurrent test restoring it mid-compute would race), so
286/// only dedicated determinism test binaries should call this.
287pub fn compute_mesh_manifest() -> MeshManifest {
288    ifc_lite_geometry::local_frame_set_enabled_override(Some(true));
289
290    let result = process_geometry_filtered_with_quality(
291        FIXTURE_IFC,
292        OpeningFilterMode::Default,
293        TessellationQuality::Medium,
294    );
295
296    let wires = resolve_fixture_wires(FIXTURE_IFC.as_bytes());
297
298    let mut meshes = Vec::with_capacity(result.meshes.len());
299    let mut top = FNV_OFFSET_BASIS;
300    let mut vertex_count = 0usize;
301    let mut triangle_count = 0usize;
302    for mesh in &result.meshes {
303        // Positions alone: the surface asserted byte-identical on EVERY target.
304        let mut hp = FNV_OFFSET_BASIS;
305        fnv1a_f32_bits(&mut hp, &mesh.positions);
306        // Normals alone: the only per-mesh surface with a documented trig gap.
307        let mut hn = FNV_OFFSET_BASIS;
308        fnv1a_f32_bits(&mut hn, &mesh.normals);
309        // Identity + topology + placement: all cross-target byte-identical.
310        let mut hio = FNV_OFFSET_BASIS;
311        fnv1a_bytes(&mut hio, &mesh.express_id.to_le_bytes());
312        fnv1a_bytes(&mut hio, &[mesh.geometry_class]);
313        fnv1a_u32s(&mut hio, &mesh.indices);
314        for c in mesh.origin {
315            fnv1a_bytes(&mut hio, &c.to_bits().to_le_bytes());
316        }
317        // Fold all three into the top-level fingerprint in a fixed order.
318        fnv1a_bytes(&mut top, &hp.to_le_bytes());
319        fnv1a_bytes(&mut top, &hn.to_le_bytes());
320        fnv1a_bytes(&mut top, &hio.to_le_bytes());
321        vertex_count += mesh.positions.len() / 3;
322        triangle_count += mesh.indices.len() / 3;
323        meshes.push(MeshManifestEntry {
324            express_id: mesh.express_id,
325            geometry_class: mesh.geometry_class,
326            vertex_count: mesh.positions.len() / 3,
327            triangle_count: mesh.indices.len() / 3,
328            positions_hash: hex(hp),
329            normals_hash: hex(hn),
330            indices_origin_hash: hex(hio),
331        });
332    }
333
334    let mut voids_hash = FNV_OFFSET_BASIS;
335    fnv1a_u32s(&mut voids_hash, &wires.void_keys);
336    fnv1a_u32s(&mut voids_hash, &wires.void_counts);
337    fnv1a_u32s(&mut voids_hash, &wires.void_values);
338
339    let mut mat_hash = FNV_OFFSET_BASIS;
340    fnv1a_u32s(&mut mat_hash, &wires.mat_ids);
341    fnv1a_u32s(&mut mat_hash, &wires.mat_counts);
342    fnv1a_bytes(&mut mat_hash, &wires.mat_rgba);
343
344    let mut styles_hash = FNV_OFFSET_BASIS;
345    fnv1a_u32s(&mut styles_hash, &wires.style_ids);
346    fnv1a_bytes(&mut styles_hash, &wires.style_rgba);
347
348    fnv1a_bytes(&mut top, b"voids");
349    fnv1a_bytes(&mut top, &voids_hash.to_le_bytes());
350    fnv1a_bytes(&mut top, b"material_colors");
351    fnv1a_bytes(&mut top, &mat_hash.to_le_bytes());
352    fnv1a_bytes(&mut top, b"styles");
353    fnv1a_bytes(&mut top, &styles_hash.to_le_bytes());
354
355    MeshManifest {
356        hash: hex(top),
357        mesh_count: result.meshes.len(),
358        vertex_count,
359        triangle_count,
360        voids_hash: hex(voids_hash),
361        void_host_count: wires.void_keys.len(),
362        material_colors_hash: hex(mat_hash),
363        material_element_count: wires.mat_ids.len(),
364        styles_hash: hex(styles_hash),
365        style_entry_count: wires.style_ids.len(),
366        meshes,
367    }
368}
369
370/// `None` if the manifests match; otherwise a human-readable report that
371/// identifies WHICH mesh diverged (index, express id, per-mesh hash), not just
372/// the top-level mismatch.
373pub fn diff_report(expected: &MeshManifest, actual: &MeshManifest) -> Option<String> {
374    if expected == actual {
375        return None;
376    }
377    let mut lines = Vec::new();
378    if expected.hash != actual.hash {
379        lines.push(format!("hash: expected {} got {}", expected.hash, actual.hash));
380    }
381    if expected.mesh_count != actual.mesh_count {
382        lines.push(format!(
383            "mesh_count: expected {} got {}",
384            expected.mesh_count, actual.mesh_count
385        ));
386    }
387    if expected.vertex_count != actual.vertex_count {
388        lines.push(format!(
389            "vertex_count: expected {} got {}",
390            expected.vertex_count, actual.vertex_count
391        ));
392    }
393    if expected.triangle_count != actual.triangle_count {
394        lines.push(format!(
395            "triangle_count: expected {} got {}",
396            expected.triangle_count, actual.triangle_count
397        ));
398    }
399    if expected.voids_hash != actual.voids_hash {
400        lines.push(format!(
401            "voids_hash: expected {} got {}",
402            expected.voids_hash, actual.voids_hash
403        ));
404    }
405    if expected.void_host_count != actual.void_host_count {
406        lines.push(format!(
407            "void_host_count: expected {} got {}",
408            expected.void_host_count, actual.void_host_count
409        ));
410    }
411    if expected.material_colors_hash != actual.material_colors_hash {
412        lines.push(format!(
413            "material_colors_hash: expected {} got {}",
414            expected.material_colors_hash, actual.material_colors_hash
415        ));
416    }
417    if expected.material_element_count != actual.material_element_count {
418        lines.push(format!(
419            "material_element_count: expected {} got {}",
420            expected.material_element_count, actual.material_element_count
421        ));
422    }
423    if expected.styles_hash != actual.styles_hash {
424        lines.push(format!(
425            "styles_hash: expected {} got {}",
426            expected.styles_hash, actual.styles_hash
427        ));
428    }
429    if expected.style_entry_count != actual.style_entry_count {
430        lines.push(format!(
431            "style_entry_count: expected {} got {}",
432            expected.style_entry_count, actual.style_entry_count
433        ));
434    }
435    let fmt = |m: &MeshManifestEntry| {
436        format!(
437            "#{} class {} v{} t{} pos={} nrm={} io={}",
438            m.express_id,
439            m.geometry_class,
440            m.vertex_count,
441            m.triangle_count,
442            m.positions_hash,
443            m.normals_hash,
444            m.indices_origin_hash,
445        )
446    };
447    let common = expected.meshes.len().min(actual.meshes.len());
448    for i in 0..common {
449        let (e, a) = (&expected.meshes[i], &actual.meshes[i]);
450        if e != a {
451            let mut which = Vec::new();
452            if e.express_id != a.express_id {
453                which.push("express_id");
454            }
455            if e.geometry_class != a.geometry_class {
456                which.push("geometry_class");
457            }
458            if e.vertex_count != a.vertex_count {
459                which.push("vertex_count");
460            }
461            if e.triangle_count != a.triangle_count {
462                which.push("triangle_count");
463            }
464            if e.positions_hash != a.positions_hash {
465                which.push("positions");
466            }
467            if e.normals_hash != a.normals_hash {
468                which.push("normals");
469            }
470            if e.indices_origin_hash != a.indices_origin_hash {
471                which.push("indices/origin");
472            }
473            lines.push(format!(
474                "mesh[{i}]: differs in [{}]: expected {} got {}",
475                which.join("+"),
476                fmt(e),
477                fmt(a)
478            ));
479        }
480    }
481    for (i, e) in expected.meshes.iter().enumerate().skip(common) {
482        lines.push(format!("mesh[{i}]: expected {} got NOTHING", fmt(e)));
483    }
484    for (i, a) in actual.meshes.iter().enumerate().skip(common) {
485        lines.push(format!("mesh[{i}]: expected NOTHING got {}", fmt(a)));
486    }
487    Some(lines.join("\n"))
488}