mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-30 04:37:29 -06:00
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:
@@ -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
|
||||
),
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user