From c65286666c57638830ebcf573384e8366cf7a845 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 00:03:39 +0700 Subject: [PATCH] Blob Brush: Size follows the pen pressure (#4590) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blob Brush Tool Options' Size takes Fixed or Pressure and a Variation, as the brush options' Size does: with Pressure the brush is Size − Variation wide where the pen presses lightest and Size + Variation where it presses hardest. The tool records the pen pressure of each point and shows each step of the drag as wide as the pen pressed there; path.blob takes [x, y, pressure] points with sizeMode and sizeVariation and outlines the stroke with that width profile. A stroke without pressure keeps the Size, and Fixed with no variation stays the default. https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/blob-brush-options-and-best-practices.html --- crates/engine/src/cmd/draw2/commands.rs | 2 +- crates/engine/src/cmd/draw2/freehand.rs | 66 +++++++++++++++++--- crates/engine/src/tests/draw2.rs | 38 +++++++++++ crates/tools/src/draw2.rs | 10 +++ crates/tools/src/draw2/gesture.rs | 54 +++++++++++++--- crates/tools/src/settings.rs | 2 +- crates/ui-egui/src/dialogs/freehand.rs | 36 +++++++++-- crates/ui-egui/src/dialogs/freehand/tests.rs | 22 +++++++ docs/mcp.md | 2 +- 9 files changed, 210 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/cmd/draw2/commands.rs b/crates/engine/src/cmd/draw2/commands.rs index 57066e3b9..d5df29421 100644 --- a/crates/engine/src/cmd/draw2/commands.rs +++ b/crates/engine/src/cmd/draw2/commands.rs @@ -128,7 +128,7 @@ pub fn specs() -> Vec { "Blob Brush", [], None, - "{points: [[x,y]…], size?: pt (10), fidelity?: pt (0.01–100; the brush follows the stroke fitted at this tolerance as path.freehand fits a Pencil stroke, and a higher value gives a smoother shape; without it the brush follows every point), merge?: true, mergeOnlySelection?: bool (false: merge with any touching same-color blob; true: only with selected ones), keepSelected?: bool (false: the drawn or merged path is not selected, and the rest of the selection stays; true: it is selected)} filled brush shape, merged with the topmost touching same-color blob and the touching ones next to it in stacking order in its group or layer → {id}", + "{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1), size?: pt (10), sizeMode?: \"fixed\" (default) | \"pressure\" (Blob Brush Tool Options' Size set to Pressure: the brush is size − sizeVariation wide at the lightest pen pressure and size + sizeVariation at the heaviest, at least 0.1 pt; a stroke without pressure keeps the size), sizeVariation?: pt (0), fidelity?: pt (0.01–100; the brush follows the stroke fitted at this tolerance as path.freehand fits a Pencil stroke, and a higher value gives a smoother shape; without it the brush follows every point), merge?: true, mergeOnlySelection?: bool (false: merge with any touching same-color blob; true: only with selected ones), keepSelected?: bool (false: the drawn or merged path is not selected, and the rest of the selection stays; true: it is selected)} filled brush shape, merged with the topmost touching same-color blob and the touching ones next to it in stacking order in its group or layer → {id}", has_doc, blob ), diff --git a/crates/engine/src/cmd/draw2/freehand.rs b/crates/engine/src/cmd/draw2/freehand.rs index e037f0cc1..5c7da7923 100644 --- a/crates/engine/src/cmd/draw2/freehand.rs +++ b/crates/engine/src/cmd/draw2/freehand.rs @@ -1,5 +1,5 @@ use super::common::{ - brush_region, path_kind, path_mut, path_of, points_param, poly_bounds, samples_param, stroke_paint, stroke_pressure, subpath_len, target_paths, + brush_region, path_kind, path_mut, path_of, poly_bounds, samples_param, stroke_paint, stroke_pressure, subpath_len, target_paths, within_input_bound, }; use super::{ @@ -7,6 +7,7 @@ use super::{ 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 ---------- @@ -366,13 +367,42 @@ 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 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")); } + // 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 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); // With `fidelity` (pt), the brush follows the stroke fitted by [`fit_freehand`], as a Pencil // stroke is fitted. Without it, or when the fitted stroke has no length (a short stroke back to // its start), the brush follows every point. @@ -380,11 +410,31 @@ pub(super) fn blob(s: &mut Session, p: &Value) -> Result { None | Some(Value::Null) => None, Some(_) => Some(number_or(p, "fidelity", 0.0, C)?.clamp(vectorcraft_tools::draw2::MIN_FIDELITY, 100.0)), }; - let fitted = fidelity - .filter(|_| pts.len() > 1) - .map(|tol| po::outline_stroke(&PathData::single(fit_freehand(&pts, tol, false)), size, Cap::Round, Join::Round, 4.0)) - .filter(|r| !r.is_empty()); - let region = fitted.unwrap_or_else(|| brush_region(&pts, size)); + let spine = |tol: Option| match tol { + Some(tol) => PathData::single(fit_freehand(&pts, tol, false)), + None => PathData::single(SubPath::polyline(&pts, false)), + }; + let region = match (&pressure, pts.len() > 1) { + (Some((prof, variation)), true) => { + let fitted = match fidelity { + Some(tol) => Some(pressure_region(&spine(Some(tol)), size, *variation, prof)?).filter(|r| !r.is_empty()), + None => None, + }; + match fitted { + Some(r) => r, + None => pressure_region(&spine(None), size, *variation, prof)?, + } + } + // 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))), + (None, _) => { + let fitted = fidelity + .filter(|_| pts.len() > 1) + .map(|tol| po::outline_stroke(&spine(Some(tol)), size, Cap::Round, Join::Round, 4.0)) + .filter(|r| !r.is_empty()); + fitted.unwrap_or_else(|| brush_region(&pts, size)) + } + }; if region.is_empty() { return Err(EngineError::Other("empty brush stroke".into())); } @@ -404,7 +454,7 @@ pub(super) fn blob(s: &mut Session, p: &Value) -> Result { let all = Selection::default(); let from = if only_selection { &st.selection } else { &all }; // A fitted stroke can curve outside the points' bounds, so the area also takes in the shape. - let area = region.control_bounds().map_or_else(|| poly_bounds(&pts, size), |b| b.union(poly_bounds(&pts, size))); + let area = region.control_bounds().map_or_else(|| poly_bounds(&pts, reach), |b| b.union(poly_bounds(&pts, reach))); for id in target_paths(&st.doc, from, area) { let Some(n) = st.doc.node(id) else { continue }; if !blob_match(n, &paint) { diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index 820c4a28d..e787f6e75 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -465,6 +465,44 @@ fn blob_brush_merges_only_with_the_selection_and_keep_selected_off_deselects() { assert!(s.doc().unwrap().doc.node(all).is_some() && s.doc().unwrap().doc.node(other).is_some()); } +/// Blob Brush Tool Options' Size set to Pressure (#4590): the brush is Size − Variation wide where +/// the pen pressed lightest and Size + Variation where it pressed hardest. A stroke without +/// pressure keeps the Size, and Fixed leaves the pressure out. +#[test] +fn blob_brush_size_follows_the_pen_pressure() { + let mut s = session(); + let inside = |s: &Session, id: NodeId, x: f64, y: f64| { + vectorcraft_geom::hit::fill_contains(&path(s, id).to_bezpath(), FillRule::NonZero, Point::new(x, y)) + }; + let blob = |s: &mut Session, p: serde_json::Value| NodeId(s.execute("path.blob", &p).unwrap()["id"].as_u64().unwrap()); + // Lightest at the left end, hardest at the right: about 2 pt wide to 18 pt wide. + let pressed = |y: f64| (0..=20).map(|i| json!([100.0 + f64::from(i) * 10.0, y, f64::from(i) / 20.0])).collect::>(); + let a = blob(&mut s, json!({"points": pressed(100.0), "size": 10, "sizeMode": "pressure", "sizeVariation": 8})); + assert!(inside(&s, a, 290.0, 107.0) && !inside(&s, a, 110.0, 103.0), "wide where pressed hard, thin where light"); + // Fixed, and Pressure on a stroke without pressure: 10 pt wide all along. + let fixed = blob(&mut s, json!({"points": pressed(300.0), "size": 10, "sizeMode": "fixed", "sizeVariation": 8})); + let mouse = blob(&mut s, json!({"points": [[100, 500], [300, 500]], "size": 10, "sizeMode": "pressure", "sizeVariation": 8})); + for (id, y) in [(fixed, 300.0), (mouse, 500.0)] { + assert!(inside(&s, id, 110.0, y + 3.0) && !inside(&s, id, 290.0, y + 7.0), "10 pt at y {y}"); + } + let err = s.execute("path.blob", &json!({"points": [[0, 0], [10, 0]], "sizeMode": "random"})).unwrap_err(); + assert!(err.to_string().contains("sizeMode"), "{err}"); + // The tool records the pen while its Size varies with Pressure. + let v = view(); + s.select_tool("blobBrush", v).unwrap(); + s.set_tool_option("sizeMode", &json!("pressure")); + s.set_tool_option("sizeVariation", &json!(8)); + assert_eq!((s.tool_options()["sizeMode"].clone(), s.tool_options()["sizeVariation"].clone()), (json!("pressure"), json!(8.0))); + let ev = |kind, i: i32| PointerEvent { pressure: i as f32 / 20.0, ..PointerEvent::new(kind, 100.0 + f64::from(i) * 10.0, 700.0) }; + s.pointer(&ev(PointerKind::Down, 0), v).unwrap(); + for i in 1..20 { + s.pointer(&ev(PointerKind::Drag, i), v).unwrap(); + } + s.pointer(&ev(PointerKind::Up, 20), v).unwrap(); + let (drawn, _) = paths(&s).into_iter().find(|(_, p)| p.control_bounds().is_some_and(|b| b.y0 > 650.0)).unwrap(); + assert!(inside(&s, drawn, 290.0, 707.0) && !inside(&s, drawn, 110.0, 703.0), "the tool's stroke follows the pen"); +} + /// 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 c8537bd75..7a4158eb5 100644 --- a/crates/tools/src/draw2.rs +++ b/crates/tools/src/draw2.rs @@ -38,6 +38,16 @@ 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` − +/// `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) +} + +/// The narrowest a Blob Brush stroke gets, in points (its Size field's minimum). +pub const MIN_BLOB_SIZE: f64 = 0.1; + /// 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 d2ac6eb56..bb6a99de3 100644 --- a/crates/tools/src/draw2/gesture.rs +++ b/crates/tools/src/draw2/gesture.rs @@ -9,8 +9,8 @@ //! key toggles to Smooth Tool off) draws a straight line at any angle. The Pencil's new path starts and ends on //! 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. The -//! other tools collect the drag polyline (shown as an overlay) and run +//! 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 //! 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,7 +38,8 @@ pub struct GestureTool { previewed: usize, id: &'static str, points: Vec, - /// The pen pressure of each of `points` (the Paintbrush only). + /// The pen pressure of each of `points` (the Paintbrush, and the Blob Brush with + /// `size_pressure`). pressures: Vec, active: bool, began: bool, @@ -57,6 +58,12 @@ 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` − + /// `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. + pub size_variation: f64, /// The angle of the Eraser tip's long axis, in degrees counter-clockwise from horizontal. pub angle: f64, /// The Eraser tip's short axis in percent of its long one (100: round). @@ -137,6 +144,8 @@ impl GestureTool { redraw: None, fidelity, size: 10.0, + size_pressure: false, + size_variation: 0.0, angle: 0.0, roundness: 100.0, fill: false, @@ -175,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" { + if self.id == "paintbrush" || (self.id == "blobBrush" && self.size_pressure) { self.pressures.push(pressure); } } @@ -236,6 +245,13 @@ 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]> { + 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()) + } + /// The stroke's points, each `[x, y, pressure]` when a pen pressed less than fully anywhere /// along it (a mouse always presses fully). fn stroke_json(&self) -> Value { @@ -374,8 +390,10 @@ impl GestureTool { "blobBrush" => vec![Action::Exec( "path.blob".into(), json!({ - "points": points_json(pts), + "points": self.stroke_json(), "size": self.size, + "sizeMode": if self.size_pressure { "pressure" } else { "fixed" }, + "sizeVariation": self.size_variation, "fidelity": self.fidelity, "keepSelected": self.keep_selected, "mergeOnlySelection": self.merge_only_selection @@ -622,7 +640,16 @@ impl Tool for GestureTool { o.push(Overlay::Path { path: area.to_bezpath(), color, width: 1.0, dashed: false }); return o; } - o.push(Overlay::Path { path: polyline(&self.points), color, width, dashed: self.id == "knife" }); + // 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() { + 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; + o.push(Overlay::Path { path: polyline(seg), color, width: width as f32, dashed: false }); + } + } else { + o.push(Overlay::Path { path: polyline(&self.points), color, width, dashed: self.id == "knife" }); + } if cx.measurement_labels && self.id == "blobBrush" && !self.smoothing { let last = self.points[self.points.len() - 1]; let length = self.points.windows(2).map(|w| w[0].distance(w[1])).sum(); @@ -691,7 +718,14 @@ impl Tool for GestureTool { } "smooth" => json!({"fidelity": self.fidelity}), "blobBrush" => { - json!({"fidelity": self.fidelity, "size": self.size, "keepSelected": self.keep_selected, "mergeOnlySelection": self.merge_only_selection}) + json!({ + "fidelity": self.fidelity, + "size": self.size, + "sizeMode": if self.size_pressure { "pressure" } else { "fixed" }, + "sizeVariation": self.size_variation, + "keepSelected": self.keep_selected, + "mergeOnlySelection": self.merge_only_selection + }) } "eraser" => json!({"size": self.size, "angle": self.angle, "roundness": self.roundness}), _ => Value::Null, @@ -701,6 +735,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() { + 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), "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 8548f3312..838faa8f2 100644 --- a/crates/tools/src/settings.rs +++ b/crates/tools/src/settings.rs @@ -40,7 +40,7 @@ const ROWS: &[Row] = &[ (&["pencil"], None, &["altSmooth", "livePreview", "liveFit"]), (&["smooth"], None, &["fidelity"]), (&["blobBrush", "eraser"], None, &["size"]), - (&["blobBrush"], None, &["fidelity", "keepSelected", "mergeOnlySelection"]), + (&["blobBrush"], None, &["fidelity", "keepSelected", "mergeOnlySelection", "sizeMode", "sizeVariation"]), // 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 fa60dce6e..1414438f9 100644 --- a/crates/ui-egui/src/dialogs/freehand.rs +++ b/crates/ui-egui/src/dialogs/freehand.rs @@ -7,8 +7,10 @@ //! Paintbrush), `keepSelected` (Pencil, Paintbrush and Blob Brush), `mergeOnlySelection` (Blob //! 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) and `size` -//! (points; Blob Brush and Eraser). +//! 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). use serde_json::{Map, Value, json}; @@ -27,11 +29,13 @@ 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; 12] = [ +const KEYS: [&str; 14] = [ "fidelity", "angle", "roundness", "size", + "sizeMode", + "sizeVariation", "fill", "altSmooth", "keepSelected", @@ -134,6 +138,28 @@ fn number(ui: &mut egui::Ui, d: &mut Dialog, (key, label): (&str, &str), suffix: }); } +/// Width of the Blob Brush'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). +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)); + } + 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)); + } + }); + ui.add_enabled_ui(pressure, |ui| number(ui, d, ("sizeVariation", tl!("Variation:")), "pt", 1, (0.0, 1000.0))); +} + /// The Fidelity rows: the label on a line of its own, then a logarithmic slider from the tool's /// lowest Fidelity to [`FIDELITY_MAX`] across the row with the field at its end, both to two /// decimals, and Accurate and Smooth under the slider's ends. @@ -182,7 +208,9 @@ fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { if d.fields.contains_key("roundness") { number(ui, d, ("roundness", tl!("Roundness:")), "%", 0, (1.0, 100.0)); } - if d.fields.contains_key("size") { + if d.fields.contains_key("sizeMode") { + blob_size(ui, d); + } else if 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 53623a63d..8a117e2f0 100644 --- a/crates/ui-egui/src/dialogs/freehand/tests.rs +++ b/crates/ui-egui/src/dialogs/freehand/tests.rs @@ -313,6 +313,28 @@ 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. +#[test] +fn the_blob_brush_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(); + let text = painted(&mut app); + assert!(!text.contains("Variation:") && !text.contains("Fixed"), "{text}"); +} + #[test] fn the_eraser_options_set_the_tips_angle_and_roundness() { let mut app = app(); diff --git a/docs/mcp.md b/docs/mcp.md index ce6cc02ed..6718a8c6e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2607,7 +2607,7 @@ naming the key; the document does not change and no undo step is recorded: `t`, `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`; -`size` and `fidelity` in `path.blob`; `width` in `path.eraseSegments`; +`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 an `x` without a `y`, or a `y` without an `x`, with "x and y must be given together". `path.freehand`,