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();
