From 8e735f1de92e21b3f1f405271bc388ef8f09ce27 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 08:16:36 -0400 Subject: [PATCH] =?UTF-8?q?M11.3:=20View=20=E2=86=92=20Snap=20to=20Pixel?= =?UTF-8?q?=20(drawing=20points=20and=20moved=20selections=20land=20on=20w?= =?UTF-8?q?hole=20pixels)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tooling.rs | 5 ++++- crates/tools/src/guides.rs | 3 +++ crates/tools/src/lib.rs | 3 +++ crates/tools/src/select.rs | 7 ++++++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/menus.rs | 42 +++++++++++++++++++++++++++++++++--- crates/ui-egui/src/state.rs | 3 +++ 7 files changed, 60 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index dbeb3220..5752e237 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -14,11 +14,13 @@ pub struct ViewInfo { pub smart_guides: bool, pub snap_to_grid: bool, pub show_bbox: bool, + /// View → Snap to Pixel: drawing and moving land on whole pixels (points at 72 ppi). + pub snap_to_pixel: bool, } impl Default for ViewInfo { fn default() -> Self { - Self { zoom: 1.0, outline: false, smart_guides: true, snap_to_grid: false, show_bbox: true } + Self { zoom: 1.0, outline: false, smart_guides: true, snap_to_grid: false, show_bbox: true, snap_to_pixel: false } } } @@ -60,6 +62,7 @@ impl Session { smart_guides: view.smart_guides, snap_to_grid: view.snap_to_grid, show_bbox: view.show_bbox, + snap_to_pixel: view.snap_to_pixel, }; f(self.tool.as_mut(), &cx) } diff --git a/crates/tools/src/guides.rs b/crates/tools/src/guides.rs index 709933f2..ebceac88 100644 --- a/crates/tools/src/guides.rs +++ b/crates/tools/src/guides.rs @@ -135,6 +135,9 @@ impl Targets { /// 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) { + 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![]); diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 3153a117..e6bcdfb2 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -148,6 +148,8 @@ pub struct ToolContext<'a> { pub snap_to_grid: bool, /// Bounding box shown (View → Show/Hide Bounding Box). pub show_bbox: bool, + /// View → Snap to Pixel. + pub snap_to_pixel: bool, } impl ToolContext<'_> { @@ -312,6 +314,7 @@ pub(crate) mod testutil { smart_guides: true, snap_to_grid: false, show_bbox: true, + snap_to_pixel: false, } } } diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 55f50203..a5999ca3 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -146,6 +146,13 @@ impl Tool for SelectionTool { d += adj; self.guides = ov; } + // Snap to Pixel: the moved selection's top-left lands on whole pixels. + if cx.snap_to_pixel + && let Some(b) = self.start_bounds + { + d = vectorcraft_geom::Vec2::new((b.x0 + d.x).round() - b.x0, (b.y0 + d.y).round() - b.y0); + self.guides.clear(); + } self.state = State::Moving { start, began: true }; self.measure = Some((p, format!("dX: {:.2} pt\ndY: {:.2} pt", d.x, d.y))); out.push(Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt }))); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3aa4d819..52fa59ba 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -210,6 +210,7 @@ impl VectorcraftApp { outline: self.ui.view.outline, smart_guides: self.ui.view.smart_guides, snap_to_grid: self.ui.view.snap_to_grid, + snap_to_pixel: self.ui.view.snap_to_pixel, show_bbox: self.ui.view.bounding_box, } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e01a56f2..a3f2d0c3 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -91,6 +91,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("view.pixelPreview", "Pixel Preview", "Cmd+Alt+Y", "{}"), ("view.trimView", "Trim View", "", "{} toggle: hide everything outside the artboards"), ("view.cornerWidget", "Hide Corner Widget", "", "{} toggle the live corner widgets"), + ("view.snapToPixel", "Snap to Pixel", "", "{} toggle: drawing and moving land on whole pixels"), ("view.zoomIn", "Zoom In", "Cmd+=", "{}"), ("view.zoomOut", "Zoom Out", "Cmd+-", "{}"), ("view.fitArtboard", "Fit Artboard in Window", "Cmd+0", "{}"), @@ -248,6 +249,29 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option flag(&mut app.ui.view.pixel_preview), "view.trimView" => flag(&mut app.ui.view.trim_view), "view.cornerWidget" => flag(&mut app.ui.view.corner_widgets), + // New View: the engine stores the current zoom, centre and rotation under the given name. + "view.saved.new" if p.get("zoom").is_none() => { + let Some(v) = app.view().copied() else { return Some(Err("no document".into())) }; + let mut params = if p.is_object() { p.clone() } else { json!({}) }; + params["center"] = json!([v.center.x, v.center.y]); + params["zoom"] = json!(v.zoom); + params["rotation"] = json!(v.rotation); + app.session.execute(id, ¶ms).map_err(|e| e.to_string()) + } + id if id.starts_with("view.goto") => { + let n: usize = id["view.goto".len()..].parse().unwrap_or(0); + let saved = app.session.active().and_then(|d| n.checked_sub(1).and_then(|i| d.doc.views.get(i)).cloned()); + match (saved, app.view_mut()) { + (Some(sv), Some(v)) => { + v.center = sv.center; + v.zoom = sv.zoom; + v.rotation = sv.rotation; + Ok(json!({ "name": sv.name })) + } + _ => Err("no such view".into()), + } + } + "view.snapToPixel" => flag(&mut app.ui.view.snap_to_pixel), id if id.starts_with("type.recentFont") => { let n: usize = id["type.recentFont".len()..].parse().unwrap_or(0); match n.checked_sub(1).and_then(|i| app.ui.recent_fonts.get(i)).cloned() { @@ -430,6 +454,7 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { "view.outline" => v.outline, "view.pixelPreview" => v.pixel_preview, "view.trimView" => v.trim_view, + "view.snapToPixel" => v.snap_to_pixel, // Type on a Path effect of the selected path type. "type.pathOptions" if p.get("effect").is_some() => { let st = app.session.active()?; @@ -1072,12 +1097,23 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { ), c("Show Grid", "view.grid"), c("Snap to Grid", "view.snapToGrid"), - todo("Snap to Pixel"), + c("Snap to Pixel", "view.snapToPixel"), c("Snap to Point", "view.snapToPoint"), todo("Snap to Glyph"), Sep, - todo("New View…"), - todo("Edit Views…"), + c("New View…", "view.saved.new"), + c("Edit Views…", "view.saved.edit"), + Sep, + c("Saved View 1", "view.goto1"), + c("Saved View 2", "view.goto2"), + c("Saved View 3", "view.goto3"), + c("Saved View 4", "view.goto4"), + c("Saved View 5", "view.goto5"), + c("Saved View 6", "view.goto6"), + c("Saved View 7", "view.goto7"), + c("Saved View 8", "view.goto8"), + c("Saved View 9", "view.goto9"), + c("Saved View 10", "view.goto10"), ], ), ( diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 235ab3aa..bf000573 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -119,6 +119,8 @@ pub struct ViewFlags { pub trim_view: bool, /// View → Hide/Show Corner Widget (live corner widgets on rectangles). pub corner_widgets: bool, + /// View → Snap to Pixel. + pub snap_to_pixel: bool, } impl Default for ViewFlags { @@ -138,6 +140,7 @@ impl Default for ViewFlags { edges: true, trim_view: false, corner_widgets: true, + snap_to_pixel: false, } } }