From 2e242bde67c202e4cd16bf3d6f88088b521ab423 Mon Sep 17 00:00:00 2001 From: Auros <79503886+StopCodeToTrain@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:41:01 +0800 Subject: [PATCH 01/47] Fix Export As freeze on large layered PSDs (#5104) Co-authored-by: luy --- crates/ui-egui/src/export_dialog.rs | 243 +++++++++++++++++++++++++--- 1 file changed, 217 insertions(+), 26 deletions(-) diff --git a/crates/ui-egui/src/export_dialog.rs b/crates/ui-egui/src/export_dialog.rs index 8fa630049ab..6cb0e310df7 100644 --- a/crates/ui-egui/src/export_dialog.rs +++ b/crates/ui-egui/src/export_dialog.rs @@ -3,6 +3,8 @@ //! pixel count, so the dialog stays instant on 36 MP documents. use std::sync::Arc; +#[cfg(not(target_arch = "wasm32"))] +use std::sync::Mutex; use photocraft_doc::Document; use serde_json::{Map, Value, json}; @@ -187,14 +189,17 @@ fn batch_paths(path: &str, docs: &[Document]) -> Vec { } /// The document a dialog exports: the whole image, or one layer (`__layer`). -fn source_document(app: &PhotocraftApp, f: &Map) -> Result, String> { - let st = app.session.active().ok_or("no document")?; +fn source_document_from(doc: Arc, f: &Map) -> Result, String> { match f.get("__layer").and_then(Value::as_u64) { - Some(id) => photocraft_engine::layer_menu_cmds::layer_document(&st.doc, photocraft_doc::LayerId(id)).map(Arc::new).map_err(|e| e.to_string()), - None => Ok(st.doc.clone()), + Some(id) => photocraft_engine::layer_menu_cmds::layer_document(&doc, photocraft_doc::LayerId(id)).map(Arc::new).map_err(|e| e.to_string()), + None => Ok(doc), } } +fn source_document(app: &PhotocraftApp, f: &Map) -> Result, String> { + source_document_from(app.session.active().ok_or("no document")?.doc.clone(), f) +} + fn s(f: &Map, k: &str) -> String { f.get(k).and_then(Value::as_str).unwrap_or_default().to_string() } @@ -281,16 +286,142 @@ fn settings(f: &Map) -> ExportSettings { } } -/// Estimated size (bytes) from a ≤512 px proxy encode, scaled by pixel count. -fn estimate(app: &PhotocraftApp, doc: &Document, f: &Map) -> Option { - let export = app.services.export.as_ref()?; - let max = if s_fmt(f) == "ico" { 256 } else { 512 }; - let proxy = export_document(doc, f, Some(max)).ok()?; - let (bytes, _) = export(&proxy, &format!("estimate.{}", s_fmt(f)), &settings(f)).ok()?; +/// The preview and size estimate share a single reduced document. Resizing every layer twice is +/// especially expensive for layered PSDs, even when the canvas itself is small. +fn preview_proxy(doc: &Document, f: &Map) -> Result { + export_document(doc, f, Some(if s_fmt(f) == "ico" { 256 } else { 512 })) +} + +fn scaled_estimate(bytes: usize, doc: &Document, proxy: &Document, f: &Map) -> u64 { let scale = n(f, "scale", 100.0) / 100.0; let full = doc.size.width as f64 * scale * doc.size.height as f64 * scale; let small = (proxy.size.width as f64 * proxy.size.height as f64).max(1.0); - Some((bytes.len() as f64 * full / small) as u64) + (bytes as f64 * full / small) as u64 +} + +/// Estimated size (bytes) from the already prepared proxy encode, scaled by pixel count. +fn estimate(app: &PhotocraftApp, doc: &Document, proxy: &Document, f: &Map) -> Option { + let export = app.services.export.as_ref()?; + let (bytes, _) = export(proxy, &format!("estimate.{}", s_fmt(f)), &settings(f)).ok()?; + Some(scaled_estimate(bytes.len(), doc, proxy, f)) +} + +struct PreviewPixels { + estimate: Option, + width: usize, + height: usize, + rgba: Vec, +} + +fn preview_pixels(proxy: &Document, estimate: Option) -> PreviewPixels { + let img = photocraft_compose::thumbnail(proxy, 360); + PreviewPixels { estimate, width: img.width as usize, height: img.height as usize, rgba: img.pixels } +} + +fn preview_sync(app: &PhotocraftApp, doc: &Document, f: &Map) -> Result { + let proxy = preview_proxy(doc, f)?; + Ok(preview_pixels(&proxy, estimate(app, doc, &proxy, f))) +} + +#[derive(Clone)] +struct CachedPreview { + signature: String, + estimate: Option, + texture: Option>, +} + +fn cache_preview(ui: &mut egui::Ui, key: egui::Id, signature: String, result: Result) -> CachedPreview { + let preview = match result { + Ok(pixels) => { + let image = egui::ColorImage::from_rgba_unmultiplied([pixels.width, pixels.height], &pixels.rgba); + let texture = Arc::new(ui.ctx().load_texture("export-preview", image, egui::TextureOptions::LINEAR)); + CachedPreview { signature, estimate: pixels.estimate, texture: Some(texture) } + } + Err(error) => { + log::warn!("Export As preview failed: {error}"); + CachedPreview { signature, estimate: None, texture: None } + } + }; + ui.data_mut(|d| d.insert_temp(key, preview.clone())); + preview +} + +#[cfg(not(target_arch = "wasm32"))] +#[derive(Clone)] +struct PendingPreview { + key: egui::Id, + signature: String, + result: Arc>>>, +} + +#[cfg(not(target_arch = "wasm32"))] +fn background_preview_needed(doc: &Document) -> bool { + doc.walk().len() > 32 || doc.size.area() > 16_000_000 +} + +/// Native app: keep the UI responsive while a many-layer document is reduced and encoded. +/// A single pending job per document also avoids starting a worker for every slider tick. +#[cfg(not(target_arch = "wasm32"))] +fn poll_or_start_preview( + ui: &mut egui::Ui, + key: egui::Id, + signature: &str, + doc: Arc, + fields: &Map, +) -> Option> { + let pending_key = egui::Id::new(("export-preview-pending", doc.id.0)); + let pending: Option = ui.data(|d| d.get_temp(pending_key)); + if let Some(pending) = pending { + let result = pending.result.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take()?; + ui.data_mut(|d| d.remove::(pending_key)); + if pending.key == key && pending.signature == signature { + return Some(result); + } + } + + let result = Arc::new(Mutex::new(None)); + let shared = result.clone(); + let ctx = ui.ctx().clone(); + let fields = fields.clone(); + let started = std::thread::Builder::new().name("export-preview".into()).spawn(move || { + let prepared = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let source = source_document_from(doc.clone(), &fields).unwrap_or(doc); + let proxy = preview_proxy(&source, &fields)?; + let estimate = estimate_native(&source, &proxy, &fields); + Ok::<_, String>(preview_pixels(&proxy, estimate)) + })) + .unwrap_or_else(|_| Err("preview worker panicked".into())); + *shared.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(prepared); + ctx.request_repaint(); + }); + match started { + Ok(_) => { + ui.data_mut(|d| d.insert_temp(pending_key, PendingPreview { key, signature: signature.to_string(), result })); + None + } + Err(error) => Some(Err(format!("could not start preview worker: {error}"))), + } +} + +/// The native exporter uses this same IO encoder. Keep the entire estimate off the UI thread. +#[cfg(not(target_arch = "wasm32"))] +fn estimate_native(doc: &Document, proxy: &Document, f: &Map) -> Option { + let settings = settings(f); + let mut options = photocraft_io::ExportOptions::default(); + if let Some(quality) = settings.jpeg_quality { + options.encode.jpeg_quality = quality; + } + options.encode.webp_lossless = settings.webp_lossless; + if let Some(quality) = settings.webp_quality { + options.encode.webp_quality = quality; + } + options.tiff_layers = settings.tiff_layers; + options.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None }; + options.tga_bits = settings.tga_bits; + options.image_previews = settings.image_previews; + options.psd_maximize_compatibility = settings.psd_maximize_compatibility; + let bytes = photocraft_io::export(proxy, &format!("estimate.{}", s_fmt(f)), &options).ok()?.bytes; + Some(scaled_estimate(bytes.len(), doc, proxy, f)) } pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) { @@ -374,28 +505,40 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map, Arc)> = ui.data(|d| d.get_temp(key)); - let (size, tex) = match cached.filter(|c| c.0 == sig) { - Some((_, size, tex)) => (size, tex), + let cached: Option = ui.data(|d| d.get_temp(key)); + let preview = match cached.filter(|cached| cached.signature == sig) { + Some(cached) => Some(cached), None => { - let src = source_document(app, f).unwrap_or_else(|_| doc.clone()); - let size = estimate(app, &src, f); - let img = photocraft_compose::thumbnail(&export_document(&src, f, Some(360)).unwrap_or_else(|_| (*src).clone()), 360); - let color = egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.pixels); - let tex = Arc::new(ui.ctx().load_texture("export-preview", color, egui::TextureOptions::LINEAR)); - ui.data_mut(|d| d.insert_temp(key, (sig, size, tex.clone()))); - (size, tex) + #[cfg(not(target_arch = "wasm32"))] + let result = if background_preview_needed(&doc) { + poll_or_start_preview(ui, key, &sig, doc.clone(), f) + } else { + let source = source_document(app, f).unwrap_or_else(|_| doc.clone()); + Some(preview_sync(app, &source, f)) + }; + #[cfg(target_arch = "wasm32")] + let result = { + let source = source_document(app, f).unwrap_or_else(|_| doc.clone()); + Some(preview_sync(app, &source, f)) + }; + result.map(|result| cache_preview(ui, key, sig, result)) } }; let side = 300.0; let (r, _) = ui.allocate_exact_size(egui::vec2(side, side), egui::Sense::hover()); crate::widgets::checker(ui.painter(), r, 8.0); - let ts = tex.size_vec2(); - let k = (side / ts.x.max(ts.y)).min(1.0e3); - let ir = egui::Rect::from_center_size(r.center(), ts * k); - ui.painter().image(tex.id(), ir, egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), egui::Color32::WHITE); + if let Some(tex) = preview.as_ref().and_then(|preview| preview.texture.as_ref()) { + let ts = tex.size_vec2(); + let k = (side / ts.x.max(ts.y)).min(1.0e3); + let ir = egui::Rect::from_center_size(r.center(), ts * k); + ui.painter().image(tex.id(), ir, egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), egui::Color32::WHITE); + } else if preview.is_none() { + ui.put(egui::Rect::from_center_size(r.center(), egui::vec2(24.0, 24.0)), egui::Spinner::new()); + } else { + ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, tl!("Preview"), egui::FontId::proportional(12.0), t.text_dim); + } ui.painter().rect_stroke(r, 0.0, egui::Stroke::new(1.0, t.separator), egui::StrokeKind::Outside); - let est = size.map_or("—".to_string(), |b| crate::sizing::human_bytes(b as f64)); + let est = preview.as_ref().and_then(|preview| preview.estimate).map_or("—".to_string(), |b| crate::sizing::human_bytes(b as f64)); ui.label(egui::RichText::new(format!("{} ≈ {est}", s_fmt(f).to_uppercase())).color(t.text_dim).size(11.5)); }); }); @@ -656,6 +799,54 @@ mod tests { assert_eq!(scaled_size(10.0, 300.0, 0.01), (1.0, 3.0)); } + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn layered_preview_runs_outside_the_ui_frame() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 32, "height": 16})).unwrap(); + for _ in 0..33 { + app.run("layer.new.layer", json!({})).unwrap(); + } + let doc = app.session.active().unwrap().doc.clone(); + assert!(background_preview_needed(&doc)); + let fields = Map::from_iter([("format".into(), json!("png")), ("scale".into(), json!(100))]); + let ctx = egui::Context::default(); + let key = egui::Id::new("layered-preview-test"); + let mut result = None; + let mut output = ctx.run_ui(Default::default(), |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + result = poll_or_start_preview(ui, key, "png100", doc.clone(), &fields); + }); + }); + output.textures_delta.clear(); + assert!(result.is_none(), "the first UI frame must only start the worker"); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + let mut output = ctx.run_ui(Default::default(), |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + result = poll_or_start_preview(ui, key, "png100", doc.clone(), &fields); + }); + }); + output.textures_delta.clear(); + if let Some(preview) = result { + let preview = preview.unwrap(); + assert_eq!((preview.width, preview.height), (32, 16)); + assert_eq!(preview.rgba.len(), 32 * 16 * 4); + assert!(preview.estimate.is_some_and(|bytes| bytes > 0)); + break; + } + assert!(std::time::Instant::now() < deadline, "background preview did not complete"); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + let tiff_fields = Map::from_iter([("format".into(), json!("tif")), ("scale".into(), json!(100))]); + let proxy = preview_proxy(&doc, &tiff_fields).unwrap(); + let options = photocraft_io::ExportOptions { tiff_layers: true, xmp: photocraft_io::XmpEmbed::None, ..Default::default() }; + let actual_bytes = photocraft_io::export(&proxy, "estimate.tif", &options).unwrap().bytes.len(); + assert_eq!(estimate_native(&doc, &proxy, &tiff_fields), Some(scaled_estimate(actual_bytes, &doc, &proxy, &tiff_fields))); + } + #[test] fn switching_export_formats_uses_independent_quality_preferences() { let prefs = photocraft_engine::prefs::Export { jpeg_quality: 43, webp_quality: 72, webp_lossless: false, ..Default::default() }; From 523f1f06bc8b9f8fd66cef4238de5f2f972dd502 Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 18:41:08 +0200 Subject: [PATCH 02/47] =?UTF-8?q?Preferences=20=E2=80=BA=20Interface:=20Ph?= =?UTF-8?q?otoshop's=20Appearance=20/=20Presentation=20/=20Options=20layou?= =?UTF-8?q?t=20(#2532)=20(#2589)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Preferences › Interface: Photoshop's Appearance / Presentation / Options layout (#2532) Replace the ~300 px theme preview cards with Color Theme swatches (a light and a dark row, since we follow the system appearance) and group the page like Photoshop: Appearance (mode, themes, canvas colour and border), Presentation (UI language, font size, scaling) and Options (checkboxes). At 1000×750 the language now shows without scrolling, in Photoshop's place rather than moved to the top. The swatch rows come from LIGHT_THEME_OPTIONS / DARK_THEME_OPTIONS, so Breeze Light and Breeze Dark show up. Each swatch keeps the favourite star (#2445) as a badge on its corner, shown on hover and kept for a favourite, with the same "Add to favorites: " / "Remove from favorites: " labels. Preferences search filters the grouped page: rows that don't match are hidden and a group left empty is skipped. Co-Authored-By: Claude Opus 5.5 (1M context) * Preferences: rustfmt the grouped Interface page Co-Authored-By: Claude Opus 5.5 (1M context) * Tests: 32-bit destructive Levels keep the HDR range (main is red since #2592) #2592 made float Levels keep values outside 0..1 and updated compose's levels_dont_clip_in_32_bit, but the engine-side copy still expected 0 and 255 at 32 bits, so test (ubuntu/windows) and corpus tests fail on main. Co-Authored-By: Claude Opus 5.5 (1M context) (cherry picked from commit 1ebaf7612e9ee013399cf3b4551f4094f63e2f04) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sr.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/prefs_ui.rs | 564 ++++++++++++++++------------ 21 files changed, 354 insertions(+), 230 deletions(-) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 9ee090d8302..037363d1dc7 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3510,6 +3510,7 @@ curves Smooth Vyhladit Paint over hair or fur on the image; Alt paints the other mode Malujte přes vlasy nebo srst v obraze; Alt maluje v opačném režimu Clear Brush Strokes Vymazat tahy štětce Show rulers Zobrazit pravítka + Presentation Prezentace # Color Sampler (added with the i18n coverage repair) Click to place · Alt-click or drag off-canvas to remove Kliknutím umístíte · Alt-kliknutím nebo přetažením mimo plátno odeberete diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 649daa5d863..8be365543ca 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2702,6 +2702,7 @@ curves Smooth Glätten Paint over hair or fur on the image; Alt paints the other mode Über Haare oder Fell im Bild malen; Alt malt im anderen Modus Clear Brush Strokes Pinselstriche löschen Show rulers Lineale anzeigen + Presentation Präsentation # Color Sampler (added with the i18n coverage repair) Click to place · Alt-click or drag off-canvas to remove Klicken zum Platzieren · Alt-Klick oder Ziehen außerhalb der Arbeitsfläche zum Entfernen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index ab76e427895..6f2b9bb9511 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3758,6 +3758,7 @@ curves Smooth Εξομάλυνση Paint over hair or fur on the image; Alt paints the other mode Βάψτε πάνω σε μαλλιά ή γούνα στην εικόνα· το Alt βάφει με την άλλη λειτουργία Clear Brush Strokes Απαλοιφή πινελιών Show rulers Εμφάνιση χαράκων + Presentation Παρουσίαση # Color Sampler (added with the i18n coverage repair) Click to place · Alt-click or drag off-canvas to remove Κλικ για τοποθέτηση · Alt-κλικ ή σύρσιμο εκτός καμβά για αφαίρεση diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 5de43b5b456..e2284c70f4c 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2743,6 +2743,7 @@ curves Smooth Suavizar Paint over hair or fur on the image; Alt paints the other mode Pinte sobre el pelo o el pelaje de la imagen; Alt pinta en el otro modo Clear Brush Strokes Borrar trazos de pincel Show rulers Mostrar reglas + Presentation Presentación # Color Sampler (added with the i18n coverage repair) Click to place · Alt-click or drag off-canvas to remove Clic para colocar · Alt-clic o arrastrar fuera del lienzo para quitar diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 4925a590702..c51aa6aa84b 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3076,6 +3076,7 @@ curves Smooth Lisser Paint over hair or fur on the image; Alt paints the other mode Peignez sur les cheveux ou la fourrure dans l'image ; Alt peint dans l'autre mode Clear Brush Strokes Effacer les traits de pinceau Show rulers Afficher les règles + Presentation Présentation # Color Sampler (added with the i18n coverage repair) Click to place · Alt-click or drag off-canvas to remove Cliquez pour placer · Alt-clic ou glisser hors du canevas pour supprimer diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index ab85d1dfd20..4e9a4fbe3ce 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1715,6 +1715,7 @@ layerLabel Magenta Bíbor Always use pressure for opacity Mindig nyomásfüggő átlátszatlanság Always use pressure for size Mindig nyomásfüggő méret Appearance Mode Megjelenési mód + Presentation Megjelenítés Auto Erase Automatikus radírozás Auto Hide Automatikus elrejtés Auto-Select: Automatikus kijelölés: diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 6e8de38ba7e..10d4ef2053f 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3063,6 +3063,7 @@ curves Smooth Haluskan Paint over hair or fur on the image; Alt paints the other mode Lukis di atas rambut atau bulu pada gambar; Alt melukis dalam mode lainnya Clear Brush Strokes Hapus Goresan Kuas Show rulers Tampilkan Penggaris + Presentation Presentasi # Color Sampler (added with the i18n coverage repair) Click to place · Alt-click or drag off-canvas to remove Klik untuk menempatkan · Alt-klik atau seret ke luar kanvas untuk menghapus diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index d27912a1b1f..7358e966e65 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2731,6 +2731,7 @@ curves Smooth Leviga Paint over hair or fur on the image; Alt paints the other mode Dipingi su capelli o pelo nell'immagine; Alt dipinge nell'altra modalità Clear Brush Strokes Cancella tracce del pennello Show rulers Mostra righelli + Presentation Presentazione # Color Sampler (added with the i18n coverage repair) Click to place · Alt-click or drag off-canvas to remove Fai clic per posizionare · Alt-clic o trascina fuori dal quadro per rimuovere diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index ffde2fd5e4c..f5d6104ea70 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3669,6 +3669,7 @@ curves Smooth スムーズ Paint over hair or fur on the image; Alt paints the other mode 画像の髪や毛の上をペイントします。Alt で反対のモードでペイント Clear Brush Strokes ブラシストロークを消去 Show rulers 定規を表示 + Presentation プレゼンテーション # Color Sampler (added with the i18n coverage repair) Click to place · Alt-click or drag off-canvas to remove クリックで配置 · Alt キーを押しながらクリック、またはカンバス外へドラッグで削除 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 038888f0a96..0547c561815 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3248,6 +3248,7 @@ curves Smooth 매끄럽게 Paint over hair or fur on the image; Alt paints the other mode 이미지의 머리카락이나 털 위를 칠합니다. Alt는 다른 모드로 칠합니다 Clear Brush Strokes 브러시 획 지우기 Show rulers 눈금자 표시 + Presentation 프레젠테이션 # Color Sampler (added with the i18n coverage repair) Click to place · Alt-click or drag off-canvas to remove 클릭하여 배치 · Alt-클릭 또는 캔버스 밖으로 드래그하여 제거 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 22adb12a20e..66db0dba6ad 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3820,6 +3820,7 @@ curves Smooth Vloeiend maken Include in playback Opnemen in afspelen Scale: Schaal: Snap to Origin Uitlijnen op oorsprong + Presentation Presentatie # Color Sampler (added with the i18n coverage repair) Click to place · Alt-click or drag off-canvas to remove Klik om te plaatsen · Alt-klik of sleep buiten het canvas om te verwijderen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index a366eaba0f7..d8df0afc9d1 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3672,6 +3672,7 @@ curves Smooth Wygładź Paint over hair or fur on the image; Alt paints the other mode Maluj po włosach lub sierści na obrazie; Alt maluje w drugim trybie Clear Brush Strokes Wyczyść pociągnięcia pędzla Show rulers Pokaż linijki + Presentation Prezentacja # Color Sampler (added with the i18n coverage repair) Click to place · Alt-click or drag off-canvas to remove Kliknij, aby umieścić · Alt-kliknij lub przeciągnij poza obszar roboczy, aby usunąć diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 9a112170e61..5f76c764983 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3081,6 +3081,7 @@ curves Smooth Suavizar Paint over hair or fur on the image; Alt paints the other mode Pinte sobre cabelo ou pelo na imagem; Alt pinta no outro modo Clear Brush Strokes Limpar traços do pincel Show rulers Mostrar réguas + Presentation Apresentação # Color Sampler (added with the i18n coverage repair) Click to place · Alt-click or drag off-canvas to remove Clique para posicionar · Alt-clique ou arraste para fora da tela para remover diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 697bbd9c5a3..b99fed9343a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3005,6 +3005,7 @@ curves Smooth Сгладить Select the crop presets to delete Выберите наборы параметров кадрирования для удаления Enter a name for the crop preset Введите имя набора параметров кадрирования There is no crop size or ratio to save Нет размера или соотношения сторон кадрирования для сохранения + Presentation Представление # Color Sampler (added with the i18n coverage repair) Click to place · Alt-click or drag off-canvas to remove Щёлкните, чтобы разместить · Alt-щелчок или перетаскивание за пределы холста для удаления diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 71ea488cc8a..b2da769bf15 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2892,6 +2892,7 @@ fontWeight Italic Kurziv Auto Hide Notifications Automatski sakrij obaveštenja Notification Duration (seconds) Trajanje obaveštenja (sekunde) Appearance Mode Režim izgleda + Presentation Prikaz Dark Theme Tamna tema Light Theme Svetla tema Pro Medium Pro srednja diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 2a69c36bbc3..e499fa162a5 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3637,6 +3637,7 @@ cameraRaw Whites Beyazlar Auto Hide Notifications Bildirimleri Otomatik Gizle Notification Duration (seconds) Bildirim Süresi (saniye) Appearance Mode Görünüm Modu + Presentation Sunum Next Appearance Mode Sonraki Görünüm Modu Sync with system Sistemle senkronizasyon Active Aktif diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index dd75907d143..8bb409d8c19 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3758,6 +3758,7 @@ curves Smooth Згладити There is no crop size or ratio to save Немає розміру чи співвідношення сторін кадрування для збереження Scale: Масштаб: Snap to Origin Прив'язати до початку координат + Presentation Представлення # Color Sampler (added with the i18n coverage repair) Click to place · Alt-click or drag off-canvas to remove Клацніть, щоб розмістити · Alt-клацання або перетягування за межі полотна для видалення diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 035743f3446..72ce77b630e 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3608,6 +3608,7 @@ curves Smooth Làm mượt Action playback stopped Đã dừng phát hành động Step {step} is waiting for its dialog Bước {step} đang chờ hộp thoại của nó @plural {n} action|{n} actions {n} hành động + Presentation Trình bày Gradient Preset Options Tùy chọn thiết lập sẵn chuyển sắc Import .GRD Nhập .GRD Choose a gradient file (.grd). Chọn tệp chuyển sắc (.grd). diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b8157e06691..f57dee7f2bd 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2683,6 +2683,7 @@ curves Smooth 平滑 Select the crop presets to delete 选择要删除的裁剪预设 Enter a name for the crop preset 输入裁剪预设的名称 There is no crop size or ratio to save 没有可保存的裁剪大小或比例 + Presentation 演示 # Color Sampler (added with the i18n coverage repair) Click to place · Alt-click or drag off-canvas to remove 单击以放置 · Alt 单击或拖到画布外以移除 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index e3a6a4b4fae..ec32d0dbd09 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2653,6 +2653,7 @@ curves Smooth 平滑 Select the crop presets to delete 選取要刪除的裁切預設集 Enter a name for the crop preset 輸入裁切預設集的名稱 There is no crop size or ratio to save 沒有可儲存的裁切尺寸或比例 + Presentation 呈現 # Color Sampler (added with the i18n coverage repair) Click to place · Alt-click or drag off-canvas to remove 按一下以放置 · Alt 按一下或拖曳至畫布外以移除 diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index f1da8775a96..997bc3be36f 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -1301,133 +1301,170 @@ fn export_field_visible(obj: &Map, key: &str) -> bool { } } -fn theme_preview(ui: &mut egui::Ui, kind: ThemeKind, width: f32) { +/// One theme as a Color Theme swatch: its panel colour under a chrome strip with an accent dot, +/// outlined when chosen. The name is the tooltip and the accessible label. A star badge on its +/// corner adds the theme to (or removes it from) the favourites listed by the appearance +/// button's menu; it shows while the swatch is hovered, and stays for a favourite. +fn theme_swatch(ui: &mut egui::Ui, kind: ThemeKind, id: &str, selected: &mut String, favorites: &mut Vec) { + const BADGE: f32 = 7.0; + let t = Tokens::get(ui.ctx()); let p = Tokens::for_kind(kind); - let (rect, _) = ui.allocate_exact_size(vec2(width, 108.0), Sense::hover()); + let label = tl!(&choice_label(id)).to_string(); + let on = selected.as_str() == id; + let (rect, resp) = ui.allocate_exact_size(vec2(30.0, 26.0), Sense::click()); let painter = ui.painter(); - painter.rect_filled(rect, 3.0, p.dock); - let bar = egui::Rect::from_min_size(rect.min, vec2(rect.width(), 16.0)); - painter.rect_filled(bar, 0.0, p.chrome); - for (x, w) in [(8.0, 18.0), (31.0, 13.0), (49.0, 16.0), (70.0, 12.0)] { - painter.rect_filled(egui::Rect::from_min_size(bar.min + vec2(x, 6.0), vec2(w, 3.0)), 1.0, p.text_dim); + let inner = rect.shrink(3.0); + painter.rect_filled(inner, 2.0, p.dock); + painter.rect_filled(egui::Rect::from_min_size(inner.min, vec2(inner.width(), 5.0)), 0.0, p.chrome); + painter.circle_filled(inner.right_bottom() - vec2(6.0, 6.0), 2.5, p.accent); + let stroke = if on { + egui::Stroke::new(2.0, t.accent) + } else if resp.hovered() { + egui::Stroke::new(1.0, t.text_dim) + } else { + egui::Stroke::new(1.0, t.card_border) + }; + painter.rect_stroke(rect.shrink(1.0), 3.0, stroke, egui::StrokeKind::Inside); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, true, on, &label)); + if resp.on_hover_text(&label).clicked() { + *selected = id.to_string(); } - let tools = egui::Rect::from_min_max(bar.left_bottom(), egui::pos2(rect.left() + 23.0, rect.bottom())); - painter.rect_filled(tools, 0.0, p.chrome); - for y in [24.0, 39.0, 54.0, 69.0, 84.0] { - // The first tool is the active one. - let color = if y == 24.0 { p.accent } else { p.icon }; - painter.rect_filled(egui::Rect::from_min_size(rect.min + vec2(7.0, y), vec2(9.0, 9.0)), 2.0, color); + let Some(theme) = SavedTheme::parse(id) else { return }; + let favorite = favorites.contains(&theme); + let center = rect.right_top() + vec2(-3.0, 3.0); + let badge = egui::Rect::from_center_size(center, vec2(BADGE * 2.0, BADGE * 2.0)); + if !(favorite || ui.rect_contains_pointer(rect.union(badge))) { + return; } - let dock = egui::Rect::from_min_max(egui::pos2(rect.right() - 75.0, bar.bottom()), rect.max); - painter.rect_filled(dock, 0.0, p.dock); - painter.rect_filled(egui::Rect::from_min_size(dock.min, vec2(dock.width(), 12.0)), 0.0, p.tab_strip); - painter.rect_filled(egui::Rect::from_min_size(dock.min + vec2(6.0, 4.0), vec2(31.0, 3.0)), 1.0, p.text_dim); - for y in [36.0, 55.0, 74.0] { - let row = egui::Rect::from_min_size(rect.min + vec2(rect.width() - 71.0, y), vec2(67.0, 16.0)); - // The middle row is the selected layer. - let selected = y == 55.0; - painter.rect_filled(row, 1.0, if selected { p.accent } else { p.card }); - painter.rect_filled(egui::Rect::from_min_size(row.min + vec2(4.0, 3.0), vec2(13.0, 10.0)), 1.0, p.canvas); - let label = if selected { egui::Color32::from_white_alpha(210) } else { p.text_dim }; - painter.rect_filled(egui::Rect::from_min_size(row.min + vec2(22.0, 6.0), vec2(35.0, 3.0)), 1.0, label); + ui.painter().circle(center, BADGE, t.card, egui::Stroke::new(1.0, t.card_border)); + let (icon, tint) = if favorite { ("star-filled", t.accent) } else { ("star", t.text_dim) }; + let action = if favorite { tl!("Remove from favorites") } else { tl!("Add to favorites") }; + let star = egui::Button::image(crate::icons::image(icon, 10.0, tint)).frame(false); + let response = ui.put(badge, star).on_hover_text(action); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("{action}: {label}"))); + if response.clicked() { + if favorite { + favorites.retain(|saved| *saved != theme); + } else { + favorites.push(theme); + } } - let canvas = egui::Rect::from_min_max(egui::pos2(tools.right(), bar.bottom()), egui::pos2(dock.left(), rect.bottom())); - painter.rect_filled(canvas, 0.0, p.canvas); - let art = canvas.shrink2(vec2(15.0, 13.0)); - painter.rect_filled(art, 0.0, p.card); - painter.rect_stroke(art, 0.0, egui::Stroke::new(1.0, p.card_border), egui::StrokeKind::Inside); } -fn theme_card(ui: &mut egui::Ui, title: &str, active: bool, selected: &mut String, favorites: &mut Vec, options: &[(&str, ThemeKind)], width: f32) { - let Some((_, first_kind)) = options.first() else { return }; +/// A grid row of theme swatches, marked Active when the appearance mode shows this family. +fn theme_row(ui: &mut egui::Ui, title: &str, active: bool, selected: &mut String, favorites: &mut Vec, options: &[(&str, ThemeKind)]) { let t = Tokens::get(ui.ctx()); - egui::Frame::new() - .fill(t.card) - .stroke(egui::Stroke::new(1.0, if active { t.accent } else { t.card_border })) - .corner_radius(t.radius) - .inner_margin(10.0) - .show(ui, |ui| { - ui.set_width(width - 20.0); - // Room for the longer list (six dark themes), so both cards line up. - ui.set_min_height(298.0); - ui.horizontal(|ui| { - ui.label(RichText::new(tl!(title)).strong().color(t.text)); - if active { - ui.label(RichText::new(tl!("Active")).color(t.accent_text).small()); - } - }); - ui.add_space(5.0); - let preview = options.iter().find(|(id, _)| *id == selected.as_str()).map_or(*first_kind, |(_, kind)| *kind); - theme_preview(ui, preview, width - 20.0); - ui.add_space(6.0); - for (id, _) in options { - let label = choice_label(id); - let row = egui::Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), 24.0)); - let hovered = ui.ctx().pointer_hover_pos().is_some_and(|p| row.contains(p)); - ui.horizontal(|ui| { - ui.radio_value(selected, (*id).to_string(), tl!(&label)); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if let Some(theme) = SavedTheme::parse(id) { - let favorite = favorites.contains(&theme); - let (icon, tint) = if favorite { ("star-filled", t.accent) } else { ("star", t.text_dim) }; - let star = egui::Button::image(crate::icons::image(icon, 15.0, tint)).frame(false); - let response = ui.add_visible(favorite || hovered, star).on_hover_text(if favorite { - tl!("Remove from favorites") - } else { - tl!("Add to favorites") - }); - response.widget_info(|| { - egui::WidgetInfo::labeled( - egui::WidgetType::Button, - true, - format!("{}: {}", if favorite { tl!("Remove from favorites") } else { tl!("Add to favorites") }, tl!(&label)), - ) - }); - if response.clicked() { - if favorite { - favorites.retain(|saved| *saved != theme); - } else { - favorites.push(theme); - } - } - } - }); - }); - } - }); -} - -fn appearance_rows(ui: &mut egui::Ui, obj: &mut Map, system: Option) { - let t = Tokens::get(ui.ctx()); - ui.label(RichText::new(tl!("Appearance")).strong().color(t.text)); - let mut mode = obj.get("appearanceMode").and_then(Value::as_str).unwrap_or("auto").to_string(); + ui.label(RichText::new(tl!(title)).color(t.text_dim)); ui.horizontal(|ui| { + // Room for the favourite star badges on the swatch corners. + ui.spacing_mut().item_spacing.x = 6.0; + for (id, kind) in options { + theme_swatch(ui, *kind, id, selected, favorites); + } + if active { + ui.add_space(6.0); + ui.label(RichText::new(tl!("Active")).color(t.accent_text).small()); + } + }); + ui.end_row(); +} + +/// Which of the Appearance rows drawn by `appearance_rows` a search shows. +#[derive(Clone, Copy)] +struct AppearanceRows { + mode: bool, + light: bool, + dark: bool, +} + +impl AppearanceRows { + /// `query` is lowercased; empty shows every row. + fn matching(query: &str) -> Self { + let shown = |key: &str| query.is_empty() || preference_matches("interface", key, query); + Self { mode: shown("appearanceMode"), light: shown("lightTheme"), dark: shown("darkTheme") } + } + + fn any(self) -> bool { + self.mode || self.light || self.dark + } +} + +/// Appearance Mode and the light and dark Color Theme swatches, as rows of the Appearance grid. +fn appearance_rows(ui: &mut egui::Ui, obj: &mut Map, system: Option, rows: AppearanceRows) { + let t = Tokens::get(ui.ctx()); + let mut mode = obj.get("appearanceMode").and_then(Value::as_str).unwrap_or("auto").to_string(); + if rows.mode { ui.label(RichText::new(tl!("Appearance Mode")).color(t.text_dim)); let choices: [(String, &str); 3] = [("auto".into(), tl!("Sync with system")), ("dark".into(), tl!("Dark")), ("light".into(), tl!("Light"))]; let pairs: Vec<(String, &str)> = choices.iter().map(|(id, label)| (id.clone(), *label)).collect(); - crate::widgets::dropdown(ui, "appearance-mode", &mut mode, &pairs, 190.0); - }); - obj.insert("appearanceMode".into(), json!(mode)); - ui.add_space(8.0); + crate::widgets::dropdown(ui, "appearance-mode", &mut mode, &pairs, 220.0); + ui.end_row(); + } let light_active = mode == "light" || (mode == "auto" && system == Some(egui::Theme::Light)); - let width = ((ui.available_width() - 10.0) / 2.0).max(190.0); + obj.insert("appearanceMode".into(), json!(mode)); let mut light = obj.get("lightTheme").and_then(Value::as_str).unwrap_or("studioLight").to_string(); let mut dark = obj.get("darkTheme").and_then(Value::as_str).unwrap_or("proMedium").to_string(); let mut favorites: Vec = obj.get("favoriteThemes").cloned().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default(); - ui.horizontal_top(|ui| { - ui.set_min_width(width * 2.0 + 10.0); - ui.allocate_ui_with_layout(vec2(width, 300.0), egui::Layout::top_down(egui::Align::Min), |ui| { - theme_card(ui, "Light Theme", light_active, &mut light, &mut favorites, LIGHT_THEME_OPTIONS, width); - }); - ui.add_space(10.0); - ui.allocate_ui_with_layout(vec2(width, 300.0), egui::Layout::top_down(egui::Align::Min), |ui| { - theme_card(ui, "Dark Theme", !light_active, &mut dark, &mut favorites, DARK_THEME_OPTIONS, width); - }); - }); + if rows.light { + theme_row(ui, "Light Theme", light_active, &mut light, &mut favorites, LIGHT_THEME_OPTIONS); + } + if rows.dark { + theme_row(ui, "Dark Theme", !light_active, &mut dark, &mut favorites, DARK_THEME_OPTIONS); + } obj.insert("lightTheme".into(), json!(light)); obj.insert("darkTheme".into(), json!(dark)); obj.insert("favoriteThemes".into(), json!(favorites)); - ui.add_space(12.0); +} + +/// A titled, outlined group of settings (Photoshop's Interface page has Appearance, +/// Presentation and Options). +fn pref_group(ui: &mut egui::Ui, title: &str, add: impl FnOnce(&mut egui::Ui)) { + let t = Tokens::get(ui.ctx()); + egui::Frame::new().stroke(egui::Stroke::new(1.0, t.card_border)).corner_radius(t.radius).inner_margin(10.0).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.label(RichText::new(tl!(title)).strong().color(t.text)); + ui.add_space(4.0); + add(ui); + }); + ui.add_space(8.0); +} + +/// Preferences › Interface laid out like Photoshop's: the theme and canvas settings, then UI +/// language, font size and scaling, then everything else. Compact enough that the language +/// shows without scrolling in a small window (#2532). A search (`query`, lowercased) hides the +/// rows that don't match and skips a group left empty. +fn interface_groups(ui: &mut egui::Ui, obj: &mut Map, keys: &[String], lang: crate::i18n::Lang, system: Option, query: &str) { + const APPEARANCE: [&str; 3] = ["canvasColor", "canvasCustomColor", "canvasBorder"]; + const PRESENTATION: [&str; 3] = ["language", "uiFontSize", "uiScale"]; + let shown = |k: &str| keys.iter().any(|key| key == k) && field_visible("interface", obj, k, query); + // In Photoshop's order, not the stored one. + let pick = |names: &[&str]| names.iter().filter(|n| shown(n)).map(|n| n.to_string()).collect::>(); + let appearance = pick(&APPEARANCE); + let presentation = pick(&PRESENTATION); + let rest = keys.iter().filter(|k| !APPEARANCE.contains(&k.as_str()) && !PRESENTATION.contains(&k.as_str()) && shown(k)); + // Checkboxes line up at the left edge, as in Photoshop; the other options keep a label column. + let (checks, other): (Vec, Vec) = rest.cloned().partition(|k| obj.get(k).is_some_and(Value::is_boolean)); + let rows = AppearanceRows::matching(query); + let grid = |id: &str| egui::Grid::new(("prefs-grid", id.to_string())).num_columns(2).spacing([14.0, 7.0]).min_col_width(150.0); + if rows.any() || !appearance.is_empty() { + pref_group(ui, "Appearance", |ui| { + grid("interface-appearance").show(ui, |ui| { + appearance_rows(ui, obj, system, rows); + field_rows(ui, "interface", obj, appearance, lang); + }); + }); + } + if !presentation.is_empty() { + pref_group(ui, "Presentation", |ui| { + grid("interface-presentation").show(ui, |ui| field_rows(ui, "interface", obj, presentation, lang)); + }); + } + if !checks.is_empty() || !other.is_empty() { + pref_group(ui, "Options", |ui| { + egui::Grid::new("prefs-grid-interface-checks").num_columns(2).spacing([0.0, 7.0]).show(ui, |ui| field_rows(ui, "interface", obj, checks, lang)); + grid("interface-options").show(ui, |ui| field_rows(ui, "interface", obj, other, lang)); + }); + } } /// Where a clicked colour swatch leaves its preference path and label for the dialog body, which @@ -1439,6 +1476,22 @@ fn ctx_take_pick(ui: &egui::Ui) -> Option<(String, String)> { ui.ctx().data_mut(|d| d.remove_temp::<(String, String)>(egui::Id::new(PICK_ID))) } +/// Whether `section.key` gets a generic row for this search (`query`, lowercased; empty matches +/// everything). Settings nothing reads yet stay out of the dialog (issue #204), as do those drawn +/// by their own widgets; their stored values pass through untouched. +fn field_visible(section: &str, obj: &Map, k: &str, query: &str) -> bool { + let path = format!("{section}.{k}"); + let drawn_elsewhere = (section == "interface" && matches!(k, "theme" | "appearanceMode" | "darkTheme" | "lightTheme" | "favoriteThemes")) + // View › Rulers (⌘R) sets it, as in Photoshop. + || path == "unitsAndRulers.showRulers" + || (section == "performance" && matches!(k, "useGpu" | "gpuBackend" | "renderingMode")) + || (section == "export" && !export_field_visible(obj, k)); + (query.is_empty() || preference_matches(section, k, query)) + && !drawn_elsewhere + && !prefs::is_hidden(&path) + && obj.get(k).is_some_and(|v| !matches!(v, Value::Null | Value::Object(_))) +} + /// Generic editor for a section's fields: checkboxes, dropdowns for choices, colour swatches, /// number fields with the preference's range, text fields. fn section_fields( @@ -1450,138 +1503,128 @@ fn section_fields( system: Option, search: &str, ) { - let t = Tokens::get(ui.ctx()); let query = search.to_lowercase(); - if section == "interface" && (query.is_empty() || ["appearanceMode", "darkTheme", "lightTheme"].iter().any(|key| preference_matches(section, key, &query))) - { - appearance_rows(ui, obj, system); + let mut keys: Vec = order.iter().filter(|k| obj.contains_key(*k)).cloned().collect(); + keys.extend(obj.keys().filter(|k| !order.contains(k)).cloned()); + if section == "interface" { + interface_groups(ui, obj, &keys, lang, system, &query); + return; } if section == "performance" && (query.is_empty() || preference_matches(section, "renderingMode", &query)) { rendering_mode_row(ui, obj); } - let mut keys: Vec = order.iter().filter(|k| obj.contains_key(*k)).cloned().collect(); - keys.extend(obj.keys().filter(|k| !order.contains(k)).cloned()); - egui::Grid::new(("prefs-grid", section)).num_columns(2).spacing([14.0, 7.0]).show(ui, |ui| { - for k in keys { - let path = format!("{section}.{k}"); - if !query.is_empty() && !preference_matches(section, &k, &query) { - continue; - } - // Settings nothing reads yet stay out of the dialog (issue #204); their stored values - // pass through untouched. - if (section == "interface" && matches!(k.as_str(), "theme" | "appearanceMode" | "darkTheme" | "lightTheme" | "favoriteThemes")) - // View › Rulers (⌘R) sets it, as in Photoshop. - || path == "unitsAndRulers.showRulers" - || prefs::is_hidden(&path) - || (section == "performance" && matches!(k.as_str(), "useGpu" | "gpuBackend" | "renderingMode")) - || (section == "export" && !export_field_visible(obj, &k)) - { - continue; - } - let v = obj.get(&k).cloned().unwrap_or(Value::Null); - let human = humanize(&k); - let label = tl!(&human).to_string(); - match &v { - Value::Bool(b) => { - ui.label(""); - let mut b = *b; - if path == "interface.systemTitleBar" && cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))) { - ui.vertical(|ui| { - crate::widgets::checkbox(ui, &mut b, &label); - ui.label(RichText::new(tl!("Applies at next launch.")).color(t.text_dim)); - }); - } else { - crate::widgets::checkbox(ui, &mut b, &label); - } - obj.insert(k, json!(b)); - } - Value::String(s) if path == "interface.language" => { - ui.label(RichText::new(tl!(&label)).color(t.text_dim)); - let mut pairs: Vec<(String, &str)> = vec![("auto".into(), tl!("Auto"))]; - pairs.extend(crate::i18n::Lang::all().map(|l| (l.code().to_string(), l.name()))); - let mut cur = s.clone(); - crate::widgets::dropdown(ui, &format!("pref-{path}"), &mut cur, &pairs, 220.0); - obj.insert(k, json!(cur)); - } - Value::String(s) if prefs::choices(&path).is_some() => { - ui.label(RichText::new(tl!(&label)).color(t.text_dim)); - let opts = prefs::choices(&path).unwrap_or(&[]); - let labels: Vec = opts.iter().map(|o| choice_label(o)).collect(); - let pairs: Vec<(String, &str)> = opts.iter().map(|o| o.to_string()).zip(labels.iter().map(String::as_str)).collect(); - let mut cur = s.clone(); - crate::widgets::dropdown(ui, &format!("pref-{path}"), &mut cur, &pairs, 220.0); - obj.insert(k, json!(cur)); - } - Value::String(s) if prefs::is_color(&path) => { - ui.label(RichText::new(tl!(&label)).color(t.text_dim)); - let c = prefs::parse_hex(s).unwrap_or([128, 128, 128]); - let mut rgb = c; - ui.horizontal(|ui| { - // PhotoCraft's Color Picker, as everywhere else (#2144). - if crate::widgets::color_swatch_button(ui, egui::Color32::from_rgb(rgb[0], rgb[1], rgb[2]), &label).clicked() { - ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new(PICK_ID), (path.clone(), label.clone()))); - } - hex_field(ui, &path, &mut rgb); - }); - obj.insert(k, json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]))); - let _ = color_of(s); - } - Value::String(s) => { - ui.label(RichText::new(tl!(&label)).color(t.text_dim)); - let mut s = s.clone(); - ui.add(egui::TextEdit::singleline(&mut s).desired_width(260.0)); - obj.insert(k, json!(s)); - } - Value::Number(n) => { - ui.label(RichText::new(tl!(&label)).color(t.text_dim)); - let (lo, hi) = prefs::range(&path).unwrap_or((-1e9, 1e9)); - if n.is_u64() || n.is_i64() { - let mut x = n.as_i64().unwrap_or(0); - ui.add(egui::DragValue::new(&mut x).range(lo as i64..=hi as i64).custom_parser(crate::widgets::parse_num)); - obj.insert(k, json!(x)); - } else { - let mut x = n.as_f64().unwrap_or(0.0); - ui.add(egui::DragValue::new(&mut x).range(lo..=hi).speed(0.1).max_decimals(3).custom_parser(crate::widgets::parse_num)); - obj.insert(k, json!(x)); - } - } - Value::Array(_) if path == "tools.pressureCurve" => { - ui.label(RichText::new(tl!(&label)).color(t.text_dim)); - if let Some(next) = crate::pressure_curve_ui::editor(ui, &v) { - obj.insert(k, next); - } - } - Value::Array(items) if k == "disks" => { - ui.label(RichText::new(tl!("Scratch disks")).color(t.text_dim)); - let mut items = items.clone(); + keys.retain(|k| field_visible(section, obj, k, &query)); + egui::Grid::new(("prefs-grid", section)).num_columns(2).spacing([14.0, 7.0]).show(ui, |ui| field_rows(ui, section, obj, keys, lang)); +} + +/// One grid row per setting in `keys` (already filtered by `field_visible`). +fn field_rows(ui: &mut egui::Ui, section: &str, obj: &mut Map, keys: Vec, lang: crate::i18n::Lang) { + let t = Tokens::get(ui.ctx()); + for k in keys { + let path = format!("{section}.{k}"); + let v = obj.get(&k).cloned().unwrap_or(Value::Null); + let human = humanize(&k); + let label = tl!(&human).to_string(); + match &v { + Value::Bool(b) => { + ui.label(""); + let mut b = *b; + if path == "interface.systemTitleBar" && cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))) { ui.vertical(|ui| { - for d in &mut items { - ui.horizontal(|ui| { - let mut on = d.get("enabled").and_then(Value::as_bool).unwrap_or(false); - let mut path = d.get("path").and_then(Value::as_str).unwrap_or("").to_string(); - crate::widgets::checkbox(ui, &mut on, ""); - ui.add(egui::TextEdit::singleline(&mut path).desired_width(240.0)); - *d = json!({"enabled": on, "path": path}); - }); - } - if ui.small_button(tl!("Add disk")).clicked() { - items.push(json!({"enabled": true, "path": ""})); - } + crate::widgets::checkbox(ui, &mut b, &label); + ui.label(RichText::new(tl!("Applies at next launch.")).color(t.text_dim)); }); - obj.insert(k, Value::Array(items)); + } else { + crate::widgets::checkbox(ui, &mut b, &label); } - Value::Array(items) => { - ui.label(RichText::new(tl!(&label)).color(t.text_dim)); - ui.label(RichText::new(crate::i18n::trn(lang, items.len() as u64, "{n} item", "{n} items")).color(t.text_faint)); - if !items.is_empty() && ui.small_button(tl!("Clear")).clicked() { - obj.insert(k, json!([])); - } - } - _ => continue, + obj.insert(k, json!(b)); } - ui.end_row(); + Value::String(s) if path == "interface.language" => { + ui.label(RichText::new(tl!(&label)).color(t.text_dim)); + let mut pairs: Vec<(String, &str)> = vec![("auto".into(), tl!("Auto"))]; + pairs.extend(crate::i18n::Lang::all().map(|l| (l.code().to_string(), l.name()))); + let mut cur = s.clone(); + crate::widgets::dropdown(ui, &format!("pref-{path}"), &mut cur, &pairs, 220.0); + obj.insert(k, json!(cur)); + } + Value::String(s) if prefs::choices(&path).is_some() => { + ui.label(RichText::new(tl!(&label)).color(t.text_dim)); + let opts = prefs::choices(&path).unwrap_or(&[]); + let labels: Vec = opts.iter().map(|o| choice_label(o)).collect(); + let pairs: Vec<(String, &str)> = opts.iter().map(|o| o.to_string()).zip(labels.iter().map(String::as_str)).collect(); + let mut cur = s.clone(); + crate::widgets::dropdown(ui, &format!("pref-{path}"), &mut cur, &pairs, 220.0); + obj.insert(k, json!(cur)); + } + Value::String(s) if prefs::is_color(&path) => { + ui.label(RichText::new(tl!(&label)).color(t.text_dim)); + let c = prefs::parse_hex(s).unwrap_or([128, 128, 128]); + let mut rgb = c; + ui.horizontal(|ui| { + // PhotoCraft's Color Picker, as everywhere else (#2144). + if crate::widgets::color_swatch_button(ui, egui::Color32::from_rgb(rgb[0], rgb[1], rgb[2]), &label).clicked() { + ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new(PICK_ID), (path.clone(), label.clone()))); + } + hex_field(ui, &path, &mut rgb); + }); + obj.insert(k, json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]))); + let _ = color_of(s); + } + Value::String(s) => { + ui.label(RichText::new(tl!(&label)).color(t.text_dim)); + let mut s = s.clone(); + ui.add(egui::TextEdit::singleline(&mut s).desired_width(260.0)); + obj.insert(k, json!(s)); + } + Value::Number(n) => { + ui.label(RichText::new(tl!(&label)).color(t.text_dim)); + let (lo, hi) = prefs::range(&path).unwrap_or((-1e9, 1e9)); + if n.is_u64() || n.is_i64() { + let mut x = n.as_i64().unwrap_or(0); + ui.add(egui::DragValue::new(&mut x).range(lo as i64..=hi as i64).custom_parser(crate::widgets::parse_num)); + obj.insert(k, json!(x)); + } else { + let mut x = n.as_f64().unwrap_or(0.0); + ui.add(egui::DragValue::new(&mut x).range(lo..=hi).speed(0.1).max_decimals(3).custom_parser(crate::widgets::parse_num)); + obj.insert(k, json!(x)); + } + } + Value::Array(_) if path == "tools.pressureCurve" => { + ui.label(RichText::new(tl!(&label)).color(t.text_dim)); + if let Some(next) = crate::pressure_curve_ui::editor(ui, &v) { + obj.insert(k, next); + } + } + Value::Array(items) if k == "disks" => { + ui.label(RichText::new(tl!("Scratch disks")).color(t.text_dim)); + let mut items = items.clone(); + ui.vertical(|ui| { + for d in &mut items { + ui.horizontal(|ui| { + let mut on = d.get("enabled").and_then(Value::as_bool).unwrap_or(false); + let mut path = d.get("path").and_then(Value::as_str).unwrap_or("").to_string(); + crate::widgets::checkbox(ui, &mut on, ""); + ui.add(egui::TextEdit::singleline(&mut path).desired_width(240.0)); + *d = json!({"enabled": on, "path": path}); + }); + } + if ui.small_button(tl!("Add disk")).clicked() { + items.push(json!({"enabled": true, "path": ""})); + } + }); + obj.insert(k, Value::Array(items)); + } + Value::Array(items) => { + ui.label(RichText::new(tl!(&label)).color(t.text_dim)); + ui.label(RichText::new(crate::i18n::trn(lang, items.len() as u64, "{n} item", "{n} items")).color(t.text_faint)); + if !items.is_empty() && ui.small_button(tl!("Clear")).clicked() { + obj.insert(k, json!([])); + } + } + _ => continue, } - }); + ui.end_row(); + } } /// Keyboard Shortcuts and Menus: a searchable list of commands by menu with editable shortcuts @@ -2349,6 +2392,67 @@ mod tests { assert_eq!(dark, vec![ThemeKind::Studio]); } + #[test] + fn preferences_interface_shows_language_without_scrolling() { + // #2532: in a small window the theme cards filled the page and Language sat out of view + // below them. Like Photoshop, Presentation follows Appearance and both fit. + use egui_kittest::{Harness, kittest::Queryable}; + let (mut app, _) = app_with_store(); + app.run("prefs.set", json!({"values": {"interface.language": "en"}})).unwrap(); + open_preferences(&mut app, "interface"); + let mut h = Harness::builder().with_size(vec2(1000.0, 750.0)).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + app + }); + h.run_steps(4); + let language = h.get_by_label("Language").rect(); + let dark = h.get_by_label("Dark Theme").rect(); + let ok = h.get_by_label("OK").rect(); + assert!(dark.bottom() < language.top(), "Presentation {language:?} follows the theme swatches {dark:?}"); + assert!(language.bottom() < ok.top() - 20.0, "Language {language:?} must show above the buttons {ok:?} without scrolling"); + // Every theme in the option lists gets a swatch, Breeze included. + for (id, _) in LIGHT_THEME_OPTIONS.iter().chain(DARK_THEME_OPTIONS) { + let label = choice_label(id); + assert!(h.query_by_label(&label).is_some(), "missing theme swatch {label}"); + } + assert!(h.query_by_label("Breeze light").is_some() && h.query_by_label("Breeze dark").is_some()); + } + + #[test] + fn preferences_interface_search_hides_rows_and_skips_empty_groups() { + use egui_kittest::{Harness, kittest::Queryable}; + let render = |search: &str| { + let (mut app, _) = app_with_store(); + app.run("prefs.set", json!({"values": {"interface.language": "en"}})).unwrap(); + let id = open_preferences(&mut app, "interface"); + app.ui.dialog_mut(id).unwrap().fields.insert("__search".into(), json!(search)); + let mut h = Harness::builder().with_size(vec2(1000.0, 750.0)).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + app + }); + h.run_steps(4); + h + }; + let shown = |h: &Harness<'_, PhotocraftApp>, label: &str| h.query_all_by_label(label).next().is_some(); + + let h = render("language"); + assert!(shown(&h, "Language") && shown(&h, "Presentation")); + for gone in ["Appearance", "Appearance Mode", "Dark Theme", "Options", "UI scale"] { + assert!(!shown(&h, gone), "\"language\" must not show {gone}"); + } + + let h = render("theme"); + assert!(shown(&h, "Appearance") && shown(&h, "Light Theme") && shown(&h, "Dark Theme") && shown(&h, "Breeze dark")); + for gone in ["Appearance Mode", "Presentation", "Language", "Options"] { + assert!(!shown(&h, gone), "\"theme\" must not show {gone}"); + } + + let h = render(""); + for group in ["Appearance", "Appearance Mode", "Presentation", "Options", "UI scale"] { + assert!(shown(&h, group), "an empty search shows {group}"); + } + } + /// Every generated preference label, section title and choice label has an entry in each /// language that claims complete menus (Japanese, Traditional Chinese, ...). #[test] From 911881880dffbcac0aad85fe8f6e45924abc1094 Mon Sep 17 00:00:00 2001 From: Anton Date: Tue, 13 Oct 2026 19:45:26 +0300 Subject: [PATCH 03/47] Web: show the brush preset storage banner only while a change is unsaved (#4918) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the browser denies IndexedDB from the start (a sandboxed iframe without allow-same-origin, blocked site data), the banner "Brush preset changes are not saved: SecurityError … Browser storage is unavailable" stays above the app for the whole session, before any preset was touched and with nothing to dismiss. Show it only while a preset change is unsaved. Nothing is lost before the first change; that change brings the same banner with the reason. Write errors and rejected records keep showing it, as both leave changes unsaved. Co-authored-by: kuvshin888 <192736288+kuvshin888@users.noreply.github.com> --- apps/photocraft-web/src/preset_bridge.rs | 1 + apps/photocraft-web/src/web.rs | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/photocraft-web/src/preset_bridge.rs b/apps/photocraft-web/src/preset_bridge.rs index 6c9ef84addb..a7445bbc145 100644 --- a/apps/photocraft-web/src/preset_bridge.rs +++ b/apps/photocraft-web/src/preset_bridge.rs @@ -337,6 +337,7 @@ mod tests { let b = Bridge::default(); b.unavailable("SecurityError".into()); let (mut s, _) = session(b.clone()); + assert!(!b.unsaved(), "nothing is lost before the first change, so the web shell shows no banner"); import(&mut s); assert!(s.tools.presets.iter().any(|p| p.name == "Browser seam brush")); assert!(b.unsaved()); diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index 8d7e8e08261..a5c9d8a5653 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -305,7 +305,12 @@ impl eframe::App for WebShell { } fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { - if let Some(error) = self.presets.error() { + // Only while a preset change is unsaved. Storage the browser denies from the start (a + // sandboxed iframe, blocked site data) loses nothing until the first change, and that + // change brings the banner with the reason. + if self.presets.unsaved() + && let Some(error) = self.presets.error() + { egui::Panel::top("browser_preset_storage").show(ui, |ui| { let tokens = photocraft_ui_egui::theme::Tokens::for_kind(self.app.ui.theme); // Put retry first so floating brush windows cannot cover the control. From 0bdaf905cc9548d176c13e09f31a161a5745e7e2 Mon Sep 17 00:00:00 2001 From: Gert Pellin - GPE Date: Tue, 13 Oct 2026 18:45:35 +0200 Subject: [PATCH 04/47] Curves: points outside 0..255 are refused, as the message says (#4868) A point outside 0..255 was moved onto the edge without a word, while the error for bad points says they must be in 0..255; Photoshop refuses them too. The error also named the command twice-quoted (`adjustment `curves``); it is now `curves adjustment`. Fixes #4867 --- crates/engine/src/adjust_params.rs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/adjust_params.rs b/crates/engine/src/adjust_params.rs index 5885f0cdb7e..aafdb91616a 100644 --- a/crates/engine/src/adjust_params.rs +++ b/crates/engine/src/adjust_params.rs @@ -73,7 +73,7 @@ pub fn default_for(kind: &str, mode: ColorMode) -> Result { /// None or of another kind). `mode` picks the Levels/Curves channel space when neither the params /// nor the base name one. pub fn from_params(kind: &str, p: &Value, base: Option<&Adjustment>, mode: ColorMode) -> Result { - let cmd = format!("adjustment `{kind}`"); + let cmd = format!("{kind} adjustment"); if !p.is_object() && !p.is_null() { return Err(bad(&cmd, "params must be an object")); } @@ -952,8 +952,10 @@ pub fn parse_curve(cmd: &str, key: &str, v: &Value) -> Result> { let mut pts = Vec::with_capacity(a.len()); for q in a { let q = q.as_array().filter(|q| q.len() == 2).ok_or_else(err)?; - let x = q.first().and_then(Value::as_f64).filter(|x| x.is_finite()).ok_or_else(err)?; - let y = q.get(1).and_then(Value::as_f64).filter(|y| y.is_finite()).ok_or_else(err)?; + // Like Photoshop, a point outside 0..255 is refused rather than moved onto the edge. + let level = |v: Option<&Value>| v.and_then(Value::as_f64).filter(|v| (-1e-6..=255.0 + 1e-6).contains(v)); + let x = level(q.first()).ok_or_else(err)?; + let y = level(q.get(1)).ok_or_else(err)?; pts.push(CurvePoint { input: (x as f32).clamp(0.0, 255.0) / 255.0, output: (y as f32).clamp(0.0, 255.0) / 255.0 }); } pts.sort_by(|a, b| a.input.total_cmp(&b.input)); @@ -1212,6 +1214,17 @@ mod tests { assert!(matches!(a, Adjustment::Levels { master, .. } if master.in_white > master.in_black)); } + #[test] + fn curve_points_outside_0_255_are_refused_as_the_message_says() { + // Photoshop refuses them too ("the settings in the file are invalid"); they used to be + // moved onto the edge silently. + for pts in [json!([[0, 0], [300, 255]]), json!([[0, -1], [255, 255]]), json!([[0, 0]])] { + let e = from_params("curves", &json!({ "points": pts }), None, ColorMode::Rgb).unwrap_err().to_string(); + assert!(e.contains("[input, output] pairs in 0..255") && e.contains("`curves adjustment`"), "{e}"); + } + assert!(from_params("curves", &json!({"points": [[0, 0], [128.5, 140], [255, 255]]}), None, ColorMode::Rgb).is_ok()); + } + fn curves_parts(a: &Adjustment) -> (&[CurvePoint], &[Vec; 3]) { match a { Adjustment::Curves { master, per_channel, .. } => (master, per_channel), From 759780e40d224406d7f4294ae70da06bf422f70c Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Wed, 14 Oct 2026 03:45:42 +1100 Subject: [PATCH 05/47] Clip Fill source reads to the canvas before allocating pixels. (#4590) --- crates/engine/src/fill_cmds.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/fill_cmds.rs b/crates/engine/src/fill_cmds.rs index df925caf76a..68e617bc8cd 100644 --- a/crates/engine/src/fill_cmds.rs +++ b/crates/engine/src/fill_cmds.rs @@ -100,7 +100,7 @@ pub fn fill(s: &mut Session, p: &Value) -> Result { let st = s.active().ok_or(EngineError::NoDocument)?; let doc_bounds = st.doc.bounds(); let sel = st.doc.selection.clone(); - let area = sel.as_ref().map(|m| m.content_bounds()).unwrap_or(doc_bounds); + let area = sel.as_ref().map_or(doc_bounds, |m| m.content_bounds().intersect(&doc_bounds)); let mut out = json!({ "contents": contents }); let restore = contents == "history"; let source = match contents { @@ -148,7 +148,6 @@ pub fn fill(s: &mut Session, p: &Value) -> Result { } _ => return Err(bad(format!("unknown contents `{contents}`"))), }; - let area = area.intersect(&doc_bounds); if area.is_empty() { return Ok(out); } @@ -529,6 +528,24 @@ mod tests { a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1.5 / 255.0) } + #[test] + fn history_fill_reads_only_the_selection_inside_the_canvas() { + let mut s = session(); + s.edit("selection", |doc, _| { + let mut selection = Surface::new(photocraft_color::PixelFormat::GRAY8); + selection.write_pixel(-2_000_000_000, -2_000_000_000, &[1.0]); + selection.write_pixel(4, 4, &[1.0]); + doc.selection = Some(selection); + Ok(()) + }) + .unwrap(); + // Reading the full sparse selection rectangle overflows the allocation size; + // only its intersection with this small canvas is meaningful to Fill. + s.execute(CMD, json!({"contents": "history"})).unwrap(); + assert!(s.undo()); + assert!(s.active().unwrap().doc.selection.as_ref().unwrap().sample_channel(4, 4, 0) > 0.0); + } + #[test] fn every_constant_contents() { let mut s = session(); From 213770008a64933ae83ba3620d57d560ac20299c Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Wed, 14 Oct 2026 03:45:49 +1100 Subject: [PATCH 06/47] Return allocation errors when clearing a color channel. (#4589) --- crates/engine/src/channel_clip/tests.rs | 16 ++++++++++++++++ crates/engine/src/channel_cmds.rs | 7 ++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/channel_clip/tests.rs b/crates/engine/src/channel_clip/tests.rs index e6fa57333a4..39cb029b3d2 100644 --- a/crates/engine/src/channel_clip/tests.rs +++ b/crates/engine/src/channel_clip/tests.rs @@ -147,3 +147,19 @@ fn cut_a_colour_channel_clears_only_that_channel() { s.undo(); assert!(near(&px(&s, 5, 5), &left), "one undoable step"); } + +#[test] +fn clearing_a_channel_reports_allocation_failure_without_editing() { + for depth in [8, 16, 32] { + let mut s = session(depth); + let id = s.active().unwrap().active_layer.unwrap(); + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.entries(); + crate::allocation::fail_next_for_test(); + let result = s.edit("Clear", |doc, _| crate::channel_cmds::clear_color_channel(doc, id, 0, [1.0; 4])); + let _ = crate::allocation::capacity::(0, "reset failpoint"); + assert!(result.is_err()); + assert_eq!(*s.active().unwrap().doc, *before); + assert_eq!(s.active().unwrap().history.entries(), history); + } +} diff --git a/crates/engine/src/channel_cmds.rs b/crates/engine/src/channel_cmds.rs index 120ab36b46a..7d8ca004732 100644 --- a/crates/engine/src/channel_cmds.rs +++ b/crates/engine/src/channel_cmds.rs @@ -746,15 +746,16 @@ pub(crate) fn clear_color_channel(doc: &mut Document, id: LayerId, k: usize, bac if area.is_empty() { return Ok(()); } - let cover = sel.map(|m| (m.read_region(area), m.format().channels().max(1))); - let mut px = surf.read_region(area); + let cover = + sel.as_ref().map(|m| crate::allocation::read_region(m, area, "clearing a channel selection").map(|v| (v, m.format().channels().max(1)))).transpose()?; + let mut px = crate::allocation::read_region(surf, area, "clearing a channel")?; for (i, p) in px.chunks_exact_mut(n).enumerate() { let w = cover.as_ref().map_or(1.0, |(m, mk)| m.get(i * mk).copied().unwrap_or(0.0)); if let Some(c) = p.get_mut(k) { *c += (v - *c) * w.clamp(0.0, 1.0); } } - surf.write_region(area, &px); + surf.try_write_region(area, &px)?; surf.prune(); Ok(()) } From 207d05a0e28c5216acc576b33dfad19af7eb02b6 Mon Sep 17 00:00:00 2001 From: Persi <69083413+persi-man@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:45:56 +0200 Subject: [PATCH 07/47] Channels: keep the cached composite across view-only changes (#4915) * Liquify: simplify the brush-resize gesture condition Same logic; clears clippy's nonminimal_bool on 1.96. * Channels: keep the cached composite across view-only changes Channel eye toggles bump the revision the cache was keyed on, so it was always dropped. The test only passed when the freed buffer came back at the same address, and failed under load. --- crates/engine/src/channel_cmds.rs | 1 + crates/engine/src/lib.rs | 3 +++ crates/ui-egui/src/channel_view.rs | 26 ++++++++++++++++---------- crates/ui-egui/src/liquify_ui.rs | 2 +- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/channel_cmds.rs b/crates/engine/src/channel_cmds.rs index 7d8ca004732..00f3ec4e31f 100644 --- a/crates/engine/src/channel_cmds.rs +++ b/crates/engine/src/channel_cmds.rs @@ -152,6 +152,7 @@ pub(crate) fn fix_view(st: &mut DocState) { fn touch(st: &mut DocState) { let clean = st.saved_revision == st.revision; st.revision += 1; + st.view_revision += 1; if clean { st.saved_revision = st.revision; } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 58a89742980..9c5ff5e9bde 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -197,6 +197,8 @@ pub struct DocState { /// Increments on every change; UIs re-render when it moves. pub revision: u64, pub saved_revision: u64, + /// How much of `revision` came from view-only changes (channel eyes, targeting): pixel caches can ignore those. + pub view_revision: u64, /// Area changed by the latest revision (None = assume everything changed). pub last_damage: Option, /// Coalescing key of the latest history step (see [`Session::execute`]'s `coalesce` param). @@ -242,6 +244,7 @@ impl DocState { source_read_only: false, revision: 1, saved_revision: 1, + view_revision: 0, last_damage: None, coalesce: None, channel_view: Default::default(), diff --git a/crates/ui-egui/src/channel_view.rs b/crates/ui-egui/src/channel_view.rs index 38116e58e19..b8be54d2736 100644 --- a/crates/ui-egui/src/channel_view.rs +++ b/crates/ui-egui/src/channel_view.rs @@ -23,6 +23,8 @@ const MAX_COMPOSITE_CACHE_PIXELS: usize = 8 * 1024 * 1024; struct SampledComposite { revision: u64, + /// `revision` minus the view-only part: what the pixels depend on. + content: u64, preview_key: u64, factor: u32, pixels: Vec<[f32; 4]>, @@ -251,6 +253,7 @@ pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Optio let view = st.channel_view.clone(); let active = st.active_layer; let (revision, last_damage) = (st.revision, st.last_damage); + let content = revision.wrapping_sub(st.view_revision); let id = st.doc.id.0; if view.is_plain(&st.doc) { if let Some(c) = app.channel_views.get_mut(&id) { @@ -270,12 +273,14 @@ pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Optio // Never exceed what the GPU accepts (egui panics on oversized textures). let max_side = (ctx.input(|i| i.max_texture_side) as u32).clamp(256, MAX_SIDE); let factor = doc.size.width.max(doc.size.height).div_ceil(max_side).max(1); - // A live preview can mutate the same Arc in place. Revision and preview_key, not + // A live preview can mutate the same Arc in place. Content revision and preview_key, not // pointer identity, determine whether these flattened pixels are still current. - if let Some(c) = app.channel_views.get_mut(&id) - && c.source.as_ref().is_some_and(|s| s.factor != factor || s.revision != revision || s.preview_key != preview_key) - { - c.source = None; + if let Some(c) = app.channel_views.get_mut(&id) { + if let Some(s) = c.source.as_mut().filter(|s| s.factor == factor && s.content == content && s.preview_key == preview_key) { + s.revision = revision; + } else { + c.source = None; + } } if let Some(c) = app.channel_views.get(&id) && c.key == key @@ -325,12 +330,12 @@ pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Optio c.factor = factor; c.valid = true; if let Some(pixels) = fresh.take() { - c.source = Some(SampledComposite { revision, preview_key, factor, pixels }); + c.source = Some(SampledComposite { revision, content, preview_key, factor, pixels }); } } _ => { let tex = ctx.load_texture(format!("channel-view-{id}"), img, TextureOptions::LINEAR); - let source = fresh.map(|pixels| SampledComposite { revision, preview_key, factor, pixels }); + let source = fresh.map(|pixels| SampledComposite { revision, content, preview_key, factor, pixels }); app.channel_views.insert(id, Cache { revision, key, preview_key, factor, valid: true, tex, source }); } } @@ -391,14 +396,15 @@ mod tests { app.run("channel.setVisible", json!({"channel": "red", "visible": false})).unwrap(); assert!(ensure(&mut app, &ctx, 0).is_some()); let id = app.session.active().unwrap().doc.id.0; - let first = app.channel_views.get(&id).unwrap().source.as_ref().unwrap(); + let first = app.channel_views.get_mut(&id).unwrap().source.as_mut().unwrap(); let original_sample = first.pixels[0]; - let ptr = first.pixels.as_ptr(); + // Mark the cache: a recomposite overwrites it, while a freed buffer's address can come back. + first.pixels[0] = [0.0, 1.0, 0.0, 1.0]; app.run("channel.setVisible", json!({"channel": "green", "visible": false})).unwrap(); assert!(ensure(&mut app, &ctx, 0).is_some()); let reused = app.channel_views.get(&id).unwrap().source.as_ref().unwrap(); - assert_eq!(ptr, reused.pixels.as_ptr(), "visibility does not recompose the pixels"); + assert_eq!(reused.pixels[0], [0.0, 1.0, 0.0, 1.0], "visibility does not recompose the pixels"); app.run("edit.fill", json!({"color": "#e02010"})).unwrap(); assert!(ensure(&mut app, &ctx, 0).is_some()); diff --git a/crates/ui-egui/src/liquify_ui.rs b/crates/ui-egui/src/liquify_ui.rs index c5a04556af2..07c1e5a7b6e 100644 --- a/crates/ui-egui/src/liquify_ui.rs +++ b/crates/ui-egui/src/liquify_ui.rs @@ -574,7 +574,7 @@ fn alt_tool(t: LiquifyTool) -> LiquifyTool { /// Alt+right-drag, and Control+Alt-drag, change the Liquify brush instead of painting (#4702). fn brush_resize_gesture(mods: egui::Modifiers, secondary: bool) -> bool { - (secondary && mods.alt) || (mods.ctrl && mods.alt && !secondary) + mods.alt && (secondary || mods.ctrl) } /// Horizontal motion sets the diameter (the circle's edge follows the pointer). Vertical motion From f389d4ee647755b1d382986f7db517ec471a44cd Mon Sep 17 00:00:00 2001 From: Gert Pellin - GPE Date: Tue, 13 Oct 2026 18:46:03 +0200 Subject: [PATCH 08/47] info: adjustment layers are reported with the params their command takes (#4871) document.inspect dumped an adjustment's internal model (enum name as key, snake_case fields, 0..1 values), which no adjustment command takes. It is now {"kind": "levels", "params": ...}: the kind and parameters from adjust_params::to_params, which from_params reads back. Fixes #4865 --- crates/engine/src/inspect.rs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index d0b2a72e981..bdda8c78f88 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -98,7 +98,9 @@ fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId]) -> Value { } } LayerContent::Adjustment(a) => { - v["adjustment"] = serde_json::to_value(a).unwrap_or(Value::Null); + // The same kind and parameters the adjustment commands take (`inBlack` 0..255, …), + // not the internal model. + v["adjustment"] = json!({"kind": crate::commands::adjustment_kind(a), "params": crate::adjust_params::to_params(a)}); } LayerContent::Smart(sm) => { v["smartFilters"] = Value::Array( @@ -165,6 +167,25 @@ mod tests { use photocraft_color::PixelFormat; use photocraft_doc::{LayerMask, Path, VectorMask}; + #[test] + fn an_adjustment_layer_is_reported_with_the_params_its_command_takes() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4, "height": 4})).unwrap(); + s.execute("layer.newAdjustmentLayer.levels", json!({"inBlack": 10, "inWhite": 240, "gamma": 1.2})).unwrap(); + let info = s.execute("document.inspect", json!({})).unwrap(); + let adj = &info["layers"][0]["adjustment"]; + assert_eq!(adj["kind"], "levels"); + assert_eq!((adj["params"]["inBlack"].as_f64(), adj["params"]["inWhite"].as_f64()), (Some(10.0), Some(240.0)), "{adj}"); + // Fed back to the command, the params give the same adjustment; no internal field names. + let model = match &s.active().unwrap().doc.layers[1].content { + LayerContent::Adjustment(a) => a.clone(), + c => panic!("{c:?}"), + }; + let back = crate::adjust_params::from_params("levels", &adj["params"], None, photocraft_color::ColorMode::Rgb).unwrap(); + assert_eq!(crate::adjust_params::to_params(&back), crate::adjust_params::to_params(&model)); + assert!(!adj.to_string().contains('_'), "{adj}"); + } + #[test] fn document_inspect_reports_raster_and_vector_masks_when_nested() { let cases = [(false, None), (true, None), (false, Some(true)), (false, Some(false)), (true, Some(true))]; From b52115fc0ae7601d203aafff0bea230e81b573eb Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Wed, 14 Oct 2026 03:46:10 +1100 Subject: [PATCH 09/47] Preserve document and history when Fade cannot allocate its buffers. (#4593) --- crates/engine/src/edit_menu_cmds.rs | 15 ++++++++------- crates/engine/src/edit_menu_cmds/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/edit_menu_cmds.rs b/crates/engine/src/edit_menu_cmds.rs index 344141204b1..cda3de84d43 100644 --- a/crates/engine/src/edit_menu_cmds.rs +++ b/crates/engine/src/edit_menu_cmds.rs @@ -151,14 +151,14 @@ fn surface_of(doc: &Document, id: LayerId, mask: bool) -> Option<&Surface> { /// Blend `after` back towards `before` over `area`: `before + opacity × (B(before, after) − before)` /// with blend mode `mode` (premultiplied, so alpha changes fade too). -pub fn fade_surface(before: &Surface, after: &mut Surface, area: Rect, mode: BlendMode, opacity: f32) { +pub fn fade_surface(before: &Surface, after: &mut Surface, area: Rect, mode: BlendMode, opacity: f32) -> Result<()> { let fmt = after.format(); let n = fmt.channels(); if area.is_empty() || n == 0 { - return; + return Ok(()); } - let b = before.read_region(area); - let mut a = after.read_region(area); + let b = crate::allocation::read_region(before, area, "fading pixels")?; + let mut a = crate::allocation::read_region(after, area, "fading pixels")?; let k = opacity.clamp(0.0, 1.0); let lerp_premul = |pb: &[f32], pa: &[f32], out: &mut [f32], alpha: bool| { let m = out.len(); @@ -176,7 +176,7 @@ pub fn fade_surface(before: &Surface, after: &mut Surface, area: Rect, mode: Ble } out[m - 1] = ao; }; - let mut tmp = vec![0.0f32; n]; + let mut tmp = crate::allocation::filled(n, 0.0f32, "fading pixels")?; for (pa, pb) in a.chunks_exact_mut(n).zip(b.chunks_exact(n)) { if mode == BlendMode::Normal || n == 1 { lerp_premul(pb, pa, &mut tmp, fmt.alpha); @@ -192,7 +192,8 @@ pub fn fade_surface(before: &Surface, after: &mut Surface, area: Rect, mode: Ble pa.copy_from_slice(&enc[..n]); } } - after.write_region(area, &a); + after.try_write_region(area, &a)?; + Ok(()) } fn fade(s: &mut Session, p: &Value) -> Result { @@ -212,7 +213,7 @@ fn fade(s: &mut Session, p: &Value) -> Result { s.edit(&label, |doc, _| { let l = doc.layer_mut(src.layer).ok_or(EngineError::NoLayer(src.layer))?; let surf = if src.mask { l.mask.as_mut().map(|m| &mut m.surface) } else { l.surface_mut() }.ok_or(EngineError::NoLayer(src.layer))?; - fade_surface(&before, surf, area, mode, opacity); + fade_surface(&before, surf, area, mode, opacity)?; surf.prune(); Ok(()) })?; diff --git a/crates/engine/src/edit_menu_cmds/tests.rs b/crates/engine/src/edit_menu_cmds/tests.rs index 624a236200e..f7c2f23e834 100644 --- a/crates/engine/src/edit_menu_cmds/tests.rs +++ b/crates/engine/src/edit_menu_cmds/tests.rs @@ -544,3 +544,23 @@ fn backward_change_find_moves_past_the_replaced_text() { assert_eq!((r["changed"]["start"].as_u64(), r["changed"]["end"].as_u64()), (Some(0), Some(3)), "{r}"); assert_eq!((r["found"]["start"].as_u64(), r["found"]["end"].as_u64()), (Some(8), Some(11)), "{r}"); } + +#[test] +fn fade_allocation_failure_keeps_document_and_history() { + for depth in [8, 16, 32] { + let mut s = session(depth); + s.execute("edit.fill", json!({"color": "#000000"})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.entries(); + crate::allocation::fail_next_for_test(); + let result = s.execute("edit.fade", json!({"opacity": 25})); + let _ = crate::allocation::capacity::(0, "reset failpoint"); + assert!(result.is_err()); + assert_eq!(*s.active().unwrap().doc, *before); + assert_eq!(s.active().unwrap().history.entries(), history); + assert!(s.is_enabled("edit.fade")); + s.execute("edit.fade", json!({"opacity": 25})).unwrap(); + assert!(s.undo()); + assert_eq!(*s.active().unwrap().doc, *before); + } +} From 2307acef92ed3ec0663b86d2fbd574fc87b3f7a5 Mon Sep 17 00:00:00 2001 From: Tarek Mohamed <30214297+seotarek@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:46:17 +0300 Subject: [PATCH 10/47] feat(select): preserve existing mask metadata and allow explicit target in refineEdge (#4499) - Preserve existing layer mask metadata (density, feather, linked, enabled) during select.refineEdge when outputting to layerMask. - Honor explicit mask targeting (target: "mask") in refine_edge when both selection and mask exist. - Add tests for mask metadata preservation. --- crates/engine/src/smartselect_cmds.rs | 31 +++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/smartselect_cmds.rs b/crates/engine/src/smartselect_cmds.rs index b04aae7a847..651db7cdc00 100644 --- a/crates/engine/src/smartselect_cmds.rs +++ b/crates/engine/src/smartselect_cmds.rs @@ -397,7 +397,8 @@ fn refine_edge(s: &mut Session, p: &Value) -> Result { } let region = with_sampler(s, b(p, "sampleAllLayers", false), |smp, doc| { let mask_surf = active.and_then(|id| doc.layer(id)).and_then(|l| l.mask.as_ref()).map(|m| &m.surface); - let sel = doc.selection.as_ref().or(mask_surf)?; + let target_mask = p.get("target").and_then(Value::as_str) == Some("mask"); + let sel = if target_mask { mask_surf.or(doc.selection.as_ref()) } else { doc.selection.as_ref().or(mask_surf) }?; let brush = matting::EdgeBrush::new(&strokes, doc.bounds()); matting::refine_mask_brushed(smp, &matting::surface_reader(sel), sel.content_bounds(), doc.bounds(), ¶ms, brush.as_ref()) })?; @@ -416,7 +417,8 @@ fn refine_edge(s: &mut Session, p: &Value) -> Result { s.edit("Select and Mask", |doc, _| { let surface = region.as_ref().map(matting::region_surface).unwrap_or_else(|| photocraft_raster::Surface::new(photocraft_color::PixelFormat::GRAY8)); - doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.mask = Some(LayerMask { surface, ..LayerMask::reveal_all() }); + let existing = doc.layer(id).and_then(|l| l.mask.as_ref()).cloned().unwrap_or_else(LayerMask::reveal_all); + doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.mask = Some(LayerMask { surface, ..existing }); doc.selection = None; Ok(()) })?; @@ -821,6 +823,31 @@ mod tests { assert!(s.active().unwrap().doc.layers[0].mask.is_none()); assert!(s.active().unwrap().doc.selection.is_some()); + // Existing mask settings (density, feather, linked) are preserved + s.edit("set mask", |doc, _| { + doc.layers[0].mask = Some(photocraft_doc::LayerMask { + surface: photocraft_raster::Surface::new(photocraft_color::PixelFormat::GRAY8), + density: 0.75, + feather: 3.5, + linked: false, + enabled: true, + }); + Ok(()) + }) + .unwrap(); + let r = s.execute("select.refineEdge", json!({"radius": 8, "output": "layerMask"})).unwrap(); + let l = s.active().unwrap().doc.layer(photocraft_doc::LayerId(r["layer"].as_u64().unwrap())).unwrap(); + let m = l.mask.as_ref().unwrap(); + assert_eq!(m.density, 0.75); + assert_eq!(m.feather, 3.5); + assert!(!m.linked); + s.execute("edit.undo", json!({})).unwrap(); + s.edit("clear mask", |doc, _| { + doc.layers[0].mask = None; + Ok(()) + }) + .unwrap(); + // New layer: pixels with the refined alpha; the source is hidden. let r = s.execute("select.refineEdge", json!({"radius": 8, "feather": 1, "output": "newLayer"})).unwrap(); let d = s.active().unwrap(); From 52fcca883e55b34debc7ba16b799fc15a5ac23d0 Mon Sep 17 00:00:00 2001 From: FinnNk Date: Tue, 13 Oct 2026 17:46:25 +0100 Subject: [PATCH 11/47] perf(paint): sampled tips share their samples, so cloning brush settings is cheap (#4534) (#4798) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stroke clones its brush settings on every pointer event (StrokeRenderer::tail_preview clones the dab generator and the context, each holding a BrushSettings), and a sampled tip's GrayTile carried its samples in a Vec. With a large sampled tip, common in imported ABR brushes, every pointer event copied the tip: measured 1.1 ms per event for a 1500² tip and 3.8 ms for 2500², at 120+ events a second. GrayTile's samples are now an Arc<[u16]>: a clone copies a pointer. Nothing mutates a tile's samples after building it, so only construction changes (`.into()`), and equality, serde and every read are unchanged. Measured the same way: under 0.01 ms per event at any tip size. Textures and patterns stored as GrayTile share alike. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/preset_store.rs | 2 +- crates/paint/src/tile.rs | 36 +++++++++++++++++++++-------- crates/ui-egui/src/brush_preview.rs | 2 +- 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/preset_store.rs b/crates/engine/src/preset_store.rs index 4186c167edd..519f2cc609a 100644 --- a/crates/engine/src/preset_store.rs +++ b/crates/engine/src/preset_store.rs @@ -299,7 +299,7 @@ fn decode_tip_max(bytes: &[u8], max_side: u32) -> Result { } let data: Vec = if bits == 8 { raw.iter().map(|&v| u16::from(v) * 257).collect() } else { raw.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect() }; - Ok(GrayTile { width: w, height: h, data }) + Ok(GrayTile { width: w, height: h, data: data.into() }) } // ------------------------------------------------------------------ group files diff --git a/crates/paint/src/tile.rs b/crates/paint/src/tile.rs index 6152411c91c..38ca38410e7 100644 --- a/crates/paint/src/tile.rs +++ b/crates/paint/src/tile.rs @@ -6,11 +6,14 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; /// A grayscale bitmap, row-major, 1 = full. +/// +/// The samples are shared: cloning a tile (brush settings are cloned on every pointer event of a +/// stroke, #4534) copies a pointer, not a sampled tip that may be thousands of pixels across. #[derive(Clone, Debug, PartialEq, Eq)] pub struct GrayTile { pub width: u32, pub height: u32, - pub data: Vec, + pub data: std::sync::Arc<[u16]>, } impl GrayTile { @@ -22,7 +25,7 @@ impl GrayTile { data.push(q16(f(x, y))); } } - Self { width, height, data } + Self { width, height, data: data.into() } } /// Build from 0..1 floats (`width × height`). pub fn from_f32(width: u32, height: u32, v: &[f32]) -> Self { @@ -47,7 +50,7 @@ impl GrayTile { pub fn preview(&self, max_side: u32) -> GrayTile { let q8 = |v: u32| ((v + 128) / 257 * 257) as u16; if !self.is_valid() { - return GrayTile { width: 1, height: 1, data: vec![0] }; + return GrayTile { width: 1, height: 1, data: vec![0].into() }; } let (w, h) = (self.width as usize, self.height as usize); let max = max_side.max(1) as usize; @@ -64,7 +67,8 @@ impl GrayTile { } } } - let data = sum.iter().zip(&cnt).map(|(s, c)| q8(if *c > 0 { (*s / u64::from(*c)) as u32 } else { 0 })).collect(); + let data: Vec = sum.iter().zip(&cnt).map(|(s, c)| q8(if *c > 0 { (*s / u64::from(*c)) as u32 } else { 0 })).collect(); + let data = data.into(); GrayTile { width: nw as u32, height: nh as u32, data } } } @@ -141,7 +145,7 @@ struct GrayTileIn { impl Serialize for GrayTile { fn serialize(&self, s: S) -> Result { let mut bytes = Vec::with_capacity(self.data.len() * 2); - for v in &self.data { + for v in self.data.iter() { bytes.extend_from_slice(&v.to_le_bytes()); } let enc = b64_encode(&bytes); @@ -164,7 +168,7 @@ impl<'de> Deserialize<'de> for GrayTile { if n == 0 || data.len() != n { return Err(D::Error::custom(format!("tile data has {} samples, expected {}×{}", data.len(), t.width, t.height))); } - Ok(GrayTile { width: t.width, height: t.height, data }) + Ok(GrayTile { width: t.width, height: t.height, data: data.into() }) } } @@ -300,6 +304,20 @@ impl PatternImage { mod tests { use super::*; + /// Cloning a tile (and the brush settings holding it, on every pointer event of a stroke) + /// shares its samples instead of copying them (#4534). + #[test] + fn clones_share_their_samples() { + let t = GrayTile::from_fn(64, 32, |x, y| ((x + y) % 7) as f32 / 6.0); + let c = t.clone(); + assert!(std::sync::Arc::ptr_eq(&t.data, &c.data)); + assert_eq!(t, c); + let brush = crate::brush::BrushSettings { tip: crate::brush::TipShape::Sampled(t.clone()), ..Default::default() }; + let copy = brush.clone(); + let crate::brush::TipShape::Sampled(ct) = ©.tip else { panic!("sampled") }; + assert!(std::sync::Arc::ptr_eq(&t.data, &ct.data), "the settings' clone shares the tip"); + } + #[test] fn base64_round_trip() { for n in 0..10 { @@ -315,7 +333,7 @@ mod tests { let back: GrayTile = serde_json::from_str(&j).unwrap(); assert_eq!(back, t); let f: GrayTile = serde_json::from_str(r#"{"width":2,"height":1,"data":[0.0,1.0]}"#).unwrap(); - assert_eq!(f.data, vec![0, 65535]); + assert_eq!(f.data, vec![0, 65535].into()); assert!(serde_json::from_str::(r#"{"width":2,"height":2,"data":[0.0,1.0]}"#).is_err()); } @@ -329,8 +347,8 @@ mod tests { assert!(p.data.iter().all(|v| v % 257 == 0), "8-bit levels"); // Small tiles keep their size; invalid ones never panic. assert_eq!(GrayTile::from_fn(5, 3, |_, _| 0.5).preview(96).width, 5); - assert!(GrayTile { width: 4, height: 4, data: vec![1] }.preview(96).is_valid()); - assert!(GrayTile { width: 0, height: 0, data: vec![] }.preview(0).is_valid()); + assert!(GrayTile { width: 4, height: 4, data: vec![1].into() }.preview(96).is_valid()); + assert!(GrayTile { width: 0, height: 0, data: vec![].into() }.preview(0).is_valid()); } #[test] diff --git a/crates/ui-egui/src/brush_preview.rs b/crates/ui-egui/src/brush_preview.rs index a3d2d83121d..ec6b36c07b4 100644 --- a/crates/ui-egui/src/brush_preview.rs +++ b/crates/ui-egui/src/brush_preview.rs @@ -353,7 +353,7 @@ mod tests { let s = tip_alpha(&TipShape::Sampled(bar), 1.0, 0.0, 1.0, (false, false), n); assert!(at(&s, 3, 16) > 0.9 && at(&s, 16, 2) < 0.01); // Degenerate input never panics. - let empty = paint::GrayTile { width: 0, height: 0, data: vec![] }; + let empty = paint::GrayTile { width: 0, height: 0, data: vec![].into() }; assert!(tip_alpha(&TipShape::Sampled(empty), 1.0, 0.0, 0.0, (true, true), 0).iter().all(|v| *v == 0.0)); } } From 0df333f6231816f7b892b45cfab433019dbb0c1d Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:46:33 +0700 Subject: [PATCH 12/47] fix(gpu): require consent to persist CPU recovery (#3539) --- apps/photocraft/src/gpu_startup.rs | 27 +++++++++++++++++++++++++++ apps/photocraft/src/main.rs | 17 +++-------------- docs/development.md | 2 +- 3 files changed, 31 insertions(+), 15 deletions(-) diff --git a/apps/photocraft/src/gpu_startup.rs b/apps/photocraft/src/gpu_startup.rs index bef5bb41f6e..8632fd2a55b 100644 --- a/apps/photocraft/src/gpu_startup.rs +++ b/apps/photocraft/src/gpu_startup.rs @@ -177,6 +177,12 @@ pub fn plan_with_mode(pref: GpuBackend, mode: RenderingMode, crashed: Option<&Ma plan(backend, crashed, env, safe_gpu, os) } +/// Persist successful hardware fallbacks, but never silently pin CPU mode. +/// The recovery dialog explicitly stores CPU mode when the user chooses it. +pub fn backend_to_remember(plan: &Plan) -> Option { + (plan.remember && plan.backend != GpuBackend::Cpu).then_some(plan.backend) +} + /// Instance backends for `plan` (`None`: egui's default, which honours `WGPU_BACKEND`). pub fn backends(plan: &Plan, os: Os) -> Option { if plan.env.is_some() { @@ -541,6 +547,27 @@ mod tests { assert_eq!(plan(Auto, None, Some("dx12"), true, Os::Windows).backend, Cpu); } + /// #3483: A temporary CPU recovery must not change the next launch. + #[test] + fn cpu_recovery_requires_explicit_confirmation_to_persist() { + let gl_crash = crashed("gl", "gl"); + let cpu = plan_with_mode(Auto, RenderingMode::Auto, Some(&gl_crash), None, false, Os::Other); + assert_eq!((cpu.backend, cpu.remember), (Cpu, true)); + assert_eq!(backend_to_remember(&cpu), None); + + let safe = plan_with_mode(Auto, RenderingMode::Auto, None, None, true, Os::Other); + assert_eq!(safe.backend, Cpu); + assert_eq!(backend_to_remember(&safe), None); + // An unconfirmed fallback leaves the original automatic selection intact. + assert_eq!(plan_with_mode(Auto, RenderingMode::Auto, None, None, false, Os::Other).backend, Auto); + // A working hardware fallback can still be remembered. + let vk_crash = crashed("auto", "vulkan"); + let gl = plan_with_mode(Auto, RenderingMode::Auto, Some(&vk_crash), None, false, Os::Other); + assert_eq!(backend_to_remember(&gl), Some(Gl)); + // CPU explicitly selected in preferences still works. + assert_eq!(plan_with_mode(Auto, RenderingMode::Cpu, None, None, false, Os::Other).backend, Cpu); + } + #[test] fn safe_gpu_forces_the_cpu_for_one_launch() { let p = plan(Vulkan, Some(&crashed("vulkan", "vulkan")), None, true, Os::Other); diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 938fc1f032c..87dd35229e7 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -449,24 +449,13 @@ fn main() -> eframe::Result { photocraft_ui_egui::gpu_status::queue_fallback_notice(&mut app, &reason); } app.perf.span("gpuSentinel", sentinel_ms); - // Once the first frames rendered: clear the marker, and keep a crash fallback. - let remember_cpu = - std::env::var_os("PHOTOCRAFT_GPU_STARTUP_FAILURE").is_some() || (plan.remember && plan.backend == photocraft_engine::prefs::GpuBackend::Cpu); - let remember = plan.remember.then_some(plan.backend); + // CPU rendering after a failed GPU start is temporary. Only the + // recovery-dialog choice may persist it (#3483). + let remember = gpu_startup::backend_to_remember(&plan); app.on_started(move |app| { if let Some(s) = started_sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take() { s.finish(); } - if remember_cpu - && let Err(error) = app.run( - "prefs.set", - serde_json::json!({"values": { - "performance.renderingMode": "cpu", "performance.useGpu": false - }}), - ) - { - log::warn!("couldn't remember CPU recovery: {error}"); - } if let Some(b) = remember && app.session.prefs().performance.gpu_backend != b && let Err(e) = app.run("prefs.set", serde_json::json!({"path": "performance.gpuBackend", "value": b.name()})) diff --git a/docs/development.md b/docs/development.md index bb0de12690c..17c7e67dce2 100644 --- a/docs/development.md +++ b/docs/development.md @@ -82,7 +82,7 @@ CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo test --workspace # runs the Japa ## Graphics startup and device loss -`--safe-gpu` starts with the CPU renderer for one launch (no GPU canvas; a software adapter for the window where the platform has one: WARP on Windows, llvmpipe over GL on Linux). Before creating the wgpu device the app writes and locks `gpu-starting.json` in the config directory; it clears it once the first frames have rendered. A launch that finds an unlocked marker knows the previous start died inside the graphics driver (#4) and uses the next safer backend (Windows: Vulkan → DX12 → CPU; Linux: Vulkan → GL → CPU; macOS: Metal → CPU), remembering it in `performance.gpuBackend` (Preferences › Performance › GPU Backend, with **Reset GPU Backend**). With `auto`, Intel adapters on Windows use DX12. Help › System Info shows the adapter, backend, driver and fallback state. +`--safe-gpu` starts with the CPU renderer for one launch (no GPU canvas; a software adapter for the window where the platform has one: WARP on Windows, llvmpipe over GL on Linux). Before creating the wgpu device the app writes and locks `gpu-starting.json` in the config directory; it clears it once the first frames have rendered. A launch that finds an unlocked marker knows the previous start died inside the graphics driver (#4) and uses the next safer backend (Windows: Vulkan → DX12 → CPU; Linux: Vulkan → GL → CPU; macOS: Metal → CPU), remembering a working hardware backend in `performance.gpuBackend` (Preferences › Performance › GPU Backend, with **Reset GPU Backend**). CPU recovery is temporary until **Keep Using CPU** is chosen in the recovery dialog; dismissing the dialog or quitting without a choice preserves the previous GPU preference (#3483). With `auto`, Intel adapters on Windows use DX12. Help › System Info shows the adapter, backend, driver and fallback state. On DX12 the shader compiler is FXC (`d3dcompiler_47.dll`, part of Windows), or a `dxcompiler.dll` placed beside `photocraft.exe`, loaded by its full path. wgpu's default looks `dxcompiler.dll` up by name, which reaches the current directory and `PATH` and loaded other programs' incompatible builds (#712). From 663863e62bcdd0a7c157b0c2d4b8a9904db3469e Mon Sep 17 00:00:00 2001 From: Gert Pellin - GPE Date: Tue, 13 Oct 2026 18:46:40 +0200 Subject: [PATCH 13/47] Smart Objects > Export Contents: another extension exports the contents in that format (#4847) Export Contents wrote the smart object's file as it was whatever the path, so out.png held .pcraft or .psb data. With the contents' own extension (or none) it still copies the file; with another one it opens the contents and exports them in that format, as File > Export does ("converted": true, with the export's warnings). jpg/jpeg and tif/tiff count as the same; an unknown extension is an error. Fixes #4846 --- crates/engine/src/smart_cmds.rs | 29 ++++++++++++++++++++++++--- crates/engine/src/smart_cmds/tests.rs | 12 ++++++++++- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/smart_cmds.rs b/crates/engine/src/smart_cmds.rs index e108c4d7be6..295b845df0d 100644 --- a/crates/engine/src/smart_cmds.rs +++ b/crates/engine/src/smart_cmds.rs @@ -862,12 +862,28 @@ pub fn contents_of(doc: &Document, id: LayerId) -> Result<(String, Arc>) source_bytes(&doc.metadata, &smart(doc, id)?.source).ok_or_else(|| other("the smart object's contents are unavailable")) } +/// Writes the smart object's file as it is. To a path with another extension (a `.png` for +/// `.psb` or `.pcraft` contents) the contents are opened and exported in that format instead. fn export_contents(s: &mut Session, p: &Value) -> Result { let path = path_param("layer.smartObjects.exportContents", p)?; let id = layer_param(s, p)?; let (name, bytes) = contents_of(&s.active().ok_or(EngineError::NoDocument)?.doc, id)?; - crate::file_cmds::write_file(path, &bytes)?; - Ok(json!({"path": path, "fileName": name, "bytes": bytes.len()})) + let ext = |f: &str| { + let e = std::path::Path::new(f).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase); + e.map(|e| match e.as_str() { + "jpeg" => "jpg".to_string(), + "tiff" => "tif".to_string(), + _ => e, + }) + }; + if ext(path).is_none() || ext(path) == ext(&name) { + crate::file_cmds::write_file(path, &bytes)?; + return Ok(json!({"path": path, "fileName": name, "bytes": bytes.len(), "converted": false})); + } + let contents = decode_source(&name, &bytes)?; + let r = photocraft_io::export(&contents, path, &Default::default()).map_err(|e| other(format!("can't export the contents as {}: {e}", base_name(path))))?; + crate::file_cmds::write_file(path, &r.bytes)?; + Ok(json!({"path": path, "fileName": name, "bytes": r.bytes.len(), "converted": true, "warnings": r.warnings})) } fn edit_contents(s: &mut Session, p: &Value) -> Result { @@ -1216,7 +1232,14 @@ pub fn specs() -> Vec { Ok(json!({"updated": commit_child(s, i)?})) }), spec!("layer.smartObjects.replaceContents", "Replace Contents…", SO, r##"{"layer":id?,"path":str}"##, has_smart, replace_contents), - spec!("layer.smartObjects.exportContents", "Export Contents…", SO, r##"{"layer":id?,"path":str}"##, has_smart, export_contents), + spec!( + "layer.smartObjects.exportContents", + "Export Contents…", + SO, + r##"{"layer":id?,"path":str (another extension than the contents' own exports them in that format)}"##, + has_smart, + export_contents + ), spec!("layer.smartObjects.relinkToFile", "Relink to File…", SO, r##"{"layer":id?,"path":str}"##, has_smart, |s, p| { let path = path_param("layer.smartObjects.relinkToFile", p)?.to_string(); set_source(s, p, "Relink to File", false, |_, _| Ok(SmartSource::Linked { path })) diff --git a/crates/engine/src/smart_cmds/tests.rs b/crates/engine/src/smart_cmds/tests.rs index a6cdbf48d5b..259ae92c432 100644 --- a/crates/engine/src/smart_cmds/tests.rs +++ b/crates/engine/src/smart_cmds/tests.rs @@ -529,8 +529,18 @@ fn export_replace_and_linked_contents() { paint(&mut s); convert(&mut s); let out = dir.join("contents.pcraft"); - s.execute("layer.smartObjects.exportContents", json!({"path": out.to_str().unwrap()})).unwrap(); + let r = s.execute("layer.smartObjects.exportContents", json!({"path": out.to_str().unwrap()})).unwrap(); assert!(photocraft_format::is_pcraft(&std::fs::read(&out).unwrap())); + assert_eq!(r["converted"], false); + // Another extension exports the contents in that format. + for (ext, magic) in [("png", &b"\x89PNG"[..]), ("jpeg", &b"\xFF\xD8\xFF"[..]), ("psd", &b"8BPS"[..])] { + let other = dir.join(format!("contents.{ext}")); + let r = s.execute("layer.smartObjects.exportContents", json!({"path": other.to_str().unwrap()})).unwrap(); + assert_eq!(r["converted"], true, "{ext}"); + assert!(std::fs::read(&other).unwrap().starts_with(magic), "{ext}"); + } + let bad = dir.join("contents.nope"); + assert!(s.execute("layer.smartObjects.exportContents", json!({"path": bad.to_str().unwrap()})).is_err()); // Replace with a flat PNG. let png_doc = Document::with_background( "p", From fec0573e718bcd023eb1b2e7ad19801bc0d687fa Mon Sep 17 00:00:00 2001 From: Shehan Kavishka Date: Tue, 13 Oct 2026 22:16:46 +0530 Subject: [PATCH 14/47] fix(window): reopen at the saved window position instead of re-centring (#4826) (#4850) eframe applies `NativeOptions::centered` after restoring the saved window geometry, so the window moved back to the centre of the primary monitor on every launch. Centre only when ui.ron holds no saved position. Claude-Session: https://claude.ai/code/session_01KrteAUDjpXgQZJ7659DUro Co-authored-by: Leel Wasanatha <260480244+kavishkashehan726-sudo@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- apps/photocraft/src/main.rs | 6 +++++- apps/photocraft/src/ui_state.rs | 34 ++++++++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 87dd35229e7..1739568159b 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -75,7 +75,8 @@ fn custom_titlebar(prefs_file: Option<&std::path::Path>) -> bool { /// The main window: 1440 × 900 (shrunk to fit the monitor, and maximized on the first frame /// when it still doesn't fit, `work_area::fit_window`), centred on the main monitor. Without /// `centered`, Windows cascades each new window from the top-left corner, so it opened at a -/// different offset every launch (#419). Wayland compositors place windows themselves. +/// different offset every launch (#419). A later launch restores the saved position instead +/// (`main` turns `centered` off, #4826). Wayland compositors place windows themselves. fn native_options(custom_titlebar: bool) -> eframe::NativeOptions { eframe::NativeOptions { viewport: egui::ViewportBuilder::default() @@ -330,6 +331,9 @@ fn main() -> eframe::Result { } // eframe restores the saved window layout before our code runs; drop values that would crash it. ui_state::sanitize(options.persistence_path.as_deref()); + // Centre only the first window: eframe centres after restoring the saved position, so a + // window closed on another monitor reopened in the middle of the main one (#4826). + options.centered = !ui_state::has_saved_position(options.persistence_path.as_deref()); // Crash-safe GPU startup (#4): pick the backend (a marker left by a start that died in the // driver moves to a safer one), and lock this start's marker until the first frames render. let t_sentinel = std::time::Instant::now(); diff --git a/apps/photocraft/src/ui_state.rs b/apps/photocraft/src/ui_state.rs index 4f226dcae4f..c4de67a9941 100644 --- a/apps/photocraft/src/ui_state.rs +++ b/apps/photocraft/src/ui_state.rs @@ -27,6 +27,23 @@ pub fn sanitize(path: Option<&Path>) { } } +/// Whether `path` holds a window position from an earlier session, which eframe restores. Then +/// `NativeOptions::centered` must be off: eframe applies it after restoring the saved geometry, +/// so it moved the window back to the centre of the main monitor on every launch (#4826). +pub fn has_saved_position(path: Option<&Path>) -> bool { + let Some(text) = path.and_then(|p| std::fs::read_to_string(p).ok()) else { return false }; + saved_position(&text) +} + +fn saved_position(text: &str) -> bool { + let Ok(kv) = ron::from_str::>(text) else { return false }; + let Some(window) = kv.get("window").and_then(|w| ron::from_str::(w).ok()) else { return false }; + // eframe restores the inner position on macOS and the outer position elsewhere + // (`egui_winit::WindowSettings::initialize_viewport_builder`). + let key = if cfg!(target_os = "macos") { "inner_position_pixels" } else { "outer_position_pixels" }; + matches!(field(&window, key), Some(ron::Value::Option(Some(_)))) +} + /// `Err` names the first value that would crash startup. fn check(text: &str) -> Result<(), String> { // eframe stores a map of key → RON string; a file that doesn't parse is ignored by eframe. @@ -78,7 +95,7 @@ fn field<'a>(v: &'a ron::Value, name: &str) -> Option<&'a ron::Value> { #[cfg(test)] mod tests { - use super::check; + use super::{check, saved_position}; fn file(window: &str, zoom: &str) -> String { let egui = format!("(options:(zoom_factor:{zoom},max_passes:2),data:([(18446744073709551615,(ron:\"(x:1.0)\"))]))"); @@ -105,6 +122,21 @@ mod tests { } } + #[test] + fn a_saved_window_position_is_detected() { + // #4826: the position from the report (Linux Mint, window moved to the third monitor). + let window = "(inner_position_pixels:Some((x:2048.0,y:1265.0)),outer_position_pixels:Some((x:2048.0,y:1228.0)),fullscreen:false,maximized:false,inner_size_points:Some((x:880.0,y:480.0)))"; + assert!(saved_position(&file(window, "1.0"))); + let no_position = "(inner_position_pixels:None,outer_position_pixels:None,fullscreen:false,maximized:false,inner_size_points:Some((x:880.0,y:480.0)))"; + assert!(!saved_position(&file(no_position, "1.0"))); + assert!(!saved_position(&format!("{{\"egui\":{:?}}}", "()")), "no window entry"); + for text in ["", "not ron {{{", "{\"window\":\"(((\"}"] { + assert!(!saved_position(text), "{text:?}"); + } + assert!(!super::has_saved_position(None)); + assert!(!super::has_saved_position(Some(&std::env::temp_dir().join("photocraft-missing-ui.ron")))); + } + #[test] fn unreadable_files_are_left_to_eframe() { // eframe ignores files and entries it can't parse, so they can't crash it. From ecf54d1baf139f6a0b87d51a7fa1ea5d30b71629 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Wed, 14 Oct 2026 00:46:53 +0800 Subject: [PATCH 15/47] Count document patterns in history budgets (#5105) A pattern fill or layer style copies its pattern into the document, but the tile visitor behind History's byte budget skipped Document::patterns. The pattern's pixels were left out of the budget, of trimming and of Edit > Purge > Histories, including after an undo left them only to the redo states. Related to #4972. Co-authored-by: Claude Opus 5.5 --- crates/ops/src/lib.rs | 38 ++++++++++++++++++++++++++++++++++++++ crates/ops/src/tiles.rs | 4 ++++ 2 files changed, 42 insertions(+) diff --git a/crates/ops/src/lib.rs b/crates/ops/src/lib.rs index 2a82edb4177..a20b841c174 100644 --- a/crates/ops/src/lib.rs +++ b/crates/ops/src/lib.rs @@ -581,6 +581,44 @@ mod tests { } } + /// A pattern fill or layer style copies its pattern into the document, so the pattern's tiles + /// count like layer pixels, shared ones once; undoing that step leaves them to the redo states. + #[test] + fn document_patterns_count_as_document_pixels() { + use photocraft_doc::{Pattern, Surface}; + for depth in SampleType::ALL { + let tile = 4 * 256 * 256 * depth.bytes(); + let mut cur = Arc::new(Document::new("patterns", Size::new(64, 64), ColorMode::Rgb, depth)); + let mut h = History { max_bytes: usize::MAX, ..History::default() }; + for name in ["A", "B"] { + edit(&mut h, &mut cur, "Rename", |doc| doc.name = name.into()); + h.trim(&cur); + } + let mut surface = Surface::new(cur.pixel_format()); + surface.write_pixel(1, 1, &[0.0, 0.0, 1.0, 1.0]); + let pattern = Pattern::new("Pattern", surface, 4, 4); + edit(&mut h, &mut cur, "Pattern Fill", |doc| doc.patterns.push(pattern)); + assert_eq!(h.pixel_bytes(&cur), tile, "the document's pattern"); + assert_eq!(h.unique_bytes(&cur), 0); + assert_counter(&h, &cur); + edit(&mut h, &mut cur, "Rename", |doc| doc.name = "C".into()); + assert_eq!(h.pixel_bytes(&cur), tile, "shared with history, it counts once"); + assert_counter(&h, &cur); + for _ in 0..2 { + cur = h.undo(cur).unwrap().0; + assert_counter(&h, &cur); + } + assert!(cur.patterns.is_empty()); + assert_eq!(h.unique_bytes(&cur), tile, "only the redo states hold the pattern"); + assert_eq!(h.pixel_bytes(&cur), tile); + cur = h.redo(cur).unwrap().0; + assert_eq!(h.unique_bytes(&cur), 0); + assert_counter(&h, &cur); + h.clear(); + assert_eq!(h.pixel_bytes(&cur), tile); + } + } + #[test] fn counter_tracks_eviction_undo_redo_branching_purge_and_clones() { for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { diff --git a/crates/ops/src/tiles.rs b/crates/ops/src/tiles.rs index feb639c8711..b8d27775f93 100644 --- a/crates/ops/src/tiles.rs +++ b/crates/ops/src/tiles.rs @@ -30,6 +30,10 @@ pub(crate) fn surfaces(doc: &Document, mut visit: impl FnMut(&Surface)) { for channel in doc.channels.iter().chain(&doc.quick_mask) { visit(&channel.surface); } + // A pattern fill or layer style copies its pattern into the document. + for pattern in &doc.patterns { + visit(&pattern.surface); + } if let Some(selection) = &doc.selection { visit(selection); } From 1bac788706f17780a29e5a7b873ea03fe49ded7b Mon Sep 17 00:00:00 2001 From: Gert Pellin - GPE Date: Tue, 13 Oct 2026 18:47:01 +0200 Subject: [PATCH 16/47] info: a smart object embedded in a PSD is reported as embedded (#4843) PSD import keeps every placed layer as SmartSource::Linked { path: } so duplicates share the file and export stays byte-stable, and info reported that as a link whose path is the uuid. When the uuid has an embedded file (a liFD item in the preserved lnk2/lnk3/lnkD blocks), the document's info now says {"kind": "embedded", "fileName", "uuid"}; the new linked::embedded_file_name finds it without copying the file. Fixes #4842 --- crates/engine/src/inspect.rs | 21 ++++++++++++------- crates/engine/src/smart_cmds/tests.rs | 4 ++++ crates/io/src/linked.rs | 30 +++++++++++++++++++++++++-- 3 files changed, 45 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index bdda8c78f88..22c3d3a96f7 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -38,7 +38,7 @@ pub fn document(d: &DocState) -> Value { "isolatedLayers": d.isolated_layers.iter().map(|l| l.0).collect::>(), "hasSelection": doc.selection.is_some(), "selectionBounds": doc.selection.as_ref().map(|s| { let r = s.content_bounds(); [r.x0, r.y0, r.width() as i32, r.height() as i32] }), - "layers": doc.layers.iter().rev().map(|l| layer_sel(l, &selected)).collect::>(), + "layers": doc.layers.iter().rev().map(|l| layer_sel(l, &selected, Some(&doc.metadata))).collect::>(), "history": d.history.entries(), "snapshots": d.snapshots.iter().map(|snap| json!({"id": snap.id, "name": snap.name})).collect::>(), "canUndo": d.history.can_undo(), @@ -63,11 +63,12 @@ pub fn document(d: &DocState) -> Value { /// Layer tree top-to-bottom (display order). pub fn layer(l: &Layer) -> Value { - layer_sel(l, &[]) + layer_sel(l, &[], None) } -/// [`layer`] with a `selected` flag on every node (Layers panel multi-selection). -fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId]) -> Value { +/// [`layer`] with a `selected` flag on every node (Layers panel multi-selection); with the +/// document's `meta`, a smart object whose file is embedded in the PSD says so. +fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId], meta: Option<&photocraft_doc::Metadata>) -> Value { let bounds = l.surface().map(|s| { let r = s.content_bounds(); [r.x0, r.y0, r.width() as i32, r.height() as i32] @@ -91,7 +92,7 @@ fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId]) -> Value { }); match &l.content { LayerContent::Group(g) => { - v["children"] = Value::Array(g.children.iter().rev().map(|c| layer_sel(c, selected)).collect()); + v["children"] = Value::Array(g.children.iter().rev().map(|c| layer_sel(c, selected, meta)).collect()); v["expanded"] = json!(g.expanded); if let Some(a) = &g.artboard { v["artboard"] = json!({"rect": [a.rect.x0, a.rect.y0, a.rect.width(), a.rect.height()], "background": a.background.name(), "preset": a.preset}); @@ -111,11 +112,15 @@ fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId]) -> Value { ); v["smartFiltersEnabled"] = json!(sm.filters_enabled); // Where the contents come from. A PSD placed layer (embedded or linked in Photoshop) is - // `linked` to the `Idnt` uuid of its file, which its duplicates share; New Smart Object - // via Copy embeds its own copy of the file instead. + // `linked` to the `Idnt` uuid of its file, which its duplicates share; when the file + // is embedded in the PSD (a `liFD` item) it is reported as embedded, with that uuid. + // New Smart Object via Copy embeds its own copy of the file instead. v["smartSource"] = match &sm.source { SmartSource::Embedded { file_name, .. } => json!({"kind": "embedded", "fileName": file_name}), - SmartSource::Linked { path } => json!({"kind": "linked", "path": path}), + SmartSource::Linked { path } => match meta.and_then(|m| photocraft_io::linked::embedded_file_name(m, path)) { + Some(name) => json!({"kind": "embedded", "fileName": name, "uuid": path}), + None => json!({"kind": "linked", "path": path}), + }, }; } LayerContent::Text(t) => { diff --git a/crates/engine/src/smart_cmds/tests.rs b/crates/engine/src/smart_cmds/tests.rs index 259ae92c432..a764712c9fa 100644 --- a/crates/engine/src/smart_cmds/tests.rs +++ b/crates/engine/src/smart_cmds/tests.rs @@ -1062,6 +1062,10 @@ fn inspect_reports_the_smart_source() { let source = |s: &Session| crate::inspect::layer(s.active().unwrap().doc.layer(s.active().unwrap().active_layer.unwrap()).unwrap())["smartSource"].clone(); let front = source(&s); assert_eq!(front, json!({"kind": "linked", "path": "uuid-1"})); + // The document's info knows the file is embedded in the PSD (a `liFD` item) and says so, + // with its file name; the uuid stays its key. + let info = crate::inspect::document(s.active().unwrap()); + assert_eq!(info["layers"][0]["smartSource"], json!({"kind": "embedded", "fileName": "in.png", "uuid": "uuid-1"})); // A duplicate shares the file; New Smart Object via Copy gets its own. s.execute("layer.duplicate", json!({})).unwrap(); assert_eq!(source(&s), front, "a duplicate shares its source"); diff --git a/crates/io/src/linked.rs b/crates/io/src/linked.rs index eaf77e864fe..6634735ef0f 100644 --- a/crates/io/src/linked.rs +++ b/crates/io/src/linked.rs @@ -50,6 +50,14 @@ impl<'a> Rd<'a> { /// Parses one item; `None` for malformed data or non-`liFD` items. fn parse_item(item: &[u8]) -> Option { + let (uuid, file_name, start, len) = item_header(item)?; + let bytes = item.get(start..start.checked_add(len)?)?.to_vec(); + Some(LinkedFile { uuid, file_name, bytes }) +} + +/// The uuid and file name of an embedded (`liFD`) item, and where its file bytes start and how +/// many there are; `None` for malformed data or other items. +fn item_header(item: &[u8]) -> Option<(String, String, usize, usize)> { let mut r = Rd { d: item, p: 0 }; let kind = r.take(4)?; let _version = r.u32()?; @@ -65,8 +73,26 @@ fn parse_item(item: &[u8]) -> Option { if kind != b"liFD" { return None; } - let bytes = r.take(len)?.to_vec(); - Some(LinkedFile { uuid, file_name, bytes }) + Some((uuid, file_name, r.p, len)) +} + +/// The file name of the embedded file for smart-object `uuid` among the document's preserved PSD +/// global blocks, without copying its bytes: whether a placed layer's source is embedded. +pub fn embedded_file_name(meta: &Metadata, uuid: &str) -> Option { + if uuid.is_empty() { + return None; + } + meta.psd_global_blocks.iter().filter(|(_, k, _)| matches!(k, b"lnk2" | b"lnk3" | b"lnkD")).find_map(|(_, _, d)| { + let mut r = Rd { d, p: 0 }; + while let Some(len) = r.u64() { + let item = r.take(usize::try_from(len).ok()?)?; + if let Some((_, name, ..)) = item_header(item).filter(|h| h.0 == uuid) { + return Some(name); + } + r.p = r.p.next_multiple_of(4); + } + None + }) } /// Every embedded (`liFD`) file in one linked-layer block's data. From ef8dc9ea30157d3d5c0499bd7a551bded9e899df Mon Sep 17 00:00:00 2001 From: F4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:47:10 +0200 Subject: [PATCH 17/47] Layer locks: locking the pixels clears the transparency lock, as in Photoshop (#4892) (#4898) --- crates/doc/src/lib.rs | 21 +++++++++++++++++ crates/engine/src/commands.rs | 9 ++------ crates/engine/src/layer_multi_cmds.rs | 33 +++++++++++++++++++++------ crates/ui-egui/src/panels.rs | 4 +++- 4 files changed, 52 insertions(+), 15 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index a5c5381ee1a..f2fd637b955 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -99,6 +99,27 @@ pub struct Locks { } impl Locks { + /// Set the lock a command names (`transparency`, `pixels`, `position`, `artboard`, `all`); + /// false for an unknown name. Lock Image Pixels includes Lock Transparent Pixels, so, as in + /// Photoshop, locking the pixels clears the transparency lock, and that one stays off while the + /// pixels are locked (#4892). + pub fn set(&mut self, key: &str, on: bool) -> bool { + match key { + "transparency" => self.transparency = on && !self.pixels, + "pixels" => { + self.pixels = on; + if on { + self.transparency = false; + } + } + "position" => self.position = on, + "artboard" => self.artboard = on, + "all" => self.all = on, + _ => return false, + } + true + } + /// Whether any lock is set: Photoshop then shows the lock icon on the layer's row. pub fn any(self) -> bool { self.transparency || self.pixels || self.position || self.artboard || self.all diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 89278165e60..2a769f0686c 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -701,13 +701,8 @@ fn build() -> Vec { if let Some(Value::Object(m)) = p.get("locks") { for (k, v) in m { let v = v.as_bool().unwrap_or(false); - match k.as_str() { - "transparency" => l.locks.transparency = v, - "pixels" => l.locks.pixels = v, - "position" => l.locks.position = v, - "artboard" => l.locks.artboard = v, - "all" => l.locks.all = v, - other => return Err(bad("layer.setProps", format!("unknown lock `{other}`"))), + if !l.locks.set(k, v) { + return Err(bad("layer.setProps", format!("unknown lock `{k}`"))); } } } diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 85cef3665dc..8d71810dc97 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -1168,13 +1168,7 @@ fn lock_layers(s: &mut Session, p: &Value) -> Result { for id in &sel { let l = doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?; for (k, v) in &given { - match *k { - "transparency" => l.locks.transparency = *v, - "pixels" => l.locks.pixels = *v, - "position" => l.locks.position = *v, - "artboard" => l.locks.artboard = *v, - _ => l.locks.all = *v, - } + l.locks.set(k, *v); } } Ok(()) @@ -2606,6 +2600,31 @@ mod tests { assert_eq!(s.active().unwrap().history.entries().last().map(|e| e.as_str()), Some("Reverse")); } + /// #4892: Lock Image Pixels includes Lock Transparent Pixels, so the two are never both on: + /// locking the pixels clears the transparency lock, which stays off while they are locked, + /// through both `layer.setProps` and `layer.lockLayers`. + #[test] + fn locking_pixels_clears_and_holds_off_the_transparency_lock() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let id = LayerId(s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap()); + let locks = |s: &Session| doc(s).layer(id).unwrap().locks; + s.execute("layer.setProps", json!({"locks": {"transparency": true}})).unwrap(); + assert!(locks(&s).transparency); + s.execute("layer.setProps", json!({"locks": {"pixels": true}})).unwrap(); + assert!(locks(&s).pixels && !locks(&s).transparency, "locking the pixels clears the transparency lock"); + s.execute("layer.setProps", json!({"locks": {"transparency": true}})).unwrap(); + assert!(!locks(&s).transparency, "and it stays off while the pixels are locked"); + s.execute("layer.setProps", json!({"locks": {"transparency": true, "pixels": true}})).unwrap(); + assert!(locks(&s).pixels && !locks(&s).transparency, "both at once: the pixel lock wins"); + s.execute("layer.lockLayers", json!({"pixels": false})).unwrap(); + s.execute("layer.lockLayers", json!({"transparency": true})).unwrap(); + assert!(locks(&s).transparency && !locks(&s).pixels, "with the pixels unlocked it can be set again"); + s.execute("layer.lockLayers", json!({"pixels": true})).unwrap(); + assert!(locks(&s).pixels && !locks(&s).transparency); + assert!(s.execute("layer.setProps", json!({"locks": {"nope": true}})).is_err()); + } + #[test] fn inspect_reports_selection() { let mut s = session(8); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index a69b40c41d2..9de772632d1 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -2285,7 +2285,9 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ("artboard", "scan", tl!("Prevent auto-nesting in and out of Artboards and Frames"), lk.artboard), ("all", "lock", tl!("Lock all"), lk.all), ] { - if icons::button(ui, icon, 20.0, on, tip).clicked() { + // Lock Image Pixels includes Lock Transparent Pixels, which waits for it (#4892). + let enabled = key != "transparency" || !lk.pixels; + if ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, 20.0, on, tip)).inner.clicked() { actions.push(("layer.setProps".into(), json!({"layer": l.id.0, "locks": { key: !on }}))); } } From 0a5cc0d1a0737dcdf953a2416cbc7b992374c10e Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Tue, 13 Oct 2026 09:47:17 -0700 Subject: [PATCH 18/47] Swatches: importing an .ase file numbers repeated group names in linear time (#5087) swatches.import named each imported group with unique_group, which compares a candidate name with the library's groups one by one and tries "Name", "Name 1", "Name 2" and so on until one is free. Groups without a name all take the import's group name, so a file of n unnamed groups cost about n^3/6 string comparisons. In a test build, importing a file of 9,994 unnamed groups took about 4.5 minutes. The import now builds the set of the library's group names once and keeps the next number to try for each repeated name, as it already does for swatch names. Group numbers still start at 1, and groups get the same names as before. unnamed_groups_are_numbered_in_linear_time imports such a file on a thread of its own and fails when the import takes more than 20 seconds. repeated_group_names_take_the_first_free_number checks the names when a group in the file has a name that the import already gave to an earlier group. --- crates/engine/src/swatch_cmds.rs | 12 ++++++- crates/engine/src/swatch_cmds/tests.rs | 44 ++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/swatch_cmds.rs b/crates/engine/src/swatch_cmds.rs index 72b0c783b76..7844124bdb5 100644 --- a/crates/engine/src/swatch_cmds.rs +++ b/crates/engine/src/swatch_cmds.rs @@ -600,8 +600,18 @@ fn import(s: &mut Session, p: &Value) -> Result { // Names stay unique across the library (agents address swatches by name). let mut next: std::collections::HashMap = std::collections::HashMap::new(); let mut taken: std::collections::HashSet = lib.iter().flat_map(|g| g.items.iter().map(|i| i.name.clone())).collect(); + // Group names are numbered from 1 as in `unique_group` ("Grays 1"), and the numbering + // resumes per base name. + let mut group_next: std::collections::HashMap = std::collections::HashMap::new(); + let mut group_taken: std::collections::HashSet = lib.iter().map(|g| g.name.clone()).collect(); for mut g in groups { - g.name = unique_group(&lib, &g.name); + if group_taken.contains(&g.name) { + let from = group_next.get(&g.name).copied().unwrap_or(1); + let (i, n) = (from..).map(|i| (i, format!("{} {i}", g.name))).find(|(_, n)| !group_taken.contains(n)).unwrap_or((from, g.name.clone())); + group_next.insert(g.name.clone(), i + 1); + g.name = n; + } + group_taken.insert(g.name.clone()); for sw in &mut g.items { if taken.contains(&sw.name) { // Resume numbering per base name, so a file of identical names stays linear. diff --git a/crates/engine/src/swatch_cmds/tests.rs b/crates/engine/src/swatch_cmds/tests.rs index 836b843971f..2a3e8e7ddb0 100644 --- a/crates/engine/src/swatch_cmds/tests.rs +++ b/crates/engine/src/swatch_cmds/tests.rs @@ -291,6 +291,50 @@ fn library_limits() { assert_eq!(g[0].name.chars().count(), MAX_NAME); } +/// Groups without a name take the import's group name. The import used to number each repeated +/// group name by searching the library once for every number it tried, so importing about 10,000 +/// unnamed groups took minutes. The test fails when the import takes more than 20 seconds. +#[test] +fn unnamed_groups_are_numbered_in_linear_time() { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut s = Session::new(); + s.execute("swatches.newGroup", json!({"name": "Ink 2"})).unwrap(); + let n = MAX_GROUPS - s.presets.swatches.len(); + let paper = AseSwatch { name: "Paper".into(), color: AseColor::Gray(1.0), kind: AseKind::Process }; + let mut entries = vec![AseEntry::Group { name: String::new(), swatches: Vec::new() }; n]; + entries[0] = AseEntry::Group { name: String::new(), swatches: vec![paper] }; + let file = b64_encode(&photocraft_psd::ase::write(&AseFile { entries }).unwrap()); + let r = s.execute("swatches.import", json!({"data": file, "group": "Ink"})).unwrap(); + assert_eq!(s.presets.swatches.len(), MAX_GROUPS); + let names: Vec = r["groups"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect(); + let _ = tx.send(names); + }); + let names = match rx.recv_timeout(std::time::Duration::from_secs(20)) { + Ok(names) => names, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => panic!("importing the groups did not finish"), + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => panic!("the import thread panicked"), + }; + // Numbering starts at 1 and skips the number the library already uses. + assert_eq!(names[..4], ["Ink", "Ink 1", "Ink 3", "Ink 4"]); + assert_eq!(names.last(), Some(&format!("Ink {}", names.len()))); + assert_eq!(names.iter().collect::>().len(), names.len()); +} + +#[test] +fn repeated_group_names_take_the_first_free_number() { + let mut s = Session::new(); + for name in ["Brand", "Brand 2"] { + s.execute("swatches.newGroup", json!({"name": name})).unwrap(); + } + let paper = AseSwatch { name: "Paper".into(), color: AseColor::Gray(1.0), kind: AseKind::Process }; + let entries = ["Brand", "Brand", "Brand", "Brand 1"].into_iter().map(|name| AseEntry::Group { name: name.into(), swatches: vec![paper.clone()] }).collect(); + let file = b64_encode(&photocraft_psd::ase::write(&AseFile { entries }).unwrap()); + let r = s.execute("swatches.import", json!({"data": file})).unwrap(); + // The file's "Brand 1" comes after the first "Brand" took that name. + assert_eq!(r["groups"], json!(["Brand 1", "Brand 3", "Brand 4", "Brand 1 1"])); +} + /// A value beyond f32 range used to pass the finite check, become inf as f32 and wrap to a NaN /// hue, which the prefs file stores as `null`; the next launch then dropped every saved preset. #[test] From 1460187522a45aae0b44309c9bc2397e2f0e0e3e Mon Sep 17 00:00:00 2001 From: Sean Hellwig <147074+seanhellwig@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:47:24 -0600 Subject: [PATCH 19/47] Layers: Cmd/Ctrl-click New Layer adds the layer below the current one (#4475) Like Photoshop, Cmd/Ctrl-clicking the Layers panel's New Layer button adds the layer below the selected layer instead of above it. The Background stays at the bottom: below it, the layer lands above it. --- crates/doc/src/lib.rs | 21 +++++++++++++++++++ crates/engine/src/commands.rs | 2 +- crates/engine/src/new_layer.rs | 37 ++++++++++++++++++++++++++++++++-- crates/ui-egui/src/panels.rs | 14 ++++++++++--- 4 files changed, 68 insertions(+), 6 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index f2fd637b955..3850ba0cb50 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -1169,6 +1169,16 @@ impl Document { id } + /// Insert `layer` directly below the layer `below` (or at the bottom of the root if None). + pub fn insert_below(&mut self, below: Option, layer: Layer) -> LayerId { + let id = layer.id; + match below.and_then(|b| self.path_of(b)).and_then(|path| self.siblings_mut(&path)) { + Some((sib, idx)) => sib.insert(idx, layer), + None => self.layers.insert(0, layer), + } + id + } + pub fn remove(&mut self, id: LayerId) -> Option { let path = self.path_of(id)?; let (sib, idx) = self.siblings_mut(&path)?; @@ -1366,6 +1376,17 @@ mod tests { assert!(d.layer(a).is_none()); } + #[test] + fn insert_below_lands_under_the_reference() { + let mut d = doc(); + let bg = d.layers[0].id; + let a = d.insert_above(Some(bg), Layer::raster("A", d.pixel_format())); + let b = d.insert_below(Some(a), Layer::raster("B", d.pixel_format())); + let names: Vec<_> = d.layers.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, ["Background", "B", "A"]); + assert_eq!(d.layers[1].id, b); + } + #[test] fn groups_walk_and_paths() { let mut d = doc(); diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 2a769f0686c..2b28a9946f7 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -573,7 +573,7 @@ fn build() -> Vec { "Layer…", ["Layer", "New"], Some("Cmd+Shift+N"), - r##"{"name":str?,"clipToPrevious":bool=false (Use Previous Layer to Create Clipping Mask; inside a clip group the layer joins it anyway),"color":"none|red|orange|yellow|green|seafoam|blue|indigo|magenta|fuchsia|violet|gray"="none","mode":"Normal|Multiply|…"="Normal","opacity":0..1=1,"fillNeutral":bool=false (fill with the mode's neutral color: modes without one are an error)}"##, + r##"{"name":str?,"clipToPrevious":bool=false (Use Previous Layer to Create Clipping Mask; inside a clip group the layer joins it anyway),"color":"none|red|orange|yellow|green|seafoam|blue|indigo|magenta|fuchsia|violet|gray"="none","mode":"Normal|Multiply|…"="Normal","opacity":0..1=1,"fillNeutral":bool=false (fill with the mode's neutral color: modes without one are an error),"position":"above|below"="above" (below: the Layers panel's Cmd/Ctrl-click; never under the Background)}"##, has_doc, crate::new_layer::run ), diff --git a/crates/engine/src/new_layer.rs b/crates/engine/src/new_layer.rs index ca814e6d4cb..a6f4cefd38a 100644 --- a/crates/engine/src/new_layer.rs +++ b/crates/engine/src/new_layer.rs @@ -24,7 +24,8 @@ pub fn neutral_color(mode: BlendMode) -> Option<([f32; 3], &'static str)> { } } -/// `layer.new.layer`: a pixel layer above the active one, joining the clip group it lands in. +/// `layer.new.layer`: a pixel layer above the active one (below it with `position: below`, the +/// Layers panel's ⌘/Ctrl-click), joining the clip group it lands in. pub fn run(s: &mut Session, p: &Value) -> Result { let flag = |k: &str| match p.get(k) { None | Some(Value::Null) => Ok(false), @@ -49,6 +50,14 @@ pub fn run(s: &mut Session, p: &Value) -> Result { }; let neutral = if fill_neutral { Some(neutral_color(mode).ok_or_else(|| bad(format!("{} has no neutral color to fill with", mode.label())))?.0) } else { None }; + let below = match p.get("position") { + None | Some(Value::Null) => false, + Some(v) => match v.as_str() { + Some("above") => false, + Some("below") => true, + _ => return Err(bad("`position` must be above or below")), + }, + }; let name = match p.get("name") { None | Some(Value::Null) => None, Some(v) => Some(v.as_str().ok_or_else(|| bad("`name` must be a string"))?.to_string()), @@ -65,7 +74,11 @@ pub fn run(s: &mut Session, p: &Value) -> Result { let px = photocraft_raster::from_rgba(&surface.format(), [rgb[0], rgb[1], rgb[2], 1.0]); surface.fill_rect(bounds, &px); } - let id = doc.insert_above(*active, layer); + // Nothing may sit under the Background: below it the layer lands above it instead. + let id = match *active { + Some(a) if below && doc.layer(a).is_some() && !doc.is_background(a) => doc.insert_below(Some(a), layer), + _ => doc.insert_above(*active, layer), + }; crate::clip_group::settle(doc, &[id], &Default::default(), clip); *active = Some(id); Ok(id) @@ -158,6 +171,24 @@ mod tests { assert_eq!(l.surface().unwrap().tile_count(), 0); } + /// `position: below` (⌘/Ctrl-click on the panel button) lands under the active layer; the + /// Background stays at the bottom, so below it the layer lands above it. + #[test] + fn position_below_lands_under_the_active_layer() { + let mut s = busy("rgb", 8); + let names = |s: &Session| s.active().unwrap().doc.layers.iter().map(|l| l.name.clone()).collect::>(); + s.execute("layer.new.layer", json!({"name": "A"})).unwrap(); + let below = s.execute("layer.new.layer", json!({"name": "Below", "position": "below"})).unwrap()["layer"].as_u64().unwrap(); + assert_eq!(names(&s), ["Background", "Below", "A"]); + assert_eq!(s.active().unwrap().active_layer, Some(photocraft_doc::LayerId(below))); + s.execute("layer.new.layer", json!({"name": "Above", "position": "above"})).unwrap(); + assert_eq!(names(&s), ["Background", "Below", "Above", "A"]); + let bg = s.active().unwrap().doc.layers[0].id.0; + s.execute("layer.select", json!({"layer": bg})).unwrap(); + s.execute("layer.new.layer", json!({"name": "OverBg", "position": "below"})).unwrap(); + assert_eq!(names(&s), ["Background", "OverBg", "Below", "Above", "A"]); + } + /// Bad params are errors, never panics, and change nothing. #[test] fn bad_params_are_errors() { @@ -175,6 +206,8 @@ mod tests { json!({"fillNeutral": "yes"}), json!({"clipToPrevious": 1}), json!({"name": 7}), + json!({"position": "sideways"}), + json!({"position": 1}), ] { assert!(s.execute("layer.new.layer", p.clone()).is_err(), "{p}"); } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 9de772632d1..7e35088bfdb 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -2480,9 +2480,17 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { trash.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!(label))); let new_layer = icons::button(ui, "square-plus", 26.0, false, &crate::shortcuts::tip_label(app, "Create a new layer", "layer.new.layer")); if new_layer.clicked() { - // A click makes the layer; ⌥-click asks first (the New Layer dialog), as in Photoshop. - let alt = ui.input(|i| i.modifiers.alt); - actions.push(("layer.new.layer".into(), if alt { Value::Null } else { json!({}) })); + // A click makes the layer above the selection; ⌥-click asks first (the New Layer + // dialog) and ⌘/Ctrl-click adds it below, as in Photoshop. + let (alt, below) = ui.input(|i| (i.modifiers.alt, i.modifiers.command)); + let params = if alt { + Value::Null + } else if below { + json!({"position": "below"}) + } else { + json!({}) + }; + actions.push(("layer.new.layer".into(), params)); } actions.extend(footer_drop(ui, &new_layer, footer_drag, "layer.duplicate")); let group = icons::button(ui, "folder", 26.0, false, tl!("Create a new group")); From 55ae9bc11b64291c0d35a8d70688e50b5e6001a0 Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 18:47:32 +0200 Subject: [PATCH 20/47] Docs: document the engine.commands reply shape (#3684) (#3750) * Docs: document the engine.commands reply shape (#3684) engine.commands, the engine command command.list and the MCP tool command_list reply with a bare JSON array of command objects, which the docs never said. Document the shape (keys, types, an example element, how filter/enabled_only narrow it) in control-protocol.md and mcp.md, state it in the MCP tool description, and pin it with an rpc test so the docs cannot drift. The shape itself is unchanged, so existing agents and scripts keep working. Co-Authored-By: Claude Opus 5.5 (1M context) * Command list docs/test: include main's disabledReason and needsDocument keys Main added `disabledReason` and `needsDocument` to every command list row (#3664), so the documented shape and the pinning test now list all eight keys. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- crates/automation/src/rpc.rs | 34 ++++++++++++++++++++++++++++++ crates/automation/src/server.rs | 3 ++- docs/control-protocol.md | 37 +++++++++++++++++++++++++++++---- docs/mcp.md | 4 +++- 4 files changed, 72 insertions(+), 6 deletions(-) diff --git a/crates/automation/src/rpc.rs b/crates/automation/src/rpc.rs index 5d44f0e7a78..507010b9c88 100644 --- a/crates/automation/src/rpc.rs +++ b/crates/automation/src/rpc.rs @@ -363,6 +363,40 @@ mod tests { } } + /// Pins the reply shape documented in docs/control-protocol.md › Command list replies + /// (#3684): a bare JSON array (not `{"commands": [...]}`), one object per command with + /// exactly these keys, from `engine.commands` (filtered or not) and `command.list`. + #[test] + fn command_list_replies_are_documented_array_of_objects() { + let h = session(); + let mut g = h.lock().unwrap(); + let all = g.handle("engine.commands", json!({})).unwrap(); + let filtered = g.handle("engine.commands", json!({"filter": "BLUR"})).unwrap(); + let engine = g.handle("engine.execute", json!({"command": "command.list"})).unwrap(); + for (name, reply) in [("engine.commands", &all), ("engine.commands {filter}", &filtered), ("command.list", &engine)] { + let items = reply.as_array().unwrap_or_else(|| panic!("{name} must reply with a bare array, got {reply}")); + assert!(!items.is_empty(), "{name}"); + for c in items { + let mut keys: Vec<&str> = c.as_object().unwrap().keys().map(String::as_str).collect(); + keys.sort_unstable(); + assert_eq!(keys, ["disabledReason", "enabled", "id", "label", "menu", "needsDocument", "params", "shortcut"], "{name}: {c}"); + assert!(c["id"].is_string() && c["label"].is_string() && c["params"].is_string() && c["enabled"].is_boolean(), "{c}"); + assert!(c["needsDocument"].is_boolean(), "{c}"); + assert_eq!(c["disabledReason"].is_null(), c["enabled"] == true, "disabledReason is null exactly when enabled: {c}"); + assert!(c["menu"].as_array().unwrap().iter().all(Value::is_string), "{c}"); + assert!(c["shortcut"].is_string() || c["shortcut"].is_null(), "{c}"); + } + } + let n_all = all.as_array().unwrap().len(); + assert_eq!(engine.as_array().unwrap().len(), n_all); + let filtered = filtered.as_array().unwrap(); + assert!(filtered.len() < n_all); + assert!(filtered.iter().all(|c| format!("{} {}", c["id"], c["label"]).to_lowercase().contains("blur"))); + let new = all.as_array().unwrap().iter().find(|c| c["id"] == "file.new").unwrap(); + assert_eq!((&new["menu"], &new["shortcut"], &new["enabled"]), (&json!(["File"]), &json!("Cmd+N"), &json!(true)), "{new}"); + assert_eq!((&new["disabledReason"], &new["needsDocument"]), (&Value::Null, &json!(false)), "{new}"); + } + #[test] fn lines_round_trip_and_errors() { let h = session(); diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index ea1b40239d0..b31507b0051 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -796,7 +796,8 @@ impl PhotocraftMcp { } } - #[tool(description = "List engine commands: id, label, menu path, shortcut, parameter doc, enabled now. \ + #[tool(description = "List engine commands. Returns a JSON array (no wrapper object) of {id, label, menu: [path], \ + shortcut: string|null, params: doc string, enabled: bool, disabledReason: string|null, needsDocument: bool}. \ `disabledReason` says why a command is off (null when enabled); `needsDocument` is true when it is off because no \ document is open: create or open one first (doc_new, doc_open).")] async fn command_list(&self, Parameters(p): Parameters) -> Result { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 256f377605e..771fec92e9c 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -45,7 +45,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i ## Methods - `engine.execute {command, params}`: run any engine or UI command by id. Engine commands run directly with their default params and never open a dialog. Use `ui.menu.invoke` for menu-click behaviour, which opens a command's dialog when no params are given. `params` must be a JSON object (omit it or pass `null` for none); an array, string, number or boolean is an error naming the command, in every transport (control channel, `serve`, MCP and the CLI) -- `engine.commands`: list commands with enablement +- `engine.commands`: list commands with enablement; the `result` is a JSON array of command objects (see [Command list replies](#command-list-replies)) - `ui.inspect`: full UI state (tool, panels, views, dialogs, windows, window size). The menu tree is not included; use `ui.menu.list`. `brushPicker` is `null` while closed and `{pos: [x, y], size: [w, h]?, list: {collapsed, filter, renaming, showName, showStroke, showTip, scale}}` while the Brush Preset picker is open (coordinates in screen points; `size` is the picker's content size once its corner grip was dragged, `null` before that, and the drag remembers both dimensions; the three `show*` flags pick the parts of each preset's card — at least one is always on; `scale` is the footer slider, 0.15–2 with 1 the standard, multiplying the cards' width and height — the heights stop at half size and the name and tip-size text hold their 1.0 size — and dropping the tips' size numbers at 0.40 and below; the parts and the scale are remembered across restarts, while the search, collapsed groups and a rename in progress are not). `view` holds the View/Window/Type preferences: `screen_mode`, `extras`, `show` and `snap_to` flags, `flip_horizontal`, `arrange` (Window › Arrange layout), pixel aspect, font preview size, language options. `perf.timings.gpuInfo` holds the graphics adapter, backend, driver, the backend chosen at launch and why, the canvas renderer (`gpu`/`cpu`) and, after a device loss, `lost` (`help.systemInfo` returns the same as `info`) - `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, selectionMode?, zoom?, center?, rotation?, fit?, theme?, brushSize?, brushSection?, brushTab?, brushesView?, brushPicker?, brushPickerName?, brushPickerStroke?, brushPickerTip?, brushPickerScale?, gradientBlendMode?, gradientClassic?, shapeStroke?}`: change UI state; any other field is an error, checked before anything changes (`zoom` is the canvas zoom where 1 = 100%, one document pixel per physical display pixel, so a scaled display doesn't magnify the image; `theme` is `pro`, `proMedium`, `studio`, `studioLight` or `classic`; `selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `brushesView` is `list` or `grid` for the Brushes panel; `brushPickerName`, `brushPickerStroke` and `brushPickerTip` are the Brush Preset picker's card parts (booleans, at least one on), and `brushPickerScale` its footer card-scale slider (0.15 to 2, 1 the standard); `brushPicker` is `[x, y]` in screen points to open the Brush Preset picker there (as a right-click with a painting tool does) or `null` to close it, and picking a preset in it is `tools.setBrush {"preset": name}`; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"], tabOrder: {"layers": ["Paths", "Layers", "Channels"]}}`, the right-dock groups top to bottom, their heights in points, the groups collapsed to their tab strip and each group's tab order left to right by panel name (a group left out keeps its default order, unknown names are ignored and tabs not listed follow in their default order; `dockTabs` keeps indexing tabs in their default order); `dockWidth` sets the right dock width in points, clamped to 250..520; `colorPanel` is `{background}`, whether the Color panel edits the background colour; `maskTarget` targets the active layer's mask, which, like Photoshop, swaps in the foreground/background pair kept for masks (white/black until one is picked) and swaps the pixels' pair back when the pixels are targeted again) - `typeTransform` is `null` outside a temporary Ctrl/Cmd Type-tool drag. During a drag it reports its source document/revision, original affine, oriented `frame` (layer, local bounds, document-space corners and pivot), and captured `gesture`. Drive it with `ui.pointer` and `command: true` at `down`; `move`/`up` may release the modifier. The document/history remain unchanged during preview, and `up` applies one `type.edit` within `textEdit`'s session. Pressing Escape during the drag cancels only the preview. This pointer state is never restored from saved UI state. @@ -94,7 +94,7 @@ Change the UI language without restarting with `prefs.set`: are documented in [UI localisation](localization.md). `prefs.get` and the existing preference store expose and persist the same setting; scripts keep using canonical command IDs. -`engine.execute` runs any command by id. `engine.commands` (or the engine command `command.list`) lists them all, with labels, menu paths, shortcuts, a parameter description, and whether each is currently enabled. Each row carries `enabled` (bool), `disabledReason` (`null` when enabled, otherwise the precondition that failed, such as `"no document open"`, `"no selection"` or `"the clipboard is empty"`) and `needsDocument` (true when the command is off because no document is open, so the first step is to create or open one; a command may still have its own precondition after that, such as an active text layer. False when enabled or off for another reason). With no document open, nearly every document command reports `enabled: false, needsDocument: true`; plan around `needsDocument` rather than reading `enabled` alone (#3664). Examples: +`engine.execute` runs any command by id. `engine.commands` (or the engine command `command.list`) lists them all, with labels, menu paths, shortcuts, a parameter description, and whether each is currently enabled (reply shape: [Command list replies](#command-list-replies)). Each row carries `enabled` (bool), `disabledReason` (`null` when enabled, otherwise the precondition that failed, such as `"no document open"`, `"no selection"` or `"the clipboard is empty"`) and `needsDocument` (true when the command is off because no document is open, so the first step is to create or open one; a command may still have its own precondition after that, such as an active text layer. False when enabled or off for another reason). With no document open, nearly every document command reports `enabled: false, needsDocument: true`; plan around `needsDocument` rather than reading `enabled` alone (#3664). Examples: | Command | Params | |---|---| @@ -174,6 +174,35 @@ their usual file access. Source replacement, relinking and export commands remai double-clicking its thumbnail in Layers. Text input, Commit and Cancel use the existing Type tool editing session; non-type layers return an error without changing the tool or document. +### Command list replies + +`engine.commands` (control channel and `serve`), the engine command `command.list` and the MCP +tool `command_list` all reply with a bare JSON array, not an object: there is no +`{"commands": [...]}` wrapper. On the control channel and `serve` the array is the reply's +`result` (`{"id":1,"ok":true,"result":[…]}`); the MCP tool returns it as its JSON text. +Each element describes one command, in registry order: + +```json +{"id":"file.new","label":"New…","menu":["File"],"shortcut":"Cmd+N","params":"{\"width\":1..300000=1920,…}","enabled":true,"disabledReason":null,"needsDocument":false} +``` + +| Key | Type | Meaning | +|---|---|---| +| `id` | string | The id to pass to `engine.execute` / `command_run` | +| `label` | string | Menu label | +| `menu` | array of strings | Menu path above the item (`[]` for commands without a menu item) | +| `shortcut` | string or `null` | Default shortcut, e.g. `"Cmd+Shift+X"` | +| `params` | string | Human-readable parameter doc (not a JSON schema) | +| `enabled` | boolean | Whether the command can run in the current state | +| `disabledReason` | string or `null` | Why the command can't run now (`null` when `enabled`) | +| `needsDocument` | boolean | `true` when it is off because no document is open (#3664) | + +Filtering narrows the same array without changing its elements: `serve`'s `engine.commands +{filter}` and the MCP tool's `filter` keep commands whose id or label contains the text +(case-insensitive), and the MCP tool's `enabled_only` keeps those with `enabled: true`. The +desktop control channel's `engine.commands` takes no filter and always returns every command. +The shape is pinned by a test in `crates/automation/src/rpc.rs` (#3684). + ### Background jobs (#210) Long commands (every `filter.*`, `edit.contentAwareFill`, `edit.contentAwareScale`, `file.automate.photomerge`, `brush.presets.importAbr`), file opens and interactive saves (File › Save, Save As and the TIFF Options continuation, #2017) run as background jobs in the desktop app: the window keeps drawing, the status bar shows progress with a Cancel button, and jobs that lock the active document show a modal progress dialog (Esc cancels). `engine.execute`, `ui.menu.invoke`, `ui.dialog.confirm` and `app.save` still wait for the result by default; pass `"wait": false` to get `{job, pending: true}` at once, then poll `jobs.list` (`state`: running, done, failed, cancelled; `progress` 0–1; the result or error) and stop it with `jobs.cancel {job}`. A cancelled or failed job leaves the document unchanged. While a job runs, commands that would edit its document fail with "… is still running on this document". `ui.inspect` reports `jobs` (running jobs, opening files, saving files). Set `PHOTOCRAFT_INLINE_JOBS=1` to run everything inline. @@ -267,7 +296,7 @@ How each MCP tool maps onto control methods in bridge mode: |---|---| | `command_run {id, params, wait?}` | `engine.execute {command: id, params, wait}` | | `jobs_list` / `jobs_cancel {job?}` | `jobs.list` / `jobs.cancel {job?}` | -| `command_list {filter?, enabled_only?}` | `engine.commands` (filtered by the MCP server; rows keep `disabledReason` and `needsDocument`) | +| `command_list {filter?, enabled_only?}` | `engine.commands` (filtered by the MCP server; replies with the same bare array, rows keep `disabledReason` and `needsDocument`, see [Command list replies](#command-list-replies)) | | `doc_new {…}` | `engine.execute {command: "file.new", params}` | | `doc_inspect` | `engine.execute {command: "document.inspect"}` | | `doc_open {path}` | `app.open {path}` | @@ -325,7 +354,7 @@ no MCP framing, no app start-up per command. Configure its file access with the |---|---| | `engine.execute` | `{command, params?, wait?}`: any engine command (`wait: false` starts a long one as a background job: `{job, pending}`) | | `jobs.list` / `jobs.cancel` | `{}` / `{job?}`: background jobs; cancel one or all. Every request (and MCP tool call) first applies the jobs that finished, so `doc.save`, `doc.inspect`, `doc.render` and `session.list` include a finished job's result without polling `jobs.list` first | -| `engine.commands` | `{filter?}`: registry with params docs and enablement (`enabled`, `disabledReason`, `needsDocument`) | +| `engine.commands` | `{filter?}`: registry with params docs and enablement (`enabled`, `disabledReason`, `needsDocument`); `result` is a bare array of command objects ([Command list replies](#command-list-replies)) | | `session.list` | open documents and the active index | | `doc.open` / `doc.new` | `{path}` / `file.new` params | | `doc.place` | `{path, scale? (percent), fit? (true), center? ([x, y]), index?}`: places a file as a Smart Object layer above the active one and returns `{layer, scale, bounds}`. `path` resolves beneath the read root, so this is the capability-scoped `file.placeEmbedded` — that command itself still refuses an ambient path | diff --git a/docs/mcp.md b/docs/mcp.md index 4f7e9cc7043..3046a669bc8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -13,7 +13,9 @@ described in [development.md](development.md). UI tools require a running deskto ## Tools and resources - `command_list`: discover engine ids, parameter docs and current enabled state; accepts - `filter` and `enabled_only`. + `filter` and `enabled_only`. Returns a bare JSON array of + `{id, label, menu, shortcut, params, enabled, disabledReason, needsDocument}` objects (see + [Command list replies](control-protocol.md#command-list-replies)). - `command_run`: `id`, optional `params` and `wait`. Command parameters remain engine-defined. - `command_batch`: ordered `steps`, optional `stop_on_error` (default true); stops at the first error unless false. Not an atomic transaction: earlier successful steps stay applied, with From b3df06b8c47c43a7ba2c9b7bbf6c9137bd40e427 Mon Sep 17 00:00:00 2001 From: FinnNk Date: Tue, 13 Oct 2026 17:47:40 +0100 Subject: [PATCH 21/47] perf(engine): lasso and polygon selections rasterise only the rows they span (#4532) (#4773) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit polygon_selection rasterised every lasso over the whole canvas (a w×h f32 mask) and combined it over the whole canvas, whatever the polygon's size. Without feather it now rasterises the full-width band of rows the polygon spans and combines over that band; Add and Subtract keep the old selection outside it, Replace and Intersect leave it empty there, exactly as the whole-canvas combine does. Full-width rows keep `polygon`'s coverage bit-identical (its x arithmetic is relative to the area's left edge, its y samples whole rows plus the same fractions); a feathered lasso keeps the whole-canvas path, whose blur reaches past the polygon. A test compares 24 random polygons, partly off the canvas, in every mode, with and without anti-aliasing, against the old path. Measured, a 2000-point lasso of a 400 px circle on 5472×3648: Replace 15–16 ms → 5 ms; Add to a selection unchanged (about 30 ms). Co-authored-by: Claude Opus 5.5 --- crates/engine/src/selection_cmds.rs | 76 ++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/selection_cmds.rs b/crates/engine/src/selection_cmds.rs index c59d1a4530a..8efb8d7b943 100644 --- a/crates/engine/src/selection_cmds.rs +++ b/crates/engine/src/selection_cmds.rs @@ -5,6 +5,7 @@ use photocraft_algo::selection::{self as sel, SelectionMode}; use photocraft_doc::Document; use photocraft_geom::Rect; +use photocraft_raster::Surface; use serde_json::{Value, json}; use crate::commands::CommandSpec; @@ -603,11 +604,51 @@ pub(crate) fn polygon_selection(s: &mut Session, cmd: &str, label: &str, pts: &[ return Err(EngineError::BadParams { cmd: cmd.into(), msg: "feather must be a number in 0..1000".into() }); } let area = s.active().ok_or(EngineError::NoDocument)?.doc.bounds(); - let mut mask = sel::polygon(pts, area, b(p, "antiAlias", true)); if feather > 0.0 { + // The blur spreads past the polygon and isn't bit-identical over a smaller window. + let mut mask = sel::polygon(pts, area, b(p, "antiAlias", true)); mask = sel::feather(&mask, area.width() as usize, area.height() as usize, feather); + return set_selection(s, label, area, mask, mode(p)); } - set_selection(s, label, area, mask, mode(p)) + // Only the rows the polygon spans (#4532): rows outside it are empty, and full-width rows + // keep `polygon`'s coverage bit-identical (its x arithmetic is relative to the area's left + // edge; its y samples are whole rows plus the same sub-row fractions). + let band = polygon_rows(pts, area); + let mask = sel::polygon(pts, band, b(p, "antiAlias", true)); + let m = mode(p); + let selected = s.edit(label, |doc, _| { + doc.selection = combine_band(doc.selection.as_ref(), &mask, band, m); + Ok(doc.selection.is_some()) + })?; + Ok(json!({ "selected": selected })) +} + +/// The full-width rows of `area` that points `pts` span (empty when none are finite). +fn polygon_rows(pts: &[(f32, f32)], area: Rect) -> Rect { + let ys = pts.iter().map(|p| p.1).filter(|y| y.is_finite()); + let (lo, hi) = ys.fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), y| (lo.min(y), hi.max(y))); + if lo > hi { + return Rect::EMPTY; + } + let clamp = |v: f32| v.clamp(area.y0 as f32, area.y1 as f32) as i32; + let (y0, y1) = (clamp(lo.floor()), clamp(hi.ceil() + 1.0)); + if y0 >= y1 { Rect::EMPTY } else { Rect::new(area.x0, y0, area.x1, y1) } +} + +/// [`sel::combine`] of a mask covering only `band` (zero elsewhere), giving exactly what the +/// whole-canvas combine gives: Replace and Intersect are empty outside the band, and Add and +/// Subtract keep the old selection there. +fn combine_band(old: Option<&Surface>, mask: &[f32], band: Rect, m: SelectionMode) -> Option { + let inside = sel::combine(old, mask, band, m); + let Some(old) = old.filter(|_| matches!(m, SelectionMode::Add | SelectionMode::Subtract)) else { return inside }; + if band.is_empty() { + return Some(old.clone()); + } + let values = inside.map_or_else(|| vec![0.0; band.width() as usize * band.height() as usize], |w| w.read_region(band)); + let mut s = old.clone(); + s.write_region(band, &values); + s.prune(); + (s.tile_count() > 0).then_some(s) } macro_rules! spec { @@ -1270,4 +1311,35 @@ mod tests { } } } + + /// The lasso over the polygon's rows gives exactly the whole-canvas result (#4532): random + /// polygons, partly off the canvas, with and without anti-aliasing, in every mode over an + /// existing selection. + #[test] + fn lasso_over_its_rows_matches_the_whole_canvas() { + let mut seed = 0x2545_f491_u32; + let mut rnd = |lo: f32, hi: f32| { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + lo + (seed % 10_000) as f32 / 10_000.0 * (hi - lo) + }; + for case in 0..24 { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 300, "height": 520})).unwrap(); + s.execute("select.rect", json!({"x": 40, "y": 60, "width": 200, "height": 300})).unwrap(); + let n = 3 + case % 7; + let pts: Vec<(f32, f32)> = (0..n).map(|_| (rnd(-30.0, 330.0), rnd(100.0, 420.0))).collect(); + let aa = case % 2 == 0; + let m = ["replace", "add", "subtract", "intersect"][case % 4]; + let doc = s.active().unwrap().doc.clone(); + let area = doc.bounds(); + let want = sel::combine(doc.selection.as_ref(), &sel::polygon(&pts, area, aa), area, SelectionMode::parse(m)); + let points: Vec<[f32; 2]> = pts.iter().map(|p| [p.0, p.1]).collect(); + s.execute("select.lasso", json!({"points": points, "antiAlias": aa, "mode": m})).unwrap(); + let got = s.active().unwrap().doc.selection.clone(); + let read = |x: &Option| x.as_ref().map(|s| s.read_region(area)); + assert_eq!(read(&got), read(&want), "case {case}: {m}, anti-alias {aa}, {pts:?}"); + } + } } From b007b1d35529355066ed7b20d3e6b0c7e558fda1 Mon Sep 17 00:00:00 2001 From: Gert Pellin - GPE Date: Tue, 13 Oct 2026 18:47:47 +0200 Subject: [PATCH 22/47] Replace Color: Localized Color Clusters (points, localized, range) (#4477) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Replace Color: Localized Color Clusters (points, localized, range) image.adjustments.replaceColor takes the Localized Color Clusters of Select › Color Range: "points" (where the colour was picked), "localized" and "range" (% of the canvas's longer side). With clusters on, the colour only matches near the points, through the same color_range_samples as Color Range, so the same colour elsewhere in the photo (a near-white background next to a cream garment) is left alone. Without "localized" nothing changes. localized without points, and malformed points, are refused. The dialog part follows once the canvas eyedropper of #4247 is in. Refs #4457 * Replace Color dialog: Localized Color Clusters and Range The dialog gets Photoshop's Localized Color Clusters checkbox and its Range slider (greyed while the box is off), and the canvas eyedropper (#4247) now also records where the colour was picked (points), so the localized match centres on that spot. The command's params list localized, range and points as real keys in Photoshop's order. Refs #4457 --- crates/algo/src/tone.rs | 10 ++- crates/engine/src/adjust_cmds.rs | 46 ++++++++++++- crates/engine/src/adjust_cmds/tests.rs | 35 ++++++++++ crates/ui-egui/src/filter_dialog.rs | 90 ++++++++++++++++++++------ 4 files changed, 155 insertions(+), 26 deletions(-) diff --git a/crates/algo/src/tone.rs b/crates/algo/src/tone.rs index 8592b3dda2a..2632d4a29a4 100644 --- a/crates/algo/src/tone.rs +++ b/crates/algo/src/tone.rs @@ -61,7 +61,14 @@ pub use shadows_highlights::shadows_highlights; /// Returns the selection mask (what the dialog previews). pub fn replace_color(px: &mut [[f32; 4]], color: [f32; 3], fuzziness: f32, hue: f32, saturation: f32, lightness: f32) -> Vec { let mask = crate::selection::color_range(px, color, fuzziness); - for (q, &k) in px.iter_mut().zip(&mask) { + replace_color_masked(px, &mask, hue, saturation, lightness); + mask +} + +/// Replace Color's hue, saturation and lightness shift, applied to each pixel as far as `mask` +/// (`0..=1`, one per pixel: the colour match, localized or not) covers it. +pub fn replace_color_masked(px: &mut [[f32; 4]], mask: &[f32], hue: f32, saturation: f32, lightness: f32) { + for (q, &k) in px.iter_mut().zip(mask) { if k <= 0.0 { continue; } @@ -74,7 +81,6 @@ pub fn replace_color(px: &mut [[f32; 4]], color: [f32; 3], fuzziness: f32, hue: q[i] = (c[i] + (o[i] - c[i]) * k).clamp(0.0, 1.0); } } - mask } /// Lab mean and standard deviation of the opaque pixels, `[L, a, b]` each. diff --git a/crates/engine/src/adjust_cmds.rs b/crates/engine/src/adjust_cmds.rs index 738ef921f47..c1393bb94c9 100644 --- a/crates/engine/src/adjust_cmds.rs +++ b/crates/engine/src/adjust_cmds.rs @@ -334,14 +334,54 @@ fn shadows_highlights_params(p: &Value) -> ShadowsHighlights { } fn replace_color(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "image.adjustments.replaceColor"; let c = crate::commands::color_param(p, "color", s.tools.foreground); let fuzz = num(p, "fuzziness", 40.0).clamp(0.0, 200.0); let (h, sat, l) = (num(p, "hue", 0.0).clamp(-180.0, 180.0), num(p, "saturation", 0.0).clamp(-100.0, 100.0), num(p, "lightness", 0.0).clamp(-100.0, 100.0)); - rgba_edit(s, "Replace Color", p, |px, _| { - tone::replace_color(px, [c[0], c[1], c[2]], fuzz, h, sat, l); + // Localized Color Clusters, as in Select › Color Range: the colour only matches within Range + // (a percentage of the canvas's longer side) of where it was picked. + let points = sample_points(p).map_err(|m| bad(CMD, m))?; + let localized = if p.get("localized").and_then(Value::as_bool).unwrap_or(false) { + if points.is_empty() { + return Err(bad(CMD, "localized clusters need eyedropper `points` (where the colour was picked)")); + } + let canvas = s.active().ok_or(EngineError::NoDocument)?.doc.bounds(); + Some(num(p, "range", 100.0).clamp(0.0, 100.0) / 100.0 * canvas.width().max(canvas.height()) as f32) + } else { + None + }; + rgba_edit(s, "Replace Color", p, |px, r| match localized { + Some(radius) => { + let samples: Vec<_> = points + .iter() + .map(|&(x, y)| photocraft_algo::selection::RangeSample { color: [c[0], c[1], c[2]], at: Some((x - r.x0 as f32, y - r.y0 as f32)) }) + .collect(); + let mask = photocraft_algo::selection::color_range_samples(px, r.width() as usize, &samples, fuzz, Some(radius)); + tone::replace_color_masked(px, &mask, h, sat, l); + } + None => { + tone::replace_color(px, [c[0], c[1], c[2]], fuzz, h, sat, l); + } }) } +/// The `points` of a command (`[[x, y], …]` in document pixels), or a message saying what is wrong. +fn sample_points(p: &Value) -> std::result::Result, String> { + let Some(pts) = p.get("points") else { return Ok(Vec::new()) }; + let pts = pts.as_array().ok_or("`points` must be [[x, y], …]")?; + pts.iter() + .map(|pt| { + pt.as_array() + .and_then(|a| match a.as_slice() { + [x, y] => Some((x.as_f64()? as f32, y.as_f64()? as f32)), + _ => None, + }) + .filter(|(x, y)| x.is_finite() && y.is_finite()) + .ok_or_else(|| format!("bad point {pt} in `points` (want [x, y])")) + }) + .collect() +} + /// Straight RGBA pixels and an optional coverage mask for a document's layer (or its composite). /// Pixels plus an optional coverage mask. type Sample = (Vec<[f32; 4]>, Option>); @@ -454,7 +494,7 @@ pub fn specs() -> Vec { "image.adjustments.replaceColor", "Replace Color…", ["Image", "Adjustments"], - r##"{"color":color,"fuzziness":0..200=40,"hue":-180..180=0,"saturation":-100..100=0,"lightness":-100..100=0} (color: "#rrggbb", default the foreground colour)"##, + r##"{"localized":bool=false,"color":color,"fuzziness":0..200=40,"range":0..100=100,"hue":-180..180=0,"saturation":-100..100=0,"lightness":-100..100=0,"points":[[x,y],…]?} (color: "#rrggbb", default the foreground colour; Localized Color Clusters: "points" where the colour was picked, "range" % of the longer side)"##, has_pixels, replace_color ), diff --git a/crates/engine/src/adjust_cmds/tests.rs b/crates/engine/src/adjust_cmds/tests.rs index 9f8f07db634..8fa053e86e8 100644 --- a/crates/engine/src/adjust_cmds/tests.rs +++ b/crates/engine/src/adjust_cmds/tests.rs @@ -503,3 +503,38 @@ fn a_targeted_mask_enables_editing_it_on_any_layer() { assert!(s.execute("image.adjustments.invert", mask).is_err()); assert_eq!(rgba(&s, 23, 3), pixels); } + +#[test] +fn replace_color_localized_clusters_only_change_the_colour_near_the_sample() { + // The same cream on the left (x < 8) and the right (x ≥ 40), grey between. + let cream = [0.9, 0.88, 0.79, 1.0]; + let mut s = session(8, "rgb"); + paint(&mut s, |x, _| if !(8..40).contains(&x) { cream } else { [0.5, 0.5, 0.5, 1.0] }); + let base = |s: &Session| (rgba(s, 3, 16), rgba(s, 44, 16), rgba(s, 20, 16)); + let (left, right, grey) = base(&s); + let shift = json!({"color": "#e6e0c9", "fuzziness": 20, "hue": 180, "saturation": 60}); + // Without clusters the colour is replaced everywhere (unchanged behaviour). + s.execute("image.adjustments.replaceColor", shift.clone()).unwrap(); + assert!(changed(rgba(&s, 3, 16), left) > 0.05 && changed(rgba(&s, 44, 16), right) > 0.05); + s.execute("edit.undo", json!({})).unwrap(); + // Picked on the left with Range 50 % (24 px): only the left cream changes. + let mut local = shift.clone(); + local["points"] = json!([[2, 16]]); + local["localized"] = json!(true); + local["range"] = json!(50); + s.execute("image.adjustments.replaceColor", local.clone()).unwrap(); + assert!(changed(rgba(&s, 3, 16), left) > 0.05, "near the sample"); + assert_eq!(rgba(&s, 44, 16), right, "the same colour far from the sample"); + assert_eq!(rgba(&s, 20, 16), grey); + s.execute("edit.undo", json!({})).unwrap(); + // Inside a selection that doesn't start at the origin the sample stays where it was picked. + s.execute("select.rect", json!({"x": 1, "y": 4, "width": 46, "height": 24})).unwrap(); + s.execute("image.adjustments.replaceColor", local).unwrap(); + assert!(changed(rgba(&s, 3, 16), left) > 0.05); + assert_eq!(rgba(&s, 44, 16), right); + // Clusters need a place to be near. + let err = s.execute("image.adjustments.replaceColor", json!({"localized": true})).unwrap_err(); + assert!(err.to_string().contains("points"), "{err}"); + let err = s.execute("image.adjustments.replaceColor", json!({"points": [[1]]})).unwrap_err(); + assert!(err.to_string().contains("bad point"), "{err}"); +} diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index 4c19b8943af..90073a55015 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -545,6 +545,10 @@ pub(crate) fn label(key: &str) -> String { } pub(crate) fn source_label(key: &str) -> String { + // Photoshop's name for the localized colour matching (Replace Color, Color Range). + if key == "localized" { + return "Localized Color Clusters".into(); + } // camelCase → "Camel Case" let mut s = String::new(); for (i, ch) in key.chars().enumerate() { @@ -616,6 +620,8 @@ pub(crate) fn color_eyedropper_sample(app: &mut PhotocraftApp, x: f64, y: f64) - && let Some(d) = app.ui.dialog_mut(id) { d.fields.insert("color".into(), json!(crate::color_picker_ui::hex(rgb))); + // Where it was picked: Localized Color Clusters match the colour near this point (#4457). + d.fields.insert("points".into(), json!([[x.floor(), y.floor()]])); } true } @@ -645,6 +651,8 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { f.insert(p.key, json!(count)); continue; } + // Replace Color's Range only applies with Localized Color Clusters on, as in Photoshop. + let enabled = !(cmd == REPLACE_COLOR && p.key == "range") || f.get("localized").and_then(Value::as_bool) == Some(true); let unit = if is_pixel_param_of(&cmd, &p.key) { "px" } else if p.key == "angle" { @@ -654,28 +662,33 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { } else { "" }; - if uses_angle_dial(&cmd, &p.key) { - crate::widgets::angle_dial_row(ui, &label(&p.key), &mut v, min..=max); - ui.add_space(4.0); - } else if uses_logarithmic_slider(min, max) { - // Keep one numeric field for direct entry and one log-scaled slider for - // wide ranges. slider_row would add a second, linear slider (#146). - ui.horizontal(|ui| { - ui.label(egui::RichText::new(label(&p.key)).color(t.text_dim)); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - crate::widgets::value_field(ui, &mut v, min..=max, unit, 74.0); - }); - }); - let mut lv = v.clamp(min, max).ln(); - if crate::widgets::slider(ui, &mut lv, min.ln()..=max.ln(), None).changed() { - v = lv.exp(); + ui.scope(|ui| { + if !enabled { + ui.disable(); } - ui.add_space(4.0); - } else { - // A double-click on the slider puts it back to the filter's default. - let gestures = crate::widgets::RowGestures { reset: Some(default), wheel_step: None }; - crate::widgets::slider_row_with(ui, &label(&p.key), &mut v, min..=max, unit, None, gestures); - } + if uses_angle_dial(&cmd, &p.key) { + crate::widgets::angle_dial_row(ui, &label(&p.key), &mut v, min..=max); + ui.add_space(4.0); + } else if uses_logarithmic_slider(min, max) { + // Keep one numeric field for direct entry and one log-scaled slider for + // wide ranges. slider_row would add a second, linear slider (#146). + ui.horizontal(|ui| { + ui.label(egui::RichText::new(label(&p.key)).color(t.text_dim)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + crate::widgets::value_field(ui, &mut v, min..=max, unit, 74.0); + }); + }); + let mut lv = v.clamp(min, max).ln(); + if crate::widgets::slider(ui, &mut lv, min.ln()..=max.ln(), None).changed() { + v = lv.exp(); + } + ui.add_space(4.0); + } else { + // A double-click on the slider puts it back to the filter's default. + let gestures = crate::widgets::RowGestures { reset: Some(default), wheel_step: None }; + crate::widgets::slider_row_with(ui, &label(&p.key), &mut v, min..=max, unit, None, gestures); + } + }); // Small ranges (0–1 thresholds and centres) keep the two decimals their field shows. let scale = if max - min <= 10.0 { 100.0 } else { 10.0 }; f.insert(p.key, json!((v * scale).round() / scale)); @@ -2169,6 +2182,8 @@ mod tests { assert!(color_eyedropper_armed(&app)); assert!(color_eyedropper_sample(&mut app, 4.0, 4.0)); assert_eq!(app.ui.dialogs.last().unwrap().fields["color"], json!("#00ff00")); + // #4457: where it was picked, for Localized Color Clusters. + assert_eq!(app.ui.dialogs.last().unwrap().fields["points"], json!([[4.0, 4.0]])); // Off-image: the click is consumed, nothing changes and the eyedropper stays armed. assert!(color_eyedropper_sample(&mut app, -1.0, 4.0)); assert_eq!(app.ui.dialogs.last().unwrap().fields["color"], json!("#00ff00")); @@ -2199,4 +2214,37 @@ mod tests { h.run_steps(2); assert!(!color_eyedropper_armed(h.state())); } + + /// #4457: Localized Color Clusters and its Range, as in Photoshop's dialog; the sampled point + /// and both settings reach the command. + #[test] + fn replace_color_localized_color_clusters() { + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + + // Left half red, right half red too: localized on the left point, the right half stays. + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 40, "height": 10, "background": "#ff0000"})).unwrap(); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + let mut h = Harness::builder().with_size(egui::vec2(900.0, 800.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), &mut app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + let id = open(h.state_mut(), REPLACE_COLOR).unwrap(); + h.run_steps(3); + let _ = h.get_by_label("Localized Color Clusters"); + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert(COLOR_EYEDROPPER.into(), json!(true)); + assert!(color_eyedropper_sample(h.state_mut(), 2.0, 5.0)); + h.get_by_label("Localized Color Clusters").click(); + h.run_steps(2); + let f = h.state().ui.dialogs.last().unwrap().fields.clone(); + assert_eq!((f["localized"].clone(), f["points"].clone()), (json!(true), json!([[2.0, 5.0]]))); + // The command with the dialog's fields, small range and a hue shift: only near (2, 5). + let mut p: Map = f.iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone())).collect(); + p.insert("range".into(), json!(10)); + p.insert("hue".into(), json!(120)); + h.state_mut().run(REPLACE_COLOR, Value::Object(p)).unwrap(); + let doc = &h.state().session.active().unwrap().doc; + let px = |x: i32| doc.layers[0].surface().unwrap().read_region(photocraft_geom::Rect::new(x, 5, x + 1, 6)); + assert!(px(2)[1] > 0.5, "near the point: shifted to green {:?}", px(2)); + assert!(px(38)[0] > 0.9 && px(38)[1] < 0.1, "far away: still red {:?}", px(38)); + } } From 4b76f100725a2736862effcaa90192f5fbcee6ee Mon Sep 17 00:00:00 2001 From: Nicolas Falesy <112726756+nicolasfalesy@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:47:55 -0400 Subject: [PATCH 23/47] perf: encode and quantize canvas pixels in parallel (display_color, Buffer::to_rgba8) (#4768) * perf(display): encode linear documents for the screen in parallel CanvasDisplay::texture_buffer (every CPU-path canvas upload of a linear document, which every 32-bit document is since #3792) and to_rgba8's sRGB encode ran one pixel at a time on one thread. Both now split the pixels into 16K-pixel pieces across rayon off wasm; results are bit-identical. 24 MP linear document, median of 5 (22-thread laptop, dev profile): - texture_buffer: 348.5 ms -> 29.6 ms - to_rgba8: 107.8 ms -> 10.6 ms Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AbyXia7KGdFgbNjBeggY8J * perf(compose): quantize Buffer::to_rgba8 in parallel The plain 8-bit quantize behind the CPU canvas, previews, thumbnails and 8-bit exports ran on one thread. Split it into 16K-pixel pieces across rayon off wasm; results are bit-identical. 24 MP buffer, median of 5 (22-thread laptop, dev profile): 122.3 ms -> 11.9 ms Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AbyXia7KGdFgbNjBeggY8J * perf(canvas): build canvas images in parallel, fused with the quantize display_image (CPU canvas updates, filter previews, the navigator, the crop pasteboard) quantized to RGBA8 and then ran egui's single-threaded ColorImage::from_rgba_unmultiplied over every pixel. The identity path now quantizes straight to Color32 in one parallel pass; the colour-managed path converts in parallel. Results are identical to egui's conversion. 24 MP buffer, median of 5 (22-thread laptop, dev profile): 50.9 ms -> 12.7 ms Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AbyXia7KGdFgbNjBeggY8J --------- Co-authored-by: Claude Opus 5.5 --- crates/compose/src/lib.rs | 18 +++++- crates/engine/src/display_color.rs | 36 +++++++++--- crates/engine/src/display_color_tests.rs | 58 +++++++++++++++++++ crates/ui-egui/src/canvas.rs | 72 ++++++++++++++++++++++-- 4 files changed, 166 insertions(+), 18 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 8e88675fdea..8813b122d92 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -58,11 +58,23 @@ impl Buffer { } pub fn to_rgba8(&self) -> Rgba8Image { let mut img = Rgba8Image::new(self.rect.width(), self.rect.height()); - for (o, p) in img.pixels.as_chunks_mut::<4>().0.iter_mut().zip(&self.px) { - for (dst, v) in o.iter_mut().zip(p) { - *dst = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; + let quantize = |out: &mut [[u8; 4]], src: &[[f32; 4]]| { + for (o, p) in out.iter_mut().zip(src) { + for (dst, v) in o.iter_mut().zip(p) { + *dst = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; + } } + }; + let out = img.pixels.as_chunks_mut::<4>().0; + // In parallel off wasm: a 24 MP composite took 120 ms on one core (the CPU canvas, + // previews, thumbnails and 8-bit exports all come through here). + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + out.par_chunks_mut(16 * 1024).zip(self.px.par_chunks(16 * 1024)).for_each(|(o, p)| quantize(o, p)); } + #[cfg(target_arch = "wasm32")] + quantize(out, &self.px); img } /// Flatten over an opaque background colour. diff --git a/crates/engine/src/display_color.rs b/crates/engine/src/display_color.rs index 06490dbade2..1e4aafc909e 100644 --- a/crates/engine/src/display_color.rs +++ b/crates/engine/src/display_color.rs @@ -76,14 +76,15 @@ impl CanvasDisplay { if !self.encode_srgb { return Cow::Borrowed(buf); } - let mut b = buf.clone(); - for p in &mut b.px { - for v in &mut p[..3] { + let mut px = vec![[0.0f32; 4]; buf.px.len()]; + par_pixels(&mut px, &buf.px, |out, src| { + for (o, p) in out.iter_mut().zip(src) { // Capped at the largest half float; NaN is dropped by the texel conversion. - *v = photocraft_color::convert::linear_to_srgb(v.clamp(0.0, 65504.0)); + let enc = |v: f32| photocraft_color::convert::linear_to_srgb(v.clamp(0.0, 65504.0)); + *o = [enc(p[0]), enc(p[1]), enc(p[2]), p[3]]; } - } - Cow::Owned(b) + }); + Cow::Owned(Buffer { rect: buf.rect, px }) } } @@ -97,12 +98,29 @@ fn encode_rgba8(buf: &Buffer) -> Rgba8Image { let t = encode_table(); let mut img = Rgba8Image::new(buf.rect.width(), buf.rect.height()); let code = |v: f32| t.get((v.clamp(0.0, 1.0) * 65535.0 + 0.5) as usize).copied().unwrap_or(255); - for (o, p) in img.pixels.as_chunks_mut::<4>().0.iter_mut().zip(&buf.px) { - *o = [code(p[0]), code(p[1]), code(p[2]), (p[3].clamp(0.0, 1.0) * 255.0 + 0.5) as u8]; - } + par_pixels(img.pixels.as_chunks_mut::<4>().0, &buf.px, |out, src| { + for (o, p) in out.iter_mut().zip(src) { + *o = [code(p[0]), code(p[1]), code(p[2]), (p[3].clamp(0.0, 1.0) * 255.0 + 0.5) as u8]; + } + }); img } +/// Pixels per piece [`par_pixels`] hands to one thread. +const PAR_PIXELS: usize = 16 * 1024; + +/// `f` over matching pieces of `dst` and `src`, in parallel off wasm. The display encodes run on +/// every canvas upload and preview, and a 24 MP linear document took 350 ms on one core. +fn par_pixels(dst: &mut [D], src: &[S], f: impl Fn(&mut [D], &[S]) + Sync) { + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + dst.par_chunks_mut(PAR_PIXELS).zip(src.par_chunks(PAR_PIXELS)).for_each(|(d, s)| f(d, s)); + } + #[cfg(target_arch = "wasm32")] + f(dst, src); +} + /// In-place 8-bit RGBA transform (alpha kept), in blocks so the scratch copy stays small. fn apply_u8(t: &Transform, px: &mut [u8]) { const BLOCK: usize = 1 << 20; diff --git a/crates/engine/src/display_color_tests.rs b/crates/engine/src/display_color_tests.rs index e40dca9bf6e..1270a2418f3 100644 --- a/crates/engine/src/display_color_tests.rs +++ b/crates/engine/src/display_color_tests.rs @@ -397,3 +397,61 @@ fn proof_colors_still_apply_on_top() { s.execute("view.gamutWarning", json!({"on": true})).unwrap(); assert_ne!(sig, s.color.display_signature(&d)); } + +#[test] +fn parallel_linear_encodes_match_the_scalar_curve() { + // Several parallel pieces, a ragged last one, and HDR, negative and NaN values. + let n: usize = 3 * 16 * 1024 + 77; + let px: Vec<[f32; 4]> = (0..n) + .map(|i| { + let v = match i % 9 { + 0 => f32::NAN, + 1 => -0.5, + 2 => 3.25, + _ => (i % 1001) as f32 / 1000.0, + }; + [v, (i % 257) as f32 / 256.0, 1.0 - (i % 113) as f32 / 112.0, (i % 5) as f32 / 4.0] + }) + .collect(); + let buf = photocraft_compose::Buffer { rect: Rect::new(-4, 2, -4 + n as i32, 3), px }; + let mut doc = Document::new("t", Size::new(1, 1), ColorMode::Rgb, SampleType::F32); + doc.icc_profile = Some(Builtin::LinearSrgb.profile().to_bytes()); + let mut color = crate::color_cmds::ColorState::default(); + color.settings.monitor_profile = "srgb".into(); + let d = color.canvas_display(&doc).unwrap(); + assert!(d.encode_srgb); + let enc = |v: f32| photocraft_color::convert::linear_to_srgb(v.clamp(0.0, 65504.0)); + let tex = d.texture_buffer(&buf); + assert_eq!(tex.rect, buf.rect); + for (t, p) in tex.px.iter().zip(&buf.px) { + let want = [enc(p[0]), enc(p[1]), enc(p[2]), p[3]]; + assert!(t.iter().zip(want).all(|(a, b)| a.to_bits() == b.to_bits()), "{p:?} -> {t:?}, want {want:?}"); + } + let code = |v: f32| (photocraft_color::convert::linear_to_srgb(((v.clamp(0.0, 1.0) * 65535.0 + 0.5) as usize) as f32 / 65535.0) * 255.0 + 0.5) as u8; + let img = d.to_rgba8(&buf); + for (o, p) in img.pixels.as_chunks::<4>().0.iter().zip(&buf.px) { + assert_eq!(*o, [code(p[0]), code(p[1]), code(p[2]), (p[3].clamp(0.0, 1.0) * 255.0 + 0.5) as u8], "{p:?}"); + } +} + +#[test] +fn parallel_plain_quantize_matches_the_scalar_rounding() { + let n: usize = 3 * 16 * 1024 + 77; + let px: Vec<[f32; 4]> = (0..n) + .map(|i| { + let v = match i % 9 { + 0 => f32::NAN, + 1 => -0.5, + 2 => 3.25, + _ => (i % 1001) as f32 / 1000.0, + }; + [v, (i % 257) as f32 / 256.0, 1.0 - (i % 113) as f32 / 112.0, (i % 5) as f32 / 4.0] + }) + .collect(); + let buf = photocraft_compose::Buffer { rect: Rect::new(-4, 2, -4 + n as i32, 3), px }; + let img = buf.to_rgba8(); + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; + for (o, p) in img.pixels.as_chunks::<4>().0.iter().zip(&buf.px) { + assert_eq!(*o, p.map(q), "{p:?}"); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0ed69606428..c84c2f1f975 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -990,18 +990,51 @@ fn checker(app: &mut PhotocraftApp, ctx: &egui::Context) -> egui::TextureId { } fn buffer_to_image(buf: &photocraft_compose::Buffer) -> egui::ColorImage { - let img = buf.to_rgba8(); - egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.pixels) + // `buf.to_rgba8()` then `color_image`, fused: one parallel pass, no intermediate image. + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; + let mut pixels = vec![egui::Color32::TRANSPARENT; buf.px.len()]; + let convert = |out: &mut [egui::Color32], src: &[[f32; 4]]| { + for (o, p) in out.iter_mut().zip(src) { + *o = egui::Color32::from_rgba_unmultiplied(q(p[0]), q(p[1]), q(p[2]), q(p[3])); + } + }; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + pixels.par_chunks_mut(16 * 1024).zip(buf.px.par_chunks(16 * 1024)).for_each(|(o, s)| convert(o, s)); + } + #[cfg(target_arch = "wasm32")] + convert(&mut pixels, &buf.px); + egui::ColorImage::new([buf.rect.width() as usize, buf.rect.height() as usize], pixels) +} + +/// `img` as an egui image: `ColorImage::from_rgba_unmultiplied`, in parallel off wasm. egui's own +/// conversion runs on one thread and took 37 ms of a 24 MP canvas or preview update, three times +/// the quantize before it. +fn color_image(img: &photocraft_raster::Rgba8Image) -> egui::ColorImage { + let size = [img.width as usize, img.height as usize]; + let src = img.pixels.as_chunks::<4>().0; + let mut pixels = vec![egui::Color32::TRANSPARENT; src.len()]; + let convert = |out: &mut [egui::Color32], src: &[[u8; 4]]| { + for (o, &[r, g, b, a]) in out.iter_mut().zip(src) { + *o = egui::Color32::from_rgba_unmultiplied(r, g, b, a); + } + }; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + pixels.par_chunks_mut(16 * 1024).zip(src.par_chunks(16 * 1024)).for_each(|(o, s)| convert(o, s)); + } + #[cfg(target_arch = "wasm32")] + convert(&mut pixels, src); + egui::ColorImage::new(size, pixels) } /// A composite as monitor pixels: document profile → monitor profile (nothing to do in the /// common sRGB-on-sRGB case). pub(crate) fn display_image(display: Option<&photocraft_engine::display_color::CanvasDisplay>, buf: &photocraft_compose::Buffer) -> egui::ColorImage { match display { - Some(d) if !d.is_identity() => { - let img = d.to_rgba8(buf); - egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.pixels) - } + Some(d) if !d.is_identity() => color_image(&d.to_rgba8(buf)), _ => buffer_to_image(buf), } } @@ -5718,6 +5751,33 @@ mod tabs_tests; #[cfg(test)] mod tests { + #[test] + fn display_image_matches_egui_unmultiplied_conversion() { + // Several parallel pieces and a ragged one; translucent, out-of-range and NaN values. + let n: usize = 3 * 16 * 1024 + 77; + let px: Vec<[f32; 4]> = (0..n) + .map(|i| { + let v = match i % 9 { + 0 => f32::NAN, + 1 => -0.5, + 2 => 3.25, + _ => (i % 1001) as f32 / 1000.0, + }; + [v, (i % 257) as f32 / 256.0, 1.0 - (i % 113) as f32 / 112.0, (i % 6) as f32 / 5.0] + }) + .collect(); + let buf = photocraft_compose::Buffer { rect: photocraft_geom::Rect::new(0, 0, n as i32, 1), px }; + let reference = |img: photocraft_raster::Rgba8Image| egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.pixels); + assert_eq!(super::display_image(None, &buf).pixels, reference(buf.to_rgba8()).pixels); + let mut doc = photocraft_doc::Document::new("t", photocraft_geom::Size::new(1, 1), photocraft_doc::ColorMode::Rgb, photocraft_doc::SampleType::U8); + doc.icc_profile = Some(photocraft_cms::Builtin::AdobeRgbCompat.profile().to_bytes()); + let mut color = photocraft_engine::color_cmds::ColorState::default(); + color.settings.monitor_profile = "srgb".into(); + let d = color.canvas_display(&doc).unwrap(); + assert!(!d.is_identity()); + assert_eq!(super::display_image(Some(&d), &buf).pixels, reference(d.to_rgba8(&buf)).pixels); + } + #[cfg(not(target_arch = "wasm32"))] #[test] fn every_filter_previews_off_the_ui_thread_and_a_newer_value_cancels_the_old() { From d740ea638d4c1f6ede3dff166115ea153d2310b6 Mon Sep 17 00:00:00 2001 From: DeWarePeb <68034923+DeWarePeb@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:48:02 +0200 Subject: [PATCH 24/47] Puppet Warp: a linked layer mask moves with the pixels (#4910) (#4989) Edit > Puppet Warp warped only a layer's pixels and left a linked mask where it was, so the mask no longer lined up with the content. Free Transform, Warp and Perspective Warp (#3083) already move a linked mask. `puppet_warp_with_mask` solves the mesh once and moves the mask on the same mesh: the mask under the mesh is lifted, deformed like the pixels and laid over the rest; the area it leaves takes the mask's default and the mask away from the mesh keeps its values. With a selection the mesh covers only the lifted pixels, so only that part of the mask moves. An unlinked mask stays put. Tests: the moved stripe at 8/16/32-bit, undo, an unlinked mask, a selection, and a hide-all mask in the algo crate. The engine tests fail before the fix. Claude-Session: https://claude.ai/code/session_01NPyyabA5efedMqVWMu3fiq Co-authored-by: Claude Opus 5.5 --- crates/algo/src/puppet.rs | 88 ++++++++++++++++++++++++- crates/engine/src/distort_cmds.rs | 22 ++++++- crates/engine/src/distort_cmds/tests.rs | 62 +++++++++++++++++ 3 files changed, 168 insertions(+), 4 deletions(-) diff --git a/crates/algo/src/puppet.rs b/crates/algo/src/puppet.rs index e21b7874ee8..c3c5c3693c2 100644 --- a/crates/algo/src/puppet.rs +++ b/crates/algo/src/puppet.rs @@ -1277,11 +1277,18 @@ pub const MAX_WARP_PIXELS: f64 = (1u64 << 30) as f64; /// Applies a puppet warp to `src` (content inside `bounds`). `None` when the deformed mesh would /// span more than [`MAX_WARP_PIXELS`]. pub fn puppet_warp(src: &Surface, bounds: Rect, w: &PuppetWarp, interp: Interp) -> Option { + puppet_warp_with_mask(src, bounds, w, interp, None).map(|(out, _)| out) +} + +/// [`puppet_warp`], also moving `mask` (a one-channel layer mask linked to `src`) with the same +/// mesh, as Photoshop moves a linked mask with its layer (#4910). The mask is `None` in the +/// result when none was given. +pub fn puppet_warp_with_mask(src: &Surface, bounds: Rect, w: &PuppetWarp, interp: Interp, mask: Option<&Surface>) -> Option<(Surface, Option)> { let (solver, v, order) = deform(src, bounds, w, ITERATIONS); // No triangles (an expansion that shrinks every pixel away): nothing to warp. Rendering the // empty mesh would drop every pixel of the layer. if solver.mesh.tris.is_empty() { - return Some(src.clone()); + return Some((src.clone(), mask.cloned())); } let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); for p in &v { @@ -1291,7 +1298,57 @@ pub fn puppet_warp(src: &Surface, bounds: Rect, w: &PuppetWarp, interp: Interp) if area.is_nan() || area > MAX_WARP_PIXELS { return None; } - Some(render(src, bounds, &solver.mesh, &v, &order, interp)) + let out = render(src, bounds, &solver.mesh, &v, &order, interp); + Some((out, mask.map(|m| render_mask(m, &solver.mesh, &v, &order, interp)))) +} + +/// Moves a one-channel layer mask with a deformed mesh: the mask under the mesh is lifted, +/// deformed like the layer's pixels and laid over the rest. The area the mesh leaves takes the +/// mask's default value (as [`crate::warp::warp_mesh_gray`] leaves it); outside the mesh the +/// mask keeps its values. +pub fn render_mask(mask: &Surface, mesh: &PuppetMesh, deformed: &[[f64; 2]], order: &[usize], interp: Interp) -> Surface { + let mut out = mask.clone(); + if mesh.tris.is_empty() || deformed.len() != mesh.verts.len() || mask.format().channels() != 1 { + return out; + } + let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for p in &mesh.verts { + (x0, y0, x1, y1) = (x0.min(p[0]), y0.min(p[1]), x1.max(p[0]), y1.max(p[1])); + } + if !(x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite()) { + return out; + } + // The mesh's source area, a pixel wider so its edge samples stay inside. + let lim = f64::from(1 << 20); + let src = Rect::new(x0.max(-lim).floor() as i32 - 1, y0.max(-lim).floor() as i32 - 1, x1.min(lim).ceil() as i32 + 1, y1.min(lim).ceil() as i32 + 1); + if src.is_empty() { + return out; + } + let default = mask.default_pixel().first().copied().unwrap_or(0.0); + let fmt = mask.format(); + let mut tmp = Surface::new(photocraft_color::PixelFormat::new(fmt.mode, fmt.sample, true)); + tmp.write_region(src, &mask.read_region(src).iter().flat_map(|g| [*g, 1.0]).collect::>()); + let tris: Vec<[usize; 3]> = order.iter().filter_map(|&t| mesh.tris.get(t).copied()).collect(); + // Lift: where the undeformed mesh covers the mask, the mask takes its default value. + let still: Vec<([f64; 2], [f64; 2])> = mesh.verts.iter().map(|s| (*s, *s)).collect(); + let lifted = crate::warp::warp_triangles(&tmp, src, &still, &tris, Interp::Nearest); + let b = lifted.content_bounds(); + if !b.is_empty() { + let under = out.read_region(b); + let flat: Vec = lifted.read_region(b).as_chunks::<2>().0.iter().zip(&under).map(|(p, u)| default * p[1] + u * (1.0 - p[1])).collect(); + out.write_region(b, &flat); + } + // Lay the deformed mask over what is left. + let moved: Vec<([f64; 2], [f64; 2])> = deformed.iter().zip(&mesh.verts).map(|(d, s)| (*d, *s)).collect(); + let warped = crate::warp::warp_triangles(&tmp, src, &moved, &tris, interp); + let b = warped.content_bounds(); + if !b.is_empty() { + let under = out.read_region(b); + let flat: Vec = warped.read_region(b).as_chunks::<2>().0.iter().zip(&under).map(|(p, u)| p[0] * p[1] + u * (1.0 - p[1])).collect(); + out.write_region(b, &flat); + } + out.prune(); + out } #[cfg(test)] @@ -1434,6 +1491,33 @@ mod tests { assert!(!near.content_bounds().is_empty()); } + #[test] + fn a_linked_mask_moves_with_the_mesh_and_leaves_its_default_behind() { + // #4910. A hide-all mask (default 0) that reveals the bar's top (y 10..30): moved down by + // 8, the revealed patch moves along, what it left is hidden again, and the mask away + // from the puppet keeps its values. + let s = blob(SampleType::U8); + let b = s.content_bounds(); + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut mask = Surface::with_default(PixelFormat::new(ColorMode::Grayscale, sample, false), &[0.0]); + mask.fill_rect(Rect::new(20, 10, 36, 30), &[1.0]); + mask.fill_rect(Rect::new(90, 90, 94, 94), &[0.5]); + let w = PuppetWarp { + pins: vec![PuppetPin { src: [28.0, 30.0], dst: [28.0, 38.0], rotate: None, depth: 0 }], + mode: PuppetMode::Rigid, + density: PuppetDensity::Normal, + expansion: 2.0, + }; + let (_, moved) = puppet_warp_with_mask(&s, b, &w, Interp::Bilinear, Some(&mask)).expect("warps"); + let m = moved.expect("a mask in, a mask out"); + let at = |x, y| m.sample_channel(x, y, 0); + assert!(at(28, 20) > 0.99 && at(28, 36) > 0.99, "{sample:?}: the revealed patch moved down"); + assert!(at(28, 15) < 0.01, "{sample:?}: its old top is hidden again"); + assert!((at(91, 91) - 0.5).abs() <= 1.0 / 255.0, "{sample:?}: away from the mesh nothing changes"); + assert!(puppet_warp_with_mask(&s, b, &w, Interp::Bilinear, None).expect("warps").1.is_none()); + } + } + #[test] fn a_single_pin_translates_rigidly() { let s = blob(SampleType::U8); diff --git a/crates/engine/src/distort_cmds.rs b/crates/engine/src/distort_cmds.rs index 55556c53d86..ed56e500231 100644 --- a/crates/engine/src/distort_cmds.rs +++ b/crates/engine/src/distort_cmds.rs @@ -10,9 +10,11 @@ //! resampling. The params are the data in every case (strokes, pins and planes are small //! compared with a field or a mesh, and replay is deterministic), so nothing needs a blob. +use std::cell::RefCell; + use photocraft_algo::liquify::{LiquifyField, LiquifyStroke, apply_liquify, auto_cell}; use photocraft_algo::perspective::{PerspectiveMap, Plane, Straighten, straighten}; -use photocraft_algo::puppet::{PuppetDensity, PuppetMode, PuppetPin, PuppetWarp, puppet_warp}; +use photocraft_algo::puppet::{PuppetDensity, PuppetMode, PuppetPin, PuppetWarp, puppet_warp, puppet_warp_with_mask}; use photocraft_algo::transform::Interp; use photocraft_algo::warp::{warp_mesh_gray, warp_mesh_surface}; use photocraft_color::PixelFormat; @@ -328,7 +330,23 @@ fn puppet(s: &mut Session, p: &Value, cmd: &str) -> Result { } let it = interp(p); let too_far = || bad(cmd, "the pins stretch the content too far: the result would cover more than 2^30 pixels"); - run_on_layer(s, cmd, "Puppet Warp", p, true, &|surf, sel, _| deform_selected(surf, sel, &|x| puppet_surface(x, &w, it).ok_or_else(too_far)), None) + // A linked layer mask moves with the pixels on the same mesh (#4910): the layer's warp + // solves the mesh once and leaves the moved mask here for the mask step. + let linked = s.active().and_then(|d| d.doc.layer(id)).and_then(|l| l.mask.as_ref()).filter(|m| m.linked).map(|m| m.surface.clone()); + let moved_mask: RefCell> = RefCell::new(None); + let layer_fn = |surf: &Surface, sel: Option<&Surface>, _: Rect| { + deform_selected(surf, sel, &|x| { + let b = x.content_bounds(); + if b.is_empty() { + return Ok(x.clone()); + } + let (out, mask) = puppet_warp_with_mask(x, b, &w, it, linked.as_ref()).ok_or_else(too_far)?; + *moved_mask.borrow_mut() = mask; + Ok(out) + }) + }; + let mask_fn = |m: &Surface, _: Option<&Surface>| Ok(moved_mask.borrow_mut().take().unwrap_or_else(|| m.clone())); + run_on_layer(s, cmd, "Puppet Warp", p, true, &layer_fn, Some(&mask_fn)) } fn perspective(s: &mut Session, p: &Value, cmd: &str) -> Result { diff --git a/crates/engine/src/distort_cmds/tests.rs b/crates/engine/src/distort_cmds/tests.rs index 50a8a49e622..81d17c28c2c 100644 --- a/crates/engine/src/distort_cmds/tests.rs +++ b/crates/engine/src/distort_cmds/tests.rs @@ -124,6 +124,68 @@ fn puppet_identity_translation_and_undo() { } } +fn mask_at(s: &Session, x: i32, y: i32) -> f32 { + let st = s.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().mask.as_ref().unwrap().surface.sample_channel(x, y, 0) +} + +/// [`session`] with a reveal-all mask that hides the stripes `stripes` (x ranges, y 10..40). +fn masked_session(depth: u64, stripes: &[(i32, i32)]) -> Session { + let mut s = session(depth); + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + s.edit("mask", |doc, active| { + let m = doc.layer_mut(active.unwrap()).unwrap().mask.as_mut().unwrap(); + for &(x0, x1) in stripes { + m.surface.fill_rect(Rect::new(x0, 10, x1, 40), &[0.0]); + } + Ok(()) + }) + .unwrap(); + s +} + +#[test] +fn puppet_moves_a_linked_mask_with_the_pixels() { + // #4910: Puppet Warp warped only the pixels and left a linked mask behind. A one-pin rigid + // warp moves the block by (6, 4); the stripe the mask hides (x 20..24) must move with it. + for depth in [8u64, 16, 32] { + let mut s = masked_session(depth, &[(20, 24)]); + let before = s.active().unwrap().doc.clone(); + s.execute(PUPPET, json!({"pins": [{"src": [30, 25], "dst": [36, 29]}], "mode": "rigid", "interpolation": "bilinear"})).unwrap(); + for y in [16, 30, 42] { + assert!(mask_at(&s, 27, y) < 0.01, "{depth}: the stripe moved to x 26..30 (y {y})"); + assert!(mask_at(&s, 21, y) > 0.99, "{depth}: and left x 20..24 (y {y})"); + } + assert!(mask_at(&s, 27, 12) > 0.99, "{depth}: and down by 4"); + // Pixels and mask still line up: the same layer pixel is hidden as before the warp. + let was = before.layer(s.active().unwrap().active_layer.unwrap()).unwrap().surface().unwrap().pixel(21, 26); + assert!(worst_px(&active_surface(&s).pixel(27, 30), &was) <= 1.0 / 255.0, "{depth}"); + s.undo(); + assert!(mask_at(&s, 21, 30) < 0.01 && mask_at(&s, 27, 30) > 0.99, "{depth}: undo puts the mask back"); + } +} + +#[test] +fn puppet_leaves_an_unlinked_mask_in_place() { + let mut s = masked_session(8, &[(20, 24)]); + s.execute("layer.layerMask.linked", json!({"linked": false})).unwrap(); + s.execute(PUPPET, json!({"pins": [{"src": [30, 25], "dst": [36, 29]}], "mode": "rigid"})).unwrap(); + assert!(mask_at(&s, 21, 30) < 0.01 && mask_at(&s, 27, 30) > 0.99); + assert_eq!(active_surface(&s).content_bounds(), Rect::new(16, 14, 66, 44), "the pixels still moved"); +} + +#[test] +fn puppet_with_a_selection_moves_only_the_selected_part_of_a_linked_mask() { + // The selection lifts the block's left part (x 10..30): the stripe hidden there moves with + // it, the one outside (x 40..44) stays over its pixels. + let mut s = masked_session(8, &[(20, 24), (40, 44)]); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 30, "height": 60})).unwrap(); + s.execute(PUPPET, json!({"pins": [{"src": [20, 25], "dst": [26, 29]}], "mode": "rigid"})).unwrap(); + assert!(mask_at(&s, 27, 30) < 0.01 && mask_at(&s, 21, 30) > 0.99, "the selected stripe moved"); + assert!(mask_at(&s, 41, 30) < 0.01 && mask_at(&s, 45, 30) > 0.99, "the other stayed"); + assert_eq!(active_surface(&s).pixel(41, 30)[3], 1.0, "over its pixels"); +} + #[test] fn puppet_with_an_expansion_that_empties_the_mesh_keeps_the_layer() { // #951: a pinned warp over an empty mesh panicked in the solver; it must not erase the layer From ce6fccc3f5e65d10af8b5eca4a9e7b441640a29b Mon Sep 17 00:00:00 2001 From: kijoong Date: Wed, 14 Oct 2026 01:48:09 +0900 Subject: [PATCH 25/47] fix(type): missing-font checks follow layout's PostScript resolution; split hyphenless style suffixes (#608) * fix(text): split hyphenless PostScript style suffixes A PostScript name without a hyphen kept its style in the guessed family: `GmarketSansBold` became the family "Gmarket Sans Bold" at weight 400. The guess now drops a trailing capitalised style word (Gmarket Sans, 700). A lowercase ending ("Daylight") is not a style, and a style word after the vendor tag MT stays in the legacy family ("Arial Rounded MT Bold"). Hyphenated names keep the last-hyphen parsing from #3878. PSD import stores this guess when the exact face is absent. A legacy family that ends in a style word without MT before it ("CooperBlack") is stored as the shorter family (Cooper, 900) until the face is installed. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(type): missing-font checks follow layout's PostScript resolution When a run's stored family is not installed, layout draws it with its exact PostScript face, or with the family guessed from that name if installed. Missing-font detection compared only the stored family label, so such a run was reported missing, and Replace Missing Fonts rewrote it whenever an unresolved run shared the label. missing_fonts, missing_fonts_in_layer and the replacement now use layout's decision. #1406 already corrects the label when a PSD or Affinity file is opened with the face installed, and #1844 finds exact faces outside the family prefix. The label still stays on a run reopened from .pcraft after its font was installed, on fonts that arrive after opening, and on a run created with both "font" and "postscriptName". The tests take the .pcraft and command paths. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: kijoong Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/type_extra_cmds.rs | 35 +++++++---- crates/engine/src/type_extra_cmds/tests.rs | 71 ++++++++++++++++++++++ crates/text/src/fonts.rs | 40 +++++++++++- crates/text/src/tests.rs | 17 ++++++ docs/font-resolution.md | 42 +++++++++++++ 5 files changed, 193 insertions(+), 12 deletions(-) create mode 100644 docs/font-resolution.md diff --git a/crates/engine/src/type_extra_cmds.rs b/crates/engine/src/type_extra_cmds.rs index 1e7556b1d45..efa2d791cce 100644 --- a/crates/engine/src/type_extra_cmds.rs +++ b/crates/engine/src/type_extra_cmds.rs @@ -509,20 +509,30 @@ fn update_all(s: &mut Session) -> Result { Ok(json!({"updated": n})) } -fn installed_families() -> Vec { - photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()).fonts.families().into_iter().map(|f| f.to_lowercase()).collect() +fn style_font_missing(fonts: &mut photocraft_text::FontDb, style: &photocraft_doc::text::CharStyle) -> bool { + if style.font_family.is_empty() || fonts.has_family(&style.font_family) { + return false; + } + // Match layout's choice: an exact PostScript face wins over the import-time family guess; + // a heuristic family is usable only when the stored family is unavailable. + if let Some(ps) = &style.postscript_name { + let resolved = fonts.resolve_postscript(ps); + if resolved.exact || fonts.has_family(&resolved.family) { + return false; + } + } + true } /// Font families used by type layers that the font database cannot supply. pub fn missing_fonts(doc: &Document) -> Vec { - let have = installed_families(); + let mut engine = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()); let mut out: Vec = Vec::new(); for (_, _, l) in doc.walk() { if let LayerContent::Text(t) = &l.content { for r in t.char_runs() { - let f = r.style.font_family; - if !f.is_empty() && !have.contains(&f.to_lowercase()) && !out.contains(&f) { - out.push(f); + if style_font_missing(&mut engine.fonts, &r.style) && !out.contains(&r.style.font_family) { + out.push(r.style.font_family); } } } @@ -532,12 +542,11 @@ pub fn missing_fonts(doc: &Document) -> Vec { /// Font families used by a type layer that the font database cannot supply. pub fn missing_fonts_in_layer(t: &TextLayer) -> Vec { - let have = installed_families(); + let mut engine = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()); let mut out: Vec = Vec::new(); for r in t.char_runs() { - let f = r.style.font_family; - if !f.is_empty() && !have.contains(&f.to_lowercase()) && !out.contains(&f) { - out.push(f); + if style_font_missing(&mut engine.fonts, &r.style) && !out.contains(&r.style.font_family) { + out.push(r.style.font_family); } } out @@ -580,7 +589,11 @@ fn replace_fonts(s: &mut Session, map: &serde_json::Map, all: boo let runs = t.char_runs(); let mut at = 0; for r in runs { - if let Some(to) = pick(&r.style.font_family) { + let missing_run = { + let mut engine = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()); + style_font_missing(&mut engine.fonts, &r.style) + }; + if missing_run && let Some(to) = pick(&r.style.font_family) { style_range(t, at, at + r.len, &|st| { st.font_family = to.clone(); st.postscript_name = None; diff --git a/crates/engine/src/type_extra_cmds/tests.rs b/crates/engine/src/type_extra_cmds/tests.rs index c0c091b10c9..9f604eed1bc 100644 --- a/crates/engine/src/type_extra_cmds/tests.rs +++ b/crates/engine/src/type_extra_cmds/tests.rs @@ -327,3 +327,74 @@ fn all_locked_layer_rejects_paste_lorem_ipsum_without_history() { assert_eq!(s.active().unwrap().history.past_len(), history); } } + +/// A type layer stored with a family label that is not installed, as a PSD keeps the family it +/// guessed from a PostScript name whose face was absent when the file was opened. +fn guessed_family_layer(s: &mut Session, text: &str, ps: Option<&str>) -> (LayerId, Value) { + let mut p = json!({"x": 20, "y": 70, "text": text, "font": "Imported Wrong Family"}); + if let Some(ps) = ps { + p["postscriptName"] = json!(ps); + } + let r = s.execute("type.create", p).unwrap(); + (LayerId(r["layer"].as_u64().unwrap()), r) +} + +#[test] +fn installed_postscript_face_is_not_missing_or_replaced() { + // Opening a PSD corrects the label when the face is installed; a run set by command, or one + // reopened from .pcraft after its face was installed, keeps it. + let mut s = session(8); + let (id, created) = guessed_family_layer(&mut s, "test", Some("Inter-SemiBold")); + let bytes = photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).unwrap(); + let reopened = photocraft_io::import("reopened.pcraft", &bytes).unwrap().document; + let LayerContent::Text(t) = &reopened.layer(id).unwrap().content else { panic!("text") }; + assert_eq!(t.char_runs()[0].style.font_family, "Imported Wrong Family", ".pcraft open keeps the stored label"); + let on_open = missing_font_warnings(&reopened); + s.add_document(reopened, None); + let replaced = s.execute("type.replaceAllMissingFonts", json!({})).unwrap()["replaced"].clone(); + assert_eq!((created.get("warnings"), on_open, replaced), (None, Vec::::new(), json!(0))); +} + +#[test] +fn replacing_missing_family_preserves_a_resolved_run_with_the_same_label() { + let mut s = session(8); + let (good, _) = guessed_family_layer(&mut s, "good", Some("Inter-SemiBold")); + let (bad, _) = guessed_family_layer(&mut s, "bad", None); + assert_eq!(s.execute("type.replaceAllMissingFonts", json!({})).unwrap()["replaced"], 1); + assert_eq!(text(&s, good).char_runs()[0].style.postscript_name.as_deref(), Some("Inter-SemiBold")); + assert_eq!(text(&s, bad).char_runs()[0].style.font_family, "Inter"); +} + +/// `source` renamed in memory to `family` with the PostScript name `ps`; no font file is added. +fn renamed_face(source: &[u8], family: &str, ps: &str) -> Vec { + let mut bytes = source.to_vec(); + let count = u16::from_be_bytes([bytes[4], bytes[5]]) as usize; + let record = (0..count).map(|i| 12 + i * 16).find(|r| &bytes[*r..*r + 4] == b"name").unwrap(); + let names = [(1u16, family), (2, "Regular"), (6, ps), (16, family), (17, "Regular")]; + let mut table: Vec = [0u16, names.len() as u16, (6 + names.len() * 12) as u16].iter().flat_map(|v| v.to_be_bytes()).collect(); + let mut strings = Vec::new(); + for (id, value) in names { + let encoded: Vec = value.encode_utf16().flat_map(u16::to_be_bytes).collect(); + for n in [3, 1, 0x409, id, encoded.len() as u16, strings.len() as u16] { + table.extend(n.to_be_bytes()); + } + strings.extend(encoded); + } + table.extend(strings); + let offset = bytes.len() as u32; + bytes[record + 8..record + 12].copy_from_slice(&offset.to_be_bytes()); + bytes[record + 12..record + 16].copy_from_slice(&(table.len() as u32).to_be_bytes()); + bytes.extend(table); + bytes +} + +#[test] +fn exact_postscript_face_outside_the_guessed_family_is_not_missing() { + let mut fonts = photocraft_text::FontDb::new(); + fonts.register_font_data(renamed_face(&photocraft_text::fonts::INTER_REGULAR, "Exact Only Test", "UnrelatedPS30")); + let style = + |ps: &str| photocraft_doc::text::CharStyle { font_family: "Imported Wrong Family".into(), postscript_name: Some(ps.into()), ..Default::default() }; + // Neither the stored family nor the family guessed from the name is installed: only the exact face. + assert!(!fonts.has_family(&photocraft_text::fonts::guess_from_postscript("UnrelatedPS30").family)); + assert_eq!((style_font_missing(&mut fonts, &style("UnrelatedPS30")), style_font_missing(&mut fonts, &style("UnrelatedPS31"))), (false, true)); +} diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index d1df6bc20e0..5ce71e59ac9 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -584,10 +584,48 @@ pub fn face_count(bytes: &[u8]) -> usize { } } +// CamelCase style suffix of a name without a hyphen (`GmarketSansBold`). Only a capitalised word +// counts ("Daylight" keeps its "light"), and one after a vendor tag stays in a legacy family name +// (`ArialRoundedMTBold` is the family "Arial Rounded MT Bold"). +fn split_trailing_style(ps: &str) -> (&str, &str) { + let base = ps.trim_end_matches("MT").trim_end_matches("PS"); + let mut family = base; + for suffix in ["Italic", "Oblique"] { + if let Some(rest) = family.strip_suffix(suffix).filter(|s| !s.is_empty()) { + family = rest; + break; + } + } + for suffix in [ + "ExtraLight", + "UltraLight", + "ExtraBold", + "UltraBold", + "SemiBold", + "DemiBold", + "Hairline", + "Regular", + "Medium", + "Normal", + "Heavy", + "Black", + "Light", + "Thin", + "Bold", + "Demi", + ] { + if let Some(rest) = family.strip_suffix(suffix).filter(|s| !s.is_empty()) { + family = rest; + break; + } + } + if family.len() < base.len() && !family.ends_with("MT") { (family, ps.get(family.len()..).unwrap_or("")) } else { (ps, "") } +} + /// Heuristic PostScript-name split: `MyriadPro-BoldIt` → ("Myriad Pro", 700, italic). pub fn guess_from_postscript(ps: &str) -> ResolvedFont { // A vendor or family name may contain hyphens; start with the final face suffix. - let (mut pro, mut style) = ps.rsplit_once('-').unwrap_or((ps, "")); + let (mut pro, mut style) = ps.rsplit_once('-').unwrap_or_else(|| split_trailing_style(ps)); // Keep compound style suffixes the old heuristic understood, such as Bold-It. // Regular alone must not consume a family component named Bold or Black. if matches!(style.to_ascii_lowercase().as_str(), "it" | "italic" | "oblique") diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 47be8f38805..13de665ccd5 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1991,3 +1991,20 @@ fn type_on_a_path_follows_the_curve() { dot.shape = TextShape::Path { path: Subpath::polyline(&[(5.0, 5.0), (5.0, 5.0)]), start: 0.0, flip: false }; assert_eq!(ink(&mut eng, &dot), flat); } + +#[test] +fn hyphenless_postscript_names_split_trailing_styles() { + for (ps, family, weight, italic) in [ + ("GmarketSansBold", "Gmarket Sans", 700, false), + ("NanumGothicBold", "Nanum Gothic", 700, false), + ("GmarketSansTTFBold", "Gmarket Sans TTF", 700, false), + ("ExampleSansSemiBoldItalic", "Example Sans", 600, true), + ("ExampleSansExtraLight", "Example Sans", 200, false), + // A style word after a vendor tag belongs to a legacy family name; lowercase is no style. + ("ArialRoundedMTBold", "Arial Rounded MT Bold", 400, false), + ("Daylight", "Daylight", 400, false), + ] { + let r = fonts::guess_from_postscript(ps); + assert_eq!((r.family.as_str(), r.weight, r.italic), (family, weight, italic), "{ps}"); + } +} diff --git a/docs/font-resolution.md b/docs/font-resolution.md new file mode 100644 index 00000000000..e1f1e273f74 --- /dev/null +++ b/docs/font-resolution.md @@ -0,0 +1,42 @@ +# PostScript font resolution + +PSD text may identify a face by its PostScript name as well as a family/style. +The PostScript prefix need not match the family name, and the family stored with +a run can be a guess made from that name. + +## Resolution + +`FontDb::resolve_postscript` first looks for an installed face with that exact +PostScript name (families that share the guessed prefix are read first), then +falls back to a heuristic split of the name. The heuristic splits at the last +hyphen. A name without a hyphen loses a trailing capitalised style word +(`GmarketSansBold` → Gmarket Sans, 700; `ExampleSansSemiBoldItalic` → Example +Sans, 600, italic). A lowercase ending is not a style (`Daylight`), and a style +word after the vendor tag `MT` stays in the family (`ArialRoundedMTBold` → +Arial Rounded MT Bold). + +The heuristic also gives the family, weight and slant stored on PSD import when +the exact face is absent. A legacy family whose name ends in a style word and +has no `MT` tag before it (`CooperBlack`) is then stored as the shorter family +(Cooper, 900) until the face is installed. + +## Missing fonts + +Opening a PSD or Affinity file already replaces a run's guessed family with the +installed family its PostScript name resolves to. A guess still stays on a run +when the face was absent at import and the document is reopened from `.pcraft` +after the font is installed, when fonts are registered after opening, or when a +command sets `font` and `postscriptName` together. + +Missing-font detection (`missing_fonts` and `missing_fonts_in_layer`) and Replace +Missing Fonts therefore use the same decision as layout: a run is missing only +when neither its stored family, its exact PostScript face nor the heuristic +family is installed. A run that resolves is not replaced because an unresolved +run shares its family label. + +## Cost + +Detection resolves the PostScript name only for runs whose stored family is not +installed. The first such lookup reads the name tables of installed faces; the +result is cached until fonts are registered or system fonts are loaded, and +layout performs the same lookup for those runs. From ca5755f5b059b67ad7f47c0024cb8d11c2377337 Mon Sep 17 00:00:00 2001 From: Abdul karim mia <97598943+abdul-karim-mia@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:18:18 +0530 Subject: [PATCH 26/47] Layer: Duplicate Layer to a new document, as in Photoshop (#4805) The Duplicate Layer dialog gains Photoshop's Document: New destination with a document-Name field (As stays the copy's name, multi-select keeps names, single group gets the Duplicate Group title). layer.duplicate takes document index-or-new plus optional documentName, mirroring channel.duplicate: the new document matches the source canvas, mode, depth, resolution and profile, holds just the selected layers at their positions, and becomes active. Smart-object contents are remapped within the batch so copies are independent of the source. The dialog is fixed to its content width so no dead strip remains. --- crates/engine/src/commands.rs | 18 +++- crates/engine/src/layer_copy_cmds.rs | 60 +++++++++++ crates/engine/src/layer_copy_cmds/tests.rs | 114 ++++++++++++++++++++- crates/ui-egui/src/dialogs.rs | 6 ++ crates/ui-egui/src/duplicate_layer_ui.rs | 91 ++++++++++++++-- 5 files changed, 277 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 2b28a9946f7..12ec82c6147 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -601,9 +601,25 @@ fn build() -> Vec { "Duplicate Layer…", ["Layer"], None, - r##"{"layer":id?,"inPlace":bool=false (artboards: keep the copy on the original instead of beside it),"name":str? (one layer: the copy's name)} (no layer: every selected layer)"##, + r##"{"layer":id?,"inPlace":bool=false (artboards: keep the copy on the original instead of beside it),"name":str? (one layer: the copy's name),"document":index|"new"=same document (Photoshop's Destination: "new" makes a new document the size of this canvas holding just the copies),"documentName":str? (the new document's name)} (no layer: every selected layer)"##, has_layer, |s, p| { + if let Some(d) = p.get("document") { + if d.as_str() == Some("new") { + return crate::layer_copy_cmds::duplicate_to_new_document(s, p); + } + let same = d.as_u64().is_some_and(|i| s.active_index().is_some_and(|a| i == a as u64)); + if !same { + return Err(EngineError::BadParams { + cmd: "layer.duplicate".into(), + msg: if d.as_u64().is_some() { + "the destination is another open document (use layer.copyToDocument)".into() + } else { + "`document` must be a document index or \"new\"".into() + }, + }); + } + } let in_place = p.get("inPlace").and_then(Value::as_bool).unwrap_or(false); let name = p.get("name").and_then(Value::as_str).filter(|n| !n.is_empty()).map(str::to_string); if crate::layer_multi_cmds::multi(s, p) { diff --git a/crates/engine/src/layer_copy_cmds.rs b/crates/engine/src/layer_copy_cmds.rs index 9a42778de2d..d08955fdf66 100644 --- a/crates/engine/src/layer_copy_cmds.rs +++ b/crates/engine/src/layer_copy_cmds.rs @@ -213,6 +213,66 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result { Ok(json!({"document": dest, "layers": new.iter().map(|l| l.0).collect::>(), "offset": [dx, dy]})) } +/// `layer.duplicate` with `"document": "new"`: Photoshop's Duplicate Layer… › +/// Document: New. A new document the size of the source's canvas holds just the duplicated +/// layers at their canvas positions (a transparent background elsewhere); the source is +/// untouched and the new document becomes active. Mode, depth, resolution, profile, +/// patterns and global light come along so the copies render the same. +pub(crate) fn duplicate_to_new_document(s: &mut Session, p: &Value) -> Result { + let src = s.active_index().ok_or(EngineError::NoDocument)?; + let sdoc = s.documents().get(src).ok_or(EngineError::NoDocument)?.doc.clone(); + let ids = match crate::commands::explicit_layer(s, p)? { + Some(id) => vec![id], + None => s.active().ok_or(EngineError::NoDocument)?.selected_layers(), + }; + if let Some(missing) = ids.iter().find(|id| sdoc.layer(**id).is_none()) { + return Err(EngineError::NoLayer(*missing)); + } + let ids = crate::layer_multi_cmds::top_level(&sdoc, &ids); + if ids.is_empty() { + return Err(bad("no layers to copy")); + } + if !layered(sdoc.mode) { + return Err(EngineError::Other(format!("layers can't be duplicated with a {:?} document", sdoc.mode))); + } + let doc_name = p.get("documentName").and_then(Value::as_str).filter(|n| !n.is_empty()).map(str::to_string).unwrap_or_else(|| format!("{} copy", sdoc.name)); + let mut nd = Document::new(doc_name, sdoc.size, sdoc.mode, sdoc.depth); + nd.resolution_dpi = sdoc.resolution_dpi; + nd.icc_profile.clone_from(&sdoc.icc_profile); + nd.patterns.clone_from(&sdoc.patterns); + nd.global_light = sdoc.global_light; + for id in &ids { + let mut l = sdoc.layer(*id).ok_or(EngineError::NoLayer(*id))?.duplicate(); + if ids.len() == 1 + && let Some(name) = p.get("name").and_then(Value::as_str).filter(|n| !n.is_empty()) + { + l.name = name.to_string(); + } + nd.layers.push(l); + } + // Contents ids belong to a document: fresh ones keep the copies independent of the + // source while preserving sharing within the batch (as `adapt_copies` does). + let mut contents = HashMap::new(); + let smart_ids: Vec<_> = nd.walk().into_iter().filter_map(|(_, _, l)| matches!(l.content, LayerContent::Smart(_)).then_some(l.id)).collect(); + for id in smart_ids { + if let Some(l) = nd.layer_mut(id) + && let LayerContent::Smart(sm) = &mut l.content + { + sm.contents_id = *contents.entry(sm.contents_id).or_insert_with(SmartContentsId::fresh); + } + } + let new_ids: Vec = nd.layers.iter().map(|l| l.id).collect(); + let index = s.add_document(nd, None); + crate::layer_multi_cmds::reselect(s, new_ids.clone(), new_ids.last().copied()); + let mut out = json!({"document": index, "layers": new_ids.iter().map(|l| l.0).collect::>()}); + if let [one] = new_ids.as_slice() + && let Some(map) = out.as_object_mut() + { + map.insert("layer".into(), json!(one.0)); + } + Ok(out) +} + pub fn specs() -> Vec { vec![CommandSpec { id: CMD, diff --git a/crates/engine/src/layer_copy_cmds/tests.rs b/crates/engine/src/layer_copy_cmds/tests.rs index 476df187901..a81ebbdc470 100644 --- a/crates/engine/src/layer_copy_cmds/tests.rs +++ b/crates/engine/src/layer_copy_cmds/tests.rs @@ -1,5 +1,5 @@ use photocraft_color::{ColorMode, PixelFormat, SampleType}; -use photocraft_doc::{Document, LayerId, Pattern}; +use photocraft_doc::{Document, LayerContent, LayerId, Pattern}; use photocraft_geom::Rect; use photocraft_raster::Surface; use serde_json::{Value, json}; @@ -218,6 +218,118 @@ fn copied_link_groups_are_distinct_from_each_other_and_destination() { assert_ne!(first, second); } +/// One painted layer, `depth`-bit RGB, as the only layer above the Background. +fn one_doc(depth: u32) -> (Session, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30, "depth": depth})).unwrap(); + let paint = LayerId(s.execute("layer.new.layer", json!({"name": "paint"})).unwrap()["layer"].as_u64().unwrap()); + s.execute("select.rect", json!({"x": 4, "y": 6, "width": 10, "height": 10})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + (s, paint) +} + +#[test] +fn duplicate_to_a_new_document_keeps_size_pixels_and_place_at_every_depth() { + for depth in [8, 16, 32] { + let (mut s, paint) = one_doc(depth); + let source_before = doc(&s, 0).clone(); + let r = s.execute("layer.duplicate", json!({"document": "new"})).unwrap(); + assert_eq!(r["document"], 1); + assert_eq!(s.active_index(), Some(1), "the new document becomes active"); + let (src, dst) = (doc(&s, 0), doc(&s, 1)); + assert_eq!(*src, source_before, "the source is untouched"); + assert_eq!(dst.size, src.size, "same canvas, as Photoshop"); + assert_eq!((dst.mode, dst.depth, dst.resolution_dpi), (src.mode, src.depth, src.resolution_dpi)); + assert_eq!(dst.name, format!("{} copy", src.name)); + let layers = r["layers"].as_array().unwrap(); + assert_eq!(layers.len(), 1); + let id = LayerId(layers[0].as_u64().unwrap()); + assert_eq!(r["layer"], id.0); + assert_ne!(id, paint, "a copy has its own id"); + assert_eq!(dst.layers.len(), 1, "just the duplicated layer"); + assert_eq!(dst.layers[0].name, "paint"); + assert_eq!(bounds(&s, 1, id), Rect::new(4, 6, 14, 16), "same canvas position"); + assert_eq!( + dst.layers[0].surface().unwrap().read_region(Rect::new(4, 6, 14, 16)), + src.layer(paint).unwrap().surface().unwrap().read_region(Rect::new(4, 6, 14, 16)), + "depth {depth}: identical pixels" + ); + assert_eq!(s.active().unwrap().selected_layers(), vec![id]); + } +} + +#[test] +fn duplicate_to_a_new_document_names_the_copy_and_the_document() { + let (mut s, _) = one_doc(8); + let r = s.execute("layer.duplicate", json!({"document": "new", "name": "Ink", "documentName": "Card"})).unwrap(); + assert_eq!(doc(&s, 1).name, "Card"); + assert_eq!(doc(&s, 1).layers[0].name, "Ink"); + assert_eq!(r["layer"].as_u64(), r["layers"][0].as_u64()); +} + +#[test] +fn duplicate_to_a_new_document_carries_every_selected_layer_in_order() { + let (mut s, paint) = one_doc(8); + let bg = doc(&s, 0).layers[0].id; + s.execute("layer.select", json!({"layer": paint.0, "mode": "add"})).unwrap(); + s.execute("layer.select", json!({"layer": bg, "mode": "add"})).unwrap(); + let r = s.execute("layer.duplicate", json!({"document": "new"})).unwrap(); + let names: Vec<&str> = doc(&s, 1).layers.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, ["Background", "paint"]); + assert_eq!(r["layers"].as_array().unwrap().len(), 2); + assert!(r.get("layer").is_none(), "no single layer to report"); + // A duplicated Background stays a Background: the new document has no destination one. + let back = &doc(&s, 1).layers[0]; + assert!(back.locks.transparency && back.locks.position); +} + +#[test] +fn duplicate_to_a_new_document_cuts_smart_objects_loose_as_a_batch() { + let (mut s, _) = one_doc(8); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let original = s.active().unwrap().active_layer.unwrap(); + let sibling = LayerId(s.execute("layer.duplicate", json!({})).unwrap()["layer"].as_u64().unwrap()); + let contents = |s: &Session, i: usize, id: LayerId| { + let LayerContent::Smart(sm) = &doc(s, i).layer(id).unwrap().content else { panic!("smart object") }; + sm.contents_id + }; + assert_eq!(contents(&s, 0, original), contents(&s, 0, sibling), "instances share contents"); + s.execute("layer.select", json!({"layer": original.0, "mode": "add"})).unwrap(); + s.execute("layer.select", json!({"layer": sibling.0, "mode": "add"})).unwrap(); + let r = s.execute("layer.duplicate", json!({"document": "new"})).unwrap(); + let ids: Vec = r["layers"].as_array().unwrap().iter().map(|id| LayerId(id.as_u64().unwrap())).collect(); + assert_eq!(contents(&s, 1, ids[0]), contents(&s, 1, ids[1]), "sharing survives within the batch"); + assert_ne!(contents(&s, 1, ids[0]), contents(&s, 0, original), "independent of the source"); +} + +#[test] +fn duplicate_destinations_that_are_not_new_fail_cleanly() { + let (mut s, _) = one_doc(8); + for p in [ + json!({"document": 1}), + json!({"document": 7}), + json!({"document": true}), + json!({"document": 1.5}), + json!({"document": []}), + json!({"document": {}}), + json!({"document": "other"}), + json!({"document": "new", "layer": 999_999}), + ] { + let before = (doc(&s, 0).clone(), s.documents().len(), s.active_index()); + let err = s.execute("layer.duplicate", p.clone()).unwrap_err().to_string(); + if p == json!({"document": 1}) { + assert!(err.contains("layer.copyToDocument"), "{p}: {err}"); + } + assert_eq!(s.active_index(), before.2, "{p}: the source stays active"); + assert_eq!(s.documents().len(), before.1, "{p}: no document created"); + assert_eq!(*doc(&s, 0), before.0, "{p}: the source is untouched"); + } + // One open document: same-document duplicates still work, and stay enabled. + assert!(s.is_enabled("layer.duplicate")); + s.execute("layer.duplicate", json!({})).unwrap(); +} + #[test] fn a_named_duplicate_takes_the_name_in_one_step() { let (mut s, paint) = two_docs(40, 30, 8); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 1adc9051dae..f65bcecbebc 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -221,6 +221,12 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { ui.set_min_width(w); ui.set_max_width(w); } + // Duplicate Layer… is a fixed small grid (labels + 260 pt fields ≈ 352 pt): + // the generic 380 pt minimum left a dead strip on its right. + if crate::duplicate_layer_ui::owns(&d.fields) { + ui.set_min_width(360.0); + ui.set_max_width(360.0); + } if color_picker { ui.set_width(crate::color_picker_ui::CONTENT_WIDTH); } diff --git a/crates/ui-egui/src/duplicate_layer_ui.rs b/crates/ui-egui/src/duplicate_layer_ui.rs index b1e7d6d9943..c1b76c0eadc 100644 --- a/crates/ui-egui/src/duplicate_layer_ui.rs +++ b/crates/ui-egui/src/duplicate_layer_ui.rs @@ -18,11 +18,16 @@ pub fn open(app: &mut PhotocraftApp) -> Result { fields.insert(MARK.into(), json!(true)); fields.insert("__command".into(), json!("layer.duplicate")); fields.insert("destination".into(), json!(index)); + // Photoshop's document-`Name` starts empty; the engine defaults it when empty. + fields.insert("documentName".into(), json!("")); fields.insert("document".into(), json!(st.doc.id.0)); fields.insert("selected".into(), json!(selected.iter().map(|id| id.0).collect::>())); // Several selected layers keep their names, as in Photoshop's Duplicate Layers. if selected.len() > 1 { fields.insert("__label".into(), json!("Duplicate Layers…")); + } else if layer.is_group() { + fields.insert("__label".into(), json!("Duplicate Group…")); + fields.insert("name".into(), json!(st.doc.copy_name(&layer.name))); } else { fields.insert("__label".into(), json!("Duplicate Layer…")); fields.insert("name".into(), json!(st.doc.copy_name(&layer.name))); @@ -32,9 +37,14 @@ pub fn open(app: &mut PhotocraftApp) -> Result { } pub fn body(app: &PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map) { - let documents: Vec<(usize, String)> = app.session.documents().iter().enumerate().map(|(i, d)| (i, d.doc.name.clone())).collect(); - let options: Vec<(usize, &str)> = documents.iter().map(|(i, n)| (*i, n.as_str())).collect(); - let mut destination = fields.get("destination").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).unwrap_or(0); + let documents: Vec<(String, String)> = app.session.documents().iter().enumerate().map(|(i, d)| (i.to_string(), d.doc.name.clone())).collect(); + let mut options: Vec<(String, &str)> = documents.iter().map(|(k, n)| (k.clone(), n.as_str())).collect(); + options.push(("new".to_string(), tl!("New"))); + let mut destination = match fields.get("destination") { + Some(Value::String(s)) if s == "new" => "new".to_string(), + Some(Value::Number(n)) => n.to_string(), + _ => String::new(), + }; egui::Grid::new("duplicate-layer").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| { if let Some(name) = fields.get("name").and_then(Value::as_str) { let mut name = name.to_string(); @@ -48,8 +58,20 @@ pub fn body(app: &PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map() { + fields.insert("destination".into(), json!(i)); + } } pub fn confirm(app: &mut PhotocraftApp, fields: &Map) -> Result { @@ -60,16 +82,29 @@ pub fn confirm(app: &mut PhotocraftApp, fields: &Map) -> Result { + if let Some(doc_name) = fields.get("documentName").and_then(Value::as_str).filter(|n| !n.is_empty()) { + params.insert("documentName".into(), json!(doc_name)); + } + params.insert("document".into(), json!("new")); + app.run_command("layer.duplicate", Value::Object(params)) + } + Some(Value::Number(n)) => { + let destination = + n.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|i| *i < app.session.documents().len()).ok_or("no destination document")?; + if destination == index { + app.run_command("layer.duplicate", Value::Object(params)) + } else { + params.insert("document".into(), json!(destination)); + app.run_command("layer.copyToDocument", Value::Object(params)) + } + } + _ => Err("no destination document".into()), } } @@ -114,6 +149,42 @@ mod tests { assert!(names(&app, 1).contains(&"Ink copy".to_string())); } + #[test] + fn a_new_destination_makes_a_named_document_holding_just_the_copy() { + let mut app = make_app(); + let dialog = invoke(&mut app); + let fields = &mut app.ui.dialog_mut(dialog).unwrap().fields; + fields.insert("destination".into(), json!("new")); + fields.insert("documentName".into(), json!("Card")); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + assert_eq!(app.session.documents().len(), 2); + assert_eq!(app.session.active_index(), Some(1)); + assert_eq!(app.session.documents()[1].doc.name, "Card"); + assert_eq!(app.session.documents()[0].doc.layer_count(), 2, "the source is untouched"); + assert!(names(&app, 1).contains(&"Ink copy".to_string())); + } + + #[test] + fn a_new_destination_without_a_name_defaults_it_and_refuses_a_dead_index() { + let mut app = make_app(); + let dialog = invoke(&mut app); + app.ui.dialog_mut(dialog).unwrap().fields.insert("destination".into(), json!("new")); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + assert_eq!(app.session.documents()[1].doc.name, "Poster copy"); + let dialog = invoke(&mut app); + app.ui.dialog_mut(dialog).unwrap().fields.insert("destination".into(), json!(7)); + assert!(crate::dialogs::confirm(&mut app, dialog).is_err()); + assert_eq!(app.session.documents().len(), 2); + } + + #[test] + fn a_single_group_gets_the_group_title() { + let mut app = make_app(); + app.session.execute("layer.new.group", json!({"name": "Pack"})).unwrap(); + let _dialog = invoke(&mut app); + assert_eq!(app.ui.dialogs[0].fields["__label"], "Duplicate Group…"); + } + #[test] fn a_changed_document_refuses_and_direct_runs_stay_dialog_free() { let mut app = make_app(); From a666ab5a85cc5efbfda2935f844c7f1f1f35607a Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 18:48:26 +0200 Subject: [PATCH 27/47] History: jump to a state in one step (history.selectState) (#2966) (#3533) Clicking a History panel state ran Edit > Undo / Redo once per state. Each step bumped the document revision, so the canvas could not tell what the jump changed and recomposited the whole document (re-uploading every changed layer on the GPU path). history.selectState {state} returns the document to a state (numbered as the panel lists them, redo states included) in one command: the history moves by pointer swaps, the document becomes current once, with one revision and the damage rect between start and target, and the targeted layers restored as Undo restores them. The panel runs it once per click; an open Free Transform refuses it (panel guard kept, and the transform intercept greys and refuses it for every caller). A floating piece goes back as with Undo instead of being dropped as a new step. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/commands.rs | 1 + crates/engine/src/float_cmds.rs | 10 +- crates/engine/src/history_select_cmds.rs | 258 ++++++++++++++++++ crates/engine/src/lib.rs | 1 + .../ui-egui/src/history_panel_select_tests.rs | 60 ++++ crates/ui-egui/src/panels.rs | 34 ++- crates/ui-egui/src/transform_tool.rs | 15 +- 7 files changed, 358 insertions(+), 21 deletions(-) create mode 100644 crates/engine/src/history_select_cmds.rs create mode 100644 crates/ui-egui/src/history_panel_select_tests.rs diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 12ec82c6147..a32c5feb8d7 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1310,6 +1310,7 @@ fn build() -> Vec { v.extend(crate::analysis_cmds::specs()); v.extend(crate::notes_cmds::specs()); v.extend(crate::history_cmds::specs()); + v.extend(crate::history_select_cmds::specs()); v.extend(crate::proof_sim::specs()); v.extend(crate::presets::specs()); v.extend(crate::document_preset_cmds::specs()); diff --git a/crates/engine/src/float_cmds.rs b/crates/engine/src/float_cmds.rs index aaab22fde24..e5450b2339e 100644 --- a/crates/engine/src/float_cmds.rs +++ b/crates/engine/src/float_cmds.rs @@ -8,7 +8,8 @@ //! - `select.drop` drops the piece into its layer and moves the selection with it, as one history //! step. //! - Any other command drops the piece first (so it lands exactly where it was shown), except Undo, -//! which puts it back where it was cut (nothing was changed yet). +//! which puts it back where it was cut (nothing was changed yet), and `history.selectState`, +//! which puts it back and then returns to the chosen state. //! //! While floating, the document is unchanged; [`displayed`] gives the document with the piece at //! its offset, for the canvas. The selection stays where it was until the drop. @@ -221,6 +222,13 @@ pub(crate) fn before_command(s: &mut Session, id: &str) -> Result> st.floating = None; Ok(Some(json!({"floating": "returned"}))) } + // Returning to a History state puts the piece back too (it never reached the document), + // and then moves the history: dropping it first would add a step and discard the redo + // states the jump may be heading for. + "history.selectState" => { + st.floating = None; + Ok(None) + } _ if keeps_floating(id) => Ok(None), _ => drop_floating(s).map(|_| None), } diff --git a/crates/engine/src/history_select_cmds.rs b/crates/engine/src/history_select_cmds.rs new file mode 100644 index 00000000000..e05a859a5a5 --- /dev/null +++ b/crates/engine/src/history_select_cmds.rs @@ -0,0 +1,258 @@ +//! History panel › click a state: `history.selectState` returns the document to that state in one +//! step (#2966), as Photoshop does. The panel used to run Edit › Undo / Redo once per state: each +//! bumped the document revision, so the canvas could no longer tell what the jump changed and +//! recomposited (and re-uploaded) the whole document, plus N rounds of per-command bookkeeping. +//! +//! States are numbered as the History panel lists them, like `history.deleteState`: 0 is the +//! oldest state still held, `past` (the number of undo states) is the current one, and the redo +//! states (dimmed in the panel) follow it. Jumping back leaves the skipped states redoable; +//! jumping forward redoes them. Nothing is discarded until the next edit. + +use serde_json::{Value, json}; + +use crate::commands::{CommandSpec, int}; +use crate::{EngineError, Result, Session}; + +const CMD: &str = "history.selectState"; + +fn bad(msg: impl Into) -> EngineError { + EngineError::BadParams { cmd: CMD.into(), msg: msg.into() } +} + +fn select_state(s: &mut Session, p: &Value) -> Result { + let st = s.active().ok_or(EngineError::NoDocument)?; + let past = st.history.past_len(); + let last = past.saturating_add(st.history.redo_labels().count()); + let v = match p.get("state") { + None | Some(Value::Null) => return Err(bad("missing `state` (the History entry to return to, 0 = oldest)")), + Some(_) => int(p, "state").ok_or_else(|| bad("`state` must be a number"))?, + }; + let target = usize::try_from(v).ok().filter(|i| *i <= last).ok_or_else(|| bad(format!("`state` = {v} is not a state (0..={last})")))?; + // A background job is computing from the current state: it must not move under it. + if s.active_job().is_some() { + return Err(EngineError::Other("the history can't move while a command is still running on this document".into())); + } + let st = s.active_mut().ok_or(EngineError::NoDocument)?; + if target == past { + return Ok(json!({ "state": target, "changed": false })); + } + st.coalesce = None; + // Step the history itself (pointer moves, no pixels touched), then make the reached state + // current once: one revision, one damage rect for the canvas. + let start = st.doc.clone(); + let mut doc = start.clone(); + let mut layers = None; + let mut at = past; + while at != target { + let step = if target < at { st.history.undo(doc.clone()) } else { st.history.redo(doc.clone()) }; + let Some((d, l)) = step else { break }; + doc = d; + layers = Some(l); + at = if target < at { at - 1 } else { at + 1 }; + } + let Some(layers) = layers else { + return Err(EngineError::Other("the history can't step right now".into())); + }; + // Save As changes file identity outside history, as Edit › Undo keeps it. + if doc.name != start.name { + std::sync::Arc::make_mut(&mut doc).name.clone_from(&start.name); + } + let damage = crate::layer_multi_cmds::step_damage(&start, &doc); + st.doc = doc; + crate::restore_target(st, layers); + st.revision += 1; + st.last_damage = damage; + if at != target { + // Not reached (the bounds were checked): the document is consistent at the state reached. + return Err(EngineError::Other(format!("the history stopped at state {at}"))); + } + Ok(json!({ "state": target, "changed": true, "current": st.history.entries().last() })) +} + +fn can_select(s: &Session) -> std::result::Result<(), String> { + let st = s.active().ok_or_else(|| "no document open".to_string())?; + if st.history.can_undo() || st.history.can_redo() { Ok(()) } else { Err("the history holds only one state".into()) } +} + +pub fn specs() -> Vec { + vec![CommandSpec { + id: CMD, + label: "Select History State", + menu: &[], + shortcut: None, + params: r##"{"state":index} (History entry as the panel lists them: 0 = oldest, then the current state, then the redo states) → {state,changed,current?}: returns the document to that state in one step; the states after it stay redoable"##, + enabled: can_select, + run: select_state, + journal: true, + }] +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + + /// A document with four steps after Open, each targeting different layers. + fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 32})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap(); + s + } + + type Look = (Arc, Option, Vec, Vec, Vec); + + /// What the panel can see of a session: the document, the targeted layers, the entries and + /// the redo states. + fn look(s: &Session) -> Look { + let st = s.active().unwrap(); + (st.doc.clone(), st.active_layer, st.selected_layers.clone(), st.history.entries(), st.history.redo_labels().map(str::to_string).collect()) + } + + /// Step from the current state to `target` with Edit › Undo / Redo, note what that gives, and + /// step back. + fn stepped(s: &mut Session, target: usize) -> Look { + let current = s.active().unwrap().history.past_len(); + let (there, back) = if target < current { ("edit.undo", "edit.redo") } else { ("edit.redo", "edit.undo") }; + let n = target.abs_diff(current); + for _ in 0..n { + s.execute(there, json!({})).unwrap(); + } + let seen = look(s); + for _ in 0..n { + s.execute(back, json!({})).unwrap(); + } + seen + } + + fn same(a: &Look, b: &Look) { + assert!(Arc::ptr_eq(&a.0, &b.0), "the same document"); + assert_eq!((&a.1, &a.2, &a.3, &a.4), (&b.1, &b.2, &b.3, &b.4)); + } + + #[test] + fn jumping_back_and_forward_matches_stepping() { + for target in 0..=4usize { + for from_back in [0usize, 2, 4] { + // Start `from_back` states before the end, then jump to `target`. + let mut s = session(); + for _ in 0..from_back { + s.execute("edit.undo", json!({})).unwrap(); + } + let current = 4 - from_back; + let expected = stepped(&mut s, target); + let rev = s.active().unwrap().revision; + let r = s.execute(CMD, json!({"state": target})).unwrap(); + assert_eq!(r["state"], json!(target)); + assert_eq!(r["changed"], json!(target != current)); + same(&look(&s), &expected); + assert_eq!(s.active().unwrap().history.past_len(), target); + let bumps = s.active().unwrap().revision - rev; + assert_eq!(bumps, u64::from(target != current), "{from_back} → {target}: one revision for the jump"); + } + } + } + + #[test] + fn a_jump_reports_only_the_pixels_it_changed() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 400, "height": 300})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("select.rect", json!({"x": 40, "y": 30, "width": 10, "height": 10})).unwrap(); + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + let past = s.active().unwrap().history.past_len(); + s.execute(CMD, json!({"state": past - 4})).unwrap(); + let damage = s.active().unwrap().last_damage.expect("a bounded damage rect, not a full recomposite"); + assert!(damage.width() < 400 && damage.height() < 300, "{damage:?}"); + // And forward again. + s.execute(CMD, json!({"state": past})).unwrap(); + assert!(s.active().unwrap().last_damage.is_some()); + } + + #[test] + fn redo_states_survive_a_jump_and_go_with_the_next_edit() { + let mut s = session(); + let all = s.active().unwrap().history.entries(); + s.execute(CMD, json!({"state": 1})).unwrap(); + let (_, _, _, entries, redo) = look(&s); + assert_eq!(entries, all[..2].to_vec()); + assert_eq!(redo, all[2..].to_vec(), "the later states are dimmed, next one first"); + assert!(s.is_enabled(CMD)); + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + assert!(!s.active().unwrap().history.can_redo(), "a new step discards them"); + } + + #[test] + fn select_state_restores_the_layers_the_state_targeted() { + let mut s = session(); + let bottom = s.active().unwrap().doc.layers.first().unwrap().id; + // Target the bottom layer, then jump back to the first fill: its layer comes back, as + // with Undo. + s.select_layer(bottom).unwrap(); + let expected = stepped(&mut s, 2); + s.select_layer(bottom).unwrap(); + s.execute(CMD, json!({"state": 2})).unwrap(); + same(&look(&s), &expected); + assert_ne!(s.active().unwrap().active_layer, Some(bottom)); + } + + #[test] + fn a_floating_piece_goes_back_and_the_jump_still_happens() { + let mut s = session(); + s.execute("select.rect", json!({"x": 2, "y": 2, "width": 8, "height": 8})).unwrap(); + s.execute("select.float", json!({"dx": 5, "dy": 0})).unwrap(); + let all = s.active().unwrap().history.entries(); + let doc = s.active().unwrap().doc.clone(); + assert!(s.active().unwrap().floating.is_some()); + s.execute(CMD, json!({"state": 1})).unwrap(); + let st = s.active().unwrap(); + assert!(st.floating.is_none(), "the piece went back, it wasn't dropped"); + assert_eq!(st.history.past_len(), 1); + let redo: Vec = st.history.redo_labels().map(str::to_string).collect(); + assert_eq!(redo, all[2..].to_vec(), "no Drop step was added"); + // Forward to the state the piece was floated on: the document as it was, without the piece. + s.execute(CMD, json!({"state": all.len() - 1})).unwrap(); + assert!(Arc::ptr_eq(&s.active().unwrap().doc, &doc)); + } + + #[test] + fn select_state_refuses_bad_params_and_changes_nothing() { + let mut s = Session::new(); + assert!(s.execute(CMD, json!({"state": 0})).is_err(), "no document"); + assert!(!s.is_enabled(CMD)); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + assert!(!s.is_enabled(CMD), "only the opening state"); + let mut s = session(); + s.execute("edit.undo", json!({})).unwrap(); + let before = look(&s); + let rev = s.active().unwrap().revision; + for p in [ + json!({}), + json!({"state": null}), + json!({"state": -1}), + json!({"state": 5}), + json!({"state": 99}), + json!({"state": i64::MAX}), + json!({"state": f64::MAX}), + json!({"state": "x"}), + json!({"state": "1"}), + json!({"state": [1]}), + json!({"state": {"i": 1}}), + json!({"state": true}), + json!([1]), + ] { + assert!(s.execute(CMD, p.clone()).is_err(), "{p}"); + let now = look(&s); + assert!(Arc::ptr_eq(&now.0, &before.0) && now.3 == before.3 && now.4 == before.4, "{p} changed nothing"); + } + assert_eq!(s.active().unwrap().revision, rev); + // The last redo state is reachable. + assert!(s.execute(CMD, json!({"state": 4})).is_ok()); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9c5ff5e9bde..cf4ff75b402 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -54,6 +54,7 @@ pub mod group_view_cmds; pub mod guide_layout; pub mod hidden_target; pub mod history_cmds; +pub mod history_select_cmds; pub mod image_cmds; pub mod inspect; pub mod installed_profiles; diff --git a/crates/ui-egui/src/history_panel_select_tests.rs b/crates/ui-egui/src/history_panel_select_tests.rs new file mode 100644 index 00000000000..92c458e37fa --- /dev/null +++ b/crates/ui-egui/src/history_panel_select_tests.rs @@ -0,0 +1,60 @@ +//! #2966: a click on a History panel state returns the document there with one command +//! (`history.selectState`), not one Undo / Redo per state, so the canvas refreshes once and only +//! where the jump changed pixels. + +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use super::*; + +/// Open, then New Layer, Fill, New Layer, Fill. +fn harness() -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + for (cmd, p) in + [("layer.new.layer", json!({})), ("edit.fill", json!({"color": "#ff0000"})), ("layer.new.layer", json!({})), ("edit.fill", json!({"color": "#00ff00"}))] + { + app.run(cmd, p).unwrap(); + } + let mut ready = false; + let mut h = Harness::builder().with_size(vec2(300.0, 400.0)).build_ui_state( + move |ui, app: &mut PhotocraftApp| { + if !ready { + PhotocraftApp::setup_context(ui.ctx(), theme::ThemeKind::ProMedium); + ready = true; + return; + } + history(app, ui); + }, + app, + ); + h.run_steps(3); + h +} + +fn state(h: &Harness<'_, PhotocraftApp>) -> (usize, usize, u64) { + let st = h.state().session.active().unwrap(); + (st.history.past_len(), st.history.redo_labels().count(), st.revision) +} + +#[test] +fn clicking_a_state_jumps_there_with_one_command() { + let mut h = harness(); + let (past, redo, rev) = state(&h); + assert_eq!((past, redo), (4, 0)); + // Back three states: one command, one revision (the canvas sees one change). + h.get_all_by_label("New Layer").next().expect("the first New Layer row").click(); + h.run_steps(2); + assert_eq!(state(&h), (1, 3, rev + 1)); + assert_eq!(h.state().perf.last_command, "history.selectState"); + // Forward to the last (dimmed) state, again in one step. + h.get_all_by_label("Fill").last().expect("the last Fill row").click(); + h.run_steps(2); + assert_eq!(state(&h), (4, 0, rev + 2)); + // The current state: nothing to do. + h.get_all_by_label("Fill").last().expect("the last Fill row").click(); + h.run_steps(2); + assert_eq!(state(&h), (4, 0, rev + 2)); +} diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 7e35088bfdb..24ff5f4271e 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -3102,7 +3102,7 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.painter().line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(1.0, t.separator)); } let mut snap_cmd = None; - let mut target: Option = None; + let mut target: Option = None; let all = entries.iter().map(|e| (e.clone(), false)).chain(redo.iter().map(|e| (e.clone(), true))); // The dock gives History a fixed height: the rows scroll above the footer. let footer = if t.pro { widgets::footer_height(ui) + ui.spacing().item_spacing.y } else { 0.0 }; @@ -3113,6 +3113,7 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { snap_cmd = snapshot_rows(app, ui); for (i, (e, is_redo)) in all.enumerate() { let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, i == current, &e)); if i == current { ui.painter().rect_filled(rect, if t.pro { 0.0 } else { t.radius_sm }, if t.pro { t.row_selected } else { t.accent_soft }); } else if resp.hovered() { @@ -3135,8 +3136,8 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if is_redo { t.text_faint } else { t.icon }, ); ui.painter().text(pos2(rect.left() + 58.0, rect.center().y), Align2::LEFT_CENTER, &e, egui::FontId::proportional(12.0), color); - if resp.clicked() { - target = Some(i as isize - current as isize); + if resp.clicked() && i != current { + target = Some(i); } } }); @@ -3167,13 +3168,13 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { app.ui.status = e; app.ui.status_error = true; } - if let Some(delta) = target.filter(|_| app.ui.transform.is_none()) { - let (cmd, n) = if delta < 0 { ("edit.undo", -delta) } else { ("edit.redo", delta) }; - for _ in 0..n { - if app.run(cmd, json!({})).is_err() { - break; - } - } + // One command per click (#2966): stepping with Undo/Redo once per state made the canvas + // recomposite the whole document. + if let Some(state) = target.filter(|_| app.ui.transform.is_none()) + && let Err(e) = app.run("history.selectState", json!({ "state": state })) + { + app.ui.status = e; + app.ui.status_error = true; } } @@ -4632,8 +4633,9 @@ mod snapshot_row_tests { assert_eq!(st.history.entries().len(), steps + 1); assert_eq!(st.history.entries().last().map(String::as_str), Some("Snapshot 1")); assert_eq!(top_pixel(&h), [1.0, 0.0, 0.0, 1.0], "back to red"); - // Right-click › Rename…: type a name, Enter. - h.get_by_label("Snapshot 1").click_secondary(); + // Right-click › Rename…: type a name, Enter. The new "Snapshot 1" state is listed under the + // snapshot rows, so the first match is the snapshot's row. + h.get_all_by_label("Snapshot 1").next().unwrap().click_secondary(); h.run_steps(2); h.get_by_label("Rename…").click(); h.run_steps(2); @@ -4659,8 +4661,8 @@ mod snapshot_row_tests { h.run_steps(2); assert_eq!(snapshot_names(&h), ["Lesson start"], "Escape cancels"); assert_eq!(h.state().session.active().unwrap().history.entries().len(), steps_now + 1, "the click selected it once"); - // Right-click › Delete. - h.get_by_label("Lesson start").click_secondary(); + // Right-click › Delete (the snapshot's row, above the state the click made). + h.get_all_by_label("Lesson start").next().unwrap().click_secondary(); h.run_steps(2); h.get_by_label("Delete").click(); h.run_steps(2); @@ -6045,3 +6047,7 @@ mod properties_target_regressions { assert_eq!(after, before, "pending width belongs to the old document/layer"); } } + +#[cfg(test)] +#[path = "history_panel_select_tests.rs"] +mod history_panel_select_tests; diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index a4f64baf940..81fc0fadcf9 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -1206,8 +1206,8 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> Option { i if i.starts_with("edit.transform.") && t.content_aware.is_some() => Some(false), "edit.undo" => Some(steps.is_some_and(|s| s.can_undo(&Step::of(t)))), "edit.redo" => Some(steps.is_some_and(|s| !s.redo.is_empty())), - // No Toggle Last State under the box, and one box at a time. - "edit.toggleLastState" | "edit.contentAwareScale" | "edit.puppetWarp" => Some(false), + // No Toggle Last State or history jump under the box, and one box at a time. + "edit.toggleLastState" | "history.selectState" | "edit.contentAwareScale" | "edit.puppetWarp" => Some(false), // Rotate and Flip turn the box (not while warping, where the mesh is the box). _ if box_preset(id).is_some() => Some(t.warp.is_none()), // Editable type can't be distorted (#2630, as #2701 rules for the menus outside a box). @@ -1240,9 +1240,10 @@ fn box_preset(id: &str) -> Option<(f64, f64, f64)> { } /// While transforming, the box owns the history for every caller (menus, shortcuts, the control -/// channel, MCP): Undo and Redo step through it, and Toggle Last State, which would change the -/// document under the box, is refused. Edit › Transform's Rotate and Flip turn the box itself, as -/// in Photoshop, instead of the layer under it; committing stays with ↩ and the options bar. +/// channel, MCP): Undo and Redo step through it, and Toggle Last State and History state jumps, +/// which would change the document under the box, are refused. Edit › Transform's Rotate and +/// Flip turn the box itself, as in Photoshop, instead of the layer under it; committing stays +/// with ↩ and the options bar. /// `None` for other commands or when not transforming. /// The commands the open box answers itself ([`is_enabled`], [`intercept`]). fn box_command(id: &str) -> bool { @@ -1257,7 +1258,9 @@ pub fn intercept(app: &mut PhotocraftApp, id: &str) -> Option Some(Ok(step(app, false))), "edit.redo" => Some(Ok(step(app, true))), - "edit.toggleLastState" | "edit.contentAwareScale" | "edit.puppetWarp" => Some(Err("Commit or cancel the transform first".into())), + "edit.toggleLastState" | "history.selectState" | "edit.contentAwareScale" | "edit.puppetWarp" => { + Some(Err("Commit or cancel the transform first".into())) + } _ => { let (kx, ky, angle) = box_preset(id)?; if warping { From d0fc974aaed5494de5f2800a7990066f81de86cb Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Wed, 14 Oct 2026 00:48:33 +0800 Subject: [PATCH 28/47] Wrap Clone Stamp strokes and sampling in Pattern Preview (#4997) * Wrap Clone Stamp sampling and strokes in Pattern Preview * Preserve per-dab flow and source alpha across pattern seams --------- Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> --- crates/engine/src/retouch_cmds.rs | 92 +++++-- crates/engine/src/retouch_cmds/periodic.rs | 272 +++++++++++++++++++++ crates/engine/src/retouch_cmds/tests.rs | 263 ++++++++++++++++++++ crates/paint/src/render.rs | 4 +- crates/ui-egui/src/canvas.rs | 24 +- crates/ui-egui/src/retouch_ui.rs | 25 ++ 6 files changed, 657 insertions(+), 23 deletions(-) create mode 100644 crates/engine/src/retouch_cmds/periodic.rs diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index c25608bb2e7..9ea7fd5975f 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -26,6 +26,7 @@ use crate::{EngineError, Result, Session}; mod content_aware_move; mod patch; mod pattern_stamp; +mod periodic; mod remove; pub use content_aware_move::{ContentAwarePreview, preview as content_aware_move_preview}; pub use patch::preview as patch_preview; @@ -206,6 +207,7 @@ fn clone_mapping(s: &mut Session, p: &Value, stroke: &Stroke, cmd: &str) -> Resu /// Source pixels for destination `rect`: a translated read, or a bilinear resample when the /// clone source is scaled, rotated or flipped. +#[allow(clippy::too_many_arguments)] fn clone_sample( pre: &Document, id: Option, @@ -213,8 +215,12 @@ fn clone_sample( which: SampleLayers, rect: Rect, map: &crate::presets::clone_source::Mapping, + wrap: bool, cmd: &str, ) -> Result { + let read = |rect| { + if wrap { periodic::sample(pre, id, surf, which, rect, cmd) } else { Ok(sample(pre, id, surf, which, rect)) } + }; if map.is_translation() { let off = map.offset(); let src = rect.translate(off.0, off.1); @@ -223,17 +229,28 @@ fn clone_sample( if src.width() != rect.width() || src.height() != rect.height() { return Err(bad(cmd, format!("source offset [{}, {}] is too far: the sample window leaves the coordinate range", off.0, off.1))); } - let mut r = sample(pre, id, surf, which, src); + let mut r = read(src)?; r.rect = rect; return Ok(r); } - let src = sample(pre, id, surf, which, map.source_rect(rect)); + let src = read(map.source_rect(rect))?; Ok(crate::presets::clone_source::resample(&src, rect, map, alpha_index(&surf.format()))) } /// Read-only clone overlay. Shares the stroke sampler without anchoring a source or editing history. /// Both destination and transformed source allocations are bounded for interactive use. pub fn clone_preview(s: &Session, rect: Rect, map: &crate::presets::clone_source::Mapping, sample_layer: &str) -> Result { + clone_preview_periodic(s, rect, map, sample_layer, false) +} + +/// Clone-source overlay with optional source repetition for View > Pattern Preview. +pub fn clone_preview_periodic( + s: &Session, + rect: Rect, + map: &crate::presets::clone_source::Mapping, + sample_layer: &str, + wrap: bool, +) -> Result { if ![map.source.0, map.source.1, map.anchor.0, map.anchor.1].into_iter().chain(map.m).all(|v| v.is_finite() && v.abs() < 1_000_000.0) { return Err(bad("clone.preview", "invalid preview mapping")); } @@ -253,7 +270,7 @@ pub fn clone_preview(s: &Session, rect: Rect, map: &crate::presets::clone_source let id = d.active_layer.ok_or_else(|| bad("clone.preview", "no active layer"))?; let surf = d.doc.layer(id).and_then(|l| l.surface()).ok_or_else(|| bad("clone.preview", "no pixel surface"))?; let which = sample_layers(&json!({"sampleLayer": sample_layer}), "clone.preview")?; - let region = clone_sample(&d.doc, Some(id), surf, which, rect, map, "clone.preview")?; + let region = clone_sample(&d.doc, Some(id), surf, which, rect, map, wrap, "clone.preview")?; let px = region.data.chunks_exact(surf.format().channels()).map(|p| to_rgba(&surf.format(), p)).collect(); Ok(photocraft_compose::Buffer { rect, px }) } @@ -304,16 +321,26 @@ fn clone_stamp(s: &mut Session, p: &Value) -> Result { let from_live = live.is_some(); let map = clone_mapping(s, p, &stroke, CMD)?; let off = map.offset(); - let dmg = run_stroke(s, "Clone Stamp", id, p, |pre, surf, sel, lock| { - if let Some(l) = live { - *surf = l.surface; - return Ok(l.bounds); - } - let (bounds, cov) = stroke_coverage(&stroke); - // Samples come from the pre-stroke state, so pixels painted earlier in the stroke are never re-cloned. - let paint = clone_sample(pre, id, surf, which, bounds, &map, CMD)?; - Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode)) - })?; + let dmg = + run_stroke(s, "Clone Stamp", id, p, |pre, surf, sel, lock| { + if let Some(l) = live { + *surf = l.surface; + return Ok(l.bounds); + } + if flag(p, "wrap", false) { + let mut renderer = photocraft_paint::StrokeRenderer::new(&stroke.brush, None, 1.0); + renderer.push(&stroke.points); + renderer.finish(); + let before = surf.clone(); + let bounds = renderer.bounds(); + return periodic::Composite { target: surf, before: &before, tile: pre.bounds(), opacity: stroke.brush.opacity, selection: sel, lock, mode } + .draw(bounds, &stroke.brush, &stroke.points, |r| clone_sample(pre, id, &before, which, r, &map, true, CMD)); + } + let (bounds, cov) = stroke_coverage(&stroke); + // Samples come from the pre-stroke state, so pixels painted earlier in the stroke are never re-cloned. + let paint = clone_sample(pre, id, surf, which, bounds, &map, flag(p, "wrap", false), CMD)?; + Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode)) + })?; let mut r = clone_result(dmg, off, aligned, p, &stroke); if from_live { r["fromLive"] = json!(true); @@ -414,7 +441,12 @@ impl LiveRetouch { /// Everything the stroke has touched so far. pub fn bounds(&self) -> Rect { - self.renderer.bounds().union(&self.tail) + let bounds = self.renderer.bounds().union(&self.tail); + if self.cmd == "paint.cloneStamp" && flag(&self.params, "wrap", false) { + periodic::project(bounds, self.pre.bounds()).into_iter().fold(Rect::EMPTY, |a, b| a.union(&b)) + } else { + bounds + } } /// Every point rendered so far, in order. @@ -457,6 +489,9 @@ impl LiveRetouch { let pts: Vec<_> = pts.iter().map(|q| StrokePoint::new(q.x, q.y, q.pressure)).collect(); self.points.extend_from_slice(&pts); self.renderer.push(&pts); + if self.cmd == "paint.cloneStamp" && flag(&self.params, "wrap", false) { + return self.push_periodic(); + } let old = std::mem::replace(&mut self.tail, Rect::EMPTY); let mut dmg = Rect::EMPTY; if !old.is_empty() { @@ -478,13 +513,36 @@ impl LiveRetouch { Ok(dmg) } + fn push_periodic(&mut self) -> Result { + let mut dirty = self.renderer.take_dirty_rect().union(&self.tail); + let mut tail = self.renderer.tail_preview(); + self.tail = tail.as_mut().map_or(Rect::EMPTY, |t| t.take_dirty_rect()); + dirty = dirty.union(&self.tail); + let LivePaint::Clone { which, map, cmd } = &self.paint else { + return Err(bad(&self.cmd, "periodic painting requires a clone source")); + }; + let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?; + periodic::Composite { + target: surf, + before: &self.pre_surf, + tile: self.pre.bounds(), + opacity: self.opacity, + selection: self.sel.as_ref(), + lock: self.lock, + mode: self.mode, + } + .draw(dirty, &self.brush, &self.points, |r| clone_sample(&self.pre, self.id, &self.pre_surf, *which, r, map, true, cmd)) + } + /// Redraw `r` from the pre-stroke pixels with the clone source through coverage `cov`. fn composite(&mut self, r: Rect, cov: &[f32]) -> Result { if r.is_empty() { return Ok(r); } let paint = match &self.paint { - LivePaint::Clone { which, map, cmd } => clone_sample(&self.pre, self.id, &self.pre_surf, *which, r, map, cmd)?, + LivePaint::Clone { which, map, cmd } => { + clone_sample(&self.pre, self.id, &self.pre_surf, *which, r, map, *cmd == "paint.cloneStamp" && flag(&self.params, "wrap", false), cmd)? + } LivePaint::History(src) => Region::read(src, r), }; let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?; @@ -543,7 +601,7 @@ fn healing_brush(s: &mut Session, p: &Value) -> Result { let (bounds, cov) = stroke_coverage(&stroke); let (g, cov) = pad_coverage(bounds, &cov, 2); let fmt = surf.format(); - let src = clone_sample(pre, id, surf, which, g, &map, CMD)?; + let src = clone_sample(pre, id, surf, which, g, &map, false, CMD)?; // The destination the texture is fitted to: the layer itself, or what the user sees when // sampling several layers (so healing onto an empty layer matches the composite). let dst = if which == SampleLayers::Current { Region::read(surf, g) } else { composite_region(pre, id, which, g, fmt) }; @@ -1168,7 +1226,7 @@ pub fn specs() -> Vec { menu: &[], shortcut: None, params: brush_params!( - r#","source":[sx,sy] (sampled under the first point) | "offset":[dx,dy],"aligned":bool=true,"sampleLayer":"current|currentAndBelow|all"="current","mode":"normal|multiply|…"="normal" → {"damage","offset","aligned","nextSource"}"# + r#","source":[sx,sy] (sampled under the first point) | "offset":[dx,dy],"aligned":bool=true,"sampleLayer":"current|currentAndBelow|all"="current","mode":"normal|multiply|…"="normal","wrap":bool=false (repeat source and stroke at document edges for Pattern Preview) → {"damage","offset","aligned","nextSource"}"# ), enabled: has_pixel_layer, run: clone_stamp, diff --git a/crates/engine/src/retouch_cmds/periodic.rs b/crates/engine/src/retouch_cmds/periodic.rs new file mode 100644 index 00000000000..f31542fb5ac --- /dev/null +++ b/crates/engine/src/retouch_cmds/periodic.rs @@ -0,0 +1,272 @@ +//! Bounded reads from the repeating document tile used by Pattern Preview. + +use super::*; + +/// At most four disjoint rectangles in the document tile. Projecting the dirty area separately +/// from the stroke bounds avoids scanning the whole document when a small dab crosses a seam. +pub(super) fn project(rect: Rect, tile: Rect) -> Vec { + if rect.is_empty() || tile.is_empty() { + return Vec::new(); + } + let axis = |a: i32, b: i32, lo: i32, hi: i32| { + let period = i64::from(hi) - i64::from(lo); + let length = i64::from(b) - i64::from(a); + if length >= period { + return vec![(lo, hi)]; + } + let start = i64::from(lo) + (i64::from(a) - i64::from(lo)).rem_euclid(period); + let end = start + length; + if end <= i64::from(hi) { vec![(start as i32, end as i32)] } else { vec![(start as i32, hi), (lo, (i64::from(lo) + end - i64::from(hi)) as i32)] } + }; + let xs = axis(rect.x0, rect.x1, tile.x0, tile.x1); + let ys = axis(rect.y0, rect.y1, tile.y0, tile.y1); + ys.into_iter().flat_map(|(y0, y1)| xs.iter().map(move |&(x0, x1)| Rect::new(x0, y0, x1, y1))).collect() +} + +/// Composite periodic copies of one stroke, restoring each dirty patch from pre-stroke pixels. +/// Each dab wraps once, then builds flow towards its opacity ceiling. Stroke opacity and +/// selection are applied once after all dabs, including repeated visits through other tiles. +pub(super) struct Composite<'a> { + pub target: &'a mut Surface, + pub before: &'a Surface, + pub tile: Rect, + pub opacity: f32, + pub selection: Option<&'a Surface>, + pub lock: bool, + pub mode: BlendMode, +} + +impl Composite<'_> { + pub fn draw(&mut self, dirty: Rect, brush: &BrushSettings, points: &[StrokePoint], mut paint: impl FnMut(Rect) -> Result) -> Result { + let ctx = photocraft_paint::BrushContext::new(brush); + let mut generator = photocraft_paint::dynamics::DabGenerator::new(brush, 1.0); + let (mut primary, mut dual) = (Vec::new(), Vec::new()); + generator.push(points, &mut primary, &mut dual); + generator.finish(&mut primary, &mut dual); + let primary: Vec<_> = primary + .into_iter() + .map(|d| { + let r = ctx.dab_rect(&d, false); + (d, r) + }) + .collect(); + let dual: Vec<_> = dual + .into_iter() + .map(|d| { + let r = ctx.dab_rect(&d, true); + (d, r) + }) + .collect(); + let mut damage = Rect::EMPTY; + let mut work = 0_u64; + for rect in project(dirty, self.tile) { + let mut y = rect.y0; + while y < rect.y1 { + let y1 = y.saturating_add(64).min(rect.y1); + let mut x = rect.x0; + while x < rect.x1 { + let x1 = x.saturating_add(64).min(rect.x1); + let dst = Rect::new(x, y, x1, y1); + let mut stroke = PatchStroke::new(dst, self.target.channels()); + let mut dual_stroke = PatchStroke::new(dst, 1); + let alpha = alpha_index(&self.target.format()); + for (dab, bounds) in &primary { + if !project(*bounds, self.tile).iter().any(|r| !r.intersect(&dst).is_empty()) { + continue; + } + let footprint = self.fold_dab(&ctx, dab, *bounds, dst, false, &mut paint, &mut work)?; + stroke.accumulate(dab, &footprint, alpha); + } + for (dab, bounds) in &dual { + if !project(*bounds, self.tile).iter().any(|r| !r.intersect(&dst).is_empty()) { + continue; + } + let footprint = self.fold_dab(&ctx, dab, *bounds, dst, true, &mut paint, &mut work)?; + let mut dab = *dab; + dab.opacity = 1.0; + dual_stroke.accumulate(&dab, &footprint, None); + } + stroke.normalize(alpha); + for py in dst.y0..dst.y1 { + for px in dst.x0..dst.x1 { + let i = stroke.paint.index(px, py); + let depth = brush.wet_edges.then(|| stroke.depth.get(i).copied().unwrap_or(0.0)); + let dual = brush.dual_brush.enabled.then(|| dual_stroke.coverage.get(i).copied().unwrap_or(0.0)); + if let Some(c) = stroke.coverage.get_mut(i) { + *c = ctx.stroke_mask_wet(*c, depth, dual, px, py); + } + } + } + self.target.write_region(dst, &self.before.read_region(dst)); + apply_coverage(self.target, dst, &stroke.coverage, self.opacity, self.selection, self.lock, &stroke.paint, self.mode); + damage = damage.union(&dst); + x = x1; + } + y = y1; + } + } + Ok(damage) + } + + #[allow(clippy::too_many_arguments)] + fn fold_dab( + &self, + ctx: &photocraft_paint::BrushContext, + dab: &photocraft_paint::Dab, + bounds: Rect, + dst: Rect, + dual: bool, + paint: &mut impl FnMut(Rect) -> Result, + work: &mut u64, + ) -> Result { + let mut result = PatchStroke::new(dst, if dual { 1 } else { self.target.channels() }); + let (w, h) = (i64::from(self.tile.width()), i64::from(self.tile.height())); + let nx0 = (i64::from(bounds.x0) - i64::from(dst.x1)).div_euclid(w) + 1; + let nx1 = (i64::from(bounds.x1) - 1 - i64::from(dst.x0)).div_euclid(w); + let ny0 = (i64::from(bounds.y0) - i64::from(dst.y1)).div_euclid(h) + 1; + let ny1 = (i64::from(bounds.y1) - 1 - i64::from(dst.y0)).div_euclid(h); + let (mut values, mut depths) = (Vec::new(), Vec::new()); + let wet = ctx.brush.wet_edges && !dual; + for ny in ny0..=ny1 { + for nx in nx0..=nx1 { + let (dx, dy) = (nx * w, ny * h); + let r = Rect::new( + (i64::from(dst.x0) + dx).max(i64::from(bounds.x0)) as i32, + (i64::from(dst.y0) + dy).max(i64::from(bounds.y0)) as i32, + (i64::from(dst.x1) + dx).min(i64::from(bounds.x1)) as i32, + (i64::from(dst.y1) + dy).min(i64::from(bounds.y1)) as i32, + ); + *work = work.saturating_add(r.width() as u64 * r.height() as u64); + if *work > 64 * 1024 * 1024 { + return Err(bad("paint.cloneStamp", "wrapped stroke exceeds its coverage budget")); + } + ctx.rasterize_into(dab, dual, r, &mut values, wet.then_some(&mut depths)); + let mut sampled = None; + for sy in r.y0..r.y1 { + for sx in r.x0..r.x1 { + let source_index = ((sy - r.y0) as usize) * r.width() as usize + (sx - r.x0) as usize; + let value = values.get(source_index).copied().unwrap_or(0.0); + if value <= 0.0 { + continue; + } + let (tx, ty) = ((i64::from(sx) - dx) as i32, (i64::from(sy) - dy) as i32); + let i = result.paint.index(tx, ty); + if let Some(depth) = result.depth.get_mut(i) { + *depth = depth.max(depths.get(source_index).copied().unwrap_or(0.0)); + } + let Some(previous) = result.coverage.get_mut(i) else { continue }; + if value <= *previous { + continue; + } + if !dual { + if sampled.is_none() { + sampled = Some(paint(r)?); + } + if let Some(source) = &sampled { + result.paint.px_mut(tx, ty).copy_from_slice(source.px(sx, sy)); + } + } + *previous = value; + } + } + } + } + Ok(result) + } +} + +struct PatchStroke { + coverage: Vec, + depth: Vec, + // Native colours weighted by coverage and source alpha; alpha weighted by coverage. + paint: Region, +} + +impl PatchStroke { + fn new(rect: Rect, channels: usize) -> Self { + let len = rect.width() as usize * rect.height() as usize; + Self { coverage: vec![0.0; len], depth: vec![0.0; len], paint: Region::new(rect, channels) } + } + + fn accumulate(&mut self, dab: &photocraft_paint::Dab, footprint: &PatchStroke, alpha: Option) { + let rect = self.paint.rect; + for y in rect.y0..rect.y1 { + for x in rect.x0..rect.x1 { + let i = self.paint.index(x, y); + let Some(c) = self.coverage.get_mut(i) else { continue }; + let value = footprint.coverage.get(i).copied().unwrap_or(0.0); + if value <= 0.0 { + continue; + } + if let Some(depth) = self.depth.get_mut(i) { + *depth = depth.max(footprint.depth.get(i).copied().unwrap_or(0.0)); + } + if dab.opacity <= *c { + continue; + } + let next = *c + value * (dab.opacity - *c); + let weight = next - *c; + let source = footprint.paint.px(x, y); + let sa = alpha.and_then(|a| source.get(a)).copied().unwrap_or(1.0); + for (channel, (dst, src)) in self.paint.px_mut(x, y).iter_mut().zip(source).enumerate() { + *dst += if Some(channel) == alpha { weight * sa } else { weight * sa * src }; + } + *c = next; + } + } + } + + fn normalize(&mut self, alpha: Option) { + let rect = self.paint.rect; + for y in rect.y0..rect.y1 { + for x in rect.x0..rect.x1 { + let i = self.paint.index(x, y); + let coverage = self.coverage.get(i).copied().unwrap_or(0.0); + let px = self.paint.px_mut(x, y); + let weight = alpha.and_then(|a| px.get(a)).copied().unwrap_or(coverage); + for (channel, value) in px.iter_mut().enumerate() { + let denominator = if Some(channel) == alpha { coverage } else { weight }; + *value = if denominator > 0.0 { *value / denominator } else { 0.0 }; + } + } + } + } +} + +pub(super) fn sample(pre: &Document, id: Option, surf: &Surface, which: SampleLayers, rect: Rect, cmd: &str) -> Result { + let tile = pre.bounds(); + if tile.is_empty() { + return Err(bad(cmd, "cannot repeat an empty document")); + } + if rect.is_empty() { + return Ok(Region::new(Rect::EMPTY, surf.channels())); + } + let (w, h, n) = (rect.width() as usize, rect.height() as usize, surf.channels()); + let len = w + .checked_mul(h) + .and_then(|v| v.checked_mul(n)) + .filter(|&v| v <= 16 * 1024 * 1024) + .ok_or_else(|| bad(cmd, "periodic source window exceeds its 64 MiB budget"))?; + let mut out = Region { rect, ch: n, data: vec![0.0; len] }; + // Split at tile seams. Read only the required rectangles, including the bilinear apron, + // rather than allocating or compositing the entire document for a small clone dab. + let mut dy = 0; + while dy < h { + let sy = (i64::from(rect.y0) + dy as i64 - i64::from(tile.y0)).rem_euclid(i64::from(tile.height())) as i32 + tile.y0; + let rows = (h - dy).min((tile.y1 - sy) as usize); + let mut dx = 0; + while dx < w { + let sx = (i64::from(rect.x0) + dx as i64 - i64::from(tile.x0)).rem_euclid(i64::from(tile.width())) as i32 + tile.x0; + let cols = (w - dx).min((tile.x1 - sx) as usize); + let part = super::sample(pre, id, surf, which, Rect::new(sx, sy, sx + cols as i32, sy + rows as i32)); + for (dst, src) in out.data.chunks_exact_mut(w * n).skip(dy).take(rows).zip(part.data.chunks_exact(cols * n)) { + if let Some(dst) = dst.get_mut(dx * n..(dx + cols) * n) { + dst.copy_from_slice(src); + } + } + dx += cols; + } + dy += rows; + } + Ok(out) +} diff --git a/crates/engine/src/retouch_cmds/tests.rs b/crates/engine/src/retouch_cmds/tests.rs index 5febba9c5ac..9a0ae22f081 100644 --- a/crates/engine/src/retouch_cmds/tests.rs +++ b/crates/engine/src/retouch_cmds/tests.rs @@ -3,6 +3,171 @@ use photocraft_color::ColorMode; const DEPTHS: [u64; 3] = [8, 16, 32]; +#[test] +fn periodic_clone_large_footprint_applies_opacity_once_and_respects_destination_masks() { + for depth in DEPTHS { + for lock in [false, true] { + let mut s = session(8, 8, depth, "rgb"); + paint_layer(&mut s, |x, _| { + if x < 4 { + [1.0, 0.0, 0.0, 1.0] + } else if x == 7 { + [0.0; 4] + } else { + [1.0; 4] + } + }); + s.edit("lock", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().locks.transparency = lock; + Ok(()) + }) + .unwrap(); + s.execute("select.rect", json!({"x":5,"y":0,"width":3,"height":8})).unwrap(); + s.execute("paint.cloneStamp", json!({"points":[[6,4]],"offset":[4,0],"size":32,"hardness":100,"opacity":50,"wrap":true})).unwrap(); + assert_eq!(rgba(&s, 4, 4), [1.0; 4], "selection is evaluated at the wrapped destination"); + let opaque = rgba(&s, 6, 4); + assert!((opaque[1] - 0.5).abs() <= tol(depth), "overlapping tile copies must not multiply opacity: {opaque:?}"); + let transparent = rgba(&s, 7, 4); + if lock { + assert_eq!(transparent[3], 0.0, "locked transparent destination stays transparent"); + } else { + assert!((transparent[3] - 0.5).abs() <= tol(depth), "unlocked transparent destination receives one stroke: {transparent:?}"); + assert_eq!(transparent[0], 1.0); + } + } + } +} + +#[test] +fn periodic_clone_selection_uses_native_cmyk_pixels_across_seam() { + let mut s = session(64, 32, 8, "cmyk"); + paint_layer(&mut s, |x, _| if x < 32 { [0.0, 0.0, 1.0, 1.0] } else { [1.0, 1.0, 0.0, 1.0] }); + s.execute("select.rect", json!({"x":0,"y":0,"width":4,"height":32})).unwrap(); + s.execute("paint.cloneStamp", json!({"points":[[63,16]],"offset":[-23,0],"size":16,"hardness":100,"wrap":true})).unwrap(); + let (inside, outside) = (rgba(&s, 1, 16), rgba(&s, 5, 16)); + assert!(inside[0] > 0.8 && inside[2] < 0.3, "selected wrapped destination becomes yellow: {inside:?}"); + assert!(outside[2] > outside[0] + 0.2, "unselected neighbour remains blue: {outside:?}"); +} + +#[test] +fn periodic_clone_projection_is_disjoint_and_matches_pixel_modulo() { + use std::collections::HashSet; + let tile = Rect::new(0, 0, 8, 6); + for rect in [Rect::new(-2, -3, 3, 2), Rect::new(-10, -9, 19, 14), Rect::new(7, 5, 10, 8), Rect::new(i32::MIN, i32::MIN, i32::MIN + 3, i32::MIN + 2)] { + let expected: HashSet<_> = (rect.y0..rect.y1).flat_map(|y| (rect.x0..rect.x1).map(move |x| (x.rem_euclid(8), y.rem_euclid(6)))).collect(); + let mut actual = HashSet::new(); + for r in periodic::project(rect, tile) { + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + assert!(actual.insert((x, y)), "projected rectangles overlap"); + } + } + } + assert_eq!(actual, expected); + } +} + +#[test] +fn periodic_clone_preview_interpolates_across_transformed_source_seam() { + use crate::presets::clone_source::Mapping; + for depth in DEPTHS { + let mut s = session(32, 24, depth, "rgb"); + paint_layer(&mut s, |x, _| if x == 0 { [1.0, 0.0, 0.0, 1.0] } else { [0.0, 0.0, 1.0, 1.0] }); + let before = s.active().unwrap().doc.clone(); + // A rotated mapping samples halfway between the last and first columns. + let map = Mapping { source: (31.5, 10.0), anchor: (0.0, 0.0), m: [0.0, 1.0, -1.0, 0.0] }; + let r = Rect::new(0, 0, 1, 1); + let wrapped = clone_preview_periodic(&s, r, &map, "current", true).unwrap(); + assert_eq!(wrapped.px, [[0.5, 0.0, 0.5, 1.0]]); + let ordinary = clone_preview(&s, r, &map, "current").unwrap(); + assert_eq!(ordinary.px, [[0.0, 0.0, 1.0, 0.5]]); + assert_eq!(s.active().unwrap().doc, before); + } +} + +#[test] +fn periodic_clone_destination_wraps_edges_and_undoes_at_every_depth() { + for depth in DEPTHS { + for wrap in [false, true] { + let mut s = session(100, 60, depth, "rgb"); + paint_layer(&mut s, |x, y| if x < 24 && y < 24 { [1.0, 0.0, 0.0, 1.0] } else { [1.0; 4] }); + let before = s.active().unwrap().doc.clone(); + s.execute("paint.cloneStamp", json!({"points":[[98,30]],"source":[10,10],"size":16,"hardness":100,"opacity":50,"wrap":wrap})).unwrap(); + for x in [98, 1] { + let expected = if x == 98 || wrap { [1.0, 0.5, 0.5, 1.0] } else { [1.0; 4] }; + let actual = rgba(&s, x, 30); + for (a, b) in actual.into_iter().zip(expected) { + assert!((a - b).abs() <= tol(depth), "depth={depth} wrap={wrap} x={x}: {actual:?}"); + } + } + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before, "one undo restores the whole wrapped stroke"); + } + } +} + +#[test] +fn periodic_clone_live_matches_commit_through_a_corner() { + for depth in DEPTHS { + let mut s = session(32, 24, depth, "rgb"); + paint_layer(&mut s, texture); + let before = s.active().unwrap().doc.clone(); + let mut params = json!({"points":[[30,22]],"source":[10,10],"size":16,"hardness":70,"opacity":45,"flow":60,"wrap":true}); + let mut live = LiveRetouch::begin(&s, "paint.cloneStamp", ¶ms).unwrap(); + let mut points = vec![json!([30, 22])]; + for next in [None, Some([34.0, 26.0]), Some([38.0, 28.0]), Some([30.0, 22.0])] { + if let Some([x, y]) = next { + live.push(&[StrokePoint::new(x, y, 1.0)]).unwrap(); + points.push(json!([x, y])); + } + params["points"] = json!(points); + let mut committed = Session::new(); + committed.add_document((*before).clone(), None); + committed.execute("paint.cloneStamp", params.clone()).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + let actual = live.doc.layer(id).unwrap().surface().unwrap().read_region(before.bounds()); + let expected = committed.active().unwrap().doc.layer(id).unwrap().surface().unwrap().read_region(before.bounds()); + assert_eq!(actual, expected, "live versus committed pixels at depth {depth}, points {points:?}"); + } + } +} + +#[test] +fn periodic_clone_source_repeats_both_axes_at_every_depth() { + for depth in DEPTHS { + let mut s = session(6, 4, depth, "rgb"); + paint_layer(&mut s, |x, y| [x as f32 / 6.0, y as f32 / 4.0, 0.25, 1.0]); + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + let r = Rect::new(-8, -6, 15, 11); + for which in [SampleLayers::Current, SampleLayers::CurrentAndBelow, SampleLayers::All] { + let result = periodic::sample(&st.doc, st.active_layer, surf, which, r, "test").unwrap(); + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + let expected = surf.rgba(x.rem_euclid(6), y.rem_euclid(4)); + let actual = to_rgba(&surf.format(), result.px(x, y)); + for (a, b) in actual.into_iter().zip(expected) { + assert!((a - b).abs() <= tol(depth), "{depth}-bit ({x},{y}): {actual:?} != {expected:?}"); + } + } + } + } + } +} + +#[test] +fn periodic_clone_source_bounds_allocations_and_accepts_far_coordinates() { + let s = session(6, 4, 8, "rgb"); + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + assert!(periodic::sample(&st.doc, st.active_layer, surf, SampleLayers::Current, Rect::new(0, 0, 100_000, 100_000), "test").is_err()); + for r in [Rect::new(i32::MIN, i32::MIN, i32::MIN + 3, i32::MIN + 2), Rect::new(i32::MAX - 3, i32::MAX - 2, i32::MAX, i32::MAX)] { + let result = periodic::sample(&st.doc, st.active_layer, surf, SampleLayers::Current, r, "test").unwrap(); + assert_eq!(result.data.len(), 3 * 2 * surf.channels()); + assert!(result.data.iter().all(|v| *v == 1.0)); + } +} + #[test] fn proximity_match_reads_original_pixels_for_overlapping_offsets() { // Shifting source one pixel to the left overlaps the destination in a @@ -1791,3 +1956,101 @@ fn a_live_retouch_stroke_ignores_tilt_and_times() { let read = |l: &LiveRetouch| l.doc.layer(id).unwrap().surface().unwrap().read_region(b); assert_eq!(read(&plain), read(&pen)); } + +#[test] +fn periodic_clone_low_flow_revisits_accumulate_across_tile_aliases() { + // Independent oracle: paint the same continuous stroke over two physical copies of a + // tile, then combine the two coverage values using the brush's flow accumulation rule. + let setup = |width| { + let mut s = Session::new(); + s.execute("file.new", json!({"width":width,"height":24,"depth":32})).unwrap(); + for x in (8..width).step_by(32) { + s.execute("paint.pencil", json!({"points":[[x,12]],"size":16,"color":"#ff0000"})).unwrap(); + } + s + }; + let params = |wrap| json!({"points":[[22,12],[54,12]],"offset":[-18,0],"size":2,"hardness":100,"flow":20,"opacity":100,"wrap":wrap}); + let green = |s: &Session, x| { + let st = s.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().rgba(x, 12)[1] + }; + let mut physical = setup(64); + physical.execute("paint.cloneStamp", params(false)).unwrap(); + let (a, b) = (green(&physical, 22), green(&physical, 54)); + assert!(a > 0.0 && a < 1.0 && b > 0.0 && b < 1.0, "oracle needs two partial visits: {a}, {b}"); + let expected = a * b; + let mut periodic = setup(32); + periodic.execute("paint.cloneStamp", params(true)).unwrap(); + let actual = green(&periodic, 22); + assert!((actual - expected).abs() < 0.0001, "repeated visits should build flow: actual green={actual}, expected={expected}, physical copies={a},{b}"); +} + +#[test] +fn periodic_clone_live_offer_commits_and_undoes_the_same_pixels_as_replay() { + for depth in [8, 16, 32] { + let mut s = Session::new(); + s.execute("file.new", json!({"width":32,"height":24,"depth":depth})).unwrap(); + s.execute("paint.pencil", json!({"points":[[8,8]],"size":12,"color":"#d03060"})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let id = s.active().unwrap().active_layer.unwrap(); + let params = json!({"points":[[30,22],[34,26],[38,28],[30,22]],"source":[8,8],"size":16,"hardness":70,"opacity":45,"flow":60,"wrap":true}); + let live = LiveRetouch::begin(&s, "paint.cloneStamp", ¶ms).unwrap(); + let shown = live.doc.layer(id).unwrap().surface().unwrap().read_region(before.bounds()); + s.offer_live_retouch(live).unwrap(); + let result = s.execute("paint.cloneStamp", params.clone()).unwrap(); + assert_eq!(result["fromLive"], true); + let read = |s: &Session| s.active().unwrap().doc.layer(id).unwrap().surface().unwrap().read_region(before.bounds()); + assert_eq!(read(&s), shown, "offered preview at depth {depth}"); + let mut replay = Session::new(); + replay.add_document((*before).clone(), None); + replay.execute("paint.cloneStamp", params).unwrap(); + assert_eq!(read(&s), read(&replay), "command replay at depth {depth}"); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before, "one undo at depth {depth}"); + } +} +#[test] +fn periodic_clone_interior_matches_ordinary_brush_masks_and_transparency() { + let cases = [ + json!({"wetEdges":true}), + json!({"dualBrush":{"enabled":true,"size":7,"hardness":0.5,"spacing":0.2}}), + json!({"texture":{"enabled":true,"eachTip":false,"depth":0.6}}), + json!({"pressureOpacity":true,"pressureSize":false}), + json!({"wetEdges":true,"dualBrush":{"enabled":true,"size":7},"pressureOpacity":true}), + ]; + for depth in DEPTHS { + for brush in &cases { + let setup = || { + let mut s = session(96, 64, depth, "rgb"); + paint_layer(&mut s, |x, y| { + let mut px = texture(x, y); + px[3] = if x < 40 { 0.4 } else { 0.75 }; + px + }); + s.execute("tools.setBrush", json!({"brush":brush})).unwrap(); + s.execute("select.rect", json!({"x":48,"y":16,"width":36,"height":36,"feather":2})).unwrap(); + s + }; + let mut ordinary = setup(); + let mut periodic = setup(); + let mut p = json!({"points":[[55,30,0.4],[65,34,0.8],[72,30,0.3]],"source":[20,20],"size":12,"hardness":65,"flow":35,"opacity":60}); + ordinary.execute("paint.cloneStamp", p.clone()).unwrap(); + p["wrap"] = json!(true); + periodic.execute("paint.cloneStamp", p).unwrap(); + let read = |s: &Session| { + let st = s.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().read_region(st.doc.bounds()) + }; + let (a, b) = (read(&ordinary), read(&periodic)); + let tolerance = if depth == 8 { + 1.1 / 255.0 + } else if depth == 16 { + 1.1 / 65535.0 + } else { + 0.00001 + }; + let error = a.iter().zip(&b).map(|(a, b)| (a - b).abs()).fold(0.0_f32, f32::max); + assert!(error <= tolerance, "depth={depth}, brush={brush}, max native-channel error={error}"); + } + } +} diff --git a/crates/paint/src/render.rs b/crates/paint/src/render.rs index 89c0abebd63..5f59aafad18 100644 --- a/crates/paint/src/render.rs +++ b/crates/paint/src/render.rs @@ -234,7 +234,7 @@ impl BrushContext { /// [`rasterize`](Self::rasterize); with `depth`, Wet Edges is left out of `out` and each /// painted pixel's depth inside the dab (`1 − rn`) goes to `depth`, for the stroke to apply it. - fn rasterize_into(&self, d: &Dab, dual: bool, rect: Rect, out: &mut Vec, mut depth: Option<&mut Vec>) { + pub fn rasterize_into(&self, d: &Dab, dual: bool, rect: Rect, out: &mut Vec, mut depth: Option<&mut Vec>) { let (w, h) = (rect.width() as usize, rect.height() as usize); out.clear(); out.resize(w * h, 0.0); @@ -394,7 +394,7 @@ impl BrushContext { /// [`stroke_mask`](Self::stroke_mask) with Wet Edges from the stroke's depth at the pixel /// (the deepest any dab reaches it, see [`CoverageMap::depth`]). #[inline] - fn stroke_mask_wet(&self, c: f32, depth: Option, dual: Option, x: i32, y: i32) -> f32 { + pub fn stroke_mask_wet(&self, c: f32, depth: Option, dual: Option, x: i32, y: i32) -> f32 { let b = &self.brush; let mut m = match depth { Some(dp) => c * wet_factor(1.0 - dp), diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index c84c2f1f975..62a9fda7124 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -50,11 +50,15 @@ fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &Vie (h.finish(), radius.to_bits(), at.map(f64::to_bits)) }); let key = format!( - "{:?}:{}:{:?}:{rect:?}:{map:?}:{}:{}:{}:{}:{tip_key:?}", - st.doc.id, st.revision, st.active_layer, app.ui.tool_options.clone_sample, display_key, overlay.invert, overlay.blend + "{:?}:{}:{:?}:{rect:?}:{map:?}:{}:{}:{}:{}:{}:{tip_key:?}", + st.doc.id, st.revision, st.active_layer, app.ui.tool_options.clone_sample, display_key, overlay.invert, overlay.blend, app.ui.view.pattern_preview ); if app.clone_preview.as_ref().is_none_or(|c| c.key != key) { - let Ok(mut buf) = photocraft_engine::retouch_cmds::clone_preview(&app.session, rect, &map, &app.ui.tool_options.clone_sample) else { return }; + let Ok(mut buf) = + photocraft_engine::retouch_cmds::clone_preview_periodic(&app.session, rect, &map, &app.ui.tool_options.clone_sample, app.ui.view.pattern_preview) + else { + return; + }; if overlay.invert { for px in &mut buf.px { for c in px.iter_mut().take(3) { @@ -66,7 +70,9 @@ fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &Vie // composite, whatever Sample reads), so Difference turns black where they line up. if overlay.blend != "normal" { let here = Mapping { source: (0.0, 0.0), anchor: (0.0, 0.0), m: [1.0, 0.0, 0.0, 1.0] }; - let Ok(under) = photocraft_engine::retouch_cmds::clone_preview(&app.session, rect, &here, "all") else { return }; + let Ok(under) = photocraft_engine::retouch_cmds::clone_preview_periodic(&app.session, rect, &here, "all", app.ui.view.pattern_preview) else { + return; + }; for (px, d) in buf.px.iter_mut().zip(&under.px) { *px = overlay_blend(&overlay.blend, *px, *d); } @@ -6419,6 +6425,16 @@ mod tests { assert!(mesh.vertices.iter().all(|v| v.pos.distance(center) <= 10.01)); assert_eq!(app.session.active().unwrap().revision, revision); assert!(app.session.presets.clone.active().anchor.is_none()); + let ordinary_key = app.clone_preview.as_ref().unwrap().key.clone(); + app.ui.view.pattern_preview = true; + let mut periodic_output = ctx.run_ui(Default::default(), |ui| { + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("clone-test"))); + draw_clone_preview(&mut app, &painter, &xf, [32.0, 32.0], None); + }); + assert_ne!(app.clone_preview.as_ref().unwrap().key, ordinary_key, "toggling Pattern Preview invalidates source pixels"); + assert!(!periodic_output.textures_delta.set.is_empty(), "the overlay texture is refreshed without a document edit"); + periodic_output.textures_delta.clear(); + assert_eq!(app.session.active().unwrap().revision, revision); app.session.presets.clone.overlay.show = false; let mut output = ctx.run_ui(Default::default(), |ui| { let ctx = ui.ctx(); diff --git a/crates/ui-egui/src/retouch_ui.rs b/crates/ui-egui/src/retouch_ui.rs index 48beefbaaaf..adf3c8cf697 100644 --- a/crates/ui-egui/src/retouch_ui.rs +++ b/crates/ui-egui/src/retouch_ui.rs @@ -29,6 +29,7 @@ pub(crate) fn clone_params(app: &PhotocraftApp) -> Option { let o = &app.ui.tool_options; let mut p = json!({"aligned": o.clone_aligned, "sampleLayer": o.clone_sample}); if app.ui.tool == Tool::CloneStamp { + p["wrap"] = json!(app.ui.view.pattern_preview); p["opacity"] = json!(app.session.tools.brush.opacity * 100.0); p["flow"] = json!(app.session.tools.brush.flow * 100.0); } @@ -693,6 +694,30 @@ mod tests { } } + #[test] + fn clone_stamp_in_pattern_preview_wraps_a_dab_across_the_right_edge() { + for pattern_preview in [false, true] { + let mut app = app(); + app.run("paint.pencil", json!({"points": [[10, 10]], "size": 24, "color": "#ff0000"})).unwrap(); + app.ui.tool = Tool::CloneStamp; + app.ui.clone_source = Some([10.0, 10.0]); + app.ui.view.pattern_preview = pattern_preview; + let modifiers = egui::Modifiers::NONE; + tool_event(&mut app, ToolEvent::Down { x: 98.0, y: 30.0, pressure: 1.0 }, modifiers); + tool_event(&mut app, ToolEvent::Up { x: 98.0, y: 30.0 }, modifiers); + assert!(!app.ui.status_error, "{}", app.ui.status); + let surface = active(&app).surface().unwrap(); + let center = surface.rgba(98, 30); + assert!(center[0] > 0.9 && center[1] < 0.1, "clone center: {center:?}"); + let opposite = surface.rgba(1, 30); + if pattern_preview { + assert!(opposite[0] > 0.9 && opposite[1] < 0.1, "Pattern Preview must wrap the clone footprint: {opposite:?}"); + } else { + assert!(opposite[0] > 0.9 && opposite[1] > 0.9, "ordinary cloning must not wrap: {opposite:?}"); + } + } + } + #[test] fn alt_flips_dodge_burn_and_blur_sharpen() { for (tool, flipped) in [(Tool::Dodge, Tool::Burn), (Tool::Burn, Tool::Dodge), (Tool::Blur, Tool::Sharpen), (Tool::Sharpen, Tool::Blur)] { From 9a4d925b342aa87f93b3029e7c2be414db321485 Mon Sep 17 00:00:00 2001 From: siraxe Date: Tue, 13 Oct 2026 16:48:41 +0000 Subject: [PATCH 29/47] Brush: remember the last selected brush across restarts (split from #2516) (#3472) * Brush: remember the last selected brush across restarts (split from #2516) The persistence half of #2516 on its own, so it can merge and be maintained apart from the brush-outline half. The tool's brush (settings, tip references, and the name of the preset it was picked from) is saved once the pointer is up and restored at launch: options bar, painting cursor and Brushes panel highlight all start where the session left off. Review fix from #2516 -- no per-frame work: - `ToolState::brush_rev` is bumped by every brush/preset setter (`tools.setBrush`, preset save/delete/rename/setCurrent/deleteGroup, `brush.defineFromSelection`, `brush.presets.importAbr`, the store's tip re-linking, and the shell's tool-switch/reset paths); `remember_brush` serialises only when it moved since `brush_saved_rev`, so an unchanged brush costs a counter compare per frame, not a clone and a JSON dump of the whole brush (sampled tip previews included). - Restoring counts as saved: the launch value is never rewritten. Bitmaps stay out of the preferences document: remembered tips and patterns are references (a loaded stored tile drops its full bitmap), and a sampled bitmap too big to embed is skipped, keeping the previous value so a preview never enters the prefs file. A malformed remembered brush falls back to the default. docs/scorecard.md regenerated with `cargo xtask scorecard` (34 of 154). Tests: engine 1267 lib (5 new: revision gating, rejected changes, picked preset, restore round trip, no embedded bitmaps), ui-egui 1982 lib; clippy, layers, wasm green. * rustfmt the StoredTile line and regenerate the scorecard after the main merge * engine tests: destructive Levels keep the signed float overshoot at 32 bits (#2592) * Scorecard: regenerate after merging upstream/main * Export As: compare batch paths without the Windows separator * font fix ? * fix(ui): only Filter menu dialogs get the in-dialog preview window #3877 drew the preview window in every schema dialog with a Preview checkbox, including Image > Rotation > Arbitrary. Photoshop's Rotate Canvas and adjustment dialogs have no preview window, and the taller dialog covered the canvas, which broke arbitrary_rotation_preview::size_changing_rotation_preview_is_visible_and_preserves_the_panned_center on main. The window and its reserved height now apply to filter.* commands only. Co-Authored-By: Claude Opus 5.5 * cargo fmt --check max_width = 160 fix --------- Co-authored-by: JJ Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- apps/photocraft-cli/src/lib.rs | 10 ++-- crates/engine/src/adjust_params.rs | 3 +- crates/engine/src/brush_cmds.rs | 71 ++++++++++++++++++++++- crates/engine/src/brush_cmds/tests.rs | 76 +++++++++++++++++++++++++ crates/engine/src/brush_preset_cmds.rs | 2 + crates/engine/src/lib.rs | 16 ++++++ crates/engine/src/prefs.rs | 4 ++ crates/engine/src/preset_import_cmds.rs | 1 + crates/engine/src/preset_store.rs | 18 +++++- crates/ui-egui/src/file_dialog/tests.rs | 2 +- crates/ui-egui/src/last_brush.rs | 21 +++++++ crates/ui-egui/src/lib.rs | 5 ++ crates/ui-egui/src/paint_mouse.rs | 1 + crates/ui-egui/src/prefs_ui.rs | 1 + crates/ui-egui/src/tool_reset.rs | 1 + docs/scorecard.md | 4 +- 16 files changed, 224 insertions(+), 12 deletions(-) create mode 100644 crates/ui-egui/src/last_brush.rs diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index 87050381a5e..a81cce61cec 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -298,13 +298,14 @@ fn command_list(a: &Args) -> Result, String> { fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { let opts = export_opts(a)?; let mut h = Headless::trusted_local(); - // Each missing-font warning is printed once, wherever it is first detected (open, a command or - // the save). + // Opening, running commands and exporting can all notice the same missing font: + // one `run` invocation reports each warning line at most once. let mut reported_fonts = std::collections::HashSet::::new(); match (a.positional.as_slice(), a.get("--new")) { ([file], None) => { let o = h.open(Path::new(file)).map_err(|e| e.to_string())?; let ws: Vec = serde_json::from_value(o["warnings"].clone()).unwrap_or_default(); + reported_fonts.extend(ws.iter().cloned()); warn_all(err, &ws); reported_fonts.extend(ws); } @@ -350,9 +351,8 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { warn_all(err, &remaining); if let Some(o) = a.get("--out") { let r = h.save(None, Some(Path::new(o)), a.get("--format"), &opts).map_err(|e| e.to_string())?; - // The save repeats the document's missing-font warnings; each was already reported above. - let ws: Vec = - serde_json::from_value::>(r["warnings"].clone()).unwrap_or_default().into_iter().filter(|w| !reported_fonts.contains(w)).collect(); + let ws: Vec = serde_json::from_value(r["warnings"].clone()).unwrap_or_default(); + let ws: Vec = ws.into_iter().filter(|w| reported_fonts.insert(w.clone())).collect(); warn_all(err, &ws); } Ok(()) diff --git a/crates/engine/src/adjust_params.rs b/crates/engine/src/adjust_params.rs index aafdb91616a..69fc2d76f31 100644 --- a/crates/engine/src/adjust_params.rs +++ b/crates/engine/src/adjust_params.rs @@ -1638,7 +1638,8 @@ mod tests { #[test] fn destructive_levels_dont_clip_in_32_bit() { // Input 15..230, gamma 1.3, output 10..245: integer documents stop at the output range, - // while float documents preserve the curve's signed and HDR values beyond it. + // while float documents preserve the curve's signed and HDR values beyond it (the + // compose-side `levels_dont_clip_in_32_bit` pins the same curve, #2592). let params = json!({"inBlack": 15, "inWhite": 230, "gamma": 1.3, "outBlack": 10, "outWhite": 245}); for depth in [8, 16, 32] { let mut s = crate::Session::new(); diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index 1fd186f9316..370b6fcc788 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -10,7 +10,10 @@ use photocraft_doc::LayerContent; use photocraft_geom::Rect; use photocraft_paint::mixer::{MixerSettings, apply_mixer_stroke}; use photocraft_paint::replace::{Limits, ReplaceMode, ReplaceSettings, Sampling, apply_color_replacement, color_replacement_effect}; -use photocraft_paint::{BrushPreset, BrushSettings, GrayTile, PresetCaptures, Stroke, StrokePoint, StrokeRenderer, TipShape, render_stroke}; +use photocraft_paint::tile::PREVIEW_SIDE; +use photocraft_paint::{ + BrushPreset, BrushSettings, GrayTile, Pattern, PresetCaptures, StoredTile, Stroke, StrokePoint, StrokeRenderer, TipShape, render_stroke, +}; use photocraft_raster::Surface; use serde_json::{Value, json}; @@ -773,6 +776,7 @@ fn presets_save(s: &mut Session, p: &Value) -> Result { upsert(s, BrushPreset { name: name.clone(), brush, builtin: false, group: String::new(), folder: Vec::new(), captures }); // The saved brush is the current brush now (Photoshop selects the brush it just saved). s.tools.current_preset = Some(name.clone()); + s.tools.brush_changed(); Ok(json!({ "name": name, "count": s.tools.presets.len() })) } @@ -786,6 +790,8 @@ fn presets_delete(s: &mut Session, p: &Value) -> Result { } if s.tools.current_preset.as_ref().is_some_and(|c| c.eq_ignore_ascii_case(&name)) { s.tools.current_preset = None; + // The remembered brush names the preset it was picked from: the name went away. + s.tools.brush_changed(); } s.brush_presets_changed(); Ok(json!({ "count": s.tools.presets.len() })) @@ -823,6 +829,7 @@ fn presets_set_current(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, format!("no brush preset named `{name}`"))); } s.tools.current_preset = Some(name.clone()); + s.tools.brush_changed(); Ok(json!({ "name": name })) } @@ -889,6 +896,7 @@ fn define_from_selection(s: &mut Session, p: &Value) -> Result { upsert(s, BrushPreset { name: name.clone(), brush: brush.clone(), builtin: false, group: String::new(), folder: Vec::new(), captures: Default::default() }); s.tools.brush = brush; s.tools.current_preset = Some(name.clone()); + s.tools.brush_changed(); Ok(json!({ "name": name, "width": tw, "height": th })) } @@ -948,9 +956,70 @@ pub(crate) fn set_brush(s: &mut Session, p: &Value) -> Result { Some(k) => s.tools.brush_gesture = Some((k.to_string(), before)), None => s.tools.brush_gesture = None, } + // The one accepted brush change everyone (UI sliders, control channel, presets) lands here. + s.tools.brush_changed(); Ok(brush_json(&s.tools.brush)) } +/// Remember the current brush in `Preferences::last_brush` (see [`remembered_brush`]). Called +/// every frame while the pointer is up, so it is change-driven: `ToolState::brush_rev` is compared +/// with the revision already saved and an unchanged brush clones and serialises nothing. +pub fn remember_brush(s: &mut Session) { + if s.tools.brush_rev == s.tools.brush_saved_rev { + return; + } + let Some(now) = remembered_brush(s) else { return }; + if s.prefs().last_brush != now { + s.prefs.edit(|p| p.last_brush = now); + } + s.tools.brush_saved_rev = s.tools.brush_rev; +} + +/// The current brush as JSON for `Preferences::last_brush`: settings plus tip references, no full +/// bitmap. `None` while a sampled bitmap too big to embed is current (only before the store +/// externalizes it; see [`ref_tip`]), so the previous remembered brush stands. +fn remembered_brush(s: &Session) -> Option { + let b = &s.tools.brush; + let (tip, dual, pattern) = (ref_tip(&b.tip)?, ref_tip(&b.dual_brush.tip)?, ref_pattern(&b.texture.pattern)?); + let mut c = b.clone(); + (c.tip, c.dual_brush.tip, c.texture.pattern) = (tip, dual, pattern); + Some(json!({ "preset": s.tools.current_preset.clone(), "brush": serde_json::to_value(&c).ok()? })) +} + +/// Restore `Preferences::last_brush` into the session at launch (a missing or malformed value +/// keeps the default brush). Stored tips load when the preset store attaches. +pub fn restore_brush(s: &mut Session) { + let v = s.prefs().last_brush.clone(); + let Some(patch) = v.get("brush").filter(|b| b.is_object()) else { return }; + let Ok(mut b) = merge_brush(&BrushSettings::default(), patch, "brush.restore") else { return }; + if validate_brush(&b, "brush.restore").is_err() { + return; + } + let _ = s.load_brush_tips(&mut b); + s.tools.brush = b; + s.tools.current_preset = v.get("preset").and_then(Value::as_str).map(str::to_string); + // What was just restored is exactly what the preferences hold: nothing new to write. + s.tools.brush_saved_rev = s.tools.brush_rev; +} + +/// A tip for remembering: a loaded stored tip drops its full bitmap (it serialises as a reference +/// then); a sampled bitmap bigger than a preview would enter the prefs document whole. +fn ref_tip(t: &TipShape) -> Option { + match t { + TipShape::Stored(r) if r.full.is_some() => Some(TipShape::Stored(StoredTile { full: None, ..r.clone() })), + TipShape::Sampled(g) if g.width > PREVIEW_SIDE || g.height > PREVIEW_SIDE => None, + t => Some(t.clone()), + } +} + +fn ref_pattern(p: &Pattern) -> Option { + match p { + Pattern::Stored(r) if r.full.is_some() => Some(Pattern::Stored(StoredTile { full: None, ..r.clone() })), + Pattern::Tile(g) if g.width > PREVIEW_SIDE || g.height > PREVIEW_SIDE => None, + p => Some(p.clone()), + } +} + /// The brush as JSON with the bitmaps (sampled tips, pattern tiles) that `skip` names replaced by /// placeholders: they can be megabytes of base64, and skipped ones are equal on both sides. fn light_json(b: &BrushSettings, skip: (bool, bool, bool)) -> Value { diff --git a/crates/engine/src/brush_cmds/tests.rs b/crates/engine/src/brush_cmds/tests.rs index 1d44fe6e7d3..d91e9ec4d7b 100644 --- a/crates/engine/src/brush_cmds/tests.rs +++ b/crates/engine/src/brush_cmds/tests.rs @@ -910,6 +910,82 @@ fn an_offer_that_does_not_match_the_commit_is_ignored() { assert!(r.get("fromLive").is_none()); } +#[test] +fn last_brush_is_remembered_when_the_revision_moves_only() { + let mut s = session(8, 8); + restore_brush(&mut s); + // A restore is already in the preferences: nothing to write, no clone or serialise. + s.prefs.edit(|p| p.last_brush = json!("sentinel")); + remember_brush(&mut s); + assert_eq!(s.prefs().last_brush, json!("sentinel"), "an unchanged brush must not serialise"); + s.execute("tools.setBrush", json!({"size": 23})).unwrap(); + assert!(s.tools.brush_rev > s.tools.brush_saved_rev); + remember_brush(&mut s); + assert_eq!(s.prefs().last_brush["brush"]["size"].as_f64(), Some(23.0)); + assert_eq!(s.tools.brush_saved_rev, s.tools.brush_rev, "the revision is marked saved"); + // Revision unchanged: even a hand-written value stays (the gate is a counter compare). + s.prefs.edit(|p| p.last_brush = json!("sentinel")); + remember_brush(&mut s); + assert_eq!(s.prefs().last_brush, json!("sentinel")); +} + +#[test] +fn rejected_brush_changes_do_not_move_the_revision() { + let mut s = session(8, 8); + let rev = s.tools.brush_rev; + assert!(s.execute("tools.setBrush", json!({"size": "big"})).is_err()); + assert_eq!(s.tools.brush_rev, rev, "a rejected brush leaves no change behind"); + s.execute("tools.setBrush", json!({"size": 31})).unwrap(); + assert!(s.tools.brush_rev > rev); +} + +#[test] +fn the_revision_tracks_the_picked_preset_too() { + let mut s = session(8, 8); + s.execute("tools.setBrush", json!({"preset": "Hard Round"})).unwrap(); + let rev = s.tools.brush_rev; + s.execute("brush.presets.setCurrent", json!({"name": "Soft Round"})).unwrap(); + assert!(s.tools.brush_rev > rev, "the remembered preset name follows the picked preset"); + remember_brush(&mut s); + assert_eq!(s.prefs().last_brush["preset"].as_str(), Some("Soft Round")); +} + +#[test] +fn the_remembered_brush_restores_at_launch() { + let mut s = session(8, 8); + s.execute("tools.setBrush", json!({"preset": "Hard Round", "size": 44, "hardness": 0.3})).unwrap(); + remember_brush(&mut s); + let stored = s.prefs().last_brush.clone(); + assert_eq!(stored["preset"].as_str(), Some("Hard Round")); + let mut t = Session::new(); + assert_ne!(t.tools.brush.size, 44.0, "a fresh session starts on the default brush"); + t.prefs.edit(|p| p.last_brush = stored); + restore_brush(&mut t); + assert_eq!(t.tools.brush.size, 44.0); + assert_eq!(t.tools.brush.hardness, 0.3); + assert_eq!(t.tools.current_preset.as_deref(), Some("Hard Round")); + assert_eq!(t.tools.brush_saved_rev, t.tools.brush_rev, "the restored value is already stored"); + // A malformed remembered brush keeps the default (never an error, never a panic). + let mut bad = Session::new(); + bad.prefs.edit(|p| p.last_brush = json!({"brush": {"size": "huge"}})); + restore_brush(&mut bad); + assert_eq!(bad.tools.brush.size, Session::new().tools.brush.size); +} + +#[test] +fn remembered_brushes_never_embed_large_bitmaps() { + use std::sync::Arc; + let big = GrayTile::from_fn(PREVIEW_SIDE + 1, 8, |_, _| 1.0); + assert!(ref_tip(&TipShape::Sampled(big)).is_none(), "a big sampled tip cannot be embedded"); + let small = GrayTile::from_fn(8, 8, |_, _| 1.0); + assert!(ref_tip(&TipShape::Sampled(small.clone())).is_some(), "small tiles embed"); + let stored = + StoredTile { key: "k".into(), width: 300, height: 200, preview: Arc::new(small), full: Some(Arc::new(GrayTile::from_fn(300, 200, |_, _| 1.0))) }; + let t = ref_tip(&TipShape::Stored(stored)).expect("stored tips are remembered as references"); + assert!(matches!(t, TipShape::Stored(r) if r.full.is_none()), "a loaded full bitmap is dropped"); + assert!(ref_pattern(&Pattern::Tile(GrayTile::from_fn(PREVIEW_SIDE + 1, 4, |_, _| 1.0))).is_none()); +} + /// Photoshop's Clear painting mode (#3439): a Brush or Pencil stroke in `"mode": "clear"` erases /// with that brush, and History still names the tool rather than the Eraser. #[test] diff --git a/crates/engine/src/brush_preset_cmds.rs b/crates/engine/src/brush_preset_cmds.rs index 49ee1b06308..46331126ac2 100644 --- a/crates/engine/src/brush_preset_cmds.rs +++ b/crates/engine/src/brush_preset_cmds.rs @@ -119,6 +119,7 @@ fn rename(s: &mut Session, p: &Value) -> Result { pr.builtin = false; if was_current { s.tools.current_preset = Some(new.clone()); + s.tools.brush_changed(); } s.brush_presets_changed(); } @@ -433,6 +434,7 @@ fn delete_group(s: &mut Session, p: &Value) -> Result { s.tools.presets.retain(|x| !in_folder(x, &g, &f)); if s.tools.current_preset.as_ref().is_some_and(|c| !s.tools.presets.iter().any(|x| x.name.eq_ignore_ascii_case(c))) { s.tools.current_preset = None; + s.tools.brush_changed(); } s.brush_presets_changed(); Ok(json!({ "deleted": before - s.tools.presets.len(), "count": s.tools.presets.len() })) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index cf4ff75b402..fde2b6ab610 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -299,6 +299,13 @@ pub struct ToolState { /// preset stays "the current brush" so `brush.presets.update` can overwrite it); cleared when /// the brush is reset or the preset is gone. pub current_preset: Option, + /// Bumped whenever `brush` or `current_preset` changes ([`ToolState::brush_changed`]). The + /// last-brush persistence watches it instead of comparing the brush itself, so an unchanged + /// brush costs a counter compare per frame, not a clone and serialise. + pub brush_rev: u64, + /// The revision whose value is in `Preferences::last_brush` (a restored one counts as saved): + /// [`brush_rev`](Self::brush_rev) == `brush_saved_rev` means there is nothing new to write. + pub brush_saved_rev: u64, /// A layer mask is the edit target ([`ToolState::target_mask`]). pub mask_targeted: bool, /// The foreground/background pair of the edit target that isn't current: the layer pixels' @@ -333,6 +340,13 @@ impl ToolState { /// background (reveal-paint first). pub const MASK_COLORS: [[f32; 4]; 2] = [[1.0, 1.0, 1.0, 1.0], [0.0, 0.0, 0.0, 1.0]]; + /// Note that `brush` or `current_preset` changed. Every setter of either (commands and the + /// shell's tool-switch paths) calls this; the last-brush persistence only serialises when the + /// revision has moved since the stored value ([`Self::brush_saved_rev`]). + pub fn brush_changed(&mut self) { + self.brush_rev = self.brush_rev.wrapping_add(1); + } + /// Switch the edit target between layer pixels and a layer mask. Like Photoshop, the mask and /// the pixels keep separate app-wide foreground/background pairs: changing the target puts /// the current pair aside and brings back the other one, so colours picked while a mask is @@ -365,6 +379,8 @@ impl Default for ToolState { mixer: Default::default(), brush_gesture: None, current_preset: None, + brush_rev: 0, + brush_saved_rev: 0, mask_targeted: false, other_colors: Self::MASK_COLORS, cmyk_picks: Vec::new(), diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 1dc82005db1..5e728cb5461 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -1007,6 +1007,10 @@ pub struct Preferences { /// the footer slider's card scale, saved as the user changes them and restored at launch. /// JSON owned by the shell. pub brush_picker: Value, + /// The brush the tool was last using (settings and tip references, never the full bitmaps; + /// shaped by `brush_cmds::remembered_brush`), restored at launch so the tool starts where it + /// was left. Written only when `ToolState::brush_rev` moves. + pub last_brush: Value, /// The tools' options-bar settings (Move › Auto-Select, marquee Feather, tolerance, …) that /// differ from their defaults, saved as the user changes them and restored at launch, as /// Photoshop remembers each tool's options between sessions. JSON owned by the shell. diff --git a/crates/engine/src/preset_import_cmds.rs b/crates/engine/src/preset_import_cmds.rs index 37b375cea29..e6bff8eb6b0 100644 --- a/crates/engine/src/preset_import_cmds.rs +++ b/crates/engine/src/preset_import_cmds.rs @@ -106,6 +106,7 @@ fn add_abr_presets(s: &mut Session, p: &Value, group: String, imp: photocraft_io let mut b = pr.brush.clone().picked_over(&s.tools.brush); s.load_brush_tips(&mut b).map_err(|e| bad("brush.presets.importAbr", e))?; s.tools.brush = b; + s.tools.brush_changed(); } // Distinct folder paths the file brought (its Photoshop folders, nested inside `group`). let folders: std::collections::BTreeSet<&[String]> = diff --git a/crates/engine/src/preset_store.rs b/crates/engine/src/preset_store.rs index 519f2cc609a..83cb16a838d 100644 --- a/crates/engine/src/preset_store.rs +++ b/crates/engine/src/preset_store.rs @@ -1125,6 +1125,11 @@ impl Session { } self.brush_presets_changed(); self.sync_preset_store(); + // A brush remembered from the previous run (or picked before the store loaded) may be + // painting with previews; load the full tips now (a missing one keeps its preview). + let mut brush = std::mem::take(&mut self.tools.brush); + let _ = self.load_brush_tips(&mut brush); + self.tools.brush = brush; if let Some(st) = self.preset_store.as_mut() { warnings.extend(st.take_warnings()); } @@ -1139,7 +1144,11 @@ impl Session { if st.synced_rev != Some(rev) { st.sync(&mut self.tools.presets); st.synced_rev = Some(rev); - link_stored_tips(st, &mut self.tools.brush); + if link_stored_tips(st, &mut self.tools.brush) { + // An embedded tip became a store reference: what the last-brush + // persistence would write changed without a command touching the brush. + self.tools.brush_changed(); + } } st.sync_actions(&self.actions.list, actions_rev); } @@ -1172,7 +1181,7 @@ impl Session { /// After a sync, an embedded bitmap of the tool's brush that the store now holds (the brush was /// just saved as a preset) becomes the stored tile, keeping its bitmap: the brush then matches its /// preset (Brushes panel highlight) like a preset picked from the library. -fn link_stored_tips(st: &PresetStore, b: &mut BrushSettings) { +fn link_stored_tips(st: &PresetStore, b: &mut BrushSettings) -> bool { let link = |g: &GrayTile| -> Option { if !g.is_valid() { return None; @@ -1180,24 +1189,29 @@ fn link_stored_tips(st: &PresetStore, b: &mut BrushSettings) { let h = tip_hash(g); st.tips.contains_key(&h).then(|| StoredTile { key: h, width: g.width, height: g.height, preview: Arc::new(g.preview(PREVIEW_SIDE)), full: None }) }; + let mut linked = false; if let TipShape::Sampled(g) = &b.tip && let Some(r) = link(g) && let TipShape::Sampled(g) = std::mem::take(&mut b.tip) { b.tip = TipShape::Stored(StoredTile { full: Some(Arc::new(g)), ..r }); + linked = true; } if let TipShape::Sampled(g) = &b.dual_brush.tip && let Some(r) = link(g) && let TipShape::Sampled(g) = std::mem::take(&mut b.dual_brush.tip) { b.dual_brush.tip = TipShape::Stored(StoredTile { full: Some(Arc::new(g)), ..r }); + linked = true; } if let Pattern::Tile(g) = &b.texture.pattern && let Some(r) = link(g) && let Pattern::Tile(g) = std::mem::take(&mut b.texture.pattern) { b.texture.pattern = Pattern::Stored(StoredTile { full: Some(Arc::new(g)), ..r }); + linked = true; } + linked } #[cfg(test)] diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index c3f41b487d5..5f7dfc5c981 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -739,7 +739,7 @@ fn export_as_with_several_artboards_selected_writes_one_file_each() { assert!(!app.ui.status_error, "{}", app.ui.status); assert_eq!(written.borrow().len(), 2); assert_eq!(written.borrow()[0], "/out/boards.jpg"); - // The sibling path is joined with the platform separator (`/out\boards-2-…` on Windows). + // The sibling path is built with `Path::join`, so Windows turns the separator into `\`. let second = written.borrow()[1].replace('\\', "/"); assert!(second.starts_with("/out/boards-2-") && second.ends_with(".jpg"), "{:?}", written.borrow()); let mut got = sizes.borrow().iter().map(|(_, w, h)| (*w, *h)).collect::>(); diff --git a/crates/ui-egui/src/last_brush.rs b/crates/ui-egui/src/last_brush.rs new file mode 100644 index 00000000000..45c101a7ae3 --- /dev/null +++ b/crates/ui-egui/src/last_brush.rs @@ -0,0 +1,21 @@ +//! The brush the tool was last using, remembered across restarts (`Preferences::last_brush`) so +//! the next launch starts with the same brush, options bar and painting cursor outline. +//! +//! Saved once the pointer is up (a slider drag saves once, like the Brush Preset picker's view), +//! and change-driven: `remember_brush` watches `ToolState::brush_rev`, so an unchanged brush +//! costs a counter compare per frame, not a clone and a JSON serialisation of the whole brush. + +use crate::PhotocraftApp; + +/// Remember the current brush once the pointer is up. +pub fn persist(app: &mut PhotocraftApp, ctx: &egui::Context) { + if !ctx.input(|i| i.pointer.any_down()) { + photocraft_engine::brush_cmds::remember_brush(&mut app.session); + } +} + +/// Restore the remembered brush at launch; stored tips load when the preset store attaches +/// (`Session::attach_preset_store`). +pub fn restore(app: &mut PhotocraftApp) { + photocraft_engine::brush_cmds::restore_brush(&mut app.session); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d3f1a144011..d0ebc43472a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -105,6 +105,7 @@ pub mod icons; pub mod jobs_ui; pub mod kys_import; pub mod lasso_ui; +pub(crate) mod last_brush; pub mod layer_menu_ui; pub mod layer_pick_ui; pub mod layer_picker; @@ -1861,6 +1862,10 @@ impl eframe::App for PhotocraftApp { // remembered once the pointer is up. Here, after every panel, rather than in the // picker's own code: the control channel can set it while the picker is closed. brush_picker::persist(self, &ctx); + // The tool's brush (settings and tip references) is remembered the same way. Like the + // picker's view, in the frame loop, so the control channel can set it with the panels + // closed; the engine's revision counter keeps an unchanged brush free. + last_brush::persist(self, &ctx); // Tool options are remembered between launches the same way (#2814). tool_memory::persist(self, &ctx); // So are View › Extras, Show and Snap To (#3762). diff --git a/crates/ui-egui/src/paint_mouse.rs b/crates/ui-egui/src/paint_mouse.rs index c49055e13af..a45c016c596 100644 --- a/crates/ui-egui/src/paint_mouse.rs +++ b/crates/ui-egui/src/paint_mouse.rs @@ -37,6 +37,7 @@ pub fn sync_tool_brush(app: &mut PhotocraftApp) { // The picked preset was the outgoing tool's brush: forget it, so Update Brush can't overwrite // that preset with this tool's brush. app.session.tools.current_preset = None; + app.session.tools.brush_changed(); app.ui.brush_tool = tool; } diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 997bc3be36f..cd7b3af0d05 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -220,6 +220,7 @@ pub fn load(app: &mut PhotocraftApp) { } crate::dock::restore(app); crate::brush_picker::restore(app); + crate::last_brush::restore(app); sync_rulers(app); crate::tool_memory::restore(app); crate::view_cmds::restore(app); diff --git a/crates/ui-egui/src/tool_reset.rs b/crates/ui-egui/src/tool_reset.rs index 9c97cd640fd..8de3fe4ec79 100644 --- a/crates/ui-egui/src/tool_reset.rs +++ b/crates/ui-egui/src/tool_reset.rs @@ -92,6 +92,7 @@ fn reset_session_brush(app: &mut PhotocraftApp) { app.session.tools.brush = crate::paint_mouse::fresh_brush(&app.session.tools.brush); app.session.tools.brush_gesture = None; app.session.tools.current_preset = None; + app.session.tools.brush_changed(); } fn is_selection_tool(tool: Tool) -> bool { diff --git a/docs/scorecard.md b/docs/scorecard.md index af13a3f2657..078ce21b2a1 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 7 | 13 | 7 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 145/256, round trip ≥ 258/256 | | [Tools](#tools) | 28 | 14 | 1 | | -| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 156 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 157 (target 0) | | [Type](#type) | 4 | 3 | 2 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | @@ -176,7 +176,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t ## Workspace and UI -**Settings that do nothing: 26 of 156** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 26 of 157** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. From 74cb2935704ab3dc165827bcec31aa7e24d1ab00 Mon Sep 17 00:00:00 2001 From: Ian Swope Date: Tue, 13 Oct 2026 11:49:58 -0500 Subject: [PATCH 30/47] Large document: full CPU canvas redraws run in the background, visible part first (#4024) (#5001) When the wgpu compositor can't draw a refresh (layers over its memory budget, or a feature it doesn't cover), the CPU compositor redrew the whole document inside the draw call: minutes of Not Responding on a multi-gigabyte PSB. With background jobs, a refresh of 4 MP or more is now composited by a worker in 1024 px blocks, the visible ones first; each frame uploads the finished blocks and the canvas keeps its previous contents until they land. An edit while it runs draws its own damage and restarts the worker on the edited document with the blocks still to draw. The status bar shows Redrawing N%. Web and tests keep the synchronous path. --- crates/ui-egui/src/background_redraw.rs | 252 ++++++++++++++++++ crates/ui-egui/src/background_redraw_tests.rs | 165 ++++++++++++ crates/ui-egui/src/canvas.rs | 26 +- crates/ui-egui/src/gpu_canvas.rs | 73 +++-- crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sr.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/panels.rs | 6 +- 26 files changed, 526 insertions(+), 20 deletions(-) create mode 100644 crates/ui-egui/src/background_redraw.rs create mode 100644 crates/ui-egui/src/background_redraw_tests.rs diff --git a/crates/ui-egui/src/background_redraw.rs b/crates/ui-egui/src/background_redraw.rs new file mode 100644 index 00000000000..e8196bc1c2f --- /dev/null +++ b/crates/ui-egui/src/background_redraw.rs @@ -0,0 +1,252 @@ +//! Full CPU redraws of the GPU canvas off the UI thread (#4024). When the wgpu compositor can't +//! draw a full refresh (the layers exceed its memory budget, or the document uses something it +//! doesn't cover), the CPU compositor redraws the whole document: minutes on a multi-gigabyte +//! PSB. With background jobs (the desktop app) a worker composites it in blocks instead, the +//! visible ones first, and each frame uploads the blocks that are done, so the window keeps +//! drawing and answering while the rest of the canvas fills in. Until a block lands the texture +//! keeps what it showed before. +//! +//! An edit while it runs restarts the worker on the new document with the blocks still to draw; +//! the edit's own damage is drawn by the canvas as usual. A full change (a new preview, a new +//! display transform) starts over. Without background jobs (web, tests) the canvas keeps the +//! synchronous [`GpuCanvas::upload_composite`]. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{Receiver, SyncSender, TryRecvError}; + +use photocraft_doc::{DocId, Document}; +use photocraft_engine::display_color::CanvasDisplay; +use photocraft_geom::Rect; +#[cfg(not(target_arch = "wasm32"))] +use rayon::prelude::*; + +use crate::PhotocraftApp; +use crate::gpu_canvas::GpuCanvas; + +/// Side of a block in document pixels: whole render tiles, and a power of two so each block's +/// mip levels don't depend on its neighbours. +const BLOCK: i32 = 1024; + +/// Blocks composited at once (in parallel, on top of each block's own tiles), and how many +/// finished blocks may wait for the UI thread: bounds the memory a redraw holds. +const BATCH: usize = 4; + +/// One finished block, as texels of the canvas texture. +struct Piece { + block: usize, + texels: Vec, +} + +/// A redraw in progress for one document's GPU texture. +pub(crate) struct Redraw { + blocks: Vec, + pending: Vec, + left: usize, + /// Pixels uploaded so far. + drawn: u64, + rx: Receiver, + stop: Arc, + started_ms: f64, + fallback: Option, +} + +impl Redraw { + /// Share of the document drawn, 0..=1. + pub fn fraction(&self) -> f32 { + if self.blocks.is_empty() { 1.0 } else { 1.0 - self.left as f32 / self.blocks.len() as f32 } + } +} + +impl Drop for Redraw { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + } +} + +/// Whether full CPU refreshes run in the background here. +pub(crate) fn enabled(app: &PhotocraftApp) -> bool { + cfg!(not(target_arch = "wasm32")) && app.background_jobs +} + +/// `bounds` cut into [`BLOCK`]-sized blocks, row by row. +fn blocks(bounds: Rect) -> Vec { + let mut out = Vec::new(); + let mut y = bounds.y0; + while y < bounds.y1 { + let mut x = bounds.x0; + while x < bounds.x1 { + out.push(Rect::new(x, y, (x + BLOCK).min(bounds.x1), (y + BLOCK).min(bounds.y1))); + x += BLOCK; + } + y += BLOCK; + } + out +} + +/// The order to draw `wanted` blocks in: those `visible` shows first, then the rest, each group +/// nearest the view's centre first. +fn order(blocks: &[Rect], wanted: impl Iterator, visible: Rect) -> Vec { + let (cx, cy) = ((visible.x0 as i64 + visible.x1 as i64) / 2, (visible.y0 as i64 + visible.y1 as i64) / 2); + let mut out: Vec = wanted.collect(); + out.sort_by_key(|&i| { + let b = blocks[i]; + let (bx, by) = ((b.x0 as i64 + b.x1 as i64) / 2, (b.y0 as i64 + b.y1 as i64) / 2); + (b.intersect(&visible).is_empty(), (bx - cx).pow(2) + (by - cy).pow(2)) + }); + out +} + +/// Redraw `region` of document `doc` (`doc.id`'s texture, prepared by +/// [`GpuCanvas::refresh_with`]) in the background. A redraw already running for it continues on +/// `doc` with `region`'s blocks added. +#[allow(clippy::too_many_arguments)] +pub(crate) fn start( + app: &mut PhotocraftApp, + ctx: &egui::Context, + gpu: &GpuCanvas, + doc: Arc, + display: Option>, + visible: Rect, + region: Rect, + fallback: Option, +) { + let all = blocks(doc.bounds()); + let (mut pending, since, fallback) = match app.redraws.remove(&doc.id) { + Some(old) if old.blocks == all => (old.pending.clone(), (old.started_ms, old.drawn), fallback.or_else(|| old.fallback.clone())), + _ => (vec![false; all.len()], (crate::gpu_canvas::now_ms(), 0), fallback), + }; + for (p, b) in pending.iter_mut().zip(&all) { + *p |= !b.intersect(®ion).is_empty(); + } + if pending.contains(&true) { + spawn(app, ctx, gpu, doc, display, visible, all, pending, since, fallback); + } +} + +/// After an edit drew its own damage: continue document `doc.id`'s redraw, if one is running, on +/// the edited document, so blocks still to come don't bring back what was there before it. +pub(crate) fn restart(app: &mut PhotocraftApp, ctx: &egui::Context, gpu: &GpuCanvas, doc: Arc, display: Option>, visible: Rect) { + if app.redraws.contains_key(&doc.id) { + start(app, ctx, gpu, doc, display, visible, Rect::EMPTY, None); + } +} + +/// Upload the blocks document `doc`'s redraw has finished since the last frame. When the last one +/// lands, the redraw is recorded like the synchronous refresh it replaces (and explained once if +/// it was slow). +pub(crate) fn poll(app: &mut PhotocraftApp, gpu: &GpuCanvas, doc: DocId) { + let Some(r) = app.redraws.get_mut(&doc) else { return }; + loop { + match r.rx.try_recv() { + Ok(p) => { + let b = r.blocks[p.block]; + if r.pending[p.block] && gpu.upload_rect(doc.0, [b.x0 as u32, b.y0 as u32], [b.width(), b.height()], &p.texels) { + r.pending[p.block] = false; + r.left -= 1; + r.drawn += b.width() as u64 * b.height() as u64; + } + } + Err(TryRecvError::Empty) => return, + Err(TryRecvError::Disconnected) => break, + } + } + let Some(r) = app.redraws.remove(&doc) else { return }; + if r.left > 0 { + log::warn!("canvas redraw stopped with {} of {} blocks left", r.left, r.blocks.len()); + return; + } + let ms = crate::gpu_canvas::now_ms() - r.started_ms; + app.perf.record("background", r.drawn, ms, 0.0); + crate::notices::slow_cpu_refresh(app, doc.0, ms, r.fallback.as_deref()); +} + +/// The active document's redraw in the status bar: "Redrawing" and how much is done. +pub(crate) fn status(app: &PhotocraftApp, ui: &mut egui::Ui) { + let Some(r) = app.session.active().and_then(|st| app.redraws.get(&st.doc.id)) else { return }; + let t = crate::theme::Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(format!("{:.0}%", r.fraction() * 100.0)).color(t.text_dim).size(11.5).monospace()); + ui.label(egui::RichText::new(tl!("Redrawing")).color(t.text_dim).size(12.0)); +} + +/// Stop document `doc`'s redraw (its canvas was redrawn some other way, or is gone). +pub(crate) fn cancel(app: &mut PhotocraftApp, doc: DocId) { + app.redraws.remove(&doc); +} + +/// Stop redraws of documents that are no longer open. +pub(crate) fn retain(app: &mut PhotocraftApp) { + let docs = app.session.documents(); + app.redraws.retain(|id, _| docs.iter().any(|st| st.doc.id == *id)); +} + +#[allow(clippy::too_many_arguments)] +fn spawn( + app: &mut PhotocraftApp, + ctx: &egui::Context, + gpu: &GpuCanvas, + doc: Arc, + display: Option>, + visible: Rect, + blocks: Vec, + pending: Vec, + (started_ms, drawn): (f64, u64), + fallback: Option, +) { + let id = doc.id; + let Some(format) = gpu.prepare_texture(id.0, &doc) else { return }; + let todo = order(&blocks, (0..blocks.len()).filter(|&i| pending[i]), visible); + let left = todo.len(); + let (tx, rx) = std::sync::mpsc::sync_channel(BATCH); + let stop = Arc::new(AtomicBool::new(false)); + let work = (blocks.clone(), stop.clone(), ctx.clone()); + let spawned = std::thread::Builder::new().name("canvas-redraw".into()).spawn(move || { + let (blocks, stop, ctx) = work; + // A panic stops the redraw: the receiver sees the worker gone and drops it. + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| draw(&doc, display.as_deref(), format, &blocks, &todo, &stop, &tx, &ctx))); + ctx.request_repaint(); + }); + if let Err(e) = spawned { + log::warn!("couldn't start the canvas redraw: {e}"); + return; + } + app.redraws.insert(id, Redraw { blocks, pending, left, drawn, rx, stop, started_ms, fallback }); +} + +/// The worker: composite `todo`'s blocks of `doc` in batches and hand each over, until done or +/// stopped. +#[allow(clippy::too_many_arguments)] +fn draw( + doc: &Document, + display: Option<&CanvasDisplay>, + format: eframe::wgpu::TextureFormat, + blocks: &[Rect], + todo: &[usize], + stop: &AtomicBool, + tx: &SyncSender, + ctx: &egui::Context, +) { + for batch in todo.chunks(BATCH) { + if stop.load(Ordering::Relaxed) { + return; + } + // Never reached in the browser ([`enabled`]); it only has to build there, without rayon. + #[cfg(not(target_arch = "wasm32"))] + let batch = batch.par_iter(); + #[cfg(target_arch = "wasm32")] + let batch = batch.iter(); + let pieces: Vec = batch + .map(|&block| Piece { block, texels: crate::gpu_canvas::display_texels(format, display, &photocraft_compose::render(doc, blocks[block])) }) + .collect(); + for p in pieces { + if stop.load(Ordering::Relaxed) || tx.send(p).is_err() { + return; + } + ctx.request_repaint(); + } + } +} + +#[cfg(test)] +#[path = "background_redraw_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/background_redraw_tests.rs b/crates/ui-egui/src/background_redraw_tests.rs new file mode 100644 index 00000000000..e054ffe2a65 --- /dev/null +++ b/crates/ui-egui/src/background_redraw_tests.rs @@ -0,0 +1,165 @@ +//! Background full CPU redraws (#4024). The GPU tests skip when there is no adapter. + +use super::*; +use serde_json::json; + +#[test] +fn blocks_cover_the_document_once() { + let bounds = Rect::new(0, 0, 2500, 1100); + let b = blocks(bounds); + assert_eq!(b.len(), 3 * 2); + assert_eq!(b.iter().map(|r| r.width() as u64 * r.height() as u64).sum::(), 2500 * 1100); + for (i, r) in b.iter().enumerate() { + assert!(r.x0 % BLOCK == 0 && r.y0 % BLOCK == 0, "block {i} starts on the grid: {r:?}"); + assert!(b[i + 1..].iter().all(|o| o.intersect(r).is_empty()), "block {i} overlaps another"); + } +} + +#[test] +fn visible_blocks_come_first_nearest_the_view_centre() { + let b = blocks(Rect::new(0, 0, 4096, 4096)); + // The view shows the right half of the second block row. + let visible = Rect::new(2100, 1100, 4000, 1900); + let o = order(&b, 0..b.len(), visible); + assert_eq!(o.len(), 16); + let shown: Vec = (0..b.len()).filter(|&i| !b[i].intersect(&visible).is_empty()).collect(); + assert_eq!(shown.len(), 2); + assert!(o[..2].iter().all(|i| shown.contains(i)), "{o:?}"); + // Then the block row's neighbours, the far corners last. + assert!(o[14..].contains(&12), "the bottom-left corner is among the last: {o:?}"); + // Only the blocks asked for. + assert_eq!(order(&b, [3, 9].into_iter(), visible).len(), 2); +} + +struct Gpu { + harness: egui_kittest::Harness<'static, PhotocraftApp>, + observer: GpuCanvas, +} + +/// The app on a real device with background jobs, or `None` without a GPU adapter. +fn gpu_app() -> Option { + 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 + }) + })); + let Ok(harness) = built else { + eprintln!("skipping: no GPU adapter"); + return None; + }; + let observer = observer.lock().expect("observer").take()?; + Some(Gpu { harness, observer }) +} + +/// A document the wgpu compositor leaves to the CPU (knockout on its top layer, which also has +/// Blend If). +fn cpu_only_document(app: &mut PhotocraftApp, w: u32, h: u32) -> DocId { + app.run("file.new", json!({"width": w, "height": h, "background": "#336699"})).expect("new"); + app.run("layer.new.layer", json!({})).expect("layer"); + app.run("edit.fill", json!({"color": "#cc8844"})).expect("fill"); + app.run("layer.layerStyle.blendingOptions", json!({"opacity": 60, "knockout": "shallow", "blendIf": {"channel": "gray", "underlying": [0, 10, 245, 255]}})) + .expect("blending options"); + app.session.active().expect("document").doc.id +} + +/// Frames until no redraw is running. +fn settle(g: &mut Gpu) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + loop { + g.harness.step(); + if g.harness.state().redraws.is_empty() { + return; + } + assert!(std::time::Instant::now() < deadline, "the redraw didn't finish"); + std::thread::sleep(std::time::Duration::from_millis(2)); + } +} + +/// The canvas texture of `doc` against the synchronous CPU refresh of the current document. +fn assert_matches_synchronous(g: &mut Gpu, doc: DocId) { + let app = g.harness.state_mut(); + let d = app.session.active().expect("document").doc.clone(); + let (display, _) = crate::canvas::canvas_display(app, &d, None); + let reference = doc.0 ^ (1u64 << 60); + g.observer.upload_composite(reference, &d, display.as_deref()); + let (_, size, shown) = g.observer.read_texels(doc.0).expect("canvas texture"); + let (_, _, want) = g.observer.read_texels(reference).expect("reference texture"); + assert_eq!(size, [d.size.width, d.size.height]); + let differ = shown.iter().zip(&want).filter(|(a, b)| a != b).count(); + assert_eq!(differ, 0, "{differ} texels differ from the synchronous refresh"); +} + +#[test] +fn a_full_cpu_refresh_draws_in_the_background_with_the_synchronous_pixels() { + let _gpu = photocraft_testkit::gpu_lock(); + let Some(mut g) = gpu_app() else { return }; + let doc = cpu_only_document(g.harness.state_mut(), 2600, 1700); + g.harness.step(); + assert!(g.harness.state().redraws.contains_key(&doc), "the frame started a redraw instead of compositing inline"); + settle(&mut g); + let app = g.harness.state(); + assert_eq!(app.perf.last_refresh, "background"); + assert!(app.perf.gpu_fallback.as_deref().is_some_and(|f| f.contains("Advanced Blending")), "{:?}", app.perf.gpu_fallback); + assert_matches_synchronous(&mut g, doc); +} + +#[test] +fn an_edit_during_a_redraw_is_not_overwritten_by_blocks_drawn_before_it() { + let _gpu = photocraft_testkit::gpu_lock(); + let Some(mut g) = gpu_app() else { return }; + // 4×3 blocks: the worker can't hand them all over before the next frame. + let doc = cpu_only_document(g.harness.state_mut(), 4096, 3072); + g.harness.step(); + assert!(g.harness.state().redraws.contains_key(&doc)); + // A corner, drawn last (farthest from the view's centre), changes on a layer of its own while + // the redraw runs: a small edit, drawn at once. + g.harness.state_mut().run("layer.new.layer", json!({})).expect("layer"); + g.harness.step(); + g.harness.state_mut().run("select.rect", json!({"x": 3900, "y": 2900, "width": 150, "height": 150})).expect("select"); + g.harness.step(); + g.harness.state_mut().run("edit.fill", json!({"color": "#22ee22"})).expect("fill"); + g.harness.step(); + assert_eq!(g.harness.state().perf.last_refresh, "rect", "the fill's damage was drawn synchronously"); + assert!(g.harness.state().redraws.contains_key(&doc), "the redraw goes on"); + settle(&mut g); + assert_matches_synchronous(&mut g, doc); +} + +#[test] +fn an_edit_damaging_most_of_a_large_document_is_drawn_in_the_background_too() { + let _gpu = photocraft_testkit::gpu_lock(); + let Some(mut g) = gpu_app() else { return }; + let doc = cpu_only_document(g.harness.state_mut(), 2600, 1700); + settle(&mut g); + // Filling part of the Blend If layer can change the composite anywhere: the whole canvas. + g.harness.state_mut().run("select.rect", json!({"x": 100, "y": 100, "width": 300, "height": 300})).expect("select"); + g.harness.step(); + g.harness.state_mut().run("edit.fill", json!({"color": "#22ee22"})).expect("fill"); + assert_eq!(g.harness.state().session.active().expect("document").last_damage, Some(Rect::new(0, 0, 2600, 1700))); + g.harness.step(); + assert!(g.harness.state().redraws.contains_key(&doc), "not composited inline: {}", g.harness.state().perf.last_refresh); + settle(&mut g); + assert_eq!(g.harness.state().perf.last_refresh, "background"); + assert_matches_synchronous(&mut g, doc); +} + +#[test] +fn closing_the_document_stops_its_redraw() { + let _gpu = photocraft_testkit::gpu_lock(); + let Some(mut g) = gpu_app() else { return }; + let doc = cpu_only_document(g.harness.state_mut(), 4096, 3072); + g.harness.step(); + assert!(g.harness.state().redraws.contains_key(&doc)); + g.harness.state_mut().run("file.close", json!({"save": false})).expect("close"); + g.harness.step(); + assert!(g.harness.state().redraws.is_empty()); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 62a9fda7124..23374d1683c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1527,7 +1527,7 @@ pub(crate) fn effect_reach(layers: &[photocraft_doc::Layer]) -> i32 { /// GPU path: make sure document `idx` is current in the GPU canvas. Edits draw their damage; /// unchanged lower-stack composites can be reused by the compositor inside that region. /// Returns false if there is no GPU canvas. -fn ensure_gpu(app: &mut PhotocraftApp, idx: usize, visible: DRect) -> bool { +fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible: DRect) -> bool { let Some(gpu) = app.gpu.clone() else { return false }; let Some((revision, last_damage, id)) = app .session @@ -1559,6 +1559,8 @@ fn ensure_gpu(app: &mut PhotocraftApp, idx: usize, visible: DRect) -> bool { app.navigator_gpu.remove(&id); } let size = [doc.size.width, doc.size.height]; + // A full CPU redraw running in the background: show the blocks it has finished. + crate::background_redraw::poll(app, &gpu, id); let cache = app.canvases.entry((id, GPU_OUTPUT)).or_insert(CanvasCache { revision: 0, texture: None, @@ -1581,22 +1583,37 @@ fn ensure_gpu(app: &mut PhotocraftApp, idx: usize, visible: DRect) -> bool { // Display textures for a canvas this large would not fit the GPU: the reduced CPU canvas // draws it (checked before the memory budget, which walks the history, every frame). if !gpu.fits_canvas(&doc) { + crate::background_redraw::cancel(app, id); return false; } let partial = present && damage.is_some(); gpu_budget(app, &gpu, idx, partial, visible); - let r = gpu.refresh(id.0, &doc, if partial { damage } else { None }, display.as_deref()); + let defer = crate::background_redraw::enabled(app); + let r = gpu.refresh_with(id.0, &doc, if partial { damage } else { None }, display.as_deref(), defer); if r.kind == "lost" || r.kind == crate::gpu_canvas::REFRESH_OVER_BUDGET { // The device is gone (`gpu_status` then drops the GPU canvas) or the canvas is over the // texture budget: draw this frame on the CPU path. + crate::background_redraw::cancel(app, id); return false; } + if r.kind == crate::gpu_canvas::REFRESH_DEFERRED { + // Composited off the UI thread, the visible part first (#4024). + let region = if partial { damage.unwrap_or(doc.bounds()) } else { doc.bounds() }; + crate::background_redraw::start(app, ctx, &gpu, doc.clone(), display.clone(), visible, region, r.fallback.clone()); + } else if partial { + // The edit drew its damage; blocks of a running redraw still to come follow the edit. + crate::background_redraw::restart(app, ctx, &gpu, doc.clone(), display.clone(), visible); + } else { + crate::background_redraw::cancel(app, id); + } if let Some(e) = &r.fallback && app.perf.gpu_fallback.as_deref() != Some(e.as_str()) { log::info!("{e}; using the CPU compositor"); } - app.perf.record(r.kind, r.px, r.composite_ms, r.upload_ms); + if r.kind != crate::gpu_canvas::REFRESH_DEFERRED { + app.perf.record(r.kind, r.px, r.composite_ms, r.upload_ms); + } if r.kind == "full" { crate::notices::slow_cpu_refresh(app, id.0, r.composite_ms + r.upload_ms, r.fallback.as_deref()); } @@ -1924,6 +1941,7 @@ pub(crate) fn retain_gpu_documents(app: &mut PhotocraftApp) { live.extend(crate::adjust_preview::gpu_keys(app)); gpu.retain(&live); } + crate::background_redraw::retain(app); // Upload markers must not outlive the resources they describe: native reopen can reuse // both the document ID and revision before the next frame while a preview dialog stays open. let documents = app.session.documents(); @@ -2913,7 +2931,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: offset: [0.0, 0.0], }; paint_gpu_view(&painter, rect, params, &pattern); - } else if gpu_ok && ensure_gpu(app, idx, visible_doc_rect(&xf)) { + } else if gpu_ok && ensure_gpu(app, &ctx, idx, visible_doc_rect(&xf)) { on_gpu = true; app.perf.gpu = true; // Shadow, checkerboard, document and pixel grid in one custom shader (gpu_canvas.rs). diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index bcf0e695161..046d4ac2a73 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -527,6 +527,20 @@ impl GpuCanvas { doc: &photocraft_doc::Document, damage: Option, display: Option<&photocraft_engine::display_color::CanvasDisplay>, + ) -> Refresh { + self.refresh_with(key, doc, damage, display, false) + } + + /// [`GpuCanvas::refresh`], except that with `defer`, a refresh of at least [`DEFER_PX`] the + /// CPU compositor would draw is left to the caller (`background_redraw`): the texture is made + /// ready (keeping what it shows when it already fits) and the kind is [`REFRESH_DEFERRED`]. + pub fn refresh_with( + &self, + key: u64, + doc: &photocraft_doc::Document, + damage: Option, + display: Option<&photocraft_engine::display_color::CanvasDisplay>, + defer: bool, ) -> Refresh { if let Some(f) = self.fault() { // Nothing reaches the GPU any more; the app switches to the CPU canvas. @@ -564,6 +578,12 @@ impl GpuCanvas { } Err(e) => out.fallback = Some(e.0), } + let px = region.width() as u64 * region.height() as u64; + if defer && px >= DEFER_PX && self.prepare_texture(key, doc).is_some() { + out.kind = REFRESH_DEFERRED; + out.px = px; + return out; + } if let Some(r) = damage { if r.is_empty() { out.kind = "rect"; @@ -591,22 +611,8 @@ impl GpuCanvas { /// never needs a full-size float composite or texture-format copy in memory. pub fn upload_composite(&self, key: u64, doc: &photocraft_doc::Document, display: Option<&photocraft_engine::display_color::CanvasDisplay>) { let size = [doc.size.width, doc.size.height]; - if size[0] == 0 || size[1] == 0 || !self.health.is_ok() { - return; - } - let format = self.format_for(doc.depth, size); + let Some(format) = self.prepare_texture(key, doc) else { return }; let (device, queue) = (&self.rs.device, &self.rs.queue); - { - let mut renderer = self.rs.renderer.write(); - let Some(res) = renderer.callback_resources.get_mut::() else { return }; - if res.docs.get(&key).is_none_or(|d| d.size != size || d.format != format) { - let Some(tex) = DocTextures::new(device, res, size, self.tile, format) else { - res.docs.remove(&key); - return; - }; - res.docs.insert(key, tex); - } - } let mut rgba = Vec::new(); let _ = photocraft_compose::render_bands(doc, doc.bounds(), 0, |band| -> Result<(), ()> { if !self.health.is_ok() { @@ -633,6 +639,26 @@ impl GpuCanvas { } } + /// Make sure document `key` has a display texture of `doc`'s size and format, keeping the one + /// it has when that fits. Returns its format, or `None` when it can't be created. + pub fn prepare_texture(&self, key: u64, doc: &photocraft_doc::Document) -> Option { + let size = [doc.size.width, doc.size.height]; + if size[0] == 0 || size[1] == 0 || !self.health.is_ok() { + return None; + } + let format = self.format_for(doc.depth, size); + let mut renderer = self.rs.renderer.write(); + let res = renderer.callback_resources.get_mut::()?; + if res.docs.get(&key).is_none_or(|d| d.size != size || d.format != format) { + let Some(tex) = DocTextures::new(&self.rs.device, res, size, self.tile, format) else { + res.docs.remove(&key); + return None; + }; + res.docs.insert(key, tex); + } + Some(format) + } + /// Free textures, display LUTs and their signatures for documents not in `live`. pub fn retain(&self, live: &[u64]) { let mut renderer = self.rs.renderer.write(); @@ -764,6 +790,16 @@ fn texture_buffer<'a>( } } +/// A CPU composite `buf` as texels of a `format` canvas texture, in `display`'s encoding: what +/// [`GpuCanvas::upload_rect`] takes. Runs anywhere (a redraw's worker converts its own pieces). +pub fn display_texels( + format: wgpu::TextureFormat, + display: Option<&photocraft_engine::display_color::CanvasDisplay>, + buf: &photocraft_compose::Buffer, +) -> Vec { + texels(format, &texture_buffer(display, buf).px) +} + /// What a [`GpuCanvas::refresh`] did. #[derive(Clone, Debug, Default)] pub struct Refresh { @@ -1151,6 +1187,13 @@ pub const OVER_CANVAS_BUDGET: &str = "the canvas exceeds the GPU texture budget; /// was created, and the caller draws the CPU canvas instead. pub const REFRESH_OVER_BUDGET: &str = "over-budget"; +/// [`Refresh::kind`] of a CPU refresh left to the caller ([`GpuCanvas::refresh_with`]). +pub const REFRESH_DEFERRED: &str = "deferred"; + +/// The smallest CPU refresh (pixels) [`GpuCanvas::refresh_with`] defers: below it, the +/// synchronous composite takes a fraction of a second even with many layers. +pub const DEFER_PX: u64 = 1 << 22; + /// GPU bytes one document's display texture set (every tile and mip level) may take: a quarter /// of physical memory (`ram`; 16 GB assumed when unknown), at least [`MIN_GPU_BUDGET`]. The set /// covers the whole canvas at full resolution, so its size follows the declared canvas, not the diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 037363d1dc7..41c4c03867d 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2428,6 +2428,7 @@ cameraRaw Color Barvy Violet Fialová Reset Obnovit Large document: redrawing on the CPU Velký dokument: překreslování na CPU + Redrawing Překreslování This redraw took {seconds} s on the CPU. Toto překreslení na CPU trvalo {seconds} s. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Úpravy, které mění celý dokument, například režim prolnutí, krytí nebo viditelnost vrstvy, jej překreslí celý. Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Vrstvy se nevejdou do rozpočtu paměti GPU. Zvýšením hodnoty Využití paměti v Předvolbách › Výkon se rozpočet zvětší, nejvýše na čtvrtinu paměti tohoto počítače. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 8be365543ca..29b718c2553 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2467,6 +2467,7 @@ layerLabel No Color Keine Farbe Fuchsia Fuchsia Violet Violett Large document: redrawing on the CPU Großes Dokument: Neuzeichnen auf der CPU + Redrawing Neuzeichnen This redraw took {seconds} s on the CPU. Dieses Neuzeichnen auf der CPU dauerte {seconds} s. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Änderungen, die das ganze Dokument betreffen, etwa Füllmethode, Deckkraft oder Sichtbarkeit einer Ebene, zeichnen es vollständig neu. Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Die Ebenen passen nicht in das GPU-Speicherbudget. Eine höhere Speichernutzung unter Voreinstellungen › Leistung erhöht das Budget, bis zu einem Viertel des Arbeitsspeichers dieses Computers. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 6f2b9bb9511..82f2a5de64b 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3432,6 +3432,7 @@ cameraRaw Yellow Κίτρινο Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Τα επίπεδά του δεν χωρούν στο όριο μνήμης της GPU. Αυξάνοντας τη Χρήση μνήμης στις Προτιμήσεις › Επιδόσεις αυξάνεται το όριο, έως το ένα τέταρτο της μνήμης αυτού του υπολογιστή. Knockout Διάτρηση Large document: redrawing on the CPU Μεγάλο έγγραφο: επανασχεδίαση στη CPU + Redrawing Επανασχεδίαση Median… Διάμεσος… Move Μετακίνηση New Swatch Group… Νέα ομάδα δειγμάτων χρωμάτων… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e2284c70f4c..e47aa6cba10 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2497,6 +2497,7 @@ cameraRaw Amount Cantidad Violet Violeta Reset Restaurar Large document: redrawing on the CPU Documento grande: redibujando en la CPU + Redrawing Redibujando This redraw took {seconds} s on the CPU. Este redibujado en la CPU tardó {seconds} s. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Las ediciones que cambian todo el documento, como el modo de fusión, la opacidad o la visibilidad de una capa, lo redibujan por completo. Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Las capas no caben en el presupuesto de memoria de la GPU. Aumentar Uso de memoria en Preferencias › Rendimiento amplía el presupuesto, hasta una cuarta parte de la memoria de este equipo. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index c51aa6aa84b..8085f5fdfb6 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2848,6 +2848,7 @@ cameraRaw Blending Fusion Fuchsia Fuchsia Violet Violet Large document: redrawing on the CPU Document volumineux : redessin sur le CPU + Redrawing Redessin This redraw took {seconds} s on the CPU. Ce redessin sur le CPU a pris {seconds} s. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Les modifications qui touchent tout le document, comme le mode de fusion, l'opacité ou la visibilité d'un calque, le redessinent entièrement. Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Les calques dépassent le budget de mémoire GPU. Augmenter Utilisation de la mémoire dans Préférences › Performances relève ce budget, jusqu'au quart de la mémoire de cet ordinateur. diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 4e9a4fbe3ce..57734795ead 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4050,3 +4050,4 @@ cameraRaw Yellow Sárga Color Indicates: A szín jelzése: Ink Characteristics Festék jellemzői Solidity: Fedőképesség: + Redrawing Újrarajzolás diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 10d4ef2053f..95ebe980cbc 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2835,6 +2835,7 @@ cameraRaw Blending Pembauran Fuchsia Fuksia Violet Ungu Large document: redrawing on the CPU Dokumen besar: menggambar ulang di CPU + Redrawing Menggambar ulang This redraw took {seconds} s on the CPU. Penggambaran ulang di CPU ini memakan waktu {seconds} dtk. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Suntingan yang mengubah seluruh dokumen, seperti mode campuran, opasitas, atau visibilitas layer, menggambar ulang semuanya. Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Layer tidak muat dalam anggaran memori GPU. Menaikkan Pemakaian memori di Preferensi › Performa menaikkan anggaran, hingga seperempat memori komputer ini. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 7358e966e65..db23b01e123 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2497,6 +2497,7 @@ cameraRaw Amount Quantità Fuchsia Fucsia Violet Viola Large document: redrawing on the CPU Documento grande: ridisegno sulla CPU + Redrawing Ridisegno This redraw took {seconds} s on the CPU. Questo ridisegno sulla CPU ha richiesto {seconds} s. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Le modifiche che cambiano tutto il documento, come metodo di fusione, opacità o visibilità di un livello, lo ridisegnano per intero. Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. I livelli non rientrano nel budget di memoria della GPU. Aumentare Uso memoria in Preferenze › Prestazioni alza il budget, fino a un quarto della memoria di questo computer. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index f5d6104ea70..040db7e8b37 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2398,6 +2398,7 @@ cameraRaw Color カラー Violet 紫 Reset 初期化 Large document: redrawing on the CPU 大きなドキュメント: CPU で再描画しています + Redrawing 再描画中 This redraw took {seconds} s on the CPU. この再描画は CPU で {seconds} 秒かかりました。 Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. レイヤーの描画モード、不透明度、表示/非表示など、ドキュメント全体に関わる編集では全体が再描画されます。 Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. レイヤーが GPU メモリの予算に収まりません。環境設定 › パフォーマンスのメモリ使用量を増やすと、このコンピューターのメモリの4分の1まで予算が増えます。 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 0547c561815..1c26d44ee90 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3008,6 +3008,7 @@ cameraRaw Color 색상 Indigo 남색 Fuchsia 자홍색 Large document: redrawing on the CPU 큰 문서: CPU에서 다시 그리는 중 + Redrawing 다시 그리는 중 This redraw took {seconds} s on the CPU. 이번 다시 그리기는 CPU에서 {seconds}초 걸렸습니다. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. 레이어의 혼합 모드, 불투명도, 표시 여부처럼 문서 전체를 바꾸는 편집은 문서 전체를 다시 그립니다. Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. 레이어가 GPU 메모리 예산에 맞지 않습니다. 환경 설정 › 성능에서 메모리 사용량을 늘리면 이 컴퓨터 메모리의 4분의 1까지 예산이 늘어납니다. diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 66db0dba6ad..35bad8fcd3b 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2427,6 +2427,7 @@ layerLabel No Color Geen kleur Fuchsia Fuchsia Violet Violet Large document: redrawing on the CPU Groot document: opnieuw tekenen op de CPU + Redrawing Opnieuw tekenen This redraw took {seconds} s on the CPU. Opnieuw tekenen duurde {seconds} s op de CPU. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Bewerkingen die het hele document veranderen, zoals de overvloeimodus, dekking of zichtbaarheid van een laag, tekenen alles opnieuw. Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. De lagen passen niet binnen het GPU-geheugenbudget. Door Geheugengebruik te verhogen in Voorkeuren › Prestaties wordt het budget groter, tot een kwart van het geheugen van deze computer. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index d8df0afc9d1..2d63948d348 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3458,6 +3458,7 @@ cameraRaw Roughness Chropowatość Indigo Indygo Fuchsia Fuksja Large document: redrawing on the CPU Duży dokument: odświeżanie na CPU + Redrawing Odświeżanie This redraw took {seconds} s on the CPU. To odświeżenie na CPU trwało {seconds} s. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Zmiany obejmujące cały dokument, takie jak tryb mieszania, krycie lub widoczność warstwy, odświeżają go w całości. Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Warstwy nie mieszczą się w budżecie pamięci GPU. Zwiększenie wartości Użycie pamięci w Preferencje › Wydajność zwiększa budżet, maksymalnie do jednej czwartej pamięci tego komputera. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 5f76c764983..7662e3f03d4 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2852,6 +2852,7 @@ cameraRaw Whites Brancos Fuchsia Fúcsia Violet Violeta Large document: redrawing on the CPU Documento grande: redesenhando na CPU + Redrawing Redesenhando This redraw took {seconds} s on the CPU. Este redesenho na CPU levou {seconds} s. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Edições que mudam o documento inteiro, como modo de mesclagem, opacidade ou visibilidade de uma camada, redesenham tudo. Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. As camadas não cabem no orçamento de memória da GPU. Aumentar Uso de memória em Preferências › Desempenho eleva o orçamento, até um quarto da memória deste computador. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b99fed9343a..ea320c153ed 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2697,6 +2697,7 @@ cameraRaw Roughness Неровность Fuchsia Фуксия Violet Фиолетовый Large document: redrawing on the CPU Большой документ: перерисовка на CPU + Redrawing Перерисовка This redraw took {seconds} s on the CPU. Эта перерисовка на CPU заняла {seconds} с. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Правки, затрагивающие весь документ, например режим наложения, непрозрачность или видимость слоя, перерисовывают его целиком. Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Слои не помещаются в бюджет памяти GPU. Увеличение параметра «Память» в разделе Настройки › Производительность повышает бюджет, но не больше четверти памяти этого компьютера. diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index b2da769bf15..902eb705893 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4204,3 +4204,4 @@ shape Appearance Izgled Color Indicates: Boja označava: Ink Characteristics Karakteristike mastila Solidity: Punoća: + Redrawing Ponovno iscrtavanje diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index e499fa162a5..a4dbb575d9e 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3861,3 +3861,4 @@ cameraData Lens Lens Color Indicates: Rengin Gösterdiği: Ink Characteristics Mürekkep Özellikleri Solidity: Doluluk: + Redrawing Yeniden çiziliyor diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8bb409d8c19..1367c4c7979 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2667,6 +2667,7 @@ cameraRaw Roughness Шорсткість Fuchsia Фуксія Violet Фіалковий Large document: redrawing on the CPU Великий документ: перемальовування на CPU + Redrawing Перемальовування This redraw took {seconds} s on the CPU. Це перемальовування на CPU тривало {seconds} с. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Зміни, що впливають на весь документ, як-от режим накладання, непрозорість чи видимість шару, перемальовують його повністю. Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Його шари не вміщуються в бюджет пам'яті GPU. Збільшення використання пам'яті в Налаштування › Продуктивність підвищує бюджет до чверті пам'яті цього комп'ютера. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 72ce77b630e..c1becf4b373 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2152,6 +2152,7 @@ layers Fill: Độ tô: Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Các lớp không vừa ngân sách bộ nhớ GPU. Tăng mức sử dụng bộ nhớ trong Tùy chọn › Hiệu năng sẽ nâng ngân sách, tối đa một phần tư bộ nhớ máy này. Labels Nhãn Large document: redrawing on the CPU Tài liệu lớn: đang vẽ lại trên CPU + Redrawing Đang vẽ lại Layer deleted, but confirmation preference could not be saved: {error} Đã xóa lớp, nhưng không lưu được tùy chọn xác nhận: {error} Layer From Background… Lớp từ nền… Load a keyboard shortcut set saved as .kys Tải bộ phím tắt đã lưu dạng .kys diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index f57dee7f2bd..23843a8aa4a 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2439,6 +2439,7 @@ cameraRaw Blending 混合 Violet 紫罗兰色 Reset 复位 Large document: redrawing on the CPU 大型文档:正在用 CPU 重绘 + Redrawing 正在重绘 This redraw took {seconds} s on the CPU. 此次 CPU 重绘耗时 {seconds} 秒。 Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. 更改整个文档的编辑(如图层的混合模式、不透明度或可见性)会重绘整个文档。 Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. 图层超出 GPU 内存预算。在 首选项 › 性能 中提高内存使用量可增加预算,最多为本机内存的四分之一。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index ec32d0dbd09..cbeba57d56c 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2409,6 +2409,7 @@ cameraRaw Blending 混合 Violet 紫羅蘭色 Reset 重設 Large document: redrawing on the CPU 大型文件:正在以 CPU 重繪 + Redrawing 正在重繪 This redraw took {seconds} s on the CPU. 此次 CPU 重繪耗時 {seconds} 秒。 Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. 變更整份文件的編輯(例如圖層的混合模式、不透明度或可見度)會重繪整份文件。 Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. 圖層超出 GPU 記憶體預算。在 偏好設定 › 效能 中提高記憶體使用量可增加預算,最多為本機記憶體的四分之一。 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d0ebc43472a..e1b5c6cdf6c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -24,6 +24,7 @@ pub mod analysis_ui; pub mod anchor_tools; pub mod artboard_tool; pub mod artboard_ui; +pub(crate) mod background_redraw; mod blend_keys; pub(crate) mod blend_preview; mod brand; @@ -633,6 +634,8 @@ pub struct PhotocraftApp { pub(crate) filter_preview: Option, #[cfg(not(target_arch = "wasm32"))] filter_preview_worker: filter_preview_worker::Worker, + /// Full CPU canvas redraws running in the background, by document (#4024). + pub(crate) redraws: std::collections::HashMap, /// Select › Color Range dialog preview (proxy document + mask / image textures). pub(crate) color_range: Option, /// Image › Adjustments dialog preview through a temporary clipped adjustment layer. @@ -823,6 +826,7 @@ impl PhotocraftApp { filter_preview: None, #[cfg(not(target_arch = "wasm32"))] filter_preview_worker: Default::default(), + redraws: Default::default(), color_range: None, adjust_preview: None, synthetic: Vec::new(), diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 24ff5f4271e..cff94a840f8 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1611,7 +1611,10 @@ pub fn status_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if t.pro { crate::chrome_ui::status_bar_pro(app, ui); // Background job progress with Cancel, at the far end (#210). - ui.with_layout(crate::direction::trailing(), |ui| crate::jobs_ui::status_progress(app, ui)); + ui.with_layout(crate::direction::trailing(), |ui| { + crate::jobs_ui::status_progress(app, ui); + crate::background_redraw::status(app, ui); + }); return; } if let (Some(st), Some(i)) = (app.session.active(), app.session.active_index()) { @@ -1650,6 +1653,7 @@ pub fn status_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.add_space(6.0); crate::jobs_ui::status_progress(app, ui); } + crate::background_redraw::status(app, ui); }); }); }); From 57624bd02eaed7e3a510b8cd7c7d0b5cc2909193 Mon Sep 17 00:00:00 2001 From: MrCloudy2 <123890706+MrCloudy2@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:56:08 +0200 Subject: [PATCH 31/47] PSD: Backgrounds survive a round trip through PhotoCraft (#3560) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * PSD: Backgrounds survive a round trip through PhotoCraft Photoshop opens a PSD's bottom layer as its Background when that layer's record has no transparency (-1) channel. PhotoCraft always wrote one, so every file with a Background came back from PhotoCraft with an ordinary "Layer 0"-style bottom layer instead. Measured by opening PhotoCraft's re-exports of the 427 corpus files in Photoshop 27.11: 317 lost their Background this way. - Export: the bottom top-level layer is written without the -1 channel when it is a locked (transparency and position), unclipped, unmasked raster that is opaque over the whole canvas at full opacity and Normal, as a Background always is; flags as Photoshop writes them (bit 4 marked meaningful, and "pixel data irrelevant" on adjustment layers). - Import: like Photoshop, a bottom raster layer whose record has no -1 channel becomes the Background (locked, named "Background") whatever the file names it: psd-tools' "Фон" and ag-psd's "bg" or "Layer 1" open as Backgrounds in Photoshop. Of the 227 corpus files whose bottom layer is a Background, PhotoCraft's export now writes all 227 the way the original does (it was 0), and no other bottom layer turns into one. Corpus oracles and round trips are unchanged (io 148/170, psd-tools 237/309, Photoshop 137/258 on this commit; every round trip renders the same). Co-Authored-By: Claude Opus 5.5 * PSD: a flat Bitmap / Indexed file's image opens as the locked Background Probe 7 (PhotoCraft's re-exports opened in Photoshop 27.11): with the Background fix, 314 of 318 Backgrounds survived; the other four were psd-tools' flat 1-bit Bitmap and Indexed files. Photoshop opens a flat file's image as its Background, but PhotoCraft's import of these modes left the "Background" layer unlocked, so the export wrote it as an ordinary layer. An opaque flat image now opens locked (as flat PNG/JPEG and layered-but-flattened PSDs already did) and saves as a Background. Corpus unchanged (io 148/170, psd-tools 237/309, Photoshop 137/258; every round trip renders the same). Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/io/src/psd_export.rs | 79 ++++++++++++++++++++++++++++++++++++- crates/io/src/psd_import.rs | 45 +++++++++++++++------ 2 files changed, 112 insertions(+), 12 deletions(-) diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index dfc7e46c9ee..b5e9db31e11 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -79,6 +79,32 @@ struct Ex { smart: SmartOut, /// The document's patterns, which Pattern Fill layers are rendered from. patterns: Vec, + /// The layer written as Photoshop's Background (see [`psd_background`]). + background: Option, +} + +/// The layer Photoshop should open as its Background: the bottom top-level layer when it is a +/// locked (transparency and position), unclipped, unmasked raster that is opaque over the whole +/// canvas at full opacity and Normal, as a Background always is. Photoshop recognises a +/// Background by the bottom layer having no transparency channel (measured on Photoshop 27.11: +/// with the `-1` channel written, 317 of 427 re-exported corpus files reopened with the +/// Background as an ordinary layer). +fn psd_background(doc: &Document) -> Option { + let l = doc.layers.first()?; + let LayerContent::Raster(s) = &l.content else { return None }; + let locked = l.locks.transparency && l.locks.position; + let plain = !l.clipped && l.mask.is_none() && l.vector_mask.is_none() && l.opacity >= 1.0 && l.fill_opacity >= 1.0; + let normal = l.blend == photocraft_doc::BlendMode::Normal; + if !(locked && plain && normal) { + return None; + } + let canvas = doc.bounds(); + if canvas.is_empty() || s.content_bounds() != canvas { + return None; + } + let n = s.channels(); + let opaque = s.read_region(canvas).chunks_exact(n).all(|p| p.last().is_some_and(|a| *a >= 1.0)); + opaque.then_some(l.id) } /// How deep embedded documents are exported inside each other before a smart object is written @@ -407,7 +433,16 @@ impl Ex { real_first: true, }); } - let mut flags = LayerFlags(0); + // A Background carries no transparency channel: that is how Photoshop tells it apart. + if self.background == Some(l.id) { + channels.retain(|c| c.id != -1); + } + // As Photoshop writes them: bit 4 is meaningful, and set for layers whose pixels don't + // show (adjustment layers). + let mut flags = LayerFlags(LayerFlags::BIT4_USEFUL); + if matches!(l.content, LayerContent::Adjustment(_)) { + flags.0 |= LayerFlags::PIXEL_DATA_IRRELEVANT; + } flags.set_hidden(!l.visible); flags.set_transparency_protected(l.locks.transparency); let mut blocks = vec![TaggedBlock::unicode_name(&l.name), TaggedBlock::layer_id(id)]; @@ -1459,6 +1494,7 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c comps: (!crate::comps_map::comps_unchanged(doc)).then(|| (doc.layer_comps.clone(), doc.last_document_state.clone())), smart: SmartOut::new(doc, depth), patterns: doc.patterns.clone(), + background: psd_background(doc), }; if big && !opts.force_psb { ex.warnings.push("document exceeds 30000 px; written as PSB".into()); @@ -1751,6 +1787,47 @@ mod tests { Document::new("size estimate", photocraft_geom::Size::new(width, height), ColorMode::Rgb, SampleType::U8) } + /// Photoshop opens a PSD's bottom layer as its Background only when that layer has no + /// transparency channel (measured on Photoshop 27.11: with the channel written, re-exported + /// files reopened with the Background as an ordinary layer). + #[test] + fn a_background_is_written_without_a_transparency_channel() { + use photocraft_color::Color; + let size = photocraft_geom::Size::new(8, 6); + let mut doc = Document::with_background("bg", size, ColorMode::Rgb, SampleType::U8, Color::rgb(0.2, 0.4, 0.6)); + doc.layers.push(Layer::raster("Layer 1", doc.pixel_format())); + let file = document_to_psd(&doc); + let layers = &file.layer_info.as_ref().unwrap().layers; + let ids = |i: usize| layers[i].channels.iter().map(|c| c.id).collect::>(); + assert_eq!(ids(0), vec![0, 1, 2], "the Background has no -1 channel"); + assert_eq!(layers[0].flags.0, LayerFlags::BIT4_USEFUL | LayerFlags::TRANSPARENCY_PROTECTED, "as Photoshop writes it"); + assert!(ids(1).contains(&-1), "other layers keep their transparency"); + // It comes back as the same locked, opaque Background. + let back = crate::import("bg.psd", &file.to_bytes().unwrap()).unwrap().document; + let bg = &back.layers[0]; + assert!(bg.locks.transparency && bg.locks.position, "{:?}", bg.locks); + assert_eq!(bg.surface().unwrap().read_region(back.bounds()), doc.layers[0].surface().unwrap().read_region(doc.bounds())); + // A bottom layer with transparency (or unlocked) is not a Background. + let mut holey = doc.clone(); + holey.layers[0].surface_mut().unwrap().write_pixel(2, 2, &[0.0, 0.0, 0.0, 0.0]); + assert!(document_to_psd(&holey).layer_info.unwrap().layers[0].channels.iter().any(|c| c.id == -1)); + let mut unlocked = doc.clone(); + unlocked.layers[0].locks.position = false; + assert!(document_to_psd(&unlocked).layer_info.unwrap().layers[0].channels.iter().any(|c| c.id == -1)); + } + + /// A flat 1-bit Bitmap file (no layers) opens in Photoshop as its Background; so does it in + /// PhotoCraft, and it is written back as one (no transparency channel). + #[test] + fn a_flat_bitmap_file_opens_and_saves_as_a_background() { + let file = photocraft_psd::testgen::merged_only(Version::Psd, PsdMode::Bitmap, 1, Compression::Raw, 8, 4); + let doc = crate::import("flat.psd", &file.to_bytes().unwrap()).unwrap().document; + let bg = &doc.layers[0]; + assert!(bg.name == "Background" && bg.locks.transparency && bg.locks.position, "{:?}", bg.locks); + let out = document_to_psd(&doc); + assert!(out.layer_info.unwrap().layers[0].channels.iter().all(|c| c.id >= 0)); + } + #[test] fn group_divider_id_wraps_without_colliding_with_imported_layer_ids() { let mut doc = document(8, 8); diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 1e77cdaa6a3..323fe600b3d 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -599,6 +599,35 @@ fn parse_guides(data: &[u8], doc: &mut Document) { } /// Converts a parsed PSD into a document. Never fails: problems become warnings. +/// A flattened file's merged image as its layer: like Photoshop, an opaque one is the locked +/// Background (Photoshop 27.11 opens psd-tools' flat Bitmap and Indexed files that way), so it is +/// written back as one. +fn flat_background(s: Surface) -> Layer { + let opaque = { + let r = s.content_bounds(); + let n = s.channels(); + !r.is_empty() && s.read_region(r).chunks_exact(n).all(|p| p.last().is_some_and(|a| *a >= 1.0)) + }; + let mut l = Layer::new("Background", LayerContent::Raster(s)); + l.locks.transparency = opaque; + l.locks.position = opaque; + l +} + +/// Photoshop opens a file's bottom layer as its Background when that layer's record has no +/// transparency (`-1`) channel, whatever its name and lock flags say: it is then locked and shown +/// as "Background" (measured on Photoshop 27.11 with files from psd-tools and ag-psd whose bottom +/// layer is called "Фон", "bg" or "Layer 1" and carries no locks). +fn mark_background(file: &PsdFile, layers: &mut [Layer]) { + let Some(rec) = file.layers().first() else { return }; + let Some(bottom) = layers.first_mut() else { return }; + let opaque_record = !rec.channels.is_empty() && rec.channels.iter().all(|c| c.id >= 0); + if opaque_record && matches!(bottom.content, LayerContent::Raster(_)) && !bottom.clipped { + bottom.locks.transparency = true; + bottom.locks.position = true; + } +} + pub fn psd_to_document(file: &PsdFile) -> (Document, Vec) { // Never cancelled, so always `Some`; the fallback is unreachable. psd_to_document_with(file, &photocraft_raster::Interrupt::NONE) @@ -759,16 +788,10 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) // its own profile, as the compositor and the PSD writer read them. let space = photocraft_compose::cmyk_space(&doc); doc.layers = photocraft_color::convert::with_cmyk_space(space.as_ref(), || cx.build(&tree, 0)); - // Photoshop's Background: the bottom record, a transparency-protected pixel layer stored - // without a transparency channel (-1). It is also position-locked, whatever its - // (localized) name and whether or not the file carries an `lspf` block. - if let (Some(rec), Some(bottom)) = (file.layers().first(), doc.layers.first_mut()) - && rec.flags.transparency_protected() - && !rec.channels.iter().any(|c| c.id == -1) - && matches!(bottom.content, LayerContent::Raster(_)) - { - bottom.locks.position = true; - } + // Photoshop's Background: the bottom record stored without a transparency channel (-1), + // locked whatever its (localized) name and whether or not the file carries an `lspf` + // block. Its name is kept. + mark_background(file, &mut doc.layers); // Layer › Link Layers: resource 1026 holds one group id per layer record (0 = unlinked). if let Some(Ok(photocraft_psd::resources::ResourceData::LayerGroupInfo(groups))) = file.resources.iter().find(|r| r.id == ids::LAYER_GROUP_INFO).and_then(photocraft_psd::resources::ImageResource::parsed) @@ -865,7 +888,7 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) .collect(); s.write_region(canvas, &vals); s.prune(); - doc.layers.push(Layer::new("Background", LayerContent::Raster(s))); + doc.layers.push(flat_background(s)); } } From 3260f6abb974660bf93af8c14f1c731d29d73cde Mon Sep 17 00:00:00 2001 From: Sir Starch Date: Tue, 13 Oct 2026 18:56:15 +0200 Subject: [PATCH 32/47] fix(psd): prioritize 16-bit and 32-bit global layer blocks over compatibility section (#4928) (#4950) * fix(psd): prioritize 16-bit and 32-bit global layer blocks over compatibility section (#4928) In 16-bit and 32-bit PSD files saved by Photoshop, the Layer Info section either contains 0 layers or 8-bit compatibility layers, while the actual 16-bit/32-bit layers are stored in the global Lr16/Lr32 tagged blocks. Previously, candidate_seen was initialized to layer_info.is_some(), which caused PsdFile::from_bytes to skip the Lr16/Lr32 block whenever the section was non-empty. This left the document with empty layer lists or 8-bit records that failed channel decoding at depth 16/32, causing all layers to be empty and invisible. Prioritize the depth-specific tagged block (Lr16 for 16-bit, Lr32 for 32-bit) over the compatibility section. * test(psd): improve 16-bit multi-layer compatibility regression tests --- crates/io/tests/roundtrip.rs | 52 ++++++++++++++++++++++++++++++++++++ crates/psd/src/file.rs | 51 ++++++++++++++++++++++++++++++++--- 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/crates/io/tests/roundtrip.rs b/crates/io/tests/roundtrip.rs index 651d74d53cd..6502079f203 100644 --- a/crates/io/tests/roundtrip.rs +++ b/crates/io/tests/roundtrip.rs @@ -553,3 +553,55 @@ fn blend_if_round_trips_as_blending_ranges() { assert!(back.layers[1].blend_if.is_default()); assert_eq!(back.layers[2].blend_if.get(1)[0], BlendRange { black: [1, 2], white: [3, 4] }); } + +#[test] +fn psd_16bit_layers_with_compatibility_section_open_visible() { + use photocraft_doc::{Document, Layer, LayerContent, Size}; + use photocraft_geom::Rect; + use photocraft_raster::Surface; + + // A 16-bit document with multiple distinct user layers. + let mut doc = Document::new("Test16", Size::new(50, 50), ColorMode::Rgb, SampleType::U16); + let fmt = doc.pixel_format(); + + let bg_data = vec![255u8; 50 * 50 * 8]; + let bg_surf = Surface::from_interleaved(fmt, Rect::new(0, 0, 50, 50), &bg_data); + let mut bg = Layer::new("Background", LayerContent::Raster(bg_surf)); + bg.visible = true; + doc.layers.push(bg); + + let fg_data = vec![128u8; 20 * 20 * 8]; + let fg_surf = Surface::from_interleaved(fmt, Rect::new(10, 10, 30, 30), &fg_data); + let mut fg = Layer::new("Foreground", LayerContent::Raster(fg_surf)); + fg.visible = true; + doc.layers.push(fg); + + let exported = export(&doc, "test.psd", &ExportOptions::default()).expect("export"); + let bytes = exported.bytes; + + let cmd_len = u32::from_be_bytes(bytes[26..30].try_into().unwrap()) as usize; + let res_pos = 30 + cmd_len; + let res_len = u32::from_be_bytes(bytes[res_pos..res_pos + 4].try_into().unwrap()) as usize; + let lm_pos = res_pos + 4 + res_len; + let lm_len = u32::from_be_bytes(bytes[lm_pos..lm_pos + 4].try_into().unwrap()) as usize; + let li_pos = lm_pos + 4; + + // Simulate Photoshop's 16-bit format: an empty/compatibility layer info section (count = 0) + // before the global Lr16 block (#4928). + let mut modified = bytes; + let new_lm_len = (lm_len + 4) as u32; + modified[lm_pos..lm_pos + 4].copy_from_slice(&new_lm_len.to_be_bytes()); + modified[li_pos..li_pos + 4].copy_from_slice(&4u32.to_be_bytes()); + modified.splice(li_pos + 4..li_pos + 4, [0u8, 0, 0, 0]); + + let imported = import("test.psd", &modified).expect("import").document; + + // Must import the 16-bit layers from Lr16 ("Background" and "Foreground"). + assert_eq!(imported.layers.len(), 2); + assert_eq!(imported.layers[0].name, "Background"); + assert_eq!(imported.layers[1].name, "Foreground"); + assert!(imported.layers[0].visible); + assert!(imported.layers[1].visible); + assert!(imported.layers[0].surface().unwrap().tile_count() > 0); + assert!(imported.layers[1].surface().unwrap().tile_count() > 0); +} diff --git a/crates/psd/src/file.rs b/crates/psd/src/file.rs index 330940e7a48..1e86665ad81 100644 --- a/crates/psd/src/file.rs +++ b/crates/psd/src/file.rs @@ -112,13 +112,30 @@ impl PsdFile { if s.remaining() >= 4 { let n = s.u32()?; global_layer_mask = Some(GlobalLayerMask { data: s.bytes_u64(u64::from(n))?.to_vec() }); - let mut candidate_seen = layer_info.is_some(); + let target_key = match header.depth { + 16 => Some(b"Lr16"), + 32 => Some(b"Lr32"), + _ => None, + }; + let mut candidate_seen = match target_key { + Some(_) => false, + None => layer_info.as_ref().is_some_and(|i| !i.layers.is_empty()), + }; let (blocks, trailing) = read_blocks_with(&mut s, header.version, 4, |index, signature, key, data, padding| { - if candidate_seen || !matches!(&key, b"Lr16" | b"Lr32" | b"Layr") { + let is_candidate = match target_key { + Some(k) => &key == k, + None => matches!(&key, b"Layr" | b"Lr16" | b"Lr32"), + }; + if candidate_seen || !is_candidate { return false; } candidate_seen = true; - let Ok(info) = LayerInfo::read_body(data, header.version) else { return false }; + let Ok(info) = LayerInfo::read_body(data, header.version) else { + if target_key.is_some() { + layer_info = None; + } + return false; + }; layer_info = Some(info); layer_info_placement = LayerInfoPlacement::GlobalBlock { index, signature, key, padding: padding.clone() }; true @@ -389,4 +406,32 @@ mod write_tests { } } } + + #[test] + fn global_layers_prioritized_over_compatibility_section() { + for version in [Version::Psd, Version::Psb] { + for depth in [16, 32] { + let file = testgen::layered(version, ColorMode::Rgb, depth, Compression::Raw); + let key = if depth == 16 { *b"Lr16" } else { *b"Lr32" }; + let mut lr_data = Vec::new(); + file.layer_info.as_ref().unwrap().write_body(&mut lr_data, version).unwrap(); + + // Photoshop writes an empty (or 8-bit compatibility) Layer Info section + // alongside the global Lr16/Lr32 block (#4928). + let mut compat_file = file.clone(); + compat_file.layer_info = + Some(LayerInfo { layers: vec![LayerRecord { name: b"CompatibilityLayer".to_vec(), ..Default::default() }], ..Default::default() }); + compat_file.layer_info_placement = LayerInfoPlacement::Section; + compat_file.global_blocks.insert(0, TaggedBlock::new(key, lr_data)); + + let bytes = compat_file.to_bytes().unwrap(); + let parsed = PsdFile::from_bytes(&bytes).unwrap(); + + assert_eq!(parsed.layers().len(), file.layers().len()); + assert_ne!(parsed.layers()[0].name(), "CompatibilityLayer"); + assert_eq!(parsed.layers()[0].name(), file.layers()[0].name()); + assert!(matches!(parsed.layer_info_placement, LayerInfoPlacement::GlobalBlock { key: k, .. } if k == key)); + } + } + } } From 25a82978b21eea95900cb01d321780a0da7db589 Mon Sep 17 00:00:00 2001 From: LloydNicholson <34131158+LloydNicholson@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:56:21 +0200 Subject: [PATCH 33/47] fix(segment): let Remove Background's cancel stop Select Subject's min cuts (#4878) Select Subject ran every graph cut (the GrabCut iterations and the full-resolution band re-cut in `refine_band`, tile by tile on rayon) through the non-cancellable `grid_cut`, so the Remove Background job only saw Cancel/Esc before and after the whole "Finding subject" step. Add `select_subject_with`, `grabcut_with` and `finish_region_with`, which take an `Interrupt` and return `Err(Cancelled)`: each band tile checks it before reading pixels and its cut runs through `grid_cut_cancellable`, so in-flight tiles stop at the max-flow's next poll and unstarted ones never start. Remove Background runs the step under `JobCtx::stage`. The old functions keep their signatures and results (they pass `Interrupt::NONE`), so `select.subject`, Object Selection and the layer menu are unchanged. Co-authored-by: lloyd Co-authored-by: Claude Opus 5.5 --- crates/algo/src/segment/grabcut.rs | 21 ++++++-- crates/algo/src/segment/mod.rs | 51 +++++++++++++++---- crates/algo/src/segment/subject.rs | 18 +++++-- crates/algo/src/segment/tests.rs | 81 ++++++++++++++++++++++++++++++ crates/engine/src/cutout_cmds.rs | 26 +++++++++- 5 files changed, 177 insertions(+), 20 deletions(-) diff --git a/crates/algo/src/segment/grabcut.rs b/crates/algo/src/segment/grabcut.rs index ae1a586c12f..573c09079e3 100644 --- a/crates/algo/src/segment/grabcut.rs +++ b/crates/algo/src/segment/grabcut.rs @@ -7,9 +7,10 @@ //! `γ e^{−β‖z_m − z_n‖²} / dist(m, n)` (γ = 50, β from the mean squared neighbour difference). use photocraft_geom::Rect; +use photocraft_raster::{Cancelled, Interrupt}; use super::gmm::{DEFAULT_REG, Gmm}; -use super::{FREE, HARD_BG, HARD_FG, Region, RgbImage, Sampler, contrast_beta, data_costs, grid_cut, subsample}; +use super::{FREE, HARD_BG, HARD_FG, Region, RgbImage, Sampler, contrast_beta, data_costs, grid_cut_cancellable, subsample}; /// Definite background. pub const BG: u8 = 0; @@ -33,6 +34,13 @@ fn is_fg(t: u8) -> bool { /// Runs `iters` GrabCut iterations, updating the probable labels of `trimap` in place. Returns /// the final (foreground, background) models, or `None` if either side has no pixels. pub fn grabcut(img: &RgbImage, trimap: &mut [u8], iters: usize) -> Option<(Gmm, Gmm)> { + grabcut_with(img, trimap, iters, &Interrupt::NONE).unwrap_or_default() +} + +/// [`grabcut`] that stops with `Err(Cancelled)` once `ctl` is cancelled, asked each iteration +/// and inside its min cut. `trimap` is then partly updated. +pub fn grabcut_with(img: &RgbImage, trimap: &mut [u8], iters: usize, ctl: &Interrupt) -> Result, Cancelled> { + let cancel = || ctl.cancelled(); let beta = contrast_beta(img); let fixed: Vec = trimap .iter() @@ -44,17 +52,22 @@ pub fn grabcut(img: &RgbImage, trimap: &mut [u8], iters: usize) -> Option<(Gmm, .collect(); let mut models: Option<(Gmm, Gmm)> = None; for _ in 0..iters.max(1) { + ctl.check()?; let (mut fs, mut bs) = (Vec::new(), Vec::new()); for (p, t) in img.px.iter().zip(trimap.iter()) { if is_fg(*t) { fs.push(*p) } else { bs.push(*p) } } let (fs, bs) = (subsample(&fs, MAX_SAMPLES), subsample(&bs, MAX_SAMPLES)); let (fg, bg) = match &models { - None => (Gmm::fit(&fs, K, DEFAULT_REG)?, Gmm::fit(&bs, K, DEFAULT_REG)?), + None => match (Gmm::fit(&fs, K, DEFAULT_REG), Gmm::fit(&bs, K, DEFAULT_REG)) { + (Some(f), Some(b)) => (f, b), + _ => return Ok(None), + }, Some((f, b)) => (f.refit(&fs).unwrap_or_else(|| f.clone()), b.refit(&bs).unwrap_or_else(|| b.clone())), }; let (cf, cb) = data_costs(img, &fg, &bg); - let cut = grid_cut(img, &cf, &cb, &fixed, GAMMA, beta); + // `grid_cut`'s weight, with the cut stopping on cancel. + let cut = grid_cut_cancellable(img, &cf, &cb, &fixed, |d2| GAMMA * (-beta * d2).exp(), &cancel).ok_or(Cancelled)?; let mut changed = 0usize; for (t, fgnd) in trimap.iter_mut().zip(&cut) { if *t == PR_BG || *t == PR_FG { @@ -68,7 +81,7 @@ pub fn grabcut(img: &RgbImage, trimap: &mut [u8], iters: usize) -> Option<(Gmm, break; } } - models + Ok(models) } /// Object Selection (rectangle mode): GrabCut initialised from `rect` (outside = background, diff --git a/crates/algo/src/segment/mod.rs b/crates/algo/src/segment/mod.rs index 90cc2f502cc..5c190e643a8 100644 --- a/crates/algo/src/segment/mod.rs +++ b/crates/algo/src/segment/mod.rs @@ -35,7 +35,7 @@ pub mod slic; pub mod subject; use photocraft_geom::Rect; -use photocraft_raster::Surface; +use photocraft_raster::{Cancelled, Interrupt, Surface}; pub use crate::selection::Region; use gmm::Gmm; @@ -545,6 +545,22 @@ pub fn trim_region(r: Region) -> Option { /// pixels wide around the upsampled boundary, using the given colour models, tile by tile. /// The result is anti-aliased by one pixel. pub fn finish_region(sampler: &dyn Sampler, window: Rect, step: usize, low: &[bool], lw: usize, lh: usize, models: Option<(&Gmm, &Gmm)>) -> Option { + finish_region_with(sampler, window, step, low, lw, lh, models, &Interrupt::NONE).unwrap_or_default() +} + +/// [`finish_region`] that stops with `Err(Cancelled)` once `ctl` is cancelled, asked before each +/// band tile and inside its min cut. +#[allow(clippy::too_many_arguments)] +pub fn finish_region_with( + sampler: &dyn Sampler, + window: Rect, + step: usize, + low: &[bool], + lw: usize, + lh: usize, + models: Option<(&Gmm, &Gmm)>, + ctl: &Interrupt, +) -> Result, Cancelled> { let (w, h) = (window.width() as usize, window.height() as usize); let mut mask = vec![0u8; w * h]; if step <= 1 { @@ -582,16 +598,28 @@ pub fn finish_region(sampler: &dyn Sampler, window: Rect, step: usize, low: &[bo #[cfg(target_arch = "wasm32")] mask.chunks_mut(w).enumerate().for_each(|(y, r)| fill(y, r)); if let Some((fg, bg)) = models { - refine_band(sampler, window, step, low, &band, lw, lh, fg, bg, &mut mask); + refine_band(sampler, window, step, low, &band, lw, lh, fg, bg, &mut mask, ctl)?; } } antialias_u8(&mut mask, w, h); - trim_region(Region { bbox: window, mask }) + Ok(trim_region(Region { bbox: window, mask })) } /// Full-resolution graph cut restricted to the band blocks (see [`finish_region`]). #[allow(clippy::too_many_arguments)] -fn refine_band(sampler: &dyn Sampler, window: Rect, step: usize, low: &[bool], band: &[bool], lw: usize, lh: usize, fg: &Gmm, bg: &Gmm, mask: &mut [u8]) { +fn refine_band( + sampler: &dyn Sampler, + window: Rect, + step: usize, + low: &[bool], + band: &[bool], + lw: usize, + lh: usize, + fg: &Gmm, + bg: &Gmm, + mask: &mut [u8], + ctl: &Interrupt, +) -> Result<(), Cancelled> { const T: i32 = 192; const HALO: i32 = 12; let w = window.width() as usize; @@ -612,7 +640,9 @@ fn refine_band(sampler: &dyn Sampler, window: Rect, step: usize, low: &[bool], b } ty += T; } - let run = |t: &Rect| -> (Rect, Vec) { + let cancel = || ctl.cancelled(); + let run = |t: &Rect| -> Result<(Rect, Vec), Cancelled> { + ctl.check()?; let r = t.inflate(HALO).intersect(&window); let img = sampler.rgb(r); let (rw, rh) = (r.width() as usize, r.height() as usize); @@ -635,7 +665,8 @@ fn refine_band(sampler: &dyn Sampler, window: Rect, step: usize, low: &[bool], b } } let beta = contrast_beta(&img); - let cut = grid_cut(&img, &cfg, &cbg, &fixed, 50.0, beta); + // `grid_cut`'s weight, with the cut stopping on cancel. + let cut = grid_cut_cancellable(&img, &cfg, &cbg, &fixed, |d2| 50.0 * (-beta * d2).exp(), &cancel).ok_or(Cancelled)?; let (cw, ch) = (t.width() as usize, t.height() as usize); let mut core = Vec::with_capacity(cw * ch); for yy in 0..ch { @@ -643,15 +674,16 @@ fn refine_band(sampler: &dyn Sampler, window: Rect, step: usize, low: &[bool], b let sx = (t.x0 - r.x0) as usize; core.extend_from_slice(&cut[sy * rw + sx..sy * rw + sx + cw]); } - (*t, core) + Ok((*t, core)) }; + // A cancelled tile ends the collect; tiles not yet started see `ctl` and return at once. #[cfg(not(target_arch = "wasm32"))] let results: Vec<(Rect, Vec)> = { use rayon::prelude::*; - tiles.par_iter().map(run).collect() + tiles.par_iter().map(run).collect::>()? }; #[cfg(target_arch = "wasm32")] - let results: Vec<(Rect, Vec)> = tiles.iter().map(run).collect(); + let results: Vec<(Rect, Vec)> = tiles.iter().map(run).collect::>()?; for (t, core) in results { let cw = t.width() as usize; for (yy, row) in core.chunks_exact(cw).enumerate() { @@ -661,6 +693,7 @@ fn refine_band(sampler: &dyn Sampler, window: Rect, step: usize, low: &[bool], b } } } + Ok(()) } /// Deterministic pseudo-random numbers (xorshift) for sampling and tests. diff --git a/crates/algo/src/segment/subject.rs b/crates/algo/src/segment/subject.rs index 6002672adb7..b117bb5ef6c 100644 --- a/crates/algo/src/segment/subject.rs +++ b/crates/algo/src/segment/subject.rs @@ -23,6 +23,7 @@ use std::cmp::Ordering; use std::collections::BinaryHeap; use photocraft_geom::Rect; +use photocraft_raster::{Cancelled, Interrupt}; use super::grabcut::{self, BG, PR_BG, PR_FG}; use super::slic::{rgb_to_lab, slic_lab}; @@ -35,13 +36,20 @@ const SIGMA_SPA: f32 = 0.25; /// Selects the most salient object on the canvas. pub fn select_subject(sampler: &dyn Sampler, canvas: Rect) -> Option { + select_subject_with(sampler, canvas, &Interrupt::NONE).unwrap_or_default() +} + +/// [`select_subject`] that stops with `Err(Cancelled)` once `ctl` is cancelled, asked between +/// stages and inside every min cut (the GrabCut iterations and the full-resolution re-cut). +pub fn select_subject_with(sampler: &dyn Sampler, canvas: Rect, ctl: &Interrupt) -> Result, Cancelled> { if canvas.width() < 4 || canvas.height() < 4 { - return None; + return Ok(None); } let step = super::scale_for(canvas, WORK_PX); let img = sampler.rgb_scaled(canvas, step); let (w, h) = (img.w, img.h); let sal = saliency(&img); + ctl.check()?; let t = otsu(&sal).clamp(0.15, 0.85); let mut trimap = vec![PR_BG; w * h]; let mut any_fg = false; @@ -60,15 +68,15 @@ pub fn select_subject(sampler: &dyn Sampler, canvas: Rect) -> Option { } } if !any_fg { - return None; + return Ok(None); } - let (fg, bg) = grabcut::grabcut(&img, &mut trimap, 6)?; + let Some((fg, bg)) = grabcut::grabcut_with(&img, &mut trimap, 6, ctl)? else { return Ok(None) }; let low: Vec = trimap.iter().map(|v| *v == PR_FG || *v == grabcut::FG).collect(); let low = super::clean_mask(&low, w, h, 0.2, 0.05); if !low.iter().any(|v| *v) { - return None; + return Ok(None); } - super::finish_region(sampler, canvas, step, &low, w, h, Some((&fg, &bg))) + super::finish_region_with(sampler, canvas, step, &low, w, h, Some((&fg, &bg)), ctl) } /// Per-pixel saliency in `0..=1` (see the module docs). diff --git a/crates/algo/src/segment/tests.rs b/crates/algo/src/segment/tests.rs index 869f3c67177..55ddc6cafdf 100644 --- a/crates/algo/src/segment/tests.rs +++ b/crates/algo/src/segment/tests.rs @@ -565,6 +565,87 @@ fn select_subject_finds_centred_object() { assert!(v > 0.9, "IoU {v}"); } +/// Counts full-resolution reads; the working copy goes through `rgb_scaled` uncounted, so each +/// read is one band tile of the full-resolution re-cut. +struct TileReads<'a>(ImageSampler<'a>, std::sync::atomic::AtomicUsize); + +impl Sampler for TileReads<'_> { + fn rgba(&self, r: Rect) -> Vec<[f32; 4]> { + self.1.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + self.0.rgba(r) + } + fn rgb_scaled(&self, r: Rect, step: usize) -> RgbImage { + self.0.rgb_scaled(r, step) + } +} + +#[test] +fn finish_region_cancel_stops_inside_the_band_cut() { + use photocraft_raster::{Cancelled, Interrupt}; + use std::sync::atomic::{AtomicUsize, Ordering::SeqCst}; + // One 180-pixel window is one band tile, and nothing asks after the band re-cut, so a cancel + // seen after the tile's own check can only have been seen inside its min cut. + let (n, step) = (180, 3); + let (img, truth) = disc_scene(n, n, 90.0, 90.0, 55.0, 4); + let s = ImageSampler { img: &img, origin: (0, 0) }; + let ln = n / step; + let low: Vec = (0..ln * ln).map(|i| truth[((i / ln) * step + 1) * n + (i % ln) * step + 1]).collect(); + let (fs, bs): (Vec<_>, Vec<_>) = img.px.iter().zip(&truth).partition(|(_, t)| **t); + let fit = |v: Vec<(&[f32; 3], &bool)>| Gmm::fit(&v.iter().map(|(p, _)| **p).collect::>(), 5, gmm::DEFAULT_REG).unwrap(); + let (fg, bg) = (fit(fs), fit(bs)); + let window = Rect::new(0, 0, n as i32, n as i32); + let polls = AtomicUsize::new(0); + let finish = |cancel_from: usize| { + polls.store(0, SeqCst); + let cancel = || polls.fetch_add(1, SeqCst) + 1 >= cancel_from; + finish_region_with(&s, window, step, &low, ln, ln, Some((&fg, &bg)), &Interrupt::cancel_only(&cancel)) + }; + let reg = finish(usize::MAX).unwrap().unwrap(); + assert!(polls.load(SeqCst) >= 2, "the cut polls too ({} polls)", polls.load(SeqCst)); + assert!(iou(®, &truth, n, n) > 0.95); + assert_eq!(finish_region(&s, window, step, &low, ln, ln, Some((&fg, &bg))), Some(reg)); + // Cancelled at the cut's first poll (the second in all): no region. + assert_eq!(finish(2), Err(Cancelled)); + assert_eq!(polls.load(SeqCst), 2); + assert_eq!(finish(1), Err(Cancelled), "cancelled before the tile"); +} + +#[test] +fn select_subject_cancelled_in_the_band_recut_reads_no_more_tiles() { + use photocraft_raster::{Cancelled, Interrupt}; + use std::sync::atomic::{AtomicUsize, Ordering::SeqCst}; + let (w, h) = (3000, 2000); + let (img, truth) = disc_scene(w, h, 1500.0, 1000.0, 700.0, 6); + let canvas = Rect::new(0, 0, w as i32, h as i32); + let sampler = || TileReads(ImageSampler { img: &img, origin: (0, 0) }, AtomicUsize::new(0)); + + // Not cancelled: the same region as `select_subject`, and the band cuts themselves ask + // (more polls once tiles are read than the one check before each tile). + let s = sampler(); + let band_polls = AtomicUsize::new(0); + let count = || { + if s.1.load(SeqCst) > 0 { + band_polls.fetch_add(1, SeqCst); + } + false + }; + let reg = subject::select_subject_with(&s, canvas, &Interrupt::cancel_only(&count)).unwrap().unwrap(); + let tiles = s.1.load(SeqCst); + assert!(tiles > 4, "{tiles} band tiles"); + assert!(band_polls.load(SeqCst) > tiles, "{} polls over {tiles} tiles", band_polls.load(SeqCst)); + assert!(iou(®, &truth, w, h) > 0.9); + assert_eq!(subject::select_subject(&sampler(), canvas), Some(reg)); + + // Cancelled once the first tile is read: the run stops there instead of re-cutting every + // tile. One thread, so no other tile is already in flight. + let s = sampler(); + let cancel = || s.1.load(SeqCst) > 0; + let pool = rayon::ThreadPoolBuilder::new().num_threads(1).build().unwrap(); + let r = pool.install(|| subject::select_subject_with(&s, canvas, &Interrupt::cancel_only(&cancel))); + assert_eq!(r, Err(Cancelled)); + assert_eq!(s.1.load(SeqCst), 1, "tiles read after the cancel"); +} + #[test] fn saliency_highlights_the_object() { let (img, truth) = disc_scene(120, 90, 60.0, 45.0, 22.0, 8); diff --git a/crates/engine/src/cutout_cmds.rs b/crates/engine/src/cutout_cmds.rs index baee260bee0..d5cc7346682 100644 --- a/crates/engine/src/cutout_cmds.rs +++ b/crates/engine/src/cutout_cmds.rs @@ -45,8 +45,12 @@ fn run(s: &mut Session, p: &Value) -> Result { s, "Remove Background", move |doc, _, ctx| { - ctx.progress(0.1, "Finding subject"); - let found = with_doc_sampler(doc, Some(id), sample_all, |smp, d| subject::select_subject(smp, d.bounds())); + // Cancel reaches the min cuts inside Select Subject, so Esc doesn't wait for them. + let found = ctx + .stage(0.1, 0.6, "Finding subject", |ctl| { + with_doc_sampler(doc, Some(id), sample_all, |smp, d| subject::select_subject_with(smp, d.bounds(), ctl)) + }) + .map_err(|_| EngineError::Cancelled)?; let mut region = found.ok_or_else(|| EngineError::Other("no subject found".into()))?; if ctx.cancelled() { return Err(EngineError::Cancelled); @@ -200,6 +204,24 @@ mod tests { assert!(l.mask.is_some()); } + #[test] + fn remove_background_cancelled_while_finding_the_subject_changes_nothing() { + let (mut s, _) = disc(8); + let id = active_id(&s); + let before = s.active().unwrap().revision; + // The job's cancel reaches Select Subject, which stops at its first check. + let ctx = crate::jobs::JobCtx::new(); + ctx.cancel(); + s.set_inline_job_ctx(Some(ctx.clone())); + assert!(matches!(s.execute(CMD, json!({})), Err(EngineError::Cancelled))); + assert_eq!(ctx.message(), "Finding subject"); + assert_eq!(s.active().unwrap().revision, before); + assert!(layer(&s, id).mask.is_none()); + s.set_inline_job_ctx(None); + s.execute(CMD, json!({})).unwrap(); + assert!(layer(&s, id).mask.is_some()); + } + #[test] fn remove_background_without_a_subject_leaves_the_document() { let mut s = session_with(32, 32, 8, |_, _| [0.5, 0.5, 0.5]); From 5f8733c74d9c062cbe58185fcad2c083b3acc13f Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Wed, 14 Oct 2026 00:56:28 +0800 Subject: [PATCH 34/47] Speed up retouch blur convolution with exact-output coverage (#4511) Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> --- crates/algo/src/retouch.rs | 62 ++++++++-------- crates/algo/tests/retouch_blur_exact.rs | 90 +++++++++++++++++++++++ crates/engine/examples/bench_blur_tool.rs | 62 ++++++++++++++++ 3 files changed, 185 insertions(+), 29 deletions(-) create mode 100644 crates/algo/tests/retouch_blur_exact.rs create mode 100644 crates/engine/examples/bench_blur_tool.rs diff --git a/crates/algo/src/retouch.rs b/crates/algo/src/retouch.rs index 12aac4c58a2..f7c1c18557e 100644 --- a/crates/algo/src/retouch.rs +++ b/crates/algo/src/retouch.rs @@ -117,49 +117,53 @@ pub fn sponge(c: [f32; 3], amount: f32, saturate: bool, vibrance: bool) -> [f32; pub fn local_blur(data: &[f32], w: usize, h: usize, ch: usize, alpha: Option, win: (usize, usize, usize, usize), sigma: f32) -> Vec { let (x0, y0, x1, y1) = win; let (ow, oh) = (x1 - x0, y1 - y0); + if ow == 0 || oh == 0 || ch == 0 { + return Vec::new(); + } let r = (sigma * 3.0).ceil().max(1.0) as i32; let k: Vec = (-r..=r).map(|i| (-((i * i) as f32) / (2.0 * sigma * sigma).max(1e-6)).exp()).collect(); let ks: f32 = k.iter().sum(); let k: Vec = k.into_iter().map(|v| v / ks).collect(); - let get = |x: i32, y: i32, out: &mut [f32]| { - let (xx, yy) = (x.clamp(0, w as i32 - 1) as usize, y.clamp(0, h as i32 - 1) as usize); - let i = (yy * w + xx) * ch; - out.copy_from_slice(&data[i..i + ch]); - if let Some(a) = alpha { - let av = out[a]; - for (c, v) in out.iter_mut().enumerate() { - if c != a { - *v *= av; + // Horizontal pass over the window rows extended vertically by r. + let th = oh + 2 * r as usize; + let stride = ow * ch; + let mut tmp = vec![0.0f32; stride * th]; + let mut row = vec![0.0f32; (ow + 2 * r as usize) * ch]; + for ty in 0..th { + let y = (y0 as i32 + ty as i32 - r).clamp(0, h as i32 - 1) as usize; + // Clamp and premultiply each source pixel once, rather than once for every kernel tap. + for (rx, px) in row.chunks_exact_mut(ch).enumerate() { + let x = (x0 as i32 + rx as i32 - r).clamp(0, w as i32 - 1) as usize; + let i = (y * w + x) * ch; + px.copy_from_slice(&data[i..i + ch]); + if let Some(a) = alpha { + let av = px[a]; + for (c, v) in px.iter_mut().enumerate() { + if c != a { + *v *= av; + } } } } - }; - // Horizontal pass over the window rows extended vertically by r. - let th = oh + 2 * r as usize; - let mut tmp = vec![0.0f32; ow * th * ch]; - let mut px = vec![0.0f32; ch]; - for ty in 0..th { - let y = y0 as i32 + ty as i32 - r; - for ox in 0..ow { - let o = (ty * ow + ox) * ch; - for (i, kv) in k.iter().enumerate() { - get(x0 as i32 + ox as i32 + i as i32 - r, y, &mut px); - for c in 0..ch { - tmp[o + c] += px[c] * kv; - } + let dst = &mut tmp[ty * stride..(ty + 1) * stride]; + // Contiguous accumulation can be vectorized for any channel layout. Each output still + // sums the same products in the same kernel order as the scalar convolution. + for (i, kv) in k.iter().enumerate() { + for (d, s) in dst.iter_mut().zip(&row[i * ch..i * ch + stride]) { + *d += s * kv; } } } let mut out = vec![0.0f32; ow * oh * ch]; for oy in 0..oh { + let dst = &mut out[oy * stride..(oy + 1) * stride]; + for (i, kv) in k.iter().enumerate() { + for (d, s) in dst.iter_mut().zip(&tmp[(oy + i) * stride..(oy + i + 1) * stride]) { + *d += s * kv; + } + } for ox in 0..ow { let o = (oy * ow + ox) * ch; - for (i, kv) in k.iter().enumerate() { - let s = ((oy + i) * ow + ox) * ch; - for c in 0..ch { - out[o + c] += tmp[s + c] * kv; - } - } if let Some(a) = alpha { let av = out[o + a]; for c in 0..ch { diff --git a/crates/algo/tests/retouch_blur_exact.rs b/crates/algo/tests/retouch_blur_exact.rs new file mode 100644 index 00000000000..871a52b7f4a --- /dev/null +++ b/crates/algo/tests/retouch_blur_exact.rs @@ -0,0 +1,90 @@ +//! Frozen scalar convolution from main 5dac5c6, used as an exact-output oracle. +use photocraft_algo::retouch::local_blur; + +#[test] +fn matches_scalar_blur_bit_for_bit_across_layouts_edges_and_hdr() { + for (w, h) in [(1, 1), (3, 7), (37, 29)] { + for ch in 1..=5 { + for alpha in std::iter::once(None).chain((0..ch).map(Some)) { + let data: Vec = (0..w * h * ch) + .map(|i| if Some(i % ch) == alpha { [0.0, 1e-8, 0.25, 0.75, 1.0][(i / ch) % 5] } else { ((i * 73 + 19) % 113) as f32 / 17.0 - 2.0 }) + .collect(); + for win in [(0, 0, w, h), (w / 2, h / 2, w, h), (0, 0, 1, 1), (0, 0, 0, h)] { + for sigma in [0.0, 0.25, 1.0, 3.0, 4.0, 12.0] { + let expected = scalar_local_blur(&data, w, h, ch, alpha, win, sigma); + let actual = local_blur(&data, w, h, ch, alpha, win, sigma); + assert_eq!(actual.len(), expected.len()); + for (i, (a, b)) in actual.iter().zip(&expected).enumerate() { + assert_eq!(a.to_bits(), b.to_bits(), "{w}x{h} ch={ch} alpha={alpha:?} win={win:?} sigma={sigma} sample={i}"); + } + } + } + } + } + } +} + +#[test] +fn empty_channel_layout_remains_empty() { + assert!(local_blur(&[], 5, 5, 0, None, (0, 0, 5, 5), 4.0).is_empty()); +} + +#[allow(clippy::too_many_arguments)] +fn scalar_local_blur(data: &[f32], w: usize, h: usize, ch: usize, alpha: Option, win: (usize, usize, usize, usize), sigma: f32) -> Vec { + let (x0, y0, x1, y1) = win; + let (ow, oh) = (x1 - x0, y1 - y0); + let r = (sigma * 3.0).ceil().max(1.0) as i32; + let k: Vec = (-r..=r).map(|i| (-((i * i) as f32) / (2.0 * sigma * sigma).max(1e-6)).exp()).collect(); + let ks: f32 = k.iter().sum(); + let k: Vec = k.into_iter().map(|v| v / ks).collect(); + let get = |x: i32, y: i32, out: &mut [f32]| { + let (xx, yy) = (x.clamp(0, w as i32 - 1) as usize, y.clamp(0, h as i32 - 1) as usize); + let i = (yy * w + xx) * ch; + out.copy_from_slice(&data[i..i + ch]); + if let Some(a) = alpha { + let av = out[a]; + for (c, v) in out.iter_mut().enumerate() { + if c != a { + *v *= av; + } + } + } + }; + // Horizontal pass over the window rows extended vertically by r. + let th = oh + 2 * r as usize; + let mut tmp = vec![0.0f32; ow * th * ch]; + let mut px = vec![0.0f32; ch]; + for ty in 0..th { + let y = y0 as i32 + ty as i32 - r; + for ox in 0..ow { + let o = (ty * ow + ox) * ch; + for (i, kv) in k.iter().enumerate() { + get(x0 as i32 + ox as i32 + i as i32 - r, y, &mut px); + for c in 0..ch { + tmp[o + c] += px[c] * kv; + } + } + } + } + let mut out = vec![0.0f32; ow * oh * ch]; + for oy in 0..oh { + for ox in 0..ow { + let o = (oy * ow + ox) * ch; + for (i, kv) in k.iter().enumerate() { + let s = ((oy + i) * ow + ox) * ch; + for c in 0..ch { + out[o + c] += tmp[s + c] * kv; + } + } + if let Some(a) = alpha { + let av = out[o + a]; + for c in 0..ch { + if c != a { + out[o + c] = if av > 1e-6 { out[o + c] / av } else { data[((y0 + oy) * w + x0 + ox) * ch + c] }; + } + } + } + } + } + out +} diff --git a/crates/engine/examples/bench_blur_tool.rs b/crates/engine/examples/bench_blur_tool.rs new file mode 100644 index 00000000000..c4702634402 --- /dev/null +++ b/crates/engine/examples/bench_blur_tool.rs @@ -0,0 +1,62 @@ +//! Reproducible soft Blur strokes on a 24 MP striped document. Run in release mode. +//! Setup, Undo and the output fingerprint are excluded from the timing. +use std::time::Instant; + +use photocraft_engine::{EngineError, Session}; +use photocraft_geom::Rect; +use serde_json::json; + +fn run() -> Result<(), Box> { + for depth in [8, 16, 32] { + let mut session = Session::new(); + session.execute("file.new", json!({"width":6000,"height":4000,"depth":depth,"mode":"rgb"}))?; + session.execute("layer.new.layer", json!({}))?; + session.edit("stripes", |doc, active| { + let id = active.ok_or_else(|| EngineError::Other("no layer".into()))?; + let surface = doc.layer_mut(id).and_then(|l| l.surface_mut()).ok_or_else(|| EngineError::Other("no surface".into()))?; + for x in (0..6000).step_by(64) { + let color = if x % 128 == 0 { [0.9, 0.3, 0.1, 1.0] } else { [0.1, 0.4, 0.8, 1.0] }; + surface.fill_rect(Rect::new(x, 0, (x + 64).min(6000), 4000), &color); + } + Ok(()) + })?; + for size in [100, 500, 1000] { + let params = json!({"points":[[2500,2000],[3500,2000]],"size":size,"hardness":20,"strength":50,"spacing":25}); + let mut times = Vec::new(); + let mut fingerprint = 0u64; + for sample in 0..6 { + let start = Instant::now(); + session.execute("paint.blur", params.clone())?; + let ms = start.elapsed().as_secs_f64() * 1000.0; + if sample > 0 { + times.push(ms); + } + if sample == 5 { + let st = session.active().ok_or("no document")?; + let surface = st.doc.layer(st.active_layer.ok_or("no layer")?).and_then(|l| l.surface()).ok_or("no surface")?; + for y in (1500..2500).step_by(17) { + for x in (2000..4000).step_by(13) { + for channel in surface.rgba(x, y) { + fingerprint = fingerprint.wrapping_mul(16777619) ^ u64::from(channel.to_bits()); + } + } + } + } + session.execute("edit.undo", json!({}))?; + } + times.sort_by(f64::total_cmp); + println!( + "depth={depth} size={size} median_ms={:.3} min_ms={:.3} max_ms={:.3} sampled_fingerprint={fingerprint:016x}", + times[2], times[0], times[4] + ); + } + } + Ok(()) +} + +fn main() { + if let Err(error) = run() { + eprintln!("{error}"); + std::process::exit(1); + } +} From 01b354a34059d5ecbb7c9858c900c5dcf41f4281 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Wed, 14 Oct 2026 01:03:02 +0800 Subject: [PATCH 35/47] Add filename templates to Batch export (#4466) * Add filename templates to Batch export * Keep Batch naming evidence out of the shared gap-summary header * Avoid catalog append conflicts for Batch naming labels * Keep Batch naming labels clear of catalog append conflicts * Keep Batch naming details in the automation parity row * Separate Batch documentation from evolving Actions details --------- Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> --- crates/engine/src/file_cmds.rs | 30 +++-- crates/engine/src/file_cmds/batch_naming.rs | 123 ++++++++++++++++++ crates/engine/src/file_cmds/tests.rs | 128 +++++++++++++++++++ crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 3 +- crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 3 +- crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 3 +- crates/ui-egui/src/i18n/it.tsv | 3 +- crates/ui-egui/src/i18n/ja.tsv | 3 +- crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 3 +- crates/ui-egui/src/i18n/pl.tsv | 3 +- crates/ui-egui/src/i18n/pt-br.tsv | 3 +- crates/ui-egui/src/i18n/ru.tsv | 3 +- crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 3 +- crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/view_cmds.rs | 12 +- crates/ui-egui/tests/batch_export_options.rs | 57 +++++++++ docs/batch-naming.md | 41 ++++++ docs/gaps.md | 5 +- docs/scorecard.md | 2 +- docs/target-app-parity.md | 2 + scorecard/automation.toml | 2 +- 29 files changed, 426 insertions(+), 22 deletions(-) create mode 100644 crates/engine/src/file_cmds/batch_naming.rs create mode 100644 docs/batch-naming.md diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 7470e782311..7689f396792 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -20,6 +20,8 @@ use serde_json::{Value, json}; use crate::commands::{CommandSpec, layer_param}; use crate::{EngineError, Result, Session}; +mod batch_naming; + // ---------- predicates ---------- fn has_doc(s: &Session) -> std::result::Result<(), String> { @@ -931,19 +933,25 @@ pub(crate) fn batch_inputs(p: &Value, cmd: &str) -> Result> { /// Opens each input in a scratch session, runs `f` on it and saves it to `output` as `format` /// (`"same"` keeps the input's extension). Errors per file are collected, not fatal. pub(crate) fn process_files(inputs: &[String], output: &str, format: &str, save: SaveOpts, suffix: &str, f: &dyn Fn(&mut Session) -> Result<()>) -> Value { + let outputs = inputs.iter().map(|path| { + let ext = + if format == "same" { path.rsplit('.').next().unwrap_or("png").to_ascii_lowercase() } else { format.trim_start_matches('.').to_ascii_lowercase() }; + (path.as_str(), join(output, &format!("{}{suffix}.{ext}", stem(path)))) + }); + process_outputs(outputs, save, f) +} + +fn process_outputs<'a>(outputs: impl IntoIterator, save: SaveOpts, f: &dyn Fn(&mut Session) -> Result<()>) -> Value { let mut files = Vec::new(); let mut errors = Vec::new(); let mut written = OutputClaims::default(); - for path in inputs { - let ext = - if format == "same" { path.rsplit('.').next().unwrap_or("png").to_ascii_lowercase() } else { format.trim_start_matches('.').to_ascii_lowercase() }; - let out = join(output, &format!("{}{suffix}.{ext}", stem(path))); + for (path, out) in outputs { let r = (|| -> Result<()> { written.check(&out).map_err(EngineError::Other)?; let bytes = read_file(path)?; let mut scratch = Session::new(); let doc = import(&file_name(path), &bytes)?; - scratch.add_document(doc, Some(path.clone())); + scratch.add_document(doc, Some(path.to_string())); f(&mut scratch)?; let d = scratch.active().ok_or(EngineError::NoDocument)?; save_doc(&d.doc, &out, save)?; @@ -1010,7 +1018,9 @@ fn batch(s: &mut Session, p: &Value) -> Result { } }; let actions = crate::actions_cmds::ActionState { list, playing: s.actions.playing, playback_stack: s.actions.playback_stack.clone(), ..Default::default() }; - let r = process_files(&inputs, &output, &format, SaveOpts::from_params(s, p, cmd)?, "", &|scratch| { + let names = batch_naming::plan(&inputs, &output, &format, p)?; + let save = SaveOpts::from_params(s, p, cmd)?; + let run = |scratch: &mut Session| { scratch.actions = actions.clone(); for (id, params) in &steps { let r = scratch.execute(id, params.clone())?; @@ -1020,7 +1030,11 @@ fn batch(s: &mut Session, p: &Value) -> Result { } } Ok(()) - }); + }; + let r = match names { + Some(names) => process_outputs(inputs.iter().map(String::as_str).zip(names), save, &run), + None => process_files(&inputs, &output, &format, save, "", &run), + }; Ok(r) } @@ -1535,7 +1549,7 @@ pub fn specs() -> Vec { "Batch…", &["File", "Automate"], None, - r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action),"input":folder|[paths],"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, + r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action),"input":folder|[paths],"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"naming":string? (blank keeps source names; {name}, {serial} padded to 3 digits, {date} as YYYY-MM-DD UTC; extension added automatically),"startingSerial":1..4294967295=1 (input position, including failed inputs),"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, native, batch ), diff --git a/crates/engine/src/file_cmds/batch_naming.rs b/crates/engine/src/file_cmds/batch_naming.rs new file mode 100644 index 00000000000..2a32f71328f --- /dev/null +++ b/crates/engine/src/file_cmds/batch_naming.rs @@ -0,0 +1,123 @@ +//! Batch output names are planned before opening or writing files. Names substituted from an +//! input are literal: braces in a source filename must never be interpreted as template tokens. + +use super::{EngineError, Result, Value, join, stem}; + +fn bad(message: impl Into) -> EngineError { + EngineError::BadParams { cmd: "file.automate.batch".into(), msg: message.into() } +} + +enum Part<'a> { + Literal(&'a str), + Name, + Serial, + Date, +} + +fn parse(template: &str) -> Result>> { + let mut parts = Vec::new(); + let mut rest = template; + while let Some(at) = rest.find(['{', '}']) { + parts.push(Part::Literal(&rest[..at])); + rest = &rest[at..]; + if rest.starts_with('}') { + return Err(bad("naming: unmatched closing brace")); + } + let end = rest.find('}').ok_or_else(|| bad("naming: unclosed token"))?; + parts.push(match &rest[1..end] { + "name" => Part::Name, + "serial" => Part::Serial, + "date" => Part::Date, + token => return Err(bad(format!("naming: unknown token {{{token}}}; use {{name}}, {{serial}} or {{date}}"))), + }); + rest = &rest[end + 1..]; + } + parts.push(Part::Literal(rest)); + Ok(parts) +} + +fn expand(parts: &[Part<'_>], name: &str, serial: u32, date: &str) -> String { + let mut out = String::new(); + for part in parts { + match part { + Part::Literal(s) => out.push_str(s), + Part::Name => out.push_str(name), + Part::Serial => out.push_str(&format!("{serial:03}")), + Part::Date => out.push_str(date), + } + } + out +} + +/// An absent or empty template preserves the old filename path exactly. An explicit template +/// generates one component per input; the export format supplies the extension separately. +pub(super) fn plan(inputs: &[String], output: &str, format: &str, params: &Value) -> Result>> { + let start = match params.get("startingSerial") { + None => 1, + Some(n) => { + n.as_u64().and_then(|n| u32::try_from(n).ok()).filter(|&n| n > 0).ok_or_else(|| bad("startingSerial must be an integer from 1 to 4294967295"))? + } + }; + let template = match params.get("naming") { + None => "", + Some(Value::String(s)) => s, + Some(_) => return Err(bad("naming must be a string")), + }; + if template.is_empty() { + return Ok(None); + } + if template.len() > 255 { + return Err(bad("naming must be at most 255 bytes")); + } + let parts = parse(template)?; + let has_serial = parts.iter().any(|p| matches!(p, Part::Serial)); + let timestamp = crate::analysis_cmds::now_iso(); + let date = timestamp.get(..10).unwrap_or(""); + if date.is_empty() && parts.iter().any(|p| matches!(p, Part::Date)) { + return Err(bad("naming: the current UTC date is unavailable")); + } + let mut outputs = Vec::with_capacity(inputs.len()); + for (index, path) in inputs.iter().enumerate() { + let serial = if has_serial { + u32::try_from(index).ok().and_then(|index| start.checked_add(index)).ok_or_else(|| bad("startingSerial plus the input count exceeds 4294967295"))? + } else { + start + }; + let base = expand(&parts, &stem(path), serial, date); + if base.is_empty() || base.ends_with(['.', ' ']) { + return Err(bad("naming must produce a nonempty filename stem without a trailing dot or space")); + } + let ext = + if format == "same" { path.rsplit('.').next().unwrap_or("png").to_ascii_lowercase() } else { format.trim_start_matches('.').to_ascii_lowercase() }; + let name = format!("{base}.{ext}"); + if name.len() > 255 || name.chars().any(|c| c.is_control() || "/\\<>:\"|?*".contains(c)) { + return Err(bad("naming must produce a filename of at most 255 bytes without path separators or reserved characters")); + } + outputs.push(join(output, &name)); + } + // Renaming within the source folder could replace a later input before it is read. Resolve + // existing paths so aliases such as `source/./001.png` cannot bypass the check. The default + // (no template) path keeps its existing in-place behavior. + #[cfg(not(target_arch = "wasm32"))] + { + let sources: std::collections::HashSet<_> = inputs.iter().filter_map(|p| std::fs::canonicalize(p).ok()).collect(); + for output in &outputs { + if std::fs::canonicalize(output).ok().is_some_and(|p| sources.contains(&p)) { + return Err(bad(format!("naming would replace an input file: {output}; choose a different destination or template"))); + } + } + } + Ok(Some(outputs)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn substituted_names_are_literal_and_serials_do_not_wrap_or_truncate() { + let parts = parse("{date}_{name}_{serial}").unwrap(); + assert_eq!(expand(&parts, "photo{serial}", 7, "2026-10-13"), "2026-10-13_photo{serial}_007"); + assert_eq!(expand(&parts, "photo", 1000, "2026-10-13"), "2026-10-13_photo_1000"); + } +} diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index 73c09020a52..99ddce15de5 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -946,6 +946,34 @@ fn templates_open_untitled_without_their_path() { assert_eq!(opened, [("Untitled-1", None), ("Untitled-2", None)]); } +#[test] +fn batch_naming_serial_keeps_same_named_inputs_and_their_pixels() { + let dir = tmp("naming-serial"); + let first = join(&dir, "first"); + let second = join(&dir, "second"); + std::fs::create_dir_all(&first).unwrap(); + std::fs::create_dir_all(&second).unwrap(); + let inputs = [png(&first, "photo.png", 8, 8, "#ff0000"), png(&second, "photo.png", 8, 8, "#0000ff")]; + let out = join(&dir, "out"); + let mut s = Session::new(); + let result = s + .execute( + "file.automate.batch", + json!({ + "input": inputs, "output": out, "steps": [], "format": "png", + "naming": "{name}_{serial}", "startingSerial": 7 + }), + ) + .unwrap(); + assert_eq!(result["errors"], json!([]), "{result}"); + assert_eq!(result["files"], json!([join(&out, "photo_007.png"), join(&out, "photo_008.png")])); + for (name, expected) in [("photo_007.png", [1.0, 0.0, 0.0, 1.0]), ("photo_008.png", [0.0, 0.0, 1.0, 1.0])] { + let saved = photocraft_io::import(name, &std::fs::read(join(&out, name)).unwrap()).unwrap().document; + let pixel = photocraft_compose::render(&saved, Rect::new(1, 1, 2, 2)).px[0]; + assert_eq!(pixel, expected, "{name}"); + } +} + /// Two inputs with one output name: the first result is kept and the second is an error, never /// a silent overwrite listed as written (#422). #[test] @@ -1019,3 +1047,103 @@ fn reveal_in_finder_reveals_the_documents_saved_path() { assert_eq!(r["program"], json!(program)); assert_eq!(r["args"], json!(args)); } +#[test] +fn batch_naming_validates_the_whole_plan_before_writing() { + let dir = tmp("naming-invalid"); + let input = png(&dir, "source.png", 8, 8, "#ff0000"); + let mut s = Session::new(); + for (index, extra) in [ + json!({"naming": "../{name}"}), + json!({"naming": "sub\\{name}"}), + json!({"naming": "C:{name}"}), + json!({"naming": "{unknown}"}), + json!({"naming": "{name"}), + json!({"naming": "name}"}), + json!({"naming": 42}), + json!({"naming": "{serial}", "startingSerial": 0}), + json!({"naming": "{serial}", "startingSerial": 1.5}), + json!({"naming": "{serial}", "startingSerial": "1"}), + json!({"naming": "{serial}", "startingSerial": 4294967295_u64}), + json!({"naming": "x".repeat(256)}), + ] + .into_iter() + .enumerate() + { + let out = join(&dir, &format!("out-{index}")); + let mut params = json!({"input": [input, input], "output": out, "steps": []}); + params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + let error = s.execute("file.automate.batch", params).unwrap_err(); + assert!(matches!(error, EngineError::BadParams { .. }), "{error}"); + assert!(!std::path::Path::new(&out).exists(), "invalid naming wrote outputs: {extra}"); + } +} + +#[test] +fn batch_naming_keeps_serial_positions_after_errors_and_captures_the_utc_date() { + let dir = tmp("naming-errors"); + let input = png(&dir, "good.png", 8, 8, "#0000ff"); + let out = join(&dir, "out"); + let before = crate::analysis_cmds::now_iso(); + let mut s = Session::new(); + let result = s + .execute( + "file.automate.batch", + json!({ + "input": [join(&dir, "missing.png"), input], "output": out, "steps": [], + "naming": "{date}_{serial}_{name}", "startingSerial": 9 + }), + ) + .unwrap(); + let after = crate::analysis_cmds::now_iso(); + assert_eq!(result["errors"].as_array().unwrap().len(), 1, "{result}"); + let files = result["files"].as_array().unwrap(); + assert_eq!(files.len(), 1, "{result}"); + let name = file_name(files[0].as_str().unwrap()); + assert!( + name == format!("{}_010_good.png", &before[..10]) || name == format!("{}_010_good.png", &after[..10]), + "the missing input still consumes its serial: {name}" + ); +} + +#[test] +fn batch_naming_still_reports_collisions_instead_of_overwriting() { + let dir = tmp("naming-collision"); + let red = png(&dir, "red.png", 8, 8, "#ff0000"); + let blue = png(&dir, "blue.png", 8, 8, "#0000ff"); + let out = join(&dir, "out"); + let mut s = Session::new(); + let result = s + .execute( + "file.automate.batch", + json!({ + "input": [red, blue], "output": out, "steps": [], "naming": "constant" + }), + ) + .unwrap(); + assert_eq!(result["files"], json!([join(&out, "constant.png")])); + assert_eq!(result["errors"].as_array().unwrap().len(), 1, "{result}"); + assert!(result["errors"][0]["error"].as_str().unwrap().contains("not written")); + let saved = photocraft_io::import("constant.png", &std::fs::read(join(&out, "constant.png")).unwrap()).unwrap().document; + assert_eq!(photocraft_compose::render(&saved, Rect::new(1, 1, 2, 2)).px[0], [1.0, 0.0, 0.0, 1.0]); +} +#[test] +fn batch_naming_rejects_outputs_that_replace_inputs_before_processing() { + let dir = tmp("naming-input-alias"); + let first = png(&dir, "002.png", 8, 8, "#ff0000"); + let second = png(&dir, "001.png", 8, 8, "#0000ff"); + let before = [std::fs::read(&first).unwrap(), std::fs::read(&second).unwrap()]; + let mut s = Session::new(); + // Without preflight, processing 002 first writes 001, so the next input's original pixels + // are lost before it is read. The full plan must be rejected without writing either file. + let error = s + .execute( + "file.automate.batch", + json!({ + "input": [first, second], "output": join(&dir, "."), "steps": [], "naming": "{serial}" + }), + ) + .unwrap_err(); + assert!(error.to_string().contains("input"), "{error}"); + assert_eq!(std::fs::read(&first).unwrap(), before[0]); + assert_eq!(std::fs::read(&second).unwrap(), before[1]); +} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 41c4c03867d..d730fae7abc 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3726,6 +3726,8 @@ navigator Zoom Zvětšení @plural Saved {n} action to {file}|Saved {n} actions to {file} Uložena {n} akce do souboru {file}|Uloženy {n} akce do souboru {file}|Uloženo {n} akcí do souboru {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Vynechán {n} krok bez obdoby v Photoshopu|Vynechány {n} kroky bez obdoby v Photoshopu|Vynecháno {n} kroků bez obdoby v Photoshopu + File Naming Pojmenování souborů + Starting serial Počáteční pořadové číslo # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Vložit zastavení… Save Set… Uložit sadu… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 29b718c2553..6b13188c737 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2760,6 +2760,8 @@ curves Smooth Glätten @plural Saved {n} action to {file}|Saved {n} actions to {file} {n} Aktion in {file} gespeichert|{n} Aktionen in {file} gespeichert @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} Schritt ohne Photoshop-Entsprechung ausgelassen|{n} Schritte ohne Photoshop-Entsprechung ausgelassen + File Naming Dateibenennung + Starting serial Startnummer # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Stopp einfügen… Save Set… Set speichern… diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 82f2a5de64b..11590ca3610 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1027,7 +1027,6 @@ Choose… Επιλογή… Select Folder… Επιλογή φακέλου… Override Action "Save" Commands Παράκαμψη εντολών αποθήκευσης της ενέργειας - File Naming Ονομασία αρχείων Compatibility Συμβατότητα Include ICC Profile Συμπερίληψη προφίλ ICC Error File Αρχείο σφαλμάτων @@ -3815,6 +3814,8 @@ curves Smooth Εξομάλυνση @plural Saved {n} action to {file}|Saved {n} actions to {file} Αποθηκεύτηκε {n} ενέργεια στο {file}|Αποθηκεύτηκαν {n} ενέργειες στο {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Παραλείφθηκε {n} βήμα χωρίς αντίστοιχο στο Photoshop|Παραλείφθηκαν {n} βήματα χωρίς αντίστοιχο στο Photoshop + File Naming Ονομασία αρχείων + Starting serial Αρχικός αριθμός σειράς # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Εισαγωγή διακοπής… Save Set… Αποθήκευση συνόλου… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e47aa6cba10..addcea851d4 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2801,6 +2801,8 @@ curves Smooth Suavizar @plural Saved {n} action to {file}|Saved {n} actions to {file} Se guardó {n} acción en {file}|Se guardaron {n} acciones en {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} paso sin equivalente en Photoshop omitido|{n} pasos sin equivalente en Photoshop omitidos + File Naming Nombres de archivo + Starting serial Número de serie inicial # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Insertar detención… Save Set… Guardar conjunto… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 8085f5fdfb6..1620ff821f6 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2470,7 +2470,6 @@ Choose… Choisir… Select Folder… Sélectionner un dossier… Override Action "Save" Commands Ignorer les commandes « Enregistrer » de l'action - File Naming Dénomination des fichiers Compatibility Compatibilité Mac OS Mac OS Unix Unix @@ -3119,6 +3118,8 @@ curves Smooth Lisser @plural Saved {n} action to {file}|Saved {n} actions to {file} {n} action enregistrée dans {file}|{n} actions enregistrées dans {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} étape sans équivalent Photoshop omise|{n} étapes sans équivalent Photoshop omises + File Naming Dénomination des fichiers + Starting serial Numéro de série initial # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Insérer un arrêt… Save Set… Enregistrer l’ensemble… diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 57734795ead..6a5c28999af 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4020,6 +4020,7 @@ cameraRaw Yellow Sárga @plural Saved {n} action to {file}|Saved {n} actions to {file} {n} művelet mentve. Cél: {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} Photoshop-megfelelő nélküli lépés kihagyva + Starting serial Kezdő sorszám # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Leállítás beszúrása… Save Set… Készlet mentése… diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 95ebe980cbc..8b8802e98a0 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2461,7 +2461,6 @@ Choose… Pilih… Select Folder… Pilih Folder… Override Action "Save" Commands Timpa Perintah Action "Save" - File Naming Penamaan Berkas Compatibility Kompatibilitas Mac OS Mac OS Unix Unix @@ -3106,6 +3105,8 @@ curves Smooth Haluskan @plural Saved {n} action to {file}|Saved {n} actions to {file} {n} tindakan disimpan ke {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} langkah tanpa padanan Photoshop dilewati + File Naming Penamaan Berkas + Starting serial Nomor urut awal # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Sisipkan Berhenti… Save Set… Simpan Set… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index db23b01e123..0034b37ba05 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3014,7 +3014,6 @@ curves Smooth Leviga Choose… Scegli… Select Folder… Seleziona cartella… Override Action "Save" Commands Sostituisci i comandi "Salva" dell'azione - File Naming Denominazione file Compatibility Compatibilità Mac OS Mac OS Unix Unix @@ -4082,6 +4081,8 @@ navigator Zoom Zoom @plural Saved {n} action to {file}|Saved {n} actions to {file} Salvata {n} azione in {file}|Salvate {n} azioni in {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Omesso {n} passaggio senza equivalente in Photoshop|Omessi {n} passaggi senza equivalente in Photoshop + File Naming Denominazione file + Starting serial Numero di serie iniziale # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Inserisci interruzione… Save Set… Salva set… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 040db7e8b37..2e13e3df7fd 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3306,7 +3306,6 @@ fontWeight Italic 斜体 Exposure Program 露出プログラム Extra Bold エクストラボールド Extra Light エクストラライト - File Naming ファイル名の付け方 Fill Light フィルライト First Color Readout 最初のカラー読み取り Flash フラッシュ @@ -3724,6 +3723,8 @@ curves Smooth スムーズ @plural Saved {n} action to {file}|Saved {n} actions to {file} {file} にアクション {n} 個を保存しました @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Photoshop に対応するもののない {n} 個のステップを省きました + File Naming ファイル名の付け方 + Starting serial 開始番号 # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… ストップを挿入… Save Set… セットを保存… diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 1c26d44ee90..89b0c613dcb 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3291,6 +3291,8 @@ curves Smooth 매끄럽게 @plural Saved {n} action to {file}|Saved {n} actions to {file} {file}에 액션 {n}개를 저장했습니다 @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Photoshop에 해당하는 기능이 없는 {n}개 단계를 제외했습니다 + File Naming 파일 이름 지정 + Starting serial 시작 일련번호 # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… 정지 삽입… Save Set… 세트 저장… diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 35bad8fcd3b..4bb9d7ccefe 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2853,7 +2853,6 @@ cameraRaw Color Kleur Choose… Kiezen… Select Folder… Map selecteren… Override Action "Save" Commands Opdrachten "Opslaan" in actie negeren - File Naming Bestandsnamen Compatibility Compatibiliteit Include ICC Profile ICC-profiel opnemen Error File Foutbestand @@ -3877,6 +3876,8 @@ curves Smooth Vloeiend maken @plural Saved {n} action to {file}|Saved {n} actions to {file} {n} handeling opgeslagen in {file}|{n} handelingen opgeslagen in {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} stap zonder Photoshop-equivalent weggelaten|{n} stappen zonder Photoshop-equivalent weggelaten + File Naming Bestandsnamen + Starting serial Beginnummer # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Stop invoegen… Save Set… Set opslaan… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 2d63948d348..04797ddc2ef 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2494,7 +2494,6 @@ Choose… Wybierz… Select Folder… Wybierz folder… Override Action "Save" Commands Zastąp polecenia „Zapisz” w akcji - File Naming Nazewnictwo plików Compatibility Zgodność Mac OS Mac OS Unix Unix @@ -3715,6 +3714,8 @@ curves Smooth Wygładź @plural Saved {n} action to {file}|Saved {n} actions to {file} Zapisano {n} operację do {file}|Zapisano {n} operacje do {file}|Zapisano {n} operacji do {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Pominięto {n} krok bez odpowiednika w Photoshopie|Pominięto {n} kroki bez odpowiednika w Photoshopie|Pominięto {n} kroków bez odpowiednika w Photoshopie + File Naming Nazewnictwo plików + Starting serial Początkowy numer seryjny # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Wstaw zatrzymanie… Save Set… Zapisz zestaw… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 7662e3f03d4..df6c092ba8c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2466,7 +2466,6 @@ Choose… Escolher… Select Folder… Selecionar pasta… Override Action "Save" Commands Substituir comandos "Salvar" da ação - File Naming Nomenclatura de arquivos Compatibility Compatibilidade Mac OS Mac OS Unix Unix @@ -3907,6 +3906,8 @@ layerLabel No Color Sem cor @plural Saved {n} action to {file}|Saved {n} actions to {file} {n} ação salva em {file}|{n} ações salvas em {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} etapa sem equivalente no Photoshop foi omitida|{n} etapas sem equivalente no Photoshop foram omitidas + File Naming Nomenclatura de arquivos + Starting serial Número de série inicial # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Inserir parada… Save Set… Salvar conjunto… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index ea320c153ed..3acd552a590 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1403,7 +1403,6 @@ Choose… Выбрать… Select Folder… Выбрать папку… Override Action "Save" Commands Переопределить команды «Сохранить» в операции - File Naming Именование файлов Compatibility Совместимость Windows Windows Mac OS Mac OS @@ -3063,6 +3062,8 @@ curves Smooth Сгладить @plural Saved {n} action to {file}|Saved {n} actions to {file} Сохранена {n} операция в {file}|Сохранены {n} операции в {file}|Сохранено {n} операций в {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Пропущен {n} шаг без эквивалента в Photoshop|Пропущены {n} шага без эквивалента в Photoshop|Пропущено {n} шагов без эквивалента в Photoshop + File Naming Именование файлов + Starting serial Начальный порядковый номер # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Вставить остановку… Save Set… Сохранить набор… diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index a4dbb575d9e..5719f0bd899 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3831,6 +3831,7 @@ cameraData Lens Lens @plural Saved {n} action to {file}|Saved {n} actions to {file} {n} eylem {file} dosyasına kaydedildi @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Photoshop karşılığı olmayan {n} adım atlandı + Starting serial Başlangıç sıra numarası # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Durdurma Ekle… Save Set… Seti Kaydet… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 1367c4c7979..6ec48d1dcd0 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1382,7 +1382,6 @@ Choose… Вибрати… Select Folder… Вибрати теку… Override Action "Save" Commands Перевизначити команди «Зберегти» в дії - File Naming Іменування файлів Compatibility Сумісність Windows Windows Mac OS Mac OS @@ -3816,6 +3815,8 @@ curves Smooth Згладити @plural Saved {n} action to {file}|Saved {n} actions to {file} Збережено {n} операцію до {file}|Збережено {n} операції до {file}|Збережено {n} операцій до {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Пропущено {n} крок без відповідника в Photoshop|Пропущено {n} кроки без відповідника в Photoshop|Пропущено {n} кроків без відповідника в Photoshop + File Naming Іменування файлів + Starting serial Початковий порядковий номер # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Вставити зупинку… Save Set… Зберегти набір… diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index c1becf4b373..24b63ba6144 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3599,6 +3599,8 @@ curves Smooth Làm mượt @plural Saved {n} action to {file}|Saved {n} actions to {file} Đã lưu {n} hành động vào {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Đã bỏ qua {n} bước không có chức năng tương ứng trong Photoshop + File Naming Đặt tên tệp + Starting serial Số thứ tự bắt đầu # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Chèn điểm dừng… Save Set… Lưu bộ… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 23843a8aa4a..bc729fb1317 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2726,6 +2726,8 @@ curves Smooth 平滑 @plural Saved {n} action to {file}|Saved {n} actions to {file} 已将 {n} 个动作存储到 {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out 已省略 {n} 个没有 Photoshop 对应功能的步骤 + File Naming 文件命名 + Starting serial 起始序号 # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… 插入停止… Save Set… 存储组… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index cbeba57d56c..ff84651797e 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2711,6 +2711,8 @@ curves Smooth 平滑 @plural Saved {n} action to {file}|Saved {n} actions to {file} 已將 {n} 個動作儲存到 {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out 已略過 {n} 個沒有 Photoshop 對應功能的步驟 + File Naming 檔案命名 + Starting serial 起始序號 # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… 插入停止… Save Set… 儲存組合… diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index f18c4730e24..5e6d2523345 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -1060,6 +1060,16 @@ pub fn form_body(ui: &mut egui::Ui, f: &mut Map) { egui::Grid::new("form-dialog").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| { for k in keys { let v = f.get(&k).cloned().unwrap_or(Value::Null); + if k == "naming" && f.get("__command").and_then(Value::as_str) == Some("file.automate.batch") { + let label = ui.label(tl!("File Naming")); + let mut naming = v.as_str().unwrap_or_default().to_owned(); + ui.add(egui::TextEdit::singleline(&mut naming).desired_width(260.0).hint_text("{name}")) + .labelled_by(label.id) + .on_hover_text("{name} · {serial} · {date} (UTC)"); + f.insert(k, json!(naming)); + ui.end_row(); + continue; + } if k == "quality" && f.get("__command").and_then(Value::as_str) == Some("file.automate.batch") { ui.label(label(&k)); let mut custom = v.as_f64().is_some(); @@ -1309,7 +1319,7 @@ fn front(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option= label.right() && r.left() < label.right() + 100.0 && (r.center().y - label.center().y).abs() < 3.0 + }) + .expect("starting serial number control") + .click(); + h.run_steps(2); + h.event(egui::Event::Text("7".into())); + h.run_steps(3); + h.get_by_label("Starting serial").click(); + h.run_steps(3); + assert_eq!(h.state().ui.dialogs.last().unwrap().fields["startingSerial"], 7, "typing changes the starting serial"); + evidence(&mut h, "batch-naming"); + h.get_by_label("OK").click(); + h.run_steps(5); + assert!(h.state().ui.dialogs.is_empty()); + let expected: Vec<_> = source.data().iter().map(|v| 255 - v).collect(); + for name in ["007_first.png", "008_second.png"] { + let bytes = std::fs::read(output.join(name)).expect("dialog naming reaches the exporter"); + let decoded = photocraft_codecs::decode_as(Format::Png, &bytes).unwrap(); + assert_eq!(decoded.data(), expected, "the recorded Invert action still runs"); + } + assert!(!output.join("first.png").exists()); + assert!(!output.join("second.png").exists()); +} + #[test] fn batch_default_quality_keeps_lossless_webp_and_existing_jpeg_bytes() { let _gpu = photocraft_testkit::gpu_lock(); diff --git a/docs/batch-naming.md b/docs/batch-naming.md new file mode 100644 index 00000000000..a922803f81e --- /dev/null +++ b/docs/batch-naming.md @@ -0,0 +1,41 @@ +# Batch output naming + +Select a recorded action, then open **File > Automate > Batch**. Set the input and output +folders. **File Naming** accepts a filename stem template; the selected export format supplies +the extension. Leave it blank to keep the existing source filenames and collision reporting. + +| Token | Value | +| --- | --- | +| `{name}` | Input filename without its last extension | +| `{serial}` | Input position plus **Starting serial** (default 1), padded to at least three digits | +| `{date}` | UTC date at the start of the batch, in `YYYY-MM-DD` form | + +For example, `{name}_{serial}` with starting serial 7 writes `photo_007.png` and +`photo_008.png` for two PNG inputs named `photo.png`. A serial after 999 becomes 1000; +it does not wrap. Folder inputs use the existing filename order, and an explicit input array +keeps its order. Failed inputs consume their position, so later filenames do not shift. +Braces in an input filename are literal text, not additional tokens. + +Unknown tokens, unmatched braces, invalid parameter types, path separators, reserved filename +characters, excessive filename length (255 UTF-8 bytes), and serial overflow are rejected before +export writes begin. Starting serial must be an integer from 1 to 4,294,967,295. +A custom template that resolves to an existing input file is also rejected; choose a separate +destination or another template. Ordinary per-file read, action and save failures still appear +in the batch's `errors` result. When two results share a name, including a case-only difference, +the first successful result is kept and the later collision is reported. + +The same options are available on `file.automate.batch` through the engine command interface: + +```json +{ + "input": ["first/photo.png", "second/photo.png"], + "output": "exports", + "steps": [["image.adjustments.invert", {}]], + "format": "png", + "naming": "{name}_{serial}", + "startingSerial": 7 +} +``` + +The CLI can invoke this command with `run --cmd file.automate.batch --params ...`. +This option belongs to Batch; Image Processor and droplet options retain their existing behavior. diff --git a/docs/gaps.md b/docs/gaps.md index 9ad8fc851d4..a0edbaff795 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -88,7 +88,10 @@ what an agent can't finish alone. - **Estimate:** 120–200 h. ### G11. Actions and scripting -- **Missing:** document-independent action recording (AUTO-219-1), Batch recursion and naming +- **2026-10-13 evidence:** Batch supports [output naming templates](batch-naming.md) using + `{name}`, `{serial}` and a captured UTC `{date}`, with a starting serial number. Colliding + names remain errors; custom output names cannot replace inputs. AUTO-219-5 stays partial. +- **Missing:** document-independent action recording (AUTO-219-1), Batch recursion, open-document inputs, progress/cancel and an error-log file (AUTO-219-5), ExtendScript/UXP compatibility. `.atn` steps outside the mapped events (paths, scripts, Generative Fill, Filter Gallery effects other than Spatter, Duplicate Layers into a new document, Bitmap halftone screens…) load as kept Photoshop steps diff --git a/docs/scorecard.md b/docs/scorecard.md index 078ce21b2a1..92f9315ebd7 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -231,7 +231,7 @@ Menu wiring (`docs/parity-checklist.md`): 631 of 631 menu items live (100.0%). T | AUTO-219-2 | Actions panel: step and dialog toggles, reorder, delete a step, Stop, sets, .atn import | done | [#219](https://github.com/storytold/photocraft/issues/219) | 2026-10-12: set folders (collapsible, drag between sets, actions.assignSet), dialog toggle column (playback opens the step's dialog seeded with its values and resumes on OK; actions.dialog), Insert Stop… (actions.insert), step toggles, reorder, delete a step; Load/Save Actions (.atn) in the panel menu, CLI batch and actions.load/save (atn_cmds.rs): 90 Photoshop events map to commands both ways, the rest are kept verbatim and fail at playback by name; step toggles, reorder and delete a step done; Stop steps play (actions.stopMessage) but there is no Insert Stop menu item; sets and dialog toggles are stored and round-trip but the panel neither groups by set nor edits the dialog toggle | | AUTO-219-3 | actions.record/stop/play/list commands for agents and the CLI | done | [#219](https://github.com/storytold/photocraft/issues/219) | actions.list/get/record/stop/play/delete in actions_cmds.rs; the panel, CLI and MCP share them. Nested steps pass the session authorize hook. | | AUTO-219-4 | Replay tests for crop, filters, selections and styled text on a second document | done | [#219](https://github.com/storytold/photocraft/issues/219) | actions_cmds.rs: a_recorded_edit_run_replays_on_a_second_document records a crop, a Gaussian Blur, a selection fill and faux-bold type on one document and replays all of them onto a second (plus the original layer/select/fill replay) | -| AUTO-219-5 | Batch: subfolders, open documents, naming template, progress/cancel, error log, stop | partial | [#219](https://github.com/storytold/photocraft/issues/219) | file_cmds.rs:590-631 folder/array input, per-file errors; no recursion, template or cancel | +| AUTO-219-5 | Batch: subfolders, open documents, naming template, progress/cancel, error log, stop | partial | [#219](https://github.com/storytold/photocraft/issues/219) | file_cmds.rs and file_cmds/batch_naming.rs: folder/array input, per-file errors, {name}/{serial}/{date} output templates and starting serial; no recursion, open-documents source, cancel or error-log file | | AUTO-219-6 | Plug-in ABI v2: panel kind, progress/cancel calls, read other layers, Last Filter repeats | missing | [#219](https://github.com/storytold/photocraft/issues/219) | runtime.rs:15 ABI_VERSION = 1; manifest.rs:18 Kind::Filter only | | AUTO-219-7 | Local ONNX models behind a flag, managed in Preferences; subject select, background removal | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No ONNX runtime dependency | | AUTO-219-8 | xtask check that every panel button routes through a command id | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No such xtask check | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f6209c58db3..a7c802904b1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -184,6 +184,8 @@ parity for the area and are already contained in the dimension rows above. | Workspace, panels, preferences | 40% | 150–250 | 46 of 149 prefs do nothing; no floating/OS-window panel groups; Libraries, Comments, Contextual Task Bar missing | | AI / generative | 10% | 300–600 | See the dimension row | +Batch supports [output naming templates](batch-naming.md); AUTO-219-5 remains partial. + ## Remaining effort and calibration **Calibration.** The repository's history from the first commit (2026-09-30) to 2026-10-10: 1,152 diff --git a/scorecard/automation.toml b/scorecard/automation.toml index 0de2ab3bd2b..7bec4ab4431 100644 --- a/scorecard/automation.toml +++ b/scorecard/automation.toml @@ -44,7 +44,7 @@ id = "AUTO-219-5" target = "Batch: subfolders, open documents, naming template, progress/cancel, error log, stop" status = "partial" issue = 219 -note = "file_cmds.rs:590-631 folder/array input, per-file errors; no recursion, template or cancel" +note = "file_cmds.rs and file_cmds/batch_naming.rs: folder/array input, per-file errors, {name}/{serial}/{date} output templates and starting serial; no recursion, open-documents source, cancel or error-log file" [[item]] id = "AUTO-219-6" From 50631b54f75d7c95d6f4a6d61a78d64e1bf60f5b Mon Sep 17 00:00:00 2001 From: kayrockscreenprinting Date: Tue, 13 Oct 2026 10:10:04 -0700 Subject: [PATCH 36/47] Profile prompts on open: one file at a time, before it opens, and the answer goes to that file (#3471) * Profile prompts on open: one file at a time, before it opens, like Photoshop With Ask When Opening on, files opened together put up every Embedded Profile Mismatch prompt at once, each answer went to the active document rather than its own file, and no prompt named its file. Checked in Photoshop 27.11: one prompt at a time, naming the file, before that file opens; OK opens it and asks about the next; Cancel opens neither it nor the files still to come. A file that asks is now held back until answered: the engine's background open parks the decoded document (ColorState::open_question, Session::take_held_open) and the shell queues it (open_prompt). The answer is applied to that document (ColorState::resolve_open) before it opens. Files that finish reading meanwhile wait their turn. A developed raw has no embedded profile, so it doesn't ask. Co-Authored-By: Claude Opus 5.5 * Profile prompts on open: the prompt's text is localized in every catalog The two messages are templates now, one line each so the catalog check covers them: the sentence naming the file and the mode, "Embedded: {embedded}" and "Working: {working}". The mode reads as the catalogs name it (French "RVB"), and the file's name goes in last, so braces in it are never taken for a placeholder. Rows added to all 17 catalogs; main's two "colour profile" templates (Czech and Italian only) are removed, nothing uses them. The Camera Raw control test from main expected a mismatch prompt when a raw opens. With this change a developed raw is not asked about (it has no embedded profile), so the test now checks that no prompt stands before Camera Raw. Co-Authored-By: Claude Opus 5.5 * Profile prompts: rows for the hu, sr and tr catalogs; tests hold for any read order The prompt's four templates join the Hungarian, Serbian and Turkish catalogs that landed on main. Files read in the background ask in the order their reads finish, which is the order given unless a later read overtakes an earlier one. Three tests assumed the order given and one failed once under a loaded test run; they now follow whichever file the prompt names (and still require the order given for files read on the spot). New test: a file held for its prompt still reports type in a font that isn't installed, once. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/color_cmds.rs | 83 +++++++ crates/engine/src/jobs.rs | 12 +- crates/engine/src/jobs_tests.rs | 27 +++ crates/engine/src/lib.rs | 20 ++ crates/ui-egui/src/camera_raw_ui.rs | 3 + crates/ui-egui/src/control.rs | 16 +- crates/ui-egui/src/dialogs.rs | 4 + crates/ui-egui/src/file_open.rs | 23 +- crates/ui-egui/src/i18n/cs.tsv | 6 +- crates/ui-egui/src/i18n/de.tsv | 4 + crates/ui-egui/src/i18n/el.tsv | 4 + crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/fr.tsv | 4 + crates/ui-egui/src/i18n/hu.tsv | 4 + crates/ui-egui/src/i18n/id.tsv | 4 + crates/ui-egui/src/i18n/it.tsv | 6 +- crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/ko.tsv | 4 + crates/ui-egui/src/i18n/nl.tsv | 4 + crates/ui-egui/src/i18n/pl.tsv | 4 + crates/ui-egui/src/i18n/pt-br.tsv | 4 + crates/ui-egui/src/i18n/ru.tsv | 4 + crates/ui-egui/src/i18n/sr.tsv | 4 + crates/ui-egui/src/i18n/tr.tsv | 4 + crates/ui-egui/src/i18n/uk.tsv | 4 + crates/ui-egui/src/i18n/vi.tsv | 4 + crates/ui-egui/src/i18n/zh-hans.tsv | 4 + crates/ui-egui/src/i18n/zh-hant.tsv | 4 + crates/ui-egui/src/jobs_ui.rs | 59 +++-- crates/ui-egui/src/lib.rs | 61 +++-- crates/ui-egui/src/open_prompt.rs | 147 ++++++++++++ crates/ui-egui/src/prefs_ui.rs | 34 +-- crates/ui-egui/src/profile_open_tests.rs | 255 +++++++++++++++++++++ crates/ui-egui/src/profile_prompt_tests.rs | 24 +- 34 files changed, 747 insertions(+), 105 deletions(-) create mode 100644 crates/ui-egui/src/open_prompt.rs create mode 100644 crates/ui-egui/src/profile_open_tests.rs diff --git a/crates/engine/src/color_cmds.rs b/crates/engine/src/color_cmds.rs index 31dfe87f61d..d3a6d369311 100644 --- a/crates/engine/src/color_cmds.rs +++ b/crates/engine/src/color_cmds.rs @@ -315,6 +315,60 @@ impl ColorState { } } + /// Whether opening `doc` asks about its profile first (Ask When Opening, for a mismatched or + /// missing profile), and if so what the prompt shows: the report [`Self::open_policy`] + /// would give, with `ask: true` and `action` the answer the policy suggests. Nothing is + /// changed: the answer is applied with [`Self::resolve_open`] before the document opens. + pub fn open_question(&self, doc: &Document) -> Option { + let space = mode_space(doc.mode); + if !matches!(space, ColorSpace::Rgb | ColorSpace::Cmyk | ColorSpace::Gray) { + return None; + } + let working = self.working(doc.mode); + let policy = self.settings.policy(doc.mode); + let embedded = doc.icc_profile.as_ref().and_then(|b| profile_from_bytes(b).ok()).filter(|p| p.color_space == space); + match embedded { + Some(emb) => { + let linear_hdr = + doc.depth == SampleType::F32 && (emb.same_colors(Builtin::LinearSrgb.profile()) || emb.same_colors(photocraft_cms::builtin::linear_gray())); + if !self.settings.ask_on_mismatch || emb.same_colors(&working) || linear_hdr { + return None; + } + let action = match policy { + Policy::Preserve => "preserve", + Policy::Off => "discard", + Policy::Convert => "convert", + }; + Some( + json!({"embedded": emb.description, "working": working.description, "mismatch": true, "policy": policy.id(), "action": action, "ask": true}), + ) + } + None if self.settings.ask_on_missing => { + Some(json!({"working": working.description, "policy": policy.id(), "missing": true, "action": "preserve", "ask": true})) + } + None => None, + } + } + + /// Applies the answer to an [`Self::open_question`] to a document not opened yet: + /// `preserve` (keep the embedded profile, or leave an untagged file as is), `convert` to the + /// working space, `discard` the embedded profile, or `assignWorking` (untagged files). + pub fn resolve_open(&self, doc: &mut Document, action: &str) -> Result<()> { + match action { + "preserve" | "useEmbedded" => Ok(()), + "convert" | "convertToWorking" => convert_document(doc, &self.working(doc.mode), self.settings.intent(), self.settings.bpc), + "discard" | "off" => { + doc.icc_profile = None; + Ok(()) + } + "assignWorking" => { + doc.icc_profile = Some(self.working(doc.mode).to_bytes()); + Ok(()) + } + other => Err(EngineError::Other(format!("unknown answer `{other}` (preserve|convert|discard|assignWorking)"))), + } + } + /// Proofing state of a document (defaults: coated CMYK, relative colorimetric + BPC, off). pub fn proof(&self, doc: DocId) -> ProofView { self.proofs.get(&doc).cloned().unwrap_or_else(|| ProofView { gamut_threshold: photocraft_cms::gamut::DEFAULT_THRESHOLD, ..Default::default() }) @@ -1398,6 +1452,35 @@ mod settings_tests { // The Blend Text Colors Using Gamma test is `tests/text_gamma.rs`: it changes the // process-wide text gamma, which would race every unit test here that composites type. + #[test] + fn open_question_asks_only_when_set_and_resolve_open_applies_each_answer() { + let mut s = Session::new(); + // Ask When Opening off: nothing to ask, mismatched or missing. + s.execute("edit.colorSettings", json!({"askOnMismatch": false, "askOnMissing": false})).unwrap(); + assert!(s.color.open_question(&tagged("adobe-rgb-compat")).is_none()); + let mut untagged = tagged("srgb"); + untagged.icc_profile = None; + assert!(s.color.open_question(&untagged).is_none()); + // On: a mismatch asks, suggesting the policy's answer; a matching profile never asks. + s.execute("edit.colorSettings", json!({"askOnMismatch": true, "askOnMissing": true, "policyRgb": "convert"})).unwrap(); + let q = s.color.open_question(&tagged("adobe-rgb-compat")).unwrap(); + assert_eq!((q["mismatch"].as_bool(), q["action"].as_str()), (Some(true), Some("convert"))); + assert!(q["embedded"].as_str().unwrap().starts_with("Adobe RGB")); + assert!(s.color.open_question(&tagged("srgb")).is_none()); + assert_eq!(s.color.open_question(&untagged).unwrap()["missing"], true); + // Asking changes nothing; the answer is applied to the document. + let mut d = tagged("adobe-rgb-compat"); + s.color.resolve_open(&mut d, "preserve").unwrap(); + assert!(desc(&d).unwrap().starts_with("Adobe RGB")); + s.color.resolve_open(&mut d, "convert").unwrap(); + assert_eq!(desc(&d).as_deref(), Some("sRGB IEC61966-2.1")); + s.color.resolve_open(&mut d, "discard").unwrap(); + assert_eq!(desc(&d), None); + s.color.resolve_open(&mut d, "assignWorking").unwrap(); + assert_eq!(desc(&d).as_deref(), Some("sRGB IEC61966-2.1")); + assert!(s.color.resolve_open(&mut d, "explode").is_err()); + } + #[test] fn open_policies() { let mut s = Session::new(); diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 9c8cb9cdd01..dc43f4d7941 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -412,7 +412,10 @@ impl Session { /// Open a file in the background: read (for a path) and decode on a worker, with progress /// (per layer for PSD/PSB) and cancellation, then add the document like /// [`Session::open_document`] (Color Settings policies apply). `name` names the document. - /// The job's result is `{document, name, warnings, color}`; on wasm it runs inline. + /// The job's result is `{document, name, warnings, color}`; on wasm it runs inline. When + /// Color Settings ask about the file's profile, the document is held back instead and the + /// result is `{held, name, warnings, color}` (`color` the question): take it with + /// [`Session::take_held_open`] once answered. pub fn start_open(&mut self, name: &str, source: OpenSource) -> Result { let label = format!("Opening {name}"); let name_w = name.to_string(); @@ -438,6 +441,13 @@ impl Session { }) }, move |s, r: photocraft_io::ImportResult| { + // Ask When Opening: held back until the prompt is answered (`take_held_open`). A + // developed raw has no embedded profile to ask about. + let raw = r.warnings.iter().any(|w| w.contains(photocraft_io::raw::DEVELOPED_NOTE)); + if let Some(question) = s.color.open_question(&r.document).filter(|_| !raw) { + let held = s.hold_open(r.document, r.source_read_only); + return Ok(json!({"held": held, "name": name_a, "warnings": r.warnings, "color": question})); + } let (index, color) = s.open_document(r.document, None); if let Some(st) = s.active_mut() { st.source_read_only = r.source_read_only; diff --git a/crates/engine/src/jobs_tests.rs b/crates/engine/src/jobs_tests.rs index a40b712c814..01269228182 100644 --- a/crates/engine/src/jobs_tests.rs +++ b/crates/engine/src/jobs_tests.rs @@ -463,6 +463,33 @@ fn open_runs_as_a_job_and_cancel_adds_nothing() { assert!(matches!(wait_event(&mut s, id).outcome, JobOutcome::Failed(_))); } +/// Ask When Opening: a file whose profile doesn't match is decoded but not added; the shell takes +/// it once the prompt is answered. Without Ask it opens as usual. +#[test] +fn an_open_that_asks_about_its_profile_holds_the_document_back() { + let mut src = session(8, 8); + let mut doc = (*src.active().unwrap().doc).clone(); + doc.icc_profile = Some(photocraft_cms::Builtin::from_id("adobe-rgb-compat").unwrap().profile().to_bytes()); + let bytes = Arc::new(photocraft_io::export(&doc, "x.png", &Default::default()).unwrap().bytes); + src.close(0); + let mut s = Session::new(); + s.execute("edit.colorSettings", json!({"askOnMismatch": true, "policyRgb": "preserve"})).unwrap(); + let id = job(s.start_open("x.png", OpenSource::Bytes(Arc::clone(&bytes))).unwrap()); + let JobOutcome::Done(v) = wait_event(&mut s, id).outcome else { panic!() }; + assert!(s.documents().is_empty(), "held back, not opened"); + assert_eq!((v["color"]["mismatch"].as_bool(), v["color"]["ask"].as_bool(), v["color"]["action"].as_str()), (Some(true), Some(true), Some("preserve"))); + let held = v["held"].as_u64().unwrap(); + let (taken, _) = s.take_held_open(held).expect("the held document"); + assert!(taken.icc_profile.is_some()); + assert!(s.take_held_open(held).is_none(), "taken once"); + + s.execute("edit.colorSettings", json!({"askOnMismatch": false})).unwrap(); + let id = job(s.start_open("x.png", OpenSource::Bytes(bytes)).unwrap()); + let JobOutcome::Done(v) = wait_event(&mut s, id).outcome else { panic!() }; + assert!(v.get("held").is_none()); + assert_eq!(s.documents().len(), 1); +} + #[test] fn wait_job_blocks_until_applied() { let mut s = session(400, 300); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fde2b6ab610..ff3cde6e4cd 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -459,6 +459,11 @@ pub struct Session { /// A finished live stroke offered to the `paint.stroke` / `paint.pencil` that commits it /// ([`Session::offer_live_stroke`]). pub(crate) live_commit: Option, + /// Documents a background open decoded but held back until the user answers the profile + /// prompt ([`ColorState::open_question`](color_cmds::ColorState::open_question)): `(id, + /// document, source read-only)`. The shell takes them with [`Session::take_held_open`]. + held_opens: Vec<(u64, Document, bool)>, + next_held_open: u64, } /// Move item `i` of `v` to position `to`, clamped to the end. Returns where it went; `None` when @@ -544,6 +549,21 @@ impl Session { i } + /// Holds a decoded document back from opening (see `held_opens`); returns its id. + pub(crate) fn hold_open(&mut self, doc: Document, source_read_only: bool) -> u64 { + self.next_held_open = self.next_held_open.wrapping_add(1); + self.held_opens.push((self.next_held_open, doc, source_read_only)); + self.next_held_open + } + + /// Takes a document a background open held back for the profile prompt: the document and + /// whether its source is read-only. `None` when there is no such document (already taken). + pub fn take_held_open(&mut self, id: u64) -> Option<(Document, bool)> { + let i = self.held_opens.iter().position(|(h, _, _)| *h == id)?; + let (_, doc, read_only) = self.held_opens.remove(i); + Some((doc, read_only)) + } + pub fn close(&mut self, index: usize) -> Option { if index >= self.docs.len() { return None; diff --git a/crates/ui-egui/src/camera_raw_ui.rs b/crates/ui-egui/src/camera_raw_ui.rs index 59ab86092d0..f3e2aa8fbbc 100644 --- a/crates/ui-egui/src/camera_raw_ui.rs +++ b/crates/ui-egui/src/camera_raw_ui.rs @@ -1435,6 +1435,9 @@ mod tests { let ctx = egui::Context::default(); let warnings = app.open_bytes("IMG_0001.dng", &dng).unwrap(); assert!(warnings.iter().any(|w| w.contains(photocraft_io::raw::DEVELOPED_NOTE)), "{warnings:?}"); + // A raw has no embedded profile (ProPhoto RGB is the space it is developed into), so Ask + // When Opening (on by default) has nothing to ask about. + assert!(app.ui.dialogs.is_empty(), "no profile prompt for a raw"); let raw = app.pending_raw_open.take().expect("a raw open queues the dialog"); open_raw(&mut app, &ctx, raw).unwrap(); let d = app.camera_raw.as_ref().unwrap(); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index ff2622fcbc3..db908d09430 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1386,20 +1386,10 @@ mod tests { app.open_bytes("control.dng", &dng).unwrap(); ctx.run_ui(Default::default(), |ui| crate::camera_raw_ui::show(&mut app, ui.ctx())).textures_delta.clear(); assert!(app.camera_raw.is_some()); - // The raw develops in ProPhoto, so opening it also asks about the working-space - // mismatch. Answer that prompt through the same control path before editing. - assert_eq!(app.ui.dialogs.len(), 1); - let mismatch = app.ui.dialogs.first().unwrap(); - assert_eq!(mismatch.fields["__prefsui"], "mismatch"); - assert_eq!(mismatch.fields["action"], "preserve"); - assert_eq!(mismatch.fields["applied"], "preserve"); - let mismatch_id = mismatch.id; - let update = json!({"id":"filter.cameraRaw","params":{"ui":{"set":{"exposure":0.5}}}}); - assert_eq!(call(&mut app, &ctx, "ui.menu.invoke", update.clone())["ok"], false); - let confirmed = call(&mut app, &ctx, "ui.dialog.confirm", json!({"dialog":mismatch_id})); - assert_eq!(confirmed["ok"], true, "{confirmed}"); - assert_eq!(confirmed["result"]["action"], "preserve"); + // A developed raw has no embedded profile to ask about (`open_prompt`), so no profile + // prompt stands between the open and the Camera Raw dialog. assert!(app.ui.dialogs.is_empty()); + let update = json!({"id":"filter.cameraRaw","params":{"ui":{"set":{"exposure":0.5}}}}); let original = app.session.active().unwrap().doc.clone(); let changed = call(&mut app, &ctx, "ui.menu.invoke", update); assert_eq!(changed["ok"], true, "{changed}"); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f65bcecbebc..918525a1321 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -629,6 +629,10 @@ pub fn cancel(app: &mut PhotocraftApp, id: u64) -> Result { if crate::guide_layout_ui::owns(&closed.fields) { app.ui.dialogs.retain(|child| child.fields.get("__dialog").and_then(Value::as_u64) != Some(id)); } + // Cancel on a profile prompt opens neither its file nor the ones still to come. + if crate::open_prompt::owns(&closed.fields) { + crate::open_prompt::answer(app, None)?; + } app.ui.dialogs.retain(|d| !crate::layer_style::color_picker::child_of(&d.fields, id)); app.filter_preview = None; app.color_range = None; diff --git a/crates/ui-egui/src/file_open.rs b/crates/ui-egui/src/file_open.rs index 492da515b16..d67e56dbf02 100644 --- a/crates/ui-egui/src/file_open.rs +++ b/crates/ui-egui/src/file_open.rs @@ -36,12 +36,19 @@ pub enum DropTarget { /// `warnings` from an import, plus one for each font family the active document's type uses that /// isn't installed (#4559): Photoshop warns about missing fonts as a file opens, and the CLI and /// automation already report them; the type would otherwise silently render in a fallback face. -pub(crate) fn with_missing_fonts(app: &PhotocraftApp, mut warnings: Vec) -> Vec { - if let Some(st) = app.session.active() { - for w in photocraft_engine::type_extra_cmds::missing_font_warnings(&st.doc) { - if !warnings.contains(&w) { - warnings.push(w); - } +pub(crate) fn with_missing_fonts(app: &PhotocraftApp, warnings: Vec) -> Vec { + match app.session.active() { + Some(st) => with_missing_fonts_of(&st.doc, warnings), + None => warnings, + } +} + +/// [`with_missing_fonts`] for `doc` itself: a file held back for its profile prompt +/// (`open_prompt`) isn't the active document yet. +pub(crate) fn with_missing_fonts_of(doc: &photocraft_doc::Document, mut warnings: Vec) -> Vec { + for w in photocraft_engine::type_extra_cmds::missing_font_warnings(doc) { + if !warnings.contains(&w) { + warnings.push(w); } } warnings @@ -71,9 +78,7 @@ impl PhotocraftApp { crate::jobs_ui::start_open(self, &display_name(path), Some(path.to_string()), crate::jobs_ui::bytes(bytes))?; return Ok(Vec::new()); } - let warnings = self.open_bytes(&display_name(path), bytes)?; - self.opened_from(path); - Ok(warnings) + self.open_bytes_from(&display_name(path), bytes, Some(path)) } /// Record that the active document was just opened from `path`: File › Save writes back to it diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index d730fae7abc..2a8008e7901 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2209,6 +2209,10 @@ No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Není k dispozici žádná cesta, ze které by šlo vytvořit výběr: nakreslete ji nástrojem Pero nebo ji vyberte na panelu Cesty. Cancel opening Zrušit otevírání Cancelled opening {name} Otevírání {name} zrušeno + The document “{name}” does not have an embedded {mode} profile. Dokument „{name}“ nemá vložený profil {mode}. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. Dokument „{name}“ má vložený barevný profil, který neodpovídá aktuálnímu pracovnímu prostoru barev {mode}. + Embedded: {embedded} Vložený: {embedded} + Working: {working} Pracovní: {working} Developing {name} Vyvolávání {name} Cancelled {label} Zrušeno: {label} Esc to cancel Esc pro zrušení @@ -3667,8 +3671,6 @@ curves Smooth Vyhladit fallback náhradní # Composed labels and Navigator zoom context - The document does not have an embedded colour profile. Working space: {working}. Dokument nemá vložený barevný profil. Pracovní prostor barev: {working}. - The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} Dokument má vložený barevný profil, který neodpovídá pracovnímu prostoru barev.\nVložený: {embedded}\nPracovní: {working} Importing {what} from {file}… Importují se {what} ze souboru {file}… brushes štětce gradients přechody diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 6b13188c737..86dba897892 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2179,6 +2179,10 @@ No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Kein Pfad für eine Auswahl vorhanden: zeichne einen mit dem Stift-Werkzeug oder wähle einen im Pfade-Bedienfeld. Cancel opening Öffnen abbrechen Cancelled opening {name} Öffnen von {name} abgebrochen + The document “{name}” does not have an embedded {mode} profile. Das Dokument „{name}“ hat kein eingebettetes {mode}-Profil. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. Das Dokument „{name}“ hat ein eingebettetes Farbprofil, das nicht zum aktuellen {mode}-Arbeitsfarbraum passt. + Embedded: {embedded} Eingebettet: {embedded} + Working: {working} Arbeitsfarbraum: {working} Developing {name} {name} wird entwickelt Cancelled {label} {label} abgebrochen Esc to cancel Esc zum Abbrechen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 11590ca3610..c2fdfd522dd 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1610,6 +1610,10 @@ No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Δεν υπάρχει διαδρομή για δημιουργία επιλογής: σχεδιάστε μία με το εργαλείο Πένα (Pen Tool) ή επιλέξτε μία στο πλαίσιο Διαδρομές. Cancel opening Ακύρωση ανοίγματος Cancelled opening {name} Ακυρώθηκε το άνοιγμα του {name} + The document “{name}” does not have an embedded {mode} profile. Το έγγραφο «{name}» δεν έχει ενσωματωμένο προφίλ {mode}. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. Το έγγραφο «{name}» έχει ενσωματωμένο χρωματικό προφίλ που δεν ταιριάζει με τον τρέχοντα χώρο εργασίας {mode}. + Embedded: {embedded} Ενσωματωμένο: {embedded} + Working: {working} Χώρος εργασίας: {working} Cancelled {label} Ακυρώθηκε: {label} Esc to cancel Esc για ακύρωση Opened {name} Άνοιξε το {name} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index addcea851d4..e90f8f7ef34 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2195,6 +2195,10 @@ No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. No hay ningún trazado para crear una selección: dibuje uno con la herramienta Pluma o seleccione uno en el panel Trazados. Cancel opening Cancelar apertura Cancelled opening {name} Se canceló la apertura de {name} + The document “{name}” does not have an embedded {mode} profile. El documento «{name}» no tiene un perfil {mode} incrustado. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. El documento «{name}» tiene un perfil de color incrustado que no coincide con el espacio de trabajo {mode} actual. + Embedded: {embedded} Incrustado: {embedded} + Working: {working} Espacio de trabajo: {working} Developing {name} Revelando {name} Cancelled {label} Se canceló {label} Esc to cancel Esc para cancelar diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 1620ff821f6..6d915c686bf 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2155,6 +2155,10 @@ No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Aucun tracé à partir duquel créer une sélection : dessinez-en un avec l'outil Plume ou sélectionnez-en un dans le panneau Tracés. Cancel opening Annuler l'ouverture Cancelled opening {name} Ouverture de {name} annulée + The document “{name}” does not have an embedded {mode} profile. Le document « {name} » n'a pas de profil {mode} incorporé. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. Le document « {name} » a un profil colorimétrique incorporé qui ne correspond pas à l'espace de travail {mode} actuel. + Embedded: {embedded} Incorporé : {embedded} + Working: {working} Espace de travail : {working} Developing {name} Développement de {name} Cancelled {label} {label} : annulé Esc to cancel Échap pour annuler diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 6a5c28999af..23f7da4142b 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2484,6 +2484,10 @@ navigator Zoom Nagyítás mértéke Camera Raw Filter ({layer}) Camera Raw szűrő ({layer}) Camera Raw isn't open A Camera Raw nincs megnyitva Cancelled opening {name} Megnyitás megszakítva: {name} + The document “{name}” does not have an embedded {mode} profile. A(z) „{name}” dokumentumnak nincs beágyazott {mode}-profilja. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. A(z) „{name}” dokumentum beágyazott színprofilja nem egyezik az aktuális {mode}-munkaszíntérrel. + Embedded: {embedded} Beágyazott: {embedded} + Working: {working} Munkaszíntér: {working} Cancelled {label} Megszakítva: {label} Clarity Tisztaság Color Detail Színrészletesség diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 8b8802e98a0..44b2aaf70cf 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2148,6 +2148,10 @@ No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Tidak ada path untuk dibuat seleksi: gambar dengan alat Pen atau pilih salah satu di panel Path. Cancel opening Batalkan pembukaan Cancelled opening {name} Pembukaan {name} dibatalkan + The document “{name}” does not have an embedded {mode} profile. Dokumen “{name}” tidak memiliki profil {mode} tertanam. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. Dokumen “{name}” memiliki profil warna tertanam yang tidak cocok dengan working space {mode} saat ini. + Embedded: {embedded} Tertanam: {embedded} + Working: {working} Working space: {working} Developing {name} Mengembangkan {name} Cancelled {label} {label} dibatalkan Esc to cancel Esc untuk batal diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 0034b37ba05..b7aceab4bd2 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2191,6 +2191,10 @@ No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Nessun tracciato da cui creare selezione: disegnane uno con Penna o selezionane uno nel pannello Tracciati. Cancel opening Annulla apertura Cancelled opening {name} Apertura {name} annullata + The document “{name}” does not have an embedded {mode} profile. Il documento “{name}” non ha un profilo {mode} incorporato. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. Il documento “{name}” ha un profilo colore incorporato che non corrisponde allo spazio di lavoro {mode} corrente. + Embedded: {embedded} Incorporato: {embedded} + Working: {working} Di lavoro: {working} Developing {name} Sviluppo di {name} Cancelled {label} {label} annullato Esc to cancel Esc per annullare @@ -4043,8 +4047,6 @@ layerLabel No Color Nessun colore Monitor profile on {display}: {profile} Profilo monitor su {display}: {profile} manual manuale fallback ripiego - The document does not have an embedded colour profile. Working space: {working}. Il documento non ha un profilo colore incorporato. Spazio di lavoro: {working}. - The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} Il documento ha un profilo colore incorporato che non corrisponde allo spazio di lavoro.\nIncorporato: {embedded}\nDi lavoro: {working} Importing {what} from {file}… Importazione di {what} da {file}… brushes pennelli gradients sfumature diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 2e13e3df7fd..59222977dae 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2189,6 +2189,10 @@ No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. 選択範囲を作成するパスがありません。ペンツールで描くか、パスパネルでパスを選択してください。 Cancel opening 開くのをキャンセル Cancelled opening {name} {name} を開くのをキャンセルしました + The document “{name}” does not have an embedded {mode} profile. ドキュメント「{name}」には埋め込み {mode} プロファイルがありません。 + The document “{name}” has an embedded color profile that does not match the current {mode} working space. ドキュメント「{name}」の埋め込みカラープロファイルは、現在の {mode} 作業用スペースと一致しません。 + Embedded: {embedded} 埋め込み: {embedded} + Working: {working} 作業用スペース: {working} Developing {name} {name} を現像中 Cancelled {label} {label} をキャンセルしました Esc to cancel Esc キーでキャンセル diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 89b0c613dcb..d8cfc4ee3c9 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2150,6 +2150,10 @@ No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. 선택 영역으로 만들 패스가 없습니다: 펜 도구로 그리거나 패스 패널에서 선택하세요. Cancel opening 열기 취소 Cancelled opening {name} {name} 열기를 취소했습니다 + The document “{name}” does not have an embedded {mode} profile. 문서 “{name}”에 포함된 {mode} 프로파일이 없습니다. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. 문서 “{name}”에 포함된 색상 프로파일이 현재 {mode} 작업 색상 공간과 일치하지 않습니다. + Embedded: {embedded} 포함됨: {embedded} + Working: {working} 작업 색상 공간: {working} Developing {name} {name} 현상 중 Cancelled {label} {label} 작업을 취소했습니다 Esc to cancel Esc 키로 취소 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 4bb9d7ccefe..f960037b27c 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2157,6 +2157,10 @@ No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Geen pad om een selectie van te maken: teken er een met het pengereedschap of selecteer er een in het paneel Paden. Cancel opening Openen annuleren Cancelled opening {name} Openen van {name} geannuleerd + The document “{name}” does not have an embedded {mode} profile. Het document “{name}” heeft geen ingesloten {mode}-profiel. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. Het document “{name}” heeft een ingesloten kleurprofiel dat niet overeenkomt met de huidige {mode}-werkruimte. + Embedded: {embedded} Ingesloten: {embedded} + Working: {working} Werkruimte: {working} Developing {name} {name} wordt ontwikkeld Cancelled {label} {label} geannuleerd Esc to cancel Esc om te annuleren diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 04797ddc2ef..8a2506f8e8d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2176,6 +2176,10 @@ No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Brak ścieżki, z której można utworzyć zaznaczenie: narysuj ją narzędziem Pióro lub wybierz w panelu Ścieżki. Cancel opening Anuluj otwieranie Cancelled opening {name} Anulowano otwieranie {name} + The document “{name}” does not have an embedded {mode} profile. Dokument „{name}” nie ma osadzonego profilu {mode}. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. Dokument „{name}” ma osadzony profil kolorów, który nie pasuje do bieżącej przestrzeni roboczej {mode}. + Embedded: {embedded} Osadzony: {embedded} + Working: {working} Przestrzeń robocza: {working} Cancelled {label} Anulowano: {label} Esc to cancel Esc, aby anulować Opened {name} Otworzono: {name} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index df6c092ba8c..08228157a64 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2151,6 +2151,10 @@ No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Nenhum demarcador para criar a seleção: desenhe um com a ferramenta Caneta ou selecione um no painel Demarcadores. Cancel opening Cancelar abertura Cancelled opening {name} Abertura de {name} cancelada + The document “{name}” does not have an embedded {mode} profile. O documento “{name}” não tem um perfil {mode} incorporado. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. O documento “{name}” tem um perfil de cores incorporado que não corresponde ao espaço de trabalho {mode} atual. + Embedded: {embedded} Incorporado: {embedded} + Working: {working} Espaço de trabalho: {working} Developing {name} Revelando {name} Cancelled {label} {label} cancelado Esc to cancel Esc para cancelar diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 3acd552a590..a48bb0c9dd3 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2471,6 +2471,10 @@ No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Нет контура для создания выделения: нарисуйте его инструментом «Перо» или выберите на панели «Контуры». Cancel opening Отменить открытие Cancelled opening {name} Открытие {name} отменено + The document “{name}” does not have an embedded {mode} profile. В документе «{name}» нет встроенного профиля {mode}. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. Встроенный цветовой профиль документа «{name}» не совпадает с текущим рабочим пространством {mode}. + Embedded: {embedded} Встроенный: {embedded} + Working: {working} Рабочее пространство: {working} Developing {name} Обработка {name} Cancelled {label} {label}: отменено Esc to cancel Esc — отмена diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 902eb705893..286283ae858 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2135,6 +2135,10 @@ No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Nema putanje od koje bi se napravila selekcija: nacrtajte je alatkom pero ili je izaberite u panelu Putanje. Cancel opening Otkaži otvaranje Cancelled opening {name} Otkazano otvaranje: {name} + The document “{name}” does not have an embedded {mode} profile. Dokument „{name}“ nema ugrađeni {mode} profil. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. Dokument „{name}“ ima ugrađeni profil boja koji se ne slaže sa trenutnim {mode} radnim prostorom. + Embedded: {embedded} Ugrađeni: {embedded} + Working: {working} Radni prostor: {working} Developing {name} Razvijanje: {name} Cancelled {label} Otkazano: {label} Esc to cancel Esc za otkazivanje diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 5719f0bd899..d9108fb3576 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2132,6 +2132,10 @@ No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Seçim yapılabilecek yol yok: Kalem aracıyla bir yol çizin veya Yollar panelinden birini seçin. Cancel opening Açılışı iptal et Cancelled opening {name} {name} açılışı iptal edildi + The document “{name}” does not have an embedded {mode} profile. '{name}' belgesinde gömülü bir {mode} profili yok. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. '{name}' belgesinin gömülü renk profili geçerli {mode} çalışma alanıyla eşleşmiyor. + Embedded: {embedded} Gömülü: {embedded} + Working: {working} Çalışma alanı: {working} Developing {name} {name} geliştiriliyor Cancelled {label} {label} iptal edildi Esc to cancel İptal etmek için Esc diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 6ec48d1dcd0..4106a772d8e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2444,6 +2444,10 @@ No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Немає контуру для створення виділення: намалюйте його пером або виберіть на панелі контурів. Cancel opening Скасувати відкриття Cancelled opening {name} Відкриття {name} скасовано + The document “{name}” does not have an embedded {mode} profile. Документ «{name}» не має вбудованого профілю {mode}. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. Вбудований колірний профіль документа «{name}» не відповідає поточному робочому простору {mode}. + Embedded: {embedded} Вбудований: {embedded} + Working: {working} Робочий простір: {working} Developing {name} Обробка {name} Cancelled {label} Скасовано: {label} Esc to cancel Esc для скасування diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 24b63ba6144..d7930086b7c 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1562,6 +1562,10 @@ cameraRaw Shadows Vùng tối Esc to cancel Esc để hủy Cancel opening Hủy mở tệp Cancelled opening {name} Đã hủy mở {name} + The document “{name}” does not have an embedded {mode} profile. Tài liệu “{name}” không có hồ sơ {mode} nhúng. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. Tài liệu “{name}” có hồ sơ màu nhúng không khớp với không gian làm việc {mode} hiện tại. + Embedded: {embedded} Nhúng: {embedded} + Working: {working} Không gian làm việc: {working} Cancelled {label} Đã hủy {label} Cancel transform (Esc) Hủy biến đổi (Esc) Cancel warp (Esc) Hủy biến dạng (Esc) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index bc729fb1317..22fbce1e1a3 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2256,6 +2256,10 @@ cameraRaw Blending 混合 # Layer navigation, copy transforms and cancellable opening (labels added on main since this catalog). Cancel opening 取消打开 Cancelled opening {name} 已取消打开 {name} + The document “{name}” does not have an embedded {mode} profile. 文档“{name}”没有嵌入的 {mode} 配置文件。 + The document “{name}” has an embedded color profile that does not match the current {mode} working space. 文档“{name}”嵌入的颜色配置文件与当前的 {mode} 工作空间不匹配。 + Embedded: {embedded} 嵌入:{embedded} + Working: {working} 工作空间:{working} Developing {name} 正在处理 {name} Cancelled {label} 已取消 {label} Esc to cancel 按 Esc 取消 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index ff84651797e..1c7828515dc 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2195,6 +2195,10 @@ No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. 沒有可用來建立選取範圍的路徑:請使用鋼筆工具繪製,或在路徑面板中選取一個路徑。 Cancel opening 取消開啟 Cancelled opening {name} 已取消開啟 {name} + The document “{name}” does not have an embedded {mode} profile. 文件「{name}」沒有嵌入的 {mode} 描述檔。 + The document “{name}” has an embedded color profile that does not match the current {mode} working space. 文件「{name}」嵌入的色彩描述檔與目前的 {mode} 工作空間不符。 + Embedded: {embedded} 嵌入:{embedded} + Working: {working} 工作空間:{working} Developing {name} 正在處理 {name} Cancelled {label} 已取消 {label} Esc to cancel 按 Esc 取消 diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index 99479f4e321..29da41d481a 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -527,38 +527,69 @@ fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) { /// What [`crate::PhotocraftApp::open_bytes`] does after decoding, for a background open's result /// (`{document, warnings, color}`). fn finish_open(app: &mut PhotocraftApp, name: &str, path: Option<&str>, v: &Value, slot: Option) -> Result<(), String> { + let warnings: Vec = + v.get("warnings").and_then(Value::as_array).map(|a| a.iter().filter_map(|w| w.as_str().map(str::to_string)).collect()).unwrap_or_default(); + let held = |doc, read_only, question, policy_applied| crate::open_prompt::Held { + doc, + read_only, + name: name.to_string(), + path: path.map(str::to_string), + warnings: warnings.clone(), + bytes: None, + slot, + question, + policy_applied, + }; + // Held back for the profile prompt (Ask When Opening). + if let Some(id) = v.get("held").and_then(Value::as_u64) { + let (doc, read_only) = app.session.take_held_open(id).ok_or("the open job's document is gone")?; + crate::open_prompt::arrive(app, held(doc, read_only, v.get("color").cloned(), false)); + return Ok(()); + } let mut index = v.get("document").and_then(Value::as_u64).ok_or("the open job returned no document")? as usize; + // An earlier file is waiting on its prompt: this one opens after it, in order. + if crate::open_prompt::waiting(app) + && let Some(st) = app.session.close(index) + { + let read_only = st.source_read_only; + let doc = Arc::try_unwrap(st.doc).unwrap_or_else(|d| (*d).clone()); + crate::open_prompt::arrive(app, held(doc, read_only, None, true)); + return Ok(()); + } if let Some(slot) = slot && let Ok(moved) = app.run("document.move", json!({"document": index, "to": slot})) { index = moved.get("document").and_then(Value::as_u64).map_or(index, |i| i as usize); } - let warnings: Vec = - v.get("warnings").and_then(Value::as_array).map(|a| a.iter().filter_map(|w| w.as_str().map(str::to_string)).collect()).unwrap_or_default(); app.session.set_active(index); + after_open(app, name, path, &warnings, None); + Ok(()) +} + +/// What every open does once its document is the active one: remember where it came from, say +/// so, show the import warnings (a raw opens in Camera Raw first, developed from `path` or +/// `bytes`) and fire the "Open Document" script events. +pub(crate) fn after_open(app: &mut PhotocraftApp, name: &str, path: Option<&str>, warnings: &[String], bytes: Option<&[u8]>) { if let Some(p) = path { app.opened_from(p); } app.sync_views(); - let warnings = crate::file_open::with_missing_fonts(app, warnings); - app.ui.status = crate::i18n::fmt(tl!("Opened {name}"), &[("name", name)]); + // Fonts the document's type uses that aren't installed join the import warnings (#4559). + let warnings = crate::file_open::with_missing_fonts(app, warnings.to_vec()); + let warnings = warnings.as_slice(); + let opened = crate::i18n::fmt(tl!("Opened {name}"), &[("name", name)]); + app.ui.status = opened.clone(); app.ui.status_error = false; // A raw opens in Camera Raw first; the dialog says how it was developed. - if crate::camera_raw_ui::wants_open_dialog(app, &warnings) { - crate::camera_raw_ui::queue_open_dialog(app, name, path, None); - notices::io_warnings(app, &crate::i18n::fmt(tl!("Opened {name}"), &[("name", name)]), &crate::camera_raw_ui::without_develop_note(&warnings)); + if crate::camera_raw_ui::wants_open_dialog(app, warnings) { + crate::camera_raw_ui::queue_open_dialog(app, name, path, bytes); + notices::io_warnings(app, &opened, &crate::camera_raw_ui::without_develop_note(warnings)); } else { - notices::io_warnings(app, &crate::i18n::fmt(tl!("Opened {name}"), &[("name", name)]), &warnings); + notices::io_warnings(app, &opened, warnings); } // Script events bound to "Open Document". photocraft_engine::automate_cmds::document_opened(&mut app.session); app.sync_views(); - let color = v.get("color").cloned().unwrap_or(Value::Null); - let ask = color.get("ask").and_then(Value::as_bool) == Some(true); - if ask && (color.get("mismatch").and_then(Value::as_bool) == Some(true) || color.get("missing").is_some()) { - crate::prefs_ui::open_mismatch(app, &color); - } - Ok(()) } // ------------------------------------------------------------------------------------- drawing diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e1b5c6cdf6c..0189b65adf3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -140,6 +140,7 @@ pub mod notices; mod numeric_expression; pub mod object_finder; mod opacity_keys; +pub mod open_prompt; pub mod outline; pub mod paint_mouse; pub mod palette; @@ -741,6 +742,9 @@ pub struct PhotocraftApp { pub(crate) save_dirs: HashMap, /// A save to an RGB Targa parked behind the Targa Options prompt (see `targa_options_ui`). pub(crate) targa_options: Option, + /// Files read but not opened yet: the first waits on its profile prompt, the rest behind it + /// (see `open_prompt`). + pub(crate) held_opens: std::collections::VecDeque, /// Set once the user has agreed to quit, so the resulting close request goes through. pub(crate) allow_close: bool, /// Pen pressure/tilt from the platform (see `stylus`). @@ -876,6 +880,7 @@ impl PhotocraftApp { tiff_options: None, save_dirs: HashMap::new(), targa_options: None, + held_opens: Default::default(), allow_close: false, stylus: Default::default(), background_jobs: false, @@ -1215,6 +1220,12 @@ impl PhotocraftApp { /// shown to the user). Files from disk go through [`open_file`](Self::open_file), which also /// remembers the path. Brush and gradient files go to the preset libraries instead. pub fn open_bytes(&mut self, name: &str, bytes: &[u8]) -> Result, String> { + self.open_bytes_from(name, bytes, None) + } + + /// [`Self::open_bytes`] for a file read from `path` (File › Save writes back there; it goes + /// to Open Recent), when known. + pub(crate) fn open_bytes_from(&mut self, name: &str, bytes: &[u8], path: Option<&str>) -> Result, String> { if let Some(r) = preset_files_ui::open(self, name, bytes) { return r.map(|()| Vec::new()); } @@ -1222,35 +1233,34 @@ impl PhotocraftApp { // Decoded on a worker: a tab with progress appears now, the document when it's ready // (warnings are shown then). if self.background_jobs { - jobs_ui::start_open(self, name, None, jobs_ui::bytes(bytes))?; + jobs_ui::start_open(self, name, path.map(str::to_string), jobs_ui::bytes(bytes))?; return Ok(Vec::new()); } let import = self.services.import.as_ref().ok_or("no importer configured")?; let max_svg_group_depth = self.session.prefs().file_handling.rasterize_svg_groups_deeper_than as usize; let (doc, warnings) = import(name, bytes, max_svg_group_depth)?; - // Edit › Color Settings policies apply on open; mismatches can ask what to do. - // No path yet: a bare name isn't a location to save back to (`open_file` sets the path). - let (_, color) = self.session.open_document(doc, None); - if let Some(st) = self.session.active_mut() { - st.source_read_only = photocraft_io::affinity::is_affinity(bytes); - } - self.sync_views(); - let warnings = file_open::with_missing_fonts(self, warnings); - self.ui.status = crate::i18n::fmt(tl!("Opened {name}"), &[("name", name)]); - self.ui.status_error = false; - if camera_raw_ui::wants_open_dialog(self, &warnings) { - camera_raw_ui::queue_open_dialog(self, name, None, Some(bytes)); - notices::io_warnings(self, &crate::i18n::fmt(tl!("Opened {name}"), &[("name", name)]), &camera_raw_ui::without_develop_note(&warnings)); - } else { - notices::io_warnings(self, &crate::i18n::fmt(tl!("Opened {name}"), &[("name", name)]), &warnings); - } - // Script events bound to "Open Document". - photocraft_engine::automate_cmds::document_opened(&mut self.session); - self.sync_views(); - let ask = color.get("ask").and_then(Value::as_bool) == Some(true); - if ask && (color.get("mismatch").and_then(Value::as_bool) == Some(true) || color.get("missing").is_some()) { - prefs_ui::open_mismatch(self, &color); - } + // Edit › Color Settings policies apply on open; Ask When Opening holds the file back + // until its prompt is answered, and later files wait behind it (`open_prompt`). + // Missing fonts are read from the document itself: it may not be open yet (#4559). + let warnings = file_open::with_missing_fonts_of(&doc, warnings); + // A developed raw has no embedded profile to ask about. + let developed = warnings.iter().any(|w| w.contains(photocraft_io::raw::DEVELOPED_NOTE)); + let question = self.session.color.open_question(&doc).filter(|_| !developed); + let wants_raw = camera_raw_ui::wants_open_dialog(self, &warnings); + open_prompt::arrive( + self, + open_prompt::Held { + doc, + read_only: photocraft_io::affinity::is_affinity(bytes), + name: name.to_string(), + path: path.map(str::to_string), + warnings: warnings.clone(), + bytes: (wants_raw && path.is_none()).then(|| std::sync::Arc::new(bytes.to_vec())), + slot: None, + question, + policy_applied: false, + }, + ); Ok(warnings) } @@ -2459,6 +2469,9 @@ mod navigator_gpu_tests; #[cfg(test)] mod color_swatch_tests; +#[cfg(test)] +mod profile_open_tests; + #[cfg(test)] mod profile_prompt_tests; diff --git a/crates/ui-egui/src/open_prompt.rs b/crates/ui-egui/src/open_prompt.rs new file mode 100644 index 00000000000..cd04a4fc5fb --- /dev/null +++ b/crates/ui-egui/src/open_prompt.rs @@ -0,0 +1,147 @@ +//! Ask When Opening (Edit › Color Settings): a file whose embedded profile doesn't match the +//! working space (or that has none) is held back while a prompt asks what to do, as Photoshop +//! does: one prompt at a time, naming its file, before that file opens. OK applies the answer to +//! that file and opens it, then asks about the next; Cancel opens neither it nor the files still +//! to come. Files that finish reading while a prompt is up wait their turn, so they open in order. + +use std::sync::Arc; + +use photocraft_doc::{ColorMode, Document}; +use serde_json::{Value, json}; + +use crate::PhotocraftApp; + +/// The field that marks a prompt as this module's. +const FIELD: &str = "__heldOpen"; + +/// A file read and decoded, waiting to open. +pub struct Held { + pub doc: Document, + /// The source is read-only (an Affinity file): Save doesn't write back to it. + pub read_only: bool, + pub name: String, + pub path: Option, + pub warnings: Vec, + /// The file's bytes, for Camera Raw to develop from when there is no path. + pub bytes: Option>>, + /// The tab position a reopened file keeps. + pub slot: Option, + /// The profile question ([`photocraft_engine::color_cmds::ColorState::open_question`]), or + /// `None` to open without asking. + pub question: Option, + /// The Color Settings policy was already applied (a background open added it before it had + /// to wait); otherwise opening applies it. + pub policy_applied: bool, +} + +/// Whether a file that just finished reading has to wait: one earlier in the queue does. +pub fn waiting(app: &PhotocraftApp) -> bool { + !app.held_opens.is_empty() +} + +/// Opens `held` now, or queues it behind the file whose prompt is up. +pub fn arrive(app: &mut PhotocraftApp, held: Held) { + app.held_opens.push_back(held); + advance(app); +} + +/// Opens queued files in order until one needs its prompt, which is shown if it isn't yet. +fn advance(app: &mut PhotocraftApp) { + while let Some(front) = app.held_opens.front() { + if front.question.is_some() { + if !app.ui.dialogs.iter().any(|d| d.fields.contains_key(FIELD)) { + show(app); + } + return; + } + if let Some(held) = app.held_opens.pop_front() { + // A failure is shown to the user (`open_failed`); the next file still opens. + let _ = open(app, held, None); + } + } +} + +/// Shows the Embedded Profile Mismatch (or missing profile) prompt for the first queued file. +fn show(app: &mut PhotocraftApp) { + let Some(front) = app.held_opens.front() else { return }; + let Some(q) = &front.question else { return }; + // The mode reads as the catalogs name it (French "RVB"); the file name goes in last, so + // nothing in it is taken for a placeholder. + let mode = tl!(match front.doc.mode { + ColorMode::Cmyk => "CMYK", + ColorMode::Grayscale => "Gray", + _ => "RGB", + }); + let name = front.name.as_str(); + let working = crate::i18n::fmt(tl!("Working: {working}"), &[("working", q.get("working").and_then(Value::as_str).unwrap_or("?"))]); + let missing = q.get("missing").and_then(Value::as_bool) == Some(true); + let message = if missing { + let what = crate::i18n::fmt(tl!("The document “{name}” does not have an embedded {mode} profile."), &[("mode", mode), ("name", name)]); + format!("{what}\n{working}") + } else { + let what = crate::i18n::fmt( + tl!("The document “{name}” has an embedded color profile that does not match the current {mode} working space."), + &[("mode", mode), ("name", name)], + ); + let embedded = crate::i18n::fmt(tl!("Embedded: {embedded}"), &[("embedded", q.get("embedded").and_then(Value::as_str).unwrap_or("?"))]); + format!("{what}\n{embedded}\n{working}") + }; + let action = q.get("action").and_then(Value::as_str).unwrap_or("preserve").to_string(); + crate::prefs_ui::open_mismatch(app, &message, &action, missing, FIELD); +} + +/// Whether `fields` are one of this module's prompts. +pub fn owns(fields: &serde_json::Map) -> bool { + fields.contains_key(FIELD) +} + +/// The prompt's answer: `Some(action)` (OK) opens the first queued file with it and moves on to +/// the next; `None` (Cancel) drops every queued file and stops the reads still running. +pub fn answer(app: &mut PhotocraftApp, action: Option<&str>) -> Result { + let Some(action) = action else { + let dropped = app.held_opens.len(); + let name = app.held_opens.front().map(|h| h.name.clone()).unwrap_or_default(); + app.held_opens.clear(); + for job in app.jobs.opens.iter().map(|o| o.job).collect::>() { + app.session.cancel_job(job); + } + app.ui.status = crate::i18n::fmt(tl!("Cancelled opening {name}"), &[("name", &name)]); + app.ui.status_error = false; + return Ok(json!({"cancelled": dropped})); + }; + let Some(held) = app.held_opens.pop_front() else { return Err("no file is waiting to open".into()) }; + let r = open(app, held, Some(action)); + advance(app); + r +} + +/// Opens a queued file, applying `action` (the prompt's answer) or else the Color Settings +/// policy, then does what any open does afterwards. +fn open(app: &mut PhotocraftApp, held: Held, action: Option<&str>) -> Result { + let Held { mut doc, read_only, name, path, warnings, bytes, slot, question, policy_applied } = held; + let index = if question.is_some() || policy_applied { + if let Some(action) = action + && let Err(e) = app.session.color.resolve_open(&mut doc, action) + { + app.open_failed(&name, &e.to_string()); + return Err(e.to_string()); + } + app.session.add_document(doc, None) + } else { + app.session.open_document(doc, None).0 + }; + if let Some(st) = app.session.active_mut() { + st.source_read_only = read_only; + } + let index = match slot { + Some(slot) => app + .run("document.move", json!({"document": index, "to": slot})) + .ok() + .and_then(|m| m.get("document").and_then(Value::as_u64)) + .map_or(index, |i| i as usize), + None => index, + }; + app.session.set_active(index); + crate::jobs_ui::after_open(app, &name, path.as_deref(), &warnings, bytes.as_deref().map(Vec::as_slice)); + Ok(json!({"document": index, "action": action})) +} diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index cd7b3af0d05..db908ae23f7 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -994,26 +994,13 @@ pub fn open_shortcuts(app: &mut PhotocraftApp, tab: u64) -> u64 { open_kind(app, "shortcuts", tl!("Keyboard Shortcuts and Menus"), fields) } -/// Open the Embedded Profile Mismatch prompt for the active document. -pub fn open_mismatch(app: &mut PhotocraftApp, report: &Value) -> u64 { - let msg = if report.get("missing").and_then(Value::as_bool) == Some(true) { - let template = "The document does not have an embedded colour profile. Working space: {working}."; - crate::i18n::fmt(tl!(template), &[("working", report["working"].as_str().unwrap_or("?"))]) - } else { - let template = "The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working}"; - crate::i18n::fmt(tl!(template), &[("embedded", report["embedded"].as_str().unwrap_or("?")), ("working", report["working"].as_str().unwrap_or("?"))]) - }; - let action = match report["action"].as_str() { - Some("converted") => "convert", - Some("discarded") => "discard", - _ => "preserve", - }; - open_kind( - app, - "mismatch", - tl!("Embedded Profile Mismatch"), - json!({"message": msg, "action": action, "applied": action, "missing": report.get("missing").is_some()}), - ) +/// Open the Embedded Profile Mismatch prompt for a file waiting to open (`open_prompt`): its +/// `message`, the answer selected first (`action`), and `marker`, the field that routes OK and +/// Cancel back to `open_prompt`. +pub fn open_mismatch(app: &mut PhotocraftApp, message: &str, action: &str, missing: bool, marker: &str) -> u64 { + let mut fields = json!({"message": message, "action": action, "missing": missing}); + fields[marker] = json!(true); + open_kind(app, "mismatch", tl!("Embedded Profile Mismatch"), fields) } // ------------------------------------------------------------------ bodies @@ -2011,13 +1998,10 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result { let action = f.get("action").and_then(Value::as_str).unwrap_or("preserve"); - let applied = f.get("applied").and_then(Value::as_str).unwrap_or("preserve"); - if action == applied { - return Ok(json!({"action": action})); - } - app.run("color.profileMismatch", json!({"action": action})) + crate::open_prompt::answer(app, Some(action)) } _ => Err("unknown dialog".into()), } diff --git a/crates/ui-egui/src/profile_open_tests.rs b/crates/ui-egui/src/profile_open_tests.rs new file mode 100644 index 00000000000..6828da6f70b --- /dev/null +++ b/crates/ui-egui/src/profile_open_tests.rs @@ -0,0 +1,255 @@ +//! Files opened together whose embedded profile doesn't match the working space (File › Open with +//! several selected, Ask When Opening on). Checked in Photoshop 27.11: one Embedded Profile +//! Mismatch prompt at a time, naming its file, before that file opens; OK opens it and asks about +//! the next; Cancel opens neither it nor the files still to come. + +use crate::{PhotocraftApp, Services}; +use egui::{Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::{ + Harness, + kittest::{NodeT, Queryable}, +}; +use photocraft_doc::{Color, ColorMode, Document, SampleType, Size}; +use photocraft_engine::Session; +use serde_json::Value; + +const USE: &str = "Use the embedded profile (instead of the working space)"; +const CONVERT: &str = "Convert document's colors to the working space"; +const DISCARD: &str = "Discard the embedded profile (don't color manage)"; + +/// A PNG tagged with Adobe RGB, which doesn't match the sRGB working space. +fn adobe_png() -> Vec { + let mut d = Document::with_background("t", Size::new(8, 8), ColorMode::Rgb, SampleType::U8, Color::rgba(0.2, 0.6, 0.9, 1.0)); + d.icc_profile = Some(photocraft_cms::Builtin::from_id("adobe-rgb-compat").unwrap().profile().to_bytes()); + photocraft_io::export(&d, "t.png", &Default::default()).unwrap().bytes +} + +fn harness(background: bool) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).with_max_steps(64).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new( + Session::new(), + Services { + import: Some(Box::new(|name, bytes, depth| { + photocraft_io::import_with_svg_group_depth(name, bytes, depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) + })), + ..Default::default() + }, + ) + }); + h.state_mut().background_jobs = background; + h.run_steps(3); + h.state_mut().run("edit.colorSettings", serde_json::json!({"askOnMismatch": true, "policyRgb": "preserve"})).unwrap(); + h +} + +/// Open a.png, b.png and c.png together and let every read finish. +fn open_three(h: &mut Harness<'static, PhotocraftApp>) { + let png = adobe_png(); + for name in ["a.png", "b.png", "c.png"] { + h.state_mut().open_file(&format!("/pics/{name}"), &png).unwrap(); + } + settle(h); +} + +/// Run frames until no open is still reading. +fn settle(h: &mut Harness<'static, PhotocraftApp>) { + for _ in 0..200 { + h.run_steps(2); + if h.state().jobs.opens.is_empty() && h.state().session.jobs().is_empty() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + h.run_steps(6); +} + +fn docs(h: &Harness<'static, PhotocraftApp>) -> Vec { + h.state().session.documents().iter().map(|d| d.doc.name.clone()).collect() +} + +/// The one prompt up: its message. +fn prompt(h: &Harness<'static, PhotocraftApp>) -> String { + let d = &h.state().ui.dialogs; + assert_eq!(d.len(), 1, "one prompt at a time"); + d[0].fields.get("message").and_then(Value::as_str).unwrap_or_default().to_string() +} + +/// A real click (pointer events, hit-tested like a mouse). +fn click(h: &mut Harness<'static, PhotocraftApp>, label: &str) { + let at: Pos2 = h.query_all_by_label(label).find(|n| !n.accesskit_node().is_disabled()).map(|n| n.rect().center()).unwrap(); + h.event(egui::Event::PointerMoved(at)); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(1); + } + settle(h); +} + +fn answer(h: &mut Harness<'static, PhotocraftApp>, choice: &str) { + click(h, choice); + click(h, "OK"); +} + +fn profile(h: &Harness<'static, PhotocraftApp>, name: &str) -> Option { + let st = h.state().session.documents().iter().find(|d| d.doc.name == name).unwrap(); + st.doc.icc_profile.as_ref().map(|b| photocraft_engine::color_cmds::profile_from_bytes(b).unwrap().description.clone()) +} + +/// The file the prompt on screen asks about. +fn asked(h: &Harness<'static, PhotocraftApp>) -> &'static str { + let msg = prompt(h); + ["a.png", "b.png", "c.png", "d.png"] + .into_iter() + .find(|n| msg.starts_with(&format!("The document “{n}” has an embedded color profile that does not match the current RGB working space."))) + .unwrap_or_else(|| panic!("the prompt names its file: {msg}")) +} + +/// Files read on the spot ask in the order they were given. Read in the background, they ask in +/// the order their reads finish, which is the same order unless a later read overtakes an earlier +/// one: the tests hold either way. +fn in_order(background: bool, order: &[&str]) { + if !background { + assert_eq!(order, &["a.png", "b.png", "c.png"][..order.len()]); + } +} + +#[test] +fn files_opened_together_ask_one_at_a_time_before_each_opens() { + for background in [false, true] { + let mut h = harness(background); + open_three(&mut h); + let mut answered: Vec<&str> = Vec::new(); + for _ in 0..3 { + assert_eq!(docs(&h), answered, "background {background}: a file opens only after its prompt"); + let name = asked(&h); + assert!(!answered.contains(&name), "background {background}: {name} asked twice"); + answer(&mut h, USE); + answered.push(name); + } + in_order(background, &answered); + assert!(h.state().ui.dialogs.is_empty()); + assert_eq!(docs(&h), answered, "background {background}: each opened when it was answered"); + assert_eq!(h.state().session.active().map(|d| d.doc.name.as_str()), answered.last().copied()); + } +} + +/// The prompt reads in the interface language: its sentence, the Embedded and Working lines and +/// the mode's name (French "RVB"). The file's name goes in as it is, braces and all. +#[test] +fn the_prompt_is_translated_and_still_names_its_file() { + let png = adobe_png(); + for (code, sentence, embedded, working) in [ + ( + "en", + "The document “shot {mode}.png” has an embedded color profile that does not match the current RGB working space.", + "Embedded: Adobe RGB", + "Working: sRGB", + ), + ( + "de", + "Das Dokument „shot {mode}.png“ hat ein eingebettetes Farbprofil, das nicht zum aktuellen RGB-Arbeitsfarbraum passt.", + "Eingebettet: Adobe RGB", + "Arbeitsfarbraum: sRGB", + ), + ( + "fr", + "Le document «NBSPshot {mode}.pngNBSP» a un profil colorimétrique incorporé qui ne correspond pas à l'espace de travail RVB actuel.", + "IncorporéNBSP: Adobe RGB", + "Espace de travailNBSP: sRGB", + ), + ( + "ja", + "ドキュメント「shot {mode}.png」の埋め込みカラープロファイルは、現在の RGB 作業用スペースと一致しません。", + "埋め込み: Adobe RGB", + "作業用スペース: sRGB", + ), + ] { + let services = Services { + import: Some(Box::new(|name, bytes, depth| { + photocraft_io::import_with_svg_group_depth(name, bytes, depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) + })), + ..Default::default() + }; + let mut app = PhotocraftApp::new(Session::new(), services); + app.run("edit.colorSettings", serde_json::json!({"askOnMismatch": true, "policyRgb": "preserve"})).unwrap(); + let lang = crate::i18n::Lang::from_code(code).unwrap(); + crate::i18n::with_language(lang, || app.open_file("/pics/shot {mode}.png", &png)).unwrap(); + assert!(app.session.documents().is_empty(), "{code}: held until answered"); + let message = app.ui.dialogs[0].fields.get("message").and_then(Value::as_str).unwrap().to_string(); + let lines: Vec<&str> = message.lines().collect(); + assert_eq!(lines.len(), 3, "{code}: {message}"); + assert_eq!(lines[0], sentence.replace("NBSP", "\u{a0}"), "{code}"); + assert!(lines[1].starts_with(&embedded.replace("NBSP", "\u{a0}")), "{code}: {}", lines[1]); + assert!(lines[2].starts_with(&working.replace("NBSP", "\u{a0}")), "{code}: {}", lines[2]); + } +} + +/// #4559 with a held file: type in a font that isn't installed is reported with the import +/// warnings while the file still waits for its profile prompt, and shown once when it opens. +#[test] +fn a_held_file_still_reports_its_missing_fonts() { + const MISSING: &str = "NoSuchFamily3471"; + let warning = format!("font '{MISSING}' is not installed; text may render using a fallback face"); + let mut s = Session::new(); + s.execute("file.new", serde_json::json!({"width": 40, "height": 20})).unwrap(); + s.execute("type.create", serde_json::json!({"text": "Hi", "x": 2, "y": 12, "size": 10, "font": MISSING})).unwrap(); + let mut doc = (*s.active().unwrap().doc).clone(); + doc.icc_profile = Some(photocraft_cms::Builtin::from_id("adobe-rgb-compat").unwrap().profile().to_bytes()); + let services = Services { import: Some(Box::new(move |_: &str, _: &[u8], _: usize| Ok((doc.clone(), Vec::new())))), ..Default::default() }; + let mut app = PhotocraftApp::new(Session::new(), services); + app.run("edit.colorSettings", serde_json::json!({"askOnMismatch": true, "policyRgb": "preserve"})).unwrap(); + assert_eq!(app.open_bytes("poster.psd", b"x").unwrap(), vec![warning.clone()]); + assert!(app.session.documents().is_empty() && app.ui.notices.is_empty(), "held: nothing shown yet"); + crate::open_prompt::answer(&mut app, Some("preserve")).unwrap(); + assert_eq!(app.session.documents().len(), 1); + assert_eq!(app.ui.notices.len(), 1); + assert_eq!(app.ui.notices[0].lines, vec![warning]); +} + +#[test] +fn each_answer_applies_to_its_own_file() { + for background in [false, true] { + let mut h = harness(background); + open_three(&mut h); + // Whichever file a prompt names gets that file's own answer. + let mut answered: Vec<&str> = Vec::new(); + for _ in 0..3 { + let name = asked(&h); + answer( + &mut h, + match name { + "a.png" => CONVERT, + "b.png" => DISCARD, + _ => USE, + }, + ); + answered.push(name); + } + in_order(background, &answered); + assert_eq!(docs(&h), answered); + assert_eq!(profile(&h, "a.png").as_deref(), Some("sRGB IEC61966-2.1"), "background {background}: a converted"); + assert_eq!(profile(&h, "b.png"), None, "background {background}: b discarded"); + assert!(profile(&h, "c.png").unwrap().starts_with("Adobe RGB"), "background {background}: c kept"); + } +} + +#[test] +fn cancel_opens_neither_the_file_nor_the_ones_still_to_come() { + for background in [false, true] { + let mut h = harness(background); + open_three(&mut h); + let first = asked(&h); + in_order(background, &[first]); + answer(&mut h, USE); + click(&mut h, "Cancel"); + settle(&mut h); + assert!(h.state().ui.dialogs.is_empty(), "background {background}: no prompt left"); + assert_eq!(docs(&h), [first], "background {background}"); + // A later open asks again. + h.state_mut().open_file("/pics/d.png", &adobe_png()).unwrap(); + settle(&mut h); + assert_eq!(asked(&h), "d.png"); + } +} diff --git a/crates/ui-egui/src/profile_prompt_tests.rs b/crates/ui-egui/src/profile_prompt_tests.rs index ca206b6a528..cec2a8c289d 100644 --- a/crates/ui-egui/src/profile_prompt_tests.rs +++ b/crates/ui-egui/src/profile_prompt_tests.rs @@ -1,6 +1,6 @@ -//! Embedded Profile Mismatch prompts when several files open at once (File › Open with several -//! selected): every prompt can be answered with a real click, one after another, even when the -//! user clicks while the next file's prompt is appearing. +//! Stacked dialogs of the same size in the same place (Embedded Profile Mismatch prompts used to +//! stack like this when several files opened at once): each can be answered with a real click, +//! one after another, even when an older one has been raised above the live one. use crate::{PhotocraftApp, Services}; use egui::{Modifiers, PointerButton, Pos2, vec2}; @@ -8,16 +8,8 @@ use egui_kittest::{ Harness, kittest::{NodeT, Queryable}, }; -use photocraft_doc::{Color, ColorMode, Document, SampleType, Size}; use photocraft_engine::Session; -/// A PNG tagged with Adobe RGB, which doesn't match the sRGB working space. -fn adobe_png() -> Vec { - let mut d = Document::with_background("t", Size::new(8, 8), ColorMode::Rgb, SampleType::U8, Color::rgba(0.2, 0.6, 0.9, 1.0)); - d.icc_profile = Some(photocraft_cms::Builtin::from_id("adobe-rgb-compat").unwrap().profile().to_bytes()); - photocraft_io::export(&d, "t.png", &Default::default()).unwrap().bytes -} - fn harness(background: bool) -> Harness<'static, PhotocraftApp> { let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).with_max_steps(64).build_eframe(|cc| { PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); @@ -36,11 +28,13 @@ fn harness(background: bool) -> Harness<'static, PhotocraftApp> { h } -/// Open `n` mismatched files in one go and wait until their prompts are up and laid out. +/// Stack `n` identical Embedded Profile Mismatch dialogs (same size, same place; only the newest +/// is live) and wait until they are up and laid out. Opening files no longer stacks them (they ask +/// one at a time, `profile_open_tests`), so they are opened directly. fn open_together(h: &mut Harness<'static, PhotocraftApp>, n: usize) { - let png = adobe_png(); - for i in 0..n { - h.state_mut().open_file(&format!("shot{i}.png"), &png).unwrap(); + let message = "The document “shot.png” has an embedded color profile that does not match the current RGB working space."; + for _ in 0..n { + crate::prefs_ui::open_mismatch(h.state_mut(), message, "preserve", false, "__stacked"); } for _ in 0..100 { h.run_steps(2); From be7a1f80fc06954b52b5851c58d254c2ce7bdf8f Mon Sep 17 00:00:00 2001 From: Timothy Daly <100327160+timothydaly1@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:10:11 -0400 Subject: [PATCH 37/47] Blur Gallery: Field Blur pins on the image (#3373) (#3801) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Blur Gallery: Field Blur pins on the image (#3373) Field Blur opened a parameter dialog with one centre, though the engine already blends any number of pins. It now works as Photoshop's Blur Gallery workspace: the image under the dialog takes the pointer. A click adds a pin and selects it, a drag moves a pin, and a drag around the selected pin's ring turns its blur up or down (one turn is the 0-500 px range; the arc shows the amount). Delete or Backspace removes the selected pin. The dialog shows the selected pin's Blur and Preview, and opens at the right edge, beside the image, so the pins stay in view. The pins live in the dialog's fields (`pins`, `__selected`), so the existing filter preview follows every change, OK applies filter.blurGallery.fieldBlur with them in one step, and automation drives it with ui.dialog.set. Pins are fractions of the selection's bounds when there is one, else the canvas, as the engine reads them. Proxy previews now scale Blur Gallery pins' pixel blur like other pixel params. Co-Authored-By: Claude Opus 5.5 * i18n(vi): translate the Field Blur pin strings main added the Vietnamese catalog after this branch was cut, so every_tl_literal_is_translated failed on the merge for "No pin selected" and the pin hint. Terms follow the catalog's own: ghim (pin), Bấm (click), Delete kept as the key name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE * Blur Gallery: Alt+Cancel Reset starts again from one pin main's Alt+Cancel → Reset (#3871) restores a filter dialog's schema defaults, and the schema has none for Field Blur's pins, so Reset left the workspace with no pins. blur_pins::reset now puts back the opening pin (in the middle, selected), and reset_fields runs it for the Blur Gallery. init is reset plus capturing the bounds, which Reset keeps. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE * i18n(hu, tr): translate the Field Blur pin strings main added complete Hungarian and Turkish catalogs after this branch was cut. Terms follow each catalog: tű / Mezőelmosás (informal Hungarian), pim / Alan Bulanıklığı (formal Turkish). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE * i18n(sr): translate the Field Blur pin strings Main gained a complete Serbian catalogue (#4436); every_tl_literal_is_translated needs these strings there too. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/blur_pins.rs | 311 ++++++++++++++++++++++++++ crates/ui-egui/src/blur_pins_tests.rs | 193 ++++++++++++++++ crates/ui-egui/src/canvas.rs | 11 +- crates/ui-egui/src/dialogs.rs | 3 +- crates/ui-egui/src/filter_dialog.rs | 11 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/nl.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sr.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/lib.rs | 4 + docs/gaps.md | 5 +- 27 files changed, 592 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/src/blur_pins.rs create mode 100644 crates/ui-egui/src/blur_pins_tests.rs diff --git a/crates/ui-egui/src/blur_pins.rs b/crates/ui-egui/src/blur_pins.rs new file mode 100644 index 00000000000..6a90d9910ae --- /dev/null +++ b/crates/ui-egui/src/blur_pins.rs @@ -0,0 +1,311 @@ +//! Filter › Blur Gallery › Field Blur… as Photoshop's workspace (#3373): pins on the image, each +//! with its own blur, interpolated between them. While the dialog is open the image under it takes +//! the pointer: a click on the image adds a pin (and selects it), a drag moves a pin, and a drag on +//! the selected pin's ring turns its blur up or down (one turn = the full 0–500 px; the arc shows +//! the amount). Delete or Backspace removes the selected pin. The dialog shows the selected pin's +//! Blur and the Preview switch; OK runs `filter.blurGallery.fieldBlur` with the `pins`. +//! +//! Everything lives in the dialog's fields, so automation drives it with `ui.dialog.set`: `pins` +//! (`[{x, y, blur}]`, x and y as fractions of the filter's bounds) and `__selected`. The bounds are +//! the engine's: the selection's when there is one, else the canvas. + +use egui::{Color32, Pos2, Stroke, vec2}; +use serde_json::{Map, Value, json}; + +use crate::PhotocraftApp; +use crate::canvas::ViewXform; + +pub const COMMAND: &str = "filter.blurGallery.fieldBlur"; +/// Field Blur's range, in pixels. +const MAX_BLUR: f64 = 500.0; +/// Photoshop's first pin. +const DEFAULT_BLUR: f64 = 15.0; +/// More pins than anyone places by hand; keeps the params and the per-pixel loop bounded. +pub const MAX_PINS: usize = 64; +/// Screen radii (points): the pin's dot, how near a press must be to grab it, and its ring. +const DOT: f32 = 5.0; +const GRAB: f32 = 9.0; +const RING: f32 = 24.0; +const RING_BAND: f32 = 5.0; + +pub fn owns(fields: &Map) -> bool { + fields.get("__command").and_then(Value::as_str) == Some(COMMAND) +} + +/// Turn a freshly opened Field Blur dialog into the workspace: one pin in the middle of the +/// filter's bounds with the remembered blur, selected. +pub fn init(app: &mut PhotocraftApp, fields: &mut Map) { + reset(fields); + let bounds = app.session.active().map(|st| (st.doc.id, st.doc.bounds(), st.doc.selection.clone())); + if let Some((doc, canvas, selection)) = bounds { + let b = selection.map(|s| app.cached_bounds(u64::MAX - doc.0, &s)).filter(|b| !b.is_empty()).unwrap_or(canvas); + fields.insert("__bounds".into(), json!([b.x0, b.y0, b.x1, b.y1])); + } +} + +/// One pin in the middle with the dialog's blur, selected. Alt+Cancel (Reset) runs this alone, so +/// the bounds captured on opening stay. +pub fn reset(fields: &mut Map) { + let blur = fields.get("blur").and_then(Value::as_f64).filter(|b| b.is_finite()).unwrap_or(DEFAULT_BLUR).clamp(0.0, MAX_BLUR); + fields.remove("centerX"); + fields.remove("centerY"); + fields.insert("blur".into(), json!(blur)); + fields.insert("pins".into(), json!([{"x": 0.5, "y": 0.5, "blur": blur}])); + fields.insert("__selected".into(), json!(0)); +} + +/// Where the dialog first opens (offset from the window's top-left): at the right edge, beside the +/// image, as Photoshop docks its Blur Tools panel, so the pins under it stay in view. Moved, it +/// reopens where it was left, like any dialog. +pub fn first_place(fields: &Map, ctx: &egui::Context) -> Option { + owns(fields).then(|| vec2((ctx.content_rect().width() - 480.0).max(0.0), 96.0)) +} + +/// One pin: position as fractions of the bounds, blur in pixels. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Pin { + pub x: f64, + pub y: f64, + pub blur: f64, +} + +/// The pins in `fields`, cleaned (finite, in range, at most [`MAX_PINS`]). +pub fn pins(fields: &Map) -> Vec { + let Some(list) = fields.get("pins").and_then(Value::as_array) else { return Vec::new() }; + let num = |v: &Value, k: &str, d: f64| v.get(k).and_then(Value::as_f64).filter(|x| x.is_finite()).unwrap_or(d); + list.iter() + .take(MAX_PINS) + .map(|v| Pin { x: num(v, "x", 0.5).clamp(0.0, 1.0), y: num(v, "y", 0.5).clamp(0.0, 1.0), blur: num(v, "blur", DEFAULT_BLUR).clamp(0.0, MAX_BLUR) }) + .collect() +} + +fn set_pins(fields: &mut Map, pins: &[Pin]) { + let list: Vec = pins.iter().map(|p| json!({"x": p.x, "y": p.y, "blur": p.blur})).collect(); + fields.insert("pins".into(), Value::Array(list)); +} + +/// The selected pin's index, if it exists. +pub fn selected(fields: &Map) -> Option { + let n = pins(fields).len(); + fields.get("__selected").and_then(Value::as_u64).map(|i| i as usize).filter(|i| *i < n) +} + +fn select(fields: &mut Map, i: Option) { + fields.insert("__selected".into(), i.map_or(Value::Null, |i| json!(i))); + // The dialog's Blur (remembered for the next Field Blur) follows the selected pin. + if let Some(p) = i.and_then(|i| pins(fields).get(i).copied()) { + fields.insert("blur".into(), json!(p.blur)); + } +} + +/// The filter's bounds in document pixels `[x0, y0, x1, y1]`. +fn bounds(fields: &Map) -> Option<[f64; 4]> { + let b = fields.get("__bounds")?.as_array()?; + let v = |i: usize| b.get(i).and_then(Value::as_f64); + let r = [v(0)?, v(1)?, v(2)?, v(3)?]; + (r[2] > r[0] && r[3] > r[1]).then_some(r) +} + +fn to_doc(b: [f64; 4], p: Pin) -> [f64; 2] { + [b[0] + p.x * (b[2] - b[0]), b[1] + p.y * (b[3] - b[1])] +} + +fn from_doc(b: [f64; 4], d: [f64; 2]) -> (f64, f64) { + (((d[0] - b[0]) / (b[2] - b[0])).clamp(0.0, 1.0), ((d[1] - b[1]) / (b[3] - b[1])).clamp(0.0, 1.0)) +} + +/// Remove the selected pin; the one before it (or the new first) is selected. +pub fn remove_selected(fields: &mut Map) -> bool { + let Some(i) = selected(fields) else { return false }; + let mut list = pins(fields); + list.remove(i); + set_pins(fields, &list); + select(fields, if list.is_empty() { None } else { Some(i.saturating_sub(1)) }); + true +} + +/// The dialog body: the selected pin's Blur, how to place pins, and Preview. +pub fn body(ui: &mut egui::Ui, f: &mut Map) { + let t = crate::theme::Tokens::get(ui.ctx()); + match selected(f) { + Some(i) => { + let mut list = pins(f); + if let Some(pin) = list.get_mut(i) { + let mut v = pin.blur as f32; + if crate::widgets::slider_row(ui, tl!("Blur"), &mut v, 0.0..=MAX_BLUR as f32, "px", None).changed() { + pin.blur = f64::from(v.round()).clamp(0.0, MAX_BLUR); + set_pins(f, &list); + select(f, Some(i)); + } + } + } + None => { + ui.label(egui::RichText::new(tl!("No pin selected")).color(t.text_dim)); + } + } + ui.add_space(4.0); + ui.add( + egui::Label::new( + egui::RichText::new(tl!("Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin.")) + .color(t.text_dim) + .size(11.5), + ) + .wrap(), + ); + // Delete removes the selected pin, unless a field is being typed in. + let typing = ui.ctx().memory(|m| m.focused().is_some()); + if !typing && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Delete) || i.consume_key(egui::Modifiers::NONE, egui::Key::Backspace)) { + remove_selected(f); + ui.ctx().request_repaint(); + } + if let Some(mut preview) = f.get("__preview").and_then(Value::as_bool) { + ui.add_space(4.0); + crate::widgets::checkbox(ui, &mut preview, tl!("Preview")); + f.insert("__preview".into(), json!(preview)); + } +} + +/// What a press at screen point `at` grabs: the selected pin's ring, a pin, or nothing (a new pin). +#[derive(Clone, Copy, Debug, PartialEq)] +enum Grab { + Ring(usize), + Pin(usize), + New, +} + +fn grab(fields: &Map, xf: &ViewXform, at: Pos2) -> Grab { + let Some(b) = bounds(fields) else { return Grab::New }; + let list = pins(fields); + let screen = |p: Pin| { + let d = to_doc(b, p); + xf.to_screen(d[0] as f32, d[1] as f32) + }; + if let Some(i) = selected(fields) + && let Some(p) = list.get(i) + && (screen(*p).distance(at) - RING).abs() <= RING_BAND + { + return Grab::Ring(i); + } + list.iter() + .enumerate() + .map(|(i, p)| (i, screen(*p).distance(at))) + .filter(|(_, d)| *d <= GRAB) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map_or(Grab::New, |(i, _)| Grab::Pin(i)) +} + +/// The pointer on the free image under the dialog: `over` is where it is, `press` where the +/// primary button is held (`dialogs::free_press`), both in screen points. Returns whether the +/// workspace took it. +pub fn canvas(app: &mut PhotocraftApp, ctx: &egui::Context, xf: &ViewXform, over: Pos2, press: Option) -> bool { + let Some(d) = app.ui.dialogs.last_mut().filter(|d| owns(&d.fields)) else { return false }; + let f = &mut d.fields; + let Some(b) = bounds(f) else { return false }; + let (start, down) = ctx.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_down())); + ctx.set_cursor_icon(match grab(f, xf, over) { + Grab::Ring(_) => egui::CursorIcon::Alias, + Grab::Pin(_) => egui::CursorIcon::Grab, + Grab::New => egui::CursorIcon::Crosshair, + }); + if !down && press.is_none() { + f.remove("__drag"); + return true; + } + let Some(at) = press else { return true }; + let doc = xf.to_doc(at); + let mut list = pins(f); + if start { + let drag = match grab(f, xf, at) { + Grab::Ring(i) => { + let c = list.get(i).map(|p| to_doc(b, *p)).unwrap_or(doc); + json!({"ring": i, "angle": (doc[1] - c[1]).atan2(doc[0] - c[0])}) + } + Grab::Pin(i) => { + select(f, Some(i)); + let c = list.get(i).map(|p| to_doc(b, *p)).unwrap_or(doc); + json!({"pin": i, "dx": c[0] - doc[0], "dy": c[1] - doc[1]}) + } + Grab::New if list.len() < MAX_PINS && (b[0]..=b[2]).contains(&doc[0]) && (b[1]..=b[3]).contains(&doc[1]) => { + // A new pin takes the selected pin's blur, as the dialog's Blur shows it. + let blur = f.get("blur").and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(DEFAULT_BLUR).clamp(0.0, MAX_BLUR); + let (x, y) = from_doc(b, doc); + list.push(Pin { x, y, blur }); + set_pins(f, &list); + select(f, Some(list.len() - 1)); + json!({"pin": list.len() - 1, "dx": 0.0, "dy": 0.0}) + } + Grab::New => return true, + }; + f.insert("__drag".into(), drag); + ctx.request_repaint(); + return true; + } + let Some(drag) = f.get("__drag").cloned() else { return true }; + if let Some(i) = drag.get("pin").and_then(Value::as_u64).map(|i| i as usize) + && let Some(p) = list.get_mut(i) + { + let num = |k: &str| drag.get(k).and_then(Value::as_f64).unwrap_or(0.0); + (p.x, p.y) = from_doc(b, [doc[0] + num("dx"), doc[1] + num("dy")]); + set_pins(f, &list); + } else if let Some(i) = drag.get("ring").and_then(Value::as_u64).map(|i| i as usize) + && let Some(p) = list.get_mut(i) + { + // The angle turned since the last frame, the short way round: clockwise raises the blur. + let c = to_doc(b, *p); + let a = (doc[1] - c[1]).atan2(doc[0] - c[0]); + let last = drag.get("angle").and_then(Value::as_f64).unwrap_or(a); + let turn = (a - last + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI; + p.blur = (p.blur + turn / std::f64::consts::TAU * MAX_BLUR).clamp(0.0, MAX_BLUR); + set_pins(f, &list); + select(f, Some(i)); + f.insert("__drag".into(), json!({"ring": i, "angle": a})); + } + ctx.request_repaint(); + true +} + +/// Draw the pins over the image while the workspace is open: a dot per pin (filled when +/// selected) and, round the selected one, its ring with the blur as an arc from the top. +pub fn draw(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { + let Some(d) = app.ui.dialogs.last().filter(|d| owns(&d.fields)) else { return }; + let Some(b) = bounds(&d.fields) else { return }; + let sel = selected(&d.fields); + let (dark, light) = (Stroke::new(3.0, Color32::from_black_alpha(150)), Stroke::new(1.5, Color32::from_white_alpha(235))); + for (i, p) in pins(&d.fields).into_iter().enumerate() { + let c = to_doc(b, p); + let c = xf.to_screen(c[0] as f32, c[1] as f32); + if sel == Some(i) { + painter.circle_stroke(c, RING, dark); + painter.circle_stroke(c, RING, Stroke::new(1.0, Color32::from_white_alpha(140))); + let frac = (p.blur / MAX_BLUR) as f32; + let steps = (frac * 64.0).ceil().max(1.0) as usize; + let arc: Vec = (0..=steps) + .map(|k| { + let a = -std::f32::consts::FRAC_PI_2 + std::f32::consts::TAU * frac * k as f32 / steps as f32; + c + vec2(a.cos(), a.sin()) * RING + }) + .collect(); + if frac > 0.0 { + painter.add(egui::Shape::line(arc.clone(), Stroke::new(4.5, Color32::from_black_alpha(150)))); + painter.add(egui::Shape::line(arc, Stroke::new(2.5, Color32::WHITE))); + } + painter.circle(c, DOT, Color32::WHITE, Stroke::new(1.0, Color32::from_black_alpha(180))); + } else { + painter.circle_stroke(c, DOT, dark); + painter.circle_stroke(c, DOT, light); + } + } +} + +/// Preview proxies run at 1/`k` of the document: the pins' pixel blur scales with them. +pub fn scale_for_proxy(params: &mut Value, k: u32) { + if k <= 1 { + return; + } + let Some(list) = params.get_mut("pins").and_then(Value::as_array_mut) else { return }; + for pin in list { + if let Some(b) = pin.get("blur").and_then(Value::as_f64) { + pin["blur"] = json!(b / f64::from(k)); + } + } +} diff --git a/crates/ui-egui/src/blur_pins_tests.rs b/crates/ui-egui/src/blur_pins_tests.rs new file mode 100644 index 00000000000..33a691d3d85 --- /dev/null +++ b/crates/ui-egui/src/blur_pins_tests.rs @@ -0,0 +1,193 @@ +//! #3373: Field Blur's pins are placed, dragged and tuned on the image under the dialog, the +//! preview follows them, and OK applies them. + +use egui::{Modifiers, PointerButton, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::blur_pins::{COMMAND, Pin, pins, selected}; +use crate::canvas::ViewXform; + +/// A 400×300 document at 400 %, so it fills the window round the dialog, with Field Blur open. +fn harness(selection: bool) -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + if selection { + app.run("select.rect", json!({"x": 100, "y": 50, "width": 200, "height": 100})).unwrap(); + } + app.sync_views(); + app.ui.extras.rulers = false; + let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::shortcuts::handle(app, &ctx); + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + crate::dialogs::show(app, &ctx); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + let v = &mut h.state_mut().ui.views[0]; + (v.zoom, v.center, v.fit_pending) = (4.0, [200.0, 150.0], false); + crate::filter_dialog::open(h.state_mut(), COMMAND).unwrap(); + h.run_steps(4); + h +} + +fn fields(h: &Harness<'static, PhotocraftApp>) -> serde_json::Map { + h.state().ui.dialogs.last().unwrap().fields.clone() +} + +fn xf(h: &Harness<'static, PhotocraftApp>) -> ViewXform { + let app = h.state(); + let v = &app.ui.views[0]; + ViewXform { + rect: crate::rulers::content_rect(app, app.last_canvas_rect), + zoom: v.zoom, + center: v.center, + flip: false, + rotation: v.rotation, + aspect: app.ui.view.display_aspect(), + } +} + +fn button(h: &mut Harness<'static, PhotocraftApp>, p: Pos2, down: bool) { + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: down, modifiers: Modifiers::NONE }); + h.run_steps(1); +} + +fn move_to(h: &mut Harness<'static, PhotocraftApp>, p: Pos2) { + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); +} + +fn drag(h: &mut Harness<'static, PhotocraftApp>, path: &[Pos2]) { + move_to(h, path[0]); + button(h, path[0], true); + for p in &path[1..] { + move_to(h, *p); + } + button(h, *path.last().unwrap(), false); + h.run_steps(2); +} + +/// Where pin `p` is on screen. +fn on_screen(h: &Harness<'static, PhotocraftApp>, p: Pin, bounds: [f64; 4]) -> Pos2 { + let (x, y) = (bounds[0] + p.x * (bounds[2] - bounds[0]), bounds[1] + p.y * (bounds[3] - bounds[1])); + xf(h).to_screen(x as f32, y as f32) +} + +#[test] +fn it_opens_with_one_selected_pin_in_the_middle_of_the_bounds() { + let h = harness(false); + let f = fields(&h); + assert_eq!(pins(&f), vec![Pin { x: 0.5, y: 0.5, blur: 15.0 }]); + assert_eq!(selected(&f), Some(0)); + assert_eq!(f["__bounds"], json!([0, 0, 400, 300])); + assert!(!f.contains_key("centerX") && !f.contains_key("centerY"), "the pins replace the single centre"); + // With a selection the pins are fractions of its bounds, as the engine reads them. + let h = harness(true); + assert_eq!(fields(&h)["__bounds"], json!([100, 50, 300, 150])); +} + +#[test] +fn pins_are_added_moved_tuned_and_removed_on_the_image() { + let mut h = harness(false); + let r = h.state().last_canvas_rect; + // A click on the free image (left of the dialog) adds a pin there, selected. + let a = pos2(r.left() + 80.0, r.bottom() - 80.0); + move_to(&mut h, a); + button(&mut h, a, true); + button(&mut h, a, false); + h.run_steps(2); + let f = fields(&h); + let list = pins(&f); + assert_eq!(list.len(), 2, "{list:?}"); + assert_eq!(selected(&f), Some(1)); + let at = xf(&h).to_doc(a); + assert!((list[1].x - at[0] / 400.0).abs() < 1e-3 && (list[1].y - at[1] / 300.0).abs() < 1e-3, "{:?} vs {at:?}", list[1]); + assert_eq!(list[1].blur, 15.0, "a new pin takes the selected pin's blur"); + // Dragging the pin moves it by the pointer's movement (4 screen points per pixel at 400 %). + let b = a + vec2(40.0, -20.0); + drag(&mut h, &[a, a + vec2(20.0, -10.0), b]); + let moved = pins(&fields(&h))[1]; + assert!((moved.x - (list[1].x + 10.0 / 400.0)).abs() < 1e-3 && (moved.y - (list[1].y - 5.0 / 300.0)).abs() < 1e-3, "{moved:?}"); + assert!(fields(&h).get("__drag").is_none(), "the drag ends on release"); + // A quarter turn clockwise on its ring adds a quarter of the 0–500 px range. + let c = on_screen(&h, moved, [0.0, 0.0, 400.0, 300.0]); + let ring: Vec = (0..=6).map(|k| c + vec2(24.0 * (k as f32 * 0.2618).cos(), 24.0 * (k as f32 * 0.2618).sin())).collect(); + drag(&mut h, &ring); + let tuned = pins(&fields(&h))[1]; + assert!((tuned.blur - 140.0).abs() < 2.0, "15 + 125 px: {}", tuned.blur); + assert!((tuned.x - moved.x).abs() < 1e-9, "the ring doesn't move the pin"); + assert_eq!(fields(&h)["blur"], json!(tuned.blur), "the dialog's Blur follows the selected pin"); + // The preview runs the filter with the pins (computed by the canvas's GPU path in the app; + // asked for directly here, as the headless harness has no GPU). + assert!(crate::canvas::ensure_filter_preview(h.state_mut(), 0, &egui::Context::default()).is_some()); + let preview = h.state().filter_preview.as_ref().expect("a live preview"); + assert_eq!(preview.key.params["pins"].as_array().map(Vec::len), Some(2)); + // Delete removes the selected pin; OK applies the rest in one step. + h.key_press(egui::Key::Delete); + h.run_steps(2); + assert_eq!(pins(&fields(&h)).len(), 1); + assert_eq!(selected(&fields(&h)), Some(0)); + let id = h.state().ui.dialogs.last().unwrap().id; + let steps = h.state().session.active().unwrap().history.past_len(); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + let (cmd, p) = h.state().session.journal.last().cloned().unwrap(); + assert_eq!(cmd, COMMAND); + assert_eq!(p["pins"], json!([{"x": 0.5, "y": 0.5, "blur": 15.0}]), "{p}"); + assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1); +} + +/// A click outside the bounds (beside a selection) adds no pin; pins are capped. +#[test] +fn pins_stay_inside_the_bounds_and_are_capped() { + let mut h = harness(true); + let r = h.state().last_canvas_rect; + let outside = pos2(r.left() + 30.0, r.top() + 30.0); + assert!(xf(&h).to_doc(outside)[0] < 100.0, "left of the selection"); + move_to(&mut h, outside); + button(&mut h, outside, true); + button(&mut h, outside, false); + h.run_steps(2); + assert_eq!(pins(&fields(&h)).len(), 1); + let many: Vec<_> = (0..200).map(|i| json!({"x": f64::from(i) / 200.0, "y": 0.5, "blur": 1e9})).collect(); + h.state_mut().ui.dialogs.last_mut().unwrap().fields.insert("pins".into(), json!(many)); + let list = pins(&fields(&h)); + assert_eq!(list.len(), crate::blur_pins::MAX_PINS); + assert!(list.iter().all(|p| p.blur == 500.0)); +} + +#[test] +fn proxy_previews_scale_the_pins_blur() { + let mut p = json!({"pins": [{"x": 0.2, "y": 0.3, "blur": 40.0}, {"x": 0.9, "y": 0.9, "blur": 0.0}]}); + crate::blur_pins::scale_for_proxy(&mut p, 4); + assert_eq!(p, json!({"pins": [{"x": 0.2, "y": 0.3, "blur": 10.0}, {"x": 0.9, "y": 0.9, "blur": 0.0}]})); + crate::blur_pins::scale_for_proxy(&mut p, 1); + assert_eq!(p["pins"][0]["blur"], json!(10.0)); +} + +/// Alt+Cancel → Reset (#3871) starts the workspace again from one pin in the middle, as when it +/// opened, instead of leaving it with no pins; the bounds captured on opening stay. +#[test] +fn alt_reset_starts_again_from_one_pin() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let id = crate::filter_dialog::open(&mut app, COMMAND).unwrap(); + let mut fields = app.ui.dialog_mut(id).unwrap().fields.clone(); + let bounds = fields["__bounds"].clone(); + fields.insert("pins".into(), json!([{"x": 0.1, "y": 0.2, "blur": 40.0}, {"x": 0.8, "y": 0.7, "blur": 90.0}])); + fields.insert("__selected".into(), json!(1)); + assert!(crate::dialog_alt_reset::supported(&fields)); + crate::dialog_alt_reset::apply(&mut app, &egui::Context::default(), &mut fields); + assert_eq!(pins(&fields), [Pin { x: 0.5, y: 0.5, blur: 15.0 }]); + assert_eq!(selected(&fields), Some(0)); + assert_eq!(fields["__bounds"], bounds); + assert!(!fields.contains_key("centerX") && !fields.contains_key("centerY"), "{fields:?}"); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 23374d1683c..db0efe31fa5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3214,7 +3214,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // Select and Mask's Refine Edge Brush paints on the image. let refine_brushing = primary && app.ui.dialogs.last().is_some_and(|d| crate::select_mask_view::owns(&d.fields) && crate::select_mask_view::brush_on(&d.fields)); - let hand = app.ui.tool == Tool::Hand && !picking && !range_picking && !refine_brushing; + // Field Blur's pins are placed and dragged on the image (`blur_pins`). + let pinning = primary && app.ui.dialogs.last().is_some_and(|d| crate::blur_pins::owns(&d.fields)); + let hand = app.ui.tool == Tool::Hand && !picking && !range_picking && !refine_brushing && !pinning; if let Some(d) = crate::dialogs::pan_delta(&ctx, rect, hand) { let d = xf.unmap_vec(d) / (view.zoom / ppp); view.center[0] -= d.x; @@ -3238,6 +3240,12 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: crate::color_picker_ui::sample_at(app, d[0], d[1]); } } + } else if pinning + && !middle + && let Some(p) = crate::dialogs::free_pointer_over(&ctx, rect) + { + let press = crate::dialogs::free_press(&ctx, rect); + crate::blur_pins::canvas(app, &ctx, &xf, p, press); } else if refine_brushing && !middle && let Some(p) = crate::dialogs::free_pointer_over(&ctx, rect) @@ -4445,6 +4453,7 @@ fn draw_tool_state(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, crate::tool_feedback::draw_ants(painter, &pts, false); } crate::crop_straighten::draw(app, painter, |p| xf.to_screen(p[0] as f32, p[1] as f32), hover); + crate::blur_pins::draw(app, painter, xf); if let Some(c) = app.ui.crop_rect { let deg = crate::crop_ui::angle(app); let shield = crate::crop_shield::current(app, painter.ctx()); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 918525a1321..0530031f93c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -157,7 +157,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { // effects, extends down and right instead of re-centring. let pinned: Option = ctx.data(|m| m.get_temp(id)); let place = place_id(&d); - let start = pinned.or_else(|| ctx.data_mut(|m| m.get_persisted::(place))); + let start = pinned.or_else(|| ctx.data_mut(|m| m.get_persisted::(place))).or_else(|| crate::blur_pins::first_place(&d.fields, ctx)); let mut drag = egui::Vec2::ZERO; let mut sizing = false; let area = match start { @@ -305,6 +305,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { // Image Size and Canvas Size open on Width, selected (#2329). DialogKind::Command if fields.contains_key("__sizing") => first_field_focused(ui, id, |ui| crate::sizing::body(ui, &mut fields)), DialogKind::Command if crate::adjust_dialog::owns(&fields) => crate::adjust_dialog::body(app, ui, &mut fields), + DialogKind::Command if crate::blur_pins::owns(&fields) => crate::blur_pins::body(ui, &mut fields), DialogKind::Command if fields.contains_key("__filter") => { // Color Settings: the monitor profile in use can change while it is open. if fields.get("__command").and_then(Value::as_str) == Some("edit.colorSettings") { diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index 90073a55015..89fcd3ef90c 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -435,6 +435,10 @@ pub fn reset_fields(app: &PhotocraftApp, fields: &mut Map) { } } apply_command_defaults(app, &command, fields); + // The schema has no default for the Blur Gallery's pins: start again from the opening one. + if command == crate::blur_pins::COMMAND { + crate::blur_pins::reset(fields); + } } pub fn open(app: &mut PhotocraftApp, command: &str) -> Option { @@ -454,6 +458,9 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option { } restore_remembered(app, command, spec.params, &mut fields); apply_command_defaults(app, command, &mut fields); + if command == crate::blur_pins::COMMAND { + crate::blur_pins::init(app, &mut fields); + } if parse_spec(spec.params).iter().any(|p| p.kind == Kind::Document) { // The document picker lists every open document (params refer to them by index). let names: Vec = app.session.documents().iter().map(|d| d.doc.name.clone()).collect(); @@ -906,6 +913,10 @@ pub(crate) fn preview_document_for_target( } } } + // Blur Gallery pins carry their blur in document pixels too. + if command.starts_with("filter.blurGallery.") { + crate::blur_pins::scale_for_proxy(&mut p, run_k); + } s.execute(command, p).ok()?; s.active().map(|d| crate::proxy::proxy_document(&d.doc, reduce_k)) } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 2a8008e7901..805c20f76a2 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -559,6 +559,9 @@ Blur Rozostření Blur Gallery Galerie rozostření Field Blur… Rozostření pole… +# Field Blur pins (#3373) + No pin selected Není vybrán žádný špendlík + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Kliknutím do obrazu přidáte špendlík; tažením špendlík přesunete, tažením jeho prstence změníte rozostření. Delete odstraní vybraný špendlík. Iris Blur… Rozostření clonou… Tilt-Shift… Tilt-shift… Path Blur… Rozostření podél cesty… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 86dba897892..cccbfbb35fc 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -558,6 +558,9 @@ Blur Weichzeichnen Blur Gallery Weichzeichnen-Galerie Field Blur… Feldweichzeichner… +# Field Blur pins (#3373) + No pin selected Kein Pin ausgewählt + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Klicken Sie ins Bild, um einen Pin hinzuzufügen; ziehen Sie einen Pin, um ihn zu verschieben, oder seinen Ring, um die Unschärfe zu ändern. Entf entfernt den ausgewählten Pin. Iris Blur… Irisweichzeichner… Tilt-Shift… Tilt-Shift… Path Blur… Pfadweichzeichner… diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index c2fdfd522dd..e8889313cb2 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2398,6 +2398,9 @@ Fibers… Ίνες… Field Blur Θόλωση πεδίου Field Blur… Θόλωση πεδίου… +# Field Blur pins (#3373) + No pin selected Δεν έχει επιλεγεί καρφίτσα + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Κάντε κλικ στην εικόνα για να προσθέσετε καρφίτσα· σύρετε μια καρφίτσα για να τη μετακινήσετε ή τον δακτύλιό της για να αλλάξετε τη θόλωση. Το Delete αφαιρεί την επιλεγμένη καρφίτσα. File Info Πληροφορίες αρχείου File Settings Ρυθμίσεις αρχείου Fill Path Γέμισμα διαδρομής diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e90f8f7ef34..37f88f4294d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -555,6 +555,9 @@ Blur Desenfocar Blur Gallery Galería de desenfoque Field Blur… Desenfoque de campo… +# Field Blur pins (#3373) + No pin selected Ningún pin seleccionado + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Haga clic en la imagen para añadir un pin; arrastre un pin para moverlo o su anillo para cambiar el desenfoque. Suprimir elimina el pin seleccionado. Iris Blur… Desenfoque de iris… Tilt-Shift… Cambio de inclinación… Path Blur… Desenfoque de trazado… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6d915c686bf..f7d508fb5c6 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -543,6 +543,9 @@ Blur Flou Blur Gallery Galerie de flous Field Blur… Flou de champ… +# Field Blur pins (#3373) + No pin selected Aucune épingle sélectionnée + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Cliquez dans l’image pour ajouter une épingle ; faites glisser une épingle pour la déplacer, ou son anneau pour modifier le flou. Suppr supprime l’épingle sélectionnée. Iris Blur… Flou d'iris… Tilt-Shift… Inclinaison-décalage… Path Blur… Flou de tracé… diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 23f7da4142b..70c22942058 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -561,6 +561,8 @@ Facet Lapolás Fibers… Szálak… Field Blur… Mezőelmosás… + No pin selected Nincs kijelölt tű + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Kattints a képre tű hozzáadásához; húzd a tűt az áthelyezéséhez, vagy a gyűrűjét az elmosás módosításához. A Delete törli a kijelölt tűt. Fill Path… Útvonal kitöltése… Fill Screen Képernyő kitöltése Filter Szűrő diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 44b2aaf70cf..dcc02ec1855 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -536,6 +536,9 @@ Blur Blur Blur Gallery Galeri Blur Field Blur… Field Blur… +# Field Blur pins (#3373) + No pin selected Tidak ada pin yang dipilih + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Klik gambar untuk menambah pin; seret pin untuk memindahkannya, atau cincinnya untuk mengubah blur. Delete menghapus pin yang dipilih. Iris Blur… Iris Blur… Tilt-Shift… Tilt-Shift… Path Blur… Path Blur… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index b7aceab4bd2..c077d90aa18 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -551,6 +551,9 @@ Blur Sfocatura Blur Gallery Galleria sfocatura Field Blur… Sfocatura campo… +# Field Blur pins (#3373) + No pin selected Nessuna puntina selezionata + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Fai clic sull’immagine per aggiungere una puntina; trascina una puntina per spostarla o il suo anello per cambiare la sfocatura. Canc rimuove la puntina selezionata. Iris Blur… Sfocatura diaframma… Tilt-Shift… Miniatura… Path Blur… Sfocatura tracciato… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 59222977dae..9f074e3e30b 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -553,6 +553,9 @@ Blur ぼかし Blur Gallery ぼかしギャラリー Field Blur… フィールドぼかし… +# Field Blur pins (#3373) + No pin selected ピンが選択されていません + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. 画像をクリックしてピンを追加します。ピンをドラッグして移動し、リングをドラッグしてぼかしを変更します。Delete キーで選択したピンを削除します。 Iris Blur… 虹彩絞りぼかし… Tilt-Shift… チルトシフト… Path Blur… パスぼかし… diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index d8cfc4ee3c9..eefa162a656 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -540,6 +540,9 @@ Blur 흐림 효과 Blur Gallery 흐림 효과 갤러리 Field Blur… 필드 흐림 효과… +# Field Blur pins (#3373) + No pin selected 선택한 핀 없음 + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. 이미지를 클릭하여 핀을 추가합니다. 핀을 드래그하여 이동하고, 링을 드래그하여 흐림 정도를 바꿉니다. Delete 키로 선택한 핀을 삭제합니다. Iris Blur… 조리개 흐림 효과… Tilt-Shift… 기울기-이동… Path Blur… 패스 흐림 효과… diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index f960037b27c..68e98a6fef5 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -543,6 +543,9 @@ Blur Vervagen Blur Gallery Vervagingsgalerie Field Blur… Veldvervaging… +# Field Blur pins (#3373) + No pin selected Geen speld geselecteerd + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Klik in de afbeelding om een speld toe te voegen; sleep een speld om hem te verplaatsen, of zijn ring om de vervaging te wijzigen. Delete verwijdert de geselecteerde speld. Iris Blur… Irisvervaging… Tilt-Shift… Tilt-shift… Path Blur… Padvervaging… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 8a2506f8e8d..2d76183794a 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -552,6 +552,9 @@ Blur Rozmycie Blur Gallery Galeria rozmyć Field Blur… Rozmycie pola… +# Field Blur pins (#3373) + No pin selected Nie zaznaczono pinezki + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Kliknij obraz, aby dodać pinezkę; przeciągnij pinezkę, aby ją przesunąć, lub jej pierścień, aby zmienić rozmycie. Delete usuwa zaznaczoną pinezkę. Iris Blur… Rozmycie irysowe… Tilt-Shift… Tilt-shift… Path Blur… Rozmycie ścieżki… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 08228157a64..0d48ba9b47b 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -537,6 +537,9 @@ Blur Desfoque Blur Gallery Galeria de desfoque Field Blur… Desfoque de campo… +# Field Blur pins (#3373) + No pin selected Nenhum alfinete selecionado + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Clique na imagem para adicionar um alfinete; arraste um alfinete para movê-lo ou o anel dele para alterar o desfoque. Delete remove o alfinete selecionado. Iris Blur… Desfoque de íris… Tilt-Shift… Tilt-shift… Path Blur… Desfoque de demarcador… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a48bb0c9dd3..11a3cb6b2e2 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -343,6 +343,9 @@ Oil Paint… Масляная живопись… Blur Gallery Галерея размытия Field Blur… Размытие по полю… +# Field Blur pins (#3373) + No pin selected Булавка не выбрана + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Щелкните изображение, чтобы добавить булавку; перетащите булавку, чтобы переместить ее, или ее кольцо, чтобы изменить размытие. Delete удаляет выбранную булавку. Iris Blur… Диафрагменное размытие… Tilt-Shift… Наклон-сдвиг… Path Blur… Размытие по контуру… diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 286283ae858..f47fe20162c 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -533,6 +533,8 @@ Blur Zamućenje Blur Gallery Galerija zamućenja Field Blur… Zamućenje polja… + No pin selected Nijedna čioda nije izabrana + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Kliknite na sliku da dodate čiodu; prevucite čiodu da je pomerite ili njen prsten da promenite zamućenje. Delete uklanja izabranu čiodu. Iris Blur… Iris zamućenje… Tilt-Shift… Tilt-shift… Path Blur… Zamućenje duž putanje… diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index d9108fb3576..948c9674674 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -532,6 +532,8 @@ Blur Bulanıklaştırma Blur Gallery Bulanıklaştırma Galerisi Field Blur… Alan Bulanıklığı… + No pin selected Seçili pim yok + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Pim eklemek için görüntüye tıklayın; taşımak için pimi, bulanıklığını değiştirmek için halkasını sürükleyin. Delete seçili pimi siler. Iris Blur… İris Bulanıklığı… Tilt-Shift… Tilt-Shift… Path Blur… Yol Bulanıklığı… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 4106a772d8e..2a72fdaf19a 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -321,6 +321,9 @@ Oil Paint… Олійний живопис… Blur Gallery Галерея розмиття Field Blur… Розмиття поля… +# Field Blur pins (#3373) + No pin selected Шпильку не вибрано + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Клацніть зображення, щоб додати шпильку; перетягніть шпильку, щоб перемістити її, або її кільце, щоб змінити розмиття. Delete видаляє вибрану шпильку. Iris Blur… Розмиття діафрагми… Tilt-Shift… Нахил і зсув… Path Blur… Розмиття вздовж контуру… diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index d7930086b7c..88b855a7078 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -508,6 +508,9 @@ Facet Mặt cắt Fibers… Sợi… Field Blur… Làm mờ vùng… +# Field Blur pins (#3373) + No pin selected Chưa chọn ghim nào + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Bấm vào ảnh để thêm ghim; kéo ghim để di chuyển, hoặc kéo vòng của ghim để đổi độ mờ. Delete xóa ghim đã chọn. Filter Gallery… Thư viện bộ lọc… Find Edges Tìm cạnh Flame… Ngọn lửa… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 22fbce1e1a3..28427711244 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -558,6 +558,9 @@ Blur 模糊 Blur Gallery 模糊库 Field Blur… 场景模糊… +# Field Blur pins (#3373) + No pin selected 未选择图钉 + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. 单击图像以添加图钉;拖动图钉可移动它,拖动其圆环可更改模糊程度。按 Delete 键删除所选图钉。 Iris Blur… 光圈模糊… Tilt-Shift… 移轴模糊… Path Blur… 路径模糊… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 1c7828515dc..d02e06646e9 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -563,6 +563,9 @@ Blur 模糊 Blur Gallery 模糊收藏館 Field Blur… 區域模糊… +# Field Blur pins (#3373) + No pin selected 未選取圖釘 + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. 按一下影像即可新增圖釘;拖移圖釘可移動它,拖移其圓環可變更模糊程度。按 Delete 鍵刪除所選圖釘。 Iris Blur… 光圈模糊… Tilt-Shift… 傾斜偏移… Path Blur… 路徑模糊… diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0189b65adf3..2cd006087da 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -27,6 +27,7 @@ pub mod artboard_ui; pub(crate) mod background_redraw; mod blend_keys; pub(crate) mod blend_preview; +pub mod blur_pins; mod brand; pub(crate) mod brush_cursor; pub mod brush_panel; @@ -2520,6 +2521,9 @@ mod new_layer_button_tests; #[cfg(test)] mod smart_filter_rows_tests; +#[cfg(test)] +mod blur_pins_tests; + #[cfg(test)] mod move_outline_tests; diff --git a/docs/gaps.md b/docs/gaps.md index a0edbaff795..25a286aab85 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -70,6 +70,8 @@ what an agent can't finish alone. (TOOL-214-15); Vanishing Point plane editor (TOOL-214-14); Content-Aware Move's live preview of the result (TOOL-213-4; Transform On Drop and Content-Aware Scale through the transform box, TOOL-214-7, are done). + Blur Gallery: Field Blur has its on-canvas pins (2026-10-11, #3373); Iris, Tilt-Shift, Spin and + Path Blur still edit their pins as parameter dialogs. - **Evidence:** scorecard rows above, all partial or missing. - **Impact:** these are the tools retouchers and compositors use daily; a slider dialog is not a substitute. @@ -273,6 +275,7 @@ what an agent can't finish alone. | 2026-10-12 | minor | G4: checkbox-only Brush Settings rows preserve the open controls and Smoothing settings (#4283); no readiness percentage or estimate changed | | 2026-10-12 | minor | G11: Load/Save Actions `.atn` landed (AUTO-219-2 partial); remaining panel work listed | | 2026-10-12 | minor | Refreshed against main: G5 items fixed by #2480, #2327, #2609, #2684, #2675, #2556, #2396, #2527, #2625 and TOOL-214-5/#2442; G7 TOOL-214-7 and Transform On Drop done; G2 #2377 fixed (#2417); G9 UI scale (UI-217-9) and Stamp Visible (UI-217-22) done, prefs 27 of 156; G10 #2225 done (#2482); G13 Khmer, Lao, Myanmar fallback (#3259); G14 CR3 line; G17 Windows ARM64 done (DIST-220-2) | +| 2026-10-11 | minor | G7: Field Blur pins are placed, dragged and tuned on the image (#3373); the other Blur Gallery filters remain dialogs | | 2026-10-11 | minor | D5100 image area and Camera Raw tuning validation; G14 remains open | | 2026-10-11 | minor | G13: Czech dynamic UI text localized; engine messages and native review remain open | | 2026-10-11 | minor | G8: triple/quadruple/five-click type selection done (TYPE-218-8, #3493); Type evidence recounted | From d35725200d644d6737f2b14a1532908a2cb5f64c Mon Sep 17 00:00:00 2001 From: DomiLuebben <92845669+DomiLuebben@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:21:38 +0200 Subject: [PATCH 38/47] i18n(de): translate the labels that were still English (344 entries) and fix three display bugs (#4008) * i18n(de): translate the labels that were still English A walk through the German UI with a lookup recorder (every menu item, every tool's options bar, every dialog it opens) found 344 texts that fell through to English. They were invisible to the existing i18n test, which only scans tl!("literal") calls: the labels of the schema-driven filter dialogs are built from the engine's parameter names ("centerX" -> "Center X"), and status and system-info templates are looked up at run time. - de.tsv: 344 new entries. Filter, Render (Flame, Picture Frame, Tree, Lighting Effects), Blur Gallery, Lens Correction, Color Lookup, Shadows/Highlights, Apply Image / Calculations, Layer Style blending options, Find and Replace, Document Info, the keyboard shortcut editor's commands, system info and the tool names that were missing (Color Sampler, Vertical Type, Patch). Written from the plain meaning of each label, in the vocabulary the catalog already uses (see its header: no proprietary localisation resource consulted). - "Light" was translated as "Light": the appearance mode in Preferences and the toolbar tooltip read "Light" next to "Dunkel". It is "Hell" now (Camera Raw's Light section and the font weight have their own contexts). - Export As: the dialog title was built with format!() and could not be translated; it goes through the catalog now. - Names such as "iPhone 14 Pro Max" (the artboard dialog's preset choices) were split like camelCase keys and read "I Phone 14 Pro Max"; a name with a space is shown as it is. Test: display_names_are_not_split_like_camel_case_keys. Left English on purpose: product and file-format names, units, document and layer names the engine creates ("Untitled", "Background": other code compares the latter), brush and swatch preset names (data), and words that are the same in German (Filter, Info, Zoom, Median...). Co-Authored-By: Claude Sonnet 5.5 * i18n(de): drop the process note and give the new sections English headings The first heading described how the entries were found (and when), which is not catalog content, and the other headings were German while the rest of the file uses short English section comments. Only comment lines change. Co-Authored-By: Claude Sonnet 5.5 * i18n(de): US spelling in a section heading Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: Claude Sonnet 5.5 --- crates/ui-egui/src/export_dialog.rs | 2 +- crates/ui-egui/src/filter_dialog.rs | 13 + crates/ui-egui/src/i18n/de.tsv | 356 +++++++++++++++++++++++++++- crates/ui-egui/src/rotate_view.rs | 5 +- crates/ui-egui/src/view_cmds.rs | 5 + crates/ui-egui/src/wheel_nav.rs | 3 +- 6 files changed, 376 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/export_dialog.rs b/crates/ui-egui/src/export_dialog.rs index 6cb0e310df7..a322ee108de 100644 --- a/crates/ui-egui/src/export_dialog.rs +++ b/crates/ui-egui/src/export_dialog.rs @@ -80,7 +80,7 @@ pub fn open_layer(app: &mut PhotocraftApp, layer: photocraft_doc::LayerId) -> Re let id = open(app)?; if let Some(d) = app.ui.dialogs.iter_mut().find(|d| d.id == id) { d.fields.insert("__layer".into(), json!(layer.0)); - d.fields.insert("__label".into(), json!(format!("Export As: {}", ldoc.name))); + d.fields.insert("__label".into(), json!(crate::i18n::fmt(crate::i18n::t("Export As: {name}"), &[("name", &ldoc.name)]))); d.fields.insert("__w".into(), json!(ldoc.size.width)); d.fields.insert("__h".into(), json!(ldoc.size.height)); } diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index 89fcd3ef90c..f4dacbc5757 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -556,6 +556,10 @@ pub(crate) fn source_label(key: &str) -> String { if key == "localized" { return "Localized Color Clusters".into(); } + // A name that already reads as text ("iPhone 14 Pro Max", "Web 1920") is not camelCase. + if key.contains(' ') { + return key.to_string(); + } // camelCase → "Camel Case" let mut s = String::new(); for (i, ch) in key.chars().enumerate() { @@ -975,6 +979,15 @@ pub struct FilterPreviewKey { mod tests { use super::*; + /// Preset names such as "iPhone 14 Pro Max" (the artboard dialog's choices) are text, not + /// camelCase keys: they used to read "I Phone 14 Pro Max". + #[test] + fn display_names_are_not_split_like_camel_case_keys() { + assert_eq!(source_label("iPhone 14 Pro Max"), "iPhone 14 Pro Max"); + assert_eq!(source_label("Web 1920"), "Web 1920"); + assert_eq!(source_label("centerX"), "Center X"); + } + #[test] fn only_direction_angles_get_the_dial() { // Every listed command still has a degree `angle` range, so the list cannot go stale. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index cccbfbb35fc..88ef9576754 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1711,7 +1711,7 @@ Layer Knocks Out Drop Shadow Ebene stanzt den Schlagschatten aus Legal Legal Letter Letter - Light Light + Light Hell Line Tool Linien-Werkzeug Linear Linear Linear Burn Linear nachbelichten @@ -2748,6 +2748,360 @@ curves Smooth Glätten Light Red Hellrot Medium Blue Mittelblau Light Gray Hellgrau +# Status messages, system info and tool names + Join the ArtCraft Discord ({url}) Dem ArtCraft-Discord beitreten ({url}) + Nothing to paste: the clipboard holds no image Nichts einzufügen: Die Zwischenablage enthält kein Bild + Graphics adapter: {adapter} Grafikadapter: {adapter} + Backend: {backend} ({type}) Backend: {backend} ({type}) + Driver: {driver} Treiber: {driver} + GPU backend preference: {backend} Bevorzugtes GPU-Backend: {backend} + Selected at launch: {backend} Beim Start gewählt: {backend} + Image compositor: {processor} Bildkompositor: {processor} + Window renderer: {renderer} Fensterdarstellung: {renderer} + Graphics adapter Grafikadapter + Platform: {os} {arch} Plattform: {os} {arch} + Monitor profile: {profile} Monitorprofil: {profile} + Opened {url} {url} geöffnet + {index} of {total} {index} von {total} + Export As: {name} Exportieren als: {name} + Color Sampler Tool Farbaufnahme-Werkzeug + Vertical Type Tool Vertikales Text-Werkzeug + Patch Tool Ausbessern-Werkzeug + Grid view Rasteransicht + List view Listenansicht + Proof Setup… Proof einrichten… + 32-bit Preview Options 32-Bit-Vorschauoptionen +# Shortcut editor commands without a menu item + Toggle Layer Mask Overlay Ebenenmasken-Überlagerung umschalten + Increase Type Size Schriftgröße erhöhen + Decrease Type Size Schriftgröße verringern + Increase Type Size by 10 Schriftgröße um 10 erhöhen + Decrease Type Size by 10 Schriftgröße um 10 verringern + Make Selection from Path Auswahl aus Pfad erstellen + Target Composite Channel Gesamtkanal als Ziel festlegen + Target Channel 3 Kanal 3 als Ziel festlegen + Target Channel 4 Kanal 4 als Ziel festlegen + Target Channel 5 Kanal 5 als Ziel festlegen + Target Channel 6 Kanal 6 als Ziel festlegen + Target Channel 7 Kanal 7 als Ziel festlegen + Target Channel 8 Kanal 8 als Ziel festlegen + Target Channel 9 Kanal 9 als Ziel festlegen + Switch Foreground and Background Colors Vorder- und Hintergrundfarbe vertauschen + Default Foreground and Background Colors Standardfarben für Vorder- und Hintergrund + Fill with Foreground Color Mit Vordergrundfarbe füllen + Fill with Background Color Mit Hintergrundfarbe füllen + Fill with Foreground Color, Preserve Transparency Mit Vordergrundfarbe füllen, Transparenz erhalten + Fill with Background Color, Preserve Transparency Mit Hintergrundfarbe füllen, Transparenz erhalten + Decrease Brush Size Pinselgröße verringern + Increase Brush Size Pinselgröße erhöhen + Decrease Brush Hardness Pinselhärte verringern + Increase Brush Hardness Pinselhärte erhöhen + Temporary Vorübergehend + Space Leertaste + Hand Tool (hold) Hand-Werkzeug (gedrückt halten) + Zoom In (hold, drag to scrub) Vergrößern (gedrückt halten, zum Zoomen ziehen) + Zoom Out (hold) Verkleinern (gedrückt halten) + Erase with Brush or Pencil (hold) Mit Pinsel oder Buntstift radieren (gedrückt halten) +# Document Info and color profiles + Author Title Position des Urhebers + Copyright Status Urheberrechtsstatus + Unknown Unbekannt + Copyrighted Urheberrechtlich geschützt + Public Domain Gemeinfrei + Copyright Url Urheberrechts-URL + Keywords Stichwörter + Title Titel + Profile Profil + Working Arbeitsfarbraum + Working-cmyk Arbeits-CMYK + Coated-cmyk CMYK (gestrichen) + Linear-srgb Lineares sRGB + Gray-gamma-2.2 Grau (Gamma 2,2) + Adobe-rgb-compat Adobe-RGB-kompatibel + Prophoto-compat ProPhoto-kompatibel + Display-p3 Display P3 + Intent Renderpriorität + Bpc Schwarzpunktkompensierung + Simulate Paper Papierfarbe simulieren +# Color Lookup, Shadows/Highlights, channels and Calculations + Warming Filter (85) Wärmefilter (85) + Lut LUT + Cool Kühl + Teal Orange Türkis-Orange + Faded Film Verblasster Film + Day For Night Tag für Nacht + Mono Contrast Mono-Kontrast + Cross Process Kreuzentwicklung + Shadow Amount Stärke der Tiefen + Shadow Tone Tonbreite der Tiefen + Shadow Radius Radius der Tiefen + Highlight Amount Stärke der Lichter + Highlight Tone Tonbreite der Lichter + Highlight Radius Radius der Lichter + Midtone Mitteltöne + Black Clip Schwarz beschneiden + White Clip Weiß beschneiden + Strength Stärke + Shadow Schatten + Highlight Lichter + Intensity Intensität + Neutralize Neutralisieren + Use Selection In Source Auswahl in der Quelle verwenden + Use Selection In Target Auswahl im Ziel verwenden + Cw Im Uhrzeigersinn + Ccw Gegen den Uhrzeigersinn + Nearest Nächster Nachbar + Linear Dodge Linear abwedeln + Mask Channel Maskenkanal + R G B RGB + Source Channel Quellkanal + Source Invert Quelle invertieren + Result Ergebnis + Source1 Channel Kanal der Quelle 1 + Source1 Invert Quelle 1 invertieren + Source2 Channel Kanal der Quelle 2 + Source2 Invert Quelle 2 invertieren + Channel1 Kanal 1 + Channel2 Kanal 2 + Channel3 Kanal 3 + Channel4 Kanal 4 +# Measurement log + Label Beschriftung + Date and Time Datum und Uhrzeit + Scale Units Maßstabseinheiten + Scale Factor Maßstabsfaktor + Area Fläche + Perimeter Umfang + Circularity Kreisförmigkeit + Gray Value (Minimum) Grauwert (Minimum) + Gray Value (Maximum) Grauwert (Maximum) + Gray Value (Mean) Grauwert (Mittelwert) + Gray Value (Median) Grauwert (Median) + Integrated Density Integrierte Dichte + Length Länge + W: B: + A: Wi: +# Lens Correction, Blur Gallery and distortion filters + Pattern Fill… Musterfüllung… + Generic Allgemein + Correct Distortion Verzerrung korrigieren + Correct Vignette Vignette korrigieren + Correct C A Chromatische Aberration korrigieren + Auto Scale Automatisch skalieren + Distortion Verzerrung + Red Cyan Rot/Cyan + Blue Yellow Blau/Gelb + Vignette Amount Stärke der Vignette + Vignette Midpoint Mittelpunkt der Vignette + Edge Extension Kantenerweiterung + Hexagon Sechseck + Pentagon Fünfeck + Heptagon Siebeneck + Octagon Achteck + Blade Curvature Krümmung der Blendenlamellen + Depth Map Tiefenkarte + Focal Distance Fokusabstand + Distribution Verteilung + Uniform Gleichmäßig + Monochromatic Monochrom + Seed Startwert + Spin Drehen + Draft Entwurf + Good Gut + Best Beste + Center X Mitte X + Center Y Mitte Y + Circle Kreis + Star Stern + Cross Kreuz + High Hoch + Low Niedrig + Edge Only Nur Kanten + Overlay Edge Kante überlagern + Focus Fokus + Transition Übergang + Speed Geschwindigkeit + Taper Verjüngung + End X Ende X + End Y Ende Y + Blur Angle Weichzeichnungswinkel + Rectangular To Polar Rechteckig zu Polar + Polar To Rectangular Polar zu Rechteckig + Undefined Areas Undefinierte Bereiche + Wrap Umbrechen + Repeat Wiederholen + Horizontal Only Nur horizontal + Vertical Only Nur vertikal + Generators Generatoren + Wavelength Min Wellenlänge min. + Wavelength Max Wellenlänge max. + Amplitude Min Amplitude min. + Amplitude Max Amplitude max. + Sine Sinus + Ridges Grate + Pond Ripples Teichwellen + Out From Center Von der Mitte nach außen + Around Center Um die Mitte + Reduce Color Noise Farbrauschen reduzieren + Sharpen Details Details schärfen + Remove Jpeg Artifact JPEG-Artefakte entfernen + Max Radius Maximaler Radius + Cell Size Zellengröße + Fine Dots Feine Punkte + Medium Dots Mittlere Punkte + Grainy Dots Körnige Punkte + Coarse Dots Grobe Punkte + Short Lines Kurze Linien + Medium Lines Mittlere Linien + Long Lines Lange Linien + Short Strokes Kurze Striche + Medium Strokes Mittlere Striche + Long Strokes Lange Striche +# Render filters: Flame, Picture Frame, Tree + Flame Type Flammentyp + One Flame Along Path Eine Flamme entlang des Pfads + Multiple Flames Along Path Mehrere Flammen entlang des Pfads + Multiple Flames Path Directions Mehrere Flammen, Pfadrichtungen + Multiple Flames Various Length Mehrere Flammen, unterschiedliche Längen + Candle Light Kerzenlicht + Multiple Flames One Direction Mehrere Flammen, eine Richtung + Randomize Length Länge zufällig variieren + Interval Abstand + Adjust Interval For Loops Abstand für Schleifen anpassen + Use Custom Color Eigene Farbe verwenden + Jag Zacken + Flame Lines Flammenlinien + Flame Bottom Alignment Ausrichtung des Flammenfußes + Flame Style Flammenstil + Violent Heftig + Flat Flach + Flame Shape Flammenform + To Center Zur Mitte + Pointed Spitz + Randomize Shapes Formen zufällig variieren + Fine Fein + Frame Rahmen + Vine With Flowers Ranke mit Blumen + Vine With Leaves Ranke mit Blättern + Ivy Efeu + Roses Rosen + Daisies Gänseblümchen + Berries Beeren + Bamboo Bambus + Waves Wellen + Zigzag Zickzack + Rope Seil + Scallops Muschelrand + Double Line Doppellinie + Snowflakes Schneeflocken + Stars Sterne + Hearts Herzen + Arrangement Anordnung + Thickness Dicke + Vine Color Rankenfarbe + Flower Color Blütenfarbe + Leaf Color Blattfarbe + Base Tree Type Baumtyp + Light Direction Lichtrichtung + Leaves Amount Blattmenge + Leaves Size Blattgröße + Branches Height Asthöhe + Branches Thickness Aststärke + Default Leaves Standardblätter + Leaves Color Blattfarbe + Custom Branch Color Eigene Astfarbe + Branches Color Astfarbe + Flat Shading Flächige Schattierung +# Lens, Lighting Effects, depth and mosaic filters + Lens Objektiv + Prime35 Festbrennweite 35 + Prime105 Festbrennweite 105 + Movie Prime Film-Festbrennweite + Light Type Lichttyp + Point Punkt + Infinite Unendlich + Light X Licht X + Light Y Licht Y + Light Z Licht Z + Target X Ziel X + Target Y Ziel Y + Elevation Höhe + Gloss Glanz + Metallic Metallisch + Ambience Umgebungslicht + White Is High Weiß ist hoch + Ambient Umgebung + Warmth Wärme + Softness Weichheit + Reduce Noise Rauschen reduzieren + Darken Only Nur abdunkeln + Lighten Only Nur aufhellen + Anisotropic Anisotrop + Blocks Blöcke + Pyramids Pyramiden + Depth Mode Tiefenmodus + Random Zufällig + Level Based Stufenbasiert + Solid Front Faces Einfarbige Vorderseiten + Mask Incomplete Blocks Unvollständige Blöcke maskieren + Stylization Stilisierung + Cleanliness Sauberkeit + Bristle Detail Borstendetail + Lighting Beleuchtung + Shine Glanz + Max Offset Maximaler Versatz + Unaltered Unverändert + Lower Untere + Upper Obere + Blast Böe + Stagger Versetzt + From Right Von rechts + From Left Von links + Eliminate Entfernen + Odd Fields Ungerade Halbbilder + Even Fields Gerade Halbbilder + Create By Erzeugen durch + Duplication Duplizieren + Kernel Kern + Input Mode Eingabemodus + Rgb RGB + Hsb HSB + Hsl HSL + Row Order Zeilenreihenfolge + Preserve Erhalten + Squareness Rechteckigkeit +# Find and Replace, image size, Warp and Layer Style blending options + Any Beliebig + Cmyk CMYK + To Nach + Dont Enlarge Nicht vergrößern + Find Suchen + Replace Ersetzen + Change Find Ersetzen und suchen + Case Sensitive Groß-/Kleinschreibung beachten + Whole Word Ganzes Wort + Forward Vorwärts + Based On Basierend auf + General Blending Allgemeine Füllmethode + Advanced Blending Erweiterte Füllmethode + Blend Interior Effects as Group Innere Effekte als Gruppe überblenden + Blend Clipped Layers as Group Ebenen der Schnittmaske als Gruppe überblenden + Transparency Shapes Layer Transparenz bestimmt die Ebenenform + Layer Mask Hides Effects Ebenenmaske blendet Effekte aus + Vector Mask Hides Effects Vektormaske blendet Effekte aus + This Layer: Diese Ebene: + Underlying Layer: Darunterliegende Ebene: + Up Nach oben + Checkerboard Schachbrett + Arc Lower Bogen unten + Arc Upper Bogen oben + Bend Biegen + Horizontal Distortion Horizontale Verzerrung + Vertical Distortion Vertikale Verzerrung + Stretch Strecken + Map Document Dokument zuordnen + Map Path Pfad zuordnen + Fuzziness Toleranz Object Finder Objektsucher No object found there — drag a rectangle around it Dort wurde kein Objekt gefunden — ziehen Sie ein Rechteck darum Remove after each stroke Nach jedem Strich entfernen diff --git a/crates/ui-egui/src/rotate_view.rs b/crates/ui-egui/src/rotate_view.rs index fa22ac98626..5e173da5dc8 100644 --- a/crates/ui-egui/src/rotate_view.rs +++ b/crates/ui-egui/src/rotate_view.rs @@ -322,10 +322,7 @@ mod tests { let ctx = egui::Context::default(); let mut enabled = None; let mut disabled = None; - let raw = egui::RawInput { - events: vec![Event::Rotate(std::f32::consts::FRAC_PI_6)], - ..Default::default() - }; + let raw = egui::RawInput { events: vec![Event::Rotate(std::f32::consts::FRAC_PI_6)], ..Default::default() }; let mut output = ctx.run_ui(raw, |ui| { enabled = trackpad_delta(ui.ctx(), true); disabled = trackpad_delta(ui.ctx(), false); diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index 5e6d2523345..793257be8b3 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -996,6 +996,10 @@ pub fn reset_form(f: &mut Map) { } fn label_of(key: &str) -> String { + // A name that already reads as text ("iPhone 14 Pro Max", "Web 1920") is not camelCase. + if key.contains(' ') { + return tl!(key).to_owned(); + } let mut out = String::new(); for (i, c) in key.chars().enumerate() { if i == 0 { @@ -1026,6 +1030,7 @@ mod label_tests { crate::i18n::with_language(crate::i18n::Lang::EN, || { assert_eq!(label_of("useAntialias"), "Use antialias"); assert_eq!(label_of("radius"), "Radius"); + assert_eq!(label_of("iPhone 14 Pro Max"), "iPhone 14 Pro Max"); }); } } diff --git a/crates/ui-egui/src/wheel_nav.rs b/crates/ui-egui/src/wheel_nav.rs index 4523fbc6471..c65ad1fe2c3 100644 --- a/crates/ui-egui/src/wheel_nav.rs +++ b/crates/ui-egui/src/wheel_nav.rs @@ -381,8 +381,7 @@ mod tests { assert!(matches!(legacy.events[0], Event::Zoom(_))); // Native magnification takes over before the synthetic gesture times out. - let mut native = - RawInput { time: Some(1.1), events: vec![Event::Zoom(1.1), wheel(Vec2::new(0.0, 0.25), CTRL)], ..Default::default() }; + let mut native = RawInput { time: Some(1.1), events: vec![Event::Zoom(1.1), wheel(Vec2::new(0.0, 0.25), CTRL)], ..Default::default() }; fold_legacy_pinch(&ctx, &mut native); assert!(matches!(native.events[0], Event::Zoom(f) if (f - 1.1).abs() < 1e-5)); assert!(matches!(native.events[1], Event::MouseWheel { .. })); From 995bc89c40433532db503714f0732cdbb1ac4c1c Mon Sep 17 00:00:00 2001 From: ragtux <56554701+ragtux@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:23:35 -0500 Subject: [PATCH 39/47] Auto-Blend Stack Images: a depth map by depth from focus, for Lens Blur (#2073) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Auto-Blend Stack Images: a depth map by depth from focus, for Lens Blur `edit.autoBlendLayers` with `method: stack` and `depthMap: true` adds a hidden gray Depth layer, the layer each pixel is sharpest in (black = the bottom layer, white = the top), from `photocraft_algo::dff`: the ring difference filter as focus measure, guided-filter aggregation with the merged picture as guide, a streaming peak search with Gaussian interpolation and a confidence, edge-aware weighted least squares solved by multigrid-preconditioned conjugate gradients, and guided upsampling. Once on a layer mask it drives Lens Blur's depth map. Closes #2067. Co-Authored-By: Claude Fable 5.1 * Depth from focus: a guide or plane of the wrong length is the empty map Review follow-up on #2073: `depth_from_focus` is public and indexed `guide[i]` without checking that `guide` and every `luma` plane are `w × h` long. It now returns the empty map (depth 0, confidence 0) for a mismatch, before anything is read, and the test covers a short guide, a short plane and a size that matches neither. Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Fable 5.1 --- crates/algo/src/dff.rs | 990 ++++++++++++++++++++++++++++++++ crates/algo/src/lib.rs | 1 + crates/engine/src/align_cmds.rs | 116 +++- 3 files changed, 1101 insertions(+), 6 deletions(-) create mode 100644 crates/algo/src/dff.rs diff --git a/crates/algo/src/dff.rs b/crates/algo/src/dff.rs new file mode 100644 index 00000000000..c1cf05af632 --- /dev/null +++ b/crates/algo/src/dff.rs @@ -0,0 +1,990 @@ +//! Depth from focus for Edit › Auto-Blend Layers › Stack Images: a dense, sub-layer depth map +//! from the aligned stack, the index of the layer each pixel is sharpest in. +//! +//! The pipeline is the non-learned "focus volume" recipe, written from the papers: +//! +//! 1. **Focus measure** per layer on luminance at full resolution: the *ring difference filter* +//! of Jeon, Surh, Im & Kweon, *Ring Difference Filter for Fast and Noise Robust Depth From +//! Focus*, IEEE TIP 29 (2019), the magnitude of the difference between the mean of a small +//! disk and the mean of the ring around it, a band-pass whose support averages sensor noise +//! away while staying local. Block-averaged to a working grid (half resolution). +//! 2. **Cost aggregation**: each slice of the focus volume is filtered with the *guided filter* +//! (He, Sun & Tang, PAMI 2013) using the all-in-focus luminance as guide, edge-aware +//! aggregation as in fast cost-volume filtering (Hosni et al., PAMI 2013). +//! 3. **Peak search** per pixel over the layer axis, one slice at a time: the global peak and its +//! two neighbours for the *Gaussian interpolation* of Nayar & Nakagawa, *Shape from Focus*, +//! PAMI 1994 (sub-layer depth), the second-best local maximum for a peak-ratio confidence, +//! the profile mean for a prominence term and the profile minimum for a noise gate. +//! 4. **Regularisation**: the sub-layer depth is smoothed and holes filled by an edge-aware +//! weighted least squares energy (Farbman, Fattal, Lischinski & Szeliski, SIGGRAPH 2008) +//! whose data term is the confidence, solved by conjugate gradients preconditioned by a +//! multigrid V-cycle from the separable sweeps of the *fast global smoother* (Min, Choi, Lu, +//! Ham, Sohn & Do, IEEE TIP 2014) as the start, with one Huber reweighting so isolated +//! outliers stop pulling. +//! 5. **Upsampling** to full resolution with the guided filter's linear coefficients. +//! +//! Everything is deterministic; the slices are folded one at a time, so memory is one working +//! grid per layer plus a few planes. + +use crate::photo_util::{par_map, par_rows, par_rows2}; + +/// Depth-from-focus settings. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DepthParams { + /// The working grid is `1 / 2^scale` of full resolution (1 = half). + pub scale: usize, + /// Ring difference filter radii: the disk `r ≤ r_in`, the ring `r_in < r ≤ r_out`. + pub r_in: usize, + pub r_out: usize, + /// Guided-filter aggregation radius (working-grid pixels) and regulariser. + pub agg_radius: usize, + pub agg_eps: f32, + /// Smoothness weight and guide-edge sensitivity (luminance units) of the regularisation. + pub lambda: f32, + pub sigma_c: f32, + /// Noise gate: full confidence needs a peak ≥ `(1 + gate) ×` the noise floor (the median + /// over pixels of the profile minimum). 0 = off. + pub gate: f32, + /// Robust data term: after the first solve the data weights are scaled by the Huber factor + /// `min(1, robust / |d − u|)` (in layers) and the system is solved once more. 0 = off. + pub robust: f32, +} + +impl Default for DepthParams { + fn default() -> Self { + DepthParams { scale: 1, r_in: 1, r_out: 3, agg_radius: 3, agg_eps: 1e-4, lambda: 3.0, sigma_c: 0.04, gate: 1.0, robust: 1.0 } + } +} + +/// The result of [`depth_from_focus`]. +#[derive(Clone, Debug, PartialEq)] +pub struct DepthMap { + /// Full-resolution fractional layer index per pixel, in `0 ..= n − 1`. + pub depth: Vec, + /// Full-resolution confidence in `0 ..= 1`. + pub conf: Vec, + pub w: usize, + pub h: usize, +} + +/// Mean over `k × k` blocks (partial blocks at the far edges use their own count). +pub fn block_mean(src: &[f32], w: usize, h: usize, k: usize) -> (Vec, usize, usize) { + let k = k.max(1); + let (dw, dh) = (w.div_ceil(k), h.div_ceil(k)); + if k == 1 { + return (src.to_vec(), dw, dh); + } + let mut out = vec![0.0f32; dw * dh]; + par_rows(&mut out, dw, 1, |oy, row| { + let (y0, y1) = (oy * k, (oy * k + k).min(h)); + for y in y0..y1 { + let s = &src[y * w..y * w + w]; + for (ox, o) in row.iter_mut().enumerate() { + *o += s[ox * k..(ox * k + k).min(w)].iter().sum::(); + } + } + for (ox, o) in row.iter_mut().enumerate() { + *o /= ((y1 - y0) * ((ox * k + k).min(w) - ox * k)) as f32; + } + }); + (out, dw, dh) +} + +/// Bilinear resampling of a working-grid plane (block size `k`, samples at block centres) to +/// `w × h`. +pub fn upsample_bilinear(g: &[f32], dw: usize, dh: usize, w: usize, h: usize, k: usize) -> Vec { + let mut out = vec![0.0f32; w * h]; + if dw == 0 || dh == 0 { + return out; + } + let inv = 1.0 / k.max(1) as f32; + par_rows(&mut out, w, 1, |y, row| { + let fy = ((y as f32 + 0.5) * inv - 0.5).clamp(0.0, (dh - 1) as f32); + let y0 = fy as usize; + let y1 = (y0 + 1).min(dh - 1); + let ty = fy - y0 as f32; + let (r0, r1) = (&g[y0 * dw..y0 * dw + dw], &g[y1 * dw..y1 * dw + dw]); + for (x, o) in row.iter_mut().enumerate() { + let fx = ((x as f32 + 0.5) * inv - 0.5).clamp(0.0, (dw - 1) as f32); + let x0 = fx as usize; + let x1 = (x0 + 1).min(dw - 1); + let tx = fx - x0 as f32; + let top = r0[x0] + (r0[x1] - r0[x0]) * tx; + let bot = r1[x0] + (r1[x1] - r1[x0]) * tx; + *o = top + (bot - top) * ty; + } + }); + out +} + +/// Box means over a `(2r + 1)²` window clamped to the plane (partial windows at the borders use +/// their own count): a horizontal moving sum per row in parallel, then a vertical moving sum +/// carried down the rows as one accumulator row. +struct BoxFilter { + w: usize, + h: usize, + r: usize, + inv_count: Vec, +} + +impl BoxFilter { + fn new(w: usize, h: usize, r: usize) -> BoxFilter { + let cnt = |n: usize, i: usize| ((i + r + 1).min(n) - i.saturating_sub(r)) as f32; + let mut inv_count = vec![0.0f32; w * h]; + par_rows(&mut inv_count, w, 1, |y, row| { + let cy = cnt(h, y); + for (x, o) in row.iter_mut().enumerate() { + *o = 1.0 / (cy * cnt(w, x)); + } + }); + BoxFilter { w, h, r, inv_count } + } + + fn mean(&self, src: &[f32]) -> Vec { + let (w, h, r) = (self.w, self.h, self.r); + let mut tmp = vec![0.0f32; w * h]; + par_rows(&mut tmp, w, 1, |y, row| { + let s = &src[y * w..y * w + w]; + let mut acc: f64 = s[..(r + 1).min(w)].iter().map(|v| f64::from(*v)).sum(); + for x in 0..w { + row[x] = acc as f32; + if x + r + 1 < w { + acc += f64::from(s[x + r + 1]); + } + if x >= r { + acc -= f64::from(s[x - r]); + } + } + }); + let mut out = vec![0.0f32; w * h]; + let mut acc = vec![0.0f64; w]; + for y in 0..(r + 1).min(h) { + for (a, v) in acc.iter_mut().zip(&tmp[y * w..y * w + w]) { + *a += f64::from(*v); + } + } + for y in 0..h { + for ((o, a), c) in out[y * w..y * w + w].iter_mut().zip(&acc).zip(&self.inv_count[y * w..y * w + w]) { + *o = *a as f32 * c; + } + if y + r + 1 < h { + for (a, v) in acc.iter_mut().zip(&tmp[(y + r + 1) * w..(y + r + 2) * w]) { + *a += f64::from(*v); + } + } + if y >= r { + for (a, v) in acc.iter_mut().zip(&tmp[(y - r) * w..(y - r + 1) * w]) { + *a -= f64::from(*v); + } + } + } + out + } +} + +/// Guided filter (He, Sun & Tang 2013) with a fixed grayscale guide: made once per guide and +/// radius, it keeps the guide's statistics so a slice costs only the arithmetic. +struct GuidedFilter { + guide: Vec, + mean_i: Vec, + var_i: Vec, + bf: BoxFilter, + eps: f32, +} + +impl GuidedFilter { + fn new(guide: Vec, w: usize, h: usize, r: usize, eps: f32) -> GuidedFilter { + let bf = BoxFilter::new(w, h, r); + let mean_i = bf.mean(&guide); + let ii: Vec = guide.iter().map(|v| v * v).collect(); + let var_i: Vec = bf.mean(&ii).iter().zip(&mean_i).map(|(v, m)| (v - m * m).max(0.0)).collect(); + GuidedFilter { guide, mean_i, var_i, bf, eps } + } + + /// The box-averaged linear coefficients `(ā, b̄)` such that `q ≈ ā·I + b̄`. + fn coeffs(&self, p: &[f32]) -> (Vec, Vec) { + let mean_p = self.bf.mean(p); + let ip: Vec = self.guide.iter().zip(p).map(|(i, p)| i * p).collect(); + let corr_ip = self.bf.mean(&ip); + let (mut a, mut b) = (vec![0.0f32; p.len()], vec![0.0f32; p.len()]); + for k in 0..p.len() { + let cov = corr_ip[k] - self.mean_i[k] * mean_p[k]; + a[k] = cov / (self.var_i[k] + self.eps); + b[k] = mean_p[k] - a[k] * self.mean_i[k]; + } + (self.bf.mean(&a), self.bf.mean(&b)) + } + + fn filter(&self, p: &[f32]) -> Vec { + let (a, b) = self.coeffs(p); + (0..p.len()).map(|k| a[k] * self.guide[k] + b[k]).collect() + } +} + +/// Ring difference filter taps `(dy, dx, weight)`: `+1/|disk|` for `r ≤ r_in`, `−1/|ring|` for +/// `r_in < r ≤ r_out`. +fn rdf_taps(r_in: usize, r_out: usize) -> Vec<(i64, i64, f32)> { + let (ri2, ro2) = ((r_in * r_in) as i64, (r_out * r_out) as i64); + let (mut disk, mut ring) = (Vec::new(), Vec::new()); + let r = r_out as i64; + for dy in -r..=r { + for dx in -r..=r { + let d2 = dx * dx + dy * dy; + if d2 <= ri2 { + disk.push((dy, dx)); + } else if d2 <= ro2 { + ring.push((dy, dx)); + } + } + } + let (wd, wr) = (1.0 / disk.len().max(1) as f32, -1.0 / ring.len().max(1) as f32); + disk.into_iter().map(|(y, x)| (y, x, wd)).chain(ring.into_iter().map(|(y, x)| (y, x, wr))).collect() +} + +/// Per-pixel focus measure of a luminance plane: the magnitude of the ring difference filter, +/// clamped borders. +pub fn focus_measure(y: &[f32], w: usize, h: usize, r_in: usize, r_out: usize) -> Vec { + let taps = rdf_taps(r_in, r_out.max(r_in + 1)); + let mut out = vec![0.0f32; w * h]; + if w == 0 || h == 0 { + return out; + } + par_rows(&mut out, w, 1, |yy, row| { + for &(dy, dx, wt) in &taps { + let sy = (yy as i64 + dy).clamp(0, h as i64 - 1) as usize; + let s = &y[sy * w..sy * w + w]; + for (x, o) in row.iter_mut().enumerate() { + *o += wt * s[(x as i64 + dx).clamp(0, w as i64 - 1) as usize]; + } + } + row.iter_mut().for_each(|v| *v = v.abs()); + }); + out +} + +/// Per-pixel state of the focus profile, updated one slice at a time. +#[derive(Clone, Copy)] +struct Px { + /// Best local maximum (value, left neighbour, right neighbour; −1 = none) and its index. + c1: f32, + l1: f32, + r1: f32, + i1: u32, + /// Second-best local maximum (−1 = none). + c2: f32, + /// The last two slice values. + prev: f32, + prev2: f32, + sum: f32, + cmin: f32, +} + +impl Px { + fn register(&mut self, val: f32, idx: u32, l: f32, r: f32) { + if val > self.c1 { + if self.c1 >= 0.0 { + self.c2 = self.c1; + } + (self.c1, self.i1, self.l1, self.r1) = (val, idx, l, r); + } else if val > self.c2 { + self.c2 = val; + } + } +} + +/// The streaming peak search over the layer axis. +struct PeakTracker { + px: Vec, + m: usize, +} + +impl PeakTracker { + fn new(n: usize) -> PeakTracker { + PeakTracker { px: vec![Px { c1: -1.0, l1: -1.0, r1: -1.0, i1: 0, c2: -1.0, prev: 0.0, prev2: 0.0, sum: 0.0, cmin: f32::INFINITY }; n], m: 0 } + } + + /// Fold the next slice (aggregated focus values ≥ 0). + fn push(&mut self, c: &[f32]) { + let m = self.m; + for (p, &v) in self.px.iter_mut().zip(c) { + if m >= 1 && (m == 1 || p.prev >= p.prev2) && p.prev > v { + let l = if m >= 2 { p.prev2 } else { -1.0 }; + p.register(p.prev, (m - 1) as u32, l, v); + } + p.sum += v; + p.cmin = p.cmin.min(v); + (p.prev2, p.prev) = (p.prev, v); + } + self.m += 1; + } + + /// Close the profiles: the sub-layer depth and the raw confidence per pixel. + fn finish(mut self, gate: f32) -> (Vec, Vec) { + let n = self.m; + if n == 0 { + return (vec![0.0; self.px.len()], vec![0.0; self.px.len()]); + } + for p in self.px.iter_mut() { + if n == 1 || p.prev >= p.prev2 { + let l = if n >= 2 { p.prev2 } else { -1.0 }; + p.register(p.prev, (n - 1) as u32, l, -1.0); + } + } + // The noise floor: the median of the per-pixel profile minimum (subsampled). + let mut mins: Vec = self.px.iter().step_by(7).map(|p| if p.cmin.is_finite() { p.cmin } else { 0.0 }).collect(); + let floor = if mins.is_empty() { + 0.0 + } else { + let mid = mins.len() / 2; + *mins.select_nth_unstable_by(mid, |a, b| a.total_cmp(b)).1 + }; + let inv_n = 1.0 / n as f32; + let out: Vec<(f32, f32)> = par_map(self.px.len(), |i| { + let p = &self.px[i]; + let (c1, l, r) = (p.c1, p.l1, p.r1); + let mut delta = 0.0f32; + if l >= 0.0 && r >= 0.0 && c1 > 0.0 { + // Gaussian interpolation (Nayar & Nakagawa 1994): a parabola in the log domain. + let (ll, lc, lr) = (l.max(1e-12).ln(), c1.ln(), r.max(1e-12).ln()); + let den = ll - 2.0 * lc + lr; + if den < 0.0 { + delta = (0.5 * (ll - lr) / den).clamp(-0.5, 0.5); + } + } + let conf = if c1 > 0.0 { + let prom = (1.0 - p.sum * inv_n / c1).clamp(0.0, 1.0); + // A rival local maximum of similar height means an ambiguous profile. + let pkr = if p.c2 >= 0.0 { (1.0 - p.c2 / c1).clamp(0.0, 1.0) } else { 1.0 }; + let g = if gate > 0.0 && floor > 0.0 { ((c1 - floor) / (gate * floor)).clamp(0.0, 1.0) } else { 1.0 }; + prom * pkr * g + } else { + 0.0 + }; + (p.i1 as f32 + delta, conf) + }); + out.into_iter().unzip() + } +} + +/// Scale the confidence so its 90th percentile maps to 1: `lambda` then means the same on every +/// stack. +fn normalize_conf(conf: &mut [f32]) { + let mut sample: Vec = conf.iter().step_by(7).copied().collect(); + if sample.is_empty() { + return; + } + let k = (sample.len() * 9 / 10).min(sample.len() - 1); + let p90 = *sample.select_nth_unstable_by(k, |a, b| a.total_cmp(b)).1; + if p90 > 1e-6 { + conf.iter_mut().for_each(|c| *c = (*c / p90).min(1.0)); + } +} + +/// 3×3 median, clamped borders. +fn median3(src: &[f32], w: usize, h: usize) -> Vec { + let mut out = vec![0.0f32; w * h]; + par_rows(&mut out, w, 1, |y, row| { + let ys = [y.saturating_sub(1), y, (y + 1).min(h - 1)]; + let mut v = [0.0f32; 9]; + for (x, o) in row.iter_mut().enumerate() { + let xs = [x.saturating_sub(1), x, (x + 1).min(w - 1)]; + for (k, (yy, xx)) in ys.iter().flat_map(|yy| xs.iter().map(move |xx| (*yy, *xx))).enumerate() { + v[k] = src[yy * w + xx]; + } + v.sort_unstable_by(|a, b| a.total_cmp(b)); + *o = v[4]; + } + }); + out +} + +/// Edge weights between horizontal and vertical neighbours: `exp(−|ΔI| / σ)`; `ax[i]` sits +/// between `(x, y)` and `(x + 1, y)`, `ay[i]` between `(x, y)` and `(x, y + 1)`. +fn edge_weights(guide: &[f32], w: usize, h: usize, sigma: f32) -> (Vec, Vec) { + let inv = -1.0 / sigma.max(1e-6); + let (mut ax, mut ay) = (vec![0.0f32; w * h], vec![0.0f32; w * h]); + par_rows2(&mut ax, &mut ay, w, |y, rx, ry| { + let g = &guide[y * w..y * w + w]; + for x in 0..w.saturating_sub(1) { + rx[x] = ((g[x] - g[x + 1]).abs() * inv).exp(); + } + if y + 1 < h { + let g1 = &guide[(y + 1) * w..(y + 2) * w]; + for x in 0..w { + ry[x] = ((g[x] - g1[x]).abs() * inv).exp(); + } + } + }); + (ax, ay) +} + +/// One 1-D weighted-least-squares line `(wd_i + λ(a_{i−1} + a_i)) u_i − λ a_{i−1} u_{i−1} − +/// λ a_i u_{i+1} = wd_i f_i` by the Thomas algorithm; `a[i]` is the weight between `i` and +/// `i + 1`; `scratch` holds the sweep's two coefficient lines. +fn solve_line(u: &mut [f32], f: &[f32], wd: &[f32], a: &[f32], lam: f32, scratch: &mut [f32]) { + let n = u.len(); + if n == 0 || scratch.len() < 2 * n { + return; + } + let (cp, dp) = scratch.split_at_mut(n); + let mut prev_a = 0.0f32; + for i in 0..n { + let ai = if i + 1 < n { a[i] } else { 0.0 }; + let diag = wd[i] + lam * (prev_a + ai); + let lower = -lam * prev_a; + let upper = -lam * ai; + let (c0, d0) = if i > 0 { (cp[i - 1], dp[i - 1]) } else { (0.0, 0.0) }; + let m = diag - lower * c0; + let m = if m.abs() < 1e-12 { 1e-12 } else { m }; + cp[i] = upper / m; + dp[i] = (wd[i] * f[i] - lower * d0) / m; + prev_a = ai; + } + u[n - 1] = dp[n - 1]; + for i in (0..n - 1).rev() { + u[i] = dp[i] - cp[i] * u[i + 1]; + } +} + +/// The row sweeps of the fast global smoother, in parallel over rows. +fn solve_rows(u: &mut [f32], f: &[f32], wd: &[f32], ax: &[f32], w: usize, lam: f32) { + par_rows(u, w, 1, |y, row| { + let o = y * w; + let mut scratch = vec![0.0f32; 2 * w]; + solve_line(row, &f[o..o + w], &wd[o..o + w], &ax[o..o + w], lam, &mut scratch); + }); +} + +/// The column sweeps: the planes are transposed, swept as rows, and transposed back. +fn solve_cols(u: &mut [f32], f: &[f32], wd: &[f32], ay: &[f32], w: usize, h: usize, lam: f32) { + let t = |p: &[f32]| -> Vec { + let mut o = vec![0.0f32; w * h]; + par_rows(&mut o, h, 1, |x, col| { + for (y, v) in col.iter_mut().enumerate() { + *v = p[y * w + x]; + } + }); + o + }; + let (ft, wdt, ayt) = (t(f), t(wd), t(ay)); + let mut ut = vec![0.0f32; w * h]; + solve_rows(&mut ut, &ft, &wdt, &ayt, h, lam); + par_rows(u, w, 1, |y, row| { + for (x, v) in row.iter_mut().enumerate() { + *v = ut[x * h + y]; + } + }); +} + +/// The fast global smoother's three separable passes with the `λ_t` schedule of Min et al. +/// 2014, the first pass carrying the data weights so holes are filled by interpolation, the +/// later ones the previous output: the initial guess of [`wls_solve`]. +fn sweeps(d: &[f32], wd: &[f32], ex: &[f32], ey: &[f32], w: usize, h: usize, lambda: f32) -> Vec { + const T: i32 = 3; + let ones = vec![1.0f32; w * h]; + let mut u = vec![0.0f32; w * h]; + let mut f = d.to_vec(); + for t in 1..=T { + let lam_t = 1.5 * lambda * 4f32.powi(T - t) / (4f32.powi(T) - 1.0); + let wt: &[f32] = if t == 1 { wd } else { &ones }; + solve_rows(&mut u, &f, wt, ex, w, lam_t); + f.copy_from_slice(&u); + solve_cols(&mut u, &f, &ones, ey, w, h, lam_t); + f.copy_from_slice(&u); + } + u +} + +/// The WLS operator `A = W + Σ λ a_pq` on one grid of the multigrid hierarchy, with the planes +/// a V-cycle needs there. +struct MgLevel { + w: usize, + h: usize, + /// Data weight per cell; on the coarse grids the sum over the block. + wd: Vec, + /// `λ·a` between `(x, y)` and `(x + 1, y)`, and between `(x, y)` and `(x, y + 1)`; on the + /// coarse grids the sum of the fine edges the block boundary cuts. + ax: Vec, + ay: Vec, + /// `1 / diag(A)`. + dinv: Vec, + x: Vec, + b: Vec, + r: Vec, +} + +/// Damped-Jacobi weight (4/5 is the smoothing optimum of the 5-point stencil). +const MG_OMEGA: f32 = 0.8; +/// Smoothing sweeps before and after the coarse correction, and on the coarsest grid (where +/// they are the solve). +const MG_PRE: usize = 1; +const MG_POST: usize = 1; +const MG_COARSE: usize = 32; +/// Coarsen until the grid is this small on its longer side. +const MG_MIN: usize = 16; + +impl MgLevel { + fn new(w: usize, h: usize) -> MgLevel { + let n = w * h; + MgLevel { w, h, wd: vec![0.0; n], ax: vec![0.0; n], ay: vec![0.0; n], dinv: vec![0.0; n], x: vec![0.0; n], b: vec![0.0; n], r: vec![0.0; n] } + } + + /// `out_i = f(i, (A x)_i)` over the grid. + fn apply f32 + Sync + Send>(&self, x: &[f32], out: &mut [f32], f: F) { + let (w, h) = (self.w, self.h); + let (wd, ax, ay) = (&self.wd, &self.ax, &self.ay); + par_rows(out, w, 1, |y, row| { + let o = y * w; + for (i, r) in row.iter_mut().enumerate() { + let k = o + i; + let xk = x[k]; + let mut v = wd[k] * xk; + if i > 0 { + v += ax[k - 1] * (xk - x[k - 1]); + } + if i + 1 < w { + v += ax[k] * (xk - x[k + 1]); + } + if y > 0 { + v += ay[k - w] * (xk - x[k - w]); + } + if y + 1 < h { + v += ay[k] * (xk - x[k + w]); + } + *r = f(k, v); + } + }); + } + + fn set_dinv(&mut self) { + let (w, h) = (self.w, self.h); + let (wd, ax, ay) = (&self.wd, &self.ax, &self.ay); + par_rows(&mut self.dinv, w, 1, |y, row| { + for (i, r) in row.iter_mut().enumerate() { + let k = y * w + i; + let mut v = wd[k]; + if i > 0 { + v += ax[k - 1]; + } + if i + 1 < w { + v += ax[k]; + } + if y > 0 { + v += ay[k - w]; + } + if y + 1 < h { + v += ay[k]; + } + *r = 1.0 / v.max(1e-12); + } + }); + } + + /// One damped-Jacobi sweep on `x` for `b`: `x += ω D⁻¹ (b − A x)`. + fn jacobi(&mut self, zero_start: bool) { + if zero_start { + for ((x, b), d) in self.x.iter_mut().zip(&self.b).zip(&self.dinv) { + *x = MG_OMEGA * d * b; + } + return; + } + let mut r = std::mem::take(&mut self.r); + { + let (x, b, dinv) = (&self.x, &self.b, &self.dinv); + self.apply(x, &mut r, |k, ax| x[k] + MG_OMEGA * dinv[k] * (b[k] - ax)); + } + self.r = std::mem::replace(&mut self.x, r); + } + + /// The 2×2 aggregation of this grid: the Galerkin operator of a piecewise-constant + /// prolongation (data weights summed over the block, the fine edges cut by a block boundary + /// summed into the coarse edge). + fn coarsen(&self) -> MgLevel { + let (w, h) = (self.w, self.h); + let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); + let mut c = MgLevel::new(cw, ch); + self.coarsen_wd_into(&mut c); + let (ax, ay) = (&self.ax, &self.ay); + par_rows(&mut c.ax, cw, 1, |yy, row| { + for y in 2 * yy..(2 * yy + 2).min(h) { + for (xx, o) in row.iter_mut().enumerate() { + if xx + 1 < cw { + *o += ax[y * w + 2 * xx + 1]; + } + } + } + }); + par_rows(&mut c.ay, cw, 1, |yy, row| { + if yy + 1 < ch { + let y = 2 * yy + 1; + for (xx, o) in row.iter_mut().enumerate() { + *o = ay[y * w..y * w + w][2 * xx..(2 * xx + 2).min(w)].iter().sum::(); + } + } + }); + c + } + + /// The data weights of the coarse grid `c` from this grid's (after a reweighting; the edges + /// do not change). + fn coarsen_wd_into(&self, c: &mut MgLevel) { + let (w, h, cw) = (self.w, self.h, c.w); + let wd = &self.wd; + par_rows(&mut c.wd, cw, 1, |yy, row| { + row.fill(0.0); + for y in 2 * yy..(2 * yy + 2).min(h) { + for (xx, o) in row.iter_mut().enumerate() { + *o += wd[y * w..y * w + w][2 * xx..(2 * xx + 2).min(w)].iter().sum::(); + } + } + }); + } +} + +/// `x += P x_c`: the coarse correction injected (piecewise constant). +fn prolong_add(x: &mut [f32], w: usize, xc: &[f32], cw: usize) { + par_rows(x, w, 1, |y, row| { + let c = &xc[(y / 2) * cw..(y / 2) * cw + cw]; + for (i, o) in row.iter_mut().enumerate() { + *o += c[i / 2]; + } + }); +} + +/// `b_c = Pᵀ r`: the residual summed over each block. +fn restrict(r: &[f32], w: usize, h: usize, bc: &mut [f32], cw: usize) { + par_rows(bc, cw, 1, |yy, row| { + row.fill(0.0); + for y in 2 * yy..(2 * yy + 2).min(h) { + let s = &r[y * w..y * w + w]; + for (xx, o) in row.iter_mut().enumerate() { + *o += s[2 * xx..(2 * xx + 2).min(w)].iter().sum::(); + } + } + }); +} + +/// One V-cycle from `levels[0]` (whose `b` is set) into its `x`. +fn vcycle(levels: &mut [MgLevel]) { + let Some((top, rest)) = levels.split_first_mut() else { return }; + let Some(next) = rest.first_mut() else { + for k in 0..MG_COARSE { + top.jacobi(k == 0); + } + return; + }; + for k in 0..MG_PRE { + top.jacobi(k == 0); + } + { + let mut r = std::mem::take(&mut top.r); + let (x, b) = (&top.x, &top.b); + top.apply(x, &mut r, |k, ax| b[k] - ax); + top.r = r; + } + restrict(&top.r, top.w, top.h, &mut next.b, next.w); + vcycle(rest); + let Some(next) = rest.first() else { return }; + prolong_add(&mut top.x, top.w, &next.x, next.w); + for _ in 0..MG_POST { + top.jacobi(false); + } +} + +/// Edge-aware WLS: `argmin_u Σ w_p (u_p − d_p)² + λ Σ a_pq (u_p − u_q)²`, the 2-D system +/// `(W + λL) u = W d` solved by conjugate gradients from the separable sweeps' guess, +/// preconditioned by one multigrid V-cycle (2×2 aggregation, damped Jacobi), so the low modes +/// converge where the confidence is low over a large area (a flat wall). The solver keeps its +/// hierarchy between solves, so a reweighting costs no rebuild. +struct WlsSolver { + w: usize, + h: usize, + lambda: f32, + /// Edge weights `exp(−|ΔI| / σ)` (without λ), for the separable sweeps. + ex: Vec, + ey: Vec, + levels: Vec, +} + +impl WlsSolver { + fn new(guide: &[f32], w: usize, h: usize, lambda: f32, sigma_c: f32) -> WlsSolver { + let (ex, ey) = edge_weights(guide, w, h, sigma_c); + let mut top = MgLevel::new(w, h); + for (a, e) in top.ax.iter_mut().zip(&ex) { + *a = lambda * e; + } + for (a, e) in top.ay.iter_mut().zip(&ey) { + *a = lambda * e; + } + let mut levels = vec![top]; + while levels.last().is_some_and(|l| l.w.max(l.h) > MG_MIN) { + let Some(last) = levels.last() else { break }; + let c = last.coarsen(); + levels.push(c); + } + WlsSolver { w, h, lambda, ex, ey, levels } + } + + /// The data weights: the confidence (plus a floor so the system is definite where nothing + /// is known), aggregated down the hierarchy. + fn set_weights(&mut self, conf: &[f32]) { + const EPS_DATA: f32 = 1e-4; + if let Some(top) = self.levels.first_mut() { + for (w, c) in top.wd.iter_mut().zip(conf) { + *w = c.max(0.0) + EPS_DATA; + } + } + for l in 0..self.levels.len() { + if l > 0 { + let (fine, coarse) = self.levels.split_at_mut(l); + if let (Some(f), Some(c)) = (fine.last(), coarse.first_mut()) { + f.coarsen_wd_into(c); + } + } + self.levels[l].set_dinv(); + } + } + + /// Solve for the data `d`: the solution and its final relative residual. + fn solve(&mut self, d: &[f32], max_iters: usize) -> (Vec, f32) { + let (w, h, lambda) = (self.w, self.h, self.lambda); + let n = w * h; + let Some(top) = self.levels.first() else { return (vec![0.0; n], 0.0) }; + let mut u = sweeps(d, &top.wd, &self.ex, &self.ey, w, h, lambda); + if w == 1 || h == 1 || n == 0 { + return (u, 0.0); + } + let dot = |a: &[f32], b: &[f32]| -> f64 { a.iter().zip(b).map(|(x, y)| f64::from(*x) * f64::from(*y)).sum() }; + let b: Vec = top.wd.iter().zip(d).map(|(w, d)| w * d).collect(); + let bnorm = dot(&b, &b).sqrt().max(1e-30); + let mut r = vec![0.0f32; n]; + top.apply(&u, &mut r, |k, au| b[k] - au); + let precond = |levels: &mut [MgLevel], r: &[f32], z: &mut [f32]| { + if let Some(top) = levels.first_mut() { + top.b.copy_from_slice(r); + } + vcycle(levels); + if let Some(top) = levels.first() { + z.copy_from_slice(&top.x); + } + }; + let mut z = vec![0.0f32; n]; + precond(&mut self.levels, &r, &mut z); + let mut p = z.clone(); + let mut ap = vec![0.0f32; n]; + let mut rz = dot(&r, &z); + let mut rel = (dot(&r, &r).sqrt() / bnorm) as f32; + for _ in 0..max_iters { + if rel < 1e-5 { + break; + } + let Some(top) = self.levels.first() else { break }; + top.apply(&p, &mut ap, |_, v| v); + let pap = dot(&p, &ap); + if pap <= 0.0 { + break; + } + let alpha = (rz / pap) as f32; + for (u, p) in u.iter_mut().zip(&p) { + *u += alpha * p; + } + for (r, ap) in r.iter_mut().zip(&ap) { + *r -= alpha * ap; + } + rel = (dot(&r, &r).sqrt() / bnorm) as f32; + if rel < 1e-5 { + break; + } + precond(&mut self.levels, &r, &mut z); + let rz_new = dot(&r, &z); + let beta = (rz_new / rz) as f32; + rz = rz_new; + for (p, z) in p.iter_mut().zip(&z) { + *p = z + beta * *p; + } + } + (u, rel) + } +} + +/// Conjugate-gradient iteration cap of the regularisation. +const CG_ITERS: usize = 200; + +/// Depth from focus over the `n` layers' luminance planes `luma` (`w × h`, aligned), guided by +/// the all-in-focus luminance `guide` of the same size. Without layers, or when `guide` or a +/// plane is not `w × h` long, the map is empty: depth 0 and confidence 0 everywhere. +pub fn depth_from_focus(luma: &[&[f32]], guide: &[f32], w: usize, h: usize, p: &DepthParams) -> DepthMap { + let n = luma.len(); + let empty = n == 0 || w == 0 || h == 0 || guide.len() != w * h || luma.iter().any(|l| l.len() != w * h); + if empty { + return DepthMap { depth: vec![0.0; w * h], conf: vec![0.0; w * h], w, h }; + } + let k = 1usize << p.scale.min(8); + let (g, dw, dh) = block_mean(guide, w, h, k); + let gf = GuidedFilter::new(g.clone(), dw, dh, p.agg_radius, p.agg_eps); + let mut tracker = PeakTracker::new(dw * dh); + for l in luma { + let slice = block_mean(&focus_measure(l, w, h, p.r_in, p.r_out), w, h, k).0; + if p.agg_radius > 0 { + // The guided filter can undershoot; the profile statistics assume ≥ 0. + let agg: Vec = gf.filter(&slice).into_iter().map(|v| v.max(0.0)).collect(); + tracker.push(&agg); + } else { + tracker.push(&slice); + } + } + let (depth_w, mut conf) = tracker.finish(p.gate); + let depth_w = median3(&depth_w, dw, dh); + normalize_conf(&mut conf); + let depth_w = if p.lambda > 0.0 { + let mut solver = WlsSolver::new(&g, dw, dh, p.lambda, p.sigma_c); + solver.set_weights(&conf); + let (u, _) = solver.solve(&depth_w, CG_ITERS); + if p.robust > 0.0 { + // One reweighted step with a Huber loss on the data residual. + let wd2: Vec = conf.iter().zip(&depth_w).zip(&u).map(|((c, d), u)| c * (p.robust / (d - u).abs().max(1e-6)).min(1.0)).collect(); + solver.set_weights(&wd2); + solver.solve(&depth_w, CG_ITERS).0 + } else { + u + } + } else { + depth_w + }; + let max_d = (n - 1) as f32; + let up = GuidedFilter::new(g, dw, dh, 2, 1e-2); + let depth: Vec = if k == 1 { + up.filter(&depth_w) + } else { + let (a, b) = up.coeffs(&depth_w); + let a = upsample_bilinear(&a, dw, dh, w, h, k); + let b = upsample_bilinear(&b, dw, dh, w, h, k); + (0..w * h).map(|i| a[i] * guide[i] + b[i]).collect() + }; + let depth = depth.into_iter().map(|v| v.clamp(0.0, max_d)).collect(); + let conf = if k == 1 { conf } else { upsample_bilinear(&conf, dw, dh, w, h, k) }; + DepthMap { depth, conf, w, h } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn lcg(seed: &mut u64) -> f32 { + *seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + ((*seed >> 33) % 10000) as f32 / 10000.0 + } + + #[test] + fn box_filter_matches_the_naive_window() { + let (w, h, r) = (23, 17, 3); + let mut s = 5u64; + let src: Vec = (0..w * h).map(|_| lcg(&mut s)).collect(); + let m = BoxFilter::new(w, h, r).mean(&src); + for y in 0..h { + for x in 0..w { + let (mut acc, mut cnt) = (0.0, 0.0); + for yy in y.saturating_sub(r)..(y + r + 1).min(h) { + for xx in x.saturating_sub(r)..(x + r + 1).min(w) { + acc += src[yy * w + xx]; + cnt += 1.0; + } + } + assert!((m[y * w + x] - acc / cnt).abs() < 1e-4, "({x},{y})"); + } + } + } + + #[test] + fn guided_filter_with_a_flat_guide_is_a_double_box_mean() { + let (w, h) = (40, 30); + let mut s = 9u64; + let p: Vec = (0..w * h).map(|_| lcg(&mut s)).collect(); + let q = GuidedFilter::new(vec![0.5; w * h], w, h, 2, 1e-3).filter(&p); + let bf = BoxFilter::new(w, h, 2); + let m = bf.mean(&bf.mean(&p)); + assert!(q.iter().zip(&m).all(|(a, b)| (a - b).abs() < 1e-4)); + } + + #[test] + fn ring_difference_is_zero_mean_and_sees_texture() { + let taps = rdf_taps(1, 3); + assert!(taps.iter().map(|t| t.2).sum::().abs() < 1e-6); + let (w, h) = (32, 32); + assert!(focus_measure(&vec![0.4; w * h], w, h, 1, 3).iter().all(|v| v.abs() < 1e-6)); + let check: Vec = (0..w * h).map(|i| if ((i % w) / 2 + (i / w) / 2).is_multiple_of(2) { 0.2 } else { 0.8 }).collect(); + assert!(focus_measure(&check, w, h, 1, 3).iter().cloned().fold(0.0, f32::max) > 0.1); + let (m, dw, dh) = block_mean(&check, w, h, 2); + assert_eq!((dw, dh, m.len()), (16, 16, 256)); + assert!(m.iter().all(|v| (v - 0.2).abs() < 1e-6 || (v - 0.8).abs() < 1e-6)); + let up = upsample_bilinear(&m, dw, dh, w, h, 2); + assert_eq!(up.len(), w * h); + } + + #[test] + fn peak_search_interpolates_between_layers() { + // A profile peaking between layers 2 and 3 (log-parabola), one flat profile. + let mut t = PeakTracker::new(2); + for m in 0..6 { + let v = (-((m as f32 - 2.4).powi(2)) / 2.0).exp(); + t.push(&[v, 0.1]); + } + let (d, c) = t.finish(0.0); + assert!((d[0] - 2.4).abs() < 0.05, "{d:?}"); + assert!(c[0] > 0.5 && c[1] < 1e-6, "{c:?}"); + } + + #[test] + fn smoothing_fills_holes_and_respects_edges() { + let (w, h) = (40, 20); + // A guide with a hard edge at x = 20; data known only at two pixels, one per side. + let guide: Vec = (0..w * h).map(|i| if i % w < 20 { 0.0 } else { 1.0 }).collect(); + let mut d = vec![0.0f32; w * h]; + let mut wd = vec![1e-4f32; w * h]; + (d[10 * w + 5], wd[10 * w + 5]) = (1.0, 1.0); + (d[10 * w + 35], wd[10 * w + 35]) = (5.0, 1.0); + let mut solver = WlsSolver::new(&guide, w, h, 3.0, 0.04); + solver.set_weights(&wd); + let (u, rel) = solver.solve(&d, CG_ITERS); + assert!(rel < 1e-4, "residual {rel}"); + // Each side follows its own sample (the data floor on the unknown pixels pulls a little + // toward zero), and the edge keeps them apart. + assert!((u[3 * w + 15] - 1.0).abs() < 0.15, "left side follows its sample: {}", u[3 * w + 15]); + assert!((u[15 * w + 25] - 5.0).abs() < 0.6, "right side follows its sample: {}", u[15 * w + 25]); + assert!(u[10 * w + 19] < 1.5 && u[10 * w + 20] > 4.0, "the edge holds: {} | {}", u[10 * w + 19], u[10 * w + 20]); + // The sweeps alone leave the holes near zero; the solve fills them. + let guess = sweeps(&d, &wd, &solver.ex, &solver.ey, w, h, 3.0); + assert!(guess[3 * w + 15] < 0.5); + assert_eq!(median3(&[0.5; 9], 3, 3)[4], 0.5); + } + + /// Three layers: the left third sharp in layer 0, the middle in 1, the right in 2. The depth + /// map says so, sub-layer in between, with high confidence on the texture. + #[test] + fn depth_from_focus_finds_the_sharp_layer() { + let (w, h) = (96, 48); + let texture = |x: usize, y: usize| if ((x / 2) + (y / 2)).is_multiple_of(2) { 0.2 } else { 0.8 }; + let flat = 0.5; + let layers: Vec> = (0..3).map(|k| (0..w * h).map(|i| if (i % w) / 32 == k { texture(i % w, i / w) } else { flat }).collect()).collect(); + let guide: Vec = (0..w * h).map(|i| texture(i % w, i / w)).collect(); + let refs: Vec<&[f32]> = layers.iter().map(Vec::as_slice).collect(); + let dm = depth_from_focus(&refs, &guide, w, h, &DepthParams::default()); + assert_eq!((dm.w, dm.h, dm.depth.len()), (w, h, w * h)); + for (x, want) in [(8, 0.0), (48, 1.0), (88, 2.0)] { + let d = dm.depth[24 * w + x]; + assert!((d - want).abs() < 0.35, "x {x}: depth {d}, want {want}"); + assert!(dm.conf[24 * w + x] > 0.3, "x {x}: conf {}", dm.conf[24 * w + x]); + } + assert!(dm.depth.iter().all(|v| (0.0..=2.0).contains(v))); + // Degenerate inputs are fine. + assert_eq!(depth_from_focus(&[], &guide, w, h, &DepthParams::default()).depth, vec![0.0; w * h]); + // A guide or a plane of the wrong length is no input: the empty map, never a panic. + let empty = DepthMap { depth: vec![0.0; w * h], conf: vec![0.0; w * h], w, h }; + assert_eq!(depth_from_focus(&refs, &guide[1..], w, h, &DepthParams::default()), empty); + let short = &refs[0][..w * h - 1]; + assert_eq!(depth_from_focus(&[refs[0], short], &guide, w, h, &DepthParams::default()), empty); + assert_eq!(depth_from_focus(&refs, &guide, w + 1, h, &DepthParams::default()).w, w + 1); + let one = depth_from_focus(&refs[..1], &guide, w, h, &DepthParams::default()); + assert!(one.depth.iter().all(|v| *v == 0.0)); + } +} diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index 9d6ba294815..39f48034b4f 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -26,6 +26,7 @@ pub mod camera_raw; pub mod color_to_alpha; pub mod content_aware; mod denoise; +pub mod dff; pub mod direct; mod distort; mod distort2; diff --git a/crates/engine/src/align_cmds.rs b/crates/engine/src/align_cmds.rs index 26cd1acc395..b54f4b7a73e 100644 --- a/crates/engine/src/align_cmds.rs +++ b/crates/engine/src/align_cmds.rs @@ -17,6 +17,9 @@ //! Laplacian pyramid, each coefficient from the layer sharpest at that scale //! ([`pyramid::fuse_stack`]; `haloControl` keeps a bright object's defocused glow out of the //! coarse levels). +//! Stack Images can also add a hidden Depth layer, the layer each pixel is sharpest in by depth +//! from focus ([`photocraft_algo::dff`]): black = the bottom layer, white = the top, so that once +//! on a layer mask it drives Lens Blur's depth map. use photocraft_algo::panorama::Layout; use photocraft_algo::pyramid; @@ -169,6 +172,10 @@ fn auto_blend(s: &mut Session, p: &Value) -> Result { _ => return Err(bad(cmd, format!("`haloControl` must be a number from 0 to {}", pyramid::HALO_MAX))), }, }; + let want_depth = p.get("depthMap").and_then(Value::as_bool).unwrap_or(false); + if want_depth && method != "stack" { + return Err(bad(cmd, "`depthMap` needs `method`: stack")); + } let st = s.active().ok_or(EngineError::NoDocument)?; let doc: std::sync::Arc = st.doc.clone(); let mut area = Rect::EMPTY; @@ -223,9 +230,33 @@ fn auto_blend(s: &mut Session, p: &Value) -> Result { let blended = seamless.then(|| b.composite.read_region(area)); (weights, blended) }; + // The depth map: the layer each pixel is sharpest in, from the focus volume guided by the + // merged picture (or, without one, by the per-pixel pick), as a hidden gray layer, black = + // the bottom layer, white = the top, ready for Lens Blur's depth map once on a mask. + let depth_px = want_depth.then(|| { + let n = fmt.channels(); + let guide: Vec = match &blended { + Some(px) => (0..w * h) + .map(|i| { + let c = to_rgba(&fmt, &px[i * n..(i + 1) * n]); + 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2] + }) + .collect(), + None => (0..w * h).map(|i| luma.iter().zip(&weights).map(|(l, wt)| l[i] * wt[i]).sum()).collect(), + }; + let refs: Vec<&[f32]> = luma.iter().map(Vec::as_slice).collect(); + let dm = photocraft_algo::dff::depth_from_focus(&refs, &guide, w, h, &photocraft_algo::dff::DepthParams::default()); + let scale = 1.0 / (ids.len() - 1).max(1) as f32; + let mut out = vec![0.0f32; w * h * n]; + for (i, d) in dm.depth.iter().enumerate() { + let g = (d * scale).clamp(0.0, 1.0); + photocraft_raster::from_rgba_into(&fmt, [g, g, g, 1.0], &mut out[i * n..(i + 1) * n]); + } + out + }); let top = *ids.last().ok_or_else(|| EngineError::Other("Auto-Blend needs two or more layers".into()))?; let label = "Auto-Blend Layers"; - let new_layer = s.edit(label, |doc, active| { + let (new_layer, depth_layer) = s.edit(label, |doc, active| { for (id, wt) in ids.iter().zip(&weights) { let l = doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?; let mut mask = LayerMask::hide_all(); @@ -233,17 +264,33 @@ fn auto_blend(s: &mut Session, p: &Value) -> Result { mask.surface.prune(); l.mask = Some(mask); } - let Some(px) = &blended else { return Ok(None) }; + let mut below = top; + let depth_id = depth_px.as_ref().map(|px| { + let mut l = Layer::raster(doc.next_layer_name("Depth"), fmt); + l.visible = false; + if let Some(surf) = l.surface_mut() { + surf.write_region(area, px); + surf.prune(); + } + below = doc.insert_above(Some(below), l); + below + }); + let Some(px) = &blended else { + if depth_id.is_some() { + *active = depth_id; + } + return Ok((None, depth_id)); + }; let mut l = Layer::raster(doc.next_layer_name("Blended"), fmt); if let Some(surf) = l.surface_mut() { surf.write_region(area, px); surf.prune(); } - let nid = doc.insert_above(Some(top), l); + let nid = doc.insert_above(Some(below), l); *active = Some(nid); - Ok(Some(nid)) + Ok((Some(nid), depth_id)) })?; - Ok(json!({"method": method, "layers": ids.iter().map(|i| i.0).collect::>(), "blended": new_layer.map(|l| l.0)})) + Ok(json!({"method": method, "layers": ids.iter().map(|i| i.0).collect::>(), "blended": new_layer.map(|l| l.0), "depth": depth_layer.map(|l| l.0)})) } macro_rules! spec { @@ -260,7 +307,12 @@ pub fn specs() -> Vec { r##"{"projection":"auto|perspective|cylindrical|spherical|collage|reposition","reference":layer id?=bottom selected layer,"geometricCorrection":bool=false,"interpolation":"bicubic|bilinear|nearest","registration":"auto|features|intensity"}"##, auto_align ), - spec!("edit.autoBlendLayers", "Auto-Blend Layers…", r##"{"method":"panorama|stack","seamlessTones":bool=true,"haloControl":0..8=2}"##, auto_blend), + spec!( + "edit.autoBlendLayers", + "Auto-Blend Layers…", + r##"{"method":"panorama|stack","seamlessTones":bool=true,"haloControl":0..8=2,"depthMap":bool=false}"##, + auto_blend + ), ] } @@ -533,4 +585,56 @@ mod tests { } } } + + /// Three layers, each sharp in one third: the Depth layer says which, black to white from + /// the bottom layer to the top, hidden, and undone with the rest. + #[test] + fn auto_blend_stack_depth_map() { + for depth in [8, 16, 32] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 96, "height": 48, "depth": depth, "background": "transparent"})).unwrap(); + let mut ids = Vec::new(); + for k in 0..3 { + s.execute("layer.new.layer", json!({"name": format!("L{k}")})).unwrap(); + s.edit("third", |doc, active| { + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + for y in 0..48 { + for x in 0..96 { + let v = if x / 32 == k && ((x / 2) + (y / 2)) % 2 == 0 { + 0.2 + } else if x / 32 == k { + 0.8 + } else { + 0.5 + }; + surf.fill_rect(Rect::new(x, y, x + 1, y + 1), &[v, v, v, 1.0]); + } + } + Ok(()) + }) + .unwrap(); + ids.push(s.active().unwrap().active_layer.unwrap()); + } + select(&mut s, &ids); + assert!(s.execute("edit.autoBlendLayers", json!({"method": "panorama", "depthMap": true})).is_err()); + for seamless in [false, true] { + let r = s.execute("edit.autoBlendLayers", json!({"method": "stack", "seamlessTones": seamless, "depthMap": true})).unwrap(); + let doc = &s.active().unwrap().doc; + let d = doc.layer(LayerId(r["depth"].as_u64().unwrap())).unwrap(); + assert!(!d.visible && d.name.starts_with("Depth"), "depth {depth}: {} visible {} {r}", d.name, d.visible); + let surf = d.surface().unwrap(); + for (x, want) in [(8, 0.0), (48, 0.5), (88, 1.0)] { + let px = surf.pixel(x, 24); + assert!((px[0] - want).abs() < 0.2 && (px[3] - 1.0).abs() < 1e-3, "depth {depth} seamless {seamless} x {x}: {px:?}"); + } + assert_eq!(r["blended"].is_null(), !seamless); + if seamless { + // The merged layer sits above the depth layer and is the active one. + assert_eq!(s.active().unwrap().active_layer, Some(LayerId(r["blended"].as_u64().unwrap()))); + } + s.undo(); + assert!(s.active().unwrap().doc.layer(LayerId(r["depth"].as_u64().unwrap())).is_none()); + } + } + } } From fc0a579a4acdeb534aa81cb2251e1cc321ccfe11 Mon Sep 17 00:00:00 2001 From: Elvis33LE <67311580+Elvis33LE@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:23:44 +0200 Subject: [PATCH 40/47] PSD: parallel RLE for the merged image; layer channels decode and gather on all cores (#2754) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saving a 36 MP document spent most of its time packing the merged image's RLE rows one after another on one thread (packbits over ~144 MB), and importing it decoded each layer's channels sequentially. The merged-image row packer now runs in parallel chunks that concatenate in order (rows are independent; errors surface like the sequential path's), the layer gather runs in row bands on all cores before the per-channel RLE, the layer's channels decode side by side, and the full-surface clone per layer is gone. Measured on a 6000×4000 8-bit document with a photo, a paint layer and a Levels adjustment: save 2.86 s -> 2.55-2.64 s, open's import stage 1.12 s -> 1.07 s (psd_to_document 745 -> 564 ms). The remaining save time is dominated by the merged composite render itself (~0.7 s). --- crates/io/src/psd_export.rs | 29 +++-- crates/io/src/psd_import.rs | 34 ++++-- crates/io/tests/psd_roundtrip_bytes.rs | 28 +++++ crates/psd/src/compression.rs | 145 ++++++++++++++++++++++--- 4 files changed, 208 insertions(+), 28 deletions(-) create mode 100644 crates/io/tests/psd_roundtrip_bytes.rs diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index b5e9db31e11..9db3d4799f2 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -298,6 +298,10 @@ fn upsert(raw: &mut Vec<([u8; 4], Vec)>, key: &[u8; 4], data: Vec, befor } } +fn h_of(r: photocraft_geom::Rect) -> usize { + r.height() as usize +} + fn psd_mode(m: ColorMode) -> PsdMode { match m { ColorMode::Grayscale => PsdMode::Grayscale, @@ -335,18 +339,28 @@ impl Ex { (-1..self.cc as i16).map(|id| self.encode(id, &[], 0, 0)).collect() } - fn pixel_channels(&mut self, s: &Surface, name: &str) -> (PsdRect, Vec) { + fn pixel_channels(&self, s: &Surface, name: &str) -> (PsdRect, Vec, Vec) { + let mut warnings = Vec::new(); let s = if s.format() != self.fmt { - self.warnings.push(format!("layer \"{name}\": pixels converted from {:?} to {:?}", s.format(), self.fmt)); + warnings.push(format!("layer \"{name}\": pixels converted from {:?} to {:?}", s.format(), self.fmt)); s.convert(self.fmt) } else { s.clone() }; let r = s.content_bounds(); if r.is_empty() { - return (PsdRect::default(), self.empty_channels()); + return (PsdRect::default(), self.empty_channels(), warnings); + } + // The interleaved gather is the sequential pass for full-canvas layers, so it runs + // in row bands on all cores and the band results concatenate in order. + let row_len = r.width() as usize; + let bands = crate::pixels::bands(h_of(r)); + let parts = + crate::pixels::par_map(bands, |rows| s.to_interleaved(photocraft_geom::Rect::new(r.x0, r.y0 + rows.start as i32, r.x1, r.y0 + rows.end as i32))); + let mut bytes = Vec::with_capacity(row_len * h_of(r) * s.format().bytes_per_pixel()); + for part in parts { + bytes.extend_from_slice(&part); } - let mut bytes = s.to_interleaved(r); if self.fmt.mode == ColorMode::Lab && self.fmt.sample == SampleType::U16 { crate::pixels::lab16_chroma(&mut bytes, self.cc + 1, false); } @@ -359,7 +373,7 @@ impl Ex { let order: Vec<(i16, &Vec)> = std::iter::once((-1, &planes[self.cc])).chain(planes.iter().take(self.cc).enumerate().map(|(c, p)| (c as i16, p))).collect(); let ch = crate::pixels::par_map(order, |(id, plane)| self.encode(id, plane, w, h)); - (to_psd_rect(r), ch) + (to_psd_rect(r), ch, warnings) } fn mask(&self, m: &LayerMask, vector: Option<(f32, f32)>) -> (MaskData, Option) { @@ -410,10 +424,11 @@ impl Ex { fn record(&mut self, l: &Layer, extra: Vec, pixels: Option<&Surface>) -> LayerRecord { let id = self.layer_id(l); - let (rect, mut channels) = match pixels { + let (rect, mut channels, warnings) = match pixels { Some(s) => self.pixel_channels(s, &l.name), - None => (PsdRect::default(), self.empty_channels()), + None => (PsdRect::default(), self.empty_channels(), Vec::new()), }; + self.warnings.extend(warnings); let mut mask = MaskData::None; // Vector mask density/feather live in the mask parameters, with or without a user mask. let vector = l.vector_mask.as_ref().map(|vm| (vm.density, vm.feather)); diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 323fe600b3d..444c2d6e5ba 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -163,6 +163,13 @@ impl Ctx<'_> { } } + /// [`Self::channel_plane`] without the warnings sink, so a layer's channels decode + /// concurrently (their RLE rows are the bulk of the open time on full-canvas layers). + fn channel_plane_decoded(&self, rec: &LayerRecord, id: i16) -> Option> { + rec.channel(id)?; + rec.decode_channel(id, self.file.header.depth, self.file.header.version).ok() + } + fn record_surface(&mut self, rec: &LayerRecord, name: &str) -> Surface { let r = rec.rect; if r.is_empty() || r.size().is_err() { @@ -170,15 +177,28 @@ impl Ctx<'_> { } let (w, h) = r.size().unwrap_or((0, 0)); let s = self.fmt.sample; - let mut planes = Vec::with_capacity(self.cc + 1); - for c in 0..self.cc { - planes.push(self.channel_plane(rec, c as i16, name)); - } - planes.push(self.channel_plane(rec, CHANNEL_TRANSPARENCY, name)); + let ids: Vec = (0..self.cc as i16).chain([CHANNEL_TRANSPARENCY]).collect(); + let planes: Vec>> = if cfg!(target_arch = "wasm32") { + ids.iter().map(|&id| self.channel_plane(rec, id, name)).collect() + } else { + // The channels decode independently; their RLE rows are the bulk of the open + // time for full-canvas layers, so they run side by side and the warnings land + // in the same order afterwards. + // par_map reuses the crate's worker pool (one OS thread per channel per layer + // would spawn thousands on a file with many layers). + let decoded: Vec>> = crate::pixels::par_map(ids.to_vec(), |id| self.channel_plane_decoded(rec, id)); + let mut planes: Vec>> = Vec::with_capacity(decoded.len()); + for (&id, p) in ids.iter().zip(decoded) { + if p.is_none() && rec.channel(id).is_some() { + self.warn(format!("layer \"{name}\": channel {id} could not be decoded; treated as empty")); + } + planes.push(p); + } + planes + }; // A channel that is in the file but could not be decoded leaves the layer empty, as the // warning says. Filling in for it would paint an opaque black layer over the document. - let ids = (0..self.cc as i16).chain([CHANNEL_TRANSPARENCY]); - if ids.zip(&planes).any(|(id, p)| p.is_none() && rec.channel(id).is_some()) { + if ids.iter().zip(planes.iter()).any(|(&id, p)| p.is_none() && rec.channel(id).is_some()) { return Surface::new(self.fmt); } // Only decoded channel data justifies a buffer: the decoders bound their output by the diff --git a/crates/io/tests/psd_roundtrip_bytes.rs b/crates/io/tests/psd_roundtrip_bytes.rs new file mode 100644 index 00000000000..dccd57856d5 --- /dev/null +++ b/crates/io/tests/psd_roundtrip_bytes.rs @@ -0,0 +1,28 @@ +//! The parallel RLE/decode paths must not change output bytes: a document exported by this +//! branch re-exports byte-equal after a decode/encode cycle, and matches main's exporter +//! (verified by the corpus round-trip floors in `corpus.rs`; here the byte-equality of the +//! codec layer itself is pinned). + +use photocraft_psd::compression::{Compression, PlaneLayout, decode_planes, encode_planes}; +use photocraft_psd::header::Version; + +#[test] +fn encode_decode_planes_round_trip_exactly() { + let mut data: u64 = 0x0123_4567_89ab_cdef; + let mut next = move || { + data ^= data << 13; + data ^= data >> 7; + data ^= data << 17; + data + }; + for (version, planes, w, h, depth) in + [(Version::Psd, 1usize, 640usize, 480usize, 8u16), (Version::Psb, 1, 15000, 10000, 8), (Version::Psd, 4, 513, 65, 8), (Version::Psb, 3, 64, 2049, 16)] + { + let layout = PlaneLayout { planes, width: w, height: h, depth, version }; + let n = layout.decoded_len().expect("size"); + let pattern: Vec = if h % 2 == 0 { vec![42u8; n] } else { (0..n).map(|i| ((i as u64 ^ next()) % 251) as u8).collect() }; + let encoded = encode_planes(Compression::Rle, &pattern, &layout).expect("encode"); + let decoded = decode_planes(Compression::Rle, &encoded, &layout).expect("decode"); + assert_eq!(decoded, pattern, "{version:?} {planes}x{w}x{h} d{depth}"); + } +} diff --git a/crates/psd/src/compression.rs b/crates/psd/src/compression.rs index d47408d28cc..7ad2296fbc0 100644 --- a/crates/psd/src/compression.rs +++ b/crates/psd/src/compression.rs @@ -8,7 +8,7 @@ use std::io::{Read, Write}; use crate::error::{PsdError, Result}; use crate::header::{Version, row_bytes}; -use crate::io::{Reader, WriteExt}; +use crate::io::Reader; /// Maximum number of decoded bytes a single decode call may produce: 8 GiB on /// 64-bit targets, 2 GiB elsewhere (the same budget as `photocraft-codecs`' @@ -345,21 +345,62 @@ fn encode_rle(decoded: &[u8], layout: &PlaneLayout) -> Result> { let rows = layout.rows()?; let rb = layout.row_bytes(); let cs = layout.count_size(); - let mut out = vec![0u8; rows * cs]; - let mut enc = Vec::new(); - for row in 0..rows { - let start = enc.len(); - packbits::encode(&decoded[row * rb..(row + 1) * rb], &mut enc); - let n = enc.len() - start; - if cs == 2 { - let n = u16::try_from(n).map_err(|_| PsdError::LimitExceeded("RLE row exceeds 65535 bytes (use PSB)"))?; - out[row * 2..row * 2 + 2].copy_from_slice(&n.to_be_bytes()); - } else { - let n = u32::try_from(n).map_err(|_| PsdError::LimitExceeded("RLE row too large"))?; - out[row * 4..row * 4 + 4].copy_from_slice(&n.to_be_bytes()); + if rows == 0 { + return Ok(Vec::new()); + } + // Rows are independent, so they pack in parallel chunks and concatenate in order. + let threads = if cfg!(target_arch = "wasm32") { 1 } else { std::thread::available_parallelism().map_or(1, |v| v.get()).clamp(1, 32) }; + let chunk_rows = rows.div_ceil(threads).max(1); + struct Chunk { + counts: Vec, + packed: Vec, + } + let pack_chunk = |first: usize, count: usize| -> Result { + let mut chunk = Chunk { counts: vec![0u8; count * cs], packed: Vec::new() }; + for row in first..first + count { + let start = chunk.packed.len(); + packbits::encode(&decoded[row * rb..(row + 1) * rb], &mut chunk.packed); + let n = chunk.packed.len() - start; + if cs == 2 { + let n = u16::try_from(n).map_err(|_| PsdError::LimitExceeded("RLE row exceeds 65535 bytes (use PSB)"))?; + chunk.counts[(row - first) * 2..(row - first) * 2 + 2].copy_from_slice(&n.to_be_bytes()); + } else { + let n = u32::try_from(n).map_err(|_| PsdError::LimitExceeded("RLE row too large"))?; + chunk.counts[(row - first) * 4..(row - first) * 4 + 4].copy_from_slice(&n.to_be_bytes()); + } } + Ok(chunk) + }; + let chunk_list: Vec = if threads == 1 { + vec![pack_chunk(0, rows)?] + } else { + std::thread::scope(|scope| -> Result> { + let handles: Vec<_> = (0..threads) + .map(|t| { + let first = t * chunk_rows; + let count = chunk_rows.min(rows.saturating_sub(first)); + scope.spawn(move || pack_chunk(first, count)) + }) + .collect(); + // Join every worker first, then surface the first error like the sequential + // path would instead of aborting the scope with a thread panic. + let mut all = Vec::with_capacity(threads); + for h in handles { + all.push(h.join().map_err(|_| PsdError::LimitExceeded("an RLE worker panicked"))??); + } + Ok(all) + })? + }; + let packed_total: usize = chunk_list.iter().map(|c| c.packed.len()).sum(); + let mut out = vec![0u8; rows * cs + packed_total]; + let mut counts_at = 0usize; + let mut packed_at = rows * cs; + for chunk in &chunk_list { + out[counts_at..counts_at + chunk.counts.len()].copy_from_slice(&chunk.counts); + counts_at += chunk.counts.len(); + out[packed_at..packed_at + chunk.packed.len()].copy_from_slice(&chunk.packed); + packed_at += chunk.packed.len(); } - out.put(&enc); Ok(out) } @@ -830,3 +871,79 @@ mod tests { assert!(zip_decompress(&[1, 2, 3, 4], 4).is_err()); } } + +#[cfg(test)] +mod parallel_rle_equivalence { + use super::*; + + /// The parallel row packer's output must be byte-equal to a straightforward sequential + /// encoding of the same plane, across depths, row counts around the chunking and data + /// shapes (runs, noise, all-default). + #[test] + fn parallel_rle_matches_sequential() { + let mut data: u64 = 0x9e37_79b9_7f4a_7c15; + let mut next = move || { + data ^= data << 13; + data ^= data >> 7; + data ^= data << 17; + data + }; + let layout_for = |rows: usize, rb: usize, version: Version| PlaneLayout { planes: 1, width: rb, height: rows, depth: 8, version }; + for (rows, rb, version) in [ + (0usize, 4usize, Version::Psd), + (1, 4, Version::Psd), + (7, 4, Version::Psb), + (64, 4, Version::Psd), + (65, 4, Version::Psb), // chunk boundary + (513, 4, Version::Psd), // more rows than threads on most runners + (2049, 4, Version::Psb), + (2, 4, Version::Psd), // fewer rows than threads + (33, 3000, Version::Psd), + (31, 20000, Version::Psb), + ] { + let pattern: Vec = match rows % 3 { + 0 => vec![7u8; rows * rb], // long runs + 1 => (0..rows * rb).map(|i| (i * 31 % 251) as u8).collect(), // noise-ish + _ => vec![0u8; rows * rb], // all default + }; + let pattern = if pattern.len() != rows * rb { vec![7u8; rows * rb] } else { pattern }; + let mut data = pattern.clone(); + if rows % 4 == 0 { + // sprinkle runs into the noise + for chunk in data.chunks_mut(97) { + if let Some(f) = chunk.first_mut() { + *f = 200; + } + } + } + let _ = next(); + let layout = layout_for(rows, rb, version); + let parallel = encode_rle(&data, &layout).expect("parallel encode"); + let sequential = encode_rle_sequential(&data, &layout).expect("sequential encode"); + assert_eq!(parallel, sequential, "rows {rows} rb {rb}"); + } + } + + /// The pre-parallel encoder, kept as the oracle. + fn encode_rle_sequential(decoded: &[u8], layout: &PlaneLayout) -> Result> { + let rows = layout.rows()?; + let rb = layout.row_bytes(); + let cs = layout.count_size(); + let mut out = vec![0u8; rows * cs]; + let mut enc = Vec::new(); + for row in 0..rows { + let start = enc.len(); + packbits::encode(&decoded[row * rb..(row + 1) * rb], &mut enc); + let n = enc.len() - start; + if cs == 2 { + let n = u16::try_from(n).map_err(|_| PsdError::LimitExceeded("RLE row exceeds 65535 bytes (use PSB)"))?; + out[row * 2..row * 2 + 2].copy_from_slice(&n.to_be_bytes()); + } else { + let n = u32::try_from(n).map_err(|_| PsdError::LimitExceeded("RLE row too large"))?; + out[row * 4..row * 4 + 4].copy_from_slice(&n.to_be_bytes()); + } + } + out.extend_from_slice(&enc); + Ok(out) + } +} From 50c4f72af71f2066045edf5045a4790071dc9abc Mon Sep 17 00:00:00 2001 From: optimalbytee Date: Tue, 13 Oct 2026 18:23:55 +0100 Subject: [PATCH 41/47] Speed up large-radius Unsharp Mask with guarded FFT convolution (#2987) * Speed up large-radius Unsharp Mask with guarded FFT convolution * Address Unsharp Mask review feedback --------- Co-authored-by: optimalbytee --- crates/algo/src/lib.rs | 52 ++- crates/algo/src/sharpen.rs | 18 +- crates/algo/src/sharpen/exact.rs | 134 ++++++ crates/algo/src/sharpen/fft.rs | 325 +++++++++++++ crates/algo/src/sharpen/fft/tests.rs | 146 ++++++ crates/algo/src/sharpen/fft_bench.rs | 112 +++++ crates/algo/src/sharpen/reference.rs | 113 +++++ crates/algo/src/sharpen/tests.rs | 434 ++++++++++++++++++ .../ui-egui/examples/unsharp_preview_bench.rs | 67 +++ docs/roadmap.md | 6 +- docs/unsharp-mask-performance.md | 23 + 11 files changed, 1412 insertions(+), 18 deletions(-) create mode 100644 crates/algo/src/sharpen/exact.rs create mode 100644 crates/algo/src/sharpen/fft.rs create mode 100644 crates/algo/src/sharpen/fft/tests.rs create mode 100644 crates/algo/src/sharpen/fft_bench.rs create mode 100644 crates/algo/src/sharpen/reference.rs create mode 100644 crates/algo/src/sharpen/tests.rs create mode 100644 crates/ui-egui/examples/unsharp_preview_bench.rs create mode 100644 docs/unsharp-mask-performance.md diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index 39f48034b4f..78dae7b06f1 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -553,7 +553,8 @@ impl FilterParams { let g = |sigma: f32| Halo::Radius((sigma.max(0.0) * 3.0).ceil() as i32 + 1); match self { FilterParams::GaussianBlur { radius } | FilterParams::HighPass { radius } => g(*radius), - FilterParams::UnsharpMask { radius, .. } | FilterParams::SmartSharpen { radius, .. } => g(*radius), + FilterParams::UnsharpMask { radius, .. } => Halo::Radius(sharpen::exact::halo(*radius)), + FilterParams::SmartSharpen { radius, .. } => g(*radius), FilterParams::BoxBlur { radius } | FilterParams::SurfaceBlur { radius, .. } | FilterParams::Median { radius } @@ -938,7 +939,8 @@ pub fn apply_in(surface: &Surface, params: &FilterParams, area: Rect, bounds: Re } /// [`apply_in`] that can be cancelled (checked before each tile) and reports progress per tile -/// group. `None` when `ctl` was cancelled; the input surface is never modified. +/// group. `None` when cancelled, or when a checked filter rejects geometry/allocation; +/// the input surface is never modified. pub fn apply_in_with( surface: &Surface, params: &FilterParams, @@ -970,13 +972,13 @@ pub fn apply_tiled( tile: i32, extent: Option, ) -> Surface { - // Preserve the input if a checked Shape Blur allocation cannot be completed. + // Preserve the input if a checked filter allocation cannot be completed. apply_tiled_with(surface, params, area, bounds, selection, tile, extent, &photocraft_raster::Interrupt::NONE).unwrap_or_else(|| surface.clone()) } /// [`apply_tiled`] with cancellation (checked before each tile, so a cancel takes effect within /// one tile's work) and progress (after each group of tiles). `None` when cancelled, -/// or when Shape Blur cannot allocate a tile. +/// or when a checked filter rejects geometry or cannot allocate a tile. #[allow(clippy::too_many_arguments)] pub fn apply_tiled_with( surface: &Surface, @@ -1001,15 +1003,27 @@ fn apply_tiled_with_impl( tile: i32, extent: Option, ctl: &photocraft_raster::Interrupt, - prepare_radial: bool, + prepare_fast_paths: bool, ) -> Option { if ctl.cancelled() { return None; } + if let FilterParams::UnsharpMask { amount, radius, threshold } = *params + && (!sharpen::exact::valid_params(amount, radius, threshold) + || !sharpen::exact::valid_area(area) + || !sharpen::exact::valid_area(bounds) + || extent.is_some_and(|e| !sharpen::exact::valid_area(e))) + { + return None; + } let mut out = surface.clone(); if area.is_empty() { return Some(out); } + #[cfg(not(target_arch = "wasm32"))] + if prepare_fast_paths && let Some(result) = sharpen::fft::apply(surface, params, area, selection, extent, ctl) { + return result; + } if let Some(boxes) = blur::box_widths(params) { return apply_box_blur(out, surface, area, extent, selection, &boxes, ctl); } @@ -1017,15 +1031,15 @@ fn apply_tiled_with_impl( return result; } // The public tile override must not create a zero-step loop or an - // unbounded Shape Blur scratch buffer. Standard automatic tiles are unchanged. - let tile = if matches!(params, FilterParams::ShapeBlur { .. }) { tile.clamp(1, 2048) } else { tile }; + // unbounded scratch buffer for these checked filters. Automatic tiles are unchanged. + let tile = if matches!(params, FilterParams::ShapeBlur { .. } | FilterParams::UnsharpMask { .. }) { tile.clamp(1, 2048) } else { tile }; let fmt = surface.format(); let ctx = Ctx { bounds, mode: fmt.mode, alpha: fmt.alpha }; // Prepare the immutable sample transforms once for all output tiles. If the // optional table allocation fails, use the existing sampler; only a real // cancellation returns None. Source reads keep their upstream semantics. let radial = match params { - FilterParams::RadialBlur { amount, method, quality, center_x, center_y } if prepare_radial => { + FilterParams::RadialBlur { amount, method, quality, center_x, center_y } if prepare_fast_paths => { blur::radial::Plan::for_area(bounds, *amount, *method, *quality, (*center_x, *center_y), area, ctl) } _ => None, @@ -1034,6 +1048,17 @@ fn apply_tiled_with_impl( return None; } let halo = params.halo_for(bounds); + if matches!(params, FilterParams::UnsharpMask { .. }) { + let Halo::Radius(reach) = halo else { return None }; + let (w, h) = (area.width().min(tile as u32) as usize, area.height().min(tile as u32) as usize); + let read_samples = w.checked_add(2 * reach as usize)?.checked_mul(h.checked_add(2 * reach as usize)?)?.checked_mul(fmt.channels())?; + let tile_count = u64::from(area.width()).div_ceil(tile as u64).checked_mul(u64::from(area.height()).div_ceil(tile as u64))?; + // Reject hostile source-window sizes before Image::read allocates them. + // Ordinary filter parameters and automatic tiles fit these bounds. + if read_samples > 512 * 1024 * 1024 || read_samples.checked_mul(std::mem::size_of::())? > isize::MAX as usize || tile_count > 1_048_576 { + return None; + } + } let shared = (halo == Halo::Bounds).then(|| Image::read(surface, bounds.union(&area))); // Shape rasterization depends only on params, not on a tile's pixels. let shape = match params { @@ -1091,7 +1116,10 @@ fn apply_tiled_with_impl( None if ctl.cancelled() => None, None => Some(kernel(params, src, *t, &ctx)), }, - (None, None) => Some(kernel(params, src, *t, &ctx)), + (None, None) => match *params { + FilterParams::UnsharpMask { amount, radius, threshold } => sharpen::exact::run(src, *t, &ctx, amount, radius, threshold, ctl), + _ => Some(kernel(params, src, *t, &ctx)), + }, }) else { return (*t, Vec::new()); }; @@ -1124,9 +1152,11 @@ fn apply_tiled_with_impl( if ctl.cancelled() { return None; } - // Shape Blur can reject malformed geometry or an allocation failure. + // Checked filters can reject malformed geometry or an allocation failure. // Do not hand an incomplete tile to Surface::write_region. - if shape.is_some() && results.iter().any(|(t, data)| data.len() != t.width() as usize * t.height() as usize * fmt.channels()) { + if (shape.is_some() || matches!(params, FilterParams::UnsharpMask { .. })) + && results.iter().any(|(t, data)| data.len() != t.width() as usize * t.height() as usize * fmt.channels()) + { return None; } for (t, data) in results { diff --git a/crates/algo/src/sharpen.rs b/crates/algo/src/sharpen.rs index 175de2b37ad..9e87c6b8410 100644 --- a/crates/algo/src/sharpen.rs +++ b/crates/algo/src/sharpen.rs @@ -6,6 +6,17 @@ use crate::Ctx; use crate::blur::{conv_sep, gaussian_kernel}; use crate::image::Image; +pub(crate) mod exact; + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) mod fft; + +#[cfg(test)] +mod tests; + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod fft_bench; + fn with_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, mut f: impl FnMut(f32, f32) -> f32) -> Vec { let k = gaussian_kernel(radius); let blurred = conv_sep(src, out, &k, &k, ctx.alpha); @@ -24,12 +35,7 @@ fn with_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, mut f: impl FnMut(f } pub(crate) fn unsharp(src: &Image, out: Rect, ctx: &Ctx, amount: f32, radius: f32, threshold: f32) -> Vec { - let k = amount / 100.0; - let t = threshold / 255.0; - with_blur(src, out, ctx, radius, |o, b| { - let d = o - b; - if d.abs() + 1e-6 >= t { o + k * d } else { o } - }) + exact::run(src, out, ctx, amount, radius, threshold, &photocraft_raster::Interrupt::NONE).unwrap_or_default() } pub(crate) fn smart(src: &Image, out: Rect, ctx: &Ctx, amount: f32, radius: f32, reduce_noise: f32) -> Vec { diff --git a/crates/algo/src/sharpen/exact.rs b/crates/algo/src/sharpen/exact.rs new file mode 100644 index 00000000000..7d74bc538fc --- /dev/null +++ b/crates/algo/src/sharpen/exact.rs @@ -0,0 +1,134 @@ +//! Exact separable convolution for Unsharp Mask: stream one source row and reuse +//! the blurred output for the final arithmetic. Each sample retains its tap order. +use crate::image::premultiply; +use crate::{Ctx, Image}; +use photocraft_geom::Rect; +use photocraft_raster::Interrupt; + +const MAX_SAMPLES: usize = 128 * 1024 * 1024; +// The filter dialog caps radius at 1000. Preserve Details resampling can ask +// for more; permit that while the unchanged kernel's i32 square is safe. +const MAX_RADIUS: f32 = 15_446.0; + +pub(crate) fn valid_params(amount: f32, radius: f32, threshold: f32) -> bool { + amount.is_finite() && threshold.is_finite() && radius.is_finite() && (0.0..=MAX_RADIUS).contains(&radius) +} + +pub(crate) fn halo(radius: f32) -> i32 { + if radius.is_finite() && (0.0..=MAX_RADIUS).contains(&radius) { (radius * 3.0).ceil() as i32 + 1 } else { 0 } +} + +pub(crate) fn valid_area(r: Rect) -> bool { + r.x0 <= r.x1 + && r.y0 <= r.y1 + && r.width() <= 300_000 + && r.height() <= 300_000 + && [r.x0, r.x1, r.y0, r.y1].iter().all(|v| (-1_000_000..=1_000_000).contains(v)) +} + +fn zeros(len: usize) -> Option> { + if len > MAX_SAMPLES { + return None; + } + let mut data = Vec::new(); + data.try_reserve_exact(len).ok()?; + data.resize(len, 0.0); + Some(data) +} + +/// Checked contiguous row copy, with the same zero extension as Image::get. +fn read_row(src: &Image, x0: i32, x1: i32, y: i32, dst: &mut [f32]) -> Option<()> { + dst.fill(0.0); + if y < src.rect.y0 || y >= src.rect.y1 { + return Some(()); + } + let (lo, hi) = (x0.max(src.rect.x0), x1.min(src.rect.x1)); + if lo >= hi { + return Some(()); + } + let n = src.ch; + let count = usize::try_from(i64::from(hi) - i64::from(lo)).ok()?.checked_mul(n)?; + let offset = usize::try_from(i64::from(lo) - i64::from(x0)).ok()?.checked_mul(n)?; + let row = usize::try_from(i64::from(y) - i64::from(src.rect.y0)).ok()?; + let col = usize::try_from(i64::from(lo) - i64::from(src.rect.x0)).ok()?; + let start = row.checked_mul(src.rect.width() as usize)?.checked_add(col)?.checked_mul(n)?; + dst.get_mut(offset..offset.checked_add(count)?)?.copy_from_slice(src.data.get(start..start.checked_add(count)?)?); + Some(()) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn run(src: &Image, out: Rect, ctx: &Ctx, amount: f32, radius: f32, threshold: f32, ctl: &Interrupt) -> Option> { + if ctl.cancelled() || !valid_params(amount, radius, threshold) || !valid_area(out) || !valid_area(src.rect) { + return None; + } + let n = src.ch; + if n == 0 || n > crate::fxutil::MAXC { + return None; + } + if src.data.len() != (src.rect.width() as usize).checked_mul(src.rect.height() as usize)?.checked_mul(n)? { + return None; + } + if out.is_empty() { + return Some(Vec::new()); + } + let kernel = super::gaussian_kernel(radius); + let r = kernel.len() / 2; + let (ow, oh) = (out.width() as usize, out.height() as usize); + let row_len = ow.checked_mul(n)?; + let th = oh.checked_add(2 * r)?; + let ww = ow.checked_add(2 * r)?; + let (x0, x1, y0) = (out.x0.checked_sub(r as i32)?, out.x1.checked_add(r as i32)?, out.y0.checked_sub(r as i32)?); + let mut row = zeros(ww.checked_mul(n)?)?; + let mut tmp = zeros(row_len.checked_mul(th)?)?; + let mut result = zeros(row_len.checked_mul(oh)?)?; + for (y, dst) in tmp.chunks_exact_mut(row_len).enumerate() { + if ctl.cancelled() { + return None; + } + read_row(src, x0, x1, y0.checked_add(y as i32)?, &mut row)?; + premultiply(&mut row, n, ctx.alpha); + // Tap-major traversal exposes contiguous independent samples to LLVM. No + // reduction, tap merging, symmetry or fused multiply-add changes the sums. + for (i, weight) in kernel.iter().enumerate() { + let start = i.checked_mul(n)?; + let samples = row.get(start..start.checked_add(row_len)?)?; + for (d, s) in dst.iter_mut().zip(samples) { + *d += s * weight; + } + } + } + let (amount, threshold) = (amount / 100.0, threshold / 255.0); + let cc = n - usize::from(ctx.alpha); + for (y, dst) in result.chunks_exact_mut(row_len).enumerate() { + if ctl.cancelled() { + return None; + } + for (i, weight) in kernel.iter().enumerate() { + let start = y.checked_add(i)?.checked_mul(row_len)?; + let samples = tmp.get(start..start.checked_add(row_len)?)?; + for (d, s) in dst.iter_mut().zip(samples) { + *d += s * weight; + } + } + let originals = row.get_mut(..row_len)?; + read_row(src, out.x0, out.x1, out.y0.checked_add(y as i32)?, originals)?; + for (blurred, original) in dst.chunks_exact_mut(n).zip(originals.chunks_exact(n)) { + if ctx.alpha && *original.last()? <= 0.0 { + blurred.copy_from_slice(original); + continue; + } + let a = if ctx.alpha { *blurred.last()? } else { 1.0 }; + for (b, o) in blurred.iter_mut().zip(original).take(cc) { + // Keep the original unpremultiply cut-off and division, followed by + // the threshold epsilon and inclusive comparison, at every depth. + let b_value = if ctx.alpha { if a > 1e-7 { *b / a } else { 0.0 } } else { *b }; + let d = o - b_value; + *b = if d.abs() + 1e-6 >= threshold { o + amount * d } else { *o }; + } + if ctx.alpha { + *blurred.last_mut()? = *original.last()?; + } + } + } + Some(result) +} diff --git a/crates/algo/src/sharpen/fft.rs b/crates/algo/src/sharpen/fft.rs new file mode 100644 index 00000000000..6fdf18c8b0d --- /dev/null +++ b/crates/algo/src/sharpen/fft.rs @@ -0,0 +1,325 @@ +//! Wide exact-kernel Gaussian convolution using FFTs. Summation order changes: +//! positive thresholds and unstable samples retain the sequential f32 fallback. +use crate::{FilterParams, Image}; +use photocraft_geom::Rect; +use photocraft_raster::{Interrupt, Surface}; +use rayon::prelude::*; +use rustfft::{Fft, FftPlanner, num_complex::Complex}; +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + +const MAX_BYTES: usize = 1 << 30; +const MAX_SIDE: usize = 32_768; +const STRIPE: usize = 32; +type Number = Complex; + +fn buffer(len: usize, value: T) -> Option> { + if len.checked_mul(std::mem::size_of::())? > MAX_BYTES { + return None; + } + let mut data = Vec::new(); + data.try_reserve_exact(len).ok()?; + data.resize(len, value); + Some(data) +} + +struct Plan { + size: usize, + forward: Arc>, + inverse: Arc>, + spectrum: Vec, + scratch: usize, +} +impl Plan { + fn new(needed: usize, kernel: &[f32], planner: &mut FftPlanner) -> Option { + let size = needed.checked_next_power_of_two()?; + if size > MAX_SIDE || kernel.len() > size { + return None; + } + let forward = planner.plan_fft_forward(size); + let inverse = planner.plan_fft_inverse(size); + let scratch = forward.get_inplace_scratch_len().max(inverse.get_inplace_scratch_len()); + let mut spectrum = buffer(size, Number::default())?; + for (dst, &weight) in spectrum.iter_mut().zip(kernel) { + dst.re = f64::from(weight) / size as f64; + } + forward.process_with_scratch(&mut spectrum, &mut buffer(scratch, Number::default())?); + Some(Self { size, forward, inverse, spectrum, scratch }) + } + fn work(&self) -> Option<(Vec, Vec)> { + Some((buffer(self.size, Number::default())?, buffer(self.scratch, Number::default())?)) + } + fn convolve(&self, data: &mut [Number], scratch: &mut [Number]) -> Option<()> { + if data.len() != self.size || scratch.len() < self.scratch { + return None; + } + self.forward.process_with_scratch(data, scratch); + for (d, k) in data.iter_mut().zip(&self.spectrum) { + *d *= k; + } + self.inverse.process_with_scratch(data, scratch); + Some(()) + } +} + +// Bound the complete temporary working set, including all concurrently live +// column gathers, originals, FFT scratch and result stripes. Document tiles are +// excluded, just as in the existing tile scheduler's scratch budget. +fn group_size(resident: usize, worker: usize, count: usize, workers: usize) -> Option { + let group = MAX_BYTES.checked_sub(resident)?.checked_div(worker.max(1))?.min(workers).min(count); + (group > 0).then_some(group) +} + +/// `None` declines the optimization, `Some(None)` propagates cancellation. +#[allow(clippy::too_many_arguments)] +pub(crate) fn apply( + surface: &Surface, + params: &FilterParams, + area: Rect, + selection: Option<&Surface>, + extent: Option, + ctl: &Interrupt, +) -> Option> { + let FilterParams::UnsharpMask { amount, radius, threshold } = *params else { return None }; + // At a positive threshold, even a tiny sum-order difference can switch the + // entire sharpen contribution. Zero/negative thresholds always keep it. + if !(64.0..=1000.0).contains(&radius) + || threshold > 0.0 + || amount == 0.0 + || !super::exact::valid_params(amount, radius, threshold) + || !super::exact::valid_area(area) + || area.is_empty() + || u64::from(area.width()) * u64::from(area.height()) < 65_536 + || !(1..=crate::fxutil::MAXC).contains(&surface.channels()) + || selection.is_some_and(|s| s.channels() == 0) + || extent.is_some_and(|e| !super::exact::valid_area(e) || area.intersect(&e) != area) + { + return None; + } + if ctl.cancelled() { + return Some(None); + } + match run(surface, area, amount, radius, selection, extent, ctl) { + Some(out) => Some(Some(out)), + None if ctl.cancelled() => Some(None), + None => None, + } +} + +#[allow(clippy::too_many_arguments)] +fn run(surface: &Surface, area: Rect, amount: f32, radius: f32, selection: Option<&Surface>, extent: Option, ctl: &Interrupt) -> Option { + let (w, h, n) = (area.width() as usize, area.height() as usize, surface.channels()); + let alpha = surface.format().alpha; + let kernel = super::gaussian_kernel(radius); + let r = kernel.len() / 2; + let reach = i32::try_from(r).ok()?; + let (x0, x1) = (area.x0.checked_sub(reach)?, area.x1.checked_add(reach)?); + let (y0, y1) = (area.y0.checked_sub(reach)?, area.y1.checked_add(reach)?); + let (read_x0, read_x1, read_y0, read_y1) = match extent { + Some(e) => (x0.max(e.x0), x1.min(e.x1), y0.max(e.y0), y1.min(e.y1)), + None => (x0, x1, y0, y1), + }; + let rows = usize::try_from(i64::from(read_y1) - i64::from(read_y0)).ok()?; + let read_width = usize::try_from(i64::from(read_x1) - i64::from(read_x0)).ok()?; + let row_len = w.checked_mul(n)?; + let samples = row_len.checked_mul(rows)?; + let resident = samples.checked_mul(4)?; + let mut planner = FftPlanner::::new(); + let horizontal = Plan::new(w.checked_add(2 * r)?, &kernel, &mut planner)?; + let vertical = Plan::new(h.checked_add(2 * r)?, &kernel, &mut planner)?; + // A conservative allowance includes FFT plans/twiddles in addition to spectra. + let plans = horizontal.size.checked_add(vertical.size)?.checked_mul(256)?; + let resident = resident.checked_add(plans)?; + let workers = rayon::current_num_threads().max(1); + let horizontal_worker = read_width.checked_mul(n)?.checked_mul(4)?.checked_add(horizontal.size.checked_add(horizontal.scratch)?.checked_mul(16)?)?; + let vertical_worker = STRIPE + .checked_mul(rows)? + .checked_mul(n)? + .checked_mul(4)? + .checked_add(STRIPE.checked_mul(h)?.checked_mul(n)?.checked_mul(12)?)? + .checked_add(vertical.size.checked_add(vertical.scratch)?.checked_mul(16)?)?; + let group = group_size(resident, horizontal_worker.max(vertical_worker), w.div_ceil(STRIPE), workers)?; + let mut tmp = buffer(samples, 0.0f32)?; + let finished = AtomicUsize::new(0); + // Read each needed real row once. Outside a repeated extent, horizontal + // results are repeated lazily by the vertical gather, not transformed again. + for band in tmp.chunks_mut(row_len.checked_mul(group)?) { + let first = finished.load(Ordering::Relaxed); + let ok: Option> = band + .par_chunks_exact_mut(row_len) + .enumerate() + .map(|(offset, dst)| { + if ctl.cancelled() { + return None; + } + let y = read_y0.checked_add(i32::try_from(first.checked_add(offset)?).ok()?)?; + let mut row = buffer(read_width.checked_mul(n)?, 0.0f32)?; + // Reservation above covers resize, so the reusable raster read cannot allocate. + surface.read_region_into(Rect::new(read_x0, y, read_x1, y.checked_add(1)?), &mut row); + let mut scales = [0.0f64; crate::fxutil::MAXC]; + for px in row.chunks_exact_mut(n) { + if px.iter().any(|v| !v.is_finite() || v.abs() > 1e6) || (alpha && !px.last().is_some_and(|a| (0.0..=1.0).contains(a))) { + return None; + } + let a = if alpha { *px.last()? } else { 1.0 }; + for (c, v) in px.iter_mut().enumerate() { + if alpha && c + 1 < n { + *v *= a; + } + let scale = scales.get_mut(c)?; + *scale = scale.max(f64::from(v.abs())); + } + } + let (mut data, mut scratch) = horizontal.work()?; + for channel in (0..n).step_by(2) { + if ctl.cancelled() { + return None; + } + data.fill(Number::default()); + for (i, d) in data.iter_mut().take(w + 2 * r).enumerate() { + let sx = x0.checked_add(i32::try_from(i).ok()?)?.clamp(read_x0, read_x1.checked_sub(1)?); + let start = usize::try_from(sx - read_x0).ok()?.checked_mul(n)?; + let px = row.get(start..start.checked_add(n)?)?; + let sample = |c: usize| -> f64 { + match (px.get(c), scales.get(c)) { + (Some(v), Some(s)) if *s > 0.0 => f64::from(*v) / s, + _ => 0.0, + } + }; + *d = Number::new(sample(channel), sample(channel + 1)); + } + horizontal.convolve(&mut data, &mut scratch)?; + for (px, d) in dst.chunks_exact_mut(n).zip(data.get(2 * r..2 * r + w)?) { + *px.get_mut(channel)? = (d.re * scales.get(channel)?) as f32; + if let Some(v) = px.get_mut(channel + 1) { + *v = (d.im * scales.get(channel + 1)?) as f32; + } + } + } + finished.fetch_add(1, Ordering::Relaxed); + Some(()) + }) + .collect(); + ok?; + ctl.progress(0.5 * finished.load(Ordering::Relaxed) as f32 / rows as f32); + if ctl.cancelled() { + return None; + } + } + let mut out = surface.clone(); + let count = w.div_ceil(STRIPE); + for first in (0..count).step_by(group) { + let end = first.checked_add(group)?.min(count); + let results: Option> = (first..end) + .into_par_iter() + .map(|index| { + if ctl.cancelled() { + return None; + } + let x = index.checked_mul(STRIPE)?; + let width = STRIPE.min(w.checked_sub(x)?); + let mut columns = buffer(width.checked_mul(rows)?.checked_mul(n)?, 0.0f32)?; + for (y, row) in tmp.chunks_exact(row_len).enumerate() { + if y % 64 == 0 && ctl.cancelled() { + return None; + } + for (c, px) in row.get(x * n..(x + width) * n)?.chunks_exact(n).enumerate() { + let start = (c * rows + y) * n; + columns.get_mut(start..start + n)?.copy_from_slice(px); + } + } + let rect = Rect::new(area.x0.checked_add(x as i32)?, area.y0, area.x0.checked_add((x + width) as i32)?, area.y1); + let len = width.checked_mul(h)?.checked_mul(n)?; + let mut result = buffer(len, 0.0f32)?; + let mut original = buffer(len, 0.0f32)?; + surface.read_region_into(rect, &mut original); + let (mut data, mut scratch) = vertical.work()?; + for (c, samples) in columns.chunks_exact(rows * n).enumerate() { + if ctl.cancelled() { + return None; + } + let mut scales = [0.0f64; crate::fxutil::MAXC]; + for px in samples.chunks_exact(n) { + for (s, v) in scales.iter_mut().zip(px) { + *s = s.max(f64::from(v.abs())); + } + } + for channel in (0..n).step_by(2) { + if ctl.cancelled() { + return None; + } + data.fill(Number::default()); + for (i, d) in data.iter_mut().take(h + 2 * r).enumerate() { + let sy = y0.checked_add(i as i32)?.clamp(read_y0, read_y1.checked_sub(1)?); + let start = usize::try_from(sy - read_y0).ok()?.checked_mul(n)?; + let px = samples.get(start..start + n)?; + let sample = |c: usize| -> f64 { + match (px.get(c), scales.get(c)) { + (Some(v), Some(s)) if *s > 0.0 => f64::from(*v) / s, + _ => 0.0, + } + }; + *d = Number::new(sample(channel), sample(channel + 1)); + } + vertical.convolve(&mut data, &mut scratch)?; + for (y, d) in data.get(2 * r..2 * r + h)?.iter().enumerate() { + let start = (y * width + c) * n; + *result.get_mut(start + channel)? = (d.re * scales.get(channel)?) as f32; + if let Some(v) = result.get_mut(start + channel + 1).filter(|_| channel + 1 < n) { + *v = (d.im * scales.get(channel + 1)?) as f32; + } + } + } + } + for (px, o) in result.chunks_exact_mut(n).zip(original.chunks_exact(n)) { + if alpha && *o.last()? <= 0.0 { + px.copy_from_slice(o); + continue; + } + let a = if alpha { *px.last()? } else { 1.0 }; + // Near the original unpremultiply cutoff, cancellation/FFT noise + // or f32 sum order can expose colour. Decline the whole fast path. + let uncertainty = 4.0 * kernel.len() as f32 * f32::EPSILON; + if alpha && a <= 1e-7 + uncertainty { + return None; + } + for (v, o) in px.iter_mut().zip(o).take(n - usize::from(alpha)) { + let b = if alpha { *v / a } else { *v }; + *v = o + amount / 100.0 * (o - b); + if !v.is_finite() { + return None; + } + } + if alpha { + *px.last_mut()? = *o.last()?; + } + } + if let Some(sel) = selection { + crate::mix_selection(&mut result, rect, sel, &Image { rect, ch: n, data: original }, alpha); + } + Some((rect, result)) + }) + .collect(); + for (rect, data) in results? { + if ctl.cancelled() { + return None; + } + out.try_write_region(rect, &data).ok()?; + } + ctl.progress(0.5 + 0.48 * end as f32 / count as f32); + } + if ctl.cancelled() { + return None; + } + out.prune(); + if ctl.cancelled() { + return None; + } + ctl.progress(1.0); + Some(out) +} + +#[cfg(test)] +mod tests; diff --git a/crates/algo/src/sharpen/fft/tests.rs b/crates/algo/src/sharpen/fft/tests.rs new file mode 100644 index 00000000000..e1f7a26ed80 --- /dev/null +++ b/crates/algo/src/sharpen/fft/tests.rs @@ -0,0 +1,146 @@ +use super::*; +use crate::sharpen::tests::{reference, source}; +use crate::{Ctx, image::Image}; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; + +fn compare(old: &Surface, new: &Surface, area: Rect) { + let tolerance = match old.format().sample { + SampleType::U8 => 1.0 / 255.0 + 1e-7, + SampleType::U16 => 2.0 / 65535.0 + 1e-7, + SampleType::F32 => 3e-5, + }; + for (a, b) in old.read_region(area).iter().zip(new.read_region(area)) { + let limit = if old.format().sample == SampleType::F32 { tolerance * (1.0 + a.abs()) } else { tolerance }; + assert!((a - b).abs() <= limit, "{a} != {b}, tolerance {limit}"); + } +} + +#[test] +fn fft_preserves_gaussian_quality_at_every_depth_and_model() { + let area = Rect::new(-3, 2, 10, 13); + for mode in [ + ColorMode::Bitmap, + ColorMode::Grayscale, + ColorMode::Duotone, + ColorMode::Indexed, + ColorMode::Rgb, + ColorMode::Cmyk, + ColorMode::Lab, + ColorMode::Multichannel, + ] { + for depth in SampleType::ALL { + for alpha in [false, true] { + let s = source(area, PixelFormat::new(mode, depth, alpha)); + let ctx = Ctx { bounds: area, mode, alpha }; + for radius in [3.25, 64.0] { + let src = Image::read_clamped(&s, area.inflate((radius * 3.0_f32).ceil() as i32 + 1), area); + let pixels = reference::unsharp(&src, area, &ctx, 500.0, radius, 0.0); + let mut expected = s.clone(); + expected.write_region(area, &pixels); + let actual = run(&s, area, 500.0, radius, None, Some(area), &Interrupt::NONE).unwrap(); + compare(&expected, &actual, area); + if alpha { + for (a, b) in s.read_region(area).chunks_exact(s.channels()).zip(actual.read_region(area).chunks_exact(s.channels())) { + assert_eq!(a.last(), b.last()); + if a.last() == Some(&0.0) { + assert_eq!(a, b); + } + } + } + } + } + } + } +} + +#[test] +fn fft_keeps_partial_regions_selections_and_edge_policies() { + let extent = Rect::new(-7, -9, 35, 39); + let area = Rect::new(-3, 2, 19, 23); + let mut sel = Surface::new(PixelFormat::GRAY8); + sel.fill_rect(area, &[0.4]); + for depth in SampleType::ALL { + let s = source(extent, PixelFormat::new(ColorMode::Rgb, depth, true)); + let p = FilterParams::UnsharpMask { amount: 150.0, radius: 64.0, threshold: 0.0 }; + for edge in [None, Some(extent)] { + let expected = crate::sharpen::tests::apply_reference(&s, &p, area, extent, Some(&sel), edge, 256); + let actual = run(&s, area, 150.0, 64.0, Some(&sel), edge, &Interrupt::NONE).unwrap(); + compare(&expected, &actual, extent); + } + } +} + +#[test] +fn fft_falls_back_for_thresholds_and_unstable_samples() { + let area = Rect::new(0, 0, 256, 256); + let mut s = source(area, PixelFormat::RGBA32F); + let p = FilterParams::UnsharpMask { amount: 150.0, radius: 64.0, threshold: 8.0 }; + assert!(apply(&s, &p, area, None, Some(area), &Interrupt::NONE).is_none()); + let p = FilterParams::UnsharpMask { amount: 150.0, radius: 64.0, threshold: 0.0 }; + s.fill_rect(Rect::new(0, 0, 1, 1), &[f32::NAN, 0.5, 0.5, 1.0]); + assert!(apply(&s, &p, area, None, Some(area), &Interrupt::NONE).is_none()); + s.fill_rect(area, &[0.5, 0.5, 0.5, 1e-8]); + assert!(apply(&s, &p, area, None, Some(area), &Interrupt::NONE).is_none()); + s.fill_rect(area, &[0.5, 0.5, 0.5, -0.1]); + assert!(apply(&s, &p, area, None, Some(area), &Interrupt::NONE).is_none()); +} + +#[test] +fn fft_cancellation_discards_partial_work_and_bounds_scratch() { + let area = Rect::new(0, 0, 256, 256); + let s = source(area, PixelFormat::RGBA8); + let original = s.to_interleaved(area); + let p = FilterParams::UnsharpMask { amount: 150.0, radius: 300.0, threshold: 0.0 }; + let flag = std::sync::atomic::AtomicBool::new(false); + let cancel = || flag.load(Ordering::Relaxed); + let progress = |_| flag.store(true, Ordering::Relaxed); + assert!(apply(&s, &p, area, None, Some(area), &Interrupt::new(&cancel, &progress)).unwrap().is_none()); + assert_eq!(s.to_interleaved(area), original); + flag.store(false, Ordering::Relaxed); + let after_writes = |value| { + if value > 0.55 { + flag.store(true, Ordering::Relaxed); + } + }; + assert!(apply(&s, &p, area, None, Some(area), &Interrupt::new(&cancel, &after_writes)).unwrap().is_none()); + assert_eq!(s.to_interleaved(area), original); + assert_eq!(group_size(MAX_BYTES, 1, 1, 1), None); + assert_eq!(group_size(0, usize::MAX, 1, 1), None); + assert!(Plan::new(usize::MAX, &[1.0], &mut FftPlanner::new()).is_none()); + assert!(buffer::(usize::MAX, 0.0).is_none()); +} + +#[test] +fn positive_threshold_dispatch_retains_exact_pixel_decisions() { + let area = Rect::new(-3, 2, 253, 258); + for depth in SampleType::ALL { + let s = source(area, PixelFormat::new(ColorMode::Rgb, depth, true)); + let p = FilterParams::UnsharpMask { amount: 500.0, radius: 64.0, threshold: 8.0 }; + let expected = crate::sharpen::tests::apply_reference(&s, &p, area, area, None, Some(area), 256); + let actual = crate::apply_in(&s, &p, area, area, None, area); + assert_eq!(expected.to_interleaved(area), actual.to_interleaved(area)); + } +} + +#[test] +fn fft_handles_maximum_radius_and_hdr_alpha_channel_pairs() { + let area = Rect::new(0, 0, 1, 1); + let mut s = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false)); + s.fill_rect(area, &[0.5]); + let ctx = Ctx { bounds: area, mode: ColorMode::Grayscale, alpha: false }; + let src = Image::read_clamped(&s, area.inflate(3001), area); + let pixels = reference::unsharp(&src, area, &ctx, 500.0, 1000.0, 0.0); + let mut expected = s.clone(); + expected.write_region(area, &pixels); + compare(&expected, &run(&s, area, 500.0, 1000.0, None, Some(area), &Interrupt::NONE).unwrap(), area); + + let area = Rect::new(-2, 3, 11, 14); + let mut s = Surface::new(PixelFormat::RGBA32F); + s.fill_rect(area, &[1e6, -1e5, 1e-4, 0.02]); + let ctx = Ctx { bounds: area, mode: ColorMode::Rgb, alpha: true }; + let src = Image::read_clamped(&s, area.inflate(193), area); + let pixels = reference::unsharp(&src, area, &ctx, 500.0, 64.0, 0.0); + let mut expected = s.clone(); + expected.write_region(area, &pixels); + compare(&expected, &run(&s, area, 500.0, 64.0, None, Some(area), &Interrupt::NONE).unwrap(), area); +} diff --git a/crates/algo/src/sharpen/fft_bench.rs b/crates/algo/src/sharpen/fft_bench.rs new file mode 100644 index 00000000000..3081ff7cd30 --- /dev/null +++ b/crates/algo/src/sharpen/fft_bench.rs @@ -0,0 +1,112 @@ +//! Opt-in benchmark of the adaptive production path against the frozen original. +use super::tests::{apply_reference, source}; +use crate::FilterParams; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_geom::Rect; +use photocraft_raster::Surface; +use std::time::Instant; +#[test] +#[ignore = "adaptive large-radius full-operation quality and performance; run alone"] +fn unsharp_fft_performance() { + let number = |name: &str, default| std::env::var(name).ok().and_then(|v| v.parse::().ok()).unwrap_or(default); + let (w, h) = (number("PHOTOCRAFT_UNSHARP_WIDTH", 1920), number("PHOTOCRAFT_UNSHARP_HEIGHT", 1080)); + let reps = number("PHOTOCRAFT_UNSHARP_REPS", 3); + assert!((1..=8192).contains(&w) && (1..=8192).contains(&h) && (1..=5).contains(&reps)); + let depth = match number("PHOTOCRAFT_UNSHARP_DEPTH", 8) { + 8 => SampleType::U8, + 16 => SampleType::U16, + 32 => SampleType::F32, + _ => panic!("depth"), + }; + let area = Rect::new(0, 0, w, h); + let surface = source(area, PixelFormat::new(ColorMode::Rgb, depth, true)); + let cases = std::env::var("PHOTOCRAFT_UNSHARP_CASES").unwrap_or_else(|_| "100:150:0,300:150:0,500:150:8".into()); + for case in cases.split(',') { + let v: Vec = case.split(':').map(|v| v.parse().unwrap()).collect(); + let (radius, amount, threshold) = (v[0], v[1], v[2]); + let params = FilterParams::UnsharpMask { amount, radius, threshold }; + let baseline = || apply_reference(&surface, ¶ms, area, area, None, Some(area), crate::auto_tile(¶ms, area)); + let candidate = || crate::apply_in(&surface, ¶ms, area, area, None, area); + for _ in 0..2 { + std::hint::black_box(baseline()); + std::hint::black_box(candidate()); + } + let mut before = Vec::new(); + let mut after = Vec::new(); + let mut max_error = 0.0f32; + let mut changed = 0usize; + let mut sum_error = 0.0f64; + for rep in 0..reps { + let timed = |f: &dyn Fn() -> Surface| { + let t = Instant::now(); + let output = f(); + (output, t.elapsed().as_secs_f64() * 1000.0) + }; + let ((old, a), (new, b)) = if rep % 2 == 0 { + (timed(&baseline), timed(&candidate)) + } else { + let b = timed(&candidate); + (timed(&baseline), b) + }; + before.push(a); + after.push(b); + for (a, b) in old.read_region(area).iter().zip(new.read_region(area)) { + let e = (a - b).abs(); + let limit = match depth { + SampleType::U8 => 1.0 / 255.0 + 1e-7, + SampleType::U16 => 3.0 / 65535.0 + 1e-7, + SampleType::F32 => 3e-5 * (1.0 + a.abs()), + }; + assert!(e <= limit, "quality regression: {a} != {b} at radius {radius}, threshold {threshold}"); + max_error = max_error.max(e); + if rep == 0 { + sum_error += f64::from(e); + changed += usize::from(a.to_bits() != b.to_bits()); + } + } + } + let median = |v: &[f64]| { + let mut v = v.to_vec(); + v.sort_by(f64::total_cmp); + v[v.len() / 2] + }; + println!( + "{}", + serde_json::json!({"experiment":"adaptive FFT same Gaussian, changed summation","width":w,"height":h,"depth":number("PHOTOCRAFT_UNSHARP_DEPTH",8),"radius":radius,"amount":amount,"threshold":threshold,"before_ms":before,"after_ms":after,"before_median_ms":median(&before),"after_median_ms":median(&after),"speedup":median(&before)/median(&after),"max_error":max_error,"mean_error":sum_error/(w as f64*h as f64*4.0),"changed_samples":changed}) + ); + } +} + +#[test] +#[ignore = "4K wide-radius cancellation latency; run alone in release"] +fn unsharp_fft_cancel_latency() { + use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + let area = Rect::new(0, 0, 3840, 2160); + let surface = source(area, PixelFormat::RGBA8); + let input = surface.to_interleaved(area); + let p = FilterParams::UnsharpMask { amount: 150.0, radius: 1000.0, threshold: 0.0 }; + let mut latencies = Vec::new(); + for _ in 0..5 { + let flag = AtomicBool::new(false); + let cancel_at = AtomicU64::new(0); + let started = Instant::now(); + std::thread::scope(|scope| { + let timer = scope.spawn(|| { + std::thread::sleep(std::time::Duration::from_millis(10)); + cancel_at.store(started.elapsed().as_micros() as u64, Ordering::Relaxed); + flag.store(true, Ordering::Release); + }); + let cancel = || flag.load(Ordering::Acquire); + let result = crate::apply_in_with(&surface, &p, area, area, None, area, &photocraft_raster::Interrupt::cancel_only(&cancel)); + let elapsed = started.elapsed().as_micros() as u64; + timer.join().unwrap(); + assert!(result.is_none()); + latencies.push(elapsed.saturating_sub(cancel_at.load(Ordering::Relaxed)) as f64 / 1000.0); + }); + assert_eq!(surface.to_interleaved(area), input); + } + println!( + "{}", + serde_json::json!({"width":3840,"height":2160,"radius":1000,"cancel_after_ms":10,"cancel_return_latencies_ms":latencies,"scope":"native full-resolution algo dispatch, excludes GUI scheduling/proxy creation"}) + ); +} diff --git a/crates/algo/src/sharpen/reference.rs b/crates/algo/src/sharpen/reference.rs new file mode 100644 index 00000000000..a99665c187f --- /dev/null +++ b/crates/algo/src/sharpen/reference.rs @@ -0,0 +1,113 @@ +//! Frozen exact Unsharp Mask from a28d7866, independent of production helpers. +use crate::Ctx; +use crate::image::{Image, premultiply, unpremultiply}; +use photocraft_geom::Rect; +pub(crate) fn gaussian_kernel(sigma: f32) -> Vec { + if sigma < 0.05 { + return vec![1.0]; + } + let r = (sigma * 3.0).ceil() as i32; + let k: Vec = (-r..=r).map(|i| (-(i * i) as f32 / (2.0 * sigma * sigma)).exp()).collect(); + let s: f32 = k.iter().sum(); + k.into_iter().map(|v| v / s).collect() +} + +/// Separable convolution of `src` (premultiplied internally) for `out`. +/// `src` must cover `out` grown by the kernel radii. +pub(crate) fn conv_sep(src: &Image, out: Rect, kx: &[f32], ky: &[f32], alpha: bool) -> Vec { + let n = src.ch; + let (rx, ry) = ((kx.len() / 2) as i32, (ky.len() / 2) as i32); + let (ow, oh) = (out.width() as usize, out.height() as usize); + let th = oh + 2 * ry as usize; + // Premultiplied copy of the needed source window. + let win = Rect::new(out.x0 - rx, out.y0 - ry, out.x1 + rx, out.y1 + ry); + let ww = win.width() as usize; + let mut p = vec![0.0f32; ww * win.height() as usize * n]; + for y in win.y0..win.y1 { + for x in win.x0..win.x1 { + let o = ((y - win.y0) as usize * ww + (x - win.x0) as usize) * n; + for c in 0..n { + p[o + c] = src.get(x, y, c); + } + } + } + premultiply(&mut p, n, alpha); + // Horizontal pass: (ow × th). + let mut tmp = vec![0.0f32; ow * th * n]; + for ty in 0..th { + let row = &p[ty * ww * n..(ty + 1) * ww * n]; + let dst = &mut tmp[ty * ow * n..(ty + 1) * ow * n]; + match n { + 1 => conv_row::<1>(row, dst, kx), + 2 => conv_row::<2>(row, dst, kx), + 3 => conv_row::<3>(row, dst, kx), + 4 => conv_row::<4>(row, dst, kx), + 5 => conv_row::<5>(row, dst, kx), + _ => { + for ox in 0..ow { + let d = &mut dst[ox * n..(ox + 1) * n]; + for (i, kv) in kx.iter().enumerate() { + let s = &row[(ox + i) * n..(ox + i + 1) * n]; + for c in 0..n { + d[c] += s[c] * kv; + } + } + } + } + } + } + // Vertical pass. + let mut res = vec![0.0f32; ow * oh * n]; + for oy in 0..oh { + let d = &mut res[oy * ow * n..(oy + 1) * ow * n]; + for (i, kv) in ky.iter().enumerate() { + let s = &tmp[(oy + i) * ow * n..(oy + i + 1) * ow * n]; + for (dv, sv) in d.iter_mut().zip(s) { + *dv += sv * kv; + } + } + } + unpremultiply(&mut res, n, alpha); + res +} + +// Fixed channel counts let LLVM keep one pixel's accumulators in registers and vectorize +// channels. The tap order is unchanged; unusual multichannel images keep the generic path. +fn conv_row(src: &[f32], dst: &mut [f32], kernel: &[f32]) { + let pixels = src.as_chunks::().0; + for (x, out) in dst.as_chunks_mut::().0.iter_mut().enumerate() { + let mut acc = [0.0f32; N]; + for (pixel, weight) in pixels.get(x..).unwrap_or(&[]).iter().zip(kernel) { + for c in 0..N { + acc[c] += pixel[c] * weight; + } + } + *out = acc; + } +} + +fn with_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, mut f: impl FnMut(f32, f32) -> f32) -> Vec { + let k = gaussian_kernel(radius); + let blurred = conv_sep(src, out, &k, &k, ctx.alpha); + let mut res = src.crop(out); + let n = src.ch; + let cc = if ctx.alpha { n - 1 } else { n }; + for (px, bl) in res.chunks_exact_mut(n).zip(blurred.chunks_exact(n)) { + if ctx.alpha && px[n - 1] <= 0.0 { + continue; + } + for c in 0..cc { + px[c] = f(px[c], bl[c]); + } + } + res +} + +pub(crate) fn unsharp(src: &Image, out: Rect, ctx: &Ctx, amount: f32, radius: f32, threshold: f32) -> Vec { + let k = amount / 100.0; + let t = threshold / 255.0; + with_blur(src, out, ctx, radius, |o, b| { + let d = o - b; + if d.abs() + 1e-6 >= t { o + k * d } else { o } + }) +} diff --git a/crates/algo/src/sharpen/tests.rs b/crates/algo/src/sharpen/tests.rs new file mode 100644 index 00000000000..4699bb3a101 --- /dev/null +++ b/crates/algo/src/sharpen/tests.rs @@ -0,0 +1,434 @@ +use super::*; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_raster::{Interrupt, Surface}; +use std::time::Instant; + +#[path = "reference.rs"] +pub(super) mod reference; + +pub(super) fn source(rect: Rect, fmt: PixelFormat) -> Surface { + let mut s = Surface::new(fmt); + for y in rect.y0..rect.y1 { + let data: Vec = (rect.x0..rect.x1) + .flat_map(|x| { + (0..fmt.channels()).map(move |c| { + if fmt.alpha && c + 1 == fmt.channels() { + if (x + y).rem_euclid(17) == 0 { 0.0 } else { ((x ^ y).rem_euclid(255) + 1) as f32 / 255.0 } + } else { + let value = ((x * (7 + c as i32) + y * 13).rem_euclid(257)) as f32 / 256.0; + if fmt.sample == SampleType::F32 { 3.0 * value - 0.5 } else { value } + } + }) + }) + .collect(); + s.write_region(Rect::new(rect.x0, y, rect.x1, y + 1), &data); + } + s +} + +// Preserve the surrounding tiled workload as well as the old convolution: halo reads, +// automatic tiles, grouping, selection mixing, writes and pruning are timed in both paths. +#[allow(clippy::too_many_arguments)] +pub(super) fn apply_reference( + s: &Surface, + p: &crate::FilterParams, + area: Rect, + bounds: Rect, + sel: Option<&Surface>, + extent: Option, + tile: i32, +) -> Surface { + let crate::FilterParams::UnsharpMask { amount, radius, threshold } = *p else { panic!("unsharp params") }; + let crate::Halo::Radius(halo) = p.halo_for(bounds) else { panic!("radius halo") }; + let fmt = s.format(); + let ctx = Ctx { bounds, mode: fmt.mode, alpha: fmt.alpha }; + let tiles: Vec<_> = (area.y0..area.y1) + .step_by(tile as usize) + .flat_map(|y| (area.x0..area.x1).step_by(tile as usize).map(move |x| Rect::new(x, y, (x + tile).min(area.x1), (y + tile).min(area.y1)))) + .collect(); + let group = (crate::RESULT_BUDGET / ((tile as usize).pow(2) * fmt.channels() * 4)).max(workers()).max(1); + let finished = std::sync::atomic::AtomicUsize::new(0); + let ctl = Interrupt::NONE; + let run = |t: &Rect| { + let src = match extent { + Some(e) => Image::read_clamped(s, t.inflate(halo), e), + None => Image::read(s, t.inflate(halo)), + }; + let mut data = reference::unsharp(&src, *t, &ctx, amount, radius, threshold); + if let Some(sel) = sel { + crate::mix_selection(&mut data, *t, sel, &src, fmt.alpha); + } + let done = finished.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + ctl.progress(0.98 * done as f32 / tiles.len().max(1) as f32); + (*t, data) + }; + let mut out = s.clone(); + for chunk in tiles.chunks(group) { + #[cfg(not(target_arch = "wasm32"))] + let results: Vec<_> = { + use rayon::prelude::*; + chunk.par_iter().map(run).collect() + }; + #[cfg(target_arch = "wasm32")] + let results: Vec<_> = chunk.iter().map(run).collect(); + for (t, data) in results { + out.write_region(t, &data); + } + } + out.prune(); + out +} + +fn workers() -> usize { + #[cfg(not(target_arch = "wasm32"))] + { + rayon::current_num_threads() + } + #[cfg(target_arch = "wasm32")] + { + 1 + } +} + +#[test] +fn frozen_reference_matches_current_kernel() { + let out = Rect::new(-3, 2, 16, 15); + let src = Image { rect: out.inflate(13), ch: 4, data: (0..(out.width() + 26) * (out.height() + 26) * 4).map(|i| (i % 127) as f32 / 126.0).collect() }; + let ctx = Ctx { bounds: out, mode: ColorMode::Rgb, alpha: true }; + for radius in [0.1, 0.6, 1.5, 4.0] { + assert_eq!(unsharp(&src, out, &ctx, 150.0, radius, 8.0), reference::unsharp(&src, out, &ctx, 150.0, radius, 8.0)); + } +} + +fn same_bits(a: &[f32], b: &[f32]) { + assert_eq!(a.len(), b.len()); + for (i, (&a, &b)) in a.iter().zip(b).enumerate() { + assert!(a.to_bits() == b.to_bits() || a.is_nan() && b.is_nan(), "sample {i}: {a:?} ({:x}) != {b:?} ({:x})", a.to_bits(), b.to_bits()); + } +} + +#[test] +fn exact_kernel_across_models_depths_edges_and_thresholds() { + let modes = [ + ColorMode::Bitmap, + ColorMode::Grayscale, + ColorMode::Duotone, + ColorMode::Indexed, + ColorMode::Rgb, + ColorMode::Cmyk, + ColorMode::Lab, + ColorMode::Multichannel, + ]; + for mode in modes { + for depth in SampleType::ALL { + for alpha in [false, true] { + let fmt = PixelFormat::new(mode, depth, alpha); + let area = Rect::new(-3, 2, 8, 7); + let mut surface = source(area, fmt); + if depth == SampleType::F32 { + // HDR, negative, signed zero, subnormal and nonfinite samples; colour is + // never clamped by this filter. Transparent hidden colour must survive. + let px: Vec = (0..fmt.channels()).map(|c| if alpha && c + 1 == fmt.channels() { 0.0 } else { -4.0 }).collect(); + surface.fill_rect(Rect::new(0, 3, 1, 4), &px); + for (x, value) in [3.5, -0.0, f32::MIN_POSITIVE, f32::INFINITY, f32::NAN].into_iter().enumerate() { + let mut px = vec![value; fmt.channels()]; + if alpha { + *px.last_mut().unwrap() = if x == 2 { 1e-8 } else { 1.0 }; + } + surface.fill_rect(Rect::new(x as i32, 5, x as i32 + 1, 6), &px); + } + } + let ctx = Ctx { bounds: area, mode, alpha }; + for radius in [0.0, 0.049, 0.1, 0.6, 1.5, 4.0, 17.25] { + let src = Image::read_clamped(&surface, area.inflate((radius * 3.0_f32).ceil() as i32 + 1), area); + for (amount, threshold) in [(0.0, 0.0), (50.0, 0.0), (500.0, 8.0), (300.0, 255.0)] { + same_bits(&unsharp(&src, area, &ctx, amount, radius, threshold), &reference::unsharp(&src, area, &ctx, amount, radius, threshold)); + } + // An incomplete halo has zero extension, even with alpha. + let src = Image::read(&surface, area); + let out = Rect::new(-5, 1, 10, 8); + same_bits(&unsharp(&src, out, &ctx, 150.0, radius, 8.0), &reference::unsharp(&src, out, &ctx, 150.0, radius, 8.0)); + } + } + } + } +} + +#[test] +fn exact_threshold_boundary_and_degenerate_images() { + for (w, h, n) in [(1, 1, 1), (1, 7, 2), (9, 1, 3), (7, 5, 4), (2, 3, 5), (5, 2, 7)] { + let out = Rect::new(-2, 3, w - 2, h + 3); + let ctx = Ctx { bounds: out, mode: ColorMode::Rgb, alpha: n % 2 == 0 }; + let src = Image { rect: out, ch: n, data: (0..w * h * n as i32).map(|i| if i % 7 == 0 { -0.0 } else { (i % 17) as f32 / 16.0 }).collect() }; + for radius in [0.0, 0.1, 0.75, 8.5, 64.0] { + let k = reference::gaussian_kernel(radius); + let blurred = reference::conv_sep(&src, out, &k, &k, ctx.alpha); + for (&o, &b) in src.data.iter().zip(&blurred).take(8) { + let boundary = ((o - b).abs() + 1e-6) * 255.0; + for threshold in [boundary, f32::from_bits(boundary.to_bits().saturating_sub(1)), f32::from_bits(boundary.to_bits() + 1)] { + same_bits(&unsharp(&src, out, &ctx, 500.0, radius, threshold), &reference::unsharp(&src, out, &ctx, 500.0, radius, threshold)); + } + } + } + } + let empty = Image { rect: Rect::EMPTY, ch: 4, data: Vec::new() }; + let ctx = Ctx { bounds: Rect::EMPTY, mode: ColorMode::Rgb, alpha: true }; + assert!(unsharp(&empty, Rect::EMPTY, &ctx, 100.0, 1.0, 0.0).is_empty()); +} + +#[test] +fn exact_tiled_selected_outputs_and_cancellation() { + let bounds = Rect::new(-13, -7, 281, 271); + let area = Rect::new(-3, 5, 273, 265); + let mut sel = Surface::new(PixelFormat::GRAY8); + sel.fill_rect(area, &[0.4]); + sel.fill_rect(Rect::new(250, 250, 270, 265), &[1.0]); + for depth in SampleType::ALL { + let surface = source(bounds, PixelFormat::new(ColorMode::Rgb, depth, true)); + for radius in [0.6, 3.0, 10.0] { + let p = crate::FilterParams::UnsharpMask { amount: 150.0, radius, threshold: 8.0 }; + for extent in [None, Some(bounds)] { + let expected = apply_reference(&surface, &p, area, bounds, Some(&sel), extent, 256); + for tile in [63, 256, 512] { + let actual = crate::apply_tiled(&surface, &p, area, bounds, Some(&sel), tile, extent); + assert_eq!(expected.to_interleaved(bounds), actual.to_interleaved(bounds)); + } + } + let cancelled = std::sync::atomic::AtomicBool::new(false); + let cancel = || cancelled.load(std::sync::atomic::Ordering::Relaxed); + let progress = |_| cancelled.store(true, std::sync::atomic::Ordering::Relaxed); + let ctl = Interrupt::new(&cancel, &progress); + let input = surface.to_interleaved(bounds); + assert!(crate::apply_in_with(&surface, &p, area, bounds, Some(&sel), bounds, &ctl).is_none()); + assert_eq!(surface.to_interleaved(bounds), input); + } + } +} + +#[test] +fn preserve_details_resampling_keeps_the_same_sharpening() { + use crate::resample::{Resample, resize_surface}; + let src = Rect::new(-3, 2, 19, 17); + let (sx, sy) = (2.0, 1.5); + let dst = crate::resample::scaled_rect(src, sx, sy); + for depth in SampleType::ALL { + for mode in [ColorMode::Grayscale, ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Lab] { + let s = source(src, PixelFormat::new(mode, depth, true)); + let resized = resize_surface(&s, sx, sy, Resample::Lanczos); + let p = crate::FilterParams::UnsharpMask { amount: 30.0, radius: 0.6 * sx.max(sy) as f32, threshold: 0.0 }; + let area = crate::output_area(&p, resized.content_bounds(), dst, None); + let expected = apply_reference(&resized, &p, area, dst, None, None, crate::auto_tile(&p, dst)); + let actual = resize_surface(&s, sx, sy, Resample::PreserveDetails); + assert_eq!(actual.to_interleaved(area), expected.to_interleaved(area)); + } + } +} + +#[test] +fn flat_fields_and_sharp_edges_keep_exact_halos() { + let out = Rect::new(0, 0, 17, 9); + for alpha in [false, true] { + let ctx = Ctx { bounds: out, mode: ColorMode::Rgb, alpha }; + for radius in [0.1, 0.6, 3.25, 64.0] { + let rect = out.inflate((radius * 3.0_f32).ceil() as i32 + 1); + for edge in [false, true] { + let n = 3 + usize::from(alpha); + let data = (rect.y0..rect.y1) + .flat_map(|_| { + (rect.x0..rect.x1).flat_map(|x| { + (0..n).map(move |c| { + if alpha && c + 1 == n { + 0.7 + } else if edge && x >= 8 { + 0.75 + } else { + 0.25 + } + }) + }) + }) + .collect(); + let src = Image { rect, ch: n, data }; + same_bits(&unsharp(&src, out, &ctx, 300.0, radius, 0.0), &reference::unsharp(&src, out, &ctx, 300.0, radius, 0.0)); + } + } + } +} + +#[test] +fn malformed_inputs_and_allocations_stop_gracefully() { + let rect = Rect::new(0, 0, 2, 2); + let src = Image { rect, ch: 1, data: vec![0.5; 4] }; + let ctx = Ctx { bounds: rect, mode: ColorMode::Grayscale, alpha: false }; + for (amount, radius, threshold) in [(f32::NAN, 1.0, 0.0), (100.0, f32::INFINITY, 0.0), (100.0, -1.0, 0.0), (100.0, f32::MAX, 0.0), (100.0, 1.0, f32::NAN)] { + assert!(exact::run(&src, rect, &ctx, amount, radius, threshold, &Interrupt::NONE).is_none()); + let s = source(rect, PixelFormat::GRAY8); + let p = crate::FilterParams::UnsharpMask { amount, radius, threshold }; + assert!(crate::apply_in_with(&s, &p, rect, rect, None, rect, &Interrupt::NONE).is_none()); + assert_eq!(crate::apply_in(&s, &p, rect, rect, None, rect), s); + } + for bad in [Image { ch: 0, ..src.clone() }, Image { data: vec![0.0], ..src.clone() }, Image { rect: Rect::new(5, 0, 0, 2), ..src.clone() }] { + assert!(exact::run(&bad, rect, &ctx, 100.0, 1.0, 0.0, &Interrupt::NONE).is_none()); + } + for rect in [Rect::new(i32::MIN, 0, i32::MAX, 2), Rect::new(0, 0, 300_000, 300_000), Rect::new(2, 0, 0, 2)] { + assert!(exact::run(&src, rect, &ctx, 100.0, 1.0, 0.0, &Interrupt::NONE).is_none()); + } + let s = source(Rect::new(0, 0, 2, 2), PixelFormat::GRAY8); + let p = crate::FilterParams::UnsharpMask { amount: 100.0, radius: 1.0, threshold: 0.0 }; + for tile in [i32::MIN, 0, i32::MAX] { + let r = s.content_bounds(); + assert_eq!(crate::apply_tiled(&s, &p, r, r, None, tile, Some(r)), crate::apply_in(&s, &p, r, r, None, r)); + } + // Allocation/geometry guard prevents a huge halo read before allocating it. + let p = crate::FilterParams::UnsharpMask { amount: 100.0, radius: 15_446.0, threshold: 0.0 }; + assert!(crate::apply_in_with(&s, &p, s.content_bounds(), s.content_bounds(), None, s.content_bounds(), &Interrupt::NONE).is_none()); +} + +#[test] +fn maximum_supported_radius_matches_the_frozen_kernel() { + let out = Rect::new(0, 0, 1, 1); + let src = Image { rect: out, ch: 1, data: vec![0.75] }; + let ctx = Ctx { bounds: out, mode: ColorMode::Grayscale, alpha: false }; + same_bits(&unsharp(&src, out, &ctx, 500.0, 1000.0, 8.0), &reference::unsharp(&src, out, &ctx, 500.0, 1000.0, 8.0)); +} + +#[test] +fn cancellation_stops_inside_a_tile_before_publishing() { + let rect = Rect::new(0, 0, 16, 16); + let s = source(rect, PixelFormat::RGBA8); + let src = Image::read(&s, rect); + let ctx = Ctx { bounds: rect, mode: ColorMode::Rgb, alpha: true }; + let checks = std::sync::atomic::AtomicUsize::new(0); + let cancel = || checks.fetch_add(1, std::sync::atomic::Ordering::Relaxed) >= 10; + assert!(exact::run(&src, rect, &ctx, 150.0, 30.0, 8.0, &Interrupt::cancel_only(&cancel)).is_none()); + assert_eq!(src, Image::read(&s, rect)); +} + +#[test] +#[ignore = "release full-operation baseline/candidate comparison; run alone"] +fn unsharp_performance() { + let number = |name: &str, default: i32| std::env::var(name).ok().and_then(|s| s.parse::().ok()).unwrap_or(default); + let (w, h, reps) = (number("PHOTOCRAFT_UNSHARP_WIDTH", 1500), number("PHOTOCRAFT_UNSHARP_HEIGHT", 1000), number("PHOTOCRAFT_UNSHARP_REPS", 5)); + assert!((1..=8192).contains(&w) && (1..=8192).contains(&h) && (1..=15).contains(&reps)); + let depth = match number("PHOTOCRAFT_UNSHARP_DEPTH", 8) { + 8 => SampleType::U8, + 16 => SampleType::U16, + 32 => SampleType::F32, + _ => panic!("depth"), + }; + let area = Rect::new(0, 0, w, h); + let channels = number("PHOTOCRAFT_UNSHARP_CHANNELS", 4); + let (mode, alpha) = match channels { + 1 => (ColorMode::Grayscale, false), + 2 => (ColorMode::Grayscale, true), + 3 => (ColorMode::Rgb, false), + 4 => (ColorMode::Rgb, true), + 5 => (ColorMode::Cmyk, true), + _ => panic!("channels 1..5"), + }; + let surface = source(area, PixelFormat::new(mode, depth, alpha)); + let measure = std::env::var("PHOTOCRAFT_UNSHARP_MODE").unwrap_or_else(|_| "paired".into()); + assert!(matches!(measure.as_str(), "paired" | "reference" | "candidate")); + let cases = std::env::var("PHOTOCRAFT_UNSHARP_CASES").unwrap_or_else(|_| "0.6:150:0,3:150:8,10:300:16,30:150:0".into()); + for case in cases.split(',') { + let values: Vec = case.split(':').map(|v| v.parse().unwrap()).collect(); + let (radius, amount, threshold) = (values[0], values[1], values[2]); + let p = crate::FilterParams::UnsharpMask { amount, radius, threshold }; + let tile = crate::auto_tile(&p, area); + let before = || apply_reference(&surface, &p, area, area, None, Some(area), tile); + // Isolate the streamed exact path from the new adaptive FFT dispatcher. + let after = || crate::apply_tiled_with_impl(&surface, &p, area, area, None, tile, Some(area), &Interrupt::NONE, false).unwrap(); + if measure != "paired" { + let f = if measure == "reference" { &before as &dyn Fn() -> Surface } else { &after as &dyn Fn() -> Surface }; + let mut times = Vec::new(); + for rep in 0..reps + 2 { + let start = Instant::now(); + let output = f(); + let ms = start.elapsed().as_secs_f64() * 1000.0; + std::hint::black_box(output); + if rep >= 2 { + times.push(ms); + } + } + println!( + "{}", + serde_json::json!({"mode":measure,"width":w,"height":h,"channels":channels,"depth":number("PHOTOCRAFT_UNSHARP_DEPTH",8),"radius":radius,"amount":amount,"threshold":threshold,"workers":workers(),"runs_ms":times}) + ); + continue; + } + for _ in 0..2 { + std::hint::black_box(before()); + std::hint::black_box(after()); + } + let (mut a, mut b) = (Vec::new(), Vec::new()); + for rep in 0..reps { + let timed = |f: &dyn Fn() -> Surface| { + let start = Instant::now(); + let s = f(); + (s, start.elapsed().as_secs_f64() * 1000.0) + }; + let ((old, old_ms), (new, new_ms)) = if rep % 2 == 0 { + (timed(&before), timed(&after)) + } else { + let b = timed(&after); + (timed(&before), b) + }; + assert_eq!(old.to_interleaved(area), new.to_interleaved(area)); + a.push(old_ms); + b.push(new_ms); + } + let median = |v: &[f64]| { + let mut v = v.to_vec(); + v.sort_by(f64::total_cmp); + v[v.len() / 2] + }; + println!( + "{}", + serde_json::json!({"width":w,"height":h,"channels":channels,"depth":number("PHOTOCRAFT_UNSHARP_DEPTH",8),"radius":radius,"amount":amount,"threshold":threshold,"workers":workers(),"before_ms":a,"after_ms":b,"before_median_ms":median(&a),"after_median_ms":median(&b),"speedup":median(&a)/median(&b),"max_error":0}) + ); + } +} + +#[test] +#[ignore = "unchanged-filter stage profile; run alone in release"] +fn unsharp_baseline_profile() { + let out = Rect::new(0, 0, 256, 256); + for radius in [0.6, 3.0, 10.0, 30.0] { + let reach = (radius * 3.0_f32).ceil() as i32 + 1; + let s = source(out.inflate(reach), PixelFormat::new(ColorMode::Rgb, SampleType::U8, true)); + let src = Image::read(&s, out.inflate(reach)); + let ctx = Ctx { bounds: out, mode: ColorMode::Rgb, alpha: true }; + let k = reference::gaussian_kernel(radius); + let mut times = [0.0; 4]; + for _ in 0..20 { + let start = Instant::now(); + std::hint::black_box(reference::gaussian_kernel(radius)); + times[0] += start.elapsed().as_secs_f64(); + let start = Instant::now(); + let bl = reference::conv_sep(&src, out, &k, &k, true); + times[1] += start.elapsed().as_secs_f64(); + let start = Instant::now(); + let mut res = src.crop(out); + for (px, b) in res.as_chunks_mut::<4>().0.iter_mut().zip(bl.as_chunks::<4>().0) { + if px[3] <= 0.0 { + continue; + } + for c in 0..3 { + let d = px[c] - b[c]; + if d.abs() + 1e-6 >= 8.0 / 255.0 { + px[c] += 1.5 * d; + } + } + } + std::hint::black_box(res); + times[2] += start.elapsed().as_secs_f64(); + let start = Instant::now(); + std::hint::black_box(reference::unsharp(&src, out, &ctx, 150.0, radius, 8.0)); + times[3] += start.elapsed().as_secs_f64(); + } + println!( + "{}", + serde_json::json!({"baseline_tile_radius":radius,"kernel_ms":times[0]*50.0,"convolution_ms":times[1]*50.0,"crop_blend_ms":times[2]*50.0,"total_kernel_ms":times[3]*50.0}) + ); + } +} diff --git a/crates/ui-egui/examples/unsharp_preview_bench.rs b/crates/ui-egui/examples/unsharp_preview_bench.rs new file mode 100644 index 00000000000..0f4518187a6 --- /dev/null +++ b/crates/ui-egui/examples/unsharp_preview_bench.rs @@ -0,0 +1,67 @@ +//! Synthetic existing preview workload, including proxy creation, command/history and CPU +//! flattening. Excludes scheduling, egui frames, display conversion, GPU upload/presentation. +use photocraft_color::{Color, ColorMode, SampleType}; +use photocraft_doc::{Document, LayerContent}; +use photocraft_geom::{Rect, Size}; +use photocraft_ui_egui::{filter_dialog, proxy}; +use serde_json::json; +use std::time::Instant; + +fn main() -> Result<(), Box> { + let n = |key: &str, default| std::env::var(key).ok().and_then(|s| s.parse::().ok()).unwrap_or(default); + let (w, h, reps) = (n("PHOTOCRAFT_UNSHARP_WIDTH", 6000), n("PHOTOCRAFT_UNSHARP_HEIGHT", 4000), n("PHOTOCRAFT_UNSHARP_REPS", 5)); + if w == 0 || h == 0 || w > 8192 || h > 8192 || !(1..=15).contains(&reps) { + return Err("invalid benchmark dimensions/reps".into()); + } + let depth = match n("PHOTOCRAFT_UNSHARP_DEPTH", 8) { + 8 => SampleType::U8, + 16 => SampleType::U16, + 32 => SampleType::F32, + _ => return Err("invalid depth".into()), + }; + let mut doc = Document::with_background("Synthetic Unsharp Mask", Size::new(w, h), ColorMode::Rgb, depth, Color::WHITE); + let layer = doc.layers.first_mut().ok_or("missing layer")?; + let id = layer.id; + let LayerContent::Raster(s) = &mut layer.content else { return Err("raster required".into()) }; + for y in 0..h as i32 { + let data: Vec<_> = (0..w as i32) + .flat_map(|x| [((x * 7 + y * 13) % 257) as f32 / 256.0, ((x ^ y) % 251) as f32 / 250.0, ((x + y * 11) % 127) as f32 / 126.0, 1.0]) + .collect(); + s.write_region(Rect::new(0, y, w as i32, y + 1), &data); + } + let cmd = "filter.sharpen.unsharpMask"; + let cases = std::env::var("PHOTOCRAFT_UNSHARP_CASES").unwrap_or_else(|_| "0.6:150:0,3:150:8,10:300:16,30:150:0".into()); + for case in cases.split(',') { + let v: Vec = case.split(':').map(str::parse).collect::>()?; + let (&radius, &amount, &threshold) = (v.first().ok_or("radius")?, v.get(1).ok_or("amount")?, v.get(2).ok_or("threshold")?); + for reduced in [true, false] { + let k = filter_dialog::preview_factor(cmd, &json!({"radius":radius}), proxy::preview_factor(&doc, reduced)); + let mut times = Vec::new(); + let mut checksum = 0u64; + for rep in 0..reps + 2 { + let start = Instant::now(); + let result = filter_dialog::preview_document(&doc, Some(id), cmd, &json!({"radius":radius,"amount":amount,"threshold":threshold}), k) + .ok_or("preview failed")?; + let pixels = photocraft_compose::flatten(&result); + let ms = start.elapsed().as_secs_f64() * 1000.0; + // Outside timing: stable hash over the full flattened result, no image file needed. + let hash = pixels.px.iter().flatten().fold(0u64, |h, v| h.wrapping_mul(1099511628211).wrapping_add(u64::from(v.to_bits()))); + if rep == 0 { + checksum = hash; + } else if checksum != hash { + return Err("unstable preview output".into()); + } + if rep >= 2 { + times.push(ms); + } + } + let mut sorted = times.clone(); + sorted.sort_by(f64::total_cmp); + println!( + "{}", + json!({"width":w,"height":h,"depth":n("PHOTOCRAFT_UNSHARP_DEPTH",8),"radius":radius,"amount":amount,"threshold":threshold,"proxy_factor":k,"runs_ms":times,"median_ms":sorted.get(sorted.len()/2),"checksum":checksum,"workers":rayon::current_num_threads(),"scope":"existing preview_document + CPU flatten; excludes GUI and GPU"}) + ); + } + } + Ok(()) +} diff --git a/docs/roadmap.md b/docs/roadmap.md index e14a05113e8..bca91616c7f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (Photomerge implementation status corrected; real-world quality validation remains open) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (adaptive Unsharp Mask performance evidence linked; Photomerge status correction retained; alpha gate, focus and estimates unchanged) · **Target:** Adobe Photoshop 2026 - UI.5: Panels can detach, join other tab groups, split, resize and redock through shared docking transactions. Custom layouts persist with workspaces, respect workspace locking, and retain collapse flags for intact native groups. @@ -72,6 +72,9 @@ Ranked; each item links to its gap entry. Pick from the top unless an issue is a Photoshop re-open check (FILE-216-4); Photoshop-oracle floor 133/256 → 200+. 150–250 h. 2. **Performance budgets** ([G2](gaps.md#g2-interactive-performance)): the 22 over-budget scenarios, the P12 GPU crash at 150 layers, brush and cursor lag (#2619, #2566). 200–350 h. + Local adaptive [Unsharp Mask optimisation](unsharp-mask-performance.md) improves native + threshold-zero 24 MP RGBA CPU application by 37–102x at radii 300–1000, with measured + rounding differences and an exact fallback; no published budget is closed by that measurement. 3. **User bug backlog** ([G3](gaps.md#g3-user-reported-bug-backlog)): 579 open issues, most from 0.5/0.6 users; triage weekly, fix regressions first. 250–400 h. 4. **Brush and transform feel** ([G4](gaps.md#g4-brush-feel), [G5](gaps.md#g5-transform-handles-and-modifiers)): @@ -113,6 +116,7 @@ Each milestone has a **definition of done (DoD)** and must leave `main` green on | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Linked adaptive Unsharp Mask performance and quality evidence; milestones, alpha gate and estimates unchanged | | 2026-10-10 | minor | Photomerge is implemented; real-world quality validation remains open | | 2026-10-10 | minor | Alpha gate table (core-workflow gate from the progress-docs standard): passes, stage stays alpha | | 2026-10-10 | major | Progress-docs standard: honest assessment and dated measurements moved to `target-app-parity.md`, Affinity notes to `file-format-parity.md`; new Current focus and estimates; `parity.md` renamed `parity-checklist.md` | diff --git a/docs/unsharp-mask-performance.md b/docs/unsharp-mask-performance.md new file mode 100644 index 00000000000..d530cf2f938 --- /dev/null +++ b/docs/unsharp-mask-performance.md @@ -0,0 +1,23 @@ +# Unsharp Mask performance + +Large-radius native Unsharp Mask uses separable FFT convolution with the original +discrete Gaussian coefficients and the existing `rustfft` dependency. A streamed +separable path preserves the original tap order for exact processing. + +FFT is eligible for radii 64–1000 with at least 65,536 output pixels, a nonzero +amount and a zero or negative threshold. Positive thresholds, unstable samples, +unsupported geometry and insufficient scratch use the exact path. WebAssembly +retains exact processing. Alpha, selections, edges and cancellation are preserved. +Smart Sharpen, High Pass and shared blur math are unchanged. + +FFT changes summation order and permits small rounding differences. The measured +maximum was one 8-bit storage level, approximately one 16-bit level, and 8.762e-6 +for the HDR fixture. Exact-path comparisons were byte-identical. Transform axes +are capped at 32,768; FFT scratch grouping has a 1 GiB budget, excluding document +tiles. This is not a whole-process memory limit. + +Before/after numbers, benchmark methodology, numerical tolerances and preview +measurement limits are recorded in [PR #2987](https://github.com/storytold/photocraft/pull/2987). +The comparisons use synthetic images and measure CPU computation, not slider +event-to-screen latency. No published performance budget or rendering-parity +floor is raised by these measurements. From 34ce5766d2b43417868f7034e8d43ee3598c7181 Mon Sep 17 00:00:00 2001 From: Gotya-OFFCIAL Date: Wed, 14 Oct 2026 01:24:09 +0800 Subject: [PATCH 42/47] raw: decode Panasonic RW2 RawFormat 3 and 4 (vendor-compressed) (#3286) The compressed RW2 layouts of earlier Lumix bodies (e.g. the DMC-GX7) decode at full sensor resolution instead of falling back to the camera's embedded JPEG preview. The layout was established by observation of sample files (fixed 16 bytes per 14 pixels, predictor groups with per-parity predictors, page-permuted downward bit order); the black level of these files reads 15 counts low. Adds round-trip tests through a mirrored synthetic encoder, cursor and predictor-group unit vectors, and updates the support matrix. Co-authored-by: Gotya-OFFCIAL <339410022+Gotya-OFFCIAL@users.noreply.github.com> --- crates/raw/README.md | 7 +- crates/raw/src/lib.rs | 6 +- crates/raw/src/rw2.rs | 257 ++++++++++++++++++++++++++---- crates/raw/src/testgen.rs | 130 +++++++++++++++ crates/raw/tests/vendor_codecs.rs | 58 ++++++- 5 files changed, 421 insertions(+), 37 deletions(-) diff --git a/crates/raw/README.md b/crates/raw/README.md index 232ef83b3bc..b8c75ebff6a 100644 --- a/crates/raw/README.md +++ b/crates/raw/README.md @@ -45,6 +45,10 @@ Implemented only from public specifications, papers and observation of files: by observation of sample files (bit packing, page layout, sample justification; for RAF the three sample storages and the reversed order of the X-Trans / Bayer layout records, each verified by developing with every candidate phase against the camera's preview). +* Panasonic RW2 RawFormat 3/4 (vendor-compressed): the fixed-rate 16 bytes per 14 pixels, the + predictor groups with per-parity predictors, the 2/4/8-bit field widths, the page-permuted + downward bit order and the black level sitting 15 counts above the recorded tags were all + established by observation of sample files. * Demosaicing: Malvar, He & Cutler (ICASSP 2004); Hirakawa & Parks, "Adaptive homogeneity-directed demosaicing" (IEEE TIP 2005); for X-Trans and other non-Bayer CFAs, gradient-weighted green interpolation (Lu & Tan, "Color filter array demosaicking: new method @@ -71,8 +75,9 @@ camera colour tables were copied. | Nikon compressed NEF (compression 34713): lossless and lossy type 1 / 2, 12 and 14 bit | Decoded: fixed Huffman tables + maker-note `0x0096` seeds and curve (see `src/nefc.rs`). BlackLevel `0x003d` is read in 14-bit units. "Lossy after split" files are unsupported (preview fallback) | | Sony compressed ARW ("cRAW", SonyRawFileType 2) | Decoded: 11-bit min/max + 7-bit delta blocks, SonyToneCurve to 14 bits, the curve's 512 black level when no BlackLevel tag is written (the first-generation bodies, e.g. the ILCE-7) | | Panasonic / Leica RW2, RawFormat 5 (12- and 14-bit packed) | Decoded, with PanasonicRaw black / white / WB / sensor borders | +| Panasonic / Leica RW2, RawFormat 3 and 4 (vendor-compressed, 12-bit) | Decoded; the black level is read 15 counts above the recorded tag; PanasonicRaw white / WB / sensor borders | | Olympus ORF, uncompressed 16-bit (E-1, E-400…) | Decoded, with ImageProcessing black / WB / ValidBits / crop | -| Nikon "lossy after split" NEF, Sony "Compressed RAW 2", Pentax compressed PEF, RW2 RawFormat 4 and older, Olympus compressed ORF | Unsupported: no public description of these codes was found apart from GPL decoder source, which this crate may not use (clean-room). `photocraft-io` opens the embedded JPEG preview instead | +| Nikon "lossy after split" NEF, Sony "Compressed RAW 2", Pentax compressed PEF, RW2 RawFormat 6 and newer, Olympus compressed ORF | Unsupported: no public description of these codes was found apart from GPL decoder source, which this crate may not use (clean-room). `photocraft-io` opens the embedded JPEG preview instead | | Fujifilm RAF, uncompressed (X-Trans I–V, GFX and Bayer bodies since about 2010) | Decoded: 16-bit containers (either byte order), 12-bit LSB-first packing, 14-bit packing in 32-bit words; X-Trans 6×6 or Bayer 2×2 layout, black, as-shot WB, crop, RawExposureBias, orientation from the preview's EXIF | | Fujifilm compressed RAF, early FinePix / SuperCCD RAF (no CFA TIFF) | Unsupported (no public description of the compression); preview fallback | | CR3 | Native CRX unsupported; bounded JPEG-track/PRVW preview fallback with CMT1 orientation. I/O labels the result as an 8-bit camera preview | diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index f8304b3cfa0..0bccf8ffacb 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -32,9 +32,9 @@ //! tiles, CFA and LinearRaw), CR2 (lossless JPEG with Canon slices), uncompressed or //! lossless-JPEG TIFF/EP raws (NEF, ARW, PEF… when not vendor-compressed), //! Nikon compressed NEF (lossless and lossy), Sony compressed ARW (cRAW), -//! Panasonic RW2 (RawFormat 5), uncompressed Olympus ORF and uncompressed -//! Fujifilm RAF (Bayer and X-Trans). Everything else reports -//! [`RawError::Unsupported`]. +//! Panasonic RW2 (RawFormat 5 packed and RawFormat 3/4 vendor-compressed), +//! uncompressed Olympus ORF and uncompressed Fujifilm RAF (Bayer and +//! X-Trans). Everything else reports [`RawError::Unsupported`]. //! //! The crate is standalone (no workspace dependencies), does no I/O, builds for //! `wasm32-unknown-unknown` and never panics on hostile input: sizes are diff --git a/crates/raw/src/rw2.rs b/crates/raw/src/rw2.rs index 231cd0971b9..c50d32c09c4 100644 --- a/crates/raw/src/rw2.rs +++ b/crates/raw/src/rw2.rs @@ -1,5 +1,6 @@ -//! Panasonic / Leica RW2 with the bit-packed sensor layout (PanasonicRaw -//! RawFormat 5, written by recent Lumix bodies). +//! Panasonic / Leica RW2: the bit-packed stream of recent Lumix bodies +//! (PanasonicRaw RawFormat 5) and the vendor-compressed stream of the earlier +//! ones (RawFormat 3 and 4). //! //! The container is TIFF-like (`IIU\0`); IFD0 holds Panasonic's own tags, //! whose ids and meanings are public (ExifTool's PanasonicRaw table): @@ -9,7 +10,7 @@ //! WB{Red,Green,Blue}Level (0x24–0x26), RawFormat (0x2D) and RawDataOffset //! (0x118). //! -//! The packing was established by observation of sample files: +//! RawFormat 5 — the packing was established by observation of sample files: //! //! * From RawDataOffset the data is stored in pages of 0x4000 bytes, each //! page split at 0x1FF8 with its second part first (the logical page is @@ -19,8 +20,26 @@ //! the least significant bit up, the remaining top bits unused; rows are //! whole blocks. //! -//! The older RawFormat 4 (and earlier) store vendor-compressed data, which is -//! not decoded. +//! RawFormat 3 and 4 — the vendor-compressed layout, also established by +//! observation of sample files (verified by decoding whole frames against the +//! camera's embedded JPEG and the recorded levels): +//! +//! * The payload at RawDataOffset is a fixed-rate stream: 16 bytes per 14 +//! pixels, so a row occupies `width / 14 * 16` bytes and rows are +//! byte-aligned. Bits are consumed down from the top of each 0x4000-byte +//! page, through the same page split as RawFormat 5 (`(i + 0x4000 - 0x2008) +//! % 0x4000`), reading fields LSB-first from the little-endian 16-bit word +//! the cursor points at. +//! * Pixels arrive in groups of 14 with two predictors carried over from one +//! group to the next (even and odd columns). Every third pixel carries a +//! 2-bit scale selector `sh` (0, 1, 2 or 4). The first value of each parity +//! is absolute (8 + 4 bits); the rest are 8-bit deltas around 0x80 scaled +//! by `sh`, wrapping in the low `sh` bits. +//! * The decoded samples sit 15 counts above the recorded BlackLevel tags +//! (measured on the darkest pixels of sample files), so 15 is added when +//! they are read. +//! +//! Every other RawFormat value is not decoded. use crate::error::{RawError, Result}; use crate::sensor::{BlackLevels, Cfa, Rect, Sensor}; @@ -44,6 +63,8 @@ const RAW_DATA_OFFSET: u16 = 0x0118; /// Page size and split point of the stored stream (observed). const PAGE: usize = 0x4000; const SPLIT: usize = 0x1FF8; +/// Byte split of the vendor-compressed stream (observed). +const COMPRESSED_SPLIT: usize = 0x2008; /// Physical offset (relative to RawDataOffset) of logical byte `i`. #[inline] @@ -75,34 +96,13 @@ pub(crate) fn unpack_block(b: &[u8; 16], bits: u32, out: &mut [u16]) { } } -pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result { - let ifd = t.ifd_at(t.first_ifd, 0).ok_or_else(|| RawError::malformed("RW2 has no IFD0"))?; - let raw_format = t.tag_uint(&ifd, RAW_FORMAT); - if raw_format != Some(5) { - let f = raw_format.map(|v| v.to_string()).unwrap_or_else(|| "unknown".into()); - return Err(RawError::unsupported(format!("Panasonic RW2 raw format {f} (vendor-compressed) is not decoded yet"))); - } - let width = t.tag_uint(&ifd, SENSOR_WIDTH).ok_or_else(|| RawError::malformed("RW2 has no sensor width"))? as usize; - let height = t.tag_uint(&ifd, SENSOR_HEIGHT).ok_or_else(|| RawError::malformed("RW2 has no sensor height"))? as usize; - let bits = t.tag_uint(&ifd, BITS_PER_SAMPLE).unwrap_or(12); +/// Reads the bit-packed RawFormat 5 stream (`src` starts at RawDataOffset). +fn decode_packed(src: &[u8], width: usize, height: usize, bits: u32) -> Result> { let ppb = pixels_per_block(bits).ok_or_else(|| RawError::unsupported(format!("Panasonic RW2 with {bits}-bit samples")))?; if !width.is_multiple_of(ppb) { return Err(RawError::unsupported(format!("Panasonic RW2 {width} pixels wide (not whole {ppb}-pixel blocks)"))); } - limits.check(width as u64, height as u64, 2)?; - let cfa = match t.tag_uint(&ifd, CFA_PATTERN) { - Some(1) => "RGGB", - Some(2) => "GRBG", - Some(3) => "GBRG", - Some(4) => "BGGR", - _ => return Err(RawError::unsupported("Panasonic RW2 without a CFA pattern")), - }; - let cfa = Cfa::bayer(cfa).ok_or_else(|| RawError::malformed("bad CFA"))?; - let offset = - t.tag_uint(&ifd, RAW_DATA_OFFSET).or_else(|| t.tag_uint(&ifd, tag::STRIP_OFFSETS)).ok_or_else(|| RawError::malformed("RW2 has no raw data offset"))? - as usize; let row_bytes = width / ppb * 16; - let src = t.data.get(offset..).ok_or_else(|| RawError::malformed("RW2 raw data lies outside the file"))?; let need = row_bytes * height; // Whole pages are stored; tolerate a short final page (its missing bytes read as 0). if src.len() < need.saturating_sub(PAGE) { @@ -124,11 +124,138 @@ pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result { } } }); + Ok(data) +} + +/// LSB-first bit cursor of the vendor-compressed stream: it walks down from +/// the top of each 0x4000-byte page (addressed through `COMPRESSED_SPLIT`) +/// and refills by advancing one page. +struct Bits<'a> { + buf: &'a [u8], + base: usize, + pos: usize, + nbits: u32, +} + +impl<'a> Bits<'a> { + fn new(buf: &'a [u8], base: usize) -> Self { + Bits { buf, base, pos: 0, nbits: 0 } + } + + #[inline] + fn get(&mut self, num: u32) -> u32 { + if num > self.nbits { + self.nbits += PAGE as u32 * 8; + self.pos += PAGE; + } + let mut at = ((self.nbits - num) >> 3) as usize ^ 0x3FF0; + at = (at + PAGE - COMPRESSED_SPLIT) % PAGE; + let at = self.base + at + self.pos - PAGE; + self.nbits -= num; + // Out-of-range reads (a page short at the end of the file) read as 0. + let lo = u32::from(self.buf.get(at).copied().unwrap_or(0)); + let hi = u32::from(self.buf.get(at + 1).copied().unwrap_or(0)); + ((lo | (hi << 8)) >> (self.nbits & 7)) & ((1u32 << num) - 1) + } +} + +/// Decodes one row of the vendor-compressed stream. +fn compressed_row(buf: &[u8], row_bytes: usize, row: usize, out: &mut [u16]) { + let skip = row_bytes * row; + let mut bits = Bits::new(buf, skip / PAGE * PAGE); + for _ in 0..(skip % PAGE) { + bits.get(8); + } + let mut sh: i32 = 0; + for group in out.as_chunks_mut::<14>().0 { + let mut pred = [0i32; 2]; + let mut nonz = [0i32; 2]; + for (i, px) in group.iter_mut().enumerate() { + if i % 3 == 2 { + sh = 4 >> (3 - bits.get(2)); + } + let p = i & 1; + if nonz[p] != 0 { + let j = bits.get(8) as i32; + if j != 0 { + pred[p] -= 0x80 << sh; + if pred[p] < 0 || sh == 4 { + pred[p] &= !(-1 << sh); + } + pred[p] += j << sh; + } + } else { + nonz[p] = bits.get(8) as i32; + if nonz[p] != 0 || i > 11 { + pred[p] = (nonz[p] << 4) | bits.get(4) as i32; + } + } + *px = pred[p] as u16; + } + } +} + +/// Reads the vendor-compressed RawFormat 3/4 stream (`src` starts at +/// RawDataOffset). +fn decode_compressed(src: &[u8], width: usize, height: usize, bits: u32) -> Result> { + if bits != 12 { + return Err(RawError::unsupported(format!("Panasonic RW2 with {bits}-bit compressed samples"))); + } + if !width.is_multiple_of(14) { + return Err(RawError::unsupported(format!("Panasonic RW2 {width} pixels wide (not whole 14-pixel blocks)"))); + } + let row_bytes = width / 14 * 16; + let need = row_bytes * height; + // Whole pages are stored; tolerate a short final page (its missing bytes read as 0). + if src.len() < need.saturating_sub(PAGE) { + return Err(RawError::malformed("RW2 raw data is truncated")); + } + let mut data = vec![0u16; width * height]; + let band = par::band_rows(width); + par::chunks_mut(&mut data, band * width, |i, chunk| { + for (r, out) in chunk.chunks_exact_mut(width).enumerate() { + compressed_row(src, row_bytes, i * band + r, out); + } + }); + Ok(data) +} + +pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result { + let ifd = t.ifd_at(t.first_ifd, 0).ok_or_else(|| RawError::malformed("RW2 has no IFD0"))?; + let raw_format = t.tag_uint(&ifd, RAW_FORMAT); + let compressed = match raw_format { + Some(5) => false, + Some(3 | 4) => true, + other => { + let f = other.map(|v| v.to_string()).unwrap_or_else(|| "unknown".into()); + return Err(RawError::unsupported(format!("Panasonic RW2 raw format {f} (vendor-compressed) is not decoded yet"))); + } + }; + let width = t.tag_uint(&ifd, SENSOR_WIDTH).ok_or_else(|| RawError::malformed("RW2 has no sensor width"))? as usize; + let height = t.tag_uint(&ifd, SENSOR_HEIGHT).ok_or_else(|| RawError::malformed("RW2 has no sensor height"))? as usize; + limits.check(width as u64, height as u64, 2)?; + let bits = t.tag_uint(&ifd, BITS_PER_SAMPLE).unwrap_or(12); + let cfa = match t.tag_uint(&ifd, CFA_PATTERN) { + Some(1) => "RGGB", + Some(2) => "GRBG", + Some(3) => "GBRG", + Some(4) => "BGGR", + _ => return Err(RawError::unsupported("Panasonic RW2 without a CFA pattern")), + }; + let cfa = Cfa::bayer(cfa).ok_or_else(|| RawError::malformed("bad CFA"))?; + let offset = + t.tag_uint(&ifd, RAW_DATA_OFFSET).or_else(|| t.tag_uint(&ifd, tag::STRIP_OFFSETS)).ok_or_else(|| RawError::malformed("RW2 has no raw data offset"))? + as usize; + let src = t.data.get(offset..).ok_or_else(|| RawError::malformed("RW2 raw data lies outside the file"))?; + let data = if compressed { decode_compressed(src, width, height, bits)? } else { decode_packed(src, width, height, bits)? }; let full = Rect::new(0, 0, width, height); let level = |tg: u16| t.tag_uint(&ifd, tg).map(f64::from); let mut warnings = Vec::new(); - let black = match BLACK_LEVEL.map(level) { + // The compressed variants record a black level 15 counts below the decoded black. + let black_offset = if compressed { 15.0 } else { 0.0 }; + let black_level = |tg: u16| t.tag_uint(&ifd, tg).map(|v| f64::from(v) + black_offset); + let black = match BLACK_LEVEL.map(black_level) { [Some(r), Some(g), Some(b)] => { let v = cfa.phase(0, 0).map(|c| [r, g, b][usize::from(c).min(2)] as f32); BlackLevels { rows: 2, cols: 2, values: v.to_vec(), delta_h: Vec::new(), delta_v: Vec::new() } @@ -204,4 +331,76 @@ mod tests { assert_eq!(&out[..9], &[0x3FFF; 9]); assert_eq!(&out[9..], &[0; 3]); } + + #[test] + fn compressed_bits_walk_down_pages() { + // A fresh cursor reads the word at 0x2007 first (the top of the first + // page under the compressed split), then walks down. + let mut buf = vec![0u8; 0x4000]; + buf[0x2007] = 0xAC; + buf[0x2008] = 0x03; + buf[0x2006] = 0x11; + buf[0x2017] = 0x55; + // A 4-bit field starts in the high nibble of the top word. + let mut b = Bits::new(&buf, 0); + assert_eq!(b.get(4), 0xA); + let mut b = Bits::new(&buf, 0); + assert_eq!(b.get(8), 0xAC); + assert_eq!(b.get(8), 0x11); + // 16 bytes later the cursor wraps to the end of the next 16-byte run. + for _ in 0..14 { + b.get(8); + } + assert_eq!(b.get(8), 0x55); + } + + #[test] + fn compressed_group_walks_predictors() { + // One 14-pixel group: both parities start absolute, a delta of 0x80 + // keeps their value, and the scale selector is written before the + // delta of every third pixel. The test writer mirrors `Bits` so the + // fields land where the decoder reads them. + let mut buf = vec![0u8; 0x4000]; + { + let mut w = TestWriter { buf: &mut buf, pos: 0, nbits: 0 }; + w.put(0x10, 8); + w.put(0x5, 4); + w.put(0x2A, 8); + w.put(0xA, 4); + for i in 2..14 { + if i % 3 == 2 { + w.put(0, 2); + } + w.put(0x80, 8); + } + } + let mut out = [0u16; 14]; + compressed_row(&buf, 16, 0, &mut out); + assert_eq!(out, [0x105, 0x2AA, 0x105, 0x2AA, 0x105, 0x2AA, 0x105, 0x2AA, 0x105, 0x2AA, 0x105, 0x2AA, 0x105, 0x2AA]); + } + + /// Mirror of [`Bits`] for unit tests: deposits fields where the reader + /// will pick them up. + struct TestWriter<'a> { + buf: &'a mut [u8], + pos: usize, + nbits: u32, + } + + impl TestWriter<'_> { + fn put(&mut self, v: u32, num: u32) { + if num > self.nbits { + self.nbits += PAGE as u32 * 8; + self.pos += PAGE; + } + let mut at = ((self.nbits - num) >> 3) as usize ^ 0x3FF0; + at = (at + PAGE - COMPRESSED_SPLIT) % PAGE; + let at = at + self.pos - PAGE; + self.nbits -= num; + let cur = u32::from(self.buf[at]) | (u32::from(self.buf[at + 1]) << 8); + let cur = cur | ((v & ((1 << num) - 1)) << (self.nbits & 7)); + self.buf[at] = cur as u8; + self.buf[at + 1] = (cur >> 8) as u8; + } + } } diff --git a/crates/raw/src/testgen.rs b/crates/raw/src/testgen.rs index 823cd97ae63..2de64ae4bb6 100644 --- a/crates/raw/src/testgen.rs +++ b/crates/raw/src/testgen.rs @@ -1113,6 +1113,136 @@ pub fn rw2(width: usize, height: usize, data: &[u16], bits: u32) -> Vec { b } +/// Byte split of the vendor-compressed (RawFormat 3/4) stream (see `rw2.rs`). +const RW2_COMPRESSED_SPLIT: usize = 0x2008; + +/// Writer for the vendor-compressed RW2 stream: mirrors the decoder's cursor +/// (page addressing, downward field order) and deposits every field where the +/// reader will pick it up. +struct V4Writer<'a> { + buf: &'a mut [u8], + base: usize, + pos: usize, + nbits: u32, +} + +impl V4Writer<'_> { + /// Consumes one byte of the row seek (the decoder reads 8-bit fields). + fn skip_byte(&mut self) { + if 8 > self.nbits { + self.nbits += (RW2_PAGE as u32) * 8; + self.pos += RW2_PAGE; + } + self.nbits -= 8; + } + + fn put(&mut self, v: u32, num: u32) { + if num > self.nbits { + self.nbits += (RW2_PAGE as u32) * 8; + self.pos += RW2_PAGE; + } + let mut at = ((self.nbits - num) >> 3) as usize ^ 0x3FF0; + at = (at + RW2_PAGE - RW2_COMPRESSED_SPLIT) % RW2_PAGE; + let at = self.base + at + self.pos - RW2_PAGE; + self.nbits -= num; + let cur = u32::from(self.buf[at]) | (u32::from(self.buf[at + 1]) << 8); + let cur = cur | ((v & ((1 << num) - 1)) << (self.nbits & 7)); + self.buf[at] = cur as u8; + self.buf[at + 1] = (cur >> 8) as u8; + } + + /// Encodes one row as 14-pixel groups: the scale selector is always 0 and + /// the two first pixels of each group are absolute (8 + 4 bits); later + /// pixels use the 8-bit delta path. Callers keep values inside 16..4096 + /// and stepping by at most ±127 between pixels of the same parity. + fn row(&mut self, pixels: &[u16]) { + for group in pixels.as_chunks::<14>().0 { + let mut pred = [0i32; 2]; + let mut nonz = [0i32; 2]; + for (i, &px) in group.iter().enumerate() { + if i % 3 == 2 { + self.put(0, 2); // sh = 0 + } + let p = i & 1; + let t = i32::from(px); + if nonz[p] == 0 { + let nz = (t >> 4) & 0xFF; + self.put(nz as u32, 8); + if nz != 0 || i > 11 { + self.put((t & 0xF) as u32, 4); + pred[p] = t; + } else { + pred[p] = 0; + } + nonz[p] = nz; + } else { + let q = (pred[p] - 0x80).max(0); + let j = t - q; + assert!((1..=0xFF).contains(&j), "{t} is not encodable from {}", pred[p]); + self.put(j as u32, 8); + pred[p] = q + j; + } + assert_eq!(pred[p], t); + } + } + } +} + +/// The vendor-compressed (RawFormat 3/4) stream of a whole image; see +/// [`V4Writer::row`] for the value constraints. +pub fn rw2_v4_pack(data: &[u16], width: usize) -> Vec { + assert_eq!(width % 14, 0); + let height = data.len() / width; + assert_eq!(data.len(), width * height); + let row_bytes = width / 14 * 16; + let mut buf = vec![0u8; row_bytes * height + 2 * RW2_PAGE]; + for (row, pixels) in data.chunks_exact(width).enumerate() { + let skip = row_bytes * row; + let mut w = V4Writer { buf: &mut buf, base: skip / RW2_PAGE * RW2_PAGE, pos: 0, nbits: 0 }; + for _ in 0..(skip % RW2_PAGE) { + w.skip_byte(); + } + w.row(pixels); + } + buf +} + +/// A synthetic Panasonic RW2 (RawFormat 4, vendor-compressed) with an RGGB +/// sensor, a 2-pixel border on every side, black levels 128 / 129 / 130 and +/// WB levels 512 / 256 / 384; see [`V4Writer::row`] for the pixel constraints. +pub fn rw2_format4(width: usize, height: usize, data: &[u16]) -> Vec { + let mut t = TiffBuilder::default(); + let raw = t.blob(rw2_v4_pack(data, width)); + let ifd0 = t.ifd(vec![ + (0x0002, Val::Short(vec![width as u16])), + (0x0003, Val::Short(vec![height as u16])), + (0x0004, Val::Short(vec![2])), + (0x0005, Val::Short(vec![2])), + (0x0006, Val::Short(vec![height as u16 - 2])), + (0x0007, Val::Short(vec![width as u16 - 2])), + (0x0009, Val::Short(vec![1])), + (0x000A, Val::Short(vec![12])), + (0x000E, Val::Short(vec![4095])), + (0x000F, Val::Short(vec![4095])), + (0x0010, Val::Short(vec![4095])), + (0x001C, Val::Short(vec![128])), + (0x001D, Val::Short(vec![129])), + (0x001E, Val::Short(vec![130])), + (0x0024, Val::Short(vec![512])), + (0x0025, Val::Short(vec![256])), + (0x0026, Val::Short(vec![384])), + (0x002D, Val::Short(vec![4])), + (0x010F, Val::Ascii("Panasonic".into())), + (0x0110, Val::Ascii("DC-Synthetic".into())), + (0x0112, Val::Short(vec![1])), + (0x0118, Val::Blobs(vec![raw])), + ]); + t.chain = vec![ifd0]; + let mut b = t.build(); + b[2..4].copy_from_slice(b"U\0"); + b +} + // ---------------------------------------------------------------- Olympus ORF /// A little-endian IFD whose value offsets are relative to `base` (the IFD diff --git a/crates/raw/tests/vendor_codecs.rs b/crates/raw/tests/vendor_codecs.rs index e210c6f31b0..2d91a322a52 100644 --- a/crates/raw/tests/vendor_codecs.rs +++ b/crates/raw/tests/vendor_codecs.rs @@ -2,7 +2,7 @@ //! Sony compressed ARW (cRAW), Panasonic RW2 (RawFormat 5) and uncompressed //! Olympus ORF (with its maker-note preview). -use photocraft_raw::testgen::{craw_block, mosaic, orf, rw2, scene, sony_craw}; +use photocraft_raw::testgen::{craw_block, mosaic, orf, rw2, rw2_format4, scene, sony_craw}; use photocraft_raw::*; const CURVE: [u16; 4] = [8000, 10400, 12900, 14100]; @@ -176,13 +176,63 @@ fn patch_ifd0_short(b: &mut [u8], tag: u16, v: u16) { } #[test] -fn rw2_compressed_formats_fall_back() { +fn rw2_format4_round_trip_is_exact() { + let (w, h) = (42, 6); + let mut data = Vec::with_capacity(w * h); + for y in 0..h { + for x in 0..w { + data.push(200 + 4 * (y as u16) + 3 * (x as u16)); + } + } + let b = rw2_format4(w, h, &data); + assert_eq!(identify(&b), Some(RawFormat::Rw2)); + let s = decode(&b, &Limits::default()).unwrap(); + assert_eq!((s.width, s.height), (w, h)); + assert_eq!(s.data, data); + assert_eq!(s.cfa.as_ref().unwrap().phase(0, 0), [0, 1, 1, 2]); + // The compressed variants record a black level 15 counts below the black + // of the decoded samples. + assert_eq!(s.black.values, vec![143.0, 144.0, 144.0, 145.0]); + assert_eq!(s.white, [4095.0; 3]); + assert_eq!(s.camera_wb, Some([2.0, 1.0, 1.5])); + assert_eq!(s.crop, Rect::new(2, 2, w - 4, h - 4)); + assert!(s.warnings.is_empty(), "{:?}", s.warnings); + let d = develop_sensor(&s, &DevelopOptions::default()).unwrap(); + assert_eq!((d.width as usize, d.height as usize), (w - 4, h - 4)); +} + +#[test] +fn rw2_format4_crosses_pages() { + // 16 bytes a row: 1100 rows walk past several 0x4000-byte page boundaries. + let (w, h) = (14, 1100); + let mut data = Vec::with_capacity(w * h); + for y in 0..h { + for x in 0..w { + data.push(300 + 16 * ((y % 8) as u16) + 3 * (x as u16)); + } + } + let b = rw2_format4(w, h, &data); + let s = decode(&b, &Limits::default()).unwrap(); + assert_eq!(s.data, data); +} + +#[test] +fn rw2_format4_rejects_other_block_widths() { + let (w, h) = (42, 2); + let data: Vec = (0..w * h).map(|i| 200 + (i % 14) as u16 * 3).collect(); + let mut b = rw2_format4(w, h, &data); + patch_ifd0_short(&mut b, 0x0002, 41); + assert!(matches!(decode(&b, &Limits::default()), Err(RawError::Unsupported(_)))); +} + +#[test] +fn rw2_undecoded_formats_fall_back() { let data = vec![200u16; 20 * 4]; let mut b = rw2(20, 4, &data, 12); assert!(decode(&b, &Limits::default()).is_ok()); - patch_ifd0_short(&mut b, 0x002D, 4); + patch_ifd0_short(&mut b, 0x002D, 6); match decode(&b, &Limits::default()) { - Err(RawError::Unsupported(m)) => assert!(m.contains("raw format 4"), "{m}"), + Err(RawError::Unsupported(m)) => assert!(m.contains("raw format 6"), "{m}"), other => panic!("expected unsupported, got {other:?}"), } // Width not made of whole blocks. From b41e34c20fcff1e527a41025937a1a96cdd25016 Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 19:24:17 +0200 Subject: [PATCH 43/47] Rulers: drag the zero point from the ruler corner, double-click resets it (#2955) (#3505) Drag out of the rulers' upper-left corner to move the zero point: cross hairs mark where it lands, Shift snaps it to the ruler ticks and View > Snap To targets apply otherwise; dropping it back on the corner cancels. Double-clicking the corner puts it back at the image's top-left. The rulers then read 0 at the zero point (negative left of and above it) and the Info panel's X/Y measure from it. The zero point is a document field (`ruler_origin`, document pixels), saved in .pcraft with `#[serde(default)]` so older files open at the top-left. It is set by two undoable engine commands, `view.setRulerOrigin {x, y}` and `view.resetRulerOrigin`, so agents can drive it. It is not written to PSD: the public PSD spec documents no resource for it. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/doc/src/lib.rs | 5 + crates/engine/src/edit_cmds.rs | 60 ++++++ crates/engine/src/layer_multi_cmds.rs | 1 + crates/format/src/convert.rs | 2 + crates/format/src/manifest.rs | 3 + crates/format/tests/common/mod.rs | 1 + crates/ui-egui/src/panels.rs | 7 +- crates/ui-egui/src/rulers.rs | 296 +++++++++++++++++++++++--- 8 files changed, 339 insertions(+), 36 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 3850ba0cb50..c4f7f523d15 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -865,6 +865,10 @@ pub struct Document { pub layers: Vec, pub channels: Vec, pub guides: Guides, + /// The rulers' zero point in document pixels (`[x, y]`), dragged from the ruler corner. The + /// rulers, the Info panel and other position readouts measure from it; `[0, 0]` (the + /// image's top-left) is the default. + pub ruler_origin: [f64; 2], /// Active selection as a grayscale coverage surface (None = no selection). pub selection: Option, pub metadata: Metadata, @@ -952,6 +956,7 @@ impl Document { layers: Vec::new(), channels: Vec::new(), guides: Guides::default(), + ruler_origin: [0.0, 0.0], selection: None, metadata: Metadata::default(), global_light: GlobalLight::default(), diff --git a/crates/engine/src/edit_cmds.rs b/crates/engine/src/edit_cmds.rs index 6bf834ae14f..f0541136ade 100644 --- a/crates/engine/src/edit_cmds.rs +++ b/crates/engine/src/edit_cmds.rs @@ -881,6 +881,32 @@ fn guide_cmd(s: &mut Session, p: &Value, op: &str) -> Result { Ok(Value::Null) } +/// Farthest the ruler zero point may sit from the image, in document pixels. +const MAX_RULER_ORIGIN: f64 = 1.0e7; + +/// Move the rulers' zero point (`view.setRulerOrigin`), or put it back at the image's top-left +/// (`view.resetRulerOrigin`, `p` = None). Undoable like the guides. +fn ruler_origin_cmd(s: &mut Session, p: Option<&Value>) -> Result { + let origin = match p { + None => [0.0, 0.0], + Some(p) => { + let axis = |k: &str| { + p.get(k) + .and_then(Value::as_f64) + .filter(|v| v.is_finite() && v.abs() <= MAX_RULER_ORIGIN) + .ok_or_else(|| EngineError::Other(format!("`{k}` must be a number of pixels within ±{MAX_RULER_ORIGIN}"))) + }; + [axis("x")?, axis("y")?] + } + }; + let label = if p.is_some() { "Set Ruler Origin" } else { "Reset Ruler Origin" }; + s.edit(label, |doc, _| { + doc.ruler_origin = origin; + Ok(()) + })?; + Ok(json!({"x": origin[0], "y": origin[1]})) +} + pub fn specs() -> Vec { macro_rules! spec { ($id:expr, $label:expr, $menu:expr, $sc:expr, $params:expr, $enabled:expr, $run:expr) => { @@ -987,6 +1013,18 @@ pub fn specs() -> Vec { ), spec!("view.deleteGuide", "Delete Guide", &[], None, r##"{"orientation":"horizontal|vertical","index":n}"##, has_doc, |s, p| guide_cmd(s, p, "delete")), spec!("view.clearGuides", "Clear Guides", &["View"], None, "{}", has_doc, |s, p| guide_cmd(s, p, "clear")), + spec!( + "view.setRulerOrigin", + "Set Ruler Origin", + &[], + None, + r##"{"x":px,"y":px} (the rulers' zero point in document pixels; the rulers and Info panel measure from it)"##, + has_doc, + |s, p| ruler_origin_cmd(s, Some(p)) + ), + spec!("view.resetRulerOrigin", "Reset Ruler Origin", &[], None, "{} (zero point back at the image's top-left)", has_doc, |s, _| ruler_origin_cmd( + s, None + )), ] } @@ -1915,6 +1953,28 @@ mod tests { assert!(s.execute("view.moveGuide", json!({"orientation": "vertical", "index": 3, "position": 1})).is_err()); } + /// #2955: the rulers' zero point is set and reset by command, undoable, and bad params are + /// refused without touching the document. + #[test] + fn ruler_origin_is_set_reset_and_undoable() { + let mut s = session(); + assert_eq!(s.active().unwrap().doc.ruler_origin, [0.0, 0.0]); + assert_eq!(s.execute("view.setRulerOrigin", json!({"x": 40, "y": 12.5})).unwrap(), json!({"x": 40.0, "y": 12.5})); + assert_eq!(s.active().unwrap().doc.ruler_origin, [40.0, 12.5]); + s.execute("view.resetRulerOrigin", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.ruler_origin, [0.0, 0.0]); + s.undo(); + assert_eq!(s.active().unwrap().doc.ruler_origin, [40.0, 12.5]); + for bad in [json!({}), json!({"x": 1}), json!({"x": "a", "y": 2}), json!({"x": 1e300, "y": 0}), json!({"x": -2e7, "y": 0}), json!(null), json!([1, 2])] + { + assert!(s.execute("view.setRulerOrigin", bad.clone()).is_err(), "{bad} is refused"); + } + assert_eq!(s.active().unwrap().doc.ruler_origin, [40.0, 12.5]); + let mut empty = Session::new(); + assert!(empty.execute("view.setRulerOrigin", json!({"x": 1, "y": 2})).is_err()); + assert!(empty.execute("view.resetRulerOrigin", json!({})).is_err()); + } + /// The last guide added in `orientation` for `position`. fn new_guide(s: &mut Session, orientation: &str, position: Value) -> f64 { s.execute("view.newGuide", json!({"orientation": orientation, "position": position})).unwrap(); diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 8d71810dc97..21e9e2caaca 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -639,6 +639,7 @@ fn doc_pixels_equal(a: &Document, b: &Document) -> bool { layers: _, channels, guides: _, + ruler_origin: _, selection: _, metadata: _, global_light, diff --git a/crates/format/src/convert.rs b/crates/format/src/convert.rs index a26219033b5..51d6183baa7 100644 --- a/crates/format/src/convert.rs +++ b/crates/format/src/convert.rs @@ -230,6 +230,7 @@ pub(crate) fn doc_m(d: &Document, sink: &mut dyn Sink) -> DocM { layers: d.layers.iter().map(|l| layer_m(l, sink)).collect(), channels: d.channels.iter().map(|c| channel_m(c, sink)).collect(), guides: d.guides.clone(), + ruler_origin: d.ruler_origin, selection: d.selection.as_ref().map(|s| surface_m(s, sink)), metadata: MetadataM { xmp: d.metadata.xmp.clone(), @@ -628,6 +629,7 @@ impl Loader<'_> { layers, channels, guides: m.guides.clone(), + ruler_origin: m.ruler_origin, selection: self.opt_surface(&m.selection)?, metadata: md, global_light: m.global_light, diff --git a/crates/format/src/manifest.rs b/crates/format/src/manifest.rs index d76a496fb0a..82480e05c01 100644 --- a/crates/format/src/manifest.rs +++ b/crates/format/src/manifest.rs @@ -46,6 +46,9 @@ pub struct DocM { pub layers: Vec, pub channels: Vec, pub guides: Guides, + /// The rulers' zero point in document pixels (absent in older files: the top-left). + #[serde(default)] + pub ruler_origin: [f64; 2], pub selection: Option, pub metadata: MetadataM, pub global_light: GlobalLight, diff --git a/crates/format/tests/common/mod.rs b/crates/format/tests/common/mod.rs index ef840cfc80e..8d5641d9cdd 100644 --- a/crates/format/tests/common/mod.rs +++ b/crates/format/tests/common/mod.rs @@ -230,6 +230,7 @@ pub fn rich_doc(mode: ColorMode, depth: SampleType) -> Document { }); d.guides = Guides { horizontal: vec![10.0, 20.5], vertical: vec![100.25], colors: vec![GuideColor { vertical: true, position: 100.25, rgb: [0x33, 0x66, 0xff] }] }; + d.ruler_origin = [12.5, -4.25]; let mut sel = Surface::new(PixelFormat::GRAY8); scribble(&mut sel, 14, false); d.selection = Some(sel); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index cff94a840f8..733f923c8ad 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1784,7 +1784,7 @@ fn info_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { empty(ui, tl!("No document")); return; }; - let (rev, size) = (st.revision, st.doc.size); + let (rev, size, origin) = (st.revision, st.doc.size, st.doc.ruler_origin); // Positions and sizes in the ruler unit (Preferences › Units & Rulers). let units = app.session.prefs().units_and_rulers.clone(); let dpi = st.doc.resolution_dpi as f64; @@ -1833,8 +1833,9 @@ fn info_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { widgets::hairline(ui); ui.columns(2, |cols| { let dash = || "—".to_string(); - row(&mut cols[0], "X:", pos.map_or_else(dash, |p| fx(p.0 as f64))); - row(&mut cols[0], "Y:", pos.map_or_else(dash, |p| fy(p.1 as f64))); + // Measured from the rulers' zero point (#2955). + row(&mut cols[0], "X:", pos.map_or_else(dash, |p| fx(p.0 as f64 - origin[0]))); + row(&mut cols[0], "Y:", pos.map_or_else(dash, |p| fy(p.1 as f64 - origin[1]))); row(&mut cols[1], "W:", sel.map_or_else(dash, |r| fx(r.width() as f64))); row(&mut cols[1], "H:", sel.map_or_else(dash, |r| fy(r.height() as f64))); }); diff --git a/crates/ui-egui/src/rulers.rs b/crates/ui-egui/src/rulers.rs index cc427758feb..ff5f106bde0 100644 --- a/crates/ui-egui/src/rulers.rs +++ b/crates/ui-egui/src/rulers.rs @@ -237,34 +237,59 @@ pub fn finish_drag(app: &mut PhotocraftApp, d: GuideDrag) { /// the document origin as the rotated view draws it, as in Photoshop. Tilted guides then meet the /// rulers near their numbers. Measuring along the rotated axes instead squeezed the ticks into a /// few pixels (or none at 90°) and left the rest of the ruler empty (#3233). -fn ruler_xform(xf: &ViewXform) -> ViewXform { +/// +/// The returned mapping measures from the rulers' zero point `zero` (document px, #2955): its +/// document coordinates are distances from `zero`, and 0 sits where the canvas draws `zero`. +fn ruler_xform(xf: &ViewXform, zero: [f64; 2]) -> ViewXform { let level = ViewXform { rotation: 0.0, ..*xf }; - // The level view is an axis-aligned scale, so moving the origin by `shift` on screen moves - // every point by it: re-centre on the document point that sat `shift` before the centre. - let shift = xf.to_screen(0.0, 0.0) - level.to_screen(0.0, 0.0); + let (zx, zy) = (zero[0] as f32, zero[1] as f32); + // The level view is an axis-aligned scale, so moving the zero point by `shift` on screen moves + // every point by it: re-centre on the document point that sat `shift` before the centre, then + // count from the zero point. + let shift = xf.to_screen(zx, zy) - level.to_screen(zx, zy); let center = level.to_doc(xf.rect.center() - shift); + let center = [center[0] as f32 - zx, center[1] as f32 - zy]; if !(center[0].is_finite() && center[1].is_finite()) { return level; } - ViewXform { center: [center[0] as f32, center[1] as f32], ..level } + ViewXform { center, ..level } } /// Visible ruler range in ruler units, normalised to `(min, max)` so the tick loop runs for -/// flipped views too (#1814). `xf` is the canvas mapping; rotation is ignored (see -/// [`ruler_xform`]). Shared by the renderer and its tests. -fn ruler_range(xf: &ViewXform, a: Pos2, b: Pos2, axis: usize, px_per_unit: f64) -> (f64, f64) { - let rxf = ruler_xform(xf); +/// flipped views too (#1814), measured from the zero point `zero`. `xf` is the canvas mapping; +/// rotation is ignored (see [`ruler_xform`]). Shared by the renderer and its tests. +fn ruler_range(xf: &ViewXform, a: Pos2, b: Pos2, axis: usize, px_per_unit: f64, zero: [f64; 2]) -> (f64, f64) { + let rxf = ruler_xform(xf, zero); let d0 = rxf.to_doc(a)[axis]; let d1 = rxf.to_doc(b)[axis]; let (u0, u1) = (d0 / px_per_unit, d1 / px_per_unit); (u0.min(u1), u0.max(u1)) } +/// Snap a document position to the nearest ruler tick, measured from the zero point `origin` +/// (`tick` px apart). Shift-dragging the zero point lands it on a tick, as in Photoshop. +fn snap_px_to_tick(px: f64, origin: f64, tick: f64) -> f64 { + if !(tick.is_finite() && tick > 0.0) { + return px; + } + origin + ((px - origin) / tick).round() * tick +} + +/// Where a zero-point drag from the ruler corner lands for the pointer at document `d`: on the +/// nearest ruler tick (`ticks`, px per axis) with Shift, else View › Snap To targets. +fn origin_drop(app: &mut PhotocraftApp, d: [f64; 2], shift: bool, origin: [f64; 2], ticks: [f64; 2]) -> [f64; 2] { + if shift { + [snap_px_to_tick(d[0], origin[0], ticks[0]), snap_px_to_tick(d[1], origin[1], ticks[1])] + } else { + [crate::snap_ui::snap_guide(app, true, d[0]), crate::snap_ui::snap_guide(app, false, d[1])] + } +} + /// Screen coordinate along the ruler (x for the top ruler, y for the left one) of the value `u` -/// in ruler units. The inverse of [`ruler_range`]'s mapping. -fn ruler_pos(xf: &ViewXform, vertical: bool, u: f64, px_per_unit: f64) -> f32 { +/// in ruler units from the zero point `zero`. The inverse of [`ruler_range`]'s mapping. +fn ruler_pos(xf: &ViewXform, vertical: bool, u: f64, px_per_unit: f64, zero: [f64; 2]) -> f32 { let px = (u * px_per_unit) as f32; - let rxf = ruler_xform(xf); + let rxf = ruler_xform(xf, zero); if vertical { rxf.to_screen(0.0, px).y } else { rxf.to_screen(px, 0.0).x } } @@ -282,13 +307,14 @@ fn ruler_scale(app: &PhotocraftApp, xf: &ViewXform, y_axis: bool) -> (f64, f64) (px_per_unit, nice_step(60.0 / (zoom as f64 * px_per_unit).max(1e-6), unit == Unit::Pixels)) } -/// `pos` (document px) moved to the nearest tick mark the ruler draws: where a guide lands when +/// `pos` (document px) moved to the nearest tick mark the ruler draws (counted from the zero point): where a guide lands when /// Shift is held while dragging it out of a ruler, as in Photoshop (#3770). fn snap_to_tick(app: &PhotocraftApp, xf: &ViewXform, vertical: bool, pos: f64) -> f64 { // A vertical guide sits at an x position, which the top ruler measures. let (px_per_unit, step) = ruler_scale(app, xf, !vertical); - let tick = step / 10.0 * px_per_unit; - if tick.is_finite() && tick > 0.0 { (pos / tick).round() * tick } else { pos } + // Ticks count from the zero point (#2955), which a vertical guide measures along x. + let origin = app.session.active().map_or(0.0, |d| d.doc.ruler_origin[usize::from(!vertical)]); + snap_px_to_tick(pos, origin, step / 10.0 * px_per_unit) } /// The number a ruler prints at the tick `v` (in ruler units). Like Photoshop, rulers show the @@ -317,17 +343,21 @@ pub fn draw_rulers(app: &mut PhotocraftApp, ui: &mut egui::Ui, full: Rect, xf: & // Preferences › Units & Rulers: labels in the ruler unit (percent of the document's side). let unit = app.session.prefs().units_and_rulers.rulers; let whole = unit == Unit::Pixels; + // The zero point (#2955): rulers read 0 there and count the distance from it. + let origin = app.session.active().map_or([0.0, 0.0], |d| d.doc.ruler_origin); + let mut ticks = [1.0; 2]; for vertical in [false, true] { let p = p.with_clip_rect(if vertical { left } else { top }); let (px_per_unit, step) = ruler_scale(app, xf, vertical); + ticks[usize::from(vertical)] = step * px_per_unit / 10.0; let (u0, u1) = if vertical { - ruler_range(xf, left.left_top(), left.left_bottom(), 1, px_per_unit) + ruler_range(xf, left.left_top(), left.left_bottom(), 1, px_per_unit, origin) } else { - ruler_range(xf, top.left_top(), top.right_top(), 0, px_per_unit) + ruler_range(xf, top.left_top(), top.right_top(), 0, px_per_unit, origin) }; let mut v = (u0 / step).floor() * step; while v <= u1 { - let at = |u: f64| -> f32 { ruler_pos(xf, vertical, u, px_per_unit) }; + let at = |u: f64| -> f32 { ruler_pos(xf, vertical, u, px_per_unit, origin) }; let c = at(v); if vertical { p.line_segment([pos2(left.right() - RULER, c), pos2(left.right(), c)], tick); @@ -360,14 +390,15 @@ pub fn draw_rulers(app: &mut PhotocraftApp, ui: &mut egui::Ui, full: Rect, xf: & // Drag a new guide out of a ruler; right-click to switch the ruler unit. let mut chosen_unit: Option<&'static str> = None; for (r, vertical, salt) in [(top, false, "ruler-top"), (left, true, "ruler-left"), (corner, false, "ruler-corner")] { - // Guides drag out of the top/left rulers; all three sense clicks so the right-click unit - // menu opens anywhere on the ruler (the corner only needs the menu). - let sense = if salt == "ruler-corner" { Sense::click() } else { Sense::click_and_drag() }; - let resp = ui.interact(r, ui.id().with(salt), sense); - if resp.hovered() && salt != "ruler-corner" { + // Guides drag out of the top/left rulers and the zero point out of the corner; all three + // sense clicks so the right-click unit menu opens anywhere on the ruler. + let resp = ui.interact(r, ui.id().with(salt), Sense::click_and_drag()); + if salt == "ruler-corner" { + corner_origin(app, ui, &resp, &p, xf, origin, ticks); + } else if resp.hovered() { ui.ctx().set_cursor_icon(if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical }); } - if let Some(pos) = resp.interact_pointer_pos().filter(|_| resp.dragged() || resp.drag_started()) { + if let Some(pos) = resp.interact_pointer_pos().filter(|_| salt != "ruler-corner" && (resp.dragged() || resp.drag_started())) { let d = xf.to_doc(pos); let along = if vertical { d[0] } else { d[1] }; let pos = if ui.input(|i| i.modifiers.shift) { snap_to_tick(app, xf, vertical, along) } else { crate::snap_ui::snap_guide(app, vertical, along) }; @@ -390,6 +421,41 @@ pub fn draw_rulers(app: &mut PhotocraftApp, ui: &mut egui::Ui, full: Rect, xf: & let _ = Pos2::ZERO; } +/// The ruler corner: drag the zero point out of it (cross hairs mark where it lands; Shift snaps +/// to the ruler ticks), double-click it to put the zero point back at the image's top-left. +fn corner_origin(app: &mut PhotocraftApp, ui: &egui::Ui, resp: &egui::Response, p: &egui::Painter, xf: &ViewXform, origin: [f64; 2], ticks: [f64; 2]) { + let id = resp.id.with("origin-drag"); + if resp.hovered() || resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + if resp.double_clicked() { + let _ = app.run("view.resetRulerOrigin", json!({})); + return; + } + // Dropped back on the corner (or a slow click), the zero point stays where it was. + if let Some(pos) = resp.interact_pointer_pos().filter(|_| resp.dragged() || resp.drag_started()) { + if resp.rect.contains(pos) { + ui.data_mut(|d| d.remove_temp::<[f64; 2]>(id)); + return; + } + let shift = ui.input(|i| i.modifiers.shift); + let at = origin_drop(app, xf.to_doc(pos), shift, origin, ticks); + ui.data_mut(|d| d.insert_temp(id, at)); + // Cross hairs along the document axes through the new zero point, drawn like guides so + // they turn with a rotated view. + let t = Tokens::get(ui.ctx()); + for (vertical, pos) in [(true, at[0]), (false, at[1])] { + p.line_segment(guide_segment(xf, p.clip_rect(), vertical, pos), Stroke::new(1.0, t.text)); + } + } + if resp.drag_stopped() + && let Some(at) = ui.data_mut(|d| d.remove_temp::<[f64; 2]>(id)) + { + let round = |v: f64| (v * 1000.0).round() / 1000.0; + let _ = app.run("view.setRulerOrigin", json!({"x": round(at[0]), "y": round(at[1])})); + } +} + #[cfg(test)] mod tests { use super::*; @@ -433,6 +499,140 @@ mod tests { assert!(failures.is_empty(), "ruler drawing overlaps the origin corner: {failures:?}"); } + #[test] + fn shift_snaps_the_zero_point_to_ruler_ticks_from_the_current_origin() { + assert_eq!(snap_px_to_tick(47.0, 0.0, 10.0), 50.0); + assert_eq!(snap_px_to_tick(44.9, 0.0, 10.0), 40.0); + // Ticks run from the current zero point, so they sit at 3 + 10k. + assert_eq!(snap_px_to_tick(47.0, 3.0, 10.0), 43.0); + assert_eq!(snap_px_to_tick(-8.0, 3.0, 10.0), -7.0); + // A degenerate tick never panics or moves the point. + for tick in [0.0, -1.0, f64::NAN, f64::INFINITY] { + assert_eq!(snap_px_to_tick(47.5, 3.0, tick), 47.5); + } + } + + /// Rulers alone in a 400×300 frame at 100 %, centred on a 256×128 document. + fn ruler_harness(app: PhotocraftApp) -> (egui_kittest::Harness<'static, PhotocraftApp>, Rect, ViewXform) { + let full = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 300.0)); + let rect = Rect::from_min_max(full.min + vec2(RULER, RULER), full.max); + let xf = ViewXform { rect, zoom: 1.0, center: [128.0, 64.0], flip: false, rotation: 0.0, aspect: 1.0 }; + let h = egui_kittest::Harness::builder() + .with_size(vec2(400.0, 300.0)) + .with_step_dt(1.0 / 60.0) + .build_ui_state(move |ui, app| draw_rulers(app, ui, full, &xf), app); + (h, full, xf) + } + + fn press(h: &mut egui_kittest::Harness<'static, PhotocraftApp>, pos: Pos2, pressed: bool, modifiers: egui::Modifiers) { + h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }); + h.run_steps(2); + } + + fn drag_corner_to(h: &mut egui_kittest::Harness<'static, PhotocraftApp>, full: Rect, to: Pos2, modifiers: egui::Modifiers) { + let corner = Rect::from_min_size(full.min, vec2(RULER, RULER)).center(); + h.event(egui::Event::ModifiersChanged(modifiers)); + h.event(egui::Event::PointerMoved(corner)); + h.run_steps(1); + press(h, corner, true, modifiers); + for p in [corner + vec2(10.0, 10.0), to] { + h.event(egui::Event::PointerMoved(p)); + h.run_steps(2); + } + press(h, to, false, modifiers); + h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + h.run_steps(1); + } + + fn origin(h: &egui_kittest::Harness<'static, PhotocraftApp>) -> [f64; 2] { + h.state().session.active().unwrap().doc.ruler_origin + } + + /// #2955: dragging out of the ruler corner sets the zero point (cross hairs while dragging), + /// the rulers then read 0 there, Shift snaps it to a tick, and double-clicking the corner + /// resets it. + #[test] + fn dragging_the_ruler_corner_sets_the_zero_point_and_double_click_resets_it() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 256, "height": 128})).unwrap(); + app.ui.extras.snap = false; + let (mut h, full, xf) = ruler_harness(app); + h.run_steps(2); + let target = xf.to_screen(100.0, 40.0); + drag_corner_to(&mut h, full, target, egui::Modifiers::NONE); + let o = origin(&h); + assert!((o[0] - 100.0).abs() < 0.01 && (o[1] - 40.0).abs() < 0.01, "zero point lands under the pointer: {o:?}"); + // The top ruler's "0" now sits at the new zero point, "100" (a distance, unsigned as in + // Photoshop) at the image's left edge. + h.run_steps(2); + let labels: Vec<(String, f32)> = h + .output() + .shapes + .iter() + .filter_map(|s| if let egui::Shape::Text(t) = &s.shape { Some((t.galley.text().to_string(), t.pos.x)) } else { None }) + .collect(); + let near = |text: &str, x: f32| labels.iter().any(|(t, lx)| t == text && (lx - (x + 2.0)).abs() < 1.0); + assert!(near("0", target.x), "a 0 label at the zero point: {labels:?}"); + assert!(near("100", xf.to_screen(0.0, 0.0).x), "100 at the image's left edge: {labels:?}"); + + // Mid-drag, cross hairs run through the pointer across the canvas. + let corner = Rect::from_min_size(full.min, vec2(RULER, RULER)).center(); + let mid = xf.to_screen(150.0, 90.0); + press(&mut h, corner, true, egui::Modifiers::NONE); + for p in [corner + vec2(10.0, 10.0), mid] { + h.event(egui::Event::PointerMoved(p)); + h.run_steps(2); + } + let crosshair = h.output().shapes.iter().any(|s| { + matches!(s.shape, egui::Shape::LineSegment { points: [a, b], .. } if (a.x - mid.x).abs() < 0.5 && (b.x - mid.x).abs() < 0.5 && (b.y - a.y).abs() > 200.0) + }); + assert!(crosshair, "a vertical cross-hair line through the pointer while dragging"); + assert_eq!(origin(&h), o, "the zero point moves on release only"); + press(&mut h, mid, false, egui::Modifiers::NONE); + assert_eq!(origin(&h), [150.0, 90.0]); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(origin(&h), o, "setting the zero point is undoable"); + + // A drag dropped back on the corner leaves it alone. + drag_corner_to(&mut h, full, full.min + vec2(4.0, 4.0), egui::Modifiers::NONE); + assert_eq!(origin(&h), o); + + // Shift: ticks are 10 px apart at 100 %, measured from the current zero point (100, 40). + drag_corner_to(&mut h, full, xf.to_screen(123.0, 57.0), egui::Modifiers::SHIFT); + assert_eq!(origin(&h), [120.0, 60.0]); + + for _ in 0..40 { + h.step(); + } + let corner = Rect::from_min_size(full.min, vec2(RULER, RULER)).center(); + h.event(egui::Event::PointerMoved(corner)); + h.step(); + for _ in 0..2 { + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: corner, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + h.step(); + } + } + h.run_steps(2); + assert_eq!(origin(&h), [0.0, 0.0], "double-clicking the corner resets the zero point"); + } + + /// #2955: the Info panel's X/Y are measured from the rulers' zero point. + #[test] + fn info_panel_position_reads_from_the_zero_point() { + use egui_kittest::kittest::Queryable; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("view.setRulerOrigin", json!({"x": 20, "y": 5})).unwrap(); + app.hover_doc = Some([50.5, 20.5]); + let mut h = egui_kittest::Harness::builder() + .with_size(vec2(260.0, 240.0)) + .build_ui_state(|ui, app| crate::panels::dock_body(app, ui, crate::dock::Group::Navigator, 2), app); + h.run_steps(2); + assert!(h.query_by_label("30").is_some() && h.query_by_label("15").is_some(), "X 30, Y 15 from the zero point at (20, 5)"); + assert!(h.query_by_label("50").is_none(), "not the raw image x"); + } + #[test] fn percentage_grid_spacing_uses_both_canvas_dimensions() { let mut g = GuidesGridAndSlices { grid_unit: Unit::Percent, gridline_every: 20.0, subdivisions: 2, ..Default::default() }; @@ -615,23 +815,23 @@ mod tests { for rotation in [0.0f32, 30.0, 45.0, 90.0, -119.0, 180.0] { for flip in [false, true] { let xf = ViewXform { rect, zoom: 0.5, center: [1000.0, 700.0], flip, rotation, aspect: 1.0 }; - let (u0, u1) = ruler_range(&xf, a, b, 0, 1.0); + let (u0, u1) = ruler_range(&xf, a, b, 0, 1.0, [0.0; 2]); assert!((u1 - u0 - 1600.0).abs() < 1e-2, "rotation {rotation}: top ruler covers {u0}..{u1}"); let (first, last) = if flip { (u1, u0) } else { (u0, u1) }; - assert!((ruler_pos(&xf, false, first, 1.0) - a.x).abs() < 1e-2, "rotation {rotation}: first tick"); - assert!((ruler_pos(&xf, false, last, 1.0) - b.x).abs() < 1e-2, "rotation {rotation}: last tick"); + assert!((ruler_pos(&xf, false, first, 1.0, [0.0; 2]) - a.x).abs() < 1e-2, "rotation {rotation}: first tick"); + assert!((ruler_pos(&xf, false, last, 1.0, [0.0; 2]) - b.x).abs() < 1e-2, "rotation {rotation}: last tick"); // 0 at the projected origin, 500 document px = 500 × zoom screen px further on. let origin = xf.to_screen(0.0, 0.0); - assert!((ruler_pos(&xf, false, 0.0, 1.0) - origin.x).abs() < 1e-2, "rotation {rotation}: top ruler 0"); - assert!((ruler_pos(&xf, true, 0.0, 1.0) - origin.y).abs() < 1e-2, "rotation {rotation}: left ruler 0"); + assert!((ruler_pos(&xf, false, 0.0, 1.0, [0.0; 2]) - origin.x).abs() < 1e-2, "rotation {rotation}: top ruler 0"); + assert!((ruler_pos(&xf, true, 0.0, 1.0, [0.0; 2]) - origin.y).abs() < 1e-2, "rotation {rotation}: left ruler 0"); let dir = if flip { -1.0 } else { 1.0 }; - assert!((ruler_pos(&xf, false, 500.0, 1.0) - origin.x - dir * 250.0).abs() < 1e-2, "rotation {rotation}: top scale"); - assert!((ruler_pos(&xf, true, 500.0, 1.0) - origin.y - 250.0).abs() < 1e-2, "rotation {rotation}: left scale"); + assert!((ruler_pos(&xf, false, 500.0, 1.0, [0.0; 2]) - origin.x - dir * 250.0).abs() < 1e-2, "rotation {rotation}: top scale"); + assert!((ruler_pos(&xf, true, 500.0, 1.0, [0.0; 2]) - origin.y - 250.0).abs() < 1e-2, "rotation {rotation}: left scale"); } } // Distances left of / above the origin read without a minus sign, as in Photoshop. let xf = ViewXform { rect, zoom: 0.5, center: [0.0, 0.0], flip: false, rotation: 0.0, aspect: 1.0 }; - let (u0, u1) = ruler_range(&xf, a, b, 0, 1.0); + let (u0, u1) = ruler_range(&xf, a, b, 0, 1.0, [0.0; 2]); assert!(u0 < -500.0 && u1 > 500.0, "the view spans the origin: {u0}..{u1}"); for (step, whole) in [(100.0, true), (100.0, false), (0.25, false)] { let mut v = (u0 / step).floor() * step; @@ -645,6 +845,31 @@ mod tests { assert_eq!(ruler_label(-0.25, 0.25, false), "0.25"); } + /// #2955 with #3233: a zero point moved off the image's top-left reads 0 where the canvas draws + /// it, also on a rotated or flipped view, and the ruler keeps its full length and scale. + #[test] + fn rulers_count_from_the_zero_point_on_a_rotated_view() { + let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)); + let (a, b) = (rect.left_top(), rect.right_top()); + let zero = [300.0, 120.0]; + for rotation in [0.0f32, 30.0, 90.0, -119.0] { + for flip in [false, true] { + let xf = ViewXform { rect, zoom: 0.5, center: [1000.0, 700.0], flip, rotation, aspect: 1.0 }; + let at = xf.to_screen(zero[0] as f32, zero[1] as f32); + assert!((ruler_pos(&xf, false, 0.0, 1.0, zero) - at.x).abs() < 1e-2, "rotation {rotation}: top ruler 0"); + assert!((ruler_pos(&xf, true, 0.0, 1.0, zero) - at.y).abs() < 1e-2, "rotation {rotation}: left ruler 0"); + let (u0, u1) = ruler_range(&xf, a, b, 0, 1.0, zero); + assert!((u1 - u0 - 1600.0).abs() < 1e-2, "rotation {rotation}: top ruler covers {u0}..{u1}"); + let dir = if flip { -1.0 } else { 1.0 }; + assert!((ruler_pos(&xf, false, 500.0, 1.0, zero) - at.x - dir * 250.0).abs() < 1e-2, "rotation {rotation}: top scale"); + } + } + // Unrotated, ruler value u sits over document x = zero + u. + let xf = ViewXform { rect, zoom: 0.5, center: [1000.0, 700.0], flip: false, rotation: 0.0, aspect: 1.0 }; + assert!((ruler_pos(&xf, false, -300.0, 1.0, zero) - xf.to_screen(0.0, 0.0).x).abs() < 1e-2); + assert!((ruler_pos(&xf, true, 80.0, 1.0, zero) - xf.to_screen(0.0, 200.0).y).abs() < 1e-2); + } + /// #3277: guides belong to the document, so a rotated view draws them along the document /// line; unrotated they still span the whole clip rect. #[test] @@ -674,7 +899,7 @@ mod tests { let top = Rect::from_min_size(rect.min, vec2(rect.width(), RULER)); for flip in [false, true] { let xf = ViewXform { rect, zoom: 1.0, center: [400.0, 300.0], flip, rotation: 0.0, aspect: 1.0 }; - let (u0, u1) = ruler_range(&xf, top.left_top(), top.right_top(), 0, 1.0); + let (u0, u1) = ruler_range(&xf, top.left_top(), top.right_top(), 0, 1.0, [0.0; 2]); assert!(u0 <= u1, "flip={flip}: u0 ({u0}) must be <= u1 ({u1})"); assert!(u1 - u0 > 0.0, "flip={flip}: range is empty"); } @@ -717,5 +942,10 @@ mod tests { app.run("prefs.set", json!({"path": "unitsAndRulers.rulers", "value": "percent"})).unwrap(); assert!((snap_to_tick(&app, &xf, true, 37.0) - 38.4).abs() < 1e-9); assert!((snap_to_tick(&app, &xf, false, 37.0) - 38.4).abs() < 1e-9); + // Ticks count from the zero point (#2955): a vertical guide follows the x origin only. + let mut app = new_app(); + app.run("view.setRulerOrigin", json!({"x": 3, "y": 0})).unwrap(); + assert_eq!(snap_to_tick(&app, &xf, true, 37.0), 33.0); + assert_eq!(snap_to_tick(&app, &xf, false, 37.0), 40.0); } } From 5064d80b1e2d5cebaa32362d6bee6285c186b391 Mon Sep 17 00:00:00 2001 From: Gert Pellin - GPE Date: Tue, 13 Oct 2026 19:24:27 +0200 Subject: [PATCH 44/47] TIFF: write and read EXIF as the file's own Exif and GPS directories (#4876) A flat TIFF dropped EXIF on export ("EXIF not supported") and ignored it on import. TIFF is EXIF's own container: IFD0 points to the Exif (34665) and GPS (34853) directories and holds the descriptive tags. - Export: the EXIF block's Exif and GPS directories are written as directories outside the page chain, IFD0 points to them, and IFD0's descriptive tags (ImageDescription, Make, Model, Software, DateTime, Artist, Copyright) are added unless the text metadata already wrote them. Values are copied as bytes, reordered to the file's byte order, so unknown tags survive. - Import: a classic TIFF with an Exif or GPS directory gets an EXIF block built from them and IFD0's descriptive tags. - TIFF's format caps now include EXIF. Layered TIFFs keep EXIF as resource 1058 as before (read back unchanged); the layered importer already preferred the TIFF's own EXIF when there is one. exiftool and Photoshop 2025 read the Exif and GPS tags of such a TIFF. Fixes #4875 --- crates/codecs/src/codecs/mod.rs | 1 + crates/codecs/src/codecs/tiff.rs | 35 ++++- crates/codecs/src/codecs/tiff_exif.rs | 195 ++++++++++++++++++++++++++ crates/codecs/src/format.rs | 2 +- crates/codecs/tests/metadata.rs | 6 +- crates/codecs/tests/tiff_exif.rs | 168 ++++++++++++++++++++++ crates/io/src/tiff_layers.rs | 11 +- crates/io/tests/tiff_layers.rs | 10 +- 8 files changed, 417 insertions(+), 11 deletions(-) create mode 100644 crates/codecs/src/codecs/tiff_exif.rs create mode 100644 crates/codecs/tests/tiff_exif.rs diff --git a/crates/codecs/src/codecs/mod.rs b/crates/codecs/src/codecs/mod.rs index b0b3ca46e91..afb7f967917 100644 --- a/crates/codecs/src/codecs/mod.rs +++ b/crates/codecs/src/codecs/mod.rs @@ -11,6 +11,7 @@ pub(crate) mod pcx; pub(crate) mod png; pub(crate) mod pnm; pub(crate) mod tiff; +pub(crate) mod tiff_exif; pub(crate) mod tiff_ifd; pub(crate) mod via_image; pub(crate) mod vp8; diff --git a/crates/codecs/src/codecs/tiff.rs b/crates/codecs/src/codecs/tiff.rs index b2b315e2636..5e09a68ece0 100644 --- a/crates/codecs/src/codecs/tiff.rs +++ b/crates/codecs/src/codecs/tiff.rs @@ -35,6 +35,7 @@ use tiff::encoder::colortype::{self, ColorType as TiffColorType}; use tiff::encoder::{Rational, TiffEncoder, TiffKind, TiffKindBig, TiffKindStandard, TiffValue}; use tiff::tags::{PhotometricInterpretation, ResolutionUnit, SampleFormat, Tag, Type}; +use super::tiff_exif::{self, ExifDirs}; use super::tiff_ifd::{self as ifd, File, Ifd}; use crate::Format; use crate::error::CodecError; @@ -297,6 +298,13 @@ fn metadata(f: &File<'_>, dir: &Ifd, icc: &mut Option>) -> Metadata { if dir.at == f.first_ifd { photoshop_tags(f.b) } else { (f.tag_bytes(dir, TAG_PHOTOSHOP), f.tag_bytes(dir, TAG_IMAGE_SOURCE_DATA)) }; meta.photoshop_resources = resources.map(<[u8]>::to_vec); meta.photoshop_layers = layers.map(<[u8]>::to_vec); + // EXIF: the Exif and GPS directories IFD0 points to, with IFD0's descriptive tags, as an + // EXIF block (the other pages are reduced copies or layers of the same image). + if dir.at == f.first_ifd + && let Some(e) = tiff_exif::read(f.b).filter(ExifDirs::has_directories) + { + meta.exif = Some(tiff_exif::block(&e)); + } meta } @@ -1016,6 +1024,8 @@ custom_colortype!(CmykA32F, f32, PI::CMYK, &[32; 5], &[SampleFormat::IEEEFP; 5], struct TagSet<'a> { icc: Option<&'a [u8]>, + /// The EXIF block's directories and IFD0 tags, written as the TIFF's own. + exif: Option, xmp: Option<&'a [u8]>, dpi: Option<(f32, f32)>, text: Vec<(Tag, &'a str)>, @@ -1032,6 +1042,16 @@ where [C::Inner]: TiffValue, K: TiffKind, { + // The Exif and GPS directories go before the image, outside its chain; IFD0 points to them. + let mut sub = |e: &ExifDirs, fields: &[tiff_exif::Field]| (!fields.is_empty()).then(|| tiff_exif::write_directory(enc, fields, e.le)).transpose(); + let exif_at = match &tags.exif { + Some(e) => sub(e, &e.exif)?, + None => None, + }; + let gps_at = match &tags.exif { + Some(e) => sub(e, &e.gps)?, + None => None, + }; let mut im = enc.new_image::(w, h)?; if let Some((x, y)) = tags.dpi { im.resolution_unit(ResolutionUnit::Inch); @@ -1052,6 +1072,17 @@ where for (tag, s) in &tags.text { d.write_tag(*tag, *s)?; } + if let Some(e) = &tags.exif { + // The EXIF block's descriptive IFD0 tags, unless the text metadata already wrote them. + let ifd0: Vec = e.ifd0.iter().filter(|f| !tags.text.iter().any(|(t, _)| t.to_u16() == f.tag)).cloned().collect(); + tiff_exif::write_fields(d, &ifd0, e.le)?; + } + if let Some(at) = exif_at { + d.write_tag(Tag::Unknown(tiff_exif::TAG_EXIF_IFD), at)?; + } + if let Some(at) = gps_at { + d.write_tag(Tag::Unknown(tiff_exif::TAG_GPS_IFD), at)?; + } if let Some(r) = tags.photoshop_resources { d.write_tag(Tag::Unknown(TAG_PHOTOSHOP), r)?; } @@ -1099,7 +1130,8 @@ const CLASSIC_LIMIT: u64 = u32::MAX as u64; /// room for the directory and strip tables. fn estimated_size(img: &Image, tags: &TagSet<'_>) -> u64 { let payloads = [tags.icc, tags.xmp, tags.photoshop_resources, tags.photoshop_layers].iter().flatten().map(|p| p.len() as u64).sum::(); - let text = tags.text.iter().map(|(_, s)| s.len() as u64).sum::(); + let text = tags.text.iter().map(|(_, s)| s.len() as u64).sum::() + + tags.exif.iter().flat_map(|e| e.ifd0.iter().chain(&e.exif).chain(&e.gps)).map(|f| f.value.len() as u64 + 12).sum::(); // Strip tables: one offset and one count per strip of about 1 MB. let tables = (img.data().len() as u64 / (1 << 20) + 1) * 16; (img.data().len() as u64).saturating_add(payloads).saturating_add(text).saturating_add(tables).saturating_add(1 << 20) @@ -1132,6 +1164,7 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result 0.0 && d.1 > 0.0) } else { None }, text, diff --git a/crates/codecs/src/codecs/tiff_exif.rs b/crates/codecs/src/codecs/tiff_exif.rs new file mode 100644 index 00000000000..d07f555f30c --- /dev/null +++ b/crates/codecs/src/codecs/tiff_exif.rs @@ -0,0 +1,195 @@ +//! EXIF in a TIFF file: the Exif (34665) and GPS (34853) directories that IFD0 points to, plus +//! IFD0's descriptive tags (make, model, software, artist, …), the way cameras and Photoshop +//! write them. +//! +//! An EXIF block (`Metadata::exif`) is itself a small TIFF stream, so both directions go through +//! the same reader ([`super::tiff_ifd::File`]): reading a TIFF builds a block from the file's own +//! directories, writing one copies the block's directories into the file. Values are copied as +//! bytes (re-ordered when the byte orders differ), so tags PhotoCraft doesn't know survive. +//! Classic TIFF only on the reading side: an EXIF block has 4-byte offsets. + +use tiff::encoder::{DirectoryEncoder, TiffEncoder, TiffKind}; +use tiff::tags::{Tag, Type}; +use tiff::{Directory, TiffResult}; + +use super::tiff_ifd::{File, Ifd}; + +/// IFD0's pointer to the Exif directory. +pub(crate) const TAG_EXIF_IFD: u16 = 34665; +/// IFD0's pointer to the GPS directory. +pub(crate) const TAG_GPS_IFD: u16 = 34853; +/// The Exif directory's pointer to the Interoperability directory (not carried: it holds only +/// the interoperability index, and a copied pointer would point nowhere). +const TAG_INTEROP_IFD: u16 = 40965; +/// IFD0 tags an EXIF block carries next to its directories: ImageDescription, Make, Model, +/// Software, DateTime, Artist, Copyright. Structure, resolution and orientation belong to the +/// image and are written from it. +const IFD0_TAGS: [u16; 7] = [270, 271, 272, 305, 306, 315, 33432]; +/// At most this many entries are copied from one directory. +const MAX_FIELDS: usize = 1024; + +/// One entry with its value bytes in the byte order of the stream it came from. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Field { + pub tag: u16, + pub ty: u16, + pub count: u32, + pub value: Vec, +} + +/// The EXIF parts of a TIFF stream, in its byte order (`le`). +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct ExifDirs { + pub le: bool, + pub ifd0: Vec, + pub exif: Vec, + pub gps: Vec, +} + +impl ExifDirs { + /// Whether there is an Exif or GPS directory (IFD0's descriptive tags alone are not EXIF). + pub fn has_directories(&self) -> bool { + !self.exif.is_empty() || !self.gps.is_empty() + } +} + +fn fields(f: &File<'_>, ifd: &Ifd, keep: impl Fn(u16) -> bool) -> Vec { + ifd.entries + .iter() + .filter(|e| keep(e.tag) && !matches!(e.tag, TAG_EXIF_IFD | TAG_GPS_IFD | TAG_INTEROP_IFD)) + .filter_map(|e| Some(Field { tag: e.tag, ty: e.ty, count: u32::try_from(e.count).ok()?, value: f.data(e)?.to_vec() })) + .take(MAX_FIELDS) + .collect() +} + +fn sub(f: &File<'_>, ifd: &Ifd, tag: u16) -> Option { + let at = f.tag_uint(ifd, tag)?; + (at != ifd.at).then(|| f.ifd(at)).flatten() +} + +/// The EXIF parts of the classic TIFF stream `b` (a TIFF file, or an EXIF block without its +/// `Exif\0\0` prefix): IFD0's descriptive tags and the Exif and GPS directories. +pub(crate) fn read(b: &[u8]) -> Option { + let f = File::parse(b)?; + if f.big { + return None; + } + let ifd0 = f.ifd(f.first_ifd)?; + let dir = |tag: u16| sub(&f, &ifd0, tag).map(|d| fields(&f, &d, |_| true)).unwrap_or_default(); + Some(ExifDirs { le: f.le, ifd0: fields(&f, &ifd0, |t| IFD0_TAGS.contains(&t)), exif: dir(TAG_EXIF_IFD), gps: dir(TAG_GPS_IFD) }) +} + +/// Bytes per element whose order a byte-order change reverses: a RATIONAL is two LONGs. +fn element(ty: u16) -> usize { + match ty { + 3 | 8 => 2, + 4 | 5 | 9 | 10 | 11 | 13 => 4, + 12 | 16 | 17 | 18 => 8, + _ => 1, + } +} + +/// `value` of type `ty` in byte order `to_le`, from `from_le`. +pub(crate) fn reorder(ty: u16, value: &[u8], from_le: bool, to_le: bool) -> Vec { + let n = element(ty); + if from_le == to_le || n == 1 { + return value.to_vec(); + } + value.chunks(n).flat_map(|c| c.iter().rev().copied()).collect() +} + +/// An EXIF block: a classic TIFF stream in `dirs.le` order whose IFD0 holds `dirs.ifd0` and +/// points to the Exif and GPS directories. +pub(crate) fn block(dirs: &ExifDirs) -> Vec { + let le = dirs.le; + let u16b = |v: u16| if le { v.to_le_bytes() } else { v.to_be_bytes() }; + let u32b = |v: u32| if le { v.to_le_bytes() } else { v.to_be_bytes() }; + let mut ifd0: Vec = dirs.ifd0.clone(); + // Placeholders for the pointers; their offsets are known once the layout is. + let pointer = |tag: u16| Field { tag, ty: 4, count: 1, value: vec![0; 4] }; + if !dirs.exif.is_empty() { + ifd0.push(pointer(TAG_EXIF_IFD)); + } + if !dirs.gps.is_empty() { + ifd0.push(pointer(TAG_GPS_IFD)); + } + // Layout: header, then each directory followed by its out-of-line values. + let size = |fs: &[Field]| 2 + 12 * fs.len() + 4 + fs.iter().filter(|f| f.value.len() > 4).map(|f| f.value.len().next_multiple_of(2)).sum::(); + let at0 = 8; + let at_exif = at0 + size(&ifd0); + let at_gps = at_exif + if dirs.exif.is_empty() { 0 } else { size(&dirs.exif) }; + for f in &mut ifd0 { + match f.tag { + TAG_EXIF_IFD => f.value = u32b(at_exif as u32).to_vec(), + TAG_GPS_IFD => f.value = u32b(at_gps as u32).to_vec(), + _ => {} + } + } + let mut out = Vec::new(); + out.extend_from_slice(if le { b"II" } else { b"MM" }); + out.extend_from_slice(&u16b(42)); + out.extend_from_slice(&u32b(at0 as u32)); + let write_dir = |out: &mut Vec, fs: &[Field]| { + let mut fs = fs.to_vec(); + fs.sort_by_key(|f| f.tag); + let start = out.len(); + let mut extra = start + 2 + 12 * fs.len() + 4; + let mut values = Vec::new(); + out.extend_from_slice(&u16b(fs.len() as u16)); + for f in &fs { + out.extend_from_slice(&u16b(f.tag)); + out.extend_from_slice(&u16b(f.ty)); + out.extend_from_slice(&u32b(f.count)); + if f.value.len() <= 4 { + let mut inline = f.value.clone(); + inline.resize(4, 0); + out.extend_from_slice(&inline); + } else { + out.extend_from_slice(&u32b(extra as u32)); + values.extend_from_slice(&f.value); + if f.value.len() % 2 == 1 { + values.push(0); + } + extra += f.value.len().next_multiple_of(2); + } + } + out.extend_from_slice(&u32b(0)); + out.extend_from_slice(&values); + }; + write_dir(&mut out, &ifd0); + if !dirs.exif.is_empty() { + write_dir(&mut out, &dirs.exif); + } + if !dirs.gps.is_empty() { + write_dir(&mut out, &dirs.gps); + } + out +} + +/// Writes `fields` (in byte order `from_le`) into a directory of `enc` that is not part of the +/// page chain, and returns its offset for the pointer tag. +pub(crate) fn write_directory( + enc: &mut TiffEncoder, + fields: &[Field], + from_le: bool, +) -> TiffResult { + let mut sub = enc.extra_directory()?; + write_fields(&mut sub, fields, from_le)?; + Ok(sub.finish_with_offsets()?.offset) +} + +/// Adds `fields` (in byte order `from_le`) to directory `d`, in the file's (native) byte order. +pub(crate) fn write_fields( + d: &mut DirectoryEncoder<'_, W, K>, + fields: &[Field], + from_le: bool, +) -> TiffResult<()> { + let mut dir = Directory::empty(); + for f in fields { + let Some(ty) = Type::from_u16(f.ty) else { continue }; + let entry = d.write_entry_bytes(ty, &reorder(f.ty, &f.value, from_le, cfg!(target_endian = "little")))?; + dir.extend([(Tag::from_u16_exhaustive(f.tag), entry)]); + } + d.extend_from(&dir); + Ok(()) +} diff --git a/crates/codecs/src/format.rs b/crates/codecs/src/format.rs index dca235f80f6..d3730c08fb4 100644 --- a/crates/codecs/src/format.rs +++ b/crates/codecs/src/format.rs @@ -233,7 +233,7 @@ pub fn caps(format: Format) -> FormatCaps { FormatCaps { depths: &[S::U8, S::U16], layouts: RGB_GRAY, icc: true, exif: true, xmp: true, dpi: true, text: true, animation: true, ..base } } Format::Jpeg => FormatCaps { layouts: &[L::Gray, L::Rgb, L::Cmyk], alpha: false, icc: true, exif: true, xmp: true, dpi: true, lossy: true, ..base }, - Format::Tiff => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: ALL_LAYOUTS, icc: true, xmp: true, dpi: true, text: true, ..base }, + Format::Tiff => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: ALL_LAYOUTS, icc: true, exif: true, xmp: true, dpi: true, text: true, ..base }, Format::WebP => FormatCaps { icc: true, exif: true, xmp: true, animation: true, ..base }, Format::Gif => FormatCaps { layouts: &[L::Rgba], animation: true, lossy: true, ..base }, Format::Bmp => base, diff --git a/crates/codecs/tests/metadata.rs b/crates/codecs/tests/metadata.rs index 76ff9c93858..d8f4d80a9aa 100644 --- a/crates/codecs/tests/metadata.rs +++ b/crates/codecs/tests/metadata.rs @@ -34,7 +34,11 @@ fn check_format(format: Format) { } else { assert!(warnings.contains(&FidelityWarning::IccDropped), "{format:?}"); } - if c.exif { + if c.exif && format == Format::Tiff { + // TIFF writes the block's directories as its own and rebuilds a block from them on + // reading (tests/tiff_exif.rs); IFD0's orientation here is the image's. + assert!(!warnings.contains(&FidelityWarning::ExifDropped), "{format:?}"); + } else if c.exif { assert_eq!(back.meta.exif, img.meta.exif, "{format:?} exif"); } else { assert!(warnings.contains(&FidelityWarning::ExifDropped), "{format:?}"); diff --git a/crates/codecs/tests/tiff_exif.rs b/crates/codecs/tests/tiff_exif.rs new file mode 100644 index 00000000000..92e8c91df0a --- /dev/null +++ b/crates/codecs/tests/tiff_exif.rs @@ -0,0 +1,168 @@ +//! EXIF in TIFF: an EXIF block's Exif and GPS directories and IFD0's descriptive tags are +//! written as the TIFF's own directories (IFD0 pointing to them, as cameras and Photoshop write +//! them) and read back into a block. + +use photocraft_codecs::*; + +/// A classic TIFF stream builder for EXIF blocks: directories of (tag, type, count, value bytes). +struct Block { + le: bool, +} + +type Entry = (u16, u16, u32, Vec); + +impl Block { + fn u16(&self, v: u16) -> [u8; 2] { + if self.le { v.to_le_bytes() } else { v.to_be_bytes() } + } + fn u32(&self, v: u32) -> [u8; 4] { + if self.le { v.to_le_bytes() } else { v.to_be_bytes() } + } + fn rational(&self, n: u32, d: u32) -> Vec { + [self.u32(n), self.u32(d)].concat() + } + /// IFD0 (with pointers to `exif` and `gps` appended) followed by the two directories. + fn build(&self, mut ifd0: Vec, exif: Vec, gps: Vec) -> Vec { + let size = |d: &[Entry]| 2 + 12 * d.len() + 4 + d.iter().filter(|e| e.3.len() > 4).map(|e| e.3.len().next_multiple_of(2)).sum::(); + ifd0.push((34665, 4, 1, vec![0; 4])); + ifd0.push((34853, 4, 1, vec![0; 4])); + let at_exif = 8 + size(&ifd0); + let at_gps = at_exif + size(&exif); + let n = ifd0.len(); + ifd0[n - 2].3 = self.u32(at_exif as u32).to_vec(); + ifd0[n - 1].3 = self.u32(at_gps as u32).to_vec(); + let mut out = if self.le { b"II".to_vec() } else { b"MM".to_vec() }; + out.extend(self.u16(42)); + out.extend(self.u32(8)); + for d in [ifd0, exif, gps] { + let start = out.len(); + let mut extra = start + 2 + 12 * d.len() + 4; + let mut values = Vec::new(); + out.extend(self.u16(d.len() as u16)); + for (tag, ty, count, v) in &d { + out.extend(self.u16(*tag)); + out.extend(self.u16(*ty)); + out.extend(self.u32(*count)); + if v.len() <= 4 { + let mut inline = v.clone(); + inline.resize(4, 0); + out.extend(inline); + } else { + out.extend(self.u32(extra as u32)); + values.extend(v); + if v.len() % 2 == 1 { + values.push(0); + } + extra += v.len().next_multiple_of(2); + } + } + out.extend(self.u32(0)); + out.extend(values); + } + out + } +} + +/// The entries (tag, type, count, value bytes in the block's order) of IFD0 and of the +/// directories its tags `34665` and `34853` point to. +fn dirs(b: &[u8]) -> (bool, Vec, Vec, Vec) { + let le = &b[..2] == b"II"; + let u16 = |at: usize| if le { u16::from_le_bytes([b[at], b[at + 1]]) } else { u16::from_be_bytes([b[at], b[at + 1]]) }; + let u32 = |at: usize| { + let s = [b[at], b[at + 1], b[at + 2], b[at + 3]]; + if le { u32::from_le_bytes(s) } else { u32::from_be_bytes(s) } + }; + let size = |ty: u16| match ty { + 3 | 8 => 2, + 4 | 9 | 11 | 13 => 4, + 5 | 10 | 12 => 8, + _ => 1, + }; + let read = |at: usize| -> Vec { + (0..usize::from(u16(at))) + .map(|i| { + let e = at + 2 + 12 * i; + let (tag, ty, count) = (u16(e), u16(e + 2), u32(e + 4)); + let len = count as usize * size(ty); + let at = if len <= 4 { e + 8 } else { u32(e + 8) as usize }; + (tag, ty, count, b[at..at + len].to_vec()) + }) + .collect() + }; + let ifd0 = read(u32(4) as usize); + let sub = |tag: u16| ifd0.iter().find(|e| e.0 == tag).map(|e| read(u32_of(&e.3, le) as usize)).unwrap_or_default(); + let (exif, gps) = (sub(34665), sub(34853)); + (le, ifd0.into_iter().filter(|e| !matches!(e.0, 34665 | 34853)).collect(), exif, gps) +} + +fn u32_of(v: &[u8], le: bool) -> u32 { + let s = [v[0], v[1], v[2], v[3]]; + if le { u32::from_le_bytes(s) } else { u32::from_be_bytes(s) } +} + +/// The entries of `d` with their values in little-endian order, sorted by tag. +fn normalized(d: &[Entry], le: bool) -> Vec<(u16, u16, u32, Vec)> { + let swap = |ty: u16, v: &[u8]| -> Vec { + let n = match ty { + 3 | 8 => 2, + 4 | 5 | 9 | 10 | 11 | 13 => 4, + 12 => 8, + _ => 1, + }; + if le || n == 1 { v.to_vec() } else { v.chunks(n).flat_map(|c| c.iter().rev().copied()).collect() } + }; + let mut v: Vec<_> = d.iter().map(|e| (e.0, e.1, e.2, swap(e.1, &e.3))).collect(); + v.sort_by_key(|e| e.0); + v +} + +fn camera_block(le: bool) -> Vec { + let b = Block { le }; + let ascii = |s: &str| [s.as_bytes(), b"\0"].concat(); + b.build( + vec![(271, 2, 4, ascii("Cam")), (272, 2, 9, ascii("Model X1")), (315, 2, 16, ascii("A. Photographer")), (274, 3, 1, b.u16(1).to_vec())], + vec![ + (33434, 5, 1, b.rational(1, 125)), + (34855, 3, 1, b.u16(200).to_vec()), + (36867, 2, 20, ascii("2026:10:13 09:30:00")), + (37386, 5, 1, b.rational(50, 1)), + (37500, 7, 6, b"maker!".to_vec()), + ], + vec![(1, 2, 2, ascii("N")), (2, 5, 3, [b.rational(51, 1), b.rational(30, 1), b.rational(0, 1)].concat())], + ) +} + +#[test] +fn exif_round_trips_through_a_tiff_in_either_byte_order() { + for le in [true, false] { + let block = camera_block(le); + let mut img = Image::from_raw(4, 3, ChannelLayout::Rgb, SampleType::U8, vec![90; 36]).unwrap(); + img.meta.exif = Some(block.clone()); + let tiff = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap(); + assert!(fidelity_warnings(&img, Format::Tiff).iter().all(|w| *w != FidelityWarning::ExifDropped)); + let back = decode(&tiff).unwrap().meta.exif.expect("EXIF read back"); + let (ble, b0, bexif, bgps) = dirs(&back); + let (ole, o0, oexif, ogps) = dirs(&block); + assert_eq!(normalized(&bexif, ble), normalized(&oexif, ole), "Exif directory (le {le})"); + assert_eq!(normalized(&bgps, ble), normalized(&ogps, ole), "GPS directory (le {le})"); + // IFD0's descriptive tags come along; its orientation is the image's own. + let descriptive: Vec<_> = o0.into_iter().filter(|e| e.0 != 274).collect(); + assert_eq!(normalized(&b0, ble), normalized(&descriptive, ole), "IFD0 (le {le})"); + } +} + +#[test] +fn a_tiff_without_exif_directories_reads_no_exif() { + let mut img = Image::from_raw(2, 2, ChannelLayout::Gray, SampleType::U8, vec![0; 4]).unwrap(); + img.meta.text = vec![("Artist".into(), "A. Photographer".into())]; + let tiff = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap(); + assert_eq!(decode(&tiff).unwrap().meta.exif, None); +} + +#[test] +fn no_exif_is_written_without_embed_metadata() { + let mut img = Image::from_raw(2, 2, ChannelLayout::Gray, SampleType::U8, vec![0; 4]).unwrap(); + img.meta.exif = Some(camera_block(true)); + let tiff = encode(&img, Format::Tiff, &EncodeOptions { embed_metadata: false, ..Default::default() }).unwrap(); + assert_eq!(decode(&tiff).unwrap().meta.exif, None); +} diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 0aca0fe8826..8a5810e9225 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -213,7 +213,8 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr ctl.progress(TIFF_LAYERS_DONE); warnings.extend(w.into_iter().map(|w| format!("layer data: {w}"))); // The profile and XMP are written as TIFF tags, not as resources. EXIF stays a resource - // (1058): the TIFF codec has no EXIF directory, and the PSD importer reads it back from there. + // (1058), which the PSD importer reads back unchanged; a block rebuilt from the TIFF's own + // Exif directory keeps only the Exif, GPS and descriptive tags. let resources: Vec = file.resources.iter().filter(|r| !matches!(r.id, ids::ICC_PROFILE | ids::XMP)).cloned().collect(); let resources = resources_to_bytes(&resources)?; @@ -235,10 +236,10 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr let mut img = Image::from_raw(w, h, layout, sample, data)?; img.icc = doc.icc_profile.as_ref().map(|i| i.to_vec()); img.meta = codecs::Metadata { - // EXIF is deliberately not set here: the TIFF codec has no EXIF directory, so the tags - // above carry it as resource 1058 and the importer reads them back from there. Handing the - // codec a second copy in `meta.exif` bought nothing and made the export claim it would - // drop metadata it had in fact kept (#1545). + // EXIF is deliberately not set here: the tags above carry it as resource 1058 and the + // importer reads it back from there unchanged. A second copy in `meta.exif` (the codec's + // Exif directory) would be read back in its place, rebuilt without the thumbnail and IFD0 + // orientation (#1545). xmp: doc.metadata.xmp.clone().filter(|_| opts.xmp == crate::XmpEmbed::All), text: if opts.xmp == crate::XmpEmbed::All { doc.metadata.text.clone() } else { Vec::new() }, dpi: Some((doc.resolution_dpi, doc.resolution_dpi)), diff --git a/crates/io/tests/tiff_layers.rs b/crates/io/tests/tiff_layers.rs index 913ab2f39e9..56e51bf9f98 100644 --- a/crates/io/tests/tiff_layers.rs +++ b/crates/io/tests/tiff_layers.rs @@ -127,7 +127,7 @@ fn exif() -> Vec { } /// #1545: a layered TIFF carries EXIF as Photoshop resource 1058 and reads it back, so it must -/// not report a loss it never makes. A flat TIFF really does lose the tags and still says so. +/// not report a loss it never makes. A flat TIFF writes it as its own tags and keeps it too. #[test] fn layered_tiff_keeps_exif_without_claiming_a_loss() { let mut d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS); @@ -140,9 +140,13 @@ fn layered_tiff_keeps_exif_without_claiming_a_loss() { let kept = back.metadata.exif.as_deref().expect("EXIF survives the layered export"); assert!(kept.windows(ARTIST.len()).any(|w| w == ARTIST), "the Artist tag did not survive: {kept:?}"); - // The control from the issue: the flat path drops it for real, and keeps reporting that. + // The flat path writes the EXIF block's tags as the TIFF's own (here IFD0's Artist, which + // reads back as the document's Artist text), so it reports no loss either. let flat = tiff(&d, false); - assert!(flat.warnings.iter().any(|w| w.contains("EXIF")), "a flat TIFF really does lose EXIF: {:?}", flat.warnings); + assert!(!flat.warnings.iter().any(|w| w.contains("EXIF")), "a flat TIFF keeps EXIF: {:?}", flat.warnings); + let back = import("x.tif", &flat.bytes).unwrap().document; + let artist = std::str::from_utf8(ARTIST).unwrap().trim_end_matches('\0'); + assert!(back.metadata.text.iter().any(|(k, v)| k == "Artist" && v == artist), "{:?}", back.metadata.text); } #[test] From 2571423bb34b7dddaa826c3f3494d53e26ced60c Mon Sep 17 00:00:00 2001 From: Ikki Nishihara Date: Wed, 14 Oct 2026 02:24:37 +0900 Subject: [PATCH 45/47] fix(gpu): contain errors in unwatched compositor work (#4894) * fix(gpu): contain errors in unwatched compositor work * test(gpu): gate recovery fixtures on format capabilities --------- Co-authored-by: haranishi <223786623+haranishi@users.noreply.github.com> --- crates/gpu/src/lib.rs | 57 +++++++--- crates/gpu/tests/device_loss.rs | 178 ++++++++++++++++++++++++++++++++ 2 files changed, 222 insertions(+), 13 deletions(-) diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 730de701452..223d4ed9832 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -552,17 +552,18 @@ fn field_reaches(progs: &[fx::MapProgram]) -> HashMap { want } -/// Runs `f` inside validation and internal-error scopes and returns the first error raised. +/// Runs `f` inside GPU error scopes and returns the first error raised. /// Native wgpu reports errors synchronously; where they only arrive asynchronously (WebGPU) none /// is reported. fn first_error(device: &wgpu::Device, f: impl FnOnce() -> T) -> (T, Option) { let validation = device.push_error_scope(wgpu::ErrorFilter::Validation); let internal = device.push_error_scope(wgpu::ErrorFilter::Internal); + let memory = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory); let out = f(); let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); let mut error = None; // Scopes pop in reverse order. - for pop in [internal.pop(), validation.pop()] { + for pop in [memory.pop(), internal.pop(), validation.pop()] { if let std::task::Poll::Ready(Some(e)) = std::pin::pin!(pop).poll(&mut cx) { error.get_or_insert(e); } @@ -570,6 +571,21 @@ fn first_error(device: &wgpu::Device, f: impl FnOnce() -> T) -> (T, Option(device: &wgpu::Device, watched: bool, f: impl FnOnce() -> Result) -> Result { + // WebGPU errors arrive asynchronously; its callers still use the health callbacks. + if watched || cfg!(target_arch = "wasm32") { + return f(); + } + let (result, error) = first_error(device, f); + match error { + Some(error) => Err(Unsupported(format!("GPU work failed: {error}"))), + None => result, + } +} + impl Compositor { /// Create a compositor with the default accumulation format ([`ACC_FORMAT`]). pub fn new(device: &wgpu::Device) -> Self { @@ -902,16 +918,16 @@ impl Compositor { region: Rect, mut sink: impl FnMut(&mut wgpu::CommandEncoder, ChunkOut<'_>), ) -> Result { - let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("pc_compose") }); - let stats = match self.encode_inner(device, queue, &mut encoder, doc, region, &mut sink, true) { - Ok(stats) => stats, - Err(error) => { - self.prefix.clear(); - return Err(error); - } - }; - queue.submit([encoder.finish()]); - Ok(stats) + let result = scoped_work(device, self.health.is_some(), || { + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("pc_compose") }); + let stats = self.encode_inner(device, queue, &mut encoder, doc, region, &mut sink, true)?; + queue.submit([encoder.finish()]); + Ok(stats) + }); + if result.is_err() { + self.discard_failed_work(); + } + result } /// Like [`Self::render`] but records into `encoder` without submitting. @@ -924,7 +940,22 @@ impl Compositor { region: Rect, sink: &mut dyn FnMut(&mut wgpu::CommandEncoder, ChunkOut<'_>), ) -> Result { - self.encode_inner(device, queue, encoder, doc, region, sink, false) + let result = scoped_work(device, self.health.is_some(), || self.encode_inner(device, queue, encoder, doc, region, sink, false)); + if result.is_err() { + self.discard_failed_work(); + } + result + } + + fn discard_failed_work(&mut self) { + // Cache metadata advances while recording. A failed encoder may never execute its + // texture uploads or effect maps, so none of those cached pixels can be trusted. + self.prefix.clear(); + self.residents.clear(); + self.fx.clear(); + self.patterns.clear(); + self.temps.clear(); + self.pool.clear(); } #[allow(clippy::too_many_arguments)] diff --git a/crates/gpu/tests/device_loss.rs b/crates/gpu/tests/device_loss.rs index 5106c97a728..689a7872809 100644 --- a/crates/gpu/tests/device_loss.rs +++ b/crates/gpu/tests/device_loss.rs @@ -81,3 +81,181 @@ fn health_wait_on_a_destroyed_device_is_safe() { assert!(!health.is_ok()); assert!(!health.wait(&device, None)); } + +fn assert_unwatched_destroyed_device(encode_only: bool) { + let _lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let Some((adapter, device, queue)) = device() else { return }; + let mut comp = Compositor::try_new_with_format(&device, Compositor::preferred_acc_format(&adapter)).unwrap(); + let d = Document::with_background("synthetic", Size::new(1, 1), ColorMode::Rgb, SampleType::U8, Color::rgba(0.9, 0.9, 0.9, 1.0)); + assert!(render_to_vec(&mut comp, &device, &queue, &d, d.bounds()).is_ok()); + let mut encoder = device.create_command_encoder(&Default::default()); + device.destroy(); + let mut changed = d.clone(); + changed.layers[0].opacity = 0.4; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + if encode_only { + comp.encode(&device, &queue, &mut encoder, &changed, changed.bounds(), &mut |_, _| {}) + } else { + comp.render(&device, &queue, &changed, changed.bounds(), |_, _| {}) + } + })); + assert!(result.is_ok(), "an unwatched device failure must return Unsupported instead of panicking"); + let error = result.unwrap().expect_err("a destroyed device must not report successful GPU work"); + assert!(error.0.contains("GPU work failed"), "{error}"); +} + +#[test] +fn an_unwatched_destroyed_device_returns_an_error_without_panicking() { + assert_unwatched_destroyed_device(false); +} + +#[test] +fn an_unwatched_encode_on_a_destroyed_device_returns_an_error_without_panicking() { + assert_unwatched_destroyed_device(true); +} + +#[test] +fn loss_during_an_unwatched_render_returns_an_error_without_panicking() { + let _lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let Some((adapter, device, queue)) = device() else { return }; + let mut comp = Compositor::try_new_with_format(&device, Compositor::preferred_acc_format(&adapter)).unwrap(); + comp.set_texture_limit(256); + let d = Document::with_background("synthetic", Size::new(512, 1), ColorMode::Rgb, SampleType::U8, Color::rgba(0.9, 0.9, 0.9, 1.0)); + let mut chunks = 0; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + comp.render(&device, &queue, &d, d.bounds(), |_, _| { + chunks += 1; + if chunks == 1 { + // A driver reset can arrive after the entry-point health check too. + device.destroy(); + } + }) + })); + assert!(chunks > 0, "the injection must occur after GPU work began"); + assert!(result.is_ok(), "device loss mid-render must return Unsupported instead of panicking"); + assert!(result.unwrap().is_err(), "a partially failed render must not report success"); +} + +#[test] +fn a_captured_render_error_preserves_the_device_for_a_later_render() { + let _lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let Some((adapter, device, queue)) = device() else { return }; + let mut comp = Compositor::try_new_with_format(&device, Compositor::preferred_acc_format(&adapter)).unwrap(); + let d = Document::with_background("synthetic", Size::new(1, 1), ColorMode::Rgb, SampleType::U8, Color::rgba(0.9, 0.9, 0.9, 1.0)); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + comp.render(&device, &queue, &d, d.bounds(), |_, _| { + // Reject a buffer above the device's valid capability without actually allocating it. + let _ = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("synthetic_invalid_buffer"), + size: device.limits().max_buffer_size + 1, + usage: wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + }) + })); + assert!(result.is_ok(), "GPU validation errors must return Unsupported instead of panicking"); + let error = result.unwrap().expect_err("an invalid allocation must fail this render"); + assert!(error.0.contains("synthetic_invalid_buffer"), "{error}"); + let out = render_to_vec(&mut comp, &device, &queue, &d, d.bounds()).expect("the next fitting render keeps working"); + let expected = photocraft_compose::flatten(&d); + for (cpu, gpu) in expected.px[0].iter().zip(out[0]) { + assert!((cpu - gpu).abs() <= 1.0 / 255.0, "the healthy GPU control differs: {cpu} vs {gpu}"); + } +} + +fn assert_recovery_after_invalid_encoder(encode_only: bool) { + let _lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let Some((adapter, device, queue)) = device() else { return }; + let mut comp = Compositor::try_new_with_format(&device, Compositor::preferred_acc_format(&adapter)).unwrap(); + let mut d = doc(); + d.layers[1].effects.items = vec![photocraft_doc::Effect::default_drop_shadow()]; + // Pipeline creation errors are not proof of missing hardware capabilities: check the + // adapter and the features actually enabled on this device before allowing a skip. + let required = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING; + let features = device.features(); + for format in [wgpu::TextureFormat::R32Float, wgpu::TextureFormat::R16Float] { + let adapter_usages = adapter.get_texture_format_features(format).allowed_usages; + let guaranteed_usages = format.guaranteed_format_features(features).allowed_usages; + if !adapter_usages.contains(required) + || (!features.contains(wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES) && !guaranteed_usages.contains(required)) + { + eprintln!( + "skipping effect-map recovery test: {:?} {format:?} lacks {required:?}; adapter usages={adapter_usages:?}, guaranteed usages={guaranteed_usages:?}, device features={features:?}", + adapter.get_info().backend, + ); + return; + } + } + comp.supports(&d).expect("a capable adapter must support the recovery fixture's effect-map pipelines"); + let src = device.create_buffer(&wgpu::BufferDescriptor { label: None, size: 4, usage: wgpu::BufferUsages::COPY_SRC, mapped_at_creation: false }); + let dst = device.create_buffer(&wgpu::BufferDescriptor { label: None, size: 4, usage: wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }); + let mut injected = false; + let mut sink = |encoder: &mut wgpu::CommandEncoder, _: photocraft_gpu::ChunkOut<'_>| { + injected = true; + // Misaligned copies invalidate the encoder, discarding all recorded effect-map work. + encoder.copy_buffer_to_buffer(&src, 0, &dst, 0, 1); + if encode_only { + // encode does not finish its caller-owned encoder; force an immediate error too. + let _ = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("synthetic_invalid_buffer"), + size: device.limits().max_buffer_size + 1, + usage: wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + } + }; + let result = if encode_only { + let mut encoder = device.create_command_encoder(&Default::default()); + comp.encode(&device, &queue, &mut encoder, &d, d.bounds(), &mut sink) + } else { + comp.render(&device, &queue, &d, d.bounds(), sink) + }; + assert!(injected, "the invalid encoder injection must reach the sink"); + let error = result.expect_err("invalid encoder must fail"); + assert!(error.0.contains("GPU work failed"), "the injected GPU error must be captured: {error}"); + let recovered = render_to_vec(&mut comp, &device, &queue, &d, d.bounds()).unwrap(); + let mut fresh = Compositor::try_new_with_format(&device, Compositor::preferred_acc_format(&adapter)).unwrap(); + let expected = render_to_vec(&mut fresh, &device, &queue, &d, d.bounds()).unwrap(); + for (a, b) in recovered.iter().zip(expected.iter()) { + for (a, b) in a.iter().zip(b.iter()) { + assert!((a - b).abs() <= 1.0 / 255.0, "recovered cached pixels differ: {a} vs {b}"); + } + } +} + +#[test] +fn render_recovers_effect_maps_after_invalid_encoder() { + assert_recovery_after_invalid_encoder(false); +} + +#[test] +fn encode_recovers_effect_maps_after_invalid_encoder() { + assert_recovery_after_invalid_encoder(true); +} + +#[test] +fn loss_during_intermediate_unwatched_submissions_returns_an_error() { + let _lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let Some((adapter, device, queue)) = device() else { return }; + let mut comp = Compositor::try_new_with_format(&device, Compositor::preferred_acc_format(&adapter)).unwrap(); + comp.set_texture_limit(256); + let mut d = Document::with_background("synthetic", Size::new(2048, 1), ColorMode::Rgb, SampleType::U8, Color::rgba(0.9, 0.9, 0.9, 1.0)); + for _ in 0..300 { + let mut layer = d.layers[0].clone(); + layer.id = photocraft_doc::LayerId::fresh(); + layer.opacity = 0.5; + d.layers.push(layer); + } + let mut chunks = 0; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + comp.render(&device, &queue, &d, d.bounds(), |_, _| { + chunks += 1; + if chunks == 1 { + device.destroy(); + } + }) + })); + assert!(chunks > 0); + assert!(result.is_ok(), "intermediate submit polling must not escape as a panic"); + assert!(result.unwrap().is_err()); +} From 1bcb662828b4addfe721191b849ffe3bd1c4bde0 Mon Sep 17 00:00:00 2001 From: Elvis33LE <67311580+Elvis33LE@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:24:46 +0200 Subject: [PATCH 46/47] Deep EXR: open cropped renders on their full display-window stage (#4907) A renderer's data window is its crop of the frame; the composition lives on the whole display window (our MY27 renders: data 1782x1267 at (1109,1100) inside a 4000x3000 stage). decode_deep now validates the display window against the limits and reports it as stage + origin on DeepImage; flatten pads the composed region to the stage at its offset (transparent elsewhere) and tags the image with a DisplayWindowStage warning. Files without a usable display window open as before. Includes a cropped-deep generator and a stage/offset oracle test. --- crates/codecs/src/codecs/deep_exr.rs | 46 +++++++++- crates/codecs/src/image.rs | 12 +++ crates/codecs/tests/deep_exr.rs | 131 +++++++++++++++++++++++++++ 3 files changed, 187 insertions(+), 2 deletions(-) diff --git a/crates/codecs/src/codecs/deep_exr.rs b/crates/codecs/src/codecs/deep_exr.rs index 574e13c40a1..0726228be51 100644 --- a/crates/codecs/src/codecs/deep_exr.rs +++ b/crates/codecs/src/codecs/deep_exr.rs @@ -227,6 +227,27 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res let npixels = w.checked_mul(h).ok_or_else(|| err("the data window is too large"))?; limits.check_bytes(w as u32, h as u32, (header.channels.list.len().max(1) as u64) * 4)?; + // The display window is the full stage the renderer composed onto (a render pass's data + // window is often its crop). Open on the stage when it plausibly holds the data region: + // sane size for the limits, and the data region inside it. Otherwise fall back to the + // data window alone, as before. + let display = header.shared_attributes.display_window; + let display_size = (display.size.0.max(1) as u32, display.size.1.max(1) as u32); + let (stage, origin) = match display_size { + (sw, sh) + if sw <= 1 << 15 + && sh <= 1 << 15 + && limits.check_bytes(sw, sh, 4).is_ok() + && dw.position.0 >= display.position.0 + && dw.position.1 >= display.position.1 + && dw.position.0.saturating_add(w as i32) <= display.position.0.saturating_add(sw as i32) + && dw.position.1.saturating_add(h as i32) <= display.position.1.saturating_add(sh as i32) => + { + (Some((sw, sh)), (dw.position.0 - display.position.0, dw.position.1 - display.position.1)) + } + _ => (None, (0, 0)), + }; + let exr_types: Vec = header.channels.list.iter().map(|c| c.sample_type).collect(); let bytes_per_sample: usize = exr_types.iter().map(|t| sample_bytes(*t)).sum(); let multipart = meta.requirements.has_multiple_layers; @@ -440,7 +461,7 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res } } - Ok(DeepImage { width: w as u32, height: h as u32, channels, counts: deep_counts }) + Ok(DeepImage { width: w as u32, height: h as u32, stage, origin, channels, counts: deep_counts }) } /// Where the headers end and the offset tables begin. [`MetaData::read_from_buffered`] @@ -565,7 +586,28 @@ pub(crate) fn flatten(deep: &DeepImage) -> Result { } } } - let mut img = Image::from_f32(deep.width, deep.height, layout, &out)?; + // Open on the full display-window stage when the file declares one: the renderer's data + // window is its crop, and the composition lives on the whole stage. The composed region + // pads to the stage at its offset, transparent elsewhere (limits validated the size). + let mut img = match deep.stage { + Some((sw, sh)) => { + let mut padded = vec![0f32; sw as usize * sh as usize * nc]; + let row_len = deep.width as usize * nc; + let dx = deep.origin.0.max(0) as usize; + for row in 0..deep.height as usize { + let src = row * row_len; + let dst = (row + deep.origin.1.max(0) as usize) * sw as usize + dx; + if let (Some(d), Some(r)) = (padded.get_mut(dst * nc..dst * nc + row_len), out.get(src..src + row_len)) { + d.copy_from_slice(r); + } + } + Image::from_f32(sw, sh, layout, &padded)? + } + None => Image::from_f32(deep.width, deep.height, layout, &out)?, + }; + if let Some((sw, sh)) = deep.stage { + img.warnings.push(DecodeWarning::DisplayWindowStage { stage: (sw, sh), origin: deep.origin }); + } img.warnings.push(DecodeWarning::DeepFlattened { format: F, max_samples_per_pixel: max_samples }); Ok(img) } diff --git a/crates/codecs/src/image.rs b/crates/codecs/src/image.rs index c1f9d7b75fb..db59d4fa6be 100644 --- a/crates/codecs/src/image.rs +++ b/crates/codecs/src/image.rs @@ -150,6 +150,8 @@ pub enum DecodeWarning { MorePages { total: Option }, /// Only one part of a multi-part file was decoded; `total` counts every part when known. MoreParts { total: Option }, + /// The image opened on the full `display_window` stage; the data region sits at `origin`. + DisplayWindowStage { stage: (u32, u32), origin: (i32, i32) }, /// The image data ends early (truncated or damaged file); the decoder filled in the rest /// (a baseline JPEG's missing rows come out grey). Truncated { format: Format }, @@ -193,6 +195,9 @@ impl fmt::Display for DecodeWarning { DecodeWarning::MorePages { total: None } => write!(f, "only the first page of the file was imported"), DecodeWarning::MoreParts { total: Some(n) } => write!(f, "only one of {n} EXR parts was imported"), DecodeWarning::MoreParts { total: None } => write!(f, "only one EXR part of the file was imported"), + DecodeWarning::DisplayWindowStage { stage, origin } => { + write!(f, "opened on the full display window stage ({}x{}); the data sits at ({}, {})", stage.0, stage.1, origin.0, origin.1) + } DecodeWarning::Truncated { format } => { write!(f, "{} data ends early (the file is truncated or damaged); part of the image is missing", format.name()) } @@ -232,6 +237,13 @@ pub struct DeepChannel { pub struct DeepImage { pub width: u32, pub height: u32, + /// The full stage the image opens on: the EXR `display_window` size, when the file declares + /// one the data region fits into and the limits allow. `None` when absent, absurd or not + /// containing the data region — then the image opens on the data window alone, as before. + pub stage: Option<(u32, u32)>, + /// Where this image's (0, 0) sits on the stage: `data_window.position - display_window.position`. + /// `(0, 0)` when [`Self::stage`] is `None`. + pub origin: (i32, i32), /// The sample channels in file order. pub channels: Vec, /// Cumulative sample counts, `len == width as usize * height as usize + 1`: pixel `i` diff --git a/crates/codecs/tests/deep_exr.rs b/crates/codecs/tests/deep_exr.rs index 001cfab7e95..5a6d6ae75ea 100644 --- a/crates/codecs/tests/deep_exr.rs +++ b/crates/codecs/tests/deep_exr.rs @@ -254,6 +254,9 @@ fn gen_deep( out } +/// RGBA float channels in chlist (alphabetical) order, no Z (everything composites). +const RGBA_NO_Z: [Chan; 4] = [Chan { name: "A", ty: 2 }, Chan { name: "B", ty: 2 }, Chan { name: "G", ty: 2 }, Chan { name: "R", ty: 2 }]; + /// RGBA(Z) float channels in chlist (alphabetical) order. const RGBAZ: [Chan; 5] = [Chan { name: "A", ty: 2 }, Chan { name: "B", ty: 2 }, Chan { name: "G", ty: 2 }, Chan { name: "R", ty: 2 }, Chan { name: "Z", ty: 2 }]; @@ -709,3 +712,131 @@ fn real_deep_files_decode() { assert!(img.warnings.iter().any(|w| matches!(w, photocraft_codecs::DecodeWarning::DeepFlattened { .. })), "{name}"); } } + +/// `gen_deep` for a cropped render: the data window sits at `(dwx, dwy)` inside a larger +/// display window of `sw × sh` (the stage the renderer composed onto). Scanline chunk Y +/// coordinates are absolute display rows, so they carry the data-window offset. +#[allow(clippy::too_many_arguments)] +fn gen_deep_cropped( + w: usize, + h: usize, + dwx: i32, + dwy: i32, + sw: usize, + sh: usize, + channels: &[Chan], + counts: &[u32], + sample: &dyn Fn(usize, usize) -> Vec, +) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&0x0131_2f76u32.to_le_bytes()); + out.extend_from_slice(&(2u32 | 0x800).to_le_bytes()); + + let mut chlist = Vec::new(); + for c in channels { + chlist.extend_from_slice(c.name.as_bytes()); + chlist.push(0); + chlist.extend_from_slice(&c.ty.to_le_bytes()); + chlist.push(0); + chlist.extend_from_slice(&[0u8; 3]); + chlist.extend_from_slice(&1i32.to_le_bytes()); + chlist.extend_from_slice(&1i32.to_le_bytes()); + } + chlist.push(0); + attr(&mut out, "channels", "chlist", &chlist); + attr(&mut out, "compression", "compression", &[0u8]); + // box2i is inclusive: the windows end at position + size - 1. + let dw: Vec = [dwx, dwy, dwx + w as i32 - 1, dwy + h as i32 - 1].iter().flat_map(|v| v.to_le_bytes()).collect(); + attr(&mut out, "dataWindow", "box2i", &dw); + let disp: Vec = [0i32, 0, sw as i32 - 1, sh as i32 - 1].iter().flat_map(|v| v.to_le_bytes()).collect(); + attr(&mut out, "displayWindow", "box2i", &disp); + attr(&mut out, "lineOrder", "lineOrder", &[0]); + attr(&mut out, "pixelAspectRatio", "float", &1f32.to_le_bytes()); + attr(&mut out, "screenWindowCenter", "v2f", &[0f32.to_le_bytes(), 0f32.to_le_bytes()].concat()); + attr(&mut out, "screenWindowWidth", "float", &1f32.to_le_bytes()); + attr(&mut out, "type", "string", b"deepscanline"); + attr(&mut out, "version", "int", &1i32.to_le_bytes()); + out.push(0); + + // One scanline block per data row; the pixel indexing stays data-relative. + let mut chunks: Vec> = Vec::new(); + for y in 0..h { + let pixels: Vec = (y * w..y * w + w).collect(); + let mut c = Vec::new(); + c.extend_from_slice(&(dwy + y as i32).to_le_bytes()); + let mut table = Vec::with_capacity(pixels.len() * 4); + let mut acc = 0u32; + for &p in &pixels { + acc += counts[p]; + table.extend_from_slice(&acc.to_le_bytes()); + } + let mut data = Vec::new(); + for (ci, _) in channels.iter().enumerate() { + for &p in &pixels { + for v in sample(p, ci) { + data.extend_from_slice(&v.to_le_bytes()); + } + } + } + c.extend_from_slice(&(table.len() as u64).to_le_bytes()); + c.extend_from_slice(&(data.len() as u64).to_le_bytes()); + c.extend_from_slice(&(data.len() as u64).to_le_bytes()); + c.extend_from_slice(&table); + c.extend_from_slice(&data); + chunks.push(c); + } + let mut pos = out.len() + chunks.len() * 8; + let mut table = Vec::new(); + for c in &chunks { + table.extend_from_slice(&(pos as u64).to_le_bytes()); + pos += c.len(); + } + out.extend_from_slice(&table); + for c in chunks { + out.extend_from_slice(&c); + } + out +} + +/// A cropped render opens on the full display-window stage: MY27-style renders carry a +/// `display_window` of the full frame (e.g. 4000×3000) while the deep data window is the +/// render pass's crop inside it. The image must open at stage size with the data at its +/// crop offset, transparent elsewhere — not cropped to the data window. +#[test] +fn cropped_deep_data_opens_on_the_display_stage() { + let (w, h, dwx, dwy, sw, sh) = (24usize, 16usize, 8i32, 6i32, 48usize, 32usize); + let counts = vec![1u32; w * h]; + let sample = |p: usize, ci: usize| -> Vec { + // chlist order (RGBA_NO_Z): A, B, G, R. + let x = p % w; + let y = p / w; + match ci { + 0 => vec![1.0], // A + 1 => vec![0.5], // B + 2 => vec![y as f32 / h as f32], // G + _ => vec![x as f32 / w as f32], // R + } + }; + let bytes = gen_deep_cropped(w, h, dwx, dwy, sw, sh, &RGBA_NO_Z, &counts, &sample); + let img = decode(&bytes).expect("cropped deep EXR decodes"); + assert_eq!((img.width(), img.height()), (sw as u32, sh as u32), "opens on the display window stage"); + assert!(img.warnings.iter().any(|w| matches!(w, photocraft_codecs::DecodeWarning::DisplayWindowStage { origin, .. } if *origin == (dwx, dwy)))); + + // The data sits at its crop offset: the first data pixel's green channel carries its + // data-row fraction, everything outside the data region stays transparent black. + let s = img.to_f32_samples().expect("f32"); + let nc = s.len() / (sw * sh); + let px = |x: usize, y: usize| -> Vec { s[y * sw * nc + x * nc..y * sw * nc + x * nc + nc].to_vec() }; + let first = px(dwx as usize, dwy as usize); + assert!( + (first[0] - 0.0).abs() < 1e-5 && (first[1] - 0.0).abs() < 1e-5 && (first[2] - 0.5).abs() < 1e-5 && (first[3] - 1.0).abs() < 1e-5, + "first data pixel at the offset: {first:?}" + ); + let last = px(dwx as usize + w - 1, dwy as usize + h - 1); + assert!( + (last[0] - (w - 1) as f32 / w as f32).abs() < 1e-5 && (last[1] - (h - 1) as f32 / h as f32).abs() < 1e-5, + "last data pixel keeps its coordinates: {last:?}" + ); + let outside = px(0, 0); + assert_eq!(outside, vec![0.0, 0.0, 0.0, 0.0], "outside the data region: transparent"); +} From 99e1138877e6aa7e856c6883b415e708d042fc59 Mon Sep 17 00:00:00 2001 From: Scott C Hughes <1591011+dottedt@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:24:53 -0500 Subject: [PATCH 47/47] Stroke layer effect: the outer edge stays round at wide sizes (#3646) (#3650) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(compose): a wide stroke effect keeps a round outer edge (#3646) A stroke's outside distance came from the 5x5 chamfer, whose level sets are polygons: at a 245 px outside stroke the boundary wanders ~10 px into visible flat facets. Use the exact Euclidean distance (edt_nearest, already used for glows and chiselled bevels) for the outer edge; the inside edge and spread/choke keep the chamfer, whose sub-pixel edge seeding is fitted to Photoshop at small offsets. A point source dilated by 60 px now stays within 0.5 px of a circle (was 2.5 px). Tests: a_wide_outside_stroke_is_round_not_a_polygon; stroke_edges_use_the_exact_euclidean_distance (a 3 px outside stroke's (1,3) corner coverage is 0.838, the exact distance; it was 0.764). Full photocraft-compose suite: 206 pass; fmt and clippy clean. * fix(gpu): a wide stroke keeps its exact outer edge on the GPU (#3646) 1080008a fixed the CPU compositor's stroke outer edge to use the exact Euclidean distance instead of the 5x5 chamfer, whose level sets are polygons and facet a wide stroke. The GPU compositor builds the outer stroke field from FieldKind::StrokeOutside, which still maps to the chamfer, and the canvas is drawn on the GPU, so the fix did not reach the app. Add FieldKind::StrokeOutsideExact (dist_outside_by Euclidean) used only by the stroke. The spread of drop shadows and softer outer glows keeps the chamfer on purpose (fitted to Photoshop, #224), so StrokeOutside keeps its meaning. StrokeOutsideVector is exact too, matching the CPU vector outline path. Tests: crates/gpu/src/fx.rs a_raster_stroke_uses_the_exact_outer_field (stroke uses the exact field, shadow spread keeps the chamfer), and the banded/whole field test covers the new kind. * fix(stroke): a stroke keeps the chamfer narrow and the exact edge wide (#3646) The exact Euclidean outer edge (#3646) fixed a wide stroke's faceting but regressed Photoshop's own composites against the PSD flatten oracle: the fitted 5 × 5 chamfer matches Photoshop at small offsets, and only at a large outside width do its octagon facets show. Pick the metric by the stroke's outside width instead: below `STROKE_CHAMFER_MAX` (20 px) keep the chamfer and its sub-pixel edge seeding, at or above it use the exact Euclidean transform. The measured chamfer deviation from a point source is 0.027 · r px — 0.48 px at r = 18, the widest outside stroke any Photoshop oracle matches with the chamfer, and 1.6 px at r = 60, where the boundary visibly facets. Half a pixel is reached at r ≈ 19, so the switch sits at 20. Inside stroke edges keep the chamfer. The CPU and GPU compositors pick the same field by the same width (`stroke_outside_exact`); `FieldKind` gains `StrokeOutsideVectorChamfer` for a narrow shape-layer outline, while the shadow / glow spread keeps its chamfer. io corpus: 140 → 148 (floor 146) and psd-tools 231 → 237 (floor 236), both back to main's counts with no file newly differing; a 60 px outside stroke stays round (`a_wide_outside_stroke_is_round_not_a_polygon`). --- crates/compose/src/effects.rs | 120 +++++++++++++++++++++++++++------- crates/compose/src/tests.rs | 52 +++++++++++++-- crates/gpu/src/fx.rs | 53 ++++++++++++++- 3 files changed, 197 insertions(+), 28 deletions(-) diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index 97d08f00da0..6966254547f 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -15,10 +15,10 @@ //! of strokes blend onto the backdrop with their own modes, an upper stroke covering the //! lower ones. Drop shadows are knocked out only through see-through fill. //! -//! Shapes come from the layer's alpha (after its mask). Strokes and spread / choke measure -//! distances with a 5 × 5 chamfer metric seeded at sub-pixel edge offsets (as Photoshop does); -//! precise glows and chiselled bevels use an exact Euclidean distance transform. Soft falloffs -//! are two box blurs (a tent). +//! Shapes come from the layer's alpha (after its mask). A stroke's outer edge, and precise glows / +//! chiselled bevels, use an exact Euclidean transform: a 5 × 5 chamfer's level sets are polygons, +//! which a wide stroke shows as flat facets (#3646). Inside stroke edges and spread / choke keep the +//! cheaper chamfer. Soft falloffs are two box blurs (a tent). use photocraft_color::blend::BlendMode; use photocraft_doc::Pattern; @@ -394,12 +394,30 @@ fn dist_outside(s: &Map) -> Vec { /// Distance metric for effect distance fields. #[derive(Clone, Copy, PartialEq)] enum Metric { - /// Exact Euclidean (precise glows, bevels). + /// Exact Euclidean (effect edges). Euclidean, - /// 5 × 5 chamfer (strokes, spread / choke; see [`chamfer_nearest`]). + /// 5 × 5 chamfer (inside strokes, spread / choke; see [`chamfer_nearest`]). Chamfer, } +/// Outside width from which a stroke's outer edge uses the exact Euclidean transform instead of +/// the fitted 5 × 5 chamfer. +/// +/// The chamfer's level sets are octagons: measured against a point source, its distance exceeds +/// the Euclidean radius by up to `0.027 · r` px — 0.48 px at r = 18 (the widest outside stroke any +/// Photoshop oracle matches with the chamfer) and 1.6 px at r = 60, where a wide stroke's boundary +/// visibly facets (#3646). Half a pixel of deviation is reached at r ≈ 19, so strokes narrower +/// than 20 px keep the chamfer, whose sub-pixel edge seeding Photoshop matches at small offsets, +/// and wider ones stay round. +pub const STROKE_CHAMFER_MAX: f32 = 20.0; + +/// Whether a stroke whose outside edge is `out_w` px wide uses the exact Euclidean transform (round) +/// rather than the 5 × 5 chamfer's octagon ([`STROKE_CHAMFER_MAX`]). The CPU compositor and the GPU +/// compositor's `program_with` both call this, so a stroke picks the same field in each. +pub fn stroke_outside_exact(out_w: f32) -> bool { + out_w >= STROKE_CHAMFER_MAX +} + fn dist_outside_by(s: &Map, m: Metric) -> Vec { if m == Metric::Chamfer { // Seeds start at their sub-pixel edge offset (1 − coverage). @@ -1657,9 +1675,14 @@ pub struct FxMaps { per: Vec>, bevel_paint: Vec, din: Option>, + /// Stroke outside distance below [`STROKE_CHAMFER_MAX`]: the fitted 5 × 5 chamfer. dout: Option>, - /// Shape layers: distance outside the vector outline (from local coverage). + /// Stroke outside distance at or above [`STROKE_CHAMFER_MAX`]: the exact Euclidean transform. + dout_exact: Option>, + /// Shape layers: chamfer distance outside the vector outline (from local coverage). vdout: Option>, + /// Shape layers: exact Euclidean distance outside the vector outline (from local coverage). + vdout_exact: Option>, /// `shape` joins the alpha with a filled shape's outline (`crate::effect_outline`). outline: bool, /// Frames of gradient strokes by outside width (bits): see [`stroke_frame`]. @@ -1678,7 +1701,14 @@ impl FxMaps { pub fn bytes(&self) -> usize { let n = self.shape.v.len(); let maps: usize = self.per.iter().map(Vec::len).sum(); - (1 + maps + usize::from(self.din.is_some()) + usize::from(self.dout.is_some()) + usize::from(self.vdout.is_some())) * n * 4 + (1 + maps + + usize::from(self.din.is_some()) + + usize::from(self.dout.is_some()) + + usize::from(self.dout_exact.is_some()) + + usize::from(self.vdout.is_some()) + + usize::from(self.vdout_exact.is_some())) + * n + * 4 } fn crop_vec(&self, v: &[f32], to: Rect, fill: f32) -> Vec { @@ -1765,10 +1795,34 @@ pub(crate) fn build_maps_prepared( .collect(); let has_stroke = items.iter().any(|e| matches!(e, Effect::Stroke(_))); let vector_shape = matches!(layer.content, photocraft_doc::LayerContent::Shape(_)); - let (din, dout) = if has_stroke { (Some(dist_inside_by(&shape, Metric::Chamfer)), Some(dist_outside_by(&shape, Metric::Chamfer))) } else { (None, None) }; - // Without an outline (unfilled shapes), estimate the vector outline from local coverage. + // A stroke's outer edge keeps the 5 × 5 chamfer while its outside width is under + // `STROKE_CHAMFER_MAX` — Photoshop matches the chamfer's sub-pixel edge seeding at small + // offsets — and uses the exact Euclidean transform at or above it, where the chamfer's octagon + // facets a wide stroke (#3646). Inside stroke distances always keep the chamfer. Each outer + // field is built only when a stroke of that width is present; the compositor picks per stroke, + // so one layer may hold both a narrow and a wide stroke. + let out_widths: Vec = + items.iter().filter_map(|e| if let Effect::Stroke(st) = e { Some(stroke_widths(st).1) } else { None }).filter(|w| *w > 0.0).collect(); + let chamfer_out = out_widths.iter().any(|&w| !stroke_outside_exact(w)); + let exact_out = out_widths.iter().any(|&w| stroke_outside_exact(w)); + let (din, dout, dout_exact) = if has_stroke { + ( + Some(dist_inside_by(&shape, Metric::Chamfer)), + chamfer_out.then(|| dist_outside_by(&shape, Metric::Chamfer)), + exact_out.then(|| dist_outside_by(&shape, Metric::Euclidean)), + ) + } else { + (None, None, None) + }; + // Without an outline (unfilled shapes), estimate the vector outline from local coverage; it + // picks the chamfer or the exact transform the same way. let outline = crate::effect_outline(layer).is_some(); - let vdout = (has_stroke && vector_shape && !outline).then(|| dist_outside_by(&local_coverage(&shape), Metric::Chamfer)); + let (vdout, vdout_exact) = if has_stroke && vector_shape && !outline { + let lc = local_coverage(&shape); + (chamfer_out.then(|| dist_outside_by(&lc, Metric::Chamfer)), exact_out.then(|| dist_outside_by(&lc, Metric::Euclidean))) + } else { + (None, None) + }; let mut frames: Vec<(u32, Rect)> = Vec::new(); for e in &items { if let Effect::Stroke(st) = e @@ -1780,7 +1834,7 @@ pub(crate) fn build_maps_prepared( } } } - FxMaps { rect, shape, per, bevel_paint, din, dout, vdout, outline, frames } + FxMaps { rect, shape, per, bevel_paint, din, dout, dout_exact, vdout, vdout_exact, outline, frames } } /// Far outside any shape (distance fill for cropped distance fields). @@ -1963,10 +2017,13 @@ pub(crate) fn composite_with_effects_prepared( // Strokes. Inside parts are painted over the layer (bottom instance first); outside parts // are slid beneath it, so the first listed (top) instance is processed first. let strokes: Vec<&photocraft_doc::StrokeFx> = items.iter().filter_map(|e| if let Effect::Stroke(s) = e { Some(s) } else { None }).collect(); - let (din, dout) = match (&maps.din, &maps.dout) { - (Some(a), Some(b)) if !strokes.is_empty() => (maps.crop_vec(a, big, 0.0), maps.crop_vec(b, big, FAR)), - _ => (Vec::new(), Vec::new()), - }; + let ready = !strokes.is_empty(); + let din = if ready { maps.din.as_ref().map(|a| maps.crop_vec(a, big, 0.0)).unwrap_or_default() } else { Vec::new() }; + // A stroke's outer edge picks the chamfer or the exact field by its width + // ([`stroke_outside_exact`]); both are cropped here, and only the ones a stroke needs exist. + let crop_out = |v: &Option>| v.as_ref().map(|b| maps.crop_vec(b, big, FAR)); + let (dout, dout_exact, vdout, vdout_exact) = + if ready { (crop_out(&maps.dout), crop_out(&maps.dout_exact), crop_out(&maps.vdout), crop_out(&maps.vdout_exact)) } else { (None, None, None, None) }; let widths = stroke_widths; // A gradient stroke aligned with the layer spans the stroke's own extent. let frame = |st: &photocraft_doc::StrokeFx| maps.stroke_frame(st).unwrap_or(sb); @@ -1994,7 +2051,9 @@ pub(crate) fn composite_with_effects_prepared( for (p, a) in lay.px.iter_mut().zip(&shape.v) { p[3] *= a.min(1.0); } - let vdout = maps.vdout.as_ref().filter(|_| vector_shape && !strokes.is_empty()).map(|v| maps.crop_vec(v, big, FAR)); + // Shape layers without a filled outline stroke their vector outline, estimated from local + // coverage; every other layer strokes its shape. The width then picks the field. + let vector_outline = vector_shape && !maps.outline; // Outside parts lie beneath the layer and blend onto the backdrop with their own mode. A // higher stroke covers the ones below it (Photoshop: a Multiply stroke listed above a wider // Normal stroke multiplies the backdrop, not the lower stroke). Each stroke's share is its @@ -2012,7 +2071,14 @@ pub(crate) fn composite_with_effects_prepared( // Shape layers: Photoshop strokes the vector outline (estimated from local coverage // for unfilled shapes); the stroke never shows through the shape's pixels. Along a // filled shape's outline it covers the part of each edge pixel outside the path. - let d = vdout.as_ref().unwrap_or(&dout); + let exact = stroke_outside_exact(out_w); + let d: &[f32] = if vector_outline { + (if exact { vdout_exact.as_deref() } else { vdout.as_deref() }).unwrap_or_default() + } else if exact { + dout_exact.as_deref().unwrap_or_default() + } else { + dout.as_deref().unwrap_or_default() + }; let mut share = vec![0.0f32; w * h]; for (i, sh) in share.iter_mut().enumerate() { let band = (out_w + 0.5 - d[i]).clamp(0.0, 1.0); @@ -2176,15 +2242,23 @@ pub enum FieldKind { BevelOutside, /// `bevel_dist_inside` of the shape: the inside distance of chiselled bevels. BevelInside, - /// Chamfer distance outside the shape: outside strokes, and the spread of drop shadows and - /// softer outer glows ([`dilate`]). + /// Chamfer distance outside the shape: a narrow raster stroke's outer edge (see + /// [`stroke_outside_exact`]), and the spread of drop shadows and softer outer glows + /// ([`dilate`]); their metric is fitted to Photoshop (#224) and keeps the sub-pixel seeding. StrokeOutside, /// Chamfer distance outside `1 - alpha`: the choke of inner shadows and softer inner glows. ChokeInside, /// Stroke distance inside the shape. StrokeInside, - /// Stroke distance outside a shape layer's outline (from its local coverage). + /// Exact Euclidean distance outside the shape: a wide raster stroke's outer edge (see + /// [`stroke_outside_exact`]). The chamfer's level sets are polygons (an ~8-fold octagon), so a + /// wide stroke's outer boundary facets; the shadow / glow spread above keeps the chamfer (#3646). + StrokeOutsideExact, + /// Stroke distance outside a shape layer's outline (from its local coverage), exact Euclidean. StrokeOutsideVector, + /// Chamfer distance outside a shape layer's outline (from its local coverage): a narrow vector + /// stroke's outer edge, where the chamfer matches Photoshop (see [`STROKE_CHAMFER_MAX`]). + StrokeOutsideVectorChamfer, } /// The `kind` distance field of the shape `alpha` (row-major `w × h`). Values past `reach` pixels @@ -2199,7 +2273,9 @@ pub fn distance_field(kind: FieldKind, alpha: Vec, w: usize, h: usize, reac FieldKind::StrokeOutside => dist_outside_by(&s, Metric::Chamfer), FieldKind::ChokeInside => dist_outside_by(&s.map(|a| 1.0 - a), Metric::Chamfer), FieldKind::StrokeInside => dist_inside_by(&s, Metric::Chamfer), - FieldKind::StrokeOutsideVector => dist_outside_by(&local_coverage(&s), Metric::Chamfer), + FieldKind::StrokeOutsideExact => dist_outside_by(&s, Metric::Euclidean), + FieldKind::StrokeOutsideVector => dist_outside_by(&local_coverage(&s), Metric::Euclidean), + FieldKind::StrokeOutsideVectorChamfer => dist_outside_by(&local_coverage(&s), Metric::Chamfer), } } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 6680f11a90c..f9e49ac04ed 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1315,17 +1315,61 @@ fn shapeless_effect_maps_cover_only_the_band_being_rendered() { // ---------- PSD-fidelity effect semantics (fitted on Photoshop composites) ---------- #[test] -fn stroke_corners_follow_the_5x5_chamfer_metric() { - // Square 10..30, outside stroke 3: pixel (9, 7) is offset (1, 3) from the corner pixel. - // Photoshop measures √5 + 1 = 3.236 there (exact distance 3.162): coverage 3 + 1 − 3.236. +fn stroke_edges_use_the_chamfer_when_narrow_and_the_exact_distance_when_wide() { + // #3646: a narrow stroke's outer edge keeps the 5 × 5 chamfer, whose sub-pixel seeding + // Photoshop matches at small offsets (see `stroke_outside_exact`); only a wide one — where the + // chamfer's octagon facets (see `a_wide_outside_stroke_is_round_not_a_polygon`) — uses the + // exact Euclidean transform. + // Square 10..30, outside stroke 3: (9, 7) is offset (1, 3) from the corner pixel. Photoshop + // measures √5 + 1 = 3.236 there, so coverage is 3 + 1 − 3.236 = 0.764 (the exact distance is + // 3.162, coverage 0.838). let d = fx_doc(vec![stroke(3.0, StrokePosition::Outside)]); let a = 1.0 - px(&d, 9, 7)[0]; // blue stroke over white: red channel drops by coverage assert!((a - 0.764).abs() < 0.01, "{a}"); - // (2, 2) stays Euclidean (two diagonal steps). + // (2, 2) is the same in both metrics (two diagonal steps). let a = 1.0 - px(&d, 8, 8)[0]; assert!((a - 1.0).abs() < 0.01, "{a}"); } +#[test] +fn the_stroke_metric_switches_at_the_fitted_threshold() { + // The widest outside stroke any Photoshop oracle matches with the chamfer is 18 px + // (ag-psd read-write/strokes): its octagon deviates 0.48 px from a circle there; 20 px keeps + // it. Wider strokes (the 60 px #3646 case) are round. + assert!(!crate::effects::stroke_outside_exact(18.0)); + assert!(crate::effects::stroke_outside_exact(crate::effects::STROKE_CHAMFER_MAX)); + assert!(crate::effects::stroke_outside_exact(60.0)); +} + +#[test] +fn a_wide_outside_stroke_is_round_not_a_polygon() { + // A point source dilated by 60 px must stay a circle. The 5 × 5 chamfer's octagonal level set + // made the boundary swing ~2.5 px at this radius (~10 px at 245 px) — the "low-poly" #3646. + let mut d = doc_white(170, 170); + let mut l = solid_layer("dot", Rect::new(85, 85, 86, 86), [1.0, 0.0, 0.0, 1.0]); + l.effects.items = vec![stroke(60.0, StrokePosition::Outside)]; + d.layers.push(l); + let buf = render(&d, Rect::new(0, 0, 170, 170)); + let at = |x: i32, y: i32| buf.px[(y as usize) * 170 + x as usize]; + let (mut lo, mut hi) = (f64::MAX, 0.0f64); + let stroke_at = |x: i32, y: i32| x >= 0 && y >= 0 && x < 170 && y < 170 && at(x, y)[2] > 0.5 && at(x, y)[0] < 0.5; + for k in 0..72 { + let th = (k as f64) * std::f64::consts::TAU / 72.0; + let (dx, dy) = (th.cos(), th.sin()); + let mut r = 0.0f64; + let mut t = 0.0f64; + while t < 84.0 { + if stroke_at((85.0 + dx * t).round() as i32, (85.0 + dy * t).round() as i32) { + r = t; + } + t += 0.25; + } + lo = lo.min(r); + hi = hi.max(r); + } + assert!(hi - lo <= 2.0, "the 60 px stroke boundary swings {lo}..{hi} px — a polygon, not a circle (#3646)"); +} + #[test] fn inside_stroke_respects_partial_edge_coverage() { // A 25 %-alpha edge column puts the edge 0.75 px further out: a 3 px inside stroke then diff --git a/crates/gpu/src/fx.rs b/crates/gpu/src/fx.rs index cc396d8ba0b..2149aaa4124 100644 --- a/crates/gpu/src/fx.rs +++ b/crates/gpu/src/fx.rs @@ -434,7 +434,15 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool, // composite_with_effects' stroke bands (maps 0 outside, 1 inside) let (in_w, out_w) = stroke_widths(st); if out_w > 0.0 { - let d = b.field(if vector_shape { FieldKind::StrokeOutsideVector } else { FieldKind::StrokeOutside }, out_w); + // Narrow strokes keep the fitted chamfer, wide ones the exact transform (#3646); + // the CPU picks the same field by the same width (`stroke_outside_exact`). + let kind = match (vector_shape, photocraft_compose::effects::stroke_outside_exact(out_w)) { + (true, true) => FieldKind::StrokeOutsideVector, + (true, false) => FieldKind::StrokeOutsideVectorChamfer, + (false, true) => FieldKind::StrokeOutsideExact, + (false, false) => FieldKind::StrokeOutside, + }; + let d = b.field(kind, out_w); let mut s = stage(Kernel::MStroke, Some(d), None, [out_w, 0.0, 0.0, 0.0], 0); s.out = Some(0); b.stages.push(s); @@ -522,7 +530,8 @@ pub(crate) fn field(kind: FieldKind, reach: i32, shape: &[f32], region: Rect, ou /// (the chamfer fields, a sequential sweep) the window is cut into bands that go in parallel. Without /// threads (wasm) there is nothing to spread over, and bands keep the memory small. fn threaded_inside(kind: FieldKind) -> bool { - !cfg!(target_arch = "wasm32") && matches!(kind, FieldKind::Outside | FieldKind::Inside | FieldKind::BevelOutside | FieldKind::BevelInside) + !cfg!(target_arch = "wasm32") + && matches!(kind, FieldKind::Outside | FieldKind::Inside | FieldKind::BevelOutside | FieldKind::BevelInside | FieldKind::StrokeOutsideExact) } /// [`field`] with the halo given (the tests shrink it to see that they notice). @@ -748,7 +757,9 @@ mod tests { FieldKind::ChokeInside, FieldKind::StrokeOutside, FieldKind::StrokeInside, + FieldKind::StrokeOutsideExact, FieldKind::StrokeOutsideVector, + FieldKind::StrokeOutsideVectorChamfer, ] { let reach = 9; let whole = photocraft_compose::effects::distance_field(kind, shape.clone(), w, h, reach as f32); @@ -825,4 +836,42 @@ mod tests { } assert!(too_small_noticed, "a 2 row halo went unnoticed, so this test guards nothing"); } + + /// #3646: a stroke's outer edge is the fitted 5 × 5 chamfer while it is narrow and the exact + /// Euclidean transform once it is wide — the chamfer's octagon facets a wide stroke. The GPU + /// builds it from the field its width picks (`stroke_outside_exact`), while the spread of drop + /// shadows and softer outer glows keeps the chamfer field (fitted to Photoshop, #224) — so + /// routing a wide stroke through the chamfer, or a shadow through the exact field, fails here. + #[test] + fn a_stroke_uses_the_chamfer_narrow_and_the_exact_field_wide() { + use photocraft_color::{BlendMode, Color}; + use photocraft_doc::{FxCommon, FxPaint, StrokeFx, StrokePosition}; + let light = GlobalLight::default(); + let stroke = + |size, position| Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, 1.0), size, position, paint: FxPaint::Color(Color::BLACK) }); + // Narrow (< STROKE_CHAMFER_MAX): the chamfer, exactly as before #3646. + let narrow = program(&stroke(6.0, StrokePosition::Outside), &light, false); + assert!(narrow.fields.iter().any(|(k, _)| *k == FieldKind::StrokeOutside), "a narrow raster outside stroke keeps the chamfer: {:?}", narrow.fields); + assert!(!narrow.fields.iter().any(|(k, _)| *k == FieldKind::StrokeOutsideExact), "…not the exact field: {:?}", narrow.fields); + // Wide: the exact transform. + let wide = program(&stroke(40.0, StrokePosition::Outside), &light, false); + assert!( + wide.fields.iter().any(|(k, _)| *k == FieldKind::StrokeOutsideExact), + "a wide raster outside stroke needs the exact outer field: {:?}", + wide.fields + ); + assert!(!wide.fields.iter().any(|(k, _)| *k == FieldKind::StrokeOutside), "…not the chamfer field: {:?}", wide.fields); + // A shape layer's outline stroke follows the same width rule; the inside edge keeps the chamfer. + let vector_narrow = program(&stroke(6.0, StrokePosition::Outside), &light, true); + assert!(vector_narrow.fields.iter().any(|(k, _)| *k == FieldKind::StrokeOutsideVectorChamfer), "{:?}", vector_narrow.fields); + let vector_wide = program(&stroke(40.0, StrokePosition::Outside), &light, true); + assert!(vector_wide.fields.iter().any(|(k, _)| *k == FieldKind::StrokeOutsideVector), "{:?}", vector_wide.fields); + let inner = program(&stroke(40.0, StrokePosition::Inside), &light, false); + assert!(inner.fields.iter().any(|(k, _)| *k == FieldKind::StrokeInside), "{:?}", inner.fields); + // The shadow / softer-glow spread is unchanged: still the chamfer. + let Effect::DropShadow(mut s) = Effect::default_drop_shadow() else { unreachable!() }; + s.spread = 0.5; + let shadow = program(&Effect::DropShadow(s), &light, false); + assert!(shadow.fields.iter().any(|(k, _)| *k == FieldKind::StrokeOutside), "the shadow spread keeps the chamfer: {:?}", shadow.fields); + } }