From dc5bff6947a0138c3350183edef5bab28646dd8a Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 13:39:20 -0400 Subject: [PATCH] #323: 100 % zoom is the photo's own pixels as shown (after crop and rotation) fit_rect measured Percent zoom against the uncropped, unrotated photo, so a cropped photo at 1:1 showed stretched and a rotated one used the wrong axis. The loupe, its neighbour prefetch and the compare view now use the frame's cropped-and-rotated size, which also stops a small crop being rendered above its own pixels. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/compare.rs | 2 +- crates/ui-egui/src/panels/detail.rs | 16 +++++-- crates/ui-egui/src/tests_zoom.rs | 71 ++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/src/tests_zoom.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3669610ca..8bc930fb3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -58,6 +58,8 @@ mod tests_scroll; mod tests_switch_library; #[cfg(test)] mod tests_unsaved; +#[cfg(test)] +mod tests_zoom; use std::sync::mpsc::{Receiver, Sender}; diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs index fd0d33059..8e252e906 100644 --- a/crates/ui-egui/src/panels/compare.rs +++ b/crates/ui-egui/src/panels/compare.rs @@ -159,7 +159,7 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo photo.embedded_lens.as_ref(), ); let aspect = frame.aspect() as f32; - let native = [photo.width.max(1) as usize, photo.height.max(1) as usize]; + let native = super::detail::output_px(&frame); let mut img = super::detail::fit_rect(img_area, aspect, zoom, native, ppp, app.ui.pan); if matches!(app.ui.view, ViewMode::Compare | ViewMode::Reference) { if super::detail::navigate_gesture(app, ui, &resp, img_area, img, native) { diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index d3db9d403..8a9efc950 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -115,7 +115,7 @@ pub(crate) fn fit_rect(area: Rect, aspect: f32, zoom: Zoom, img_px: [usize; 2], } } Zoom::Percent(p) => { - // full-resolution pixels at p% (the photo's native width) + // the photo's own pixels at p% (its width as shown: cropped and rotated) let w = img_px[0] as f32 * p / 100.0 / ppp; (w, w / aspect) } @@ -187,6 +187,14 @@ pub(crate) fn navigate_gesture(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: true } +/// The photo's own pixels as it is shown: its size after the crop and the user's rotation (what +/// 100 % zoom and the render size limit are measured against), at least 1 × 1. +pub(crate) fn output_px(frame: &Frame) -> [usize; 2] { + let (w, h) = frame.native_size(); + let px = |v: f64| if v.is_finite() { v.round().clamp(1.0, 1e9) as usize } else { 1 }; + [px(w), px(h)] +} + /// Ease the loupe rect toward `target` while a click-zoom animation runs; otherwise follow it exactly. fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect { let t = if *anim { 0.22 } else { 0.0 }; @@ -230,7 +238,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } else { 24.0 }); - let native = [photo.width.max(1) as usize, photo.height.max(1) as usize]; + let native = output_px(&frame); // two views (before, after): side by side or stacked let split = matches!(app.ui.before_after, BeforeAfter::SideBySide | BeforeAfter::TopBottom); let split_view = matches!(app.ui.before_after, BeforeAfter::Split | BeforeAfter::SplitTopBottom); @@ -315,8 +323,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let Some(np) = app.session.catalog.photo(nid).cloned() else { continue }; let nf = Frame::with_lens(np.width.max(1) as usize, np.height.max(1) as usize, &np.develop, !crop_tool, np.embedded_lens.as_ref()); let na = nf.aspect() as f32; - let nr = fit_rect(main_area, na, app.ui.zoom, [np.width.max(1) as usize, np.height.max(1) as usize], ppp, app.ui.pan); - let nw = app.ui.settings.prefetch_edge(nr.width().max(nr.height()) * ppp, np.width.max(np.height).max(1) as usize); + let nr = fit_rect(main_area, na, app.ui.zoom, output_px(&nf), ppp, app.ui.pan); + let nw = app.ui.settings.prefetch_edge(nr.width().max(nr.height()) * ppp, output_px(&nf).into_iter().max().unwrap_or(1)); let (w, h) = if na >= 1.0 { (nw, (nw as f32 / na) as usize) } else { ((nw as f32 * na) as usize, nw) }; if let Some(job) = app.session.loupe_job(nid, w.max(8), h.max(8), !crop_tool) { app.renderer.prefetch(Slot::Prefetch(n as u8), job, PREFETCH_PRIORITY); diff --git a/crates/ui-egui/src/tests_zoom.rs b/crates/ui-egui/src/tests_zoom.rs new file mode 100644 index 000000000..b90506073 --- /dev/null +++ b/crates/ui-egui/src/tests_zoom.rs @@ -0,0 +1,71 @@ +//! 100 % means the photo's own pixels as it is shown: after the crop and the user's rotation. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +/// The demo library in a 1400×900 window at 1 px per point, the first photo open in Detail. +fn detail() -> Headless { + let services = Services { png: None, ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + 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}"); + h.settle(SETTLE); + h +} + +fn edit(h: &mut Headless, f: impl FnOnce(&mut lightcraft_develop::DevelopSettings)) { + let id = h.app.session.active().expect("a photo is open"); + let mut s = (*h.app.session.develop_of(id).expect("settings")).clone(); + f(&mut s); + h.app.session.set_develop(id, s, "Test").expect("edit"); +} + +fn at_100(h: &mut Headless) -> (f32, f32) { + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.settle(SETTLE); + let r = h.app.image_rect.expect("the loupe drew the photo"); + (r.width(), r.height()) +} + +fn size_of(h: &Headless) -> (f32, f32) { + let id = h.app.session.active().expect("a photo is open"); + let p = h.app.session.catalog.photo(id).expect("photo"); + (p.width as f32, p.height as f32) +} + +// Given an uncropped photo, 100 % is its pixel size +#[test] +fn an_uncropped_photo_at_100_is_its_pixel_size() { + let mut h = detail(); + let (nw, nh) = size_of(&h); + let (w, hh) = at_100(&mut h); + assert!((w - nw).abs() < 1.5 && (hh - nh).abs() < 1.5, "{w}×{hh} for {nw}×{nh}"); +} + +// Given a crop to half the width and a third of the height, 100 % is the cropped pixels +#[test] +fn a_cropped_photo_at_100_is_the_crops_pixel_size() { + let mut h = detail(); + let (nw, nh) = size_of(&h); + edit(&mut h, |s| s.crop.geometry.rect = lightcraft_geom::Rect { x0: 0.25, y0: 0.2, x1: 0.75, y1: 0.2 + 1.0 / 3.0 }); + let (w, hh) = at_100(&mut h); + assert!((w - nw / 2.0).abs() < 1.5 && (hh - nh / 3.0).abs() < 1.5, "{w}×{hh} for a crop of {}×{}", nw / 2.0, nh / 3.0); +} + +// Given a photo rotated a quarter turn, 100 % is its pixel size with the axes swapped +#[test] +fn a_rotated_photo_at_100_is_its_pixel_size_swapped() { + let mut h = detail(); + let (nw, nh) = size_of(&h); + edit(&mut h, |s| s.orientation = lightcraft_geom::Orientation::Rotate90); + let (w, hh) = at_100(&mut h); + assert!((w - nh).abs() < 1.5 && (hh - nw).abs() < 1.5, "{w}×{hh} for {nh}×{nw}"); +}