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
+219 -1
View File
@@ -4,7 +4,8 @@
use std::collections::BTreeSet;
use serde_json::{Value, json};
use vectorcraft_doc::NodeId;
use vectorcraft_doc::{LiveCorners, LiveShape, NodeId};
use vectorcraft_geom::shapes::CornerKind;
use vectorcraft_geom::{AnchorKind, PathData, Point};
use vectorcraft_tools::{PointerEvent, PointerKind};
@@ -456,3 +457,220 @@ fn the_tool_draws_one_step_per_drag_and_arrows_change_the_count() {
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(shape_of(&s, id).map(|s| s.width), Some(120.0));
}
// ---------- Corner Tool ----------
/// A square (100, 100)–(300, 300), plain and selected: anchors from the top left, clockwise.
fn square(s: &mut Session) -> NodeId {
let id = line(s, &[(100.0, 100.0), (300.0, 100.0), (300.0, 300.0), (100.0, 300.0)], true);
select(s, &[id]);
id
}
fn live(s: &Session, id: NodeId) -> Option<LiveShape> {
s.doc().unwrap().doc.node(id).unwrap().live_shape().cloned()
}
fn drawn(s: &Session, id: NodeId, corner: usize) -> f64 {
LiveCorners::of(s.doc().unwrap().doc.node(id).unwrap()).unwrap().drawn_radius(corner)
}
#[test]
fn corner_set_cuts_live_corners_as_one_undo_step() {
let mut s = session();
let id = square(&mut s);
let plain = path(&s, id);
let n = steps(&s);
// The selected path: all four.
let r = s.execute("addon.pathtools.cornerSet", &json!({"radius": 20, "kind": "chamfer"})).unwrap();
assert_eq!(r, json!({"ids": [id.0], "corners": 4}));
assert_eq!((steps(&s), path(&s, id).anchor_count()), (n + 1, 8));
let lc = LiveCorners::of(s.doc().unwrap().doc.node(id).unwrap()).map(|c| (c.drawn_radius(3), c.kind(3))).unwrap();
assert_eq!(lc, (20.0, CornerKind::Chamfer));
// The same again changes nothing: no step.
s.execute("addon.pathtools.cornerSet", &json!({"radius": 20, "kind": "chamfer"})).unwrap();
assert_eq!(steps(&s), n + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((path(&s, id), live(&s, id)), (plain, None));
}
#[test]
fn corner_set_goes_by_anchors_the_selection_and_items_and_fits() {
let mut s = session();
let id = square(&mut s);
let other = line(&mut s, &[(350.0, 10.0), (390.0, 10.0), (390.0, 50.0)], false);
// Named anchors, of each path by `items`, at once; an open end takes none.
let items = json!([{"id": id.0, "anchors": [1]}, {"id": other.0, "anchors": [0, 1]}]);
let n = steps(&s);
let r = s.execute("addon.pathtools.cornerSet", &json!({"items": items, "radius": 5})).unwrap();
assert_eq!((r["corners"].clone(), steps(&s)), (json!(2), n + 1));
assert_eq!((drawn(&s, id, 1), drawn(&s, id, 0), drawn(&s, other, 1)), (5.0, 0.0, 5.0));
// `ids` with `anchors`, keeping the kind each has.
s.execute("addon.pathtools.cornerSet", &json!({"ids": [id.0], "anchors": [1], "radius": 9})).unwrap();
assert_eq!(drawn(&s, id, 1), 9.0);
// Direct-selected anchors: only their corners, which stay selected as they gain anchors.
select_anchors(&mut s, id, &[0]);
s.execute("addon.pathtools.cornerSet", &json!({"radius": 7})).unwrap();
assert_eq!((drawn(&s, id, 0), drawn(&s, id, 2)), (7.0, 0.0));
let sel = s.doc().unwrap().selection.anchors[&id].clone();
let c = LiveCorners::of(s.doc().unwrap().doc.node(id).unwrap()).unwrap();
assert_eq!(c.corners_of(&sel), BTreeSet::from([0]));
// A radius that doesn't fit is drawn at what does.
s.execute("addon.pathtools.cornerSet", &json!({"ids": [id.0], "anchors": [2], "radius": 5000})).unwrap();
assert!(drawn(&s, id, 2) <= 200.0 - 7.0 + 1e-9, "{}", drawn(&s, id, 2));
}
#[test]
fn corner_remove_makes_corners_sharp_and_a_path_plain_again() {
let mut s = session();
let id = square(&mut s);
let plain = path(&s, id);
s.execute("addon.pathtools.cornerSet", &json!({"anchors": [0, 1], "radius": 10, "kind": "invertedRound"})).unwrap();
let n = steps(&s);
let r = s.execute("addon.pathtools.cornerRemove", &json!({"items": [{"id": id.0, "anchors": [0]}]})).unwrap();
assert_eq!((r["corners"].clone(), steps(&s), drawn(&s, id, 0), drawn(&s, id, 1)), (json!(1), n + 1, 0.0, 10.0));
s.execute("addon.pathtools.cornerRemove", &json!({})).unwrap();
assert_eq!((steps(&s), path(&s, id), live(&s, id)), (n + 2, plain, None));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(drawn(&s, id, 1), 10.0);
}
#[test]
fn corner_commands_refuse_bad_params() {
let mut s = session();
let id = square(&mut s);
let smooth = circle(&mut s);
let i = id.0;
refuses(
&mut s,
"addon.pathtools.cornerSet",
&[
json!({"ids": [i]}),
json!({"ids": [i], "radius": -1}),
json!({"ids": [i], "radius": 1e308}),
json!({"ids": [i], "radius": "big"}),
json!({"ids": [i], "radius": 5, "kind": "spiky"}),
json!({"ids": [i], "anchors": "all", "radius": 5}),
json!({"ids": [i], "anchors": [-1], "radius": 5}),
json!({"ids": [i], "anchors": [9], "radius": 5}),
json!({"ids": [u64::MAX], "radius": 5}),
json!({"items": 3, "radius": 5}),
json!({"items": [{"anchors": [0]}], "radius": 5}),
json!({"ids": [smooth.0], "radius": 5}),
],
);
refuses(&mut s, "addon.pathtools.cornerRemove", &[json!({"anchors": [9], "ids": [i]}), json!({"ids": [smooth.0]})]);
// Locked: left alone.
s.execute("object.lock", &json!({"ids": [i]})).ok();
if !s.doc().unwrap().doc.is_editable(id) {
refuses(&mut s, "addon.pathtools.cornerSet", &[json!({"items": [{"id": i}], "radius": 5})]);
}
}
#[test]
fn the_corner_tool_clicks_drags_and_removes_in_one_step_each() {
let mut s = session();
let id = square(&mut s);
let view = ViewInfo { smart_guides: false, ..ViewInfo::default() };
s.select_tool("cornerTool", view).unwrap();
s.set_tool_option("radius", &json!(15));
let n = steps(&s);
let press = |s: &mut Session, k, x, y| s.pointer(&PointerEvent::new(k, x, y), view).unwrap();
// A click on the top right anchor.
press(&mut s, PointerKind::Down, 300.0, 100.0);
press(&mut s, PointerKind::Up, 300.0, 100.0);
assert_eq!((steps(&s), drawn(&s, id, 1)), (n + 1, 15.0));
// A drag from the bottom right anchor, previewed, one step.
press(&mut s, PointerKind::Down, 300.0, 300.0);
press(&mut s, PointerKind::Drag, 280.0, 280.0);
assert_eq!(drawn(&s, id, 2), 20.0, "the preview shows");
press(&mut s, PointerKind::Drag, 270.0, 270.0);
press(&mut s, PointerKind::Up, 270.0, 270.0);
assert_eq!((steps(&s), drawn(&s, id, 2)), (n + 2, 30.0));
// A drag on the marker resizes; a double-click on it removes.
press(&mut s, PointerKind::Down, 270.0, 270.0);
press(&mut s, PointerKind::Drag, 260.0, 260.0);
press(&mut s, PointerKind::Up, 260.0, 260.0);
assert_eq!((steps(&s), drawn(&s, id, 2)), (n + 3, 40.0));
press(&mut s, PointerKind::DoubleClick, 260.0, 260.0);
assert_eq!((steps(&s), drawn(&s, id, 2)), (n + 4, 0.0));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(drawn(&s, id, 2), 40.0);
// A click on a marker selects the corner and loads its values.
press(&mut s, PointerKind::Down, 285.0, 115.0);
press(&mut s, PointerKind::Up, 285.0, 115.0);
assert_eq!(steps(&s), n + 3, "selecting is no edit");
assert_eq!(s.doc().unwrap().selection.anchors.get(&id).map(|a| a.len()), Some(2));
// The bottom right one, resized by its marker, loads its radius.
press(&mut s, PointerKind::Down, 260.0, 260.0);
press(&mut s, PointerKind::Up, 260.0, 260.0);
assert_eq!(s.tool_options_of("cornerTool")["radius"], json!(40.0));
// An Alt-marquee over the left side: both anchors, in one step.
let alt = vectorcraft_tools::Mods { alt: true, ..Default::default() };
for (k, x, y) in [(PointerKind::Down, 50.0, 50.0), (PointerKind::Drag, 150.0, 350.0), (PointerKind::Up, 150.0, 350.0)] {
s.pointer(&PointerEvent::new(k, x, y).with_mods(alt), view).unwrap();
}
assert_eq!((steps(&s), drawn(&s, id, 0), drawn(&s, id, 3)), (n + 4, 40.0, 40.0));
}
// ---------- Slide Anchor Along Path ----------
#[test]
fn slide_anchor_moves_it_along_the_path_as_one_undo_step() {
let mut s = session();
let e = circle(&mut s);
let before = path(&s, e);
let area = kurbo::Shape::area(&before.to_bezpath()).abs();
let want = before.subpaths[0].segment(3);
let n = steps(&s);
let r = s.execute("addon.pathtools.slideAnchor", &json!({"id": e.0, "anchor": 3, "t": 0.5})).unwrap();
assert!(r["strayed"].as_f64().unwrap() < 0.05, "{r}");
let after = path(&s, e);
assert_eq!((steps(&s), after.anchor_count()), (n + 1, 16));
assert!(after.subpaths[0].anchors[3].p.distance(kurbo::ParamCurve::eval(&want, 0.5)) < 1e-9);
assert!((kurbo::Shape::area(&after.to_bezpath()).abs() / area - 1.0).abs() < 1e-3);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(&s, e), before);
// No move, no step.
s.execute("addon.pathtools.slideAnchor", &json!({"id": e.0, "anchor": 3, "t": 0})).unwrap();
assert_eq!(steps(&s), n);
}
#[test]
fn slide_anchor_refuses_bad_params() {
let mut s = session();
let l = line(&mut s, &[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false);
let i = l.0;
refuses(
&mut s,
"addon.pathtools.slideAnchor",
&[
json!({"id": i, "anchor": 0, "t": 0.5}),
json!({"id": i, "anchor": 2, "t": -0.5}),
json!({"id": i, "anchor": 9, "t": 0.5}),
json!({"id": i, "anchor": 1, "t": 1.5}),
json!({"id": i, "anchor": 1}),
json!({"id": i, "t": 0.5}),
json!({"id": i, "subpath": 4, "anchor": 1, "t": 0.5}),
json!({"id": u64::MAX, "anchor": 1, "t": 0.5}),
],
);
}
#[test]
fn the_slide_tool_previews_and_commits_one_step() {
let mut s = session();
let l = line(&mut s, &[(100.0, 100.0), (200.0, 100.0), (300.0, 100.0)], false);
let view = ViewInfo { smart_guides: false, ..ViewInfo::default() };
s.select_tool("slideAnchor", view).unwrap();
let n = steps(&s);
s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 100.0), view).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 250.0, 120.0), view).unwrap();
let at = |s: &Session| path(s, l).subpaths[0].anchors[1].p;
assert!(at(&s).distance(Point::new(250.0, 100.0)) < 1e-3, "the preview shows");
s.pointer(&PointerEvent::new(PointerKind::Drag, 280.0, 90.0), view).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 280.0, 90.0), view).unwrap();
assert!(steps(&s) == n + 1 && at(&s).distance(Point::new(280.0, 100.0)) < 1e-3);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(at(&s), Point::new(200.0, 100.0));
}