M2.1: allow Pen tool and path.join to continue, close, and join compound paths (#3274, #3276, #3379, #3380)

This commit is contained in:
el-j
2026-10-24 00:27:05 +02:00
parent 6757720bba
commit 2a1aca81f0
6 changed files with 1286 additions and 219 deletions
+10 -1
View File
@@ -883,6 +883,10 @@ fn show_all(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({ "count": ids.len() }))
}
/// A closed contour smaller than this (in square document units) has no usable winding direction,
/// so Make Compound Path leaves its orientation alone rather than flipping it on noise.
const MIN_WINDING_AREA: f64 = 1e-6;
fn compound_make(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let Some(backmost) = ids.first().copied() else { return Err(EngineError::Other("nothing selected".into())) };
@@ -904,7 +908,12 @@ fn compound_make(s: &mut Session, _: &Value) -> Result<Value> {
// The paths in front run against the backmost one, so where they overlap it they cut holes
// under the non-zero fill rule (#1197). A compound path's paths turn together, by its
// first: its own holes (a letter's counters) stay holes.
let direction = |n: &Node| n.path_data().and_then(|pd| pd.subpaths.first()).map_or(0.0, |sp| sp.signed_area().signum());
let direction = |n: &Node| {
n.path_data()
.and_then(|pd| pd.subpaths.first())
.filter(|sp| sp.closed && sp.signed_area().abs() > MIN_WINDING_AREA)
.map_or(0.0, |sp| sp.signed_area().signum())
};
let first = |unit: &[Arc<Node>]| unit.first().map_or(0.0, |p| direction(p));
let back = units.first().map_or(0.0, |u| first(u));
for (i, unit) in units.into_iter().enumerate() {
+527 -107
View File
@@ -3,7 +3,7 @@
use std::collections::BTreeSet;
use serde_json::{Value, json};
use vectorcraft_doc::{NodeId, NodeKind, Scaling};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Scaling};
use vectorcraft_geom::{Anchor, AnchorKind, PathData, Point, SubPath, Vec2};
use super::create::anchor_from_json;
@@ -19,11 +19,19 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Anchor (Pen)",
[],
None,
"{id, x, y, in?: [x,y], out?: [x,y]} append to the end of the last open subpath",
"{id, x, y, in?: [x,y], out?: [x,y], subpath?: usize (the open subpath to extend, 0 included; an invalid or closed one is an error; default: the last open subpath)} append to the end of an open subpath",
has_doc,
append_anchor
),
cmd!("path.close", "Close Path", [], None, "{id, in?: [x,y] (handle into the first anchor), independent?: bool}", has_doc, close),
cmd!(
"path.close",
"Close Path",
[],
None,
"{id, subpath?: usize (the open subpath to close, 0 included; default: the last open one), in?: [x,y] (handle into the first anchor), independent?: bool}",
has_doc,
close
),
cmd!(
"path.moveAnchors",
"Move Anchors",
@@ -65,7 +73,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reverse Path Direction",
["Object", "Path"],
None,
"{ids?|id?, reversed?: bool (true: every subpath runs counter-clockwise on screen, false: clockwise, the Attributes panel's Reverse Path Direction On / Off; omitted: flip each path)} the paths in ids or the selection (groups and compound paths: the paths inside), as one undo step → {changed: paths}",
"{ids?|id?, subpath?: usize (reverse only this subpath of each path, 0 included; default: every subpath), reversed?: bool (true: every subpath runs counter-clockwise on screen, false: clockwise, the Attributes panel's Reverse Path Direction On / Off; omitted: flip each path; with subpath, the direction is not read: that subpath is flipped)} the paths in ids or the selection (groups and compound paths: the paths inside), as one undo step → {changed: paths}",
has_doc,
reverse
),
@@ -74,7 +82,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Join",
["Object", "Path"],
Some("Cmd+J"),
"{ids?: [path, path] (default: the selected paths), ends?: [\"first\"|\"last\", \"first\"|\"last\"] (which end of each path to join, as the Pen joins them; default: the nearest pair), to?: \"corner\"|\"smooth\" (the joined anchor: corner retracts both handles, smooth pulls handles in line with the neighbours)} join two open paths into one (the first keeps its id, selected), or close a single open path; without ids, direct-selected anchors lying on one another along a path merge into one anchor instead",
"{ids?: [path, ...] (default: the selected paths; a compound path ID expands to its open member paths), subpaths?: [usize, usize] (which subpaths to join), ends?: [\"first\"|\"last\", \"first\"|\"last\"] (which end of each path to join, for two paths/subpaths; default without ids: direct-selected ends if any, else the nearest pair; default with ids: the nearest pair), to?: \"corner\"|\"smooth\" (the joined anchor: corner retracts both handles, smooth pulls handles in line with the neighbours)} join two or more open paths/subpaths into one (the first keeps its id, selected), or close a single open path; without ids, direct-selected anchors lying on one another along a path merge into one anchor instead",
has_selection,
join
),
@@ -141,23 +149,42 @@ pub(super) fn path_mut(d: &mut vectorcraft_doc::Document, id: NodeId) -> Result<
fn append_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.appendAnchor", "missing id"))?;
let a = anchor_from_json(p, "path.appendAnchor")?;
let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize);
s.edit("Pen", |d, sel| {
let path = path_mut(d, id)?;
match path.subpaths.last_mut() {
Some(sp) if !sp.closed => sp.anchors.push(a),
_ => path.subpaths.push(SubPath::new(vec![a], false)),
}
sel.set([id]);
let last = path.subpaths.len() - 1;
let ai = path.subpaths[last].anchors.len() - 1;
sel.anchors.insert(id, BTreeSet::from([(last, ai)]));
let si = match subpath {
// An explicit subpath is honoured, 0 included, or refused: never swapped for another.
Some(si) => match path.subpaths.get_mut(si).filter(|sp| !sp.closed) {
Some(sp) => {
sp.anchors.push(a);
si
}
None => return Err(bad("path.appendAnchor", format!("subpath {si} is not an open subpath"))),
},
None => match path.subpaths.iter_mut().enumerate().rev().find(|(_, sp)| !sp.closed) {
Some((si, sp)) => {
sp.anchors.push(a);
si
}
None => {
path.subpaths.push(SubPath::new(vec![a], false));
path.subpaths.len() - 1
}
},
};
let ai = path.subpaths[si].anchors.len() - 1;
let top = compound_or_self(d, id);
sel.set([top]);
sel.anchors.insert(id, BTreeSet::from([(si, ai)]));
Ok(())
})?;
ok()
}
/// Close the active open subpath of `id`, optionally setting the incoming handle of the first anchor.
fn close(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?;
let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize);
let h_in = match p.get("in").filter(|h| !h.is_null()) {
None => None,
Some(h) => Some(finite_numbers(h).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad("path.close", "`in` must be [x, y]"))?),
@@ -165,7 +192,10 @@ fn close(s: &mut Session, p: &Value) -> Result<Value> {
let independent = switch_or(p, "independent", false, "path.close")?;
s.edit("Close Path", |d, sel| {
let path = path_mut(d, id)?;
let sp = path.subpaths.iter_mut().rev().find(|s| !s.closed).ok_or_else(|| EngineError::Other("path is already closed".into()))?;
let sp = match subpath {
Some(si) => path.subpaths.get_mut(si).filter(|s| !s.closed).ok_or_else(|| EngineError::Other("path is already closed".into()))?,
None => path.subpaths.iter_mut().rev().find(|s| !s.closed).ok_or_else(|| EngineError::Other("path is already closed".into()))?,
};
sp.closed = true;
if let (Some(h), Some(first)) = (h_in, sp.anchors.first_mut()) {
first.h_in = h;
@@ -174,7 +204,8 @@ fn close(s: &mut Session, p: &Value) -> Result<Value> {
first.kind = AnchorKind::Smooth;
}
}
sel.set([id]);
let top = compound_or_self(d, id);
sel.set([top]);
Ok(())
})?;
ok()
@@ -288,9 +319,41 @@ fn set_anchors(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
/// The compound path enclosing `id`, or `id` itself if it is not inside a compound path.
fn compound_or_self(d: &Document, id: NodeId) -> NodeId {
d.parent_of(id).filter(|p| matches!(d.node(*p).map(|n| &n.kind), Some(NodeKind::Compound { .. }))).unwrap_or(id)
}
/// The open member paths of a compound path, or its first child if none are open.
fn open_members(n: &Node) -> Vec<NodeId> {
let NodeKind::Compound { children, .. } = &n.kind else { return vec![] };
let open: Vec<NodeId> =
children.iter().filter(|c| c.path_data().is_some_and(|pd| pd.subpaths.iter().any(|sp| !sp.closed))).map(|c| c.id).collect();
if open.is_empty() { children.first().map(|c| vec![c.id]).unwrap_or_default() } else { open }
}
/// The selected paths to join or average, resolving compound paths to their open member paths.
fn selected_paths(s: &Session) -> Result<Vec<NodeId>> {
let st = s.doc()?;
Ok(st.selection.objects.iter().copied().filter(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Path { .. }))).collect())
let mut out = vec![];
for &id in &st.selection.objects {
let Some(n) = st.doc.node(id) else { continue };
match &n.kind {
NodeKind::Path { .. } => out.push(id),
NodeKind::Compound { .. } => {
let open = open_members(n);
let with_anchors: Vec<NodeId> =
open.iter().filter(|id| st.selection.anchors.get(id).is_some_and(|s| !s.is_empty())).copied().collect();
if !with_anchors.is_empty() {
out.extend(with_anchors);
} else {
out.extend(open);
}
}
_ => {}
}
}
Ok(out)
}
/// The paths in the subtrees of `ids` (in groups and compound paths too), each once.
@@ -309,6 +372,7 @@ pub(super) fn paths_in(d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec<Nod
fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.reverse";
let want = p.get("reversed").map(|v| v.as_bool().ok_or_else(|| bad(C, "`reversed` must be true or false"))).transpose()?;
let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize);
let ids = paths_in(&s.doc()?.doc, &targets(s, p)?);
if ids.is_empty() {
return Err(bad(C, "select paths"));
@@ -316,18 +380,22 @@ fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
let changed = super::overprint::edit_counted(s, "Reverse Path Direction", |d| {
let mut changed = 0;
for id in &ids {
// The subpaths to flip: all, or those not yet running the wanted way.
let flip: Vec<bool> = d
.node(*id)
.and_then(|n| n.path_data())
.map_or(vec![], |pd| pd.subpaths.iter().map(|sp| want.is_none_or(|ccw| (sp.signed_area() < 0.0) != ccw)).collect());
if flip.contains(&true) {
for (sp, f) in path_mut(d, *id)?.subpaths.iter_mut().zip(flip) {
if f {
sp.reverse();
let path = path_mut(d, *id)?;
if let Some(si) = subpath {
if let Some(sp) = path.subpaths.get_mut(si) {
sp.reverse();
changed += 1;
}
} else {
let flip: Vec<bool> = path.subpaths.iter().map(|sp| want.is_none_or(|ccw| (sp.signed_area() < 0.0) != ccw)).collect();
if flip.contains(&true) {
for (sp, f) in path.subpaths.iter_mut().zip(flip) {
if f {
sp.reverse();
}
}
changed += 1;
}
changed += 1;
}
}
Ok(changed)
@@ -386,6 +454,70 @@ fn merge_coincident(sp: &mut SubPath, picked: impl Fn(usize) -> bool, join_kind:
before - sp.anchors.len()
}
/// Close subpath `si` of path `id`, merging coincident endpoints if present.
fn close_subpath_in_node(d: &mut Document, id: NodeId, si: usize, join_kind: Option<bool>) -> Result<()> {
let path = path_mut(d, id)?;
let sp = path.subpaths.get_mut(si).filter(|s| !s.closed).ok_or_else(|| EngineError::Other("path is already closed".into()))?;
if sp.anchors.len() > 2
&& end_points(sp).is_some_and(|(f, l)| f.distance(l) < 1e-6)
&& let Some(last) = sp.anchors.pop()
&& let Some(first) = sp.anchors.first_mut()
{
first.h_in = last.h_in;
sp.closed = true;
if let Some(smooth) = join_kind {
join_point(sp, 0, smooth);
}
}
sp.closed = true;
Ok(())
}
/// Join subpaths `sa` and `sb` in the same path node `id`.
fn join_subpaths_in_node(d: &mut Document, id: NodeId, (sa, sb): (usize, usize), (rx, ry): (bool, bool), join_kind: Option<bool>) -> Result<()> {
if sa == sb {
return close_subpath_in_node(d, id, sa, join_kind);
}
let path = path_mut(d, id)?;
let mut x = path.subpaths.get(sa).cloned().ok_or_else(|| EngineError::Other("join needs open paths".into()))?;
let mut y = path.subpaths.get(sb).cloned().ok_or_else(|| EngineError::Other("join needs open paths".into()))?;
if x.closed || y.closed {
return Err(EngineError::Other("join needs open paths".into()));
}
if rx {
x.reverse();
}
if ry {
y.reverse();
}
let junction = x.anchors.len().saturating_sub(1);
if let (Some(xe), Some(ys)) = (x.anchors.last().map(|a| a.p), y.anchors.first().map(|a| a.p))
&& xe.distance(ys) < 1e-6
{
let first = y.anchors.remove(0);
if let Some(l) = x.anchors.last_mut() {
l.h_out = first.h_out;
}
}
x.anchors.extend(y.anchors);
if let Some(smooth) = join_kind {
join_point(&mut x, junction, smooth);
}
if sa > sb {
path.subpaths.remove(sb);
if let Some(sub) = path.subpaths.get_mut(sa - 1) {
*sub = x;
}
} else {
if let Some(sub) = path.subpaths.get_mut(sa) {
*sub = x;
}
path.subpaths.remove(sb);
}
Ok(())
}
/// Join open paths into a single path, or close an open path, preserving compound path structure.
fn join(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.join";
let join_kind = match p.get("to").and_then(Value::as_str) {
@@ -394,19 +526,148 @@ fn join(s: &mut Session, p: &Value) -> Result<Value> {
Some("corner") => Some(false),
Some(_) => return Err(bad(C, "`to` must be \"corner\" or \"smooth\"")),
};
let ids = match ids_param(p, "ids") {
Some(ids) => {
let st = s.doc()?;
if let Some(id) = ids.iter().find(|id| !matches!(st.doc.node(**id).map(|n| &n.kind), Some(NodeKind::Path { .. }))) {
return Err(bad(C, format!("{} isn't a path", id.0)));
let subpaths = match p.get("subpaths") {
None => None,
Some(v) => match v.as_array().and_then(|a| {
if a.len() == 2
&& let (Some(sa), Some(sb)) = (a[0].as_u64(), a[1].as_u64())
{
Some((sa as usize, sb as usize))
} else {
None
}
ids
}
None => selected_paths(s)?,
}) {
Some(pair) => Some(pair),
None => return Err(bad(C, "`subpaths` must be two integer indices")),
},
};
// The ends to join, as (reverse the first path, reverse the second): the first path's end
// must come last, the second's first.
let ends = match p.get("ends") {
None => None,
Some(e) => match e.as_array().map(|a| a.iter().map(Value::as_str).collect::<Vec<_>>()).as_deref() {
Some([Some(a @ ("first" | "last")), Some(b @ ("first" | "last"))]) => Some((*a == "first", *b == "last")),
_ => return Err(bad(C, "ends must be two of \"first\" and \"last\"")),
},
};
if let Some(ids) = ids_param(p, "ids") {
if ends.is_some() && ids.len() > 2 {
return Err(bad(C, "`ends` applies to joining exactly two paths"));
}
if ids.len() == 2 && ids[0] == ids[1] {
let id = ids[0];
let (sa, sb) = subpaths.ok_or_else(|| bad(C, "subpaths required when joining the same path"))?;
let (rx, ry) = ends.unwrap_or((false, false));
s.edit("Join", |d, sel| {
join_subpaths_in_node(d, id, (sa, sb), (rx, ry), join_kind)?;
sel.set([compound_or_self(d, id)]);
Ok(())
})?;
return ok();
}
if ids.len() == 2
&& let Some((sa, sb)) = subpaths
{
let (a, b) = (ids[0], ids[1]);
let (rx, ry) = ends.unwrap_or((false, false));
s.edit("Join", |d, sel| {
join_two(d, a, b, (sa, sb), (rx, ry), join_kind)?;
sel.set([compound_or_self(d, a)]);
Ok(())
})?;
return ok();
}
let st = s.doc()?;
let mut resolved = vec![];
for &id in &ids {
let Some(n) = st.doc.node(id) else {
return Err(bad(C, format!("{} isn't a path", id.0)));
};
match &n.kind {
NodeKind::Path { .. } => resolved.push(id),
NodeKind::Compound { .. } => {
let open = open_members(n);
if ids.len() > 1 && open.len() != 1 {
return Err(bad(C, format!("compound {} must contain exactly one open path to join", id.0)));
}
resolved.extend(open);
}
_ => return Err(bad(C, format!("{} isn't a path", id.0))),
}
}
match resolved.as_slice() {
[one] => {
let id = *one;
let si = match subpaths {
Some((si, _)) => si,
None => {
let pd = s.doc()?.doc.node(id).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(id))?;
pd.subpaths
.iter()
.position(|sp| !sp.closed && !sp.anchors.is_empty())
.ok_or_else(|| EngineError::Other("path is already closed".into()))?
}
};
s.edit("Join", |d, sel| {
close_subpath_in_node(d, id, si, join_kind)?;
sel.set([compound_or_self(d, id)]);
Ok(())
})?;
}
[a, b, ..] => {
if resolved.iter().collect::<BTreeSet<_>>().len() != resolved.len() {
return Err(bad(C, "join two different paths"));
}
let (a, b) = (*a, *b);
if ends.is_some() && resolved.len() > 2 {
return Err(bad(C, "`ends` applies to joining exactly two paths"));
}
if let Some((rx, ry)) = ends {
let st = s.doc()?;
let pa = st.doc.node(a).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(a))?;
let pb = st.doc.node(b).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(b))?;
let sa = match subpaths {
Some((s, _)) => s,
None => pa.subpaths.iter().position(|s| !s.closed).ok_or_else(|| EngineError::Other("join needs open paths".into()))?,
};
let sb = match subpaths {
Some((_, s)) => s,
None => pb.subpaths.iter().position(|s| !s.closed).ok_or_else(|| EngineError::Other("join needs open paths".into()))?,
};
s.edit("Join", |d, sel| {
join_two(d, a, b, (sa, sb), (rx, ry), join_kind)?;
sel.set([compound_or_self(d, a)]);
Ok(())
})?;
return ok();
}
let st = s.doc()?;
let mut open_subs = Vec::with_capacity(resolved.len());
for &id in &resolved {
let pd = st.doc.node(id).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(id))?;
let si = pd
.subpaths
.iter()
.position(|sp| !sp.closed && !sp.anchors.is_empty())
.ok_or_else(|| EngineError::Other("join needs open paths".into()))?;
open_subs.push((id, si));
}
s.edit("Join", |d, sel| {
let top = join_nearest(d, open_subs, join_kind)?;
sel.set([top]);
Ok(())
})?;
}
[] => return Err(EngineError::Other("select paths to join".into())),
}
return ok();
}
// Direct-selected anchors lying on one another along a path (two corners brought together by
// Average) become one anchor; the paths themselves are joined only when there are none.
if p.get("ids").is_none() {
{
let st = s.doc()?;
let mut merged = vec![];
for (id, picked) in &st.selection.anchors {
@@ -423,96 +684,253 @@ fn join(s: &mut Session, p: &Value) -> Result<Value> {
for (id, path) in merged {
*path_mut(d, id)? = path;
}
// The anchor indices changed: the paths stay selected, whole.
sel.set(objects);
Ok(())
})?;
return ok();
}
}
// The ends to join, as (reverse the first path, reverse the second): the first path's end
// must come last, the second's first.
let ends = match p.get("ends") {
None => None,
Some(e) => match e.as_array().map(|a| a.iter().map(Value::as_str).collect::<Vec<_>>()).as_deref() {
Some([Some(a @ ("first" | "last")), Some(b @ ("first" | "last"))]) => Some((*a == "first", *b == "last")),
_ => return Err(bad(C, "ends must be two of \"first\" and \"last\"")),
},
};
match ids.as_slice() {
[one] => {
let id = *one;
s.edit("Join", |d, _| {
let path = path_mut(d, id)?;
let sp = path.subpaths.iter_mut().find(|s| !s.closed).ok_or_else(|| EngineError::Other("path is already closed".into()))?;
// Merge coincident end points.
if sp.anchors.len() > 2
&& end_points(sp).is_some_and(|(f, l)| f.distance(l) < 1e-6)
&& let Some(last) = sp.anchors.pop()
&& let Some(first) = sp.anchors.first_mut()
{
first.h_in = last.h_in;
// Closed first, so the merged anchor smooths between both its neighbours.
sp.closed = true;
if let Some(smooth) = join_kind {
join_point(sp, 0, smooth);
let direct_ends: Vec<(NodeId, usize, &'static str)> = {
let st = s.doc()?;
let mut ends = vec![];
for (&id, picked) in &st.selection.anchors {
if let Some(pd) = st.doc.node(id).and_then(|n| n.path_data()) {
for (si, sp) in pd.subpaths.iter().enumerate() {
if sp.closed || sp.anchors.is_empty() {
continue;
}
let last_idx = sp.anchors.len() - 1;
let f = picked.contains(&(si, 0));
let l = picked.contains(&(si, last_idx));
if f && l && last_idx > 0 {
ends.push((id, si, "both"));
} else if f {
ends.push((id, si, "first"));
} else if l {
ends.push((id, si, "last"));
}
}
sp.closed = true;
Ok(())
})?;
}
[a, b, ..] => {
let (a, b) = (*a, *b);
if a == b {
return Err(bad(C, "join two different paths"));
}
s.edit("Join", |d, sel| {
let pb = d.node(b).and_then(|n| n.path_data()).cloned().ok_or(EngineError::NoNode(b))?;
let pa = path_mut(d, a)?;
let (Some(sa), Some(sb)) = (pa.subpaths.iter().position(|s| !s.closed), pb.subpaths.iter().position(|s| !s.closed)) else {
return Err(EngineError::Other("join needs open paths".into()));
};
let mut x = pa.subpaths[sa].clone();
let mut y = pb.subpaths[sb].clone();
let (Some((xf, xl)), Some((yf, yl))) = (end_points(&x), end_points(&y)) else {
return Err(EngineError::Other("join needs open paths".into()));
};
// The ends asked for, else the closest pair.
let cands =
[(xl.distance(yf), false, false), (xl.distance(yl), false, true), (xf.distance(yf), true, false), (xf.distance(yl), true, true)];
let (rx, ry) =
ends.unwrap_or_else(|| cands.into_iter().min_by(|p, q| p.0.total_cmp(&q.0)).map_or((false, false), |(_, rx, ry)| (rx, ry)));
if rx {
x.reverse();
}
if ry {
y.reverse();
}
let junction = x.anchors.len() - 1;
if let (Some(xe), Some(ys)) = (x.anchors.last().map(|a| a.p), y.anchors.first().map(|a| a.p))
&& xe.distance(ys) < 1e-6
{
let first = y.anchors.remove(0);
if let Some(l) = x.anchors.last_mut() {
l.h_out = first.h_out;
}
ends
};
// More than two selected ends can't name one join: refuse it instead of joining everything.
// A single end (left selected by the Pen) falls through to joining the selected paths.
if direct_ends.len() > 2 {
return Err(bad(C, "select exactly two path ends to join"));
}
if direct_ends.len() == 1 && direct_ends[0].2 == "both" {
let (id, si, _) = direct_ends[0];
s.edit("Join", |d, sel| {
close_subpath_in_node(d, id, si, join_kind)?;
sel.set([compound_or_self(d, id)]);
Ok(())
})?;
return ok();
}
if direct_ends.len() == 2 {
let (ida, sa, ea) = direct_ends[0];
let (idb, sb, eb) = direct_ends[1];
let (rx, ry) = match (ea, eb) {
("first", "first") => (true, false),
("first", "last") => (true, true),
("last", "first") => (false, false),
("last", "last") => (false, true),
_ => {
let st = s.doc()?;
let pa = st.doc.node(ida).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(ida))?;
let pb = st.doc.node(idb).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(idb))?;
let (fa, la) = pa.subpaths.get(sa).and_then(end_points).ok_or_else(|| EngineError::Other("join needs open paths".into()))?;
let (fb, lb) = pb.subpaths.get(sb).and_then(end_points).ok_or_else(|| EngineError::Other("join needs open paths".into()))?;
let mut best: Option<(bool, bool, f64)> = None;
let cands = [
(la.distance(fb), false, false, (ea == "last" || ea == "both") && (eb == "first" || eb == "both")),
(la.distance(lb), false, true, (ea == "last" || ea == "both") && (eb == "last" || eb == "both")),
(fa.distance(fb), true, false, (ea == "first" || ea == "both") && (eb == "first" || eb == "both")),
(fa.distance(lb), true, true, (ea == "first" || ea == "both") && (eb == "last" || eb == "both")),
];
for (dist, rx_c, ry_c, valid) in cands {
if valid && best.as_ref().is_none_or(|b| dist < b.2) {
best = Some((rx_c, ry_c, dist));
}
}
x.anchors.extend(y.anchors);
if let Some(smooth) = join_kind {
join_point(&mut x, junction, smooth);
match best {
Some((rx, ry, _)) => (rx, ry),
None => (ea == "first", eb == "last"),
}
pa.subpaths[sa] = x;
d.remove(b)?;
sel.set([a]);
}
};
if ida == idb {
if sa == sb {
s.edit("Join", |d, sel| {
close_subpath_in_node(d, ida, sa, join_kind)?;
sel.set([compound_or_self(d, ida)]);
Ok(())
})?;
} else {
s.edit("Join", |d, sel| {
join_subpaths_in_node(d, ida, (sa, sb), (rx, ry), join_kind)?;
sel.set([compound_or_self(d, ida)]);
Ok(())
})?;
}
return ok();
} else {
s.edit("Join", |d, sel| {
join_two(d, ida, idb, (sa, sb), (rx, ry), join_kind)?;
sel.set([compound_or_self(d, ida)]);
Ok(())
})?;
return ok();
}
[] => return Err(EngineError::Other("select paths to join".into())),
}
let ids = selected_paths(s)?;
if ids.is_empty() {
return Err(EngineError::Other("select paths to join".into()));
}
let mut open_subs: Vec<(NodeId, usize)> = vec![];
{
let st = s.doc()?;
for &id in &ids {
if let Some(pd) = st.doc.node(id).and_then(|n| n.path_data()) {
for (si, sp) in pd.subpaths.iter().enumerate() {
if !sp.closed && !sp.anchors.is_empty() {
open_subs.push((id, si));
}
}
}
}
}
if open_subs.is_empty() {
if ids.len() == 1 {
return Err(EngineError::Other("path is already closed".into()));
}
return Err(EngineError::Other("select paths to join".into()));
}
if open_subs.len() == 1 {
let (id, si) = open_subs[0];
s.edit("Join", |d, sel| {
close_subpath_in_node(d, id, si, join_kind)?;
sel.set([compound_or_self(d, id)]);
Ok(())
})?;
return ok();
}
s.edit("Join", |d, sel| {
let top = join_nearest(d, open_subs, join_kind)?;
sel.set([top]);
Ok(())
})?;
ok()
}
/// Join open subpaths, always the two whose ends lie nearest each other, until one is left.
/// `open_subs` lists `(node, subpath)` pairs. Returns the node holding the result (or its compound).
fn join_nearest(d: &mut Document, mut open_subs: Vec<(NodeId, usize)>, join_kind: Option<bool>) -> Result<NodeId> {
let err = || EngineError::Other("join needs open paths".into());
while open_subs.len() > 1 {
let mut ends_info = Vec::with_capacity(open_subs.len());
for &(id, si) in &open_subs {
let p = d.node(id).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(id))?;
let sp = p.subpaths.get(si).ok_or_else(err)?;
ends_info.push(end_points(sp).ok_or_else(err)?);
}
let mut best: Option<(usize, usize, bool, bool, f64)> = None;
for (i, &(fa, la)) in ends_info.iter().enumerate() {
for (j, &(fb, lb)) in ends_info.iter().enumerate().skip(i + 1) {
let cands =
[(la.distance(fb), false, false), (la.distance(lb), false, true), (fa.distance(fb), true, false), (fa.distance(lb), true, true)];
for (dist, rx, ry) in cands {
if best.as_ref().is_none_or(|b| dist < b.4) {
best = Some((i, j, rx, ry, dist));
}
}
}
}
let Some((i, j, rx, ry, _)) = best else { return Err(err()) };
let ((ida, sa), (idb, sb)) = (open_subs[i], open_subs[j]);
if ida == idb {
join_subpaths_in_node(d, ida, (sa, sb), (rx, ry), join_kind)?;
// Subpath `sb` is gone and `sa` took the merged result; later subpaths shift down.
open_subs.remove(j);
if sa > sb {
open_subs[i].1 -= 1;
}
for (k, item) in open_subs.iter_mut().enumerate() {
if k != i && item.0 == ida && item.1 > sb {
item.1 -= 1;
}
}
} else {
// `join_two` removes `b` only when `sb` was its last subpath; otherwise `b` keeps its
// other subpaths, which stay in `open_subs` with their indexes shifted down.
join_two(d, ida, idb, (sa, sb), (rx, ry), join_kind)?;
open_subs.remove(j);
for item in &mut open_subs {
if item.0 == idb && item.1 > sb {
item.1 -= 1;
}
}
}
}
let first = open_subs.first().map(|(id, _)| *id).ok_or_else(err)?;
Ok(compound_or_self(d, first))
}
/// Join path `b` into path `a` along the specified subpaths and orientations.
fn join_two(d: &mut Document, a: NodeId, b: NodeId, (sa, sb): (usize, usize), (rx, ry): (bool, bool), join_kind: Option<bool>) -> Result<()> {
let pb = d.node(b).and_then(|n| n.path_data()).cloned().ok_or(EngineError::NoNode(b))?;
let pa = path_mut(d, a)?;
let mut x = pa.subpaths.get(sa).cloned().ok_or_else(|| EngineError::Other("join needs open paths".into()))?;
let mut y = pb.subpaths.get(sb).cloned().ok_or_else(|| EngineError::Other("join needs open paths".into()))?;
if x.closed || y.closed || x.anchors.is_empty() || y.anchors.is_empty() {
return Err(EngineError::Other("join needs open paths".into()));
}
if rx {
x.reverse();
}
if ry {
y.reverse();
}
let junction = x.anchors.len().saturating_sub(1);
if let (Some(xe), Some(ys)) = (x.anchors.last().map(|a| a.p), y.anchors.first().map(|a| a.p))
&& xe.distance(ys) < 1e-6
{
let first = y.anchors.remove(0);
if let Some(l) = x.anchors.last_mut() {
l.h_out = first.h_out;
}
}
x.anchors.extend(y.anchors);
if let Some(smooth) = join_kind {
join_point(&mut x, junction, smooth);
}
if let Some(sub) = pa.subpaths.get_mut(sa) {
*sub = x;
}
if pb.subpaths.len() <= 1 {
let parent = d.parent_of(b).filter(|p| matches!(d.node(*p).map(|n| &n.kind), Some(NodeKind::Compound { .. })));
d.remove(b)?;
if let Some(parent) = parent
&& d.node(parent).is_some_and(|n| n.children().is_some_and(|children| children.is_empty()))
{
d.remove(parent)?;
}
} else {
let pb_mut = path_mut(d, b)?;
if sb < pb_mut.subpaths.len() {
pb_mut.subpaths.remove(sb);
}
}
Ok(())
}
pub(super) type AnchorTargets = Vec<(NodeId, Vec<(usize, usize)>)>;
pub(super) fn anchor_targets(s: &Session) -> Result<AnchorTargets> {
@@ -624,6 +1042,7 @@ pub(crate) fn delete_anchors(s: &mut Session, _: &Value) -> Result<Value> {
let cuts = refs.iter().filter_map(|&(s, seg)| (s == si).then_some(seg)).collect();
without_segments(sp, &cuts)
})
.filter(|s| s.anchors.len() >= 2)
.collect();
} else {
// Remove every selected anchor against the original indexes, keeping surviving
@@ -635,6 +1054,7 @@ pub(crate) fn delete_anchors(s: &mut Session, _: &Value) -> Result<Value> {
let gone = set.iter().filter_map(|&(s, ai)| (s == si).then_some(ai)).collect();
without_anchors(sp, &gone)
})
.filter(|s| s.anchors.len() >= 2)
.collect();
}
path.subpaths.retain(|s| s.anchors.len() >= 2);
@@ -693,7 +1113,7 @@ fn without_anchors(sp: SubPath, removed: &BTreeSet<usize>) -> Vec<SubPath> {
}
/// Remove connections, splitting open/closed contours into their surviving open runs.
fn without_segments(sp: SubPath, removed: &BTreeSet<usize>) -> Vec<SubPath> {
pub(super) fn without_segments(sp: SubPath, removed: &BTreeSet<usize>) -> Vec<SubPath> {
let cuts: BTreeSet<usize> = removed.iter().copied().filter(|&i| i < sp.segment_count()).collect();
let Some(&first) = cuts.first() else { return vec![sp] };
let n = sp.anchors.len();
+4 -2
View File
@@ -471,8 +471,10 @@ fn anchors(s: &mut Session, p: &Value) -> Result<Value> {
if mode == "set" {
sel.clear();
}
// A path selected as a whole has every anchor selected: toggle and add start from them all.
let whole = sel.contains(id) && sel.partial(id).is_none();
// A path selected as a whole has every anchor selected: toggle and add start from them all,
// but only when no partial anchor selection is active and only on a single object. With
// several objects selected, a click picks that anchor alone, so Join can take two picked ends.
let whole = sel.contains(id) && sel.partial(id).is_none() && sel.anchors.is_empty() && sel.objects.len() <= 1;
sel.add(id);
// A segment pick's endpoints aren't selected anchors: start from none of them.
if sel.segments.remove(&id).is_some() {
+533 -19
View File
@@ -1683,25 +1683,6 @@ fn direct_segment_delete_keeps_the_other_rectangle_edges_and_one_undo() {
}
}
/// A segment pick's endpoints are hollow: Shift-clicking or Shift-marqueeing one selects that
/// anchor, rather than toggling it off and leaving the other endpoint selected.
#[test]
fn selecting_an_anchor_after_a_segment_pick_starts_from_no_anchors() {
let mut s = session();
let id = rect(&mut s, 100.0, 100.0, 200.0, 150.0);
let segment = json!({"id": id.0, "segments": [[0, 0]]});
s.execute("select.segments", &segment).unwrap();
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 0]], "mode": "toggle"})).unwrap();
let sel = &s.doc().unwrap().selection;
assert_eq!(sel.partial(id), Some(&[(0, 0)].into()));
assert!(sel.segments.is_empty());
s.execute("select.segments", &segment).unwrap();
s.execute("select.anchorsMany", &json!({"items": [{"id": id.0, "anchors": [[0, 1]]}], "mode": "add"})).unwrap();
let sel = &s.doc().unwrap().selection;
assert_eq!(sel.partial(id), Some(&[(0, 1)].into()));
assert!(sel.segments.is_empty());
}
#[test]
fn segment_delete_preserves_curves_appearance_and_unrelated_contours() {
let data = "M100 100C110 70 150 130 170 100C180 75 230 125 250 100C270 140 280 80 310 100C320 120 140 140 100 100Z M400 100L500 100L500 200Z";
@@ -2398,3 +2379,536 @@ fn open_anchor_delete_after_transform_keeps_mixed_selection_atomic() {
assert_eq!(s.doc().unwrap().selection.objects, vec![id]);
}
}
/// Pen tool can continue drawing an open path inside a compound path and close it.
#[test]
fn compound_path_pen_continue_and_close() {
use vectorcraft_tools::Cursor;
let (v, none) = (view(), Mods::default());
let mut s = session();
let r = rect(&mut s, 50.0, 50.0, 200.0, 200.0);
let p = line(&mut s, 100.0, 100.0, 200.0, 100.0);
s.execute("select.set", &json!({"ids": [r.0, p.0]})).unwrap();
let cid = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap());
s.select_tool("pen", v).unwrap();
// Hovering over the last anchor of the open member path shows PenContinue.
assert_eq!(s.cursor(Point::new(200.0, 100.0), none, v), Cursor::PenContinue);
// Click on the last anchor to continue drawing.
gesture(&mut s, &[(200.0, 100.0)], none);
// Rubber band is active, cursor over the start of the path shows PenClose.
assert_eq!(s.cursor(Point::new(100.0, 100.0), none, v), Cursor::PenClose);
// Add an intermediate anchor.
gesture(&mut s, &[(200.0, 200.0)], none);
assert_eq!(s.cursor(Point::new(100.0, 100.0), none, v), Cursor::PenClose);
// Click on the first anchor to close the path.
gesture(&mut s, &[(100.0, 100.0)], none);
// Verify member path is now closed inside the compound path.
let d = &s.doc().unwrap().doc;
let cnode = d.node(cid).unwrap();
let ch = cnode.children().unwrap();
assert_eq!(ch.len(), 2);
for child in ch {
let pd = child.path_data().unwrap();
assert!(pd.is_closed(), "all member paths should be closed");
}
// Selection should be on the compound path.
assert_eq!(s.doc().unwrap().selection.objects, vec![cid]);
}
/// Pen tool can continue an open path inside a compound path and join another open path inside it.
#[test]
fn compound_path_pen_continue_and_join() {
use vectorcraft_tools::Cursor;
let (v, none) = (view(), Mods::default());
let mut s = session();
let a = line(&mut s, 100.0, 100.0, 200.0, 100.0);
let b = line(&mut s, 300.0, 100.0, 400.0, 100.0);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let cid = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap());
s.select_tool("pen", v).unwrap();
assert_eq!(s.cursor(Point::new(200.0, 100.0), none, v), Cursor::PenContinue);
// Continue from a's end.
gesture(&mut s, &[(200.0, 100.0)], none);
// Hover over b's start shows PenJoin.
assert_eq!(s.cursor(Point::new(300.0, 100.0), none, v), Cursor::PenJoin);
// Click on b's start to join them.
assert_eq!(gesture(&mut s, &[(300.0, 100.0)], none), 1);
// The two open paths are now joined into one path inside the compound path.
let d = &s.doc().unwrap().doc;
let cnode = d.node(cid).unwrap();
let ch = cnode.children().unwrap();
assert_eq!(ch.len(), 1, "joined into one path");
let pd = ch[0].path_data().unwrap();
assert_eq!(pd.subpaths[0].anchors.len(), 4);
assert_eq!(s.doc().unwrap().selection.objects, vec![cid]);
}
/// Path join (Cmd+J) works on compound paths with open paths.
#[test]
fn compound_path_join_command() {
let mut s = session();
let a = line(&mut s, 100.0, 100.0, 200.0, 100.0);
let b = line(&mut s, 300.0, 100.0, 400.0, 100.0);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let cid = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap());
// Join with compound path selected joins its 2 open paths.
s.execute("path.join", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
let cnode = d.node(cid).unwrap();
let ch = cnode.children().unwrap();
assert_eq!(ch.len(), 1);
let pd = ch[0].path_data().unwrap();
assert!(!pd.is_closed());
assert_eq!(pd.subpaths[0].anchors.len(), 4);
assert_eq!(s.doc().unwrap().selection.objects, vec![cid]);
// Joining again closes the single open path.
s.execute("path.join", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
let cnode = d.node(cid).unwrap();
let ch = cnode.children().unwrap();
assert_eq!(ch.len(), 1);
assert!(ch[0].path_data().unwrap().is_closed());
}
/// Path join with direct-selected endpoints of a compound path connects those endpoints.
#[test]
fn compound_path_join_direct_selected_endpoints() {
let mut s = session();
let a = line(&mut s, 100.0, 100.0, 200.0, 100.0);
let b = line(&mut s, 300.0, 100.0, 400.0, 100.0);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let cid = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap());
let ch = s.doc().unwrap().doc.node(cid).unwrap().children().unwrap().clone();
let ca = ch[0].id;
let cb = ch[1].id;
// Direct select endpoints: ca's first (100, 100) and cb's start (300, 100).
s.execute("select.anchors", &json!({"id": ca.0, "anchors": [[0, 0]], "mode": "set"})).unwrap();
s.execute("select.anchors", &json!({"id": cb.0, "anchors": [[0, 0]], "mode": "add"})).unwrap();
s.execute("path.join", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
let cnode = d.node(cid).unwrap();
let ch = cnode.children().unwrap();
assert_eq!(ch.len(), 1);
let pd = ch[0].path_data().unwrap();
let pts: Vec<(f64, f64)> = pd.subpaths[0].anchors.iter().map(|x| (x.p.x, x.p.y)).collect();
// ca's first was connected to cb's last.
assert_eq!(pts, [(200.0, 100.0), (100.0, 100.0), (300.0, 100.0), (400.0, 100.0)]);
}
/// Explicit ids in path.join validate compound expansion and allow joining three or more paths.
#[test]
fn compound_path_join_explicit_ids_validation() {
let mut s = session();
let a = line(&mut s, 100.0, 100.0, 200.0, 100.0);
let b = line(&mut s, 300.0, 100.0, 400.0, 100.0);
let c = line(&mut s, 500.0, 100.0, 600.0, 100.0);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let cid = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap());
// Multi-id join with compound containing != 1 open path (has 2) errors.
let err = s.execute("path.join", &json!({"ids": [cid.0, c.0]})).unwrap_err();
assert!(err.to_string().contains("must contain exactly one open path to join"));
// Single compound id with 2 open paths resolves to 2 paths and succeeds.
assert!(s.execute("path.join", &json!({"ids": [cid.0]})).is_ok());
// Multi-id join with compound now containing 1 open path + another path succeeds.
assert!(s.execute("path.join", &json!({"ids": [cid.0, c.0]})).is_ok());
// Joining with explicit ends and > 2 paths errors.
let x = line(&mut s, 0.0, 0.0, 10.0, 10.0);
let y = line(&mut s, 20.0, 0.0, 30.0, 10.0);
let z = line(&mut s, 40.0, 0.0, 50.0, 10.0);
let err = s.execute("path.join", &json!({"ids": [x.0, y.0, z.0], "ends": ["first", "last"]})).unwrap_err();
assert!(err.to_string().contains("`ends` applies to joining exactly two paths"));
// Joining three or more paths joins them all.
s.execute("path.join", &json!({"ids": [x.0, y.0, z.0]})).unwrap();
let d = &s.doc().unwrap().doc;
let xnode = d.node(x).unwrap();
let pd = xnode.path_data().unwrap();
assert_eq!(pd.subpaths[0].anchors.len(), 6);
assert!(d.node(y).is_none());
assert!(d.node(z).is_none());
}
/// Joining the only member of a compound path into another path removes the empty compound parent.
#[test]
fn join_removes_empty_compound_parent() {
let mut s = session();
let a = line(&mut s, 0.0, 0.0, 10.0, 0.0);
let b = line(&mut s, 12.0, 0.0, 20.0, 0.0);
// Make b into a compound path (single open path child)
s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
let cid = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap());
// Join a and the compound path
s.execute("select.set", &json!({"ids": [a.0, cid.0]})).unwrap();
s.execute("path.join", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
// Compound path container should be removed since its only member was joined into a.
assert!(d.node(cid).is_none());
assert!(d.node(a).is_some());
let pd = d.node(a).unwrap().path_data().unwrap();
assert_eq!(pd.subpaths[0].anchors.len(), 4);
}
/// Join with three or more open paths selected joins them all into a single path (Illustrator parity).
#[test]
fn join_three_or_more_paths() {
let mut s = session();
// Path 1: (0, 0) -> (10, 0)
let p1 = line(&mut s, 0.0, 0.0, 10.0, 0.0);
// Path 2: (12, 0) -> (20, 0)
let p2 = line(&mut s, 12.0, 0.0, 20.0, 0.0);
// Path 3: (22, 0) -> (30, 0)
let p3 = line(&mut s, 22.0, 0.0, 30.0, 0.0);
// Path 4: (32, 0) -> (40, 0)
let p4 = line(&mut s, 32.0, 0.0, 40.0, 0.0);
s.execute("select.set", &json!({"ids": [p1.0, p2.0, p3.0, p4.0]})).unwrap();
s.execute("path.join", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
let remaining_id = s.doc().unwrap().selection.objects[0];
let pd = d.node(remaining_id).unwrap().path_data().unwrap();
assert_eq!(pd.subpaths.len(), 1);
assert!(!pd.is_closed());
assert_eq!(pd.subpaths[0].anchors.len(), 8);
let xs: Vec<f64> = pd.subpaths[0].anchors.iter().map(|a| a.p.x).collect();
assert_eq!(xs, vec![0.0, 10.0, 12.0, 20.0, 22.0, 30.0, 32.0, 40.0]);
}
/// Join with compound path containing three open member paths joins them all into one.
#[test]
fn compound_path_join_three_open_members() {
let mut s = session();
let a = line(&mut s, 0.0, 0.0, 10.0, 0.0);
let b = line(&mut s, 15.0, 0.0, 25.0, 0.0);
let c = line(&mut s, 30.0, 0.0, 40.0, 0.0);
s.execute("select.set", &json!({"ids": [a.0, b.0, c.0]})).unwrap();
let cid = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap());
s.execute("path.join", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
let cnode = d.node(cid).unwrap();
let ch = cnode.children().unwrap();
assert_eq!(ch.len(), 1);
let pd = ch[0].path_data().unwrap();
assert!(!pd.is_closed());
assert_eq!(pd.subpaths[0].anchors.len(), 6);
assert_eq!(s.doc().unwrap().selection.objects, vec![cid]);
}
/// Joining one subpath of a multi-subpath node retains its other subpaths.
#[test]
fn join_multi_subpath_retains_unjoined_subpaths() {
let mut s = session();
let a = line(&mut s, 0.0, 0.0, 100.0, 0.0);
let b = line(&mut s, 200.0, 0.0, 300.0, 0.0);
// Add a second open subpath to b.
let second_sub = vectorcraft_geom::SubPath::polyline(&[Point::new(400.0, 50.0), Point::new(500.0, 50.0)], false);
s.edit("Add Subpath", |d, _| {
if let NodeKind::Path { path, .. } = &mut d.node_mut(b).unwrap().kind {
path.subpaths.push(second_sub);
}
Ok(())
})
.unwrap();
// Direct-select an endpoint on a and an endpoint on b's second subpath.
s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 1]], "mode": "set"})).unwrap();
s.execute("select.anchors", &json!({"id": b.0, "anchors": [[1, 0]], "mode": "add"})).unwrap();
s.execute("path.join", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
// Node b still exists because its first subpath was not consumed.
assert!(d.node(b).is_some());
let pb = d.node(b).unwrap().path_data().unwrap();
assert_eq!(pb.subpaths.len(), 1);
assert_eq!(pb.subpaths[0].anchors[0].p, Point::new(200.0, 0.0));
// Node a now has the joined subpath.
let pa = d.node(a).unwrap().path_data().unwrap();
assert_eq!(pa.subpaths.len(), 1);
assert_eq!(pa.subpaths[0].anchors.len(), 4);
}
/// The Pen tool does not continue or join guide paths.
#[test]
fn pen_does_not_continue_or_join_guide_paths() {
use vectorcraft_tools::Cursor;
let (v, none) = (view(), Mods::default());
let mut s = session();
let g = line(&mut s, 100.0, 100.0, 200.0, 100.0);
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
s.execute("view.guides.make", &json!({})).unwrap();
s.select_tool("pen", v).unwrap();
// Hovering over the guide endpoint does not offer PenContinue.
assert_ne!(s.cursor(Point::new(200.0, 100.0), none, v), Cursor::PenContinue);
}
/// Joining two open subpaths within the same path node merges them into one subpath.
#[test]
fn join_two_subpaths_of_same_path_node() {
let mut s = session();
let p = line(&mut s, 0.0, 0.0, 100.0, 0.0);
let sub2 = vectorcraft_geom::SubPath::polyline(&[Point::new(150.0, 0.0), Point::new(250.0, 0.0)], false);
s.edit("Add Subpath", |d, _| {
if let NodeKind::Path { path, .. } = &mut d.node_mut(p).unwrap().kind {
path.subpaths.push(sub2);
}
Ok(())
})
.unwrap();
// Direct-select anchor (0, 1) and anchor (1, 0) of the same node.
s.execute("select.anchors", &json!({"id": p.0, "anchors": [[0, 1], [1, 0]], "mode": "set"})).unwrap();
s.execute("path.join", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
let pd = d.node(p).unwrap().path_data().unwrap();
assert_eq!(pd.subpaths.len(), 1);
assert!(!pd.subpaths[0].closed);
assert_eq!(pd.subpaths[0].anchors.len(), 4);
assert_eq!(pd.subpaths[0].anchors[0].p, Point::new(0.0, 0.0));
assert_eq!(pd.subpaths[0].anchors[1].p, Point::new(100.0, 0.0));
assert_eq!(pd.subpaths[0].anchors[2].p, Point::new(150.0, 0.0));
assert_eq!(pd.subpaths[0].anchors[3].p, Point::new(250.0, 0.0));
}
/// Direct-selecting 2 open endpoints in a complex compound path with multiple open segments joins only those 2.
#[test]
fn join_complex_compound_path_direct_endpoints() {
let mut s = session();
let c1 = line(&mut s, 0.0, 0.0, 50.0, 0.0);
let c2 = line(&mut s, 100.0, 0.0, 150.0, 0.0);
let c3 = line(&mut s, 200.0, 0.0, 250.0, 0.0);
s.execute("select.set", &json!({"ids": [c1.0, c2.0, c3.0]})).unwrap();
let comp = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap());
// Direct-select the end of c1 and the start of c3.
s.execute("select.anchors", &json!({"id": c1.0, "anchors": [[0, 1]], "mode": "set"})).unwrap();
s.execute("select.anchors", &json!({"id": c3.0, "anchors": [[0, 0]], "mode": "add"})).unwrap();
s.execute("path.join", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
let comp_node = d.node(comp).unwrap();
let children = comp_node.children().unwrap();
// c3 was joined into c1, c2 remains untouched. Total 2 children in compound.
assert_eq!(children.len(), 2);
let joined = children.iter().find(|c| c.id == c1).unwrap().path_data().unwrap();
assert_eq!(joined.subpaths.len(), 1);
assert_eq!(joined.subpaths[0].anchors.len(), 4);
assert_eq!(joined.subpaths[0].anchors[0].p, Point::new(0.0, 0.0));
assert_eq!(joined.subpaths[0].anchors[1].p, Point::new(50.0, 0.0));
assert_eq!(joined.subpaths[0].anchors[2].p, Point::new(200.0, 0.0));
assert_eq!(joined.subpaths[0].anchors[3].p, Point::new(250.0, 0.0));
let intact = children.iter().find(|c| c.id == c2).unwrap().path_data().unwrap();
assert_eq!(intact.subpaths.len(), 1);
assert_eq!(intact.subpaths[0].anchors.len(), 2);
}
/// Continuing a complex compound path with the Pen tool begins at the exact clicked anchor and joins to another open segment.
#[test]
fn pen_continue_and_join_specific_open_subpath_in_compound() {
let v = view();
let mut s = session();
let c1 = line(&mut s, 10.0, 100.0, 60.0, 100.0);
let c2 = line(&mut s, 10.0, 200.0, 60.0, 200.0);
s.execute("select.set", &json!({"ids": [c1.0, c2.0]})).unwrap();
let comp = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap());
s.select_tool("pen", v).unwrap();
// Click at the end of c1 (60, 100) to continue drawing c1.
s.pointer(&PointerEvent::new(PointerKind::Down, 60.0, 100.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 60.0, 100.0), v).unwrap();
// Now click at the start of c2 (10, 200) to join c1 and c2.
s.pointer(&PointerEvent::new(PointerKind::Down, 10.0, 200.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 10.0, 200.0), v).unwrap();
let d = &s.doc().unwrap().doc;
let comp_node = d.node(comp).unwrap();
let children = comp_node.children().unwrap();
assert_eq!(children.len(), 1);
let pd = children[0].path_data().unwrap();
assert_eq!(pd.subpaths[0].anchors.len(), 4);
assert_eq!(pd.subpaths[0].anchors[0].p, Point::new(10.0, 100.0));
assert_eq!(pd.subpaths[0].anchors[1].p, Point::new(60.0, 100.0));
assert_eq!(pd.subpaths[0].anchors[2].p, Point::new(10.0, 200.0));
assert_eq!(pd.subpaths[0].anchors[3].p, Point::new(60.0, 200.0));
}
#[test]
fn join_rejects_ends_with_more_than_two_paths() {
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
let b = rect(&mut s, 40.0, 10.0, 20.0, 20.0);
let c = rect(&mut s, 70.0, 10.0, 20.0, 20.0);
let err = s.execute("path.join", &json!({"ids": [a.0, b.0, c.0], "ends": ["first", "last"]})).unwrap_err();
assert!(err.to_string().contains("`ends` applies to joining exactly two paths"));
}
#[test]
fn join_removes_empty_compound_when_last_member_joined() {
let mut s = session();
let p1 = NodeId(s.execute("path.create", &json!({"d": "M10 10 L50 10"})).unwrap()["id"].as_u64().unwrap());
let p2 = NodeId(s.execute("path.create", &json!({"d": "M60 10 L100 10"})).unwrap()["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [p2.0]})).unwrap();
let comp = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap());
assert!(matches!(s.doc().unwrap().doc.node(comp).unwrap().kind, NodeKind::Compound { .. }));
// Join p1 and compound containing p2
s.execute("path.join", &json!({"ids": [p1.0, comp.0]})).unwrap();
assert!(s.doc().unwrap().doc.node(comp).is_none(), "empty compound container was removed");
}
#[test]
fn test_join_direct_selected_two_anchors() {
for (pick_a, pick_b, exp_junction) in [(0, 0, (0.0, 105.0)), (0, 1, (0.0, 200.0)), (1, 0, (100.0, 105.0)), (1, 1, (100.0, 200.0))] {
let mut s = session();
let a = line(&mut s, 0.0, 0.0, 100.0, 0.0);
let b = line(&mut s, 105.0, 0.0, 200.0, 0.0);
s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, pick_a]], "mode": "set"})).unwrap();
s.execute("select.anchors", &json!({"id": b.0, "anchors": [[0, pick_b]], "mode": "toggle"})).unwrap();
s.execute("path.join", &json!({})).unwrap();
let pd = path(&s, a);
let pts: Vec<f64> = pd.subpaths[0].anchors.iter().map(|x| x.p.x).collect();
// Check junction: there must be consecutive points matching exp_junction
let connected =
pts.windows(2).any(|w| (w[0] == exp_junction.0 && w[1] == exp_junction.1) || (w[0] == exp_junction.1 && w[1] == exp_junction.0));
assert!(connected, "pick_a={pick_a}, pick_b={pick_b}, exp={exp_junction:?}, got pts={pts:?}");
}
}
#[test]
fn test_join_direct_selected_after_whole_selection() {
let mut s = session();
let a = line(&mut s, 0.0, 0.0, 100.0, 0.0);
let b = line(&mut s, 105.0, 0.0, 200.0, 0.0);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
// User wants to join (0, 0) and (200, 0): pick a's anchor 0 and b's anchor 1
s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]], "mode": "toggle"})).unwrap();
s.execute("select.anchors", &json!({"id": b.0, "anchors": [[0, 1]], "mode": "toggle"})).unwrap();
s.execute("path.join", &json!({})).unwrap();
let pd = path(&s, a);
let pts: Vec<f64> = pd.subpaths[0].anchors.iter().map(|x| x.p.x).collect();
let connected = pts.windows(2).any(|w| (w[0] == 0.0 && w[1] == 200.0) || (w[0] == 200.0 && w[1] == 0.0));
assert!(connected, "expected 0.0 and 200.0 to be connected, got pts={pts:?}");
}
/// Review #2: the Pen sends the subpath it continues even when that is subpath 0, so the engine
/// never falls back to the last open subpath (append, close) or to every subpath (reverse).
#[test]
fn pen_continues_and_reverses_subpath_zero_of_a_path_with_two_open_subpaths() {
let v = view();
let mut s = session();
let id = NodeId(s.execute("path.create", &json!({"d": "M0 0 L100 0 M0 50 L100 50"})).unwrap()["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.select_tool("pen", v).unwrap();
// Continue from the end of subpath 0: the new anchor must land on subpath 0, not on subpath 1.
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 0.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 100.0, 0.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 0.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 200.0, 0.0), v).unwrap();
let pd = path(&s, id);
assert_eq!(pd.subpaths[0].anchors.len(), 3, "the new anchor extends subpath 0");
assert_eq!(pd.subpaths[1].anchors.len(), 2, "subpath 1 is left alone");
assert_eq!(pd.subpaths[0].anchors[2].p, Point::new(200.0, 0.0));
// Continue from the start of subpath 0: only subpath 0 reverses, the other keeps its direction.
let mut s = session();
let id = NodeId(s.execute("path.create", &json!({"d": "M0 0 L100 0 M0 50 L100 50"})).unwrap()["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.select_tool("pen", v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 0.0, 0.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 0.0, 0.0), v).unwrap();
let pd = path(&s, id);
assert_eq!(pd.subpaths[0].anchors[0].p, Point::new(100.0, 0.0), "subpath 0 was reversed");
assert_eq!(pd.subpaths[1].anchors[0].p, Point::new(0.0, 50.0), "subpath 1 keeps its direction");
}
/// Review #5: Join closes the first open subpath, not subpath 0, so a closed subpath 0 doesn't
/// make it fail with "path is already closed".
#[test]
fn join_closes_the_open_subpath_when_subpath_zero_is_closed() {
for explicit in [false, true] {
let mut s = session();
let id = NodeId(s.execute("path.create", &json!({"d": "M0 0 L10 0 L10 10 Z M50 0 L90 0 L90 40"})).unwrap()["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
let params = if explicit { json!({"ids": [id.0]}) } else { json!({}) };
s.execute("path.join", &params).unwrap();
let pd = path(&s, id);
assert!(pd.subpaths[0].closed && pd.subpaths[1].closed, "both subpaths are closed now: {params}");
assert_eq!(pd.subpaths[0].anchors.len(), 3, "the closed subpath is untouched");
}
}
/// Review #7: more than two selected path ends can't name one join, so it is refused rather than
/// silently joining every selected path.
#[test]
fn join_refuses_more_than_two_selected_ends() {
let mut s = session();
let a = line(&mut s, 0.0, 0.0, 10.0, 0.0);
let b = line(&mut s, 20.0, 0.0, 30.0, 0.0);
let c = line(&mut s, 40.0, 0.0, 50.0, 0.0);
s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]], "mode": "set"})).unwrap();
s.execute("select.anchors", &json!({"id": b.0, "anchors": [[0, 0]], "mode": "add"})).unwrap();
s.execute("select.anchors", &json!({"id": c.0, "anchors": [[0, 0]], "mode": "add"})).unwrap();
let before = s.doc().unwrap().doc.clone();
let err = s.execute("path.join", &json!({})).unwrap_err();
assert!(err.to_string().contains("exactly two"), "{err}");
assert_eq!(s.doc().unwrap().doc, before, "nothing was joined");
}
/// Review #7: joining a path to one subpath of a node that has other subpaths leaves that node and
/// its remaining subpaths in place, also when more paths follow.
#[test]
fn join_keeps_the_other_subpaths_of_a_joined_node() {
let mut s = session();
let a = line(&mut s, 0.0, 0.0, 10.0, 0.0);
let b = NodeId(s.execute("path.create", &json!({"d": "M10 0 L20 0 M100 100 L110 100"})).unwrap()["id"].as_u64().unwrap());
let c = line(&mut s, 20.0, 0.0, 30.0, 0.0);
s.execute("path.join", &json!({"ids": [a.0, b.0, c.0]})).unwrap();
assert_eq!(path(&s, a).subpaths[0].anchors.len(), 4, "a, b's first subpath and c became one");
let rest = path(&s, b);
assert_eq!(rest.subpaths.len(), 1, "b keeps its other subpath");
assert_eq!(rest.subpaths[0].anchors[0].p, Point::new(100.0, 100.0));
assert!(s.doc().unwrap().doc.node(c).is_none(), "c was consumed");
}
/// Review #7: an empty subpath is refused with an error, not an underflow.
#[test]
fn join_two_refuses_an_empty_subpath() {
let mut s = session();
let a = line(&mut s, 0.0, 0.0, 10.0, 0.0);
let b = line(&mut s, 20.0, 0.0, 30.0, 0.0);
s.edit("Empty subpath", |d, _| {
if let NodeKind::Path { path, .. } = &mut d.node_mut(a).unwrap().kind {
path.subpaths[0] = vectorcraft_geom::SubPath::new(vec![], false);
}
Ok(())
})
.unwrap();
assert!(s.execute("path.join", &json!({"ids": [a.0, b.0], "ends": ["last", "first"], "subpaths": [0, 0]})).is_err());
}