mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-31 21:48:53 -06:00
#323: no loupe texture is larger than the GPU allows
The Automatic preview size let the loupe texture reach 8192 px on every platform. The browser build draws with egui_glow, which panics on a texture larger than the GPU takes, and many WebGL devices allow only 2048 or 4096. Every render size the loupe asks for (loupe, neighbour prefetch, hover and Before stand-ins, compare tiles, the second window) and every zoom window is now clamped to the context's max_texture_side (2048 when the host reports none, 512 at the least). Headless drivers can pretend a small GPU for tests. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
63cfc6ab70
commit
b7f632f5c0
@@ -107,6 +107,8 @@ impl HeadlessView {
|
||||
pub struct Headless {
|
||||
pub app: LightcraftApp,
|
||||
pub view: HeadlessView,
|
||||
/// The largest texture the pretend GPU takes (what a WebGL device may report: 2048).
|
||||
pub max_texture_side: usize,
|
||||
/// Logical size (points) and scale.
|
||||
pub size: egui::Vec2,
|
||||
pub pixels_per_point: f32,
|
||||
@@ -126,6 +128,7 @@ impl Headless {
|
||||
Headless {
|
||||
app,
|
||||
view: HeadlessView::new(),
|
||||
max_texture_side: 16384,
|
||||
size: egui::vec2(size[0], size[1]),
|
||||
pixels_per_point,
|
||||
time: 0.0,
|
||||
@@ -148,6 +151,7 @@ impl Headless {
|
||||
/// Run one frame.
|
||||
pub fn step(&mut self) {
|
||||
let mut raw = HeadlessView::raw_input(self.size, self.pixels_per_point, self.time, std::mem::take(&mut self.events));
|
||||
raw.max_texture_side = Some(self.max_texture_side);
|
||||
self.app.raw_input_hook(&mut raw);
|
||||
let app = &mut self.app;
|
||||
let commands = self.view.run(raw, |ui| {
|
||||
|
||||
@@ -168,7 +168,8 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo
|
||||
super::detail::pan_image(app, img_area, img, resp.drag_delta());
|
||||
}
|
||||
}
|
||||
let want = (img.width().max(img.height()).min(img_area.width().max(img_area.height()) * 4.0) * ppp).min(2560.0) as usize;
|
||||
let want = (img.width().max(img.height()).min(img_area.width().max(img_area.height()) * 4.0) * ppp)
|
||||
.min(2560.0_f32.min(super::detail::texture_side(ui.ctx()) as f32)) as usize;
|
||||
let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) };
|
||||
if let Some(job) = app.session.render_job(id, rw.max(8), rh.max(8), false, true) {
|
||||
app.renderer.request(slot, job, 60);
|
||||
|
||||
@@ -187,6 +187,12 @@ pub(crate) fn navigate_gesture(app: &mut LightcraftApp, ui: &mut egui::Ui, resp:
|
||||
true
|
||||
}
|
||||
|
||||
/// The largest texture the GPU behind `ctx` takes (egui reports it; 2048 when it doesn't, the
|
||||
/// smallest limit WebGL devices have).
|
||||
pub(crate) fn texture_side(ctx: &egui::Context) -> usize {
|
||||
ctx.input(|i| i.raw.max_texture_side).unwrap_or(2048)
|
||||
}
|
||||
|
||||
/// The photo's own pixels as it is shown: its size after the crop and the user's rotation (what
|
||||
/// 100 % zoom and the render size limit are measured against), at least 1 × 1.
|
||||
pub(crate) fn output_px(frame: &Frame) -> [usize; 2] {
|
||||
@@ -239,6 +245,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
24.0
|
||||
});
|
||||
let native = output_px(&frame);
|
||||
let texture_side = texture_side(ui.ctx());
|
||||
// two views (before, after): side by side or stacked
|
||||
let split = matches!(app.ui.before_after, BeforeAfter::SideBySide | BeforeAfter::TopBottom);
|
||||
let split_view = matches!(app.ui.before_after, BeforeAfter::Split | BeforeAfter::SplitTopBottom);
|
||||
@@ -291,10 +298,10 @@ 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);
|
||||
let want = app.ui.settings.loupe_edge(target_rect.width().max(target_rect.height()) * ppp * scale, native_long, texture_side);
|
||||
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);
|
||||
let se = app.ui.settings.stand_in_edge(want, texture_side);
|
||||
let (sw, sh) = if aspect >= 1.0 { (se, (se as f32 / aspect) as usize) } else { ((se as f32 * aspect) as usize, se) };
|
||||
if let Some(job) = app.session.loupe_job(id, rw.max(8), rh.max(8), !crop_tool) {
|
||||
let job = if interacting { job.draft() } else { job };
|
||||
@@ -324,7 +331,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let nf = Frame::with_lens(np.width.max(1) as usize, np.height.max(1) as usize, &np.develop, !crop_tool, np.embedded_lens.as_ref());
|
||||
let na = nf.aspect() as f32;
|
||||
let nr = fit_rect(main_area, na, app.ui.zoom, output_px(&nf), ppp, app.ui.pan);
|
||||
let nw = app.ui.settings.prefetch_edge(nr.width().max(nr.height()) * ppp, output_px(&nf).into_iter().max().unwrap_or(1));
|
||||
let nw =
|
||||
app.ui.settings.prefetch_edge(nr.width().max(nr.height()) * ppp, output_px(&nf).into_iter().max().unwrap_or(1), texture_side);
|
||||
let (w, h) = if na >= 1.0 { (nw, (nw as f32 / na) as usize) } else { ((nw as f32 * na) as usize, nw) };
|
||||
if let Some(job) = app.session.loupe_job(nid, w.max(8), h.max(8), !crop_tool) {
|
||||
app.renderer.prefetch(Slot::Prefetch(n as u8), job, PREFETCH_PRIORITY);
|
||||
@@ -354,7 +362,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
&& !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);
|
||||
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);
|
||||
let look = d.hash64();
|
||||
let mut region_full = None;
|
||||
@@ -371,7 +379,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
to_px(canvas.bottom() - target_rect.top()),
|
||||
);
|
||||
if !interacting
|
||||
&& let Some(win) = crate::region::window_for(fw, fh, visible)
|
||||
&& 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)
|
||||
{
|
||||
let view = crate::region::RegionView { photo: id, key: job.key, full: (fw, fh), window: win, settings: look };
|
||||
|
||||
@@ -37,7 +37,8 @@ fn body(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
};
|
||||
// a render sized for this window
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let (w, h) = (((area.width() * ppp) as usize).clamp(64, 4096), ((area.height() * ppp) as usize).clamp(64, 4096));
|
||||
let side = super::detail::texture_side(ui.ctx()).clamp(64, 4096);
|
||||
let (w, h) = (((area.width() * ppp) as usize).clamp(64, side), ((area.height() * ppp) as usize).clamp(64, side));
|
||||
if let Some(job) = app.session.loupe_job(id, w, h, true)
|
||||
&& app.renderer.textures.get(&Slot::Second).is_none_or(|t| t.key != job.key)
|
||||
&& !app.renderer.is_pending(Slot::Second)
|
||||
|
||||
@@ -42,6 +42,13 @@ pub fn margin_for(full_long: usize) -> usize {
|
||||
want.div_ceil(SNAP).max(1) * SNAP
|
||||
}
|
||||
|
||||
/// The widest a window may be on a host whose GPU textures are at most `texture_side` px: a
|
||||
/// window is one texture, and a texture over the limit makes some backends (egui_glow, i.e. the
|
||||
/// browser build) panic. A multiple of [`SNAP`], at least one grid step, at most [`MAX_SPAN`].
|
||||
pub fn max_span(texture_side: usize) -> usize {
|
||||
(texture_side.min(MAX_SPAN) / SNAP * SNAP).max(SNAP)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -50,8 +57,9 @@ pub fn needed(drawn_long: f32, rendered_long: usize) -> bool {
|
||||
|
||||
/// 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 a margin,
|
||||
/// snapped outwards to the [`SNAP`] grid (margin: see [`margin_for`]) 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> {
|
||||
/// snapped outwards to the [`SNAP`] grid (margin: see [`margin_for`]) and clamped into the frame,
|
||||
/// at most `max_span` ([`max_span`]) wide or high. `None`: nothing is visible.
|
||||
pub fn window_for(full_w: usize, full_h: usize, visible: (f32, f32, f32, f32), max_span: usize) -> 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;
|
||||
}
|
||||
@@ -64,10 +72,10 @@ pub fn window_for(full_w: usize, full_h: usize, visible: (f32, f32, f32, f32)) -
|
||||
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 {
|
||||
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)));
|
||||
let a = mid.saturating_sub(max_span / 2) / SNAP * SNAP;
|
||||
return Some((a, (a + max_span).min(full)));
|
||||
}
|
||||
Some((a, b))
|
||||
};
|
||||
@@ -93,7 +101,7 @@ mod tests {
|
||||
// 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();
|
||||
let w = window_for(48_000, 32_000, (20_000.0, 10_000.0, 21_400.0, 10_900.0), MAX_SPAN).unwrap();
|
||||
let m = margin_for(48_000);
|
||||
assert!(w.x + m <= 20_000 && w.x + w.w >= 21_400 + m, "{w:?}");
|
||||
assert!(w.y + m <= 10_000 && w.y + w.h >= 10_900 + m, "{w:?}");
|
||||
@@ -103,19 +111,19 @@ mod tests {
|
||||
// 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();
|
||||
let a = window_for(48_000, 32_000, (20_000.0, 10_000.0, 21_400.0, 10_900.0), MAX_SPAN).unwrap();
|
||||
let b = window_for(48_000, 32_000, (20_040.0, 10_030.0, 21_440.0, 10_930.0), MAX_SPAN).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();
|
||||
let c = window_for(48_000, 32_000, (26_000.0, 10_000.0, 27_400.0, 10_900.0), MAX_SPAN).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();
|
||||
let w = window_for(3000, 2000, (-500.0, -500.0, 900.0, 700.0), MAX_SPAN).unwrap();
|
||||
assert_eq!((w.x, w.y), (0, 0));
|
||||
let w = window_for(3000, 2000, (2500.0, 1500.0, 9000.0, 9000.0)).unwrap();
|
||||
let w = window_for(3000, 2000, (2500.0, 1500.0, 9000.0, 9000.0), MAX_SPAN).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));
|
||||
}
|
||||
@@ -145,23 +153,36 @@ mod tests {
|
||||
assert!(!v.is_current(PhotoId(1), (6000, 4000), 12), "another look (an edit, an undo)");
|
||||
}
|
||||
|
||||
// Given a GPU that allows 2048 px textures, no window is wider than that
|
||||
#[test]
|
||||
fn a_window_fits_the_texture_side() {
|
||||
for tex in [512, 2048, 4096] {
|
||||
let span = max_span(tex);
|
||||
assert!(span <= tex.max(SNAP) && span.is_multiple_of(SNAP), "{tex}: {span}");
|
||||
let w = window_for(48_000, 32_000, (20_000.0, 10_000.0, 21_400.0, 10_900.0), span).unwrap();
|
||||
assert!(w.w <= span && w.h <= span, "{tex}: {w:?}");
|
||||
}
|
||||
assert_eq!(max_span(100_000), MAX_SPAN);
|
||||
assert_eq!(max_span(0), SNAP);
|
||||
}
|
||||
|
||||
// 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);
|
||||
assert_eq!(window_for(1000, 1000, (2000.0, 0.0, 3000.0, 100.0), MAX_SPAN), None);
|
||||
assert_eq!(window_for(1000, 1000, (10.0, 10.0, 10.0, 90.0), MAX_SPAN), None);
|
||||
assert_eq!(window_for(0, 1000, (0.0, 0.0, 10.0, 10.0), MAX_SPAN), 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);
|
||||
assert_eq!(window_for(1000, 1000, (v, 0.0, 10.0, 10.0), MAX_SPAN), None);
|
||||
}
|
||||
let w = window_for(usize::MAX / 2, usize::MAX / 2, (0.0, 0.0, 1e30, 1e30)).unwrap();
|
||||
let w = window_for(usize::MAX / 2, usize::MAX / 2, (0.0, 0.0, 1e30, 1e30), MAX_SPAN).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();
|
||||
let w = window_for(100_000, 100_000, (0.0, 0.0, 90_000.0, 90_000.0), MAX_SPAN).unwrap();
|
||||
assert!(w.w <= MAX_SPAN && w.h <= MAX_SPAN);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +182,9 @@ pub const PREVIEW_LIMITS: [u32; 5] = [0, 1600, 2560, 3840, 5120];
|
||||
/// The most the loupe ever renders in one go (memory and GPU texture size), whatever the setting.
|
||||
pub const LOUPE_EDGE_CEILING: u32 = 8192;
|
||||
|
||||
/// The smallest GPU texture side the loupe plans for, whatever the host reports.
|
||||
pub const MIN_TEXTURE_SIDE: usize = 512;
|
||||
|
||||
/// Loupe render sizes move in steps of this many pixels.
|
||||
pub const LOUPE_EDGE_STEP: usize = 64;
|
||||
|
||||
@@ -191,9 +194,12 @@ pub const STANDARD_PREVIEW_EDGE: u32 = 2560;
|
||||
impl AppSettings {
|
||||
/// Long edge (pixels) to render the loupe at when it is drawn `wanted_px` wide on screen:
|
||||
/// that size (rounded up to [`LOUPE_EDGE_STEP`], so resizing a window doesn't re-render at
|
||||
/// every pixel), never above the photo's own `native_long_edge`, the user's limit or the ceiling.
|
||||
pub fn loupe_edge(&self, wanted_px: f32, native_long_edge: usize) -> usize {
|
||||
let ceiling = if self.preview_limit == 0 { LOUPE_EDGE_CEILING } else { self.preview_limit.clamp(512, LOUPE_EDGE_CEILING) } as usize;
|
||||
/// every pixel), never above the photo's own `native_long_edge`, the user's limit, the ceiling
|
||||
/// or `texture_side`, the largest texture the GPU allows (egui_glow, i.e. the browser build,
|
||||
/// panics on a bigger one, and many WebGL devices allow only 2048 or 4096).
|
||||
pub fn loupe_edge(&self, wanted_px: f32, native_long_edge: usize, texture_side: usize) -> usize {
|
||||
let limit = if self.preview_limit == 0 { LOUPE_EDGE_CEILING } else { self.preview_limit.clamp(512, LOUPE_EDGE_CEILING) } as usize;
|
||||
let ceiling = limit.min(texture_side.max(MIN_TEXTURE_SIDE));
|
||||
let wanted = if wanted_px.is_nan() { 8.0 } else { wanted_px.clamp(8.0, ceiling as f32) } as usize;
|
||||
let stepped = wanted.div_ceil(LOUPE_EDGE_STEP) * LOUPE_EDGE_STEP;
|
||||
stepped.min(native_long_edge.max(8)).min(ceiling)
|
||||
@@ -202,14 +208,14 @@ impl AppSettings {
|
||||
/// Long edge for warming a neighbouring photo: its loupe size but never above the preview
|
||||
/// source level. A full-size decode would replace the open photo's single full-resolution
|
||||
/// source in the cache, and nothing is gained by it.
|
||||
pub fn prefetch_edge(&self, wanted_px: f32, native_long_edge: usize) -> usize {
|
||||
self.loupe_edge(wanted_px, native_long_edge).min(lightcraft_engine::SourceLevel::Preview.max_edge())
|
||||
pub fn prefetch_edge(&self, wanted_px: f32, native_long_edge: usize, texture_side: usize) -> usize {
|
||||
self.loupe_edge(wanted_px, native_long_edge, texture_side).min(lightcraft_engine::SourceLevel::Preview.max_edge())
|
||||
}
|
||||
|
||||
/// Long edge for the hover (preset / profile) and Before renders, which are stand-ins drawn
|
||||
/// over the loupe: capped at the preview source level like the old default.
|
||||
pub fn stand_in_edge(&self, loupe_edge: usize) -> usize {
|
||||
loupe_edge.min(lightcraft_engine::SourceLevel::Preview.max_edge())
|
||||
pub fn stand_in_edge(&self, loupe_edge: usize, texture_side: usize) -> usize {
|
||||
loupe_edge.min(lightcraft_engine::SourceLevel::Preview.max_edge()).min(texture_side.max(MIN_TEXTURE_SIDE))
|
||||
}
|
||||
|
||||
/// The edge Build Standard-Sized Previews uses.
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
|
||||
use crate::state::{AppSettings, LOUPE_EDGE_CEILING, PREVIEW_LIMITS, STANDARD_PREVIEW_EDGE};
|
||||
|
||||
/// A texture side no GPU we run on refuses (see the tests of small ones below).
|
||||
const BIG: usize = 16384;
|
||||
|
||||
fn with_limit(preview_limit: u32) -> AppSettings {
|
||||
AppSettings { preview_limit, ..Default::default() }
|
||||
}
|
||||
@@ -18,21 +21,21 @@ fn default_is_automatic() {
|
||||
// Given Automatic, when a 24 MP photo is shown at 1:1, then the render is the photo's own width
|
||||
#[test]
|
||||
fn automatic_renders_a_one_to_one_view_at_native_size() {
|
||||
assert_eq!(with_limit(0).loupe_edge(6000.0, 6000), 6000);
|
||||
assert_eq!(with_limit(0).loupe_edge(6000.0, 6000, BIG), 6000);
|
||||
}
|
||||
|
||||
// Given Automatic, a Retina fit view of 3024 px is not held to the old 2560 px cap
|
||||
#[test]
|
||||
fn automatic_does_not_cap_a_fit_view_at_the_old_default() {
|
||||
assert_eq!(with_limit(0).loupe_edge(3024.0, 6000), 3072, "rounded up to the next 64 px step");
|
||||
assert_eq!(with_limit(0).loupe_edge(3024.0, 6000, BIG), 3072, "rounded up to the next 64 px step");
|
||||
}
|
||||
|
||||
// Dragging a window edge must not re-render (and re-key the caches) on every pixel
|
||||
#[test]
|
||||
fn render_sizes_move_in_steps() {
|
||||
let s = with_limit(0);
|
||||
assert_eq!(s.loupe_edge(2945.0, 6000), s.loupe_edge(3008.0, 6000));
|
||||
assert_ne!(s.loupe_edge(3008.0, 6000), s.loupe_edge(3009.0, 6000));
|
||||
assert_eq!(s.loupe_edge(2945.0, 6000, BIG), s.loupe_edge(3008.0, 6000, BIG));
|
||||
assert_ne!(s.loupe_edge(3008.0, 6000, BIG), s.loupe_edge(3009.0, 6000, BIG));
|
||||
}
|
||||
|
||||
// Neighbour prefetch and hover / before stand-ins stay at the preview level: a full-size decode
|
||||
@@ -42,29 +45,55 @@ fn prefetch_and_stand_ins_never_ask_for_the_full_source_level() {
|
||||
use lightcraft_engine::SourceLevel;
|
||||
let s = with_limit(0);
|
||||
for wanted in [1000.0, 2560.0, 3024.0, 6000.0, 48000.0] {
|
||||
assert!(s.prefetch_edge(wanted, 6000) <= SourceLevel::Preview.max_edge(), "{wanted}");
|
||||
assert!(s.stand_in_edge(s.loupe_edge(wanted, 6000)) <= SourceLevel::Preview.max_edge());
|
||||
assert!(s.prefetch_edge(wanted, 6000, BIG) <= SourceLevel::Preview.max_edge(), "{wanted}");
|
||||
assert!(s.stand_in_edge(s.loupe_edge(wanted, 6000, BIG), BIG) <= SourceLevel::Preview.max_edge());
|
||||
}
|
||||
assert_eq!(s.prefetch_edge(1000.0, 6000), 1024, "a small view is not raised");
|
||||
assert_eq!(s.prefetch_edge(1000.0, 6000, BIG), 1024, "a small view is not raised");
|
||||
}
|
||||
|
||||
// Given Automatic, a small photo zoomed far in is not rendered above its own pixels
|
||||
#[test]
|
||||
fn never_renders_above_the_photos_own_pixels() {
|
||||
assert_eq!(with_limit(0).loupe_edge(8000.0, 1000), 1000);
|
||||
assert_eq!(with_limit(0).loupe_edge(8000.0, 1000, BIG), 1000);
|
||||
}
|
||||
|
||||
// Given Automatic, a huge zoom on a huge photo stays under the memory/GPU ceiling
|
||||
#[test]
|
||||
fn automatic_is_bounded_by_the_ceiling() {
|
||||
assert_eq!(with_limit(0).loupe_edge(48000.0, 12000), LOUPE_EDGE_CEILING as usize);
|
||||
assert_eq!(with_limit(0).loupe_edge(48000.0, 12000, BIG), LOUPE_EDGE_CEILING as usize);
|
||||
}
|
||||
|
||||
// Given the user chose 1600 px, the render never exceeds it
|
||||
#[test]
|
||||
fn an_explicit_limit_caps_the_render() {
|
||||
assert_eq!(with_limit(1600).loupe_edge(6000.0, 6000), 1600);
|
||||
assert_eq!(with_limit(1600).loupe_edge(900.0, 6000), 960, "and does not raise a small view past its step");
|
||||
assert_eq!(with_limit(1600).loupe_edge(6000.0, 6000, BIG), 1600);
|
||||
assert_eq!(with_limit(1600).loupe_edge(900.0, 6000, BIG), 960, "and does not raise a small view past its step");
|
||||
}
|
||||
|
||||
// Given a browser whose GPU allows 2048 px textures (egui_glow panics on a bigger one), no render
|
||||
// the loupe asks for is larger than that: the loupe, its neighbours' prefetch and the stand-ins
|
||||
#[test]
|
||||
fn nothing_is_rendered_larger_than_the_gpu_texture_side() {
|
||||
let s = with_limit(0);
|
||||
for tex in [512, 2048, 4096, 8192] {
|
||||
for wanted in [100.0, 2000.0, 6000.0, 48000.0, f32::INFINITY] {
|
||||
let e = s.loupe_edge(wanted, 12000, tex);
|
||||
assert!(e <= tex, "loupe {e} > {tex}");
|
||||
assert!(s.prefetch_edge(wanted, 12000, tex) <= tex);
|
||||
assert!(s.stand_in_edge(e, tex) <= tex);
|
||||
}
|
||||
}
|
||||
assert_eq!(s.loupe_edge(6000.0, 6000, 2048), 2048);
|
||||
}
|
||||
|
||||
// A host that reports nothing sensible gets the smallest safe sizes, never a panic or zero
|
||||
#[test]
|
||||
fn a_nonsense_texture_side_is_clamped() {
|
||||
let s = with_limit(0);
|
||||
for tex in [0, 1, 7] {
|
||||
let e = s.loupe_edge(1000.0, 6000, tex);
|
||||
assert!((8..=512).contains(&e), "{tex}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Whatever the inputs (hostile or degenerate), the result is a usable size
|
||||
@@ -72,11 +101,11 @@ fn an_explicit_limit_caps_the_render() {
|
||||
fn degenerate_inputs_give_a_usable_size() {
|
||||
let s = with_limit(0);
|
||||
for wanted in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -5.0, 0.0] {
|
||||
let e = s.loupe_edge(wanted, 6000);
|
||||
let e = s.loupe_edge(wanted, 6000, BIG);
|
||||
assert!((8..=LOUPE_EDGE_CEILING as usize).contains(&e), "{wanted}: {e}");
|
||||
}
|
||||
assert!((8..=LOUPE_EDGE_CEILING as usize).contains(&s.loupe_edge(1000.0, 0)) && s.prefetch_edge(f32::NAN, 0) >= 8);
|
||||
assert!(with_limit(7).loupe_edge(1000.0, 6000) >= 8, "a corrupt limit is clamped");
|
||||
assert!((8..=LOUPE_EDGE_CEILING as usize).contains(&s.loupe_edge(1000.0, 0, BIG)) && s.prefetch_edge(f32::NAN, 0, BIG) >= 8);
|
||||
assert!(with_limit(7).loupe_edge(1000.0, 6000, BIG) >= 8, "a corrupt limit is clamped");
|
||||
}
|
||||
|
||||
// Build Standard-Sized Previews needs a number even when the limit is Automatic
|
||||
@@ -295,4 +324,23 @@ mod in_the_loupe {
|
||||
h.settle(SETTLE);
|
||||
assert!(region_tile(&h).is_some(), "a window render");
|
||||
}
|
||||
|
||||
// Given a browser GPU that takes only 2048 px textures (egui_glow panics on a bigger one), when
|
||||
// I zoom to 1:1 and 8:1, then no texture the loupe holds is larger than that
|
||||
#[test]
|
||||
fn no_loupe_texture_exceeds_the_gpu_limit() {
|
||||
let (mut h, _) = detail();
|
||||
h.max_texture_side = 2048;
|
||||
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
|
||||
h.settle(SETTLE);
|
||||
for zoom in [crate::state::Zoom::Fit, crate::state::Zoom::Percent(100.0), crate::state::Zoom::Percent(800.0)] {
|
||||
h.app.ui.zoom = zoom;
|
||||
h.settle(SETTLE);
|
||||
for (slot, t) in &h.app.renderer.textures {
|
||||
assert!(t.size[0] <= 2048 && t.size[1] <= 2048, "{zoom:?} {slot:?}: {}×{}", t.size[0], t.size[1]);
|
||||
}
|
||||
let main = h.app.renderer.textures.get(&Slot::Main).expect("loupe");
|
||||
assert!(main.size[0].max(main.size[1]) >= 700, "still a real render, not a thumbnail: {:?}", main.size);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user