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.
This commit is contained in:
Marco Arnulfo
2026-10-13 00:14:43 +02:00
parent 701f78ea2e
commit f0d1ea9c4e
3 changed files with 89 additions and 12 deletions
+9 -8
View File
@@ -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);
+17 -4
View File
@@ -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);
}
+63
View File
@@ -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::<f32>();
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:?}");
}
}
}
}