mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-14 19:27:07 -04:00
Snap: carry SnapView on the tool context
This commit is contained in:
1 parent
18e196acda
commit
4de93d1345
11 files changed
+198
-30
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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 { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
Reference in new issue
Block a user