Curvature: deleting a point re-curves the path through the remaining points

Deleting a point of a finished path with the Curvature tool sent
path.removeAnchor, which refits the two segments that met at the point so
one curve follows them. The path kept the bend of the deleted point
instead of curving through the points that remain.

The tool now sends path.removeAnchor with curvature: true, which uses the
new curvature_remove. Each smooth neighbor takes new handles from its new
neighbors, and an end beside one of them takes its end handle again. A
corner neighbor keeps its handle directions, and its handle on the new
segment scales with the segment's chord. With two points left, the points
that curve lose their handles. Object > Path > Remove Anchor Points and
the Delete Anchor Point tool still refit the curve.
This commit is contained in:
dotCooCoo
2026-10-29 10:00:30 +07:00
committed by bflatastic
parent 4661615af4
commit 686cd1f2f5
6 changed files with 217 additions and 11 deletions
+12 -5
View File
@@ -109,13 +109,14 @@ pub(super) fn remove_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
let ai = usize_req(p, "anchor", C)?;
let curvature = switch_or(p, "curvature", false, C)?;
let removed = s.edit("Delete Anchor Point", |d, sel| {
let sp = path_mut(d, id)?.subpaths.get(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
if ai >= sp.anchors.len() {
return Err(EngineError::Other("no such anchor".into()));
}
let mut emptied = Beside::replacing(d, &[id]);
let removed = remove_anchors_of(d, sel, &mut emptied, id, [(si, ai)])?;
let removed = remove_anchors_of(d, sel, &mut emptied, id, [(si, ai)], curvature)?;
emptied.finish(d)?;
Ok(removed)
})?;
@@ -141,7 +142,7 @@ pub(super) fn remove_anchors(s: &mut Session, _: &Value) -> Result<Value> {
let shape: Vec<(usize, bool)> =
d.node(id).and_then(Node::path_data).map(|p| p.subpaths.iter().map(|sp| (sp.anchors.len(), sp.closed)).collect()).unwrap_or_default();
let next = following_anchors(&shape, &anchors);
if remove_anchors_of(d, sel, &mut emptied, id, anchors)? {
if remove_anchors_of(d, sel, &mut emptied, id, anchors, false)? {
removed += 1;
} else if !next.is_empty() {
// The next anchor along is selected, so the next Remove Anchor Points goes on from
@@ -180,14 +181,16 @@ fn following_anchors(shape: &[(usize, bool)], removed: &[(usize, usize)]) -> BTr
}
/// Remove `anchors` (subpath, anchor) of path `id` without opening it, refitting the curve round
/// each one. A subpath left with one anchor is removed, and a path left with no subpath is removed
/// through `emptied`. True when the path went.
/// each one, or with `curvature` re-curving it as the Curvature tool curves its points
/// ([`vectorcraft_tools::draw2::curvature_remove`]). A subpath left with one anchor is removed,
/// and a path left with no subpath is removed through `emptied`. True when the path went.
fn remove_anchors_of(
d: &mut Document,
sel: &mut Selection,
emptied: &mut Beside,
id: NodeId,
anchors: impl IntoIterator<Item = (usize, usize)>,
curvature: bool,
) -> Result<bool> {
let mut anchors: Vec<(usize, usize)> = anchors.into_iter().collect();
// The last first, so the indexes still to come stay valid.
@@ -196,7 +199,11 @@ fn remove_anchors_of(
let path = path_mut(d, id)?;
for (si, ai) in anchors {
if let Some(sp) = path.subpaths.get_mut(si) {
po::remove_anchor(sp, ai);
if curvature {
vectorcraft_tools::draw2::curvature_remove(sp, ai);
} else {
po::remove_anchor(sp, ai);
}
}
}
path.subpaths.retain(|sp| sp.anchors.len() >= 2);
+1 -1
View File
@@ -38,7 +38,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Delete Anchor Point",
[],
None,
"{id, subpath?, anchor} remove one anchor, re-fitting the curve",
"{id, subpath?, anchor, curvature?} remove one anchor, re-fitting the curve (with curvature: true, the curve where it was follows the remaining anchors, as the Curvature tool curves its points)",
has_doc,
remove_anchor
),
+34
View File
@@ -597,6 +597,40 @@ fn curvature_click_first_point_closes() {
assert_eq!(p.anchor_count(), 3);
}
/// Delete on a point of a finished Curvature path re-curves the rest through the remaining points,
/// as if they had been drawn without it, in one undo step.
#[test]
fn curvature_delete_curves_through_the_remaining_points() {
let mut s = session();
let v = view();
s.select_tool("curvature", v).unwrap();
let pts = [(100.0, 300.0), (200.0, 200.0), (300.0, 320.0), (400.0, 220.0), (500.0, 300.0)];
for (x, y) in pts {
gesture(&mut s, &[(x, y)], Mods::default());
}
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
let id = paths(&s)[0].0;
let before = path(&s, id).subpaths[0].clone();
let remaining = |gone: usize| -> Vec<(Point, bool)> {
pts.iter().enumerate().filter(|&(i, _)| i != gone).map(|(_, &(x, y))| (Point::new(x, y), false)).collect()
};
for (gone, &(x, y)) in pts.iter().enumerate() {
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
let steps = undo_steps(&s);
gesture(&mut s, &[(x, y)], Mods::default());
s.tool_key(ToolKey::Delete, Mods::default(), v).unwrap();
assert_eq!(undo_steps(&s), steps + 1, "one step");
let want = vectorcraft_tools::draw2::curvature_spline(&remaining(gone), false);
let got = path(&s, id).subpaths[0].clone();
assert_eq!(got.anchors.len(), want.anchors.len());
for (a, b) in got.anchors.iter().zip(&want.anchors) {
assert!(a.p.distance(b.p) < 1e-9 && a.h_in.distance(b.h_in) < 1e-6 && a.h_out.distance(b.h_out) < 1e-6, "point {gone}: {a:?} != {b:?}");
}
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(&s, id).subpaths[0], before);
}
}
/// The Curvature tool edits a path another tool drew (#798), one undo step per edit, keeping its
/// shape except where edited.
#[test]
+3 -1
View File
@@ -17,7 +17,9 @@ use vectorcraft_pathops::{Cap, Join};
use crate::{Action, Tool, ToolContext};
pub use anchor::AnchorTool;
pub use curvature::{CurvatureTool, EndCurve, curvature_close, curvature_extend, curvature_insert, curvature_move, curvature_smooth};
pub use curvature::{
CurvatureTool, EndCurve, curvature_close, curvature_extend, curvature_insert, curvature_move, curvature_remove, curvature_smooth,
};
pub use family::FamilyTool;
pub use gesture::GestureTool;
+110 -3
View File
@@ -9,7 +9,8 @@
//! It edits any selected path too, whatever drew it, keeping its shape except where edited
//! (#798). A click on one of its points makes that the current point (direct-selected); a drag
//! moves it, re-curving only the two segments at it ([`curvature_move`]); Alt-click or
//! double-click toggles it between smooth and corner and Backspace/Delete removes it. A click on
//! double-click toggles it between smooth and corner and Backspace/Delete removes it, the curve
//! where it was then following the remaining points ([`curvature_remove`]). A click on
//! a segment adds a smooth point there without changing the shape ([`curvature_insert`]), and a
//! drag moves the new point at once. While the current point is an end of an open path, clicks go
//! on from it ([`curvature_extend`]): the old segments keep their shape, the new ones curve
@@ -20,7 +21,7 @@ use serde_json::{Value, json};
use vectorcraft_doc::{NodeId, Selection};
use vectorcraft_geom::{Anchor, AnchorKind, BezPath, Point, SubPath, Vec2};
use super::{FEEDBACK, anchor_in, curvature_spline, editable_paths, end_handle, remove_anchor, segment_in, smooth_handles};
use super::{FEEDBACK, anchor_in, curvature_spline, editable_paths, end_handle, segment_in, smooth_handles};
use crate::guides::{DrawSnap, Leave, Targets};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
@@ -212,6 +213,106 @@ pub fn curvature_smooth(sp: &mut SubPath, i: usize) -> bool {
}
}
/// Remove anchor `i` of `sp` as the Curvature tool deletes a point: the curve where it was follows
/// the remaining points. Each neighbor that curves by the tool's rule (a smooth anchor, or an end
/// of an open path with a handle into its segment) takes new handles from its new neighbors
/// ([`smooth_handles`]; at an end, the one [`end_handle`] into the curve), and so does an end beyond
/// one of them whose handle mirrors its tangent. A corner neighbor keeps its handles' directions,
/// the one on the new segment scaled with its chord, as [`curvature_move`] scales them. With two
/// anchors left the subpath is open, and those that curve by the tool's rule lose their handles, as
/// [`curvature_spline`] draws two points as a line; a corner keeps its handles. On a path the tool
/// drew, the result is the path it draws through the remaining points, except that an Alt-clicked
/// end beside a corner without handles curves (the path stores it like any end there). False, and
/// `sp` unchanged, when there is no anchor `i`.
pub fn curvature_remove(sp: &mut SubPath, i: usize) -> bool {
let n = sp.anchors.len();
let Some(a) = sp.anchors.get(i).copied() else { return false };
let closed = sp.closed;
let is_end = |j: usize| !closed && (j == 0 || j + 1 == n);
// An end without a handle beside a corner without handles is stored the same whether it was
// Alt-clicked or not ([`curvature_spline`] gives it no handle either way), so it curves, as an
// end the tool places does by default. Beside a tangent, a missing handle means a corner.
let flags: Vec<bool> = sp
.anchors
.iter()
.enumerate()
.map(|(j, b)| {
if !is_end(j) {
return b.kind == AnchorKind::Smooth;
}
let near = sp.anchors.get(if j == 0 { 1 } else { n.saturating_sub(2) });
b.has_in() || b.has_out() || near.is_none_or(|q| end_handle(b.p, q, j == 0).is_none())
})
.collect();
let curved = |j: usize| flags.get(j).copied().unwrap_or(false);
let [prev, next] = neighbours(sp, i);
// A corner on both sides of the removed point: its handle on the new segment scales with it.
if let (Some(pi), Some(ni)) = (prev, next)
&& pi != ni
{
for (j, other, out) in [(pi, ni, true), (ni, pi, false)] {
let to = sp.anchors.get(other).map(|o| o.p);
if curved(j) {
continue;
}
let Some((b, to)) = sp.anchors.get_mut(j).zip(to) else { continue };
let old = b.p.distance(a.p);
let s = if old > 1e-9 { b.p.distance(to) / old } else { 1.0 };
let h = if out { &mut b.h_out } else { &mut b.h_in };
*h = b.p + (*h - b.p) * s;
}
}
let mut again: Vec<usize> = [prev, next].into_iter().flatten().filter(|&j| curved(j)).collect();
// An end beside a smooth neighbor mirrors that neighbor's tangent, which changes.
for j in again.clone() {
if !is_end(j) {
again.extend(neighbours(sp, j).into_iter().flatten().filter(|&k| k != i && is_end(k) && curved(k)));
}
}
sp.anchors.remove(i);
let mut again: Vec<usize> = again.into_iter().map(|j| if j > i { j - 1 } else { j }).collect();
again.sort_unstable();
again.dedup();
let m = sp.anchors.len();
if m < 3 {
sp.closed = false;
}
if !sp.closed {
// A new end has nothing beyond it.
if let Some(e) = sp.anchors.first_mut() {
e.h_in = e.p;
}
if let Some(e) = sp.anchors.last_mut() {
e.h_out = e.p;
}
}
let end = |j: usize| !sp.closed && (j == 0 || j + 1 == m);
let (ends, inner): (Vec<usize>, Vec<usize>) = again.into_iter().partition(|&j| end(j));
// The inner anchors first: an end's handle mirrors its neighbor's tangent.
for j in inner {
let [b, c] = neighbours(sp, j).map(|k| k.and_then(|k| sp.anchors.get(k)).map(|x| x.p));
let Some(x) = sp.anchors.get(j).map(|x| x.p) else { continue };
let fresh = match (b, c) {
(Some(b), Some(c)) => smooth_handles(b, x, c).map(|(h_in, h_out)| Anchor { p: x, h_in, h_out, kind: AnchorKind::Smooth }),
_ => None,
};
if let Some(y) = sp.anchors.get_mut(j) {
*y = fresh.unwrap_or_else(|| Anchor::corner(x));
}
}
for j in ends {
let first = j == 0;
let near = if first { sp.anchors.get(1) } else { j.checked_sub(1).and_then(|k| sp.anchors.get(k)) }.copied();
let Some(x) = sp.anchors.get(j).map(|x| x.p) else { continue };
// Two points are a line.
let h = near.filter(|_| m >= 3).and_then(|q| end_handle(x, &q, first)).unwrap_or(x);
if let Some(y) = sp.anchors.get_mut(j) {
*y = if first { Anchor { h_out: h, ..Anchor::corner(x) } } else { Anchor { h_in: h, ..Anchor::corner(x) } };
}
}
true
}
/// Go on from an end of the open subpath `sp` (its start with `start`) to a new end at `p`; the old
/// end bends into the new segment as `how` says. The new end has no handle on the outer side and,
/// unless it is a `corner` (Alt-clicked), the one [`end_handle`] into the curve, so its segment
@@ -313,6 +414,12 @@ fn select_point((id, si, ai): (NodeId, usize, usize)) -> Action {
Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "set"}))
}
/// Delete anchor `(id, si, ai)` of a selected path, the curve where it was following the remaining
/// points ([`curvature_remove`]).
fn delete_point((id, si, ai): (NodeId, usize, usize)) -> Action {
Action::Exec("path.removeAnchor".into(), json!({"id": id.0, "subpath": si, "anchor": ai, "curvature": true}))
}
/// Toggle anchor `(id, si, ai)` of a selected path between smooth and corner. An end of an open
/// path counts as smooth while it has a handle into its segment (the ends the tool curves are
/// corners with one handle).
@@ -560,7 +667,7 @@ impl Tool for CurvatureTool {
}
ToolKey::Backspace | ToolKey::Delete if self.claims_key(cx, key) => {
if self.active(cx).is_none() {
return current_point(cx).map(remove_anchor).into_iter().collect();
return current_point(cx).map(delete_point).into_iter().collect();
}
let i = self.current.unwrap_or(self.pts.len() - 1).min(self.pts.len() - 1);
self.pts.remove(i);
+57 -1
View File
@@ -244,6 +244,62 @@ fn curvature_smooth_uses_the_tool_rule() {
assert_eq!(fold, before);
}
/// Removing a point of a path the tool drew leaves the path it draws through the remaining points,
/// open or closed, and two points left are a straight line.
#[test]
fn curvature_remove_curves_through_the_remaining_points() {
let at = |corners: &[usize]| -> Vec<(Point, bool)> {
[(0.0, 0.0), (100.0, -80.0), (220.0, 30.0), (300.0, -60.0), (420.0, 10.0), (500.0, -40.0)]
.iter()
.enumerate()
.map(|(j, &(x, y))| (pt(x, y), corners.contains(&j)))
.collect()
};
// A corner in the middle, and corners next to both ends (the ends then have no handle).
for pts in [at(&[2]), at(&[1, 4]), at(&[])] {
for closed in [false, true] {
for i in 0..pts.len() {
let mut sp = curvature_spline(&pts, closed);
assert!(curvature_remove(&mut sp, i));
let rest: Vec<(Point, bool)> = pts.iter().enumerate().filter(|&(j, _)| j != i).map(|(_, &q)| q).collect();
let want = curvature_spline(&rest, closed);
assert_eq!(sp.closed, want.closed);
for (a, b) in sp.anchors.iter().zip(&want.anchors) {
assert!(a.h_in.distance(b.h_in) < 1e-6 && a.h_out.distance(b.h_out) < 1e-6, "{pts:?} closed {closed}, point {i}: {a:?} != {b:?}");
}
}
}
}
let pts = at(&[2]);
for closed in [false, true] {
let mut sp = curvature_spline(&pts[..3], closed);
assert!(curvature_remove(&mut sp, 1));
assert!(!sp.closed && sp.segment_is_line(0), "two points are a line: {sp:?}");
}
let mut sp = curvature_spline(&pts, false);
let before = sp.clone();
assert!(!curvature_remove(&mut sp, 9));
assert_eq!(sp, before);
}
/// A corner neighbor with handles keeps their directions; its handle on the new segment scales
/// with the segment's chord.
#[test]
fn curvature_remove_keeps_a_corner_neighbors_handle_direction() {
let corner = Anchor { p: pt(100.0, 0.0), h_in: pt(80.0, -20.0), h_out: pt(120.0, -30.0), kind: AnchorKind::Corner };
let mut sp = SubPath::new(vec![Anchor::corner(pt(0.0, 0.0)), corner, Anchor::corner(pt(200.0, 0.0)), Anchor::corner(pt(300.0, 0.0))], false);
assert!(curvature_remove(&mut sp, 2));
let c = sp.anchors[1];
assert_eq!(c.h_in, corner.h_in);
let (old, new) = (corner.h_out - corner.p, c.h_out - c.p);
assert!(old.cross(new).abs() < 1e-9 && old.dot(new) > 0.0, "{c:?}");
assert!((new.hypot() / old.hypot() - 2.0).abs() < 1e-9, "the chord doubled: {c:?}");
// Two anchors left: the corner keeps its handle on the segment that stays.
let mut sp = SubPath::new(vec![Anchor::corner(pt(0.0, 0.0)), corner, Anchor::corner(pt(200.0, 0.0))], false);
assert!(curvature_remove(&mut sp, 2));
assert_eq!((sp.anchors[1].h_in, sp.anchors[1].h_out), (corner.h_in, corner.p));
}
/// A curved end counts as smooth for the toggle: Alt-click turns it into a corner.
#[test]
fn alt_click_on_a_curved_end_makes_it_a_corner() {
@@ -345,7 +401,7 @@ fn alt_click_or_double_click_toggles_and_delete_removes() {
let s = selected(id, Some(2));
let cx = quiet(&d, &s, &p);
assert!(t.claims_key(&cx, ToolKey::Backspace));
assert_eq!(t.key(&cx, ToolKey::Delete, Mods::default()), vec![remove_anchor((id, 0, 2))]);
assert_eq!(t.key(&cx, ToolKey::Delete, Mods::default()), vec![delete_point((id, 0, 2))]);
// Esc lets go of the point.
assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]);
}