mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-29 04:27:30 -06:00
#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:
co-authored by
Claude Sonnet 5.5
parent
f8f091436a
commit
dc5bff6947
@@ -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};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
Reference in New Issue
Block a user