diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3a9a341316..ab3e6939a7 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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 = mesh.vertices.iter().map(|v| v.pos).collect(); + let mut d: Vec = 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());