diff --git a/crates/model/src/extrude.rs b/crates/model/src/extrude.rs index 114606b95..71d550367 100644 --- a/crates/model/src/extrude.rs +++ b/crates/model/src/extrude.rs @@ -96,10 +96,14 @@ fn point_in(p: P, c: &[P]) -> bool { inside } +/// Most contours grouped into outlines; containment is tested pairwise, so the smallest are dropped. +pub const MAX_CONTOURS: usize = 10_000; + /// Group closed contours into outlines by nesting depth (even depth = outer, odd = hole of the -/// innermost containing outer contour). Degenerate contours are dropped. +/// innermost containing outer contour). Degenerate contours are dropped, as are the smallest +/// past [`MAX_CONTOURS`] (a kept hole always keeps its larger container). pub fn group_contours(contours: Vec>) -> Vec { - let cs: Vec> = contours + let mut cs: Vec> = contours .into_iter() .map(|mut c| { c.dedup(); @@ -110,26 +114,60 @@ pub fn group_contours(contours: Vec>) -> Vec { }) .filter(|c| c.len() >= 3 && signed_area(c).abs() > 1e-9) .collect(); + if cs.len() > MAX_CONTOURS { + log::warn!("model: {} contours, grouping the {MAX_CONTOURS} largest", cs.len()); + // Keep the largest, in their original order. + let area: Vec = cs.iter().map(|c| signed_area(c).abs()).collect(); + let mut keep: Vec = (0..cs.len()).collect(); + keep.sort_by(|&a, &b| area[b].total_cmp(&area[a])); + keep.truncate(MAX_CONTOURS); + keep.sort_unstable(); + cs = keep.into_iter().map(|i| std::mem::take(&mut cs[i])).collect(); + } let n = cs.len(); let area: Vec = cs.iter().map(|c| signed_area(c).abs()).collect(); - // Containers of each contour (by a vertex inside; nested contours do not cross). - let parents: Vec> = (0..n) + let bounds: Vec<[P; 2]> = cs + .iter() + .map(|c| c.iter().fold([[f64::MAX; 2], [f64::MIN; 2]], |[lo, hi], p| [[lo[0].min(p[0]), lo[1].min(p[1])], [hi[0].max(p[0]), hi[1].max(p[1])]])) + .collect(); + // The containers of each contour: the larger contours holding a vertex of it (nested contours + // do not cross); the bounding box must hold the vertex first. Depth is their count, so + // overlapping (non-nested) paths keep every container they sit inside. + let containers = |i: usize, only: &dyn Fn(usize) -> bool| -> Vec { + let p = cs[i][0]; + (0..n) + .filter(|&j| j != i && area[j] > area[i] && only(j)) + .filter(|&j| (bounds[j][0][0]..=bounds[j][1][0]).contains(&p[0]) && (bounds[j][0][1]..=bounds[j][1][1]).contains(&p[1])) + .filter(|&j| point_in(p, &cs[j])) + .collect() + }; + let depth: Vec = (0..n).map(|i| containers(i, &|_| true).len()).collect(); + // A hole belongs to the innermost containing contour that is itself an outline: with + // overlapping paths the smallest container can be a hole, which has no outline to take it. + let parent: Vec> = (0..n) .map(|i| { - let probe = cs[i][0]; - (0..n).filter(|&j| j != i && area[j] > area[i] && point_in(probe, &cs[j])).collect() + if depth[i].is_multiple_of(2) { + None + } else { + containers(i, &|j| depth[j].is_multiple_of(2)).into_iter().min_by(|&a, &b| area[a].total_cmp(&area[b])) + } }) .collect(); let mut outlines: Vec = vec![]; let mut slot = vec![usize::MAX; n]; let mut order: Vec = (0..n).collect(); - order.sort_by_key(|&i| parents[i].len()); - for i in order { - let depth = parents[i].len(); - if depth.is_multiple_of(2) { + order.sort_by_key(|&i| depth[i]); + // Every outline gets its slot before any hole is attached: with overlapping paths a hole's + // parent can be deeper than the hole itself. + for &i in &order { + if depth[i].is_multiple_of(2) { slot[i] = outlines.len(); outlines.push(Outline { outer: cs[i].clone(), holes: vec![] }); - } else if let Some(&p) = parents[i].iter().filter(|&&j| parents[j].len() == depth - 1).min_by(|&&a, &&b| area[a].total_cmp(&area[b])) - && slot[p] != usize::MAX + } + } + for &i in &order { + if !depth[i].is_multiple_of(2) + && let Some(p) = parent[i] { outlines[slot[p]].holes.push(cs[i].clone()); } diff --git a/crates/model/src/gltf.rs b/crates/model/src/gltf.rs index 79dab1ddc..d55c39455 100644 --- a/crates/model/src/gltf.rs +++ b/crates/model/src/gltf.rs @@ -11,12 +11,14 @@ //! `KHR_lights_punctual` lights (directional, point, spot). Sparse accessors and morph targets //! are ignored. +use std::collections::HashMap; use std::{cell::Cell, sync::Arc}; use serde_json::Value; use crate::{ - AlphaMode, Animation, Channel, ChannelPath, Interp, Mat4, Material, Mesh, Model, ModelError, Node, Primitive, Result, Skin, TexRef, Texture, Wrap, perr, + AlphaMode, Animation, Channel, ChannelPath, Interp, MAX_IMPORT_TEXTURE_PIXELS, Mat4, Material, Mesh, Model, ModelError, Node, Primitive, Result, Skin, + TexRef, Texture, Wrap, perr, }; const GLB_MAGIC: u32 = 0x4654_6C67; @@ -352,19 +354,41 @@ pub(crate) fn parse_with_budget(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>> = vec![]; - for im in arr(&j, "images") { - let data = if let Some(u) = im.get("uri").and_then(Value::as_str) { - load_uri(u, resolve).ok() - } else if let Some(bv) = idx(im, "bufferView") { - arr(&j, "bufferViews").get(bv).and_then(|v| { - let b = d.buffers.get(idx(v, "buffer").unwrap_or(0))?; - let o = idx(v, "byteOffset").unwrap_or(0); - b.get(o..o.checked_add(idx(v, "byteLength").unwrap_or(0))?).map(<[u8]>::to_vec) - }) - } else { - None + // Images naming one source decode once, and all of them within one pixel budget. + let mut image_of: HashMap>> = HashMap::new(); + let mut pixels_left = MAX_IMPORT_TEXTURE_PIXELS; + let mut warnings: Vec = vec![]; + for (n, im) in arr(&j, "images").iter().enumerate() { + let (uri, view) = (im.get("uri").and_then(Value::as_str), idx(im, "bufferView")); + let key = match (uri, view) { + (Some(u), _) => format!("uri:{u}"), + (None, Some(bv)) => format!("bufferView:{bv}"), + (None, None) => { + images.push(None); + continue; + } }; - images.push(data.and_then(|b| Texture::decode(&b).ok()).map(Arc::new)); + let image = image_of.entry(key).or_insert_with(|| { + let data = if let Some(u) = uri { + load_uri(u, resolve).ok() + } else { + arr(&j, "bufferViews").get(view.unwrap_or(usize::MAX)).and_then(|v| { + let b = d.buffers.get(idx(v, "buffer").unwrap_or(0))?; + let o = idx(v, "byteOffset").unwrap_or(0); + b.get(o..o.checked_add(idx(v, "byteLength").unwrap_or(0))?).map(<[u8]>::to_vec) + }) + }; + match data.map(|b| Texture::decode_within(&b, &mut pixels_left)) { + Some(Ok(t)) => Some(Arc::new(t)), + Some(Err(e)) => { + log::warn!("model: glTF image {n} left out: {e}"); + warnings.push(format!("image {n} left out: {e}")); + None + } + None => None, + } + }); + images.push(image.clone()); } let wrap = |v: u64| match v { 33071 => Wrap::Clamp, @@ -373,18 +397,45 @@ pub(crate) fn parse_with_budget(bytes: &[u8], resolve: &dyn Fn(&str) -> Option> = vec![]; let mut tex_map: Vec> = vec![]; + // One texture per decoded image and wrapping: repeated references share it instead of copying + // the pixels. The image is keyed by its allocation, so image entries naming the same file + // (which share one decode) also share their wrap variants. + let mut texture_of: HashMap<(usize, [Wrap; 2]), usize> = HashMap::new(); for t in arr(&j, "textures") { let Some(img) = idx(t, "source").and_then(|s| images.get(s).cloned().flatten()) else { tex_map.push(None); continue; }; + let src = Arc::as_ptr(&img) as usize; let samp = idx(t, "sampler").and_then(|s| arr(&j, "samplers").get(s)); let ws = samp.and_then(|s| s.get("wrapS")).and_then(Value::as_u64).unwrap_or(10497); let wt = samp.and_then(|s| s.get("wrapT")).and_then(Value::as_u64).unwrap_or(10497); let w = [wrap(ws), wrap(wt)]; - let tex = if img.wrap == w { img } else { Arc::new(Texture { wrap: w, ..(*img).clone() }) }; - tex_map.push(Some(textures.len())); - textures.push(tex); + let i = match texture_of.get(&(src, w)) { + Some(&i) => i, + None => { + let tex = if img.wrap == w { + img + } else { + // A wrap variant copies the pixels, so it is charged to the import budget too; + // one that does not fit is left out like an undecodable image. + let cost = u64::from(img.width) * u64::from(img.height); + if cost > pixels_left { + warnings.push(format!( + "a texture with another wrap mode left out: the model's textures would hold more than {MAX_IMPORT_TEXTURE_PIXELS} pixels" + )); + tex_map.push(None); + continue; + } + pixels_left -= cost; + Arc::new(Texture { wrap: w, ..(*img).clone() }) + }; + textures.push(tex); + texture_of.insert((src, w), textures.len() - 1); + textures.len() - 1 + } + }; + tex_map.push(Some(i)); } let tref = |v: Option<&Value>| -> Option { let v = v?; @@ -536,7 +587,7 @@ pub(crate) fn parse_with_budget(bytes: &[u8], resolve: &dyn Fn(&str) -> Option String { diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 2a7374a05..b95a9f487 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -46,7 +46,7 @@ pub(crate) fn perr(s: impl Into) -> ModelError { } /// Texture coordinate wrapping (glTF sampler wrap modes). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] pub enum Wrap { #[default] Repeat, @@ -64,6 +64,12 @@ pub struct Texture { pub wrap: [Wrap; 2], } +/// Most pixels one texture may hold (8192 × 8192: 1 GiB as float RGBA). +pub const MAX_TEXTURE_PIXELS: u64 = 1 << 26; +/// Most pixels the textures of one import may hold together (8 GiB as float RGBA: thirty-two 4K +/// maps). A texture past it is left out and the import says so (`Model::warnings`). +pub const MAX_IMPORT_TEXTURE_PIXELS: u64 = 1 << 29; + impl Texture { pub fn solid(c: [f32; 4]) -> Texture { Texture { width: 1, height: 1, data: vec![c], wrap: [Wrap::Repeat; 2] } @@ -75,8 +81,23 @@ impl Texture { } /// Decode a PNG or JPEG. pub fn decode(bytes: &[u8]) -> Result { - let img = image::load_from_memory(bytes).map_err(|e| perr(format!("image: {e}")))?.to_rgba32f(); + Self::decode_within(bytes, &mut MAX_IMPORT_TEXTURE_PIXELS.clone()) + } + /// [`decode`](Self::decode), of at most [`MAX_TEXTURE_PIXELS`], taking the pixels from `pixels_left`, an import's remaining + /// texture budget, before the image is decoded. + pub fn decode_within(bytes: &[u8], pixels_left: &mut u64) -> Result { + let err = |e: image::ImageError| perr(format!("image: {e}")); + let (w, h) = Self::dimensions(bytes)?; + let pixels = w as u64 * h as u64; + if pixels > MAX_TEXTURE_PIXELS { + return Err(ModelError::Unsupported(format!("a {w}×{h} texture (at most {MAX_TEXTURE_PIXELS} pixels)"))); + } + if pixels > *pixels_left { + return Err(ModelError::Unsupported(format!("a {w}×{h} texture: the model's textures would hold more than {MAX_IMPORT_TEXTURE_PIXELS} pixels"))); + } + let img = image::load_from_memory(bytes).map_err(err)?.to_rgba32f(); let (w, h) = (img.width(), img.height()); + *pixels_left -= pixels; let data = img.pixels().map(|p| p.0).collect(); Ok(Texture { width: w, height: h, data, wrap: [Wrap::Repeat; 2] }) } @@ -577,8 +598,9 @@ pub struct Instance<'m> { pub mesh: &'m Mesh, /// Node → model space. pub world: Mat4, - /// Skinned meshes: joint matrices (model space, `joint_world × inverse_bind`). - pub joints: Option>, + /// Skinned meshes: joint matrices (model space, `joint_world × inverse_bind`), shared by + /// the instances of one skin. + pub joints: Option>>, } impl Model { @@ -629,6 +651,8 @@ impl Model { let mut seen = vec![false; self.nodes.len()]; let mut stack: Vec = self.roots.clone(); let mut out = vec![]; + // The pose is one for the whole scene, so every instance of a skin shares its matrices. + let mut joints_of: std::collections::HashMap>> = std::collections::HashMap::new(); while let Some(i) = stack.pop() { if i >= self.nodes.len() || seen[i] { continue; @@ -636,12 +660,19 @@ impl Model { seen[i] = true; let n = &self.nodes[i]; if let Some(m) = n.mesh.and_then(|m| self.meshes.get(m)) { - let joints = n.skin.and_then(|s| self.skins.get(s)).map(|s| { - s.joints - .iter() - .enumerate() - .map(|(k, &j)| world.get(j).copied().unwrap_or(Mat4::IDENTITY) * s.inverse_bind.get(k).copied().unwrap_or(Mat4::IDENTITY)) - .collect() + let joints = n.skin.and_then(|si| self.skins.get(si).map(|s| (si, s))).map(|(si, s)| { + joints_of + .entry(si) + .or_insert_with(|| { + Arc::new( + s.joints + .iter() + .enumerate() + .map(|(k, &j)| world.get(j).copied().unwrap_or(Mat4::IDENTITY) * s.inverse_bind.get(k).copied().unwrap_or(Mat4::IDENTITY)) + .collect(), + ) + }) + .clone() }); // Skinned vertices are placed by their joints alone (glTF: the node transform of // a skinned mesh is ignored). diff --git a/crates/model/src/obj.rs b/crates/model/src/obj.rs index 22d9877a2..c9d364fc7 100644 --- a/crates/model/src/obj.rs +++ b/crates/model/src/obj.rs @@ -9,10 +9,10 @@ //! Phong exponent `Ns` (`sqrt(2 / (Ns + 2))`) and metallic 0; `map_Kd`, `map_Ke`, //! `map_Bump`/`bump`/`norm` load base colour, emissive and normal textures. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; -use crate::{AlphaMode, Material, Mesh, Model, Primitive, Result, TexRef, Texture, perr}; +use crate::{AlphaMode, MAX_IMPORT_TEXTURE_PIXELS, Material, Mesh, Model, Primitive, Result, TexRef, Texture, perr}; /// 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 finds `checker.png` next to the @@ -39,24 +39,41 @@ fn resolve_reference(resolve: &dyn Fn(&str) -> Option>, name: &str) -> O None } -/// Parse MTL text into named materials (textures appended to `textures`). -pub fn parse_mtl(text: &str, resolve: &dyn Fn(&str) -> Option>, textures: &mut Vec>) -> Vec { +/// The textures an OBJ import has decoded, by reference, and the pixels it may still decode. +pub struct TextureCache { + loaded: HashMap>, + pixels_left: u64, + /// Textures left out, for the user. + pub warnings: Vec, +} + +impl Default for TextureCache { + fn default() -> Self { + TextureCache { loaded: HashMap::new(), pixels_left: MAX_IMPORT_TEXTURE_PIXELS, warnings: vec![] } + } +} + +/// Parse MTL text into named materials (textures appended to `textures`, decoded once per +/// reference through `cache`). +pub fn parse_mtl(text: &str, resolve: &dyn Fn(&str) -> Option>, textures: &mut Vec>, cache: &mut TextureCache) -> Vec { let mut out: Vec = vec![]; - let mut loaded: HashMap> = HashMap::new(); + let TextureCache { loaded, pixels_left, warnings } = cache; let mut tex = |name: &str, textures: &mut Vec>| -> Option { let key = name.trim().to_string(); let t = *loaded.entry(key.clone()).or_insert_with(|| { let Some(b) = resolve_reference(resolve, &key) else { log::warn!("model: texture `{key}` was not found next to the model"); + warnings.push(format!("texture `{key}` was not found next to the model")); return None; }; - match Texture::decode(&b) { + match Texture::decode_within(&b, pixels_left) { Ok(t) => { textures.push(Arc::new(t)); Some(textures.len() - 1) } Err(e) => { log::warn!("model: cannot decode texture `{key}`: {e}"); + warnings.push(format!("texture `{key}` left out: {e}")); None } } @@ -151,9 +168,13 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< let mut vt: Vec<[f32; 2]> = vec![]; let mut vn: Vec<[f32; 3]> = vec![]; let mut materials: Vec = vec![]; + let mut material_of: HashMap = HashMap::new(); let mut textures: Vec> = vec![]; + let mut cache = TextureCache::default(); + let mut libraries: HashSet = HashSet::new(); // One primitive per material, in order of first use. let mut prims: Vec<(Option, Primitive, HashMap, Vec)> = vec![]; + let mut prim_of: HashMap, usize> = HashMap::new(); let mut current: Option = None; let mut smooth = 0u32; let mut flat_id = 1u32 << 31; @@ -181,11 +202,18 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< } "mtllib" => { let name = line["mtllib".len()..].trim(); + // A library named again is already loaded. + if !libraries.insert(name.to_string()) { + continue; + } let mut found = false; for file in std::iter::once(name).chain(name.split_whitespace()).chain(sibling_name(name)) { if let Some(b) = resolve(file) { - let mut ms = parse_mtl(&String::from_utf8_lossy(&b), resolve, &mut textures); - materials.append(&mut ms); + let ms = parse_mtl(&String::from_utf8_lossy(&b), resolve, &mut textures, &mut cache); + for m in ms { + material_of.entry(m.name.clone()).or_insert(materials.len()); + materials.push(m); + } found = true; break; } @@ -196,12 +224,11 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< } "usemtl" => { let name = line["usemtl".len()..].trim(); - current = materials.iter().position(|m| m.name == name); - if current.is_none() { + current = Some(*material_of.entry(name.to_string()).or_insert_with(|| { // Unknown material: a named grey default. materials.push(Material { name: name.to_string(), metallic: 0.0, roughness: 0.5, base_color: [0.8, 0.8, 0.8, 1.0], ..Material::default() }); - current = Some(materials.len() - 1); - } + materials.len() - 1 + })); } "f" => { if rest.len() < 3 { @@ -234,13 +261,10 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< } else { smooth }; - let slot = match prims.iter().position(|p| p.0 == current) { - Some(i) => i, - None => { - prims.push((current, Primitive { material: current, ..Default::default() }, HashMap::new(), vec![])); - prims.len() - 1 - } - }; + let slot = *prim_of.entry(current).or_insert_with(|| { + prims.push((current, Primitive { material: current, ..Default::default() }, HashMap::new(), vec![])); + prims.len() - 1 + }); let (_, prim, map, need) = &mut prims[slot]; let mut ids = vec![]; for (vi, ti, ni) in refs { @@ -290,7 +314,9 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< } mesh.primitives.push(p); } - Ok(Model::single(mesh, materials).with_textures(textures)) + let mut model = Model::single(mesh, materials).with_textures(textures); + model.warnings = cache.warnings; + Ok(model) } impl Model { diff --git a/crates/model/src/tests.rs b/crates/model/src/tests.rs index 5ee6a6950..fd3576afe 100644 --- a/crates/model/src/tests.rs +++ b/crates/model/src/tests.rs @@ -432,6 +432,58 @@ fn contours_group_into_outlines_by_nesting() { assert_eq!(g.iter().map(|o| o.holes.len()).sum::(), 2); } +#[test] +fn overlapping_contours_keep_every_container_in_their_depth() { + let rect = |x0: f64, y0: f64, x1: f64, y1: f64| vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1]]; + // C's first vertex lies inside both A and B, which overlap without nesting: C is two deep, + // an outer contour, not a hole cut out of B. + let g = group_contours(vec![rect(0.0, 0.0, 10.0, 10.0), rect(5.0, -2.0, 13.0, 8.0), rect(9.0, 1.0, 12.0, 2.0)]); + assert_eq!(g.len(), 3); + assert_eq!(g.iter().map(|o| o.holes.len()).sum::(), 0); +} + +#[test] +fn a_contour_inside_a_hole_takes_the_innermost_outline_not_the_hole() { + let rect = |x0: f64, y0: f64, x1: f64, y1: f64| vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1]]; + // A and B overlap; C lies in A only (a hole of A); D lies in A, B and C (three deep, a hole). + // The smallest container of D is the hole C, which has no outline of its own: D goes to B. + let g = group_contours(vec![rect(0.0, 0.0, 10.0, 10.0), rect(5.0, -2.0, 13.0, 8.0), rect(1.0, 1.0, 9.0, 9.0), rect(6.0, 2.0, 7.0, 3.0)]); + assert_eq!(g.len(), 2); + assert_eq!(g.iter().map(|o| o.holes.len()).collect::>(), [1, 1], "C is a hole of A, D a hole of B (not dropped)"); +} + +#[test] +fn a_hole_whose_outline_is_deeper_than_it_is_still_attached() { + let rect = |x0: f64, y0: f64, x1: f64, y1: f64| vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1]]; + // D is three deep and its innermost outline A four deep: A's slot must exist before D is attached. + let a = rect(0.0, 0.0, 10.0, 10.0); + let g = group_contours(vec![ + a.clone(), + rect(-1.0, -1.0, 20.0, 20.0), + rect(-2.0, -2.0, 25.0, 25.0), + rect(5.0, 5.0, 6.0, 6.0), + rect(-100.0, -100.0, 3.0, 3.0), + rect(-0.5, -1000.0, 0.5, 1000.0), + ]); + let o = g.iter().find(|o| o.outer == a).expect("A is an outline"); + assert_eq!(o.holes.len(), 1, "D is a hole of A"); +} + +#[test] +fn gltf_images_naming_one_file_share_their_wrap_variants() { + let png = { + let img = image::RgbaImage::from_fn(2, 2, |x, y| if (x + y) % 2 == 0 { image::Rgba([255, 0, 0, 255]) } else { image::Rgba([0, 0, 255, 255]) }); + let mut out = std::io::Cursor::new(Vec::new()); + img.write_to(&mut out, image::ImageFormat::Png).unwrap(); + out.into_inner() + }; + // Two image entries with one URI share a decode; two textures clamp it, so they share one copy. + let doc = br#"{"asset":{"version":"2.0"},"images":[{"uri":"t.png"},{"uri":"t.png"}], + "samplers":[{"wrapS":33071,"wrapT":33071}],"textures":[{"source":0,"sampler":0},{"source":1,"sampler":0}]}"#; + let m = gltf::parse(doc, &|u| (u == "t.png").then(|| png.clone())).unwrap(); + assert_eq!(m.textures.len(), 1, "one clamped copy for both textures"); +} + #[test] fn gltf_cameras_and_punctual_lights() { let doc = r#"{ @@ -731,3 +783,102 @@ fn gltf_strip_expansion_is_charged_to_the_read_budget() { let e = crate::gltf::parse_with_budget(doc.as_bytes(), &|_| None, 5000).err().unwrap_or_else(|| panic!("imported")); assert!(e.to_string().contains("more than 5000 components"), "{e}"); } + +#[test] +fn texture_decoding_is_charged_to_the_import_budget() { + let png = { + let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([0, 255, 0, 255])); + let mut b = std::io::Cursor::new(vec![]); + img.write_to(&mut b, image::ImageFormat::Png).unwrap(); + b.into_inner() + }; + let mut left = 4; + assert_eq!(Texture::decode_within(&png, &mut left).unwrap().data.len(), 4); + assert_eq!(left, 0); + assert!(matches!(Texture::decode_within(&png, &mut left), Err(ModelError::Unsupported(_)))); +} + +#[test] +fn obj_repeated_material_libraries_and_textures_load_once() { + let png = { + let img = image::RgbaImage::from_pixel(1, 1, image::Rgba([0, 255, 0, 255])); + let mut b = std::io::Cursor::new(vec![]); + img.write_to(&mut b, image::ImageFormat::Png).unwrap(); + b.into_inner() + }; + let mtl = b"newmtl A\nmap_Kd t.png\nnewmtl B\nmap_Kd t.png\n".to_vec(); + let src = b"mtllib m.mtl\nmtllib m.mtl\nv 0 0 0\nv 1 0 0\nv 0 1 0\nusemtl A\nf 1 2 3\nusemtl B\nf 1 2 3\nusemtl A\nf 1 2 3\n"; + let m = obj::parse(src, &|name| match name { + "m.mtl" => Some(mtl.clone()), + "t.png" => Some(png.clone()), + _ => None, + }) + .unwrap(); + assert_eq!(m.materials.iter().map(|m| m.name.as_str()).collect::>(), ["A", "B"]); + assert_eq!(m.textures.len(), 1); + assert_eq!(m.meshes[0].primitives.iter().map(|p| (p.material, p.triangle_count())).collect::>(), [(Some(0), 2), (Some(1), 1)]); +} + +#[test] +fn gltf_textures_naming_one_image_share_it() { + let doc = String::from_utf8(fixture("quad.gltf")).unwrap(); + // Three textures of one image: the fixture's sampler, the same sampler again, no sampler. + let doc = doc.replace( + "\"textures\": [\n {\n \"source\": 0,\n \"sampler\": 0\n }\n ]", + "\"textures\": [{\"source\": 0, \"sampler\": 0}, {\"source\": 0, \"sampler\": 0}, {\"source\": 0}]", + ); + let m = gltf::parse(doc.as_bytes(), &|_| None).unwrap(); + assert_eq!(m.textures.len(), 2); + assert_eq!(m.textures[0].wrap, [Wrap::Clamp, Wrap::Repeat]); + assert_eq!(m.textures[1].wrap, [Wrap::Repeat, Wrap::Repeat]); +} + +#[test] +fn instances_of_one_skin_share_its_joint_matrices() { + let p = mesh(PrimitiveKind::Plane, &PrimParams { size: [2.0, 2.0, 0.0], ..Default::default() }); + let mut m = Model::single(Mesh { name: "s".into(), primitives: vec![p] }, vec![Material::default()]); + m.nodes[0].skin = Some(0); + m.nodes.push(Node { mesh: Some(0), skin: Some(0), ..Node::default() }); + m.roots = vec![0, 1]; + m.skins.push(Skin { joints: vec![0], inverse_bind: vec![Mat4::IDENTITY] }); + let inst = m.instances(None, 0.0); + assert_eq!(inst.len(), 2); + assert!(Arc::ptr_eq(inst[0].joints.as_ref().unwrap(), inst[1].joints.as_ref().unwrap())); +} + +#[test] +fn contour_grouping_is_bounded() { + let sq = |x: f64, y: f64, s: f64| vec![[x, y], [x + s, y], [x + s, y + s], [x, y + s]]; + let n = extrude::MAX_CONTOURS + 1; + let cs: Vec> = (0..n).map(|i| sq(i as f64 * 2.0, 0.0, 1.0)).collect(); + let g = group_contours(cs); + assert_eq!(g.len(), extrude::MAX_CONTOURS); + assert!(g.iter().all(|o| o.holes.is_empty())); +} + +/// A texture the import leaves out (too large, or not an image) is named in `Model::warnings`, +/// which the import shows the user; the model still loads. +#[test] +fn left_out_textures_are_reported_as_import_warnings() { + // A 24-bit BMP header claiming 9000×9000 (81 MP, over one texture's limit) over no pixel data. + let mut big = b"BM".to_vec(); + big.extend_from_slice(&[0; 8]); + big.extend_from_slice(&54u32.to_le_bytes()); + big.extend_from_slice(&40u32.to_le_bytes()); + big.extend_from_slice(&9000u32.to_le_bytes()); + big.extend_from_slice(&9000u32.to_le_bytes()); + big.extend_from_slice(&[1, 0, 24, 0]); + big.extend_from_slice(&[0; 24]); + let mtl = b"newmtl A\nmap_Kd big.bmp\nnewmtl B\nmap_Kd junk.png\nnewmtl C\nmap_Kd gone.png\n".to_vec(); + let src = b"mtllib m.mtl\nv 0 0 0\nv 1 0 0\nv 0 1 0\nusemtl A\nf 1 2 3\n"; + let m = obj::parse(src, &|name| match name { + "m.mtl" => Some(mtl.clone()), + "big.bmp" => Some(big.clone()), + "junk.png" => Some(b"not an image".to_vec()), + _ => None, + }) + .unwrap(); + assert!(m.textures.is_empty()); + let all = m.warnings.join("|"); + assert!(all.contains("big.bmp") && all.contains("junk.png") && all.contains("gone.png"), "{all}"); +}