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 extrusions_split_into_front_bevel_side_and_back() { use super::extrude::{Surface, extrude_surfaces, extrude_surfaces_stacked}; let square = Outline { outer: vec![[0.0, 0.0], [100.0, 0.0], [100.0, 100.0], [0.0, 100.0]], holes: vec![] }; let p = ExtrudeParams { bevel: BevelStyle::Angular, bevel_depth: 10.0, hole_bevel: 1.0, depth: 50.0 }; let whole = extrude(std::slice::from_ref(&square), &p); let parts = extrude_surfaces(std::slice::from_ref(&square), &p); // The parts hold exactly the triangles of the whole mesh. assert_eq!(parts.iter().map(|m| m.triangle_count()).sum::(), whole.triangle_count()); let [front, bevel, side, back] = &parts; // Caps: flat, at the layer plane (front, facing −z) and at the extrusion depth (back, +z). assert!(front.positions.iter().all(|q| q[2] == 0.0) && front.normals.iter().all(|n| n[2] < -0.99)); assert!(back.positions.iter().all(|q| q[2] == 50.0) && back.normals.iter().all(|n| n[2] > 0.99)); // Walls: the full outline, between the bevels (z 10..40), with sideways normals. assert!(side.positions.iter().all(|q| (10.0..=40.0).contains(&q[2])) && side.normals.iter().all(|n| n[2].abs() < 1e-6)); assert!(side.positions.iter().any(|q| q[0] == 0.0)); // Bevels: slanted between the caps and the walls, front and back. assert!(bevel.triangle_count() > 0 && bevel.normals.iter().all(|n| n[2].abs() > 0.1 && n[2].abs() < 0.99)); assert!(bevel.positions.iter().any(|q| q[2] == 0.0) && bevel.positions.iter().any(|q| q[2] == 50.0)); assert_eq!(Surface::ALL.map(Surface::index), [0, 1, 2, 3]); // Without a bevel there is nothing on the Bevel surface. let flat = extrude_surfaces(std::slice::from_ref(&square), &ExtrudeParams { bevel: BevelStyle::None, ..p }); assert_eq!(flat[Surface::Bevel.index()].triangle_count(), 0); assert!(flat[Surface::Side.index()].triangle_count() > 0); // Stacked like `extrude_stacked`. let up = extrude_surfaces_stacked(std::slice::from_ref(&square), &p, 2); assert!(up[0].positions.iter().all(|q| (q[2] + 2.0 * super::extrude::STACK_GAP as f32).abs() < 1e-6)); } #[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])); } } /// A binary STL: `header` (padded to 80 bytes) and facets of (normal, vertices, attribute). fn binary_stl(header: &[u8], facets: &[([f32; 3], [[f32; 3]; 3], u16)]) -> Vec { let mut b = header.to_vec(); b.resize(80, 0); b.extend((facets.len() as u32).to_le_bytes()); for (n, v, attr) in facets { for c in n.iter().chain(v.iter().flatten()) { b.extend(c.to_le_bytes()); } b.extend(attr.to_le_bytes()); } b } /// The unit square in the XY plane (Z-up file), facing +Z, as two facets. const SQUARE: [[[f32; 3]; 3]; 2] = [[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0]], [[0.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]]]; #[test] fn stl_binary_is_faceted_and_turned_y_up() { // A header that begins with `solid`, as many binary exporters write it. let bytes = binary_stl(b"solid part", &[([0.0, 0.0, 1.0], SQUARE[0], 0), ([0.0, 0.0, 1.0], SQUARE[1], 0)]); let m = load("part.STL", &bytes, &|_| None).unwrap(); assert_eq!(format_of("part.STL"), Some("stl")); assert_eq!(m.meshes[0].name, "part"); assert!(m.materials.is_empty()); let p = &m.meshes[0].primitives[0]; assert_eq!((p.triangle_count(), p.positions.len()), (2, 6)); assert!(p.uvs.is_empty()); // Z-up +Z becomes Y-up +Y; file Y becomes −Z. assert_eq!(p.normals, vec![[0.0, 1.0, 0.0]; 6]); assert_eq!(p.positions[2], [1.0, 0.0, -1.0]); // Counter-clockwise from outside after the turn. let (a, b, c) = (p.positions[0], p.positions[1], p.positions[2]); assert!(dot(cross(sub(b, a), sub(c, a)), p.normals[0]) > 0.0); // Trailing bytes after the last facet are ignored. let mut longer = bytes.clone(); longer.extend([0u8; 7]); assert_eq!(stl::parse(&longer).unwrap().meshes, m.meshes); } #[test] fn stl_ascii_with_polygon_loops_and_several_solids() { let src = "solid Bracket\n facet normal 0 0 1\n outer loop\n vertex 0 0 0\n vertex 1 0 0\n vertex 1 1 0\n vertex 0 1 0\n endloop\n endfacet\nendsolid Bracket\n\ solid second\nfacet normal 0 0 0\nouter loop\nvertex 0 0 1\nvertex 1 0 1\nvertex 1E0 1 1\nendloop\nendfacet\nendsolid\n"; let m = load("bracket.stl", src.as_bytes(), &|_| None).unwrap(); assert_eq!(m.meshes[0].name, "Bracket"); // The quad fans into two triangles; the second solid adds one. assert_eq!(m.triangle_count(), 3); let (lo, hi) = m.bounds().unwrap(); assert_eq!((lo, hi), ([0.0, 0.0, -1.0], [1.0, 1.0, 0.0])); assert!(stl::parse(b"solid x\nfacet normal 0 0 1\nouter loop\nvertex 0 zero 0\n").is_err()); } #[test] fn stl_binary_colours() { // VisCAM / SolidView: bit 15 set, red in bits 10–14. let red = 0x8000 | (31 << 10); let m = stl::parse(&binary_stl(b"", &[([0.0; 3], SQUARE[0], red), ([0.0; 3], SQUARE[1], 0)])).unwrap(); assert_eq!(m.meshes[0].primitives.len(), 2); assert_eq!(m.materials.len(), 2); assert!(m.materials[0].base_color.iter().zip([1.0, 0.0, 0.0, 1.0]).all(|(x, y)| (x - y).abs() < 1e-5)); assert_eq!(m.materials[0].metallic, 0.0); assert_eq!(m.meshes[0].primitives[1].material, Some(1)); // Materialise Magics: a part colour in the header; bit 15 clear = the facet's own colour // with red in bits 0–4. let mut header = b"COLOR=".to_vec(); header.extend([0, 255, 0, 255]); let m = stl::parse(&binary_stl(&header, &[([0.0; 3], SQUARE[0], 31), ([0.0; 3], SQUARE[1], 0x8000)])).unwrap(); let close = |a: [f32; 4], b: [f32; 4]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5); assert!(close(m.materials[0].base_color, [1.0, 0.0, 0.0, 1.0]), "{:?}", m.materials[0]); assert!(close(m.materials[1].base_color, [0.0, 1.0, 0.0, 1.0]), "{:?}", m.materials[1]); } #[test] fn stl_hostile_input_is_an_error() { // Claims four billion facets but has one: truncated. let mut bytes = binary_stl(b"", &[([0.0; 3], SQUARE[0], 0)]); bytes.splice(80..84, u32::MAX.to_le_bytes()); assert!(stl::parse(&bytes).is_err()); assert!(stl::parse(b"").is_err()); assert!(stl::parse(&[0u8; 83]).is_err()); // No facets, or only non-finite ones. assert!(stl::parse(&binary_stl(b"", &[])).is_err()); assert!(stl::parse(&binary_stl(b"", &[([0.0; 3], [[f32::NAN, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], 0)])).is_err()); assert!(stl::parse(b"solid empty\nendsolid empty\n").is_err()); // A degenerate facet keeps its stored normal. let m = stl::parse(&binary_stl(b"", &[([1.0, 0.0, 0.0], [[0.0; 3]; 3], 0)])).unwrap(); assert_eq!(m.meshes[0].primitives[0].normals[0], [1.0, 0.0, 0.0]); } /// A one-triangle glTF whose POSITION is accessor 0, over a buffer of `bytes` zero bytes. fn gltf_doc(accessors: &str, views: &str, bytes: usize) -> String { let b64 = "A".repeat(bytes / 3 * 4) + ["", "AA==", "AAA="][bytes % 3]; format!( r#"{{"asset": {{"version": "2.0"}}, "scenes": [{{"nodes": [0]}}], "nodes": [{{"mesh": 0}}], "meshes": [{{"primitives": [{{"attributes": {{"POSITION": 0}}}}]}}], "buffers": [{{"byteLength": {bytes}, "uri": "data:application/octet-stream;base64,{b64}"}}], "bufferViews": [{views}], "accessors": [{accessors}]}}"# ) } #[test] fn gltf_accessor_counts_are_checked_against_the_buffer_before_allocating() { // Untrusted counts, offsets and strides must fail the import, not allocate from the count or // overflow (#1235). let view = r#"{"buffer": 0, "byteLength": 36}"#; let cases = [ // A count far beyond the 36-byte buffer. (r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 4000000000}"#, view, "past its buffer"), // count * components overflows. (r#"{"bufferView": 0, "componentType": 5126, "type": "MAT4", "count": 9223372036854775807}"#, view, "too large"), // Offsets that overflow when added. ( r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3, "byteOffset": 16}"#, r#"{"buffer": 0, "byteOffset": 18446744073709551610}"#, "past its buffer", ), // A stride that overflows with the count. (r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3000000000}"#, r#"{"buffer": 0, "byteStride": 252}"#, "past its buffer"), // A stride smaller than an element would re-read the same bytes for a huge count. (r#"{"bufferView": 0, "componentType": 5126, "type": "MAT4", "count": 30}"#, r#"{"buffer": 0, "byteStride": 1}"#, "smaller than"), // No buffer view: nothing backs the count, so it is capped. (r#"{"componentType": 5126, "type": "VEC3", "count": 1000000000}"#, view, "without a bufferView"), ]; let t0 = std::time::Instant::now(); for (acc, v, want) in cases { let doc = gltf_doc(acc, v, 36); let e = crate::gltf::parse(doc.as_bytes(), &|_| None).err().unwrap_or_else(|| panic!("{acc} imported")); assert!(e.to_string().contains(want), "{acc}: {e}"); } assert!(t0.elapsed() < std::time::Duration::from_secs(10), "{:?}", t0.elapsed()); } #[test] fn gltf_integer_accessors_are_checked_too() { let doc = gltf_doc(r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3}"#, r#"{"buffer": 0, "byteLength": 36}"#, 36) .replace(r#""attributes": {"POSITION": 0}"#, r#""attributes": {"POSITION": 0}, "indices": 1"#); for idx in [ r#"{"bufferView": 0, "componentType": 5125, "type": "SCALAR", "count": 4000000000}"#, r#"{"componentType": 5125, "type": "SCALAR", "count": 4000000000}"#, r#"{"bufferView": 0, "componentType": 5125, "type": "VEC4", "count": 9223372036854775807}"#, ] { let doc = doc.replace(r#""count": 3}]"#, &format!(r#""count": 3}}, {idx}]"#)); assert!(crate::gltf::parse(doc.as_bytes(), &|_| None).is_err(), "{idx}"); } } #[test] fn gltf_strided_and_buffer_less_accessors_still_import() { // Interleaved positions (stride 24) ending exactly at the buffer's end, and a small all-zero // accessor without a view. let doc = gltf_doc(r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3}"#, r#"{"buffer": 0, "byteStride": 24}"#, 60); let m = crate::gltf::parse(doc.as_bytes(), &|_| None).unwrap(); assert_eq!(m.meshes[0].primitives[0].positions.len(), 3); let doc = gltf_doc(r#"{"componentType": 5126, "type": "VEC3", "count": 6}"#, r#"{"buffer": 0}"#, 3); let m = crate::gltf::parse(doc.as_bytes(), &|_| None).unwrap(); assert_eq!(m.meshes[0].primitives[0].positions.len(), 6); // One byte short of the third element is an error. let doc = gltf_doc(r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3}"#, r#"{"buffer": 0, "byteStride": 24}"#, 59); assert!(crate::gltf::parse(doc.as_bytes(), &|_| None).is_err()); }