Add-ons: Path Precision Tools — Corner Tool and Slide Anchor

Path Precision Tools gains two tools, kept out of the toolbars (All Tools
drawer and Object › Plug-ins › Path Precision Tools, no shortcuts):

- Corner Tool (cornerTool): editable corners on any path through the
  existing Live Corners. Hover shows an eligible anchor, a click cuts a
  corner with the tool's radius and kind, a drag sizes it (Shift: whole
  values), a corner's marker selects it and loads its values, resizes it
  and removes it at 0 or on a double-click; a corner never grows past
  what fits; an Alt-marquee cuts every eligible anchor inside.
- Corners section in the Anchor Inspector: radius, kind, selected count,
  Apply to Selected Anchors.
- Slide Anchor Along Path (slideAnchor): a drag runs an anchor along its
  curve, the neighbouring segments refitted by the keep-shape kernel;
  Shift snaps where the tangent is a multiple of 45° from the constrain
  angle.
- Commands addon.pathtools.cornerSet, cornerRemove and slideAnchor, one
  undo step each, params validated and covered by the command sweep.
- Cursor stand-ins, catalog rows in every language, docs and tests.
This commit is contained in:
bflatastic
2026-10-31 04:21:26 +07:00
parent 0494f505d9
commit c9dc4fa857
55 changed files with 1939 additions and 47 deletions
+6
View File
@@ -3,9 +3,11 @@
//! submenu selects them. Like every tool they only emit commands (`addon.<name>.*`).
pub mod anchor_eraser;
pub mod corner_tool;
pub mod extend_path;
pub mod measure_inspect;
pub mod parametric_shape;
pub mod slide_anchor;
pub mod smart_pen;
use crate::{Tool, ToolInfo};
@@ -16,6 +18,8 @@ pub const PATH_TOOLS: &[ToolInfo] = &[
ToolInfo { id: extend_path::ID, label: "Extend Path Tool", shortcut: None, icon: "dc-arrow-extend" },
ToolInfo { id: measure_inspect::ID, label: "Measure & Inspect Tool", shortcut: None, icon: "ruler" },
ToolInfo { id: parametric_shape::ID, label: "Parametric Shape Tool", shortcut: None, icon: "hexagon" },
ToolInfo { id: corner_tool::ID, label: "Corner Tool", shortcut: None, icon: "dc-rounded-rect" },
ToolInfo { id: slide_anchor::ID, label: "Slide Anchor Along Path", shortcut: None, icon: "dc-anchor" },
];
/// Smart Pen's tool, a flyout group of its own.
@@ -37,9 +41,11 @@ pub fn heading(id: &str) -> Option<&'static str> {
pub fn create(id: &str) -> Option<Box<dyn Tool>> {
Some(match id {
anchor_eraser::ID => Box::new(anchor_eraser::AnchorEraserTool::default()),
corner_tool::ID => Box::new(corner_tool::CornerTool::default()),
extend_path::ID => Box::new(extend_path::ExtendPathTool::default()),
measure_inspect::ID => Box::new(measure_inspect::MeasureInspectTool::default()),
parametric_shape::ID => Box::new(parametric_shape::ParametricShapeTool::default()),
slide_anchor::ID => Box::new(slide_anchor::SlideAnchorTool::default()),
smart_pen::ID => Box::new(smart_pen::SmartPenTool::default()),
_ => return None,
})
+387
View File
@@ -0,0 +1,387 @@
//! Corner Tool (Path Precision Tools): editable corners on any path, selected or not, through the
//! app's Live Corners (`addon.pathtools.cornerSet` / `cornerRemove`). Over an anchor that can take
//! a corner, the anchor shows; a click cuts one with the tool's radius and kind, a drag sizes it
//! (Shift: whole values of the ruler's unit). A cut corner shows a marker at the centre of its cut:
//! a click selects the corner and loads its radius and kind into the tool, a drag resizes it (to 0
//! it goes), a double-click removes it. A corner can't grow past what fits. Alt-marquee cuts
//! corners on every anchor inside; a plain marquee selects them, and a click away deselects.
use std::cell::Cell;
use std::collections::BTreeMap;
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, LiveCorners, NodeId, NodeKind};
use vectorcraft_geom::corners::Corner;
use vectorcraft_geom::shapes::CornerKind;
use vectorcraft_geom::{Affine, Point, Rect};
use crate::builder::HIGHLIGHT_RED;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
/// The tool's id.
pub const ID: &str = "cornerTool";
/// The radius a click cuts by default (pt).
pub const RADIUS: f64 = 10.0;
/// The largest radius asked for (pt).
pub const MAX_RADIUS: f64 = 1e6;
/// The undo step of a drag.
const LABEL: &str = "Corner Radius";
const SET: &str = "addon.pathtools.cornerSet";
const REMOVE: &str = "addon.pathtools.cornerRemove";
/// How far (px) the pointer travels before a press is a drag.
const DRAG_PX: f64 = 3.0;
/// Most markers drawn at once.
const MAX_MARKERS: usize = 2000;
const MARKER_PX: f32 = 7.0;
const BLUE: [u8; 3] = [0x4f, 0x9d, 0xff];
/// A corner the tool can act on.
#[derive(Clone, Debug, PartialEq)]
struct Spot {
id: NodeId,
corner: Corner,
/// Maps the corner's space into the document.
xf: Affine,
/// The radius it is drawn with: 0 for a sharp corner.
drawn: f64,
kind: CornerKind,
}
impl Spot {
fn of(lc: &LiveCorners, id: NodeId, corner: &Corner) -> Self {
Self { id, corner: *corner, xf: lc.xf, drawn: lc.drawn_radius(corner.index), kind: lc.kind(corner.index) }
}
/// Where it shows: a cut corner's marker, the centre of a round cut, or a sharp corner's anchor.
fn at(&self) -> Point {
self.xf * self.corner.on_bisector(self.drawn / self.corner.sin())
}
/// The params naming it.
fn items(&self) -> Value {
json!([{ "id": self.id.0, "anchors": [self.corner.index] }])
}
}
/// Every cut corner of the editable paths.
fn cut_corners(doc: &Document) -> Vec<Spot> {
let editable = doc.editable();
let mut out = vec![];
doc.walk(|n| {
if matches!(n.kind, NodeKind::Path { live: Some(_), guide: false, .. })
&& editable(n.id)
&& let Some(lc) = LiveCorners::of(n)
{
out.extend(lc.corners.iter().filter(|c| lc.drawn_radius(c.index) > 0.0).map(|c| Spot::of(&lc, n.id, c)));
}
});
out
}
/// The sharp corner of an editable path nearest `p` within the point tolerance.
fn sharp_corner_at(cx: &ToolContext, p: Point) -> Option<Spot> {
let (tol, editable) = (cx.point_tol(), cx.doc.editable());
let mut best: Option<(f64, Spot)> = None;
cx.doc.walk(|n| {
let NodeKind::Path { path, guide: false, .. } = &n.kind else { return };
if !path.anchors().any(|(_, _, a)| a.p.distance(p) <= tol) || !editable(n.id) {
return;
}
let Some(lc) = LiveCorners::of(n) else { return };
for c in lc.corners.iter().filter(|c| lc.drawn_radius(c.index) <= 0.0) {
let s = Spot::of(&lc, n.id, c);
let d = s.at().distance(p);
if d <= tol && best.as_ref().is_none_or(|b| d < b.0) {
best = Some((d, s));
}
}
});
best.map(|(_, s)| s)
}
/// The corners of the editable paths inside `r`, by path: anchor indices of each uncut outline.
fn corners_inside(cx: &ToolContext, r: Rect) -> BTreeMap<NodeId, Vec<usize>> {
let editable = cx.doc.editable();
let mut out = BTreeMap::new();
cx.doc.walk(|n| {
if !matches!(n.kind, NodeKind::Path { guide: false, .. }) || !editable(n.id) {
return;
}
let Some(lc) = LiveCorners::of(n) else { return };
let inside: Vec<usize> = lc.corners.iter().filter(|c| r.contains(lc.xf * c.at)).map(|c| c.index).collect();
if !inside.is_empty() {
out.insert(n.id, inside);
}
});
out
}
/// The largest radius `spot` draws with the corners around it as they are.
fn max_radius(doc: &Document, spot: &Spot) -> f64 {
let Some(lc) = doc.node(spot.id).and_then(LiveCorners::of) else { return 0.0 };
let radii: Vec<f64> = (0..lc.base.anchor_count()).map(|k| lc.drawn_radius(k)).collect();
spot.corner.max_shared_radius(&lc.corners, &radii, |k| k == spot.corner.index)
}
/// `select.anchors` params selecting `corners` of path `id`.
fn select_params(cx: &ToolContext, id: NodeId, corners: &[usize], mode: &str) -> Option<Value> {
let lc = cx.doc.node(id).and_then(LiveCorners::of)?;
let anchors: Vec<_> = lc.anchors_of(&corners.iter().copied().collect()).into_iter().collect();
Some(json!({ "id": id.0, "anchors": anchors, "mode": mode }))
}
/// A document revision and the cut corners it has.
type Marks = ((u64, u64), Arc<Vec<Spot>>);
#[derive(Clone, Debug, Default)]
enum State {
#[default]
Idle,
/// Pressed on a corner: a drag sizes it.
Corner {
spot: Box<Spot>,
start: Point,
/// The largest radius that fits.
max: f64,
/// The drag has left the click.
began: bool,
/// The radius the drag asks for.
radius: f64,
/// Where the pointer is.
at: Point,
},
/// `cut`: an Alt-marquee, which cuts corners; else it selects them.
Marquee { start: Point, cur: Point, cut: bool, toggle: bool },
}
pub struct CornerTool {
radius: f64,
kind: CornerKind,
state: State,
hover: Option<Spot>,
/// The cut corners of the document as it was at a revision, for hovering and the markers.
cache: Cell<Option<Marks>>,
}
impl Default for CornerTool {
fn default() -> Self {
Self { radius: RADIUS, kind: CornerKind::Round, state: State::Idle, hover: None, cache: Cell::new(None) }
}
}
impl CornerTool {
fn markers(&self, cx: &ToolContext) -> Arc<Vec<Spot>> {
let kept = self.cache.take().filter(|(rev, _)| *rev == cx.revision);
let list = kept.map_or_else(|| Arc::new(cut_corners(cx.doc)), |(_, list)| list);
self.cache.set(Some((cx.revision, list.clone())));
list
}
/// The corner at `p`: a cut corner's marker, else a sharp corner's anchor.
fn spot_at(&self, cx: &ToolContext, p: Point) -> Option<Spot> {
let (marks, tol) = (self.markers(cx), cx.point_tol());
let dist = |s: &&Spot| s.at().distance(p);
let mark = marks.iter().filter(|s| dist(s) <= tol).min_by(|a, b| dist(a).total_cmp(&dist(b)));
mark.cloned().or_else(|| sharp_corner_at(cx, p))
}
/// The command a drag of `spot` to `radius` stands for.
fn sized(&self, spot: &Spot, radius: f64) -> Action {
if radius > 0.0 {
let kind = if spot.drawn > 0.0 { spot.kind } else { self.kind };
Action::Preview(SET.into(), json!({ "items": spot.items(), "radius": radius, "kind": kind }))
} else {
Action::Preview(REMOVE.into(), json!({ "items": spot.items() }))
}
}
fn drag_corner(&mut self, cx: &ToolContext, p: Point, mods: Mods) -> Vec<Action> {
let State::Corner { spot, start, max, began, radius, at } = &mut self.state else { return vec![] };
*at = p;
let mut out = vec![];
if !*began {
if p.distance(*start) < cx.tol(DRAG_PX) {
return out;
}
*began = true;
out.push(Action::Begin(LABEL.into()));
}
let inv = spot.xf.inverse();
let change = spot.corner.radius_change(inv * p - inv * *start);
// From a sharp corner a drag either way sizes it; a cut one grows inward and shrinks back.
let asked = if spot.drawn > 0.0 { spot.drawn + change } else { change.abs() };
let asked = if mods.shift { cx.unit.to_pt(cx.unit.from_pt(asked).round()) } else { asked };
*radius = asked.min(*max).max(0.0);
let (spot, r) = (spot.clone(), *radius);
out.push(self.sized(&spot, r));
out
}
/// A press on a corner let go without a drag.
fn click_corner(&self, cx: &ToolContext, spot: &Spot, mods: Mods) -> Vec<Action> {
if spot.drawn <= 0.0 {
return vec![Action::Exec(SET.into(), json!({ "items": spot.items(), "radius": self.radius, "kind": self.kind }))];
}
let mode = if mods.shift { "toggle" } else { "set" };
select_params(cx, spot.id, &[spot.corner.index], mode).map_or_else(Vec::new, |p| vec![Action::Exec("select.anchors".into(), p)])
}
fn finish_marquee(&self, cx: &ToolContext, r: Rect, cut: bool, toggle: bool) -> Vec<Action> {
let inside = corners_inside(cx, r);
if cut {
if inside.is_empty() {
return vec![];
}
let items: Vec<Value> = inside.iter().map(|(id, corners)| json!({ "id": id.0, "anchors": corners })).collect();
return vec![Action::Exec(SET.into(), json!({ "items": items, "radius": self.radius, "kind": self.kind }))];
}
if inside.is_empty() && !toggle {
return vec![Action::Exec("select.none".into(), json!({}))];
}
let modes = std::iter::once(if toggle { "toggle" } else { "set" }).chain(std::iter::repeat(if toggle { "toggle" } else { "add" }));
inside
.iter()
.zip(modes)
.filter_map(|((id, corners), mode)| Some(Action::Exec("select.anchors".into(), select_params(cx, *id, corners, mode)?)))
.collect()
}
}
impl Tool for CornerTool {
fn id(&self) -> &'static str {
ID
}
fn busy(&self) -> bool {
!matches!(self.state, State::Idle)
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
match ev.kind {
PointerKind::Move => {
self.hover = self.spot_at(cx, p);
vec![]
}
PointerKind::Down => {
self.hover = None;
let spot = if ev.mods.alt { None } else { self.spot_at(cx, p) };
self.state = match spot {
Some(spot) => {
// A cut corner's values are the tool's from now on, as the panel shows.
if spot.drawn > 0.0 {
(self.radius, self.kind) = (spot.drawn, spot.kind);
}
let max = max_radius(cx.doc, &spot);
State::Corner { radius: spot.drawn, spot: Box::new(spot), start: p, max, began: false, at: p }
}
None => State::Marquee { start: p, cur: p, cut: ev.mods.alt, toggle: ev.mods.shift },
};
vec![]
}
PointerKind::Drag => {
if let State::Marquee { cur, .. } = &mut self.state {
*cur = p;
return vec![];
}
self.drag_corner(cx, p, ev.mods)
}
PointerKind::Up => match std::mem::take(&mut self.state) {
State::Idle => vec![],
State::Marquee { start, cut, toggle, .. } => self.finish_marquee(cx, Rect::from_points(start, p), cut, toggle),
state @ State::Corner { began: true, .. } => {
self.state = state;
let mut out = self.drag_corner(cx, p, ev.mods);
if let State::Corner { spot, radius, .. } = std::mem::take(&mut self.state) {
// Back where it started: nothing to keep.
if spot.drawn <= 0.0 && radius <= 0.0 {
return vec![Action::Cancel];
}
}
out.push(Action::Commit);
out
}
State::Corner { spot, .. } => self.click_corner(cx, &spot, ev.mods),
},
PointerKind::DoubleClick => {
self.state = State::Idle;
match self.spot_at(cx, p).filter(|s| s.drawn > 0.0) {
Some(s) => vec![Action::Exec(REMOVE.into(), json!({ "items": s.items() }))],
None => vec![],
}
}
}
}
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
if key != ToolKey::Escape {
return vec![];
}
match std::mem::take(&mut self.state) {
State::Corner { began: true, .. } => vec![Action::Cancel],
_ => vec![],
}
}
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
self.busy() && key == ToolKey::Escape
}
fn hover_ended(&mut self, _cx: &ToolContext) {
self.hover = None;
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let ring = |p, color, filled| Overlay::CurvePoint { p, color, filled, size: MARKER_PX };
match &self.state {
State::Marquee { start, cur, .. } => return vec![Overlay::Marquee(Rect::from_points(*start, *cur))],
State::Corner { began: true, radius, max, at, .. } => {
let mut out = vec![];
if cx.measurement_labels {
out.push(Overlay::Measure { p: *at, text: format!("Radius: {}", cx.len(*radius)) });
}
// At the limit of what fits.
if *max > 0.0 && *radius >= *max {
out.push(ring(*at, HIGHLIGHT_RED, false));
}
return out;
}
_ => {}
}
let mut out: Vec<Overlay> = self.markers(cx).iter().take(MAX_MARKERS).map(|s| ring(s.at(), BLUE, false)).collect();
match &self.hover {
Some(s) if s.drawn > 0.0 => out.push(ring(s.at(), BLUE, true)),
Some(s) => out.push(Overlay::Anchor { p: s.at(), color: BLUE, filled: true, size: MARKER_PX }),
None => {}
}
out
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
Cursor::CornerTool
}
fn options(&self) -> Value {
json!({ "radius": self.radius, "kind": self.kind })
}
fn set_option(&mut self, key: &str, v: &Value) {
match key {
"radius" => {
if let Some(r) = v.as_f64().filter(|r| (0.0..=MAX_RADIUS).contains(r)) {
self.radius = r;
}
}
"kind" => {
if let Ok(k) = serde_json::from_value(v.clone()) {
self.kind = k;
}
}
_ => {}
}
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,161 @@
use vectorcraft_doc::corners::set_corners;
use vectorcraft_doc::{Appearance, Node, Selection};
use vectorcraft_geom::{PathData, SubPath};
use super::*;
use crate::testutil::*;
/// A square (100, 100)–(300, 300), plain: anchors from the top left, clockwise.
fn doc_with_square() -> (Document, NodeId) {
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let id = d.alloc_id();
let pts = [(100.0, 100.0), (300.0, 100.0), (300.0, 300.0), (100.0, 300.0)].map(|(x, y)| Point::new(x, y));
d.insert(Some(l), 0, Node::path(id, PathData::single(SubPath::polyline(&pts, true)), Appearance::default_art())).unwrap();
(d, id)
}
/// [`doc_with_square`] with a chamfer of radius 20 on corner 1, whose marker is at (280, 120).
fn doc_with_corner() -> (Document, NodeId) {
let (mut d, id) = doc_with_square();
let Some(NodeKind::Path { path, live, .. }) = d.node_mut(id).map(|n| &mut n.kind) else { panic!() };
set_corners(path, live, &[1].into(), Some(20.0), Some(CornerKind::Chamfer));
(d, id)
}
fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
PointerEvent::new(kind, x, y)
}
fn with(mut e: PointerEvent, f: impl Fn(&mut Mods)) -> PointerEvent {
f(&mut e.mods);
e
}
fn radius_of(a: Option<&Action>) -> f64 {
let Some(Action::Preview(_, p)) = a else { panic!("{a:?}") };
p["radius"].as_f64().unwrap()
}
#[test]
fn hover_shows_the_anchor_and_a_click_cuts_with_the_tools_values() {
let (d, id) = doc_with_square();
let (sel, p) = (Selection::default(), paint());
let cx = cx(&d, &sel, &p);
let mut t = CornerTool::default();
t.set_option("radius", &json!(15));
t.set_option("kind", &json!("invertedRound"));
assert!(t.pointer(&cx, &ev(PointerKind::Move, 301.0, 101.0)).is_empty());
assert!(matches!(t.overlays(&cx).as_slice(), [Overlay::Anchor { filled: true, .. }]), "{:?}", t.overlays(&cx));
t.pointer(&cx, &ev(PointerKind::Move, 200.0, 200.0));
assert!(t.overlays(&cx).is_empty(), "nothing there");
assert!(t.pointer(&cx, &ev(PointerKind::Down, 300.0, 100.0)).is_empty());
assert!(t.busy());
let up = t.pointer(&cx, &ev(PointerKind::Up, 300.0, 100.0));
assert_eq!(up, vec![Action::Exec(SET.into(), json!({"items": [{"id": id.0, "anchors": [1]}], "radius": 15.0, "kind": "invertedRound"}))]);
assert!(!t.busy());
}
#[test]
fn a_drag_from_an_anchor_sizes_the_corner_and_stops_at_what_fits() {
let (d, id) = doc_with_square();
let (sel, p) = (Selection::default(), paint());
let cx = cx(&d, &sel, &p);
let mut t = CornerTool::default();
t.pointer(&cx, &ev(PointerKind::Down, 300.0, 100.0));
let drag = t.pointer(&cx, &ev(PointerKind::Drag, 280.0, 120.0));
assert_eq!(drag.first(), Some(&Action::Begin(LABEL.into())));
assert!((radius_of(drag.get(1)) - 20.0).abs() < 1e-9, "{drag:?}");
let Some(Action::Preview(cmd, params)) = drag.get(1) else { panic!() };
assert_eq!((cmd.as_str(), params["items"][0]["id"].clone(), params["kind"].clone()), (SET, json!(id.0), json!("round")));
// Shift: whole values.
let shifted = t.pointer(&cx, &with(ev(PointerKind::Drag, 276.6, 123.4), |m| m.shift = true));
assert_eq!(radius_of(shifted.first()), 23.0);
// Past what fits: as far as the sharp corners next to it, marked at the limit.
let far = t.pointer(&cx, &ev(PointerKind::Drag, 0.0, 400.0));
assert!((radius_of(far.first()) - 200.0).abs() < 1e-9, "{far:?}");
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::CurvePoint { color, .. } if *color == HIGHLIGHT_RED)));
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text.contains("Radius: 200"))));
let up = t.pointer(&cx, &ev(PointerKind::Up, 280.0, 120.0));
assert!(matches!(up.as_slice(), [Action::Preview(..), Action::Commit]), "{up:?}");
// Back on the anchor: nothing to keep. Escape cancels.
t.pointer(&cx, &ev(PointerKind::Down, 300.0, 100.0));
t.pointer(&cx, &ev(PointerKind::Drag, 280.0, 120.0));
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 300.0, 100.0)), vec![Action::Cancel]);
t.pointer(&cx, &ev(PointerKind::Down, 300.0, 100.0));
t.pointer(&cx, &ev(PointerKind::Drag, 280.0, 120.0));
assert!(t.claims_key(&cx, ToolKey::Escape));
assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel]);
assert!(!t.busy());
}
#[test]
fn a_cut_corners_marker_selects_loads_resizes_and_removes() {
let (d, id) = doc_with_corner();
let (sel, p) = (Selection::default(), paint());
let cx = cx(&d, &sel, &p);
let mut t = CornerTool::default();
assert!(matches!(t.overlays(&cx).as_slice(), [Overlay::CurvePoint { filled: false, .. }]), "the marker shows");
t.pointer(&cx, &ev(PointerKind::Move, 281.0, 119.0));
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::CurvePoint { filled: true, .. })), "hovered");
// A click selects the corner's anchors and loads its values.
t.pointer(&cx, &ev(PointerKind::Down, 280.0, 120.0));
let up = t.pointer(&cx, &ev(PointerKind::Up, 280.0, 120.0));
let Some(Action::Exec(cmd, params)) = up.first() else { panic!("{up:?}") };
assert_eq!((cmd.as_str(), params["id"].clone(), params["mode"].clone()), ("select.anchors", json!(id.0), json!("set")));
assert_eq!(params["anchors"], json!([[0, 1], [0, 2]]));
assert_eq!(t.options(), json!({"radius": 20.0, "kind": "chamfer"}));
// A drag resizes it, its kind kept; to nothing it goes.
t.pointer(&cx, &ev(PointerKind::Down, 280.0, 120.0));
let drag = t.pointer(&cx, &ev(PointerKind::Drag, 270.0, 130.0));
assert!((radius_of(drag.get(1)) - 30.0).abs() < 1e-9, "{drag:?}");
assert!(matches!(drag.get(1), Some(Action::Preview(_, p)) if p["kind"] == json!("chamfer")));
let gone = t.pointer(&cx, &ev(PointerKind::Up, 310.0, 90.0));
assert!(matches!(gone.as_slice(), [Action::Preview(c, _), Action::Commit] if c == REMOVE), "{gone:?}");
// A double-click removes it.
let dbl = t.pointer(&cx, &ev(PointerKind::DoubleClick, 280.0, 120.0));
assert_eq!(dbl, vec![Action::Exec(REMOVE.into(), json!({"items": [{"id": id.0, "anchors": [1]}]}))]);
assert!(t.pointer(&cx, &ev(PointerKind::DoubleClick, 300.0, 300.0)).is_empty(), "a sharp corner has nothing to remove");
}
#[test]
fn an_alt_marquee_cuts_every_corner_inside_and_a_plain_one_selects() {
let (d, id) = doc_with_corner();
let (sel, p) = (Selection::default(), paint());
let cx = cx(&d, &sel, &p);
let mut t = CornerTool::default();
// Alt over an anchor starts a marquee too; the chamfered corner counts, the far side doesn't.
let alt = |e| with(e, |m| m.alt = true);
t.pointer(&cx, &alt(ev(PointerKind::Down, 50.0, 50.0)));
t.pointer(&cx, &alt(ev(PointerKind::Drag, 350.0, 200.0)));
assert!(matches!(t.overlays(&cx).as_slice(), [Overlay::Marquee(_)]));
let up = t.pointer(&cx, &alt(ev(PointerKind::Up, 350.0, 200.0)));
assert_eq!(up, vec![Action::Exec(SET.into(), json!({"items": [{"id": id.0, "anchors": [0, 1]}], "radius": RADIUS, "kind": "round"}))]);
t.pointer(&cx, &alt(ev(PointerKind::Down, 400.0, 400.0)));
assert!(t.pointer(&cx, &alt(ev(PointerKind::Up, 450.0, 450.0))).is_empty(), "nothing inside");
// Without Alt the corners inside are selected; a click away deselects.
t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0));
let up = t.pointer(&cx, &ev(PointerKind::Up, 200.0, 200.0));
let Some(Action::Exec(cmd, params)) = up.first() else { panic!("{up:?}") };
assert_eq!((cmd.as_str(), params["anchors"].clone(), params["mode"].clone()), ("select.anchors", json!([[0, 0]]), json!("set")));
t.pointer(&cx, &ev(PointerKind::Down, 400.0, 400.0));
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 400.0, 400.0)), vec![Action::Exec("select.none".into(), json!({}))]);
}
#[test]
fn locked_paths_are_left_alone_and_options_are_checked() {
let (mut d, id) = doc_with_corner();
d.node_mut(id).unwrap().locked = true;
let (sel, p) = (Selection::default(), paint());
let cx = cx(&d, &sel, &p);
let mut t = CornerTool::default();
assert!(t.overlays(&cx).is_empty());
t.pointer(&cx, &ev(PointerKind::Move, 100.0, 100.0));
assert!(t.overlays(&cx).is_empty());
for bad in [json!(-1), json!(1e308), json!("x")] {
t.set_option("radius", &bad);
}
t.set_option("kind", &json!("spiky"));
assert_eq!(t.options(), json!({"radius": RADIUS, "kind": "round"}));
assert_eq!(t.cursor(&cx, Point::ZERO, Mods::default()), Cursor::CornerTool);
}
+231
View File
@@ -0,0 +1,231 @@
//! Slide Anchor Along Path (Path Precision Tools): drag an anchor, selected or not, and it runs
//! along the curve it sits on, the two segments beside it refitted so the outline stays
//! (`addon.pathtools.slideAnchor`, previewed while dragging, one undo step). Shift snaps to where
//! the path's tangent is at a multiple of 45° from the constrain angle. Ends of open paths don't
//! slide.
use serde_json::json;
use vectorcraft_doc::{NodeId, NodeKind};
use vectorcraft_geom::{Point, SubPath};
use vectorcraft_pathops::addons::pathtools::{slide_stops, slide_t_at};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
/// The tool's id.
pub const ID: &str = "slideAnchor";
/// The undo step's name.
const LABEL: &str = "Slide Anchor Along Path";
const BLUE: [u8; 3] = [0x4f, 0x9d, 0xff];
const SIZE: f32 = 7.0;
/// An anchor that can slide.
#[derive(Clone, Debug, PartialEq)]
struct Grip {
id: NodeId,
subpath: usize,
anchor: usize,
p: Point,
/// The subpath as it was.
sp: SubPath,
}
#[derive(Default)]
pub struct SlideAnchorTool {
hover: Option<Grip>,
/// The anchor being dragged and how far along.
drag: Option<(Grip, f64)>,
}
/// The anchor of an editable path nearest `p` within the point tolerance that can slide.
fn grip_at(cx: &ToolContext, p: Point) -> Option<Grip> {
let (tol, editable) = (cx.point_tol(), cx.doc.editable());
let mut best: Option<(f64, NodeId, usize, usize, Point)> = None;
cx.doc.walk(|n| {
let NodeKind::Path { path, guide: false, .. } = &n.kind else { return };
for (si, ai, a) in path.anchors() {
let d = a.p.distance(p);
let open_end = path.subpaths.get(si).is_some_and(|sp| !sp.closed && (ai == 0 || ai + 1 == sp.anchors.len()));
if d <= tol && !open_end && best.is_none_or(|b| d < b.0) && editable(n.id) {
best = Some((d, n.id, si, ai, a.p));
}
}
});
let (_, id, subpath, anchor, at) = best?;
let sp = cx.doc.node(id)?.path_data()?.subpaths.get(subpath)?.clone();
Some(Grip { id, subpath, anchor, p: at, sp })
}
/// How far along a drag to `p` takes `g`: with Shift, to the nearest place where the tangent is at
/// a multiple of 45° from the constrain angle.
fn slide_to(cx: &ToolContext, g: &Grip, p: Point, mods: Mods) -> f64 {
let t = slide_t_at(&g.sp, g.anchor, p).unwrap_or(0.0);
let stops = if mods.shift { slide_stops(&g.sp, g.anchor, cx.constrain_angle) } else { vec![] };
stops.into_iter().min_by(|a, b| (a - t).abs().total_cmp(&(b - t).abs())).unwrap_or(t)
}
impl Tool for SlideAnchorTool {
fn id(&self) -> &'static str {
ID
}
fn busy(&self) -> bool {
self.drag.is_some()
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
match ev.kind {
PointerKind::Move => {
self.hover = grip_at(cx, ev.pos);
vec![]
}
PointerKind::Down => {
let Some(g) = grip_at(cx, ev.pos) else { return vec![] };
self.hover = None;
self.drag = Some((g, 0.0));
vec![Action::Begin(LABEL.into())]
}
PointerKind::Drag | PointerKind::Up => {
let Some((g, _)) = self.drag.take() else { return vec![] };
let t = slide_to(cx, &g, ev.pos, ev.mods);
let params = json!({ "id": g.id.0, "subpath": g.subpath, "anchor": g.anchor, "t": t });
let preview = Action::Preview("addon.pathtools.slideAnchor".into(), params);
if ev.kind == PointerKind::Drag {
self.drag = Some((g, t));
return vec![preview];
}
// Back where it started: nothing to keep.
if t == 0.0 { vec![Action::Cancel] } else { vec![preview, Action::Commit] }
}
PointerKind::DoubleClick => vec![],
}
}
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
if key == ToolKey::Escape && self.drag.take().is_some() { vec![Action::Cancel] } else { vec![] }
}
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
self.drag.is_some() && key == ToolKey::Escape
}
fn hover_ended(&mut self, _cx: &ToolContext) {
self.hover = None;
}
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
let anchor = |p, filled| Overlay::Anchor { p, color: BLUE, filled, size: SIZE };
match (&self.drag, &self.hover) {
(Some((g, _)), _) => vec![anchor(g.p, false)],
(None, Some(g)) => vec![anchor(g.p, true)],
(None, None) => vec![],
}
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
Cursor::SlideAnchor
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use vectorcraft_doc::{Appearance, Document, Node, Selection};
use vectorcraft_geom::PathData;
/// A document with an open zigzag (100, 100), (200, 100), (300, 100) and a circle-ish curve.
fn doc_with_line() -> (Document, NodeId) {
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let id = d.alloc_id();
let pts = [Point::new(100.0, 100.0), Point::new(200.0, 100.0), Point::new(300.0, 100.0)];
d.insert(Some(l), 0, Node::path(id, PathData::single(SubPath::polyline(&pts, false)), Appearance::default_art())).unwrap();
(d, id)
}
fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
PointerEvent::new(kind, x, y)
}
#[test]
fn dragging_an_anchor_previews_the_slide_and_commits() {
let (d, id) = doc_with_line();
let (sel, p) = (Selection::default(), paint());
let cx = cx(&d, &sel, &p);
let mut t = SlideAnchorTool::default();
assert!(t.pointer(&cx, &ev(PointerKind::Move, 201.0, 101.0)).is_empty());
assert!(matches!(t.overlays(&cx).as_slice(), [Overlay::Anchor { filled: true, .. }]));
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 201.0, 101.0)), vec![Action::Begin(LABEL.into())]);
assert!(t.busy());
let drag = t.pointer(&cx, &ev(PointerKind::Drag, 250.0, 130.0));
let Some(Action::Preview(cmd, params)) = drag.first() else { panic!("{drag:?}") };
assert_eq!(cmd, "addon.pathtools.slideAnchor");
assert_eq!((params["id"].clone(), params["subpath"].clone(), params["anchor"].clone()), (json!(id.0), json!(0), json!(1)));
assert!((params["t"].as_f64().unwrap() - 0.5).abs() < 1e-6);
// The other way along the path.
let up = t.pointer(&cx, &ev(PointerKind::Up, 175.0, 90.0));
assert!(matches!(up.as_slice(), [Action::Preview(_, p), Action::Commit] if p["t"].as_f64().unwrap() < 0.0), "{up:?}");
assert!(!t.busy());
}
#[test]
fn back_where_it_started_nothing_is_kept_and_escape_cancels() {
let (d, _) = doc_with_line();
let (sel, p) = (Selection::default(), paint());
let cx = cx(&d, &sel, &p);
let mut t = SlideAnchorTool::default();
t.pointer(&cx, &ev(PointerKind::Down, 200.0, 100.0));
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 200.0, 100.0)), vec![Action::Cancel]);
t.pointer(&cx, &ev(PointerKind::Down, 200.0, 100.0));
t.pointer(&cx, &ev(PointerKind::Drag, 250.0, 100.0));
assert!(t.claims_key(&cx, ToolKey::Escape));
assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel]);
assert!(!t.busy() && !t.claims_key(&cx, ToolKey::Escape));
}
#[test]
fn ends_and_empty_canvas_do_nothing() {
let (d, _) = doc_with_line();
let (sel, p) = (Selection::default(), paint());
let cx = cx(&d, &sel, &p);
let mut t = SlideAnchorTool::default();
for (x, y) in [(100.0, 100.0), (300.0, 100.0), (150.0, 300.0)] {
assert!(t.pointer(&cx, &ev(PointerKind::Down, x, y)).is_empty(), "({x}, {y})");
assert!(t.pointer(&cx, &ev(PointerKind::Up, x, y)).is_empty());
}
assert_eq!(t.cursor(&cx, Point::ZERO, Mods::default()), Cursor::SlideAnchor);
}
#[test]
fn shift_snaps_to_where_the_tangent_is_at_45_degrees() {
// A smooth anchor on a curve turning from east to south through south-east.
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let id = d.alloc_id();
let a = |x: f64, y: f64, h: (f64, f64), o: (f64, f64)| vectorcraft_geom::Anchor {
p: Point::new(x, y),
h_in: Point::new(h.0, h.1),
h_out: Point::new(o.0, o.1),
kind: vectorcraft_geom::AnchorKind::Smooth,
};
let sp = SubPath::new(
vec![
a(0.0, 100.0, (0.0, 100.0), (0.0, 100.0)),
a(100.0, 100.0, (100.0, 100.0), (155.0, 100.0)),
a(200.0, 200.0, (200.0, 145.0), (200.0, 200.0)),
],
false,
);
d.insert(Some(l), 0, Node::path(id, PathData::single(sp.clone()), Appearance::default_art())).unwrap();
let (sel, p) = (Selection::default(), paint());
let cx = cx(&d, &sel, &p);
let mut t = SlideAnchorTool::default();
t.pointer(&cx, &ev(PointerKind::Down, 100.0, 100.0));
let mut shifted = ev(PointerKind::Drag, 140.0, 105.0);
shifted.mods.shift = true;
let drag = t.pointer(&cx, &shifted);
let Some(Action::Preview(_, params)) = drag.first() else { panic!("{drag:?}") };
let at = params["t"].as_f64().unwrap();
assert!((at - 0.5).abs() < 1e-6, "half way round a symmetric quarter turn: {at}");
}
}
+11 -1
View File
@@ -180,6 +180,12 @@ pub enum Cursor {
/// The Parametric Shape Tool (Path Precision Tools add-on): a drag draws a shape out from its
/// centre; a selected shape's handles edit it.
ParametricShape,
/// The Corner Tool (Path Precision Tools add-on): a click or drag on an anchor cuts a corner;
/// a corner's marker resizes it.
CornerTool,
/// The Slide Anchor Along Path tool (Path Precision Tools add-on): a drag runs an anchor along
/// the path.
SlideAnchor,
/// The Smart Pen Tool (Smart Pen add-on) away from paths, where a click starts a new path (over
/// a path or while drawing it shows the Pen's and Direct Selection's cursors).
SmartPen,
@@ -278,6 +284,8 @@ impl Cursor {
| Cursor::ExtendPath
| Cursor::MeasureInspect
| Cursor::ParametricShape
| Cursor::CornerTool
| Cursor::SlideAnchor
| Cursor::SmartPen => Cursor::Crosshair,
c => c,
}
@@ -316,7 +324,9 @@ impl Cursor {
| Cursor::AnchorEraser
| Cursor::ExtendPath
| Cursor::MeasureInspect
| Cursor::ParametricShape => Some(Cursor::Crosshair),
| Cursor::ParametricShape
| Cursor::CornerTool
| Cursor::SlideAnchor => Some(Cursor::Crosshair),
Cursor::TypeVertical => Some(Cursor::Text),
Cursor::MeshAdd | Cursor::PuppetPin => Some(Cursor::PenAdd),
Cursor::SmartPen => Some(Cursor::Pen),
+12
View File
@@ -702,6 +702,18 @@ group = "Drawing and editing tools"
shown_when = ["The Parametric Shape Tool (Path Precision Tools add-on): a drag draws a re-editable shape out from its centre; a click selects one and shows its handles."]
hotspot = "Exact: the centre of the shape being drawn."
[[cursor]]
id = "CornerTool"
group = "Drawing and editing tools"
shown_when = ["The Corner Tool (Path Precision Tools add-on): a click or drag on an anchor where two sides meet cuts a corner there; a drag on a corner's marker resizes it; an Alt-drag cuts corners on every anchor inside the marquee."]
hotspot = "Exact: the anchor or marker being picked."
[[cursor]]
id = "SlideAnchor"
group = "Drawing and editing tools"
shown_when = ["The Slide Anchor Along Path tool (Path Precision Tools add-on): a drag moves an anchor along the curve it sits on."]
hotspot = "Exact: the anchor being dragged follows it along the path."
# ── Symbolism tools ───────────────────────────────────────────────────────────────────────────────
# The brush size is drawn by the app as a circle around the cursor.
+1
View File
@@ -69,6 +69,7 @@ const ROWS: &[Row] = &[
// The add-ons' tools (`crate::addons`).
(&[crate::addons::anchor_eraser::ID], None, &["size", "tolerance", "pressure"]),
(&[crate::addons::extend_path::ID], None, &["mode"]),
(&[crate::addons::corner_tool::ID], None, &["radius", "kind"]),
(&[crate::addons::smart_pen::ID], None, &["next"]),
// The kind first: a new kind resets the values after it.
(&[crate::addons::parametric_shape::ID], None, &["kind", "count", "a", "b", "radius", "width", "height", "rotation"]),