Files
photocraft/crates/vector/src/edit.rs
T
bcaadccbfa Vector: bound flattening memory, keep over-budget dashes, cheaper edge build (#5195) (#5204)
* Vector: bound flattening memory, keep over-budget dashes, cheaper edge build (#5195)

- flatten: a path's flattened points are capped at 1 << 20; past it every
  curve keeps a proportional share of its pieces (at least one), so hostile
  handles from files (vmsk, SVG, effect outlines) cannot exhaust memory.
  fill_rasterizer flattens the whole path under one budget.
- stroke: past the dash step budget (or below coordinate precision),
  dash_polyline keeps the dashes made so far and draws the rest solid
  instead of dropping the stroke. Open polylines are no longer copied.
- raster: coordinates are clamped to +-1e15 and edges with a non-finite
  slope (vanishingly short) are skipped, so huge finite coordinates no
  longer produce NaN coverage. fill_rasterizer sorts edges once instead of
  after every component (stable sort, identical order).
- Cleanups: dead miter `half`, dead loop-length check in trace_mask,
  shared mask render body, map_knots helper, shared straight-segment test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Vector: bound stroke outline points (#5211)

- stroke_polygons dashes all lines first, then counts round caps and
  joins. When their full-resolution tessellation would exceed 1 << 20
  outline points, every round cap and join gets the same smaller segment
  count (down to 4 per circle, 1 per join; area-exact radius kept).
  Strokes under the budget are unchanged; no piece is dropped.
- The dash step budget (100000) is now shared by all lines of a stroke
  instead of per polyline, so many subpaths cannot multiply the dash
  (and cap) count. Past it the rest is drawn solid, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:02:09 -07:00

629 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Point-level path editing, Photoshop's Direct Selection, Convert Point and Add / Delete Anchor
//! Point tools: move anchors, drag direction handles, reshape a segment by dragging it, convert
//! between smooth and corner points, insert and remove anchors, and hit-test a path's parts. Pure geometry on [`Path`]; the engine's `path.*` edit
//! commands and the canvas previews share it, so a preview is exactly what the command commits.
//!
//! Knots are addressed as `[subpath, knot]`. Every edit validates its indices and returns an
//! error message instead of panicking.
use photocraft_doc::{Knot, Path};
use photocraft_geom::Point;
/// One of a knot's two direction handles.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Handle {
/// The control point of the segment arriving at the knot.
In,
/// The control point of the segment leaving the knot.
Out,
}
impl Handle {
pub fn parse(s: &str) -> Option<Handle> {
match s {
"in" => Some(Handle::In),
"out" => Some(Handle::Out),
_ => None,
}
}
pub fn name(self) -> &'static str {
match self {
Handle::In => "in",
Handle::Out => "out",
}
}
}
/// The part of a path under the pointer.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Hit {
Anchor([usize; 2]),
Handle([usize; 2], Handle),
/// The segment leaving knot `[subpath, knot]`, at curve parameter `t`.
Segment([usize; 2], f64),
}
fn add(p: Point, d: [f64; 2]) -> Point {
Point::new(p.x + d[0], p.y + d[1])
}
fn dist(a: Point, b: Point) -> f64 {
(a.x - b.x).hypot(a.y - b.y)
}
fn knot_mut(path: &mut Path, [s, k]: [usize; 2]) -> Result<&mut Knot, String> {
path.subpaths.get_mut(s).and_then(|sp| sp.knots.get_mut(k)).ok_or_else(|| format!("no knot {k} in subpath {s}"))
}
/// The knot ending the segment that leaves `[s, k]` (wrapping on closed subpaths).
fn segment_end(path: &Path, [s, k]: [usize; 2]) -> Result<[usize; 2], String> {
let sp = path.subpaths.get(s).ok_or_else(|| format!("no subpath {s}"))?;
let n = sp.knots.len();
match k.checked_add(1) {
Some(next) if k < n && next < n => Ok([s, next]),
Some(_) if k < n && sp.closed && n > 1 => Ok([s, 0]),
_ => Err(format!("no segment leaves knot {k} of subpath {s}")),
}
}
fn handle_mut(k: &mut Knot, h: Handle) -> &mut Point {
match h {
Handle::In => &mut k.in_ctrl,
Handle::Out => &mut k.out_ctrl,
}
}
fn handle(k: &Knot, h: Handle) -> Point {
match h {
Handle::In => k.in_ctrl,
Handle::Out => k.out_ctrl,
}
}
/// Whether the segment from knot `a` to knot `b` is straight: both of its handles retracted.
fn straight(a: &Knot, b: &Knot) -> bool {
let retracted = |k: &Knot, h: Handle| dist(handle(k, h), k.anchor) < 1e-9;
retracted(a, Handle::Out) && retracted(b, Handle::In)
}
fn other(h: Handle) -> Handle {
match h {
Handle::In => Handle::Out,
Handle::Out => Handle::In,
}
}
/// A smooth knot keeps its handles collinear: point handle `h`'s opposite away from it, keeping
/// the opposite's length (a retracted handle stays retracted, as in Photoshop).
fn keep_smooth(k: &mut Knot, h: Handle) {
if !k.smooth {
return;
}
let (a, moved) = (k.anchor, handle(k, h));
let opp = handle_mut(k, other(h));
let (len, d) = (dist(*opp, a), dist(moved, a));
if len > 1e-9 && d > 1e-9 {
*opp = Point::new(a.x - (moved.x - a.x) / d * len, a.y - (moved.y - a.y) / d * len);
}
}
/// Moves anchors (with their handles) by `d`. Each knot moves once, however often it is listed.
pub fn move_anchors(path: &mut Path, anchors: &[[usize; 2]], d: [f64; 2]) -> Result<(), String> {
let mut list = anchors.to_vec();
list.sort_unstable();
list.dedup();
for r in &list {
knot_mut(path, *r)?;
}
for r in list {
let k = knot_mut(path, r)?;
*k = Knot { anchor: add(k.anchor, d), in_ctrl: add(k.in_ctrl, d), out_ctrl: add(k.out_ctrl, d), smooth: k.smooth };
}
Ok(())
}
/// The Curvature Pen's anchor drag: moves the anchors like [`move_anchors`], then recomputes the
/// handles of every smooth knot in the subpaths it touched with [`curvature_knots`], so the curve
/// keeps flowing smoothly through all its anchors, as Photoshop does. Corner knots keep their own
/// handles (moved with them).
pub fn move_anchors_curvature(path: &mut Path, anchors: &[[usize; 2]], d: [f64; 2]) -> Result<(), String> {
move_anchors(path, anchors, d)?;
let mut subs: Vec<usize> = anchors.iter().map(|a| a[0]).collect();
subs.sort_unstable();
subs.dedup();
for s in subs {
let sp = path.subpaths.get_mut(s).ok_or_else(|| format!("no subpath {s}"))?;
let pts: Vec<[f64; 2]> = sp.knots.iter().map(|k| [k.anchor.x, k.anchor.y]).collect();
let corners: Vec<bool> = sp.knots.iter().map(|k| !k.smooth).collect();
let fresh = curvature_knots(&pts, &corners, sp.closed);
for (k, f) in sp.knots.iter_mut().zip(fresh) {
if k.smooth {
k.in_ctrl = Point::new(f[1][0], f[1][1]);
k.out_ctrl = Point::new(f[2][0], f[2][1]);
}
}
}
Ok(())
}
/// The Curvature Pen's double-click on an anchor: a corner becomes smooth and a smooth knot a
/// corner (handles retracted), then the subpath reflows as [`move_anchors_curvature`] does, so the
/// segments around a new smooth point curve through it.
pub fn toggle_curvature_point(path: &mut Path, knot: [usize; 2]) -> Result<(), String> {
let k = knot_mut(path, knot)?;
*k = if k.smooth { Knot::corner(k.anchor.x, k.anchor.y) } else { Knot { smooth: true, ..*k } };
move_anchors_curvature(path, &[knot], [0.0, 0.0])
}
/// Puts handle `h` of a knot at `to`. A smooth knot turns its other handle to stay collinear,
/// unless `independent` (Convert Point / ⌥), which makes it a corner whose handles move apart.
pub fn move_handle(path: &mut Path, knot: [usize; 2], h: Handle, to: Point, independent: bool) -> Result<(), String> {
let k = knot_mut(path, knot)?;
*handle_mut(k, h) = to;
if independent {
k.smooth = false;
}
keep_smooth(k, h);
Ok(())
}
/// Convert Point: without `out`, a corner with retracted handles (a click on an anchor); with
/// `out`, a smooth knot whose handles are `out` and its mirror (a drag from an anchor).
pub fn convert_point(path: &mut Path, knot: [usize; 2], out: Option<Point>) -> Result<(), String> {
let k = knot_mut(path, knot)?;
let a = k.anchor;
*k = match out {
Some(o) => Knot::smooth(a, Point::new(2.0 * a.x - o.x, 2.0 * a.y - o.y), o),
None => Knot::corner(a.x, a.y),
};
Ok(())
}
/// Drags the segment leaving `knot` at curve parameter `t` by `d`. A straight segment (both
/// handles retracted) moves with its two anchors; a curve reshapes so the point at `t` follows
/// the pointer exactly, with the nearer handle moving more. Smooth end knots stay smooth.
pub fn bend_segment(path: &mut Path, knot: [usize; 2], t: f64, d: [f64; 2]) -> Result<(), String> {
let end = segment_end(path, knot)?;
let a = *knot_mut(path, knot)?;
let b = *knot_mut(path, end)?;
if straight(&a, &b) {
return move_anchors(path, &[knot, end], d);
}
// B(t) moves by 3(1−t)²t·Δc1 + 3(1−t)t²·Δc2. With Δc1 = (1−t)·s·d and Δc2 = t·s·d that is
// 3t(1−t)((1−t)² + t²)·s·d, so s below makes it exactly d. The ends are clamped: a drag right
// at an anchor would need an unbounded handle move.
let t = if t.is_finite() { t.clamp(0.05, 0.95) } else { 0.5 };
let u = 1.0 - t;
let s = 1.0 / (3.0 * t * u * (u * u + t * t));
let (w1, w2) = (u * s, t * s);
let ka = knot_mut(path, knot)?;
ka.out_ctrl = add(ka.out_ctrl, [d[0] * w1, d[1] * w1]);
keep_smooth(ka, Handle::Out);
let kb = knot_mut(path, end)?;
kb.in_ctrl = add(kb.in_ctrl, [d[0] * w2, d[1] * w2]);
keep_smooth(kb, Handle::In);
Ok(())
}
fn lerp(a: Point, b: Point, t: f64) -> Point {
Point::new(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t)
}
/// Add Anchor Point: inserts an anchor into the segment leaving `knot` at curve parameter `t`
/// (strictly between 0 and 1) and returns the new knot's `[subpath, knot]`. A curve is split at
/// `t` (de Casteljau), so its shape does not change and the new anchor is smooth; on a straight
/// segment (both handles retracted) the new anchor is a corner on the line.
pub fn add_anchor(path: &mut Path, knot: [usize; 2], t: f64) -> Result<[usize; 2], String> {
if !(t.is_finite() && t > 0.0 && t < 1.0) {
return Err("`t` must be strictly between 0 and 1".into());
}
let end = segment_end(path, knot)?;
let a = *knot_mut(path, knot)?;
let b = *knot_mut(path, end)?;
let new = if straight(&a, &b) {
// Hit testing and hover feedback use the cubic parameter, which is not linear even
// when both handles are retracted. Keep the inserted corner at that same point.
let p = eval(&[a.anchor, a.out_ctrl, b.in_ctrl, b.anchor], t);
Knot::corner(p.x, p.y)
} else {
let (p01, p12, p23) = (lerp(a.anchor, a.out_ctrl, t), lerp(a.out_ctrl, b.in_ctrl, t), lerp(b.in_ctrl, b.anchor, t));
let (p012, p123) = (lerp(p01, p12, t), lerp(p12, p23, t));
knot_mut(path, knot)?.out_ctrl = p01;
knot_mut(path, end)?.in_ctrl = p23;
Knot::smooth(lerp(p012, p123, t), p012, p123)
};
let [s, k] = knot;
let at = k.checked_add(1).ok_or_else(|| format!("no segment leaves knot {k} of subpath {s}"))?;
let sp = path.subpaths.get_mut(s).ok_or_else(|| format!("no subpath {s}"))?;
// `at` ≤ len: the segment leaving `k` exists, so `k` < len.
sp.knots.insert(at.min(sp.knots.len()), new);
Ok([s, at])
}
/// Delete Anchor Point: removes a knot. Its neighbours are joined by one segment that keeps the
/// previous anchor's out handle and the next anchor's in handle; a closed subpath stays closed.
/// Removing a subpath's last anchor removes the subpath. Refused (unchanged) when it would leave
/// the path with no anchors at all. Returns whether the subpath was removed.
pub fn delete_anchor(path: &mut Path, [s, k]: [usize; 2]) -> Result<bool, String> {
knot_mut(path, [s, k])?;
let total: usize = path.subpaths.iter().map(|sp| sp.knots.len()).fold(0, usize::saturating_add);
if total <= 1 {
return Err("can't delete the path's only anchor point; delete the path instead".into());
}
let sp = path.subpaths.get_mut(s).ok_or_else(|| format!("no subpath {s}"))?;
if sp.knots.len() <= 1 {
path.subpaths.remove(s);
return Ok(true);
}
sp.knots.remove(k);
Ok(false)
}
/// Every anchor of subpath `s` (⌥-click with Direct Selection selects the whole subpath).
pub fn subpath_anchors(path: &Path, s: usize) -> Vec<[usize; 2]> {
path.subpaths.get(s).map_or_else(Vec::new, |sp| (0..sp.knots.len()).map(|k| [s, k]).collect())
}
/// Anchors inside the box `[x0, y0, x1, y1]` (a Direct Selection marquee), in path order.
pub fn anchors_in(path: &Path, [x0, y0, x1, y1]: [f64; 4]) -> Vec<[usize; 2]> {
let (x0, x1, y0, y1) = (x0.min(x1), x0.max(x1), y0.min(y1), y0.max(y1));
let mut out = Vec::new();
for (s, sp) in path.subpaths.iter().enumerate() {
for (k, kn) in sp.knots.iter().enumerate() {
if (x0..=x1).contains(&kn.anchor.x) && (y0..=y1).contains(&kn.anchor.y) {
out.push([s, k]);
}
}
}
out
}
/// The Curvature Pen's knots, `[anchor, in, out]`, through `anchors`: a smooth curve that passes
/// through every anchor, Catmull-Rom style. An inner anchor's handles lie on the line parallel
/// to its neighbours' chord (so they are collinear), each a third of the distance to the
/// neighbour on its side long (the chordal scaling that keeps uneven spacing from overshooting).
/// The ends of an open path aim at their neighbour's nearer handle. `corners[i]` retracts anchor
/// `i`'s handles (a corner point); a missing entry is smooth. Coincident points give retracted
/// handles, never NaN.
pub fn curvature_knots(anchors: &[[f64; 2]], corners: &[bool], closed: bool) -> Vec<[[f64; 2]; 3]> {
let n = anchors.len();
let mut knots: Vec<[[f64; 2]; 3]> = anchors.iter().map(|&a| [a; 3]).collect();
let dist = |a: [f64; 2], b: [f64; 2]| (b[0] - a[0]).hypot(b[1] - a[1]);
let unit = |a: [f64; 2], b: [f64; 2]| {
let l = dist(a, b);
(l > 1e-9).then(|| [(b[0] - a[0]) / l, (b[1] - a[1]) / l])
};
let corner = |i: usize| corners.get(i).copied().unwrap_or(false);
let inner = |i: usize| closed || (i > 0 && i + 1 < n);
for (i, &p) in anchors.iter().enumerate() {
if n < 3 || corner(i) || !inner(i) {
continue;
}
let prev = anchors[(i + n - 1) % n];
let next = anchors[(i + 1) % n];
let Some(d) = unit(prev, next).or_else(|| unit(p, next)) else { continue };
let (li, lo) = (dist(prev, p) / 3.0, dist(p, next) / 3.0);
knots[i] = [p, [p[0] - d[0] * li, p[1] - d[1] * li], [p[0] + d[0] * lo, p[1] + d[1] * lo]];
}
if !closed && n >= 3 {
for (end, nb, take_in) in [(0, 1, true), (n - 1, n - 2, false)] {
if corner(end) {
continue;
}
let p = anchors[end];
let aim = if take_in { knots[nb][1] } else { knots[nb][2] };
let Some(d) = unit(p, aim) else { continue };
let l = dist(p, anchors[nb]) / 3.0;
let h = [p[0] + d[0] * l, p[1] + d[1] * l];
if take_in {
knots[end][2] = h;
} else {
knots[end][1] = h;
}
}
}
knots
}
/// The point at parameter `t` of the cubic segment `[anchor, out, in, anchor]`.
pub fn eval(seg: &[Point; 4], t: f64) -> Point {
let u = 1.0 - t;
let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
Point::new(a * seg[0].x + b * seg[1].x + c * seg[2].x + d * seg[3].x, a * seg[0].y + b * seg[1].y + c * seg[2].y + d * seg[3].y)
}
/// The part of `path` within `tol` of `p`: one of the `handles` shown (they sit on top), else an
/// anchor, else a segment; the nearest wins within each kind.
pub fn hit(path: &Path, p: Point, tol: f64, handles: &[([usize; 2], Handle)]) -> Option<Hit> {
let nearest = |it: &mut dyn Iterator<Item = (f64, Hit)>| it.filter(|(d, _)| *d <= tol).min_by(|a, b| a.0.total_cmp(&b.0)).map(|(_, h)| h);
let knot = |r: [usize; 2]| path.subpaths.get(r[0]).and_then(|sp| sp.knots.get(r[1]));
let on_handle = nearest(&mut handles.iter().filter_map(|&(r, h)| {
let k = knot(r)?;
Some((dist(handle(k, h), p), Hit::Handle(r, h)))
}));
if on_handle.is_some() {
return on_handle;
}
let anchors =
path.subpaths.iter().enumerate().flat_map(|(s, sp)| sp.knots.iter().enumerate().map(move |(k, kn)| (dist(kn.anchor, p), Hit::Anchor([s, k]))));
if let Some(h) = nearest(&mut anchors.into_iter()) {
return Some(h);
}
// Segments: the nearest of 64 samples, refined once around it (plenty at screen tolerances).
const N: usize = 64;
let mut segs = path.subpaths.iter().enumerate().flat_map(|(s, sp)| {
sp.segments().into_iter().enumerate().map(move |(k, seg)| {
let best = (0..=N).map(|i| i as f64 / N as f64).map(|t| (dist(eval(&seg, t), p), t)).min_by(|a, b| a.0.total_cmp(&b.0)).unwrap_or((f64::MAX, 0.5));
let step = 1.0 / N as f64;
let fine = (0..=16)
.map(|i| (best.1 - step + 2.0 * step * i as f64 / 16.0).clamp(0.0, 1.0))
.map(|t| (dist(eval(&seg, t), p), t))
.fold(best, |a, b| if b.0 < a.0 { b } else { a });
(fine.0, Hit::Segment([s, k], fine.1))
})
});
nearest(&mut segs)
}
#[cfg(test)]
mod tests {
use super::*;
use photocraft_doc::Subpath;
fn tri() -> Path {
Path::new(vec![Subpath::polygon(&[(0.0, 0.0), (100.0, 0.0), (50.0, 80.0)])])
}
/// A smooth knot at (50, 0) between corners, handles 20 px either side.
fn curve() -> Path {
let mut sp = Subpath::polyline(&[(0.0, 50.0), (50.0, 0.0), (100.0, 50.0)]);
sp.knots[1] = Knot::smooth(Point::new(50.0, 0.0), Point::new(30.0, 0.0), Point::new(70.0, 0.0));
Path::new(vec![sp])
}
fn at(path: &Path, s: usize, k: usize) -> Knot {
path.subpaths[s].knots[k]
}
/// A closed four-anchor curvature loop (all smooth), as the Curvature Pen draws it.
fn loop4() -> Path {
let pts = [[0.0, 50.0], [100.0, 0.0], [200.0, 50.0], [100.0, 100.0]];
let knots = curvature_knots(&pts, &[false; 4], true)
.into_iter()
.map(|k| Knot::smooth(Point::new(k[0][0], k[0][1]), Point::new(k[1][0], k[1][1]), Point::new(k[2][0], k[2][1])))
.collect();
Path::new(vec![Subpath { closed: true, knots, op: Default::default() }])
}
#[test]
fn a_curvature_drag_reflows_every_smooth_knot_through_the_moved_anchor() {
let mut p = loop4();
move_anchors_curvature(&mut p, &[[0, 1]], [0.0, 60.0]).unwrap();
let pts = [[0.0, 50.0], [100.0, 60.0], [200.0, 50.0], [100.0, 100.0]];
let want = curvature_knots(&pts, &[false; 4], true);
for (k, w) in p.subpaths[0].knots.iter().zip(want) {
assert_eq!([[k.anchor.x, k.anchor.y], [k.in_ctrl.x, k.in_ctrl.y], [k.out_ctrl.x, k.out_ctrl.y]], w);
}
// The neighbours turned: not a plain move.
assert_ne!(at(&p, 0, 0).out_ctrl, at(&loop4(), 0, 0).out_ctrl);
}
#[test]
fn a_curvature_drag_keeps_corner_handles_and_rejects_bad_indices() {
let mut p = curve();
move_anchors_curvature(&mut p, &[[0, 1]], [0.0, 10.0]).unwrap();
assert_eq!((at(&p, 0, 0), at(&p, 0, 2)), (at(&curve(), 0, 0), at(&curve(), 0, 2)), "the corners kept their handles");
assert_eq!(at(&p, 0, 1).anchor, Point::new(50.0, 10.0));
let before = curve();
let mut q = curve();
assert!(move_anchors_curvature(&mut q, &[[0, 9]], [1.0, 1.0]).is_err());
assert!(move_anchors_curvature(&mut q, &[[4, 0]], [1.0, 1.0]).is_err());
assert_eq!(q, before, "a refused edit changes nothing");
}
#[test]
fn a_curvature_double_click_turns_a_corner_smooth_and_back() {
// A closed square of corners, as the Pen draws it.
let square = || Path::new(vec![Subpath::polygon(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)])]);
let mut p = square();
toggle_curvature_point(&mut p, [0, 1]).unwrap();
let k = at(&p, 0, 1);
assert!(k.smooth);
let want = curvature_knots(&[[0.0, 0.0], [100.0, 0.0], [100.0, 100.0], [0.0, 100.0]], &[true, false, true, true], true)[1];
assert_eq!([[k.in_ctrl.x, k.in_ctrl.y], [k.out_ctrl.x, k.out_ctrl.y]], [want[1], want[2]], "its handles follow its neighbours");
assert_eq!((at(&p, 0, 0), at(&p, 0, 2)), (at(&square(), 0, 0), at(&square(), 0, 2)), "the corners around keep theirs");
toggle_curvature_point(&mut p, [0, 1]).unwrap();
assert_eq!(p, square(), "a second double-click makes it a corner again");
assert!(toggle_curvature_point(&mut p, [0, 7]).is_err());
assert!(toggle_curvature_point(&mut p, [3, 0]).is_err());
assert_eq!(p, square());
}
#[test]
fn anchors_move_with_their_handles_once_each() {
let mut p = curve();
move_anchors(&mut p, &[[0, 1], [0, 1]], [5.0, -2.0]).unwrap();
let k = at(&p, 0, 1);
assert_eq!((k.anchor, k.in_ctrl, k.out_ctrl), (Point::new(55.0, -2.0), Point::new(35.0, -2.0), Point::new(75.0, -2.0)));
assert_eq!(at(&p, 0, 0).anchor, Point::new(0.0, 50.0), "other anchors stay");
}
#[test]
fn bad_indices_are_errors_and_change_nothing() {
let mut p = tri();
assert!(move_anchors(&mut p, &[[0, 0], [0, 9]], [1.0, 1.0]).is_err());
assert_eq!(p, tri(), "validated before any knot moved");
assert!(move_handle(&mut p, [3, 0], Handle::In, Point::new(0.0, 0.0), false).is_err());
assert!(bend_segment(&mut p, [0, 7], 0.5, [1.0, 1.0]).is_err());
assert!(convert_point(&mut p, [usize::MAX, usize::MAX], None).is_err());
let mut open = curve();
assert!(bend_segment(&mut open, [0, 2], 0.5, [1.0, 1.0]).is_err(), "an open subpath's last knot starts no segment");
assert!(bend_segment(&mut open, [0, usize::MAX], 0.5, [1.0, 1.0]).is_err());
}
#[test]
fn smooth_handles_stay_collinear_and_keep_their_length() {
let mut p = curve();
move_handle(&mut p, [0, 1], Handle::Out, Point::new(50.0, 30.0), false).unwrap();
let k = at(&p, 0, 1);
assert_eq!(k.out_ctrl, Point::new(50.0, 30.0));
assert!(dist(k.in_ctrl, Point::new(50.0, -20.0)) < 1e-9, "opposite turned, length 20 kept: {:?}", k.in_ctrl);
assert!(k.smooth);
}
#[test]
fn independent_handles_break_the_knot() {
let mut p = curve();
move_handle(&mut p, [0, 1], Handle::In, Point::new(40.0, 20.0), true).unwrap();
let k = at(&p, 0, 1);
assert_eq!((k.in_ctrl, k.out_ctrl, k.smooth), (Point::new(40.0, 20.0), Point::new(70.0, 0.0), false));
}
#[test]
fn convert_point_retracts_or_pulls_mirrored_handles() {
let mut p = curve();
convert_point(&mut p, [0, 1], None).unwrap();
assert_eq!(at(&p, 0, 1), Knot::corner(50.0, 0.0));
convert_point(&mut p, [0, 0], Some(Point::new(10.0, 60.0))).unwrap();
let k = at(&p, 0, 0);
assert_eq!((k.in_ctrl, k.out_ctrl, k.smooth), (Point::new(-10.0, 40.0), Point::new(10.0, 60.0), true));
}
#[test]
fn straight_segments_move_with_both_anchors() {
let mut p = tri();
// The closing segment (2 → 0) wraps.
bend_segment(&mut p, [0, 2], 0.5, [0.0, 10.0]).unwrap();
assert_eq!((at(&p, 0, 2).anchor, at(&p, 0, 0).anchor, at(&p, 0, 1).anchor), (Point::new(50.0, 90.0), Point::new(0.0, 10.0), Point::new(100.0, 0.0)));
}
#[test]
fn curves_bend_through_the_dragged_point() {
for t in [0.2, 0.5, 0.8] {
let mut p = curve();
let seg = |p: &Path| p.subpaths[0].segments()[1];
let before = eval(&seg(&p), t);
bend_segment(&mut p, [0, 1], t, [3.0, 12.0]).unwrap();
let after = eval(&seg(&p), t);
assert!(dist(after, Point::new(before.x + 3.0, before.y + 12.0)) < 1e-9, "t={t}: {after:?}");
let k = at(&p, 0, 1);
let (vi, vo) = ((k.in_ctrl.x - 50.0, k.in_ctrl.y), (k.out_ctrl.x - 50.0, k.out_ctrl.y));
assert!((vi.0 * vo.1 - vi.1 * vo.0).abs() < 1e-9 && vi.0 * vo.0 + vi.1 * vo.1 < 0.0, "the smooth knot stayed smooth");
}
}
#[test]
fn hits_prefer_handles_then_anchors_then_segments() {
let p = curve();
let shown = [([0, 1], Handle::Out)];
assert_eq!(hit(&p, Point::new(71.0, 1.0), 3.0, &shown), Some(Hit::Handle([0, 1], Handle::Out)));
assert_eq!(hit(&p, Point::new(71.0, 1.0), 3.0, &[]), None, "hidden handles aren't hit");
assert_eq!(hit(&p, Point::new(49.0, 1.0), 3.0, &shown), Some(Hit::Anchor([0, 1])));
let Some(Hit::Segment(r, t)) = hit(&tri(), Point::new(50.0, 1.0), 3.0, &[]) else { panic!("segment") };
assert_eq!(r, [0, 0]);
assert!((t - 0.5).abs() < 0.02, "{t}");
assert_eq!(hit(&tri(), Point::new(50.0, 40.0), 3.0, &[]), None);
}
#[test]
fn adding_to_a_curve_splits_it_without_changing_its_shape() {
for t in [0.5, 0.13, 0.9] {
let mut p = curve();
let seg = curve().subpaths[0].segments()[0];
let r = add_anchor(&mut p, [0, 0], t).unwrap();
assert_eq!((r, p.subpaths[0].knots.len()), ([0, 1], 4));
let k = at(&p, 0, 1);
assert!(k.smooth, "a split curve gets a smooth anchor");
assert!(dist(k.anchor, eval(&seg, t)) < 1e-9, "the new anchor is where it was clicked");
// 100 evenly spaced parameters of the old segment land on the two halves.
let halves = p.subpaths[0].segments();
for i in 0..=100 {
let u = i as f64 / 100.0;
let q = if u <= t { eval(&halves[0], u / t) } else { eval(&halves[1], (u - t) / (1.0 - t)) };
assert!(dist(q, eval(&seg, u)) < 0.01, "t={t} u={u}");
}
assert_eq!(halves[2], curve().subpaths[0].segments()[1], "the next segment is untouched");
}
}
#[test]
fn adding_to_a_straight_segment_uses_the_hit_position() {
for (points, closed) in [
(vec![(0.0, 0.0), (100.0, 0.0)], false),
(vec![(0.0, 0.0), (0.0, 100.0)], false),
(vec![(0.0, 0.0), (100.0, 100.0)], false),
(vec![(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], true),
] {
for t in [0.25, 0.75] {
let sp = if closed { Subpath::polygon(&points) } else { Subpath::polyline(&points) };
let mut p = Path::new(vec![sp]);
let segment = if closed { 2 } else { 0 };
let clicked = eval(&p.subpaths[0].segments()[segment], t);
let Some(Hit::Segment(knot, hit_t)) = hit(&p, clicked, 1.0, &[]) else { panic!("expected a segment hit at {clicked:?}") };
let added = add_anchor(&mut p, knot, hit_t).unwrap();
let inserted = at(&p, added[0], added[1]);
assert!(dist(inserted.anchor, clicked) < 1e-9, "t={t}: inserted {:?}, clicked {clicked:?}", inserted.anchor);
assert_eq!(inserted, Knot::corner(clicked.x, clicked.y), "straight segments retain retracted handles");
}
}
}
#[test]
fn adding_to_a_straight_segment_makes_a_corner_on_the_line() {
let mut p = tri();
// The closing segment (2 → 0) wraps: the new anchor goes last.
assert_eq!(add_anchor(&mut p, [0, 2], 0.5).unwrap(), [0, 3]);
assert_eq!(at(&p, 0, 3), Knot::corner(25.0, 40.0));
assert_eq!(add_anchor(&mut p, [0, 0], 0.25).unwrap(), [0, 1]);
assert_eq!(at(&p, 0, 1), Knot::corner(15.625, 0.0));
assert_eq!(p.subpaths[0].knots.len(), 5);
assert!(p.subpaths[0].closed);
}
#[test]
fn deleting_joins_the_neighbours_with_their_handles() {
let mut sq = Path::new(vec![Subpath::polygon(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)])]);
assert!(!delete_anchor(&mut sq, [0, 2]).unwrap());
assert_eq!((sq.subpaths[0].knots.len(), sq.subpaths[0].closed), (3, true));
let mut c = Path::new(vec![Subpath::polyline(&[(0.0, 50.0), (50.0, 0.0), (100.0, 50.0)])]);
c.subpaths[0].knots[0].out_ctrl = Point::new(10.0, 20.0);
c.subpaths[0].knots[2].in_ctrl = Point::new(90.0, 20.0);
delete_anchor(&mut c, [0, 1]).unwrap();
assert_eq!(c.subpaths[0].segments(), vec![[Point::new(0.0, 50.0), Point::new(10.0, 20.0), Point::new(90.0, 20.0), Point::new(100.0, 50.0)]]);
}
#[test]
fn deleting_a_subpaths_last_anchor_removes_it_but_never_the_last_anchor() {
let mut p = tri();
p.subpaths.push(Subpath::polyline(&[(5.0, 5.0)]));
assert!(delete_anchor(&mut p, [1, 0]).unwrap());
assert_eq!(p, tri());
let mut one = Path::new(vec![Subpath::polyline(&[(5.0, 5.0)])]);
let before = one.clone();
assert!(delete_anchor(&mut one, [0, 0]).is_err());
assert_eq!(one, before);
}
#[test]
fn bad_anchor_edits_are_errors_and_change_nothing() {
let mut p = curve();
for t in [0.0, 1.0, -0.5, 2.0, f64::NAN, f64::INFINITY] {
assert!(add_anchor(&mut p, [0, 0], t).is_err(), "t={t}");
}
assert!(add_anchor(&mut p, [0, 2], 0.5).is_err(), "an open subpath's last knot starts no segment");
assert!(add_anchor(&mut p, [0, usize::MAX], 0.5).is_err());
assert!(add_anchor(&mut p, [usize::MAX, 0], 0.5).is_err());
assert!(delete_anchor(&mut p, [0, 3]).is_err());
assert!(delete_anchor(&mut p, [1, 0]).is_err());
assert_eq!(p, curve());
}
#[test]
fn marquee_and_subpath_selections() {
let p = tri();
assert_eq!(anchors_in(&p, [110.0, -5.0, 40.0, 5.0]), vec![[0, 1]]);
assert_eq!(subpath_anchors(&p, 0), vec![[0, 0], [0, 1], [0, 2]]);
assert!(subpath_anchors(&p, 4).is_empty());
}
}