mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-02 22:20:50 -06:00
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:
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user