Eraser: Angle and Roundness in Eraser Tool Options shape the tip

Squashed from #4180 onto main.
This commit is contained in:
dotCooCoo
2026-10-26 09:09:46 +07:00
committed by bflatastic
parent 7117c82cab
commit 3d8c40f500
12 changed files with 284 additions and 31 deletions
+6 -4
View File
@@ -209,8 +209,8 @@ fn split_by_line(pd: &PathData, rule: FillRule, axis: &Axis) -> Option<Vec<PathD
return Some(components(&a).into_iter().chain(components(&b)).collect());
}
// Open paths: the parts on each side (each pass removes the other side).
let a = erase_outline(pd, &|q| axis.side(q) < 0.0)?;
let b = erase_outline(pd, &|q| axis.side(q) >= 0.0)?;
let a = erase_outline(pd, f64::INFINITY, &|q| axis.side(q) < 0.0)?;
let b = erase_outline(pd, f64::INFINITY, &|q| axis.side(q) >= 0.0)?;
Some(if pd.subpaths.len() == 1 {
a.into_iter().chain(b).collect()
} else {
@@ -249,7 +249,9 @@ fn crop(pd: &PathData, rule: FillRule, rect: Rect) -> Option<Vec<PathData>> {
return Some(if r.is_empty() { vec![] } else { vec![r] });
}
let inside = rect.inflate(1e-9, 1e-9);
let pieces = erase_outline(pd, &|q| !(q.x >= inside.x0 && q.x <= inside.x1 && q.y >= inside.y0 && q.y <= inside.y1))?;
// Sampled at least every half of the rectangle's shorter side, so a line through it is found.
let step = rect.width().min(rect.height()) / 2.0;
let pieces = erase_outline(pd, step, &|q| !(q.x >= inside.x0 && q.x <= inside.x1 && q.y >= inside.y0 && q.y <= inside.y1))?;
Some(if pd.subpaths.len() == 1 || pieces.is_empty() {
pieces
} else {
@@ -317,7 +319,7 @@ fn mirror(pd: &PathData, rule: FillRule, axis: &Axis, sign: f64) -> Vec<PathData
let joined = po::boolean(&kept, FillRule::NonZero, &kept.transformed(m), FillRule::NonZero, BoolOp::Union);
return if joined.is_empty() { vec![] } else { vec![joined] };
}
let kept = match erase_outline(pd, &|q| axis.side(q) * sign < 0.0) {
let kept = match erase_outline(pd, f64::INFINITY, &|q| axis.side(q) * sign < 0.0) {
Some(pieces) => pieces.into_iter().flat_map(|p| p.subpaths).collect(),
None => pd.subpaths.clone(),
};
+30 -12
View File
@@ -12,6 +12,7 @@ use vectorcraft_geom::hit::fill_contains;
use vectorcraft_geom::{Anchor, AnchorKind, BezPath, FillRule, PathData, Point, Rect, SubPath, Vec2};
use vectorcraft_pathops as po;
use vectorcraft_pathops::{BoolOp, Cap, Join, SimplifyOptions};
use vectorcraft_tools::draw2::{eraser_area, eraser_tip};
use super::create::{add_look, insert_look};
use super::newart::NewArt;
@@ -107,7 +108,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Eraser",
[],
None,
"{points: [[x,y]…], size?: pt (10)} erase a round-brush stroke from paths (selected, or all) → {ids}",
"{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",
has_doc,
erase_region
),
@@ -410,18 +411,26 @@ pub(super) fn components(pd: &PathData) -> Vec<PathData> {
groups.into_iter().map(PathData::new).collect()
}
/// The pieces of `sp` where `remove(point)` is false. `None` when nothing is removed.
fn cut_subpath(sp: &SubPath, remove: &dyn Fn(Point) -> bool) -> Option<Vec<SubPath>> {
/// The pieces of `sp` where `remove(point)` is false. `None` when nothing is removed. Each segment
/// is sampled at least every `step` pt (and at least 24 times), so a removed stretch at least
/// `step` long is found.
fn cut_subpath(sp: &SubPath, step: f64, remove: &dyn Fn(Point) -> bool) -> Option<Vec<SubPath>> {
let nseg = sp.segment_count();
if nseg == 0 {
return sp.anchors.first().filter(|a| remove(a.p)).map(|_| vec![]);
}
const K: usize = 24;
const MAX_SAMPLES: usize = 4096;
let mut runs: Vec<Vec<(usize, f64, f64)>> = vec![];
let mut cur: Vec<(usize, f64, f64)> = vec![];
let mut any = false;
for i in 0..nseg {
let c = sp.segment(i);
// The curve moves at most 3 times its longest control-polygon edge per unit of t, so with
// n samples, neighboring samples are at most 3 · edge / n apart.
let edge = (c.p1 - c.p0).hypot().max((c.p2 - c.p1).hypot()).max((c.p3 - c.p2).hypot());
let wanted = (3.0 * edge / step).ceil();
let samples = if wanted.is_finite() && wanted > K as f64 { (wanted as usize).min(MAX_SAMPLES) } else { K };
let mut prev_t = 0.0;
let mut prev_rm = remove(c.eval(0.0));
let mut start = if prev_rm { None } else { Some(0.0) };
@@ -431,8 +440,8 @@ fn cut_subpath(sp: &SubPath, remove: &dyn Fn(Point) -> bool) -> Option<Vec<SubPa
runs.push(std::mem::take(&mut cur));
}
}
for k in 1..=K {
let t = k as f64 / K as f64;
for k in 1..=samples {
let t = k as f64 / samples as f64;
let rm = remove(c.eval(t));
if rm != prev_rm {
let (mut lo, mut hi) = (prev_t, t);
@@ -500,13 +509,14 @@ fn cut_subpath(sp: &SubPath, remove: &dyn Fn(Point) -> bool) -> Option<Vec<SubPa
Some(out)
}
/// Erase from an outline path (open or closed) where `remove` holds. Returns the new geometry per
/// output object (one object per piece when the path had a single subpath), or None if unchanged.
pub(super) fn erase_outline(pd: &PathData, remove: &dyn Fn(Point) -> bool) -> Option<Vec<PathData>> {
/// Erase from an outline path (open or closed) where `remove` holds, sampling each segment at least
/// every `step` pt (`f64::INFINITY` for the fixed sampling). Returns the new geometry per output
/// object (one object per piece when the path had a single subpath), or None if unchanged.
pub(super) fn erase_outline(pd: &PathData, step: f64, remove: &dyn Fn(Point) -> bool) -> Option<Vec<PathData>> {
let mut changed = false;
let mut subs = vec![];
for sp in &pd.subpaths {
match cut_subpath(sp, remove) {
match cut_subpath(sp, step, remove) {
Some(pieces) => {
changed = true;
subs.extend(pieces);
@@ -1159,9 +1169,17 @@ fn erase_region(s: &mut Session, p: &Value) -> Result<Value> {
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 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;
let st = s.doc()?;
let region = brush_region(&pts, size);
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 round: Vec<Point> = pts.iter().map(|q| squash * *q).collect();
let mut plan: Vec<(NodeId, Vec<PathData>)> = vec![];
for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, r)) {
let (pd, rule) = path_of(&st.doc, id)?;
@@ -1170,7 +1188,7 @@ fn erase_region(s: &mut Session, p: &Value) -> Result<Value> {
continue;
}
plan.push((id, components(&po::boolean(&pd, rule, &region, FillRule::NonZero, BoolOp::Difference))));
} else if let Some(pieces) = erase_outline(&pd, &|q| dist_to_polyline(&pts, q) <= r) {
} else if let Some(pieces) = erase_outline(&pd, short / 2.0, &|q| dist_to_polyline(&round, squash * q) <= r) {
plan.push((id, pieces));
}
}
@@ -1205,7 +1223,7 @@ fn erase_segments(s: &mut Session, p: &Value) -> Result<Value> {
let mut plan = vec![];
for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, w)) {
let (pd, _) = path_of(&st.doc, id)?;
if let Some(pieces) = erase_outline(&pd, &|q| dist_to_polyline(&pts, q) <= w / 2.0) {
if let Some(pieces) = erase_outline(&pd, w / 2.0, &|q| dist_to_polyline(&pts, q) <= w / 2.0) {
plan.push((id, pieces));
}
}