Merge pull request #7240 from dotCooCoo/feat/smooth-reach-overlay

Smooth tool: the drag shows the band the tool smooths
This commit is contained in:
BFlat
2026-11-14 02:40:24 +07:00
committed by GitHub
6 changed files with 1128 additions and 5 deletions
+6
View File
@@ -804,6 +804,12 @@ impl Session {
self.tool.wants_ticks()
}
/// Did the active tool leave part of its overlays for the next frame
/// ([`vectorcraft_tools::Tool::overlays_pending`])?
pub fn tool_overlays_pending(&self) -> bool {
self.tool.overlays_pending()
}
/// Does the active tool build a stroke from every position the pointer passed
/// ([`vectorcraft_tools::Tool::wants_samples`])?
pub fn tool_wants_samples(&self) -> bool {
+675 -1
View File
@@ -23,9 +23,13 @@
//! 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.
use std::cell::Cell;
use std::collections::{BTreeMap, BTreeSet};
use serde_json::{Value, json};
use vectorcraft_doc::{NodeId, NodeKind};
use vectorcraft_geom::{Affine, Point};
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_area, eraser_tip, points_json, polyline};
use crate::guides::DrawSnap;
@@ -35,6 +39,32 @@ use crate::{Action, Cursor, Mods, Overlay, PenPose, PointerEvent, PointerKind, T
/// 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.
@@ -136,6 +166,584 @@ pub struct GestureTool {
/// 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>,
/// 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
@@ -214,6 +822,8 @@ impl GestureTool {
snap: DrawSnap::default(),
pressed: Point::ZERO,
tip_at: None,
band: Cell::new(None),
band_pending: Cell::new(false),
}
}
@@ -349,6 +959,56 @@ impl GestureTool {
self.poses.clear();
self.previewed = 0;
self.shift_line = None;
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)?
@@ -726,6 +1386,11 @@ impl Tool for GestureTool {
fn wants_samples(&self) -> bool {
matches!(self.id, "pencil" | "paintbrush" | "blobBrush" | "eraser" | "knife" | "smooth" | "pathEraser")
}
/// A smoothing drag's band outlined again after a zoom change reaches the whole drag over
/// several frames.
fn overlays_pending(&self) -> bool {
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")
@@ -776,12 +1441,21 @@ impl Tool for GestureTool {
if began { stale.into_iter().chain([Action::Commit]).collect() } else { vec![] }
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
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,
+435 -2
View File
@@ -1037,6 +1037,439 @@ fn pencil_alt_smooths_within_the_smooth_tools_radius() {
}
}
/// The dashed paths among the overlays.
fn bands(t: &GestureTool, cx: &ToolContext) -> Vec<vectorcraft_geom::BezPath> {
t.overlays(cx).into_iter().filter_map(|o| if let Overlay::Path { path, dashed: true, .. } = o { Some(path) } else { None }).collect()
}
/// How far `q` is from the polyline through `pts`.
fn dist_to_drag(q: Point, pts: &[Point]) -> f64 {
pts.windows(2)
.map(|w| {
let ab = w[1] - w[0];
let t = if ab.hypot2() > 0.0 { ((q - w[0]).dot(ab) / ab.hypot2()).clamp(0.0, 1.0) } else { 0.0 };
q.distance(w[0] + ab * t)
})
.fold(f64::INFINITY, f64::min)
}
/// Check the band a smoothing drag through the tool's points shows at radius `r`: one dashed path,
/// every point of which is `r` from the drag, around an area that holds every point nearer than `r`
/// and none farther. Returns the path.
fn check_band(t: &GestureTool, cx: &ToolContext, r: f64) -> vectorcraft_geom::BezPath {
use vectorcraft_geom::kurbo::Shape;
let pts = &t.points;
let found = bands(t, cx);
let [outline] = found.as_slice() else { panic!("one band, got {found:?}") };
for seg in outline.segments() {
for s in [0.0, 0.25, 0.5, 0.75, 1.0] {
let q = seg.eval(s);
let dq = dist_to_drag(q, pts);
assert!((dq - r).abs() < 0.02 * r, "{q:?} on the outline is {dq} from the drag, radius {r}");
}
}
let kept = t.band.take();
let areas: Vec<_> = kept.iter().flat_map(|b| b.tiles.values().map(|t| t.area.to_bezpath())).collect();
t.band.set(kept);
let b = outline.bounding_box();
let mut probes = 0;
for i in 0..=80 {
for j in 0..=80 {
let q = Point::new(b.x0 - r + (b.width() + 2.0 * r) * f64::from(i) / 80.0, b.y0 - r + (b.height() + 2.0 * r) * f64::from(j) / 80.0);
let dq = dist_to_drag(q, pts);
let inside = areas.iter().any(|a| a.contains(q));
if dq < 0.97 * r {
assert!(inside, "{q:?} {dq} from the drag is inside the band, radius {r}");
probes += 1;
} else if dq > 1.03 * r {
assert!(!inside, "{q:?} {dq} from the drag is outside the band, radius {r}");
}
}
}
assert!(probes > 100, "{probes}");
outline.clone()
}
/// While a smoothing drag has a selected path to smooth, it shows dashed the band its release smooths:
/// everything within the radius it sends of the drag, round at both ends, around the drag's thin
/// line. Alt smoothing with the Pencil, Paintbrush and Blob Brush shows it too; a drag with nothing
/// selected and a Blob Brush stroke that paints do not.
#[test]
fn smoothing_drag_shows_the_band_it_smooths() {
use vectorcraft_geom::kurbo::{ParamCurveArclen, Shape};
let (d, id) = doc_with_rect();
let mut s = Selection::default();
s.set([id]);
let none = Selection::default();
let p = paint();
let alt = Mods { alt: true, ..Default::default() };
let pts = [(50.0, 150.0), (150.0, 150.0)];
for zoom in [1.0, 2.0, 0.5, 200.0] {
let mut cx = cx(&d, &s, &p);
cx.smart_guides = false;
cx.zoom = zoom;
let r = match drag(&mut GestureTool::new("smooth"), &cx, &pts, Mods::default()).last().and_then(|a| a.first()) {
Some(Action::Exec(c, v)) if c == "path.smoothRegion" => v["radius"].as_f64().unwrap_or(0.0),
a => panic!("expected path.smoothRegion, got: {a:?}"),
};
for (id, alt_smooth, m) in [("smooth", false, Mods::default()), ("pencil", true, alt), ("paintbrush", false, alt), ("blobBrush", false, alt)]
{
let mut t = GestureTool::new(id);
t.set_option("altSmooth", &json!(alt_smooth));
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1).with_mods(m));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, pts[1].0, pts[1].1).with_mods(m));
let band = check_band(&t, &cx, r);
let b = band.bounding_box();
let near = |a: f64, w: f64| (a - w).abs() < 0.02 * r;
assert!(near(b.x0, 50.0 - r) && near(b.x1, 150.0 + r) && near(b.y0, 150.0 - r) && near(b.y1, 150.0 + r), "{id} at zoom {zoom}: {b:?}");
// The whole outline shows: two sides and two half circles.
let length: f64 = band.segments().map(|s| s.arclen(1e-6 * r)).sum();
let want = 200.0 + 2.0 * std::f64::consts::PI * r;
assert!((length - want).abs() < 0.02 * want, "{id} at zoom {zoom}: outline {length}, band {want}");
assert!(
t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Path { width, dashed: false, .. } if *width == 1.0)),
"{id}: the drag's line"
);
}
// Nothing selected: the release smooths nothing, so no band shows.
let mut empty = crate::testutil::cx(&d, &none, &p);
empty.zoom = zoom;
let mut t = GestureTool::new("smooth");
t.pointer(&empty, &PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1));
t.pointer(&empty, &PointerEvent::new(PointerKind::Drag, pts[1].0, pts[1].1));
assert!(bands(&t, &empty).is_empty());
// A Blob Brush stroke that paints shows its own area, not the band.
let mut t = GestureTool::new("blobBrush");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, pts[1].0, pts[1].1));
assert!(bands(&t, &cx).is_empty());
}
}
/// The band shown while a long drag goes on, grown step by step, is one outline that holds every
/// point within the radius of the drag and none farther, whether the drag crosses itself or runs
/// on across the page; a zoom change mid-drag with the pointer still asks for frames until the band
/// covers the whole drag at the new radius, and the next drag does not keep the last one's band.
#[test]
fn smoothing_band_follows_a_long_drag() {
use vectorcraft_geom::kurbo::Shape;
let (d, id) = doc_with_rect();
let mut s = Selection::default();
s.set([id]);
let p = paint();
let mut cx = cx(&d, &s, &p);
cx.smart_guides = false;
// A curve that crosses itself, and one that runs on across the page.
let crossing: Vec<Point> =
(0..240).map(|i| f64::from(i) * 0.05).map(|a| Point::new(250.0 + 80.0 * (a * 1.3).sin(), 250.0 + 60.0 * (a * 0.7).cos())).collect();
let across: Vec<Point> =
(0..900).map(f64::from).map(|i| Point::new(20.0 + 0.5 * i, 250.0 + 100.0 * (i * 0.05).sin() + 30.0 * (i * 0.37).sin())).collect();
for pts in [crossing, across] {
cx.zoom = 1.0;
let mut t = GestureTool::new("smooth");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pts[0].x, pts[0].y));
for q in &pts[1..] {
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, q.x, q.y));
assert_eq!(bands(&t, &cx).len(), 1);
}
check_band(&t, &cx, cx.tol(12.0));
assert!(!t.overlays_pending(), "the band covers the drag");
// The view zooms in mid-drag while the pointer holds still: the tool asks for frames until
// the band covers the whole drag at the new radius.
cx.zoom = 2.0;
let mut frames = 1;
bands(&t, &cx);
while t.overlays_pending() {
assert!(frames <= pts.len(), "the band reaches the whole drag");
bands(&t, &cx);
frames += 1;
}
let n = t.points.len();
assert_eq!(frames, (n - 1).div_ceil(BAND_STEP * BAND_STEPS_PER_CALL), "frames for {n} points");
check_band(&t, &cx, cx.tol(12.0));
assert!(!t.overlays_pending());
let last = pts[pts.len() - 1];
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, last.x, last.y));
// The next drag shows only its own band.
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 400.0, 450.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 420.0, 450.0));
let b = check_band(&t, &cx, cx.tol(12.0)).bounding_box();
assert!(b.x0 > 390.0 && b.y0 > 440.0, "{b:?}");
}
}
/// A segment of a drag too long for the squares at the current zoom (a jump drawn zoomed out,
/// viewed zoomed in, or one reaching far off the page) shows as the two sides of its band, and the
/// band goes on past it: the parts dragged before and after it show, and nothing stays pending.
/// Two such segments that cross show no edge inside each other's band, and one that runs back
/// along another shows its sides once.
#[test]
fn smoothing_band_shows_a_segment_too_long_for_its_squares() {
use vectorcraft_geom::kurbo::{ParamCurveArclen, Shape};
let (d, id) = doc_with_rect();
let mut s = Selection::default();
s.set([id]);
let p = paint();
let mut cx = cx(&d, &s, &p);
cx.smart_guides = false;
let before = [Point::new(100.0, 100.0), Point::new(130.0, 100.0)];
let after = [Point::new(20_100.0, 100.0), Point::new(20_100.0, 130.0), Point::new(20_130.0, 130.0)];
let far = [Point::new(1e17, 100.0)];
let crossing = [(100.0, 100.0), (30_100.0, 100.0), (30_100.0, 130.0), (15_100.0, -20_000.0), (15_130.0, -20_000.0)].map(Point::from);
let back = [(100.0, 100.0), (30_100.0, 100.0), (130.0, 100.0)].map(Point::from);
for (case, pts) in [
("jump", [&before[..], &after[..]].concat()),
("far", [&before[..], &far[..], &after[..]].concat()),
("crossing", crossing.to_vec()),
("back", back.to_vec()),
] {
// Drawn zoomed out, where the jump is about 42 half squares, then viewed at 1,000%, where
// it is more than 4,096.
cx.zoom = 0.1;
let mut t = GestureTool::new("smooth");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pts[0].x, pts[0].y));
for q in &pts[1..] {
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, q.x, q.y));
bands(&t, &cx);
}
cx.zoom = 10.0;
let r = cx.tol(12.0);
let mut frames = 0;
let mut found = bands(&t, &cx);
while t.overlays_pending() {
assert!(frames < pts.len(), "{case}: the band reaches the whole drag");
found = bands(&t, &cx);
frames += 1;
}
let [outline] = found.as_slice() else { panic!("{case}: one band, got {found:?}") };
for seg in outline.segments() {
for s in [0.0, 0.25, 0.5, 0.75, 1.0] {
let q = seg.eval(s);
let dq = dist_to_drag(q, &pts);
assert!((dq - r).abs() < 0.02 * r, "{case}: {q:?} on the outline is {dq} from the drag, radius {r}");
}
}
// The band of each long segment shows on both of its sides, as far as the release reaches,
// except where the band of another part of the drag holds the side (on the page: a point
// on a segment to 1e17 resolves only to about 10 units).
let on_outline = |q: Point| {
outline.segments().any(|seg| {
(0..=8).any(|k| seg.eval(f64::from(k) / 8.0).distance(q) < 0.02 * r) || dist_to_drag(q, &[seg.start(), seg.end()]) < 0.02 * r
})
};
for w in pts.windows(2).filter(|w| w[0].distance(w[1]) > 10_000.0 && w[0].x < 1e6 && w[1].x < 1e6) {
let d = w[1] - w[0];
let n = vectorcraft_geom::kurbo::Vec2::new(-d.y, d.x) * (r / d.hypot());
for k in 1..40 {
for q in [w[0].lerp(w[1], f64::from(k) / 40.0) + n, w[0].lerp(w[1], f64::from(k) / 40.0) - n] {
if dist_to_drag(q, &pts) > 0.98 * r {
assert!(on_outline(q), "{case}: {q:?} on a side of {w:?} shows");
}
}
}
}
if case == "back" {
// Running back along the jump draws its sides once: two sides and two half circles.
let length: f64 = outline.segments().map(|s| s.arclen(1e-6 * r)).sum();
let want = 2.0 * 30_000.0 + 2.0 * std::f64::consts::PI * r;
assert!((length - want).abs() < 0.01 * want, "{case}: outline {length}, band {want}");
}
let kept = t.band.take();
let areas: Vec<_> = kept.iter().flat_map(|b| b.tiles.values().map(|t| t.area.to_bezpath())).collect();
t.band.set(kept);
// Every point near each short part dragged on either side of a long segment is in the band.
for w in pts.windows(2).filter(|w| w[0].distance(w[1]) < 1_000.0) {
for q in (0..=20).map(|k| w[0].lerp(w[1], f64::from(k) / 20.0)) {
for (dx, dy) in [(0.0, 0.0), (0.9, 0.0), (-0.9, 0.0), (0.0, 0.9), (0.0, -0.9)] {
let probe = q + vectorcraft_geom::kurbo::Vec2::new(dx * r, dy * r);
assert!(areas.iter().any(|a| a.contains(probe)), "{case}: {probe:?} near the drag is in the band");
}
}
}
}
}
/// A drag drawn zoomed out whose every segment is too long for the squares once the view zooms in,
/// and whose segments all cross one another, is outlined over several calls: no call does more
/// than about [`BAND_WORK_PER_CALL`] of work on the long segments, and the band then shows in full.
#[test]
fn smoothing_band_of_many_long_segments_is_made_in_bounded_calls() {
let (d, id) = doc_with_rect();
let mut s = Selection::default();
s.set([id]);
let p = paint();
let mut cx = cx(&d, &s, &p);
cx.smart_guides = false;
// A star whose chords nearly all pass through its middle, 4,000 units each.
let step = std::f64::consts::PI * (1.0 - 1.0 / 40.3);
let pts: Vec<Point> = (0..161).map(|k| f64::from(k) * step).map(|a| Point::new(5000.0 + 2000.0 * a.cos(), 5000.0 + 2000.0 * a.sin())).collect();
cx.zoom = 0.05;
let mut t = GestureTool::new("smooth");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pts[0].x, pts[0].y));
for q in &pts[1..] {
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, q.x, q.y));
bands(&t, &cx);
}
cx.zoom = 640.0;
let r = cx.tol(SMOOTH_RADIUS);
let work = |t: &GestureTool| {
let kept = t.band.take();
let w = kept.as_ref().map_or(0, |b| b.work);
t.band.set(kept);
w
};
let mut found = bands(&t, &cx);
let mut frames = 1;
let mut most = work(&t);
while t.overlays_pending() {
assert!(frames < 10_000, "the band reaches the whole drag");
found = bands(&t, &cx);
most = most.max(work(&t));
frames += 1;
}
assert!(most <= 2 * BAND_WORK_PER_CALL, "{most} work in one call");
assert!(frames > 2, "{frames} calls");
let kept = t.band.take();
let long = kept.as_ref().map_or(0, |b| b.long.len());
t.band.set(kept);
assert_eq!(long, pts.len() - 1, "every segment is long");
let [outline] = found.as_slice() else { panic!("one band, got {}", found.len()) };
for seg in outline.segments().step_by(7) {
for s in [0.0, 0.5, 1.0] {
let q = seg.eval(s);
let dq = dist_to_drag(q, &pts);
assert!((dq - r).abs() < 0.02 * r, "{q:?} on the outline is {dq} from the drag, radius {r}");
}
}
}
/// The band is kept in squares; a straight drag whose band edge runs along a side of a square, or
/// just inside or outside it, still shows that edge, and the sides between two squares the band
/// fills do not show.
#[test]
fn smoothing_band_shows_an_edge_along_a_square_side() {
use vectorcraft_geom::kurbo::{ParamCurveArclen, Shape};
let (d, id) = doc_with_rect();
let mut s = Selection::default();
s.set([id]);
let p = paint();
let mut cx = cx(&d, &s, &p);
cx.smart_guides = false;
for zoom in [1.0, 3.0] {
cx.zoom = zoom;
let r = cx.tol(SMOOTH_RADIUS);
let side = BAND_TILE_RADII * r;
// A side of the squares near 230 (across and down: the grid is square).
let at = (BAND_TILE_OFFSET + (230.0 / side - BAND_TILE_OFFSET).round()) * side;
let eps = 1e-3 * r;
for off in [0.0, 0.5 * eps, -0.5 * eps, 1.5 * eps, -1.5 * eps] {
for (edge, upright) in [(-r, false), (r, false), (-r, true), (r, true)] {
let c = at + edge + off;
let ends = if upright {
[Point::new(c, 100.0), Point::new(c, 100.0 + 2.5 * side)]
} else {
[Point::new(100.0, c), Point::new(100.0 + 2.5 * side, c)]
};
let mut t = GestureTool::new("smooth");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, ends[0].x, ends[0].y));
for i in 1..=20 {
let q = ends[0].lerp(ends[1], f64::from(i) / 20.0);
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, q.x, q.y));
}
let band = check_band(&t, &cx, r);
// Two sides and two half circles, each shown once.
let length: f64 = band.segments().map(|s| s.arclen(1e-6 * r)).sum();
let want = 2.0 * 2.5 * side + 2.0 * std::f64::consts::PI * r;
assert!(
(length - want).abs() < 0.02 * want,
"zoom {zoom}, edge {edge}, off {off}, upright {upright}: outline {length}, band {want}, {:?}",
band.bounding_box()
);
}
}
}
}
/// A drag whose band edge runs along a side of a square and then turns across it shows the edge
/// up to the turn and no line where the band goes on across the side.
#[test]
fn smoothing_band_shows_no_seam_where_it_turns_across_a_square_side() {
let (d, id) = doc_with_rect();
let mut s = Selection::default();
s.set([id]);
let p = paint();
let mut cx = cx(&d, &s, &p);
cx.smart_guides = false;
for zoom in [1.0, 3.0] {
cx.zoom = zoom;
let r = cx.tol(SMOOTH_RADIUS);
let side = BAND_TILE_RADII * r;
let at = (BAND_TILE_OFFSET + (230.0 / side - BAND_TILE_OFFSET).round()) * side;
let eps = 1e-3 * r;
for off in [0.0, 0.5 * eps, -0.5 * eps] {
for (edge, upright) in [(-r, false), (r, false), (-r, true), (r, true)] {
let c = at + edge + off;
// Along the side, then across it.
let turn = 100.0 + 1.5 * side;
let corners = [(100.0, c), (turn, c), (turn, c - edge.signum() * 2.0 * side)];
let pts: Vec<Point> = corners.into_iter().map(|(u, v)| if upright { Point::new(v, u) } else { Point::new(u, v) }).collect();
let mut t = GestureTool::new("smooth");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pts[0].x, pts[0].y));
for w in pts.windows(2) {
for i in 1..=12 {
let q = w[0].lerp(w[1], f64::from(i) / 12.0);
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, q.x, q.y));
}
}
check_band(&t, &cx, r);
}
}
}
}
/// One straight piece of a square's outline along a side, where the band goes on across only part
/// of it, shows only the part with nothing across.
#[test]
fn smoothing_band_square_shows_only_the_open_part_of_a_side() {
use vectorcraft_geom::kurbo::{ParamCurveArclen, Shape};
let mut band = SmoothBand::new(Point::ZERO, 10.0);
let r = band.tile_rect((0, 0));
let (x, y) = (r.x0, r.y1);
let area = |x0: f64, y0: f64, x1: f64, y1: f64| vectorcraft_geom::shapes::rectangle(Rect::new(x0, y0, x1, y1));
let tile = |area| BandTile { area, outline: vectorcraft_geom::BezPath::new(), near_long: vec![], cut: vectorcraft_geom::BezPath::new() };
// Along the bottom side from 10 to 60, with the band across it from 40 to 60.
band.tiles.insert((0, 0), tile(area(x + 10.0, y - 20.0, x + 60.0, y)));
band.tiles.insert((0, 1), tile(area(x + 40.0, y, x + 60.0, y + 20.0)));
let outline = band.tile_outline((0, 0));
let along: f64 = outline.segments().filter(|s| s.bounding_box().height() < 1e-9 && (s.start().y - y).abs() < 1e-9).map(|s| s.arclen(1e-9)).sum();
assert!((along - 30.0).abs() < 1e-6, "{along} of the side shows: {}", outline.to_svg());
for s in outline.segments() {
assert!(!((s.start().y - y).abs() < 1e-9 && (s.end().y - y).abs() < 1e-9 && s.start().x.max(s.end().x) > x + 40.0 + 1e-9), "{s:?}");
}
}
/// A selection with no path the Smooth tool can change (a two-anchor line) shows no band.
#[test]
fn smoothing_drag_shows_no_band_without_a_path_it_smooths() {
let (mut d, _) = doc_with_rect();
let l = d.layers[0].id;
let id = d.alloc_id();
let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![
vectorcraft_geom::PathEl::MoveTo(Point::new(300.0, 300.0)),
vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 300.0)),
]));
d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap();
let mut s = Selection::default();
s.set([id]);
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = GestureTool::new("smooth");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 300.0, 300.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 400.0, 300.0));
let o = t.overlays(&cx);
assert!(!o.iter().any(|o| matches!(o, Overlay::Path { dashed: true, .. })), "{o:?}");
}
/// An Alt smoothing drag uses the Smooth tool's Fidelity for Pencil, Paintbrush and Blob Brush.
#[test]
fn alt_smoothing_uses_the_smooth_tools_fidelity() {
@@ -1665,7 +2098,7 @@ fn the_eraser_sends_its_tip_and_shows_the_area_it_covers() {
/// The Blob Brush's Angle and Roundness (Blob Brush Tool Options, 0° and 100% by default) go with
/// its stroke, kept within −180–180° and 0–100%; while it drags, a tip that is not round shows the
/// outline of the area it covers with the length label, and an Alt drag that smooths shows the
/// Smooth tool's thin line.
/// Smooth tool's thin line and the dashed band it smooths.
#[test]
fn the_blob_brush_sends_its_tip_and_shows_the_area_it_covers() {
let (d, id) = doc_with_rect();
@@ -1704,7 +2137,7 @@ fn the_blob_brush_sends_its_tip_and_shows_the_area_it_covers() {
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0).with_mods(alt));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0).with_mods(alt));
let o = t.overlays(&cx);
let [Overlay::Path { path, width, .. }] = &o[..] else { panic!("{o:?}") };
let [Overlay::Path { dashed: true, .. }, Overlay::Path { path, width, dashed: false, .. }] = &o[..] else { panic!("{o:?}") };
assert_eq!(*width, 1.0);
assert!(vectorcraft_geom::Shape::bounding_box(path).height() < 0.1, "{o:?}");
}
+6
View File
@@ -136,6 +136,12 @@ pub trait Tool: Send {
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
vec![]
}
/// Did the last [`Tool::overlays`] leave part of its overlays for later calls (the band of a
/// long smoothing drag, outlined again after a zoom change)? The host then draws again on the
/// next frame, even when the pointer holds still.
fn overlays_pending(&self) -> bool {
false
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
Cursor::Arrow
}
+4
View File
@@ -1266,6 +1266,10 @@ fn selection_visuals(
} else {
vec![]
};
// The tool shows the rest of its overlays on the next frame.
if app.session.tool_overlays_pending() {
painter.ctx().request_repaint();
}
// Gap Options' Preview shows the gaps it finds.
overlays.extend(crate::dialogs::live_paint_gaps::overlays(app));
if app.ui.view.edges && !revolve_edit {
+2 -2
View File
@@ -1040,8 +1040,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. The Pen continues any open path from the end clicked, and while drawing, a click on an end of another open path joins the two into one (#776). The Curvature tool edits any selected path, whatever drew it, keeping its shape except where edited (#798): a click on a point makes it current and a drag moves it, re-curving only its two segments; double-click, or Alt-click on any point but an end of an open path, toggles it smooth or corner and Delete removes it; a click on a segment adds a point without changing the shape; with an end of an open path current, clicks go on from it and its other end closes it (`path.curvatureEdit`). An Alt-click on an end of a selected open path makes that end current without changing it, so the next click goes on from it, as [the help page](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-shapes-with-the-curvature-tool.html) describes for continuing a path; an Alt-click that starts a new path places a corner point, as later Alt-clicks do. Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners. A filled Pencil path keeps a stroke of None (#1804, part of #1679). While a Pencil or Paintbrush stroke is drawn back to within Close Paths When Ends Are Within of its start, the cursor shows the closing ring and a release there closes the path, as [Draw straight lines with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-straight-lines-with-the-pencil-tool.html) describes for the Pencil. The [ and ] keys reach the Blob Brush, the Eraser and the Symbolism tools in the app (#1734, part of #1661).
Blob Brush and Eraser Tool Options' Size is Fixed or Pressure with a Variation (#4590, #3961): with Pressure a pen stroke is Size − Variation wide where it presses lightest and Size + Variation where it presses hardest, and a mouse stroke keeps the Size.
With the Blob Brush, Shift held during a drag keeps the stroke on the horizontal or vertical line through the press point, whichever is nearer to the pointer (90° steps from the Constrain Angle preference), and the points dragged since Shift went down move to the other line when the pointer crosses to it; the points dragged while Shift is up follow the pointer.
With the Blob Brush, Alt held before the press, or pressed before the drag has moved, switches to the Smooth tool, as the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists: the cursor shows the Smooth tool, the drag shows a thin line without the length label, Shift does not constrain it, and the release smooths the selected paths under the drag (`path.smoothRegion`) instead of painting; with nothing selected the release changes no path. A stroke that has moved keeps painting when Alt goes down.
Curvature point markers are circular, with no direction handles. While drawing, its own point feedback replaces generic selection markers; existing paths use the selection overlay, including partial selections and Hide Edges. Anchor Size applies, and Direct Selection and Pen retain their handles. The real egui-frame regressions in `crates/ui-egui/src/canvas.rs` verify marker positions, counts and unchanged documents (#1236, in part); the existing-path interpolation request remains open. The curve through the points (`curvature_spline` in `crates/tools/src/draw2.rs`) makes each stretch of three points follow the circle through them: a smooth point's handles lie along that circle's tangent, each `chord / (3 cos²(α/2))` long (α between handle and chord, capped at 90°), and the ends of an open path mirror their neighbour's tangent in their chord, so a J or a U curves evenly, a turn tighter than 30° comes to a point, and uneven spacing doesn't loop; the rubber band, a dragged or inserted point and `path.convertAnchor` to smooth use the same rule, while the Pencil's fitting keeps its own; `tests/draw2.rs` checks that the rubber band is the path the click makes (#1236, in part). The Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512). A path the Smooth tool changes stays selected whole, and the anchor points and segments selected on it with Direct Selection are deselected. Selected paths the drag does not change keep their selected anchors, and undo restores the earlier selection (`tests/draw2.rs`, `smoothing_a_path_drops_its_direct_selected_anchors`).
With the Blob Brush, Alt held before the press, or pressed before the drag has moved, switches to the Smooth tool, as the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists: the cursor shows the Smooth tool, the drag shows a thin line and, with a path selected that it can smooth, the dashed band it smooths, without the length label, Shift does not constrain it, and the release smooths the selected paths under the drag (`path.smoothRegion`) instead of painting; with nothing selected the release changes no path. A stroke that has moved keeps painting when Alt goes down.
Curvature point markers are circular, with no direction handles. While drawing, its own point feedback replaces generic selection markers; existing paths use the selection overlay, including partial selections and Hide Edges. Anchor Size applies, and Direct Selection and Pen retain their handles. The real egui-frame regressions in `crates/ui-egui/src/canvas.rs` verify marker positions, counts and unchanged documents (#1236, in part); the existing-path interpolation request remains open. The curve through the points (`curvature_spline` in `crates/tools/src/draw2.rs`) makes each stretch of three points follow the circle through them: a smooth point's handles lie along that circle's tangent, each `chord / (3 cos²(α/2))` long (α between handle and chord, capped at 90°), and the ends of an open path mirror their neighbour's tangent in their chord, so a J or a U curves evenly, a turn tighter than 30° comes to a point, and uneven spacing doesn't loop; the rubber band, a dragged or inserted point and `path.convertAnchor` to smooth use the same rule, while the Pencil's fitting keeps its own; `tests/draw2.rs` checks that the rubber band is the path the click makes (#1236, in part). The Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512). A path the Smooth tool changes stays selected whole, and the anchor points and segments selected on it with Direct Selection are deselected. Selected paths the drag does not change keep their selected anchors, and undo restores the earlier selection (`tests/draw2.rs`, `smoothing_a_path_drops_its_direct_selected_anchors`). While the Smooth tool drags with a path selected that it can smooth, a dashed outline shows the band it smooths: everything within its radius of the drag (12 screen pixels, VectorCraft's own value), round at both ends; Alt smoothing with the Pencil, Paintbrush and Blob Brush shows the same band.
While Alt is held during a Paintbrush stroke or before a press with no path selected that the Smooth tool can act on, the Paintbrush shows a closing cursor (a crosshair with a ring), and so does the Pencil with Alt key toggles to Smooth Tool off when Alt is pressed after the drag starts; releasing the mouse button with Alt still held closes the path. Before a Paintbrush press with such a path selected, Alt shows the Smooth cursor, and holding Alt at the press smooths that path with the Smooth tool's radius and Smooth Tool Options' Fidelity. A Pencil drag started with Shift or Alt held draws a straight line instead, which stays open.
With Shift held at the press, the Pencil draws a straight line from the press point to the pointer at 45° steps from the Constrain Angle, as [Draw straight lines with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-straight-lines-with-the-pencil-tool.html) describes; pressed on an end of a selected open path, the line continues the path from that end (part of #3799). With Option/Alt held at the press and Alt key toggles to Smooth Tool off, the Pencil draws a straight line at any angle, whose ends snap to Smart Guides, and it stays open when Alt is still held at the release; Shift and Alt together keep the 45° steps (#3799). While Shift or Alt is held before the press, the Pencil shows a straight-line cursor (a crosshair with a short line), and over an end of a selected open path, where the line continues the path, a path-continuation cursor (the same line leaving a small square), as the page describes.
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. A shape just drawn stays adjustable with its tool: its Live Shape widgets drag, a press on its centre point (Show Center) moves the selection with the Selection tool's move arrow, snapping and Alt copy, and a press in the rotate zone just outside a bounding-box corner turns it about the centre with the Selection tool's rotate cursor (#5477; tests `a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corner` and, through the engine, `shapetools.rs`). A drag on one of its eight bounding-box handles scales it as the Selection tool's handles do, with their cursors and snapping, instead of drawing a new shape from the handle. A shape tool takes a press as a handle's only on the drawn handle square (VectorCraft's own rule: 3 screen points from its centre along each axis at the default Anchor Size, a half point more or less per step; the Selection tool takes 5), so a press just beside a handle still draws (tests `a_shape_tool_scales_the_selection_by_its_bounding_box_handles`, `a_shape_tool_draws_from_a_press_just_beside_a_handle` and `a_shape_tool_scales_the_shape_just_drawn_by_its_bounding_box_handle`). The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812).