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        match type_name {
260            "IFCSTYLEDITEM" => spans.styled_items.push((id, start, end)),
261            "IFCINDEXEDCOLOURMAP" => spans.indexed_colour_maps.push((id, start, end)),
262            "IFCMATERIALDEFINITIONREPRESENTATION" => {
263                spans.material_def_reprs.push((id, start, end))
264            }
265            "IFCRELASSOCIATESMATERIAL" => spans.rel_associates_material.push((id, start, end)),
266            "IFCRELVOIDSELEMENT" => spans.void_rels.push((id, start, end)),
267            "IFCRELFILLSELEMENT" => spans.fills_rels.push((id, start, end)),
268            "IFCRELAGGREGATES" => spans.aggregate_rels.push((id, start, end)),
269            _ => {}
270        }
271    }
272    let resolved = resolve_prepass(&spans, &mut decoder, ResolveOptions::default());
273    let (void_keys, void_counts, void_values) = flat_voids(&resolved.void_index);
274    let (mat_ids, mat_counts, mat_rgba) = flat_material_colors(&resolved.element_material_colors);
275    let (style_ids, style_rgba) = flat_styles_rgba8(&resolved, &mut decoder);
276    FixtureWires {
277        void_keys,
278        void_counts,
279        void_values,
280        mat_ids,
281        mat_counts,
282        mat_rgba,
283        style_ids,
284        style_rgba,
285    }
286}
287
288/// Compute the mesh-output determinism manifest over [`FIXTURE_IFC`] at
289/// `TessellationQuality::Medium` (the byte-identity density).
290///
291/// Pins the LOCAL-FRAME output (per-element f64 `origin` + element-local f32
292/// positions - the shipping wasm viewer path) by forcing
293/// `local_frame_set_enabled_override(Some(true))` for the REST OF THE PROCESS:
294/// wasm already defaults ON, native defaults OFF, and equalizing the flag is
295/// what makes the two targets' bytes comparable. The override is deliberately
296/// not restored (a concurrent test restoring it mid-compute would race), so
297/// only dedicated determinism test binaries should call this.
298pub fn compute_mesh_manifest() -> MeshManifest {
299    ifc_lite_geometry::local_frame_set_enabled_override(Some(true));
300
301    let result = process_geometry_filtered_with_quality(
302        FIXTURE_IFC,
303        OpeningFilterMode::Default,
304        TessellationQuality::Medium,
305    );
306
307    let wires = resolve_fixture_wires(FIXTURE_IFC.as_bytes());
308
309    let mut meshes = Vec::with_capacity(result.meshes.len());
310    let mut top = FNV_OFFSET_BASIS;
311    let mut vertex_count = 0usize;
312    let mut triangle_count = 0usize;
313    for mesh in &result.meshes {
314        // Positions alone: the surface asserted byte-identical on EVERY target.
315        let mut hp = FNV_OFFSET_BASIS;
316        fnv1a_f32_bits(&mut hp, &mesh.positions);
317        // Normals alone: the only per-mesh surface with a documented trig gap.
318        let mut hn = FNV_OFFSET_BASIS;
319        fnv1a_f32_bits(&mut hn, &mesh.normals);
320        // Identity + topology + placement: all cross-target byte-identical.
321        let mut hio = FNV_OFFSET_BASIS;
322        fnv1a_bytes(&mut hio, &mesh.express_id.to_le_bytes());
323        fnv1a_bytes(&mut hio, &[mesh.geometry_class]);
324        fnv1a_u32s(&mut hio, &mesh.indices);
325        for c in mesh.origin {
326            fnv1a_bytes(&mut hio, &c.to_bits().to_le_bytes());
327        }
328        // Fold all three into the top-level fingerprint in a fixed order.
329        fnv1a_bytes(&mut top, &hp.to_le_bytes());
330        fnv1a_bytes(&mut top, &hn.to_le_bytes());
331        fnv1a_bytes(&mut top, &hio.to_le_bytes());
332        vertex_count += mesh.positions.len() / 3;
333        triangle_count += mesh.indices.len() / 3;
334        meshes.push(MeshManifestEntry {
335            express_id: mesh.express_id,
336            geometry_class: mesh.geometry_class,
337            vertex_count: mesh.positions.len() / 3,
338            triangle_count: mesh.indices.len() / 3,
339            positions_hash: hex(hp),
340            normals_hash: hex(hn),
341            indices_origin_hash: hex(hio),
342        });
343    }
344
345    let mut voids_hash = FNV_OFFSET_BASIS;
346    fnv1a_u32s(&mut voids_hash, &wires.void_keys);
347    fnv1a_u32s(&mut voids_hash, &wires.void_counts);
348    fnv1a_u32s(&mut voids_hash, &wires.void_values);
349
350    let mut mat_hash = FNV_OFFSET_BASIS;
351    fnv1a_u32s(&mut mat_hash, &wires.mat_ids);
352    fnv1a_u32s(&mut mat_hash, &wires.mat_counts);
353    fnv1a_bytes(&mut mat_hash, &wires.mat_rgba);
354
355    let mut styles_hash = FNV_OFFSET_BASIS;
356    fnv1a_u32s(&mut styles_hash, &wires.style_ids);
357    fnv1a_bytes(&mut styles_hash, &wires.style_rgba);
358
359    fnv1a_bytes(&mut top, b"voids");
360    fnv1a_bytes(&mut top, &voids_hash.to_le_bytes());
361    fnv1a_bytes(&mut top, b"material_colors");
362    fnv1a_bytes(&mut top, &mat_hash.to_le_bytes());
363    fnv1a_bytes(&mut top, b"styles");
364    fnv1a_bytes(&mut top, &styles_hash.to_le_bytes());
365
366    MeshManifest {
367        hash: hex(top),
368        mesh_count: result.meshes.len(),
369        vertex_count,
370        triangle_count,
371        voids_hash: hex(voids_hash),
372        void_host_count: wires.void_keys.len(),
373        material_colors_hash: hex(mat_hash),
374        material_element_count: wires.mat_ids.len(),
375        styles_hash: hex(styles_hash),
376        style_entry_count: wires.style_ids.len(),
377        meshes,
378    }
379}
380
381/// `None` if the manifests match; otherwise a human-readable report that
382/// identifies WHICH mesh diverged (index, express id, per-mesh hash), not just
383/// the top-level mismatch.
384pub fn diff_report(expected: &MeshManifest, actual: &MeshManifest) -> Option<String> {
385    if expected == actual {
386        return None;
387    }
388    let mut lines = Vec::new();
389    if expected.hash != actual.hash {
390        lines.push(format!("hash: expected {} got {}", expected.hash, actual.hash));
391    }
392    if expected.mesh_count != actual.mesh_count {
393        lines.push(format!(
394            "mesh_count: expected {} got {}",
395            expected.mesh_count, actual.mesh_count
396        ));
397    }
398    if expected.vertex_count != actual.vertex_count {
399        lines.push(format!(
400            "vertex_count: expected {} got {}",
401            expected.vertex_count, actual.vertex_count
402        ));
403    }
404    if expected.triangle_count != actual.triangle_count {
405        lines.push(format!(
406            "triangle_count: expected {} got {}",
407            expected.triangle_count, actual.triangle_count
408        ));
409    }
410    if expected.voids_hash != actual.voids_hash {
411        lines.push(format!(
412            "voids_hash: expected {} got {}",
413            expected.voids_hash, actual.voids_hash
414        ));
415    }
416    if expected.void_host_count != actual.void_host_count {
417        lines.push(format!(
418            "void_host_count: expected {} got {}",
419            expected.void_host_count, actual.void_host_count
420        ));
421    }
422    if expected.material_colors_hash != actual.material_colors_hash {
423        lines.push(format!(
424            "material_colors_hash: expected {} got {}",
425            expected.material_colors_hash, actual.material_colors_hash
426        ));
427    }
428    if expected.material_element_count != actual.material_element_count {
429        lines.push(format!(
430            "material_element_count: expected {} got {}",
431            expected.material_element_count, actual.material_element_count
432        ));
433    }
434    if expected.styles_hash != actual.styles_hash {
435        lines.push(format!(
436            "styles_hash: expected {} got {}",
437            expected.styles_hash, actual.styles_hash
438        ));
439    }
440    if expected.style_entry_count != actual.style_entry_count {
441        lines.push(format!(
442            "style_entry_count: expected {} got {}",
443            expected.style_entry_count, actual.style_entry_count
444        ));
445    }
446    let fmt = |m: &MeshManifestEntry| {
447        format!(
448            "#{} class {} v{} t{} pos={} nrm={} io={}",
449            m.express_id,
450            m.geometry_class,
451            m.vertex_count,
452            m.triangle_count,
453            m.positions_hash,
454            m.normals_hash,
455            m.indices_origin_hash,
456        )
457    };
458    let common = expected.meshes.len().min(actual.meshes.len());
459    for i in 0..common {
460        let (e, a) = (&expected.meshes[i], &actual.meshes[i]);
461        if e != a {
462            let mut which = Vec::new();
463            if e.express_id != a.express_id {
464                which.push("express_id");
465            }
466            if e.geometry_class != a.geometry_class {
467                which.push("geometry_class");
468            }
469            if e.vertex_count != a.vertex_count {
470                which.push("vertex_count");
471            }
472            if e.triangle_count != a.triangle_count {
473                which.push("triangle_count");
474            }
475            if e.positions_hash != a.positions_hash {
476                which.push("positions");
477            }
478            if e.normals_hash != a.normals_hash {
479                which.push("normals");
480            }
481            if e.indices_origin_hash != a.indices_origin_hash {
482                which.push("indices/origin");
483            }
484            lines.push(format!(
485                "mesh[{i}]: differs in [{}]: expected {} got {}",
486                which.join("+"),
487                fmt(e),
488                fmt(a)
489            ));
490        }
491    }
492    for (i, e) in expected.meshes.iter().enumerate().skip(common) {
493        lines.push(format!("mesh[{i}]: expected {} got NOTHING", fmt(e)));
494    }
495    for (i, a) in actual.meshes.iter().enumerate().skip(common) {
496        lines.push(format!("mesh[{i}]: expected NOTHING got {}", fmt(a)));
497    }
498    Some(lines.join("\n"))
499}