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
@@ -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 ),@@ -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(()) })?;@@ -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();@@ -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| {@@ -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();@@ -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();