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
+54 -20
View File
@@ -1,9 +1,11 @@
//! Path Precision Tools (`addon.pathtools.*`), a built-in add-on: the Anchor Eraser Brush's stroke,
//! the Extend Path tool's extension, the Measure & Inspect tool's readouts, the Anchor Inspector's
//! anchor edits, Split / Add Points at Crossings and the Parametric Shape Tool's shapes ([`shape`]).
//! anchor edits, Split / Add Points at Crossings, Slide Anchor Along Path, the Corner Tool's corners
//! ([`corners`]) and the Parametric Shape Tool's shapes ([`shape`]).
//! Its tools, panels and submenu (Object › Plug-ins › Path Precision Tools) are the tools crate's
//! and the UI's.
mod corners;
mod shape;
#[cfg(test)]
mod tests;
@@ -139,8 +141,18 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
crossings
),
cmd!(
"addon.pathtools.slideAnchor",
"Slide Anchor Along Path",
[],
None,
"{id, subpath?: index (0), anchor: index, t: -0.98..0.98 (where to: positive, a curve parameter on the segment after the anchor; negative, back from the end of the one before)} move an anchor along its path, the segments beside it refitted so the outline stays, as one undo step; ends of open paths don't slide → {id, strayed (pt)}",
has_doc,
slide_anchor
),
];
v.extend(shape::specs());
v.extend(corners::specs());
v
}
@@ -288,8 +300,8 @@ fn edit_whole(s: &mut Session, label: &str, changed: Vec<(NodeId, PathData)>) ->
// ---------- Extend Path ----------
/// The open subpath an `extend` request names: (path id, subpath index, its data).
fn open_subpath(s: &Session, p: &Value, cmd: &str) -> Result<(NodeId, usize, SubPath)> {
/// The subpath a request names: (path id, subpath index, its data).
fn named_subpath(s: &Session, p: &Value, cmd: &str) -> Result<(NodeId, usize, SubPath)> {
let id = checked_id(s, p.get("id").unwrap_or(&Value::Null), cmd)?;
let si = usize_opt(p, "subpath", cmd)?.unwrap_or(0);
let d = &s.doc()?.doc;
@@ -298,15 +310,33 @@ fn open_subpath(s: &Session, p: &Value, cmd: &str) -> Result<(NodeId, usize, Sub
}
let path = d.node(id).and_then(|n| n.path_data()).ok_or_else(|| bad(cmd, "`id` is not a path"))?;
let sp = path.subpaths.get(si).ok_or_else(|| bad(cmd, "no such subpath"))?;
if sp.closed || sp.anchors.len() < 2 {
return Err(bad(cmd, "the subpath has no ends to extend (it is closed or a single point)"));
}
Ok((id, si, sp.clone()))
}
/// Put `new` in place of the subpath `old` (from [`named_subpath`]) as one step labelled `label`
/// (none when it is the same); the path's direct selection goes.
fn set_subpath(s: &mut Session, label: &str, (id, si, old): (NodeId, usize, SubPath), new: SubPath) -> Result<()> {
if new == old {
return Ok(());
}
s.edit(label, |d, sel| {
let mut np = d.node(id).and_then(|n| n.path_data()).cloned().ok_or(EngineError::NoNode(id))?;
if let Some(slot) = np.subpaths.get_mut(si) {
*slot = new;
}
sel.anchors.remove(&id);
sel.segments.remove(&id);
set_path(d, id, np);
Ok(())
})
}
fn extend(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.pathtools.extend";
let (id, si, sp) = open_subpath(s, p, C)?;
let (id, si, sp) = named_subpath(s, p, C)?;
if sp.closed || sp.anchors.len() < 2 {
return Err(bad(C, "the subpath has no ends to extend (it is closed or a single point)"));
}
let at_start = match text_param(p, "end", C)? {
Some("start") => true,
Some("end") => false,
@@ -328,22 +358,26 @@ fn extend(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "`length` must be a number of at most 1000000 pt either way"));
}
let new = pt::extend_open(&sp, at_start, length, mode).ok_or_else(|| bad(C, "the path is too short to shorten that much"))?;
if new != sp {
s.edit("Extend Path", |d, sel| {
let n = d.node(id).and_then(|n| n.path_data()).cloned().ok_or(EngineError::NoNode(id))?;
let mut np = n;
if let Some(slot) = np.subpaths.get_mut(si) {
*slot = new;
}
sel.anchors.remove(&id);
sel.segments.remove(&id);
set_path(d, id, np);
Ok(())
})?;
}
set_subpath(s, "Extend Path", (id, si, sp), new)?;
Ok(json!({ "id": id.0, "length": length }))
}
// ---------- Slide Anchor Along Path ----------
fn slide_anchor(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.pathtools.slideAnchor";
let (id, si, sp) = named_subpath(s, p, C)?;
let anchor = usize_opt(p, "anchor", C)?.ok_or_else(|| bad(C, "give `anchor`, the index of the anchor to slide"))?;
let t = number_or(p, "t", f64::NAN, C)?;
if t.is_nan() || t.abs() > pt::MAX_SLIDE {
return Err(bad(C, "`t` must be a number from -0.98 to 0.98"));
}
let (new, strayed) =
pt::slide_anchor(&sp, anchor, t).ok_or_else(|| bad(C, "that anchor can't slide: an end of an open path has nowhere to go"))?;
set_subpath(s, "Slide Anchor Along Path", (id, si, sp), new)?;
Ok(json!({ "id": id.0, "strayed": strayed }))
}
// ---------- Measure & Inspect ----------
fn polar(h: Option<(f64, f64)>) -> Value {
@@ -0,0 +1,144 @@
//! The Corner Tool's commands: the app's Live Corners ([`LiveCorners`]) cut, resized and removed on
//! chosen anchors, each edit one undo step.
use std::collections::BTreeSet;
use serde_json::{Value, json};
use vectorcraft_doc::{LiveCorners, NodeId, NodeKind};
use vectorcraft_geom::shapes::CornerKind;
use vectorcraft_tools::addons::corner_tool::MAX_RADIUS;
use super::super::super::object::recut;
use super::super::super::{CommandSpec, bad, checked_id, checked_ids_or_selection, cmd, has_doc, number_or};
use super::{check_size, paths_under};
use crate::{Result, Session};
pub(super) fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"addon.pathtools.cornerSet",
"Set Corners",
[],
None,
"{ids?: [id…] (the selection), anchors?: [index…] (of each path's outline with no corners cut; else its direct-selected anchors, or all of them), items?: [{id, anchors?}…] (instead of ids: each path with its own anchors), radius: pt 0..1000000 (drawn no larger than fits), kind?: \"round\"|\"invertedRound\"|\"chamfer\" (each corner's own)} cut live corners at the anchors that can have one, as one undo step → {ids, corners (how many)}",
has_doc,
corner_set
),
cmd!(
"addon.pathtools.cornerRemove",
"Remove Corners",
[],
None,
"{ids?: [id…] (the selection), anchors?: [index…] (of each path's outline with no corners cut; else its direct-selected anchors, or all of them), items?: [{id, anchors?}…] (instead of ids: each path with its own anchors)} make those live corners sharp again, as one undo step → {ids, corners (how many)}",
has_doc,
corner_remove
),
]
}
/// The `anchors` of `p`.
fn anchors_param(p: &Value, cmd: &str) -> Result<Option<BTreeSet<usize>>> {
let Some(v) = p.get("anchors").filter(|v| !v.is_null()) else { return Ok(None) };
let list = v.as_array().ok_or_else(|| bad(cmd, format!("`anchors` must list anchor indices (0, 1, 2…), not {v}")))?;
list.iter()
.map(|k| {
k.as_u64()
.and_then(|k| usize::try_from(k).ok())
.ok_or_else(|| bad(cmd, format!("`anchors` must list anchor indices (0, 1, 2…), not {k}")))
})
.collect::<Result<_>>()
.map(Some)
}
/// The corners a request names: each path with the corners (anchor indices of its uncut outline).
fn named_corners(s: &Session, p: &Value, cmd: &str) -> Result<Vec<(NodeId, BTreeSet<usize>)>> {
let shared = anchors_param(p, cmd)?;
// Each path with its own `anchors`, or sharing the request's.
let mut own: Vec<Option<BTreeSet<usize>>> = vec![];
let ids: Vec<NodeId> = match p.get("items").filter(|v| !v.is_null()) {
None => paths_under(&s.doc()?.doc, &checked_ids_or_selection(s, p, cmd)?),
Some(Value::Array(items)) => {
let mut ids = vec![];
for it in items {
ids.push(checked_id(s, it.get("id").unwrap_or(&Value::Null), cmd)?);
own.push(anchors_param(it, cmd)?);
}
ids
}
Some(v) => return Err(bad(cmd, format!("`items` must be a list of {{id, anchors?}}, not {v}"))),
};
let st = s.doc()?;
let d = &st.doc;
check_size(ids.iter().filter_map(|id| d.node(*id)?.path_data()), cmd)?;
let editable = d.editable();
let mut out = vec![];
for (i, id) in ids.iter().enumerate() {
let Some(lc) = d.node(*id).filter(|_| editable(*id)).and_then(LiveCorners::of) else { continue };
let corners = match own.get(i).and_then(Option::as_ref).or(shared.as_ref()) {
Some(given) => {
let n = lc.base.anchor_count();
if let Some(past) = given.iter().find(|k| **k >= n) {
return Err(bad(cmd, format!("object {id} has {n} anchors, so no anchor {past}")));
}
given.iter().copied().filter(|k| lc.corner(*k).is_some()).collect()
}
None => st.selection.partial(*id).filter(|a| !a.is_empty()).map_or_else(|| lc.all(), |a| lc.corners_of(a)),
};
if !corners.is_empty() {
out.push((*id, corners));
}
}
if out.is_empty() {
return Err(bad(cmd, "choose anchors where two sides meet at an angle: only those take a corner"));
}
Ok(out)
}
/// Cut `radius` and `kind` on `corners` as one step (none when they have them already).
fn edit_corners(s: &mut Session, label: &str, corners: Vec<(NodeId, BTreeSet<usize>)>, radius: f64, kind: Option<CornerKind>) -> Result<Value> {
let d = &s.doc()?.doc;
let same = corners.iter().all(|(id, picked)| {
d.node(*id)
.and_then(LiveCorners::of)
.is_some_and(|c| picked.iter().all(|k| (c.drawn_radius(*k) - radius).abs() < 1e-9 && kind.is_none_or(|kd| c.kind(*k) == kd)))
});
let out = json!({
"ids": corners.iter().map(|(id, _)| id.0).collect::<Vec<_>>(),
"corners": corners.iter().map(|(_, c)| c.len()).sum::<usize>(),
});
if !same {
s.edit(label, |d, sel| {
for (id, picked) in corners {
let Some(NodeKind::Path { path, live, .. }) = d.node_mut(id).map(|n| &mut n.kind) else { continue };
// A cut changes the anchors, which is all a segment selection goes by.
sel.segments.remove(&id);
recut(path, live, sel.anchors.get_mut(&id), &picked, Some(radius), kind);
}
Ok(())
})?;
}
Ok(out)
}
fn corner_set(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.pathtools.cornerSet";
let radius = number_or(p, "radius", f64::NAN, C)?;
if !(0.0..=MAX_RADIUS).contains(&radius) {
return Err(bad(C, "`radius` must be from 0 to 1000000 pt"));
}
let kind = p
.get("kind")
.filter(|v| !v.is_null())
.map(|v| {
serde_json::from_value::<CornerKind>(v.clone())
.map_err(|_| bad(C, format!("`kind` must be \"round\", \"invertedRound\" or \"chamfer\", not {v}")))
})
.transpose()?;
let corners = named_corners(s, p, C)?;
edit_corners(s, "Set Corners", corners, radius, kind)
}
fn corner_remove(s: &mut Session, p: &Value) -> Result<Value> {
let corners = named_corners(s, p, "addon.pathtools.cornerRemove")?;
edit_corners(s, "Remove Corners", corners, 0.0, Some(CornerKind::Round))
}
+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));
}
+1
View File
@@ -35,4 +35,5 @@ pub(crate) use arrange::group_nodes;
pub(crate) use bounds::bounds_rewrite;
pub use commands::specs;
pub(super) use compound::{as_clipping_path, make_clipping_path, release_clip};
pub(super) use shape::recut;
pub(crate) use transform::{apply_transform, preview_copies, scaling, transform_node, transform_patterns, transforms_objects};
+24 -9
View File
@@ -1,3 +1,6 @@
use vectorcraft_doc::AnchorRef;
use vectorcraft_geom::PathData;
use super::spans::Span;
use super::{
BTreeSet, CornerKind, EngineError, LiveCorners, LiveShape, NodeId, NodeKind, Result, Session, Value, bad, check_numbers, check_switches,
@@ -249,17 +252,29 @@ pub(super) fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
Some(k) => k.clone(),
None => c.picked(partial.as_deref()),
};
let (selected, layout) = (partial.as_deref().map(|a| c.corners_of(a)), c.sources().to_vec());
set_corners(path, live, &picked, radius, kind);
// Direct-Selected corners stay selected as they gain or lose anchors.
if let (Some(anchors), Some(selected)) = (partial, selected)
&& let Some(c) = LiveCorners::new(path, live.as_deref())
&& c.sources() != layout
{
*anchors = c.anchors_of(&selected);
}
recut(path, live, partial, &picked, radius, kind);
}
Ok(())
})?;
ok()
}
/// [`set_corners`] with the Direct-Selected corners (`partial`, the path's selected anchors)
/// staying selected as they gain or lose anchors.
pub(in crate::cmd) fn recut(
path: &mut PathData,
live: &mut Option<Box<LiveShape>>,
partial: Option<&mut BTreeSet<AnchorRef>>,
picked: &BTreeSet<usize>,
radius: Option<f64>,
kind: Option<CornerKind>,
) {
let before = LiveCorners::new(path, live.as_deref()).map(|c| (partial.as_deref().map(|a| c.corners_of(a)), c.sources().to_vec()));
set_corners(path, live, picked, radius, kind);
if let (Some(anchors), Some((Some(selected), layout))) = (partial, before)
&& let Some(c) = LiveCorners::new(path, live.as_deref())
&& c.sources() != layout
{
*anchors = c.anchors_of(&selected);
}
}