From 3955bf4f1911a69ea08947634b3e2b459feece2f Mon Sep 17 00:00:00 2001 From: Jeremy Date: Fri, 9 Oct 2026 03:01:45 +0100 Subject: [PATCH] Canvas: checker moves with the image; pixel grid from 500%, over pixels only (#1491) The GPU canvas anchored the checker to the top-left of the canvas viewport, while the pixel grid sits on document pixel edges, so panning or zooming slid the checker under the image and out of step with the grid. The checker is now measured from the document origin; its 8 screen pixel cell is unchanged. The pixel grid appeared from 800% (1200% on the CPU fallback), was faint and was drawn across empty pixels. It now shows above 500%, lightens a dark pixel by a quarter, and is skipped where the pixel is transparent (GPU canvas). New offscreen render tests in tests/checker_canvas.rs cover cell size at nine zooms, the phase, the grid threshold and strength, and the grid over empty checker. --- crates/ui-egui/src/canvas.rs | 4 +- crates/ui-egui/src/gpu_canvas.rs | 25 ++-- crates/ui-egui/tests/checker_canvas.rs | 193 +++++++++++++++++++++++++ 3 files changed, 210 insertions(+), 12 deletions(-) create mode 100644 crates/ui-egui/tests/checker_canvas.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4c9690f38b..00c5f76d28 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1996,11 +1996,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: } // Pixel grid at high zoom (the GPU path draws its own). - if !on_gpu && pixel_grid && view.zoom >= 12.0 { + if !on_gpu && pixel_grid && view.zoom > 5.0 { let vis = img_rect.intersect(rect); let a = xf.to_doc(vis.min); let b = xf.to_doc(vis.max); - let grid = Stroke::new(1.0, Color32::from_white_alpha(40)); + let grid = Stroke::new(1.0, Color32::from_white_alpha(64)); for x in (a[0].floor() as i32)..=(b[0].ceil() as i32) { let sx = xf.to_screen(x as f32, 0.0).x; painter.line_segment([pos2(sx, vis.top()), pos2(sx, vis.bottom())], grid); diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index 8f6aa2e37e..d6c71fc66c 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -13,10 +13,11 @@ //! //! Per frame the shader draws, in *device pixels*: //! - a soft analytic drop shadow around the document on the pasteboard (erf-blurred box), -//! - a screen-space transparency checkerboard (8 pt squares, grays 255/204) under the document, +//! - a transparency checkerboard (8 pt squares, grays 255/204, the same size at every zoom) +//! under the document, anchored to the document's top-left corner so it moves with the image, //! - the document itself, sampled trilinearly when zoomed out, bilinearly between 1× and 2×, and //! with an anti-aliased "sharp nearest" filter at integer scales and ≥ 2×, -//! - a one-device-pixel pixel grid at zoom ≥ 8. +//! - a one-device-pixel pixel grid above 500% zoom, over pixels with content only. //! //! GPU resources live in the renderer's `callback_resources` type map; the per-frame callback only //! carries plain view parameters, so it is `Send + Sync` on every target (including wasm). @@ -1594,7 +1595,8 @@ impl CanvasCallback { let p = &self.params; let (origin, scale) = self.placement(ppp); let (mode, lod) = filter_mode(scale); - let grid = if p.pixel_grid && p.zoom >= 8.0 { 0.16 } else { 0.0 }; + // The pixel grid shows above 500% and lightens a dark pixel by about a quarter. + let grid = if p.pixel_grid && p.zoom > 5.0 { 0.25 } else { 0.0 }; let square = if style.checker_square > 0.0 { (style.checker_square * ppp).round().max(1.0) } else { 0.0 }; let (l, d, g) = (style.checker_light, style.checker_dark, style.gamut_color); // 32-bit preview: linear-light gain 2^exposure (0 = off) and 1 / gamma. @@ -1615,8 +1617,8 @@ impl CanvasCallback { lod, grid, square, - (self.rect.min.x * ppp).round(), - (self.rect.min.y * ppp).round(), + 0.0, + 0.0, p.display as f32, if out_linear { 1.0 } else { 0.0 }, l[0], @@ -1720,7 +1722,7 @@ struct View { a: vec4, // screen_w, screen_h, scale (device px per doc px), pixels_per_point b: vec4, // doc origin x, y (device px), doc w, h (doc px) c: vec4, // filter mode, lod, grid alpha, checker square (device px) - d: vec4, // checker anchor x, y (device px), display (0 none, 1 LUT, 2 LUT + gamut), output linear + d: vec4, // unused x, y, display (0 none, 1 LUT, 2 LUT + gamut), output linear e: vec4, // checker light rgb, gamut warning opacity f: vec4, // checker dark rgb, 32-bit preview gain (2^exposure; 0 = off) g: vec4, // gamut warning rgb, 32-bit preview 1 / gamma @@ -1806,11 +1808,13 @@ fn vs_tile(@builtin(vertex_index) vi: u32) -> VOut { return VOut(px_to_clip(p)); } +// Cells are screen-sized (view.c.w device px) but anchored to the document's top-left corner, so +// the pattern moves with the image as it is panned or zoomed. fn checker(p: vec2) -> vec3 { if (view.c.w <= 0.0) { return vec3(1.0); } - let c = floor((p - view.d.xy) / view.c.w); + let c = floor((p - view.b.xy) / view.c.w); let odd = fract((c.x + c.y) * 0.5) > 0.25; return select(view.e.xyz, view.f.xyz, odd); } @@ -1854,12 +1858,13 @@ fn fs_tile(in: VOut) -> @location(0) vec4 { col = vec4(shown * col.a, col.a); } var rgb = col.rgb + checker(p) * (1.0 - col.a); - let grid = view.c.z; + // The pixel grid is drawn over pixels with content, not over empty checker. + let grid = view.c.z * col.a; if (grid > 0.0) { let e = fract(d) * scale; if (e.x < 1.0 || e.y < 1.0) { - let l = dot(rgb, vec3(0.299, 0.587, 0.114)); - rgb = mix(rgb, select(vec3(1.0), vec3(0.0), l > 0.55), grid); + let on_light = dot(rgb, vec3(0.299, 0.587, 0.114)) > 0.55; + rgb = mix(rgb, select(vec3(1.0), vec3(0.0), on_light), select(grid, grid * 0.64, on_light)); } } if (view.d.w > 0.5) { diff --git a/crates/ui-egui/tests/checker_canvas.rs b/crates/ui-egui/tests/checker_canvas.rs new file mode 100644 index 0000000000..45892dc99e --- /dev/null +++ b/crates/ui-egui/tests/checker_canvas.rs @@ -0,0 +1,193 @@ +//! The transparency checker is made of 8 screen pixel cells (the Medium preference) at every zoom, +//! anchored to the document's top-left corner so the pattern moves with the image. The pixel grid shows above 500% zoom, +//! over pixels with content only, never over empty checker. +//! +//! These tests render the real app offscreen and read pixels back. They skip when no GPU adapter +//! is available. + +use photocraft_ui_egui::PhotocraftApp; +use photocraft_ui_egui::control::{ControlRequest, handle}; +use serde_json::{Value, json}; + +type Harness = egui_kittest::Harness<'static, PhotocraftApp>; + +const LIGHT: u8 = 255; +const DARK: u8 = 204; + +fn harness() -> Option { + let built = std::panic::catch_unwind(|| { + egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 800.0)).with_pixels_per_point(1.0).with_max_steps(64).wgpu().build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + if let Some(rs) = cc.wgpu_render_state.as_ref() { + app.set_wgpu(rs.clone()); + } + app + }) + }); + match built { + Ok(h) => Some(h), + Err(_) => { + eprintln!("skipping: no GPU adapter"); + None + } + } +} + +fn control(h: &mut Harness, method: &str, params: Value) { + let ctx = h.ctx.clone(); + let (req, _rx) = ControlRequest::new(method, params); + handle(h.state_mut(), &ctx, &req); + h.run_steps(4); +} + +/// True for the neutral light greys the default checker (and its pixel-grid darkening) paints. +fn is_checker_grey(p: [u8; 4]) -> bool { + p[0] == p[1] && p[1] == p[2] && p[0] >= 160 +} + +/// Longest run of `true` in `flags`, as `(start, len)`. +fn longest_run(flags: impl Iterator) -> (usize, usize) { + let (mut best, mut start, mut len) = ((0, 0), 0, 0); + for (i, f) in flags.enumerate() { + if f { + if len == 0 { + start = i; + } + len += 1; + if len > best.1 { + best = (start, len); + } + } else { + len = 0; + } + } + best +} + +/// A rendered screen. +struct Shot { + w: u32, + px: Vec, +} + +impl Shot { + fn at(&self, x: u32, y: u32) -> [u8; 4] { + let i = ((y * self.w + x) * 4) as usize; + [self.px[i], self.px[i + 1], self.px[i + 2], self.px[i + 3]] + } +} + +/// Opens a `doc` px square document at `zoom` (transparent, or filled black when `black`), renders +/// it with the pixel grid on or off, and returns the screenshot with the document's left and top +/// screen edge and its width in screen pixels. +fn render(h: &mut Harness, zoom: f32, doc: u32, black: bool, grid: bool) -> (Shot, u32, u32, u32) { + { + let app = h.state_mut(); + while app.session.active().is_some() { + app.run("file.close", json!({"discard": true})).expect("close"); + } + let background = if black { "white" } else { "transparent" }; + app.run("file.new", json!({"width": doc, "height": doc, "background": background})).expect("new"); + if black { + app.run("edit.fill", json!({"color": "#000000"})).expect("fill"); + } + app.sync_views(); + app.ui.view.show.pixel_grid = grid; + } + control(h, "ui.set", json!({"zoom": zoom, "center": [doc as f32 / 2.0, doc as f32 / 2.0]})); + h.run_steps(3); + assert!(h.state().perf.gpu, "zoom {zoom}: expected the GPU canvas"); + let rendered = h.render().expect("render"); + let (width, height) = (rendered.width(), rendered.height()); + let img = Shot { w: width, px: rendered.into_raw() }; + let r = h.state().last_canvas_rect; + let (cx, cy) = (r.center().x as u32, r.center().y as u32); + // Black fill is 0 (64 on a grid line), the pasteboard is 39. + let on_doc = |p: [u8; 4]| if black { p[0] < 12 || (58..=70).contains(&p[0]) } else { is_checker_grey(p) }; + let (x0, w) = longest_run((0..width).map(|x| on_doc(img.at(x, cy)))); + let (y0, hgt) = longest_run((0..height).map(|y| on_doc(img.at(cx, y)))); + let doc_px = (doc as f32 * zoom).round() as i64; + assert!((w as i64 - doc_px).abs() <= 1 && (hgt as i64 - doc_px).abs() <= 1, "zoom {zoom}: found a {w}x{hgt} document, want {doc_px}"); + (img, x0 as u32, y0 as u32, w as u32) +} + +#[test] +fn checker_cells_are_8_screen_pixels_at_every_zoom() { + let Some(mut h) = harness() else { return }; + h.run_steps(4); + // The 8 px cell never changes with the zoom. + for (zoom, doc) in [(0.5, 64), (1.0, 64), (1.1, 64), (2.0, 32), (3.3, 32), (5.5, 32), (8.0, 32), (13.0, 32), (32.0, 12)] { + let (img, x0, y0, _) = render(&mut h, zoom, doc, false, false); + let n = ((doc as f32 * zoom) / 8.0) as u32; + assert!(n >= 2, "zoom {zoom}: test needs at least two cells"); + for row in 0..n.min(4) { + for col in 0..n.min(6) { + let (sx, sy) = (x0 + col * 8 + 4, y0 + row * 8 + 4); + let got = img.at(sx, sy); + let want = if (col + row) % 2 == 0 { LIGHT } else { DARK }; + assert!( + got[0] == want && got[1] == want && got[2] == want, + "zoom {zoom}, cell ({col}, {row}) centre ({sx}, {sy}): got {got:?}, want grey {want}" + ); + } + } + // Crisp: every pixel of the row is exactly one of the two greys, no blended edges. + let y = y0 + 4; + for x in x0..x0 + (n * 8).min(48) { + let v = img.at(x, y)[0]; + assert!(v == LIGHT || v == DARK, "zoom {zoom}: blended pixel {v} at ({x}, {y})"); + } + } +} + +#[test] +fn checker_phase_moves_with_the_image() { + let Some(mut h) = harness() else { return }; + h.run_steps(4); + // The document origin moves four screen pixels between these two views, and the checker + // phase moves with it: the same screen position flips from a light to a dark cell. + let mut sample_at = None; + for (zoom, expected) in [(1.0, LIGHT), (1.25, DARK)] { + let (img, x0, y0, _) = render(&mut h, zoom, 32, false, false); + let (sx, sy) = *sample_at.get_or_insert((x0 + 6, y0 + 2)); + let px = img.at(sx, sy); + assert!(px[0] == expected, "zoom {zoom}: phase at fixed screen position ({sx}, {sy}) is {px:?}, want {expected}"); + } +} + +/// Screen columns inside the document (from `x0`) whose pixel is lighter than the black fill. +fn lit_columns(img: &Shot, x0: u32, y: u32, len: u32) -> Vec { + (0..len).filter(|i| img.at(x0 + i, y)[0] > 25).collect() +} + +#[test] +fn pixel_grid_shows_above_500_percent_and_lightens_a_dark_pixel_by_about_a_quarter() { + let Some(mut h) = harness() else { return }; + h.run_steps(4); + let (flat, x0, y0, _) = render(&mut h, 5.0, 48, true, true); + assert!(lit_columns(&flat, x0, y0 + 20, 100).is_empty(), "no grid at 500%"); + for zoom in [6.0f32, 8.0, 12.0] { + let (img, x0, y0, _) = render(&mut h, zoom, 48, true, true); + let lit = lit_columns(&img, x0, y0 + (zoom * 3.5) as u32, (zoom * 10.0) as u32); + assert_eq!(lit.len(), 10, "zoom {zoom}: grid columns {lit:?}"); + assert!(lit.windows(2).all(|p| p[1] - p[0] == zoom as u32), "zoom {zoom}: spacing of {lit:?}"); + let v = img.at(x0 + lit[2], y0 + (zoom * 3.5) as u32)[0]; + assert!((60..=68).contains(&v), "zoom {zoom}: a grid line over black is {v}, want about 64 (a quarter of the way to white)"); + } +} + +#[test] +fn pixel_grid_is_not_drawn_over_empty_checker() { + let Some(mut h) = harness() else { return }; + h.run_steps(4); + for zoom in [8.0f32, 16.0] { + let (img, x0, y0, _) = render(&mut h, zoom, 24, false, true); + for y in y0..y0 + 64 { + for x in x0..x0 + 64 { + let v = img.at(x, y)[0]; + assert!(v == LIGHT || v == DARK, "zoom {zoom}: grid or blend {v} over empty checker at ({x}, {y})"); + } + } + } +}