Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion coverage-exemptions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ reason = "The arm that clears a voice whose sound index is missing. A voice is o

[[exempt]]
file = "tools/cli/src/main.rs"
lines = [1766, 1767, 1769, 1770, 1771, 1772, 1773, 1784, 1788, 1789, 1790, 1792, 1796, 1810, 1811, 1812, 1814, 1815, 1816, 1817, 1818, 1819, 1820, 1821, 1822, 1823, 1824, 1825, 1827, 1831, 1832, 1855, 1873, 1874, 1875, 1876, 1883, 1884, 1885, 1886, 1887, 1888, 1889, 1892, 1896, 1897, 1898, 1902, 2282, 2283, 2284, 2285]
lines = [2219, 2220, 2222, 2223, 2224, 2225, 2226, 2237, 2241, 2242, 2243, 2245, 2249, 2263, 2264, 2265, 2267, 2268, 2269, 2270, 2271, 2272, 2273, 2274, 2275, 2276, 2277, 2278, 2280, 2284, 2285, 2308, 2326, 2327, 2328, 2329, 2336, 2337, 2338, 2339, 2340, 2341, 2342, 2345, 2349, 2350, 2351, 2355, 2735, 2736, 2737, 2738]
reason = "The determinism emit half past its first child, plus the two arms that answer for a target this process is not running on. Reaching the emit arms from a test would mean building and running pinned runs under instrumentation: the report-reading arms need the first pinned run (renew-ui, no arguments) to compile and succeed, and the leg construction and write at the tail need all eleven - spread across six workspace packages, four of them samples and two engine crates - to succeed. The success path is not untested: the three determinism legs execute it on Linux, Windows and macOS on every push, and a failure there is what a broken emit looks like. Everything testable without a subprocess has been moved out - digests_from_output has seven cases beside it, digest_name is the one spelling both sides call, emit_note is unit-tested on both branches, pinned_invocation asserts the --target pass-through with no device, and the emit-red path is driven end to end by tests/targets.rs in both output modes. What is left is process orchestration, the leg written when every child succeeded, and the arms no gating push can reach: a compiler that answered and failed when asked its own version, a child that cannot start at all, a child whose report is unreadable, a leg file that cannot be written, one pinned run claiming a digest name another already used, and a working directory that has ceased to exist beneath the process. The --target arms are their own case: the refusal for a triple the table cannot name is reached by nothing at all, because every lane passes either a known triple or none, and it exists so a target added to CI and forgotten in that table fails loudly instead of emitting a leg labelled by a guess; the arm that labels a leg from its triple is executed only by the Android emulator lane, which is advisory and cannot redden main, so it is held by a lane whose red only a reader sees - which is the honest description until that row is signed and the lane gates."

[[exempt]]
Expand Down
22 changes: 13 additions & 9 deletions crates/mesh/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ the 25 STL seeds, `examples/make_ply_corpus.rs` the 24 PLY ones,
`examples/make_mtl_corpus.rs` the 21 MTL ones and
`examples/make_glb_corpus.rs` the 20 container ones and
`examples/make_accessor_corpus.rs` the 21 accessor ones and
`examples/make_gltf_corpus.rs` the 43 document-and-container ones; between them
`examples/make_gltf_corpus.rs` the 44 document-and-container ones; between them
every committed seed is built here rather than found. **The blob's 25 seeds
need no such argument at all**, because the format is this crate's own
and `examples/make_blob_corpus.rs` gets every byte from `blob::write`. **For OBJ that rule bites
Expand Down Expand Up @@ -372,14 +372,18 @@ between them would be answering a question the document did not settle.
A `uri` is a payload this reader decodes or a second file it will not
open — the same pair of answers a buffer's `uri` gets.

