diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2e7a459a7a..963b94a7f4 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2493,7 +2493,14 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // Crop tool: the layers' pixels past the canvas while a frame is edited, under the canvas // (whose edge blends into them) and without its shadow. let beyond = draw_beyond_canvas(app, &painter, &xf, idx, output); - let drop_shadow = drop_shadow && !beyond; + // View › Pattern Preview (#1067): copies of the document around it, from the same texture. + let pattern = if app.ui.view.pattern_preview { + let v = visible_doc_rect(&xf); + crate::pattern_preview::copies([v.x0, v.y0, v.x1, v.y1].map(f64::from), size) + } else { + Vec::new() + }; + let drop_shadow = drop_shadow && !beyond && !app.ui.view.pattern_preview; // Live adjustment previews on big documents use a downsampled proxy (see proxy.rs), unless // Preferences › Performance › Low Resolution Previews is off. let mut on_gpu = false; @@ -2526,8 +2533,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: display: sync_display_lut(app, &doc, key, output), output: output.unwrap_or(0), hdr: hdr_preview(app, &doc), + offset: [0.0, 0.0], }; - crate::gpu_canvas::GpuCanvas::paint(&painter, rect, params); + paint_gpu_view(&painter, rect, params, &pattern); } else if gpu_ok && ensure_gpu(app, idx, visible_doc_rect(&xf)) { on_gpu = true; app.perf.gpu = true; @@ -2547,40 +2555,19 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: display: sync_display_lut(app, &doc, doc.id.0, output), output: output.unwrap_or(0), hdr: hdr_preview(app, &doc), + offset: [0.0, 0.0], }; - crate::gpu_canvas::GpuCanvas::paint(&painter, rect, params); + paint_gpu_view(&painter, rect, params, &pattern); } else { if !crate::theme::Tokens::get(&ctx).bevel && drop_shadow { painter.add(egui::Shadow { offset: [0, 8], blur: 28, spread: 0, color: Color32::from_black_alpha(150) }.as_shape(img_rect, 0)); } let rotated = view.rotation.abs() > 0.05; - let size = [doc.size.width as f32, doc.size.height as f32]; - match app.session.prefs().transparency_and_gamut.square() { - Some(square) => { - let checker_id = checker(app, &ctx); - if rotated { - let tiles = vec2(size[0], size[1]) / (2.0 * square); - paint_mapped_image(&painter, &xf, checker_id, Rect::from_min_max(Pos2::ZERO, pos2(tiles.x, tiles.y)), size, Color32::WHITE); - } else { - let tiles = img_rect.size() / (2.0 * square); - painter.image(checker_id, img_rect, Rect::from_min_max(Pos2::ZERO, pos2(tiles.x, tiles.y)), Color32::WHITE); - } - } - None => { - if rotated { - paint_mapped_quad(&painter, &xf, size, Color32::WHITE); - } else { - painter.rect_filled(img_rect, 0.0, Color32::WHITE); - } - } - } - if let Some((tex, _scale)) = ensure_texture(app, &ctx, idx, output) { - if rotated { - paint_mapped_image(&painter, &xf, tex, Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), size, Color32::WHITE); - } else { - let uv = if flip { Rect::from_min_max(pos2(1.0, 0.0), pos2(0.0, 1.0)) } else { Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)) }; - painter.image(tex, img_rect, uv, Color32::WHITE); - } + let checkerboard = app.session.prefs().transparency_and_gamut.square().map(|square| (checker(app, &ctx), square)); + let tex = ensure_texture(app, &ctx, idx, output).map(|(tex, _scale)| tex); + paint_cpu_document(&painter, &xf, doc.bounds(), rotated, checkerboard, tex); + for &c in &pattern { + paint_cpu_document(&painter, &pattern_xf(&xf, c, size), doc.bounds(), rotated, checkerboard, tex); } } // Channels panel: alpha / Quick Mask overlays and single-channel views (channel_view.rs). @@ -3408,6 +3395,69 @@ fn paint_mapped_image(painter: &egui::Painter, xf: &ViewXform, tex: egui::Textur painter.add(egui::Shape::mesh(mesh)); } +/// The GPU canvas view, then its View › Pattern Preview copies (#1067): the same texture drawn +/// again whole document sizes away, each with its own view uniform and no shadow. +fn paint_gpu_view(painter: &egui::Painter, rect: Rect, params: crate::gpu_canvas::ViewParams, copies: &[[i32; 2]]) { + crate::gpu_canvas::GpuCanvas::paint(painter, rect, params); + for (k, &c) in copies.iter().enumerate() { + let copy = crate::gpu_canvas::ViewParams { + offset: crate::pattern_preview::offset(c, params.doc_size), + shadow: false, + // Keyed by slot, not by tile index, so panning reuses the same few uniform buffers. + view_key: egui::Id::new((params.view_key, "pattern", k)).value(), + ..params + }; + crate::gpu_canvas::GpuCanvas::paint(painter, rect, copy); + } +} + +/// The mapping that draws Pattern Preview copy `c` of a `size` document where it sits next to +/// the document: the same view, looking the matching number of document sizes back. +fn pattern_xf(xf: &ViewXform, c: [i32; 2], size: [u32; 2]) -> ViewXform { + let [ox, oy] = crate::pattern_preview::offset(c, size); + ViewXform { center: [xf.center[0] - ox, xf.center[1] - oy], ..*xf } +} + +/// CPU canvas: the transparency checkerboard (or white) and the display texture, for the +/// document or one of its Pattern Preview copies (`xf` from [`pattern_xf`]). +fn paint_cpu_document( + painter: &egui::Painter, + xf: &ViewXform, + bounds: DRect, + rotated: bool, + checker: Option<(egui::TextureId, f32)>, + tex: Option, +) { + let size = [bounds.width() as f32, bounds.height() as f32]; + let img_rect = xf.doc_rect(bounds); + match checker { + Some((checker_id, square)) => { + if rotated { + let tiles = vec2(size[0], size[1]) / (2.0 * square); + paint_mapped_image(painter, xf, checker_id, Rect::from_min_max(Pos2::ZERO, pos2(tiles.x, tiles.y)), size, Color32::WHITE); + } else { + let tiles = img_rect.size() / (2.0 * square); + painter.image(checker_id, img_rect, Rect::from_min_max(Pos2::ZERO, pos2(tiles.x, tiles.y)), Color32::WHITE); + } + } + None => { + if rotated { + paint_mapped_quad(painter, xf, size, Color32::WHITE); + } else { + painter.rect_filled(img_rect, 0.0, Color32::WHITE); + } + } + } + if let Some(tex) = tex { + if rotated { + paint_mapped_image(painter, xf, tex, Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), size, Color32::WHITE); + } else { + let uv = if xf.flip { Rect::from_min_max(pos2(1.0, 0.0), pos2(0.0, 1.0)) } else { Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)) }; + painter.image(tex, img_rect, uv, Color32::WHITE); + } + } +} + fn paint_mapped_quad(painter: &egui::Painter, xf: &ViewXform, size: [f32; 2], color: Color32) { let [w, h] = size; let pts = vec![xf.to_screen(0.0, 0.0), xf.to_screen(w, 0.0), xf.to_screen(w, h), xf.to_screen(0.0, h)]; diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index 6764fb302c..e25c61676e 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -82,6 +82,10 @@ pub struct ViewParams { /// before the display LUT; `None` when off. The display LUT must then leave it out /// (`ColorState::gpu_canvas_lut`). pub hdr: Option<[f32; 2]>, + /// Where this draw puts the document, in document pixels from its own place: `[0, 0]` for the + /// document, a whole number of widths and heights for a View › Pattern Preview copy (#1067). + /// A copy samples the same texture, so nothing is composited again. + pub offset: [f32; 2], } /// When high-bit documents get an `Rgba16Float` canvas texture. @@ -1679,8 +1683,8 @@ impl CanvasCallback { lod, grid, square, - 0.0, - 0.0, + if p.offset[0].is_finite() { p.offset[0] } else { 0.0 }, + if p.offset[1].is_finite() { p.offset[1] } else { 0.0 }, p.display as f32, if out_linear { 1.0 } else { 0.0 }, l[0], @@ -1777,11 +1781,12 @@ impl CallbackTrait for CanvasCallback { let rot = if self.params.rotation.is_finite() { self.params.rotation } else { 0.0 }; let (cos, sin) = if rot.abs() < 1e-8 { (1.0, 0.0) } else { (rot.cos(), rot.sin()) }; let cen = self.params.center; + let [ox, oy] = self.params.offset.map(|v| if v.is_finite() { v } else { 0.0 }); pass.set_pipeline(&res.tile_pipeline); pass.set_bind_group(2, res.luts.get(&(self.params.doc, self.params.output)).unwrap_or(&res.identity_lut), &[]); for t in &doc.tiles { let [tx, ty, tw, th] = t.rect.map(|v| v as f32); - let (x0, y0, x1, y1) = tile_screen_aabb(origin, scale, cen, cos, sin, [tx, ty, tw, th]); + let (x0, y0, x1, y1) = tile_screen_aabb(origin, scale, cen, cos, sin, [tx + ox, ty + oy, tw, th]); if x1 < cx0 || y1 < cy0 || x0 > cx1 || y0 > cy1 { continue; } @@ -1796,7 +1801,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, // unused x, y, display (0 none, 1 LUT, 2 LUT + gamut), output linear + d: vec4, // pattern preview offset x, y (doc px), 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 @@ -1904,17 +1909,18 @@ fn fs_shadow(in: VOut) -> @location(0) vec4 { @vertex fn vs_tile(@builtin(vertex_index) vi: u32) -> VOut { - let p = doc_to_px(tile.r.xy + tile.r.zw * corner(vi)); + // A View › Pattern Preview copy sits a whole number of document sizes away (view.d.xy). + let p = doc_to_px(view.d.xy + tile.r.xy + tile.r.zw * corner(vi)); 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 { +// the pattern moves with the image as it is panned or zoomed. `d` is in document pixels (each +// Pattern Preview copy has the same cells). +fn checker(d: vec2) -> vec3 { if (view.c.w <= 0.0) { return vec3(1.0); } - let d = px_to_doc(p); let c = floor(d * view.a.z / view.c.w); let odd = fract((c.x + c.y) * 0.5) > 0.25; return select(view.e.xyz, view.f.xyz, odd); @@ -1924,7 +1930,7 @@ fn checker(p: vec2) -> vec3 { fn fs_tile(in: VOut) -> @location(0) vec4 { let p = in.pos.xy; let scale = view.a.z; - let d = px_to_doc(p); // document pixel coordinates + let d = px_to_doc(p) - view.d.xy; // document pixel coordinates (within this copy) let size = tile.r.zw; let t = d - tile.r.xy; // texel coordinates within this tile let mode = view.c.x; @@ -1958,7 +1964,7 @@ fn fs_tile(in: VOut) -> @location(0) vec4 { let shown = select(l.rgb, mix(l.rgb, view.g.xyz, view.e.w), view.d.z > 1.5 && l.a > 0.5); col = vec4(shown * col.a, col.a); } - var rgb = col.rgb + checker(p) * (1.0 - col.a); + var rgb = col.rgb + checker(d) * (1.0 - col.a); // The pixel grid is drawn over pixels with content, not over empty checker. let grid = view.c.z * col.a; if (grid > 0.0) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b167bb5acc..59707c71e8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -120,6 +120,7 @@ mod panel_docking; pub mod panels; pub mod parity; pub mod patch_preview; +pub mod pattern_preview; pub mod perspective_ui; pub mod pixel_grid; pub mod plugin_ui; diff --git a/crates/ui-egui/src/pattern_preview.rs b/crates/ui-egui/src/pattern_preview.rs new file mode 100644 index 0000000000..eb1b8245cc --- /dev/null +++ b/crates/ui-egui/src/pattern_preview.rs @@ -0,0 +1,109 @@ +//! View › Pattern Preview (#1067): the canvas shows the document repeated around itself as a +//! seamless tile, so what leaves one edge is seen coming back at the opposite one. The copies +//! reuse the document's display texture (no compositing per copy); selection, guides and other +//! overlays stay on the document itself. + +use std::ops::RangeInclusive; + +/// Most tiles drawn along each axis, the document included: 15 on each side of the view's centre +/// tile (at most 960 copies, each one textured quad per visible texture tile). A tiny document +/// zoomed far out would otherwise need thousands of copies per frame; past this the pattern stops. +pub const MAX_PER_AXIS: i32 = 31; + +/// The copies of a `size` document that cover `visible` (the document-pixel area the view shows: +/// x0, y0, x1, y1), as tile indices: copy `[i, j]` is the document moved by `i` widths and `j` +/// heights. The document itself (`[0, 0]`) is not listed. Row by row, left to right. When more +/// than [`MAX_PER_AXIS`] tiles would fit along an axis, the run is centred on the view. +pub fn copies(visible: [f64; 4], size: [u32; 2]) -> Vec<[i32; 2]> { + let (Some(xs), Some(ys)) = (axis(visible[0], visible[2], size[0]), axis(visible[1], visible[3], size[1])) else { + return Vec::new(); + }; + ys.flat_map(|j| xs.clone().map(move |i| [i, j])).filter(|&c| c != [0, 0]).collect() +} + +/// Document offset of copy `c` in pixels of a `size` document. +pub fn offset(c: [i32; 2], size: [u32; 2]) -> [f32; 2] { + [c[0] as f32 * size[0] as f32, c[1] as f32 * size[1] as f32] +} + +/// Tile indices along one axis that overlap `lo..hi` for tiles `len` pixels long. +fn axis(lo: f64, hi: f64, len: u32) -> Option> { + if len == 0 || !lo.is_finite() || !hi.is_finite() || hi <= lo { + return None; + } + let n = f64::from(len); + // Far past any real view; keeps the ± MAX_PER_AXIS arithmetic inside i32. + let index = |v: f64| v.clamp(-1e9, 1e9) as i32; + let first = index((lo / n).floor()); + let last = index((hi / n).ceil()) - 1; + if last.saturating_sub(first) < MAX_PER_AXIS { + return Some(first..=last); + } + let mid = index(((lo + hi) / 2.0 / n).floor()); + let half = MAX_PER_AXIS / 2; + Some(mid - half..=mid + half) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn document_filling_the_view_has_no_copies() { + // Zoomed in: the view shows part of a 1000 × 800 document. + assert!(copies([100.0, 100.0, 600.0, 500.0], [1000, 800]).is_empty()); + // Exactly the document. + assert!(copies([0.0, 0.0, 1000.0, 800.0], [1000, 800]).is_empty()); + } + + #[test] + fn fit_view_gets_the_neighbours_that_show() { + // A 100 × 100 document centred in a view 300 doc px wide and 100 tall: one copy each side. + assert_eq!(copies([-100.0, 0.0, 200.0, 100.0], [100, 100]), vec![[-1, 0], [1, 0]]); + // A view slightly larger than the document all round: the eight neighbours. + let c = copies([-10.0, -10.0, 110.0, 110.0], [100, 100]); + assert_eq!(c, vec![[-1, -1], [0, -1], [1, -1], [-1, 0], [1, 0], [-1, 1], [0, 1], [1, 1]]); + } + + #[test] + fn zoomed_out_fills_the_view() { + // 200 × 100 document, zoomed out so the view shows 1000 × 500 doc px around it. + let c = copies([-400.0, -200.0, 600.0, 300.0], [200, 100]); + // Columns -2..=2, rows -2..=2, minus the document itself. + assert_eq!(c.len(), 5 * 5 - 1); + assert!(c.contains(&[-2, -2]) && c.contains(&[2, 2]) && !c.contains(&[0, 0])); + assert_eq!(offset([-2, 1], [200, 100]), [-400.0, 100.0]); + } + + #[test] + fn panned_view_only_draws_what_shows() { + // Panned right so the document is off screen to the left: only copies to its right. + let c = copies([250.0, 10.0, 450.0, 90.0], [100, 100]); + assert_eq!(c, vec![[2, 0], [3, 0], [4, 0]]); + // Panned up-left past the corner. + assert_eq!(copies([-150.0, -150.0, -60.0, -60.0], [100, 100]), vec![[-2, -2], [-1, -2], [-2, -1], [-1, -1]]); + } + + #[test] + fn copies_are_capped_and_centred_on_the_view() { + // A 1 px document zoomed out over a view thousands of pixels wide. + let c = copies([-2000.0, -1000.0, 2000.0, 1000.0], [1, 1]); + assert_eq!(c.len(), (MAX_PER_AXIS * MAX_PER_AXIS - 1) as usize); + let half = MAX_PER_AXIS / 2; + assert!(c.iter().all(|&[i, j]| (-half..=half).contains(&i) && (-half..=half).contains(&j))); + // Panned far away: the run follows the view's centre, not the document. + let c = copies([1_000_000.0, 0.0, 1_004_000.0, 1.0], [1, 1]); + assert_eq!(c.len(), MAX_PER_AXIS as usize); + assert!(c.iter().all(|&[i, j]| j == 0 && (1_002_000 - half..=1_002_000 + half).contains(&i))); + } + + #[test] + fn hostile_input_draws_nothing() { + assert!(copies([0.0, 0.0, 10.0, 10.0], [0, 10]).is_empty()); + assert!(copies([f64::NAN, 0.0, 10.0, 10.0], [10, 10]).is_empty()); + assert!(copies([0.0, 0.0, f64::INFINITY, 10.0], [10, 10]).is_empty()); + assert!(copies([10.0, 0.0, 0.0, 10.0], [10, 10]).is_empty()); + // Huge but finite: capped, no overflow. + assert_eq!(copies([-1e300, -1e300, 1e300, 1e300], [1, 1]).len(), (MAX_PER_AXIS * MAX_PER_AXIS - 1) as usize); + } +} diff --git a/crates/ui-egui/tests/pattern_preview_canvas.rs b/crates/ui-egui/tests/pattern_preview_canvas.rs new file mode 100644 index 0000000000..627bbddaa2 --- /dev/null +++ b/crates/ui-egui/tests/pattern_preview_canvas.rs @@ -0,0 +1,93 @@ +//! View › Pattern Preview (#1067): the canvas shows the document repeated around itself, on the +//! GPU canvas and on the CPU canvas (flipped views draw through the CPU path), and nothing past +//! the document while it is off. Skips when no GPU adapter exists (like `color_managed_canvas.rs`). + +use photocraft_ui_egui::PhotocraftApp; +use photocraft_ui_egui::canvas::ViewXform; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, PhotocraftApp>; + +fn harness() -> Option { + let built = std::panic::catch_unwind(|| { + egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.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 + } + } +} + +/// The colour on screen at document point `at` (which may lie past the document). +fn sample(h: &mut Harness, at: [f32; 2]) -> [u8; 3] { + h.run_steps(6); + let xf = ViewXform::active(h.state()).expect("canvas view"); + let p = xf.to_screen(at[0], at[1]); + let img = h.render().expect("render"); + let px = img.get_pixel(p.x.round() as u32, p.y.round() as u32); + [px[0], px[1], px[2]] +} + +/// View › Pattern Preview, as the menu runs it. +fn toggle(h: &mut Harness) { + let (request, _) = photocraft_ui_egui::control::ControlRequest::new("ui.menu.invoke", json!({"id": "view.patternPreview"})); + let ctx = h.ctx.clone(); + let response = photocraft_ui_egui::control::handle(h.state_mut(), &ctx, &request); + assert!(matches!(response, photocraft_ui_egui::control::Outcome::Done(_))); +} + +fn near(a: [u8; 3], b: [u8; 3]) -> bool { + (0..3).all(|i| a[i].abs_diff(b[i]) <= 3) +} + +#[test] +fn pattern_preview_repeats_the_document_on_gpu_and_cpu_canvases() { + let Some(mut h) = harness() else { return }; + let app = h.state_mut(); + app.run("file.new", json!({"width": 200, "height": 100, "mode": "rgb", "depth": 8})).expect("new"); + app.run("edit.fill", json!({"color": "#2050d0"})).expect("fill"); + // A red band along the left edge only, so the copy to the right shows it beside the right edge. + app.run("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 100})).expect("select"); + app.run("edit.fill", json!({"color": "#e01010"})).expect("fill band"); + app.run("select.deselect", json!({})).expect("deselect"); + let v = app.ui.views.get_mut(0).expect("view"); + v.zoom = 1.0; + v.center = [100.0, 50.0]; + v.fit_pending = false; + v.fill_pending = false; + let blue = [0x20, 0x50, 0xd0]; + let red = [0xe0, 0x10, 0x10]; + // Points past the document: right of its right edge (the next copy's red band), below it + // (the copy underneath), and up-left (the diagonal copy's blue interior). + let right = [210.0, 50.0]; + let below = [100.0, 150.0]; + let diagonal = [-60.0, -40.0]; + for flip in [false, true] { + let path = if flip { "CPU" } else { "GPU" }; + h.state_mut().ui.view.flip_horizontal = flip; + h.state_mut().ui.view.pattern_preview = false; + let off = sample(&mut h, right); + assert!(!near(off, red) && !near(off, blue), "{path}: pasteboard without Pattern Preview, got {off:?}"); + assert!(near(sample(&mut h, [10.0, 50.0]), red), "{path}: the document's own band"); + toggle(&mut h); + assert!(h.state().ui.view.pattern_preview); + let got = sample(&mut h, right); + assert!(near(got, red), "{path}: copy to the right shows the left edge, got {got:?}"); + let got = sample(&mut h, below); + assert!(near(got, blue), "{path}: copy below, got {got:?}"); + let got = sample(&mut h, diagonal); + assert!(near(got, blue), "{path}: diagonal copy, got {got:?}"); + toggle(&mut h); + assert!(!h.state().ui.view.pattern_preview); + } +}