mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-18 05:09:52 +02:00
View > Pattern Preview only toggled a flag that nothing read. With it on, the canvas now draws the document repeated around itself as a seamless tile, like Photoshop, on the GPU canvas and the CPU fallback: - pattern_preview.rs works out which copies cover the visible area (at most 31 per axis, centred on the view). - GPU: each copy is another paint callback over the same document texture, offset by whole document sizes in the shader (ViewParams::offset, the spare view-uniform slots), so nothing is composited again and seams are exact. - CPU: the same texture drawn again with a shifted view mapping. - The drop shadow is off while tiling; selection, guides and other overlays stay on the document itself. Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
parent
bacc825d33
commit
3af4c46a18
|
|
@ -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<egui::TextureId>,
|
||||
) {
|
||||
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)];
|
||||
|
|
|
|||
|
|
@ -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<f32>, // screen_w, screen_h, scale (device px per doc px), pixels_per_point
|
||||
b: vec4<f32>, // doc origin x, y (device px), doc w, h (doc px)
|
||||
c: vec4<f32>, // filter mode, lod, grid alpha, checker square (device px)
|
||||
d: vec4<f32>, // unused x, y, display (0 none, 1 LUT, 2 LUT + gamut), output linear
|
||||
d: vec4<f32>, // pattern preview offset x, y (doc px), display (0 none, 1 LUT, 2 LUT + gamut), output linear
|
||||
e: vec4<f32>, // checker light rgb, gamut warning opacity
|
||||
f: vec4<f32>, // checker dark rgb, 32-bit preview gain (2^exposure; 0 = off)
|
||||
g: vec4<f32>, // gamut warning rgb, 32-bit preview 1 / gamma
|
||||
|
|
@ -1904,17 +1909,18 @@ fn fs_shadow(in: VOut) -> @location(0) vec4<f32> {
|
|||
|
||||
@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<f32>) -> vec3<f32> {
|
||||
// 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<f32>) -> vec3<f32> {
|
||||
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<f32>) -> vec3<f32> {
|
|||
fn fs_tile(in: VOut) -> @location(0) vec4<f32> {
|
||||
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<f32> {
|
|||
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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
109
crates/ui-egui/src/pattern_preview.rs
Normal file
109
crates/ui-egui/src/pattern_preview.rs
Normal file
|
|
@ -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<RangeInclusive<i32>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
93
crates/ui-egui/tests/pattern_preview_canvas.rs
Normal file
93
crates/ui-egui/tests/pattern_preview_canvas.rs
Normal file
|
|
@ -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<Harness> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user