From 1f693acea6b5737f6d1fa5021ef3ca68e5fa7569 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 22:19:43 +0200 Subject: [PATCH] Scale Composition: walk the parent chain through cameras and lights, and give Z its own Scale speed The renderer applies every ancestor's transform, so a scaled null above a camera already carries the camera's children. A Scale key with one shared ease gets a separate Z ease before its speed is scaled. --- crates/engine/src/cmd/comp_more.rs | 65 ++++++++++++++------------- crates/engine/src/tests/scale_comp.rs | 48 ++++++++++++++++++++ 2 files changed, 81 insertions(+), 32 deletions(-) diff --git a/crates/engine/src/cmd/comp_more.rs b/crates/engine/src/cmd/comp_more.rs index 2783c47be..00f1bb200 100644 --- a/crates/engine/src/cmd/comp_more.rs +++ b/crates/engine/src/cmd/comp_more.rs @@ -129,8 +129,14 @@ fn scale_prop_z(pr: &mut effectcraft_project::Property, f: f64) { k.in_ease.iter_mut().for_each(|e| e.speed *= s_in); k.out_ease.iter_mut().for_each(|e| e.speed *= s_out); } else { - for e in [k.in_ease.get_mut(2), k.out_ease.get_mut(2)].into_iter().flatten() { - e.speed *= f; + // A shorter list lends its first ease to Z (`effective_ease`): give Z its own. + for list in [&mut k.in_ease, &mut k.out_ease] { + if let Some(&first) = list.first() { + list.resize(list.len().max(3), first); + } + if let Some(e) = list.get_mut(2) { + e.speed *= f; + } } } } @@ -162,45 +168,40 @@ fn scale_comp(s: &mut Session, p: &Value) -> Result { let active = s.active_comp_id() == Some(cid); s.edit("Scale Composition", None, |proj, st| { let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - // A parent's Scale carries its children (X and Y only when the parent is 2D); a camera or - // light parent has none. - let scaling_parents: std::collections::HashMap<_, _> = - c.layers.iter().filter(|l| l.transform().is_some_and(|tr| tr.get("scale").is_some())).map(|l| (l.id, (l.is_3d(), l.parent))).collect(); - // A 3D ancestor's Scale carries depth through any 2D layers between (bounded: parent - // chains in a file can loop). - let depth_carried = |mut cur: Option| { + // An ancestor's Scale carries its descendants (X and Y only when that ancestor is 2D), + // through cameras and lights too: the renderer applies every ancestor's transform. + let chain: std::collections::HashMap<_, _> = + c.layers.iter().map(|l| (l.id, (l.transform().is_some_and(|tr| tr.get("scale").is_some()), l.is_3d(), l.parent))).collect(); + // (X and Y carried, Z carried); bounded, as parent chains in a file can loop. + let carried = |mut cur: Option| { + let (mut xy, mut z) = (false, false); for _ in 0..64 { - let Some((three_d, up)) = cur.and_then(|id| scaling_parents.get(&id)) else { return false }; - if *three_d { - return true; - } - cur = *up; + let Some(&(has_scale, three_d, up)) = cur.and_then(|id| chain.get(&id)) else { break }; + xy |= has_scale; + z |= has_scale && three_d; + cur = up; } - false + (xy, z) }; for l in &mut c.layers { - let parent_3d = l.parent.and_then(|id| scaling_parents.get(&id).map(|p| p.0)); - let flat_parent = parent_3d == Some(false) && l.is_3d() && !depth_carried(l.parent); + let (xy_carried, z_carried) = carried(l.parent); + let three_d = l.is_3d(); let Some(tr) = l.transform_mut() else { continue }; - match parent_3d { - None => { - for name in ["position", "positionX", "positionY", "positionZ", "poi", "scale"] { - if let Some(pr) = tr.get_mut(name) { - scale_prop(pr, f); - } - } - } - Some(_) if flat_parent => { - if let Some(pr) = tr.get_mut("positionZ") { + if !xy_carried { + for name in ["position", "positionX", "positionY", "positionZ", "poi", "scale"] { + if let Some(pr) = tr.get_mut(name) { scale_prop(pr, f); } - for name in ["position", "scale"] { - if let Some(pr) = tr.get_mut(name) { - scale_prop_z(pr, f); - } + } + } else if three_d && !z_carried { + if let Some(pr) = tr.get_mut("positionZ") { + scale_prop(pr, f); + } + for name in ["position", "scale"] { + if let Some(pr) = tr.get_mut(name) { + scale_prop_z(pr, f); } } - Some(_) => {} } if let Some(cam) = l.props.sub_mut("cameraOptions") { for name in ["zoom", "focusDistance", "aperture"] { diff --git a/crates/engine/src/tests/scale_comp.rs b/crates/engine/src/tests/scale_comp.rs index 08ddb544b..27397e744 100644 --- a/crates/engine/src/tests/scale_comp.rs +++ b/crates/engine/src/tests/scale_comp.rs @@ -170,6 +170,30 @@ fn scale_comp_keeps_the_ease_of_depth_moves_under_a_2d_parent() { assert_eq!(scale[..2], scale0[..2]); } +#[test] +fn scale_comp_gives_z_its_own_speed_when_a_scale_key_has_one_ease() { + let mut s = comp(200, 100); + let card = solid(&mut s, "#ff0000", 20, 10); + let rig = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [card], "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.setParent", json!({"layers": [card], "parent": rig})).unwrap(); + s.execute("prop.addKey", json!({"layer": card, "path": "transform/scale", "time": 0, "value": [100, 100, 100]})).unwrap(); + s.execute("prop.addKey", json!({"layer": card, "path": "transform/scale", "time": 1, "value": [100, 100, 300]})).unwrap(); + s.execute("keys.setEase", json!({"layer": card, "path": "transform/scale", "time": 0, "side": "out", "speed": 300, "influence": 50})).unwrap(); + // One ease for every dimension, as a project file may store it. + let cid = s.active_comp_id().unwrap(); + let key = + &mut std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(card)).unwrap().props.prop_mut("transform/scale").unwrap().keys + [0]; + key.out_ease.truncate(1); + let scale0 = v3(&s, card, "transform/scale", 0.25); + + s.execute("comp.scale", json!({"factor": 2})).unwrap(); + let scale = v3(&s, card, "transform/scale", 0.25); + assert!((scale[2] - scale0[2] * 2.0).abs() < 1e-6, "{scale0:?} → {scale:?}"); + assert_eq!(scale[..2], scale0[..2], "X and Y keep the shared ease"); +} + #[test] fn scale_comp_scales_depth_once_through_a_3d_grandparent() { let mut s = comp(200, 100); @@ -195,6 +219,30 @@ fn scale_comp_scales_depth_once_through_a_3d_grandparent() { assert!(near, "{card0:?} → {card1:?}"); } +#[test] +fn scale_comp_scales_once_through_a_camera_under_a_scaled_null() { + let mut s = comp(200, 100); + // solid → camera → B (3D null): B's Scale reaches the solid through the camera. + let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.newNull", json!({"name": "B"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [b], "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.setParent", json!({"layers": [cam], "parent": b})).unwrap(); + let held = solid(&mut s, "#00ff00", 10, 10); + s.execute("layer.setSwitch", json!({"layers": [held], "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.setParent", json!({"layers": [held], "parent": cam})).unwrap(); + s.execute("prop.set", json!({"layer": held, "path": "transform/position", "value": [30, 0, 500]})).unwrap(); + let held0 = bounds(&s, 0.0, 1); + assert!(held0.4 > 0, "the solid is in view: {held0:?}"); + + s.execute("comp.scale", json!({"factor": 2})).unwrap(); + assert_eq!(v3(&s, held, "transform/position", 0.0), [30.0, 0.0, 500.0]); + assert_eq!(v3(&s, held, "transform/scale", 0.0), [100.0; 3]); + let held1 = bounds(&s, 0.0, 1); + let near = + [(held1.0, held0.0 * 2), (held1.1, held0.1 * 2), (held1.2, held0.2 * 2 + 1), (held1.3, held0.3 * 2 + 1)].iter().all(|&(x, y)| (x - y).abs() <= 2); + assert!(near, "{held0:?} → {held1:?}"); +} + #[test] fn scale_comp_refuses_a_bad_factor_and_changes_nothing() { let mut s = comp(640, 360);