Merge pull request #4626 from storytold/feat/blob-pressure

Blob Brush and Eraser: Size follows the pen pressure (#4590, #3961)
This commit is contained in:
BFlat
2026-10-30 01:39:56 +07:00
committed by GitHub
14 changed files with 303 additions and 42 deletions
+2 -2
View File
@@ -92,7 +92,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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
),
@@ -128,7 +128,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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
),
+32 -1
View File
@@ -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<f64>)]) -> Option<Pressu
PressureProfile::from_samples(along.into_iter().map(|(s, p)| (if at > 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<f64>)], cmd: &str) -> Result<Option<(PressureProfile, f64)>> {
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<PathData> {
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;
+26 -9
View File
@@ -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<Value> {
pub(super) fn erase_region(s: &mut Session, p: &Value) -> Result<Value> {
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<Point> = 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<Point> = 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<PathData>)> = 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, &region, FillRule::NonZero, BoolOp::Intersect), FillRule::NonZero) <= 1e-6 {
continue;
}
plan.push((id, components(&po::boolean(&pd, rule, &region, 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));
}
}
+33 -9
View File
@@ -1,6 +1,6 @@
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,
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,
@@ -368,11 +368,15 @@ fn adjacent_to_topmost(doc: &Document, touched: &[NodeId], paint: &Paint) -> Vec
pub(super) fn blob(s: &mut Session, p: &Value) -> Result<Value> {
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"));
}
let pressure = size_pressure(p, &samples, C)?;
let pts: Vec<Point> = 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 +384,31 @@ pub(super) fn blob(s: &mut Session, p: &Value) -> Result<Value> {
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<f64>| 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::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 +428,7 @@ pub(super) fn blob(s: &mut Session, p: &Value) -> Result<Value> {
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) {
+59
View File
@@ -465,6 +465,65 @@ 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::<Vec<_>>();
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");
}
/// 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::<Vec<_>>();
// (mode, erased 3 pt off the stroke at the light end, erased 7 pt off it at the hard end).
for (mode, light, hard) in [("pressure", false, true), ("fixed", true, false)] {
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_eq!((!filled(110.0, 103.0), !filled(290.0, 107.0)), (light, hard), "{mode}");
assert!(!filled(200.0, 100.0) && filled(110.0, 150.0), "{mode}: the stroke is erased, 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.
+10
View File
@@ -38,6 +38,16 @@ pub fn min_fidelity(id: &str) -> f64 {
if id == "smooth" { SMOOTH_MIN_FIDELITY } else { MIN_FIDELITY }
}
/// 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_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 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<Box<dyn Tool>> {
Some(match id {
+70 -10
View File
@@ -9,9 +9,9 @@
//! 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
//! one command on release. With Shift held, the Blob Brush's points stay on the horizontal or
//! pressed less than fully on sends `[x, y, pressure]` points) for pressure-sensitive brushes, and
//! 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,7 +38,8 @@ pub struct GestureTool {
previewed: usize,
id: &'static str,
points: Vec<Point>,
/// The pen pressure of each of `points` (the Paintbrush only).
/// The pen pressure of each of `points` (the Paintbrush, and the Blob Brush and the Eraser
/// with `size_pressure`).
pressures: Vec<f32>,
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 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 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.varies_size() && self.size_pressure) {
self.pressures.push(pressure);
}
}
@@ -236,6 +245,19 @@ impl GestureTool {
self.shift_line = None;
}
/// 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.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
/// along it (a mouse always presses fully).
fn stroke_json(&self) -> Value {
@@ -374,8 +396,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
@@ -383,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 => {
@@ -617,12 +648,22 @@ 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;
}
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.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::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,9 +732,22 @@ 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}),
"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,
}
}
@@ -701,6 +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.varies_size() => match v.as_str() {
Some("pressure") => self.size_pressure = true,
Some("fixed") => self.size_pressure = false,
_ => {}
},
"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),
+5 -2
View File
@@ -313,8 +313,11 @@ fn blob_brush_sends_keep_selected_and_merge_only_with_selection() {
"{acts:?}"
);
}
// The Eraser keeps Size, Angle and Roundness: a round tip by default.
assert_eq!(GestureTool::new("eraser").options(), json!({"size": 10.0, "angle": 0.0, "roundness": 100.0}));
// The Eraser keeps Size (Fixed by default), Angle and Roundness: 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})
);
}
/// The Blob Brush has a Fidelity, 1.5 pt by default and 0.01 to 100 pt, and its stroke sends it.
+1 -1
View File
@@ -39,7 +39,7 @@ const ROWS: &[Row] = &[
// Paintbrush has none of them).
(&["pencil"], None, &["altSmooth", "livePreview", "liveFit"]),
(&["smooth"], None, &["fidelity"]),
(&["blobBrush", "eraser"], None, &["size"]),
(&["blobBrush", "eraser"], None, &["size", "sizeMode", "sizeVariation"]),
(&["blobBrush"], None, &["fidelity", "keepSelected", "mergeOnlySelection"]),
// Eraser Tool Options' tip shape.
(&["eraser"], None, &["angle", "roundness"]),
+32 -4
View File
@@ -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 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};
@@ -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 and the Eraser's Size mode menu.
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));
}
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") {
@@ -313,6 +313,30 @@ 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 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_and_eraser_size_can_follow_the_pen_pressure() {
let mut app = app();
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}");
}
#[test]
fn the_eraser_options_set_the_tips_angle_and_roundness() {
let mut app = app();
+2 -2
View File
@@ -2606,8 +2606,8 @@ These commands refuse a string or another value that is not a number with `` `dy
naming the key; the document does not change and no undo step is recorded: `t`, `dx` and `dy` in
`path.reshapeSegment` and `object.blend.spine.reshapeSegment`; `x`, `y` and `t` in `path.curvatureEdit`; `x` and `y`
in `path.convertAnchor`; `t` in `path.insertAnchor`; `fidelity` and the `within` of `redraw` in `path.freehand` (and in
`brush.freehand`, which runs it); `size`, `angle` and `roundness` in `path.eraseRegion`;
`size` and `fidelity` in `path.blob`; `width` in `path.eraseSegments`;
`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
an `x` without a `y`, or a `y` without an `x`, with "x and y must be given together". `path.freehand`,
File diff suppressed because one or more lines are too long
+3 -1
View File
File diff suppressed because one or more lines are too long