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})"); + } + } + } +}