mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 03:06:57 -06:00
Shift pressed while dragging a segment had no effect on the curve: Direct Selection's curved-segment and spine-segment drags, the Anchor Point tool and the Pen with Alt sent the pointer offset alone, and the segment bent freely. The reference app keeps the handles in the perpendicular direction and of equal length while Shift is held. path.reshapeSegment and object.blend.spine.reshapeSegment take perpendicular (default false, which keeps today's behavior). With it, both of the segment's handles stand at right angles to the line between its anchors, on the same side and at the same length, and the point at t lies as far from that line as the moved point does. A smooth anchor at either end stays smooth, as it does in a drag without perpendicular, and a segment whose anchors coincide bends as it does without perpendicular. With Direct Selection, Shift pressed once a curved segment's drag has started sends perpendicular; Shift held at the press still toggles the path's selection, as before. Direct Selection's spine-segment drag and the Anchor Point tool's segment drag send Shift as perpendicular too, so the Pen with Alt does as well. A straight segment dragged with Direct Selection already moves at 45° steps with Shift. Docs: docs/mcp.md, docs/target-app-parity.md.
1338 lines
71 KiB
Rust
1338 lines
71 KiB
Rust
//! 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 an end of an open path continues it
|
|
//! (selecting it); while drawing, clicking an end of another open path joins the two into one
|
|
//! (#776). 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).
|
|
//! The pointer shows the new-path asterisk where a click starts a new path. While a path is being
|
|
//! drawn it shows no badge, except where a click closes the path, joins another open path or
|
|
//! converts an anchor.
|
|
//! 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, editable_paths};
|
|
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<NodeId>,
|
|
/// The anchor a press placed, while the drag shapes its handles.
|
|
drag: Option<Place>,
|
|
hover: Option<Point>,
|
|
/// 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,
|
|
/// Where the button went down: the drag threshold is measured from it.
|
|
pressed: Point,
|
|
/// The pointer has moved past the drag threshold since the press: a press released inside it
|
|
/// is a click, so the anchor stays a corner without handles.
|
|
dragged: bool,
|
|
/// 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<AnchorTool>,
|
|
/// Smart Guides for the anchors placed (and the pointer hovering).
|
|
snap: DrawSnap,
|
|
}
|
|
|
|
/// Screen pixels the pointer must move after a press before it shapes handles instead of clicking.
|
|
const DRAG_THRESHOLD: f64 = 3.0;
|
|
|
|
/// 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<NodeId>,
|
|
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))
|
|
}
|
|
|
|
/// 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<NodeId>) -> 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) {
|
|
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 (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))
|
|
}
|
|
|
|
/// 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 (or of one inside a
|
|
/// selected group or compound path, whose anchors show too): the Anchor Point tool's gesture,
|
|
/// within its tolerance.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum Endpoint {
|
|
First,
|
|
Last,
|
|
}
|
|
/// Pick the closest eligible endpoint at `p`. The caller supplies the historical
|
|
/// tie choice: closing prefers the first end, resuming prefers the last. A one-anchor path has
|
|
/// no eligible closing end.
|
|
fn pick_endpoint(first: Option<Point>, last: Point, p: Point, tol: f64, tie: Endpoint) -> Option<Endpoint> {
|
|
let first_distance = first.map(|a| p.distance(a)).filter(|d| *d <= tol);
|
|
let last_distance = p.distance(last);
|
|
let last_distance = (last_distance <= tol).then_some(last_distance);
|
|
match (first_distance, last_distance) {
|
|
(Some(f), Some(l)) if f < l => Some(Endpoint::First),
|
|
(Some(f), Some(l)) if l < f => Some(Endpoint::Last),
|
|
(Some(_), Some(_)) => Some(tie),
|
|
(Some(_), None) => Some(Endpoint::First),
|
|
(None, Some(_)) => Some(Endpoint::Last),
|
|
(None, None) => None,
|
|
}
|
|
}
|
|
/// 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, crate::draw2::shown_paths(cx), p, point).is_some()
|
|
|| crate::draw2::segment_in(cx, crate::draw2::shown_paths(cx), p, cx.pick_tol()).is_some())
|
|
}
|
|
|
|
/// The cursor where [`alt_converts`]: Alt lends the Anchor Point tool, so its caret over a handle
|
|
/// end or an anchor, the Reshape Segment cursor over a segment.
|
|
fn alt_cursor(cx: &ToolContext, p: Point) -> Cursor {
|
|
let point = cx.point_tol();
|
|
if hit_handle(cx, p, point).is_some() || crate::draw2::anchor_in(cx, crate::draw2::shown_paths(cx), p, point).is_some() {
|
|
Cursor::AnchorPoint
|
|
} else {
|
|
Cursor::ReshapeSegment
|
|
}
|
|
}
|
|
|
|
/// 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<Action> {
|
|
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;
|
|
self.pressed = ev.pos;
|
|
self.dragged = false;
|
|
if let Some((id, first, last, _)) = active {
|
|
let end = last_anchor(cx, id);
|
|
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}))];
|
|
}
|
|
// Alt uses Anchor Point behavior at the terminal anchor too.
|
|
if let Some(acts) = self.alt_convert(cx, ev) {
|
|
return acts;
|
|
}
|
|
if picked == Some(Endpoint::Last)
|
|
&& 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)];
|
|
}
|
|
let raw_other = open_end_at(cx, ev.pos, tol, Some(id));
|
|
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}))];
|
|
}
|
|
if snapped == Some(Endpoint::Last)
|
|
&& 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)];
|
|
}
|
|
}
|
|
// 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))) {
|
|
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]}))];
|
|
}
|
|
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;
|
|
}
|
|
// 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)
|
|
&& let Some(picked) = pick_endpoint(Some(first), last, at, tol, Endpoint::Last)
|
|
{
|
|
self.drawing = true;
|
|
self.path = Some(id);
|
|
if picked == Endpoint::First {
|
|
return vec![Action::Exec("path.reverse".into(), json!({}))];
|
|
}
|
|
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 at_first {
|
|
out.push(Action::Exec("path.reverse".into(), json!({"ids": [id.0]})));
|
|
}
|
|
return out;
|
|
}
|
|
}
|
|
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);
|
|
}
|
|
// Illustrator treats a press as a drag only past a few screen pixels. `last` follows the
|
|
// pointer meanwhile, so Space pressed before the threshold moves the anchor by the
|
|
// next move only.
|
|
if !self.dragged {
|
|
if ev.pos.distance(self.pressed) < cx.tol(DRAG_THRESHOLD) {
|
|
self.last = ev.pos;
|
|
return vec![];
|
|
}
|
|
self.dragged = true;
|
|
}
|
|
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<Action> {
|
|
match key {
|
|
ToolKey::Enter | ToolKey::Escape => self.deactivate(cx),
|
|
_ => vec![],
|
|
}
|
|
}
|
|
fn deactivate(&mut self, cx: &ToolContext) -> Vec<Action> {
|
|
self.stop();
|
|
self.snap.clear();
|
|
let placing = self.drag.take().is_some();
|
|
let handling = self.handle.take().is_some();
|
|
// Finish the preview as it stands; clearing state alone leaves its transaction open.
|
|
let mut acts = self.convert.take().map(|mut t| t.deactivate(cx)).unwrap_or_default();
|
|
if placing || handling {
|
|
acts.push(Action::Commit);
|
|
}
|
|
acts
|
|
}
|
|
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
|
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 {
|
|
// An Alt gesture under way keeps the Anchor Point tool's cursor.
|
|
if let Some(t) = &self.convert {
|
|
return t.cursor(cx, p, m);
|
|
}
|
|
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);
|
|
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() {
|
|
return Cursor::PenJoin;
|
|
}
|
|
}
|
|
if alt_converts(cx, p, m) {
|
|
return alt_cursor(cx, p);
|
|
}
|
|
// These show whenever no path is being drawn, also after another path was selected while
|
|
// the Pen was drawing.
|
|
if active.is_none() {
|
|
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, _)| pick_endpoint(Some(first), last, p, tol, Endpoint::Last).is_some())
|
|
|| open_end_at(cx, p, tol, None).is_some()
|
|
{
|
|
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 => {}
|
|
}
|
|
}
|
|
// The new-path asterisk shows only while a click starts a new path.
|
|
if active.is_some() { Cursor::PenDrawing } else { 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<Vec<Action>> {
|
|
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<Action> {
|
|
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<Vec<Action>> {
|
|
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 press_inside_drag_threshold_places_a_corner() {
|
|
let (d, s) = (vectorcraft_doc::Document::new(500.0, 500.0), Selection::default());
|
|
let paint = paint();
|
|
let cx =
|
|
ToolContext { zoom: 1.0, smart_guides: false, snap_to_point: false, snap_to_pixel: false, snap_to_grid: false, ..cx(&d, &s, &paint) };
|
|
let mut pen = PenTool::default();
|
|
pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0));
|
|
assert!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 101.0, 101.0)).is_empty());
|
|
assert_eq!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Up, 101.0, 101.0)), vec![Action::Commit]);
|
|
pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 200.0));
|
|
assert_eq!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 204.0, 200.0)).len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn snapped_last_endpoint_converts_active_path_after_raw_miss() {
|
|
let first = Point::new(100.0, 300.0);
|
|
let last = Point::new(200.0, 400.0);
|
|
let (mut d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[first, Point::new(200.0, 300.0), last], false));
|
|
d.grid.spacing = 100.0;
|
|
d.grid.subdivisions = 1;
|
|
let paint = paint();
|
|
let cx = ToolContext {
|
|
zoom: 1.0,
|
|
selection_tolerance: 8.0,
|
|
smart_guides: false,
|
|
snap_to_point: false,
|
|
snap_to_pixel: false,
|
|
snap_to_grid: true,
|
|
..cx(&d, &s, &paint)
|
|
};
|
|
let sp = &d.node(id).unwrap().path_data().unwrap().subpaths[0];
|
|
assert!(!sp.closed);
|
|
assert_eq!(sp.anchors.iter().map(|a| a.p).collect::<Vec<_>>(), 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!(!pen.busy());
|
|
|
|
let raw = Point::new(237.0, 403.0);
|
|
let tol = cx.tol(5.0).max(cx.point_tol());
|
|
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_eq!(
|
|
pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, raw.x, raw.y)),
|
|
vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 2, last.x, last.y)]
|
|
);
|
|
assert_eq!(pen.handle, Some((id, 0, 2, last)));
|
|
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)));
|
|
}
|
|
|
|
#[test]
|
|
fn raw_other_endpoint_wins_before_snapped_active_last_endpoint() {
|
|
let first = Point::new(100.0, 300.0);
|
|
let last = Point::new(200.0, 400.0);
|
|
let raw = Point::new(237.0, 403.0);
|
|
let (mut d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[first, Point::new(200.0, 300.0), last], false));
|
|
let other = d.alloc_id();
|
|
let path = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&[raw, Point::new(337.0, 403.0)], false));
|
|
d.insert(Some(d.layers[0].id), 2, vectorcraft_doc::Node::path(other, path, vectorcraft_doc::Appearance::default_art())).unwrap();
|
|
d.grid.spacing = 100.0;
|
|
d.grid.subdivisions = 1;
|
|
let paint = paint();
|
|
let cx = ToolContext {
|
|
zoom: 1.0,
|
|
selection_tolerance: 8.0,
|
|
smart_guides: false,
|
|
snap_to_point: false,
|
|
snap_to_pixel: false,
|
|
snap_to_grid: true,
|
|
..cx(&d, &s, &paint)
|
|
};
|
|
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!(!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)));
|
|
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"]}))]
|
|
);
|
|
assert!(!pen.busy() && pen.active(&cx).is_none());
|
|
assert!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Up, raw.x, raw.y)).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn snapped_first_endpoint_closes_active_path_after_raw_miss() {
|
|
let first = Point::new(100.0, 300.0);
|
|
let last = Point::new(200.0, 400.0);
|
|
let (mut d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[first, Point::new(200.0, 300.0), last], false));
|
|
d.grid.spacing = 100.0;
|
|
d.grid.subdivisions = 1;
|
|
let paint = paint();
|
|
let cx = ToolContext {
|
|
zoom: 1.0,
|
|
selection_tolerance: 8.0,
|
|
smart_guides: false,
|
|
snap_to_point: false,
|
|
snap_to_pixel: false,
|
|
snap_to_grid: true,
|
|
..cx(&d, &s, &paint)
|
|
};
|
|
let sp = &d.node(id).unwrap().path_data().unwrap().subpaths[0];
|
|
assert!(!sp.closed);
|
|
assert_eq!(sp.anchors.iter().map(|a| a.p).collect::<Vec<_>>(), 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!(!pen.busy());
|
|
|
|
let raw = Point::new(63.0, 303.0);
|
|
let tol = cx.tol(5.0).max(cx.point_tol());
|
|
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_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}))]
|
|
);
|
|
let place = pen.drag.unwrap();
|
|
assert!(place.closing);
|
|
assert_eq!((place.path, place.at), (Some(id), first));
|
|
assert_eq!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Up, raw.x, raw.y)), vec![Action::Commit]);
|
|
assert!(!pen.busy() && pen.active(&cx).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn raw_other_endpoint_wins_before_snapped_active_first_endpoint() {
|
|
let first = Point::new(100.0, 300.0);
|
|
let last = Point::new(200.0, 400.0);
|
|
let raw = Point::new(63.0, 303.0);
|
|
let (mut d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[first, Point::new(200.0, 300.0), last], false));
|
|
let other = d.alloc_id();
|
|
let path = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&[raw, Point::new(63.0, 403.0)], false));
|
|
d.insert(Some(d.layers[0].id), 2, vectorcraft_doc::Node::path(other, path, vectorcraft_doc::Appearance::default_art())).unwrap();
|
|
d.grid.spacing = 100.0;
|
|
d.grid.subdivisions = 1;
|
|
let paint = paint();
|
|
let cx = ToolContext {
|
|
zoom: 1.0,
|
|
selection_tolerance: 8.0,
|
|
smart_guides: false,
|
|
snap_to_point: false,
|
|
snap_to_pixel: false,
|
|
snap_to_grid: true,
|
|
..cx(&d, &s, &paint)
|
|
};
|
|
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!(!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)));
|
|
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"]}))]
|
|
);
|
|
assert!(!pen.busy() && pen.active(&cx).is_none());
|
|
assert!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Up, raw.x, raw.y)).is_empty());
|
|
}
|
|
|
|
#[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<Action>| 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_pressed_inside_the_drag_threshold_moves_the_anchor_by_the_next_move_only() {
|
|
let (d, s) = (vectorcraft_doc::Document::new(500.0, 500.0), Selection::default());
|
|
let paint = paint();
|
|
let cx =
|
|
ToolContext { zoom: 1.0, smart_guides: false, snap_to_point: false, snap_to_pixel: false, snap_to_grid: false, ..cx(&d, &s, &paint) };
|
|
let space = Mods { space: true, ..Mods::default() };
|
|
let mut pen = PenTool::default();
|
|
pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0));
|
|
// Still inside the threshold, Space held: nothing happens yet.
|
|
assert!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 102.0, 100.0).with_mods(space)).is_empty());
|
|
// The move that crosses it moves the anchor by that move (2 px to 4 px), not by the 4 px since the press.
|
|
let acts = pen.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 104.0, 100.0).with_mods(space));
|
|
let Some(Action::Preview(c, v)) = acts.first() else { panic!("not a preview: {acts:?}") };
|
|
assert_eq!(c, "path.create");
|
|
assert_eq!(v["anchors"][0]["x"], json!(102.0), "moved by the delta");
|
|
assert_eq!(v["anchors"][0]["y"], json!(100.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<Action>| 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);
|
|
}
|
|
|
|
/// #544: picking the terminal anchor uses the pointer, even when new points snap elsewhere.
|
|
#[test]
|
|
fn terminal_anchor_conversion_precedes_grid_snapping() {
|
|
let (mut d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(13.0, 303.0), Point::new(63.0, 303.0)], false));
|
|
d.grid.spacing = 100.0;
|
|
d.grid.subdivisions = 1;
|
|
let p = paint();
|
|
let cx = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) };
|
|
let mut t = PenTool { drawing: true, ..PenTool::default() };
|
|
assert_eq!(t.cursor(&cx, Point::new(63.0, 303.0), Mods::default()), Cursor::PenConvert);
|
|
assert_eq!(
|
|
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 63.0, 303.0)),
|
|
vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 1, 63.0, 303.0)]
|
|
);
|
|
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 63.0, 303.0)), vec![Action::Commit]);
|
|
// The same raw picking applies when resuming the path and when closing it.
|
|
let mut t = PenTool::default();
|
|
assert_eq!(t.cursor(&cx, Point::new(63.0, 303.0), Mods::default()), Cursor::PenContinue);
|
|
assert!(click(&mut t, &cx, 63.0, 303.0, Mods::default()).is_empty());
|
|
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}))]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn smart_guides_cannot_steal_a_terminal_anchor_press() {
|
|
let (mut d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0), Point::new(60.0, 300.0)], false));
|
|
let other = d.alloc_id();
|
|
let rect = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(66.0, 300.0, 76.0, 310.0));
|
|
d.insert(Some(d.layers[0].id), 2, vectorcraft_doc::Node::path(other, rect, vectorcraft_doc::Appearance::default_art())).unwrap();
|
|
let p = paint();
|
|
let cx = cx(&d, &s, &p);
|
|
let pos = Point::new(63.0, 300.0);
|
|
assert_eq!(crate::guides::snap_draw(&cx, pos, &[id]).0, Point::new(66.0, 300.0));
|
|
let mut t = PenTool { drawing: true, ..PenTool::default() };
|
|
assert_eq!(t.cursor(&cx, pos, Mods::default()), Cursor::PenConvert);
|
|
assert_eq!(
|
|
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pos.x, pos.y)),
|
|
vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 1, 60.0, 300.0)]
|
|
);
|
|
}
|
|
|
|
/// Nearby endpoints share a pick radius: use the closest one for cursor and press.
|
|
#[test]
|
|
fn overlapping_endpoint_tolerances_pick_the_nearest_end() {
|
|
let paint = paint();
|
|
let mut failures = vec![];
|
|
for zoom in [0.5, 1.0, 2.0] {
|
|
let first = Point::new(100.0, 300.0);
|
|
let last = Point::new(100.0 + 6.0 / zoom, 300.0);
|
|
let (d, id, selection) = drawing(vectorcraft_geom::SubPath::polyline(&[first, last], false));
|
|
for grid in [false, true] {
|
|
let cx = ToolContext {
|
|
zoom,
|
|
selection_tolerance: 8.0,
|
|
smart_guides: false,
|
|
snap_to_point: false,
|
|
snap_to_grid: grid,
|
|
..cx(&d, &selection, &paint)
|
|
};
|
|
for shift in [false, true] {
|
|
let mods = Mods { shift, ..Mods::default() };
|
|
// Include off-anchor clicks in both halves of the overlap and the midpoint.
|
|
for offset in [6.0, 0.0, 1.0, 3.0, 5.0] {
|
|
let at = Point::new(first.x + offset / zoom, first.y);
|
|
let first_end = offset <= 3.0;
|
|
let mut pen = PenTool { drawing: true, ..PenTool::default() };
|
|
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}))]
|
|
} else {
|
|
vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 1, last.x, last.y)]
|
|
};
|
|
let actual = pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(mods));
|
|
if actual_cursor != cursor || actual != expected {
|
|
failures.push((zoom, grid, shift, offset, actual_cursor, actual));
|
|
}
|
|
assert_eq!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x, at.y)), vec![Action::Commit]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
assert!(failures.is_empty(), "cursor and press must pick the closest eligible endpoint: {failures:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn overlapping_endpoint_tolerances_continue_from_the_nearest_end() {
|
|
let paint = paint();
|
|
let mut failures = vec![];
|
|
for zoom in [0.5, 1.0, 2.0] {
|
|
let first = Point::new(100.0, 300.0);
|
|
let last = Point::new(100.0 + 6.0 / zoom, 300.0);
|
|
let (d, id, selection) = drawing(vectorcraft_geom::SubPath::polyline(&[first, last], false));
|
|
for grid in [false, true] {
|
|
let cx = ToolContext {
|
|
zoom,
|
|
selection_tolerance: 8.0,
|
|
smart_guides: false,
|
|
snap_to_point: false,
|
|
snap_to_grid: grid,
|
|
..cx(&d, &selection, &paint)
|
|
};
|
|
for offset in [6.0, 0.0, 1.0, 3.0, 5.0] {
|
|
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 actual = click(&mut pen, &cx, at.x, at.y, Mods::default());
|
|
if actual != expected {
|
|
failures.push((zoom, grid, offset, actual));
|
|
}
|
|
assert_eq!(pen.active(&cx).map(|a| a.0), Some(id));
|
|
}
|
|
}
|
|
}
|
|
assert!(failures.is_empty(), "resuming must reverse only for the closest first endpoint: {failures:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn coincident_endpoints_keep_the_prior_tie_choice() {
|
|
let at = Point::new(100.0, 300.0);
|
|
let (d, id, selection) = drawing(vectorcraft_geom::SubPath::polyline(&[at, at], false));
|
|
let paint = paint();
|
|
for zoom in [0.5, 1.0, 2.0] {
|
|
let cx = ToolContext { zoom, smart_guides: false, snap_to_point: false, ..cx(&d, &selection, &paint) };
|
|
let mut drawing = PenTool { drawing: true, ..PenTool::default() };
|
|
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}))]
|
|
);
|
|
let mut resuming = PenTool::default();
|
|
assert_eq!(resuming.cursor(&cx, at, Mods::default()), Cursor::PenContinue);
|
|
assert!(click(&mut resuming, &cx, at.x, at.y, Mods::default()).is_empty());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn terminal_anchor_uses_the_selection_tolerance_at_each_zoom() {
|
|
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();
|
|
for zoom in [0.5, 2.0] {
|
|
let cx = ToolContext { zoom, selection_tolerance: 8.0, smart_guides: false, snap_to_point: false, ..cx(&d, &s, &p) };
|
|
let pos = Point::new(60.0, 300.0 + 6.0 / zoom);
|
|
let mut t = PenTool { drawing: true, ..PenTool::default() };
|
|
assert_eq!(t.cursor(&cx, pos, Mods::default()), Cursor::PenConvert);
|
|
assert_eq!(
|
|
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pos.x, pos.y)),
|
|
vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 1, 60.0, 300.0)]
|
|
);
|
|
let narrow = ToolContext { selection_tolerance: 1.0, ..cx };
|
|
// The upstream point picker retains a minimum screen radius at low tolerance.
|
|
let near = Point::new(60.0, 300.0 + 4.0 / zoom);
|
|
let mut narrow_pen = PenTool { drawing: true, ..PenTool::default() };
|
|
assert_eq!(narrow_pen.cursor(&narrow, near, Mods::default()), Cursor::PenConvert);
|
|
assert_eq!(
|
|
click(&mut narrow_pen, &narrow, near.x, near.y, Mods::default()),
|
|
vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 1, 60.0, 300.0)]
|
|
);
|
|
assert_eq!(narrow_pen.cursor(&narrow, Point::new(60.0, 300.0 + 6.0 / zoom), Mods::default()), Cursor::PenDrawing);
|
|
}
|
|
}
|
|
|
|
/// Upstream's point radius grows with the drawn anchor; cursor and press keep agreeing.
|
|
#[test]
|
|
fn enlarged_anchor_radius_is_used_for_endpoints_and_alt_conversion() {
|
|
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();
|
|
for zoom in [0.5, 1.0, 2.0] {
|
|
let cx = ToolContext { zoom, selection_tolerance: 1.0, anchor_size: 7, smart_guides: false, snap_to_point: false, ..cx(&d, &s, &p) };
|
|
let at = Point::new(60.0 + 6.0 / zoom, 300.0);
|
|
let mut terminal = PenTool { drawing: true, ..PenTool::default() };
|
|
assert_eq!(terminal.cursor(&cx, at, Mods::default()), Cursor::PenConvert);
|
|
assert_eq!(
|
|
click(&mut terminal, &cx, at.x, at.y, Mods::default()),
|
|
vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 1, 60.0, 300.0)]
|
|
);
|
|
let mut continuing = PenTool::default();
|
|
assert_eq!(continuing.cursor(&cx, at, Mods::default()), Cursor::PenContinue);
|
|
assert!(click(&mut continuing, &cx, at.x, at.y, Mods::default()).is_empty());
|
|
assert_eq!(continuing.active(&cx).map(|a| a.0), Some(id));
|
|
let mut closing = PenTool { drawing: true, ..PenTool::default() };
|
|
let first = Point::new(10.0 - 6.0 / zoom, 300.0);
|
|
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}))]
|
|
);
|
|
let mut alt_pen = PenTool { drawing: true, ..PenTool::default() };
|
|
let alt = Mods { alt: true, ..Mods::default() };
|
|
assert!(alt_pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(alt)).is_empty());
|
|
assert!(alt_pen.busy(), "Alt must reach the enlarged terminal-anchor target");
|
|
assert!(alt_pen.convert.is_some());
|
|
let away_from_segment = Point::new(35.0, 300.0 + 4.0 / zoom);
|
|
assert!(!alt_converts(&cx, away_from_segment, alt), "segment picking retains the smaller selection radius");
|
|
}
|
|
}
|
|
|
|
/// #516: Alt on the terminal anchor borrows Anchor Point, just like the other anchors.
|
|
#[test]
|
|
fn alt_on_the_terminal_anchor_pulls_symmetric_handles() {
|
|
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!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 300.0).with_mods(alt)).is_empty());
|
|
assert_eq!(
|
|
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 230.0, 330.0).with_mods(alt)),
|
|
vec![
|
|
Action::Begin("Convert Anchor Point".into()),
|
|
Action::Preview("path.convertAnchor".into(), json!({"id": id.0, "subpath": 0, "anchor": 1, "to": "smooth", "x": 230.0, "y": 330.0}))
|
|
]
|
|
);
|
|
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 230.0, 330.0)), vec![Action::Commit]);
|
|
assert!(
|
|
matches!(click(&mut t, &cx, 300.0, 400.0, Mods::default()).as_slice(), [Action::Begin(_), Action::Preview(c, _)] if c == "path.appendAnchor")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ending_during_a_pen_gesture_commits_and_clears_it() {
|
|
let (d, _, 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);
|
|
for terminal in [false, true] {
|
|
for key in [Some(ToolKey::Escape), Some(ToolKey::Enter), None] {
|
|
let mut t = PenTool { drawing: true, ..PenTool::default() };
|
|
let x = if terminal { 200.0 } else { 300.0 };
|
|
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, 300.0));
|
|
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x + 20.0, 330.0));
|
|
let acts = match key {
|
|
Some(k) => t.key(&cx, k, Mods::default()),
|
|
None => t.deactivate(&cx),
|
|
};
|
|
assert_eq!(acts, vec![Action::Commit]);
|
|
assert!(!t.busy());
|
|
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x + 20.0, 330.0)).is_empty());
|
|
assert_eq!(t.cursor(&cx, Point::new(400.0, 400.0), Mods::default()), Cursor::Pen);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// #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<Action> {
|
|
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);
|
|
// Alt lends the Anchor Point tool, and its caret with it.
|
|
assert_eq!(t.cursor(&cx0, Point::new(250.0, 101.0), alt), Cursor::AnchorPoint);
|
|
assert_eq!(t.cursor(&cx0, Point::new(200.0, 101.0), alt), Cursor::AnchorPoint);
|
|
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::ReshapeSegment);
|
|
assert_eq!(t.cursor(&cx, Point::new(150.0, 302.0), Mods::default()), Cursor::PenDrawing);
|
|
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!(t.cursor(&cx, Point::new(150.0, 262.0), alt), Cursor::ReshapeSegment, "kept during the reshape");
|
|
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 && v["perpendicular"] == false),
|
|
"{a:?}"
|
|
);
|
|
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 262.0)), vec![Action::Commit]);
|
|
// Shift held too keeps the handles at right angles to the line between the anchors.
|
|
let alt_shift = Mods { shift: true, ..alt };
|
|
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 302.0).with_mods(alt));
|
|
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 160.0, 262.0).with_mods(alt_shift));
|
|
assert!(matches!(&a[1], Action::Preview(c, v) if c == "path.reshapeSegment" && v["perpendicular"] == true), "{a:?}");
|
|
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 160.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);
|
|
// An end of B shows what a click there does: it goes on drawing B.
|
|
assert_eq!(t.cursor(&cx_b, Point::new(60.0, 400.0), Mods::default()), Cursor::PenContinue);
|
|
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:?}");
|
|
}
|
|
|
|
/// The new-path asterisk shows until a path is started; while one is being drawn the pen has no
|
|
/// badge, and the asterisk comes back once the path is finished.
|
|
#[test]
|
|
fn the_new_path_asterisk_shows_only_before_a_path_is_started() {
|
|
let (d, _, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0)], false));
|
|
let p = paint();
|
|
let cx = cx(&d, &s, &p);
|
|
let empty = Point::new(250.0, 450.0);
|
|
let mut t = PenTool::default();
|
|
assert_eq!(t.cursor(&cx, empty, Mods::default()), Cursor::Pen, "no path started");
|
|
t.drawing = true;
|
|
assert_eq!(t.cursor(&cx, empty, Mods::default()), Cursor::PenDrawing, "after the first anchor");
|
|
let a = click(&mut t, &cx, empty.x, empty.y, Mods::default());
|
|
assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.appendAnchor"), "{a:?}");
|
|
assert_eq!(t.cursor(&cx, empty, Mods::default()), Cursor::PenDrawing);
|
|
assert_eq!(t.cursor(&cx, empty, Mods { shift: true, ..Mods::default() }), Cursor::PenDrawing, "Shift constrains the next anchor");
|
|
t.key(&cx, ToolKey::Enter, Mods::default());
|
|
assert_eq!(t.cursor(&cx, empty, Mods::default()), Cursor::Pen, "the path is finished");
|
|
assert_eq!(Cursor::PenDrawing.precise(), Cursor::Crosshair);
|
|
}
|
|
}
|