**One name for the bytes, not two.** An image may state its type in
`mimeType`, in its payload's URI, or in both; the format requires one
beside a view, because a view carries bytes and nothing about them. When
both are present and differ, the document is refused rather than the
contradiction being handed on — the same trade the payload decoder
makes when it refuses a resource two texts could spell. An absent type is
not a disagreement: a URI may omit one, and that is the document having
said nothing rather than having said something else.
**`mimeType` wins where a document states one, and the two labels are
never compared** — the format relates neither to the other, and an
absence on either side reads as an absence rather than as an empty
string. The reasoning is on `gltf::Image::media_type`, which is where it
belongs and where it will stay correct.

`gltf::tables` reads the materials and the images together from either
shape of the asset, doing the container dispatch that `gltf::read` does
for geometry. Its images own their bytes, because the parsed document
lives inside the call and nothing pointing into it can be handed back; a
caller that wants to avoid that copy holds the parse itself and calls
`gltf::images`.

## `data:` URIs, and why a decoder is strict about spelling

Expand Down
13 changes: 13 additions & 0 deletions crates/mesh/examples/make_gltf_corpus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,19 @@ fn image_seeds() -> Vec<(String, Vec<u8>)> {
"image-payload-broken".to_owned(),
with(r#"[{"uri":"data:image/png;base64,AQID!A=="}]"#),
),
// **Several images over one view, which is the shape that
// amplifies.** Nothing in the format says two images may not
// name the same region, so the entries are cheap to write and
// each one costs a copy to hold. Kept as a seed because the
// arithmetic it provokes is not reachable from any other shape
// here.
(
"image-shared-view".to_owned(),
stored(
r#"[{"bufferView":0,"mimeType":"image/png"},{"bufferView":0,"mimeType":"image/png"},
{"bufferView":0,"mimeType":"image/jpeg"},{"bufferView":0,"mimeType":"image/png"}]"#,
),
),
// A second file, which this crate will not open.
(
"image-names-a-file".to_owned(),
Expand Down
31 changes: 31 additions & 0 deletions crates/mesh/src/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,37 @@ impl Format {
Self::Mtl => None,
}
}

/// Read what this format says beyond its geometry, or `None` if it
/// says nothing this crate has a vocabulary for.
///
/// **`None` is not an error, and it is not "no materials".** It is
/// "this format does not state them in the vocabulary this returns".
/// OBJ and MTL do carry materials, in a different model entirely --
/// Wavefront's, which `mtl::read` answers with and which is not
/// convertible into this one without inventing values. A caller that
/// reports an empty table for an OBJ would be saying something false
/// about the file.
///
/// Exhaustive on purpose, like [`read`](Self::read): a format added
/// to this enum has to answer this question before it compiles,
/// which is the compile-time check a wildcard would throw away.
///
/// `wanted` decides whether an image's bytes are copied out of the
/// document or only measured; see [`gltf::ImageBytes`].
#[must_use]
pub fn tables(
self,
bytes: &[u8],
wanted: gltf::ImageBytes,
) -> Option<Result<gltf::Tables, MeshError>> {
match self {
Self::Glb | Self::Gltf => Some(
gltf::tables(bytes, wanted).map_err(|refusal| MeshError::Gltf(Box::new(refusal))),
),
Self::Obj | Self::Mtl | Self::Stl | Self::Ply | Self::Blob => None,
}
}
}

/// Which format these bytes are.
Expand Down
194 changes: 186 additions & 8 deletions crates/mesh/src/gltf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,12 +132,16 @@ pub enum GltfError {
count: usize,
},

/// A buffer this reader will not go and get.
/// A resource this reader will not go and get: a buffer, or an image.
///
/// Refused rather than ignored: a document whose geometry lives in a
/// second file describes a model this cannot assemble, and returning
/// what it *can* assemble would be returning half a model without
/// saying so.
/// saying so. The same holds for an image, which is why the two
/// share a refusal -- but **the caller's answer differs**, because a
/// missing texture leaves a whole model where a missing buffer
/// leaves none, so a caller that only wanted geometry is entitled to
/// carry on past this one.
ExternalResource,

