From e2b4cef111329e3945d8f2afb25af38a3bf83197 Mon Sep 17 00:00:00 2001 From: makesomethingshit Date: Sat, 17 Oct 2026 14:22:43 +0900 Subject: [PATCH] fix(blend): route Anchor Point tool through spine anchors and handles (#2544) --- crates/tools/src/draw2/anchor.rs | 127 +++++++++++++++++++++++++++++-- 1 file changed, 119 insertions(+), 8 deletions(-) diff --git a/crates/tools/src/draw2/anchor.rs b/crates/tools/src/draw2/anchor.rs index 5cf08b16d..1713792d0 100644 --- a/crates/tools/src/draw2/anchor.rs +++ b/crates/tools/src/draw2/anchor.rs @@ -6,11 +6,12 @@ //! handle end → retract that handle only (#2267); drag a handle → move it independently (Shift: at //! 45° steps round its anchor); drag a segment → reshape the curve, a selected blend's spine's too, //! straight or curved (#1111; Shift: its handles at right angles to the line between its anchors, -//! at one length). +//! at one length). Blend spine points and handles follow the same conversions (#2544). //! Scissors: click a path to split it there. use serde_json::json; use vectorcraft_doc::NodeId; +use vectorcraft_geom::Anchor; use vectorcraft_geom::Point; use super::{hit_anchor, hit_segment, insert_anchor, remove_anchor}; @@ -36,6 +37,21 @@ enum State { start: Point, began: bool, }, + /// Dragging a selected blend's spine point into a symmetric smooth point. + SpineConvert { + id: NodeId, + ai: usize, + start: Point, + began: bool, + }, + /// Dragging or retracting one of a selected blend's spine handles. + SpineHandle { + id: NodeId, + ai: usize, + out: bool, + start: Point, + began: bool, + }, /// Bending a segment: a path's, or (`spine`) a selected blend's spine's. Reshape { id: NodeId, @@ -67,6 +83,44 @@ impl AnchorTool { Self { id, state: State::Idle, guides: vec![], snap: HandleSnap::default() } } + /// Only selected, editable blends are eligible; their visible spines are + /// computed in document coordinates, as for Direct Selection. + fn spine_point(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, Anchor)> { + crate::direct::spines(cx).into_iter().filter(|(id, _)| cx.selection.contains(*id)).find_map(|(id, path)| { + path.anchors().find(|(si, _, a)| *si == 0 && a.p.distance(p) <= tol).map(|(_, ai, a)| (id, ai, *a)) + }) + } + + fn spine_handle(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, bool)> { + crate::direct::spines(cx).into_iter().filter(|(id, _)| cx.selection.contains(*id)).find_map(|(id, path)| { + path.anchors().filter(|(si, _, _)| *si == 0).find_map(|(_, ai, a)| { + if a.has_out() && a.h_out.distance(p) <= tol { + Some((id, ai, true)) + } else if a.has_in() && a.h_in.distance(p) <= tol { + Some((id, ai, false)) + } else { + None + } + }) + }) + } + + /// Unlike Direct Selection, Anchor Point has no remembered focus after a + /// tool switch. Show the handles on selected blends so they stay editable. + fn spine_overlays(cx: &ToolContext) -> Vec { + let mut out = vec![]; + for (id, path) in crate::direct::spines(cx).into_iter().filter(|(id, _)| cx.selection.contains(*id)) { + let color = cx.doc.layer_color(id); + for (_, _, a) in path.anchors() { + for h in [a.h_in, a.h_out].into_iter().filter(|h| h.distance(a.p) > 1e-6) { + out.push(Overlay::Line { a: a.p, b: h, color, dashed: false }); + out.push(Overlay::Handle { p: h, color }); + } + } + } + out + } + fn add(cx: &ToolContext, p: Point) -> Vec { hit_segment(cx, p, cx.pick_tol()).map(insert_anchor).into_iter().collect() } @@ -115,6 +169,10 @@ impl Tool for AnchorTool { if let Some((id, si, ai, out)) = hit_handle(cx, p, point) { self.state = State::Handle { id, si, ai, out, start: p, began: false }; self.snap = HandleSnap::default(); + } else if let Some((id, ai, out)) = Self::spine_handle(cx, p, point) { + self.state = State::SpineHandle { id, ai, out, start: p, began: false }; + } else if let Some((id, ai, _)) = Self::spine_point(cx, p, point) { + self.state = State::SpineConvert { id, ai, start: p, began: false }; } else if let Some((id, si, ai)) = hit_anchor(cx, p, point) { self.state = State::Convert { id, si, ai, start: p, began: false }; } else if let Some((id, si, seg, t)) = hit_segment(cx, p, tol) { @@ -128,7 +186,11 @@ impl Tool for AnchorTool { let mut out = vec![]; let (began, far) = match self.state { State::Idle => return out, - State::Convert { began, start, .. } | State::Handle { began, start, .. } | State::Reshape { began, start, .. } => { + State::Convert { began, start, .. } + | State::Handle { began, start, .. } + | State::SpineConvert { began, start, .. } + | State::SpineHandle { began, start, .. } + | State::Reshape { began, start, .. } => { (began, p.distance(start) >= cx.tol(2.0)) } }; @@ -136,8 +198,12 @@ impl Tool for AnchorTool { if !far { return out; } - out.push(Action::Begin(if matches!(self.state, State::Convert { .. }) { "Convert Anchor Point" } else { "Reshape" }.into())); - if let State::Convert { began, .. } | State::Handle { began, .. } | State::Reshape { began, .. } = &mut self.state { + out.push(Action::Begin(if matches!(self.state, State::Convert { .. } | State::SpineConvert { .. }) { "Convert Anchor Point" } else { "Reshape" }.into())); + if let State::Convert { began, .. } + | State::Handle { began, .. } + | State::SpineConvert { began, .. } + | State::SpineHandle { began, .. } + | State::Reshape { began, .. } = &mut self.state { *began = true; } } @@ -147,6 +213,14 @@ impl Tool for AnchorTool { "path.convertAnchor".into(), json!({"id": id.0, "subpath": si, "anchor": ai, "to": "smooth", "x": p.x, "y": p.y}), ), + State::SpineConvert { id, ai, .. } => Action::Preview( + "object.blend.spine.convertAnchor".into(), + json!({"id": id.0, "anchor": ai, "to": "smooth", "x": p.x, "y": p.y}), + ), + State::SpineHandle { id, ai, out, .. } => Action::Preview( + "object.blend.spine.moveAnchor".into(), + json!({"id": id.0, "anchor": ai, "handle": if out { "out" } else { "in" }, "x": p.x, "y": p.y, "independent": true}), + ), State::Handle { id, si, ai, out, .. } => { let (q, guides) = self.snap.snap(cx, (id, si, ai), p, ev.mods.shift); self.guides = guides; @@ -171,7 +245,11 @@ impl Tool for AnchorTool { self.guides.clear(); let st = std::mem::replace(&mut self.state, State::Idle); match st { - State::Convert { began: true, .. } | State::Handle { began: true, .. } | State::Reshape { began: true, .. } => { + State::Convert { began: true, .. } + | State::Handle { began: true, .. } + | State::SpineConvert { began: true, .. } + | State::SpineHandle { began: true, .. } + | State::Reshape { began: true, .. } => { vec![Action::Commit] } State::Convert { id, si, ai, began: false, .. } => { @@ -187,6 +265,31 @@ impl Tool for AnchorTool { vec![] } } + State::SpineConvert { id, ai, began: false, .. } => { + let has_handles = crate::direct::spines(cx) + .into_iter() + .find(|(blend, _)| *blend == id) + .and_then(|(_, path)| path.subpaths.first()?.anchors.get(ai).copied()) + .is_some_and(|a| a.has_in() || a.has_out()); + if has_handles { + vec![Action::Exec("object.blend.spine.convertAnchor".into(), json!({"id": id.0, "anchor": ai, "to": "corner"}))] + } else { + vec![] + } + } + State::SpineHandle { id, ai, out, began: false, .. } => { + let Some(a) = crate::direct::spines(cx) + .into_iter() + .find(|(blend, _)| *blend == id) + .and_then(|(_, path)| path.subpaths.first()?.anchors.get(ai).copied()) + else { + return vec![]; + }; + vec![Action::Exec( + "object.blend.spine.moveAnchor".into(), + json!({"id": id.0, "anchor": ai, "handle": if out { "out" } else { "in" }, "x": a.p.x, "y": a.p.y, "independent": true}), + )] + } State::Handle { id, si, ai, out, began: false, .. } => { let Some(a) = cx.doc.node(id).and_then(|n| n.path_data()).and_then(|pd| pd.subpaths.get(si)?.anchors.get(ai)) else { return vec![]; @@ -205,12 +308,20 @@ impl Tool for AnchorTool { fn deactivate(&mut self, _cx: &ToolContext) -> Vec { let st = std::mem::replace(&mut self.state, State::Idle); match st { - State::Convert { began: true, .. } | State::Handle { began: true, .. } | State::Reshape { began: true, .. } => vec![Action::Commit], + State::Convert { began: true, .. } + | State::Handle { began: true, .. } + | State::SpineConvert { began: true, .. } + | State::SpineHandle { began: true, .. } + | State::Reshape { began: true, .. } => vec![Action::Commit], _ => vec![], } } - fn overlays(&self, _cx: &ToolContext) -> Vec { - if matches!(self.state, State::Handle { .. }) { self.guides.clone() } else { vec![] } + fn overlays(&self, cx: &ToolContext) -> Vec { + let mut out = if self.id == "anchorPoint" { Self::spine_overlays(cx) } else { vec![] }; + if matches!(self.state, State::Handle { .. }) { + out.extend(self.guides.iter().cloned()); + } + out } fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { match self.id {