M11.3: View → Snap to Pixel (drawing points and moved selections land on whole pixels)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 08:16:36 -04:00
parent 16a73dc02a
commit 8e735f1de9
7 changed files with 60 additions and 4 deletions

View File

@ -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)
}

View File

@ -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<Overlay>) {
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![]);

View File

@ -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,
}
}
}

View File

@ -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 })));

View File

@ -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,
}
}

View File

@ -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<R
"view.pixelPreview" => 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, &params).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<bool> {
"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<Item>)> {
),
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"),
],
),
(

View File

@ -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,
}
}
}