From 9e458e14cc3f941da778a60d92badfcd86438822 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 18:08:57 -0500 Subject: [PATCH] craft-mesh: glTF sparse accessors, specular-glossiness, WebP textures; PLY per-corner UVs and TextureFile (#915) * craft-mesh: FBX 7.x reader from EffectCraft (`fbx`) EffectCraft's FBX reader (crates/model/src/fbx.rs: binary with 32- and 64-bit record offsets and zlib arrays, ASCII, geometry layer elements, the Model transform chain, Lambert/Phong materials, textures) onto the crate's Scene. The reader was written by vangel76 (EffectCraft #736); its memory budget and shared instanced meshes by DWAA1660 (EffectCraft #1234). It includes the fixes of the open EffectCraft PR #2955: a binary child record must end within its parent, a failed array inflate reports only its status, textures that end at one image share it and its bytes are charged to the budget, and material slots and connections are found through maps. What differs from the app's copy, because the model here is neutral: polygons stay whole and layer elements stay indexed pools (no vertex unification, no computed normals or tangents); images are bytes, not decoded pixels; the axis system is not applied (Document::axes_to_y_up gives it); the memory budget is Limits::max_data_bytes; a file without meshes is a scene, not an error. base64_decode moves from the gltf module to the crate root so both formats share it (gltf re-exports it). * craft-mesh: read FBX 6.x FBX 6.1 files differ from 7.x in how the scene is laid out, not in the record syntax: a Model holds its own Vertices and layer elements, properties are Properties60/Property (no label field), Connect lines name objects by Class::Name instead of by id, and GlobalSettings sits among the objects. The reader now takes all four, and NormalIndex beside NormalsIndex, so 6.x geometry, transforms, the hierarchy and materials are read. 6.x textures (connected to Models) are not. Ported from storytold/deckcraft#1380 (crates/mesh/src/fbx), whose reader took 6.1 to 7.7; its mesh building, axis turn and rasteriser stay in DeckCraft. * craft-mesh: glTF sparse accessors, specular-glossiness and WebP textures; PLY per-corner texture coordinates and TextureFile glTF: - Sparse accessors are read instead of refused: the values replace the elements the indices name, over the buffer view's values or over zeros. An index past the accessor, more entries than elements, or a read past a view is Malformed. - KHR_materials_pbrSpecularGlossiness becomes the nearest metallic-roughness material: diffuse factor and texture, no metal, roughness 1 - glossiness. - A texture whose image is only in EXT_texture_webp uses it; one with a fallback source keeps the fallback. Both extensions may now be required without a warning. PLY: - A face's `texcoord` list (u v per corner, as MeshLab writes wedge coordinates) becomes the UV set, indexed per corner, instead of the vertices' own s/t. - `comment TextureFile ` gives the mesh a material whose base colour texture is that file, named and listed in Scene::external, never opened. Ported from storytold/deckcraft#743 (crates/mesh/src/gltf.rs, ply.rs). --------- Co-authored-by: Ethan Dale Co-authored-by: uvxszi --- crates/mesh/README.md | 12 +++-- crates/mesh/src/gltf.rs | 88 ++++++++++++++++++++++++++++------- crates/mesh/src/ply.rs | 56 +++++++++++++++++++--- crates/mesh/src/tests/gltf.rs | 81 ++++++++++++++++++++++++++++++-- crates/mesh/src/tests/ply.rs | 36 +++++++++++++- 5 files changed, 241 insertions(+), 32 deletions(-) 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); +}