diff --git a/crates/mesh/src/obj.rs b/crates/mesh/src/obj.rs index d47151e3..1ceba5df 100644 --- a/crates/mesh/src/obj.rs +++ b/crates/mesh/src/obj.rs @@ -14,7 +14,7 @@ //! `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::collections::{HashMap, HashSet}; use std::fmt::Write as _; use crate::{ @@ -32,10 +32,42 @@ pub fn sibling_name(name: &str) -> Option<&str> { (!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 } +/// The image a `map_*` line names: the rest of the line after its options +/// (`-bm 1 -o 0.5 0.5 my file.png`). Options are passed over by their argument counts, so a +/// name with spaces is one name; after an option this reader does not know, the name is the +/// final token. +fn map_name(args: &str) -> &str { + fn split(s: &str) -> (&str, &str) { + s.split_once(char::is_whitespace).map_or((s, ""), |(a, b)| (a, b.trim_start())) + } + let mut rest = args.trim(); + loop { + let (token, after) = split(rest); + if after.is_empty() { + break; + } + // How many arguments the option takes, and from which one they may be left out: + // `-o`, `-s` and `-t` take one to three numbers. + let (count, optional_from) = match token { + "-blendu" | "-blendv" | "-bm" | "-boost" | "-cc" | "-clamp" | "-imfchan" | "-texres" | "-type" => (1, 1), + "-mm" => (2, 2), + "-o" | "-s" | "-t" => (3, 1), + t if t.strip_prefix('-').is_some_and(|o| o.starts_with(|c: char| c.is_ascii_alphabetic())) => { + return args.split_whitespace().last().unwrap_or(args); + } + _ => break, + }; + rest = after; + for i in 0..count { + let (arg, after) = split(rest); + // The last token is always the name. + if after.is_empty() || (i >= optional_from && arg.parse::().is_err()) { + break; + } + rest = after; + } + } + rest.trim_end() } /// Materials of an MTL library and the images they name. `TextureRef::image` indexes the @@ -144,6 +176,8 @@ struct Reader<'a> { objects: Vec, scene: Scene, material_ids: HashMap, + /// The `mtllib` names already read: exporters repeat the line for every object. + libraries: HashSet, material: Option, group: String, smoothing: u32, @@ -218,7 +252,9 @@ impl Reader<'_> { } fn library(&mut self, name: &str, resolve: Resolver) -> Result<()> { - if name.is_empty() { + // A library named again is already read: reading it again would add its materials and + // images a second time and count its bytes against the limit once per line. + if name.is_empty() || !self.libraries.insert(name.to_string()) { return Ok(()); } if self.scene.external.len() >= self.limits.max_materials { @@ -412,6 +448,7 @@ pub fn read(bytes: &[u8], limits: &Limits, resolve: Resolver) -> Result { objects: vec![], scene: Scene::default(), material_ids: HashMap::new(), + libraries: HashSet::new(), material: None, group: String::new(), smoothing: 0, diff --git a/crates/mesh/src/tests/obj.rs b/crates/mesh/src/tests/obj.rs index 512ffd4e..530073f6 100644 --- a/crates/mesh/src/tests/obj.rs +++ b/crates/mesh/src/tests/obj.rs @@ -281,3 +281,39 @@ fn writer_places_meshes_keeps_colours_and_names_materials_apart() { scene.meshes[0].parts[0].corners[0].position = 9; assert!(write(&scene, None, &Limits::default()).is_err()); } + +#[test] +fn map_options_are_passed_over_by_their_argument_counts() { + // `-o` takes one to three numbers, so the name after it may hold spaces; the final token alone + // would be `checker.png`. + let mtl = "newmtl A\nmap_Kd -o 0.5 0.5 my checker.png\nmap_Bump -bm 0.3 -clamp on rough bump.png\nmap_Ke -s 2 2 2 -mm 0 1 glow.png\n\ + newmtl B\nmap_Kd -o 1 wood.png\nmap_Bump -unknown 3 my bump.png\nmap_Ke -bm\n"; + let (materials, images) = read_mtl(mtl.as_bytes(), &Limits::default()).unwrap(); + let name = |t: Option| t.map(|t| images[t.image].uri.clone().unwrap()); + assert_eq!(name(materials[0].base_color_texture).as_deref(), Some("my checker.png")); + assert_eq!(name(materials[0].normal_texture).as_deref(), Some("rough bump.png")); + assert_eq!(name(materials[0].emissive_texture).as_deref(), Some("glow.png")); + assert_eq!(name(materials[1].base_color_texture).as_deref(), Some("wood.png")); + // an option this reader does not know: the final token + assert_eq!(name(materials[1].normal_texture).as_deref(), Some("bump.png")); + // a lone token is the name, whatever it looks like + assert_eq!(name(materials[1].emissive_texture).as_deref(), Some("-bm")); +} + +#[test] +fn a_library_named_again_is_read_once() { + // Exporters repeat `mtllib` for every object. Each repeat used to read the library again: + // its materials and images twice, and its bytes counted against the limit once per line. + let mtl = "newmtl Red\nKd 1 0 0\nmap_Kd red.png\n"; + let src = "mtllib m.mtl\no a\nv 0 0 0\nv 1 0 0\nv 0 1 0\nusemtl Red\nf 1 2 3\nmtllib m.mtl\no b\nusemtl Red\nf 1 2 3\nmtllib m.mtl\n"; + let mut asked = 0; + let limits = Limits { max_data_bytes: mtl.len() * 2, ..Limits::default() }; + let s = read(src.as_bytes(), &limits, &mut |_| { + asked += 1; + Some(mtl.as_bytes().to_vec()) + }) + .unwrap(); + assert_eq!(asked, 1); + assert_eq!((s.materials.len(), s.images.len()), (1, 1)); + assert_eq!(s.external, ["m.mtl", "red.png"]); +}