From 8f3e83aaeeba38d7077088fb045eac4891470a04 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 13:04:03 -0500 Subject: [PATCH] Add craft-mesh: STL, OBJ/MTL, PLY and glTF/GLB behind one neutral mesh model (#819) * Add craft-mesh: neutral mesh model with STL, OBJ/MTL and PLY * craft-mesh: glTF 2.0 and GLB * craft-mesh: README, crate list entry, a test for the UV set bound --- Cargo.lock | 7 + Cargo.toml | 1 + README.md | 4 + crates/mesh/Cargo.toml | 21 + crates/mesh/README.md | 149 +++++ crates/mesh/src/gltf.rs | 1057 +++++++++++++++++++++++++++++++++ crates/mesh/src/lib.rs | 711 ++++++++++++++++++++++ crates/mesh/src/obj.rs | 571 ++++++++++++++++++ crates/mesh/src/ply.rs | 529 +++++++++++++++++ crates/mesh/src/stl.rs | 289 +++++++++ crates/mesh/src/tests.rs | 163 +++++ crates/mesh/src/tests/gltf.rs | 718 ++++++++++++++++++++++ crates/mesh/src/tests/obj.rs | 283 +++++++++ crates/mesh/src/tests/ply.rs | 195 ++++++ crates/mesh/src/tests/stl.rs | 158 +++++ 15 files changed, 4856 insertions(+) create mode 100644 crates/mesh/Cargo.toml create mode 100644 crates/mesh/README.md create mode 100644 crates/mesh/src/gltf.rs create mode 100644 crates/mesh/src/lib.rs create mode 100644 crates/mesh/src/obj.rs create mode 100644 crates/mesh/src/ply.rs create mode 100644 crates/mesh/src/stl.rs create mode 100644 crates/mesh/src/tests.rs create mode 100644 crates/mesh/src/tests/gltf.rs create mode 100644 crates/mesh/src/tests/obj.rs create mode 100644 crates/mesh/src/tests/ply.rs create mode 100644 crates/mesh/src/tests/stl.rs diff --git a/Cargo.lock b/Cargo.lock index 213a2ce3..60473316 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1054,6 +1054,13 @@ dependencies = [ "objc2-app-kit 0.3.2", ] +[[package]] +name = "craft-mesh" +version = "0.1.0" +dependencies = [ + "serde_json", +] + [[package]] name = "craft-mime" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 08e45a86..42a44ab7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ craft-font-catalog = { path = "crates/font-catalog" } craft-font-manager = { path = "crates/font-manager" } craft-font-store = { path = "crates/font-store" } craft-gcode = { path = "crates/gcode" } +craft-mesh = { path = "crates/mesh" } craft-heif = { path = "crates/heif" } craft-fonts-build = { path = "crates/fonts-build" } craft-i18n = { path = "crates/i18n" } diff --git a/README.md b/README.md index 012d119b..e7fd5d6c 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,10 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " bool { + bytes.starts_with(b"glTF") +} + +/// Split a GLB container into its JSON chunk and its binary chunk. +pub fn split_glb(bytes: &[u8]) -> Result<(&[u8], Option<&[u8]>)> { + let bad = |m: &str| malformed("GLB", m); + let u32_at = |at: usize| bytes.get(at..at.checked_add(4)?).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes); + if !is_glb(bytes) { + return Err(bad("not a GLB file")); + } + let version = u32_at(4).ok_or_else(|| bad("truncated header"))?; + if version != 2 { + return Err(Error::Unsupported(format!("GLB: version {version} is not glTF 2.0"))); + } + let length = u32_at(8).ok_or_else(|| bad("truncated header"))? as usize; + if length > bytes.len() { + return Err(bad(&format!("truncated: the header says {length} bytes, the file has {}", bytes.len()))); + } + let mut at = 12usize; + let (mut json, mut bin) = (None, None); + while at.saturating_add(8) <= length { + let (Some(len), Some(kind)) = (u32_at(at), u32_at(at + 4)) else { break }; + let start = at + 8; + let end = start.checked_add(len as usize).filter(|e| *e <= length).ok_or_else(|| bad("a chunk runs past the end of the file"))?; + let data = bytes.get(start..end).ok_or_else(|| bad("a chunk runs past the end of the file"))?; + match kind { + CHUNK_JSON if json.is_none() => json = Some(data), + CHUNK_BIN if json.is_some() && bin.is_none() => bin = Some(data), + CHUNK_JSON | CHUNK_BIN => return Err(bad("chunks out of order")), + // Chunks of unknown types are skipped, as the specification asks. + _ => {} + } + at = end; + } + Ok((json.ok_or_else(|| bad("no JSON chunk"))?, bin)) +} + +/// A GLB container of `json` and an optional binary chunk (each padded to four bytes). +pub fn glb_container(json: &[u8], bin: &[u8]) -> Result> { + let pad = |n: usize| (4 - n % 4) % 4; + let json_len = json.len() + pad(json.len()); + let bin_len = bin.len() + pad(bin.len()); + let total = 12 + 8 + json_len + if bin.is_empty() { 0 } else { 8 + bin_len }; + let size = |n: usize| u32::try_from(n).map_err(|_| too_large("GLB", "more than the format's 32-bit lengths hold")); + let mut out = Vec::with_capacity(total); + out.extend_from_slice(b"glTF"); + out.extend_from_slice(&2u32.to_le_bytes()); + out.extend_from_slice(&size(total)?.to_le_bytes()); + out.extend_from_slice(&size(json_len)?.to_le_bytes()); + out.extend_from_slice(b"JSON"); + out.extend_from_slice(json); + out.extend(std::iter::repeat_n(b' ', pad(json.len()))); + if !bin.is_empty() { + out.extend_from_slice(&size(bin_len)?.to_le_bytes()); + out.extend_from_slice(b"BIN\0"); + out.extend_from_slice(bin); + out.extend(std::iter::repeat_n(0u8, pad(bin.len()))); + } + Ok(out) +} + +/// Decode standard or URL-safe base64 (with or without padding; whitespace ignored). +pub fn base64_decode(s: &str) -> Option> { + let mut out = Vec::with_capacity(s.len() / 4 * 3 + 3); + let mut acc = 0u32; + let mut bits = 0; + for c in s.bytes() { + let v = match c { + b'A'..=b'Z' => c - b'A', + b'a'..=b'z' => c - b'a' + 26, + b'0'..=b'9' => c - b'0' + 52, + b'+' | b'-' => 62, + b'/' | b'_' => 63, + b'=' => break, + b' ' | b'\n' | b'\r' | b'\t' => continue, + _ => return None, + }; + acc = (acc << 6) | u32::from(v); + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((acc >> bits) as u8); + acc &= (1 << bits) - 1; + } + } + Some(out) +} + +/// Percent-decode a URI. A `%` that two hex digits do not follow stays as it is. +pub fn uri_decode(s: &str) -> String { + let b = s.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while let Some(&c) = b.get(i) { + let hex = |k: usize| b.get(k).and_then(|c| (*c as char).to_digit(16)); + if let (b'%', Some(h), Some(l)) = (c, hex(i + 1), hex(i + 2)) { + out.push((h * 16 + l) as u8); + i += 3; + } else { + out.push(c); + i += 1; + } + } + String::from_utf8_lossy(&out).into_owned() +} + +/// The media type and bytes of a `data:` URI. +fn data_uri(uri: &str) -> Option)>> { + let rest = uri.strip_prefix("data:")?; + Some((|| { + let (meta, data) = rest.split_once(',').ok_or_else(|| malformed(KIND, "a data URI without a comma"))?; + let mime = meta.split(';').next().unwrap_or("").to_string(); + let bytes = if meta.ends_with(";base64") { + base64_decode(data).ok_or_else(|| malformed(KIND, "bad base64 in a data URI"))? + } else { + uri_decode(data).into_bytes() + }; + Ok((mime, bytes)) + })()) +} + +fn arr<'v>(v: &'v Value, k: &str) -> &'v [Value] { + v.get(k).and_then(Value::as_array).map(Vec::as_slice).unwrap_or(&[]) +} + +/// A non-negative integer field; one too large for `usize` counts as absent. +fn idx(v: &Value, k: &str) -> Option { + v.get(k).and_then(Value::as_u64).and_then(|x| usize::try_from(x).ok()) +} + +/// The indices in an array field that are below `len`. +fn indices(v: &Value, k: &str, len: usize) -> Vec { + arr(v, k).iter().filter_map(Value::as_u64).filter_map(|c| usize::try_from(c).ok()).filter(|c| *c < len).collect() +} + +fn num(v: &Value, k: &str, d: f64) -> f64 { + v.get(k).and_then(Value::as_f64).filter(|x| x.is_finite()).unwrap_or(d) +} + +fn nums(v: &Value, k: &str, d: [f64; N]) -> [f64; N] { + let mut out = d; + if let Some(a) = v.get(k).and_then(Value::as_array) { + for (slot, x) in out.iter_mut().zip(a) { + *slot = x.as_f64().filter(|x| x.is_finite()).unwrap_or(*slot); + } + } + out +} + +fn name(v: &Value) -> String { + v.get("name").and_then(Value::as_str).unwrap_or("").to_string() +} + +/// One component of type `ct` at the start of `s`, mapped to 0..1 / −1..1 when `norm`. +fn scalar(ct: usize, s: &[u8], norm: bool) -> Option { + let b2 = || s.get(..2).and_then(|b| <[u8; 2]>::try_from(b).ok()); + let b4 = || s.get(..4).and_then(|b| <[u8; 4]>::try_from(b).ok()); + Some(match (ct, norm) { + (5126, _) => f64::from(f32::from_le_bytes(b4()?)), + (5125, _) => f64::from(u32::from_le_bytes(b4()?)), + (5123, false) => f64::from(u16::from_le_bytes(b2()?)), + (5123, true) => f64::from(u16::from_le_bytes(b2()?)) / 65_535.0, + (5122, false) => f64::from(i16::from_le_bytes(b2()?)), + (5122, true) => (f64::from(i16::from_le_bytes(b2()?)) / 32_767.0).max(-1.0), + (5121, false) => f64::from(*s.first()?), + (5121, true) => f64::from(*s.first()?) / 255.0, + (5120, false) => f64::from(*s.first()? as i8), + (5120, true) => (f64::from(*s.first()? as i8) / 127.0).max(-1.0), + _ => return None, + }) +} + +struct Doc<'a> { + j: &'a Value, + buffers: Vec>, + vertices_left: Cell, + triangles_left: Cell, + parts_left: Cell, + zero_left: Cell, + /// Values skins and animations may still take, and bytes images may still copy. + data_left: Cell, +} + +fn charge(cell: &Cell, n: usize, what: &str) -> Result<()> { + cell.set(cell.get().checked_sub(n).ok_or_else(|| too_large(KIND, format!("more {what} than the limit allows")))?); + Ok(()) +} + +impl Doc<'_> { + fn accessor(&self, a: usize) -> Result<&Value> { + arr(self.j, "accessors").get(a).ok_or_else(|| malformed(KIND, format!("no accessor {a}"))) + } + + /// Elements the accessor declares. + fn count(&self, a: usize) -> Option { + idx(self.accessor(a).ok()?, "count") + } + + /// The bytes of a buffer view. + fn view(&self, bv: usize) -> Result<&[u8]> { + let view = arr(self.j, "bufferViews").get(bv).ok_or_else(|| malformed(KIND, format!("no bufferView {bv}")))?; + let buf = + self.buffers.get(idx(view, "buffer").unwrap_or(0)).ok_or_else(|| malformed(KIND, "a bufferView names a buffer that does not exist"))?; + let start = idx(view, "byteOffset").unwrap_or(0); + let end = match view.get("byteLength") { + Some(_) => start.checked_add(idx(view, "byteLength").unwrap_or(usize::MAX)), + None => Some(buf.len()), + }; + end.and_then(|end| buf.get(start..end)).ok_or_else(|| malformed(KIND, "a bufferView runs past its buffer")) + } + + /// The accessor's values, `n` per element, at most `max` elements: every length is checked + /// against the bytes that are there before anything is allocated from a count. + fn rows(&self, a: usize, max: usize, what: &str) -> Result<(Vec, usize)> { + let acc = self.accessor(a)?; + if acc.get("sparse").is_some() { + return Err(Error::Unsupported("glTF: sparse accessors".into())); + } + let n = match acc.get("type").and_then(Value::as_str).unwrap_or("") { + "SCALAR" => 1, + "VEC2" => 2, + "VEC3" => 3, + "VEC4" | "MAT2" => 4, + "MAT3" => 9, + "MAT4" => 16, + t => return Err(malformed(KIND, format!("accessor type `{}`", t.chars().take(16).collect::()))), + }; + let ct = idx(acc, "componentType").unwrap_or(5126); + let csize = match ct { + 5120 | 5121 => 1, + 5122 | 5123 => 2, + 5125 | 5126 => 4, + _ => return Err(malformed(KIND, format!("componentType {ct}"))), + }; + let norm = acc.get("normalized").and_then(Value::as_bool).unwrap_or(false); + let count = match acc.get("count") { + None => 0, + Some(c) => c.as_u64().and_then(|c| usize::try_from(c).ok()).ok_or_else(|| malformed(KIND, "an accessor count is not a valid size"))?, + }; + if count > max { + return Err(too_large(KIND, format!("{count} {what}, more than the limit allows"))); + } + let total = count.checked_mul(n).ok_or_else(|| too_large(KIND, "an accessor count is too large"))?; + // No buffer view: all zeros. + let Some(bv) = idx(acc, "bufferView") else { + charge(&self.zero_left, total, "values without a buffer")?; + return Ok((vec![0.0; total], n)); + }; + let stride = arr(self.j, "bufferViews").get(bv).and_then(|v| idx(v, "byteStride")).filter(|s| *s > 0).unwrap_or(csize * n); + if stride < csize * n { + return Err(malformed(KIND, format!("bufferView byteStride {stride} is smaller than its {}-byte elements", csize * n))); + } + let past = || malformed(KIND, "an accessor reads past its buffer"); + let data = self.view(bv)?.get(idx(acc, "byteOffset").unwrap_or(0)..).ok_or_else(past)?; + if count > 0 { + let end = (count - 1).checked_mul(stride).and_then(|o| o.checked_add(csize * n)).ok_or_else(past)?; + if end > data.len() { + return Err(past()); + } + } + // Matrices of 1- and 2-byte components pad their columns to 4 bytes; not handled (rare). + let mut out = vec![0.0; total]; + for (i, row) in out.chunks_exact_mut(n).enumerate() { + for (c, v) in row.iter_mut().enumerate() { + *v = i.checked_mul(stride).and_then(|o| data.get(o.checked_add(c * csize)?..)).and_then(|s| scalar(ct, s, norm)).ok_or_else(past)?; + } + } + Ok((out, n)) + } + + /// A vertex attribute of `nv` vertices with `n` components in `want`; `None` when the + /// primitive has none or it does not fit the positions. + fn attribute(&self, attrs: &Value, key: &str, nv: usize, want: &[usize]) -> Result, usize)>> { + let Some(a) = idx(attrs, key).filter(|a| self.count(*a) == Some(nv)) else { return Ok(None) }; + let (v, n) = self.rows(a, nv, key)?; + Ok(want.contains(&n).then_some((v, n))) + } +} + +/// What was passed over, for the warnings. +#[derive(Default)] +struct Skipped { + triangles: usize, + primitives: usize, +} + +fn rows_of(v: &[f64]) -> impl Iterator + '_ { + v.as_chunks::().0.iter().copied() +} + +/// Append the primitive's vertex attributes to the mesh's pools; the bases its corners count +/// from (`NONE` for a pool the primitive does not fill). +fn attributes(d: &Doc, attrs: &Value, pos: &[f64], mesh: &mut Mesh) -> Result<[u32; 3]> { + let nv = pos.len() / 3; + let base_of = |len: usize| { + u32::try_from(len) + .ok() + .filter(|b| (*b as usize).saturating_add(nv) < NONE as usize) + .ok_or_else(|| too_large(KIND, "more vertices in a mesh than a 32-bit index holds")) + }; + let base = base_of(mesh.positions.len())?; + let mut normal_base = NONE; + if let Some((v, _)) = d.attribute(attrs, "NORMAL", nv, &[3])? { + normal_base = base_of(mesh.normals.len())?; + mesh.normals.extend(rows_of::<3>(&v)); + } + let mut sets = Vec::new(); + while sets.len() < MAX_UV_SETS { + let Some((v, _)) = d.attribute(attrs, &format!("TEXCOORD_{}", sets.len()), nv, &[2])? else { break }; + sets.push(v); + } + let mut uv_base = NONE; + if !sets.is_empty() { + let len = mesh.uv_sets.first().map_or(0, Vec::len); + uv_base = base_of(len)?; + // Every set of the mesh has one length: a set this primitive lacks is zeros. + while mesh.uv_sets.len() < sets.len() { + mesh.uv_sets.push(vec![[0.0; 2]; len]); + } + for (k, set) in mesh.uv_sets.iter_mut().enumerate() { + match sets.get(k) { + Some(v) => set.extend(rows_of::<2>(v).map(|t| t.map(|x| x as f32))), + None => set.resize(len + nv, [0.0; 2]), + } + } + } + let total = mesh.positions.len() + nv; + match d.attribute(attrs, "COLOR_0", nv, &[3, 4])? { + Some((v, n)) => { + // Vertices of earlier primitives without a colour are white. + mesh.colors.resize(mesh.positions.len(), [1.0; 4]); + mesh.colors.extend(v.chunks_exact(n).map(|c| [0, 1, 2, 3].map(|k| c.get(k).map_or(1.0, |x| *x as f32)))); + } + None if !mesh.colors.is_empty() => mesh.colors.resize(total, [1.0; 4]), + None => {} + } + match (d.attribute(attrs, "JOINTS_0", nv, &[4])?, d.attribute(attrs, "WEIGHTS_0", nv, &[4])?) { + (Some((j, _)), Some((w, _))) => { + mesh.joints.resize(mesh.positions.len(), [0; 4]); + mesh.weights.resize(mesh.positions.len(), [0.0; 4]); + mesh.joints.extend(rows_of::<4>(&j).map(|r| r.map(|x| x as u16))); + mesh.weights.extend(rows_of::<4>(&w).map(|r| r.map(|x| x as f32))); + } + _ if !mesh.joints.is_empty() => { + mesh.joints.resize(total, [0; 4]); + mesh.weights.resize(total, [0.0; 4]); + } + _ => {} + } + mesh.positions.extend(rows_of::<3>(pos)); + Ok([base, normal_base, uv_base]) +} + +/// Where the vertices of one set of attribute accessors start in the mesh's pools (positions, +/// normals, UVs), and how many there are. Primitives of a mesh that name the same accessors +/// (one vertex buffer, several materials) share the vertices. +type Shared = std::collections::HashMap; + +fn primitive(d: &Doc, p: &Value, mesh: &mut Mesh, shared: &mut Shared, materials: usize, skipped: &mut Skipped) -> Result<()> { + let mode = idx(p, "mode").unwrap_or(4); + let position = p.get("attributes").and_then(|a| idx(a, "POSITION")); + let (Some(attrs), Some(pa), 4..=6) = (p.get("attributes"), position, mode) else { + skipped.primitives += 1; + return Ok(()); + }; + charge(&d.parts_left, 1, "primitives")?; + let key = attrs.to_string(); + let ([base, normal_base, uv_base], nv) = match shared.get(&key) { + Some(known) => *known, + None => { + let (pos, n) = d.rows(pa, d.vertices_left.get(), "vertices")?; + if n != 3 { + return Err(malformed(KIND, "POSITION is not VEC3")); + } + charge(&d.vertices_left, pos.len() / 3, "vertices")?; + let placed = (attributes(d, attrs, &pos, mesh)?, pos.len() / 3); + shared.insert(key, placed); + placed + } + }; + let left = d.triangles_left.get(); + // A strip or fan of k indices makes k − 2 triangles; a list, k / 3. + let cap = if mode == 4 { left.saturating_mul(3).saturating_add(2) } else { left.saturating_add(2) }; + let raw: Vec = match idx(p, "indices") { + Some(a) => { + let (v, n) = d.rows(a, cap, "indices")?; + if n != 1 { + return Err(malformed(KIND, "indices are not SCALAR")); + } + v.iter().map(|x| *x as u32).collect() + } + None if nv > cap => return Err(too_large(KIND, "more triangles than the limit allows")), + None => (0..nv as u32).collect(), + }; + let at = |k: usize| raw.get(k).copied(); + let triangle = |k: usize| match mode { + 4 => Some([at(3 * k)?, at(3 * k + 1)?, at(3 * k + 2)?]), + 5 if k.is_multiple_of(2) => Some([at(k)?, at(k + 1)?, at(k + 2)?]), + 5 => Some([at(k + 1)?, at(k)?, at(k + 2)?]), + _ => Some([at(0)?, at(k + 1)?, at(k + 2)?]), + }; + let finite = |i: u32| (i as usize) < nv && mesh.positions.get(base as usize + i as usize).is_some_and(|p| p.iter().all(|x| x.is_finite())); + let offset = |b: u32, i: u32| if b == NONE { NONE } else { b + i }; + let mut part = Part { material: idx(p, "material").filter(|m| *m < materials), ..Part::default() }; + let count = if mode == 4 { raw.len() / 3 } else { raw.len().saturating_sub(2) }; + for t in (0..count).filter_map(triangle) { + if t.iter().all(|i| finite(*i)) { + part.corners.extend(t.map(|i| Corner { position: base + i, normal: offset(normal_base, i), uv: offset(uv_base, i) })); + } else { + skipped.triangles += 1; + } + } + skipped.triangles += usize::from(mode == 4 && !raw.len().is_multiple_of(3)); + charge(&d.triangles_left, part.corners.len() / 3, "triangles")?; + mesh.parts.push(part); + Ok(()) +} + +fn buffers(j: &Value, bin: Option<&[u8]>, limits: &Limits, scene: &mut Scene, resolve: Resolver) -> Result>> { + let list = arr(j, "buffers"); + if list.len() > limits.max_extras { + return Err(too_large(KIND, format!("more than {} buffers", limits.max_extras))); + } + let mut out = Vec::new(); + let mut total = 0usize; + for (i, b) in list.iter().enumerate() { + let data = match b.get("uri").and_then(Value::as_str) { + Some(uri) => match data_uri(uri) { + Some(data) => data?.1, + None => { + let name = uri_decode(uri); + scene.external.push(name.clone()); + resolve(&name).ok_or(Error::Missing(name))? + } + }, + // Without a binary chunk the buffer is empty, and whatever reads it runs past it. + None if i == 0 => bin.map(<[u8]>::to_vec).unwrap_or_default(), + None => vec![], + }; + total = total.saturating_add(data.len()); + if total > limits.max_data_bytes { + return Err(too_large(KIND, format!("buffers of more than {} bytes", limits.max_data_bytes))); + } + out.push(data); + } + Ok(out) +} + +fn images(d: &Doc, limits: &Limits, scene: &mut Scene) -> Result<()> { + let list = arr(d.j, "images"); + if list.len() > limits.max_materials { + return Err(too_large(KIND, format!("more than {} images", limits.max_materials))); + } + for im in list { + let mut image = Image { name: name(im), mime_type: im.get("mimeType").and_then(Value::as_str).map(str::to_string), ..Image::default() }; + if let Some(uri) = im.get("uri").and_then(Value::as_str) { + match data_uri(uri) { + // An image that cannot be decoded is left without data, not an error. + Some(data) => { + if let Ok((mime, bytes)) = data { + image.mime_type = image.mime_type.or(Some(mime).filter(|m| !m.is_empty())); + image.data = Some(bytes); + } + } + None => { + let name = uri_decode(uri); + scene.external.push(name.clone()); + image.uri = Some(name); + } + } + } else if let Some(bytes) = idx(im, "bufferView").and_then(|bv| d.view(bv).ok()) { + charge(&d.data_left, bytes.len(), "image bytes")?; + image.data = Some(bytes.to_vec()); + } + scene.images.push(image); + } + Ok(()) +} + +fn materials(j: &Value, limits: &Limits, scene: &mut Scene) -> Result<()> { + let list = arr(j, "materials"); + if list.len() > limits.max_materials { + return Err(too_large(KIND, format!("more than {} materials", limits.max_materials))); + } + let wrap = |s: Option<&Value>, k: &str| match s.and_then(|s| s.get(k)).and_then(Value::as_u64) { + Some(33071) => Wrap::Clamp, + Some(33648) => Wrap::Mirror, + _ => Wrap::Repeat, + }; + let images = scene.images.len(); + let tref = |v: Option<&Value>| -> Option { + let v = v?; + let texture = arr(j, "textures").get(idx(v, "index")?)?; + let sampler = idx(texture, "sampler").and_then(|s| arr(j, "samplers").get(s)); + let image = idx(texture, "source").filter(|i| *i < images)?; + Some(TextureRef { image, uv_set: idx(v, "texCoord").unwrap_or(0) as u32, wrap: [wrap(sampler, "wrapS"), wrap(sampler, "wrapT")] }) + }; + for m in list { + let null = Value::Null; + let pbr = m.get("pbrMetallicRoughness").unwrap_or(&null); + let ext = m.get("extensions"); + let strength = ext.and_then(|e| e.get("KHR_materials_emissive_strength")).map_or(1.0, |e| num(e, "emissiveStrength", 1.0)); + scene.materials.push(Material { + name: name(m), + base_color: nums(pbr, "baseColorFactor", [1.0; 4]).map(|v| v as f32), + metallic: num(pbr, "metallicFactor", 1.0) as f32, + roughness: num(pbr, "roughnessFactor", 1.0) as f32, + emissive: nums(m, "emissiveFactor", [0.0; 3]).map(|v| (v * strength) as f32), + alpha_mode: match m.get("alphaMode").and_then(Value::as_str) { + Some("MASK") => AlphaMode::Mask(num(m, "alphaCutoff", 0.5) as f32), + Some("BLEND") => AlphaMode::Blend, + _ => AlphaMode::Opaque, + }, + double_sided: m.get("doubleSided").and_then(Value::as_bool).unwrap_or(false), + unlit: ext.and_then(|e| e.get("KHR_materials_unlit")).is_some(), + base_color_texture: tref(pbr.get("baseColorTexture")), + metallic_roughness_texture: tref(pbr.get("metallicRoughnessTexture")), + normal_texture: tref(m.get("normalTexture")), + normal_scale: m.get("normalTexture").map_or(1.0, |n| num(n, "scale", 1.0)) as f32, + occlusion_texture: tref(m.get("occlusionTexture")), + occlusion_strength: m.get("occlusionTexture").map_or(1.0, |n| num(n, "strength", 1.0)) as f32, + emissive_texture: tref(m.get("emissiveTexture")), + }); + } + Ok(()) +} + +fn nodes(j: &Value, limits: &Limits, scene: &mut Scene) -> Result<()> { + let list = arr(j, "nodes"); + if list.len() > limits.max_nodes { + return Err(too_large(KIND, format!("more than {} nodes", limits.max_nodes))); + } + let skins = arr(j, "skins").len().min(limits.max_extras); + for n in list { + let matrix = n.get("matrix").and_then(Value::as_array).filter(|a| a.len() == 16).map(|_| nums(n, "matrix", IDENTITY)); + let trs = Trs { + translation: nums(n, "translation", [0.0; 3]), + rotation: nums(n, "rotation", [0.0, 0.0, 0.0, 1.0]), + scale: nums(n, "scale", [1.0; 3]), + }; + scene.nodes.push(Node { + name: name(n), + transform: matrix.unwrap_or_else(|| trs.matrix()), + trs: matrix.is_none().then_some(trs), + mesh: idx(n, "mesh").filter(|m| *m < scene.meshes.len()), + skin: idx(n, "skin").filter(|s| *s < skins), + camera: idx(n, "camera").filter(|c| *c < scene.cameras.len()), + light: n.get("extensions").and_then(|e| e.get("KHR_lights_punctual")).and_then(|e| idx(e, "light")).filter(|l| *l < scene.lights.len()), + children: indices(n, "children", list.len()), + }); + } + scene.roots = match arr(j, "scenes").get(idx(j, "scene").unwrap_or(0)).or_else(|| arr(j, "scenes").first()) { + Some(s) => indices(s, "nodes", list.len()), + None => { + // No scene: every node that is nobody's child. + let mut child = vec![false; list.len()]; + for c in scene.nodes.iter().flat_map(|n| &n.children) { + if let Some(slot) = child.get_mut(*c) { + *slot = true; + } + } + child.iter().enumerate().filter(|(_, c)| !**c).map(|(i, _)| i).collect() + } + }; + Ok(()) +} + +fn skins(d: &Doc, limits: &Limits, scene: &mut Scene) -> Result<()> { + let list = arr(d.j, "skins"); + if list.len() > limits.max_extras { + return Err(too_large(KIND, format!("more than {} skins", limits.max_extras))); + } + for s in list { + let joints = indices(s, "joints", scene.nodes.len()); + let inverse_bind = match idx(s, "inverseBindMatrices") { + Some(a) => { + let (v, n) = d.rows(a, joints.len(), "inverse bind matrices")?; + charge(&d.data_left, v.len(), "skin and animation values")?; + if n == 16 { rows_of::<16>(&v).collect() } else { vec![] } + } + None => vec![], + }; + scene.skins.push(Skin { name: name(s), joints, inverse_bind }); + } + Ok(()) +} + +fn animations(d: &Doc, limits: &Limits, scene: &mut Scene) -> Result<()> { + let list = arr(d.j, "animations"); + if list.len() > limits.max_extras { + return Err(too_large(KIND, format!("more than {} animations", limits.max_extras))); + } + for a in list { + let samplers = arr(a, "samplers"); + let mut anim = Animation { name: name(a), channels: vec![] }; + if arr(a, "channels").len() > limits.max_extras { + return Err(too_large(KIND, format!("more than {} channels in an animation", limits.max_extras))); + } + for c in arr(a, "channels") { + let Some(t) = c.get("target") else { continue }; + let Some(node) = idx(t, "node").filter(|n| *n < scene.nodes.len()) else { continue }; + let path = match t.get("path").and_then(Value::as_str) { + Some("translation") => ChannelPath::Translation, + Some("rotation") => ChannelPath::Rotation, + Some("scale") => ChannelPath::Scale, + _ => continue, + }; + let Some(s) = idx(c, "sampler").and_then(|s| samplers.get(s)) else { continue }; + let interpolation = match s.get("interpolation").and_then(Value::as_str) { + Some("STEP") => Interpolation::Step, + Some("CUBICSPLINE") => Interpolation::CubicSpline, + _ => Interpolation::Linear, + }; + let (Some(input), Some(output)) = (idx(s, "input"), idx(s, "output")) else { continue }; + let floats = |a: usize| -> Result> { + let (v, _) = d.rows(a, d.data_left.get(), "animation keys")?; + charge(&d.data_left, v.len(), "skin and animation values")?; + Ok(v.iter().map(|x| *x as f32).collect()) + }; + anim.channels.push(Channel { node, path, interpolation, times: floats(input)?, values: floats(output)? }); + } + scene.animations.push(anim); + } + Ok(()) +} + +fn cameras_and_lights(j: &Value, limits: &Limits, scene: &mut Scene) -> Result<()> { + let lights = j.get("extensions").and_then(|e| e.get("KHR_lights_punctual")).map_or(&[][..], |e| arr(e, "lights")); + if arr(j, "cameras").len() > limits.max_extras || lights.len() > limits.max_extras { + return Err(too_large(KIND, format!("more than {} cameras or lights", limits.max_extras))); + } + let null = Value::Null; + for c in arr(j, "cameras") { + let projection = match (c.get("type").and_then(Value::as_str), c.get("orthographic")) { + (Some("orthographic"), Some(o)) => Projection::Orthographic { + xmag: num(o, "xmag", 1.0), + ymag: num(o, "ymag", 1.0), + znear: num(o, "znear", 0.01), + zfar: num(o, "zfar", 1000.0), + }, + _ => { + let p = c.get("perspective").unwrap_or(&null); + Projection::Perspective { + yfov: num(p, "yfov", 0.8).clamp(1e-3, 3.1), + aspect: p.get("aspectRatio").and_then(Value::as_f64).filter(|a| a.is_finite() && *a > 0.0), + znear: num(p, "znear", 0.01), + zfar: p.get("zfar").and_then(Value::as_f64).filter(|z| z.is_finite()), + } + } + }; + scene.cameras.push(Camera { name: name(c), projection }); + } + for l in lights { + let kind = match l.get("type").and_then(Value::as_str) { + Some("directional") => LightKind::Directional, + Some("spot") => { + let s = l.get("spot").unwrap_or(&null); + let outer = num(s, "outerConeAngle", std::f64::consts::FRAC_PI_4).clamp(1e-3, std::f64::consts::FRAC_PI_2); + LightKind::Spot { inner: num(s, "innerConeAngle", 0.0).clamp(0.0, outer), outer } + } + _ => LightKind::Point, + }; + let range = l.get("range").and_then(Value::as_f64).filter(|r| r.is_finite()); + scene.lights.push(Light { name: name(l), kind, color: nums(l, "color", [1.0; 3]), intensity: num(l, "intensity", 1.0).max(0.0), range }); + } + Ok(()) +} + +/// Required extensions that only change how a file looks (textures, material models) or that +/// the reader handles: the geometry reads without the others being understood. +fn check_extensions(j: &Value, scene: &mut Scene) -> Result<()> { + const LOOKS: [&str; 5] = ["KHR_texture_transform", "KHR_texture_basisu", "EXT_texture_webp", "MSFT_texture_dds", "KHR_lights_punctual"]; + for e in arr(j, "extensionsRequired").iter().filter_map(Value::as_str) { + let short: String = e.chars().take(64).collect(); + if matches!(e, "KHR_mesh_quantization" | "KHR_materials_unlit" | "KHR_materials_emissive_strength") { + continue; + } + if !LOOKS.contains(&e) && !e.starts_with("KHR_materials_") { + let hint = if e.contains("draco") || e.contains("meshopt") { " (compressed meshes: save the file uncompressed)" } else { "" }; + return Err(Error::Unsupported(format!("glTF: required extension {short}{hint}"))); + } + if scene.warnings.len() < 16 { + scene.warnings.push(format!("required extension {short} is not read")); + } + } + Ok(()) +} + +/// Read a `.gltf` (JSON) or `.glb` file. External buffers are asked of `resolve`; see the +/// module documentation. +pub fn read(bytes: &[u8], limits: &Limits, resolve: Resolver) -> Result { + let (json, bin) = if is_glb(bytes) { split_glb(bytes)? } else { (bytes, None) }; + let j: Value = serde_json::from_slice(json).map_err(|e| malformed(KIND, format!("JSON: {e}")))?; + let version = j.get("asset").and_then(|a| a.get("version")).and_then(Value::as_str).unwrap_or(""); + if version.split('.').next() != Some("2") { + return Err(Error::Unsupported(format!("glTF: version `{}` (2.x is read)", version.chars().take(16).collect::()))); + } + let mut scene = Scene::default(); + check_extensions(&j, &mut scene)?; + let d = Doc { + j: &j, + buffers: buffers(&j, bin, limits, &mut scene, resolve)?, + vertices_left: Cell::new(limits.max_vertices), + triangles_left: Cell::new(limits.max_triangles), + parts_left: Cell::new(limits.max_parts), + zero_left: Cell::new(MAX_ZERO_COMPONENTS), + data_left: Cell::new(limits.max_data_bytes), + }; + images(&d, limits, &mut scene)?; + materials(&j, limits, &mut scene)?; + if arr(&j, "meshes").len() > limits.max_meshes { + return Err(too_large(KIND, format!("more than {} meshes", limits.max_meshes))); + } + let mut skipped = Skipped::default(); + for m in arr(&j, "meshes") { + let mut mesh = Mesh { name: name(m), ..Mesh::default() }; + let mut shared = Shared::new(); + for p in arr(m, "primitives") { + primitive(&d, p, &mut mesh, &mut shared, scene.materials.len(), &mut skipped)?; + } + scene.meshes.push(mesh); + } + cameras_and_lights(&j, limits, &mut scene)?; + nodes(&j, limits, &mut scene)?; + skins(&d, limits, &mut scene)?; + animations(&d, limits, &mut scene)?; + count_warning(&mut scene.warnings, skipped.primitives, "primitive(s) are points, lines or have no positions and were left out"); + count_warning(&mut scene.warnings, skipped.triangles, "triangle(s) could not be read and were left out"); + // A hierarchy with a cycle, or one too deep to walk, is refused here rather than by every caller. + scene.instances(limits)?; + Ok(scene) +} + +/// The binary chunk and the buffer views and accessors over it, as they are written. +#[derive(Default)] +struct Writer { + bin: Vec, + views: Vec, + accessors: Vec, +} + +impl Writer { + fn view(&mut self, bytes: &[u8], target: Option) -> usize { + self.bin.resize(self.bin.len().next_multiple_of(4), 0); + let mut view = json!({ "buffer": 0, "byteOffset": self.bin.len(), "byteLength": bytes.len() }); + if let (Some(t), Some(v)) = (target, view.as_object_mut()) { + v.insert("target".into(), json!(t)); + } + self.bin.extend_from_slice(bytes); + self.views.push(view); + self.views.len() - 1 + } + + /// A float accessor of `N` components per row; with the bounds glTF wants for positions. + fn floats(&mut self, rows: impl Iterator, kind: &str, bounds: bool) -> usize { + let mut bytes = Vec::new(); + let (mut lo, mut hi, mut count) = ([f32::INFINITY; N], [f32::NEG_INFINITY; N], 0usize); + for row in rows { + count += 1; + for (k, x) in row.iter().enumerate() { + bytes.extend_from_slice(&x.to_le_bytes()); + if let (Some(l), Some(h)) = (lo.get_mut(k), hi.get_mut(k)) { + *l = l.min(*x); + *h = h.max(*x); + } + } + } + let view = self.view(&bytes, Some(34962)); + let mut acc = json!({ "bufferView": view, "componentType": 5126, "count": count, "type": kind }); + if let (true, Some(a)) = (bounds && count > 0, acc.as_object_mut()) { + a.insert("min".into(), json!(lo.to_vec())); + a.insert("max".into(), json!(hi.to_vec())); + } + self.accessors.push(acc); + self.accessors.len() - 1 + } + + fn indices(&mut self, triangles: &[[u32; 3]]) -> usize { + let bytes: Vec = triangles.iter().flatten().flat_map(|i| i.to_le_bytes()).collect(); + let view = self.view(&bytes, Some(34963)); + self.accessors.push(json!({ "bufferView": view, "componentType": 5125, "count": triangles.len() * 3, "type": "SCALAR" })); + self.accessors.len() - 1 + } + + /// The glTF mesh of `mesh`, or `None` when it has no triangle. + fn mesh(&mut self, mesh: &Mesh) -> Option { + let u = mesh.unified(); + if u.parts.iter().all(Vec::is_empty) { + return None; + } + let mut attrs = Map::new(); + attrs.insert("POSITION".into(), json!(self.floats(u.positions.iter().map(|p| p.map(|x| x as f32)), "VEC3", true))); + if !u.normals.is_empty() { + attrs.insert("NORMAL".into(), json!(self.floats(u.normals.iter().map(|n| n.map(|x| x as f32)), "VEC3", false))); + } + if !u.uvs.is_empty() { + for (k, set) in mesh.uv_sets.iter().enumerate() { + let rows = u.corners.iter().map(|c| set.get(c.uv as usize).copied().unwrap_or_default()); + attrs.insert(format!("TEXCOORD_{k}"), json!(self.floats(rows, "VEC2", false))); + } + } + if !u.colors.is_empty() { + attrs.insert("COLOR_0".into(), json!(self.floats(u.colors.iter().copied(), "VEC4", false))); + } + let mut primitives = Vec::new(); + for (part, triangles) in mesh.parts.iter().zip(&u.parts).filter(|(_, t)| !t.is_empty()) { + let mut p = json!({ "attributes": attrs, "indices": self.indices(triangles) }); + if let (Some(m), Some(o)) = (part.material, p.as_object_mut()) { + o.insert("material".into(), json!(m)); + } + primitives.push(p); + } + let mut out = json!({ "primitives": primitives }); + if let (false, Some(o)) = (mesh.name.is_empty(), out.as_object_mut()) { + o.insert("name".into(), json!(mesh.name)); + } + Some(out) + } +} + +/// The media type of encoded image bytes, for the formats glTF allows. +fn sniff(bytes: &[u8]) -> &'static str { + if bytes.starts_with(&[0xFF, 0xD8]) { "image/jpeg" } else { "image/png" } +} + +/// Images, samplers and textures as written: one texture per image and wrap pair in use. +#[derive(Default)] +struct Textures { + images: Vec, + /// The written index of each image of the scene (`None`: no bytes and no name to write). + image_map: Vec>, + samplers: Vec<[Wrap; 2]>, + textures: Vec<(usize, usize)>, +} + +impl Textures { + fn slot(&mut self, t: Option) -> Option { + let t = t?; + let image = (*self.image_map.get(t.image)?)?; + let sampler = self.samplers.iter().position(|s| *s == t.wrap).unwrap_or_else(|| { + self.samplers.push(t.wrap); + self.samplers.len() - 1 + }); + let index = self.textures.iter().position(|x| *x == (image, sampler)).unwrap_or_else(|| { + self.textures.push((image, sampler)); + self.textures.len() - 1 + }); + Some(json!({ "index": index, "texCoord": t.uv_set })) + } +} + +fn material_json(m: &Material, textures: &mut Textures, extensions: &mut Vec<&'static str>) -> Value { + let mut pbr = json!({ "baseColorFactor": m.base_color, "metallicFactor": m.metallic, "roughnessFactor": m.roughness }); + let mut out = json!({ "name": m.name, "doubleSided": m.double_sided }); + let mut ext = Map::new(); + // glTF's emissive factor stops at 1; the rest is a strength. + let peak = m.emissive.iter().copied().fold(1.0f32, f32::max); + if peak > 1.0 { + ext.insert("KHR_materials_emissive_strength".into(), json!({ "emissiveStrength": peak })); + } + if m.unlit { + ext.insert("KHR_materials_unlit".into(), json!({})); + } + for name in ["KHR_materials_emissive_strength", "KHR_materials_unlit"] { + if ext.contains_key(name) && !extensions.contains(&name) { + extensions.push(name); + } + } + let set = |o: &mut Value, key: &str, v: Option| { + if let (Some(v), Some(o)) = (v, o.as_object_mut()) { + o.insert(key.into(), v); + } + }; + set(&mut pbr, "baseColorTexture", textures.slot(m.base_color_texture)); + set(&mut pbr, "metallicRoughnessTexture", textures.slot(m.metallic_roughness_texture)); + let with = |slot: Option, key: &str, v: f32| { + slot.map(|mut s| { + if let Some(o) = s.as_object_mut() { + o.insert(key.into(), json!(v)); + } + s + }) + }; + set(&mut out, "normalTexture", with(textures.slot(m.normal_texture), "scale", m.normal_scale)); + set(&mut out, "occlusionTexture", with(textures.slot(m.occlusion_texture), "strength", m.occlusion_strength)); + set(&mut out, "emissiveTexture", textures.slot(m.emissive_texture)); + set(&mut out, "emissiveFactor", Some(json!(m.emissive.map(|e| e / peak)))); + set(&mut out, "pbrMetallicRoughness", Some(pbr)); + match m.alpha_mode { + AlphaMode::Opaque => {} + AlphaMode::Blend => set(&mut out, "alphaMode", Some(json!("BLEND"))), + AlphaMode::Mask(cutoff) => { + set(&mut out, "alphaMode", Some(json!("MASK"))); + set(&mut out, "alphaCutoff", Some(json!(cutoff))); + } + } + set(&mut out, "extensions", (!ext.is_empty()).then_some(Value::Object(ext))); + out +} + +/// JSON has no number for these, and a file with them is not glTF. +fn check_finite(scene: &Scene) -> Result<()> { + let positions = scene.meshes.iter().flat_map(|m| m.positions.iter().flatten()).all(|x| (*x as f32).is_finite()); + let nodes = scene.nodes.iter().flat_map(|n| n.transform.iter()).all(|x| x.is_finite()); + let materials = scene.materials.iter().all(|m| { + let values = [m.metallic, m.roughness, m.normal_scale, m.occlusion_strength]; + let cutoff = if let AlphaMode::Mask(c) = m.alpha_mode { c } else { 0.0 }; + m.base_color.iter().chain(&m.emissive).chain(&values).all(|x| x.is_finite()) && cutoff.is_finite() + }); + if positions && nodes && materials { Ok(()) } else { Err(malformed(KIND, "a position, transform or material value is not finite")) } +} + +/// The scene as a binary glTF file: its nodes, meshes (each part a primitive over the mesh's +/// [`Mesh::unified`] vertices: positions, normals, UV sets and colours) and materials, with +/// images that have bytes embedded and the others named by their URI. The binary chunk is +/// limited to `limits.max_data_bytes`. +pub fn write_glb(scene: &Scene, limits: &Limits) -> Result> { + scene.validate()?; + scene.instances(limits)?; + check_finite(scene)?; + let mut w = Writer::default(); + let mut meshes = Vec::new(); + let mesh_map: Vec> = scene + .meshes + .iter() + .map(|m| { + w.mesh(m).map(|v| { + meshes.push(v); + meshes.len() - 1 + }) + }) + .collect(); + let mut textures = Textures::default(); + for image in &scene.images { + let mut out = Map::new(); + if !image.name.is_empty() { + out.insert("name".into(), json!(image.name)); + } + match (&image.data, &image.uri) { + (Some(bytes), _) => { + out.insert("bufferView".into(), json!(w.view(bytes, None))); + out.insert("mimeType".into(), json!(image.mime_type.clone().unwrap_or_else(|| sniff(bytes).into()))); + } + (None, Some(uri)) => drop(out.insert("uri".into(), json!(uri))), + (None, None) => { + textures.image_map.push(None); + continue; + } + } + textures.image_map.push(Some(textures.images.len())); + textures.images.push(Value::Object(out)); + } + let mut extensions = Vec::new(); + let materials: Vec = scene.materials.iter().map(|m| material_json(m, &mut textures, &mut extensions)).collect(); + let nodes: Vec = scene + .nodes + .iter() + .map(|n| { + let mut out = Map::new(); + if !n.name.is_empty() { + out.insert("name".into(), json!(n.name)); + } + if n.transform != IDENTITY { + out.insert("matrix".into(), json!(n.transform)); + } + if let Some(m) = n.mesh.and_then(|m| mesh_map.get(m).copied().flatten()) { + out.insert("mesh".into(), json!(m)); + } + if !n.children.is_empty() { + out.insert("children".into(), json!(n.children)); + } + Value::Object(out) + }) + .collect(); + if w.bin.len() > limits.max_data_bytes { + return Err(too_large("GLB", format!("more than {} bytes of data", limits.max_data_bytes))); + } + let wrap = |w: Wrap| match w { + Wrap::Repeat => 10497, + Wrap::Clamp => 33071, + Wrap::Mirror => 33648, + }; + let mut root = Map::new(); + root.insert("asset".into(), json!({ "version": "2.0", "generator": "craft-mesh" })); + root.insert("scene".into(), json!(0)); + root.insert("scenes".into(), json!([{ "nodes": scene.roots }])); + // glTF does not allow empty arrays. + let mut list = |key: &str, values: Vec| { + if !values.is_empty() { + root.insert(key.into(), Value::Array(values)); + } + }; + list("nodes", nodes); + list("meshes", meshes); + list("materials", materials); + list("textures", textures.textures.iter().map(|(image, sampler)| json!({ "source": image, "sampler": sampler })).collect()); + list("samplers", textures.samplers.iter().map(|s| json!({ "wrapS": wrap(s[0]), "wrapT": wrap(s[1]) })).collect()); + list("images", textures.images); + list("accessors", w.accessors); + list("bufferViews", w.views); + list("extensionsUsed", extensions.iter().map(|e| json!(e)).collect()); + if !w.bin.is_empty() { + root.insert("buffers".into(), json!([{ "byteLength": w.bin.len() }])); + } + let json = serde_json::to_vec(&Value::Object(root)).map_err(|e| malformed(KIND, e))?; + glb_container(&json, &w.bin) +} diff --git a/crates/mesh/src/lib.rs b/crates/mesh/src/lib.rs new file mode 100644 index 00000000..73756966 --- /dev/null +++ b/crates/mesh/src/lib.rs @@ -0,0 +1,711 @@ +//! 3D mesh file formats behind one neutral model. +//! +//! Each format is a module behind a Cargo feature of the same name: [`stl`], [`obj`] (with MTL), +//! [`ply`] and [`gltf`] (glTF 2.0 and GLB). A reader turns bytes into a [`Scene`]; a writer turns +//! a [`Scene`] into bytes. Mapping a [`Scene`] onto an application's own mesh, body or document +//! types is the caller's business, as are computing missing normals, tangents and UVs, welding, +//! unit scaling and turning a file's up axis. +//! +//! Conventions of the model, whatever the file: +//! +//! - Positions and normals are `f64` in the file's own axes and units (STL and PLY files are +//! usually Z-up, glTF is Y-up in metres; nothing is turned or scaled here). +//! - UVs have their origin at the top left with v pointing down (glTF's convention). OBJ and PLY +//! store v up; their readers and writers flip it. +//! - Material colours are linear RGB(A). MTL and STL colours are display (sRGB) colours and are +//! converted. Vertex colours are the stored values scaled to 0..1. +//! - A face is a polygon of three or more corners. Each corner names a position, and optionally +//! a normal and a UV, by index into the mesh's pools, so a format with separate indices (OBJ) +//! is kept exactly; [`Mesh::unified`] gives the one-index triangle list a renderer wants. +//! +//! Input is untrusted. Every reader takes [`Limits`], checks every count, index and length +//! against the bytes that are there, and returns an [`Error`] or a warning in +//! [`Scene::warnings`] instead of panicking. No reader opens a file: a reference to another file +//! (an OBJ material library, a glTF buffer or image) is listed in [`Scene::external`] and asked +//! of the caller's [`Resolver`], which may decline. +//! +//! Depends on no other Craft crate. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] +// The helpers the formats share are not all used by every subset of them. +#![cfg_attr(not(all(feature = "stl", feature = "obj", feature = "ply", feature = "gltf")), allow(dead_code, unused_imports))] + +#[cfg(feature = "gltf")] +pub mod gltf; +#[cfg(feature = "obj")] +pub mod obj; +#[cfg(feature = "ply")] +pub mod ply; +#[cfg(feature = "stl")] +pub mod stl; + +use std::collections::HashMap; +use std::fmt; + +/// Why a file could not be read or a scene could not be written. +#[derive(Clone, Debug, PartialEq)] +pub enum Error { + /// The bytes are not a valid file of the format. + Malformed(String), + /// The file is valid but exceeds one of the [`Limits`]; names the limit. + TooLarge(String), + /// The file needs something this crate does not read (a compression extension, …). + Unsupported(String), + /// The file needs another file that the resolver did not supply; holds its name. + Missing(String), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::Malformed(m) | Error::TooLarge(m) | Error::Unsupported(m) => f.write_str(m), + Error::Missing(name) => write!(f, "`{name}`, which the file needs, was not supplied"), + } + } +} + +impl std::error::Error for Error {} + +pub type Result = std::result::Result; + +pub(crate) fn malformed(kind: &str, m: impl fmt::Display) -> Error { + Error::Malformed(format!("{kind}: {m}")) +} + +pub(crate) fn too_large(kind: &str, m: impl fmt::Display) -> Error { + Error::TooLarge(format!("{kind}: {m}")) +} + +/// The bounds a reader keeps. The defaults suit a desktop application; lower them for a +/// browser or a preview. +#[derive(Clone, Debug, PartialEq)] +pub struct Limits { + /// Most entries in any one attribute pool of the file (positions, normals, UVs). + pub max_vertices: usize, + /// Most triangles in the file, counting a polygon of `n` corners as `n - 2`. + pub max_triangles: usize, + /// Most corners of one face. + pub max_face_corners: usize, + /// Most meshes (OBJ objects, glTF meshes). + pub max_meshes: usize, + /// Most parts (groups, material runs, glTF primitives) in the file. + pub max_parts: usize, + /// Most materials, and most images. + pub max_materials: usize, + /// Most nodes, and most node visits when a hierarchy is walked. + pub max_nodes: usize, + /// Deepest node nesting. + pub max_depth: usize, + /// Most bytes of external and embedded data (buffers, material libraries) taken in total. + pub max_data_bytes: usize, + /// Most skins, animations, cameras and lights (each), and most channels of one animation. + pub max_extras: usize, +} + +impl Default for Limits { + fn default() -> Self { + Limits { + max_vertices: 20_000_000, + max_triangles: 20_000_000, + max_face_corners: 4096, + max_meshes: 100_000, + max_parts: 100_000, + max_materials: 100_000, + max_nodes: 100_000, + max_depth: 64, + max_data_bytes: 1 << 30, + max_extras: 10_000, + } + } +} + +/// Answers a file's reference to another file (a name exactly as the file writes it) with that +/// file's bytes, or `None`. The crate never opens anything itself. +pub type Resolver<'a> = &'a mut dyn FnMut(&str) -> Option>; + +/// A [`Resolver`] that declines everything: for self-contained files. +pub fn no_external(_: &str) -> Option> { + None +} + +/// Marks a corner without a normal or a UV. +pub const NONE: u32 = u32::MAX; + +/// One corner of a face: indices into [`Mesh::positions`], [`Mesh::normals`] and the UV sets +/// ([`NONE`] when the corner has no normal or no UV). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct Corner { + pub position: u32, + pub normal: u32, + pub uv: u32, +} + +impl Corner { + /// A corner with only a position. + pub fn at(position: u32) -> Corner { + Corner { position, normal: NONE, uv: NONE } + } + + /// A corner whose position, normal and UV share one index. + pub fn all(index: u32) -> Corner { + Corner { position: index, normal: index, uv: index } + } +} + +/// Faces of a mesh that share a group name, a material and a smoothing group. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Part { + /// The group's name (OBJ `g`); empty when the file has none. + pub name: String, + /// Index into [`Scene::materials`]. + pub material: Option, + /// OBJ smoothing group; 0 is off. + pub smoothing: u32, + /// Corners of each face, in order. Empty means every face is a triangle. + pub face_sizes: Vec, + /// The corners of all faces, one face after another. + pub corners: Vec, +} + +impl Part { + /// The faces, each a slice of its corners. Stops at the first face the corner list is too + /// short for. + pub fn faces(&self) -> impl Iterator { + let mut rest = self.corners.as_slice(); + let mut sizes = self.face_sizes.iter(); + let all_triangles = self.face_sizes.is_empty(); + std::iter::from_fn(move || { + let n = if all_triangles { 3 } else { *sizes.next()? as usize }; + let (face, tail) = rest.split_at_checked(n)?; + rest = tail; + (n > 0).then_some(face) + }) + } + + /// The faces as triangles: a polygon becomes a fan from its first corner. + pub fn triangles(&self) -> impl Iterator + '_ { + self.faces().flat_map(|f| { + let first = f.first().copied(); + f.get(1..).unwrap_or_default().windows(2).filter_map(move |w| match (first, w) { + (Some(a), [b, c]) => Some([a, *b, *c]), + _ => None, + }) + }) + } + + /// Triangles the faces fan into. + pub fn triangle_count(&self) -> usize { + if self.face_sizes.is_empty() { self.corners.len() / 3 } else { self.face_sizes.iter().map(|n| (*n as usize).saturating_sub(2)).sum() } + } +} + +/// One object: attribute pools and the parts whose corners index them. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Mesh { + pub name: String, + pub positions: Vec<[f64; 3]>, + pub normals: Vec<[f64; 3]>, + /// UV sets; every set has the same length and is indexed by [`Corner::uv`]. + pub uv_sets: Vec>, + /// RGBA per position (same length as `positions`), or empty. + pub colors: Vec<[f32; 4]>, + /// Skinning (glTF): up to four joints (indices into the skin's joint list) and weights per + /// position; both the length of `positions`, or empty. + pub joints: Vec<[u16; 4]>, + pub weights: Vec<[f32; 4]>, + pub parts: Vec, +} + +/// A triangle list with one index per vertex, as [`Mesh::unified`] builds it. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Unified { + pub positions: Vec<[f64; 3]>, + /// One per vertex when every corner has a normal, else empty. + pub normals: Vec<[f64; 3]>, + /// The first UV set, one per vertex when every corner has a UV, else empty. + pub uvs: Vec<[f32; 2]>, + /// One per vertex when the mesh has colours, else empty. + pub colors: Vec<[f32; 4]>, + /// The corner of the mesh each vertex came from: its position index finds the joints and + /// weights, its UV index the other UV sets. + pub corners: Vec, + /// Triangles per part, in the order of [`Mesh::parts`]. + pub parts: Vec>, +} + +impl Mesh { + pub fn triangle_count(&self) -> usize { + self.parts.iter().map(Part::triangle_count).sum() + } + + /// Checks what every reader guarantees and every writer needs: each corner's indices are in + /// range, the face sizes add up, and the per-position arrays have the positions' length. + pub fn validate(&self) -> Result<()> { + let bad = |m: &str| Err(Error::Malformed(format!("mesh `{}`: {m}", self.name))); + let uvs = self.uv_sets.first().map_or(0, Vec::len); + if self.uv_sets.iter().any(|s| s.len() != uvs) { + return bad("UV sets differ in length"); + } + for (what, len) in [("colours", self.colors.len()), ("joints", self.joints.len()), ("weights", self.weights.len())] { + if len != 0 && len != self.positions.len() { + return bad(&format!("{what} do not match the positions")); + } + } + for p in &self.parts { + let total = if p.face_sizes.is_empty() { + (p.corners.len() % 3 == 0).then_some(p.corners.len()) + } else { + p.face_sizes.iter().try_fold(0usize, |n, s| if *s >= 3 { n.checked_add(*s as usize) } else { None }) + }; + if total != Some(p.corners.len()) { + return bad("face sizes do not match the corners"); + } + for c in &p.corners { + let ok = (c.position as usize) < self.positions.len() + && (c.normal == NONE || (c.normal as usize) < self.normals.len()) + && (c.uv == NONE || (c.uv as usize) < uvs); + if !ok { + return bad("a corner index is out of range"); + } + } + } + Ok(()) + } + + /// The mesh as triangles with one vertex per distinct corner. Corners whose indices are out + /// of range are dropped with their triangle. + pub fn unified(&self) -> Unified { + let mut out = Unified::default(); + let all = |f: &dyn Fn(&Corner) -> bool| self.parts.iter().all(|p| p.corners.iter().all(f)); + let normals = !self.normals.is_empty() && all(&|c| c.normal != NONE); + let uv_set = self.uv_sets.first().filter(|_| all(&|c| c.uv != NONE)); + let colors = self.colors.len() == self.positions.len() && !self.colors.is_empty(); + let mut ids: HashMap = HashMap::new(); + for part in &self.parts { + let mut tris = Vec::with_capacity(part.triangle_count()); + 'tri: for t in part.triangles() { + let mut tri = [0u32; 3]; + for (slot, c) in tri.iter_mut().zip(t) { + let key = Corner { + position: c.position, + normal: if normals { c.normal } else { NONE }, + uv: if uv_set.is_some() { c.uv } else { NONE }, + }; + if let Some(id) = ids.get(&key) { + *slot = *id; + continue; + } + let Some(p) = self.positions.get(c.position as usize) else { continue 'tri }; + let n = self.normals.get(c.normal as usize).filter(|_| normals); + let uv = uv_set.and_then(|s| s.get(c.uv as usize)); + if (normals && n.is_none()) || (uv_set.is_some() && uv.is_none()) { + continue 'tri; + } + let Ok(id) = u32::try_from(out.positions.len()) else { continue 'tri }; + out.positions.push(*p); + out.normals.extend(n); + out.uvs.extend(uv); + if colors { + out.colors.extend(self.colors.get(c.position as usize)); + } + out.corners.push(c); + ids.insert(key, id); + *slot = id; + } + tris.push(tri); + } + out.parts.push(tris); + } + out + } +} + +/// How a material's alpha is used (glTF `alphaMode`). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub enum AlphaMode { + #[default] + Opaque, + /// Visible where alpha is at least the cutoff. + Mask(f32), + Blend, +} + +/// How a texture continues outside 0..1. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum Wrap { + #[default] + Repeat, + Clamp, + Mirror, +} + +/// A texture slot: an image of the scene, the UV set it reads and how it wraps along u and v. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct TextureRef { + /// Index into [`Scene::images`]. + pub image: usize, + pub uv_set: u32, + pub wrap: [Wrap; 2], +} + +/// A metallic-roughness material: the simple parameters every format here can carry. +#[derive(Clone, Debug, PartialEq)] +pub struct Material { + pub name: String, + /// Linear RGBA. + pub base_color: [f32; 4], + pub metallic: f32, + pub roughness: f32, + /// Linear RGB. + pub emissive: [f32; 3], + pub alpha_mode: AlphaMode, + pub double_sided: bool, + /// Shown as it is, without lighting (glTF `KHR_materials_unlit`). + pub unlit: bool, + pub base_color_texture: Option, + pub metallic_roughness_texture: Option, + pub normal_texture: Option, + pub normal_scale: f32, + pub occlusion_texture: Option, + pub occlusion_strength: f32, + pub emissive_texture: Option, +} + +impl Default for Material { + fn default() -> Self { + Material { + name: String::new(), + base_color: [1.0; 4], + metallic: 1.0, + roughness: 1.0, + emissive: [0.0; 3], + alpha_mode: AlphaMode::Opaque, + double_sided: false, + unlit: false, + base_color_texture: None, + metallic_roughness_texture: None, + normal_texture: None, + normal_scale: 1.0, + occlusion_texture: None, + occlusion_strength: 1.0, + emissive_texture: None, + } + } +} + +impl Material { + /// A plain dielectric of one colour. + pub fn plain(name: impl Into, base_color: [f32; 4]) -> Material { + Material { name: name.into(), base_color, metallic: 0.0, roughness: 0.5, ..Material::default() } + } +} + +/// An image a material uses: a reference the caller resolves, or bytes the file embedded. The +/// bytes are the encoded image (PNG, JPEG, …); decoding is the caller's. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Image { + pub name: String, + /// The reference as the file writes it, when the image is another file. + pub uri: Option, + pub mime_type: Option, + /// The encoded bytes, when the file embeds them or the resolver supplied them. + pub data: Option>, +} + +/// A 4×4 transform, column-major (as glTF writes it): a point is `M · [x, y, z, 1]`. +pub type Mat4 = [f64; 16]; + +pub const IDENTITY: Mat4 = [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0]; + +/// `a · b`: `b` applies first. +pub fn mat_mul(a: &Mat4, b: &Mat4) -> Mat4 { + let mut m = [0.0; 16]; + for (i, v) in m.iter_mut().enumerate() { + let (col, row) = (i / 4, i % 4); + *v = (0..4).map(|k| a[k * 4 + row] * b[col * 4 + k]).sum(); + } + m +} + +/// The point `p` moved by `m`. +pub fn transform_point(m: &Mat4, p: [f64; 3]) -> [f64; 3] { + [0, 1, 2].map(|r| m[r] * p[0] + m[4 + r] * p[1] + m[8 + r] * p[2] + m[12 + r]) +} + +/// Does `m` mirror (a negative determinant)? A mirrored mesh needs its winding reversed to +/// keep facing out; the writers of formats without a hierarchy do that. +pub fn is_mirror(m: &Mat4) -> bool { + let l = |r: usize, c: usize| m[c * 4 + r]; + l(0, 0) * (l(1, 1) * l(2, 2) - l(1, 2) * l(2, 1)) - l(0, 1) * (l(1, 0) * l(2, 2) - l(1, 2) * l(2, 0)) + + l(0, 2) * (l(1, 0) * l(2, 1) - l(1, 1) * l(2, 0)) + < 0.0 +} + +/// The normal `n` under `m` (by the inverse transpose of its linear part, so scale, shear and +/// mirrors are right), normalised; `n` itself when the result has no length. +pub fn transform_normal(m: &Mat4, n: [f64; 3]) -> [f64; 3] { + let l = |r: usize, c: usize| m[c * 4 + r]; + // Cyclic indices give each cofactor its sign; the cofactors are the inverse transpose + // times the determinant, whose sign a mirror flips. + let cof = |r: usize, c: usize| { + let (r1, r2, c1, c2) = ((r + 1) % 3, (r + 2) % 3, (c + 1) % 3, (c + 2) % 3); + l(r1, c1) * l(r2, c2) - l(r1, c2) * l(r2, c1) + }; + let sign = if is_mirror(m) { -1.0 } else { 1.0 }; + let v = [0, 1, 2].map(|r| sign * (cof(r, 0) * n[0] + cof(r, 1) * n[1] + cof(r, 2) * n[2])); + let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); + if len.is_finite() && len > 0.0 { v.map(|x| x / len) } else { n } +} + +/// A placement as translation, rotation (a unit quaternion `x, y, z, w`) and scale. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Trs { + pub translation: [f64; 3], + pub rotation: [f64; 4], + pub scale: [f64; 3], +} + +impl Trs { + /// `T · R · S`. + pub fn matrix(&self) -> Mat4 { + let [x, y, z, w] = self.rotation; + let r = [ + [1.0 - 2.0 * (y * y + z * z), 2.0 * (x * y + z * w), 2.0 * (x * z - y * w)], + [2.0 * (x * y - z * w), 1.0 - 2.0 * (x * x + z * z), 2.0 * (y * z + x * w)], + [2.0 * (x * z + y * w), 2.0 * (y * z - x * w), 1.0 - 2.0 * (x * x + y * y)], + ]; + let mut m = IDENTITY; + for (c, (col, s)) in r.iter().zip(self.scale).enumerate() { + for (row, v) in col.iter().enumerate() { + m[c * 4 + row] = v * s; + } + } + m[12] = self.translation[0]; + m[13] = self.translation[1]; + m[14] = self.translation[2]; + m + } +} + +/// A node of the scene's hierarchy. +#[derive(Clone, Debug, PartialEq)] +pub struct Node { + pub name: String, + /// Placement relative to the parent. + pub transform: Mat4, + /// The same placement in parts, when the file gave it so (animations move the parts). + pub trs: Option, + /// Index into [`Scene::cameras`]. + pub camera: Option, + /// Index into [`Scene::lights`]. + pub light: Option, + /// Index into [`Scene::meshes`]. + pub mesh: Option, + /// Index into [`Scene::skins`]. + pub skin: Option, + /// Indices into [`Scene::nodes`]. + pub children: Vec, +} + +impl Default for Node { + fn default() -> Self { + Node { name: String::new(), transform: IDENTITY, trs: None, camera: None, light: None, mesh: None, skin: None, children: Vec::new() } + } +} + +/// A skin (glTF): the joint nodes and their inverse bind matrices. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Skin { + pub name: String, + /// Indices into [`Scene::nodes`]. + pub joints: Vec, + /// One per joint, or empty for identity. + pub inverse_bind: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Projection { + /// Vertical field of view in radians; the aspect ratio when the file fixes one. + Perspective { yfov: f64, aspect: Option, znear: f64, zfar: Option }, + /// Half the view's width and height. + Orthographic { xmag: f64, ymag: f64, znear: f64, zfar: f64 }, +} + +/// A camera, looking along its node's −Z with +Y up. +#[derive(Clone, Debug, PartialEq)] +pub struct Camera { + pub name: String, + pub projection: Projection, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum LightKind { + Directional, + Point, + /// Cone angles in radians from the axis. + Spot { + inner: f64, + outer: f64, + }, +} + +/// A punctual light, shining along its node's −Z. +#[derive(Clone, Debug, PartialEq)] +pub struct Light { + pub name: String, + pub kind: LightKind, + /// Linear RGB. + pub color: [f64; 3], + /// Candela for point and spot lights, lux for directional ones. + pub intensity: f64, + pub range: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ChannelPath { + Translation, + Rotation, + Scale, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Interpolation { + Step, + Linear, + /// Three values per key: in-tangent, value, out-tangent. + CubicSpline, +} + +/// The keys of one part of one node's placement. Sampling them is the caller's. +#[derive(Clone, Debug, PartialEq)] +pub struct Channel { + /// Index into [`Scene::nodes`]. + pub node: usize, + pub path: ChannelPath, + pub interpolation: Interpolation, + /// Key times in seconds. + pub times: Vec, + /// The keys' values, flat: 3 per key for translation and scale, 4 for rotation (times 3 + /// for a cubic spline). + pub values: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Animation { + pub name: String, + pub channels: Vec, +} + +/// What a file holds. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Scene { + pub meshes: Vec, + pub materials: Vec, + pub images: Vec, + /// Every format fills this: a format without a hierarchy gets one root node per mesh. + pub nodes: Vec, + /// The top-level nodes. + pub roots: Vec, + pub skins: Vec, + pub animations: Vec, + pub cameras: Vec, + pub lights: Vec, + /// Every other file this one names, as written, whether or not the resolver supplied it. + pub external: Vec, + /// What was left out or repaired while reading. + pub warnings: Vec, +} + +impl Scene { + /// A scene of meshes without a hierarchy: one root node per mesh. + pub fn from_meshes(meshes: Vec, materials: Vec) -> Scene { + let nodes: Vec = meshes.iter().enumerate().map(|(i, m)| Node { name: m.name.clone(), mesh: Some(i), ..Node::default() }).collect(); + Scene { roots: (0..nodes.len()).collect(), nodes, meshes, materials, ..Scene::default() } + } + + /// Every placed mesh: the mesh's index and its world transform, walking the hierarchy from + /// the roots. A hierarchy that is deeper than `limits.max_depth`, takes more than + /// `limits.max_nodes` visits (a cycle, or a shared subtree expanded too often) or names a + /// node that is not there is an error. + pub fn instances(&self, limits: &Limits) -> Result> { + let mut out = Vec::new(); + let mut stack: Vec<(usize, Mat4, usize)> = self.roots.iter().rev().map(|r| (*r, IDENTITY, 0)).collect(); + let mut visits = 0usize; + while let Some((index, parent, depth)) = stack.pop() { + visits += 1; + if visits > limits.max_nodes { + return Err(Error::TooLarge(format!("the node hierarchy takes more than {} visits", limits.max_nodes))); + } + if depth > limits.max_depth { + return Err(Error::TooLarge(format!("the node hierarchy is deeper than {}", limits.max_depth))); + } + let node = self.nodes.get(index).ok_or_else(|| Error::Malformed(format!("node {index} does not exist")))?; + let world = mat_mul(&parent, &node.transform); + if let Some(m) = node.mesh.filter(|m| *m < self.meshes.len()) { + out.push((m, world)); + } + stack.extend(node.children.iter().rev().map(|c| (*c, world, depth + 1))); + } + Ok(out) + } + + /// [`Mesh::validate`] for every mesh, and every material, image, mesh, skin, camera, light + /// and node index in range. + pub fn validate(&self) -> Result<()> { + let bad = |m: String| Err(Error::Malformed(m)); + for m in &self.meshes { + m.validate()?; + if m.parts.iter().any(|p| p.material.is_some_and(|i| i >= self.materials.len())) { + return bad(format!("mesh `{}` names a material that does not exist", m.name)); + } + } + for m in &self.materials { + let slots = [m.base_color_texture, m.metallic_roughness_texture, m.normal_texture, m.occlusion_texture, m.emissive_texture]; + if slots.iter().flatten().any(|t| t.image >= self.images.len()) { + return bad(format!("material `{}` names an image that does not exist", m.name)); + } + } + let n = self.nodes.len(); + for node in &self.nodes { + let refs = + [(node.mesh, self.meshes.len()), (node.skin, self.skins.len()), (node.camera, self.cameras.len()), (node.light, self.lights.len())]; + if refs.iter().any(|(i, len)| i.is_some_and(|i| i >= *len)) || node.children.iter().any(|c| *c >= n) { + return bad(format!("node `{}` names something that does not exist", node.name)); + } + } + let channels = self.animations.iter().flat_map(|a| &a.channels).any(|c| c.node >= n); + if self.roots.iter().any(|r| *r >= n) || self.skins.iter().any(|s| s.joints.iter().any(|j| *j >= n)) || channels { + return bad("a root, joint or animation channel names a node that does not exist".into()); + } + Ok(()) + } +} + +/// sRGB-encoded component to linear. +pub fn srgb_to_linear(c: f32) -> f32 { + if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) } +} + +/// Linear component to sRGB-encoded. +pub fn linear_to_srgb(c: f32) -> f32 { + if c <= 0.003_130_8 { c * 12.92 } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 } +} + +/// Warnings that would repeat are counted and reported once. +pub(crate) fn count_warning(warnings: &mut Vec, count: usize, what: &str) { + if count > 0 { + warnings.push(format!("{count} {what}")); + } +} + +/// The unit normal of triangle `v` from its counter-clockwise winding; `None` when it has no +/// area. +pub fn face_normal(v: [[f64; 3]; 3]) -> Option<[f64; 3]> { + let (a, b) = ([0, 1, 2].map(|k| v[1][k] - v[0][k]), [0, 1, 2].map(|k| v[2][k] - v[0][k])); + let n = [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; + let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt(); + (len.is_finite() && len > 0.0).then(|| n.map(|x| x / len)) +} + +#[cfg(test)] +mod tests; diff --git a/crates/mesh/src/obj.rs b/crates/mesh/src/obj.rs new file mode 100644 index 00000000..d47151e3 --- /dev/null +++ b/crates/mesh/src/obj.rs @@ -0,0 +1,571 @@ +//! Wavefront OBJ geometry and MTL materials, read and written. +//! +//! OBJ read: `v x y z` with optional vertex colours (`v x y z r g b`), `vt`, `vn`, `f` with +//! `v`, `v/vt`, `v//vn` and `v/vt/vn` references (negative ones count back from the end), +//! polygons of any size kept whole, `o` objects (one [`Mesh`] each, holding only the vertices +//! it uses), `g` groups, `usemtl` and `s` smoothing groups (a [`Part`] per combination), +//! `mtllib` (asked of the resolver), `\` line continuations and `#` comments. Lines (`l`), +//! points (`p`) and free-form geometry are passed over. A vertex or face line that cannot be +//! read is left out and counted in one warning; the numbering of later vertices is kept. +//! +//! MTL materials map onto metallic-roughness: `Kd` → base colour, `d`/`Tr` → alpha (blended +//! when below 1), `Ke` → emissive, `Pr`/`Pm` (the PBR extension) → roughness/metallic, +//! otherwise roughness from the Phong exponent `Ns` (`sqrt(2 / (Ns + 2))`) and metallic 0; +//! `map_Kd`, `map_Ke`, `map_Bump`/`bump`/`norm` name base colour, emissive and normal images. +//! An image is only named ([`Image::uri`]); reading and decoding it is the caller's. + +use std::collections::HashMap; +use std::fmt::Write as _; + +use crate::{ + AlphaMode, Corner, Error, Image, Limits, Material, Mesh, NONE, Part, Resolver, Result, Scene, TextureRef, count_warning, is_mirror, + linear_to_srgb, malformed, srgb_to_linear, too_large, transform_normal, transform_point, +}; + +const KIND: &str = "OBJ"; + +/// The last component of a referenced path, treating `\` as a separator too: a path from a +/// machine of another kind (`C:\textures\checker.png`) still names `checker.png`. `None` when +/// the name carries no path at all. +pub fn sibling_name(name: &str) -> Option<&str> { + let file = name.rsplit(['/', '\\']).next()?; + (!file.is_empty() && file.len() < name.len()).then_some(file) +} + +/// The image a `map_*` line names: the rest of the line (a name with spaces is one name), +/// or its final token when the line carries options (`-bm 1 file.png`). +fn map_name(rest: &str) -> &str { + if rest.starts_with('-') { rest.split_whitespace().last().unwrap_or(rest) } else { rest } +} + +/// Materials of an MTL library and the images they name. `TextureRef::image` indexes the +/// returned images. +pub fn read_mtl(bytes: &[u8], limits: &Limits) -> Result<(Vec, Vec)> { + let text = String::from_utf8_lossy(bytes); + let mut out: Vec = vec![]; + let mut images: Vec = vec![]; + let mut pr_seen = false; + for line in text.lines() { + let line = line.trim(); + let (key, rest) = line.split_once(char::is_whitespace).map(|(k, r)| (k, r.trim())).unwrap_or((line, "")); + if key == "newmtl" { + if out.len() >= limits.max_materials { + return Err(too_large("MTL", format!("more than {} materials", limits.max_materials))); + } + pr_seen = false; + out.push(Material::plain(rest, [1.0; 4])); + continue; + } + let Some(m) = out.last_mut() else { continue }; + mtl_line(m, key, rest, &mut pr_seen, &mut images, limits)?; + } + for m in &mut out { + if m.base_color[3] < 0.999 { + m.alpha_mode = AlphaMode::Blend; + } + } + Ok((out, images)) +} + +fn mtl_line(m: &mut Material, key: &str, rest: &str, pr_seen: &mut bool, images: &mut Vec, limits: &Limits) -> Result<()> { + let finite = |x: &str| x.parse::().ok().filter(|v| v.is_finite()); + let f3 = || -> [f32; 3] { + let v: Vec = rest.split_whitespace().take(3).filter_map(finite).collect(); + match v[..] { + [a, b, c] => [a, b, c], + [a, ..] => [a; 3], + [] => [0.0; 3], + } + }; + let f1 = || rest.split_whitespace().next().and_then(finite); + let mut tex = || -> Result> { + let name = map_name(rest); + if name.is_empty() { + return Ok(None); + } + let image = match images.iter().position(|i| i.uri.as_deref() == Some(name)) { + Some(i) => i, + None if images.len() >= limits.max_materials => return Err(too_large("MTL", format!("more than {} images", limits.max_materials))), + None => { + images.push(Image { name: name.to_string(), uri: Some(name.to_string()), ..Image::default() }); + images.len() - 1 + } + }; + Ok(Some(TextureRef { image, ..TextureRef::default() })) + }; + match key { + "Kd" => { + let c = f3().map(srgb_to_linear); + m.base_color = [c[0], c[1], c[2], m.base_color[3]]; + } + "Ke" => m.emissive = f3().map(srgb_to_linear), + "d" => m.base_color[3] = f1().unwrap_or(1.0).clamp(0.0, 1.0), + "Tr" => m.base_color[3] = (1.0 - f1().unwrap_or(0.0)).clamp(0.0, 1.0), + "Ns" if !*pr_seen => m.roughness = (2.0 / (f1().unwrap_or(0.0).max(0.0) + 2.0)).sqrt(), + "Pr" => { + m.roughness = f1().unwrap_or(0.5).clamp(0.0, 1.0); + *pr_seen = true; + } + "Pm" => m.metallic = f1().unwrap_or(0.0).clamp(0.0, 1.0), + "map_Kd" => m.base_color_texture = tex()?, + "map_Ke" => { + m.emissive_texture = tex()?; + if m.emissive == [0.0; 3] { + m.emissive = [1.0; 3]; + } + } + "map_Bump" | "map_bump" | "bump" | "norm" => m.normal_texture = tex()?, + _ => {} + } + Ok(()) +} + +/// A 1-based (or negative, counting back) OBJ reference as an index into `len` entries. +fn index(token: &str, len: usize) -> Option { + let value: i64 = token.parse().ok()?; + let len = i64::try_from(len).ok()?; + let value = if value < 0 { len.checked_add(value)? } else { value.checked_sub(1)? }; + (0..len).contains(&value).then(|| u32::try_from(value).ok()).flatten() +} + +#[derive(Default)] +struct Object { + name: String, + parts: Vec, + slots: HashMap<(String, Option, u32), usize>, +} + +struct Reader<'a> { + limits: &'a Limits, + v: Vec<[f64; 3]>, + colors: Vec<[f32; 4]>, + vt: Vec<[f32; 2]>, + vn: Vec<[f64; 3]>, + objects: Vec, + scene: Scene, + material_ids: HashMap, + material: Option, + group: String, + smoothing: u32, + /// The part faces go to until the object, group, material or smoothing changes. + slot: Option, + triangles: usize, + parts: usize, + bad_lines: usize, + data_bytes: usize, +} + +const NAN3: [f64; 3] = [f64::NAN; 3]; + +/// `N` finite numbers from the next `N` tokens. +fn numbers<'a, const N: usize>(mut it: impl Iterator) -> Option<[f64; N]> { + let mut out = [0.0; N]; + for v in &mut out { + *v = it.next()?.parse::().ok().filter(|v| v.is_finite())?; + } + Some(out) +} + +impl Reader<'_> { + fn pool_room(&self, len: usize, what: &str) -> Result<()> { + if len >= self.limits.max_vertices { + return Err(too_large(KIND, format!("more than {} {what}", self.limits.max_vertices))); + } + Ok(()) + } + + fn vertex(&mut self, rest: &[&str]) -> Result<()> { + self.pool_room(self.v.len(), "vertices")?; + let p = numbers::<3>(rest.iter().copied()); + // Keep the numbering: later faces count vertices by position. + self.v.push(p.unwrap_or(NAN3)); + self.bad_lines += usize::from(p.is_none()); + // `v x y z r g b`: vertices without a colour are white. + if let (true, Some([r, g, b])) = (rest.len() >= 6, numbers::<3>(rest.iter().skip(3).copied())) { + self.colors.resize(self.v.len() - 1, [1.0; 4]); + self.colors.push([r as f32, g as f32, b as f32, 1.0]); + } + Ok(()) + } + + fn start_object(&mut self, name: String) -> Result<()> { + self.slot = None; + let full = self.objects.len() >= self.limits.max_meshes; + match self.objects.last_mut() { + // An empty object takes the name instead of starting another. + Some(o) if o.parts.is_empty() => o.name = name, + _ if full => return Err(too_large(KIND, format!("more than {} objects", self.limits.max_meshes))), + _ => self.objects.push(Object { name, ..Object::default() }), + } + Ok(()) + } + + fn use_material(&mut self, name: &str) -> Result<()> { + self.slot = None; + self.material = match self.material_ids.get(name) { + Some(i) => Some(*i), + None if self.scene.materials.len() >= self.limits.max_materials => { + return Err(too_large(KIND, format!("more than {} materials", self.limits.max_materials))); + } + None => { + // Not in any library read: a named grey default. + self.scene.materials.push(Material::plain(name, [0.8, 0.8, 0.8, 1.0])); + self.material_ids.insert(name.to_string(), self.scene.materials.len() - 1); + Some(self.scene.materials.len() - 1) + } + }; + Ok(()) + } + + fn library(&mut self, name: &str, resolve: Resolver) -> Result<()> { + if name.is_empty() { + return Ok(()); + } + if self.scene.external.len() >= self.limits.max_materials { + return Err(too_large(KIND, format!("more than {} material libraries", self.limits.max_materials))); + } + self.scene.external.push(name.to_string()); + // The name as written (it may hold spaces), then each of several names, then the file + // name of a path from another machine. + let Some(bytes) = std::iter::once(name).chain(name.split_whitespace()).chain(sibling_name(name)).find_map(resolve) else { + self.scene.warnings.push(format!("material library `{name}` was not supplied")); + return Ok(()); + }; + self.data_bytes = self.data_bytes.saturating_add(bytes.len()); + if self.data_bytes > self.limits.max_data_bytes { + return Err(too_large(KIND, format!("material libraries of more than {} bytes", self.limits.max_data_bytes))); + } + let (materials, images) = read_mtl(&bytes, self.limits)?; + let base = self.scene.images.len(); + if base.saturating_add(images.len()) > self.limits.max_materials + || self.scene.materials.len().saturating_add(materials.len()) > self.limits.max_materials + { + return Err(too_large(KIND, format!("more than {} materials or images", self.limits.max_materials))); + } + self.scene.external.extend(images.iter().filter_map(|i| i.uri.clone())); + self.scene.images.extend(images); + for mut m in materials { + for t in [&mut m.base_color_texture, &mut m.emissive_texture, &mut m.normal_texture].into_iter().flatten() { + t.image += base; + } + // The first definition of a name wins, as for a reader that looks names up in order. + self.material_ids.entry(m.name.clone()).or_insert(self.scene.materials.len()); + self.scene.materials.push(m); + } + Ok(()) + } + + fn corner(&self, token: &str) -> Option { + let mut fields = token.split('/'); + let position = index(fields.next()?, self.v.len())?; + self.v.get(position as usize).filter(|p| p[0].is_finite())?; + let uv = fields.next().and_then(|t| index(t, self.vt.len())).filter(|i| self.vt.get(*i as usize).is_some_and(|t| t[0].is_finite())); + let normal = fields.next().and_then(|t| index(t, self.vn.len())).filter(|i| self.vn.get(*i as usize).is_some_and(|n| n[0].is_finite())); + Some(Corner { position, normal: normal.unwrap_or(NONE), uv: uv.unwrap_or(NONE) }) + } + + fn part(&mut self) -> Result> { + if self.objects.is_empty() { + self.objects.push(Object::default()); + } + let Some(object) = self.objects.last_mut() else { return Ok(None) }; + let slot = match self.slot { + Some(s) => s, + None => match object.slots.get(&(self.group.clone(), self.material, self.smoothing)) { + Some(s) => *s, + None => { + if self.parts >= self.limits.max_parts { + return Err(too_large(KIND, format!("more than {} groups", self.limits.max_parts))); + } + self.parts += 1; + object.parts.push(Part { name: self.group.clone(), material: self.material, smoothing: self.smoothing, ..Part::default() }); + object.slots.insert((self.group.clone(), self.material, self.smoothing), object.parts.len() - 1); + object.parts.len() - 1 + } + }, + }; + self.slot = Some(slot); + Ok(object.parts.get_mut(slot)) + } + + fn face(&mut self, rest: &[&str]) -> Result<()> { + if rest.len() > self.limits.max_face_corners { + return Err(too_large(KIND, format!("a face of more than {} corners", self.limits.max_face_corners))); + } + let corners: Option> = rest.iter().map(|t| self.corner(t)).collect(); + let Some(corners) = corners.filter(|c| c.len() >= 3) else { + self.bad_lines += 1; + return Ok(()); + }; + self.triangles = self.triangles.saturating_add(corners.len() - 2); + if self.triangles > self.limits.max_triangles { + return Err(too_large(KIND, format!("more than {} triangles", self.limits.max_triangles))); + } + let Some(part) = self.part()? else { return Ok(()) }; + // Sizes are only kept once a face is not a triangle. + if corners.len() != 3 && part.face_sizes.is_empty() { + part.face_sizes = vec![3; part.corners.len() / 3]; + } + if corners.len() != 3 || !part.face_sizes.is_empty() { + part.face_sizes.push(corners.len() as u32); + } + part.corners.extend(corners); + Ok(()) + } + + fn line(&mut self, line: &str, resolve: Resolver) -> Result<()> { + let mut it = line.split_whitespace(); + let Some(key) = it.next() else { return Ok(()) }; + let after = line.get(key.len()..).unwrap_or("").trim(); + match key { + "v" => self.vertex(&it.collect::>())?, + "vt" => { + self.pool_room(self.vt.len(), "texture coordinates")?; + // `vt u` alone is a 1D coordinate; OBJ's v axis points up the image. + let uv = numbers::<2>(it.chain(["0"])); + self.vt.push(uv.map_or([f32::NAN; 2], |[u, v]| [u as f32, 1.0 - v as f32])); + self.bad_lines += usize::from(uv.is_none()); + } + "vn" => { + self.pool_room(self.vn.len(), "normals")?; + let n = numbers::<3>(it); + self.vn.push(n.unwrap_or(NAN3)); + self.bad_lines += usize::from(n.is_none()); + } + "s" => { + self.slot = None; + self.smoothing = match it.next() { + Some("off" | "0") | None => 0, + Some("on") => 1, + Some(v) => v.parse::().unwrap_or(0), + }; + } + "o" => self.start_object(after.to_string())?, + "g" => { + self.slot = None; + self.group = after.to_string(); + } + "usemtl" => self.use_material(after)?, + "mtllib" => self.library(after, resolve)?, + "f" => self.face(&it.collect::>())?, + _ => {} + } + Ok(()) + } + + /// Each object as a mesh holding only the vertices it uses. + fn finish(mut self) -> Result { + let colored = !self.colors.is_empty(); + self.colors.resize(self.v.len(), [1.0; 4]); + let mut meshes = Vec::new(); + for object in std::mem::take(&mut self.objects) { + if object.parts.is_empty() { + continue; + } + let mut mesh = Mesh { name: object.name, parts: object.parts, ..Mesh::default() }; + // The file's order is kept within each pool. + let used = |of: fn(&Corner) -> u32| { + let mut u: Vec = mesh.parts.iter().flat_map(|p| p.corners.iter().map(of)).filter(|i| *i != NONE).collect(); + u.sort_unstable(); + u.dedup(); + u + }; + let (pu, nu, tu) = (used(|c| c.position), used(|c| c.normal), used(|c| c.uv)); + let place = |u: &[u32], i: u32| if i == NONE { NONE } else { u.binary_search(&i).map_or(NONE, |k| k as u32) }; + for c in mesh.parts.iter_mut().flat_map(|p| p.corners.iter_mut()) { + *c = Corner { position: place(&pu, c.position), normal: place(&nu, c.normal), uv: place(&tu, c.uv) }; + } + mesh.positions = pu.iter().map(|i| self.v.get(*i as usize).copied().unwrap_or_default()).collect(); + if colored { + mesh.colors = pu.iter().map(|i| self.colors.get(*i as usize).copied().unwrap_or([1.0; 4])).collect(); + } + mesh.normals = nu.iter().map(|i| self.vn.get(*i as usize).copied().unwrap_or_default()).collect(); + let uvs: Vec<[f32; 2]> = tu.iter().map(|i| self.vt.get(*i as usize).copied().unwrap_or_default()).collect(); + if !uvs.is_empty() { + mesh.uv_sets.push(uvs); + } + meshes.push(mesh); + } + if meshes.is_empty() { + return Err(malformed(KIND, "no faces")); + } + let mut scene = Scene::from_meshes(meshes, std::mem::take(&mut self.scene.materials)); + scene.images = std::mem::take(&mut self.scene.images); + scene.external = std::mem::take(&mut self.scene.external); + scene.warnings = std::mem::take(&mut self.scene.warnings); + count_warning(&mut scene.warnings, self.bad_lines, "vertex or face line(s) could not be read and were left out"); + Ok(scene) + } +} + +/// Read an OBJ file. Its material libraries are asked of `resolve` by the name the file +/// writes; every name asked, and every image a library names, is listed in +/// [`Scene::external`]. +pub fn read(bytes: &[u8], limits: &Limits, resolve: Resolver) -> Result { + let text = String::from_utf8_lossy(bytes); + let mut r = Reader { + limits, + v: vec![], + colors: vec![], + vt: vec![], + vn: vec![], + objects: vec![], + scene: Scene::default(), + material_ids: HashMap::new(), + material: None, + group: String::new(), + smoothing: 0, + slot: None, + triangles: 0, + parts: 0, + bad_lines: 0, + data_bytes: 0, + }; + // A line ending in `\` continues on the next. + let mut joined = String::new(); + for raw in text.lines() { + let line = raw.split('#').next().unwrap_or(""); + if let Some(head) = line.trim_end().strip_suffix('\\') { + joined.push_str(head); + joined.push(' '); + continue; + } + if joined.is_empty() { + r.line(line.trim(), resolve)?; + } else { + joined.push_str(line); + r.line(joined.trim(), resolve)?; + joined.clear(); + } + } + r.line(joined.trim(), resolve)?; + r.finish() +} + +/// A name as one OBJ token: whitespace and control characters become `_`. +fn token(name: &str) -> String { + let t: String = name.trim().chars().map(|c| if c.is_whitespace() || c.is_control() { '_' } else { c }).collect(); + if t.is_empty() { "material".into() } else { t } +} + +/// The name each material is written with: its [`token`], made unique with `_2`, `_3`… where +/// two would be the same, also ignoring case (the names may become file names). +pub fn material_names(scene: &Scene) -> Vec { + let mut seen = std::collections::BTreeSet::new(); + scene + .materials + .iter() + .map(|m| { + let base = token(&m.name); + let mut unique = base.clone(); + let mut n = 1u32; + while !seen.insert(unique.to_lowercase()) { + n = n.saturating_add(1); + unique = format!("{base}_{n}"); + } + unique + }) + .collect() +} + +/// The scene as OBJ text: every mesh placed by its nodes as an object (`o`), with its vertex +/// colours, first UV set and normals, a group (`g`), material (`usemtl`) and smoothing group +/// (`s`) per part, and polygons kept whole. `mtllib` is the material library's file name, to +/// be written next to the OBJ from [`write_mtl`]; `None` writes no `mtllib` line. +pub fn write(scene: &Scene, mtllib: Option<&str>, limits: &Limits) -> Result { + let mut text = String::new(); + if let Some(lib) = mtllib.filter(|_| !scene.materials.is_empty()) { + let _ = writeln!(text, "mtllib {}", lib.replace(['\n', '\r'], " ")); + } + let materials = material_names(scene); + // OBJ indices run on across objects. + let (mut vbase, mut tbase, mut nbase) = (1u64, 1u64, 1u64); + let mut smoothing = 0; + for (index, world) in scene.instances(limits)? { + let Some(mesh) = scene.meshes.get(index) else { continue }; + mesh.validate()?; + if !mesh.name.trim().is_empty() { + let _ = writeln!(text, "o {}", mesh.name.trim().replace(|c: char| c.is_control(), " ")); + } + let colors = mesh.colors.len() == mesh.positions.len(); + let mirror = is_mirror(&world); + for (i, p) in mesh.positions.iter().enumerate() { + let p = transform_point(&world, *p); + let _ = write!(text, "v {} {} {}", p[0], p[1], p[2]); + if let Some(c) = mesh.colors.get(i).filter(|_| colors) { + let _ = write!(text, " {} {} {}", c[0], c[1], c[2]); + } + text.push('\n'); + } + let uvs = mesh.uv_sets.first().map_or(&[][..], Vec::as_slice); + for t in uvs { + let _ = writeln!(text, "vt {} {}", t[0], 1.0 - t[1]); + } + for n in &mesh.normals { + let n = transform_normal(&world, *n); + let _ = writeln!(text, "vn {} {} {}", n[0], n[1], n[2]); + } + for part in &mesh.parts { + if !part.name.trim().is_empty() { + let _ = writeln!(text, "g {}", part.name.trim().replace(|c: char| c.is_control(), " ")); + } + if let Some(m) = part.material.and_then(|m| materials.get(m)) { + let _ = writeln!(text, "usemtl {m}"); + } + if part.smoothing != smoothing { + smoothing = part.smoothing; + let _ = if smoothing == 0 { writeln!(text, "s off") } else { writeln!(text, "s {smoothing}") }; + } + for face in part.faces() { + text.push('f'); + // A mirrored placement turns the winding; turn it back so the face faces out. + let mut face = face.to_vec(); + if mirror { + face.reverse(); + } + for c in face { + let v = vbase + u64::from(c.position); + let _ = match (c.uv != NONE, c.normal != NONE) { + (true, true) => write!(text, " {v}/{}/{}", tbase + u64::from(c.uv), nbase + u64::from(c.normal)), + (true, false) => write!(text, " {v}/{}", tbase + u64::from(c.uv)), + (false, true) => write!(text, " {v}//{}", nbase + u64::from(c.normal)), + (false, false) => write!(text, " {v}"), + }; + } + text.push('\n'); + } + } + vbase += mesh.positions.len() as u64; + tbase += uvs.len() as u64; + nbase += mesh.normals.len() as u64; + } + if vbase == 1 { + return Err(Error::Malformed("OBJ: the scene has no placed mesh to write".into())); + } + Ok(text) +} + +/// The scene's materials as an MTL library, named as [`write`] names them: `Kd`, `d`, `Ke`, +/// `Ns` (for readers without the PBR extension) with `Pr` and `Pm`, and `map_Kd`, `map_Ke` +/// and `norm` for images that have a [`Image::uri`]. +pub fn write_mtl(scene: &Scene) -> String { + let mut mtl = String::new(); + for (m, name) in scene.materials.iter().zip(material_names(scene)) { + let kd = [m.base_color[0], m.base_color[1], m.base_color[2]].map(linear_to_srgb); + let ke = m.emissive.map(linear_to_srgb); + let r = m.roughness.clamp(0.0, 1.0); + let ns = if r > 0.0 { (2.0 / (r * r) - 2.0).clamp(0.0, 1000.0) } else { 1000.0 }; + let _ = writeln!( + mtl, + "newmtl {name}\nKd {} {} {}\nd {}\nKe {} {} {}\nNs {ns}\nPr {r}\nPm {}", + kd[0], kd[1], kd[2], m.base_color[3], ke[0], ke[1], ke[2], m.metallic + ); + for (key, tex) in [("map_Kd", m.base_color_texture), ("map_Ke", m.emissive_texture), ("norm", m.normal_texture)] { + if let Some(uri) = tex.and_then(|t| scene.images.get(t.image)).and_then(|i| i.uri.as_deref()) { + let _ = writeln!(mtl, "{key} {}", uri.replace(['\n', '\r'], " ")); + } + } + mtl.push('\n'); + } + mtl +} diff --git a/crates/mesh/src/ply.rs b/crates/mesh/src/ply.rs new file mode 100644 index 00000000..bba47898 --- /dev/null +++ b/crates/mesh/src/ply.rs @@ -0,0 +1,529 @@ +//! PLY (polygon file format), read. +//! +//! From the format's public description: a text header naming elements and their properties, +//! then the elements' rows in ASCII or binary (little- or big-endian). [`read`] takes the +//! `vertex` element (`x`, `y`, `z` of any numeric type; `nx`, `ny`, `nz`; `s`/`t`, `u`/`v` or +//! `texture_u`/`texture_v`; `red`, `green`, `blue` and `alpha`) and the `face` element's +//! `vertex_indices` list, polygons kept whole. Every other element and property is passed +//! over. A file without faces is a point cloud: a mesh with positions and no parts. +//! +//! Positions are kept as stored, so the indices of a point cloud stay the file's; a face that +//! names a vertex that is not there, or one whose position is not finite, is left out and +//! counted in a warning. +//! +//! [`parse_header`] and [`Rows`] are public for callers that need more than a mesh from a PLY +//! file (every scalar property as a column, `edge` elements, thinning a large cloud): they +//! read the same header and rows with the same checks. + +use crate::{Corner, Limits, Mesh, Part, Result, Scene, count_warning, malformed, too_large}; + +const KIND: &str = "PLY"; +/// Largest header read (bytes). +pub const MAX_HEADER: usize = 1 << 20; +/// Most elements a header may declare. +pub const MAX_ELEMENTS: usize = 64; +/// Most properties one element may have. +pub const MAX_PROPERTIES: usize = 256; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Scalar { + I8, + U8, + I16, + U16, + I32, + U32, + F32, + F64, +} + +impl Scalar { + fn parse(s: &str) -> Option { + Some(match s { + "char" | "int8" => Scalar::I8, + "uchar" | "uint8" => Scalar::U8, + "short" | "int16" => Scalar::I16, + "ushort" | "uint16" => Scalar::U16, + "int" | "int32" => Scalar::I32, + "uint" | "uint32" => Scalar::U32, + "float" | "float32" => Scalar::F32, + "double" | "float64" => Scalar::F64, + _ => return None, + }) + } + + /// Bytes of one value in a binary file. + pub fn size(self) -> usize { + match self { + Scalar::I8 | Scalar::U8 => 1, + Scalar::I16 | Scalar::U16 => 2, + Scalar::I32 | Scalar::U32 | Scalar::F32 => 4, + Scalar::F64 => 8, + } + } + + pub fn is_integer(self) -> bool { + !matches!(self, Scalar::F32 | Scalar::F64) + } + + /// The value at the start of `b`. + pub fn read(self, b: &[u8], big: bool) -> Option { + fn take(b: &[u8], big: bool) -> Option<[u8; N]> { + let mut a: [u8; N] = b.get(..N)?.try_into().ok()?; + if big { + a.reverse(); + } + Some(a) + } + Some(match self { + Scalar::I8 => f64::from(i8::from_le_bytes(take(b, big)?)), + Scalar::U8 => f64::from(*b.first()?), + Scalar::I16 => f64::from(i16::from_le_bytes(take(b, big)?)), + Scalar::U16 => f64::from(u16::from_le_bytes(take(b, big)?)), + Scalar::I32 => f64::from(i32::from_le_bytes(take(b, big)?)), + Scalar::U32 => f64::from(u32::from_le_bytes(take(b, big)?)), + Scalar::F32 => f64::from(f32::from_le_bytes(take(b, big)?)), + Scalar::F64 => f64::from_le_bytes(take(b, big)?), + }) + } + + /// A colour channel of this type as 0..1: integers by their range, floats as they are. + pub fn unit(self, v: f64) -> f32 { + let v = match self { + Scalar::F32 | Scalar::F64 => v, + Scalar::U8 | Scalar::I8 => v / 255.0, + Scalar::U16 | Scalar::I16 => v / 65_535.0, + Scalar::U32 | Scalar::I32 => v / 4_294_967_295.0, + }; + if v.is_finite() { v.clamp(0.0, 1.0) as f32 } else { 0.0 } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Kind { + Scalar(Scalar), + /// The count's type and the items' type. + List(Scalar, Scalar), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Property { + pub name: String, + pub kind: Kind, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Element { + pub name: String, + /// Rows the header declares; not yet checked against the file. + pub count: u64, + pub properties: Vec, +} + +impl Element { + /// Bytes per row in a binary file, when every property is a scalar. + pub fn row_size(&self) -> Option { + self.properties.iter().try_fold(0usize, |n, p| match p.kind { + Kind::Scalar(s) => n.checked_add(s.size()), + Kind::List(..) => None, + }) + } + + /// The first scalar property with one of `names` (ASCII case ignored), and its type. + pub fn find(&self, names: &[&str]) -> Option<(usize, Scalar)> { + names.iter().find_map(|n| { + self.properties.iter().enumerate().find_map(|(i, p)| match p.kind { + Kind::Scalar(s) if p.name.eq_ignore_ascii_case(n) => Some((i, s)), + _ => None, + }) + }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Encoding { + Ascii, + Little, + Big, +} + +/// What the header says, and where the rows start. +#[derive(Clone, Debug, PartialEq)] +pub struct Header { + pub encoding: Encoding, + pub elements: Vec, + /// Offset of the first row. + pub body: usize, + /// The text of the `comment` lines. + pub comments: Vec, +} + +impl Header { + /// Rows the header declares for the element `name` (0 when it has none). + pub fn count(&self, name: &str) -> u64 { + self.elements.iter().find(|e| e.name == name).map_or(0, |e| e.count) + } +} + +/// Does `bytes` start like a PLY file? +pub fn is_ply(bytes: &[u8]) -> bool { + bytes.starts_with(b"ply") && bytes.get(3).is_some_and(|b| matches!(b, b'\n' | b'\r')) +} + +fn property(e: &mut Element, it: &mut std::str::SplitWhitespace<'_>) -> Result<()> { + if e.properties.len() >= MAX_PROPERTIES { + return Err(too_large(KIND, format!("more than {MAX_PROPERTIES} properties in an element"))); + } + let first = it.next().unwrap_or(""); + let kind = if first == "list" { + let count = it.next().and_then(Scalar::parse).ok_or_else(|| malformed(KIND, "a list property without a count type"))?; + let item = it.next().and_then(Scalar::parse).ok_or_else(|| malformed(KIND, "a list property without an item type"))?; + if !count.is_integer() { + return Err(malformed(KIND, "a list's count must be an integer")); + } + Kind::List(count, item) + } else { + Kind::Scalar(Scalar::parse(first).ok_or_else(|| malformed(KIND, format!("unknown property type `{}`", short(first))))?) + }; + let name = it.next().ok_or_else(|| malformed(KIND, "a property without a name"))?.to_string(); + e.properties.push(Property { name, kind }); + Ok(()) +} + +fn short(s: &str) -> String { + s.chars().take(32).collect() +} + +/// Read the header: the encoding, the elements with their properties, and where the rows start. +pub fn parse_header(bytes: &[u8]) -> Result
{ + if !is_ply(bytes) { + return Err(malformed(KIND, "not a PLY file (it must start with `ply`)")); + } + let head = bytes.get(..bytes.len().min(MAX_HEADER)).unwrap_or(bytes); + // `end_header` at the start of a line, so a comment that mentions it does not end the header. + let end = head + .windows(11) + .position(|w| matches!(w.first(), Some(b'\n' | b'\r')) && w.get(1..) == Some(b"end_header")) + .map(|p| p + 1) + .ok_or_else(|| malformed(KIND, "no `end_header` in the first megabyte"))?; + // The rows start after the end of that line (`\n`, `\r\n` or a lone `\r`). + let mut body = end + 10; + while head.get(body).is_some_and(|b| *b == b' ' || *b == b'\t') { + body += 1; + } + match (head.get(body), head.get(body + 1)) { + (Some(b'\r'), Some(b'\n')) => body += 2, + (Some(b'\n' | b'\r'), _) => body += 1, + (None, _) => {} + _ => return Err(malformed(KIND, "`end_header` is not on a line of its own")), + } + let text = String::from_utf8_lossy(head.get(..end).unwrap_or_default()); + let mut encoding = None; + let mut elements: Vec = Vec::new(); + let mut comments = Vec::new(); + for line in text.split(['\n', '\r']).skip(1) { + if let Some(c) = line.strip_prefix("comment ") { + comments.push(c.trim().to_string()); + continue; + } + let mut it = line.split_whitespace(); + match it.next() { + Some("format") => { + encoding = Some(match it.next() { + Some("ascii") => Encoding::Ascii, + Some("binary_little_endian") => Encoding::Little, + Some("binary_big_endian") => Encoding::Big, + other => return Err(malformed(KIND, format!("unknown format `{}`", short(other.unwrap_or(""))))), + }); + } + Some("element") => { + let name = it.next().ok_or_else(|| malformed(KIND, "an element without a name"))?.to_string(); + let count = it + .next() + .and_then(|c| c.parse::().ok()) + .ok_or_else(|| malformed(KIND, format!("element `{}` has no count", short(&name))))?; + if elements.len() >= MAX_ELEMENTS { + return Err(too_large(KIND, format!("more than {MAX_ELEMENTS} elements"))); + } + elements.push(Element { name, count, properties: Vec::new() }); + } + Some("property") => property(elements.last_mut().ok_or_else(|| malformed(KIND, "a property before any element"))?, &mut it)?, + // `comment`, `obj_info` and blank lines. + _ => {} + } + } + let encoding = encoding.ok_or_else(|| malformed(KIND, "no `format` line"))?; + Ok(Header { encoding, elements, body, comments }) +} + +fn space(b: &u8) -> bool { + b.is_ascii_whitespace() +} + +/// The values of the rows, read one after another in the header's order. +pub enum Rows<'a> { + Binary { b: &'a [u8], pos: usize, big: bool }, + Ascii { tokens: std::slice::Split<'a, u8, fn(&u8) -> bool>, left: usize }, +} + +impl<'a> Rows<'a> { + pub fn new(h: &Header, bytes: &'a [u8]) -> Rows<'a> { + let b = bytes.get(h.body..).unwrap_or_default(); + match h.encoding { + Encoding::Ascii => Rows::Ascii { tokens: b.split(space as fn(&u8) -> bool), left: b.len() }, + Encoding::Little => Rows::Binary { b, pos: 0, big: false }, + Encoding::Big => Rows::Binary { b, pos: 0, big: true }, + } + } + + /// Bytes not yet read. Every row takes at least one, so an element that declares more + /// rows than this is damaged. + pub fn remaining(&self) -> usize { + match self { + Rows::Binary { b, pos, .. } => b.len().saturating_sub(*pos), + Rows::Ascii { left, .. } => *left, + } + } + + /// The next value, of type `t`. + pub fn value(&mut self, t: Scalar) -> Result { + match self { + Rows::Binary { b, pos, big } => { + let v = b.get(*pos..).and_then(|r| t.read(r, *big)).ok_or_else(|| malformed(KIND, "the file ends early"))?; + *pos += t.size(); + Ok(v) + } + Rows::Ascii { tokens, left } => loop { + let tok = tokens.next().ok_or_else(|| malformed(KIND, "the file ends early"))?; + *left = left.saturating_sub(tok.len() + 1); + if tok.is_empty() { + continue; + } + let s = std::str::from_utf8(tok).map_err(|_| malformed(KIND, "a value is not a number"))?; + // Integers written as decimals (`255.0`) are taken too. + return s.parse::().map_err(|_| malformed(KIND, format!("`{}` is not a number", short(s)))); + }, + } + } + + /// Pass over `n` values of type `t`. + pub fn skip(&mut self, t: Scalar, n: usize) -> Result<()> { + match self { + Rows::Binary { b, pos, .. } => { + *pos = n + .checked_mul(t.size()) + .and_then(|k| pos.checked_add(k)) + .filter(|p| *p <= b.len()) + .ok_or_else(|| malformed(KIND, "the file ends early"))?; + Ok(()) + } + Rows::Ascii { .. } => (0..n).try_for_each(|_| self.value(t).map(|_| ())), + } + } + + /// A list's length, of type `t`, at most `max`. + pub fn count(&mut self, t: Scalar, max: usize) -> Result { + let n = self.value(t)?; + if !(n >= 0.0 && n <= max as f64 && n.fract() == 0.0) { + return Err(too_large(KIND, format!("a list of more than {max} items (or a damaged length)"))); + } + Ok(n as usize) + } + + /// Checks that the rows of `e` can be in what is left of the file, before any is read: a + /// row takes at least a byte, and exactly [`Element::row_size`] in a binary file. + pub fn check(&self, e: &Element) -> Result<()> { + let per_row = match self { + Rows::Binary { .. } => e.row_size().unwrap_or(1).max(1), + Rows::Ascii { .. } => 1, + }; + match e.count.checked_mul(per_row as u64) { + Some(n) if n <= self.remaining() as u64 => Ok(()), + _ => Err(malformed(KIND, format!("the file is too short for its {} `{}` rows", e.count, short(&e.name)))), + } + } + + /// Pass over all rows of `e`. + pub fn skip_element(&mut self, e: &Element, max_list: usize) -> Result<()> { + self.check(e)?; + if let (Rows::Binary { pos, .. }, Some(size)) = (&mut *self, e.row_size()) { + // `check` has shown that the block fits. + *pos = pos.saturating_add((e.count as usize).saturating_mul(size)); + return Ok(()); + } + for _ in 0..e.count { + for p in &e.properties { + match p.kind { + Kind::Scalar(t) => self.skip(t, 1)?, + Kind::List(c, t) => { + let n = self.count(c, max_list)?; + self.skip(t, n)?; + } + } + } + } + Ok(()) + } +} + +/// Where the vertex element keeps what a mesh takes. +struct VertexColumns { + xyz: [usize; 3], + normal: Option<[usize; 3]>, + uv: Option<[usize; 2]>, + rgb: Option<[(usize, Scalar); 3]>, + alpha: Option<(usize, Scalar)>, +} + +impl VertexColumns { + fn of(e: &Element) -> Result { + let at = |names: &[&str]| e.find(names).map(|(i, _)| i); + let three = |a: &[&str], b: &[&str], c: &[&str]| Some([at(a)?, at(b)?, at(c)?]); + let xyz = three(&["x"], &["y"], &["z"]).ok_or_else(|| malformed(KIND, "the vertices have no `x`, `y` and `z`"))?; + let uv = (|| Some([at(&["s", "u", "texture_u"])?, at(&["t", "v", "texture_v"])?]))(); + let rgb = + (|| Some([e.find(&["red", "r", "diffuse_red"])?, e.find(&["green", "g", "diffuse_green"])?, e.find(&["blue", "b", "diffuse_blue"])?]))(); + Ok(VertexColumns { xyz, normal: three(&["nx"], &["ny"], &["nz"]), uv, rgb, alpha: e.find(&["alpha", "a"]) }) + } +} + +fn vertices(e: &Element, rows: &mut Rows<'_>, mesh: &mut Mesh, limits: &Limits) -> Result<()> { + let w = VertexColumns::of(e)?; + if e.count > limits.max_vertices as u64 { + return Err(too_large(KIND, format!("{} vertices (limit {})", e.count, limits.max_vertices))); + } + rows.check(e)?; + let mut uvs = Vec::new(); + let mut slots = vec![0.0f64; e.properties.len()]; + for _ in 0..e.count { + for (p, slot) in e.properties.iter().zip(&mut slots) { + match p.kind { + Kind::Scalar(t) => *slot = rows.value(t)?, + Kind::List(c, t) => { + let n = rows.count(c, limits.max_face_corners)?; + rows.skip(t, n)?; + } + } + } + let v = |i: usize| slots.get(i).copied().unwrap_or(0.0); + mesh.positions.push(w.xyz.map(v)); + if let Some(n) = w.normal { + mesh.normals.push(n.map(v)); + } + if let Some([s, t]) = w.uv { + // PLY texture coordinates have their origin at the bottom left. + uvs.push([v(s) as f32, 1.0 - v(t) as f32]); + } + if let Some(rgb) = w.rgb { + let c = rgb.map(|(i, t)| t.unit(v(i))); + mesh.colors.push([c[0], c[1], c[2], w.alpha.map_or(1.0, |(i, t)| t.unit(v(i)))]); + } + } + if w.uv.is_some() { + mesh.uv_sets.push(uvs); + } + Ok(()) +} + +struct Faces { + part: Part, + triangles: usize, + bad: usize, +} + +fn faces(e: &Element, list: usize, rows: &mut Rows<'_>, vertex_count: u64, out: &mut Faces, limits: &Limits) -> Result<()> { + rows.check(e)?; + let mut face: Vec = Vec::new(); + for _ in 0..e.count { + for (i, p) in e.properties.iter().enumerate() { + match p.kind { + Kind::Scalar(t) => rows.skip(t, 1)?, + Kind::List(c, t) if i == list => { + let n = rows.count(c, limits.max_face_corners)?; + face.clear(); + for _ in 0..n { + face.push(rows.value(t)?); + } + } + Kind::List(c, t) => { + let n = rows.count(c, limits.max_face_corners)?; + rows.skip(t, n)?; + } + } + } + let corners: Option> = face + .iter() + .map(|v| (*v >= 0.0 && *v < vertex_count as f64 && v.fract() == 0.0).then(|| u32::try_from(*v as u64).ok().map(Corner::at)).flatten()) + .collect(); + let Some(corners) = corners.filter(|c| c.len() >= 3) else { + out.bad += 1; + continue; + }; + out.triangles = out.triangles.saturating_add(corners.len() - 2); + if out.triangles > limits.max_triangles { + return Err(too_large(KIND, format!("more than {} triangles", limits.max_triangles))); + } + out.part.face_sizes.push(corners.len() as u32); + out.part.corners.extend(corners); + } + Ok(()) +} + +/// Read a PLY file into a scene of one mesh (unnamed: PLY has no names). The elements may come +/// in any order. +pub fn read(bytes: &[u8], limits: &Limits) -> Result { + let h = parse_header(bytes)?; + let vertex_count = h.elements.iter().find(|e| e.name == "vertex").ok_or_else(|| malformed(KIND, "no `vertex` element"))?.count; + let mut rows = Rows::new(&h, bytes); + let mut mesh = Mesh::default(); + let mut out = Faces { part: Part::default(), triangles: 0, bad: 0 }; + let (mut seen_vertices, mut seen_faces) = (false, false); + for e in &h.elements { + let list = e.properties.iter().position(|p| matches!(p.name.as_str(), "vertex_indices" | "vertex_index") && matches!(p.kind, Kind::List(..))); + match (e.name.as_str(), list) { + ("vertex", _) if !seen_vertices => { + seen_vertices = true; + vertices(e, &mut rows, &mut mesh, limits)?; + } + ("face", Some(list)) if !seen_faces => { + seen_faces = true; + faces(e, list, &mut rows, vertex_count, &mut out, limits)?; + } + _ => rows.skip_element(e, limits.max_face_corners)?, + } + // Nothing after the vertices and faces is wanted. + if seen_vertices && (seen_faces || !h.elements.iter().any(|e| e.name == "face")) { + break; + } + } + // Faces that name a vertex whose position is not a number are left out. + let finite = |c: &Corner| mesh.positions.get(c.position as usize).is_some_and(|p| p.iter().all(|x| x.is_finite())); + let mut part = out.part; + if !mesh.positions.iter().flatten().all(|x| x.is_finite()) { + let mut kept = Part::default(); + for f in part.faces() { + if f.iter().all(finite) { + kept.face_sizes.push(f.len() as u32); + kept.corners.extend(f); + } else { + out.bad += 1; + } + } + part = kept; + } + if !mesh.normals.is_empty() { + part.corners.iter_mut().for_each(|c| c.normal = c.position); + } + if !mesh.uv_sets.is_empty() { + part.corners.iter_mut().for_each(|c| c.uv = c.position); + } + if part.face_sizes.iter().all(|n| *n == 3) { + part.face_sizes.clear(); + } + if !part.corners.is_empty() { + mesh.parts.push(part); + } + let mut scene = Scene::from_meshes(vec![mesh], Vec::new()); + count_warning(&mut scene.warnings, out.bad, "face(s) could not be read and were left out"); + Ok(scene) +} diff --git a/crates/mesh/src/stl.rs b/crates/mesh/src/stl.rs new file mode 100644 index 00000000..e60911a7 --- /dev/null +++ b/crates/mesh/src/stl.rs @@ -0,0 +1,289 @@ +//! STL (stereolithography) meshes, binary and ASCII, read and written. +//! +//! From the public description of the format (3D Systems' StereoLithography Interface +//! Specification, 1989, and its widely documented colour extensions): +//! +//! - Binary: an 80-byte header, a little-endian `u32` facet count, then 50 bytes per facet: a +//! normal and three vertices (12 `f32`) and a 16-bit attribute word. Bytes after the last +//! facet are ignored. A count of 0 over a file that holds facets (some writers leave it +//! unset) is read for the facets that are there, with a warning; a count larger than the +//! file holds is a truncated file and an error. +//! - ASCII: `solid name`, then `facet normal nx ny nz` / `outer loop` / `vertex x y z`… / +//! `endloop` / `endfacet`, closed by `endsolid`; several solids may follow each other. +//! Loops with more than three vertices are fan-triangulated. +//! - Colour (binary only): with `COLOR=r g b a` (four bytes) in the header (Materialise Magics), +//! that is the part's colour and a facet whose attribute has bit 15 clear has its own colour +//! (red in bits 0–4, green 5–9, blue 10–14); otherwise (VisCAM / SolidView) a facet with bit 15 +//! set has its own colour (blue in bits 0–4, green 5–9, red 10–14). Each colour becomes a +//! plain material named `#RRGGBB` and a [`Part`] of the mesh; uncoloured files get neither. +//! +//! Detection: a file whose size is exactly what its binary header says is binary (many binary +//! headers begin with `solid`); otherwise a file that starts with `solid` and has a `facet` or +//! `endsolid` is ASCII; otherwise it is binary. +//! +//! STL has no units, no UVs and no shared vertices: every facet keeps its own three positions. +//! Its normal is the face normal of its counter-clockwise winding; the stored normal is used +//! only when the triangle is degenerate (stored normals are often zero or wrong). Facets with a +//! non-finite coordinate are left out with a warning. + +use std::collections::HashMap; +use std::fmt::Write as _; + +use crate::{ + Corner, Error, Limits, Material, Mesh, Part, Result, Scene, count_warning, face_normal, is_mirror, malformed, srgb_to_linear, too_large, + transform_point, +}; + +const KIND: &str = "STL"; +/// Header (80) plus facet count (4). +const HEADER: usize = 84; +/// Bytes per binary facet. +const FACET: usize = 50; + +/// Does `bytes` look like an ASCII STL (`solid` first, and a `facet` or `endsolid` in the first +/// 512 bytes)? Binary STL has no signature to test for. +pub fn is_ascii(bytes: &[u8]) -> bool { + let head = bytes.get(..bytes.len().min(512)).unwrap_or(&[]); + let text = String::from_utf8_lossy(head); + let t = text.trim_start(); + t.get(..5).is_some_and(|k| k.eq_ignore_ascii_case("solid")) && (t.contains("facet") || t.contains("endsolid")) +} + +/// Read an STL file (binary or ASCII, detected from the contents) into a scene of one mesh. +pub fn read(bytes: &[u8], limits: &Limits) -> Result { + let count = bytes.get(80..HEADER).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(|b| u32::from_le_bytes(b) as usize); + let needed = count.and_then(|n| n.checked_mul(FACET)).and_then(|n| n.checked_add(HEADER)); + if needed != Some(bytes.len()) && is_ascii(bytes) { + return ascii(bytes, limits); + } + match count { + Some(count) => binary(bytes, count, limits), + None => Err(malformed(KIND, "neither a binary file (shorter than its 84-byte header) nor an ASCII one (no `solid`)")), + } +} + +/// Facets grouped into parts by colour, in order of first use. +struct Builder<'a> { + mesh: Mesh, + colors: Vec>, + slots: HashMap, usize>, + facets: usize, + skipped: usize, + limits: &'a Limits, +} + +impl<'a> Builder<'a> { + fn new(limits: &'a Limits) -> Self { + Builder { mesh: Mesh::default(), colors: Vec::new(), slots: HashMap::new(), facets: 0, skipped: 0, limits } + } + + /// Add one triangle; `stored` is the file's facet normal. + fn tri(&mut self, color: Option<[u8; 3]>, stored: [f64; 3], v: [[f64; 3]; 3]) -> Result<()> { + if v.iter().flatten().any(|c| !c.is_finite()) { + self.skipped += 1; + return Ok(()); + } + if self.facets >= self.limits.max_triangles { + return Err(too_large(KIND, format!("more than {} triangles", self.limits.max_triangles))); + } + if self.mesh.positions.len().saturating_add(3) > self.limits.max_vertices { + return Err(too_large(KIND, format!("more than {} vertices", self.limits.max_vertices))); + } + let unit = |n: [f64; 3]| { + let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt(); + (len.is_finite() && len > 0.0).then(|| n.map(|x| x / len)) + }; + let n = face_normal(v).or_else(|| unit(stored)).unwrap_or([0.0, 0.0, 1.0]); + let slot = *self.slots.entry(color).or_insert_with(|| { + self.mesh.parts.push(Part::default()); + self.colors.push(color); + self.mesh.parts.len() - 1 + }); + let (Some(part), Ok(base), Ok(normal)) = + (self.mesh.parts.get_mut(slot), u32::try_from(self.mesh.positions.len()), u32::try_from(self.mesh.normals.len())) + else { + return Err(too_large(KIND, "more vertices than a 32-bit index holds")); + }; + self.mesh.normals.push(n); + for (k, p) in (0u32..).zip(v) { + self.mesh.positions.push(p); + part.corners.push(Corner { position: base + k, normal, uv: crate::NONE }); + } + self.facets += 1; + Ok(()) + } + + fn finish(mut self, name: String, mut warnings: Vec) -> Result { + count_warning(&mut warnings, self.skipped, "facet(s) with a non-finite coordinate were left out"); + if self.facets == 0 { + return Err(malformed(KIND, "no facets")); + } + let mut materials = Vec::new(); + let coloured = self.colors.iter().any(Option::is_some); + for (part, color) in self.mesh.parts.iter_mut().zip(&self.colors) { + let material = match color { + Some(c) => { + let lin = c.map(|x| srgb_to_linear(f32::from(x) / 255.0)); + Material::plain(format!("#{:02X}{:02X}{:02X}", c[0], c[1], c[2]), [lin[0], lin[1], lin[2], 1.0]) + } + // Uncoloured facets next to coloured ones: a neutral grey of their own. + None if coloured => Material::plain("STL", [0.6, 0.6, 0.6, 1.0]), + None => continue, + }; + part.material = Some(materials.len()); + materials.push(material); + } + self.mesh.name = name; + let mut scene = Scene::from_meshes(vec![self.mesh], materials); + scene.warnings = warnings; + Ok(scene) + } +} + +fn f32_at(b: &[u8], at: usize) -> f32 { + b.get(at..at + 4).and_then(|s| <[u8; 4]>::try_from(s).ok()).map_or(f32::NAN, f32::from_le_bytes) +} + +/// A 5-bit channel expanded to 8 bits. +fn five(x: u16) -> u8 { + let x = (x & 31) as u8; + (x << 3) | (x >> 2) +} + +/// The Magics `COLOR=` part colour in the header (RGB; alpha is ignored). +fn header_color(header: &[u8]) -> Option<[u8; 3]> { + let at = header.windows(6).position(|w| w == b"COLOR=")?; + let c = header.get(at + 6..at + 10)?; + Some([*c.first()?, *c.get(1)?, *c.get(2)?]) +} + +fn binary(bytes: &[u8], count: usize, limits: &Limits) -> Result { + let header = bytes.get(..80).ok_or_else(|| malformed(KIND, "header truncated"))?; + let magics = header_color(header); + let body = bytes.get(HEADER..).unwrap_or(&[]); + let room = body.len() / FACET; + let mut warnings = Vec::new(); + if count > room { + return Err(malformed(KIND, format!("the header counts {count} facets, the file holds {room} (truncated)"))); + } + // Some writers leave the count unset. + let take = if count == 0 { room } else { count }; + if count == 0 && room > 0 { + warnings.push(format!("the header counts no facets, the file holds {room}")); + } + if take > limits.max_triangles { + return Err(too_large(KIND, format!("{take} triangles (limit {})", limits.max_triangles))); + } + let mut b = Builder::new(limits); + for f in body.as_chunks::().0.iter().take(take) { + let n = [f32_at(f, 0), f32_at(f, 4), f32_at(f, 8)]; + let v = [0usize, 1, 2].map(|k| [f32_at(f, 12 + 12 * k), f32_at(f, 16 + 12 * k), f32_at(f, 20 + 12 * k)]); + let attr = f.get(48..50).and_then(|a| <[u8; 2]>::try_from(a).ok()).map_or(0, u16::from_le_bytes); + let color = match magics { + Some(part) => Some(if attr & 0x8000 == 0 { [five(attr), five(attr >> 5), five(attr >> 10)] } else { part }), + None => (attr & 0x8000 != 0).then(|| [five(attr >> 10), five(attr >> 5), five(attr)]), + }; + b.tri(color, n.map(f64::from), v.map(|p| p.map(f64::from)))?; + } + let text = String::from_utf8_lossy(header); + let name = text.trim_matches(|c: char| c == '\0' || c.is_whitespace()); + b.finish(name.strip_prefix("solid").unwrap_or(name).trim().to_string(), warnings) +} + +/// Three numbers from the rest of an ASCII line. +fn three<'a>(it: &mut impl Iterator) -> Option<[f64; 3]> { + Some([it.next()?.parse().ok()?, it.next()?.parse().ok()?, it.next()?.parse().ok()?]) +} + +fn ascii(bytes: &[u8], limits: &Limits) -> Result { + let text = String::from_utf8_lossy(bytes); + let mut b = Builder::new(limits); + let mut name: Option = None; + let mut normal = [0.0f64; 3]; + let mut loop_v: Vec<[f64; 3]> = vec![]; + for (ln, line) in text.lines().enumerate() { + let line = line.trim(); + let mut it = line.split_whitespace(); + let Some(key) = it.next() else { continue }; + match key.to_ascii_lowercase().as_str() { + "solid" if name.is_none() => name = Some(line.get(5..).unwrap_or("").trim().to_string()), + "facet" => { + // `facet normal nx ny nz` + normal = it.next().and_then(|_| three(&mut it)).unwrap_or([0.0; 3]); + loop_v.clear(); + } + "vertex" => { + if loop_v.len() >= limits.max_face_corners { + return Err(too_large(KIND, format!("line {}: a loop of more than {} vertices", ln + 1, limits.max_face_corners))); + } + loop_v.push(three(&mut it).ok_or_else(|| malformed(KIND, format!("line {}: expected three numbers", ln + 1)))?); + } + "endloop" | "endfacet" => { + if let Some((&first, rest)) = loop_v.split_first() { + for w in rest.windows(2) { + if let [a, c] = w { + b.tri(None, normal, [first, *a, *c])?; + } + } + } + loop_v.clear(); + } + _ => {} + } + } + b.finish(name.unwrap_or_default(), Vec::new()) +} + +/// Every triangle of the scene in world space, with its face normal. +fn triangles(scene: &Scene, limits: &Limits, mut each: impl FnMut([f64; 3], [[f64; 3]; 3])) -> Result<()> { + for (index, world) in scene.instances(limits)? { + let Some(mesh) = scene.meshes.get(index) else { continue }; + for t in mesh.parts.iter().flat_map(Part::triangles) { + let p = t.map(|c| mesh.positions.get(c.position as usize).copied()); + let [Some(a), Some(b), Some(c)] = p else { return Err(malformed(KIND, "a corner index is out of range")) }; + // A mirrored placement turns the winding; turn it back so the facet faces out. + let v = if is_mirror(&world) { [a, c, b] } else { [a, b, c] }.map(|p| transform_point(&world, p)); + each(face_normal(v).unwrap_or([0.0; 3]), v); + } + } + Ok(()) +} + +/// Binary STL of every mesh of the scene, placed by its nodes, as one solid. `name` goes in the +/// header (cut to 80 bytes; a leading `solid` would make readers take the file for ASCII, so it +/// is written after a prefix). Colours are not written. +pub fn write_binary(scene: &Scene, name: &str, limits: &Limits) -> Result> { + let mut header = format!("STL {name}").into_bytes(); + header.resize(80, b' '); + let mut out = header; + out.extend_from_slice(&[0; 4]); + let mut n = 0u64; + triangles(scene, limits, |normal, v| { + n += 1; + for x in std::iter::once(normal).chain(v).flatten() { + out.extend_from_slice(&(x as f32).to_le_bytes()); + } + out.extend_from_slice(&[0, 0]); + })?; + let count = u32::try_from(n).map_err(|_| Error::TooLarge("STL: more triangles than the 32-bit count holds".into()))?; + if let Some(slot) = out.get_mut(80..HEADER) { + slot.copy_from_slice(&count.to_le_bytes()); + } + Ok(out) +} + +/// ASCII STL of every mesh of the scene, placed by its nodes, as one solid named `name` (its +/// graphic ASCII characters). +pub fn write_ascii(scene: &Scene, name: &str, limits: &Limits) -> Result { + let name: String = name.chars().filter(|c| c.is_ascii_graphic()).collect(); + let mut s = format!("solid {name}\n"); + triangles(scene, limits, |n, v| { + let _ = write!(s, " facet normal {:e} {:e} {:e}\n outer loop\n", n[0], n[1], n[2]); + for p in v { + let _ = writeln!(s, " vertex {:e} {:e} {:e}", p[0], p[1], p[2]); + } + s += " endloop\n endfacet\n"; + })?; + let _ = writeln!(s, "endsolid {name}"); + Ok(s) +} diff --git a/crates/mesh/src/tests.rs b/crates/mesh/src/tests.rs new file mode 100644 index 00000000..c2d5b9e0 --- /dev/null +++ b/crates/mesh/src/tests.rs @@ -0,0 +1,163 @@ +//! Tests. Every sample is generated here; the crate ships no asset files. + +use super::*; + +#[cfg(feature = "gltf")] +mod gltf; +#[cfg(feature = "obj")] +mod obj; +#[cfg(feature = "ply")] +mod ply; +#[cfg(feature = "stl")] +mod stl; + +/// Every truncation of `good` and two mutations of every byte go through `read`: it may refuse +/// them, it must not panic, and what it accepts must be a valid scene. +pub(crate) fn hammer(good: &[u8], read: &dyn Fn(&[u8]) -> Result) { + let check = |bytes: &[u8]| { + if let Ok(scene) = read(bytes) { + scene.validate().unwrap(); + } + }; + for len in 0..good.len() { + check(&good[..len]); + } + for i in 0..good.len() { + let mut b = good.to_vec(); + b[i] ^= 0xA5; + check(&b); + b[i] = 0xFF; + check(&b); + } + check(b"\x00\x01garbage\xff\xfe"); +} + +pub(crate) fn tri_mesh() -> Mesh { + Mesh { + name: "Tri".into(), + positions: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + parts: vec![Part { corners: [0, 1, 2, 0, 3, 1].map(Corner::at).to_vec(), ..Part::default() }], + ..Mesh::default() + } +} + +#[test] +fn part_faces_and_fans() { + let quad = Part { face_sizes: vec![4, 3], corners: (0..7).map(Corner::at).collect(), ..Part::default() }; + assert_eq!(quad.faces().map(<[Corner]>::len).collect::>(), [4, 3]); + assert_eq!(quad.triangle_count(), 3); + let tris: Vec<[u32; 3]> = quad.triangles().map(|t| t.map(|c| c.position)).collect(); + assert_eq!(tris, [[0, 1, 2], [0, 2, 3], [4, 5, 6]]); + // Sizes that overrun the corners stop the iteration instead of panicking. + let short = Part { face_sizes: vec![3, 9], corners: (0..5).map(Corner::at).collect(), ..Part::default() }; + assert_eq!(short.faces().count(), 1); +} + +#[test] +fn validate_catches_what_writers_cannot_take() { + let mut m = tri_mesh(); + m.validate().unwrap(); + m.parts[0].corners[0].position = 4; + assert!(m.validate().is_err()); + let mut m = tri_mesh(); + m.parts[0].corners[0].normal = 0; + assert!(m.validate().is_err(), "a normal index without normals"); + let mut m = tri_mesh(); + m.parts[0].face_sizes = vec![4, 3]; + assert!(m.validate().is_err()); + let mut m = tri_mesh(); + m.colors = vec![[1.0; 4]]; + assert!(m.validate().is_err()); + let mut s = Scene::from_meshes(vec![tri_mesh()], vec![]); + s.validate().unwrap(); + s.meshes[0].parts[0].material = Some(0); + assert!(s.validate().is_err()); + let mut s = Scene::from_meshes(vec![tri_mesh()], vec![]); + s.nodes[0].children.push(7); + assert!(s.validate().is_err()); +} + +#[test] +fn unified_gives_one_vertex_per_distinct_corner() { + // Two triangles sharing an edge; the shared positions carry different normals. + let mut m = tri_mesh(); + m.normals = vec![[0.0, 0.0, 1.0], [0.0, -1.0, 0.0]]; + m.colors = vec![[1.0, 0.0, 0.0, 1.0]; 4]; + for (i, c) in m.parts[0].corners.iter_mut().enumerate() { + c.normal = (i / 3) as u32; + } + let u = m.unified(); + assert_eq!(u.positions.len(), 6); + assert_eq!(u.normals.len(), 6); + assert_eq!(u.colors.len(), 6); + assert!(u.uvs.is_empty()); + assert_eq!(u.corners.iter().map(|c| c.position).collect::>(), [0, 1, 2, 0, 3, 1]); + assert_eq!(u.parts, [vec![[0, 1, 2], [3, 4, 5]]]); + // Without normals the shared positions are shared vertices. + let u = tri_mesh().unified(); + assert_eq!((u.positions.len(), u.normals.len()), (4, 0)); + assert_eq!(u.parts, [vec![[0, 1, 2], [0, 3, 1]]]); + // A corner out of range drops its triangle, not the process. + let mut bad = tri_mesh(); + bad.parts[0].corners[4].position = 99; + assert_eq!(bad.unified().parts, [vec![[0, 1, 2]]]); +} + +#[test] +fn transforms() { + let mut t = IDENTITY; + t[12] = 5.0; + let mut s = IDENTITY; + s[0] = 2.0; + // Scale first, then move. + assert_eq!(transform_point(&mat_mul(&t, &s), [1.0, 1.0, 1.0]), [7.0, 1.0, 1.0]); + // A non-uniform scale tilts a normal the other way. + let n = transform_normal(&s, [1.0, 1.0, 0.0]); + assert!((n[0] * 2.0 - n[1]).abs() < 1e-12 && n[1] > 0.0, "{n:?}"); + // A mirror keeps the normal pointing out of the mirrored surface. + let mut mirror = IDENTITY; + mirror[0] = -1.0; + assert_eq!(transform_normal(&mirror, [1.0, 0.0, 0.0]).map(|x| x.round()), [-1.0, 0.0, 0.0]); + assert_eq!(transform_normal(&[0.0; 16], [0.0, 0.0, 1.0]), [0.0, 0.0, 1.0]); +} + +fn chain(len: usize) -> Scene { + let mut s = Scene::from_meshes(vec![tri_mesh()], vec![]); + s.nodes = (0..len) + .map(|i| Node { children: if i + 1 < len { vec![i + 1] } else { vec![] }, mesh: (i + 1 == len).then_some(0), ..Node::default() }) + .collect(); + s.roots = vec![0]; + s +} + +#[test] +fn instances_walk_the_hierarchy_within_limits() { + let mut s = chain(3); + s.nodes[0].transform[12] = 1.0; + s.nodes[2].transform[13] = 2.0; + let placed = s.instances(&Limits::default()).unwrap(); + assert_eq!(placed.len(), 1); + assert_eq!(transform_point(&placed[0].1, [0.0; 3]), [1.0, 2.0, 0.0]); + // Limit: depth. + let limits = Limits { max_depth: 4, ..Limits::default() }; + assert!(chain(5).instances(&limits).is_ok()); + assert!(matches!(chain(6).instances(&limits), Err(Error::TooLarge(_)))); + // Limit: visits. A cycle ends there too. + let limits = Limits { max_nodes: 5, max_depth: usize::MAX, ..Limits::default() }; + assert!(chain(5).instances(&limits).is_ok()); + assert!(matches!(chain(6).instances(&limits), Err(Error::TooLarge(_)))); + let mut cycle = chain(2); + cycle.nodes[1].children = vec![0]; + assert!(matches!(cycle.instances(&Limits::default()), Err(Error::TooLarge(_)))); + // A child that does not exist is an error, not a panic. + let mut missing = chain(1); + missing.nodes[0].children = vec![9]; + assert!(matches!(missing.instances(&Limits::default()), Err(Error::Malformed(_)))); +} + +#[test] +fn srgb_round_trips() { + for c in [0.0f32, 0.002, 0.2, 0.5, 1.0] { + assert!((linear_to_srgb(srgb_to_linear(c)) - c).abs() < 1e-6); + } +} diff --git a/crates/mesh/src/tests/gltf.rs b/crates/mesh/src/tests/gltf.rs new file mode 100644 index 00000000..9564c811 --- /dev/null +++ b/crates/mesh/src/tests/gltf.rs @@ -0,0 +1,718 @@ +use serde_json::{Value, json}; + +use super::hammer; +use crate::gltf::{MAX_UV_SETS, MAX_ZERO_COMPONENTS, base64_decode, glb_container, read, split_glb, uri_decode, write_glb}; +use crate::{ + AlphaMode, ChannelPath, Corner, Error, IDENTITY, Image, Interpolation, LightKind, Limits, Material, Mesh, NONE, Node, Part, Projection, Scene, + TextureRef, Wrap, face_normal, no_external, transform_point, +}; + +/// Builds the binary chunk with its buffer views and accessors. +#[derive(Default)] +struct B { + bin: Vec, + views: Vec, + accessors: Vec, +} + +impl B { + fn view(&mut self, bytes: &[u8]) -> usize { + self.bin.resize(self.bin.len().next_multiple_of(4), 0); + self.views.push(json!({ "buffer": 0, "byteOffset": self.bin.len(), "byteLength": bytes.len() })); + self.bin.extend_from_slice(bytes); + self.views.len() - 1 + } + + fn acc(&mut self, bytes: &[u8], component: u32, count: usize, kind: &str) -> usize { + let view = self.view(bytes); + self.accessors.push(json!({ "bufferView": view, "componentType": component, "count": count, "type": kind })); + self.accessors.len() - 1 + } + + fn f32s(&mut self, v: &[f32], kind: &str, per: usize) -> usize { + self.acc(&v.iter().flat_map(|x| x.to_le_bytes()).collect::>(), 5126, v.len() / per, kind) + } + + fn u16s(&mut self, v: &[u16]) -> usize { + self.acc(&v.iter().flat_map(|x| x.to_le_bytes()).collect::>(), 5123, v.len(), "SCALAR") + } + + /// The document: `rest` with the accessors, views and buffer added. + fn json(&self, mut rest: Value) -> Value { + let o = rest.as_object_mut().unwrap(); + o.entry("asset").or_insert(json!({ "version": "2.0" })); + o.insert("accessors".into(), json!(self.accessors)); + o.insert("bufferViews".into(), json!(self.views)); + o.entry("buffers").or_insert(json!([{ "byteLength": self.bin.len() }])); + rest + } + + fn glb(&self, rest: Value) -> Vec { + glb_container(&serde_json::to_vec(&self.json(rest)).unwrap(), &self.bin).unwrap() + } +} + +const QUAD: [f32; 12] = [0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 0.0]; + +fn parse(bytes: &[u8]) -> crate::Result { + read(bytes, &Limits::default(), &mut no_external) +} + +/// A quad with normals and UVs under a node tree: a root moved by TRS, its child placed by a +/// matrix, with a material. +fn quad() -> Vec { + let mut b = B::default(); + let p = b.f32s(&QUAD, "VEC3", 3); + let n = b.f32s(&[0.0, 0.0, 1.0].repeat(4), "VEC3", 3); + let t = b.f32s(&[0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0], "VEC2", 2); + let i = b.u16s(&[0, 1, 2, 0, 2, 3]); + let mut m = IDENTITY; + m[13] = 5.0; + let h = std::f64::consts::FRAC_1_SQRT_2; + b.glb(json!({ + "scene": 0, + "scenes": [{ "nodes": [0] }], + "nodes": [ + { "name": "root", "translation": [1.0, 0.0, 0.0], "rotation": [0.0, 0.0, h, h], "scale": [2.0, 2.0, 2.0], "children": [1] }, + { "name": "leaf", "matrix": m, "mesh": 0 }, + { "name": "not in the scene", "mesh": 0 } + ], + "meshes": [{ "name": "Quad", "primitives": [{ "attributes": { "POSITION": p, "NORMAL": n, "TEXCOORD_0": t }, "indices": i, "material": 0 }] }], + "materials": [{ "name": "Paint", "pbrMetallicRoughness": { "baseColorFactor": [1.0, 0.5, 0.25, 1.0], "metallicFactor": 0.0, "roughnessFactor": 0.4 }, "doubleSided": true, "alphaMode": "MASK" }] + })) +} + +#[test] +fn glb_quad_with_a_node_tree_and_a_material() { + let s = parse(&quad()).unwrap(); + s.validate().unwrap(); + assert!(s.warnings.is_empty() && s.external.is_empty()); + let m = &s.meshes[0]; + assert_eq!((m.name.as_str(), m.positions.len(), m.normals.len(), m.uv_sets[0].len(), m.triangle_count()), ("Quad", 4, 4, 4, 2)); + assert_eq!(m.parts[0].corners[4], Corner::all(2)); + assert_eq!(m.parts[0].material, Some(0)); + // UVs are glTF's own convention: kept as stored. + assert_eq!(m.uv_sets[0][2], [1.0, 1.0]); + let paint = &s.materials[0]; + assert_eq!((paint.name.as_str(), paint.base_color, paint.metallic, paint.roughness), ("Paint", [1.0, 0.5, 0.25, 1.0], 0.0, 0.4)); + assert_eq!((paint.double_sided, paint.alpha_mode), (true, AlphaMode::Mask(0.5))); + // The default scene's roots; the tree places the mesh: up 5, scaled by 2, turned a quarter + // about Z, moved 1 along X. + assert_eq!(s.roots, [0]); + assert!(s.nodes[0].trs.is_some() && s.nodes[1].trs.is_none()); + let placed = s.instances(&Limits::default()).unwrap(); + assert_eq!(placed.len(), 1); + let p = transform_point(&placed[0].1, [1.0, 0.0, 0.0]); + assert!((p[0] - -9.0).abs() < 1e-9 && (p[1] - 2.0).abs() < 1e-9 && p[2].abs() < 1e-9, "{p:?}"); +} + +fn base64(data: &[u8]) -> String { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::new(); + for c in data.chunks(3) { + let n = c.iter().enumerate().fold(0u32, |n, (k, b)| n | u32::from(*b) << (16 - 8 * k)); + for k in 0..4 { + out.push(if k <= c.len() { T[(n >> (18 - 6 * k)) as usize & 63] as char } else { '=' }); + } + } + out +} + +/// One triangle as a `.gltf` document whose buffer and image are named by `buffer` and `image`. +fn gltf_text(buffer: &str, image: &str) -> (Vec, Vec) { + let mut b = B::default(); + let p = b.f32s(&QUAD[..9], "VEC3", 3); + let doc = b.json(json!({ + "buffers": [{ "byteLength": b.bin.len(), "uri": buffer }], + "nodes": [{ "mesh": 0 }], + "meshes": [{ "primitives": [{ "attributes": { "POSITION": p }, "material": 0 }] }], + "materials": [{ "pbrMetallicRoughness": { "baseColorTexture": { "index": 0, "texCoord": 1 } } }], + "textures": [{ "source": 0, "sampler": 0 }], + "samplers": [{ "wrapS": 33071, "wrapT": 33648 }], + "images": [{ "uri": image }] + })); + (serde_json::to_vec(&doc).unwrap(), b.bin) +} + +#[test] +fn embedded_and_external_resources() { + // Embedded: data URIs for the buffer and the image. + let bin = gltf_text("", "").1; + let png = b"\x89PNG not really"; + let (doc, _) = gltf_text(&format!("data:application/octet-stream;base64,{}", base64(&bin)), &format!("data:image/png;base64,{}", base64(png))); + let mut asked = 0; + let s = read(&doc, &Limits::default(), &mut |_| { + asked += 1; + None + }) + .unwrap(); + assert_eq!((asked, s.external.len(), s.meshes[0].triangle_count()), (0, 0, 1)); + assert_eq!( + (s.images[0].data.as_deref(), s.images[0].mime_type.as_deref(), s.images[0].uri.as_deref()), + (Some(&png[..]), Some("image/png"), None) + ); + // The texture slot keeps its UV set and wrap modes. + assert_eq!(s.materials[0].base_color_texture, Some(TextureRef { image: 0, uv_set: 1, wrap: [Wrap::Clamp, Wrap::Mirror] })); + // No scene in the file: every node that is nobody's child is a root. + assert_eq!(s.roots, [0]); + + // External: the buffer is asked for by its decoded name; the image is only named. + let (doc, bin) = gltf_text("my%20data.bin", "../textures/wood.png"); + let mut asked = Vec::new(); + let s = read(&doc, &Limits::default(), &mut |name| { + asked.push(name.to_string()); + Some(bin.clone()) + }) + .unwrap(); + assert_eq!(asked, ["my data.bin"]); + assert_eq!(s.external, ["my data.bin", "../textures/wood.png"]); + assert_eq!((s.images[0].uri.as_deref(), s.images[0].data.as_ref()), (Some("../textures/wood.png"), None)); + s.validate().unwrap(); + // A buffer that is not supplied is named in the error. + assert_eq!(parse(&doc), Err(Error::Missing("my data.bin".into()))); + // An image that cannot be decoded is left without bytes. + let (doc, _) = gltf_text(&format!("data:;base64,{}", base64(&bin)), "data:image/png;base64,!!!"); + assert_eq!(parse(&doc).unwrap().images[0].data, None); + assert!(parse(&gltf_text("data:;base64,!!!", "x.png").0).is_err()); +} + +#[test] +fn base64_and_uri_decoding() { + assert_eq!(base64_decode("aGVsbG8=").unwrap(), b"hello"); + assert_eq!(base64_decode("aGVs\nbG8").unwrap(), b"hello"); + assert_eq!(base64_decode("-_8").unwrap(), [0xfb, 0xff]); + assert!(base64_decode("a$b").is_none()); + assert_eq!(uri_decode("a%20b%2Fc"), "a b/c"); + assert_eq!(uri_decode("a%+1b"), "a%+1b"); + assert_eq!(uri_decode("a%4"), "a%4"); + assert_eq!(uri_decode("%"), "%"); +} + +#[test] +fn glb_container_checks() { + let glb = quad(); + let (json, bin) = split_glb(&glb).unwrap(); + assert!(json.starts_with(b"{") && bin.unwrap().len() >= 12 * 4); + assert!(split_glb(b"glTF").is_err()); + assert!(split_glb(b"nope").is_err()); + let mut v1 = glb.clone(); + v1[4] = 1; + assert!(matches!(split_glb(&v1), Err(Error::Unsupported(_)))); + // The header's length is checked against the file. + assert!(split_glb(&glb[..glb.len() - 1]).is_err()); + // A chunk that runs past the end. + let mut long = glb.clone(); + long[12..16].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(split_glb(&long).is_err()); + // A binary chunk before the JSON one. + let mut swapped = glb.clone(); + swapped[16..20].copy_from_slice(b"BIN\0"); + assert!(split_glb(&swapped).is_err()); + // An unknown chunk is skipped. + let mut extra = glb.clone(); + extra.extend(4u32.to_le_bytes()); + extra.extend(b"XTRAdata"); + let len = extra.len() as u32; + extra[8..12].copy_from_slice(&len.to_le_bytes()); + assert_eq!(parse(&extra).unwrap().meshes, parse(&glb).unwrap().meshes); +} + +fn one_mesh(b: &B, primitive: Value) -> Vec { + b.glb(json!({ "nodes": [{ "mesh": 0 }], "meshes": [{ "primitives": [primitive] }] })) +} + +fn positions_of(s: &Scene) -> Vec<[u32; 3]> { + s.meshes[0].parts.iter().flat_map(Part::triangles).map(|t| t.map(|c| c.position)).collect() +} + +#[test] +fn strips_fans_and_unindexed_primitives() { + let mut b = B::default(); + let p = b.f32s(&QUAD, "VEC3", 3); + let i = b.u16s(&[0, 1, 3, 2]); + let s = parse(&one_mesh(&b, json!({ "attributes": { "POSITION": p }, "indices": i, "mode": 5 }))).unwrap(); + // Every second triangle of a strip is turned so that all face the same way. + assert_eq!(positions_of(&s), [[0, 1, 3], [3, 1, 2]]); + let s = parse(&one_mesh(&b, json!({ "attributes": { "POSITION": p }, "mode": 6 }))).unwrap(); + assert_eq!(positions_of(&s), [[0, 1, 2], [0, 2, 3]]); + // Without indices the vertices are taken in order; a vertex left over is dropped. + let s = parse(&one_mesh(&b, json!({ "attributes": { "POSITION": p } }))).unwrap(); + assert_eq!((positions_of(&s), s.warnings.len()), (vec![[0, 1, 2]], 1)); + // Points and lines are left out, with a warning; the mesh stays so indices keep their meaning. + let s = parse(&one_mesh(&b, json!({ "attributes": { "POSITION": p }, "mode": 1 }))).unwrap(); + assert_eq!((s.meshes.len(), s.meshes[0].parts.len(), s.warnings.len()), (1, 0, 1)); +} + +#[test] +fn strides_quantized_attributes_and_accessors_without_a_buffer() { + let mut b = B::default(); + // Positions and normals interleaved: 24 bytes per vertex. + let interleaved: Vec = + QUAD.chunks(3).flat_map(|p| p.iter().chain(&[0.0, 0.0, 1.0]).flat_map(|x| x.to_le_bytes()).collect::>()).collect(); + let view = b.view(&interleaved); + b.views[view]["byteStride"] = json!(24); + b.accessors.push(json!({ "bufferView": view, "componentType": 5126, "count": 4, "type": "VEC3" })); + b.accessors.push(json!({ "bufferView": view, "byteOffset": 12, "componentType": 5126, "count": 4, "type": "VEC3" })); + // Normalised bytes for UVs and colours, as KHR_mesh_quantization allows. + let uv = b.acc(&[0, 0, 255, 0, 255, 255, 0, 255], 5121, 4, "VEC2"); + b.accessors[uv]["normalized"] = json!(true); + let color = b.acc(&[255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255], 5121, 4, "VEC3"); + b.accessors[color]["normalized"] = json!(true); + // A second UV set with no buffer view: all zeros. + b.accessors.push(json!({ "componentType": 5126, "count": 4, "type": "VEC2" })); + let zero = b.accessors.len() - 1; + let i = b.acc(&[0, 1, 2, 0, 2, 3], 5121, 6, "SCALAR"); + let glb = + one_mesh(&b, json!({ "attributes": { "POSITION": 0, "NORMAL": 1, "TEXCOORD_0": uv, "TEXCOORD_1": zero, "COLOR_0": color }, "indices": i })); + let s = parse(&glb).unwrap(); + s.validate().unwrap(); + let m = &s.meshes[0]; + assert_eq!(m.positions[2], [1.0, 1.0, 0.0]); + assert_eq!(m.normals, vec![[0.0, 0.0, 1.0]; 4]); + assert_eq!(m.uv_sets, [vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]], vec![[0.0; 2]; 4]]); + // A colour without alpha is opaque. + assert_eq!(m.colors[1], [0.0, 1.0, 0.0, 1.0]); + assert_eq!(m.triangle_count(), 2); + // A stride smaller than the element is refused. + b.views[view]["byteStride"] = json!(8); + assert!(parse(&one_mesh(&b, json!({ "attributes": { "POSITION": 0 } }))).is_err()); + // Accessors without a buffer share one budget of zeros, however often they are named. + let mut b = B::default(); + let keys = b.f32s(&[0.0; 3], "VEC3", 3); + b.accessors.push(json!({ "componentType": 5126, "count": MAX_ZERO_COMPONENTS, "type": "SCALAR" })); + let zeros = b.accessors.len() - 1; + let animated = |output: usize| { + b.glb(json!({ "nodes": [{}], "animations": [{ "samplers": [{ "input": zeros, "output": output }], "channels": [{ "sampler": 0, "target": { "node": 0, "path": "translation" } }] }] })) + }; + assert_eq!(parse(&animated(keys)).unwrap().animations[0].channels[0].times.len(), MAX_ZERO_COMPONENTS); + assert!(matches!(parse(&animated(zeros)), Err(Error::TooLarge(_)))); +} + +#[test] +fn primitives_of_a_mesh_share_its_pools() { + let mut b = B::default(); + let p = b.f32s(&QUAD, "VEC3", 3); + let n = b.f32s(&[0.0, 0.0, 1.0].repeat(4), "VEC3", 3); + let c = b.f32s(&[1.0, 0.0, 0.0, 0.5].repeat(4), "VEC4", 4); + let t = b.f32s(&[0.25; 8], "VEC2", 2); + let i = b.u16s(&[0, 1, 2]); + let glb = b.glb(json!({ "meshes": [{ "primitives": [ + { "attributes": { "POSITION": p }, "indices": i }, + { "attributes": { "POSITION": p, "NORMAL": n, "COLOR_0": c, "TEXCOORD_0": t }, "indices": i }, + { "attributes": { "POSITION": p }, "indices": i } + ] }] })); + let s = parse(&glb).unwrap(); + s.validate().unwrap(); + let m = &s.meshes[0]; + // The first and third name the same accessors and share their vertices. + assert_eq!((m.positions.len(), m.normals.len(), m.uv_sets[0].len(), m.colors.len(), m.parts.len()), (8, 4, 4, 8, 3)); + // The second primitive's corners count from where its vertices start in each pool. + assert_eq!(m.parts[1].corners[1], Corner { position: 5, normal: 1, uv: 1 }); + assert_eq!(m.parts[2].corners[1], Corner { position: 1, normal: NONE, uv: NONE }); + // Vertices of primitives without colours are white. + assert_eq!((m.colors[0], m.colors[5]), ([1.0; 4], [1.0, 0.0, 0.0, 0.5])); + // An attribute that does not fit the positions is passed over. + let short = b.f32s(&[0.0, 0.0, 1.0], "VEC3", 3); + let s = parse(&one_mesh(&b, json!({ "attributes": { "POSITION": p, "NORMAL": short } }))).unwrap(); + assert!(s.meshes[0].normals.is_empty()); +} + +#[test] +fn uv_sets_past_the_bound_are_not_read() { + let mut b = B::default(); + let p = b.f32s(&QUAD[..9], "VEC3", 3); + let t = b.f32s(&[0.5; 6], "VEC2", 2); + let mut attrs = json!({ "POSITION": p }); + for k in 0..MAX_UV_SETS + 2 { + attrs[format!("TEXCOORD_{k}")] = json!(t); + } + let s = parse(&one_mesh(&b, json!({ "attributes": attrs }))).unwrap(); + assert_eq!(s.meshes[0].uv_sets.len(), MAX_UV_SETS); + s.validate().unwrap(); +} + +#[test] +fn bad_triangles_are_left_out_and_later_ones_keep_their_vertices() { + let mut b = B::default(); + let p = b.f32s(&QUAD, "VEC3", 3); + // Triangles: (0,1,2), (0,1,9) out of range, (0,2,3), and two indices left over. + let i = b.u16s(&[0, 1, 2, 0, 1, 9, 0, 2, 3, 1, 2]); + let s = parse(&one_mesh(&b, json!({ "attributes": { "POSITION": p }, "indices": i }))).unwrap(); + assert_eq!(positions_of(&s), [[0, 1, 2], [0, 2, 3]]); + assert_eq!(s.warnings, ["2 triangle(s) could not be read and were left out"]); + // A position that is not finite takes its triangles with it. + let mut nan = QUAD; + nan[3] = f32::NAN; + let p = b.f32s(&nan, "VEC3", 3); + let i = b.u16s(&[0, 1, 2, 0, 2, 3]); + let s = parse(&one_mesh(&b, json!({ "attributes": { "POSITION": p }, "indices": i }))).unwrap(); + assert_eq!(positions_of(&s), [[0, 2, 3]]); + s.validate().unwrap(); +} + +#[test] +fn counts_are_checked_against_the_buffer_before_anything_is_allocated() { + // A one-triangle buffer whose accessors claim far more. + for (kind, count) in [("VEC3", 1u64 << 40), ("MAT4", u64::MAX / 16), ("VEC3", u64::MAX), ("VEC3", 4)] { + let mut b = B::default(); + b.f32s(&QUAD[..9], "VEC3", 3); + b.accessors[0]["count"] = json!(count); + b.accessors[0]["type"] = json!(kind); + let limits = Limits { max_vertices: usize::MAX, max_triangles: usize::MAX, ..Limits::default() }; + assert!(read(&one_mesh(&b, json!({ "attributes": { "POSITION": 0 } })), &limits, &mut no_external).is_err(), "{kind} {count}"); + } + // Integer accessors too. + let mut b = B::default(); + let p = b.f32s(&QUAD, "VEC3", 3); + let i = b.u16s(&[0, 1, 2]); + b.accessors[i]["count"] = json!(1u64 << 33); + let limits = Limits { max_triangles: usize::MAX, ..Limits::default() }; + assert!(read(&one_mesh(&b, json!({ "attributes": { "POSITION": p }, "indices": i })), &limits, &mut no_external).is_err()); + // A view that runs past its buffer, an accessor offset past its view, things that are not there. + let mut b = B::default(); + let p = b.f32s(&QUAD, "VEC3", 3); + b.views[0]["byteLength"] = json!(1u64 << 40); + assert!(parse(&one_mesh(&b, json!({ "attributes": { "POSITION": p } }))).is_err()); + b.views[0]["byteLength"] = json!(48); + b.accessors[0]["byteOffset"] = json!(40); + assert!(parse(&one_mesh(&b, json!({ "attributes": { "POSITION": p } }))).is_err()); + assert!(parse(&one_mesh(&b, json!({ "attributes": { "POSITION": 7 } }))).is_err()); + b.accessors[0]["bufferView"] = json!(3); + assert!(parse(&one_mesh(&b, json!({ "attributes": { "POSITION": p } }))).is_err()); +} + +#[test] +fn what_is_not_read_is_refused_or_named() { + let mut b = B::default(); + let p = b.f32s(&QUAD[..9], "VEC3", 3); + let doc = |extra: Value| { + let mut v = b.json(json!({ "meshes": [{ "primitives": [{ "attributes": { "POSITION": p } }] }] })); + v.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + glb_container(&serde_json::to_vec(&v).unwrap(), &b.bin).unwrap() + }; + assert!(parse(&doc(json!({}))).is_ok()); + assert!(matches!(parse(&doc(json!({ "asset": { "version": "1.0" } }))), Err(Error::Unsupported(_)))); + assert!(matches!(parse(&doc(json!({ "asset": {} }))), Err(Error::Unsupported(_)))); + assert!(matches!(parse(&doc(json!({ "extensionsRequired": ["KHR_draco_mesh_compression"] }))), Err(Error::Unsupported(_)))); + assert!(matches!(parse(&doc(json!({ "extensionsRequired": ["VENDOR_something_new"] }))), Err(Error::Unsupported(_)))); + // Extensions that only change looks are named in a warning; the ones the reader handles are not. + let s = parse(&doc( + json!({ "extensionsRequired": ["KHR_texture_transform", "KHR_materials_clearcoat", "KHR_mesh_quantization", "KHR_materials_unlit"] }), + )) + .unwrap(); + assert_eq!(s.warnings.len(), 2); + // Sparse accessors. + let mut sparse = B::default(); + let p = sparse.f32s(&QUAD, "VEC3", 3); + sparse.accessors[p]["sparse"] = json!({ "count": 1 }); + assert!(matches!(parse(&one_mesh(&sparse, json!({ "attributes": { "POSITION": p } }))), Err(Error::Unsupported(_)))); + assert!(parse(b"{ not json").is_err()); + assert!(parse(b"").is_err()); +} + +/// A skinned, animated triangle with a camera and lights. +fn rigged() -> Vec { + let mut b = B::default(); + let p = b.f32s(&QUAD[..9], "VEC3", 3); + let joints = b.acc(&[0, 1, 0, 0].repeat(3), 5121, 3, "VEC4"); + let weights = b.f32s(&[0.75, 0.25, 0.0, 0.0].repeat(3), "VEC4", 4); + let mut bind = IDENTITY.map(|x| x as f32).to_vec(); + bind.extend(IDENTITY.map(|x| x as f32)); + bind[12] = -1.0; + let ibm = b.f32s(&bind, "MAT4", 16); + let times = b.f32s(&[0.0, 1.0], "SCALAR", 1); + let moves = b.f32s(&[0.0, 0.0, 0.0, 0.0, 2.0, 0.0], "VEC3", 3); + b.glb(json!({ + "extensions": { "KHR_lights_punctual": { "lights": [ + { "type": "spot", "color": [1.0, 0.5, 0.0], "intensity": 40.0, "range": 9.0, "spot": { "innerConeAngle": 0.1, "outerConeAngle": 0.5 } }, + { "name": "Sun", "type": "directional" } + ] } }, + "scenes": [{ "nodes": [0, 3] }], + "nodes": [ + { "name": "body", "mesh": 0, "skin": 0, "children": [1] }, + { "name": "hip", "children": [2] }, + { "name": "knee" }, + { "name": "eye", "camera": 0, "extensions": { "KHR_lights_punctual": { "light": 1 } } } + ], + "meshes": [{ "primitives": [{ "attributes": { "POSITION": p, "JOINTS_0": joints, "WEIGHTS_0": weights } }] }], + "skins": [{ "name": "rig", "joints": [1, 2, 99], "inverseBindMatrices": ibm }], + "animations": [{ "name": "hop", "samplers": [{ "input": times, "output": moves, "interpolation": "STEP" }], + "channels": [{ "sampler": 0, "target": { "node": 1, "path": "translation" } }, { "sampler": 0, "target": { "node": 1, "path": "weights" } }, { "sampler": 0, "target": { "node": 77, "path": "scale" } }] }], + "cameras": [{ "name": "Main", "type": "perspective", "perspective": { "yfov": 0.6, "aspectRatio": 1.5, "znear": 0.1 } }, { "type": "orthographic", "orthographic": { "xmag": 2.0, "ymag": 1.0, "znear": 0.1, "zfar": 50.0 } }] + })) +} + +#[test] +fn skins_animations_cameras_and_lights() { + let s = parse(&rigged()).unwrap(); + s.validate().unwrap(); + let m = &s.meshes[0]; + assert_eq!((m.joints[0], m.weights[0]), ([0, 1, 0, 0], [0.75, 0.25, 0.0, 0.0])); + // A joint that names no node is dropped. + let skin = &s.skins[0]; + assert_eq!((skin.name.as_str(), skin.joints.as_slice(), skin.inverse_bind.len()), ("rig", &[1, 2][..], 2)); + assert_eq!(skin.inverse_bind[0][12], -1.0); + assert_eq!(s.nodes[0].skin, Some(0)); + // Only the channels this model holds, on nodes that exist. + let hop = &s.animations[0]; + assert_eq!((hop.name.as_str(), hop.channels.len()), ("hop", 1)); + let c = &hop.channels[0]; + assert_eq!((c.node, c.path, c.interpolation), (1, ChannelPath::Translation, Interpolation::Step)); + assert_eq!((c.times.as_slice(), c.values.as_slice()), (&[0.0, 1.0][..], &[0.0, 0.0, 0.0, 0.0, 2.0, 0.0][..])); + assert_eq!(s.cameras[0].projection, Projection::Perspective { yfov: 0.6, aspect: Some(1.5), znear: 0.1, zfar: None }); + assert_eq!(s.cameras[1].projection, Projection::Orthographic { xmag: 2.0, ymag: 1.0, znear: 0.1, zfar: 50.0 }); + assert_eq!((s.nodes[3].camera, s.nodes[3].light), (Some(0), Some(1))); + assert_eq!((s.lights[0].kind, s.lights[0].color, s.lights[0].range), (LightKind::Spot { inner: 0.1, outer: 0.5 }, [1.0, 0.5, 0.0], Some(9.0))); + assert_eq!((s.lights[1].name.as_str(), s.lights[1].kind, s.lights[1].intensity), ("Sun", LightKind::Directional, 1.0)); +} + +#[test] +fn materials_and_embedded_images() { + let mut b = B::default(); + let p = b.f32s(&QUAD, "VEC3", 3); + let image = b.view(b"\xff\xd8 jpeg bytes"); + let glb = b.glb(json!({ + "meshes": [{ "primitives": [{ "attributes": { "POSITION": p }, "material": 5 }] }], + "materials": [{ + "pbrMetallicRoughness": { "baseColorTexture": { "index": 0 }, "metallicRoughnessTexture": { "index": 9 } }, + "normalTexture": { "index": 0, "scale": 0.5 }, "occlusionTexture": { "index": 0, "strength": 0.25 }, "emissiveTexture": { "index": 1 }, + "emissiveFactor": [1.0, 0.5, 0.0], "alphaMode": "BLEND", + "extensions": { "KHR_materials_emissive_strength": { "emissiveStrength": 4.0 }, "KHR_materials_unlit": {} } + }, {}], + "textures": [{ "source": 0 }, { "source": 3 }], + "images": [{ "name": "wood", "bufferView": image, "mimeType": "image/jpeg" }] + })); + let s = parse(&glb).unwrap(); + s.validate().unwrap(); + let m = &s.materials[0]; + assert_eq!( + (s.images[0].name.as_str(), s.images[0].data.as_deref(), s.images[0].mime_type.as_deref()), + ("wood", Some(&b"\xff\xd8 jpeg bytes"[..]), Some("image/jpeg")) + ); + assert_eq!(m.base_color_texture, Some(TextureRef::default())); + // Slots that name a texture or an image that is not there stay empty. + assert_eq!((m.metallic_roughness_texture, m.emissive_texture), (None, None)); + assert_eq!((m.normal_scale, m.occlusion_strength, m.emissive, m.alpha_mode, m.unlit), (0.5, 0.25, [4.0, 2.0, 0.0], AlphaMode::Blend, true)); + // glTF's defaults; a material index that does not exist leaves the part without one. + assert_eq!(s.materials[1], Material::default()); + assert_eq!(s.meshes[0].parts[0].material, None); +} + +fn too(r: crate::Result) -> bool { + matches!(r, Err(Error::TooLarge(_))) +} + +fn with(glb: &[u8], f: &dyn Fn(&mut Limits)) -> crate::Result { + let mut l = Limits::default(); + f(&mut l); + read(glb, &l, &mut no_external) +} + +#[test] +fn limits() { + let q = quad(); + assert!(with(&q, &|l| l.max_vertices = 4).is_ok()); + assert!(too(with(&q, &|l| l.max_vertices = 3))); + assert!(with(&q, &|l| l.max_triangles = 2).is_ok()); + assert!(too(with(&q, &|l| l.max_triangles = 1))); + assert!(with(&q, &|l| l.max_parts = 1).is_ok()); + assert!(too(with(&q, &|l| l.max_parts = 0))); + assert!(with(&q, &|l| l.max_meshes = 1).is_ok()); + assert!(too(with(&q, &|l| l.max_meshes = 0))); + assert!(with(&q, &|l| l.max_materials = 1).is_ok()); + assert!(too(with(&q, &|l| l.max_materials = 0))); + assert!(with(&q, &|l| l.max_nodes = 3).is_ok()); + assert!(too(with(&q, &|l| l.max_nodes = 2))); + assert!(with(&q, &|l| l.max_depth = 1).is_ok()); + assert!(too(with(&q, &|l| l.max_depth = 0))); + let bin = split_glb(&q).unwrap().1.unwrap().len(); + assert!(with(&q, &|l| l.max_data_bytes = bin).is_ok()); + assert!(too(with(&q, &|l| l.max_data_bytes = bin - 1))); +} + +#[test] +fn limits_count_shared_accessors_expansions_and_extras() { + // One accessor named by the primitives of one mesh is one set of vertices; named by + // several meshes, each keeps its own, so the count that matters is the sum. + let mut b = B::default(); + let p = b.f32s(&QUAD, "VEC3", 3); + let i = b.u16s(&[0, 1, 2]); + let prims: Vec = (0..5).map(|_| json!({ "attributes": { "POSITION": p }, "indices": i })).collect(); + let one = b.glb(json!({ "meshes": [{ "primitives": prims }] })); + assert!(with(&one, &|l| l.max_vertices = 4).is_ok()); + assert_eq!(parse(&one).unwrap().meshes[0].positions.len(), 4); + let meshes: Vec = (0..5).map(|_| json!({ "primitives": [prims[0]] })).collect(); + let shared = b.glb(json!({ "meshes": meshes })); + assert!(with(&shared, &|l| l.max_vertices = 20).is_ok()); + assert!(too(with(&shared, &|l| l.max_vertices = 19))); + assert!(with(&shared, &|l| l.max_triangles = 5).is_ok()); + assert!(too(with(&shared, &|l| l.max_triangles = 4))); + assert!(with(&one, &|l| l.max_parts = 5).is_ok()); + assert!(too(with(&one, &|l| l.max_parts = 4))); + // Strips and unindexed primitives count the triangles they expand to. + let strip = one_mesh(&b, json!({ "attributes": { "POSITION": p }, "mode": 5 })); + assert!(with(&strip, &|l| l.max_triangles = 2).is_ok()); + assert!(too(with(&strip, &|l| l.max_triangles = 1))); + + // Skins, animations, cameras, lights and the values they read. + let r = rigged(); + assert!(with(&r, &|l| l.max_extras = 3).is_ok()); + for (key, extra) in + [("skins", json!({})), ("animations", json!({})), ("cameras", json!({ "type": "perspective" })), ("buffers", json!({ "byteLength": 0 }))] + { + let (json, bin) = split_glb(&r).unwrap(); + let mut doc: Value = serde_json::from_slice(json).unwrap(); + let list = doc[key].as_array_mut().unwrap(); + while list.len() < 4 { + list.push(extra.clone()); + } + let glb = glb_container(&serde_json::to_vec(&doc).unwrap(), bin.unwrap()).unwrap(); + assert!(with(&glb, &|l| l.max_extras = 4).is_ok(), "{key}"); + assert!(too(with(&glb, &|l| l.max_extras = 3)), "{key}"); + } + let image_and_keys = with(&r, &|l| l.max_data_bytes = split_glb(&r).unwrap().1.unwrap().len()); + assert!(image_and_keys.is_ok()); +} + +#[test] +fn hierarchies_with_cycles_or_missing_nodes() { + let mut b = B::default(); + let p = b.f32s(&QUAD, "VEC3", 3); + let tree = |nodes: Value| { + b.glb(json!({ "scenes": [{ "nodes": [0] }], "nodes": nodes, "meshes": [{ "primitives": [{ "attributes": { "POSITION": p } }] }] })) + }; + // A node that is its own descendant is refused instead of walked for ever. + assert!(matches!(parse(&tree(json!([{ "children": [1] }, { "children": [0], "mesh": 0 }]))), Err(Error::TooLarge(_)))); + assert!(matches!(parse(&tree(json!([{ "children": [0] }]))), Err(Error::TooLarge(_)))); + // Children, meshes and skins that do not exist are dropped. + let s = parse(&tree(json!([{ "children": [1, 40], "mesh": 3, "skin": 2, "camera": 1 }, { "mesh": 0 }]))).unwrap(); + s.validate().unwrap(); + assert_eq!((s.nodes[0].children.as_slice(), s.nodes[0].mesh, s.nodes[0].skin, s.nodes[0].camera), (&[1][..], None, None, None)); + // A shared subtree expanded too often runs into the visit limit, not out of memory. + let fan: Vec = (0..20).map(|i| if i < 19 { json!({ "children": [i + 1, i + 1] }) } else { json!({ "mesh": 0 }) }).collect(); + assert!(matches!(parse(&tree(json!(fan))), Err(Error::TooLarge(_)))); +} + +#[test] +fn hostile_files_never_panic() { + hammer(&quad(), &parse); + hammer(&rigged(), &parse); + let (doc, _) = gltf_text(&format!("data:application/octet-stream;base64,{}", base64(&gltf_text("", "").1)), "wood.png"); + hammer(&doc, &parse); +} + +fn painted() -> Scene { + let mesh = Mesh { + name: "Card".into(), + positions: QUAD.chunks(3).map(|p| [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])]).collect(), + normals: vec![[0.0, 0.0, 1.0]], + uv_sets: vec![vec![[0.0, 1.0], [1.0, 1.0], [1.0, 0.0], [0.0, 0.0]], vec![[0.5, 0.5]; 4]], + colors: vec![[1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0], [1.0; 4]], + parts: vec![ + // A quad: written as two triangles. + Part { + name: "front".into(), + material: Some(0), + face_sizes: vec![4], + corners: (0..4).map(|i| Corner { position: i, normal: 0, uv: i }).collect(), + ..Part::default() + }, + Part { material: Some(1), corners: [0, 2, 1].map(|i| Corner { position: i, normal: 0, uv: i }).to_vec(), ..Part::default() }, + Part::default(), + ], + ..Mesh::default() + }; + let wood = TextureRef { image: 0, uv_set: 1, wrap: [Wrap::Clamp, Wrap::Repeat] }; + let materials = vec![ + Material { + name: "Wood".into(), + base_color: [0.5, 0.25, 0.125, 1.0], + metallic: 0.0, + roughness: 0.75, + emissive: [2.0, 1.0, 0.0], + alpha_mode: AlphaMode::Mask(0.25), + double_sided: true, + unlit: true, + base_color_texture: Some(wood), + normal_texture: Some(TextureRef { image: 1, ..TextureRef::default() }), + normal_scale: 0.5, + occlusion_texture: Some(TextureRef { image: 2, ..TextureRef::default() }), + ..Material::default() + }, + Material { alpha_mode: AlphaMode::Blend, ..Material::plain("Glass", [1.0, 1.0, 1.0, 0.5]) }, + ]; + let mut scene = Scene::from_meshes(vec![mesh, Mesh { name: "empty".into(), ..Mesh::default() }], materials); + scene.images = vec![ + Image { name: "wood".into(), data: Some(b"\x89PNG wood".to_vec()), ..Image::default() }, + Image { uri: Some("bump.png".into()), ..Image::default() }, + Image::default(), + ]; + // root → the card, moved; the empty mesh's node stays, without a mesh. + scene.nodes[0].transform[12] = 3.0; + scene.nodes.push(Node { name: "root".into(), children: vec![0, 1], ..Node::default() }); + scene.roots = vec![2]; + scene +} + +#[test] +fn written_files_read_back() { + let scene = painted(); + let glb = write_glb(&scene, &Limits::default()).unwrap(); + assert_eq!(glb.len() % 4, 0); + let back = parse(&glb).unwrap(); + back.validate().unwrap(); + assert!(back.warnings.is_empty(), "{:?}", back.warnings); + // The hierarchy: names, placement, and the mesh without triangles gone from its node. + assert_eq!(back.roots, [2]); + assert_eq!( + back.nodes.iter().map(|n| (n.name.as_str(), n.mesh, n.children.len())).collect::>(), + [("Card", Some(0), 0), ("empty", None, 0), ("root", None, 2)] + ); + assert_eq!(back.nodes[0].transform, scene.nodes[0].transform); + // The geometry, as one vertex per distinct corner. + let (a, b) = (scene.meshes[0].unified(), back.meshes[0].unified()); + assert_eq!((back.meshes.len(), back.meshes[0].name.as_str()), (1, "Card")); + assert_eq!((&a.positions, &a.normals, &a.uvs, &a.colors), (&b.positions, &b.normals, &b.uvs, &b.colors)); + assert_eq!(b.parts, [vec![[0, 1, 2], [0, 2, 3]], vec![[0, 2, 1]]]); + assert_eq!(back.meshes[0].uv_sets[1], vec![[0.5, 0.5]; 4]); + assert_eq!(back.meshes[0].parts.iter().map(|p| p.material).collect::>(), [Some(0), Some(1)]); + // The materials; the image without bytes or a name takes its texture slot with it. + let mut wood = scene.materials[0].clone(); + wood.occlusion_texture = None; + assert_eq!(back.materials, [wood, scene.materials[1].clone()]); + assert_eq!(back.images.len(), 2); + assert_eq!((back.images[0].data.as_deref(), back.images[0].mime_type.as_deref()), (Some(&b"\x89PNG wood"[..]), Some("image/png"))); + assert_eq!(back.images[1].uri.as_deref(), Some("bump.png")); + assert_eq!(back.external, ["bump.png"]); +} + +#[test] +fn writer_refuses_what_is_not_gltf() { + let limits = Limits::default(); + let mut s = painted(); + s.nodes[0].transform[12] = f64::NAN; + assert!(write_glb(&s, &limits).is_err()); + let mut s = painted(); + s.meshes[0].positions[0][0] = f64::INFINITY; + assert!(write_glb(&s, &limits).is_err()); + let mut s = painted(); + s.materials[0].roughness = f32::NAN; + assert!(write_glb(&s, &limits).is_err()); + let mut s = painted(); + s.meshes[0].parts[0].corners[0].position = 50; + assert!(write_glb(&s, &limits).is_err()); + let mut s = painted(); + s.nodes[0].children = vec![2]; + assert!(write_glb(&s, &limits).is_err(), "a cycle"); + assert!(matches!(write_glb(&painted(), &Limits { max_data_bytes: 64, ..Limits::default() }), Err(Error::TooLarge(_)))); + // A scene with nothing in it is still a file. + let empty = parse(&write_glb(&Scene::default(), &limits).unwrap()).unwrap(); + assert!(empty.meshes.is_empty() && empty.nodes.is_empty()); + // A mirrored node keeps its matrix: glTF viewers turn the winding themselves. + let mut s = Scene::from_meshes(vec![super::tri_mesh()], vec![]); + s.nodes[0].transform[0] = -1.0; + let back = parse(&write_glb(&s, &limits).unwrap()).unwrap(); + assert_eq!(back.nodes[0].transform[0], -1.0); + let t = back.meshes[0].unified().parts[0][0].map(|i| back.meshes[0].positions[i as usize]); + assert_eq!(face_normal(t), Some([0.0, 0.0, 1.0])); +} diff --git a/crates/mesh/src/tests/obj.rs b/crates/mesh/src/tests/obj.rs new file mode 100644 index 00000000..512ffd4e --- /dev/null +++ b/crates/mesh/src/tests/obj.rs @@ -0,0 +1,283 @@ +use super::hammer; +use crate::obj::{material_names, read, read_mtl, sibling_name, write, write_mtl}; +use crate::{AlphaMode, Corner, Error, Limits, Material, Mesh, NONE, Part, Scene, no_external}; + +/// A unit cube with texture coordinates, normals, two materials and one face written with +/// negative references. +const CUBE: &str = "# a unit cube\nmtllib cube.mtl\no Cube\n\ +v -0.5 -0.5 0.5\nv 0.5 -0.5 0.5\nv 0.5 0.5 0.5\nv -0.5 0.5 0.5\nv -0.5 -0.5 -0.5\nv 0.5 -0.5 -0.5\nv 0.5 0.5 -0.5\nv -0.5 0.5 -0.5\n\ +vt 0 0\nvt 1 0\nvt 1 1\nvt 0 1\nvn 0 0 1\nvn 0 0 -1\nvn 1 0 0\nvn -1 0 0\nvn 0 1 0\nvn 0 -1 0\n\ +usemtl Red\nf 1/1/1 2/2/1 3/3/1 4/4/1\nf 6/1/2 5/2/2 8/3/2 7/4/2\nf 2/1/3 6/2/3 7/3/3 3/4/3\n\ +usemtl Gold\nf 5/1/4 1/2/4 4/3/4 8/4/4\nf 4/1/5 3/2/5 7/3/5 8/4/5\nf -4/1/6 -3/2/6 -7/3/6 -8/4/6\n"; +const CUBE_MTL: &str = + "# materials for the cube\nnewmtl Red\nKd 1.0 0.0 0.0\nNs 98\nd 1.0\n\nnewmtl Gold\nKd 1.0 0.766 0.336\nKe 0.0 0.0 0.0\nPr 0.3\nPm 1.0\n"; + +fn parse(bytes: &[u8]) -> crate::Result { + read(bytes, &Limits::default(), &mut no_external) +} + +fn cube() -> Scene { + read(CUBE.as_bytes(), &Limits::default(), &mut |name| (name == "cube.mtl").then(|| CUBE_MTL.as_bytes().to_vec())).unwrap() +} + +#[test] +fn cube_with_its_material_library() { + let s = cube(); + s.validate().unwrap(); + assert!(s.warnings.is_empty(), "{:?}", s.warnings); + assert_eq!(s.external, ["cube.mtl"]); + assert_eq!(s.materials.len(), 2); + let red = &s.materials[0]; + assert_eq!((red.name.as_str(), red.base_color, red.metallic), ("Red", [1.0, 0.0, 0.0, 1.0], 0.0)); + assert!((red.roughness - (2.0f32 / 100.0).sqrt()).abs() < 1e-6); + assert_eq!((s.materials[1].metallic, s.materials[1].roughness), (1.0, 0.3)); + let m = &s.meshes[0]; + assert_eq!((m.name.as_str(), m.parts.len(), m.triangle_count()), ("Cube", 2, 12)); + assert_eq!((m.parts[0].material, m.parts[1].material), (Some(0), Some(1))); + // Polygons stay whole, and the pools are the file's: 8 positions, 4 UVs, 6 normals. + assert_eq!(m.parts[0].face_sizes, [4, 4, 4]); + assert_eq!((m.positions.len(), m.uv_sets[0].len(), m.normals.len()), (8, 4, 6)); + // Four distinct (v, vt, vn) corners per face. + let u = m.unified(); + assert_eq!((u.positions.len(), u.normals.len(), u.uvs.len()), (24, 24, 24)); + // Every face is wound counter-clockwise from outside (its face normal agrees with `vn`). + for t in u.parts.iter().flatten() { + let p = t.map(|i| u.positions[i as usize]); + let f = crate::face_normal(p).unwrap(); + let n = u.normals[t[0] as usize]; + assert!(f[0] * n[0] + f[1] * n[1] + f[2] * n[2] > 0.99); + } + // V is flipped to the image's top-down rows. + assert_eq!(m.uv_sets[0][0], [0.0, 1.0]); + // The negative references of the last face name the same vertices as positive ones would. + let last: Vec = m.parts[1].faces().last().unwrap().iter().map(|c| c.position).collect(); + assert_eq!(last, [4, 5, 1, 0]); +} + +#[test] +fn a_library_that_is_not_supplied_leaves_named_defaults() { + let s = parse(CUBE.as_bytes()).unwrap(); + assert_eq!(s.external, ["cube.mtl"]); + assert_eq!(s.warnings.len(), 1); + assert_eq!(s.materials.iter().map(|m| m.name.as_str()).collect::>(), ["Red", "Gold"]); + assert_eq!(s.materials[0].base_color, [0.8, 0.8, 0.8, 1.0]); +} + +#[test] +fn library_names_with_spaces_several_names_and_foreign_paths() { + let src = |lib: &str| format!("mtllib {lib}\nv 0 0 0\nv 1 0 0\nv 0 1 0\nusemtl Red\nf 1 2 3\n"); + for (lib, supplied) in [("my materials.mtl", "my materials.mtl"), ("a.mtl b.mtl", "b.mtl"), ("C:\\models\\cube.mtl", "cube.mtl")] { + let mut asked = Vec::new(); + let s = read(src(lib).as_bytes(), &Limits::default(), &mut |name| { + asked.push(name.to_string()); + (name == supplied).then(|| CUBE_MTL.as_bytes().to_vec()) + }) + .unwrap(); + assert_eq!(s.materials[0].base_color, [1.0, 0.0, 0.0, 1.0], "{lib}: asked {asked:?}"); + // The name as written is asked first and is what the scene lists. + assert_eq!((asked[0].as_str(), s.external[0].as_str()), (lib, lib)); + } + assert_eq!(sibling_name("C:\\textures\\checker.png"), Some("checker.png")); + assert_eq!(sibling_name("checker.png"), None); +} + +#[test] +fn mtl_images_are_named_not_opened() { + let mtl = "newmtl A\nmap_Kd my checker.png\nmap_Bump -bm 1 bump.png\nmap_Ke glow.png\nd 0.5\nnewmtl B\nmap_Kd my checker.png\nTr 0.25\n"; + let (materials, images) = read_mtl(mtl.as_bytes(), &Limits::default()).unwrap(); + // A complete name wins over its final token; options are passed over; a name is one image. + assert_eq!(images.iter().map(|i| i.uri.as_deref().unwrap()).collect::>(), ["my checker.png", "bump.png", "glow.png"]); + assert!(images.iter().all(|i| i.data.is_none())); + assert_eq!(materials[0].base_color_texture.unwrap().image, 0); + assert_eq!(materials[0].normal_texture.unwrap().image, 1); + assert_eq!(materials[0].emissive, [1.0; 3], "an emissive map without a colour shows"); + assert_eq!(materials[1].base_color_texture.unwrap().image, 0); + assert_eq!((materials[0].alpha_mode, materials[0].base_color[3]), (AlphaMode::Blend, 0.5)); + assert_eq!(materials[1].base_color[3], 0.75); + // Through an OBJ the image names are listed after the library. + let s = + read(b"mtllib m.mtl\nv 0 0 0\nv 1 0 0\nv 0 1 0\nusemtl A\nf 1 2 3\n", &Limits::default(), &mut |_| Some(mtl.as_bytes().to_vec())).unwrap(); + assert_eq!(s.external, ["m.mtl", "my checker.png", "bump.png", "glow.png"]); + s.validate().unwrap(); +} + +#[test] +fn objects_groups_quads_and_negative_indices() { + let src = "# cube\no Cube\nv 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nv 0 0 1\nv 1 0 1\nv 1 1 1\nv 0 1 1\n\ + f 1 4 3 2\nf 5 6 7 8\ng side walls\nf 1 2 6 5\nf 2/1 3/2 7/3 6/4\nf 3//1 4//1 8//1 7//1\nf -4 -8 -5 -1\n\ + o Tri\nv 5 5 5\nv 6 5 5\nv 5 6 5\nf -3 -2 -1\n"; + let s = parse(src.as_bytes()).unwrap(); + s.validate().unwrap(); + assert!(s.warnings.is_empty(), "{:?}", s.warnings); + assert_eq!( + s.meshes.iter().map(|m| (m.name.as_str(), m.triangle_count(), m.positions.len())).collect::>(), + [("Cube", 12, 8), ("Tri", 1, 3)] + ); + // Each object holds only its own vertices, and has a node. + assert_eq!(s.meshes[1].positions[0], [5.0, 5.0, 5.0]); + assert_eq!(s.nodes.iter().map(|n| n.mesh).collect::>(), [Some(0), Some(1)]); + // A group is a part; its name keeps its spaces. + assert_eq!(s.meshes[0].parts.iter().map(|p| p.name.as_str()).collect::>(), ["", "side walls"]); + // References to texture coordinates and normals that are not there leave the corner without. + assert!(s.meshes[0].parts[1].corners.iter().all(|c| c.uv == NONE && c.normal == NONE)); + // Without `o`, the file is one unnamed mesh. + let s = parse(b"v 0 0 0\nv 1 0 0\nv 0 1 0\ng a\nf 1 2 3\ng b\nf 3 2 1\n").unwrap(); + assert_eq!((s.meshes.len(), s.meshes[0].name.as_str(), s.meshes[0].parts.len()), (1, "", 2)); +} + +#[test] +fn corner_normals_and_smoothing_groups() { + let vertices = "v 0 0 0\nv 1 0 0\nv 0 1 0\nv 0 -0.8660254037844386 0.5\n"; + let authored = format!("{vertices}vn 0 0 1\nvn 0 0.5 0.8660254037844386\nf 1//-2 2//-2 3//-2\nf 2//-1 1//-1 4//-1\n"); + let s = parse(authored.as_bytes()).unwrap(); + let m = &s.meshes[0]; + assert_eq!((m.positions.len(), m.normals.len()), (4, 2)); + // Normals keep the precision of the text. + assert_eq!(m.normals[1], [0.0, 0.5, 0.8660254037844386]); + assert_eq!(m.parts[0].corners.iter().map(|c| c.normal).collect::>(), [0, 0, 0, 1, 1, 1]); + // A smoothing group is a part; `s off` and `s 0` end it. + let s = parse(format!("{vertices}s 5\nf 1 2 3\ns off\nf 2 1 4\ns on\nf 1 2 3\ns 0\nf 1 2 3\n").as_bytes()).unwrap(); + assert_eq!(s.meshes[0].parts.iter().map(|p| (p.smoothing, p.triangle_count())).collect::>(), [(5, 1), (0, 2), (1, 1)]); +} + +#[test] +fn vertex_colours_continuations_and_polygons() { + let src = "v 0 0 0 1 0 0\nv 1 0 0\nv 1 1 0 0 0 1\nv 0 1 0\nv 0.5 2 0\nf 1 2 \\\n 3 4 5\n"; + let s = parse(src.as_bytes()).unwrap(); + let m = &s.meshes[0]; + // Vertices without a colour are white once any has one. + assert_eq!(m.colors, [[1.0, 0.0, 0.0, 1.0], [1.0; 4], [0.0, 0.0, 1.0, 1.0], [1.0; 4], [1.0; 4]]); + assert_eq!((m.parts[0].face_sizes.as_slice(), m.triangle_count()), (&[5][..], 3)); + // Triangles alone keep no sizes; a later polygon fills them in. + let s = parse(b"v 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nf 1 2 3\nf 1 2 3 4\n").unwrap(); + assert_eq!(s.meshes[0].parts[0].face_sizes, [3, 4]); + assert!(parse(b"v 0 0 0\nv 1 0 0\nv 1 1 0\nf 1 2 3\n").unwrap().meshes[0].parts[0].face_sizes.is_empty()); + assert!(s.meshes[0].colors.is_empty()); +} + +#[test] +fn bad_lines_are_left_out_and_counted() { + // A vertex that cannot be read keeps its number; faces that use it are left out. + let s = parse(b"v nan 0 0\nv 1 0 0\nv 0 1 0\nv 0 0 1\nf 1 2 3\nf 2 3 4\nf 2 3 x\nf 2 3\n").unwrap(); + assert_eq!(s.meshes[0].triangle_count(), 1); + assert_eq!(s.meshes[0].positions, [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]); + assert_eq!(s.warnings, ["4 vertex or face line(s) could not be read and were left out"]); + // Non-finite normals and texture coordinates leave their corners without. + let s = parse(b"v 0 0 0\nv 1 0 0\nv 0 1 0\nvn nan 0 1\nvt inf 0\nf 1/1/1 2/1/1 3/1/1\n").unwrap(); + assert!(s.meshes[0].normals.is_empty() && s.meshes[0].uv_sets.is_empty()); + assert_eq!(s.warnings.len(), 1); + for src in [ + "", + "f 1 2 3", + "v 1 2\nf 1 1 1", + "v 0 0 0\n", + "v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 99999999999999999999", + "v 0 0 0\nv 1 0 0\nv 0 1 0\nf -1 -2 -9", + "o\ng\nf", + ] { + assert!(parse(src.as_bytes()).is_err(), "{src:?}"); + } + assert!(parse(b"\x00\xffv 1 1 1").is_err()); + hammer(CUBE.as_bytes(), &parse); + hammer(CUBE_MTL.as_bytes(), &|b| read(CUBE.as_bytes(), &Limits::default(), &mut |_| Some(b.to_vec()))); +} + +#[test] +fn limits() { + let too = |r: crate::Result| matches!(r, Err(Error::TooLarge(_))); + let lim = |f: &dyn Fn(&mut Limits)| { + let mut l = Limits::default(); + f(&mut l); + l + }; + let read_with = |src: &str, l: &Limits| read(src.as_bytes(), l, &mut |_| Some(CUBE_MTL.as_bytes().to_vec())); + // Each pool on its own, counting definitions no face uses. + assert!(read_with(CUBE, &lim(&|l| l.max_vertices = 8)).is_ok()); + assert!(too(read_with(CUBE, &lim(&|l| l.max_vertices = 7)))); + let unused = "v 0 0 0\nv 1 0 0\nv 0 1 0\nvt 0 0\nvt 0 0\nvt 0 0\nvt 0 0\nf 1 2 3\n"; + assert!(too(read_with(unused, &lim(&|l| l.max_vertices = 3))), "texture coordinates"); + assert!(too(read_with(&unused.replace("vt 0 0", "vn 0 0 1"), &lim(&|l| l.max_vertices = 3))), "normals"); + assert!(read_with(CUBE, &lim(&|l| l.max_triangles = 12)).is_ok()); + assert!(too(read_with(CUBE, &lim(&|l| l.max_triangles = 11)))); + assert!(read_with(CUBE, &lim(&|l| l.max_face_corners = 4)).is_ok()); + assert!(too(read_with(CUBE, &lim(&|l| l.max_face_corners = 3)))); + assert!(read_with(CUBE, &lim(&|l| l.max_parts = 2)).is_ok()); + assert!(too(read_with(CUBE, &lim(&|l| l.max_parts = 1)))); + assert!(read_with(CUBE, &lim(&|l| l.max_materials = 2)).is_ok()); + assert!(too(read_with(CUBE, &lim(&|l| l.max_materials = 1)))); + // Materials named by `usemtl` alone count too. + assert!(too(read(CUBE.as_bytes(), &lim(&|l| l.max_materials = 1), &mut no_external))); + let two = "o a\nv 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\no b\nf 1 2 3\n"; + assert!(read_with(two, &lim(&|l| l.max_meshes = 2)).is_ok()); + assert!(too(read_with(two, &lim(&|l| l.max_meshes = 1)))); + assert!(read_with(CUBE, &lim(&|l| l.max_data_bytes = CUBE_MTL.len())).is_ok()); + assert!(too(read_with(CUBE, &lim(&|l| l.max_data_bytes = CUBE_MTL.len() - 1)))); +} + +#[test] +fn written_files_read_back() { + let scene = cube(); + let limits = Limits::default(); + let text = write(&scene, Some("cube.mtl"), &limits).unwrap(); + let mtl = write_mtl(&scene); + let back = read(text.as_bytes(), &limits, &mut |name| (name == "cube.mtl").then(|| mtl.as_bytes().to_vec())).unwrap(); + assert!(back.warnings.is_empty(), "{:?}", back.warnings); + assert_eq!(back.meshes, scene.meshes); + for (a, b) in back.materials.iter().zip(&scene.materials) { + assert_eq!((a.name.as_str(), a.metallic), (b.name.as_str(), b.metallic)); + assert!(a.base_color.iter().zip(b.base_color).all(|(x, y)| (x - y).abs() < 1e-5), "{a:?} {b:?}"); + assert!((a.roughness - b.roughness).abs() < 1e-5); + } + // Without a library name no `mtllib` is written; `usemtl` still names the materials. + let alone = write(&scene, None, &limits).unwrap(); + assert!(!alone.contains("mtllib") && alone.contains("usemtl Red\n")); +} + +#[test] +fn writer_places_meshes_keeps_colours_and_names_materials_apart() { + let mesh = Mesh { + name: "A\nB".into(), + positions: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + normals: vec![[0.0, 0.0, 1.0]], + colors: vec![[1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]], + parts: vec![ + Part { + name: "g1".into(), + material: Some(0), + smoothing: 2, + corners: [0, 1, 2].map(|p| Corner { position: p, normal: 0, uv: NONE }).to_vec(), + ..Part::default() + }, + Part { material: Some(1), corners: [0, 1, 2].map(Corner::at).to_vec(), ..Part::default() }, + ], + ..Mesh::default() + }; + let mut scene = + Scene::from_meshes(vec![mesh], vec![Material::plain("Steel", [1.0; 4]), Material::plain("steel", [1.0; 4]), Material::plain(" ", [1.0; 4])]); + assert_eq!(material_names(&scene), ["Steel", "steel_2", "material"]); + // The mesh placed twice: moved, and mirrored. + scene.nodes.push(scene.nodes[0].clone()); + scene.roots.push(1); + scene.nodes[0].transform[14] = 3.0; + scene.nodes[1].transform[0] = -1.0; + let text = write(&scene, Some("m.mtl"), &Limits::default()).unwrap(); + assert!(text.contains("o A B\n"), "a control character cannot split the line: {text}"); + assert!(text.contains("v 0 0 3 1 0 0\n") && text.contains("vn 0 0 1\n")); + assert!(text.contains("g g1\nusemtl Steel\ns 2\nf 1//1 2//1 3//1\nusemtl steel_2\ns off\nf 1 2 3\n"), "{text}"); + // The second object's indices run on, and its mirrored faces are turned back. + assert!(text.contains("s 2\nf 6//2 5//2 4//2\n"), "{text}"); + let back = parse(text.as_bytes()).unwrap(); + assert_eq!(back.meshes.len(), 2); + assert_eq!(back.meshes[0].colors, scene.meshes[0].colors); + for m in &back.meshes { + let u = m.unified(); + let t = u.parts[0][0].map(|i| u.positions[i as usize]); + assert!(crate::face_normal(t).unwrap()[2] > 0.99, "both copies face +Z"); + } + // Nothing placed, or a mesh that fails validation, is an error. + let mut empty = scene.clone(); + empty.roots.clear(); + assert!(write(&empty, None, &Limits::default()).is_err()); + scene.meshes[0].parts[0].corners[0].position = 9; + assert!(write(&scene, None, &Limits::default()).is_err()); +} diff --git a/crates/mesh/src/tests/ply.rs b/crates/mesh/src/tests/ply.rs new file mode 100644 index 00000000..3c416055 --- /dev/null +++ b/crates/mesh/src/tests/ply.rs @@ -0,0 +1,195 @@ +use super::hammer; +use crate::ply::{Encoding, Kind, MAX_ELEMENTS, MAX_PROPERTIES, Rows, Scalar, is_ply, parse_header, read}; +use crate::{Error, Limits, Scene}; + +fn parse(bytes: &[u8]) -> crate::Result { + read(bytes, &Limits::default()) +} + +/// A binary file with CRLF header lines, an element before the vertices, a property after the +/// colours and one triangle. +fn binary(big: bool) -> Vec { + let order = if big { "big" } else { "little" }; + let mut b = format!( + "ply\r\nformat binary_{order}_endian 1.0\r\ncomment made by hand\r\nelement camera 1\r\nproperty double k\r\nelement vertex 3\r\nproperty float32 x\r\nproperty float32 y\r\nproperty float32 z\r\nproperty uchar red\r\nproperty uchar green\r\nproperty uchar blue\r\nproperty float intensity\r\nelement face 1\r\nproperty list uchar int vertex_indices\r\nend_header\r\n" + ) + .into_bytes(); + let f = |v: f32| if big { v.to_be_bytes() } else { v.to_le_bytes() }; + b.extend(if big { 9.5f64.to_be_bytes() } else { 9.5f64.to_le_bytes() }); + for (i, p) in [[0.0f32, 0.0, 0.0], [1.5, 0.0, 0.0], [0.0, 2.5, -3.0]].iter().enumerate() { + for v in p { + b.extend(f(*v)); + } + b.extend([10 * i as u8, 20, 255]); + b.extend(f(0.5)); + } + b.push(3); + for i in [0i32, 1, 2] { + b.extend(if big { i.to_be_bytes() } else { i.to_le_bytes() }); + } + b +} + +/// A quad with normals, texture coordinates and 16-bit colours with alpha. +const FULL: &str = "ply\nformat ascii 1.0\ncomment the end_header is not here\nelement vertex 4\nproperty double x\nproperty double y\nproperty double z\n\ +property float nx\nproperty float ny\nproperty float nz\nproperty float s\nproperty float t\n\ +property ushort red\nproperty ushort green\nproperty ushort blue\nproperty ushort alpha\n\ +element face 1\nproperty list uchar uint vertex_index\nend_header\n\ +0 0 0 0 0 1 0 0 65535 0 0 65535\n1 0 0 0 0 1 1 0 0 65535 0 65535\n1 1 0 0 0 1 1 1 0 0 65535 0\n0.1 1 0 0 0 1 0 0.25 0 0 0 65535\n4 0 1 2 3\n"; + +#[test] +fn binary_little_and_big_endian_with_colours_and_other_elements() { + for big in [false, true] { + let bytes = binary(big); + let h = parse_header(&bytes).unwrap(); + assert_eq!(h.encoding, if big { Encoding::Big } else { Encoding::Little }); + assert_eq!((h.count("vertex"), h.count("face"), h.count("edge")), (3, 1, 0)); + assert_eq!(h.comments, ["made by hand"]); + let s = parse(&bytes).unwrap(); + s.validate().unwrap(); + let m = &s.meshes[0]; + assert_eq!(m.positions, [[0.0, 0.0, 0.0], [1.5, 0.0, 0.0], [0.0, 2.5, -3.0]]); + assert_eq!(m.colors[1], [10.0 / 255.0, 20.0 / 255.0, 1.0, 1.0]); + assert_eq!(m.parts[0].corners.iter().map(|c| c.position).collect::>(), [0, 1, 2]); + assert!(m.parts[0].face_sizes.is_empty() && m.normals.is_empty() && m.uv_sets.is_empty()); + } +} + +#[test] +fn ascii_with_normals_uvs_colours_and_a_quad() { + let s = parse(FULL.as_bytes()).unwrap(); + s.validate().unwrap(); + let m = &s.meshes[0]; + // Doubles keep their precision. + assert_eq!(m.positions[3], [0.1, 1.0, 0.0]); + assert_eq!(m.normals, vec![[0.0, 0.0, 1.0]; 4]); + // V is flipped to the image's top-down rows. + assert_eq!(m.uv_sets[0], [[0.0, 1.0], [1.0, 1.0], [1.0, 0.0], [0.0, 0.75]]); + // Integer channels are scaled by their type's range. + assert_eq!(m.colors, [[1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]); + // The polygon stays whole, and each corner names its vertex's normal and UV. + assert_eq!(m.parts[0].face_sizes, [4]); + assert!(m.parts[0].corners.iter().all(|c| c.normal == c.position && c.uv == c.position)); + // Float colours are taken as they are; `u`/`v` name texture coordinates too. + let floats = "ply\nformat ascii 1.0\nelement vertex 3\nproperty float x\nproperty float y\nproperty float z\nproperty float red\nproperty float green\nproperty float blue\nproperty float u\nproperty float v\nelement face 1\nproperty list uchar int vertex_indices\nend_header\n0 0 0 1 0 0 0 0\n1 0 0 0 0.5 0 1 0\n0 1 0 0 0 1 0 1\n3 0 1 2\n"; + let s = parse(floats.as_bytes()).unwrap(); + assert_eq!(s.meshes[0].colors[1], [0.0, 0.5, 0.0, 1.0]); + assert_eq!(s.meshes[0].uv_sets[0][2], [0.0, 0.0]); +} + +#[test] +fn point_clouds_and_faces_before_vertices() { + let points = "ply\nformat ascii 1.0\nelement vertex 2\nproperty float32 x\nproperty float32 y\nproperty float32 z\nend_header\n1 2 3\n4 5 6\n"; + let s = parse(points.as_bytes()).unwrap(); + assert_eq!((s.meshes[0].positions.as_slice(), s.meshes[0].parts.len()), (&[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]][..], 0)); + assert!(s.meshes[0].colors.is_empty()); + let face_first = "ply\nformat ascii 1.0\nelement face 1\nproperty list uchar int vertex_indices\nelement vertex 3\nproperty float x\nproperty float y\nproperty float z\nend_header\n3 0 1 2\n0 0 0\n1 0 0\n0 1 0\n"; + let s = parse(face_first.as_bytes()).unwrap(); + assert_eq!((s.meshes[0].triangle_count(), s.meshes[0].positions.len()), (1, 3)); +} + +#[test] +fn faces_that_cannot_be_read_are_left_out() { + let head = "ply\nformat ascii 1.0\nelement vertex 3\nproperty float x\nproperty float y\nproperty float z\nelement face 2\nproperty list uchar int vertex_indices\nend_header\n"; + // A face naming a vertex that is not there. + let s = parse(format!("{head}0 0 0 1 0 0 0 1 0\n3 0 1 2\n3 0 1 99\n").as_bytes()).unwrap(); + assert_eq!((s.meshes[0].triangle_count(), s.warnings.len()), (1, 1)); + // A face naming a vertex whose position is not a number; the position itself is kept. + let s = parse(format!("{head}0 0 0 1 0 0 nan 1 0\n3 0 1 2\n2 0 1\n").as_bytes()).unwrap(); + assert_eq!((s.meshes[0].triangle_count(), s.meshes[0].positions.len()), (0, 3)); + assert_eq!(s.warnings, ["2 face(s) could not be read and were left out"]); + s.validate().unwrap(); +} + +#[test] +fn rows_serve_callers_that_read_more_than_a_mesh() { + // Every scalar property of the vertices, as a point-cloud reader would take them. + let bytes = binary(true); + let h = parse_header(&bytes).unwrap(); + let mut rows = Rows::new(&h, &bytes); + rows.skip_element(&h.elements[0], 16).unwrap(); + let vertex = &h.elements[1]; + rows.check(vertex).unwrap(); + assert_eq!(vertex.row_size(), Some(19)); + assert_eq!(vertex.find(&["Intensity"]), Some((6, Scalar::F32))); + let mut intensity = Vec::new(); + for _ in 0..vertex.count { + for (i, p) in vertex.properties.iter().enumerate() { + let Kind::Scalar(t) = p.kind else { panic!("the vertices have no lists") }; + let v = rows.value(t).unwrap(); + if i == 6 { + intensity.push(v); + } + } + } + assert_eq!(intensity, [0.5; 3]); + assert_eq!(rows.count(Scalar::U8, 16).unwrap(), 3); + assert!(Scalar::I16.is_integer() && !Scalar::F64.is_integer()); + assert_eq!(Scalar::U16.unit(65_535.0), 1.0); +} + +#[test] +fn hostile_ply_is_refused_without_a_panic() { + assert!(is_ply(b"ply\n") && !is_ply(b"plyx") && !is_ply(b"pl")); + let cases: [&[u8]; 14] = [ + b"", + b"ply", + b"ply\nformat ascii 1.0\n", + b"ply\nformat ascii 1.0\nelement vertex 18446744073709551615\nproperty float x\nproperty float y\nproperty float z\nend_header\n1 2 3", + b"ply\nformat binary_little_endian 1.0\nelement vertex 18446744073709551615\nproperty float x\nproperty float y\nproperty float z\nend_header\n1234", + b"ply\nformat binary_little_endian 1.0\nelement vertex 1\nproperty list uchar float x\nproperty float y\nproperty float z\nend_header\n\xff", + b"ply\nformat ascii 1.0\nelement vertex 1\nproperty float x\nend_header\n1", + b"ply\nformat ascii 1.0\nproperty float x\nend_header\n", + b"ply\nformat binary_big_endian 1.0\nelement face 2\nproperty list float int vertex_indices\nend_header\n", + b"ply\nformat utf8 1.0\nelement vertex 0\nend_header\n", + b"ply\nelement vertex 0\nproperty float x\nproperty float y\nproperty float z\nend_header\n", + b"ply\nformat ascii 1.0\nelement vertex 1\nproperty quad x\nend_header\n", + b"ply\nformat ascii 1.0\nelement vertex 1\nproperty float x\nproperty float y\nproperty float z\nend_header trailing\n1 2 3\n", + b"ply\nformat ascii 1.0\nelement vertex 1\nproperty float x\nproperty float y\nproperty float z\nend_header\n1 two 3\n", + ]; + for c in cases { + assert!(parse(c).is_err(), "{:?}", String::from_utf8_lossy(c)); + } + // A cut-off file is refused, wherever it is cut. + let good = binary(false); + for n in 0..good.len() { + assert!(parse(&good[..n]).is_err(), "cut at {n}"); + } + // A header that never ends is not searched past its first megabyte. + let mut endless = b"ply\nformat ascii 1.0\n".to_vec(); + endless.resize((1 << 20) + 64, b'c'); + endless.extend(b"\nend_header\n"); + assert!(parse(&endless).is_err()); + for good in [binary(false), binary(true), FULL.as_bytes().to_vec()] { + hammer(&good, &parse); + } +} + +#[test] +fn limits() { + let too = |r: crate::Result| matches!(r, Err(Error::TooLarge(_))); + let full = FULL.as_bytes(); + assert!(read(full, &Limits { max_vertices: 4, ..Limits::default() }).is_ok()); + assert!(too(read(full, &Limits { max_vertices: 3, ..Limits::default() }))); + assert!(read(full, &Limits { max_triangles: 2, ..Limits::default() }).is_ok()); + assert!(too(read(full, &Limits { max_triangles: 1, ..Limits::default() }))); + assert!(read(full, &Limits { max_face_corners: 4, ..Limits::default() }).is_ok()); + assert!(too(read(full, &Limits { max_face_corners: 3, ..Limits::default() }))); + // The header's own bounds. + let elements = |n: usize| { + format!( + "ply\nformat ascii 1.0\n{}element vertex 0\nproperty float x\nproperty float y\nproperty float z\nend_header\n", + "element e 0\n".repeat(n) + ) + }; + assert!(parse(elements(MAX_ELEMENTS - 1).as_bytes()).is_ok()); + assert!(too(parse(elements(MAX_ELEMENTS).as_bytes()))); + let properties = |n: usize| { + format!( + "ply\nformat ascii 1.0\nelement vertex 0\n{}end_header\n", + "property float x\nproperty float y\nproperty float z\n".repeat(n).lines().take(n).map(|l| format!("{l}\n")).collect::() + ) + }; + assert!(parse(properties(MAX_PROPERTIES).as_bytes()).is_ok()); + assert!(too(parse(properties(MAX_PROPERTIES + 1).as_bytes()))); +} diff --git a/crates/mesh/src/tests/stl.rs b/crates/mesh/src/tests/stl.rs new file mode 100644 index 00000000..1d784e91 --- /dev/null +++ b/crates/mesh/src/tests/stl.rs @@ -0,0 +1,158 @@ +use super::hammer; +use crate::stl::{read, write_ascii, write_binary}; +use crate::{Error, IDENTITY, Limits, Scene}; + +/// A facet: normal, vertices, attribute word. +type Facet = ([f32; 3], [[f32; 3]; 3], u16); + +/// A binary STL: `header` (padded to 80 bytes) and its facets. +fn binary_stl(header: &[u8], facets: &[Facet]) -> Vec { + let mut b = header.to_vec(); + b.resize(80, 0); + b.extend((facets.len() as u32).to_le_bytes()); + for (n, v, attr) in facets { + for c in n.iter().chain(v.iter().flatten()) { + b.extend(c.to_le_bytes()); + } + b.extend(attr.to_le_bytes()); + } + b +} + +/// The unit square in the XY plane, facing +Z, as two facets. +const SQUARE: [[[f32; 3]; 3]; 2] = [[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0]], [[0.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]]]; + +fn parse(bytes: &[u8]) -> crate::Result { + read(bytes, &Limits::default()) +} + +#[test] +fn binary_is_faceted_and_keeps_the_files_axes() { + // A header that begins with `solid`, as many binary exporters write it. + let bytes = binary_stl(b"solid part", &[([0.0, 0.0, 1.0], SQUARE[0], 0), ([0.0, 0.0, 1.0], SQUARE[1], 0)]); + let s = parse(&bytes).unwrap(); + s.validate().unwrap(); + let m = &s.meshes[0]; + assert_eq!(m.name, "part"); + assert!(s.materials.is_empty() && s.warnings.is_empty()); + assert_eq!((m.triangle_count(), m.positions.len(), m.parts.len()), (2, 6, 1)); + assert!(m.uv_sets.is_empty()); + assert_eq!(m.normals, vec![[0.0, 0.0, 1.0]; 2]); + assert_eq!(m.positions[2], [1.0, 1.0, 0.0]); + // One root node places the mesh. + assert_eq!((s.roots.as_slice(), s.nodes[0].mesh, s.nodes[0].transform), (&[0][..], Some(0), IDENTITY)); + // Trailing bytes after the last facet are ignored. + let mut longer = bytes.clone(); + longer.extend([0u8; 7]); + assert_eq!(parse(&longer).unwrap().meshes, s.meshes); +} + +#[test] +fn ascii_with_polygon_loops_and_several_solids() { + let src = "solid Bracket\n facet normal 0 0 1\n outer loop\n vertex 0 0 0\n vertex 1 0 0\n vertex 1 1 0\n vertex 0 1 0\n endloop\n endfacet\nendsolid Bracket\n\ + solid second\nfacet normal 0 0 0\nouter loop\nvertex 0 0 1\nvertex 1 0 1\nvertex 1E0 1 1\nendloop\nendfacet\nendsolid\n"; + let s = parse(src.as_bytes()).unwrap(); + assert_eq!(s.meshes[0].name, "Bracket"); + // The quad fans into two triangles; the second solid adds one. + assert_eq!(s.meshes[0].triangle_count(), 3); + assert!(parse(b"solid x\nfacet normal 0 0 1\nouter loop\nvertex 0 zero 0\n").is_err()); + // A name that is not UTF-8, and a facet with a non-finite vertex, which is left out. + let mut ascii = b"solid caf\xe9\n".to_vec(); + ascii.extend(b"facet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\n"); + ascii.extend(b"facet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex nan 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid\n"); + let s = parse(&ascii).unwrap(); + assert_eq!((s.meshes[0].triangle_count(), s.warnings.len()), (1, 1)); + // ASCII keeps the precision of its text. + let s = parse(b"solid p\nfacet normal 0 0 1\nouter loop\nvertex 0.1 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid\n").unwrap(); + assert_eq!(s.meshes[0].positions[0][0], 0.1f64); +} + +#[test] +fn binary_colours() { + // VisCAM / SolidView: bit 15 set, red in bits 10–14. + let red = 0x8000 | (31 << 10); + let s = parse(&binary_stl(b"", &[([0.0; 3], SQUARE[0], red), ([0.0; 3], SQUARE[1], 0)])).unwrap(); + s.validate().unwrap(); + assert_eq!((s.meshes[0].parts.len(), s.materials.len()), (2, 2)); + assert!(s.materials[0].base_color.iter().zip([1.0, 0.0, 0.0, 1.0]).all(|(x, y)| (x - y).abs() < 1e-5)); + assert_eq!((s.materials[0].name.as_str(), s.materials[0].metallic), ("#FF0000", 0.0)); + assert_eq!(s.meshes[0].parts[1].material, Some(1)); + // Materialise Magics: a part colour in the header; bit 15 clear = the facet's own colour + // with red in bits 0–4. + let mut header = b"COLOR=".to_vec(); + header.extend([0, 255, 0, 255]); + let s = parse(&binary_stl(&header, &[([0.0; 3], SQUARE[0], 31), ([0.0; 3], SQUARE[1], 0x8000)])).unwrap(); + let close = |a: [f32; 4], b: [f32; 4]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5); + assert!(close(s.materials[0].base_color, [1.0, 0.0, 0.0, 1.0]), "{:?}", s.materials[0]); + assert!(close(s.materials[1].base_color, [0.0, 1.0, 0.0, 1.0]), "{:?}", s.materials[1]); +} + +#[test] +fn quirks_and_hostile_input() { + // Claims four billion facets but has one: truncated. + let mut bytes = binary_stl(b"", &[([0.0; 3], SQUARE[0], 0)]); + bytes.splice(80..84, u32::MAX.to_le_bytes()); + assert!(parse(&bytes).is_err()); + // A count left at 0 over a file with facets: they are read, with a warning. + bytes.splice(80..84, 0u32.to_le_bytes()); + let s = parse(&bytes).unwrap(); + assert_eq!((s.meshes[0].triangle_count(), s.warnings.len()), (1, 1)); + assert!(parse(b"").is_err()); + assert!(parse(&[0u8; 83]).is_err()); + assert!(parse(b"solid").is_err()); + assert!(parse(&[0xff; 200]).is_err()); + // No facets, or only non-finite ones. + assert!(parse(&binary_stl(b"", &[])).is_err()); + assert!(parse(&binary_stl(b"", &[([0.0; 3], [[f32::NAN, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], 0)])).is_err()); + assert!(parse(b"solid empty\nendsolid empty\n").is_err()); + // A non-finite facet among good ones is left out, with a warning. + let s = + parse(&binary_stl(b"", &[([0.0; 3], SQUARE[0], 0), ([0.0; 3], [[f32::INFINITY, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], 0)])).unwrap(); + assert_eq!((s.meshes[0].triangle_count(), s.warnings.len()), (1, 1)); + // A degenerate facet keeps its stored normal; a garbage stored normal loses to the winding. + let s = parse(&binary_stl(b"", &[([1.0, 0.0, 0.0], [[0.0; 3]; 3], 0), ([f32::NAN, 9.0, -7.0], SQUARE[0], 0)])).unwrap(); + assert_eq!(s.meshes[0].normals, [[1.0, 0.0, 0.0], [0.0, 0.0, 1.0]]); + hammer(&binary_stl(b"solid x", &[([0.0; 3], SQUARE[0], 0x8000 | 31), ([0.0; 3], SQUARE[1], 0)]), &parse); + hammer(b"solid a\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid a\n", &parse); +} + +#[test] +fn limits() { + let two = binary_stl(b"", &[([0.0; 3], SQUARE[0], 0), ([0.0; 3], SQUARE[1], 0)]); + let ascii = write_ascii(&parse(&two).unwrap(), "sq", &Limits::default()).unwrap(); + for bytes in [two.as_slice(), ascii.as_bytes()] { + assert!(read(bytes, &Limits { max_triangles: 2, ..Limits::default() }).is_ok()); + assert!(matches!(read(bytes, &Limits { max_triangles: 1, ..Limits::default() }), Err(Error::TooLarge(_)))); + assert!(read(bytes, &Limits { max_vertices: 6, ..Limits::default() }).is_ok()); + assert!(matches!(read(bytes, &Limits { max_vertices: 5, ..Limits::default() }), Err(Error::TooLarge(_)))); + } + let quad = "solid q\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 1 1 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid\n"; + assert!(read(quad.as_bytes(), &Limits { max_face_corners: 4, ..Limits::default() }).is_ok()); + assert!(matches!(read(quad.as_bytes(), &Limits { max_face_corners: 3, ..Limits::default() }), Err(Error::TooLarge(_)))); +} + +#[test] +fn written_files_read_back() { + let mut scene = Scene::from_meshes(vec![super::tri_mesh()], vec![]); + // The node's placement is applied: STL has no hierarchy. + scene.nodes[0].transform[12] = 10.0; + let limits = Limits::default(); + let binary = write_binary(&scene, "solid tri", &limits).unwrap(); + assert_eq!(binary.len(), 84 + 2 * 50); + assert!(!binary.starts_with(b"solid"), "a binary header that starts with `solid` confuses readers"); + let ascii = write_ascii(&scene, "tri one", &limits).unwrap(); + assert!(ascii.starts_with("solid trione\n") && ascii.ends_with("endsolid trione\n")); + for bytes in [binary.as_slice(), ascii.as_bytes()] { + let back = parse(bytes).unwrap(); + let m = &back.meshes[0]; + assert_eq!(m.triangle_count(), 2); + assert_eq!(m.positions, [[10.0, 0.0, 0.0], [11.0, 0.0, 0.0], [10.0, 1.0, 0.0], [10.0, 0.0, 0.0], [10.0, 0.0, 1.0], [11.0, 0.0, 0.0]]); + assert_eq!(m.normals, [[0.0, 0.0, 1.0], [0.0, 1.0, 0.0]]); + } + // A scene that cannot be walked or indexed is an error. + let mut bad = scene.clone(); + bad.meshes[0].parts[0].corners[0].position = 99; + assert!(write_binary(&bad, "x", &limits).is_err()); + bad.nodes[0].children = vec![0]; + assert!(write_ascii(&bad, "x", &limits).is_err()); +}