From 44044d93baf10d1595ceb217a1c0a7f3fedfcd8d Mon Sep 17 00:00:00 2001 From: el-j Date: Mon, 26 Oct 2026 10:04:10 +0100 Subject: [PATCH] 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() {