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"); +}