effectcraft/crates/engine/src/tests_model3d.rs

196 lines
9.3 KiB
Rust

//! Advanced 3D commands: model import → model layers, primitives, the renderer switch with
//! Geometry Options, Environment Layer and Material commands (undo/redo, serde).
use std::sync::Arc;
use effectcraft_keyframe::Value as KV;
use effectcraft_project::{Footage, FootageKind, ItemId, LayerId, LayerSource, PrimitiveKind, Project, Renderer};
use effectcraft_render::FootageSource;
use effectcraft_time::{FrameRate, Tick};
use serde_json::json;
use crate::{Importer, Session};
fn fixture(name: &str) -> String {
format!("{}/../model/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"))
}
/// Loads models from disk (what `effectcraft-media` does in the app).
struct DiskModels;
impl FootageSource for DiskModels {
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<effectcraft_raster::Image>> {
None
}
fn model(&self, _: ItemId, f: &Footage) -> Option<Arc<effectcraft_model::Model>> {
let dir = std::path::Path::new(&f.path).parent()?.to_path_buf();
let bytes = std::fs::read(&f.path).ok()?;
effectcraft_model::load(&f.path, &bytes, &|u| std::fs::read(dir.join(u)).ok()).ok().map(Arc::new)
}
}
struct ModelImporter;
impl Importer for ModelImporter {
fn probe(&self, path: &str) -> Result<Footage, String> {
effectcraft_model::format_of(path).ok_or("not a model")?;
Ok(Footage {
path: path.into(),
kind: FootageKind::Model,
width: 0,
height: 0,
pixel_aspect: 1.0,
frame_rate: FrameRate::FPS_30,
native_rate: None,
duration: Tick::ZERO,
has_video: false,
has_audio: false,
alpha: Default::default(),
premul_color: [0.0; 3],
loop_count: 1,
codec: "glTF".into(),
missing: false,
sequence: vec![],
color_profile: None,
..Default::default()
})
}
}
fn session() -> Session {
let mut s = Session { footage: Arc::new(DiskModels), importer: Some(Arc::new(ModelImporter)), ..Default::default() };
s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 2})).unwrap();
s
}
fn layer(s: &Session, id: u64) -> effectcraft_project::Layer {
s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone()
}
#[test]
fn import_gltf_and_add_model_layer() {
let mut s = session();
let r = s.execute("file.import", json!({"paths": [fixture("quad.gltf")]})).unwrap();
let item = r["items"][0].as_u64().unwrap();
assert_eq!(s.project.item(ItemId(item)).unwrap().type_name(), "3D Model");
// Adding the item to the comp makes a model layer (3D, fitted, with the clip popup).
let id = s.execute("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap();
let l = layer(&s, id);
assert_eq!(l.source, LayerSource::Model { item: ItemId(item) });
assert!(l.switches.three_d);
assert!(l.props.sub("masks").is_none() && l.props.sub("materialOptions").is_some());
// Bounds 2 units tall → half the comp height.
let scale = l.props.prop("geometryOptions/unitScale").unwrap().value.as_f64();
assert!((scale - 90.0).abs() < 1e-6, "{scale}");
let anim = l.props.prop("geometryOptions/animation").unwrap();
assert_eq!(anim.value, KV::Enum(1));
match &anim.ui {
effectcraft_project::ParamUi::Popup { options } => assert_eq!(options, &vec!["None".to_string(), "Move".to_string()]),
u => panic!("{u:?}"),
}
s.undo();
assert!(s.active_comp().unwrap().layers.is_empty());
s.redo();
assert_eq!(s.active_comp().unwrap().layers.len(), 1);
// layer.newModel imports and places in one step.
let r = s.execute("layer.newModel", json!({"path": fixture("cube.obj")})).unwrap();
assert!(r["layer"].as_u64().is_some());
assert!(s.execute("layer.newModel", json!({"path": "/nonexistent/thing.png"})).is_err());
}
#[test]
fn primitives_commands() {
let mut s = session();
for k in PrimitiveKind::ALL {
let r = s.execute("layer.new3dPrimitive", json!({"kind": k.label().to_lowercase()})).unwrap();
let l = layer(&s, r["layer"].as_u64().unwrap());
assert_eq!(l.source, LayerSource::Primitive { kind: k });
assert_eq!(l.name, k.label());
assert!(l.switches.three_d);
assert!(l.props.sub("geometryOptions").is_some());
}
let r = s
.execute("layer.newPrimitive", json!({"kind": "sphere", "radius": 80, "segments": 12, "baseColor": [1, 0, 0], "metallic": 100, "castsShadows": "on"}))
.unwrap();
let l = layer(&s, r["layer"].as_u64().unwrap());
assert_eq!(l.props.prop("geometryOptions/radius").unwrap().value.as_f64(), 80.0);
assert_eq!(l.props.prop("materialOptions/metallic").unwrap().value.as_f64(), 100.0);
assert_eq!(l.props.prop("materialOptions/castsShadows").unwrap().value, KV::Enum(1));
assert!(s.execute("layer.newPrimitive", json!({"kind": "teapot"})).is_err());
let n = s.active_comp().unwrap().layers.len();
s.undo();
assert_eq!(s.active_comp().unwrap().layers.len(), n - 1);
// Menu entries point at the real command now.
assert!(s.is_enabled("layer.new3dPrimitive"));
}
#[test]
fn renderer_switch_adds_geometry_options() {
let mut s = session();
let t = s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap();
assert!(layer(&s, t).props.sub("geometryOptions").is_none());
s.execute("comp.renderer", json!({"renderer": "advanced3d"})).unwrap();
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Advanced3D);
let g = layer(&s, t);
let g = g.props.sub("geometryOptions").unwrap();
let ids: Vec<&str> = g.children.iter().map(|c| c.match_id()).collect();
assert_eq!(ids, vec!["bevelStyle", "bevelDepth", "holeBevelDepth", "extrusionDepth"]);
// New shape layers in the Advanced 3D comp get them too.
let sh = s.execute("layer.newShape", json!({})).unwrap()["layer"].as_u64().unwrap();
assert!(layer(&s, sh).props.sub("geometryOptions").is_some());
assert_eq!(s.execute("comp.renderer", json!({})).unwrap()["renderer"], "advanced3d");
s.undo();
s.undo();
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D);
assert!(layer(&s, t).props.sub("geometryOptions").is_none());
s.redo();
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Advanced3D);
assert!(s.execute("comp.renderer", json!({"renderer": "raytraced"})).is_err());
// Extrusion values are ordinary properties.
s.execute("prop.set", json!({"layer": t, "path": "geometryOptions/extrusionDepth", "value": 40})).unwrap();
assert_eq!(layer(&s, t).props.prop("geometryOptions/extrusionDepth").unwrap().value.as_f64(), 40.0);
}
#[test]
fn environment_layer_toggle() {
let mut s = session();
let id = s.execute("layer.newSolid", json!({"width": 64, "height": 32})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.environment", json!({"layer": id})).unwrap();
assert!(layer(&s, id).environment && layer(&s, id).switches.three_d);
s.execute("layer.environment", json!({"layer": id})).unwrap();
assert!(!layer(&s, id).environment);
s.undo();
assert!(layer(&s, id).environment);
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
assert!(s.execute("layer.environment", json!({"layer": cam})).is_err());
// An Environment light sources a layer.
let li = s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap()["layer"].as_u64().unwrap();
assert!(layer(&s, li).props.prop("lightOptions/source").is_some());
}
#[test]
fn material_commands_and_serde() {
let mut s = session();
let a = s.execute("layer.newPrimitive", json!({"kind": "cube"})).unwrap()["layer"].as_u64().unwrap();
let b = s.execute("layer.newPrimitive", json!({"kind": "torus"})).unwrap()["layer"].as_u64().unwrap();
s.execute("material.set", json!({"layers": [a], "baseColor": "#3366ff", "roughness": 20, "acceptsLights": false})).unwrap();
assert_eq!(layer(&s, a).props.prop("materialOptions/roughness").unwrap().value.as_f64(), 20.0);
assert!(s.execute("material.set", json!({"layers": [a], "castsShadows": "sometimes"})).is_err());
s.execute("material.duplicateAssign", json!({"layers": [a, b]})).unwrap();
assert_eq!(layer(&s, b).props.prop("materialOptions/roughness").unwrap().value.as_f64(), 20.0);
assert_eq!(layer(&s, b).props.prop("materialOptions/acceptsLights").unwrap().value, KV::Bool(false));
s.execute("material.reset", json!({"layers": [a]})).unwrap();
assert_eq!(layer(&s, a).props.prop("materialOptions/roughness").unwrap().value.as_f64(), 50.0);
s.undo();
assert_eq!(layer(&s, a).props.prop("materialOptions/roughness").unwrap().value.as_f64(), 20.0);
assert!(s.execute("material.revealSource", json!({"layer": a})).is_err());
// The project (model and primitive sources, environment flag) round-trips through JSON.
let r = s.execute("file.import", json!({"paths": [fixture("quad.glb")]})).unwrap();
let item = r["items"][0].as_u64().unwrap();
let m = s.execute("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap();
assert_eq!(s.execute("material.revealSource", json!({"layer": m})).unwrap()["item"], item);
let json = s.project.to_json();
let back = Project::from_json(&json).unwrap();
assert_eq!(&back, &*s.project);
}