#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 <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 b7f632f5c0
commit 3255cda67e
4 changed files with 243 additions and 36 deletions
+32 -20
View File
@@ -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());
+170 -6
View File
@@ -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<usize>,
}
/// 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
+9
View File
@@ -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 }
+32 -10
View File
@@ -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");
}
}