Merge pull request #1485

This commit is contained in:
Brandon Thomas
2026-10-18 09:35:33 -04:00
6 changed files with 124 additions and 2 deletions
+22 -2
View File
@@ -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
),
+42
View File
@@ -1343,3 +1343,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;
}
+5
View File
@@ -317,6 +317,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;
}
+5
View File
@@ -4153,6 +4153,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 {
+45
View File
@@ -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");
}