Snap: carry SnapView on the tool context

This commit is contained in:
Cameron Gilroy authored and Brandon Thomas committed 2026-10-06 09:05:59 -04:00
1 parent 18e196acda
commit 4de93d1345
11 files changed
+198 -30

No files matched your search

+1 -1
View File
@@ -27,7 +27,7 @@ pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
pub use designcraft_compose as compose;
pub use designcraft_doc as doc;
pub use designcraft_tools as tools;
pub use tooling::{UiRequest, ViewInfo};
pub use tooling::{SnapView, UiRequest, ViewInfo};
#[derive(Debug, thiserror::Error)]
pub enum EngineError {
+5 -5
View File
@@ -45,7 +45,7 @@ fn smart_quotes_and_formatting() {
fn tool_gesture_creates_one_undo_step() {
let mut s = session();
s.set_tool("rectangle");
let v = ViewInfo { zoom: 1.0 };
let v = ViewInfo::at_zoom(1.0);
use designcraft_tools::{PointerEvent, PointerKind};
s.pointer(&PointerEvent::new(PointerKind::Down, 10.0, 10.0), v).unwrap();
for i in 1..10 {
@@ -134,7 +134,7 @@ fn align_and_distribute() {
fn pen_draws_and_direct_selection_edits() {
use designcraft_tools::{PointerEvent, PointerKind};
let mut s = session();
let v = ViewInfo { zoom: 1.0 };
let v = ViewInfo::at_zoom(1.0);
s.set_tool("pen");
let click = |s: &mut Session, x: f64, y: f64| {
s.pointer(&PointerEvent::new(PointerKind::Down, x, y), v).unwrap();
@@ -189,7 +189,7 @@ fn place_gun_click_drag_and_into_frame() {
let b64 = cmd::base64_encode(&png);
s.execute("place.load", &json!({"base64": b64, "name": "a.png"})).unwrap();
assert_eq!(s.tool_id(), "placeGun");
let v = ViewInfo { zoom: 1.0 };
let v = ViewInfo::at_zoom(1.0);
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 180.0, 300.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 180.0, 300.0), v).unwrap();
@@ -413,7 +413,7 @@ fn gridify_while_drawing_frames() {
let mut s = session();
let n0 = s.doc().unwrap().doc.spreads[0].items.len();
s.set_tool("rectangleFrame");
let v = ViewInfo { zoom: 1.0 };
let v = ViewInfo::at_zoom(1.0);
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 312.0, 212.0), v).unwrap();
for k in [ToolKey::Right, ToolKey::Right, ToolKey::Up, ToolKey::Left] {
@@ -434,7 +434,7 @@ fn gap_tool_drag_moves_the_gap() {
let mut s = session();
let a = s.execute("frame.create", &json!({"rect": [100, 100, 200, 300]})).unwrap()["id"].as_u64().unwrap();
s.set_tool("gap");
let v = ViewInfo { zoom: 1.0 };
let v = ViewInfo::at_zoom(1.0);
s.pointer(&PointerEvent::new(PointerKind::Down, 250.0, 200.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 270.0, 205.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 270.0, 205.0), v).unwrap();
+28 -3
View File
@@ -2,6 +2,8 @@
use std::sync::Arc;
use designcraft_geom::Unit;
pub use designcraft_tools::SnapView;
use designcraft_tools::{Action, CanvasLayout, Cursor, Mods, Overlay, PointerEvent, ToolContext, ToolKey};
use serde::Serialize;
use serde_json::Value;
@@ -27,10 +29,24 @@ pub enum UiRequest {
}
/// View information the UI passes with pointer events.
#[derive(Clone, Copy, Debug, Default, Serialize)]
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct ViewInfo {
/// Screen pixels per point.
pub zoom: f64,
pub snap: SnapView,
pub unit: Unit,
}
impl ViewInfo {
pub const fn at_zoom(zoom: f64) -> Self {
Self { zoom, snap: SnapView::FACTORY, unit: Unit::Points }
}
}
impl Default for ViewInfo {
fn default() -> Self {
Self::at_zoom(1.0)
}
}
impl Session {
@@ -68,7 +84,16 @@ impl Session {
let layer = st.active_layer;
let layout = CanvasLayout::new(&doc, st.editing_parents);
let cache = self.cache.clone();
let cx = ToolContext { doc: &doc, selection: &sel, cache: &cache, layout: &layout, zoom: view.zoom.max(1e-6), layer, snap: true };
let cx = ToolContext {
doc: &doc,
selection: &sel,
cache: &cache,
layout: &layout,
zoom: view.zoom.max(1e-6),
layer,
snap: view.snap,
unit: view.unit,
};
Some(f(self.tool.as_mut(), &cx))
}
@@ -97,7 +122,7 @@ impl Session {
}
pub fn wants_text(&mut self) -> bool {
self.with_ctx(ViewInfo { zoom: 1.0 }, |t, cx| t.wants_text(cx)).unwrap_or(false)
self.with_ctx(ViewInfo::at_zoom(1.0), |t, cx| t.wants_text(cx)).unwrap_or(false)
}
pub fn run_actions(&mut self, actions: Vec<Action>) -> Result<()> {
+1 -1
View File
@@ -24,7 +24,7 @@ impl Default for Headless {
pub(crate) const NEEDS_APP: &str = "it needs the desktop app: start `designcraft --control 7979` and run the MCP server \
with `designcraft-cli mcp --connect 7979`";
const VIEW: ViewInfo = ViewInfo { zoom: 1.0 };
const VIEW: ViewInfo = ViewInfo::at_zoom(1.0);
fn s<'a>(p: &'a Value, k: &str) -> Option<&'a str> {
p.get(k).and_then(Value::as_str)
+2 -2
View File
@@ -73,7 +73,7 @@ impl Tool for FrameTool {
match ev.kind {
PointerKind::Down => {
let pos = match cx.layout.spread_at(ev.pos) {
Some((sr, sp)) if cx.snap => cx.layout.to_canvas(sr, crate::snap::snap_point(cx, sr, sp).0),
Some((sr, sp)) if cx.snap.any() => cx.layout.to_canvas(sr, crate::snap::snap_point(cx, sr, sp).0),
_ => ev.pos,
};
self.start = Some(pos);
@@ -92,7 +92,7 @@ impl Tool for FrameTool {
let Some((sr, sa)) = cx.layout.spread_at(a) else { return vec![] };
let mut ev = *ev;
self.guides.clear();
if cx.snap && !ev.mods.shift {
if cx.snap.any() && !ev.mods.shift {
let (p, g) = crate::snap::snap_point(cx, sr, cx.layout.to_spread(sr, ev.pos));
ev.pos = cx.layout.to_canvas(sr, p);
self.guides = g;
+98 -2
View File
@@ -24,7 +24,7 @@ mod xform;
use designcraft_compose::Cache;
use designcraft_doc::{Document, Item, ItemId, Selection, SpreadRef};
use designcraft_geom::{Affine, BezPath, Point, Rect};
use designcraft_geom::{Affine, BezPath, Point, Rect, Unit, Vec2};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -107,6 +107,87 @@ pub enum Action {
View(Value),
}
/// Which snap categories are on, and the snap zone in screen pixels.
///
/// Each flag is its own view switch, not one combined mode.
#[allow(clippy::struct_excessive_bools)]
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct SnapView {
pub snap_to_guides: bool,
pub snap_to_document_grid: bool,
pub show_guides: bool,
pub smart_guides: bool,
pub align_edges: bool,
pub align_centers: bool,
pub smart_dimensions: bool,
pub smart_spacing: bool,
pub zone_px: f64,
}
impl SnapView {
pub const FACTORY: Self = Self {
snap_to_guides: true,
snap_to_document_grid: false,
show_guides: true,
smart_guides: true,
align_edges: true,
align_centers: true,
smart_dimensions: true,
smart_spacing: true,
zone_px: 4.0,
};
pub const OFF: Self = Self {
snap_to_guides: false,
snap_to_document_grid: false,
show_guides: true,
smart_guides: false,
align_edges: false,
align_centers: false,
smart_dimensions: false,
smart_spacing: false,
zone_px: 4.0,
};
/// Guides, the document grid, or smart guides are on.
pub fn any(self) -> bool {
self.snap_to_guides || self.snap_to_document_grid || self.smart_guides
}
}
impl Default for SnapView {
fn default() -> Self {
Self::FACTORY
}
}
/// The gesture asking for a snap.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Gesture {
Move,
Resize,
Create,
Rotate,
Point,
}
pub struct SnapRequest<'a> {
pub spread: SpreadRef,
pub gesture: Gesture,
pub rect: Rect,
/// Left, center, right. All false means the axis is locked.
pub x_edges: [bool; 3],
/// Top, center, bottom.
pub y_edges: [bool; 3],
pub exclude: &'a [ItemId],
pub copying: bool,
/// Proposed width and height for the dimensions pass. None when that length is not being set.
pub lengths: [Option<f64>; 2],
/// Proposed rotation in the same degrees `transform.rotate` already receives. None unless rotating.
pub angle: Option<f64>,
pub radius: f64,
pub pointer: Point,
}
/// Read-only context a tool sees.
pub struct ToolContext<'a> {
pub doc: &'a Document,
@@ -117,7 +198,8 @@ pub struct ToolContext<'a> {
pub zoom: f64,
/// Layer new items go on.
pub layer: designcraft_doc::LayerId,
pub snap: bool,
pub snap: SnapView,
pub unit: Unit,
}
impl ToolContext<'_> {
@@ -206,6 +288,20 @@ pub enum Overlay {
a: Point,
b: Point,
},
/// Spacing segment in canvas coordinates, with a measurement label.
Gap {
a: Point,
b: Point,
label: String,
},
}
/// Correction from a snap, in spread coordinates for `delta`.
#[derive(Clone, Debug, Default)]
pub struct Snap {
pub delta: Vec2,
pub angle: Option<f64>,
pub guides: Vec<Overlay>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+1 -1
View File
@@ -265,7 +265,7 @@ impl Tool for SelectionTool {
// The movement in the spread's own coordinates (its view may be turned).
let mut ds = cx.layout.delta_to_spread(origin_spread, d);
let b0s = bounds0.map(|b| cx.layout.xf(origin_spread).inverse().transform_rect_bbox(b));
if cx.snap
if cx.snap.any()
&& let Some(b0) = b0s
{
let snap = crate::snap::snap_rect(cx, origin_spread, b0 + ds, &cx.selection.items);
+49 -11
View File
@@ -3,16 +3,12 @@
//! lines to draw (canvas coordinates).
use designcraft_doc::{ItemId, Orientation, SpreadRef};
use designcraft_geom::{Point, Rect, Vec2};
use designcraft_geom::{Point, Rect};
pub use crate::{Gesture, Snap, SnapRequest, SnapView};
use crate::{Overlay, ToolContext};
#[derive(Clone, Debug, Default)]
pub struct Snap {
pub delta: Vec2,
pub guides: Vec<Overlay>,
}
#[derive(Clone, Copy)]
struct Target {
v: f64,
@@ -75,19 +71,19 @@ fn best(cands: &[f64], targets: &[Target], tol: f64) -> Option<(f64, Target)> {
out
}
/// Snap a moving rect (spread coords). Edges and centre snap independently per axis.
pub fn snap_rect(cx: &ToolContext, sr: SpreadRef, r: Rect, exclude: &[ItemId]) -> Snap {
/// Closest target on each free axis. Tolerance is 5 screen pixels, same as [`snap_rect`].
fn snap_axes(cx: &ToolContext, sr: SpreadRef, r: Rect, exclude: &[ItemId], x_free: bool, y_free: bool) -> Snap {
let tol = cx.tol(5.0);
let (xs, ys) = targets(cx, sr, exclude);
let mut s = Snap::default();
let xf = cx.layout.xf(sr);
if let Some((d, t)) = best(&[r.x0, r.center().x, r.x1], &xs, tol) {
if x_free && let Some((d, t)) = best(&[r.x0, r.center().x, r.x1], &xs, tol) {
s.delta.x = d;
let lo = t.lo.min(r.y0) - 6.0;
let hi = t.hi.max(r.y1) + 6.0;
s.guides.push(guide(xf * Point::new(t.v, lo), xf * Point::new(t.v, hi), t.smart));
}
if let Some((d, t)) = best(&[r.y0, r.center().y, r.y1], &ys, tol) {
if y_free && let Some((d, t)) = best(&[r.y0, r.center().y, r.y1], &ys, tol) {
s.delta.y = d;
let lo = t.lo.min(r.x0) - 6.0;
let hi = t.hi.max(r.x1) + 6.0;
@@ -96,12 +92,54 @@ pub fn snap_rect(cx: &ToolContext, sr: SpreadRef, r: Rect, exclude: &[ItemId]) -
s
}
/// Snap a moving rect (spread coords). Edges and centre snap independently per axis.
pub fn snap_rect(cx: &ToolContext, sr: SpreadRef, r: Rect, exclude: &[ItemId]) -> Snap {
snap_axes(cx, sr, r, exclude, true, true)
}
/// Snap a single point (drawing tools).
pub fn snap_point(cx: &ToolContext, sr: SpreadRef, p: Point) -> (Point, Vec<Overlay>) {
let s = snap_rect(cx, sr, Rect::from_points(p, p), &[]);
(p + s.delta, s.guides)
}
fn rect_finite(r: Rect) -> bool {
r.x0.is_finite() && r.y0.is_finite() && r.x1.is_finite() && r.y1.is_finite()
}
/// Closest-target snap for one gesture.
///
/// Same targets and 5 px tolerance as [`snap_rect`], and only when a snap category is on.
/// A non-finite rect returns no correction. An axis whose three edge flags are all false stays at 0.
pub fn snap(cx: &ToolContext, req: SnapRequest<'_>) -> Snap {
if !rect_finite(req.rect) || !cx.snap.any() {
return Snap::default();
}
let x_free = req.x_edges != [false; 3];
let y_free = req.y_edges != [false; 3];
if x_free && y_free {
return snap_rect(cx, req.spread, req.rect, req.exclude);
}
snap_axes(cx, req.spread, req.rect, req.exclude, x_free, y_free)
}
fn guide(a: Point, b: Point, smart: bool) -> Overlay {
if smart { Overlay::Guide { a, b } } else { Overlay::Line { a, b, color: [255, 0, 255], dashed: false } }
}
#[cfg(test)]
mod tests {
use super::*;
// The factory flags are const. Pin them anyway.
#[test]
#[allow(clippy::assertions_on_constants)]
fn factory_zone_is_four_pixels() {
assert!(SnapView::FACTORY.snap_to_guides);
assert!(!SnapView::FACTORY.snap_to_document_grid);
assert!(SnapView::FACTORY.smart_guides);
assert_eq!(SnapView::FACTORY.zone_px, 4.0);
assert!(!SnapView::OFF.any());
assert!(SnapView::FACTORY.any());
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
use designcraft_compose::Cache;
use designcraft_doc::build::NewDocument;
use designcraft_doc::{Document, ParaFormat, Selection, SpreadRef};
use designcraft_geom::Rect;
use designcraft_geom::{Rect, Unit};
use super::*;
fn ctx<'a>(d: &'a Document, s: &'a Selection, c: &'a Cache, l: &'a CanvasLayout) -> ToolContext<'a> {
ToolContext { doc: d, selection: s, cache: c, layout: l, zoom: 1.0, layer: d.default_layer(), snap: false }
ToolContext { doc: d, selection: s, cache: c, layout: l, zoom: 1.0, layer: d.default_layer(), snap: SnapView::OFF, unit: Unit::Points }
}
#[test]
+9
View File
@@ -1222,6 +1222,15 @@ fn draw_tool_overlays(app: &mut DesignApp, painter: &egui::Painter, xf: &Xf) {
Overlay::Guide { a, b } => {
painter.line_segment([xf.to_screen(a), xf.to_screen(b)], Stroke::new(1.0, Color32::from_rgb(0, 200, 83)));
}
Overlay::Gap { a, b, label } => {
let (a, b) = (xf.to_screen(a), xf.to_screen(b));
painter.line_segment([a, b], Stroke::new(1.0, Color32::from_rgb(0, 200, 83)));
let s = a + (b - a) * 0.5;
let g = painter.layout_no_wrap(label, egui::FontId::proportional(11.0), Color32::WHITE);
let r = Rect::from_min_size(s, g.size() + vec2(10.0, 6.0));
painter.rect_filled(r, 3.0, Color32::from_rgba_unmultiplied(70, 70, 70, 230));
painter.galley(r.min + vec2(5.0, 3.0), g, Color32::WHITE);
}
Overlay::Path { .. } => {}
}
}
+2 -2
View File
@@ -26,7 +26,7 @@ pub mod widgets;
use std::collections::HashMap;
use std::sync::mpsc::{Receiver, Sender};
use designcraft_engine::{Session, UiRequest, ViewInfo};
use designcraft_engine::{Session, SnapView, UiRequest, ViewInfo};
use designcraft_geom::{Point, Unit};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
@@ -429,7 +429,7 @@ impl DesignApp {
self.pane = pane;
}
pub fn view_info(&self) -> ViewInfo {
ViewInfo { zoom: self.view().map(|v| v.zoom).unwrap_or(1.0) }
ViewInfo { zoom: self.view().map(|v| v.zoom).unwrap_or(1.0), snap: SnapView::FACTORY, unit: self.ui.units }
}
pub fn status(&mut self, s: impl Into<String>) {