M1: guides snap to layer edges and centres when dragged

Dragging a guide, or moving one, now snaps to nearby layer edges and centres on the guide's own axis: within 5 screen pixels, gated by Snap to Objects and suspended with Cmd/Ctrl. Page guides see the page's top-level layers; frame guides see the frame's box, its layout grids and every visible layer inside it, in the frame's own space.

Snapping sits in the interaction path (canvas/guides.rs, not guide.add/guide.move) so the commands stay deterministic for the control channel and MCP. Tests: guide_snap_tests.rs in the engine and ui-egui crates; docs/ui-parity.md updated.

Signed-off-by: Ahmed Mahmoud <ahmedmhmouad41@gmail.com>
This commit is contained in:
Ahmed Mahmoud
2026-10-29 09:00:47 +03:00
parent cabfc9f8fa
commit 475a7da502
7 changed files with 288 additions and 14 deletions
+61
View File
@@ -9,6 +9,7 @@ use figcraft_geom::{Affine, Point, Rect, Vec2, transform_rect_bbox};
use serde::Serialize;
use crate::guides;
use crate::tools::SNAP_PX;
/// A guide line to draw: from (x0, y0) to (x1, y1) in page coordinates, with an optional label
/// (a distance) at its middle; `dashed` marks a measurement's extension line.
@@ -127,6 +128,48 @@ impl Targets {
t
}
/// Snap lines for a guide on `owner` (a page or a top-level frame): a page's top-level layers,
/// or a frame's own box, layout grids and descendants, in the owner's own space so a rotated
/// frame's guide snaps along its axes.
pub fn gather_for_guide(doc: &Document, page: NodeId, owner: NodeId) -> Targets {
let space = if owner == page { Affine::IDENTITY } else { doc.world_transform(owner) };
let to = space.inverse();
let bbox = |id: NodeId| doc.node(id).map(|n| transform_rect_bbox(to * doc.world_transform(id), n.local_rect())).filter(|b| b.is_finite());
let mut t = Targets { space, ..Default::default() };
if owner == page {
if let Some(p) = doc.node(page) {
for c in &p.children {
if doc.node(*c).is_some_and(|n| n.visible)
&& let Some(b) = bbox(*c)
{
t.add_box(b);
}
}
}
return t;
}
// The frame's own box and its layout grids.
if let Some(b) = bbox(owner) {
t.add_box(b);
let (gx, gy) = figcraft_layout::grid_snap_lines_in(doc, owner, to * doc.world_transform(owner));
for x in gx {
t.xs.push((x, b.y0, b.y1));
}
for y in gy {
t.ys.push((y, b.x0, b.x1));
}
}
// Everything in the frame. Nested layers count, so a guide over a group reaches its parts.
for id in doc.subtree(owner).into_iter().skip(1) {
if doc.node(id).is_some_and(|n| n.visible)
&& let Some(b) = bbox(id)
{
t.add_box(b);
}
}
t
}
/// A box to snap to (in snap space): its edges and centre lines, and equal spacing with it.
pub fn add_box(&mut self, r: Rect) {
for x in [r.x0, r.center().x, r.x1] {
@@ -317,6 +360,16 @@ impl Targets {
(dx, dy, self.guides_spans(&xs, &ys))
}
/// The correction along one axis (x when `vertical`, else y) to the nearest target line within
/// `threshold`, in snap space. A guide moves on its own axis only, so the other is left alone.
pub fn snap_axis(&self, vertical: bool, offset: f64, threshold: f64) -> Option<f64> {
if !offset.is_finite() || !threshold.is_finite() || threshold < 0.0 {
return None;
}
let lines = if vertical { &self.xs } else { &self.ys };
lines.iter().map(|(v, _, _)| v - offset).filter(|d| d.is_finite() && d.abs() <= threshold).min_by(|a, b| a.abs().total_cmp(&b.abs()))
}
/// 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);
@@ -345,6 +398,14 @@ impl Targets {
}
}
/// Snap a guide on `owner` (a page or a top-level frame) to the nearest layer edge or centre along
/// its own axis, within `SNAP_PX` screen pixels at `zoom`. Returns the new offset in `owner`'s
/// coordinates, or `None` when nothing is in reach.
pub fn snap_guide(doc: &Document, page: NodeId, owner: NodeId, vertical: bool, offset: f64, zoom: f64) -> Option<f64> {
let threshold = SNAP_PX / zoom.max(1e-6);
Targets::gather_for_guide(doc, page, owner).snap_axis(vertical, offset, threshold).map(|d| offset + d).filter(|v| v.is_finite())
}
/// The parent of the moving layers (the first one's), or the page.
fn parent_of(doc: &Document, page: NodeId, moving: &[NodeId]) -> NodeId {
moving.first().and_then(|m| doc.node(*m)).and_then(|n| n.parent).unwrap_or(page)
+100
View File
@@ -0,0 +1,100 @@
//! Guides snap to layer edges and centres when dragged out of a ruler or moved, on the guide's own
//! axis only, within five screen pixels at any zoom (page guides see the page's top-level layers;
//! frame guides see the frame's box, layout grids and every layer in it).
use figcraft_doc::NodeId;
use figcraft_geom::Affine;
use serde_json::json;
use crate::Session;
use crate::params::id_of;
use crate::snap::{Targets, snap_guide};
fn id(v: serde_json::Value) -> NodeId {
id_of(&v["id"]).unwrap()
}
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
fn page_snap(s: &Session, vertical: bool, offset: f64, zoom: f64) -> Option<f64> {
let (doc, page) = (s.doc().unwrap(), s.page().unwrap());
snap_guide(doc, page, page, vertical, offset, zoom)
}
#[test]
fn a_page_guide_snaps_to_a_top_level_layers_edges_and_centre() {
let mut s = Session::new();
s.execute("node.create", &json!({"type": "rectangle", "x": 100, "y": 300, "width": 50, "height": 40})).unwrap();
// Edges at 100 and 150, centre at 125; within five pixels at zoom 1.
assert_eq!(page_snap(&s, true, 102.0, 1.0), Some(100.0));
assert_eq!(page_snap(&s, true, 124.0, 1.0), Some(125.0));
assert_eq!(page_snap(&s, true, 148.0, 1.0), Some(150.0));
// Out of reach.
assert_eq!(page_snap(&s, true, 106.0, 1.0), None);
// The guide's own axis only: x 302 is far from the x edges, y 302 is a pixel above 300.
assert_eq!(page_snap(&s, true, 302.0, 1.0), None);
assert_eq!(page_snap(&s, false, 302.0, 1.0), Some(300.0));
}
#[test]
fn the_reach_is_five_screen_pixels_at_any_zoom() {
let mut s = Session::new();
s.execute("node.create", &json!({"type": "rectangle", "x": 100, "y": 300, "width": 50, "height": 40})).unwrap();
// Six page pixels away: out of reach at zoom 2 (2.5), in reach at zoom 0.5 (10).
assert_eq!(page_snap(&s, true, 106.0, 2.0), None);
assert_eq!(page_snap(&s, true, 106.0, 0.5), Some(100.0));
// A zoom of zero is clamped rather than dividing by zero.
assert_eq!(page_snap(&s, true, 100.0, 0.0), Some(100.0));
}
#[test]
fn a_frame_guide_snaps_to_its_box_its_children_and_nested_layers() {
let mut s = Session::new();
let frame = id(s.execute("node.create", &json!({"type": "frame", "x": 100, "y": 50, "width": 300, "height": 200})).unwrap());
let inner = id(s.execute("node.create", &json!({"type": "frame", "parent": frame, "x": 10, "y": 10, "width": 60, "height": 60})).unwrap());
s.execute("node.create", &json!({"type": "rectangle", "parent": inner, "x": 20, "y": 20, "width": 20, "height": 20})).unwrap();
let outside = id(s.execute("node.create", &json!({"type": "rectangle", "x": 1000, "y": 1000, "width": 20, "height": 20})).unwrap());
let page = s.page().unwrap();
let snap = |s: &Session, vertical: bool, offset: f64| snap_guide(s.doc().unwrap(), page, frame, vertical, offset, 1.0);
// The frame's own right edge is 300.
assert_eq!(snap(&s, true, 299.0), Some(300.0));
// The nested rectangle sits at 30 in the frame; its left edge and centre (40) are targets.
assert_eq!(snap(&s, true, 31.0), Some(30.0));
assert_eq!(snap(&s, true, 41.0), Some(40.0));
// The layer outside the frame is not.
assert_eq!(snap(&s, true, 999.0), None);
// Hiding the nested rectangle takes its lines away.
let inner_rect = s.doc().unwrap().node(inner).unwrap().children[0];
s.execute("node.set", &json!({"ids": [inner_rect], "props": {"visible": false}})).unwrap();
assert_eq!(snap(&s, true, 31.0), None);
// A layer outside the frame still doesn't matter, and `outside` exists to prove the scope.
assert!(s.doc().unwrap().node(outside).is_some());
}
#[test]
fn a_frame_guide_snaps_along_the_frames_own_axes_when_it_is_turned() {
let mut s = Session::new();
let frame = id(s.execute("node.create", &json!({"type": "frame", "x": 100, "y": 100, "width": 300, "height": 200})).unwrap());
s.execute("node.create", &json!({"type": "rectangle", "parent": frame, "x": 40, "y": 20, "width": 20, "height": 20})).unwrap();
s.execute("node.set", &json!({"ids": [frame], "props": {"rotation": 30.0}})).unwrap();
let (doc, page) = (s.doc().unwrap(), s.page().unwrap());
let t = Targets::gather_for_guide(doc, page, frame);
assert_ne!(t.space, Affine::IDENTITY, "guides snap in the frame's space");
assert!(t.xs.iter().any(|(x, _, _)| near(*x, 40.0)), "{:?}", t.xs);
// The rotation rounds the offsets in the last bits, so compare with a tolerance.
assert!(snap_guide(doc, page, frame, true, 41.0, 1.0).is_some_and(|v| near(v, 40.0)));
assert!(snap_guide(doc, page, frame, false, 21.0, 1.0).is_some_and(|v| near(v, 20.0)));
}
#[test]
fn a_guide_with_nothing_near_stays_where_it_is() {
let s = Session::new();
let page = s.page().unwrap();
let doc = s.doc().unwrap();
assert_eq!(snap_guide(doc, page, page, true, 12.5, 1.0), None);
assert_eq!(snap_guide(doc, page, page, false, 12.5, 1.0), None);
// Non-finite input is refused rather than spread.
assert_eq!(snap_guide(doc, page, page, true, f64::NAN, 1.0), None);
}
+2
View File
@@ -295,6 +295,8 @@ mod gesture_preview_tests;
#[cfg(test)]
mod gradient_shorthand_tests;
#[cfg(test)]
mod guide_snap_tests;
#[cfg(test)]
mod hide_publishing_tests;
#[cfg(test)]
mod history_behaviour_tests;
+23 -12
View File
@@ -97,23 +97,28 @@ fn place(doc: &Document, owner: NodeId, index: usize, vertical: bool, offset: f6
Some(Placed { owner, index, vertical, offset, line: Some(guides::segment(doc, owner, vertical, offset)?) })
}
/// Guide `index` of `owner` (a page or frame) through page point `p`, its offset rounded to whole
/// pixels with `snap` (else to hundredths).
fn through(app: &FigApp, owner: NodeId, index: usize, vertical: bool, p: Point, snap: bool) -> Option<Placed> {
/// Guide `index` of `owner` (a page or frame) through page point `p`. Its offset snaps to a nearby
/// layer edge or centre when `object_zoom` is set (`None`: object snapping off), then to whole
/// pixels with `pixel` (else to hundredths); a snapped offset keeps the edge exactly.
fn through(app: &FigApp, owner: NodeId, index: usize, vertical: bool, p: Point, pixel: bool, object_zoom: Option<f64>) -> Option<Placed> {
let ds = app.session.ds().ok()?;
let v = guides::offset_at(&ds.doc, owner, vertical, p)?;
let offset = if snap { v.round() } else { (v * 100.0).round() / 100.0 };
let offset = match object_zoom.and_then(|zoom| figcraft_engine::snap::snap_guide(&ds.doc, ds.page, owner, vertical, v, zoom)) {
Some(snapped) => snapped,
None if pixel => v.round(),
None => (v * 100.0).round() / 100.0,
};
if owner == ds.page { Some(Placed { owner, index, vertical, offset, line: None }) } else { place(&ds.doc, owner, index, vertical, offset) }
}
/// A guide dragged out of a ruler (`vertical`: the left one) and dropped at page point `p`: on the
/// top-level frame under it, in the frame's coordinates and along its axis nearer the ruler's, or
/// on the page.
fn drop_target(app: &FigApp, p: Point, vertical: bool, snap: bool) -> Option<Placed> {
fn drop_target(app: &FigApp, p: Point, vertical: bool, pixel: bool, object_zoom: Option<f64>) -> Option<Placed> {
let ds = app.session.ds().ok()?;
match guides::drop_owner(&ds.doc, ds.page, p) {
f if f != ds.page => through(app, f, usize::MAX, guides::frame_axis(&ds.doc, f, vertical), p, snap),
page => through(app, page, usize::MAX, vertical, p, snap),
f if f != ds.page => through(app, f, usize::MAX, guides::frame_axis(&ds.doc, f, vertical), p, pixel, object_zoom),
page => through(app, page, usize::MAX, vertical, p, pixel, object_zoom),
}
}
@@ -182,18 +187,24 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool {
return false;
}
let r = app.canvas.rect;
let (pressed, released, alt) = ctx.input(|i| {
(i.pointer.button_pressed(egui::PointerButton::Primary), i.pointer.button_released(egui::PointerButton::Primary), i.modifiers.alt)
let (pressed, released, alt, suspend) = ctx.input(|i| {
(
i.pointer.button_pressed(egui::PointerButton::Primary),
i.pointer.button_released(egui::PointerButton::Primary),
i.modifiers.alt,
i.modifiers.command || i.modifiers.ctrl,
)
});
if let Some(drag) = ctx.data(|d| d.get_temp::<GuideDrag>(drag_id())) {
let vertical = match drag {
GuideDrag::New { vertical, .. } | GuideDrag::Move { vertical, .. } => vertical,
};
let p = app.to_page(pos);
let snap = app.session.ds().map(|d| d.view.snap_pixel).unwrap_or(true);
let (pixel, object_zoom) =
app.session.ds().map(|d| (d.view.snap_pixel, (d.view.snap_objects && !suspend).then_some(d.view.zoom))).unwrap_or((true, None));
let at = match drag {
GuideDrag::New { vertical, .. } => drop_target(app, p, vertical, snap),
GuideDrag::Move { owner, index, vertical, .. } => through(app, owner, index, vertical, p, snap),
GuideDrag::New { vertical, .. } => drop_target(app, p, vertical, pixel, object_zoom),
GuideDrag::Move { owner, index, vertical, .. } => through(app, owner, index, vertical, p, pixel, object_zoom),
};
// Alt: distances from the guide to the layers in the frame it crosses.
let frame = drag.frame().or_else(|| at.map(|g| g.owner).filter(|o| app.session.ds().is_ok_and(|d| d.page != *o)));
+97
View File
@@ -0,0 +1,97 @@
//! Guides dragged out of a ruler snap to nearby layer edges and centres (Snap to Objects), on the
//! guide's own axis, and Cmd/Ctrl suspends that while the guides keep their pixel snapping.
use egui::{Event, Modifiers, Pos2};
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use serde_json::json;
use crate::layers_drag_tests::Ui;
fn id(v: serde_json::Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn rulers(mut s: Session) -> Ui {
s.execute("view.rulers", &json!({"on": true})).unwrap();
Ui::new(s)
}
fn guides(u: &Ui, owner: NodeId) -> Vec<(bool, f64)> {
let ds = u.app.session.ds().unwrap();
ds.doc.node(owner).unwrap().guides().iter().map(|g| (g.vertical, g.offset)).collect()
}
fn page(u: &Ui) -> NodeId {
u.app.session.ds().unwrap().page
}
fn from_top_ruler(u: &Ui) -> Pos2 {
let r = u.app.canvas.rect;
Pos2::new(r.min.x + r.width() * 0.5, r.min.y + 5.0)
}
fn from_left_ruler(u: &Ui) -> Pos2 {
let r = u.app.canvas.rect;
Pos2::new(r.min.x + 5.0, r.min.y + r.height() * 0.5)
}
fn drag(u: &mut Ui, from: Pos2, to: Pos2) {
u.frame(vec![Event::PointerMoved(from)]);
u.button(from, true);
u.frame(vec![Event::PointerMoved(to)]);
u.button(to, false);
}
fn drag_with(u: &mut Ui, from: Pos2, to: Pos2, m: Modifiers) {
u.frame_with(vec![Event::PointerMoved(from)], m);
u.frame_with(vec![Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: m }], m);
u.frame_with(vec![Event::PointerMoved(to)], m);
u.frame_with(vec![Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: m }], m);
}
#[test]
fn a_guide_dragged_from_a_ruler_snaps_to_a_layers_edge() {
let mut s = Session::new();
s.execute("node.create", &json!({"type": "rectangle", "x": 100, "y": 100, "width": 120, "height": 80})).unwrap();
let mut u = rulers(s);
let start = from_top_ruler(&u);
let to = u.app.to_screen(200.0, 102.0);
drag(&mut u, start, to);
assert_eq!(guides(&u, page(&u)), vec![(false, 100.0)], "two pixels from the top edge");
}
#[test]
fn a_guide_dragged_over_a_frame_snaps_to_a_layer_inside_it() {
let mut s = Session::new();
let frame = id(s.execute("node.create", &json!({"type": "frame", "x": 100, "y": 100, "width": 300, "height": 200})).unwrap());
s.execute("node.create", &json!({"type": "rectangle", "parent": frame, "x": 10, "y": 10, "width": 40, "height": 40})).unwrap();
let mut u = rulers(s);
let start = from_left_ruler(&u);
let to = u.app.to_screen(112.0, 200.0);
drag(&mut u, start, to);
assert_eq!(guides(&u, frame), vec![(true, 10.0)], "two pixels from the child's left edge");
}
#[test]
fn with_snap_to_objects_off_the_guide_keeps_its_pixel_rounding() {
let mut s = Session::new();
s.execute("node.create", &json!({"type": "rectangle", "x": 100, "y": 100, "width": 120, "height": 80})).unwrap();
s.execute("view.snapObjects", &json!({"on": false})).unwrap();
let mut u = rulers(s);
let start = from_top_ruler(&u);
let to = u.app.to_screen(200.0, 102.0);
drag(&mut u, start, to);
assert_eq!(guides(&u, page(&u)), vec![(false, 102.0)], "rounded to the pixel, not the edge");
}
#[test]
fn cmd_or_ctrl_suspends_guide_snapping() {
let mut s = Session::new();
s.execute("node.create", &json!({"type": "rectangle", "x": 100, "y": 100, "width": 120, "height": 80})).unwrap();
let mut u = rulers(s);
let start = from_top_ruler(&u);
let to = u.app.to_screen(200.0, 102.0);
drag_with(&mut u, start, to, Modifiers::CTRL);
assert_eq!(guides(&u, page(&u)), vec![(false, 102.0)]);
}
+2
View File
@@ -90,6 +90,8 @@ mod gradient_shorthand_tests;
#[cfg(test)]
mod guide_release_tests;
#[cfg(test)]
mod guide_snap_tests;
#[cfg(test)]
mod home_recents_tests;
#[cfg(test)]
mod home_tests;
+3 -2
View File
File diff suppressed because one or more lines are too long