#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:
Nathaniel Ledford
2026-10-08 18:28:45 -04:00
co-authored by Claude Sonnet 5.5
parent 63cfc6ab70
commit b7f632f5c0
7 changed files with 134 additions and 45 deletions
+4
View File
@@ -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| {
+2 -1
View File
@@ -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);
+13 -5
View File
@@ -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 };
+2 -1
View File
@@ -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)
+38 -17
View File
@@ -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);
}
}
+13 -7
View File
@@ -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.
+62 -14
View File
@@ -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);
}
}
}