diff --git a/crates/engine/src/cmd/comp_more.rs b/crates/engine/src/cmd/comp_more.rs index a51d9636c..2783c47be 100644 --- a/crates/engine/src/cmd/comp_more.rs +++ b/crates/engine/src/cmd/comp_more.rs @@ -91,19 +91,48 @@ fn scale_prop(pr: &mut effectcraft_project::Property, f: f64) { } } -/// Multiply only the Z of a 3D value (and of its keys and spatial tangents) by `f`. +/// Multiply only the Z of a 3D value (and of its keys and spatial tangents) by `f`. Speeds follow: +/// a spatial side's speed by how much the path's direction there stretches, so the ease keeps +/// its shape; a per-dimension one (Scale) by `f` for Z only. fn scale_prop_z(pr: &mut effectcraft_project::Property, f: f64) { use effectcraft_keyframe::Value as KV; + let stretch = |v: [f64; 3]| { + let n = v[0].hypot(v[1]).hypot(v[2]); + if n > 1e-12 && n.is_finite() { v[0].hypot(v[1]).hypot(v[2] * f) / n } else { 1.0 } + }; + let pos = |k: &effectcraft_project::Keyframe| k.value.as_vec3(); + let sub = |a: [f64; 3], b: [f64; 3]| [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; + let is_zero = |v: [f64; 3]| v.iter().all(|x| x.abs() <= 1e-12); + // Read every side's direction before anything changes (auto tangents come from the neighbours). + let sides: Vec<(f64, f64)> = (0..pr.keys.len()) + .map(|i| { + let (tin, tout) = effectcraft_keyframe::spatial_tangents(&pr.keys, i); + let here = pr.keys.get(i).map(pos).unwrap_or_default(); + let chord = |j: Option| j.and_then(|j| pr.keys.get(j)).map(|k| sub(pos(k), here)).unwrap_or_default(); + let tin = if is_zero(tin) { chord(i.checked_sub(1)) } else { tin }; + let tout = if is_zero(tout) { chord(Some(i + 1)) } else { tout }; + (stretch(tin), stretch(tout)) + }) + .collect(); + let spatial = pr.spatial; let mul = |v: &mut KV| { if let KV::Vec3(a) = v { a[2] *= f; } }; mul(&mut pr.value); - for k in &mut pr.keys { + for (k, (s_in, s_out)) in pr.keys.iter_mut().zip(sides) { mul(&mut k.value); k.spatial_in[2] *= f; k.spatial_out[2] *= f; + if spatial { + 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; + } + } } } @@ -136,10 +165,22 @@ fn scale_comp(s: &mut Session, p: &Value) -> Result { // 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())).collect(); + 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| { + 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; + } + false + }; for l in &mut c.layers { - let parent_3d = l.parent.and_then(|id| scaling_parents.get(&id).copied()); - let flat_parent = parent_3d == Some(false) && l.is_3d(); + 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 Some(tr) = l.transform_mut() else { continue }; match parent_3d { None => { diff --git a/crates/engine/src/tests/scale_comp.rs b/crates/engine/src/tests/scale_comp.rs index 252366bee..08ddb544b 100644 --- a/crates/engine/src/tests/scale_comp.rs +++ b/crates/engine/src/tests/scale_comp.rs @@ -147,6 +147,54 @@ fn scale_comp_scales_depth_under_a_2d_parent_and_children_of_a_camera() { assert_eq!(v3(&s, held, "transform/scale", 0.0), held_scale0.map(|v| v * 2.0)); } +#[test] +fn scale_comp_keeps_the_ease_of_depth_moves_under_a_2d_parent() { + 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/position", "time": 0, "value": [0, 0, 0]})).unwrap(); + s.execute("prop.addKey", json!({"layer": card, "path": "transform/position", "time": 1, "value": [0, 0, 100]})).unwrap(); + s.execute("keys.setEase", json!({"layer": card, "path": "transform/position", "time": 0, "side": "out", "speed": 100, "influence": 50})).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(); + let (pos0, scale0) = (v3(&s, card, "transform/position", 0.25), v3(&s, card, "transform/scale", 0.25)); + + s.execute("comp.scale", json!({"factor": 2})).unwrap(); + // Twice the distance in the same time: the same ease, every frame twice as deep. + let (pos, scale) = (v3(&s, card, "transform/position", 0.25), v3(&s, card, "transform/scale", 0.25)); + assert!((pos[2] - pos0[2] * 2.0).abs() < 1e-6, "{pos0:?} → {pos:?}"); + assert!((scale[2] - scale0[2] * 2.0).abs() < 1e-6, "{scale0:?} → {scale:?}"); + assert_eq!(scale[..2], scale0[..2]); +} + +#[test] +fn scale_comp_scales_depth_once_through_a_3d_grandparent() { + let mut s = comp(200, 100); + s.execute("layer.newCamera", json!({})).unwrap(); + // card (3D) → A (2D null) → B (3D null): B's Scale already carries the depth. + let card = solid(&mut s, "#ff0000", 20, 10); + let a = s.execute("layer.newNull", json!({"name": "A"})).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": [card, b], "switch": "threeD", "value": true})).unwrap(); + s.execute("prop.set", json!({"layer": b, "path": "transform/position", "value": [100, 50, 0]})).unwrap(); + s.execute("layer.setParent", json!({"layers": [a], "parent": b})).unwrap(); + s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [0, 0]})).unwrap(); + s.execute("layer.setParent", json!({"layers": [card], "parent": a})).unwrap(); + s.execute("prop.set", json!({"layer": card, "path": "transform/position", "value": [-40, 0, 500]})).unwrap(); + let card0 = bounds(&s, 0.0, 0); + assert!(card0.4 > 0, "the card is in view"); + + s.execute("comp.scale", json!({"factor": 2})).unwrap(); + assert_eq!(v3(&s, card, "transform/position", 0.0), [-40.0, 0.0, 500.0]); + let card1 = bounds(&s, 0.0, 0); + let near = + [(card1.0, card0.0 * 2), (card1.1, card0.1 * 2), (card1.2, card0.2 * 2 + 1), (card1.3, card0.3 * 2 + 1)].iter().all(|&(x, y)| (x - y).abs() <= 2); + assert!(near, "{card0:?} → {card1:?}"); +} + #[test] fn scale_comp_refuses_a_bad_factor_and_changes_nothing() { let mut s = comp(640, 360);