/// A payload embedded in the document that will not decode.
Expand Down Expand Up @@ -316,7 +320,7 @@ impl core::fmt::Display for GltfError {
} => write!(f, "`{table}[{index}]` of a table holding {count}"),
Self::ExternalResource => write!(
f,
"this document keeps its geometry somewhere else, and this reader takes bytes"
"this document keeps a resource somewhere else, and this reader takes bytes"
),
Self::Payload(refusal) => write!(f, "an embedded payload will not decode: {refusal}"),
Self::BufferWithoutSource { buffer } => write!(
Expand Down Expand Up @@ -704,11 +708,13 @@ pub struct Image<'a> {
/// document's answer, and the URI's is what is left when it gives
/// none.
///
/// **`None` is the document having said nothing**, which is not the
/// same as `Some("")` — a URI may carry an empty media type, and
/// the decoder below reports that rather than applying RFC 2397's
/// default, so that a caller needing an explicit type can see there
/// was none.
/// **`None` is the document having said nothing, whichever side said
/// it.** A `mimeType` written `""` and a payload carrying no type at
/// all both normalise to `None`, so this is never `Some("")` — a
/// value every caller would otherwise have to know to treat as
/// absent. The decoder below reports a payload's missing type as an
/// empty string rather than applying RFC 2397's default, and this is
/// where that becomes an absence.
///
/// **Reported, never judged.** Which types are readable is a fact
/// about what the caller is doing with the bytes, and this layer
Expand Down Expand Up @@ -878,6 +884,178 @@ pub fn images<'s>(root: Value<'_>, source: &'s Source<'_>) -> Result<Vec<Image<'
Ok(out)
}

/// What a document says beyond its geometry.
///
/// **Two tables that travel together because one caller wants both.**
/// A tool reporting what it imported needs the materials and the images
/// at once, and the alternative -- asking for each separately -- makes
/// the caller build the container dispatch and the buffer table twice.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Tables {
/// Every material, in the vocabulary the format states them in.
pub materials: Vec<Material>,
/// Which image each texture draws its bytes from, where it says.
///
/// **The step between a material and an image, which is a step.** A
/// material names a *texture*, and a texture names a *source* — so a
/// caller holding a material's `TextureRef` and a list of images
/// cannot pair them without this. `source` is optional in the
/// format, because an extension may supply the image instead, and a
/// texture that names none reads as `None` rather than as zero.
pub textures: Vec<Option<u32>>,
/// Every image the document carries.
pub images: Vec<Stored>,
}

/// An image a caller can hold after the document is gone.
///
/// **Separate from [`Image`] because owning is a different thing from
/// borrowing, not a mode of it.** An `Image` points into the document
/// that produced it and costs nothing; this outlives that document, and
/// for an image stored in a `bufferView` that means a copy. Making it a
/// second type rather than a flag on the first keeps the cost where a
/// reader can see it.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Stored {
/// What the document called it, if it called it anything.
pub name: Option<String>,
/// The media type the document states for these bytes, if it states
/// one. See [`Image::media_type`] for how the two possible
/// statements are reconciled.
pub media_type: Option<String>,
/// How long the image is, whether or not its bytes were kept.
///
/// **The length is free and the bytes are not.** A caller reporting
/// what a model carries wants this and nothing else, so it is stated
/// separately rather than being read off a `bytes` that may not be
/// there.
pub len: usize,
/// The bytes, when [`ImageBytes::Kept`] asked for them.
pub bytes: Option<Vec<u8>>,
}

