Scale Composition: Z of 3D layers under 2D parents, percentage guides left alone, region of interest scaled

A 2D parent's Scale carries no depth, so its 3D children scale their own Z.
Percentage guides keep their percent exactly instead of drifting by rounding.
Tests now check the specific errors and cover a layer parented to a camera.
This commit is contained in:
Marco Arnulfo
2026-10-30 00:44:58 +01:00
parent 4aaba7c3bf
commit 76369bc10a
2 changed files with 99 additions and 17 deletions
+50 -14
View File
@@ -91,11 +91,28 @@ 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`.
fn scale_prop_z(pr: &mut effectcraft_project::Property, f: f64) {
use effectcraft_keyframe::Value as KV;
let mul = |v: &mut KV| {
if let KV::Vec3(a) = v {
a[2] *= f;
}
};
mul(&mut pr.value);
for k in &mut pr.keys {
mul(&mut k.value);
k.spatial_in[2] *= f;
k.spatial_out[2] *= f;
}
}
/// Scale Composition (After Effects ships it as File ▸ Scripts ▸ Scale Composition.jsx): the comp
/// and its contents grow or shrink by one factor, keys included, in one undo step. Layers whose
/// parent has a Scale follow that parent; everything else scales its Position (and Point of
/// Interest) and Scale. Anchor Point, masks, shapes and effects live in layer space and follow the
/// layer's Scale. Cameras scale their Zoom so 3D layers keep their place in the frame.
/// layer's Scale. Cameras scale their Zoom so 3D layers keep their place in the frame; a 2D parent
/// carries no depth, so its 3D children scale their Z themselves.
fn scale_comp(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "comp.scale";
let cid = comp_id(s, p)?;
@@ -113,19 +130,36 @@ fn scale_comp(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, format!("the composition would be {w}×{h}: width and height run from 4 to 30,000 px")));
}
let (w, h) = (w as u32, h as u32);
s.edit("Scale Composition", None, |proj, _| {
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; a camera or light parent has none.
let scaling_parents: std::collections::HashSet<_> =
c.layers.iter().filter(|l| l.transform().is_some_and(|tr| tr.get("scale").is_some())).map(|l| l.id).collect();
// 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();
for l in &mut c.layers {
let follows_parent = l.parent.is_some_and(|id| scaling_parents.contains(&id));
if !follows_parent && let Some(tr) = l.transform_mut() {
for name in ["position", "positionX", "positionY", "positionZ", "poi", "scale"] {
if let Some(pr) = tr.get_mut(name) {
scale_prop(pr, f);
let parent_3d = l.parent.and_then(|id| scaling_parents.get(&id).copied());
let flat_parent = parent_3d == Some(false) && 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") {
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"] {
@@ -135,13 +169,15 @@ fn scale_comp(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
let (old_w, old_h) = (c.width, c.height);
for g in &mut c.guides {
let px = g.px(old_w, old_h) * f;
g.set_px(px, w, h);
// Percentage guides keep their percent.
for g in c.guides.iter_mut().filter(|g| !g.percent) {
g.position *= f;
}
c.width = w;
c.height = h;
if active && let Some(r) = &mut st.region_of_interest {
*r = r.map(|v| v * f);
}
Ok(())
})?;
Ok(json!({"width": w, "height": h, "factor": f}))
+49 -3
View File
@@ -5,7 +5,7 @@ use effectcraft_project::{Layer, LayerId};
use effectcraft_time::Tick;
use serde_json::json;
use crate::Session;
use crate::{EngineError, Session};
fn comp(w: u32, h: u32) -> Session {
let mut s = Session::default();
@@ -92,6 +92,7 @@ fn scale_comp_to_a_width_keeps_the_aspect_and_scales_cameras_3d_and_guides() {
let zoom = |s: &Session| layer(s, cam).props.prop("cameraOptions/zoom").unwrap().value.as_f64();
let (zoom0, cam0, poi0) = (zoom(&s), v3(&s, cam, "transform/position", 0.0), v3(&s, cam, "transform/poi", 0.0));
let seen0 = bounds(&s, 0.0, 0);
s.execute("view.setRegionOfInterest", json!({"rect": [10, 20, 70, 70]})).unwrap();
let r = s.execute("comp.scale", json!({"width": 960})).unwrap();
assert_eq!((r["width"].as_u64(), r["height"].as_u64(), r["factor"].as_f64()), (Some(960), Some(540), Some(1.5)));
@@ -107,19 +108,64 @@ fn scale_comp_to_a_width_keeps_the_aspect_and_scales_cameras_3d_and_guides() {
let c = s.active_comp().unwrap();
assert_eq!(c.guides[0].px(c.width, c.height), 240.0);
assert_eq!(c.guides[1].position, 25.0, "a percentage guide keeps its percent");
assert_eq!(s.state.region_of_interest, Some([15.0, 30.0, 105.0, 105.0]), "the region of interest covers the same part");
// A height alone works the same way.
let r = s.execute("comp.scale", json!({"height": 270})).unwrap();
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(480), Some(270)));
}
#[test]
fn scale_comp_scales_depth_under_a_2d_parent_and_children_of_a_camera() {
let mut s = comp(200, 100);
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
// A 3D card under a 2D null: the null's Scale carries X and Y but not Z.
let card = solid(&mut s, "#ff0000", 20, 10);
let rig = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": rig, "path": "transform/position", "value": [100, 50]})).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.set", json!({"layer": card, "path": "transform/position", "value": [-40, 0, 500]})).unwrap();
// A 3D card parented to the camera, in front of it.
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 (card0, held0) = (bounds(&s, 0.0, 0), bounds(&s, 0.0, 1));
let (held_pos0, held_scale0) = (v3(&s, held, "transform/position", 0.0), v3(&s, held, "transform/scale", 0.0));
assert!(card0.4 > 0 && held0.4 > 0, "both cards are in view: {card0:?} {held0:?}");
s.execute("comp.scale", json!({"factor": 2})).unwrap();
let near = |a: (i64, i64, i64, i64, u32), b: (i64, i64, i64, i64, u32)| {
[(a.0, b.0 * 2), (a.1, b.1 * 2), (a.2, b.2 * 2 + 1), (a.3, b.3 * 2 + 1)].iter().all(|&(x, y)| (x - y).abs() <= 2)
};
let (card1, held1) = (bounds(&s, 0.0, 0), bounds(&s, 0.0, 1));
assert!(near(card1, card0), "the card under the 2D null doubles: {card0:?} → {card1:?}");
assert_eq!(v3(&s, card, "transform/position", 0.0), [-40.0, 0.0, 1000.0]);
assert!(near(held1, held0), "the card held by the camera doubles: {held0:?} → {held1:?}");
assert_eq!(v3(&s, held, "transform/position", 0.0), held_pos0.map(|v| v * 2.0));
assert_eq!(v3(&s, held, "transform/scale", 0.0), held_scale0.map(|v| v * 2.0));
}
#[test]
fn scale_comp_refuses_a_bad_factor_and_changes_nothing() {
let mut s = comp(640, 360);
solid(&mut s, "#ff0000", 100, 100);
let before = s.project.clone();
for p in [json!({}), json!({"factor": 0}), json!({"factor": -2}), json!({"width": 0}), json!({"factor": 100}), json!({"factor": 0.001})] {
assert!(s.execute("comp.scale", p.clone()).is_err(), "{p} is refused");
let cases = [
(json!({}), "pass `factor`"),
(json!({"factor": 0}), "4 to 30,000 px"),
(json!({"factor": -2}), "4 to 30,000 px"),
(json!({"width": 0}), "4 to 30,000 px"),
(json!({"factor": 100}), "4 to 30,000 px"),
(json!({"factor": 0.001}), "4 to 30,000 px"),
];
for (p, why) in cases {
match s.execute("comp.scale", p.clone()) {
Err(EngineError::BadParams { cmd, msg }) => assert!(cmd == "comp.scale" && msg.contains(why), "{p}: {msg}"),
other => panic!("{p} is refused as bad parameters, got {other:?}"),
}
assert!(s.project == before, "{p} leaves the project alone");
}
s.execute("comp.scale", json!({"factor": 1})).unwrap();
}