From 3d8c40f500ebb750bf07eb0380378395f555752f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 26 Oct 2026 08:31:53 +0700 Subject: [PATCH] Eraser: Angle and Roundness in Eraser Tool Options shape the tip Squashed from #4180 onto main. --- crates/engine/src/cmd/cut.rs | 10 ++-- crates/engine/src/cmd/draw2.rs | 42 +++++++++---- crates/engine/src/tests_cut.rs | 11 ++++ crates/engine/src/tests_draw2.rs | 81 +++++++++++++++++++++++++- crates/tools/src/draw2.rs | 29 ++++++++- crates/tools/src/draw2/gesture.rs | 70 ++++++++++++++++++++-- crates/tools/src/settings.rs | 2 + crates/ui-egui/src/dialogs/freehand.rs | 61 ++++++++++++++++++- crates/ui-egui/src/menus.rs | 2 +- docs/control-protocol.md | 3 +- docs/mcp.md | 3 +- docs/target-app-parity.md | 1 + 12 files changed, 284 insertions(+), 31 deletions(-) diff --git a/crates/engine/src/cmd/cut.rs b/crates/engine/src/cmd/cut.rs index 7b18289e9..faf905052 100644 --- a/crates/engine/src/cmd/cut.rs +++ b/crates/engine/src/cmd/cut.rs @@ -209,8 +209,8 @@ fn split_by_line(pd: &PathData, rule: FillRule, axis: &Axis) -> Option= 0.0)?; + let a = erase_outline(pd, f64::INFINITY, &|q| axis.side(q) < 0.0)?; + let b = erase_outline(pd, f64::INFINITY, &|q| axis.side(q) >= 0.0)?; Some(if pd.subpaths.len() == 1 { a.into_iter().chain(b).collect() } else { @@ -249,7 +249,9 @@ fn crop(pd: &PathData, rule: FillRule, rect: Rect) -> Option> { return Some(if r.is_empty() { vec![] } else { vec![r] }); } let inside = rect.inflate(1e-9, 1e-9); - let pieces = erase_outline(pd, &|q| !(q.x >= inside.x0 && q.x <= inside.x1 && q.y >= inside.y0 && q.y <= inside.y1))?; + // Sampled at least every half of the rectangle's shorter side, so a line through it is found. + let step = rect.width().min(rect.height()) / 2.0; + let pieces = erase_outline(pd, step, &|q| !(q.x >= inside.x0 && q.x <= inside.x1 && q.y >= inside.y0 && q.y <= inside.y1))?; Some(if pd.subpaths.len() == 1 || pieces.is_empty() { pieces } else { @@ -317,7 +319,7 @@ fn mirror(pd: &PathData, rule: FillRule, axis: &Axis, sign: f64) -> Vec pieces.into_iter().flat_map(|p| p.subpaths).collect(), None => pd.subpaths.clone(), }; diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index 8c1a153f3..2737c59b5 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -12,6 +12,7 @@ use vectorcraft_geom::hit::fill_contains; use vectorcraft_geom::{Anchor, AnchorKind, BezPath, FillRule, PathData, Point, Rect, SubPath, Vec2}; use vectorcraft_pathops as po; use vectorcraft_pathops::{BoolOp, Cap, Join, SimplifyOptions}; +use vectorcraft_tools::draw2::{eraser_area, eraser_tip}; use super::create::{add_look, insert_look}; use super::newart::NewArt; @@ -107,7 +108,7 @@ pub fn specs() -> Vec { "Eraser", [], None, - "{points: [[x,y]…], size?: pt (10)} erase a round-brush stroke from paths (selected, or all) → {ids}", + "{points: [[x,y]…], size?: pt (10), angle?: ° (0), roundness?: % (100, kept within 1–100)} erase the stroke of an elliptical tip from paths (selected, or all) → {ids}. The tip is `size` across its long axis, which runs at `angle` counter-clockwise from horizontal; its short axis is `roundness` percent of the long one", has_doc, erase_region ), @@ -410,18 +411,26 @@ pub(super) fn components(pd: &PathData) -> Vec { groups.into_iter().map(PathData::new).collect() } -/// The pieces of `sp` where `remove(point)` is false. `None` when nothing is removed. -fn cut_subpath(sp: &SubPath, remove: &dyn Fn(Point) -> bool) -> Option> { +/// The pieces of `sp` where `remove(point)` is false. `None` when nothing is removed. Each segment +/// is sampled at least every `step` pt (and at least 24 times), so a removed stretch at least +/// `step` long is found. +fn cut_subpath(sp: &SubPath, step: f64, remove: &dyn Fn(Point) -> bool) -> Option> { let nseg = sp.segment_count(); if nseg == 0 { return sp.anchors.first().filter(|a| remove(a.p)).map(|_| vec![]); } const K: usize = 24; + const MAX_SAMPLES: usize = 4096; let mut runs: Vec> = vec![]; let mut cur: Vec<(usize, f64, f64)> = vec![]; let mut any = false; for i in 0..nseg { let c = sp.segment(i); + // The curve moves at most 3 times its longest control-polygon edge per unit of t, so with + // n samples, neighboring samples are at most 3 · edge / n apart. + let edge = (c.p1 - c.p0).hypot().max((c.p2 - c.p1).hypot()).max((c.p3 - c.p2).hypot()); + let wanted = (3.0 * edge / step).ceil(); + let samples = if wanted.is_finite() && wanted > K as f64 { (wanted as usize).min(MAX_SAMPLES) } else { K }; let mut prev_t = 0.0; let mut prev_rm = remove(c.eval(0.0)); let mut start = if prev_rm { None } else { Some(0.0) }; @@ -431,8 +440,8 @@ fn cut_subpath(sp: &SubPath, remove: &dyn Fn(Point) -> bool) -> Option bool) -> Option bool) -> Option> { +/// Erase from an outline path (open or closed) where `remove` holds, sampling each segment at least +/// every `step` pt (`f64::INFINITY` for the fixed sampling). Returns the new geometry per output +/// object (one object per piece when the path had a single subpath), or None if unchanged. +pub(super) fn erase_outline(pd: &PathData, step: f64, remove: &dyn Fn(Point) -> bool) -> Option> { let mut changed = false; let mut subs = vec![]; for sp in &pd.subpaths { - match cut_subpath(sp, remove) { + match cut_subpath(sp, step, remove) { Some(pieces) => { changed = true; subs.extend(pieces); @@ -1159,9 +1169,17 @@ fn erase_region(s: &mut Session, p: &Value) -> Result { if !(size.is_finite() && size > 0.0) { return Err(bad(C, "size must be positive")); } + let angle = number_or(p, "angle", 0.0, C)?; + let roundness = number_or(p, "roundness", 100.0, C)?; let r = size / 2.0; + // The tip's short axis: an open path is sampled at least every half of it. + let short = size * roundness.clamp(1.0, 100.0) / 100.0; let st = s.doc()?; - let region = brush_region(&pts, size); + let region = eraser_area(&pts, size, angle, roundness); + // A point is under the tip when, with the tip squashed back into a circle, it is within the + // circle's radius of the squashed drag. + let squash = eraser_tip(angle, roundness).inverse(); + let round: Vec = pts.iter().map(|q| squash * *q).collect(); let mut plan: Vec<(NodeId, Vec)> = vec![]; for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, r)) { let (pd, rule) = path_of(&st.doc, id)?; @@ -1170,7 +1188,7 @@ fn erase_region(s: &mut Session, p: &Value) -> Result { continue; } plan.push((id, components(&po::boolean(&pd, rule, ®ion, FillRule::NonZero, BoolOp::Difference)))); - } else if let Some(pieces) = erase_outline(&pd, &|q| dist_to_polyline(&pts, q) <= r) { + } else if let Some(pieces) = erase_outline(&pd, short / 2.0, &|q| dist_to_polyline(&round, squash * q) <= r) { plan.push((id, pieces)); } } @@ -1205,7 +1223,7 @@ fn erase_segments(s: &mut Session, p: &Value) -> Result { let mut plan = vec![]; for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, w)) { let (pd, _) = path_of(&st.doc, id)?; - if let Some(pieces) = erase_outline(&pd, &|q| dist_to_polyline(&pts, q) <= w / 2.0) { + if let Some(pieces) = erase_outline(&pd, w / 2.0, &|q| dist_to_polyline(&pts, q) <= w / 2.0) { plan.push((id, pieces)); } } diff --git a/crates/engine/src/tests_cut.rs b/crates/engine/src/tests_cut.rs index a0a0d2a7b..f599203fd 100644 --- a/crates/engine/src/tests_cut.rs +++ b/crates/engine/src/tests_cut.rs @@ -161,6 +161,17 @@ fn rect_cut_crops_to_real_geometry() { assert!(s.execute("path.rectCut", &json!({"rect": [0, 0, 10]})).is_err()); } +#[test] +fn rect_cut_keeps_a_long_line_through_a_narrow_rectangle() { + // A 200 pt line used to be tested every 200 / 24 pt (at 400 and 408.33 near here), so a + // rectangle between two of those points kept nothing of it. + let mut s = session(); + let line = path(&mut s, &[(300.0, 150.0), (500.0, 150.0)], false); + select(&mut s, &[line]); + s.execute("path.rectCut", &json!({"rect": [402, 100, 4, 100]})).unwrap(); + assert!(near(bounds(&s, line), Rect::new(402.0, 150.0, 406.0, 150.0))); +} + #[test] fn mirror_cut_makes_a_symmetric_shape() { let mut s = session(); diff --git a/crates/engine/src/tests_draw2.rs b/crates/engine/src/tests_draw2.rs index c6ee784c8..45dc9c074 100644 --- a/crates/engine/src/tests_draw2.rs +++ b/crates/engine/src/tests_draw2.rs @@ -725,6 +725,81 @@ fn eraser_removes_small_shape_entirely() { assert!(paths(&s).is_empty()); } +/// The Eraser's tip is an ellipse `size` across its long axis, at `angle` counter-clockwise from +/// horizontal, with a short axis `roundness` percent as long: a vertical drag across a square +/// removes a band as wide as the tip is across, and a press on a horizontal line removes the +/// tip's horizontal chord through its center. +#[test] +fn the_eraser_tip_takes_its_angle_and_roundness() { + // The area of a 100 pt square left after a vertical drag through its middle, and the length + // of a 200 pt line left after a press on its middle, with a 20 pt tip. + let erase = |tip: serde_json::Value| { + let mut s = session(); + rect(&mut s, 100.0, 100.0, 100.0, 100.0); + line(&mut s, 300.0, 150.0, 500.0, 150.0); + let mut stroke = json!({"points": [[150, 50], [150, 250]], "size": 20}); + let mut press = json!({"points": [[400, 150]], "size": 20}); + for p in [&mut stroke, &mut press] { + if let (Some(p), Some(tip)) = (p.as_object_mut(), tip.as_object()) { + p.extend(tip.clone()); + } + } + // With nothing selected each time, so the Eraser erases every path it touches. + s.execute("select.none", &json!({})).unwrap(); + s.execute("path.eraseRegion", &stroke).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + s.execute("path.eraseRegion", &press).unwrap(); + let ps = paths(&s); + let left: f64 = ps.iter().map(|p| &p.1).filter(|p| p.is_closed()).map(area).sum(); + let len: f64 = ps.iter().map(|p| &p.1).filter(|p| !p.is_closed()).map(|p| p.length()).sum(); + (left, len) + }; + // `band`: how wide the drag cuts; `chord`: how much of the line the press removes. + let close = |(left, len): (f64, f64), band: f64, chord: f64| { + assert!((left - (10000.0 - 100.0 * band)).abs() < 2.0 * band + 2.0, "{band} pt band: {left} left of the square"); + assert!((len - (200.0 - chord)).abs() < 0.03 * chord + 0.02, "{chord} pt chord: {len} left of the line"); + }; + // Round by default: 20 pt either way. + close(erase(json!({})), 20.0, 20.0); + // The long axis horizontal: 20 pt either way. + close(erase(json!({"angle": 0, "roundness": 25})), 20.0, 20.0); + // The long axis upright: 5 pt either way. + close(erase(json!({"angle": 90, "roundness": 25})), 5.0, 5.0); + // At 45°, half axes 10 and 2.5: the band is 2·√((10² + 2.5²) / 2), about 14.58 pt, and the + // chord 2 / √(0.5 / 10² + 0.5 / 2.5²), about 6.86 pt. + close(erase(json!({"angle": 45, "roundness": 25})), 2.0 * ((100.0f64 + 6.25) / 2.0).sqrt(), 2.0 / (0.5f64 / 100.0 + 0.5 / 6.25).sqrt()); + // A roundness past 100 is kept to 100, and one below 1 to 1. + close(erase(json!({"angle": 90, "roundness": 400})), 20.0, 20.0); + close(erase(json!({"angle": 90, "roundness": 0})), 0.2, 0.2); +} + +#[test] +fn a_narrow_eraser_finds_a_long_line_between_its_samples() { + // A 200 pt line from x = 300 used to be tested every 200 / 24 pt (at 400 and 408.33 near + // here), so a tip or a Path Eraser band that fits between two of those points missed it. + let left_after = |command: &str, p: serde_json::Value| { + let mut s = session(); + line(&mut s, 300.0, 150.0, 500.0, 150.0); + s.execute(command, &p).unwrap(); + paths(&s).iter().map(|p| p.1.length()).sum::() + }; + // A 20 pt tip upright at 25%: 5 pt across the line, from 401.5 to 406.5. + let len = left_after("path.eraseRegion", json!({"points": [[404, 150]], "size": 20, "angle": 90, "roundness": 25})); + assert!((len - 195.0).abs() < 0.2, "the narrow tip left {len} pt of the line"); + // An 8 pt Path Eraser band across the line, from 400.2 to 408.2. + let len = left_after("path.eraseSegments", json!({"points": [[404.2, 100], [404.2, 200]], "width": 8})); + assert!((len - 192.0).abs() < 0.2, "the Path Eraser left {len} pt of the line"); + // The same line drawn as a curve whose handles both sit on its start moves slowly there and + // fast near its end, three times as fast as its length suggests, so 80 even steps in t put + // samples at 492.59 and 500 and a 5 pt tip at 496.3 sits between them. + let mut s = session(); + let anchors = json!([{"x": 300, "y": 150}, {"x": 500, "y": 150, "in": [300, 150]}]); + s.execute("path.create", &json!({ "anchors": anchors })).unwrap(); + s.execute("path.eraseRegion", &json!({"points": [[496.3, 150]], "size": 5})).unwrap(); + let len = paths(&s).iter().map(|p| p.1.length()).sum::(); + assert!((len - 195.0).abs() < 0.2, "the tip left {len} pt of the curve"); +} + #[test] fn path_eraser_splits_selected_path() { let mut s = session(); @@ -998,8 +1073,8 @@ fn drawing_commands_refuse_a_point_or_pressure_given_as_text() { s.execute("path.freehand", &pressed).unwrap(); } -/// The Eraser, Path Eraser, Smooth, Join and Blob Brush commands refuse a size, width, radius, -/// fidelity or tolerance given as text or as another value that is not a number, and change +/// The Eraser, Path Eraser, Smooth, Join and Blob Brush commands refuse a size, angle, roundness, +/// width, radius, fidelity or tolerance given as text or as another value that is not a number, and change /// nothing; null is read as the key left out. #[test] fn drawing_commands_refuse_a_size_given_as_text() { @@ -1009,6 +1084,8 @@ fn drawing_commands_refuse_a_size_given_as_text() { let stroke = json!({"points": [[90, 150], [150, 150], [210, 150]]}); for (cmd, key) in [ ("path.eraseRegion", "size"), + ("path.eraseRegion", "angle"), + ("path.eraseRegion", "roundness"), ("path.eraseSegments", "width"), ("path.smoothRegion", "radius"), ("path.smoothRegion", "fidelity"), diff --git a/crates/tools/src/draw2.rs b/crates/tools/src/draw2.rs index 8d84eaf4d..65b81f53e 100644 --- a/crates/tools/src/draw2.rs +++ b/crates/tools/src/draw2.rs @@ -10,7 +10,9 @@ mod gesture; use serde_json::{Value, json}; use vectorcraft_doc::{NodeId, NodeKind}; -use vectorcraft_geom::{Anchor, AnchorKind, BezPath, Point, SubPath, Vec2}; +use vectorcraft_geom::{Affine, Anchor, AnchorKind, BezPath, PathData, Point, SubPath, Vec2}; +use vectorcraft_pathops as po; +use vectorcraft_pathops::{Cap, Join}; use crate::{Action, Tool, ToolContext}; @@ -204,6 +206,31 @@ pub fn polyline(pts: &[Point]) -> BezPath { bp } +/// The map that takes a circle about the origin to the Eraser's tip of the same size: an ellipse +/// whose long axis runs at `angle` degrees counter-clockwise on the page (y-down document space) +/// and whose short axis is `roundness` percent of the long one (kept within 1–100). +pub fn eraser_tip(angle: f64, roundness: f64) -> Affine { + let t = angle.to_radians(); + let u = Vec2::new(t.cos(), -t.sin()); + let v = Vec2::new(-u.y, u.x); + let r = roundness.clamp(1.0, 100.0) / 100.0; + let (xx, xy, yy) = (u.x * u.x + r * v.x * v.x, u.x * u.y + r * v.x * v.y, u.y * u.y + r * v.y * v.y); + Affine::new([xx, xy, xy, yy, 0.0, 0.0]) +} + +/// The area the Eraser's tip (`size` across its long axis, [`eraser_tip`]'s `angle` and +/// `roundness`) covers along `pts`: a round stroke along the points squashed by the tip's map, +/// stretched back. +pub fn eraser_area(pts: &[Point], size: f64, angle: f64, roundness: f64) -> PathData { + let stretch = eraser_tip(angle, roundness); + let squash = stretch.inverse(); + let mut round: Vec = pts.iter().map(|p| squash * *p).collect(); + if let [p] = round[..] { + round.push(p + Vec2::new(0.01, 0.0)); + } + po::outline_stroke(&PathData::single(SubPath::polyline(&round, false)), size, Cap::Round, Join::Round, 4.0).transformed(stretch) +} + /// Visit `n` and everything inside it, depth first in paint order, but a Shaper Group's original /// shapes (its first child), which are not shown. pub(crate) fn walk_shown<'a>(n: &'a vectorcraft_doc::Node, f: &mut impl FnMut(&'a vectorcraft_doc::Node)) { diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs index c5ab06c8e..6df1cf99e 100644 --- a/crates/tools/src/draw2/gesture.rs +++ b/crates/tools/src/draw2/gesture.rs @@ -18,7 +18,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::NodeId; use vectorcraft_geom::Point; -use super::{FEEDBACK, points_json, polyline}; +use super::{FEEDBACK, eraser_area, points_json, polyline}; use crate::guides::DrawSnap; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; @@ -41,8 +41,12 @@ pub struct GestureTool { redraw: Option<(NodeId, f64)>, /// Fit tolerance in points (Pencil/Paintbrush/Smooth "Fidelity"). pub fidelity: f64, - /// Brush / eraser diameter in points. + /// Brush / eraser diameter in points (the Eraser's across its tip's long axis). pub size: f64, + /// The angle of the Eraser tip's long axis, in degrees counter-clockwise from horizontal. + pub angle: f64, + /// The Eraser tip's short axis in percent of its long one (100: round). + pub roundness: f64, /// Fill new pencil/brush strokes. pub fill: bool, /// "Keep selected": select the path a Pencil, Paintbrush or Blob Brush stroke draws, continues @@ -106,6 +110,8 @@ impl GestureTool { redraw: None, fidelity, size: 10.0, + angle: 0.0, + roundness: 100.0, fill: false, keep_selected: id != "blobBrush", // Off, as Pencil Tool Options ships it: Alt keeps drawing a closed path until it is on. @@ -321,7 +327,10 @@ impl GestureTool { "mergeOnlySelection": self.merge_only_selection }), )], - "eraser" => vec![Action::Exec("path.eraseRegion".into(), json!({"points": points_json(pts), "size": self.size}))], + "eraser" => vec![Action::Exec( + "path.eraseRegion".into(), + json!({"points": points_json(pts), "size": self.size, "angle": self.angle, "roundness": self.roundness}), + )], "knife" if pts.len() >= 2 => vec![Action::Exec("path.knife".into(), json!({"points": points_json(pts)}))], "smooth" if has_sel && pts.len() >= 2 => { vec![Action::Exec("path.smoothRegion".into(), json!({"points": points_json(pts), "radius": tol, "fidelity": self.fidelity}))] @@ -526,6 +535,12 @@ impl Tool for GestureTool { _ => 1.0, }; let color = if self.id == "eraser" { [0x80, 0x80, 0x80] } else { FEEDBACK }; + // A tip that is not round shows the outline of the area it covers. + if self.id == "eraser" && self.roundness < 100.0 { + let area = eraser_area(&self.points, self.size, self.angle, self.roundness); + o.push(Overlay::Path { path: area.to_bezpath(), color, width: 1.0, dashed: false }); + return o; + } o.push(Overlay::Path { path: polyline(&self.points), color, width, dashed: self.id == "knife" }); o } @@ -586,7 +601,7 @@ impl Tool for GestureTool { } "smooth" => json!({"fidelity": self.fidelity}), "blobBrush" => json!({"size": self.size, "keepSelected": self.keep_selected, "mergeOnlySelection": self.merge_only_selection}), - "eraser" => json!({"size": self.size}), + "eraser" => json!({"size": self.size, "angle": self.angle, "roundness": self.roundness}), _ => Value::Null, } } @@ -594,6 +609,8 @@ impl Tool for GestureTool { match key { "fidelity" => self.fidelity = v.as_f64().unwrap_or(self.fidelity).clamp(0.05, 100.0), "size" => self.size = v.as_f64().unwrap_or(self.size).clamp(0.1, 1000.0), + "angle" => self.angle = v.as_f64().unwrap_or(self.angle).clamp(-180.0, 180.0), + "roundness" => self.roundness = v.as_f64().unwrap_or(self.roundness).clamp(1.0, 100.0), "fill" => self.fill = v.as_bool().unwrap_or(self.fill), "altSmooth" => self.alt_smooth = v.as_bool().unwrap_or(self.alt_smooth), "liveFit" if self.id == "pencil" => self.live_fit = v.as_bool().unwrap_or(self.live_fit), @@ -817,8 +834,8 @@ mod tests { "{acts:?}" ); } - // The Eraser keeps Size only. - assert_eq!(GestureTool::new("eraser").options(), json!({"size": 10.0})); + // The Eraser keeps Size, Angle and Roundness: a round tip by default. + assert_eq!(GestureTool::new("eraser").options(), json!({"size": 10.0, "angle": 0.0, "roundness": 100.0})); } /// Shift keeps a Blob Brush stroke on the horizontal or vertical line through the press point, @@ -1380,6 +1397,47 @@ mod tests { assert!(matches!(&a[0], Action::Exec(c, v) if c == "path.knife" && v["points"].as_array().unwrap().len() == 3)); } + /// The Eraser's Angle and Roundness (Eraser Tool Options) go with its stroke, kept within + /// −180–180° and 1–100%; while it drags, a tip that is not round shows the outline of the area + /// it covers, and a round one the drag as wide as the tip. + #[test] + fn the_eraser_sends_its_tip_and_shows_the_area_it_covers() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = GestureTool::new("eraser"); + t.set_option("size", &json!(20)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0)); + let round = t.overlays(&cx); + assert!(matches!(&round[..], [Overlay::Path { width, .. }] if *width == 20.0), "{round:?}"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0)); + for (angle, roundness, sent) in [(90.0, 25.0, (90.0, 25.0)), (400.0, 0.0, (180.0, 1.0)), (-400.0, 250.0, (-180.0, 100.0))] { + t.set_option("angle", &json!(angle)); + t.set_option("roundness", &json!(roundness)); + assert_eq!((t.options()["angle"].as_f64(), t.options()["roundness"].as_f64()), (Some(sent.0), Some(sent.1))); + let acts = drag(&mut t, &cx, &[(50.0, 150.0), (250.0, 150.0)], Mods::default()); + assert!( + matches!(&acts[2][0], Action::Exec(c, v) if c == "path.eraseRegion" && v["size"] == 20.0 && v["angle"] == sent.0 && v["roundness"] == sent.1), + "{acts:?}" + ); + } + // A tip 20 pt long and 5 pt wide, standing upright: along a horizontal drag from x 50 to + // x 150 the outline reaches 2.5 pt either side of the drag and 10 pt above and below + // its ends. + t.set_option("angle", &json!(90)); + t.set_option("roundness", &json!(25)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0)); + let o = t.overlays(&cx); + let [Overlay::Path { path, width, .. }] = &o[..] else { panic!("{o:?}") }; + assert_eq!(*width, 1.0); + let b = vectorcraft_geom::Shape::bounding_box(path); + assert!((b.x0 - 47.5).abs() < 0.1 && (b.x1 - 152.5).abs() < 0.1, "{b:?}"); + assert!((b.y0 - 140.0).abs() < 0.1 && (b.y1 - 160.0).abs() < 0.1, "{b:?}"); + } + #[test] fn smooth_needs_selection_and_brackets_resize() { let (d, _) = doc_with_rect(); diff --git a/crates/tools/src/settings.rs b/crates/tools/src/settings.rs index ffde5c089..633380904 100644 --- a/crates/tools/src/settings.rs +++ b/crates/tools/src/settings.rs @@ -41,6 +41,8 @@ const ROWS: &[Row] = &[ (&["smooth"], None, &["fidelity"]), (&["blobBrush", "eraser"], None, &["size"]), (&["blobBrush"], None, &["keepSelected", "mergeOnlySelection"]), + // Eraser Tool Options' tip shape. + (&["eraser"], None, &["angle", "roundness"]), // Live Paint Selection Tool Options (`builder::LivePaintSelectionTool`). (&["livePaintSelection"], None, &["selectFills", "selectStrokes", "color", "width"]), (&["polygon"], None, &["sides"]), diff --git a/crates/ui-egui/src/dialogs/freehand.rs b/crates/ui-egui/src/dialogs/freehand.rs index aa747a111..43709b5fc 100644 --- a/crates/ui-egui/src/dialogs/freehand.rs +++ b/crates/ui-egui/src/dialogs/freehand.rs @@ -5,8 +5,8 @@ //! //! Fields: `tool`; `fidelity` (points; Pencil, Paintbrush and Smooth), `fill` (Pencil and //! Paintbrush), `keepSelected` (Pencil, Paintbrush and Blob Brush), `mergeOnlySelection` (Blob -//! Brush), `closeWithin` and `editWithin` (screen pixels, 0 turns it off; Pencil and Paintbrush) and -//! `size` (points; Blob Brush and Eraser). +//! Brush), `closeWithin` and `editWithin` (screen pixels, 0 turns it off; Pencil and Paintbrush), +//! `angle` (degrees) and `roundness` (percent; Eraser) and `size` (points; Blob Brush and Eraser). use serde_json::{Map, Value, json}; @@ -25,7 +25,8 @@ pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_wid pub const TOOLS: [&str; 5] = ["pencil", "paintbrush", "smooth", "blobBrush", "eraser"]; /// The options the dialog edits, in the order it shows them (a tool has those it reports). -const KEYS: [&str; 9] = ["fidelity", "size", "fill", "altSmooth", "keepSelected", "mergeOnlySelection", "closeWithin", "editWithin", "liveFit"]; +const KEYS: [&str; 11] = + ["fidelity", "angle", "roundness", "size", "fill", "altSmooth", "keepSelected", "mergeOnlySelection", "closeWithin", "editWithin", "liveFit"]; /// Width of the label column: wide enough for the tolerances' labels when there are any. fn label_w(d: &Dialog) -> f32 { @@ -96,6 +97,12 @@ fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { fidelity(ui, d); ui.add_space(10.0); } + if d.fields.contains_key("angle") { + number(ui, d, ("angle", tl!("Angle:")), "°", 0, (-180.0, 180.0)); + } + if d.fields.contains_key("roundness") { + number(ui, d, ("roundness", tl!("Roundness:")), "%", 0, (1.0, 100.0)); + } if d.fields.contains_key("size") { number(ui, d, ("size", tl!("Size:")), "pt", 1, (0.1, 1000.0)); } @@ -208,6 +215,8 @@ mod tests { // The Blob Brush also has Keep selected and Merge only with selection. let blob = tool == "blobBrush"; assert_eq!((text.contains("Keep selected"), text.contains("Merge only with selection")), (blob, blob), "{tool}: {text}"); + // The Eraser also has Angle and Roundness. + assert_eq!((text.contains("Angle:"), text.contains("Roundness:")), (!blob, !blob), "{tool}: {text}"); app.ui.dialog.as_mut().unwrap().fields.insert("size".into(), json!(24)); super::super::confirm(&mut app).unwrap(); assert_eq!(app.session.tool_options_of(tool)["size"].as_f64(), Some(24.0), "{tool}"); @@ -366,6 +375,52 @@ mod tests { assert_eq!(ids(&app), vec![a, b], "merged into b only"); } + /// Eraser Tool Options' Angle and Roundness shape the tip the next Eraser drag erases with, + /// and the tool keeps them while another tool is chosen. + #[test] + fn the_eraser_options_set_the_tips_angle_and_roundness() { + let mut app = app(); + // The width of the gap a vertical Eraser drag cuts through the middle of a 100 pt square. + let erase = |app: &mut VectorcraftApp| { + app.run("select.set", json!({"ids": []})).unwrap(); + let r = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap(); + let id = r["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.select_tool("eraser"); + let view = app.view_info(); + let pointer = |kind, y| vectorcraft_tools::PointerEvent::new(kind, 150.0, y); + crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Down, 50.0), view); + for y in [100.0, 150.0, 200.0] { + crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Drag, y), view); + } + crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Up, 250.0), view); + let st = app.session.doc().unwrap(); + let bounds: Vec<_> = st.selection.objects.iter().filter_map(|id| st.doc.node(*id)?.path_data()?.control_bounds()).collect(); + let [a, b] = bounds[..] else { panic!("two halves: {bounds:?}") }; + let (l, r) = if a.x0 < b.x0 { (a, b) } else { (b, a) }; + r.x0 - l.x1 + }; + app.run("tool.options", json!({"tool": "eraser"})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + // A round tip by default. + assert_eq!((d.f64("angle", -1.0), d.f64("roundness", -1.0)), (0.0, 100.0)); + d.fields.insert("size".into(), json!(20)); + super::super::confirm(&mut app).unwrap(); + let round = erase(&mut app); + assert!((round - 20.0).abs() < 0.2, "a 20 pt round tip cuts {round} pt"); + // Upright and a quarter as wide: the band it erases is 5 pt wide. + app.run("tool.options", json!({"tool": "eraser"})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("angle".into(), json!(90)); + d.fields.insert("roundness".into(), json!(25)); + super::super::confirm(&mut app).unwrap(); + app.select_tool("selection"); + let o = app.session.tool_options_of("eraser"); + assert_eq!((o["angle"].as_f64(), o["roundness"].as_f64(), o["size"].as_f64()), (Some(90.0), Some(25.0), Some(20.0))); + let narrow = erase(&mut app); + assert!((narrow - 5.0).abs() < 0.2, "a 20 by 5 pt upright tip cuts {narrow} pt"); + } + #[test] fn the_other_tools_have_no_freehand_options() { let mut app = app(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index aa2269e00..3b9c27c1c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -621,7 +621,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "tool.options", "Tool Options…", "", - "{tool: id} what double-clicking a tool button opens: hand → fits the artboard in the window (view.fitArtboard), zoom → 100% (view.actualSize), rotate|scale|reflect|shear → that Object › Transform dialog (dialog `rotate`, `scale`, `reflect` or `shear`; error `nothing selected` without a selection), selection|directSelection|groupSelection → the Move dialog (dialog `move`; the same error), gradient|magicWand → the Gradient or Magic Wand panel (window.panel), artboard → Artboard Options of the active artboard (dialog `artboardOptions`, fields index, name, x, y, width, height, showCenterMark, showCrossHairs; OK runs artboard.setProps, and with other artboards selected with the Artboard tool a new width or height goes to each of them too, in its `artboards` list, each resized about the reference point the form used for that size, or about its top-left corner when the size was set in the fields directly), flare → Flare Tool Options (dialog `flareOptions`, fields diameter and pathLength (pt), opacity, brightness, growth, fuzziness, longest, rayFuzziness, largest (%), rays, rings, direction (°), raysOn, ringsOn; OK runs tool.setOption {tool: \"flare\", values}: the next flare dragged out uses them, and a Flare click's dialog, which also has x and y, draws one there with them), lineSegment|arc|rectangularGrid|polarGrid → that tool's Tool Options, its click dialog without x and y (dialog kind = the tool id, `__toolOptions: true`; lineSegment: length (pt), angle (°), fillLine; arc: lengthX, lengthY (pt), reference (0–8), arcType: open|closed, baseAlong: x|y, slope (−100…100), fillArc; rectangularGrid: width, height (pt, the Default Size), reference (0–8), rows, columns, rowSkew, columnSkew (%), frame, fill; polarGrid: the same with concentric, radial, concentricSkew, radialSkew and compound for rows, columns, the skews and frame; OK runs tool.setOption {tool, values} and draws nothing: the next drag uses them (Fill Line, the arc's type, base, slope and fill, the grids' dividers, skews and switches) and a click's dialog opens at them), columnGraph|stackedColumnGraph|barGraph|stackedBarGraph|lineGraph|areaGraph|scatterGraph|pieGraph|radarGraph → Graph Type for the selected graph (dialog `command` running graph.setType; an error without one), symbolSprayer|symbolShifter|symbolScruncher|symbolSizer|symbolSpinner|symbolStainer|symbolScreener|symbolStyler → Symbolism Tools Options (dialog `symbolismOptions`, fields tool, diameter (pt), intensity and density (1–10), shared by the eight tools; OK runs tool.setOption {tool, values}), blend → Blend Options, perspectiveGrid → Perspective Grid Options, eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values}), pencil|paintbrush|smooth|blobBrush|eraser → that tool's Tool Options (dialog `freehandOptions`, fields tool and the options the tool keeps: fidelity (pt), fill, keepSelected (on for pencil and paintbrush, off for blobBrush), mergeOnlySelection (off), closeWithin and editWithin (px, 0 is off), size (pt); OK runs tool.setOption {tool, values}), livePaintSelection → Live Paint Selection Tool Options (dialog `livePaintSelectionOptions`, fields selectFills and selectStrokes (both on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the highlight's width, 1–20 px; 4); OK runs tool.setOption {tool: \"livePaintSelection\", values}), shapeBuilder → Shape Builder Tool Options (dialog `shapeBuilderOptions`, fields gapDetection (off), gapLength (pt, 0–72; 3), openFilledAsClosed (on), clickStrokeSplitsPath (off), pickColorFrom: artwork|swatches (artwork), cursorSwatchPreview (off), selection: freeform|straightLine (freeform), highlightFill (on), highlightStroke (on), highlightColor (#rrggbb or a layer color's name; #ff4f4f); OK runs tool.setOption {tool: \"shapeBuilder\", values})", + "{tool: id} what double-clicking a tool button opens: hand → fits the artboard in the window (view.fitArtboard), zoom → 100% (view.actualSize), rotate|scale|reflect|shear → that Object › Transform dialog (dialog `rotate`, `scale`, `reflect` or `shear`; error `nothing selected` without a selection), selection|directSelection|groupSelection → the Move dialog (dialog `move`; the same error), gradient|magicWand → the Gradient or Magic Wand panel (window.panel), artboard → Artboard Options of the active artboard (dialog `artboardOptions`, fields index, name, x, y, width, height, showCenterMark, showCrossHairs; OK runs artboard.setProps, and with other artboards selected with the Artboard tool a new width or height goes to each of them too, in its `artboards` list, each resized about the reference point the form used for that size, or about its top-left corner when the size was set in the fields directly), flare → Flare Tool Options (dialog `flareOptions`, fields diameter and pathLength (pt), opacity, brightness, growth, fuzziness, longest, rayFuzziness, largest (%), rays, rings, direction (°), raysOn, ringsOn; OK runs tool.setOption {tool: \"flare\", values}: the next flare dragged out uses them, and a Flare click's dialog, which also has x and y, draws one there with them), lineSegment|arc|rectangularGrid|polarGrid → that tool's Tool Options, its click dialog without x and y (dialog kind = the tool id, `__toolOptions: true`; lineSegment: length (pt), angle (°), fillLine; arc: lengthX, lengthY (pt), reference (0–8), arcType: open|closed, baseAlong: x|y, slope (−100…100), fillArc; rectangularGrid: width, height (pt, the Default Size), reference (0–8), rows, columns, rowSkew, columnSkew (%), frame, fill; polarGrid: the same with concentric, radial, concentricSkew, radialSkew and compound for rows, columns, the skews and frame; OK runs tool.setOption {tool, values} and draws nothing: the next drag uses them (Fill Line, the arc's type, base, slope and fill, the grids' dividers, skews and switches) and a click's dialog opens at them), columnGraph|stackedColumnGraph|barGraph|stackedBarGraph|lineGraph|areaGraph|scatterGraph|pieGraph|radarGraph → Graph Type for the selected graph (dialog `command` running graph.setType; an error without one), symbolSprayer|symbolShifter|symbolScruncher|symbolSizer|symbolSpinner|symbolStainer|symbolScreener|symbolStyler → Symbolism Tools Options (dialog `symbolismOptions`, fields tool, diameter (pt), intensity and density (1–10), shared by the eight tools; OK runs tool.setOption {tool, values}), blend → Blend Options, perspectiveGrid → Perspective Grid Options, eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values}), pencil|paintbrush|smooth|blobBrush|eraser → that tool's Tool Options (dialog `freehandOptions`, fields tool and the options the tool keeps: fidelity (pt), fill, keepSelected (on for pencil and paintbrush, off for blobBrush), mergeOnlySelection (off), closeWithin and editWithin (px, 0 is off), size (pt), angle (°, −180–180; 0) and roundness (%, 1–100; 100) of the eraser's tip; OK runs tool.setOption {tool, values}), livePaintSelection → Live Paint Selection Tool Options (dialog `livePaintSelectionOptions`, fields selectFills and selectStrokes (both on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the highlight's width, 1–20 px; 4); OK runs tool.setOption {tool: \"livePaintSelection\", values}), shapeBuilder → Shape Builder Tool Options (dialog `shapeBuilderOptions`, fields gapDetection (off), gapLength (pt, 0–72; 3), openFilledAsClosed (on), clickStrokeSplitsPath (off), pickColorFrom: artwork|swatches (artwork), cursorSwatchPreview (off), selection: freeform|straightLine (freeform), highlightFill (on), highlightStroke (on), highlightColor (#rrggbb or a layer color's name; #ff4f4f); OK runs tool.setOption {tool: \"shapeBuilder\", values})", ), ( "ui.colorGuideOptions", diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 2851ed36f..8f93f0997 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -786,7 +786,8 @@ keeps: `fidelity` (pt; Pencil, Paintbrush, Smooth), `fill` (Pencil, Paintbrush), Blob Brush; on by default for the Pencil and Paintbrush and off for the Blob Brush, and while it is off the path a stroke draws, continues or merges into is not selected afterwards), `mergeOnlySelection` (Blob Brush; off by default, and while it is on a stroke merges only with selected blobs), `closeWithin` and `editWithin` (screen pixels, 0 turns -it off; Pencil, Paintbrush) and `size` (pt; Blob Brush, Eraser). `ui.dialog.confirm` runs +it off; Pencil, Paintbrush), `size` (pt; Blob Brush, Eraser), and `angle` (°, −180 to 180; 0) and `roundness` +(%, 1 to 100; 100) of the Eraser's tip (Eraser). `ui.dialog.confirm` runs `tool.setOption {tool, values}`. `ui.pointer` events also take `holdMs` (0..60000): the pointer then holds still that long, button down, before the diff --git a/docs/mcp.md b/docs/mcp.md index e2db0e79d..8fe109fce 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2389,7 +2389,8 @@ These commands refuse a string or another value that is not a number with `` `dy naming the key; the document does not change and no undo step is recorded: `t`, `dx` and `dy` in `path.reshapeSegment` and `object.blend.spine.reshapeSegment`; `x`, `y` and `t` in `path.curvatureEdit`; `x` and `y` in `path.convertAnchor`; `t` in `path.insertAnchor`; `fidelity` and the `within` of `redraw` in `path.freehand` (and in -`brush.freehand`, which runs it); `size` in `path.eraseRegion` and `path.blob`; `width` in `path.eraseSegments`; +`brush.freehand`, which runs it); `size`, `angle` and `roundness` in `path.eraseRegion`; `size` in `path.blob`; +`width` in `path.eraseSegments`; `radius` and `fidelity` in `path.smoothRegion`; `tolerance` in `path.joinScrub`; `dx` and `dy` in `path.moveAnchors`; and `dx`, `dy`, `tol` and `radius` in `path.reshape`. A null is read as a key left out. `path.convertAnchor` refuses an `x` without a `y`, or a `y` without an `x`, with "x and y must be given together". `path.freehand`, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 724337a23..71343af3f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -638,6 +638,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With Shift held at the press, the Pencil draws a straight line from the press point to the pointer at 45° steps from the Constrain Angle, as [Draw straight lines with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-straight-lines-with-the-pencil-tool.html) describes; pressed on an end of a selected open path, the line continues the path from that end (part of #3799). With Option/Alt held at the press and Alt key toggles to Smooth Tool off, the Pencil draws a straight line at any angle, whose ends snap to Smart Guides, and it stays open when Alt is still held at the release; Shift and Alt together keep the 45° steps (#3799). While Shift or Alt is held before the press, the Pencil shows a straight-line cursor (a crosshair with a short line), and over an end of a selected open path, where the line continues the path, a path-continuation cursor (the same line leaving a small square), as the page describes. - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812). - **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes). + Eraser Tool Options has Angle and Roundness besides Size, as [Erase paths using the Eraser tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/erase-paths-using-eraser-tool.html) describes: the Eraser's tip is an ellipse Size across, its long axis at Angle and its short axis Roundness percent of the long one, and a drag with a tip that is not round shows the outline of the area it erases (`path.eraseRegion` `angle` and `roundness`). - **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag). Align, Distribute, Distribute Spacing, Reflect and Shear return an error for any `horizontal`, `vertical`, `to`, `axis`, `bounds` or `spacing` value they don't take, listing the choices, and Align returns one when a call gives neither `horizontal` nor `vertical`; nothing moves and no undo step is recorded. Align and Distribute act on the anchor points selected with Direct Selection: the points of one path align to each other, not to the artboard, and Distribute spaces them (#1641, fixes #1636; aligning to a key anchor is still missing). Transform Again uses the Copy, Transform Patterns, Scale Strokes & Effects and Scale Corners choices of the transform it repeats; after a bounding-box handle drag on area type, it resizes the type area again and the type keeps its size (`object.transformAgain`). With anchors selected with Direct Selection, Move, Rotate, Scale, Reflect, Shear and Transform Again (the commands, their dialogs and the four tools' drags) transform only those anchors, their handles with them, about the centre of the box around them unless a point was clicked; a copy still copies the whole object (#1408; behaviour reference: [adjust path segments](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html); the default reference point and the copy are our choices). The Move, Rotate, Scale, Reflect and Shear dialogs have a Preview checkbox, off when they open: while it is on the canvas follows the values, Cancel rolls it back and OK is still one undo step; Up/Down step the Rotate and Shear angles and the Scale percentages by 1, Shift by 10 (#1412). The five dialogs and Transform Each have Transform Objects beside Transform Patterns: off, the art stays and only its pattern tiles transform (`objects` on the transform commands), and Transform Again repeats the choice (#1448; behaviour reference: [scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html); the checkboxes are always enabled and the Scale page's pair is applied to all of them, our choices). Reflect's axis is Horizontal, Vertical or an angle (#1460; [reflect objects along an axis](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/reflect-objects-along-an-axis.html)). The link that keeps Transform Each's two scales equal is not built.