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 2221cc06..5d158cb8 100644 --- a/crates/mesh/README.md +++ b/crates/mesh/README.md @@ -174,6 +174,43 @@ 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` 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. 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 +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 +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 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 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..9bf9c7f7 --- /dev/null +++ b/crates/mesh/examples/make_import_golden.rs @@ -0,0 +1,140 @@ +//! 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. 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 all three 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. 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)] +// 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}; + +#[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 sidecar, written from the bytes rather than beside them. +/// +/// **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!( + "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. 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\ + 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), + ) +} + +fn main() { + let directory = goldens(); + std::fs::create_dir_all(&directory).expect("the goldens directory is writable"); + + 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"); + + // **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"); + + let sidecar = directory.join("panel.provenance.txt"); + std::fs::write(&sidecar, provenance(&document, &bytes)).expect("the sidecar is writable"); + + println!( + "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 new file mode 100644 index 00000000..73fd8322 --- /dev/null +++ b/crates/mesh/tests/golden.rs @@ -0,0 +1,417 @@ +//! A model, and the bytes this crate reads it to, both committed. +//! +//! **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. +//! +//! 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 for *this* document +//! +//! Not for the path in general, and the difference matters. +//! +//! 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 +//! 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 +//! +//! ```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)] + +// **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 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 +/// 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!( + 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.** +#[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() + ); + first_difference(&actual, &expected, "the canonical form"); +} + +/// **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(), 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(), + 9, + "the per-corner normal stream is present" + ); + assert_eq!( + mesh.corner_texcoords.len(), + 9, + "and the per-corner texture coordinates" + ); + + // **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.** +/// +/// 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 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(); + first_difference( + &committed, + described.as_bytes(), + "`panel.gltf` against the code that describes it. 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 +/// wrote once. +#[test] +fn the_committed_blob_round_trips() { + let expected = read("panel.msh"); + let mesh = blob::read(&expected).expect("the committed blob reads"); + first_difference( + &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..a84062de --- /dev/null +++ b/crates/mesh/tests/goldens/panel.gltf @@ -0,0 +1,33 @@ +{"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":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, +"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 00000000..a2006574 Binary files /dev/null and b/crates/mesh/tests/goldens/panel.msh differ diff --git a/crates/mesh/tests/goldens/panel.provenance.txt b/crates/mesh/tests/goldens/panel.provenance.txt new file mode 100644 index 00000000..1f517b95 --- /dev/null +++ b/crates/mesh/tests/goldens/panel.provenance.txt @@ -0,0 +1,48 @@ +panel.gltf — a glTF 2.0 document, generated, carrying its own buffer as a +base64 payload so it stands alone: no container, no second file. 2046 bytes. + +panel.msh — this crate's canonical form, as `blob::write` produces it from +`format::detect(panel.gltf).read(..)`. 308 bytes. +fnv1a-64 of panel.msh: 0xfb501a24ba23b667 + +all three files written by: crates/mesh/examples/make_import_golden.rs +the document itself is defined in: crates/mesh/tests/shared/import_golden_source.rs +refresh with: cargo run -p renew-mesh --example make_import_golden + +what the source reaches, deliberately: + two primitives in one mesh, so `place::append` runs and each primitive's + corners are resolved against its own accessors before being concatenated + a node with a translation, so positions arrive somewhere other than + where the accessor put them, and the placement path runs + indices, so the indexed path runs rather than the implicit one + per-corner normals and texture coordinates - the two optional streams a + glTF document can carry - so the blob's `present` bitfield is not zero + a material, a texture and an image, which change no geometry: they are + here so the golden proves they stay out of the canonical form + +what it does not reach: per-face normals (no glTF states them), the binary +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. 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 +need not. What the comparison pins is that a reader which began to +normalise, average or reorder would move a byte. + +endianness: the blob is little-endian on every target, not native - +`blob::write` writes to_le_bytes and the reader takes from_le_bytes - so +these bytes are the same on a machine of either endianness. + +refresh ritual: none needed. Unlike a rendered golden, where no two adapters +rasterize alike and candidates are uploaded from a pinned lane and adopted by +hand, any machine that runs the generator reproduces these files. The gate +checks that the committed source still matches the code above, so a +regeneration that was never committed is a failure rather than a surprise. diff --git a/crates/mesh/tests/place.rs b/crates/mesh/tests/place.rs index c228217c..2473e6d1 100644 --- a/crates/mesh/tests/place.rs +++ b/crates/mesh/tests/place.rs @@ -15,10 +15,13 @@ use renew_mesh::{Mesh, MeshError, place}; /// Compare coordinates by value, within the tolerance a transform earns. /// -/// **Not by bits here, unlike the assembly suite**: placement multiplies -/// and adds, so the result is the arithmetic's, not the file's, and an -/// exact comparison would be asserting something about rounding rather -/// than about the transform. +/// **Not by bits here, unlike the assembly suite and the import +/// golden**: those compare fixtures whose operands make the arithmetic +/// exact, and this suite deliberately uses a non-uniform scale and a +/// rotation, where it is not. Placement multiplies and adds, so the +/// result is the arithmetic's rather than the file's, and an exact +/// comparison would be asserting something about rounding rather than +/// about the transform. fn near(got: [f32; 3], want: [f32; 3], what: &str) { for (index, (left, right)) in got.iter().zip(&want).enumerate() { assert!( @@ -33,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]], } } @@ -58,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 @@ -105,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() { diff --git a/crates/mesh/tests/shared/import_golden_source.rs b/crates/mesh/tests/shared/import_golden_source.rs new file mode 100644 index 00000000..1fb91d12 --- /dev/null +++ b/crates/mesh/tests/shared/import_golden_source.rs @@ -0,0 +1,159 @@ +//! The import golden's source model, written once for the two targets +//! that need it. +//! +//! **Included by `#[path]` from both sides of the golden**, because +//! Cargo compiles a `tests/` subdirectory for nobody: +//! +//! * `crates/mesh/examples/make_import_golden.rs` writes it to disk. +//! * `crates/mesh/tests/golden.rs` holds the committed file to it. +//! +//! That second consumer is the point. A generator that writes both the +//! source and the bytes it reads to will happily rewrite them together, +//! so a golden compared only against its own regeneration proves the +//! reader agrees with itself and nothing more. + +#[path = "base64_encode.rs"] +mod base64_encode; + +/// The source model, spelled out. +/// +/// **Every value in it is distinct wherever a reader could confuse +/// two.** That is the whole design rule here, and it was learned the +/// hard way: an earlier version gave each primitive one repeated normal, +/// and a reader that shuffled normals within a primitive was then +/// invisible — to this golden and to every other test in the crate. +/// A fixture whose values repeat cannot see a permutation of them. +/// +/// So, deliberately: +/// +/// * **Two primitives, of different sizes** — a triangle and a quad, so +/// the corner count is nine. Nine is not the `present` bitfield's +/// value, which matters because the two sit adjacent in the blob's +/// header: when both were six, swapping the fields left the committed +/// bytes identical and the golden could not see it. +/// * **Non-identity indices**, `[2,0,1]` and `[0,1,2,0,2,3]`. With +/// `[0,1,2]` the indexed and unindexed paths produce the same bytes, +/// so "the indexed path runs" was true structurally and invisible +/// observationally. The quad's list also reads two of its vertices +/// twice, which nothing else here does. +/// * **A distinct normal at every corner**, so which normal lands where +/// is observable at all. +/// * **A node with a translation and a non-uniform scale**, so positions +/// move *and* normals go through the inverse transpose. A translation +/// alone leaves that matrix the identity — which would have pinned the +/// placement path while leaving the arithmetic that made `renew-math` +/// this crate's first dependency untested. Both factors are exact +/// powers of two, so the exactness argument survives. +/// * **Per-corner normals and texture coordinates**, the two optional +/// streams a glTF document can carry, so the blob's `present` bitfield +/// is not zero. The third, per-face normals, no glTF states. +/// * **A material, a texture and an image**, which change no geometry at +/// all — they are here so the document exercises those 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. The +/// `POSITION` accessors state `min` and `max` because the format +/// requires them there — this reader ignores both, and a committed +/// fixture that no independent validator would accept is worth less +/// than one that would. +pub fn source() -> 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 +}