//! The Pen tool (P). //! //! Click adds a corner anchor; click-drag adds a smooth anchor with symmetric handles (Alt breaks //! them: the incoming handle stays where it was when Alt went down and only the outgoing one //! follows the pointer; Space held moves the anchor, handles and all); Shift constrains to 45°. //! A press on the first anchor closes the path, the drag shaping the closing curve. Clicking the //! last one retracts its outgoing handle, so the next segment leaves it as a corner; dragging from //! it pulls a new one out on its own. //! Alt held over a handle end, an anchor or a segment of a selected path (the one being drawn too) //! works as the Anchor Point tool: dragging a handle moves it alone, clicking a smooth anchor makes //! it a corner, dragging an anchor pulls out new symmetric handles and dragging a segment reshapes //! it. Cmd held lends the selection tool used last for a drag (the engine's //! `Session::pointer`); the path being drawn goes on afterwards while it stays selected. //! Enter/Esc (or switching tools) ends the path. Clicking the end of a selected open path continues //! it. The rubber-band preview shows the next segment (Enable Rubber Band for Pen Tool). Auto Add/Delete: between paths, a click on a //! segment of a selected path adds an anchor there and a click on one of its anchors deletes it //! (Shift held or General → Disable Auto Add/Delete starts a new path instead). On a selected //! blend's spine a click adds a point (on a point no key object sits on: deletes it). //! Each anchor placed snaps to Smart Guides ([`DrawSnap`]): onto the anchors, centres and paths of //! the other art and of the path being drawn, into line with them, onto the construction guides //! through the last anchor; with Shift held it slides along its 45° step into line. Hovering shows //! where the next one would go. use serde_json::json; use vectorcraft_doc::{NodeId, NodeKind}; use vectorcraft_geom::{BezPath, Point}; use crate::direct::hit_handle; use crate::draw2::AnchorTool; use crate::guides::{DrawSnap, Leave}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; #[derive(Default)] pub struct PenTool { /// A path is being drawn... drawing: bool, /// ...this one, once the engine made it: another path selected meanwhile isn't drawn on. path: Option, /// The anchor a press placed, while the drag shapes its handles. drag: Option, hover: Option, /// The incoming handle of the anchor being dragged out, as last previewed. in_h: Point, /// The pointer at the last button-down or drag: Space held moves the anchor as far as it moves. last: Point, /// The last anchor of the path being drawn, while a click retracts its outgoing handle or a /// drag pulls a new one out: (path, subpath, anchor, its position). handle: Option<(NodeId, usize, usize, Point)>, /// The Anchor Point tool, while an Alt gesture on a selected path's handle or anchor lasts. convert: Option, /// Smart Guides for the anchors placed (and the pointer hovering). snap: DrawSnap, } /// An anchor being placed, as the press decided: where it is, the path it goes on (none: the /// first anchor of a new path) and whether it closes that path (the press was on its first /// anchor). The drag only shapes its handles, whatever the path looks like meanwhile: a closing /// preview leaves the path closed, so no longer one the pen draws on. #[derive(Clone, Copy)] struct Place { at: Point, path: 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)> { if cx.selection.objects.len() != 1 { return None; } let id = cx.selection.objects[0]; let n = cx.doc.node(id)?; let NodeKind::Path { path, .. } = &n.kind else { return None }; let sp = path.subpaths.last()?; if sp.closed { return None; } let first = sp.anchors.first()?.p; let last = sp.anchors.last()?; Some((id, first, last.p, last.h_out)) } /// The last anchor of `id`'s last subpath: (subpath, anchor). fn last_anchor(cx: &ToolContext, id: NodeId) -> Option<(usize, usize)> { let pd = cx.doc.node(id)?.path_data()?; let si = pd.subpaths.len().checked_sub(1)?; let ai = pd.subpaths.get(si)?.anchors.len().checked_sub(1)?; Some((si, ai)) } /// Alt held over a handle end, an anchor or a segment of a selected path: the Anchor Point tool's /// gesture, within its tolerance. fn alt_converts(cx: &ToolContext, p: Point, m: Mods) -> bool { let point = cx.point_tol(); m.alt && (hit_handle(cx, p, point).is_some() || crate::draw2::anchor_in(cx, editable_paths(cx), p, point).is_some() || crate::draw2::segment_in(cx, editable_paths(cx), p, cx.pick_tol()).is_some()) } /// The selected paths the pen edits (not guides, nor locked or hidden ones). fn editable_paths<'a>(cx: &'a ToolContext) -> impl Iterator + 'a { cx.selection .objects .iter() .copied() .filter(|&id| cx.doc.is_editable(id) && cx.doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Path { guide: false, .. }))) } /// Preview the outgoing handle of anchor `ai` of subpath `si` at `h`, the incoming one left alone. fn set_out_handle(id: NodeId, si: usize, ai: usize, h: Point) -> Action { Action::Preview( "path.setHandle".into(), json!({"id": id.0, "subpath": si, "anchor": ai, "which": "out", "x": h.x, "y": h.y, "independent": true}), ) } impl Tool for PenTool { fn id(&self) -> &'static str { "pen" } fn busy(&self) -> bool { self.drag.is_some() || self.handle.is_some() || self.convert.is_some() } 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); } let active = self.active(cx); if self.drawing && active.is_none() && ev.kind == PointerKind::Down { self.stop(); } // 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, _)| Leave::segment(cx, last, ev.mods.shift)); 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()), _ => ev.pos, }; match ev.kind { PointerKind::Move => { self.hover = Some(p); vec![] } PointerKind::Down => { self.last = ev.pos; if let Some((id, first, last, _)) = active { let end = last_anchor(cx, id); // A one-anchor path doesn't close on itself: its anchor is the last one too. if p.distance(first) <= tol && end.is_some_and(|(_, ai)| ai > 0) { 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}))]; } if p.distance(last) <= tol && let Some((si, ai)) = end { self.handle = Some((id, si, ai, last)); return vec![Action::Begin("Convert Anchor Point".into()), set_out_handle(id, si, ai, last)]; } if let Some(acts) = self.alt_convert(cx, ev) { return acts; } self.drag = Some(Place { at: p, path: Some(id), 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}))]; } if let Some(acts) = self.alt_convert(cx, ev) { return acts; } if let Some(acts) = spine_click(cx, p, tol) { return acts; } // Continue a selected open path when clicking on one of its ends. if let Some((_, first, last, _)) = active_path(cx) && (p.distance(last) <= tol || p.distance(first) <= tol) { self.drawing = true; self.path = active_path(cx).map(|a| a.0); if p.distance(first) <= tol && p.distance(last) > tol { return vec![Action::Exec("path.reverse".into(), json!({}))]; } return vec![]; } 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 }); vec![Action::Begin("Pen".into()), Action::Preview("path.create".into(), json!({"anchors": [{"x": p.x, "y": p.y}]}))] } PointerKind::Drag => { if let Some(t) = &mut self.convert { return t.pointer(cx, ev); } if let Some((id, si, ai, a)) = self.handle { let mut h = ev.pos; if ev.mods.shift { h = a + vectorcraft_geom::constrain_angle(ev.pos - a, 45.0); } return vec![set_out_handle(id, si, ai, h)]; } let Some(Place { at: mut a, path, 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.last = ev.pos; let mut out_h = ev.pos; if ev.mods.shift { out_h = a + vectorcraft_geom::constrain_angle(ev.pos - a, 45.0); } let alt = ev.mods.alt; let Some(id) = path else { // First anchor of a new path: re-issue create with handles. let in_h = a - (out_h - a); return vec![Action::Preview( "path.create".into(), json!({"anchors": [{"x": a.x, "y": a.y, "out": [out_h.x, out_h.y], "in": [in_h.x, in_h.y]}]}), )]; }; 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}), )]; } // 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]}), )] } PointerKind::Up => { self.snap.clear(); if let Some(mut t) = self.convert.take() { return t.pointer(cx, ev); } if self.handle.take().is_some() { return vec![Action::Commit]; } let Some(place) = self.drag.take() else { return vec![] }; if place.closing { self.stop(); } vec![Action::Commit] } PointerKind::DoubleClick => vec![], } } fn key(&mut self, cx: &ToolContext, key: ToolKey, _m: Mods) -> Vec { match key { ToolKey::Enter | ToolKey::Escape => { self.stop(); self.drag = None; self.handle = None; self.snap.clear(); // An Alt gesture under way ends as it stands. self.convert.take().map(|mut t| t.deactivate(cx)).unwrap_or_default() } _ => vec![], } } fn deactivate(&mut self, cx: &ToolContext) -> Vec { self.stop(); self.snap.clear(); self.convert.take().map(|mut t| t.deactivate(cx)).unwrap_or_default() } fn overlays(&self, cx: &ToolContext) -> Vec { if let Some(t) = &self.convert { return t.overlays(cx); } let mut o = vec![]; 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 mut bp = BezPath::new(); bp.move_to(last); if out.distance(last) > 1e-9 { bp.quad_to(out, h); } else { bp.line_to(h); } o.push(Overlay::Path { path: bp, color: cx.doc.layer_color(id), width: 1.0, dashed: false }); } o.extend_from_slice(self.snap.guides()); o } fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { let tol = cx.tol(5.0).max(cx.point_tol()); if let Some((id, first, last, _)) = self.active(cx) { let one = last_anchor(cx, id).is_some_and(|(_, ai)| ai == 0); if p.distance(first) <= tol && !one { return Cursor::PenClose; } if p.distance(last) <= tol { return Cursor::PenConvert; } } if alt_converts(cx, p, m) { return Cursor::PenConvert; } if !self.drawing { match spine_click(cx, p, tol).as_deref() { Some([Action::Exec(c, _)]) if c == "object.blend.spine.removeAnchor" => return Cursor::PenDelete, Some([_]) => return Cursor::PenAdd, _ => {} } if active_path(cx).is_some_and(|(_, first, last, _)| p.distance(last) <= tol || p.distance(first) <= tol) { return Cursor::PenContinue; } match auto_add_delete(cx, p, m, tol) { Some(Action::Exec(c, _)) if c == "path.removeAnchor" => return Cursor::PenDelete, Some(_) => return Cursor::PenAdd, None => {} } } Cursor::Pen } } 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)) } /// The path is finished. fn stop(&mut self) { (self.drawing, self.path) = (false, None); } /// Hand an Alt press on a selected path's handle, anchor or segment to the Anchor Point tool. fn alt_convert(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Option> { if !alt_converts(cx, ev.pos, ev.mods) { return None; } let mut t = AnchorTool::new("anchorPoint"); let acts = t.pointer(cx, ev); if !t.busy() { return None; } self.convert = Some(t); Some(acts) } } /// Auto Add/Delete: what a click at `p` does to a selected path. On one of its anchors it deletes /// it; else, where one of its segments passes within `tol`, it adds an anchor. None when the /// preference turns it off, Shift is held, or no selected path is there. fn auto_add_delete(cx: &ToolContext, p: Point, m: Mods, tol: f64) -> Option { if !cx.auto_add_delete || m.shift { return None; } let anchor = crate::draw2::anchor_in(cx, editable_paths(cx), p, tol).map(crate::draw2::remove_anchor); anchor.or_else(|| crate::draw2::segment_in(cx, editable_paths(cx), p, tol).map(crate::draw2::insert_anchor)) } /// A click on a selected blend's spine: delete the point under `p` when no key object sits on it /// (a key's point does nothing), else add one where the spine passes within `tol`. fn spine_click(cx: &ToolContext, p: Point, tol: f64) -> Option> { for (id, path) in crate::direct::spines(cx).into_iter().filter(|(id, _)| cx.selection.contains(*id)) { let Some(NodeKind::Blend { children, spec }) = cx.doc.node(id).map(|n| &n.kind) else { continue }; let keys = vectorcraft_doc::live::blend_spine(children, spec).and_then(|(_, a)| a).unwrap_or_default(); if let Some((_, ai, _)) = path.anchors().find(|(_, _, a)| a.p.distance(p) <= tol) { if keys.contains(&ai) { return Some(vec![]); } return Some(vec![Action::Exec("object.blend.spine.removeAnchor".into(), json!({"id": id.0, "anchor": ai}))]); } if path.nearest(p).is_some_and(|n| n.4 <= tol) { return Some(vec![Action::Exec("object.blend.spine.addAnchor".into(), json!({"id": id.0, "x": p.x, "y": p.y}))]); } } None } #[cfg(test)] mod tests { use super::*; use crate::testutil::*; use vectorcraft_doc::Selection; #[test] fn first_click_creates_path() { let (d, _) = doc_with_rect(); let s = Selection::default(); let p = paint(); let cx = cx(&d, &s, &p); let mut t = PenTool::default(); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); assert_eq!(a[0], Action::Begin("Pen".into())); assert!(matches!(&a[1], Action::Preview(c, _) if c == "path.create")); assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 10.0, 10.0)), vec![Action::Commit]); } #[test] fn alt_pressed_mid_drag_keeps_the_incoming_handle() { let (mut d, _) = doc_with_rect(); let l = d.layers[0].id; let id = d.alloc_id(); let line = vectorcraft_geom::shapes::line(Point::new(10.0, 300.0), Point::new(60.0, 300.0)); d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); let mut s = Selection::default(); s.set([id]); let p = paint(); let cx = cx(&d, &s, &p); let mut t = PenTool { drawing: true, ..PenTool::default() }; let alt = Mods { alt: true, ..Mods::default() }; let handles = |acts: Vec| match acts.as_slice() { [Action::Preview(c, v)] if c == "path.appendAnchor" => (v["in"].clone(), v["out"].clone()), other => panic!("not an anchor preview: {other:?}"), }; t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 400.0, 420.0)); let drag = PointerEvent::new(PointerKind::Drag, 450.0, 420.0); assert_eq!(handles(t.pointer(&cx, &drag)), (json!([350.0, 420.0]), json!([450.0, 420.0]))); // Alt goes down: the curve shaped so far stays, and only the outgoing handle moves on. let drag = PointerEvent::new(PointerKind::Drag, 450.0, 470.0).with_mods(alt); assert_eq!(handles(t.pointer(&cx, &drag)), (json!([350.0, 420.0]), json!([450.0, 470.0]))); let drag = PointerEvent::new(PointerKind::Drag, 400.0, 480.0).with_mods(alt); assert_eq!(handles(t.pointer(&cx, &drag)), (json!([350.0, 420.0]), json!([400.0, 480.0]))); assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 400.0, 480.0).with_mods(alt)), vec![Action::Commit]); // Alt held from the start: the new anchor gets an outgoing handle only. t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 300.0, 440.0).with_mods(alt)); let drag = PointerEvent::new(PointerKind::Drag, 340.0, 440.0).with_mods(alt); assert_eq!(handles(t.pointer(&cx, &drag)), (json!([300.0, 440.0]), json!([340.0, 440.0]))); } #[test] fn space_moves_the_anchor_being_placed() { let (mut d, _) = doc_with_rect(); let space = Mods { space: true, ..Mods::default() }; // The first anchor of a new path. let (s, p) = (Selection::default(), paint()); let cx0 = cx(&d, &s, &p); let mut t = PenTool::default(); t.pointer(&cx0, &PointerEvent::new(PointerKind::Down, 400.0, 420.0)); t.pointer(&cx0, &PointerEvent::new(PointerKind::Drag, 450.0, 420.0)); let created = |acts: Vec| match acts.as_slice() { [Action::Preview(c, v)] if c == "path.create" => v["anchors"][0].clone(), other => panic!("not a create preview: {other:?}"), }; let a = created(t.pointer(&cx0, &PointerEvent::new(PointerKind::Drag, 450.0, 380.0).with_mods(space))); assert_eq!(a, json!({"x": 400.0, "y": 380.0, "out": [450.0, 380.0], "in": [350.0, 380.0]}), "moved, handles and all"); // Space released: the drag shapes the handles round the anchor's new place. let a = created(t.pointer(&cx0, &PointerEvent::new(PointerKind::Drag, 460.0, 380.0))); assert_eq!(a, json!({"x": 400.0, "y": 380.0, "out": [460.0, 380.0], "in": [340.0, 380.0]})); // An anchor added to an open path, its handles broken by Alt: the kept incoming handle // moves with it. let l = d.layers[0].id; let id = d.alloc_id(); let line = vectorcraft_geom::shapes::line(Point::new(10.0, 300.0), Point::new(60.0, 300.0)); d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); let mut s = Selection::default(); s.set([id]); let cx = cx(&d, &s, &p); let mut t = PenTool { drawing: true, ..PenTool::default() }; let alt = Mods { alt: true, ..Mods::default() }; t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 400.0, 420.0)); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 450.0, 420.0)); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 450.0, 470.0).with_mods(alt)); let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 450.0, 450.0).with_mods(Mods { space: true, ..alt })); assert_eq!( 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]}) )] ); } /// A document with an open path of `anchors`, selected, being drawn by a Pen. fn drawing(anchors: vectorcraft_geom::SubPath) -> (vectorcraft_doc::Document, NodeId, Selection) { let (mut d, _) = doc_with_rect(); let l = d.layers[0].id; let id = d.alloc_id(); let path = vectorcraft_geom::PathData::single(anchors); d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, path, vectorcraft_doc::Appearance::default_art())).unwrap(); let mut s = Selection::default(); s.set([id]); (d, id, s) } fn set_out(id: NodeId, ai: usize, x: f64, y: f64) -> Action { Action::Preview("path.setHandle".into(), json!({"id": id.0, "subpath": 0, "anchor": ai, "which": "out", "x": x, "y": y, "independent": true})) } #[test] fn clicking_the_last_anchor_retracts_its_handle_and_dragging_pulls_a_new_one() { // The last anchor is smooth: handles at 40 and 80 either side of (60, 300). let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0), Point::new(60.0, 300.0)], false); sp.anchors[1].h_in = Point::new(40.0, 300.0); sp.anchors[1].h_out = Point::new(80.0, 300.0); let (d, id, s) = drawing(sp); let p = paint(); let cx = cx(&d, &s, &p); let mut t = PenTool { drawing: true, ..PenTool::default() }; assert_eq!(t.cursor(&cx, Point::new(61.0, 301.0), Mods::default()), Cursor::PenConvert); // A click retracts the outgoing handle and adds no anchor. let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 61.0, 301.0)); assert_eq!(a, vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 1, 60.0, 300.0)]); assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 61.0, 301.0)), vec![Action::Commit]); // A drag pulls a new outgoing handle out. t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 60.0, 300.0)); assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 70.0, 280.0)), vec![set_out(id, 1, 70.0, 280.0)]); assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 70.0, 280.0)), vec![Action::Commit]); // Elsewhere a click still adds an anchor, and the first anchor still closes the path. let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 400.0)); assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.appendAnchor"), "{a:?}"); t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 200.0, 400.0)); assert_eq!(t.cursor(&cx, Point::new(10.0, 300.0), Mods::default()), Cursor::PenClose); } /// #501: a press on the first anchor closes the path, however far the drag then goes: the /// closing preview leaves the path closed (so not one the pen extends), and each move still /// previews the close with symmetric handles (Alt breaks them), released as a close. #[test] fn a_drag_from_the_first_anchor_always_closes() { let pts = [Point::new(100.0, 300.0), Point::new(200.0, 300.0), Point::new(200.0, 400.0)]; let (mut d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&pts, false)); 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}))]); // 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}))] }; 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})"); } let alt = Mods { alt: true, ..Mods::default() }; assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 170.0, 250.0).with_mods(alt)), close(170.0, 250.0, true)); assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 170.0, 250.0)), vec![Action::Commit]); assert!(!t.busy() && t.active(&c).is_none(), "the path is done"); } #[test] fn a_one_anchor_path_does_not_close_on_itself() { let (d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0)], false)); let p = paint(); let cx = cx(&d, &s, &p); let mut t = PenTool { drawing: true, ..PenTool::default() }; assert_eq!(t.cursor(&cx, Point::new(10.0, 300.0), Mods::default()), Cursor::PenConvert); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 300.0)); assert_eq!(a, vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 0, 10.0, 300.0)]); } #[test] fn clicking_a_selected_blend_spine_adds_a_point() { let (mut d, _) = doc_with_rect(); let l = d.layers[0].id; let key = |id: NodeId, x: f64| { let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, 300.0, x + 20.0, 320.0)); std::sync::Arc::new(vectorcraft_doc::Node::path(id, r, vectorcraft_doc::Appearance::default_art())) }; let (g, k1, k2) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); d.insert( Some(l), 1, vectorcraft_doc::Node::new(g, NodeKind::Blend { children: vec![key(k1, 100.0), key(k2, 200.0)], spec: Default::default() }), ) .unwrap(); let mut s = Selection::default(); s.set([g]); let p = paint(); let cx = cx(&d, &s, &p); let mut t = PenTool::default(); assert_eq!(t.cursor(&cx, Point::new(160.0, 311.0), Mods::default()), Cursor::PenAdd); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 160.0, 311.0)); assert_eq!(a, vec![Action::Exec("object.blend.spine.addAnchor".into(), json!({"id": g.0, "x": 160.0, "y": 310.0}))]); // A key's own point is left alone (the click snaps to the spine line). assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 110.0, 310.0)), vec![]); } fn click(t: &mut PenTool, cx: &ToolContext, x: f64, y: f64, m: Mods) -> Vec { let acts = t.pointer(cx, &PointerEvent::new(PointerKind::Down, x, y).with_mods(m)); t.pointer(cx, &PointerEvent::new(PointerKind::Up, x, y).with_mods(m)); acts } #[test] fn clicking_a_selected_path_adds_or_deletes_an_anchor() { let (mut d, _) = doc_with_rect(); let l = d.layers[0].id; let id = d.alloc_id(); let pts = [Point::new(300.0, 400.0), Point::new(350.0, 300.0), Point::new(400.0, 400.0)]; let path = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&pts, false)); d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, path, vectorcraft_doc::Appearance::default_art())).unwrap(); let mut s = Selection::default(); s.set([id]); let p = paint(); let off = ToolContext { auto_add_delete: false, ..cx(&d, &s, &p) }; let cx = cx(&d, &s, &p); let none = Mods::default(); let mut t = PenTool::default(); // On a segment: add an anchor there. assert_eq!(t.cursor(&cx, Point::new(325.0, 351.0), none), Cursor::PenAdd); let a = click(&mut t, &cx, 325.0, 351.0, none); assert!(matches!(&a[..], [Action::Exec(c, v)] if c == "path.insertAnchor" && v["id"] == id.0 && v["segment"] == 0), "{a:?}"); // On an anchor: delete it. assert_eq!(t.cursor(&cx, Point::new(350.0, 302.0), none), Cursor::PenDelete); let a = click(&mut t, &cx, 350.0, 302.0, none); assert_eq!(a, vec![Action::Exec("path.removeAnchor".into(), json!({"id": id.0, "subpath": 0, "anchor": 1}))]); // An end still continues the path. assert_eq!(t.cursor(&cx, Point::new(400.0, 400.0), none), Cursor::PenContinue); // Shift, or the preference turned off, starts a new path instead. let shift = Mods { shift: true, ..Mods::default() }; assert_eq!(t.cursor(&cx, Point::new(325.0, 351.0), shift), Cursor::Pen); assert_eq!(t.cursor(&off, Point::new(350.0, 302.0), none), Cursor::Pen); let a = click(&mut t, &off, 350.0, 302.0, none); assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.create"), "{a:?}"); // While a path is being drawn, a click goes on drawing it. let a = click(&mut t, &cx, 325.0, 351.0, none); assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.appendAnchor"), "{a:?}"); let mut t = PenTool::default(); let a = click(&mut t, &cx, 325.0, 351.0, shift); assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.create"), "{a:?}"); } /// Each anchor snaps to Smart Guides (#506): hovering shows where it goes (the rubber band ends /// there), in line with another object's centre; Shift slides it along its 45° step into line. #[test] fn anchors_snap_to_smart_guides() { let (d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0), Point::new(60.0, 300.0)], false)); let p = paint(); let cx = cx(&d, &s, &p); let mut t = PenTool { drawing: true, ..PenTool::default() }; t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 152.0, 318.0)); let o = t.overlays(&cx); assert!(o.iter().any(|o| matches!(o, Overlay::Label { text, p, .. } if text == "align" && *p == Point::new(150.0, 318.0))), "{o:?}"); assert!(o.iter().any( |o| matches!(o, Overlay::Path { path, .. } if path.elements().last().and_then(|e| e.end_point()) == Some(Point::new(150.0, 318.0))) )); 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}))); 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}))); // 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()); assert_eq!(a[1], Action::Preview("path.create".into(), json!({"anchors": [{"x": 100.0, "y": 100.0}]}))); } #[test] fn alt_over_a_shown_handle_or_an_anchor_converts() { // The middle anchor is smooth: handles at (150, 100) and (250, 100) round (200, 100). let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 200.0), Point::new(200.0, 100.0), Point::new(300.0, 200.0)], false); sp.anchors[1] = vectorcraft_geom::Anchor::smooth(Point::new(200.0, 100.0), Point::new(250.0, 100.0)); let (d, id, mut s) = drawing(sp); let p = paint(); let alt = Mods { alt: true, ..Mods::default() }; let t = PenTool::default(); let cx0 = cx(&d, &s, &p); assert_eq!(t.cursor(&cx0, Point::new(250.0, 101.0), alt), Cursor::PenConvert); assert_eq!(t.cursor(&cx0, Point::new(200.0, 101.0), alt), Cursor::PenConvert); assert_eq!(t.cursor(&cx0, Point::new(250.0, 101.0), Mods::default()), Cursor::Pen); assert_eq!(t.cursor(&cx0, Point::new(150.0, 300.0), alt), Cursor::Pen); // A drag of the handle moves it alone, as one gesture. let mut t = PenTool::default(); assert_eq!(t.pointer(&cx0, &PointerEvent::new(PointerKind::Down, 250.0, 101.0).with_mods(alt)), vec![]); assert!(t.busy()); let a = t.pointer(&cx0, &PointerEvent::new(PointerKind::Drag, 260.0, 140.0).with_mods(alt)); assert_eq!( a, vec![ Action::Begin("Reshape".into()), Action::Preview( "path.setHandle".into(), json!({"id": id.0, "subpath": 0, "anchor": 1, "which": "out", "x": 260.0, "y": 140.0, "independent": true}) ) ] ); // Alt let go mid-drag: the gesture goes on to its end. assert_eq!(t.pointer(&cx0, &PointerEvent::new(PointerKind::Up, 260.0, 140.0)), vec![Action::Commit]); assert!(!t.busy()); // With other anchors direct-selected the handles of this one are hidden, and not grabbed. s.anchors.insert(id, std::collections::BTreeSet::from([(0, 0)])); let cx1 = cx(&d, &s, &p); assert_eq!(t.cursor(&cx1, Point::new(250.0, 101.0), alt), Cursor::Pen); } /// #494: Alt held over a segment of a selected path reshapes it as the Anchor Point tool does, /// while drawing too; Alt away from paths still places an anchor. #[test] fn alt_over_a_segment_reshapes_it() { let (d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0)], false)); let p = paint(); let cx = cx(&d, &s, &p); let alt = Mods { alt: true, ..Mods::default() }; let mut t = PenTool { drawing: true, ..PenTool::default() }; assert_eq!(t.cursor(&cx, Point::new(150.0, 302.0), alt), Cursor::PenConvert); assert_eq!(t.cursor(&cx, Point::new(150.0, 302.0), Mods::default()), Cursor::Pen); assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 302.0).with_mods(alt)), vec![]); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 262.0).with_mods(alt)); assert_eq!(a[0], Action::Begin("Reshape".into())); assert!( matches!(&a[1], Action::Preview(c, v) if c == "path.reshapeSegment" && v["id"] == id.0 && v["segment"] == 0 && v["dy"] == -40.0), "{a:?}" ); assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 262.0)), vec![Action::Commit]); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 250.0, 400.0).with_mods(alt)); assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.appendAnchor"), "{a:?}"); } /// #494: the Pen goes on drawing the path it drew while that path stays selected (after a Cmd /// drag with Direct Selection), but never another open path selected meanwhile. #[test] fn the_pen_draws_on_the_path_it_began_only() { let (mut d, a, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0), Point::new(60.0, 300.0)], false)); let l = d.layers[0].id; let b = d.alloc_id(); let line = vectorcraft_geom::shapes::line(Point::new(10.0, 400.0), Point::new(60.0, 400.0)); d.insert(Some(l), 2, vectorcraft_doc::Node::path(b, line, vectorcraft_doc::Appearance::default_art())).unwrap(); let p = paint(); let mut t = PenTool { drawing: true, ..PenTool::default() }; let cx_a = cx(&d, &s, &p); let appended = |acts: &[Action]| match acts { [Action::Begin(_), Action::Preview(c, v)] if c == "path.appendAnchor" => v["id"].as_u64(), _ => None, }; assert_eq!(appended(&click(&mut t, &cx_a, 100.0, 320.0, Mods::default())), Some(a.0)); // Path B selected instead: the next click starts a new path. let mut sb = Selection::default(); sb.set([b]); let cx_b = cx(&d, &sb, &p); assert_eq!(t.cursor(&cx_b, Point::new(150.0, 350.0), Mods::default()), Cursor::Pen); let acts = click(&mut t, &cx_b, 150.0, 350.0, Mods::default()); assert!(matches!(&acts[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.create"), "{acts:?}"); } }