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.
This commit is contained in:
Marco Arnulfo committed 2026-10-30 00:44:58 +01:00
1 parent 76e02f88b7
commit 1f693acea6
2 files changed
+81 -32

No files matched your search

+33 -32
View File
@@ -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<Value> {
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<effectcraft_project::LayerId>| {
// 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<effectcraft_project::LayerId>| {
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"] {
+48
View File
@@ -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);