Merge pull request #891 from storytold/fix/798-curvature-any-path

Fix #798: the Curvature tool edits any selected path
This commit is contained in:
BFlat 2026-10-10 11:29:31 +07:00 committed by GitHub
commit b47f611e18
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 870 additions and 43 deletions

File diff suppressed because one or more lines are too long

View File

@ -36,6 +36,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
curvature
),
cmd!(
"path.curvatureEdit",
"Curvature",
[],
None,
"{id, subpath?, op: \"move\"|\"insert\"|\"extend\"|\"close\", anchor?, segment?, t?, end?: \"start\"|\"end\", from?: \"keep\"|\"smooth\"|\"corner\", x?, y?} edit a path as the Curvature tool does, its shape kept elsewhere; the anchor edited becomes the direct-selected one → {anchor}. move: anchor to x, y, re-curving only its two segments; insert: a smooth anchor at t on segment, the shape unchanged (with x, y: then moved there); extend: an open subpath goes on from its end (default) or start to a new end at x, y; close: an open subpath closes from that end. The old end bends into the new segment as from says: keep its curve (default), smooth through its neighbours, or a corner",
has_doc,
curvature_edit
),
cmd!(
"path.removeAnchor",
"Delete Anchor Point",
@ -593,6 +602,76 @@ fn curvature(s: &mut Session, p: &Value) -> Result<Value> {
add_look(s, "Curvature", path_kind(PathData::single(sp)), look, None)
}
/// An edit `path.curvatureEdit` makes.
enum CurvatureOp {
Move(usize, Point),
Insert(usize, f64, Option<Point>),
Extend(Point),
Close,
}
fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
use vectorcraft_tools::draw2::{EndCurve, curvature_close, curvature_extend, curvature_insert, curvature_move};
const C: &str = "path.curvatureEdit";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
let at = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
(Some(x), Some(y)) if x.is_finite() && y.is_finite() => Some(Point::new(x, y)),
(None, None) => None,
_ => return Err(bad(C, "x and y must be finite numbers")),
};
let to = || at.ok_or_else(|| bad(C, "missing x, y"));
let start = match str_param(p, "end") {
None | Some("end") => false,
Some("start") => true,
Some(e) => return Err(bad(C, format!("unknown end {e:?}"))),
};
let how = match str_param(p, "from") {
None => EndCurve::Keep,
Some(f) => EndCurve::parse(f).ok_or_else(|| bad(C, format!("unknown from {f:?}")))?,
};
let op = match str_param(p, "op") {
Some("move") => CurvatureOp::Move(usize_req(p, "anchor", C)?, to()?),
Some("insert") => CurvatureOp::Insert(usize_req(p, "segment", C)?, f64_or(p, "t", 0.5), at),
Some("extend") => CurvatureOp::Extend(to()?),
Some("close") => CurvatureOp::Close,
Some(o) => return Err(bad(C, format!("unknown op {o:?}"))),
None => return Err(bad(C, "missing op")),
};
let anchor = s.edit("Curvature", |d, sel| {
let path = path_mut(d, id)?;
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
if matches!(op, CurvatureOp::Extend(_) | CurvatureOp::Close) && sp.closed {
return Err(EngineError::Other("path is already closed".into()));
}
let ai = match op {
CurvatureOp::Move(ai, q) => curvature_move(sp, ai, q).then_some(ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?,
CurvatureOp::Insert(seg, t, q) => {
let ai = curvature_insert(sp, seg, t).ok_or_else(|| EngineError::Other("no such segment".into()))?;
if let Some(q) = q {
curvature_move(sp, ai, q);
}
ai
}
CurvatureOp::Extend(q) => {
curvature_extend(sp, start, q, how);
if start { 0 } else { sp.anchors.len() - 1 }
}
CurvatureOp::Close => {
if sp.anchors.len() < 3 {
return Err(EngineError::Other("closing needs three anchors".into()));
}
curvature_close(sp, start, how);
if start { sp.anchors.len() - 1 } else { 0 }
}
};
sel.set([id]);
sel.anchors.insert(id, [(si, ai)].into());
Ok(ai)
})?;
Ok(json!({ "anchor": anchor }))
}
// ---------- anchor tools ----------
fn remove_anchor(s: &mut Session, p: &Value) -> Result<Value> {

View File

@ -197,6 +197,68 @@ fn curvature_click_first_point_closes() {
assert_eq!(p.anchor_count(), 3);
}
/// The Curvature tool edits a path another tool drew (#798), one undo step per edit, keeping its
/// shape except where edited.
#[test]
fn curvature_edits_any_selected_path() {
let mut s = session();
let v = view();
let anchors = json!([
{"x": 100, "y": 300},
{"x": 200, "y": 200, "in": [160, 210], "out": [260, 185]},
{"x": 350, "y": 280, "in": [320, 230], "out": [380, 330]},
{"x": 450, "y": 350, "in": [420, 360], "out": [480, 300]},
{"x": 550, "y": 260, "in": [520, 290]}
]);
let id = NodeId(s.execute("path.create", &json!({ "anchors": anchors })).unwrap()["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.select_tool("curvature", v).unwrap();
let before = path(&s, id).subpaths[0].clone();
// Drag a point: only its two segments change.
assert_eq!(gesture(&mut s, &[(350.0, 280.0), (360.0, 320.0), (370.0, 340.0)], Mods::default()), 1);
let sp = path(&s, id).subpaths[0].clone();
assert_eq!(sp.anchors[2].p, Point::new(370.0, 340.0));
assert_eq!((sp.segment(0), sp.segment(3)), (before.segment(0), before.segment(3)));
assert_eq!(s.doc().unwrap().selection.anchors.get(&id).map(|a| a.len()), Some(1), "the point is current");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(&s, id).subpaths[0], before);
// Alt-click toggles it, Delete removes the current point: one step each.
let (alt, steps) = (Mods { alt: true, ..Default::default() }, undo_steps(&s));
assert_eq!(gesture(&mut s, &[(200.0, 200.0)], alt), 1);
assert!(!path(&s, id).subpaths[0].anchors[1].has_out());
s.tool_key(ToolKey::Delete, Mods::default(), v).unwrap();
assert_eq!(path(&s, id).anchor_count(), 4);
assert_eq!(undo_steps(&s), steps + 2);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(&s, id).subpaths[0], before);
// A press on a segment adds a point, the drag moving it: one step.
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
let on = kurbo::ParamCurve::eval(&before.segment(3), 0.5);
assert_eq!(gesture(&mut s, &[(on.x, on.y), (on.x, on.y + 20.0), (on.x, on.y + 30.0)], Mods::default()), 1);
let sp = path(&s, id).subpaths[0].clone();
assert_eq!(sp.anchors.len(), 6);
assert_eq!(sp.anchors[4].p, Point::new(on.x, on.y + 30.0));
assert_eq!((sp.segment(0), sp.segment(1), sp.segment(2)), (before.segment(0), before.segment(1), before.segment(2)));
s.execute("edit.undo", &json!({})).unwrap();
// A click on an end, then two more: the path goes on, its old segments unchanged.
let steps = undo_steps(&s);
for (x, y) in [(550.0, 260.0), (650.0, 300.0), (720.0, 220.0)] {
gesture(&mut s, &[(x, y)], Mods::default());
}
assert_eq!(undo_steps(&s), steps + 2, "selecting the end is no step");
let sp = path(&s, id).subpaths[0].clone();
assert_eq!(sp.anchors.len(), 7);
assert!((0..4).all(|i| sp.segment(i) == before.segment(i)));
assert_eq!(sp.anchors[6].p, Point::new(720.0, 220.0));
// The other end closes it; Esc and a click away start a new path.
gesture(&mut s, &[(100.0, 300.0)], Mods::default());
assert!(path(&s, id).is_closed());
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
gesture(&mut s, &[(700.0, 550.0)], Mods::default());
assert_eq!(paths(&s).len(), 2);
}
#[test]
fn add_and_delete_anchor_tools() {
let mut s = session();

View File

@ -215,6 +215,11 @@ fn structured_junk() {
("path.freehand", json!({"points": [[0, 0], [0, 0], [0, 0], [0, 0]]})),
("path.freehand", json!({"points": [[1e308, 1e308], [-1e308, 0]], "fidelity": 0})),
("path.curvature", json!({"points": [{"x": 0, "y": 0}]})),
("path.curvatureEdit", json!({"id": 2, "op": "move", "anchor": 1000, "x": 0, "y": 0})),
("path.curvatureEdit", json!({"id": 2, "op": "move", "anchor": 0, "x": 1e308, "y": -1e308})),
("path.curvatureEdit", json!({"id": 2, "subpath": 7, "op": "extend", "x": 1e308, "y": -1e308, "from": "smooth"})),
("path.curvatureEdit", json!({"id": 2, "op": "insert", "segment": 99, "t": -5, "x": 1})),
("path.curvatureEdit", json!({"id": 2, "op": "close", "end": "start", "from": "corner"})),
("path.knife", json!({"points": [[0, 0], [1e9, 1e9]]})),
("path.eraseRegion", json!({"points": [[100, 80]], "size": 0})),
("path.eraseRegion", json!({"points": [[100, 80], [101, 80]], "size": 1e9})),

View File

@ -15,7 +15,7 @@ use vectorcraft_geom::{Anchor, AnchorKind, BezPath, Point, SubPath};
use crate::{Action, Tool, ToolContext};
pub use anchor::AnchorTool;
pub use curvature::CurvatureTool;
pub use curvature::{CurvatureTool, EndCurve, curvature_close, curvature_extend, curvature_insert, curvature_move};
pub use family::FamilyTool;
pub use gesture::GestureTool;
@ -94,6 +94,15 @@ fn candidate_paths(cx: &ToolContext) -> Vec<NodeId> {
v
}
/// The selected paths the Pen and the Curvature tool edit (not guides, nor locked or hidden ones).
pub(crate) fn editable_paths<'a>(cx: &'a ToolContext) -> impl Iterator<Item = NodeId> + 'a {
cx.selection
.objects
.iter()
.copied()
.filter(|&id| cx.doc.is_editable(id) && cx.doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Path { guide: false, .. })))
}
/// Nearest anchor within `tol`: (path, subpath, anchor).
pub(crate) fn hit_anchor(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize)> {
anchor_in(cx, candidate_paths(cx), p, tol)

View File

@ -5,28 +5,293 @@
//! first point to close; Backspace/Delete removes the last touched point; Esc/Enter ends the path.
//! A rubber band shows the curve to the cursor (Enable Rubber Band for Curvature Tool). Each point
//! placed or dragged snaps to Smart Guides ([`DrawSnap`]), as the Pen's anchors do.
//!
//! 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
//! 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
//! through the new points, and a click on the path's other end closes it. Each edit is one
//! `path.curvatureEdit`, `path.convertAnchor` or `path.removeAnchor` (one undo step).
use serde_json::{Value, json};
use vectorcraft_doc::{NodeId, Selection};
use vectorcraft_geom::Point;
use vectorcraft_geom::{Anchor, AnchorKind, BezPath, Point, SubPath, Vec2};
use super::{FEEDBACK, catmull_rom};
use super::{FEEDBACK, anchor_in, catmull_rom, editable_paths, remove_anchor, segment_in};
use crate::guides::{DrawSnap, Leave, Targets};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
/// The command that edits a selected path.
const EDIT: &str = "path.curvatureEdit";
#[derive(Default)]
pub struct CurvatureTool {
pts: Vec<(Point, bool)>,
closed: bool,
id: Option<NodeId>,
/// Point being dragged: (index, began).
drag: Option<(usize, Point, bool)>,
/// Last touched point (Backspace removes it).
/// The point being dragged, the pointer at the press, and whether the drag began.
drag: Option<(Grab, Point, bool)>,
/// Last touched point of the path being drawn (Backspace removes it).
current: Option<usize>,
hover: Option<Point>,
/// The end the last click on a selected path placed.
placed: Option<Placed>,
snap: DrawSnap,
}
/// What a press grabbed for a drag.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Grab {
/// A point of the path being drawn.
Drawn(usize),
/// An anchor of a selected path: (path, subpath, anchor).
Anchor(NodeId, usize, usize),
/// The anchor the press added on a segment of a selected path: (path, subpath, segment, t).
Inserted(NodeId, usize, usize, f64),
}
/// The end a click on a selected path placed, while it still is that end (its subpath has as many
/// anchors as then): going on from it, it curves through its neighbours, or stays a corner when it
/// was Alt-clicked. Any other end keeps its curve.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Placed {
id: NodeId,
si: usize,
start: bool,
anchors: usize,
corner: bool,
}
/// The current point of a selected path while it is an end of an open subpath: where clicks go on
/// from (its start with `start`), and the other end, which a click closes the subpath on.
#[derive(Clone, Copy)]
struct End {
id: NodeId,
si: usize,
start: bool,
anchors: usize,
p: Point,
other: Point,
}
impl End {
/// `end` for `path.curvatureEdit`.
fn name(&self) -> &'static str {
if self.start { "start" } else { "end" }
}
}
/// How the end a path goes on from bends into the new segment ([`curvature_extend`],
/// [`curvature_close`]).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum EndCurve {
/// The segment it ends keeps its shape: the new handle goes on along its handle, or along the
/// curve where it has none, and with both it is smooth.
#[default]
Keep,
/// Smooth through its neighbours, as [`catmull_rom`] curves a point (a point the Curvature tool
/// placed: its segment re-curves).
Smooth,
/// A corner, without handles.
Corner,
}
impl EndCurve {
pub fn parse(s: &str) -> Option<Self> {
Some(match s {
"keep" => Self::Keep,
"smooth" => Self::Smooth,
"corner" => Self::Corner,
_ => return None,
})
}
pub fn name(self) -> &'static str {
match self {
Self::Keep => "keep",
Self::Smooth => "smooth",
Self::Corner => "corner",
}
}
}
/// The anchors before and after anchor `i` of `sp` (round the start of a closed one).
fn neighbours(sp: &SubPath, i: usize) -> [Option<usize>; 2] {
let n = sp.anchors.len();
let wrap = sp.closed && n > 1;
let prev = if i > 0 { Some(i - 1) } else { wrap.then(|| n - 1) };
let next = if i + 1 < n { Some(i + 1) } else { wrap.then_some(0) };
[prev, next]
}
/// Move anchor `i` of `sp` to `q` as the Curvature tool drags a point: only the two segments at it
/// change. A smooth anchor turns along the line through its neighbours; the neighbours' handles on
/// those segments, and a corner's own handles, keep their directions, their lengths scaling with
/// their segment's chord, so the segments beyond don't move and a corner without handles keeps
/// none. False when there is no anchor `i`.
pub fn curvature_move(sp: &mut SubPath, i: usize, q: Point) -> bool {
let Some(&a) = sp.anchors.get(i) else { return false };
let mut own = [a.h_in - a.p, a.h_out - a.p];
let mut ends = [None; 2];
for (side, j) in neighbours(sp, i).into_iter().enumerate() {
let Some(b) = j.and_then(|j| sp.anchors.get_mut(j)) else { continue };
let old = a.p.distance(b.p);
let s = if old > 1e-9 { q.distance(b.p) / old } else { 1.0 };
let h = if side == 0 { &mut b.h_out } else { &mut b.h_in };
*h = b.p + (*h - b.p) * s;
own[side] *= s;
ends[side] = Some(b.p);
}
let mut m = Anchor { p: q, h_in: q + own[0], h_out: q + own[1], kind: a.kind };
if let (AnchorKind::Smooth, [Some(prev), Some(next)]) = (a.kind, ends) {
let d = next - prev;
let l = d.hypot();
if l > 1e-9 {
// A handle it lacks comes out a third of the way to that side's neighbour.
let len = |h: Vec2, to: Point| if h.hypot() > 1e-9 { h.hypot() } else { q.distance(to) / 3.0 };
m.h_in = q - d * (len(own[0], prev) / l);
m.h_out = q + d * (len(own[1], next) / l);
}
}
if let Some(x) = sp.anchors.get_mut(i) {
*x = m;
}
true
}
/// Add a smooth anchor at `t` on segment `seg` of `sp` without changing its shape (on a straight
/// segment its handles lie along it, so a drag curves it): the new anchor's index. None when there
/// is no such segment.
pub fn curvature_insert(sp: &mut SubPath, seg: usize, t: f64) -> Option<usize> {
if seg >= sp.segment_count() {
return None;
}
let line = sp.segment_is_line(seg);
let i = sp.insert_anchor(seg, if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.5 });
if line {
sp.smooth_anchor(i);
}
Some(i)
}
/// Go on from an end of the open subpath `sp` (its start with `start`) to a new end at `p`, which
/// has no handles; the old end bends into the new segment as `how` says.
pub fn curvature_extend(sp: &mut SubPath, start: bool, p: Point, how: EndCurve) {
if start {
sp.reverse();
}
bend_last(sp, p, how);
sp.anchors.push(Anchor::corner(p));
if start {
sp.reverse();
}
}
/// Close the open subpath `sp` from its end (its start with `start`): that end bends into the
/// closing segment as `how` says, and the other keeps its curve.
pub fn curvature_close(sp: &mut SubPath, start: bool, how: EndCurve) {
if start {
sp.reverse();
}
if let (Some(first), Some(last)) = (sp.anchors.first().map(|a| a.p), sp.anchors.last().map(|a| a.p)) {
bend_last(sp, first, how);
sp.reverse();
bend_last(sp, last, EndCurve::Keep);
sp.reverse();
sp.closed = true;
}
if start {
sp.reverse();
}
}
/// Bend the last anchor of `sp` into a new segment on to `to`, as `how` says.
fn bend_last(sp: &mut SubPath, to: Point, how: EndCurve) {
let prev = sp.anchors.len().checked_sub(2).and_then(|j| sp.anchors.get(j)).copied();
let Some(e) = sp.anchors.last_mut() else { return };
match how {
EndCurve::Corner => e.retract(),
EndCurve::Smooth => {
let t = prev.map_or(Vec2::ZERO, |b| (to - b.p) / 6.0);
if t.hypot() > 1e-9 {
*e = Anchor { p: e.p, h_in: e.p - t, h_out: e.p + t, kind: AnchorKind::Smooth };
} else {
e.retract();
}
}
EndCurve::Keep => {
// The way the curve comes in: from its handle, or else the segment's tangent there.
let back = if e.has_in() { Some(e.h_in) } else { prev.map(|b| if b.h_out.distance(e.p) > 1e-9 { b.h_out } else { b.p }) };
let d = back.map_or(Vec2::ZERO, |b| e.p - b);
let l = d.hypot();
e.h_out = if l > 1e-9 { e.p + d * (e.p.distance(to) / 3.0 / l) } else { e.p };
if e.has_in() && e.has_out() {
e.kind = AnchorKind::Smooth;
}
}
}
}
/// Subpath `si` of `id`, a selected path the tool edits.
fn subpath<'a>(cx: &ToolContext<'a>, id: NodeId, si: usize) -> Option<&'a SubPath> {
if !editable_paths(cx).any(|e| e == id) {
return None;
}
cx.doc.node(id)?.path_data()?.subpaths.get(si)
}
/// The current point of a selected path: its one direct-selected anchor (path, subpath, anchor).
fn current_point(cx: &ToolContext) -> Option<(NodeId, usize, usize)> {
let mut sets = cx.selection.anchors.iter().filter(|(_, set)| !set.is_empty());
let (&id, set) = sets.next()?;
if set.len() != 1 || sets.next().is_some() {
return None;
}
let &(si, ai) = set.first()?;
subpath(cx, id, si).filter(|sp| ai < sp.anchors.len()).map(|_| (id, si, ai))
}
/// The current point while it is an end of an open subpath.
fn current_end(cx: &ToolContext) -> Option<End> {
let (id, si, ai) = current_point(cx)?;
let sp = subpath(cx, id, si)?;
let n = sp.anchors.len();
let (first, last) = (sp.anchors.first()?.p, sp.anchors.last()?.p);
if sp.closed || (ai > 0 && ai + 1 < n) {
return None;
}
// A one-anchor subpath goes on from its end.
let start = ai == 0 && n > 1;
let (p, other) = if start { (first, last) } else { (last, first) };
Some(End { id, si, start, anchors: n, p, other })
}
/// Make anchor `(id, si, ai)` the current point.
fn select_point((id, si, ai): (NodeId, usize, usize)) -> Action {
Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "set"}))
}
/// Toggle anchor `(id, si, ai)` of a selected path between smooth and corner.
fn toggle(cx: &ToolContext, (id, si, ai): (NodeId, usize, usize)) -> Option<Action> {
let a = subpath(cx, id, si)?.anchors.get(ai)?;
let to = if a.kind == AnchorKind::Smooth { "corner" } else { "smooth" };
Some(Action::Exec("path.convertAnchor".into(), json!({"id": id.0, "subpath": si, "anchor": ai, "to": to})))
}
/// Snap targets for dragging anchors `anchors` of subpath `si` of `id` (the rest of the path
/// pulls).
fn hold_targets(snap: &mut DrawSnap, cx: &ToolContext, id: NodeId, si: usize, anchors: &[usize]) {
let sel = Selection { objects: vec![id], anchors: [(id, anchors.iter().map(|&ai| (si, ai)).collect())].into(), ..Selection::default() };
snap.hold(cx, || Targets::for_anchor_drag(cx.doc, &sel));
}
/// How near a press picks a point.
fn point_tol(cx: &ToolContext) -> f64 {
cx.tol(5.0).max(cx.point_tol())
}
impl CurvatureTool {
/// The path being drawn, if it is still the single selected object and matches our points.
fn active(&self, cx: &ToolContext) -> Option<NodeId> {
@ -58,12 +323,70 @@ impl CurvatureTool {
self.id = None;
self.drag = None;
self.current = None;
self.placed = None;
self.snap.clear();
}
/// The segment to the next point leaves the last one.
fn leave(&self, cx: &ToolContext) -> Option<Leave> {
self.pts.last().map(|(p, _)| Leave::segment(cx, *p, false))
/// The path the next click goes on (left out of the snap targets, its anchors still pulling)
/// and the segment to it: the path being drawn, or the selected one whose end is current.
fn leave(&self, cx: &ToolContext) -> Option<(NodeId, Leave)> {
match self.active(cx) {
Some(id) => self.pts.last().map(|(p, _)| (id, Leave::segment(cx, *p, false))),
None => current_end(cx).map(|e| (e.id, Leave::segment(cx, e.p, false))),
}
}
/// How end `e` bends into the next segment: as [`Placed`] says for the end the last click
/// placed, else it keeps its curve.
fn how(&self, e: &End) -> EndCurve {
match self.placed {
Some(pl) if (pl.id, pl.si, pl.start, pl.anchors) == (e.id, e.si, e.start, e.anchors) => {
if pl.corner {
EndCurve::Corner
} else {
EndCurve::Smooth
}
}
_ => EndCurve::Keep,
}
}
/// A press on a selected path the tool isn't drawing: on the other end of the open path going
/// on (it closes), on a point, anywhere while an end is current (the path goes on), or on a
/// segment. None elsewhere: a new path starts.
fn press_selected(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Option<Vec<Action>> {
let (p, tol) = (ev.pos, point_tol(cx));
let end = current_end(cx);
if let Some(e) = end.filter(|e| e.anchors >= 3 && p.distance(e.other) <= tol) {
let how = self.how(&e);
self.placed = None;
let v = json!({"id": e.id.0, "subpath": e.si, "op": "close", "end": e.name(), "from": how.name()});
return Some(vec![Action::Exec(EDIT.into(), v)]);
}
if let Some(hit) = anchor_in(cx, editable_paths(cx), p, tol) {
let mut out = vec![select_point(hit)];
if ev.mods.alt {
out.extend(toggle(cx, hit));
return Some(out);
}
let (id, si, ai) = hit;
hold_targets(&mut self.snap, cx, id, si, &[ai]);
self.drag = Some((Grab::Anchor(id, si, ai), p, false));
return Some(out);
}
if let Some(e) = end {
let q = self.snap.press(cx, p, &[e.id], Some(&Leave::segment(cx, e.p, false)));
let how = self.how(&e);
self.placed = Some(Placed { id: e.id, si: e.si, start: e.start, anchors: e.anchors + 1, corner: ev.mods.alt });
let v = json!({"id": e.id.0, "subpath": e.si, "op": "extend", "end": e.name(), "x": q.x, "y": q.y, "from": how.name()});
return Some(vec![Action::Exec(EDIT.into(), v)]);
}
let (id, si, seg, t) = segment_in(cx, editable_paths(cx), p, tol)?;
let n = subpath(cx, id, si)?.anchors.len().max(1);
hold_targets(&mut self.snap, cx, id, si, &[seg, (seg + 1) % n]);
self.drag = Some((Grab::Inserted(id, si, seg, t), p, true));
let v = json!({"id": id.0, "subpath": si, "op": "insert", "segment": seg, "t": t});
Some(vec![Action::Begin("Curvature".into()), Action::Preview(EDIT.into(), v)])
}
}
@ -82,8 +405,8 @@ impl Tool for CurvatureTool {
}
match ev.kind {
PointerKind::Move => {
let from = active.and_then(|_| self.leave(cx));
self.hover = Some(self.snap.hover(cx, p, active.as_slice(), from.as_ref()));
let (on, from) = self.leave(cx).unzip();
self.hover = Some(self.snap.hover(cx, p, on.as_slice(), from.as_ref()));
vec![]
}
PointerKind::Down => {
@ -99,19 +422,21 @@ impl Tool for CurvatureTool {
return vec![Action::Exec("path.curvature".into(), self.params())];
}
// The point dragged and the curve through it move: the rest pull.
let sel = Selection { objects: vec![id], anchors: [(id, [(0, i)].into())].into(), ..Selection::default() };
self.snap.hold(cx, || Targets::for_anchor_drag(cx.doc, &sel));
self.drag = Some((i, p, false));
hold_targets(&mut self.snap, cx, id, 0, &[i]);
self.drag = Some((Grab::Drawn(i), p, false));
return vec![];
}
if !self.closed {
let from = self.leave(cx);
let from = self.leave(cx).map(|l| l.1);
let p = self.snap.press(cx, p, &[id], from.as_ref());
self.pts.push((p, ev.mods.alt));
self.current = Some(self.pts.len() - 1);
return vec![Action::Exec("path.curvature".into(), self.params())];
}
}
if let Some(out) = self.press_selected(cx, ev) {
return out;
}
// Start a new path.
self.reset();
let p = self.snap.press(cx, p, &[], None);
@ -120,20 +445,30 @@ impl Tool for CurvatureTool {
vec![Action::Exec("path.curvature".into(), self.params()), Action::Notify("created".into())]
}
PointerKind::Drag => {
let Some((i, start, began)) = self.drag else { return vec![] };
let Some((grab, start, began)) = self.drag else { return vec![] };
let mut out = vec![];
if !began {
if p.distance(start) < cx.tol(3.0) {
return out;
}
out.push(Action::Begin("Curvature".into()));
self.drag = Some((i, start, true));
self.drag = Some((grab, start, true));
}
let p = self.snap.drag(cx, p, None);
if let Some(pt) = self.pts.get_mut(i) {
pt.0 = p;
}
out.push(Action::Preview("path.curvature".into(), self.params()));
out.push(match grab {
Grab::Drawn(i) => {
if let Some(pt) = self.pts.get_mut(i) {
pt.0 = p;
}
Action::Preview("path.curvature".into(), self.params())
}
Grab::Anchor(id, si, ai) => {
Action::Preview(EDIT.into(), json!({"id": id.0, "subpath": si, "op": "move", "anchor": ai, "x": p.x, "y": p.y}))
}
Grab::Inserted(id, si, seg, t) => {
Action::Preview(EDIT.into(), json!({"id": id.0, "subpath": si, "op": "insert", "segment": seg, "t": t, "x": p.x, "y": p.y}))
}
});
out
}
PointerKind::Up => {
@ -145,7 +480,7 @@ impl Tool for CurvatureTool {
}
PointerKind::DoubleClick => {
if active.is_none() {
return vec![];
return anchor_in(cx, editable_paths(cx), p, point_tol(cx)).and_then(|hit| toggle(cx, hit)).into_iter().collect();
}
let Some(i) = self.hit_point(cx, p) else { return vec![] };
self.pts[i].1 = !self.pts[i].1;
@ -158,14 +493,30 @@ impl Tool for CurvatureTool {
self.id = Some(cx.selection.objects[0]);
}
}
fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool {
matches!(key, ToolKey::Backspace | ToolKey::Delete)
&& self.drag.is_none()
&& if self.active(cx).is_some() { self.pts.len() > 1 } else { current_point(cx).is_some() }
}
fn key(&mut self, cx: &ToolContext, key: ToolKey, _m: Mods) -> Vec<Action> {
match key {
ToolKey::Escape | ToolKey::Enter => {
let dragging = self.drag.is_some_and(|d| d.2);
let drawing = self.active(cx).is_some();
self.reset();
if dragging { vec![Action::Cancel] } else { vec![] }
if dragging {
return vec![Action::Cancel];
}
// A selected path's current point goes: clicks no longer go on from it.
if drawing || current_point(cx).is_none() {
return vec![];
}
vec![Action::Exec("select.set".into(), json!({"ids": crate::json_ids(&cx.selection.objects)}))]
}
ToolKey::Backspace | ToolKey::Delete if self.active(cx).is_some() && self.pts.len() > 1 && self.drag.is_none() => {
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();
}
let i = self.current.unwrap_or(self.pts.len() - 1).min(self.pts.len() - 1);
self.pts.remove(i);
if self.pts.len() < 3 {
@ -184,19 +535,34 @@ impl Tool for CurvatureTool {
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let mut o = self.snap.guides().to_vec();
let band = cx.curvature_rubber_band && self.drag.is_none();
let mut bp = BezPath::new();
if self.active(cx).is_none() {
// The curve on from the current end of a selected path: its last segment and the next.
if band
&& let (Some(e), Some(h)) = (current_end(cx), self.hover)
&& let Some(sp) = subpath(cx, e.id, e.si)
{
let n = sp.anchors.len();
let near = if e.start {
sp.anchors.iter().take(2).rev().map(Anchor::reversed).collect()
} else {
sp.anchors.iter().skip(n.saturating_sub(2)).copied().collect()
};
let mut tail = SubPath::new(near, false);
curvature_extend(&mut tail, false, h, self.how(&e));
tail.to_bezpath_into(&mut bp);
o.push(Overlay::Path { path: bp, color: FEEDBACK, width: 1.0, dashed: false });
}
return o;
}
if cx.curvature_rubber_band
if band
&& !self.closed
&& self.drag.is_none()
&& let Some(h) = self.hover
{
let mut pts = self.pts.clone();
pts.push((h, false));
let sp = catmull_rom(&pts, false);
let mut bp = vectorcraft_geom::BezPath::new();
sp.to_bezpath_into(&mut bp);
catmull_rom(&pts, false).to_bezpath_into(&mut bp);
o.push(Overlay::Path { path: bp, color: FEEDBACK, width: 1.0, dashed: false });
}
for (i, (p, _)) in self.pts.iter().enumerate() {
@ -204,7 +570,33 @@ impl Tool for CurvatureTool {
}
o
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
if self.active(cx).is_some() {
return match self.hit_point(cx, p) {
Some(0) if !self.closed && self.pts.len() >= 3 => Cursor::PenClose,
Some(_) if m.alt => Cursor::PenConvert,
Some(_) => Cursor::Move,
None => Cursor::Pen,
};
}
let tol = point_tol(cx);
let end = current_end(cx);
if end.is_some_and(|e| e.anchors >= 3 && p.distance(e.other) <= tol) {
return Cursor::PenClose;
}
if let Some((id, si, ai)) = anchor_in(cx, editable_paths(cx), p, tol) {
let open_end = subpath(cx, id, si).is_some_and(|sp| !sp.closed && (ai == 0 || ai + 1 == sp.anchors.len()));
return if m.alt {
Cursor::PenConvert
} else if open_end {
Cursor::PenContinue
} else {
Cursor::Move
};
}
if end.is_none() && segment_in(cx, editable_paths(cx), p, tol).is_some() {
return Cursor::PenAdd;
}
Cursor::Pen
}
}
@ -213,7 +605,8 @@ impl Tool for CurvatureTool {
mod tests {
use super::*;
use crate::testutil::*;
use vectorcraft_doc::Selection;
use vectorcraft_doc::{Appearance, Document, Node, Selection};
use vectorcraft_geom::{ParamCurve, PathData};
#[test]
fn first_click_creates_then_notify_tracks_path() {
@ -252,4 +645,266 @@ mod tests {
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 202.0, 99.0));
assert!(t.overlays(&cx).is_empty());
}
fn pt(x: f64, y: f64) -> Point {
Point::new(x, y)
}
/// An open pen-style path: a corner start, two smooth anchors with uneven handles, a cusp and
/// an end with only an incoming handle.
fn wave() -> SubPath {
SubPath::new(
vec![
Anchor::corner(pt(0.0, 100.0)),
Anchor::with_handles(pt(100.0, 0.0), pt(60.0, 10.0), pt(160.0, -15.0)),
Anchor::with_handles(pt(250.0, 80.0), pt(220.0, 30.0), pt(280.0, 130.0)),
Anchor::with_handles(pt(350.0, 150.0), pt(320.0, 160.0), pt(380.0, 100.0)),
Anchor::with_handles(pt(450.0, 60.0), pt(420.0, 90.0), pt(450.0, 60.0)),
],
false,
)
}
/// Every point of `a` lies on `b` (within 1e-6), sampled.
fn lies_on(a: &SubPath, b: &SubPath) -> bool {
let b = PathData::single(b.clone());
(0..a.segment_count()).all(|i| (0..=20).all(|k| b.nearest(a.segment(i).eval(k as f64 / 20.0)).is_some_and(|n| n.4 < 1e-6)))
}
fn collinear(a: Vec2, b: Vec2) -> bool {
a.cross(b).abs() < 1e-6 * a.hypot().max(1.0) * b.hypot().max(1.0)
}
#[test]
fn moving_a_point_recurves_only_its_two_segments() {
let before = wave();
let mut sp = before.clone();
assert_eq!(sp.anchors[2].kind, AnchorKind::Smooth);
assert!(curvature_move(&mut sp, 2, pt(260.0, 140.0)));
assert_eq!(sp.anchors[2].p, pt(260.0, 140.0));
// The segments beyond its neighbours are bit-identical.
assert_eq!(sp.segment(0), before.segment(0));
assert_eq!(sp.segment(3), before.segment(3));
// The neighbours keep their handle directions on its segments, and their other handles.
assert!(collinear(sp.anchors[1].h_out - sp.anchors[1].p, before.anchors[1].h_out - before.anchors[1].p));
assert!(collinear(sp.anchors[3].h_in - sp.anchors[3].p, before.anchors[3].h_in - before.anchors[3].p));
assert_eq!((sp.anchors[1].h_in, sp.anchors[3].h_out), (before.anchors[1].h_in, before.anchors[3].h_out));
// The moved smooth point turns along the line through its neighbours.
let a = sp.anchors[2];
assert_eq!(a.kind, AnchorKind::Smooth);
assert!(collinear(a.h_out - a.p, sp.anchors[3].p - sp.anchors[1].p) && collinear(a.h_in - a.p, a.h_out - a.p));
assert!((a.h_out - a.p).dot(sp.anchors[3].p - sp.anchors[1].p) > 0.0);
// A cusp keeps its handle directions; a moved end changes its one segment only.
assert_eq!(sp.anchors[3].kind, AnchorKind::Corner);
assert!(curvature_move(&mut sp, 3, pt(360.0, 170.0)));
assert!(collinear(sp.anchors[3].h_out - sp.anchors[3].p, before.anchors[3].h_out - before.anchors[3].p));
let mut end = before.clone();
assert!(curvature_move(&mut end, 4, pt(470.0, 20.0)));
assert_eq!(end.segment(2), before.segment(2));
assert!(!curvature_move(&mut end, 5, pt(0.0, 0.0)));
}
#[test]
fn moving_a_corner_without_handles_keeps_its_sides_straight() {
let (d, id) = doc_with_rect();
let mut sp = d.node(id).unwrap().path_data().unwrap().subpaths[0].clone();
assert!(curvature_move(&mut sp, 0, pt(80.0, 70.0)));
assert!((0..sp.segment_count()).all(|i| sp.segment_is_line(i)));
assert_eq!(sp.anchors[0], Anchor::corner(pt(80.0, 70.0)));
}
#[test]
fn going_on_from_an_end_keeps_the_old_segments() {
let before = wave();
let mut sp = before.clone();
curvature_extend(&mut sp, false, pt(550.0, 120.0), EndCurve::Keep);
assert_eq!(sp.anchors.len(), 6);
assert!((0..4).all(|i| sp.segment(i) == before.segment(i)));
// The old end turns smooth, going on along its incoming handle, a third of the chord out.
let e = sp.anchors[4];
assert_eq!(e.kind, AnchorKind::Smooth);
assert!(collinear(e.h_out - e.p, e.p - e.h_in) && (e.h_out - e.p).dot(e.p - e.h_in) > 0.0);
assert!((e.h_out.distance(e.p) - e.p.distance(pt(550.0, 120.0)) / 3.0).abs() < 1e-9);
assert_eq!(sp.anchors[5], Anchor::corner(pt(550.0, 120.0)));
// The point placed then curves through its neighbours as a drawn path's points do.
curvature_extend(&mut sp, false, pt(650.0, 60.0), EndCurve::Smooth);
let drawn = catmull_rom(&[(pt(450.0, 60.0), false), (pt(550.0, 120.0), false), (pt(650.0, 60.0), false)], false);
assert_eq!((sp.anchors[5].h_in, sp.anchors[5].h_out), (drawn.anchors[1].h_in, drawn.anchors[1].h_out));
assert!((0..4).all(|i| sp.segment(i) == before.segment(i)));
// A corner stays one.
curvature_extend(&mut sp, false, pt(700.0, 100.0), EndCurve::Corner);
assert!(!sp.anchors[6].has_in() && !sp.anchors[6].has_out());
// From the start: the old segments come one later, and the new end is the first anchor.
let mut sp = before.clone();
curvature_extend(&mut sp, true, pt(-100.0, 50.0), EndCurve::Keep);
assert_eq!(sp.anchors[0], Anchor::corner(pt(-100.0, 50.0)));
assert!((0..4).all(|i| sp.segment(i + 1) == before.segment(i)));
// A corner end without handles goes on along its segment's tangent.
let old = sp.anchors[1];
assert!(old.has_in() && !old.has_out());
assert!(collinear(old.h_in - old.p, old.p - before.anchors[1].h_in) && (old.h_in - old.p).dot(old.p - before.anchors[1].h_in) > 0.0);
}
#[test]
fn closing_keeps_the_old_segments() {
let before = wave();
let mut sp = before.clone();
curvature_close(&mut sp, false, EndCurve::Keep);
assert!(sp.closed && sp.anchors.len() == 5);
assert!((0..4).all(|i| sp.segment(i) == before.segment(i)));
// The first anchor gains an incoming handle along its tangent: no kink there.
let f = sp.anchors[0];
assert!(f.has_in() && collinear(f.p - f.h_in, before.anchors[1].h_in - f.p));
}
#[test]
fn inserting_a_point_keeps_the_shape() {
let before = wave();
let mut sp = before.clone();
assert_eq!(curvature_insert(&mut sp, 1, 0.3), Some(2));
assert_eq!(sp.anchors.len(), 6);
assert_eq!(sp.anchors[2].kind, AnchorKind::Smooth);
assert!(lies_on(&sp, &before) && lies_on(&before, &sp));
// On a straight side the point is smooth too, its handles along the side.
let (d, id) = doc_with_rect();
let rect = d.node(id).unwrap().path_data().unwrap().subpaths[0].clone();
let mut sp = rect.clone();
let i = curvature_insert(&mut sp, 0, 0.5).unwrap();
assert_eq!(sp.anchors[i].kind, AnchorKind::Smooth);
assert!(sp.anchors[i].has_in() && sp.anchors[i].has_out());
assert!(lies_on(&sp, &rect) && lies_on(&rect, &sp));
assert_eq!(curvature_insert(&mut sp, 99, 0.5), None);
}
/// A document with one path, `sp`.
fn doc_with(sp: SubPath) -> (Document, NodeId) {
let mut d = Document::new(800.0, 600.0);
let l = d.layers[0].id;
let id = d.alloc_id();
d.insert(Some(l), 0, Node::path(id, PathData::single(sp), Appearance::default_art())).unwrap();
(d, id)
}
/// Path `id` selected, anchor `current` of its first subpath direct-selected (or none).
fn selected(id: NodeId, current: Option<usize>) -> Selection {
let mut s = Selection::default();
s.set([id]);
if let Some(ai) = current {
s.anchors.insert(id, [(0, ai)].into());
}
s
}
/// A context without snapping.
fn quiet<'a>(d: &'a Document, s: &'a Selection, p: &'a crate::PaintDefaults) -> ToolContext<'a> {
ToolContext { smart_guides: false, snap_to_point: false, ..cx(d, s, p) }
}
fn press(t: &mut CurvatureTool, cx: &ToolContext, x: f64, y: f64, alt: bool) -> Vec<Action> {
let ev = PointerEvent::new(PointerKind::Down, x, y).with_mods(Mods { alt, ..Mods::default() });
let mut a = t.pointer(cx, &ev);
a.extend(t.pointer(cx, &PointerEvent::new(PointerKind::Up, x, y)));
a
}
/// The op and params of a `path.curvatureEdit` action.
fn edit_op(a: &Action) -> Option<(&str, &Value)> {
match a {
Action::Exec(c, v) | Action::Preview(c, v) if c == EDIT => Some((v["op"].as_str()?, v)),
_ => None,
}
}
#[test]
fn a_press_on_a_point_selects_it_and_a_drag_moves_it() {
let (d, id) = doc_with(wave());
let (s, p) = (selected(id, None), paint());
let cx = quiet(&d, &s, &p);
let mut t = CurvatureTool::default();
assert_eq!(t.cursor(&cx, pt(250.0, 81.0), Mods::default()), Cursor::Move);
assert_eq!(t.cursor(&cx, pt(450.0, 61.0), Mods::default()), Cursor::PenContinue);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 251.0, 81.0));
assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 2]], "mode": "set"}))]);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 260.0, 140.0));
assert_eq!(a[0], Action::Begin("Curvature".into()));
let moved = edit_op(&a[1]).map(|(op, v)| (op, v["anchor"].clone(), v["x"].clone(), v["y"].clone()));
assert_eq!(moved, Some(("move", json!(2), json!(260.0), json!(140.0))));
assert!(t.busy());
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 260.0, 140.0)), vec![Action::Commit]);
// A press without a drag only selects.
assert_eq!(press(&mut t, &cx, 100.0, 0.0, false).len(), 1);
}
#[test]
fn alt_click_or_double_click_toggles_and_delete_removes() {
let (d, id) = doc_with(wave());
let p = paint();
let s = selected(id, None);
let cx = quiet(&d, &s, &p);
let mut t = CurvatureTool::default();
let to = |a: &[Action]| a.iter().find(|a| matches!(a, Action::Exec(c, _) if c == "path.convertAnchor")).cloned();
let a = press(&mut t, &cx, 100.0, 0.0, true);
assert!(matches!(to(&a), Some(Action::Exec(_, v)) if v["anchor"] == 1 && v["to"] == "corner"), "{a:?}");
assert_eq!(t.cursor(&cx, pt(100.0, 0.0), Mods { alt: true, ..Mods::default() }), Cursor::PenConvert);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::DoubleClick, 0.0, 100.0));
assert!(matches!(to(&a), Some(Action::Exec(_, v)) if v["anchor"] == 0 && v["to"] == "smooth"), "{a:?}");
// Delete is the tool's while a point is current, and removes it.
assert!(!t.claims_key(&cx, ToolKey::Delete));
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))]);
// 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]}))]);
}
#[test]
fn a_press_on_a_segment_adds_a_point_and_a_drag_moves_it_in_one_step() {
let (d, id) = doc_with(wave());
let (s, p) = (selected(id, None), paint());
let cx = quiet(&d, &s, &p);
let mut t = CurvatureTool::default();
let on = wave().segment(1).eval(0.5);
assert_eq!(t.cursor(&cx, on, Mods::default()), Cursor::PenAdd);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, on.x, on.y));
assert_eq!(a[0], Action::Begin("Curvature".into()));
assert!(
matches!(edit_op(&a[1]), Some(("insert", v)) if v["segment"] == 1 && (v["t"].as_f64().unwrap() - 0.5).abs() < 1e-3 && v.get("x").is_none())
);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, on.x + 1.0, on.y + 40.0));
assert!(matches!(edit_op(&a[0]), Some(("insert", v)) if v["segment"] == 1 && v["y"] == on.y + 40.0), "{a:?}");
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, on.x, on.y + 40.0)), vec![Action::Commit]);
}
#[test]
fn clicks_go_on_from_a_current_end_and_the_other_end_closes() {
let p = paint();
let mut sp = wave();
let mut t = CurvatureTool::default();
let (d, id) = doc_with(sp.clone());
let s = selected(id, Some(4));
let a = press(&mut t, &quiet(&d, &s, &p), 550.0, 120.0, false);
assert!(matches!(edit_op(&a[0]), Some(("extend", v)) if v["end"] == "end" && v["from"] == "keep" && v["x"] == 550.0), "{a:?}");
// As the engine does it: the new end is current, and the next click curves through it.
curvature_extend(&mut sp, false, pt(550.0, 120.0), EndCurve::Keep);
let (d, id) = doc_with(sp.clone());
let s = selected(id, Some(5));
let cx = quiet(&d, &s, &p);
t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 650.0, 60.0));
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Path { .. })), "the rubber band shows");
let a = press(&mut t, &cx, 650.0, 60.0, true);
assert!(matches!(edit_op(&a[0]), Some(("extend", v)) if v["from"] == "smooth"), "{a:?}");
curvature_extend(&mut sp, false, pt(650.0, 60.0), EndCurve::Smooth);
let (d, id) = doc_with(sp.clone());
let s = selected(id, Some(6));
let cx = quiet(&d, &s, &p);
// The Alt-clicked point stays a corner; a click on the first point closes.
assert_eq!(t.cursor(&cx, pt(0.0, 100.0), Mods::default()), Cursor::PenClose);
let a = press(&mut t, &cx, 0.0, 100.0, false);
assert!(matches!(edit_op(&a[0]), Some(("close", v)) if v["from"] == "corner" && v["end"] == "end"), "{a:?}");
// Any other end keeps its curve; the start goes on from the start.
let s = selected(id, Some(0));
let a = press(&mut t, &quiet(&d, &s, &p), -50.0, 150.0, false);
assert!(matches!(edit_op(&a[0]), Some(("extend", v)) if v["end"] == "start" && v["from"] == "keep"), "{a:?}");
}
}

