diff --git a/crates/engine/src/cmd/draw2/commands.rs b/crates/engine/src/cmd/draw2/commands.rs index d5df29421..d34a79afb 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]…], 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", + "{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), 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 ), diff --git a/crates/engine/src/cmd/draw2/common.rs b/crates/engine/src/cmd/draw2/common.rs index b8c598a2d..760105dcf 100644 --- a/crates/engine/src/cmd/draw2/common.rs +++ b/crates/engine/src/cmd/draw2/common.rs @@ -1,7 +1,8 @@ use super::{ Appearance, Arc, Beside, Cap, Color, Document, EngineError, FillRule, Join, Node, NodeId, NodeKind, Paint, PathData, Point, PressureProfile, - Rect, Result, Selection, Session, SubPath, Value, Vec2, bad, index, json, po, + Rect, Result, Selection, Session, SubPath, Value, Vec2, bad, index, json, number_or, po, }; +use vectorcraft_doc::{LineCap, LineJoin, StrokeLayer, WidthProfile}; // ---------- helpers ---------- @@ -64,6 +65,36 @@ pub(super) fn stroke_pressure(samples: &[(Point, Option)]) -> Option 1e-9 { s / at } else { 0.0 }, p))) } +/// Blob Brush and Eraser Tool Options' Size set to Pressure (`sizeMode: "pressure"`, with +/// `sizeVariation` in points): the stroke's pen pressure along it and the variation, `None` for +/// Fixed (the default), no variation or a stroke without pressure (a mouse), which keeps the size. +pub(super) fn size_pressure(p: &Value, samples: &[(Point, Option)], cmd: &str) -> Result> { + match p.get("sizeMode").filter(|v| !v.is_null()).map(|v| v.as_str().ok_or(v)) { + None | Some(Ok("fixed")) => Ok(None), + Some(Ok("pressure")) => { + let variation = number_or(p, "sizeVariation", 0.0, cmd)?; + if !(variation.is_finite() && variation >= 0.0) { + return Err(bad(cmd, "sizeVariation must be 0 or more")); + } + Ok(stroke_pressure(samples).filter(|_| variation > 0.0).map(|prof| (prof, variation))) + } + Some(Ok(other)) => Err(bad(cmd, format!("sizeMode is \"fixed\" or \"pressure\", not `{other}`"))), + Some(Err(other)) => Err(bad(cmd, format!("sizeMode is \"fixed\" or \"pressure\", not {other}"))), + } +} + +/// The area a stroke along `spine` covers with Size set to Pressure: as wide at each point as +/// [`vectorcraft_tools::draw2::pressure_size`] gives for the pressure `prof` records there, with +/// round ends and joins, its overlaps united into one outline. +pub(super) fn pressure_region(spine: &PathData, size: f64, variation: f64, prof: &PressureProfile) -> Result { + let widest = size + variation; + let width = |t: f64| vectorcraft_tools::draw2::pressure_size(size, variation, prof.at(t)) / widest; + let profile = WidthProfile { points: prof.points.iter().map(|&(t, _)| (t, width(t), width(t))).collect() }; + let round = StrokeLayer { cap: LineCap::Round, join: LineJoin::Round, ..StrokeLayer::new(Paint::None, widest) }; + let outline = PathData::from_bezpath(&vectorcraft_render::width_outline(&spine.to_bezpath(), widest, &profile, &round, 0.05)); + po::try_unite_all(&[(&outline, FillRule::NonZero)]).map_err(|e| EngineError::Other(e.to_string())) +} + /// The length of a subpath. pub(super) fn subpath_len(sp: &SubPath) -> f64 { use kurbo::ParamCurveArclen; diff --git a/crates/engine/src/cmd/draw2/cut.rs b/crates/engine/src/cmd/draw2/cut.rs index e3a566f17..d9be52764 100644 --- a/crates/engine/src/cmd/draw2/cut.rs +++ b/crates/engine/src/cmd/draw2/cut.rs @@ -1,6 +1,6 @@ use super::common::{ - dist_to_polyline, fill_targets, ids_json, path_mut, path_of, points_param, poly_bounds, replace_with_pieces, sibling_with, target_paths, - usize_opt, + 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, @@ -264,30 +264,47 @@ pub(super) fn knife(s: &mut Session, p: &Value) -> Result { pub(super) fn erase_region(s: &mut Session, p: &Value) -> Result { const C: &str = "path.eraseRegion"; - let pts = points_param(p, "points", C)?; + let samples = samples_param(p, "points", C)?; let size = number_or(p, "size", 10.0, C)?; 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 pressure = size_pressure(p, &samples, C)?; + let pts: Vec = samples.into_iter().map(|(pt, _)| pt).collect(); 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; + // The tip's short axis (its narrowest with Pressure): an open path is sampled at least every half of it. + let narrowest = pressure.as_ref().map_or(size, |(_, v)| vectorcraft_tools::draw2::pressure_size(size, *v, 0.0)); + let short = narrowest * roundness.clamp(1.0, 100.0) / 100.0; let st = s.doc()?; - 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 stretch = eraser_tip(angle, roundness); + let squash = stretch.inverse(); let round: Vec = pts.iter().map(|q| squash * *q).collect(); + // With Size set to Pressure the tip is as wide as the pen pressed: the area is the squashed + // drag outlined at those widths, stretched back, and a point is under it when the area holds it. + let pressed = match (&pressure, round.len() > 1) { + (Some((prof, v)), true) => Some(pressure_region(&PathData::single(SubPath::polyline(&round, false)), size, *v, prof)?.transformed(stretch)), + (Some((prof, v)), false) => Some(eraser_area(&pts, vectorcraft_tools::draw2::pressure_size(size, *v, prof.at(0.0)), angle, roundness)), + (None, _) => None, + }; + let reach = pressure.as_ref().map_or(r, |(_, v)| (size + v) / 2.0); + let region = pressed.clone().unwrap_or_else(|| eraser_area(&pts, size, angle, roundness)); + let outline = pressed.map(|pd| pd.to_bezpath()); + let under = |q: Point| match &outline { + Some(bp) => fill_contains(bp, FillRule::NonZero, q), + None => dist_to_polyline(&round, squash * q) <= r, + }; let mut plan: Vec<(NodeId, Vec)> = vec![]; - for (id, pd, rule) in fill_targets(&st.doc, &st.selection, poly_bounds(&pts, r))? { + 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| dist_to_polyline(&round, squash * q) <= r) { + } else if let Some(pieces) = erase_outline(&pd, short / 2.0, &under) { plan.push((id, pieces)); } } diff --git a/crates/engine/src/cmd/draw2/freehand.rs b/crates/engine/src/cmd/draw2/freehand.rs index 5c7da7923..6267c8e2c 100644 --- a/crates/engine/src/cmd/draw2/freehand.rs +++ b/crates/engine/src/cmd/draw2/freehand.rs @@ -1,13 +1,12 @@ use super::common::{ - brush_region, path_kind, path_mut, path_of, poly_bounds, samples_param, stroke_paint, stroke_pressure, subpath_len, target_paths, - within_input_bound, + brush_region, path_kind, path_mut, path_of, poly_bounds, pressure_region, samples_param, size_pressure, stroke_paint, stroke_pressure, + subpath_len, target_paths, within_input_bound, }; use super::{ Anchor, Appearance, BTreeSet, BoolOp, Cap, Color, Document, EngineError, FillRule, Join, NewArt, Node, NodeId, NodeKind, Paint, PathData, Point, PressureProfile, Result, Selection, Session, SimplifyOptions, SubPath, Value, bad, id_param, insert_look, json, number_or, po, remove_each, str_param, switch_or, }; -use vectorcraft_doc::{LineCap, LineJoin, StrokeLayer, WidthProfile}; // ---------- Pencil / Paintbrush ---------- @@ -367,18 +366,6 @@ fn adjacent_to_topmost(doc: &Document, touched: &[NodeId], paint: &Paint) -> Vec out } -/// The area a Blob Brush stroke along `spine` covers with Size set to Pressure: as wide at each -/// point as [`vectorcraft_tools::draw2::blob_pressure_size`] gives for the pressure `prof` records -/// there, with round ends and joins, its overlaps united into one outline. -fn pressure_region(spine: &PathData, size: f64, variation: f64, prof: &PressureProfile) -> Result { - let widest = size + variation; - let width = |t: f64| vectorcraft_tools::draw2::blob_pressure_size(size, variation, prof.at(t)) / widest; - let profile = WidthProfile { points: prof.points.iter().map(|&(t, _)| (t, width(t), width(t))).collect() }; - let round = StrokeLayer { cap: LineCap::Round, join: LineJoin::Round, ..StrokeLayer::new(Paint::None, widest) }; - let outline = PathData::from_bezpath(&vectorcraft_render::width_outline(&spine.to_bezpath(), widest, &profile, &round, 0.05)); - po::try_unite_all(&[(&outline, FillRule::NonZero)]).map_err(|e| EngineError::Other(e.to_string())) -} - pub(super) fn blob(s: &mut Session, p: &Value) -> Result { const C: &str = "path.blob"; let samples = samples_param(p, "points", C)?; @@ -386,20 +373,7 @@ pub(super) fn blob(s: &mut Session, p: &Value) -> Result { if !(size.is_finite() && size > 0.0) { return Err(bad(C, "size must be positive")); } - // Size set to Pressure: the width follows the pen from size − sizeVariation to size + - // sizeVariation. A stroke without pressure (a mouse) keeps the size. - let pressure = match p.get("sizeMode").filter(|v| !v.is_null()).map(|v| v.as_str().ok_or(v)) { - None | Some(Ok("fixed")) => None, - Some(Ok("pressure")) => { - let variation = number_or(p, "sizeVariation", 0.0, C)?; - if !(variation.is_finite() && variation >= 0.0) { - return Err(bad(C, "sizeVariation must be 0 or more")); - } - stroke_pressure(&samples).filter(|_| variation > 0.0).map(|prof| (prof, variation)) - } - Some(Ok(other)) => return Err(bad(C, format!("sizeMode is \"fixed\" or \"pressure\", not `{other}`"))), - Some(Err(other)) => return Err(bad(C, format!("sizeMode is \"fixed\" or \"pressure\", not {other}"))), - }; + let pressure = size_pressure(p, &samples, C)?; let pts: Vec = samples.into_iter().map(|(pt, _)| pt).collect(); // The widest the brush gets: the area merges look in. let reach = pressure.as_ref().map_or(size, |(_, v)| size + v); @@ -426,7 +400,7 @@ pub(super) fn blob(s: &mut Session, p: &Value) -> Result { } } // A click: one dab as wide as the pen pressed. - (Some((prof, variation)), false) => brush_region(&pts, vectorcraft_tools::draw2::blob_pressure_size(size, *variation, prof.at(0.0))), + (Some((prof, variation)), false) => brush_region(&pts, vectorcraft_tools::draw2::pressure_size(size, *variation, prof.at(0.0))), (None, _) => { let fitted = fidelity .filter(|_| pts.len() > 1) diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index e787f6e75..9075e77e1 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -503,6 +503,27 @@ fn blob_brush_size_follows_the_pen_pressure() { assert!(inside(&s, drawn, 290.0, 707.0) && !inside(&s, drawn, 110.0, 703.0), "the tool's stroke follows the pen"); } +/// Eraser Tool Options' Size set to Pressure (#3961): the tip is Size − Variation across where the +/// pen pressed lightest and Size + Variation where it pressed hardest; Fixed erases at the Size. +#[test] +fn eraser_size_follows_the_pen_pressure() { + let pressed = |y: f64| (0..=20).map(|i| json!([100.0 + f64::from(i) * 10.0, y, f64::from(i) / 20.0])).collect::>(); + for (mode, thin_end_erased) in [("pressure", false), ("fixed", true)] { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 400.0, 200.0); + s.execute("path.eraseRegion", &json!({"points": pressed(100.0), "size": 10, "sizeMode": mode, "sizeVariation": 8})).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(290.0, 107.0), "{mode}: erased where the pen pressed hard"); + assert_eq!(!filled(110.0, 103.0), thin_end_erased, "{mode}: the light end"); + assert!(filled(110.0, 150.0), "{mode}: the rest stays"); + } + let mut s = session(); + let err = s.execute("path.eraseRegion", &json!({"points": [[0, 0], [10, 0]], "sizeMode": 5})).unwrap_err(); + assert!(err.to_string().contains("sizeMode"), "{err}"); +} + /// 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 7a4158eb5..be2019a38 100644 --- a/crates/tools/src/draw2.rs +++ b/crates/tools/src/draw2.rs @@ -38,15 +38,15 @@ pub fn min_fidelity(id: &str) -> f64 { if id == "smooth" { SMOOTH_MIN_FIDELITY } else { MIN_FIDELITY } } -/// The Blob Brush's width at pen `pressure` (0..1) with Size set to Pressure: `size` − +/// The Blob Brush's or the Eraser's width at pen `pressure` (0..1) with Size set to Pressure: `size` − /// `variation` at the lightest pressure to `size` + `variation` at the heaviest, at least -/// [`MIN_BLOB_SIZE`]. -pub fn blob_pressure_size(size: f64, variation: f64, pressure: f64) -> f64 { - (size + variation * (2.0 * pressure.clamp(0.0, 1.0) - 1.0)).max(MIN_BLOB_SIZE) +/// [`MIN_BRUSH_SIZE`]. +pub fn pressure_size(size: f64, variation: f64, pressure: f64) -> f64 { + (size + variation * (2.0 * pressure.clamp(0.0, 1.0) - 1.0)).max(MIN_BRUSH_SIZE) } -/// The narrowest a Blob Brush stroke gets, in points (its Size field's minimum). -pub const MIN_BLOB_SIZE: f64 = 0.1; +/// The narrowest a Blob Brush or Eraser stroke gets, in points (their Size field's minimum). +pub const MIN_BRUSH_SIZE: f64 = 0.1; /// Create a drawing tool by id (None = not one of ours). pub fn create(id: &str) -> Option> { diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs index bb6a99de3..2a6380aa9 100644 --- a/crates/tools/src/draw2/gesture.rs +++ b/crates/tools/src/draw2/gesture.rs @@ -10,8 +10,8 @@ //! Smart Guides ([`DrawSnap`]: an anchor, a path, in line with the art), hovering too; the points //! between follow the hand. The Paintbrush records the pen pressure of each point (a stroke a pen //! pressed less than fully on sends `[x, y, pressure]` points) for pressure-sensitive brushes, and -//! so does the Blob Brush while its Size varies with Pressure. 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 +//! so do the Blob Brush and the Eraser while their Size varies with Pressure. 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 //! has moved, switches the Blob Brush to the Smooth tool: the drag smooths the selected paths. @@ -38,8 +38,8 @@ pub struct GestureTool { previewed: usize, id: &'static str, points: Vec, - /// The pen pressure of each of `points` (the Paintbrush, and the Blob Brush with - /// `size_pressure`). + /// The pen pressure of each of `points` (the Paintbrush, and the Blob Brush and the Eraser + /// with `size_pressure`). pressures: Vec, active: bool, began: bool, @@ -58,11 +58,11 @@ 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 Tool Options' Size set to Pressure (else Fixed): the brush is `size` − + /// 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 Tool Options' Size Variation, in points. + /// Blob Brush and Eraser Tool Options' Size Variation, in points. pub size_variation: f64, /// The angle of the Eraser tip's long axis, in degrees counter-clockwise from horizontal. pub angle: f64, @@ -184,7 +184,7 @@ impl GestureTool { /// Add a stroke sample. fn push(&mut self, p: Point, pressure: f32) { self.points.push(p); - if self.id == "paintbrush" || (self.id == "blobBrush" && self.size_pressure) { + if self.id == "paintbrush" || (self.varies_size() && self.size_pressure) { self.pressures.push(pressure); } } @@ -245,11 +245,17 @@ impl GestureTool { self.shift_line = None; } - /// The pen pressure of each point of a Blob Brush stroke whose Size varies with Pressure, when - /// a pen pressed less than fully anywhere along it (a stroke without pressure keeps its Size). - fn blob_pressures(&self) -> Option<&[f32]> { + /// Does the tool's Size take Fixed or Pressure (the Blob Brush and the Eraser)? + fn varies_size(&self) -> bool { + matches!(self.id, "blobBrush" | "eraser") + } + + /// The pen pressure of each point of a Blob Brush or Eraser stroke whose Size varies with + /// Pressure, when a pen pressed less than fully anywhere along it (a stroke without pressure + /// keeps its Size). + fn size_pressures(&self) -> Option<&[f32]> { let pressed = self.pressures.len() == self.points.len() && self.pressures.iter().any(|p| *p < 1.0); - (self.id == "blobBrush" && self.size_pressure && !self.smoothing && pressed).then_some(self.pressures.as_slice()) + (self.varies_size() && self.size_pressure && !self.smoothing && pressed).then_some(self.pressures.as_slice()) } /// The stroke's points, each `[x, y, pressure]` when a pen pressed less than fully anywhere @@ -401,7 +407,14 @@ impl GestureTool { )], "eraser" => vec![Action::Exec( "path.eraseRegion".into(), - json!({"points": points_json(pts), "size": self.size, "angle": self.angle, "roundness": self.roundness}), + json!({ + "points": self.stroke_json(), + "size": self.size, + "sizeMode": if self.size_pressure { "pressure" } else { "fixed" }, + "sizeVariation": self.size_variation, + "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 => { @@ -635,16 +648,17 @@ impl Tool for GestureTool { }; 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. + // shortcut: at its Size even with Pressure; the release erases at the pen's widths. 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; } // With Size set to Pressure, each step of the drag shows as wide as the pen pressed there. - if let Some(pressures) = self.blob_pressures() { + if let Some(pressures) = self.size_pressures() { for (seg, p) in self.points.windows(2).zip(pressures.windows(2)) { let pressure = f64::from(p[0] + p[1]) / 2.0; - let width = super::blob_pressure_size(self.size, self.size_variation, pressure) * cx.zoom; + let width = super::pressure_size(self.size, self.size_variation, pressure) * cx.zoom; o.push(Overlay::Path { path: polyline(seg), color, width: width as f32, dashed: false }); } } else { @@ -727,7 +741,13 @@ impl Tool for GestureTool { "mergeOnlySelection": self.merge_only_selection }) } - "eraser" => json!({"size": self.size, "angle": self.angle, "roundness": self.roundness}), + "eraser" => json!({ + "size": self.size, + "sizeMode": if self.size_pressure { "pressure" } else { "fixed" }, + "sizeVariation": self.size_variation, + "angle": self.angle, + "roundness": self.roundness + }), _ => Value::Null, } } @@ -735,12 +755,12 @@ 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.id == "blobBrush" => match v.as_str() { + "sizeMode" if self.varies_size() => match v.as_str() { Some("pressure") => self.size_pressure = true, Some("fixed") => self.size_pressure = false, _ => {} }, - "sizeVariation" if self.id == "blobBrush" => self.size_variation = v.as_f64().unwrap_or(self.size_variation).clamp(0.0, 1000.0), + "sizeVariation" if self.varies_size() => self.size_variation = v.as_f64().unwrap_or(self.size_variation).clamp(0.0, 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), diff --git a/crates/tools/src/settings.rs b/crates/tools/src/settings.rs index 838faa8f2..9e2cb90a9 100644 --- a/crates/tools/src/settings.rs +++ b/crates/tools/src/settings.rs @@ -39,8 +39,8 @@ const ROWS: &[Row] = &[ // Paintbrush has none of them). (&["pencil"], None, &["altSmooth", "livePreview", "liveFit"]), (&["smooth"], None, &["fidelity"]), - (&["blobBrush", "eraser"], None, &["size"]), - (&["blobBrush"], None, &["fidelity", "keepSelected", "mergeOnlySelection", "sizeMode", "sizeVariation"]), + (&["blobBrush", "eraser"], None, &["size", "sizeMode", "sizeVariation"]), + (&["blobBrush"], None, &["fidelity", "keepSelected", "mergeOnlySelection"]), // Eraser Tool Options' tip shape. (&["eraser"], None, &["angle", "roundness"]), // Live Paint Selection Tool Options (`builder::LivePaintSelectionTool`). diff --git a/crates/ui-egui/src/dialogs/freehand.rs b/crates/ui-egui/src/dialogs/freehand.rs index 1414438f9..783e79093 100644 --- a/crates/ui-egui/src/dialogs/freehand.rs +++ b/crates/ui-egui/src/dialogs/freehand.rs @@ -8,9 +8,9 @@ //! Brush), `closeWithin` and `editWithin` (screen pixels, 0 turns it off; Pencil and Paintbrush), //! `angle` (degrees) and `roundness` (percent; 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: with Pressure the brush is Size − Variation wide at the lightest pen pressure and Size + -//! Variation at the heaviest). +//! (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). use serde_json::{Map, Value, json}; @@ -138,11 +138,11 @@ fn number(ui: &mut egui::Ui, d: &mut Dialog, (key, label): (&str, &str), suffix: }); } -/// Width of the Blob Brush's Size mode menu. +/// Width of the Blob Brush's and the Eraser's Size mode menu. const MODE_W: f32 = 96.0; -/// The Blob Brush'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). +/// 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"; diff --git a/crates/ui-egui/src/dialogs/freehand/tests.rs b/crates/ui-egui/src/dialogs/freehand/tests.rs index 8a117e2f0..6d316ef02 100644 --- a/crates/ui-egui/src/dialogs/freehand/tests.rs +++ b/crates/ui-egui/src/dialogs/freehand/tests.rs @@ -313,24 +313,26 @@ fn the_blob_brush_options_set_what_a_stroke_merges_with_and_selects() { /// 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. -/// The Blob Brush's Size is Fixed or Pressure (#4590), with the Variation Pressure adds and takes; -/// OK sets both, and the Eraser has neither. +/// The Blob Brush's and the Eraser's Size is Fixed or Pressure (#4590, #3961), with the Variation +/// Pressure adds and takes; OK sets both, each tool keeping its own, and the Pencil has neither. #[test] -fn the_blob_brush_size_can_follow_the_pen_pressure() { +fn the_blob_brush_and_eraser_size_can_follow_the_pen_pressure() { let mut app = app(); - app.run("tool.options", json!({"tool": "blobBrush"})).unwrap(); - let text = painted(&mut app); - assert!(text.contains("Fixed") && text.contains("Variation:"), "{text}"); - let d = app.ui.dialog.as_mut().unwrap(); - assert_eq!((d.str("sizeMode"), d.f64("sizeVariation", -1.0)), ("fixed".to_string(), 0.0), "Fixed with no variation by default"); - d.fields.insert("sizeMode".into(), json!("pressure")); - d.fields.insert("sizeVariation".into(), json!(6)); - super::super::confirm(&mut app).unwrap(); - let o = app.session.tool_options_of("blobBrush"); - assert_eq!((o["sizeMode"].clone(), o["sizeVariation"].as_f64()), (json!("pressure"), Some(6.0))); - app.run("tool.options", json!({"tool": "blobBrush"})).unwrap(); - assert!(painted(&mut app).contains("Pressure")); - app.run("tool.options", json!({"tool": "eraser"})).unwrap(); + for (tool, variation) in [("blobBrush", 6.0), ("eraser", 4.0)] { + app.run("tool.options", json!({"tool": tool})).unwrap(); + let text = painted(&mut app); + assert!(text.contains("Fixed") && text.contains("Variation:"), "{tool}: {text}"); + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!((d.str("sizeMode"), d.f64("sizeVariation", -1.0)), ("fixed".to_string(), 0.0), "{tool}: Fixed with no variation by default"); + d.fields.insert("sizeMode".into(), json!("pressure")); + d.fields.insert("sizeVariation".into(), json!(variation)); + super::super::confirm(&mut app).unwrap(); + let o = app.session.tool_options_of(tool); + assert_eq!((o["sizeMode"].clone(), o["sizeVariation"].as_f64()), (json!("pressure"), Some(variation)), "{tool}"); + app.run("tool.options", json!({"tool": tool})).unwrap(); + assert!(painted(&mut app).contains("Pressure"), "{tool}"); + } + app.run("tool.options", json!({"tool": "pencil"})).unwrap(); let text = painted(&mut app); assert!(!text.contains("Variation:") && !text.contains("Fixed"), "{text}"); } diff --git a/docs/mcp.md b/docs/mcp.md index 6718a8c6e..901c8e433 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2606,7 +2606,7 @@ 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`, `angle` and `roundness` in `path.eraseRegion`; +`brush.freehand`, which runs it); `size`, `sizeVariation`, `angle` and `roundness` in `path.eraseRegion`; `size`, `sizeVariation` 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