diff --git a/crates/engine/src/cmd/draw2/commands.rs b/crates/engine/src/cmd/draw2/commands.rs index ffa11e362c..49e277e5a0 100644 --- a/crates/engine/src/cmd/draw2/commands.rs +++ b/crates/engine/src/cmd/draw2/commands.rs @@ -92,7 +92,7 @@ pub fn specs() -> Vec { "Eraser", [], None, - "{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1), size?: pt (10), sizeMode?: \"fixed\" (default) | \"pressure\" (Eraser Tool Options' Size set to Pressure: the tip is size − sizeVariation across where the pen presses lightest and size + sizeVariation where it presses hardest, as path.blob), sizeVariation?: pt (0), angle?: ° (0: the tip's long axis counter-clockwise from level), 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", + "{points: [[x,y]…] or [[x,y,pressure]…] or [[x,y,pressure,tiltX,tiltY,rotation]…] (pen pressure 0..1, tilt −90..90°, barrel rotation ° clockwise, as path.freehand), size?: pt (10), angle?: ° (0: the tip's long axis counter-clockwise from level), roundness?: % (100, kept within 1–100), angleMode?, roundnessMode?, sizeMode?: \"fixed\" (default) | \"random\" | \"pressure\" | \"tilt\" | \"bearing\" | \"rotation\" (Eraser Tool Options' variation menus; a varied angle turns either way, clockwise or counter-clockwise), angleVariation?: ° (0–180, default 0, either way: clockwise or counter-clockwise), roundnessVariation?: % (0–100, default 0), sizeVariation?: pt (0 or more, default 0)} 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. A value whose mode is not fixed varies by up to its variation either way: random draws one value for the drag; pressure goes from the value less the variation where the pen presses lightest to the value plus it where it presses hardest (size on pressure as path.blob); tilt from less with the pen lying flat to more with it upright; bearing and rotation from less at −180° to more at 180° of the pen's lean direction or barrel turn. A drag without that pen reading (a mouse) erases at the value itself. An unknown mode, or a variation below 0 or past its range, is an error and erases nothing", has_doc, erase_region ), diff --git a/crates/engine/src/cmd/draw2/cut.rs b/crates/engine/src/cmd/draw2/cut.rs index 645c184b14..64b8bdee16 100644 --- a/crates/engine/src/cmd/draw2/cut.rs +++ b/crates/engine/src/cmd/draw2/cut.rs @@ -1,11 +1,14 @@ use super::common::{ - dist_to_polyline, fill_targets, ids_json, path_mut, path_of, points_param, poly_bounds, pressure_region, replace_with_pieces, samples_param, - sibling_with, size_pressure, target_paths, usize_opt, + PenRecord, Sample, dist_to_polyline, fill_targets, ids_json, path_mut, path_of, points_param, poly_bounds, pressure_region, replace_with_pieces, + samples_param, sibling_with, size_pressure, target_paths, usize_opt, }; use super::{ - Anchor, AnchorKind, Beside, BezPath, BoolOp, Cap, EngineError, FillRule, Join, NodeId, ParamCurve, PathData, Point, Result, Session, SubPath, - Value, bad, eraser_area, eraser_tip, f64_or, fill_contains, json, number_or, object_param, po, + Anchor, AnchorKind, Beside, BezPath, BoolOp, Cap, EngineError, FillRule, Join, NodeId, Paint, ParamCurve, PathData, Point, Result, Session, + SubPath, Value, Vec2, bad, eraser_area, eraser_tip, f64_or, fill_contains, json, number_or, object_param, po, }; +use vectorcraft_brush::{Brush, BrushKind, Calligraphic, Variation}; +use vectorcraft_doc::StrokeLayer; +use vectorcraft_tools::draw2::MIN_BRUSH_SIZE; /// Split a filled path into connected pieces (each outer contour with its holes). pub(in super::super) fn components(pd: &PathData) -> Vec { @@ -278,7 +281,11 @@ pub(super) fn erase_region(s: &mut Session, p: &Value) -> Result { } let angle = number_or(p, "angle", 0.0, C)?; let roundness = number_or(p, "roundness", 100.0, C)?; - let pressure = size_pressure(p, &samples, C)?; + let vary = eraser_variations(p, C)?; + if varies_beyond_size_pressure(&vary) { + return erase_varied(s, &samples, [angle, roundness, size], vary); + } + let pressure = if vary[2].0 == Variation::Pressure { size_pressure(p, &samples, C)? } else { None }; let pts: Vec = samples.into_iter().map(|s| s.pt).collect(); let r = size / 2.0; // The tip's short axis (its narrowest with Pressure): an open path is sampled at least every half of it. @@ -318,6 +325,101 @@ pub(super) fn erase_region(s: &mut Session, p: &Value) -> Result { apply_plan(s, "Eraser", plan) } +/// The variation modes `path.eraseRegion` takes for each of `angleMode`, `roundnessMode` and +/// `sizeMode`. +const ERASER_MODES: &str = "\"fixed\", \"random\", \"pressure\", \"tilt\", \"bearing\" or \"rotation\""; + +/// Eraser Tool Options' Angle, Roundness and Size variations: for each of angle (`angleMode` with +/// `angleVariation` in degrees, at most 180), roundness (`roundnessMode` with `roundnessVariation` +/// in percent, at most 100) and size (`sizeMode` with `sizeVariation` in points), its mode and how +/// far it varies either way. A mode left out or null is Fixed, and a variation left out or null +/// is 0; any other mode, or a variation that is negative or past its most, is refused. +fn eraser_variations(p: &Value, cmd: &str) -> Result<[(Variation, f64); 3]> { + const KEYS: [(&str, &str, f64); 3] = + [("angleMode", "angleVariation", 180.0), ("roundnessMode", "roundnessVariation", 100.0), ("sizeMode", "sizeVariation", f64::INFINITY)]; + let mut out = [(Variation::Fixed, 0.0); 3]; + for (slot, (mode_key, var_key, most)) in out.iter_mut().zip(KEYS) { + let mode = match p.get(mode_key).filter(|v| !v.is_null()) { + None => Variation::Fixed, + Some(Value::String(id)) => Variation::parse(id).ok_or_else(|| bad(cmd, format!("{mode_key} is one of {ERASER_MODES}, not `{id}`")))?, + Some(other) => return Err(bad(cmd, format!("{mode_key} is one of {ERASER_MODES}, not {other}"))), + }; + let variation = number_or(p, var_key, 0.0, cmd)?; + if !(variation.is_finite() && variation >= 0.0) { + return Err(bad(cmd, format!("{var_key} must be 0 or more"))); + } + if variation > most { + return Err(bad(cmd, format!("{var_key} must be at most {most}"))); + } + *slot = (mode, variation); + } + Ok(out) +} + +/// Does any of the Eraser's Angle, Roundness and Size vary with something other than Size on +/// Pressure? A mode whose variation is 0 does not vary. +fn varies_beyond_size_pressure(vary: &[(Variation, f64); 3]) -> bool { + vary.iter().enumerate().any(|(i, (mode, v))| *v > 0.0 && *mode != Variation::Fixed && !(i == 2 && *mode == Variation::Pressure)) +} + +/// `path.eraseRegion` with its tip varying along the drag ([`eraser_variations`]): the area the +/// tip covers is swept as a Calligraphic brush nib of the same angle, roundness, size, modes and +/// variations, with what the pen recorded along the drag. Random draws one value for the drag +/// and the pen modes follow the pen at each point; a drag without that recording (a mouse) takes +/// the value itself. `tip`: the angle, roundness and size. +fn erase_varied(s: &mut Session, samples: &[Sample], tip: [f64; 3], vary: [(Variation, f64); 3]) -> Result { + let [angle, roundness, size] = tip; + let mut pts: Vec = samples.iter().map(|s| s.pt).collect(); + // A press without a drag is a dab: a sweep needs some length. + if let [only] = pts[..] { + pts.push(only + Vec2::new(0.01, 0.0)); + } + // A nib's size is kept within 1296 pt and the stroke's width scales it: a larger tip is swept + // at a fraction of its size on a stroke as many times wider. + let [(am, av), (rm, rv), (sm, sv)] = vary; + let sv = if sm == Variation::Fixed { 0.0 } else { sv }; + let scale = ((size + sv) / Calligraphic::RANGES[2].1).max(1.0); + let nib = Calligraphic { + angle, + roundness: roundness.clamp(1.0, 100.0), + size: size / scale, + variation: [av, rv, sv / scale], + modes: Some([am, rm, sm]), + }; + let brush = Brush { name: "Eraser".into(), kind: BrushKind::Calligraphic(nib) }; + let mut stroke = StrokeLayer::new(Paint::None, scale); + PenRecord::of_samples(samples).write(&mut stroke); + let spine = SubPath::polyline(&pts, false); + let st = s.doc()?; + let pieces = vectorcraft_brush::stroke_pieces(&st.doc, &brush, &PathData::single(spine).to_bezpath(), &stroke); + let areas: Vec = pieces.iter().filter_map(|n| n.path_data().cloned()).collect(); + if areas.is_empty() { + return Ok(json!({ "ids": [] })); + } + let region = + po::try_unite_all(&areas.iter().map(|a| (a, FillRule::NonZero)).collect::>()).map_err(|e| EngineError::Other(e.to_string()))?; + let bp = region.to_bezpath(); + // The narrowest the tip gets across: an open path is sampled at least every half of it. + let low = |i: usize| match vary[i] { + (Variation::Fixed, _) => tip[i], + (_, v) => tip[i] - v, + }; + let short = low(2).max(MIN_BRUSH_SIZE) * low(1).clamp(1.0, 100.0) / 100.0; + let reach = (size + sv) / 2.0; + let mut plan: Vec<(NodeId, Vec)> = vec![]; + for (id, pd, rule) in fill_targets(&st.doc, &st.selection, poly_bounds(&pts, reach))? { + if pd.is_closed() { + if po::area(&po::boolean(&pd, rule, ®ion, FillRule::NonZero, BoolOp::Intersect), FillRule::NonZero) <= 1e-6 { + continue; + } + plan.push((id, components(&po::boolean(&pd, rule, ®ion, FillRule::NonZero, BoolOp::Difference)))); + } else if let Some(pieces) = erase_outline(&pd, short / 2.0, &|q| fill_contains(&bp, FillRule::NonZero, q)) { + plan.push((id, pieces)); + } + } + apply_plan(s, "Eraser", plan) +} + fn apply_plan(s: &mut Session, label: &str, plan: Vec<(NodeId, Vec)>) -> Result { if plan.is_empty() { return Ok(json!({ "ids": [] })); diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index e5e6174af0..a0129c1cd1 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -612,6 +612,100 @@ fn eraser_size_follows_the_pen_pressure() { assert!(err.to_string().contains("sizeMode"), "{err}"); } +/// Eraser Tool Options' Angle, Roundness and Size variation menus: a value on Rotation, Bearing, +/// Tilt or Pressure takes what the pen read along the drag, Random stays within its Variation, a +/// drag without the reading erases at the value itself, an open path is cut where the varied tip +/// covers it, and an unknown mode or a variation out of its range is refused with nothing erased. +#[test] +fn the_eraser_tip_varies_with_its_modes() { + // Which of `probes` a level drag through the middle of a 400 by 200 rectangle erases, each of + // its points `[x, 100]` followed by `pen` ([pressure, tiltX, tiltY, rotation]). + let erased = |tip: serde_json::Value, pen: serde_json::Value, probes: &[(f64, f64)]| -> Vec { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 400.0, 200.0); + let points: Vec<_> = (0..=20) + .map(|i| { + let mut p = vec![json!(100.0 + f64::from(i) * 10.0), json!(100.0)]; + p.extend(pen.as_array().cloned().unwrap_or_default()); + serde_json::Value::Array(p) + }) + .collect(); + s.execute("path.eraseRegion", &with(&json!({"points": points, "size": 20}), tip.clone())).unwrap(); + let filled = |x: f64, y: f64| { + paths(&s).iter().any(|(_, pd)| vectorcraft_geom::hit::fill_contains(&pd.to_bezpath(), FillRule::NonZero, Point::new(x, y))) + }; + assert!(filled(200.0, 150.0), "{tip} {pen}: the rest of the rectangle stays"); + probes.iter().map(|&(x, y)| !filled(x, y)).collect() + }; + let none = json!(null); + // A 20 by 2 pt tip lying level erases a band 2 pt tall; standing upright it erases one 20 pt tall. + let level = json!({"roundness": 10}); + let probes = [(200.0, 100.5), (200.0, 108.0)]; + assert_eq!(erased(level.clone(), json!([]), &probes), [true, false], "Fixed"); + let on_rotation = with(&level, json!({"angleMode": "rotation", "angleVariation": 90})); + // The barrel turned half way round reads the most, which turns the tip upright. + assert_eq!(erased(on_rotation.clone(), json!([none, none, none, 180]), &probes), [true, true], "Rotation"); + assert_eq!(erased(on_rotation.clone(), json!([]), &probes), [true, false], "Rotation without a reading"); + // Leaning to the left (a bearing of 180°) reads the most too. + let on_bearing = with(&level, json!({"angleMode": "bearing", "angleVariation": 90})); + assert_eq!(erased(on_bearing.clone(), json!([none, -45, 0, none]), &probes), [true, true], "Bearing"); + assert_eq!(erased(on_bearing, json!([none, 45, 0, none]), &probes), [true, false], "Bearing at 0°"); + // A round tip: upright the pen gives 28 pt, nearly flat about 12 pt. + let on_tilt = json!({"sizeMode": "tilt", "sizeVariation": 8}); + let wide = [(200.0, 105.0), (200.0, 113.0)]; + assert_eq!(erased(on_tilt.clone(), json!([none, 0, 0, none]), &wide), [true, true], "Tilt upright"); + assert_eq!(erased(on_tilt, json!([none, 89, 0, none]), &wide), [true, false], "Tilt flat"); + // Roundness from 10 % less 90 (kept to 1 %) at the lightest pressure to 100 % at the heaviest. + let on_pressure = with(&level, json!({"roundnessMode": "pressure", "roundnessVariation": 90})); + assert_eq!(erased(on_pressure.clone(), json!([1.0]), &probes), [true, true], "Pressure at the heaviest"); + // At the lightest the tip is 0.2 pt across, narrower than the 2 pt band Fixed erases. + assert_eq!(erased(on_pressure, json!([0.0]), &[(200.0, 100.0), (200.0, 100.5)]), [true, false], "Pressure at the lightest"); + // Random: one size for the drag, from 16 to 24 pt. + let random = json!({"sizeMode": "random", "sizeVariation": 4}); + assert_eq!(erased(random, json!([]), &[(200.0, 107.5), (200.0, 112.5)]), [true, false], "Random"); + // A press on a line with the tip turned upright by its rotation cuts 5 pt of it, not 20. + let left_of_line = |pen: serde_json::Value| { + let mut s = session(); + line(&mut s, 300.0, 150.0, 500.0, 150.0); + let mut point = vec![json!(400), json!(150)]; + point.extend(pen.as_array().cloned().unwrap_or_default()); + let tip = json!({"points": [point], "size": 20, "roundness": 25, "angleMode": "rotation", "angleVariation": 90}); + s.execute("path.eraseRegion", &tip).unwrap(); + paths(&s).iter().map(|p| p.1.length()).sum::() + }; + let upright = left_of_line(json!([none, none, none, 180])); + assert!((upright - 195.0).abs() < 0.3, "the upright tip left {upright} pt of the line"); + let level_tip = left_of_line(json!([])); + assert!((level_tip - 180.0).abs() < 0.3, "the level tip left {level_tip} pt of the line"); + // A 1500 pt tip on Tilt erases 1500 pt across with a mouse, as it does on Fixed. + let mut s = session(); + rect(&mut s, 0.0, 0.0, 4000.0, 4000.0); + let big = json!({"points": [[1000, 2000], [3000, 2000]], "size": 1500, "sizeMode": "tilt", "sizeVariation": 10}); + s.execute("path.eraseRegion", &big).unwrap(); + let filled = + |x: f64, y: f64| paths(&s).iter().any(|(_, pd)| vectorcraft_geom::hit::fill_contains(&pd.to_bezpath(), FillRule::NonZero, Point::new(x, y))); + assert_eq!((filled(2000.0, 2740.0), filled(2000.0, 2760.0)), (false, true), "a 1500 pt tip"); + // A Size Variation kept while Size is Fixed leaves the size alone. + let kept = with(&on_rotation, json!({"sizeMode": "fixed", "sizeVariation": 1000000})); + assert_eq!(erased(kept, json!([]), &probes), [true, false], "Size on Fixed with a Variation"); + let mut s = session(); + let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let stroke = json!({"points": [[-10, 50], [110, 50]]}); + let modes = "\"fixed\", \"random\", \"pressure\", \"tilt\", \"bearing\" or \"rotation\""; + for (extra, want) in [ + (json!({"angleMode": "wheel"}), format!("angleMode is one of {modes}, not `wheel`")), + (json!({"roundnessMode": 3}), format!("roundnessMode is one of {modes}, not 3")), + (json!({"sizeMode": "stylusWheel"}), format!("sizeMode is one of {modes}, not `stylusWheel`")), + (json!({"angleVariation": 181}), "angleVariation must be at most 180".to_string()), + (json!({"roundnessVariation": 101}), "roundnessVariation must be at most 100".to_string()), + (json!({"roundnessVariation": -1}), "roundnessVariation must be 0 or more".to_string()), + ] { + refuses_with(&mut s, "path.eraseRegion", &with(&stroke, extra), &want); + } + refuses_a_number_given_as_text(&mut s, "path.eraseRegion", &stroke, "angleVariation"); +} + /// A Blob Brush stroke merges with the topmost matching path it touches and with the touched /// matching paths next to it in stacking order in the same group or layer; matching paths in other /// layers or groups, or below an object of another kind, are left alone. diff --git a/crates/tools/src/draw2.rs b/crates/tools/src/draw2.rs index d85ff4fffa..46e77314b6 100644 --- a/crates/tools/src/draw2.rs +++ b/crates/tools/src/draw2.rs @@ -14,7 +14,7 @@ use vectorcraft_geom::{Affine, Anchor, AnchorKind, BezPath, PathData, Point, Sub use vectorcraft_pathops as po; use vectorcraft_pathops::{Cap, Join}; -use crate::{Action, Tool, ToolContext}; +use crate::{Action, PenPose, Tool, ToolContext}; pub use anchor::AnchorTool; pub use curvature::{ @@ -87,6 +87,34 @@ pub fn pressure_size(size: f64, variation: f64, pressure: f64) -> f64 { /// The narrowest a Blob Brush or Eraser stroke gets, in points (their Size field's minimum). pub const MIN_BRUSH_SIZE: f64 = 0.1; +/// A pen's tilt as a lean on the page (each tilt's tangent) and its angle from upright in degrees, +/// as Pointer Events' tiltX and tiltY convert to an altitude and an azimuth. +fn pen_lean(tilt: [f32; 2]) -> ([f64; 2], f64) { + let [x, y] = tilt.map(|d| f64::from(d).to_radians().tan()); + ([x, y], x.hypot(y).atan().to_degrees()) +} + +/// Which way pose `q` leans, in degrees counter-clockwise on the page from leaning to the right, +/// when it leans 1° or more from upright (`path.eraseRegion` keeps the bearing before a pen +/// nearer upright than that). +pub(crate) fn pen_bearing(q: PenPose) -> Option { + let ([x, y], from_upright) = pen_lean(q.tilt?); + (from_upright >= 1.0).then(|| (-y).atan2(x).to_degrees()) +} + +/// The 0..1 that each of the Eraser's variation modes Pressure, Tilt, Bearing and Rotation reads +/// from a pen at `pressure` (0..1) held as `pose`, leaning toward `bearing` (degrees, from +/// [`pen_bearing`]): 0 gives the value less its variation and 1 the value plus it, as +/// `path.eraseRegion` reads a stroke's points. Tilt reads 1 upright and 0 lying flat; Bearing and +/// Rotation read ½ plus the angle over 360°, the rotation turned counter-clockwise. What the pen +/// does not give reads ½, the value itself. +pub(crate) fn pen_controls(pressure: f64, pose: Option, bearing: Option) -> [f64; 4] { + let tilt = pose.and_then(|q| q.tilt).map_or(0.5, |t| 1.0 - pen_lean(t).1 / 90.0); + let rotation = pose.and_then(|q| q.rotation).map_or(0.5, |r| 0.5 + vectorcraft_geom::normalize_deg(-f64::from(r)) / 360.0); + let bearing = bearing.map_or(0.5, |b| 0.5 + b / 360.0); + [pressure, tilt, bearing, rotation].map(|c| if c.is_finite() { c.clamp(0.0, 1.0) } else { 0.5 }) +} + /// Create a drawing tool by id (None = not one of ours). pub fn create(id: &str) -> Option> { Some(match id { diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs index 562654c3c4..75b71ec8df 100644 --- a/crates/tools/src/draw2/gesture.rs +++ b/crates/tools/src/draw2/gesture.rs @@ -12,7 +12,10 @@ //! pressed less than fully on sends `[x, y, pressure]` points) for pressure-sensitive brushes, and //! the pen's tilt and barrel rotation where the pen reads them (`[x, y, pressure, tiltX, tiltY, //! rotation]` points, `null` for what a point lacks) for brushes that vary with them. The Blob -//! Brush and Eraser also record pressure when their Size varies with Pressure. The other tools +//! Brush and Eraser also record pressure when their Size varies with Pressure, and the Eraser +//! records the pressure, tilt and rotation that its Angle, Roundness and Size variations follow; +//! while it erases, its tip outline at the pointer takes the angle, roundness and size the pen +//! gives there. The other tools //! collect the drag polyline (shown as an overlay) and run one command on release. With Shift //! held, the Blob Brush's points stay on the horizontal or //! vertical line through the press point. Alt held at the press, or before the Blob Brush's drag @@ -29,6 +32,7 @@ use std::cell::Cell; use std::collections::{BTreeMap, BTreeSet}; use serde_json::{Value, json}; +use vectorcraft_brush::{Calligraphic, Variation}; use vectorcraft_doc::{NodeId, NodeKind}; use vectorcraft_geom::{Affine, BezPath, FillRule, ParamCurve, PathData, PathSeg, Point, Rect, Vec2, shapes}; use vectorcraft_pathops as po; @@ -88,10 +92,11 @@ pub struct GestureTool { previewed: usize, id: &'static str, points: Vec, - /// The pen pressure of each of `points` (the Paintbrush, and the Blob Brush and the Eraser - /// with `size_pressure`). + /// The pen pressure of each of `points` (the Paintbrush, the Blob Brush with Size on Pressure, + /// and the Eraser with any of its variations on Pressure). pressures: Vec, - /// The pen's tilt and rotation at each of `points` (the Paintbrush only). + /// The pen's tilt and rotation at each of `points` (the Paintbrush, and the Eraser with any of + /// its variations on Tilt, Bearing or Rotation). poses: Vec>, active: bool, began: bool, @@ -117,16 +122,22 @@ pub struct GestureTool { pub fidelity: f64, /// Brush / eraser diameter in points (the Eraser's across its tip's long axis). pub size: f64, - /// Blob Brush and Eraser Tool Options' Size set to Pressure (else Fixed): the tip is `size` − - /// `size_variation` wide at the lightest pen pressure and `size` + `size_variation` at the - /// heaviest. - pub size_pressure: bool, + /// Blob Brush and Eraser Tool Options' Size mode: Fixed or Pressure for the Blob Brush (with + /// Pressure the tip is `size` − `size_variation` wide at the lightest pen pressure and `size` + + /// `size_variation` at the heaviest), and any [`Variation`] for the Eraser. + pub size_mode: Variation, /// Blob Brush and Eraser Tool Options' Size Variation, in points. pub size_variation: f64, /// The angle of the Blob Brush or Eraser tip's long axis, in degrees counter-clockwise from horizontal. pub angle: f64, /// The Blob Brush or Eraser tip's short axis in percent of its long one (100: round). pub roundness: f64, + /// Eraser Tool Options' Angle mode and its Variation in degrees (0 to 180). + pub angle_mode: Variation, + pub angle_variation: f64, + /// Eraser Tool Options' Roundness mode and its Variation in percent (0 to 100). + pub roundness_mode: Variation, + pub roundness_variation: f64, /// Fill new pencil/brush strokes. pub fill: bool, /// "Keep selected": select the path a Pencil, Paintbrush or Blob Brush stroke draws, continues @@ -176,6 +187,9 @@ pub struct GestureTool { tip_at: Option, /// A pen pressed less than fully at a point of the stroke whose pressure is kept. light: bool, + /// Which way the pen leaned at the last point of the stroke where it leaned 1° or more from + /// upright ([`super::pen_bearing`]), for the Eraser's tip outline. + bearing: Option, /// The length of the stroke, in points. length: f64, /// The outline of the area a Blob Brush or Eraser drag covers, as far as it is built @@ -829,10 +843,14 @@ impl GestureTool { redraw: None, fidelity, size: 10.0, - size_pressure: false, + size_mode: Variation::Fixed, size_variation: 0.0, angle: 0.0, roundness: 100.0, + angle_mode: Variation::Fixed, + angle_variation: 0.0, + roundness_mode: Variation::Fixed, + roundness_variation: 0.0, fill: false, keep_selected: id != "blobBrush", // Off, as Pencil Tool Options ships it: Alt keeps drawing a closed path until it is on. @@ -852,6 +870,7 @@ impl GestureTool { pressed: Point::ZERO, tip_at: None, light: false, + bearing: None, length: 0.0, area: Cell::new(None), area_pending: Cell::new(false), @@ -908,15 +927,40 @@ impl GestureTool { self.length += last.distance(p); } self.points.push(p); - if self.id == "paintbrush" || (self.varies_size() && self.size_pressure) { + let modes = self.tip_modes(); + // The poses go out beside the pressures (`stroke_json`), so a stroke that keeps one keeps both. + let posed = self.id == "paintbrush" || modes.iter().any(|m| matches!(m, Variation::Tilt | Variation::Bearing | Variation::Rotation)); + if posed || modes.contains(&Variation::Pressure) { self.pressures.push(ev.pressure); self.light |= ev.pressure < 1.0; } - if self.id == "paintbrush" { - self.poses.push(ev.pen.map(PenPose::sanitized)); + if posed { + let pose = ev.pen.map(PenPose::sanitized); + self.bearing = pose.and_then(super::pen_bearing).or(self.bearing); + self.poses.push(pose); } } + /// Give the Eraser's last stroke sample the pen's tilt and rotation of the event `ev`, a drag + /// within a pixel of that sample that adds none: a pen turned or tilted in place turns its tip + /// there, in the outline and in what the release erases. The sample keeps its pressure, which + /// the area outline of the drag was built with, and its pose when `ev` reads none. + fn refresh_last_pose(&mut self, ev: &PointerEvent) { + if self.id != "eraser" || self.poses.len() != self.points.len() || ev.pen.is_none() { + return; + } + let pose = ev.pen.map(PenPose::sanitized); + let Some(last) = self.poses.last_mut() else { return }; + let had_bearing = last.and_then(super::pen_bearing).is_some(); + *last = pose; + // The bearing is that of the last sample the pen leaned at, as `push` keeps it. + self.bearing = match pose.and_then(super::pen_bearing) { + Some(b) => Some(b), + None if had_bearing => self.poses.iter().rev().find_map(|q| q.and_then(super::pen_bearing)), + None => self.bearing, + }; + } + /// Would a press with `m` held draw a straight Pencil line? Shift or Alt held, unless Alt lends /// the Smooth tool: the rule `PointerKind::Down` sets `straight` by. fn straight_at_press(&self, m: Mods) -> bool { @@ -975,12 +1019,14 @@ impl GestureTool { } /// The Blob Brush's or the Eraser's tip at the pointer, before and while painting: its outline - /// `size` across at its angle and roundness, at the zoom, black on a white halo so it reads on + /// `size` across at its angle and roundness (the Eraser's as the pen varies them while it + /// erases, [`Self::tip_now`]), at the zoom, black on a white halo so it reads on /// any art. Precise cursors show only the crosshair, and smoothing shows no tip. fn tip_outline(&self, cx: &ToolContext) -> Vec { let Some(at) = self.tip_at.filter(|_| !self.smoothing && !cx.precise_cursors) else { return vec![] }; - let tip = if self.id == "blobBrush" { blob_tip(self.angle, self.roundness) } else { eraser_tip(self.angle, self.roundness) }; - let r = self.size / 2.0; + let [angle, roundness, size] = self.tip_now(); + let tip = if self.id == "blobBrush" { blob_tip(angle, roundness) } else { eraser_tip(angle, roundness) }; + let r = size / 2.0; // A tolerance relative to the radius: four arcs at any size or zoom. let circle = kurbo::Shape::to_path(&kurbo::Circle::new(Point::ORIGIN, r), 0.1 * r.max(1e-6)); let path = Affine::translate(at.to_vec2()) * tip * circle; @@ -999,6 +1045,7 @@ impl GestureTool { self.previewed = 0; self.shift_line = None; self.light = false; + self.bearing = None; self.length = 0.0; self.area.set(None); self.area_pending.set(false); @@ -1064,7 +1111,53 @@ impl GestureTool { /// keeps its Size). fn size_pressures(&self) -> Option<&[f32]> { let pressed = self.pressures.len() == self.points.len() && self.light; - (self.varies_size() && self.size_pressure && !self.smoothing && pressed).then_some(self.pressures.as_slice()) + (self.tip_modes()[2] == Variation::Pressure && !self.smoothing && pressed).then_some(self.pressures.as_slice()) + } + + /// How the tip's angle, roundness and size vary: the Eraser's three modes, the Blob Brush's + /// Size mode (its angle and roundness stay fixed), and Fixed for the other tools. A mode whose + /// Variation is 0 is Fixed. + fn tip_modes(&self) -> [Variation; 3] { + let fixed_at_zero = |mode: Variation, v: f64| if v > 0.0 { mode } else { Variation::Fixed }; + match self.id { + "eraser" => [ + fixed_at_zero(self.angle_mode, self.angle_variation), + fixed_at_zero(self.roundness_mode, self.roundness_variation), + fixed_at_zero(self.size_mode, self.size_variation), + ], + "blobBrush" => [Variation::Fixed, Variation::Fixed, fixed_at_zero(self.size_mode, self.size_variation)], + _ => [Variation::Fixed; 3], + } + } + + /// The Eraser's angle, roundness and size where its tip outline shows: while it erases, each + /// value on a mode that follows the pen goes from the value less its Variation to the value + /// plus it as the pen reads now (the pressure once the pen has pressed less than fully, the + /// tilt and rotation of the last point, and the bearing of the last point the pen leaned 1° or + /// more at), as `path.eraseRegion` reads the stroke; before a press, and for Random, Fixed and a + /// pen that gives no reading, the values themselves. The Blob Brush's are its own values. + fn tip_now(&self) -> [f64; 3] { + let base = [self.angle, self.roundness, self.size]; + if self.id != "eraser" || !self.active { + return base; + } + let pressure = if self.light { self.pressures.last().map_or(0.5, |p| f64::from(*p)) } else { 0.5 }; + let controls = super::pen_controls(pressure, self.poses.last().copied().flatten(), self.bearing); + let variations = [self.angle_variation, self.roundness_variation, self.size_variation]; + let mut out = base; + for (i, (v, mode)) in out.iter_mut().zip(self.tip_modes()).enumerate() { + let c = match mode { + Variation::Pressure => controls[0], + Variation::Tilt => controls[1], + Variation::Bearing => controls[2], + Variation::Rotation => controls[3], + Variation::Fixed | Variation::Random => continue, + }; + // The size goes past a nib's 1296 pt, as `path.eraseRegion` sweeps it. + *v = if i == 2 { super::pressure_size(*v, variations[i], c) } else { Calligraphic::moved(i, *v, variations[i] * (2.0 * c - 1.0)) }; + } + let [angle, roundness, size] = out; + [angle, roundness.clamp(1.0, 100.0), size.max(super::MIN_BRUSH_SIZE)] } /// The stroke's points: `[x, y, pressure, tiltX, tiltY, rotation]` when a pen read its tilt or @@ -1254,7 +1347,7 @@ impl GestureTool { json!({ "points": self.stroke_json(), "size": self.size, - "sizeMode": if self.size_pressure { "pressure" } else { "fixed" }, + "sizeMode": self.size_mode.id(), "sizeVariation": self.size_variation, "fidelity": self.fidelity, "angle": self.angle, @@ -1268,10 +1361,14 @@ impl GestureTool { json!({ "points": self.stroke_json(), "size": self.size, - "sizeMode": if self.size_pressure { "pressure" } else { "fixed" }, + "sizeMode": self.size_mode.id(), "sizeVariation": self.size_variation, "angle": self.angle, - "roundness": self.roundness + "angleMode": self.angle_mode.id(), + "angleVariation": self.angle_variation, + "roundness": self.roundness, + "roundnessMode": self.roundness_mode.id(), + "roundnessVariation": self.roundness_variation }), )], "knife" if pts.len() >= 2 => vec![Action::Exec("path.knife".into(), json!({"points": points_json(pts)}))], @@ -1385,6 +1482,9 @@ impl Tool for GestureTool { let release_end = kept.unwrap_or(end); self.near_start = self.release_closes_near_start(cx, release_end); self.connects = self.connects_at(cx, release_end).is_some(); + if self.points.last().is_some_and(|l| l.distance(p) < cx.tol(1.0)) { + self.refresh_last_pose(ev); + } return vec![]; } self.push(p, ev); @@ -1438,13 +1538,17 @@ impl Tool for GestureTool { } } else if self.points.last().is_some_and(|l| l.distance(p) >= cx.tol(1.0)) { self.push(end, ev); - } else if self.grid_snaps(cx) - && !self.smoothing - && self.points.len() > 1 - && let Some(last) = self.points.last_mut() - { - // The last step was where the button went up: it moves where that snaps. - *last = end; + } else { + // A release within a pixel of the last step gives it the pen's pose there. + self.refresh_last_pose(ev); + if self.grid_snaps(cx) + && !self.smoothing + && self.points.len() > 1 + && let Some(last) = self.points.last_mut() + { + // The last step was where the button went up: it moves where that snaps. + *last = end; + } } let out = if cancel { vec![Action::Cancel] } else { self.finish(cx, ev.mods) }; self.active = false; @@ -1666,7 +1770,7 @@ impl Tool for GestureTool { json!({ "fidelity": self.fidelity, "size": self.size, - "sizeMode": if self.size_pressure { "pressure" } else { "fixed" }, + "sizeMode": self.size_mode.id(), "sizeVariation": self.size_variation, "angle": self.angle, "roundness": self.roundness, @@ -1676,10 +1780,14 @@ impl Tool for GestureTool { } "eraser" => json!({ "size": self.size, - "sizeMode": if self.size_pressure { "pressure" } else { "fixed" }, + "sizeMode": self.size_mode.id(), "sizeVariation": self.size_variation, "angle": self.angle, - "roundness": self.roundness + "angleMode": self.angle_mode.id(), + "angleVariation": self.angle_variation, + "roundness": self.roundness, + "roundnessMode": self.roundness_mode.id(), + "roundnessVariation": self.roundness_variation }), _ => Value::Null, } @@ -1688,12 +1796,19 @@ impl Tool for GestureTool { match key { "fidelity" => self.fidelity = v.as_f64().unwrap_or(self.fidelity).clamp(super::min_fidelity(self.id), 100.0), "size" => self.size = v.as_f64().unwrap_or(self.size).clamp(0.1, 1000.0), - "sizeMode" if self.varies_size() => match v.as_str() { - Some("pressure") => self.size_pressure = true, - Some("fixed") => self.size_pressure = false, + // The Blob Brush's Size is Fixed or Pressure; the Eraser's Angle, Roundness and Size + // take any mode. + "sizeMode" if self.varies_size() => match v.as_str().and_then(Variation::parse) { + Some(m) if self.id == "eraser" || matches!(m, Variation::Fixed | Variation::Pressure) => self.size_mode = m, _ => {} }, + "angleMode" if self.id == "eraser" => self.angle_mode = v.as_str().and_then(Variation::parse).unwrap_or(self.angle_mode), + "roundnessMode" if self.id == "eraser" => self.roundness_mode = v.as_str().and_then(Variation::parse).unwrap_or(self.roundness_mode), "sizeVariation" if self.varies_size() => self.size_variation = v.as_f64().unwrap_or(self.size_variation).clamp(0.0, 1000.0), + "angleVariation" if self.id == "eraser" => self.angle_variation = v.as_f64().unwrap_or(self.angle_variation).clamp(0.0, 180.0), + "roundnessVariation" if self.id == "eraser" => { + self.roundness_variation = v.as_f64().unwrap_or(self.roundness_variation).clamp(0.0, 100.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(if self.id == "blobBrush" { 0.0 } else { 1.0 }, 100.0), "fill" => self.fill = v.as_bool().unwrap_or(self.fill), diff --git a/crates/tools/src/draw2/gesture/tests.rs b/crates/tools/src/draw2/gesture/tests.rs index 4db9310c79..9dfbdba2de 100644 --- a/crates/tools/src/draw2/gesture/tests.rs +++ b/crates/tools/src/draw2/gesture/tests.rs @@ -527,10 +527,20 @@ fn blob_brush_sends_keep_selected_and_merge_only_with_selection() { "{acts:?}" ); } - // The Eraser keeps Size (Fixed by default), Angle and Roundness: a round tip by default. + // The Eraser keeps Size, Angle and Roundness, each Fixed by default: a round tip by default. assert_eq!( GestureTool::new("eraser").options(), - json!({"size": 10.0, "sizeMode": "fixed", "sizeVariation": 0.0, "angle": 0.0, "roundness": 100.0}) + json!({ + "size": 10.0, + "sizeMode": "fixed", + "sizeVariation": 0.0, + "angle": 0.0, + "angleMode": "fixed", + "angleVariation": 0.0, + "roundness": 100.0, + "roundnessMode": "fixed", + "roundnessVariation": 0.0 + }) ); } @@ -2768,6 +2778,161 @@ fn the_blob_brush_and_the_eraser_show_their_tip_at_the_pointer() { } } +/// Eraser Tool Options' Angle, Roundness and Size variation menus: the Eraser keeps each mode and +/// Variation, the release sends them with the pen's readings along the drag, and while it erases +/// its tip outline at the pointer takes the angle, roundness and size the pen gives there. The Blob +/// Brush's Size takes Fixed and Pressure only, and it has no Angle or Roundness menus. +#[test] +fn the_eraser_tip_follows_its_variation_modes() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + // The bounds of the tip outline, the last two overlays (its halo and its line). + let tip = |t: &GestureTool| match &t.overlays(&cx)[..] { + [.., Overlay::Path { path: halo, width: 3.0, .. }, Overlay::Path { path, color: [0, 0, 0], width: 1.0, .. }] => { + assert_eq!(halo, path); + vectorcraft_geom::Shape::bounding_box(path) + } + o => panic!("{o:?}"), + }; + let near = |b: vectorcraft_geom::Rect, want: (f64, f64, f64, f64)| { + assert!([b.x0 - want.0, b.y0 - want.1, b.x1 - want.2, b.y1 - want.3].iter().all(|e| e.abs() < 0.01), "{b:?} vs {want:?}"); + }; + let mut t = GestureTool::new("eraser"); + assert_eq!( + (t.options()["angleMode"].clone(), t.options()["roundnessMode"].clone(), t.options()["sizeMode"].clone()), + (json!("fixed"), json!("fixed"), json!("fixed")), + "Fixed by default" + ); + for (k, v) in [ + ("size", json!(20)), + ("roundness", json!(25)), + ("angleMode", json!("rotation")), + ("angleVariation", json!(90)), + ("roundnessMode", json!("random")), + ("roundnessVariation", json!(10)), + ("sizeMode", json!("tilt")), + ("sizeVariation", json!(8)), + ] { + t.set_option(k, &v); + } + let o = t.options(); + assert_eq!( + [&o["angleMode"], &o["angleVariation"], &o["roundnessMode"], &o["roundnessVariation"], &o["sizeMode"], &o["sizeVariation"]], + [&json!("rotation"), &json!(90.0), &json!("random"), &json!(10.0), &json!("tilt"), &json!(8.0)] + ); + // A mode it does not have leaves the mode as it was; a Variation is kept within its range. + t.set_option("angleMode", &json!("wheel")); + t.set_option("roundnessVariation", &json!(500)); + assert_eq!((t.options()["angleMode"].clone(), t.options()["roundnessVariation"].clone()), (json!("rotation"), json!(100.0))); + t.set_option("roundnessVariation", &json!(10)); + // Before a press the outline is the set tip: 20 by 5 pt, lying level. + t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 100.0, 50.0)); + near(tip(&t), (90.0, 47.5, 110.0, 52.5)); + // Erasing with the barrel turned half way round and the pen upright, the tip stands upright and + // is 28 by 7 pt; Random leaves the roundness as set. + let pen = Some(PenPose { tilt: Some([0.0, 0.0]), rotation: Some(180.0) }); + t.pointer(&cx, &PointerEvent { pen, pressure: 0.5, ..PointerEvent::new(PointerKind::Down, 100.0, 50.0) }); + t.pointer(&cx, &PointerEvent { pen, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) }); + near(tip(&t), (196.5, 36.0, 203.5, 64.0)); + let acts = t.pointer(&cx, &PointerEvent { pen, pressure: 0.5, ..PointerEvent::new(PointerKind::Up, 200.0, 50.0) }); + let Some(Action::Exec(c, v)) = acts.first() else { panic!("{acts:?}") }; + assert_eq!(c, "path.eraseRegion"); + assert_eq!( + [&v["angleMode"], &v["angleVariation"], &v["roundnessMode"], &v["roundnessVariation"], &v["sizeMode"], &v["sizeVariation"]], + [&json!("rotation"), &json!(90.0), &json!("random"), &json!(10.0), &json!("tilt"), &json!(8.0)] + ); + assert_eq!( + v["points"], + json!([[100.0, 50.0, 0.5, 0.0, 0.0, 180.0], [200.0, 50.0, 0.5, 0.0, 0.0, 180.0]]), + "the pen's readings go with the points" + ); + // A mouse drag, which reads no tilt or rotation, keeps the set tip. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 50.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 50.0)); + near(tip(&t), (190.0, 47.5, 210.0, 52.5)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 200.0, 50.0)); + // A point without a reading after one with it goes back to the set tip there, as the release + // erases it. + t.pointer(&cx, &PointerEvent { pen, pressure: 0.5, ..PointerEvent::new(PointerKind::Down, 100.0, 50.0) }); + t.pointer(&cx, &PointerEvent { pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) }); + near(tip(&t), (190.0, 47.5, 210.0, 52.5)); + t.pointer(&cx, &PointerEvent { pressure: 0.5, ..PointerEvent::new(PointerKind::Up, 200.0, 50.0) }); + // The Blob Brush's Size is Fixed or Pressure, and its Angle and Roundness have no menus. + let mut b = GestureTool::new("blobBrush"); + b.set_option("sizeMode", &json!("tilt")); + b.set_option("angleMode", &json!("rotation")); + assert_eq!(b.options()["sizeMode"], json!("fixed")); + assert!(b.options().get("angleMode").is_none() && b.options().get("roundnessVariation").is_none()); + b.set_option("sizeMode", &json!("pressure")); + assert_eq!(b.options()["sizeMode"], json!("pressure")); +} + +/// The Eraser's tip outline follows a pen turned in place, which adds no stroke point, and the +/// release erases with that reading; and its size passes 1296 pt as `path.eraseRegion` sweeps it. +#[test] +fn the_eraser_tip_turns_in_place_and_passes_a_nib_size() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let tip = |t: &GestureTool| match &t.overlays(&cx)[..] { + [.., Overlay::Path { path, color: [0, 0, 0], width: 1.0, .. }] => vectorcraft_geom::Shape::bounding_box(path), + o => panic!("{o:?}"), + }; + let near = |b: vectorcraft_geom::Rect, want: (f64, f64, f64, f64)| { + assert!([b.x0 - want.0, b.y0 - want.1, b.x1 - want.2, b.y1 - want.3].iter().all(|e| e.abs() < 0.01), "{b:?} vs {want:?}"); + }; + let mut t = GestureTool::new("eraser"); + for (k, v) in [("size", json!(20)), ("roundness", json!(25)), ("angleMode", json!("rotation")), ("angleVariation", json!(90))] { + t.set_option(k, &v); + } + let level = Some(PenPose { tilt: None, rotation: Some(0.0) }); + let turned = Some(PenPose { tilt: None, rotation: Some(180.0) }); + t.pointer(&cx, &PointerEvent { pen: level, pressure: 0.5, ..PointerEvent::new(PointerKind::Down, 100.0, 50.0) }); + t.pointer(&cx, &PointerEvent { pen: level, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) }); + near(tip(&t), (190.0, 47.5, 210.0, 52.5)); + // The barrel turns half way round with the pointer still: the tip stands upright. + t.pointer(&cx, &PointerEvent { pen: turned, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) }); + near(tip(&t), (197.5, 40.0, 202.5, 60.0)); + let acts = t.pointer(&cx, &PointerEvent { pen: turned, pressure: 0.5, ..PointerEvent::new(PointerKind::Up, 200.0, 50.0) }); + let Some(Action::Exec(_, v)) = acts.first() else { panic!("{acts:?}") }; + assert_eq!(v["points"], json!([[100.0, 50.0, 0.5, null, null, 0.0], [200.0, 50.0, 0.5, null, null, 180.0]])); + // A release at the last point erases with the pose the pen has as it lifts. + t.pointer(&cx, &PointerEvent { pen: level, pressure: 0.5, ..PointerEvent::new(PointerKind::Down, 100.0, 50.0) }); + t.pointer(&cx, &PointerEvent { pen: level, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) }); + let acts = t.pointer(&cx, &PointerEvent { pen: turned, pressure: 0.5, ..PointerEvent::new(PointerKind::Up, 200.0, 50.0) }); + let Some(Action::Exec(_, v)) = acts.first() else { panic!("{acts:?}") }; + assert_eq!(v["points"], json!([[100.0, 50.0, 0.5, null, null, 0.0], [200.0, 50.0, 0.5, null, null, 180.0]])); + // A release there that reads no pose keeps the last one. + t.pointer(&cx, &PointerEvent { pen: level, pressure: 0.5, ..PointerEvent::new(PointerKind::Down, 100.0, 50.0) }); + t.pointer(&cx, &PointerEvent { pen: turned, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) }); + let acts = t.pointer(&cx, &PointerEvent { pressure: 0.5, ..PointerEvent::new(PointerKind::Up, 200.0, 50.0) }); + let Some(Action::Exec(_, v)) = acts.first() else { panic!("{acts:?}") }; + assert_eq!(v["points"], json!([[100.0, 50.0, 0.5, null, null, 0.0], [200.0, 50.0, 0.5, null, null, 180.0]])); + // With Angle on Bearing, a pen that leaned only at the last point and stands up there again + // leaves no bearing: the tip lies level, as set. + t.set_option("angleMode", &json!("bearing")); + let upright = Some(PenPose { tilt: Some([0.0, 0.0]), rotation: None }); + let leaning = Some(PenPose { tilt: Some([0.0, 45.0]), rotation: None }); + t.pointer(&cx, &PointerEvent { pen: upright, pressure: 0.5, ..PointerEvent::new(PointerKind::Down, 100.0, 50.0) }); + t.pointer(&cx, &PointerEvent { pen: leaning, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) }); + assert!(tip(&t).height() > 6.0, "{:?}", tip(&t)); + t.pointer(&cx, &PointerEvent { pen: upright, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) }); + near(tip(&t), (190.0, 47.5, 210.0, 52.5)); + t.pointer(&cx, &PointerEvent { pen: upright, pressure: 0.5, ..PointerEvent::new(PointerKind::Up, 200.0, 50.0) }); + // Size 1000 with a Variation of 1000 on Tilt, the pen upright: a tip 2000 pt across. + let mut t = GestureTool::new("eraser"); + for (k, v) in [("size", json!(1000)), ("sizeMode", json!("tilt")), ("sizeVariation", json!(1000))] { + t.set_option(k, &v); + } + let upright = Some(PenPose { tilt: Some([0.0, 0.0]), rotation: None }); + t.pointer(&cx, &PointerEvent { pen: upright, pressure: 0.5, ..PointerEvent::new(PointerKind::Down, 100.0, 50.0) }); + t.pointer(&cx, &PointerEvent { pen: upright, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) }); + near(tip(&t), (-800.0, -950.0, 1200.0, 1050.0)); +} + #[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 a4d1b1a64f..a5c92b00b0 100644 --- a/crates/tools/src/settings.rs +++ b/crates/tools/src/settings.rs @@ -43,6 +43,8 @@ const ROWS: &[Row] = &[ (&["blobBrush"], None, &["fidelity", "keepSelected", "mergeOnlySelection"]), // Eraser Tool Options' tip shape. (&["blobBrush", "eraser"], None, &["angle", "roundness"]), + // Eraser Tool Options' Angle and Roundness variation menus and fields. + (&["eraser"], None, &["angleMode", "angleVariation", "roundnessMode", "roundnessVariation"]), // Live Paint Selection Tool Options (`builder::LivePaintSelectionTool`). (&["livePaintSelection"], None, &["selectFills", "selectStrokes", "color", "width"]), // Live Paint Bucket Options (`builder::LivePaintBucketTool`). diff --git a/crates/ui-egui/src/dialogs/freehand.rs b/crates/ui-egui/src/dialogs/freehand.rs index 48f31731cc..bc7dd47212 100644 --- a/crates/ui-egui/src/dialogs/freehand.rs +++ b/crates/ui-egui/src/dialogs/freehand.rs @@ -9,11 +9,16 @@ //! Brush), `closeWithin` and `editWithin` (screen pixels, 0 turns it off; Pencil and Paintbrush), //! `angle` (degrees) and `roundness` (percent; Blob Brush and Eraser), `livePreview` (Pencil: the stroke shows //! with its fill and stroke while it is drawn, or as a thin line until the release), `size` -//! (points; Blob Brush and Eraser), and `sizeMode` (`fixed` or `pressure`) and `sizeVariation` -//! (points; Blob Brush and Eraser: with Pressure the tip is Size − Variation wide at the lightest -//! pen pressure and Size + Variation at the heaviest). +//! (points; Blob Brush and Eraser), and `sizeMode` and `sizeVariation` (points; Blob Brush and +//! Eraser: with Pressure the tip is Size − Variation wide at the lightest pen pressure and Size + +//! Variation at the heaviest). The Blob Brush's `sizeMode` is `fixed` or `pressure`. The Eraser's +//! Angle, Roundness and Size each have a menu (`angleMode`, `roundnessMode`, `sizeMode`: `fixed`, +//! `random`, `pressure`, `tilt`, `bearing` or `rotation`) before the field and a Variation field +//! under it (`angleVariation` in degrees, `roundnessVariation` in percent, `sizeVariation`), +//! greyed while the menu is Fixed. use serde_json::{Map, Value, json}; +use vectorcraft_brush::Variation; use super::document_setup::check; use super::{DialogSpec, form, run_and_close}; @@ -30,10 +35,14 @@ 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; 14] = [ +const KEYS: [&str; 18] = [ "fidelity", "angle", + "angleMode", + "angleVariation", "roundness", + "roundnessMode", + "roundnessVariation", "size", "sizeMode", "sizeVariation", @@ -140,26 +149,58 @@ fn number(ui: &mut egui::Ui, d: &mut Dialog, (key, label): (&str, &str), suffix: }); } -/// Width of the Blob Brush's and the Eraser's Size mode menu. +/// Width of the Blob Brush's and the Eraser's variation menus. const MODE_W: f32 = 96.0; -/// The Blob Brush's and the Eraser's Size rows: Size with its Fixed or Pressure menu before the -/// field, then the Variation that Pressure adds and takes (greyed while Size is Fixed). -fn blob_size(ui: &mut egui::Ui, d: &mut Dialog) { - const MODES: [&str; 2] = ["fixed", "pressure"]; - let pressure = d.str("sizeMode") == "pressure"; - let labels = ["Fixed", "Pressure"]; - let label_w = label_w(ui) - MODE_W - ui.spacing().item_spacing.x; - let size = d.f64("size", 10.0); - widgets::label_row(ui, tl!("Size:"), label_w, |ui| { - if let Some(m) = widgets::dropdown(ui, "freehand-size-mode", labels[usize::from(pressure)], &labels, MODE_W).and_then(|i| MODES.get(i)) { - d.fields.insert("sizeMode".into(), json!(m)); +/// A tip value with a variation menu: its field. +struct Varied<'a> { + /// The value's key; the menu's is `Mode` and the Variation's `Variation`. + key: &'a str, + label: &'a str, + unit: &'a str, + decimals: usize, + range: (f64, f64), + /// The value's default. + default: f64, + /// The Variation field's range. + variation: (f64, f64), +} + +/// The rows of a tip value with a variation menu: the value with its menu of `modes` before the +/// field, then the Variation field the menu's modes other than Fixed take (greyed while the +/// menu is Fixed). The Blob Brush's Size offers Fixed and Pressure, and the Eraser's Angle, +/// Roundness and Size every mode. +fn varied(ui: &mut egui::Ui, d: &mut Dialog, v: &Varied, modes: &[Variation]) { + let (mode_key, var_key) = (format!("{}Mode", v.key), format!("{}Variation", v.key)); + let mode = Variation::parse(&d.str(&mode_key)).filter(|m| modes.contains(m)).unwrap_or_default(); + let labels: Vec<&str> = modes.iter().map(|m| m.label()).collect(); + let menu_label_w = label_w(ui) - MODE_W - ui.spacing().item_spacing.x; + let value = d.f64(v.key, v.default); + widgets::label_row(ui, v.label, menu_label_w, |ui| { + if let Some(m) = widgets::dropdown(ui, ("freehand-mode", v.key), mode.label(), &labels, MODE_W).and_then(|i| modes.get(i)) { + d.fields.insert(mode_key.clone(), json!(m.id())); } - if let Some(x) = widgets::range_field(ui, ("freehand-field", "size"), size, 0.1..=1000.0, "pt", 1, FIELD_W) { - d.fields.insert("size".into(), json!(x)); + if let Some(x) = widgets::range_field(ui, ("freehand-field", v.key), value, v.range.0..=v.range.1, v.unit, v.decimals, FIELD_W) { + d.fields.insert(v.key.into(), json!(x)); } }); - ui.add_enabled_ui(pressure, |ui| number(ui, d, ("sizeVariation", tl!("Variation:")), "pt", 1, (0.0, 1000.0))); + ui.add_enabled_ui(mode != Variation::Fixed, |ui| { + let label_w = label_w(ui); + let x = d.f64(&var_key, 0.0); + widgets::label_row(ui, tl!("Variation:"), label_w, |ui| { + if let Some(x) = + widgets::range_field(ui, ("freehand-field", var_key.as_str()), x, v.variation.0..=v.variation.1, v.unit, v.decimals, FIELD_W) + { + d.fields.insert(var_key.clone(), json!(x)); + } + }); + }); +} + +/// The Blob Brush's and the Eraser's Size: [`varied`] in points. +fn size_rows(ui: &mut egui::Ui, d: &mut Dialog, modes: &[Variation]) { + let size = Varied { key: "size", label: tl!("Size:"), unit: "pt", decimals: 1, range: (0.1, 1000.0), default: 10.0, variation: (0.0, 1000.0) }; + varied(ui, d, &size, modes); } /// The Fidelity rows: the label on a line of its own, then a logarithmic slider from the tool's @@ -208,19 +249,39 @@ 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") { - let min = if d.str("tool") == "blobBrush" { 0.0 } else { 1.0 }; - number(ui, d, ("roundness", tl!("Roundness:")), "%", 0, (min, 100.0)); + let eraser = d.fields.contains_key("angleMode"); + if eraser { + // The Eraser: Angle, Roundness and Size each with every variation mode. + let angle = + Varied { key: "angle", label: tl!("Angle:"), unit: "°", decimals: 0, range: (-180.0, 180.0), default: 0.0, variation: (0.0, 180.0) }; + varied(ui, d, &angle, &Variation::ALL); + let roundness = Varied { + key: "roundness", + label: tl!("Roundness:"), + unit: "%", + decimals: 0, + range: (1.0, 100.0), + default: 100.0, + variation: (0.0, 100.0), + }; + varied(ui, d, &roundness, &Variation::ALL); + size_rows(ui, d, &Variation::ALL); + } else { + if d.fields.contains_key("angle") { + number(ui, d, ("angle", tl!("Angle:")), "°", 0, (-180.0, 180.0)); + } + if d.fields.contains_key("roundness") { + let min = if d.str("tool") == "blobBrush" { 0.0 } else { 1.0 }; + number(ui, d, ("roundness", tl!("Roundness:")), "%", 0, (min, 100.0)); + } } if d.str("tool") == "blobBrush" { blob_tip_preview(ui, d); } - if d.fields.contains_key("sizeMode") { - blob_size(ui, d); - } else if d.fields.contains_key("size") { + // The Eraser's Size rows are above. + if !eraser && d.fields.contains_key("sizeMode") { + size_rows(ui, d, &[Variation::Fixed, Variation::Pressure]); + } else if !eraser && d.fields.contains_key("size") { number(ui, d, ("size", tl!("Size:")), "pt", 1, (0.1, 1000.0)); } if d.fields.contains_key("fill") { diff --git a/crates/ui-egui/src/dialogs/freehand/tests.rs b/crates/ui-egui/src/dialogs/freehand/tests.rs index 35d216bc77..1b41fc0886 100644 --- a/crates/ui-egui/src/dialogs/freehand/tests.rs +++ b/crates/ui-egui/src/dialogs/freehand/tests.rs @@ -366,6 +366,38 @@ fn the_blob_brush_and_eraser_size_can_follow_the_pen_pressure() { assert!(!text.contains("Variation:") && !text.contains("Fixed"), "{text}"); } +/// Eraser Tool Options gives Angle, Roundness and Size each a variation menu with a Variation +/// field; OK sets the modes and Variations. The Blob Brush keeps one menu, on Size. +#[test] +fn the_eraser_angle_roundness_and_size_each_have_a_variation_menu() { + let mut app = app(); + app.run("tool.options", json!({"tool": "eraser"})).unwrap(); + let text = painted(&mut app); + assert_eq!((text.matches("Variation:").count(), text.matches("Fixed").count()), (3, 3), "{text}"); + let d = app.ui.dialog.as_mut().unwrap(); + for (k, v) in [ + ("angleMode", json!("bearing")), + ("angleVariation", json!(30)), + ("roundnessMode", json!("random")), + ("roundnessVariation", json!(20)), + ("sizeMode", json!("rotation")), + ("sizeVariation", json!(5)), + ] { + d.fields.insert(k.into(), v); + } + let text = painted(&mut app); + assert!(text.contains("Bearing") && text.contains("Random") && text.contains("Rotation") && !text.contains("Fixed"), "{text}"); + super::super::confirm(&mut app).unwrap(); + let o = app.session.tool_options_of("eraser"); + assert_eq!( + [&o["angleMode"], &o["angleVariation"], &o["roundnessMode"], &o["roundnessVariation"], &o["sizeMode"], &o["sizeVariation"]], + [&json!("bearing"), &json!(30.0), &json!("random"), &json!(20.0), &json!("rotation"), &json!(5.0)] + ); + app.run("tool.options", json!({"tool": "blobBrush"})).unwrap(); + let text = painted(&mut app); + assert_eq!(text.matches("Variation:").count(), 1, "{text}"); +} + #[test] fn the_eraser_options_set_the_tips_angle_and_roundness() { let mut app = app(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e6f3621e8e..d52720f29e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -973,7 +973,10 @@ the path a stroke draws, continues or merges into is not selected afterwards), ` 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), `size` (pt; Blob Brush, Eraser), and `angle` (°, −180 to 180; 0) and `roundness` (%, 0 to 100 for the Blob Brush, where 0 is drawn as 0.1, and 1 to 100 for -the Eraser; 100) of the tip (Blob Brush, Eraser); the tip keeps its angle on the page along a stroke. `ui.dialog.confirm` runs +the Eraser; 100) of the tip (Blob Brush, Eraser); the tip keeps its angle on the page along a stroke. `sizeMode` and +`sizeVariation` (pt) vary the size (Blob Brush: `fixed` or `pressure`). The Eraser also has `angleMode`, +`angleVariation` (°, 0 to 180), `roundnessMode` and `roundnessVariation` (%, 0 to 100), and its three modes are each +`fixed`, `random`, `pressure`, `tilt`, `bearing` or `rotation`, as `path.eraseRegion` takes them. `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 b372fd3c68..6484c32fea 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5535,11 +5535,28 @@ moves that point alone as the focal point, with or without Shift (Shift keeps th focal points stay highlighted where it left them; Escape during a drag puts the path back and keeps them, and switching to another tool clears them. +`path.eraseRegion` (the Eraser) takes a variation mode for each of its tip's values, as the variation menus of +Eraser Tool Options set them ([Erase paths using the Eraser +tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/erase-paths-using-eraser-tool.html)): +`angleMode` with `angleVariation` (degrees, 0 to 180), `roundnessMode` with `roundnessVariation` (percent, 0 to 100) +and `sizeMode` with `sizeVariation` (points, 0 or more), each `"fixed"` (the default), `"random"`, `"pressure"`, +`"tilt"`, `"bearing"` or `"rotation"`. A value that varies takes up to its variation either way. Random draws one +value for the drag. Pressure goes from the value less the variation where the pen presses lightest to the value plus +it where the pen presses hardest. Tilt goes from less with the pen lying flat to more with it upright. Bearing and +Rotation go from less at −180° to more at 180° of the way the pen leans or its barrel turns. The tip at each point of +the drag takes what the pen read there (the points' pressure, tilt and rotation, as `path.freehand` takes them); a +drag without that reading, such as a mouse drag, erases at the value itself. One Random value per drag, which way +Tilt, Bearing and Rotation run, and the value itself without a reading are VectorCraft's own rules. +The command refuses another mode, and a variation below 0 or past its range, and erases nothing. In the desktop app +the Eraser's tip outline at the pointer takes the angle, roundness and size the pen gives while it erases. +`tool.setOption {tool: "eraser"}` takes the same keys. + These commands refuse a string or another value that is not a number with `` `dy` must be a number, not "10" ``, 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`, `sizeVariation`, `angle` and `roundness` in `path.eraseRegion`; +`brush.freehand`, which runs it); `size`, `sizeVariation`, `angle`, `angleVariation`, `roundness` and +`roundnessVariation` in `path.eraseRegion`; `size`, `sizeVariation`, `angle`, `roundness` and `fidelity` 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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1a81970f99..4c27883d11 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1146,6 +1146,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Alt-drag with the Knife cuts in a straight line, and Shift+Alt-drag cuts at 45° or 90°, as [Cut objects using the Knife and Scissors tools](https://web.archive.org/web/20260926095638/https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/cut-objects.html) and [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) describe. Alt counts only when held at the press; the dashed line runs from the press point to the pointer, stays straight after Alt is let go, and release sends `path.knife` with those two points. Alt pressed after the press leaves the cut freehand. Shift+Alt keeps the endpoint at 45° steps from the horizontal and vertical, whatever the Constrain Angle. With Smart Guides on, a straight Knife cut snaps to anchor points and paths: [Cut objects using the Knife and Scissors tools](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/cut-objects.html) describes the Knife snapping to anchor points and paths while Smart Guides are on. Its ends snap as the ends of the Pencil's straight line do, to anchor points and path segments of art that is shown: the press, and the release of an Alt-drag. Shift+Alt steps 45° from the snapped press, and its release stays on that step. With Smart Guides off, or with Snap to Pixel or Snap to Grid on, the cut is not snapped. The dashed line and `path.knife` use the snapped points. 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`). + Eraser Tool Options gives Angle, Roundness and Size each a menu of Fixed, Random, Pressure, Tilt, Bearing and Rotation with a Variation field, as the same page describes: the tip varies by up to the Variation either way, Random once per drag and the others with what the pen reads at each point of the drag, and the tip outline at the pointer follows the pen while it erases. The outline of the area a drag shows keeps the set Angle and Roundness, and a mouse drag erases at the set values (`path.eraseRegion` `angleMode`, `roundnessMode`, `sizeMode` and their variations). The outline a Blob Brush or Eraser drag shows is built a bounded amount per frame, so a long stroke does not slow the canvas: each frame takes at most 256 new points of the drag and outlines again only the pieces of up to 32 segments they change, and the parts of a piece's outline inside the rest of the area are removed a limited amount at a time. While work is left, the canvas draws again with the pointer still, the points not outlined yet show as a thin line, and the tip shows at the pointer. With Size set to Pressure the outline also shows only the edge of the whole area. The release sends the whole stroke as before (`path.blob`, `path.eraseRegion`). The settled part of that outline is kept in paths of up to 64 pieces each. A path is shared from one frame to the next until a piece in it changes, and the canvas keeps the triangles it drew the path with while the view stays the same, so a frame does not copy, flatten or tessellate a settled path again; a pan, zoom or rotation draws each one once more. This is VectorCraft's own rule. The outline of a drag with a round Eraser tip is drawn whole each frame. - **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).