use std::collections::HashMap; use super::extrude::{BevelStyle, ExtrudeParams, Outline, extrude, group_contours}; use super::prim::{PrimParams, PrimitiveKind, mesh}; use super::triangulate::{signed_area, triangulate}; use super::*; fn fixture(name: &str) -> Vec { std::fs::read(format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"))).unwrap() } fn resolver(name: &str) -> Option> { std::fs::read(format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"))).ok() } fn check_quad(m: &Model) { assert_eq!(m.nodes.len(), 2); assert_eq!(m.roots, vec![0]); assert_eq!(m.nodes[0].children, vec![1]); assert_eq!(m.meshes.len(), 1); let p = &m.meshes[0].primitives[0]; assert_eq!(p.positions.len(), 4); assert_eq!(p.indices, vec![0, 1, 2, 0, 2, 3]); assert_eq!(p.uvs[0], [0.0, 1.0]); assert_eq!(p.normals[2], [0.0, 0.0, 1.0]); let mat = &m.materials[0]; assert_eq!(mat.name, "Painted"); assert_eq!(mat.base_color, [1.0, 0.5, 0.25, 1.0]); assert!((mat.metallic - 0.25).abs() < 1e-6 && (mat.roughness - 0.75).abs() < 1e-6); assert_eq!(mat.emissive, [0.1, 0.2, 0.3]); assert!(mat.double_sided); let t = &m.textures[mat.base_color_tex.unwrap().texture]; assert_eq!((t.width, t.height), (2, 2)); assert_eq!(t.data[0], [1.0, 0.0, 0.0, 1.0]); assert_eq!(t.data[3], [1.0, 1.0, 1.0, 1.0]); assert_eq!(t.wrap, [Wrap::Clamp, Wrap::Repeat]); // Hierarchy: scale 2 under a +1 Y translation. let (lo, hi) = m.bounds().unwrap(); assert!((lo[0] + 1.0).abs() < 1e-9 && (hi[0] - 1.0).abs() < 1e-9); assert!((lo[1] - 0.0).abs() < 1e-9 && (hi[1] - 2.0).abs() < 1e-9); // Animation: root translation (0,1,0) → (2,1,0) over 1 s. assert_eq!(m.animations.len(), 1); assert_eq!(m.animations[0].name, "Move"); assert!((m.animations[0].duration() - 1.0).abs() < 1e-9); let inst = m.instances(Some(0), 0.5); assert_eq!(inst.len(), 1); let c = inst[0].world.apply(vec3(0.0, 0.0, 0.0)); assert!((c.x - 1.0).abs() < 1e-6 && (c.y - 1.0).abs() < 1e-6, "{c:?}"); } #[test] fn gltf_with_data_uris() { let m = gltf::parse(&fixture("quad.gltf"), &|_| None).unwrap(); check_quad(&m); } #[test] fn glb_matches_gltf() { let m = load("quad.glb", &fixture("quad.glb"), &resolver).unwrap(); check_quad(&m); let g = gltf::parse(&fixture("quad.gltf"), &|_| None).unwrap(); assert_eq!(m.meshes, g.meshes); assert_eq!(m.materials, g.materials); } #[test] fn gltf_errors_are_reported() { assert!(gltf::parse(b"{\"asset\":{\"version\":\"1.0\"}}", &|_| None).is_err()); assert!(gltf::parse(b"not json", &|_| None).is_err()); let missing = br#"{"asset":{"version":"2.0"},"buffers":[{"uri":"nowhere.bin","byteLength":4}]}"#; assert!(matches!(gltf::parse(missing, &|_| None), Err(ModelError::Missing(_)))); assert!(gltf::split_glb(b"glTF\x01\0\0\0").is_err()); } #[test] fn base64_and_uri_decoding() { assert_eq!(gltf::base64_decode("aGVsbG8=").unwrap(), b"hello"); assert_eq!(gltf::base64_decode("aGVsbG8").unwrap(), b"hello"); assert_eq!(gltf::uri_decode("my%20file.bin"), "my file.bin"); } #[test] fn obj_with_mtl() { let m = load("cube.obj", &fixture("cube.obj"), &resolver).unwrap(); assert_eq!(m.materials.len(), 2); let red = &m.materials[0]; assert_eq!(red.name, "Red"); assert_eq!(red.base_color, [1.0, 0.0, 0.0, 1.0]); assert!((red.roughness - (2.0f32 / 100.0).sqrt()).abs() < 1e-6); assert_eq!(red.metallic, 0.0); let gold = &m.materials[1]; assert_eq!((gold.metallic, gold.roughness), (1.0, 0.3)); let prims = &m.meshes[0].primitives; assert_eq!(prims.len(), 2); assert_eq!(prims[0].material, Some(0)); assert_eq!(prims.iter().map(|p| p.triangle_count()).sum::(), 12); // Four unique (v, vt, vn) corners per face. assert_eq!(m.vertex_count(), 24); // Every face is wound counter-clockwise from outside (face normal agrees with vn). for p in prims { for t in p.indices.chunks(3) { let (a, b, c) = (p.positions[t[0] as usize], p.positions[t[1] as usize], p.positions[t[2] as usize]); let f = cross(sub(b, a), sub(c, a)); assert!(dot(f, p.normals[t[0] as usize]) > 0.0); } } let (lo, hi) = m.bounds().unwrap(); assert_eq!((lo, hi), ([-0.5; 3], [0.5; 3])); // V is flipped to the image's top-down rows. assert_eq!(prims[0].uvs[0], [0.0, 1.0]); } #[test] fn obj_without_normals_gets_them() { let src = b"v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n"; let m = obj::parse(src, &|_| None).unwrap(); let p = &m.meshes[0].primitives[0]; assert_eq!(p.normals, vec![[0.0, 0.0, 1.0]; 3]); assert!(obj::parse(b"v 0 0 0\n", &|_| None).is_err()); } #[test] fn channel_interpolation_modes() { let mut c = Channel { node: 0, path: ChannelPath::Translation, interp: Interp::Step, times: vec![0.0, 1.0], values: vec![0.0, 0.0, 0.0, 10.0, 0.0, 0.0] }; assert_eq!(c.sample(0.7).unwrap()[0], 0.0); c.interp = Interp::Linear; assert!((c.sample(0.7).unwrap()[0] - 7.0).abs() < 1e-9); assert_eq!(c.sample(5.0).unwrap()[0], 10.0); // Cubic spline with zero tangents: smoothstep. c.interp = Interp::CubicSpline; c.values = vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 10.0, 0.0, 0.0, 0.0, 0.0, 0.0]; assert!((c.sample(0.5).unwrap()[0] - 5.0).abs() < 1e-9); assert!((c.sample(0.25).unwrap()[0] - 10.0 * (3.0 * 0.0625 - 2.0 * 0.015625)).abs() < 1e-9); // Rotation: slerp keeps unit length, half way around Z. let r = Channel { node: 0, path: ChannelPath::Rotation, interp: Interp::Linear, times: vec![0.0, 1.0], values: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0] }; let q = r.sample(0.5).unwrap(); assert!((q[2] - (0.5f64).sqrt()).abs() < 1e-9 && (q[3] - (0.5f64).sqrt()).abs() < 1e-9); } #[test] fn skinning_moves_vertices_with_joints() { let mut p = mesh(PrimitiveKind::Plane, &PrimParams { size: [2.0, 2.0, 0.0], ..Default::default() }); p.joints = vec![[0, 0, 0, 0], [1, 0, 0, 0], [1, 0, 0, 0], [0, 0, 0, 0]]; p.weights = vec![[1.0, 0.0, 0.0, 0.0]; 4]; 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::default()); m.nodes.push(Node { translation: [0.0, 0.0, 5.0], ..Node::default() }); m.roots = vec![0, 1, 2]; m.skins.push(Skin { joints: vec![1, 2], inverse_bind: vec![Mat4::IDENTITY; 2] }); let inst = m.instances(None, 0.0); let pr = &inst[0].mesh.primitives[0]; assert_eq!(skin_point(&inst[0], pr, 0, pr.positions[0])[2], 0.0); assert_eq!(skin_point(&inst[0], pr, 1, pr.positions[1])[2], 5.0); } /// Edges of a mesh welded by position: (undirected edge → uses, directed edge → uses). fn edge_stats(p: &Primitive) -> (HashMap<(u32, u32), usize>, HashMap<(u32, u32), usize>) { let mut weld: HashMap<[u32; 3], u32> = HashMap::new(); let ids: Vec = p .positions .iter() .map(|q| { let k = q.map(|v| (v + 0.0).to_bits()); let n = weld.len() as u32; *weld.entry(k).or_insert(n) }) .collect(); let mut und = HashMap::new(); let mut dir = HashMap::new(); for t in p.indices.chunks(3) { let v = [ids[t[0] as usize], ids[t[1] as usize], ids[t[2] as usize]]; if v[0] == v[1] || v[1] == v[2] || v[0] == v[2] { continue; } for k in 0..3 { let (a, b) = (v[k], v[(k + 1) % 3]); *und.entry((a.min(b), a.max(b))).or_insert(0) += 1; *dir.entry((a, b)).or_insert(0) += 1; } } (und, dir) } fn assert_watertight(p: &Primitive) { let (und, dir) = edge_stats(p); for (e, n) in &und { assert_eq!(*n, 2, "edge {e:?} used {n} times"); } for (e, n) in &dir { assert_eq!(*n, 1, "directed edge {e:?} used {n} times (inconsistent winding)"); } } /// Signed volume (positive when faces wind outward). fn volume(p: &Primitive) -> f64 { p.indices .chunks(3) .map(|t| { let [a, b, c] = [0, 1, 2].map(|k| p.positions[t[k] as usize].map(|v| v as f64)); (a[0] * (b[1] * c[2] - b[2] * c[1]) - a[1] * (b[0] * c[2] - b[2] * c[0]) + a[2] * (b[0] * c[1] - b[1] * c[0])) / 6.0 }) .sum() } /// Vertex normals agree with the faces they belong to. fn assert_normals_outward(p: &Primitive) { for t in p.indices.chunks(3) { let (a, b, c) = (p.positions[t[0] as usize], p.positions[t[1] as usize], p.positions[t[2] as usize]); let f = cross(sub(b, a), sub(c, a)); if dot(f, f) < 1e-10 { continue; } for &i in t { let n = p.normals[i as usize]; assert!((dot(n, n) - 1.0).abs() < 1e-3, "normal not unit"); assert!(dot(f, n) > 0.0, "normal {n:?} faces away from triangle {f:?}"); } } } #[test] fn primitive_vertex_counts_and_normals() { let pr = PrimParams { segments: 16, rings: 8, ..Default::default() }; let cases = [ (PrimitiveKind::Cube, 24, 36), (PrimitiveKind::Plane, 4, 6), (PrimitiveKind::Sphere, 17 * 9, 6 * 16 * 7), (PrimitiveKind::Torus, 17 * 9, 6 * 16 * 8), (PrimitiveKind::Cylinder, 2 * 17 + 2 * 18, 6 * 16 + 2 * 3 * 16), (PrimitiveKind::Cone, 2 * 17 + 18, 3 * 16 + 3 * 16), ]; for (k, v, i) in cases { let m = mesh(k, &pr); assert_eq!((m.positions.len(), m.indices.len()), (v, i), "{k:?}"); assert_eq!(m.normals.len(), v); assert_eq!(m.uvs.len(), v); assert_eq!(m.tangents.len(), v); assert_normals_outward(&m); if k != PrimitiveKind::Plane { assert!(volume(&m) > 0.0, "{k:?} winds inward"); assert_watertight(&m); } } // Volumes approach the analytic ones. let cube = mesh(PrimitiveKind::Cube, &PrimParams { size: [2.0, 3.0, 4.0], ..Default::default() }); assert!((volume(&cube) - 24.0).abs() < 1e-4); let s = mesh(PrimitiveKind::Sphere, &PrimParams { radius: 1.0, segments: 128, rings: 64, ..Default::default() }); assert!((volume(&s) - 4.0 / 3.0 * std::f64::consts::PI).abs() < 0.01); } #[test] fn triangulation_covers_polygon_with_hole() { let outer = vec![[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [5.0, 12.0], [0.0, 10.0]]; let hole = vec![[3.0, 3.0], [3.0, 7.0], [7.0, 7.0], [7.0, 3.0]]; let pts: Vec<[f64; 2]> = outer.iter().chain(hole.iter()).copied().collect(); let tris = triangulate(&outer, std::slice::from_ref(&hole)); let area: f64 = tris.iter().map(|t| signed_area(&[pts[t[0]], pts[t[1]], pts[t[2]]]) * 0.5).sum(); let expect = signed_area(&outer).abs() * 0.5 - 16.0; assert!((area - expect).abs() < 1e-9, "{area} vs {expect}"); assert!(tris.iter().all(|t| signed_area(&[pts[t[0]], pts[t[1]], pts[t[2]]]) >= 0.0)); // A concave "C". let c = vec![[0.0, 0.0], [6.0, 0.0], [6.0, 2.0], [2.0, 2.0], [2.0, 4.0], [6.0, 4.0], [6.0, 6.0], [0.0, 6.0]]; let tris = triangulate(&c, &[]); let area: f64 = tris.iter().map(|t| signed_area(&[c[t[0]], c[t[1]], c[t[2]]]) * 0.5).sum(); assert!((area - 28.0).abs() < 1e-9); } fn ring_outline() -> Outline { let circle = |r: f64, n: usize| -> Vec<[f64; 2]> { (0..n).map(|i| (i as f64 / n as f64) * std::f64::consts::TAU).map(|a| [r * a.cos(), r * a.sin()]).collect() }; Outline { outer: circle(100.0, 48), holes: vec![circle(50.0, 32)] } } #[test] fn extrusion_is_watertight_with_outward_normals() { let square = Outline { outer: vec![[0.0, 0.0], [100.0, 0.0], [100.0, 100.0], [0.0, 100.0]], holes: vec![] }; for bevel in BevelStyle::ALL { for o in [square.clone(), ring_outline()] { let p = ExtrudeParams { bevel, bevel_depth: 8.0, hole_bevel: 0.5, depth: 40.0 }; let m = extrude(std::slice::from_ref(&o), &p); assert_watertight(&m); assert_normals_outward(&m); assert!(volume(&m) > 0.0, "{bevel:?}: inward winding"); let (lo, hi) = m.bounds().unwrap(); assert!(lo[2].abs() < 1e-6 && (hi[2] - 40.0).abs() < 1e-4); } } // Without a bevel the square block's volume is exact. let m = extrude(&[square], &ExtrudeParams { bevel: BevelStyle::None, bevel_depth: 0.0, hole_bevel: 1.0, depth: 10.0 }); assert!((volume(&m) - 100_000.0).abs() < 1e-2); // Front cap faces the camera (−z), back cap away. assert!(m.normals.iter().any(|n| n[2] < -0.99) && m.normals.iter().any(|n| n[2] > 0.99)); } #[test] fn bevels_shrink_the_caps() { let square = Outline { outer: vec![[0.0, 0.0], [100.0, 0.0], [100.0, 100.0], [0.0, 100.0]], holes: vec![] }; let m = extrude(std::slice::from_ref(&square), &ExtrudeParams { bevel: BevelStyle::Angular, bevel_depth: 10.0, hole_bevel: 1.0, depth: 50.0 }); // Front cap vertices (z = 0) are inset by the bevel. let front: Vec<_> = m.positions.iter().filter(|p| p[2] == 0.0).collect(); assert!(front.iter().all(|p| p[0] >= 9.999 && p[0] <= 90.001)); // The widest ring (the walls) still reaches the outline. assert!(m.positions.iter().any(|p| p[0] == 0.0 && p[2] == 10.0)); // Convex bevels have more profile steps than angular ones. let c = extrude(std::slice::from_ref(&square), &ExtrudeParams { bevel: BevelStyle::Convex, bevel_depth: 10.0, hole_bevel: 1.0, depth: 50.0 }); assert!(c.positions.len() > m.positions.len()); } #[test] fn contours_group_into_outlines_by_nesting() { let sq = |x: f64, y: f64, s: f64| vec![[x, y], [x + s, y], [x + s, y + s], [x, y + s]]; // "O" (outer + hole) next to an island inside another hole. let g = group_contours(vec![sq(0.0, 0.0, 10.0), sq(2.0, 2.0, 6.0), sq(20.0, 0.0, 10.0), sq(22.0, 2.0, 6.0), sq(24.0, 4.0, 2.0)]); assert_eq!(g.len(), 3); assert_eq!(g.iter().map(|o| o.holes.len()).sum::(), 2); } #[test] fn gltf_cameras_and_punctual_lights() { let doc = r#"{ "asset": {"version": "2.0"}, "scene": 0, "scenes": [{"nodes": [0, 2]}], "nodes": [ {"name": "Rig", "translation": [0, 0, 10], "children": [1]}, {"name": "Cam", "camera": 0, "translation": [1, 0, 0]}, {"name": "Sun", "rotation": [-0.7071068, 0, 0, 0.7071068], "extensions": {"KHR_lights_punctual": {"light": 1}}}, {"name": "Orphan", "camera": 1} ], "cameras": [ {"type": "perspective", "name": "Shot", "perspective": {"yfov": 0.5, "znear": 0.1, "aspectRatio": 1.5}}, {"type": "orthographic", "orthographic": {"xmag": 2, "ymag": 1, "znear": 0, "zfar": 10}} ], "extensions": {"KHR_lights_punctual": {"lights": [ {"type": "spot", "name": "Key", "color": [1, 0.5, 0.25], "intensity": 3, "spot": {"innerConeAngle": 0.2, "outerConeAngle": 0.4}}, {"type": "directional", "intensity": 2} ]}} }"#; let m = crate::gltf::parse(doc.as_bytes(), &|_| None).unwrap(); assert_eq!(m.cameras.len(), 2); assert_eq!(m.cameras[0].name, "Shot"); assert_eq!(m.cameras[0].projection, CameraProjection::Perspective { yfov: 0.5, aspect: Some(1.5), znear: 0.1 }); assert_eq!(m.cameras[1].projection, CameraProjection::Orthographic { xmag: 2.0, ymag: 1.0 }); assert_eq!(m.lights.len(), 2); assert_eq!(m.lights[0].kind, ModelLightKind::Spot { inner: 0.2, outer: 0.4 }); assert_eq!(m.lights[0].color, [1.0, 0.5, 0.25]); assert_eq!(m.lights[1].kind, ModelLightKind::Directional); assert_eq!(m.lights[1].name, "Light 2"); // Only nodes of the scene are placed; the camera node inherits its parent's translation. let cams = m.placed(false, None, 0.0); assert_eq!(cams.len(), 1); assert_eq!((cams[0].index, cams[0].node), (0, 1)); let eye = cams[0].world.apply(vec3(0.0, 0.0, 0.0)); assert!((eye.x - 1.0).abs() < 1e-9 && (eye.z - 10.0).abs() < 1e-9, "{eye:?}"); let lights = m.placed(true, None, 0.0); assert_eq!(lights.len(), 1); // −90° about X: the light's −Z axis points down (−Y in glTF). let d = lights[0].world.apply_vec(vec3(0.0, 0.0, -1.0)); assert!((d.y + 1.0).abs() < 1e-6, "{d:?}"); } #[test] fn stacked_extrusions_move_towards_the_camera() { let square = Outline { outer: vec![[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]], holes: vec![] }; let p = ExtrudeParams { bevel: BevelStyle::None, bevel_depth: 0.0, hole_bevel: 1.0, depth: 10.0 }; let base = extrude(std::slice::from_ref(&square), &p); let up = super::extrude::extrude_stacked(std::slice::from_ref(&square), &p, 3); assert_eq!(base.indices, up.indices); for (a, b) in base.positions.iter().zip(&up.positions) { assert!((a[2] - b[2] - 3.0 * super::extrude::STACK_GAP as f32).abs() < 1e-6); assert_eq!((a[0], a[1]), (b[0], b[1])); } }