From 0adade3d7c9f39e3e3de247fc6c1ff26cf2f3d8d Mon Sep 17 00:00:00 2001 From: Taiki Endo Date: Mon, 15 Jan 2024 21:30:25 +0900 Subject: [PATCH] Add Loader API & clean up tests/examples to use it --- Cargo.toml | 4 +- example/examples/kiss3d.rs | 153 ------------------------ {example => examples/kiss3d}/Cargo.toml | 8 +- examples/kiss3d/src/main.rs | 114 ++++++++++++++++++ src/lib.rs | 2 + src/loader.rs | 129 ++++++++++++++++++++ tests/assimp.rs | 146 ++++++++++++++++++---- 7 files changed, 375 insertions(+), 181 deletions(-) delete mode 100644 example/examples/kiss3d.rs rename {example => examples/kiss3d}/Cargo.toml (65%) create mode 100644 examples/kiss3d/src/main.rs create mode 100644 src/loader.rs diff --git a/Cargo.toml b/Cargo.toml index ac44cbd..3013a2e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,7 +44,9 @@ workspace = true [workspace] resolver = "2" -members = ["example"] +members = [ + "examples/kiss3d", +] # This table is shared by projects under https://github.com/taiki-e. # It is not intended for manual editing. diff --git a/example/examples/kiss3d.rs b/example/examples/kiss3d.rs deleted file mode 100644 index 9c5a5a1..0000000 --- a/example/examples/kiss3d.rs +++ /dev/null @@ -1,153 +0,0 @@ -use std::{ - cell::RefCell, - ffi::OsStr, - fs, io, - path::{Path, PathBuf}, - rc::Rc, - str::FromStr, -}; - -use anyhow::{bail, Result}; -use clap::Parser; -use kiss3d::{light::Light, nalgebra as na, scene::SceneNode, window::Window}; -use na::{Translation3, UnitQuaternion, Vector3}; - -#[derive(Debug, Parser)] -struct Args { - #[clap(parse(from_os_str))] - path: PathBuf, - #[clap(long, value_name = "X,Y,Z", default_value = "0.1,0.1,0.1")] - scale: Scale, -} - -#[derive(Debug)] -struct Scale(f32, f32, f32); - -impl FromStr for Scale { - type Err = anyhow::Error; - - fn from_str(s: &str) -> Result { - let mut iter = s.trim().splitn(3, ','); - Ok(Scale( - iter.next().unwrap().parse()?, - iter.next().unwrap().parse()?, - iter.next().unwrap().parse()?, - )) - } -} - -fn main() -> Result<()> { - let args = Args::parse(); - eprintln!("args={args:?}"); - let path = &args.path; - let scale = Vector3::new(args.scale.0, args.scale.1, args.scale.2); - - let mut window = Window::new(&format!("{} ー Meshes Example", args.path.display())); - - let mut base = match path.extension().and_then(OsStr::to_str) { - Some("stl" | "STL") => add_stl(&mut window, path, scale)?, - Some("dae" | "DAE") => add_collada(&mut window, path, scale)?, - // Some("obj" | "OBJ") => add_obj(&mut window, path, scale)?, - _ => bail!("unsupported file type {path:?}"), - }; - base.set_local_scale(args.scale.0, args.scale.1, args.scale.2); - - base.append_translation(&Translation3::new(0.0, -0.05, -0.2)); - - window.set_light(Light::StickToCamera); - - let rot_triangle = UnitQuaternion::from_axis_angle(&Vector3::z_axis(), 0.014); - - let eye = na::Point3::new(3.0f32, 1.0, 1.0); - let at = na::Point3::new(0.0f32, 0.0, 0.0); - let mut camera = kiss3d::camera::ArcBall::new(eye, at); - camera.set_up_axis(na::Vector3::z()); - camera.set_dist_step(0.5); - while window.render_with_camera(&mut camera) { - base.prepend_to_local_rotation(&rot_triangle); - } - - Ok(()) -} - -fn add_stl(window: &mut Window, path: &Path, scale: na::Vector3) -> io::Result { - let stl = mesh_loader::Mesh::merge(mesh_loader::stl::from_slice(&fs::read(path)?)?.meshes); - eprintln!( - "name={:?},vertices={},faces={}", - stl.name, - stl.vertices.len(), - stl.faces.len() - ); - let mesh = kiss3d::resource::Mesh::new( - stl.vertices.into_iter().map(Into::into).collect(), - stl.faces - .into_iter() - .map(|f| na::Point3::new(f[0], f[1], f[2])) - .collect(), - Some(stl.normals.into_iter().map(Into::into).collect()), - None, - false, - ); - let mesh = Rc::new(RefCell::new(mesh)); - Ok(window.add_mesh(mesh, scale)) -} - -fn add_collada(window: &mut Window, path: &Path, scale: na::Vector3) -> io::Result { - let mut base = window.add_group(); - let collada = mesh_loader::Mesh::merge( - mesh_loader::collada::from_str(&fs::read_to_string(path)?)?.meshes, - ); - eprintln!( - "name={:?},vertices={},normals={},texcoords0={},texcoords1={},faces={}", - collada.name, - collada.vertices.len(), - collada.normals.len(), - collada.texcoords[0].len(), - collada.texcoords[1].len(), - collada.faces.len() - ); - let positions = collada - .vertices - .iter() - .map(|&v| na::Point3::from(v)) - .collect(); - let normals = if collada.normals.is_empty() { - None - } else { - Some( - collada - .normals - .iter() - .map(|&v| na::Vector3::from(v)) - .collect(), - ) - }; - let texcoords = if collada.texcoords[0].is_empty() { - None - } else { - Some( - collada.texcoords[0] - .iter() - .map(|&v| na::Point2::from(v)) - .collect(), - ) - }; - let faces = collada - .faces - .iter() - .map(|v| na::Point3::new(v[0], v[1], v[2])) - .collect(); - let mut _scene = base.add_mesh( - Rc::new(RefCell::new(kiss3d::resource::Mesh::new( - positions, faces, normals, texcoords, false, - ))), - scale, - ); - - // TODO(material) - // if let Some(path) = materials.get(0) { - // scene.set_texture_from_file(path, path.to_str().unwrap()); - // } - - Ok(base) -} diff --git a/example/Cargo.toml b/examples/kiss3d/Cargo.toml similarity index 65% rename from example/Cargo.toml rename to examples/kiss3d/Cargo.toml index 26a7815..6009504 100644 --- a/example/Cargo.toml +++ b/examples/kiss3d/Cargo.toml @@ -1,15 +1,15 @@ [package] -name = "example" +name = "kiss3d-example" version = "0.0.0" edition = "2021" publish = false -[dev-dependencies] -mesh-loader = { path = ".." } +[dependencies] +mesh-loader = { path = "../.." } anyhow = "1" -clap = { version = "3", features = ["derive"] } kiss3d = { git = "https://github.com/sebcrozet/kiss3d.git", rev = "da70cd0", features = ["vertex_index_u32"] } +lexopt = "0.3" [lints] workspace = true diff --git a/examples/kiss3d/src/main.rs b/examples/kiss3d/src/main.rs new file mode 100644 index 0000000..fc77c65 --- /dev/null +++ b/examples/kiss3d/src/main.rs @@ -0,0 +1,114 @@ +use std::{ + cell::RefCell, + path::{Path, PathBuf}, + rc::Rc, +}; + +use anyhow::Result; +use kiss3d::{light::Light, nalgebra as na, scene::SceneNode, window::Window}; +use lexopt::prelude::*; +use na::{Translation3, UnitQuaternion, Vector3}; + +const DEFAULT_SCALE: f32 = 0.1; + +#[derive(Debug)] +struct Args { + path: PathBuf, + scale: f32, +} + +impl Args { + fn parse() -> Result { + let mut parser = lexopt::Parser::from_env(); + let mut path = None; + let mut scale = None; + while let Some(arg) = parser.next()? { + match arg { + Value(v) => path = Some(v.into()), + Long("scale") => scale = Some(parser.value()?.parse()?), + Short('h') | Long("help") => { + path = None; + break; + } + arg => return Err(arg.unexpected().into()), + } + } + let Some(path) = path else { + println!( + "Usage: cargo run --bin {} -- [--scale ]", + env!("CARGO_BIN_NAME") + ); + std::process::exit(1); + }; + Ok(Self { + path, + scale: scale.unwrap_or(DEFAULT_SCALE), + }) + } +} + +fn main() -> Result<()> { + let args = Args::parse()?; + eprintln!("args={args:?}"); + let path = &args.path; + let scale = Vector3::new(args.scale, args.scale, args.scale); + + let mut window = Window::new(&format!("{} ー mesh-loader example", args.path.display())); + + let mut base = add_mesh(&mut window, path, scale)?; + base.set_local_scale(args.scale, args.scale, args.scale); + + base.append_translation(&Translation3::new(0.0, -0.05, -0.2)); + + window.set_light(Light::StickToCamera); + + let rot_triangle = UnitQuaternion::from_axis_angle(&Vector3::z_axis(), 0.014); + + let eye = na::Point3::new(3.0f32, 1.0, 1.0); + let at = na::Point3::new(0.0f32, 0.0, 0.0); + let mut camera = kiss3d::camera::ArcBall::new(eye, at); + camera.set_up_axis(na::Vector3::z()); + camera.set_dist_step(0.5); + while window.render_with_camera(&mut camera) { + base.prepend_to_local_rotation(&rot_triangle); + } + + Ok(()) +} + +fn add_mesh(window: &mut Window, path: &Path, scale: na::Vector3) -> Result { + let loader = mesh_loader::Loader::default().merge_meshes(true); + let mut scene = loader.load(path)?; + assert_eq!(scene.meshes.len(), 1); // merge_meshes guarantees this. + let mesh = scene.meshes.pop().unwrap(); + eprintln!("mesh={mesh:?}"); + let coords = mesh.vertices.into_iter().map(Into::into).collect(); + let faces = mesh + .faces + .into_iter() + .map(|f| na::Point3::new(f[0], f[1], f[2])) + .collect(); + let normals = if mesh.normals.is_empty() { + None + } else { + Some(mesh.normals.into_iter().map(Into::into).collect()) + }; + let uvs = if mesh.texcoords[0].is_empty() { + None + } else { + Some(mesh.texcoords[0].iter().copied().map(Into::into).collect()) + }; + let kiss3d_mesh = Rc::new(RefCell::new(kiss3d::resource::Mesh::new( + coords, faces, normals, uvs, false, + ))); + let kiss3d_scene = window.add_mesh(kiss3d_mesh, scale); + // TODO(material) + // if let Some(color) = material.diffuse_color() { + // kiss3d_scene.set_color(color[0], color[1], color[2]); + // } + // if let Some(path) = materials.get(0) { + // kiss3d_scene.set_texture_from_file(path, path.to_str().unwrap()); + // } + + Ok(kiss3d_scene) +} diff --git a/src/lib.rs b/src/lib.rs index d70da64..3e5dc55 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,6 +26,8 @@ mod error; #[cfg(any(feature = "collada", feature = "stl"))] mod utils; +mod loader; +pub use loader::*; mod common; pub use common::*; diff --git a/src/loader.rs b/src/loader.rs new file mode 100644 index 0000000..45a3c00 --- /dev/null +++ b/src/loader.rs @@ -0,0 +1,129 @@ +use std::{ffi::OsStr, fs, io, path::Path}; + +use crate::{Mesh, Scene}; + +type Reader = fn(&Path) -> io::Result>; + +#[derive(Debug)] +pub struct Loader { + reader: Reader, + merge_meshes: bool, + // STL config + #[cfg(feature = "stl")] + stl_parse_color: bool, +} + +fn default_reader(path: &Path) -> io::Result> { + fs::read(path) +} + +impl Default for Loader { + fn default() -> Self { + Self { + reader: default_reader, + merge_meshes: false, + stl_parse_color: false, + } + } +} + +impl Loader { + pub fn load>(&self, path: P) -> io::Result { + self.load_(path.as_ref()) + } + fn load_(&self, path: &Path) -> io::Result { + self.load_from_slice_(&(self.reader)(path)?, path.as_ref()) + } + pub fn load_from_slice>(&self, bytes: &[u8], path: P) -> io::Result { + self.load_from_slice_(bytes, path.as_ref()) + } + fn load_from_slice_(&self, bytes: &[u8], path: &Path) -> io::Result { + match path.extension().and_then(OsStr::to_str) { + #[cfg(feature = "stl")] + Some("stl" | "STL") => self.load_stl_from_slice_(bytes, path), + #[cfg(not(feature = "stl"))] + Some("stl" | "STL") => { + bail!("'stl' feature of mesh-loader must be enabled to parse STL"); + } + #[cfg(feature = "collada")] + Some("dae" | "DAE") => self.load_from_slice_(bytes, path), + #[cfg(not(feature = "collada"))] + Some("dae" | "DAE") => { + bail!("'collada' feature of mesh-loader must be enabled to parse COLLADA"); + } + // #[cfg(feature = "obj")] + // Some("obj" | "OBJ") => self.load_obj_(path), + // #[cfg(not(feature = "obj"))] + // Some("obj" | "OBJ") => { + // bail!("'obj' feature of mesh-loader must be enabled to parse OBJ"); + // } + _ => bail!("unsupported or unrecognized file type {path:?}"), + } + } + + #[cfg(feature = "stl")] + pub fn load_stl>(&self, path: P) -> io::Result { + self.load_stl_(path.as_ref()) + } + #[cfg(feature = "stl")] + fn load_stl_(&self, path: &Path) -> io::Result { + self.load_stl_from_slice_(&(self.reader)(path)?, path) + } + #[cfg(feature = "stl")] + pub fn load_stl_from_slice>(&self, bytes: &[u8], path: P) -> io::Result { + self.load_stl_from_slice_(bytes, path.as_ref()) + } + #[cfg(feature = "stl")] + fn load_stl_from_slice_(&self, bytes: &[u8], path: &Path) -> io::Result { + let scene = crate::stl::from_slice_internal(bytes, Some(path), self.stl_parse_color)?; + Ok(self.post_process(scene)) + } + #[cfg(feature = "stl")] + #[must_use] + pub fn stl_parse_color(mut self, enable: bool) -> Self { + self.stl_parse_color = enable; + self + } + + #[cfg(feature = "collada")] + pub fn load_collada>(&self, path: P) -> io::Result { + self.load_collada_(path.as_ref()) + } + #[cfg(feature = "collada")] + fn load_collada_(&self, path: &Path) -> io::Result { + self.load_collada_from_slice_(&(self.reader)(path)?, path) + } + #[cfg(feature = "collada")] + pub fn load_collada_from_slice>( + &self, + bytes: &[u8], + path: P, + ) -> io::Result { + self.load_collada_from_slice_(bytes, path.as_ref()) + } + #[cfg(feature = "collada")] + fn load_collada_from_slice_(&self, bytes: &[u8], _path: &Path) -> io::Result { + let scene = crate::collada::from_slice(bytes)?; + Ok(self.post_process(scene)) + } + + fn post_process(&self, mut scene: Scene) -> Scene { + if self.merge_meshes && scene.meshes.len() != 1 { + scene.meshes = vec![Mesh::merge(scene.meshes)]; + } + scene + } + + /// Default: `false` + #[must_use] + pub fn merge_meshes(mut self, enable: bool) -> Self { + self.merge_meshes = enable; + self + } + /// Default: [`std::fs::read`] + #[must_use] + pub fn custom_reader(mut self, f: Reader) -> Self { + self.reader = f; + self + } +} diff --git a/tests/assimp.rs b/tests/assimp.rs index 8627a4f..b9029c1 100644 --- a/tests/assimp.rs +++ b/tests/assimp.rs @@ -12,10 +12,10 @@ use walkdir::WalkDir; #[test] fn test() { - let mut download_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - download_dir.push("tests/fixtures"); + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let download_dir = &manifest_dir.join("tests/fixtures"); - clone(&download_dir, "assimp/assimp", &["/test/models/"]).unwrap(); + clone(download_dir, "assimp/assimp", &["/test/models/"]).unwrap(); let models = &download_dir.join("assimp/assimp/test/models"); let mut collada_models = BTreeSet::new(); @@ -23,6 +23,12 @@ fn test() { let mut stl_models = BTreeSet::new(); for e in WalkDir::new(models).into_iter().filter_map(Result::ok) { let path = e.path(); + if let Some(filename) = path.file_name().and_then(OsStr::to_str) { + if filename.contains("UTF16") { + // Skip non-UTF-8 text files. + continue; + } + } match path.extension().and_then(OsStr::to_str) { Some("dae" | "DAE") => collada_models.insert(path.to_owned()), // Some("obj" | "OBJ") => obj_models.insert(path.to_owned()), @@ -30,10 +36,11 @@ fn test() { _ => false, }; } - assert_eq!(collada_models.len(), 25); + assert_eq!(collada_models.len(), 24); // assert_eq!(obj_models.len(), 26); assert_eq!(stl_models.len(), 8); + let mesh_loader = mesh_loader::Loader::default().stl_parse_color(true); let mut assimp_importer = assimp::Importer::new(); assimp_importer.pre_transform_vertices(|x| x.enable = true); assimp_importer.collada_ignore_up_direction(true); @@ -41,20 +48,42 @@ fn test() { // COLLADA for (i, path) in collada_models.iter().enumerate() { + eprintln!(); + eprintln!( + "parsing {:?} (i={i})", + path.strip_prefix(manifest_dir).unwrap() + ); let filename = path.file_name().unwrap().to_str().unwrap(); - eprintln!("parsing {path:?} (i={i})"); - if matches!(filename, "cube_UTF16LE.dae") { - // not utf8 - continue; - } // mesh-loader - let ml = mesh_loader::collada::from_slice(&fs::read(path).unwrap()).unwrap(); + let ml = mesh_loader.load_collada(path).unwrap(); for (i, m) in ml.meshes.iter().enumerate() { - eprintln!("ml.meshes[{i}].name = {:?}", m.name); + eprintln!("ml.meshes[{i}]={m:?}"); } let ml = mesh_loader::Mesh::merge(ml.meshes); + eprintln!("merge(ml.meshes)={ml:?}"); + assert_eq!(ml.vertices.len(), ml.faces.len() * 3); + if ml.normals.is_empty() { + assert_eq!(ml.normals.capacity(), 0); + } else { + assert_eq!(ml.vertices.len(), ml.normals.len()); + } + for texcoords in &ml.texcoords { + if texcoords.is_empty() { + assert_eq!(texcoords.capacity(), 0); + } else { + assert_eq!(ml.vertices.len(), texcoords.len()); + } + } + for colors in &ml.colors { + if colors.is_empty() { + assert_eq!(colors.capacity(), 0); + } else { + assert_eq!(ml.vertices.len(), colors.len()); + } + } + // assimp match filename { // assimp parse error: Cannot parse string \" 0.0 0.0 0.0 1.0 \" as a real number: does not start with digit or decimal point followed by digit. "library_animation_clips.dae" => continue, @@ -62,8 +91,6 @@ fn test() { "cube_tristrips.dae" if option_env!("CI").is_some() => continue, _ => {} } - - // assimp let ai = assimp_importer.read_file(path.to_str().unwrap()).unwrap(); let ai_vertices = ai .mesh_iter() @@ -96,20 +123,20 @@ fn test() { .collect::>(); // TODO - if !matches!(i, 3 | 6 | 19 | 23) { + if !matches!(i, 3 | 6 | 18 | 22) { assert_eq!(ml.faces.len(), ai_faces.len()); // TODO - if !matches!(i, 0 | 2 | 4 | 8 | 9 | 13 | 14 | 21 | 24) { + if !matches!(i, 0 | 2 | 4 | 7 | 8 | 12 | 13 | 20 | 23) { for (ml, ai) in ml.faces.iter().copied().zip(ai_faces) { assert_eq!(ml, ai); } } } // TODO - if !matches!(i, 0 | 3 | 4 | 6 | 8 | 9 | 13 | 14 | 19 | 21 | 23) { + if !matches!(i, 0 | 3 | 4 | 6 | 7 | 8 | 12 | 13 | 18 | 20 | 22) { assert_eq!(ml.vertices.len(), ai_vertices.len()); // TODO - if !matches!(i, 2 | 5 | 11 | 12 | 16 | 17 | 20 | 24) { + if !matches!(i, 2 | 5 | 10 | 11 | 15 | 16 | 19 | 23) { let mut first = true; let mut x = 1.; for (j, (ml, ai)) in ml.vertices.iter().copied().zip(ai_vertices).enumerate() { @@ -142,26 +169,55 @@ fn test() { // STL for (i, path) in stl_models.iter().enumerate() { + eprintln!(); + eprintln!( + "parsing {:?} (i={i})", + path.strip_prefix(manifest_dir).unwrap() + ); let filename = path.file_name().unwrap().to_str().unwrap(); - eprintln!("parsing {path:?} (i={i})"); // mesh-loader - let ml = mesh_loader::stl::from_slice(&fs::read(path).unwrap()).unwrap(); + let ml = mesh_loader.load_stl(path).unwrap(); for (i, m) in ml.meshes.iter().enumerate() { - eprintln!("ml.meshes[{i}].name = {:?}", m.name); + eprintln!("ml.meshes[{i}]={m:?}"); } let ml = mesh_loader::Mesh::merge(ml.meshes); + eprintln!("merge(ml.meshes)={ml:?}"); + assert_eq!(ml.vertices.len(), ml.faces.len() * 3); + assert_eq!(ml.vertices.len(), ml.normals.len()); + for texcoords in &ml.texcoords { + assert_eq!(texcoords.len(), 0); + assert_eq!(texcoords.capacity(), 0); + } + for (i, colors) in ml.colors.iter().enumerate() { + if i != 0 { + assert_eq!(colors.len(), 0); + assert_eq!(colors.capacity(), 0); + } else if colors.is_empty() { + assert_eq!(colors.capacity(), 0); + } else { + assert_eq!(ml.vertices.len(), colors.len()); + } + } + // assimp match filename { // assimp error: "STL: ASCII file is empty or invalid; no data loaded" "triangle_with_empty_solid.stl" if option_env!("CI").is_some() => continue, _ => {} } - - // assimp let ai = assimp_importer.read_file(path.to_str().unwrap()).unwrap(); assert_eq!(ai.num_meshes, 1); + assert_eq!(ai.num_meshes, ai.num_materials); let ai = ai.mesh(0).unwrap(); + assert_eq!(ai.num_vertices, ai.num_faces * 3); + assert_eq!(ai.num_vertices as usize, ai.vertex_iter().count()); + assert_eq!(ai.num_vertices as usize, ai.normal_iter().count()); + assert!(!ai.has_texture_coords(0)); + if ai.has_vertex_colors(0) { + assert_eq!(ai.num_vertices as usize, ai.vertex_color_iter(0).count()); + } + assert!(!ai.has_texture_coords(1)); assert_eq!(ml.faces.len(), ai.num_faces as usize); for (ml, ai) in ml @@ -173,6 +229,7 @@ fn test() { assert_eq!(ml, ai); } assert_eq!(ml.vertices.len(), ai.num_vertices as usize); + assert_eq!(ml.normals.len(), ai.num_vertices as usize); for (j, (ml, ai)) in ml .vertices .iter() @@ -180,8 +237,8 @@ fn test() { .zip(ai.vertex_iter().map(|f| [f.x, f.y, f.z])) .enumerate() { + let eps = f32::EPSILON * 10.; for i in 0..ml.len() { - let eps = f32::EPSILON * 10.; let (a, b) = (ml[i], ai[i]); assert!( (a - b).abs() < eps, @@ -192,6 +249,49 @@ fn test() { ); } } + for (j, (ml, ai)) in ml + .normals + .iter() + .copied() + .zip(ai.normal_iter().map(|f| [f.x, f.y, f.z])) + .enumerate() + { + let eps = f32::EPSILON; + for i in 0..ml.len() { + let (a, b) = (ml[i], ai[i]); + assert!( + (a - b).abs() < eps, + "assertion failed: `(left !== right)` \ + (left: `{a:?}`, right: `{b:?}`, expect diff: `{eps:?}`, real diff: `{:?}`) \ + at normals[{j}][{i}]", + (a - b).abs() + ); + } + } + if ai.has_vertex_colors(0) { + assert_eq!(ml.colors[0].len(), ai.num_vertices as usize); + for (j, (ml, ai)) in ml.colors[0] + .iter() + .copied() + .zip(ai.vertex_color_iter(0).map(|f| [f.r, f.g, f.b, f.a])) + .enumerate() + { + let eps = f32::EPSILON; + for i in 0..ml.len() { + let (a, b) = (ml[i], ai[i]); + assert!( + (a - b).abs() < eps, + "assertion failed: `(left !== right)` \ + (left: `{a:?}`, right: `{b:?}`, expect diff: `{eps:?}`, real diff: `{:?}`) \ + at normals[{j}][{i}]", + (a - b).abs() + ); + assert!(a >= 0. && a <= 100.); + } + } + } else { + assert_eq!(ml.colors[0].len(), 0); + } } }