effectcraft/crates/engine/src/tests_3d.rs

246 lines
12 KiB
Rust

//! 3D commands: cameras, lights, views, camera tools (with undo/redo), and the 3D demo comp.
use effectcraft_project::{AutoOrient, LayerSource, LightKind};
use effectcraft_render::three_d::View3D;
use serde_json::json;
use crate::Session;
fn session() -> Session {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 2})).unwrap();
s
}
#[test]
fn auto_orient_command() {
let mut s = session();
let id = s.execute("layer.newSolid", json!({"width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.autoOrient", json!({"layer": id, "mode": "towardsCamera"})).unwrap();
assert_eq!(s.active_comp().unwrap().layers[0].auto_orient, AutoOrient::TowardsCamera);
s.undo();
assert_eq!(s.active_comp().unwrap().layers[0].auto_orient, AutoOrient::Off);
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
assert!(s.execute("layer.autoOrient", json!({"layer": cam, "mode": "towardsCamera"})).is_err());
s.execute("layer.autoOrient", json!({"layer": cam, "mode": "off"})).unwrap();
assert!(!effectcraft_render::three_d::camera::is_two_node(&s.active_comp().unwrap().layers[0]));
}
fn layer_count(s: &Session) -> usize {
s.active_comp().unwrap().layers.len()
}
fn prop(s: &Session, layer: u64, path: &str) -> effectcraft_keyframe::Value {
let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(layer)).unwrap();
l.props.prop(path).unwrap_or_else(|| panic!("no {path}")).value.clone()
}
#[test]
fn new_camera_with_preset_undo_redo() {
let mut s = session();
let r = s.execute("layer.newCamera", json!({"preset": "35mm"})).unwrap();
let id = r["layer"].as_u64().unwrap();
assert_eq!(layer_count(&s), 1);
let zoom = prop(&s, id, "cameraOptions/zoom").as_f64();
assert!((zoom - 640.0 * 35.0 / 36.0).abs() < 1e-9);
assert_eq!(prop(&s, id, "cameraOptions/focusDistance").as_f64(), zoom);
let l = s.active_comp().unwrap().layers[0].clone();
assert_eq!(l.source, LayerSource::Camera);
assert_eq!(l.auto_orient, AutoOrient::TowardsPointOfInterest);
assert!(s.undo());
assert_eq!(layer_count(&s), 0);
assert!(s.redo());
assert_eq!(layer_count(&s), 1);
assert!(s.execute("layer.newCamera", json!({"preset": "13mm-fisheye"})).is_err());
}
#[test]
fn camera_settings_one_node_keeps_view() {
let mut s = session();
let id = s.execute("layer.newCamera", json!({"poi": [100, 50, 0], "position": [320, 180, -400]})).unwrap()["layer"].as_u64().unwrap();
let cid = s.active_comp_id().unwrap();
let fwd = |s: &Session| {
let c = s.project.comp(cid).unwrap();
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, c, s.time());
effectcraft_render::three_d::active_camera(&ctx).forward()
};
let before = fwd(&s);
s.execute("layer.cameraSettings", json!({"layer": id, "type": "oneNode", "dof": true, "aperture": 30})).unwrap();
let l = s.active_comp().unwrap().layers[0].clone();
assert_eq!(l.auto_orient, AutoOrient::Off);
assert!((fwd(&s) - before).length() < 1e-9, "one-node camera keeps looking the same way");
assert!(prop(&s, id, "cameraOptions/dof").as_bool());
// Layer Settings (Cmd+Shift+Y) on a camera opens Camera Settings.
s.execute("layer.settings", json!({"layer": id, "zoom": 500})).unwrap();
assert_eq!(prop(&s, id, "cameraOptions/zoom").as_f64(), 500.0);
s.undo();
s.undo();
let l = s.active_comp().unwrap().layers[0].clone();
assert_eq!(l.auto_orient, AutoOrient::TowardsPointOfInterest);
assert!(!prop(&s, id, "cameraOptions/dof").as_bool());
}
#[test]
fn new_light_and_change_type() {
let mut s = session();
let id = s
.execute(
"layer.newLight",
json!({"kind": "Spot", "intensity": 150, "color": [1, 0.5, 0.25], "coneAngle": 60, "castsShadows": true, "falloff": "Smooth"}),
)
.unwrap()["layer"]
.as_u64()
.unwrap();
assert_eq!(prop(&s, id, "lightOptions/intensity").as_f64(), 150.0);
assert_eq!(prop(&s, id, "lightOptions/coneAngle").as_f64(), 60.0);
assert_eq!(prop(&s, id, "lightOptions/falloff").as_enum(), 1);
assert!(prop(&s, id, "lightOptions/castsShadows").as_bool());
s.execute("layer.lightSettings", json!({"layer": id, "kind": "Point"})).unwrap();
let l = s.active_comp().unwrap().layers[0].clone();
assert_eq!(l.source, LayerSource::Light { kind: LightKind::Point });
assert!(l.props.prop("lightOptions/coneAngle").is_none());
assert_eq!(prop(&s, id, "lightOptions/intensity").as_f64(), 150.0, "shared values carry over");
s.undo();
let l = s.active_comp().unwrap().layers[0].clone();
assert_eq!(l.source, LayerSource::Light { kind: LightKind::Spot });
assert!(s.execute("layer.newLight", json!({"kind": "Laser"})).is_err());
}
/// The Environment light is listed in `layer.newLight`'s help and in the kind errors, and takes
/// its equirectangular source as a layer id (#261).
#[test]
fn environment_light_is_listed_and_takes_a_source_layer() {
let mut s = session();
let spec = crate::command_specs().iter().find(|c| c.id == "layer.newLight").unwrap();
assert!(spec.params.contains("lightOptions/source"), "{}", spec.params);
let e = s.execute("layer.newLight", json!({"kind": "Laser"})).unwrap_err().to_string();
let point = s.execute("layer.newLight", json!({"kind": "Point"})).unwrap()["layer"].as_u64().unwrap();
let e2 = s.execute("layer.lightSettings", json!({"layer": point, "kind": "Laser"})).unwrap_err().to_string();
for k in LightKind::ALL {
assert!(spec.params.contains(k.label()), "help lists {}: {}", k.label(), spec.params);
assert!(e.contains(k.label()) && e2.contains(k.label()), "{e} / {e2}");
}
let sky = s.execute("layer.newSolid", json!({"width": 256, "height": 128, "name": "Sky"})).unwrap()["layer"].as_u64().unwrap();
let env = s.execute("layer.newLight", json!({"kind": "Environment", "name": "Env"})).unwrap()["layer"].as_u64().unwrap();
let source = s.active_comp().unwrap().layer(effectcraft_project::LayerId(env)).unwrap().source.clone();
assert_eq!(source, LayerSource::Light { kind: LightKind::Environment });
assert_eq!(prop(&s, env, "lightOptions/source").as_layer(), None, "empty: the comp's Environment Layer");
s.execute("prop.set", json!({"layer": env, "path": "lightOptions/source", "value": sky})).unwrap();
assert_eq!(prop(&s, env, "lightOptions/source").as_layer(), Some(sky));
s.undo();
assert_eq!(prop(&s, env, "lightOptions/source").as_layer(), None);
}
#[test]
fn three_d_switch_undo_redo() {
let mut s = session();
let id = s.execute("layer.newSolid", json!({"width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layer": id, "switch": "threeD", "value": true})).unwrap();
assert!(s.active_comp().unwrap().layers[0].is_3d());
s.undo();
assert!(!s.active_comp().unwrap().layers[0].is_3d());
s.redo();
assert!(s.active_comp().unwrap().layers[0].is_3d());
// Turning 3D off discards Z and the 3D-only rotations.
s.execute("prop.set", json!({"layer": id, "path": "transform/position", "value": [10, 20, 300]})).unwrap();
s.execute("prop.set", json!({"layer": id, "path": "transform/rotationX", "value": 45})).unwrap();
s.execute("layer.setSwitch", json!({"layer": id, "switch": "threeD", "value": false})).unwrap();
assert_eq!(prop(&s, id, "transform/position").as_vec3(), [10.0, 20.0, 0.0]);
assert_eq!(prop(&s, id, "transform/rotationX").as_f64(), 0.0);
s.undo();
assert_eq!(prop(&s, id, "transform/position").as_vec3(), [10.0, 20.0, 300.0]);
}
#[test]
fn draft_3d_turns_off_lighting() {
let mut s = session();
let cid = s.active_comp_id().unwrap();
let id = s.execute("layer.newSolid", json!({"width": 640, "height": 360, "color": [1, 1, 1]})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layer": id, "switch": "threeD", "value": true})).unwrap();
s.execute("layer.newLight", json!({"kind": "Point", "intensity": 50, "position": [320, 180, -400]})).unwrap();
let px = |s: &Session| s.render(cid, s.time(), effectcraft_render::RenderOpts::default()).get(320, 180)[0];
assert!(px(&s) < 0.5);
s.execute("comp.setSwitch", json!({"switch": "draft3d", "value": true})).unwrap();
assert!((px(&s) - 1.0).abs() < 1e-5);
}
#[test]
fn views_switch_and_override_camera() {
let mut s = session();
let cid = s.active_comp_id().unwrap();
let id = s.execute("layer.newSolid", json!({"width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layer": id, "switch": "threeD", "value": true})).unwrap();
assert!(s.view_camera(cid).is_none());
s.execute("view.3d.top", json!({})).unwrap();
let cam = s.view_camera(cid).unwrap();
assert!(cam.ortho);
assert!((cam.forward().y - 1.0).abs() < 1e-9);
s.execute("view.set3DView", json!({"view": "Custom View 2"})).unwrap();
assert!(!s.view_camera(cid).unwrap().ortho);
s.execute("view.3d.last", json!({})).unwrap();
assert_eq!(s.state.views3d[&cid].current, View3D::Top);
// Orbit a custom view: edits the view camera, not the project.
s.execute("view.3d.custom1", json!({})).unwrap();
let rev = s.revision;
let before = s.view_camera(cid).unwrap().eye;
s.execute("camera.orbit", json!({"yaw": 20})).unwrap();
assert!((s.view_camera(cid).unwrap().eye - before).length() > 1.0);
assert_eq!(s.revision, rev);
s.execute("view.reset3DView", json!({})).unwrap();
assert!((s.view_camera(cid).unwrap().eye - before).length() < 1e-9);
s.execute("view.3d.activeCamera", json!({})).unwrap();
assert!(s.view_camera(cid).is_none());
let q = s.execute("view.get3D", json!({})).unwrap();
assert_eq!(q["view"], "activeCamera");
assert!(s.execute("view.set3DView", json!({"view": "sideways"})).is_err());
}
#[test]
fn camera_tools_edit_active_camera_with_undo() {
let mut s = session();
// No camera layer: the tools explain what is needed.
assert!(s.execute("camera.orbit", json!({"yaw": 10})).is_err());
let id = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
let p0 = prop(&s, id, "transform/position").as_vec3();
let poi = prop(&s, id, "transform/poi").as_vec3();
let dist = |a: [f64; 3], b: [f64; 3]| ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt();
// A drag = several merged steps → one undo.
let undo_len = s.history.undo.len();
for _ in 0..5 {
s.execute("camera.orbit", json!({"yaw": 6, "pitch": -2, "merge": "drag-1"})).unwrap();
}
assert_eq!(s.history.undo.len(), undo_len + 1);
let p1 = prop(&s, id, "transform/position").as_vec3();
assert!((dist(p1, poi) - dist(p0, poi)).abs() < 1e-6, "orbit keeps the distance to the POI");
assert!(dist(p1, p0) > 10.0);
s.execute("camera.pan", json!({"dx": 50, "dy": 0})).unwrap();
let poi2 = prop(&s, id, "transform/poi").as_vec3();
assert!(dist(poi2, poi) > 1.0, "pan moves the POI too");
s.execute("camera.dolly", json!({"amount": 100})).unwrap();
s.undo();
s.undo();
s.undo();
assert_eq!(prop(&s, id, "transform/position").as_vec3(), p0);
// One-node cameras orbit by re-orienting.
s.execute("layer.cameraSettings", json!({"layer": id, "type": "oneNode"})).unwrap();
s.execute("camera.orbit", json!({"yaw": 30})).unwrap();
let o = prop(&s, id, "transform/orientation").as_vec3();
assert!(o[1].abs() > 1.0, "{o:?}");
}
#[test]
fn demo_has_3d_showcase() {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
let item = s.project.find_by_name(crate::demo::SHOWCASE_3D).unwrap();
let cid = item.id;
let c = s.project.comp(cid).unwrap();
assert!(c.layers.iter().any(|l| l.is_camera()));
assert!(c.layers.iter().any(|l| l.is_light()));
assert!(c.layers.iter().filter(|l| l.switches.three_d).count() >= 4);
let img =
s.render(cid, effectcraft_time::Tick::from_seconds_f64(4.0), effectcraft_render::RenderOpts { scale: 0.125, motion_blur: false, ..Default::default() });
let lit = img.data.iter().filter(|p| p[3] > 0.99 && p[0] + p[1] + p[2] > 0.3).count();
assert!(lit > 2_000, "{lit}");
}