mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-30 12:47:13 -06:00
M2.1: allow Pen tool and path.join to continue, close, and join compound paths (#3274, #3276, #3379, #3380)
This commit is contained in:
+527
-107
@@ -3,7 +3,7 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeId, NodeKind, Scaling};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Scaling};
|
||||
use vectorcraft_geom::{Anchor, AnchorKind, PathData, Point, SubPath, Vec2};
|
||||
|
||||
use super::create::anchor_from_json;
|
||||
@@ -19,11 +19,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add Anchor (Pen)",
|
||||
[],
|
||||
None,
|
||||
"{id, x, y, in?: [x,y], out?: [x,y]} append to the end of the last open subpath",
|
||||
"{id, x, y, in?: [x,y], out?: [x,y], subpath?: usize (the open subpath to extend, 0 included; an invalid or closed one is an error; default: the last open subpath)} append to the end of an open subpath",
|
||||
has_doc,
|
||||
append_anchor
|
||||
),
|
||||
cmd!("path.close", "Close Path", [], None, "{id, in?: [x,y] (handle into the first anchor), independent?: bool}", has_doc, close),
|
||||
cmd!(
|
||||
"path.close",
|
||||
"Close Path",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath?: usize (the open subpath to close, 0 included; default: the last open one), in?: [x,y] (handle into the first anchor), independent?: bool}",
|
||||
has_doc,
|
||||
close
|
||||
),
|
||||
cmd!(
|
||||
"path.moveAnchors",
|
||||
"Move Anchors",
|
||||
@@ -65,7 +73,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reverse Path Direction",
|
||||
["Object", "Path"],
|
||||
None,
|
||||
"{ids?|id?, reversed?: bool (true: every subpath runs counter-clockwise on screen, false: clockwise, the Attributes panel's Reverse Path Direction On / Off; omitted: flip each path)} the paths in ids or the selection (groups and compound paths: the paths inside), as one undo step → {changed: paths}",
|
||||
"{ids?|id?, subpath?: usize (reverse only this subpath of each path, 0 included; default: every subpath), reversed?: bool (true: every subpath runs counter-clockwise on screen, false: clockwise, the Attributes panel's Reverse Path Direction On / Off; omitted: flip each path; with subpath, the direction is not read: that subpath is flipped)} the paths in ids or the selection (groups and compound paths: the paths inside), as one undo step → {changed: paths}",
|
||||
has_doc,
|
||||
reverse
|
||||
),
|
||||
@@ -74,7 +82,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Join",
|
||||
["Object", "Path"],
|
||||
Some("Cmd+J"),
|
||||
"{ids?: [path, path] (default: the selected paths), ends?: [\"first\"|\"last\", \"first\"|\"last\"] (which end of each path to join, as the Pen joins them; default: the nearest pair), to?: \"corner\"|\"smooth\" (the joined anchor: corner retracts both handles, smooth pulls handles in line with the neighbours)} join two open paths into one (the first keeps its id, selected), or close a single open path; without ids, direct-selected anchors lying on one another along a path merge into one anchor instead",
|
||||
"{ids?: [path, ...] (default: the selected paths; a compound path ID expands to its open member paths), subpaths?: [usize, usize] (which subpaths to join), ends?: [\"first\"|\"last\", \"first\"|\"last\"] (which end of each path to join, for two paths/subpaths; default without ids: direct-selected ends if any, else the nearest pair; default with ids: the nearest pair), to?: \"corner\"|\"smooth\" (the joined anchor: corner retracts both handles, smooth pulls handles in line with the neighbours)} join two or more open paths/subpaths into one (the first keeps its id, selected), or close a single open path; without ids, direct-selected anchors lying on one another along a path merge into one anchor instead",
|
||||
has_selection,
|
||||
join
|
||||
),
|
||||
@@ -141,23 +149,42 @@ pub(super) fn path_mut(d: &mut vectorcraft_doc::Document, id: NodeId) -> Result<
|
||||
fn append_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("path.appendAnchor", "missing id"))?;
|
||||
let a = anchor_from_json(p, "path.appendAnchor")?;
|
||||
let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize);
|
||||
s.edit("Pen", |d, sel| {
|
||||
let path = path_mut(d, id)?;
|
||||
match path.subpaths.last_mut() {
|
||||
Some(sp) if !sp.closed => sp.anchors.push(a),
|
||||
_ => path.subpaths.push(SubPath::new(vec![a], false)),
|
||||
}
|
||||
sel.set([id]);
|
||||
let last = path.subpaths.len() - 1;
|
||||
let ai = path.subpaths[last].anchors.len() - 1;
|
||||
sel.anchors.insert(id, BTreeSet::from([(last, ai)]));
|
||||
let si = match subpath {
|
||||
// An explicit subpath is honoured, 0 included, or refused: never swapped for another.
|
||||
Some(si) => match path.subpaths.get_mut(si).filter(|sp| !sp.closed) {
|
||||
Some(sp) => {
|
||||
sp.anchors.push(a);
|
||||
si
|
||||
}
|
||||
None => return Err(bad("path.appendAnchor", format!("subpath {si} is not an open subpath"))),
|
||||
},
|
||||
None => match path.subpaths.iter_mut().enumerate().rev().find(|(_, sp)| !sp.closed) {
|
||||
Some((si, sp)) => {
|
||||
sp.anchors.push(a);
|
||||
si
|
||||
}
|
||||
None => {
|
||||
path.subpaths.push(SubPath::new(vec![a], false));
|
||||
path.subpaths.len() - 1
|
||||
}
|
||||
},
|
||||
};
|
||||
let ai = path.subpaths[si].anchors.len() - 1;
|
||||
let top = compound_or_self(d, id);
|
||||
sel.set([top]);
|
||||
sel.anchors.insert(id, BTreeSet::from([(si, ai)]));
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Close the active open subpath of `id`, optionally setting the incoming handle of the first anchor.
|
||||
fn close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?;
|
||||
let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize);
|
||||
let h_in = match p.get("in").filter(|h| !h.is_null()) {
|
||||
None => None,
|
||||
Some(h) => Some(finite_numbers(h).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad("path.close", "`in` must be [x, y]"))?),
|
||||
@@ -165,7 +192,10 @@ fn close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let independent = switch_or(p, "independent", false, "path.close")?;
|
||||
s.edit("Close Path", |d, sel| {
|
||||
let path = path_mut(d, id)?;
|
||||
let sp = path.subpaths.iter_mut().rev().find(|s| !s.closed).ok_or_else(|| EngineError::Other("path is already closed".into()))?;
|
||||
let sp = match subpath {
|
||||
Some(si) => path.subpaths.get_mut(si).filter(|s| !s.closed).ok_or_else(|| EngineError::Other("path is already closed".into()))?,
|
||||
None => path.subpaths.iter_mut().rev().find(|s| !s.closed).ok_or_else(|| EngineError::Other("path is already closed".into()))?,
|
||||
};
|
||||
sp.closed = true;
|
||||
if let (Some(h), Some(first)) = (h_in, sp.anchors.first_mut()) {
|
||||
first.h_in = h;
|
||||
@@ -174,7 +204,8 @@ fn close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
first.kind = AnchorKind::Smooth;
|
||||
}
|
||||
}
|
||||
sel.set([id]);
|
||||
let top = compound_or_self(d, id);
|
||||
sel.set([top]);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
@@ -288,9 +319,41 @@ fn set_anchors(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// The compound path enclosing `id`, or `id` itself if it is not inside a compound path.
|
||||
fn compound_or_self(d: &Document, id: NodeId) -> NodeId {
|
||||
d.parent_of(id).filter(|p| matches!(d.node(*p).map(|n| &n.kind), Some(NodeKind::Compound { .. }))).unwrap_or(id)
|
||||
}
|
||||
|
||||
/// The open member paths of a compound path, or its first child if none are open.
|
||||
fn open_members(n: &Node) -> Vec<NodeId> {
|
||||
let NodeKind::Compound { children, .. } = &n.kind else { return vec![] };
|
||||
let open: Vec<NodeId> =
|
||||
children.iter().filter(|c| c.path_data().is_some_and(|pd| pd.subpaths.iter().any(|sp| !sp.closed))).map(|c| c.id).collect();
|
||||
if open.is_empty() { children.first().map(|c| vec![c.id]).unwrap_or_default() } else { open }
|
||||
}
|
||||
|
||||
/// The selected paths to join or average, resolving compound paths to their open member paths.
|
||||
fn selected_paths(s: &Session) -> Result<Vec<NodeId>> {
|
||||
let st = s.doc()?;
|
||||
Ok(st.selection.objects.iter().copied().filter(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Path { .. }))).collect())
|
||||
let mut out = vec![];
|
||||
for &id in &st.selection.objects {
|
||||
let Some(n) = st.doc.node(id) else { continue };
|
||||
match &n.kind {
|
||||
NodeKind::Path { .. } => out.push(id),
|
||||
NodeKind::Compound { .. } => {
|
||||
let open = open_members(n);
|
||||
let with_anchors: Vec<NodeId> =
|
||||
open.iter().filter(|id| st.selection.anchors.get(id).is_some_and(|s| !s.is_empty())).copied().collect();
|
||||
if !with_anchors.is_empty() {
|
||||
out.extend(with_anchors);
|
||||
} else {
|
||||
out.extend(open);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The paths in the subtrees of `ids` (in groups and compound paths too), each once.
|
||||
@@ -309,6 +372,7 @@ pub(super) fn paths_in(d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec<Nod
|
||||
fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.reverse";
|
||||
let want = p.get("reversed").map(|v| v.as_bool().ok_or_else(|| bad(C, "`reversed` must be true or false"))).transpose()?;
|
||||
let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize);
|
||||
let ids = paths_in(&s.doc()?.doc, &targets(s, p)?);
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "select paths"));
|
||||
@@ -316,18 +380,22 @@ fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let changed = super::overprint::edit_counted(s, "Reverse Path Direction", |d| {
|
||||
let mut changed = 0;
|
||||
for id in &ids {
|
||||
// The subpaths to flip: all, or those not yet running the wanted way.
|
||||
let flip: Vec<bool> = d
|
||||
.node(*id)
|
||||
.and_then(|n| n.path_data())
|
||||
.map_or(vec![], |pd| pd.subpaths.iter().map(|sp| want.is_none_or(|ccw| (sp.signed_area() < 0.0) != ccw)).collect());
|
||||
if flip.contains(&true) {
|
||||
for (sp, f) in path_mut(d, *id)?.subpaths.iter_mut().zip(flip) {
|
||||
if f {
|
||||
sp.reverse();
|
||||
let path = path_mut(d, *id)?;
|
||||
if let Some(si) = subpath {
|
||||
if let Some(sp) = path.subpaths.get_mut(si) {
|
||||
sp.reverse();
|
||||
changed += 1;
|
||||
}
|
||||
} else {
|
||||
let flip: Vec<bool> = path.subpaths.iter().map(|sp| want.is_none_or(|ccw| (sp.signed_area() < 0.0) != ccw)).collect();
|
||||
if flip.contains(&true) {
|
||||
for (sp, f) in path.subpaths.iter_mut().zip(flip) {
|
||||
if f {
|
||||
sp.reverse();
|
||||
}
|
||||
}
|
||||
changed += 1;
|
||||
}
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
Ok(changed)
|
||||
@@ -386,6 +454,70 @@ fn merge_coincident(sp: &mut SubPath, picked: impl Fn(usize) -> bool, join_kind:
|
||||
before - sp.anchors.len()
|
||||
}
|
||||
|
||||
/// Close subpath `si` of path `id`, merging coincident endpoints if present.
|
||||
fn close_subpath_in_node(d: &mut Document, id: NodeId, si: usize, join_kind: Option<bool>) -> Result<()> {
|
||||
let path = path_mut(d, id)?;
|
||||
let sp = path.subpaths.get_mut(si).filter(|s| !s.closed).ok_or_else(|| EngineError::Other("path is already closed".into()))?;
|
||||
if sp.anchors.len() > 2
|
||||
&& end_points(sp).is_some_and(|(f, l)| f.distance(l) < 1e-6)
|
||||
&& let Some(last) = sp.anchors.pop()
|
||||
&& let Some(first) = sp.anchors.first_mut()
|
||||
{
|
||||
first.h_in = last.h_in;
|
||||
sp.closed = true;
|
||||
if let Some(smooth) = join_kind {
|
||||
join_point(sp, 0, smooth);
|
||||
}
|
||||
}
|
||||
sp.closed = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Join subpaths `sa` and `sb` in the same path node `id`.
|
||||
fn join_subpaths_in_node(d: &mut Document, id: NodeId, (sa, sb): (usize, usize), (rx, ry): (bool, bool), join_kind: Option<bool>) -> Result<()> {
|
||||
if sa == sb {
|
||||
return close_subpath_in_node(d, id, sa, join_kind);
|
||||
}
|
||||
let path = path_mut(d, id)?;
|
||||
let mut x = path.subpaths.get(sa).cloned().ok_or_else(|| EngineError::Other("join needs open paths".into()))?;
|
||||
let mut y = path.subpaths.get(sb).cloned().ok_or_else(|| EngineError::Other("join needs open paths".into()))?;
|
||||
if x.closed || y.closed {
|
||||
return Err(EngineError::Other("join needs open paths".into()));
|
||||
}
|
||||
if rx {
|
||||
x.reverse();
|
||||
}
|
||||
if ry {
|
||||
y.reverse();
|
||||
}
|
||||
let junction = x.anchors.len().saturating_sub(1);
|
||||
if let (Some(xe), Some(ys)) = (x.anchors.last().map(|a| a.p), y.anchors.first().map(|a| a.p))
|
||||
&& xe.distance(ys) < 1e-6
|
||||
{
|
||||
let first = y.anchors.remove(0);
|
||||
if let Some(l) = x.anchors.last_mut() {
|
||||
l.h_out = first.h_out;
|
||||
}
|
||||
}
|
||||
x.anchors.extend(y.anchors);
|
||||
if let Some(smooth) = join_kind {
|
||||
join_point(&mut x, junction, smooth);
|
||||
}
|
||||
if sa > sb {
|
||||
path.subpaths.remove(sb);
|
||||
if let Some(sub) = path.subpaths.get_mut(sa - 1) {
|
||||
*sub = x;
|
||||
}
|
||||
} else {
|
||||
if let Some(sub) = path.subpaths.get_mut(sa) {
|
||||
*sub = x;
|
||||
}
|
||||
path.subpaths.remove(sb);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Join open paths into a single path, or close an open path, preserving compound path structure.
|
||||
fn join(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.join";
|
||||
let join_kind = match p.get("to").and_then(Value::as_str) {
|
||||
@@ -394,19 +526,148 @@ fn join(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some("corner") => Some(false),
|
||||
Some(_) => return Err(bad(C, "`to` must be \"corner\" or \"smooth\"")),
|
||||
};
|
||||
let ids = match ids_param(p, "ids") {
|
||||
Some(ids) => {
|
||||
let st = s.doc()?;
|
||||
if let Some(id) = ids.iter().find(|id| !matches!(st.doc.node(**id).map(|n| &n.kind), Some(NodeKind::Path { .. }))) {
|
||||
return Err(bad(C, format!("{} isn't a path", id.0)));
|
||||
let subpaths = match p.get("subpaths") {
|
||||
None => None,
|
||||
Some(v) => match v.as_array().and_then(|a| {
|
||||
if a.len() == 2
|
||||
&& let (Some(sa), Some(sb)) = (a[0].as_u64(), a[1].as_u64())
|
||||
{
|
||||
Some((sa as usize, sb as usize))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
ids
|
||||
}
|
||||
None => selected_paths(s)?,
|
||||
}) {
|
||||
Some(pair) => Some(pair),
|
||||
None => return Err(bad(C, "`subpaths` must be two integer indices")),
|
||||
},
|
||||
};
|
||||
// The ends to join, as (reverse the first path, reverse the second): the first path's end
|
||||
// must come last, the second's first.
|
||||
let ends = match p.get("ends") {
|
||||
None => None,
|
||||
Some(e) => match e.as_array().map(|a| a.iter().map(Value::as_str).collect::<Vec<_>>()).as_deref() {
|
||||
Some([Some(a @ ("first" | "last")), Some(b @ ("first" | "last"))]) => Some((*a == "first", *b == "last")),
|
||||
_ => return Err(bad(C, "ends must be two of \"first\" and \"last\"")),
|
||||
},
|
||||
};
|
||||
|
||||
if let Some(ids) = ids_param(p, "ids") {
|
||||
if ends.is_some() && ids.len() > 2 {
|
||||
return Err(bad(C, "`ends` applies to joining exactly two paths"));
|
||||
}
|
||||
if ids.len() == 2 && ids[0] == ids[1] {
|
||||
let id = ids[0];
|
||||
let (sa, sb) = subpaths.ok_or_else(|| bad(C, "subpaths required when joining the same path"))?;
|
||||
let (rx, ry) = ends.unwrap_or((false, false));
|
||||
s.edit("Join", |d, sel| {
|
||||
join_subpaths_in_node(d, id, (sa, sb), (rx, ry), join_kind)?;
|
||||
sel.set([compound_or_self(d, id)]);
|
||||
Ok(())
|
||||
})?;
|
||||
return ok();
|
||||
}
|
||||
if ids.len() == 2
|
||||
&& let Some((sa, sb)) = subpaths
|
||||
{
|
||||
let (a, b) = (ids[0], ids[1]);
|
||||
let (rx, ry) = ends.unwrap_or((false, false));
|
||||
s.edit("Join", |d, sel| {
|
||||
join_two(d, a, b, (sa, sb), (rx, ry), join_kind)?;
|
||||
sel.set([compound_or_self(d, a)]);
|
||||
Ok(())
|
||||
})?;
|
||||
return ok();
|
||||
}
|
||||
|
||||
let st = s.doc()?;
|
||||
let mut resolved = vec![];
|
||||
for &id in &ids {
|
||||
let Some(n) = st.doc.node(id) else {
|
||||
return Err(bad(C, format!("{} isn't a path", id.0)));
|
||||
};
|
||||
match &n.kind {
|
||||
NodeKind::Path { .. } => resolved.push(id),
|
||||
NodeKind::Compound { .. } => {
|
||||
let open = open_members(n);
|
||||
if ids.len() > 1 && open.len() != 1 {
|
||||
return Err(bad(C, format!("compound {} must contain exactly one open path to join", id.0)));
|
||||
}
|
||||
resolved.extend(open);
|
||||
}
|
||||
_ => return Err(bad(C, format!("{} isn't a path", id.0))),
|
||||
}
|
||||
}
|
||||
match resolved.as_slice() {
|
||||
[one] => {
|
||||
let id = *one;
|
||||
let si = match subpaths {
|
||||
Some((si, _)) => si,
|
||||
None => {
|
||||
let pd = s.doc()?.doc.node(id).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(id))?;
|
||||
pd.subpaths
|
||||
.iter()
|
||||
.position(|sp| !sp.closed && !sp.anchors.is_empty())
|
||||
.ok_or_else(|| EngineError::Other("path is already closed".into()))?
|
||||
}
|
||||
};
|
||||
s.edit("Join", |d, sel| {
|
||||
close_subpath_in_node(d, id, si, join_kind)?;
|
||||
sel.set([compound_or_self(d, id)]);
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
[a, b, ..] => {
|
||||
if resolved.iter().collect::<BTreeSet<_>>().len() != resolved.len() {
|
||||
return Err(bad(C, "join two different paths"));
|
||||
}
|
||||
let (a, b) = (*a, *b);
|
||||
if ends.is_some() && resolved.len() > 2 {
|
||||
return Err(bad(C, "`ends` applies to joining exactly two paths"));
|
||||
}
|
||||
if let Some((rx, ry)) = ends {
|
||||
let st = s.doc()?;
|
||||
let pa = st.doc.node(a).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(a))?;
|
||||
let pb = st.doc.node(b).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(b))?;
|
||||
let sa = match subpaths {
|
||||
Some((s, _)) => s,
|
||||
None => pa.subpaths.iter().position(|s| !s.closed).ok_or_else(|| EngineError::Other("join needs open paths".into()))?,
|
||||
};
|
||||
let sb = match subpaths {
|
||||
Some((_, s)) => s,
|
||||
None => pb.subpaths.iter().position(|s| !s.closed).ok_or_else(|| EngineError::Other("join needs open paths".into()))?,
|
||||
};
|
||||
s.edit("Join", |d, sel| {
|
||||
join_two(d, a, b, (sa, sb), (rx, ry), join_kind)?;
|
||||
sel.set([compound_or_self(d, a)]);
|
||||
Ok(())
|
||||
})?;
|
||||
return ok();
|
||||
}
|
||||
let st = s.doc()?;
|
||||
let mut open_subs = Vec::with_capacity(resolved.len());
|
||||
for &id in &resolved {
|
||||
let pd = st.doc.node(id).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(id))?;
|
||||
let si = pd
|
||||
.subpaths
|
||||
.iter()
|
||||
.position(|sp| !sp.closed && !sp.anchors.is_empty())
|
||||
.ok_or_else(|| EngineError::Other("join needs open paths".into()))?;
|
||||
open_subs.push((id, si));
|
||||
}
|
||||
s.edit("Join", |d, sel| {
|
||||
let top = join_nearest(d, open_subs, join_kind)?;
|
||||
sel.set([top]);
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
[] => return Err(EngineError::Other("select paths to join".into())),
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
|
||||
// Direct-selected anchors lying on one another along a path (two corners brought together by
|
||||
// Average) become one anchor; the paths themselves are joined only when there are none.
|
||||
if p.get("ids").is_none() {
|
||||
{
|
||||
let st = s.doc()?;
|
||||
let mut merged = vec![];
|
||||
for (id, picked) in &st.selection.anchors {
|
||||
@@ -423,96 +684,253 @@ fn join(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for (id, path) in merged {
|
||||
*path_mut(d, id)? = path;
|
||||
}
|
||||
// The anchor indices changed: the paths stay selected, whole.
|
||||
sel.set(objects);
|
||||
Ok(())
|
||||
})?;
|
||||
return ok();
|
||||
}
|
||||
}
|
||||
// The ends to join, as (reverse the first path, reverse the second): the first path's end
|
||||
// must come last, the second's first.
|
||||
let ends = match p.get("ends") {
|
||||
None => None,
|
||||
Some(e) => match e.as_array().map(|a| a.iter().map(Value::as_str).collect::<Vec<_>>()).as_deref() {
|
||||
Some([Some(a @ ("first" | "last")), Some(b @ ("first" | "last"))]) => Some((*a == "first", *b == "last")),
|
||||
_ => return Err(bad(C, "ends must be two of \"first\" and \"last\"")),
|
||||
},
|
||||
};
|
||||
match ids.as_slice() {
|
||||
[one] => {
|
||||
let id = *one;
|
||||
s.edit("Join", |d, _| {
|
||||
let path = path_mut(d, id)?;
|
||||
let sp = path.subpaths.iter_mut().find(|s| !s.closed).ok_or_else(|| EngineError::Other("path is already closed".into()))?;
|
||||
// Merge coincident end points.
|
||||
if sp.anchors.len() > 2
|
||||
&& end_points(sp).is_some_and(|(f, l)| f.distance(l) < 1e-6)
|
||||
&& let Some(last) = sp.anchors.pop()
|
||||
&& let Some(first) = sp.anchors.first_mut()
|
||||
{
|
||||
first.h_in = last.h_in;
|
||||
// Closed first, so the merged anchor smooths between both its neighbours.
|
||||
sp.closed = true;
|
||||
if let Some(smooth) = join_kind {
|
||||
join_point(sp, 0, smooth);
|
||||
|
||||
let direct_ends: Vec<(NodeId, usize, &'static str)> = {
|
||||
let st = s.doc()?;
|
||||
let mut ends = vec![];
|
||||
for (&id, picked) in &st.selection.anchors {
|
||||
if let Some(pd) = st.doc.node(id).and_then(|n| n.path_data()) {
|
||||
for (si, sp) in pd.subpaths.iter().enumerate() {
|
||||
if sp.closed || sp.anchors.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let last_idx = sp.anchors.len() - 1;
|
||||
let f = picked.contains(&(si, 0));
|
||||
let l = picked.contains(&(si, last_idx));
|
||||
if f && l && last_idx > 0 {
|
||||
ends.push((id, si, "both"));
|
||||
} else if f {
|
||||
ends.push((id, si, "first"));
|
||||
} else if l {
|
||||
ends.push((id, si, "last"));
|
||||
}
|
||||
}
|
||||
sp.closed = true;
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
[a, b, ..] => {
|
||||
let (a, b) = (*a, *b);
|
||||
if a == b {
|
||||
return Err(bad(C, "join two different paths"));
|
||||
}
|
||||
s.edit("Join", |d, sel| {
|
||||
let pb = d.node(b).and_then(|n| n.path_data()).cloned().ok_or(EngineError::NoNode(b))?;
|
||||
let pa = path_mut(d, a)?;
|
||||
let (Some(sa), Some(sb)) = (pa.subpaths.iter().position(|s| !s.closed), pb.subpaths.iter().position(|s| !s.closed)) else {
|
||||
return Err(EngineError::Other("join needs open paths".into()));
|
||||
};
|
||||
let mut x = pa.subpaths[sa].clone();
|
||||
let mut y = pb.subpaths[sb].clone();
|
||||
let (Some((xf, xl)), Some((yf, yl))) = (end_points(&x), end_points(&y)) else {
|
||||
return Err(EngineError::Other("join needs open paths".into()));
|
||||
};
|
||||
// The ends asked for, else the closest pair.
|
||||
let cands =
|
||||
[(xl.distance(yf), false, false), (xl.distance(yl), false, true), (xf.distance(yf), true, false), (xf.distance(yl), true, true)];
|
||||
let (rx, ry) =
|
||||
ends.unwrap_or_else(|| cands.into_iter().min_by(|p, q| p.0.total_cmp(&q.0)).map_or((false, false), |(_, rx, ry)| (rx, ry)));
|
||||
if rx {
|
||||
x.reverse();
|
||||
}
|
||||
if ry {
|
||||
y.reverse();
|
||||
}
|
||||
let junction = x.anchors.len() - 1;
|
||||
if let (Some(xe), Some(ys)) = (x.anchors.last().map(|a| a.p), y.anchors.first().map(|a| a.p))
|
||||
&& xe.distance(ys) < 1e-6
|
||||
{
|
||||
let first = y.anchors.remove(0);
|
||||
if let Some(l) = x.anchors.last_mut() {
|
||||
l.h_out = first.h_out;
|
||||
}
|
||||
ends
|
||||
};
|
||||
|
||||
// More than two selected ends can't name one join: refuse it instead of joining everything.
|
||||
// A single end (left selected by the Pen) falls through to joining the selected paths.
|
||||
if direct_ends.len() > 2 {
|
||||
return Err(bad(C, "select exactly two path ends to join"));
|
||||
}
|
||||
|
||||
if direct_ends.len() == 1 && direct_ends[0].2 == "both" {
|
||||
let (id, si, _) = direct_ends[0];
|
||||
s.edit("Join", |d, sel| {
|
||||
close_subpath_in_node(d, id, si, join_kind)?;
|
||||
sel.set([compound_or_self(d, id)]);
|
||||
Ok(())
|
||||
})?;
|
||||
return ok();
|
||||
}
|
||||
|
||||
if direct_ends.len() == 2 {
|
||||
let (ida, sa, ea) = direct_ends[0];
|
||||
let (idb, sb, eb) = direct_ends[1];
|
||||
let (rx, ry) = match (ea, eb) {
|
||||
("first", "first") => (true, false),
|
||||
("first", "last") => (true, true),
|
||||
("last", "first") => (false, false),
|
||||
("last", "last") => (false, true),
|
||||
_ => {
|
||||
let st = s.doc()?;
|
||||
let pa = st.doc.node(ida).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(ida))?;
|
||||
let pb = st.doc.node(idb).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(idb))?;
|
||||
let (fa, la) = pa.subpaths.get(sa).and_then(end_points).ok_or_else(|| EngineError::Other("join needs open paths".into()))?;
|
||||
let (fb, lb) = pb.subpaths.get(sb).and_then(end_points).ok_or_else(|| EngineError::Other("join needs open paths".into()))?;
|
||||
let mut best: Option<(bool, bool, f64)> = None;
|
||||
let cands = [
|
||||
(la.distance(fb), false, false, (ea == "last" || ea == "both") && (eb == "first" || eb == "both")),
|
||||
(la.distance(lb), false, true, (ea == "last" || ea == "both") && (eb == "last" || eb == "both")),
|
||||
(fa.distance(fb), true, false, (ea == "first" || ea == "both") && (eb == "first" || eb == "both")),
|
||||
(fa.distance(lb), true, true, (ea == "first" || ea == "both") && (eb == "last" || eb == "both")),
|
||||
];
|
||||
for (dist, rx_c, ry_c, valid) in cands {
|
||||
if valid && best.as_ref().is_none_or(|b| dist < b.2) {
|
||||
best = Some((rx_c, ry_c, dist));
|
||||
}
|
||||
}
|
||||
x.anchors.extend(y.anchors);
|
||||
if let Some(smooth) = join_kind {
|
||||
join_point(&mut x, junction, smooth);
|
||||
match best {
|
||||
Some((rx, ry, _)) => (rx, ry),
|
||||
None => (ea == "first", eb == "last"),
|
||||
}
|
||||
pa.subpaths[sa] = x;
|
||||
d.remove(b)?;
|
||||
sel.set([a]);
|
||||
}
|
||||
};
|
||||
if ida == idb {
|
||||
if sa == sb {
|
||||
s.edit("Join", |d, sel| {
|
||||
close_subpath_in_node(d, ida, sa, join_kind)?;
|
||||
sel.set([compound_or_self(d, ida)]);
|
||||
Ok(())
|
||||
})?;
|
||||
} else {
|
||||
s.edit("Join", |d, sel| {
|
||||
join_subpaths_in_node(d, ida, (sa, sb), (rx, ry), join_kind)?;
|
||||
sel.set([compound_or_self(d, ida)]);
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
return ok();
|
||||
} else {
|
||||
s.edit("Join", |d, sel| {
|
||||
join_two(d, ida, idb, (sa, sb), (rx, ry), join_kind)?;
|
||||
sel.set([compound_or_self(d, ida)]);
|
||||
Ok(())
|
||||
})?;
|
||||
return ok();
|
||||
}
|
||||
[] => return Err(EngineError::Other("select paths to join".into())),
|
||||
}
|
||||
|
||||
let ids = selected_paths(s)?;
|
||||
if ids.is_empty() {
|
||||
return Err(EngineError::Other("select paths to join".into()));
|
||||
}
|
||||
let mut open_subs: Vec<(NodeId, usize)> = vec![];
|
||||
{
|
||||
let st = s.doc()?;
|
||||
for &id in &ids {
|
||||
if let Some(pd) = st.doc.node(id).and_then(|n| n.path_data()) {
|
||||
for (si, sp) in pd.subpaths.iter().enumerate() {
|
||||
if !sp.closed && !sp.anchors.is_empty() {
|
||||
open_subs.push((id, si));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if open_subs.is_empty() {
|
||||
if ids.len() == 1 {
|
||||
return Err(EngineError::Other("path is already closed".into()));
|
||||
}
|
||||
return Err(EngineError::Other("select paths to join".into()));
|
||||
}
|
||||
if open_subs.len() == 1 {
|
||||
let (id, si) = open_subs[0];
|
||||
s.edit("Join", |d, sel| {
|
||||
close_subpath_in_node(d, id, si, join_kind)?;
|
||||
sel.set([compound_or_self(d, id)]);
|
||||
Ok(())
|
||||
})?;
|
||||
return ok();
|
||||
}
|
||||
|
||||
s.edit("Join", |d, sel| {
|
||||
let top = join_nearest(d, open_subs, join_kind)?;
|
||||
sel.set([top]);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Join open subpaths, always the two whose ends lie nearest each other, until one is left.
|
||||
/// `open_subs` lists `(node, subpath)` pairs. Returns the node holding the result (or its compound).
|
||||
fn join_nearest(d: &mut Document, mut open_subs: Vec<(NodeId, usize)>, join_kind: Option<bool>) -> Result<NodeId> {
|
||||
let err = || EngineError::Other("join needs open paths".into());
|
||||
while open_subs.len() > 1 {
|
||||
let mut ends_info = Vec::with_capacity(open_subs.len());
|
||||
for &(id, si) in &open_subs {
|
||||
let p = d.node(id).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(id))?;
|
||||
let sp = p.subpaths.get(si).ok_or_else(err)?;
|
||||
ends_info.push(end_points(sp).ok_or_else(err)?);
|
||||
}
|
||||
let mut best: Option<(usize, usize, bool, bool, f64)> = None;
|
||||
for (i, &(fa, la)) in ends_info.iter().enumerate() {
|
||||
for (j, &(fb, lb)) in ends_info.iter().enumerate().skip(i + 1) {
|
||||
let cands =
|
||||
[(la.distance(fb), false, false), (la.distance(lb), false, true), (fa.distance(fb), true, false), (fa.distance(lb), true, true)];
|
||||
for (dist, rx, ry) in cands {
|
||||
if best.as_ref().is_none_or(|b| dist < b.4) {
|
||||
best = Some((i, j, rx, ry, dist));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((i, j, rx, ry, _)) = best else { return Err(err()) };
|
||||
let ((ida, sa), (idb, sb)) = (open_subs[i], open_subs[j]);
|
||||
if ida == idb {
|
||||
join_subpaths_in_node(d, ida, (sa, sb), (rx, ry), join_kind)?;
|
||||
// Subpath `sb` is gone and `sa` took the merged result; later subpaths shift down.
|
||||
open_subs.remove(j);
|
||||
if sa > sb {
|
||||
open_subs[i].1 -= 1;
|
||||
}
|
||||
for (k, item) in open_subs.iter_mut().enumerate() {
|
||||
if k != i && item.0 == ida && item.1 > sb {
|
||||
item.1 -= 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// `join_two` removes `b` only when `sb` was its last subpath; otherwise `b` keeps its
|
||||
// other subpaths, which stay in `open_subs` with their indexes shifted down.
|
||||
join_two(d, ida, idb, (sa, sb), (rx, ry), join_kind)?;
|
||||
open_subs.remove(j);
|
||||
for item in &mut open_subs {
|
||||
if item.0 == idb && item.1 > sb {
|
||||
item.1 -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let first = open_subs.first().map(|(id, _)| *id).ok_or_else(err)?;
|
||||
Ok(compound_or_self(d, first))
|
||||
}
|
||||
|
||||
/// Join path `b` into path `a` along the specified subpaths and orientations.
|
||||
fn join_two(d: &mut Document, a: NodeId, b: NodeId, (sa, sb): (usize, usize), (rx, ry): (bool, bool), join_kind: Option<bool>) -> Result<()> {
|
||||
let pb = d.node(b).and_then(|n| n.path_data()).cloned().ok_or(EngineError::NoNode(b))?;
|
||||
let pa = path_mut(d, a)?;
|
||||
let mut x = pa.subpaths.get(sa).cloned().ok_or_else(|| EngineError::Other("join needs open paths".into()))?;
|
||||
let mut y = pb.subpaths.get(sb).cloned().ok_or_else(|| EngineError::Other("join needs open paths".into()))?;
|
||||
if x.closed || y.closed || x.anchors.is_empty() || y.anchors.is_empty() {
|
||||
return Err(EngineError::Other("join needs open paths".into()));
|
||||
}
|
||||
if rx {
|
||||
x.reverse();
|
||||
}
|
||||
if ry {
|
||||
y.reverse();
|
||||
}
|
||||
let junction = x.anchors.len().saturating_sub(1);
|
||||
if let (Some(xe), Some(ys)) = (x.anchors.last().map(|a| a.p), y.anchors.first().map(|a| a.p))
|
||||
&& xe.distance(ys) < 1e-6
|
||||
{
|
||||
let first = y.anchors.remove(0);
|
||||
if let Some(l) = x.anchors.last_mut() {
|
||||
l.h_out = first.h_out;
|
||||
}
|
||||
}
|
||||
x.anchors.extend(y.anchors);
|
||||
if let Some(smooth) = join_kind {
|
||||
join_point(&mut x, junction, smooth);
|
||||
}
|
||||
if let Some(sub) = pa.subpaths.get_mut(sa) {
|
||||
*sub = x;
|
||||
}
|
||||
if pb.subpaths.len() <= 1 {
|
||||
let parent = d.parent_of(b).filter(|p| matches!(d.node(*p).map(|n| &n.kind), Some(NodeKind::Compound { .. })));
|
||||
d.remove(b)?;
|
||||
if let Some(parent) = parent
|
||||
&& d.node(parent).is_some_and(|n| n.children().is_some_and(|children| children.is_empty()))
|
||||
{
|
||||
d.remove(parent)?;
|
||||
}
|
||||
} else {
|
||||
let pb_mut = path_mut(d, b)?;
|
||||
if sb < pb_mut.subpaths.len() {
|
||||
pb_mut.subpaths.remove(sb);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) type AnchorTargets = Vec<(NodeId, Vec<(usize, usize)>)>;
|
||||
|
||||
pub(super) fn anchor_targets(s: &Session) -> Result<AnchorTargets> {
|
||||
@@ -624,6 +1042,7 @@ pub(crate) fn delete_anchors(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let cuts = refs.iter().filter_map(|&(s, seg)| (s == si).then_some(seg)).collect();
|
||||
without_segments(sp, &cuts)
|
||||
})
|
||||
.filter(|s| s.anchors.len() >= 2)
|
||||
.collect();
|
||||
} else {
|
||||
// Remove every selected anchor against the original indexes, keeping surviving
|
||||
@@ -635,6 +1054,7 @@ pub(crate) fn delete_anchors(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let gone = set.iter().filter_map(|&(s, ai)| (s == si).then_some(ai)).collect();
|
||||
without_anchors(sp, &gone)
|
||||
})
|
||||
.filter(|s| s.anchors.len() >= 2)
|
||||
.collect();
|
||||
}
|
||||
path.subpaths.retain(|s| s.anchors.len() >= 2);
|
||||
@@ -693,7 +1113,7 @@ fn without_anchors(sp: SubPath, removed: &BTreeSet<usize>) -> Vec<SubPath> {
|
||||
}
|
||||
|
||||
/// Remove connections, splitting open/closed contours into their surviving open runs.
|
||||
fn without_segments(sp: SubPath, removed: &BTreeSet<usize>) -> Vec<SubPath> {
|
||||
pub(super) fn without_segments(sp: SubPath, removed: &BTreeSet<usize>) -> Vec<SubPath> {
|
||||
let cuts: BTreeSet<usize> = removed.iter().copied().filter(|&i| i < sp.segment_count()).collect();
|
||||
let Some(&first) = cuts.first() else { return vec![sp] };
|
||||
let n = sp.anchors.len();
|
||||
|
||||
Reference in New Issue
Block a user