collective

Browse / github.com/storytold/vectorcraft / patch

Drag a multi-point gradient mesh selection as one

[Bug / Gradient Mesh] Dragging a multi-node selection transforms the whole object ### Bug Description Selecting multiple mesh nodes inside a Gradient Mesh and attempting to drag/move them results in shifting or moving the entire object transform bounds, rather than moving only the selected cluster of nodes independently. ### Expected Behavior Dragging a multi-node selection should translate only the selected mesh control points on the canvas while keeping the unselected mesh nodes fixed in place. ### Environment - **App:** VectorCraft (Latest release) - **OS:** Linux (Debian Forky) - **Desktop Environment:** KDE Plasma 6.7 (Wayland)

Status
open
Score
0.00
Adopters
0 other operators
Author
Silvertree2010 (imported from pull request #4342, open)
Base
443b31b13df3a7ab611c2fe1b95e73898ed74fdd
Size
+218 −15 in 6 files
Review
pending
Shared
2026-10-28
Id
26ad2201294a1601abc328a5

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/live/commands.rs crates/engine/src/cmd/live/mesh.rs crates/engine/src/tests/live.rs crates/tools/src/direct.rs crates/tools/src/meshblend.rs crates/tools/src/meshedit.rs

diff --git a/crates/engine/src/cmd/live/commands.rs b/crates/engine/src/cmd/live/commands.rs--- a/crates/engine/src/cmd/live/commands.rs+++ b/crates/engine/src/cmd/live/commands.rs@@ -254,7 +254,7 @@ pub fn specs() -> Vec<CommandSpec> {             "Move Mesh Point",             [],             None,-            "{id, index, x, y, handle?: 0 right|1 left|2 down|3 up} move a point of a gradient mesh or mesh envelope (its handles follow), or with `handle` place that handle end at (x, y)",+            "{id, index, x, y, handle?: 0 right|1 left|2 down|3 up, also?: [index]} move a point of a gradient mesh or mesh envelope (its handles follow), and the `also` points by the same offset, or with `handle` place that handle end at (x, y)",             has_doc,             mesh_move_point         ),diff --git a/crates/engine/src/cmd/live/mesh.rs b/crates/engine/src/cmd/live/mesh.rs--- a/crates/engine/src/cmd/live/mesh.rs+++ b/crates/engine/src/cmd/live/mesh.rs@@ -177,11 +177,33 @@ pub(super) fn mesh_move_point(s: &mut Session, p: &Value) -> Result<Value> {     let index = index_param(p, "index", C)?;     let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);     let handle = handle_param(p, C)?;+    // More points moved by the same offset (a multi-point drag).+    let mut also = match p.get("also") {+        None | Some(Value::Null) => vec![],+        Some(Value::Array(v)) => {+            v.iter().map(|i| i.as_u64().map(|i| i as usize).ok_or_else(|| bad(C, "`also` must be point indices"))).collect::<Result<Vec<_>>>()?+        }+        Some(_) => return Err(bad(C, "`also` must be point indices")),+    };+    // Each point moves once, however often it is listed.+    also.sort_unstable();+    also.dedup();+    also.retain(|i| *i != index);+    if handle.is_some() && !also.is_empty() {+        return Err(bad(C, "`also` moves points, not handles"));+    }     with_mesh(s, p, C, "Move Mesh Point", true, |m| {         let pt = m.points.get_mut(index).ok_or_else(|| bad(C, "index out of range"))?;         match handle {             Some(h) => pt.handles[h] = q - pt.p,-            None => pt.p = q,+            None => {+                let d = q - pt.p;+                pt.p = q;+                for i in &also {+                    let pt = m.points.get_mut(*i).ok_or_else(|| bad(C, "index out of range"))?;+                    pt.p += d;+                }+            }         }         Ok(())     })?;diff --git a/crates/engine/src/tests/live.rs b/crates/engine/src/tests/live.rs--- a/crates/engine/src/tests/live.rs+++ b/crates/engine/src/tests/live.rs@@ -851,6 +851,28 @@ fn mesh_point_colour_move_and_undo() {     assert!(s.execute("object.mesh.setPointColor", &json!({"id": a.0, "index": 99, "color": "#000000"})).is_err()); } +/// `also` moves more points of the mesh by the same offset as `index` (a multi-point drag); the+/// other points stay where they are (#4207).+#[test]+fn mesh_move_point_moves_the_also_points_by_the_same_offset() {+    let mut s = session();+    let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);+    sel(&mut s, &[a]);+    s.execute("object.mesh.create", &json!({"rows": 1, "cols": 1})).unwrap();+    let before = mesh_of(&s, a);+    s.execute("object.mesh.movePoint", &json!({"id": a.0, "index": 0, "x": 20, "y": -20, "also": [1]})).unwrap();+    let m = mesh_of(&s, a);+    assert_eq!(m.points[0].p, Point::new(20.0, -20.0));+    assert_eq!(m.points[1].p, Point::new(120.0, -20.0));+    assert_eq!((m.points[2].p, m.points[3].p), (before.points[2].p, before.points[3].p));+    assert!(s.execute("object.mesh.movePoint", &json!({"id": a.0, "index": 0, "x": 0, "y": 0, "also": [99]})).is_err());+    // A point listed twice (or the grabbed point listed again) still moves once.+    s.execute("object.mesh.movePoint", &json!({"id": a.0, "index": 0, "x": 30, "y": -20, "also": [1, 1, 0]})).unwrap();+    let m = mesh_of(&s, a);+    assert_eq!(m.points[0].p, Point::new(30.0, -20.0));+    assert_eq!(m.points[1].p, Point::new(130.0, -20.0));+}+ #[test] fn mesh_add_line_and_delete_point() {     let mut s = session();diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs--- a/crates/tools/src/direct.rs+++ b/crates/tools/src/direct.rs@@ -574,7 +574,7 @@ impl Tool for DirectSelectionTool {                 self.snapped = false;                 if let Some(g) = self.mesh.hit(cx, &cx.selection.objects, p) {                     self.state = State::Mesh;-                    return self.mesh.press(cx, g);+                    return self.mesh.press(cx, g, ev.mods.shift);                 }                 self.mesh.unfocus();                 if let Some((id, si, ai, out)) = hit_handle(cx, p, point) {@@ -875,11 +875,17 @@ impl Tool for DirectSelectionTool {                 self.state = State::Idle;                 let r = Rect::from_points(start, p);                 if r.width() < cx.tol(3.0) && r.height() < cx.tol(3.0) {-                    return if toggle { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] };+                    if toggle {+                        return vec![];+                    }+                    self.mesh.unfocus();+                    return vec![Action::Exec("select.none".into(), json!({}))];                 }                 // With Group Selection's enclosed marquee, the tool selects the objects inside it                 // (members of groups too), each whole, as a click selects one.                 if enclosed {+                    // Whole objects: no mesh points stay selected to drag.+                    self.mesh.unfocus();                     let mut touched = vectorcraft_doc::hit::marquee(cx.doc, r, cx.isolation, true);                     let editable = cx.doc.editable();                     touched.retain(|id| editable(*id));@@ -887,6 +893,13 @@ impl Tool for DirectSelectionTool {                     let art = Action::Exec(if toggle { "select.toggle" } else { "select.set" }.into(), json!({ "ids": ids }));                     return std::iter::once(art).chain(crate::rulerguide::marquee_select(cx, r, true)).collect();                 }+                // The points of selected meshes inside the rect: dragging one moves them all.+                // Group Selection picks objects whole and drags no mesh points.+                if self.group {+                    self.mesh.unfocus();+                } else {+                    self.mesh.select_in(cx, &cx.selection.objects, r, toggle);+                }                 // Anchors inside the rect, on editable paths inside isolation mode.                 let mut sel: Vec<(NodeId, Vec<AnchorRef>)> = vec![];                 walk_pickable(cx.doc, cx.isolation, |n| {diff --git a/crates/tools/src/meshblend.rs b/crates/tools/src/meshblend.rs--- a/crates/tools/src/meshblend.rs+++ b/crates/tools/src/meshblend.rs@@ -191,7 +191,7 @@ impl Tool for MeshTool {                     if cx.selection.objects != [g.id] && cx.doc.node(g.id).is_some_and(|n| matches!(n.kind, NodeKind::Mesh(_))) {                         out.push(Action::Exec("select.set".into(), json!({"ids": [g.id.0]})));                     }-                    out.extend(self.edit.press(cx, g));+                    out.extend(self.edit.press(cx, g, false));                     return out;                 }                 self.edit.unfocus();diff --git a/crates/tools/src/meshedit.rs b/crates/tools/src/meshedit.rs--- a/crates/tools/src/meshedit.rs+++ b/crates/tools/src/meshedit.rs@@ -8,7 +8,7 @@ use std::borrow::Cow; use serde_json::{Value, json}; use vectorcraft_doc::live::{GradientMesh, envelope_grid}; use vectorcraft_doc::{EnvelopeKind, Node, NodeId, NodeKind};-use vectorcraft_geom::{Point, Vec2};+use vectorcraft_geom::{Point, Rect, Vec2};  use crate::{Action, Overlay, ToolContext}; @@ -49,6 +49,8 @@ struct ActiveDrag { pub struct MeshEdit {     /// The point last clicked: its handles show and can be dragged.     focus: Option<(NodeId, usize)>,+    /// The selected points (Shift-click, Direct Selection's marquee): dragging one moves them all.+    selected: Vec<(NodeId, usize)>,     drag: Option<ActiveDrag>, } @@ -72,9 +74,28 @@ impl MeshEdit {         })     } -    /// Start dragging `grab` (its point takes the focus).-    pub fn press(&mut self, cx: &ToolContext, grab: MeshGrab) -> Vec<Action> {-        self.focus = Some((grab.id, grab.index));+    /// Start dragging `grab` (its point takes the focus). A point joins the selected points (or,+    /// with `toggle`, leaves them and is not dragged); without `toggle` a point outside them+    /// becomes the only one.+    pub fn press(&mut self, cx: &ToolContext, grab: MeshGrab, toggle: bool) -> Vec<Action> {+        let key = (grab.id, grab.index);+        // Points the document lost since they were selected (deleted, undone…) drop out.+        self.selected.retain(|(id, i)| cx.doc.node(*id).and_then(grid_of).is_some_and(|g| *i < g.points.len()));+        if grab.handle.is_none() {+            let had = self.selected.contains(&key);+            if toggle && had {+                self.selected.retain(|k| *k != key);+                self.focus = None;+                return vec![];+            }+            if !toggle && !had {+                self.selected.clear();+            }+            if !had {+                self.selected.push(key);+            }+        }+        self.focus = Some(key);         let mut origin = Point::ZERO;         if let Some(grid) = cx.doc.node(grab.id).and_then(grid_of)             && let Some(q) = grid.points.get(grab.index)@@ -93,6 +114,11 @@ impl MeshEdit {         let mut params = json!({"id": g.id.0, "index": g.index, "x": p.x, "y": p.y});         if let Some(h) = g.handle {             params["handle"] = json!(h);+        } else {+            let also: Vec<usize> = self.selected.iter().filter(|(id, i)| *id == g.id && *i != g.index).map(|(_, i)| *i).collect();+            if !also.is_empty() {+                params["also"] = json!(also);+            }         }         Some(vec![Action::Preview("object.mesh.movePoint".into(), params)])     }@@ -106,9 +132,33 @@ impl MeshEdit {         self.drag.is_some()     } -    /// Forget the focused point.+    /// Forget the focused and the selected points.     pub fn unfocus(&mut self) {         self.focus = None;+        self.selected.clear();+    }++    /// Select the points of the meshes `ids` (editable ones) inside `r`, added to the selected+    /// points with `toggle` (toggled), else in their place; whether any point was inside.+    pub fn select_in(&mut self, cx: &ToolContext, ids: &[NodeId], r: Rect, toggle: bool) -> bool {+        let editable = cx.doc.editable();+        let mut inside = vec![];+        for id in ids.iter().filter(|id| editable(**id)) {+            let Some(grid) = cx.doc.node(*id).and_then(grid_of) else { continue };+            inside.extend(grid.points.iter().enumerate().filter(|(_, q)| r.contains(q.p)).map(|(i, _)| (*id, i)));+        }+        if !toggle {+            self.selected.clear();+        }+        for k in &inside {+            if let Some(at) = self.selected.iter().position(|s| s == k) {+                self.selected.remove(at);+            } else {+                self.selected.push(*k);+            }+        }+        self.focus = None;+        !inside.is_empty()     }      /// `{meshPoint: {id, index}|null}`: the focused point, which a solid fill colour recolours@@ -117,12 +167,20 @@ impl MeshEdit {         json!({"meshPoint": self.focus.map(|(id, index)| json!({"id": id.0, "index": index}))})     } -    /// The focused point's handles (lines and ends).+    /// The selected points, and the focused point's handles (lines and ends).     pub fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {-        let Some((id, index)) = self.focus else { return vec![] };-        let Some(grid) = cx.doc.node(id).and_then(grid_of) else { return vec![] };-        let Some(q) = grid.points.get(index) else { return vec![] };-        let mut out = vec![Overlay::Anchor { p: q.p, color: FEEDBACK, filled: true, size: 5.0 }];+        let mut out = vec![];+        for (id, index) in self.selected.iter().filter(|k| Some(**k) != self.focus) {+            if let Some(grid) = cx.doc.node(*id).and_then(grid_of)+                && let Some(q) = grid.points.get(*index)+            {+                out.push(Overlay::Anchor { p: q.p, color: FEEDBACK, filled: true, size: 5.0 });+            }+        }+        let Some((id, index)) = self.focus else { return out };+        let Some(grid) = cx.doc.node(id).and_then(grid_of) else { return out };+        let Some(q) = grid.points.get(index) else { return out };+        out.push(Overlay::Anchor { p: q.p, color: FEEDBACK, filled: true, size: 5.0 });         for h in q.handles.iter().filter(|h| h.hypot() > 1e-6) {             out.push(Overlay::Line { a: q.p, b: q.p + *h, color: FEEDBACK, dashed: false });             out.push(Overlay::Handle { p: q.p + *h, color: FEEDBACK });@@ -214,6 +272,94 @@ mod tests {         assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 90.0, 210.0)), vec![Action::Commit]);     } +    /// Dragging one of the mesh points a Direct Selection marquee picked moves them all, not the+    /// whole object (#4207).+    #[test]+    fn direct_selection_drags_marquee_selected_mesh_points_together() {+        let (mut d, a) = doc_with_rect();+        let grid = vectorcraft_doc::live::envelope_grid(1, 1, &grid_points(Rect::new(100.0, 100.0, 200.0, 200.0), 1, 1), &[]).unwrap();+        d.node_mut(a).unwrap().kind = NodeKind::Mesh(grid);+        let mut s = Selection::default();+        s.set([a]);+        let p = paint();+        let c = cx(&d, &s, &p);+        let mut t = crate::direct::DirectSelectionTool::new(false);+        // A marquee around the top two points (0 and 1).+        t.pointer(&c, &PointerEvent::new(PointerKind::Down, 90.0, 90.0));+        t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 210.0, 110.0));+        t.pointer(&c, &PointerEvent::new(PointerKind::Up, 210.0, 110.0));+        assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)), vec![Action::Begin("Move Mesh Point".into())]);+        assert_eq!(+            t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 120.0, 80.0)),+            vec![Action::Preview("object.mesh.movePoint".into(), json!({"id": a.0, "index": 0, "x": 120.0, "y": 80.0, "also": [1]}))]+        );+        assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 120.0, 80.0)), vec![Action::Commit]);+        // Shift-clicking a point adds it: point 3 joins, dragging it moves 0 and 1 too.+        t.pointer(&c, &PointerEvent::new(PointerKind::Down, 200.0, 200.0).with_mods(crate::Mods { shift: true, ..Default::default() }));+        t.pointer(&c, &PointerEvent::new(PointerKind::Up, 200.0, 200.0));+        t.pointer(&c, &PointerEvent::new(PointerKind::Down, 200.0, 200.0));+        assert_eq!(+            t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 210.0, 210.0)),+            vec![Action::Preview("object.mesh.movePoint".into(), json!({"id": a.0, "index": 3, "x": 210.0, "y": 210.0, "also": [0, 1]}))]+        );+    }++    /// Selected mesh points the mesh no longer has (it shrank after the marquee) are not sent+    /// with a drag, which the engine would reject as out of range (#4207).+    #[test]+    fn direct_selection_drag_drops_mesh_points_the_mesh_lost() {+        let (mut d, a) = doc_with_rect();+        let r = Rect::new(100.0, 100.0, 200.0, 200.0);+        let grid = vectorcraft_doc::live::envelope_grid(1, 2, &grid_points(r, 1, 2), &[]).unwrap();+        d.node_mut(a).unwrap().kind = NodeKind::Mesh(grid);+        let mut s = Selection::default();+        s.set([a]);+        let p = paint();+        let mut t = crate::direct::DirectSelectionTool::new(false);+        {+            let c = cx(&d, &s, &p);+            // A marquee around the bottom row (points 3, 4 and 5).+            t.pointer(&c, &PointerEvent::new(PointerKind::Down, 90.0, 190.0));+            t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 210.0, 210.0));+            t.pointer(&c, &PointerEvent::new(PointerKind::Up, 210.0, 210.0));+        }+        // The mesh shrinks to 4 points (an undo, a deleted line…).+        let grid = vectorcraft_doc::live::envelope_grid(1, 1, &grid_points(r, 1, 1), &[]).unwrap();+        d.node_mut(a).unwrap().kind = NodeKind::Mesh(grid);+        let c = cx(&d, &s, &p);+        assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 200.0, 200.0)), vec![Action::Begin("Move Mesh Point".into())]);+        assert_eq!(+            t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 210.0, 190.0)),+            vec![Action::Preview("object.mesh.movePoint".into(), json!({"id": a.0, "index": 3, "x": 210.0, "y": 190.0}))]+        );+    }++    /// Group Selection's marquees select objects whole: they leave no mesh points selected, and+    /// clear the ones an earlier marquee picked (#4207).+    #[test]+    fn group_selection_marquees_leave_no_mesh_points_selected() {+        let (mut d, a) = doc_with_rect();+        let grid = vectorcraft_doc::live::envelope_grid(1, 1, &grid_points(Rect::new(100.0, 100.0, 200.0, 200.0), 1, 1), &[]).unwrap();+        d.node_mut(a).unwrap().kind = NodeKind::Mesh(grid);+        let mut s = Selection::default();+        s.set([a]);+        let p = paint();+        let c = cx(&d, &s, &p);+        let mesh_point_shown = |o: &[Overlay]| o.iter().any(|o| matches!(o, Overlay::Anchor { p, .. } if *p == Point::new(100.0, 100.0)));+        let mut t = crate::direct::DirectSelectionTool::new(true);+        // A marquee around the top two points (0 and 1).+        t.pointer(&c, &PointerEvent::new(PointerKind::Down, 90.0, 90.0));+        t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 210.0, 110.0));+        t.pointer(&c, &PointerEvent::new(PointerKind::Up, 210.0, 110.0));+        assert!(!mesh_point_shown(&t.overlays(&c)));+        // An enclosed marquee (E) around the whole mesh.+        t.pointer(&c, &PointerEvent::new(PointerKind::Down, 50.0, 50.0));+        t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 250.0));+        t.key(&c, crate::ToolKey::Letter('e'), crate::Mods::default());+        t.pointer(&c, &PointerEvent::new(PointerKind::Up, 250.0, 250.0));+        assert!(!mesh_point_shown(&t.overlays(&c)));+    }+     #[test]     fn mesh_drag_honors_measurement_labels() {         let (d, _e) = doc_with_envelope();