Files
vectorcraft/crates/tools/src/draw2/gesture.rs
T
2026-10-29 10:11:42 +07:00

730 lines
35 KiB
Rust

//! Freehand gesture tools: Pencil (N), Paintbrush (B), Blob Brush (Shift+B), Eraser (Shift+E),
//! Knife, Smooth, Path Eraser and Join.
//!
//! Pencil and Paintbrush preview the fitted path live (`path.freehand` re-applied on the snapshot)
//! and commit on release; with Live curve fitting off, the Pencil's preview joins the stroke's
//! points with straight segments and the release fits them; Alt on release (or ending near the start) closes the path, and starting
//! near an end of a selected open path continues it. Shift held at the press draws a straight
//! Pencil line at 45° steps from the Constrain Angle instead, and Alt held at the press (with Alt
//! 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
//! 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.
use serde_json::{Value, json};
use vectorcraft_doc::{NodeId, NodeKind};
use vectorcraft_geom::Point;
use super::{FEEDBACK, eraser_area, points_json, polyline};
use crate::guides::DrawSnap;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
/// How far from its drag the Smooth tool smooths, in screen pixels. The Pencil uses the same
/// radius while Alt switches it to the Smooth tool.
const SMOOTH_RADIUS: f64 = 12.0;
pub struct GestureTool {
/// How many points the last preview of the stroke had ([`crate::previews_at`]).
previewed: usize,
id: &'static str,
points: Vec<Point>,
/// The pen pressure of each of `points` (the Paintbrush only).
pressures: Vec<f32>,
active: bool,
began: bool,
/// A release where the pointer last dragged to would close the stroke because it ends near
/// its start (Close Paths When Ends Are Within): the cursor shows the closing ring.
near_start: bool,
/// Pencil/Paintbrush continuing a selected open path: (path, continue from its start).
extend: Option<(NodeId, bool)>,
/// A Pencil stroke continuing a selected open path: the other paths selected at the press,
/// whose open ends it connects to.
connect_to: Vec<NodeId>,
/// Pencil/Paintbrush starting on a selected path away from its ends: (path, Edit Selected
/// Paths' distance in points); the stroke redraws the section it ends on (#2850).
redraw: Option<(NodeId, f64)>,
/// Fit tolerance in points (Pencil/Paintbrush/Smooth/Blob Brush "Fidelity").
pub fidelity: f64,
/// Brush / eraser diameter in points (the Eraser's across its tip's long axis).
pub size: 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).
pub roundness: f64,
/// Fill new pencil/brush strokes.
pub fill: bool,
/// "Keep selected": select the path a Pencil, Paintbrush or Blob Brush stroke draws, continues
/// or merges into (on for the Pencil and Paintbrush, off for the Blob Brush).
pub keep_selected: bool,
/// "Alt key toggles to Smooth Tool": holding Alt with the Pencil tool smooths selected paths
/// instead of closing the path (Pencil only, off by default).
pub alt_smooth: bool,
/// "Live preview" (Pencil only, on by default): the stroke shows with its fill and stroke as it
/// is drawn. Off, only a thin line follows the pen while drawing, and the path takes its fill
/// and stroke on release.
pub live_preview: bool,
/// "Live curve fitting": the Pencil's preview fits the stroke while it is drawn; off, the
/// preview shows the points joined by straight segments and the release fits them (Pencil
/// only, on by default).
pub live_fit: bool,
/// Active drag is smoothing with the Pencil or the Blob Brush (Alt held).
smoothing: bool,
/// The pointer has moved from the press point, by any distance (a move too short to add a
/// point counts).
moved: bool,
/// Active Pencil drag draws a straight line (Shift or Alt held at the press): the stroke is
/// the press point and the pointer.
straight: bool,
/// The straight line is Alt's: the pointer is not constrained to 45° steps, and the line's end
/// snaps to Smart Guides as a stroke's end does.
unconstrained: bool,
/// Where the pointer was for each Blob Brush point dragged since Shift went down: the last
/// `shift_raw.len()` of `points`.
shift_raw: Vec<Point>,
/// Where the pointer was for the Shift-constrained point the next [`Self::push`] adds.
shift_next: Option<Point>,
/// The angle of the line the dragged points were last moved onto: while it holds they are on
/// it, and the next drag event need not move them again.
shift_line: Option<f64>,
/// "Merge only with selection": a Blob Brush stroke merges only with selected paths.
pub merge_only_selection: bool,
/// "Edit selected paths within N pixels".
pub edit_within: f64,
/// "Close paths when ends are within N pixels".
pub close_within: f64,
/// Smart Guides for the Pencil's ends.
snap: DrawSnap,
}
/// The paths a stroke can redraw for selected object `id`: the path itself, or a compound path's
/// member paths.
fn redraw_targets(cx: &ToolContext, id: NodeId) -> Vec<NodeId> {
let Some(n) = cx.doc.node(id) else { return vec![] };
match &n.kind {
NodeKind::Path { .. } => vec![id],
NodeKind::Compound { children, .. } => children.iter().filter(|c| c.path_data().is_some()).map(|c| c.id).collect(),
_ => vec![],
}
}
impl GestureTool {
pub fn new(id: &str) -> Self {
let id: &'static str = match id {
"paintbrush" => "paintbrush",
"blobBrush" => "blobBrush",
"eraser" => "eraser",
"knife" => "knife",
"smooth" => "smooth",
"pathEraser" => "pathEraser",
"join" => "join",
_ => "pencil",
};
let fidelity = if id == "smooth" { 2.5 } else { 1.5 };
Self {
id,
previewed: 0,
points: vec![],
pressures: vec![],
active: false,
began: false,
near_start: false,
extend: None,
connect_to: vec![],
redraw: None,
fidelity,
size: 10.0,
angle: 0.0,
roundness: 100.0,
fill: false,
keep_selected: id != "blobBrush",
// Off, as Pencil Tool Options ships it: Alt keeps drawing a closed path until it is on.
alt_smooth: false,
live_preview: true,
live_fit: true,
smoothing: false,
moved: false,
straight: false,
unconstrained: false,
shift_raw: vec![],
shift_next: None,
shift_line: None,
merge_only_selection: false,
edit_within: 12.0,
close_within: 15.0,
snap: DrawSnap::default(),
}
}
/// Do the ends of its strokes snap (the Pencil)?
fn snaps(&self) -> bool {
self.id == "pencil"
}
fn draws_path(&self) -> bool {
matches!(self.id, "pencil" | "paintbrush")
}
/// Does a stroke show as the path it draws while it is drawn? Not with the Pencil's Live preview
/// off: then the drag shows as a thin line (an overlay) until the release.
fn previews(&self) -> bool {
self.id != "pencil" || self.live_preview
}
/// Add a stroke sample.
fn push(&mut self, p: Point, pressure: f32) {
let raw = self.shift_next.take();
if self.points.is_empty() {
self.shift_raw.clear();
} else if let Some(raw) = raw {
self.shift_raw.push(raw);
}
self.points.push(p);
if self.id == "paintbrush" {
self.pressures.push(pressure);
}
}
/// Would a press with `m` held draw a straight Pencil line? Shift or Alt held, unless Alt lends
/// the Smooth tool: the rule `PointerKind::Down` sets `straight` by.
fn straight_at_press(&self, m: Mods) -> bool {
self.id == "pencil" && !(self.alt_smooth && m.alt) && (m.shift || m.alt)
}
/// Would Alt held now switch the Blob Brush to the Smooth tool? It does before a press, and
/// during a drag that has not moved from the press point yet.
fn blob_alt_smooths(&self, m: Mods) -> bool {
self.id == "blobBrush" && m.alt && !self.moved
}
/// Where the stroke takes a pointer at `p`. For the Blob Brush with Shift held, that is the
/// horizontal or vertical line through the press point that is nearer in angle to `p` (90°
/// steps from the Constrain Angle), at `p`'s distance from the press point. The points dragged
/// since Shift went down move to that line too, each at its pointer position's distance and on
/// that position's side of the press point; a pointer at the press point leaves them on their
/// line. Without Shift, or while the Blob Brush smooths, the stroke takes `p`.
fn constrained(&mut self, cx: &ToolContext, p: Point, m: Mods) -> Point {
self.shift_next = None;
if self.straight && self.unconstrained {
return p;
}
if self.straight
&& let Some(&start) = self.points.first()
{
return start + vectorcraft_geom::constrain_angle_from(p - start, 45.0, cx.constrain_angle);
}
let Some(&start) = self.points.first().filter(|_| self.id == "blobBrush" && m.shift && !self.smoothing) else {
self.shift_raw.clear();
self.shift_line = None;
return p;
};
self.shift_next = Some(p);
// The press point is on both lines: the points dragged before stay on theirs.
if (p - start).hypot() < vectorcraft_geom::EPS {
return start;
}
let d = vectorcraft_geom::constrain_angle_from(p - start, 90.0, cx.constrain_angle);
// `line` is the line's angle, measured counterclockwise on screen as the Constrain Angle is measured.
let line = (-d.y).atan2(d.x).to_degrees();
let on_line = |q: Point| start + vectorcraft_geom::constrain_angle_from(q - start, 180.0, line);
if self.shift_line != Some(line) {
for (q, raw) in self.points.iter_mut().skip(1).rev().zip(self.shift_raw.iter().rev()) {
*q = on_line(*raw);
}
self.shift_line = Some(line);
}
on_line(p)
}
/// Forget the stroke.
fn clear(&mut self) {
self.moved = false;
self.points.clear();
self.pressures.clear();
self.previewed = 0;
self.shift_line = None;
}
/// 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 {
if self.pressures.len() == self.points.len() && self.pressures.iter().any(|p| *p < 1.0) {
Value::Array(self.points.iter().zip(&self.pressures).map(|(p, f)| json!([p.x, p.y, (f64::from(*f) * 1000.0).round() / 1000.0])).collect())
} else {
points_json(&self.points)
}
}
fn label(&self) -> &'static str {
match self.id {
"paintbrush" => "Paintbrush",
"blobBrush" => "Blob Brush",
"eraser" => "Eraser",
"knife" => "Knife",
"smooth" => "Smooth",
"pathEraser" => "Path Eraser",
"join" => "Join",
_ => "Pencil",
}
}
/// The selected open path whose end is near `p` (Edit Selected Paths, off at 0 pixels), and
/// that end.
fn find_extend(&self, cx: &ToolContext, p: Point) -> Option<(NodeId, bool, Point)> {
self.find_end(cx, p, &cx.selection.objects)
}
/// [`Self::find_extend`] among `ids`.
fn find_end(&self, cx: &ToolContext, p: Point, ids: &[NodeId]) -> Option<(NodeId, bool, Point)> {
if self.edit_within <= 0.0 {
return None;
}
let tol = cx.tol(self.edit_within);
let editable = cx.doc.editable();
for id in ids {
let Some(pd) = cx.doc.node(*id).and_then(|n| n.path_data()) else { continue };
if !editable(*id) {
continue;
}
let Some(sp) = pd.subpaths.iter().rev().find(|s| !s.closed && !s.anchors.is_empty()) else { continue };
let (first, last) = (sp.anchors[0].p, sp.anchors[sp.anchors.len() - 1].p);
if p.distance(last) <= tol {
return Some((*id, false, last));
}
if p.distance(first) <= tol {
return Some((*id, true, first));
}
}
None
}
/// The selected path whose outline is near `p` (Edit Selected Paths, off at 0 pixels).
fn find_redraw(&self, cx: &ToolContext, p: Point) -> Option<(NodeId, f64)> {
let tol = cx.tol(self.edit_within);
if self.edit_within <= 0.0 || !tol.is_finite() {
return None;
}
// A selected compound path (a shape with a hole) redraws the member path the stroke starts
// on, the nearest of them: a compound path has no path of its own.
cx.selection.objects.iter().find_map(|id| {
redraw_targets(cx, *id)
.into_iter()
.filter(|m| cx.doc.is_editable(*m))
.filter_map(|m| Some((m, cx.doc.node(m)?.path_data()?.nearest(p)?.4)))
.filter(|(_, d)| *d <= tol)
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(m, _)| (m, tol))
})
}
/// Does a stroke that ends at `end` end near its start (Close Paths When Ends Are Within, off
/// at 0 pixels)?
fn ends_near_start(&self, cx: &ToolContext, end: Point) -> bool {
self.close_within > 0.0 && self.points.len() > 2 && self.points[0].distance(end) <= cx.tol(self.close_within)
}
/// Does a release now, with the stroke ending at `end`, close a freehand stroke because it ends
/// near its start (`finish`)? A stroke that redraws a selected path closes only when the engine
/// finds it does not end on that path, so it counts as not closing.
fn release_closes_near_start(&self, cx: &ToolContext, end: Point) -> bool {
self.draws_path() && !self.smoothing && !self.straight && self.extend.is_none() && self.redraw.is_none() && self.ends_near_start(cx, end)
}
/// Parameters of `path.freehand` for the current stroke.
pub fn freehand_params(&self, closed: bool) -> Value {
let mut v = json!({
"points": self.stroke_json(),
"fidelity": self.fidelity,
"closed": closed,
"style": if self.id == "paintbrush" { "brush" } else { "pencil" },
"fill": self.fill,
"keepSelected": self.keep_selected,
});
if let Some((id, start)) = self.extend {
v["extend"] = json!({"id": id.0, "end": if start { "start" } else { "end" }});
} else if let Some((id, within)) = self.redraw {
v["redraw"] = json!({"id": id.0, "within": within});
}
v
}
fn finish(&mut self, cx: &ToolContext, m: Mods) -> Vec<Action> {
let pts = &self.points;
let has_sel = !cx.selection.is_empty();
let tol = cx.tol(self.edit_within);
let radius = cx.tol(SMOOTH_RADIUS);
if self.smoothing {
if has_sel && pts.len() >= 2 {
return vec![Action::Exec(
"path.smoothRegion".into(),
json!({"points": points_json(pts), "radius": radius, "fidelity": self.fidelity}),
)];
}
return vec![];
}
match self.id {
"pencil" | "paintbrush" => {
if !self.began {
return vec![];
}
// Close Paths When Ends Are Within is off at 0 pixels.
let near = pts.last().is_some_and(|&end| self.ends_near_start(cx, end));
// A straight line has two points: closing it would draw it back over itself.
let closed = !self.straight && ((m.alt && !self.alt_smooth) || (self.extend.is_none() && near));
let mut params = self.freehand_params(closed);
// A Pencil stroke that continues a selected path and ends on an open end of another
// path selected at the press connects the two.
let connect = pts.last().and_then(|&end| self.find_end(cx, end, &self.connect_to));
if let Some((other, at_start, _)) = connect {
params["connect"] = json!({"id": other.0, "end": if at_start { "start" } else { "end" }});
}
vec![Action::Preview("path.freehand".into(), params), Action::Commit]
}
"blobBrush" => vec![Action::Exec(
"path.blob".into(),
json!({
"points": points_json(pts),
"size": self.size,
"fidelity": self.fidelity,
"keepSelected": self.keep_selected,
"mergeOnlySelection": self.merge_only_selection
}),
)],
"eraser" => vec![Action::Exec(
"path.eraseRegion".into(),
json!({"points": points_json(pts), "size": self.size, "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 => {
vec![Action::Exec("path.smoothRegion".into(), json!({"points": points_json(pts), "radius": radius, "fidelity": self.fidelity}))]
}
"pathEraser" if has_sel => vec![Action::Exec("path.eraseSegments".into(), json!({"points": points_json(pts), "width": cx.tol(8.0)}))],
"join" if pts.len() >= 2 => vec![Action::Exec("path.joinScrub".into(), json!({"points": points_json(pts), "tolerance": tol}))],
_ => vec![],
}
}
}
impl Tool for GestureTool {
fn id(&self) -> &'static str {
self.id
}
fn busy(&self) -> bool {
self.active
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
match ev.kind {
PointerKind::Move if self.snaps() => {
self.snap.hover(cx, p, &[], None);
vec![]
}
PointerKind::Down => {
self.active = true;
self.began = false;
self.near_start = false;
// Alt switches the Pencil to the Smooth tool while Alt key toggles to Smooth Tool is on, and always
// switches the Blob Brush.
self.smoothing = ev.mods.alt && (self.id == "blobBrush" || (self.id == "pencil" && self.alt_smooth));
// Alt draws a straight line only while it doesn't lend the Smooth tool; with Shift
// too, the line keeps Shift's 45° steps.
self.straight = self.id == "pencil" && !self.smoothing && (ev.mods.shift || ev.mods.alt);
self.unconstrained = self.straight && !ev.mods.shift;
let extend = if self.draws_path() && !self.smoothing { self.find_extend(cx, p) } else { None };
self.extend = extend.map(|(id, at_start, _)| (id, at_start));
// The stroke's previews select the path it continues, so the others are kept now.
self.connect_to = match self.extend {
Some((id, _)) if self.id == "pencil" => cx.selection.objects.iter().copied().filter(|o| *o != id).collect(),
_ => vec![],
};
self.redraw = if self.draws_path() && !self.smoothing && self.extend.is_none() { self.find_redraw(cx, p) } else { None };
// A path continued starts at its end, one redrawn on it; a new one where it snaps.
// A straight line is constrained from the end it continues, so it starts there.
let start = if let Some((_, _, end)) = extend.filter(|_| self.straight) {
end
} else if self.snaps() && !self.smoothing && self.extend.is_none() && self.redraw.is_none() {
self.snap.press(cx, p, &[], None)
} else {
p
};
self.clear();
self.push(start, ev.pressure);
vec![]
}
PointerKind::Drag => {
if !self.active {
return vec![];
}
if (!self.began && !self.straight && self.id == "pencil" && self.alt_smooth && ev.mods.alt) || self.blob_alt_smooths(ev.mods) {
self.smoothing = true;
}
self.moved |= self.points.first().is_some_and(|start| p.distance(*start) > vectorcraft_geom::EPS);
let p = self.constrained(cx, p, ev.mods);
let mut end = p;
if self.snaps() && !self.smoothing && !self.straight {
// Where the stroke would end: shown, taken on release.
end = self.snap.drag(cx, p, None);
}
let p = if self.straight && self.unconstrained && self.snaps() {
// Alt's line ends where a stroke would: shown, taken on release.
self.snap.drag(cx, p, None)
} else {
p
};
if self.straight {
if !self.unconstrained {
// The press's Smart Guides would stay on screen: the line's end doesn't snap.
self.snap.clear();
}
// The line runs from the press point to the pointer, wherever the drag went.
if self.points.first().is_some_and(|s| s.distance(p) < cx.tol(1.0)) {
// Back at the press point there is no line: the one shown goes.
self.points.truncate(1);
return if std::mem::take(&mut self.began) { vec![Action::Cancel] } else { vec![] };
}
self.points.truncate(1);
} else if self.points.last().is_some_and(|l| l.distance(p) < cx.tol(1.0)) || self.points.len() >= crate::MAX_STROKE_SAMPLES {
// A release here keeps the last point unless it snaps (`Up`), so the ring
// follows the point `finish` will test.
let kept = self.points.last().copied().filter(|l| l.distance(p) < cx.tol(1.0) && !self.snaps());
let release_end = kept.unwrap_or(end);
self.near_start = self.release_closes_near_start(cx, release_end);
return vec![];
}
self.push(p, ev.pressure);
self.near_start = self.release_closes_near_start(cx, end);
if !self.draws_path() || self.smoothing {
return vec![];
}
let mut out = vec![];
if !self.began {
self.began = true;
out.push(Action::Begin(self.label().into()));
}
// With the Pencil's Live preview off a thin line shows instead (`overlays`).
if self.previews() && crate::previews_at(self.points.len()) {
self.previewed = self.points.len();
let mut v = self.freehand_params(false);
if !self.live_fit {
// Live curve fitting off: the release fits the stroke (`finish`).
v["fit"] = json!(false);
}
out.push(Action::Preview("path.freehand".into(), v));
}
out
}
PointerKind::Up => {
if !self.active {
return vec![];
}
// A release away from the press point counts as movement.
self.moved |= self.points.first().is_some_and(|start| p.distance(*start) > vectorcraft_geom::EPS);
// Alt pressed while the pointer stood still at the press point switches the release too.
if self.blob_alt_smooths(ev.mods) {
self.smoothing = true;
}
let p = self.constrained(cx, p, ev.mods);
let end = if self.snaps() && !self.smoothing && (!self.straight || self.unconstrained) { self.snap.drag(cx, p, None) } else { p };
self.snap.clear();
let mut cancel = false;
if self.straight {
self.points.truncate(1);
if self.points.first().is_some_and(|s| s.distance(end) >= cx.tol(1.0)) {
self.push(end, ev.pressure);
} else {
cancel = self.began;
}
} else if self.points.last().is_some_and(|l| l.distance(p) >= cx.tol(1.0)) {
self.push(end, ev.pressure);
} else if self.snaps()
&& !self.smoothing
&& self.points.len() > 1
&& let Some(last) = self.points.last_mut()
{
// The last step was where the button went up: it moves where that snaps.
*last = end;
}
let out = if cancel { vec![Action::Cancel] } else { self.finish(cx, ev.mods) };
self.active = false;
self.began = false;
self.near_start = false;
self.smoothing = false;
self.straight = false;
self.unconstrained = false;
self.clear();
self.extend = None;
self.connect_to.clear();
self.redraw = None;
out
}
_ => vec![],
}
}
/// The freehand tools build their stroke from every position the pointer passed; the Join tool
/// only needs where its scribble crosses the ends.
fn wants_samples(&self) -> bool {
matches!(self.id, "pencil" | "paintbrush" | "blobBrush" | "eraser" | "knife" | "smooth" | "pathEraser")
}
/// `[` and `]` are the Blob Brush's and the Eraser's: their size (#1661).
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
matches!(key, ToolKey::BracketLeft | ToolKey::BracketRight) && matches!(self.id, "blobBrush" | "eraser")
}
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _m: Mods) -> Vec<Action> {
match key {
ToolKey::Escape if self.active => {
let began = self.began;
self.snap.clear();
self.active = false;
self.began = false;
self.near_start = false;
self.smoothing = false;
self.straight = false;
self.unconstrained = false;
self.clear();
if began { vec![Action::Cancel] } else { vec![] }
}
ToolKey::BracketLeft if matches!(self.id, "blobBrush" | "eraser") => {
self.size = (self.size - 1.0).max(1.0);
vec![]
}
ToolKey::BracketRight if matches!(self.id, "blobBrush" | "eraser") => {
self.size = (self.size + 1.0).min(1000.0);
vec![]
}
_ => vec![],
}
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
let began = self.began;
// Drags past the last preview are in the stroke kept, too, and with Live curve fitting
// off the stroke kept is fitted.
let stale = (began && (self.previewed != self.points.len() || !self.live_fit) && self.draws_path() && !self.smoothing)
.then(|| Action::Preview("path.freehand".into(), self.freehand_params(false)));
self.snap.clear();
self.active = false;
self.near_start = false;
self.began = false;
self.smoothing = false;
self.straight = false;
self.unconstrained = false;
self.clear();
if began { stale.into_iter().chain([Action::Commit]).collect() } else { vec![] }
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let mut o = self.snap.guides().to_vec();
// A path being drawn shows itself, except with the Pencil's Live preview off.
if !self.active || self.points.len() < 2 || (self.began && self.previews()) {
return o;
}
// A Blob Brush drag that smooths shows the Smooth tool's thin line.
let width = match self.id {
"blobBrush" | "eraser" if !self.smoothing => (self.size * cx.zoom) as f32,
_ => 1.0,
};
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.
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" });
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();
o.push(Overlay::Measure { p: last + vectorcraft_geom::Vec2::new(cx.tol(12.0), cx.tol(12.0)), text: format!("L: {}", cx.len(length)) });
}
o
}
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
// A drag that smooths, or Alt before a Pencil or Blob Brush stroke has begun to draw (a
// stroke that has begun keeps drawing with Alt).
if self.smoothing || (self.id == "pencil" && self.alt_smooth && m.alt && !self.began) || self.blob_alt_smooths(m) {
return Cursor::Smooth;
}
// Before a press that would draw a straight line: over an end of a selected open path,
// the line continues that path from the end (`PointerKind::Down`).
if !self.active && self.straight_at_press(m) {
return if self.find_extend(cx, p).is_some() { Cursor::PencilContinue } else { Cursor::PencilStraight };
}
// Alt held at the release closes the path (`finish`).
// A straight line stays open, so its release has nothing to close, and before a press Alt
// gives the Pencil a straight line.
if self.draws_path() && !self.alt_smooth && m.alt && !self.straight && (self.active || self.id != "pencil") {
return if self.id == "paintbrush" { Cursor::PaintbrushClose } else { Cursor::PencilClose };
}
// A release near a freehand stroke's start closes it too (`finish`): the ring shows while
// the pointer is there.
if self.draws_path() && self.active && self.near_start {
return if self.id == "paintbrush" { Cursor::PaintbrushClose } else { Cursor::PencilClose };
}
match self.id {
"paintbrush" => Cursor::Paintbrush,
"blobBrush" => Cursor::BlobBrush,
"eraser" => Cursor::Eraser,
"knife" => Cursor::Knife,
"smooth" => Cursor::Smooth,
"pathEraser" => Cursor::PathEraser,
"join" => Cursor::Join,
// Before a press that draws a new path the Pencil shows its star; over a selected path
// that the press continues from an end or redraws (`PointerKind::Down`), it does not.
_ if !self.active && self.find_extend(cx, p).is_none() && self.find_redraw(cx, p).is_none() => Cursor::PencilNew,
_ => Cursor::Pencil,
}
}
fn options(&self) -> Value {
match self.id {
"pencil" => {
json!({
"fidelity": self.fidelity,
"fill": self.fill,
"altSmooth": self.alt_smooth,
"livePreview": self.live_preview,
"liveFit": self.live_fit,
"keepSelected": self.keep_selected,
"editWithin": self.edit_within,
"closeWithin": self.close_within
})
}
"paintbrush" => {
json!({
"fidelity": self.fidelity,
"fill": self.fill,
"keepSelected": self.keep_selected,
"editWithin": self.edit_within,
"closeWithin": self.close_within
})
}
"smooth" => json!({"fidelity": self.fidelity}),
"blobBrush" => {
json!({"fidelity": self.fidelity, "size": self.size, "keepSelected": self.keep_selected, "mergeOnlySelection": self.merge_only_selection})
}
"eraser" => json!({"size": self.size, "angle": self.angle, "roundness": self.roundness}),
_ => Value::Null,
}
}
fn set_option(&mut self, key: &str, v: &Value) {
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),
"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),
"altSmooth" => self.alt_smooth = v.as_bool().unwrap_or(self.alt_smooth),
"livePreview" if self.id == "pencil" => self.live_preview = v.as_bool().unwrap_or(self.live_preview),
"liveFit" if self.id == "pencil" => self.live_fit = v.as_bool().unwrap_or(self.live_fit),
"keepSelected" => self.keep_selected = v.as_bool().unwrap_or(self.keep_selected),
"mergeOnlySelection" => self.merge_only_selection = v.as_bool().unwrap_or(self.merge_only_selection),
"editWithin" => self.edit_within = v.as_f64().unwrap_or(self.edit_within).clamp(0.0, 100.0),
"closeWithin" => self.close_within = v.as_f64().unwrap_or(self.close_within).clamp(0.0, 100.0),
_ => {}
}
}
}
#[cfg(test)]
mod tests;