#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 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-08 18:28:45 -04:00
co-authored by Claude Sonnet 5.5
parent f8f091436a
commit dc5bff6947
4 changed files with 86 additions and 5 deletions
+2
View File
@@ -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};
+1 -1
View File
@@ -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) {
+12 -4
View File
@@ -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);
+71
View File
@@ -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}");
}