mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-28 12:07:48 -06:00
Layers: with several layers selected, value edits, stopwatches and viewer Scale / Rotate change them all (#355, #491)
With several layers selected, After Effects applies a property edit to the same property of every selected layer; we changed only the layer whose row was edited (the Properties panel showed only the first selected layer), so a stopwatch switched on one layer and later moves of the others had nothing to keyframe (#355), and scaling or rotating a selection changed one layer (#491). - Engine: `prop.set`, `prop.toggleAnimation` and `prop.toggleKey` take `selected: true`: the change also goes to the same property (by match path, `transform/scale`, `effects/<effect>#n/<param>`) of every other selected, unlocked layer whose value has the same kind, in one undo step (a scrub keeps folding into it through its `merge` key). With `offset: true` (a scrub) each layer changes by the same amount from its own value; a typed value sets them all. Position with Separate Dimensions is handled per layer. - The Timeline, Properties and Effect Controls panels pass `selected` (and `offset` while a value is dragged). The Properties panel heads "(N) Selected Objects" (zh-CN / zh-TW / ja / uk rows). - Viewer: a bounding-box handle drag scales every selected layer by the same ratio about its own anchor point; the Rotation tool turns them all by the same angle (one undo step). Tests: engine test (stopwatch, scrub offset, typed value, keyframe button, one undo step, unselected layers untouched); kittests: the Properties panel with three layers selected (header, stopwatch on all three undone in one step, a scrub moving all three by the same amount) and a handle drag scaling two selected layers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
This commit is contained in:
@@ -348,3 +348,53 @@ fn graph_editor_buttons_that_do_not_fit_move_to_the_more_menu() {
|
||||
click(&mut h, pos2(item[0] + item[2] / 2.0, item[1] + item[3] / 2.0));
|
||||
assert_eq!(h.state().ui.timeline.graph_transform_box, !before, "the menu item runs the button");
|
||||
}
|
||||
|
||||
/// #355, #491: with three layers selected the Properties panel heads "(3) Selected Objects"; its
|
||||
/// stopwatch animates Position on all three (one undo step) and scrubbing X moves all three by
|
||||
/// the same amount.
|
||||
#[test]
|
||||
fn properties_panel_edits_every_selected_layer() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Three", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap();
|
||||
let mut ids = vec![];
|
||||
for (i, c) in ["#e04020", "#20e040", "#2040e0"].iter().enumerate() {
|
||||
let l = s.execute("layer.newSolid", json!({"color": c, "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": [60.0 + 80.0 * i as f64, 90.0]})).unwrap();
|
||||
ids.push(l);
|
||||
}
|
||||
s.execute("layer.select", json!({"layers": ids})).unwrap();
|
||||
let first = s.state.selected_layers[0];
|
||||
let uid = s.active_comp().unwrap().layer(first).unwrap().props.prop("transform/position").unwrap().uid;
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.state_mut().show_panel(effectcraft_ui_egui::dock::PanelKind::Properties);
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().auto.find("properties.selectedObjects").map(|e| e.label.clone()), Some("(3) Selected Objects".into()));
|
||||
let pos = |h: &Harness<'_, EffectcraftApp>, l: u64| {
|
||||
h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("transform/position").unwrap().clone()
|
||||
};
|
||||
let at = |h: &Harness<'_, EffectcraftApp>, id: &str| {
|
||||
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
|
||||
pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)
|
||||
};
|
||||
let sw = at(&h, &format!("properties.prop.{uid}.stopwatch"));
|
||||
h.input_mut().events.push(Event::PointerMoved(sw));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: sw, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: sw, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
h.run_steps(3);
|
||||
assert!(ids.iter().all(|l| pos(&h, *l).is_animated()), "every selected layer's stopwatch");
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert!(ids.iter().all(|l| !pos(&h, *l).is_animated()), "one undo step");
|
||||
// Scrub X: each layer moves by the same amount.
|
||||
let before: Vec<f64> = ids.iter().map(|l| pos(&h, *l).value.components()[0]).collect();
|
||||
let x = at(&h, &format!("properties.prop.{uid}.value.0"));
|
||||
drag(&mut h, x, x + egui::vec2(30.0, 0.0), Default::default());
|
||||
let after: Vec<f64> = ids.iter().map(|l| pos(&h, *l).value.components()[0]).collect();
|
||||
let d = after[0] - before[0];
|
||||
assert!(d.abs() > 1.0, "the scrub moved X: {before:?} → {after:?}");
|
||||
for (a, b) in after.iter().zip(&before) {
|
||||
assert!((a - b - d).abs() < 1e-6, "same change on every layer: {before:?} → {after:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1707,6 +1707,32 @@ fn handle_drags_scale_about_the_anchor_and_follow_the_pointer() {
|
||||
assert!(close(scale(&h), [162.5, 162.5]), "{:?}", scale(&h));
|
||||
}
|
||||
|
||||
/// #491: with several layers selected, a handle drag scales all of them by the same ratio, each
|
||||
/// about its own anchor point, in one undo step.
|
||||
#[test]
|
||||
fn handle_drags_scale_every_selected_layer() {
|
||||
let mut h = harness();
|
||||
let layers = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect::<Vec<_>>();
|
||||
let (box_id, plate) = (layers[0], layers[1]);
|
||||
let scale = |h: &Harness<'_, EffectcraftApp>, id: LayerId| {
|
||||
let l = h.state().session.active_comp().unwrap().layer(id).unwrap().clone();
|
||||
l.props.prop("transform/scale").unwrap().value.as_vec3()
|
||||
};
|
||||
h.state_mut().session.execute("prop.set", json!({"layer": plate.0, "path": "transform/scale", "value": [50, 50, 100]})).unwrap();
|
||||
h.state_mut().session.execute("layer.select", json!({"layers": [box_id.0, plate.0]})).unwrap();
|
||||
h.state_mut().session.execute("view.snapping", json!({"value": false})).unwrap();
|
||||
h.run_steps(2);
|
||||
let undo0 = h.state().session.history.undo.len();
|
||||
// The box's bottom-right corner (360, 220) to (400, 260): twice the box's size.
|
||||
let corner = rect(&h, &format!("viewer.handle.{}.2", box_id.0)).center();
|
||||
let to = screen(&h, [400.0, 260.0]);
|
||||
drag_path(&mut h, &[corner, to], Default::default());
|
||||
let (b, p) = (scale(&h, box_id), scale(&h, plate));
|
||||
assert!((b[0] - 200.0).abs() < 1.0 && (b[1] - 200.0).abs() < 1.0, "{b:?}");
|
||||
assert!((p[0] - 100.0).abs() < 1.0 && (p[1] - 100.0).abs() < 1.0, "the other layer doubles too: {p:?}");
|
||||
assert_eq!(h.state().session.history.undo.len(), undo0 + 1, "one undo step");
|
||||
}
|
||||
|
||||
/// #252: with snapping on, a dragged handle snaps to the comp's corners and edges (and other
|
||||
/// layers'), so a layer scales exactly to the comp; Shift keeps the proportions and still lands
|
||||
/// on the comp's size.
|
||||
|
||||
Reference in New Issue
Block a user