/// Whether a caller wants an image's bytes or only the fact of it.
///
/// **A copy is the only way out of the borrow, so it has to be asked
/// for.** An image read from a `bufferView` points into the document,
/// and a `Tables` outlives the parse that produced it -- so keeping the
/// bytes means copying them. A caller reporting what a model carries
/// needs the name, the type and the length, and none of those need the
/// bytes; a caller writing files needs all of it.
///
/// Stated at the call site rather than inferred, because the cost is
/// the whole cost of the call: on a four-megabyte texture the copy
/// measured at 99% of the time and 99.9% of the bytes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ImageBytes {
/// Copy them, because they are going somewhere.
Kept,
/// Report the length and drop them.
Counted,
}

/// Read what a document says beyond its geometry, in either shape.
///
/// **The sibling of [`read`], and it exists for the same reason.** A
/// binary glTF wraps its document in a container beside a chunk; a
/// `.gltf` is that document on its own, and a caller that wants the
/// tables out of either without holding the parse would otherwise write
/// that dispatch itself.
///
/// It does not replace [`images`] for a caller that can hold the parse:
/// the images here own their bytes, so which source each came from is
/// no longer visible in the value.
///
/// **The images own their bytes**, which a borrowed form could not: the
/// parsed document lives inside this call and cannot be handed back
/// beside things that point into it. A caller that wants to avoid the
/// copy has [`images`] and can hold the parse itself.
///
/// # Errors
///
/// A [`GltfError`] naming the layer that refused and carrying its
/// numbers.
pub fn tables(bytes: &[u8], wanted: ImageBytes) -> Result<Tables, GltfError> {
let (document, chunk) = if glb::looks_like(bytes) {
let container = glb::read(bytes).map_err(GltfError::Container)?;
(container.json, container.binary)
} else {
(bytes, None)
};

let json = parse(document)?;
let root = json.root();
let source = Source::of(root, chunk)?;
// **Owning the bytes is the one thing here that can amplify**, and
// it is bounded by the same ceiling geometry and materials answer
// to. An image from a `bufferView` borrows until this line; a
// document that points a thousand images at one shared megabyte
// costs two bytes an entry to write and a gigabyte to hold, which
// is the shape the rest of this crate already refuses.
let mut held = 0_usize;
let mut owned = Vec::new();
for image in images(root, &source)? {
let len = image.bytes.len();
let bytes = match wanted {
ImageBytes::Counted => None,
ImageBytes::Kept => {
// **The one line here that can amplify, and the ceiling
// it answers to.** Nothing says two images must name two
// views: a document may point a thousand of them at one
// shared megabyte, paying about thirty bytes an entry to
// do it. Measured before this existed, that reached
// nearly three thousand times the input and grew as its
// square.
crate::refuse_over_image_ceiling(held, len).map_err(GltfError::Geometry)?;
held += len;
Some(image.bytes.into_owned())
}
};
owned.push(Stored {
name: image.name,
media_type: image.media_type,
len,
bytes,
});
}

Ok(Tables {
materials: materials(root)?,
textures: textures(root)?,
images: owned,
})
}

/// Which image each texture draws its bytes from.
///
/// **Only `source`, because that is the only member anything here can
/// follow.** A texture also names a sampler, and a sampler is filtering
/// and wrapping — facts for whoever draws with it, and nothing this
/// crate has a home for yet.
///
/// # Errors
///
/// A [`GltfError`]: `Document` for a table or an entry of the wrong
/// kind, or a `source` that is not a number.
pub fn textures(root: Value<'_>) -> Result<Vec<Option<u32>>, GltfError> {
let Some(table) = root.get("textures") else {
return Ok(Vec::new());
};

let mut out = Vec::new();
for entry in table.elements().map_err(GltfError::Document)? {
// An object, for the reason every other table checks: every
// member of a number answers absent, so a texture written `5`
// would read as one naming no source.
entry.entries().map_err(GltfError::Document)?;
out.push(match entry.get("source") {
None => None,
Some(source) => Some(source.as_u32()?),
});
}
Ok(out)
}

/// Read the document's materials, in the vocabulary glTF states them.
///
/// A material object has no required members, so an empty one is legal
Expand Down
Loading
Loading