Compare commits

...
Author SHA1 Message Date
Tanish Manku 1b867ddeec fix(ui-egui): canvas textures fit the device's texture limit; GPU tests hold the GPU lock on the app's device
- The CPU canvas texture and the crop's beyond-canvas bands were reduced to fit MAX_TEXTURE (4096)
  whatever the device allows. On a device with a smaller limit (2048 on some older GPUs and WebGL
  contexts) a 2600 px document was uploaded at full size: egui asserts and the backend can't
  create it. They now fit the smaller of the two. Test: cpu_canvas_texture_fits_the_device_limit.
- background_redraw and navigator GPU tests built their harness with egui's default wgpu limits
  instead of the app's wgpu_setup (the adapter's own limits), and the GPU canvas lost the device
  on it ("Device is lost", every run on an RTX 5090). They now use wgpu_setup.
- Eleven tests built a wgpu device without photocraft_testkit::gpu_lock, which testkit requires so
  devices aren't created concurrently; they now hold it.
2026-10-14 20:54:02 -07:00
11 changed files with 76 additions and 27 deletions

No files matched your search

+15 -10
View File
@@ -41,16 +41,21 @@ fn gpu_app() -> Option<Gpu> {
let observer = Arc::new(std::sync::Mutex::new(None));
let observer_out = observer.clone();
let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).with_pixels_per_point(1.0).with_max_steps(64).wgpu().build_eframe(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
if let Some(rs) = cc.wgpu_render_state.as_ref() {
*observer_out.lock().expect("observer") = Some(GpuCanvas::new(rs));
app.set_wgpu(rs.clone());
}
app.background_jobs = true;
app
})
egui_kittest::Harness::builder()
.with_size(egui::vec2(1000.0, 700.0))
.with_pixels_per_point(1.0)
.with_max_steps(64)
.wgpu_setup(crate::gpu_canvas::wgpu_setup())
.build_eframe(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
if let Some(rs) = cc.wgpu_render_state.as_ref() {
*observer_out.lock().expect("observer") = Some(GpuCanvas::new(rs));
app.set_wgpu(rs.clone());
}
app.background_jobs = true;
app
})
}));
let Ok(harness) = built else {
eprintln!("skipping: no GPU adapter");
+31 -3
View File
@@ -15,6 +15,13 @@ use crate::state::{Tool, View};
/// Largest texture side we upload; bigger documents display downsampled until the GPU path lands.
pub const MAX_TEXTURE: u32 = 4096;
/// The largest texture side to upload on this device: [`MAX_TEXTURE`], or less where the GPU
/// allows less (2048 on some older or low-end GPUs and WebGL contexts). Past the device's limit
/// egui asserts and the backend fails to create the texture.
fn texture_side(ctx: &egui::Context) -> u32 {
u32::try_from(ctx.input(|i| i.max_texture_side)).unwrap_or(MAX_TEXTURE).clamp(1, MAX_TEXTURE)
}
pub(crate) struct ClonePreviewCache {
key: String,
texture: egui::TextureHandle,
@@ -1421,7 +1428,7 @@ pub fn ensure_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize,
if cache.tex_revision != revision || cache.texture.is_none() || cache.tex_preview_key != preview_key {
let t0 = crate::gpu_canvas::now_ms();
let longest = doc.size.width.max(doc.size.height);
let factor = longest.div_ceil(MAX_TEXTURE).max(1);
let factor = longest.div_ceil(texture_side(ctx)).max(1);
let (w, h) = ((doc.size.width / factor).max(1), (doc.size.height / factor).max(1));
let current = cache.texture.as_mut().filter(|t| t.size() == [w as usize, h as usize]);
if let (Some(d), Some(t)) = (damage, current) {
@@ -4504,8 +4511,8 @@ fn draw_beyond_canvas(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &Vie
None => {
let t0 = crate::gpu_canvas::now_ms();
let extent = photocraft_engine::extra_cmds::reveal_all_bounds(&doc);
// One texel per `k` document pixels keeps every texture within MAX_TEXTURE.
let k = extent.width().max(extent.height()).div_ceil(MAX_TEXTURE).max(1);
// One texel per `k` document pixels keeps every texture within the device's side.
let k = extent.width().max(extent.height()).div_ceil(texture_side(&ctx)).max(1);
let proxy = (k > 1).then(|| photocraft_compose::proxy::proxy_document(&doc, k));
let src = proxy.as_ref().unwrap_or(&*doc);
let k = k as i32;
@@ -6483,6 +6490,25 @@ mod tests {
assert_eq!(uploads(&ctx), vec![(true, crisp)], "a partial update keeps the crisp sampling");
}
/// A device that allows smaller textures than MAX_TEXTURE (2048 on some older GPUs and WebGL
/// contexts, and on the fallback adapter the GPU tests sometimes get under load): the canvas
/// texture is reduced to fit it. A 2600 px document was uploaded at full size, which egui
/// asserts on and the backend can't create.
#[test]
fn cpu_canvas_texture_fits_the_device_limit() {
for (side, (w, h), want) in [(2048, (2600, 1700), [1300, 850]), (2048, (5000, 2000), [1666, 666]), (8192, (5000, 2000), [2500, 1000])] {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": w, "height": h})).unwrap();
let ctx = egui::Context::default();
ctx.input_mut(|i| i.max_texture_side = side);
let (tex, _) = ensure_texture(&mut app, &ctx, 0, None).unwrap();
let mut delta = ctx.tex_manager().write().take_delta();
let sizes: Vec<_> = delta.set.get(&tex).into_iter().flatten().map(|d| d.image.size()).collect();
delta.clear();
assert_eq!(sizes, vec![want], "{w}x{h} on a {side} px device");
}
}
#[test]
fn cpu_canvas_of_a_reduced_document_stays_bilinear() {
// Over MAX_TEXTURE the texture holds one texel per `factor` pixels: a texel is not a
@@ -6500,6 +6526,7 @@ mod tests {
#[test]
fn gpu_display_luts_are_per_display() {
let _gpu = photocraft_testkit::gpu_lock();
let Ok(rs) = std::panic::catch_unwind(|| egui_kittest::wgpu::create_render_state(crate::gpu_canvas::wgpu_setup(), Default::default())) else {
eprintln!("skipping: no GPU adapter");
return;
@@ -7418,6 +7445,7 @@ mod tests {
// must update only the reduced pixels it touched.
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
let ctx = egui::Context::default();
ctx.input_mut(|i| i.max_texture_side = 8192);
app.run("file.new", json!({"width": 4097, "height": 90, "background": "transparent"})).unwrap();
let tilt = json!({"size": 30, "hardness": 1.0, "spacing": 0.05, "shapeDynamics": {"enabled": true, "size": {"control": "penTilt"}}});
app.run("tools.setBrush", json!({ "brush": tilt })).unwrap();
@@ -83,6 +83,7 @@ fn alt_click_clipping_still_creates_releases_and_undoes_without_moving_rows() {
#[test]
#[ignore = "writes synthetic English Layers-panel screenshots for before/after inspection"]
fn write_clipping_indicator_preview() {
let _gpu = photocraft_testkit::gpu_lock();
let output = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../plan/evidence/clipping");
std::fs::create_dir_all(&output).unwrap();
for theme in crate::theme::ThemeKind::ALL {
@@ -7,6 +7,7 @@ use std::{cell::RefCell, rc::Rc};
type Writes = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
fn tiff_prompt(command: &str) -> (Prompted, Writes, DocId, DocId) {
let _gpu = photocraft_testkit::gpu_lock();
let mut app = app_with_docs(2);
make_dirty(&mut app, 0);
let (saved, other) = (doc_id(&app, 0), doc_id(&app, 1));
@@ -51,6 +51,7 @@ fn read_mips(gpu: &GpuCanvas, key: u64) -> Vec<Vec<[f32; 4]>> {
#[test]
fn canvas_mips_preserve_opaque_pixels_and_partial_updates() {
let _gpu = photocraft_testkit::gpu_lock();
let rs = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| egui_kittest::wgpu::create_render_state(wgpu_setup(), Default::default())));
let Ok(rs) = rs else {
eprintln!("skipping mip readback: no GPU adapter");
+20 -14
View File
@@ -9,20 +9,25 @@ type Harness = egui_kittest::Harness<'static, PhotocraftApp>;
fn harness() -> Option<Harness> {
let built = std::panic::catch_unwind(|| {
egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_pixels_per_point(1.0).with_max_steps(64).wgpu().build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
if let Some(rs) = cc.wgpu_render_state.as_ref() {
app.set_wgpu(rs.clone());
}
app.ui.panels.navigator = true;
app.run("file.new", json!({"width": 400, "height": 300, "background": "white"})).unwrap();
app.run("select.rect", json!({"x": 0, "y": 0, "width": 200, "height": 300})).unwrap();
app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
app.run("select.deselect", json!({})).unwrap();
app.sync_views();
app
})
egui_kittest::Harness::builder()
.with_size(egui::vec2(1400.0, 900.0))
.with_pixels_per_point(1.0)
.with_max_steps(64)
.wgpu_setup(crate::gpu_canvas::wgpu_setup())
.build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
if let Some(rs) = cc.wgpu_render_state.as_ref() {
app.set_wgpu(rs.clone());
}
app.ui.panels.navigator = true;
app.run("file.new", json!({"width": 400, "height": 300, "background": "white"})).unwrap();
app.run("select.rect", json!({"x": 0, "y": 0, "width": 200, "height": 300})).unwrap();
app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
app.run("select.deselect", json!({})).unwrap();
app.sync_views();
app
})
});
match built {
Ok(h) => Some(h),
@@ -47,6 +52,7 @@ fn near(a: [u8; 4], b: [u8; 3]) -> bool {
#[test]
fn navigator_draws_the_gpu_canvas_texture_and_follows_edits() {
let _gpu = photocraft_testkit::gpu_lock();
let Some(mut h) = harness() else { return };
h.run_steps(8);
if !h.state().perf.gpu {
@@ -175,6 +175,7 @@ fn shared_layout_keeps_panel_identity_across_themes_and_respects_workspace_lock(
/// Explicit offscreen evidence; this creates no native windows.
#[test]
fn capture_custom_panel_docking_visual_fixtures() {
let _gpu = photocraft_testkit::gpu_lock();
let Some(directory) = std::env::var_os("CRAFT_UI_DOCKING_FIXTURES").map(std::path::PathBuf::from) else { return };
std::fs::create_dir_all(&directory).unwrap();
for theme in crate::theme::ThemeKind::ALL {
@@ -211,6 +212,7 @@ fn capture_custom_panel_docking_visual_fixtures() {
#[test]
fn floating_group_titles_are_opaque_in_studio_themes() {
let _gpu = photocraft_testkit::gpu_lock();
// Studio's tab strip is transparent over the dock's card; a floating group must not show the canvas (#4509).
let mut app = app();
app.run("window.panel.float", json!({"panel":"swatches","x":40,"y":60,"width":300,"height":300})).unwrap();
@@ -346,6 +348,7 @@ fn splitting_a_short_column_keeps_the_layers_footer_visible_and_usable() {
#[test]
fn populated_canvas_keeps_a_real_opacity_edit_after_floating_and_redocking() {
let _gpu = photocraft_testkit::gpu_lock();
let mut app = app();
app.ui.theme = crate::theme::ThemeKind::Studio;
app.run("file.new", json!({"width":480,"height":360})).unwrap();
+1
View File
@@ -5901,6 +5901,7 @@ mod compact_visual_regressions {
#[test]
fn capture_compact_visual_fixtures() {
let _gpu = photocraft_testkit::gpu_lock();
let Some(directory) = std::env::var_os("CRAFT_UI_VISUAL_FIXTURES").map(std::path::PathBuf::from) else { return };
std::fs::create_dir_all(&directory).unwrap();
for kind in crate::theme::ThemeKind::ALL {
+1
View File
@@ -66,6 +66,7 @@ mod tests {
#[test]
fn rendered_glyph_has_a_left_head_and_a_lower_right_head() {
let _gpu = photocraft_testkit::gpu_lock();
let mut h = egui_kittest::Harness::builder().with_size(vec2(32.0, 32.0)).wgpu().build_ui(|ui| {
paint(ui.painter(), pos2(8.0, 8.0), Color32::WHITE);
});
+1
View File
@@ -1130,6 +1130,7 @@ fn temporary_type_transform_captures_press_and_rejects_collapsed_geometry() {
#[test]
#[ignore = "writes visual evidence to target/issue-1091; run explicitly for visual QA"]
fn temporary_type_transform_visual_evidence() {
let _gpu = photocraft_testkit::gpu_lock();
let out = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/issue-1091");
std::fs::create_dir_all(&out).unwrap();
for (index, theme) in crate::theme::ThemeKind::ALL.into_iter().enumerate() {
+1
View File
@@ -1926,6 +1926,7 @@ mod tests {
/// `%`'s, so the number lines up with the box and its unit (#5150).
#[test]
fn value_field_digits_ink_is_centred_and_level_with_the_unit() {
let _gpu = photocraft_testkit::gpu_lock();
let scale = 4.0;
for kind in [crate::theme::ThemeKind::Pro, crate::theme::ThemeKind::Studio] {
let mut h = Harness::builder().with_size(egui::vec2(100.0, 40.0)).with_pixels_per_point(scale).wgpu().build_ui_state(