213 lines
8.0 KiB
Rust
213 lines
8.0 KiB
Rust
//! Smart Guides: snapping to anchors, object bounds (edges/centres), artboards, with the magenta
|
|
//! construction lines and labels Illustrator users expect.
|
|
|
|
use vectorcraft_doc::hit::{HitOptions, hit_test};
|
|
use vectorcraft_doc::{Document, NodeId, NodeKind};
|
|
use vectorcraft_geom::{Point, Rect, Vec2};
|
|
|
|
use crate::{Overlay, ToolContext};
|
|
|
|
pub const MAGENTA: [u8; 3] = [0xff, 0x3d, 0xfc];
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
enum Kind {
|
|
Anchor,
|
|
Center,
|
|
Edge,
|
|
Artboard,
|
|
}
|
|
|
|
impl Kind {
|
|
fn label(self) -> &'static str {
|
|
match self {
|
|
Kind::Anchor => "anchor",
|
|
Kind::Center => "center",
|
|
Kind::Edge => "path",
|
|
Kind::Artboard => "artboard",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Snap targets gathered from the document (excluding the objects being edited).
|
|
#[derive(Default)]
|
|
pub struct Targets {
|
|
points: Vec<(Point, Kind)>,
|
|
xs: Vec<(f64, Point, Kind)>,
|
|
ys: Vec<(f64, Point, Kind)>,
|
|
}
|
|
|
|
impl Targets {
|
|
pub fn collect(doc: &Document, exclude: &[NodeId], visible: Option<Rect>) -> Self {
|
|
let mut t = Targets::default();
|
|
let excluded = |id: NodeId| exclude.iter().any(|e| doc.ancestry(id).is_some_and(|a| a.contains(e)));
|
|
let add_rect = |t: &mut Targets, r: Rect, kind: Kind| {
|
|
let c = r.center();
|
|
for p in [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)] {
|
|
t.xs.push((p.x, p, kind));
|
|
t.ys.push((p.y, p, kind));
|
|
}
|
|
t.points.push((c, Kind::Center));
|
|
t.xs.push((c.x, c, Kind::Center));
|
|
t.ys.push((c.y, c, Kind::Center));
|
|
};
|
|
for ab in &doc.artboards {
|
|
add_rect(&mut t, ab.rect, Kind::Artboard);
|
|
}
|
|
let mut budget = 20_000usize;
|
|
doc.walk(|n| {
|
|
if budget == 0 || n.is_container() || !n.visible || excluded(n.id) {
|
|
return;
|
|
}
|
|
let Some(b) = n.geometric_bounds() else { return };
|
|
if let Some(v) = visible
|
|
&& b.intersect(v).area() <= 0.0
|
|
&& !v.contains(b.center())
|
|
{
|
|
return;
|
|
}
|
|
if let NodeKind::Path { path, .. } = &n.kind {
|
|
for (_, _, a) in path.anchors() {
|
|
t.points.push((a.p, Kind::Anchor));
|
|
budget = budget.saturating_sub(1);
|
|
}
|
|
}
|
|
add_rect(&mut t, b, Kind::Edge);
|
|
});
|
|
t
|
|
}
|
|
|
|
/// Anchors and centres of the visible leaves under `roots` (the roots included) whose bounds
|
|
/// reach into `near`.
|
|
fn points_near(doc: &Document, roots: &[NodeId], near: Rect) -> Self {
|
|
let mut t = Targets::default();
|
|
for n in roots.iter().filter_map(|id| doc.node(*id)) {
|
|
n.walk(&mut |c| {
|
|
if c.is_container() || !c.visible {
|
|
return;
|
|
}
|
|
let Some(b) = c.geometric_bounds() else { return };
|
|
if b.x0 > near.x1 || b.x1 < near.x0 || b.y0 > near.y1 || b.y1 < near.y0 {
|
|
return;
|
|
}
|
|
if let NodeKind::Path { path, .. } = &c.kind {
|
|
t.points.extend(path.anchors().map(|(_, _, a)| (a.p, Kind::Anchor)));
|
|
}
|
|
t.points.push((b.center(), Kind::Center));
|
|
});
|
|
}
|
|
t
|
|
}
|
|
|
|
/// Snap a single point. Returns the snapped point and guide overlays.
|
|
pub fn snap_point(&self, p: Point, tol: f64) -> (Point, Vec<Overlay>) {
|
|
if let Some((q, k)) = self.points.iter().filter(|(q, _)| q.distance(p) <= tol).min_by(|a, b| a.0.distance(p).total_cmp(&b.0.distance(p))) {
|
|
return (*q, vec![Overlay::Label { p: *q, text: k.label().into(), color: MAGENTA }]);
|
|
}
|
|
let mut out = p;
|
|
let mut ov = vec![];
|
|
if let Some((x, from, _)) =
|
|
self.xs.iter().filter(|(x, _, _)| (x - p.x).abs() <= tol).min_by(|a, b| (a.0 - p.x).abs().total_cmp(&(b.0 - p.x).abs()))
|
|
{
|
|
out.x = *x;
|
|
ov.push(Overlay::Line { a: *from, b: Point::new(*x, p.y), color: MAGENTA, dashed: false });
|
|
}
|
|
if let Some((y, from, _)) =
|
|
self.ys.iter().filter(|(y, _, _)| (y - p.y).abs() <= tol).min_by(|a, b| (a.0 - p.y).abs().total_cmp(&(b.0 - p.y).abs()))
|
|
{
|
|
out.y = *y;
|
|
ov.push(Overlay::Line { a: *from, b: Point::new(p.x, *y), color: MAGENTA, dashed: false });
|
|
}
|
|
if !ov.is_empty() {
|
|
ov.push(Overlay::Label { p: out, text: "align".into(), color: MAGENTA });
|
|
}
|
|
(out, ov)
|
|
}
|
|
|
|
/// Snap a moving rectangle (selection bounds after a move by `d`): tries its corners/edges/centre.
|
|
pub fn snap_rect(&self, r: Rect, tol: f64) -> (Vec2, Vec<Overlay>) {
|
|
let c = r.center();
|
|
let xs = [r.x0, c.x, r.x1];
|
|
let ys = [r.y0, c.y, r.y1];
|
|
let best_x = xs
|
|
.iter()
|
|
.flat_map(|x| self.xs.iter().map(move |(t, from, _)| (t - x, *from, *x)))
|
|
.filter(|(d, _, _)| d.abs() <= tol)
|
|
.min_by(|a, b| a.0.abs().total_cmp(&b.0.abs()));
|
|
let best_y = ys
|
|
.iter()
|
|
.flat_map(|y| self.ys.iter().map(move |(t, from, _)| (t - y, *from, *y)))
|
|
.filter(|(d, _, _)| d.abs() <= tol)
|
|
.min_by(|a, b| a.0.abs().total_cmp(&b.0.abs()));
|
|
let mut d = Vec2::ZERO;
|
|
let mut ov = vec![];
|
|
if let Some((dx, from, x)) = best_x {
|
|
d.x = dx;
|
|
let x = x + dx;
|
|
let (y0, y1) = (from.y.min(r.y0), from.y.max(r.y1));
|
|
ov.push(Overlay::Line { a: Point::new(x, y0), b: Point::new(x, y1), color: MAGENTA, dashed: false });
|
|
}
|
|
if let Some((dy, from, y)) = best_y {
|
|
d.y = dy;
|
|
let y = y + dy;
|
|
let (x0, x1) = (from.x.min(r.x0), from.x.max(r.x1));
|
|
ov.push(Overlay::Line { a: Point::new(x0, y), b: Point::new(x1, y), color: MAGENTA, dashed: false });
|
|
}
|
|
(d, ov)
|
|
}
|
|
}
|
|
|
|
/// Snap `p` for a drawing tool when smart guides (or grid snapping) are on.
|
|
pub fn snap_draw(cx: &ToolContext, p: Point, exclude: &[NodeId]) -> (Point, Vec<Overlay>) {
|
|
if cx.snap_to_pixel {
|
|
return (Point::new(p.x.round(), p.y.round()), vec![]);
|
|
}
|
|
if cx.snap_to_grid {
|
|
let s = cx.doc.grid.spacing / cx.doc.grid.subdivisions.max(1) as f64;
|
|
return (vectorcraft_geom::snap::snap_point_to_grid(p, s), vec![]);
|
|
}
|
|
if !cx.smart_guides {
|
|
return (p, vec![]);
|
|
}
|
|
Targets::collect(cx.doc, exclude, None).snap_point(p, cx.tol(5.0))
|
|
}
|
|
|
|
/// Snap a picked point (a transform tool's reference point) to the nearest anchor or centre of the
|
|
/// selection or of the object under the pointer, when Snap to Point or Smart Guides is on.
|
|
pub fn snap_pick(cx: &ToolContext, p: Point) -> (Point, Vec<Overlay>) {
|
|
if !(cx.snap_to_point || cx.smart_guides) {
|
|
return (p, vec![]);
|
|
}
|
|
let tol = cx.tol(5.0);
|
|
let mut roots = cx.selection.objects.clone();
|
|
roots.extend(hit_test(cx.doc, p, HitOptions { tol, ..cx.hit_options() }).map(|h| h.leaf));
|
|
let near = Rect::new(p.x - tol, p.y - tol, p.x + tol, p.y + tol);
|
|
Targets::points_near(cx.doc, &roots, near).snap_point(p, tol)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::testutil::*;
|
|
|
|
#[test]
|
|
fn snaps_to_anchor_and_alignment() {
|
|
let (d, _) = doc_with_rect();
|
|
let t = Targets::collect(&d, &[], None);
|
|
let (p, ov) = t.snap_point(Point::new(102.0, 99.0), 4.0);
|
|
assert_eq!(p, Point::new(100.0, 100.0));
|
|
assert!(matches!(&ov[0], Overlay::Label { text, .. } if text == "anchor"));
|
|
let (p, _) = t.snap_point(Point::new(301.0, 199.0), 4.0);
|
|
assert_eq!(p.y, 200.0);
|
|
}
|
|
|
|
#[test]
|
|
fn rect_snap_aligns_edges() {
|
|
let (d, id) = doc_with_rect();
|
|
let t = Targets::collect(&d, &[id], None);
|
|
// Artboard is 0..500; a rect at x0=2 snaps to the artboard's left edge.
|
|
let (dv, ov) = t.snap_rect(Rect::new(2.0, 50.0, 52.0, 90.0), 4.0);
|
|
assert_eq!(dv.x, -2.0);
|
|
assert!(!ov.is_empty());
|
|
}
|
|
}
|