Snap: pen and anchor points

This commit is contained in:
Cameron Gilroy
2026-10-06 09:05:59 -04:00
committed by Brandon Thomas
parent e76570c875
commit 09c8dc3f06
4 changed files with 158 additions and 22 deletions
+3 -1
View File
@@ -237,7 +237,9 @@ fn pen_draws_and_direct_selection_edits() {
s.pointer(&PointerEvent::new(PointerKind::Drag, 90.0, 80.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 90.0, 80.0), v).unwrap();
let a = s.doc().unwrap().doc.item(id).unwrap().path.subpaths[0].anchors[0].p;
assert_eq!(a, designcraft_geom::Point::new(90.0, 80.0));
// The pen placed this anchor on the baseline at y 96. Dragging toward (90, 80)
// proposes y 76, and the baseline at 72 is inside the zone, so the anchor lands at (90, 72).
assert_eq!(a, designcraft_geom::Point::new(90.0, 72.0));
}
#[test]
+32 -9
View File
@@ -17,6 +17,8 @@ pub struct PenTool {
anchors: Vec<(Point, Point, Point)>,
dragging: bool,
hover: Point,
/// Smart guides in canvas coordinates, as `snap_point` returned them.
guides: Vec<Overlay>,
}
fn anchor_json(a: &(Point, Point, Point)) -> Value {
@@ -29,8 +31,20 @@ impl PenTool {
self.spread = None;
self.anchors.clear();
self.dragging = false;
self.guides.clear();
vec![]
}
/// Shift constrains first. The stored point is the snapped spread point. Guides are already canvas.
fn snap_click(&mut self, cx: &ToolContext, spread: SpreadRef, mut p: Point, shift: bool) -> Point {
if shift && let Some(last) = self.anchors.last() {
p = last.0 + designcraft_geom::constrain_angle(p - last.0, 45.0);
}
let (p, guides) = crate::snap::snap_point(cx, spread, p);
self.guides = guides;
p
}
fn commit_anchor(&mut self) -> Vec<Action> {
let Some(sr) = self.spread else { return vec![] };
let Some(last) = self.anchors.last() else { return vec![] };
@@ -51,18 +65,27 @@ impl Tool for PenTool {
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let Some((sr, sp)) = cx.layout.spread_at(ev.pos) else { return vec![] };
let Some((sr, sp)) = cx.layout.spread_at(ev.pos) else {
self.guides.clear();
return vec![];
};
self.hover = ev.pos;
// The pen continues on the spread where the path started.
let sp = match self.spread {
Some(s) if s != sr => cx.layout.to_spread(s, ev.pos),
_ => sp,
};
let snap_spread = self.spread.unwrap_or(sr);
// Pick up the item id created by path.create (the newest selected path).
if self.path.is_none() && self.anchors.len() >= 2 {
self.path = cx.selection.items.last().map(|i| i.0);
}
match ev.kind {
PointerKind::Move => {
let p = self.snap_click(cx, snap_spread, sp, ev.mods.shift);
self.hover = cx.layout.to_canvas(snap_spread, p);
vec![]
}
PointerKind::Down => {
// Close on the first anchor.
if self.anchors.len() >= 2
@@ -76,12 +99,7 @@ impl Tool for PenTool {
self.finish();
return out;
}
let mut p = sp;
if ev.mods.shift
&& let Some(last) = self.anchors.last()
{
p = last.0 + designcraft_geom::constrain_angle(p - last.0, 45.0);
}
let p = self.snap_click(cx, snap_spread, sp, ev.mods.shift);
if self.anchors.is_empty() {
self.spread = Some(sr);
}
@@ -120,9 +138,12 @@ impl Tool for PenTool {
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let Some(sr) = self.spread else { return vec![] };
let xf = cx.layout.xf(sr);
let mut out = Vec::new();
let Some(sr) = self.spread else {
out.extend(self.guides.iter().cloned());
return out;
};
let xf = cx.layout.xf(sr);
if let Some(last) = self.anchors.last()
&& !self.dragging
{
@@ -139,6 +160,8 @@ impl Tool for PenTool {
out.push(Overlay::Line { a: xf * a.1, b: xf * a.2, color: [79, 153, 255], dashed: false });
let _ = BezPath::new();
}
// Guides are already canvas. Do not run them through the spread transform again.
out.extend(self.guides.iter().cloned());
out
}
+91 -11
View File
@@ -5,7 +5,7 @@
//! empty-canvas drags make a marquee, double-clicking a text frame switches to the Type tool.
use designcraft_doc::{ItemId, SpreadRef};
use designcraft_geom::{Point, Rect, Vec2};
use designcraft_geom::{Affine, Point, Rect, Vec2};
use serde_json::{Value, json};
use crate::{Action, Cursor, Gesture, Mods, Overlay, PointerEvent, PointerKind, SnapRequest, Tool, ToolContext, ToolKey, rect_json, spread_json};
@@ -13,12 +13,42 @@ use crate::{Action, Cursor, Gesture, Mods, Overlay, PointerEvent, PointerKind, S
#[derive(Clone, Debug)]
enum Drag {
None,
Pending { start: Point, hit: bool },
Move { start: Point, origin_spread: SpreadRef, bounds0: Option<Rect>, exclude: Vec<ItemId> },
Resize { handle: usize, start: Point, from: Rect, spread: SpreadRef },
Rotate { center: Point, start_angle: f64, start_rotation: Option<f64> },
Marquee { start: Point, cur: Point },
Anchor { id: u64, si: usize, ai: usize, handle: Option<&'static str>, start: Point },
Pending {
start: Point,
hit: bool,
},
Move {
start: Point,
origin_spread: SpreadRef,
bounds0: Option<Rect>,
exclude: Vec<ItemId>,
},
Resize {
handle: usize,
start: Point,
from: Rect,
spread: SpreadRef,
},
Rotate {
center: Point,
start_angle: f64,
start_rotation: Option<f64>,
},
Marquee {
start: Point,
cur: Point,
},
Anchor {
id: u64,
si: usize,
ai: usize,
handle: Option<&'static str>,
start: Point,
/// Spread and the anchor's spread position at pointer down.
/// None when that point could not be read. The live document during a drag is the
/// previous preview, so the snap must not read the anchor from it again.
at: Option<(SpreadRef, Point)>,
},
}
/// Anchor or handle of a selected item under `p` (canvas): (item, subpath, anchor, handle).
@@ -49,6 +79,20 @@ pub fn anchor_at_in(cx: &ToolContext, ids: &[designcraft_doc::ItemId], p: Point)
None
}
/// Spread holding the anchor, and the anchor in that spread's coordinates.
fn anchor_spread_point(cx: &ToolContext, id: u64, si: usize, ai: usize) -> Option<(SpreadRef, Point)> {
let item_id = ItemId(id);
let loc = cx.doc.find(item_id)?;
let it = cx.doc.item(item_id)?;
let local = it.path.subpaths.get(si)?.anchors.get(ai)?.p;
Some((loc.spread, (cx.doc.parent_xf(&loc) * it.xf) * local))
}
/// Spread-space correction as a canvas delta. Translation cancels, leaving the linear part of `xf`.
fn spread_delta_to_canvas(xf: Affine, d: Vec2) -> Vec2 {
(xf * Point::new(d.x, d.y)) - (xf * Point::ORIGIN)
}
pub struct SelectionTool {
direct: bool,
drag: Drag,
@@ -337,7 +381,8 @@ impl Tool for SelectionTool {
if self.direct
&& let Some((id, si, ai, handle)) = anchor_at(cx, p)
{
self.drag = Drag::Anchor { id, si, ai, handle, start: p };
let at = anchor_spread_point(cx, id, si, ai);
self.drag = Drag::Anchor { id, si, ai, handle, start: p, at };
return vec![Action::Begin(if handle.is_some() { "Move Direction Handle".into() } else { "Move Anchor".into() })];
}
if !self.direct
@@ -565,9 +610,44 @@ impl Tool for SelectionTool {
// Screen y points down: a positive screen angle is clockwise.
vec![Action::Preview("transform.rotate".into(), json!({"angle": -a}))]
}
Drag::Anchor { id, si, ai, handle, start } => {
Drag::Anchor { id, si, ai, handle, start, at } => {
let d = p - start;
let mut params = json!({"id": id, "anchors": [[si, ai]], "dx": d.x, "dy": d.y});
let mut dx = d.x;
let mut dy = d.y;
self.guides.clear();
// Direction handles do not snap. An anchor excludes the path it is on.
if handle.is_none()
&& let Some((spread, origin)) = at
{
let proposed = origin + cx.layout.delta_to_spread(spread, d);
let exclude = [ItemId(id)];
let hit = crate::snap::snap(
cx,
SnapRequest {
spread,
gesture: Gesture::Point,
rect: Rect::from_points(proposed, proposed),
x_edges: [true, true, true],
y_edges: [true, true, true],
exclude: &exclude,
copying: false,
lengths: [None, None],
angle: None,
radius: 0.0,
pointer: proposed,
},
);
// dx and dy stay in canvas space. Snap.delta is spread space.
let corr = spread_delta_to_canvas(cx.layout.xf(spread), hit.delta);
if corr.x.is_finite() {
dx += corr.x;
}
if corr.y.is_finite() {
dy += corr.y;
}
self.guides = hit.guides;
}
let mut params = json!({"id": id, "anchors": [[si, ai]], "dx": dx, "dy": dy});
if let Some(h) = handle {
params["handle"] = json!(h);
}
@@ -640,7 +720,7 @@ impl Tool for SelectionTool {
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
match &self.drag {
Drag::Marquee { start, cur } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))],
Drag::Move { .. } | Drag::Resize { .. } | Drag::Rotate { .. } => self.guides.clone(),
Drag::Move { .. } | Drag::Resize { .. } | Drag::Rotate { .. } | Drag::Anchor { .. } => self.guides.clone(),
_ => vec![],
}
}
+32 -1
View File
@@ -1,6 +1,8 @@
use std::sync::Arc;
use designcraft_compose::Cache;
use designcraft_doc::build::NewDocument;
use designcraft_doc::{Document, Item, ItemId, ParaFormat, Selection, Shape, SpreadRef};
use designcraft_doc::{Document, Guide, Item, ItemId, Orientation, ParaFormat, Selection, Shape, SpreadRef};
use designcraft_geom::shapes;
use designcraft_geom::{Point, Rect, Unit};
@@ -521,3 +523,32 @@ fn add_rotated(doc: &mut Document, rect: Rect, degrees: f64) -> ItemId {
doc.insert_item(SpreadRef::Doc(0), item, None).unwrap();
id
}
#[test]
fn pen_click_snaps_to_a_guide() {
let mut d = Document::new(&NewDocument::default());
Arc::make_mut(&mut d.spreads[0]).pages[0].guides.push(Guide {
orientation: Orientation::Vertical,
position: 100.0,
spread: true,
locked: false,
layer: None,
liquid: false,
});
let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false));
let mut cx = ctx(&d, &s, &c, &l);
cx.snap = SnapView::FACTORY;
let mut t = create("pen");
let click = |t: &mut Box<dyn Tool>, cx: &ToolContext, x: f64, y: f64| {
t.pointer(cx, &PointerEvent::new(PointerKind::Down, x, y));
t.pointer(cx, &PointerEvent::new(PointerKind::Up, x, y))
};
assert!(click(&mut t, &cx, 102.0, 80.0).is_empty());
let up = click(&mut t, &cx, 180.0, 80.0);
let Action::Exec(cmd, p) = &up[0] else {
panic!("expected path.create, got {up:?}");
};
assert_eq!(cmd, "path.create");
let x = p["anchors"][0]["p"][0].as_f64().unwrap();
assert!((x - 100.0).abs() < 1e-6, "anchor x {x}");
}