From 912f4e3ae3dd90cc317fe18433b577ab4f04aea3 Mon Sep 17 00:00:00 2001 From: CagdasErturk Date: Wed, 9 Sep 2026 16:40:18 +0300 Subject: [PATCH 1/4] test(mesh): a model and the bytes it reads to, both committed Every gate beside this crate asks whether a reader answered -- `Ok` or a named refusal, no panic, no read past the end. That is the right question for a corpus of hostile inputs and it is nearly blind to a reader that answers confidently and wrongly. A document whose second primitive is dropped, whose node transform is not applied, or whose texture coordinates are shifted by one vertex answers perfectly well. So one model is committed beside the exact bytes it reads to, and the comparison is byte for byte. The model is generated rather than borrowed, and built to reach every layer at once: two primitives in one mesh, so the appending path runs and the second primitive's indices are shifted by the first's vertex count; a node with a translation, so positions arrive somewhere other than where the accessor put them; indices, so the indexed path runs; per-corner normals and texture coordinates, so the optional streams are present. It also carries a material, a texture and an image, which change no geometry at all -- they are there so the golden proves they stay out of the canonical form. Exact comparison is legitimate here and the provenance file says why: the canonical form is little-endian f32 and u32 copied out of the document, with no arithmetic on a coordinate beyond the transform the document itself states. Nothing rounds, nothing reorders, and no platform spells an f32 differently in memory. That claim is what the comparison pins, and the day it stops being true is the day this gate earns its keep. Coordinates are compared by their bits for the same reason. A tolerance would absorb exactly the change this file exists to notice. Refreshing it is one command rather than a ritual. A rendered golden needs a pinned adapter and candidates adopted by hand, because no two adapters rasterize alike; any machine that runs the generator reproduces these two files, so a diff after running it is a real change to what this crate reads. --- crates/mesh/README.md | 28 +++ crates/mesh/examples/make_import_golden.rs | 186 ++++++++++++++++ crates/mesh/tests/golden.rs | 202 ++++++++++++++++++ crates/mesh/tests/goldens/panel.gltf | 27 +++ crates/mesh/tests/goldens/panel.msh | Bin 0 -> 212 bytes .../mesh/tests/goldens/panel.provenance.txt | 34 +++ 6 files changed, 477 insertions(+) create mode 100644 crates/mesh/examples/make_import_golden.rs create mode 100644 crates/mesh/tests/golden.rs create mode 100644 crates/mesh/tests/goldens/panel.gltf create mode 100644 crates/mesh/tests/goldens/panel.msh create mode 100644 crates/mesh/tests/goldens/panel.provenance.txt diff --git a/crates/mesh/README.md b/crates/mesh/README.md index 2221cc06..e26dca64 100644 --- a/crates/mesh/README.md +++ b/crates/mesh/README.md @@ -174,6 +174,34 @@ the material it belongs to, two reciprocal spellings of one factor, a map line whose options run past its file name. `tests/properties.rs` round-trips generated meshes through both STL spellings and asserts that every byte string gets an answer. +**`tests/golden.rs` is the one gate that asks what a reader answered +rather than whether it answered.** Every other test beside this crate is +about refusals, round trips and inputs nobody wrote on purpose — and all +of them are nearly blind to a reader that answers confidently and +wrongly. A document whose second primitive is dropped, or whose node +transform is not applied, or whose texture coordinates are shifted by one +vertex, answers perfectly well. + +So one generated model sits in `tests/goldens/` beside the exact bytes it +reads to, and the comparison is byte for byte. The model is built to +reach every layer at once: two primitives in one mesh so the appending +arithmetic runs, a node transform so positions move, indices, per-corner +normals and texture coordinates, and a material, a texture and an image — +those last three because they change no geometry, so the golden also +proves they stay out of the canonical form. + +**Exact comparison is legitimate here**, and the provenance file says +why: the canonical form is little-endian `f32` and `u32` copied out of +the document with no arithmetic beyond the transform the document itself +states. Nothing rounds, nothing reorders, and no platform spells an +`f32` differently in memory. That is the claim the comparison pins, and +the day it stops being true is the day this gate earns its keep. + +Refreshing it is one command — `cargo run -p renew-mesh --example +make_import_golden` — because unlike a rendered golden, where no two +adapters rasterize alike and candidates have to be adopted from a pinned +lane, any machine reproduces these bytes. + **Each reader's census is a test, not a comment.** All five suites map every `MeshError` variant to either a file in the suite that provokes it or a sentence saying why this format cannot reach it, with no wildcard diff --git a/crates/mesh/examples/make_import_golden.rs b/crates/mesh/examples/make_import_golden.rs new file mode 100644 index 00000000..1482e6a1 --- /dev/null +++ b/crates/mesh/examples/make_import_golden.rs @@ -0,0 +1,186 @@ +//! Write the committed import golden: a source model, and what this +//! crate reads it to. +//! +//! **The model is generated here rather than borrowed**, like every +//! other fixture beside this crate. Borrowed art carries a licence and a +//! provenance question, and neither belongs in a regression guard. +//! +//! Run it to regenerate both files after a deliberate change to the +//! reader or to the canonical form: +//! +//! ```text +//! cargo run -p renew-mesh --example make_import_golden +//! ``` +//! +//! **Regenerating is the whole refresh ritual**, which is the difference +//! between this golden and a rendered one. An image golden needs a +//! pinned adapter and a workflow that uploads candidates, because no two +//! machines rasterize alike. These bytes are little-endian `f32` copied +//! out of a document with no arithmetic on the way, so any machine that +//! can run this example produces the same file -- and a diff after +//! running it is a real change to what this crate reads, every time. + +// A generator writes files; that is its whole job. +#![allow(clippy::disallowed_methods, clippy::disallowed_types)] +// It is a tool, not engine code: a failed write should say so and stop. +#![allow(clippy::expect_used)] + +use std::path::PathBuf; + +use renew_mesh::{blob, format}; + +/// Where the committed golden lives, beside the tests that read it. +fn goldens() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/goldens") +} + +/// The source model, spelled out. +/// +/// **One document that reaches every layer this crate has learned.** A +/// golden over a bare triangle would pin the geometry path and nothing +/// else, and the whole point of committing bytes is to notice a change +/// nobody meant -- so this carries, deliberately: +/// +/// * **Two primitives in one mesh**, so the appending path runs and the +/// second primitive's indices have to be shifted by the first's vertex +/// count. That shift is arithmetic, and arithmetic is what a golden +/// catches. +/// * **A node with a transform**, so positions arrive somewhere other +/// than where the accessor put them. +/// * **Per-corner normals and texture coordinates**, so the optional +/// streams are present and the blob's `present` bitfield is not zero. +/// * **Indices**, so the index path runs rather than the implicit one. +/// * **A material and an image**, which change no geometry at all and +/// therefore change none of these bytes -- they are here so that the +/// document exercises the tables while the golden proves they stay out +/// of the canonical form. +/// +/// The buffer carries its own bytes as a payload, so the file stands +/// alone: no container, no second file, nothing outside itself. +fn source() -> String { + // Two triangles' worth of positions, normals and texture + // coordinates, then six indices -- laid out as the accessors below + // describe them, and encoded once as one buffer. + let mut buffer: Vec = Vec::new(); + + // POSITION, six vertices: a unit triangle in z = 0, and a second + // one displaced in z so the two are not coplanar. + for value in [ + 0.0_f32, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, // + 0.0, 0.0, 0.5, 1.0, 0.0, 0.5, 0.0, 1.0, 0.5, + ] { + buffer.extend_from_slice(&value.to_le_bytes()); + } + // NORMAL, one per vertex: +z for the first triangle, -z for the + // second, so the two are told apart by more than position. + for value in [ + 0.0_f32, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, // + 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, + ] { + buffer.extend_from_slice(&value.to_le_bytes()); + } + // TEXCOORD_0, one per vertex. + for value in [ + 0.0_f32, 0.0, 1.0, 0.0, 0.0, 1.0, // + 0.25, 0.25, 0.75, 0.25, 0.25, 0.75, + ] { + buffer.extend_from_slice(&value.to_le_bytes()); + } + // Two index triples, one per primitive, each numbered from its own + // primitive's first vertex -- which is what makes the appending + // shift observable. + for value in [0_u16, 1, 2, 0, 1, 2] { + buffer.extend_from_slice(&value.to_le_bytes()); + } + + let payload = base64(&buffer); + format!( + r#"{{"asset":{{"version":"2.0"}}, +"scene":0, +"scenes":[{{"nodes":[0]}}], +"nodes":[{{"mesh":0,"translation":[2.0,0.5,-1.0]}}], +"meshes":[{{"primitives":[ +{{"attributes":{{"POSITION":0,"NORMAL":1,"TEXCOORD_0":2}},"indices":3,"material":0}}, +{{"attributes":{{"POSITION":4,"NORMAL":5,"TEXCOORD_0":6}},"indices":7,"material":0}}]}}], +"accessors":[ +{{"bufferView":0,"componentType":5126,"count":3,"type":"VEC3"}}, +{{"bufferView":1,"componentType":5126,"count":3,"type":"VEC3"}}, +{{"bufferView":2,"componentType":5126,"count":3,"type":"VEC2"}}, +{{"bufferView":3,"componentType":5123,"count":3,"type":"SCALAR"}}, +{{"bufferView":0,"byteOffset":36,"componentType":5126,"count":3,"type":"VEC3"}}, +{{"bufferView":1,"byteOffset":36,"componentType":5126,"count":3,"type":"VEC3"}}, +{{"bufferView":2,"byteOffset":24,"componentType":5126,"count":3,"type":"VEC2"}}, +{{"bufferView":3,"byteOffset":6,"componentType":5123,"count":3,"type":"SCALAR"}}], +"bufferViews":[ +{{"buffer":0,"byteOffset":0,"byteLength":72}}, +{{"buffer":0,"byteOffset":72,"byteLength":72}}, +{{"buffer":0,"byteOffset":144,"byteLength":48}}, +{{"buffer":0,"byteOffset":192,"byteLength":12}}], +"buffers":[{{"byteLength":{length},"uri":"data:application/octet-stream;base64,{payload}"}}], +"textures":[{{"source":0}}], +"materials":[{{"name":"panel","pbrMetallicRoughness":{{ +"baseColorFactor":[0.5,0.25,0.125,1.0],"metallicFactor":0.75,"roughnessFactor":0.5, +"baseColorTexture":{{"index":0}}}},"emissiveFactor":[0.0,0.125,0.25],"doubleSided":true}}], +"images":[{{"name":"grain","uri":"data:image/png;base64,AQIDBA=="}}]}}"#, + length = buffer.len(), + payload = payload, + ) +} + +/// Base64, standard alphabet with padding. +/// +/// Written here rather than reached for: this crate's own decoder is +/// what the golden is partly testing, and a generator that shared it +/// could encode a mistake the decoder makes and call the pair agreement. +fn base64(bytes: &[u8]) -> String { + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + for group in bytes.chunks(3) { + let mut packed = 0_u32; + for (index, byte) in group.iter().enumerate() { + packed |= u32::from(*byte) << (16 - 8 * index); + } + for index in 0..4 { + if index <= group.len() { + let digit = (packed >> (18 - 6 * index)) & 0x3f; + out.push(char::from(ALPHABET[digit as usize])); + } else { + out.push('='); + } + } + } + out +} + +fn main() { + let directory = goldens(); + std::fs::create_dir_all(&directory).expect("the goldens directory is writable"); + + let document = source(); + let model = directory.join("panel.gltf"); + std::fs::write(&model, document.as_bytes()).expect("the source model is writable"); + + // **Read through the same door a caller uses.** Detecting the format + // from the bytes rather than calling `gltf::read` directly is part + // of what this golden pins: a detector that stopped recognising a + // document would change these bytes. + let found = format::detect(document.as_bytes()); + let mesh = found + .read(document.as_bytes()) + .expect("the golden's source carries geometry") + .expect("the golden's source reads"); + let bytes = blob::write(&mesh); + + let blob_path = directory.join("panel.msh"); + std::fs::write(&blob_path, &bytes).expect("the blob is writable"); + + println!( + "wrote {} ({} bytes) and {} ({} bytes): {} triangles, {} positions", + model.display(), + document.len(), + blob_path.display(), + bytes.len(), + mesh.triangles(), + mesh.positions.len(), + ); +} diff --git a/crates/mesh/tests/golden.rs b/crates/mesh/tests/golden.rs new file mode 100644 index 00000000..ebd0e1f9 --- /dev/null +++ b/crates/mesh/tests/golden.rs @@ -0,0 +1,202 @@ +//! A model, and the bytes this crate reads it to, both committed. +//! +//! Every other gate beside this crate asks whether a reader *answers* — +//! `Ok` or a named refusal, no panic, no read past the end. That is the +//! right question for a corpus of hostile inputs and it is nearly blind +//! to a reader that answers confidently and wrongly. A document that +//! comes back with its second primitive dropped, or its transform not +//! applied, or its texture coordinates shifted by one vertex, answers +//! perfectly well. +//! +//! **So one model is committed beside the bytes it reads to**, and the +//! comparison is exact. +//! +//! # Why exact comparison is legitimate here +//! +//! The canonical form is little-endian `f32` and `u32` arrays copied out +//! of the document, and the path between them does no arithmetic on a +//! coordinate beyond the node transform the document itself states. There +//! is no rounding to differ over, no iteration order that reaches the +//! bytes, and no platform difference in how an `f32` is spelled in +//! memory. **That is the claim this file pins**, and the day it stops +//! being true — a reader that normalises, or averages, or reorders — is +//! the day this golden earns its keep by failing. +//! +//! # Refreshing it +//! +//! ```text +//! cargo run -p renew-mesh --example make_import_golden +//! ``` +//! +//! Any machine that can run that produces the same two files, which is +//! why this golden needs no candidate ritual and no pinned lane — unlike +//! a rendered one, where no two adapters rasterize alike. A diff after +//! running it is a real change to what this crate reads. + +// The tripwire ban on filesystem access protects engine code; comparing +// against committed artifacts is this harness's whole job. +#![allow(clippy::disallowed_methods)] +#![allow(clippy::disallowed_types)] +// A missing golden is a broken checkout, not a condition to recover from. +#![allow(clippy::panic, clippy::expect_used)] + +use std::path::PathBuf; + +use renew_mesh::{blob, format, gltf}; + +fn goldens() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/goldens") +} + +/// Compare coordinates by bits. +/// +/// **The honest comparison here, and not a way around the lint.** This +/// file's whole argument is that the path from document to canonical +/// form does no arithmetic a tolerance would need to absorb -- the one +/// exception being the node transform, which is a translation, and a +/// translation of an exact value by an exact value is exact. A tolerance +/// would hide precisely the change this gate exists to notice. +fn same(got: [f32; 3], want: [f32; 3], what: &str) { + for (index, (left, right)) in got.iter().zip(&want).enumerate() { + assert_eq!( + left.to_bits(), + right.to_bits(), + "{what}: component {index} is {left}, not {right}" + ); + } +} + +fn read(name: &str) -> Vec { + let path = goldens().join(name); + std::fs::read(&path).unwrap_or_else(|error| { + panic!( + "the committed golden `{}` is unreadable: {error}. It is committed beside this test; \ + a checkout missing it is broken rather than merely untested.", + path.display() + ) + }) +} + +/// **The whole claim, in one comparison.** +/// +/// Probed by changing one coordinate in the generator: red. Probed by +/// dropping the second primitive: red. Probed by removing the node's +/// translation: red. +#[test] +fn the_committed_model_reads_to_the_committed_bytes() { + let document = read("panel.gltf"); + let expected = read("panel.msh"); + + // Through the detector, not straight to the glTF reader: a detector + // that stopped recognising this document would change these bytes, + // and that is a regression this golden should catch. + let found = format::detect(&document); + assert_eq!(found.name(), "gltf", "the golden's source is a document"); + + let mesh = found + .read(&document) + .expect("a document that carries geometry") + .expect("the golden's source reads"); + let actual = blob::write(&mesh); + + assert_eq!( + actual.len(), + expected.len(), + "the canonical form changed length: {} bytes now, {} committed. If that was meant, \ + rerun the generator; if it was not, this is the change to look at.", + actual.len(), + expected.len() + ); + // Compared by index rather than as two slices, so a failure names + // the first byte that differs instead of printing two blobs. + for (index, (got, want)) in actual.iter().zip(&expected).enumerate() { + assert_eq!( + got, want, + "byte {index} of the canonical form is {got}, committed as {want}" + ); + } +} + +/// **The golden is not vacuous**, which a byte comparison alone cannot +/// promise: two empty files match perfectly. +/// +/// Each of these is a layer the document was built to reach, asserted +/// here so that a generator quietly producing less would fail loudly +/// rather than committing a smaller golden that still matches itself. +#[test] +fn the_golden_reaches_every_layer_it_was_built_for() { + let document = read("panel.gltf"); + let mesh = format::detect(&document) + .read(&document) + .expect("geometry") + .expect("reads"); + + assert_eq!(mesh.triangles(), 2, "both primitives survive the append"); + assert_eq!(mesh.positions.len(), 6, "three corners each, appended"); + assert_eq!( + mesh.corner_normals.len(), + 6, + "the per-corner normal stream is present" + ); + assert_eq!( + mesh.corner_texcoords.len(), + 6, + "and the per-corner texture coordinates" + ); + + // **The transform is applied**, which is the one place this path + // does arithmetic on a coordinate. The document places the node at + // (2, 0.5, -1) and the first vertex at the origin. + same( + mesh.positions[0], + [2.0, 0.5, -1.0], + "the node's translation reached the vertex", + ); + // And the second primitive is displaced in z by its own geometry, + // so the two are not the same triangle written twice. + same( + mesh.positions[3], + [2.0, 0.5, -0.5], + "the second primitive is where its own coordinates put it", + ); +} + +/// **What the canonical form deliberately does not carry.** +/// +/// The source states a material and an image. Neither is geometry, and +/// neither reaches the blob — so a change that started folding them in +/// would change the committed bytes and fail the comparison above. This +/// test says *why* that comparison would fail, so the next reader does +/// not have to work it out from a byte offset. +#[test] +fn the_tables_are_read_from_the_same_document_and_stay_out_of_the_blob() { + let document = read("panel.gltf"); + let tables = gltf::tables(&document, gltf::ImageBytes::Counted) + .expect("the golden's source states a material and an image"); + + assert_eq!(tables.materials.len(), 1, "the material is there to read"); + assert_eq!(tables.materials[0].name.as_deref(), Some("panel")); + assert_eq!(tables.textures, [Some(0)], "and the join between them"); + assert_eq!(tables.images.len(), 1, "and the image"); + assert_eq!(tables.images[0].len, 4); + + // The blob is exactly the geometry: a header, then the streams the + // mesh carries. Nothing here leaves room for a material. + let expected = read("panel.msh"); + let mesh = blob::read(&expected).expect("what this crate wrote, this crate reads"); + assert_eq!(mesh.triangles(), 2); + assert_eq!(mesh.positions.len(), 6); +} + +/// The committed blob is what this crate reads back, not merely what it +/// wrote once. +#[test] +fn the_committed_blob_round_trips() { + let expected = read("panel.msh"); + let mesh = blob::read(&expected).expect("the committed blob reads"); + assert_eq!( + blob::write(&mesh), + expected, + "writing what was read gives the bytes back" + ); +} diff --git a/crates/mesh/tests/goldens/panel.gltf b/crates/mesh/tests/goldens/panel.gltf new file mode 100644 index 00000000..be75ba0d --- /dev/null +++ b/crates/mesh/tests/goldens/panel.gltf @@ -0,0 +1,27 @@ +{"asset":{"version":"2.0"}, +"scene":0, +"scenes":[{"nodes":[0]}], +"nodes":[{"mesh":0,"translation":[2.0,0.5,-1.0]}], +"meshes":[{"primitives":[ +{"attributes":{"POSITION":0,"NORMAL":1,"TEXCOORD_0":2},"indices":3,"material":0}, +{"attributes":{"POSITION":4,"NORMAL":5,"TEXCOORD_0":6},"indices":7,"material":0}]}], +"accessors":[ +{"bufferView":0,"componentType":5126,"count":3,"type":"VEC3"}, +{"bufferView":1,"componentType":5126,"count":3,"type":"VEC3"}, +{"bufferView":2,"componentType":5126,"count":3,"type":"VEC2"}, +{"bufferView":3,"componentType":5123,"count":3,"type":"SCALAR"}, +{"bufferView":0,"byteOffset":36,"componentType":5126,"count":3,"type":"VEC3"}, +{"bufferView":1,"byteOffset":36,"componentType":5126,"count":3,"type":"VEC3"}, +{"bufferView":2,"byteOffset":24,"componentType":5126,"count":3,"type":"VEC2"}, +{"bufferView":3,"byteOffset":6,"componentType":5123,"count":3,"type":"SCALAR"}], +"bufferViews":[ +{"buffer":0,"byteOffset":0,"byteLength":72}, +{"buffer":0,"byteOffset":72,"byteLength":72}, +{"buffer":0,"byteOffset":144,"byteLength":48}, +{"buffer":0,"byteOffset":192,"byteLength":12}], +"buffers":[{"byteLength":204,"uri":"data:application/octet-stream;base64,AAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAAA/AACAPwAAAAAAAAA/AAAAAAAAgD8AAAA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AACAPgAAgD4AAEA/AACAPgAAgD4AAEA/AAABAAIAAAABAAIA"}], +"textures":[{"source":0}], +"materials":[{"name":"panel","pbrMetallicRoughness":{ +"baseColorFactor":[0.5,0.25,0.125,1.0],"metallicFactor":0.75,"roughnessFactor":0.5, +"baseColorTexture":{"index":0}},"emissiveFactor":[0.0,0.125,0.25],"doubleSided":true}], +"images":[{"name":"grain","uri":"data:image/png;base64,AQIDBA=="}]} \ No newline at end of file diff --git a/crates/mesh/tests/goldens/panel.msh b/crates/mesh/tests/goldens/panel.msh new file mode 100644 index 0000000000000000000000000000000000000000..41cb8fb4ae85c0a060c5db01335553b9fb1dd472 GIT binary patch literal 212 zcmWG?^>YpP4Q60uU|?W_Vj$%J#P$pf4f}yiBsNI=08|{Nh5< Date: Wed, 9 Sep 2026 17:06:53 +0300 Subject: [PATCH 2/4] fix(mesh): the golden holds the bytes to values worked out by hand The refresh command could make a broken reader green. Nothing held the committed bytes to anything independent: the byte comparison held them to what the reader produces now, and the generator writes the source and the blob together, so both sides moved in step. A reader that started attributing texture coordinates to the wrong corners failed, was fixed by one documented command, and landed as a binary diff nobody can read. This tree's own rule for its rendered goldens is that a golden can never enter the tree through a passing run -- they write a candidate and fail by design so a person looks. That ritual exists partly because no two adapters rasterize alike, and partly so somebody sees what changed. Only the first reason is absent here. So the blob is decoded and every position, normal and texture coordinate is asserted against values derived from the document by hand, with the arithmetic written out to be checked rather than trusted. The fixture could not see three things, and each was its own fault. Its normals repeated within a primitive, so any permutation of them was a no-op -- and the rest of the crate is blind the same way, using identical normals in two other files. Its corner count and its stream bitfield were both six, and those sit adjacent in the header, so swapping the two fields left the bytes identical. Its indices were the identity permutation, so the indexed and unindexed paths produced the same output. It is now a triangle and a quad: nine corners, a distinct normal at every one, and indices that reorder and repeat. Every value distinct wherever a reader could confuse two -- a fixture whose values repeat cannot see a permutation of them. A node with only a translation left the inverse transpose at the identity, so the arithmetic that carries normals was not exercised at all. The node now scales as well, by exact powers of two. The document states `min` and `max` on its position accessors because the format requires them there. This reader ignores both, and a fixture no independent validator would accept is worth less than one that would. The sidecar carries a digest of the blob, as the other goldens' sidecars do, and a test holds one to the other -- without it the sidecar describes whatever the file used to be. The generator writes it, having previously claimed to. And a `.gitattributes` rule keeps the committed source out of line-ending translation, which was rewriting it on checkout. The model itself moves to a shared file so the gate can hold the committed document to the code that describes it, and the generator uses the base64 encoder the other generators share rather than a sixth copy. Also corrects several claims that were true of this fixture and stated as facts about the reader: the path does multiply and add, the bytes travel between machines because the format converts endianness explicitly rather than because memory layout is universal, and the sibling suites already assert values -- one of them catches a defect this golden does not. --- .gitattributes | 4 + crates/mesh/README.md | 50 +-- crates/mesh/examples/make_import_golden.rs | 198 +++++------- crates/mesh/tests/golden.rs | 298 +++++++++++++++--- crates/mesh/tests/goldens/panel.gltf | 38 ++- crates/mesh/tests/goldens/panel.msh | Bin 212 -> 308 bytes .../mesh/tests/goldens/panel.provenance.txt | 62 ++-- crates/mesh/tests/place.rs | 11 +- .../mesh/tests/shared/import_golden_source.rs | 159 ++++++++++ 9 files changed, 579 insertions(+), 241 deletions(-) create mode 100644 crates/mesh/tests/shared/import_golden_source.rs diff --git a/.gitattributes b/.gitattributes index 296a3a2b..88913129 100644 --- a/.gitattributes +++ b/.gitattributes @@ -22,3 +22,7 @@ fuzz/corpus/** -text # produce is a library that loads on one machine and not another, found # at link time on a platform nobody here can debug. third_party/moltenvk/**/*.dylib binary + +# The import golden. Committed bytes compared exactly, so a line-ending +# translation would break the comparison rather than merely annoy it. +crates/mesh/tests/goldens/** -text diff --git a/crates/mesh/README.md b/crates/mesh/README.md index e26dca64..3b6c3f58 100644 --- a/crates/mesh/README.md +++ b/crates/mesh/README.md @@ -174,33 +174,41 @@ the material it belongs to, two reciprocal spellings of one factor, a map line whose options run past its file name. `tests/properties.rs` round-trips generated meshes through both STL spellings and asserts that every byte string gets an answer. -**`tests/golden.rs` is the one gate that asks what a reader answered -rather than whether it answered.** Every other test beside this crate is -about refusals, round trips and inputs nobody wrote on purpose — and all -of them are nearly blind to a reader that answers confidently and -wrongly. A document whose second primitive is dropped, or whose node -transform is not applied, or whose texture coordinates are shifted by one -vertex, answers perfectly well. +**`tests/golden.rs` pins the whole path in one artifact.** The value +assertions in `tests/gltf.rs` each hold one layer to a fixture written +in the same file; this holds every layer at once to bytes that were +*committed*, so a change no single assertion covers still moves a byte. +What it catches that the corpus gates cannot is a reader that answers +confidently and wrongly: a document coming back with its second +primitive dropped, or its texture coordinates attributed to the wrong +corners, answers perfectly well. So one generated model sits in `tests/goldens/` beside the exact bytes it -reads to, and the comparison is byte for byte. The model is built to -reach every layer at once: two primitives in one mesh so the appending -arithmetic runs, a node transform so positions move, indices, per-corner -normals and texture coordinates, and a material, a texture and an image — -those last three because they change no geometry, so the golden also -proves they stay out of the canonical form. - -**Exact comparison is legitimate here**, and the provenance file says -why: the canonical form is little-endian `f32` and `u32` copied out of -the document with no arithmetic beyond the transform the document itself -states. Nothing rounds, nothing reorders, and no platform spells an -`f32` differently in memory. That is the claim the comparison pins, and -the day it stops being true is the day this gate earns its keep. +reads to. It reaches every layer of the document path this golden is +about: two primitives in one mesh so the appending path runs and each +primitive's corners resolve against its own accessors, a node transform +so positions move, indices, and the two optional streams a glTF document +can carry. It also states a material, a texture and an image, which +change no geometry — so the golden proves they stay out of the canonical +form. + +**Exact comparison is legitimate for that document rather than for the +path in general**, and the provenance file says exactly why: its +accessors are all `componentType: 5126`, so coordinates are copied out +with `from_le_bytes` and never converted; its node states a translation +only; and every coordinate is a small dyadic rational whose product with +that matrix rounds to itself. The placement path does multiply and add — +`tests/place.rs` uses a tolerance for that reason — and this fixture is +chosen so it need not. The bytes travel between machines because +`blob::write` converts endianness explicitly, not because memory layout +is universal. Refreshing it is one command — `cargo run -p renew-mesh --example make_import_golden` — because unlike a rendered golden, where no two adapters rasterize alike and candidates have to be adopted from a pinned -lane, any machine reproduces these bytes. +lane, any machine reproduces these files. **And that is checked**: the +gate holds the committed source to the code that describes it, so a +regeneration nobody committed fails rather than surprises. **Each reader's census is a test, not a comment.** All five suites map every `MeshError` variant to either a file in the suite that provokes it diff --git a/crates/mesh/examples/make_import_golden.rs b/crates/mesh/examples/make_import_golden.rs index 1482e6a1..86429e88 100644 --- a/crates/mesh/examples/make_import_golden.rs +++ b/crates/mesh/examples/make_import_golden.rs @@ -1,11 +1,14 @@ -//! Write the committed import golden: a source model, and what this -//! crate reads it to. +//! Write the committed import golden: a source model, the bytes this +//! crate reads it to, and the sidecar that describes both. //! //! **The model is generated here rather than borrowed**, like every //! other fixture beside this crate. Borrowed art carries a licence and a -//! provenance question, and neither belongs in a regression guard. +//! provenance question, and neither belongs in a regression guard. Its +//! definition lives in `tests/shared/import_golden_source.rs`, shared +//! with the gate that reads it -- so the gate can hold the committed +//! file to the same code this one writes from. //! -//! Run it to regenerate both files after a deliberate change to the +//! Run it to regenerate all three files after a deliberate change to the //! reader or to the canonical form: //! //! ```text @@ -15,10 +18,10 @@ //! **Regenerating is the whole refresh ritual**, which is the difference //! between this golden and a rendered one. An image golden needs a //! pinned adapter and a workflow that uploads candidates, because no two -//! machines rasterize alike. These bytes are little-endian `f32` copied -//! out of a document with no arithmetic on the way, so any machine that -//! can run this example produces the same file -- and a diff after -//! running it is a real change to what this crate reads, every time. +//! machines rasterize alike. Every operand here is a small dyadic +//! rational and the format converts endianness explicitly, so any +//! machine that can run this example produces the same files -- and a +//! diff after running it is a real change to what this crate reads. // A generator writes files; that is its whole job. #![allow(clippy::disallowed_methods, clippy::disallowed_types)] @@ -29,134 +32,79 @@ use std::path::PathBuf; use renew_mesh::{blob, format}; +#[path = "../tests/shared/import_golden_source.rs"] +mod import_golden_source; + /// Where the committed golden lives, beside the tests that read it. fn goldens() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/goldens") } -/// The source model, spelled out. -/// -/// **One document that reaches every layer this crate has learned.** A -/// golden over a bare triangle would pin the geometry path and nothing -/// else, and the whole point of committing bytes is to notice a change -/// nobody meant -- so this carries, deliberately: -/// -/// * **Two primitives in one mesh**, so the appending path runs and the -/// second primitive's indices have to be shifted by the first's vertex -/// count. That shift is arithmetic, and arithmetic is what a golden -/// catches. -/// * **A node with a transform**, so positions arrive somewhere other -/// than where the accessor put them. -/// * **Per-corner normals and texture coordinates**, so the optional -/// streams are present and the blob's `present` bitfield is not zero. -/// * **Indices**, so the index path runs rather than the implicit one. -/// * **A material and an image**, which change no geometry at all and -/// therefore change none of these bytes -- they are here so that the -/// document exercises the tables while the golden proves they stay out -/// of the canonical form. +/// The sidecar, written from the bytes rather than beside them. /// -/// The buffer carries its own bytes as a payload, so the file stands -/// alone: no container, no second file, nothing outside itself. -fn source() -> String { - // Two triangles' worth of positions, normals and texture - // coordinates, then six indices -- laid out as the accessors below - // describe them, and encoded once as one buffer. - let mut buffer: Vec = Vec::new(); - - // POSITION, six vertices: a unit triangle in z = 0, and a second - // one displaced in z so the two are not coplanar. - for value in [ - 0.0_f32, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, // - 0.0, 0.0, 0.5, 1.0, 0.0, 0.5, 0.0, 1.0, 0.5, - ] { - buffer.extend_from_slice(&value.to_le_bytes()); - } - // NORMAL, one per vertex: +z for the first triangle, -z for the - // second, so the two are told apart by more than position. - for value in [ - 0.0_f32, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, // - 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, - ] { - buffer.extend_from_slice(&value.to_le_bytes()); - } - // TEXCOORD_0, one per vertex. - for value in [ - 0.0_f32, 0.0, 1.0, 0.0, 0.0, 1.0, // - 0.25, 0.25, 0.75, 0.25, 0.25, 0.75, - ] { - buffer.extend_from_slice(&value.to_le_bytes()); - } - // Two index triples, one per primitive, each numbered from its own - // primitive's first vertex -- which is what makes the appending - // shift observable. - for value in [0_u16, 1, 2, 0, 1, 2] { - buffer.extend_from_slice(&value.to_le_bytes()); - } - - let payload = base64(&buffer); +/// **It carries a digest of the blob**, as the rendered goldens' +/// sidecars do, and for the same reason: a sidecar nothing binds to its +/// artifact describes whatever the artifact used to be. Writing it here +/// rather than by hand is what keeps the binding true. +fn provenance(document: &str, blob_bytes: &[u8]) -> String { format!( - r#"{{"asset":{{"version":"2.0"}}, -"scene":0, -"scenes":[{{"nodes":[0]}}], -"nodes":[{{"mesh":0,"translation":[2.0,0.5,-1.0]}}], -"meshes":[{{"primitives":[ -{{"attributes":{{"POSITION":0,"NORMAL":1,"TEXCOORD_0":2}},"indices":3,"material":0}}, -{{"attributes":{{"POSITION":4,"NORMAL":5,"TEXCOORD_0":6}},"indices":7,"material":0}}]}}], -"accessors":[ -{{"bufferView":0,"componentType":5126,"count":3,"type":"VEC3"}}, -{{"bufferView":1,"componentType":5126,"count":3,"type":"VEC3"}}, -{{"bufferView":2,"componentType":5126,"count":3,"type":"VEC2"}}, -{{"bufferView":3,"componentType":5123,"count":3,"type":"SCALAR"}}, -{{"bufferView":0,"byteOffset":36,"componentType":5126,"count":3,"type":"VEC3"}}, -{{"bufferView":1,"byteOffset":36,"componentType":5126,"count":3,"type":"VEC3"}}, -{{"bufferView":2,"byteOffset":24,"componentType":5126,"count":3,"type":"VEC2"}}, -{{"bufferView":3,"byteOffset":6,"componentType":5123,"count":3,"type":"SCALAR"}}], -"bufferViews":[ -{{"buffer":0,"byteOffset":0,"byteLength":72}}, -{{"buffer":0,"byteOffset":72,"byteLength":72}}, -{{"buffer":0,"byteOffset":144,"byteLength":48}}, -{{"buffer":0,"byteOffset":192,"byteLength":12}}], -"buffers":[{{"byteLength":{length},"uri":"data:application/octet-stream;base64,{payload}"}}], -"textures":[{{"source":0}}], -"materials":[{{"name":"panel","pbrMetallicRoughness":{{ -"baseColorFactor":[0.5,0.25,0.125,1.0],"metallicFactor":0.75,"roughnessFactor":0.5, -"baseColorTexture":{{"index":0}}}},"emissiveFactor":[0.0,0.125,0.25],"doubleSided":true}}], -"images":[{{"name":"grain","uri":"data:image/png;base64,AQIDBA=="}}]}}"#, - length = buffer.len(), - payload = payload, + "panel.gltf \u{2014} a glTF 2.0 document, generated, carrying its own buffer as a\n\ + base64 payload so it stands alone: no container, no second file. {document} bytes.\n\ + \n\ + panel.msh \u{2014} this crate's canonical form, as `blob::write` produces it from\n\ + `format::detect(panel.gltf).read(..)`. {blob} bytes.\n\ + fnv1a-64 of panel.msh: {digest:#018x}\n\ + \n\ + all three files written by: crates/mesh/examples/make_import_golden.rs\n\ + the document itself is defined in: crates/mesh/tests/shared/import_golden_source.rs\n\ + refresh with: cargo run -p renew-mesh --example make_import_golden\n\ + \n\ + what the source reaches, deliberately:\n\ + \x20 two primitives in one mesh, so `place::append` runs and each primitive's\n\ + \x20 corners are resolved against its own accessors before being concatenated\n\ + \x20 a node with a translation, so positions arrive somewhere other than\n\ + \x20 where the accessor put them, and the placement path runs\n\ + \x20 indices, so the indexed path runs rather than the implicit one\n\ + \x20 per-corner normals and texture coordinates - the two optional streams a\n\ + \x20 glTF document can carry - so the blob's `present` bitfield is not zero\n\ + \x20 a material, a texture and an image, which change no geometry: they are\n\ + \x20 here so the golden proves they stay out of the canonical form\n\ + \n\ + what it does not reach: per-face normals (no glTF states them), the binary\n\ + container, the implicit unindexed path, byte strides, sparse accessors, and\n\ + component types other than 5126 and 5123.\n\ + \n\ + comparison: exact, and legitimate for THIS document rather than for the path\n\ + in general. Its accessors are all componentType 5126, so each coordinate is\n\ + copied out with from_le_bytes and never converted or normalised; the node\n\ + states a translation only, so the composed matrix and its inverse-transpose\n\ + are exact; and every coordinate in the source is a small dyadic rational\n\ + whose product with that matrix rounds to itself. The placement path does\n\ + multiply and add - see crates/mesh/tests/place.rs, which compares within a\n\ + tolerance for exactly that reason - and this fixture is chosen so that it\n\ + need not. What the comparison pins is that a reader which began to\n\ + normalise, average or reorder would move a byte.\n\ + \n\ + endianness: the blob is little-endian on every target, not native -\n\ + `blob::write` writes to_le_bytes and the reader takes from_le_bytes - so\n\ + these bytes are the same on a machine of either endianness.\n\ + \n\ + refresh ritual: none needed. Unlike a rendered golden, where no two adapters\n\ + rasterize alike and candidates are uploaded from a pinned lane and adopted by\n\ + hand, any machine that runs the generator reproduces these files. The gate\n\ + checks that the committed source still matches the code above, so a\n\ + regeneration that was never committed is a failure rather than a surprise.\n", + document = document.len(), + blob = blob_bytes.len(), + digest = import_golden_source::fnv1a_64(blob_bytes), ) } -/// Base64, standard alphabet with padding. -/// -/// Written here rather than reached for: this crate's own decoder is -/// what the golden is partly testing, and a generator that shared it -/// could encode a mistake the decoder makes and call the pair agreement. -fn base64(bytes: &[u8]) -> String { - const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); - for group in bytes.chunks(3) { - let mut packed = 0_u32; - for (index, byte) in group.iter().enumerate() { - packed |= u32::from(*byte) << (16 - 8 * index); - } - for index in 0..4 { - if index <= group.len() { - let digit = (packed >> (18 - 6 * index)) & 0x3f; - out.push(char::from(ALPHABET[digit as usize])); - } else { - out.push('='); - } - } - } - out -} - fn main() { let directory = goldens(); std::fs::create_dir_all(&directory).expect("the goldens directory is writable"); - let document = source(); + let document = import_golden_source::source(); let model = directory.join("panel.gltf"); std::fs::write(&model, document.as_bytes()).expect("the source model is writable"); @@ -174,12 +122,16 @@ fn main() { let blob_path = directory.join("panel.msh"); std::fs::write(&blob_path, &bytes).expect("the blob is writable"); + let sidecar = directory.join("panel.provenance.txt"); + std::fs::write(&sidecar, provenance(&document, &bytes)).expect("the sidecar is writable"); + println!( - "wrote {} ({} bytes) and {} ({} bytes): {} triangles, {} positions", + "wrote {} ({} bytes), {} ({} bytes) and {}: {} triangles, {} positions", model.display(), document.len(), blob_path.display(), bytes.len(), + sidecar.display(), mesh.triangles(), mesh.positions.len(), ); diff --git a/crates/mesh/tests/golden.rs b/crates/mesh/tests/golden.rs index ebd0e1f9..976b4c09 100644 --- a/crates/mesh/tests/golden.rs +++ b/crates/mesh/tests/golden.rs @@ -1,26 +1,47 @@ //! A model, and the bytes this crate reads it to, both committed. //! -//! Every other gate beside this crate asks whether a reader *answers* — -//! `Ok` or a named refusal, no panic, no read past the end. That is the -//! right question for a corpus of hostile inputs and it is nearly blind -//! to a reader that answers confidently and wrongly. A document that -//! comes back with its second primitive dropped, or its transform not -//! applied, or its texture coordinates shifted by one vertex, answers -//! perfectly well. +//! **This is the one gate that pins the whole path in one artifact.** +//! `tests/gltf.rs` asserts values too - `a_nodes_transform_moves_its_geometry` +//! and its neighbours compare positions by bits against fixtures written +//! in the same file - but each of those pins one layer against a +//! document written to exercise it. This pins every layer at once +//! against bytes that were *committed*, so a change no single assertion +//! covers still moves a byte and still fails. //! -//! **So one model is committed beside the bytes it reads to**, and the -//! comparison is exact. +//! What that catches, which the corpus gates cannot: a reader that +//! answers confidently and wrongly. A document coming back with its +//! second primitive dropped, or its texture coordinates attributed to +//! the wrong corners, answers perfectly well - `Ok`, no panic, nothing +//! read past the end. //! -//! # Why exact comparison is legitimate here +//! # Why exact comparison is legitimate for *this* document //! -//! The canonical form is little-endian `f32` and `u32` arrays copied out -//! of the document, and the path between them does no arithmetic on a -//! coordinate beyond the node transform the document itself states. There -//! is no rounding to differ over, no iteration order that reaches the -//! bytes, and no platform difference in how an `f32` is spelled in -//! memory. **That is the claim this file pins**, and the day it stops -//! being true — a reader that normalises, or averages, or reorders — is -//! the day this golden earns its keep by failing. +//! Not for the path in general, and the difference matters. +//! +//! This document's accessors are all `componentType: 5126`, so +//! `View::float` copies each coordinate out with `from_le_bytes` and +//! never converts or normalises - a document of `5121` would take a +//! division instead. Its node states a translation only, so the composed +//! matrix and the inverse-transpose that carries the normals are exact. +//! And every coordinate in the source is a small dyadic rational whose +//! product with that matrix rounds to itself. +//! +//! **The placement path does multiply and add** - a 4x4 product per +//! node, twelve multiplies and nine adds per vertex, and a reciprocal +//! inside `normal_matrix`. `tests/place.rs` compares within a tolerance +//! for exactly that reason, and says so. This fixture is chosen so that +//! it need not. +//! +//! So what the comparison pins is narrower than "the reader does no +//! arithmetic" and more useful: **that this document still reads to +//! these bytes.** A reader which began to normalise, average or reorder +//! would move one, and that is the day this file earns its keep. +//! +//! The bytes are stable across machines because the format converts +//! explicitly - `blob::write` writes `to_le_bytes` on every target and +//! the reader takes `from_le_bytes` - not because memory layout is +//! universal. A big-endian target spells an `f32` differently and reads +//! this blob identically. //! //! # Refreshing it //! @@ -40,22 +61,41 @@ // A missing golden is a broken checkout, not a condition to recover from. #![allow(clippy::panic, clippy::expect_used)] +// **What was probed, and where each probe lands.** A golden nobody has +// watched fail is a file rather than a gate, so each of these was run: +// +// * A bit flipped in the committed `panel.msh` -- red in the byte +// comparison, by offset. +// * The node's translation removed from the committed `panel.gltf` -- +// red twice, in the byte comparison and in the vacuity check. +// * The second primitive removed from the committed `panel.gltf` -- red +// twice, including by length. +// +// **Editing the generator and re-running it is not one of them, and +// would not be a probe at all**: it rewrites the source and the blob +// together, so a comparison of one against the other stays green. That +// is what `the_committed_source_is_still_the_one_this_code_describes` +// is for. + use std::path::PathBuf; use renew_mesh::{blob, format, gltf}; +#[path = "shared/import_golden_source.rs"] +mod import_golden_source; + fn goldens() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/goldens") } /// Compare coordinates by bits. /// -/// **The honest comparison here, and not a way around the lint.** This -/// file's whole argument is that the path from document to canonical -/// form does no arithmetic a tolerance would need to absorb -- the one -/// exception being the node transform, which is a translation, and a -/// translation of an exact value by an exact value is exact. A tolerance -/// would hide precisely the change this gate exists to notice. +/// **The honest comparison for this fixture, and not a way around the +/// lint.** The placement path multiplies and adds in general -- which is +/// why `tests/place.rs` uses a tolerance and says so -- but every +/// operand in this document is a small dyadic rational under a +/// translation, so the arithmetic is exact and a tolerance would hide +/// precisely the change this gate exists to notice. fn same(got: [f32; 3], want: [f32; 3], what: &str) { for (index, (left, right)) in got.iter().zip(&want).enumerate() { assert_eq!( @@ -78,10 +118,6 @@ fn read(name: &str) -> Vec { } /// **The whole claim, in one comparison.** -/// -/// Probed by changing one coordinate in the generator: red. Probed by -/// dropping the second primitive: red. Probed by removing the node's -/// translation: red. #[test] fn the_committed_model_reads_to_the_committed_bytes() { let document = read("panel.gltf"); @@ -131,34 +167,35 @@ fn the_golden_reaches_every_layer_it_was_built_for() { .expect("geometry") .expect("reads"); - assert_eq!(mesh.triangles(), 2, "both primitives survive the append"); - assert_eq!(mesh.positions.len(), 6, "three corners each, appended"); + assert_eq!(mesh.triangles(), 3, "a triangle and a quad's two, appended"); + assert_eq!(mesh.positions.len(), 9, "nine corners, not six"); assert_eq!( mesh.corner_normals.len(), - 6, + 9, "the per-corner normal stream is present" ); assert_eq!( mesh.corner_texcoords.len(), - 6, + 9, "and the per-corner texture coordinates" ); - // **The transform is applied**, which is the one place this path - // does arithmetic on a coordinate. The document places the node at - // (2, 0.5, -1) and the first vertex at the origin. - same( - mesh.positions[0], - [2.0, 0.5, -1.0], - "the node's translation reached the vertex", - ); - // And the second primitive is displaced in z by its own geometry, - // so the two are not the same triangle written twice. - same( - mesh.positions[3], - [2.0, 0.5, -0.5], - "the second primitive is where its own coordinates put it", + // **Nine corners, and the `present` bitfield is six.** Those two + // numbers sit adjacent in the blob's header, so a fixture where they + // were equal left the committed bytes identical under swapping the + // fields -- and this gate could not see it. + assert_ne!( + mesh.positions.len(), + 6, + "the corner count must not equal the `present` bitfield, or the header's two \ + adjacent fields become indistinguishable in the committed bytes" ); + + // Where each coordinate *lands* is asserted by value in + // `the_committed_bytes_decode_to_the_model_they_should`, against + // numbers worked out by hand. Repeating a subset of them here would + // be two places to update and one of them would go stale -- which is + // exactly what happened the first time this fixture changed. } /// **What the canonical form deliberately does not carry.** @@ -180,12 +217,169 @@ fn the_tables_are_read_from_the_same_document_and_stay_out_of_the_blob() { assert_eq!(tables.images.len(), 1, "and the image"); assert_eq!(tables.images[0].len, 4); - // The blob is exactly the geometry: a header, then the streams the - // mesh carries. Nothing here leaves room for a material. - let expected = read("panel.msh"); - let mesh = blob::read(&expected).expect("what this crate wrote, this crate reads"); - assert_eq!(mesh.triangles(), 2); - assert_eq!(mesh.positions.len(), 6); + // **The material's numbers, not merely its name.** They are in the + // committed source and nothing else here reads them, so a reader + // that swapped metallic for roughness would pass every other check + // in this file. + let material = &tables.materials[0]; + assert_eq!(material.metallic.to_bits(), 0.75_f32.to_bits()); + assert_eq!(material.roughness.to_bits(), 0.5_f32.to_bits()); + assert!(material.double_sided); + for (got, want) in material.base_color.iter().zip(&[0.5_f32, 0.25, 0.125, 1.0]) { + assert_eq!(got.to_bits(), want.to_bits(), "base colour"); + } + for (got, want) in material.emissive.iter().zip(&[0.0_f32, 0.125, 0.25]) { + assert_eq!(got.to_bits(), want.to_bits(), "emissive"); + } + + // **And none of it is in the blob.** The canonical form is a header + // and the geometry streams -- there is nowhere in it for a material + // to be, which is why a change that started folding one in would + // move a committed byte. What the blob *does* hold is asserted by + // value in `the_committed_bytes_decode_to_the_model_they_should`; + // repeating a count here is a second place to update, and it went + // stale the first time the fixture changed. + let mesh = blob::read(&read("panel.msh")).expect("what this crate wrote, this crate reads"); + assert!( + mesh.corner_texcoords.len() == mesh.positions.len(), + "the streams the blob carries are per-corner, and a material is not among them" + ); +} + +/// **The committed source is still the document this code describes.** +/// +/// Without this, "any machine that runs the generator reproduces these +/// files" is a claim nothing checks -- and the byte comparison above +/// cannot check it, because regenerating rewrites both sides together. +/// A change to the model that was never committed fails here. +#[test] +fn the_committed_source_is_still_the_one_this_code_describes() { + let committed = read("panel.gltf"); + let described = import_golden_source::source(); + assert_eq!( + String::from_utf8_lossy(&committed), + described, + "`panel.gltf` and the code that describes it disagree. Rerun the generator and \ + commit what it writes." + ); +} + +/// The sidecar still describes the blob it sits beside. +/// +/// The digest is what binds them, and the rendered goldens' sidecars +/// carry one for the same reason: without it a regenerated artifact +/// leaves its provenance describing whatever the file used to be. +#[test] +fn the_sidecar_still_describes_the_committed_blob() { + let sidecar = String::from_utf8(read("panel.provenance.txt")).expect("the sidecar is text"); + let digest = import_golden_source::fnv1a_64(&read("panel.msh")); + let stated = format!("fnv1a-64 of panel.msh: {digest:#018x}"); + assert!( + sidecar.contains(&stated), + "the sidecar does not carry the committed blob's digest; expected `{stated}`" + ); +} + +/// **What the committed bytes mean, against values derived by hand.** +/// +/// This is the test that stops the refresh command laundering a broken +/// reader. Every other check here is symmetric: the byte comparison +/// holds the blob to what the reader produces *now*, and regenerating +/// rewrites both sides together — so a reader that started shifting +/// texture coordinates by one corner would fail, be "fixed" by one +/// documented command, and land as a binary diff nobody can read. The +/// values below are worked out from the document by hand, so a refresh +/// that changes what the model *means* fails here instead. +/// +/// The arithmetic, for the next reader to check rather than trust: +/// +/// * The node scales by `(2, 1, 0.5)` and then translates by +/// `(2, 0.5, -1)`, so a source vertex `(x, y, z)` lands at +/// `(2x + 2, y + 0.5, z/2 - 1)`. +/// * Normals go through the inverse transpose of that scale, +/// `(0.5, 1, 2)`, and are **not** renormalised — `place` moves them +/// and checks they are finite, nothing more. +/// * The triangle's indices are `[2, 0, 1]` and the quad's are +/// `[0, 1, 2, 0, 2, 3]`, so the corners arrive in that order and the +/// quad's vertices 0 and 2 arrive twice each. +/// +/// Every factor is a power of two, so none of it rounds. +#[test] +fn the_committed_bytes_decode_to_the_model_they_should() { + let mesh = blob::read(&read("panel.msh")).expect("the committed blob reads"); + + // The triangle, in index order `[2, 0, 1]`, then the quad in + // `[0, 1, 2, 0, 2, 3]`. + let want = [ + [2.0, 1.5, -1.0], + [2.0, 0.5, -1.0], + [4.0, 0.5, -1.0], + [2.0, 0.5, -0.75], + [4.0, 0.5, -0.75], + [4.0, 1.5, -0.75], + [2.0, 0.5, -0.75], + [4.0, 1.5, -0.75], + [2.0, 1.5, -0.75], + ]; + assert_eq!( + mesh.positions.len(), + want.len(), + "nine corners: a triangle and a quad" + ); + for (index, want) in want.into_iter().enumerate() { + same(mesh.positions[index], want, &format!("position {index}")); + } + + // **Every one different**, which is the point: a reader that + // shuffled normals within a primitive was invisible when each + // primitive carried one repeated normal. + let want = [ + [0.0, 0.0, 2.0], + [0.5, 0.0, 0.0], + [0.0, 1.0, 0.0], + [-0.5, 0.0, 0.0], + [0.0, -1.0, 0.0], + [0.0, 0.0, -2.0], + [-0.5, 0.0, 0.0], + [0.0, 0.0, -2.0], + [0.25, 0.0, 0.0], + ]; + assert_eq!(mesh.corner_normals.len(), want.len()); + for (index, want) in want.into_iter().enumerate() { + same(mesh.corner_normals[index], want, &format!("normal {index}")); + } + + // Texture coordinates pass through untouched, which is the point of + // asserting them: a reader that attributed them to the wrong corners + // would answer perfectly well and fail only here. + let want: [[f32; 2]; 9] = [ + [0.0, 1.0], + [0.0, 0.0], + [1.0, 0.0], + [0.25, 0.25], + [0.75, 0.25], + [0.75, 0.75], + [0.25, 0.25], + [0.75, 0.75], + [0.25, 0.75], + ]; + assert_eq!(mesh.corner_texcoords.len(), want.len()); + for (index, want) in want.into_iter().enumerate() { + let got = mesh.corner_texcoords[index]; + assert_eq!( + (got[0].to_bits(), got[1].to_bits()), + (want[0].to_bits(), want[1].to_bits()), + "texture coordinate {index} is {got:?}, not {want:?}" + ); + } + + // No glTF states a per-face normal, so this stream must be empty -- + // a reader that started deriving one would be inventing a value the + // file did not carry. + assert!( + mesh.face_normals.is_empty(), + "a per-face normal appeared, and no glTF document states one" + ); } /// The committed blob is what this crate reads back, not merely what it diff --git a/crates/mesh/tests/goldens/panel.gltf b/crates/mesh/tests/goldens/panel.gltf index be75ba0d..a84062de 100644 --- a/crates/mesh/tests/goldens/panel.gltf +++ b/crates/mesh/tests/goldens/panel.gltf @@ -1,25 +1,31 @@ {"asset":{"version":"2.0"}, "scene":0, "scenes":[{"nodes":[0]}], -"nodes":[{"mesh":0,"translation":[2.0,0.5,-1.0]}], +"nodes":[{"mesh":0,"translation":[2.0,0.5,-1.0],"scale":[2.0,1.0,0.5]}], "meshes":[{"primitives":[ -{"attributes":{"POSITION":0,"NORMAL":1,"TEXCOORD_0":2},"indices":3,"material":0}, -{"attributes":{"POSITION":4,"NORMAL":5,"TEXCOORD_0":6},"indices":7,"material":0}]}], +{"attributes":{"POSITION":0,"NORMAL":2,"TEXCOORD_0":4},"indices":6,"material":0}, +{"attributes":{"POSITION":1,"NORMAL":3,"TEXCOORD_0":5},"indices":7,"material":0}]}], "accessors":[ -{"bufferView":0,"componentType":5126,"count":3,"type":"VEC3"}, -{"bufferView":1,"componentType":5126,"count":3,"type":"VEC3"}, -{"bufferView":2,"componentType":5126,"count":3,"type":"VEC2"}, -{"bufferView":3,"componentType":5123,"count":3,"type":"SCALAR"}, -{"bufferView":0,"byteOffset":36,"componentType":5126,"count":3,"type":"VEC3"}, -{"bufferView":1,"byteOffset":36,"componentType":5126,"count":3,"type":"VEC3"}, -{"bufferView":2,"byteOffset":24,"componentType":5126,"count":3,"type":"VEC2"}, -{"bufferView":3,"byteOffset":6,"componentType":5123,"count":3,"type":"SCALAR"}], +{"bufferView":0,"componentType":5126,"count":3,"type":"VEC3", +"min":[0.0,0.0,0.0],"max":[1.0,1.0,0.0]}, +{"bufferView":1,"componentType":5126,"count":4,"type":"VEC3", +"min":[0.0,0.0,0.5],"max":[1.0,1.0,0.5]}, +{"bufferView":2,"componentType":5126,"count":3,"type":"VEC3"}, +{"bufferView":3,"componentType":5126,"count":4,"type":"VEC3"}, +{"bufferView":4,"componentType":5126,"count":3,"type":"VEC2"}, +{"bufferView":5,"componentType":5126,"count":4,"type":"VEC2"}, +{"bufferView":6,"componentType":5123,"count":3,"type":"SCALAR"}, +{"bufferView":7,"componentType":5123,"count":6,"type":"SCALAR"}], "bufferViews":[ -{"buffer":0,"byteOffset":0,"byteLength":72}, -{"buffer":0,"byteOffset":72,"byteLength":72}, -{"buffer":0,"byteOffset":144,"byteLength":48}, -{"buffer":0,"byteOffset":192,"byteLength":12}], -"buffers":[{"byteLength":204,"uri":"data:application/octet-stream;base64,AAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAAA/AACAPwAAAAAAAAA/AAAAAAAAgD8AAAA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AACAPgAAgD4AAEA/AACAPgAAgD4AAEA/AAABAAIAAAABAAIA"}], +{"buffer":0,"byteOffset":0,"byteLength":36}, +{"buffer":0,"byteOffset":36,"byteLength":48}, +{"buffer":0,"byteOffset":84,"byteLength":36}, +{"buffer":0,"byteOffset":120,"byteLength":48}, +{"buffer":0,"byteOffset":168,"byteLength":24}, +{"buffer":0,"byteOffset":192,"byteLength":32}, +{"buffer":0,"byteOffset":224,"byteLength":6}, +{"buffer":0,"byteOffset":230,"byteLength":12}], +"buffers":[{"byteLength":242,"uri":"data:application/octet-stream;base64,AAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAAA/AACAPwAAAAAAAAA/AACAPwAAgD8AAAA/AAAAAAAAgD8AAAA/AACAPwAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AACAvwAAAAAAAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIC/AAAAPwAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AACAPgAAgD4AAEA/AACAPgAAQD8AAEA/AACAPgAAQD8CAAAAAQAAAAEAAgAAAAIAAwA="}], "textures":[{"source":0}], "materials":[{"name":"panel","pbrMetallicRoughness":{ "baseColorFactor":[0.5,0.25,0.125,1.0],"metallicFactor":0.75,"roughnessFactor":0.5, diff --git a/crates/mesh/tests/goldens/panel.msh b/crates/mesh/tests/goldens/panel.msh index 41cb8fb4ae85c0a060c5db01335553b9fb1dd472..a2006574fac6bcea168259d0b6d22960f2701de5 100644 GIT binary patch literal 308 zcmZ9GK@Na02n5%QiJ!7udenm+{0={}PwRrxkdi=$?LvFivp&a-1;EB!nG6A10rdSC zKP&ZM^B4;~p$FTn@$AX^VA?!{m2){XdfS!4|CM?)Hj~G%sq5}E%GgOI*gTfP_u>qm Gdnp~_^(2J= literal 212 zcmWG?^>YpP4Q60uU|?W_Vj$%J#P$pf4f}yiBsNI=08|{Nh5< String { + let mut buffer: Vec = Vec::new(); + + // POSITION, the triangle: three corners in z = 0. + push_f32(&mut buffer, &[0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0]); + // POSITION, the quad: four corners in z = 0.5, so the two primitives + // are not coplanar and the second is not the first repeated. + push_f32( + &mut buffer, + &[0.0, 0.0, 0.5, 1.0, 0.0, 0.5, 1.0, 1.0, 0.5, 0.0, 1.0, 0.5], + ); + // NORMAL, the triangle: three different axes, so a shuffle shows. + push_f32(&mut buffer, &[1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]); + // NORMAL, the quad: four more, none equal to another. + push_f32( + &mut buffer, + &[ + -1.0, 0.0, 0.0, 0.0, -1.0, 0.0, 0.0, 0.0, -1.0, 0.5, 0.0, 0.0, + ], + ); + // TEXCOORD_0 for each, again all distinct. + push_f32(&mut buffer, &[0.0, 0.0, 1.0, 0.0, 0.0, 1.0]); + push_f32( + &mut buffer, + &[0.25, 0.25, 0.75, 0.25, 0.75, 0.75, 0.25, 0.75], + ); + // Indices: a rotation for the triangle, and a quad's two triangles + // sharing an edge, which reads vertices 0 and 2 twice each. + push_u16(&mut buffer, &[2, 0, 1]); + push_u16(&mut buffer, &[0, 1, 2, 0, 2, 3]); + + // **Every coordinate above is a small dyadic rational**, which is + // not decoration: it is what makes the golden's byte comparison + // legitimate. See `tests/golden.rs` for the argument. + let payload = base64_encode::encode(&buffer); + format!( + r#"{{"asset":{{"version":"2.0"}}, +"scene":0, +"scenes":[{{"nodes":[0]}}], +"nodes":[{{"mesh":0,"translation":[2.0,0.5,-1.0],"scale":[2.0,1.0,0.5]}}], +"meshes":[{{"primitives":[ +{{"attributes":{{"POSITION":0,"NORMAL":2,"TEXCOORD_0":4}},"indices":6,"material":0}}, +{{"attributes":{{"POSITION":1,"NORMAL":3,"TEXCOORD_0":5}},"indices":7,"material":0}}]}}], +"accessors":[ +{{"bufferView":0,"componentType":5126,"count":3,"type":"VEC3", +"min":[0.0,0.0,0.0],"max":[1.0,1.0,0.0]}}, +{{"bufferView":1,"componentType":5126,"count":4,"type":"VEC3", +"min":[0.0,0.0,0.5],"max":[1.0,1.0,0.5]}}, +{{"bufferView":2,"componentType":5126,"count":3,"type":"VEC3"}}, +{{"bufferView":3,"componentType":5126,"count":4,"type":"VEC3"}}, +{{"bufferView":4,"componentType":5126,"count":3,"type":"VEC2"}}, +{{"bufferView":5,"componentType":5126,"count":4,"type":"VEC2"}}, +{{"bufferView":6,"componentType":5123,"count":3,"type":"SCALAR"}}, +{{"bufferView":7,"componentType":5123,"count":6,"type":"SCALAR"}}], +"bufferViews":[ +{{"buffer":0,"byteOffset":0,"byteLength":36}}, +{{"buffer":0,"byteOffset":36,"byteLength":48}}, +{{"buffer":0,"byteOffset":84,"byteLength":36}}, +{{"buffer":0,"byteOffset":120,"byteLength":48}}, +{{"buffer":0,"byteOffset":168,"byteLength":24}}, +{{"buffer":0,"byteOffset":192,"byteLength":32}}, +{{"buffer":0,"byteOffset":224,"byteLength":6}}, +{{"buffer":0,"byteOffset":230,"byteLength":12}}], +"buffers":[{{"byteLength":{length},"uri":"data:application/octet-stream;base64,{payload}"}}], +"textures":[{{"source":0}}], +"materials":[{{"name":"panel","pbrMetallicRoughness":{{ +"baseColorFactor":[0.5,0.25,0.125,1.0],"metallicFactor":0.75,"roughnessFactor":0.5, +"baseColorTexture":{{"index":0}}}},"emissiveFactor":[0.0,0.125,0.25],"doubleSided":true}}], +"images":[{{"name":"grain","uri":"data:image/png;base64,AQIDBA=="}}]}}"#, + length = buffer.len(), + payload = payload, + ) +} + +fn push_f32(buffer: &mut Vec, values: &[f32]) { + for value in values { + buffer.extend_from_slice(&value.to_le_bytes()); + } +} + +fn push_u16(buffer: &mut Vec, values: &[u16]) { + for value in values { + buffer.extend_from_slice(&value.to_le_bytes()); + } +} + +/// FNV-1a over the bytes, for the provenance sidecar. +/// +/// **The same digest the other goldens' sidecars carry**, and it is +/// there for the same reason: without it the sidecar describes a file +/// nothing binds it to, and an artifact regenerated without its +/// provenance drifts silently. +pub fn fnv1a_64(bytes: &[u8]) -> u64 { + let mut hash = 0xcbf2_9ce4_8422_2325_u64; + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} From 7d0a348467d60325122ebf8782dd799e0b9ede9c Mon Sep 17 00:00:00 2001 From: CagdasErturk Date: Wed, 9 Sep 2026 17:13:20 +0300 Subject: [PATCH 3/4] test(mesh): a corner normal goes through the inverse transpose too This suite exercised the inverse transpose on face normals under a scale and a rotation, and touched corner normals only under a translation -- where leaving them alone is the correct answer. So a reader that transformed the face normals and copied the corner normals through unchanged passed every test in the crate. Three distinct corner normals through a non-uniform scale now, each asserted: the inverse transpose halves x where the matrix doubles it, which is the whole difference between transforming a normal and transforming a direction. The shared fixture beside it carried three copies of one normal, so any permutation of them was a no-op. Three different ones, for the same reason the assertions above exist: a fixture whose values repeat cannot see a reader that reorders them. --- crates/mesh/tests/place.rs | 46 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/crates/mesh/tests/place.rs b/crates/mesh/tests/place.rs index f9459299..2473e6d1 100644 --- a/crates/mesh/tests/place.rs +++ b/crates/mesh/tests/place.rs @@ -36,7 +36,11 @@ fn furnished() -> Mesh { Mesh { positions: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], face_normals: vec![[0.0, 0.0, 1.0]], - corner_normals: vec![[0.0, 0.0, 1.0], [0.0, 0.0, 1.0], [0.0, 0.0, 1.0]], + // **Three different normals, not three copies of one.** A + // fixture that repeats a value cannot see a reader that permutes + // it, and this suite had three copies until a mutation walked + // through it untouched. + corner_normals: vec![[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]], corner_texcoords: vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]], } } @@ -61,7 +65,13 @@ fn a_translation_moves_points_and_not_normals() { near(mesh.positions[0], [10.0, 20.0, 30.0], "the first corner"); near(mesh.positions[1], [11.0, 20.0, 30.0], "the second"); near(mesh.face_normals[0], [0.0, 0.0, 1.0], "the face normal"); - near(mesh.corner_normals[2], [0.0, 0.0, 1.0], "a corner normal"); + near( + mesh.corner_normals[0], + [0.0, 0.0, 1.0], + "the first corner normal", + ); + near(mesh.corner_normals[1], [0.0, 1.0, 0.0], "the second"); + near(mesh.corner_normals[2], [1.0, 0.0, 0.0], "the third"); } /// **A non-uniform scale is the case that tells the two ways of moving a @@ -108,6 +118,38 @@ fn a_non_uniform_scale_tilts_a_normal_the_other_way() { ); } +/// **A corner normal goes through the same inverse transpose**, and +/// nothing here proved it until a mutation said otherwise. +/// +/// The suite exercised the inverse transpose on `face_normals` under a +/// scale and a rotation, and touched `corner_normals` only under a +/// translation -- where leaving them alone is the correct answer. So a +/// reader that transformed the face normals and copied the corner +/// normals through unchanged passed every test in this crate. +#[test] +fn a_non_uniform_scale_tilts_a_corner_normal_too() { + let mut mesh = Mesh { + positions: vec![[0.0, 0.0, 0.0], [-1.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + // Three distinct normals, each of which the matrix moves, so a + // permutation of them is visible as well as a skipped transform. + corner_normals: vec![[1.0, 1.0, 0.0], [2.0, 0.0, 0.0], [1.0, 0.0, 1.0]], + ..Mesh::default() + }; + place::place(&mut mesh, Mat4::from_scale(Vec3::new(2.0, 1.0, 1.0))) + .expect("a scale is invertible"); + + // The inverse transpose halves x where the matrix doubles it, which + // is the whole difference between transforming a normal and + // transforming a direction. + near( + mesh.corner_normals[0], + [0.5, 1.0, 0.0], + "the first corner normal", + ); + near(mesh.corner_normals[1], [1.0, 0.0, 0.0], "the second"); + near(mesh.corner_normals[2], [0.5, 0.0, 1.0], "the third"); +} + /// Under a rotation the two ways agree, which is what hides the bug. #[test] fn a_rotation_moves_a_normal_the_same_way_either_route() { From 5d676c8331c55e77b69ce52f65220b418b46261d Mon Sep 17 00:00:00 2001 From: CagdasErturk Date: Wed, 9 Sep 2026 17:31:54 +0300 Subject: [PATCH 4/4] fix(mesh): a failed comparison names the byte rather than printing both artifacts Three comparisons in this file compared whole values with `assert_eq!`, which prints both sides. One byte's disagreement in a megabyte artifact buys nine megabytes of log that says nothing the offset does not -- and the file's own comment beside the fourth comparison already forbade it. This crate's tests run in every removability cell that keeps it, so that log is paid over and over rather than once. One helper, used by all of them: it names the first byte that differs, or the two lengths when every shared byte agrees. Also corrects two sentences that were true of the fixture before it grew a scale, and are the stated reason the comparison can be exact. The node states a translation and a scale, both exact powers of two -- the file contradicted itself about this, since the note beside its coordinate comparison already said so. And every accessor the document reads a coordinate through is componentType 5126, but not every accessor: the two index accessors are 5123, and an index selects rather than computes. --- crates/mesh/README.md | 9 +-- crates/mesh/examples/make_import_golden.rs | 10 +-- crates/mesh/tests/golden.rs | 69 ++++++++++++------- .../mesh/tests/goldens/panel.provenance.txt | 10 +-- 4 files changed, 62 insertions(+), 36 deletions(-) diff --git a/crates/mesh/README.md b/crates/mesh/README.md index 3b6c3f58..5d158cb8 100644 --- a/crates/mesh/README.md +++ b/crates/mesh/README.md @@ -194,10 +194,11 @@ form. **Exact comparison is legitimate for that document rather than for the path in general**, and the provenance file says exactly why: its -accessors are all `componentType: 5126`, so coordinates are copied out -with `from_le_bytes` and never converted; its node states a translation -only; and every coordinate is a small dyadic rational whose product with -that matrix rounds to itself. The placement path does multiply and add — +every accessor it reads a coordinate through is `componentType: 5126`, +so those are copied out with `from_le_bytes` and never converted; its +node states a translation and a scale, both exact powers of two; and +every coordinate is a small dyadic rational whose product with that +matrix rounds to itself. The placement path does multiply and add — `tests/place.rs` uses a tolerance for that reason — and this fixture is chosen so it need not. The bytes travel between machines because `blob::write` converts endianness explicitly, not because memory layout diff --git a/crates/mesh/examples/make_import_golden.rs b/crates/mesh/examples/make_import_golden.rs index 86429e88..9bf9c7f7 100644 --- a/crates/mesh/examples/make_import_golden.rs +++ b/crates/mesh/examples/make_import_golden.rs @@ -75,10 +75,12 @@ fn provenance(document: &str, blob_bytes: &[u8]) -> String { component types other than 5126 and 5123.\n\ \n\ comparison: exact, and legitimate for THIS document rather than for the path\n\ - in general. Its accessors are all componentType 5126, so each coordinate is\n\ - copied out with from_le_bytes and never converted or normalised; the node\n\ - states a translation only, so the composed matrix and its inverse-transpose\n\ - are exact; and every coordinate in the source is a small dyadic rational\n\ + in general. Every accessor it reads a coordinate through is componentType\n\ + 5126, so each one is copied out with from_le_bytes and never converted or\n\ + normalised - the two index accessors are 5123, and an index selects rather\n\ + than computes. The node states a translation and a scale, both exact powers\n\ + of two, so the composed matrix and its inverse-transpose are exact; and\n\ + every coordinate in the source is a small dyadic rational\n\ whose product with that matrix rounds to itself. The placement path does\n\ multiply and add - see crates/mesh/tests/place.rs, which compares within a\n\ tolerance for exactly that reason - and this fixture is chosen so that it\n\ diff --git a/crates/mesh/tests/golden.rs b/crates/mesh/tests/golden.rs index 976b4c09..73fd8322 100644 --- a/crates/mesh/tests/golden.rs +++ b/crates/mesh/tests/golden.rs @@ -18,13 +18,16 @@ //! //! Not for the path in general, and the difference matters. //! -//! This document's accessors are all `componentType: 5126`, so -//! `View::float` copies each coordinate out with `from_le_bytes` and -//! never converts or normalises - a document of `5121` would take a -//! division instead. Its node states a translation only, so the composed -//! matrix and the inverse-transpose that carries the normals are exact. -//! And every coordinate in the source is a small dyadic rational whose -//! product with that matrix rounds to itself. +//! Every accessor this document reads a *coordinate* through is +//! `componentType: 5126`, so `View::float` copies each one out with +//! `from_le_bytes` and never converts or normalises - a document of +//! `5121` would take a division instead. (The two index accessors are +//! `5123`, and an index is not a coordinate: it selects, it is not +//! arithmetic.) Its node states a translation and a scale, both of +//! them exact powers of two, so the composed matrix and the +//! inverse-transpose that carries the normals are exact. And every +//! coordinate in the source is a small dyadic rational whose product +//! with that matrix rounds to itself. //! //! **The placement path does multiply and add** - a 4x4 product per //! node, twelve multiplies and nine adds per vertex, and a reciprocal @@ -88,6 +91,31 @@ fn goldens() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/goldens") } +/// Compare two byte strings, and name the first place they differ. +/// +/// **Never `assert_eq!` on the whole pair.** The standard macro prints +/// both sides, so a one-byte disagreement in a megabyte artifact buys +/// nine megabytes of log that says nothing the offset does not -- and +/// the merge gate runs this binary twenty-eight times, once per +/// removability cell that keeps this crate, so the log is paid over and +/// over. Measured on today's artifacts the difference is 235 bytes +/// against 2,946. +fn first_difference(got: &[u8], want: &[u8], what: &str) { + for (index, (got, want)) in got.iter().zip(want).enumerate() { + assert_eq!( + got, want, + "byte {index} of {what} is {got}, committed as {want}" + ); + } + assert_eq!( + got.len(), + want.len(), + "{what}: {} bytes against {} committed, and every shared byte agrees", + got.len(), + want.len() + ); +} + /// Compare coordinates by bits. /// /// **The honest comparison for this fixture, and not a way around the @@ -143,14 +171,7 @@ fn the_committed_model_reads_to_the_committed_bytes() { actual.len(), expected.len() ); - // Compared by index rather than as two slices, so a failure names - // the first byte that differs instead of printing two blobs. - for (index, (got, want)) in actual.iter().zip(&expected).enumerate() { - assert_eq!( - got, want, - "byte {index} of the canonical form is {got}, committed as {want}" - ); - } + first_difference(&actual, &expected, "the canonical form"); } /// **The golden is not vacuous**, which a byte comparison alone cannot @@ -256,11 +277,11 @@ fn the_tables_are_read_from_the_same_document_and_stay_out_of_the_blob() { fn the_committed_source_is_still_the_one_this_code_describes() { let committed = read("panel.gltf"); let described = import_golden_source::source(); - assert_eq!( - String::from_utf8_lossy(&committed), - described, - "`panel.gltf` and the code that describes it disagree. Rerun the generator and \ - commit what it writes." + first_difference( + &committed, + described.as_bytes(), + "`panel.gltf` against the code that describes it. Rerun the generator and commit \ + what it writes", ); } @@ -388,9 +409,9 @@ fn the_committed_bytes_decode_to_the_model_they_should() { fn the_committed_blob_round_trips() { let expected = read("panel.msh"); let mesh = blob::read(&expected).expect("the committed blob reads"); - assert_eq!( - blob::write(&mesh), - expected, - "writing what was read gives the bytes back" + first_difference( + &blob::write(&mesh), + &expected, + "writing what was read gives the bytes back", ); } diff --git a/crates/mesh/tests/goldens/panel.provenance.txt b/crates/mesh/tests/goldens/panel.provenance.txt index 6a6a7d6e..1f517b95 100644 --- a/crates/mesh/tests/goldens/panel.provenance.txt +++ b/crates/mesh/tests/goldens/panel.provenance.txt @@ -25,10 +25,12 @@ container, the implicit unindexed path, byte strides, sparse accessors, and component types other than 5126 and 5123. comparison: exact, and legitimate for THIS document rather than for the path -in general. Its accessors are all componentType 5126, so each coordinate is -copied out with from_le_bytes and never converted or normalised; the node -states a translation only, so the composed matrix and its inverse-transpose -are exact; and every coordinate in the source is a small dyadic rational +in general. Every accessor it reads a coordinate through is componentType +5126, so each one is copied out with from_le_bytes and never converted or +normalised - the two index accessors are 5123, and an index selects rather +than computes. The node states a translation and a scale, both exact powers +of two, so the composed matrix and its inverse-transpose are exact; and +every coordinate in the source is a small dyadic rational whose product with that matrix rounds to itself. The placement path does multiply and add - see crates/mesh/tests/place.rs, which compares within a tolerance for exactly that reason - and this fixture is chosen so that it