diff --git a/crates/mesh/README.md b/crates/mesh/README.md index 8b2ebc998..58e4cf21a 100644 --- a/crates/mesh/README.md +++ b/crates/mesh/README.md @@ -65,15 +65,20 @@ free-form geometry are passed over. any order; scalar and list properties of every type. From the `vertex` element: `x y z`, `nx ny nz`, `s t` / `u v` / `texture_u texture_v`, `red green blue alpha` (also `r g b a` and `diffuse_*`), integer colours scaled by their type. From the `face` element: `vertex_indices` or -`vertex_index`, polygons kept whole. A file without faces is a point cloud (positions, no parts). +`vertex_index`, polygons kept whole, and a `texcoord` list of a `u v` per corner, which then is +the UV set instead of the vertices' own. A `comment TextureFile ` line gives the mesh a +material whose base colour texture is that file (named, never opened). A file without faces is a +point cloud (positions, no parts). `ply::parse_header` and `ply::Rows` are public for callers that need every property as a column, `edge` elements or thinning. **glTF and GLB.** The default scene's node tree (matrix or TRS); `TRIANGLES`, `TRIANGLE_STRIP` and `TRIANGLE_FAN`, indexed or not; POSITION, NORMAL, every TEXCOORD_n (up to 8), COLOR_0, JOINTS_0, WEIGHTS_0 in any component type the specification or `KHR_mesh_quantization` allows; strided -buffer views; accessors without a buffer view (zeros); materials with their texture slots, UV set -and sampler wrap modes, `KHR_materials_emissive_strength`, `KHR_materials_unlit`; images embedded +buffer views; accessors without a buffer view (zeros); sparse accessors; materials with their +texture slots, UV set and sampler wrap modes, `KHR_materials_emissive_strength`, +`KHR_materials_unlit`, `KHR_materials_pbrSpecularGlossiness` (diffuse colour and texture, no +metal, roughness from the glossiness); textures whose image is in `EXT_texture_webp`; images embedded in a buffer view or a `data:` URI, or named; skins; translation, rotation and scale animations; cameras; `KHR_lights_punctual`. Primitives of a mesh that name the same accessors share their vertices. `write_glb` writes nodes, meshes (positions, normals, UV sets, colours), materials and @@ -93,6 +98,7 @@ FBX 6.x files (a `Model` holds its mesh, `Properties60`, objects connected by na geometry, transforms, the hierarchy and materials. Not read: FBX 5 and older, 6.x textures, animation, skin deformers, blend shapes, cameras, lights, NURBS. +Not read (glTF): required geometry extensions (Draco, meshopt) are Not read (glTF): sparse accessors and required geometry extensions (Draco, meshopt) are `Error::Unsupported`; points, lines, morph targets, tangents and other extensions are passed over. Not written: skins, animations, cameras, lights, and `.gltf` with separate files. diff --git a/crates/mesh/src/gltf.rs b/crates/mesh/src/gltf.rs index aecc545ce..90e7b0840 100644 --- a/crates/mesh/src/gltf.rs +++ b/crates/mesh/src/gltf.rs @@ -4,16 +4,18 @@ //! Read: the default scene's node hierarchy (matrix or translation/rotation/scale), triangle //! primitives (`TRIANGLES`, `TRIANGLE_STRIP`, `TRIANGLE_FAN`, indexed or not) with POSITION, //! NORMAL, every TEXCOORD_n, COLOR_0, JOINTS_0 and WEIGHTS_0 of any component type the -//! specification or `KHR_mesh_quantization` allows, strided buffer views, metallic-roughness -//! materials (factors, the five texture slots with their UV set and wrap modes, alpha modes, -//! double-sided, `KHR_materials_emissive_strength`, `KHR_materials_unlit`), images (embedded +//! specification or `KHR_mesh_quantization` allows, strided buffer views, sparse accessors, +//! metallic-roughness materials (factors, the five texture slots with their UV set and wrap +//! modes, alpha modes, double-sided, `KHR_materials_emissive_strength`, `KHR_materials_unlit`; +//! `KHR_materials_pbrSpecularGlossiness` as its diffuse colour and texture with roughness from +//! the glossiness), textures whose image is only in `EXT_texture_webp`, images (embedded //! in a buffer view or a `data:` URI, or named), skins, animations of translation, rotation //! and scale, cameras and `KHR_lights_punctual` lights. A glTF mesh becomes one [`Mesh`] whose //! pools hold its primitives' vertices one after another (once for primitives that name the //! same accessors), and each primitive a [`Part`]. //! -//! Not read: sparse accessors and required extensions that change how geometry is stored -//! (Draco, meshopt) are [`Error::Unsupported`]; points, lines, morph targets, tangents and +//! Not read: required extensions that change how geometry is stored (Draco, meshopt) are +//! [`Error::Unsupported`]; points, lines, morph targets, tangents and //! other extensions are passed over (skipped primitives are counted in a warning). //! //! Buffers are needed to read anything, so an external buffer is asked of the resolver (by its @@ -238,9 +240,6 @@ impl Doc<'_> { /// 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, @@ -266,11 +265,23 @@ impl Doc<'_> { 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 mut out = match idx(acc, "bufferView") { + // No buffer view: all zeros. + None => { + charge(&self.zero_left, total, "values without a buffer")?; + vec![0.0; total] + } + Some(bv) => self.dense(acc, bv, (ct, csize, norm), n, count)?, }; + if let Some(sparse) = acc.get("sparse") { + self.sparse(sparse, (ct, csize, norm), n, &mut out)?; + } + Ok((out, n)) + } + + /// The `count` elements of `n` components an accessor reads from buffer view `bv`. + fn dense(&self, acc: &Value, bv: usize, (ct, csize, norm): (usize, usize, bool), n: usize, count: usize) -> Result> { + let total = count * 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))); @@ -290,7 +301,34 @@ impl Doc<'_> { *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)) + Ok(out) + } + + /// Put a sparse accessor's values over the elements it names. Its indices and values are + /// tightly packed in their buffer views; an index past the accessor is an error. + fn sparse(&self, sparse: &Value, (ct, csize, norm): (usize, usize, bool), n: usize, out: &mut [f64]) -> Result<()> { + let bad = || malformed(KIND, "a sparse accessor reads past its buffer or names an element that is not there"); + let count = idx(sparse, "count").filter(|k| *k <= out.len() / n).ok_or_else(bad)?; + let part = |key: &str| -> Result<(&Value, &[u8])> { + let v = sparse.get(key).ok_or_else(bad)?; + Ok((v, self.view(idx(v, "bufferView").ok_or_else(bad)?)?.get(idx(v, "byteOffset").unwrap_or(0)..).ok_or_else(bad)?)) + }; + let ((indices, index_data), (_, value_data)) = (part("indices")?, part("values")?); + let index_type = idx(indices, "componentType").unwrap_or(5125); + let index_size = match index_type { + 5121 => 1, + 5123 => 2, + 5125 => 4, + t => return Err(malformed(KIND, format!("sparse index componentType {t}"))), + }; + for e in 0..count { + let target = index_data.get(e * index_size..).and_then(|s| scalar(index_type, s, false)).ok_or_else(bad)? as usize; + let row = target.checked_mul(n).and_then(|at| out.get_mut(at..at.checked_add(n)?)).ok_or_else(bad)?; + for (c, v) in row.iter_mut().enumerate() { + *v = value_data.get((e * n + c) * csize..).and_then(|s| scalar(ct, s, norm)).ok_or_else(bad)?; + } + } + Ok(()) } /// A vertex attribute of `nv` vertices with `n` components in `want`; `None` when the @@ -517,7 +555,9 @@ fn materials(j: &Value, limits: &Limits, scene: &mut Scene) -> Result<()> { 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)?; + // `EXT_texture_webp`: the image when the texture has no fallback `source`. + let webp = || texture.get("extensions").and_then(|e| e.get("EXT_texture_webp")).and_then(|e| idx(e, "source")); + let image = idx(texture, "source").or_else(webp).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 { @@ -546,6 +586,15 @@ fn materials(j: &Value, limits: &Limits, scene: &mut Scene) -> Result<()> { occlusion_strength: m.get("occlusionTexture").map_or(1.0, |n| num(n, "strength", 1.0)) as f32, emissive_texture: tref(m.get("emissiveTexture")), }); + // The older specular-glossiness model, as the nearest metallic-roughness material: its + // diffuse colour and texture, no metal, and roughness from the glossiness. + if let (Some(sg), Some(out)) = (ext.and_then(|e| e.get("KHR_materials_pbrSpecularGlossiness")), scene.materials.last_mut()) { + out.base_color = nums(sg, "diffuseFactor", [1.0; 4]).map(|v| v as f32); + out.base_color_texture = tref(sg.get("diffuseTexture")); + out.metallic = 0.0; + out.roughness = (1.0 - num(sg, "glossinessFactor", 1.0)).clamp(0.0, 1.0) as f32; + out.metallic_roughness_texture = None; + } } Ok(()) } @@ -694,10 +743,17 @@ fn cameras_and_lights(j: &Value, limits: &Limits, scene: &mut Scene) -> Result<( /// 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"]; + const LOOKS: [&str; 4] = ["KHR_texture_transform", "KHR_texture_basisu", "MSFT_texture_dds", "KHR_lights_punctual"]; + const READ: [&str; 5] = [ + "KHR_mesh_quantization", + "KHR_materials_unlit", + "KHR_materials_emissive_strength", + "KHR_materials_pbrSpecularGlossiness", + "EXT_texture_webp", + ]; 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") { + if READ.contains(&e) { continue; } if !LOOKS.contains(&e) && !e.starts_with("KHR_materials_") { diff --git a/crates/mesh/src/ply.rs b/crates/mesh/src/ply.rs index bba478986..1d3063178 100644 --- a/crates/mesh/src/ply.rs +++ b/crates/mesh/src/ply.rs @@ -4,8 +4,11 @@ //! 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. +//! `vertex_indices` list, polygons kept whole, with its `texcoord` list (a `u v` per corner, +//! which then is the UV set instead of the vertices' own). A `comment TextureFile ` line +//! gives the mesh a material whose base colour texture is that file: named in [`Image::uri`] +//! and [`Scene::external`], never opened. 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 @@ -15,7 +18,7 @@ //! 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}; +use crate::{Corner, Image, Limits, Material, Mesh, Part, Result, Scene, TextureRef, count_warning, malformed, too_large}; const KIND: &str = "PLY"; /// Largest header read (bytes). @@ -427,14 +430,18 @@ fn vertices(e: &Element, rows: &mut Rows<'_>, mesh: &mut Mesh, limits: &Limits) struct Faces { part: Part, + /// A UV per face corner, from the faces' `texcoord` lists. + uvs: Vec<[f32; 2]>, 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(); + let texcoord = e.properties.iter().position(|p| p.name == "texcoord" && matches!(p.kind, Kind::List(..))); + let (mut face, mut coords): (Vec, Vec) = (Vec::new(), Vec::new()); for _ in 0..e.count { + coords.clear(); for (i, p) in e.properties.iter().enumerate() { match p.kind { Kind::Scalar(t) => rows.skip(t, 1)?, @@ -445,6 +452,12 @@ fn faces(e: &Element, list: usize, rows: &mut Rows<'_>, vertex_count: u64, out: face.push(rows.value(t)?); } } + // `u v` per corner. + Kind::List(c, t) if Some(i) == texcoord => { + for _ in 0..rows.count(c, limits.max_face_corners.saturating_mul(2))? { + coords.push(rows.value(t)?); + } + } Kind::List(c, t) => { let n = rows.count(c, limits.max_face_corners)?; rows.skip(t, n)?; @@ -455,10 +468,21 @@ fn faces(e: &Element, list: usize, rows: &mut Rows<'_>, vertex_count: u64, out: .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 { + let Some(mut corners) = corners.filter(|c| c.len() >= 3) else { out.bad += 1; continue; }; + // Coordinates that do not match the corners are passed over. + if coords.len() == corners.len() * 2 { + if out.uvs.len().saturating_add(corners.len()) > limits.max_vertices { + return Err(too_large(KIND, format!("more than {} texture coordinates", limits.max_vertices))); + } + for (corner, uv) in corners.iter_mut().zip(coords.as_chunks::<2>().0) { + corner.uv = out.uvs.len() as u32; + // The origin is at the bottom left. + out.uvs.push([uv[0] as f32, 1.0 - uv[1] as f32]); + } + } 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))); @@ -476,7 +500,7 @@ pub fn read(bytes: &[u8], limits: &Limits) -> Result { 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 out = Faces { part: Part::default(), uvs: Vec::new(), 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(..))); @@ -514,16 +538,34 @@ pub fn read(bytes: &[u8], limits: &Limits) -> Result { if !mesh.normals.is_empty() { part.corners.iter_mut().for_each(|c| c.normal = c.position); } - if !mesh.uv_sets.is_empty() { + if !out.uvs.is_empty() { + // Per-corner coordinates replace the vertices' own. + mesh.uv_sets = vec![std::mem::take(&mut out.uvs)]; + } else 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(); } + // `comment TextureFile ` (MeshLab and others): the image the UVs are for. + let texture = h.comments.iter().find_map(|c| { + let (key, name) = c.split_once(char::is_whitespace)?; + (key.eq_ignore_ascii_case("TextureFile") && !name.trim().is_empty()).then(|| name.trim().to_string()) + }); + let textured = texture.is_some() && !part.corners.is_empty(); + if textured { + part.material = Some(0); + } if !part.corners.is_empty() { mesh.parts.push(part); } let mut scene = Scene::from_meshes(vec![mesh], Vec::new()); + if let Some(name) = texture.filter(|_| textured) { + let texture = TextureRef { image: 0, ..TextureRef::default() }; + scene.materials.push(Material { base_color_texture: Some(texture), ..Material::plain("", [1.0; 4]) }); + scene.images.push(Image { uri: Some(name.clone()), ..Image::default() }); + scene.external.push(name); + } 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/tests/gltf.rs b/crates/mesh/src/tests/gltf.rs index 9564c8111..c4498b3e2 100644 --- a/crates/mesh/src/tests/gltf.rs +++ b/crates/mesh/src/tests/gltf.rs @@ -401,11 +401,6 @@ fn what_is_not_read_is_refused_or_named() { )) .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()); } @@ -716,3 +711,79 @@ fn writer_refuses_what_is_not_gltf() { 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])); } + +#[test] +fn sparse_accessors_replace_the_elements_they_name() { + let mut b = B::default(); + let p = b.f32s(&QUAD, "VEC3", 3); + let indices = b.view(&[3u8, 1]); + let far = b.view(&[4u8, 1]); + let values = b.view(&[0.0f32, 5.0, 0.0, 2.0, 0.0, 0.0].iter().flat_map(|x| x.to_le_bytes()).collect::>()); + b.accessors[p]["sparse"] = json!({ "count": 2, "indices": { "bufferView": indices, "componentType": 5121 }, "values": { "bufferView": values } }); + // The same over an accessor without a buffer view: zeros, then the sparse values. + b.accessors.push(json!({ "componentType": 5126, "count": 4, "type": "VEC3", "sparse": b.accessors[p]["sparse"] })); + let prims = json!([{ "attributes": { "POSITION": p } }, { "attributes": { "POSITION": p + 1 } }]); + let s = parse(&b.glb(json!({ "nodes": [{ "mesh": 0 }], "meshes": [{ "primitives": prims }] }))).unwrap(); + s.validate().unwrap(); + let pos = &s.meshes[0].positions; + assert_eq!(pos[..4], [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 5.0, 0.0]]); + assert_eq!(pos[4..], [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 5.0, 0.0]]); + + // What a sparse accessor may not do: name an element past the accessor, hold more entries + // than it, read past a view, or leave its indices or values out. + let broken = |change: &dyn Fn(&mut Value)| { + let mut b2 = B { bin: b.bin.clone(), views: b.views.clone(), accessors: b.accessors.clone() }; + change(&mut b2.accessors[p]["sparse"]); + parse(&one_mesh(&b2, json!({ "attributes": { "POSITION": p } }))) + }; + assert!(broken(&|_| {}).is_ok()); + for change in [ + (&|s: &mut Value| s["indices"]["bufferView"] = json!(far)) as &dyn Fn(&mut Value), + &|s| s["count"] = json!(5), + &|s| s["count"] = json!(3), + &|s| s["values"]["byteOffset"] = json!(4), + &|s| s["indices"]["componentType"] = json!(5126), + &|s| *s = json!({ "count": 1 }), + ] { + assert!(matches!(broken(change), Err(Error::Malformed(_)))); + } + hammer(&one_mesh(&b, json!({ "attributes": { "POSITION": p } })), &parse); +} + +#[test] +fn specular_glossiness_and_webp_textures() { + let mut b = B::default(); + let p = b.f32s(&QUAD[..9], "VEC3", 3); + let doc = b.glb(json!({ + "extensionsRequired": ["KHR_materials_pbrSpecularGlossiness", "EXT_texture_webp"], + "nodes": [{ "mesh": 0 }], + "meshes": [{ "primitives": [{ "attributes": { "POSITION": p }, "material": 0 }] }], + "images": [{ "uri": "fallback.png" }, { "uri": "only.webp" }, { "uri": "better.webp" }], + "textures": [ + { "extensions": { "EXT_texture_webp": { "source": 1 } } }, + { "source": 0, "extensions": { "EXT_texture_webp": { "source": 2 } } }, + ], + "materials": [ + { + "pbrMetallicRoughness": { "baseColorFactor": [0.0, 0.0, 0.0, 1.0], "metallicRoughnessTexture": { "index": 1 } }, + "normalTexture": { "index": 1 }, + "extensions": { "KHR_materials_pbrSpecularGlossiness": { + "diffuseFactor": [0.5, 0.25, 1.0, 0.75], "diffuseTexture": { "index": 0, "texCoord": 1 }, "glossinessFactor": 0.25, + } }, + }, + { "extensions": { "KHR_materials_pbrSpecularGlossiness": {} } }, + ], + })); + let s = parse(&doc).unwrap(); + s.validate().unwrap(); + // Both extensions are read, so neither is named in a warning. + assert!(s.warnings.is_empty(), "{:?}", s.warnings); + let m = &s.materials[0]; + assert_eq!((m.base_color, m.metallic, m.roughness), ([0.5, 0.25, 1.0, 0.75], 0.0, 0.75)); + // A texture with only a WebP image uses it; one with a fallback keeps the fallback. + assert_eq!(m.base_color_texture, Some(TextureRef { image: 1, uv_set: 1, ..TextureRef::default() })); + assert_eq!(m.normal_texture.map(|t| t.image), Some(0)); + assert_eq!(m.metallic_roughness_texture, None); + let plain = &s.materials[1]; + assert_eq!((plain.base_color, plain.metallic, plain.roughness, plain.base_color_texture), ([1.0; 4], 0.0, 0.0, None)); +} diff --git a/crates/mesh/src/tests/ply.rs b/crates/mesh/src/tests/ply.rs index 3c4160553..7c8c9d285 100644 --- a/crates/mesh/src/tests/ply.rs +++ b/crates/mesh/src/tests/ply.rs @@ -1,6 +1,6 @@ use super::hammer; use crate::ply::{Encoding, Kind, MAX_ELEMENTS, MAX_PROPERTIES, Rows, Scalar, is_ply, parse_header, read}; -use crate::{Error, Limits, Scene}; +use crate::{Corner, Error, Limits, Scene}; fn parse(bytes: &[u8]) -> crate::Result { read(bytes, &Limits::default()) @@ -193,3 +193,37 @@ fn limits() { assert!(parse(properties(MAX_PROPERTIES).as_bytes()).is_ok()); assert!(too(parse(properties(MAX_PROPERTIES + 1).as_bytes()))); } + +/// As MeshLab writes a textured mesh: the texture named in a comment, a `u v` per face corner. +const WEDGE_UVS: &str = "ply\nformat ascii 1.0\ncomment TextureFile wood grain.png\nelement vertex 4\nproperty float x\nproperty float y\nproperty float z\n\ +property float s\nproperty float t\nelement face 3\nproperty list uchar int vertex_indices\nproperty list uchar float texcoord\nend_header\n\ +0 0 0 9 9\n1 0 0 9 9\n1 1 0 9 9\n0 1 0 9 9\n3 0 1 2 6 0 0 1 0 1 1\n3 0 2 3 6 0 0.25 1 1 0 1\n3 0 1 3 4 0 0 1 1\n"; + +#[test] +fn per_corner_texcoords_and_the_texture_file_comment() { + let s = parse(WEDGE_UVS.as_bytes()).unwrap(); + s.validate().unwrap(); + let mesh = &s.meshes[0]; + // Vertex 0 has two coordinates, one per face: the faces' lists are the UV set (v flipped), + // not the vertices' `s t`. A list that does not fit its face is passed over. + assert_eq!(mesh.uv_sets, [vec![[0.0, 1.0], [1.0, 1.0], [1.0, 0.0], [0.0, 0.75], [1.0, 0.0], [0.0, 0.0]]]); + let corners: Vec = + [(0, 0), (1, 1), (2, 2), (0, 3), (2, 4), (3, 5)].map(|(position, uv)| Corner { position, normal: crate::NONE, uv }).to_vec(); + assert_eq!(mesh.parts[0].corners, [corners, [0, 1, 3].map(Corner::at).to_vec()].concat()); + // The comment names the image of a material; nothing is opened. + let material = &s.materials[mesh.parts[0].material.unwrap()]; + assert_eq!(s.images[material.base_color_texture.unwrap().image].uri.as_deref(), Some("wood grain.png")); + assert_eq!(s.external, ["wood grain.png"]); + // Without either, the vertices' own coordinates and no material, as before. + let plain = WEDGE_UVS.replace("comment TextureFile wood grain.png\n", "").replace("texcoord", "other"); + let s = parse(plain.as_bytes()).unwrap(); + assert_eq!( + (s.materials.len(), s.meshes[0].uv_sets[0].len(), s.meshes[0].parts[0].corners[4]), + (0, 4, Corner { position: 2, normal: crate::NONE, uv: 2 }) + ); + // The coordinates count against the vertex limit, and a coordinate list may be twice a face. + let tight = |max_vertices, max_face_corners| read(WEDGE_UVS.as_bytes(), &Limits { max_vertices, max_face_corners, ..Limits::default() }); + assert!(tight(6, 3).is_ok()); + assert!(matches!(tight(5, 3), Err(Error::TooLarge(_)))); + hammer(WEDGE_UVS.as_bytes(), &parse); +}