From 6d20cc21515db560fab030939dee8a681c468a9f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 20:17:30 +0700 Subject: [PATCH 1/2] Engine: adding a 3D model switches a Classic 3D comp to Advanced 3D (#264) Imported GLB/glTF models were invisible in the Composition viewer while text showed. Only the Advanced 3D renderer draws model and primitive layers, and new compositions (and New Comp from Selection) are Classic 3D by default, but adding a model layer left the comp's renderer alone, so the model drew nothing on every backend. It wasn't the reporter's GPU: on this machine the viewer showed nothing for a GLB in a new comp with the GPU compositor (Vulkan) and with Mercury Software Only, and the model in both once the comp used Advanced 3D. As After Effects documents ("Advanced 3D gets automatically selected when you add a 3D model to the composition"), layer.newModel / layer.addItem of a model and layer.newPrimitive now switch a Classic 3D comp to Advanced 3D inside the same edit (one undo step, Geometry Options added to its text and shape layers like Composition Settings does) and toast that the comp changed renderer. Switching a comp that has model layers back to Classic 3D toasts why they disappear. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/model3d.rs | 46 ++++++++++++++++++++------- docs/architecture.md | 3 +- 2 files changed, 37 insertions(+), 12 deletions(-) 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/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 ▸ From 3cc95206857fce92661d38465f276bde385a6378 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 20:17:35 +0700 Subject: [PATCH 2/2] Test: an imported GLB shows in a new composition in software (#264) The host test writes a binary glTF cube (built in the test), imports it with the real media layer, makes New Comp from Selection and renders with no accelerator (the CPU path an Intel HD Graphics 5500 most likely gets): the comp is Advanced 3D and the red cube covers part of the frame. Before the fix the comp stayed Classic 3D and drew nothing. The engine test checks the switch, its single undo step, the toasts, that a second model doesn't switch again, and primitives. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests_model3d.rs | 36 +++++++++++++++++++++ crates/host/src/lib.rs | 52 ++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) 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() {