mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-02 22:49:48 -06:00
#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:
co-authored by
Claude Sonnet 5.5
parent
2079e8e08d
commit
e0539db3f5
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user