mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-26 11:07:54 -06:00
New crate effectcraft-model (L2): glTF 2.0 (.gltf/.glb, data URIs, external buffers and images, PBR metallic-roughness with base/MR/normal/occlusion/emissive textures, node hierarchy, skins, step/linear/cubic animations) and OBJ/MTL import, parametric primitives, extruded/bevelled outline meshes and polygon triangulation with holes. Project: FootageKind::Model, LayerSource::Model/Primitive, Layer::environment, Geometry and Material Options builders, Environment light Source. Media probes and loads models (FootageSource::model). Engine: layer.newModel, layer.new3dPrimitive/newPrimitive, comp.renderer, layer.environment, material.set/reset/duplicateAssign/revealSource (stubs replaced); File > Import and layer.addItem pick up models. Hand-written fixtures with attribution sidecars. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
319 lines
14 KiB
Rust
319 lines
14 KiB
Rust
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<u8> {
|
||
std::fs::read(format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"))).unwrap()
|
||
}
|
||
|
||
fn resolver(name: &str) -> Option<Vec<u8>> {
|
||
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::<usize>(), 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<u32> = 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::<usize>(), 2);
|
||
}
|