diff --git a/crates/ui-egui/src/about.rs b/crates/ui-egui/src/about.rs index d0323a999..3fe10eac5 100644 --- a/crates/ui-egui/src/about.rs +++ b/crates/ui-egui/src/about.rs @@ -52,7 +52,7 @@ pub fn paint_mark(ui: &egui::Ui, rect: Rect, color: Color32) { let mut res = vello_cpu::Resources::new(); rc.render(&mut pm, &mut res); let ci = egui::ColorImage::from_rgba_premultiplied([px as usize, px as usize], pm.data_as_u8_slice()); - let t = ctx.load_texture("artcraft_mark", ci, egui::TextureOptions::LINEAR); + let t = crate::widgets::load_texture(ctx, "artcraft_mark", ci, egui::TextureOptions::LINEAR); ctx.data_mut(|d| d.insert_temp(key, t.clone())); t }); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 24150ede7..ef5ed0ef4 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -587,6 +587,11 @@ fn patch_texture(app: &mut DesignApp, layout: &CanvasLayout, ppp: f64, doc_key: let Some(regions) = designcraft_render::damage::damage_with(&old, &new, Some(&app.session.cache)) else { return false }; let t0 = crate::now_ms(); let (tw, th) = ((sh.size.0 as f64 * ppp).round() as i64, (sh.size.1 as f64 * ppp).round() as i64); + // A texture `upload` sampled down to the GPU limit has other pixels than the view: a region + // computed in view pixels would be written outside it. Render it whole instead. + if app.canvas.texture.as_ref().is_none_or(|t| t.size() != [tw.max(0) as usize, th.max(0) as usize]) { + return false; + } let k = sh.zoom * ppp; let mut px: Option<(i64, i64, i64, i64)> = None; for (r, rect) in ®ions { @@ -667,6 +672,7 @@ fn upload(app: &mut DesignApp, ctx: &egui::Context, mut img: designcraft_render: designcraft_render::separation_view(&mut img, None, app.ui.ink_limit); } let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); + let ci = crate::widgets::fit_texture(ci, ctx.input(|i| i.max_texture_side)); match &mut app.canvas.texture { Some(tex) => tex.set(ci, egui::TextureOptions::LINEAR), None => app.canvas.texture = Some(ctx.load_texture("canvas", ci, egui::TextureOptions::LINEAR)), @@ -1336,7 +1342,7 @@ fn draw_inverse_highlight( let mut res = vello_cpu::Resources::new(); rc.render(&mut pm, &mut res); let ci = egui::ColorImage::from_rgba_premultiplied([w as usize, h as usize], pm.data_as_u8_slice()); - let t = ctx.load_texture("inverse_selection", ci, egui::TextureOptions::NEAREST); + let t = crate::widgets::load_texture(ctx, "inverse_selection", ci, egui::TextureOptions::NEAREST); ctx.data_mut(|d| d.insert_temp(cache_id, (key, t.clone()))); t } @@ -2238,6 +2244,38 @@ mod tests { (s.horizontal_units, s.vertical_units) } + /// A viewport wider than the GPU's largest texture: the canvas texture is sampled down, and an + /// edit repaints it whole. A region repainted in view pixels would land in the wrong place, + /// or outside the texture (a wgpu validation error). A view that fits is still patched. + #[test] + fn an_edit_repaints_a_canvas_texture_sampled_down_to_the_gpu_limit_whole() { + for (width, sampled_down) in [(1440.0, false), (2600.0, true)] { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let frame = app.run("frame.create", json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Text"})).unwrap(); + let mut h = Harness::builder().with_size(vec2(width, 900.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(6); + let limit = h.ctx.input(|i| i.max_texture_side); + let size = h.state().canvas.texture.as_ref().map(|t| t.size()).unwrap(); + assert!(size[0] <= limit && size[1] <= limit, "{size:?}"); + let shown = h.state().canvas.shown.unwrap().size; + assert_eq!(shown.0 > limit as f32, sampled_down, "view {shown:?}, limit {limit}"); + let patches = h.state().canvas.patches; + // A small edit: one more character in the frame. + h.state_mut().run("text.insert", json!({"story": frame["story"], "at": 4, "text": "s"})).unwrap(); + h.run_steps(6); + assert_eq!(h.state().canvas.patches == patches, sampled_down, "width {width}"); + assert_eq!(h.state().canvas.texture.as_ref().map(|t| t.size()), Some(size)); + } + } + #[test] fn selected_merged_owners_paint_the_same_row_and_column_targets() { for column in [true, false] { diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs index 63f02e9ac..61c9cb42d 100644 --- a/crates/ui-egui/src/panels/glyphs.rs +++ b/crates/ui-egui/src/panels/glyphs.rs @@ -76,7 +76,11 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let bar = scroll.bar_inner_margin + scroll.bar_width + scroll.bar_outer_margin; let cols = (((ui.available_width() - bar) / CELL).floor() as u32).max(1); let ppp = ui.ctx().pixels_per_point(); - let cell_px = (CELL * ppp).round() as u32; + let cell_px = (CELL * ppp).round().max(1.0) as u32; + // The grid is one texture: no more rows than the GPU's largest texture holds (a narrow + // panel of a large CJK font ran past it). + let max_rows = (ui.ctx().input(|i| i.max_texture_side) as u32 / cell_px).max(1); + chars.truncate((max_rows * cols) as usize); let ink = t.text_strong; let key = egui::Id::new(("glyph_grid", &st.family, &st.style, scope, &q, cols, cell_px, ink.to_array())); let tex: egui::TextureHandle = match ui.data(|d| d.get_temp::(key)) { @@ -84,7 +88,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { None => { let img = designcraft_render::glyphs::glyph_grid(&db, &st.family, &st.style, &chars, cols, cell_px, ink.to_array()); let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); - let h = ui.ctx().load_texture("glyph_grid", ci, egui::TextureOptions::LINEAR); + let h = crate::widgets::load_texture(ui.ctx(), "glyph_grid", ci, egui::TextureOptions::LINEAR); ui.data_mut(|d| d.insert_temp(key, h.clone())); h } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index e728aa94c..c88ae30a3 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -447,7 +447,7 @@ pub fn font_menu_body(app: &mut DesignApp, ui: &mut egui::Ui, menu: &[designcraf let tex = tex.unwrap_or_else(|| { let img = designcraft_render::glyphs::text_line(&fonts, f, "Regular", "Sample", (18.0 * ppp) as u32, t.text.to_array()); let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); - let h = ui.ctx().load_texture(format!("font_preview_{f}"), ci, egui::TextureOptions::LINEAR); + let h = crate::widgets::load_texture(ui.ctx(), format!("font_preview_{f}"), ci, egui::TextureOptions::LINEAR); ui.data_mut(|d| d.insert_temp(key, h.clone())); h }); diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index ad8a8ab86..2d6d56da2 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -34,7 +34,7 @@ fn thumb(app: &mut DesignApp, ctx: &egui::Context, abs: usize, h: f32) -> Option &designcraft_render::RenderOptions { printing_only: true, ..Default::default() }, )?; let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); - let tex = ctx.load_texture(format!("thumb{abs}"), ci, egui::TextureOptions::LINEAR); + let tex = crate::widgets::load_texture(ctx, format!("thumb{abs}"), ci, egui::TextureOptions::LINEAR); // Drop thumbnails of older revisions. let (uid, rev) = (st.uid, st.revision); map.retain(|k, _| !(k.0 == uid && k.1 != rev)); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index af5a7b46c..44316fda4 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -717,3 +717,49 @@ mod tests { assert_eq!(m.step(), 1.0); } } + +/// `img` made to fit the GPU's largest texture (`max_side` pixels a side), sampled down +/// (nearest pixel) when it is larger. A texture past the limit panics in the renderer, so every +/// UI texture goes through this: an oversized image loses resolution, not the document. +pub fn fit_texture(img: egui::ColorImage, max_side: usize) -> egui::ColorImage { + let [w, h] = img.size; + let max_side = max_side.max(1); + if w <= max_side && h <= max_side { + return img; + } + let k = (max_side as f64 / w.max(h) as f64).min(1.0); + let (nw, nh) = (((w as f64 * k).floor() as usize).clamp(1, max_side), ((h as f64 * k).floor() as usize).clamp(1, max_side)); + let mut pixels = Vec::with_capacity(nw * nh); + for y in 0..nh { + let sy = (y * h / nh).min(h.saturating_sub(1)); + for x in 0..nw { + let sx = (x * w / nw).min(w.saturating_sub(1)); + pixels.push(img.pixels.get(sy * w + sx).copied().unwrap_or(Color32::TRANSPARENT)); + } + } + egui::ColorImage::new([nw, nh], pixels) +} + +/// [`egui::Context::load_texture`] for an image of any size (see [`fit_texture`]). +pub fn load_texture(ctx: &egui::Context, name: impl Into, img: egui::ColorImage, options: egui::TextureOptions) -> egui::TextureHandle { + let max = ctx.input(|i| i.max_texture_side); + ctx.load_texture(name, fit_texture(img, max), options) +} + +#[cfg(test)] +mod texture_tests { + /// The glyph grid that crashed the renderer: 7 columns of 1200 characters at 60 px, 10320 px + /// tall against an 8192 px limit. + #[test] + fn oversized_images_fit_the_texture_limit() { + let img = egui::ColorImage::new([420, 10320], vec![egui::Color32::WHITE; 420 * 10320]); + let out = super::fit_texture(img, 8192); + assert!(out.size[0] <= 8192 && out.size[1] == 8192, "{:?}", out.size); + assert_eq!(out.pixels.len(), out.size[0] * out.size[1]); + let small = egui::ColorImage::new([10, 10], vec![egui::Color32::RED; 100]); + assert_eq!(super::fit_texture(small, 8192).size, [10, 10]); + let wide = egui::ColorImage::new([20000, 3], vec![egui::Color32::RED; 60000]); + let out = super::fit_texture(wide, 8192); + assert!(out.size[0] == 8192 && out.size[1] >= 1); + } +}