From f0d1ea9c4e79c27b740ecb7345e0ab79830a3d46 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 00:14:43 +0200 Subject: [PATCH] Before/After wipe: the line stays in the middle of the view when zoomed in (fixes #1351) A wipe drew its line through the middle of the photo. Zoomed in, that point is usually off screen, so the whole view fell on one side of it and showed only the Before (the original) or only the After. The line now goes through the middle of the part of the photo on screen: the same place at Fit, and in view at any zoom. --- crates/ui-egui/src/panels/detail.rs | 17 ++++--- crates/ui-egui/src/region.rs | 21 ++++++-- crates/ui-egui/src/tests_before_after.rs | 63 ++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 234476167..93a848942 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -642,19 +642,22 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } app.loupe_shown = Some((id, shown)); // a wipe shows the Before whole-frame render on its side of the line… + let line = crate::region::wipe_line(img_rect, canvas); if app.ui.before_after == BeforeAfter::Split && let Some(tex) = app.renderer.textures.get(&Slot::Before).filter(|t| t.photo == id) { let before_rect = fit_texture_rect(img_rect, tex.size); - let left = Rect::from_min_max(before_rect.min, pos2(before_rect.center().x, before_rect.bottom())); - p.image(tex.tex.id(), left, Rect::from_min_max(pos2(0.0, 0.0), pos2(0.5, 1.0)), Color32::WHITE); + let u = ((line.x - before_rect.left()) / before_rect.width().max(1.0)).clamp(0.0, 1.0); + let left = Rect::from_min_max(before_rect.min, pos2(before_rect.left() + u * before_rect.width(), before_rect.bottom())); + p.image(tex.tex.id(), left, Rect::from_min_max(pos2(0.0, 0.0), pos2(u, 1.0)), Color32::WHITE); } if app.ui.before_after == BeforeAfter::SplitTopBottom && let Some(tex) = app.renderer.textures.get(&Slot::Before).filter(|t| t.photo == id) { let before_rect = fit_texture_rect(img_rect, tex.size); - let top = Rect::from_min_max(before_rect.min, pos2(before_rect.right(), before_rect.center().y)); - p.image(tex.tex.id(), top, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 0.5)), Color32::WHITE); + let v = ((line.y - before_rect.top()) / before_rect.height().max(1.0)).clamp(0.0, 1.0); + let top = Rect::from_min_max(before_rect.min, pos2(before_rect.right(), before_rect.top() + v * before_rect.height())); + p.image(tex.tex.id(), top, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, v)), Color32::WHITE); } // …and the window renders go over the whole-frame ones, all of them, where they belong in the // frame (clipped to their pane, or their side of the line) @@ -666,12 +669,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } // the line of a wipe over both if app.ui.before_after == BeforeAfter::Split { - let mid = img_rect.center().x; - p.line_segment([pos2(mid, img_rect.top()), pos2(mid, img_rect.bottom())], Stroke::new(1.5, Color32::WHITE)); + p.line_segment([pos2(line.x, img_rect.top()), pos2(line.x, img_rect.bottom())], Stroke::new(1.5, Color32::WHITE)); } if app.ui.before_after == BeforeAfter::SplitTopBottom { - let mid = img_rect.center().y; - p.line_segment([pos2(img_rect.left(), mid), pos2(img_rect.right(), mid)], Stroke::new(1.5, Color32::WHITE)); + p.line_segment([pos2(img_rect.left(), line.y), pos2(img_rect.right(), line.y)], Stroke::new(1.5, Color32::WHITE)); } if app.ui.show_clipping && !show_before && !fullscreen { clipping_overlay(app, &p, img_rect); diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index 8fe29e135..82b33b8f6 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -157,17 +157,26 @@ impl SizeHold { } } +/// Where the line of a wipe crosses: the middle of the part of the photo that is on screen. At +/// Fit that is the photo's middle; zoomed in, the photo's own middle is mostly off screen, and a +/// line there left the whole view to one side (#1351). +pub fn wipe_line(img: egui::Rect, canvas: egui::Rect) -> egui::Pos2 { + let shown = img.intersect(canvas); + if shown.is_positive() { shown.center() } else { img.center() } +} + /// Where a window of one side of a Before/After view may be drawn: its own pane in side by side /// and stacked views (a window carries margin beyond what its pane shows, which would otherwise /// spill into the neighbour), its side of the line in a wipe, else the canvas. pub fn window_clip(mode: crate::state::BeforeAfter, before_side: bool, pane: egui::Rect, canvas: egui::Rect, img: egui::Rect) -> egui::Rect { use crate::state::BeforeAfter; let (min, max) = (canvas.min, canvas.max); + let line = wipe_line(img, canvas); let clip = match (mode, before_side) { - (BeforeAfter::Split, true) => egui::Rect::from_min_max(min, egui::pos2(img.center().x, max.y)), - (BeforeAfter::SplitTopBottom, true) => egui::Rect::from_min_max(min, egui::pos2(max.x, img.center().y)), - (BeforeAfter::Split, false) => egui::Rect::from_min_max(egui::pos2(img.center().x, min.y), max), - (BeforeAfter::SplitTopBottom, false) => egui::Rect::from_min_max(egui::pos2(min.x, img.center().y), max), + (BeforeAfter::Split, true) => egui::Rect::from_min_max(min, egui::pos2(line.x, max.y)), + (BeforeAfter::SplitTopBottom, true) => egui::Rect::from_min_max(min, egui::pos2(max.x, line.y)), + (BeforeAfter::Split, false) => egui::Rect::from_min_max(egui::pos2(line.x, min.y), max), + (BeforeAfter::SplitTopBottom, false) => egui::Rect::from_min_max(egui::pos2(min.x, line.y), max), (BeforeAfter::SideBySide | BeforeAfter::TopBottom, _) => pane, _ => canvas, }; @@ -423,6 +432,10 @@ mod tests { assert_eq!((before.max.x, after.min.x), (500.0, 500.0), "the line is the image's middle"); assert_eq!(window_clip(SplitTopBottom, true, canvas, canvas, img).max.y, 300.0); assert_eq!(window_clip(SplitTopBottom, false, canvas, canvas, img).min.y, 300.0); + // zoomed in, the photo's middle (x = 1600) is off screen: the line is the view's middle + let zoomed = egui::Rect::from_min_max(egui::pos2(-200.0, -900.0), egui::pos2(3400.0, 1500.0)); + assert_eq!(window_clip(Split, false, canvas, canvas, zoomed).min.x, 500.0); + assert_eq!(window_clip(SplitTopBottom, true, canvas, canvas, zoomed).max.y, 300.0); assert_eq!(window_clip(Off, false, canvas, canvas, img), canvas); assert_eq!(window_clip(Original, true, canvas, canvas, img), canvas); } diff --git a/crates/ui-egui/src/tests_before_after.rs b/crates/ui-egui/src/tests_before_after.rs index 96fab9cbe..ae35a35b2 100644 --- a/crates/ui-egui/src/tests_before_after.rs +++ b/crates/ui-egui/src/tests_before_after.rs @@ -69,3 +69,66 @@ fn wb_selector_picks_the_same_point_in_the_before_view() { } } } + +/// Mean (r, g, b) of the 41 × 41 screen pixels around `at`. +fn mean_around(shot: &egui::ColorImage, at: egui::Pos2) -> [f32; 3] { + let mut sum = [0.0f32; 3]; + let mut n = 0.0; + for y in (at.y as usize - 20)..=(at.y as usize + 20) { + for x in (at.x as usize - 20)..=(at.x as usize + 20) { + let c = shot[(x, y)]; + sum[0] += f32::from(c.r()); + sum[1] += f32::from(c.g()); + sum[2] += f32::from(c.b()); + n += 1.0; + } + } + sum.map(|s| s / n) +} + +/// Zoomed in to compare a detail, every Before / After view still shows the edited photo on its +/// After side. A wipe drew its line through the middle of the photo, which zoomed in is mostly off +/// screen: the whole view was the Before, the original photo (#1351). +#[test] +fn zoomed_in_the_after_side_shows_the_edits() { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail", "right": "none", "filmstrip": false}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 1.5}}), T); + assert_eq!(r["ok"], true, "{r}"); + let shot = |h: &mut Headless, mode: BeforeAfter, zoom: Zoom| { + h.app.ui.before_after = mode; + h.app.ui.zoom = zoom; + // (far enough from the middle of the photo that, zoomed in, it is off screen) + h.app.ui.pan = (0.25, 0.75); + h.settle(SETTLE); + h.paint() + }; + for zoom in [Zoom::Fit, Zoom::Percent(100.0), Zoom::Percent(400.0)] { + let (edited, original) = (shot(&mut h, BeforeAfter::Off, zoom), shot(&mut h, BeforeAfter::Original, zoom)); + let c = h.app.canvas_rect.expect("canvas").center(); + let luma = |m: [f32; 3]| m.iter().sum::(); + for mode in [BeforeAfter::SideBySide, BeforeAfter::TopBottom, BeforeAfter::Split, BeforeAfter::SplitTopBottom] { + let s = shot(&mut h, mode, zoom); + let canvas = h.app.canvas_rect.expect("canvas"); + // the middle of the After pane, or of the After side of the line + let (after, before) = match mode { + BeforeAfter::SideBySide | BeforeAfter::Split => { + (c + egui::vec2(canvas.width() / 4.0, 0.0), c - egui::vec2(canvas.width() / 4.0, 0.0)) + } + _ => (c + egui::vec2(0.0, canvas.height() / 4.0), c - egui::vec2(0.0, canvas.height() / 4.0)), + }; + let (a, b) = (mean_around(&s, after), mean_around(&s, before)); + if matches!(mode, BeforeAfter::Split | BeforeAfter::SplitTopBottom) { + // a wipe shows the loupe's own pixels on each side of the line + let (e, o) = (mean_around(&edited, after), mean_around(&original, before)); + assert!((luma(a) - luma(e)).abs() < 6.0, "{mode:?} {zoom:?}: the After side {a:?} is the edited photo {e:?}"); + assert!((luma(b) - luma(o)).abs() < 6.0, "{mode:?} {zoom:?}: the Before side {b:?} is the original {o:?}"); + } else { + // two views of the same point of the photo: the After one is the brighter + assert!(luma(a) > luma(b) + 30.0, "{mode:?} {zoom:?}: the After {a:?} is the edited photo, brighter than the Before {b:?}"); + } + } + } +}