mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-27 17:51:59 +01:00
Merge pull request #8994 from FinnNk/fix/cpu-canvas-centred-previews
fix(ui): centre size-changing filter previews on the CPU canvas
This commit is contained in:
+207
-29
@@ -229,6 +229,10 @@ pub struct DetailTexture {
|
||||
pub(crate) struct CpuTextures {
|
||||
pub texture: egui::TextureId,
|
||||
pub scale: f32,
|
||||
/// The document area the texture covers: the document's bounds, or for a filter preview that
|
||||
/// changes the canvas's dimensions (Image › Rotation) its result centred on the document, as
|
||||
/// the GPU canvas draws it.
|
||||
pub bounds: DRect,
|
||||
/// The detail texture and the document area it covers (x0, y0, x1, y1 in document pixels).
|
||||
pub detail: Option<(egui::TextureId, [f32; 4])>,
|
||||
}
|
||||
@@ -1686,6 +1690,27 @@ pub fn ensure_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize,
|
||||
ensure_texture_in_view(app, ctx, idx, display, None).map(|t| (t.texture, t.scale))
|
||||
}
|
||||
|
||||
/// The document area a filter preview of `preview` size covers, for a `doc` sized document
|
||||
/// previewed at proxy factor `k`: the document's bounds when it is the document's size (reduced by
|
||||
/// `k`), else its own size (times `k`) centred on the document's centre, as the GPU canvas centres
|
||||
/// a size-changing preview (Image › Rotation).
|
||||
fn preview_bounds(doc: photocraft_geom::Size, preview: photocraft_geom::Size, k: u32) -> DRect {
|
||||
let k = k.max(1);
|
||||
let full = DRect::from_size(doc);
|
||||
if doc.width.div_ceil(k) == preview.width && doc.height.div_ceil(k) == preview.height {
|
||||
return full;
|
||||
}
|
||||
let side = |p: u32, d: u32| {
|
||||
let covered = i64::from(p) * i64::from(k);
|
||||
let x0 = (i64::from(d) - covered).div_euclid(2);
|
||||
let clamp = |v: i64| v.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32;
|
||||
(clamp(x0), clamp(x0 + covered))
|
||||
};
|
||||
let (x0, x1) = side(preview.width, doc.width);
|
||||
let (y0, y1) = side(preview.height, doc.height);
|
||||
DRect::new(x0, y0, x1, y1)
|
||||
}
|
||||
|
||||
/// [`ensure_texture`], and for a canvas showing `view` of a document too large for one
|
||||
/// full-resolution texture, the detail texture of the part in view ([`detail_plan`], #7568).
|
||||
pub(crate) fn ensure_texture_in_view(
|
||||
@@ -1701,6 +1726,23 @@ pub(crate) fn ensure_texture_in_view(
|
||||
(st.revision, st.last_damage.map(|r| if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) }), st.doc.id)
|
||||
};
|
||||
let (mut doc, mut preview_key) = display_doc(app, idx);
|
||||
// A filter dialog's live preview: the GPU canvas draws it from its own texture, but this CPU
|
||||
// canvas (no usable GPU, a flipped or pixel-aspect-corrected view, a GPU fallback) showed the
|
||||
// document unfiltered. Its result is drawn here instead, as the channel views draw it.
|
||||
// A preview reduced for a zoomed-out view (a proxy, factor `k`) covers the whole document at
|
||||
// its own size. One whose dimensions change otherwise (Image › Rotation) covers its own area,
|
||||
// centred on the document's, as the GPU canvas draws it (`preview_bounds`).
|
||||
let committed = app.session.documents().get(idx).map(|st| st.doc.bounds()).unwrap_or(doc.bounds());
|
||||
let mut bounds = committed;
|
||||
let mut reduced = false;
|
||||
if let Some((preview, key)) = filter_channel_preview(app, idx, ctx) {
|
||||
let k = app.filter_preview.as_ref().map_or(1, |p| p.key.k.max(1));
|
||||
bounds = preview_bounds(committed.size(), preview.size, k);
|
||||
(doc, preview_key, reduced) = (preview, key, k > 1);
|
||||
}
|
||||
// Detail textures and partial redraws work in the document's own pixels.
|
||||
let reduced = reduced || bounds != committed;
|
||||
let view = view.filter(|_| !reduced);
|
||||
// A flipped view draws a GPU machine's canvas through here: an adjustment dialog's preview of
|
||||
// a large document (taken by the GPU path) would cost a full-size CPU composite per change.
|
||||
if crate::adjust_preview::shown_key(app) == Some(preview_key)
|
||||
@@ -1711,7 +1753,7 @@ pub(crate) fn ensure_texture_in_view(
|
||||
}
|
||||
let (display, display_key) = canvas_display(app, &doc, output);
|
||||
let seen = app.canvases.get(&cache_key(id, output)).map(|c| (c.tex_revision, c.tex_preview_key));
|
||||
let damage = seen.and_then(|seen| damage_since(app, idx, seen, (revision, preview_key), display_key, last_damage));
|
||||
let damage = seen.and_then(|seen| damage_since(app, idx, seen, (revision, preview_key), display_key, last_damage)).filter(|_| !reduced);
|
||||
// The detail texture can lag the whole-document one (made or moved later): its own damage.
|
||||
let detail_seen = view.and_then(|v| {
|
||||
let c = app.canvases.get(&cache_key(id, output))?;
|
||||
@@ -1774,10 +1816,10 @@ pub(crate) fn ensure_texture_in_view(
|
||||
let texture = cache.texture.as_ref()?.id();
|
||||
let scale = cache.scale;
|
||||
// Without a view (the Navigator's call) the views' detail textures stay as they are.
|
||||
let Some(view) = view else { return Some(CpuTextures { texture, scale, detail: None }) };
|
||||
let Some(view) = view else { return Some(CpuTextures { texture, scale, bounds, detail: None }) };
|
||||
let old = cache.detail.iter().position(|(k, _)| *k == view.key).map(|i| cache.detail.remove(i).1);
|
||||
let Some(plan) = detail_plan([doc.size.width, doc.size.height], texture_side(ctx), view.zoom, view.visible) else {
|
||||
return Some(CpuTextures { texture, scale, detail: None });
|
||||
return Some(CpuTextures { texture, scale, bounds, detail: None });
|
||||
};
|
||||
let t0 = crate::gpu_canvas::now_ms();
|
||||
let name = format!("canvas-detail-{}-{}", id.0, cache_key(id, output).1);
|
||||
@@ -1785,7 +1827,7 @@ pub(crate) fn ensure_texture_in_view(
|
||||
if detail.as_ref().is_some_and(|d| d.rendered) {
|
||||
app.perf.span("canvas detail", crate::gpu_canvas::now_ms() - t0);
|
||||
}
|
||||
let Some(mut detail) = detail else { return Some(CpuTextures { texture, scale, detail: None }) };
|
||||
let Some(mut detail) = detail else { return Some(CpuTextures { texture, scale, bounds, detail: None }) };
|
||||
detail.rendered = false;
|
||||
// Where its texels lie in the document: reduced texel t covers [t·W/w, (t+1)·W/w).
|
||||
let (w, h) = ((doc.size.width / plan.factor).max(1), (doc.size.height / plan.factor).max(1));
|
||||
@@ -1797,7 +1839,7 @@ pub(crate) fn ensure_texture_in_view(
|
||||
if cache.detail.len() > MAX_DETAIL_VIEWS {
|
||||
cache.detail.remove(0);
|
||||
}
|
||||
Some(CpuTextures { texture, scale, detail: Some(shown) })
|
||||
Some(CpuTextures { texture, scale, bounds, detail: Some(shown) })
|
||||
}
|
||||
|
||||
/// Most CPU canvas views of one document on one display that keep a detail texture.
|
||||
@@ -3579,6 +3621,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
let drop_shadow = border == photocraft_engine::prefs::CanvasBorder::DropShadow;
|
||||
// Drop shadow, checkerboard, document image.
|
||||
let img_rect = xf.doc_rect(doc.bounds());
|
||||
// The canvas edge the Line border outlines: a size-changing preview's own (CPU canvas).
|
||||
let mut edge_rect = img_rect;
|
||||
// The document area the image is drawn over: a size-changing preview's own on the CPU
|
||||
// canvas (Image › Rotation). Overlays drawn from the same preview follow it.
|
||||
let mut shown_area = doc.bounds();
|
||||
// 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);
|
||||
@@ -3657,42 +3704,60 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
paint_gpu_view(&painter, rect, params, &pattern);
|
||||
} else {
|
||||
app.navigator_gpu.remove(&doc.id);
|
||||
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 checkerboard = app.session.prefs().transparency_and_gamut.square().map(|square| (checker(app, &ctx), square));
|
||||
// A document too large for one full-resolution texture also gets the part in view at
|
||||
// the screen's resolution, drawn over the reduced whole (#7568).
|
||||
let in_view =
|
||||
InView { visible: visible_doc_rect(&xf), zoom: view.zoom, key: egui::Id::new(("pc-canvas-detail", ctx.viewport_id(), idx, primary)).value() };
|
||||
let textures = ensure_texture_in_view(app, &ctx, idx, output, Some(in_view));
|
||||
let tex = textures.as_ref().map(|t| t.texture);
|
||||
// The area the image covers: a size-changing preview's own (Image › Rotation).
|
||||
let area = textures.as_ref().map_or(doc.bounds(), |t| t.bounds);
|
||||
shown_area = area;
|
||||
if area != doc.bounds() {
|
||||
edge_rect = xf.doc_rect(area);
|
||||
}
|
||||
if !crate::theme::Tokens::get(&ctx).bevel && drop_shadow {
|
||||
let shadow_rect = edge_rect;
|
||||
painter.add(egui::Shadow { offset: [0, 8], blur: 28, spread: 0, color: Color32::from_black_alpha(150) }.as_shape(shadow_rect, 0));
|
||||
}
|
||||
let rotated = view.rotation.abs() > 0.05;
|
||||
let checkerboard = app.session.prefs().transparency_and_gamut.square().map(|square| (checker(app, &ctx), square));
|
||||
// Repeated-quad coverage is not proven identical on all Pattern Preview adapters.
|
||||
// Retain its legacy checker submissions, including the central document's.
|
||||
let opaque = !app.ui.view.pattern_preview && app.canvases.get(&cache_key(doc.id, output)).is_some_and(|c| c.texture_opaque) && tex.is_some();
|
||||
paint_cpu_document(&painter, &xf, doc.bounds(), rotated, checkerboard, tex, opaque);
|
||||
paint_cpu_document(&painter, &xf, area, rotated, checkerboard, tex, opaque);
|
||||
if let Some((detail, area)) = textures.and_then(|t| t.detail) {
|
||||
paint_cpu_detail(&painter, &xf, doc.bounds(), rotated, checkerboard, detail, area);
|
||||
}
|
||||
for &c in &pattern {
|
||||
paint_cpu_document(&painter, &pattern_xf(&xf, c, size), doc.bounds(), rotated, checkerboard, tex, opaque);
|
||||
// Pattern Preview tiles what is shown: a size-changing preview's area, from its origin.
|
||||
let (tile, copies) = if area == doc.bounds() || !app.ui.view.pattern_preview {
|
||||
(size, pattern.clone())
|
||||
} else {
|
||||
let tile = [area.width(), area.height()];
|
||||
let v = visible_doc_rect(&xf).translate(-area.x0, -area.y0);
|
||||
(tile, crate::pattern_preview::copies([v.x0, v.y0, v.x1, v.y1].map(f64::from), tile))
|
||||
};
|
||||
for &c in &copies {
|
||||
paint_cpu_document(&painter, &pattern_xf(&xf, c, tile), area, rotated, checkerboard, tex, opaque);
|
||||
}
|
||||
}
|
||||
// Channels panel: alpha / Quick Mask overlays and single-channel views (channel_view.rs).
|
||||
if let Some(tex) = crate::channel_view::ensure(app, &ctx, idx) {
|
||||
// Rendered from the live preview, so over the area the preview is shown in.
|
||||
let a = shown_area;
|
||||
if view.rotation.abs() > 0.05 {
|
||||
paint_mapped_image(
|
||||
paint_mapped_image_at(
|
||||
&painter,
|
||||
&xf,
|
||||
tex,
|
||||
Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)),
|
||||
[doc.size.width as f32, doc.size.height as f32],
|
||||
[a.x0 as f32, a.y0 as f32],
|
||||
[a.width() as f32, a.height() as f32],
|
||||
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);
|
||||
painter.image(tex, xf.doc_rect(a), uv, Color32::WHITE);
|
||||
}
|
||||
}
|
||||
crate::color_range_ui::paint_selection_preview(app, &ctx, &painter, doc.id, img_rect, flip);
|
||||
@@ -3841,7 +3906,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
}
|
||||
}
|
||||
|
||||
(img_rect, beyond, layer_edges)
|
||||
(beyond, layer_edges, edge_rect)
|
||||
};
|
||||
let painted = if crop_camera { None } else { Some(paint_canvas(app, ui, &display_view, xf)) };
|
||||
let img_rect = xf.doc_rect(doc.bounds());
|
||||
@@ -4503,7 +4568,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
} else {
|
||||
xf
|
||||
};
|
||||
let (img_rect, beyond, layer_edges) = painted.unwrap_or_else(|| paint_canvas(app, ui, &display_view, xf));
|
||||
let (beyond, layer_edges, edge_rect) = painted.unwrap_or_else(|| paint_canvas(app, ui, &display_view, xf));
|
||||
// A Hand drag (the tool, Space or the middle button) keeps every overlay on screen, as in
|
||||
// Photoshop: the Pen path being drawn, paths, guides, grid, transform box (#6592). Only the
|
||||
// tool cursors below give way to the fist.
|
||||
@@ -4550,7 +4615,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
// The canvas edge isn't outlined while the pixels past it show (Photoshop's crop preview),
|
||||
// nor while the Crop tool hides everything outside its box (Show Cropped Area off).
|
||||
if border == photocraft_engine::prefs::CanvasBorder::Line && !beyond && !crate::crop_shield::hides_outside(app) {
|
||||
painter.rect_stroke(img_rect, 0.0, Stroke::new(1.0, Color32::from_gray(20)), egui::StrokeKind::Outside);
|
||||
painter.rect_stroke(edge_rect, 0.0, Stroke::new(1.0, Color32::from_gray(20)), egui::StrokeKind::Outside);
|
||||
}
|
||||
crate::type_tool::draw_overlay(app, &painter, &xf);
|
||||
crate::transform_tool::draw_overlay(app, &painter, &xf);
|
||||
@@ -4918,10 +4983,10 @@ fn zoom_about(view: &mut View, xf: &ViewXform, p: Pos2, new_zoom: f32, center_on
|
||||
view.center = [before[0] as f32 - u.x, before[1] as f32 - u.y];
|
||||
}
|
||||
|
||||
/// Textured document quad whose corners follow [`ViewXform`] (rotation + flip).
|
||||
fn paint_mapped_image(painter: &egui::Painter, xf: &ViewXform, tex: egui::TextureId, uv: Rect, size: [f32; 2], tint: Color32) {
|
||||
let [w, h] = size;
|
||||
let pts = [xf.to_screen(0.0, 0.0), xf.to_screen(w, 0.0), xf.to_screen(w, h), xf.to_screen(0.0, h)];
|
||||
/// A texture mapped over the `size` document area starting at `origin` (follows a rotated view).
|
||||
fn paint_mapped_image_at(painter: &egui::Painter, xf: &ViewXform, tex: egui::TextureId, uv: Rect, origin: [f32; 2], size: [f32; 2], tint: Color32) {
|
||||
let ([x, y], [w, h]) = (origin, size);
|
||||
let pts = [xf.to_screen(x, y), xf.to_screen(x + w, y), xf.to_screen(x + w, y + h), xf.to_screen(x, y + h)];
|
||||
let uvs = [uv.min, pos2(uv.max.x, uv.min.y), uv.max, pos2(uv.min.x, uv.max.y)];
|
||||
let mut mesh = Mesh::with_texture(tex);
|
||||
for i in 0..4 {
|
||||
@@ -4967,12 +5032,13 @@ fn paint_cpu_document(
|
||||
opaque: bool,
|
||||
) {
|
||||
let size = [bounds.width() as f32, bounds.height() as f32];
|
||||
let origin = [bounds.x0 as f32, bounds.y0 as f32];
|
||||
let img_rect = xf.doc_rect(bounds);
|
||||
match checker {
|
||||
Some((checker_id, square)) if !opaque || tex.is_none() => {
|
||||
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);
|
||||
paint_mapped_image_at(painter, xf, checker_id, Rect::from_min_max(Pos2::ZERO, pos2(tiles.x, tiles.y)), origin, 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);
|
||||
@@ -4983,7 +5049,7 @@ fn paint_cpu_document(
|
||||
Some(_) => {}
|
||||
None => {
|
||||
if rotated {
|
||||
paint_mapped_quad(painter, xf, size, Color32::WHITE);
|
||||
paint_mapped_quad(painter, xf, origin, size, Color32::WHITE);
|
||||
} else {
|
||||
painter.rect_filled(img_rect, 0.0, Color32::WHITE);
|
||||
}
|
||||
@@ -4991,7 +5057,7 @@ fn paint_cpu_document(
|
||||
}
|
||||
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);
|
||||
paint_mapped_image_at(painter, xf, tex, Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), origin, 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);
|
||||
@@ -5041,9 +5107,9 @@ fn paint_cpu_detail(
|
||||
quad(tex, [pos2(0.0, 0.0), pos2(1.0, 0.0), pos2(1.0, 1.0), pos2(0.0, 1.0)]);
|
||||
}
|
||||
|
||||
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)];
|
||||
fn paint_mapped_quad(painter: &egui::Painter, xf: &ViewXform, origin: [f32; 2], size: [f32; 2], color: Color32) {
|
||||
let ([x, y], [w, h]) = (origin, size);
|
||||
let pts = vec![xf.to_screen(x, y), xf.to_screen(x + w, y), xf.to_screen(x + w, y + h), xf.to_screen(x, y + h)];
|
||||
painter.add(egui::Shape::convex_polygon(pts, color, Stroke::NONE));
|
||||
}
|
||||
|
||||
@@ -6902,6 +6968,118 @@ mod tests {
|
||||
/// #7490: the inline (web) preview path composites and uploads each changed preview once, as
|
||||
/// the background worker does. It used to flatten and upload it twice and record it twice,
|
||||
/// so the frame's refresh time counted the preview twice.
|
||||
/// The CPU canvas (no usable GPU; a flipped or pixel-aspect-corrected view, or a GPU fallback,
|
||||
/// on a GPU machine) shows a filter dialog's live preview. It showed the document unfiltered.
|
||||
#[test]
|
||||
fn the_cpu_canvas_shows_a_filter_dialogs_preview() {
|
||||
let pixels = |ctx: &egui::Context, id: egui::TextureId| {
|
||||
let mut delta = ctx.tex_manager().write().take_delta();
|
||||
let egui::ImageData::Color(image) = &delta.set.get(&id).and_then(|d| d.last()).expect("the canvas texture was written").image;
|
||||
let (size, px) = (image.size, image.pixels.clone());
|
||||
delta.clear();
|
||||
(size, px)
|
||||
};
|
||||
for (size, zoom) in [([64u32, 48u32], 1.0f32), ([2400, 2200], 0.25)] {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.background_jobs = false;
|
||||
app.run("file.new", json!({"width": size[0], "height": size[1], "background": "white"})).unwrap();
|
||||
app.run("edit.fill", json!({"color": "#3399cc"})).unwrap();
|
||||
app.ui.views.get_mut(0).expect("the view").zoom = zoom;
|
||||
let ctx = egui::Context::default();
|
||||
let (tex, _) = ensure_texture(&mut app, &ctx, 0, None).unwrap();
|
||||
let (_, plain) = pixels(&ctx, tex);
|
||||
crate::filter_dialog::open(&mut app, "filter.stylize.solarize").unwrap();
|
||||
let (tex, _) = ensure_texture(&mut app, &ctx, 0, None).unwrap();
|
||||
let (tex_size, shown) = pixels(&ctx, tex);
|
||||
let preview = app.filter_preview.as_ref().and_then(|p| p.result.clone()).expect("the dialog's preview");
|
||||
let (display, _) = canvas_display(&app, &preview, None);
|
||||
let expected = display_image(display.as_deref(), &photocraft_compose::flatten(&preview));
|
||||
assert_eq!(tex_size, expected.size, "{size:?}: the preview's own size (a proxy when zoomed out)");
|
||||
assert_eq!(shown, expected.pixels, "{size:?}: the canvas shows the preview");
|
||||
assert_ne!(shown.first(), plain.first(), "{size:?}: not the unfiltered document");
|
||||
}
|
||||
// A preview that changes the canvas's dimensions covers its own area, centred on the
|
||||
// document's (as the GPU canvas centres it), rather than being stretched over it.
|
||||
let size = |w, h| photocraft_geom::Size::new(w, h);
|
||||
assert_eq!(preview_bounds(size(64, 32), size(64, 32), 1), DRect::new(0, 0, 64, 32));
|
||||
assert_eq!(preview_bounds(size(2401, 2200), size(1201, 1100), 2), DRect::new(0, 0, 2401, 2200), "a proxy");
|
||||
assert_eq!(preview_bounds(size(64, 32), size(32, 64), 1), DRect::new(16, -16, 48, 48), "rotated");
|
||||
assert_eq!(preview_bounds(size(64, 32), size(80, 40), 1), DRect::new(-8, -4, 72, 36), "canvas grown");
|
||||
assert_eq!(preview_bounds(size(4000, 2000), size(1000, 2000), 2), DRect::new(1000, -1000, 3000, 3000), "a rotated proxy");
|
||||
|
||||
// Image › Image Rotation › Arbitrary 90° on a 64×32 document: its 32×64 preview is drawn over
|
||||
// the centred 32×64 area.
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.background_jobs = false;
|
||||
app.run("file.new", json!({"width": 64, "height": 32, "background": "white"})).unwrap();
|
||||
let id = crate::filter_dialog::open(&mut app, "image.rotation.arbitrary").unwrap();
|
||||
let d = app.ui.dialogs.iter_mut().find(|d| d.id == id).unwrap();
|
||||
d.fields.insert("angle".into(), json!(90.0));
|
||||
d.fields.insert("__preview".into(), json!(true));
|
||||
let ctx = egui::Context::default();
|
||||
let t = ensure_texture_in_view(&mut app, &ctx, 0, None, None).unwrap();
|
||||
let (size, _) = pixels(&ctx, t.texture);
|
||||
assert_eq!(size, [32, 64], "the rotated preview's texture");
|
||||
assert_eq!(t.bounds, DRect::new(16, -16, 48, 48), "centred on the document");
|
||||
let centre = |r: DRect| (r.x0 + r.x1, r.y0 + r.y1);
|
||||
assert_eq!(centre(t.bounds), centre(app.session.active().unwrap().doc.bounds()), "the same centre, so the view's pan holds");
|
||||
// Preview off, then Cancel: the document again, unchanged.
|
||||
let revision = app.session.active().unwrap().revision;
|
||||
app.ui.dialogs.iter_mut().find(|d| d.id == id).unwrap().fields.insert("__preview".into(), json!(false));
|
||||
let t = ensure_texture_in_view(&mut app, &ctx, 0, None, None).unwrap();
|
||||
assert_eq!(t.bounds, DRect::new(0, 0, 64, 32), "Preview off");
|
||||
crate::dialogs::cancel(&mut app, id).unwrap();
|
||||
let t = ensure_texture_in_view(&mut app, &ctx, 0, None, None).unwrap();
|
||||
assert_eq!(t.bounds, DRect::new(0, 0, 64, 32), "after Cancel");
|
||||
assert_eq!(app.session.active().unwrap().revision, revision, "the document untouched");
|
||||
assert_eq!(app.session.active().unwrap().doc.size, photocraft_geom::Size::new(64, 32));
|
||||
}
|
||||
|
||||
/// A channel overlay drawn from a size-changing preview covers the preview's own area, as the
|
||||
/// image does, not the document's: a 90° rotation of a 64×32 document shows a 32×64 overlay.
|
||||
#[test]
|
||||
fn a_channel_overlay_follows_a_size_changing_preview() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.background_jobs = false;
|
||||
app.run("file.new", json!({"width": 64, "height": 32, "background": "white"})).unwrap();
|
||||
app.run("channel.new", json!({"name": "Alpha", "fill": "black"})).unwrap();
|
||||
let id = crate::filter_dialog::open(&mut app, "image.rotation.arbitrary").unwrap();
|
||||
let d = app.ui.dialogs.iter_mut().find(|d| d.id == id).unwrap();
|
||||
d.fields.insert("angle".into(), json!(90.0));
|
||||
d.fields.insert("__preview".into(), json!(true));
|
||||
let ctx = egui::Context::default();
|
||||
for rotation in [0.0f32, 30.0] {
|
||||
let view = View { zoom: 4.0, center: [32.0, 16.0], rotation, ..app.ui.views[0].clone() };
|
||||
let mut output = ctx.run_ui(Default::default(), |ui| {
|
||||
canvas_view(&mut app, ui, 0, Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)), view.clone(), true);
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
let overlay = crate::channel_view::ensure(&mut app, &ctx, 0).expect("the channel overlay");
|
||||
let mesh = output
|
||||
.shapes
|
||||
.iter()
|
||||
.find_map(|s| match &s.shape {
|
||||
egui::Shape::Mesh(m) if m.texture_id == overlay => Some(m.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the overlay is painted");
|
||||
// The quad's sides, in screen points: the two shortest distances from a corner (the
|
||||
// third is the diagonal; a rotated view turns the quad, not its side lengths).
|
||||
let v: Vec<Pos2> = mesh.vertices.iter().map(|v| v.pos).collect();
|
||||
let mut d: Vec<f32> = v[1..4].iter().map(|p| v[0].distance(*p)).collect();
|
||||
d.sort_by(f32::total_cmp);
|
||||
let (short, long) = (d[0], d[1]);
|
||||
// Which side is the width: the one along the view's x axis turned by the rotation.
|
||||
let along = |p: Pos2| {
|
||||
let r = rotation.to_radians();
|
||||
((p - v[0]).x * r.cos() + (p - v[0]).y * r.sin()).abs()
|
||||
};
|
||||
let width = v[1..4].iter().map(|p| along(*p)).fold(0.0f32, f32::max);
|
||||
assert!((long / short - 2.0).abs() < 0.01, "rotation {rotation}: sides {short}×{long}, the preview's 1:2");
|
||||
assert!((width - short).abs() < 0.5, "rotation {rotation}: the short side ({short}) runs across the view (width {width})");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_filter_preview_is_composited_and_recorded_once() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
|
||||
Reference in New Issue
Block a user