Files
vectorcraft/crates/engine/src/cmd/addons/pathtools/tests.rs
T
dotCooCoo 187032fbb2 Path Precision Tools: Corner commands refuse unknown item keys
addon.pathtools.cornerSet and cornerRemove now refuse a key other than
id and anchors in an `items` member, with the standard unknown-parameter
error, instead of ignoring it. A misspelled `anchor` used to fall back
to the request's `anchors`, else the path's direct-selected anchors,
else every corner of the path. They also refuse `items` given together
with `ids`, which `items` replaces. Saved steps played with replay
still run as recorded.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-11-13 16:03:08 -08:00

1278 lines
62 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Path Precision Tools' commands: each one undo step, bad params refused, and the tools' gestures
//! through the session.
use std::collections::BTreeSet;
use serde_json::{Value, json};
use vectorcraft_doc::{LiveCorners, LiveShape, NodeId};
use vectorcraft_geom::shapes::CornerKind;
use vectorcraft_geom::{AnchorKind, PathData, Point, SubPath};
use vectorcraft_tools::{PointerEvent, PointerKind};
use crate::{Session, ViewInfo};
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
s
}
fn id_of(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn line(s: &mut Session, pts: &[(f64, f64)], closed: bool) -> NodeId {
let anchors: Vec<Value> = pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect();
id_of(&s.execute("path.create", &json!({"anchors": anchors, "closed": closed})).unwrap())
}
/// A circle of 16 anchors about (100, 100), radius 50, selected.
fn circle(s: &mut Session) -> NodeId {
let e = id_of(&s.execute("shape.ellipse", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap());
s.execute("object.path.addAnchorPoints", &json!({})).unwrap();
s.execute("object.path.addAnchorPoints", &json!({})).unwrap();
e
}
fn path(s: &Session, id: NodeId) -> PathData {
s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone()
}
fn select(s: &mut Session, ids: &[NodeId]) {
s.select(|_, sel| sel.set(ids.iter().copied())).unwrap();
}
/// Direct-select anchors `anchors` (of subpath 0) of `id`.
fn select_anchors(s: &mut Session, id: NodeId, anchors: &[usize]) {
s.select(|_, sel| {
sel.set([id]);
sel.anchors.insert(id, anchors.iter().map(|a| (0, *a)).collect::<BTreeSet<_>>());
})
.unwrap();
}
fn steps(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
/// Each of `bad` is refused and leaves the document as it was.
fn refuses(s: &mut Session, cmd: &str, bad: &[Value]) {
let (before, n) = (s.doc().unwrap().doc.clone(), steps(s));
for p in bad {
assert!(s.execute(cmd, p).is_err(), "{cmd} {p}");
}
assert_eq!(steps(s), n, "{cmd}");
assert!(*s.doc().unwrap().doc == *before, "{cmd} changed the document");
}
#[test]
fn the_brush_removes_anchors_under_it_keeping_the_shape() {
let mut s = session();
let e = circle(&mut s);
assert_eq!(path(&s, e).anchor_count(), 16);
let bounds = path(&s, e).bounds().unwrap();
let n = steps(&s);
let r = s.execute("addon.pathtools.erase", &json!({"points": [[150, 40], [150, 160]], "diameter": 60, "tolerance": 100})).unwrap();
let left = path(&s, e).anchor_count();
assert!(left < 16 && left > 8, "{left}");
assert_eq!(r["removed"], json!(16 - left));
assert_eq!(steps(&s), n + 1, "one undo step");
let b = path(&s, e).bounds().unwrap();
assert!((b.x1 - bounds.x1).abs() < 2.5 && (b.y0 - bounds.y0).abs() < 0.01, "{b:?} vs {bounds:?}");
// The anchors away from the brush stay: those on the left of the circle.
assert!(path(&s, e).anchors().filter(|(_, _, a)| a.p.x < 100.0).count() >= 7);
// A plain delete takes every anchor under it, whatever the shape.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("addon.pathtools.erase", &json!({"points": [[150, 40], [150, 160]], "diameter": 60, "plain": true})).unwrap();
assert!(path(&s, e).anchors().all(|(_, _, a)| a.p.x < 121.0));
// Nothing under the brush: no step.
let n = steps(&s);
s.execute("addon.pathtools.erase", &json!({"points": [[390, 290]], "diameter": 5})).unwrap();
assert_eq!(steps(&s), n);
refuses(
&mut s,
"addon.pathtools.erase",
&[
json!({}),
json!({"points": "x"}),
json!({"points": [[1, "y"]]}),
json!({"points": [[100, 100]], "diameter": 0}),
json!({"points": [[100, 100]], "diameter": 3000}),
json!({"points": [[100, 100]], "tolerance": 0}),
json!({"points": [[100, 100]], "plain": "yes"}),
],
);
}
#[test]
fn the_brush_tool_is_one_undo_step_per_stroke() {
let mut s = session();
let e = circle(&mut s);
let view = ViewInfo { smart_guides: false, ..ViewInfo::default() };
s.select_tool("anchorEraserBrush", view).unwrap();
assert_eq!(s.tool_id(), "anchorEraserBrush");
s.set_tool_option("size", &json!(60));
s.set_tool_option("tolerance", &json!(100));
let n = steps(&s);
for (k, y) in
[(PointerKind::Down, 40.0), (PointerKind::Drag, 80.0), (PointerKind::Drag, 120.0), (PointerKind::Drag, 160.0), (PointerKind::Up, 160.0)]
{
s.pointer(&PointerEvent::new(k, 150.0, y), view).unwrap();
}
assert_eq!(steps(&s), n + 1);
assert!(path(&s, e).anchor_count() < 16);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(&s, e).anchor_count(), 16);
}
#[test]
fn extend_lengthens_and_shortens_an_end() {
let mut s = session();
let l = line(&mut s, &[(0.0, 100.0), (100.0, 100.0)], false);
let n = steps(&s);
s.execute("addon.pathtools.extend", &json!({"id": l.0, "end": "end", "length": 50})).unwrap();
assert_eq!(path(&s, l).subpaths[0].anchors.last().unwrap().p, Point::new(150.0, 100.0));
assert_eq!(steps(&s), n + 1);
s.execute("addon.pathtools.extend", &json!({"id": l.0, "end": "start", "to": [-30, 120], "mode": "bezier"})).unwrap();
assert!((path(&s, l).subpaths[0].anchors[0].p.x + 30.0).abs() < 1e-6);
let r = s.execute("addon.pathtools.extend", &json!({"id": l.0, "end": "end", "length": -60})).unwrap();
assert_eq!(r["length"], json!(-60.0));
assert!((path(&s, l).length() - 120.0).abs() < 1e-3, "{}", path(&s, l).length());
assert_eq!(steps(&s), n + 3);
let sq = id_of(&s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap());
refuses(
&mut s,
"addon.pathtools.extend",
&[
json!({"id": l.0, "end": "end"}),
json!({"id": l.0, "end": "middle", "length": 5}),
json!({"id": l.0, "end": "end", "length": 5, "to": [1, 1]}),
json!({"id": l.0, "end": "end", "length": 5, "mode": "spiral"}),
json!({"id": l.0, "end": "end", "length": -1000}),
json!({"id": l.0, "end": "end", "length": 1e9}),
json!({"id": l.0, "end": "end", "to": [1]}),
json!({"id": l.0, "subpath": 3, "end": "end", "length": 5}),
json!({"id": sq.0, "end": "end", "length": 5}),
json!({"id": 99999, "end": "end", "length": 5}),
],
);
}
#[test]
fn inspect_reads_the_path_under_a_point() {
let mut s = session();
let r = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 50})).unwrap());
let v = s.execute("addon.pathtools.inspect", &json!({"id": r.0, "x": 99, "y": 1})).unwrap();
assert_eq!((v["closed"].clone(), v["anchors"].clone()), (json!(true), json!(4)));
assert!((v["length"].as_f64().unwrap() - 300.0).abs() < 1e-6 && (v["area"].as_f64().unwrap() - 5000.0).abs() < 1e-6);
assert!((v["anchor"]["angle"].as_f64().unwrap() - 90.0).abs() < 1e-9);
assert!(v["radius"].is_null());
assert!(s.execute("addon.pathtools.inspect", &json!({"id": r.0, "x": 1})).is_err());
assert!(s.execute("addon.pathtools.inspect", &json!({"id": r.0, "x": 1, "y": 1, "tolerance": -1})).is_err());
}
#[test]
fn smooth_corner_and_equalize_edit_the_selected_anchors_in_one_step_each() {
let mut s = session();
let r = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
select(&mut s, &[r]);
let n = steps(&s);
assert_eq!(s.execute("addon.pathtools.smooth", &json!({})).unwrap()["anchors"], json!(4));
assert!(path(&s, r).anchors().all(|(_, _, a)| a.kind == AnchorKind::Smooth && (a.h_out - a.p).hypot() > 39.0));
assert_eq!(steps(&s), n + 1);
// Only the direct-selected anchor.
select_anchors(&mut s, r, &[0]);
s.execute("addon.pathtools.corner", &json!({})).unwrap();
let p = path(&s, r);
assert!(!p.subpaths[0].anchors[0].has_out() && p.subpaths[0].anchors[1].has_out());
assert_eq!(steps(&s), n + 2);
// Equalise: one handle made to match the other.
select_anchors(&mut s, r, &[1]);
s.execute("addon.pathtools.setAnchor", &json!({"outLength": 10})).unwrap();
let a = path(&s, r).subpaths[0].anchors[1];
assert!(((a.h_out - a.p).hypot() - 10.0).abs() < 1e-9 && (a.h_in - a.p).hypot() > 39.0);
s.execute("addon.pathtools.equalize", &json!({})).unwrap();
let a = path(&s, r).subpaths[0].anchors[1];
assert!(((a.h_out - a.p).hypot() - (a.h_in - a.p).hypot()).abs() < 1e-9);
// A selection with nothing to change makes no step.
let n = steps(&s);
assert_eq!(s.execute("addon.pathtools.equalize", &json!({})).unwrap()["anchors"], json!(0));
assert_eq!(steps(&s), n);
refuses(&mut s, "addon.pathtools.smooth", &[json!({"ratio": 0.9}), json!({"ratio": "x"})]);
refuses(&mut s, "addon.pathtools.corner", &[json!({"retract": 1})]);
}
#[test]
fn set_anchor_moves_anchors_and_sets_handles() {
let mut s = session();
let l = line(&mut s, &[(0.0, 0.0), (50.0, 0.0), (100.0, 0.0)], false);
select_anchors(&mut s, l, &[1]);
let n = steps(&s);
s.execute("addon.pathtools.setAnchor", &json!({"x": 60, "y": 20, "outLength": 30, "outAngle": 90})).unwrap();
let a = path(&s, l).subpaths[0].anchors[1];
assert_eq!(a.p, Point::new(60.0, 20.0));
assert!(a.h_out.distance(Point::new(60.0, -10.0)) < 1e-9, "{a:?}");
assert_eq!(steps(&s), n + 1);
refuses(&mut s, "addon.pathtools.setAnchor", &[json!({}), json!({"x": "1"}), json!({"inLength": -2}), json!({"y": f64::MAX})]);
}
#[test]
fn remove_keep_shape_takes_the_direct_selected_anchors() {
let mut s = session();
let e = circle(&mut s);
// Selected whole: nothing to say which anchors go.
refuses(&mut s, "addon.pathtools.removeKeepShape", &[json!({})]);
select_anchors(&mut s, e, &[1, 5]);
let n = steps(&s);
assert_eq!(s.execute("addon.pathtools.removeKeepShape", &json!({})).unwrap()["removed"], json!(2));
assert_eq!(path(&s, e).anchor_count(), 14);
assert_eq!(steps(&s), n + 1);
}
#[test]
fn add_points_splits_selected_segments_evenly() {
let mut s = session();
let l = line(&mut s, &[(0.0, 0.0), (100.0, 0.0)], false);
select(&mut s, &[l]);
let n = steps(&s);
assert_eq!(s.execute("addon.pathtools.addPoints", &json!({"count": 3})).unwrap()["added"], json!(3));
let xs: Vec<f64> = path(&s, l).anchors().map(|(_, _, a)| a.p.x).collect();
assert!(xs.iter().zip([0.0, 25.0, 50.0, 75.0, 100.0]).all(|(x, w)| (x - w).abs() < 1e-3), "{xs:?}");
assert_eq!(steps(&s), n + 1);
refuses(&mut s, "addon.pathtools.addPoints", &[json!({"count": 0}), json!({"count": 101}), json!({"count": 1.5})]);
// An anchor alone selects no segment.
select_anchors(&mut s, l, &[1]);
refuses(&mut s, "addon.pathtools.addPoints", &[json!({})]);
}
#[test]
fn close_joins_open_ends() {
let mut s = session();
let l = line(&mut s, &[(0.0, 0.0), (100.0, 0.0), (50.0, 80.0)], false);
select(&mut s, &[l]);
let n = steps(&s);
assert_eq!(s.execute("addon.pathtools.close", &json!({"mode": "smooth"})).unwrap()["closed"], json!(1));
assert!(path(&s, l).subpaths[0].closed);
assert_eq!(steps(&s), n + 1);
refuses(&mut s, "addon.pathtools.close", &[json!({}), json!({"mode": "round"})]);
}
#[test]
fn crossings_add_anchors_or_cut_in_one_step() {
let mut s = session();
let a = line(&mut s, &[(0.0, 0.0), (100.0, 100.0)], false);
let b = line(&mut s, &[(0.0, 100.0), (100.0, 0.0)], false);
select(&mut s, &[a, b]);
let n = steps(&s);
assert_eq!(s.execute("addon.pathtools.crossings", &json!({})).unwrap()["crossings"], json!(2));
assert_eq!((path(&s, a).anchor_count(), path(&s, b).anchor_count()), (3, 3));
assert!(path(&s, a).subpaths[0].anchors[1].p.distance(Point::new(50.0, 50.0)) < 1e-6);
assert_eq!(steps(&s), n + 1);
s.execute("edit.undo", &json!({})).unwrap();
let r = s.execute("addon.pathtools.crossings", &json!({"cut": true})).unwrap();
assert_eq!(r["ids"].as_array().unwrap().len(), 4, "{r}");
assert_eq!(steps(&s), n + 1);
refuses(&mut s, "addon.pathtools.crossings", &[json!({"cut": "yes"}), json!({"ids": [123456]})]);
}
/// A selected path with an open polyline subpath through each of `subs`; an empty list makes an
/// empty subpath.
fn compound(s: &mut Session, subs: &[&[(f64, f64)]]) -> NodeId {
let id = line(s, &[(0.0, 0.0), (1.0, 0.0)], false);
let pd =
PathData::new(subs.iter().map(|pts| SubPath::polyline(&pts.iter().map(|&(x, y)| Point::new(x, y)).collect::<Vec<_>>(), false)).collect());
s.edit("Test", |d, _| {
super::set_path(d, id, pd);
Ok(())
})
.unwrap();
select(s, &[id]);
id
}
/// The anchor points of each subpath of `pd`, rounded to a millionth.
fn points(pd: &PathData) -> Vec<Vec<(f64, f64)>> {
let r = |v: f64| (v * 1e6).round() / 1e6;
pd.subpaths.iter().map(|sp| sp.anchors.iter().map(|a| (r(a.p.x), r(a.p.y))).collect()).collect()
}
/// A path's subpaths: A (0), an empty one (1), C (2) and D (3, the last).
const SUBS: [&[(f64, f64)]; 4] =
[&[(0.0, 0.0), (100.0, 0.0), (200.0, 0.0)], &[], &[(0.0, 50.0), (100.0, 50.0)], &[(0.0, 100.0), (100.0, 100.0), (200.0, 100.0)]];
/// Direct-select `segments` of `id` and the anchors `anchors`.
fn select_segments(s: &mut Session, id: NodeId, anchors: &[(usize, usize)], segments: &[(usize, usize)]) {
s.select(|_, sel| {
sel.set([id]);
sel.anchors.insert(id, anchors.iter().copied().collect());
sel.segments.insert(id, segments.iter().copied().collect());
})
.unwrap();
}
#[test]
fn picked_segments_reads_only_its_own_subpath() {
let set: BTreeSet<(usize, usize)> = [(0, 0), (0, 2), (1, 1), (3, 0), (3, 1)].into();
let sp = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(1.0, 0.0), Point::new(2.0, 0.0), Point::new(3.0, 0.0)], false);
let all: BTreeSet<usize> = (0..4).collect();
let picked = |si| super::picked_segments(Some(&set), si, &sp, &all).into_iter().collect::<Vec<_>>();
assert_eq!([picked(0), picked(1), picked(2), picked(3), picked(4)], [vec![0, 2], vec![1], vec![], vec![0, 1], vec![]]);
// Without direct-selected segments, the segments between two selected anchors.
let anchors: BTreeSet<usize> = [0, 1, 3].into();
for none in [None, Some(&BTreeSet::new())] {
assert_eq!(super::picked_segments(none, 0, &sp, &anchors).into_iter().collect::<Vec<_>>(), [0]);
}
}
#[test]
fn places_by_subpath_keeps_the_order_and_leaves_out_missing_subpaths() {
let on = [(3, 1, 0.75), (0, 0, 0.5), (3, 1, 0.25), (7, 0, 0.5), (0, 1, 0.1), (3, 0, 0.5), (0, 0, 0.2)];
let by_sub = super::places_by_subpath(&on, 4);
assert_eq!(by_sub, [vec![(0, 0.5), (1, 0.1), (0, 0.2)], vec![], vec![], vec![(1, 0.75), (1, 0.25), (0, 0.5)]]);
// The same places as filtering the whole list for each subpath.
for (si, here) in by_sub.iter().enumerate() {
let filtered: Vec<(usize, f64)> = on.iter().filter(|(s, ..)| *s == si).map(|&(_, seg, t)| (seg, t)).collect();
assert_eq!(*here, filtered);
}
assert!(super::places_by_subpath(&on, 0).is_empty());
}
#[test]
fn add_points_adds_to_the_selected_segments_of_each_subpath() {
let mut s = session();
let c = compound(&mut s, &SUBS);
let before = path(&s, c);
// The first segment of A and the second of D; a segment of C without its anchors selected.
select_segments(&mut s, c, &[(0, 0), (0, 1), (0, 2), (3, 0), (3, 1), (3, 2)], &[(0, 0), (2, 0), (3, 1)]);
let n = steps(&s);
assert_eq!(s.execute("addon.pathtools.addPoints", &json!({})).unwrap()["added"], json!(2));
let want = vec![
vec![(0.0, 0.0), (50.0, 0.0), (100.0, 0.0), (200.0, 0.0)],
vec![],
vec![(0.0, 50.0), (100.0, 50.0)],
vec![(0.0, 100.0), (100.0, 100.0), (150.0, 100.0), (200.0, 100.0)],
];
let after = path(&s, c);
assert_eq!(points(&after), want);
assert_eq!(steps(&s), n + 1);
let st = s.doc().unwrap();
assert_eq!((st.selection.objects.clone(), st.selection.anchors.get(&c), st.selection.segments.get(&c)), (vec![c], None, None));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(&s, c), before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(path(&s, c), after);
// Without selected segments: those between two selected anchors, here on the last subpath only.
for segments in [None, Some(BTreeSet::new())] {
s.execute("edit.undo", &json!({})).unwrap();
s.select(|_, sel| {
sel.set([c]);
sel.anchors.insert(c, [(0, 0), (3, 1), (3, 2)].into());
sel.segments.remove(&c);
if let Some(set) = segments.clone() {
sel.segments.insert(c, set);
}
})
.unwrap();
assert_eq!(s.execute("addon.pathtools.addPoints", &json!({})).unwrap()["added"], json!(1));
assert_eq!(
points(&path(&s, c)),
[points(&before)[..3].to_vec(), vec![vec![(0.0, 100.0), (100.0, 100.0), (150.0, 100.0), (200.0, 100.0)]]].concat()
);
}
}
#[test]
fn crossings_add_anchors_on_each_subpath_of_a_compound_path() {
let mut s = session();
// A and the last subpath cross both lines; the empty subpath and C, which ends short of them, do not.
let c = compound(&mut s, &[&[(0.0, 50.0), (200.0, 50.0)], &[], &[(0.0, 150.0), (40.0, 150.0)], &[(0.0, 250.0), (400.0, 250.0)]]);
let q = line(&mut s, &[(100.0, 0.0), (100.0, 300.0)], false);
let r = line(&mut s, &[(150.0, 0.0), (150.0, 300.0)], false);
select(&mut s, &[c, q, r]);
let n = steps(&s);
assert_eq!(s.execute("addon.pathtools.crossings", &json!({})).unwrap()["crossings"], json!(8));
let want = vec![
vec![(0.0, 50.0), (100.0, 50.0), (150.0, 50.0), (200.0, 50.0)],
vec![],
vec![(0.0, 150.0), (40.0, 150.0)],
vec![(0.0, 250.0), (100.0, 250.0), (150.0, 250.0), (400.0, 250.0)],
];
assert_eq!(points(&path(&s, c)), want);
assert_eq!(points(&path(&s, q)), [vec![(100.0, 0.0), (100.0, 50.0), (100.0, 250.0), (100.0, 300.0)]]);
assert_eq!(steps(&s), n + 1);
s.execute("edit.undo", &json!({})).unwrap();
// Cut: the compound path keeps its pieces as subpaths, in order; each line becomes three paths.
let out = ids(&s.execute("addon.pathtools.crossings", &json!({"cut": true})).unwrap());
assert_eq!(out.len(), 7);
assert_eq!(out[0], c);
let pieces = vec![
vec![(0.0, 50.0), (100.0, 50.0)],
vec![(100.0, 50.0), (150.0, 50.0)],
vec![(150.0, 50.0), (200.0, 50.0)],
vec![],
vec![(0.0, 150.0), (40.0, 150.0)],
vec![(0.0, 250.0), (100.0, 250.0)],
vec![(100.0, 250.0), (150.0, 250.0)],
vec![(150.0, 250.0), (400.0, 250.0)],
];
assert_eq!(points(&path(&s, c)), pieces);
let qs: Vec<Vec<Vec<(f64, f64)>>> = out[1..4].iter().map(|id| points(&path(&s, *id))).collect();
assert_eq!(qs, [[vec![(100.0, 0.0), (100.0, 50.0)]], [vec![(100.0, 50.0), (100.0, 250.0)]], [vec![(100.0, 250.0), (100.0, 300.0)]]]);
assert_eq!(steps(&s), n + 1);
}
// ---------- Parametric Shape Tool ----------
/// The parametric shape object `id` is now, as the tool reads it.
fn shape_of(s: &Session, id: NodeId) -> Option<vectorcraft_pathops::addons::pathtools::shapes::ParametricShape> {
vectorcraft_tools::addons::parametric_shape::read(s.doc().unwrap().doc.node(id).unwrap())
}
fn star(s: &mut Session) -> NodeId {
let r = s.execute("addon.pathtools.shape.create", &json!({"kind": "star", "center": [200, 150], "width": 100, "height": 100})).unwrap();
id_of(&r)
}
#[test]
fn create_draws_a_selected_parametric_shape_in_one_step() {
let mut s = session();
let n = steps(&s);
let r = s.execute("addon.pathtools.shape.create", &json!({"kind": "gear", "center": [200, 150], "count": 10, "a": 0.3, "radius": 2})).unwrap();
let id = id_of(&r);
assert_eq!(steps(&s), n + 1, "one undo step");
assert_eq!((r["shape"]["kind"].clone(), r["shape"]["count"].clone(), r["shape"]["width"].clone()), (json!("gear"), json!(10), json!(100.0)));
assert_eq!(s.doc().unwrap().selection.objects, [id]);
let g = shape_of(&s, id).unwrap();
assert_eq!((g.count, g.a, g.radius), (10, 0.3, 2.0));
assert!(path(&s, id).anchor_count() > 40, "40 tooth corners, rounded");
// Every kind, with its defaults.
for kind in ["rectangle", "ellipse", "polygon", "star", "ring", "arrow", "cross", "heart", "gear", "bubble"] {
let id = id_of(&s.execute("addon.pathtools.shape.create", &json!({"kind": kind, "center": [50, 50]})).unwrap());
assert_eq!(shape_of(&s, id).map(|s| s.kind.id()), Some(kind));
}
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(steps(&s), n + 10);
}
#[test]
fn set_changes_the_numbers_in_one_step_and_nothing_without_a_change() {
let mut s = session();
let id = star(&mut s);
assert_eq!(path(&s, id).anchor_count(), 10);
let n = steps(&s);
let r = s.execute("addon.pathtools.shape.set", &json!({"id": id.0, "count": 8, "a": 0.3, "rotation": 15})).unwrap();
assert_eq!((r["shape"]["count"].clone(), r["shape"]["rotation"].clone()), (json!(8), json!(15.0)));
assert_eq!(path(&s, id).anchor_count(), 16);
assert_eq!(steps(&s), n + 1);
// The same numbers again, or none: no step.
s.execute("addon.pathtools.shape.set", &json!({"id": id.0, "count": 8})).unwrap();
s.execute("addon.pathtools.shape.set", &json!({"id": id.0})).unwrap();
assert_eq!(steps(&s), n + 1);
// Another kind takes its own defaults.
let r = s.execute("addon.pathtools.shape.set", &json!({"id": id.0, "kind": "arrow"})).unwrap();
assert_eq!((r["shape"]["a"].clone(), r["shape"]["rotation"].clone()), (json!(0.4), json!(15.0)));
assert_eq!(path(&s, id).anchor_count(), 7);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(&s, id).anchor_count(), 16);
}
#[test]
fn bad_shape_params_are_refused() {
let mut s = session();
let id = star(&mut s);
let rect = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
let c = json!([10, 10]);
refuses(
&mut s,
"addon.pathtools.shape.create",
&[
json!({}),
json!({"center": null}),
json!({"center": [1]}),
json!({"center": "here"}),
json!({"center": [1e300, 0]}),
json!({"center": c, "kind": "cloud"}),
json!({"center": c, "kind": 3}),
json!({"center": c, "count": 2}),
json!({"center": c, "count": 1001}),
json!({"center": c, "count": 4.5}),
json!({"center": c, "width": 0}),
json!({"center": c, "height": 16385}),
json!({"center": c, "width": "40"}),
json!({"center": c, "kind": "star", "a": 2}),
json!({"center": c, "kind": "bubble", "b": -1.5}),
json!({"center": c, "radius": -1}),
json!({"center": c, "rotation": "up"}),
],
);
refuses(
&mut s,
"addon.pathtools.shape.set",
&[json!({}), json!({"id": rect.0, "count": 6}), json!({"id": 999_999}), json!({"id": id.0, "count": 0}), json!({"id": id.0, "width": -5})],
);
s.execute("object.setProps", &json!({"ids": [id.0], "locked": true})).unwrap();
refuses(&mut s, "addon.pathtools.shape.set", &[json!({"id": id.0, "count": 6})]);
}
#[test]
fn an_anchor_edit_makes_it_a_plain_path_and_transforms_keep_it_editable() {
let mut s = session();
let id = star(&mut s);
// Moved, turned and scaled with the ordinary commands: still the shape, at its new place.
s.execute("object.move", &json!({"dx": 20, "dy": -10})).unwrap();
assert!(shape_of(&s, id).is_some_and(|s| s.center.distance(Point::new(220.0, 140.0)) < 1e-6));
s.execute("object.rotate", &json!({"angle": 30, "origin": [220, 140]})).unwrap();
let r = shape_of(&s, id).unwrap();
assert!((r.rotation + 30.0).abs() < 1e-6, "30° counter-clockwise is −30 here: {r:?}");
s.execute("object.scale", &json!({"sx": 200, "origin": [220, 140]})).unwrap();
let r = shape_of(&s, id).unwrap();
assert!((r.width - 200.0).abs() < 1e-6 && r.center.distance(Point::new(220.0, 140.0)) < 1e-6, "{r:?}");
let n = steps(&s);
s.execute("addon.pathtools.shape.set", &json!({"id": id.0, "count": 6})).unwrap();
assert_eq!((path(&s, id).anchor_count(), steps(&s)), (12, n + 1));
assert!((shape_of(&s, id).unwrap().rotation + 30.0).abs() < 1e-6, "the turn stays");
// Stretched across its own axes (it is turned): skewed, so no longer.
s.execute("object.scale", &json!({"sx": 150, "sy": 100, "origin": [220, 140]})).unwrap();
assert_eq!(shape_of(&s, id), None);
s.execute("edit.undo", &json!({})).unwrap();
assert!(shape_of(&s, id).is_some(), "undo brings it back");
// An anchor moved by hand: a plain path, which `set` refuses.
select_anchors(&mut s, id, &[2]);
s.execute("addon.pathtools.setAnchor", &json!({"x": 0})).unwrap();
assert_eq!(shape_of(&s, id), None);
refuses(&mut s, "addon.pathtools.shape.set", &[json!({"id": id.0, "count": 7})]);
}
#[test]
fn convert_makes_plain_shapes_parametric_in_one_step() {
let mut s = session();
let r = id_of(&s.execute("shape.rectangle", &json!({"x": 10, "y": 20, "width": 100, "height": 50})).unwrap());
let p = id_of(&s.execute("shape.polygon", &json!({"cx": 200, "cy": 150, "radius": 40, "sides": 7})).unwrap());
let st = id_of(&s.execute("shape.star", &json!({"cx": 300, "cy": 150, "radius1": 40, "radius2": 15})).unwrap());
select(&mut s, &[r, p, st]);
let n = steps(&s);
let out = s.execute("addon.pathtools.shape.convert", &json!({})).unwrap();
assert_eq!(out["ids"], json!([r.0, p.0]));
assert_eq!(steps(&s), n + 1);
assert_eq!(shape_of(&s, r).map(|s| s.kind.id()), Some("rectangle"));
assert_eq!(shape_of(&s, p).map(|s| (s.kind.id(), s.count)), Some(("polygon", 7)));
assert_eq!(shape_of(&s, st), None);
assert_eq!(path(&s, r).bounds().map(|b| b.center()), Some(Point::new(60.0, 45.0)));
// Nothing left to convert.
select(&mut s, &[r, st]);
refuses(&mut s, "addon.pathtools.shape.convert", &[json!({}), json!({"ids": [st.0]}), json!({"ids": [123456]})]);
}
#[test]
fn the_shape_data_stays_out_of_svg_export() {
let mut s = session();
let id = star(&mut s);
s.execute("object.setProps", &json!({"ids": [id.0], "data": {"pivot": "1,2"}})).unwrap();
let key = vectorcraft_tools::addons::parametric_shape::KEY;
let node = s.doc().unwrap().doc.node(id).unwrap().clone();
assert!(node.extras().attrs.as_deref().is_some_and(|a| a.data.iter().any(|(k, _)| k == key)), "kept with the object");
assert!(shape_of(&s, id).is_some(), "setting other data keeps it a shape");
let svg = vectorcraft_svg::export(&s.doc().unwrap().doc, &Default::default());
assert!(svg.contains("data-pivot=\"1,2\""), "the object's own data still goes out: {svg}");
assert!(!svg.contains(key) && !svg.contains("fp&quot;") && !svg.contains("\"fp\""), "{svg}");
}
#[test]
fn the_tool_draws_one_step_per_drag_and_arrows_change_the_count() {
let mut s = session();
let view = ViewInfo { smart_guides: false, ..ViewInfo::default() };
s.select_tool("parametricShape", view).unwrap();
s.set_tool_option("kind", &json!("star"));
let n = steps(&s);
s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 150.0), view).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 200.0, 100.0), view).unwrap();
s.tool_key(vectorcraft_tools::ToolKey::Up, vectorcraft_tools::Mods::default(), view).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 200.0, 90.0), view).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 200.0, 90.0), view).unwrap();
assert_eq!(steps(&s), n + 1);
let id = *s.doc().unwrap().selection.objects.first().unwrap();
let shape = shape_of(&s, id).unwrap();
assert_eq!((shape.kind.id(), shape.count, shape.height, shape.rotation), ("star", 6, 120.0, 0.0));
// Its size handle (lower right of its box) drags into one more step.
let corner = shape.frame() * Point::new(shape.width / 2.0, shape.height / 2.0);
for (k, x) in [(PointerKind::Down, corner.x), (PointerKind::Drag, corner.x + 30.0), (PointerKind::Up, corner.x + 30.0)] {
s.pointer(&PointerEvent::new(k, x, corner.y), view).unwrap();
}
assert_eq!(steps(&s), n + 2);
assert!((shape_of(&s, id).unwrap().width - 180.0).abs() < 1e-9);
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 corner_commands_refuse_an_unknown_item_key() {
let mut s = session();
let id = square(&mut s);
let i = id.0;
let set = json!({"items": [{"id": i, "anchor": [0]}], "radius": 5});
let e = s.execute("addon.pathtools.cornerSet", &set).unwrap_err().to_string();
assert!(e.contains("unknown parameter `items[0].anchor`; did you mean `items[0].anchors`?"), "{e}");
let set_late = json!({"items": [{"id": i, "anchors": [0]}, {"id": i, "radius": 3}], "radius": 5});
let e = s.execute("addon.pathtools.cornerSet", &set_late).unwrap_err().to_string();
assert!(e.contains("unknown parameter `items[1].radius`; the command takes: items[1].id, items[1].anchors"), "{e}");
refuses(&mut s, "addon.pathtools.cornerSet", &[set, set_late]);
s.execute("addon.pathtools.cornerSet", &json!({"radius": 10})).unwrap();
let remove = json!({"items": [{"id": i, "anchor": [0]}]});
let e = s.execute("addon.pathtools.cornerRemove", &remove).unwrap_err().to_string();
assert!(e.contains("unknown parameter `items[0].anchor`; did you mean `items[0].anchors`?"), "{e}");
refuses(&mut s, "addon.pathtools.cornerRemove", &[remove]);
assert_eq!(drawn(&s, id, 0), 10.0);
}
#[test]
fn corner_items_take_their_documented_keys() {
let mut s = session();
let id = square(&mut s);
let i = id.0;
let r = s.execute("addon.pathtools.cornerSet", &json!({"items": [{"id": i, "anchors": [0]}], "radius": 5})).unwrap();
assert_eq!((r["corners"].clone(), drawn(&s, id, 0), drawn(&s, id, 1)), (json!(1), 5.0, 0.0));
// A member without `anchors` takes the request's.
s.execute("addon.pathtools.cornerSet", &json!({"items": [{"id": i}], "anchors": [2], "radius": 6})).unwrap();
assert_eq!(drawn(&s, id, 2), 6.0);
let r = s.execute("addon.pathtools.cornerRemove", &json!({"items": [{"id": i, "anchors": [0]}]})).unwrap();
assert_eq!((r["corners"].clone(), drawn(&s, id, 0), drawn(&s, id, 2)), (json!(1), 0.0, 6.0));
// A null `ids` beside `items` counts as not given.
s.execute("addon.pathtools.cornerRemove", &json!({"items": [{"id": i}], "ids": null})).unwrap();
assert_eq!(drawn(&s, id, 2), 0.0);
}
#[test]
fn corner_commands_refuse_items_with_ids() {
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);
let both = json!({"items": [{"id": id.0, "anchors": [0]}], "ids": [other.0], "radius": 5});
let e = s.execute("addon.pathtools.cornerSet", &both).unwrap_err().to_string();
assert!(e.contains("give `items` or `ids`, not both"), "{e}");
refuses(&mut s, "addon.pathtools.cornerSet", &[both]);
s.execute("addon.pathtools.cornerSet", &json!({"ids": [id.0], "radius": 5})).unwrap();
let both = json!({"items": [{"id": id.0}], "ids": [id.0]});
let e = s.execute("addon.pathtools.cornerRemove", &both).unwrap_err().to_string();
assert!(e.contains("give `items` or `ids`, not both"), "{e}");
refuses(&mut s, "addon.pathtools.cornerRemove", &[both]);
}
#[test]
fn saved_corner_steps_with_older_item_keys_still_play() {
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);
let saved = json!({"items": [{"id": id.0, "anchors": [0], "old": 1}], "ids": [other.0], "radius": 5});
s.replay("addon.pathtools.cornerSet", &saved).unwrap();
assert_eq!((drawn(&s, id, 0), drawn(&s, id, 1)), (5.0, 0.0));
s.replay("addon.pathtools.cornerRemove", &json!({"items": [{"id": id.0, "old": 1}]})).unwrap();
assert_eq!(drawn(&s, id, 0), 0.0);
}
/// A closed five-pointed star about (200, 150): its points at the even indices 0, 2… (the odd
/// numbers counting from 1), its inner corners at the odd ones.
fn star_path(s: &mut Session) -> NodeId {
let pts: Vec<(f64, f64)> = (0..10)
.map(|i| {
let (r, a) = (if i % 2 == 0 { 100.0 } else { 40.0 }, std::f64::consts::PI * f64::from(i) / 5.0);
(200.0 + r * a.sin(), 150.0 - r * a.cos())
})
.collect();
line(s, &pts, true)
}
/// The cut corners of `id`: anchor indices of its uncut outline with a radius drawn.
fn cut(s: &Session, id: NodeId) -> Vec<usize> {
let lc = LiveCorners::of(s.doc().unwrap().doc.node(id).unwrap()).unwrap();
(0..lc.base.anchor_count()).filter(|k| lc.drawn_radius(*k) > 0.0).collect()
}
#[test]
fn corner_set_every_takes_the_odd_or_even_numbered_anchors_of_each_path() {
let mut s = session();
let star = star_path(&mut s);
// An open zig-zag: its ends (1st and 5th) take no corner but still count.
let zig = line(&mut s, &[(10.0, 250.0), (30.0, 210.0), (50.0, 250.0), (70.0, 210.0), (90.0, 250.0)], false);
select(&mut s, &[star, zig]);
let n = steps(&s);
// Odd: the 1st, 3rd, 5th… of each path, as one step.
let r = s.execute("addon.pathtools.cornerSet", &json!({"radius": 4, "every": "odd"})).unwrap();
assert_eq!((r["corners"].clone(), steps(&s)), (json!(6), n + 1));
assert_eq!((cut(&s, star), cut(&s, zig)), (vec![0, 2, 4, 6, 8], vec![2]));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((cut(&s, star), cut(&s, zig), steps(&s)), (vec![], vec![], n));
// Even: the 2nd, 4th, 6th….
let r = s.execute("addon.pathtools.cornerSet", &json!({"radius": 4, "every": "even"})).unwrap();
assert_eq!((r["corners"].clone(), steps(&s)), (json!(7), n + 1));
assert_eq!((cut(&s, star), cut(&s, zig)), (vec![1, 3, 5, 7, 9], vec![1, 3]));
// "all" (and no `every`) is every chosen corner, as before.
s.execute("addon.pathtools.cornerSet", &json!({"ids": [zig.0], "radius": 4, "every": "all"})).unwrap();
assert_eq!(cut(&s, zig), vec![1, 2, 3]);
}
#[test]
fn corner_set_every_filters_the_named_and_direct_selected_anchors() {
let mut s = session();
let star = star_path(&mut s);
// Of the named anchors, only the even-numbered ones (indices 1 and 3); numbers stay the path's.
let r = s.execute("addon.pathtools.cornerSet", &json!({"ids": [star.0], "anchors": [1, 2, 3, 4], "radius": 3, "every": "even"})).unwrap();
assert_eq!(r["corners"], json!(2));
assert_eq!(cut(&s, star), vec![1, 3]);
// Per path through `items`.
s.execute("addon.pathtools.cornerSet", &json!({"items": [{"id": star.0, "anchors": [5, 6, 7]}], "radius": 3, "every": "odd"})).unwrap();
assert_eq!(cut(&s, star), vec![1, 3, 6]);
// Of the direct-selected anchors.
s.execute("addon.pathtools.cornerRemove", &json!({"ids": [star.0]})).unwrap();
select_anchors(&mut s, star, &[7, 8, 9]);
s.execute("addon.pathtools.cornerSet", &json!({"radius": 3, "every": "odd"})).unwrap();
assert_eq!(cut(&s, star), vec![8]);
}
#[test]
fn corner_set_every_refuses_bad_values_and_says_when_nothing_is_left() {
let mut s = session();
let zig = line(&mut s, &[(10.0, 250.0), (30.0, 210.0), (50.0, 250.0), (70.0, 210.0)], false);
select(&mut s, &[zig]);
let i = zig.0;
refuses(
&mut s,
"addon.pathtools.cornerSet",
&[
json!({"ids": [i], "radius": 5, "every": "third"}),
json!({"ids": [i], "radius": 5, "every": "Odd"}),
json!({"ids": [i], "radius": 5, "every": 2}),
json!({"ids": [i], "radius": 5, "every": true}),
json!({"ids": [i], "anchors": [1], "radius": 5, "every": "odd"}),
json!({"ids": [i], "anchors": [0, 1], "radius": 5, "every": "odd"}),
],
);
let e = s.execute("addon.pathtools.cornerSet", &json!({"ids": [i], "radius": 5, "every": "third"})).unwrap_err().to_string();
assert!(e.contains("`every` must be \"all\", \"odd\" or \"even\""), "{e}");
// The 2nd anchor is a corner, but not an odd-numbered one.
let e = s.execute("addon.pathtools.cornerSet", &json!({"ids": [i], "anchors": [1], "radius": 5, "every": "odd"})).unwrap_err().to_string();
assert!(e.contains("odd-numbered"), "{e}");
// An open end is no corner at all, whatever `every` says.
let e = s.execute("addon.pathtools.cornerSet", &json!({"ids": [i], "anchors": [0], "radius": 5, "every": "odd"})).unwrap_err().to_string();
assert!(e.contains("two sides meet"), "{e}");
}
/// A closed house: a 100 × 100 square under a roof, its corners 90°, 90°, 135°, 90° and 135°.
fn house(s: &mut Session) -> NodeId {
line(s, &[(250.0, 100.0), (350.0, 100.0), (350.0, 200.0), (300.0, 250.0), (250.0, 200.0)], true)
}
#[test]
fn corner_set_angle_range_rounds_only_the_corners_in_it_on_each_path() {
let mut s = session();
// The star's points meet at about 38°, its inner corners at about 110°.
let (star, home) = (star_path(&mut s), house(&mut s));
select(&mut s, &[star, home]);
let n = steps(&s);
// The sharp spikes only: the house has none, the star five, as one step.
let r = s.execute("addon.pathtools.cornerSet", &json!({"radius": 3, "maxAngle": 60})).unwrap();
assert_eq!((r["ids"].clone(), r["corners"].clone(), steps(&s)), (json!([star.0]), json!(5), n + 1));
assert_eq!((cut(&s, star), cut(&s, home)), (vec![0, 2, 4, 6, 8], vec![]));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((cut(&s, star), steps(&s)), (vec![], n));
// Exactly 90°: the house's square corners.
s.execute("addon.pathtools.cornerSet", &json!({"radius": 3, "minAngle": 90, "maxAngle": 90})).unwrap();
assert_eq!((cut(&s, star), cut(&s, home)), (vec![], vec![0, 1, 3]));
// 100° to 140°: the star's inner corners and the roof's; the square corners stay as they are.
let r = s.execute("addon.pathtools.cornerSet", &json!({"radius": 2, "minAngle": 100, "maxAngle": 140})).unwrap();
assert_eq!(r["corners"], json!(7));
assert_eq!((cut(&s, star), cut(&s, home)), (vec![1, 3, 5, 7, 9], vec![0, 1, 2, 3, 4]));
assert_eq!((drawn(&s, home, 0), drawn(&s, home, 2)), (3.0, 2.0));
// On top of the named anchors and of `every`.
s.execute("addon.pathtools.cornerRemove", &json!({})).unwrap();
s.execute("addon.pathtools.cornerSet", &json!({"ids": [home.0], "anchors": [1, 2, 3], "radius": 4, "minAngle": 80, "maxAngle": 100})).unwrap();
assert_eq!(cut(&s, home), vec![1, 3]);
s.execute("addon.pathtools.cornerSet", &json!({"ids": [star.0], "radius": 4, "every": "even", "minAngle": 100})).unwrap();
assert_eq!(cut(&s, star), vec![1, 3, 5, 7, 9]);
}
#[test]
fn corner_set_angle_range_on_a_three_anchor_path() {
let mut s = session();
// An open pen path of three anchors: one corner, of 90°.
let id = line(&mut s, &[(10.0, 10.0), (60.0, 60.0), (110.0, 10.0)], false);
select(&mut s, &[id]);
let e = s.execute("addon.pathtools.cornerSet", &json!({"radius": 5, "maxAngle": 45})).unwrap_err().to_string();
assert!(e.contains("none of the chosen corners has an angle from 0° to 45°"), "{e}");
let r = s.execute("addon.pathtools.cornerSet", &json!({"radius": 5, "minAngle": 80, "maxAngle": 100})).unwrap();
assert_eq!(r["corners"], json!(1));
assert_eq!(cut(&s, id), vec![1]);
}
#[test]
fn corner_set_angle_range_refuses_bad_values_and_says_when_nothing_matches() {
let mut s = session();
let star = star_path(&mut s);
select(&mut s, &[star]);
let i = star.0;
refuses(
&mut s,
"addon.pathtools.cornerSet",
&[
json!({"ids": [i], "radius": 5, "minAngle": -1}),
json!({"ids": [i], "radius": 5, "maxAngle": 181}),
json!({"ids": [i], "radius": 5, "minAngle": 1e308}),
json!({"ids": [i], "radius": 5, "maxAngle": "sharp"}),
json!({"ids": [i], "radius": 5, "minAngle": 120, "maxAngle": 60}),
json!({"ids": [i], "radius": 5, "minAngle": 50, "maxAngle": 100}),
json!({"ids": [i], "radius": 5, "every": "odd", "minAngle": 100}),
],
);
let err = |s: &mut Session, p: Value| s.execute("addon.pathtools.cornerSet", &p).unwrap_err().to_string();
let e = err(&mut s, json!({"ids": [i], "radius": 5, "maxAngle": 200}));
assert!(e.contains("`maxAngle` must be from 0 to 180 degrees"), "{e}");
let e = err(&mut s, json!({"ids": [i], "radius": 5, "minAngle": 120, "maxAngle": 60}));
assert!(e.contains("`minAngle` (120°) can't be more than `maxAngle` (60°)"), "{e}");
let e = err(&mut s, json!({"ids": [i], "radius": 5, "minAngle": 50, "maxAngle": 100}));
assert!(e.contains("none of the chosen corners has an angle from 50° to 100°"), "{e}");
let e = err(&mut s, json!({"ids": [i], "radius": 5, "every": "odd", "minAngle": 100}));
assert!(e.contains("none of the chosen corners at an odd-numbered anchor (the 1st, 3rd, 5th…) has an angle from 100° to 180°"), "{e}");
}
#[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));
}
fn ids(v: &Value) -> Vec<NodeId> {
v["ids"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect()
}
#[test]
fn divide_into_dashes_cuts_a_path_into_open_pieces_keeping_its_look() {
let mut s = session();
let l = line(&mut s, &[(0.0, 0.0), (100.0, 0.0)], false);
select(&mut s, &[l]);
s.execute("stroke.set", &json!({"weight": 3})).unwrap();
let n = steps(&s);
let r = s.execute("addon.pathtools.divideDashes", &json!({"dash": 30, "gap": 10})).unwrap();
assert_eq!(steps(&s), n + 1, "one undo step");
// 30 + 10 + 30 + 10 + 20: the gaps are left empty.
let pieces = ids(&r);
assert_eq!((pieces.len(), r["dashes"].clone()), (3, json!(3)));
let st = s.doc().unwrap();
let spans: Vec<(f64, f64)> = pieces.iter().map(|id| path(&s, *id).bounds().map(|b| (b.x0, b.x1)).unwrap()).collect();
let want = [(0.0, 30.0), (40.0, 70.0), (80.0, 100.0)];
assert!(spans.iter().zip(want).all(|(a, b)| (a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6), "{spans:?}");
assert!(pieces.iter().all(|id| st.doc.node(*id).unwrap().appearance.stroke_width() == 3.0), "the stroke stays");
assert_eq!(st.selection.objects, pieces);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(&s, l).anchor_count(), 2);
// Kept gaps; fitted 25s with no gap.
let r = s.execute("addon.pathtools.divideDashes", &json!({"dash": 30, "gap": 10, "removeGaps": false})).unwrap();
assert_eq!((ids(&r).len(), r["dashes"].clone()), (5, json!(3)));
s.execute("edit.undo", &json!({})).unwrap();
let r = s.execute("addon.pathtools.divideDashes", &json!({"dash": 30, "fit": true})).unwrap();
let spans: Vec<f64> = ids(&r).iter().map(|id| path(&s, *id).bounds().unwrap().width()).collect();
assert_eq!(spans.len(), 3);
assert!(spans.iter().all(|w| (w - 100.0 / 3.0).abs() < 1e-6), "{spans:?}");
}
#[test]
fn divide_into_dashes_divides_only_the_selected_segments() {
let mut s = session();
let u = line(&mut s, &[(0.0, 0.0), (40.0, 0.0), (40.0, 40.0), (0.0, 40.0)], false);
// The middle side, between anchors 1 and 2.
select_anchors(&mut s, u, &[1, 2]);
let r = s.execute("addon.pathtools.divideDashes", &json!({"dash": 10})).unwrap();
let pieces = ids(&r);
assert_eq!((pieces.len(), r["dashes"].clone()), (6, json!(4)), "the first side, four dashes, the last side");
assert_eq!(path(&s, pieces[0]).bounds().unwrap().width(), 40.0);
let layer: Vec<NodeId> = s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect();
assert_eq!(layer, pieces, "in the path's place, in order");
}
#[test]
fn divide_into_dashes_divides_the_selected_segments_of_each_subpath() {
let mut s = session();
let c = compound(&mut s, &SUBS);
let before = path(&s, c);
// The first segment of A and the second of D; a segment of C without its anchors selected.
select_segments(&mut s, c, &[(0, 0), (0, 1), (0, 2), (3, 0), (3, 1), (3, 2)], &[(0, 0), (2, 0), (3, 1)]);
let n = steps(&s);
let r = s.execute("addon.pathtools.divideDashes", &json!({"dash": 25})).unwrap();
assert_eq!(steps(&s), n + 1);
assert_eq!(r["dashes"], json!(8));
let made: Vec<Vec<Vec<(f64, f64)>>> = ids(&r).iter().map(|id| points(&path(&s, *id))).collect();
let piece = |x0: f64, x1: f64, y: f64| vec![vec![(x0, y), (x1, y)]];
let mut want = vec![vec![vec![], vec![(0.0, 50.0), (100.0, 50.0)]]];
want.extend([(0.0, 25.0), (25.0, 50.0), (50.0, 75.0), (75.0, 100.0)].map(|(a, b)| piece(a, b, 0.0)));
want.push(vec![vec![(100.0, 0.0), (200.0, 0.0)]]);
want.push(vec![vec![(0.0, 100.0), (100.0, 100.0)]]);
want.extend([(100.0, 125.0), (125.0, 150.0), (150.0, 175.0), (175.0, 200.0)].map(|(a, b)| piece(a, b, 100.0)));
assert_eq!(made, want, "what stays of the path, then each subpath's pieces in order");
assert_eq!(s.doc().unwrap().selection.objects, ids(&r));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(&s, c), before);
// With no segments selected: those between two selected anchors, here on the last subpath only.
select_segments(&mut s, c, &[(0, 0), (3, 1), (3, 2)], &[]);
let r = s.execute("addon.pathtools.divideDashes", &json!({"dash": 25})).unwrap();
assert_eq!((ids(&r).len(), r["dashes"].clone()), (6, json!(4)));
assert_eq!(points(&path(&s, ids(&r)[0])), points(&before)[..3]);
}
#[test]
fn divide_into_dashes_refuses_bad_params_and_too_many_pieces() {
let mut s = session();
let l = line(&mut s, &[(0.0, 0.0), (1e5, 0.0)], false);
select(&mut s, &[l]);
refuses(
&mut s,
"addon.pathtools.divideDashes",
&[
json!({"dash": 0}),
json!({"dash": -5}),
json!({"dash": 1e308}),
json!({"dash": "10"}),
json!({"gap": -1}),
json!({"gap": 2e6}),
json!({"fit": "yes"}),
json!({"removeGaps": 1}),
json!({"dash": 0.5}),
],
);
// 200000 pieces refused above; 1000 is fine.
assert_eq!(s.execute("addon.pathtools.divideDashes", &json!({"dash": 100})).unwrap()["dashes"], json!(1000));
s.execute("select.none", &json!({})).unwrap();
refuses(&mut s, "addon.pathtools.divideDashes", &[json!({})]);
}
/// The dashes made of `r`'s ids: (first x, last x) of each, by its anchors.
fn dash_spans(s: &Session, r: &Value) -> Vec<(f64, f64)> {
ids(r).iter().map(|id| path(s, *id).bounds().map(|b| (b.x0, b.x1)).unwrap()).collect()
}
fn near(a: &[(f64, f64)], b: &[(f64, f64)]) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|(a, b)| (a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6)
}
#[test]
fn expand_dashes_makes_an_open_path_of_each_dash_keeping_the_stroke() {
let mut s = session();
let l = line(&mut s, &[(0.0, 0.0), (100.0, 0.0)], false);
select(&mut s, &[l]);
s.execute("paint.setFill", &json!({"none": true})).unwrap();
s.execute("stroke.set", &json!({"weight": 3, "cap": "round", "dash": [30, 10]})).unwrap();
let n = steps(&s);
let r = s.execute("addon.pathtools.expandDashes", &json!({})).unwrap();
assert_eq!(steps(&s), n + 1, "one undo step");
assert!(near(&dash_spans(&s, &r), &[(0.0, 30.0), (40.0, 70.0), (80.0, 100.0)]), "{:?}", dash_spans(&s, &r));
assert_eq!(r["dashes"], json!(3));
let st = s.doc().unwrap();
for id in ids(&r) {
let node = st.doc.node(id).unwrap();
let stroke = node.appearance.stroke().unwrap();
assert_eq!((stroke.width, stroke.cap, stroke.dash.clone()), (3.0, vectorcraft_doc::LineCap::Round, None), "the stroke stays, dashes off");
assert!(!path(&s, id).subpaths[0].closed);
}
assert_eq!(st.selection.objects, ids(&r));
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(l).unwrap().appearance.stroke().unwrap().dash.is_some());
// The offset shifts the pattern as the stroke draws it: 20 into the first dash.
s.execute("stroke.set", &json!({"dashOffset": 20})).unwrap();
let r = s.execute("addon.pathtools.expandDashes", &json!({"ids": [l.0]})).unwrap();
assert!(near(&dash_spans(&s, &r), &[(0.0, 10.0), (20.0, 50.0), (60.0, 90.0)]), "{:?}", dash_spans(&s, &r));
}
#[test]
fn expand_dashes_keeps_the_fill_below_and_follows_aligned_corners() {
let mut s = session();
let rect = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 60})).unwrap());
select(&mut s, &[rect]);
s.execute("stroke.set", &json!({"dash": [20, 20]})).unwrap();
let had_fill = !s.doc().unwrap().doc.node(rect).unwrap().appearance.fill_paint().is_none();
let r = s.execute("addon.pathtools.expandDashes", &json!({})).unwrap();
// 320 round: eight dashes of 20, a dash bending round each corner it meets.
let pieces = ids(&r);
assert_eq!(pieces.len(), 8);
let st = s.doc().unwrap();
let layer: Vec<NodeId> = st.doc.layers[0].children().unwrap().iter().map(|n| n.id).collect();
if had_fill {
let kept = st.doc.node(rect).unwrap();
assert!(kept.appearance.stroke().is_none() && kept.appearance.fill().is_some(), "the fill stays on the rectangle");
assert_eq!(layer, [vec![rect], pieces.clone()].concat(), "the fill below its dashes");
} else {
assert_eq!(layer, pieces);
}
assert!(pieces.iter().all(|id| st.doc.node(*id).unwrap().appearance.fill().is_none()), "an open dash isn't filled");
s.execute("edit.undo", &json!({})).unwrap();
// Aligned to corners: a dash centred on each corner, the four sides each starting and ending
// on half a dash; 12 dashes along the sides.
s.execute("stroke.set", &json!({"dash": [10, 10], "alignDashes": true})).unwrap();
let r = s.execute("addon.pathtools.expandDashes", &json!({})).unwrap();
let corners = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 60.0), Point::new(0.0, 60.0)];
let mut centred = 0;
for id in ids(&r) {
let sp = path(&s, id).subpaths[0].clone();
if let [a, b, c] = sp.anchors.as_slice()
&& corners.iter().any(|k| b.p.distance(*k) < 1e-6)
{
assert!((a.p.distance(b.p) - 5.0).abs() < 1e-6 && (c.p.distance(b.p) - 5.0).abs() < 1e-6, "{sp:?}");
centred += 1;
}
}
assert_eq!((ids(&r).len(), centred), (16, 4));
}
#[test]
fn expand_dashes_turns_dots_into_paths_on_their_point_and_releases_compound_paths() {
let mut s = session();
let l = line(&mut s, &[(0.0, 0.0), (100.0, 0.0)], false);
select(&mut s, &[l]);
s.execute("stroke.set", &json!({"dash": [0, 20], "cap": "round"})).unwrap();
let r = s.execute("addon.pathtools.expandDashes", &json!({})).unwrap();
assert_eq!(ids(&r).len(), 6, "a dot every 20 from 0 to 100");
assert!(ids(&r).iter().all(|id| path(&s, *id).bounds().unwrap().width() == 0.0));
s.execute("edit.undo", &json!({})).unwrap();
// Under a butt cap a dot paints nothing, so there is nothing to expand.
s.execute("stroke.set", &json!({"cap": "butt"})).unwrap();
refuses(&mut s, "addon.pathtools.expandDashes", &[json!({})]);
// A compound path's dashes are paths of their own with its stroke.
let a = line(&mut s, &[(0.0, 100.0), (50.0, 100.0)], false);
let b = line(&mut s, &[(0.0, 120.0), (50.0, 120.0)], false);
select(&mut s, &[a, b]);
let c = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap());
s.execute("stroke.set", &json!({"weight": 2, "dash": [20, 10], "cap": "butt"})).unwrap();
let r = s.execute("addon.pathtools.expandDashes", &json!({})).unwrap();
// Each 50 line: 20, gap, 20.
let st = s.doc().unwrap();
assert_eq!(ids(&r).len(), 4);
for id in ids(&r) {
let n = st.doc.node(id).unwrap();
assert!(matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. }), "a plain path");
assert_eq!(n.appearance.stroke_width(), 2.0);
assert!(n.appearance.stroke().unwrap().dash.is_none());
}
assert!(st.doc.node(c).is_none_or(|n| n.appearance.stroke().is_none()), "the compound path's dashes are gone from it");
}
#[test]
fn expand_dashes_refuses_without_dashes_and_past_the_limit() {
let mut s = session();
let l = line(&mut s, &[(0.0, 0.0), (1e5, 0.0)], false);
refuses(&mut s, "addon.pathtools.expandDashes", &[json!({})]);
select(&mut s, &[l]);
refuses(&mut s, "addon.pathtools.expandDashes", &[json!({}), json!({"ids": "x"}), json!({"zzz": 1})]);
// 125000 dashes refused; 1000 are fine.
s.execute("stroke.set", &json!({"dash": [0.4, 0.4]})).unwrap();
refuses(&mut s, "addon.pathtools.expandDashes", &[json!({})]);
s.execute("stroke.set", &json!({"dash": [50, 50]})).unwrap();
assert_eq!(s.execute("addon.pathtools.expandDashes", &json!({})).unwrap()["dashes"], json!(1000));
}