Gradient Mesh: select several mesh points; move, color and opacity reach them all (#4207, #4208, #4170)

This commit is contained in:
bflatastic
2026-10-30 03:37:43 +07:00
parent 6b3a6b8788
commit d56bbb49ff
37 changed files with 774 additions and 116 deletions
+14
View File
@@ -282,6 +282,20 @@ impl ToolContext<'_> {
pub fn star_radii_label(&self, outer: f64, inner: f64) -> String {
format!("R1: {}\nR2: {}", self.len(outer), self.len(inner))
}
/// The move an arrow key nudges by: General › Keyboard Increment (ten times with Shift),
/// along General › Constrain Angle's axes; `None` for other keys.
pub fn arrow_nudge(&self, key: crate::ToolKey, mods: crate::Mods) -> Option<Vec2> {
use crate::ToolKey;
let step = self.keyboard_increment * if mods.shift { 10.0 } else { 1.0 };
let d = match key {
ToolKey::Left => Point::new(-step, 0.0),
ToolKey::Right => Point::new(step, 0.0),
ToolKey::Up => Point::new(0.0, -step),
ToolKey::Down => Point::new(0.0, step),
_ => return None,
};
Some(vectorcraft_geom::constrain_axes(self.constrain_angle).map_or(d, |axes| axes * d).to_vec2())
}
/// A move label: `dX: …` over `dY: …`.
pub fn offset_label(&self, dx: f64, dy: f64) -> String {
format!("dX: {}\ndY: {}", self.len(dx), self.len(dy))
+64 -20
View File
@@ -25,8 +25,11 @@
//! pointer ([`release_guide_at`]).
//! Direct Selection also edits a blend's spine: drag its points (a key object on a point moves
//! with it) and, once a point is clicked, its handles; and the points of selected gradient meshes
//! and mesh envelopes and their handles ([`MeshEdit`]; a solid fill colour recolours a gradient
//! mesh's point last clicked). Dragging a corner or an edge of area
//! and mesh envelopes and their handles ([`MeshEdit`]): Shift-clicks and the marquee pick several,
//! a click on a gradient mesh's line picks its two ends and one inside a patch the four points
//! around it, a drag of picked points moves them all, the arrow keys nudge them, and a solid fill
//! colour or the Transparency panel's opacity goes to a gradient mesh's picked points. Dragging a
//! corner or an edge of area
//! type's frame reshapes the type area (`text.reshapeArea`): the text reflows at its size. A click
//! on a frame anchor or a marquee selects frame anchors (Shift toggles them). While the selection
//! holds only area type with selected frame anchors, those show filled, a drag of one of them moves
@@ -49,7 +52,7 @@ use vectorcraft_geom::{Anchor, PathData, Point, Rect, Vec2};
use crate::bbox::move_delta;
use crate::corners::{self, CornerDrag, over_widget};
use crate::guides::{HandleSnap, Leave, PointSnap, Targets};
use crate::meshedit::MeshEdit;
use crate::meshedit::{MeshEdit, grid_of};
use crate::pathtype::{self, BracketDrag, over_bracket};
use crate::rulerguide::GuideEdit;
use crate::select::{MoveSnap, hold_point, is_area_type, is_path_type, matrix_json, release_guide_at};
@@ -280,20 +283,17 @@ impl DirectSelectionTool {
if !matches!(self.state, State::Idle) {
return None;
}
let step = cx.keyboard_increment * if mods.shift { 10.0 } else { 1.0 };
let d = match key {
ToolKey::Left => Point::new(-step, 0.0),
ToolKey::Right => Point::new(step, 0.0),
ToolKey::Up => Point::new(0.0, -step),
ToolKey::Down => Point::new(0.0, step),
_ => return None,
};
let d = cx.arrow_nudge(key, mods)?;
let items = self.frame_anchors(cx);
if items.is_empty() {
(!items.is_empty()).then_some((items, d))
}
/// An arrow key's nudge of the picked points of the selected meshes.
fn mesh_nudge(&self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Option<Vec<Action>> {
if !matches!(self.state, State::Idle) {
return None;
}
let d = vectorcraft_geom::constrain_axes(cx.constrain_angle).map_or(d, |axes| axes * d);
Some((items, d.to_vec2()))
self.mesh.nudge(cx, key, mods, &|id| cx.selection.contains(id))
}
/// Is `key` the E that Group Selection takes while a marquee is being dragged? Direct
@@ -583,6 +583,9 @@ impl Tool for DirectSelectionTool {
/// entirely inside it, whole, and E pressed again makes it select what it selects by default.
/// The arrow keys nudge the selected frame anchors of area type.
fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec<Action> {
if let Some(out) = self.mesh_nudge(cx, key, mods) {
return out;
}
if let Some((items, d)) = self.frame_nudge(cx, key, mods) {
return vec![Action::Exec("text.reshapeArea".into(), reshape_params(&items, d)), Action::Notify(FRAME_PICKED.into())];
}
@@ -609,7 +612,7 @@ impl Tool for DirectSelectionTool {
if frame { vec![Action::Cancel, Action::Notify(FRAME_PICKED.into())] } else { vec![Action::Cancel] }
}
fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool {
self.claims_letter(key) || self.frame_nudge(cx, key, Mods::default()).is_some()
self.claims_letter(key) || self.mesh_nudge(cx, key, Mods::default()).is_some() || self.frame_nudge(cx, key, Mods::default()).is_some()
}
fn notify(&mut self, cx: &ToolContext, what: &str) {
if what == FRAME_PICKED {
@@ -656,9 +659,12 @@ 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, &cx.selection.objects, g, p, ev.mods.shift);
}
// Shift keeps the picked mesh points: it adds to the selection.
if !ev.mods.shift {
self.mesh.unfocus();
}
self.mesh.unfocus();
if let Some((id, si, ai, out)) = hit_handle(cx, p, point) {
self.state = State::Handle { id, si, ai, out };
self.handle_snap = HandleSnap::default();
@@ -772,7 +778,7 @@ impl Tool for DirectSelectionTool {
(PointerKind::Drag, State::Mesh) => self.mesh.drag_to(p).unwrap_or_default(),
(PointerKind::Up, State::Mesh) => {
self.state = State::Idle;
self.mesh.release().unwrap_or_default()
self.mesh.release(cx).unwrap_or_default()
}
(PointerKind::Drag, State::Marquee { start, toggle, enclosed, .. }) => {
self.state = State::Marquee { start, cur: p, toggle, enclosed };
@@ -820,7 +826,7 @@ impl Tool for DirectSelectionTool {
State::Corner(c) => c.overlays(cx),
_ => {
let mut out = self.spine_overlays(cx);
out.extend(self.mesh.overlays(cx));
out.extend(self.mesh.overlays(cx, &|id| cx.selection.contains(id)));
if !self.group {
out.extend(frame_overlays(cx, &self.frame_anchors(cx)));
out.extend(pathtype::overlays(cx));
@@ -1131,6 +1137,24 @@ impl DirectSelectionTool {
h.ancestry.push(part);
h.leaf = part;
}
// On a gradient mesh a click picks the point, the mesh line's two ends or the four points
// around the patch under it (Shift toggles them), and selects the mesh; a drag moves the
// picked points.
if !ev.mods.alt
&& let Some(NodeKind::Mesh(m)) = cx.doc.node(h.leaf).map(|n| &n.kind)
&& let Some(points) = crate::meshedit::part_points(m, p, cx.point_tol(), tol)
{
let id = h.leaf;
let mut out = match (ev.mods.shift, cx.selection.contains(id)) {
(true, false) => vec![Action::Exec("select.add".into(), json!({"ids": [id.0]}))],
(false, false) => vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))],
_ => vec![],
};
self.frame.clear();
self.state = State::Mesh;
out.extend(self.mesh.press_points(cx, &cx.selection.objects, id, &points, p, ev.mods.shift));
return out;
}
// A segment pick keeps its endpoints available for movement without selecting
// the anchors themselves. Dragging reshapes a curve, else moves it. Alt picks
// the whole path (and Alt-drag copies it), as with Group Selection.
@@ -1344,6 +1368,26 @@ impl DirectSelectionTool {
});
framed.retain(|(id, _)| editable(*id));
}
// Direct Selection: the mesh points inside the rect are picked (with Shift, toggled), and
// their meshes selected whole; with Shift a mesh is toggled only when it gains its first
// picked point or loses its last one.
let mut meshed: Vec<NodeId> = vec![];
if !self.group {
let mut inside: Vec<(NodeId, usize)> = vec![];
walk_pickable(cx.doc, cx.isolation, |n| {
if editable(n.id)
&& let Some(grid) = grid_of(n)
{
inside.extend(grid.points.iter().enumerate().filter(|(_, q)| r.contains(q.p)).map(|(i, _)| (n.id, i)));
}
});
for (id, any) in self.mesh.marquee(&inside, toggle) {
meshed.push(id);
if !toggle || cx.selection.contains(id) != any {
sel.push((id, vec![]));
}
}
}
let mut marked: BTreeMap<NodeId, BTreeSet<AnchorRef>> =
if toggle { self.frame_anchors(cx).into_iter().map(|(id, v)| (id, v.into_iter().collect())).collect() } else { BTreeMap::new() };
for (id, inside) in &framed {
@@ -1363,7 +1407,7 @@ impl DirectSelectionTool {
// the marquee touches them, as with the Selection tool (#927).
for id in vectorcraft_doc::hit::marquee(cx.doc, r, cx.isolation, true) {
let whole = cx.doc.node(id).is_some_and(|n| !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }));
if whole && !sel.iter().any(|(s, _)| *s == id) && !framed.iter().any(|(f, _)| *f == id) {
if whole && !sel.iter().any(|(s, _)| *s == id) && !framed.iter().any(|(f, _)| *f == id) && !meshed.contains(&id) {
sel.push((id, vec![]));
}
}
+30 -15
View File
@@ -8,9 +8,11 @@
//! the click); click inside a mesh or a mesh envelope → `object.mesh.addLine` (a gradient mesh's
//! new point takes the current fill color in its own model, unless Shift); drag a mesh point,
//! or a handle of the point last clicked → `object.mesh.movePoint` previews ([`MeshEdit`]);
//! Alt-click a mesh point → `object.mesh.deletePoint`. Clicking a gradient mesh's point selects
//! the mesh, and the next solid fill color recolors just that point. Escape during a drag
//! cancels it.
//! Shift-click mesh points to pick several (Shift-click a picked one to drop it): a drag of one
//! moves them all (`object.mesh.movePoints`) and the arrow keys nudge them. Alt-click a mesh
//! point → `object.mesh.deletePoint`. Clicking a gradient mesh's point selects the mesh, and the
//! next solid fill color or Transparency panel opacity goes to just the picked points. Escape
//! during a drag cancels it.
use serde_json::json;
use vectorcraft_color::Paint;
@@ -190,6 +192,11 @@ impl MeshTool {
fn grab_at(&self, cx: &ToolContext, p: Point) -> Option<crate::meshedit::MeshGrab> {
self.edit.hit(cx, &Self::meshes(cx), p)
}
/// An arrow key's nudge of the picked points.
fn nudge(&self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Option<Vec<Action>> {
self.edit.nudge(cx, key, mods, &|_| true)
}
}
impl Tool for MeshTool {
@@ -200,18 +207,23 @@ impl Tool for MeshTool {
let p = ev.pos;
match ev.kind {
PointerKind::Down => {
if let Some(g) = self.grab_at(cx, p) {
let meshes = Self::meshes(cx);
if let Some(g) = self.edit.hit(cx, &meshes, p) {
if ev.mods.alt && g.handle.is_none() {
self.edit.unfocus();
return vec![Action::Exec("object.mesh.deletePoint".into(), json!({"id": g.id.0, "index": g.index}))];
}
// A gradient mesh's point is picked for the next fill colour, so its mesh is
// what gets selected.
// what gets selected (with Shift, added to the selection).
let mut out = vec![];
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]})));
if cx.doc.node(g.id).is_some_and(|n| matches!(n.kind, NodeKind::Mesh(_))) {
if ev.mods.shift && !cx.selection.contains(g.id) {
out.push(Action::Exec("select.add".into(), json!({"ids": [g.id.0]})));
} else if !ev.mods.shift && cx.selection.objects != [g.id] {
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, &meshes, g, p, ev.mods.shift));
return out;
}
self.edit.unfocus();
@@ -242,7 +254,7 @@ impl Tool for MeshTool {
vec![]
}
PointerKind::Drag => self.edit.drag_to(p).unwrap_or_default(),
PointerKind::Up => self.edit.release().unwrap_or_default(),
PointerKind::Up => self.edit.release(cx).unwrap_or_default(),
_ => vec![],
}
}
@@ -256,7 +268,7 @@ impl Tool for MeshTool {
}
}
}
out.extend(self.edit.overlays(cx));
out.extend(self.edit.overlays(cx, &|_| true));
out
}
fn cursor(&self, cx: &ToolContext, p: Point, mods: Mods) -> Cursor {
@@ -273,16 +285,19 @@ impl Tool for MeshTool {
self.edit.dragging()
}
/// Escape during a drag of a mesh point or handle cancels it: the point goes back and the
/// release does nothing.
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
/// release does nothing. The arrow keys nudge the picked points.
fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec<Action> {
if key != ToolKey::Escape {
return vec![];
return self.nudge(cx, key, mods).unwrap_or_default();
}
self.edit.cancel().unwrap_or_default()
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool {
self.nudge(cx, key, Mods::default()).is_some()
}
fn deactivate(&mut self, cx: &ToolContext) -> Vec<Action> {
self.edit.unfocus();
self.edit.release().unwrap_or_default()
self.edit.release(cx).unwrap_or_default()
}
}
+328 -34
View File
@@ -1,16 +1,21 @@
//! Dragging mesh points and their handles on the canvas, shared by the Mesh tool and Direct
//! Selection: gradient meshes and mesh envelopes alike (`object.mesh.movePoint`). Clicking a
//! point focuses it: its handles show and can be dragged too, and on a gradient mesh a solid fill
//! colour recolours it (the tool's `meshPoint` option).
//! Picking and dragging mesh points and their handles on the canvas, shared by the Mesh tool and
//! Direct Selection: gradient meshes and mesh envelopes alike. A click on a point picks it alone
//! and focuses it: its handles show and can be dragged too. Shift-clicks add points to the picked
//! ones or take them away, and Direct Selection's marquee and its clicks on a gradient mesh's line
//! or patch pick points too ([`part_points`]). The picked points show filled; a drag of one of
//! them moves them all (`object.mesh.movePoint` for one, `object.mesh.movePoints` for several)
//! and the arrow keys nudge them. On a gradient mesh a solid fill colour recolours them and the
//! Transparency panel's opacity fades them (the tool's `meshPoints` option).
use std::borrow::Cow;
use std::collections::BTreeSet;
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 crate::{Action, Overlay, ToolContext};
use crate::{Action, Mods, Overlay, ToolContext, ToolKey};
const FEEDBACK: [u8; 3] = [0x4f, 0x9d, 0xff];
@@ -27,6 +32,29 @@ pub fn grid_of(n: &Node) -> Option<Cow<'_, GradientMesh>> {
}
}
/// The points of gradient mesh `m` a Direct Selection click at `p` picks: the point within
/// `point_tol`, else the two ends of the mesh line within `line_tol`, else the four points around
/// the patch under `p`.
pub fn part_points(m: &GradientMesh, p: Point, point_tol: f64, line_tol: f64) -> Option<Vec<usize>> {
if let Some(i) = m.point_near(p, point_tol) {
return Some(vec![i]);
}
// `lines` lists the rows' edges, then the columns' (and nothing for a malformed mesh).
let (rows, cols) = (m.rows.max(1) as usize, m.cols.max(1) as usize);
if let Some((si, ..)) = m.lines().nearest(p).filter(|n| n.4 <= line_tol) {
let across = (rows + 1) * cols;
return Some(if si < across {
let (r, c) = (si / cols, si % cols);
vec![m.idx(r, c), m.idx(r, c + 1)]
} else {
let (c, r) = ((si - across) / rows, (si - across) % rows);
vec![m.idx(r, c), m.idx(r + 1, c)]
});
}
let (r, c, ..) = m.locate(p)?;
Some(vec![m.idx(r, c), m.idx(r, c + 1), m.idx(r + 1, c), m.idx(r + 1, c + 1)])
}
/// What a press on a mesh grabs: the mesh, the point and (on the focused point) a handle.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MeshGrab {
@@ -35,21 +63,46 @@ pub struct MeshGrab {
pub handle: Option<usize>,
}
/// Mesh points, each mesh with its points.
type MeshPoints = Vec<(NodeId, Vec<usize>)>;
/// An in-progress mesh point or handle drag.
struct ActiveDrag {
grab: MeshGrab,
/// Where the grabbed point or handle was when the drag began.
/// Where the press was.
start: Point,
/// Where the grabbed point or handle was when the drag began (where the press was, on a mesh
/// line or patch).
origin: Point,
/// The current drag position (preview target); set on the first `drag_to`.
at: Option<Point>,
/// The picked points, when the drag moves several of them by its offset; empty when it moves
/// `grab` alone to the pointer.
many: MeshPoints,
}
/// Point and handle drags on meshes.
/// The params of `object.mesh.movePoints` moving `items` by `d`.
fn move_params(items: &[(NodeId, Vec<usize>)], d: Vec2) -> Value {
match items {
[(id, points)] => json!({"id": id.0, "points": points, "dx": d.x, "dy": d.y}),
_ => {
let items: Vec<Value> = items.iter().map(|(id, v)| json!({"id": id.0, "points": v})).collect();
json!({"items": items, "dx": d.x, "dy": d.y})
}
}
}
/// Mesh point picks and point and handle drags on meshes.
#[derive(Default)]
pub struct MeshEdit {
/// The point last clicked: its handles show and can be dragged.
focus: Option<(NodeId, usize)>,
/// The picked points (the focused one among them).
picked: BTreeSet<(NodeId, usize)>,
drag: Option<ActiveDrag>,
/// Pressed without Shift on points already picked among others: a click (no drag) picks
/// these alone, as a drag moves every picked point.
click: Option<(NodeId, Vec<usize>)>,
}
impl MeshEdit {
@@ -72,23 +125,84 @@ 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));
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)
{
origin = if let Some(h) = grab.handle { q.p + q.handles[h] } else { q.p };
/// The picked points that still apply: of editable meshes `shown` accepts, in range.
fn live(&self, cx: &ToolContext, shown: &dyn Fn(NodeId) -> bool) -> MeshPoints {
let editable = cx.doc.editable();
let mut out: Vec<(NodeId, usize, Vec<usize>)> = vec![];
for &(id, i) in &self.picked {
if out.last().is_none_or(|(last, ..)| *last != id) {
let len = if shown(id) && editable(id) { cx.doc.node(id).and_then(grid_of).map_or(0, |g| g.points.len()) } else { 0 };
out.push((id, len, vec![]));
}
if let Some((_, len, v)) = out.last_mut()
&& i < *len
{
v.push(i);
}
}
self.drag = Some(ActiveDrag { grab, origin, at: None });
vec![Action::Begin("Move Mesh Point".into())]
out.into_iter().filter(|(.., v)| !v.is_empty()).map(|(id, _, v)| (id, v)).collect()
}
/// A press at `p` on `grab`, among the meshes `ids`: a handle drag, or a point picked as
/// [`Self::press_points`] picks it.
pub fn press(&mut self, cx: &ToolContext, ids: &[NodeId], grab: MeshGrab, p: Point, shift: bool) -> Vec<Action> {
let Some(h) = grab.handle else { return self.press_points(cx, ids, grab.id, &[grab.index], p, shift) };
let Some(q) = cx.doc.node(grab.id).and_then(grid_of).and_then(|g| g.points.get(grab.index).cloned()) else { return vec![] };
let origin = q.p + q.handles.get(h).copied().unwrap_or_default();
self.start(ActiveDrag { grab, start: p, origin, at: None, many: vec![] })
}
/// A press at `p` on the points `points` of mesh `id` (one point, a mesh line's ends or a
/// patch's corners), among the meshes `ids`. Shift takes them away from the picked points when
/// all of them are picked (no drag), else adds them; without it they are picked alone unless
/// all of them already are. A drag then moves every picked point.
pub fn press_points(&mut self, cx: &ToolContext, ids: &[NodeId], id: NodeId, points: &[usize], p: Point, shift: bool) -> Vec<Action> {
let keys: Vec<(NodeId, usize)> = points.iter().map(|i| (id, *i)).collect();
let all = keys.iter().all(|k| self.picked.contains(k));
self.click = None;
if shift && all {
for k in &keys {
self.picked.remove(k);
}
self.focus = self.focus.filter(|f| !keys.contains(f));
return vec![];
}
if !shift && !all {
self.picked.clear();
}
self.picked.extend(keys.iter().copied());
self.focus = keys.first().copied().filter(|_| keys.len() == 1);
let many = self.live(cx, &|m| m == id || ids.contains(&m));
let count: usize = many.iter().map(|(_, v)| v.len()).sum();
if count == 0 {
return vec![];
}
if !shift && all && count > keys.len() {
self.click = Some((id, points.to_vec()));
}
let grab = MeshGrab { id, index: points.first().copied().unwrap_or_default(), handle: None };
// One point goes where the pointer takes it; a line or a patch moves by the drag's offset.
let origin = match points {
[i] => cx.doc.node(id).and_then(grid_of).and_then(|g| g.points.get(*i).map(|q| q.p)).unwrap_or(p),
_ => p,
};
let many = if count == 1 && keys.len() == 1 { vec![] } else { many };
self.start(ActiveDrag { grab, start: p, origin, at: None, many })
}
fn start(&mut self, d: ActiveDrag) -> Vec<Action> {
let label = if d.many.is_empty() { "Move Mesh Point" } else { "Move Mesh Points" };
self.drag = Some(d);
vec![Action::Begin(label.into())]
}
/// The preview of a drag to `p` (`None` when nothing is being dragged).
pub fn drag_to(&mut self, p: Point) -> Option<Vec<Action>> {
let d = self.drag.as_mut()?;
d.at = Some(p);
if !d.many.is_empty() {
return Some(vec![Action::Preview("object.mesh.movePoints".into(), move_params(&d.many, p - d.origin))]);
}
let g = d.grab;
let mut params = json!({"id": g.id.0, "index": g.index, "x": p.x, "y": p.y});
if let Some(h) = g.handle {
@@ -97,13 +211,21 @@ impl MeshEdit {
Some(vec![Action::Preview("object.mesh.movePoint".into(), params)])
}
/// End a drag (`None` when nothing was being dragged).
pub fn release(&mut self) -> Option<Vec<Action>> {
self.drag.take().map(|_| vec![Action::Commit])
/// End a drag (`None` when nothing was being dragged). A click on one of several picked
/// points picks it alone.
pub fn release(&mut self, cx: &ToolContext) -> Option<Vec<Action>> {
let d = self.drag.take()?;
if let Some((id, points)) = self.click.take()
&& d.at.is_none_or(|a| a.distance(d.start) < cx.tol(3.0))
{
self.picked = points.into_iter().map(|i| (id, i)).collect();
}
Some(vec![Action::Commit])
}
/// Cancel a drag: the point or handle goes back (`None` when nothing was being dragged).
pub fn cancel(&mut self) -> Option<Vec<Action>> {
self.click = None;
self.drag.take().map(|_| vec![Action::Cancel])
}
@@ -111,26 +233,69 @@ impl MeshEdit {
self.drag.is_some()
}
/// Forget the focused point.
/// Forget the focused and the picked points.
pub fn unfocus(&mut self) {
self.focus = None;
(self.focus, self.click) = (None, None);
self.picked.clear();
}
/// `{meshPoint: {id, index}|null}`: the focused point, which a solid fill colour recolours
/// (`paint.setFill` on a gradient mesh).
/// A marquee's picks: the points `inside` it are picked (with `toggle`, those picked are
/// taken away and the others added). Returns each mesh with points inside and whether any of
/// its points are picked now.
pub fn marquee(&mut self, inside: &[(NodeId, usize)], toggle: bool) -> Vec<(NodeId, bool)> {
if !toggle {
self.picked.clear();
}
for k in inside {
if !(toggle && self.picked.remove(k)) {
self.picked.insert(*k);
}
}
self.focus = self.picked.first().copied().filter(|_| self.picked.len() == 1);
let mut meshes: Vec<NodeId> = inside.iter().map(|(id, _)| *id).collect();
meshes.dedup();
meshes.into_iter().map(|id| (id, self.picked.range((id, 0)..=(id, usize::MAX)).next().is_some())).collect()
}
/// An arrow key's nudge of the picked points of the meshes `shown` accepts
/// (`object.mesh.movePoints`); `None` during a drag, for other keys or without picked points.
pub fn nudge(&self, cx: &ToolContext, key: ToolKey, mods: Mods, shown: &dyn Fn(NodeId) -> bool) -> Option<Vec<Action>> {
if self.drag.is_some() {
return None;
}
let d = cx.arrow_nudge(key, mods)?;
let live = self.live(cx, shown);
(!live.is_empty()).then(|| vec![Action::Exec("object.mesh.movePoints".into(), move_params(&live, d))])
}
/// `{meshPoints: [{id, index}…]}`: the picked points, which a solid fill colour recolours
/// (`paint.setFill`) and the Transparency panel's opacity fades (`transparency.set`) on a
/// gradient mesh.
pub fn options(&self) -> Value {
json!({"meshPoint": self.focus.map(|(id, index)| json!({"id": id.0, "index": index}))})
json!({"meshPoints": self.picked.iter().map(|(id, index)| json!({"id": id.0, "index": index})).collect::<Vec<_>>()})
}
/// 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 }];
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 });
/// The picked points of the meshes `shown` accepts, filled, and the focused point's handles
/// (lines and ends).
pub fn overlays(&self, cx: &ToolContext, shown: &dyn Fn(NodeId) -> bool) -> Vec<Overlay> {
let mut out = vec![];
for (id, points) in self.live(cx, shown) {
let Some(grid) = cx.doc.node(id).and_then(grid_of) else { continue };
out.extend(points.iter().filter_map(|i| grid.points.get(*i)).map(|q| Overlay::Anchor {
p: q.p,
color: FEEDBACK,
filled: true,
size: 5.0,
}));
}
if let Some((id, index)) = self.focus.filter(|(id, _)| shown(*id))
&& let Some(grid) = cx.doc.node(id).and_then(grid_of)
&& let Some(q) = grid.points.get(index)
{
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 });
}
}
if cx.measurement_labels
&& let Some(d) = &self.drag
@@ -262,4 +427,133 @@ mod tests {
assert!(t.overlays(&on).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &want)));
assert!(!t.overlays(&off).iter().any(|o| matches!(o, Overlay::Measure { .. })));
}
/// A 2×2 gradient mesh over the 100..200 square, its points 50 apart.
fn doc_with_mesh() -> (Document, NodeId) {
let (mut d, a) = doc_with_rect();
d.remove(a).unwrap();
let id = d.alloc_id();
let m = GradientMesh::from_surface(2, 2, &|u, v| Point::new(100.0 + 100.0 * u, 100.0 + 100.0 * v), &|_, _| vectorcraft_color::Color::WHITE);
let l = d.layers[0].id;
d.insert(Some(l), 0, Node::new(id, NodeKind::Mesh(m))).unwrap();
(d, id)
}
/// The picked points of `t`, as its `meshPoints` option lists them.
fn picked(t: &dyn Tool) -> Vec<usize> {
t.options()["meshPoints"].as_array().unwrap().iter().map(|p| p["index"].as_u64().unwrap() as usize).collect()
}
fn shift(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
let mut e = PointerEvent::new(kind, x, y);
e.mods.shift = true;
e
}
/// With the Mesh tool and Direct Selection, Shift-clicks pick several mesh points (and take a
/// picked one away); they show filled, a drag of one moves them all as one move, the arrow keys
/// nudge them, and a click on one of them picks it alone (#4207).
#[test]
fn shift_clicks_pick_mesh_points_that_drag_and_nudge_together() {
let (d, m) = doc_with_mesh();
let mut s = Selection::default();
s.set([m]);
let p = paint();
let c = cx(&d, &s, &p);
let at = |x: f64, y: f64| grid_of(d.node(m).unwrap()).unwrap().point_near(Point::new(x, y), 1.0).unwrap();
let sorted = |mut v: Vec<usize>| {
v.sort();
v
};
let (a, b, e) = (at(100.0, 100.0), at(200.0, 150.0), at(150.0, 100.0));
let tools: [(&str, Box<dyn Tool>); 2] =
[("mesh", crate::meshblend::create("mesh").unwrap()), ("directSelection", Box::new(crate::direct::DirectSelectionTool::new(false)))];
for (what, mut t) in tools {
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)), vec![Action::Begin("Move Mesh Point".into())], "{what}");
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 100.0, 100.0));
assert_eq!(t.pointer(&c, &shift(PointerKind::Down, 200.0, 150.0)), vec![Action::Begin("Move Mesh Points".into())], "{what}");
t.pointer(&c, &shift(PointerKind::Up, 200.0, 150.0));
assert_eq!(picked(&*t), sorted(vec![a, b]), "{what}");
let filled = t.overlays(&c).iter().filter(|o| matches!(o, Overlay::Anchor { filled: true, .. })).count();
assert_eq!(filled, 2, "{what}: the picked points show filled");
// A drag of either moves both.
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)), vec![Action::Begin("Move Mesh Points".into())], "{what}");
assert_eq!(
t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 110.0, 95.0)),
vec![Action::Preview("object.mesh.movePoints".into(), json!({"id": m.0, "points": sorted(vec![a, b]), "dx": 10.0, "dy": -5.0}))],
"{what}"
);
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 110.0, 95.0)), vec![Action::Commit], "{what}");
assert_eq!(picked(&*t), sorted(vec![a, b]), "{what}: a drag keeps them picked");
// Shift-clicking a picked point takes it away, without a drag.
assert!(t.pointer(&c, &shift(PointerKind::Down, 200.0, 150.0)).is_empty(), "{what}");
t.pointer(&c, &shift(PointerKind::Up, 200.0, 150.0));
assert_eq!(picked(&*t), [a], "{what}");
// The arrow keys nudge the picked points.
assert!(t.claims_key(&c, ToolKey::Right), "{what}");
assert_eq!(
t.key(&c, ToolKey::Right, Mods::default()),
vec![Action::Exec("object.mesh.movePoints".into(), json!({"id": m.0, "points": [a], "dx": 1.0, "dy": 0.0}))],
"{what}"
);
// A click on one of several picked points picks it alone.
t.pointer(&c, &shift(PointerKind::Down, 150.0, 100.0));
t.pointer(&c, &shift(PointerKind::Up, 150.0, 100.0));
assert_eq!(picked(&*t), sorted(vec![a, e]), "{what}");
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, 100.0));
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 100.0, 100.0)), vec![Action::Commit], "{what}");
assert_eq!(picked(&*t), [a], "{what}");
}
}
/// Direct Selection's marquee picks the mesh points inside it and selects their mesh (Shift
/// toggles them); a click on a mesh line picks its two ends and one inside a patch the four
/// points around it, selecting the mesh, and a drag from there moves them (#4207).
#[test]
fn direct_selection_picks_mesh_points_by_marquee_line_and_patch() {
let (d, m) = doc_with_mesh();
let mut s = Selection::default();
s.set([m]);
let p = paint();
let c = cx(&d, &s, &p);
let at = |x: f64, y: f64| grid_of(d.node(m).unwrap()).unwrap().point_near(Point::new(x, y), 1.0).unwrap();
let set = |v: &[(f64, f64)]| {
let mut v: Vec<usize> = v.iter().map(|(x, y)| at(*x, *y)).collect();
v.sort();
v
};
let mut t = crate::direct::DirectSelectionTool::new(false);
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 90.0, 90.0));
t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 160.0, 120.0));
let out = t.pointer(&c, &PointerEvent::new(PointerKind::Up, 160.0, 120.0));
assert!(
matches!(out.first(), Some(Action::Exec(cmd, q)) if cmd == "select.anchorsMany" && q["items"] == json!([{"id": m.0, "anchors": []}])),
"{out:?}"
);
assert_eq!(picked(&t), set(&[(100.0, 100.0), (150.0, 100.0)]));
// Shift: the picked points inside leave, the others join; the mesh stays selected.
t.pointer(&c, &shift(PointerKind::Down, 90.0, 90.0));
t.pointer(&c, &shift(PointerKind::Drag, 110.0, 160.0));
let out = t.pointer(&c, &shift(PointerKind::Up, 110.0, 160.0));
assert!(matches!(out.first(), Some(Action::Exec(cmd, q)) if cmd == "select.anchorsMany" && q["items"] == json!([])), "{out:?}");
assert_eq!(picked(&t), set(&[(150.0, 100.0), (100.0, 150.0)]));
// A mesh line: its two ends.
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 125.0, 150.0)), vec![Action::Begin("Move Mesh Points".into())]);
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 125.0, 150.0));
assert_eq!(picked(&t), set(&[(100.0, 150.0), (150.0, 150.0)]));
// A patch: the four points around it, which a drag moves by its offset.
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 175.0, 175.0)), vec![Action::Begin("Move Mesh Points".into())]);
let four = set(&[(150.0, 150.0), (200.0, 150.0), (150.0, 200.0), (200.0, 200.0)]);
assert_eq!(
t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 185.0, 180.0)),
vec![Action::Preview("object.mesh.movePoints".into(), json!({"id": m.0, "points": four, "dx": 10.0, "dy": 5.0}))]
);
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 185.0, 180.0));
// A mesh that isn't selected yet gets selected.
let none = Selection::default();
let c = cx(&d, &none, &p);
let out = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 125.0, 125.0));
assert_eq!(out, vec![Action::Exec("select.set".into(), json!({"ids": [m.0]})), Action::Begin("Move Mesh Points".into())]);
assert_eq!(picked(&t), set(&[(100.0, 100.0), (150.0, 100.0), (100.0, 150.0), (150.0, 150.0)]));
}
}