effectcraft/crates/render/src/three_d/tests.rs

544 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use effectcraft_color::Label;
use effectcraft_geom::{Vec3, vec3};
use effectcraft_keyframe::Value;
use effectcraft_project::build;
use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, LightKind, Project, Solid};
use effectcraft_time::{FrameRate, Tick};
use super::camera::{Rig, View3D, default_view_cam, orientation_for};
use super::*;
use crate::{NoFootage, RenderOpts, Renderer, render_frame};
const W: u32 = 200;
const H: u32 = 100;
fn setup() -> (Project, ItemId, Comp) {
let mut p = Project::default();
// Exact float maths; 8/16 bpc quantisation has its own tests (tests_color).
p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32;
let comp = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
(p, cid, comp)
}
/// A 3D solid of `w`×`h` centred at `pos`.
fn solid3(p: &mut Project, comp: &Comp, color: [f32; 3], w: u32, h: u32, pos: [f64; 3]) -> Layer {
let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 }));
let mut l = build::layer(p, comp, "Solid", LayerSource::Solid { item: sid }, (w, h), None);
l.switches.three_d = true;
set(&mut l, "transform/position", Value::Vec3(pos));
l
}
fn set(l: &mut Layer, path: &str, v: Value) {
l.props.prop_mut(path).unwrap_or_else(|| panic!("no {path}")).value = v;
}
fn push(p: &mut Project, cid: ItemId, l: Layer) {
// Appended = lowest in the stack.
p.comp_mut(cid).unwrap().layers.push(l);
}
fn render(p: &Project, cid: ItemId) -> effectcraft_raster::Image {
render_frame(p, cid, Tick::ZERO, 1.0)
}
fn close(a: f32, b: f32, eps: f32) -> bool {
(a - b).abs() <= eps
}
fn zoom() -> f64 {
W as f64 * 50.0 / 36.0
}
// ---------------------------------------------------------------- projection
#[test]
fn z0_plane_matches_2d() {
let (mut p, cid, comp) = setup();
let l = solid3(&mut p, &comp, [1.0, 0.0, 0.0], 40, 20, [50.0, 30.0, 0.0]);
push(&mut p, cid, l);
let img = render(&p, cid);
// Covers x 30..70, y 20..40.
assert_eq!(img.get(31, 21), [1.0, 0.0, 0.0, 1.0]);
assert_eq!(img.get(68, 38), [1.0, 0.0, 0.0, 1.0]);
assert_eq!(img.get(29, 30)[3], 0.0);
assert_eq!(img.get(71, 30)[3], 0.0);
}
#[test]
fn farther_layers_project_smaller() {
let (mut p, cid, comp) = setup();
// At z = zoom the plane is twice as far from the eye: half size around the comp centre.
let l = solid3(&mut p, &comp, [0.0, 1.0, 0.0], 80, 40, [100.0, 50.0, zoom()]);
push(&mut p, cid, l);
let img = render(&p, cid);
// 40×20 centred on (100, 50): x 80..120, y 40..60.
assert!(img.get(82, 42)[3] > 0.99);
assert!(img.get(117, 57)[3] > 0.99);
assert!(img.get(77, 50)[3] < 0.01, "{:?}", img.get(77, 50));
assert!(img.get(100, 37)[3] < 0.01);
}
#[test]
fn projection_math_matches_camera_state() {
let cam = default_camera(1920.0, 1080.0);
let pr = cam.projection(1920.0, 1080.0);
let p = pr.apply(vec3(100.0, 200.0, 0.0));
assert!((p.x - 100.0).abs() < 1e-6 && (p.y - 200.0).abs() < 1e-6);
// A point at depth 2·zoom maps halfway to the centre.
let q = cam.project(1920.0, 1080.0, vec3(0.0, 0.0, cam.zoom)).unwrap();
assert!((q.x - 480.0).abs() < 1e-6 && (q.y - 270.0).abs() < 1e-6, "{q:?}");
assert!(cam.project(1920.0, 1080.0, vec3(960.0, 540.0, -cam.zoom - 10.0)).is_none());
}
#[test]
fn y_rotation_foreshortens_with_correct_perspective() {
let (mut p, cid, comp) = setup();
let mut l = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 100, 60, [100.0, 50.0, 0.0]);
set(&mut l, "transform/rotationY", Value::Scalar(60.0));
push(&mut p, cid, l);
let img = render(&p, cid);
// cos 60° = 0.5: roughly half as wide; the near edge (right, z<0) is taller than the far edge.
assert!(img.get(100, 50)[3] > 0.99);
assert!(img.get(140, 50)[3] < 0.01);
let col_h = |x: u32| (0..H).filter(|&y| img.get(x as i64, y as i64)[3] > 0.5).count();
assert!(col_h(122) > col_h(78), "near edge {} vs far edge {}", col_h(122), col_h(78));
}
#[test]
fn layer_behind_camera_is_clipped() {
let (mut p, cid, comp) = setup();
let l = solid3(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100, [100.0, 50.0, -zoom() - 50.0]);
push(&mut p, cid, l);
let img = render(&p, cid);
assert!(img.data.iter().all(|px| px[3] == 0.0));
}
// ---------------------------------------------------------------- depth ordering
#[test]
fn nearer_layer_wins_regardless_of_stack_order() {
let (mut p, cid, comp) = setup();
let far = solid3(&mut p, &comp, [1.0, 0.0, 0.0], 600, 300, [100.0, 50.0, 200.0]);
let near = solid3(&mut p, &comp, [0.0, 1.0, 0.0], 40, 40, [100.0, 50.0, 0.0]);
// Far layer is on top of the stack.
push(&mut p, cid, far);
push(&mut p, cid, near);
let img = render(&p, cid);
assert_eq!(img.get(100, 50), [0.0, 1.0, 0.0, 1.0]);
assert_eq!(img.get(5, 50)[0], 1.0);
}
#[test]
fn coplanar_layers_keep_stack_order() {
let (mut p, cid, comp) = setup();
let top = solid3(&mut p, &comp, [1.0, 0.0, 0.0], 40, 40, [100.0, 50.0, 30.0]);
let bottom = solid3(&mut p, &comp, [0.0, 0.0, 1.0], 80, 80, [100.0, 50.0, 30.0]);
push(&mut p, cid, top);
push(&mut p, cid, bottom);
let img = render(&p, cid);
assert_eq!(img.get(100, 50), [1.0, 0.0, 0.0, 1.0]);
}
#[test]
fn intersecting_planes_split_per_pixel() {
let (mut p, cid, comp) = setup();
let flat = solid3(&mut p, &comp, [1.0, 0.0, 0.0], 160, 80, [100.0, 50.0, 0.0]);
let mut tilted = solid3(&mut p, &comp, [0.0, 1.0, 0.0], 160, 80, [100.0, 50.0, 0.0]);
// Rotated about Y: its right half comes towards the camera, its left half goes behind.
set(&mut tilted, "transform/rotationY", Value::Scalar(40.0));
push(&mut p, cid, tilted);
push(&mut p, cid, flat);
let img = render(&p, cid);
assert_eq!(img.get(125, 50), [0.0, 1.0, 0.0, 1.0], "right: tilted plane in front");
assert_eq!(img.get(75, 50), [1.0, 0.0, 0.0, 1.0], "left: flat plane in front");
}
#[test]
fn two_d_layer_breaks_3d_group() {
let (mut p, cid, comp) = setup();
let far_top = solid3(&mut p, &comp, [1.0, 0.0, 0.0], 60, 60, [100.0, 50.0, 300.0]);
let sid = p.add_item("S2", Label::Red, None, ItemKind::Solid(Solid { color: [0.0, 0.0, 1.0], width: 20, height: 100, pixel_aspect: 1.0 }));
let mut two_d = build::layer(&mut p, &comp, "2D", LayerSource::Solid { item: sid }, (20, 100), None);
set(&mut two_d, "transform/position", Value::Vec3([100.0, 50.0, 0.0]));
let near_bottom = solid3(&mut p, &comp, [0.0, 1.0, 0.0], 200, 100, [100.0, 50.0, 0.0]);
// Stack: far 3D (top), 2D, near 3D (bottom). The far layer is a separate group above the
// 2D layer, so it draws over everything even though it is farther away.
push(&mut p, cid, far_top);
push(&mut p, cid, two_d);
push(&mut p, cid, near_bottom);
let img = render(&p, cid);
assert_eq!(img.get(100, 50), [1.0, 0.0, 0.0, 1.0]);
assert_eq!(img.get(5, 5), [0.0, 1.0, 0.0, 1.0]);
}
// ---------------------------------------------------------------- lighting
fn light(p: &mut Project, comp: &Comp, kind: LightKind, pos: [f64; 3]) -> Layer {
let mut l = build::layer(p, comp, "Light", LayerSource::Light { kind }, (W, H), None);
if kind != LightKind::Ambient {
set(&mut l, "transform/position", Value::Vec3(pos));
}
l
}
fn no_specular(l: &mut Layer) {
set(l, "materialOptions/specularIntensity", Value::Scalar(0.0));
}
#[test]
fn head_on_point_light_scales_by_diffuse() {
let (mut p, cid, comp) = setup();
let mut s = solid3(&mut p, &comp, [1.0, 0.8, 0.6], 200, 100, [100.0, 50.0, 0.0]);
no_specular(&mut s);
let l = light(&mut p, &comp, LightKind::Point, [100.0, 50.0, -500.0]);
push(&mut p, cid, l);
push(&mut p, cid, s);
let img = render(&p, cid);
let c = img.get(100, 50);
// Diffuse 50% · N·L = 1 · intensity 100%.
assert!(close(c[0], 0.5, 1e-3) && close(c[1], 0.4, 1e-3) && close(c[2], 0.3, 1e-3), "{c:?}");
// Off-axis pixels are lit at a grazing angle → darker.
assert!(img.get(2, 2)[0] < c[0]);
}
#[test]
fn ambient_light_uses_ambient_coefficient() {
let (mut p, cid, comp) = setup();
let mut s = solid3(&mut p, &comp, [0.8, 0.8, 0.8], 200, 100, [100.0, 50.0, 0.0]);
set(&mut s, "materialOptions/ambient", Value::Scalar(50.0));
let mut l = light(&mut p, &comp, LightKind::Ambient, [0.0; 3]);
set(&mut l, "lightOptions/intensity", Value::Scalar(50.0));
push(&mut p, cid, l);
push(&mut p, cid, s);
let c = render(&p, cid).get(10, 10);
assert!(close(c[0], 0.8 * 0.5 * 0.5, 1e-4), "{c:?}");
}
#[test]
fn lights_off_or_not_accepting_lights_render_unlit() {
let (mut p, cid, comp) = setup();
let mut s = solid3(&mut p, &comp, [0.3, 0.6, 0.9], 200, 100, [100.0, 50.0, 0.0]);
set(&mut s, "materialOptions/acceptsLights", Value::Bool(false));
let l = light(&mut p, &comp, LightKind::Point, [100.0, 50.0, -500.0]);
push(&mut p, cid, l);
push(&mut p, cid, s);
let c = render(&p, cid).get(100, 50);
assert!(close(c[0], 0.3, 1e-5) && close(c[2], 0.9, 1e-5), "{c:?}");
}
#[test]
fn light_behind_layer_needs_transmission() {
let (mut p, cid, comp) = setup();
let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 200, 100, [100.0, 50.0, 0.0]);
no_specular(&mut s);
let l = light(&mut p, &comp, LightKind::Point, [100.0, 50.0, 500.0]);
push(&mut p, cid, l);
push(&mut p, cid, s.clone());
assert!(render(&p, cid).get(100, 50)[0] < 1e-4);
// 100% transmission passes the light through the layer.
let c = p.comp_mut(cid).unwrap();
set(&mut c.layers[1], "materialOptions/lightTransmission", Value::Scalar(100.0));
let v = render(&p, cid).get(100, 50)[0];
assert!(close(v, 0.5, 1e-3), "{v}");
}
#[test]
fn specular_highlight_adds_light() {
let (mut p, cid, comp) = setup();
let s = solid3(&mut p, &comp, [0.5, 0.5, 0.5], 200, 100, [100.0, 50.0, 0.0]);
let l = light(&mut p, &comp, LightKind::Point, [100.0, 50.0, -zoom()]);
push(&mut p, cid, l);
push(&mut p, cid, s);
let c = render(&p, cid).get(100, 50);
// 0.5·0.5 diffuse + 0.5 specular (N·H = 1) tinted by the layer colour (Metal 100%) = 0.5.
assert!(close(c[0], 0.25 + 0.25, 2e-3), "{c:?}");
}
#[test]
fn spot_cone_limits_light() {
let (mut p, cid, comp) = setup();
let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 200, 100, [100.0, 50.0, 0.0]);
no_specular(&mut s);
let mut l = light(&mut p, &comp, LightKind::Spot, [100.0, 50.0, -200.0]);
set(&mut l, "transform/poi", Value::Vec3([100.0, 50.0, 0.0]));
set(&mut l, "lightOptions/coneAngle", Value::Scalar(20.0));
set(&mut l, "lightOptions/coneFeather", Value::Scalar(0.0));
push(&mut p, cid, l);
push(&mut p, cid, s);
let img = render(&p, cid);
assert!(img.get(100, 50)[0] > 0.45);
// tan(10°)·200 ≈ 35 px radius.
assert!(img.get(160, 50)[0] < 1e-4);
}
#[test]
fn falloff_inverse_square_clamped() {
let mut ls = LightState::from_layer(&crate::EvalCtx::new(&Project::default(), ItemId(0), &Comp::new(W, H, FrameRate::FPS_30, Tick::ZERO), Tick::ZERO), &{
let (mut p, _, comp) = setup();
light(&mut p, &comp, LightKind::Point, [0.0, 0.0, 0.0])
})
.unwrap();
ls.falloff = 2;
ls.radius = 100.0;
assert!((ls.attenuation(vec3(50.0, 0.0, 0.0)) - 1.0).abs() < 1e-12);
assert!((ls.attenuation(vec3(200.0, 0.0, 0.0)) - 0.25).abs() < 1e-12);
ls.falloff = 1;
ls.falloff_distance = 100.0;
assert!((ls.attenuation(vec3(150.0, 0.0, 0.0)) - 0.5).abs() < 1e-12);
assert_eq!(ls.attenuation(vec3(250.0, 0.0, 0.0)), 0.0);
}
// ---------------------------------------------------------------- shadows
#[test]
fn caster_shadows_receiver() {
let (mut p, cid, comp) = setup();
let mut recv = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 200, 100, [100.0, 50.0, 100.0]);
no_specular(&mut recv);
let mut caster = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 20, 20, [60.0, 50.0, 0.0]);
no_specular(&mut caster);
set(&mut caster, "materialOptions/castsShadows", Value::Enum(1));
// Parallel light straight along +z: the shadow lands right behind the caster.
let mut l = light(&mut p, &comp, LightKind::Parallel, [100.0, 50.0, -500.0]);
set(&mut l, "transform/poi", Value::Vec3([100.0, 50.0, 0.0]));
set(&mut l, "lightOptions/castsShadows", Value::Bool(true));
push(&mut p, cid, l);
push(&mut p, cid, caster);
push(&mut p, cid, recv);
let img = render(&p, cid);
// Lit receiver away from the shadow: 0.5.
assert!(close(img.get(150, 50)[0], 0.5, 1e-3));
// Light from the left (direction (300, 0, 500)): the shadow shifts +60 px at the receiver's
// depth, to world x 110..130 → screen ≈ 107..122.
let c = p.comp_mut(cid).unwrap();
set(&mut c.layers[0], "transform/position", Value::Vec3([100.0 - 300.0, 50.0, -500.0]));
let img = render(&p, cid);
let lit = img.get(150, 50)[0];
let expect = 0.5 * (500.0 / (300.0f64 * 300.0 + 500.0 * 500.0).sqrt()) as f32;
assert!(close(lit, expect, 1e-3), "lit {lit} vs {expect}");
assert!(img.get(115, 50)[0] < 1e-3, "shadowed {:?}", img.get(115, 50));
assert!(close(img.get(60, 50)[0], expect, 1e-3), "caster itself is lit");
}
#[test]
fn shadow_darkness_and_accepts_off() {
let (mut p, cid, comp) = setup();
let mut recv = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 400, 200, [100.0, 50.0, 100.0]);
no_specular(&mut recv);
let mut caster = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 400, 200, [100.0, 50.0, 0.0]);
set(&mut caster, "materialOptions/castsShadows", Value::Enum(2)); // Only: invisible caster
let mut l = light(&mut p, &comp, LightKind::Parallel, [100.0, 50.0, -500.0]);
set(&mut l, "transform/poi", Value::Vec3([100.0, 50.0, 0.0]));
set(&mut l, "lightOptions/castsShadows", Value::Bool(true));
set(&mut l, "lightOptions/shadowDarkness", Value::Scalar(50.0));
push(&mut p, cid, l);
push(&mut p, cid, caster);
push(&mut p, cid, recv);
let v = render(&p, cid).get(100, 50)[0];
assert!(close(v, 0.25, 1e-3), "half-dark shadow: {v}");
let c = p.comp_mut(cid).unwrap();
set(&mut c.layers[2], "materialOptions/acceptsShadows", Value::Enum(0));
let v = render(&p, cid).get(100, 50)[0];
assert!(close(v, 0.5, 1e-3), "no shadow accepted: {v}");
}
// ---------------------------------------------------------------- cameras
fn camera(p: &mut Project, comp: &Comp) -> Layer {
build::layer(p, comp, "Camera", LayerSource::Camera, (W, H), None)
}
#[test]
fn default_camera_layer_matches_comp_camera() {
let (mut p, cid, comp) = setup();
let cam = camera(&mut p, &comp);
assert_eq!(cam.props.prop("cameraOptions/zoom").unwrap().value.as_f64(), zoom());
let s = solid3(&mut p, &comp, [1.0, 0.0, 0.0], 40, 20, [50.0, 30.0, 0.0]);
push(&mut p, cid, s.clone());
let a = render(&p, cid);
p.comp_mut(cid).unwrap().layers.insert(0, cam);
let b = render(&p, cid);
assert_eq!(a, b);
}
#[test]
fn two_node_camera_looks_at_poi() {
let (mut p, cid, comp) = setup();
let mut cam = camera(&mut p, &comp);
// Look at a layer far to the right: it lands in the middle of the frame.
set(&mut cam, "transform/poi", Value::Vec3([600.0, 50.0, 0.0]));
set(&mut cam, "transform/position", Value::Vec3([600.0, 50.0, -zoom()]));
let s = solid3(&mut p, &comp, [0.0, 0.0, 1.0], 20, 20, [600.0, 50.0, 0.0]);
push(&mut p, cid, cam);
push(&mut p, cid, s);
let img = render(&p, cid);
assert!(img.get(100, 50)[2] > 0.99);
assert!(img.get(80, 50)[3] < 0.01);
}
#[test]
fn one_node_camera_uses_orientation() {
let (mut p, cid, comp) = setup();
let mut cam = camera(&mut p, &comp);
cam.auto_orient = effectcraft_project::AutoOrient::Off;
set(&mut cam, "transform/position", Value::Vec3([100.0, 50.0, 0.0]));
// Turn to look along +x.
set(&mut cam, "transform/orientation", Value::Vec3(orientation_for(vec3(1.0, 0.0, 0.0))));
let mut s = solid3(&mut p, &comp, [1.0, 1.0, 0.0], 40, 40, [400.0, 50.0, 0.0]);
set(&mut s, "transform/rotationY", Value::Scalar(90.0));
push(&mut p, cid, cam);
push(&mut p, cid, s);
let img = render(&p, cid);
assert!(img.get(100, 50)[3] > 0.99, "{:?}", img.get(100, 50));
}
#[test]
fn orientation_for_round_trips() {
for d in [vec3(1.0, 0.0, 0.0), vec3(0.0, 0.0, 1.0), vec3(-0.3, 0.4, 0.8), vec3(0.2, -0.9, -0.1)] {
let o = orientation_for(d);
let f = effectcraft_geom::Mat4::orientation(Vec3::from(o)).apply_vec(vec3(0.0, 0.0, 1.0));
assert!((f - d.normalize()).length() < 1e-9, "{d:?} → {o:?} → {f:?}");
}
}
#[test]
fn depth_of_field_blurs_out_of_focus_layers() {
let (mut p, cid, comp) = setup();
let mut cam = camera(&mut p, &comp);
set(&mut cam, "cameraOptions/dof", Value::Bool(true));
set(&mut cam, "cameraOptions/aperture", Value::Scalar(60.0));
let s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 60, 60, [100.0, 50.0, 0.0]);
push(&mut p, cid, cam);
push(&mut p, cid, s);
// In focus (focus distance = zoom = distance to z=0): hard edge at x = 70.
let a = render(&p, cid);
assert!(a.get(71, 50)[3] > 0.99 && a.get(69, 50)[3] < 0.01);
let c = p.comp_mut(cid).unwrap();
set(&mut c.layers[0], "cameraOptions/focusDistance", Value::Scalar(zoom() * 0.5));
let b = render(&p, cid);
let e = b.get(70, 50)[3];
assert!(e > 0.1 && e < 0.9, "soft edge {e}");
assert!(b.get(66, 50)[3] > 0.01);
}
/// Pixels in column `x` whose alpha is strictly between 0.05 and 0.95.
fn soft_px(img: &effectcraft_raster::Image, x: i64) -> usize {
(0..img.height as i64).filter(|&y| (0.05..0.95).contains(&img.get(x, y)[3])).count()
}
#[test]
fn tilted_layer_blurs_progressively_with_depth() {
let (mut p, cid, comp) = setup();
let mut cam = camera(&mut p, &comp);
set(&mut cam, "cameraOptions/dof", Value::Bool(true));
set(&mut cam, "cameraOptions/aperture", Value::Scalar(80.0));
set(&mut cam, "cameraOptions/irisShape", Value::Enum(4)); // Hexagon
let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 160, 40, [100.0, 50.0, 0.0]);
set(&mut s, "transform/rotationY", Value::Scalar(60.0));
push(&mut p, cid, cam);
push(&mut p, cid, s);
let img = render(&p, cid);
// The centre column sits on the focal plane: a hard top edge. The ends are 70 px in front of
// and behind it: soft.
let xs: Vec<i64> = (0..W as i64).filter(|&x| img.get(x, 50)[3] > 0.5).collect();
let (x0, x1) = (xs[0], *xs.last().unwrap());
let at = |f: f64| x0 + ((x1 - x0) as f64 * f) as i64;
let (centre, left, right) = (soft_px(&img, 100), soft_px(&img, at(0.1)), soft_px(&img, at(0.9)));
assert!(centre <= 2, "centre {centre}");
assert!(left >= 4 && right >= 4, "ends {left} {right}");
}
#[test]
fn iris_shape_and_highlights_shape_the_bokeh() {
// A small bright square far out of focus: its blur takes the iris' shape.
let spot = |shape: u32, gain: f64| {
let (mut p, cid, comp) = setup();
let mut cam = camera(&mut p, &comp);
set(&mut cam, "cameraOptions/dof", Value::Bool(true));
set(&mut cam, "cameraOptions/aperture", Value::Scalar(120.0));
set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(zoom() * 0.5));
set(&mut cam, "cameraOptions/irisShape", Value::Enum(shape));
set(&mut cam, "cameraOptions/highlightGain", Value::Scalar(gain));
set(&mut cam, "cameraOptions/highlightThreshold", Value::Scalar(200.0));
let s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 2, 2, [100.0, 50.0, 0.0]);
push(&mut p, cid, cam);
push(&mut p, cid, s);
render(&p, cid)
};
let area = |img: &effectcraft_raster::Image| img.data.iter().filter(|q| q[3] > 1e-4).count();
let (tri, dec, rect) = (spot(1, 0.0), spot(8, 0.0), spot(0, 0.0));
assert!(area(&tri) * 3 < area(&dec) * 2, "triangle {} vs decagon {}", area(&tri), area(&dec));
// Fast Rectangle: a box.
let (x0, x1) = (0..W as i64).filter(|&x| rect.get(x, 50)[3] > 1e-4).fold((i64::MAX, 0), |(a, b), x| (a.min(x), b.max(x)));
let y_extent = (0..H as i64).filter(|&y| rect.get(100, y)[3] > 1e-4).count() as i64;
assert!(((x1 - x0 + 1) - y_extent).abs() <= 2, "square box {} vs {}", x1 - x0 + 1, y_extent);
// Highlight Gain brightens the blurred highlight.
let lit = spot(8, 100.0);
let sum = |img: &effectcraft_raster::Image| img.data.iter().map(|q| q[0]).sum::<f32>();
assert!(sum(&lit) > sum(&dec) * 1.5, "{} vs {}", sum(&lit), sum(&dec));
}
// ---------------------------------------------------------------- views
#[test]
fn view_ids_round_trip() {
for v in View3D::ALL {
assert_eq!(View3D::from_id(v.id()), Some(v));
assert_eq!(View3D::from_id(v.label()), Some(v));
}
assert_eq!(View3D::from_id("Custom View 2"), Some(View3D::Custom2));
}
#[test]
fn front_view_is_orthographic() {
let (mut p, cid, comp) = setup();
// In an orthographic view depth doesn't change size.
let a = solid3(&mut p, &comp, [1.0, 0.0, 0.0], 40, 40, [60.0, 50.0, 0.0]);
let b = solid3(&mut p, &comp, [0.0, 1.0, 0.0], 40, 40, [140.0, 50.0, 1000.0]);
push(&mut p, cid, a);
push(&mut p, cid, b);
let mut vc = default_view_cam(View3D::Front, W as f64, H as f64);
vc.zoom = 1.0;
let opts = RenderOpts { view: Some(vc.state()), ..Default::default() };
let img = Renderer::new(&p, &NoFootage, opts).comp_frame(cid, Tick::ZERO);
assert!(img.get(41, 50)[0] > 0.99 && img.get(39, 50)[3] < 0.01);
assert!(img.get(121, 50)[1] > 0.99 && img.get(159, 50)[1] > 0.99 && img.get(161, 50)[3] < 0.01);
}
#[test]
fn top_view_sees_planes_edge_on() {
let (mut p, cid, comp) = setup();
let a = solid3(&mut p, &comp, [1.0, 0.0, 0.0], 100, 100, [100.0, 50.0, 0.0]);
let mut b = solid3(&mut p, &comp, [0.0, 1.0, 0.0], 100, 100, [100.0, 50.0, 0.0]);
set(&mut b, "transform/rotationX", Value::Scalar(90.0));
push(&mut p, cid, a);
push(&mut p, cid, b);
let mut vc = default_view_cam(View3D::Top, W as f64, H as f64);
vc.zoom = 1.0;
let opts = RenderOpts { view: Some(vc.state()), ..Default::default() };
let img = Renderer::new(&p, &NoFootage, opts).comp_frame(cid, Tick::ZERO);
// From the top, the X-rotated plane is a 100×100 square; the upright one is a line.
assert!(img.get(100, 30)[1] > 0.99 && img.get(100, 70)[1] > 0.99);
assert!(img.get(100, 30)[0] < 0.01);
}
#[test]
fn rig_orbit_keeps_distance_and_pan_moves_both() {
let vc = default_view_cam(View3D::Custom1, 1920.0, 1080.0);
let mut r = Rig::from_view(&vc);
let d0 = (r.eye - r.poi).length();
r.orbit(37.0, -15.0);
assert!(((r.eye - r.poi).length() - d0).abs() < 1e-6);
let (e, p) = (r.eye, r.poi);
r.pan(10.0, 0.0);
assert!(((r.eye - e) - (r.poi - p)).length() < 1e-9);
r.dolly(100.0, false);
assert!(((r.eye - r.poi).length() - (d0 - 100.0)).abs() < 1e-6);
// Pitch is clamped short of the poles.
r.orbit(0.0, 500.0);
let off = r.eye - r.poi;
assert!(off.y.abs() / off.length() < 0.9999);
}