From 0c322fd89d634efdd63339819cd6b48347c40129 Mon Sep 17 00:00:00 2001 From: Jerome CAMUS Date: Sun, 25 Oct 2026 17:17:44 +0100 Subject: [PATCH 1/4] #2201: one undo reverts a whole mask free-transform drag path.freeTransform ignored the drag's merge key, so each frame was its own undo step. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/paths.rs | 4 ++-- crates/engine/src/tests/stubs.rs | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/paths.rs b/crates/engine/src/cmd/paths.rs index afd7aba12..04cf2b439 100644 --- a/crates/engine/src/cmd/paths.rs +++ b/crates/engine/src/cmd/paths.rs @@ -8,7 +8,7 @@ use effectcraft_project::{FeatherFalloff, GroupKind, ItemId, LayerId, LayerSourc use serde_json::{Value, json}; use super::layer_menu::target_masks; -use super::{CommandSpec, b_p, bad, f_p, has_comp, has_layers, layer_mut, layer_p, str_p}; +use super::{CommandSpec, b_p, bad, f_p, has_comp, has_layers, layer_mut, layer_p, merge_p, str_p}; use crate::{EngineError, Result, Session, VertexRef, cmd}; // ---------------------------------------------------------------- RotoBezier @@ -308,7 +308,7 @@ fn free_transform(s: &mut Session, p: &Value) -> Result { let y = v[1] * scale[1]; [x * c - y * sn, x * sn + y * c] }; - let n = s.edit("Free Transform Points", None, |proj, _| { + let n = s.edit("Free Transform Points", merge_p(p), |proj, _| { let mut n = 0; for (lid, uid) in &targets { let l = layer_mut(proj, cid, *lid)?; diff --git a/crates/engine/src/tests/stubs.rs b/crates/engine/src/tests/stubs.rs index 84321ee7e..0d7dd47ac 100644 --- a/crates/engine/src/tests/stubs.rs +++ b/crates/engine/src/tests/stubs.rs @@ -583,3 +583,19 @@ fn convert_to_bezier_reads_the_shape_as_it_is_set() { let mid = |v: &[f64]| (v.iter().copied().fold(f64::MAX, f64::min) + v.iter().copied().fold(f64::MIN, f64::max)) / 2.0; assert!((mid(&xs) - 10.0).abs() < 1e-6 && (mid(&ys) - 5.0).abs() < 1e-6, "centred on the position: {:?} {:?}", mid(&xs), mid(&ys)); } + +/// #2201: a viewer drag on the free-transform box sends one `path.freeTransform` per frame with a +/// shared merge key; one undo must revert the whole drag, not the last frame. +#[test] +fn free_transform_drag_undoes_in_one_step() { + let mut s = comp(); + let (l, m) = masked(&mut s); + s.state.selected_vertices.clear(); + let before = mask_path(&s, l, m); + for k in [90, 90, 90, 90] { + s.execute("path.freeTransform", json!({"layer": l, "scale": k, "merge": "viewer-drag-1"})).unwrap(); + } + assert_ne!(mask_path(&s, l, m), before); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(mask_path(&s, l, m), before, "one undo reverts the whole drag"); +} From 580bdc4e7b9b18f946479a197e4150ff1615e269 Mon Sep 17 00:00:00 2001 From: Jerome CAMUS Date: Sun, 25 Oct 2026 20:12:14 +0100 Subject: [PATCH 2/4] fix data Integrity & Integration --- crates/engine/src/cmd/paths.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/paths.rs b/crates/engine/src/cmd/paths.rs index 04cf2b439..2f0ecbdb4 100644 --- a/crates/engine/src/cmd/paths.rs +++ b/crates/engine/src/cmd/paths.rs @@ -541,8 +541,8 @@ pub fn specs() -> Vec { "path.freeTransform", "Free Transform Points", ["Layer", "Mask and Shape Path"], - None, - "{layer?, mask?, scale?: % | [x%, y%], rotation?: deg, offset?: [dx, dy], anchor?: [x, y]}", + None, + "{layer?, mask?, scale?: % | [x%, y%], rotation?: deg, offset?: [dx, dy], anchor?: [x, y], merge?: string}", has_layers, free_transform ), From 6a6fdaf62fb08be486cb53fbf432aff441a0edb6 Mon Sep 17 00:00:00 2001 From: Jerome CAMUS Date: Sun, 25 Oct 2026 20:26:22 +0100 Subject: [PATCH 3/4] Improve tests --- crates/engine/src/tests/stubs.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/tests/stubs.rs b/crates/engine/src/tests/stubs.rs index 0d7dd47ac..7da8d12ad 100644 --- a/crates/engine/src/tests/stubs.rs +++ b/crates/engine/src/tests/stubs.rs @@ -595,7 +595,10 @@ fn free_transform_drag_undoes_in_one_step() { for k in [90, 90, 90, 90] { s.execute("path.freeTransform", json!({"layer": l, "scale": k, "merge": "viewer-drag-1"})).unwrap(); } - assert_ne!(mask_path(&s, l, m), before); + let after = mask_path(&s, l, m); + assert_ne!(after, before); s.execute("edit.undo", json!({})).unwrap(); assert_eq!(mask_path(&s, l, m), before, "one undo reverts the whole drag"); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(mask_path(&s, l, m), after, "redo restores the whole drag"); } From 82d3c2c92de36951006d036354e40655ed63290f Mon Sep 17 00:00:00 2001 From: Jerome CAMUS Date: Thu, 29 Oct 2026 18:27:28 +0100 Subject: [PATCH 4/4] Fix formatting in path.freeTransform spec Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/paths.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/paths.rs b/crates/engine/src/cmd/paths.rs index 2f0ecbdb4..7803e8fac 100644 --- a/crates/engine/src/cmd/paths.rs +++ b/crates/engine/src/cmd/paths.rs @@ -541,7 +541,7 @@ pub fn specs() -> Vec { "path.freeTransform", "Free Transform Points", ["Layer", "Mask and Shape Path"], - None, + None, "{layer?, mask?, scale?: % | [x%, y%], rotation?: deg, offset?: [dx, dy], anchor?: [x, y], merge?: string}", has_layers, free_transform