From 3255cda67ea59eea13504a91858e53707328ab40 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 16:29:30 -0400 Subject: [PATCH] #323: the loupe renders the canvas, plus the visible window at no more than 100 % At 8:1 on a 24 MP photo every drag frame rendered the whole 24 MP frame, and a Retina fit view (about 2800 px) decoded the original of every photo stepped to. A new render plan (region::plan, pure and tested) makes the cost follow the canvas, not the zoom: - the whole-frame render is canvas-sized and never above the preview source level, the preview size limit or the texture side (a drag drafts that); - once the view is zoomed past it (by more than 25 %) the visible window is rendered, cut from a frame that is at most the photo's own pixels, so beyond 100 % the GPU magnifies it instead of a bigger render being made; this also ends the duplicated second render above 100 %; - a window is drafted during a drag and a draft of an older value still draws; - a user who capped the preview size keeps the whole-frame render at fit; - no window where the picture on screen isn't the frame's own aspect (an unsupported raw's embedded JPEG cropped differently, #280). Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/panels/detail.rs | 52 ++++--- crates/ui-egui/src/region.rs | 176 +++++++++++++++++++++- crates/ui-egui/src/state.rs | 9 ++ crates/ui-egui/src/tests_preview_limit.rs | 42 ++++-- 4 files changed, 243 insertions(+), 36 deletions(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 8242848bc..9c494e981 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -298,7 +298,19 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let scale = if interacting { 0.6 } else { 1.0 }; // render at the final size: a click-zoom animation only changes how the result is drawn let native_long = native[0].max(native[1]); - let want = app.ui.settings.loupe_edge(target_rect.width().max(target_rect.height()) * ppp * scale, native_long, texture_side); + let drawn_long = target_rect.width().max(target_rect.height()) * ppp; + // the whole frame at about canvas size; the zoomed part of it is a window render (below) + let plan = crate::region::plan( + &app.ui.settings, + crate::region::ViewSizes { + drawn_long, + canvas_long: canvas.width().max(canvas.height()) * ppp, + native_long, + texture_side, + draft_scale: scale, + }, + ); + let want = plan.main_edge; let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) }; // hover and before renders are stand-ins: the preview size is plenty and keeps them cheap let se = app.ui.settings.stand_in_edge(want, texture_side); @@ -352,39 +364,39 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { { app.renderer.request(Slot::Before, job, 90); } - // zoomed past what the whole-frame render holds: also render the part on screen at the zoom - // scale (not while comparing, hovering a look or showing an overlay: those draw one image; and - // not during a drag, whose draft is what shows until it ends) + // zoomed past what the whole-frame render holds: also render the part on screen, at no more + // than 100 % (the GPU magnifies beyond that). Not while comparing, hovering a look or showing + // an overlay (those draw one image), nor where the shown picture isn't the frame's own (an + // unsupported raw's embedded JPEG with another crop: window coordinates are the frame's). let plain_view = !split && !show_before && !split_view && hover_key.is_none() && !app.ui.soft_proof - && view_overlay(app, &d) == lightcraft_pipeline::Overlay::None; - let drawn_long = target_rect.width().max(target_rect.height()) * ppp; - let settled_edge = app.ui.settings.loupe_edge(drawn_long, native_long, texture_side); - let region_possible = plain_view && crate::region::needed(drawn_long, settled_edge); + && view_overlay(app, &d) == lightcraft_pipeline::Overlay::None + && crate::region::same_aspect(display_aspect, aspect); let look = d.hash64(); let mut region_full = None; - if region_possible { - let (fw, fh) = if aspect >= 1.0 { (drawn_long, drawn_long / aspect) } else { (drawn_long * aspect, drawn_long) }; + if let Some(frame_edge) = plan.window_edge.filter(|_| plain_view) { + let (fw, fh) = if aspect >= 1.0 { (frame_edge as f32, frame_edge as f32 / aspect) } else { (frame_edge as f32 * aspect, frame_edge as f32) }; let (fw, fh) = (fw.round().max(1.0) as usize, fh.round().max(1.0) as usize); region_full = Some((fw, fh)); - // what is on screen, in pixels of the zoomed frame - let to_px = |v: f32| v * ppp; + // what is on screen, in pixels of the window's frame (the drawn frame, scaled) + let to_px = ppp * frame_edge as f32 / drawn_long.max(1.0); let visible = ( - to_px(canvas.left() - target_rect.left()), - to_px(canvas.top() - target_rect.top()), - to_px(canvas.right() - target_rect.left()), - to_px(canvas.bottom() - target_rect.top()), + to_px * (canvas.left() - target_rect.left()), + to_px * (canvas.top() - target_rect.top()), + to_px * (canvas.right() - target_rect.left()), + to_px * (canvas.bottom() - target_rect.top()), ); - if !interacting - && let Some(win) = crate::region::window_for(fw, fh, visible, crate::region::max_span(texture_side)) + if let Some(win) = crate::region::window_for(fw, fh, visible, crate::region::max_span(texture_side)) && let Some(job) = app.session.region_job(id, fw, fh, win, !crop_tool) { + // a drag renders drafts of the window, as it does of the whole frame + let job = if interacting { job.draft() } else { job }; let view = crate::region::RegionView { photo: id, key: job.key, full: (fw, fh), window: win, settings: look }; // windows of other photos, looks and zooms can't be drawn any more - app.region_tiles.retain(|_, v| v.is_current(id, (fw, fh), look)); + app.region_tiles.retain(|_, v| v.is_current(id, (fw, fh), look, interacting)); app.region_tiles.insert(job.key, view); app.region_view = Some(view); app.renderer.request(Slot::Region, job, 99); @@ -461,7 +473,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // the window render, drawn over the whole-frame one where it belongs in the frame if let Some(full) = region_full && let Some(tex) = app.renderer.textures.get(&Slot::Region).filter(|t| t.photo == id) - && let Some(v) = app.region_tiles.get(&tex.key).filter(|v| v.is_current(id, full, look)) + && let Some(v) = app.region_tiles.get(&tex.key).filter(|v| v.is_current(id, full, look, interacting)) { let (fw, fh) = (v.full.0 as f32, v.full.1 as f32); let at = img_rect.min + vec2(v.window.x as f32 / fw * img_rect.width(), v.window.y as f32 / fh * img_rect.height()); diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index f59cb1f9b..96c882e7c 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -30,8 +30,11 @@ pub struct RegionView { impl RegionView { /// Whether this window's pixels still belong in a loupe showing `photo` with `settings` in a /// frame of `full` pixels (they are drawn until the next window arrives, if so). - pub fn is_current(&self, photo: lightcraft_catalog::PhotoId, full: (usize, usize), settings: u64) -> bool { - self.photo == photo && self.full == full && self.settings == settings + /// + /// While a slider is dragged (`drafting`) a window made for an earlier value of it still + /// belongs: drafts follow the drag, and waiting for an exact match would show none. + pub fn is_current(&self, photo: lightcraft_catalog::PhotoId, full: (usize, usize), settings: u64, drafting: bool) -> bool { + self.photo == photo && self.full == full && (drafting || self.settings == settings) } } @@ -49,6 +52,61 @@ pub fn max_span(texture_side: usize) -> usize { (texture_side.min(MAX_SPAN) / SNAP * SNAP).max(SNAP) } +/// A window render is worth it only when the whole-frame render is this much smaller than the +/// zoomed frame: a little softness (a Retina fit view of 2800 px from the 2560 px preview) is not +/// worth decoding the original for. +pub const WINDOW_RATIO: f32 = 1.25; + +/// How big the loupe's view of the photo is, and what the host allows. +#[derive(Clone, Copy, Debug)] +pub struct ViewSizes { + /// Long edge of the photo as drawn (physical px): the zoomed frame. + pub drawn_long: f32, + /// Long edge of the canvas it is drawn in (physical px). + pub canvas_long: f32, + /// The photo's own long edge as shown (after crop and rotation). + pub native_long: usize, + /// The largest texture the GPU takes. + pub texture_side: usize, + /// 1.0, or less while a slider is dragged (a draft of the whole-frame render). + pub draft_scale: f32, +} + +/// What the loupe renders for a view: the whole frame at about canvas size, and, once the view is +/// zoomed past what that holds, the window on screen at no more than 100 %. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LoupePlan { + /// Long edge of the whole-frame render ([`Slot::Main`](crate::render::Slot::Main)): never more + /// than the canvas shows, the user's preview limit, the texture side or the preview source level. + pub main_edge: usize, + /// Long edge of the zoomed frame the window is cut from (never above the photo's own pixels: + /// beyond 100 % the GPU magnifies the window), or `None`: the whole-frame render is enough. + pub window_edge: Option, +} + +/// The loupe's render plan. The cost of a render follows the canvas, not the zoom: the whole-frame +/// render is canvas-sized, and the window holds only the pixels on screen at no more than 100 %. +pub fn plan(settings: &crate::state::AppSettings, v: ViewSizes) -> LoupePlan { + let canvas_long = if v.canvas_long.is_finite() { v.canvas_long.max(8.0) } else { 8.0 }; + let drawn_long = if v.drawn_long.is_finite() { v.drawn_long.max(8.0) } else { canvas_long }; + let scale = if v.draft_scale.is_finite() { v.draft_scale.clamp(0.1, 1.0) } else { 1.0 }; + let main_edge = settings + .loupe_edge(drawn_long.min(canvas_long) * scale, v.native_long, v.texture_side) + .min(lightcraft_engine::SourceLevel::Preview.max_edge()); + let frame_edge = settings.window_frame_edge(drawn_long, v.native_long); + // a user who capped the preview size has chosen speed over sharpness at fit + let fit = drawn_long <= canvas_long * 1.05; + let window_edge = (frame_edge as f32 > main_edge as f32 / scale * WINDOW_RATIO && !(fit && settings.preview_limit != 0)).then_some(frame_edge); + LoupePlan { main_edge, window_edge } +} + +/// Whether the picture on screen has the frame's aspect (to the one pixel a texture's whole-pixel +/// size is off by): a window is a part of the frame, so it can only be placed over a picture that +/// is the frame (an unsupported raw's embedded JPEG may be cropped differently). +pub fn same_aspect(shown: f32, frame: f32) -> bool { + shown.is_finite() && frame.is_finite() && frame > 0.0 && (shown / frame - 1.0).abs() <= 0.02 +} + /// Whether a view drawn `drawn_long` pixels along its long edge needs a window render on top of /// the whole-frame render, which has only `rendered_long` of them. pub fn needed(drawn_long: f32, rendered_long: usize) -> bool { @@ -87,6 +145,110 @@ pub fn window_for(full_w: usize, full_h: usize, visible: (f32, f32, f32, f32), m #[cfg(test)] mod tests { use super::*; + use crate::state::AppSettings; + + const BIG: usize = 16384; + + fn sizes(drawn: f32, canvas: f32, native: usize) -> ViewSizes { + ViewSizes { drawn_long: drawn, canvas_long: canvas, native_long: native, texture_side: BIG, draft_scale: 1.0 } + } + + fn auto() -> AppSettings { + AppSettings::default() + } + + // Given a fit view on a Retina Mac (about 2800 px), the preview is enough: no original decoded + #[test] + fn a_retina_fit_view_needs_no_window() { + let p = plan(&auto(), sizes(2800.0, 2800.0, 6000)); + assert_eq!(p.main_edge, 2560, "the preview source level"); + assert_eq!(p.window_edge, None); + } + + // Given a 4K display, the fit view is 1.5x the preview: the window makes it sharp + #[test] + fn a_large_fit_view_gets_a_window() { + let p = plan(&auto(), sizes(3840.0, 3840.0, 6000)); + assert_eq!((p.main_edge, p.window_edge), (2560, Some(3840))); + } + + // Given 1:1 on a 24 MP photo in a 2800 px canvas, the whole frame stays canvas-sized and the + // window is cut from the photo's own pixels + #[test] + fn one_to_one_renders_the_canvas_and_a_native_window() { + let p = plan(&auto(), sizes(6000.0, 2800.0, 6000)); + assert_eq!((p.main_edge, p.window_edge), (2560, Some(6000))); + } + + // Given 800 %, the window is still at 100 % (the GPU magnifies it): the cost does not grow + #[test] + fn beyond_one_to_one_the_window_stays_at_native_resolution() { + let p = plan(&auto(), sizes(48000.0, 2800.0, 6000)); + assert_eq!((p.main_edge, p.window_edge), (2560, Some(6000))); + let q = plan(&auto(), sizes(480.0 * 100.0, 2800.0, 6000)); + assert_eq!(q.window_edge, Some(6000)); + } + + // Given a small photo zoomed in, the whole-frame render already holds all its pixels + #[test] + fn a_small_photo_needs_no_window() { + let p = plan(&auto(), sizes(8000.0, 1400.0, 1000)); + assert_eq!((p.main_edge, p.window_edge), (1000, None)); + } + + // Given a slider drag at 400 % (drafts at 0.6 scale), the window plan does not change: it is + // the whole-frame draft that gets cheaper + #[test] + fn a_drag_shrinks_the_whole_frame_draft_not_the_window() { + let still = plan(&auto(), sizes(24000.0, 2800.0, 6000)); + let drag = plan(&auto(), ViewSizes { draft_scale: 0.6, ..sizes(24000.0, 2800.0, 6000) }); + assert!(drag.main_edge < still.main_edge, "{drag:?} vs {still:?}"); + assert_eq!(drag.window_edge, still.window_edge); + } + + // Given the user capped the preview size, the whole-frame render obeys it, and a fit view + // stays on it (no original decoded); a zoomed view still gets its sharp window + #[test] + fn a_preview_limit_caps_the_whole_frame_render_and_spares_the_fit_view() { + let s = AppSettings { preview_limit: 1600, ..Default::default() }; + let fit = plan(&s, sizes(2800.0, 2800.0, 6000)); + assert_eq!((fit.main_edge, fit.window_edge), (1600, None)); + let zoomed = plan(&s, sizes(6000.0, 2800.0, 6000)); + assert_eq!((zoomed.main_edge, zoomed.window_edge), (1600, Some(6000))); + } + + // Given a GPU with 2048 px textures, the whole-frame render obeys it, the window frame need not + // (a window is cut to the texture side separately) + #[test] + fn the_texture_side_caps_the_whole_frame_render_only() { + let p = plan(&auto(), ViewSizes { texture_side: 2048, ..sizes(6000.0, 2800.0, 6000) }); + assert_eq!((p.main_edge, p.window_edge), (2048, Some(6000))); + } + + // Given a picture that is not the frame (an embedded JPEG cropped differently), no window + #[test] + fn a_window_needs_a_picture_with_the_frames_aspect() { + assert!(same_aspect(1.5, 1.5) && same_aspect(1.51, 1.5)); + assert!(!same_aspect(1.5, 1.0) && !same_aspect(f32::NAN, 1.0) && !same_aspect(1.0, 0.0)); + } + + // Given a drag at fit on a Retina Mac, the window does not switch on (it would flicker with the drag) + #[test] + fn a_drag_at_fit_does_not_switch_the_window_on() { + let p = plan(&auto(), ViewSizes { draft_scale: 0.6, ..sizes(2800.0, 2800.0, 6000) }); + assert_eq!(p.window_edge, None); + } + + // Hostile numbers give a plan, never a panic + #[test] + fn hostile_sizes_give_a_usable_plan() { + for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -3.0, 0.0] { + let p = plan(&auto(), ViewSizes { drawn_long: v, canvas_long: v, draft_scale: v, ..sizes(1.0, 1.0, 6000) }); + assert!((8..=2560).contains(&p.main_edge), "{v}: {p:?}"); + } + let p = plan(&auto(), sizes(1000.0, 1000.0, 0)); + assert!(p.main_edge >= 8); + } // Given a view drawn at 6000 px and a render of 6000, no window is needed; at 6000 vs 2560 it is #[test] @@ -147,10 +309,12 @@ mod tests { fn a_tile_is_drawn_only_while_it_shows_what_the_loupe_shows() { use lightcraft_catalog::PhotoId; let v = RegionView { photo: PhotoId(1), key: 7, full: (6000, 4000), window: PixelWindow { x: 0, y: 0, w: 256, h: 256 }, settings: 11 }; - assert!(v.is_current(PhotoId(1), (6000, 4000), 11)); - assert!(!v.is_current(PhotoId(2), (6000, 4000), 11), "another photo"); - assert!(!v.is_current(PhotoId(1), (6000, 4001), 11), "another zoom"); - assert!(!v.is_current(PhotoId(1), (6000, 4000), 12), "another look (an edit, an undo)"); + assert!(v.is_current(PhotoId(1), (6000, 4000), 11, false)); + assert!(!v.is_current(PhotoId(2), (6000, 4000), 11, false), "another photo"); + assert!(!v.is_current(PhotoId(1), (6000, 4001), 11, false), "another zoom"); + assert!(!v.is_current(PhotoId(1), (6000, 4000), 12, false), "another look (an edit, an undo)"); + assert!(v.is_current(PhotoId(1), (6000, 4000), 12, true), "a drag's drafts follow the value"); + assert!(!v.is_current(PhotoId(2), (6000, 4000), 12, true), "…of this photo only"); } // Given a GPU that allows 2048 px textures, no window is wider than that diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index ca4794609..c7ff1e86b 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -218,6 +218,15 @@ impl AppSettings { loupe_edge.min(lightcraft_engine::SourceLevel::Preview.max_edge()).min(texture_side.max(MIN_TEXTURE_SIDE)) } + /// Long edge of the zoomed frame a window render is cut from, for a photo drawn `drawn_long` + /// px along its long edge: as drawn, but never above the photo's own pixels (beyond 100 % the + /// GPU magnifies the window) or the ceiling. The preview size limit is not applied: it is about + /// the whole-frame render (see [`crate::region::plan`]). + pub fn window_frame_edge(&self, drawn_long: f32, native_long_edge: usize) -> usize { + let wanted = if drawn_long.is_nan() { 8.0 } else { drawn_long.clamp(8.0, LOUPE_EDGE_CEILING as f32) } as usize; + wanted.min(native_long_edge.max(8)).min(LOUPE_EDGE_CEILING as usize) + } + /// The edge Build Standard-Sized Previews uses. pub fn standard_preview_edge(&self) -> u32 { if self.preview_limit == 0 { STANDARD_PREVIEW_EDGE } else { self.preview_limit } diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index 1b5cc9d58..c02454946 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -169,15 +169,20 @@ mod in_the_loupe { t.size[0].max(t.size[1]) } - // Given a photo larger than the old 2560 px cap, when I zoom to 1:1, then the loupe holds its pixels + // Given a photo larger than the canvas, when I zoom to 1:1, then the whole frame stays about + // canvas-sized (a drag redoes that, not 24 MP) and the pixels on screen come from a window + // cut from the photo's own pixels #[test] - fn one_to_one_is_rendered_at_the_photos_own_size() { + fn one_to_one_is_a_canvas_sized_frame_plus_a_native_window() { let (mut h, native) = detail(); h.request("engine.execute", json!({"command": "view.zoom100"}), T); h.settle(SETTLE); - let want = native.min(crate::state::LOUPE_EDGE_CEILING as usize); - let got = rendered_long_edge(&h); - assert!(got.abs_diff(want) <= 2, "native {native}: rendered {got}, wanted {want}"); + let main = rendered_long_edge(&h); + assert!(main <= 2560 && main < native, "native {native}: the whole-frame render is {main}"); + let region = h.app.region_view.expect("a window render"); + assert_eq!(region.full.0.max(region.full.1), native, "the window is cut from the photo's own pixels"); + let (w, hh) = region_tile(&h).expect("its texture"); + assert!(w >= 1300 && hh >= 700, "it covers the canvas: {w}×{hh}"); } // Given the user capped the preview at 1600 px, then 1:1 is rendered no larger than that @@ -195,13 +200,12 @@ mod in_the_loupe { h.app.renderer.textures.get(&Slot::Region).map(|t| (t.size[0], t.size[1])) } - // Given a photo that fits the whole-frame render at 1:1, then no second render is made + // Given a fit view, the whole-frame render is all there is: no second render, no original decoded #[test] - fn no_window_render_while_the_whole_frame_render_is_sharp_enough() { - let (mut h, _) = detail(); - h.request("engine.execute", json!({"command": "view.zoom100"}), T); - h.settle(SETTLE); + fn a_fit_view_makes_no_window_render() { + let (h, _) = detail(); assert_eq!(region_tile(&h), None); + assert_eq!(h.app.region_view, None); } // Given the user capped the preview at 1600 px, when I zoom to 1:1, then the visible part is @@ -343,4 +347,22 @@ mod in_the_loupe { assert!(main.size[0].max(main.size[1]) >= 700, "still a real render, not a thumbnail: {:?}", main.size); } } + + // Given 400 % on a big photo, when a slider is dragged, then the work per frame follows the + // canvas, not the zoom: a draft of the whole frame at canvas scale and a draft of the window + #[test] + fn a_slider_drag_when_zoomed_in_renders_canvas_sized_drafts() { + let (mut h, native) = detail(); + h.app.ui.zoom = crate::state::Zoom::Percent(400.0); + h.settle(SETTLE); + h.app.session.begin_interaction("Exposure").unwrap(); + h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), T); + h.settle(SETTLE); + let main = rendered_long_edge(&h); + assert!(main <= 1000 && main * 4 < native, "the whole-frame draft is {main} px for a {native} px photo"); + let region = h.app.region_view.expect("the window is drafted too"); + assert_eq!(region.full.0.max(region.full.1), native, "at 100 %, magnified by the GPU"); + let (w, hh) = region_tile(&h).expect("a window draft"); + assert!(w * hh <= 3_000_000, "{w}×{hh}: the window holds what is on screen, not the frame"); + } }