3D models: bound and share decoded textures, share skin matrices, index OBJ materials

* fix(model): share decoded textures and skin matrices, bound texture pixels, index OBJ materials

Texture::decode expanded any image to float RGBA without a size check. Repeated mtllib lines
reparsed the library and decoded its textures again; glTF textures with a non-default sampler
each copied their image's pixels; every instance of a skin built its own joint matrices. OBJ
usemtl and faces scanned the growing material and primitive lists, and contour grouping kept
every ancestor of every contour.

* address review: keep largest contours and dedupe mtllib by set

* address review: contour depth counts containers, glTF wrap copies and FBX textures share the import pixel budget

* address review: holes go to the innermost outline; glTF wrap variants keyed by decoded image

* address review: create every outline slot before attaching holes

* Import: a model's left-out parts reach the user as an import warning (Model::warnings, Importer::probe_noting)

* Models: left-out textures are import warnings; the import texture budget is 2^29 pixels

* Import: a model's left-out parts reach the user as an import warning (Model::warnings, Importer::probe_noting)

* Import: a model's left-out parts reach the user as an import warning

* FBX textures move to #2955 (it reworks the same code)
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-12 07:00:15 -05:00
1 parent b9da77c63d
commit 63fda85a51
5 files changed
+356 -59

No files matched your search

+50 -12
View File
@@ -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<P>>) -> Vec<Outline> {
let cs: Vec<Vec<P>> = contours
let mut cs: Vec<Vec<P>> = contours
.into_iter()
.map(|mut c| {
c.dedup();
@@ -110,26 +114,60 @@ pub fn group_contours(contours: Vec<Vec<P>>) -> Vec<Outline> {
})
.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<f64> = cs.iter().map(|c| signed_area(c).abs()).collect();
let mut keep: Vec<usize> = (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<f64> = 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<Vec<usize>> = (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<usize> {
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<usize> = (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<Option<usize>> = (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<Outline> = vec![];
let mut slot = vec![usize::MAX; n];
let mut order: Vec<usize> = (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());
}
+68 -17
View File
@@ -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<V
// Images → textures (decoded once per image; samplers set the wrap modes).
let mut images: Vec<Option<Arc<Texture>>> = 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<String, Option<Arc<Texture>>> = HashMap::new();
let mut pixels_left = MAX_IMPORT_TEXTURE_PIXELS;
let mut warnings: Vec<String> = 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<V
};
let mut textures: Vec<Arc<Texture>> = vec![];
let mut tex_map: Vec<Option<usize>> = 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<TexRef> {
let v = v?;
@@ -536,7 +587,7 @@ pub(crate) fn parse_with_budget(bytes: &[u8], resolve: &dyn Fn(&str) -> Option<V
.and_then(|e| e.get("KHR_lights_punctual"))
.map(|e| arr(e, "lights").iter().enumerate().map(|(i, l)| light(l, i)).collect())
.unwrap_or_default();
Ok(Model { meshes, nodes, roots, materials, textures, skins, animations, cameras, lights, warnings: Vec::new() })
Ok(Model { meshes, nodes, roots, materials, textures, skins, animations, cameras, lights, warnings })
}
fn name_or(v: &Value, d: String) -> String {
+41 -10
View File
@@ -46,7 +46,7 @@ pub(crate) fn perr(s: impl Into<String>) -> 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<Texture> {
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<Texture> {
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<Vec<Mat4>>,
/// Skinned meshes: joint matrices (model space, `joint_world × inverse_bind`), shared by
/// the instances of one skin.
pub joints: Option<Arc<Vec<Mat4>>>,
}
impl Model {
@@ -629,6 +651,8 @@ impl Model {
let mut seen = vec![false; self.nodes.len()];
let mut stack: Vec<usize> = 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<usize, Arc<Vec<Mat4>>> = 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).
+46 -20
View File
@@ -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<Vec<u8>>, 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<Vec<u8>>, textures: &mut Vec<Arc<Texture>>) -> Vec<Material> {
/// The textures an OBJ import has decoded, by reference, and the pixels it may still decode.
pub struct TextureCache {
loaded: HashMap<String, Option<usize>>,
pixels_left: u64,
/// Textures left out, for the user.
pub warnings: Vec<String>,
}
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<Vec<u8>>, textures: &mut Vec<Arc<Texture>>, cache: &mut TextureCache) -> Vec<Material> {
let mut out: Vec<Material> = vec![];
let mut loaded: HashMap<String, Option<usize>> = HashMap::new();
let TextureCache { loaded, pixels_left, warnings } = cache;
let mut tex = |name: &str, textures: &mut Vec<Arc<Texture>>| -> Option<TexRef> {
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<Vec<u8>>) -> Result<
let mut vt: Vec<[f32; 2]> = vec![];
let mut vn: Vec<[f32; 3]> = vec![];
let mut materials: Vec<Material> = vec![];
let mut material_of: HashMap<String, usize> = HashMap::new();
let mut textures: Vec<Arc<Texture>> = vec![];
let mut cache = TextureCache::default();
let mut libraries: HashSet<String> = HashSet::new();
// One primitive per material, in order of first use.
let mut prims: Vec<(Option<usize>, Primitive, HashMap<Key, u32>, Vec<bool>)> = vec![];
let mut prim_of: HashMap<Option<usize>, usize> = HashMap::new();
let mut current: Option<usize> = 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<Vec<u8>>) -> 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<Vec<u8>>) -> 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<Vec<u8>>) -> 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<Vec<u8>>) -> 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 {
+151
View File
@@ -432,6 +432,58 @@ fn contours_group_into_outlines_by_nesting() {
assert_eq!(g.iter().map(|o| o.holes.len()).sum::<usize>(), 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::<usize>(), 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::<Vec<_>>(), [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::<Vec<_>>(), ["A", "B"]);
assert_eq!(m.textures.len(), 1);
assert_eq!(m.meshes[0].primitives.iter().map(|p| (p.material, p.triangle_count())).collect::<Vec<_>>(), [(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<Vec<[f64; 2]>> = (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}");
}