mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 21:57:12 -06:00
Add-ons: Path Precision Tools — Shared Edge Merge
Path Precision Tools gains the Shared Edge Merge Tool, kept out of the toolbars (All Tools drawer and Object › Plug-ins › Path Precision Tools, no shortcut), and Keep Shared Points Together: - Shared Edge Merge Tool (sharedEdge): over two paths touching at two coincident anchors (tolerance 0 to 3 pt) hovering shows the two stretches and the merged edge; a click replaces both with it, half way between them, or following the upper (Shift) or lower (Alt) path. Both paths stay closed or open, anchors either stretch has inside are matched on the other, one undo step. - Making ends: near anchors meet on a click and an anchor one path lacks is added to match; Shift+Alt asks for this inside a stretch. - Keep Shared Points Together: an option that makes Slide Anchor Along Path, Extend Path and Set Anchor take coincident anchors along. Nothing else in the app changes. - Shared Edges section in the Anchor Inspector: brush diameter, coincidence and the option. - Commands addon.pathtools.mergeEdge and movePointsTogether, params validated and covered by the command sweep. - Cursor stand-in, catalog rows in every language, docs and tests.
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
//! 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, Slide Anchor Along Path, the Corner Tool's corners
|
||||
//! ([`corners`]) and the Parametric Shape Tool's shapes ([`shape`]).
|
||||
//! ([`corners`]), the Parametric Shape Tool's shapes ([`shape`]), and the Shared Edge Merge Tool's
|
||||
//! merges with Keep Shared Points Together ([`shared_edge`]).
|
||||
//! Its tools, panels and submenu (Object › Plug-ins › Path Precision Tools) are the tools crate's
|
||||
//! and the UI's.
|
||||
|
||||
@@ -9,6 +10,7 @@ mod corners;
|
||||
#[cfg(test)]
|
||||
mod effect_tests;
|
||||
mod shape;
|
||||
mod shared_edge;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -57,7 +59,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Extend Path",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath?: index (0), end: \"start\"|\"end\", length?: pt (negative shortens; never all of it), to?: [x, y] (instead of length: the length that brings the end nearest this point), mode?: \"straight\"|\"bezier\" (stretch the last segment's curve, no anchor added)|\"arc\" (along the circle the end curves on, at most half way round) (\"straight\")} lengthen an open path at an end the way it runs there, or shorten it back along itself, as one undo step → {id, length (pt)}",
|
||||
"{id, subpath?: index (0), end: \"start\"|\"end\", length?: pt (negative shortens; never all of it), to?: [x, y] (instead of length: the length that brings the end nearest this point), mode?: \"straight\"|\"bezier\" (stretch the last segment's curve, no anchor added)|\"arc\" (along the circle the end curves on, at most half way round) (\"straight\")} lengthen an open path at an end the way it runs there, or shorten it back along itself, as one undo step; with Keep Shared Points Together on, anchors coincident with an end that moves go with it → {id, length (pt)}",
|
||||
has_doc,
|
||||
extend
|
||||
),
|
||||
@@ -75,7 +77,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Anchor",
|
||||
[],
|
||||
None,
|
||||
"{x?, y?: pt (on the page), inLength?, outLength?: pt (0 retracts), inAngle?, outAngle?: degrees counter-clockwise} move the selected anchors (with their handles) and set their handles, as one undo step: direct-selected anchors, or every anchor of a path selected whole; a smooth anchor's other handle turns with the one set → {anchors}",
|
||||
"{x?, y?: pt (on the page), inLength?, outLength?: pt (0 retracts), inAngle?, outAngle?: degrees counter-clockwise} move the selected anchors (with their handles) and set their handles, as one undo step: direct-selected anchors, or every anchor of a path selected whole; a smooth anchor's other handle turns with the one set; with Keep Shared Points Together on, anchors coincident with one that moves go with it → {anchors}",
|
||||
has_selection,
|
||||
set_anchor
|
||||
),
|
||||
@@ -147,13 +149,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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)}",
|
||||
"{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; with Keep Shared Points Together on, anchors coincident with it go with it → {id, strayed (pt)}",
|
||||
has_doc,
|
||||
slide_anchor
|
||||
),
|
||||
];
|
||||
v.extend(shape::specs());
|
||||
v.extend(corners::specs());
|
||||
v.extend(shared_edge::specs());
|
||||
v
|
||||
}
|
||||
|
||||
@@ -315,19 +318,22 @@ fn named_subpath(s: &Session, p: &Value, cmd: &str) -> Result<(NodeId, usize, Su
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// (none when it is the same), with what Keep Shared Points Together moves too; 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(());
|
||||
}
|
||||
let mut np = s.doc()?.doc.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;
|
||||
}
|
||||
let shared = shared_edge::shared_points(s, [(id, &np)])?;
|
||||
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);
|
||||
shared_edge::move_shared(d, shared);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -458,10 +464,12 @@ fn edit_anchors(s: &mut Session, label: &str, cmd: &str, f: impl Fn(&mut SubPath
|
||||
}
|
||||
}
|
||||
if !changed.is_empty() {
|
||||
let shared = shared_edge::shared_points(s, changed.iter().map(|(id, np)| (*id, np)))?;
|
||||
s.edit(label, |d, _| {
|
||||
for (id, np) in changed {
|
||||
set_path(d, id, np);
|
||||
}
|
||||
shared_edge::move_shared(d, shared);
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
//! The Shared Edge Merge Tool's command and Keep Shared Points Together: with that option on, the
|
||||
//! add-on's own commands that move an anchor (Slide Anchor Along Path, Extend Path, Set Anchor) take
|
||||
//! the anchors coincident with it along.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::{AnchorRef, Document, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{PathData, Point, Vec2};
|
||||
use vectorcraft_pathops::addons::pathtools as pt;
|
||||
use vectorcraft_tools::addons::shared_edge::{DIAMETER, DIAMETER_RANGE, ID};
|
||||
|
||||
use super::super::super::{CommandSpec, always, bad, checked_id, cmd, finite_numbers, has_doc, number_or, switch_or, text_param};
|
||||
use super::{edit_whole, set_path};
|
||||
use crate::{Result, Session};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"addon.pathtools.mergeEdge",
|
||||
"Merge Shared Edge",
|
||||
[],
|
||||
None,
|
||||
"{a, b: two path ids (they needn't be selected), at: [x, y] (where), diameter?: pt 0.25..200 (20; how near `at` the edge is looked for), tolerance?: pt 0..3 (0.002; how near two anchors count as coincident), follow?: \"average\" (half way between the two)|\"a\"|\"b\" (keep that path's outline), ends?: bool (false: true makes an end even where the paths could merge)} where the paths touch at two coincident anchors, one each side of `at`, replace the stretches between them with one shared edge; stretches much longer one than the other (by over 10%) or leaving an end at over 120° aren't merged. Otherwise make an end there: two near anchors meet, and an anchor only one path has there is matched by a new one on the other. Each path stays closed or open; one undo step → {a, b, made: \"edge\"|\"end\"|\"nothing\" (merged already)}",
|
||||
has_doc,
|
||||
merge_edge
|
||||
),
|
||||
cmd!(
|
||||
"addon.pathtools.movePointsTogether",
|
||||
"Keep Shared Points Together",
|
||||
[],
|
||||
None,
|
||||
"{enabled?: bool (default: the opposite of now)} Keep Shared Points Together: whether anchors coincident (within the Shared Edge Merge Tool's `tolerance`) with one that Slide Anchor Along Path, Extend Path or Set Anchor moves move with it, on any editable path (the `together` option of tool `sharedEdge`, kept with the preferences; off by default) → {enabled}",
|
||||
always,
|
||||
move_points_together
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// A path a merge may edit.
|
||||
fn merge_path<'a>(d: &'a Document, id: NodeId, cmd: &str) -> Result<&'a PathData> {
|
||||
if !d.is_editable(id) {
|
||||
return Err(bad(cmd, "the path is locked or hidden"));
|
||||
}
|
||||
match d.node(id).map(|n| &n.kind) {
|
||||
Some(NodeKind::Path { path, guide: false, .. }) => Ok(path),
|
||||
_ => Err(bad(cmd, "`a` and `b` must be two different paths")),
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_edge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.pathtools.mergeEdge";
|
||||
let a = checked_id(s, p.get("a").unwrap_or(&Value::Null), C)?;
|
||||
let b = checked_id(s, p.get("b").unwrap_or(&Value::Null), C)?;
|
||||
if a == b {
|
||||
return Err(bad(C, "`a` and `b` must be two different paths"));
|
||||
}
|
||||
let [x, y] = p.get("at").and_then(finite_numbers::<2>).ok_or_else(|| bad(C, "`at` must be [x, y]"))?;
|
||||
let at = Point::new(x, y);
|
||||
let diameter = number_or(p, "diameter", DIAMETER, C)?;
|
||||
if !(DIAMETER_RANGE.0..=DIAMETER_RANGE.1).contains(&diameter) {
|
||||
return Err(bad(C, "`diameter` must be from 0.25 to 200 pt"));
|
||||
}
|
||||
let tolerance = number_or(p, "tolerance", pt::COINCIDENCE, C)?;
|
||||
if !(0.0..=pt::MAX_COINCIDENCE).contains(&tolerance) {
|
||||
return Err(bad(C, "`tolerance` must be from 0 to 3 pt"));
|
||||
}
|
||||
let follow = match text_param(p, "follow", C)? {
|
||||
None => pt::Follow::Average,
|
||||
Some(f) => pt::Follow::parse(f).ok_or_else(|| bad(C, "`follow` must be \"average\", \"a\" or \"b\""))?,
|
||||
};
|
||||
let ends = switch_or(p, "ends", false, C)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let (pa, pb) = (merge_path(d, a, C)?, merge_path(d, b, C)?);
|
||||
let radius = diameter / 2.0;
|
||||
let edge = if ends { None } else { pt::stretches_at(pa, pb, at, radius, tolerance) };
|
||||
let plan = edge.or_else(|| pt::ends_at(pa, pb, at, radius)).ok_or_else(|| bad(C, "the paths have nothing to merge there"))?;
|
||||
let (na, nb) = pt::apply_merge(pa, pb, &plan, follow).ok_or_else(|| bad(C, "the paths have nothing to merge there"))?;
|
||||
let changed: Vec<(NodeId, PathData)> =
|
||||
[(a, pa, na), (b, pb, nb)].into_iter().filter(|(_, old, new)| *old != new).map(|(id, _, new)| (id, new)).collect();
|
||||
let made = match plan {
|
||||
_ if changed.is_empty() => "nothing",
|
||||
pt::MergePlan::Edge { .. } => "edge",
|
||||
pt::MergePlan::Ends { .. } => "end",
|
||||
};
|
||||
edit_whole(s, "Merge Shared Edge", changed)?;
|
||||
Ok(json!({ "a": a.0, "b": b.0, "made": made }))
|
||||
}
|
||||
|
||||
fn move_points_together(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.pathtools.movePointsTogether";
|
||||
let now = s.tool_options_of(ID)["together"].as_bool().unwrap_or(false);
|
||||
let enabled = switch_or(p, "enabled", !now, C)?;
|
||||
s.set_tool_options(Some(ID), &Map::from_iter([("together".to_string(), json!(enabled))]));
|
||||
Ok(json!({ "enabled": enabled }))
|
||||
}
|
||||
|
||||
/// Anchors to move along with others: each with how far.
|
||||
pub(super) type SharedPoints = Vec<(NodeId, AnchorRef, Vec2)>;
|
||||
|
||||
/// With Keep Shared Points Together on, the anchors of editable paths coincident with an anchor
|
||||
/// that `changed` (paths in their new form) moves, and not moving themselves. Subpaths whose
|
||||
/// anchors `changed` adds or removes move none: their anchors stay put.
|
||||
pub(super) fn shared_points<'a>(s: &Session, changed: impl IntoIterator<Item = (NodeId, &'a PathData)>) -> Result<SharedPoints> {
|
||||
let options = s.tool_options_of(ID);
|
||||
if options["together"].as_bool() != Some(true) {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let tol = options["tolerance"].as_f64().unwrap_or(pt::COINCIDENCE);
|
||||
let d = &s.doc()?.doc;
|
||||
let mut moved: BTreeSet<(NodeId, AnchorRef)> = BTreeSet::new();
|
||||
let mut from: Vec<(Point, Vec2)> = vec![];
|
||||
for (id, new) in changed {
|
||||
let Some(old) = d.node(id).and_then(|n| n.path_data()) else { continue };
|
||||
for (si, (o, n)) in old.subpaths.iter().zip(&new.subpaths).enumerate().filter(|(_, (o, n))| o.anchors.len() == n.anchors.len()) {
|
||||
for (ai, (x, y)) in o.anchors.iter().zip(&n.anchors).enumerate().filter(|(_, (x, y))| x.p != y.p) {
|
||||
moved.insert((id, (si, ai)));
|
||||
from.push((x.p, y.p - x.p));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
if from.is_empty() {
|
||||
return Ok(out);
|
||||
}
|
||||
from.sort_by(|a, b| a.0.x.total_cmp(&b.0.x));
|
||||
let editable = d.editable();
|
||||
d.walk(|n| {
|
||||
let NodeKind::Path { path, guide: false, .. } = &n.kind else { return };
|
||||
if !editable(n.id) {
|
||||
return;
|
||||
}
|
||||
for (si, ai, a) in path.anchors() {
|
||||
let start = from.partition_point(|(p, _)| p.x < a.p.x - tol);
|
||||
let mut near = from.iter().skip(start).take_while(|(p, _)| p.x <= a.p.x + tol);
|
||||
if let Some((_, by)) = near.find(|(p, _)| p.distance(a.p) <= tol)
|
||||
&& !moved.contains(&(n.id, (si, ai)))
|
||||
{
|
||||
out.push((n.id, (si, ai), *by));
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Move the anchors [`shared_points`] found; what they are on stops being a live shape.
|
||||
pub(super) fn move_shared(d: &mut Document, shared: SharedPoints) {
|
||||
let mut by_path: BTreeMap<NodeId, Vec<(AnchorRef, Vec2)>> = BTreeMap::new();
|
||||
for (id, at, by) in shared {
|
||||
by_path.entry(id).or_default().push((at, by));
|
||||
}
|
||||
for (id, anchors) in by_path {
|
||||
let Some(mut np) = d.node(id).and_then(|n| n.path_data()).cloned() else { continue };
|
||||
for ((si, ai), by) in anchors {
|
||||
if let Some(a) = np.anchor_mut(si, ai) {
|
||||
a.translate(by);
|
||||
}
|
||||
}
|
||||
set_path(d, id, np);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//! The Shared Edge Merge Tool's command and Keep Shared Points Together: one undo step each, bad
|
||||
//! params refused, the tool's click through the session, and only the add-on's commands affected.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeId;
|
||||
use vectorcraft_geom::{PathData, Point};
|
||||
use vectorcraft_tools::{Mods, 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 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();
|
||||
NodeId(s.execute("path.create", &json!({"anchors": anchors, "closed": closed})).unwrap()["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn path(s: &Session, id: NodeId) -> PathData {
|
||||
s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone()
|
||||
}
|
||||
|
||||
fn steps(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
fn points(s: &Session, id: NodeId) -> Vec<Point> {
|
||||
path(s, id).anchors().map(|(_, _, a)| a.p).collect()
|
||||
}
|
||||
|
||||
/// Two closed shapes with a bent edge each between (100, 100) and (200, 100).
|
||||
fn shapes(s: &mut Session) -> (NodeId, NodeId) {
|
||||
let a = line(s, &[(100.0, 100.0), (150.0, 104.0), (200.0, 100.0), (200.0, 50.0), (100.0, 50.0)], true);
|
||||
let b = line(s, &[(200.0, 100.0), (150.0, 96.0), (100.0, 100.0), (100.0, 150.0), (200.0, 150.0)], true);
|
||||
(a, b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_edge_replaces_both_stretches_as_one_undo_step() {
|
||||
let mut s = session();
|
||||
let (a, b) = shapes(&mut s);
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let (pa, pb) = (path(&s, a), path(&s, b));
|
||||
let n = steps(&s);
|
||||
let r = s.execute("addon.pathtools.mergeEdge", &json!({"a": a.0, "b": b.0, "at": [125, 100]})).unwrap();
|
||||
assert_eq!(r, json!({"a": a.0, "b": b.0, "made": "edge"}));
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
assert!(path(&s, a).subpaths[0].closed && path(&s, b).subpaths[0].closed);
|
||||
assert_eq!(points(&s, a)[..3], [Point::new(100.0, 100.0), Point::new(150.0, 100.0), Point::new(200.0, 100.0)]);
|
||||
assert_eq!(points(&s, b)[..3], [Point::new(200.0, 100.0), Point::new(150.0, 100.0), Point::new(100.0, 100.0)]);
|
||||
// Nothing left to do: no step.
|
||||
let r = s.execute("addon.pathtools.mergeEdge", &json!({"a": a.0, "b": b.0, "at": [125, 100]})).unwrap();
|
||||
assert_eq!(r["made"], json!("nothing"));
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((path(&s, a), path(&s, b)), (pa, pb));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_edge_follows_the_path_asked_and_makes_ends() {
|
||||
let mut s = session();
|
||||
let (a, b) = shapes(&mut s);
|
||||
let pa = path(&s, a);
|
||||
s.execute("addon.pathtools.mergeEdge", &json!({"a": a.0, "b": b.0, "at": [125, 100], "follow": "a"})).unwrap();
|
||||
assert_eq!(path(&s, a), pa);
|
||||
assert_eq!(points(&s, b)[1], Point::new(150.0, 104.0));
|
||||
// Ends asked for: the near anchors of two lines meet, one undo step.
|
||||
let c = line(&mut s, &[(10.0, 200.0), (50.0, 200.0)], false);
|
||||
let d = line(&mut s, &[(54.0, 200.0), (90.0, 240.0)], false);
|
||||
let n = steps(&s);
|
||||
let r = s.execute("addon.pathtools.mergeEdge", &json!({"a": c.0, "b": d.0, "at": [52, 200], "ends": true})).unwrap();
|
||||
assert_eq!((r["made"].clone(), steps(&s)), (json!("end"), n + 1));
|
||||
assert_eq!((points(&s, c)[1], points(&s, d)[0]), (Point::new(52.0, 200.0), Point::new(52.0, 200.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_edge_refuses_bad_params_and_locked_paths() {
|
||||
let mut s = session();
|
||||
let (a, b) = shapes(&mut s);
|
||||
let (i, j) = (a.0, b.0);
|
||||
let n = steps(&s);
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"a": i, "b": i, "at": [125, 100]}),
|
||||
json!({"a": i, "b": j}),
|
||||
json!({"a": i, "b": j, "at": [1]}),
|
||||
json!({"a": i, "b": j, "at": [1, "x"]}),
|
||||
json!({"a": i, "b": j, "at": [125, 100], "diameter": 0.1}),
|
||||
json!({"a": i, "b": j, "at": [125, 100], "diameter": 201}),
|
||||
json!({"a": i, "b": j, "at": [125, 100], "tolerance": 3.5}),
|
||||
json!({"a": i, "b": j, "at": [125, 100], "tolerance": -1}),
|
||||
json!({"a": i, "b": j, "at": [125, 100], "follow": "up"}),
|
||||
json!({"a": i, "b": j, "at": [125, 100], "ends": 1}),
|
||||
json!({"a": i, "b": u64::MAX, "at": [125, 100]}),
|
||||
json!({"a": i, "b": j, "at": [390, 290]}),
|
||||
] {
|
||||
assert!(s.execute("addon.pathtools.mergeEdge", &p).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(steps(&s), n);
|
||||
s.execute("select.set", &json!({"ids": [i]})).unwrap();
|
||||
s.execute("object.lock", &json!({})).unwrap();
|
||||
assert!(s.execute("addon.pathtools.mergeEdge", &json!({"a": i, "b": j, "at": [125, 100]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tool_merges_unselected_paths_on_a_click_with_shift_following_the_upper() {
|
||||
let mut s = session();
|
||||
let (lower, upper) = shapes(&mut s);
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let view = ViewInfo { smart_guides: false, ..ViewInfo::default() };
|
||||
s.select_tool("sharedEdge", view).unwrap();
|
||||
let keep = path(&s, upper);
|
||||
let n = steps(&s);
|
||||
let shifted = |kind| {
|
||||
let mut e = PointerEvent::new(kind, 125.0, 100.0);
|
||||
e.mods = Mods { shift: true, ..Mods::default() };
|
||||
e
|
||||
};
|
||||
s.pointer(&shifted(PointerKind::Down), view).unwrap();
|
||||
s.pointer(&shifted(PointerKind::Up), view).unwrap();
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
assert_eq!(path(&s, upper), keep);
|
||||
assert_eq!(points(&s, lower)[1], Point::new(150.0, 96.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_shared_points_together_is_off_by_default_and_a_preference_of_the_tool() {
|
||||
let mut s = session();
|
||||
assert_eq!(s.tool_options_of("sharedEdge")["together"], json!(false));
|
||||
assert_eq!(s.execute("addon.pathtools.movePointsTogether", &json!({"enabled": true})).unwrap(), json!({"enabled": true}));
|
||||
assert_eq!(s.tool_options_of("sharedEdge")["together"], json!(true), "kept with another tool active");
|
||||
assert_eq!(s.execute("addon.pathtools.movePointsTogether", &json!({})).unwrap(), json!({"enabled": false}));
|
||||
assert!(s.execute("addon.pathtools.movePointsTogether", &json!({"enabled": 1})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slide_takes_coincident_anchors_along_in_one_step_when_the_option_is_on() {
|
||||
let mut s = session();
|
||||
let a = line(&mut s, &[(100.0, 100.0), (200.0, 100.0), (300.0, 100.0)], false);
|
||||
let b = line(&mut s, &[(200.0, 100.0), (200.0, 200.0)], false);
|
||||
let far = line(&mut s, &[(200.0, 101.0), (250.0, 200.0)], false);
|
||||
let slide = json!({"id": a.0, "anchor": 1, "t": 0.5});
|
||||
let n = steps(&s);
|
||||
s.execute("addon.pathtools.slideAnchor", &slide).unwrap();
|
||||
assert_eq!(points(&s, b)[0], Point::new(200.0, 100.0), "off: it stays");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("addon.pathtools.movePointsTogether", &json!({"enabled": true})).unwrap();
|
||||
s.execute("addon.pathtools.slideAnchor", &slide).unwrap();
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
let moved = points(&s, a)[1];
|
||||
assert!(moved.distance(Point::new(250.0, 100.0)) < 1e-6, "{moved:?}");
|
||||
assert!(points(&s, b)[0].distance(moved) < 1e-9);
|
||||
assert_eq!(points(&s, b)[1], Point::new(200.0, 200.0));
|
||||
assert_eq!(points(&s, far)[0], Point::new(200.0, 101.0), "not coincident");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(points(&s, b)[0], Point::new(200.0, 100.0));
|
||||
// A wider coincidence reaches it, and a locked path stays.
|
||||
s.set_tool_options(Some("sharedEdge"), &serde_json::Map::from_iter([("tolerance".to_string(), json!(2))]));
|
||||
s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
|
||||
s.execute("object.lock", &json!({})).unwrap();
|
||||
s.execute("addon.pathtools.slideAnchor", &slide).unwrap();
|
||||
assert!(points(&s, far)[0].distance(Point::new(250.0, 101.0)) < 1e-6);
|
||||
assert_eq!(points(&s, b)[0], Point::new(200.0, 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_anchor_and_extend_take_coincident_anchors_along_but_built_in_moves_do_not() {
|
||||
let mut s = session();
|
||||
let a = line(&mut s, &[(100.0, 100.0), (200.0, 100.0)], false);
|
||||
let b = line(&mut s, &[(200.0, 100.0), (200.0, 200.0)], false);
|
||||
s.execute("addon.pathtools.movePointsTogether", &json!({"enabled": true})).unwrap();
|
||||
// Set Anchor on one selected anchor.
|
||||
s.select(|_, sel| {
|
||||
sel.set([a]);
|
||||
sel.anchors.insert(a, [(0, 1)].into_iter().collect());
|
||||
})
|
||||
.unwrap();
|
||||
let n = steps(&s);
|
||||
s.execute("addon.pathtools.setAnchor", &json!({"x": 220, "y": 90})).unwrap();
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
assert_eq!(points(&s, b), [Point::new(220.0, 90.0), Point::new(200.0, 200.0)]);
|
||||
// An edit that moves no anchor moves none.
|
||||
s.execute("addon.pathtools.corner", &json!({})).unwrap();
|
||||
assert_eq!(points(&s, b)[0], Point::new(220.0, 90.0));
|
||||
// Extend Path moves an end only when it adds no anchor.
|
||||
s.execute("addon.pathtools.extend", &json!({"id": a.0, "end": "end", "length": 10, "mode": "bezier"})).unwrap();
|
||||
let end = points(&s, a)[1];
|
||||
assert!(points(&s, b)[0].distance(end) < 1e-9);
|
||||
let end = points(&s, b)[0];
|
||||
s.execute("addon.pathtools.extend", &json!({"id": a.0, "end": "end", "length": 10})).unwrap();
|
||||
assert_eq!(points(&s, a).len(), 3);
|
||||
assert_eq!(points(&s, b)[0], end, "the end it left stays with what shares it");
|
||||
// The app's own moves are untouched by the option.
|
||||
s.select(|_, sel| {
|
||||
sel.set([a]);
|
||||
sel.anchors.insert(a, [(0, 1)].into_iter().collect());
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("object.move", &json!({"dx": 5, "dy": 5})).unwrap();
|
||||
assert_eq!(points(&s, b)[0], end);
|
||||
}
|
||||
Reference in New Issue
Block a user