Eraser: Size follows the pen pressure too (#3961)

Eraser Tool Options' Size takes Fixed or Pressure and a Variation as the
Blob Brush's does: the tool records the pen, and path.eraseRegion outlines
the squashed drag at the pen's widths, stretched back to the tip's angle
and roundness. The pressure parsing and outline move to draw2's common
helpers, shared by both commands.
This commit is contained in:
bflatastic
2026-10-30 01:13:20 +07:00
parent 4a89ba84f8
commit 71fbdf5ff0
11 changed files with 154 additions and 89 deletions
+1 -1
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
),
+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));
}
}
+4 -30
View File
@@ -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<PathData> {
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<Value> {
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<Value> {
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<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);
@@ -426,7 +400,7 @@ pub(super) fn blob(s: &mut Session, p: &Value) -> Result<Value> {
}
}
// 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)