mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-30 20:58:00 -06:00
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:
@@ -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
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, ®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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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::<Vec<_>>();
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user