mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-02 06:27:53 -06:00
#323: engine region_job and the window geometry for zoomed views
Session::region_job renders one PixelWindow of the loupe's frame from the source level that frame size needs, with a key of its own and no cache writes. ui-egui::region decides when a window is needed (the whole-frame render is stretched) and which one: the visible pixels plus a margin, snapped to a 256 px grid so small pans reuse the render, bounded in size, clamped into the frame. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
b358022f14
commit
2079e8e08d
@@ -881,6 +881,32 @@ impl crate::Session {
|
||||
Some(job)
|
||||
}
|
||||
|
||||
/// A render of one window of the loupe's frame, for a view zoomed past what one whole-frame
|
||||
/// render can hold: the frame is `full_w × full_h` (the size the loupe would draw it at) and
|
||||
/// the result is `window`'s pixels of it, at that scale, from the source level that size needs.
|
||||
/// Nothing is cached here (a window is only worth keeping while it is on screen).
|
||||
pub fn region_job(
|
||||
&mut self,
|
||||
id: PhotoId,
|
||||
full_w: usize,
|
||||
full_h: usize,
|
||||
window: lightcraft_pipeline::PixelWindow,
|
||||
apply_crop: bool,
|
||||
) -> Option<RenderJob> {
|
||||
let mut job = self.render_job(id, full_w, full_h, false, apply_crop)?;
|
||||
job.request.window = Some(window);
|
||||
job.key = Hasher128::new()
|
||||
.u64(job.key)
|
||||
.str("window")
|
||||
.u64(window.x as u64)
|
||||
.u64(window.y as u64)
|
||||
.u64(window.w as u64)
|
||||
.u64(window.h as u64)
|
||||
.finish()
|
||||
.0 as u64;
|
||||
Some(job)
|
||||
}
|
||||
|
||||
/// The embedded preview of an unedited raw (path, loader), when the app installed a loader.
|
||||
fn embedded_of(&self, p: &Photo) -> Option<(String, PreviewLoader)> {
|
||||
match (&p.source, &self.media.preview_loader) {
|
||||
@@ -1218,6 +1244,31 @@ mod tests {
|
||||
assert_ne!(a.key, b.key);
|
||||
}
|
||||
|
||||
// Issue #323: a window of a 1:1 view is its own job: the window's pixels, at the zoom scale,
|
||||
// from the original, with a key of its own
|
||||
#[test]
|
||||
fn region_jobs_render_a_window_of_the_zoomed_frame() {
|
||||
use lightcraft_pipeline::PixelWindow;
|
||||
let mut s = crate::Session::with_demo();
|
||||
let p = s.catalog.photos().next().unwrap().clone();
|
||||
let (w, h) = (p.width as usize, p.height as usize);
|
||||
assert!(w.max(h) > 2560, "demo photos are camera-sized");
|
||||
let win = PixelWindow { x: w / 3, y: h / 3, w: 300, h: 200 };
|
||||
let job = s.region_job(p.id, w, h, win, true).unwrap();
|
||||
assert_eq!(job.level, SourceLevel::Full, "a 1:1 window reads the original");
|
||||
let img = job.run().rendered.unwrap().image;
|
||||
assert_eq!((img.width, img.height), (300, 200));
|
||||
// other windows, and the whole-frame job, never share a key
|
||||
let other = s.region_job(p.id, w, h, PixelWindow { x: win.x + 64, ..win }, true).unwrap();
|
||||
let whole = s.render_job(p.id, w, h, false, true).unwrap();
|
||||
let again = s.region_job(p.id, w, h, win, true).unwrap();
|
||||
assert_ne!(other.key, again.key);
|
||||
assert_ne!(whole.key, again.key);
|
||||
assert_eq!(s.region_job(p.id, w, h, win, true).unwrap().key, again.key, "the same window keeps its key");
|
||||
// a window never writes the photo's view preview or the thumbnail cache
|
||||
assert!(again.view_cache.is_none() && again.cache.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variant_jobs_have_their_own_keys_and_hit_the_cache() {
|
||||
let mut s = crate::Session::with_demo();
|
||||
|
||||
@@ -19,6 +19,7 @@ pub mod menubar;
|
||||
pub mod menus;
|
||||
pub mod merge;
|
||||
pub mod panels;
|
||||
pub mod region;
|
||||
pub mod render;
|
||||
pub mod shortcuts;
|
||||
pub mod softpaint;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
//! Sharp zoomed views (issue #323). The loupe renders the whole frame in one texture, which can't
|
||||
//! be as large as a 1:1 or 8:1 view of a big photo. Past that size it also renders just the window
|
||||
//! of the frame that is on screen, at the zoom scale, and draws it over the whole-frame render.
|
||||
//!
|
||||
//! This module is the geometry: when a window is needed and which one. Windows are snapped to a
|
||||
//! grid and carry a margin, so panning by a few pixels re-uses the render and the spatial stages
|
||||
//! (clarity, dehaze…) have the context they read around the visible edge.
|
||||
|
||||
use lightcraft_engine::pipeline::PixelWindow;
|
||||
|
||||
/// Windows start and end on multiples of this many pixels of the zoomed frame.
|
||||
pub const SNAP: usize = 256;
|
||||
/// Context rendered beyond the visible pixels (a multiple of [`SNAP`] after snapping).
|
||||
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;
|
||||
|
||||
/// 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 {
|
||||
drawn_long.is_finite() && drawn_long > rendered_long as f32 * 1.02 + 1.0
|
||||
}
|
||||
|
||||
/// The window of a `full_w × full_h` frame to render so that `visible` — `(x0, y0, x1, y1)` in the
|
||||
/// frame's pixels, as much of the frame as is on screen — is covered: expanded by [`MARGIN`],
|
||||
/// snapped outwards to the [`SNAP`] grid and clamped into the frame. `None`: nothing is visible.
|
||||
pub fn window_for(full_w: usize, full_h: usize, visible: (f32, f32, f32, f32)) -> Option<PixelWindow> {
|
||||
if full_w == 0 || full_h == 0 || [visible.0, visible.1, visible.2, visible.3].iter().any(|v| !v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let axis = |lo: f32, hi: f32, full: usize| -> Option<(usize, usize)> {
|
||||
let (lo, hi) = (lo.max(0.0), hi.min(full as f32));
|
||||
if hi <= lo {
|
||||
return None;
|
||||
}
|
||||
let a = (lo as usize).saturating_sub(MARGIN) / SNAP * SNAP;
|
||||
let b = ((hi.ceil() as usize).saturating_add(MARGIN)).div_ceil(SNAP).saturating_mul(SNAP).min(full);
|
||||
// a window larger than MAX_SPAN keeps the visible part (centred on it), snapped
|
||||
if b - a > MAX_SPAN {
|
||||
let mid = ((lo + hi) / 2.0) as usize;
|
||||
let a = mid.saturating_sub(MAX_SPAN / 2) / SNAP * SNAP;
|
||||
return Some((a, (a + MAX_SPAN).min(full)));
|
||||
}
|
||||
Some((a, b))
|
||||
};
|
||||
let (x0, x1) = axis(visible.0, visible.2, full_w)?;
|
||||
let (y0, y1) = axis(visible.1, visible.3, full_h)?;
|
||||
Some(PixelWindow { x: x0, y: y0, w: x1 - x0, h: y1 - y0 })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Given a view drawn at 6000 px and a render of 6000, no window is needed; at 6000 vs 2560 it is
|
||||
#[test]
|
||||
fn a_window_is_needed_only_when_the_whole_frame_render_is_stretched() {
|
||||
assert!(!needed(6000.0, 6000));
|
||||
assert!(!needed(6100.0, 6000), "a couple of percent is not worth a second render");
|
||||
assert!(needed(6000.0, 2560));
|
||||
assert!(needed(48000.0, 8192));
|
||||
assert!(!needed(f32::NAN, 100) && !needed(f32::INFINITY, 100));
|
||||
}
|
||||
|
||||
// Given a visible area in the middle of a big frame, the window covers it with a margin, on the grid
|
||||
#[test]
|
||||
fn the_window_covers_the_visible_area_with_a_margin_on_the_grid() {
|
||||
let w = window_for(48_000, 32_000, (20_000.0, 10_000.0, 21_400.0, 10_900.0)).unwrap();
|
||||
assert!(w.x <= 20_000 - MARGIN && w.x + w.w >= 21_400 + MARGIN, "{w:?}");
|
||||
assert!(w.y <= 10_000 - MARGIN && w.y + w.h >= 10_900 + MARGIN, "{w:?}");
|
||||
assert_eq!((w.x % SNAP, w.y % SNAP, w.w % SNAP, w.h % SNAP), (0, 0, 0, 0));
|
||||
}
|
||||
|
||||
// Given a small pan, the same window is kept (no re-render); a big pan changes it
|
||||
#[test]
|
||||
fn small_pans_keep_the_window() {
|
||||
let a = window_for(48_000, 32_000, (20_000.0, 10_000.0, 21_400.0, 10_900.0)).unwrap();
|
||||
let b = window_for(48_000, 32_000, (20_040.0, 10_030.0, 21_440.0, 10_930.0)).unwrap();
|
||||
assert_eq!(a, b);
|
||||
let c = window_for(48_000, 32_000, (26_000.0, 10_000.0, 27_400.0, 10_900.0)).unwrap();
|
||||
assert_ne!(a, c);
|
||||
}
|
||||
|
||||
// Given a frame smaller than the margin, or a view at its edge, the window is clamped into it
|
||||
#[test]
|
||||
fn the_window_stays_inside_the_frame() {
|
||||
let w = window_for(3000, 2000, (-500.0, -500.0, 900.0, 700.0)).unwrap();
|
||||
assert_eq!((w.x, w.y), (0, 0));
|
||||
let w = window_for(3000, 2000, (2500.0, 1500.0, 9000.0, 9000.0)).unwrap();
|
||||
assert!(w.x + w.w <= 3000 && w.y + w.h <= 2000, "{w:?}");
|
||||
assert_eq!((w.x + w.w, w.y + w.h), (3000, 2000));
|
||||
}
|
||||
|
||||
// Given nothing visible, or hostile numbers, there is no window (never a panic)
|
||||
#[test]
|
||||
fn nothing_visible_or_hostile_input_is_no_window() {
|
||||
assert_eq!(window_for(1000, 1000, (2000.0, 0.0, 3000.0, 100.0)), None);
|
||||
assert_eq!(window_for(1000, 1000, (10.0, 10.0, 10.0, 90.0)), None);
|
||||
assert_eq!(window_for(0, 1000, (0.0, 0.0, 10.0, 10.0)), None);
|
||||
for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
|
||||
assert_eq!(window_for(1000, 1000, (v, 0.0, 10.0, 10.0)), None);
|
||||
}
|
||||
let w = window_for(usize::MAX / 2, usize::MAX / 2, (0.0, 0.0, 1e30, 1e30)).unwrap();
|
||||
assert!(w.w <= MAX_SPAN && w.h <= MAX_SPAN, "{w:?}");
|
||||
}
|
||||
|
||||
// Given a view of an enormous area, the window is bounded
|
||||
#[test]
|
||||
fn a_window_is_bounded() {
|
||||
let w = window_for(100_000, 100_000, (0.0, 0.0, 90_000.0, 90_000.0)).unwrap();
|
||||
assert!(w.w <= MAX_SPAN && w.h <= MAX_SPAN);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user