Files
figcraft/crates/engine/src/snap.rs
T
BFlatandClaude Opus 5.5 6f9801790c M1: frame guides (#70)
Ruler guides can belong to a frame: `guide.add/move/remove/clear` take `frame` (as well as
`page`), keeping the guide in the frame's own coordinates. Frame guides move with the frame, span
only it on the canvas, are saved and copied with it, and snap the layers inside it. A guide dragged
out of a ruler and dropped on a frame becomes that frame's guide; its label shows the offset in the
frame.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:16:56 +07:00

239 lines
10 KiB
Rust

//! Snapping and smart guides: while moving or resizing, edges and centres snap to nearby layers'
//! edges/centres, the parent frame, layout grid columns and page guides; the matches come back as
//! guide lines (with spacing labels) for the canvas to draw in red. Vector points also snap to
//! other points (see `vectoredit::snap`); landing on one shows a marker (a zero-length guide).
use figcraft_doc::{Document, NodeId, NodeKind};
use figcraft_geom::{Point, Rect, Vec2};
use serde::Serialize;
/// A guide line to draw: from (x0, y0) to (x1, y1) in page coordinates, with an optional label
/// (a distance) at its middle.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct Guide {
pub x0: f64,
pub y0: f64,
pub x1: f64,
pub y1: f64,
pub label: Option<String>,
}
/// A line to snap: its position on one axis and the span it covers on the other (x, y0, y1 for a
/// vertical line; y, x0, x1 for a horizontal one).
pub type Span = (f64, f64, f64);
/// Snap candidates gathered once at the start of a gesture.
#[derive(Clone, Debug, Default)]
pub struct Targets {
/// Vertical lines (x) with the y-span of the rect they came from.
pub xs: Vec<Span>,
/// Horizontal lines (y) with the x-span.
pub ys: Vec<Span>,
/// Sibling boxes (for spacing labels).
pub boxes: Vec<Rect>,
/// Points to land on exactly (see [`Targets::add_point`]).
pub points: Vec<Point>,
}
impl Targets {
/// Gather snap lines from the siblings of `moving` (and the parent frame), page guides and
/// layout grids, excluding the moving nodes and their descendants.
pub fn gather(doc: &Document, page: NodeId, moving: &[NodeId]) -> Targets {
let mut t = Targets::default();
let push = |r: Rect, t: &mut Targets| {
for x in [r.x0, r.center().x, r.x1] {
t.xs.push((x, r.y0, r.y1));
}
for y in [r.y0, r.center().y, r.y1] {
t.ys.push((y, r.x0, r.x1));
}
t.boxes.push(r);
};
let parent = moving.first().and_then(|m| doc.node(*m)).and_then(|n| n.parent).unwrap_or(page);
// The parent frame's own box (and its layout grids).
if parent != page
&& let Some(b) = doc.world_bounds(parent)
{
for x in [b.x0, b.center().x, b.x1] {
t.xs.push((x, b.y0, b.y1));
}
for y in [b.y0, b.center().y, b.y1] {
t.ys.push((y, b.x0, b.x1));
}
let (gx, gy) = figcraft_layout::grid_snap_lines(doc, parent);
for x in gx {
t.xs.push((x, b.y0, b.y1));
}
for y in gy {
t.ys.push((y, b.x0, b.x1));
}
}
// Siblings and, at the top level, all top-level layers.
if let Some(p) = doc.node(parent) {
for c in &p.children {
if moving.contains(c) || !doc.node(*c).is_some_and(|n| n.visible) {
continue;
}
if let Some(b) = doc.world_bounds(*c) {
push(b, &mut t);
}
}
}
if let Some(NodeKind::Page { guides, .. }) = doc.node(page).map(|n| &n.kind) {
for g in guides {
if g.vertical {
t.xs.push((g.offset, -1e7, 1e7));
} else {
t.ys.push((g.offset, -1e7, 1e7));
}
}
}
// The guides of the frames the moving layers are in, across each frame.
let mut f = parent;
for _ in 0..128 {
let Some(n) = doc.node(f).filter(|_| f != page) else { break };
if let Some(b) = doc.world_bounds(f).filter(|_| !n.guides.is_empty()) {
let xf = doc.world_transform(f);
for g in &n.guides {
if g.vertical {
t.xs.push(((xf * Point::new(g.offset, 0.0)).x, b.y0, b.y1));
} else {
t.ys.push(((xf * Point::new(0.0, g.offset)).y, b.x0, b.x1));
}
}
}
let Some(up) = n.parent else { break };
f = up;
}
t
}
/// A point to snap onto (a vector point, a segment midpoint): lines through it on both axes,
/// and a marker when a moving point lands on it.
pub fn add_point(&mut self, p: Point) {
self.xs.push((p.x, p.y, p.y));
self.ys.push((p.y, p.x, p.x));
self.points.push(p);
}
/// Snap a moving box: returns the (dx, dy) correction and guides to draw.
pub fn snap_rect(&self, r: Rect, threshold: f64) -> (f64, f64, Vec<Guide>) {
let (xs, ys) = rect_spans(r);
let (dx, dy, guides) = self.snap_spans(&xs, &ys, threshold);
(dx.unwrap_or(0.0), dy.unwrap_or(0.0), guides)
}
/// Snap a single point (resize handles, drawing).
pub fn snap_point(&self, x: f64, y: f64, threshold: f64) -> (f64, f64, Vec<Guide>) {
let (dx, dy, guides) = self.snap_spans(&[(x, y, y)], &[(y, x, x)], threshold);
(dx.unwrap_or(0.0), dy.unwrap_or(0.0), guides)
}
/// Snap several moving points together (vector points): the correction on each axis (`None`
/// where nothing was in reach), the guides, and a marker (a zero-length guide) on every target
/// point a moving point lands on.
pub fn snap_points(&self, pts: &[Point], threshold: f64) -> (Option<f64>, Option<f64>, Vec<Guide>) {
let xs: Vec<Span> = pts.iter().map(|p| (p.x, p.y, p.y)).collect();
let ys: Vec<Span> = pts.iter().map(|p| (p.y, p.x, p.x)).collect();
let (dx, dy, mut guides) = self.snap_spans(&xs, &ys, threshold);
let d = Vec2::new(dx.unwrap_or(0.0), dy.unwrap_or(0.0));
for p in pts.iter().map(|p| *p + d) {
let marker = Guide { x0: p.x, y0: p.y, x1: p.x, y1: p.y, label: None };
if self.points.iter().any(|t| (*t - p).hypot() < 1e-6) && !guides.contains(&marker) {
guides.push(marker);
}
}
(dx, dy, guides)
}
/// Snap moving lines: `xs` are vertical ones (x with the y-span they cover), `ys` horizontal
/// ones. Returns the nearest correction on each axis within `threshold` and the guides for the
/// lines that coincide with targets afterwards.
pub fn snap_spans(&self, xs: &[Span], ys: &[Span], threshold: f64) -> (Option<f64>, Option<f64>, Vec<Guide>) {
let best = |cands: &[Span], vals: &[Span]| -> Option<f64> {
let mut best: Option<f64> = None;
for (line, _, _) in cands {
for (v, _, _) in vals {
let d = line - v;
if d.abs() <= threshold && best.is_none_or(|b| d.abs() < b.abs()) {
best = Some(d);
}
}
}
best
};
let (dx, dy) = (best(&self.xs, xs), best(&self.ys, ys));
let (ox, oy) = (dx.unwrap_or(0.0), dy.unwrap_or(0.0));
let xs: Vec<Span> = xs.iter().map(|(x, a, b)| (x + ox, a + oy, b + oy)).collect();
let ys: Vec<Span> = ys.iter().map(|(y, a, b)| (y + oy, a + ox, b + ox)).collect();
(dx, dy, self.guides_spans(&xs, &ys))
}
/// Guide lines for edges of `r` that coincide with targets.
pub fn guides_for(&self, r: Rect) -> Vec<Guide> {
let (xs, ys) = rect_spans(r);
self.guides_spans(&xs, &ys)
}
/// Guides for moving lines (as in [`Self::snap_spans`]) that coincide with targets, each
/// spanning the line and the targets on it.
fn guides_spans(&self, xs: &[Span], ys: &[Span]) -> Vec<Guide> {
let span = |cands: &[Span], (v, a, b): Span| -> Option<(f64, f64)> {
let hits: Vec<&Span> = cands.iter().filter(|(l, _, _)| (l - v).abs() < 1e-6).collect();
(!hits.is_empty()).then(|| (hits.iter().map(|h| h.1).fold(a, f64::min).max(-1e6), hits.iter().map(|h| h.2).fold(b, f64::max).min(1e6)))
};
let mut out = Vec::new();
for x in xs {
if let Some((y0, y1)) = span(&self.xs, *x) {
out.push(Guide { x0: x.0, y0, x1: x.0, y1, label: None });
}
}
for y in ys {
if let Some((x0, x1)) = span(&self.ys, *y) {
out.push(Guide { x0, y0: y.0, x1, y1: y.0, label: None });
}
}
out
}
}
/// A box's edges and centre lines as moving lines (see [`Targets::snap_spans`]).
fn rect_spans(r: Rect) -> ([Span; 3], [Span; 3]) {
let c = r.center();
([(r.x0, r.y0, r.y1), (c.x, r.y0, r.y1), (r.x1, r.y0, r.y1)], [(r.y0, r.x0, r.x1), (c.y, r.x0, r.x1), (r.y1, r.x0, r.x1)])
}
/// Distance lines from `r` to the nearest box on each side (Alt-hover measuring), or to `other`.
pub fn measure(r: Rect, other: Rect) -> Vec<Guide> {
let mut out = Vec::new();
let fmt = |d: f64| {
let v = (d * 100.0).round() / 100.0;
if v.fract() == 0.0 { format!("{}", v as i64) } else { format!("{v}") }
};
if other.contains(r.origin()) && other.contains(Point::new(r.x1, r.y1)) {
// Inside: distances to each edge of the container.
let cy = r.center().y;
let cx = r.center().x;
out.push(Guide { x0: other.x0, y0: cy, x1: r.x0, y1: cy, label: Some(fmt(r.x0 - other.x0)) });
out.push(Guide { x0: r.x1, y0: cy, x1: other.x1, y1: cy, label: Some(fmt(other.x1 - r.x1)) });
out.push(Guide { x0: cx, y0: other.y0, x1: cx, y1: r.y0, label: Some(fmt(r.y0 - other.y0)) });
out.push(Guide { x0: cx, y0: r.y1, x1: cx, y1: other.y1, label: Some(fmt(other.y1 - r.y1)) });
return out;
}
let cy = (r.y0.max(other.y0) + r.y1.min(other.y1)) / 2.0;
let cx = (r.x0.max(other.x0) + r.x1.min(other.x1)) / 2.0;
let cy = if r.y0.max(other.y0) <= r.y1.min(other.y1) { cy } else { r.center().y };
let cx = if r.x0.max(other.x0) <= r.x1.min(other.x1) { cx } else { r.center().x };
if other.x0 >= r.x1 {
out.push(Guide { x0: r.x1, y0: cy, x1: other.x0, y1: cy, label: Some(fmt(other.x0 - r.x1)) });
} else if other.x1 <= r.x0 {
out.push(Guide { x0: other.x1, y0: cy, x1: r.x0, y1: cy, label: Some(fmt(r.x0 - other.x1)) });
}
if other.y0 >= r.y1 {
out.push(Guide { x0: cx, y0: r.y1, x1: cx, y1: other.y0, label: Some(fmt(other.y0 - r.y1)) });
} else if other.y1 <= r.y0 {
out.push(Guide { x0: cx, y0: other.y1, x1: cx, y1: r.y0, label: Some(fmt(r.y0 - other.y1)) });
}
out
}