mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-12 21:32:52 +00:00
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:
1 parent
b9da77c63d
commit
63fda85a51
5 files changed
+356
-59
No files matched your search
+50
-12
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
Reference in new issue
Block a user