diff --git a/crates/engine/src/cmd/live/commands.rs b/crates/engine/src/cmd/live/commands.rs index 82105b00e..aec4e32d0 100644 --- a/crates/engine/src/cmd/live/commands.rs +++ b/crates/engine/src/cmd/live/commands.rs @@ -7,7 +7,7 @@ use super::envelope::{ env_edit_contents, env_expand, env_info, env_make_mesh, env_make_top, env_make_warp, env_options, env_release, env_reset_mesh, env_reset_warp, env_set_mesh_point, }; -use super::mesh::{mesh_add_line, mesh_create, mesh_delete_point, mesh_expand, mesh_move_point, mesh_set_color}; +use super::mesh::{mesh_add_line, mesh_create, mesh_delete_point, mesh_expand, mesh_move_point, mesh_move_points, mesh_set_color}; use super::{CommandSpec, always, cmd, has_doc, has_multi, has_selection}; pub fn specs() -> Vec { @@ -245,7 +245,7 @@ pub fn specs() -> Vec { "Set Mesh Point Color", [], None, - "{id, index, color, keepModel?: bool (as paint.setFill), opacity?: 0..1} colour of one mesh point", + "{id, index | points: [index…] | items: [{id, index | points}…], color?, keepModel?: bool (as paint.setFill), opacity?: 0..1} colour and/or opacity of gradient mesh points (one undo step; an index out of range changes nothing)", has_doc, mesh_set_color ), @@ -258,6 +258,15 @@ pub fn specs() -> Vec { has_doc, mesh_move_point ), + cmd!( + "object.mesh.movePoints", + "Move Mesh Points", + [], + None, + "{id, points: [index…] | items: [{id, index | points}…], dx, dy} move points of gradient meshes or mesh envelopes by (dx, dy), their handles with them (one undo step; an index out of range changes nothing)", + has_doc, + mesh_move_points + ), cmd!( "object.mesh.addLine", "Add Mesh Line", diff --git a/crates/engine/src/cmd/live/mesh.rs b/crates/engine/src/cmd/live/mesh.rs index 1d11080fb..602e2e3e9 100644 --- a/crates/engine/src/cmd/live/mesh.rs +++ b/crates/engine/src/cmd/live/mesh.rs @@ -1,7 +1,9 @@ +use std::collections::{BTreeMap, BTreeSet}; + use super::common::{fix_ids, ids_json, is_mesh, replace_with, roots_param, selected_of}; use super::envelope::{outline_of, rows_cols}; use super::{ - Color, Document, EngineError, EnvelopeKind, GradientMesh, MeshAppearance, Node, NodeKind, Paint, Point, Result, Session, Value, bad, + Color, Document, EngineError, EnvelopeKind, GradientMesh, MeshAppearance, Node, NodeId, NodeKind, Paint, Point, Result, Session, Value, bad, check_numbers, color_value, f64_or, f64_req, finite_numbers, gradient_sampler, id_param, json, live, ok, str_param, }; @@ -106,25 +108,86 @@ fn image_mesh(d: &Document, im: &vectorcraft_doc::ImageObject, rows: u32, cols: } /// Run `f` on the gradient mesh `p.id` (one undo step `label`); with `envelopes`, a mesh -/// envelope's grid too (its points and handles are written back: editing fills in the handles). +/// envelope's grid too (see [`edit_grid`]). fn with_mesh(s: &mut Session, p: &Value, cmd: &str, label: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result) -> Result { let id = id_param(p, "id").ok_or_else(|| bad(cmd, "missing `id`"))?; - let c = cmd.to_string(); - s.edit(label, move |d, _| match d.node_mut(id).map(|n| &mut n.kind) { + s.edit(label, move |d, _| edit_grid(d, id, cmd, envelopes, f)) +} + +/// Run `f` on the gradient mesh `id` of `d`; with `envelopes`, a mesh envelope's grid too (its +/// points and handles are written back: editing fills in the handles). +fn edit_grid(d: &mut Document, id: NodeId, cmd: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result) -> Result { + match d.node_mut(id).map(|n| &mut n.kind) { Some(NodeKind::Mesh(m)) => f(m), Some(NodeKind::Envelope { kind, .. }) if envelopes && matches!(**kind, EnvelopeKind::Mesh { .. }) => { let EnvelopeKind::Mesh { rows, cols, points, handles } = &mut **kind else { - return Err(bad(&c, "not a gradient mesh or mesh envelope")); + return Err(bad(cmd, "not a gradient mesh or mesh envelope")); }; - let mut m = live::envelope_grid(*rows, *cols, points, handles).ok_or_else(|| bad(&c, "the envelope's mesh is malformed"))?; + let mut m = live::envelope_grid(*rows, *cols, points, handles).ok_or_else(|| bad(cmd, "the envelope's mesh is malformed"))?; let r = f(&mut m)?; (*rows, *cols) = (m.rows, m.cols); *points = m.points.iter().map(|q| q.p).collect(); *handles = m.points.iter().map(|q| q.handles).collect(); Ok(r) } - Some(_) => Err(bad(&c, if envelopes { "not a gradient mesh or mesh envelope" } else { "not a gradient mesh" })), + Some(_) => Err(bad(cmd, if envelopes { "not a gradient mesh or mesh envelope" } else { "not a gradient mesh" })), None => Err(EngineError::NoNode(id)), + } +} + +/// Most mesh points one command names, over all its meshes. +const MAX_POINTS: usize = 1 << 20; + +/// The mesh points `p` names: `{id, index}`, `{id, points: [index…]}` or `{items: [{id, index | +/// points}…]}`. Each mesh comes once, with its points in order and without repeats. +fn point_items(p: &Value, cmd: &str) -> Result)>> { + let one = |q: &Value| -> Result<(NodeId, BTreeSet)> { + let id = id_param(q, "id").ok_or_else(|| bad(cmd, "missing `id`"))?; + let points = match q.get("points") { + None | Some(Value::Null) => BTreeSet::from([index_param(q, "index", cmd)?]), + Some(Value::Array(v)) if v.len() <= MAX_POINTS => v + .iter() + .map(|i| index_of(i.as_f64().ok_or_else(|| bad(cmd, "`points` must be point indexes"))?, "points", cmd)) + .collect::>()?, + Some(_) => return Err(bad(cmd, "`points` must be a list of point indexes")), + }; + Ok((id, points)) + }; + let items: Vec<(NodeId, BTreeSet)> = match p.get("items") { + None | Some(Value::Null) => vec![one(p)?], + Some(Value::Array(v)) if v.len() <= MAX_POINTS => v.iter().map(one).collect::>()?, + Some(_) => return Err(bad(cmd, "`items` must be a list of {id, points}")), + }; + let mut merged: BTreeMap> = BTreeMap::new(); + for (id, points) in items { + merged.entry(id).or_default().extend(points); + } + if merged.values().map(BTreeSet::len).sum::() > MAX_POINTS { + return Err(bad(cmd, format!("at most {MAX_POINTS} points"))); + } + Ok(merged.into_iter().collect()) +} + +/// Run `f` on each mesh point `items` names, as one undo step `label` (an index out of range +/// fails it, changing nothing); with `envelopes`, on mesh envelopes' points too. +fn with_points( + s: &mut Session, + items: &[(NodeId, BTreeSet)], + cmd: &str, + label: &str, + envelopes: bool, + mut f: impl FnMut(&mut live::MeshPoint) -> Result<()>, +) -> Result<()> { + s.edit(label, |d, _| { + for (id, points) in items { + edit_grid(d, *id, cmd, envelopes, |m| { + for i in points { + f(m.points.get_mut(*i).ok_or_else(|| bad(cmd, "index out of range"))?)?; + } + Ok(()) + })?; + } + Ok(()) }) } @@ -140,7 +203,11 @@ pub(super) fn handle_param(p: &Value, cmd: &str) -> Result> { } pub(super) fn index_param(p: &Value, key: &str, cmd: &str) -> Result { - let i = f64_req(p, key, cmd)?; + index_of(f64_req(p, key, cmd)?, key, cmd) +} + +/// `i`, the value of `key`, as an index: a whole number ≥ 0. +fn index_of(i: f64, key: &str, cmd: &str) -> Result { if i < 0.0 { return Err(bad(cmd, "index must be ≥ 0")); } @@ -156,14 +223,13 @@ pub(super) fn mesh_set_color(s: &mut Session, p: &Value) -> Result { const C: &str = "object.mesh.setPointColor"; check_numbers(p, &["opacity"], C)?; crate::cmd::paint::check_keep_model(p, C)?; - let index = index_param(p, "index", C)?; + let items = point_items(p, C)?; let color = p.get("color").and_then(color_value).map(|c| crate::cmd::paint::new_color(s, p, c)); let opacity = p.get("opacity").and_then(Value::as_f64); if color.is_none() && opacity.is_none() { return Err(bad(C, "missing `color` or `opacity`")); } - with_mesh(s, p, C, "Mesh Point Color", false, |m| { - let pt = m.points.get_mut(index).ok_or_else(|| bad(C, "index out of range"))?; + with_points(s, &items, C, "Mesh Point Color", false, |pt| { if let Some(c) = color { pt.color = c; } @@ -191,6 +257,18 @@ pub(super) fn mesh_move_point(s: &mut Session, p: &Value) -> Result { ok() } +pub(super) fn mesh_move_points(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.mesh.movePoints"; + let items = point_items(p, C)?; + let (dx, dy) = (f64_req(p, "dx", C)?, f64_req(p, "dy", C)?); + // `Session::edit` refuses a move past the canvas's coordinate range. + with_points(s, &items, C, "Move Mesh Points", true, |pt| { + pt.p = Point::new(pt.p.x + dx, pt.p.y + dy); + Ok(()) + })?; + ok() +} + pub(super) fn mesh_add_line(s: &mut Session, p: &Value) -> Result { const C: &str = "object.mesh.addLine"; let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?); diff --git a/crates/engine/src/cmd/opacitymask.rs b/crates/engine/src/cmd/opacitymask.rs index 50fcc349a..d5a7f600d 100644 --- a/crates/engine/src/cmd/opacitymask.rs +++ b/crates/engine/src/cmd/opacitymask.rs @@ -10,7 +10,7 @@ use std::collections::HashSet; use serde_json::{Value, json}; use vectorcraft_color::BlendMode; -use vectorcraft_doc::{Document, Knockout, Node, NodeId, OpacityMask, Selection}; +use vectorcraft_doc::{Document, Knockout, Node, NodeId, NodeKind, OpacityMask, Selection}; use super::maskedit::{begin, finish, mask_group, mask_parts, show_editing}; use super::pathops::{Beside, separate}; @@ -368,12 +368,19 @@ impl TransparencyInfo { } } - /// The opacity of the focused gradient mesh `point` (see `transparency.set`) instead of its - /// object's. - fn with_mesh_point(mut self, point: Option<(NodeId, usize, f32)>) -> Self { - if let Some((_, _, o)) = point { - self.opacity = Some((o as f64 * 10_000.0).round() / 100.0); - } + /// The opacity of the picked gradient mesh `points` (see `transparency.set`) instead of their + /// objects': `None` where they differ. + fn with_mesh_points(mut self, d: &Document, points: Option)>>) -> Self { + let Some(points) = points else { return self }; + let mut all = points.iter().flat_map(|(id, v)| { + let m = match d.node(*id).map(|n| &n.kind) { + Some(NodeKind::Mesh(m)) => Some(m), + _ => None, + }; + v.iter().filter_map(move |i| m?.points.get(*i)).map(|q| (q.opacity as f64 * 10_000.0).round() as i64) + }); + let first = all.next(); + self.opacity = first.filter(|f| all.all(|o| o == *f)).map(|o| o as f64 / 100.0); self } } @@ -404,8 +411,8 @@ impl Session { pub fn transparency_info(&self) -> TransparencyInfo { match (self.active(), transparency_targets(self, &Value::Null)) { (Some(st), Ok(ids)) => { - let point = super::paint::focused_mesh_point_of(self, &ids); - TransparencyInfo::of(&st.doc, ids).with_mesh_point(point) + let points = super::paint::picked_mesh_points_of(self, &ids); + TransparencyInfo::of(&st.doc, ids).with_mesh_points(&st.doc, points) } _ => TransparencyInfo::default(), } @@ -415,8 +422,8 @@ impl Session { fn info(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let ids = transparency_targets(s, p)?; - let point = super::paint::focused_mesh_point_of(s, &ids); - let i = TransparencyInfo::of(&st.doc, ids).with_mesh_point(point); + let points = super::paint::picked_mesh_points_of(s, &ids); + let i = TransparencyInfo::of(&st.doc, ids).with_mesh_points(&st.doc, points); Ok(json!({ "ids": i.ids.iter().map(|id| id.0).collect::>(), "opacity": i.opacity, diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 005fedc2a..c0ca01b67 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -462,9 +462,9 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) let selected = item.targets(s, p)?; let ids = super::graph::paint_targets(&s.doc()?.doc, &selected); let paint = if span { placed_across(&s.doc()?.doc, &ids, paint, p, fill) } else { paint }; - // The gradient mesh point last clicked with the Mesh tool or Direct Selection: a solid fill - // recolours just that point of its mesh. - let mesh_point = if fill && matches!(paint, Paint::Solid { .. }) { focused_mesh_point(s) } else { None }; + // The gradient mesh points picked with the Mesh tool or Direct Selection: a solid fill + // recolours just those points of their meshes. + let mesh_points = if fill && matches!(paint, Paint::Solid { .. }) { picked_mesh_points(s, &ids) } else { vec![] }; // Without `item`, unless the Appearance panel's active fill item takes the paint. let colorized = if fill && p.get("item").is_none() && item == ItemTarget::Top { colorized_images(&s.doc()?.doc, &ids, &paint).map_err(|e| bad(cmd, e))? @@ -486,11 +486,14 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) } if index.is_none() && let Paint::Solid { color, .. } = &paint - && let Some((_, i)) = mesh_point.filter(|(id, _)| *id == n.id) + && let Some((_, points)) = mesh_points.iter().find(|(id, _)| *id == n.id) && let NodeKind::Mesh(m) = &mut n.kind - && let Some(q) = m.points.get_mut(i) { - q.color = *color; + for i in points { + if let Some(q) = m.points.get_mut(*i) { + q.color = *color; + } + } return Ok(()); } if index.is_none() @@ -689,20 +692,31 @@ fn black_or_white(c: &Color) -> bool { } } -/// The mesh point the active tool has focused (its `meshPoint` option: the Mesh tool's or Direct -/// Selection's point last clicked). -fn focused_mesh_point(s: &Session) -> Option<(NodeId, usize)> { +/// The points the active tool has picked (its `meshPoints` option: the Mesh tool's or Direct +/// Selection's) on the gradient meshes among `ids`, in range, each mesh with its points. +fn picked_mesh_points(s: &Session, ids: &[NodeId]) -> Vec<(NodeId, Vec)> { let o = s.tool_options(); - let pt = o.get("meshPoint")?; - Some((NodeId(pt.get("id")?.as_u64()?), usize::try_from(pt.get("index")?.as_u64()?).ok()?)) + let (Some(picked), Ok(st)) = (o.get("meshPoints").and_then(Value::as_array), s.doc()) else { return vec![] }; + let mut out: Vec<(NodeId, Vec)> = vec![]; + for pt in picked { + let (Some(id), Some(index)) = (pt.get("id").and_then(Value::as_u64).map(NodeId), pt.get("index").and_then(Value::as_u64)) else { continue }; + let Ok(index) = usize::try_from(index) else { continue }; + if !ids.contains(&id) || !matches!(st.doc.node(id).map(|n| &n.kind), Some(NodeKind::Mesh(m)) if index < m.points.len()) { + continue; + } + match out.iter_mut().find(|(m, _)| *m == id) { + Some((_, v)) => v.push(index), + None => out.push((id, vec![index])), + } + } + out } -/// The focused mesh point (see [`focused_mesh_point`]) with its opacity, when `ids` is just the -/// gradient mesh it belongs to. -pub(crate) fn focused_mesh_point_of(s: &Session, ids: &[NodeId]) -> Option<(NodeId, usize, f32)> { - let (id, index) = focused_mesh_point(s).filter(|(id, _)| ids == [*id])?; - let NodeKind::Mesh(m) = &s.doc().ok()?.doc.node(id)?.kind else { return None }; - Some((id, index, m.points.get(index)?.opacity)) +/// The picked gradient mesh points (see [`picked_mesh_points`]) when each of `ids` is a gradient +/// mesh with points picked: the Transparency panel's opacity is theirs then. +pub(crate) fn picked_mesh_points_of(s: &Session, ids: &[NodeId]) -> Option)>> { + let v = picked_mesh_points(s, ids); + (!ids.is_empty() && ids.iter().all(|id| v.iter().any(|(m, _)| m == id))).then_some(v) } /// A type run's stroke; a painted stroke on a run without one gets 1 pt. @@ -848,15 +862,16 @@ fn transparency(s: &mut Session, p: &Value) -> Result { q.remove("item"); q.remove("id"); q.insert("ids".into(), json!(ids.iter().map(|id| id.0).collect::>())); - // The gradient mesh point last clicked with the Mesh tool or Direct Selection takes the - // opacity itself, so only the mesh around it fades (#4206). + // The gradient mesh points picked with the Mesh tool or Direct Selection take the opacity + // themselves, so only the mesh around them fades (#4206, #4170, #4208). if matches!(item, ItemTarget::Top) && let Some(o) = q.get("opacity").and_then(Value::as_f64) - && let Some((id, index, _)) = focused_mesh_point_of(s, &ids) + && let Some(points) = picked_mesh_points_of(s, &ids) { q.remove("opacity"); let opacity = f64::from(super::opacitymask::percent(o)); - s.execute("object.mesh.setPointColor", &json!({"id": id.0, "index": index, "opacity": opacity}))?; + let items: Vec = points.iter().map(|(id, v)| json!({"id": id.0, "points": v})).collect(); + s.execute("object.mesh.setPointColor", &json!({"items": items, "opacity": opacity}))?; if q.keys().all(|k| k == "ids") { return ok(); } diff --git a/crates/engine/src/tests/editcolors.rs b/crates/engine/src/tests/editcolors.rs index 511431c3c..1f8e6bd40 100644 --- a/crates/engine/src/tests/editcolors.rs +++ b/crates/engine/src/tests/editcolors.rs @@ -233,6 +233,81 @@ fn a_clicked_mesh_point_takes_the_opacity() { assert!(mesh_opacities(&s, m).iter().all(|a| *a == 1.0)); } +/// Mesh points picked together (Shift-clicks with the Mesh tool, a Direct Selection marquee) all +/// take a solid fill colour and the Transparency panel's opacity, and only they do; the panel +/// shows their opacity, blank when it differs; a drag of one moves them all as one undo step +/// (#4207, #4208, #4170). +#[test] +fn picked_mesh_points_take_the_fill_opacity_and_drag_together() { + let mut s = session(); + let m = rect(&mut s, json!("#ff0000")); + s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap(); + s.execute("select.set", &json!({"ids": [m.0]})).unwrap(); + let v = ViewInfo::default(); + let point = |s: &Session, i: usize| { + let NodeKind::Mesh(g) = &s.doc().unwrap().doc.node(m).unwrap().kind else { panic!("not a mesh") }; + g.points[i].p + }; + let pointer = |s: &mut Session, kind: PointerKind, q: vectorcraft_geom::Point, shift: bool| { + let mut ev = PointerEvent::new(kind, q.x, q.y); + ev.mods.shift = shift; + s.pointer(&ev, v).unwrap(); + }; + let click = |s: &mut Session, i: usize, shift: bool| { + let q = point(s, i); + pointer(s, PointerKind::Down, q, shift); + pointer(s, PointerKind::Up, q, shift); + }; + let check = |s: &Session, picked: &[usize], hex: &str, opacity: f32, what: &str| { + let (c, o) = (mesh_colors(s, m), mesh_opacities(s, m)); + for i in 0..c.len() { + let on = picked.contains(&i); + assert_eq!(c[i].to_hex() == hex, on, "{what}: point {i} colour {c:?}"); + assert_eq!((o[i] - opacity).abs() < 1e-6, on, "{what}: point {i} opacity {o:?}"); + } + assert_eq!(s.doc().unwrap().doc.node(m).unwrap().opacity, 1.0, "{what}: the object stays opaque"); + }; + // The Mesh tool: a click, then Shift-clicks add points (and a second one takes one away). + s.select_tool("mesh", v).unwrap(); + click(&mut s, 1, false); + for i in [4, 8, 8] { + click(&mut s, i, true); + } + s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("transparency.set", &json!({"opacity": 40})).unwrap(); + check(&s, &[1, 4], "#0000ff", 0.4, "mesh tool"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step for both points"); + assert_eq!(s.transparency_info().opacity, Some(40.0), "the panel shows the points' opacity"); + s.execute("object.mesh.setPointColor", &json!({"id": m.0, "index": 4, "opacity": 0.9})).unwrap(); + assert_eq!(s.transparency_info().opacity, None, "mixed: blank"); + // Direct Selection: a marquee over the top-left patch picks its four points. + s.select_tool("directSelection", v).unwrap(); + let corner = point(&s, 0); + pointer(&mut s, PointerKind::Down, corner - vectorcraft_geom::Vec2::new(8.0, 8.0), false); + let far = point(&s, 4) + vectorcraft_geom::Vec2::new(5.0, 5.0); + pointer(&mut s, PointerKind::Drag, far, false); + pointer(&mut s, PointerKind::Up, far, false); + assert_eq!(s.doc().unwrap().selection.objects, [m], "the marquee selects the mesh"); + s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap(); + s.execute("transparency.set", &json!({"opacity": 20})).unwrap(); + check(&s, &[0, 1, 3, 4], "#00ff00", 0.2, "marquee"); + // A drag of one picked point moves all four by the same offset, in one step. + let before: Vec<_> = (0..9).map(|i| point(&s, i)).collect(); + let undo = s.doc().unwrap().history.undo.len(); + let d = vectorcraft_geom::Vec2::new(4.0, 6.0); + pointer(&mut s, PointerKind::Down, before[4], false); + pointer(&mut s, PointerKind::Drag, before[4] + d, false); + pointer(&mut s, PointerKind::Up, before[4] + d, false); + for (i, was) in before.iter().enumerate() { + let want = if [0, 1, 3, 4].contains(&i) { *was + d } else { *was }; + assert!(point(&s, i).distance(want) < 1e-9, "point {i}: {:?} ≠ {want:?}", point(&s, i)); + } + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((0..9).map(|i| point(&s, i)).collect::>(), before); +} + #[test] fn blend_moves_a_mesh_to_the_blended_colour() { let mut s = session(); diff --git a/crates/engine/src/tests/live.rs b/crates/engine/src/tests/live.rs index aa09ad88f..d663db702 100644 --- a/crates/engine/src/tests/live.rs +++ b/crates/engine/src/tests/live.rs @@ -851,6 +851,63 @@ fn mesh_point_colour_move_and_undo() { assert!(s.execute("object.mesh.setPointColor", &json!({"id": a.0, "index": 99, "color": "#000000"})).is_err()); } +/// `object.mesh.movePoints` moves just the points it names (their handles with them), over one or +/// several meshes, as one undo step; `object.mesh.setPointColor` colours and fades several points +/// alike. A bad index, a non-number or a move past the coordinate range fails and changes nothing +/// (#4207, #4208). +#[test] +fn mesh_points_move_and_colour_together() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let b = rect(&mut s, 200.0, 0.0, 100.0, 100.0); + sel(&mut s, &[a, b]); + s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap(); + let (ma, mb) = (mesh_of(&s, a), mesh_of(&s, b)); + let n = undo_len(&s); + s.execute("object.mesh.movePoints", &json!({"id": a.0, "points": [0, 4, 4], "dx": 10, "dy": -5})).unwrap(); + let m = mesh_of(&s, a); + for (i, (q, was)) in m.points.iter().zip(&ma.points).enumerate() { + let want = if i == 0 || i == 4 { Point::new(was.p.x + 10.0, was.p.y - 5.0) } else { was.p }; + assert_eq!((q.p, q.handles), (want, was.handles), "point {i}: moved once if named, its handles with it"); + } + assert_eq!(undo_len(&s), n + 1); + s.execute("object.mesh.movePoints", &json!({"items": [{"id": a.0, "points": [1]}, {"id": b.0, "index": 2}], "dx": 1, "dy": 1})).unwrap(); + assert_eq!(mesh_of(&s, a).points[1].p, Point::new(ma.points[1].p.x + 1.0, ma.points[1].p.y + 1.0)); + assert_eq!(mesh_of(&s, b).points[2].p, Point::new(mb.points[2].p.x + 1.0, mb.points[2].p.y + 1.0)); + assert_eq!(undo_len(&s), n + 2, "both meshes in one step"); + // A move that would take a point past the coordinate range fails. + assert!(s.execute("object.mesh.movePoints", &json!({"id": a.0, "points": [0], "dx": 1e308, "dy": 0})).is_err()); + assert_eq!(undo_len(&s), n + 2); + for _ in 0..2 { + s.execute("edit.undo", &json!({})).unwrap(); + } + assert_eq!((mesh_of(&s, a), mesh_of(&s, b)), (ma.clone(), mb.clone())); + // Several points take one colour and opacity, in one step. + s.execute("object.mesh.setPointColor", &json!({"id": a.0, "points": [2, 6], "color": "#0000ff", "opacity": 0.25})).unwrap(); + for (i, q) in mesh_of(&s, a).points.iter().enumerate() { + let named = i == 2 || i == 6; + assert_eq!((q.color.to_hex() == "#0000ff", q.opacity == 0.25), (named, named), "point {i}"); + } + assert_eq!(undo_len(&s), n + 1); + let n = undo_len(&s); + let before = (mesh_of(&s, a), mesh_of(&s, b)); + for p in [ + json!({"id": a.0, "points": [0, 99], "dx": 1, "dy": 1}), + json!({"items": [{"id": a.0, "points": [0]}, {"id": b.0, "points": [99]}], "dx": 1, "dy": 1}), + json!({"id": a.0, "points": [-1], "dx": 1, "dy": 1}), + json!({"id": a.0, "points": [1.5], "dx": 1, "dy": 1}), + json!({"id": a.0, "points": ["1"], "dx": 1, "dy": 1}), + json!({"id": a.0, "points": 1, "dx": 1, "dy": 1}), + json!({"id": a.0, "points": [0], "dx": "1", "dy": 1}), + json!({"items": 3, "dx": 1, "dy": 1}), + json!({"points": [0], "dx": 1, "dy": 1}), + ] { + assert!(s.execute("object.mesh.movePoints", &p).is_err(), "{p}"); + } + assert!(s.execute("object.mesh.setPointColor", &json!({"id": a.0, "points": [0, 9], "color": "#000000"})).is_err()); + assert_eq!(((mesh_of(&s, a), mesh_of(&s, b)), undo_len(&s)), (before, n), "nothing changed"); +} + #[test] fn mesh_add_line_and_delete_point() { let mut s = session(); diff --git a/crates/tools/src/context.rs b/crates/tools/src/context.rs index f1973dfd5..7773762f2 100644 --- a/crates/tools/src/context.rs +++ b/crates/tools/src/context.rs @@ -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 { + 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)) diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index cb0a958f6..cba1c40b9 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -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> { + 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 { + 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 = 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> = 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![])); } } diff --git a/crates/tools/src/meshblend.rs b/crates/tools/src/meshblend.rs index 0ac424267..68c1e4132 100644 --- a/crates/tools/src/meshblend.rs +++ b/crates/tools/src/meshblend.rs @@ -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 { 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> { + 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 { + /// release does nothing. The arrow keys nudge the picked points. + fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec { 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 { + fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool { + self.nudge(cx, key, Mods::default()).is_some() + } + fn deactivate(&mut self, cx: &ToolContext) -> Vec { self.edit.unfocus(); - self.edit.release().unwrap_or_default() + self.edit.release(cx).unwrap_or_default() } } diff --git a/crates/tools/src/meshedit.rs b/crates/tools/src/meshedit.rs index 39f8adcdb..b3904efe4 100644 --- a/crates/tools/src/meshedit.rs +++ b/crates/tools/src/meshedit.rs @@ -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> { } } +/// 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> { + 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, } +/// Mesh points, each mesh with its points. +type MeshPoints = Vec<(NodeId, Vec)>; + /// 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, + /// 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)], d: Vec2) -> Value { + match items { + [(id, points)] => json!({"id": id.0, "points": points, "dx": d.x, "dy": d.y}), + _ => { + let items: Vec = 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, + /// 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)>, } 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 { - 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)> = 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 { + 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 { + 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 { + 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> { 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> { - 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> { + 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> { + 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 = 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> { + 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::>()}) } - /// The focused point's handles (lines and ends). - pub fn overlays(&self, cx: &ToolContext) -> Vec { - 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 { + 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 { + 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| { + 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); 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 = 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)])); + } } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index e5a6fe5a2..a496b97be 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1860,6 +1860,7 @@ font-technology Variable Променливи Move in Perspective Преместване в перспектива Move Locked and Hidden Artwork with Artboard Преместване на заключени и скрити обекти с работната област Move Mesh Point Преместване на точка от мрежата + Move Mesh Points Преместване на точки от мрежата Move Plane Преместване на равнина Move Plane to Match Object Преместване на равнината според обекта Move Rows Преместване на редове diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index d721532cf..bd918d326 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2419,6 +2419,7 @@ Move Library Item to Group Mou l’element de la biblioteca al grup Move Locked and Hidden Artwork with Artboard Mou la il·lustració bloquejada i oculta amb el tauler Move Mesh Point Mou el punt de malla + Move Mesh Points Mou els punts de malla Move Panel Mou el panell Move Plane Mou el pla Move Plane and Copy Objects Mou el pla i copia els objectes diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 0c33de6af..63a9223ce 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3314,6 +3314,7 @@ theme Appearance Motiv Make Envelope Vytvořit obálku Move Envelope Mesh Point Přesunout bod sítě obálky Move Mesh Point Přesunout bod sítě + Move Mesh Points Přesunout body sítě Move Spine Point Přesunout bod páteře Reshape Spine Segment Přetvarovat segment páteře Reset with Mesh Obnovit sítí diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 4e8f3d233..312f80712 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -3072,6 +3072,7 @@ theme Appearance Udseende Make Envelope Opret konvolut Move Envelope Mesh Point Flyt konvolut-meshpunkt Move Mesh Point Flyt meshpunkt + Move Mesh Points Flyt meshpunkter Move Spine Point Flyt rygradpunkt Reshape Spine Segment Omform rygradsegment Reset with Mesh Nulstil med mesh diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 74feb99bf..19e8c766f 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1915,6 +1915,7 @@ file-filter Templates Vorlagen Move in Perspective In Perspektive verschieben Move Locked and Hidden Artwork with Artboard Gesperrte und ausgeblendete Grafik mit Zeichenfläche verschieben Move Mesh Point Gitterpunkt verschieben + Move Mesh Points Gitterpunkte verschieben Move Plane Perspektivebene verschieben Move Plane to Match Object Perspektivebene an Objekt ausrichten Move Rows Zeilen verschieben diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index eb0fee4b5..9c4647fc4 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2675,6 +2675,7 @@ theme Appearance Εμφάνιση Move in Perspective Μετακίνηση σε προοπτική Move Locked and Hidden Artwork with Artboard Μετακίνηση κλειδωμένων και κρυφών σχεδίων μαζί με τον πίνακα σχεδίασης Move Mesh Point Μετακίνηση σημείου πλέγματος + Move Mesh Points Μετακίνηση σημείων πλέγματος Move Plane Μετακίνηση επιπέδου προοπτικής Move Plane to Match Object Μετακίνηση επιπέδου προοπτικής ώστε να ταιριάζει στο αντικείμενο Move Rows Μετακίνηση γραμμών diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index c7ae0fec7..64c34fbc0 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1913,6 +1913,7 @@ font-technology Variable Variables Move in Perspective Mover en perspectiva Move Locked and Hidden Artwork with Artboard Mover ilustración bloqueada y oculta con la mesa de trabajo Move Mesh Point Mover punto de malla + Move Mesh Points Mover puntos de malla Move Plane Mover plano Move Plane to Match Object Mover plano para que coincida con el objeto Move Rows Mover filas diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index a4235d8c3..bf54f5265 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -3253,6 +3253,7 @@ theme Appearance Ulkoasu Make Envelope Tee kuori Move Envelope Mesh Point Siirrä kuoren verkkopistettä Move Mesh Point Siirrä verkkopistettä + Move Mesh Points Siirrä verkkopisteitä Move Spine Point Siirrä selkärangan pistettä Reshape Spine Segment Muotoile selkärangan segmentti uudelleen Reset with Mesh Palauta verkolla diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 53d167834..eef0404d5 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1912,6 +1912,7 @@ font-technology Variable Variables Move in Perspective Déplacer en perspective Move Locked and Hidden Artwork with Artboard Déplacer les illustrations verrouillées et masquées avec le plan de travail Move Mesh Point Déplacer le point de filet + Move Mesh Points Déplacer les points de filet Move Plane Déplacer le plan Move Plane to Match Object Déplacer le plan pour l’aligner sur l’objet Move Rows Déplacer les lignes diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 20dd32810..19d2d85ee 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2418,6 +2418,7 @@ file-filter Templates Sablonok Move Library Item to Group Könyvtárelem áthelyezése csoportba Move Locked and Hidden Artwork with Artboard Zárolt és rejtett grafika mozgatása a rajztáblával Move Mesh Point Hálópont áthelyezése + Move Mesh Points Hálópontok áthelyezése Move Panel Panel áthelyezése Move Plane Sík áthelyezése Move Plane to Match Object Sík igazítása az objektumhoz diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index dab8acf35..e65b36190 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3072,6 +3072,7 @@ theme Appearance Tampilan Make Envelope Buat envelope Move Envelope Mesh Point Pindahkan titik mesh envelope Move Mesh Point Pindahkan titik mesh + Move Mesh Points Pindahkan titik-titik mesh Move Spine Point Pindahkan titik spine Reshape Spine Segment Bentuk ulang segmen spine Reset with Mesh Atur ulang dengan mesh diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 782ac2301..5326bb34c 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1940,6 +1940,7 @@ file-filter Templates Modelli Move in Perspective Sposta in prospettiva Move Locked and Hidden Artwork with Artboard Sposta grafica bloccata e nascosta con la tavola da disegno Move Mesh Point Sposta punto trama + Move Mesh Points Sposta punti trama Move Plane Sposta piano Move Plane to Match Object Sposta piano in base all'oggetto Move Rows Sposta righe diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index fa545683e..927b8d3b8 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2082,6 +2082,7 @@ font-technology Variable バリアブル Move Guide ガイドを移動 Move Locked and Hidden Artwork with Artboard ロックおよび非表示のアートワークをアートボードと一緒に移動 Move Mesh Point メッシュポイントを移動 + Move Mesh Points メッシュポイントを移動 Move Plane 面を移動 Move Rows 行を移動 Move Slices スライスを移動 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 02fa7cbc5..dbcaeca5d 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -2885,6 +2885,7 @@ theme Appearance იერსახე Make Envelope გარსის შექმნა Move Envelope Mesh Point გარსის ბადის წერტილის გადაადგილება Move Mesh Point ბადის წერტილის გადაადგილება + Move Mesh Points ბადის წერტილების გადაადგილება Move Spine Point ხერხემლის წერტილის გადაადგილება Reshape Spine Segment ხერხემლის სეგმენტის ფორმის შეცვლა Reset with Mesh ბადით ჩამოყრა diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 1d9b827af..81c87fdae 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2077,6 +2077,7 @@ font-technology Variable 가변 Move Guide 안내선 이동 Move Locked and Hidden Artwork with Artboard 잠기거나 숨겨진 아트워크를 아트보드와 함께 이동 Move Mesh Point 메시 포인트 이동 + Move Mesh Points 메시 포인트 이동 Move Plane 평면 이동 Move Rows 행 이동 Move Slices 슬라이스 이동 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 74debd4a1..3ec76a559 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3086,6 +3086,7 @@ theme Appearance Vormgeving Make Envelope Omtrek maken Move Envelope Mesh Point Maaspunt van omtrek verplaatsen Move Mesh Point Maaspunt verplaatsen + Move Mesh Points Maaspunten verplaatsen Move Spine Point Aspunt verplaatsen Reshape Spine Segment Segment van de as opnieuw vormgeven Reset with Mesh Opnieuw instellen met maas diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index c1051905b..e2b44fb04 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3194,6 +3194,7 @@ theme Appearance Wygląd Make Envelope Utwórz kopertę Move Envelope Mesh Point Przesuń punkt siatki koperty Move Mesh Point Przesuń punkt siatki + Move Mesh Points Przesuń punkty siatki Move Spine Point Przesuń punkt grzbietu Reshape Spine Segment Zmień kształt segmentu grzbietu Reset with Mesh Resetuj z siatką diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 401402b14..878bf119f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1597,6 +1597,7 @@ Move Guide Mover Guia Move Locked and Hidden Artwork with Artboard Mover Arte Bloqueada e Oculta com a Mesa de Trabalho Move Mesh Point Mover Ponto de Malha + Move Mesh Points Mover Pontos de Malha Move Plane Mover Plano Move Plane to Match Object Mover Plano para Corresponder ao Objeto Move Slices Mover Fatias diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index f458e20f7..90f7a8f5e 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1938,6 +1938,7 @@ file-filter Templates Шаблоны Move in Perspective Переместить в перспективе Move Locked and Hidden Artwork with Artboard Перемещать заблокированный и скрытый рисунок вместе с монтажной областью Move Mesh Point Переместить точку сетки + Move Mesh Points Переместить точки сетки Move Plane Переместить плоскость Move Plane to Match Object Переместить плоскость по объекту Move Rows Переместить строки diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 110968766..d8f16d7c8 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1860,6 +1860,7 @@ font-technology Variable Variabla Move in Perspective Flytta i perspektiv Move Locked and Hidden Artwork with Artboard Flytta låst och dold grafik med ritytan Move Mesh Point Flytta nätpunkt + Move Mesh Points Flytta nätpunkter Move Plane Flytta plan Move Plane to Match Object Flytta plan så att det matchar objekt Move Rows Flytta rader diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index c8327c943..85822e458 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1757,6 +1757,7 @@ font-technology Variable Değişken Move in Perspective Perspektifte Hareket Et Move Locked and Hidden Artwork with Artboard Çalışma Yüzeyi ile Kilitli ve Gizli Resimleri Taşıma Move Mesh Point Mesh Noktasını Taşı + Move Mesh Points Mesh Noktalarını Taşı Move Plane Düzlemi Taşı Move Plane to Match Object Nesneyi Eşleştirecek Düzlemi Taşı Move Rows Satırları Taşı diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 097b25252..69d617b9b 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1934,6 +1934,7 @@ file-filter Templates Шаблони Move in Perspective Перемістити у перспективі Move Locked and Hidden Artwork with Artboard Переміщати заблокований та прихований малюнок разом із монтажною областю Move Mesh Point Перемістити точку сітки + Move Mesh Points Перемістити точки сітки Move Plane Перемістити площину Move Plane to Match Object Перемістити площину об’єктом Move Rows Перемістити рядки diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index a902698a0..4d9c25583 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2227,6 +2227,7 @@ file-filter Templates Mẫu Move Library Item to Group Di chuyển mục thư viện vào nhóm Move Locked and Hidden Artwork with Artboard Di chuyển tác phẩm đã khóa và ẩn cùng bảng vẽ Move Mesh Point Di chuyển điểm lưới + Move Mesh Points Di chuyển các điểm lưới Move Panel Di chuyển bảng Move Plane Di chuyển mặt phẳng Move Plane to Match Object Di chuyển mặt phẳng khớp với đối tượng diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index ac5e9fcae..8aed395a3 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1811,6 +1811,7 @@ file-filter Templates 模板 Move in Perspective 在透视中移动 Move Locked and Hidden Artwork with Artboard 随画板移动锁定和隐藏图稿 Move Mesh Point 移动网格点 + Move Mesh Points 移动网格点 Move Plane 移动平面 Move Plane to Match Object 移动平面以符合对象 Move Slices 移动切片 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index ff6da5768..0d2172acf 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2729,6 +2729,7 @@ file-filter Templates 範本 Move in Perspective 在透視中移動 Move Locked and Hidden Artwork with Artboard 移動工作區域時一併移動鎖定與隱藏的圖稿 Move Mesh Point 移動網格點 + Move Mesh Points 移動網格點 Move Plane 移動平面 Move Plane to Match Object 移動平面以符合物件 Move Rows 移動列 diff --git a/docs/mcp.md b/docs/mcp.md index 0e38a883d..be148d897 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1249,7 +1249,7 @@ the Transparency panel's values, with `null` where the objects differ. Making a empty mask and enters mask editing: art drawn then becomes the mask, and the mask commands act on the masked object until `transparency.stopEditingOpacityMask`. While a mask is edited, `object.exitIsolation` (Esc, the isolation bar's back arrow, Exit Isolation Mode) leaves mask editing the same way, in one undo step. Saves and exports never include -the editing layer. +the editing layer. With gradient mesh points picked, `opacity` goes to those points (see Picking several mesh points). ```json {"name":"run_command","arguments":{"command":"transparency.set","params":{"ids":[12],"opacity":40,"blend":"Multiply"}}} @@ -5599,6 +5599,29 @@ Envelope Options. {"name":"run_command","arguments":{"command":"object.mesh.movePoint","params":{"id":12,"index":4,"x":220,"y":140,"handle":0}}} ``` +## Picking several mesh points + +The Mesh tool and Direct Selection pick mesh points on gradient meshes and mesh envelopes. A click picks one point +(its handles show); Shift-click adds a point or takes a picked one away. Direct Selection's marquee picks the points +inside it (Shift toggles them) and selects their meshes; its click on a gradient mesh's line picks the line's two +ends, and a click inside a patch picks the four points around it. The picked points show filled. Dragging one of them +moves them all as one undo step (`object.mesh.movePoints`), a click on one of several picked points picks it alone, +and the arrow keys nudge them by the keyboard increment. The active tool lists them in its options +(`tool.setOption {}` returns them as `meshPoints: [{id, index}…]`). On gradient meshes a solid fill colour (`paint.setFill`, from the +Color panel, Swatches or the Eyedropper) recolours just the picked points, and when every target of +`transparency.set` is a gradient mesh with points picked, its `opacity` fades those points instead of the objects +(`transparency.info` then shows their opacity, `null` when it differs). Both are one undo step. + +`object.mesh.movePoints {id, points: [index…], dx, dy}` (or `{items: [{id, points}…], dx, dy}` over several meshes) +moves points by (dx, dy), their handles with them; `object.mesh.setPointColor` takes `points` or `items` the same +way to colour or fade several points at once. An index out of range, an index that is not a whole number ≥ 0, or a +move past the coordinate range fails and changes nothing. + +```json +{"name":"run_command","arguments":{"command":"object.mesh.movePoints","params":{"id":12,"points":[0,4],"dx":10,"dy":-5}}} +{"name":"run_command","arguments":{"command":"object.mesh.setPointColor","params":{"id":12,"points":[0,4],"color":"#3366ff","opacity":0.5}}} +``` + ## Shortcut sets from other apps `shortcutSet.read {path? | data? | dataBase64?, name?}` reads a shortcut set another app saved as text (a `.kys` file diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index bf7f1c7c3..a5067aff8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Drawing aids: Character panel menu › Show Snap to Glyph Options shows the Snap to Glyph section (Baseline, x-height, Glyph Bounds, Proximity Guides, Angular Guides, Anchor Point; `view.snapToGlyph.options`) (#4232); scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Colour, gradients & mesh: several gradient mesh points can be picked (Shift-clicks with the Mesh tool and Direct Selection, Direct Selection's marquee, its click on a mesh line or inside a patch); a drag or the arrow keys move them all (`object.mesh.movePoints`), and a solid fill colour or the Transparency panel's opacity goes to each of them (#4207, #4208, #4170); scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -874,6 +874,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Image Trace, simpler contour walk:** `contour::trace_mask` works out each turn from the pixels during the walk, with no precomputed direction table and no assertion; identical loops to the previous walk on every 4 × 4 mask (see [quality notes](image-trace-quality.md#a-simpler-contour-walk)). - **Image Trace, measured:** `crates/trace/tests/quality` traces drawings made from known vector art with every preset and [`image-trace-quality.md`](image-trace-quality.md) records the numbers against the baseline. Black and White, Grayscale and Color place each edge by the blend of the pixels across it (to a fraction of a pixel on anti-aliased and blurred images; hard-edged images are read as staircases) and fit outlines as Flat Logo does. *Measured* on each preset's own inputs, all sizes and variants: Default's outlines went from 0.31 px off on average to 0.09 (largest 1.51 to 1.02), Black and White Logo's from 0.27 to 0.06 with 0.3 kinks per trace where it had 15, and the gaps between abutting paths from 64 px to 22. On clean curved shapes Black and White Logo is 0.02 px off (the open tracer measured beside it: 0.20). The palette is made of the picture's own colours (blends along edges take none of it, colours that noise made of one colour merge, a blend pixel goes to one of the two colours it lies between; #1504): 16 Colors on flat art, all variants, went from 70 paths to 4.2 and from 5.1 px off to 0.13, with no path left in a colour the art doesn't have (5.9 before). Photos are smoothed within their tones before they are quantised and stacked layers go by lightness (#1516): High Fidelity Photo went from 3,653 paths to 121 with its colour error down from 2.30 to 1.75, and a 16 megapixel photo-like image is traced in 3.5 s where 4 megapixels were refused. Since then, by the same harness: outlines are cut at their junctions and each shared piece is fitted once (#1535: gaps and overlaps between abutting paths 22 to 50 px of area down to under 2); layers are traced within the box of their pixels and a few at a time (#1546: a 16 megapixel photo 3.9 GB to 0.9 GB); black and white are read against the image's own black and the paper near each edge (#1563, for thin lines in blurred and scanned art); Ignore Color and the Auto Color and Outline presets (#1574); Snap Curves To Lines levels lines within 2.5 degrees of level or upright (#1593, the angle our choice); Create: Strokes puts centre lines in the middle of their ink and makes them as wide as it (#1595: Outline 0.295 to 0.052 px from the true centre line, 18.4 to 2.7 anchors a stroke); sharp corners whose pixel is mostly outside the shape stay corners (#1609: Default 69% to 76% kept). Three options, each off unless asked for as the legacy presets have them: Shapes (#1609: 57 to 62% of plain circles, ellipses, rectangles and regular polygons come out as live shapes, rounded rectangles rarely), Auto Grouping (#1618: paths grouped with what lies inside them, the same picture) and Gradients (#1627: linear gradients traced as one gradient-filled path; on gradient inputs 6 Colors' colour error 2.12 to 0.62 with 6.4 to 3.4 paths, none found in flat art). Snap Curves To Lines moves no end of a line by more than 1.6 px, so long lines a degree or two off stay as the image has them (#1647: Technical Drawing's largest distance 4.21 px to 1.46). Flat Logo takes the tones a scan's uneven paper makes of one colour for one colour (#1657: on scans 470 paths and 21 px off to 8.5 paths and 0.09 px). Shapes finds rounded rectangles and shapes in aliased, blurred and scanned art (#1845: 57 to 62% of the plain shapes up to 98 to 100%, no false shape but one line 2 px wide). Stroked lines get square caps where their ink is cut square, and a wide end that turned towards a corner goes back on its line (#1846: Outline's largest distance 2.94 px to 0.88, 14 anchors to 10). Each of the 14 documented presets has an enhanced version with Auto Grouping and, in all but High Fidelity Photo, Shapes (#1847), measured beside its preset: equal or better within 0.005 px and 1% of edge ΔE on every preset's own inputs (Default 0.091 to 0.083 px); Auto Grouping keeps traced order in its groups, which removed a light seam along the counters of letters. Gradients grows a gradient over the pixels noise and soft rims took off it (#1848: blurred and noisy linear gradients 3 to 11 paths beside the gradient down to 0.5 with 6 Colors, 24 to 56 down to 0.5 to 1.5 with High Fidelity Photo). Silhouettes is 0.52 px off because its threshold is 200 (0.11 px at 128). Still weak, by the same numbers: rounded rectangles and other line-and-arc outlines take 12 to 16 anchors (the fitter does not split lines from arcs), blurred and scanned polygons lose corners, hairlines under half a pixel are lost to the threshold, aliased art is about 0.25 px off, a scan's uneven paper splits into tones when more colours are asked for than the art has, stroke junctions stay at their skeleton pixels, a three-stop gradient in a small or scanned disc is not found, radial gradients stay bands (a draft, #1849, traces them; the documented option is for linear ones, so it waits for a decision), near-regular polygons a few dozen pixels across are made regular with Shapes (up to 0.5 px off), and aliased stroked lines are 0.8 px off at worst. - **Gradient mesh from an image:** Object › Create Gradient Mesh (`object.mesh.create`) on an embedded image makes a mesh of 1 to 50 rows and columns over the picture's rectangle, placed with the image's transform; each point takes the alpha-weighted average colour and the opacity of the area around it, and Appearance and Highlight apply. A linked image is refused with a message to embed it (#1676). Scores and hours unchanged. +- **Several mesh points at once (#4207, #4208, #4170):** the Mesh tool and Direct Selection pick several points of a gradient mesh or mesh envelope: Shift-click adds a point or takes a picked one away, Direct Selection's marquee picks the points inside it (Shift toggles them) and selects their meshes, and its click on a mesh line picks the line's two ends and one inside a patch the four points around it. The picked points show filled; dragging one moves them all as one undo step and the arrow keys nudge them (`object.mesh.movePoints {id, points, dx, dy}` or `{items, dx, dy}`), and a click on one of several picked points picks it alone. A solid fill colour (`paint.setFill`: Color panel, Swatches, Eyedropper) recolours every picked point, and the Transparency panel's Opacity fades them instead of the object, the panel showing their opacity (blank when it differs); `object.mesh.setPointColor` takes `points` or `items`. Public reference: [Edit mesh objects](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-meshes/edit-mesh-objects.html) (Shift selects multiple mesh points; a fill colour colours the selected point), [Macworld, gradient mesh](https://www.macworld.com/article/202693/gmesh2.html) (Direct Selection: a mesh line selects the points at both ends, a patch the points around it, Shift-click adds or removes points), [Peachpit](https://www.peachpit.com/articles/article.aspx?p=1638270) (each mesh point can have its own colour or transparency). Tests: `shift_clicks_pick_mesh_points_that_drag_and_nudge_together` and `direct_selection_picks_mesh_points_by_marquee_line_and_patch` in `crates/tools/src/meshedit.rs`, `picked_mesh_points_take_the_fill_opacity_and_drag_together` in `crates/engine/src/tests/editcolors.rs`, `mesh_points_move_and_colour_together` in `crates/engine/src/tests/live.rs`. Scores and hours unchanged. - **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. Line art whose only solid area is white paper (strokes a pixel or two wide) takes its colours from the strokes' cores, with the lighter shades along a stroke's anti-aliased edge folded into its ink; a page whose ink (a channel at least 48 below white) covers under 1% of it traces to nothing, as blank paper does. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original. - **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own, and regions they bound merge without seams where the lines cross at rounded coordinates (#893); filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points is refused with advice rather than running out of memory, an image over 64 megapixels is traced scaled down to that size (aspect kept, paths at the image's own size) and one over 192 megapixels is refused, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). A single selected mirror repeat shows a prominent axis with a center move handle and two rotation handles. Drag the line or center to change spacing, or an end to change angle; Shift snaps rotation to 45°. The original artwork stays in place, the reflected copy previews live, Escape cancels and each drag takes one undo step through `object.repeat.options`. Controls follow zoom/view rotation and hide with edges or the bounding box. Engine and headless canvas regressions passed for gestures, history and visibility; native move, rotation and Escape screenshots were reviewed. [Public mirror-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-mirror-repeat-options.html). A single selected grid repeat shows a handle below the grid for rows, one right of it for columns and one past its bottom-right corner for both, and sliders between its first two columns and rows for the horizontal and vertical spacing; the source keeps its size, each drag is one undo step and Escape cancels it ([public grid-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html), #2691). Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756). Choosing the Blend tool again while it is active (its toolbar button, W or `tool.select`) finishes the blend: the object clicked first is dropped and the next click no longer adds to the blend just made, so the click after it starts a new blend ([Blending objects](https://web.archive.org/web/20241217212108/https://helpx.adobe.com/illustrator/using/blending-objects.html)). @@ -1203,6 +1204,7 @@ applies to fills). Tests: `color::gradient::tests` (perceptual midpoint, expansi | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Colour, gradients & mesh: several gradient mesh points can be picked (Shift-clicks with the Mesh tool and Direct Selection, Direct Selection's marquee, its click on a mesh line or inside a patch); a drag or the arrow keys move them all (`object.mesh.movePoints`), and a solid fill colour or the Transparency panel's opacity goes to each of them (#4207, #4208, #4170); scores unchanged. | | 2026-10-30 | minor | Drawing aids: Character panel menu › Show Snap to Glyph Options shows the Snap to Glyph section (Baseline, x-height, Glyph Bounds, Proximity Guides, Angular Guides, Anchor Point; `view.snapToGlyph.options`) (#4232); evidence added; score and hours unchanged (estimated). | | 2026-10-30 | minor | Selection, transform & align tools: Align panel menu › Align to Glyph Bounds › Point Text and Area Text align and distribute type by the ink of its glyphs (#4231); evidence added; score and hours unchanged (estimated). | | 2026-10-30 | minor | Blob Brush and Eraser Tool Options: Size follows the pen pressure, Fixed or Pressure with a Variation (#4590, #3961); scores and estimates unchanged |