mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 18:18:17 -06:00
Properties/Timeline/Effect Controls: a typed component edits only that component of the other selected layers (#1434)
With several layers selected, typing a value into one dimension (say Z Position) sent the edited layer's whole value with selected: true, and prop.set gave every selected layer that same value: they all took the first layer's X and Y and stacked on top of it. Scrubbing was fine (it sends an offset). prop.set takes dims: with selected and a typed value, the other selected layers get only those components and keep the rest of their own value (Position with Separate Dimensions included). The three panels send the components the edit changed (both for linked Scale). Without dims, agents still set the whole value on every selected layer. Fixes #1434 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N9kwMcw2pYQ5fSjKa3ZPnh
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
05955c08b4
commit
642eed0802
@@ -241,7 +241,9 @@ pub(crate) fn proportional_share(index: usize, count: usize) -> f64 {
|
||||
}
|
||||
|
||||
/// `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. With `offset` and
|
||||
/// `offset` (a scrub) the same change from each layer's own value. A typed value with `dims`
|
||||
/// (the components edited, e.g. `[2]` for Z) sets only those components on the other layers,
|
||||
/// which keep the rest of their own value (#1434). With `offset` and
|
||||
/// `proportional` (Proportional Scrubbing) each layer takes [`proportional_share`] of the change
|
||||
/// by its place in the selection order, the scrubbed layer too. One undo step (with `merge`,
|
||||
/// one that a continuing scrub keeps folding into).
|
||||
@@ -261,6 +263,23 @@ fn set_selected(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.then(|| s.state.selected_layers.iter().copied().filter(|o| *o == lid || twins.iter().any(|(t, _)| t == o)).collect());
|
||||
let share = |l: LayerId| order.as_ref().map_or(1.0, |o| o.iter().position(|x| *x == l).map_or(1.0, |i| proportional_share(i, o.len())));
|
||||
let scalar = p.get("value").is_some_and(Value::is_number);
|
||||
// Typed into some components only: the others stay each layer's own.
|
||||
let dims: Option<Vec<usize>> = match (&delta, p.get("dims").and_then(Value::as_array), &new) {
|
||||
(None, Some(a), Some(_)) if !scalar => Some(a.iter().filter_map(Value::as_u64).filter_map(|d| usize::try_from(d).ok()).collect()),
|
||||
_ => None,
|
||||
};
|
||||
let typed = |s: &Session, q: &mut Value, olid: LayerId, ouid: Uid| {
|
||||
if let (Some(dims), Some(n)) = (&dims, &new)
|
||||
&& let Some(mut c) = current_components(s, cid, olid, ouid)
|
||||
{
|
||||
for &d in dims {
|
||||
if let (Some(slot), Some(v)) = (c.get_mut(d), n.get(d)) {
|
||||
*slot = *v;
|
||||
}
|
||||
}
|
||||
q["value"] = json!(c);
|
||||
}
|
||||
};
|
||||
// `p` aimed at (layer, prop) with its value moved by `k` × the change.
|
||||
let shifted = |s: &Session, q: &mut Value, olid: LayerId, ouid: Uid, k: f64| {
|
||||
if let Some(d) = &delta
|
||||
@@ -291,6 +310,7 @@ fn set_selected(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let mut q = twin_params(p, olid, ouid);
|
||||
shifted(s, &mut q, olid, ouid, k);
|
||||
typed(s, &mut q, olid, ouid);
|
||||
// (a layer that can't take it, e.g. a clamped or static property, keeps its value)
|
||||
let _ = set(s, &q);
|
||||
}
|
||||
@@ -1229,7 +1249,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Property Value",
|
||||
[],
|
||||
None,
|
||||
"{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), proportional? (with offset: Proportional Scrubbing, each layer by its share in selection order, the first none, the last all)}",
|
||||
"{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), proportional? (with offset: Proportional Scrubbing, each layer by its share in selection order, the first none, the last all), dims? (with selected, a typed value: the components edited, e.g. [2] for Z; the other layers keep the rest of their own value)}",
|
||||
has_layers,
|
||||
set_selected
|
||||
),
|
||||
|
||||
@@ -1193,3 +1193,45 @@ fn relative_trim_moves_every_selected_layer_by_the_same_delta() {
|
||||
s.execute("layer.timing", json!({"layers": [a], "inDelta": 0.1})).unwrap();
|
||||
assert!(near(io(&s, a).0, -0.9), "{:?}", io(&s, a));
|
||||
}
|
||||
|
||||
/// #1434: with several 3D layers selected, typing a value into one component (Z Position) sets
|
||||
/// that component on each of them; their other components stay their own. Before, every
|
||||
/// selected layer took the edited layer's whole value and they piled up on top of it.
|
||||
#[test]
|
||||
fn a_typed_component_applies_only_that_component_to_the_selected_layers() {
|
||||
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();
|
||||
for (l, xy) in [(a, [10.0, 20.0]), (b, [110.0, 120.0]), (c, [210.0, 220.0])] {
|
||||
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": [xy[0], xy[1], 5.0]})).unwrap();
|
||||
}
|
||||
s.execute("layer.select", json!({"layers": [a, b, c]})).unwrap();
|
||||
let pos = |s: &Session, l: u64| prop(s, l, "transform/position").value.components();
|
||||
let uid = prop(&s, a, "transform/position").uid;
|
||||
// The panels send the edited layer's value with the typed component, and which one it was.
|
||||
let undo = s.history.undo.len();
|
||||
s.execute_checked("prop.set", json!({"layer": a, "prop": uid, "value": [10.0, 20.0, -300.0], "selected": true, "offset": false, "dims": [2]})).unwrap();
|
||||
assert_eq!(pos(&s, a), vec![10.0, 20.0, -300.0]);
|
||||
assert_eq!(pos(&s, b), vec![110.0, 120.0, -300.0], "B keeps its X and Y");
|
||||
assert_eq!(pos(&s, c), vec![210.0, 220.0, -300.0], "C keeps its X and Y");
|
||||
assert_eq!(s.history.undo.len(), undo + 1, "one undo step");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(pos(&s, b), vec![110.0, 120.0, 5.0]);
|
||||
|
||||
// Linked Scale: both edited components go to every layer, Z stays each layer's own.
|
||||
s.execute("prop.set", json!({"layer": c, "path": "transform/scale", "value": [50.0, 50.0, 30.0]})).unwrap();
|
||||
let scale_uid = prop(&s, a, "transform/scale").uid;
|
||||
s.execute("prop.set", json!({"layer": a, "prop": scale_uid, "value": [200.0, 200.0, 100.0], "selected": true, "dims": [0, 1]})).unwrap();
|
||||
assert_eq!(prop(&s, c, "transform/scale").value.components(), vec![200.0, 200.0, 30.0]);
|
||||
|
||||
// Position with Separate Dimensions on one of the other layers.
|
||||
s.execute("prop.separateDimensions", json!({"layer": b, "value": true})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": a, "prop": uid, "value": [10.0, 20.0, 40.0], "selected": true, "dims": [2]})).unwrap();
|
||||
let axis = |m: &str| prop(&s, b, &format!("transform/{m}")).value.as_f64();
|
||||
assert_eq!((axis("positionX"), axis("positionY"), axis("positionZ")), (110.0, 120.0, 40.0));
|
||||
|
||||
// Without `dims` (agents setting a whole value) every selected layer takes the value as is.
|
||||
s.execute("prop.set", json!({"layer": a, "prop": uid, "value": [1.0, 2.0, 3.0], "selected": true})).unwrap();
|
||||
assert_eq!(pos(&s, c), vec![1.0, 2.0, 3.0]);
|
||||
}
|
||||
|
||||
@@ -377,6 +377,11 @@ fn prop_row(
|
||||
let mut nc = c.clone();
|
||||
nc[d] = nv;
|
||||
set(actions, json!(nc));
|
||||
// Typed into this component only: other selected layers keep the rest of
|
||||
// their own value (#1434).
|
||||
if let Some((_, a)) = actions.last_mut() {
|
||||
a["dims"] = json!([d]);
|
||||
}
|
||||
}
|
||||
x = vr.max.x + 8.0;
|
||||
}
|
||||
|
||||
@@ -306,6 +306,11 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
|
||||
}
|
||||
}
|
||||
set(actions, json!(nc));
|
||||
// Typed into these components only: other selected layers keep the rest of
|
||||
// their own value (#1434).
|
||||
if let Some((_, a)) = actions.last_mut() {
|
||||
a["dims"] = json!((from..from + shown).collect::<Vec<_>>());
|
||||
}
|
||||
}
|
||||
x = vr.max.x + 10.0;
|
||||
}
|
||||
|
||||
@@ -4064,6 +4064,11 @@ fn value_editor(
|
||||
}
|
||||
}
|
||||
set(actions, json!(nc));
|
||||
// Typed into these components only: other selected layers keep the rest of
|
||||
// their own value (#1434).
|
||||
if let Some((_, a)) = actions.last_mut() {
|
||||
a["dims"] = json!((from..from + shown).collect::<Vec<_>>());
|
||||
}
|
||||
}
|
||||
x = r.max.x + if d + 1 < n { 2.0 } else { 0.0 };
|
||||
if d + 1 < n {
|
||||
|
||||
@@ -539,3 +539,48 @@ fn arrow_keys_move_the_caret_in_rename_fields() {
|
||||
let layer_name = h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(layer)).unwrap().name.clone();
|
||||
assert_eq!(layer_name, "Pla-te");
|
||||
}
|
||||
|
||||
/// #1434: with several 3D layers selected, typing a Z Position sets Z on each of them; their X
|
||||
/// and Y stay their own. Every selected layer used to take the typed layer's whole Position, so
|
||||
/// they stacked on top of it and seemed to disappear.
|
||||
#[test]
|
||||
fn typing_one_component_with_several_layers_selected_keeps_their_other_components() {
|
||||
let (mut h, _) = harness();
|
||||
let (a, b, transform) = {
|
||||
let s = &mut h.state_mut().session;
|
||||
let mut ids = vec![];
|
||||
for (name, xy) in [("A", [40.0, 50.0]), ("B", [200.0, 120.0])] {
|
||||
let l = s.execute("layer.newSolid", json!({"name": name, "color": "#406080", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": [xy[0], xy[1], 0.0]})).unwrap();
|
||||
ids.push(l);
|
||||
}
|
||||
s.execute("layer.select", json!({"layers": ids})).unwrap();
|
||||
let tr = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(ids[0])).unwrap().props.sub("transform").unwrap().uid;
|
||||
(ids[0], ids[1], tr)
|
||||
};
|
||||
h.state_mut().ui.timeline.open_layers.insert(a);
|
||||
h.state_mut().ui.timeline.open_groups.insert(transform);
|
||||
h.run_steps(3);
|
||||
let pos = |h: &Harness<'_, EffectcraftApp>, l: u64| {
|
||||
h.state()
|
||||
.session
|
||||
.active_comp()
|
||||
.unwrap()
|
||||
.layer(effectcraft_engine::project::LayerId(l))
|
||||
.unwrap()
|
||||
.props
|
||||
.prop("transform/position")
|
||||
.unwrap()
|
||||
.value
|
||||
.components()
|
||||
};
|
||||
let position =
|
||||
h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(a)).unwrap().props.prop("transform/position").unwrap().uid;
|
||||
let p = center(&h, &format!("timeline.prop.{position}.value.2"));
|
||||
click(&mut h, p, 1);
|
||||
type_text(&mut h, "-300");
|
||||
key(&mut h, Key::Enter);
|
||||
assert_eq!(pos(&h, a), [40.0, 50.0, -300.0]);
|
||||
assert_eq!(pos(&h, b), [200.0, 120.0, -300.0], "B keeps its own X and Y");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user