From e0539db3f57c0c0a248f75d6103fc1b48e925641 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 13:04:25 -0400 Subject: [PATCH] #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 --- crates/ui-egui/src/control.rs | 8 ++- crates/ui-egui/src/lib.rs | 6 ++ crates/ui-egui/src/panels/detail.rs | 46 +++++++++++++++ crates/ui-egui/src/region.rs | 10 ++++ crates/ui-egui/src/render.rs | 24 ++++++-- crates/ui-egui/src/tests_preview_limit.rs | 72 +++++++++++++++++++++++ 6 files changed, 161 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 7456a7240..072edc334 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -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, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a4e009e4f..3669610ca 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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, + pub(crate) region_tiles: std::collections::HashMap, /// 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, diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 7810b95e4..71d11018c 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -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; diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index 75cc062c9..1c894f963 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -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 { diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 5c1c1bbbb..b4940ea54 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -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::::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) { 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) { diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index 1fb32a0e7..c17b51574 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -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::() / 3.0; + n += 1.0; + } + } + assert!(sum / n < 6.0, "mean difference {:.2} / 255 between the window and the frame render", sum / n); + } }