diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index b803a36c8..41a55651b 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -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| { diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs index 8e252e906..77ee4242f 100644 --- a/crates/ui-egui/src/panels/compare.rs +++ b/crates/ui-egui/src/panels/compare.rs @@ -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); diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index fb6d9115f..8242848bc 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -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 }; diff --git a/crates/ui-egui/src/panels/second.rs b/crates/ui-egui/src/panels/second.rs index 775a922d4..ac239bcdd 100644 --- a/crates/ui-egui/src/panels/second.rs +++ b/crates/ui-egui/src/panels/second.rs @@ -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) diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index 2b961c958..f59cb1f9b 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -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 { +/// 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 { 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); } } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index f7ba3cb6c..ca4794609 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -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. diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index 4ee9efc04..1b5cc9d58 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -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); + } + } }