collective

Browse / github.com/storytold/vectorcraft / patch

Fix canvas pointer mapping under subpixel dock layouts

Fix canvas pointer mapping under subpixel dock layouts ## What and why Small differences in dock layout can produce canvas rectangles that differ by fractions of a device pixel. This causes the same pointer position to map to different document coordinates across otherwise equivalent layouts. Round the canvas rectangle to physical pixels before storing and using it, keeping rendering, inspection and pointer transforms consistent. ## How it was tested Updated against upstream main `443b31b1` after the canvas module split. The rounding remains in `canvas.rs`; the unchanged regression test now lives in `crates/ui-egui/src/canvas/tests.

Status
open
Score
0.00
Adopters
0 other operators
Author
michael-holzheu (imported from pull request #4292, open)
Base
443b31b13df3a7ab611c2fe1b95e73898ed74fdd
Size
+30 −0 in 2 files
Review
pending
Shared
2026-10-28
Id
28441620619988fd02436422

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui-egui/src/canvas.rs crates/ui-egui/src/canvas/tests.rs

diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs--- a/crates/ui-egui/src/canvas.rs+++ b/crates/ui-egui/src/canvas.rs@@ -1,6 +1,7 @@ //! The document canvas: rendering, rulers, navigation, pointer routing to tools and on-canvas //! selection visuals (bounding box, anchors, handles, smart-guide style labels). +use egui::emath::GuiRounding as _; use egui::{Color32, CornerRadius, Pos2, Sense, Shape, Stroke, StrokeKind, Ui, Vec2, pos2, vec2}; use serde_json::json; use vectorcraft_doc::text::TextKind;@@ -196,6 +197,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {     let bars = app.ui.screen_mode < 2;     let full = if bars { egui::Rect::from_min_max(full.min, full.max - vec2(SCROLL_BAR, SCROLL_BAR)) } else { full };     let rect = if app.ui.view.rulers && app.ui.screen_mode < 3 { egui::Rect::from_min_max(full.min + vec2(RULER, RULER), full.max) } else { full };+    // Dock layout can differ by fractions of a device pixel between fresh windows.+    // Use one pixel-aligned rectangle for drawing, inspection and pointer transforms.+    let rect = rect.round_to_pixels(ui.ctx().pixels_per_point());     app.canvas_rect = Some(rect);     let fitted = app.view().is_some_and(|v| v.fitted);     if !fitted {diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs--- a/crates/ui-egui/src/canvas/tests.rs+++ b/crates/ui-egui/src/canvas/tests.rs@@ -2,6 +2,32 @@ use super::*; use vectorcraft_engine::Session; use vectorcraft_geom::Shape as _; +#[test]+fn subpixel_dock_layout_does_not_change_pointer_document_coordinates() {+    let mut observed = Vec::new();+    for width in [1324.0 + 29.0 / 32.0, 1324.875, 1325.875] {+        let mut app = VectorcraftApp::new(Session::new(), Default::default());+        app.session.execute("file.new", &json!({"width": 800, "height": 600, "created": 0})).unwrap();+        let ctx = egui::Context::default();+        ctx.set_pixels_per_point(2.0);+        let raw = egui::RawInput {+            screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(1800.0, 900.0))),+            max_texture_side: Some(8192),+            ..Default::default()+        };+        let mut out = ctx.run_ui(raw, |ui| {+            let area = egui::Rect::from_min_size(pos2(54.0, 72.0), vec2(width + SCROLL_BAR, 636.0 + SCROLL_BAR));+            let mut child = ui.new_child(egui::UiBuilder::new().max_rect(area));+            show(&mut app, &mut child);+        });+        out.textures_delta.clear();+        let rect = app.canvas_rect.unwrap();+        observed.push((rect, Xf::new(rect, app.view().unwrap()).to_doc(pos2(440.0, 330.0))));+    }+    assert_eq!(observed[0], observed[1], "the same physical canvas must map the same pointer identically");+    assert_ne!(observed[0], observed[2], "a real device-pixel resize must still change the mapping");+}+ /// A rendered raster becomes the image egui's own conversion makes of it, half-transparent /// edges and all; one whose bytes don't match its size becomes an empty image of that size. #[test]