mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 05:37:23 -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:
@@ -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))
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user