#323: the loupe renders the visible window at the zoom scale

When the whole-frame render is smaller than the zoomed view (a 1:1 or 8:1 view
of a large photo, or a capped Preview size) the loupe also requests a window
render (Slot::Region) of the pixels on screen plus a margin, and draws it over
the whole-frame render at its place of the frame. Stateless, grid-snapped
windows mean small pans reuse the render. Not used in the browser build, while
comparing, hovering a look, soft-proofing or showing an overlay. ui.inspect's
loupe reports the window.

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 2079e8e08d
commit e0539db3f5
6 changed files with 161 additions and 5 deletions
+7 -1
View File
@@ -92,7 +92,13 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value {
"activeMask": app.session.active_mask,
"widgetCount": app.widgets.len(),
"perf": {"frameMs": app.perf.frame_ms, "logicMs": app.perf.logic_ms, "updateMs": app.perf.update_ms, "maxUpdateMs": app.perf.max_update_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersInFlight": app.renderer.in_flight(), "pendingSlots": app.renderer.pending_slots(), "mergeRunning": app.merge.busy(), "lastMerge": app.merge.last_result, "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures(), "variantTextures": app.renderer.variant_textures(), "gpu": (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten(), "gpuReason": lightcraft_engine::gpu::unavailable_reason(), "gpuFallback": lightcraft_engine::gpu::last_fallback()},
"loupe": app.loupe_shown.map(|(p, src)| json!({"photo": p.0, "source": src, "pending": app.renderer.is_pending(crate::render::Slot::Main)})),
"loupe": app.loupe_shown.map(|(p, src)| json!({
"photo": p.0,
"source": src,
"pending": app.renderer.is_pending(crate::render::Slot::Main),
// zoomed past the whole-frame render: the window rendered at the zoom scale (pixels of the zoomed frame)
"region": app.region_view.map(|r| json!({"full": [r.full.0, r.full.1], "window": [r.window.x, r.window.y, r.window.w, r.window.h], "pending": app.renderer.is_pending(crate::render::Slot::Region)})),
})),
"hoverPreview": app.hover_preview.as_ref().map(|h| h.label.clone()),
"status": app.ui.status,
"notices": app.notices,
+6
View File
@@ -198,6 +198,10 @@ pub struct LightcraftApp {
/// What the loupe drew last frame: photo and source ("render", "cached", "embedded", "small",
/// "thumb", "none").
pub loupe_shown: Option<(lightcraft_catalog::PhotoId, &'static str)>,
/// The window render the loupe asked for last, by job key (see [`region`]); kept for the few
/// windows whose textures can be on screen, and for the inspector.
pub region_view: Option<region::RegionView>,
pub(crate) region_tiles: std::collections::HashMap<u64, region::RegionView>,
/// Photo Merge dialog previews and background merges.
pub merge: merge::MergeState,
/// An import in progress (the import review dialog's batches).
@@ -265,6 +269,8 @@ impl LightcraftApp {
widgets: vec![],
gesture: None,
loupe_shown: None,
region_view: None,
region_tiles: Default::default(),
merge: merge::MergeState::default(),
import: None,
scan: None,
+46
View File
@@ -336,6 +336,42 @@ 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)
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 region_wanted = plain_view && app.renderer.can_render_windows() && crate::region::needed(drawn_long, rw.max(rh));
if region_wanted {
let (fw, fh) = if aspect >= 1.0 { (drawn_long, drawn_long / aspect) } else { (drawn_long * aspect, drawn_long) };
let (fw, fh) = (fw.round().max(1.0) as usize, fh.round().max(1.0) as usize);
// what is on screen, in pixels of the zoomed frame
let to_px = |v: f32| v * ppp;
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()),
);
if let Some(win) = crate::region::window_for(fw, fh, visible)
&& let Some(job) = app.session.region_job(id, fw, fh, win, !crop_tool)
{
let job = if interacting { job.draft() } else { job };
let view = crate::region::RegionView { photo: id, key: job.key, full: (fw, fh), window: win };
if app.region_tiles.len() > 8 {
app.region_tiles.clear();
}
app.region_tiles.insert(job.key, view);
app.region_view = Some(view);
app.renderer.request(Slot::Region, job, 99);
}
} else {
app.region_view = None;
}
let p = ui.painter_at(canvas);
// what a view slot shows: its own render of this photo, else the stand-ins (no blank frame
// between photos, and the full render replaces them in place)
@@ -400,6 +436,16 @@ 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 region_wanted
&& 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.photo == id && Some(v.full) == app.region_view.map(|r| r.full))
{
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());
let dst = Rect::from_min_size(at, vec2(v.window.w as f32 / fw * img_rect.width(), v.window.h as f32 / fh * img_rect.height()));
p.image(tex.tex.id(), dst, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
app.loupe_shown = Some((id, shown));
if app.ui.before_after == BeforeAfter::Split {
let mid = img_rect.center().x;
+10
View File
@@ -15,6 +15,16 @@ pub const MARGIN: usize = 256;
/// The most a window may span on either axis: a bound on its texture whatever the window size.
pub const MAX_SPAN: usize = 8192;
/// A window render the loupe asked for: which photo, the size of the zoomed frame it is a window
/// of, and the window. The texture that comes back is drawn at this place of the frame.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RegionView {
pub photo: lightcraft_catalog::PhotoId,
pub key: u64,
pub full: (usize, usize),
pub window: PixelWindow,
}
/// 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 {
+20 -4
View File
@@ -30,6 +30,9 @@ pub enum Slot {
/// A thumbnail's stand-in (embedded preview of an unedited raw).
ThumbQuick(PhotoId),
Main,
/// The loupe zoomed past what `Main` can hold: the window of the frame that is on screen,
/// rendered at the zoom scale and drawn over `Main` (see [`crate::region`]).
Region,
/// The loupe's stand-in until `Main` has the photo.
Preview,
Before,
@@ -137,6 +140,14 @@ pub struct Renderer {
#[cfg(not(target_arch = "wasm32"))]
const IDLE_TRIM: std::time::Duration = std::time::Duration::from_secs(3);
impl Slot {
/// An interactive view of the open photo: slider drags redo only the stages they feed, and a
/// draft that finishes late still replaces older pixels.
pub fn is_view(self) -> bool {
matches!(self, Slot::Main | Slot::Region | Slot::Before | Slot::Hover)
}
}
impl Default for Renderer {
fn default() -> Self {
let threads = if cfg!(target_arch = "wasm32") { 0 } else { JobPool::<Slot, RenderResult>::default_threads().min(6) };
@@ -219,6 +230,12 @@ impl Renderer {
}
true
}
/// Whether windows of the frame can be rendered here: not in the browser build, whose workers
/// rebuild the request from a wire format that has no window yet.
pub fn can_render_windows(&self) -> bool {
self.offload.is_none()
}
/// Run jobs through `offload` from now on (instead of the job pool / inline).
pub fn set_offload(&mut self, offload: Box<dyn RenderOffload>) {
self.offload = Some(offload);
@@ -247,8 +264,7 @@ impl Renderer {
job.request_id = lightcraft_preview::next_tick();
self.request_ids.insert(slot, job.request_id);
// (an offload keeps its own per-view stage caches; the flag tells it to)
let job =
if matches!(slot, Slot::Main | Slot::Before | Slot::Hover) { job.with_stages(self.stages.entry(slot).or_default().clone()) } else { job };
let job = if slot.is_view() { job.with_stages(self.stages.entry(slot).or_default().clone()) } else { job };
if self.offload.is_some() {
self.seq += 1;
self.queue.retain(|q| q.slot != slot);
@@ -452,7 +468,7 @@ impl Renderer {
// Ignore obsolete pixels, failures and decoded sources, even for the same render key;
// an interactive view still takes a superseded draft that is newer than what it shows.
if self.request_ids.get(&slot) != Some(&r.request_id) {
let newer_draft = matches!(slot, Slot::Main | Slot::Before | Slot::Hover)
let newer_draft = slot.is_view()
&& self.request_ids.contains_key(&slot)
&& r.request_id >= self.epoch
&& self.shown_ids.get(&slot).is_none_or(|shown| r.request_id > *shown)
@@ -464,7 +480,7 @@ impl Renderer {
self.request_ids.remove(&slot);
session.accept(&r);
}
if matches!(slot, Slot::Main | Slot::Before | Slot::Hover) {
if slot.is_view() {
self.shown_ids.insert(slot, r.request_id);
}
if self.pending.get(&slot).is_some_and(|p| p.0 == r.key) {
+72
View File
@@ -161,4 +161,76 @@ mod in_the_loupe {
h.settle(SETTLE);
assert!(rendered_long_edge(&h) <= 1600, "rendered {}", rendered_long_edge(&h));
}
fn region_tile(h: &Headless) -> Option<(usize, usize)> {
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
#[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);
assert_eq!(region_tile(&h), None);
}
// Given the user capped the preview at 1600 px, when I zoom to 1:1, then the visible part is
// rendered on its own at the zoom scale and drawn over the whole-frame render
#[test]
fn a_capped_one_to_one_view_gets_a_sharp_window() {
let (mut h, native) = detail();
h.app.ui.settings.preview_limit = 1600;
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
h.settle(SETTLE);
let (w, hh) = region_tile(&h).expect("a window render");
// it covers what is on screen (the 1400×900 window less its chrome), and is not the frame
assert!(w >= 1300 && hh >= 700, "{w}×{hh}");
assert!(w < native && hh < native, "{w}×{hh} of a {native} px frame");
let region = h.app.region_view.expect("the window is described for the inspector");
assert_eq!(region.full.0.max(region.full.1), native, "1:1 means the frame is the photo's own size");
}
// Given 800 % zoom (a frame of 48000 px), the window is rendered and bounded
#[test]
fn eight_to_one_renders_only_what_is_visible() {
let (mut h, native) = detail();
h.app.ui.zoom = crate::state::Zoom::Percent(800.0);
h.settle(SETTLE);
let (w, hh) = region_tile(&h).expect("a window render");
assert!(w.max(hh) <= crate::region::MAX_SPAN && w < native * 8 / 2, "{w}×{hh}");
// …and panning far away asks for another window
let before = h.app.region_view.unwrap().window;
h.app.ui.pan = (0.9, 0.9);
h.settle(SETTLE);
assert_ne!(h.app.region_view.unwrap().window, before);
}
// The window is drawn where it belongs: its pixels agree with the whole-frame render at the
// same place of the frame (a shift, a flip or a wrong scale would not)
#[test]
fn the_window_lines_up_with_the_whole_frame_render() {
let (mut h, _) = detail();
h.app.renderer.keep_pixels = true;
h.app.ui.settings.preview_limit = 1600;
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
h.settle(SETTLE);
let view = h.app.region_view.expect("a window render");
let tile = h.app.renderer.textures.get(&Slot::Region).and_then(|t| t.pixels.clone()).expect("tile pixels");
let main = h.app.renderer.textures.get(&Slot::Main).and_then(|t| t.pixels.clone()).expect("main pixels");
let (mut sum, mut n) = (0.0f32, 0.0f32);
for j in (8..tile.size[1] - 8).step_by(37) {
for i in (8..tile.size[0] - 8).step_by(37) {
// the same point of the frame in the whole-frame render
let u = (view.window.x + i) as f32 / view.full.0 as f32;
let v = (view.window.y + j) as f32 / view.full.1 as f32;
let (mx, my) =
(((u * main.size[0] as f32) as usize).min(main.size[0] - 1), ((v * main.size[1] as f32) as usize).min(main.size[1] - 1));
let (a, b) = (tile.pixels[j * tile.size[0] + i], main.pixels[my * main.size[0] + mx]);
sum += (0..3).map(|k| (a.to_array()[k] as f32 - b.to_array()[k] as f32).abs()).sum::<f32>() / 3.0;
n += 1.0;
}
}
assert!(sum / n < 6.0, "mean difference {:.2} / 255 between the window and the frame render", sum / n);
}
}