mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 21:57:04 +00:00
1718 lines
83 KiB
Rust
1718 lines
83 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, and
|
||
//! the pen's tilt and barrel rotation where the pen reads them (`[x, y, pressure, tiltX, tiltY,
|
||
//! rotation]` points, `null` for what a point lacks) for brushes that vary with them. The Blob
|
||
//! Brush and Eraser also record pressure when 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.
|
||
//! Alt held at a Paintbrush press with a selected path also smooths; Alt pressed after its stroke
|
||
//! begins closes the path on release.
|
||
//! Alt held at the Knife's press makes it cut along the straight line from the press point to the
|
||
//! pointer, even if Alt is released during the drag. Shift on each drag or release constrains that
|
||
//! line to 45° steps from the canvas axes.
|
||
|
||
mod area;
|
||
|
||
use std::cell::Cell;
|
||
use std::collections::{BTreeMap, BTreeSet};
|
||
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_doc::{NodeId, NodeKind};
|
||
use vectorcraft_geom::{Affine, BezPath, FillRule, ParamCurve, PathData, PathSeg, Point, Rect, Vec2, shapes};
|
||
use vectorcraft_pathops as po;
|
||
|
||
use super::{FEEDBACK, blob_area, blob_tip, eraser_tip, points_json, polyline};
|
||
use crate::guides::DrawSnap;
|
||
use crate::{Action, Cursor, Mods, Overlay, PenPose, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||
use area::{AreaKey, AreaPreview};
|
||
|
||
/// How far from its drag the Smooth tool smooths, in screen pixels. Alt smoothing with the
|
||
/// Pencil, Paintbrush or Blob Brush uses the same radius.
|
||
const SMOOTH_RADIUS: f64 = 12.0;
|
||
|
||
/// The most points of a drag that one step of its band adds, as when the band is outlined again
|
||
/// at a new zoom.
|
||
const BAND_STEP: usize = 32;
|
||
/// The most steps one call adds to a band: a band outlined again for a long drag shows in full
|
||
/// over several frames ([`Tool::overlays_pending`]).
|
||
const BAND_STEPS_PER_CALL: usize = 8;
|
||
/// The side of the squares a smoothing drag's band is kept in, in radii: a step of the drag adds
|
||
/// its area only to the squares it reaches, so the work of a step does not grow with the drag.
|
||
const BAND_TILE_RADII: f64 = 8.0;
|
||
/// Where the grid of those squares starts, in squares from the origin, so that the edge of the
|
||
/// band of a drag along whole coordinates does not run along a side of a square.
|
||
const BAND_TILE_OFFSET: f64 = 0.381_966;
|
||
/// The most squares one segment of a drag is checked against: a segment longer than 2,048 squares
|
||
/// is kept outside the squares, and shows as the two sides of its band.
|
||
const BAND_TILE_SAMPLES: f64 = 4096.0;
|
||
/// The side of the cells of a coarser grid, in squares: each cell lists the squares and the long
|
||
/// segments in it, so that a new square or long segment is checked only against those near it.
|
||
const BAND_CELL_TILES: i64 = 1024;
|
||
/// The most points, half a cell apart, along a long segment at which its cells are listed: a
|
||
/// longer segment is listed in no cell and checked against every square and long segment.
|
||
const BAND_CELL_SAMPLES: f64 = 4096.0;
|
||
/// The most work one call does on the band's long segments: comparisons of a long segment with a
|
||
/// square or another long segment, and pieces of an outline tested. The rest waits for the next
|
||
/// call ([`Tool::overlays_pending`]), and the band shown until then is the last one finished.
|
||
const BAND_WORK_PER_CALL: usize = 20_000;
|
||
|
||
/// The finest Fidelity (screen pixels) a Pencil or Paintbrush stroke is fitted at while it is drawn:
|
||
/// a fit's cost grows as its Fidelity shrinks (at 0.01 pt a long pen stroke lagged, #4565), and
|
||
/// a preview within half a pixel of the stroke looks the same. The release fits it at its Fidelity.
|
||
const PREVIEW_FIDELITY_PX: f64 = 0.5;
|
||
/// The Smooth tool's Fidelity (screen pixels) until Smooth Tool Options sets another.
|
||
const SMOOTH_FIDELITY: f64 = 2.5;
|
||
|
||
/// The `fidelity` (points) the Pencil and the Paintbrush send `path.freehand` for `px` screen pixels
|
||
/// at the current zoom, within the range the command takes
|
||
/// ([`super::MIN_FIT_TOLERANCE`]..[`super::MAX_FIT_TOLERANCE`]).
|
||
fn fit_tolerance(cx: &ToolContext, px: f64) -> f64 {
|
||
cx.tol(px).clamp(super::MIN_FIT_TOLERANCE, super::MAX_FIT_TOLERANCE)
|
||
}
|
||
|
||
pub struct GestureTool {
|
||
/// How many points the last preview of the stroke had ([`crate::previews_at`]); 0 when it was
|
||
/// fitted coarser than the stroke kept ([`PREVIEW_FIDELITY_PX`]).
|
||
previewed: usize,
|
||
id: &'static str,
|
||
points: Vec<Point>,
|
||
/// The pen pressure of each of `points` (the Paintbrush, and the Blob Brush and the Eraser
|
||
/// with `size_pressure`).
|
||
pressures: Vec<f32>,
|
||
/// The pen's tilt and rotation at each of `points` (the Paintbrush only).
|
||
poses: Vec<Option<PenPose>>,
|
||
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,
|
||
/// A release where the pointer last dragged to would connect the continued path to another
|
||
/// selected path's open end (`finish`): the cursor shows the continuation cursor.
|
||
connects: bool,
|
||
/// Pencil/Paintbrush continuing a selected open path: (path, continue from its start).
|
||
extend: Option<(NodeId, bool)>,
|
||
/// The other end of the path `extend` continues: a release near it closes the path.
|
||
close_to: Option<Point>,
|
||
/// 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 (Pencil/Paintbrush/Smooth/Blob Brush "Fidelity"): in screen pixels for the
|
||
/// Pencil and the Paintbrush, so a stroke keeps as many points at any zoom (#5729), and for the
|
||
/// Smooth tool, so a drag smooths as much at any zoom; in points for the Blob Brush.
|
||
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 Blob Brush or Eraser tip's long axis, in degrees counter-clockwise from horizontal.
|
||
pub angle: f64,
|
||
/// The Blob Brush or 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). Paintbrush Alt smoothing with a
|
||
/// selected path and Blob Brush Alt smoothing do not use this toggle.
|
||
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,
|
||
/// The Smooth tool's Fidelity (screen pixels), used when Alt switches the Pencil, Paintbrush or
|
||
/// Blob Brush to Smooth. The engine sets it from Smooth Tool Options (`smoothFidelity`).
|
||
smooth_fidelity: f64,
|
||
/// Active drag is smoothing with the Pencil, Paintbrush or 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), or active Knife
|
||
/// drag cuts along one (Alt held at the press): the stroke is the press point and the pointer.
|
||
straight: bool,
|
||
/// The straight line is not constrained to 45° steps: the Pencil's Alt line, whose end snaps to
|
||
/// Smart Guides as a stroke's end does, or the Knife's line while Shift is up.
|
||
unconstrained: bool,
|
||
/// Axis selected during the current uninterrupted Shift interval, in degrees.
|
||
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 and the press of a straight Knife cut.
|
||
snap: DrawSnap,
|
||
/// Where the pointer went down, before any snap: a straight Knife cut released there cuts
|
||
/// nothing, even when its press snapped elsewhere.
|
||
pressed: Point,
|
||
/// Where the Blob Brush's or the Eraser's tip outline shows: the pointer over the canvas,
|
||
/// unless Cmd/Ctrl lends a selection tool or Alt the Smooth tool.
|
||
tip_at: Option<Point>,
|
||
/// A pen pressed less than fully at a point of the stroke whose pressure is kept.
|
||
light: bool,
|
||
/// The length of the stroke, in points.
|
||
length: f64,
|
||
/// The outline of the area a Blob Brush or Eraser drag covers, as far as it is built
|
||
/// ([`AreaPreview`]).
|
||
area: Cell<Option<AreaPreview>>,
|
||
/// The last [`Tool::overlays`] left part of that outline for later calls.
|
||
area_pending: Cell<bool>,
|
||
/// The band the smoothing drag has shown so far ([`GestureTool::smooth_band`]).
|
||
band: Cell<Option<SmoothBand>>,
|
||
/// The band last shown does not cover the whole drag yet; the next call adds to it.
|
||
band_pending: Cell<bool>,
|
||
}
|
||
|
||
/// The band a smoothing drag smooths, for the drag's first `used` points at `radius`, kept as its
|
||
/// area in each square of a grid that it reaches.
|
||
struct SmoothBand {
|
||
radius: f64,
|
||
used: usize,
|
||
first: Point,
|
||
last: Point,
|
||
tiles: BTreeMap<(i64, i64), BandTile>,
|
||
/// The segments of the drag too long for the squares ([`BAND_TILE_SAMPLES`]).
|
||
long: Vec<LongSegment>,
|
||
/// The squares and long segments listed in each cell of a coarser grid ([`BAND_CELL_TILES`]).
|
||
cells: BTreeMap<(i64, i64), BandCell>,
|
||
/// The long segments listed in no cell ([`BAND_CELL_SAMPLES`]).
|
||
wide: Vec<usize>,
|
||
/// The squares near a long segment whose `cut` is to be made again.
|
||
dirty_tiles: BTreeSet<(i64, i64)>,
|
||
/// The long segments whose `drawn` is to be made again.
|
||
dirty_long: BTreeSet<usize>,
|
||
/// The band changed since `shown` was made.
|
||
changed: bool,
|
||
/// The outline last finished.
|
||
shown: Option<BezPath>,
|
||
/// The work done on long segments in the current call ([`BAND_WORK_PER_CALL`]).
|
||
work: usize,
|
||
}
|
||
|
||
/// The squares and long segments (indexes into [`SmoothBand::long`]) in one cell of the coarser grid.
|
||
#[derive(Default)]
|
||
struct BandCell {
|
||
tiles: Vec<(i64, i64)>,
|
||
long: Vec<usize>,
|
||
}
|
||
|
||
/// The part of a band in one square of the grid, and its outline less the square's sides.
|
||
struct BandTile {
|
||
area: PathData,
|
||
outline: BezPath,
|
||
/// The long segments whose band can reach the square (indexes into [`SmoothBand::long`]).
|
||
near_long: Vec<usize>,
|
||
/// `outline` less its parts inside the band of a long segment in `near_long`.
|
||
cut: BezPath,
|
||
}
|
||
|
||
/// A segment of a drag too long for the squares, from `a` to `b`, with the part of it outside the
|
||
/// squares (`from` and `to`: 0 at `a`, 1 at `b`): the squares hold its band for a square's side
|
||
/// from each end ([`SmoothBand::add_long`]).
|
||
struct LongSegment {
|
||
a: Point,
|
||
b: Point,
|
||
from: f64,
|
||
to: f64,
|
||
/// The squares its band can reach.
|
||
near: BTreeSet<(i64, i64)>,
|
||
/// The other long segments whose band can meet its band.
|
||
meets: Vec<usize>,
|
||
/// The parts of the two sides of its band that show.
|
||
drawn: BezPath,
|
||
}
|
||
|
||
impl LongSegment {
|
||
/// The two sides of its band at `radius`, over its part outside the squares.
|
||
fn sides(&self, radius: f64) -> [vectorcraft_geom::Line; 2] {
|
||
let d = self.b - self.a;
|
||
let s = Vec2::new(-d.y, d.x) * (radius / d.hypot());
|
||
let (p, q) = (self.a.lerp(self.b, self.from), self.a.lerp(self.b, self.to));
|
||
[vectorcraft_geom::Line::new(p + s, q + s), vectorcraft_geom::Line::new(p - s, q - s)]
|
||
}
|
||
}
|
||
|
||
impl SmoothBand {
|
||
/// The band of a drag that starts at `first`, before it has moved.
|
||
fn new(first: Point, radius: f64) -> Self {
|
||
Self {
|
||
radius,
|
||
used: 1,
|
||
first,
|
||
last: first,
|
||
tiles: BTreeMap::new(),
|
||
long: vec![],
|
||
cells: BTreeMap::new(),
|
||
wide: vec![],
|
||
dirty_tiles: BTreeSet::new(),
|
||
dirty_long: BTreeSet::new(),
|
||
changed: true,
|
||
shown: None,
|
||
work: 0,
|
||
}
|
||
}
|
||
|
||
/// The cell of the coarser grid that holds square `key`.
|
||
fn cell_of((ix, iy): (i64, i64)) -> (i64, i64) {
|
||
(ix.div_euclid(BAND_CELL_TILES), iy.div_euclid(BAND_CELL_TILES))
|
||
}
|
||
|
||
/// The cells around points half a cell apart along the segment from `a` to `b`: every cell
|
||
/// holding a square its band can reach, or a point within the radius of its band. `None` for
|
||
/// a segment longer than [`BAND_CELL_SAMPLES`] half cells or reaching far outside any document.
|
||
fn cells_along(&self, a: Point, b: Point) -> Option<BTreeSet<(i64, i64)>> {
|
||
let n = (a.distance(b) / (0.5 * BAND_CELL_TILES as f64 * self.side())).ceil().max(1.0);
|
||
if !n.is_finite() || n > BAND_CELL_SAMPLES {
|
||
return None;
|
||
}
|
||
let mut cells = BTreeSet::new();
|
||
for k in 0..=(n as u32) {
|
||
let (cx, cy) = Self::cell_of(self.tile_of(a.lerp(b, f64::from(k) / n))?);
|
||
for (dx, dy) in [(-1, -1), (0, -1), (1, -1), (-1, 0), (0, 0), (1, 0), (-1, 1), (0, 1), (1, 1)] {
|
||
cells.insert((cx + dx, cy + dy));
|
||
}
|
||
}
|
||
Some(cells)
|
||
}
|
||
|
||
/// Whether the band of the segment from `a` to `b` can reach square `key`.
|
||
fn reaches(&self, key: (i64, i64), a: Point, b: Point) -> bool {
|
||
distance_to_segment(self.tile_rect(key).center(), a, b) <= self.radius + 0.5 * std::f64::consts::SQRT_2 * self.side()
|
||
}
|
||
|
||
/// Whether the segment from `a` to `b` is too long for the squares: longer than
|
||
/// [`BAND_TILE_SAMPLES`] half squares.
|
||
fn too_long(&self, a: Point, b: Point) -> bool {
|
||
let n = a.distance(b) / (0.5 * self.side());
|
||
n.is_finite() && n > BAND_TILE_SAMPLES
|
||
}
|
||
|
||
/// Add the band of the segment from `a` to `b`, too long for the squares: the squares take its
|
||
/// band for a square's side from each end, where they can, and the rest is kept as the
|
||
/// segment.
|
||
fn add_long(&mut self, a: Point, b: Point) {
|
||
let d = b - a;
|
||
let stub = d * (self.side() / d.hypot());
|
||
let from = if self.grow(&[a, a + stub]).is_some() { self.side() / d.hypot() } else { 0.0 };
|
||
let to = if self.grow(&[b - stub, b]).is_some() { 1.0 - self.side() / d.hypot() } else { 1.0 };
|
||
let i = self.long.len();
|
||
// The squares and long segments listed in its cells, or all of them.
|
||
let mut tiles = BTreeSet::new();
|
||
let mut others: BTreeSet<usize> = self.wide.iter().copied().collect();
|
||
match self.cells_along(a, b) {
|
||
Some(cells) => {
|
||
self.work += cells.len();
|
||
for c in cells {
|
||
let cell = self.cells.entry(c).or_default();
|
||
tiles.extend(cell.tiles.iter().copied());
|
||
others.extend(cell.long.iter().copied());
|
||
cell.long.push(i);
|
||
}
|
||
}
|
||
None => {
|
||
tiles.extend(self.tiles.keys().copied());
|
||
others.extend(0..i);
|
||
self.wide.push(i);
|
||
}
|
||
}
|
||
self.work += tiles.len() + others.len();
|
||
let near: BTreeSet<_> = tiles.into_iter().filter(|&k| self.reaches(k, a, b)).collect();
|
||
for k in &near {
|
||
if let Some(t) = self.tiles.get_mut(k) {
|
||
t.near_long.push(i);
|
||
self.dirty_tiles.insert(*k);
|
||
}
|
||
}
|
||
let reach = 2.0 * self.radius + 2e-3 * self.radius;
|
||
let meets: Vec<usize> = others.into_iter().filter(|&j| self.long.get(j).is_some_and(|o| segment_distance(a, b, o.a, o.b) <= reach)).collect();
|
||
for &j in &meets {
|
||
if let Some(o) = self.long.get_mut(j) {
|
||
o.meets.push(i);
|
||
self.dirty_long.insert(j);
|
||
}
|
||
}
|
||
self.long.push(LongSegment { a, b, from, to, near, meets, drawn: BezPath::new() });
|
||
self.dirty_long.insert(i);
|
||
self.last = b;
|
||
self.changed = true;
|
||
}
|
||
|
||
/// The side of the grid's squares.
|
||
fn side(&self) -> f64 {
|
||
BAND_TILE_RADII * self.radius
|
||
}
|
||
|
||
/// The square of the grid `key` names.
|
||
fn tile_rect(&self, (ix, iy): (i64, i64)) -> Rect {
|
||
let s = self.side();
|
||
let (x0, y0) = ((ix as f64 + BAND_TILE_OFFSET) * s, (iy as f64 + BAND_TILE_OFFSET) * s);
|
||
Rect::new(x0, y0, x0 + s, y0 + s)
|
||
}
|
||
|
||
/// The square of the grid that holds `p`; `None` far outside any document.
|
||
fn tile_of(&self, p: Point) -> Option<(i64, i64)> {
|
||
let s = self.side();
|
||
let (x, y) = ((p.x / s - BAND_TILE_OFFSET).floor(), (p.y / s - BAND_TILE_OFFSET).floor());
|
||
(x.abs() < 1e15 && y.abs() < 1e15).then_some((x as i64, y as i64))
|
||
}
|
||
|
||
/// The squares within the radius of `piece`: those around points along it half a square apart.
|
||
fn tiles_near(&self, piece: &[Point]) -> Option<BTreeSet<(i64, i64)>> {
|
||
let mut keys = BTreeSet::new();
|
||
for w in piece.windows(2) {
|
||
let n = (w[0].distance(w[1]) / (0.5 * self.side())).ceil().max(1.0);
|
||
if !n.is_finite() || n > BAND_TILE_SAMPLES {
|
||
return None;
|
||
}
|
||
for k in 0..=(n as u32) {
|
||
let (ix, iy) = self.tile_of(w[0].lerp(w[1], f64::from(k) / n))?;
|
||
for (dx, dy) in [(-1, -1), (0, -1), (1, -1), (-1, 0), (0, 0), (1, 0), (-1, 1), (0, 1), (1, 1)] {
|
||
keys.insert((ix + dx, iy + dy));
|
||
}
|
||
}
|
||
}
|
||
Some(keys)
|
||
}
|
||
|
||
/// Add the area within the radius of `piece`, the drag from the last point covered so far, to
|
||
/// the squares it reaches. `None`, with the band unchanged, when the outlines cannot be
|
||
/// combined.
|
||
fn grow(&mut self, piece: &[Point]) -> Option<()> {
|
||
let (r, nz) = (self.radius, FillRule::NonZero);
|
||
let end = *piece.last()?;
|
||
// Within a hundredth of the radius at any zoom.
|
||
let precision = (0.01 * r).min(po::DEFAULT_PRECISION);
|
||
let keys = self.tiles_near(piece)?;
|
||
let added = blob_area(piece, 2.0 * r, 0.0, 100.0);
|
||
let mut changed = vec![];
|
||
for key in keys {
|
||
let rect = self.tile_rect(key);
|
||
let clip = po::try_boolean(&added, nz, &shapes::rectangle(rect), nz, po::BoolOp::Intersect, precision).ok()?;
|
||
if clip.is_empty() {
|
||
continue;
|
||
}
|
||
let area = match self.tiles.get(&key) {
|
||
Some(t) => po::try_boolean(&t.area, nz, &clip, nz, po::BoolOp::Union, precision).ok().filter(|a| !a.is_empty())?,
|
||
None => clip,
|
||
};
|
||
changed.push((key, area));
|
||
}
|
||
// A square's outline depends on the squares across its sides, so those are outlined
|
||
// again too.
|
||
let mut redo = BTreeSet::new();
|
||
for (key @ (ix, iy), area) in changed {
|
||
redo.extend([(ix, iy), (ix - 1, iy), (ix + 1, iy), (ix, iy - 1), (ix, iy + 1)]);
|
||
let near_long = match self.tiles.get(&key) {
|
||
Some(t) => t.near_long.clone(),
|
||
None => {
|
||
// The long segments listed in the square's cell, and those in no cell.
|
||
let cell = self.cells.entry(Self::cell_of(key)).or_default();
|
||
cell.tiles.push(key);
|
||
let mut listed: BTreeSet<usize> = cell.long.iter().copied().collect();
|
||
listed.extend(self.wide.iter().copied());
|
||
let near: Vec<usize> = listed.into_iter().filter(|&i| self.long.get(i).is_some_and(|l| self.reaches(key, l.a, l.b))).collect();
|
||
for &i in &near {
|
||
if let Some(l) = self.long.get_mut(i) {
|
||
l.near.insert(key);
|
||
}
|
||
}
|
||
near
|
||
}
|
||
};
|
||
// The sides of those long segments are cut where the square's area holds them.
|
||
self.dirty_long.extend(near_long.iter().copied());
|
||
self.tiles.insert(key, BandTile { area, outline: BezPath::new(), near_long, cut: BezPath::new() });
|
||
}
|
||
self.changed = true;
|
||
let outlines: Vec<_> = redo.into_iter().filter(|k| self.tiles.contains_key(k)).map(|k| (k, self.tile_outline(k))).collect();
|
||
for (key, outline) in outlines {
|
||
if let Some(t) = self.tiles.get_mut(&key) {
|
||
t.outline = outline;
|
||
if !t.near_long.is_empty() {
|
||
self.dirty_tiles.insert(key);
|
||
}
|
||
}
|
||
}
|
||
self.last = end;
|
||
Some(())
|
||
}
|
||
|
||
/// The band's outline: the last one finished. While the band has changed since, the parts it
|
||
/// changed are made again, until [`BAND_WORK_PER_CALL`] is spent in the call; when all are
|
||
/// made, the outline is joined from them ([`SmoothBand::make_outline`]).
|
||
fn outline(&mut self) -> Option<BezPath> {
|
||
while self.changed {
|
||
if !self.dirty_tiles.is_empty() || !self.dirty_long.is_empty() {
|
||
if self.work >= BAND_WORK_PER_CALL {
|
||
break;
|
||
}
|
||
if let Some(k) = self.dirty_tiles.pop_first() {
|
||
let (cut, work) = self.tile_cut(k);
|
||
self.work += work;
|
||
if let Some(t) = self.tiles.get_mut(&k) {
|
||
t.cut = cut;
|
||
}
|
||
} else if let Some(i) = self.dirty_long.pop_first() {
|
||
let (drawn, work) = self.long_drawn(i);
|
||
self.work += work;
|
||
if let Some(l) = self.long.get_mut(i) {
|
||
l.drawn = drawn;
|
||
}
|
||
}
|
||
} else {
|
||
self.shown = Some(self.make_outline());
|
||
self.changed = false;
|
||
}
|
||
}
|
||
self.shown.clone()
|
||
}
|
||
|
||
/// The band's outline: every square's, joined, then the parts that show of the two sides of
|
||
/// the band of each segment too long for the squares. A square near such a segment shows its
|
||
/// `cut` ([`SmoothBand::tile_cut`]), and such a segment its `drawn` ([`SmoothBand::long_drawn`]).
|
||
fn make_outline(&self) -> BezPath {
|
||
let tiles = self.tiles.values().flat_map(|t| (if t.near_long.is_empty() { &t.outline } else { &t.cut }).elements().iter().copied());
|
||
BezPath::from_vec(tiles.chain(self.long.iter().flat_map(|l| l.drawn.elements().iter().copied())).collect())
|
||
}
|
||
|
||
/// The outline of square `key` less its parts inside the band of a long segment near it (more
|
||
/// than a thousandth of the radius in), and the work that took.
|
||
fn tile_cut(&self, key: (i64, i64)) -> (BezPath, usize) {
|
||
let Some(t) = self.tiles.get(&key) else { return (BezPath::new(), 1) };
|
||
let (r, eps) = (self.radius, 1e-3 * self.radius);
|
||
let near: Vec<&LongSegment> = t.near_long.iter().filter_map(|&i| self.long.get(i)).collect();
|
||
let mut out = BezPath::new();
|
||
let mut pen = None;
|
||
let mut work = 1;
|
||
for seg in t.outline.segments() {
|
||
// The pieces of the segment between the points where it crosses a side of the band
|
||
// of a long segment, each left out when it lies inside that band.
|
||
let mut ts: Vec<f64> = near
|
||
.iter()
|
||
.flat_map(|l| l.sides(r))
|
||
.flat_map(|line| seg.intersect_line(line))
|
||
.map(|x| x.segment_t)
|
||
.filter(|t| *t > 0.0 && *t < 1.0)
|
||
.collect();
|
||
ts.extend([0.0, 1.0]);
|
||
ts.sort_by(f64::total_cmp);
|
||
ts.dedup();
|
||
work += near.len() * ts.len();
|
||
for w in ts.windows(2) {
|
||
let piece = if ts.len() == 2 { seg } else { seg.subsegment(w[0]..w[1]) };
|
||
let m = piece.eval(0.5);
|
||
if near.iter().any(|l| distance_to_segment(m, l.a, l.b) < r - eps) {
|
||
pen = None;
|
||
} else {
|
||
push_seg(&mut out, &mut pen, piece);
|
||
}
|
||
}
|
||
}
|
||
(out, work)
|
||
}
|
||
|
||
/// The parts that show of the two sides of the band of long segment `i`, outside the squares'
|
||
/// part of it, and the work that took. Left out: the parts inside the band area of a square,
|
||
/// inside the band of another long segment (more than a thousandth of the radius in), and on
|
||
/// a side of an earlier long segment, which already shows. Only the squares and long segments
|
||
/// near it are checked.
|
||
fn long_drawn(&self, i: usize) -> (BezPath, usize) {
|
||
let Some(l) = self.long.get(i) else { return (BezPath::new(), 1) };
|
||
let (r, eps) = (self.radius, 1e-3 * self.radius);
|
||
let others: Vec<(usize, &LongSegment)> = l.meets.iter().filter_map(|&j| self.long.get(j).map(|o| (j, o))).collect();
|
||
// Whether a square's band area holds `q`.
|
||
let in_tiles = |q: Point| {
|
||
self.tile_of(q).and_then(|k| self.tiles.get(&k)).is_some_and(|t| vectorcraft_geom::Shape::winding(&t.area.to_bezpath(), q) != 0)
|
||
};
|
||
let mut out = BezPath::new();
|
||
let mut work = 1;
|
||
for line in l.sides(r) {
|
||
// Where the side crosses the band area of a square it passes, a side of another long
|
||
// segment's band, or the end of a side it runs along.
|
||
let mut ts = vec![0.0, 1.0];
|
||
for t in l.near.iter().filter_map(|k| self.tiles.get(k)) {
|
||
ts.extend(t.area.to_bezpath().segments().flat_map(|seg| seg.intersect_line(line)).map(|x| x.line_t));
|
||
}
|
||
// The stretches of the side inside the band of another long segment, and those on a
|
||
// side of an earlier one.
|
||
let mut inside = vec![];
|
||
let mut along = vec![];
|
||
for &(j, other) in &others {
|
||
inside.extend(capsule_span(line, other.a, other.b, r - eps));
|
||
for s in other.sides(r) {
|
||
ts.extend(PathSeg::Line(s).intersect_line(line).into_iter().map(|x| x.line_t));
|
||
if j < i {
|
||
ts.extend(
|
||
[s.p0, s.p1]
|
||
.into_iter()
|
||
.filter(|&p| distance_to_segment(p, line.p0, line.p1) <= eps)
|
||
.map(|p| (p - line.p0).dot(line.p1 - line.p0) / (line.p1 - line.p0).hypot2()),
|
||
);
|
||
along.extend(capsule_span(line, s.p0, s.p1, eps));
|
||
}
|
||
}
|
||
}
|
||
let (inside, along) = (merge_spans(inside), merge_spans(along));
|
||
ts.retain(|t| (0.0..=1.0).contains(t));
|
||
ts.sort_by(f64::total_cmp);
|
||
ts.dedup();
|
||
work += l.near.len() + others.len() + ts.len();
|
||
let mut pen = None;
|
||
for w in ts.windows(2) {
|
||
let (p, q) = (line.p0.lerp(line.p1, w[0]), line.p0.lerp(line.p1, w[1]));
|
||
let mid = 0.5 * (w[0] + w[1]);
|
||
let hidden = in_tiles(p.midpoint(q)) || spans_cover(&inside, mid, mid) || spans_cover(&along, w[0], w[1]);
|
||
if !hidden {
|
||
push_seg(&mut out, &mut pen, PathSeg::Line(vectorcraft_geom::Line::new(p, q)));
|
||
}
|
||
}
|
||
}
|
||
(out, work)
|
||
}
|
||
|
||
/// The outline of the band's part in square `key`, less the parts along a side of the square
|
||
/// (within a thousandth of the radius) where the band goes on across that side: where the
|
||
/// square across it holds band area a thousandth of the radius past it.
|
||
fn tile_outline(&self, key: (i64, i64)) -> BezPath {
|
||
let Some(tile) = self.tiles.get(&key) else { return BezPath::new() };
|
||
let (rect, eps) = (self.tile_rect(key), 1e-3 * self.radius);
|
||
let (ix, iy) = key;
|
||
// A point's distance across a side (x for a side that runs up and down) and along it.
|
||
let split = |upright: bool, p: Point| if upright { (p.x, p.y) } else { (p.y, p.x) };
|
||
// Each side with band area across it: whether it runs up and down, where it runs, the
|
||
// direction away from this square, the area across, and where along the side that area's
|
||
// outline has a point on the side (where the area across can start or stop).
|
||
let sides: Vec<_> = [
|
||
(true, rect.x0, (ix - 1, iy), -1.0),
|
||
(true, rect.x1, (ix + 1, iy), 1.0),
|
||
(false, rect.y0, (ix, iy - 1), -1.0),
|
||
(false, rect.y1, (ix, iy + 1), 1.0),
|
||
]
|
||
.into_iter()
|
||
.filter_map(|(upright, at, k, away)| {
|
||
let area = self.tiles.get(&k)?.area.to_bezpath();
|
||
let cuts: Vec<f64> = area
|
||
.segments()
|
||
.flat_map(|s| [s.start(), s.end()])
|
||
.map(|p| split(upright, p))
|
||
.filter(|&(off, _)| (off - at).abs() <= eps)
|
||
.map(|(_, along)| along)
|
||
.collect();
|
||
Some((upright, at, away, area, cuts))
|
||
})
|
||
.collect();
|
||
let mut out = BezPath::new();
|
||
let mut pen = None;
|
||
for seg in tile.area.to_bezpath().segments() {
|
||
let (a, b, mid) = (seg.start(), seg.end(), seg.eval(0.5));
|
||
let Some((upright, at, away, area, cuts)) = sides.iter().find(|s| [a, b, mid].iter().all(|&p| (split(s.0, p).0 - s.1).abs() <= eps))
|
||
else {
|
||
push_seg(&mut out, &mut pen, seg);
|
||
continue;
|
||
};
|
||
// The parts of the segment between the points where the area across starts or stops
|
||
// (more than `eps` from its ends), each kept unless the area across holds the point
|
||
// `eps` past the side beside it: the square across holds band area there exactly when
|
||
// its own edge of the band lies farther out than `eps`, and so shows.
|
||
let (la, lb) = (split(*upright, a).1, split(*upright, b).1);
|
||
let mut ts: Vec<f64> = cuts
|
||
.iter()
|
||
.filter(|&&c| (c - la) * (c - lb) < 0.0 && (c - la).abs() > eps && (c - lb).abs() > eps)
|
||
.map(|&c| (c - la) / (lb - la))
|
||
.collect();
|
||
ts.sort_by(f64::total_cmp);
|
||
let whole = ts.is_empty();
|
||
ts.insert(0, 0.0);
|
||
ts.push(1.0);
|
||
for w in ts.windows(2) {
|
||
let (p, q) = (a.lerp(b, w[0]), a.lerp(b, w[1]));
|
||
let m = if whole { mid } else { p.midpoint(q) };
|
||
let past = at + away * eps;
|
||
let probe = if *upright { Point::new(past, m.y) } else { Point::new(m.x, past) };
|
||
if vectorcraft_geom::Shape::winding(area, probe) != 0 {
|
||
pen = None;
|
||
} else if whole {
|
||
push_seg(&mut out, &mut pen, seg);
|
||
} else {
|
||
push_seg(&mut out, &mut pen, PathSeg::Line(vectorcraft_geom::Line::new(p, q)));
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
}
|
||
|
||
/// Add `seg` to `out`, starting a new subpath unless it starts at `pen`, where the last one ended.
|
||
fn push_seg(out: &mut BezPath, pen: &mut Option<Point>, seg: PathSeg) {
|
||
let a = seg.start();
|
||
if *pen != Some(a) {
|
||
out.move_to(a);
|
||
}
|
||
match seg {
|
||
PathSeg::Line(l) => out.line_to(l.p1),
|
||
PathSeg::Quad(q) => out.quad_to(q.p1, q.p2),
|
||
PathSeg::Cubic(c) => out.curve_to(c.p1, c.p2, c.p3),
|
||
}
|
||
*pen = Some(seg.end());
|
||
}
|
||
|
||
/// How far `q` is from the segment from `a` to `b`.
|
||
fn distance_to_segment(q: Point, a: Point, b: Point) -> f64 {
|
||
let d = b - a;
|
||
let t = if d.hypot2() > 0.0 { ((q - a).dot(d) / d.hypot2()).clamp(0.0, 1.0) } else { 0.0 };
|
||
q.distance(a + d * t)
|
||
}
|
||
|
||
/// How far the segment from `a` to `b` is from the segment from `c` to `d`.
|
||
fn segment_distance(a: Point, b: Point, c: Point, d: Point) -> f64 {
|
||
let side = |p: Point, q: Point, s: Point| (q - p).cross(s - p);
|
||
if side(a, b, c) * side(a, b, d) < 0.0 && side(c, d, a) * side(c, d, b) < 0.0 {
|
||
return 0.0;
|
||
}
|
||
distance_to_segment(a, c, d).min(distance_to_segment(b, c, d)).min(distance_to_segment(c, a, b)).min(distance_to_segment(d, a, b))
|
||
}
|
||
|
||
/// The stretch of `line`, as `line_t` from and to (0 at its start, 1 at its end, and beyond),
|
||
/// within `rad` of the segment from `a` to `b`; `None` when no point of the line is.
|
||
fn capsule_span(line: vectorcraft_geom::Line, a: Point, b: Point, rad: f64) -> Option<(f64, f64)> {
|
||
let (p, d) = (line.p0, line.p1 - line.p0);
|
||
let dd = d.hypot2();
|
||
if dd <= 0.0 || rad.is_nan() || rad < 0.0 {
|
||
return None;
|
||
}
|
||
let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
|
||
// The round ends.
|
||
for c in [a, b] {
|
||
let f = p - c;
|
||
let (half_b, k) = (d.dot(f), f.hypot2() - rad * rad);
|
||
let disc = half_b * half_b - dd * k;
|
||
if disc >= 0.0 {
|
||
let root = disc.sqrt();
|
||
lo = lo.min((-half_b - root) / dd);
|
||
hi = hi.max((-half_b + root) / dd);
|
||
}
|
||
}
|
||
// The part between them: along the segment from 0 to its length, and across it within `rad`.
|
||
let e = b - a;
|
||
let len = e.hypot();
|
||
if len > 0.0 {
|
||
let u = e / len;
|
||
let n = Vec2::new(-u.y, u.x);
|
||
let (mut s0, mut s1) = (f64::NEG_INFINITY, f64::INFINITY);
|
||
for (at, rate, min, max) in [((p - a).dot(u), d.dot(u), 0.0, len), ((p - a).dot(n), d.dot(n), -rad, rad)] {
|
||
if rate == 0.0 {
|
||
if at < min || at > max {
|
||
s1 = f64::NEG_INFINITY;
|
||
}
|
||
} else {
|
||
let (x, y) = ((min - at) / rate, (max - at) / rate);
|
||
s0 = s0.max(x.min(y));
|
||
s1 = s1.min(x.max(y));
|
||
}
|
||
}
|
||
if s0 <= s1 {
|
||
lo = lo.min(s0);
|
||
hi = hi.max(s1);
|
||
}
|
||
}
|
||
(lo <= hi).then_some((lo, hi))
|
||
}
|
||
|
||
/// `spans` sorted and joined where they overlap.
|
||
fn merge_spans(mut spans: Vec<(f64, f64)>) -> Vec<(f64, f64)> {
|
||
spans.sort_by(|x, y| x.0.total_cmp(&y.0));
|
||
let mut out: Vec<(f64, f64)> = Vec::with_capacity(spans.len());
|
||
for (a, b) in spans {
|
||
match out.last_mut() {
|
||
Some(last) if a <= last.1 => last.1 = last.1.max(b),
|
||
_ => out.push((a, b)),
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Whether one of `spans` (from [`merge_spans`]) holds everything from `from` to `to`.
|
||
fn spans_cover(spans: &[(f64, f64)], from: f64, to: f64) -> bool {
|
||
let i = spans.partition_point(|s| s.0 <= from);
|
||
i > 0 && spans.get(i - 1).is_some_and(|s| s.1 >= to)
|
||
}
|
||
|
||
/// 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![],
|
||
}
|
||
}
|
||
|
||
/// Whether the selection contains a path the Smooth command can act on: one with a subpath of three
|
||
/// or more anchors, or of two anchors joined by a curve.
|
||
fn has_selected_smooth_path(cx: &ToolContext) -> bool {
|
||
let editable = cx.doc.editable();
|
||
let smoothable = |sp: &vectorcraft_geom::SubPath| sp.anchors.len() >= 3 || (0..sp.segment_count()).any(|i| !super::segment_is_straight(sp, i));
|
||
cx.selection.objects.iter().filter_map(|id| cx.doc.node(*id)).any(|n| {
|
||
let mut found = false;
|
||
n.walk(&mut |child| {
|
||
if !found
|
||
&& matches!(&child.kind, NodeKind::Path { guide: false, path, .. } if path.control_bounds().is_some() && path.subpaths.iter().any(smoothable))
|
||
&& editable(child.id)
|
||
{
|
||
found = true;
|
||
}
|
||
});
|
||
found
|
||
})
|
||
}
|
||
|
||
/// The Smooth tool's cursor: Not Allowed when no selected path can be smoothed, since a drag then
|
||
/// changes nothing. A selected path anywhere on the canvas is enough, so the cursor does not depend
|
||
/// on where the pointer is.
|
||
fn smooth_cursor(cx: &ToolContext) -> Cursor {
|
||
if has_selected_smooth_path(cx) { Cursor::Smooth } else { Cursor::NotAllowed }
|
||
}
|
||
|
||
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" { SMOOTH_FIDELITY } else { 1.5 };
|
||
Self {
|
||
id,
|
||
previewed: 0,
|
||
points: vec![],
|
||
pressures: vec![],
|
||
poses: vec![],
|
||
active: false,
|
||
began: false,
|
||
near_start: false,
|
||
connects: false,
|
||
extend: None,
|
||
close_to: None,
|
||
connect_to: vec![],
|
||
redraw: None,
|
||
fidelity,
|
||
size: 10.0,
|
||
size_pressure: false,
|
||
size_variation: 0.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,
|
||
smooth_fidelity: SMOOTH_FIDELITY,
|
||
smoothing: false,
|
||
moved: false,
|
||
straight: false,
|
||
unconstrained: false,
|
||
shift_line: None,
|
||
merge_only_selection: false,
|
||
edit_within: 12.0,
|
||
close_within: 15.0,
|
||
snap: DrawSnap::default(),
|
||
pressed: Point::ZERO,
|
||
tip_at: None,
|
||
light: false,
|
||
length: 0.0,
|
||
area: Cell::new(None),
|
||
area_pending: Cell::new(false),
|
||
band: Cell::new(None),
|
||
band_pending: Cell::new(false),
|
||
}
|
||
}
|
||
|
||
/// Do the ends of its strokes snap (the Pencil)?
|
||
fn snaps(&self) -> bool {
|
||
self.id == "pencil"
|
||
}
|
||
|
||
/// Does a straight Knife cut snap to anchors and paths? Only with Smart Guides on, which Snap to
|
||
/// Pixel (for drawing) and Snap to Grid turn off (`PointSnap::new`). Its ends snap as the
|
||
/// Pencil's straight line's do: the press, and the release unless Shift keeps it on a 45° step.
|
||
fn knife_snaps(&self, cx: &ToolContext) -> bool {
|
||
self.id == "knife" && self.straight && cx.smart_guides && !cx.snaps_to_pixel(crate::PixelOp::Drawing) && !cx.snap_to_grid
|
||
}
|
||
|
||
/// Does the end of a straight line snap: the Pencil's Alt line's, or a straight Knife cut's
|
||
/// without Shift?
|
||
fn line_end_snaps(&self, cx: &ToolContext) -> bool {
|
||
self.straight && self.unconstrained && (self.snaps() || self.knife_snaps(cx))
|
||
}
|
||
|
||
/// Where a stroke or straight line released at `p` ends: where it snaps when its end snaps.
|
||
fn release_end(&mut self, cx: &ToolContext, p: Point) -> Point {
|
||
if (self.snaps() && !self.smoothing && !self.straight) || self.line_end_snaps(cx) { self.snap.drag(cx, p, None) } else { p }
|
||
}
|
||
|
||
/// Is a straight line with its end at `end` back at its press point (within a pixel), so that
|
||
/// there is no line? A Knife cut also is when the pointer, at `pointer`, is back where it went
|
||
/// down, though its press snapped elsewhere.
|
||
fn back_at_press(&self, cx: &ToolContext, pointer: Point, end: Point) -> bool {
|
||
self.points.first().is_some_and(|s| s.distance(end) < cx.tol(1.0)) || (self.id == "knife" && self.pressed.distance(pointer) < cx.tol(1.0))
|
||
}
|
||
|
||
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, with the pen's pressure, tilt and rotation of the event `ev` there.
|
||
fn push(&mut self, p: Point, ev: &PointerEvent) {
|
||
if let Some(last) = self.points.last() {
|
||
self.length += last.distance(p);
|
||
}
|
||
self.points.push(p);
|
||
if self.id == "paintbrush" || (self.varies_size() && self.size_pressure) {
|
||
self.pressures.push(ev.pressure);
|
||
self.light |= ev.pressure < 1.0;
|
||
}
|
||
if self.id == "paintbrush" {
|
||
self.poses.push(ev.pen.map(PenPose::sanitized));
|
||
}
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
|
||
/// Shift keeps an active straight Knife cut at 45° steps; without Shift it follows the pointer.
|
||
/// Releasing Alt does not end a straight cut.
|
||
fn knife_shift(&mut self, m: Mods) {
|
||
if self.id == "knife" && self.straight {
|
||
self.unconstrained = !m.shift;
|
||
}
|
||
}
|
||
|
||
/// Constrain a straight Pencil line from the Constrain Angle, a straight Knife cut from the
|
||
/// canvas axes, or the current Blob Brush sample. Blob Brush chooses an
|
||
/// axis once per uninterrupted Shift interval; samples already drawn never move.
|
||
/// A smoothing drag follows the pointer without a Shift constraint.
|
||
fn constrained(&mut self, cx: &ToolContext, p: Point, m: Mods) -> Point {
|
||
if self.straight && self.unconstrained {
|
||
return p;
|
||
}
|
||
if self.straight
|
||
&& let Some(&start) = self.points.first()
|
||
{
|
||
let origin = if self.id == "knife" { 0.0 } else { cx.constrain_angle };
|
||
return start + vectorcraft_geom::constrain_angle_from(p - start, 45.0, origin);
|
||
}
|
||
let Some(&start) = self.points.first().filter(|_| self.id == "blobBrush" && m.shift && !self.smoothing) else {
|
||
self.shift_line = None;
|
||
return p;
|
||
};
|
||
if self.shift_line.is_none() {
|
||
// Raw press-to-pointer displacement in logical screen pixels along the constraint
|
||
// axes. Zoom changes the document distance, not the activation tolerance.
|
||
let v = p - start;
|
||
let v = vectorcraft_geom::constrain_axes(cx.constrain_angle).map_or(Point::new(v.x, v.y), |axes| axes.inverse() * Point::new(v.x, v.y));
|
||
let x = v.x.abs() * cx.zoom;
|
||
let y = v.y.abs() * cx.zoom;
|
||
self.shift_line = if x >= 1.0 && y < 0.5 {
|
||
Some(cx.constrain_angle)
|
||
} else if y >= 1.0 && x < 0.5 {
|
||
Some(cx.constrain_angle + 90.0)
|
||
} else {
|
||
// No unconstrained trail while pending, including an initially diagonal drag.
|
||
return self.points.last().copied().unwrap_or(start);
|
||
};
|
||
}
|
||
start + vectorcraft_geom::constrain_angle_from(p - start, 180.0, self.shift_line.unwrap_or(cx.constrain_angle))
|
||
}
|
||
|
||
/// The Blob Brush's or the Eraser's tip at the pointer, before and while painting: its outline
|
||
/// `size` across at its angle and roundness, at the zoom, black on a white halo so it reads on
|
||
/// any art. Precise cursors show only the crosshair, and smoothing shows no tip.
|
||
fn tip_outline(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||
let Some(at) = self.tip_at.filter(|_| !self.smoothing && !cx.precise_cursors) else { return vec![] };
|
||
let tip = if self.id == "blobBrush" { blob_tip(self.angle, self.roundness) } else { eraser_tip(self.angle, self.roundness) };
|
||
let r = self.size / 2.0;
|
||
// A tolerance relative to the radius: four arcs at any size or zoom.
|
||
let circle = kurbo::Shape::to_path(&kurbo::Circle::new(Point::ORIGIN, r), 0.1 * r.max(1e-6));
|
||
let path = Affine::translate(at.to_vec2()) * tip * circle;
|
||
vec![
|
||
Overlay::Path { path: path.clone(), color: [0xff, 0xff, 0xff], width: 3.0, dashed: false },
|
||
Overlay::Path { path, color: [0, 0, 0], width: 1.0, dashed: false },
|
||
]
|
||
}
|
||
|
||
/// Forget the stroke.
|
||
fn clear(&mut self) {
|
||
self.moved = false;
|
||
self.points.clear();
|
||
self.pressures.clear();
|
||
self.poses.clear();
|
||
self.previewed = 0;
|
||
self.shift_line = None;
|
||
self.light = false;
|
||
self.length = 0.0;
|
||
self.area.set(None);
|
||
self.area_pending.set(false);
|
||
self.band.set(None);
|
||
self.band_pending.set(false);
|
||
}
|
||
|
||
/// The outline of the band a smoothing drag smooths: every point within `radius` of the drag,
|
||
/// round at both ends. The band shown last is kept and grown by the steps dragged since, at
|
||
/// most [`BAND_STEP`] points at a time and [`BAND_STEPS_PER_CALL`] steps a call; a new radius
|
||
/// (the zoom changed) or another drag starts it again. While the band covers less of the drag
|
||
/// than it can, [`Tool::overlays_pending`] holds, so the host draws again and the band reaches
|
||
/// the whole drag with the pointer still. When a step cannot be added whole, its segments are
|
||
/// added one at a time: a segment longer than [`BAND_TILE_SAMPLES`] half squares is kept
|
||
/// outside the squares, and one whose outlines cannot be combined adds nothing. Either way the
|
||
/// band goes on past it. The work on such long segments stops for the call once
|
||
/// [`BAND_WORK_PER_CALL`] is spent, and the drag is added to the band only once the outline of
|
||
/// what it holds is finished.
|
||
fn smooth_band(&self, radius: f64) -> Option<BezPath> {
|
||
let pts = &self.points;
|
||
let &first = pts.first()?;
|
||
let mut b = self
|
||
.band
|
||
.take()
|
||
.filter(|b| b.radius == radius && b.first == first && b.used.checked_sub(1).and_then(|i| pts.get(i)) == Some(&b.last))
|
||
.unwrap_or_else(|| SmoothBand::new(first, radius));
|
||
b.work = 0;
|
||
let mut outline = b.outline();
|
||
if !b.changed {
|
||
for _ in 0..BAND_STEPS_PER_CALL {
|
||
let Some(from) = b.used.checked_sub(1).filter(|_| b.used < pts.len() && b.work < BAND_WORK_PER_CALL) else { break };
|
||
let mut to = (from + BAND_STEP).min(pts.len() - 1);
|
||
let Some(piece) = pts.get(from..=to) else { break };
|
||
if b.grow(piece).is_none() {
|
||
for (k, w) in piece.windows(2).enumerate() {
|
||
if b.work >= BAND_WORK_PER_CALL {
|
||
to = from + k;
|
||
break;
|
||
}
|
||
if b.too_long(w[0], w[1]) {
|
||
b.add_long(w[0], w[1]);
|
||
} else if b.grow(w).is_none() {
|
||
b.last = w[1];
|
||
}
|
||
}
|
||
}
|
||
b.used = to + 1;
|
||
}
|
||
outline = b.outline();
|
||
}
|
||
self.band_pending.set(b.used < pts.len() || b.changed);
|
||
self.band.set(Some(b));
|
||
outline.filter(|o| !o.elements().is_empty())
|
||
}
|
||
|
||
/// 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.light;
|
||
(self.varies_size() && self.size_pressure && !self.smoothing && pressed).then_some(self.pressures.as_slice())
|
||
}
|
||
|
||
/// The stroke's points: `[x, y, pressure, tiltX, tiltY, rotation]` when a pen read its tilt or
|
||
/// rotation anywhere along it (`null` for what a point lacks, and for the pressure of a stroke
|
||
/// pressed fully all along), else `[x, y, pressure]` when a pen pressed less than fully
|
||
/// anywhere (a mouse always presses fully), else `[x, y]`.
|
||
fn stroke_json(&self) -> Value {
|
||
let n = self.points.len();
|
||
let pressed = self.pressures.len() == n && self.pressures.iter().any(|p| *p < 1.0);
|
||
let posed = self.poses.len() == n && self.poses.iter().flatten().any(|q| q.tilt.is_some() || q.rotation.is_some());
|
||
let round = |v: f32, k: f64| (f64::from(v) * k).round() / k;
|
||
if posed {
|
||
let points = self.points.iter().zip(&self.pressures).zip(&self.poses).map(|((p, f), q)| {
|
||
let q = q.unwrap_or_default();
|
||
let (tilt, rotation) = (q.tilt.map(|t| t.map(|v| round(v, 100.0))), q.rotation.map(|r| round(r, 100.0)));
|
||
json!([p.x, p.y, pressed.then(|| round(*f, 1000.0)), tilt.map(|t| t[0]), tilt.map(|t| t[1]), rotation])
|
||
});
|
||
Value::Array(points.collect())
|
||
} else if pressed {
|
||
Value::Array(self.points.iter().zip(&self.pressures).map(|(p, f)| json!([p.x, p.y, round(*f, 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)
|
||
}
|
||
|
||
/// The other end of the open end `find_end` found: the opposite end of `id`'s last open
|
||
/// subpath.
|
||
fn other_end(cx: &ToolContext, id: NodeId, at_start: bool) -> Option<Point> {
|
||
let pd = cx.doc.node(id)?.path_data()?;
|
||
let sp = pd.subpaths.iter().rev().find(|s| !s.closed && !s.anchors.is_empty())?;
|
||
let a = if at_start { sp.anchors.last() } else { sp.anchors.first() };
|
||
a.map(|a| a.p)
|
||
}
|
||
|
||
/// Continues the path at the open end `find_extend` found, if any, noting its other end.
|
||
fn set_extend(&mut self, cx: &ToolContext, found: Option<(NodeId, bool, Point)>) {
|
||
self.extend = found.map(|(id, at_start, _)| (id, at_start));
|
||
self.close_to = found.and_then(|(id, at_start, _)| Self::other_end(cx, id, at_start));
|
||
}
|
||
|
||
/// [`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)? A stroke continuing a selected path ends near that path's other end, a
|
||
/// straight line too; a new straight line never closes, as it would run back over itself.
|
||
fn ends_near_start(&self, cx: &ToolContext, end: Point) -> bool {
|
||
if self.close_within <= 0.0 {
|
||
return false;
|
||
}
|
||
let tol = cx.tol(self.close_within);
|
||
match (self.extend, self.close_to) {
|
||
(Some(_), Some(to)) => self.points.len() >= 2 && to.distance(end) <= tol,
|
||
(Some(_), None) => false,
|
||
(None, _) => !self.straight && self.points.len() > 2 && self.points.first().is_some_and(|s| s.distance(end) <= tol),
|
||
}
|
||
}
|
||
|
||
/// The open end of another path selected at the press that a continuing Pencil stroke ending
|
||
/// at `end` connects to (`finish`).
|
||
fn connects_at(&self, cx: &ToolContext, end: Point) -> Option<(NodeId, bool, Point)> {
|
||
self.extend.and_then(|_| self.find_end(cx, end, &self.connect_to))
|
||
}
|
||
|
||
/// Does a release now, with the stroke ending at `end`, close the stroke because it ends near
|
||
/// its start, or near the other end of the path it continues (`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, and a stroke that connects to another path doesn't close.
|
||
fn release_closes_near_start(&self, cx: &ToolContext, end: Point) -> bool {
|
||
self.draws_path() && !self.smoothing && self.redraw.is_none() && self.ends_near_start(cx, end) && self.connects_at(cx, end).is_none()
|
||
}
|
||
|
||
/// Parameters of `path.freehand` for the current stroke, fitted at Fidelity on screen. Only the
|
||
/// Pencil and the Paintbrush (`draws_path`) send it; the Blob Brush's Fidelity stays in points.
|
||
pub fn freehand_params(&self, cx: &ToolContext, closed: bool) -> Value {
|
||
let mut v = json!({
|
||
"points": self.stroke_json(),
|
||
"fidelity": fit_tolerance(cx, 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 pts.len() >= 2 && has_selected_smooth_path(cx) {
|
||
return vec![Action::Exec(
|
||
"path.smoothRegion".into(),
|
||
json!({"points": points_json(pts), "radius": radius, "fidelity": cx.tol(self.smooth_fidelity)}),
|
||
)];
|
||
}
|
||
return vec![];
|
||
}
|
||
match self.id {
|
||
"pencil" | "paintbrush" => {
|
||
if !self.began {
|
||
return vec![];
|
||
}
|
||
// Close Paths When Ends Are Within is off at 0 pixels. A stroke continuing a
|
||
// selected path closes it near its other end, a straight line too.
|
||
let near = pts.last().is_some_and(|&end| self.ends_near_start(cx, end));
|
||
// Alt at the release closes a freehand stroke; a new straight line has two points:
|
||
// closing it would draw it back over itself.
|
||
let closed = (!self.straight && m.alt && (self.id == "paintbrush" || !self.alt_smooth)) || near;
|
||
// A Pencil stroke that continues a selected path and ends on an open end of another
|
||
// path selected at the press connects the two, and doesn't close.
|
||
let connect = pts.last().and_then(|&end| self.connects_at(cx, end));
|
||
let mut params = self.freehand_params(cx, closed && connect.is_none());
|
||
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": self.stroke_json(),
|
||
"size": self.size,
|
||
"sizeMode": if self.size_pressure { "pressure" } else { "fixed" },
|
||
"sizeVariation": self.size_variation,
|
||
"fidelity": self.fidelity,
|
||
"angle": self.angle,
|
||
"roundness": self.roundness,
|
||
"keepSelected": self.keep_selected,
|
||
"mergeOnlySelection": self.merge_only_selection
|
||
}),
|
||
)],
|
||
"eraser" => vec![Action::Exec(
|
||
"path.eraseRegion".into(),
|
||
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 pts.len() >= 2 && has_selected_smooth_path(cx) => {
|
||
vec![Action::Exec(
|
||
"path.smoothRegion".into(),
|
||
json!({"points": points_json(pts), "radius": radius, "fidelity": cx.tol(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;
|
||
if self.varies_size() {
|
||
self.tip_at = (!ev.mods.cmd && !self.blob_alt_smooths(ev.mods)).then_some(p);
|
||
}
|
||
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;
|
||
self.connects = false;
|
||
// Alt switches the Pencil when its toggle is on, the Blob Brush always, and the
|
||
// Paintbrush when the press has a selected path.
|
||
self.smoothing = ev.mods.alt
|
||
&& (self.id == "blobBrush"
|
||
|| (self.id == "pencil" && self.alt_smooth)
|
||
|| (self.id == "paintbrush" && has_selected_smooth_path(cx)));
|
||
// Alt draws a straight line only while it doesn't lend the Smooth tool; with Shift
|
||
// too, the line keeps Shift's 45° steps. Alt makes the Knife cut along a straight line.
|
||
self.straight = (self.id == "pencil" && !self.smoothing && (ev.mods.shift || ev.mods.alt)) || (self.id == "knife" && 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.set_extend(cx, extend);
|
||
// 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 if self.knife_snaps(cx) {
|
||
// A straight Knife cut, its overlay and Shift's 45° steps start from the snapped point.
|
||
self.snap.press_on_paths(cx, p)
|
||
} else {
|
||
p
|
||
};
|
||
self.clear();
|
||
self.pressed = p;
|
||
self.push(start, ev);
|
||
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.knife_shift(ev.mods);
|
||
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.line_end_snaps(cx) {
|
||
// Alt's line ends where a stroke would, and a Knife cut's on the anchors and
|
||
// paths its press snaps to: shown, taken on release.
|
||
self.snap.drag(cx, p, None)
|
||
} else {
|
||
p
|
||
};
|
||
if self.straight {
|
||
if !self.line_end_snaps(cx) {
|
||
// 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.back_at_press(cx, ev.pos, p) {
|
||
// 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);
|
||
// The line ends at `p`, where its end snaps.
|
||
end = p;
|
||
} 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);
|
||
self.connects = self.connects_at(cx, release_end).is_some();
|
||
return vec![];
|
||
}
|
||
self.push(p, ev);
|
||
self.near_start = self.release_closes_near_start(cx, end);
|
||
self.connects = self.connects_at(cx, end).is_some();
|
||
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()) {
|
||
let mut v = self.freehand_params(cx, false);
|
||
let fidelity = self.fidelity.max(PREVIEW_FIDELITY_PX);
|
||
v["fidelity"] = json!(fit_tolerance(cx, fidelity));
|
||
// A preview fitted coarser than the stroke kept is stale (`deactivate`).
|
||
self.previewed = if fidelity > self.fidelity { 0 } else { self.points.len() };
|
||
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;
|
||
}
|
||
// Alt only on release cannot turn a freehand Knife drag into a straight cut.
|
||
self.knife_shift(ev.mods);
|
||
let p = self.constrained(cx, p, ev.mods);
|
||
let end = self.release_end(cx, p);
|
||
self.snap.clear();
|
||
let mut cancel = false;
|
||
if self.straight {
|
||
self.points.truncate(1);
|
||
if !self.back_at_press(cx, ev.pos, end) {
|
||
self.push(end, ev);
|
||
} else {
|
||
cancel = self.began;
|
||
}
|
||
} else if self.points.last().is_some_and(|l| l.distance(p) >= cx.tol(1.0)) {
|
||
self.push(end, ev);
|
||
} 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.connects = false;
|
||
self.smoothing = false;
|
||
self.straight = false;
|
||
self.unconstrained = false;
|
||
self.clear();
|
||
self.extend = None;
|
||
self.close_to = 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")
|
||
}
|
||
/// The outline of a long Blob Brush or Eraser drag, and a smoothing drag's band outlined again
|
||
/// after a zoom change, reach the whole drag over several frames.
|
||
fn overlays_pending(&self) -> bool {
|
||
self.area_pending.get() || self.band_pending.get()
|
||
}
|
||
/// `[` 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.connects = 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 hover_ended(&mut self, _cx: &ToolContext) {
|
||
self.tip_at = None;
|
||
}
|
||
fn deactivate(&mut self, cx: &ToolContext) -> Vec<Action> {
|
||
let began = self.began;
|
||
self.tip_at = None;
|
||
// 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(cx, false)));
|
||
self.snap.clear();
|
||
self.active = false;
|
||
self.near_start = false;
|
||
self.connects = 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> {
|
||
self.area_pending.set(false);
|
||
self.band_pending.set(false);
|
||
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()) {
|
||
o.extend(self.tip_outline(cx));
|
||
return o;
|
||
}
|
||
// A smoothing drag with a selected path it can smooth also shows, dashed, the band its
|
||
// release smooths.
|
||
if (self.id == "smooth" || self.smoothing)
|
||
&& has_selected_smooth_path(cx)
|
||
&& let Some(path) = self.smooth_band(cx.tol(SMOOTH_RADIUS))
|
||
{
|
||
o.push(Overlay::Path { path, color: FEEDBACK, width: 1.0, dashed: true });
|
||
}
|
||
// 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, each step of the drag
|
||
// as wide as the pen pressed there with Size set to Pressure. So does a round Blob Brush
|
||
// tip: a line as wide as the brush would end square, where the stroke it adds is round at
|
||
// both ends (#4343). The outline is built a bounded amount per call ([`AreaPreview`]).
|
||
if matches!(self.id, "eraser" | "blobBrush") && (self.roundness < 100.0 || self.id == "blobBrush") && !self.smoothing {
|
||
let pressures = self.size_pressures();
|
||
let key = AreaKey {
|
||
blob: self.id == "blobBrush",
|
||
size: self.size,
|
||
variation: self.size_variation,
|
||
angle: self.angle,
|
||
roundness: self.roundness,
|
||
pressure: pressures.is_some(),
|
||
};
|
||
let mut area = self.area.take().filter(|a| a.fits(key, &self.points)).unwrap_or_else(|| AreaPreview::new(key, self.points[0]));
|
||
area.step(&self.points, pressures);
|
||
for path in area.paths(&self.points) {
|
||
o.push(Overlay::Path { path, color, width: 1.0, dashed: false });
|
||
}
|
||
self.area_pending.set(area.pending(&self.points));
|
||
self.area.set(Some(area));
|
||
} else if let Some(pressures) = self.size_pressures() {
|
||
// With Size set to Pressure, each step of the drag shows as wide as the pen pressed there.
|
||
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.length;
|
||
o.push(Overlay::Measure { p: last + vectorcraft_geom::Vec2::new(cx.tol(12.0), cx.tol(12.0)), text: format!("L: {}", cx.len(length)) });
|
||
}
|
||
// Over the stroke's footprint, which reaches the tip's edge at the pointer.
|
||
o.extend(self.tip_outline(cx));
|
||
o
|
||
}
|
||
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
|
||
// An active smoothing drag, or Alt before a press that would switch the Pencil, Paintbrush
|
||
// or Blob Brush to Smooth. A Paintbrush stroke already drawing keeps drawing with Alt. With
|
||
// no selected path to smooth, the Pencil and the Blob Brush show Not Allowed, as the Smooth
|
||
// tool does.
|
||
if self.smoothing
|
||
|| (self.id == "pencil" && self.alt_smooth && m.alt && !self.began)
|
||
|| (!self.active && self.id == "paintbrush" && m.alt && has_selected_smooth_path(cx))
|
||
|| self.blob_alt_smooths(m)
|
||
{
|
||
return smooth_cursor(cx);
|
||
}
|
||
// 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 };
|
||
}
|
||
// A continuing Pencil stroke that a release would connect to another selected path's open
|
||
// end (`finish`) shows that it continues on; `connects` tests the point the release keeps.
|
||
if self.id == "pencil" && self.active && self.connects {
|
||
return Cursor::PencilContinue;
|
||
}
|
||
// 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.id == "paintbrush" || !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" => smooth_cursor(cx),
|
||
"pathEraser" => Cursor::PathEraser,
|
||
"join" => Cursor::Join,
|
||
// Before a press over an open end of a selected path the Pencil shows that the stroke
|
||
// continues it (`PointerKind::Down`).
|
||
_ if !self.active && self.find_extend(cx, p).is_some() => Cursor::PencilContinue,
|
||
// Before a press that draws a new path the Pencil shows its star; over a selected path
|
||
// that the press redraws (`PointerKind::Down`), it does not.
|
||
_ if !self.active && 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,
|
||
"sizeMode": if self.size_pressure { "pressure" } else { "fixed" },
|
||
"sizeVariation": self.size_variation,
|
||
"angle": self.angle,
|
||
"roundness": self.roundness,
|
||
"keepSelected": self.keep_selected,
|
||
"mergeOnlySelection": self.merge_only_selection
|
||
})
|
||
}
|
||
"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,
|
||
}
|
||
}
|
||
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),
|
||
"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(if self.id == "blobBrush" { 0.0 } else { 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),
|
||
"smoothFidelity" if matches!(self.id, "pencil" | "paintbrush" | "blobBrush") => {
|
||
self.smooth_fidelity = v.as_f64().unwrap_or(self.smooth_fidelity).clamp(super::min_fidelity("smooth"), 100.0)
|
||
}
|
||
"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;
|