Files
vectorcraft/crates/tools/src/direct.rs
T
Brandon ThomasandClaude Opus 5.5 bfee80a22c M0: workspace skeleton — geom, color, doc, render, text, pathops, svg, tools, engine
Engine-first Cargo workspace for DrawCraft (clean-room Illustrator):
- geom: anchor-based path model, shapes, hit testing, snapping
- color: colours, paints, gradients, swatches, blend modes, harmonies
- doc: persistent Arc document tree, appearance stacks, text model, selection
- render: vello_cpu renderer (appearance, gradients, clip groups, blend modes)
- text: font DB, harfrust shaping, point/area/path layout
- pathops: curve booleans (linesweeper), Pathfinder, offset, simplify
- svg: import (usvg) and export
- tools: selection/direct/pen/shape tools emitting commands
- engine: Session, history with interactions, ~110 commands

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 05:53:29 -04:00

260 lines
11 KiB
Rust

//! Direct Selection (A) and Group Selection tools.
//!
//! Direct Selection: click an anchor to select it (Shift toggles), click a segment to select the
//! path's anchors on that segment, drag to move selected anchors, drag a direction handle to
//! reshape, marquee to select anchors. Group Selection: click selects the leaf; each further click
//! on it adds the next enclosing group.
use drawcraft_doc::hit::hit_test;
use drawcraft_doc::{AnchorRef, NodeId, NodeKind};
use drawcraft_geom::{Point, Rect};
use serde_json::{Value, json};
use crate::bbox::move_delta;
use crate::select::matrix_json;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext};
#[derive(Clone, Debug)]
enum State {
Idle,
MoveAnchors { start: Point, began: bool },
MoveObject { start: Point, began: bool },
Handle { id: NodeId, si: usize, ai: usize, out: bool },
Marquee { start: Point, cur: Point, add: bool },
}
pub struct DirectSelectionTool {
group: bool,
state: State,
}
impl DirectSelectionTool {
pub fn new(group: bool) -> Self {
Self { group, state: State::Idle }
}
}
/// Anchor (or handle) of any selected/visible path under `p`.
fn hit_anchor(cx: &ToolContext, p: Point, tol: f64, selected_only: bool) -> Option<(NodeId, usize, usize)> {
let ids: Vec<NodeId> = if selected_only {
cx.selection.objects.clone()
} else {
let mut v = vec![];
cx.doc.walk(|n| {
if matches!(n.kind, NodeKind::Path { .. }) {
v.push(n.id)
}
});
v.reverse();
v
};
for id in ids {
if !cx.doc.is_editable(id) {
continue;
}
if let Some(pd) = cx.doc.node(id).and_then(|n| n.path_data()) {
for (si, ai, a) in pd.anchors() {
if a.p.distance(p) <= tol {
return Some((id, si, ai));
}
}
}
}
None
}
/// Direction handle of a partially selected anchor under `p`: (id, si, ai, is_out).
fn hit_handle(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize, bool)> {
for (id, set) in &cx.selection.anchors {
let Some(pd) = cx.doc.node(*id).and_then(|n| n.path_data()) else { continue };
for &(si, ai) in set {
let Some(a) = pd.subpaths.get(si).and_then(|s| s.anchors.get(ai)) else { continue };
if a.has_out() && a.h_out.distance(p) <= tol {
return Some((*id, si, ai, true));
}
if a.has_in() && a.h_in.distance(p) <= tol {
return Some((*id, si, ai, false));
}
}
}
None
}
fn anchors_json(v: &[AnchorRef]) -> Value {
Value::Array(v.iter().map(|(s, a)| json!([s, a])).collect())
}
impl Tool for DirectSelectionTool {
fn id(&self) -> &'static str {
if self.group { "groupSelection" } else { "directSelection" }
}
fn busy(&self) -> bool {
!matches!(self.state, State::Idle)
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
let tol = cx.tol(4.0);
match (ev.kind, self.state.clone()) {
(PointerKind::Down, _) if self.group => {
let Some(h) = hit_test(cx.doc, p, cx.hit_options()) else {
self.state = State::Marquee { start: p, cur: p, add: ev.mods.shift };
return vec![];
};
// Walk up from the leaf: select the first ancestor not yet selected (below the layer).
let chain: Vec<NodeId> = h.ancestry.iter().skip(1).rev().copied().collect();
let target = if cx.selection.contains(h.leaf) {
chain.iter().find(|id| !cx.selection.contains(**id)).copied().unwrap_or(h.leaf)
} else {
h.leaf
};
self.state = State::MoveObject { start: p, began: false };
if cx.selection.contains(target) {
vec![]
} else if ev.mods.shift || cx.selection.contains(h.leaf) {
vec![Action::Exec("select.add".into(), json!({"ids": [target.0]}))]
} else {
vec![Action::Exec("select.set".into(), json!({"ids": [target.0]}))]
}
}
(PointerKind::Down, _) => {
if let Some((id, si, ai, out)) = hit_handle(cx, p, tol) {
self.state = State::Handle { id, si, ai, out };
return vec![Action::Begin("Reshape".into())];
}
if let Some((id, si, ai)) = hit_anchor(cx, p, tol, false) {
let already = cx.selection.partial(id).is_some_and(|s| s.contains(&(si, ai)));
self.state = State::MoveAnchors { start: p, began: false };
if ev.mods.shift {
return vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "toggle"}))];
}
if !already {
return vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "set"}))];
}
return vec![];
}
if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) {
// Clicking a segment/fill selects the whole leaf path (all anchors).
self.state = State::MoveObject { start: p, began: false };
if ev.mods.shift {
return vec![Action::Exec("select.toggle".into(), json!({"id": h.leaf.0}))];
}
if !cx.selection.contains(h.leaf) || cx.selection.partial(h.leaf).is_some() {
return vec![Action::Exec("select.set".into(), json!({"ids": [h.leaf.0]}))];
}
return vec![];
}
self.state = State::Marquee { start: p, cur: p, add: ev.mods.shift };
vec![]
}
(PointerKind::Drag, State::MoveAnchors { start, began }) => {
let mut out = vec![];
if !began {
if p.distance(start) < cx.tol(3.0) {
return out;
}
out.push(Action::Begin("Move".into()));
self.state = State::MoveAnchors { start, began: true };
}
let d = move_delta(start, p, ev.mods.shift);
out.push(Action::Preview("path.moveAnchors".into(), json!({"dx": d.x, "dy": d.y})));
out
}
(PointerKind::Drag, State::MoveObject { start, began }) => {
let mut out = vec![];
if !began {
if p.distance(start) < cx.tol(3.0) {
return out;
}
out.push(Action::Begin("Move".into()));
self.state = State::MoveObject { start, began: true };
}
let d = move_delta(start, p, ev.mods.shift);
out.push(Action::Preview(
"object.transform".into(),
json!({"matrix": matrix_json(drawcraft_geom::Affine::translate(d)), "copy": ev.mods.alt}),
));
out
}
(PointerKind::Drag, State::Handle { id, si, ai, out }) => {
vec![Action::Preview(
"path.setHandle".into(),
json!({"id": id.0, "subpath": si, "anchor": ai, "which": if out {"out"} else {"in"}, "x": p.x, "y": p.y, "independent": ev.mods.alt}),
)]
}
(PointerKind::Drag, State::Marquee { start, add, .. }) => {
self.state = State::Marquee { start, cur: p, add };
vec![]
}
(PointerKind::Up, State::MoveAnchors { began, .. } | State::MoveObject { began, .. }) => {
self.state = State::Idle;
if began { vec![Action::Commit] } else { vec![] }
}
(PointerKind::Up, State::Handle { .. }) => {
self.state = State::Idle;
vec![Action::Commit]
}
(PointerKind::Up, State::Marquee { start, add, .. }) => {
self.state = State::Idle;
let r = Rect::from_points(start, p);
if r.width() < cx.tol(3.0) && r.height() < cx.tol(3.0) {
return if add { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] };
}
// Collect anchors inside the rect for every editable path.
let mut sel: Vec<(NodeId, Vec<AnchorRef>)> = vec![];
cx.doc.walk(|n| {
if let NodeKind::Path { path, .. } = &n.kind {
let v: Vec<AnchorRef> = path.anchors().filter(|(_, _, a)| r.contains(a.p)).map(|(s, i, _)| (s, i)).collect();
if !v.is_empty() {
sel.push((n.id, v));
}
}
});
sel.retain(|(id, _)| cx.doc.is_editable(*id));
let items: Vec<Value> = sel.iter().map(|(id, v)| json!({"id": id.0, "anchors": anchors_json(v)})).collect();
vec![Action::Exec("select.anchorsMany".into(), json!({"items": items, "add": add}))]
}
_ => vec![],
}
}
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
match self.state {
State::Marquee { start, cur, .. } => vec![Overlay::Marquee(Rect::from_points(start, cur))],
_ => vec![],
}
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::ArrowHollow
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use drawcraft_doc::Selection;
#[test]
fn click_anchor_selects_it() {
let (d, id) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = DirectSelectionTool::new(false);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0));
assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))]);
}
#[test]
fn marquee_selects_anchors() {
let (d, id) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = DirectSelectionTool::new(false);
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 150.0));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0));
assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": id.0, "anchors": [[0, 1]]}], "add": false}))]);
}
}