From 2a1aca81f058a878bcd6f2170a406efe2f76c4b9 Mon Sep 17 00:00:00 2001 From: el-j Date: Sat, 24 Oct 2026 00:27:05 +0200 Subject: [PATCH 1/4] M2.1: allow Pen tool and path.join to continue, close, and join compound paths (#3274, #3276, #3379, #3380) --- crates/engine/src/cmd/object.rs | 11 +- crates/engine/src/cmd/path.rs | 634 +++++++++++++++--- crates/engine/src/cmd/select.rs | 6 +- crates/engine/src/tests_draw2.rs | 552 ++++++++++++++- crates/tools/src/pen.rs | 295 +++++--- .../src/i18n/untranslated_messages.txt | 7 + 6 files changed, 1286 insertions(+), 219 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 93cdfd74a..cdc43c51f 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -883,6 +883,10 @@ fn show_all(s: &mut Session, _: &Value) -> Result { 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 { 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 { // 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]| 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() { diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index f55378a34..94667d776 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -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 { "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 { "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 { "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 { 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 { 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 { 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 { 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 { 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 { + let NodeKind::Compound { children, .. } = &n.kind else { return vec![] }; + let open: Vec = + 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> { 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 = + 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 Result { 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 { 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 = 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 = 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) -> 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) -> 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 { 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 { 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::>()).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::>().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 { 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::>()).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) -> Result { + 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) -> 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 { @@ -624,6 +1042,7 @@ pub(crate) fn delete_anchors(s: &mut Session, _: &Value) -> Result { 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 { 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) -> Vec { } /// Remove connections, splitting open/closed contours into their surviving open runs. -fn without_segments(sp: SubPath, removed: &BTreeSet) -> Vec { +pub(super) fn without_segments(sp: SubPath, removed: &BTreeSet) -> Vec { let cuts: BTreeSet = removed.iter().copied().filter(|&i| i < sp.segment_count()).collect(); let Some(&first) = cuts.first() else { return vec![sp] }; let n = sp.anchors.len(); diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index 2f39fe472..8ec804c13 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -471,8 +471,10 @@ fn anchors(s: &mut Session, p: &Value) -> Result { 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() { diff --git a/crates/engine/src/tests_draw2.rs b/crates/engine/src/tests_draw2.rs index cc247ee8a..d5278c566 100644 --- a/crates/engine/src/tests_draw2.rs +++ b/crates/engine/src/tests_draw2.rs @@ -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 = 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 = 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 = 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", ¶ms).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()); +} diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index 85168a4b2..2b7bf12e8 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -30,7 +30,7 @@ use vectorcraft_doc::{NodeId, NodeKind}; use vectorcraft_geom::{BezPath, Point, Vec2}; use crate::direct::hit_handle; -use crate::draw2::{AnchorTool, editable_paths}; +use crate::draw2::{AnchorTool, editable_paths, shown_paths}; use crate::guides::{DrawSnap, Leave}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; @@ -40,6 +40,8 @@ pub struct PenTool { drawing: bool, /// ...this one, once the engine made it: another path selected meanwhile isn't drawn on. path: Option, + /// The subpath of `path` being drawn or continued. + subpath: Option, /// The anchor a press placed, while the drag shapes its handles. drag: Option, hover: Option, @@ -72,48 +74,110 @@ const DRAG_THRESHOLD: f64 = 3.0; struct Place { at: Point, path: Option, + subpath: Option, closing: bool, } -/// The open path the pen is extending: the single selected open path. -fn active_path(cx: &ToolContext) -> Option<(NodeId, Point, Point, Point)> { +/// The open path the pen is extending: the single selected open path, or the single open path of a +/// single selected compound path. +fn active_path(cx: &ToolContext) -> Option<(NodeId, usize, Point, Point, Point)> { if cx.selection.objects.len() != 1 { return None; } - let id = cx.selection.objects[0]; + let id = cx.selection.objects.first().copied()?; let n = cx.doc.node(id)?; - let NodeKind::Path { path, .. } = &n.kind else { return None }; - let sp = path.subpaths.last()?; - if sp.closed { + let path_node = match &n.kind { + NodeKind::Path { .. } => Some(n), + NodeKind::Compound { children, .. } => { + let open: Vec<&vectorcraft_doc::Node> = + children.iter().filter(|c| c.path_data().is_some_and(|pd| pd.subpaths.iter().any(|sp| !sp.closed))).map(|c| c.as_ref()).collect(); + if open.len() == 1 { open.first().copied() } else { None } + } + _ => None, + }?; + let path = path_node.path_data()?; + let open_subs: Vec<(usize, &vectorcraft_geom::SubPath)> = path.subpaths.iter().enumerate().filter(|(_, s)| !s.closed).collect(); + if open_subs.len() != 1 { return None; } + let (si, sp) = open_subs[0]; let first = sp.anchors.first()?.p; let last = sp.anchors.last()?; - Some((id, first, last.p, last.h_out)) + Some((path_node.id, si, first, last.p, last.h_out)) } -/// An end of an open path (one subpath, not `except`) under `p`, within `tol`: the path the Pen -/// continues or joins to, and whether it's the path's first anchor. Only paths that can be edited, -/// not guides. -fn open_end_at(cx: &ToolContext, p: Point, tol: f64, except: Option) -> Option<(NodeId, bool)> { - let id = vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options())?.leaf; - if Some(id) == except || !cx.doc.is_editable(id) { +/// Checks if an editable endpoint on path `id` lies within `tol` of point `p`, returning the endpoint info and distance. +fn endpoint_on(cx: &ToolContext, id: NodeId, p: Point, tol: f64, except: Option<(NodeId, usize)>) -> Option<((NodeId, usize, bool), f64)> { + if !cx.doc.is_editable(id) || cx.doc.node(id).is_some_and(|n| matches!(&n.kind, NodeKind::Path { guide: true, .. })) { return None; } - let NodeKind::Path { path, guide: false, .. } = &cx.doc.node(id)?.kind else { return None }; - let [sp] = path.subpaths.as_slice() else { return None }; - if sp.closed { - return None; + let pd = cx.doc.node(id).and_then(|n| n.path_data())?; + let mut best: Option<((NodeId, usize, bool), f64)> = None; + for (si, sp) in pd.subpaths.iter().enumerate() { + if Some((id, si)) == except || sp.closed || sp.anchors.is_empty() { + continue; + } + let first = sp.anchors.first().map(|a| a.p); + let Some(last) = sp.anchors.last().map(|a| a.p) else { continue }; + if let Some(end) = pick_endpoint(first, last, p, tol, Endpoint::Last) { + let pt = match end { + Endpoint::First => first.unwrap_or(last), + Endpoint::Last => last, + }; + let dist = p.distance(pt); + if best.as_ref().is_none_or(|(_, d)| dist < *d) { + best = Some(((id, si, end == Endpoint::First), dist)); + } + } } - let (first, last) = (sp.anchors.first()?.p, sp.anchors.last()?.p); - let end = pick_endpoint(Some(first), last, p, tol, Endpoint::Last)?; - Some((id, end == Endpoint::First)) + best } -/// The last anchor of `id`'s last subpath: (subpath, anchor). -fn last_anchor(cx: &ToolContext, id: NodeId) -> Option<(usize, usize)> { +/// An end of an open path (any editable path, including inside groups and compound paths, not `except`) +/// under `p`, within `tol`: the path the Pen continues or joins to, and whether it's the path's first anchor. +fn open_end_at(cx: &ToolContext, p: Point, tol: f64, except: Option<(NodeId, usize)>) -> Option<(NodeId, usize, bool)> { + let mut best: Option<((NodeId, usize, bool), f64)> = None; + // 1. Shown paths (anchors visible because object, group, or compound path is selected). + for id in shown_paths(cx) { + if let Some(cand) = endpoint_on(cx, id, p, tol, except) + && best.as_ref().is_none_or(|(_, d)| cand.1 < *d) + { + best = Some(cand); + } + } + if best.is_some() { + return best.map(|(res, _)| res); + } + // 2. Hit-tested object (clicked directly on an unselected path, group, or compound path). + if let Some(hit) = vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options()) { + let leaf = hit.leaf; + if cx.doc.is_editable(leaf) { + if let Some(cand) = endpoint_on(cx, leaf, p, tol, except) { + return Some(cand.0); + } + if let Some(n) = cx.doc.node(leaf) + && let NodeKind::Compound { children, .. } = &n.kind + { + for c in children { + if let Some(cand) = endpoint_on(cx, c.id, p, tol, except) + && best.as_ref().is_none_or(|(_, d)| cand.1 < *d) + { + best = Some(cand); + } + } + } + } + } + best.map(|(res, _)| res) +} + +/// The last anchor of `id`'s subpath: (subpath, anchor). +fn last_anchor(cx: &ToolContext, id: NodeId, subpath: Option) -> Option<(usize, usize)> { let pd = cx.doc.node(id)?.path_data()?; - let si = pd.subpaths.len().checked_sub(1)?; + let si = match subpath { + Some(si) if si < pd.subpaths.len() => si, + _ => pd.subpaths.len().checked_sub(1)?, + }; let ai = pd.subpaths.get(si)?.anchors.len().checked_sub(1)?; Some((si, ai)) } @@ -181,8 +245,12 @@ impl Tool for PenTool { fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { let tol = cx.tol(5.0).max(cx.point_tol()); // The path a press began is the selected one by the next event. - if self.drawing && self.path.is_none() { - self.path = active_path(cx).map(|a| a.0); + if self.drawing + && self.path.is_none() + && let Some(a) = active_path(cx) + { + self.path = Some(a.0); + self.subpath = Some(a.1); } let active = self.active(cx); if self.drawing && active.is_none() && ev.kind == PointerKind::Down { @@ -190,10 +258,12 @@ impl Tool for PenTool { } // The path being drawn moves its bounds as it grows: only its anchors and segments pull. let drawn = active.map(|a| a.0); - let from = active.map(|(_, _, last, handle)| { + + let from = active.map(|(_, _, _, last, handle)| { let leave = Leave::segment(cx, last, ev.mods.shift); if handle == last { leave } else { leave.without_contact() } }); + let p = match ev.kind { PointerKind::Move => self.snap.hover(cx, ev.pos, drawn.as_slice(), from.as_ref()), PointerKind::Down => self.snap.press(cx, ev.pos, drawn.as_slice(), from.as_ref()), @@ -208,15 +278,16 @@ impl Tool for PenTool { self.last = ev.pos; self.pressed = ev.pos; self.dragged = false; - if let Some((id, first, last, _)) = active { - let end = last_anchor(cx, id); + if let Some((id, si, first, last, _)) = active { + let end = last_anchor(cx, id, Some(si)); let closing_end = end.filter(|(_, ai)| *ai > 0).map(|_| first); let picked = pick_endpoint(closing_end, last, ev.pos, tol, Endpoint::First); // Pick at the raw pointer before snapping or constraining a new point. // A one-anchor path doesn't close on itself: its anchor is the last one too. if picked == Some(Endpoint::First) { - self.drag = Some(Place { at: first, path: Some(id), closing: true }); - return vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))]; + self.drag = Some(Place { at: first, path: Some(id), subpath: Some(si), closing: true }); + let params = json!({"id": id.0, "subpath": si}); + return vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), params)]; } // Alt uses Anchor Point behavior at the terminal anchor too. if let Some(acts) = self.alt_convert(cx, ev) { @@ -228,12 +299,13 @@ impl Tool for PenTool { self.handle = Some((id, si, ai, last)); return vec![Action::Begin("Convert Anchor Point".into()), set_out_handle(id, si, ai, last)]; } - let raw_other = open_end_at(cx, ev.pos, tol, Some(id)); + let raw_other = open_end_at(cx, ev.pos, tol, Some((id, si))); if raw_other.is_none() && p != ev.pos { let snapped = pick_endpoint(closing_end, last, p, tol, Endpoint::First); if snapped == Some(Endpoint::First) { - self.drag = Some(Place { at: first, path: Some(id), closing: true }); - return vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))]; + self.drag = Some(Place { at: first, path: Some(id), subpath: Some(si), closing: true }); + let params = json!({"id": id.0, "subpath": si}); + return vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), params)]; } if snapped == Some(Endpoint::Last) && let Some((si, ai)) = end @@ -243,14 +315,17 @@ impl Tool for PenTool { } } // An end of another open path: the two become one, and the path is finished. - if let Some((other, at_first)) = raw_other.or_else(|| open_end_at(cx, p, tol, Some(id))) { + if let Some((other, other_si, at_first)) = raw_other.or_else(|| open_end_at(cx, p, tol, Some((id, si)))) { self.stop(); let end = if at_first { "first" } else { "last" }; - return vec![Action::Exec("path.join".into(), json!({"ids": [id.0, other.0], "ends": ["last", end]}))]; + let mut params = json!({"ids": [id.0, other.0], "ends": ["last", end]}); + params["subpaths"] = json!([si, other_si]); + return vec![Action::Exec("path.join".into(), params)]; } - self.drag = Some(Place { at: p, path: Some(id), closing: false }); + self.drag = Some(Place { at: p, path: Some(id), subpath: Some(si), closing: false }); self.in_h = p; - return vec![Action::Begin("Pen".into()), Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": p.x, "y": p.y}))]; + let params = json!({"id": id.0, "x": p.x, "y": p.y, "subpath": si}); + return vec![Action::Begin("Pen".into()), Action::Preview("path.appendAnchor".into(), params)]; } if let Some(acts) = self.alt_convert(cx, ev) { return acts; @@ -261,22 +336,35 @@ impl Tool for PenTool { // A raw endpoint anywhere wins before either snapped continuation route. for at in std::iter::once(ev.pos).chain((p != ev.pos).then_some(p)) { // Continue a selected open path when clicking on one of its ends. - if let Some((id, first, last, _)) = active_path(cx) + if let Some((id, si, first, last, _)) = active_path(cx) && let Some(picked) = pick_endpoint(Some(first), last, at, tol, Endpoint::Last) { self.drawing = true; self.path = Some(id); + self.subpath = Some(si); if picked == Endpoint::First { - return vec![Action::Exec("path.reverse".into(), json!({}))]; + let p = + if cx.selection.objects.as_slice() == [id] { json!({"subpath": si}) } else { json!({"ids": [id.0], "subpath": si}) }; + return vec![Action::Exec("path.reverse".into(), p)]; } return vec![]; } // Continue any other open path from the end clicked, selecting it. - if let Some((id, at_first)) = open_end_at(cx, at, tol, None) { - (self.drawing, self.path) = (true, Some(id)); - let mut out = vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]; + if let Some((id, si, at_first)) = open_end_at(cx, at, tol, None) { + (self.drawing, self.path, self.subpath) = (true, Some(id), Some(si)); + let top = cx + .doc + .parent_of(id) + .filter(|p| matches!(cx.doc.node(*p).map(|n| &n.kind), Some(NodeKind::Compound { .. }))) + .unwrap_or(id); + let mut out = vec![]; + if cx.selection.objects.as_slice() != [top] { + out.push(Action::Exec("select.set".into(), json!({"ids": [top.0]}))); + } if at_first { - out.push(Action::Exec("path.reverse".into(), json!({"ids": [id.0]}))); + let p = + if cx.selection.objects.as_slice() == [id] { json!({"subpath": si}) } else { json!({"ids": [id.0], "subpath": si}) }; + out.push(Action::Exec("path.reverse".into(), p)); } return out; } @@ -284,8 +372,8 @@ impl Tool for PenTool { if let Some(act) = auto_add_delete(cx, ev.pos, ev.mods, tol) { return vec![act]; } - (self.drawing, self.path) = (true, None); - self.drag = Some(Place { at: p, path: None, closing: false }); + (self.drawing, self.path, self.subpath) = (true, None, None); + self.drag = Some(Place { at: p, path: None, subpath: None, closing: false }); vec![Action::Begin("Pen".into()), Action::Preview("path.create".into(), json!({"anchors": [{"x": p.x, "y": p.y}]}))] } PointerKind::Drag => { @@ -309,13 +397,13 @@ impl Tool for PenTool { } return vec![set_out_handle(id, si, ai, h)]; } - let Some(Place { at: mut a, path, closing }) = self.drag else { return vec![] }; + let Some(Place { at: mut a, path, subpath, closing }) = self.drag else { return vec![] }; // Space held moves the anchor being placed, handles and all. if ev.mods.space && !closing { let d = ev.pos - self.last; a += d; self.in_h += d; - self.drag = Some(Place { at: a, path, closing }); + self.drag = Some(Place { at: a, path, subpath, closing }); } self.last = ev.pos; let mut out_h = ev.pos; @@ -332,19 +420,21 @@ impl Tool for PenTool { )]; }; if closing { - return vec![Action::Preview( - "path.close".into(), - json!({"id": id.0, "in": [2.0 * a.x - out_h.x, 2.0 * a.y - out_h.y], "independent": alt}), - )]; + let mut params = json!({"id": id.0, "in": [2.0 * a.x - out_h.x, 2.0 * a.y - out_h.y], "independent": alt}); + if let Some(si) = subpath { + params["subpath"] = json!(si); + } + return vec![Action::Preview("path.close".into(), params)]; } // Alt pressed mid-drag keeps the curve already shaped into the anchor: only the // outgoing handle follows. Held from the start, the incoming handle stays retracted. let in_h = if alt { self.in_h } else { a - (out_h - a) }; self.in_h = in_h; - vec![Action::Preview( - "path.appendAnchor".into(), - json!({"id": id.0, "x": a.x, "y": a.y, "in": [in_h.x, in_h.y], "out": [out_h.x, out_h.y]}), - )] + let mut params = json!({"id": id.0, "x": a.x, "y": a.y, "in": [in_h.x, in_h.y], "out": [out_h.x, out_h.y]}); + if let Some(si) = subpath { + params["subpath"] = json!(si); + } + vec![Action::Preview("path.appendAnchor".into(), params)] } PointerKind::Up => { self.snap.clear(); @@ -369,7 +459,9 @@ impl Tool for PenTool { _ => vec![], } } + /// Deactivate the pen tool, committing in-flight operations and restoring compound path selection. fn deactivate(&mut self, cx: &ToolContext) -> Vec { + let path = self.path; self.stop(); self.snap.clear(); let placing = self.drag.take().is_some(); @@ -379,6 +471,13 @@ impl Tool for PenTool { if placing || handling { acts.push(Action::Commit); } + if let Some(id) = path + && cx.selection.objects.as_slice() == [id] + && let Some(parent) = cx.doc.parent_of(id) + && cx.doc.node(parent).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. })) + { + acts.push(Action::Exec("select.set".into(), json!({"ids": [parent.0]}))); + } acts } fn overlays(&self, cx: &ToolContext) -> Vec { @@ -389,7 +488,7 @@ impl Tool for PenTool { if cx.pen_rubber_band && self.drag.is_none() && self.handle.is_none() - && let (Some((id, _, last, out)), Some(h)) = (self.active(cx), self.hover) + && let (Some((id, _, _, last, out)), Some(h)) = (self.active(cx), self.hover) { let mut bp = BezPath::new(); bp.move_to(last); @@ -413,14 +512,14 @@ impl Tool for PenTool { } let tol = cx.tol(5.0).max(cx.point_tol()); let active = self.active(cx); - if let Some((id, first, last, _)) = active { - let closing_end = last_anchor(cx, id).filter(|(_, ai)| *ai > 0).map(|_| first); + if let Some((id, si, first, last, _)) = active { + let closing_end = last_anchor(cx, id, Some(si)).filter(|(_, ai)| *ai > 0).map(|_| first); match pick_endpoint(closing_end, last, p, tol, Endpoint::First) { Some(Endpoint::First) => return Cursor::PenClose, Some(Endpoint::Last) => return Cursor::PenConvert, None => {} } - if !alt_converts(cx, p, m) && open_end_at(cx, p, tol, Some(id)).is_some() { + if !alt_converts(cx, p, m) && open_end_at(cx, p, tol, Some((id, si))).is_some() { return Cursor::PenJoin; } } @@ -435,9 +534,7 @@ impl Tool for PenTool { Some([_]) => return Cursor::PenAdd, _ => {} } - if active_path(cx).is_some_and(|(_, first, last, _)| pick_endpoint(Some(first), last, p, tol, Endpoint::Last).is_some()) - || open_end_at(cx, p, tol, None).is_some() - { + if open_end_at(cx, p, tol, None).is_some() { return Cursor::PenContinue; } match auto_add_delete(cx, p, m, tol) { @@ -454,13 +551,31 @@ impl Tool for PenTool { impl PenTool { /// The open path being drawn: the one the pen made or went on with, while it is the single /// selected open path. - fn active(&self, cx: &ToolContext) -> Option<(NodeId, Point, Point, Point)> { - active_path(cx).filter(|(id, ..)| self.drawing && self.path.is_none_or(|p| p == *id)) + fn active(&self, cx: &ToolContext) -> Option<(NodeId, usize, Point, Point, Point)> { + if self.drawing + && let Some(pid) = self.path + && (cx.selection.objects.as_slice() == [pid] + || cx.doc.parent_of(pid).is_some_and(|parent| { + cx.selection.objects.as_slice() == [parent] && cx.doc.node(parent).is_some_and(|pn| matches!(pn.kind, NodeKind::Compound { .. })) + })) + && cx.doc.is_editable(pid) + && let Some(n) = cx.doc.node(pid) + && let Some(pd) = n.path_data() + { + let (si, sp) = match self.subpath { + Some(si) => pd.subpaths.get(si).filter(|s| !s.closed).map(|s| (si, s))?, + None => pd.subpaths.iter().enumerate().rev().find(|(_, s)| !s.closed)?, + }; + let first = sp.anchors.first()?.p; + let last = sp.anchors.last()?; + return Some((pid, si, first, last.p, last.h_out)); + } + active_path(cx).filter(|(id, si, ..)| self.drawing && self.path.is_none_or(|p| p == *id) && self.subpath.is_none_or(|s| s == *si)) } /// The path is finished. fn stop(&mut self) { - (self.drawing, self.path) = (false, None); + (self.drawing, self.path, self.subpath) = (false, None, None); } /// Hand an Alt press on a selected path's handle, anchor or segment to the Anchor Point tool. @@ -550,7 +665,7 @@ mod tests { assert_eq!(sp.anchors.iter().map(|a| a.p).collect::>(), vec![first, Point::new(200.0, 300.0), last]); let mut pen = PenTool::default(); assert!(click(&mut pen, &cx, last.x, last.y, Mods::default()).is_empty()); - assert_eq!(pen.active(&cx).map(|(id, first, last, _)| (id, first, last)), Some((id, first, last))); + assert_eq!(pen.active(&cx).map(|(id, _, first, last, _)| (id, first, last)), Some((id, first, last))); assert!(!pen.busy()); let raw = Point::new(237.0, 403.0); @@ -558,8 +673,8 @@ mod tests { assert!(raw.distance(first) > tol && raw.distance(last) > tol); let from = Leave::segment(&cx, last, false); assert_eq!(DrawSnap::default().press(&cx, raw, &[id], Some(&from)), last); - assert!(open_end_at(&cx, raw, tol, Some(id)).is_none()); - assert!(open_end_at(&cx, last, tol, Some(id)).is_none()); + assert!(open_end_at(&cx, raw, tol, Some((id, 0))).is_none()); + assert!(open_end_at(&cx, last, tol, Some((id, 0))).is_none()); assert_eq!( pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, raw.x, raw.y)), @@ -569,7 +684,7 @@ mod tests { assert!(pen.drag.is_none() && pen.busy()); assert_eq!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Up, raw.x, raw.y)), vec![Action::Commit]); assert!(pen.handle.is_none() && !pen.busy()); - assert_eq!(pen.active(&cx).map(|(id, first, last, _)| (id, first, last)), Some((id, first, last))); + assert_eq!(pen.active(&cx).map(|(id, _, first, last, _)| (id, first, last)), Some((id, first, last))); } #[test] @@ -595,17 +710,17 @@ mod tests { }; let mut pen = PenTool::default(); assert!(click(&mut pen, &cx, last.x, last.y, Mods::default()).is_empty()); - assert_eq!(pen.active(&cx).map(|(id, first, last, _)| (id, first, last)), Some((id, first, last))); + assert_eq!(pen.active(&cx).map(|(id, _, first, last, _)| (id, first, last)), Some((id, first, last))); assert!(!pen.busy()); let tol = cx.tol(5.0).max(cx.point_tol()); assert!(raw.distance(first) > tol && raw.distance(last) > tol); - assert_eq!(open_end_at(&cx, raw, tol, Some(id)), Some((other, true))); + assert_eq!(open_end_at(&cx, raw, tol, Some((id, 0))), Some((other, 0, true))); let from = Leave::segment(&cx, last, false); assert_eq!(DrawSnap::default().press(&cx, raw, &[id], Some(&from)), last); assert_eq!( pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, raw.x, raw.y)), - vec![Action::Exec("path.join".into(), json!({"ids": [id.0, other.0], "ends": ["last", "first"]}))] + vec![Action::Exec("path.join".into(), json!({"ids": [id.0, other.0], "ends": ["last", "first"], "subpaths": [0, 0]}))] ); assert!(!pen.busy() && pen.active(&cx).is_none()); assert!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Up, raw.x, raw.y)).is_empty()); @@ -633,7 +748,7 @@ mod tests { assert_eq!(sp.anchors.iter().map(|a| a.p).collect::>(), vec![first, Point::new(200.0, 300.0), last]); let mut pen = PenTool::default(); assert!(click(&mut pen, &cx, last.x, last.y, Mods::default()).is_empty()); - assert_eq!(pen.active(&cx).map(|(id, first, last, _)| (id, first, last)), Some((id, first, last))); + assert_eq!(pen.active(&cx).map(|(id, _, first, last, _)| (id, first, last)), Some((id, first, last))); assert!(!pen.busy()); let raw = Point::new(63.0, 303.0); @@ -641,12 +756,12 @@ mod tests { assert!(raw.distance(first) > tol && raw.distance(last) > tol); let from = Leave::segment(&cx, last, false); assert_eq!(DrawSnap::default().press(&cx, raw, &[id], Some(&from)), first); - assert!(open_end_at(&cx, raw, tol, Some(id)).is_none()); - assert!(open_end_at(&cx, first, tol, Some(id)).is_none()); + assert!(open_end_at(&cx, raw, tol, Some((id, 0))).is_none()); + assert!(open_end_at(&cx, first, tol, Some((id, 0))).is_none()); assert_eq!( pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, raw.x, raw.y)), - vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))] + vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0, "subpath": 0}))] ); let place = pen.drag.unwrap(); assert!(place.closing); @@ -678,17 +793,17 @@ mod tests { }; let mut pen = PenTool::default(); assert!(click(&mut pen, &cx, last.x, last.y, Mods::default()).is_empty()); - assert_eq!(pen.active(&cx).map(|(id, first, last, _)| (id, first, last)), Some((id, first, last))); + assert_eq!(pen.active(&cx).map(|(id, _, first, last, _)| (id, first, last)), Some((id, first, last))); assert!(!pen.busy()); let tol = cx.tol(5.0).max(cx.point_tol()); assert!(raw.distance(first) > tol && raw.distance(last) > tol); - assert_eq!(open_end_at(&cx, raw, tol, Some(id)), Some((other, true))); + assert_eq!(open_end_at(&cx, raw, tol, Some((id, 0))), Some((other, 0, true))); let from = Leave::segment(&cx, last, false); assert_eq!(DrawSnap::default().press(&cx, raw, &[id], Some(&from)), first); assert_eq!( pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, raw.x, raw.y)), - vec![Action::Exec("path.join".into(), json!({"ids": [id.0, other.0], "ends": ["last", "first"]}))] + vec![Action::Exec("path.join".into(), json!({"ids": [id.0, other.0], "ends": ["last", "first"], "subpaths": [0, 0]}))] ); assert!(!pen.busy() && pen.active(&cx).is_none()); assert!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Up, raw.x, raw.y)).is_empty()); @@ -796,7 +911,7 @@ mod tests { acts, vec![Action::Preview( "path.appendAnchor".into(), - json!({"id": id.0, "x": 400.0, "y": 400.0, "in": [350.0, 400.0], "out": [450.0, 450.0]}) + json!({"id": id.0, "x": 400.0, "y": 400.0, "in": [350.0, 400.0], "out": [450.0, 450.0], "subpath": 0}) )] ); } @@ -865,7 +980,7 @@ mod tests { assert_eq!(t.active(&cx).map(|a| a.0), Some(id)); assert_eq!( t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 13.0, 303.0)), - vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))] + vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0, "subpath": 0}))] ); } @@ -915,7 +1030,7 @@ mod tests { let cursor = if first_end { Cursor::PenClose } else { Cursor::PenConvert }; let actual_cursor = pen.cursor(&cx, at, mods); let expected = if first_end { - vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))] + vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0, "subpath": 0}))] } else { vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 1, last.x, last.y)] }; @@ -952,7 +1067,7 @@ mod tests { let at = Point::new(first.x + offset / zoom, first.y); let mut pen = PenTool::default(); assert_eq!(pen.cursor(&cx, at, Mods::default()), Cursor::PenContinue); - let expected = if offset < 3.0 { vec![Action::Exec("path.reverse".into(), json!({}))] } else { vec![] }; + let expected = if offset < 3.0 { vec![Action::Exec("path.reverse".into(), json!({"subpath": 0}))] } else { vec![] }; let actual = click(&mut pen, &cx, at.x, at.y, Mods::default()); if actual != expected { failures.push((zoom, grid, offset, actual)); @@ -975,7 +1090,7 @@ mod tests { assert_eq!(drawing.cursor(&cx, at, Mods::default()), Cursor::PenClose); assert_eq!( click(&mut drawing, &cx, at.x, at.y, Mods::default()), - vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))] + vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0, "subpath": 0}))] ); let mut resuming = PenTool::default(); assert_eq!(resuming.cursor(&cx, at, Mods::default()), Cursor::PenContinue); @@ -1032,7 +1147,7 @@ mod tests { assert_eq!(closing.cursor(&cx, first, Mods::default()), Cursor::PenClose); assert_eq!( click(&mut closing, &cx, first.x, first.y, Mods::default()), - vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))] + vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0, "subpath": 0}))] ); let mut alt_pen = PenTool { drawing: true, ..PenTool::default() }; let alt = Mods { alt: true, ..Mods::default() }; @@ -1099,12 +1214,12 @@ mod tests { let p = paint(); let mut t = PenTool { drawing: true, ..PenTool::default() }; let a = t.pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 101.0, 301.0)); - assert_eq!(a, vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))]); + assert_eq!(a, vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0, "subpath": 0}))]); // What the tool sees once the preview applied: the path closed. d.node_mut(id).unwrap().path_data_mut().unwrap().subpaths[0].closed = true; let c = cx(&d, &s, &p); let close = |x: f64, y: f64, alt: bool| { - vec![Action::Preview("path.close".into(), json!({"id": id.0, "in": [200.0 - x, 600.0 - y], "independent": alt}))] + vec![Action::Preview("path.close".into(), json!({"id": id.0, "in": [200.0 - x, 600.0 - y], "independent": alt, "subpath": 0}))] }; for (x, y) in [(105.0, 290.0), (130.0, 260.0), (160.0, 240.0)] { assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, x, y)), close(x, y, false), "at ({x}, {y})"); @@ -1216,10 +1331,10 @@ mod tests { let to = Point::new(150.0, 318.0); assert!(o.iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &cx.segment_measure_label(from, to)))); let a = click(&mut t, &cx, 152.0, 318.0, Mods::default()); - assert_eq!(a[1], Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": 150.0, "y": 318.0}))); + assert_eq!(a[1], Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": 150.0, "y": 318.0, "subpath": 0}))); let shift = Mods { shift: true, ..Mods::default() }; let a = click(&mut t, &cx, 152.0, 310.0, shift); - assert_eq!(a[1], Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": 150.0, "y": 300.0}))); + assert_eq!(a[1], Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": 150.0, "y": 300.0, "subpath": 0}))); // The first anchor of a new path lands on the rect's corner. let mut t = PenTool::default(); let a = click(&mut t, &cx, 102.0, 99.0, Mods::default()); diff --git a/crates/ui-egui/src/i18n/untranslated_messages.txt b/crates/ui-egui/src/i18n/untranslated_messages.txt index e526314c8..f0c050348 100644 --- a/crates/ui-egui/src/i18n/untranslated_messages.txt +++ b/crates/ui-egui/src/i18n/untranslated_messages.txt @@ -34,6 +34,7 @@ JSON recovery transport exceeds 8 MiB; use the binary adapter PDF content is incomplete or exceeds the reader's limits; no document was imported PDF flattener: {e} PDF options: {e} +PDF security settings are not an object SVG is not UTF-8, UTF-16 or ISO 8859-1 Save writes one artboard: name one, or export several with document.export Select a dimension first @@ -73,6 +74,7 @@ Text options: {e} `direction` must be "auto", "leftToRight" or "rightToLeft" `each` is the value one design stands for, more than 0 `ecc` must be "L", "M", "Q" or "H" +`ends` applies to joining exactly two paths `event` must be one of {_1} `files` must be 1 to 1000 paths of font files the last text.findFontFiles search found `folder` must be an absolute folder path @@ -139,6 +141,7 @@ Text options: {e} `starts` must be an array of anchor indices (or nulls) `stop` must be a stop index `strokeMode` applies to strokes (stroke: true) +`subpaths` must be two integer indices `style` must be roman or japanese `symbol` must be a symbol name `target` must be "object" or "contents", not {v} @@ -386,6 +389,7 @@ command must be a shape.* command commands[{i}]: missing `command` commands[{i}]: unknown command `{id}` composer must be "singleLine" or "everyLine" +compound {_1} must contain exactly one open path to join copy needs path: the file to copy into the document's folder copy takes images only: relink placed documents where their files are copying a file isn't available on the web @@ -952,6 +956,7 @@ select area type and closed paths (at least one area type object) select artwork to add its colours select artwork to make a group of its colours select artwork to recolour, or give a colour `group` +select exactly two path ends to join select objects or give ids select objects to align to the guide select one Intertwine group @@ -1003,6 +1008,8 @@ subLines contains an invalid lane subLines is missing subLines must be a list or null subLines must contain at most {MAX_SUB_LINES} valid lanes +subpath {si} is not an open subpath +subpaths required when joining the same path swatchConflict must be "merge" or "add", not {v} text must be {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} text transform is collapsed or unrepresentable From 11f1d5c4d6c5fcbcb8a96df2334580dc044330bf Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 24 Oct 2026 16:08:09 -0600 Subject: [PATCH 2/4] M2.1: Pen continuation in a compound path reverses only the path it continues Clicking the first anchor of an open path inside a compound path made the Pen select the compound and then reverse through the selection, which `paths_in` expands to every member path: the clicked subpath of each sibling flipped too, and under the non-zero fill rule a hole became a fill. Name the path in the reverse instead. The adjacent continuation of an already selected path is unaffected: it issues no selection change, so its selection-addressed reverse still means that path. Regressions: an engine test continues a compound member with two open subpaths at its first anchor and holds the member's other subpath and the sibling member byte-identical (anchors, handles, closed flags); a tool test pins the emitted `path.reverse` parameters to the member's id. --- crates/engine/src/tests_draw2.rs | 26 ++++++++++++++++++ crates/tools/src/pen.rs | 45 +++++++++++++++++++++++++++++--- 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/tests_draw2.rs b/crates/engine/src/tests_draw2.rs index 3181f2155..80b3f5dc1 100644 --- a/crates/engine/src/tests_draw2.rs +++ b/crates/engine/src/tests_draw2.rs @@ -2960,3 +2960,29 @@ fn join_two_refuses_an_empty_subpath() { .unwrap(); assert!(s.execute("path.join", &json!({"ids": [a.0, b.0], "ends": ["last", "first"], "subpaths": [0, 0]})).is_err()); } + +/// Continuing a compound member at its first anchor reverses that subpath only. The Pen puts the +/// compound back in the selection before it reverses, so the reverse has to name the member path: +/// addressed through the selection it would walk the compound and flip every member's subpath. +#[test] +fn pen_continue_at_a_first_anchor_in_a_compound_reverses_only_that_subpath() { + let (v, none) = (view(), Mods::default()); + let mut s = session(); + let a = NodeId(s.execute("path.create", &json!({"d": "M100 100 L200 100 M100 150 L200 150"})).unwrap()["id"].as_u64().unwrap()); + let b = NodeId(s.execute("path.create", &json!({"d": "M300 100 L400 100 M300 150 L400 150"})).unwrap()["id"].as_u64().unwrap()); + 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()); + // The member path itself is the whole selection, as a direct selection inside the compound + // leaves it: continuing it selects the compound again, which must not widen the reverse. + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + let (before_a, before_b) = (path(&s, a), path(&s, b)); + + s.select_tool("pen", v).unwrap(); + gesture(&mut s, &[(100.0, 100.0)], none); + + let after_a = path(&s, a); + assert_eq!(after_a.subpaths[0].anchors.iter().map(|x| x.p).collect::>(), vec![Point::new(200.0, 100.0), Point::new(100.0, 100.0)]); + assert_eq!(after_a.subpaths[1], before_a.subpaths[1], "the member's other subpath keeps its anchors and handles"); + assert_eq!(path(&s, b), before_b, "the compound's other member keeps its anchors, handles and closed flags"); + assert_eq!(s.doc().unwrap().selection.objects, vec![cid], "the compound is selected again"); +} diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index 2b7bf12e8..dc04c370b 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -362,9 +362,10 @@ impl Tool for PenTool { out.push(Action::Exec("select.set".into(), json!({"ids": [top.0]}))); } if at_first { - let p = - if cx.selection.objects.as_slice() == [id] { json!({"subpath": si}) } else { json!({"ids": [id.0], "subpath": si}) }; - out.push(Action::Exec("path.reverse".into(), p)); + // Name the path: the `select.set` above may have just put its compound + // parent in the selection, and a reverse addressed through the + // selection would flip this subpath of every member of it. + out.push(Action::Exec("path.reverse".into(), json!({"ids": [id.0], "subpath": si}))); } return out; } @@ -1458,4 +1459,42 @@ mod tests { assert_eq!(t.cursor(&cx, empty, Mods::default()), Cursor::Pen, "the path is finished"); assert_eq!(Cursor::PenDrawing.precise(), Cursor::Crosshair); } + + /// Continuing an open path inside a compound path at its first anchor names the path it + /// reverses. The same press selects the compound, and `path.reverse` without `ids` would then + /// walk the compound and flip that subpath of every member. + #[test] + fn continuing_a_compound_member_at_its_first_anchor_reverses_that_path_by_id() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let (a, b, compound) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let two_subpaths = |x: f64| { + vectorcraft_geom::PathData::new(vec![ + vectorcraft_geom::SubPath::polyline(&[Point::new(x, 300.0), Point::new(x + 100.0, 300.0)], false), + vectorcraft_geom::SubPath::polyline(&[Point::new(x, 350.0), Point::new(x + 100.0, 350.0)], false), + ]) + }; + let path = |id, x| std::sync::Arc::new(vectorcraft_doc::Node::path(id, two_subpaths(x), vectorcraft_doc::Appearance::default_art())); + let mut c = vectorcraft_doc::Node::new( + compound, + NodeKind::Compound { children: vec![path(a, 10.0), path(b, 300.0)], rule: vectorcraft_geom::FillRule::NonZero }, + ); + c.appearance = vectorcraft_doc::Appearance::default_art().into(); + d.insert(Some(l), 1, c).unwrap(); + let p = paint(); + // The member path alone is the selection, as a direct selection inside the compound leaves it. + let mut s = Selection::default(); + s.set([a]); + let cx = ToolContext { smart_guides: false, snap_to_point: false, snap_to_grid: false, snap_to_pixel: false, ..cx(&d, &s, &p) }; + let mut pen = PenTool::default(); + assert_eq!( + click(&mut pen, &cx, 10.0, 300.0, Mods::default()), + vec![ + Action::Exec("select.set".into(), json!({"ids": [compound.0]})), + Action::Exec("path.reverse".into(), json!({"ids": [a.0], "subpath": 0})), + ], + "the reverse names the member path, not the compound the select.set just selected" + ); + assert_eq!((pen.path, pen.subpath), (Some(a), Some(0))); + } } From 44044d93baf10d1595ceb217a1c0a7f3fedfcd8d Mon Sep 17 00:00:00 2001 From: el-j Date: Mon, 26 Oct 2026 10:04:10 +0100 Subject: [PATCH 3/4] M2.1: honor ends on selected join and compute nearest pair for subpaths (#3274, #3276, #3379, #3380) --- crates/engine/src/cmd/path.rs | 50 ++++++++++++++++++++++++++++++-- crates/engine/src/tests_draw2.rs | 25 ++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 94667d776..3684df0c3 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -408,6 +408,14 @@ fn end_points(sp: &SubPath) -> Option<(Point, Point)> { Some((sp.anchors.first()?.p, sp.anchors.last()?.p)) } +/// Orientations (reverse first, reverse second) that bring the nearest ends together. +fn nearest_ends(sp_a: &SubPath, sp_b: &SubPath) -> Option<(bool, bool)> { + let (fa, la) = end_points(sp_a)?; + let (fb, lb) = end_points(sp_b)?; + let cands = [(la.distance(fb), false, false), (la.distance(lb), false, true), (fa.distance(fb), true, false), (fa.distance(lb), true, true)]; + cands.into_iter().min_by(|p, q| p.0.total_cmp(&q.0)).map(|(_, rx, ry)| (rx, ry)) +} + /// Corner or smooth on an anchor a join leaves behind. fn join_point(sp: &mut SubPath, ai: usize, smooth: bool) { if smooth { @@ -558,7 +566,14 @@ fn join(s: &mut Session, p: &Value) -> Result { 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)); + let st = s.doc()?; + let p = st.doc.node(id).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(id))?; + let spa = p.subpaths.get(sa).ok_or_else(|| EngineError::Other("join needs open paths".into()))?; + let spb = p.subpaths.get(sb).ok_or_else(|| EngineError::Other("join needs open paths".into()))?; + let (rx, ry) = match ends { + Some(e) => e, + None => nearest_ends(spa, spb).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)]); @@ -570,7 +585,15 @@ fn join(s: &mut Session, p: &Value) -> Result { && let Some((sa, sb)) = subpaths { let (a, b) = (ids[0], ids[1]); - let (rx, ry) = ends.unwrap_or((false, false)); + 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 spa = pa.subpaths.get(sa).ok_or_else(|| EngineError::Other("join needs open paths".into()))?; + let spb = pb.subpaths.get(sb).ok_or_else(|| EngineError::Other("join needs open paths".into()))?; + let (rx, ry) = match ends { + Some(e) => e, + None => nearest_ends(spa, spb).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)]); @@ -812,6 +835,9 @@ fn join(s: &mut Session, p: &Value) -> Result { } return Err(EngineError::Other("select paths to join".into())); } + if ends.is_some() && open_subs.len() > 2 { + return Err(bad(C, "`ends` applies to joining exactly two paths")); + } if open_subs.len() == 1 { let (id, si) = open_subs[0]; s.edit("Join", |d, sel| { @@ -821,6 +847,26 @@ fn join(s: &mut Session, p: &Value) -> Result { })?; return ok(); } + if let Some((rx, ry)) = ends + && open_subs.len() == 2 + { + let (a, sa) = open_subs[0]; + let (b, sb) = open_subs[1]; + if a == b { + s.edit("Join", |d, sel| { + join_subpaths_in_node(d, a, (sa, sb), (rx, ry), join_kind)?; + sel.set([compound_or_self(d, a)]); + Ok(()) + })?; + } else { + 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(); + } s.edit("Join", |d, sel| { let top = join_nearest(d, open_subs, join_kind)?; diff --git a/crates/engine/src/tests_draw2.rs b/crates/engine/src/tests_draw2.rs index 80b3f5dc1..528f1de90 100644 --- a/crates/engine/src/tests_draw2.rs +++ b/crates/engine/src/tests_draw2.rs @@ -2169,6 +2169,31 @@ fn join_takes_the_paths_and_the_ends_to_join() { } } +/// `path.join {ends}` on selected paths honors the requested ends, not the nearest pair. +#[test] +fn join_selected_paths_honors_ends_parameter() { + let mut s = session(); + let a = line(&mut s, 0.0, 0.0, 100.0, 0.0); + let b = line(&mut s, 110.0, 0.0, 200.0, 0.0); + s.select(|_, sel| sel.set([a, b])).unwrap(); + // The far ends: a's first to b's last (nearest pair would be a's last and b's first). + s.execute("path.join", &json!({"ends": ["first", "last"]})).unwrap(); + let pts: Vec<(f64, f64)> = path(&s, a).subpaths[0].anchors.iter().map(|x| (x.p.x, x.p.y)).collect(); + assert_eq!(pts, [(100.0, 0.0), (0.0, 0.0), (200.0, 0.0), (110.0, 0.0)]); +} + +/// `path.join {ids, subpaths}` without `ends` computes the nearest pair within those subpaths. +#[test] +fn join_subpaths_without_ends_computes_nearest_pair() { + 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, 110.0, 0.0); + // Nearest ends between a (0..100) and b (200..110) are 100 and 110. + s.execute("path.join", &json!({"ids": [a.0, b.0], "subpaths": [0, 0]})).unwrap(); + let pts: Vec<(f64, f64)> = path(&s, a).subpaths[0].anchors.iter().map(|x| (x.p.x, x.p.y)).collect(); + assert_eq!(pts, [(0.0, 0.0), (100.0, 0.0), (110.0, 0.0), (200.0, 0.0)]); +} + /// `path.join {to}` makes the joined anchor a corner or smooth point. #[test] fn join_to_corner_or_smooth_sets_the_merged_anchor() { From 1ffc04506c24899046db48c63e25ff650066a404 Mon Sep 17 00:00:00 2001 From: skylar Date: Tue, 27 Oct 2026 11:11:36 -0600 Subject: [PATCH 4/4] fix(engine): honor explicit ends when joining direct selections --- crates/engine/src/cmd/path.rs | 5 +- crates/engine/src/tests/draw2.rs | 105 +++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 3684df0c3..f56abc8f3 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -714,7 +714,10 @@ fn join(s: &mut Session, p: &Value) -> Result { } } - let direct_ends: Vec<(NodeId, usize, &'static str)> = { + // An explicit `ends` names the ends to join: the direct selection only decides when it is absent. + let direct_ends: Vec<(NodeId, usize, &'static str)> = if ends.is_some() { + vec![] + } else { let st = s.doc()?; let mut ends = vec![]; for (&id, picked) in &st.selection.anchors { diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index ee05c9ef7..7bb0a3edf 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -2394,6 +2394,111 @@ fn join_selected_paths_honors_ends_parameter() { assert_eq!(pts, [(100.0, 0.0), (0.0, 0.0), (200.0, 0.0), (110.0, 0.0)]); } +/// An explicit `ends` wins over a direct-selected end pair when `ids` is omitted, as on `main`. +#[test] +fn join_explicit_ends_win_over_direct_selected_end_points() { + let pts = |s: &Session, id: NodeId| path(s, id).subpaths[0].anchors.iter().map(|x| (x.p.x, x.p.y)).collect::>(); + // (picked anchor of a, picked anchor of b, the ends asked for, the result they ask for) + let cases = [ + // Picks say a's last to b's first (also the nearest pair); the ends ask for the far ends. + (1, 0, ["first", "last"], [(100.0, 0.0), (0.0, 0.0), (200.0, 0.0), (110.0, 0.0)]), + // Picks say a's first to b's first; the ends ask for a's last to b's first. + (0, 0, ["last", "first"], [(0.0, 0.0), (100.0, 0.0), (110.0, 0.0), (200.0, 0.0)]), + ]; + for (pick_a, pick_b, ends, want) in cases { + let mut s = session(); + let a = line(&mut s, 0.0, 0.0, 100.0, 0.0); + let b = line(&mut s, 110.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": "add"})).unwrap(); + let sel_before = s.doc().unwrap().selection.anchors.clone(); + s.execute("path.join", &json!({"ends": ends})).unwrap(); + assert_eq!(pts(&s, a), want, "ends {ends:?} with a:{pick_a} b:{pick_b} picked"); + assert!(s.doc().unwrap().doc.node(b).is_none(), "b was joined into a"); + // One undo step puts both lines, and the selection they had, back. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(pts(&s, a), [(0.0, 0.0), (100.0, 0.0)]); + assert_eq!(pts(&s, b), [(110.0, 0.0), (200.0, 0.0)]); + assert_eq!(s.doc().unwrap().selection.objects, vec![a, b], "the selection came back"); + assert_eq!(s.doc().unwrap().selection.anchors, sel_before, "with its direct-selected anchors"); + } +} + +/// The ends asked for apply to the selection order: with b selected first, both lines reverse and b keeps its id. +#[test] +fn join_explicit_ends_follow_the_selection_order() { + let mut s = session(); + let a = line(&mut s, 0.0, 0.0, 100.0, 0.0); + let b = line(&mut s, 110.0, 0.0, 200.0, 0.0); + // In selection order the picks (b's first, a's last) match the ends asked for; the head reads the + // pick map in id order (a before b), so it joins the other pair, which this pins. + s.execute("select.anchors", &json!({"id": b.0, "anchors": [[0, 0]], "mode": "set"})).unwrap(); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 1]], "mode": "add"})).unwrap(); + s.execute("path.join", &json!({"ends": ["first", "last"]})).unwrap(); + let pts: Vec<(f64, f64)> = path(&s, b).subpaths[0].anchors.iter().map(|x| (x.p.x, x.p.y)).collect(); + assert_eq!(pts, [(200.0, 0.0), (110.0, 0.0), (100.0, 0.0), (0.0, 0.0)], "b keeps its id and runs 200 to 0"); + assert!(s.doc().unwrap().doc.node(a).is_none(), "a was joined into b"); +} + +/// Two open subpaths of one path take the ends asked for, not the picked pair. +#[test] +fn join_explicit_ends_on_two_subpaths_of_one_path() { + let mut s = session(); + let id = NodeId(s.execute("path.create", &json!({"d": "M0 0 L100 0 M200 0 L110 0"})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 1], [1, 0]], "mode": "set"})).unwrap(); + // The picks say subpath 0's last to subpath 1's first; the ends ask for subpath 1's last. + s.execute("path.join", &json!({"ends": ["last", "last"]})).unwrap(); + let pd = path(&s, id); + assert_eq!(pd.subpaths.len(), 1, "the two subpaths became one"); + let pts: Vec<(f64, f64)> = pd.subpaths[0].anchors.iter().map(|x| (x.p.x, x.p.y)).collect(); + assert_eq!(pts, [(0.0, 0.0), (100.0, 0.0), (110.0, 0.0), (200.0, 0.0)]); +} + +/// `ends` names two paths: three open subpaths are refused, with and without a picked pair, and nothing changes. +#[test] +fn join_explicit_ends_refuses_more_than_two_open_subpaths() { + for picks in [false, true] { + let mut s = session(); + let a = line(&mut s, 0.0, 0.0, 100.0, 0.0); + let b = line(&mut s, 110.0, 0.0, 200.0, 0.0); + let c = line(&mut s, 210.0, 0.0, 300.0, 0.0); + s.select(|_, sel| sel.set([a, b, c])).unwrap(); + if picks { + // A picked pair cannot narrow a three-path request: two paths is what `ends` names. + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 1]], "mode": "add"})).unwrap(); + s.execute("select.anchors", &json!({"id": b.0, "anchors": [[0, 0]], "mode": "add"})).unwrap(); + } + let before = s.doc().unwrap().doc.clone(); + let sel_before = s.doc().unwrap().selection.objects.clone(); + let err = s.execute("path.join", &json!({"ends": ["first", "last"]})).unwrap_err(); + assert!(err.to_string().contains("exactly two paths"), "{err}"); + assert_eq!(s.doc().unwrap().doc, before, "nothing was joined"); + assert_eq!(s.doc().unwrap().selection.objects, sel_before, "the selection is unchanged"); + } +} + +/// Controls: explicit `ids` take `ends` over the picks, and without `ends` the picks still decide. +#[test] +fn join_direct_selected_end_points_controls() { + let pts = |s: &Session, id: NodeId| path(s, id).subpaths[0].anchors.iter().map(|x| (x.p.x, x.p.y)).collect::>(); + let pick = |s: &mut Session, a: NodeId, b: NodeId, pa: usize, pb: usize| { + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, pa]], "mode": "set"})).unwrap(); + s.execute("select.anchors", &json!({"id": b.0, "anchors": [[0, pb]], "mode": "add"})).unwrap(); + }; + let mut s = session(); + let a = line(&mut s, 0.0, 0.0, 100.0, 0.0); + let b = line(&mut s, 110.0, 0.0, 200.0, 0.0); + pick(&mut s, a, b, 1, 0); + s.execute("path.join", &json!({"ids": [a.0, b.0], "ends": ["first", "last"]})).unwrap(); + assert_eq!(pts(&s, a), [(100.0, 0.0), (0.0, 0.0), (200.0, 0.0), (110.0, 0.0)], "ids: the ends win"); + let mut s = session(); + let a = line(&mut s, 0.0, 0.0, 100.0, 0.0); + let b = line(&mut s, 110.0, 0.0, 200.0, 0.0); + pick(&mut s, a, b, 0, 0); + s.execute("path.join", &json!({})).unwrap(); + assert_eq!(pts(&s, a), [(100.0, 0.0), (0.0, 0.0), (110.0, 0.0), (200.0, 0.0)], "no ends: the picks decide"); +} + /// Without `ends`, the end points picked with the direct selection are the ones joined. #[test] fn join_uses_the_selected_end_points() {