//! 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> { None } fn model(&self, _: ItemId, f: &Footage) -> Option> { 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 { 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); }