From 642eed0802d6516a691c4e589a6983d91adbc665 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Wed, 14 Oct 2026 14:39:26 -0600 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01N9kwMcw2pYQ5fSjKa3ZPnh --- crates/engine/src/commands/prop.rs | 24 ++++++++++- crates/engine/src/tests_timeline.rs | 42 ++++++++++++++++++ crates/ui-egui/src/panels/effect_controls.rs | 5 +++ crates/ui-egui/src/panels/properties.rs | 5 +++ crates/ui-egui/src/panels/timeline.rs | 5 +++ crates/ui-egui/tests/ui_inline_edit.rs | 45 ++++++++++++++++++++ 6 files changed, 124 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 29da9c1e..8f263818 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -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 { .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> = 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 { } 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 { "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 ), diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index 71a9943c..a7aa8bac 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -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]); +} diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index bad1db39..ecfed5a2 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -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; } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 2649a38e..5cb36513 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -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::>()); + } } x = vr.max.x + 10.0; } diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507..f54b1cd4 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -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::>()); + } } x = r.max.x + if d + 1 < n { 2.0 } else { 0.0 }; if d + 1 < n { diff --git a/crates/ui-egui/tests/ui_inline_edit.rs b/crates/ui-egui/tests/ui_inline_edit.rs index dd20e55b..d7e532d7 100644 --- a/crates/ui-egui/tests/ui_inline_edit.rs +++ b/crates/ui-egui/tests/ui_inline_edit.rs @@ -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"); +}