mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04: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 <[email protected]> Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
This commit is contained in:
9 files changed
+342
-20
No files matched your search
@@ -165,6 +165,145 @@ fn property_reply(p: &Value, layer: &Layer, pr: &Property, time: Tick, value: Va
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- several selected layers
|
||||||
|
|
||||||
|
/// With `selected: true` (the Timeline, Properties and Effect Controls panels pass it): the same
|
||||||
|
/// property (by match path, `transform/position`, `effects/<effect>#n/<param>`) of every other
|
||||||
|
/// selected, unlocked layer of the comp whose value is of the same kind, as (layer, uid). Empty
|
||||||
|
/// when `selected` is not set or the edited layer isn't selected. After Effects applies a value
|
||||||
|
/// edit, the stopwatch and the keyframe button to all selected layers (#355, #491).
|
||||||
|
fn selected_twins(s: &Session, p: &Value, cmd: &str) -> Result<Vec<(LayerId, Uid)>> {
|
||||||
|
if b_p(p, "selected") != Some(true) {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
let (cid, lid, uid) = prop_ref(s, p, cmd)?;
|
||||||
|
let Some(comp) = s.project.comp(cid) else { return Ok(vec![]) };
|
||||||
|
let Some(layer) = comp.layer(lid) else { return Ok(vec![]) };
|
||||||
|
let (Some(path), Some(pr)) = (layer.props.match_path_of(uid), layer.props.find(uid)) else { return Ok(vec![]) };
|
||||||
|
if !s.state.selected_layers.contains(&lid) {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
let kind = (pr.value.kind_name(), pr.value.components().len());
|
||||||
|
Ok(s.state
|
||||||
|
.selected_layers
|
||||||
|
.iter()
|
||||||
|
.filter(|o| **o != lid)
|
||||||
|
.filter_map(|o| comp.layer(*o))
|
||||||
|
.filter(|l| !l.switches.locked)
|
||||||
|
.filter_map(|l| l.props.prop(&path).filter(|q| (q.value.kind_name(), q.value.components().len()) == kind).map(|q| (l.id, q.uid)))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `p` aimed at property `uid` of layer `lid` instead.
|
||||||
|
fn twin_params(p: &Value, lid: LayerId, uid: Uid) -> Value {
|
||||||
|
let mut q = p.clone();
|
||||||
|
if let Some(o) = q.as_object_mut() {
|
||||||
|
o.remove("path");
|
||||||
|
o.remove("selected");
|
||||||
|
o.remove("offset");
|
||||||
|
}
|
||||||
|
q["layer"] = json!(lid.0);
|
||||||
|
q["prop"] = json!(uid);
|
||||||
|
q
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The value of property `uid` of `lid` at the CTI as numbers (Position with Separate
|
||||||
|
/// Dimensions: its X / Y / Z Position).
|
||||||
|
fn current_components(s: &Session, cid: ItemId, lid: LayerId, uid: Uid) -> Option<Vec<f64>> {
|
||||||
|
let l = s.project.comp(cid)?.layer(lid)?;
|
||||||
|
let lt = l.layer_time(s.time_of(cid));
|
||||||
|
if let Some(tr) = l.transform()
|
||||||
|
&& tr.get("position").is_some_and(|q| q.uid == uid)
|
||||||
|
&& tr.get("positionX").is_some()
|
||||||
|
{
|
||||||
|
return Some(["positionX", "positionY", "positionZ"].iter().filter_map(|m| tr.get(m)).map(|q| q.value_at(lt).as_f64()).collect());
|
||||||
|
}
|
||||||
|
Some(l.props.find(uid)?.value_at(lt).components())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A JSON number or array of numbers.
|
||||||
|
fn json_numbers(v: &Value) -> Option<Vec<f64>> {
|
||||||
|
match v {
|
||||||
|
Value::Number(n) => Some(vec![n.as_f64()?]),
|
||||||
|
Value::Array(a) => a.iter().map(Value::as_f64).collect(),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `prop.set`; with `selected`, also on the other selected layers: the same value, or with
|
||||||
|
/// `offset` (a scrub) the same change from each layer's own value. One undo step (with `merge`,
|
||||||
|
/// one that a continuing scrub keeps folding into).
|
||||||
|
fn set_selected(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
|
let twins = selected_twins(s, p, "prop.set")?;
|
||||||
|
if twins.is_empty() {
|
||||||
|
return set(s, p);
|
||||||
|
}
|
||||||
|
let (cid, lid, uid) = prop_ref(s, p, "prop.set")?;
|
||||||
|
let new = p.get("value").and_then(json_numbers);
|
||||||
|
let delta = match (b_p(p, "offset"), &new, current_components(s, cid, lid, uid)) {
|
||||||
|
(Some(true), Some(n), Some(c)) => Some(n.iter().zip(c.iter().chain(std::iter::repeat(&0.0))).map(|(a, b)| a - b).collect::<Vec<f64>>()),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let run = |s: &mut Session| -> Result<Value> {
|
||||||
|
let out = set(s, p)?;
|
||||||
|
for (olid, ouid) in twins {
|
||||||
|
let mut q = twin_params(p, olid, ouid);
|
||||||
|
if let Some(d) = &delta
|
||||||
|
&& let Some(c) = current_components(s, cid, olid, ouid)
|
||||||
|
{
|
||||||
|
let v: Vec<f64> = c.iter().enumerate().map(|(i, x)| x + d.get(i).copied().unwrap_or(0.0)).collect();
|
||||||
|
q["value"] = if p.get("value").is_some_and(Value::is_number) { json!(v.first().copied().unwrap_or(0.0)) } else { json!(v) };
|
||||||
|
}
|
||||||
|
// (a layer that can't take it, e.g. a clamped or static property, keeps its value)
|
||||||
|
let _ = set(s, &q);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
};
|
||||||
|
if merge_p(p).is_some() { run(s) } else { super::app_more::grouped(s, "Change Property", run) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `prop.toggleAnimation`; with `selected`, the other selected layers' same property follows.
|
||||||
|
fn toggle_anim_selected(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
|
let twins = selected_twins(s, p, "prop.toggleAnimation")?;
|
||||||
|
if twins.is_empty() {
|
||||||
|
return toggle_anim(s, p);
|
||||||
|
}
|
||||||
|
let run = |s: &mut Session| -> Result<Value> {
|
||||||
|
let on = toggle_anim(s, p)?;
|
||||||
|
for (olid, ouid) in twins {
|
||||||
|
let mut q = twin_params(p, olid, ouid);
|
||||||
|
q["value"] = on.clone();
|
||||||
|
let _ = toggle_anim(s, &q);
|
||||||
|
}
|
||||||
|
Ok(on)
|
||||||
|
};
|
||||||
|
// (a stopwatch drag over several rows keeps folding into the step its `merge` names)
|
||||||
|
if merge_p(p).is_some() { run(s) } else { super::app_more::grouped(s, "Toggle Animation", run) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `prop.toggleKey`; with `selected`, the other selected layers' same property gets (or loses)
|
||||||
|
/// its keyframe at the current time too.
|
||||||
|
fn toggle_key_selected(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
|
let twins = selected_twins(s, p, "prop.toggleKey")?;
|
||||||
|
if twins.is_empty() {
|
||||||
|
return toggle_key(s, p);
|
||||||
|
}
|
||||||
|
let cid = prop_ref(s, p, "prop.toggleKey")?.0;
|
||||||
|
super::app_more::grouped(s, "Add/Remove Keyframe", |s| {
|
||||||
|
let added = toggle_key(s, p)?;
|
||||||
|
let want = added.as_bool().unwrap_or(true);
|
||||||
|
for (olid, ouid) in twins {
|
||||||
|
let _ = with_prop(s, "Add/Remove Keyframe", None, cid, olid, ouid, |pr, lt| {
|
||||||
|
if key_at(&pr.keys, lt).is_some() != want {
|
||||||
|
toggle_key_at(pr, lt);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(added)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let (cid, lid, uid) = prop_ref(s, p, "prop.set")?;
|
let (cid, lid, uid) = prop_ref(s, p, "prop.set")?;
|
||||||
// Position with Separate Dimensions: write the X/Y/Z Position properties instead.
|
// Position with Separate Dimensions: write the X/Y/Z Position properties instead.
|
||||||
@@ -1001,13 +1140,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
"Set Property Value",
|
"Set Property Value",
|
||||||
[],
|
[],
|
||||||
None,
|
None,
|
||||||
"{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?}",
|
"{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change)}",
|
||||||
has_layers,
|
has_layers,
|
||||||
set
|
set_selected
|
||||||
),
|
),
|
||||||
cmd!("prop.toggleAnimation", "Toggle Stopwatch", [], None, "{layer?, path|prop, value?, merge?}", has_layers, toggle_anim),
|
cmd!("prop.toggleAnimation", "Toggle Stopwatch", [], None, "{layer?, path|prop, value?, merge?, selected?}", has_layers, toggle_anim_selected),
|
||||||
cmd!("prop.addKey", "Add Keyframe", [], None, "{layer?, path|prop, time? (layer s by default), timeBase?: layer|comp, value?}", has_layers, add_key),
|
cmd!("prop.addKey", "Add Keyframe", [], None, "{layer?, path|prop, time? (layer s by default), timeBase?: layer|comp, value?}", has_layers, add_key),
|
||||||
cmd!("prop.toggleKey", "Add or Remove Keyframe at Current Time", [], None, "{layer?, path|prop}", has_layers, toggle_key),
|
cmd!("prop.toggleKey", "Add or Remove Keyframe at Current Time", [], None, "{layer?, path|prop, selected?}", has_layers, toggle_key_selected),
|
||||||
cmd!(
|
cmd!(
|
||||||
"keys.toggleTransform",
|
"keys.toggleTransform",
|
||||||
"Add or Remove Transform Keyframe",
|
"Add or Remove Transform Keyframe",
|
||||||
|
|||||||
@@ -831,3 +831,51 @@ fn toggle_transform_key_covers_selected_layers_separated_position_and_3d_rotatio
|
|||||||
}
|
}
|
||||||
assert!(s.execute("keys.toggleTransform", json!({"prop": "skew"})).is_err());
|
assert!(s.execute("keys.toggleTransform", json!({"prop": "skew"})).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #355, #491: with several layers selected, the stopwatch, the keyframe button and value edits
|
||||||
|
/// (`selected: true`, as the panels send them) apply to every selected layer in one undo step; a
|
||||||
|
/// scrub (`offset`) moves each layer by the same amount, a typed value sets them all.
|
||||||
|
#[test]
|
||||||
|
fn property_edits_apply_to_every_selected_layer() {
|
||||||
|
let (mut s, a) = setup();
|
||||||
|
let b = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
|
||||||
|
let c = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
|
||||||
|
s.execute("prop.set", json!({"layer": b, "path": "transform/position", "value": [10, 20]})).unwrap();
|
||||||
|
s.execute("layer.select", json!({"layers": [a, b]})).unwrap();
|
||||||
|
let pos = |s: &Session, l: u64| prop(s, l, "transform/position").value_at(effectcraft_time::Tick::ZERO).components();
|
||||||
|
let uid = prop(&s, a, "transform/position").uid;
|
||||||
|
|
||||||
|
// Stopwatch on all selected layers, one undo step; the unselected layer is untouched.
|
||||||
|
let undo = s.history.undo.len();
|
||||||
|
s.execute("prop.toggleAnimation", json!({"layer": a, "prop": uid, "selected": true})).unwrap();
|
||||||
|
assert!(prop(&s, a, "transform/position").is_animated() && prop(&s, b, "transform/position").is_animated());
|
||||||
|
assert!(!prop(&s, c, "transform/position").is_animated());
|
||||||
|
assert_eq!(s.history.undo.len(), undo + 1);
|
||||||
|
s.execute("edit.undo", json!({})).unwrap();
|
||||||
|
assert!(!prop(&s, a, "transform/position").is_animated() && !prop(&s, b, "transform/position").is_animated());
|
||||||
|
|
||||||
|
// A scrub moves both by the same change (merged into one step while it continues).
|
||||||
|
let (pa, pb) = (pos(&s, a), pos(&s, b));
|
||||||
|
for k in 1..=3 {
|
||||||
|
let v = [pa[0] + 5.0 * k as f64, pa[1]];
|
||||||
|
s.execute("prop.set", json!({"layer": a, "prop": uid, "value": v, "selected": true, "offset": true, "merge": "scrub-1"})).unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(pos(&s, a)[..2], [pa[0] + 15.0, pa[1]]);
|
||||||
|
assert_eq!(pos(&s, b)[..2], [pb[0] + 15.0, pb[1]]);
|
||||||
|
assert_eq!(s.history.undo.len(), undo + 1, "one undo step for the scrub");
|
||||||
|
// A typed value sets them all.
|
||||||
|
s.execute("prop.set", json!({"layer": a, "path": "transform/opacity", "value": 40, "selected": true})).unwrap();
|
||||||
|
assert_eq!(prop(&s, b, "transform/opacity").value.as_f64(), 40.0);
|
||||||
|
assert_eq!(prop(&s, c, "transform/opacity").value.as_f64(), 100.0);
|
||||||
|
s.execute("edit.undo", json!({})).unwrap();
|
||||||
|
assert_eq!(prop(&s, a, "transform/opacity").value.as_f64(), 100.0);
|
||||||
|
assert_eq!(prop(&s, b, "transform/opacity").value.as_f64(), 100.0);
|
||||||
|
|
||||||
|
// The keyframe button adds a key on each selected layer.
|
||||||
|
s.execute("prop.toggleKey", json!({"layer": a, "path": "transform/scale", "selected": true})).unwrap();
|
||||||
|
assert_eq!(prop(&s, a, "transform/scale").keys.len(), 1);
|
||||||
|
assert_eq!(prop(&s, b, "transform/scale").keys.len(), 1);
|
||||||
|
// Without `selected` only the given layer changes.
|
||||||
|
s.execute("prop.set", json!({"layer": a, "path": "transform/rotation", "value": 30})).unwrap();
|
||||||
|
assert_eq!(prop(&s, b, "transform/rotation").value.as_f64(), 0.0);
|
||||||
|
}
|
||||||
@@ -405,6 +405,7 @@ const JAPANESE: &[(&str, &str)] = &[
|
|||||||
("Select a command to change its shortcut.", "ショートカットを変更するコマンドを選択してください。"),
|
("Select a command to change its shortcut.", "ショートカットを変更するコマンドを選択してください。"),
|
||||||
("Select a layer to see its effects", "レイヤーを選択するとエフェクトが表示されます"),
|
("Select a layer to see its effects", "レイヤーを選択するとエフェクトが表示されます"),
|
||||||
("Select a layer to see its properties", "レイヤーを選択するとプロパティが表示されます"),
|
("Select a layer to see its properties", "レイヤーを選択するとプロパティが表示されます"),
|
||||||
|
("({}) Selected Objects", "({}) 個の選択されたオブジェクト"),
|
||||||
(
|
(
|
||||||
"Select a layer with a mask or shape path (or the path property), or Puppet pins.",
|
"Select a layer with a mask or shape path (or the path property), or Puppet pins.",
|
||||||
"マスクまたはシェイプパスのあるレイヤー(またはパスプロパティ)か、パペットピンを選択してください。",
|
"マスクまたはシェイプパスのあるレイヤー(またはパスプロパティ)か、パペットピンを選択してください。",
|
||||||
@@ -840,6 +841,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
|
|||||||
("Select a command to change its shortcut.", "选择一条命令以更改其快捷键。"),
|
("Select a command to change its shortcut.", "选择一条命令以更改其快捷键。"),
|
||||||
("Select a layer to see its effects", "选择图层以查看其效果"),
|
("Select a layer to see its effects", "选择图层以查看其效果"),
|
||||||
("Select a layer to see its properties", "选择图层以查看其属性"),
|
("Select a layer to see its properties", "选择图层以查看其属性"),
|
||||||
|
("({}) Selected Objects", "({}) 个选定对象"),
|
||||||
(
|
(
|
||||||
"Select a layer with a mask or shape path (or the path property), or Puppet pins.",
|
"Select a layer with a mask or shape path (or the path property), or Puppet pins.",
|
||||||
"请选择带蒙版或形状路径(或路径属性)的图层,或带有人偶定位点的图层。",
|
"请选择带蒙版或形状路径(或路径属性)的图层,或带有人偶定位点的图层。",
|
||||||
@@ -1364,6 +1366,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
|
|||||||
("Select a command to change its shortcut.", "選取一條命令以更改其快速鍵。"),
|
("Select a command to change its shortcut.", "選取一條命令以更改其快速鍵。"),
|
||||||
("Select a layer to see its effects", "選取圖層以查看其效果"),
|
("Select a layer to see its effects", "選取圖層以查看其效果"),
|
||||||
("Select a layer to see its properties", "選取圖層以查看其屬性"),
|
("Select a layer to see its properties", "選取圖層以查看其屬性"),
|
||||||
|
("({}) Selected Objects", "({}) 個選取的物件"),
|
||||||
(
|
(
|
||||||
"Select a layer with a mask or shape path (or the path property), or Puppet pins.",
|
"Select a layer with a mask or shape path (or the path property), or Puppet pins.",
|
||||||
"請選取帶遮色片或形狀路徑(或路徑屬性)的圖層,或帶有人偶釘選點的圖層。",
|
"請選取帶遮色片或形狀路徑(或路徑屬性)的圖層,或帶有人偶釘選點的圖層。",
|
||||||
@@ -1882,6 +1885,7 @@ const UKRAINIAN: &[(&str, &str)] = &[
|
|||||||
("Select a command to change its shortcut.", "Виберіть команду, щоб змінити її клавіатурне скорочення."),
|
("Select a command to change its shortcut.", "Виберіть команду, щоб змінити її клавіатурне скорочення."),
|
||||||
("Select a layer to see its effects", "Виділіть шар, щоб переглянути його ефекти"),
|
("Select a layer to see its effects", "Виділіть шар, щоб переглянути його ефекти"),
|
||||||
("Select a layer to see its properties", "Виділіть шар, щоб переглянути його властивості"),
|
("Select a layer to see its properties", "Виділіть шар, щоб переглянути його властивості"),
|
||||||
|
("({}) Selected Objects", "Вибрано об’єктів: ({})"),
|
||||||
(
|
(
|
||||||
"Select a layer with a mask or shape path (or the path property), or Puppet pins.",
|
"Select a layer with a mask or shape path (or the path property), or Puppet pins.",
|
||||||
"Виділіть шар із маскою або контуром фігури (або властивість контуру) чи опорні точки деформації.",
|
"Виділіть шар із маскою або контуром фігури (або властивість контуру) чи опорні точки деформації.",
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ fn key_navigator(
|
|||||||
match what {
|
match what {
|
||||||
"prev" => actions.push(("time.go".into(), json!({"to": "prevKey", "prop": uid}))),
|
"prev" => actions.push(("time.go".into(), json!({"to": "prevKey", "prop": uid}))),
|
||||||
"next" => actions.push(("time.go".into(), json!({"to": "nextKey", "prop": uid}))),
|
"next" => actions.push(("time.go".into(), json!({"to": "nextKey", "prop": uid}))),
|
||||||
_ => actions.push(("prop.toggleKey".into(), json!({"layer": layer.id.0, "prop": uid}))),
|
_ => actions.push(("prop.toggleKey".into(), json!({"layer": layer.id.0, "prop": uid, "selected": true}))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -241,7 +241,7 @@ fn prop_row(
|
|||||||
);
|
);
|
||||||
app.auto.add(&format!("effectControls.prop.{uid}.stopwatch"), swr, &prop.name);
|
app.auto.add(&format!("effectControls.prop.{uid}.stopwatch"), swr, &prop.name);
|
||||||
if resp.clicked() {
|
if resp.clicked() {
|
||||||
actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid})));
|
actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid, "selected": true})));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if prop.is_animated() {
|
if prop.is_animated() {
|
||||||
@@ -273,8 +273,11 @@ fn prop_row(
|
|||||||
ui.interact(name_clip, egui::Id::new(("ec-prop-name", uid)), Sense::click());
|
ui.interact(name_clip, egui::Id::new(("ec-prop-name", uid)), Sense::click());
|
||||||
app.auto.add(&format!("effectControls.prop.{uid}.name"), name_clip, &prop.name);
|
app.auto.add(&format!("effectControls.prop.{uid}.name"), name_clip, &prop.name);
|
||||||
let merge = format!("ec-{uid}");
|
let merge = format!("ec-{uid}");
|
||||||
let set =
|
// With several layers selected the edit applies to the same effect property of each (#491).
|
||||||
|actions: &mut Actions, v: serde_json::Value| actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge})));
|
let scrub = ui.ctx().dragged_id().is_some();
|
||||||
|
let set = |actions: &mut Actions, v: serde_json::Value| {
|
||||||
|
actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub})))
|
||||||
|
};
|
||||||
let twirl_open = |app: &mut EffectcraftApp, ui: &mut egui::Ui| {
|
let twirl_open = |app: &mut EffectcraftApp, ui: &mut egui::Ui| {
|
||||||
let open = app.ui.fx_slider_open.contains(&uid);
|
let open = app.ui.fx_slider_open.contains(&uid);
|
||||||
let tw = Rect::from_center_size(pos2(swr.min.x - 9.0, cy), vec2(10.0, 10.0));
|
let tw = Rect::from_center_size(pos2(swr.min.x - 9.0, cy), vec2(10.0, 10.0));
|
||||||
|
|||||||
@@ -72,6 +72,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||||||
let mut y = top;
|
let mut y = top;
|
||||||
let mut actions: Actions = vec![];
|
let mut actions: Actions = vec![];
|
||||||
|
|
||||||
|
// Several layers selected: "(3) Selected Objects", as in After Effects. The rows show the
|
||||||
|
// first layer; the stopwatch, keyframe button and value edits apply to all of them (#355).
|
||||||
|
let selected = app.session.state.selected_layers.iter().filter(|id| comp.layer(**id).is_some()).count();
|
||||||
|
if selected > 1 {
|
||||||
|
let n = selected.to_string();
|
||||||
|
let label = crate::i18n::tr_args("({}) Selected Objects", &[&n]);
|
||||||
|
p.text(pos2(x0, y + ROW_H / 2.0), Align2::LEFT_CENTER, &label, Tokens::semibold(12.0), t.text);
|
||||||
|
app.auto.add("properties.selectedObjects", Rect::from_min_size(pos2(x0, y), vec2(w, ROW_H)), &label);
|
||||||
|
y += ROW_H + 4.0;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- Layer Transform
|
// ---------------------------------------------------------------- Layer Transform
|
||||||
if let Some(tr) = layer.transform().cloned() {
|
if let Some(tr) = layer.transform().cloned() {
|
||||||
section_header(app, ui, &p, x0, w, y, "Layer Transform", "properties.transform");
|
section_header(app, ui, &p, x0, w, y, "Layer Transform", "properties.transform");
|
||||||
@@ -218,7 +229,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
|
|||||||
match what {
|
match what {
|
||||||
"prev" => actions.push(("time.go".into(), json!({"to": "prevKey", "prop": uid}))),
|
"prev" => actions.push(("time.go".into(), json!({"to": "prevKey", "prop": uid}))),
|
||||||
"next" => actions.push(("time.go".into(), json!({"to": "nextKey", "prop": uid}))),
|
"next" => actions.push(("time.go".into(), json!({"to": "nextKey", "prop": uid}))),
|
||||||
_ => actions.push(("prop.toggleKey".into(), json!({"layer": lid, "prop": uid}))),
|
_ => actions.push(("prop.toggleKey".into(), json!({"layer": lid, "prop": uid, "selected": true}))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -228,13 +239,18 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
|
|||||||
icons::paint(p, swr, Icon::Stopwatch, if resp.hovered() { t.text } else { t.text_dim });
|
icons::paint(p, swr, Icon::Stopwatch, if resp.hovered() { t.text } else { t.text_dim });
|
||||||
app.auto.add(&format!("{base}.stopwatch"), swr, &pr.name);
|
app.auto.add(&format!("{base}.stopwatch"), swr, &pr.name);
|
||||||
if resp.clicked() {
|
if resp.clicked() {
|
||||||
actions.push(("prop.toggleAnimation".into(), json!({"layer": lid, "prop": uid})));
|
actions.push(("prop.toggleAnimation".into(), json!({"layer": lid, "prop": uid, "selected": true})));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
p.text(pos2(r.min.x + 42.0, cy), Align2::LEFT_CENTER, &pr.name, Tokens::ui(12.0), t.text);
|
p.text(pos2(r.min.x + 42.0, cy), Align2::LEFT_CENTER, &pr.name, Tokens::ui(12.0), t.text);
|
||||||
let vx = r.min.x + (r.width() * 0.5).max(130.0);
|
let vx = r.min.x + (r.width() * 0.5).max(130.0);
|
||||||
let merge = format!("props-{uid}");
|
let merge = format!("props-{uid}");
|
||||||
let set = |actions: &mut Actions, v: serde_json::Value| actions.push(("prop.set".into(), json!({"layer": lid, "prop": uid, "value": v, "merge": merge})));
|
// With several layers selected the edit applies to all: a scrub by the same change, a typed
|
||||||
|
// value as it is.
|
||||||
|
let scrub = ui.ctx().dragged_id().is_some();
|
||||||
|
let set = |actions: &mut Actions, v: serde_json::Value| {
|
||||||
|
actions.push(("prop.set".into(), json!({"layer": lid, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub})))
|
||||||
|
};
|
||||||
let value = ectx.value(layer, pr);
|
let value = ectx.value(layer, pr);
|
||||||
let is_scale = pr.name == "Scale";
|
let is_scale = pr.name == "Scale";
|
||||||
let pct = is_scale || pr.name == "Opacity";
|
let pct = is_scale || pr.name == "Opacity";
|
||||||
|
|||||||
@@ -2403,7 +2403,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||||||
ui_actions.push(UiAct::SetTime(layer.comp_time(k.time)));
|
ui_actions.push(UiAct::SetTime(layer.comp_time(k.time)));
|
||||||
}
|
}
|
||||||
if ui.interact(mid, egui::Id::new(("kmid", uid)), Sense::click()).clicked() {
|
if ui.interact(mid, egui::Id::new(("kmid", uid)), Sense::click()).clicked() {
|
||||||
actions.push(("prop.toggleKey".into(), json!({"layer": layer.id.0, "prop": uid})));
|
actions.push(("prop.toggleKey".into(), json!({"layer": layer.id.0, "prop": uid, "selected": true})));
|
||||||
}
|
}
|
||||||
app.auto.add(&format!("timeline.prop.{uid}.addKey"), mid, "Add or remove keyframe");
|
app.auto.add(&format!("timeline.prop.{uid}.addKey"), mid, "Add or remove keyframe");
|
||||||
}
|
}
|
||||||
@@ -2426,10 +2426,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||||||
actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid})));
|
actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid})));
|
||||||
}
|
}
|
||||||
} else if let Some((value, merge)) = switch_gesture(ui, &resp, r, "stopwatch", prop.is_animated()) {
|
} else if let Some((value, merge)) = switch_gesture(ui, &resp, r, "stopwatch", prop.is_animated()) {
|
||||||
actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid, "value": value, "merge": merge})));
|
actions
|
||||||
|
.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid, "value": value, "merge": merge, "selected": true})));
|
||||||
} else if resp.clicked() && !resp.clicked_by(egui::PointerButton::Primary) {
|
} else if resp.clicked() && !resp.clicked_by(egui::PointerButton::Primary) {
|
||||||
// Keyboard or accessibility activation.
|
// Keyboard or accessibility activation.
|
||||||
actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid})));
|
actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid, "selected": true})));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let name_x = indent + 12.0;
|
let name_x = indent + 12.0;
|
||||||
@@ -3510,8 +3511,11 @@ fn value_editor(
|
|||||||
let uid = prop.uid;
|
let uid = prop.uid;
|
||||||
let mut dims = vec![];
|
let mut dims = vec![];
|
||||||
let merge = format!("scrub-{uid}");
|
let merge = format!("scrub-{uid}");
|
||||||
|
// With several layers selected the edit applies to all of them (#491): a scrub by the same
|
||||||
|
// change, a typed value as it is.
|
||||||
|
let scrub = ui.ctx().dragged_id().is_some();
|
||||||
let set = |actions: &mut Vec<(String, serde_json::Value)>, v: serde_json::Value| {
|
let set = |actions: &mut Vec<(String, serde_json::Value)>, v: serde_json::Value| {
|
||||||
actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge})))
|
actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub})))
|
||||||
};
|
};
|
||||||
let is_3d = layer.is_3d();
|
let is_3d = layer.is_3d();
|
||||||
let mut x = at.x;
|
let mut x = at.x;
|
||||||
|
|||||||
@@ -84,12 +84,17 @@ enum Gesture {
|
|||||||
/// Layer pixels → comp as the drag began, and its inverse.
|
/// Layer pixels → comp as the drag began, and its inverse.
|
||||||
l2c: Mat3,
|
l2c: Mat3,
|
||||||
inv: Mat3,
|
inv: Mat3,
|
||||||
|
/// The other selected layers and their scales as the drag began: they scale by the same
|
||||||
|
/// ratio about their own anchor points, as in After Effects (#491).
|
||||||
|
others: Vec<(LayerId, [f64; 3])>,
|
||||||
},
|
},
|
||||||
Rotate {
|
Rotate {
|
||||||
layer: LayerId,
|
layer: LayerId,
|
||||||
center: Pos2,
|
center: Pos2,
|
||||||
start_angle: f64,
|
start_angle: f64,
|
||||||
start_rot: f64,
|
start_rot: f64,
|
||||||
|
/// The other selected layers and their rotations: they turn by the same angle.
|
||||||
|
others: Vec<(LayerId, f64)>,
|
||||||
},
|
},
|
||||||
Anchor {
|
Anchor {
|
||||||
layer: LayerId,
|
layer: LayerId,
|
||||||
@@ -258,6 +263,16 @@ pub(crate) fn fx2c(ctx: &EvalCtx, layer: &Layer) -> Mat3 {
|
|||||||
from_effect_space(ctx, layer, l2c(ctx, layer).0)
|
from_effect_space(ctx, layer, l2c(ctx, layer).0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The selected, unlocked layers of `comp` other than `layer` (when `layer` is one of them): a
|
||||||
|
/// handle or Rotation tool drag on one changes them all.
|
||||||
|
fn selected_others<'a>(app: &EffectcraftApp, comp: &'a Comp, layer: LayerId) -> Vec<&'a Layer> {
|
||||||
|
let sel = &app.session.state.selected_layers;
|
||||||
|
if !sel.contains(&layer) {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
sel.iter().filter(|id| **id != layer).filter_map(|id| comp.layer(*id)).filter(|l| !l.switches.locked && l.transform().is_some()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// The least homogeneous depth (`w`: the distance in front of a perspective camera in pixels, 1
|
/// The least homogeneous depth (`w`: the distance in front of a perspective camera in pixels, 1
|
||||||
/// for 2D layers and orthographic views) a viewer outline is drawn at: points at or behind the
|
/// for 2D layers and orthographic views) a viewer outline is drawn at: points at or behind the
|
||||||
/// camera would flip through infinity and draw long lines across the viewer (#572).
|
/// camera would flip through infinity and draw long lines across the viewer (#572).
|
||||||
@@ -1232,6 +1247,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||||||
}
|
}
|
||||||
Tool::MaskFeather => ov::begin_feather(app, &ectx, &paths, &map, press).map(Gesture::Overlay),
|
Tool::MaskFeather => ov::begin_feather(app, &ectx, &paths, &map, press).map(Gesture::Overlay),
|
||||||
Tool::Rotate => pick(app, &ectx, cpt, mods.shift).map(|l| {
|
Tool::Rotate => pick(app, &ectx, cpt, mods.shift).map(|l| {
|
||||||
|
let others = selected_others(app, &comp, l).into_iter().filter_map(|o| Some((o.id, ectx.f(o, o.transform()?, "rotation", 0.0)))).collect();
|
||||||
let layer = comp.layer(l).cloned();
|
let layer = comp.layer(l).cloned();
|
||||||
let center = layer
|
let center = layer
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -1239,7 +1255,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||||||
.map(|p| map.to_screen([p[0], p[1]]))
|
.map(|p| map.to_screen([p[0], p[1]]))
|
||||||
.unwrap_or(pos);
|
.unwrap_or(pos);
|
||||||
let rot = layer.as_ref().and_then(|l| l.transform().map(|tr| ectx.f(l, tr, "rotation", 0.0))).unwrap_or(0.0);
|
let rot = layer.as_ref().and_then(|l| l.transform().map(|tr| ectx.f(l, tr, "rotation", 0.0))).unwrap_or(0.0);
|
||||||
Gesture::Rotate { layer: l, center, start_angle: ((pos.y - center.y) as f64).atan2((pos.x - center.x) as f64).to_degrees(), start_rot: rot }
|
Gesture::Rotate {
|
||||||
|
layer: l,
|
||||||
|
center,
|
||||||
|
start_angle: ((pos.y - center.y) as f64).atan2((pos.x - center.x) as f64).to_degrees(),
|
||||||
|
start_rot: rot,
|
||||||
|
others,
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
Tool::PanBehind => pick(app, &ectx, cpt, false).and_then(|l| {
|
Tool::PanBehind => pick(app, &ectx, cpt, false).and_then(|l| {
|
||||||
let layer = comp.layer(l)?;
|
let layer = comp.layer(l)?;
|
||||||
@@ -1349,6 +1371,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||||||
axes: handle_axes(hi),
|
axes: handle_axes(hi),
|
||||||
l2c,
|
l2c,
|
||||||
inv,
|
inv,
|
||||||
|
others: selected_others(app, &comp, lid)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|o| Some((o.id, ectx.v3(o, o.transform()?, "scale", [100.0; 3]))))
|
||||||
|
.collect(),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
} else if let Some(l) = pick(app, &ectx, cpt, mods.shift) {
|
} else if let Some(l) = pick(app, &ectx, cpt, mods.shift) {
|
||||||
@@ -1417,23 +1443,29 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||||||
let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/position", "value": v, "merge": merge}));
|
let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/position", "value": v, "merge": merge}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Gesture::Scale { layer, start_scale, start_local, handle, anchor, axes, l2c, inv } => {
|
Gesture::Scale { layer, start_scale, start_local, handle, anchor, axes, l2c, inv, others } => {
|
||||||
let lp = inv.apply(gv2(cpt[0], cpt[1]));
|
let lp = inv.apply(gv2(cpt[0], cpt[1]));
|
||||||
let uniform = mods.shift && axes == [true, true];
|
let uniform = mods.shift && axes == [true, true];
|
||||||
let f = drag_ratio(start_local, [lp.x - anchor[0], lp.y - anchor[1]], axes, uniform);
|
let f = drag_ratio(start_local, [lp.x - anchor[0], lp.y - anchor[1]], axes, uniform);
|
||||||
// The handle snaps (to the comp's and other layers' edges, corners and centres),
|
// The handle snaps (to the comp's and other layers' edges, corners and centres),
|
||||||
// so a layer scales exactly to them, e.g. to the comp's size.
|
// so a layer scales exactly to them, e.g. to the comp's size.
|
||||||
let f = snap_handle(f, handle, anchor, &l2c, axes, uniform, |p| vt::snap(app, &ctx, &ectx, &map, &[layer], &[p], mods));
|
let f = snap_handle(f, handle, anchor, &l2c, axes, uniform, |p| vt::snap(app, &ctx, &ectx, &map, &[layer], &[p], mods));
|
||||||
let v = [start_scale[0] * f[0], start_scale[1] * f[1], start_scale[2]];
|
for (lid, s0) in std::iter::once((layer, start_scale)).chain(others) {
|
||||||
let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/scale", "value": v, "merge": merge}));
|
let v = [s0[0] * f[0], s0[1] * f[1], s0[2]];
|
||||||
|
let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/scale", "value": v, "merge": merge}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Gesture::Rotate { layer, center, start_angle, start_rot } => {
|
Gesture::Rotate { layer, center, start_angle, start_rot, others } => {
|
||||||
let ang = ((pos.y - center.y) as f64).atan2((pos.x - center.x) as f64).to_degrees();
|
let ang = ((pos.y - center.y) as f64).atan2((pos.x - center.x) as f64).to_degrees();
|
||||||
let mut r = start_rot + (ang - start_angle);
|
let mut r = start_rot + (ang - start_angle);
|
||||||
if mods.shift {
|
if mods.shift {
|
||||||
r = (r / 45.0).round() * 45.0;
|
r = (r / 45.0).round() * 45.0;
|
||||||
}
|
}
|
||||||
let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/rotation", "value": r, "merge": merge}));
|
let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/rotation", "value": r, "merge": merge}));
|
||||||
|
for (lid, r0) in others {
|
||||||
|
let _ =
|
||||||
|
app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/rotation", "value": r0 + (r - start_rot), "merge": merge}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Gesture::Anchor { layer, start_anchor, start_pos, start, inv, l2p, own, anchor_only } => {
|
Gesture::Anchor { layer, start_anchor, start_pos, start, inv, l2p, own, anchor_only } => {
|
||||||
// Pan Behind snaps the anchor point (to its own layer's box, other layers' features,
|
// Pan Behind snaps the anchor point (to its own layer's box, other layers' features,
|
||||||
|
|||||||
@@ -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));
|
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");
|
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));
|
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
|
/// #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
|
/// layers'), so a layer scales exactly to the comp; Shift keeps the proportions and still lands
|
||||||
/// on the comp's size.
|
/// on the comp's size.
|
||||||
|
|||||||
Reference in new issue
Block a user