diff --git a/crates/engine/src/commands/model3d.rs b/crates/engine/src/commands/model3d.rs index ff2ff1edf..0fd840ff2 100644 --- a/crates/engine/src/commands/model3d.rs +++ b/crates/engine/src/commands/model3d.rs @@ -61,6 +61,9 @@ fn comp_renderer(s: &mut Session, p: &Value) -> Result { sync_geometry_options(proj, cid); Ok(()) })?; + if r == Renderer::Classic3D && s.project.comp(cid).is_some_and(|c| c.layers.iter().any(|l| l.source.is_model())) { + s.toast("Classic 3D doesn't draw 3D model layers: they show with the Advanced 3D renderer"); + } Ok(json!({"renderer": renderer_id(r)})) } @@ -71,6 +74,25 @@ fn renderer_id(r: Renderer) -> &'static str { } } +/// Only the Advanced 3D renderer draws model and primitive layers, so adding one switches a +/// Classic 3D comp to it, like After Effects (inside the adding edit: one undo step). Whether +/// the renderer changed. +fn use_advanced_3d(proj: &mut effectcraft_project::Project, cid: ItemId) -> Result { + let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + let switched = c.renderer != Renderer::Advanced3D; + c.renderer = Renderer::Advanced3D; + sync_geometry_options(proj, cid); + Ok(switched) +} + +/// Tell the user when [`use_advanced_3d`] changed the comp's renderer. +fn report_switch(s: &mut Session, cid: ItemId, switched: bool) { + if switched { + let name = s.project.item(cid).map_or("the composition", |i| i.name.as_str()).to_string(); + s.toast(format!("\"{name}\" now uses the Advanced 3D renderer, which draws 3D models")); + } +} + // ---------------------------------------------------------------- model layers /// Set a property's value at comp time `t` (adds a key when it is animated). @@ -121,7 +143,7 @@ pub(crate) fn new_model(s: &mut Session, p: &Value) -> Result { let start = fr.snap_nearest(f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(Tick::ZERO)); let index = index_p(p, "layer.newModel")?; let position = position_p(p, "layer.newModel")?; - let id = s.edit("New 3D Model Layer", None, |proj, st| { + let (id, switched) = s.edit("New 3D Model Layer", None, |proj, st| { let mut l = build::layer(proj, &comp, &name, LayerSource::Model { item }, (comp.width, comp.height), None); let g = build::model_geometry_options(&mut Ids(&mut proj.next_id), scale, &clips); if let Some(slot) = l.props.children.iter_mut().find(|c| c.match_id() == "geometryOptions") { @@ -131,9 +153,11 @@ pub(crate) fn new_model(s: &mut Session, p: &Value) -> Result { l.in_point = start; l.out_point = comp.duration.max(start + fr.frame_duration()); place(&mut l, position); - insert_layer_at(proj, st, cid, l, index) + let id = insert_layer_at(proj, st, cid, l, index)?; + Ok((id, use_advanced_3d(proj, cid)?)) })?; - Ok(json!({"layer": id.0, "item": item.0})) + report_switch(s, cid, switched); + Ok(json!({"layer": id.0, "item": item.0, "advanced3d": true})) } fn kind_p(p: &Value, cmd: &str) -> Result { @@ -187,7 +211,7 @@ fn new_primitive(s: &mut Session, p: &Value) -> Result { let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| kind.label().to_string()); let t = super::time_p(s, p, Some(&comp)); - let id = s.edit(&format!("New {}", kind.label()), None, |proj, st| { + let (id, switched) = s.edit(&format!("New {}", kind.label()), None, |proj, st| { let mut l = build::layer(proj, &comp, &name, LayerSource::Primitive { kind }, (comp.width, comp.height), None); for k in ["width", "height", "depth", "radius", "tubeRadius", "segments", "rings"] { if let Some(v) = f_p(p, k) { @@ -199,11 +223,11 @@ fn new_primitive(s: &mut Session, p: &Value) -> Result { set_at(&mut l, "transform/position", t, KV::Vec3([g(0), g(1), g(2)])); } apply_material(&mut l, p, t, "layer.newPrimitive")?; - insert_layer(proj, st, cid, l) + let id = insert_layer(proj, st, cid, l)?; + Ok((id, use_advanced_3d(proj, cid)?)) })?; - // Primitives are drawn by the Advanced 3D renderer. - let advanced = s.project.comp(cid).is_some_and(|c| c.renderer == Renderer::Advanced3D); - Ok(json!({"layer": id.0, "kind": kind.label(), "advanced3d": advanced})) + report_switch(s, cid, switched); + Ok(json!({"layer": id.0, "kind": kind.label(), "advanced3d": true})) } // ---------------------------------------------------------------- environment layer @@ -352,7 +376,7 @@ pub fn specs() -> Vec { "3D Model Layer", [], None, - "{item?: id or name of a 3D model footage item, path?: a .gltf, .glb or .obj file to import, name?, time? (s), index? (1-based stack position), position? ([x, y] comp px)}", + "{item?: id or name of a 3D model footage item, path?: a .gltf, .glb or .obj file to import, name?, time? (s), index? (1-based stack position), position? ([x, y] comp px)}; switches a Classic 3D comp to Advanced 3D", has_comp, new_model ), @@ -361,7 +385,7 @@ pub fn specs() -> Vec { "3D Primitive", [], None, - "{kind: cube|sphere|plane|torus|cone|cylinder, name?, width?, height?, depth?, radius?, tubeRadius?, segments?, rings?, position? [x,y,z], baseColor?, metallic?, roughness?, emissive?, castsShadows?, acceptsShadows?, acceptsLights?}", + "{kind: cube|sphere|plane|torus|cone|cylinder, name?, width?, height?, depth?, radius?, tubeRadius?, segments?, rings?, position? [x,y,z], baseColor?, metallic?, roughness?, emissive?, castsShadows?, acceptsShadows?, acceptsLights?}; switches a Classic 3D comp to Advanced 3D", has_comp, new_primitive ), @@ -370,7 +394,7 @@ pub fn specs() -> Vec { "New 3D Primitive", [], None, - "{kind: cube|sphere|plane|torus|cone|cylinder, name?, width?, height?, depth?, radius?, tubeRadius?, segments?, rings?, position? [x,y,z], baseColor?, metallic?, roughness?, emissive?, castsShadows?, acceptsShadows?, acceptsLights?}", + "{kind: cube|sphere|plane|torus|cone|cylinder, name?, width?, height?, depth?, radius?, tubeRadius?, segments?, rings?, position? [x,y,z], baseColor?, metallic?, roughness?, emissive?, castsShadows?, acceptsShadows?, acceptsLights?}; switches a Classic 3D comp to Advanced 3D", has_comp, new_primitive ), diff --git a/crates/engine/src/tests_model3d.rs b/crates/engine/src/tests_model3d.rs index 02e4e52a2..1d8b80f93 100644 --- a/crates/engine/src/tests_model3d.rs +++ b/crates/engine/src/tests_model3d.rs @@ -193,3 +193,39 @@ fn material_commands_and_serde() { let back = Project::from_json(&json).unwrap(); assert_eq!(&back, &*s.project); } + +/// #264: only Advanced 3D draws models and primitives, and new comps are Classic 3D, so an +/// imported model was invisible. Adding one switches the comp to Advanced 3D (in the same undo +/// step, with a toast), as After Effects does. +#[test] +fn adding_models_switches_classic_comps_to_advanced_3d() { + let toasts = |s: &mut Session| -> Vec { + s.drain_events().into_iter().filter_map(|e| if let crate::Event::Toast { message, .. } = e { Some(message) } else { None }).collect() + }; + let mut s = session(); + let text = s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap(); + let item = s.execute("file.import", json!({"paths": [fixture("quad.glb")]})).unwrap()["items"][0].as_u64().unwrap(); + assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D); + toasts(&mut s); + let r = s.execute("layer.addItem", json!({"item": item})).unwrap(); + assert_eq!(r["advanced3d"], true); + assert_eq!(s.active_comp().unwrap().renderer, Renderer::Advanced3D); + // As with Composition Settings: the text layer gets Geometry Options. + assert!(layer(&s, text).props.sub("geometryOptions").is_some()); + assert!(toasts(&mut s).iter().any(|m| m.contains("Advanced 3D"))); + // A second model leaves the comp (and the user) alone. + s.execute("layer.addItem", json!({"item": item})).unwrap(); + assert!(toasts(&mut s).is_empty()); + s.undo(); + s.undo(); + assert_eq!(s.active_comp().unwrap().layers.len(), 1); + assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D); + assert!(layer(&s, text).props.sub("geometryOptions").is_none()); + // Primitives too. + s.execute("layer.newPrimitive", json!({"kind": "cube"})).unwrap(); + assert_eq!(s.active_comp().unwrap().renderer, Renderer::Advanced3D); + // Switching back to Classic 3D says why the model layers disappear. + toasts(&mut s); + s.execute("comp.renderer", json!({"renderer": "classic3d"})).unwrap(); + assert!(toasts(&mut s).iter().any(|m| m.contains("Classic 3D doesn't draw"))); +} diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index b4ca00a49..7f2d1a5cd 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -205,6 +205,58 @@ mod tests { assert!(s.render(cid, s.time(), opts).data.iter().all(|p| p[3] == 0.0)); } + /// A binary glTF of a red cube 2 units wide (positions and indices only). + fn glb_cube() -> Vec { + let mut bin: Vec = (0..8u32).flat_map(|i| [i & 1, (i >> 1) & 1, (i >> 2) & 1].map(|b| b as f32 * 2.0 - 1.0)).flat_map(f32::to_le_bytes).collect(); + let faces: [[u16; 4]; 6] = [[0, 2, 3, 1], [4, 5, 7, 6], [0, 1, 5, 4], [2, 6, 7, 3], [0, 4, 6, 2], [1, 3, 7, 5]]; + bin.extend(faces.iter().flat_map(|f| [f[0], f[1], f[2], f[0], f[2], f[3]]).flat_map(u16::to_le_bytes)); + let gltf = json!({ + "asset": {"version": "2.0"}, + "scene": 0, + "scenes": [{"nodes": [0]}], + "nodes": [{"mesh": 0}], + "meshes": [{"primitives": [{"attributes": {"POSITION": 0}, "indices": 1, "material": 0}]}], + "materials": [{"pbrMetallicRoughness": {"baseColorFactor": [1, 0, 0, 1], "metallicFactor": 0, "roughnessFactor": 1}}], + "buffers": [{"byteLength": bin.len()}], + "bufferViews": [{"buffer": 0, "byteOffset": 0, "byteLength": 96}, {"buffer": 0, "byteOffset": 96, "byteLength": 72}], + "accessors": [ + {"bufferView": 0, "componentType": 5126, "count": 8, "type": "VEC3", "min": [-1, -1, -1], "max": [1, 1, 1]}, + {"bufferView": 1, "componentType": 5123, "count": 36, "type": "SCALAR"} + ] + }); + let mut text = gltf.to_string().into_bytes(); + text.resize(text.len().div_ceil(4) * 4, b' '); + let chunk = |kind: &[u8; 4], data: &[u8]| [&(data.len() as u32).to_le_bytes()[..], kind, data].concat(); + let body = [chunk(b"JSON", &text), chunk(b"BIN\0", &bin)].concat(); + [&b"glTF"[..], &2u32.to_le_bytes(), &(12 + body.len() as u32).to_le_bytes(), &body].concat() + } + + /// #264: a model imported into a new composition (Classic 3D by default) was invisible on + /// every renderer, because only Advanced 3D draws models. Adding it now switches the comp, + /// and the software renderer (no GPU, as on the reporter's Intel HD Graphics 5500) draws it. + #[test] + fn imported_glb_shows_in_a_new_composition() { + let path = std::env::temp_dir().join(format!("effectcraft-264-cube-{}.glb", std::process::id())); + std::fs::write(&path, glb_cube()).unwrap(); + let mut s = super::session(); + assert!(s.accel.is_none(), "renders in software"); + let r = s.execute("file.import", json!({"paths": [path.to_string_lossy()]})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + s.state.project_selection = vec![effectcraft_project::ItemId(r["items"][0].as_u64().unwrap())]; + s.execute("file.newCompFromSelection", json!({})).unwrap(); + let comp = s.active_comp().unwrap(); + assert_eq!(comp.renderer, effectcraft_project::Renderer::Advanced3D); + let layer = comp.layers[0].id.0; + s.execute("prop.set", json!({"layer": layer, "path": "transform/rotationY", "value": 30})).unwrap(); + s.execute("prop.set", json!({"layer": layer, "path": "transform/rotationX", "value": 20})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let img = s.render(cid, s.time(), effectcraft_engine::render::RenderOpts { scale: 0.25, ..Default::default() }); + let red = img.data.iter().filter(|p| p[3] > 0.99 && p[0] > 0.2 && p[1] < 0.05 && p[2] < 0.05).count(); + let _ = std::fs::remove_file(&path); + // Half the comp height wide, seen at an angle: well over 5% of the frame. + assert!(red * 20 > img.data.len(), "red cube pixels: {red} of {}", img.data.len()); + } + /// The web app's path: outputs go to a sink (downloads), never to the file system. #[test] fn render_queue_exports_to_a_sink() { diff --git a/docs/architecture.md b/docs/architecture.md index 49f9da4ae..6d29b2945 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -100,7 +100,8 @@ time), primitive layers (Layer ▸ New ▸ Cube/Sphere/Plane/Torus/Cone/Cylinder Geometry Options), text and shape layers with Geometry Options ▸ Extrusion Depth (glyph and fill outlines flattened, triangulated with holes and extruded with Angular/Concave/Convex bevels, per character so per-character 3D carries over), and a textured card for every other -3D layer. Shading is physically based in linear light (glTF metallic-roughness; Cook–Torrance +3D layer. Classic 3D doesn't draw model or primitive layers, so adding one switches a Classic 3D +comp to Advanced 3D in the same undo step, as in After Effects. Shading is physically based in linear light (glTF metallic-roughness; Cook–Torrance GGX specular, Smith–Schlick geometry, Schlick Fresnel, Lambert diffuse, after Karis 2013): parallel, spot and point lights with After Effects' falloffs, ambient lights, image-based light from an Environment light whose Source (or the comp's Environment Layer, Layer ▸