View File

@ -27,7 +27,7 @@ use vectorcraft_doc::{NodeId, NodeKind};
use vectorcraft_geom::{BezPath, Point};
use crate::direct::hit_handle;
use crate::draw2::AnchorTool;
use crate::draw2::{AnchorTool, editable_paths};
use crate::guides::{DrawSnap, Leave};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
@ -122,15 +122,6 @@ fn alt_converts(cx: &ToolContext, p: Point, m: Mods) -> bool {
|| crate::draw2::segment_in(cx, editable_paths(cx), p, cx.pick_tol()).is_some())
}
/// The selected paths the pen edits (not guides, nor locked or hidden ones).
fn editable_paths<'a>(cx: &'a ToolContext) -> impl Iterator<Item = NodeId> + 'a {
cx.selection
.objects
.iter()
.copied()
.filter(|&id| cx.doc.is_editable(id) && cx.doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Path { guide: false, .. })))
}
/// Preview the outgoing handle of anchor `ai` of subpath `si` at `h`, the incoming one left alone.
fn set_out_handle(id: NodeId, si: usize, ai: usize, h: Point) -> Action {
Action::Preview(

View File

@ -3355,6 +3355,7 @@ axis Both Ambos
@msg cannot distort a zero-size bounding box no se puede distorsionar un cuadro delimitador de tamaño cero
@msg cannot read PDF: {0} no se puede leer el PDF: {0}
@msg cannot split an open path at its end point no se puede dividir un trazado abierto en su punto final
@msg closing needs three anchors cerrar necesita tres puntos de ancla
@msg check an asset to export marca un recurso para exportar
@msg choice must be "merge" or "add", not `{choice}` choice debe ser "merge" o "add", no `{choice}`
@msg choose a direction to divide in elige una dirección en la que dividir

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3352,6 +3352,7 @@ axis Both Les deux
@msg cannot distort a zero-size bounding box impossible de déformer un cadre de sélection de taille nulle
@msg cannot read PDF: {0} impossible de lire le PDF : {0}
@msg cannot split an open path at its end point impossible de scinder un tracé ouvert à son extrémité
@msg closing needs three anchors la fermeture nécessite trois points d’ancrage
@msg check an asset to export cochez une ressource à exporter
@msg choice must be "merge" or "add", not `{choice}` choice doit valoir "merge" ou "add", pas `{choice}`
@msg choose a direction to divide in choisissez une direction de division

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3355,6 +3355,7 @@ axis Both Entrambi
@msg cannot distort a zero-size bounding box impossibile distorcere un riquadro di delimitazione di dimensione zero
@msg cannot read PDF: {0} impossibile leggere il PDF: {0}
@msg cannot split an open path at its end point impossibile dividere un tracciato aperto nel suo punto finale
@msg closing needs three anchors per chiudere servono tre punti di ancoraggio
@msg check an asset to export spunta una risorsa da esportare
@msg choice must be "merge" or "add", not `{choice}` choice deve essere "merge" o "add", non `{choice}`
@msg choose a direction to divide in scegli una direzione in cui dividere

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3279,6 +3279,7 @@ theme Appearance 外観
@msg no saved selections 保存した選択範囲はありません
@msg no such artboard 指定されたアートボードが見つかりません
@msg cannot split an open path at its end point オープンパスは端点で分割できません
@msg closing needs three anchors 閉じるには 3 つのアンカーポイントが必要です
@msg empty brush stroke ブラシストロークが空です
@msg no such anchor 指定されたアンカーポイントが見つかりません
@msg no such segment 指定されたセグメントが見つかりません

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3351,6 +3351,7 @@ axis Both Оба
@msg cannot distort a zero-size bounding box нельзя исказить ограничительную рамку нулевого размера
@msg cannot read PDF: {0} не удалось прочитать PDF: {0}
@msg cannot split an open path at its end point нельзя разделить открытый контур в его конечной точке
@msg closing needs three anchors для замыкания нужны три опорные точки
@msg check an asset to export отметьте ресурс для экспорта
@msg choice must be "merge" or "add", not `{choice}` choice должно быть "merge" или "add", а не `{choice}`
@msg choose a direction to divide in выберите направление для разделения

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3338,6 +3338,7 @@ axis Both Обидві
@msg cannot distort a zero-size bounding box не можна спотворити обмежувальну рамку нульового розміру
@msg cannot read PDF: {0} не вдалося прочитати PDF: {0}
@msg cannot split an open path at its end point не можна розділити відкритий контур у його кінцевій точці
@msg closing needs three anchors для замикання потрібні три опорні точки
@msg check an asset to export позначте ресурс для експорту
@msg choice must be "merge" or "add", not `{choice}` choice має бути "merge" або "add", а не `{choice}`
@msg choose a direction to divide in виберіть напрямок для поділу

Can't render this file because it has a wrong number of fields in line 13.

View File

@ -1230,6 +1230,26 @@ the path it had begun while that stays selected. Its reply's `tool` is the tool
{"name":"run_command","arguments":{"command":"path.moveAnchors","params":{"dx":0,"dy":40}}}
```
## Editing a path with the Curvature tool
The Curvature tool edits any selected path, whatever drew it, keeping its shape except where edited (#798). Each edit
is one undo step and leaves the anchor it edited direct-selected (the tool's current point):
`path.curvatureEdit {id, subpath?, op, …}` answers `{anchor}`. `op: "move"` with `anchor`, `x`, `y` moves a point and
re-curves only its two segments: a smooth point turns along the line through its neighbours, and the neighbours'
handles keep their directions (their lengths scale with the chord), so the segments beyond don't change.
`op: "insert"` with `segment`, `t` adds a smooth point there without changing the shape (with `x`, `y` it is then
moved there: the tool's click-and-drag on a segment). `op: "extend"` with `x`, `y` goes on from the `end` (default)
or the `start` of an open subpath, and `op: "close"` closes it from that end; `from` says how the old end bends into
the new segment: `"keep"` (default) keeps its curve, `"smooth"` curves it through its neighbours (a point the tool
placed), `"corner"` leaves it without handles. Alt-click or double-click toggles a point with `path.convertAnchor`, and
Backspace/Delete removes the current point with `path.removeAnchor`.
```json
{"name":"run_command","arguments":{"command":"path.curvatureEdit","params":{"id":12,"op":"move","anchor":2,"x":260,"y":140}}}
{"name":"run_command","arguments":{"command":"path.curvatureEdit","params":{"id":12,"op":"extend","end":"end","x":550,"y":120}}}
{"name":"run_command","arguments":{"command":"path.curvatureEdit","params":{"id":12,"op":"extend","x":650,"y":60,"from":"smooth"}}}
```
## Registration and trim marks
Every document has the built-in `[Registration]` swatch (listed after None by `swatch.list`): a colour that prints on