mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-01 05:58:19 -06:00
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:
@@ -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"] {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user