From b2d6690fd202c6a4a053839f946fc2e0fdb939c0 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 7 Nov 2026 16:27:36 +0100 Subject: [PATCH] Number fields: Mask Feather stays non-negative on the other selected layers (#3195) A typed *-1 (the edited layer clamped at 0 by the panel) or a change by the same amount could take another selected layer's Mask Feather below 0. Clamp it at 0 there, as the panels do for the edited layer. --- crates/engine/src/cmd/prop.rs | 23 +++++++++++++++++++++++ crates/engine/src/tests/timeline.rs | 24 ++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/crates/engine/src/cmd/prop.rs b/crates/engine/src/cmd/prop.rs index dc043d78f..ae37ea1b7 100644 --- a/crates/engine/src/cmd/prop.rs +++ b/crates/engine/src/cmd/prop.rs @@ -436,6 +436,9 @@ 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); + if is_mask_feather(s, cid, olid, ouid) { + non_negative(&mut q["value"]); + } // (a layer that can't take it, e.g. a clamped or static property, keeps its value) let _ = set(s, &q); } @@ -444,6 +447,26 @@ fn set_selected(s: &mut Session, p: &Value) -> Result { if merge_p(p).is_some() { run(s) } else { super::app_more::grouped(s, "Change Property", run) } } +/// Whether property `uid` of `lid` is a mask's Mask Feather, which is never negative (After +/// Effects; the panels clamp a typed or scrubbed value at 0). +fn is_mask_feather(s: &Session, cid: ItemId, lid: LayerId, uid: Uid) -> bool { + let Some(l) = s.project.comp(cid).and_then(|c| c.layer(lid)) else { return false }; + l.props.find(uid).is_some_and(|q| q.match_id == "feather") && l.props.parent_of(uid).is_some_and(|g| matches!(g.kind, GroupKind::Mask { .. })) +} + +/// A JSON number, or each number of an array, raised to at least 0. +fn non_negative(v: &mut Value) { + match v { + Value::Number(n) => { + if let Some(x) = n.as_f64().filter(|x| *x < 0.0) { + *v = json!(x.max(0.0)); + } + } + Value::Array(a) => a.iter_mut().for_each(non_negative), + _ => {} + } +} + /// `prop.toggleAnimation`; with `selected`, the other selected layers' same property follows. fn toggle_anim_selected(s: &mut Session, p: &Value) -> Result { let twins = selected_twins(s, p, "prop.toggleAnimation")?; diff --git a/crates/engine/src/tests/timeline.rs b/crates/engine/src/tests/timeline.rs index 6ea4d1336..43eb922e2 100644 --- a/crates/engine/src/tests/timeline.rs +++ b/crates/engine/src/tests/timeline.rs @@ -1554,6 +1554,30 @@ fn a_typed_factor_scales_each_selected_layers_own_value() { assert_eq!(prop(&s, b, "transform/position").value.components()[..2], [50.0, 50.0], "B halves its own X, keeps its Y"); } +/// #3195: Mask Feather is never negative on the other selected layers either, when a typed `*-1` +/// (the edited layer clamped at 0 by the panel) or a change by the same amount takes theirs +/// below 0. +#[test] +fn a_selected_mask_feather_stays_non_negative() { + let (mut s, a) = setup(); + let b = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + for (l, feather) in [(a, [10.0, 20.0]), (b, [4.0, 30.0])] { + s.execute("mask.new", json!({"layer": l, "vertices": [[0, 0], [20, 0], [20, 20]]})).unwrap(); + s.execute("prop.set", json!({"layer": l, "path": "masks/#1/feather", "value": feather})).unwrap(); + } + s.execute("layer.select", json!({"layers": [a, b]})).unwrap(); + let feather = |s: &Session, l: u64| prop(s, l, "masks/#1/feather").value.components(); + let uid = prop(&s, a, "masks/#1/feather").uid; + // `*-1` into the linked pair: the panel clamps the edited layer at [0, 0]. + s.execute_checked("prop.set", json!({"layer": a, "prop": uid, "value": [0.0, 0.0], "selected": true, "dims": [0, 1], "factor": -1.0})).unwrap(); + assert_eq!(feather(&s, a), [0.0, 0.0]); + assert_eq!(feather(&s, b), [0.0, 0.0], "B is clamped at 0, not [-4, -30]"); + // A change by the same amount (a scrub, or a typed `+`): A by [-10, -15] from [10, 20]. + s.execute("edit.undo", json!({})).unwrap(); + s.execute_checked("prop.set", json!({"layer": a, "prop": uid, "value": [0.0, 5.0], "selected": true, "offset": true})).unwrap(); + assert_eq!(feather(&s, b), [0.0, 15.0], "B's X is clamped at 0, its Y moves by -15"); +} + /// #2485: linked Audio Levels typed with several layers selected: each takes the typed Left, and /// its Right moves by its own change, so every layer keeps its own stereo balance. #[test]