From 12c43aaa4933100f1131cf787a1e8db8d239bff6 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Fri, 16 Oct 2026 07:51:28 +0530 Subject: [PATCH 01/86] Save for Web: WebP, lossy at its Quality or lossless (part of #6391, #2415) Save for Web (Legacy) gains a WebP format: lossy through our VP8 encoder at the dialog's 0-100 Quality, or lossless (new `lossless` param). Translucent pixels keep their alpha as in PNG-24 (matte when Transparency is off); Embed Color Profile and the Metadata choice apply as for JPEG. The lossy preview decodes the file so its artefacts show; 4-Up halves a lossy quality. AVIF and JPEG XL from #6391 are not included. Co-Authored-By: Claude Opus 5.5 --- apps/photocraft/src/services.rs | 16 +++---- crates/engine/src/web_cmds.rs | 74 +++++++++++++++++++++++------ crates/engine/src/web_cmds/tests.rs | 64 ++++++++++++++++++++++++- crates/ui-egui/src/file_ui.rs | 47 ++++++++++++++++-- docs/file-format-parity.md | 5 +- 5 files changed, 176 insertions(+), 30 deletions(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 6c34fda33a..830ed51a36 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -796,7 +796,7 @@ mod tests { app.run("prefs.set", json!({"path": "export.quickExportFormat", "value": format})).unwrap(); let _ = invoke(&mut app, "file.export.quickExportAsPng"); } - for format in ["gif", "png8", "png24", "jpeg", "wbmp"] { + for format in ["gif", "png8", "png24", "jpeg", "webp", "wbmp"] { dialog(&mut app, "file.export.saveForWebLegacy", json!({"format": format})); } app.run("slice.new", json!({"x": 0, "y": 0, "width": 2, "height": 2})).unwrap(); @@ -809,13 +809,13 @@ mod tests { let exts: Vec<&str> = asked.iter().filter_map(|s| s.rsplit_once('.').map(|(_, e)| e)).collect(); // The extension each save above should suggest, in order. let want = [ - "psd", // Save As untitled - "pcraft jpeg gif bmp psd", // Save As opened .pcraft .jpeg .gif .bmp .dng - "psd", // Save a Copy - "png jpg webp tif tga", // Export As - "png jpg gif webp", // Quick Export - "gif png png jpg wbmp html", // Save for Web: gif png8 png24 jpeg wbmp, then with slices - "csv pcpresets", // Measurement Log, Export Presets + "psd", // Save As untitled + "pcraft jpeg gif bmp psd", // Save As opened .pcraft .jpeg .gif .bmp .dng + "psd", // Save a Copy + "png jpg webp tif tga", // Export As + "png jpg gif webp", // Quick Export + "gif png png jpg webp wbmp html", // Save for Web: gif png8 png24 jpeg webp wbmp, then with slices + "csv pcpresets", // Measurement Log, Export Presets ] .join(" "); assert_eq!(exts, want.split(' ').collect::>()); diff --git a/crates/engine/src/web_cmds.rs b/crates/engine/src/web_cmds.rs index 595ad21956..3fca7aa8ae 100644 --- a/crates/engine/src/web_cmds.rs +++ b/crates/engine/src/web_cmds.rs @@ -38,6 +38,9 @@ pub enum WebFormat { Png24, Jpeg, Wbmp, + /// WebP, lossy (our VP8 encoder) or lossless. Not in Photoshop's legacy dialog, but the + /// format people prepare web images in now (#2415, #6391). + Webp, } impl WebFormat { @@ -48,6 +51,7 @@ impl WebFormat { "png24" | "png-24" | "png" | "png32" => WebFormat::Png24, "jpeg" | "jpg" => WebFormat::Jpeg, "wbmp" => WebFormat::Wbmp, + "webp" => WebFormat::Webp, _ => return None, }) } @@ -57,6 +61,7 @@ impl WebFormat { WebFormat::Png8 | WebFormat::Png24 => "png", WebFormat::Jpeg => "jpg", WebFormat::Wbmp => "wbmp", + WebFormat::Webp => "webp", } } pub fn indexed(self) -> bool { @@ -93,8 +98,10 @@ pub struct WebSettings { /// Web Snap: palette entries within this distance (0..=1 of the channel range) snap to the /// web-safe cube. pub web_snap: f32, - /// JPEG quality 0..=100. + /// JPEG and lossy WebP quality 0..=100. pub quality: u8, + /// WebP: lossless instead of lossy at `quality`. + pub lossless: bool, pub progressive: bool, pub optimized: bool, pub embed_icc: bool, @@ -117,6 +124,7 @@ impl Default for WebSettings { interlaced: false, web_snap: 0.0, quality: 60, + lossless: false, progressive: false, optimized: true, embed_icc: false, @@ -148,7 +156,7 @@ impl WebSettings { } let s = |k: &str| p.get(k).and_then(Value::as_str); if let Some(f) = s("format") { - st.format = WebFormat::from_id(f).ok_or_else(|| bad(cmd, format!("unknown format `{f}` (gif|png8|png24|jpeg|wbmp)")))?; + st.format = WebFormat::from_id(f).ok_or_else(|| bad(cmd, format!("unknown format `{f}` (gif|png8|png24|jpeg|wbmp|webp)")))?; } if let Some(k) = s("palette") { st.palette = match k { @@ -184,6 +192,7 @@ impl WebSettings { for (k, slot) in [ ("interlaced", &mut st.interlaced), ("progressive", &mut st.progressive), + ("lossless", &mut st.lossless), ("optimized", &mut st.optimized), ("embedIcc", &mut st.embed_icc), ("convertToSrgb", &mut st.convert_to_srgb), @@ -241,6 +250,7 @@ impl WebSettings { WebFormat::Png24 => "png24", WebFormat::Jpeg => "jpeg", WebFormat::Wbmp => "wbmp", + WebFormat::Webp => "webp", }; let palette = match self.palette { PaletteKind::Exact => "exact", @@ -265,7 +275,7 @@ impl WebSettings { WebMetadata::All => "all", }; let matte = self.matte.map_or("none".to_string(), |c| format!("#{:02x}{:02x}{:02x}", to8(c[0]), to8(c[1]), to8(c[2]))); - let mut p = json!({"format": format, "palette": palette, "colors": self.colors, "dither": dither, "ditherAmount": (self.dither_amount * 100.0).round(), "transparency": self.transparency, "matte": matte, "interlaced": self.interlaced, "webSnap": (self.web_snap * 100.0).round(), "quality": self.quality, "progressive": self.progressive, "optimized": self.optimized, "embedIcc": self.embed_icc, "metadata": metadata, "convertToSrgb": self.convert_to_srgb}); + let mut p = json!({"format": format, "palette": palette, "colors": self.colors, "dither": dither, "ditherAmount": (self.dither_amount * 100.0).round(), "transparency": self.transparency, "matte": matte, "interlaced": self.interlaced, "webSnap": (self.web_snap * 100.0).round(), "quality": self.quality, "lossless": self.lossless, "progressive": self.progressive, "optimized": self.optimized, "embedIcc": self.embed_icc, "metadata": metadata, "convertToSrgb": self.convert_to_srgb}); if let Some(table) = &self.color_table { p["colorTable"] = json!(table.iter().map(|c| rgb_hex(*c)).collect::>()); } @@ -429,21 +439,34 @@ pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: O let mut color_table = Vec::new(); let mut transparent_index = None; let mut indices = Vec::new(); + // True colour (PNG-24, WebP): straight RGBA when transparency is on and a pixel is + // translucent, else RGB over the matte. + let true_color = || { + let alpha = st.transparency && region().any(|p| p[3] < 1.0); + let k = if alpha { 4 } else { 3 }; + let mut data = Vec::with_capacity(w * h * k); + for p in region() { + let q = if alpha { *p } else { over(p) }; + data.extend_from_slice(&[to8(q[0]), to8(q[1]), to8(q[2]), to8(q[3])][..k]); + } + let layout = if alpha { photocraft_codecs::ChannelLayout::Rgba } else { photocraft_codecs::ChannelLayout::Rgb }; + (data, layout) + }; + let rgba = |data: &[u8], layout: photocraft_codecs::ChannelLayout| -> Vec { + if layout == photocraft_codecs::ChannelLayout::Rgba { + data.to_vec() + } else { + data.as_chunks::<3>().0.iter().flat_map(|c| [c[0], c[1], c[2], 255]).collect() + } + }; + let meta = || photocraft_codecs::Metadata { dpi: Some((dpi, dpi)), xmp: xmp.map(str::to_string), ..Default::default() }; let (bytes, colors) = match st.format { WebFormat::Png24 => { - let alpha = st.transparency && region().any(|p| p[3] < 1.0); - let k = if alpha { 4 } else { 3 }; - let mut data = Vec::with_capacity(w * h * k); - for p in region() { - let q = if alpha { *p } else { over(p) }; - data.extend_from_slice(&[to8(q[0]), to8(q[1]), to8(q[2]), to8(q[3])][..k]); - } + let (data, layout) = true_color(); if preview { - look = if alpha { data.clone() } else { data.as_chunks::<3>().0.iter().flat_map(|c| [c[0], c[1], c[2], 255]).collect() }; + look = rgba(&data, layout); } - let layout = if alpha { photocraft_codecs::ChannelLayout::Rgba } else { photocraft_codecs::ChannelLayout::Rgb }; - let meta = photocraft_codecs::Metadata { dpi: Some((dpi, dpi)), xmp: xmp.map(str::to_string), ..Default::default() }; - let img = photocraft_codecs::Image::from_u8(ww, hh, layout, data).map_err(e)?.with_icc(icc.map(<[u8]>::to_vec)).with_meta(meta); + let img = photocraft_codecs::Image::from_u8(ww, hh, layout, data).map_err(e)?.with_icc(icc.map(<[u8]>::to_vec)).with_meta(meta()); let opts = photocraft_codecs::EncodeOptions { png_interlaced: st.interlaced, embed_icc: icc.is_some(), @@ -452,6 +475,27 @@ pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: O }; (photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &opts).map_err(e)?, None) } + WebFormat::Webp => { + let (data, layout) = true_color(); + let icc = icc.filter(|_| st.embed_icc); + let img = photocraft_codecs::Image::from_u8(ww, hh, layout, data).map_err(e)?.with_icc(icc.map(<[u8]>::to_vec)).with_meta(meta()); + let opts = photocraft_codecs::EncodeOptions { + webp_lossless: st.lossless, + webp_quality: st.quality.min(100), + embed_icc: icc.is_some(), + embed_metadata: xmp.is_some(), + ..Default::default() + }; + let bytes = photocraft_codecs::encode(&img, photocraft_codecs::Format::WebP, &opts).map_err(e)?; + if preview { + // Lossless shows the source; lossy shows the compression artefacts. + look = match (!st.lossless).then(|| photocraft_codecs::decode(&bytes).ok()).flatten() { + Some(img) => img.convert(photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8).data().to_vec(), + None => rgba(img.data(), layout), + }; + } + (bytes, None) + } WebFormat::Jpeg => { let mut rgb = Vec::with_capacity(w * h * 3); for p in region() { @@ -1468,7 +1512,7 @@ pub fn specs() -> Vec { "Save for Web (Legacy)…", &["File", "Export"], Some("Cmd+Alt+Shift+S"), - r##"{"preset":"GIF 128 Dithered|JPEG High|PNG-24|…"?,"format":"gif|png8|png24|jpeg|wbmp"="png24","palette":"perceptual|selective|adaptive|restrictive|exact|systemMac|systemWindows|uniform"="selective","colors":2..256=256,"colorTable":["#rrggbb",…]? (fixed opaque source palette, 1–256 entries; 255 with transparency),"colorTableEdits":{"0":"#rrggbb",…}? (replace entries after indexing; requires colorTable),"dither":"none|diffusion|pattern|noise"="diffusion","ditherAmount":0..100=88,"transparency":bool=true,"matte":"#rrggbb|none"="none" (a preset's own: white; formats without transparency go over white),"interlaced":bool=false,"webSnap":0..100=0,"quality":0..100=60,"progressive":bool=false,"optimized":bool=true,"embedIcc":bool=false,"metadata":"none|copyright|copyrightAndContact|all"="copyright","convertToSrgb":bool=true,"width"|"height"|"percent"? (image size),"resample":"bicubic|bilinear|nearest"?,"path":file? (whole image),"dir":folder? (one file per slice in images/),"imagesFolder":relative-folder?="images","html":bool=false,"trustedSliceHtml":bool=false (only for trusted documents; emit raw no-image cell HTML),"slices":"all|user"="all","numbers":[n]?} → no path/dir: {bytes,width,height,colors,colorTable,transparentIndex} estimate; else {files, html, bytes}"##, + r##"{"preset":"GIF 128 Dithered|JPEG High|PNG-24|…"?,"format":"gif|png8|png24|jpeg|wbmp|webp"="png24","palette":"perceptual|selective|adaptive|restrictive|exact|systemMac|systemWindows|uniform"="selective","colors":2..256=256,"colorTable":["#rrggbb",…]? (fixed opaque source palette, 1–256 entries; 255 with transparency),"colorTableEdits":{"0":"#rrggbb",…}? (replace entries after indexing; requires colorTable),"dither":"none|diffusion|pattern|noise"="diffusion","ditherAmount":0..100=88,"transparency":bool=true,"matte":"#rrggbb|none"="none" (a preset's own: white; formats without transparency go over white),"interlaced":bool=false,"webSnap":0..100=0,"quality":0..100=60 (JPEG, lossy WebP),"lossless":bool=false (WebP),"progressive":bool=false,"optimized":bool=true,"embedIcc":bool=false,"metadata":"none|copyright|copyrightAndContact|all"="copyright","convertToSrgb":bool=true,"width"|"height"|"percent"? (image size),"resample":"bicubic|bilinear|nearest"?,"path":file? (whole image),"dir":folder? (one file per slice in images/),"imagesFolder":relative-folder?="images","html":bool=false,"trustedSliceHtml":bool=false (only for trusted documents; emit raw no-image cell HTML),"slices":"all|user"="all","numbers":[n]?} → no path/dir: {bytes,width,height,colors,colorTable,transparentIndex} estimate; else {files, html, bytes}"##, has_doc, save_for_web ), diff --git a/crates/engine/src/web_cmds/tests.rs b/crates/engine/src/web_cmds/tests.rs index c3ffe6edce..63f2ac7820 100644 --- a/crates/engine/src/web_cmds/tests.rs +++ b/crates/engine/src/web_cmds/tests.rs @@ -188,7 +188,7 @@ fn estimates_every_format_at_every_depth() { for depth in [8, 16, 32] { let mut s = session(depth); let mut sizes = Vec::new(); - for f in ["gif", "png8", "png24", "jpeg", "wbmp"] { + for f in ["gif", "png8", "png24", "jpeg", "wbmp", "webp"] { let r = s.execute("file.export.saveForWebLegacy", json!({"format": f, "colors": 16})).unwrap(); assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(64), Some(48)), "{f}"); assert!(r["bytes"].as_u64().unwrap() > 20, "{f}"); @@ -286,6 +286,63 @@ fn jpeg_quality_progressive_and_metadata() { assert!(decode(&lo).meta.xmp.is_none()); } +/// WebP (#2415, #6391): lossy at the chosen quality or lossless, keeping translucency unless +/// transparency is off, with the chosen metadata and an optional colour profile. +#[test] +fn webp_lossy_lossless_transparency_and_metadata() { + let dir = tmp("webp"); + let mut s = session(16); + s.execute("file.fileInfo", json!({"copyright": "© Example", "description": "secret notes"})).unwrap(); + s.execute("edit.assignProfile", json!({"profile": "srgb"})).unwrap(); + let path = |name: &str| format!("{dir}/{name}"); + let mut export = |name: &str, p: Value| { + let mut p = p; + p["format"] = json!("webp"); + p["path"] = json!(path(name)); + s.execute("file.export.saveForWebLegacy", p).unwrap(); + std::fs::read(path(name)).unwrap() + }; + let lo = export("lo.webp", json!({"quality": 5})); + let hi = export("hi.webp", json!({"quality": 95, "embedIcc": true})); + let lossless = export("lossless.webp", json!({"lossless": true, "metadata": "none"})); + let opaque = export("opaque.webp", json!({"transparency": false, "matte": "#00ff00"})); + for bytes in [&lo, &hi, &lossless, &opaque] { + assert_eq!((&bytes[0..4], &bytes[8..12]), (&b"RIFF"[..], &b"WEBP"[..])); + } + assert!(lo.len() < hi.len(), "quality drives the size: {} vs {}", lo.len(), hi.len()); + let has = |b: &[u8], fourcc: &[u8]| b.windows(4).any(|w| w == fourcc); + assert!(has(&hi, b"VP8 ") && has(&hi, b"ALPH"), "lossy keeps translucency in an alpha chunk"); + assert!(has(&lossless, b"VP8L") && !has(&lossless, b"VP8 ")); + assert!(!has(&opaque, b"ALPH"), "transparency off composites over the matte"); + + // Lossless is the PNG-24 export's pixels exactly. + s.execute("file.export.saveForWebLegacy", json!({"format": "png24", "path": path("same.png")})).unwrap(); + let (a, b) = (decode(&path("lossless.webp")), decode(&path("same.png"))); + let rgba = |i: &photocraft_codecs::Image| i.convert(photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8).data().to_vec(); + assert_eq!(rgba(&a), rgba(&b)); + // The transparent corner stays transparent; with transparency off it is the matte. + assert_eq!(rgba(&a)[3], 0); + let o = rgba(&decode(&path("opaque.webp"))); + assert!(o[3] == 255 && o[1] > 200 && o[0] < 60, "{:?}", &o[..4]); + + let meta = decode(&path("hi.webp")); + let xmp = meta.meta.xmp.unwrap_or_default(); + assert!(xmp.contains("Example") && !xmp.contains("secret"), "copyright only"); + assert!(meta.icc.is_some(), "Embed Color Profile"); + assert!(decode(&path("lo.webp")).icc.is_none()); + assert!(a.meta.xmp.is_none(), "metadata none"); +} + +/// Sizes WebP cannot hold are an error, not a crash. +#[test] +fn webp_too_large_is_an_error() { + let mut s = session(8); + for lossless in [false, true] { + let r = s.execute("file.export.saveForWebLegacy", json!({"format": "webp", "lossless": lossless, "width": 20000, "height": 2})); + assert!(r.is_err(), "lossless {lossless}: {r:?}"); + } +} + #[test] fn slices_export_with_html_table() { let dir = tmp("slices"); @@ -621,6 +678,9 @@ fn web_settings_from_params() { assert!((st.web_snap - 0.5).abs() < 1e-6); assert!(WebSettings::from_params(&json!({"preset": "Nope"}), "x").is_err()); assert!(WebSettings::from_params(&json!({"palette": "weird"}), "x").is_err()); + let webp = WebSettings::from_params(&json!({"format": "webp", "lossless": true, "quality": 70}), "x").unwrap(); + assert_eq!((webp.format, webp.lossless, webp.quality), (WebFormat::Webp, true, 70)); + assert_eq!(WebSettings::from_params(&webp.to_params(), "x").unwrap(), webp); for p in PRESETS { let st = preset_settings(p).unwrap(); assert_eq!(WebSettings::from_params(&st.to_params(), "x").unwrap(), st, "{p} round-trips through params"); @@ -633,7 +693,7 @@ fn previews_show_the_optimised_pixels() { let doc = s.active().unwrap().doc.clone(); let (wd, _, _) = web_document(&doc, &json!({}), &WebSettings::default()).unwrap(); let buf = photocraft_compose::flatten(&wd); - for f in ["jpeg", "gif", "png8", "png24", "wbmp"] { + for f in ["jpeg", "gif", "png8", "png24", "wbmp", "webp"] { let st = WebSettings::from_params(&json!({"format": f, "quality": 90, "transparency": false}), "x").unwrap(); let o = optimize(&buf.px, 64, wd.bounds(), &st, None, None, 72.0, true).unwrap(); assert_eq!(o.preview.len(), 64 * 48 * 4, "{f}"); diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs index 3f87f6339c..44a6ecb056 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -199,7 +199,7 @@ fn b(f: &Map, k: &str, d: bool) -> bool { #[path = "file_ui/web_preview.rs"] mod web_preview; -const WEB_FORMATS: [(&str, &str); 5] = [("gif", "GIF"), ("png8", "PNG-8"), ("png24", "PNG-24"), ("jpeg", "JPEG"), ("wbmp", "WBMP")]; +const WEB_FORMATS: [(&str, &str); 6] = [("gif", "GIF"), ("png8", "PNG-8"), ("png24", "PNG-24"), ("jpeg", "JPEG"), ("webp", "WebP"), ("wbmp", "WBMP")]; const WEB_COMMAND: &str = "file.export.saveForWebLegacy"; pub fn open_web(app: &mut PhotocraftApp) -> u64 { @@ -228,6 +228,7 @@ pub fn open_web(app: &mut PhotocraftApp) -> u64 { for (k, v) in [ ("format", json!("jpeg")), ("quality", json!(60)), + ("lossless", json!(false)), ("palette", json!("selective")), ("colors", json!(128)), ("dither", json!("diffusion")), @@ -258,7 +259,7 @@ fn variants(p: &Value) -> Vec { m.remove("colorTable"); m.remove("colorTableEdits"); } - if fmt == "jpeg" { + if fmt == "jpeg" || (fmt == "webp" && !p["lossless"].as_bool().unwrap_or(false)) { v["quality"] = json!((p["quality"].as_f64().unwrap_or(60.0) / k).round().max(1.0)); } else if fmt == "gif" || fmt == "png8" { v["colors"] = json!((p["colors"].as_f64().unwrap_or(128.0) / k).round().max(2.0)); @@ -580,6 +581,14 @@ fn web_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map { + check(ui, f, "lossless", tl!("Lossless"), false); + if !b(f, "lossless", false) { + number(ui, f, "quality", tl!("Quality"), 0.0..=100.0, "", 60.0); + } + check(ui, f, "transparency", tl!("Transparency"), true); + check(ui, f, "embedIcc", tl!("Embed Color Profile"), false); + } "wbmp" => { dropdown_str( ui, @@ -595,7 +604,7 @@ fn web_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-16 · **Change:** minor (Save for Web writes WebP, lossy or lossless, #6391; earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -29,7 +29,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225); palette PNGs open in Indexed Color mode with their transparent entry (#4804) | | GIF | R/W | yes | yes | File › Open keeps the first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the delays (#572). Per-frame delays and keeping the frames in `.pcraft` are not done | | BMP | R/W | yes | yes | 8-bit; palette BMPs open in Indexed Color mode | -| WebP | R/W | yes | yes | Lossless and our own lossy VP8 encoder; ICC/EXIF/XMP; Save keeps an opened file lossy (at its quality) or lossless (#2399) | +| WebP | R/W | yes | yes | Lossless and our own lossy VP8 encoder; ICC/EXIF/XMP; Save keeps an opened file lossy (at its quality) or lossless (#2399); Save for Web (Legacy) writes it too, lossy at its Quality or lossless, a format Photoshop's legacy dialog lacks (#6391) | | AVIF | R/W | no (#463) | optional feature, off by default | | | JPEG XL | R/W | inside DNG only | no | | | HEIF/HEIC | R/W(HEIF) | yes (pure-Rust heic-rs, `heif` feature, on in releases) | no | 8/16-bit | @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Save for Web (Legacy) offers WebP, lossy or lossless, with its own preview and size estimate (#2415, #6391) | | 2026-10-14 | minor | PSD smart filter masks: Photoshop stores a smart object's filtered pixels without its filter mask and mixes the unfiltered ones (`FEid`) back in; import now mixes them and export writes them that way, so sf-filter-mask-hard, -gradient and sf-stack-blend-and-mask pass; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | Lossy DNG (compression 34892) decodes: 8-bit baseline JPEG tiles and strips, CFA and LinearRaw, partial edge tiles; the stored values take the normal levels and LinearizationTable (FILE-215-11, lossy DNG part) | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | From 24326695786cd0f63811618a51f9c97e182ec135 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Fri, 16 Oct 2026 16:26:53 +0530 Subject: [PATCH 02/86] Free Transform moves every selected layer (#6501, #3384, #6096) With several layers selected, Ctrl/Cmd+T framed and transformed only the active layer, whatever its kind: a pixel layer moved while a type layer beside it stayed put (or the other way round). Engine: `edit.transform` takes `layers` and, given no `layer`, moves the selected layers that no selected group carries, leaving out the Background, as Photoshop does. One transform, one history step; every layer is priced and lock-checked before any is built. Transform Again repeats on the selection; Transform Again on a Copy keeps the active layer's frame. UI: the box frames all of them (`begin_several`) and its preview is their composite with styles reaching past the frame, rebuilt when an edit made while it is open changes how they look (children of a moving group and styles of enclosing groups included). Selected pixels are lifted per pixel layer. The Move tool's shared Show Transform Controls frame gets handles. Warp is greyed for several layers, and so are Distort and Perspective while editable type is among them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T48KpM3QwGvfQxtZ1icCRq --- crates/engine/src/edit_cmds.rs | 16 +- crates/engine/src/transform_cmds.rs | 119 +++++-- crates/engine/src/transform_multi_tests.rs | 209 ++++++++++++ crates/ui-egui/src/cam_transform.rs | 1 + crates/ui-egui/src/canvas.rs | 43 +-- crates/ui-egui/src/enable_rules.rs | 17 + crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/snap_ui.rs | 8 +- crates/ui-egui/src/state.rs | 6 + crates/ui-egui/src/transform_several_tests.rs | 304 +++++++++++++++++ crates/ui-egui/src/transform_tool.rs | 315 ++++++++++++++++-- crates/ui-egui/src/type_transform.rs | 1 + docs/gaps.md | 7 +- docs/ui-design.md | 2 +- docs/ui-parity.md | 6 +- 15 files changed, 967 insertions(+), 90 deletions(-) create mode 100644 crates/engine/src/transform_multi_tests.rs create mode 100644 crates/ui-egui/src/transform_several_tests.rs diff --git a/crates/engine/src/edit_cmds.rs b/crates/engine/src/edit_cmds.rs index dcb89aa980..74cbe8b812 100644 --- a/crates/engine/src/edit_cmds.rs +++ b/crates/engine/src/edit_cmds.rs @@ -705,9 +705,10 @@ fn toggle_last_state(s: &mut Session) -> Result { Ok(json!(if redo { s.redo() } else { s.undo() })) } -/// Edit › Transform › Again: replay the last `edit.transform` on the active layer. +/// Edit › Transform › Again: replay the last `edit.transform` on the selected layers (#6501). fn transform_again(s: &mut Session) -> Result { - let p = transform_again_params(s)?; + let ids = s.active().map(crate::transform_cmds::selection_targets).unwrap_or_default(); + let p = transform_again_params(s, &ids)?; s.execute("edit.transform", p) } @@ -715,8 +716,8 @@ fn transform_again(s: &mut Session) -> Result { /// last transform on the copy, as one history step, so pressing it again steps and repeats /// (#352). The transform is worked out before anything changes, so a refusal leaves no copy. fn transform_again_copy(s: &mut Session) -> Result { - let p = transform_again_params(s)?; let id = active_id(s)?; + let p = transform_again_params(s, &[id])?; let journal = s.journal.len(); // The steps are recorded (Again on a Copy is not replayable itself): with one layer selected, // "duplicate the current layer" plays back on any document, a layer id only on this one. @@ -742,8 +743,9 @@ fn transform_again_copy(s: &mut Session) -> Result { } } -/// The `edit.transform` params that repeat the last transform on the active layer. -fn transform_again_params(s: &Session) -> Result { +/// The `edit.transform` params that repeat the last transform on the layers `ids` (their frame +/// together is the box it starts from). +fn transform_again_params(s: &Session, ids: &[LayerId]) -> Result { let (_, last) = s.journal.iter().rev().find(|(id, _)| id == "edit.transform").cloned().ok_or(EngineError::Other("there is no transform to repeat".into()))?; let (rect, quad) = (last.get("rect").cloned(), last.get("quad").cloned()); @@ -762,8 +764,8 @@ fn transform_again_params(s: &Session) -> Result { ) .ok_or(EngineError::Other("the last transform can't be repeated".into()))?; let d = s.active().ok_or(EngineError::NoDocument)?; - let l = d.doc.layer(active_id(s)?).ok_or(EngineError::Other("no active layer".into()))?; - let b = crate::transform_cmds::transform_bounds(&d.doc, l); + d.doc.layer(active_id(s)?).ok_or(EngineError::Other("no active layer".into()))?; + let b = crate::transform_cmds::transform_bounds_of(&d.doc, ids); if b.is_empty() { return Err(EngineError::Other("nothing to transform".into())); } diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index 1c0153a42e..a81114cfd1 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -257,6 +257,51 @@ pub fn locked_for_transform(doc: &Document, id: LayerId) -> bool { false } +/// The layers Free Transform moves with the Layers panel's selection, bottom-to-top: with several +/// layers selected, every one that no selected group already carries, as in Photoshop (#6501), but +/// the Background, which Show Transform Controls doesn't frame either. With one layer selected (or +/// only the Background besides it), that layer; the active layer when nothing else is. +pub fn selection_targets(st: &crate::DocState) -> Vec { + let selected = st.selected_layers(); + if selected.len() < 2 { + return st.active_layer.into_iter().collect(); + } + let ids: Vec = crate::layer_multi_cmds::top_level(&st.doc, &selected).into_iter().filter(|id| !st.doc.is_background(*id)).collect(); + if ids.is_empty() { st.active_layer.into_iter().collect() } else { ids } +} + +/// The `layers` param: the layers one box transforms together (at most 10000, each in the +/// document), without those a listed group carries. `None` when it isn't given. +fn layers_param(doc: &Document, p: &Value) -> Result>> { + let a = match p.get("layers") { + None | Some(Value::Null) => return Ok(None), + Some(Value::Array(a)) if !a.is_empty() && a.len() <= 10_000 => a, + Some(v) => return Err(bad(format!("`layers` is a non-empty list of at most 10000 layer ids, got {v}"))), + }; + let mut ids = Vec::with_capacity(a.len()); + for v in a { + let id = v.as_u64().map(LayerId).ok_or_else(|| bad(format!("`layers` holds layer ids, got {v}")))?; + if doc.layer(id).is_none() { + return Err(EngineError::NoLayer(id)); + } + if !ids.contains(&id) { + ids.push(id); + } + } + Ok(Some(crate::layer_multi_cmds::top_level(doc, &ids))) +} + +/// The box Free Transform opens on the layers `ids` together: their frames ([`transform_frame`]) +/// joined, as Show Transform Controls draws it. Empty when none of them has anything to move. +pub fn transform_frame_of(doc: &Document, ids: &[LayerId]) -> Rect { + ids.iter().filter_map(|id| doc.layer(*id)).map(|l| transform_frame(doc, l)).filter(|b| !b.is_empty()).fold(Rect::EMPTY, |a, b| a.union(&b)) +} + +/// [`transform_bounds`] of the layers `ids` together. +pub fn transform_bounds_of(doc: &Document, ids: &[LayerId]) -> Rect { + ids.iter().filter_map(|id| doc.layer(*id)).map(|l| transform_bounds(doc, l)).filter(|b| !b.is_empty()).fold(Rect::EMPTY, |a, b| a.union(&b)) +} + /// The largest result (px) a single Free Transform warp may build: 2³⁰, the bound Puppet Warp uses /// too ([`photocraft_algo::puppet::MAX_WARP_PIXELS`]). Only the size of the result counts, not /// where it lands, so moving a layer far off the canvas stays allowed. A scale past it is refused @@ -573,6 +618,7 @@ fn with_panel_target(s: &Session, p: &Value) -> Value { let mut p = if p.is_object() { p.clone() } else { json!({}) }; if p.get("target").is_none() && p.get("layer").is_none() + && p.get("layers").is_none() && let (Some(m), Some(Value::Object(t))) = (p.as_object_mut(), crate::channel_cmds::targeted_channel(s)) { m.extend(t); @@ -583,23 +629,38 @@ fn with_panel_target(s: &Session, p: &Value) -> Value { fn transform(s: &mut Session, p: &Value) -> Result { let p = &with_panel_target(s, p); let st = s.active().ok_or(EngineError::NoDocument)?; - let id = crate::commands::explicit_layer(s, p)?.or(st.active_layer); - let lone = lone_target(&st.doc, id, p)?; + let many = layers_param(&st.doc, p)?; + if many.is_some() && (p.get("layer").is_some() || !matches!(crate::channel_cmds::target_of(p), crate::channel_cmds::Target::Pixels)) { + return Err(bad("`layers` moves layers: pass it without `layer` or a mask or channel `target`")); + } + let explicit = crate::commands::explicit_layer(s, p)?; + let id = explicit.or(st.active_layer); + let lone = if many.is_some() { None } else { lone_target(&st.doc, id, p)? }; let id = match (id, lone.is_some() && crate::channel_cmds::is_channel_target(p)) { (_, true) => None, (Some(id), false) => Some(id), + (None, false) if many.is_some() => None, (None, false) => return Err(EngineError::Other("no active layer".into())), }; + // The layers the box moves: those listed, the one named, or else the layer selection (#6501). + let ids: Vec = match (many, explicit) { + (Some(ids), _) => ids, + _ if lone.is_some() => Vec::new(), + (None, Some(id)) => vec![id], + (None, None) => selection_targets(st), + }; + if let Some(missing) = ids.iter().find(|id| st.doc.layer(**id).is_none()) { + return Err(EngineError::NoLayer(*missing)); + } let rect = match p.get("rect").and_then(Value::as_array) { Some(r) if r.len() == 4 => { let v: Vec = r.iter().map(|x| x.as_f64().unwrap_or(0.0)).collect(); [v[0], v[1], v[2], v[3]] } _ => { - let b = match (lone, id) { - (Some(surf), _) => target_bounds(&st.doc, surf), - (None, Some(id)) => transform_bounds(&st.doc, st.doc.layer(id).ok_or(EngineError::NoLayer(id))?), - (None, None) => Rect::EMPTY, + let b = match lone { + Some(surf) => target_bounds(&st.doc, surf), + None => transform_bounds_of(&st.doc, &ids), }; if b.is_empty() { return Err(EngineError::Other("nothing to transform".into())); @@ -629,22 +690,25 @@ fn transform(s: &mut Session, p: &Value) -> Result { (m[6].abs() < 1e-12 && m[7].abs() < 1e-12).then(|| Affine { m: [m[0] / m[8], m[3] / m[8], m[1] / m[8], m[4] / m[8], m[2] / m[8], m[5] / m[8]] }); let interp = Interp::parse(p.get("interpolation").and_then(Value::as_str).unwrap_or("bicubic")); let lone = lone.is_some(); + let layers: Vec = ids.iter().map(|id| id.0).collect(); let p = p.clone(); // A background job when started with `Session::start` (#1015): the warp of a large layer runs // on a worker with progress, and can be cancelled. crate::jobs::edit_job( s, "Free Transform", - move |doc, _, ctx| ctx.stage(0.0, 1.0, "Transforming", |ctl| transform_doc(doc, id, &p, lone, &h, affine, interp, ctl)), - move |()| json!({"rect": rect}), + move |doc, _, ctx| ctx.stage(0.0, 1.0, "Transforming", |ctl| transform_doc(doc, id, &ids, &p, lone, &h, affine, interp, ctl)), + move |()| json!({"rect": rect, "layers": layers}), ) } -/// The edit of [`transform`]: the layer (or a lone mask or channel) and the selection outline. +/// The edit of [`transform`]: the layers `ids` (or the lone mask or channel of `id`) and the +/// selection outline. #[allow(clippy::too_many_arguments)] fn transform_doc( doc: &mut Document, id: Option, + ids: &[LayerId], p: &Value, lone: bool, h: &Homography, @@ -673,18 +737,31 @@ fn transform_doc( } return Ok(()); } - let id = id.ok_or_else(|| EngineError::Other("no active layer".into()))?; - let is_group = doc.layer(id).is_some_and(Layer::is_group); - let group = group_locks(doc, id); - // Photoshop turns the Background into a normal layer before transforming it. - crate::extra_cmds::background_to_layer(doc, id); - let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; - transform_layer_with(if is_group { None } else { sel.as_ref() }, group, l, h, affine, interp, ctl)?; + if ids.is_empty() { + return Err(EngineError::Other("no active layer".into())); + } + // A group moves whole: the selection doesn't limit what is inside it. + let layer_sel = |l: &Layer| if l.is_group() { None } else { sel.as_ref() }; + // Every layer is priced before any is built, so one that doesn't fit leaves all as they were. + for &id in ids { + let l = doc.layer(id).ok_or(EngineError::NoLayer(id))?; + check_layer_budget(layer_sel(l), l, h)?; + } + for &id in ids { + let group = group_locks(doc, id); + // Photoshop turns the Background into a normal layer before transforming it. + crate::extra_cmds::background_to_layer(doc, id); + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + let doc_sel = layer_sel(l); + apply_transform(doc_sel, group, l, h, affine, interp, ctl)?; + } if !h.is_identity() { // Type layers re-render from their new transform. let snapshot = doc.clone(); - if let Some(l) = doc.layer_mut(id) { - refresh_text(&snapshot, l); + for &id in ids { + if let Some(l) = doc.layer_mut(id) { + refresh_text(&snapshot, l); + } } // The selection outline moves with the pixels. if let Some(sel) = &doc.selection { @@ -700,7 +777,7 @@ pub fn specs() -> Vec { label: "Free Transform", menu: &[], shortcut: None, - params: r##"{"layer":id?,"rect":[x0,y0,x1,y1]? (source frame; default = layer content ∩ selection),"quad":[[x,y]×4]? (where the frame's corners go, clockwise from top-left),"matrix":[a,b,c,d,e,f]? (affine alternative),"scaleX":%?,"scaleY":%?,"rotate":deg?,"dx":px?,"dy":px? (relative alternative: scale and rotate about the frame's centre, then move, as a Photoshop action records a Free Transform),"interpolation":"bicubic|bilinear|nearest"="bicubic","target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask transforms alone; a linked mask moves with its layer)}"##, + params: r##"{"layer":id?,"layers":[id,…]? (several layers moved by one box, one step; default with neither = the selected layers but the Background, as Photoshop's Free Transform with several selected),"rect":[x0,y0,x1,y1]? (source frame; default = the layers' content ∩ selection),"quad":[[x,y]×4]? (where the frame's corners go, clockwise from top-left),"matrix":[a,b,c,d,e,f]? (affine alternative),"scaleX":%?,"scaleY":%?,"rotate":deg?,"dx":px?,"dy":px? (relative alternative: scale and rotate about the frame's centre, then move, as a Photoshop action records a Free Transform),"interpolation":"bicubic|bilinear|nearest"="bicubic","target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask transforms alone; a linked mask moves with its layer)}"##, enabled: has_layer, journal: true, run: transform, @@ -1424,3 +1501,7 @@ mod tests { } } } + +#[cfg(test)] +#[path = "transform_multi_tests.rs"] +mod multi_tests; diff --git a/crates/engine/src/transform_multi_tests.rs b/crates/engine/src/transform_multi_tests.rs new file mode 100644 index 0000000000..d8f508f50b --- /dev/null +++ b/crates/engine/src/transform_multi_tests.rs @@ -0,0 +1,209 @@ +//! Free Transform of several selected layers by one box (#6501). + +use super::*; + +/// A 200 x 200 document: a red 40 x 20 block on "Paint" (x 10..50, y 10..30) and a type layer +/// over it, both selected, the type layer active (as after creating it and ⌘-clicking Paint). +fn two_layers() -> (Session, LayerId, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 200, "background": "white"})).unwrap(); + let paint = LayerId(s.execute("layer.new.layer", json!({"name": "Paint"})).unwrap()["layer"].as_u64().unwrap()); + s.edit("paint", |doc, _| { + doc.layer_mut(paint).unwrap().surface_mut().unwrap().fill_rect(Rect::new(10, 10, 50, 30), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + let text = LayerId(s.execute("type.create", json!({"x": 80, "y": 120, "text": "Hi", "size": 30})).unwrap()["layer"].as_u64().unwrap()); + s.execute("layer.select", json!({"layer": paint.0})).unwrap(); + s.execute("layer.select", json!({"layer": text.0, "mode": "add"})).unwrap(); + (s, paint, text) +} + +fn bounds(s: &Session, id: LayerId) -> Rect { + let st = s.active().unwrap(); + transform_bounds(&st.doc, st.doc.layer(id).unwrap()) +} + +fn text_matrix(s: &Session, id: LayerId) -> [f64; 6] { + let st = s.active().unwrap(); + let LayerContent::Text(t) = &st.doc.layer(id).unwrap().content else { panic!("not a type layer") }; + t.transform.m +} + +#[test] +fn free_transform_moves_every_selected_layer_in_one_step() { + let (mut s, paint, text) = two_layers(); + let (p0, t0, m0) = (bounds(&s, paint), bounds(&s, text), text_matrix(&s, text)); + let steps = s.active().unwrap().history.past_len(); + // Before #6501 only the active layer moved, whichever kind it was. + let r = s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 30, 40]})).unwrap(); + assert_eq!(r["layers"], json!([paint.0, text.0])); + assert_eq!(bounds(&s, paint), p0.translate(30, 40)); + assert_eq!(bounds(&s, text), t0.translate(30, 40)); + let st = s.active().unwrap(); + assert_eq!(st.history.past_len(), steps + 1, "one history step"); + let history = s.execute("document.inspect", json!({})).unwrap()["history"].clone(); + assert_eq!(history.as_array().unwrap().last(), Some(&json!("Free Transform"))); + // The type stays editable: it keeps the transform instead of pixels. + assert_eq!(text_matrix(&s, text)[4..], [m0[4] + 30.0, m0[5] + 40.0]); + s.undo(); + assert_eq!((bounds(&s, paint), bounds(&s, text)), (p0, t0), "and one Undo takes both back"); +} + +#[test] +fn the_default_frame_is_the_selected_layers_together() { + let (mut s, paint, text) = two_layers(); + let both = bounds(&s, paint).union(&bounds(&s, text)); + let m0 = text_matrix(&s, text); + // 200 % about the joined frame's centre: each layer scales about that point, not its own. + let r = s.execute("edit.transform", json!({"scaleX": 200, "scaleY": 200})).unwrap(); + let rect: Vec = serde_json::from_value(r["rect"].clone()).unwrap(); + assert_eq!(rect, [both.x0, both.y0, both.x1, both.y1].map(f64::from)); + let (cx, cy) = (f64::from(both.x0 + both.x1) / 2.0, f64::from(both.y0 + both.y1) / 2.0); + let p = bounds(&s, paint); + assert_eq!((f64::from(p.x0), f64::from(p.y0)), (2.0 * 10.0 - cx, 2.0 * 10.0 - cy)); + let m = text_matrix(&s, text); + assert!((m[0] - 2.0).abs() < 1e-9 && (m[3] - 2.0).abs() < 1e-9, "{m:?}"); + assert!((m[4] - (2.0 * m0[4] - cx)).abs() < 1e-9 && (m[5] - (2.0 * m0[5] - cy)).abs() < 1e-9, "{m:?}"); +} + +#[test] +fn an_explicit_layer_still_transforms_that_layer_alone() { + let (mut s, paint, text) = two_layers(); + let t0 = bounds(&s, text); + let r = s.execute("edit.transform", json!({"layer": paint.0, "matrix": [1, 0, 0, 1, 5, 0]})).unwrap(); + assert_eq!(r["layers"], json!([paint.0])); + assert_eq!(bounds(&s, paint), Rect::new(15, 10, 55, 30)); + assert_eq!(bounds(&s, text), t0); +} + +#[test] +fn the_layers_param_names_the_layers_and_a_listed_group_carries_its_children() { + let (mut s, paint, text) = two_layers(); + let t0 = bounds(&s, text); + s.execute("layer.select", json!({"layer": paint.0})).unwrap(); + let group = LayerId(s.execute("layer.groupLayers", json!({})).unwrap()["layer"].as_u64().unwrap()); + // The group and the layer in it: the layer moves once, with its group. + let r = s.execute("edit.transform", json!({"layers": [paint.0, group.0], "matrix": [1, 0, 0, 1, 7, 0]})).unwrap(); + assert_eq!(r["layers"], json!([group.0])); + assert_eq!(bounds(&s, paint), Rect::new(17, 10, 57, 30)); + assert_eq!(bounds(&s, text), t0, "not listed, not moved"); +} + +#[test] +fn the_background_stays_out_of_a_selection_of_several() { + let (mut s, paint, text) = two_layers(); + let bg = s.active().unwrap().doc.layers[0].id; + s.execute("layer.select", json!({"layer": bg.0, "mode": "add"})).unwrap(); + let r = s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 3, 0]})).unwrap(); + assert_eq!(r["layers"], json!([paint.0, text.0])); + let st = s.active().unwrap(); + assert!(st.doc.is_background(bg), "still the Background"); + assert_eq!(st.doc.layer(bg).unwrap().surface().unwrap().content_bounds(), Rect::new(0, 0, 200, 200)); +} + +#[test] +fn a_selection_limits_the_pixels_of_every_selected_layer() { + let (mut s, paint, text) = two_layers(); + let t0 = bounds(&s, text); + // The left half of the block: only those pixels of Paint move; the type moves whole (#2630). + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 30, "height": 200})).unwrap(); + s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 0, 100]})).unwrap(); + let st = s.active().unwrap(); + let surf = st.doc.layer(paint).unwrap().surface().unwrap(); + assert_eq!(surf.content_bounds(), Rect::new(10, 10, 50, 130)); + assert_eq!(surf.sample_channel(20, 15, 3), 0.0, "the selected part left"); + assert_eq!(surf.sample_channel(40, 15, 3), 1.0, "the rest stayed"); + assert_eq!(surf.sample_channel(20, 115, 3), 1.0, "and landed below"); + assert_eq!(bounds(&s, text), t0.translate(0, 100)); +} + +#[test] +fn a_locked_layer_among_them_refuses_the_whole_transform() { + let (mut s, paint, text) = two_layers(); + s.execute("layer.setProps", json!({"layer": text.0, "locks": {"position": true}})).unwrap(); + s.execute("layer.select", json!({"layer": paint.0})).unwrap(); + s.execute("layer.select", json!({"layer": text.0, "mode": "add"})).unwrap(); + let st = s.active().unwrap(); + let (before, revision) = (st.doc.clone(), st.revision); + let err = s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 5, 0]})).unwrap_err().to_string(); + assert!(err.contains("locked"), "{err}"); + let st = s.active().unwrap(); + assert!(st.doc == before && st.revision == revision, "nothing moved, not even the unlocked layer"); +} + +#[test] +fn one_layer_past_the_pixel_budget_leaves_every_layer_as_it_was() { + let (mut s, paint, _) = two_layers(); + // A 1 px dot, beside the 40 px block: only the block, stretched, outgrows the test budget. + let dot = LayerId(s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap()); + s.edit("dot", |doc, _| { + doc.layer_mut(dot).unwrap().surface_mut().unwrap().fill_rect(Rect::new(10, 40, 11, 41), &[0.0, 1.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + let st = s.active().unwrap(); + let (before, revision) = (st.doc.clone(), st.revision); + let k = 4e4; + assert!(s.execute("edit.transform", json!({"layers": [dot.0, paint.0], "matrix": [k, 0, 0, 1, -10.0 * k, 0]})).is_err()); + let st = s.active().unwrap(); + assert!(st.doc == before && st.revision == revision); +} + +#[test] +fn distort_with_type_among_the_layers_is_refused() { + let (mut s, _, _) = two_layers(); + let before = s.active().unwrap().doc.clone(); + let quad = json!({"rect": [0, 0, 100, 100], "quad": [[0, 0], [100, 0], [120, 100], [-20, 100]]}); + assert!(s.execute("edit.transform", quad).is_err()); + assert!(s.active().unwrap().doc == before); +} + +#[test] +fn transform_again_repeats_on_every_selected_layer() { + let (mut s, paint, text) = two_layers(); + let (p0, t0) = (bounds(&s, paint), bounds(&s, text)); + let both = p0.union(&t0); + let r = [both.x0, both.y0, both.x1, both.y1].map(f64::from); + let quad = [[r[0] + 10.0, r[1]], [r[2] + 10.0, r[1]], [r[2] + 10.0, r[3]], [r[0] + 10.0, r[3]]]; + // Nearest neighbour: no resampling fringe to blur the bounds. + s.execute("edit.transform", json!({"rect": r, "quad": quad, "interpolation": "nearest"})).unwrap(); + s.execute("edit.transform.again", json!({})).unwrap(); + assert_eq!(bounds(&s, paint), p0.translate(20, 0)); + assert_eq!(bounds(&s, text), t0.translate(20, 0)); +} + +#[test] +fn transform_again_on_a_copy_copies_and_moves_the_active_layer_only() { + let (mut s, paint, text) = two_layers(); + s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 10, 0]})).unwrap(); + let (p1, t1) = (bounds(&s, paint), bounds(&s, text)); + s.execute("edit.transform.againCopy", json!({})).unwrap(); + let st = s.active().unwrap(); + let copy = st.active_layer.unwrap(); + assert!(copy != paint && copy != text); + assert_eq!(bounds(&s, copy), t1.translate(10, 0), "the copy of the active type layer moved on"); + assert_eq!((bounds(&s, paint), bounds(&s, text)), (p1, t1), "the originals stayed"); +} + +#[test] +fn bad_layers_params_are_errors_not_panics() { + let (mut s, paint, _) = two_layers(); + let before = s.active().unwrap().doc.clone(); + let m = json!([1, 0, 0, 1, 5, 0]); + for p in [ + json!({"layers": [], "matrix": m}), + json!({"layers": "all", "matrix": m}), + json!({"layers": [paint.0, "x"], "matrix": m}), + json!({"layers": [-1], "matrix": m}), + json!({"layers": [987_654], "matrix": m}), + json!({"layers": [u64::MAX], "matrix": m}), + json!({"layers": (0..10_001).collect::>(), "matrix": m}), + json!({"layers": [paint.0], "layer": paint.0, "matrix": m}), + json!({"layers": [paint.0], "target": "quickMask", "matrix": m}), + json!({"layers": [paint.0], "target": {"channel": 0}, "matrix": m}), + ] { + assert!(s.execute("edit.transform", p.clone()).is_err(), "{p}"); + } + assert!(s.active().unwrap().doc == before); +} diff --git a/crates/ui-egui/src/cam_transform.rs b/crates/ui-egui/src/cam_transform.rs index 995645940e..3677a68835 100644 --- a/crates/ui-egui/src/cam_transform.rs +++ b/crates/ui-egui/src/cam_transform.rs @@ -60,6 +60,7 @@ pub(crate) fn begin(app: &mut PhotocraftApp, offset: [i32; 2]) -> bool { mode: Default::default(), content_aware: None, skew_v: 0.0, + layers: Vec::new(), }; app.ui.cam_transform = Some(CamTransform { document, revision, offset, frame, gesture: None }); app.ui.status = crate::i18n::fmt(tl!("Scale or rotate the moved area, then press {key}"), &[("key", &crate::shortcuts::pretty("Enter"))]); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 57d3575111..06c9e7a197 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3879,10 +3879,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // corner badge, as Photoshop's corner radius cursor. ui.ctx().set_cursor_icon(egui::CursorIcon::Default); crate::icons::cursor_badge(&ctx, "scan", p); - } else if let Some((r, p, c)) = response - .hover_pos() - .filter(|_| !transform_controls_several(app)) - .and_then(|p| transform_controls_rect(app, &xf).and_then(|r| transform_controls_cursor(r, p).map(|c| (r, p, c)))) + } else if let Some((r, p, c)) = + response.hover_pos().and_then(|p| transform_controls_rect(app, &xf).and_then(|r| transform_controls_cursor(r, p).map(|c| (r, p, c)))) { // The Move tool draws handles without opening a Free Transform session. // Give them their resize/rotate cursors before the first pointer press (#2871). @@ -4731,12 +4729,6 @@ fn transform_controls_layers(app: &PhotocraftApp) -> Vec<&photocraft_doc::Layer> st.selected_layers().into_iter().filter_map(|id| st.doc.layer(id)).filter(|l| !crate::doc_props_ui::is_background(&st.doc, l)).collect() } -/// Several layers are selected: Show Transform Controls frames them all, as Photoshop does, but -/// Free Transform works on one layer, so the frame has no handles to scale or rotate them. -fn transform_controls_several(app: &PhotocraftApp) -> bool { - transform_controls_layers(app).len() > 1 -} - /// Move tool › Show Transform Controls: the boxes Free Transform would open on the selected /// layers, joined into one. fn transform_controls_rect(app: &PhotocraftApp, xf: &ViewXform) -> Option { @@ -4800,11 +4792,9 @@ fn transform_controls_hit(r: Rect, p: Pos2) -> bool { transform_controls_cursor(r, p).is_some() } -/// Enter the existing Free Transform session when a Move-tool transform control is pressed. +/// Enter the existing Free Transform session when a Move-tool transform control is pressed: on +/// every selected layer, as the frame shows them (#6501). fn begin_transform_controls_at(app: &mut PhotocraftApp, ctx: &egui::Context, xf: &ViewXform, p: Pos2) -> bool { - if transform_controls_several(app) { - return false; - } let Some(r) = transform_controls_rect(app, xf) else { return false }; if !transform_controls_hit(r, p) { return false; @@ -4818,8 +4808,8 @@ fn begin_transform_controls_at(app: &mut PhotocraftApp, ctx: &egui::Context, xf: } } -/// Move tool › Show Transform Controls: the selected layers' bounding box, with its eight handles -/// for one layer, and the outline of every selected shape layer, as in Photoshop. +/// Move tool › Show Transform Controls: the selected layers' bounding box, with its eight handles, +/// and the outline of every selected shape layer, as in Photoshop. fn draw_transform_controls(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { let Some(r) = transform_controls_rect(app, xf) else { return }; let accent = crate::theme::Tokens::get(painter.ctx()).accent; @@ -4830,9 +4820,6 @@ fn draw_transform_controls(app: &mut PhotocraftApp, painter: &egui::Painter, xf: } } painter.rect_stroke(r, 0.0, Stroke::new(1.0, accent), egui::StrokeKind::Middle); - if transform_controls_several(app) { - return; - } for p in [r.left_top(), r.center_top(), r.right_top(), r.right_center(), r.right_bottom(), r.center_bottom(), r.left_bottom(), r.left_center()] { let h = Rect::from_center_size(p, vec2(7.0, 7.0)); painter.rect_filled(h, 0.0, Color32::WHITE); @@ -8196,34 +8183,36 @@ mod transform_controls_tests { use serde_json::json; #[test] - fn several_selected_layers_share_one_frame_without_handles() { - // As in Photoshop: Show Transform Controls frames every selected layer. Free Transform - // works on one layer, so the shared frame doesn't start it. + fn several_selected_layers_share_one_frame_that_transforms_them_all() { + // As in Photoshop: Show Transform Controls frames every selected layer, and its handles + // start one Free Transform of all of them (#6501). let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); app.session.execute("shape.create", json!({"kind": "rect", "rect": [20, 30, 40, 40], "fill": "#ff0000"})).unwrap(); let a = app.session.active().unwrap().active_layer.unwrap(); app.session.execute("shape.create", json!({"kind": "rect", "rect": [100, 120, 50, 30], "fill": "#ff0000"})).unwrap(); + let b = app.session.active().unwrap().active_layer.unwrap(); app.sync_views(); app.ui.tool = Tool::Move; app.ui.tool_options.move_show_transform = true; let xf = ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)), zoom: 1.0, center: [100.0, 100.0], flip: false, rotation: 0.0, aspect: 1.0 }; let one = transform_controls_rect(&app, &xf).unwrap(); - assert!(!transform_controls_several(&app)); app.session.execute("layer.select", json!({"layer": a.0, "mode": "add"})).unwrap(); - assert!(transform_controls_several(&app)); let both = transform_controls_rect(&app, &xf).unwrap(); assert!(both.contains_rect(one) && both.width() > one.width() && both.height() > one.height(), "{both:?} frames both"); let (lt, rb) = (xf.to_doc(both.left_top()), xf.to_doc(both.right_bottom())); assert!((lt[0] - 20.0).abs() < 1e-3 && (lt[1] - 30.0).abs() < 1e-3 && (rb[0] - 150.0).abs() < 1e-3 && (rb[1] - 150.0).abs() < 1e-3, "{lt:?} {rb:?}"); - let ctx = egui::Context::default(); - assert!(!begin_transform_controls_at(&mut app, &ctx, &xf, both.right_bottom())); - assert!(app.ui.transform.is_none()); // The Background never joins the frame. let bg = app.session.active().unwrap().doc.layers[0].id; app.session.execute("layer.select", json!({"layer": bg.0, "mode": "add"})).unwrap(); assert_eq!(transform_controls_layers(&app).len(), 2); + assert_eq!(transform_controls_rect(&app, &xf), Some(both)); + let ctx = egui::Context::default(); + assert!(begin_transform_controls_at(&mut app, &ctx, &xf, both.right_bottom())); + let t = app.ui.transform.as_ref().expect("the shared frame's handle starts Free Transform"); + assert_eq!(t.rect, [20.0, 30.0, 150.0, 150.0]); + assert_eq!(t.layers, [a.0, b.0]); } #[test] diff --git a/crates/ui-egui/src/enable_rules.rs b/crates/ui-egui/src/enable_rules.rs index e3712c1a9a..6abf64fd1d 100644 --- a/crates/ui-egui/src/enable_rules.rs +++ b/crates/ui-egui/src/enable_rules.rs @@ -126,6 +126,23 @@ pub fn disabled(app: &crate::PhotocraftApp, id: &str) -> bool { if transform && (target.is_object() || target == "quickMask") { return false; } + // Several layers selected: one box moves them all, the Background left out (#6501). It doesn't + // warp, and doesn't distort while editable type is among them. + if transform { + let targets = photocraft_engine::transform_cmds::selection_targets(st); + if targets.len() > 1 || targets.first() != st.active_layer.as_ref() { + let text = targets.iter().any(|t| st.doc.layer(*t).is_some_and(|l| matches!(l.content, LayerContent::Text(_)))); + return match id { + "edit.transform.warp" + | "edit.transform.splitWarpCrosswise" + | "edit.transform.splitWarpHorizontally" + | "edit.transform.splitWarpVertically" + | "edit.transform.removeWarpSplit" => true, + "edit.transform.distort" | "edit.transform.perspective" => text, + _ => false, + }; + } + } // Editable type can be scaled, rotated and skewed but not distorted (#2630): Photoshop greys // Distort and Perspective until the type is rasterized, and the split warps (Warp on type // offers only the warp styles, #3004). A targeted unlinked mask or channel of the type layer diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bde51ff27a..3cb3121eee 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2689,6 +2689,9 @@ mod transform_undo_tests; #[cfg(test)] mod transform_type_tests; +#[cfg(test)] +mod transform_several_tests; + #[cfg(test)] mod save_identity_tests; diff --git a/crates/ui-egui/src/snap_ui.rs b/crates/ui-egui/src/snap_ui.rs index e0b122c373..d562c1fc0b 100644 --- a/crates/ui-egui/src/snap_ui.rs +++ b/crates/ui-egui/src/snap_ui.rs @@ -195,15 +195,17 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) { let crop_tol = crate::crop_ui::tolerance(app); let gesture = if let Some(t) = &app.ui.transform { let corner = match crate::transform_tool::hit(t, p, crate::transform_tool::handle_tolerance(app)) { - crate::transform_tool::Hit::Corner(index) => t.quad.get(index).map(|&point| (Gesture::TransformCorner { index, point }, vec![LayerId(t.layer)])), + crate::transform_tool::Hit::Corner(index) => { + t.quad.get(index).map(|&point| (Gesture::TransformCorner { index, point }, crate::transform_tool::moving_layers(t))) + } _ => None, }; if t.mode == crate::state::TransformMode::Distort { corner } else if near_handle(&t.quad, p, crate::transform_tool::handle_tolerance(app)) { - corner.or_else(|| Some((Gesture::Point, vec![LayerId(t.layer)]))) + corner.or_else(|| Some((Gesture::Point, crate::transform_tool::moving_layers(t)))) } else if in_quad(&t.quad, p) { - Some((Gesture::TransformMove { rect: quad_rect(&t.quad) }, vec![LayerId(t.layer)])) + Some((Gesture::TransformMove { rect: quad_rect(&t.quad) }, crate::transform_tool::moving_layers(t))) } else { None } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 5cbda434e7..559322f192 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -936,6 +936,12 @@ pub struct TransformSession { /// skew and rotation with any vertical skew, so the bar keeps showing the typed one. #[serde(default)] pub skew_v: f64, + /// Free Transform of several selected layers (#6501): every layer the box moves, in + /// `edit.transform`'s `layers` order. Empty when it moves `layer` alone. `layer` stays the + /// active layer, which need not be one of them (the Background is left out, a selected group + /// carries its selected children). + #[serde(default)] + pub layers: Vec, } /// Content-Aware Scale's options bar: Amount, Protect and Protect Skin Tones. diff --git a/crates/ui-egui/src/transform_several_tests.rs b/crates/ui-egui/src/transform_several_tests.rs new file mode 100644 index 0000000000..30330a5b6f --- /dev/null +++ b/crates/ui-egui/src/transform_several_tests.rs @@ -0,0 +1,304 @@ +//! Free Transform with several layers selected (#6501): ⌘T frames them all and the box moves +//! every one of them, type and pixels alike, in one history step. + +use photocraft_doc::{LayerContent, LayerId}; +use photocraft_geom::Rect; +use serde_json::json; + +use crate::PhotocraftApp; + +/// A 200 × 200 document: "Paint" with a red block at x 10..50, y 10..30, and a type layer over it. +/// Both are selected with Paint active, as after ⌘-clicking Paint in the Layers panel (the +/// reporter's steps). +fn app() -> (PhotocraftApp, LayerId, LayerId) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 200, "background": "white"})).unwrap(); + app.sync_views(); + let paint = LayerId(app.run("layer.new.layer", json!({"name": "Paint"})).unwrap()["layer"].as_u64().unwrap()); + app.run("select.rect", json!({"x": 10, "y": 10, "width": 40, "height": 20})).unwrap(); + app.run("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + let text = LayerId(app.run("type.create", json!({"x": 80, "y": 120, "text": "Hi", "size": 30})).unwrap()["layer"].as_u64().unwrap()); + app.run("layer.select", json!({"layer": text.0})).unwrap(); + app.run("layer.select", json!({"layer": paint.0, "mode": "add"})).unwrap(); + assert_eq!(app.session.active().unwrap().active_layer, Some(paint)); + (app, paint, text) +} + +fn bounds(app: &PhotocraftApp, id: LayerId) -> Rect { + let st = app.session.active().unwrap(); + photocraft_engine::transform_cmds::transform_bounds(&st.doc, st.doc.layer(id).unwrap()) +} + +fn free_transform(app: &mut PhotocraftApp) { + crate::menus::invoke(app, &egui::Context::default(), "edit.freeTransform", json!({})).unwrap(); +} + +#[test] +fn free_transform_frames_every_selected_layer_and_moves_them_all() { + let (mut app, paint, text) = app(); + let (p0, t0) = (bounds(&app, paint), bounds(&app, text)); + free_transform(&mut app); + let t = app.ui.transform.as_ref().unwrap(); + let both = p0.union(&t0); + assert_eq!(t.rect, [both.x0, both.y0, both.x1, both.y1].map(f64::from), "the box frames both layers"); + assert_eq!((t.layer, t.layers.clone()), (paint.0, vec![paint.0, text.0])); + // Both move with the box: neither stays behind on the canvas under it. + let pv = app.transform_preview.as_ref().unwrap(); + assert!(!pv.doc.layer(paint).unwrap().visible && !pv.doc.layer(text).unwrap().visible); + let steps = app.session.active().unwrap().history.past_len(); + // A quarter turn about the box's centre. + let r = t.rect; + let (cx, cy) = ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0); + if let Some(t) = app.ui.transform.as_mut() { + t.quad = t.quad.map(|[x, y]| [cx - (y - cy), cy + (x - cx)]); + } + crate::transform_tool::commit(&mut app); + assert_eq!(app.ui.status, ""); + assert!(app.ui.transform.is_none()); + let (p1, t1) = (bounds(&app, paint), bounds(&app, text)); + assert!(p1.width().abs_diff(p0.height()) <= 1 && p1.height().abs_diff(p0.width()) <= 1, "Paint turned: {p0:?} -> {p1:?}"); + assert!(t1.width().abs_diff(t0.height()) <= 2 && t1.height().abs_diff(t0.width()) <= 2, "the type turned: {t0:?} -> {t1:?}"); + let st = app.session.active().unwrap(); + assert!(matches!(st.doc.layer(text).unwrap().content, LayerContent::Text(_)), "still editable type"); + assert_eq!(st.history.past_len(), steps + 1, "one history step"); + assert_eq!(st.selected_layers(), [paint, text], "both stay selected"); +} + +#[test] +fn with_a_selection_the_pixels_are_lifted_and_the_type_moves_whole() { + let (mut app, paint, text) = app(); + let t0 = bounds(&app, text); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 30, "height": 200})).unwrap(); + free_transform(&mut app); + let pv = app.transform_preview.as_ref().unwrap(); + let under = pv.doc.layer(paint).unwrap(); + assert!(under.visible, "Paint stays, less the selected pixels"); + assert_eq!(under.surface().unwrap().content_bounds(), Rect::new(30, 10, 50, 30)); + assert!(!pv.doc.layer(text).unwrap().visible, "the type moves whole (#2630)"); + if let Some(t) = app.ui.transform.as_mut() { + t.quad = t.quad.map(|[x, y]| [x, y + 100.0]); + } + crate::transform_tool::commit(&mut app); + assert_eq!(app.ui.status, ""); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layer(paint).unwrap().surface().unwrap().content_bounds(), Rect::new(10, 10, 50, 130)); + assert_eq!(bounds(&app, text), t0.translate(0, 100)); +} + +#[test] +fn cancel_leaves_every_layer_as_it_was() { + let (mut app, paint, text) = app(); + let before = app.session.active().unwrap().doc.clone(); + free_transform(&mut app); + if let Some(t) = app.ui.transform.as_mut() { + t.quad = t.quad.map(|[x, y]| [x + 20.0, y]); + } + crate::transform_tool::cancel(&mut app); + assert!(app.ui.transform.is_none()); + let st = app.session.active().unwrap(); + assert!(st.doc == before); + assert_eq!(st.selected_layers(), [paint, text]); +} + +#[test] +fn warp_is_greyed_and_distort_too_while_type_is_among_them() { + let (mut app, paint, text) = app(); + for id in ["edit.freeTransform", "edit.transform.scale", "edit.transform.rotate", "edit.transform.skew", "edit.transform.flipHorizontal"] { + assert!(crate::menus::is_enabled(&app, id), "{id}"); + } + for id in ["edit.transform.warp", "edit.transform.distort", "edit.transform.perspective"] { + assert!(!crate::menus::is_enabled(&app, id), "{id} is greyed"); + } + assert_eq!(crate::transform_tool::begin_warp(&mut app, &egui::Context::default()), Err(crate::transform_tool::WARP_ONE_LAYER.into())); + assert!(app.ui.transform.is_none(), "no box is left open"); + // In an open box too. + free_transform(&mut app); + for id in ["edit.transform.warp", "edit.transform.distort", "edit.transform.perspective"] { + assert!(!crate::menus::is_enabled(&app, id), "{id} is greyed in the box"); + } + crate::transform_tool::cancel(&mut app); + // Two pixel layers distort. + app.run("layer.select", json!({"layer": text.0})).unwrap(); + app.run("layer.rasterize.type", json!({})).unwrap(); + app.run("layer.select", json!({"layer": paint.0, "mode": "add"})).unwrap(); + assert_eq!(app.session.active().unwrap().selected_layers().len(), 2); + assert!(crate::menus::is_enabled(&app, "edit.transform.distort") && crate::menus::is_enabled(&app, "edit.transform.perspective")); + assert!(!crate::menus::is_enabled(&app, "edit.transform.warp")); +} + +#[test] +fn a_locked_layer_among_them_refuses_before_the_box_opens() { + let (mut app, paint, text) = app(); + app.run("layer.setProps", json!({"layer": text.0, "locks": {"position": true}})).unwrap(); + app.run("layer.select", json!({"layer": text.0})).unwrap(); + app.run("layer.select", json!({"layer": paint.0, "mode": "add"})).unwrap(); + let r = crate::menus::invoke(&mut app, &egui::Context::default(), "edit.freeTransform", json!({})); + assert_eq!(r, Err(crate::transform_tool::LOCKED.into())); + assert!(app.ui.transform.is_none()); +} + +#[test] +fn with_the_background_active_the_box_moves_the_other_selected_layers() { + let (mut app, paint, text) = app(); + let bg = app.session.active().unwrap().doc.layers[0].id; + app.run("layer.select", json!({"layer": bg.0, "mode": "add"})).unwrap(); + assert_eq!(app.session.active().unwrap().active_layer, Some(bg)); + assert!(crate::menus::is_enabled(&app, "edit.freeTransform"), "not greyed for the Background"); + free_transform(&mut app); + let t = app.ui.transform.as_ref().unwrap(); + assert_eq!((t.layer, t.layers.clone()), (bg.0, vec![paint.0, text.0])); + let p0 = bounds(&app, paint); + if let Some(t) = app.ui.transform.as_mut() { + // Nearest neighbour: no resampling fringe at the block's edge. + t.interpolation = "nearest".into(); + t.quad = t.quad.map(|[x, y]| [x + 5.0, y]); + } + crate::transform_tool::commit(&mut app); + assert_eq!(app.ui.status, ""); + let st = app.session.active().unwrap(); + assert!(st.doc.is_background(bg), "the Background is left alone"); + assert_eq!(bounds(&app, paint), p0.translate(5, 0)); +} + +#[test] +fn picking_another_layer_applies_the_box_to_all_of_them() { + let (mut app, paint, text) = app(); + let (p0, t0) = (bounds(&app, paint), bounds(&app, text)); + free_transform(&mut app); + if let Some(t) = app.ui.transform.as_mut() { + t.interpolation = "nearest".into(); + t.quad = t.quad.map(|[x, y]| [x + 10.0, y]); + } + app.run("layer.select", json!({"layer": text.0})).unwrap(); + crate::transform_tool::end_if_left(&mut app); + assert!(app.ui.transform.is_none()); + assert_eq!(bounds(&app, paint), p0.translate(10, 0)); + assert_eq!(bounds(&app, text), t0.translate(10, 0)); +} + +/// Paint with a 6 px outside stroke, then both layers selected again (Paint active). +fn stroked() -> (PhotocraftApp, LayerId, LayerId) { + let (mut app, paint, text) = app(); + app.run("layer.select", json!({"layer": paint.0})).unwrap(); + app.run("layer.layerStyle.stroke", json!({"size": 6, "color": "#00ff00", "position": "outside"})).unwrap(); + app.run("layer.select", json!({"layer": text.0})).unwrap(); + app.run("layer.select", json!({"layer": paint.0, "mode": "add"})).unwrap(); + (app, paint, text) +} + +#[test] +fn styles_past_the_frame_move_with_the_box() { + let (mut app, _, _) = stroked(); + free_transform(&mut app); + // The frame is the content's (the block starts at x 10); its stroke reaches 6 px past it. + assert_eq!(app.ui.transform.as_ref().unwrap().rect[0], 10.0, "the handles stay on the content"); + let (stroke, quad) = crate::transform_tool::moving_texel(&app, [6, 20]); + assert!(stroke.is_some_and(|c| c.a() > 200 && c.g() > 200), "the stroke left of the block is in the moving pixels: {stroke:?}"); + assert!(quad[0][0] <= 4.0, "and drawn there: {quad:?}"); + // Moving the box carries the texture's own rect with it. + if let Some(t) = app.ui.transform.as_mut() { + t.quad = t.quad.map(|[x, y]| [x + 50.0, y + 5.0]); + } + let (_, moved) = crate::transform_tool::moving_texel(&app, [6, 20]); + for (a, b) in quad.iter().zip(&moved) { + assert!((b[0] - a[0] - 50.0).abs() < 1e-9 && (b[1] - a[1] - 5.0).abs() < 1e-9, "{quad:?} -> {moved:?}"); + } + // A quarter turn maps the stroke's corner where the frame's map takes it. + let r = app.ui.transform.as_ref().unwrap().rect; + let (cx, cy) = ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0); + if let Some(t) = app.ui.transform.as_mut() { + t.quad = crate::transform_tool::corners(r).map(|[x, y]| [cx - (y - cy), cy + (x - cx)]); + } + let (_, turned) = crate::transform_tool::moving_texel(&app, [6, 20]); + let [x0, y0] = quad[0]; + assert!((turned[0][0] - (cx - (y0 - cy))).abs() < 1e-6 && (turned[0][1] - (cy + (x0 - cx))).abs() < 1e-6, "{turned:?}"); +} + +/// A texel of the type's glyphs in the moving pixels as the box opened. +fn glyph_texel(app: &PhotocraftApp, text: LayerId) -> [i32; 2] { + let b = bounds(app, text); + (b.y0..b.y1) + .flat_map(|y| (b.x0..b.x1).map(move |x| [x, y])) + .find(|p| crate::transform_tool::moving_texel(app, *p).0.is_some_and(|c| c.a() > 200)) + .expect("the type is in the moving pixels") +} + +#[test] +fn hiding_or_fading_a_moving_layer_shows_in_the_preview_at_once() { + let (mut app, _, text) = app(); + free_transform(&mut app); + let p = glyph_texel(&app, text); + // Paint stays the active layer, so the box stays open. + app.run("layer.setProps", json!({"layer": text.0, "opacity": 0.5})).unwrap(); + crate::transform_tool::follow_edits(&mut app); + crate::transform_tool::end_if_left(&mut app); + assert!(app.ui.transform.is_some()); + let half = crate::transform_tool::moving_texel(&app, p).0.unwrap(); + assert!((100..=155).contains(&half.a()), "half opacity: {half:?}"); + app.run("layer.setProps", json!({"layer": text.0, "visible": false})).unwrap(); + crate::transform_tool::follow_edits(&mut app); + assert_eq!(crate::transform_tool::moving_texel(&app, p).0.map(|c| c.a()), Some(0), "a hidden layer moves nothing"); + // The commit still transforms it, hidden, like Photoshop. + let t0 = bounds(&app, text); + if let Some(t) = app.ui.transform.as_mut() { + t.interpolation = "nearest".into(); + t.quad = t.quad.map(|[x, y]| [x + 10.0, y]); + } + crate::transform_tool::commit(&mut app); + assert_eq!(bounds(&app, text), t0.translate(10, 0)); +} + +#[test] +fn a_style_added_while_the_box_is_open_grows_the_moving_pixels() { + let (mut app, paint, _) = app(); + free_transform(&mut app); + assert!(crate::transform_tool::moving_texel(&app, [6, 20]).0.is_none(), "nothing past the block yet"); + app.run("layer.layerStyle.stroke", json!({"layer": paint.0, "size": 6, "color": "#00ff00", "position": "outside"})).unwrap(); + crate::transform_tool::follow_edits(&mut app); + let stroke = crate::transform_tool::moving_texel(&app, [6, 20]).0; + assert!(stroke.is_some_and(|c| c.a() > 200), "{stroke:?}"); +} + +fn group(app: &mut PhotocraftApp, ids: &[LayerId]) -> LayerId { + app.run("layer.select", json!({"layer": ids[0].0})).unwrap(); + for id in &ids[1..] { + app.run("layer.select", json!({"layer": id.0, "mode": "add"})).unwrap(); + } + LayerId(app.run("layer.groupLayers", json!({})).unwrap()["layer"].as_u64().unwrap()) +} + +#[test] +fn edits_to_the_children_of_a_moving_group_show_in_the_preview() { + let (mut app, paint, text) = app(); + let g = group(&mut app, &[paint]); + app.run("layer.select", json!({"layer": g.0})).unwrap(); + app.run("layer.select", json!({"layer": text.0, "mode": "add"})).unwrap(); + free_transform(&mut app); + assert_eq!(app.ui.transform.as_ref().unwrap().layers, [g.0, text.0]); + let red = |app: &PhotocraftApp| crate::transform_tool::moving_texel(app, [20, 20]).0.map(|c| c.a()); + assert_eq!(red(&app), Some(255), "the group's child is in the moving pixels"); + app.run("layer.setProps", json!({"layer": paint.0, "opacity": 0.5})).unwrap(); + crate::transform_tool::follow_edits(&mut app); + crate::transform_tool::end_if_left(&mut app); + assert!(app.ui.transform.is_some(), "the box stays open"); + assert!(red(&app).is_some_and(|a| (100..=155).contains(&a)), "the child at half opacity: {:?}", red(&app)); + app.run("layer.setProps", json!({"layer": paint.0, "visible": false})).unwrap(); + crate::transform_tool::follow_edits(&mut app); + assert_eq!(red(&app), Some(0), "a hidden child moves nothing"); +} + +#[test] +fn a_style_of_the_group_around_the_moving_layers_moves_with_them() { + let (mut app, paint, text) = app(); + let g = group(&mut app, &[paint, text]); + app.run("layer.layerStyle.stroke", json!({"layer": g.0, "size": 6, "color": "#00ff00", "position": "outside"})).unwrap(); + // The two layers in the group, not the group itself. + app.run("layer.select", json!({"layer": text.0})).unwrap(); + app.run("layer.select", json!({"layer": paint.0, "mode": "add"})).unwrap(); + free_transform(&mut app); + assert_eq!(app.ui.transform.as_ref().unwrap().layers, [paint.0, text.0]); + let (stroke, quad) = crate::transform_tool::moving_texel(&app, [6, 20]); + assert!(stroke.is_some_and(|c| c.a() > 200 && c.g() > 200), "the group's stroke left of the block: {stroke:?}"); + assert!(quad[0][0] <= 4.0, "{quad:?}"); +} diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index d46ef189bc..4b1c42254d 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -91,15 +91,87 @@ pub struct TransformPreview { committing: Option, /// Content-Aware Scale's live preview (cas_transform.rs). pub(crate) content_aware: Option, + /// A box over several layers (#6501): their composite, which `texture` holds. + several: Option, +} + +/// The moving pixels of a box over several layers ([`begin_several`]): the layers composited on +/// their own, with their styles, opacity and masks. Composited again when an edit made while the +/// box is open changes how they look ([`follow_edits`]), since nothing is tinted at draw time. +struct Several { + ctx: egui::Context, + name: String, + max_side: usize, + ids: Vec, + /// The pixels a selection lifted out of moving pixel layers, as the box opened. Every other + /// moving layer (groups, type, shapes, smart objects, pixel layers without a selection) moves + /// whole, so the live document holds what it moves, children and their looks included. + lifted: Vec<(LayerId, photocraft_raster::Surface)>, + /// The document showing only the moving layers, as last composited. + moving: Document, + /// The box's frame. + frame: photocraft_geom::Rect, + /// The document area the texture covers: the frame grown by the reach of the moving layers' + /// styles, so a shadow, glow or outside stroke past their content moves with the box. + rect: photocraft_geom::Rect, +} + +impl Several { + /// `live` showing only the layers `ids`, with the `lifted` pixels in place of their layers'. + /// Unclipped: a clipping base that isn't moving would clip them away (#5239). + fn shown(live: &Document, ids: &[LayerId], lifted: &[(LayerId, photocraft_raster::Surface)]) -> Document { + let mut d = live.clone(); + show_only(&mut d.layers, ids); + for id in ids { + if let Some(l) = d.layer_mut(*id) { + l.clipped = false; + } + } + for (id, surf) in lifted { + if let Some(s) = d.layer_mut(*id).and_then(|l| l.surface_mut()) { + *s = surf.clone(); + } + } + d + } + + /// Composite the moving layers over their frame and their styles' reach. + fn render(&mut self) -> crate::transform_tex::PreviewTextures { + self.rect = self.frame.inflate(style_reach(&self.moving, &self.ids)); + let (image, uv) = composite_image(&self.moving, self.rect, self.max_side); + crate::transform_tex::PreviewTextures::new(&self.ctx, self.name.clone(), image, uv) + } +} + +/// How far past their content the styles of the layers `ids` can draw, in document pixels: their +/// own, the layers' inside them and the groups' around them, since a group's style draws around +/// its children's composite and so adds to theirs. +fn style_reach(doc: &Document, ids: &[LayerId]) -> i32 { + let reach_of = + |l: &photocraft_doc::Layer| if l.visible && photocraft_compose::effects::has_effects(l) { photocraft_compose::effects::margin(l).max(0) } else { 0 }; + // The groups around each layer: everything on its path but itself. + let around = + |id: LayerId| doc.path_of(id).map_or(0, |p| (1..p.len()).filter_map(|n| doc.layer_at(p.get(..n)?)).fold(0i32, |r, g| r.saturating_add(reach_of(g)))); + // An explicit stack rather than recursion: documents can nest deeply. + let mut stack: Vec<(&photocraft_doc::Layer, i32)> = ids.iter().filter_map(|id| Some((doc.layer(*id)?, around(*id)))).collect(); + let mut reach = 0; + while let Some((l, outer)) = stack.pop() { + let here = outer.saturating_add(reach_of(l)); + reach = reach.max(here); + if let LayerContent::Group(g) = &l.content { + stack.extend(g.children.iter().map(|c| (c, here))); + } + } + reach } /// What [`follow_edits`] needs to rebuild the preview document. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] struct Follow { /// The document revision `doc` was built from. revision: u64, - /// The moving layer is hidden in `doc` (nothing was lifted out of it). - hidden: bool, + /// The moving layers hidden in `doc` (nothing was lifted out of them). + hidden: Vec, } /// Photoshop's alert when Free Transform (⌘T, Edit › Transform, the Move tool's transform controls) @@ -183,7 +255,7 @@ impl Steps { } } -fn corners(r: [f64; 4]) -> [[f64; 2]; 4] { +pub(crate) fn corners(r: [f64; 4]) -> [[f64; 2]; 4] { [[r[0], r[1]], [r[2], r[1]], [r[2], r[3]], [r[0], r[3]]] } @@ -204,6 +276,11 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> return begin_lone(app, ctx, doc, surf, target); } let id = st.active_layer.ok_or("no active layer")?; + // Several layers selected: one box moves them all, as in Photoshop (#6501). + let targets = photocraft_engine::transform_cmds::selection_targets(st); + if targets != [id] { + return begin_several(app, ctx, doc, revision, id, targets); + } let layer = doc.layer(id).ok_or("no layer")?; if photocraft_engine::transform_cmds::locked_for_transform(&doc, id) { // Photoshop refuses before the box opens rather than when it is committed (#2585). @@ -251,7 +328,7 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> opacity: layer.opacity * layer.fill_opacity, above: Some(above), styled: styled.then(|| crate::transform_fx::StyledPreview::new(format!("transform-{session}-styled"))), - follow: Some(Follow { revision, hidden: lifted.is_none() }), + follow: Some(Follow { revision, hidden: if lifted.is_none() { vec![id] } else { Vec::new() } }), committing: None, gesture: None, warp_drag: None, @@ -263,6 +340,7 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> tool: app.ui.tool, path: None, content_aware: None, + several: None, }); app.ui.transform = Some(TransformSession { session, @@ -279,11 +357,135 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> mode: Default::default(), content_aware: None, skew_v: 0.0, + layers: Vec::new(), }); start_steps(app); Ok(()) } +/// Free Transform of the selected layers `ids` together (#6501): the box frames them all, and the +/// preview moves their composite, drawn over the layers they cover. `active` stays the session's +/// `layer`, so picking another layer still applies the box. The selected pixels of each pixel +/// layer move when there is a selection; type, shapes, smart objects and groups move whole. +fn begin_several(app: &mut PhotocraftApp, ctx: &egui::Context, doc: Arc, revision: u64, active: LayerId, ids: Vec) -> Result<(), String> { + use photocraft_engine::transform_cmds as tc; + if ids.iter().any(|id| tc::locked_for_transform(&doc, *id)) { + app.ui.open_dialog(crate::state::DialogKind::Error, json!({"message": tl!(LOCKED)}).as_object().cloned().unwrap_or_default()); + return Err(LOCKED.into()); + } + let b = tc::transform_frame_of(&doc, &ids); + let top = *ids.last().ok_or("no layer")?; + if b.is_empty() { + return Err("Could not transform: the layers are empty".into()); + } + crate::type_tool::commit(app); + let rect = [b.x0 as f64, b.y0 as f64, b.x1 as f64, b.y1 as f64]; + let session = app.ui.alloc_id(); + // `under`: the canvas under the box, without the moving pixels. `moving`: those pixels alone. + let mut under = (*doc).clone(); + let mut lifted = Vec::new(); + let mut hidden = Vec::new(); + for &id in &ids { + let Some(l) = doc.layer(id) else { continue }; + match (doc.selection.as_ref().filter(|_| !tc::moves_whole(l)), l.surface()) { + (Some(sel), Some(surf)) => { + let (part, rest) = tc::split_selected(surf, sel).map_err(|e| e.to_string())?; + if let Some(s) = under.layer_mut(id).and_then(|l| l.surface_mut()) { + *s = rest; + } + lifted.push((id, part)); + } + _ => { + if let Some(l) = under.layer_mut(id) { + l.visible = false; + } + hidden.push(id); + } + } + } + let max_side = ctx.input(|i| i.max_texture_side).max(app.gpu.as_ref().map_or(1, |g| g.max_texture_side())); + let above = Above::new(ctx, format!("transform-{session}-above"), &doc, top, max_side); + above.hide_in(&mut under.layers); + // Opacity, fill, masks and styles are in the composite already. + let mut several = Several { + ctx: ctx.clone(), + name: format!("transform-{session}"), + max_side, + moving: Several::shown(&doc, &ids, &lifted), + lifted, + ids: ids.clone(), + frame: b, + rect: b, + }; + let texture = several.render(); + app.transform_preview = Some(TransformPreview { + session, + doc: Arc::new(under), + texture, + opacity: 1.0, + above: Some(above), + styled: None, + follow: Some(Follow { revision, hidden }), + committing: None, + gesture: None, + warp_drag: None, + split_tool: None, + split_pointer: None, + split_placing: false, + split_quick: false, + steps: Steps::default(), + tool: app.ui.tool, + path: None, + content_aware: None, + several: Some(several), + }); + app.ui.transform = Some(TransformSession { + session, + layer: active.0, + rect, + quad: corners(rect), + pivot: [(rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0], + interpolation: "bicubic".into(), + warp: None, + selection: false, + target: None, + path: None, + made: None, + mode: Default::default(), + content_aware: None, + skew_v: 0.0, + layers: ids.iter().map(|id| id.0).collect(), + }); + start_steps(app); + Ok(()) +} + +/// The moving texture's texel at document point `p` as the box opened (full resolution), and +/// where the texture's corners are drawn now: what a test of a box over several layers sees. +#[cfg(test)] +pub(crate) fn moving_texel(app: &PhotocraftApp, p: [i32; 2]) -> (Option, [[f64; 2]; 4]) { + let (Some(t), Some(pv)) = (&app.ui.transform, &app.transform_preview) else { return (None, [[0.0; 2]; 4]) }; + let r = pv.several.as_ref().map_or(photocraft_geom::Rect::new(t.rect[0] as i32, t.rect[1] as i32, t.rect[2] as i32, t.rect[3] as i32), |sv| sv.rect); + let texel = pv.texture.level_image(0).and_then(|(image, _)| { + let (x, y) = (usize::try_from(p[0] - r.x0).ok()?, usize::try_from(p[1] - r.y0).ok()?); + (x < image.size[0] && y < image.size[1]).then(|| image.pixels[y * image.size[0] + x]) + }); + (texel, texture_quad(t, pv)) +} + +/// The layers the box moves: every layer of a several-layer session (#6501), else its one layer. +pub(crate) fn moving_layers(t: &TransformSession) -> Vec { + if t.layers.is_empty() { vec![LayerId(t.layer)] } else { t.layers.iter().copied().map(LayerId).collect() } +} + +/// Is any of the layers the box moves of this kind? +fn moves_kind(app: &PhotocraftApp, t: &TransformSession, kind: fn(&LayerContent) -> bool) -> bool { + app.session.active().is_some_and(|st| moving_layers(t).iter().any(|id| st.doc.layer(*id).is_some_and(|l| kind(&l.content)))) +} + +/// Why Warp refuses with several layers selected. +pub const WARP_ONE_LAYER: &str = "Warp works on one layer at a time: select a single layer"; + /// Free Transform on a copy (⌥⌘T, no menu item): duplicates the active layer (with a selection, /// its selected pixels, as Layer via Copy) and transforms the copy (#352). pub fn begin_copy(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> { @@ -351,7 +553,7 @@ pub(crate) fn begin_content_aware( texture, opacity: layer.opacity * layer.fill_opacity, above: Some(above), - follow: Some(Follow { revision, hidden: false }), + follow: Some(Follow { revision, hidden: Vec::new() }), committing: None, gesture: None, warp_drag: None, @@ -364,6 +566,7 @@ pub(crate) fn begin_content_aware( path: None, content_aware: Some(live), styled: None, + several: None, }); app.ui.transform = Some(TransformSession { session, @@ -380,6 +583,7 @@ pub(crate) fn begin_content_aware( mode: Default::default(), content_aware: Some(Default::default()), skew_v: 0.0, + layers: Vec::new(), }); start_steps(app); Ok(()) @@ -485,6 +689,7 @@ fn begin_lone( tool: app.ui.tool, path: None, content_aware: None, + several: None, }); app.ui.transform = Some(TransformSession { session, @@ -501,6 +706,7 @@ fn begin_lone( mode: Default::default(), content_aware: None, skew_v: 0.0, + layers: Vec::new(), }); start_steps(app); Ok(()) @@ -538,6 +744,7 @@ fn begin_path(app: &mut PhotocraftApp, ctx: &egui::Context, params: serde_json:: above: None, follow: None, committing: None, + several: None, }); app.ui.transform = Some(TransformSession { session, @@ -554,6 +761,7 @@ fn begin_path(app: &mut PhotocraftApp, ctx: &egui::Context, params: serde_json:: mode: Default::default(), content_aware: None, skew_v: 0.0, + layers: Vec::new(), }); start_steps(app); Ok(()) @@ -623,6 +831,7 @@ pub fn begin_selection(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<( tool: app.ui.tool, path: None, content_aware: None, + several: None, }); app.ui.transform = Some(TransformSession { session, @@ -639,6 +848,7 @@ pub fn begin_selection(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<( mode: Default::default(), content_aware: None, skew_v: 0.0, + layers: Vec::new(), }); start_steps(app); Ok(()) @@ -649,9 +859,16 @@ pub fn begin_warp(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), St if app.ui.transform.as_ref().is_some_and(|t| t.content_aware.is_some()) { return Err(CAS_ONLY_SCALES.into()); } + if app.ui.transform.as_ref().is_some_and(|t| !t.layers.is_empty()) { + return Err(WARP_ONE_LAYER.into()); + } let fresh = app.ui.transform.is_none(); if fresh { begin(app, ctx)?; + if app.ui.transform.as_ref().is_some_and(|t| !t.layers.is_empty()) { + cancel(app); + return Err(WARP_ONE_LAYER.into()); + } } enter_warp(app, ctx); // Started in Warp: that is where Undo stops. Switching an open transform to Warp is a step. @@ -671,8 +888,9 @@ pub fn enter_warp(app: &mut PhotocraftApp, ctx: &egui::Context) { let existing = app.session.active().and_then(|d| d.doc.layer(LayerId(app.ui.transform.as_ref()?.layer)).and_then(photocraft_engine::warp_cmds::smart_warp_doc_space)); let Some(t) = app.ui.transform.as_mut() else { return }; - // Warp moves layers and paths (#3027): a lone mask or channel keeps the box. - if t.warp.is_some() || t.target.is_some() { + // Warp moves layers and paths (#3027): a lone mask or channel keeps the box, and so do + // several layers (#6501). + if t.warp.is_some() || t.target.is_some() || !t.layers.is_empty() { return; } if t.quad == corners(t.rect) @@ -1055,7 +1273,12 @@ pub fn commit(app: &mut PhotocraftApp) { if t.quad == corners(t.rect) { return; // untouched: nothing to do and no history step; a ⌥⌘T copy or a place stays } - let mut p = json!({"layer": t.layer, "rect": t.rect, "quad": t.quad, "interpolation": t.interpolation}); + let mut p = json!({"rect": t.rect, "quad": t.quad, "interpolation": t.interpolation}); + if t.layers.is_empty() { + p["layer"] = json!(t.layer); + } else { + p["layers"] = json!(t.layers); + } if let Some(target) = &t.target { p["target"] = target.clone(); } @@ -1144,7 +1367,7 @@ pub fn end_if_left(app: &mut PhotocraftApp) { if editing_placed_smart_object { app.ui.transform = None; app.transform_preview = None; - } else if switched_document || st.doc.layer(layer).is_none() { + } else if switched_document || moving_layers(t).iter().any(|id| st.doc.layer(*id).is_none()) || st.doc.layer(layer).is_none() { cancel(app); } else if app.transform_preview.as_ref().is_some_and(|pv| pv.tool != app.ui.tool) || (!selection && st.active_layer.is_some_and(|active| active.0 != layer.0)) @@ -1166,18 +1389,33 @@ pub fn follow_edits(app: &mut PhotocraftApp) { let (Some(t), Some(pv)) = (&app.ui.transform, app.transform_preview.as_mut()) else { return }; let Some(follow) = pv.follow.as_mut() else { return }; let Some(st) = app.session.active().filter(|st| st.doc.id == pv.doc.id && st.revision != follow.revision) else { return }; - let id = LayerId(t.layer); - let (Some(live), Some(moving)) = (st.doc.layer(id), pv.doc.layer(id)) else { return }; + let ids = moving_layers(t); let mut pd = (*st.doc).clone(); - if let Some(l) = pd.layer_mut(id) { - l.content = moving.content.clone(); - l.visible = l.visible && !follow.hidden; + for id in &ids { + let (Some(_), Some(moving)) = (st.doc.layer(*id), pv.doc.layer(*id)) else { return }; + if let Some(l) = pd.layer_mut(*id) { + l.content = moving.content.clone(); + l.visible = l.visible && !follow.hidden.contains(id); + } } - if let Some(a) = pv.above.as_mut() { - a.rebuild(&st.doc, id); + if let (Some(a), Some(top)) = (pv.above.as_mut(), ids.last()) { + a.rebuild(&st.doc, *top); a.hide_in(&mut pd.layers); } - pv.opacity = if live.visible { live.opacity * live.fill_opacity } else { 0.0 }; + // Several layers' opacity, fill, visibility and styles are in their composite: composite them + // again when an edit changed how they look. + if let Some(sv) = pv.several.as_mut() { + let now = Several::shown(&st.doc, &sv.ids, &sv.lifted); + if now.layers != sv.moving.layers { + sv.moving = now; + pv.texture = sv.render(); + } + } + if t.layers.is_empty() + && let Some(live) = st.doc.layer(LayerId(t.layer)) + { + pv.opacity = if live.visible { live.opacity * live.fill_opacity } else { 0.0 }; + } follow.revision = st.revision; pv.doc = Arc::new(pd); } @@ -1247,6 +1485,17 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> Option { "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()), + // One box over several layers doesn't warp (#6501). + "edit.transform.warp" + | "layer.smartObjects.warp" + | "edit.transform.splitWarpCrosswise" + | "edit.transform.splitWarpHorizontally" + | "edit.transform.splitWarpVertically" + | "edit.transform.removeWarpSplit" + if !t.layers.is_empty() => + { + Some(false) + } // Editable type can't be distorted (#2630, as #2701 rules for the menus outside a box). "edit.transform.distort" | "edit.transform.perspective" if moves_type(app, t) => Some(false), _ => None, @@ -1255,10 +1504,7 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> Option { /// Does the box move an editable type layer (not its selection outline, a lone mask or a path)? fn moves_type(app: &PhotocraftApp, t: &TransformSession) -> bool { - !t.selection - && t.target.is_none() - && t.path.is_none() - && app.session.active().and_then(|st| st.doc.layer(LayerId(t.layer))).is_some_and(|l| matches!(l.content, LayerContent::Text(_))) + !t.selection && t.target.is_none() && t.path.is_none() && moves_kind(app, t, |c| matches!(c, LayerContent::Text(_))) } /// Edit › Transform's Rotate and Flip presets while a box is open: what they do to the box, as @@ -1456,8 +1702,9 @@ fn round_to_pixel_grid(app: &mut PhotocraftApp) { if t.selection || t.warp.is_some() || t.content_aware.is_some() || t.target.is_some() { return; } - let vector = - t.path.is_some() || app.session.active().and_then(|st| st.doc.layer(LayerId(t.layer))).is_some_and(|l| matches!(l.content, LayerContent::Shape(_))); + let shapes = + |st: &photocraft_engine::DocState| moving_layers(t).iter().all(|id| st.doc.layer(*id).is_some_and(|l| matches!(l.content, LayerContent::Shape(_)))); + let vector = t.path.is_some() || app.session.active().is_some_and(shapes); if !vector || !upright(&t.quad) { return; } @@ -1501,10 +1748,7 @@ pub(crate) fn corner_distorts(app: &PhotocraftApp, index: usize, mods: egui::Mod /// Distort and Perspective in `enable_rules::disabled`, for the box's own layer. A box on a path, /// on the selection outline or on a lone mask or channel moves no type. fn distort_refused(app: &PhotocraftApp, t: &TransformSession) -> bool { - t.path.is_none() - && !t.selection - && t.target.is_none() - && app.session.active().and_then(|st| st.doc.layer(LayerId(t.layer))).is_some_and(|l| matches!(l.content, LayerContent::Text(_))) + t.path.is_none() && !t.selection && t.target.is_none() && moves_kind(app, t, |c| matches!(c, LayerContent::Text(_))) } /// Where Distort and Perspective are refused ([`distort_refused`]), ⌘-drag and ⌘⌥⇧-drag of a @@ -2133,7 +2377,7 @@ pub fn draw_content(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform mesh.add_triangle(0, 1, 2); mesh.add_triangle(0, 2, 3); painter.add(clip_to_canvas(mesh, canvas_size(pv), |p| xf.to_screen(p.x, p.y))); - } else if let Some(h) = Homography::rect_to_quad([0.0, 0.0, 1.0, 1.0], t.quad) { + } else if let Some(h) = Homography::rect_to_quad([0.0, 0.0, 1.0, 1.0], texture_quad(t, pv)) { // A 24×24 grid keeps perspective previews straight. let n = 24; let zoom = xf.zoom * painter.ctx().pixels_per_point(); @@ -2170,6 +2414,18 @@ pub fn draw_content(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform } } +/// Where the box puts the moving texture's corners: the box's own, or, when the texture covers +/// more than the frame (the styles of a box over several layers reach past it, [`Several`]), +/// where the frame's transform takes the texture's rect. +fn texture_quad(t: &TransformSession, pv: &TransformPreview) -> [[f64; 2]; 4] { + let Some(sv) = pv.several.as_ref().filter(|sv| sv.rect != sv.frame) else { return t.quad }; + let Some(h) = Homography::rect_to_quad(t.rect, t.quad) else { return t.quad }; + corners([sv.rect.x0, sv.rect.y0, sv.rect.x1, sv.rect.y1].map(f64::from)).map(|[x, y]| { + let (a, b) = h.apply(x, y); + [a, b] + }) +} + /// The box, handles, reference point and path outline over the preview that [`draw_content`] /// drew; the warp grid and its split guides for a warp. pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { @@ -3129,6 +3385,7 @@ mod tests { mode: Default::default(), content_aware: None, skew_v: 0.0, + layers: Vec::new(), } } diff --git a/crates/ui-egui/src/type_transform.rs b/crates/ui-egui/src/type_transform.rs index 0c1b4837ed..a07d134e13 100644 --- a/crates/ui-egui/src/type_transform.rs +++ b/crates/ui-egui/src/type_transform.rs @@ -128,6 +128,7 @@ pub(crate) fn frame(app: &mut PhotocraftApp) -> Option { mode: Default::default(), content_aware: None, skew_v: 0.0, + layers: Vec::new(), }) } diff --git a/docs/gaps.md b/docs/gaps.md index faf0884c89..94ef163d03 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 (G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; 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; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (G5: Free Transform of several selected layers, #6501; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; 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; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **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 @@ -190,7 +190,9 @@ what an agent can't finish alone. snapping (#2681, #2684); Auto-Select box follows the picked layer (#2666, #2675); ⌘-arrow layer nudge (#2474, #2556); polygonal lasso Shift angles (#2352, #2396); double-click Zoom tool to 100% (#2508, #2527); tool-key memory in a group (#2608, #2625); hold-a-key spring-loaded - tools (#2672); H/V skew fields in the Free Transform options bar. + tools (#2672); H/V skew fields in the Free Transform options bar; Free Transform of several + selected layers, ⌘T and the Move tool's shared frame (#6501, #3384, #6096; Warp stays one + layer). - **Detail:** [`ui-parity.md`](ui-parity.md). - **Estimate:** 60–110 h. @@ -270,6 +272,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | G5: Free Transform of several selected layers (#6501): one box moves them all; no estimate changed | | 2026-10-14 | minor | G1: a PSD smart object's filter mask shows when the file opens (Photoshop stores the filtered pixels unmasked); export stores them that way too; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | G14: D90 image area measured (4288 × 2848 at (10, 10)); gap remains open | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | diff --git a/docs/ui-design.md b/docs/ui-design.md index d4ac18d297..180e4a092c 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -54,7 +54,7 @@ curl -sfL -o assets/icons/.svg https://raw.githubusercontent.com/lucide-ic | Feature | Module | Behaviour | |---|---|---| | Type tool | `type_tool.rs` | Click: point text with the placeholder "Lorem Ipsum" selected. Drag: paragraph box. Inline caret and selection drawn from the text engine layout. ⌥/⌘ word and line navigation, ↩ newline, ⌘↩ or Esc commits, a click outside commits. One history step per session (`coalesce`). A new layer is named after its text; an empty one is deleted. | -| Free Transform | `transform_tool.rs` | ⌘T. Corners and edges scale proportionally (⇧ frees them: an edge then stretches one axis); ⌥ scales about the reference point; ⌘-corner distorts; dragging outside rotates (⇧ snaps to 15°); dragging inside moves. Preview = document without the moving pixels + a textured 24×24 mesh. ↩ or a double-click commits via `edit.transform {rect, quad}`. | +| Free Transform | `transform_tool.rs` | ⌘T. Corners and edges scale proportionally (⇧ frees them: an edge then stretches one axis); ⌥ scales about the reference point; ⌘-corner distorts; dragging outside rotates (⇧ snaps to 15°); dragging inside moves. Preview = document without the moving pixels + a textured 24×24 mesh. ↩ or a double-click commits via `edit.transform {rect, quad}`. With several layers selected one box frames and moves them all (`layers`; the Background is left out, Warp is one layer at a time). | | Quick layer pick | `quick_pick.rs` | macOS: ⌘⌥⌃-click with any tool selects the topmost visible layer with pixels under the pointer (`layer.pickAt`) without switching tools; the drag and release are swallowed. Windows/Linux have no third modifier distinct from the ⌃⌥ brush resize, so the gesture is a no-op there (use the Move tool's Auto-Select). A failed pick is a status-bar error. | | Layers rows | `panels.rs` | Double-click: on the name renames in place; on the Background makes it a normal layer; on an adjustment or fill thumbnail opens its Properties; on a Smart Object thumbnail opens its contents (Edit Contents); anywhere else on the row opens Layer Style. | | Layer masks | `panels.rs` | Clicking the mask thumbnail targets the mask (corner-bracket frame; the tab reads "Layer, Layer Mask/8"). Brush, eraser (paints background colour), gradient, bucket and moving the selected pixels (⌘-drag, `select.float`; the hole takes the background colour) then send `"target": "mask"`. Adjustment and fill layers target their mask automatically. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 793f09840c..73f8f0eb56 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Free Transform of several selected layers, #6501; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -68,6 +68,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Warp: presets, bend, split crosswise/vertical/horizontal, 3×3–5×5 and custom grids | done | | | Options bar X/Y, W/H % with link, angle | done | | | With a selection, the box is the selection's rectangle and the marching ants follow the box live | done | `transform_frame`, `transform_tool.rs` (`draw_selection`): move, scale, rotate, skew, distort, perspective and warp, before the transform is applied; also the Move tool's Show Transform Controls (#5297) | +| Several layers selected: one box frames them all and moves every one (pixels, type, shapes, smart objects, groups), one history step; with a selection each pixel layer's selected pixels move; Transform Again repeats on them | partial | #6501, `transform_tool.rs` (`begin_several`), `transform_several_tests.rs`, engine `edit.transform` `layers` (`transform_multi_tests.rs`); the Background is left out, as Show Transform Controls leaves it; Warp and splits are greyed for several layers; the preview moves their styles past the frame and follows opacity, fill, visibility and style edits made while the box is open (`styles_past_the_frame_move_with_the_box`, `hiding_or_fading_a_moving_layer_shows_in_the_preview_at_once`), but draws the layers stacked between the moving ones under them (the commit keeps every layer's place) | | Options bar H/V skew fields | done | `transform_tool.rs` (`Parts`, `skew_about_pivot`): W/H, angle and skew read as Photoshop decomposes the box; `skew_fields_lean_the_box_about_the_reference_point` | | Interpolation in the options bar | partial | Bicubic/Bilinear/Nearest; no Bicubic Smoother/Sharper/Automatic, Preserve Details | | Commit/cancel buttons in the options bar | missing | #2442 | @@ -96,7 +97,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | ⌥-drag duplicates, ⇧ constrains to 45° | done | `move_mods.rs` | | Auto-Select with hover outline, box-select layers, double-click fit | missing | TOOL-214-4 | | Auto-Select bounding box and snapping follow the picked layer | partial | follow the previous layer (#2666) | -| Several layers selected (⇧-click adds, box-select): Show Transform Controls frames them all, with every selected shape's outline | partial: the shared frame has no handles, since Free Transform works on one layer (`canvas.rs` `transform_controls_layers`, `several_selected_layers_share_one_frame_without_handles`; `move_auto_select_tests.rs`) | | +| Several layers selected (⇧-click adds, box-select): Show Transform Controls frames them all, with every selected shape's outline, and its handles transform them all | done | `canvas.rs` `transform_controls_layers`, `several_selected_layers_share_one_frame_that_transforms_them_all`; `move_auto_select_tests.rs`; #6501 | | ⌘-drag temporary Move shows the box and snaps | partial | #2681 | | ⌘-right-click layer list under the pointer | done | `layer_pick_ui.rs` | | Double-click type to edit it (caret at the pointer, Type tool) | done | #1132, `type_tool::edit_at` | @@ -227,6 +228,7 @@ Paragraph; the rest open as in-app floating windows. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Free Transform of several selected layers (#6501, #3384, #6096): ⌘T and the Move tool's shared frame move every selected layer in one step; row added as partial (Warp one layer at a time, preview stacks the in-between layers under the moving ones; styles past the frame and appearance edits while open show in the preview); Show Transform Controls row → done | | 2026-10-13 | minor | Menu bar from the keyboard: Alt / F10 focus, Alt+letter, underlined mnemonics (#4988) | | 2026-10-13 | minor | Layers panel filter row: Kind is a real dropdown with Kind, Name and Mode filters, drivable by `ui.set {layerFilter}` (#5048); row added as partial | | 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the P flyout cycle) are live toolbar tools, so only Art History Brush is missing; no percentage or estimate changed | From bfc37773a89651132b86ee9124b4a8e728ca43c4 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Sat, 17 Oct 2026 20:16:27 +0530 Subject: [PATCH 03/86] test(engine): Straighten Layer turns every selected layer, as in Photoshop Straighten's non-crop branch runs `edit.transform` without a layer, so since Free Transform takes the selected layers it turns all of them. That matches Photoshop ("only the selected layer(s) will straighten"); the test pins it, with one history step and one Undo, and checks that an unselected layer stays put. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/transform_multi_tests.rs | 24 ++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/engine/src/transform_multi_tests.rs b/crates/engine/src/transform_multi_tests.rs index d8f508f50b..c75e658779 100644 --- a/crates/engine/src/transform_multi_tests.rs +++ b/crates/engine/src/transform_multi_tests.rs @@ -207,3 +207,27 @@ fn bad_layers_params_are_errors_not_panics() { } assert!(s.active().unwrap().doc == before); } + +#[test] +fn straighten_layer_rotates_every_selected_layer() { + // Photoshop's Straighten Layer turns all the selected layers, not just the active one; it + // goes through `edit.transform` without a layer, so it takes the selection like Free Transform. + let (mut s, paint, text) = two_layers(); + let (p0, m0) = (bounds(&s, paint), text_matrix(&s, text)); + s.execute("image.analysis.rulerTool", json!({"start": [10, 30], "end": [70, 25]})).unwrap(); + let steps = s.active().unwrap().history.past_len(); + let r = s.execute("image.analysis.straightenLayer", json!({})).unwrap(); + assert_eq!(r["cropped"], false); + assert!(bounds(&s, paint) != p0, "the selected pixel layer turned too"); + assert!(text_matrix(&s, text)[1].abs() > 0.05 && m0[1].abs() < 1e-9, "the active type layer turned"); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "one history step"); + assert!(s.undo()); + assert_eq!((bounds(&s, paint), text_matrix(&s, text)), (p0, m0), "and one Undo takes both back"); + + // With one layer selected, only that layer turns. + s.execute("layer.select", json!({"layer": paint.0})).unwrap(); + s.execute("image.analysis.rulerTool", json!({"start": [10, 30], "end": [70, 25]})).unwrap(); + s.execute("image.analysis.straightenLayer", json!({})).unwrap(); + assert!(bounds(&s, paint) != p0); + assert_eq!(text_matrix(&s, text), m0, "an unselected layer stays put"); +} From b9847b3f9e301f5202cdc95a1c1de550cd8dd0e2 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Sun, 18 Oct 2026 16:03:54 +0530 Subject: [PATCH 04/86] Save for Web: an on/off option that isn't true or false is an error (#6391) "lossless": "true" was read as absent, so the export silently wrote lossy WebP. Every on/off option (transparency, interlaced, progressive, lossless, optimized, embedIcc, convertToSrgb) now refuses anything but true, false or null, and the refused export writes and remembers nothing. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/web_cmds.rs | 10 ++++++++-- crates/engine/src/web_cmds/tests.rs | 25 +++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/web_cmds.rs b/crates/engine/src/web_cmds.rs index 561999461c..50fffb0165 100644 --- a/crates/engine/src/web_cmds.rs +++ b/crates/engine/src/web_cmds.rs @@ -181,7 +181,13 @@ impl WebSettings { if let Some(a) = p.get("ditherAmount").and_then(Value::as_f64) { st.dither_amount = (a as f32 / 100.0).clamp(0.0, 1.0); } - if let Some(b) = p.get("transparency").and_then(Value::as_bool) { + // An on/off option given as anything but true or false is an error, not its default. + let flag = |k: &str| match p.get(k) { + None | Some(Value::Null) => Ok(None), + Some(Value::Bool(b)) => Ok(Some(*b)), + Some(_) => Err(bad(cmd, format!("`{k}` must be true or false"))), + }; + if let Some(b) = flag("transparency")? { st.transparency = b; } match s("matte") { @@ -197,7 +203,7 @@ impl WebSettings { ("embedIcc", &mut st.embed_icc), ("convertToSrgb", &mut st.convert_to_srgb), ] { - if let Some(b) = p.get(k).and_then(Value::as_bool) { + if let Some(b) = flag(k)? { *slot = b; } } diff --git a/crates/engine/src/web_cmds/tests.rs b/crates/engine/src/web_cmds/tests.rs index 59f0c147ca..d027c8651f 100644 --- a/crates/engine/src/web_cmds/tests.rs +++ b/crates/engine/src/web_cmds/tests.rs @@ -343,6 +343,31 @@ fn webp_too_large_is_an_error() { } } +/// An on/off option that isn't true or false is refused (`"lossless": "true"` would otherwise +/// write lossy WebP), and the refused export writes and remembers nothing. +#[test] +fn non_boolean_flags_are_refused_without_changing_anything() { + let dir = tmp("flags"); + let mut s = session(8); + let out = format!("{dir}/out.webp"); + let flags = ["transparency", "interlaced", "progressive", "lossless", "optimized", "embedIcc", "convertToSrgb"]; + for k in flags { + for v in [json!("true"), json!(1), json!([true])] { + let rev = s.active().unwrap().revision; + let mut p = json!({"format": "webp", "path": out}); + p[k] = v.clone(); + let err = s.execute("file.export.saveForWebLegacy", p).unwrap_err().to_string(); + assert!(err.contains(k), "{k}: {v}: {err}"); + assert_eq!(s.active().unwrap().revision, rev, "{k}: the document is untouched"); + assert!(!std::path::Path::new(&out).exists(), "{k}: nothing written"); + assert!(s.file_menu.last_web.is_none(), "{k}: nothing remembered"); + } + } + // null is the same as leaving it out. + s.execute("file.export.saveForWebLegacy", json!({"format": "webp", "path": out, "lossless": null})).unwrap(); + assert!(std::path::Path::new(&out).exists()); +} + #[test] fn slices_export_with_html_table() { let dir = tmp("slices"); From a83b2f848e21e68d81e4135234125b3fff1d40f8 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Thu, 22 Oct 2026 09:19:05 +0530 Subject: [PATCH 05/86] review: a null layer beside layers is absent; Warp keeps several layers' box `edit.transform` refused {"layers": [...], "layer": null}, though `null` means "not given" everywhere else (explicit_layer). And the Warp button took the type-warp branch before enter_warp's several-layers guard, so with a type layer active among several it warped that layer alone while the preview moved them all: the guard now runs first. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/transform_cmds.rs | 4 +++- crates/engine/src/transform_multi_tests.rs | 10 ++++++++++ crates/ui-egui/src/transform_several_tests.rs | 14 ++++++++++++++ crates/ui-egui/src/transform_tool.rs | 4 ++++ 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index 5b1bf00d19..e32a9a037f 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -789,7 +789,9 @@ fn transform(s: &mut Session, p: &Value) -> Result { let p = &with_panel_target(s, p); let st = s.active().ok_or(EngineError::NoDocument)?; let many = layers_param(&st.doc, p)?; - if many.is_some() && (p.get("layer").is_some() || !matches!(crate::channel_cmds::target_of(p), crate::channel_cmds::Target::Pixels)) { + if many.is_some() + && (!matches!(p.get("layer"), None | Some(Value::Null)) || !matches!(crate::channel_cmds::target_of(p), crate::channel_cmds::Target::Pixels)) + { return Err(bad("`layers` moves layers: pass it without `layer` or a mask or channel `target`")); } let explicit = crate::commands::explicit_layer(s, p)?; diff --git a/crates/engine/src/transform_multi_tests.rs b/crates/engine/src/transform_multi_tests.rs index c75e658779..ad34af7937 100644 --- a/crates/engine/src/transform_multi_tests.rs +++ b/crates/engine/src/transform_multi_tests.rs @@ -186,6 +186,16 @@ fn transform_again_on_a_copy_copies_and_moves_the_active_layer_only() { assert_eq!((bounds(&s, paint), bounds(&s, text)), (p1, t1), "the originals stayed"); } +#[test] +fn a_null_layer_beside_layers_counts_as_absent() { + let (mut s, paint, text) = two_layers(); + let t0 = bounds(&s, text); + let r = s.execute("edit.transform", json!({"layers": [paint.0], "layer": null, "matrix": [1, 0, 0, 1, 5, 0]})).unwrap(); + assert_eq!(r["layers"], json!([paint.0])); + assert_eq!(bounds(&s, paint), Rect::new(15, 10, 55, 30)); + assert_eq!(bounds(&s, text), t0); +} + #[test] fn bad_layers_params_are_errors_not_panics() { let (mut s, paint, _) = two_layers(); diff --git a/crates/ui-egui/src/transform_several_tests.rs b/crates/ui-egui/src/transform_several_tests.rs index 30330a5b6f..ee593e341f 100644 --- a/crates/ui-egui/src/transform_several_tests.rs +++ b/crates/ui-egui/src/transform_several_tests.rs @@ -127,6 +127,20 @@ fn warp_is_greyed_and_distort_too_while_type_is_among_them() { assert!(!crate::menus::is_enabled(&app, "edit.transform.warp")); } +#[test] +fn the_warp_button_keeps_the_box_when_the_active_layer_is_type() { + let (mut app, paint, text) = app(); + app.run("layer.select", json!({"layer": paint.0})).unwrap(); + app.run("layer.select", json!({"layer": text.0, "mode": "add"})).unwrap(); + assert_eq!(app.session.active().unwrap().active_layer, Some(text)); + free_transform(&mut app); + let before = app.ui.transform.as_ref().map(|t| (t.rect, t.quad)); + crate::transform_tool::enter_warp(&mut app, &egui::Context::default()); + let t = app.ui.transform.as_ref().unwrap(); + assert!(t.warp.is_none(), "no warp on several layers"); + assert_eq!(Some((t.rect, t.quad)), before); +} + #[test] fn a_locked_layer_among_them_refuses_before_the_box_opens() { let (mut app, paint, text) = app(); diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index ecfc14722f..1ddb05a40a 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -882,6 +882,10 @@ pub fn begin_warp(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), St /// custom mesh following the current box (so a transform made first carries over). Editable type /// gets its own Warp Text style instead ([`enter_type_warp`]). pub fn enter_warp(app: &mut PhotocraftApp, ctx: &egui::Context) { + // Several layers keep the box (#6501), even when the active one is type. + if app.ui.transform.as_ref().is_some_and(|t| !t.layers.is_empty()) { + return; + } if app.ui.transform.as_ref().is_some_and(|t| t.warp.is_none()) && warps_type(app) { return enter_type_warp(app, ctx); } From a3b6850970d560669b3a3c1c990ad0d53aad7da0 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Thu, 22 Oct 2026 09:19:10 +0530 Subject: [PATCH 06/86] review: Save for Web checks its settings before dropping a floating selection save_for_web_with_writer committed a floating move (a document edit) before WebSettings::from_params, so a bad on/off value like "lossless": "yes" failed after changing the document. Settings are checked first now; a valid export still drops the selection first. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/web_cmds.rs | 3 ++- crates/engine/src/web_cmds/tests.rs | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/web_cmds.rs b/crates/engine/src/web_cmds.rs index b2c0a025ef..136037c56b 100644 --- a/crates/engine/src/web_cmds.rs +++ b/crates/engine/src/web_cmds.rs @@ -941,10 +941,11 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u if let Some(authorize) = s.authorize { authorize(cmd, p)?; } + // Settings are checked first, so a refused export leaves a floating selection where it is. + let st = WebSettings::from_params(p, cmd)?; // Direct platform calls must export the moved selection as it appears on the canvas, // just as Session::execute does before dispatching the native command. crate::float_cmds::before_command(s, cmd)?; - let st = WebSettings::from_params(p, cmd)?; let d = s.active().ok_or(EngineError::NoDocument)?; let doc = d.doc.clone(); let (wdoc, sx, sy) = web_document(&doc, p, &st)?; diff --git a/crates/engine/src/web_cmds/tests.rs b/crates/engine/src/web_cmds/tests.rs index a9a4543ea5..767d05f222 100644 --- a/crates/engine/src/web_cmds/tests.rs +++ b/crates/engine/src/web_cmds/tests.rs @@ -343,6 +343,25 @@ fn webp_too_large_is_an_error() { } } +#[test] +fn platform_writer_checks_settings_before_dropping_floating_pixels() { + let mut s = session(8); + s.execute("select.rect", json!({"x": 8, "y": 8, "width": 4, "height": 4})).unwrap(); + s.execute("select.float", json!({"dx": 32})).unwrap(); + let rev = s.active().unwrap().revision; + let params = json!({"format": "webp", "path": "moved.webp", "lossless": "yes"}); + let mut output = Vec::new(); + let refused = save_for_web_with_writer(&mut s, ¶ms, &mut |_, bytes| { + output.extend_from_slice(bytes); + Ok(()) + }); + assert!(refused.unwrap_err().to_string().contains("lossless")); + assert!(output.is_empty()); + assert!(s.file_menu.last_web.is_none()); + assert_eq!(s.active().unwrap().revision, rev, "the document is untouched"); + assert!(crate::float_cmds::floating(s.active().unwrap()).is_some(), "a refused export must not commit a floating move"); +} + /// An on/off option that isn't true or false is refused (`"lossless": "true"` would otherwise /// write lossy WebP), and the refused export writes and remembers nothing. #[test] From 1bdf5a602f1c67cc7424f56ddd57c65431143f7b Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:18:34 +0200 Subject: [PATCH 07/86] Fix keyboard shortcut set names with '/' or '=' being truncated Found while testing crates/engine/src/kys.rs:64 (parse; written by write at :285) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/kys.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/kys.rs b/crates/engine/src/kys.rs index 6a0ff99c84..dfd72f5250 100644 --- a/crates/engine/src/kys.rs +++ b/crates/engine/src/kys.rs @@ -49,6 +49,18 @@ pub struct KysSet { pub windows: bool, } +/// The set name in the root `filename` attribute (`$$$/.../{name}={name}`). The name may itself +/// contain `/` or `=`, so split on the key prefix and the doubled name, not on the last separator. +fn filename_to_name(f: &str) -> &str { + let Some(rest) = f.strip_prefix("$$$/") else { return f.rsplit(['=', '/']).next().unwrap_or(f).trim() }; + let rest = rest.strip_prefix("FileName/Presets/KeyboardCustomization/").unwrap_or(rest); + let mid = rest.len() / 2; + if rest.len() % 2 == 1 && rest.is_char_boundary(mid) && rest.as_bytes()[mid] == b'=' && rest[..mid] == rest[mid + 1..] { + return rest[..mid].trim(); + } + rest.split_once('=').map_or(rest, |(_, n)| n).trim() +} + /// Parse a `.kys` file (or Photoshop's live `Keyboard Shortcuts.psp`, the same XML). Rows /// without a name or a shortcut are skipped. pub fn parse(text: &str) -> Result { @@ -61,7 +73,7 @@ pub fn parse(text: &str) -> Result { if root.tag_name().name() != "photoshop-keyboard-shortcuts" { return Err("not a keyboard shortcut set (.kys): no root".into()); } - let name = root.attribute("filename").map(|f| f.rsplit(['=', '/']).next().unwrap_or(f).trim()).filter(|n| !n.is_empty()).map(str::to_string); + let name = root.attribute("filename").map(filename_to_name).filter(|n| !n.is_empty()).map(str::to_string); let mut set = KysSet { name, ..Default::default() }; for node in root.children().filter(|n| n.is_element()) { match node.tag_name().name() { @@ -309,6 +321,13 @@ pub fn write(name: &str, entries: &[Entry]) -> String { mod tests { use super::*; + #[test] + fn set_name_with_separators_round_trips() { + for n in ["Retouch 1/2", "a=b", "x/y=z"] { + assert_eq!(parse(&write(n, &[])).unwrap().name.as_deref(), Some(n)); + } + } + const SET: &str = "\u{feff} \tCmd+L From 2e849bb6fc1d742522efaa6bdb73aa0694a27f6d Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:31:35 +0200 Subject: [PATCH 08/86] Keep a bare '+' key when saving a keyboard shortcut set Found while testing crates/engine/src/kys.rs:263-271 (photoshop_key), used by write at :288 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/kys.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/engine/src/kys.rs b/crates/engine/src/kys.rs index 6a0ff99c84..f9904af65b 100644 --- a/crates/engine/src/kys.rs +++ b/crates/engine/src/kys.rs @@ -262,6 +262,7 @@ pub fn photoshop_key(sc: &str) -> Option { // The key is the last part; `Cmd++` ends in an empty part, so take it from the string. let (mods, key) = match n.strip_suffix("++") { Some(m) => (m, "+"), + None if n == "+" => ("", "+"), None => n.rsplit_once('+').unwrap_or(("", n.as_str())), }; let has = |m: &str| mods.split('+').any(|x| x == m); @@ -323,6 +324,14 @@ mod tests { Candidate { id: id.into(), label: label.into(), default: default.map(str::to_string) } } + #[test] + fn a_bare_plus_key_is_kept_when_saving() { + assert_eq!(photoshop_key("+").as_deref(), Some("+")); + assert_eq!(photoshop_key("Cmd++").as_deref(), Some("Cmd++")); + let e = Entry { id: "tools.zoom".into(), label: "Zoom Tool".into(), tool: true, keys: vec!["+".into()] }; + assert!(write("Mine", &[e]).contains(">+")); + } + #[test] fn rows_match_by_label_and_the_default_holder_wins_a_shared_label() { let set = parse(SET).unwrap(); From 6e2e02fa2663ebd062ad1409f39e04d60655d1d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Lu=C3=ADs=20da=20Silva?= Date: Thu, 22 Oct 2026 14:40:15 -0300 Subject: [PATCH 09/86] Read layer-record blend keys in PSD layer effects Photoshop 2026 writes some effect blend modes (Md ) as the layer record's four-character key (vLit, lddg, lbrn, lgCl, lLit...) rather than a BlnM descriptor ID. blend_from_id knew only the descriptor IDs, so those effects imported as Normal: a Vivid Light Color Overlay became a flat wash. Fall back to BlendMode::from_psd_key when the value is no descriptor ID. Smart-filter blend options share the function and get the same fix. Fixes #8567 --- crates/io/src/effects_map.rs | 39 ++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/io/src/effects_map.rs b/crates/io/src/effects_map.rs index 45e0d7469e..f3bd70fee6 100644 --- a/crates/io/src/effects_map.rs +++ b/crates/io/src/effects_map.rs @@ -69,9 +69,15 @@ const BLEND_LONG_NAMES: [(BlendMode, &str); 17] = [ (BlendMode::Luminosity, "luminosity"), ]; -/// A blend-mode enum from either its four-character code or its long string ID. +/// A blend-mode enum from its four-character code, its long string ID, or the layer record's key: +/// Photoshop 2026 writes `vLit`, `lddg`, `lbrn`… as an effect's `Md ` for some modes (#8567). pub(crate) fn blend_from_id(v: &[u8]) -> Option { - BLEND_NAMES.iter().chain(&BLEND_LONG_NAMES).find(|(_, n)| n.as_bytes() == v).map(|(m, _)| *m) + BLEND_NAMES + .iter() + .chain(&BLEND_LONG_NAMES) + .find(|(_, n)| n.as_bytes() == v) + .map(|(m, _)| *m) + .or_else(|| <[u8; 4]>::try_from(v).ok().and_then(BlendMode::from_psd_key)) } fn blend_of(d: &Descriptor, key: &str, default: BlendMode) -> BlendMode { @@ -823,6 +829,35 @@ mod tests { } } + #[test] + fn layer_record_blend_keys_parse() { + for (key, mode) in [ + (b"vLit", BlendMode::VividLight), + (b"lLit", BlendMode::LinearLight), + (b"pLit", BlendMode::PinLight), + (b"hMix", BlendMode::HardMix), + (b"lbrn", BlendMode::LinearBurn), + (b"lddg", BlendMode::LinearDodge), + (b"dkCl", BlendMode::DarkerColor), + (b"lgCl", BlendMode::LighterColor), + (b"fsub", BlendMode::Subtract), + (b"fdiv", BlendMode::Divide), + ] { + assert_eq!(blend_from_id(key), Some(mode), "{}", String::from_utf8_lossy(key)); + } + assert_eq!(blend_from_id(b"zzzz"), None); + // The issue's Color Overlay: Vivid Light stored as `vLit`, not left as Normal. + let key = |v: &str| Value::Enumerated { type_id: Id::new("BlnM"), value: Id::new(v) }; + let d = Descriptor::new("null") + .with("masterFXSwitch", Value::Boolean(true)) + .with("SoFi", Value::Descriptor(Descriptor::new("SoFi").with("enab", Value::Boolean(true)).with("Md ", key("vLit")))); + let mut data = 0u32.to_be_bytes().to_vec(); + data.extend(VersionedDescriptor { version: 16, descriptor: d }.to_bytes()); + let (_, fx) = parse_lfx2(&data).unwrap(); + let modes: Vec = fx.iter().filter_map(|e| if let Effect::ColorOverlay { common, .. } = e { Some(common.blend) } else { None }).collect(); + assert_eq!(modes, vec![BlendMode::VividLight]); + } + #[test] fn long_string_blend_ids_parse() { // Some writers store effect modes as long string IDs (`BlnM` `multiply`, `colorDodge`) From 52b829f848513b174bc6f1c89a221b83d94a81dc Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Thu, 22 Oct 2026 23:21:17 +0530 Subject: [PATCH 10/86] Test: a floating piece among several layers checks the frame's layers, not the removed transform_controls_several Main's #7538 test asserted through transform_controls_several, which this branch removes now that several selected layers transform together (#6501). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UmLxt3PeAHUmPEQjSjYqj6 --- crates/ui-egui/src/canvas.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 821abf7c97..8a118fba9e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -9382,7 +9382,7 @@ mod transform_controls_tests { app.session.execute("select.rect", json!({"x": 20, "y": 20, "width": 100, "height": 90})).unwrap(); app.ui.tool = Tool::Move; app.ui.tool_options.move_show_transform = true; - assert!(transform_controls_several(&app), "two layers framed"); + assert_eq!(transform_controls_layers(&app).len(), 2, "two layers framed"); app.session.execute("select.float", json!({"dx": 5, "dy": 0})).unwrap(); assert!(photocraft_engine::float_cmds::floating(app.session.active().unwrap()).is_some()); let xf = From 7525331f6a24bd47caa21b010507590415d8525b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 22 Oct 2026 14:13:28 -0700 Subject: [PATCH 11/86] History: trimming to the memory budget after Clear History keeps the History Brush source Clear History moves the History Brush source into the cleared states' record and leaves History::brush_source empty. While Clear History can be undone, trim_with trims the cleared states, but it looked the pinned source up in the emptied field. The source state was then dropped like any other old state, and after Edit > Undo Clear History the History Brush had no source. trim_with now takes the source from Cleared::brush_source while Clear History can be undone, and from History::brush_source otherwise, as History::held picks the cleared or the live states. --- crates/ops/src/lib.rs | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/crates/ops/src/lib.rs b/crates/ops/src/lib.rs index 498c1deb8e..e1ecc8ee33 100644 --- a/crates/ops/src/lib.rs +++ b/crates/ops/src/lib.rs @@ -402,10 +402,10 @@ impl History { /// beyond it unseen (#4530). The most recent state is still always kept. pub fn trim_with(&mut self, current: &Document, pinned: &[&Document]) -> usize { // While Clear History can be undone, the cleared states are what history holds: the - // oldest of them go first, as the list's would. - let (undo, redo) = match &mut self.cleared { - Some(c) => (&mut c.undo, &c.redo), - None => (&mut self.undo, &self.redo), + // oldest of them go first, as the list's would. The source set before it stays pinned. + let (undo, redo, brush_source) = match &mut self.cleared { + Some(c) => (&mut c.undo, &c.redo, c.brush_source), + None => (&mut self.undo, &self.redo, self.brush_source), }; if self.max_bytes == 0 || undo.len() <= 1 { return 0; @@ -439,7 +439,7 @@ impl History { } // The History Brush's source is kept whatever its age, so its own tiles come first; the // newest states then fill what remains of the budget (the most recent one always). - let source = self.brush_source.and_then(|id| undo.iter().position(|s| s.id == id)); + let source = brush_source.and_then(|id| undo.iter().position(|s| s.id == id)); if let Some(state) = source.and_then(|i| undo.get(i)) { total = total.saturating_add(tile_bytes(&state.doc, &mut seen)); } @@ -683,6 +683,31 @@ mod tests { assert_eq!((h.brush_source(), h.row_doc(1, &cur).unwrap().name.as_str()), (Some(1), "a")); } + /// The byte budget keeps the source set before Clear History among the cleared states, so + /// Undo Clear History brings it back as the History Brush's source. + #[test] + fn byte_budget_keeps_the_brush_source_of_the_cleared_states() { + let mut h = History::new(50); + let mut cur = Arc::new(base()); + for i in 0..5 { + edit(&mut h, &mut cur, &format!("paint {i}"), |d| { + let id = d.layers[0].id; + d.layer_mut(id).unwrap().surface_mut().unwrap().write_pixel(1, 1, &[i as f32 / 8.0, 0.0, 0.0, 1.0]); + }); + } + assert!(h.set_brush_source(1)); + let source = h.state(1).unwrap(); + assert!(h.clear_states()); + h.max_bytes = 1; + assert_eq!(h.trim(&cur), 3); + assert_counter(&h, &cur); + assert!(h.is_cleared() && h.past_len() == 0, "the list stays cleared"); + cur = h.undo(cur).unwrap().0; + assert_eq!(h.entries(), ["paint 0", "paint 3", "paint 4"], "the source is kept with the last step"); + assert_eq!(h.brush_source(), Some(0)); + assert!(Arc::ptr_eq(&h.row_doc(0, &cur).unwrap(), &source), "the History Brush paints from the same state"); + } + #[test] fn new_edit_clears_redo() { let mut h = History::default(); From 86761e74299f90bbfcfb5b2c193bc3a6a0d52daf Mon Sep 17 00:00:00 2001 From: Finn Newick Date: Thu, 22 Oct 2026 23:52:26 +0100 Subject: [PATCH 12/86] fix(fill): keep CMYK typed for a Solid Color fill in a CMYK document The Solid Color picker passed only its sRGB stand-in to layer.newFillLayer.solidColor and solid_fill.set, so CMYK 50/50/50/100 typed in a CMYK document came back as other inks (57/47/45/91 in the report). Both commands take the inks the colour was picked as (`cmyk`) and store exactly those in a CMYK document; a new fill seeded from the foreground uses its CMYK pick, as fills do since #4196. The dialog passes the CMYK it holds, and reopening a CMYK fill's picker shows its inks. Fixes #8568 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands.rs | 38 ++++++++++------ crates/engine/src/solid_fill_cmds.rs | 53 +++++++++++++++++++++-- crates/ui-egui/src/color_picker_ui.rs | 7 +++ crates/ui-egui/src/solid_fill_ui.rs | 15 ++++++- crates/ui-egui/src/solid_fill_ui/tests.rs | 40 +++++++++++++++++ 5 files changed, 135 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 2b65788fcc..d466a36a88 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -267,6 +267,19 @@ pub(crate) fn check_f32_values(cmd: &str, value: &Value) -> Result<()> { check(cmd, value, 0) } +/// `p[key]` as CMYK inks 0..1 (`[c, m, y, k]`), the values a colour was picked as, or `None` +/// when absent or null. A CMYK document stores exactly these instead of what the colour's sRGB +/// stand-in converts back to (#4196, #8568). +pub(crate) fn cmyk_param(cmd: &str, p: &Value, key: &str) -> Result> { + let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) }; + let inks: Option> = + v.as_array().filter(|a| a.len() == 4).and_then(|a| a.iter().map(|x| x.as_f64().filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 1.0) as f32)).collect()); + match inks.as_deref() { + Some(&[c, m, y, k]) => Ok(Some([c, m, y, k])), + _ => Err(bad(cmd, format!("`{key}` must be [c, m, y, k] numbers 0..1"))), + } +} + pub(crate) fn color_param(cmd: &str, p: &Value, key: &str, default: [f32; 4]) -> Result<[f32; 4]> { Ok(match p.get(key) { Some(Value::Array(a)) if a.len() >= 3 => { @@ -1051,13 +1064,22 @@ fn build() -> Vec { "Solid Color…", ["Layer", "New Fill Layer"], None, - r##"{"color":"#rrggbb"=foreground,"path":"work"|saved path name|{…path}? (the active path: becomes the vector mask, making a shape; else the selection is the layer mask)}"##, + r##"{"color":"#rrggbb"=foreground,"cmyk":[c,m,y,k]? (inks 0..1 the colour was picked as: a CMYK document's fill holds exactly these; default the foreground's CMYK pick),"path":"work"|saved path name|{…path}? (the active path: becomes the vector mask, making a shape; else the selection is the layer mask)}"##, has_doc, |s, p| { let c = color_param("layer.newFillLayer.solidColor", p, "color", s.tools.foreground)?; + // The inks the colour was picked as, so CMYK 50/50/50/100 stays 50/50/50/100 instead + // of what its sRGB stand-in converts back to through the document profile (#8568). + let inks = match cmyk_param("layer.newFillLayer.solidColor", p, "cmyk")? { + Some(k) => Some(k), + None => s.tools.picked_cmyk([c[0], c[1], c[2]]), + }; let default_mask = layer_panel_option(s, "useDefaultMasksOnFillLayers", true); let id = s.edit("New Color Fill Layer", |doc, active| { - let fill = Fill::Solid(Color::rgba(c[0], c[1], c[2], c[3])).in_mode(doc.mode); + let fill = match inks { + Some(k) if doc.mode == ColorMode::Cmyk => Fill::Solid(Color { mode: ColorMode::Cmyk, c: k, alpha: c[3] }), + _ => Fill::Solid(Color::rgba(c[0], c[1], c[2], c[3])).in_mode(doc.mode), + }; let mut l = Layer::new(doc.next_layer_name("Color Fill"), LayerContent::Fill(fill)); mask_new_layer(doc, &mut l, p, "layer.newFillLayer.solidColor", default_mask)?; let id = doc.insert_above(*active, l); @@ -1196,17 +1218,7 @@ fn build() -> Vec { r##"{"foreground":"#rrggbb"|[r,g,b,a?]?,"background":same?,"foregroundCmyk":[c,m,y,k]? (inks 0..1 the foreground was picked as: a CMYK document is filled with exactly these),"backgroundCmyk":same?}"##, always, |s, p| { - let cmyk = |key: &str| -> Result> { - let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) }; - let inks: Option> = v - .as_array() - .filter(|a| a.len() == 4) - .and_then(|a| a.iter().map(|x| x.as_f64().filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 1.0) as f32)).collect()); - match inks.as_deref() { - Some(&[c, m, y, k]) => Ok(Some([c, m, y, k])), - _ => Err(bad("tools.setColors", format!("`{key}` must be [c, m, y, k] numbers 0..1"))), - } - }; + let cmyk = |key: &str| cmyk_param("tools.setColors", p, key); let (fg_cmyk, bg_cmyk) = (cmyk("foregroundCmyk")?, cmyk("backgroundCmyk")?); let foreground = color_param("tools.setColors", p, "foreground", s.tools.foreground)?; let background = color_param("tools.setColors", p, "background", s.tools.background)?; diff --git a/crates/engine/src/solid_fill_cmds.rs b/crates/engine/src/solid_fill_cmds.rs index efa43f6116..85d941760d 100644 --- a/crates/engine/src/solid_fill_cmds.rs +++ b/crates/engine/src/solid_fill_cmds.rs @@ -4,7 +4,7 @@ use photocraft_color::{Color, ColorMode}; use photocraft_doc::{Fill, LayerContent}; use serde_json::{Value, json}; -use crate::commands::{CommandSpec, check_pixels_unlocked, layer_param}; +use crate::commands::{CommandSpec, check_pixels_unlocked, cmyk_param, layer_param}; use crate::{EngineError, Result, Session}; pub const SET: &str = "layer.fill.solidColor.set"; @@ -57,7 +57,13 @@ fn set(s: &mut Session, p: &Value) -> Result { let id = layer_param(s, p)?; let layer = st.doc.layer(id).ok_or(EngineError::NoLayer(id))?; let LayerContent::Fill(Fill::Solid(original)) = layer.content else { return Err(bad("the target must be a Solid Color Fill layer")) }; - let color = color_param(p.get("color").ok_or_else(|| bad("color is required"))?, original, st.doc.mode)?; + let mut color = color_param(p.get("color").ok_or_else(|| bad("color is required"))?, original, st.doc.mode)?; + // The inks the colour was picked as: a CMYK document keeps them exactly (#8568). + if let Some(k) = cmyk_param(SET, p, "cmyk")? + && st.doc.mode == ColorMode::Cmyk + { + color = Color { mode: ColorMode::Cmyk, c: k, alpha: color.alpha }; + } if color != original { check_pixels_unlocked(&st.doc, id)?; if st.doc.effective_locks(id).transparency && color.alpha != original.alpha { @@ -78,7 +84,7 @@ pub fn specs() -> Vec { label: "Solid Color…", menu: &[], shortcut: None, - params: r##"{"layer":id? (default active),"document":id? (reject a stale dialog),"color":"#rrggbb"|"#rrggbbaa"|[r,g,b,a?]|{mode,c:[f32;4],alpha:f32}}; RGB values without alpha preserve it; stored Color preserves its model and precision"##, + params: r##"{"layer":id? (default active),"document":id? (reject a stale dialog),"color":"#rrggbb"|"#rrggbbaa"|[r,g,b,a?]|{mode,c:[f32;4],alpha:f32},"cmyk":[c,m,y,k]? (inks 0..1 the colour was picked as: a CMYK document keeps exactly these)}; RGB values without alpha preserve it; stored Color preserves its model and precision"##, enabled: |s| s.active().map(|_| ()).ok_or_else(|| "no document open".into()), run: set, journal: true, @@ -89,6 +95,47 @@ pub fn specs() -> Vec { mod tests { use super::*; + /// #8568: CMYK 50/50/50/100 entered for a Solid Color fill in a CMYK document stays exactly + /// those inks, on creation and on edit, instead of what its sRGB stand-in converts back to + /// through the document profile. RGB documents ignore the inks. + #[test] + fn cmyk_inks_survive_solid_color_fills_in_a_cmyk_document() { + let inks = [0.5, 0.5, 0.5, 1.0]; + let hex = |k: [f32; 4]| { + let [r, g, b] = photocraft_color::convert::cmyk_to_rgb(k).map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8); + format!("#{r:02x}{g:02x}{b:02x}") + }; + let stored = |s: &Session, id: u64| match s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().content { + LayerContent::Fill(Fill::Solid(c)) => c, + _ => panic!("not a solid fill"), + }; + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("image.mode.cmyk", json!({})).unwrap(); + let id = s.execute("layer.newFillLayer.solidColor", json!({"color": hex(inks), "cmyk": inks})).unwrap()["layer"].as_u64().unwrap(); + assert_eq!(stored(&s, id), Color { mode: ColorMode::Cmyk, c: inks, alpha: 1.0 }); + // The sRGB stand-in alone does drift: that is what the inks prevent. + let drifted = s.execute("layer.newFillLayer.solidColor", json!({"color": hex(inks)})).unwrap()["layer"].as_u64().unwrap(); + assert_ne!(stored(&s, drifted).c, inks); + // Editing keeps the new inks too. + let edit = [0.2, 0.3, 0.4, 0.5]; + s.execute(SET, json!({"layer": id, "color": hex(edit), "cmyk": edit})).unwrap(); + assert_eq!(stored(&s, id), Color { mode: ColorMode::Cmyk, c: edit, alpha: 1.0 }); + // A foreground picked as CMYK seeds a new fill with its inks, as fills do (#4196). + s.execute("tools.setColors", json!({"foreground": hex(edit), "foregroundCmyk": edit})).unwrap(); + let seeded = s.execute("layer.newFillLayer.solidColor", json!({})).unwrap()["layer"].as_u64().unwrap(); + assert_eq!(stored(&s, seeded).c, edit); + for bad in [json!([0.1, 0.2, 0.3]), json!("50,50,50,100"), json!([0.1, 0.2, 0.3, f64::NAN])] { + assert!(s.execute("layer.newFillLayer.solidColor", json!({"cmyk": bad})).is_err(), "{bad}"); + assert!(s.execute(SET, json!({"layer": id, "color": "#000000", "cmyk": bad})).is_err(), "{bad}"); + } + // An RGB document takes the colour, not the inks. + let mut rgb = Session::new(); + rgb.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let id = rgb.execute("layer.newFillLayer.solidColor", json!({"color": "#336699", "cmyk": inks})).unwrap()["layer"].as_u64().unwrap(); + assert_eq!(stored(&rgb, id).mode, ColorMode::Rgb); + } + #[test] fn edit_preserves_layer_and_masks_at_all_depths_and_undo_redo() { for depth in [8, 16, 32] { diff --git a/crates/ui-egui/src/color_picker_ui.rs b/crates/ui-egui/src/color_picker_ui.rs index 27177ceb57..8c91b7df5b 100644 --- a/crates/ui-egui/src/color_picker_ui.rs +++ b/crates/ui-egui/src/color_picker_ui.rs @@ -321,6 +321,13 @@ where (hex(to_rgb(v)) == hex(rgb)).then_some(v) } +/// The CMYK values typed into the picker, while they still match its colour: the inks a command +/// should keep in a CMYK document (#4196, #8568). +pub(crate) fn picked_cmyk(f: &Map) -> Option<[f32; 4]> { + let rgb = f.get("color").and_then(Value::as_str).and_then(parse_hex)?; + kept::<4>(f, "__cmyk", rgb, photocraft_color::convert::cmyk_to_rgb) +} + /// Current colour, keeping the stored HSB, Lab or CMYK values while they still match `color`. pub fn current(f: &Map) -> Components { let rgb = f.get("color").and_then(Value::as_str).and_then(parse_hex).unwrap_or([0.0; 3]); diff --git a/crates/ui-egui/src/solid_fill_ui.rs b/crates/ui-egui/src/solid_fill_ui.rs index 5d9112abab..2b6c11cf07 100644 --- a/crates/ui-egui/src/solid_fill_ui.rs +++ b/crates/ui-egui/src/solid_fill_ui.rs @@ -27,7 +27,14 @@ pub(crate) fn open(app: &mut PhotocraftApp) -> Option { let layer = st.active_layer?; let LayerContent::Fill(Fill::Solid(color)) = st.doc.layer(layer)?.content else { return None }; let params = json!({"layer": layer.0, "document": st.doc.id.0}); - Some(color_picker_ui::open_for_command(app, "Color Picker (Solid Color)", color.to_rgb(), cmds::SET, params)) + let id = color_picker_ui::open_for_command(app, "Color Picker (Solid Color)", color.to_rgb(), cmds::SET, params); + // A CMYK fill reopens showing its own inks, not ones recomputed from its sRGB stand-in (#8568). + if color.mode == photocraft_color::ColorMode::Cmyk + && let Some(d) = app.ui.dialog_mut(id) + { + d.fields.insert("__cmyk".into(), json!(color.c)); + } + Some(id) } /// Creating a Solid Color fill is transactional: the document is unchanged until OK. @@ -57,7 +64,8 @@ pub(crate) fn confirm_new(app: &mut PhotocraftApp, f: &Map) -> Re return Err("the target document is no longer active".into()); } let color = f.get("color").and_then(Value::as_str).ok_or("missing Solid Color colour")?; - app.run("layer.newFillLayer.solidColor", json!({"color": color})) + // CMYK typed into the picker stays those inks in a CMYK document (#8568). + app.run("layer.newFillLayer.solidColor", json!({"color": color, "cmyk": color_picker_ui::picked_cmyk(f)})) } pub(crate) fn owns(f: &Map) -> bool { @@ -98,6 +106,9 @@ pub(crate) fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result let color = f.get("color").cloned().ok_or("missing color")?; let object = params.as_object_mut().ok_or("invalid Solid Color target")?; object.insert("color".into(), color); + if let Some(inks) = color_picker_ui::picked_cmyk(f) { + object.insert("cmyk".into(), json!(inks)); + } app.run(cmds::SET, params) } diff --git a/crates/ui-egui/src/solid_fill_ui/tests.rs b/crates/ui-egui/src/solid_fill_ui/tests.rs index 3317875153..30c718eb07 100644 --- a/crates/ui-egui/src/solid_fill_ui/tests.rs +++ b/crates/ui-egui/src/solid_fill_ui/tests.rs @@ -269,3 +269,43 @@ fn cmyk_lab_preview_thumbnail_cancel_and_ok_are_consistent() { assert_eq!(app.session.active().unwrap().doc.layer(id).unwrap().content, LayerContent::Fill(Fill::Solid(preview))); } } + +/// #8568: CMYK 50/50/50/100 typed into the Solid Color picker of a CMYK document is the fill's +/// colour after OK, for a new fill and for an edited one, not what its sRGB stand-in converts back +/// to through the document profile. +#[test] +fn cmyk_typed_into_the_picker_is_the_fills_colour_in_a_cmyk_document() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("image.mode.cmyk", json!({})).unwrap(); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + let typed = |f: &mut serde_json::Map, inks: [f32; 4]| { + let [r, g, b] = photocraft_color::convert::cmyk_to_rgb(inks).map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8); + f.insert("color".into(), json!(format!("#{r:02x}{g:02x}{b:02x}"))); + f.insert("__cmyk".into(), json!(inks)); + }; + let fill = |app: &PhotocraftApp| { + let st = app.session.active().unwrap(); + match st.doc.layer(st.active_layer.unwrap()).unwrap().content { + LayerContent::Fill(Fill::Solid(c)) => c, + _ => panic!("not a solid fill"), + } + }; + let inks = [0.5, 0.5, 0.5, 1.0]; + let id = open_new(&mut app).unwrap(); + let mut f = app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.clone(); + typed(&mut f, inks); + confirm_new(&mut app, &f).unwrap(); + assert_eq!(fill(&app), Color { mode: ColorMode::Cmyk, c: inks, alpha: 1.0 }); + + let edit = [0.25, 0.75, 0.0, 0.1]; + let id = open(&mut app).unwrap(); + let mut f = app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.clone(); + typed(&mut f, edit); + confirm(&mut app, &f).unwrap(); + assert_eq!(fill(&app).c, edit); + // Reopening the picker shows the stored inks. + let id = open(&mut app).unwrap(); + let shown = color_picker_ui::current(&app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields); + assert_eq!(shown.cmyk, edit); +} From 496056bb1583ecb7e8414c9d31ae81132a808d20 Mon Sep 17 00:00:00 2001 From: Finn Newick Date: Fri, 23 Oct 2026 00:01:14 +0100 Subject: [PATCH 13/86] fix(fill): new inks with the same display colour are still an edit CMYK 50/50/50/100 and nearly identical inks can show as one colour, so the Solid Color picker's no-change check now also compares the inks. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/solid_fill_ui.rs | 12 ++++++++---- crates/ui-egui/src/solid_fill_ui/tests.rs | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/solid_fill_ui.rs b/crates/ui-egui/src/solid_fill_ui.rs index 2b6c11cf07..a1f7b5f8d8 100644 --- a/crates/ui-egui/src/solid_fill_ui.rs +++ b/crates/ui-egui/src/solid_fill_ui.rs @@ -97,16 +97,20 @@ pub(crate) fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result return Err("the target document is no longer active".into()); } let layer = LayerId(params.get("layer").and_then(Value::as_u64).ok_or("missing Solid Color layer")?); - if !st.doc.layer(layer).is_some_and(|l| matches!(l.content, LayerContent::Fill(Fill::Solid(_)))) { + let Some(LayerContent::Fill(Fill::Solid(original))) = st.doc.layer(layer).map(|l| &l.content) else { return Err("the target Solid Color Fill layer no longer exists".into()); - } - if f.get("color") == f.get("__orig") { + }; + // Different inks can share a display colour (CMYK 0/0/0/100 and 50/50/50/100 are both black): + // only a colour whose inks are unchanged too is no edit (#8568). + let inks = color_picker_ui::picked_cmyk(f); + let same_inks = inks.is_none_or(|k| original.mode != photocraft_color::ColorMode::Cmyk || original.c == k); + if f.get("color") == f.get("__orig") && same_inks { return Ok(json!({"layer": layer.0, "unchanged": true})); } let color = f.get("color").cloned().ok_or("missing color")?; let object = params.as_object_mut().ok_or("invalid Solid Color target")?; object.insert("color".into(), color); - if let Some(inks) = color_picker_ui::picked_cmyk(f) { + if let Some(inks) = inks { object.insert("cmyk".into(), json!(inks)); } app.run(cmds::SET, params) diff --git a/crates/ui-egui/src/solid_fill_ui/tests.rs b/crates/ui-egui/src/solid_fill_ui/tests.rs index 30c718eb07..ae61fe51f0 100644 --- a/crates/ui-egui/src/solid_fill_ui/tests.rs +++ b/crates/ui-egui/src/solid_fill_ui/tests.rs @@ -308,4 +308,18 @@ fn cmyk_typed_into_the_picker_is_the_fills_colour_in_a_cmyk_document() { let id = open(&mut app).unwrap(); let shown = color_picker_ui::current(&app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields); assert_eq!(shown.cmyk, edit); + + // Other inks with the same display colour are still an edit. + let near = [0.5, 0.5, 0.499, 1.0]; + let id = open(&mut app).unwrap(); + let mut f = app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.clone(); + typed(&mut f, near); + confirm(&mut app, &f).unwrap(); + let rich = [0.5, 0.5, 0.5, 1.0]; + let id = open(&mut app).unwrap(); + let mut f = app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.clone(); + typed(&mut f, rich); + assert_eq!(f.get("color"), f.get("__orig"), "both inks show as the same colour"); + confirm(&mut app, &f).unwrap(); + assert_eq!(fill(&app).c, rich); } From e0a60a65c9001e6d44b2c8b756cd79a18ea3db37 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 04:17:01 +0200 Subject: [PATCH 14/86] Export As: a remembered pixel size keeps the proportions of the image being exported (fixes #8738) --- crates/ui-egui/src/export_dialog.rs | 109 ++++++++++++++++++++++++---- 1 file changed, 94 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/export_dialog.rs b/crates/ui-egui/src/export_dialog.rs index 29ee74b1aa..8b780e995c 100644 --- a/crates/ui-egui/src/export_dialog.rs +++ b/crates/ui-egui/src/export_dialog.rs @@ -111,16 +111,36 @@ pub fn open(app: &mut PhotocraftApp) -> Result { f.insert("__w".into(), json!(st.doc.size.width)); f.insert("__h".into(), json!(st.doc.size.height)); seed_remembered(app, &mut f); - let explicit = s(&f, "sizing") == "pixels" && f.contains_key("width") && f.contains_key("height"); - if !explicit && let Ok(size) = resize_from_scale(f64::from(st.doc.size.width), f64::from(st.doc.size.height), n(&f, "scale", 100.0)) { + fit_size(&mut f, f64::from(st.doc.size.width), f64::from(st.doc.size.height)); + Ok(app.ui.open_dialog(DialogKind::Command, f)) +} + +/// The size fields for exporting a `src_w` × `src_h` source. A remembered percent scales it. +/// Remembered exact pixels with Constrain proportions keep their width and take the height from +/// this source, as ticking the checkbox does: the last export's proportions would stretch an +/// image of another shape (#8738). Unlinked, the exact pixels are the size the user asked for. +/// A linked width this source can't reach within the Scale field's 1..=1000 % falls back to the +/// remembered percent. +fn fit_size(f: &mut Map, src_w: f64, src_h: f64) { + if s(f, "sizing") == "pixels" && f.contains_key("width") && f.contains_key("height") { + if !f.get("lockAspect").and_then(Value::as_bool).unwrap_or(true) { + return; + } + if let Ok(size) = resize_from_side(src_w, src_h, n(f, "width", src_w.max(1.0)), n(f, "height", src_h.max(1.0)), true, true) + && (1.0..=1000.0).contains(&size.scale) + { + write_size(f, size, "pixels"); + return; + } + } + if let Ok(size) = resize_from_scale(src_w, src_h, n(f, "scale", 100.0)) { f.insert("width".into(), json!(size.width)); f.insert("height".into(), json!(size.height)); - if (n(&f, "scale", 100.0) - size.scale).abs() > 1e-9 { + if (n(f, "scale", 100.0) - size.scale).abs() > 1e-9 { f.insert("scale".into(), json!(size.scale)); } f.insert("sizing".into(), json!("scale")); } - Ok(app.ui.open_dialog(DialogKind::Command, f)) } /// Layer › Export As…: the same dialog for just the active layer (trimmed to its pixels). @@ -134,14 +154,9 @@ pub fn open_layer(app: &mut PhotocraftApp, layer: photocraft_doc::LayerId) -> Re f.insert("__label".into(), json!(crate::i18n::fmt(crate::i18n::t("Export As: {name}"), &[("name", &ldoc.name)]))); f.insert("__w".into(), json!(ldoc.size.width)); f.insert("__h".into(), json!(ldoc.size.height)); - // `open` seeded width and height from the document; in scale mode they follow the layer's - // trimmed size, so the dialog shows and edits the size the layer exports at. Exact pixels - // (sizing "pixels") are a size the user asked for, so those stay. - if s(f, "sizing") != "pixels" - && let Ok(size) = resize_from_scale(f64::from(ldoc.size.width), f64::from(ldoc.size.height), n(f, "scale", 100.0)) - { - write_size(f, size, "scale"); - } + // `open` fitted width and height to the document; they follow the layer's trimmed size + // instead, so the dialog shows and edits the size the layer exports at. + fit_size(f, f64::from(ldoc.size.width), f64::from(ldoc.size.height)); } Ok(id) } @@ -1196,8 +1211,9 @@ mod tests { } /// Layer › Export As opens with the layer's trimmed size, not the document's: `open` seeds the - /// size fields from the document, and the layer replaces them in scale mode. A remembered - /// percent scales the layer; remembered exact pixels stay the size the user asked for. + /// size fields to the document, and the layer refits them. A remembered percent scales the + /// layer; remembered exact pixels stay the size the user asked for, in the layer's proportions + /// when they are linked. #[test] fn layer_export_as_opens_with_the_layer_size() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); @@ -1216,10 +1232,73 @@ mod tests { let f = &app.ui.dialog_mut(id).unwrap().fields; assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(5.0), Some(3.0))); - app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), json!({"sizing": "pixels", "width": 120, "height": 80}))); + app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), json!({"sizing": "pixels", "width": 120, "height": 80, "lockAspect": false}))); let id = open_layer(&mut app, layer).unwrap(); let f = &app.ui.dialog_mut(id).unwrap().fields; assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(120.0), Some(80.0))); + + // With Constrain proportions the remembered width stays and the height follows the layer. + app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), json!({"sizing": "pixels", "width": 50, "height": 80, "lockAspect": true}))); + let id = open_layer(&mut app, layer).unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(50.0), Some(30.0))); + assert_eq!(f["sizing"], "pixels"); + } + + /// #8738: a pixel size remembered from a landscape export, with Constrain proportions on, + /// keeps its width and takes the height from the portrait image being exported, as ticking + /// the checkbox does; unlinked, both sides are the exact size asked for. A recorded action + /// step is not the remembered size: it replays with exactly the pixels it was recorded with. + #[test] + fn a_remembered_pixel_size_keeps_this_images_proportions() { + let sizes = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let log = sizes.clone(); + let services = crate::Services { + export: Some(Box::new(move |doc, _, _| { + log.borrow_mut().push((doc.size.width, doc.size.height)); + Ok((Vec::new(), Vec::new())) + })), + write: Some(Box::new(|_, _| Ok(()))), + ..Default::default() + }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + app.run("file.new", json!({"width": 30, "height": 40})).unwrap(); + let landscape = json!({"sizing": "pixels", "width": 20, "height": 15, "scale": 50, "lockAspect": true}); + app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), landscape.clone())); + let id = open(&mut app).unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(20.0), Some(27.0))); + assert_eq!(f["sizing"], "pixels"); + assert!((f["scale"].as_f64().unwrap() - 200.0 / 3.0).abs() < 1e-9, "{}", f["scale"]); + + let mut unlinked = landscape.clone(); + unlinked["lockAspect"] = json!(false); + app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), unlinked)); + let id = open(&mut app).unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(20.0), Some(15.0))); + + app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), landscape.clone())); + let mut step = landscape; + step["path"] = json!("/x.png"); + step["format"] = json!("png"); + replay(&mut app, &step).unwrap(); + assert_eq!(*sizes.borrow(), [(20, 15)]); + } + + /// A remembered linked width this image can't take within the Scale field's 1..=1000 % + /// (2000 px from a 4000 px photo, on a 100 px image) falls back to the remembered percent. + #[test] + fn a_remembered_pixel_size_out_of_scale_falls_back_to_the_percent() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.session + .prefs + .edit(|p| p.dialogs.insert(COMMAND.into(), json!({"sizing": "pixels", "width": 2000, "height": 1500, "scale": 50, "lockAspect": true}))); + let id = open(&mut app).unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(50.0), Some(50.0))); + assert_eq!((f["sizing"].as_str(), f["scale"].as_f64()), (Some("scale"), Some(50.0))); } /// Only the dialog's own settings are remembered: not the destination, the document size or From ce304774e0701019495f224ab574fb26b0d46ce0 Mon Sep 17 00:00:00 2001 From: Finn Newick Date: Fri, 23 Oct 2026 04:45:03 +0100 Subject: [PATCH 15/86] fix(blur): Gaussian Blur radii below 1 blur by the radius asked for Sampling the Gaussian at whole pixels left radii 0.1-0.3 with almost no blur (at 0.3 the kernel's variance was 0.008 instead of 0.09), then a jump. Below 1 the sampling width is now chosen so the kernel's variance is exactly radius^2; from 1 up the kernel is unchanged. Fixes #8544 Co-Authored-By: Claude Opus 5.5 --- crates/algo/src/blur.rs | 67 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/crates/algo/src/blur.rs b/crates/algo/src/blur.rs index fb62681222..64335cd2f2 100644 --- a/crates/algo/src/blur.rs +++ b/crates/algo/src/blur.rs @@ -31,6 +31,33 @@ pub(crate) fn gaussian_kernel(sigma: f32) -> Vec { k.into_iter().map(|v| v / s).collect() } +/// Gaussian Blur's kernel for `radius` (its standard deviation). Sampling the curve at whole +/// pixels leaves a small radius almost no blur: at 0.3 the neighbour weight is about 0.004, a +/// tenth of the spread asked for (#8544). Below 1 the sampling width is instead chosen so the +/// kernel has exactly the variance `radius²`; from 1 up the sampled kernel is within about 0.4% +/// of it (its 3σ truncation), so larger radii are unchanged and the blur grows smoothly from 0.05. +pub(crate) fn gaussian_blur_kernel(radius: f32) -> Vec { + if !(0.05..1.0).contains(&radius) { + return gaussian_kernel(radius); + } + let variance = |k: &[f32]| { + let r = (k.len() / 2) as f32; + k.iter().enumerate().map(|(i, w)| w * (i as f32 - r).powi(2)).sum::() + }; + let target = radius * radius; + // The sampled kernel's variance only falls short of sigma², so the width lies above `radius`. + let (mut lo, mut hi) = (radius, radius + 1.0); + for _ in 0..40 { + let mid = 0.5 * (lo + hi); + if variance(&gaussian_kernel(mid)) < target { + lo = mid; + } else { + hi = mid; + } + } + gaussian_kernel(0.5 * (lo + hi)) +} + /// 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 { @@ -111,7 +138,7 @@ pub(crate) fn gaussian(src: &Image, out: Rect, ctx: &Ctx, radius: f32) -> Vec box_passes(src, out, ctx.alpha, &boxes), None => { - let k = gaussian_kernel(radius); + let k = gaussian_blur_kernel(radius); conv_sep(src, out, &k, &k, ctx.alpha) } } @@ -789,6 +816,44 @@ mod tests { use photocraft_color::{ColorMode, PixelFormat, SampleType}; use photocraft_raster::Surface; + /// #8544: Gaussian Blur spreads a hard edge for every radius from 0.1, growing smoothly + /// with the radius (no dead zone up to 0.3, then a jump); from 1 up the kernel is unchanged. + #[test] + fn small_gaussian_radii_blur_smoothly() { + let spread = |k: &[f32]| { + let r = (k.len() / 2) as f32; + k.iter().enumerate().map(|(i, w)| w * (i as f32 - r).powi(2)).sum::() + }; + let mut last = 0.0f32; + for step in 1..=20 { + let radius = step as f32 * 0.05; + let k = gaussian_blur_kernel(radius); + assert!((k.iter().sum::() - 1.0).abs() < 1e-5, "normalised at {radius}"); + if radius < 1.0 { + assert!((spread(&k) - radius * radius).abs() < 1e-4, "variance at {radius}: {}", spread(&k)); + } + // A hard 0 → 1 edge: the first pixel past it takes the kernel's tail beyond the centre. + let c = k.len() / 2; + let edge: f32 = k[c + 1..].iter().sum(); + assert!(edge > last, "radius {radius}: edge {edge} after {last}"); + if radius > 0.1 { + assert!(edge - last < 0.05, "radius {radius}: jump {} from {last}", edge - last); + } + last = edge; + } + assert!(gaussian_blur_kernel(0.1).len() >= 3, "0.1 blurs"); + // No step where the variance-matched range hands over to plain sampling. + let edge = |r: f32| { + let k = gaussian_blur_kernel(r); + k[k.len() / 2 + 1..].iter().sum::() + }; + assert!((edge(0.999) - edge(1.0)).abs() < 0.005, "{} vs {}", edge(0.999), edge(1.0)); + for radius in [1.0, 1.5, 3.0, 4.0] { + assert_eq!(gaussian_blur_kernel(radius), gaussian_kernel(radius), "{radius}"); + } + assert_eq!(gaussian_blur_kernel(0.0), vec![1.0]); + } + #[test] fn radial_blur_samples_large_arcs_beyond_64_intervals() { let bounds = Rect::new(0, 0, 1000, 1000); From ac7f3bc559cfe235e4943ec51d0c8ddedd1585d4 Mon Sep 17 00:00:00 2001 From: Finn Newick Date: Fri, 23 Oct 2026 05:28:39 +0100 Subject: [PATCH 16/86] fix(align): measure shape layers by everything they draw Align and Distribute measured shape layers from their cached render, which is clipped to the canvas, so a shape partly off the canvas looked smaller and Align moved layers by the wrong amount, needing several clicks. A shape layer is now measured from a render over its own path and stroke bounds, unclipped (or by those bounds past 64 MP). Fixes #8660 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/layer_multi_cmds.rs | 39 +++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 83d4e815a2..1e81c3d698 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -1019,11 +1019,29 @@ pub(crate) fn note_insert(s: &mut Session, before: &Document) { pub fn layer_bounds(l: &Layer) -> Option { let r = match &l.content { LayerContent::Group(g) => g.children.iter().filter_map(layer_bounds).fold(Rect::EMPTY, |a, b| a.union(&b)), + LayerContent::Shape(sh) => shape_pixel_bounds(sh).unwrap_or_else(|| l.surface().map(|s| s.content_bounds()).unwrap_or(Rect::EMPTY)), _ => l.surface().map(|s| s.content_bounds()).unwrap_or(Rect::EMPTY), }; (!r.is_empty()).then_some(r) } +/// Largest area [`shape_pixel_bounds`] renders to find a shape's extent; beyond it the path and +/// stroke bounds stand in. +const SHAPE_BOUNDS_MAX_PX: u64 = 64 << 20; + +/// The pixels a shape layer draws, wherever they fall. Its cache is rendered clipped to the +/// canvas, so a shape partly off the canvas measured from it looked smaller and Align moved it +/// by the wrong amount, a little more on each click (#8660). The shape is rendered again over its +/// path and stroke bounds, unclipped, and measured from that. +fn shape_pixel_bounds(sh: &photocraft_doc::ShapeLayer) -> Option { + let hard = photocraft_compose::hard_shape_bounds(sh)?.inflate(1); + if u64::from(hard.width()) * u64::from(hard.height()) > SHAPE_BOUNDS_MAX_PX { + return Some(hard); + } + let r = photocraft_vector::render_shape(sh, PixelFormat::GRAYA8, hard).content_bounds(); + (!r.is_empty()).then_some(r) +} + #[derive(Clone, Copy, PartialEq, Eq, Debug)] enum Edge { Top, @@ -2363,6 +2381,27 @@ mod tests { } } + /// #8660: a shape partly off the canvas aligns by everything it draws, not the part on the + /// canvas, so one click aligns the layers and a second moves nothing. + #[test] + fn align_uses_a_shape_layers_full_extent_off_the_canvas() { + let mut s = session(8); + let shape = + LayerId(s.execute("shape.create", json!({"kind": "rect", "rect": [-20, 10, 40, 20], "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap()); + assert_eq!(bounds(&s, shape), Rect::new(-20, 10, 20, 30), "the shape's own extent, not the canvas-clipped render"); + let pixels = rect_layer(&mut s, Rect::new(30, 40, 60, 50)); + select_all(&mut s, &[shape, pixels]); + s.execute("layer.align.leftEdges", json!({})).unwrap(); + assert_eq!(bounds(&s, pixels), Rect::new(-20, 40, 10, 50)); + assert_eq!(bounds(&s, shape), Rect::new(-20, 10, 20, 30)); + s.execute("layer.align.leftEdges", json!({})).unwrap(); + assert_eq!(bounds(&s, pixels), Rect::new(-20, 40, 10, 50), "a second click moves nothing"); + // Centred: the union is -20..20 wide on the shape's side and 30 px for the pixel layer. + s.execute("layer.align.horizontalCenters", json!({})).unwrap(); + assert_eq!(bounds(&s, shape), Rect::new(-20, 10, 20, 30)); + assert_eq!(bounds(&s, pixels), Rect::new(-15, 40, 15, 50)); + } + #[test] fn align_single_layer_to_selection() { for depth in [8, 16, 32] { From 0a8615c2fb9356a7e671dcbad0a8b0f44ec2b7f4 Mon Sep 17 00:00:00 2001 From: Finn Newick Date: Fri, 23 Oct 2026 05:31:56 +0100 Subject: [PATCH 17/86] test(blur): small Gaussian radii soften a rendered edge Co-Authored-By: Claude Opus 5.5 --- crates/algo/src/blur.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/algo/src/blur.rs b/crates/algo/src/blur.rs index 64335cd2f2..520ead8b4c 100644 --- a/crates/algo/src/blur.rs +++ b/crates/algo/src/blur.rs @@ -816,6 +816,29 @@ mod tests { use photocraft_color::{ColorMode, PixelFormat, SampleType}; use photocraft_raster::Surface; + /// #8544, rendered: Gaussian Blur at 0.1–0.3 px through the filter itself (the direct-kernel + /// path) visibly softens a hard edge, more with each step, as the kernel predicts. + #[test] + fn small_gaussian_radii_soften_a_rendered_edge() { + let canvas = Rect::new(0, 0, 20, 4); + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(canvas, &[0.0, 0.0, 0.0, 1.0]); + s.fill_rect(Rect::new(10, 0, 20, 4), &[1.0, 1.0, 1.0, 1.0]); + let mut last = 0.0f32; + for radius in [0.1f32, 0.2, 0.3] { + let p = FilterParams::GaussianBlur { radius }; + assert!(box_widths(&p).is_none(), "the direct-kernel path"); + let out = crate::apply(&s, &p, canvas, canvas, None); + // The last black pixel takes the white side's share of the kernel. + let got = out.pixel(9, 1)[0]; + let k = gaussian_blur_kernel(radius); + let want: f32 = k[k.len() / 2 + 1..].iter().sum(); + assert!((got - want).abs() <= 1.0 / 255.0, "radius {radius}: {got} vs {want}"); + assert!(got >= 1.0 / 255.0 && got > last, "radius {radius}: {got} after {last}"); + last = got; + } + } + /// #8544: Gaussian Blur spreads a hard edge for every radius from 0.1, growing smoothly /// with the radius (no dead zone up to 0.3, then a jump); from 1 up the kernel is unchanged. #[test] From aca57d981b85b1ca2b77d6eedb0a5d566c722278 Mon Sep 17 00:00:00 2001 From: Finn Newick Date: Fri, 23 Oct 2026 05:47:50 +0100 Subject: [PATCH 18/86] bench(algo): Criterion benchmark for Gaussian Blur's direct-kernel path Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/algo/Cargo.toml | 5 +++++ crates/algo/benches/blur.rs | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+) create mode 100644 crates/algo/benches/blur.rs diff --git a/Cargo.lock b/Cargo.lock index 5c96f56119..854430173f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4696,6 +4696,7 @@ dependencies = [ name = "photocraft-algo" version = "0.14.0" dependencies = [ + "criterion", "photocraft-color", "photocraft-geom", "photocraft-raster", diff --git a/crates/algo/Cargo.toml b/crates/algo/Cargo.toml index c4f34b4b1d..2d152b88ea 100644 --- a/crates/algo/Cargo.toml +++ b/crates/algo/Cargo.toml @@ -22,3 +22,8 @@ rayon = { workspace = true } [dev-dependencies] serde_json = { workspace = true } +criterion = { workspace = true } + +[[bench]] +name = "blur" +harness = false diff --git a/crates/algo/benches/blur.rs b/crates/algo/benches/blur.rs new file mode 100644 index 0000000000..3165b40cd0 --- /dev/null +++ b/crates/algo/benches/blur.rs @@ -0,0 +1,33 @@ +//! `cargo bench -p photocraft-algo` +use criterion::{Criterion, criterion_group, criterion_main}; +use photocraft_algo::{FilterParams, apply}; +use photocraft_color::PixelFormat; +use photocraft_geom::Rect; +use photocraft_raster::Surface; + +fn textured(n: u32) -> Surface { + let r = Rect::new(0, 0, n as i32, n as i32); + let mut s = Surface::new(PixelFormat::RGBA8); + let bytes: Vec = (0..n).flat_map(|y| (0..n).flat_map(move |x| [(x ^ y) as u8, (x / 3) as u8, (y * 7) as u8, 255])).collect(); + s.write_interleaved(r, &bytes); + s +} + +/// Gaussian Blur's direct-kernel path (radius up to 4; larger radii use box passes), including +/// the variance-matched kernels below radius 1 (#8544). +fn gaussian_direct(c: &mut Criterion) { + let n = 2048; + let s = textured(n); + let area = Rect::new(0, 0, n as i32, n as i32); + for radius in [0.3f32, 0.8, 3.0] { + let p = FilterParams::GaussianBlur { radius }; + c.bench_function(&format!("gaussian_blur_{radius}px_2048"), |b| b.iter(|| apply(&s, &p, area, area, None))); + } +} + +criterion_group! { + name = benches; + config = Criterion::default().sample_size(10); + targets = gaussian_direct +} +criterion_main!(benches); From 9ff4b6a232bab5902c37a9889ad5d3a997cef365 Mon Sep 17 00:00:00 2001 From: Finn Newick Date: Fri, 23 Oct 2026 05:56:26 +0100 Subject: [PATCH 19/86] fix(align): measure a feathered shape by its soft edge The shape is rendered over its feathered reach, not just its path and stroke; a fill that shows everywhere (inverted path, or density below 100 %) keeps the canvas-clipped measure, having no extent of its own. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/layer_multi_cmds.rs | 41 +++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 1e81c3d698..60be8e6a35 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -1031,14 +1031,20 @@ const SHAPE_BOUNDS_MAX_PX: u64 = 64 << 20; /// The pixels a shape layer draws, wherever they fall. Its cache is rendered clipped to the /// canvas, so a shape partly off the canvas measured from it looked smaller and Align moved it -/// by the wrong amount, a little more on each click (#8660). The shape is rendered again over its -/// path and stroke bounds, unclipped, and measured from that. +/// by the wrong amount, a little more on each click (#8660). The shape is rendered again over +/// everything it can draw (its path, stroke and feather), unclipped, and measured from that. A fill +/// that shows everywhere (an inverted path, or below 100 % density) has no extent of its own and +/// keeps the canvas-clipped measure (`None`). fn shape_pixel_bounds(sh: &photocraft_doc::ShapeLayer) -> Option { - let hard = photocraft_compose::hard_shape_bounds(sh)?.inflate(1); - if u64::from(hard.width()) * u64::from(hard.height()) > SHAPE_BOUNDS_MAX_PX { - return Some(hard); + let unbounded_fill = sh.fill.is_some() && (sh.path.inverted || (sh.density.is_finite() && sh.density < 1.0)); + if unbounded_fill { + return None; } - let r = photocraft_vector::render_shape(sh, PixelFormat::GRAYA8, hard).content_bounds(); + let reach = photocraft_compose::shape_bounds(sh)?.inflate(1); + if u64::from(reach.width()) * u64::from(reach.height()) > SHAPE_BOUNDS_MAX_PX { + return photocraft_compose::hard_shape_bounds(sh); + } + let r = photocraft_vector::render_shape(sh, PixelFormat::GRAYA8, reach).content_bounds(); (!r.is_empty()).then_some(r) } @@ -2400,6 +2406,29 @@ mod tests { s.execute("layer.align.horizontalCenters", json!({})).unwrap(); assert_eq!(bounds(&s, shape), Rect::new(-20, 10, 20, 30)); assert_eq!(bounds(&s, pixels), Rect::new(-15, 40, 15, 50)); + // A feathered shape is measured by its soft edge too, which reaches past the path. + let soft = + LayerId(s.execute("shape.create", json!({"kind": "rect", "rect": [-20, 50, 40, 20], "fill": "#00ff00"})).unwrap()["layer"].as_u64().unwrap()); + s.edit("feather", |doc, _| { + if let Some(LayerContent::Shape(sh)) = doc.layer_mut(soft).map(|l| &mut l.content) { + sh.feather = 4.0; + } + Ok(()) + }) + .unwrap(); + let b = bounds(&s, soft); + assert!(b.x0 < -20 && b.x1 > 20 && b.y0 < 50 && b.y1 > 70, "{b:?}"); + // A fill that shows everywhere keeps the canvas-clipped measure. + s.edit("invert", |doc, _| { + if let Some(LayerContent::Shape(sh)) = doc.layer_mut(soft).map(|l| &mut l.content) { + sh.feather = 0.0; + sh.path.inverted = true; + } + Ok(()) + }) + .unwrap(); + let LayerContent::Shape(sh) = &doc(&s).layer(soft).unwrap().content else { panic!("shape") }; + assert_eq!(shape_pixel_bounds(sh), None); } #[test] From eb1c6d291fd3dc3929d646cf20c3caacdcde9489 Mon Sep 17 00:00:00 2001 From: Finn Newick Date: Fri, 23 Oct 2026 06:02:26 +0100 Subject: [PATCH 20/86] fix(blur): exact variance right up to radius 1 The search kept gaussian_kernel's width, which gains taps just below radius 1, so it could settle on that step with the wrong variance. The half-width is now fixed at ceil(3*radius) during the search, as gaussian_kernel uses from radius 1. Co-Authored-By: Claude Opus 5.5 --- crates/algo/src/blur.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/algo/src/blur.rs b/crates/algo/src/blur.rs index 520ead8b4c..1595dd1b88 100644 --- a/crates/algo/src/blur.rs +++ b/crates/algo/src/blur.rs @@ -44,18 +44,27 @@ pub(crate) fn gaussian_blur_kernel(radius: f32) -> Vec { let r = (k.len() / 2) as f32; k.iter().enumerate().map(|(i, w)| w * (i as f32 - r).powi(2)).sum::() }; + // Sampled at `width` with a fixed half-width: its variance then rises steadily with `width`, + // so the search can't settle on the step where `gaussian_kernel` gains taps. ceil(3·radius) + // is also the half-width `gaussian_kernel` uses from radius 1, so the hand-over is smooth. + let half = (radius * 3.0).ceil().max(1.0) as i32; + let sampled = |width: f32| { + let k: Vec = (-half..=half).map(|i| (-(i * i) as f32 / (2.0 * width * width)).exp()).collect(); + let s: f32 = k.iter().sum(); + k.into_iter().map(|v| v / s).collect::>() + }; let target = radius * radius; // The sampled kernel's variance only falls short of sigma², so the width lies above `radius`. let (mut lo, mut hi) = (radius, radius + 1.0); for _ in 0..40 { let mid = 0.5 * (lo + hi); - if variance(&gaussian_kernel(mid)) < target { + if variance(&sampled(mid)) < target { lo = mid; } else { hi = mid; } } - gaussian_kernel(0.5 * (lo + hi)) + sampled(0.5 * (lo + hi)) } /// Separable convolution of `src` (premultiplied internally) for `out`. @@ -871,6 +880,11 @@ mod tests { k[k.len() / 2 + 1..].iter().sum::() }; assert!((edge(0.999) - edge(1.0)).abs() < 0.005, "{} vs {}", edge(0.999), edge(1.0)); + // Exact variance right up to the hand-over, past where `gaussian_kernel` changes width. + for radius in [0.98f32, 0.995, 0.998, 0.9995] { + let k = gaussian_blur_kernel(radius); + assert!((spread(&k) - radius * radius).abs() < 2e-5, "variance at {radius}: {}", spread(&k)); + } for radius in [1.0, 1.5, 3.0, 4.0] { assert_eq!(gaussian_blur_kernel(radius), gaussian_kernel(radius), "{radius}"); } From 242ff51ecaf232513dfde184b89d3c6458dd0b0e Mon Sep 17 00:00:00 2001 From: Finn Newick Date: Fri, 23 Oct 2026 06:05:00 +0100 Subject: [PATCH 21/86] fix(align): lay a measured shape's gradient out in the document's frame Align and Distribute now measure through layer_bounds_in with the document bounds as the frame for gradients that aren't aligned with the layer, so an opacity stop measures as the cached render shows it; only the render clip is widened to the shape's reach. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/layer_multi_cmds.rs | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 60be8e6a35..73c2eb921c 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -1017,9 +1017,16 @@ pub(crate) fn note_insert(s: &mut Session, before: &Document) { /// Content bounds used by Align/Distribute: the layer's pixels (type and shape layers use their /// rendered pixels), or the union of a group's children. Fill and adjustment layers have none. pub fn layer_bounds(l: &Layer) -> Option { + layer_bounds_in(l, None) +} + +/// [`layer_bounds`] in a document of bounds `canvas`: the frame a shape's gradients are laid out +/// in when they aren't aligned with the layer, so an opacity stop measures as it renders. Without +/// it the shape's own reach stands in. +pub fn layer_bounds_in(l: &Layer, canvas: Option) -> Option { let r = match &l.content { - LayerContent::Group(g) => g.children.iter().filter_map(layer_bounds).fold(Rect::EMPTY, |a, b| a.union(&b)), - LayerContent::Shape(sh) => shape_pixel_bounds(sh).unwrap_or_else(|| l.surface().map(|s| s.content_bounds()).unwrap_or(Rect::EMPTY)), + LayerContent::Group(g) => g.children.iter().filter_map(|c| layer_bounds_in(c, canvas)).fold(Rect::EMPTY, |a, b| a.union(&b)), + LayerContent::Shape(sh) => shape_pixel_bounds(sh, canvas).unwrap_or_else(|| l.surface().map(|s| s.content_bounds()).unwrap_or(Rect::EMPTY)), _ => l.surface().map(|s| s.content_bounds()).unwrap_or(Rect::EMPTY), }; (!r.is_empty()).then_some(r) @@ -1035,7 +1042,7 @@ const SHAPE_BOUNDS_MAX_PX: u64 = 64 << 20; /// everything it can draw (its path, stroke and feather), unclipped, and measured from that. A fill /// that shows everywhere (an inverted path, or below 100 % density) has no extent of its own and /// keeps the canvas-clipped measure (`None`). -fn shape_pixel_bounds(sh: &photocraft_doc::ShapeLayer) -> Option { +fn shape_pixel_bounds(sh: &photocraft_doc::ShapeLayer, canvas: Option) -> Option { let unbounded_fill = sh.fill.is_some() && (sh.path.inverted || (sh.density.is_finite() && sh.density < 1.0)); if unbounded_fill { return None; @@ -1044,7 +1051,9 @@ fn shape_pixel_bounds(sh: &photocraft_doc::ShapeLayer) -> Option { if u64::from(reach.width()) * u64::from(reach.height()) > SHAPE_BOUNDS_MAX_PX { return photocraft_compose::hard_shape_bounds(sh); } - let r = photocraft_vector::render_shape(sh, PixelFormat::GRAYA8, reach).content_bounds(); + // Gradients lay out in the document's frame (as the cached render does); only the clip widens. + let frame = canvas.unwrap_or(reach); + let r = photocraft_vector::CompiledShape::new(sh, photocraft_vector::DEFAULT_TOLERANCE, frame).render(PixelFormat::GRAYA8, reach).content_bounds(); (!r.is_empty()).then_some(r) } @@ -1089,7 +1098,7 @@ impl Edge { /// Selected top-level layers that have content bounds, bottom-to-top. fn bounded_selection(doc: &Document, sel: &[LayerId]) -> Vec<(LayerId, Rect)> { - top_level(doc, sel).into_iter().filter_map(|id| Some((id, layer_bounds(doc.layer(id)?)?))).collect() + top_level(doc, sel).into_iter().filter_map(|id| Some((id, layer_bounds_in(doc.layer(id)?, Some(doc.bounds()))?))).collect() } fn align(s: &mut Session, p: &Value, kind: &str) -> Result { @@ -2428,7 +2437,7 @@ mod tests { }) .unwrap(); let LayerContent::Shape(sh) = &doc(&s).layer(soft).unwrap().content else { panic!("shape") }; - assert_eq!(shape_pixel_bounds(sh), None); + assert_eq!(shape_pixel_bounds(sh, Some(doc(&s).bounds())), None); } #[test] From 9a570c5ed5c98748f210c515386abc1b92087d83 Mon Sep 17 00:00:00 2001 From: Finn Newick Date: Fri, 23 Oct 2026 06:15:38 +0100 Subject: [PATCH 22/86] fix(blur): one kernel width for every Gaussian radius below 1 A width chosen per radius added taps at 1/3 and 2/3, where the blur dipped. The variance-matched kernels now all use a half-width of 3, the width gaussian_kernel has at radius 1. Co-Authored-By: Claude Opus 5.5 --- crates/algo/src/blur.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/algo/src/blur.rs b/crates/algo/src/blur.rs index 1595dd1b88..0f48d9c690 100644 --- a/crates/algo/src/blur.rs +++ b/crates/algo/src/blur.rs @@ -44,10 +44,11 @@ pub(crate) fn gaussian_blur_kernel(radius: f32) -> Vec { let r = (k.len() / 2) as f32; k.iter().enumerate().map(|(i, w)| w * (i as f32 - r).powi(2)).sum::() }; - // Sampled at `width` with a fixed half-width: its variance then rises steadily with `width`, - // so the search can't settle on the step where `gaussian_kernel` gains taps. ceil(3·radius) - // is also the half-width `gaussian_kernel` uses from radius 1, so the hand-over is smooth. - let half = (radius * 3.0).ceil().max(1.0) as i32; + // Sampled at `width` with one fixed half-width for every radius below 1: its variance then + // rises steadily with `width` (the search can't settle on a step where taps are added), and + // the blur grows without the dips a width chosen per radius made at 1/3 and 2/3. Three is the + // half-width `gaussian_kernel` uses at radius 1, so the hand-over is smooth too. + let half = 3; let sampled = |width: f32| { let k: Vec = (-half..=half).map(|i| (-(i * i) as f32 / (2.0 * width * width)).exp()).collect(); let s: f32 = k.iter().sum(); @@ -880,6 +881,10 @@ mod tests { k[k.len() / 2 + 1..].iter().sum::() }; assert!((edge(0.999) - edge(1.0)).abs() < 0.005, "{} vs {}", edge(0.999), edge(1.0)); + // No dip where a per-radius width used to add taps (1/3 and 2/3). + for pair in [(0.33f32, 0.34f32), (0.66, 0.67)] { + assert!(edge(pair.1) > edge(pair.0), "edge falls from {} to {}", pair.0, pair.1); + } // Exact variance right up to the hand-over, past where `gaussian_kernel` changes width. for radius in [0.98f32, 0.995, 0.998, 0.9995] { let k = gaussian_blur_kernel(radius); From ad91e9f38737ddc677c131e791f402c1f4697905 Mon Sep 17 00:00:00 2001 From: Finn Newick Date: Fri, 23 Oct 2026 06:17:56 +0100 Subject: [PATCH 23/86] fix(align): feather a measured shape over its reach, gradients in the canvas CompiledShape::new used one rectangle as both the gradient frame and the area a feather is computed over, so measuring in the document's frame cut the feather off at the canvas. CompiledShape::with_frames takes the two separately; Align measures with the feather over the shape's reach and gradients in the canvas. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/layer_multi_cmds.rs | 9 +++++++-- crates/vector/src/lib.rs | 13 ++++++++++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 73c2eb921c..e68a536906 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -1051,9 +1051,11 @@ fn shape_pixel_bounds(sh: &photocraft_doc::ShapeLayer, canvas: Option) -> if u64::from(reach.width()) * u64::from(reach.height()) > SHAPE_BOUNDS_MAX_PX { return photocraft_compose::hard_shape_bounds(sh); } - // Gradients lay out in the document's frame (as the cached render does); only the clip widens. + // Gradients lay out in the document's frame (as the cached render does); the feather and the + // clip cover the shape's whole reach, beyond the canvas too. let frame = canvas.unwrap_or(reach); - let r = photocraft_vector::CompiledShape::new(sh, photocraft_vector::DEFAULT_TOLERANCE, frame).render(PixelFormat::GRAYA8, reach).content_bounds(); + let shape = photocraft_vector::CompiledShape::with_frames(sh, photocraft_vector::DEFAULT_TOLERANCE, reach, frame); + let r = shape.render(PixelFormat::GRAYA8, reach).content_bounds(); (!r.is_empty()).then_some(r) } @@ -2427,6 +2429,9 @@ mod tests { .unwrap(); let b = bounds(&s, soft); assert!(b.x0 < -20 && b.x1 > 20 && b.y0 < 50 && b.y1 > 70, "{b:?}"); + // Measured as Align does, in the document's frame: the feather off the canvas still counts. + let in_doc = layer_bounds_in(doc(&s).layer(soft).unwrap(), Some(doc(&s).bounds())).unwrap(); + assert_eq!(in_doc, b); // A fill that shows everywhere keeps the canvas-clipped measure. s.edit("invert", |doc, _| { if let Some(LayerContent::Shape(sh)) = doc.layer_mut(soft).map(|l| &mut l.content) { diff --git a/crates/vector/src/lib.rs b/crates/vector/src/lib.rs index ac5fe8aad5..9206a4baf9 100644 --- a/crates/vector/src/lib.rs +++ b/crates/vector/src/lib.rs @@ -563,14 +563,21 @@ pub struct CompiledShape { impl CompiledShape { /// Compiles the shape at flattening tolerance `tol`. `canvas` is the frame gradients - /// without "Align with layer" are laid out in. + /// without "Align with layer" are laid out in, and the area a feather is computed over. pub fn new(shape: &ShapeLayer, tol: f64, canvas: Rect) -> Self { + Self::with_frames(shape, tol, canvas, canvas) + } + + /// [`CompiledShape::new`] with the feather computed over `coverage` while gradients still lay + /// out in `gradient_frame`: measuring a shape beyond the canvas it is drawn in. + pub fn with_frames(shape: &ShapeLayer, tol: f64, coverage: Rect, gradient_frame: Rect) -> Self { let pb = shape.path.control_bounds().unwrap_or((0.0, 0.0, 0.0, 0.0)); - let cb = (f64::from(canvas.x0), f64::from(canvas.y0), f64::from(canvas.x1), f64::from(canvas.y1)); + let g = gradient_frame; + let cb = (f64::from(g.x0), f64::from(g.y0), f64::from(g.x1), f64::from(g.y1)); let area = fill_rasterizer(&shape.path, tol); let mut bounds = None; let fill = shape.fill.as_ref().map(|f| { - let coverage = FillCoverage::new(shape, &area, canvas); + let coverage = FillCoverage::new(shape, &area, coverage); bounds = coverage.bounds(); (coverage, Paint::from_fill(f, pb, cb)) }); From 3ef632d313fcecba6e177efb830c01b3951f7894 Mon Sep 17 00:00:00 2001 From: Finn Newick Date: Fri, 23 Oct 2026 06:30:34 +0100 Subject: [PATCH 24/86] fix(align): an uncached fill that shows everywhere measures as the canvas Without a cached render, an inverted or sub-100 % density fill measured as empty and dropped out of Align and Distribute. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/layer_multi_cmds.rs | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index e68a536906..c81d24c4f1 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -1026,12 +1026,25 @@ pub fn layer_bounds(l: &Layer) -> Option { pub fn layer_bounds_in(l: &Layer, canvas: Option) -> Option { let r = match &l.content { LayerContent::Group(g) => g.children.iter().filter_map(|c| layer_bounds_in(c, canvas)).fold(Rect::EMPTY, |a, b| a.union(&b)), - LayerContent::Shape(sh) => shape_pixel_bounds(sh, canvas).unwrap_or_else(|| l.surface().map(|s| s.content_bounds()).unwrap_or(Rect::EMPTY)), + LayerContent::Shape(sh) => shape_pixel_bounds(sh, canvas).unwrap_or_else(|| { + let cached = l.surface().map(|s| s.content_bounds()).unwrap_or(Rect::EMPTY); + // A fill that shows everywhere covers the canvas, rendered yet or not. + match canvas { + Some(c) if cached.is_empty() && unbounded_fill(sh) => c, + _ => cached, + } + }), _ => l.surface().map(|s| s.content_bounds()).unwrap_or(Rect::EMPTY), }; (!r.is_empty()).then_some(r) } +/// Whether the shape's fill shows everywhere (an inverted path, or below 100 % density), so it has +/// no extent of its own. +fn unbounded_fill(sh: &photocraft_doc::ShapeLayer) -> bool { + sh.fill.is_some() && (sh.path.inverted || (sh.density.is_finite() && sh.density < 1.0)) +} + /// Largest area [`shape_pixel_bounds`] renders to find a shape's extent; beyond it the path and /// stroke bounds stand in. const SHAPE_BOUNDS_MAX_PX: u64 = 64 << 20; @@ -1043,8 +1056,7 @@ const SHAPE_BOUNDS_MAX_PX: u64 = 64 << 20; /// that shows everywhere (an inverted path, or below 100 % density) has no extent of its own and /// keeps the canvas-clipped measure (`None`). fn shape_pixel_bounds(sh: &photocraft_doc::ShapeLayer, canvas: Option) -> Option { - let unbounded_fill = sh.fill.is_some() && (sh.path.inverted || (sh.density.is_finite() && sh.density < 1.0)); - if unbounded_fill { + if unbounded_fill(sh) { return None; } let reach = photocraft_compose::shape_bounds(sh)?.inflate(1); @@ -2443,6 +2455,12 @@ mod tests { .unwrap(); let LayerContent::Shape(sh) = &doc(&s).layer(soft).unwrap().content else { panic!("shape") }; assert_eq!(shape_pixel_bounds(sh, Some(doc(&s).bounds())), None); + // Without a cached render yet, such a fill still measures as the canvas, not as empty. + let mut uncached = doc(&s).layer(soft).unwrap().clone(); + if let LayerContent::Shape(sh) = &mut uncached.content { + sh.cache = None; + } + assert_eq!(layer_bounds_in(&uncached, Some(doc(&s).bounds())), Some(doc(&s).bounds())); } #[test] From e923de0ff880a14196f22b9e84725e3750f83500 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 23 Oct 2026 09:41:23 +0200 Subject: [PATCH 25/86] CLI: run and batch skip Deselect with nothing selected, as playback does (#8802) Action playback treats Deselect with nothing selected as a silent no-op since #6744, as Photoshop does. photocraft-cli runs its --cmd list and batch steps one by one through command_run, so a Deselect after Reveal Selection (which drops the selection since #5251) stopped the whole run with "no selection" and wrote no output. run and batch now use the same check as playback (actions_cmds::noop_step, now public). run still prints a result line for the skipped step. A single Deselect outside a script stays disabled, as the #6744 test wants. Tests: deselect_noop_tests::run_skips_deselect_with_nothing_selected and batch_skips_deselect_with_nothing_selected (both fail on main). --- apps/photocraft-cli/src/lib.rs | 100 +++++++++++++++++++++++++++++- crates/engine/src/actions_cmds.rs | 3 +- 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index c2497e1bf1..e57e22cb8c 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -390,6 +390,11 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { return Err("run needs at least one --cmd (or --out)".into()); } for (id, params) in cmds { + // Deselect with nothing selected is a silent no-op in a script, as in action playback (#8802). + if photocraft_engine::actions_cmds::noop_step(&h.session, &id) { + print_json(out, &json!({"command": id, "result": null}), true)?; + continue; + } let r = h.command_run(&id, params).map_err(|e| format!("`{id}`: {e}"))?; if let Some(e) = photocraft_engine::actions_cmds::nested_failure(&id, &r) { return Err(format!("`{id}`: {e}")); @@ -573,7 +578,10 @@ fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { h.session.actions.list = set.clone(); h.open(input).map_err(|e| e.to_string())?; for (id, p) in &actions { - if photocraft_engine::actions_cmds::shell_view_command(id) || photocraft_engine::actions_cmds::shell_save_command(id) { + if photocraft_engine::actions_cmds::shell_view_command(id) + || photocraft_engine::actions_cmds::shell_save_command(id) + || photocraft_engine::actions_cmds::noop_step(&h.session, id) + { continue; } let r = h.command_run(id, p.clone()).map_err(|e| format!("`{id}`: {e}"))?; @@ -876,3 +884,93 @@ mod batch_action_set_tests { let _ = std::fs::remove_dir_all(&dir); } } + +#[cfg(test)] +mod deselect_noop_tests { + use super::*; + + fn invoke(args: &[&str]) -> (i32, String, String) { + let (mut output, mut errors) = (Vec::new(), Vec::new()); + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + let code = run(&args, &mut output, &mut errors); + (code, String::from_utf8(output).unwrap(), String::from_utf8(errors).unwrap()) + } + + /// #8802: Deselect with nothing selected is skipped in a `run`, as action playback does + /// (#6744), so a script that deselects after Reveal Selection still finishes and saves. + #[test] + fn run_skips_deselect_with_nothing_selected() { + let folder = std::env::temp_dir().join(format!("photocraft-cli-deselect-run-{}", std::process::id())); + std::fs::create_dir_all(&folder).unwrap(); + let native = folder.join("out.pcraft").to_string_lossy().to_string(); + let (code, stdout, stderr) = invoke(&[ + "run", + "--new", + r#"{"width":16,"height":16}"#, + "--cmd", + "select.rect", + "--params", + r#"{"x":0,"y":0,"width":8,"height":16}"#, + "--cmd", + "layer.new.layer", + "--cmd", + "layer.layerMask.revealSelection", + "--cmd", + "select.deselect", + "--cmd", + "layer.new.layer", + "--params", + r#"{"name":"after"}"#, + "--out", + &native, + ]); + assert_eq!(code, 0, "{stderr}"); + let results: Vec = stdout.lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(results.len(), 5, "every command reports a result: {stdout}"); + assert_eq!(results[3], json!({"command": "select.deselect", "result": null})); + let (code, stdout, stderr) = invoke(&["info", &native, "--compact"]); + assert_eq!(code, 0, "{stderr}"); + let info: Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(info["layers"][0]["name"], "after", "the steps after Deselect ran: {info}"); + + // Deselect with a selection still deselects: Inverse afterwards finds nothing to invert. + let (code, _, stderr) = invoke(&[ + "run", + "--new", + r#"{"width":16,"height":16}"#, + "--cmd", + "select.all", + "--cmd", + "select.deselect", + "--cmd", + "select.inverse", + "--out", + &native, + ]); + assert_eq!(code, 1, "{stderr}"); + assert!(stderr.contains("`select.inverse`") && stderr.contains("no selection"), "{stderr}"); + + let _ = std::fs::remove_dir_all(&folder); + } + + /// #8802: a batched action that starts with Deselect plays on files with nothing selected, + /// as the same action does in the Actions panel (#6744). + #[test] + fn batch_skips_deselect_with_nothing_selected() { + let dir = std::env::temp_dir().join(format!("photocraft-cli-deselect-batch-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let path = |name: &str| dir.join(name).to_string_lossy().to_string(); + std::fs::create_dir_all(dir.join("in")).unwrap(); + let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path("in/a.png")]); + assert_eq!(code, 0, "{stderr}"); + std::fs::write(dir.join("steps.json"), json!([["select.deselect", {}], ["image.imageRotation.90cw", {}]]).to_string()).unwrap(); + let (code, stdout, stderr) = invoke(&["batch", "--actions", &path("steps.json"), "--in", &path("in"), "--out", &path("out")]); + assert_eq!(code, 0, "{stdout}{stderr}"); + assert!(stdout.contains("1 succeeded, 0 failed"), "{stdout}"); + let (code, stdout, stderr) = invoke(&["info", &path("out/a.png"), "--compact"]); + assert_eq!(code, 0, "{stderr}"); + let info: Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "the step after Deselect ran: {info}"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs index 6f559a5659..8788455158 100644 --- a/crates/engine/src/actions_cmds.rs +++ b/crates/engine/src/actions_cmds.rs @@ -354,7 +354,8 @@ fn stop(s: &mut Session, _p: &Value) -> Result { /// A step Photoshop plays as a silent no-op although its menu item is greyed: Deselect with an /// open document and nothing selected (actions often start with it to clear any selection, #6744). -fn noop_step(s: &Session, id: &str) -> bool { +/// The CLI's `run` and `batch` scripts skip it too (#8802). +pub fn noop_step(s: &Session, id: &str) -> bool { id == "select.deselect" && s.active().is_some_and(|d| d.doc.selection.is_none()) } From 7c180529d60d6f12cfb441228645e1fe7ecd5d0d Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 23 Oct 2026 10:05:10 +0200 Subject: [PATCH 26/86] Layers: a new fill or adjustment layer drops the selection it took as mask (#8801) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new fill or adjustment layer made while something is selected takes the selection as its layer mask (#1250). In Photoshop the marching ants are then gone, as after Reveal Selection, which drops the selection since #5251. PhotoCraft kept it, so the next pixel edit on another layer (Duplicate, Desaturate, Levels…) only touched the area inside the old selection. mask_new_layer now clears the selection when it became the layer mask, in the same history step: one undo removes the layer and brings the selection back. A path used as vector mask leaves the selection alone, and so does the Gradient tool's fill layer. Tests: new_adjustment_and_fill_layers_are_masked_by_the_selection asserts the selection is gone and comes back on undo, and the new an_edit_after_a_new_fill_layer_on_a_selection_reaches_the_whole_layer covers the duplicate-and-desaturate case (both fail on main). Tests that deselected right after a new fill layer no longer need to; tests that need a selection after one select again. --- crates/engine/src/commands.rs | 19 +++++++----- crates/engine/src/layer_multi_cmds.rs | 4 ++- crates/engine/src/tests.rs | 29 +++++++++++++++++-- crates/engine/src/transform_cmds.rs | 1 - crates/engine/tests/it/layer_mask_depth.rs | 14 ++++----- .../tests/it/merge_transparent_bounds.rs | 1 - crates/ui-egui/src/shortcut_dispatch/tests.rs | 4 ++- crates/ui-egui/src/vector_ui.rs | 5 +++- 8 files changed, 55 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 2b65788fcc..75caa550d4 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -392,7 +392,7 @@ pub fn blend_from_str(s: &str) -> Option { /// The active selection as the mask of a layer being created, or None without a selection. /// Photoshop masks a new adjustment or fill layer with the selection, so the adjustment acts on -/// the selected area only (#1250); the selection itself stays, as Reveal Selection leaves it. +/// the selected area only (#1250). [`mask_new_layer`] then drops the selection (#8801). pub(crate) fn selection_mask(doc: &Document) -> Result> { doc.selection.as_ref().map(|sel| mask_at_depth(LayerMask { surface: sel.clone(), ..LayerMask::reveal_all() }, doc.depth)).transpose() } @@ -417,9 +417,10 @@ pub(crate) fn layer_panel_option(s: &Session, key: &str, fallback: bool) -> bool /// Mask a fill or adjustment layer being created, as Photoshop does: the path selected in the /// Paths panel (`"path"`: `"work"`, a saved path's name or a path object) becomes its vector /// mask, which makes a Solid Color fill a shape (#1419); without one, the selection becomes its -/// layer mask ([`selection_mask`]); without either, a white mask reveals the whole layer (#1768). -/// A path that isn't there is an error. -pub(crate) fn mask_new_layer(doc: &Document, l: &mut Layer, p: &Value, cmd: &str, default_mask: bool) -> Result<()> { +/// layer mask ([`selection_mask`]) and is dropped, as Reveal Selection drops it (#5251, #8801); +/// without either, a white mask reveals the whole layer (#1768). A path that isn't there is an +/// error. +pub(crate) fn mask_new_layer(doc: &mut Document, l: &mut Layer, p: &Value, cmd: &str, default_mask: bool) -> Result<()> { let path = match p.get("path") { None | Some(Value::Null) => None, Some(Value::String(n)) if crate::vector_cmds::is_work(Some(n)) => Some(doc.work_path.clone().ok_or_else(|| bad(cmd, "no work path"))?), @@ -430,9 +431,13 @@ pub(crate) fn mask_new_layer(doc: &Document, l: &mut Layer, p: &Value, cmd: &str }; match path { Some(path) => l.vector_mask = Some(photocraft_doc::VectorMask::new(path)), - None => { - l.mask = selection_mask(doc)?.or_else(|| default_mask.then(LayerMask::reveal_all)).map(|mask| mask_at_depth(mask, doc.depth)).transpose()?; - } + None => match selection_mask(doc)? { + Some(mask) => { + l.mask = Some(mask); + doc.selection = None; + } + None => l.mask = default_mask.then(LayerMask::reveal_all).map(|mask| mask_at_depth(mask, doc.depth)).transpose()?, + }, } Ok(()) } diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 83d4e815a2..eba0638fa8 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -3355,16 +3355,18 @@ mod layers_panel_parity_tests { assert!(mask.is_none() && !vector, "{cmd}: no selection, no path: no mask at all"); } - s.execute("select.rect", json!({"x": 4, "y": 4, "width": 8, "height": 6})).unwrap(); for (cmd, params) in KINDS { + s.execute("select.rect", json!({"x": 4, "y": 4, "width": 8, "height": 6})).unwrap(); let (mask, vector) = create(&mut s, cmd, params, None); let mask = mask.unwrap_or_else(|| panic!("{cmd}: the selection still becomes the layer mask")); assert!(!vector, "{cmd}"); assert!(mask.surface.pixel(6, 6)[0] > 0.99, "{cmd}: inside the selection reveals"); assert!(mask.surface.pixel(20, 12)[0] < 0.01, "{cmd}: outside the selection hides"); + assert!(s.active().unwrap().doc.selection.is_none(), "{cmd}: the selection went into the mask (#8801)"); } // An explicit path wins over the selection, as with the option on. + s.execute("select.rect", json!({"x": 4, "y": 4, "width": 8, "height": 6})).unwrap(); for selected in [true, false] { if !selected { s.execute("select.deselect", json!({})).unwrap(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0b1b48aa3b..279f296eaa 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -265,8 +265,9 @@ fn new_adjustment_and_fill_layers_are_masked_by_the_selection() { assert_eq!(mask.value(10, 10), 1.0, "{id} at {depth}-bit: the mask hides the selected area"); assert_eq!(mask.value(2, 2), 0.0, "{id} at {depth}-bit: the mask reveals outside the selection"); assert!(mask.linked && mask.enabled, "{id}"); - // The selection stays, as after Layer › Layer Mask › Reveal Selection. - assert!(d.doc.selection.is_some(), "{id} dropped the selection"); + // The selection went into the mask, so Photoshop drops it, as Layer › Layer Mask › + // Reveal Selection does (#5251, #8801). + assert!(d.doc.selection.is_none(), "{id} at {depth}-bit kept the selection"); // The white canvas is untouched outside the selection. assert_eq!(px(&mut s, 2, 2), vec![1.0, 1.0, 1.0, 1.0], "{id} at {depth}-bit changed pixels outside the selection"); if blackens { @@ -274,6 +275,29 @@ fn new_adjustment_and_fill_layers_are_masked_by_the_selection() { } s.execute("edit.undo", json!({})).unwrap(); assert!(s.active().unwrap().doc.layer(layer).is_none(), "{id}: undo keeps the layer"); + let sel = s.active().unwrap().doc.selection.as_ref().unwrap_or_else(|| panic!("{id}: undo does not bring the selection back")); + assert_eq!(sel.content_bounds(), photocraft_geom::Rect::new(8, 8, 24, 24), "{id}: undo restores the same selection"); + } + } +} + +#[test] +fn an_edit_after_a_new_fill_layer_on_a_selection_reaches_the_whole_layer() { + // #8801: the selection that masked a new fill or adjustment layer is gone, so the next + // pixel edit on another layer is not limited to it. + for (id, params) in [("layer.newFillLayer.solidColor", json!({"color": "#000000"})), ("layer.newAdjustmentLayer.invert", json!({}))] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 20})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#3366cc"})).unwrap(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap(); + s.execute(id, params).unwrap(); + s.execute("layer.setProps", json!({"visible": false})).unwrap(); + s.execute("layer.selectBottom", json!({})).unwrap(); + s.execute("layer.duplicate", json!({})).unwrap(); + s.execute("image.adjustments.desaturate", json!({})).unwrap(); + for x in [5, 35] { + let p = px(&mut s, x, 10); + assert!((p[0] - p[1]).abs() < 1e-3 && (p[1] - p[2]).abs() < 1e-3, "{id}: ({x}, 10) is not grey: {p:?}"); } } } @@ -351,6 +375,7 @@ fn new_fill_and_adjustment_layers_take_the_active_path_as_their_vector_mask() { let want = if path_name == "work" { d.doc.work_path.clone().unwrap() } else { d.doc.paths[0].path.clone() }; assert_eq!(vm.path, want, "{id}: the vector mask is the path"); assert!(l.mask.is_none(), "{id}: the path wins over the selection"); + assert!(d.doc.selection.is_some(), "{id}: an unused selection stays"); for (x, y) in [(2, 2), (42, 32), (30, 30)] { assert_eq!(px(&mut s, x, y), vec![1.0, 1.0, 1.0, 1.0], "{id} ({path_name}) at {depth}-bit changed pixels outside the path at ({x}, {y})"); } diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index fce8c1be49..d3c7d99ad3 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -1215,7 +1215,6 @@ mod tests { let mut s = session(); s.execute("select.rect", json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap(); s.execute("layer.newFillLayer.gradient", json!({"from": "#ff0000", "to": "#0000ff", "angle": 0})).unwrap(); - s.execute("select.deselect", json!({})).unwrap(); s.edit("cache", |doc, active| { let (fmt, canvas) = (doc.pixel_format(), doc.bounds()); let l = doc.layer_mut(active.unwrap()).unwrap(); diff --git a/crates/engine/tests/it/layer_mask_depth.rs b/crates/engine/tests/it/layer_mask_depth.rs index 7093164a5f..cbe4138532 100644 --- a/crates/engine/tests/it/layer_mask_depth.rs +++ b/crates/engine/tests/it/layer_mask_depth.rs @@ -45,12 +45,10 @@ fn newly_created_layer_masks_follow_document_depth() { assert_eq!(m.value(20, 20), white); if selected { assert_eq!(m.value(0, 0), 0.0); - if command == "layer.layerMask.revealSelection" { - // The selection became the mask: Photoshop drops it, as Hide Selection does. - assert!(d.doc.selection.is_none(), "{command}: the selection is dropped"); - } else { - assert_eq!(d.doc.selection, before.selection, "the selection is unchanged"); - } + // The selection became the mask: Photoshop drops it, as Hide Selection does + // (#5251; new fill and adjustment layers too, #8801). + assert!(d.doc.selection.is_none(), "{command}: the selection is dropped"); + assert!(before.selection.is_some()); } else { assert_eq!(m.surface.tile_count(), 0, "uniform masks stay sparse"); assert_eq!(m.value(-300_000, 300_000), white); @@ -72,7 +70,6 @@ fn soft_mask_edits_match_after_native_and_psd_reopen() { let mut s = session(depth, "rgb"); s.execute("select.rect", json!({"x": 8, "y": 6, "width": 40, "height": 32, "antiAlias": false})).unwrap(); s.execute("layer.newFillLayer.solidColor", json!({"color": "#000000"})).unwrap(); - s.execute("select.deselect", json!({})).unwrap(); let before = s.active().unwrap().doc.clone(); let native = photocraft_format::save_to_bytes(&before, &Default::default()).unwrap(); let psd = photocraft_io::export(&before, "psd", &Default::default()).unwrap(); @@ -138,7 +135,8 @@ fn mask_creation_preserves_selection_coverage_and_existing_masks() { before.selection.as_ref().unwrap().read_region(before.bounds()), "{depth}-bit coverage is unchanged" ); - let kept = if command == "layer.layerMask.revealSelection" { None } else { before.selection.clone() }; + // The selection went into the mask, except for the Gradient tool's fill layer. + let kept = if command == "gradient.fill.create" { before.selection.clone() } else { None }; assert_eq!(after.selection, kept, "{command}"); assert_eq!(after.layers[0].mask, before.layers[0].mask, "creating a mask does not requantize an existing mask"); } diff --git a/crates/engine/tests/it/merge_transparent_bounds.rs b/crates/engine/tests/it/merge_transparent_bounds.rs index c796007233..03e9473fb2 100644 --- a/crates/engine/tests/it/merge_transparent_bounds.rs +++ b/crates/engine/tests/it/merge_transparent_bounds.rs @@ -101,7 +101,6 @@ fn merging_a_masked_fill_layer_keeps_content_bounds_in_every_mode() { let (mut s, a, _) = scene(mode, depth); s.execute("select.rect", json!({"x": B[0], "y": B[1], "width": B[2], "height": B[3]})).unwrap(); let fill = id_of(&s.execute("layer.newFillLayer.solidColor", json!({"color": "#3060c0"})).unwrap()); - s.execute("select.deselect", json!({})).unwrap(); (s, a, fill) }; for &(mode, depth) in CASES { diff --git a/crates/ui-egui/src/shortcut_dispatch/tests.rs b/crates/ui-egui/src/shortcut_dispatch/tests.rs index 9bb0e5df24..d537e743ee 100644 --- a/crates/ui-egui/src/shortcut_dispatch/tests.rs +++ b/crates/ui-egui/src/shortcut_dispatch/tests.rs @@ -600,10 +600,12 @@ fn delete_with_several_layers_selected_deletes_them_even_with_the_mask_targeted( let first = photocraft_doc::LayerId(s.execute("layer.newAdjustmentLayer.curves", json!({})).unwrap()["layer"].as_u64().unwrap()); let second = photocraft_doc::LayerId(s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap()["layer"].as_u64().unwrap()); s.execute("layer.select", json!({"layer": first.0, "mode": "add"})).unwrap(); + // The new layers took the harness's selection as their masks (#8801): select again. + s.execute("select.all", json!({})).unwrap(); let st = s.active().unwrap(); assert_eq!(st.selected_layers().len(), 2); assert!(st.active_layer.and_then(|l| st.doc.layer(l)).is_some_and(|l| l.mask.is_some()), "the active adjustment layer has a mask"); - assert!(st.doc.selection.is_some(), "the harness keeps a selection"); + assert!(st.doc.selection.is_some(), "a selection is active"); h.state_mut().ui.mask_target = true; h.run_steps(2); let n = names(&h).len(); diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 56036a8520..6b3dab62cb 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -3253,11 +3253,14 @@ mod tests { // Deselected (or a stale name): the selection is the layer mask, no vector mask. for sel in [None, Some("Gone".to_string())] { app.ui.selected_path = sel; + // The selection goes into the new layer's mask, so each fill layer needs its own (#8801). + app.run("select.rect", json!({"x": 150, "y": 150, "width": 20, "height": 20})).unwrap(); let r = app.run("layer.newFillLayer.solidColor", json!({})); let l = layer(&app, r); - assert!(l.vector_mask.is_none() && l.mask.is_some()); + assert!(l.vector_mask.is_none() && l.mask.as_ref().is_some_and(|m| m.value(160, 160) == 1.0 && m.value(5, 5) == 0.0)); } // Make Work Path selects the new work path. + app.run("select.rect", json!({"x": 150, "y": 150, "width": 20, "height": 20})).unwrap(); app.run("select.toWorkPath", json!({"tolerance": 2.0})).unwrap(); assert_eq!(app.ui.selected_path.as_deref(), Some("work")); } From fa8c55da56790bd38670fcdf0f15928ea53636e9 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 23 Oct 2026 10:06:32 +0200 Subject: [PATCH 27/86] CLI: document run and batch, and the test helper --- apps/photocraft-cli/src/lib.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index e57e22cb8c..5eae884338 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -365,6 +365,8 @@ fn command_list(a: &Args) -> Result, String> { Ok(cmds) } +/// `run`: open a file (or make a new one), run each `--cmd` in order, print each result as a +/// JSON line and save to `--out`. Deselect with nothing selected is skipped (#8802). 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(); @@ -539,6 +541,8 @@ fn strip_shell_steps(action: &mut Action) { (action.steps, action.disabled, action.dialogs) = (steps, disabled, dialogs); } +/// `batch`: play the steps of an actions file on every image in `--in` and save to `--out`. +/// Deselect with nothing selected is skipped, as in playback (#8802). fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { let actions_path = a.get("--actions").ok_or("batch needs --actions ")?; let in_dir = PathBuf::from(a.get("--in").ok_or("batch needs --in ")?); @@ -889,6 +893,7 @@ mod batch_action_set_tests { mod deselect_noop_tests { use super::*; + /// Run the CLI with `args`: (exit code, stdout, stderr). fn invoke(args: &[&str]) -> (i32, String, String) { let (mut output, mut errors) = (Vec::new(), Vec::new()); let args: Vec = args.iter().map(|s| s.to_string()).collect(); From e22984536c2cc6df786bc71e68828145528a8ea8 Mon Sep 17 00:00:00 2001 From: xwize Date: Fri, 23 Oct 2026 09:43:30 +0100 Subject: [PATCH 28/86] Indexed Color: keep Brush strokes inside the colour table An Indexed Color document keeps its pixels expanded to RGB with the palette as side data, and the Brush rendered straight onto that surface. A red stroke on a black/white document therefore left opaque red pixels, and soft edges or partial opacity left blends, all outside the colour table, while the document still reported Indexed mode. Photoshop (and GIMP, which documents it) paint the nearest table colour instead. The stroke commit now maps the damaged rectangle to the nearest table entry with the same no-dither quantiser the Indexed Color conversion uses. That keeps the table's transparent entry binary as well. The live preview applies the same step, so the canvas shows the pixels the commit keeps. This covers paint.stroke and paint.pencil, including erasing. Masks and channels are gray targets and are left alone. Fixes #8669 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/brush_cmds.rs | 14 ++++++- crates/engine/src/mode_cmds.rs | 28 ++++++++++++- crates/engine/src/mode_cmds/tests.rs | 61 ++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index feb238cf40..18024bbd3d 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -293,6 +293,7 @@ fn stroke_with(s: &mut Session, cmd: &str, p: &Value, label: &str, brush: BrushS return Ok(l.bounds); } let sel = doc.selection.clone(); + let table = crate::mode_cmds::paint_color_table(doc, p); let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?; let mut brush = brush; erase_locked(&mut brush, lock, bg); @@ -323,6 +324,9 @@ fn stroke_with(s: &mut Session, cmd: &str, p: &Value, label: &str, brush: BrushS } else { render_stroke(surf, &brush, &pts, sel.as_ref(), lock, zoom) }; + if let Some(table) = &table { + crate::mode_cmds::keep_in_color_table(table, surf, damage); + } Ok(damage) }) })?; @@ -583,7 +587,7 @@ impl LiveStroke { check_coords(pts, &self.cmd)?; self.points.extend_from_slice(pts); let cmyk = self.cmyk.clone(); - photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || { + let dmg = photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || -> Result { self.renderer.push(pts); if let Some(symmetry) = &self.symmetry { for ((mirror, distinct), reflected) in self.mirrors.iter_mut().zip(&mut self.mirror_distinct).zip(symmetry.reflected_passes(pts)) { @@ -629,7 +633,13 @@ impl LiveStroke { dmg = dmg.union(&self.tail); } Ok(dmg) - }) + })?; + // The preview shows the pixels the commit keeps (see `stroke_with`). + if let Some(table) = crate::mode_cmds::paint_color_table(&self.doc, &self.params) { + let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.layer, &self.params)?; + crate::mode_cmds::keep_in_color_table(&table, surf, dmg); + } + Ok(dmg) } } diff --git a/crates/engine/src/mode_cmds.rs b/crates/engine/src/mode_cmds.rs index 180f940e91..87954e437b 100644 --- a/crates/engine/src/mode_cmds.rs +++ b/crates/engine/src/mode_cmds.rs @@ -12,7 +12,7 @@ use photocraft_algo::transform::{Homography, Interp}; use photocraft_color::{ColorMode, PixelFormat, SampleType}; use photocraft_doc::adjust::CurvePoint; use photocraft_doc::{ColorTable, Document, Duotone, DuotoneInk, Layer, LayerContent, LayerId, Size}; -use photocraft_geom::Affine; +use photocraft_geom::{Affine, Rect}; use photocraft_raster::Surface; use serde_json::{Value, json}; @@ -391,6 +391,32 @@ fn color_table(s: &mut Session, p: &Value) -> Result { Ok(out) } +/// The colour table painting on `p`'s target must stay inside: an Indexed Color document's, +/// when the target is layer pixels (masks and channels are gray, not indexed). +pub(crate) fn paint_color_table(doc: &Document, p: &Value) -> Option { + let pixels = crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::Pixels; + doc.color_table.clone().filter(|_| pixels && doc.mode == ColorMode::Indexed) +} + +/// Map the pixels of `r` to their nearest colour-table entry, as the Indexed Color conversion +/// does with no dither, so painting never leaves the table (#8669). +pub(crate) fn keep_in_color_table(table: &ColorTable, surf: &mut Surface, r: Rect) { + let r = r.intersect(&surf.tile_bounds()); + if r.is_empty() || table.colors.is_empty() { + return; + } + let fmt = surf.format(); + let mut data = surf.read_region(r); + let mut px: Vec<[f32; 4]> = data.chunks_exact(fmt.channels()).map(|q| photocraft_raster::to_rgba(&fmt, q)).collect(); + quantize::quantize(&mut px, r.width() as usize, &table.colors, Dither::None, 0.0, table.transparent.map(usize::from)); + let mut enc = [0.0f32; 8]; + for (q, c) in data.chunks_exact_mut(fmt.channels()).zip(px) { + photocraft_raster::from_rgba_into(&fmt, c, &mut enc); + q.iter_mut().zip(enc).for_each(|(d, v)| *d = v); + } + surf.write_region(r, &data); +} + // ---------- Bitmap ---------- /// Bitmap's Output resolution range (ppi): what a new document may have. diff --git a/crates/engine/src/mode_cmds/tests.rs b/crates/engine/src/mode_cmds/tests.rs index 84d816a349..a9ec8cca72 100644 --- a/crates/engine/src/mode_cmds/tests.rs +++ b/crates/engine/src/mode_cmds/tests.rs @@ -696,3 +696,64 @@ fn color_table_rejects_an_invalid_transparent_index_without_editing() { s.execute("image.mode.colorTable", json!({"transparent": 0})).unwrap(); assert_eq!(doc(&s).color_table.as_ref().unwrap().transparent, Some(0)); } + +/// A white 16×16 document in Indexed Color with the two-entry black/white table (#8669's repro). +fn black_white_indexed(transparency: bool) -> Session { + let mut s = Session::new(); + let background = if transparency { "transparent" } else { "white" }; + s.execute("file.new", json!({"width": 16, "height": 16, "background": background})).unwrap(); + paint(&mut s, |x, _| { + if x < 2 { + [0.0; 4] + } else if x < 4 { + [0.0, 0.0, 0.0, 1.0] + } else { + [1.0; 4] + } + }); + s.execute("image.mode.indexedColor", json!({"palette": "exact", "forced": "none", "transparency": transparency, "dither": "none"})).unwrap(); + s +} + +#[test] +fn brush_stroke_stays_in_indexed_color_table() { + let mut s = black_white_indexed(false); + assert_eq!(doc(&s).color_table.as_ref().unwrap().colors, vec![[0, 0, 0], [255, 255, 255]]); + s.execute("paint.stroke", json!({"points": [[8, 8, 1]], "size": 8, "color": "#ff0000", "opacity": 1, "flow": 1})).unwrap(); + // Red's nearest entry is black. + assert_eq!(doc(&s).layers[0].surface().unwrap().rgba(8, 8), [0.0, 0.0, 0.0, 1.0]); + // Soft edges and partial opacity blend, and the blends map back onto the table too. + s.execute("paint.stroke", json!({"points": [[4, 12], [14, 12]], "size": 6, "hardness": 0.0, "color": "#808080", "opacity": 0.5})).unwrap(); + let table = doc(&s).color_table.clone().unwrap(); + assert!(colours(doc(&s)).iter().all(|c| table.colors.contains(c)), "off-table pixels: {:?}", colours(doc(&s))); + assert_eq!(doc(&s).mode, ColorMode::Indexed); +} + +#[test] +fn brush_stroke_keeps_indexed_transparency_binary() { + let mut s = black_white_indexed(true); + let table = doc(&s).color_table.clone().unwrap(); + assert!(table.transparent.is_some()); + s.execute("paint.stroke", json!({"points": [[0, 8], [15, 8]], "size": 8, "hardness": 0.0, "color": "#ff0000", "opacity": 0.7})).unwrap(); + let d = doc(&s); + let surf = d.layers[0].surface().unwrap(); + for (x, y) in (0..16).flat_map(|y| (0..16).map(move |x| (x, y))) { + let a = surf.rgba(x, y)[3]; + assert!(a == 0.0 || a == 1.0, "alpha {a} at ({x},{y})"); + } + assert!(colours(d).iter().all(|c| table.colors.contains(c)), "off-table pixels: {:?}", colours(d)); +} + +#[test] +fn live_brush_preview_matches_indexed_commit() { + let mut s = black_white_indexed(false); + let params = json!({"points": [[4, 8], [12, 8]], "size": 7, "hardness": 0.0, "color": "#ff0000", "opacity": 0.6, "smoothing": 0.0, "seed": 1}); + let preview = crate::brush_cmds::LiveStroke::begin(&s, ¶ms).unwrap(); + let table = doc(&s).color_table.clone().unwrap(); + assert!(colours(&preview.doc).iter().all(|c| table.colors.contains(c)), "preview off the table: {:?}", colours(&preview.doc)); + s.execute("paint.stroke", params).unwrap(); + let (live, committed) = (preview.doc.layers[0].surface().unwrap(), doc(&s).layers[0].surface().unwrap()); + for (x, y) in (0..16).flat_map(|y| (0..16).map(move |x| (x, y))) { + assert_eq!(live.rgba(x, y), committed.rgba(x, y), "preview/commit at ({x},{y})"); + } +} From 33e8eedde878b007aecac7446d4c2fba1ed3cf85 Mon Sep 17 00:00:00 2001 From: xwize Date: Fri, 23 Oct 2026 10:18:39 +0100 Subject: [PATCH 29/86] Contributors: credit xwize as James Jones Co-Authored-By: Claude Opus 5.5 --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 30077a230f..e78d134864 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -251,3 +251,6 @@ sync_github_name = true [people.benandez] real_name = "Martin Ball" + +[people.xwize] +real_name = "James Jones" From d73604860ed1cd00db4c26becfbfd806dfdc9af3 Mon Sep 17 00:00:00 2001 From: xwize Date: Fri, 23 Oct 2026 10:33:48 +0100 Subject: [PATCH 30/86] Indexed Color: remap only the pixels a stroke changed, a tile at a time Review follow-up. The palette step quantised the whole stroke damage rectangle as one dense buffer. A long diagonal stroke on a large canvas has a rectangle near the size of the document, so this allocated about 28 bytes per pixel of it. It also remapped pixels inside that rectangle the brush never touched, which can already be off the table (Paint Bucket doesn't snap yet). keep_in_color_table now walks the rectangle tile by tile and maps only the pixels that differ from the surface before the stroke (a cheap clone: tiles are shared until written). The live preview compares against its own pre-stroke surface the same way. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/brush_cmds.rs | 7 +++-- crates/engine/src/mode_cmds.rs | 44 ++++++++++++++++++++-------- crates/engine/src/mode_cmds/tests.rs | 18 ++++++++++++ 3 files changed, 54 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index 18024bbd3d..102dc7de04 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -300,6 +300,7 @@ fn stroke_with(s: &mut Session, cmd: &str, p: &Value, label: &str, brush: BrushS if auto_erase { apply_auto_erase(&mut brush, surf, pts.first(), fg, bg); } + let unpainted = table.as_ref().map(|_| surf.clone()); let damage = if let Some(axis) = &symmetry { let reflected = axis.reflected_passes(&pts); let copies: Vec<_> = reflected @@ -324,8 +325,8 @@ fn stroke_with(s: &mut Session, cmd: &str, p: &Value, label: &str, brush: BrushS } else { render_stroke(surf, &brush, &pts, sel.as_ref(), lock, zoom) }; - if let Some(table) = &table { - crate::mode_cmds::keep_in_color_table(table, surf, damage); + if let (Some(table), Some(pre)) = (&table, &unpainted) { + crate::mode_cmds::keep_in_color_table(table, pre, surf, damage); } Ok(damage) }) @@ -637,7 +638,7 @@ impl LiveStroke { // The preview shows the pixels the commit keeps (see `stroke_with`). if let Some(table) = crate::mode_cmds::paint_color_table(&self.doc, &self.params) { let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.layer, &self.params)?; - crate::mode_cmds::keep_in_color_table(&table, surf, dmg); + crate::mode_cmds::keep_in_color_table(&table, &self.pre, surf, dmg); } Ok(dmg) } diff --git a/crates/engine/src/mode_cmds.rs b/crates/engine/src/mode_cmds.rs index 87954e437b..77738bfc67 100644 --- a/crates/engine/src/mode_cmds.rs +++ b/crates/engine/src/mode_cmds.rs @@ -398,23 +398,43 @@ pub(crate) fn paint_color_table(doc: &Document, p: &Value) -> Option doc.color_table.clone().filter(|_| pixels && doc.mode == ColorMode::Indexed) } -/// Map the pixels of `r` to their nearest colour-table entry, as the Indexed Color conversion -/// does with no dither, so painting never leaves the table (#8669). -pub(crate) fn keep_in_color_table(table: &ColorTable, surf: &mut Surface, r: Rect) { - let r = r.intersect(&surf.tile_bounds()); - if r.is_empty() || table.colors.is_empty() { +/// Map the pixels of `r` that the stroke changed (they differ from `pre`) to their nearest +/// colour-table entry, as the Indexed Color conversion does with no dither, so painting never +/// leaves the table (#8669). One tile at a time: a long stroke's bounds can span the canvas. +pub(crate) fn keep_in_color_table(table: &ColorTable, pre: &Surface, surf: &mut Surface, r: Rect) { + if table.colors.is_empty() { return; } let fmt = surf.format(); - let mut data = surf.read_region(r); - let mut px: Vec<[f32; 4]> = data.chunks_exact(fmt.channels()).map(|q| photocraft_raster::to_rgba(&fmt, q)).collect(); - quantize::quantize(&mut px, r.width() as usize, &table.colors, Dither::None, 0.0, table.transparent.map(usize::from)); + let n = fmt.channels(); let mut enc = [0.0f32; 8]; - for (q, c) in data.chunks_exact_mut(fmt.channels()).zip(px) { - photocraft_raster::from_rgba_into(&fmt, c, &mut enc); - q.iter_mut().zip(enc).for_each(|(d, v)| *d = v); + for tc in r.tiles() { + let t = tc.rect().intersect(&r); + if t.is_empty() || !surf.has_tiles_in(t) { + continue; + } + let (before, mut data) = (pre.read_region(t), surf.read_region(t)); + let (mut changed, mut px) = (Vec::new(), Vec::new()); + for (i, (q, b)) in data.chunks_exact(n).zip(before.chunks_exact(n)).enumerate() { + if q != b { + changed.push(i); + px.push(photocraft_raster::to_rgba(&fmt, q)); + } + } + if px.is_empty() { + continue; + } + let w = px.len(); + quantize::quantize(&mut px, w, &table.colors, Dither::None, 0.0, table.transparent.map(usize::from)); + let mut mapped = changed.into_iter().zip(px).peekable(); + for (i, q) in data.chunks_exact_mut(n).enumerate() { + if let Some((_, c)) = mapped.next_if(|(j, _)| *j == i) { + photocraft_raster::from_rgba_into(&fmt, c, &mut enc); + q.iter_mut().zip(enc).for_each(|(d, v)| *d = v); + } + } + surf.write_region(t, &data); } - surf.write_region(r, &data); } // ---------- Bitmap ---------- diff --git a/crates/engine/src/mode_cmds/tests.rs b/crates/engine/src/mode_cmds/tests.rs index a9ec8cca72..cf6ae1f793 100644 --- a/crates/engine/src/mode_cmds/tests.rs +++ b/crates/engine/src/mode_cmds/tests.rs @@ -757,3 +757,21 @@ fn live_brush_preview_matches_indexed_commit() { assert_eq!(live.rgba(x, y), committed.rgba(x, y), "preview/commit at ({x},{y})"); } } + +#[test] +fn indexed_brush_stroke_leaves_pixels_it_did_not_paint() { + let mut s = black_white_indexed(false); + // An off-table pixel inside the stroke's bounds but away from its dabs (Paint Bucket can + // still leave one) is not the stroke's to remap. + s.edit("setup", |doc, active| { + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + let px = photocraft_raster::from_rgba(&surf.format(), [1.0, 0.0, 0.0, 1.0]); + surf.write_region(Rect::new(13, 2, 14, 3), &px); + Ok(()) + }) + .unwrap(); + s.execute("paint.stroke", json!({"points": [[2, 2], [14, 14]], "size": 2, "hardness": 1.0, "color": "#ff0000", "smoothing": 0.0})).unwrap(); + let surf = doc(&s).layers[0].surface().unwrap(); + assert_eq!(surf.rgba(13, 2), [1.0, 0.0, 0.0, 1.0]); + assert_eq!(surf.rgba(8, 8), [0.0, 0.0, 0.0, 1.0]); +} From 68ca48ddebf62317ffa64d4e423a0fbaa66d4965 Mon Sep 17 00:00:00 2001 From: xwize Date: Fri, 23 Oct 2026 10:46:57 +0100 Subject: [PATCH 31/86] Indexed Color: Pencil Auto Erase compares against the painted colour Review follow-up. In an Indexed document an off-table foreground paints as its nearest table entry. Auto Erase still compared the stroke's start pixel with the raw foreground, so a second stroke that started on what the first one painted never switched to the background colour. Both the commit and the live preview now compare against the foreground as the table paints it. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/brush_cmds.rs | 4 ++++ crates/engine/src/mode_cmds.rs | 10 ++++++++++ crates/engine/src/mode_cmds/tests.rs | 13 +++++++++++++ 3 files changed, 27 insertions(+) diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index 102dc7de04..ea097f7350 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -298,6 +298,8 @@ fn stroke_with(s: &mut Session, cmd: &str, p: &Value, label: &str, brush: BrushS let mut brush = brush; erase_locked(&mut brush, lock, bg); if auto_erase { + // A stroke in the table's colour starts on what the foreground painted as. + let fg = table.as_ref().map_or(fg, |t| crate::mode_cmds::table_color(t, fg)); apply_auto_erase(&mut brush, surf, pts.first(), fg, bg); } let unpainted = table.as_ref().map(|_| surf.clone()); @@ -521,9 +523,11 @@ impl LiveStroke { let base = (st.doc.id, st.revision); let mut doc = (*st.doc).clone(); let sel = doc.selection.clone(); + let table = crate::mode_cmds::paint_color_table(&doc, p); let (surf, lock) = crate::channel_cmds::target_surface(&mut doc, layer, p)?; erase_locked(&mut brush, lock, s.tools.background); if pencil && flag(p, "autoErase", false) { + let fg = table.as_ref().map_or(fg, |t| crate::mode_cmds::table_color(t, fg)); apply_auto_erase(&mut brush, surf, pts.first(), fg, s.tools.background); } let renderer = StrokeRenderer::new(&brush, Some(surf.format()), zoom); diff --git a/crates/engine/src/mode_cmds.rs b/crates/engine/src/mode_cmds.rs index 77738bfc67..2da2e67eca 100644 --- a/crates/engine/src/mode_cmds.rs +++ b/crates/engine/src/mode_cmds.rs @@ -398,6 +398,16 @@ pub(crate) fn paint_color_table(doc: &Document, p: &Value) -> Option doc.color_table.clone().filter(|_| pixels && doc.mode == ColorMode::Indexed) } +/// The colour `c` paints as in a document with `table` (see [`keep_in_color_table`]). +pub(crate) fn table_color(table: &ColorTable, c: [f32; 4]) -> [f32; 4] { + let mut px = [c]; + if !table.colors.is_empty() { + quantize::quantize(&mut px, 1, &table.colors, Dither::None, 0.0, table.transparent.map(usize::from)); + } + let [c] = px; + c +} + /// Map the pixels of `r` that the stroke changed (they differ from `pre`) to their nearest /// colour-table entry, as the Indexed Color conversion does with no dither, so painting never /// leaves the table (#8669). One tile at a time: a long stroke's bounds can span the canvas. diff --git a/crates/engine/src/mode_cmds/tests.rs b/crates/engine/src/mode_cmds/tests.rs index cf6ae1f793..df672c6a3e 100644 --- a/crates/engine/src/mode_cmds/tests.rs +++ b/crates/engine/src/mode_cmds/tests.rs @@ -775,3 +775,16 @@ fn indexed_brush_stroke_leaves_pixels_it_did_not_paint() { assert_eq!(surf.rgba(13, 2), [1.0, 0.0, 0.0, 1.0]); assert_eq!(surf.rgba(8, 8), [0.0, 0.0, 0.0, 1.0]); } + +#[test] +fn pencil_auto_erase_knows_the_indexed_foreground() { + let mut s = black_white_indexed(false); + let pencil = json!({"points": [[8, 8]], "size": 3, "color": "#ff0000", "autoErase": true}); + s.execute("paint.pencil", pencil.clone()).unwrap(); + assert_eq!(doc(&s).layers[0].surface().unwrap().rgba(8, 8), [0.0, 0.0, 0.0, 1.0]); + // Red painted as black, so a stroke starting there auto-erases to the (white) background. + let preview = crate::brush_cmds::LiveStroke::begin_with(&s, "paint.pencil", &pencil).unwrap(); + assert_eq!(preview.doc.layers[0].surface().unwrap().rgba(8, 8), [1.0, 1.0, 1.0, 1.0]); + s.execute("paint.pencil", pencil).unwrap(); + assert_eq!(doc(&s).layers[0].surface().unwrap().rgba(8, 8), [1.0, 1.0, 1.0, 1.0]); +} From ba22b57e83f582668cfd1b4be66a69e7bc7d5414 Mon Sep 17 00:00:00 2001 From: xwize Date: Fri, 23 Oct 2026 10:50:41 +0100 Subject: [PATCH 32/86] Contributors: drop the people.toml entry from this PR It isn't part of the Indexed Color fix; the credit can come separately. Co-Authored-By: Claude Opus 5.5 --- contributors/people.toml | 3 --- 1 file changed, 3 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index e78d134864..30077a230f 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -251,6 +251,3 @@ sync_github_name = true [people.benandez] real_name = "Martin Ball" - -[people.xwize] -real_name = "James Jones" From ed868a65d468c3c90640232a1f672a11f9321e96 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 11:59:54 +0200 Subject: [PATCH 33/86] Export As: fit a remembered size to the layer, and keep a 1 px derived side Layer > Export As now fits the remembered size against the layer's trimmed size directly, instead of fitting the document first and refitting the layer from the percent the document fell back to. A linked side whose other side rounds below one pixel keeps it at 1 px, as the Scale field already does. --- crates/ui-egui/src/export_dialog.rs | 69 ++++++++++++++++++++++------- 1 file changed, 54 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/export_dialog.rs b/crates/ui-egui/src/export_dialog.rs index 8b780e995c..2589dbd28a 100644 --- a/crates/ui-egui/src/export_dialog.rs +++ b/crates/ui-egui/src/export_dialog.rs @@ -90,7 +90,14 @@ fn remember(app: &mut PhotocraftApp, f: &Map) { } pub fn open(app: &mut PhotocraftApp) -> Result { - let st = app.session.active().ok_or("no document")?; + let size = app.session.active().ok_or("no document")?.doc.size; + open_sized(app, size.width, size.height) +} + +/// The dialog with its size fields fitted to a `src_w` × `src_h` source: the document, or the +/// layer Layer › Export As exports. Fitting the document first would replace a remembered pixel +/// size the document can't take with the percent, even when the layer can take it. +fn open_sized(app: &mut PhotocraftApp, src_w: u32, src_h: u32) -> Result { let mut f = Map::new(); f.insert("__export".into(), json!(true)); f.insert("__label".into(), json!("Export As")); @@ -106,12 +113,12 @@ pub fn open(app: &mut PhotocraftApp) -> Result { f.insert("lockAspect".into(), json!(true)); f.insert("profile".into(), json!("document")); f.insert("embedProfile".into(), json!(true)); - f.insert("width".into(), json!(st.doc.size.width)); - f.insert("height".into(), json!(st.doc.size.height)); - f.insert("__w".into(), json!(st.doc.size.width)); - f.insert("__h".into(), json!(st.doc.size.height)); + f.insert("width".into(), json!(src_w)); + f.insert("height".into(), json!(src_h)); + f.insert("__w".into(), json!(src_w)); + f.insert("__h".into(), json!(src_h)); seed_remembered(app, &mut f); - fit_size(&mut f, f64::from(st.doc.size.width), f64::from(st.doc.size.height)); + fit_size(&mut f, f64::from(src_w), f64::from(src_h)); Ok(app.ui.open_dialog(DialogKind::Command, f)) } @@ -147,16 +154,13 @@ fn fit_size(f: &mut Map, src_w: f64, src_h: f64) { pub fn open_layer(app: &mut PhotocraftApp, layer: photocraft_doc::LayerId) -> Result { let st = app.session.active().ok_or("no document")?; let ldoc = photocraft_engine::layer_menu_cmds::layer_document(&st.doc, layer).map_err(|e| e.to_string())?; - let id = open(app)?; + // The size fields follow the layer's trimmed size, so the dialog shows and edits the size + // the layer exports at. + let id = open_sized(app, ldoc.size.width, ldoc.size.height)?; if let Some(d) = app.ui.dialogs.iter_mut().find(|d| d.id == id) { let f = &mut d.fields; f.insert("__layer".into(), json!(layer.0)); f.insert("__label".into(), json!(crate::i18n::fmt(crate::i18n::t("Export As: {name}"), &[("name", &ldoc.name)]))); - f.insert("__w".into(), json!(ldoc.size.width)); - f.insert("__h".into(), json!(ldoc.size.height)); - // `open` fitted width and height to the document; they follow the layer's trimmed size - // instead, so the dialog shows and edits the size the layer exports at. - fit_size(f, f64::from(ldoc.size.width), f64::from(ldoc.size.height)); } Ok(id) } @@ -357,17 +361,18 @@ pub(crate) fn resize_from_scale(src_w: f64, src_h: f64, scale: f64) -> Result Result { let src_w = src_w.max(1.0); let src_h = src_h.max(1.0); let (width, height) = if edited_width { let width = finite_px(width)?; - let height = if lock { finite_px(src_h * width / src_w)? } else { finite_px(height)? }; + let height = if lock { finite_px((src_h * width / src_w).max(1.0))? } else { finite_px(height)? }; (width, height) } else { let height = finite_px(height)?; - let width = if lock { finite_px(src_w * height / src_h)? } else { finite_px(width)? }; + let width = if lock { finite_px((src_w * height / src_h).max(1.0))? } else { finite_px(width)? }; (width, height) }; let scale = if edited_width { width / src_w * 100.0 } else { height / src_h * 100.0 }; @@ -1301,6 +1306,40 @@ mod tests { assert_eq!((f["sizing"].as_str(), f["scale"].as_f64()), (Some("scale"), Some(50.0))); } + /// A remembered linked width whose height rounds below one pixel keeps its width: the height + /// is one pixel, as the Scale field gives at the same 10 %, and not the remembered percent. + #[test] + fn a_remembered_pixel_width_keeps_a_one_pixel_height() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 1000, "height": 1})).unwrap(); + app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), json!({"sizing": "pixels", "width": 100, "height": 75, "scale": 100, "lockAspect": true}))); + let id = open(&mut app).unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(100.0), Some(1.0))); + assert_eq!(f["sizing"], "pixels"); + // The same for an edited height on a 1 × 1000 image. + let size = resize_from_side(1.0, 1000.0, 1.0, 100.0, true, false).unwrap(); + assert_eq!((size.width, size.height), (1.0, 100.0)); + } + + /// Layer › Export As fits the remembered size to the layer, not to the document: a remembered + /// linked width the tall document can't take (800 px wide is 320 000 px high) still fits the + /// 100 × 10 layer at 800 × 80. + #[test] + fn a_layer_export_fits_the_remembered_size_to_the_layer() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 100, "height": 40000})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 100, "height": 10})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + let layer = app.session.active().unwrap().active_layer.unwrap(); + app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), json!({"sizing": "pixels", "width": 800, "height": 600, "scale": 50, "lockAspect": true}))); + let id = open_layer(&mut app, layer).unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(800.0), Some(80.0))); + assert_eq!(f["sizing"], "pixels"); + } + /// Only the dialog's own settings are remembered: not the destination, the document size or /// the layer the dialog was opened with. #[test] From 1df36facbd053c9bd219e73650f920d1d1ef2920 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Batuhan=20G=C3=B6zeg=C3=BC?= Date: Fri, 23 Oct 2026 16:34:04 +0300 Subject: [PATCH 34/86] i18n(tr): fix mistranslations, inconsistencies and untranslated rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Color Decontaminate: "Renkli" (colourful) -> "Renkleri Arındır" - Warp split commands and "Cancel warp": "Çözgü" (textile warp) -> "Çarpıtma" - Constraint hint: "Shift" was translated as "kaydırma"; keep the key name - Highlights (tonal range) -> "Parlak Tonlar", as in the other rows - Luminance -> "Lüminans" (was "parlaklık", which is Brightness) - Indexed -> "İndekslenmiş", Trilinear -> "Üç Doğrusal" - Levels In/Out Black/White: distinct "Giriş/Çıkış Siyahı/Beyazı" - Layer Knocks Out Drop Shadow now says "Alt Gölge" like Drop Shadow - Contact Sheet II -> "Kontak Sayfası II" - Fix word order of the clone-source hints, "After (X)", "On Black/White" - Translate "Preview size" and "Print preview image" - Keep theme names Solarized and Breeze; Pro Light -> "Pro Açık" - Add contributor entry for the author --- contributors/people.toml | 4 ++ crates/ui-egui/src/i18n/tr.tsv | 70 +++++++++++++++++----------------- 2 files changed, 39 insertions(+), 35 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 30077a230f..a26f686d60 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -251,3 +251,7 @@ sync_github_name = true [people.benandez] real_name = "Martin Ball" + +[people.batuhangozegu] +real_name = "Batuhan Gözegü" +sync_github_name = false diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 342a0ebff4..2774fb9d82 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -89,7 +89,7 @@ Batch… Toplu… Create Droplet… Damlacık Oluştur… Crop and Straighten Photos Fotoğrafları Kırp ve Düzelt - Contact Sheet II… İletişim Sayfası II… + Contact Sheet II… Kontak Sayfası II… Conditional Mode Change… Koşullu Mod Değişikliği… Fit Image… Görüntüyü Sığdır… Lens Correction… Mercek Düzeltme… @@ -247,7 +247,7 @@ Threshold… Eşik… Gradient Map… Degrade Eşlemesi… Selective Color… Seçici Renk… - Shadows/Highlights… Gölgeler/Vurgular… + Shadows/Highlights… Gölgeler/Parlak Tonlar… HDR Toning… HDR Tonlama… Desaturate Doygunluğu Azalt Match Color… Rengi Eşleştir… @@ -410,7 +410,7 @@ Stamp Down Aşağıya Damgala Flatten Image Görüntüyü Düzleştir Matting Matlama - Color Decontaminate… Renkli Dekontamine… + Color Decontaminate… Renkleri Arındır… Remove Black Matte Siyah Matı Kaldır Remove White Matte Beyaz Matı Kaldır Defringe… Kenar Halesini Kaldır… @@ -438,7 +438,7 @@ Right Edges Sağ Kenarlar Distribute Dağıt Vertically Dikey Olarak - Horizontally Yatay olarak + Horizontally Yatay Olarak Layer Edges Katman Kenarları Isolate Layers Katmanları Yalıt Layer Content Options… Katman İçerik Seçenekleri… @@ -777,9 +777,9 @@ Other… Diğer… Remove Warp Split Çarpıtma Bölünmesini Kaldır Shape Şekil - Split Warp Crosswise Çözgüyü Çapraz Olarak Böl - Split Warp Horizontally Çözgüyü Yatay Olarak Böl - Split Warp Vertically Çözgüyü Dikey Olarak Böl + Split Warp Crosswise Çarpıtmayı Çapraz Olarak Böl + Split Warp Horizontally Çarpıtmayı Yatay Olarak Böl + Split Warp Vertically Çarpıtmayı Dikey Olarak Böl Statistics… İstatistikler… Refine Strokes Fırça Vuruşlarını Hassaslaştır @plural {n} item|{n} items {n} öğe @@ -888,7 +888,7 @@ Cancel any current edits (Esc) Geçerli düzenlemeleri iptal et (Esc) Cancel current crop operation (Esc) Geçerli kırpma işlemini iptal et (Esc) Cancel transform (Esc) Dönüşümü iptal et (Esc) - Cancel warp (Esc) Çözgüyü iptal et (Esc) + Cancel warp (Esc) Çarpıtmayı İptal Et (Esc) Canvas Tuval Canvas extension color: Tuval uzatma rengi: Capture Yakala @@ -945,7 +945,7 @@ Commit warp ({key}) Çarpıtmayı onayla ({key}) Constant Sabit Constrain proportions Oranları koru - Contact Sheet II İletişim Sayfası II + Contact Sheet II Kontak Sayfası II Content-Aware İçeriğe Duyarlı Contiguous Bitişik Contrast Kontrast @@ -1055,7 +1055,7 @@ Dots Noktalar Drag a rectangle around the object Nesnenin çevresine bir dikdörtgen sürükleyin Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Kısıtlama eklemek için görüntü üzerinde sürükleyin; yatay/dikey için Shift; silmek için sağ tıklayın. - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Kısıtlama eklemek için resmin üzerine sürükleyin; Yatay/dikey için kaydırma; değiştirmek veya silmek için bir kısıtlamaya sağ tıklayın. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Kısıtlama eklemek için görüntü üzerinde sürükleyin; yatay/dikey için Shift tuşunu kullanın; değiştirmek veya silmek için bir kısıtlamaya sağ tıklayın. Drag to move the active shape's path or the Work Path Etkin şeklin yolunu veya Çalışma Yolunu taşımak için sürükleyin Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Tıklayın: çapa noktasını seçin · Sürükleyin: çapa noktasını, tutamacı veya parçayı taşıyın · {shift}-tıklayın: ekleyin · {alt}-tıklayın: alt yolun tamamı Draw with the Pen tool (P) or make a work path from a selection. Kalem aracıyla (P) çizim yapın veya seçimden bir çalışma yolu oluşturun. @@ -1146,7 +1146,7 @@ Hide Properties Özellikleri Gizle Highlight Compression Parlak Ton Sıkıştırma Highlight Priority Parlak Ton Önceliği - Highlights Öne Çıkanlar + Highlights Parlak Tonlar Home Ana sayfa Horizontal scale Yatay ölçek Hue Ton @@ -1182,7 +1182,7 @@ Kerning Karakter Aralığı Keyboard Shortcuts Klavye Kısayolları Keyboard Shortcuts and Menus Klavye Kısayolları ve Menüler - Kind tür + Kind Tür Label Size Etiket Boyutu Labels Etiketler Lanczos (sharp) Lanczos (keskin) @@ -1218,7 +1218,7 @@ Lock: Kilit: Logical Length: Mantıksal Uzunluk: Logical Units: Mantıksal Birimler: - Luminance parlaklık + Luminance Lüminans Luminance Detail Lüminans Ayrıntısı Magenta Macenta Magenta · Green Macenta · Yeşil @@ -1520,7 +1520,7 @@ Tracking (1/1000 em) İzleme (1/1000 em) Transparency Saydamlık Transparent Saydam - Trilinear Üç çizgili + Trilinear Üç Doğrusal Type Styles Metin Stilleri Type: Tür: Underline Altı çizili @@ -1643,7 +1643,7 @@ ExtraBold Ekstra Kalın ExtraLight Ekstra Hafif Eyedropper Tool Damlalık Aracı - Fade Solmaya + Fade Solma Fill Opacity Dolgu Opaklığı Film & Video Film ve Video From Şuradan @@ -1656,14 +1656,14 @@ Hard Sert Hard Mix Sert Karışım Healing Brush Tool Düzeltme Fırçası Aracı - Highlights from: Öne çıkanlar: + Highlights from: Parlak tonlar (başlangıç): History Brush Tool Geçmiş Fırçası Aracı Horizontal Type Tool Yatay Metin Aracı Horizontal Type Mask Tool Yatay Metin Maskesi Aracı Vertical Type Mask Tool Dikey Metin Maskesi Aracı Image Size: Görüntü Boyutu: Inches İnç - Indexed Dizine eklendi + Indexed İndekslenmiş Initial Direction Başlangıç Yönü Inner Bevel İç Eğim Inner Glow İç Işıma @@ -1675,7 +1675,7 @@ Lasso Kement Lasso Tool Kement Aracı Layer Count Katman Sayısı - Layer Knocks Out Drop Shadow Katman Düşen Gölgeyi Deler + Layer Knocks Out Drop Shadow Katman Alt Gölgeyi Gizler Legal Legal Letter Letter Light Hafif @@ -2036,7 +2036,7 @@ Expand Genişlet Expand Panel Group Panel Grubunu Genişlet Expand group ({key}-click: all groups) Grubu genişlet ({key}-tıklayın: tüm gruplar) - Fade steps Adımları soldur + Fade steps Solma Adımı Sayısı Hide the layer's effects ({key}-click: all layers) Katmanın efektlerini gizle ({key}-tıklama: tüm katmanlar) Import Brushes… (.abr) Fırçaları İçe Aktar… (.abr) Move Group Down Grubu Aşağı Taşı @@ -2058,10 +2058,10 @@ Reset GPU Backend GPU Arka Ucunu Sıfırla Applies at next launch. Bir sonraki açılışta geçerlidir. Click to sample the foreground colour · {key}-click for background Ön plan rengini örneklemek için tıklayın · arka plan için {key}-tıklayın - {key}-click to set the source {key}-kaynağı ayarlamak için tıklayın + {key}-click to set the source Kaynağı ayarlamak için {key} tuşuna basarak tıklayın {key} to subtract {key} çıkarmak için Clone source {n} ({key}-click with Clone Stamp to set) Klon kaynağı {n} (ayarlamak için Klonlama Damgası ile {key}-tıklayın) - Source: not set ({key}-click with Clone Stamp) Kaynak: ayarlanmadı ({key}-Klon Damgası ile tıklayın) + Source: not set ({key}-click with Clone Stamp) Kaynak: ayarlanmadı (Klonlama Damgası ile {key} tuşuna basarak tıklayın) Pixel Layer Piksel Katmanı Type Layer Metin Katmanı Shape Layer Şekil Katmanı @@ -2463,7 +2463,7 @@ cameraRaw Blue Mavi cameraRaw Purple Mor cameraRaw Magenta Macenta cameraRaw Blending Karıştırma -cameraRaw Luminance Parlaklık +cameraRaw Luminance Lüminans cameraRaw Temperature Sıcaklık cameraRaw Color Renk Patch Tool Yama Aracı @@ -2932,9 +2932,9 @@ cameraRaw Color Renk Gray Value (Median) Gri Değeri (Medyan) Integrated Density Entegre Yoğunluk Length Uzunluk - In Black Siyah - In White Beyaz - Out Black Siyah + In Black Giriş Siyahı + In White Giriş Beyazı + Out Black Çıkış Siyahı Out White Çıkış Beyazı Tint Color Ton Rengi Stops Duraklar @@ -3275,7 +3275,7 @@ cameraRaw Color Renk Adds grain to the soft edges of the brush tip. Fırça ucunun yumuşak kenarlarına gren ekler. Adobe PDF Presets… Adobe PDF Ön Ayarları… Advanced Blending Gelişmiş Karıştırma - After (X) (X)'ten sonra + After (X) Sonra (X) Airbrush: keeps painting while the pointer rests. Airbrush: İşaretçi dururken boyamaya devam eder. Alternates for Current Selection Geçerli Seçimin Alternatifleri Apply Data Set Veri Kümesini Uygula @@ -3309,7 +3309,7 @@ cameraRaw Color Renk Channel4 Kanal 4 Clipping Path Kırpma Yolu Clone Klon - Color Decontaminate Renkli Dekontaminasyon + Color Decontaminate Renkleri Arındır Color Halftone Renk Yarım Tonu Color Lookup Tables Renk Arama Tabloları Color Settings Renk Ayarları @@ -3485,7 +3485,7 @@ cameraRaw Color Renk Select and Mask Seç ve Maskele Semi Bold Yarı Kalın Set Measurement Scale Ölçüm Ölçeğini Ayarla - Shadows/Highlights Gölgeler/Vurgular + Shadows/Highlights Gölgeler/Parlak Tonlar Shape Blur Şekil Bulanıklığı Shear Kesme Show the layer's effects (⌥-click: all layers) Katmanın efektlerini göster (⌥ tuşuna basarak tıklayın: tüm katmanlar) @@ -3787,8 +3787,8 @@ cameraData Lens Lens Loading fonts… Yazı tipleri yükleniyor… Onion Skin Soğan Kabuğu Marching Ants Yürüyen Karıncalar - On Black Siyah - On White Beyaz + On Black Siyah Üzerinde + On White Beyaz Üzerinde On Layers Katmanlarda Show Edge Kenarı Göster Show Original Orijinali Göster @@ -4071,7 +4071,7 @@ cameraData Lens Lens Next Brush Sonraki fırça First Brush İlk fırça Last Brush Son fırça - Preview size Preview size + Preview size Önizleme Boyutu Memory Usage Bellek kullanımı Per-document RAM limit (MB) Belge başına RAM sınırı (MB) Installed RAM: {ram} MB; this limit: {percent}% Kurulu RAM: {ram} MB; bu sınır: {percent}% @@ -4095,10 +4095,10 @@ cameraData Lens Lens # Actions: a step dialog holds a suspended recording (#7365) Confirm or cancel the open action step dialog first Önce açık eylem adımı iletişim kutusunu onaylayın veya iptal edin Pro Medium Gray Pro Orta Gri - Solarized Solarize - Breeze Esinti + Solarized Solarized + Breeze Breeze Pro Light Gray Pro Açık Gri - Pro Light Profesyonel Işık + Pro Light Pro Açık Resize Windows to Fit Pencereleri sığacak şekilde boyutlandır EXR EXR @@ -4110,4 +4110,4 @@ cameraData Lens Lens Not rendered: this adjustment ({key}) is kept as imported. Görüntülenmiyor: bu ayarlama ({key}) içe aktarıldığı gibi korunuyor. # Print preview image (#8236) - Print preview image Print preview image + Print preview image Baskı Önizleme Görüntüsü From 4dd1ccc2a3893c538599bd71842e6d94df2c5773 Mon Sep 17 00:00:00 2001 From: John Turnbach Date: Fri, 23 Oct 2026 10:13:38 -0400 Subject: [PATCH 35/86] fix(text): write a style-only type edit into the PSD's Txt2 style runs Photoshop reads a type layer's character style from the document's Txt2 block, not from the layer's TySh. build_txt2_at kept a Txt2 object as it was whenever its text still matched the layer, and built new style runs only for edited or new objects. So a type.setStyle with no text change (font, faux bold, size, ...) was saved into the TySh but not into Txt2, and Photoshop reopened the layer in its old style. The same setStyle together with a type.edit did reach Photoshop, because the object then took the edited-object path. An object is now kept only while its style runs agree with the layer's character runs on every key the writer regenerates: font (by name through the block's font list), size, faux bold and italic, leading, scales, tracking, fill colour and the OpenType keys. The auto-kern mode is still refitted separately. A key the file's run lacks, or holds as another type, is skipped, so an unedited save still returns the file's block byte for byte (#2374). When a key disagrees, the object goes through the edited-object path: the file's structure is kept, its style runs are rebuilt from the layer, and the glyph pen positions laid out in the old style are dropped. Regression test: text::tests::style_only_edit_restyles_the_kept_txt2_object builds a Photoshop-shaped Txt2 (Arial-Black, faux bold on) and checks that an unedited layer gets the block back byte for byte, that turning faux bold off writes /2 false, and that a font change plus faux bold off names AppleSDGothicNeo-Heavy in the runs. It fails on main (faux bold stays true) and passes with the fix. The io helper add_type_layer in psd_kerning.rs now applies the file's Txt2 to its kept layers, as opening the file does. Before, those layers contradicted their own Txt2 objects (no salt where the object had it on), which the old keep-whatever-the-file-had behaviour hid. Fixes #8415 --- crates/io/tests/it/psd_kerning.rs | 7 ++- crates/text/src/psd.rs | 92 ++++++++++++++++++++++++++++++- crates/text/src/tests.rs | 50 +++++++++++++++++ 3 files changed, 146 insertions(+), 3 deletions(-) diff --git a/crates/io/tests/it/psd_kerning.rs b/crates/io/tests/it/psd_kerning.rs index ad492fce25..c2a72f640f 100644 --- a/crates/io/tests/it/psd_kerning.rs +++ b/crates/io/tests/it/psd_kerning.rs @@ -205,8 +205,11 @@ fn add_type_layer(objects: &[(&str, &str)], kept: &[(&str, i32)], at: usize) -> for (text, index) in kept { let mut t = layer(text, &[(text.len(), Kerning::Metrics, 0.0)]); photocraft_text::TextEngine::new().render_layer(&mut t, doc.resolution_dpi, doc.pixel_format()); - let raw = photocraft_text::psd::build_tysh(&t, doc.resolution_dpi, None); - t.psd_raw = Some(std::sync::Arc::new(photocraft_text::psd::set_text_index(&raw, *index).unwrap())); + let raw = photocraft_text::psd::set_text_index(&photocraft_text::psd::build_tysh(&t, doc.resolution_dpi, None), *index).unwrap(); + // As opening the file does: a kept layer's style holds what its `Txt2` object says (#8415: + // a layer whose style disagrees with its object is saved as a style edit). + photocraft_text::psd::apply_txt2(&mut t, &raw, &photocraft_text::psd::parse_txt2(&photoshop_txt2(objects)).unwrap()); + t.psd_raw = Some(std::sync::Arc::new(raw)); doc.layers.push(Layer::new(*text, LayerContent::Text(t))); } doc.metadata.psd_global_blocks = vec![(*b"8BIM", *b"Txt2", std::sync::Arc::new(photoshop_txt2(objects)))]; diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 43ccbd1233..ed03f63ecf 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -1058,6 +1058,90 @@ fn txt2_style_runs(layer: &TextLayer, base: &E, fonts: &mut Vec, dpi: f32) -> Some(out) } +/// Whether the style runs of a `Txt2` text model (`/6 /0`) agree with `layer`'s character runs +/// on every key [`txt2_style_runs`] writes, the auto-kern mode aside ([`with_modes`] refits that +/// on its own): font (by name, through `fonts`, the block's font list), size, faux bold and +/// italic, leading, scales, tracking, fill colour and the OpenType keys. A key the file's run +/// lacks, or holds as another type, is no evidence of an edit and is skipped, so an unedited +/// save still gets the file's block back byte for byte (#2374); a key that disagrees means the +/// layer's style was edited since the file was written (#8415). +fn txt2_style_matches(layer: &TextLayer, model: &E, fonts: &[E], dpi: f32) -> bool { + let Some(file_runs) = model.path(&["6", "0"]).and_then(E::as_array) else { return true }; + let k = if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 }; + let run_len = |i: usize| file_runs.get(i).and_then(|r| r.get("1")).and_then(E::as_i64).unwrap_or(0); + let runs = layer.char_runs(); + let default = CharStyle::default(); + let ed_text = format!("{}\r", layer.text.replace('\n', "\r")); + let (mut ri, mut run_end) = (0usize, runs.first().map_or(0, |r| r.len)); + let (mut fi, mut left) = (0usize, run_len(0)); + // Each (layer run, file run) pair is compared once, however long it is. + let mut compared = None; + for (b, ch) in ed_text.char_indices() { + while b >= run_end && ri + 1 < runs.len() { + ri += 1; + run_end = run_end.saturating_add(runs.get(ri).map_or(0, |r| r.len)); + } + while left <= 0 && fi + 1 < file_runs.len() { + fi += 1; + left = run_len(fi); + } + if compared != Some((ri, fi)) { + let s = runs.get(ri).map_or(&default, |r| &r.style); + if let Some(style) = file_runs.get(fi).and_then(|r| r.path(&["0", "0", "6"])) + && !txt2_style_agrees(s, style, fonts, k) + { + return false; + } + compared = Some((ri, fi)); + } + left = left.saturating_sub(ch.len_utf16() as i64); + } + true +} + +/// Whether one `Txt2` style dictionary agrees with `s` (see [`txt2_style_matches`]); `k` is the +/// document's points per PostScript point, as in [`txt2_style_runs`]. +fn txt2_style_agrees(s: &CharStyle, style: &E, fonts: &[E], k: f32) -> bool { + let close = |a: f64, b: f64| (a - b).abs() <= 1e-3 * a.abs().max(b.abs()).max(1.0); + let number = |key: &str, want: f32| style.get(key).and_then(E::as_f64).is_none_or(|v| close(v, f64::from(want))); + let flag = |key: &str, want: bool| style.get(key).and_then(E::as_bool).is_none_or(|v| v == want); + let font = style.get("0").and_then(E::as_i64).and_then(|i| usize::try_from(i).ok()).and_then(|i| fonts.get(i)).and_then(|f| f.path(&["0", "0", "0"])); + if font.and_then(E::as_str).is_some_and(|name| name != postscript_for(s)) { + return false; + } + let auto_leading = s.leading_pt.is_none(); + if !(number("1", s.size_pt * k) && flag("2", s.faux_bold) && flag("3", s.faux_italic) && flag("4", auto_leading)) { + return false; + } + // An automatic leading's value is Photoshop's own; only a set one is ours to compare. + if style.get("4").and_then(E::as_bool) == Some(false) && !auto_leading && !number("5", s.leading_pt.unwrap_or(0.0) * k) { + return false; + } + if !(number("6", s.horizontal_scale) && number("7", s.vertical_scale) && number("8", s.tracking.round())) { + return false; + } + if let Some(paint) = style.get("53").and_then(|p| p.get("0")) { + let (kind, values) = fill_color(&s.color); + if paint.get("0").and_then(E::as_i64).is_some_and(|v| v != kind) { + return false; + } + if let Some(have) = paint.get("1").and_then(E::as_array) { + let same = + have.len() == values.len() && have.iter().zip(&values).all(|(a, b)| matches!((a.as_f64(), b.as_f64()), (Some(a), Some(b)) if close(a, b))); + if !same { + return false; + } + } + } + let feature = |tag: &str| s.features.iter().find(|f| f.tag == tag).map(|f| f.value > 0); + if !TXT2_FEATURES.iter().all(|&(key, tag)| flag(key, feature(tag).unwrap_or(tag == "calt"))) { + return false; + } + // A mask beyond the 20 sets is ignored on import (`add_txt2_features`), so here too. + let sets = (0..20).filter(|n| feature(&format!("ss{:02}", n + 1)) == Some(true)).fold(0i64, |m, n| m | 1 << n); + style.get(TXT2_STYLISTIC_SETS).and_then(E::as_i64).filter(|m| (0..1 << 20).contains(m)).is_none_or(|m| m == sets) +} + /// The index of the PostScript font `name` in a `Txt2` font list (`<< /0 << /99 /CoolTypeFont /// /0 << /0 name /2 type >> >> >>` entries), appending it (shaped like the list's first entry) /// when missing. `None` for an empty list: there is no entry to shape a new one on. @@ -1161,7 +1245,13 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi // it is replaced like a missing one (below) and re-saving such a file repairs it, whether // or not the layer's text changed. let slot = prev_objects.get(at).filter(|o| !is_stub(o) || all_stubs); - let kept = slot.filter(|o| o.get("0").and_then(|m| m.get("0")).and_then(E::as_str) == Some(text.as_str())); + // Kept only while its style runs still say what the layer says (#8415): Photoshop reads + // the character style from them, so a style-only edit (font, faux bold, size, …) must + // restyle the object the way an edited one is, or the file's old style shows on reopen. + let kept = slot.filter(|o| { + let model = o.get("0"); + model.and_then(|m| m.get("0")).and_then(E::as_str) == Some(text.as_str()) && model.is_some_and(|m| txt2_style_matches(layer, m, &fonts, dpi)) + }); // An edited object (#6968): the file's object at this slot with its text changed. It keeps // the file's structure — paragraph runs, the view naming its frame, the rest — with every // run array refitted to the new text. A bare `<< /0 << /0 text /6 runs >> >>` stub in diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 83f699fc85..7575178363 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1404,6 +1404,56 @@ fn edited_text_keeps_the_files_txt2_object() { } } +/// #8415: a style-only edit (`type.setStyle` with no text change) reaches the file's `Txt2`. +/// Photoshop reads the character style from the object's style runs, and an object whose text +/// still matched kept the file's runs, so Photoshop reopened the layer in its old font with faux +/// bold still on. A layer whose style agrees with the runs still gets the block back byte for +/// byte (#2374); one whose font or faux bold changed is restyled like an edited object, and the +/// glyph pen positions laid out in the old style are dropped. +#[test] +fn style_only_edit_restyles_the_kept_txt2_object() { + use crate::engine_data::Value as E; + // Strings in the block are UTF-16 with a BOM; every character here is below U+0100. + let utf16 = |s: &str| format!("(\u{fe}\u{ff}{})", s.chars().map(|c| format!("\0{c}")).collect::()); + let font = |name: &str| format!("<< /0 << /99 /CoolTypeFont /0 << /0 {} /2 1 >> >> >> ", utf16(name)); + // One object as Photoshop writes it: paragraph runs, style runs, a view and pen positions. + let prev = format!( + "\n\n/98 << /0 14 >> /0 << /1 << /0 [ {}{}] >> >> /1 << /1 [ << /0 << /0 {} /5 << /0 [ << /0 << /0 << /0 (\u{fe}\u{ff}) /5 << /7 1.2 >> >> >> /1 3 >> ] >> /6 << /0 [ << /0 << /0 << /0 (\u{fe}\u{ff}) /6 << /0 0 /1 12.0 /2 true /3 false /11 1 >> >> >> /1 3 >> ] >> >> /1 << /0 [ << /0 0 >> ] >> /21 << /1 [ 1.25 2.5 ] >> >> ] >>", + font("Arial-Black"), + font("ArialMT"), + utf16("AV\r"), + ); + let prev: Vec = prev.chars().map(|c| c as u32 as u8).collect(); + let style = CharStyle { postscript_name: Some("Arial-Black".into()), size_pt: 12.0, faux_bold: true, ..Default::default() }; + let t = styled("AV", style.clone()); + assert_eq!(crate::psd::build_txt2(&[(0, &t)], Some(&prev)), prev, "an unedited style keeps the file's block"); + + let font_name = |out: &E, run: &E| { + let i = run.path(&["0", "0", "6", "0"]).and_then(E::as_i64).and_then(|i| usize::try_from(i).ok()).expect("a font index"); + out.path(&["0", "1", "0"]).and_then(E::as_array).and_then(|f| f.get(i)).and_then(|f| f.path(&["0", "0", "0"])).and_then(E::as_str).map(str::to_owned) + }; + for (what, edited, want_font) in [ + ("faux bold off", CharStyle { faux_bold: false, ..style.clone() }, "Arial-Black"), + ( + "font and faux bold", + CharStyle { postscript_name: Some("AppleSDGothicNeo-Heavy".into()), faux_bold: false, ..style.clone() }, + "AppleSDGothicNeo-Heavy", + ), + ] { + let t = styled("AV", edited); + let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap(); + let object = &out.path(&["1", "1"]).and_then(E::as_array).unwrap()[0]; + assert_eq!(object.path(&["0", "0"]).and_then(E::as_str), Some("AV\r"), "{what}: the text is unchanged"); + let runs = object.path(&["0", "6", "0"]).and_then(E::as_array).unwrap(); + assert!(!runs.is_empty(), "{what}: the object has style runs"); + for run in runs { + assert_eq!(run.path(&["0", "0", "6", "2"]).and_then(E::as_bool), Some(false), "{what}: faux bold is off in Txt2"); + assert_eq!(font_name(&out, run).as_deref(), Some(want_font), "{what}: Txt2 names the layer's font"); + } + assert!(object.get("21").is_none(), "{what}: pen positions of the old style are dropped"); + } +} + /// A type layer the file's `Txt2` has no object for (one added before the save, #7698) gets an /// object shaped like the file's own, with a view naming its own frame and a frame in `/0 /8 /0`, /// instead of a bare stub: Photoshop and Photopea refused the file when the frames ran out. From aedab302b2848e44a1f4c3ac7263e8b8d49d7d1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Batuhan=20G=C3=B6zeg=C3=BC?= Date: Fri, 23 Oct 2026 18:12:29 +0300 Subject: [PATCH 36/86] ui: larger Align icons and overflow button; Save for Web keeps its width on an invalid Matte - Move tool Align buttons: 16 px glyph in the 24 px box (was 12 px) - Document/panel overflow button: 16 px glyph in the normal text colour (was 12 px, dim) - Save for Web: a failed preview no longer draws its error beside the pane, which widened the dialog; the same message stays on the row above the panes --- crates/ui-egui/src/align_bar.rs | 2 +- crates/ui-egui/src/file_ui.rs | 7 +++---- crates/ui-egui/src/tab_strip.rs | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/align_bar.rs b/crates/ui-egui/src/align_bar.rs index d663283e02..0a8ca4b3be 100644 --- a/crates/ui-egui/src/align_bar.rs +++ b/crates/ui-egui/src/align_bar.rs @@ -35,7 +35,7 @@ pub fn options(app: &mut PhotocraftApp, ui: &mut egui::Ui) { for (cmd, icon, label) in ALIGN { let tip = format!("{}: {}", tl!("Align"), tl!(label)); let on = app.session.is_enabled(cmd); - if ui.add_enabled_ui(on, |ui| icons::button(ui, icon, 24.0, false, &tip)).inner.clicked() { + if ui.add_enabled_ui(on, |ui| icons::button_with_icon_size(ui, icon, 24.0, 16.0, false, &tip)).inner.clicked() { let _ = app.run(cmd, json!({})); } } diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs index e014ca9e36..6041f6a062 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -331,10 +331,9 @@ fn web_pane(app: &PhotocraftApp, ui: &mut egui::Ui, size: egui::Vec2, p: Option< None => (px.iter().flat_map(|q| q.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).collect::>(), None), Some(p) => match web_preview::optimized(ui, key, &format!("{}|{rev}|{frame:?}|{size_sig}", doc.id.0), p, &px, w, h) { Ok((preview, bytes, colors, ext)) => (preview, Some(((bytes as f64 * ratio) as usize, colors, ext))), - Err(error) => { - ui.label(error); - (vec![0; (w * h * 4) as usize], None) - } + // The error shows on the row above the panes; a label here sat beside the pane + // and widened the dialog (#8830). + Err(_) => (vec![0; (w * h * 4) as usize], None), }, }; let img = egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], &rgba); diff --git a/crates/ui-egui/src/tab_strip.rs b/crates/ui-egui/src/tab_strip.rs index 1419471f1f..0a58cb6244 100644 --- a/crates/ui-egui/src/tab_strip.rs +++ b/crates/ui-egui/src/tab_strip.rs @@ -65,7 +65,7 @@ pub fn overflow_button(ui: &mut Ui, id: egui::Id, r: Rect, tip: &str, labels: &[ if resp.hovered() { paint_ui.painter().rect_filled(r.shrink2(vec2(1.0, 3.0)), t.radius_sm, t.hover.gamma_multiply(0.6)); } - crate::icons::paint(&paint_ui, r, "chevrons-right", 12.0, if resp.hovered() { t.text } else { t.text_dim }); + crate::icons::paint(&paint_ui, r, "chevrons-right", 16.0, t.text); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tip)); let resp = resp.on_hover_text(tip); egui::Popup::menu(&resp).show(|ui| { From 4e816dc0ca376cd33cfc8e1d746843b549b17031 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Fri, 23 Oct 2026 11:04:33 +0530 Subject: [PATCH 37/86] feat(ui): Photoshop's Select and Mask workspace Select > Select and Mask... opens a full-window workspace laid out as Photoshop's, replacing the parameter dialog: - options bar: tool, add/subtract, Size, Hardness (Brush), Sample All Layers, Select Subject, Refine Hair (greyed: #2671) - tools: Quick Selection W, Refine Edge Brush R, Brush B, Object Selection W, Lasso and Polygonal Lasso L (one slot), Hand H, Zoom Z; Shift+W / Shift+L cycle - the document under its tab, shown in the chosen view - Properties: Remember Settings, View Mode (view letters, Show Edge J, Show Original P, Real-Time Refinement, High Quality Preview, Transparency/Opacity, Overlay colour and Indicates), Refine Mode (Object Aware greyed: #2788), Edge Detection, Global Refinements with Clear Selection and Invert, Output Settings; Reset, OK, Cancel The tools become select.refineEdge `edits` (the Refine Edge Brush its `refineStrokes`): OK is one history step, Cancel drops everything, Ctrl+Z undoes per edit. The preview runs in the background on a reduced copy (full size with High Quality Preview), reusing the selection after the edits it already applied; Real-Time Refinement previews the stroke being drawn. A targeted layer mask is refined and output to the mask. Closes #7069. Closes #8148. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 6 +- crates/ui-egui/src/distort_ui.rs | 30 +- crates/ui-egui/src/i18n/cs.tsv | 14 + crates/ui-egui/src/i18n/de.tsv | 15 + crates/ui-egui/src/i18n/el.tsv | 14 + crates/ui-egui/src/i18n/es.tsv | 14 + crates/ui-egui/src/i18n/fr.tsv | 14 + crates/ui-egui/src/i18n/hi.tsv | 15 + crates/ui-egui/src/i18n/hu.tsv | 14 + crates/ui-egui/src/i18n/id.tsv | 15 + crates/ui-egui/src/i18n/it.tsv | 14 + crates/ui-egui/src/i18n/ja.tsv | 15 + crates/ui-egui/src/i18n/ko.tsv | 15 + crates/ui-egui/src/i18n/nl.tsv | 14 + crates/ui-egui/src/i18n/pl.tsv | 15 + crates/ui-egui/src/i18n/pt-br.tsv | 14 + crates/ui-egui/src/i18n/ru.tsv | 14 + crates/ui-egui/src/i18n/sr.tsv | 14 + crates/ui-egui/src/i18n/tr.tsv | 14 + crates/ui-egui/src/i18n/uk.tsv | 15 + crates/ui-egui/src/i18n/vi.tsv | 15 + crates/ui-egui/src/i18n/zh-hans.tsv | 15 + crates/ui-egui/src/i18n/zh-hant.tsv | 15 + crates/ui-egui/src/layer_row_ui/edit_tests.rs | 4 +- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/mask_props_ui_tests.rs | 31 +- crates/ui-egui/src/menus.rs | 3 +- crates/ui-egui/src/select_mask_ui.rs | 1776 +++++++++++++++++ crates/ui-egui/src/select_mask_ui_tests.rs | 544 +++++ crates/ui-egui/src/select_mask_view.rs | 59 +- crates/ui-egui/src/shortcuts.rs | 6 +- docs/control-protocol.md | 1 + docs/gaps.md | 9 +- docs/scorecard.md | 2 +- docs/target-app-parity.md | 7 +- scorecard/tools.toml | 2 +- 36 files changed, 2750 insertions(+), 35 deletions(-) create mode 100644 crates/ui-egui/src/select_mask_ui.rs create mode 100644 crates/ui-egui/src/select_mask_ui_tests.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d2543a54c2..9e1cbcc223 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2887,9 +2887,13 @@ fn fit_doc_tabs(natural: &[f32], selected: usize, avail: f32) -> crate::tab_stri /// Photoshop's tab title: "name @ 50% (Layer 1, RGB/8)", "(Layer 1, Layer Mask/8)" when the mask /// is targeted, "(Quick Mask/8)" in Quick Mask mode; the Background layer's name is omitted. fn pro_tab_title(app: &PhotocraftApp, i: usize) -> String { + tab_title_at(app, i, app.ui.views.get(i).map_or(100.0, |v| v.zoom * 100.0)) +} + +/// [`pro_tab_title`] at `zoom` percent (a workspace with its own view, such as Select and Mask). +pub(crate) fn tab_title_at(app: &PhotocraftApp, i: usize, zoom: f32) -> String { use photocraft_engine::channel_cmds::ChannelTarget; let Some(st) = app.session.documents().get(i) else { return String::new() }; - let zoom = app.ui.views.get(i).map_or(100.0, |v| v.zoom * 100.0); // Quick Mask mode targets the mask, not the layer (see `paint_target`): painting and Free // Transform change the red overlay, so the tab says so, as Photoshop's does. let quick_mask = st.doc.quick_mask.is_some() && st.channel_view.target == ChannelTarget::Composite; diff --git a/crates/ui-egui/src/distort_ui.rs b/crates/ui-egui/src/distort_ui.rs index 5441a971ee..fd97fda70a 100644 --- a/crates/ui-egui/src/distort_ui.rs +++ b/crates/ui-egui/src/distort_ui.rs @@ -24,6 +24,8 @@ pub struct Distort { pub liquify: Option, /// Edit › Content-Aware Fill… (caf_ui). pub caf: Option, + /// Select › Select and Mask… (select_mask_ui). + pub select_mask: Option, pub puppet: Option, pub perspective: Option, pub vanishing: Option, @@ -35,7 +37,12 @@ pub struct Distort { impl Distort { pub fn active(&self) -> bool { - self.liquify.is_some() || self.caf.is_some() || self.puppet.is_some() || self.perspective.is_some() || self.vanishing.is_some() + self.liquify.is_some() + || self.caf.is_some() + || self.select_mask.is_some() + || self.puppet.is_some() + || self.perspective.is_some() + || self.vanishing.is_some() } /// Short description for `ui.inspect`. @@ -43,6 +50,7 @@ impl Distort { json!({ "liquify": self.liquify.as_ref().map(|d| d.describe()), "contentAwareFill": self.caf.as_ref().map(|d| d.describe()), + "selectAndMask": self.select_mask.as_ref().map(|d| d.describe()), "puppet": self.puppet.as_ref().map(|p| p.describe()), "perspective": self.perspective.as_ref().map(|p| p.describe()), "vanishingPoint": self.vanishing.as_ref().map(|v| v.describe()), @@ -117,6 +125,9 @@ pub fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Val "edit.contentAwareFill" if ui.is_some() && app.distort.caf.is_some() => Some(crate::caf_ui::control(app, ui.unwrap_or(&Value::Null))), // `ui` addresses the workspace: closed, that is an error, not a fill with the defaults. "edit.contentAwareFill" if ui.is_some() => Some(Err("Content-Aware Fill is not open".into())), + "select.selectAndMask" if ui.is_some() && app.distort.select_mask.is_some() => Some(crate::select_mask_ui::control(app, ui.unwrap_or(&Value::Null))), + "select.selectAndMask" if ui.is_some() => Some(Err("Select and Mask is not open".into())), + "select.selectAndMask" => Some(crate::select_mask_ui::open(app).map(|_| json!({"selectAndMask": app.distort.describe()["selectAndMask"]}))), "edit.puppetWarp" | "layer.smartObjects.puppetWarp" if empty => { Some(crate::puppet_ui::begin(app, ctx, id).map(|_| json!({"puppet": app.distort.describe()["puppet"]}))) } @@ -141,6 +152,10 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> crate::caf_ui::pointer(app, ev, mods); return true; } + if app.distort.select_mask.is_some() { + crate::select_mask_ui::pointer(app, ev, mods); + return true; + } if app.distort.liquify.is_some() { crate::liquify_ui::pointer(app, ev, mods); return true; @@ -182,6 +197,15 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { } return true; } + if app.distort.select_mask.is_some() { + // Enter closes a Polygonal Lasso polygon, else OK; Esc drops one, else Cancel. + if enter(ctx) { + crate::select_mask_ui::enter(app); + } else if esc(ctx) { + crate::select_mask_ui::escape(app); + } + return true; + } if app.distort.liquify.is_some() { if enter(ctx) { let _ = crate::liquify_ui::commit(app); @@ -285,6 +309,7 @@ pub fn drop_foreign(app: &mut PhotocraftApp) { app.distort.liquify = None; // Stops its background preview too; what Apply wrote stays, as with Cancel. crate::caf_ui::cancel(app); + crate::select_mask_ui::cancel(app); app.distort.puppet = None; app.distort.perspective = None; app.distort.vanishing = None; @@ -299,6 +324,9 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { if app.distort.caf.is_some() { crate::caf_ui::show(app, ctx); } + if app.distort.select_mask.is_some() { + crate::select_mask_ui::show(app, ctx); + } if app.distort.gallery.is_some() { crate::gallery_ui::show(app, ctx); } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 1e373d2792..23bcdee835 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4127,3 +4127,17 @@ cameraRaw Dehaze Odstranění oparu # Print preview image (#8236) Print preview image Print preview image + Color Aware Podle barev + Decontaminate Colors Dekontaminovat barvy + Edge Detection Detekce okraje + Global Refinements Globální upřesnění + High Quality Preview Náhled ve vysoké kvalitě + Object Aware Podle objektů + Real-Time Refinement Upřesnění v reálném čase + Refine Edge Brush Tool Nástroj štětec pro upřesnění okraje + Refine Hair Upřesnit vlasy + Refine Mode Režim upřesnění + Remember Settings Zapamatovat nastavení + View Mode Režim zobrazení + Object Aware needs a segmentation model, which isn't available yet Režim Podle objektů vyžaduje segmentační model, který zatím není k dispozici + Refine Hair needs hair detection, which isn't available yet Upřesnit vlasy vyžaduje detekci vlasů, která zatím není k dispozici diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 5152f361ef..178a641461 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3393,3 +3393,18 @@ curves Smooth Glätten # Print preview image (#8236) Print preview image Print preview image + Clear Selection Auswahl löschen + Color Aware Farbbezogen + Decontaminate Colors Farben dekontaminieren + Edge Detection Kantenerkennung + Global Refinements Globale Verbesserungen + High Quality Preview Hochwertige Vorschau + Object Aware Objektbezogen + Real-Time Refinement Verbesserung in Echtzeit + Refine Edge Brush Tool Kante-verbessern-Pinsel-Werkzeug + Refine Hair Haare verbessern + Refine Mode Verbesserungsmodus + Remember Settings Einstellungen merken + View Mode Ansichtsmodus + Object Aware needs a segmentation model, which isn't available yet Objektbezogen benötigt ein Segmentierungsmodell, das noch nicht verfügbar ist + Refine Hair needs hair detection, which isn't available yet Haare verbessern benötigt eine Haarerkennung, die noch nicht verfügbar ist diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index b72e1f8d2f..23fd2c3d7c 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4082,3 +4082,17 @@ curves Smooth Εξομάλυνση # Print preview image (#8236) Print preview image Print preview image + Color Aware Με βάση το χρώμα + Decontaminate Colors Απολύμανση χρωμάτων + Edge Detection Ανίχνευση άκρης + Global Refinements Καθολικές βελτιώσεις + High Quality Preview Προεπισκόπηση υψηλής ποιότητας + Object Aware Με βάση το αντικείμενο + Real-Time Refinement Βελτίωση σε πραγματικό χρόνο + Refine Edge Brush Tool Εργαλείο πινέλου βελτίωσης ακμής + Refine Hair Βελτίωση μαλλιών + Refine Mode Λειτουργία βελτίωσης + Remember Settings Απομνημόνευση ρυθμίσεων + View Mode Λειτουργία προβολής + Object Aware needs a segmentation model, which isn't available yet Η λειτουργία Με βάση το αντικείμενο χρειάζεται μοντέλο τμηματοποίησης, που δεν είναι ακόμη διαθέσιμο + Refine Hair needs hair detection, which isn't available yet Η Βελτίωση μαλλιών χρειάζεται ανίχνευση μαλλιών, που δεν είναι ακόμη διαθέσιμη diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 419dd0d452..f63f7b67bb 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4344,3 +4344,17 @@ navigator Zoom Zoom # Print preview image (#8236) Print preview image Print preview image + Color Aware Según color + Decontaminate Colors Descontaminar colores + Edge Detection Detección de bordes + Global Refinements Perfeccionamientos globales + High Quality Preview Previsualización de alta calidad + Object Aware Según objeto + Real-Time Refinement Perfeccionamiento en tiempo real + Refine Edge Brush Tool Herramienta Pincel para perfeccionar bordes + Refine Hair Perfeccionar pelo + Refine Mode Modo de perfeccionamiento + Remember Settings Recordar ajustes + View Mode Modo de visualización + Object Aware needs a segmentation model, which isn't available yet Según objeto necesita un modelo de segmentación que aún no está disponible + Refine Hair needs hair detection, which isn't available yet Perfeccionar pelo necesita detección de pelo, que aún no está disponible diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index c3a1b800ac..26eac9d2c9 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4424,3 +4424,17 @@ shape Appearance Apparence # Print preview image (#8236) Print preview image Print preview image + Color Aware Selon la couleur + Decontaminate Colors Décontaminer les couleurs + Edge Detection Détection des contours + Global Refinements Améliorations globales + High Quality Preview Aperçu haute qualité + Object Aware Selon l'objet + Real-Time Refinement Amélioration en temps réel + Refine Edge Brush Tool Outil Pinceau d'amélioration du contour + Refine Hair Améliorer les cheveux + Refine Mode Mode d'amélioration + Remember Settings Mémoriser les paramètres + View Mode Mode d'affichage + Object Aware needs a segmentation model, which isn't available yet Selon l'objet nécessite un modèle de segmentation, pas encore disponible + Refine Hair needs hair detection, which isn't available yet Améliorer les cheveux nécessite une détection des cheveux, pas encore disponible diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 34d749f777..131c5aa131 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -535,3 +535,18 @@ cameraRaw Yellow पीला Missing delete command हटाने का कमांड नहीं मिला Invalid delete command हटाने का कमांड अमान्य है Layer deleted, but confirmation preference could not be saved: {error} लेयर हटा दी गई, लेकिन पुष्टि की प्राथमिकता सहेजी नहीं जा सकी: {error} + Clear Selection चयन साफ़ करें + Color Aware रंग-आधारित + Decontaminate Colors रंग शुद्ध करें + Edge Detection किनारा पहचान + Global Refinements वैश्विक परिशोधन + High Quality Preview उच्च गुणवत्ता पूर्वावलोकन + Object Aware ऑब्जेक्ट-आधारित + Real-Time Refinement रियल-टाइम परिशोधन + Refine Edge Brush Tool किनारा परिशोधन ब्रश टूल + Refine Hair बाल परिशोधित करें + Refine Mode परिशोधन मोड + Remember Settings सेटिंग्स याद रखें + View Mode दृश्य मोड + Object Aware needs a segmentation model, which isn't available yet ऑब्जेक्ट-आधारित के लिए सेगमेंटेशन मॉडल चाहिए, जो अभी उपलब्ध नहीं है + Refine Hair needs hair detection, which isn't available yet बाल परिशोधित करने के लिए बालों की पहचान चाहिए, जो अभी उपलब्ध नहीं है diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 275de55bdb..e4c2ee2819 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4298,3 +4298,17 @@ cameraRaw Yellow Sárga # Print preview image (#8236) Print preview image Print preview image + Color Aware Színalapú + Decontaminate Colors Színek fertőtlenítése + Edge Detection Élészlelés + Global Refinements Globális finomítások + High Quality Preview Kiváló minőségű előnézet + Object Aware Objektumalapú + Real-Time Refinement Valós idejű finomítás + Refine Edge Brush Tool Élfinomító ecset eszköz + Refine Hair Haj finomítása + Refine Mode Finomítási mód + Remember Settings Beállítások megjegyzése + View Mode Nézetmód + Object Aware needs a segmentation model, which isn't available yet Az Objektumalapú módhoz szegmentálási modell kell, amely még nem érhető el + Refine Hair needs hair detection, which isn't available yet A Haj finomításához hajfelismerés kell, amely még nem érhető el diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 51ffe4d8b9..50df853ffa 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3396,3 +3396,18 @@ curves Smooth Haluskan # Print preview image (#8236) Print preview image Print preview image + Clear Selection Hapus Seleksi + Color Aware Berbasis Warna + Decontaminate Colors Dekontaminasi Warna + Edge Detection Deteksi Tepi + Global Refinements Penyempurnaan Global + High Quality Preview Pratinjau Kualitas Tinggi + Object Aware Berbasis Objek + Real-Time Refinement Penyempurnaan Waktu Nyata + Refine Edge Brush Tool Alat Kuas Perhalus Tepi + Refine Hair Perhalus Rambut + Refine Mode Mode Penyempurnaan + Remember Settings Ingat Pengaturan + View Mode Mode Tampilan + Object Aware needs a segmentation model, which isn't available yet Berbasis Objek memerlukan model segmentasi, yang belum tersedia + Refine Hair needs hair detection, which isn't available yet Perhalus Rambut memerlukan deteksi rambut, yang belum tersedia diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 3c65c5a25f..d92cd347ba 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4499,3 +4499,17 @@ shape Appearance Aspetto # Print preview image (#8236) Print preview image Print preview image + Color Aware In base al colore + Decontaminate Colors Decontamina colori + Edge Detection Rilevamento bordi + Global Refinements Perfezionamenti globali + High Quality Preview Anteprima di alta qualità + Object Aware In base all'oggetto + Real-Time Refinement Perfezionamento in tempo reale + Refine Edge Brush Tool Strumento Pennello per migliorare il bordo + Refine Hair Migliora capelli + Refine Mode Metodo di perfezionamento + Remember Settings Ricorda impostazioni + View Mode Modalità di visualizzazione + Object Aware needs a segmentation model, which isn't available yet In base all'oggetto richiede un modello di segmentazione, non ancora disponibile + Refine Hair needs hair detection, which isn't available yet Migliora capelli richiede il rilevamento dei capelli, non ancora disponibile diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c9018c00df..f12797d444 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4060,3 +4060,18 @@ curves Smooth スムーズ # Print preview image (#8236) Print preview image Print preview image + Clear Selection 選択範囲をクリア + Color Aware カラー認識 + Decontaminate Colors 不要なカラーの除去 + Edge Detection エッジの検出 + Global Refinements グローバル調整 + High Quality Preview 高品質プレビュー + Object Aware オブジェクト認識 + Real-Time Refinement リアルタイム調整 + Refine Edge Brush Tool 境界線調整ブラシツール + Refine Hair 髪の毛を調整 + Refine Mode 調整モード + Remember Settings 設定を保存 + View Mode 表示モード + Object Aware needs a segmentation model, which isn't available yet オブジェクト認識にはセグメンテーションモデルが必要ですが、まだ利用できません + Refine Hair needs hair detection, which isn't available yet 髪の毛を調整には髪の毛の検出が必要ですが、まだ利用できません diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index c70cf5fb1c..b5d2c04ede 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3591,3 +3591,18 @@ curves Smooth 매끄럽게 # Print preview image (#8236) Print preview image Print preview image + Clear Selection 선택 영역 지우기 + Color Aware 색상 인식 + Decontaminate Colors 색상 정화 + Edge Detection 가장자리 감지 + Global Refinements 전역 다듬기 + High Quality Preview 고품질 미리 보기 + Object Aware 개체 인식 + Real-Time Refinement 실시간 다듬기 + Refine Edge Brush Tool 가장자리 다듬기 브러시 도구 + Refine Hair 머리카락 다듬기 + Refine Mode 다듬기 모드 + Remember Settings 설정 기억 + View Mode 보기 모드 + Object Aware needs a segmentation model, which isn't available yet 개체 인식에는 분할 모델이 필요하지만 아직 사용할 수 없습니다 + Refine Hair needs hair detection, which isn't available yet 머리카락 다듬기에는 머리카락 감지가 필요하지만 아직 사용할 수 없습니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 20de061389..fe8cbe2a46 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4142,3 +4142,17 @@ curves Smooth Vloeiend maken # Print preview image (#8236) Print preview image Print preview image + Color Aware Kleurbewust + Decontaminate Colors Kleuren zuiveren + Edge Detection Randdetectie + Global Refinements Globale verfijningen + High Quality Preview Voorvertoning in hoge kwaliteit + Object Aware Objectbewust + Real-Time Refinement Verfijning in realtime + Refine Edge Brush Tool Gereedschap Penseel Rand verfijnen + Refine Hair Haar verfijnen + Refine Mode Verfijningsmodus + Remember Settings Instellingen onthouden + View Mode Weergavemodus + Object Aware needs a segmentation model, which isn't available yet Objectbewust vereist een segmentatiemodel dat nog niet beschikbaar is + Refine Hair needs hair detection, which isn't available yet Haar verfijnen vereist haardetectie die nog niet beschikbaar is diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index b66b543d74..9a6df5484a 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3998,3 +3998,18 @@ curves Smooth Wygładź # Print preview image (#8236) Print preview image Print preview image + Clear Selection Wyczyść zaznaczenie + Color Aware Według kolorów + Decontaminate Colors Odkażanie kolorów + Edge Detection Wykrywanie krawędzi + Global Refinements Dopracowanie globalne + High Quality Preview Podgląd wysokiej jakości + Object Aware Według obiektów + Real-Time Refinement Dopracowanie w czasie rzeczywistym + Refine Edge Brush Tool Narzędzie Pędzel dopracowania krawędzi + Refine Hair Dopracuj włosy + Refine Mode Tryb dopracowania + Remember Settings Zapamiętaj ustawienia + View Mode Tryb widoku + Object Aware needs a segmentation model, which isn't available yet Tryb Według obiektów wymaga modelu segmentacji, który nie jest jeszcze dostępny + Refine Hair needs hair detection, which isn't available yet Dopracowanie włosów wymaga wykrywania włosów, które nie jest jeszcze dostępne diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index e1195ea059..a76443d5f9 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4321,3 +4321,17 @@ layerLabel No Color Sem cor # Print preview image (#8236) Print preview image Print preview image + Color Aware Com reconhecimento de cor + Decontaminate Colors Descontaminar cores + Edge Detection Detecção de borda + Global Refinements Refinamentos globais + High Quality Preview Visualização de alta qualidade + Object Aware Com reconhecimento de objeto + Real-Time Refinement Refinamento em tempo real + Refine Edge Brush Tool Ferramenta Pincel de refinamento de borda + Refine Hair Refinar cabelo + Refine Mode Modo de refinamento + Remember Settings Lembrar configurações + View Mode Modo de exibição + Object Aware needs a segmentation model, which isn't available yet Com reconhecimento de objeto requer um modelo de segmentação, ainda não disponível + Refine Hair needs hair detection, which isn't available yet Refinar cabelo requer detecção de cabelo, ainda não disponível diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 66bc4d128e..dd4cd5eda8 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4587,3 +4587,17 @@ shape Appearance Оформление # Print preview image (#8236) Print preview image Print preview image + Color Aware С учетом цвета + Decontaminate Colors Очистить цвета + Edge Detection Обнаружение краев + Global Refinements Глобальное уточнение + High Quality Preview Высококачественный просмотр + Object Aware С учетом объектов + Real-Time Refinement Уточнение в реальном времени + Refine Edge Brush Tool Инструмент «Кисть уточнения края» + Refine Hair Уточнить волосы + Refine Mode Режим уточнения + Remember Settings Запомнить параметры + View Mode Режим просмотра + Object Aware needs a segmentation model, which isn't available yet Для режима «С учетом объектов» нужна модель сегментации, которая пока недоступна + Refine Hair needs hair detection, which isn't available yet Для уточнения волос нужно распознавание волос, которое пока недоступно diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index f071ee5399..925aec44f4 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4454,3 +4454,17 @@ shape Appearance Izgled # Print preview image (#8236) Print preview image Print preview image + Color Aware Prema boji + Decontaminate Colors Dekontaminiraj boje + Edge Detection Detekcija ivice + Global Refinements Globalna doterivanja + High Quality Preview Pregled visokog kvaliteta + Object Aware Prema objektu + Real-Time Refinement Doterivanje u realnom vremenu + Refine Edge Brush Tool Alatka četkica za doterivanje ivice + Refine Hair Doteraj kosu + Refine Mode Režim doterivanja + Remember Settings Zapamti podešavanja + View Mode Režim prikaza + Object Aware needs a segmentation model, which isn't available yet Režim Prema objektu zahteva model segmentacije, koji još nije dostupan + Refine Hair needs hair detection, which isn't available yet Doterivanje kose zahteva detekciju kose, koja još nije dostupna diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 90fe0c3eef..41100af634 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4112,3 +4112,17 @@ cameraData Lens Lens # Print preview image (#8236) Print preview image Print preview image + Color Aware Renge Duyarlı + Decontaminate Colors Renkleri Arındır + Edge Detection Kenar Algılama + Global Refinements Genel Hassaslaştırmalar + High Quality Preview Yüksek Kaliteli Önizleme + Object Aware Nesneye Duyarlı + Real-Time Refinement Gerçek Zamanlı Hassaslaştırma + Refine Edge Brush Tool Kenarı Hassaslaştırma Fırçası Aracı + Refine Hair Saçı Hassaslaştır + Refine Mode Hassaslaştırma Modu + Remember Settings Ayarları Hatırla + View Mode Görünüm Modu + Object Aware needs a segmentation model, which isn't available yet Nesneye Duyarlı mod henüz kullanılamayan bir bölütleme modeli gerektirir + Refine Hair needs hair detection, which isn't available yet Saçı Hassaslaştır henüz kullanılamayan saç algılama gerektirir diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 56ad5e718a..684dd72fb6 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4091,3 +4091,18 @@ curves Smooth Згладити # Print preview image (#8236) Print preview image Print preview image + Clear Selection Очистити виділення + Color Aware З урахуванням кольору + Decontaminate Colors Очистити кольори + Edge Detection Виявлення краю + Global Refinements Глобальне уточнення + High Quality Preview Високоякісний перегляд + Object Aware З урахуванням об'єктів + Real-Time Refinement Уточнення в реальному часі + Refine Edge Brush Tool Інструмент «Пензель уточнення краю» + Refine Hair Уточнити волосся + Refine Mode Режим уточнення + Remember Settings Запам'ятати параметри + View Mode Режим перегляду + Object Aware needs a segmentation model, which isn't available yet Для режиму «З урахуванням об'єктів» потрібна модель сегментації, яка ще недоступна + Refine Hair needs hair detection, which isn't available yet Для уточнення волосся потрібне розпізнавання волосся, яке ще недоступне diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 65f866e486..9968728ce2 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3874,3 +3874,18 @@ curves Smooth Làm mượt # Print preview image (#8236) Print preview image Print preview image + Clear Selection Xóa vùng chọn + Color Aware Nhận biết màu + Decontaminate Colors Khử nhiễm màu + Edge Detection Phát hiện cạnh + Global Refinements Tinh chỉnh tổng thể + High Quality Preview Xem trước chất lượng cao + Object Aware Nhận biết đối tượng + Real-Time Refinement Tinh chỉnh theo thời gian thực + Refine Edge Brush Tool Công cụ cọ tinh chỉnh cạnh + Refine Hair Tinh chỉnh tóc + Refine Mode Chế độ tinh chỉnh + Remember Settings Ghi nhớ cài đặt + View Mode Chế độ xem + Object Aware needs a segmentation model, which isn't available yet Nhận biết đối tượng cần mô hình phân đoạn, hiện chưa có + Refine Hair needs hair detection, which isn't available yet Tinh chỉnh tóc cần tính năng phát hiện tóc, hiện chưa có diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 16cd47edf8..edb6d3d3fd 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3041,3 +3041,18 @@ curves Smooth 平滑 # Print preview image (#8236) Print preview image Print preview image + Clear Selection 清除选区 + Color Aware 颜色识别 + Decontaminate Colors 净化颜色 + Edge Detection 边缘检测 + Global Refinements 全局调整 + High Quality Preview 高品质预览 + Object Aware 对象识别 + Real-Time Refinement 实时调整 + Refine Edge Brush Tool 调整边缘画笔工具 + Refine Hair 调整细线 + Refine Mode 调整模式 + Remember Settings 记住设置 + View Mode 视图模式 + Object Aware needs a segmentation model, which isn't available yet 对象识别需要分割模型,目前尚不可用 + Refine Hair needs hair detection, which isn't available yet 调整细线需要毛发检测,目前尚不可用 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 7350d24ba9..6bb4891e54 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3005,3 +3005,18 @@ curves Smooth 平滑 # Print preview image (#8236) Print preview image Print preview image + Clear Selection 清除選取範圍 + Color Aware 顏色感知 + Decontaminate Colors 淨化顏色 + Edge Detection 邊緣偵測 + Global Refinements 整體調整 + High Quality Preview 高品質預視 + Object Aware 物件感知 + Real-Time Refinement 即時調整 + Refine Edge Brush Tool 調整邊緣筆刷工具 + Refine Hair 調整毛髮 + Refine Mode 調整模式 + Remember Settings 記住設定 + View Mode 檢視模式 + Object Aware needs a segmentation model, which isn't available yet 物件感知需要分割模型,目前尚無法使用 + Refine Hair needs hair detection, which isn't available yet 調整毛髮需要毛髮偵測,目前尚無法使用 diff --git a/crates/ui-egui/src/layer_row_ui/edit_tests.rs b/crates/ui-egui/src/layer_row_ui/edit_tests.rs index 0c217dd3d0..65496cbe30 100644 --- a/crates/ui-egui/src/layer_row_ui/edit_tests.rs +++ b/crates/ui-egui/src/layer_row_ui/edit_tests.rs @@ -153,8 +153,8 @@ fn mask_double_click_launches_select_and_mask_with_preference() { let at = masks.iter().find(|(kind, _)| *kind == crate::mask_thumbs_ui::MaskKind::Pixel).unwrap().1.center(); double_click(&mut h, at); assert!(h.state().ui.mask_target); - let dialog = h.state().ui.dialogs.last().expect("select and mask dialog should be opened"); - assert_eq!(dialog.fields.get("__command").and_then(|v| v.as_str()), Some("select.refineEdge")); + let w = h.state().distort.describe()["selectAndMask"].clone(); + assert_eq!(w["settings"]["output"].as_str(), Some("layerMask"), "the Select and Mask workspace opens on the mask: {w}"); } #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6ea5992d43..96f0cf75f6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -194,6 +194,7 @@ pub mod rulers; pub mod screen_picker; pub mod scrollbars; mod scrub_wrap; +pub mod select_mask_ui; pub mod select_mask_view; pub mod served_fonts; pub mod shape_dialog; diff --git a/crates/ui-egui/src/mask_props_ui_tests.rs b/crates/ui-egui/src/mask_props_ui_tests.rs index 782337ed36..0fcc9de195 100644 --- a/crates/ui-egui/src/mask_props_ui_tests.rs +++ b/crates/ui-egui/src/mask_props_ui_tests.rs @@ -293,21 +293,22 @@ fn refine_invert_inverts_the_mask_and_leaves_the_layer_alone() { } #[test] -fn refine_row_opens_the_select_and_mask_and_color_range_dialogs() { - for (label, open) in [("Select and Mask…", "select.refineEdge"), ("Color Range…", crate::color_range_ui::COMMAND)] { - let mut h = harness(false, crate::theme::ThemeKind::Pro); - assert!(h.state().ui.dialogs.is_empty()); - h.get_by_label(label).click(); - h.run_steps(3); - let dialogs = &h.state().ui.dialogs; - assert_eq!(dialogs.len(), 1, "{label} opens one dialog"); - let fields = &dialogs[0].fields; - let opened = fields.get("__command").and_then(Value::as_str).unwrap_or(crate::color_range_ui::COMMAND); - assert_eq!(opened, open, "{label} opened {opened}"); - if open == crate::color_range_ui::COMMAND { - assert!(crate::color_range_ui::owns(fields), "the Color Range dialog, not the generic one"); - } - } +fn refine_row_opens_the_select_and_mask_workspace_and_color_range_dialog() { + // Select and Mask… opens Photoshop's workspace on the targeted mask. + let mut h = harness(false, crate::theme::ThemeKind::Pro); + h.get_by_label("Select and Mask…").click(); + h.run_steps(3); + assert!(h.state().ui.dialogs.is_empty(), "a workspace, not a dialog"); + let w = h.state().distort.describe()["selectAndMask"].clone(); + assert_eq!(w["settings"]["output"].as_str(), Some("layerMask"), "it refines the mask: {w}"); + // Color Range… opens its own dialog. + let mut h = harness(false, crate::theme::ThemeKind::Pro); + assert!(h.state().ui.dialogs.is_empty()); + h.get_by_label("Color Range…").click(); + h.run_steps(3); + let dialogs = &h.state().ui.dialogs; + assert_eq!(dialogs.len(), 1, "Color Range… opens one dialog"); + assert!(crate::color_range_ui::owns(&dialogs[0].fields), "the Color Range dialog, not the generic one"); } /// A Studio-layout theme's floating Properties card, with no mask targeted. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 57b15fb8ad..f27e5bb634 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -492,7 +492,8 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: } Ok(json!({"dialog": crate::fill_ui::open(app)})) } - "select.selectAndMask" => Ok(json!({"dialog": crate::filter_dialog::open(app, "select.refineEdge")})), + // Photoshop's workspace (select_mask_ui); `distort_ui::menu` routes it first. + "select.selectAndMask" => crate::select_mask_ui::open(app).map(|_| json!({"selectAndMask": app.distort.describe()["selectAndMask"]})), // Select › Transform Selection from the menu: the interactive box (with params: the engine). "select.transformSelection" if params.as_object().is_none_or(|o| o.is_empty()) => { crate::transform_tool::begin_selection(app, ctx).map(|_| json!({"transform": app.ui.transform})) diff --git a/crates/ui-egui/src/select_mask_ui.rs b/crates/ui-egui/src/select_mask_ui.rs new file mode 100644 index 0000000000..baf06a4a9f --- /dev/null +++ b/crates/ui-egui/src/select_mask_ui.rs @@ -0,0 +1,1776 @@ +//! Select › Select and Mask…: Photoshop's Select and Mask workspace (#7069, #8148, TOOL-214-1), a +//! full-window workspace like Content-Aware Fill's. +//! +//! Laid out as Photoshop's: the tool's options bar across the top, the tools on the left (Quick +//! Selection W, Refine Edge Brush R, Brush B, Object Selection W, Lasso and Polygonal Lasso L in +//! one slot, Hand H, Zoom Z; Shift+W and Shift+L cycle), the document under its tab in the middle, +//! shown in the chosen view, and the Properties panel on the right: Remember Settings, View Mode +//! (the views with their letters, Show Edge J, Show Original P, Real-Time Refinement, High Quality +//! Preview, Transparency or Opacity, Overlay's colour and what it indicates), Refine Mode, Edge +//! Detection, Global Refinements (with Clear Selection and Invert) and Output Settings, then +//! Reset, OK and Cancel. The view keys (O M V A T K Y, F cycles, hold X for the image) work as in +//! the dialog this replaces. +//! +//! What the workspace holds is plain data: the tools' edits (`select.refineEdge`'s `edits`), the +//! Refine Edge Brush strokes (`refineStrokes`) and the settings. OK runs `select.refineEdge` with +//! them, one history step; Cancel drops them; Ctrl+Z undoes the last edit or stroke. The preview +//! runs the same edits ([`photocraft_engine::select_mask_edits`]) on a reduced copy (full size +//! with High Quality Preview) in the background and keeps the selection after the edits it has +//! applied, so a new stroke costs only that stroke; then it shows the refined selection in the +//! view ([`crate::select_mask_view::preview_proxy`]). With Real-Time Refinement the stroke being +//! drawn joins the preview while the pointer moves; without, when it is released. +//! +//! Not here yet: Refine Hair (#2671) and Object Aware (a learned model, #2788) are shown but greyed +//! out, and Photoshop's Presets. + +use std::sync::Arc; + +use egui::{Align2, Color32, FontId, Pos2, Rect as ERect, Sense, Stroke, TextureHandle, pos2, vec2}; +use photocraft_doc::{Document, LayerId}; +use photocraft_engine::select_mask_edits::{self, MAX_EDIT_POINTS, MAX_EDITS}; +use photocraft_engine::smartselect_cmds::{MAX_STROKE_POINTS, MAX_STROKES}; +use photocraft_geom::Rect; +use photocraft_raster::Surface; +use serde_json::{Map, Value, json}; + +use crate::PhotocraftApp; +use crate::canvas::ToolEvent; +use crate::select_mask_view::Mode; +use crate::theme::Tokens; +use crate::widgets; + +/// Longest side of the reduced copy the preview runs on (px), unless High Quality Preview. +const PROXY_SIDE: usize = 1600; +const LEFT_W: f32 = 48.0; +const BAR_H: f32 = 36.0; +const TAB_H: f32 = 28.0; +const PANEL_W: f32 = 350.0; +const UNDO_LIMIT: usize = 100; +/// Marching ants with more edges than this are not drawn. +const MAX_ANT_EDGES: usize = 200_000; +/// The `prefs.dialogs` key under which Remember Settings keeps the settings. +const REMEMBERED: &str = "select.refineEdge.workspace"; +const CMD: &str = crate::select_mask_view::COMMAND; +/// The Output To targets, in Photoshop's order. +const OUTPUTS: [&str; 6] = ["selection", "layerMask", "newLayer", "newLayerWithMask", "newDocument", "newDocumentWithMask"]; +/// Photoshop's brush sizes (px). +const SIZES: std::ops::RangeInclusive = 1.0..=5000.0; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SmTool { + Quick, + RefineEdge, + Brush, + Object, + Lasso, + PolygonalLasso, + Hand, + Zoom, +} + +impl SmTool { + fn name(self) -> &'static str { + match self { + SmTool::Quick => "quick", + SmTool::RefineEdge => "refineEdge", + SmTool::Brush => "brush", + SmTool::Object => "object", + SmTool::Lasso => "lasso", + SmTool::PolygonalLasso => "polygonalLasso", + SmTool::Hand => "hand", + SmTool::Zoom => "zoom", + } + } + + fn from_name(s: &str) -> Option { + Some(match s { + "quick" => SmTool::Quick, + "refineEdge" => SmTool::RefineEdge, + "brush" => SmTool::Brush, + "object" => SmTool::Object, + "lasso" => SmTool::Lasso, + "polygonalLasso" => SmTool::PolygonalLasso, + "hand" => SmTool::Hand, + "zoom" => SmTool::Zoom, + _ => return None, + }) + } + + fn icon(self) -> &'static str { + match self { + SmTool::Quick => "quick-selection", + SmTool::RefineEdge => "brush-cleaning", + SmTool::Brush => "brush", + SmTool::Object => "square-dashed-mouse-pointer", + SmTool::Lasso => "lasso", + SmTool::PolygonalLasso => "pentagon", + SmTool::Hand => "hand", + SmTool::Zoom => "zoom-in", + } + } + + fn tip(self) -> String { + let (label, key) = match self { + SmTool::Quick => (tl!("Quick Selection Tool"), "W"), + SmTool::RefineEdge => (tl!("Refine Edge Brush Tool"), "R"), + SmTool::Brush => (tl!("Brush Tool"), "B"), + SmTool::Object => (tl!("Object Selection Tool"), "W"), + SmTool::Lasso => (tl!("Lasso Tool"), "L"), + SmTool::PolygonalLasso => (tl!("Polygonal Lasso Tool"), "L"), + SmTool::Hand => (tl!("Hand Tool"), "H"), + SmTool::Zoom => (tl!("Zoom Tool"), "Z"), + }; + format!("{label} ({key})") + } + + /// Paints with a round brush (size from `[` and `]`). + fn brushes(self) -> bool { + matches!(self, SmTool::Quick | SmTool::RefineEdge | SmTool::Brush) + } + + /// Adds to or subtracts from the selection (the options bar's + and −; Alt swaps them). + fn edits(self) -> bool { + !matches!(self, SmTool::Hand | SmTool::Zoom) + } +} + +/// The Properties panel's settings (kept between uses with Remember Settings). +#[derive(Clone, Debug, PartialEq)] +pub struct SmOpts { + pub view: Mode, + /// Each view's Transparency / Opacity (percent), in [`Mode::ALL`] order. + pub opacity: [f32; 7], + pub show_edge: bool, + pub show_original: bool, + /// Real-Time Refinement: the stroke being drawn joins the preview while the pointer moves. + pub real_time: bool, + /// High Quality Preview: the preview runs on the document at full size. + pub high_quality: bool, + /// Overlay's colour and whether it marks the selected areas (else the masked ones). + pub overlay_color: [u8; 3], + pub indicates_selected: bool, + pub radius: f32, + pub smart_radius: bool, + pub smooth: f32, + pub feather: f32, + pub contrast: f32, + pub shift_edge: f32, + pub decontaminate: bool, + pub amount: f32, + pub output: String, + pub remember: bool, +} + +impl Default for SmOpts { + fn default() -> Self { + SmOpts { + view: Mode::OnionSkin, + opacity: Mode::ALL.map(|m| m.default_opacity().unwrap_or(100.0)), + show_edge: false, + show_original: false, + real_time: true, + high_quality: false, + overlay_color: [255, 0, 0], + indicates_selected: false, + radius: 0.0, + smart_radius: false, + smooth: 0.0, + feather: 0.0, + contrast: 0.0, + shift_edge: 0.0, + decontaminate: false, + amount: 100.0, + output: "selection".into(), + remember: false, + } + } +} + +fn mode_index(m: Mode) -> usize { + Mode::ALL.iter().position(|x| *x == m).unwrap_or(0) +} + +impl SmOpts { + /// Applies the settings named in `v` (control channel and remembered keys); others stay. + pub fn apply(&mut self, v: &Value) -> Result<(), String> { + let flag = |k: &str| v.get(k).and_then(Value::as_bool); + for (k, slot) in [ + ("showEdge", &mut self.show_edge), + ("showOriginal", &mut self.show_original), + ("realTimeRefinement", &mut self.real_time), + ("highQualityPreview", &mut self.high_quality), + ("smartRadius", &mut self.smart_radius), + ("decontaminate", &mut self.decontaminate), + ("remember", &mut self.remember), + ] { + if let Some(b) = flag(k) { + *slot = b; + } + } + for (k, slot, lo, hi) in [ + ("radius", &mut self.radius, 0.0, 250.0), + ("smooth", &mut self.smooth, 0.0, 100.0), + ("feather", &mut self.feather, 0.0, 1000.0), + ("contrast", &mut self.contrast, 0.0, 100.0), + ("shiftEdge", &mut self.shift_edge, -100.0, 100.0), + ("amount", &mut self.amount, 0.0, 100.0), + ] { + if let Some(n) = v.get(k).and_then(Value::as_f64).filter(|n| n.is_finite()) { + *slot = (n as f32).clamp(lo, hi); + } + } + if let Some(m) = v.get("view").and_then(Value::as_str) { + self.view = Mode::from_id(m).ok_or_else(|| format!("bad view `{m}` ({})", Mode::ALL.map(|m| m.id()).join("|")))?; + } + match v.get("opacity") { + // A number: the current view's; an object: per view. + Some(Value::Number(n)) => { + if let Some(n) = n.as_f64().filter(|n| n.is_finite()) { + self.opacity[mode_index(self.view)] = (n as f32).clamp(0.0, 100.0); + } + } + Some(Value::Object(o)) => { + for (k, n) in o { + if let (Some(m), Some(n)) = (Mode::from_id(k), n.as_f64().filter(|n| n.is_finite())) { + self.opacity[mode_index(m)] = (n as f32).clamp(0.0, 100.0); + } + } + } + _ => {} + } + if let Some(c) = v.get("overlayColor").and_then(Value::as_array).filter(|c| c.len() == 3) { + let ch = |i: usize| c.get(i).and_then(Value::as_u64).map(|v| v.min(255) as u8); + if let (Some(r), Some(g), Some(b)) = (ch(0), ch(1), ch(2)) { + self.overlay_color = [r, g, b]; + } + } + match v.get("indicates").and_then(Value::as_str) { + None => {} + Some("masked") => self.indicates_selected = false, + Some("selected") => self.indicates_selected = true, + Some(s) => return Err(format!("bad indicates `{s}` (masked|selected)")), + } + if let Some(o) = v.get("output").and_then(Value::as_str) { + if !OUTPUTS.contains(&o) { + return Err(format!("bad output `{o}` ({})", OUTPUTS.join("|"))); + } + self.output = o.to_string(); + } + self.follow_decontaminate(); + Ok(()) + } + + /// Decontaminating needs new pixels, so (as in Photoshop) it outputs to a new layer or document. + fn follow_decontaminate(&mut self) { + if self.decontaminate && matches!(self.output.as_str(), "selection" | "layerMask") { + self.output = "newLayerWithMask".into(); + } + } + + /// The settings as [`SmOpts::apply`] reads them. + pub fn to_json(&self) -> Value { + let opacity: Map = Mode::ALL.iter().zip(self.opacity).map(|(m, o)| (m.id().to_string(), json!(o))).collect(); + json!({ + "view": self.view.id(), + "opacity": opacity, + "showEdge": self.show_edge, + "showOriginal": self.show_original, + "realTimeRefinement": self.real_time, + "highQualityPreview": self.high_quality, + "overlayColor": self.overlay_color, + "indicates": if self.indicates_selected { "selected" } else { "masked" }, + "radius": self.radius, + "smartRadius": self.smart_radius, + "smooth": self.smooth, + "feather": self.feather, + "contrast": self.contrast, + "shiftEdge": self.shift_edge, + "decontaminate": self.decontaminate, + "amount": self.amount, + "output": self.output, + "remember": self.remember, + }) + } + + /// `select.refineEdge`'s refinement and output params. + fn refine_params(&self) -> Map { + let mut p = Map::new(); + p.insert("radius".into(), json!(self.radius)); + p.insert("smartRadius".into(), json!(self.smart_radius)); + p.insert("smooth".into(), json!(self.smooth)); + p.insert("feather".into(), json!(self.feather)); + p.insert("contrast".into(), json!(self.contrast)); + p.insert("shiftEdge".into(), json!(self.shift_edge)); + p.insert("decontaminate".into(), json!(self.decontaminate)); + p.insert("amount".into(), json!(self.amount)); + p.insert("output".into(), json!(self.output)); + p + } + + /// The preview's view (`select_mask_view`'s `__view`). + fn view_params(&self) -> Value { + let c = self.overlay_color.map(|v| f32::from(v) / 255.0); + json!({ + "mode": self.view.id(), + "opacity": self.opacity[mode_index(self.view)] / 100.0, + "showEdge": self.show_edge, + "showOriginal": self.show_original, + "color": c, + "indicates": if self.indicates_selected { "selected" } else { "masked" }, + }) + } +} + +/// One undoable step. +#[derive(Clone, Debug)] +enum Step { + Edit(Value), + Stroke(Value), + /// A reset, with what it replaced. + State { + edits: Vec, + strokes: Vec, + opts: Box, + }, +} + +/// A press-and-drag on the view with a tool, in document px. +#[derive(Clone, Debug)] +struct Gesture { + tool: SmTool, + add: bool, + points: Vec<[f64; 2]>, +} + +/// The selection after the first `edits` (document coordinates) on the copy reduced by `k`. +#[derive(Clone)] +struct BaseCache { + k: u32, + edits: Vec, + base: Option, +} + +/// A computed preview, ready to upload. +struct Shown { + image: egui::ColorImage, + /// Marching ants (document px) in the Marching Ants view. + ants: Option>, + /// Reduced-copy pixels at least half selected. + selected: usize, + /// How many points of the stroke being drawn it includes ([`usize::MAX`]: every stroke is in). + trail: usize, + /// The document px the image covers (the reduced copy's size × `k`). + covers: [f32; 2], +} + +#[cfg(not(target_arch = "wasm32"))] +type Outcome = Result<(Shown, BaseCache, Arc), String>; + +/// The preview being computed or shown. +#[derive(Default)] +struct Preview { + /// Bumped whenever the state changes; a result for an older generation is dropped. + want: u64, + #[cfg(not(target_arch = "wasm32"))] + running: Option<(u64, u64, std::sync::mpsc::Receiver, photocraft_engine::jobs::JobCtx)>, + shown: u64, + pending: Option, + tex: Option, + ants: Option>, + covers: [f32; 2], + selected: usize, + /// [`Shown::trail`] of the preview shown. + trail: usize, + cache: Option, + /// The reduced copy of the document (its factor and the revision it was made from). + proxy: Option<(u32, u64, Arc)>, + error: Option, +} + +pub struct SelectMaskWorkspace { + layer: Option, + doc_index: usize, + canvas: Rect, + /// The document revision the textures were made from. + revision: u64, + /// The targeted layer mask is what is refined (`"target": "mask"`). + target_mask: bool, + pub opts: SmOpts, + pub tool: SmTool, + /// The tools W and L pick: the last of each slot used. + w_tool: SmTool, + l_tool: SmTool, + pub add: bool, + pub quick_size: f64, + pub refine_size: f64, + pub brush_size: f64, + pub hardness: f32, + pub sample_all: bool, + pub edits: Vec, + pub strokes: Vec, + undo: Vec, + redo: Vec, + gesture: Option, + /// The stroke last drawn, until a preview with it arrives (its newest part is drawn over the + /// preview meanwhile, so painting looks continuous). + trail: Option, + /// The Polygonal Lasso's vertices so far, and whether it adds. + poly: Option<(bool, Vec<[f64; 2]>)>, + /// X is held: the image without the view. + hide_view: bool, + /// Document px per reduced-copy px (High Quality Preview uses 1). + k: u32, + pressed: bool, + /// The device's texture side limit, as the context reports it each frame. + texture_limit: usize, + image_tex: Option, + zoom: f32, + center: [f64; 2], + hover: Option<[f64; 2]>, + preview: Preview, + pub error: Option, +} + +impl SelectMaskWorkspace { + pub fn describe(&self) -> Value { + json!({ + "tool": self.tool.name(), + "mode": if self.add { "add" } else { "subtract" }, + "size": self.size(), + "hardness": self.hardness, + "sampleAllLayers": self.sample_all, + "edits": self.edits.len(), + "strokes": self.strokes.len(), + "undo": self.undo.len(), + "redo": self.redo.len(), + "polygon": self.poly.as_ref().map_or(0, |(_, p)| p.len()), + "settings": self.opts.to_json(), + "proxyScale": self.proxy_k(), + "selectedProxyPixels": self.preview.selected, + "previewReady": (self.preview.tex.is_some() || self.preview.pending.is_some()) && self.preview.shown == self.preview.want, + "previewError": self.preview.error, + "error": self.error, + }) + } + + /// The factor the preview runs at. + fn proxy_k(&self) -> u32 { + if self.opts.high_quality { 1 } else { self.k } + } + + /// The current brush tool's size (Quick Selection, Refine Edge Brush, Brush; else the brush's). + fn size(&self) -> f64 { + match self.tool { + SmTool::Quick => self.quick_size, + SmTool::RefineEdge => self.refine_size, + _ => self.brush_size, + } + } + + fn set_size(&mut self, v: f64) { + let v = if v.is_finite() { v.round().clamp(*SIZES.start(), *SIZES.end()) } else { return }; + match self.tool { + SmTool::Quick => self.quick_size = v, + SmTool::RefineEdge => self.refine_size = v, + SmTool::Brush => self.brush_size = v, + _ => {} + } + } + + fn pick(&mut self, t: SmTool) { + self.tool = t; + match t { + SmTool::Quick | SmTool::Object => self.w_tool = t, + SmTool::Lasso | SmTool::PolygonalLasso => self.l_tool = t, + _ => {} + } + if t != SmTool::PolygonalLasso { + self.poly = None; + } + } + + /// The preview is out of date. + fn stale(&mut self) { + self.preview.want += 1; + } + + fn push(&mut self, s: Step) { + self.redo.clear(); + if self.undo.len() == UNDO_LIMIT { + self.undo.remove(0); + } + self.undo.push(s); + } + + fn points_in(list: &[Value]) -> usize { + list.iter().map(|e| e.get("points").and_then(Value::as_array).map_or(0, Vec::len)).sum() + } + + /// Adds a tool edit (undoable), within the engine's limits. + fn add_edit(&mut self, e: Value) { + if self.edits.len() >= MAX_EDITS { + self.error = Some(format!("at most {MAX_EDITS} edits: undo some, or reset")); + return; + } + if Self::points_in(&self.edits) + Self::points_in(std::slice::from_ref(&e)) > MAX_EDIT_POINTS { + self.error = Some(format!("at most {MAX_EDIT_POINTS} points in all edits: undo some, or reset")); + return; + } + self.error = None; + self.edits.push(e.clone()); + self.push(Step::Edit(e)); + self.stale(); + } + + /// Adds a Refine Edge Brush stroke (undoable), within the engine's limits. + fn add_stroke(&mut self, s: Value) { + if self.strokes.len() >= MAX_STROKES { + self.error = Some(format!("at most {MAX_STROKES} Refine Edge Brush strokes: undo some, or reset")); + return; + } + if Self::points_in(&self.strokes) + Self::points_in(std::slice::from_ref(&s)) > MAX_STROKE_POINTS { + self.error = Some(format!("at most {MAX_STROKE_POINTS} points in all Refine Edge Brush strokes: undo some, or reset")); + return; + } + self.error = None; + self.strokes.push(s.clone()); + self.push(Step::Stroke(s)); + self.stale(); + } + + /// What `g` adds when it ends: a tool edit or a Refine Edge Brush stroke (`None`: nothing). + fn finish(&self, g: &Gesture) -> Option> { + let round = |p: &[f64; 2]| json!([(p[0] * 10.0).round() / 10.0, (p[1] * 10.0).round() / 10.0]); + let pts: Vec = g.points.iter().map(round).collect(); + let mode = if g.add { "add" } else { "subtract" }; + let first = g.points.first()?; + Some(match g.tool { + SmTool::Quick => Ok(json!({"tool": "quick", "points": pts, "size": self.quick_size, "mode": mode, "sampleAllLayers": self.sample_all})), + SmTool::Brush => Ok(json!({"tool": "brush", "points": pts, "size": self.brush_size, "hardness": self.hardness, "mode": mode})), + SmTool::RefineEdge => Err(json!({"points": pts, "size": self.refine_size, "mode": mode})), + SmTool::Lasso | SmTool::PolygonalLasso if pts.len() >= 3 => Ok(json!({"tool": "lasso", "points": pts, "mode": mode})), + SmTool::Object => { + let last = g.points.last().unwrap_or(first); + let (w, h) = (last[0] - first[0], last[1] - first[1]); + // A click (or a drag of a couple of pixels) picks the object under the pointer. + if w.abs().max(h.abs()) * f64::from(self.zoom.max(0.01)) < 4.0 { + Ok(json!({"tool": "object", "point": round(first), "mode": mode, "sampleAllLayers": self.sample_all})) + } else { + let r = |v: f64| (v * 10.0).round() / 10.0; + Ok(json!({"tool": "object", "rect": [r(first[0]), r(first[1]), r(w), r(h)], "mode": mode, "sampleAllLayers": self.sample_all})) + } + } + _ => return None, + }) + } + + fn end_gesture(&mut self) { + let Some(g) = self.gesture.take() else { return }; + self.trail = Some(g.clone()); + match self.finish(&g) { + Some(Ok(e)) => self.add_edit(e), + Some(Err(s)) => self.add_stroke(s), + None => self.stale(), + } + } + + /// The Polygonal Lasso's polygon closes into an edit. + fn close_polygon(&mut self) { + if let Some((add, points)) = self.poly.take() + && points.len() >= 3 + { + self.gesture = Some(Gesture { tool: SmTool::PolygonalLasso, add, points }); + self.end_gesture(); + } + } + + fn begin(&mut self, p: [f64; 2], alt: bool) { + let add = self.add != alt; + match self.tool { + SmTool::PolygonalLasso => { + let near = |q: &[f64; 2]| (q[0] - p[0]).hypot(q[1] - p[1]) * f64::from(self.zoom.max(0.01)) < 6.0; + match &mut self.poly { + // Clicking the first vertex again closes the polygon. + Some((_, pts)) if pts.len() >= 3 && pts.first().is_some_and(near) => self.close_polygon(), + Some((_, pts)) => pts.push(p), + None => self.poly = Some((add, vec![p])), + } + } + t if t.edits() => { + self.gesture = Some(Gesture { tool: t, add, points: vec![p] }); + self.trail = None; + self.preview.trail = 0; + } + _ => {} + } + } + + fn extend(&mut self, p: [f64; 2]) { + let step = if self.tool.brushes() { (self.size() / 8.0).max(1.0) } else { 0.5 }; + let real_time = self.opts.real_time; + let Some(g) = self.gesture.as_mut() else { return }; + match g.tool { + // Object Selection keeps the press and the current corner. + SmTool::Object => { + g.points.truncate(1); + g.points.push(p); + } + _ => { + if g.points.last().is_some_and(|l| (l[0] - p[0]).hypot(l[1] - p[1]) < step) { + return; + } + g.points.push(p); + } + } + if real_time { + self.stale(); + } + } +} + +/// Undo the last edit, stroke or reset. +fn undo(w: &mut SelectMaskWorkspace) { + w.end_gesture(); + w.poly = None; + let Some(step) = w.undo.pop() else { return }; + let back = match step { + Step::Edit(e) => { + w.edits.pop(); + Step::Edit(e) + } + Step::Stroke(s) => { + w.strokes.pop(); + Step::Stroke(s) + } + Step::State { edits, strokes, opts } => { + let now = Step::State { + edits: std::mem::replace(&mut w.edits, edits), + strokes: std::mem::replace(&mut w.strokes, strokes), + opts: Box::new(w.opts.clone()), + }; + w.opts = *opts; + now + } + }; + w.redo.push(back); + w.error = None; + w.stale(); +} + +fn redo(w: &mut SelectMaskWorkspace) { + w.end_gesture(); + let Some(step) = w.redo.pop() else { return }; + let back = match step { + Step::Edit(e) => { + w.edits.push(e.clone()); + Step::Edit(e) + } + Step::Stroke(s) => { + w.strokes.push(s.clone()); + Step::Stroke(s) + } + Step::State { edits, strokes, opts } => { + let now = Step::State { + edits: std::mem::replace(&mut w.edits, edits), + strokes: std::mem::replace(&mut w.strokes, strokes), + opts: Box::new(w.opts.clone()), + }; + w.opts = *opts; + now + } + }; + w.undo.push(back); + w.error = None; + w.stale(); +} + +/// Reset (the panel's ↺): the settings back to Photoshop's defaults and the edits and strokes +/// dropped, as one undoable step. +fn reset(w: &mut SelectMaskWorkspace) { + w.end_gesture(); + w.poly = None; + let opts = SmOpts { remember: w.opts.remember, ..SmOpts::default() }; + let prev = + Step::State { edits: std::mem::take(&mut w.edits), strokes: std::mem::take(&mut w.strokes), opts: Box::new(std::mem::replace(&mut w.opts, opts)) }; + w.push(prev); + w.error = None; + w.stale(); +} + +/// Opens the workspace on the selection (or the active layer's mask). +pub fn open(app: &mut PhotocraftApp) -> Result<(), String> { + if let Some(Err(why)) = photocraft_engine::commands::find(CMD).map(|c| (c.enabled)(&app.session)) { + return Err(format!("Select and Mask needs a selection or a layer mask ({why})")); + } + let doc_index = app.session.active_index().ok_or("no document")?; + // A targeted layer mask is what is refined (Photoshop opens on it from the mask's thumbnail). + let target_mask = app.layer_mask_targeted(); + let st = app.session.active().ok_or("no document")?; + let layer = st.active_layer.filter(|id| st.doc.layer(*id).is_some()); + let canvas = st.doc.bounds(); + let revision = st.revision; + let mask_only = st.doc.selection.is_none() && layer.and_then(|id| st.doc.layer(id)).is_some_and(|l| l.mask.is_some()); + let k = u32::try_from((canvas.width().max(canvas.height()) as usize).div_ceil(PROXY_SIDE).max(1)).unwrap_or(u32::MAX); + let mut opts = SmOpts::default(); + match app.session.prefs().dialogs.get(REMEMBERED) { + Some(saved) if saved.get("remember").and_then(Value::as_bool) == Some(true) => { + let _ = opts.apply(saved); + } + _ if mask_only || target_mask => opts.output = "layerMask".into(), + _ => {} + } + let mut w = SelectMaskWorkspace { + layer, + doc_index, + canvas, + revision, + target_mask, + opts, + tool: SmTool::Quick, + w_tool: SmTool::Quick, + l_tool: SmTool::Lasso, + add: true, + quick_size: 30.0, + refine_size: 30.0, + brush_size: 30.0, + hardness: 100.0, + sample_all: false, + edits: Vec::new(), + strokes: Vec::new(), + undo: Vec::new(), + redo: Vec::new(), + gesture: None, + trail: None, + poly: None, + hide_view: false, + k, + pressed: false, + texture_limit: 2048, + image_tex: None, + zoom: 0.0, + center: [f64::from(canvas.x0 + canvas.x1) / 2.0, f64::from(canvas.y0 + canvas.y1) / 2.0], + hover: None, + preview: Preview::default(), + error: None, + }; + w.stale(); + app.distort.select_mask = Some(w); + Ok(()) +} + +/// The params OK runs `select.refineEdge` with. +fn commit_params(w: &SelectMaskWorkspace) -> Value { + let mut p = w.opts.refine_params(); + p.insert("sampleAllLayers".into(), json!(w.sample_all)); + if !w.edits.is_empty() { + p.insert("edits".into(), Value::Array(w.edits.clone())); + } + if !w.strokes.is_empty() { + p.insert("refineStrokes".into(), Value::Array(w.strokes.clone())); + } + if w.target_mask { + p.insert("target".into(), json!("mask")); + } + Value::Object(p) +} + +fn remember(app: &mut PhotocraftApp, opts: &SmOpts) { + let v = if opts.remember { opts.to_json() } else { json!({"remember": false}) }; + app.session.prefs.edit(|p| p.dialogs.insert(REMEMBERED.into(), v)); +} + +/// OK: runs `select.refineEdge` and closes. A failure leaves the workspace open with the error in +/// its panel, so nothing painted is lost. +pub fn commit(app: &mut PhotocraftApp) -> Result { + let w = app.distort.select_mask.as_mut().ok_or("Select and Mask is not open")?; + w.end_gesture(); + w.close_polygon(); + let (p, opts) = (commit_params(w), w.opts.clone()); + match app.run(CMD, p) { + Ok(v) => { + remember(app, &opts); + close(app); + Ok(v) + } + Err(e) => { + if let Some(w) = app.distort.select_mask.as_mut() { + w.error = Some(e.clone()); + } + app.ui.status = e.clone(); + app.ui.status_error = true; + Err(e) + } + } +} + +/// Cancel (button, Esc): closes; the document is untouched. +pub fn cancel(app: &mut PhotocraftApp) { + close(app); +} + +fn close(app: &mut PhotocraftApp) { + if let Some(w) = app.distort.select_mask.take() { + cancel_preview(w); + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn cancel_preview(w: SelectMaskWorkspace) { + if let Some((_, _, _, job)) = w.preview.running { + job.cancel(); + } +} + +#[cfg(target_arch = "wasm32")] +fn cancel_preview(_: SelectMaskWorkspace) {} + +/// Enter: closes a Polygonal Lasso polygon, else OK. True when it closed a polygon. +pub fn enter(app: &mut PhotocraftApp) -> bool { + let Some(w) = app.distort.select_mask.as_mut() else { return false }; + if w.poly.is_some() { + w.close_polygon(); + return true; + } + let _ = commit(app); + false +} + +/// Esc: drops a Polygonal Lasso polygon being drawn, else Cancel. +pub fn escape(app: &mut PhotocraftApp) { + match app.distort.select_mask.as_mut() { + Some(w) if w.poly.is_some() => w.poly = None, + _ => cancel(app), + } +} + +/// Control channel: `select.selectAndMask {"ui": {...}}` while the workspace is open. +pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result { + let flag = |k: &str| ui.get(k).and_then(Value::as_bool) == Some(true); + if flag("cancel") { + cancel(app); + return Ok(json!({"cancelled": true})); + } + { + let w = app.distort.select_mask.as_mut().ok_or("Select and Mask is not open")?; + let before = w.opts.clone(); + let mut o = w.opts.clone(); + o.apply(ui)?; + if let Some(t) = ui.get("tool").and_then(Value::as_str) { + let t = SmTool::from_name(t).ok_or(format!("bad tool `{t}` (quick|refineEdge|brush|object|lasso|polygonalLasso|hand|zoom)"))?; + w.pick(t); + } + w.opts = o; + match ui.get("mode").and_then(Value::as_str) { + None => {} + Some("add") => w.add = true, + Some("subtract") => w.add = false, + Some(m) => return Err(format!("bad mode `{m}` (add|subtract)")), + } + if let Some(n) = ui.get("size").and_then(Value::as_f64) { + w.set_size(n); + } + if let Some(n) = ui.get("hardness").and_then(Value::as_f64).filter(|n| n.is_finite()) { + w.hardness = (n as f32).clamp(0.0, 100.0); + } + if let Some(b) = ui.get("sampleAllLayers").and_then(Value::as_bool) { + w.sample_all = b; + } + if let Some(b) = ui.get("hideView").and_then(Value::as_bool) { + w.hide_view = b; + } + if w.opts != before { + w.stale(); + } + if let Some(s) = ui.get("stroke") { + let pts: Vec<[f64; 2]> = serde_json::from_value(s.get("points").cloned().unwrap_or(Value::Null)).map_err(|e| format!("bad stroke points: {e}"))?; + if pts.iter().any(|p| !p[0].is_finite() || !p[1].is_finite()) { + return Err("bad stroke points: not finite".into()); + } + let alt = s.get("alt").and_then(Value::as_bool).unwrap_or(false); + if w.tool == SmTool::PolygonalLasso { + w.poly = Some((w.add != alt, pts)); + w.close_polygon(); + } else if let Some((first, rest)) = pts.split_first() { + w.begin(*first, alt); + for p in rest { + w.extend(*p); + } + w.end_gesture(); + } + } + let sample_all = w.sample_all; + for (key, edit) in [ + ("selectSubject", json!({"tool": "subject", "sampleAllLayers": sample_all})), + ("clearSelection", json!({"tool": "clear"})), + ("invert", json!({"tool": "invert"})), + ] { + if flag(key) { + w.add_edit(edit); + } + } + if flag("reset") { + reset(w); + } + if flag("undo") { + undo(w); + } + if flag("redo") { + redo(w); + } + } + if flag("waitPreview") { + wait_preview(app)?; + } + if flag("commit") { + return commit(app).map(|v| json!({"committed": true, "result": v})); + } + Ok(app.distort.select_mask.as_ref().map(SelectMaskWorkspace::describe).unwrap_or(Value::Null)) +} + +/// `e` (document coordinates) on the copy reduced by `k`: points, sizes and rectangles scale by +/// `1 / k`. +fn scale_edit(e: &Value, k: u32) -> Value { + let k = f64::from(k.max(1)); + let mut e = e.clone(); + let Some(o) = e.as_object_mut() else { return e }; + let scale = |v: &mut Value| { + if let Some(n) = v.as_f64() { + *v = json!(n / k); + } + }; + if let Some(pts) = o.get_mut("points").and_then(Value::as_array_mut) { + for p in pts.iter_mut().filter_map(Value::as_array_mut) { + p.iter_mut().for_each(scale); + } + } + for key in ["point", "rect"] { + if let Some(a) = o.get_mut(key).and_then(Value::as_array_mut) { + a.iter_mut().for_each(scale); + } + } + if let Some(size) = o.get("size").and_then(Value::as_f64) { + o.insert("size".into(), json!((size / k).max(1.0))); + } + e +} + +/// What a preview computation needs (moved to a worker thread). +struct Job { + doc: Arc, + revision: u64, + layer: Option, + k: u32, + target_mask: bool, + /// The committed edits and, with Real-Time Refinement, the one being drawn. + edits: Vec, + extra: Option, + /// The refinement params with `__view` and `refineStrokes`. + params: Value, + cache: Option, + proxy: Option>, + display: Option>, + texture_limit: usize, + /// [`Shown::trail`] of this preview. + trail: usize, +} + +fn job(app: &PhotocraftApp) -> Option { + let w = app.distort.select_mask.as_ref()?; + let st = app.session.active()?; + let k = w.proxy_k(); + let mut params = w.opts.refine_params(); + params.insert("sampleAllLayers".into(), json!(w.sample_all)); + params.insert("__view".into(), w.opts.view_params()); + let gesture = w.gesture.as_ref().and_then(|g| w.finish(g)); + let mut strokes = w.strokes.clone(); + let mut extra = None; + match gesture { + Some(Ok(e)) if w.opts.real_time => extra = Some(e), + Some(Err(s)) if w.opts.real_time => strokes.push(s), + _ => {} + } + if !strokes.is_empty() { + params.insert("refineStrokes".into(), Value::Array(strokes)); + } + if w.target_mask { + params.insert("target".into(), json!("mask")); + } + let proxy = w.preview.proxy.as_ref().filter(|(pk, rev, _)| *pk == k && *rev == st.revision).map(|(_, _, d)| Arc::clone(d)); + Some(Job { + doc: Arc::clone(&st.doc), + revision: st.revision, + layer: w.layer, + k, + target_mask: w.target_mask, + edits: w.edits.clone(), + extra, + params: Value::Object(params), + cache: w.preview.cache.clone(), + proxy, + display: crate::canvas::canvas_display(app, &st.doc, None).0, + texture_limit: w.texture_limit, + trail: match &w.gesture { + Some(g) if w.opts.real_time => g.points.len(), + Some(_) => 0, + None => usize::MAX, + }, + }) +} + +/// Unit edges between selected (≥ ½) and unselected pixels of `m` (`w × h`, reduced by `k`), in +/// document px; `None` past [`MAX_ANT_EDGES`]. +fn ant_edges(m: &[f32], w: usize, h: usize, k: f32) -> Option> { + let at = |x: i64, y: i64| x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h && m.get(y as usize * w + x as usize).is_some_and(|v| *v >= 0.5); + let mut out = Vec::new(); + for y in 0..h as i64 { + for x in 0..w as i64 { + if !at(x, y) { + continue; + } + let (fx, fy) = (x as f32, y as f32); + for (n, a, b) in [ + (at(x - 1, y), [fx, fy], [fx, fy + 1.0]), + (at(x + 1, y), [fx + 1.0, fy], [fx + 1.0, fy + 1.0]), + (at(x, y - 1), [fx, fy], [fx + 1.0, fy]), + (at(x, y + 1), [fx, fy + 1.0], [fx + 1.0, fy + 1.0]), + ] { + if !n { + out.push([[a[0] * k, a[1] * k], [b[0] * k, b[1] * k]]); + } + } + if out.len() > MAX_ANT_EDGES { + return None; + } + } + } + Some(out) +} + +/// The preview of `job`: the edits applied (from where the cache left off), the edge refined and +/// the result shown in the view, as an image no wider than the device allows. +fn compute(job: Job, cancel: Option<&photocraft_engine::jobs::JobCtx>) -> Result<(Shown, BaseCache, Arc), String> { + let k = job.k.max(1); + let proxy = job.proxy.unwrap_or_else(|| Arc::new(crate::proxy::proxy_document(&job.doc, k))); + let mut doc = (*proxy).clone(); + let layer = job.layer.filter(|id| doc.layer(*id).is_some()); + let mut params = job.params; + let start = || { + let mask = layer.and_then(|id| doc.layer(id)).and_then(|l| l.mask.as_ref()).map(|m| m.surface.clone()); + if job.target_mask { mask.or_else(|| doc.selection.clone()) } else { doc.selection.clone().or(mask) } + }; + let scaled = |list: &[Value]| -> Result, String> { + let list: Vec = list.iter().map(|e| scale_edit(e, k)).collect(); + select_mask_edits::parse(&json!({ "edits": list })).map_err(|e| e.to_string()) + }; + // With no edits yet, the selection Select and Mask starts from is where the first one starts. + let mut cache = BaseCache { k, edits: Vec::new(), base: start() }; + if !job.edits.is_empty() || job.extra.is_some() { + // The selection after the edits already applied, when they still lead the list. + let (from, done) = match job.cache.filter(|c| c.k == k && job.edits.starts_with(&c.edits)) { + Some(c) => (c.base, c.edits.len()), + None => (start(), 0), + }; + let rest = scaled(job.edits.get(done..).unwrap_or_default())?; + let base = select_mask_edits::apply(&doc, layer, from, &rest); + cache = BaseCache { k, edits: job.edits.clone(), base: base.clone() }; + let base = match &job.extra { + Some(e) => select_mask_edits::apply(&doc, layer, base, &scaled(std::slice::from_ref(e))?), + None => base, + }; + // Nothing selected is an empty selection, not "use the layer mask". + doc.selection = Some(base.unwrap_or_else(|| Surface::new(photocraft_color::PixelFormat::GRAY8))); + if let Some(o) = params.as_object_mut() { + o.remove("target"); + } + } + let shown = crate::select_mask_view::preview_proxy(doc, layer, ¶ms, k, cancel); + if cancel.is_some_and(photocraft_engine::jobs::JobCtx::cancelled) { + return Err("cancelled".into()); + } + let shown = shown.ok_or("the preview failed")?; + let (pw, ph) = (shown.size.width as usize, shown.size.height as usize); + let f = crate::filter_preview_ui::texture_factor(shown.size.width, shown.size.height, job.texture_limit) as usize; + let (tw, th) = (pw.div_ceil(f.max(1)), ph.div_ceil(f.max(1))); + let buf = photocraft_compose::render_reduced(&shown, u32::try_from(tw).unwrap_or(1), u32::try_from(th).unwrap_or(1)); + let image = crate::canvas::display_image(job.display.as_deref(), &buf); + let coverage = photocraft_algo::selection::mask_from_surface(shown.selection.as_ref(), shown.bounds()); + let selected = coverage.iter().filter(|v| **v >= 0.5).count(); + let mode = params.get("__view").and_then(|v| v.get("mode")).and_then(Value::as_str); + let ants = (mode == Some(Mode::MarchingAnts.id())).then(|| ant_edges(&coverage, pw, ph, k as f32)).flatten(); + let covers = [(pw as f32) * k as f32, (ph as f32) * k as f32]; + Ok((Shown { image, ants, selected, trail: job.trail, covers }, cache, proxy)) +} + +/// Takes the preview of state `generation` (made from document `revision`): the image when it is +/// still the state wanted (or a stroke is being drawn: Real-Time Refinement shows the latest one +/// it has), and the selection after the edits and the reduced copy for the next preview. +fn take_result(w: &mut SelectMaskWorkspace, generation: u64, revision: u64, r: Result<(Shown, BaseCache, Arc), String>) { + let pv = &mut w.preview; + match r { + Ok((shown, cache, proxy)) => { + pv.proxy = Some((cache.k, revision, proxy)); + pv.cache = Some(cache); + if generation == pv.want || w.pressed { + pv.trail = shown.trail; + if shown.trail == usize::MAX { + w.trail = None; + } + pv.shown = generation; + pv.selected = shown.selected; + pv.error = None; + pv.pending = Some(shown); + } + } + Err(e) if e == "cancelled" => {} + Err(e) => { + pv.shown = generation; + pv.error = Some(e); + } + } +} + +/// Computes the preview now, on this thread (tests and the control channel). +fn wait_preview(app: &mut PhotocraftApp) -> Result<(), String> { + let j = job(app).ok_or("Select and Mask is not open")?; + let revision = j.revision; + let r = compute(j, None); + let w = app.distort.select_mask.as_mut().ok_or("Select and Mask is not open")?; + let err = r.as_ref().err().cloned(); + let generation = w.preview.want; + take_result(w, generation, revision, r); + err.map_or(Ok(()), Err) +} + +/// Starts a background preview when the shown one is out of date, and collects a finished one. +/// While a stroke is drawn with Real-Time Refinement, a running preview is let finish (cancelling +/// it on every move would never show one) and the next starts from the latest state. +#[cfg(not(target_arch = "wasm32"))] +fn drive_preview(app: &mut PhotocraftApp, ctx: &egui::Context) { + let Some(w) = app.distort.select_mask.as_mut() else { return }; + if let Some((generation, revision, rx, _)) = &w.preview.running { + match rx.try_recv() { + Ok(r) => { + let (generation, revision) = (*generation, *revision); + w.preview.running = None; + take_result(w, generation, revision, r); + } + Err(std::sync::mpsc::TryRecvError::Empty) => { + if *generation != w.preview.want + && !w.pressed + && let Some((_, _, _, job)) = &w.preview.running + { + job.cancel(); + } + ctx.request_repaint_after(std::time::Duration::from_millis(30)); + return; + } + Err(std::sync::mpsc::TryRecvError::Disconnected) => w.preview.running = None, + } + } + let Some(w) = app.distort.select_mask.as_ref() else { return }; + let waiting = w.pressed && !w.opts.real_time; + if w.preview.running.is_some() || w.preview.shown == w.preview.want || waiting { + return; + } + let generation = w.preview.want; + let Some(j) = job(app) else { return }; + let revision = j.revision; + let (tx, rx) = std::sync::mpsc::sync_channel(1); + let ctl = photocraft_engine::jobs::JobCtx::new(); + let work = ctl.clone(); + let repaint = ctx.clone(); + let spawned = std::thread::Builder::new().name("select-mask-preview".into()).spawn(move || { + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| compute(j, Some(&work)))).unwrap_or_else(|_| Err("the preview failed".into())); + let _ = tx.send(r); + repaint.request_repaint(); + }); + let Some(w) = app.distort.select_mask.as_mut() else { return }; + match spawned { + Ok(_) => w.preview.running = Some((generation, revision, rx, ctl)), + Err(e) => w.preview.error = Some(format!("could not start the preview: {e}")), + } +} + +/// The web build has no threads: the preview is computed when the pointer comes up. +#[cfg(target_arch = "wasm32")] +fn drive_preview(app: &mut PhotocraftApp, _ctx: &egui::Context) { + let due = app.distort.select_mask.as_ref().is_some_and(|w| w.preview.shown != w.preview.want && !w.pressed); + if due { + let _ = wait_preview(app); + } +} + +/// Pointer in document coordinates (from the view or the control channel). +pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { + let Some(w) = app.distort.select_mask.as_mut() else { return }; + match ev { + ToolEvent::Down { x, y, .. } if w.tool == SmTool::Zoom => { + let f = if mods.alt { 0.5 } else { 2.0 }; + w.zoom = (w.zoom * f).clamp(0.01, 32.0); + w.center = [x, y]; + } + ToolEvent::Down { x, y, .. } => w.begin([x, y], mods.alt), + ToolEvent::Move { x, y, .. } => { + w.hover = Some([x, y]); + w.extend([x, y]); + } + ToolEvent::Up { x, y } => { + w.extend([x, y]); + w.end_gesture(); + } + } +} + +/// The workspace's keys (unless a text field has the keyboard): tools, brush size, views, undo. +pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) { + use egui::{Key, Modifiers}; + let Some(w) = app.distort.select_mask.as_mut() else { return }; + let cmd_shift = Modifiers::COMMAND | Modifiers::SHIFT; + if ctx.input_mut(|i| i.consume_key(cmd_shift, Key::Z)) { + redo(w); + return; + } + if ctx.input_mut(|i| i.consume_key(Modifiers::COMMAND, Key::Z)) { + undo(w); + return; + } + if egui::Popup::is_any_open(ctx) { + w.hide_view = false; + return; + } + let pressed = |k: Key, m: Modifiers| ctx.input_mut(|i| i.consume_key(m, k)); + if pressed(Key::W, Modifiers::SHIFT) { + w.pick(if w.w_tool == SmTool::Quick { SmTool::Object } else { SmTool::Quick }); + } + if pressed(Key::L, Modifiers::SHIFT) { + w.pick(if w.l_tool == SmTool::Lasso { SmTool::PolygonalLasso } else { SmTool::Lasso }); + } + for (k, t) in + [(Key::W, w.w_tool), (Key::R, SmTool::RefineEdge), (Key::B, SmTool::Brush), (Key::L, w.l_tool), (Key::H, SmTool::Hand), (Key::Z, SmTool::Zoom)] + { + if pressed(k, Modifiers::NONE) { + w.pick(t); + } + } + if pressed(Key::OpenBracket, Modifiers::NONE) { + let s = w.size(); + w.set_size((s * 0.9).min(s - 1.0)); + } + if pressed(Key::CloseBracket, Modifiers::NONE) { + let s = w.size(); + w.set_size((s * 1.1).max(s + 1.0)); + } + if (pressed(Key::Backspace, Modifiers::NONE) || pressed(Key::Delete, Modifiers::NONE)) + && let Some((_, pts)) = w.poly.as_mut() + { + pts.pop(); + if pts.is_empty() { + w.poly = None; + } + } + // The views: their letters, F cycles, J Show Edge, P Show Original; holding X shows the image. + let before = w.opts.clone(); + if pressed(Key::F, Modifiers::NONE) { + w.opts.view = w.opts.view.next(); + } + for m in Mode::ALL { + if pressed(m.key(), Modifiers::NONE) { + w.opts.view = m; + } + } + if pressed(Key::J, Modifiers::NONE) { + w.opts.show_edge = !w.opts.show_edge; + } + if pressed(Key::P, Modifiers::NONE) { + w.opts.show_original = !w.opts.show_original; + } + if w.opts != before { + w.stale(); + } + w.hide_view = ctx.input(|i| i.key_down(Key::X) && !i.modifiers.command && !i.modifiers.alt && !i.modifiers.ctrl); +} + +/// A view: screen px per document px and the document point at the centre of `area`. +#[derive(Clone, Copy)] +struct View { + area: ERect, + zoom: f32, + center: [f64; 2], +} + +impl View { + fn to_screen(self, p: [f64; 2]) -> Pos2 { + let c = self.area.center(); + pos2(c.x + ((p[0] - self.center[0]) as f32) * self.zoom, c.y + ((p[1] - self.center[1]) as f32) * self.zoom) + } + fn to_doc(self, s: Pos2) -> [f64; 2] { + let c = self.area.center(); + [self.center[0] + f64::from((s.x - c.x) / self.zoom), self.center[1] + f64::from((s.y - c.y) / self.zoom)] + } + fn rect(self, r: Rect) -> ERect { + ERect::from_min_max(self.to_screen([f64::from(r.x0), f64::from(r.y0)]), self.to_screen([f64::from(r.x1), f64::from(r.y1)])) + } +} + +/// Upload what changed: the document (for X and until the first preview) and a finished preview. +fn upload(app: &mut PhotocraftApp, ctx: &egui::Context) { + let need_image = app.distort.select_mask.as_ref().is_some_and(|w| w.image_tex.is_none()); + if need_image { + let img = app.session.active().map(|st| { + let (cw, ch) = (st.doc.size.width, st.doc.size.height); + let f = crate::filter_preview_ui::texture_factor(cw, ch, PROXY_SIDE); + let buf = photocraft_compose::render_reduced(&st.doc, cw.div_ceil(f.max(1)), ch.div_ceil(f.max(1))); + let (display, _) = crate::canvas::canvas_display(app, &st.doc, None); + crate::canvas::display_image(display.as_deref(), &buf) + }); + if let (Some(w), Some(img)) = (app.distort.select_mask.as_mut(), img) { + w.image_tex = Some(ctx.load_texture("select-mask-image", img, egui::TextureOptions::LINEAR)); + } + } + let Some(w) = app.distort.select_mask.as_mut() else { return }; + if let Some(s) = w.preview.pending.take() { + match &mut w.preview.tex { + Some(t) => t.set(s.image, egui::TextureOptions::LINEAR), + None => w.preview.tex = Some(ctx.load_texture("select-mask-preview", s.image, egui::TextureOptions::LINEAR)), + } + w.preview.ants = s.ants; + w.preview.covers = s.covers; + } +} + +/// A section of the Properties panel: a bold title that folds it, open by default. +fn section(ui: &mut egui::Ui, id: &str, title: &str, body: impl FnOnce(&mut egui::Ui)) { + egui::CollapsingHeader::new(egui::RichText::new(title).strong()).id_salt(id).default_open(true).show(ui, body); +} + +/// The options bar of the current tool. +fn options_bar(ui: &mut egui::Ui, w: &mut SelectMaskWorkspace) -> Option { + let mut edit = None; + crate::icons::button(ui, w.tool.icon(), 26.0, false, &w.tool.tip()); + ui.add_space(6.0); + if w.tool.edits() { + if crate::icons::button(ui, "plus", 26.0, w.add, tl!("Add")).clicked() { + w.add = true; + } + if crate::icons::button(ui, "minus", 26.0, !w.add, tl!("Subtract")).clicked() { + w.add = false; + } + ui.add_space(8.0); + } + if w.tool.brushes() { + ui.label(tl!("Size:")); + let mut size = w.size() as f32; + if widgets::value_field(ui, &mut size, 1.0..=5000.0, " px", 64.0).changed() { + w.set_size(f64::from(size)); + } + ui.add_space(6.0); + } + if w.tool == SmTool::Brush { + ui.label(tl!("Hardness:")); + widgets::value_field(ui, &mut w.hardness, 0.0..=100.0, "%", 56.0); + ui.add_space(6.0); + } + if matches!(w.tool, SmTool::Quick | SmTool::RefineEdge | SmTool::Object) && widgets::checkbox(ui, &mut w.sample_all, tl!("Sample All Layers")).changed() { + w.stale(); + } + if w.tool.edits() { + ui.add_space(8.0); + if widgets::secondary_button(ui, tl!("Select Subject"), 0.0).clicked() { + edit = Some(json!({"tool": "subject", "sampleAllLayers": w.sample_all})); + } + // Refine Hair needs hair detection PhotoCraft doesn't have yet (#2671): greyed, saying so. + ui.add_enabled_ui(false, |ui| widgets::secondary_button(ui, tl!("Refine Hair"), 0.0)) + .inner + .on_disabled_hover_text(tl!("Refine Hair needs hair detection, which isn't available yet")); + } + edit +} + +/// The Properties panel. Returns the tool edit its buttons made (Clear Selection, Invert). +fn properties(ui: &mut egui::Ui, w: &mut SelectMaskWorkspace, t: &Tokens) -> Option { + let mut edit = None; + ui.spacing_mut().item_spacing.y = 6.0; + widgets::checkbox(ui, &mut w.opts.remember, tl!("Remember Settings")); + widgets::hairline(ui); + section(ui, "sm-view", tl!("View Mode"), |ui| { + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.label(egui::RichText::new(tl!("View")).color(t.text_dim)); + let labels: Vec = Mode::ALL.iter().map(|m| format!("{} ({:?})", m.label(), m.key())).collect(); + let options: Vec<(Mode, &str)> = Mode::ALL.iter().zip(&labels).map(|(m, l)| (*m, l.as_str())).collect(); + // The labels carry the view's letter, already translated. + widgets::dropdown_literal(ui, "sm-view-mode", &mut w.opts.view, &options, 112.0); + }); + // Beside the view list, as Photoshop's; long labels are cut rather than widening the + // panel past its edge (every row below lines up on that edge). + let room = ui.available_width(); + ui.vertical(|ui| { + ui.set_max_width(room); + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate); + widgets::checkbox(ui, &mut w.opts.show_edge, &format!("{} (J)", tl!("Show Edge"))); + widgets::checkbox(ui, &mut w.opts.show_original, &format!("{} (P)", tl!("Show Original"))); + widgets::checkbox(ui, &mut w.opts.real_time, tl!("Real-Time Refinement")); + widgets::checkbox(ui, &mut w.opts.high_quality, tl!("High Quality Preview")); + }); + }); + let i = mode_index(w.opts.view); + if w.opts.view.default_opacity().is_some() { + let label = if w.opts.view == Mode::OnionSkin { tl!("Transparency") } else { tl!("Opacity") }; + if let Some(o) = w.opts.opacity.get_mut(i) { + widgets::slider_row(ui, label, o, 0.0..=100.0, "%", None); + } + } + if w.opts.view == Mode::Overlay { + ui.horizontal(|ui| { + ui.label(tl!("Color")); + widgets::color_edit_button_srgb(ui, &mut w.opts.overlay_color); + ui.add_space(8.0); + ui.label(tl!("Indicates")); + widgets::dropdown(ui, "sm-indicates", &mut w.opts.indicates_selected, &[(false, tl!("Masked Areas")), (true, tl!("Selected Areas"))], 120.0); + }); + } + }); + section(ui, "sm-refine-mode", tl!("Refine Mode"), |ui| { + ui.horizontal(|ui| { + let _ = ui.selectable_label(true, tl!("Color Aware")); + // Object Aware needs a learned segmentation model (#2788). + ui.add_enabled_ui(false, |ui| ui.selectable_label(false, tl!("Object Aware"))) + .inner + .on_disabled_hover_text(tl!("Object Aware needs a segmentation model, which isn't available yet")); + }); + }); + section(ui, "sm-edge", tl!("Edge Detection"), |ui| { + widgets::slider_row(ui, tl!("Radius"), &mut w.opts.radius, 0.0..=250.0, " px", None); + widgets::checkbox(ui, &mut w.opts.smart_radius, tl!("Smart Radius")); + }); + section(ui, "sm-global", tl!("Global Refinements"), |ui| { + widgets::slider_row(ui, tl!("Smooth"), &mut w.opts.smooth, 0.0..=100.0, "", None); + widgets::slider_row(ui, tl!("Feather"), &mut w.opts.feather, 0.0..=1000.0, " px", None); + widgets::slider_row(ui, tl!("Contrast"), &mut w.opts.contrast, 0.0..=100.0, "%", None); + widgets::slider_row(ui, tl!("Shift Edge"), &mut w.opts.shift_edge, -100.0..=100.0, "%", None); + ui.horizontal(|ui| { + if widgets::secondary_button(ui, tl!("Clear Selection"), 0.0).clicked() { + edit = Some(json!({"tool": "clear"})); + } + if widgets::secondary_button(ui, tl!("Invert"), 0.0).clicked() { + edit = Some(json!({"tool": "invert"})); + } + }); + }); + section(ui, "sm-output", tl!("Output Settings"), |ui| { + widgets::checkbox(ui, &mut w.opts.decontaminate, tl!("Decontaminate Colors")); + ui.add_enabled_ui(w.opts.decontaminate, |ui| widgets::slider_row(ui, tl!("Amount"), &mut w.opts.amount, 0.0..=100.0, "%", None)); + w.opts.follow_decontaminate(); + ui.horizontal(|ui| { + ui.label(tl!("Output To")); + let labels = + [tl!("Selection"), tl!("Layer Mask"), tl!("New Layer"), tl!("New Layer With Mask"), tl!("New Document"), tl!("New Document With Mask")]; + // Decontaminating makes new pixels: only the new layer and document targets apply. + let options: Vec<(String, &str)> = OUTPUTS + .iter() + .zip(labels) + .filter(|(o, _)| !w.opts.decontaminate || !matches!(**o, "selection" | "layerMask")) + .map(|(o, l)| (o.to_string(), l)) + .collect(); + widgets::dropdown(ui, "sm-output-to", &mut w.opts.output, &options, 170.0); + }); + }); + if let Some(e) = w.error.as_ref().or(w.preview.error.as_ref()) { + ui.label(egui::RichText::new(e.as_str()).color(t.danger).size(11.0)); + } + edit +} + +/// Draws the workspace (a full-window layer over the app). +pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { + // The document changed under the workspace: new textures, a new preview. + let rev = app.session.active().map(|s| s.revision); + if let Some(w) = app.distort.select_mask.as_mut() + && Some(w.revision) != rev + { + w.revision = rev.unwrap_or(w.revision); + w.image_tex = None; + w.preview.cache = None; + w.stale(); + } + upload(app, ctx); + let title = app.distort.select_mask.as_ref().map(|w| crate::canvas::tab_title_at(app, w.doc_index, if w.zoom > 0.0 { w.zoom * 100.0 } else { 100.0 })); + let t = Tokens::get(ctx); + let screen = ctx.content_rect(); + let mut action: Option<&str> = None; + let mut events: Vec = Vec::new(); + let mut mods = egui::Modifiers::NONE; + let mut edit: Option = None; + let mut do_reset = false; + egui::Area::new(egui::Id::new("select-mask-workspace")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| { + let Some(w) = app.distort.select_mask.as_mut() else { return }; + let before = (w.opts.clone(), w.sample_all); + w.texture_limit = ctx.input(|i| i.max_texture_side); + let (full, _) = ui.allocate_exact_size(screen.size(), Sense::hover()); + let painter = ui.painter().clone(); + painter.rect_filled(full, 0.0, t.chrome); + // The options bar across the top, as Photoshop's. + let bar = ERect::from_min_size(full.min, vec2(full.width(), BAR_H)); + painter.rect_filled(bar, 0.0, t.dock); + painter.line_segment([bar.left_bottom(), bar.right_bottom()], Stroke::new(1.0, t.separator)); + let mut bui = ui.new_child(egui::UiBuilder::new().max_rect(bar.shrink2(vec2(8.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); + if let Some(e) = options_bar(&mut bui, w) { + edit = Some(e); + } + let body = ERect::from_min_max(pos2(full.left(), bar.bottom()), full.max); + // The tools on the left: Lasso and Polygonal Lasso share a slot (right-click to pick). + let left = ERect::from_min_size(body.min, vec2(LEFT_W, body.height())); + painter.rect_filled(left, 0.0, t.dock); + painter.line_segment([left.right_top(), left.right_bottom()], Stroke::new(1.0, t.separator)); + let mut strip = ui.new_child(egui::UiBuilder::new().max_rect(left.shrink2(vec2(6.0, 8.0)))); + strip.spacing_mut().item_spacing.y = 4.0; + for slot in [SmTool::Quick, SmTool::RefineEdge, SmTool::Brush, SmTool::Object, w.l_tool, SmTool::Hand, SmTool::Zoom] { + let r = crate::icons::button(&mut strip, slot.icon(), 34.0, w.tool == slot, &slot.tip()); + if r.clicked() { + w.pick(slot); + } + if matches!(slot, SmTool::Lasso | SmTool::PolygonalLasso) { + // The slot's flyout triangle, as the main toolbar's. + let c = r.rect.right_bottom() - vec2(3.0, 3.0); + painter.add(egui::Shape::convex_polygon(vec![c, c - vec2(5.0, 0.0), c - vec2(0.0, 5.0)], t.icon, Stroke::NONE)); + r.context_menu(|ui| { + for l in [SmTool::Lasso, SmTool::PolygonalLasso] { + if ui.selectable_label(w.tool == l, l.tip()).clicked() { + w.pick(l); + ui.close(); + } + } + }); + } + } + // The Properties panel on the right. + let right = ERect::from_min_size(pos2(body.right() - PANEL_W, body.top()), vec2(PANEL_W, body.height())); + painter.rect_filled(right, 0.0, t.dock); + painter.line_segment([right.left_top(), right.left_bottom()], Stroke::new(1.0, t.separator)); + let head = ERect::from_min_size(right.min, vec2(PANEL_W, TAB_H)); + painter.line_segment([head.left_bottom(), head.right_bottom()], Stroke::new(1.0, t.separator)); + painter.text(head.left_center() + vec2(12.0, 0.0), Align2::LEFT_CENTER, tl!("Properties"), FontId::proportional(12.0), t.text); + let mut props = ui.new_child( + egui::UiBuilder::new().max_rect(ERect::from_min_max(head.left_bottom() + vec2(14.0, 10.0), pos2(right.right() - 14.0, right.bottom() - 56.0))), + ); + egui::ScrollArea::vertical().id_salt("sm-props").show(&mut props, |ui| { + if let Some(e) = properties(ui, w, &t) { + edit = Some(e); + } + }); + // Reset at the left, OK and Cancel against the right edge. + let foot = ERect::from_min_size(pos2(right.left() + 10.0, right.bottom() - 46.0), vec2(PANEL_W - 20.0, 32.0)); + painter.line_segment([pos2(right.left(), foot.top() - 6.0), pos2(right.right(), foot.top() - 6.0)], Stroke::new(1.0, t.separator)); + let mut fb = ui.new_child(egui::UiBuilder::new().max_rect(foot).layout(egui::Layout::right_to_left(egui::Align::Center))); + if widgets::secondary_button(&mut fb, tl!("Cancel"), 70.0).clicked() { + action = Some("cancel"); + } + if widgets::primary_button(&mut fb, tl!("OK"), 64.0).clicked() { + action = Some("ok"); + } + fb.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + if crate::icons::button(ui, "undo-2", 24.0, false, tl!("Reset")).clicked() { + do_reset = true; + } + }); + // The document under its tab. + let doc_area = ERect::from_min_max(pos2(left.right(), body.top()), pos2(right.left(), body.bottom())); + let tab = ERect::from_min_size(doc_area.min, vec2(doc_area.width(), TAB_H)); + painter.rect_filled(tab, 0.0, t.dock); + painter.line_segment([tab.left_bottom(), tab.right_bottom()], Stroke::new(1.0, t.separator)); + let title = title.clone().unwrap_or_default(); + let galley = painter.layout_no_wrap(title, FontId::proportional(12.0), t.text); + let tab_w = (galley.size().x + 28.0).min(tab.width()); + let active_tab = ERect::from_min_size(tab.min, vec2(tab_w, TAB_H)); + painter.rect_filled(active_tab, 0.0, t.canvas); + painter.galley(pos2(active_tab.left() + 14.0, active_tab.center().y - galley.size().y / 2.0), galley, t.text); + let area = ERect::from_min_max(pos2(doc_area.left(), tab.bottom()), doc_area.max); + painter.rect_filled(area, 0.0, t.canvas); + let (cw, chh) = (w.canvas.width() as f32, w.canvas.height() as f32); + if w.zoom <= 0.0 { + w.zoom = ((area.width() - 40.0) / cw.max(1.0)).min((area.height() - 40.0) / chh.max(1.0)).clamp(0.01, 4.0); + } + let resp = ui.interact(area, egui::Id::new("select-mask-view"), Sense::click_and_drag()); + // Over the image the keys are the workspace's: a panel button clicked last keeps the focus + // otherwise, and Space (held to pan) would press it again. A number being typed keeps it. + if resp.hovered() + && !ctx.text_edit_focused() + && let Some(focus) = ctx.memory(|m| m.focused()) + { + ctx.memory_mut(|m| m.surrender_focus(focus)); + } + if resp.hovered() { + let scroll = ui.input(|i| i.smooth_scroll_delta.y); + if scroll != 0.0 + && let Some(hp) = resp.hover_pos() + { + let v = View { area, zoom: w.zoom, center: w.center }; + let at = v.to_doc(hp); + w.zoom = (w.zoom * (scroll / 200.0).exp()).clamp(0.01, 32.0); + let after = View { area, zoom: w.zoom, center: w.center }.to_doc(hp); + w.center = [w.center[0] + at[0] - after[0], w.center[1] + at[1] - after[1]]; + } + } + let v = View { area, zoom: w.zoom, center: w.center }; + let clip = painter.with_clip_rect(area); + let img = v.rect(w.canvas); + let uv = ERect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + widgets::checker(&clip, img.intersect(area), 8.0); + let preview = w.preview.tex.as_ref().filter(|_| !w.hide_view); + match (preview, &w.image_tex) { + (Some(tex), _) => { + // The reduced copy's last row and column may reach past the canvas. + let covers = ERect::from_min_size(img.min, vec2(w.preview.covers[0] * v.zoom, w.preview.covers[1] * v.zoom)); + clip.with_clip_rect(img.intersect(area)).image(tex.id(), covers, uv, Color32::WHITE); + } + (None, Some(tex)) => { + clip.image(tex.id(), img, uv, Color32::WHITE); + } + (None, None) => {} + } + // Marching Ants: the refined selection's edge, black dashes on white. + if !w.hide_view + && w.opts.view == Mode::MarchingAnts + && let Some(edges) = &w.preview.ants + { + let phase = (ui.input(|i| i.time) * 8.0) as usize; + for e in edges { + let (a, b) = (v.to_screen([f64::from(e[0][0]), f64::from(e[0][1])]), v.to_screen([f64::from(e[1][0]), f64::from(e[1][1])])); + let dark = ((e[0][0] + e[0][1]) as usize / 4 + phase).is_multiple_of(2); + clip.line_segment([a, b], Stroke::new(1.0, if dark { Color32::BLACK } else { Color32::WHITE })); + } + ctx.request_repaint_after(std::time::Duration::from_millis(120)); + } + if w.preview.shown != w.preview.want && !w.pressed { + let sp = ERect::from_min_size(area.right_bottom() - vec2(26.0, 26.0), vec2(18.0, 18.0)); + ui.put(sp, egui::Spinner::new().size(16.0)); + } + // The stroke being drawn: the part the preview doesn't show yet (all of it without + // Real-Time Refinement), joined to the part it does, so the stroke follows the pointer. + let ink = Color32::from_rgba_unmultiplied(t.accent.r(), t.accent.g(), t.accent.b(), 110); + if let Some(g) = w.gesture.as_ref().or(w.trail.as_ref()) { + let line: Vec = g.points.iter().map(|p| v.to_screen(*p)).collect(); + match g.tool { + SmTool::Quick | SmTool::Brush | SmTool::RefineEdge if w.preview.trail != usize::MAX => { + let tail = line.get(w.preview.trail.saturating_sub(1)..).unwrap_or_default(); + let r = w.size() as f32 * 0.5 * v.zoom; + for p in tail { + clip.circle_filled(*p, r, ink); + } + if tail.len() > 1 { + clip.add(egui::Shape::line(tail.to_vec(), Stroke::new(2.0 * r, ink))); + } + } + SmTool::Lasso => { + clip.add(egui::Shape::line(line, Stroke::new(1.0, t.accent))); + } + SmTool::Object => { + if let (Some(a), Some(b)) = (line.first(), line.last()) { + clip.rect_stroke(ERect::from_two_pos(*a, *b), 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Middle); + } + } + _ => {} + } + } + if let Some((_, pts)) = &w.poly { + let mut line: Vec = pts.iter().map(|p| v.to_screen(*p)).collect(); + if let Some(hp) = resp.hover_pos() { + line.push(hp); + } + clip.add(egui::Shape::line(line, Stroke::new(1.0, t.accent))); + } + // Space, the middle button or the Hand pan. The Polygonal Lasso takes clicks: each adds a + // vertex, a double-click closes. + let pan = ui.input(|i| i.key_down(egui::Key::Space) || i.pointer.middle_down()) || w.tool == SmTool::Hand; + // The cursor: Photoshop's hand while panning (closed while dragging), the brush's circle + // (with + or −), the zoom glass, else a crosshair. + if let Some(hp) = resp.hover_pos() { + w.hover = Some(v.to_doc(hp)); + } + let alt = ui.input(|i| i.modifiers.alt); + if pan && (resp.dragged() || resp.is_pointer_button_down_on()) { + ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + } else if let Some(hp) = resp.hover_pos() { + if pan { + ctx.set_cursor_icon(egui::CursorIcon::Grab); + } else if w.tool.brushes() { + let r = (w.size() as f32 * 0.5 * v.zoom).max(2.0); + clip.circle_stroke(hp, r, Stroke::new(1.5, Color32::BLACK)); + clip.circle_stroke(hp, r, Stroke::new(0.75, Color32::WHITE)); + let add = w.add != alt; + for s in [Stroke::new(2.5, Color32::from_black_alpha(140)), Stroke::new(1.0, Color32::from_white_alpha(220))] { + clip.line_segment([hp - vec2(3.0, 0.0), hp + vec2(3.0, 0.0)], s); + if add { + clip.line_segment([hp - vec2(0.0, 3.0), hp + vec2(0.0, 3.0)], s); + } + } + ctx.set_cursor_icon(egui::CursorIcon::None); + } else { + ctx.set_cursor_icon(match w.tool { + SmTool::Zoom if alt => egui::CursorIcon::ZoomOut, + SmTool::Zoom => egui::CursorIcon::ZoomIn, + _ => egui::CursorIcon::Crosshair, + }); + } + } + clip.rect_stroke(img, 0.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Outside); + if pan && resp.dragged() { + let dl = resp.drag_delta(); + w.center = [w.center[0] - f64::from(dl.x / w.zoom), w.center[1] - f64::from(dl.y / w.zoom)]; + } else if w.tool == SmTool::PolygonalLasso && !pan { + mods = ui.input(|i| i.modifiers); + if resp.double_clicked() { + w.close_polygon(); + } else if resp.clicked() + && let Some(pp) = resp.interact_pointer_pos() + { + let q = v.to_doc(pp); + events.push(ToolEvent::Down { x: q[0], y: q[1], pressure: 1.0 }); + } + } else if !pan + && resp.is_pointer_button_down_on() + && let Some(pp) = resp.interact_pointer_pos() + { + let q = v.to_doc(pp); + mods = ui.input(|i| i.modifiers); + if w.pressed { + events.push(ToolEvent::Move { x: q[0], y: q[1], pressure: 1.0 }); + ctx.request_repaint(); + } else { + w.pressed = true; + events.push(ToolEvent::Down { x: q[0], y: q[1], pressure: 1.0 }); + } + } + if w.pressed && !resp.is_pointer_button_down_on() { + w.pressed = false; + let q = w.hover.unwrap_or(w.center); + events.push(ToolEvent::Up { x: q[0], y: q[1] }); + } + if (w.opts.clone(), w.sample_all) != before { + w.stale(); + } + }); + for ev in events { + pointer(app, ev, mods); + } + if let Some(w) = app.distort.select_mask.as_mut() { + if let Some(e) = edit { + w.add_edit(e); + } + if do_reset { + reset(w); + } + } + drive_preview(app, ctx); + match action { + Some("ok") => { + let _ = commit(app); + } + Some("cancel") => cancel(app), + _ => {} + } +} + +#[cfg(test)] +#[path = "select_mask_ui_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/select_mask_ui_tests.rs b/crates/ui-egui/src/select_mask_ui_tests.rs new file mode 100644 index 0000000000..db4b3cc913 --- /dev/null +++ b/crates/ui-egui/src/select_mask_ui_tests.rs @@ -0,0 +1,544 @@ +use super::*; +use egui::{Key, Modifiers}; +use egui_kittest::kittest::{NodeT, Queryable}; + +/// A 160 × 100 document: warm on the left (x < 80), cool on the right, with the top-left +/// 20 × 20 selected. +fn app_with_selection() -> PhotocraftApp { + app_sized(160, 100) +} + +fn app_sized(w: i32, h: i32) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", json!({"width": w, "height": h, "depth": 8})).unwrap(); + app.session + .edit("paint", |doc, _| { + let s = doc.layers[0].surface_mut().unwrap(); + s.fill_rect(Rect::new(0, 0, w / 2, h), &[0.85, 0.55, 0.25, 1.0]); + s.fill_rect(Rect::new(w / 2, 0, w, h), &[0.15, 0.35, 0.75, 1.0]); + Ok(()) + }) + .unwrap(); + app.session.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap(); + app.session.prefs.edit(|p| { + p.dialogs.remove(REMEMBERED); + }); + app.sync_views(); + app +} + +fn ws(app: &PhotocraftApp) -> &SelectMaskWorkspace { + app.distort.select_mask.as_ref().unwrap() +} + +fn ui(app: &mut PhotocraftApp, v: Value) -> Value { + control(app, &v).unwrap() +} + +fn selected(app: &mut PhotocraftApp) -> u64 { + ui(app, json!({"waitPreview": true}))["selectedProxyPixels"].as_u64().unwrap() +} + +fn cov(app: &PhotocraftApp, x: i32, y: i32) -> f32 { + app.session.active().unwrap().doc.selection.as_ref().map_or(0.0, |s| s.sample_channel(x, y, 0)) +} + +/// Photoshop opens on Quick Selection adding, the Onion Skin view, Real-Time Refinement on, and +/// output to the selection. +#[test] +fn opens_like_photoshop() { + let mut app = app_with_selection(); + crate::menus::invoke(&mut app, &egui::Context::default(), "select.selectAndMask", json!({})).unwrap(); + let d = ws(&app).describe(); + assert_eq!((d["tool"].as_str(), d["mode"].as_str(), d["size"].as_f64()), (Some("quick"), Some("add"), Some(30.0))); + let s = &d["settings"]; + assert_eq!((s["view"].as_str(), s["output"].as_str()), (Some("onionSkin"), Some("selection"))); + assert_eq!((s["realTimeRefinement"].as_bool(), s["highQualityPreview"].as_bool(), s["remember"].as_bool()), (Some(true), Some(false), Some(false))); + assert_eq!((s["opacity"]["onionSkin"].as_f64(), s["opacity"]["onBlack"].as_f64()), (Some(50.0), Some(100.0))); + assert_eq!(selected(&mut app), 400, "the 20 × 20 selection"); + assert!(ws(&app).describe()["previewReady"].as_bool().unwrap()); +} + +#[test] +fn opening_needs_a_selection_or_a_layer_mask() { + let mut app = app_with_selection(); + app.session.execute("select.deselect", json!({})).unwrap(); + assert!(open(&mut app).is_err()); + assert!(app.distort.select_mask.is_none()); + // `ui` addresses an open workspace only. + let e = crate::distort_ui::menu(&mut app, &egui::Context::default(), "select.selectAndMask", &json!({"ui": {}})).unwrap(); + assert!(e.is_err()); +} + +/// Every tool's stroke is an edit (the Refine Edge Brush's a stroke), each one undoable. +#[test] +fn tools_make_edits_and_undo_and_redo_them() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + let before = selected(&mut app); + // Quick Selection across the cool half grows the selection over it. + ui(&mut app, json!({"stroke": {"points": [[120.0, 50.0], [130.0, 50.0]]}})); + let quick = selected(&mut app); + assert!(quick > before + 3000, "{before} → {quick}"); + // Brush, Lasso and Polygonal Lasso subtract with Alt. + ui(&mut app, json!({"tool": "brush", "size": 10, "hardness": 50, "stroke": {"points": [[100.0, 10.0], [100.0, 90.0]], "alt": true}})); + let brushed = selected(&mut app); + assert!(brushed < quick, "{quick} → {brushed}"); + ui(&mut app, json!({"tool": "lasso", "mode": "subtract", "stroke": {"points": [[140.0, 0.0], [160.0, 0.0], [160.0, 100.0], [140.0, 100.0]]}})); + ui(&mut app, json!({"tool": "polygonalLasso", "mode": "add", "stroke": {"points": [[30.0, 30.0], [50.0, 30.0], [50.0, 50.0]]}})); + ui(&mut app, json!({"tool": "refineEdge", "stroke": {"points": [[80.0, 20.0], [80.0, 80.0]]}})); + let d = ws(&app).describe(); + assert_eq!((d["edits"].as_u64(), d["strokes"].as_u64(), d["undo"].as_u64()), (Some(4), Some(1), Some(5))); + let e = &ws(&app).edits; + assert_eq!( + (e[1]["tool"].as_str(), e[1]["mode"].as_str(), e[1]["hardness"].as_f64(), e[1]["size"].as_f64()), + (Some("brush"), Some("subtract"), Some(50.0), Some(10.0)) + ); + assert_eq!((e[2]["tool"].as_str(), e[2]["mode"].as_str()), (Some("lasso"), Some("subtract"))); + assert_eq!((e[3]["tool"].as_str(), e[3]["points"].as_array().map(Vec::len)), (Some("lasso"), Some(3))); + // Undo everything but the Quick Selection stroke, then redo the Brush's. + for _ in 0..4 { + ui(&mut app, json!({"undo": true})); + } + assert_eq!(selected(&mut app), quick); + ui(&mut app, json!({"redo": true})); + assert_eq!(selected(&mut app), brushed); + // A new edit drops what could be redone. + ui(&mut app, json!({"invert": true})); + assert_eq!(ws(&app).describe()["redo"].as_u64(), Some(0)); +} + +/// Object Selection: a click picks the object under it, a drag the object in the rectangle. +#[test] +fn object_selection_clicks_and_drags() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + ws_mut(&mut app).zoom = 1.0; + ui(&mut app, json!({"tool": "object", "stroke": {"points": [[120.0, 50.0], [121.0, 50.0]]}})); + ui(&mut app, json!({"stroke": {"points": [[90.0, 10.0], [150.0, 90.0]]}})); + let e = &ws(&app).edits; + assert_eq!(e[0]["point"], json!([120.0, 50.0])); + assert_eq!(e[1]["rect"], json!([90.0, 10.0, 60.0, 80.0])); +} + +fn ws_mut(app: &mut PhotocraftApp) -> &mut SelectMaskWorkspace { + app.distort.select_mask.as_mut().unwrap() +} + +/// Select Subject, Clear Selection and Invert are edits too; Reset drops everything at once, +/// undoably. +#[test] +fn buttons_and_reset() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + ui(&mut app, json!({"clearSelection": true})); + assert_eq!(selected(&mut app), 0); + ui(&mut app, json!({"invert": true})); + assert_eq!(selected(&mut app), 160 * 100); + ui(&mut app, json!({"selectSubject": true})); + assert_eq!(ws(&app).edits.last().and_then(|e| e["tool"].as_str()), Some("subject")); + ui(&mut app, json!({"radius": 12, "view": "blackWhite", "remember": true})); + let d = ui(&mut app, json!({"reset": true})); + assert_eq!((d["edits"].as_u64(), d["settings"]["radius"].as_f64(), d["settings"]["view"].as_str()), (Some(0), Some(0.0), Some("onionSkin"))); + assert_eq!(d["settings"]["remember"].as_bool(), Some(true), "Reset keeps Remember Settings"); + assert_eq!(selected(&mut app), 400); + let d = ui(&mut app, json!({"undo": true})); + assert_eq!((d["edits"].as_u64(), d["settings"]["radius"].as_f64()), (Some(3), Some(12.0))); +} + +/// OK runs `select.refineEdge` with the edits, strokes and settings: one history step, the same +/// result as the command with those params, and the workspace closes. +#[test] +fn ok_is_one_step_and_matches_the_command() { + let mut app = app_with_selection(); + let mut direct = app_with_selection(); + open(&mut app).unwrap(); + ui(&mut app, json!({"radius": 2, "stroke": {"points": [[120.0, 50.0]]}})); + ui(&mut app, json!({"tool": "brush", "stroke": {"points": [[40.0, 60.0], [60.0, 60.0]]}})); + let params = commit_params(ws(&app)); + let steps = app.session.active().unwrap().history.past_len(); + let r = ui(&mut app, json!({"commit": true})); + assert_eq!(r["committed"], json!(true)); + assert!(app.distort.select_mask.is_none(), "OK closes"); + assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1); + assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Select and Mask")); + direct.session.execute(CMD, params).unwrap(); + for (x, y) in [(5, 5), (120, 50), (50, 60), (50, 80), (79, 50)] { + assert_eq!(cov(&app, x, y), cov(&direct, x, y), "({x}, {y})"); + } + assert!(cov(&app, 120, 50) > 0.5 && cov(&app, 50, 60) > 0.5 && cov(&app, 50, 80) < 0.5); +} + +/// Cancel leaves the document as it was. +#[test] +fn cancel_changes_nothing() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + ui(&mut app, json!({"invert": true, "radius": 10})); + let steps = app.session.active().unwrap().history.past_len(); + let r = ui(&mut app, json!({"cancel": true})); + assert_eq!(r["cancelled"], json!(true)); + assert!(app.distort.select_mask.is_none()); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); + assert!(cov(&app, 5, 5) > 0.99 && cov(&app, 100, 50) < 0.01); +} + +/// Remember Settings keeps the settings for the next time; without it the workspace opens on +/// Photoshop's defaults. +#[test] +fn remember_settings() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + ui(&mut app, json!({"remember": true, "radius": 7, "view": "overlay", "overlayColor": [0, 0, 255], "commit": true})); + app.session.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap(); + open(&mut app).unwrap(); + let s = ws(&app).describe()["settings"].clone(); + assert_eq!((s["radius"].as_f64(), s["view"].as_str(), s["overlayColor"].clone()), (Some(7.0), Some("overlay"), json!([0, 0, 255]))); + ui(&mut app, json!({"remember": false, "commit": true})); + app.session.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap(); + open(&mut app).unwrap(); + let s = ws(&app).describe()["settings"].clone(); + assert_eq!((s["radius"].as_f64(), s["view"].as_str()), (Some(0.0), Some("onionSkin"))); +} + +/// Decontaminate Colors makes new pixels, so it outputs to a new layer (as Photoshop does). +#[test] +fn decontaminate_outputs_to_a_new_layer() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + let d = ui(&mut app, json!({"decontaminate": true})); + assert_eq!(d["settings"]["output"].as_str(), Some("newLayerWithMask")); + let d = ui(&mut app, json!({"output": "newDocument"})); + assert_eq!(d["settings"]["output"].as_str(), Some("newDocument")); + let d = ui(&mut app, json!({"output": "selection"})); + assert_eq!(d["settings"]["output"].as_str(), Some("newLayerWithMask")); +} + +/// A layer mask without a selection (or the targeted mask) is refined and output to the mask. +#[test] +fn a_layer_mask_opens_on_the_mask() { + let mut app = app_with_selection(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + // Revealing the selection uses it up (#8381), leaving the mask alone. + app.session.execute("layer.layerMask.revealSelection", json!({})).unwrap(); + assert!(app.session.active().unwrap().doc.selection.is_none()); + open(&mut app).unwrap(); + assert_eq!(ws(&app).describe()["settings"]["output"].as_str(), Some("layerMask")); + assert_eq!(selected(&mut app), 400, "the mask is what is shown"); +} + +/// Bad control params are errors, never a panic, and change nothing. +#[test] +fn hostile_control_params_are_errors() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + for bad in [ + json!({"tool": "wand"}), + json!({"mode": "intersect"}), + json!({"view": "sideways"}), + json!({"output": "printer"}), + json!({"indicates": "both"}), + json!({"stroke": {"points": "x"}}), + json!({"stroke": {"points": [[1.0]]}}), + ] { + assert!(control(&mut app, &bad).is_err(), "{bad}"); + } + for odd in [json!({"size": -5}), json!({"size": 1e12}), json!({"hardness": 500}), json!({"radius": 1e9, "opacity": -3}), json!({"stroke": {"points": []}})] + { + control(&mut app, &odd).unwrap(); + } + let d = ws(&app).describe(); + assert_eq!((d["tool"].as_str(), d["edits"].as_u64()), (Some("quick"), Some(0))); + assert_eq!((d["size"].as_f64(), d["hardness"].as_f64(), d["settings"]["radius"].as_f64()), (Some(5000.0), Some(100.0), Some(250.0))); + assert!(control(&mut app, &json!({"waitPreview": true})).is_ok(), "the clamped settings still preview"); +} + +/// High Quality Preview runs on the document at full size; otherwise on a copy at most 1600 px. +#[test] +fn high_quality_preview_is_full_size() { + let mut app = app_sized(3300, 60); + open(&mut app).unwrap(); + assert_eq!(ws(&app).describe()["proxyScale"].as_u64(), Some(3)); + let reduced = selected(&mut app); + assert!((40..=60).contains(&reduced), "{reduced} reduced px of the 20 × 20 selection"); + assert_eq!(ui(&mut app, json!({"highQualityPreview": true}))["proxyScale"].as_u64(), Some(1)); + assert_eq!(selected(&mut app), 400); +} + +/// Edits scale with the reduced copy: points, rectangles and sizes by 1 / k. +#[test] +fn edits_scale_to_the_reduced_copy() { + let e = json!({"tool": "quick", "points": [[30.0, 60.0]], "size": 30.0, "rect": [3, 6, 9, 12], "point": [15, 3]}); + assert_eq!(scale_edit(&e, 3), json!({"tool": "quick", "points": [[10.0, 20.0]], "size": 10.0, "rect": [1.0, 2.0, 3.0, 4.0], "point": [5.0, 1.0]})); + assert_eq!(scale_edit(&json!({"size": 2.0}), 4)["size"], json!(1.0), "never below a pixel"); + assert_eq!(scale_edit(&json!("x"), 2), json!("x")); +} + +/// With Real-Time Refinement the stroke being drawn is in the preview before it is released. +#[test] +fn real_time_refinement_previews_the_stroke_being_drawn() { + for real_time in [true, false] { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + ui(&mut app, json!({"realTimeRefinement": real_time})); + let before = selected(&mut app); + crate::distort_ui::pointer(&mut app, ToolEvent::Down { x: 120.0, y: 50.0, pressure: 1.0 }, Modifiers::NONE); + crate::distort_ui::pointer(&mut app, ToolEvent::Move { x: 130.0, y: 50.0, pressure: 1.0 }, Modifiers::NONE); + let during = selected(&mut app); + assert_eq!(during > before, real_time, "real time {real_time}: {before} → {during}"); + // The preview knows how much of the stroke it shows: the rest is drawn over it. + assert_eq!(ws(&app).preview.trail, if real_time { 2 } else { 0 }); + crate::distort_ui::pointer(&mut app, ToolEvent::Up { x: 130.0, y: 50.0 }, Modifiers::NONE); + assert!(ws(&app).trail.is_some(), "the released stroke stays drawn until its preview comes"); + assert!(selected(&mut app) > before); + assert_eq!(ws(&app).edits.len(), 1); + assert!(ws(&app).trail.is_none() && ws(&app).preview.trail == usize::MAX, "then the preview shows all of it"); + } +} + +/// The preview keeps the selection after the edits it applied and starts the next from there: +/// after undo it starts over. +#[test] +fn the_preview_reuses_the_edits_it_applied() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + ui(&mut app, json!({"stroke": {"points": [[120.0, 50.0]]}})); + selected(&mut app); + assert_eq!(ws(&app).preview.cache.as_ref().map(|c| c.edits.len()), Some(1)); + ui(&mut app, json!({"tool": "brush", "stroke": {"points": [[40.0, 60.0]]}})); + let both = selected(&mut app); + assert_eq!(ws(&app).preview.cache.as_ref().map(|c| c.edits.len()), Some(2)); + ui(&mut app, json!({"undo": true})); + let one = selected(&mut app); + assert!(one < both); + assert_eq!(ws(&app).preview.cache.as_ref().map(|c| c.edits.len()), Some(1)); +} + +/// Sends `key` with `modifiers` through the workspace's key handling. +fn press(app: &mut PhotocraftApp, ctx: &egui::Context, key: Key, modifiers: Modifiers) { + let ev = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + let input = egui::RawInput { events: vec![ev], ..Default::default() }; + ctx.run_ui(input, |ui| keys(app, ui.ctx())).textures_delta.clear(); +} + +/// Photoshop's keys: W, R, B, L, H, Z pick the tools (Shift+W and Shift+L cycle their slots), +/// [ and ] size the brush, the view letters, F, J and P the view, Ctrl+Z undoes. +#[test] +fn keys_like_photoshop() { + let mut app = app_with_selection(); + let ctx = egui::Context::default(); + open(&mut app).unwrap(); + let tool = |app: &PhotocraftApp| ws(app).tool; + for (key, mods, want) in [ + (Key::R, Modifiers::NONE, SmTool::RefineEdge), + (Key::B, Modifiers::NONE, SmTool::Brush), + (Key::W, Modifiers::NONE, SmTool::Quick), + (Key::W, Modifiers::SHIFT, SmTool::Object), + (Key::H, Modifiers::NONE, SmTool::Hand), + (Key::W, Modifiers::NONE, SmTool::Object), + (Key::L, Modifiers::NONE, SmTool::Lasso), + (Key::L, Modifiers::SHIFT, SmTool::PolygonalLasso), + (Key::Z, Modifiers::NONE, SmTool::Zoom), + (Key::L, Modifiers::NONE, SmTool::PolygonalLasso), + (Key::B, Modifiers::NONE, SmTool::Brush), + ] { + press(&mut app, &ctx, key, mods); + assert_eq!(tool(&app), want, "{mods:?}+{key:?}"); + } + press(&mut app, &ctx, Key::CloseBracket, Modifiers::NONE); + assert_eq!(ws(&app).brush_size, 33.0); + press(&mut app, &ctx, Key::OpenBracket, Modifiers::NONE); + assert_eq!(ws(&app).brush_size, 30.0); + let view = |app: &PhotocraftApp| ws(app).opts.view; + for (key, want) in [ + (Key::K, Mode::BlackWhite), + (Key::A, Mode::OnBlack), + (Key::T, Mode::OnWhite), + (Key::V, Mode::Overlay), + (Key::Y, Mode::OnLayers), + (Key::M, Mode::MarchingAnts), + (Key::F, Mode::Overlay), + (Key::O, Mode::OnionSkin), + ] { + press(&mut app, &ctx, key, Modifiers::NONE); + assert_eq!(view(&app), want, "{key:?}"); + } + press(&mut app, &ctx, Key::J, Modifiers::NONE); + press(&mut app, &ctx, Key::P, Modifiers::NONE); + assert!(ws(&app).opts.show_edge && ws(&app).opts.show_original); + ui(&mut app, json!({"invert": true})); + press(&mut app, &ctx, Key::Z, Modifiers::COMMAND); + assert_eq!(ws(&app).edits.len(), 0); + press(&mut app, &ctx, Key::Z, Modifiers::COMMAND | Modifiers::SHIFT); + assert_eq!(ws(&app).edits.len(), 1); +} + +/// The Polygonal Lasso takes clicks: Enter closes the polygon (not OK), Esc drops it (not Cancel), +/// clicking the first vertex closes it too. +#[test] +fn polygonal_lasso_enter_and_escape() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + ws_mut(&mut app).zoom = 1.0; + ui(&mut app, json!({"tool": "polygonalLasso"})); + let click = |app: &mut PhotocraftApp, x: f64, y: f64| crate::distort_ui::pointer(app, ToolEvent::Down { x, y, pressure: 1.0 }, Modifiers::NONE); + for (x, y) in [(30.0, 30.0), (60.0, 30.0), (60.0, 60.0)] { + click(&mut app, x, y); + } + assert_eq!(ws(&app).describe()["polygon"].as_u64(), Some(3)); + assert!(enter(&mut app), "Enter closes the polygon"); + assert!(app.distort.select_mask.is_some(), "and leaves the workspace open"); + assert_eq!(ws(&app).edits.len(), 1); + click(&mut app, 30.0, 30.0); + click(&mut app, 60.0, 30.0); + escape(&mut app); + assert!(app.distort.select_mask.is_some() && ws(&app).poly.is_none(), "Esc drops the polygon only"); + for (x, y) in [(70.0, 70.0), (90.0, 70.0), (90.0, 90.0), (71.0, 71.0)] { + click(&mut app, x, y); + } + assert_eq!(ws(&app).edits.len(), 2, "clicking the first vertex closes it"); + escape(&mut app); + assert!(app.distort.select_mask.is_none(), "Esc with no polygon cancels"); +} + +/// The workspace draws: tools on the left, the options bar, the Properties panel with Photoshop's +/// sections and buttons; clicking a tool picks it, OK commits. +#[test] +fn renders_photoshops_layout() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.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; + } + show(app, &ctx); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + for label in [ + "Remember Settings", + "View Mode", + "Real-Time Refinement", + "High Quality Preview", + "Refine Mode", + "Color Aware", + "Edge Detection", + "Smart Radius", + "Global Refinements", + "Clear Selection", + "Invert", + "Output Settings", + "Decontaminate Colors", + "Select Subject", + "Refine Hair", + "Sample All Layers", + "OK", + "Cancel", + ] { + assert!(h.query_by_label_contains(label).is_some(), "{label} is shown"); + } + assert!(h.get_by_label("Refine Hair").accesskit_node().is_disabled(), "Refine Hair is greyed out"); + h.get_by_label("Brush Tool (B)").click(); + h.run_steps(2); + assert_eq!(h.state().distort.select_mask.as_ref().map(|w| w.tool), Some(SmTool::Brush)); + assert!(h.query_by_label_contains("Hardness").is_some(), "the Brush's options bar"); + h.get_by_label("Invert").click(); + h.run_steps(2); + assert_eq!(h.state().distort.select_mask.as_ref().map(|w| w.edits.len()), Some(1)); + h.get_by_label("OK").click(); + h.run_steps(2); + assert!(h.state().distort.select_mask.is_none(), "OK closes"); + let app = h.state(); + assert!(cov(app, 5, 5) < 0.01 && cov(app, 100, 50) > 0.99, "the inverted selection"); +} + +/// Panning shows Photoshop's hand: open while Space is held (or the Hand tool is picked), closed +/// while dragging; and Space doesn't press the panel button clicked last. +#[test] +fn panning_shows_the_hand() { + use egui::{PointerButton, Pos2}; + let mut app = app_with_selection(); + open(&mut app).unwrap(); + ui(&mut app, json!({"tool": "brush"})); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.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::distort_ui::keys(app, &ctx); + keys(app, &ctx); + show(app, &ctx); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + // Click Invert, then move over the image. + h.get_by_label("Invert").click(); + h.run_steps(2); + assert_eq!(h.state().distort.select_mask.as_ref().map(|w| w.edits.len()), Some(1)); + let over = Pos2::new(LEFT_W + 300.0, BAR_H + TAB_H + 300.0); + h.event(egui::Event::PointerMoved(over)); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "the brush's own cursor"); + let space = |h: &mut egui_kittest::Harness<'_, PhotocraftApp>, pressed: bool| { + h.event(egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(1); + }; + space(&mut h, true); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grab, "Space: the open hand"); + assert_eq!(h.state().distort.select_mask.as_ref().map(|w| w.edits.len()), Some(1), "Space didn't press Invert again"); + let centre = h.state().distort.select_mask.as_ref().map(|w| w.center).unwrap(); + h.event(egui::Event::PointerButton { pos: over, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for dx in [5.0, 20.0, 40.0] { + h.event(egui::Event::PointerMoved(Pos2::new(over.x + dx, over.y))); + h.run_steps(1); + } + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing, "dragging: the closed hand"); + h.event(egui::Event::PointerButton { pos: Pos2::new(over.x + 40.0, over.y), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(1); + space(&mut h, false); + let w = h.state().distort.select_mask.as_ref().unwrap(); + assert!(w.center[0] < centre[0], "the view panned: {:?} → {:?}", centre, w.center); + assert!(w.edits.len() == 1 && w.strokes.is_empty(), "panning painted nothing"); + // The Hand tool shows the open hand without Space. + h.state_mut().distort.select_mask.as_mut().unwrap().pick(SmTool::Hand); + h.event(egui::Event::PointerMoved(over)); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grab); +} + +/// The greyed Object Aware and Refine Hair say why they can't be used. +#[test] +fn greyed_controls_explain_themselves() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.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; + } + show(app, &ctx); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + for (label, why) in [("Object Aware", "segmentation model"), ("Refine Hair", "hair detection")] { + assert!(h.get_by_label(label).accesskit_node().is_disabled(), "{label} is greyed"); + assert!(h.query_by_label_contains(why).is_none(), "{label}: no tooltip before hovering"); + let at = h.get_by_label(label).rect().center(); + h.hover_at(at); + h.run_steps(8); + assert!(h.query_by_label_contains(why).is_some(), "{label}: hovering says why"); + h.hover_at(egui::pos2(700.0, 450.0)); + h.run_steps(4); + } +} diff --git a/crates/ui-egui/src/select_mask_view.rs b/crates/ui-egui/src/select_mask_view.rs index 9f5440c4aa..7ef5de6ddd 100644 --- a/crates/ui-egui/src/select_mask_view.rs +++ b/crates/ui-egui/src/select_mask_view.rs @@ -57,7 +57,7 @@ impl Mode { Mode::ALL.into_iter().find(|m| m.id() == id) } - fn label(self) -> &'static str { + pub(crate) fn label(self) -> &'static str { match self { Mode::OnionSkin => tl!("Onion Skin"), Mode::MarchingAnts => tl!("Marching Ants"), @@ -70,7 +70,7 @@ impl Mode { } /// The single-letter key that picks this view. - fn key(self) -> egui::Key { + pub(crate) fn key(self) -> egui::Key { match self { Mode::OnionSkin => egui::Key::O, Mode::MarchingAnts => egui::Key::M, @@ -83,7 +83,7 @@ impl Mode { } /// The view's opacity (percent) until it is changed; `None` for views without one. - fn default_opacity(self) -> Option { + pub(crate) fn default_opacity(self) -> Option { match self { Mode::OnionSkin | Mode::Overlay => Some(50.0), Mode::OnBlack | Mode::OnWhite => Some(100.0), @@ -91,7 +91,7 @@ impl Mode { } } - fn next(self) -> Mode { + pub(crate) fn next(self) -> Mode { let i = Mode::ALL.iter().position(|m| *m == self).unwrap_or(0); Mode::ALL.get(i + 1).copied().unwrap_or(Mode::OnionSkin) } @@ -425,6 +425,10 @@ struct View { opacity: f32, show_edge: bool, show_original: bool, + /// Overlay's colour (Photoshop's default rubylith red) and whether it marks the selected + /// areas rather than the masked ones. + color: Color, + selected: bool, } impl View { @@ -436,6 +440,16 @@ impl View { opacity: v.and_then(|v| v.get("opacity")).and_then(Value::as_f64).filter(|o| o.is_finite()).unwrap_or(0.5).clamp(0.0, 1.0) as f32, show_edge: b("showEdge"), show_original: b("showOriginal"), + color: v + .and_then(|v| v.get("color")) + .and_then(Value::as_array) + .filter(|c| c.len() == 3) + .and_then(|c| { + let ch = |i: usize| c.get(i).and_then(Value::as_f64).filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 1.0) as f32); + Some(Color::rgb(ch(0)?, ch(1)?, ch(2)?)) + }) + .unwrap_or(Color::rgb(1.0, 0.0, 0.0)), + selected: v.and_then(|v| v.get("indicates")).and_then(Value::as_str) == Some("selected"), } } } @@ -448,9 +462,21 @@ fn length(params: &Value, key: &str, k: u32) -> f64 { /// The preview of the Select and Mask dialog for `doc`: the proxy reduced by `k`, shown in the /// requested view. `None` when the refinement fails or is cancelled. pub fn preview_document(doc: &Document, active: Option, params: &Value, k: u32, cancel: Option<&photocraft_engine::jobs::JobCtx>) -> Option { + let k = k.max(1); + preview_proxy(crate::proxy::proxy_document(doc, k), active, params, k, cancel) +} + +/// [`preview_document`] on `proxy`, the document already reduced by `k` (the Select and Mask +/// workspace builds it itself, its selection already carrying the workspace's tool edits). +pub fn preview_proxy( + mut proxy: Document, + active: Option, + params: &Value, + k: u32, + cancel: Option<&photocraft_engine::jobs::JobCtx>, +) -> Option { let view = View::of(params); let k = k.max(1); - let mut proxy = crate::proxy::proxy_document(doc, k); let active = active.filter(|id| proxy.layer(*id).is_some()); // What Select and Mask starts from: the selection, else the active layer's mask. let original = proxy.selection.clone().or_else(|| active.and_then(|id| proxy.layer(id)).and_then(|l| l.mask.as_ref()).map(|m| m.surface.clone())); @@ -546,7 +572,10 @@ fn show(doc: &mut Document, active: Option, view: &View, coverage: &Cov doc.selection = Some(coverage.surface(|v| v)); match view.mode { Mode::MarchingAnts => {} - Mode::Overlay => doc.layers.push(veil("Overlay", Color::rgb(1.0, 0.0, 0.0), o, Some(coverage.surface(|v| 1.0 - v)))), + Mode::Overlay => { + let marked = if view.selected { coverage.surface(|v| v) } else { coverage.surface(|v| 1.0 - v) }; + doc.layers.push(veil("Overlay", view.color, o, Some(marked))); + } Mode::OnBlack => doc.layers.push(veil("On Black", Color::BLACK, o, Some(coverage.surface(|v| 1.0 - v)))), Mode::OnWhite => doc.layers.push(veil("On White", Color::WHITE, o, Some(coverage.surface(|v| 1.0 - v)))), Mode::BlackWhite => { @@ -692,6 +721,24 @@ mod tests { assert!(pixel(&edge, 35, 10)[0] > 0.99 && pixel(&edge, 55, 10)[0] < 0.01, "Show Edge includes the painted area"); } + /// Overlay takes Photoshop's colour swatch and Indicates: the masked (default) or the selected + /// areas carry the tint. + #[test] + fn overlay_colour_and_what_it_indicates() { + let doc = half_selected(); + let active = doc.layers.first().map(|l| l.id); + let near = |a: &[f32], b: &[f32]| a.iter().zip(b).all(|(a, b)| (a - b).abs() < 0.02); + let view = |indicates: &str| json!({"__view": {"mode": "overlay", "opacity": 1.0, "color": [0.0, 0.0, 1.0], "indicates": indicates}}); + let masked = preview_document(&doc, active, &view("masked"), 1, None).expect("a preview"); + assert!(near(&pixel(&masked, 35, 10), &[0.0, 0.0, 1.0, 1.0]) && near(&pixel(&masked, 5, 10), &[1.0, 1.0, 1.0, 1.0])); + let selected = preview_document(&doc, active, &view("selected"), 1, None).expect("a preview"); + assert!(near(&pixel(&selected, 5, 10), &[0.0, 0.0, 1.0, 1.0]) && near(&pixel(&selected, 35, 10), &[1.0, 1.0, 1.0, 1.0])); + // A hostile colour falls back to red. + let bad = json!({"__view": {"mode": "overlay", "opacity": 1.0, "color": ["x", 2, null]}}); + let red = preview_document(&doc, active, &bad, 1, None).expect("a preview"); + assert!(near(&pixel(&red, 35, 10), &[1.0, 0.0, 0.0, 1.0])); + } + /// Show Edge shows the band around the selection's edge; Show Original the unrefined selection. #[test] fn show_edge_and_show_original() { diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 3300b9d92e..3e1def57ae 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -472,11 +472,15 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { if app.distort.caf.is_some() && !ctx.text_edit_focused() { crate::caf_ui::keys(app, ctx); } + // And Select and Mask's tools, views and brush size. + if app.distort.select_mask.is_some() && !ctx.text_edit_focused() { + crate::select_mask_ui::keys(app, ctx); + } use crate::shortcut_dispatch::{Focus, dispatch_pressed}; let focus = Focus::of(ctx); // Dialogs (and Liquify's panel while one of its controls has focus) keep canvas zoom; a // focused text field keeps its keys. - let liquify_focus = (app.distort.liquify.is_some() || app.distort.caf.is_some()) && focus != Focus::None; + let liquify_focus = (app.distort.liquify.is_some() || app.distort.caf.is_some() || app.distort.select_mask.is_some()) && focus != Focus::None; if liquify_focus || !app.ui.dialogs.is_empty() || app.discard.is_some() { if focus != Focus::Text { nav_keys(app, ctx); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9e60af2136..c6f020ebe9 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -70,6 +70,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - A save dialog's answer that PhotoCraft changes, by giving a name without an extension the suggested file's or by lowercasing the extension (Preferences › File Handling › Lowercase Extension), opens a replace prompt when a file exists under the changed name: a dialog with `__replaceSaved`, `message`, `detail` and `path` (the file the save would replace). `ui.dialog.confirm` writes the file; `ui.dialog.cancel` ends the save without writing, as Cancel in the save dialog does. The file dialog counts as open until the prompt is answered. A name that differs only in letter case and reaches the same file (Windows and macOS by default) does not open it. - Layer Style (`layerStyle {effect?}` opens on that effect kind): the fields hold the dialog's whole state, so agents read and drive it like a user. `effects` lists every effect instance in the layer's order as `{id, kind, on, params, fx}` (`fx` is the effect snapshot the dialog loaded; OK edits that snapshot with `params`, so anything the dialog doesn't model survives). `selected` picks the page (`blendingOptions` or an instance id), `preview` (default on) gates the live canvas preview, `p:blendingOptions` edits blend mode/opacity/fill opacity, and `patternList` names the usable patterns. OK replaces the layer's effects in one step (`layer.layerStyle.replace`); Cancel discards. "New Style…" saves the pending state via `style.presets.new {effects}`, a swatch click applies a style preset, and Make/Reset Default use `layer.layerStyle.makeDefault` / `layer.layerStyle.defaultFor` - Preferences (`ui.menu.invoke {id: "edit.preferences.interface"}`) exposes the sections in its `values` field. Apply (`ui.dialog.apply`) and OK (`ui.dialog.confirm`) on Preferences are refused over control because automation denies whole-preferences updates and filesystem-bearing preference sections. A refused `ui.dialog.apply` returns an error and keeps the dialog open; a refused `ui.dialog.confirm` returns an error but closes and removes the dialog. `ui.dialog.cancel` closes the dialog without saving. Individual safe preferences can instead be changed with `engine.execute` using `prefs.set` (e.g. `{"command": "prefs.set", "params": {"path": "interface.showTooltips", "value": false}}`). Settings marked for the next launch still require a restart. + - Select › Select and Mask… (`ui.menu.invoke {id: "select.selectAndMask"}`, Ctrl+Alt+R) opens Photoshop's workspace; `ui.inspect` reports it as `distort.selectAndMask`. Drive it with `ui.menu.invoke {id: "select.selectAndMask", params: {ui: {…}}}`: `tool` (`quick|refineEdge|brush|object|lasso|polygonalLasso|hand|zoom`), `mode` (`add|subtract`), `size`, `hardness`, `sampleAllLayers`, `stroke: {points: [[x,y],…], alt?}` (a drag with the current tool in document px: Object Selection takes a click or the rectangle's corners, the lassos the polygon), `selectSubject`, `clearSelection`, `invert`, the settings (`view`, `opacity`, `showEdge`, `showOriginal`, `realTimeRefinement`, `highQualityPreview`, `overlayColor`, `indicates`, `radius`, `smartRadius`, `smooth`, `feather`, `contrast`, `shiftEdge`, `decontaminate`, `amount`, `output`, `remember`), `undo`, `redo`, `reset`, `waitPreview`, `commit` (OK: `select.refineEdge` with the workspace's `edits` and `refineStrokes`, one history step) and `cancel`. The reply describes the workspace (`edits`, `strokes`, `selectedProxyPixels`, `previewReady`, …). - Edit › Fill… (`ui.menu.invoke {id: "edit.fill"}`, Shift+F5, Shift+Backspace) opens the Fill dialog; its fields are `edit.fill`'s params (`contents`, `color`, `pattern`, `colorAdaptation`, `mode`, `opacity`, `preserveTransparency`), and OK remembers them in the preferences (`dialogs["edit.fill"]`). - Save for Web (`ui.menu.invoke {id: "file.export.saveForWebLegacy"}`) is resizable with independently scrolling settings. Dialog fields `__view` (`original|optimized|2up|4up`), `__zoom` (`null` = Fit, otherwise a factor; 1 = 100% in physical display pixels), and `__center: [u,v]` control the shared preview navigation. `__previewRects` reports pane rectangles in screen points. Drag a preview to pan, scroll/pinch to zoom at the pointer, or double-click to fit. GIF/PNG-8 expose `colorTable: ["#rrggbb", …]` (opaque source entries) and `colorTableEdits: {"0":"#rrggbb", …}` (zero-based replacements after quantization). A swatch opens a color picker; edits update the preview and saved file without remapping pixel indices or changing the document. Reset clears replacements. Changing palette/size/matte/transparency settings regenerates the table and clears index-based edits. Previews and generated tables use a bounded 2048-pixel proxy; full-size export uses that explicit table unchanged. `__paletteApproximate` reports proxy-derived palette generation, and enlarged proxy previews are labelled. The engine export command accepts the same table/edit parameters; its no-path estimate returns the source `colorTable` and `transparentIndex` as well as size statistics. Opening a new dialog does not carry a previous image's table across. Image Size has `width` and `height` (px), `percent` and `resample` (`nearest|bilinear|bicubic|bicubicSmoother|bicubicSharper`, default `bicubic`); `__constrain` (default true at each opening) links the two sides in proportion. A side edit sets both sides and removes `percent`, which the export command otherwise applies first; a Percent edit sets both sides from it. Each opening starts at the document's full size, while `resample` is remembered with the encoding choices. The document is never resized. - A pixel tool pressed on a type, shape, Smart Object or fill layer (e.g. through `ui.pointer`) opens the "Rasterize?" prompt instead of painting: a dialog with `__rasterize` (`type|shape|smartObject|fill`), `message`, `layer`, `tool` and `at`. `ui.dialog.confirm` runs the `layer.rasterize.*` command and then paints at `at` (two history states, `{"rasterized", "painted"}`); `ui.dialog.cancel` does nothing. diff --git a/docs/gaps.md b/docs/gaps.md index 5b5a04413b..d7914161a0 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-22 · **Change:** minor (G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; 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; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (G7: Select and Mask is Photoshop's workspace with its own tools, #7069, #8148, TOOL-214-1 still partial; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; 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; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **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 @@ -69,8 +69,10 @@ what an agent can't finish alone. - **Estimate:** 60–100 h (2–9 h each, per the calibration). ### G7. Workspaces shown as parameter dialogs -- **Missing:** Select and Mask workspace layout - (TOOL-214-1, Refine Hair #2671; its view modes preview live since #1754, Refine Edge Brush #2972); Content-Aware Fill workspace with a paintable sampling area +- **Missing:** Select and Mask's Refine Hair (#2671), Object Aware refine mode (#2788) and Presets + (TOOL-214-1; the workspace landed 2026-10-23, #7069, #8148: Photoshop's layout with its own + Quick Selection, Refine Edge Brush, Brush, Object Selection, Lasso and Polygonal Lasso, Hand and + Zoom, the view modes since #1754, Real-Time Refinement and High Quality Preview); Content-Aware Fill workspace with a paintable sampling area (TOOL-214-15); Vanishing Point plane editor (TOOL-214-14). Content-Aware Move's live preview of the result is done (TOOL-213-4, 2026-10-16, #6680: every selection the command takes, coarse first for large ones, then the exact result), as are Transform On Drop and Content-Aware Scale @@ -346,6 +348,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | G7: Select and Mask workspace (Photoshop's layout, its tools as `select.refineEdge` edits, Real-Time Refinement, High Quality Preview), #7069, #8148; Refine Hair, Object Aware and Presets remain; no estimate changed | | 2026-10-22 | minor | G1: 32-bit Hue/Saturation unclipped with Photoshop's saturation factor, bright colours keep their hue, Colorize's saturation scaled by 1 − \|Lightness\|; photoshop rgb32 hue-saturation and hue-saturation-colorize pass; Photoshop-oracle floor 188 → 190 of 258 (#5781) | | 2026-10-21 | minor | #8304: compound layer properties export as separate Set events, rename last; independent Photoshop replay pending; percentages and estimates unchanged | | 2026-10-21 | minor | G11: Batch and droplets take the result at the action's recorded Save / Save As step (Photoshop's "Override Action 'Save As' Commands"); estimate unchanged | diff --git a/docs/scorecard.md b/docs/scorecard.md index be32d94aee..adca3df683 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -149,7 +149,7 @@ Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpu | TOOL-213-13 | Brush regression tests: no seams, hard edge, zero-scatter defaults, ABR never black | done | [#213](https://github.com/storytold/photocraft/issues/213) | Seam, hard-edge and zero-scatter tests in brush_tests.rs; ABR tip colour test in abr_map/tests.rs | | TOOL-213-14 | Tablet: no OS arrow cursor over the canvas on macOS, Windows and Linux | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Brush tools set CursorIcon::None (canvas.rs:1259-1275); not verified per OS | | TOOL-213-18 | Pattern Stamp tool: sibling of Clone Stamp in the S flyout | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::PatternStamp in the S group; paint.patternStamp stamps the Patterns-panel tile with Aligned and Impressionist (an approximation: it jitters each dab's tile phase, not Photoshop's smeared impressionist look); engine tests cover depths, seams, hostile input | -| TOOL-214-1 | Select and Mask workspace: view modes, Refine Edge brush, 6 outputs | partial | [#214](https://github.com/storytold/photocraft/issues/214) | select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask, New Document, New Document with Mask (#2998); the workspace tools Quick Selection, Brush, Lasso, Object Selection, Select Subject, Clear Selection and Invert run as select.refineEdge edits, drivable by agents (#7069); no workspace layout or toolbar yet | +| TOOL-214-1 | Select and Mask workspace: view modes, Refine Edge brush, 6 outputs | partial | [#214](https://github.com/storytold/photocraft/issues/214) | select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask, New Document, New Document with Mask (#2998); Photoshop's workspace (select_mask_ui.rs, #7069, #8148): options bar, tools Quick Selection W, Refine Edge Brush R, Brush B, Object Selection W, Lasso and Polygonal Lasso L, Hand H, Zoom Z, Properties with Remember Settings, View Mode (Real-Time Refinement, High Quality Preview, Overlay colour and Indicates), Refine Mode, Edge Detection, Global Refinements with Clear Selection and Invert, Output Settings; the tools run as select.refineEdge edits; Ctrl+Z per edit; no Refine Hair (#2671), Object Aware (#2788) or Presets | | TOOL-214-2 | Marquee: Shift square, Alt from centre, live W x H readout | done | [#214](https://github.com/storytold/photocraft/issues/214) | canvas.rs marquee_corners: Shift square or circle, Alt from the centre, live W x H readout beside the cursor (marquee_readout); marquee_tests.rs | | TOOL-214-3 | Color Range test: Sampled Colors at Fuzziness 0 selects the exact match only | done | [#214](https://github.com/storytold/photocraft/issues/214) | color_range_fuzziness_zero_selects_exact_match_only in selection_cmds.rs asserts exact match is selected and 1-level-off pixels are rejected at fuzziness 0; tonal presets covered in color_range_presets | | TOOL-214-4 | Move tool: box-select layers, Auto-Select hover outline, double-click fit, drag to a tab | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Auto-Select hover outline (Preferences › Tools › Show Highlight on Rollover, #2834) and box-select layers from an empty spot (move_box.rs:59, layer.selectInRect, #2844) done; dragging layers with the Move tool (or from the Layers panel) onto another document's tab copies them there (layer_transfer.rs:162, layer.copyToDocument, #589/#851); double-click fit and dragging a selection's pixels to another document open | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c6af89fada..836078eb11 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PhotoCraft vs Adobe Photoshop: target-app parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-21 · **Change:** minor (compound layer properties export as separate Photoshop Set events, #8304; JPEG Options dialog landed, FILE-215-19 partial; Print › Functions › Bleed through `file.print`; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-23 · **Change:** minor (Select and Mask workspace with its own tools, #7069, #8148, percentages not re-measured; compound layer properties export as separate Photoshop Set events, #8304; JPEG Options dialog landed, FILE-215-19 partial; Print › Functions › Bleed through `file.print`; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); @@ -75,7 +75,7 @@ Weights reflect what a working Photoshop user depends on (photographers, retouch | Dimension | Weight | % ready | Remaining (agent-h) | Evidence | Detail | |---|---:|---:|---:|---|---| -| Features in depth (tools, layers, selections, type, filters, transforms) | 25% | 55% | 600–1,000 | Scorecard checklists: Tools 20 done / 15 partial / 6 missing, Type 0 / 3 / 6, Automation 3 / 2 / 6. 14 tools missing; Select and Mask, Vanishing Point and Content-Aware Fill are parameter dialogs, not workspaces; type on a path, manual kerning, RTL paragraphs missing | [gaps.md](gaps.md) | +| Features in depth (tools, layers, selections, type, filters, transforms) | 25% | 55% | 600–1,000 | Scorecard checklists: Tools 20 done / 15 partial / 6 missing, Type 0 / 3 / 6, Automation 3 / 2 / 6. 14 tools missing; Vanishing Point and Content-Aware Fill are parameter dialogs, not workspaces (Select and Mask is one since 2026-10-23); type on a path, manual kerning, RTL paragraphs missing | [gaps.md](gaps.md) | | UI / UX fidelity and feel | 15% | 40% | 350–600 | ~150 open UI issues; Workspace checklist 13 / 5 / 5 (2026-10-12; 5 / 7 / 11 on 2026-10-10); 27 of 156 preferences do nothing (was 46 of 149); no floating panel groups, thin Color panel, brush lag and pressure jumps reported | [ui-parity.md](ui-parity.md), [brush-parity.md](brush-parity.md) | | File formats and fidelity | 15% | 60% | 300–500 | PSD oracle on Photoshop-authored files 133/256 (52%); io corpus 146/170 (86%); psd-tools 236/309 (76%); round trips ~100% of corpus; but users report Photoshop refusing our files (#1281, #2469; a text-layer case, #2374, was fixed 2026-10-10); no Photoshop PDF, EPS, JPEG 2000, JPEG XL, AVIF read, CR3 | [file-format-parity.md](file-format-parity.md) | | Performance | 10% | 25% | 200–350 | 3 of 25 budgeted scenarios meet budget (baseline 2026-10-05); 150-layer nudge crashes the GPU path (P12); layer move on 15000×10000 16-bit 2.7 s p50; users report brush and cursor lag (#2619, #2566, #1994) | [scorecard.md](scorecard.md#performance) | @@ -168,7 +168,7 @@ parity for the area and are already contained in the dimension rows above. | Area | % | Hours | Notes | |---|---:|---:|---| | Layers, groups, masks, blend modes, smart objects, layer styles | 65% | 150–250 | Broad and GPU-accelerated. Open: a single pixel layer copies its pixels without its mask and styles (UI-217-23: smart object, type, shape, group, fill and adjustment layers and multi-layer selections copy and paste whole), smart-object perspective (FILE-215-6), a user contour library (FILE-215-15). Multi-layer Smart Object conversion (#2052, #2651), Stamp Visible (UI-217-22) and mask targeting by command (#2458) are done | -| Selections, Select and Mask, channels | 55% | 120–200 | All selection tools, single row/column marquee included (#2837). Select and Mask is a dialog with Photoshop's view modes, a Refine Edge Brush and six outputs, not a workspace (TOOL-214-1), no Refine Hair (#2671); Select Subject/Sky classical, 559 ms p50 on 24 MP vs a 100 ms budget | +| Selections, Select and Mask, channels | 55% | 120–200 | All selection tools, single row/column marquee included (#2837). Select and Mask is Photoshop's workspace with its own tools (Quick Selection, Refine Edge Brush, Brush, Object Selection, Lasso, Polygonal Lasso), view modes and six outputs (TOOL-214-1, #7069, #8148), no Refine Hair (#2671), Object Aware or Presets; Select Subject/Sky classical, 559 ms p50 on 24 MP vs a 100 ms budget | | Painting and brush engine | 55% | 150–250 | Every Brush Settings section exists; feel is the gap: lag on big brushes, pressure jumps, smoothing and edge quality reports ([brush-parity.md](brush-parity.md)) | | Retouching (healing, clone, patch, remove, content-aware) | 50% | 100–180 | Remove tool non-generative; Content-Aware Fill has no workspace (TOOL-214-15); no live stroke preview for the Mixer Brush yet (TOOL-213-7) | | Transform, warp, puppet, perspective, liquify | 55% | 100–180 | Free Transform modifiers right, with a right-click transform menu (TOOL-214-5) and H/V skew fields; Puppet Warp mesh follows the object outline (TOOL-214-6); Liquify called "useless" by a user (#2606) | @@ -565,6 +565,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | Select and Mask is Photoshop's workspace with its own tools (#7069, #8148); TOOL-214-1 stays partial (Refine Hair, Object Aware, Presets); percentages and estimates unchanged | | 2026-10-21 | minor | #8304: compound layer properties export as separate Set events, rename last; independent Photoshop replay pending; percentages and estimates unchanged | | 2026-10-19 | minor | JPEG Options dialog landed (FILE-215-19 partial: no Matte, progressive Scans or size preview); percentages unchanged | | 2026-10-18 | minor | Layers: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial; percentages unchanged | diff --git a/scorecard/tools.toml b/scorecard/tools.toml index 2ec39ab4d9..6e2bd77e1c 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -115,7 +115,7 @@ id = "TOOL-214-1" target = "Select and Mask workspace: view modes, Refine Edge brush, 6 outputs" status = "partial" issue = 214 -note = "select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask, New Document, New Document with Mask (#2998); the workspace tools Quick Selection, Brush, Lasso, Object Selection, Select Subject, Clear Selection and Invert run as select.refineEdge edits, drivable by agents (#7069); no workspace layout or toolbar yet" +note = "select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask, New Document, New Document with Mask (#2998); Photoshop's workspace (select_mask_ui.rs, #7069, #8148): options bar, tools Quick Selection W, Refine Edge Brush R, Brush B, Object Selection W, Lasso and Polygonal Lasso L, Hand H, Zoom Z, Properties with Remember Settings, View Mode (Real-Time Refinement, High Quality Preview, Overlay colour and Indicates), Refine Mode, Edge Detection, Global Refinements with Clear Selection and Invert, Output Settings; the tools run as select.refineEdge edits; Ctrl+Z per edit; no Refine Hair (#2671), Object Aware (#2788) or Presets" [[item]] id = "TOOL-214-2" From 148e87944a1e1b761a8be11d493bf07daba07fb1 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:31:29 +0200 Subject: [PATCH 38/86] Raise the Define Pattern size cap from 4000 to 8000 px per edge (#8820) --- crates/doc/src/pattern.rs | 2 +- crates/engine/src/pattern_cmds.rs | 18 ++++++++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/crates/doc/src/pattern.rs b/crates/doc/src/pattern.rs index 9a43c0a467..f203528c7e 100644 --- a/crates/doc/src/pattern.rs +++ b/crates/doc/src/pattern.rs @@ -5,7 +5,7 @@ use photocraft_geom::Rect; use photocraft_raster::Surface; -/// Largest pattern edge a file may declare (the PSD reader's limit; Define Pattern stops at 4000). +/// Largest pattern edge a file may declare (the PSD reader's limit; Define Pattern stops at 8000). pub const MAX_EDGE: u32 = 30_000; /// Most pixels a pattern may have to be painted. Renderers expand a pattern into a 16-byte-per-pixel diff --git a/crates/engine/src/pattern_cmds.rs b/crates/engine/src/pattern_cmds.rs index 95163d39f5..31f26e4e6c 100644 --- a/crates/engine/src/pattern_cmds.rs +++ b/crates/engine/src/pattern_cmds.rs @@ -197,6 +197,9 @@ pub fn default_name(s: &Session) -> String { (2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or(base) } +/// Longest edge Define Pattern accepts; 8000² stays under `MAX_PAINT_PIXELS`, so a defined pattern always paints. +const MAX_DEFINE_EDGE: u32 = 8000; + fn define(s: &mut Session, p: &Value) -> Result { let cmd = "edit.definePattern"; let st = s.active().ok_or(EngineError::NoDocument)?; @@ -212,8 +215,8 @@ fn define(s: &mut Session, p: &Value) -> Result { if r.is_empty() { return Err(bad(cmd, "the area is empty")); } - if r.width() > 4000 || r.height() > 4000 { - return Err(bad(cmd, "patterns are limited to 4000 × 4000 px")); + if r.width() > MAX_DEFINE_EDGE || r.height() > MAX_DEFINE_EDGE { + return Err(bad(cmd, "patterns are limited to 8000 × 8000 px")); } let mut surf = composite_surface(doc, r); // Opaque patterns drop their alpha channel (as Photoshop does). @@ -532,6 +535,17 @@ mod tests { } } + #[test] + fn define_pattern_accepts_edges_over_4000_up_to_8000() { + for (w, ok) in [(4100, true), (8000, true), (8001, false)] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": w, "height": 2, "depth": 8})).unwrap(); + s.execute("edit.fill", json!({"color": "#336699"})).unwrap(); + let r = s.execute("edit.definePattern", json!({})); + assert_eq!(r.is_ok(), ok, "width {w}: {r:?}"); + } + } + #[test] fn pattern_fill_layer_renders_at_every_depth_and_undoes() { for depth in [8u64, 16, 32] { From d387f52f1f2f5681b2c7d1c517aa5b5992c7bc88 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:32:39 +0200 Subject: [PATCH 39/86] Box-filter minified patterns in the CPU and GPU samplers (#8831) --- crates/compose/src/effects.rs | 6 +-- crates/compose/src/pattern.rs | 84 ++++++++++++++++++++++++++++++++--- crates/gpu/src/compose.wgsl | 35 +++++++++++---- crates/gpu/tests/it/parity.rs | 2 +- 4 files changed, 106 insertions(+), 21 deletions(-) diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index 264dc3529b..a55550094e 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -1257,8 +1257,7 @@ fn paint_pattern( m, |i| { let (x, y) = (f64::from(big.x0 + (i % w) as i32) + 0.5, f64::from(big.y0 + (i / w) as i32) + 0.5); - let (u, v) = place.map(x, y); - tile.sample(u, v) + tile.sample_area(&place, x, y) }, blend, opacity, @@ -1609,8 +1608,7 @@ fn bevel_height(shape: &Map, b: &Bevel, g: &BevelGeom, tex: &TextureCtx, pattern || (), |_, y, row, _| { for (x, o) in row.iter_mut().enumerate() { - let (u, v) = place.map(f64::from(x0 + x as i32) + 0.5, f64::from(y0 + y as i32) + 0.5); - let p = tile.sample(u, v); + let p = tile.sample_area(&place, f64::from(x0 + x as i32) + 0.5, f64::from(y0 + y as i32) + 0.5); let l = (0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]) * p[3]; *o += k * if t.invert { 1.0 - l } else { l }; } diff --git a/crates/compose/src/pattern.rs b/crates/compose/src/pattern.rs index b66ab0ad1e..258ef5664a 100644 --- a/crates/compose/src/pattern.rs +++ b/crates/compose/src/pattern.rs @@ -3,7 +3,9 @@ //! A pattern tiles the plane from an origin (the layer's frame when linked, else the canvas //! origin, plus the phase), scaled and rotated about that origin. Samples are bilinear with //! wrap-around on premultiplied colour, so integer placements at 100 % reproduce the tile -//! exactly and scaled ones stay seamless. +//! exactly and scaled ones stay seamless. Below 100 % each document pixel averages an +//! `n × n` grid of bilinear taps over its footprint in the tile (see [`pixel_taps`]), so a +//! minified tile is box-filtered instead of aliased; at 100 % and above it is a single tap. use std::collections::HashMap; use std::sync::{Arc, Mutex, OnceLock}; @@ -157,6 +159,33 @@ impl Tile { /// Bilinear, wrapping sample at continuous tile coordinates (pixel centres at `i + 0.5`). /// Returns straight alpha. pub fn sample(&self, u: f64, v: f64) -> [f32; 4] { + unpremultiply(self.sample_premul(u, v)) + } + + /// The pattern at the centre `(x, y)` of a document pixel, filtered over the pixel's + /// footprint in the tile when the placement shrinks it. Returns straight alpha. + pub fn sample_area(&self, place: &Placement, x: f64, y: f64) -> [f32; 4] { + let n = pixel_taps(place.inv_scale); + if n == 1 { + let (u, v) = place.map(x, y); + return self.sample(u, v); + } + let mut acc = [0.0f32; 4]; + for j in 0..n { + for i in 0..n { + let (u, v) = place.map(x + tap_offset(i, n), y + tap_offset(j, n)); + let q = self.sample_premul(u, v); + for c in 0..4 { + acc[c] += q[c]; + } + } + } + let k = 1.0 / (n * n) as f32; + unpremultiply([acc[0] * k, acc[1] * k, acc[2] * k, acc[3] * k]) + } + + /// Bilinear, wrapping sample, premultiplied. + fn sample_premul(&self, u: f64, v: f64) -> [f32; 4] { let (u, v) = (u - 0.5, v - 0.5); let (x0, y0) = (u.floor(), v.floor()); let (fx, fy) = ((u - x0) as f32, (v - y0) as f32); @@ -177,13 +206,32 @@ impl Tile { } } } - if acc[3] <= 0.0 { - return [0.0; 4]; - } - [acc[0] / acc[3], acc[1] / acc[3], acc[2] / acc[3], acc[3].min(1.0)] + acc } } +/// Most taps per axis of the minification filter (the WGSL mirror uses the same cap). +const MAX_TAPS: usize = 8; + +/// Taps per axis for a placement whose inverse scale is `inv_scale`: one pixel covers +/// `inv_scale` tile pixels, so that many taps (capped) box-filter it. 1 at 100 % and above. +fn pixel_taps(inv_scale: f64) -> usize { + let s = inv_scale as f32; + if s.is_finite() && s > 1.0 { (s.ceil() as usize).min(MAX_TAPS) } else { 1 } +} + +/// Offset in document pixels of tap `i` of `n`, centred on the pixel. +fn tap_offset(i: usize, n: usize) -> f64 { + (i as f64 + 0.5) / n as f64 - 0.5 +} + +fn unpremultiply(acc: [f32; 4]) -> [f32; 4] { + if acc[3] <= 0.0 { + return [0.0; 4]; + } + [acc[0] / acc[3], acc[1] / acc[3], acc[2] / acc[3], acc[3].min(1.0)] +} + /// Where a pattern sits: document point → tile coordinates. #[derive(Clone, Copy, Debug)] pub struct Placement { @@ -234,8 +282,7 @@ pub fn render(tile: &Tile, place: &Placement, rect: Rect) -> Vec<[f32; 4]> { let mut out = Vec::with_capacity(rect.width() as usize * rect.height() as usize); for y in rect.y0..rect.y1 { for x in rect.x0..rect.x1 { - let (u, v) = place.map(f64::from(x) + 0.5, f64::from(y) + 0.5); - out.push(tile.sample(u, v)); + out.push(tile.sample_area(place, f64::from(x) + 0.5, f64::from(y) + 0.5)); } } out @@ -299,6 +346,29 @@ mod tests { assert!((u - -3.0).abs() < 1e-9 && (v - 1.0).abs() < 1e-9, "{u},{v}"); } + #[test] + fn minified_pattern_is_filtered_not_aliased() { + // 1-px dark / light stripes (mean 0.5), shown at 25 %: every pixel covers 4 tile pixels. + // The 0.125 px phase puts each pixel centre on a light tile pixel's centre, so a single + // bilinear tap would return pure white. + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(Rect::new(0, 0, 8, 8), &[1.0, 1.0, 1.0, 1.0]); + for x in (0..8).step_by(2) { + s.fill_rect(Rect::new(x, 0, x + 1, 8), &[0.0, 0.0, 0.0, 1.0]); + } + let t = Tile::new(&Pattern::new("stripes", s, 8, 8)).unwrap(); + let place = Placement::new(Rect::EMPTY, false, (0.125, 0.0), 0.25, 0.0); + let (u, _) = place.map(0.5, 0.5); + assert_eq!(t.sample(u, 0.5)[0], 1.0, "the single tap lands on a light stripe"); + for p in render(&t, &place, Rect::new(0, 0, 8, 1)) { + assert!((p[0] - 0.5).abs() < 0.05, "{p:?}"); + } + // 100 % stays exact. + let one = Placement::new(Rect::EMPTY, false, (0.0, 0.0), 1.0, 0.0); + let row = render(&t, &one, Rect::new(0, 0, 2, 1)); + assert_eq!((row[0][0], row[1][0]), (0.0, 1.0)); + } + #[test] fn sampling_at_hostile_phases_does_not_overflow() { let patterns = [checker()]; diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index a6c0c63579..dc18241b46 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -1097,15 +1097,10 @@ fn wrap(i: i32, n: i32) -> i32 { return ((i % n) + n) % n; } -// compose::pattern::Tile::sample through Placement::map. p3 = (origin, cos, sin), -// p4 = (1 / scale, tile w, tile h); `pat_tex` holds premultiplied RGBA. -fn pattern_sample(d: vec2) -> vec4 { - let dx = f32(d.x) + 0.5 - op.p3.x; - let dy = f32(d.y) + 0.5 - op.p3.y; - let c = op.p3.z; - let s = op.p3.w; - let u = (dx * c - dy * s) * op.p4.x - 0.5; - let v = (dx * s + dy * c) * op.p4.x - 0.5; +// One bilinear, wrapping, premultiplied tap at tile coordinates (u, v) (Tile::sample_premul). +fn pattern_tap(u0: f32, v0: f32) -> vec4 { + let u = u0 - 0.5; + let v = v0 - 0.5; let x0 = floor(u); let y0 = floor(v); let fx = u - x0; @@ -1124,6 +1119,28 @@ fn pattern_sample(d: vec2) -> vec4 { acc += textureLoad(pat_tex, vec2(wrap(ix + i, tw), wrap(iy + j, th)), 0) * (wx[i] * wy[j]); } } + return acc; +} + +// compose::pattern::Tile::sample_area through Placement::map. p3 = (origin, cos, sin), +// p4 = (1 / scale, tile w, tile h); `pat_tex` holds premultiplied RGBA. Below 100 % a pixel +// averages n x n taps over its footprint (n = ceil(1 / scale), at most 8), like the CPU. +fn pattern_sample(d: vec2) -> vec4 { + var n = 1; + if (op.p4.x > 1.0) { n = i32(min(ceil(op.p4.x), 8.0)); } + let c = op.p3.z; + let s = op.p3.w; + var acc = vec4(0.0); + for (var j = 0; j < n; j++) { + for (var i = 0; i < n; i++) { + let ox = (f32(i) + 0.5) / f32(n) - 0.5; + let oy = (f32(j) + 0.5) / f32(n) - 0.5; + let dx = f32(d.x) + 0.5 + ox - op.p3.x; + let dy = f32(d.y) + 0.5 + oy - op.p3.y; + acc += pattern_tap((dx * c - dy * s) * op.p4.x, (dx * s + dy * c) * op.p4.x); + } + } + acc = acc / f32(n * n); if (acc.a <= 0.0) { return vec4(0.0); } return vec4(acc.rgb / acc.a, min(acc.a, 1.0)); } diff --git a/crates/gpu/tests/it/parity.rs b/crates/gpu/tests/it/parity.rs index 464ad1f1a2..476474f4ac 100644 --- a/crates/gpu/tests/it/parity.rs +++ b/crates/gpu/tests/it/parity.rs @@ -2630,7 +2630,7 @@ fn pattern_cache_tracks_source_format_and_cow_tiles() { fn pattern_fill_layers() { let Some(mut g) = gpu() else { return }; let pat = checker_pattern(); - for (link, scale, angle, masked) in [(true, 1.0, 0.0, false), (false, 0.7, 25.0, true), (true, 1.6, -40.0, false)] { + for (link, scale, angle, masked) in [(true, 1.0, 0.0, false), (false, 0.7, 25.0, true), (true, 1.6, -40.0, false), (true, 0.3, 10.0, false)] { let mut d = base_doc(70, 50); d.patterns.push(pat.clone()); let fill = Fill::Pattern { name: pat.name.clone(), id: pat.id.clone(), scale, angle, link, phase: (3.0, -2.0) }; From cddef0c6cc260fa1aeabc061dbee56f346c28604 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:32:59 +0200 Subject: [PATCH 40/86] fix: File > Import > Variable Data Sets opens a file picker (#8816) --- crates/ui-egui/src/file_dialog/tests.rs | 12 ++++++++++++ crates/ui-egui/src/menus.rs | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index a70d6acd8e..52149ab8e0 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -1472,3 +1472,15 @@ fn save_a_copy_appends_copy_unless_the_preference_says_not_to() { ); } } + +#[test] +fn import_variable_data_sets_menu_asks_for_a_csv_and_imports_it() { + let (mut app, open, _) = app(); + let ctx = egui::Context::default(); + assert_eq!(crate::menus::invoke(&mut app, &ctx, "file.import.variableDataSets", json!({})).unwrap(), json!({"fileDialog": "open"})); + app.poll_file_dialog(&ctx, None); + assert!(matches!(open.borrow().as_slice(), [(FileDialogRequest::Open { multiple: false, extensions: Some(e), .. }, _)] if e == &["csv", "txt"])); + answer(&open, Some(FileDialogAnswer::Contents("sets.csv".into(), b"headline\nHello\nWorld\n".to_vec()))); + app.poll_file_dialog(&ctx, None); + assert_eq!(app.session.active().map(|d| d.doc.variables.data_sets.len()), Some(2)); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 57b15fb8ad..e4a8532e62 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -228,6 +228,18 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: "file.import.videoFramesToLayers" if params.get("path").and_then(Value::as_str).is_none() => { app.ask_folder(|app, path| app.run("file.import.videoFramesToLayers", json!({"path": path})).map_err(|e| e.to_string())) } + // File > Import > Variable Data Sets… asks for the CSV (#8816); with a `path` the engine reads it itself. + "file.import.variableDataSets" if params.get("path").and_then(Value::as_str).is_none() => { + let doc = app.active_doc_id()?; + let params = params.clone(); + app.pick_file_bytes_filtered(&["csv", "txt"], move |app, _name, bytes| { + app.refocus(doc)?; + let text = String::from_utf8_lossy(&bytes).into_owned(); + let r = photocraft_engine::import_data_sets_text(&mut app.session, &text, ¶ms).map_err(|e| e.to_string()); + app.sync_views(); + r + }) + } "file.save" => { // Writes back only to a layered file that holds the whole document; anything else // goes through Save As, so the original is never overwritten with less (#3806). From 9df6c39da4fc68fbbbc9ed64f06b2793e74206f6 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Fri, 23 Oct 2026 21:23:43 +0530 Subject: [PATCH 41/86] fix(ui): Select and Mask keeps its work on a second open and drops refused strokes - Opening Select and Mask while it is open is an error, not a fresh workspace that silently discards the open one's edits, strokes and settings. - A tool edit or Refine Edge Brush stroke refused at the engine's limits no longer stays drawn: the trail is cleared and the preview is rebuilt without it. Addresses CodeRabbit review on #8896. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/select_mask_ui.rs | 19 +++++++++--- crates/ui-egui/src/select_mask_ui_tests.rs | 35 ++++++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/select_mask_ui.rs b/crates/ui-egui/src/select_mask_ui.rs index baf06a4a9f..323c613246 100644 --- a/crates/ui-egui/src/select_mask_ui.rs +++ b/crates/ui-egui/src/select_mask_ui.rs @@ -506,14 +506,21 @@ impl SelectMaskWorkspace { list.iter().map(|e| e.get("points").and_then(Value::as_array).map_or(0, Vec::len)).sum() } + /// An item the engine's limits refuse: say why, and drop what was drawn for it. + fn reject(&mut self, why: String) { + self.error = Some(why); + self.trail = None; + self.stale(); + } + /// Adds a tool edit (undoable), within the engine's limits. fn add_edit(&mut self, e: Value) { if self.edits.len() >= MAX_EDITS { - self.error = Some(format!("at most {MAX_EDITS} edits: undo some, or reset")); + self.reject(format!("at most {MAX_EDITS} edits: undo some, or reset")); return; } if Self::points_in(&self.edits) + Self::points_in(std::slice::from_ref(&e)) > MAX_EDIT_POINTS { - self.error = Some(format!("at most {MAX_EDIT_POINTS} points in all edits: undo some, or reset")); + self.reject(format!("at most {MAX_EDIT_POINTS} points in all edits: undo some, or reset")); return; } self.error = None; @@ -525,11 +532,11 @@ impl SelectMaskWorkspace { /// Adds a Refine Edge Brush stroke (undoable), within the engine's limits. fn add_stroke(&mut self, s: Value) { if self.strokes.len() >= MAX_STROKES { - self.error = Some(format!("at most {MAX_STROKES} Refine Edge Brush strokes: undo some, or reset")); + self.reject(format!("at most {MAX_STROKES} Refine Edge Brush strokes: undo some, or reset")); return; } if Self::points_in(&self.strokes) + Self::points_in(std::slice::from_ref(&s)) > MAX_STROKE_POINTS { - self.error = Some(format!("at most {MAX_STROKE_POINTS} points in all Refine Edge Brush strokes: undo some, or reset")); + self.reject(format!("at most {MAX_STROKE_POINTS} points in all Refine Edge Brush strokes: undo some, or reset")); return; } self.error = None; @@ -699,6 +706,10 @@ fn reset(w: &mut SelectMaskWorkspace) { /// Opens the workspace on the selection (or the active layer's mask). pub fn open(app: &mut PhotocraftApp) -> Result<(), String> { + // Opening again would drop the open workspace's work: edit it with `ui`, or cancel it. + if app.distort.select_mask.is_some() { + return Err("Select and Mask is already open".into()); + } if let Some(Err(why)) = photocraft_engine::commands::find(CMD).map(|c| (c.enabled)(&app.session)) { return Err(format!("Select and Mask needs a selection or a layer mask ({why})")); } diff --git a/crates/ui-egui/src/select_mask_ui_tests.rs b/crates/ui-egui/src/select_mask_ui_tests.rs index db4b3cc913..3ed398e271 100644 --- a/crates/ui-egui/src/select_mask_ui_tests.rs +++ b/crates/ui-egui/src/select_mask_ui_tests.rs @@ -296,6 +296,41 @@ fn real_time_refinement_previews_the_stroke_being_drawn() { } } +/// A stroke refused at the engine's limits leaves nothing drawn: no trail, and a preview without it. +#[test] +fn a_refused_stroke_leaves_no_trail_or_preview() { + for tool in ["quick", "refineEdge"] { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + ui(&mut app, json!({"tool": tool})); + let w = ws_mut(&mut app); + if tool == "quick" { + w.edits = vec![json!({"tool": "invert"}); MAX_EDITS]; + } else { + w.strokes = vec![json!({"points": []}); MAX_STROKES]; + } + crate::distort_ui::pointer(&mut app, ToolEvent::Down { x: 120.0, y: 50.0, pressure: 1.0 }, Modifiers::NONE); + crate::distort_ui::pointer(&mut app, ToolEvent::Move { x: 130.0, y: 50.0, pressure: 1.0 }, Modifiers::NONE); + let want = ws(&app).preview.want; + crate::distort_ui::pointer(&mut app, ToolEvent::Up { x: 130.0, y: 50.0 }, Modifiers::NONE); + let w = ws(&app); + assert!(w.error.as_deref().is_some_and(|e| e.starts_with("at most")), "{tool}: {:?}", w.error); + assert!(w.trail.is_none(), "{tool}: the refused stroke is not drawn"); + assert!(w.preview.want > want, "{tool}: a new preview drops the refused stroke"); + } +} + +/// Opening again while the workspace is open keeps its work and says why. +#[test] +fn opening_again_keeps_the_open_workspace() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + ui(&mut app, json!({"stroke": {"points": [[120.0, 50.0], [130.0, 50.0]]}})); + let e = crate::menus::invoke(&mut app, &egui::Context::default(), "select.selectAndMask", json!({})); + assert!(e.is_err_and(|e| e.contains("already open"))); + assert_eq!(ws(&app).edits.len(), 1, "the edit survives"); +} + /// The preview keeps the selection after the edits it applied and starts the next from there: /// after undo it starts over. #[test] From 0bbe1e20792047e9de9bfbaa9cf770bf3a721ba5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Fri, 23 Oct 2026 18:15:27 +0200 Subject: [PATCH 42/86] fix(ui): Clone Source overlay for the Healing Brush, soft with the brush (#8907, #8908) The overlay showed for the Clone Stamp only, although the Clone Source panel drives the Healing Brush too (#8907). Clipped, it was a hard circle at the brush radius, or a boolean mask for shaped tips, whatever the hardness, so a soft brush previewed a hard disc while it painted a soft dab (#8908). - `draw_clone_preview` runs for the Clone Stamp and the Healing Brush; Pattern Preview's repeating source stays the Clone Stamp's (`wrap`). - Clipped, the overlay's alpha is the coverage of the dab the tool paints there (`retouch::stroke_coverage_within`): hardness, a soft tip's reach, shaped and sampled tips. The cache keeps the brush and covered area. A very large soft tip is cropped to the 1024 px source budget. - `brush_cursor::tip_mask` is gone (its only user). - snapshot example: `--hover-at X,Y` for what a tool shows under the pointer. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/examples/snapshot.rs | 6 + crates/ui-egui/src/brush_cursor.rs | 52 ----- crates/ui-egui/src/canvas.rs | 340 +++++++++++++++++----------- crates/ui-egui/src/retouch_ui.rs | 22 ++ 4 files changed, 237 insertions(+), 183 deletions(-) diff --git a/crates/ui-egui/examples/snapshot.rs b/crates/ui-egui/examples/snapshot.rs index 3a461040f6..0eec259ccf 100644 --- a/crates/ui-egui/examples/snapshot.rs +++ b/crates/ui-egui/examples/snapshot.rs @@ -21,6 +21,8 @@ //! `--right-click-at X,Y` opens a screen-space context menu after the script, including panel //! and document-tab menus that are outside the document-coordinate control pointer. //! `--click-at X,Y` opens a screen-space menu (for example the top Select menu) after the script. +//! `--hover-at X,Y` leaves the pointer there without a click, for what a tool shows under it (the +//! brush outline, the Clone Source overlay). use photocraft_ui_egui::control::{ControlRequest, Outcome, handle}; use photocraft_ui_egui::{PhotocraftApp, Services}; @@ -160,6 +162,10 @@ fn main() { harness.event(egui::Event::PointerButton { pos, button, pressed: false, modifiers: egui::Modifiers::NONE }); harness.run_steps(4); } + if let Some((x, y)) = arg(&args, "--hover-at").and_then(|s| s.split_once(',').and_then(|(x, y)| Some((x.parse::().ok()?, y.parse::().ok()?)))) { + harness.event(egui::Event::PointerMoved(egui::pos2(x, y))); + harness.run_steps(4); + } let t_settle = std::time::Instant::now(); while t_settle.elapsed() < std::time::Duration::from_millis(settle_ms) { harness.step(); diff --git a/crates/ui-egui/src/brush_cursor.rs b/crates/ui-egui/src/brush_cursor.rs index 22a489ba9a..175eb8ad4b 100644 --- a/crates/ui-egui/src/brush_cursor.rs +++ b/crates/ui-egui/src/brush_cursor.rs @@ -134,43 +134,6 @@ pub(crate) fn rings(ctx: &egui::Context, brush: &BrushSettings, full: bool) -> O traced } -/// Which texels of the document-pixel `rect` (`x0, y0, x1, y1`, row-major) lie inside the tip -/// `rings` (radius units, even-odd so holes stay clear) when the tip is centred on `at` with -/// `radius` document pixels. The Clone Source overlay uses it to clip to a shaped tip. -pub(crate) fn tip_mask(rings: &[Vec<[f32; 2]>], at: [f64; 2], radius: f64, rect: [i32; 4]) -> Vec { - let [x0, y0, x1, y1] = rect; - let (w, h) = (usize::try_from(x1 - x0).unwrap_or(0), usize::try_from(y1 - y0).unwrap_or(0)); - let mut mask = vec![false; w.saturating_mul(h)]; - let mut hits: Vec = Vec::new(); - for row in 0..h { - let y = f64::from(y0) + row as f64 + 0.5; - hits.clear(); - for ring in rings { - for (i, a) in ring.iter().enumerate() { - let Some(b) = ring.get((i + 1) % ring.len()) else { continue }; - let (ax, ay) = (at[0] + f64::from(a[0]) * radius, at[1] + f64::from(a[1]) * radius); - let (bx, by) = (at[0] + f64::from(b[0]) * radius, at[1] + f64::from(b[1]) * radius); - if (ay <= y) != (by <= y) { - hits.push(ax + (y - ay) / (by - ay) * (bx - ax)); - } - } - } - hits.sort_by(f64::total_cmp); - for pair in hits.chunks(2) { - let [a, b] = pair else { continue }; - let (from, to) = (a - f64::from(x0) - 0.5, b - f64::from(x0) - 0.5); - let start = from.ceil().max(0.0) as usize; - let end = ((to.floor() + 1.0).max(0.0) as usize).min(w); - for x in start..end { - if let Some(m) = mask.get_mut(row * w + x) { - *m = true; - } - } - } - } - mask -} - #[cfg(test)] mod tests { use super::*; @@ -295,19 +258,4 @@ mod tests { assert!(ex.x.is_finite() && ex.y.is_finite() && ey.x.is_finite() && ey.y.is_finite(), "size {size}"); } } - - /// The overlay clip follows the tip: a square ring covers its own texels, a hole stays clear, - /// and a rotated flat tip leaves the corners of the brush square outside. - #[test] - fn tip_mask_follows_the_ring_shape() { - let square = vec![[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]]; - let hole = vec![[-0.1, -0.1], [0.1, -0.1], [0.1, 0.1], [-0.1, 0.1]]; - let mask = tip_mask(&[square.clone(), hole], [10.0, 10.0], 10.0, [0, 0, 20, 20]); - let at = |x: usize, y: usize| mask[y * 20 + x]; - assert!(at(6, 10) && at(14, 10) && at(10, 6) && at(10, 14), "inside the square"); - assert!(!at(3, 10) && !at(10, 16) && !at(0, 0), "outside the square"); - assert!(!at(10, 10) && at(8, 10), "the hole is clear"); - assert_eq!(mask.iter().filter(|m| **m).count(), 10 * 10 - 2 * 2, "10x10 square minus the 2x2 hole"); - assert!(tip_mask(&[square], [10.0, 10.0], 10.0, [5, 5, 5, 9]).is_empty(), "an empty rect is harmless"); - } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d2543a54c2..ba99ef9597 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -84,14 +84,55 @@ pub(crate) fn detail_plan(size: [u32; 2], side: u32, zoom: f32, visible: DRect) pub(crate) struct ClonePreviewCache { key: String, + /// The brush a clipped overlay took its coverage from: a hardness or tip change redraws it. + brush: Option, + /// The document area the texture covers. + rect: DRect, texture: egui::TextureHandle, } -/// A transient source image beneath the brush outline; never part of the document render. +/// The most document pixels one dab's coverage is rendered over for the overlay: a 1000 px brush, +/// the largest the overlay draws, at hardness 0 reaches about 1.73 × its radius (`tip_reach`). +const CLONE_PREVIEW_MAX_PIXELS: u64 = 1800 * 1800; + +/// The largest side of the overlay: `retouch_cmds::clone_preview_periodic` samples at most 1024 px +/// a side, and the source area it reads has a 2 px apron around the overlay's (`source_rect`). +const CLONE_PREVIEW_MAX_SIDE: i32 = 1020; + +/// The coverage of one dab of `brush` at `at`, as the brush engine paints it with opacity and flow +/// at full: its tip, angle, roundness and hardness (a soft tip reaches past its radius). The area +/// it covers and its `width × height` coverage values in 0..1. A very large soft tip is cropped to +/// the source budget around `at`, losing only the faint end of its tail. +fn clone_dab_coverage(brush: &photocraft_paint::BrushSettings, at: [f64; 2]) -> Option<(DRect, Vec)> { + let brush = photocraft_paint::BrushSettings { opacity: 1.0, flow: 1.0, erase: false, ..brush.clone() }; + let stroke = photocraft_paint::Stroke { brush, points: vec![photocraft_paint::StrokePoint::new(at[0], at[1], 1.0)] }; + let (rect, cov) = photocraft_paint::retouch::stroke_coverage_within(&stroke, CLONE_PREVIEW_MAX_PIXELS)?; + let half = CLONE_PREVIEW_MAX_SIDE / 2; + let (cx, cy) = (at[0].floor() as i32, at[1].floor() as i32); + let window = rect.intersect(&DRect::new(cx.saturating_sub(half), cy.saturating_sub(half), cx.saturating_add(half), cy.saturating_add(half))); + if window.is_empty() { + return None; + } + if window == rect { + return Some((rect, cov)); + } + let stride = rect.width() as usize; + let mut cropped = Vec::with_capacity(window.width() as usize * window.height() as usize); + for y in window.y0..window.y1 { + let row = (y - rect.y0) as usize * stride; + let from = row + (window.x0 - rect.x0) as usize; + cropped.extend_from_slice(cov.get(from..from + window.width() as usize)?); + } + Some((window, cropped)) +} + +/// A transient source image beneath the brush outline; never part of the document render. The +/// Clone Source panel drives the Clone Stamp and the Healing Brush alike, so both show it. fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, at: [f64; 2], output: Option) { use photocraft_engine::presets::clone_source::{Mapping, overlay_blend, transform_matrix}; let overlay = app.session.presets.clone.overlay.clone(); - if app.ui.tool != Tool::CloneStamp || !overlay.show || (overlay.auto_hide && app.drag.is_some()) { + let tool = app.ui.tool; + if !matches!(tool, Tool::CloneStamp | Tool::Healing) || !overlay.show || (overlay.auto_hide && app.drag.is_some()) { return; } let slot = app.session.presets.clone.active().clone(); @@ -100,30 +141,43 @@ fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &Vie if !radius.is_finite() || radius <= 0.0 || radius > 500.0 || !at.iter().all(|v| v.is_finite() && v.abs() < 1_000_000.0) { return; } + // View › Pattern Preview repeats the source for the Clone Stamp only (`paint.cloneStamp`'s + // `wrap`); the Healing Brush samples it as it is. + let wrap = tool == Tool::CloneStamp && app.ui.view.pattern_preview; let stroke_start = app.drag.as_ref().map(|d| d.start); let anchor = if app.ui.tool_options.clone_aligned { slot.anchor.or(stroke_start) } else { stroke_start }.unwrap_or(at); let map = Mapping { source: (source[0], source[1]), anchor: (anchor[0], anchor[1]), m: transform_matrix(slot.scale, slot.rotation, slot.flip_h, slot.flip_v) }; - let rect = DRect::new((at[0] - radius).floor() as i32, (at[1] - radius).floor() as i32, (at[0] + radius).ceil() as i32, (at[1] + radius).ceil() as i32); let Some(st) = app.session.active() else { return }; let (display, display_key) = canvas_display(app, &st.doc, output); - // A shaped tip (sampled bitmap, or a rotated / flattened round one) clips the overlay to its - // outline instead of a circle, as Photoshop does with Clipped on. - let tip_rings = if overlay.clipped { crate::brush_cursor::rings(painter.ctx(), &app.session.tools.brush, true) } else { None }; - let tip_key = tip_rings.as_ref().map(|r| { - use std::hash::{Hash, Hasher}; - let mut h = std::collections::hash_map::DefaultHasher::new(); - r.iter().flatten().for_each(|p| (p[0].to_bits(), p[1].to_bits()).hash(&mut h)); - (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, app.ui.view.pattern_preview + "{:?}:{}:{:?}:{:?}:{map:?}:{}:{}:{}:{}:{wrap}:{}:{}", + st.doc.id, + st.revision, + st.active_layer, + at.map(f64::to_bits), + app.ui.tool_options.clone_sample, + display_key, + overlay.invert, + overlay.blend, + overlay.clipped, + radius.to_bits() ); - if app.clone_preview.as_ref().is_none_or(|c| c.key != key) { - 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 { + let brush = overlay.clipped.then_some(&app.session.tools.brush); + if app.clone_preview.as_ref().is_none_or(|c| c.key != key || c.brush.as_ref() != brush) { + // Clipped (Photoshop's default): the dab the tool paints here, so a soft brush previews + // soft and a shaped tip keeps its outline. Unclipped: the brush's whole square. + let (rect, coverage) = match brush { + Some(b) => { + let Some((rect, coverage)) = clone_dab_coverage(b, at) else { return }; + (rect, Some(coverage)) + } + None => ( + DRect::new((at[0] - radius).floor() as i32, (at[1] - radius).floor() as i32, (at[0] + radius).ceil() as i32, (at[1] + radius).ceil() as i32), + None, + ), + }; + let Ok(mut buf) = photocraft_engine::retouch_cmds::clone_preview_periodic(&app.session, rect, &map, &app.ui.tool_options.clone_sample, wrap) else { return; }; if overlay.invert { @@ -137,55 +191,43 @@ 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_periodic(&app.session, rect, &here, "all", app.ui.view.pattern_preview) else { + let Ok(under) = photocraft_engine::retouch_cmds::clone_preview_periodic(&app.session, rect, &here, "all", wrap) else { return; }; for (px, d) in buf.px.iter_mut().zip(&under.px) { *px = overlay_blend(&overlay.blend, *px, *d); } } - if let Some(rings) = &tip_rings { - let mask = crate::brush_cursor::tip_mask(rings, at, radius, [rect.x0, rect.y0, rect.x1, rect.y1]); - for (px, inside) in buf.px.iter_mut().zip(mask) { - if !inside { - *px = [0.0; 4]; - } + if let Some(coverage) = &coverage { + for (px, c) in buf.px.iter_mut().zip(coverage) { + px[3] *= c.clamp(0.0, 1.0); } } let image = display_image(display.as_deref(), &buf); + let brush = brush.cloned(); match app.clone_preview.as_mut() { Some(cache) => { cache.texture.set(image, canvas_texture_options(1)); cache.key = key; + cache.brush = brush; + cache.rect = rect; + } + None => { + let texture = painter.ctx().load_texture("clone-preview", image, canvas_texture_options(1)); + app.clone_preview = Some(ClonePreviewCache { key, brush, rect, texture }); } - None => app.clone_preview = Some(ClonePreviewCache { key, texture: painter.ctx().load_texture("clone-preview", image, canvas_texture_options(1)) }), } } let Some(cache) = &app.clone_preview else { return }; + let rect = cache.rect; let tint = Color32::from_white_alpha((overlay.opacity.clamp(0.0, 100.0) * 2.55).round() as u8); - // A textured triangle fan provides a circular clip, including mirrored canvas views. + // A textured quad over the covered area: its texels carry the clip, in mirrored and rotated + // canvas views too. let mut mesh = egui::Mesh::with_texture(cache.texture.id()); - let vertex = |x: f64, y: f64| egui::epaint::Vertex { - pos: xf.to_screen(x as f32, y as f32), - uv: pos2((x - f64::from(rect.x0)) as f32 / rect.width() as f32, (y - f64::from(rect.y0)) as f32 / rect.height() as f32), - color: tint, - }; - if !overlay.clipped || tip_rings.is_some() { - for (x, y) in [(rect.x0, rect.y0), (rect.x1, rect.y0), (rect.x1, rect.y1), (rect.x0, rect.y1)] { - mesh.vertices.push(vertex(f64::from(x), f64::from(y))); - } - mesh.indices.extend_from_slice(&[0, 1, 2, 0, 2, 3]); - painter.add(egui::Shape::mesh(mesh)); - return; - } - mesh.vertices.push(vertex(at[0], at[1])); - for i in 0..=64 { - let angle = f64::from(i) * std::f64::consts::TAU / 64.0; - mesh.vertices.push(vertex(at[0] + radius * angle.cos(), at[1] + radius * angle.sin())); - } - for i in 1..=64 { - mesh.indices.extend_from_slice(&[0, i, i + 1]); + for (x, y, u, v) in [(rect.x0, rect.y0, 0.0, 0.0), (rect.x1, rect.y0, 1.0, 0.0), (rect.x1, rect.y1, 1.0, 1.0), (rect.x0, rect.y1, 0.0, 1.0)] { + mesh.vertices.push(egui::epaint::Vertex { pos: xf.to_screen(x as f32, y as f32), uv: pos2(u, v), color: tint }); } + mesh.indices.extend_from_slice(&[0, 1, 2, 0, 2, 3]); painter.add(egui::Shape::mesh(mesh)); } @@ -7810,72 +7852,135 @@ mod tests { } } + /// An app with a 64 × 64 white document, a clone source and the Clone Stamp at `size` px. + fn clone_overlay_app(size: f32) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + app.run("cloneSource.set", json!({"source": [16, 16]})).unwrap(); + app.ui.tool = Tool::CloneStamp; + app.session.tools.brush.size = size; + app + } + + /// One frame of the overlay at (32, 32): its shapes and texture uploads. + fn clone_overlay_frame(ctx: &egui::Context, app: &mut PhotocraftApp, xf: &ViewXform) -> egui::FullOutput { + 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(app, &painter, xf, [32.0, 32.0], None); + }) + } + + /// The overlay texture a frame uploaded and the alpha at a document pixel, read from the + /// document area the overlay covers (transparent outside it). + fn clone_overlay_alpha(app: &PhotocraftApp, output: &mut egui::FullOutput) -> impl Fn(i32, i32) -> u8 + use<> { + let cache = app.clone_preview.as_ref().expect("an overlay was drawn"); + let image = output + .textures_delta + .set + .get(&cache.texture.id()) + .and_then(|deltas| deltas.last()) + .map(|d| match &d.image { + egui::ImageData::Color(c) => c.clone(), + }) + .expect("the overlay uploads a texture"); + output.textures_delta.clear(); + let rect = cache.rect; + assert_eq!(image.size, [rect.width() as usize, rect.height() as usize], "the texture covers the overlay's area"); + move |x, y| { + if x < rect.x0 || y < rect.y0 || x >= rect.x1 || y >= rect.y1 { + return 0; + } + image.pixels[(y - rect.y0) as usize * image.width() + (x - rect.x0) as usize].a() + } + } + + fn clone_overlay_view(side: f32, zoom: f32) -> ViewXform { + ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(side, side)), zoom, center: [32.0, 32.0], flip: false, rotation: 0.0, aspect: 1.0 } + } + #[test] fn clone_overlay_is_circular_and_respects_visibility() { let ctx = egui::Context::default(); - let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); - app.run("cloneSource.set", json!({"source": [16,16]})).unwrap(); - app.ui.tool = Tool::CloneStamp; - app.session.tools.brush.size = 20.0; - let xf = - ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0)), zoom: 1.0, center: [32.0, 32.0], flip: false, rotation: 0.0, aspect: 1.0 }; + let mut app = clone_overlay_app(20.0); + let xf = clone_overlay_view(100.0, 1.0); let revision = app.session.active().unwrap().revision; - let mut output = ctx.run_ui(Default::default(), |ui| { - let ctx = ui.ctx(); - let painter = 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); - }); - output.textures_delta.clear(); - let mesh = output - .shapes - .iter() - .find_map(|s| match &s.shape { - egui::Shape::Mesh(m) => Some(m), - _ => None, - }) - .unwrap(); - assert_eq!(mesh.indices.len(), 64 * 3); - let center = xf.to_screen(32.0, 32.0); - assert!(mesh.vertices.iter().all(|v| v.pos.distance(center) <= 10.01)); + let mut output = clone_overlay_frame(&ctx, &mut app, &xf); + assert!(output.shapes.iter().any(|s| matches!(s.shape, egui::Shape::Mesh(_))), "the overlay is drawn"); + let alpha = clone_overlay_alpha(&app, &mut output); + assert_eq!(alpha(32, 32), 255, "the centre shows the source"); + assert_eq!(alpha(32 + 8, 32), 255, "inside the hard 10 px radius"); + assert_eq!(alpha(32 + 8, 32 + 8), 0, "the brush square's corner, outside the circle, is clipped"); + assert_eq!(alpha(32 + 12, 32), 0, "past the hard tip's rim"); 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); - }); + let mut periodic_output = clone_overlay_frame(&ctx, &mut app, &xf); 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(); - let painter = 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); - }); + let mut output = clone_overlay_frame(&ctx, &mut app, &xf); output.textures_delta.clear(); assert!(output.shapes.iter().all(|s| !matches!(s.shape, egui::Shape::Mesh(_)))); } + /// A soft brush previews soft: the overlay fades towards the rim with the brush's hardness, as + /// the stroke it previews does, and reaches past the radius as a soft tip paints. A brush + /// change redraws an overlay that hasn't moved. + #[test] + fn clone_overlay_follows_the_brush_hardness() { + let ctx = egui::Context::default(); + let mut app = clone_overlay_app(20.0); + let xf = clone_overlay_view(100.0, 1.0); + let mut output = clone_overlay_frame(&ctx, &mut app, &xf); + let hard = clone_overlay_alpha(&app, &mut output); + app.session.tools.brush.hardness = 0.0; + let mut output = clone_overlay_frame(&ctx, &mut app, &xf); + let soft = clone_overlay_alpha(&app, &mut output); + assert!(hard(32, 32) == 255 && soft(32, 32) > 240, "both show the source at the centre: {} {}", hard(32, 32), soft(32, 32)); + assert_eq!(hard(32 + 8, 32), 255); + assert!(soft(32 + 8, 32) < 200, "a soft tip fades before its rim: {}", soft(32 + 8, 32)); + assert!(soft(32 + 4, 32) > soft(32 + 8, 32), "and fades outwards"); + assert!(hard(32 + 12, 32) == 0 && soft(32 + 13, 32) > 0, "a soft tip reaches past the radius, a hard one doesn't"); + // The largest brush the overlay draws still shows at its softest. + let mut big = clone_overlay_app(1000.0); + big.session.tools.brush.hardness = 0.0; + let mut output = clone_overlay_frame(&ctx, &mut big, &xf); + assert_eq!(clone_overlay_alpha(&big, &mut output)(32, 32), 255, "a 1000 px soft brush previews too"); + } + + /// The Healing Brush shares the Clone Stamp's source and overlay (Photoshop's Clone Source + /// panel drives both); other tools show none. + #[test] + fn clone_overlay_shows_for_the_healing_brush_too() { + let ctx = egui::Context::default(); + let xf = clone_overlay_view(100.0, 1.0); + for (tool, shown) in [(Tool::Healing, true), (Tool::CloneStamp, true), (Tool::Brush, false), (Tool::SpotHealing, false)] { + let mut app = clone_overlay_app(20.0); + app.ui.tool = tool; + let mut output = clone_overlay_frame(&ctx, &mut app, &xf); + assert_eq!(output.shapes.iter().any(|s| matches!(s.shape, egui::Shape::Mesh(_))), shown, "{tool:?}"); + if shown { + assert_eq!(clone_overlay_alpha(&app, &mut output)(32, 32), 255, "{tool:?}: the source shows under the brush"); + } + output.textures_delta.clear(); + } + } + #[test] fn clone_overlay_keeps_magnified_pixels_crisp_on_upload_and_refresh() { let ctx = egui::Context::default(); - let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); - app.run("cloneSource.set", json!({"source": [16, 16]})).unwrap(); - app.ui.tool = Tool::CloneStamp; - app.session.tools.brush.size = 20.0; - let xf = - ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)), zoom: 4.0, center: [32.0, 32.0], flip: false, rotation: 0.0, aspect: 1.0 }; + let mut app = clone_overlay_app(20.0); + let xf = clone_overlay_view(200.0, 4.0); for invert in [false, true] { app.session.presets.clone.overlay.invert = invert; let mut output = ctx.run_ui(Default::default(), |ui| { draw_clone_preview(&mut app, ui.painter(), &xf, [32.0, 32.0], None); }); - let upload = output.textures_delta.set.values().flatten().find(|d| d.image.size() == [20, 20]).expect("clone overlay upload"); + let id = app.clone_preview.as_ref().unwrap().texture.id(); + let upload = output.textures_delta.set.get(&id).and_then(|d| d.last()).expect("clone overlay upload"); let options = upload.options; output.textures_delta.clear(); assert_eq!(options.magnification, egui::TextureFilter::Nearest, "invert={invert}"); @@ -7886,28 +7991,20 @@ mod tests { #[test] fn clone_overlay_blend_mode_mixes_with_the_image_under_the_brush() { let ctx = egui::Context::default(); - let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); - app.run("cloneSource.set", json!({"source": [16,16]})).unwrap(); - app.ui.tool = Tool::CloneStamp; - app.session.tools.brush.size = 20.0; - let xf = - ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0)), zoom: 1.0, center: [32.0, 32.0], flip: false, rotation: 0.0, aspect: 1.0 }; - // The centre texel of the overlay texture uploaded for `mode`. + let mut app = clone_overlay_app(20.0); + let xf = clone_overlay_view(100.0, 1.0); + // The overlay texel at the brush centre for `mode`. let mut centre = |mode: &str| { app.run("cloneSource.overlay", json!({"blend": mode})).unwrap(); - let mut 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); - }); - // The brush's 20 × 20 px square (the font atlas may upload too). - let image = output.textures_delta.set.values().flatten().find_map(|d| match &d.image { - egui::ImageData::Color(c) if c.size == [20, 20] => Some(c.clone()), - _ => None, + let mut output = clone_overlay_frame(&ctx, &mut app, &xf); + let cache = app.clone_preview.as_ref().unwrap(); + let image = output.textures_delta.set.get(&cache.texture.id()).and_then(|d| d.last()).map(|d| match &d.image { + egui::ImageData::Color(c) => c.clone(), }); output.textures_delta.clear(); let image = image.expect("the overlay uploads a texture"); - image.pixels[image.width() * (image.height() / 2) + image.width() / 2] + let rect = cache.rect; + image.pixels[(32 - rect.y0) as usize * image.width() + (32 - rect.x0) as usize] }; // A white source over the white image: Normal shows white, Difference black. assert_eq!(centre("normal"), Color32::WHITE); @@ -7919,33 +8016,14 @@ mod tests { #[test] fn clone_overlay_is_clipped_to_a_shaped_tip() { let ctx = egui::Context::default(); - let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); - app.run("cloneSource.set", json!({"source": [16,16]})).unwrap(); - app.ui.tool = Tool::CloneStamp; - app.session.tools.brush.size = 20.0; + let mut app = clone_overlay_app(20.0); app.session.tools.brush.roundness = 0.25; - let xf = - ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0)), zoom: 1.0, center: [32.0, 32.0], flip: false, rotation: 0.0, aspect: 1.0 }; - let mut 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); - }); - let image = output - .textures_delta - .set - .values() - .flatten() - .find_map(|d| match &d.image { - egui::ImageData::Color(c) if c.size == [20, 20] => Some(c.clone()), - _ => None, - }) - .expect("the overlay uploads a texture"); - output.textures_delta.clear(); - let alpha = |x: usize, y: usize| image.pixels[y * image.width() + x].a(); - assert_eq!(alpha(10, 10), 255, "the tip's centre shows the source"); - assert_eq!(alpha(10, 2), 0, "inside the circle but outside the flat tip: clipped"); - assert_eq!(alpha(1, 10), 255, "the tip's long axis stays"); + let xf = clone_overlay_view(100.0, 1.0); + let mut output = clone_overlay_frame(&ctx, &mut app, &xf); + let alpha = clone_overlay_alpha(&app, &mut output); + assert_eq!(alpha(32, 32), 255, "the tip's centre shows the source"); + assert_eq!(alpha(32, 24), 0, "inside the circle but outside the flat tip: clipped"); + assert!(alpha(25, 32) > 240, "the tip's long axis stays: {}", alpha(25, 32)); } #[test] diff --git a/crates/ui-egui/src/retouch_ui.rs b/crates/ui-egui/src/retouch_ui.rs index 495a1babf6..33706bb25b 100644 --- a/crates/ui-egui/src/retouch_ui.rs +++ b/crates/ui-egui/src/retouch_ui.rs @@ -752,6 +752,28 @@ mod tests { } } + /// A Clone Stamp click paints with the brush's hardness (the Brush popup's), not a hard tip: + /// near the rim a soft brush paints only part of the source, a hard one all of it. + #[test] + fn clone_stamp_paints_with_the_brush_hardness() { + let rim = |hardness: f32| { + let mut app = app(); + app.run("tools.setBrush", json!({"brush": {"size": 24, "hardness": hardness}})).unwrap(); + app.run("paint.pencil", json!({"points": [[15, 30]], "size": 40, "color": "#ff0000"})).unwrap(); + app.ui.tool = Tool::CloneStamp; + app.ui.clone_source = Some([15.0, 30.0]); + let m = egui::Modifiers::NONE; + tool_event(&mut app, ToolEvent::Down { x: 70.0, y: 30.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Up { x: 70.0, y: 30.0 }, m); + assert!(!app.ui.status_error, "{}", app.ui.status); + // 9 px from the centre of a 12 px radius: inside a hard tip, on a soft tip's falloff. + 1.0 - active(&app).surface().unwrap().rgba(79, 30)[1] + }; + let (hard, soft) = (rim(1.0), rim(0.0)); + assert!(hard > 0.99, "a hard tip clones fully near its rim: {hard}"); + assert!(soft < 0.8, "a soft tip clones only part of the source there: {soft}"); + } + #[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 59929f65f27b128716287186647ac358d1809060 Mon Sep 17 00:00:00 2001 From: John Turnbach Date: Fri, 23 Oct 2026 12:45:44 -0400 Subject: [PATCH 43/86] test(io): style kept type layers as their Txt2 objects in add_type_layer Review pointed out that the kept layers in the add_type_layer fixture did not match their Txt2 objects: apply_txt2 copies kerning and the OpenType features from the file, but photoshop_txt2 writes MyriadPro-Regular at 12 pt while the layers were Inter at 24 pt. Since this PR, build_txt2_at treats that as a style edit and rebuilds those objects, so the fixture no longer exercised the unchanged-object path. The kept layers now carry the object's font and size, and add_type_layer asserts that each kept layer's Txt2 object is saved unchanged. Without the font and size, that assertion fails in three of the four tests that use the helper. --- crates/io/tests/it/psd_kerning.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/io/tests/it/psd_kerning.rs b/crates/io/tests/it/psd_kerning.rs index c2a72f640f..928e1a6bbc 100644 --- a/crates/io/tests/it/psd_kerning.rs +++ b/crates/io/tests/it/psd_kerning.rs @@ -199,11 +199,16 @@ type Reopened = (String, Option, Vec); /// `TextIndex`, bottom first), with a type layer "Hi" made the way the Type tool makes one /// (`build_tysh`, no index of its own) inserted at `at`, saved and reopened: each layer's /// index and font features (but `calt`, on by default and written on as Photoshop does), and -/// "Hi"'s style run in the saved `Txt2`. +/// "Hi"'s style run in the saved `Txt2`. The kept layers are styled as their objects are (font +/// and size too), so their objects are saved unchanged, which this checks. fn add_type_layer(objects: &[(&str, &str)], kept: &[(&str, i32)], at: usize) -> (Vec, E) { let mut doc = Document::new("k", photocraft_geom::Size::new(64, 32), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8); for (text, index) in kept { let mut t = layer(text, &[(text.len(), Kerning::Metrics, 0.0)]); + // `photoshop_txt2` writes MyriadPro-Regular at 12 pt (the document is at 72 dpi). + t.runs[0].style.size_pt = 12.0; + t.runs[0].style.postscript_name = Some("MyriadPro-Regular".into()); + t.sync_summary(); photocraft_text::TextEngine::new().render_layer(&mut t, doc.resolution_dpi, doc.pixel_format()); let raw = photocraft_text::psd::set_text_index(&photocraft_text::psd::build_tysh(&t, doc.resolution_dpi, None), *index).unwrap(); // As opening the file does: a kept layer's style holds what its `Txt2` object says (#8415: @@ -231,6 +236,11 @@ fn add_type_layer(objects: &[(&str, &str)], kept: &[(&str, i32)], at: usize) -> (l.name.clone(), index, t.char_runs().iter().flat_map(|r| r.style.features.iter().filter(|f| f.tag != "calt").map(|f| f.tag.clone())).collect()) }) .collect(); + let source = photocraft_text::psd::parse_txt2(&photoshop_txt2(objects)).unwrap(); + let object = |txt2: &E, i: i32| txt2.path(&["1", "1"]).and_then(E::as_array).and_then(|a| a.get(i as usize)).cloned(); + for (text, index) in kept { + assert_eq!(object(&txt2, *index), object(&source, *index), "{text}: the kept layer's object is saved unchanged"); + } let hi = layers.iter().find(|l| l.0 == "Hi").and_then(|l| l.1).unwrap() as usize; let run = txt2.path(&["1", "1"]).and_then(E::as_array).unwrap()[hi].path(&["0", "6", "0"]).and_then(E::as_array).unwrap()[0].clone(); (layers, run) From c558de747aea2d564d8531ba515893a705f82f0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Fri, 23 Oct 2026 19:16:32 +0200 Subject: [PATCH 44/86] fix(ui): a scaled-down Clone Source keeps its overlay within the sampling budget Review follow-up (#8912): a Clone Source scaled below 100 % reads a larger source area than the overlay covers, so a large brush's overlay overflowed `clone_preview_periodic`'s 1024 px and vanished. The coverage window now shrinks around the pointer until both its area and `Mapping::source_rect` of it fit. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 54 +++++++++++++++++++++++++++++------- 1 file changed, 44 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ba99ef9597..7e8aaf70bb 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -95,22 +95,40 @@ pub(crate) struct ClonePreviewCache { /// the largest the overlay draws, at hardness 0 reaches about 1.73 × its radius (`tip_reach`). const CLONE_PREVIEW_MAX_PIXELS: u64 = 1800 * 1800; -/// The largest side of the overlay: `retouch_cmds::clone_preview_periodic` samples at most 1024 px -/// a side, and the source area it reads has a 2 px apron around the overlay's (`source_rect`). -const CLONE_PREVIEW_MAX_SIDE: i32 = 1020; +/// The largest side `retouch_cmds::clone_preview_periodic` samples, for the overlay's area and for +/// the source area it reads (`Mapping::source_rect`: the overlay's, mapped, with a 2 px apron). +const CLONE_PREVIEW_MAX_SIDE: u32 = 1024; /// The coverage of one dab of `brush` at `at`, as the brush engine paints it with opacity and flow /// at full: its tip, angle, roundness and hardness (a soft tip reaches past its radius). The area -/// it covers and its `width × height` coverage values in 0..1. A very large soft tip is cropped to -/// the source budget around `at`, losing only the faint end of its tail. -fn clone_dab_coverage(brush: &photocraft_paint::BrushSettings, at: [f64; 2]) -> Option<(DRect, Vec)> { +/// it covers and its `width × height` coverage values in 0..1. A very large soft tip, or a source +/// the Clone Source panel scales down (which reads a larger area than it covers), is cropped +/// around `at` to the sampling budget, keeping the middle of the dab. +fn clone_dab_coverage( + brush: &photocraft_paint::BrushSettings, + at: [f64; 2], + map: &photocraft_engine::presets::clone_source::Mapping, +) -> Option<(DRect, Vec)> { let brush = photocraft_paint::BrushSettings { opacity: 1.0, flow: 1.0, erase: false, ..brush.clone() }; let stroke = photocraft_paint::Stroke { brush, points: vec![photocraft_paint::StrokePoint::new(at[0], at[1], 1.0)] }; let (rect, cov) = photocraft_paint::retouch::stroke_coverage_within(&stroke, CLONE_PREVIEW_MAX_PIXELS)?; - let half = CLONE_PREVIEW_MAX_SIDE / 2; + let fits = |r: DRect| r.width() <= CLONE_PREVIEW_MAX_SIDE && r.height() <= CLONE_PREVIEW_MAX_SIDE; let (cx, cy) = (at[0].floor() as i32, at[1].floor() as i32); - let window = rect.intersect(&DRect::new(cx.saturating_sub(half), cy.saturating_sub(half), cx.saturating_add(half), cy.saturating_add(half))); - if window.is_empty() { + let around = |half: i32| rect.intersect(&DRect::new(cx.saturating_sub(half), cy.saturating_sub(half), cx.saturating_add(half), cy.saturating_add(half))); + // The source area scales with the overlay's: shrink by what overflows, a few times for the + // apron's rounding. + let mut half = i32::try_from((CLONE_PREVIEW_MAX_SIDE - 4) / 2).unwrap_or(510); + let mut window = around(half); + for _ in 0..8 { + let src = map.source_rect(window); + if fits(window) && fits(src) { + break; + } + let over = f64::from(src.width().max(src.height())) / f64::from(CLONE_PREVIEW_MAX_SIDE - 4); + half = ((f64::from(half) / over.max(1.0)).floor() as i32).saturating_sub(2).max(1); + window = around(half); + } + if window.is_empty() || !fits(window) || !fits(map.source_rect(window)) { return None; } if window == rect { @@ -169,7 +187,7 @@ fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &Vie // soft and a shaped tip keeps its outline. Unclipped: the brush's whole square. let (rect, coverage) = match brush { Some(b) => { - let Some((rect, coverage)) = clone_dab_coverage(b, at) else { return }; + let Some((rect, coverage)) = clone_dab_coverage(b, at, &map) else { return }; (rect, Some(coverage)) } None => ( @@ -7951,6 +7969,22 @@ mod tests { assert_eq!(clone_overlay_alpha(&big, &mut output)(32, 32), 255, "a 1000 px soft brush previews too"); } + /// A scaled Clone Source reads a larger source area than the overlay covers: a large brush's + /// overlay shrinks around the pointer until that area fits the sampling budget, rather than + /// vanishing. + #[test] + fn clone_overlay_of_a_scaled_source_stays_within_the_sampling_budget() { + let ctx = egui::Context::default(); + let xf = clone_overlay_view(100.0, 1.0); + for (size, scale) in [(600.0, 200), (1000.0, 300), (1000.0, 50)] { + let mut app = clone_overlay_app(size); + app.run("cloneSource.set", json!({"width": scale, "height": scale})).unwrap(); + let mut output = clone_overlay_frame(&ctx, &mut app, &xf); + assert!(output.shapes.iter().any(|s| matches!(s.shape, egui::Shape::Mesh(_))), "{size} px at {scale} %: the overlay shows"); + assert_eq!(clone_overlay_alpha(&app, &mut output)(32, 32), 255, "{size} px at {scale} %: the source under the pointer"); + } + } + /// The Healing Brush shares the Clone Stamp's source and overlay (Photoshop's Clone Source /// panel drives both); other tools show none. #[test] From f6f0d1d50c7e3ec1f2c6ecfd2303498d30dd0460 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Fri, 23 Oct 2026 19:41:27 +0200 Subject: [PATCH 45/86] fix(ui): the brush resize HUD previews the engine's soft falloff (#8930) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Ctrl+Alt / Alt+right-drag disc showed the hardness as a solid core to `hardness × radius` and a linear fade to clear at the radius, not the brush engine's round tip (a core, then a Gaussian shoulder). A soft tip, which paints up to ~1.73 × its radius, faded out at the circle. `tip_preview` now builds its rings from `photocraft_paint::tip_shape`, `tip_falloff` and `tip_reach` in document pixels at the view's zoom: solid to the core, the engine's falloff out to its reach. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/brush_resize.rs | 91 +++++++++++++++++++++++------- 1 file changed, 71 insertions(+), 20 deletions(-) diff --git a/crates/ui-egui/src/brush_resize.rs b/crates/ui-egui/src/brush_resize.rs index 7d41aaa1ba..ec509f189f 100644 --- a/crates/ui-egui/src/brush_resize.rs +++ b/crates/ui-egui/src/brush_resize.rs @@ -137,14 +137,14 @@ fn update(app: &mut PhotocraftApp, r: Resize, x: f64, y: f64) { } /// Draw the resize feedback: the brush circle at the anchor, filled with the brush preview -/// colour whose solid core shows the hardness (Photoshop's), and the readout beside the pointer. +/// colour as a dab of the brush's hardness covers (Photoshop's), and the readout beside the pointer. /// Returns true while a resize is in progress (the canvas then hides its cursor). pub fn draw(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) -> bool { let Some(r) = app.brush_resize else { return false }; let b = &app.session.tools.brush; let c = xf.to_screen(r.anchor[0] as f32, r.anchor[1] as f32); let radius = (b.size / 2.0 * xf.zoom).max(1.0); - tip_preview(painter, c, radius, b.hardness, preview_tint(app)); + tip_preview(painter, c, b.size / 2.0, xf.zoom, b.hardness, preview_tint(app)); painter.circle_stroke(c, radius + 0.5, Stroke::new(1.0, Color32::from_black_alpha(140))); painter.circle_stroke(c, radius, Stroke::new(1.0, Color32::from_white_alpha(220))); // Photoshop hides the pointer while the circle stays put. @@ -168,32 +168,44 @@ fn preview_tint(app: &PhotocraftApp) -> Color32 { }) } -/// A round tip of `radius` screen points: solid out to `hardness` of the radius, fading to clear -/// at the edge. One mesh (two rings and a centre), so its cost doesn't grow with the brush. -fn tip_preview(painter: &egui::Painter, c: Pos2, radius: f32, hardness: f32, color: Color32) { +/// The round tip as the brush engine paints it: `radius` document pixels at `zoom` screen points +/// per document pixel, solid out to its core and then the engine's falloff (`tip_falloff`) out to +/// its reach, past the radius for a soft tip, so the disc shows what a dab of this hardness covers. +/// One mesh of rings, so its cost doesn't grow with the brush. +fn tip_preview(painter: &egui::Painter, c: Pos2, radius: f32, zoom: f32, hardness: f32, color: Color32) { + /// Rings across the falloff: the shoulder is smooth, so linear steps between them are close. + const RINGS: usize = 16; + if !(radius.is_finite() && zoom.is_finite() && radius > 0.0 && zoom > 0.0) { + return; + } + let (core, _) = photocraft_paint::tip_shape(radius, hardness); + let reach = photocraft_paint::tip_reach(radius, hardness).max(core); + let outer = reach * zoom; let clip = painter.clip_rect(); - if !clip.expand(radius).contains(c) { + if !clip.expand(outer).contains(c) { return; } // Enough segments for a smooth edge at any size, capped so huge brushes stay cheap. - let n = ((radius * 0.5) as usize).clamp(24, 160); - let inner = radius * hardness.clamp(0.0, 1.0); - let clear = Color32::TRANSPARENT; + let n = ((outer * 0.5) as usize).clamp(24, 160); let mut mesh = egui::Mesh::default(); mesh.colored_vertex(c, color); - for k in 0..n { - let a = k as f32 / n as f32 * std::f32::consts::TAU; - let d = vec2(a.cos(), a.sin()); - mesh.colored_vertex(c + d * inner, color); - mesh.colored_vertex(c + d * radius, clear); + for k in 0..=RINGS { + let d = core + (reach - core) * k as f32 / RINGS as f32; + // The last ring is the reach, where the falloff has run out. + let tint = if k == RINGS { Color32::TRANSPARENT } else { color.gamma_multiply(photocraft_paint::tip_falloff(d, radius, hardness)) }; + for j in 0..n { + let a = j as f32 / n as f32 * std::f32::consts::TAU; + mesh.colored_vertex(c + vec2(a.cos(), a.sin()) * d * zoom, tint); + } } let n = n as u32; - for k in 0..n { - let (i0, o0) = (1 + 2 * k, 2 + 2 * k); - let (i1, o1) = (1 + 2 * ((k + 1) % n), 2 + 2 * ((k + 1) % n)); - mesh.add_triangle(0, i0, i1); - mesh.add_triangle(i0, o0, o1); - mesh.add_triangle(i0, o1, i1); + let at = |ring: u32, j: u32| 1 + ring * n + j % n; + for j in 0..n { + mesh.add_triangle(0, at(0, j), at(0, j + 1)); + for ring in 0..RINGS as u32 { + mesh.add_triangle(at(ring, j), at(ring + 1, j), at(ring + 1, j + 1)); + mesh.add_triangle(at(ring, j), at(ring + 1, j + 1), at(ring, j + 1)); + } } painter.add(mesh); } @@ -526,6 +538,45 @@ mod tests { } /// Preferences ▸ Cursors ▸ Brush Preview colour tints the HUD's tip preview (red by default). + /// The tip's mesh, painted at the origin: (distance from the centre, alpha) per vertex. + fn tip_mesh(radius: f32, zoom: f32, hardness: f32) -> Vec<(f32, u8)> { + let ctx = egui::Context::default(); + let color = Color32::from_rgba_unmultiplied(255, 0, 0, 200); + let mut out = ctx.run_ui(Default::default(), |ui| { + let painter = ui.painter().with_clip_rect(egui::Rect::from_center_size(Pos2::ZERO, vec2(4000.0, 4000.0))); + tip_preview(&painter, Pos2::ZERO, radius, zoom, hardness, color); + }); + out.textures_delta.clear(); + let mesh = out + .shapes + .iter() + .find_map(|c| match &c.shape { + egui::Shape::Mesh(m) => Some(m.clone()), + _ => None, + }) + .expect("the tip is drawn"); + mesh.vertices.iter().map(|v| (v.pos.to_vec2().length(), v.color.a())).collect() + } + + /// The disc follows the engine's round tip: a hard one is solid to its rim, a soft one fades + /// as the engine's falloff does and reaches past the radius as its dabs do. + #[test] + fn the_tip_preview_follows_the_brush_falloff() { + for zoom in [1.0, 2.5] { + let hard = tip_mesh(50.0, zoom, 1.0); + assert!(hard.iter().filter(|(d, _)| *d < 48.0 * zoom).all(|(_, a)| *a == 200), "zoom {zoom}: a hard tip is solid inside"); + assert!(hard.iter().all(|(d, a)| *d <= 52.0 * zoom || *a == 0), "zoom {zoom}: and ends at its rim"); + let soft = tip_mesh(50.0, zoom, 0.0); + for (d, a) in &soft { + let want = 200.0 * photocraft_paint::tip_falloff(d / zoom, 50.0, 0.0); + assert!((f32::from(*a) - want).abs() <= 3.0 || *a == 0, "zoom {zoom}: at {d}: {a}, the engine {want}"); + } + let reach = photocraft_paint::tip_reach(50.0, 0.0); + assert!(soft.iter().any(|(d, a)| *d > 60.0 * zoom && *a > 0), "zoom {zoom}: a soft tip shows past its radius"); + assert!(soft.iter().all(|(d, _)| *d <= reach * zoom + 0.5), "zoom {zoom}: and no further than it reaches"); + } + } + #[test] fn the_tip_preview_uses_the_brush_preview_colour() { let mut app = app(Tool::Brush); From 04dd3d5b638911581fa66d550e46894b10d08c2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Fri, 23 Oct 2026 20:53:42 +0200 Subject: [PATCH 46/86] fix(ui): the options-bar brush chip draws the brush's own tip (#8936) The chip drew eight concentric circles from `hardness` alone, with an ad-hoc ramp, so it was always round and didn't show the engine's falloff. It now draws `brush_preview::tip_texture`, as the Brush Preset picker's thumbnails do: `dab_coverage` for a round tip's hardness, its angle and roundness, a sampled tip's bitmap. `panels::brush_tip` is gone. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels.rs | 58 +++++++++++++++++++++++++++--------- 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index b316669258..465269d98f 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -4433,19 +4433,6 @@ fn hex_edit(ui: &mut egui::Ui, id: egui::Id, color: &mut [f32; 4]) -> bool { false } -/// Photoshop's brush preset picker chip: a soft/hard round tip preview with the size underneath. -/// Draw a round brush tip preview (hard core fading to a soft edge). -fn brush_tip(p: &egui::Painter, c: egui::Pos2, rad: f32, hardness: f32, color: Color32) { - let inner = rad * hardness.clamp(0.05, 1.0); - let steps = 8; - for k in (0..=steps).rev() { - let f = k as f32 / steps as f32; - let rr = inner + (rad - inner) * f; - let a = if hardness >= 0.99 { 1.0 } else { (1.0 - f).powf(1.5) }; - p.circle_filled(c, rr, color.gamma_multiply(a)); - } -} - /// The Brush's or Pencil's Mode dropdown: the blend modes, with Photoshop's painting-only Clear /// after Dissolve (#3439). Clear erases with the brush (`clear`); it shows while chosen, or while /// `~` is held (`held`). The wheel over it steps the mode, as over the Layers panel's (#2061). @@ -4538,7 +4525,11 @@ fn brush_preset_chip(ui: &mut egui::Ui, b: &photocraft_engine::BrushSettings, st let c = pos2(r.left() + 14.0, r.top() + 11.0); // Photoshop's white tip vanishes on light chrome; light themes use their icon colour. let tip = if t.dark() { Color32::WHITE } else { t.icon }; - brush_tip(ui.painter(), c, 7.0, b.hardness, tip); + // The brush's own tip, as the Brush Preset picker's thumbnails draw it: the engine's falloff + // for its hardness, its angle and roundness, a sampled tip's shape. + let tex = crate::brush_preview::tip_texture(ui.ctx(), "options-bar-chip", &b.tip, (b.hardness, b.angle, b.roundness), 32, tip); + let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + ui.painter().image(tex.id(), Rect::from_center_size(c, vec2(14.0, 14.0)), uv, Color32::WHITE); ui.painter().text(pos2(c.x, r.bottom() - 5.0), Align2::CENTER_CENTER, format!("{}", b.size.round() as i64), egui::FontId::proportional(9.5), t.text_dim); icons::paint(ui, Rect::from_center_size(pos2(r.right() - 9.0, c.y), vec2(10.0, 10.0)), "chevron-down", 9.0, t.text_faint); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Brush Preset picker"))); @@ -7533,3 +7524,42 @@ mod sampler_readout_tests { assert_eq!(sampler_readouts(&mut app).len(), 2, "so does a new sampler"); } } + +#[cfg(test)] +mod brush_chip_tests { + use super::*; + + /// The options-bar chip's tip texture for `brush`, row-major alpha (32 × 32). + fn chip_tip(brush: &photocraft_engine::BrushSettings) -> Vec { + let ctx = egui::Context::default(); + let mut state = crate::state::UiState::default(); + let mut out = ctx.run_ui(Default::default(), |ui| brush_preset_chip(ui, brush, &mut state)); + // `Painter::image` is a textured mesh. + let id = out.shapes.iter().find_map(|c| match &c.shape { + egui::Shape::Mesh(m) if (m.calc_bounds().width() - 14.0).abs() < 0.01 => Some(m.texture_id), + _ => None, + }); + let image = id.and_then(|id| out.textures_delta.set.get(&id)).and_then(|d| d.last()).map(|d| match &d.image { + egui::ImageData::Color(c) => c.clone(), + }); + out.textures_delta.clear(); + let image = image.expect("the chip draws its tip as an uploaded texture"); + assert_eq!(image.size, [32, 32]); + image.pixels.iter().map(|p| p.a()).collect() + } + + /// The chip shows the brush's own tip: a soft brush fades from its centre as its dabs do, a + /// hard one is solid to its rim, and a flattened tip is flat, not a round blob. + #[test] + fn the_brush_chip_draws_the_brushs_own_tip() { + let at = |a: &[u8], x: usize, y: usize| a[y * 32 + x]; + let hard = chip_tip(&photocraft_engine::BrushSettings { hardness: 1.0, ..Default::default() }); + let soft = chip_tip(&photocraft_engine::BrushSettings { hardness: 0.0, ..Default::default() }); + assert!(at(&hard, 16, 16) == 255 && at(&hard, 16 + 11, 16) == 255, "hard: solid to near the rim"); + assert!(at(&soft, 16, 16) > 230, "soft: full at the centre"); + assert!(at(&soft, 16 + 11, 16) < 80, "soft: faint near the rim: {}", at(&soft, 16 + 11, 16)); + assert!(at(&soft, 16 + 6, 16) > at(&soft, 16 + 11, 16), "and fading outwards"); + let flat = chip_tip(&photocraft_engine::BrushSettings { hardness: 1.0, roundness: 0.3, ..Default::default() }); + assert!(at(&flat, 16 + 11, 16) > 200 && at(&flat, 16, 16 + 11) == 0, "a flattened tip is flat"); + } +} From 390793f926a14c6384c8672e9d244cc92b94ab2d Mon Sep 17 00:00:00 2001 From: Finn Newick Date: Fri, 23 Oct 2026 23:07:52 +0100 Subject: [PATCH 47/86] fix(align): measure shapes' full extent only for Align and Distribute layer_bounds goes back to the cached render's bounds, so the layer list, copy and artboard paths don't re-render shapes. Align and Distribute use align_bounds, which measures a shape's own extent off the canvas. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/layer_multi_cmds.rs | 57 +++++++++++++++------------ 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index c81d24c4f1..2b73798127 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -1014,27 +1014,31 @@ pub(crate) fn note_insert(s: &mut Session, before: &Document) { st.last_damage = step_damage(before, &st.doc); } -/// Content bounds used by Align/Distribute: the layer's pixels (type and shape layers use their -/// rendered pixels), or the union of a group's children. Fill and adjustment layers have none. +/// Content bounds of a layer's pixels as stored (type and shape layers: their cached render), or +/// the union of a group's children. Fill and adjustment layers have none. Cheap: Layers list, +/// artboards, copy and slices call it for every layer. pub fn layer_bounds(l: &Layer) -> Option { - layer_bounds_in(l, None) + let r = match &l.content { + LayerContent::Group(g) => g.children.iter().filter_map(layer_bounds).fold(Rect::EMPTY, |a, b| a.union(&b)), + _ => l.surface().map(|s| s.content_bounds()).unwrap_or(Rect::EMPTY), + }; + (!r.is_empty()).then_some(r) } -/// [`layer_bounds`] in a document of bounds `canvas`: the frame a shape's gradients are laid out -/// in when they aren't aligned with the layer, so an opacity stop measures as it renders. Without -/// it the shape's own reach stands in. -pub fn layer_bounds_in(l: &Layer, canvas: Option) -> Option { +/// The bounds Align and Distribute move by, in a document of bounds `canvas`: [`layer_bounds`], +/// except that a shape layer is measured over everything it draws, beyond the canvas too (its +/// cache is clipped to the canvas, #8660), by rendering it again. Only those commands pay for that +/// render, once per click; `canvas` is the frame a shape's gradients are laid out in, so an opacity +/// stop measures as it renders. +pub(crate) fn align_bounds(l: &Layer, canvas: Rect) -> Option { let r = match &l.content { - LayerContent::Group(g) => g.children.iter().filter_map(|c| layer_bounds_in(c, canvas)).fold(Rect::EMPTY, |a, b| a.union(&b)), + LayerContent::Group(g) => g.children.iter().filter_map(|c| align_bounds(c, canvas)).fold(Rect::EMPTY, |a, b| a.union(&b)), LayerContent::Shape(sh) => shape_pixel_bounds(sh, canvas).unwrap_or_else(|| { let cached = l.surface().map(|s| s.content_bounds()).unwrap_or(Rect::EMPTY); // A fill that shows everywhere covers the canvas, rendered yet or not. - match canvas { - Some(c) if cached.is_empty() && unbounded_fill(sh) => c, - _ => cached, - } + if cached.is_empty() && unbounded_fill(sh) { canvas } else { cached } }), - _ => l.surface().map(|s| s.content_bounds()).unwrap_or(Rect::EMPTY), + _ => return layer_bounds(l), }; (!r.is_empty()).then_some(r) } @@ -1055,7 +1059,7 @@ const SHAPE_BOUNDS_MAX_PX: u64 = 64 << 20; /// everything it can draw (its path, stroke and feather), unclipped, and measured from that. A fill /// that shows everywhere (an inverted path, or below 100 % density) has no extent of its own and /// keeps the canvas-clipped measure (`None`). -fn shape_pixel_bounds(sh: &photocraft_doc::ShapeLayer, canvas: Option) -> Option { +fn shape_pixel_bounds(sh: &photocraft_doc::ShapeLayer, canvas: Rect) -> Option { if unbounded_fill(sh) { return None; } @@ -1065,8 +1069,7 @@ fn shape_pixel_bounds(sh: &photocraft_doc::ShapeLayer, canvas: Option) -> } // Gradients lay out in the document's frame (as the cached render does); the feather and the // clip cover the shape's whole reach, beyond the canvas too. - let frame = canvas.unwrap_or(reach); - let shape = photocraft_vector::CompiledShape::with_frames(sh, photocraft_vector::DEFAULT_TOLERANCE, reach, frame); + let shape = photocraft_vector::CompiledShape::with_frames(sh, photocraft_vector::DEFAULT_TOLERANCE, reach, canvas); let r = shape.render(PixelFormat::GRAYA8, reach).content_bounds(); (!r.is_empty()).then_some(r) } @@ -1112,7 +1115,7 @@ impl Edge { /// Selected top-level layers that have content bounds, bottom-to-top. fn bounded_selection(doc: &Document, sel: &[LayerId]) -> Vec<(LayerId, Rect)> { - top_level(doc, sel).into_iter().filter_map(|id| Some((id, layer_bounds_in(doc.layer(id)?, Some(doc.bounds()))?))).collect() + top_level(doc, sel).into_iter().filter_map(|id| Some((id, align_bounds(doc.layer(id)?, doc.bounds())?))).collect() } fn align(s: &mut Session, p: &Value, kind: &str) -> Result { @@ -2415,19 +2418,23 @@ mod tests { #[test] fn align_uses_a_shape_layers_full_extent_off_the_canvas() { let mut s = session(8); + let drawn = |s: &Session, id: LayerId| align_bounds(doc(s).layer(id).unwrap(), doc(s).bounds()).unwrap(); let shape = LayerId(s.execute("shape.create", json!({"kind": "rect", "rect": [-20, 10, 40, 20], "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap()); - assert_eq!(bounds(&s, shape), Rect::new(-20, 10, 20, 30), "the shape's own extent, not the canvas-clipped render"); + let measured = align_bounds(doc(&s).layer(shape).unwrap(), doc(&s).bounds()); + assert_eq!(measured, Some(Rect::new(-20, 10, 20, 30)), "Align: the shape's own extent, not the canvas-clipped render"); + // Everything else keeps the cheap measure of the cached render (no re-render per layer). + assert_eq!(bounds(&s, shape), Rect::new(0, 10, 20, 30), "layer_bounds: the cache, clipped to the canvas"); let pixels = rect_layer(&mut s, Rect::new(30, 40, 60, 50)); select_all(&mut s, &[shape, pixels]); s.execute("layer.align.leftEdges", json!({})).unwrap(); assert_eq!(bounds(&s, pixels), Rect::new(-20, 40, 10, 50)); - assert_eq!(bounds(&s, shape), Rect::new(-20, 10, 20, 30)); + assert_eq!(drawn(&s, shape), Rect::new(-20, 10, 20, 30)); s.execute("layer.align.leftEdges", json!({})).unwrap(); assert_eq!(bounds(&s, pixels), Rect::new(-20, 40, 10, 50), "a second click moves nothing"); // Centred: the union is -20..20 wide on the shape's side and 30 px for the pixel layer. s.execute("layer.align.horizontalCenters", json!({})).unwrap(); - assert_eq!(bounds(&s, shape), Rect::new(-20, 10, 20, 30)); + assert_eq!(drawn(&s, shape), Rect::new(-20, 10, 20, 30)); assert_eq!(bounds(&s, pixels), Rect::new(-15, 40, 15, 50)); // A feathered shape is measured by its soft edge too, which reaches past the path. let soft = @@ -2439,11 +2446,9 @@ mod tests { Ok(()) }) .unwrap(); - let b = bounds(&s, soft); - assert!(b.x0 < -20 && b.x1 > 20 && b.y0 < 50 && b.y1 > 70, "{b:?}"); // Measured as Align does, in the document's frame: the feather off the canvas still counts. - let in_doc = layer_bounds_in(doc(&s).layer(soft).unwrap(), Some(doc(&s).bounds())).unwrap(); - assert_eq!(in_doc, b); + let b = drawn(&s, soft); + assert!(b.x0 < -20 && b.x1 > 20 && b.y0 < 50 && b.y1 > 70, "{b:?}"); // A fill that shows everywhere keeps the canvas-clipped measure. s.edit("invert", |doc, _| { if let Some(LayerContent::Shape(sh)) = doc.layer_mut(soft).map(|l| &mut l.content) { @@ -2454,13 +2459,13 @@ mod tests { }) .unwrap(); let LayerContent::Shape(sh) = &doc(&s).layer(soft).unwrap().content else { panic!("shape") }; - assert_eq!(shape_pixel_bounds(sh, Some(doc(&s).bounds())), None); + assert_eq!(shape_pixel_bounds(sh, doc(&s).bounds()), None); // Without a cached render yet, such a fill still measures as the canvas, not as empty. let mut uncached = doc(&s).layer(soft).unwrap().clone(); if let LayerContent::Shape(sh) = &mut uncached.content { sh.cache = None; } - assert_eq!(layer_bounds_in(&uncached, Some(doc(&s).bounds())), Some(doc(&s).bounds())); + assert_eq!(align_bounds(&uncached, doc(&s).bounds()), Some(doc(&s).bounds())); } #[test] From 9267c4fcf83a260d4bae0e441dc1af9bc48aa23b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Batuhan=20G=C3=B6zeg=C3=BC?= <112725779+batuhangozegu@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:08:48 +0300 Subject: [PATCH 48/86] Update contributors/people.toml Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- contributors/people.toml | 3 --- 1 file changed, 3 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 4bb99e2157..2e2f945188 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -252,9 +252,6 @@ sync_github_name = true [people.benandez] real_name = "Martin Ball" -[people.batuhangozegu] -real_name = "Batuhan Gözegü" -sync_github_name = false [people.ronibalthazar] sync_github_name = true From 0dd7400274438d1663f10d1d9ac7881672c7e39e Mon Sep 17 00:00:00 2001 From: denstu473 <63150090+denstu473@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:38:36 +0200 Subject: [PATCH 49/86] fix(ui): keep repeated Tab presses in text editing (#8889) egui queues focus navigation before the type tool consumes Tab. Removing the key event leaves that navigation queued, allowing menu focus to block later Tabs and ordinary text input. Cancel the queued move when inserting a tab. Add a full-app input regression that fails on the second Tab before the fix and verifies three Tab presses, caret positions, and subsequent typing. --- crates/ui-egui/src/type_tool.rs | 7 ++++++- crates/ui-egui/src/type_tool_tests.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index 0bfda45226..4468a70570 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -1058,7 +1058,12 @@ pub fn handle_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { Key::Enter if m.command => commit_with(app, *m), Key::Enter => insert(app, "\n"), // Photoshop types a tab character; layout advances it to the next tab stop. - Key::Tab if !m.command && !m.alt && !m.shift => insert(app, "\t"), + Key::Tab if !m.command && !m.alt && !m.shift => { + // egui queues focus navigation before this handler runs; consuming the + // key alone would let the menu bar take focus after the first tab (#8889). + ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + insert(app, "\t"); + } Key::Escape if crate::type_transform::active(app) => crate::type_transform::cancel_drag(app), // The Type Mask tools: Escape always cancels, leaving the selection as it was. Key::Escape if ed.mask => cancel(app), diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index e6e3b66c83..a39cb83d57 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -969,6 +969,33 @@ fn tab_while_typing_inserts_a_tab() { assert_eq!(h.state().ui.panels.dock, panels, "the panels stay as they were"); } +/// #8889: separate Tab presses must keep inserting text with the menu bar and panels present. +#[test] +fn repeated_tabs_while_typing_keep_keyboard_focus_on_the_canvas() { + let mut app = new_app(); + let id = LayerId(app.run("type.create", json!({"text": "HOHO", "size": 60, "x": 300, "y": 420})).unwrap()["layer"].as_u64().unwrap()); + let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).with_max_steps(32).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + app + }); + h.run_steps(4); + let p = glyph(&mut h, id, 1, 0.2); + click(&mut h, p); + assert_eq!(selection(&h), (1, 1)); + let panels = h.state().ui.panels.dock; + for (expected, caret) in [("H\tOHO", 2), ("H\t\tOHO", 3), ("H\t\t\tOHO", 4)] { + h.key_press(egui::Key::Tab); + h.run_steps(2); + assert_eq!(text(h.state(), id).text, expected); + assert_eq!(selection(&h), (caret, caret)); + assert!(h.state().ui.text_edit.is_some(), "still editing"); + assert_eq!(h.state().ui.panels.dock, panels, "the panels stay as they were"); + } + h.event(egui::Event::Text("typed".into())); + h.run_steps(2); + assert_eq!(text(h.state(), id).text, "H\t\t\ttypedOHO"); +} + #[test] fn text_color_dialog_edits_only_the_selected_range_and_cancel_is_inert() { let mut app = new_app(); From 35c6c82b2db00d409c6a9b604ed93c6e92269f6e Mon Sep 17 00:00:00 2001 From: Federico Olivares Date: Fri, 23 Oct 2026 22:20:16 -0300 Subject: [PATCH 50/86] Add cross-PR acceptance tests for the parity batch --- crates/engine/tests/it/integration_batch.rs | 52 +++++++++++++++++++++ crates/engine/tests/it/main.rs | 1 + 2 files changed, 53 insertions(+) create mode 100644 crates/engine/tests/it/integration_batch.rs diff --git a/crates/engine/tests/it/integration_batch.rs b/crates/engine/tests/it/integration_batch.rs new file mode 100644 index 0000000000..c206697e54 --- /dev/null +++ b/crates/engine/tests/it/integration_batch.rs @@ -0,0 +1,52 @@ +//! Cross-PR acceptance checks for the merged parity batch: each test pins one fix through the +//! public command surface, so a bad merge between the PRs shows up here. +use photocraft_engine::Session; +use serde_json::json; +use std::time::{Duration, Instant}; + +fn session(width: u32, height: u32) -> Session { + let mut session = Session::new(); + session.execute("file.new", json!({"width": width, "height": height})).unwrap(); + session +} + +#[test] +fn a_new_fill_layer_consumes_the_selection_it_took_as_mask() { + let mut s = session(32, 24); + s.execute("select.rect", json!({"x": 2, "y": 2, "width": 10, "height": 8})).unwrap(); + s.execute("layer.newFillLayer.solidColor", json!({"color": "#4080c0"})).unwrap(); + assert!(s.active().unwrap().doc.selection.is_none()); +} + +#[test] +fn define_pattern_accepts_a_canvas_wider_than_the_old_4000_px_cap() { + let mut s = session(4500, 8); + s.execute("edit.definePattern", json!({"name": "Wide"})).unwrap(); + let mut s = session(8001, 8); + assert!(s.execute("edit.definePattern", json!({"name": "TooWide"})).is_err()); +} + +#[test] +fn a_header_declaring_more_pixels_than_the_file_holds_is_refused_quickly() { + // BMP: 30000 x 30000 at 24 bpp declared in a 54-byte file. + let mut bmp = vec![0u8; 54]; + bmp[0..2].copy_from_slice(b"BM"); + bmp[10..14].copy_from_slice(&54u32.to_le_bytes()); + bmp[14..18].copy_from_slice(&40u32.to_le_bytes()); + bmp[18..22].copy_from_slice(&30000i32.to_le_bytes()); + bmp[22..26].copy_from_slice(&30000i32.to_le_bytes()); + bmp[26..28].copy_from_slice(&1u16.to_le_bytes()); + bmp[28..30].copy_from_slice(&24u16.to_le_bytes()); + let start = Instant::now(); + assert!(photocraft_codecs::decode(&bmp).is_err()); + assert!(start.elapsed() < Duration::from_secs(5)); +} + + +#[test] +fn gaussian_blur_accepts_radii_below_one_without_failing() { + for radius in [0.05, 0.1, 0.3, 0.7, 1.0] { + let mut s = session(24, 16); + s.execute("filter.blur.gaussianBlur", json!({"radius": radius})).unwrap(); + } +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 25455e539c..f6a8360a4f 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -37,6 +37,7 @@ mod guide_layout_limit; mod guide_layout_preview; mod hdr_gamma_direction; mod huge_doc_flatten; +mod integration_batch; mod jpeg_exif_resolution; mod keyboard_shortcut_bulk; mod kra; From 7e4ff3222db384e3c7d9c57d3fc264c2ad2f6ee8 Mon Sep 17 00:00:00 2001 From: Federico Olivares Date: Fri, 23 Oct 2026 22:28:21 -0300 Subject: [PATCH 51/86] Acceptance test: Gaussian Blur radius starts at 0.1 --- crates/engine/tests/it/integration_batch.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/tests/it/integration_batch.rs b/crates/engine/tests/it/integration_batch.rs index c206697e54..8d5e358a52 100644 --- a/crates/engine/tests/it/integration_batch.rs +++ b/crates/engine/tests/it/integration_batch.rs @@ -45,7 +45,7 @@ fn a_header_declaring_more_pixels_than_the_file_holds_is_refused_quickly() { #[test] fn gaussian_blur_accepts_radii_below_one_without_failing() { - for radius in [0.05, 0.1, 0.3, 0.7, 1.0] { + for radius in [0.1, 0.3, 0.7, 1.0] { let mut s = session(24, 16); s.execute("filter.blur.gaussianBlur", json!({"radius": radius})).unwrap(); } From eda6e71835e3c7e20995e1754d3ee9d33ad75ec5 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Sat, 24 Oct 2026 09:22:48 +0530 Subject: [PATCH 52/86] fix(ui): Select and Mask applies its work only to the document and layer it opened on The workspace kept the document's index and the layer, but OK ran select.refineEdge on whatever was active. A document switch and OK queued on the control channel with no frame between them applied the first document's edits to the second, and selecting another layer in the same document sent a layer-mask OK to that layer. The workspace now stores its DocId. OK, control requests, the preview and pointer events check that the active document and layer are the ones it opened on; when they are not, the workspace closes with the reason in the status bar and nothing is edited. distort_ui's cancel_stale drops it like Liquify and Puppet, and the pointer route has the same on_doc guard (review by FredApps and aiongg on #8896). --- crates/ui-egui/src/select_mask_ui.rs | 51 +++++++++++-- crates/ui-egui/src/select_mask_ui_tests.rs | 85 ++++++++++++++++++++++ 2 files changed, 131 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/select_mask_ui.rs b/crates/ui-egui/src/select_mask_ui.rs index 323c613246..8b406ab85c 100644 --- a/crates/ui-egui/src/select_mask_ui.rs +++ b/crates/ui-egui/src/select_mask_ui.rs @@ -26,7 +26,7 @@ use std::sync::Arc; use egui::{Align2, Color32, FontId, Pos2, Rect as ERect, Sense, Stroke, TextureHandle, pos2, vec2}; -use photocraft_doc::{Document, LayerId}; +use photocraft_doc::{DocId, Document, LayerId}; use photocraft_engine::select_mask_edits::{self, MAX_EDIT_POINTS, MAX_EDITS}; use photocraft_engine::smartselect_cmds::{MAX_STROKE_POINTS, MAX_STROKES}; use photocraft_geom::Rect; @@ -388,8 +388,9 @@ struct Preview { } pub struct SelectMaskWorkspace { + /// The document and layer the workspace was opened on: its work applies to them only. layer: Option, - doc_index: usize, + pub(crate) document: DocId, canvas: Rect, /// The document revision the textures were made from. revision: u64, @@ -713,10 +714,10 @@ pub fn open(app: &mut PhotocraftApp) -> Result<(), String> { if let Some(Err(why)) = photocraft_engine::commands::find(CMD).map(|c| (c.enabled)(&app.session)) { return Err(format!("Select and Mask needs a selection or a layer mask ({why})")); } - let doc_index = app.session.active_index().ok_or("no document")?; // A targeted layer mask is what is refined (Photoshop opens on it from the mask's thumbnail). let target_mask = app.layer_mask_targeted(); let st = app.session.active().ok_or("no document")?; + let document = st.doc.id; let layer = st.active_layer.filter(|id| st.doc.layer(*id).is_some()); let canvas = st.doc.bounds(); let revision = st.revision; @@ -732,7 +733,7 @@ pub fn open(app: &mut PhotocraftApp) -> Result<(), String> { } let mut w = SelectMaskWorkspace { layer, - doc_index, + document, canvas, revision, target_mask, @@ -769,6 +770,31 @@ pub fn open(app: &mut PhotocraftApp) -> Result<(), String> { Ok(()) } +/// Why the open workspace's work no longer applies: the active document or layer is not the one it +/// was opened on (another tab, or another layer selected by a shortcut or the control channel). +/// `select.refineEdge` works on the active document and layer, so its edits must not run there. +pub(crate) fn stale(app: &PhotocraftApp) -> Option<&'static str> { + let w = app.distort.select_mask.as_ref()?; + let Some(st) = app.session.active().filter(|st| st.doc.id == w.document) else { + return Some("Select and Mask was closed: its document is no longer the active one"); + }; + let layer = st.active_layer.filter(|id| st.doc.layer(*id).is_some()); + (layer != w.layer).then_some("Select and Mask was closed: another layer was selected") +} + +/// Closes a [`stale`] workspace (its work is dropped, the new target untouched): the reason. +fn drop_stale(app: &mut PhotocraftApp) -> Result<(), String> { + match stale(app) { + Some(why) => { + close(app); + app.ui.status = why.into(); + app.ui.status_error = true; + Err(why.into()) + } + None => Ok(()), + } +} + /// The params OK runs `select.refineEdge` with. fn commit_params(w: &SelectMaskWorkspace) -> Value { let mut p = w.opts.refine_params(); @@ -793,6 +819,7 @@ fn remember(app: &mut PhotocraftApp, opts: &SmOpts) { /// OK: runs `select.refineEdge` and closes. A failure leaves the workspace open with the error in /// its panel, so nothing painted is lost. pub fn commit(app: &mut PhotocraftApp) -> Result { + drop_stale(app)?; let w = app.distort.select_mask.as_mut().ok_or("Select and Mask is not open")?; w.end_gesture(); w.close_polygon(); @@ -861,6 +888,7 @@ pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result { cancel(app); return Ok(json!({"cancelled": true})); } + drop_stale(app)?; { let w = app.distort.select_mask.as_mut().ok_or("Select and Mask is not open")?; let before = w.opts.clone(); @@ -986,6 +1014,9 @@ struct Job { } fn job(app: &PhotocraftApp) -> Option { + if stale(app).is_some() { + return None; + } let w = app.distort.select_mask.as_ref()?; let st = app.session.active()?; let k = w.proxy_k(); @@ -1142,6 +1173,7 @@ fn take_result(w: &mut SelectMaskWorkspace, generation: u64, revision: u64, r: R /// Computes the preview now, on this thread (tests and the control channel). fn wait_preview(app: &mut PhotocraftApp) -> Result<(), String> { + drop_stale(app)?; let j = job(app).ok_or("Select and Mask is not open")?; let revision = j.revision; let r = compute(j, None); @@ -1213,6 +1245,9 @@ fn drive_preview(app: &mut PhotocraftApp, _ctx: &egui::Context) { /// Pointer in document coordinates (from the view or the control channel). pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { + if drop_stale(app).is_err() { + return; + } let Some(w) = app.distort.select_mask.as_mut() else { return }; match ev { ToolEvent::Down { x, y, .. } if w.tool == SmTool::Zoom => { @@ -1505,7 +1540,13 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { w.stale(); } upload(app, ctx); - let title = app.distort.select_mask.as_ref().map(|w| crate::canvas::tab_title_at(app, w.doc_index, if w.zoom > 0.0 { w.zoom * 100.0 } else { 100.0 })); + let title = app.distort.select_mask.as_ref().map(|w| { + crate::canvas::tab_title_at( + app, + app.session.documents().iter().position(|d| d.doc.id == w.document).unwrap_or(0), + if w.zoom > 0.0 { w.zoom * 100.0 } else { 100.0 }, + ) + }); let t = Tokens::get(ctx); let screen = ctx.content_rect(); let mut action: Option<&str> = None; diff --git a/crates/ui-egui/src/select_mask_ui_tests.rs b/crates/ui-egui/src/select_mask_ui_tests.rs index 3ed398e271..edb3602f8c 100644 --- a/crates/ui-egui/src/select_mask_ui_tests.rs +++ b/crates/ui-egui/src/select_mask_ui_tests.rs @@ -577,3 +577,88 @@ fn greyed_controls_explain_themselves() { h.run_steps(4); } } + +fn call(app: &mut PhotocraftApp, method: &str, params: Value) -> Value { + let (req, _rx) = crate::control::ControlRequest::new(method, params); + match crate::control::handle(app, &egui::Context::default(), &req) { + crate::control::Outcome::Done(v) => v, + _ => panic!("{method}: expected an immediate reply"), + } +} + +/// A second document with its own selection, the first one active again. +fn two_documents() -> PhotocraftApp { + let mut app = app_with_selection(); + app.session.execute("file.new", json!({"width": 160, "height": 100, "depth": 8})).unwrap(); + app.session.execute("select.rect", json!({"x": 100, "y": 50, "width": 30, "height": 30})).unwrap(); + assert!(app.session.set_active(0)); + app.sync_views(); + app +} + +fn fingerprint(app: &PhotocraftApp, i: usize) -> (u64, Option, Option) { + let st = &app.session.documents()[i]; + let sel = st.doc.selection.as_ref(); + (st.revision, sel.map(|s| s.sample_channel(10, 10, 0)), sel.map(|s| s.sample_channel(120, 50, 0))) +} + +/// The #8896 review: a document switch and OK queued on the control channel, with no frame +/// between them, must not apply the first document's edits to the second. +#[test] +fn ok_after_a_document_switch_changes_neither_document() { + let mut app = two_documents(); + open(&mut app).unwrap(); + ui(&mut app, json!({"stroke": {"points": [[120.0, 50.0], [130.0, 50.0]]}})); + let before = (fingerprint(&app, 0), fingerprint(&app, 1)); + let switched = call(&mut app, "ui.menu.invoke", json!({"command": "window.document.1"})); + assert_eq!(app.session.active_index(), Some(1), "{switched}"); + let r = call(&mut app, "ui.menu.invoke", json!({"command": "select.selectAndMask", "params": {"ui": {"commit": true}}})); + assert!(r.get("error").is_some(), "the stale OK is refused: {r}"); + assert_eq!((fingerprint(&app, 0), fingerprint(&app, 1)), before, "neither document changed"); + assert!(app.distort.select_mask.is_none(), "the stale workspace is closed"); +} + +/// Every route into the workspace checks its document: control edits, the preview and pointer +/// events on another document are refused, and none of them edits that document. +#[test] +fn control_preview_and_pointer_on_another_document_are_refused() { + for route in ["control", "preview", "pointer"] { + let mut app = two_documents(); + open(&mut app).unwrap(); + let before = fingerprint(&app, 1); + assert!(app.session.set_active(1)); + match route { + "control" => assert!(control(&mut app, &json!({"stroke": {"points": [[120.0, 50.0], [130.0, 50.0]]}})).is_err()), + "preview" => assert!(control(&mut app, &json!({"waitPreview": true})).is_err()), + _ => { + let ev = ToolEvent::Down { x: 120.0, y: 50.0, pressure: 1.0 }; + crate::distort_ui::pointer(&mut app, ev, egui::Modifiers::NONE); + } + } + assert!(app.distort.select_mask.is_none(), "{route}: the stale workspace is closed"); + assert_eq!(fingerprint(&app, 1), before, "{route}: the other document is untouched"); + } +} + +/// Selecting another layer in the same document (a shortcut, or the control channel between +/// requests) must not send a layer-mask OK to that layer. +#[test] +fn ok_after_a_layer_switch_leaves_the_new_layer_alone() { + let mut app = app_with_selection(); + let first = app.session.active().unwrap().active_layer.unwrap(); + let other = app.run("layer.new.layer", json!({"name": "Other"})).unwrap()["layer"].as_u64().map(LayerId).unwrap(); + app.run("layer.select", json!({"layer": first.0})).unwrap(); + open(&mut app).unwrap(); + ui(&mut app, json!({"output": "layerMask"})); + let revision = app.session.active().unwrap().revision; + call(&mut app, "engine.execute", json!({"command": "layer.select", "params": {"layer": other.0}})); + let revision_after_select = app.session.active().unwrap().revision; + let r = call(&mut app, "ui.menu.invoke", json!({"command": "select.selectAndMask", "params": {"ui": {"commit": true}}})); + assert!(r.get("error").is_some(), "the stale OK is refused: {r}"); + let st = app.session.active().unwrap(); + assert!(st.doc.layer(other).unwrap().mask.is_none(), "no mask on the newly selected layer"); + assert!(st.doc.layer(first).unwrap().mask.is_none(), "nor on the first"); + assert!(st.doc.selection.is_some(), "the selection stands"); + assert!(revision_after_select >= revision && st.revision == revision_after_select, "nothing was edited after the switch"); + assert!(app.distort.select_mask.is_none()); +} From aad49d3213f82813c9d268a6d9d2988a2581a8ec Mon Sep 17 00:00:00 2001 From: Federico Olivares Date: Sat, 24 Oct 2026 00:55:36 -0300 Subject: [PATCH 53/86] Acceptance tests: drop the BMP header check, its PR left the batch --- crates/engine/tests/it/integration_batch.rs | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/crates/engine/tests/it/integration_batch.rs b/crates/engine/tests/it/integration_batch.rs index 8d5e358a52..81a6984638 100644 --- a/crates/engine/tests/it/integration_batch.rs +++ b/crates/engine/tests/it/integration_batch.rs @@ -2,7 +2,6 @@ //! public command surface, so a bad merge between the PRs shows up here. use photocraft_engine::Session; use serde_json::json; -use std::time::{Duration, Instant}; fn session(width: u32, height: u32) -> Session { let mut session = Session::new(); @@ -26,21 +25,6 @@ fn define_pattern_accepts_a_canvas_wider_than_the_old_4000_px_cap() { assert!(s.execute("edit.definePattern", json!({"name": "TooWide"})).is_err()); } -#[test] -fn a_header_declaring_more_pixels_than_the_file_holds_is_refused_quickly() { - // BMP: 30000 x 30000 at 24 bpp declared in a 54-byte file. - let mut bmp = vec![0u8; 54]; - bmp[0..2].copy_from_slice(b"BM"); - bmp[10..14].copy_from_slice(&54u32.to_le_bytes()); - bmp[14..18].copy_from_slice(&40u32.to_le_bytes()); - bmp[18..22].copy_from_slice(&30000i32.to_le_bytes()); - bmp[22..26].copy_from_slice(&30000i32.to_le_bytes()); - bmp[26..28].copy_from_slice(&1u16.to_le_bytes()); - bmp[28..30].copy_from_slice(&24u16.to_le_bytes()); - let start = Instant::now(); - assert!(photocraft_codecs::decode(&bmp).is_err()); - assert!(start.elapsed() < Duration::from_secs(5)); -} #[test] From 3450ca20bc9a2003ea39162367c9c540d084633b Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Sat, 24 Oct 2026 11:02:59 +0530 Subject: [PATCH 54/86] select-mask: bind the preview's base cache to the document revision A preview job still running when the document changed stored its base selection back, and the next preview reused it for the new pixels. BaseCache now records its revision and compute reuses it only on a match. Also describe TOOL-214-1 as the full-window workspace. --- crates/ui-egui/src/select_mask_ui.rs | 8 +++--- crates/ui-egui/src/select_mask_ui_tests.rs | 30 ++++++++++++++++++++++ docs/scorecard.md | 2 +- scorecard/tools.toml | 2 +- 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/select_mask_ui.rs b/crates/ui-egui/src/select_mask_ui.rs index 8b406ab85c..be61327fd9 100644 --- a/crates/ui-egui/src/select_mask_ui.rs +++ b/crates/ui-egui/src/select_mask_ui.rs @@ -346,6 +346,8 @@ struct Gesture { #[derive(Clone)] struct BaseCache { k: u32, + /// The document revision the base was computed from. + revision: u64, edits: Vec, base: Option, } @@ -1105,16 +1107,16 @@ fn compute(job: Job, cancel: Option<&photocraft_engine::jobs::JobCtx>) -> Result select_mask_edits::parse(&json!({ "edits": list })).map_err(|e| e.to_string()) }; // With no edits yet, the selection Select and Mask starts from is where the first one starts. - let mut cache = BaseCache { k, edits: Vec::new(), base: start() }; + let mut cache = BaseCache { k, revision: job.revision, edits: Vec::new(), base: start() }; if !job.edits.is_empty() || job.extra.is_some() { // The selection after the edits already applied, when they still lead the list. - let (from, done) = match job.cache.filter(|c| c.k == k && job.edits.starts_with(&c.edits)) { + let (from, done) = match job.cache.filter(|c| c.k == k && c.revision == job.revision && job.edits.starts_with(&c.edits)) { Some(c) => (c.base, c.edits.len()), None => (start(), 0), }; let rest = scaled(job.edits.get(done..).unwrap_or_default())?; let base = select_mask_edits::apply(&doc, layer, from, &rest); - cache = BaseCache { k, edits: job.edits.clone(), base: base.clone() }; + cache = BaseCache { k, revision: job.revision, edits: job.edits.clone(), base: base.clone() }; let base = match &job.extra { Some(e) => select_mask_edits::apply(&doc, layer, base, &scaled(std::slice::from_ref(e))?), None => base, diff --git a/crates/ui-egui/src/select_mask_ui_tests.rs b/crates/ui-egui/src/select_mask_ui_tests.rs index edb3602f8c..f33c69c078 100644 --- a/crates/ui-egui/src/select_mask_ui_tests.rs +++ b/crates/ui-egui/src/select_mask_ui_tests.rs @@ -349,6 +349,36 @@ fn the_preview_reuses_the_edits_it_applied() { assert_eq!(ws(&app).preview.cache.as_ref().map(|c| c.edits.len()), Some(1)); } +/// The selection the edits made on an older document is not reused: a Quick Selection stroke +/// on a repainted image previews what OK would commit. +#[test] +fn the_preview_recomputes_the_edits_after_the_document_changes() { + let warm = |app: &mut PhotocraftApp| { + app.session + .edit("paint", |doc, _| { + doc.layers[0].surface_mut().unwrap().fill_rect(Rect::new(0, 0, 160, 100), &[0.85, 0.55, 0.25, 1.0]); + Ok(()) + }) + .unwrap(); + }; + let stroke = json!({"stroke": {"points": [[120.0, 50.0], [130.0, 50.0]]}}); + let mut app = app_with_selection(); + open(&mut app).unwrap(); + ui(&mut app, stroke.clone()); + let two_tone = selected(&mut app); + // The pixels change under the workspace (a control-channel command) before it redraws. + warm(&mut app); + let after = selected(&mut app); + let mut fresh = app_with_selection(); + warm(&mut fresh); + open(&mut fresh).unwrap(); + ui(&mut fresh, stroke); + let expected = selected(&mut fresh); + assert_ne!(two_tone, expected, "the repaint changes what the stroke selects"); + assert_eq!(after, expected); + assert_eq!(ws(&app).preview.cache.as_ref().map(|c| c.revision), app.session.active().map(|s| s.revision)); +} + /// Sends `key` with `modifiers` through the workspace's key handling. fn press(app: &mut PhotocraftApp, ctx: &egui::Context, key: Key, modifiers: Modifiers) { let ev = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; diff --git a/docs/scorecard.md b/docs/scorecard.md index 8b73d00e2b..2bc5f8bc3e 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -153,7 +153,7 @@ Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpu | TOOL-213-13 | Brush regression tests: no seams, hard edge, zero-scatter defaults, ABR never black | done | [#213](https://github.com/storytold/photocraft/issues/213) | Seam, hard-edge and zero-scatter tests in brush_tests.rs; ABR tip colour test in abr_map/tests.rs | | TOOL-213-14 | Tablet: no OS arrow cursor over the canvas on macOS, Windows and Linux | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Brush tools set CursorIcon::None (canvas.rs:1259-1275); not verified per OS | | TOOL-213-18 | Pattern Stamp tool: sibling of Clone Stamp in the S flyout | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::PatternStamp in the S group; paint.patternStamp stamps the Patterns-panel tile with Aligned and Impressionist (an approximation: it jitters each dab's tile phase, not Photoshop's smeared impressionist look); engine tests cover depths, seams, hostile input | -| TOOL-214-1 | Select and Mask workspace: view modes, Refine Edge brush, 6 outputs | partial | [#214](https://github.com/storytold/photocraft/issues/214) | select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask, New Document, New Document with Mask (#2998); Photoshop's workspace (select_mask_ui.rs, #7069, #8148): options bar, tools Quick Selection W, Refine Edge Brush R, Brush B, Object Selection W, Lasso and Polygonal Lasso L, Hand H, Zoom Z, Properties with Remember Settings, View Mode (Real-Time Refinement, High Quality Preview, Overlay colour and Indicates), Refine Mode, Edge Detection, Global Refinements with Clear Selection and Invert, Output Settings; the tools run as select.refineEdge edits; Ctrl+Z per edit; no Refine Hair (#2671), Object Aware (#2788) or Presets | +| TOOL-214-1 | Select and Mask workspace: view modes, Refine Edge brush, 6 outputs | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Photoshop's full-window Select and Mask workspace (select_mask_ui.rs, #7069, #8148); OK applies select.refineEdge. Live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask, New Document, New Document with Mask (#2998); the workspace has the options bar, tools Quick Selection W, Refine Edge Brush R, Brush B, Object Selection W, Lasso and Polygonal Lasso L, Hand H, Zoom Z, Properties with Remember Settings, View Mode (Real-Time Refinement, High Quality Preview, Overlay colour and Indicates), Refine Mode, Edge Detection, Global Refinements with Clear Selection and Invert, Output Settings; the tools run as select.refineEdge edits; Ctrl+Z per edit; no Refine Hair (#2671), Object Aware (#2788) or Presets | | TOOL-214-2 | Marquee: Shift square, Alt from centre, live W x H readout | done | [#214](https://github.com/storytold/photocraft/issues/214) | canvas.rs marquee_corners: Shift square or circle, Alt from the centre, live W x H readout beside the cursor (marquee_readout); marquee_tests.rs | | TOOL-214-3 | Color Range test: Sampled Colors at Fuzziness 0 selects the exact match only | done | [#214](https://github.com/storytold/photocraft/issues/214) | color_range_fuzziness_zero_selects_exact_match_only in selection_cmds.rs asserts exact match is selected and 1-level-off pixels are rejected at fuzziness 0; tonal presets covered in color_range_presets | | TOOL-214-4 | Move tool: box-select layers, Auto-Select hover outline, double-click fit, drag to a tab | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Auto-Select hover outline (Preferences › Tools › Show Highlight on Rollover, #2834) and box-select layers from an empty spot (move_box.rs:59, layer.selectInRect, #2844) done; dragging layers with the Move tool (or from the Layers panel) onto another document's tab copies them there (layer_transfer.rs:162, layer.copyToDocument, #589/#851); double-click fit and dragging a selection's pixels to another document open | diff --git a/scorecard/tools.toml b/scorecard/tools.toml index 6e2bd77e1c..15be8c414f 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -115,7 +115,7 @@ id = "TOOL-214-1" target = "Select and Mask workspace: view modes, Refine Edge brush, 6 outputs" status = "partial" issue = 214 -note = "select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask, New Document, New Document with Mask (#2998); Photoshop's workspace (select_mask_ui.rs, #7069, #8148): options bar, tools Quick Selection W, Refine Edge Brush R, Brush B, Object Selection W, Lasso and Polygonal Lasso L, Hand H, Zoom Z, Properties with Remember Settings, View Mode (Real-Time Refinement, High Quality Preview, Overlay colour and Indicates), Refine Mode, Edge Detection, Global Refinements with Clear Selection and Invert, Output Settings; the tools run as select.refineEdge edits; Ctrl+Z per edit; no Refine Hair (#2671), Object Aware (#2788) or Presets" +note = "Photoshop's full-window Select and Mask workspace (select_mask_ui.rs, #7069, #8148); OK applies select.refineEdge. Live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask, New Document, New Document with Mask (#2998); the workspace has the options bar, tools Quick Selection W, Refine Edge Brush R, Brush B, Object Selection W, Lasso and Polygonal Lasso L, Hand H, Zoom Z, Properties with Remember Settings, View Mode (Real-Time Refinement, High Quality Preview, Overlay colour and Indicates), Refine Mode, Edge Detection, Global Refinements with Clear Selection and Invert, Output Settings; the tools run as select.refineEdge edits; Ctrl+Z per edit; no Refine Hair (#2671), Object Aware (#2788) or Presets" [[item]] id = "TOOL-214-2" From e386cc2ae9ad692439016aa386ec68a7b4919e1d Mon Sep 17 00:00:00 2001 From: Federico Olivares Date: Sat, 24 Oct 2026 02:33:02 -0300 Subject: [PATCH 55/86] Format the acceptance tests --- crates/engine/tests/it/integration_batch.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/engine/tests/it/integration_batch.rs b/crates/engine/tests/it/integration_batch.rs index 81a6984638..14977beece 100644 --- a/crates/engine/tests/it/integration_batch.rs +++ b/crates/engine/tests/it/integration_batch.rs @@ -25,8 +25,6 @@ fn define_pattern_accepts_a_canvas_wider_than_the_old_4000_px_cap() { assert!(s.execute("edit.definePattern", json!({"name": "TooWide"})).is_err()); } - - #[test] fn gaussian_blur_accepts_radii_below_one_without_failing() { for radius in [0.1, 0.3, 0.7, 1.0] { From af5bf81f90768cf833e178a62f029b7d899880dc Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Sat, 24 Oct 2026 11:53:15 +0530 Subject: [PATCH 56/86] select-mask: refuse requests whole, drop old-revision previews, no brush on an empty selection - control: tool, mode and stroke are parsed before anything changes, so a refused request leaves the workspace as it was. - A preview made from an older document revision is dropped (even mid-stroke), and a revision change clears the shown texture and the pending result too. - Show Edge applies the Refine Edge Brush only when the selection has content, as OK (matting::refine_area) does. - control-protocol.md documents `hideView`. --- crates/ui-egui/src/select_mask_ui.rs | 57 ++++++++++++++++------ crates/ui-egui/src/select_mask_ui_tests.rs | 51 +++++++++++++++++++ crates/ui-egui/src/select_mask_view.rs | 5 +- docs/control-protocol.md | 2 +- 4 files changed, 97 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/select_mask_ui.rs b/crates/ui-egui/src/select_mask_ui.rs index be61327fd9..8c0295c971 100644 --- a/crates/ui-egui/src/select_mask_ui.rs +++ b/crates/ui-egui/src/select_mask_ui.rs @@ -894,18 +894,36 @@ pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result { { let w = app.distort.select_mask.as_mut().ok_or("Select and Mask is not open")?; let before = w.opts.clone(); + // Everything is read before anything changes, so rejected params change nothing. let mut o = w.opts.clone(); o.apply(ui)?; - if let Some(t) = ui.get("tool").and_then(Value::as_str) { - let t = SmTool::from_name(t).ok_or(format!("bad tool `{t}` (quick|refineEdge|brush|object|lasso|polygonalLasso|hand|zoom)"))?; + let tool = match ui.get("tool").and_then(Value::as_str) { + Some(t) => Some(SmTool::from_name(t).ok_or(format!("bad tool `{t}` (quick|refineEdge|brush|object|lasso|polygonalLasso|hand|zoom)"))?), + None => None, + }; + let add = match ui.get("mode").and_then(Value::as_str) { + None => None, + Some("add") => Some(true), + Some("subtract") => Some(false), + Some(m) => return Err(format!("bad mode `{m}` (add|subtract)")), + }; + let stroke = match ui.get("stroke") { + Some(s) => { + let pts: Vec<[f64; 2]> = + serde_json::from_value(s.get("points").cloned().unwrap_or(Value::Null)).map_err(|e| format!("bad stroke points: {e}"))?; + if pts.iter().any(|p| !p[0].is_finite() || !p[1].is_finite()) { + return Err("bad stroke points: not finite".into()); + } + Some((pts, s.get("alt").and_then(Value::as_bool).unwrap_or(false))) + } + None => None, + }; + if let Some(t) = tool { w.pick(t); } w.opts = o; - match ui.get("mode").and_then(Value::as_str) { - None => {} - Some("add") => w.add = true, - Some("subtract") => w.add = false, - Some(m) => return Err(format!("bad mode `{m}` (add|subtract)")), + if let Some(add) = add { + w.add = add; } if let Some(n) = ui.get("size").and_then(Value::as_f64) { w.set_size(n); @@ -922,12 +940,7 @@ pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result { if w.opts != before { w.stale(); } - if let Some(s) = ui.get("stroke") { - let pts: Vec<[f64; 2]> = serde_json::from_value(s.get("points").cloned().unwrap_or(Value::Null)).map_err(|e| format!("bad stroke points: {e}"))?; - if pts.iter().any(|p| !p[0].is_finite() || !p[1].is_finite()) { - return Err("bad stroke points: not finite".into()); - } - let alt = s.get("alt").and_then(Value::as_bool).unwrap_or(false); + if let Some((pts, alt)) = stroke { if w.tool == SmTool::PolygonalLasso { w.poly = Some((w.add != alt, pts)); w.close_polygon(); @@ -1149,6 +1162,10 @@ fn compute(job: Job, cancel: Option<&photocraft_engine::jobs::JobCtx>) -> Result /// still the state wanted (or a stroke is being drawn: Real-Time Refinement shows the latest one /// it has), and the selection after the edits and the reduced copy for the next preview. fn take_result(w: &mut SelectMaskWorkspace, generation: u64, revision: u64, r: Result<(Shown, BaseCache, Arc), String>) { + // Made from pixels the document no longer has (even mid-stroke). + if revision != w.revision { + return; + } let pv = &mut w.preview; match r { Ok((shown, cache, proxy)) => { @@ -1176,6 +1193,7 @@ fn take_result(w: &mut SelectMaskWorkspace, generation: u64, revision: u64, r: R /// Computes the preview now, on this thread (tests and the control channel). fn wait_preview(app: &mut PhotocraftApp) -> Result<(), String> { drop_stale(app)?; + follow_revision(app); let j = job(app).ok_or("Select and Mask is not open")?; let revision = j.revision; let r = compute(j, None); @@ -1529,18 +1547,25 @@ fn properties(ui: &mut egui::Ui, w: &mut SelectMaskWorkspace, t: &Tokens) -> Opt edit } -/// Draws the workspace (a full-window layer over the app). -pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { - // The document changed under the workspace: new textures, a new preview. +/// The document changed under the workspace: new textures, a new preview, and nothing of the old +/// one shown or reused. +fn follow_revision(app: &mut PhotocraftApp) { let rev = app.session.active().map(|s| s.revision); if let Some(w) = app.distort.select_mask.as_mut() && Some(w.revision) != rev { w.revision = rev.unwrap_or(w.revision); w.image_tex = None; + w.preview.tex = None; + w.preview.pending = None; w.preview.cache = None; w.stale(); } +} + +/// Draws the workspace (a full-window layer over the app). +pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { + follow_revision(app); upload(app, ctx); let title = app.distort.select_mask.as_ref().map(|w| { crate::canvas::tab_title_at( diff --git a/crates/ui-egui/src/select_mask_ui_tests.rs b/crates/ui-egui/src/select_mask_ui_tests.rs index f33c69c078..2a4c484530 100644 --- a/crates/ui-egui/src/select_mask_ui_tests.rs +++ b/crates/ui-egui/src/select_mask_ui_tests.rs @@ -379,6 +379,57 @@ fn the_preview_recomputes_the_edits_after_the_document_changes() { assert_eq!(ws(&app).preview.cache.as_ref().map(|c| c.revision), app.session.active().map(|s| s.revision)); } +/// A request with a bad field is refused whole: the valid fields before it change nothing either. +#[test] +fn a_refused_request_changes_nothing() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + let before = ws(&app).describe(); + for bad in [ + json!({"tool": "brush", "mode": "invalid"}), + json!({"tool": "brush", "mode": "subtract", "size": 80, "radius": 40, "stroke": {"points": "x"}}), + json!({"hardness": 10, "sampleAllLayers": true, "hideView": true, "stroke": {"points": [[1.0]]}}), + ] { + assert!(control(&mut app, &bad).is_err(), "{bad}"); + assert_eq!(ws(&app).describe(), before, "{bad}"); + assert!(!ws(&app).hide_view && !ws(&app).sample_all, "{bad}"); + } +} + +/// With nothing selected OK refines nothing, so Show Edge shows no Refine Edge Brush stroke. +#[test] +fn show_edge_shows_no_stroke_without_a_selection() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + ui(&mut app, json!({"clearSelection": true, "showEdge": true})); + let empty = selected(&mut app); + ui(&mut app, json!({"tool": "refineEdge", "stroke": {"points": [[40.0, 20.0], [40.0, 80.0]]}})); + assert_eq!(selected(&mut app), empty); + ui(&mut app, json!({"commit": true})); + assert_eq!(cov(&app, 40, 50), 0.0, "and OK selects nothing there"); +} + +/// A preview of pixels the document no longer has is never shown, even mid-stroke, and the old one +/// goes when the document changes. +#[test] +fn a_preview_of_an_old_revision_is_dropped() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + selected(&mut app); + assert!(ws(&app).preview.pending.is_some()); + let j = job(&app).unwrap(); + let old = j.revision; + let late = compute(j, None); + app.session.execute("select.all", json!({})).unwrap(); + follow_revision(&mut app); + assert!(ws(&app).preview.pending.is_none() && ws(&app).preview.tex.is_none(), "the old preview is gone"); + let w = ws_mut(&mut app); + w.pressed = true; + let generation = w.preview.want; + take_result(w, generation, old, late); + assert!(w.preview.pending.is_none() && w.preview.cache.is_none() && w.preview.shown != generation); +} + /// Sends `key` with `modifiers` through the workspace's key handling. fn press(app: &mut PhotocraftApp, ctx: &egui::Context, key: Key, modifiers: Modifiers) { let ev = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; diff --git a/crates/ui-egui/src/select_mask_view.rs b/crates/ui-egui/src/select_mask_view.rs index 7ef5de6ddd..c657ac3fd3 100644 --- a/crates/ui-egui/src/select_mask_view.rs +++ b/crates/ui-egui/src/select_mask_view.rs @@ -483,7 +483,10 @@ pub fn preview_proxy( let canvas = Rect::new(0, 0, i32::try_from(proxy.size.width).unwrap_or(i32::MAX), i32::try_from(proxy.size.height).unwrap_or(i32::MAX)); let coverage = if view.show_edge { let band = Coverage::edge_band(original.as_ref(), canvas, length(params, "radius", k).round().clamp(1.0, 250.0) as usize); - band.with_brush(scaled_strokes(params, k).as_ref().and_then(|s| photocraft_algo::matting::EdgeBrush::new(s, canvas)).as_ref(), canvas) + // With nothing selected OK refines nothing (`matting::refine_area`), so the brush shows nothing either. + let selects = original.as_ref().is_some_and(|s| !s.content_bounds().intersect(&canvas).is_empty()); + let brush = if selects { scaled_strokes(params, k).and_then(|s| photocraft_algo::matting::EdgeBrush::new(&s, canvas)) } else { None }; + band.with_brush(brush.as_ref(), canvas) } else if view.show_original { Coverage::read(original.as_ref(), canvas) } else { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index a5132ce3e9..07f6c7c033 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -71,7 +71,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - Layer Style (`layerStyle {effect?}` opens on that effect kind): the fields hold the dialog's whole state, so agents read and drive it like a user. `effects` lists every effect instance in the layer's order as `{id, kind, on, params, fx}` (`fx` is the effect snapshot the dialog loaded; OK edits that snapshot with `params`, so anything the dialog doesn't model survives). `selected` picks the page (`blendingOptions` or an instance id), `preview` (default on) gates the live canvas preview, `p:blendingOptions` edits blend mode/opacity/fill opacity, and `patternList` names the usable patterns. OK replaces the layer's effects in one step (`layer.layerStyle.replace`); Cancel discards. "New Style…" saves the pending state via `style.presets.new {effects}`, a swatch click applies a style preset, and Make/Reset Default use `layer.layerStyle.makeDefault` / `layer.layerStyle.defaultFor` - Gradient Overlay exposes `params.method` (`smooth`, `perceptual`, `linear`, `classic`). `custom` denotes an existing curve without a recorded method and preserves it. Choosing a method retains all recorded source colour stops. If no source record exists (including baked PSD imports), choosing a method rebuilds from the two end colours, matching legacy command behaviour; the old curve stays unchanged until that choice. Later endpoint edits rebuild the colour curve using the recorded method. The source stops, smoothness and method survive dialog reopening, undo/redo and native `.pcraft` saves. Saving a style preset through "New Style…" may lose the multi-stop source curve: it passes only params to `style.presets.new`, and `effect_from_params` rebuilds the effect from the default gradient. PSD export continues to write the baked appearance; reopening that PSD shows Custom because editable interpolation metadata is not exported yet. Opacity interpolation and the multi-stop editor are separate work. - Preferences (`ui.menu.invoke {id: "edit.preferences.interface"}`) exposes the sections in its `values` field. Apply (`ui.dialog.apply`) and OK (`ui.dialog.confirm`) on Preferences are refused over control because automation denies whole-preferences updates and filesystem-bearing preference sections. A refused `ui.dialog.apply` returns an error and keeps the dialog open; a refused `ui.dialog.confirm` returns an error but closes and removes the dialog. `ui.dialog.cancel` closes the dialog without saving. Individual safe preferences can instead be changed with `engine.execute` using `prefs.set` (e.g. `{"command": "prefs.set", "params": {"path": "interface.showTooltips", "value": false}}`). Settings marked for the next launch still require a restart. - - Select › Select and Mask… (`ui.menu.invoke {id: "select.selectAndMask"}`, Ctrl+Alt+R) opens Photoshop's workspace; `ui.inspect` reports it as `distort.selectAndMask`. Drive it with `ui.menu.invoke {id: "select.selectAndMask", params: {ui: {…}}}`: `tool` (`quick|refineEdge|brush|object|lasso|polygonalLasso|hand|zoom`), `mode` (`add|subtract`), `size`, `hardness`, `sampleAllLayers`, `stroke: {points: [[x,y],…], alt?}` (a drag with the current tool in document px: Object Selection takes a click or the rectangle's corners, the lassos the polygon), `selectSubject`, `clearSelection`, `invert`, the settings (`view`, `opacity`, `showEdge`, `showOriginal`, `realTimeRefinement`, `highQualityPreview`, `overlayColor`, `indicates`, `radius`, `smartRadius`, `smooth`, `feather`, `contrast`, `shiftEdge`, `decontaminate`, `amount`, `output`, `remember`), `undo`, `redo`, `reset`, `waitPreview`, `commit` (OK: `select.refineEdge` with the workspace's `edits` and `refineStrokes`, one history step) and `cancel`. The reply describes the workspace (`edits`, `strokes`, `selectedProxyPixels`, `previewReady`, …). + - Select › Select and Mask… (`ui.menu.invoke {id: "select.selectAndMask"}`, Ctrl+Alt+R) opens Photoshop's workspace; `ui.inspect` reports it as `distort.selectAndMask`. Drive it with `ui.menu.invoke {id: "select.selectAndMask", params: {ui: {…}}}`: `tool` (`quick|refineEdge|brush|object|lasso|polygonalLasso|hand|zoom`), `mode` (`add|subtract`), `size`, `hardness`, `sampleAllLayers`, `hideView` (true shows the image without the view, as holding X does), `stroke: {points: [[x,y],…], alt?}` (a drag with the current tool in document px: Object Selection takes a click or the rectangle's corners, the lassos the polygon), `selectSubject`, `clearSelection`, `invert`, the settings (`view`, `opacity`, `showEdge`, `showOriginal`, `realTimeRefinement`, `highQualityPreview`, `overlayColor`, `indicates`, `radius`, `smartRadius`, `smooth`, `feather`, `contrast`, `shiftEdge`, `decontaminate`, `amount`, `output`, `remember`), `undo`, `redo`, `reset`, `waitPreview`, `commit` (OK: `select.refineEdge` with the workspace's `edits` and `refineStrokes`, one history step) and `cancel`. The reply describes the workspace (`edits`, `strokes`, `selectedProxyPixels`, `previewReady`, …). - Edit › Fill… (`ui.menu.invoke {id: "edit.fill"}`, Shift+F5, Shift+Backspace) opens the Fill dialog; its fields are `edit.fill`'s params (`contents`, `color`, `pattern`, `colorAdaptation`, `mode`, `opacity`, `preserveTransparency`), and OK remembers them in the preferences (`dialogs["edit.fill"]`). - Save for Web (`ui.menu.invoke {id: "file.export.saveForWebLegacy"}`) is resizable with independently scrolling settings. Dialog fields `__view` (`original|optimized|2up|4up`), `__zoom` (`null` = Fit, otherwise a factor; 1 = 100% in physical display pixels), and `__center: [u,v]` control the shared preview navigation. `__previewRects` reports pane rectangles in screen points. Drag a preview to pan, scroll/pinch to zoom at the pointer, or double-click to fit. GIF/PNG-8 expose `colorTable: ["#rrggbb", …]` (opaque source entries) and `colorTableEdits: {"0":"#rrggbb", …}` (zero-based replacements after quantization). A swatch opens a color picker; edits update the preview and saved file without remapping pixel indices or changing the document. Reset clears replacements. Changing palette/size/matte/transparency settings regenerates the table and clears index-based edits. Previews and generated tables use a bounded 2048-pixel proxy; full-size export uses that explicit table unchanged. `__paletteApproximate` reports proxy-derived palette generation, and enlarged proxy previews are labelled. The engine export command accepts the same table/edit parameters; its no-path estimate returns the source `colorTable` and `transparentIndex` as well as size statistics. Opening a new dialog does not carry a previous image's table across. Image Size has `width` and `height` (px), `percent` and `resample` (`nearest|bilinear|bicubic|bicubicSmoother|bicubicSharper`, default `bicubic`); `__constrain` (default true at each opening) links the two sides in proportion. A side edit sets both sides and removes `percent`, which the export command otherwise applies first; a Percent edit sets both sides from it. Each opening starts at the document's full size, while `resample` is remembered with the encoding choices. The document is never resized. - A pixel tool pressed on a type, shape, Smart Object or fill layer (e.g. through `ui.pointer`) opens the "Rasterize?" prompt instead of painting: a dialog with `__rasterize` (`type|shape|smartObject|fill`), `message`, `layer`, `tool` and `at`. `ui.dialog.confirm` runs the `layer.rasterize.*` command and then paints at `at` (two history states, `{"rasterized", "painted"}`); `ui.dialog.cancel` does nothing. From 254e501de952b1c4da2ec9b8d84b2f0768cc65ad Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Sat, 24 Oct 2026 14:47:13 +0530 Subject: [PATCH 57/86] fix(engine): a recorded several-layer Free Transform replays on the selection The canvas box commits `edit.transform` with the `layers` it opened on, and the Action recorder kept those ids verbatim, so replaying in another document failed with NoLayer or moved unrelated layers. When the listed layers are the ones the selection gives the command, the recorded step drops `layers` and replays on the layers selected then; other explicit lists keep their ids. Claude-Session: https://claude.ai/code/session_01D8r6W6haR5FWJyxUakMEfA --- crates/engine/src/layer_multi_cmds.rs | 24 +++++++++++++ crates/engine/src/transform_cmds.rs | 13 ++++--- crates/engine/src/transform_multi_tests.rs | 40 ++++++++++++++++++++++ 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 47114f7e5d..48183e0b10 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -211,6 +211,9 @@ pub(crate) fn recorded_step(s: &Session, id: &str, p: Value, pre_selected: Optio if id == "layer.moveTo" { return recorded_move(s, p, pre_selected); } + if id == "edit.transform" { + return recorded_transform(s, p, pre_selected); + } let Some(layer_id) = p.get("layer").and_then(Value::as_u64).map(LayerId) else { return p }; let is_sole_selected = match pre_selected { Some(sel) => sel == [layer_id], @@ -226,6 +229,27 @@ pub(crate) fn recorded_step(s: &Session, id: &str, p: Value, pre_selected: Optio p } +/// A Free Transform of several layers lists their ids (the box's session keeps them while it is +/// open). When they are the layers the selection gave it, the step drops `layers`, so it replays on +/// the layers selected then, in any document, rather than on these ids (#6501). +fn recorded_transform(s: &Session, mut p: Value, pre_selected: Option<&[LayerId]>) -> Value { + let Some(st) = s.active() else { return p }; + let Some(listed) = p.get("layers").and_then(Value::as_array) else { return p }; + let Some(listed) = listed.iter().map(|v| v.as_u64().map(LayerId)).collect::>>() else { return p }; + let selected = pre_selected.map_or_else(|| st.selected_layers(), <[LayerId]>::to_vec); + let mut moved = top_level(&st.doc, &listed); + let mut targets = crate::transform_cmds::targets_of(&st.doc, &selected, st.active_layer); + moved.sort_unstable(); + moved.dedup(); + targets.sort_unstable(); + if moved == targets + && let Some(fields) = p.as_object_mut() + { + fields.remove("layers"); + } + p +} + fn selection_target(s: &Session, p: &Value) -> Result { let Some(name) = p.get("name") else { if p.get("occurrence").is_some() { diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index 48e8abe219..29dc01f83d 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -268,12 +268,17 @@ pub fn locked_for_transform(doc: &Document, id: LayerId) -> bool { /// the Background, which Show Transform Controls doesn't frame either. With one layer selected (or /// only the Background besides it), that layer; the active layer when nothing else is. pub fn selection_targets(st: &crate::DocState) -> Vec { - let selected = st.selected_layers(); + targets_of(&st.doc, &st.selected_layers(), st.active_layer) +} + +/// [`selection_targets`] for the layers `selected`, `active` the active one: what an `edit.transform` +/// without `layer` or `layers` moves, so a recorded step can drop a `layers` list that matches it. +pub(crate) fn targets_of(doc: &Document, selected: &[LayerId], active: Option) -> Vec { if selected.len() < 2 { - return st.active_layer.into_iter().collect(); + return active.into_iter().collect(); } - let ids: Vec = crate::layer_multi_cmds::top_level(&st.doc, &selected).into_iter().filter(|id| !st.doc.is_background(*id)).collect(); - if ids.is_empty() { st.active_layer.into_iter().collect() } else { ids } + let ids: Vec = crate::layer_multi_cmds::top_level(doc, selected).into_iter().filter(|id| !doc.is_background(*id)).collect(); + if ids.is_empty() { active.into_iter().collect() } else { ids } } /// The `layers` param: the layers one box transforms together (at most 10000, each in the diff --git a/crates/engine/src/transform_multi_tests.rs b/crates/engine/src/transform_multi_tests.rs index ad34af7937..e26572d6e7 100644 --- a/crates/engine/src/transform_multi_tests.rs +++ b/crates/engine/src/transform_multi_tests.rs @@ -241,3 +241,43 @@ fn straighten_layer_rotates_every_selected_layer() { assert!(bounds(&s, paint) != p0); assert_eq!(text_matrix(&s, text), m0, "an unselected layer stays put"); } + +#[test] +fn a_recorded_transform_of_the_selected_layers_replays_on_the_selection_in_another_document() { + let (mut s, paint, text) = two_layers(); + s.execute("actions.record", json!({"name": "Move both"})).unwrap(); + // What the canvas box commits: the ids it opened on, listed (#6501). + s.execute("edit.transform", json!({"layers": [paint.0, text.0], "matrix": [1, 0, 0, 1, 30, 40]})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + let step = s.actions.list[0].steps[0].1.clone(); + assert!(step.get("layers").is_none(), "the step plays on the selection, not on these ids: {step}"); + // Another document, its ids shifted by an extra layer under the two that get selected. + s.execute("file.new", json!({"width": 200, "height": 200, "background": "white"})).unwrap(); + let spare = LayerId(s.execute("layer.new.layer", json!({"name": "Spare"})).unwrap()["layer"].as_u64().unwrap()); + let a = LayerId(s.execute("layer.new.layer", json!({"name": "A"})).unwrap()["layer"].as_u64().unwrap()); + let b = LayerId(s.execute("layer.new.layer", json!({"name": "B"})).unwrap()["layer"].as_u64().unwrap()); + assert_ne!((a, b), (paint, text), "the ids differ from the recorded ones"); + s.edit("paint", |doc, _| { + for (id, x) in [(spare, 100), (a, 10), (b, 60)] { + doc.layer_mut(id).unwrap().surface_mut().unwrap().fill_rect(Rect::new(x, 10, x + 20, 30), &[0.0, 0.0, 1.0, 1.0]); + } + Ok(()) + }) + .unwrap(); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + s.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap(); + let (a0, b0, spare0) = (bounds(&s, a), bounds(&s, b), bounds(&s, spare)); + let r = s.execute("actions.play", json!({"action": 0})).unwrap(); + assert!(r.get("failed").is_none(), "{r}"); + assert_eq!((bounds(&s, a), bounds(&s, b)), (a0.translate(30, 40), b0.translate(30, 40)), "the selected layers moved"); + assert_eq!(bounds(&s, spare), spare0, "the unselected layer stayed"); +} + +#[test] +fn a_recorded_transform_of_other_layers_than_the_selection_keeps_their_ids() { + let (mut s, paint, _) = two_layers(); + s.execute("actions.record", json!({"name": "Paint only"})).unwrap(); + s.execute("edit.transform", json!({"layers": [paint.0], "matrix": [1, 0, 0, 1, 5, 0]})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[0].steps[0].1["layers"], json!([paint.0])); +} From a1d656a0f0061cbe3fd6e094aadd54da933054b4 Mon Sep 17 00:00:00 2001 From: berce <75228263+bercedev@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:24:18 +0300 Subject: [PATCH 58/86] fix(text): write raw EngineData strings back as the same bytes EngineData strings without the FE FF mark were parsed into a String and written back as FE FF + UTF-16BE, so an unedited save rewrote raw strings in Txt2 (like /2 (0100...)), which makes Photoshop reject the file. Parse them into Value::RawString and write them back unchanged. Refs #9091 --- crates/text/src/engine_data.rs | 38 ++++++++++++++++++++++++++++------ crates/text/src/psd.rs | 4 ++-- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/crates/text/src/engine_data.rs b/crates/text/src/engine_data.rs index 19f3af1886..4acd08ac50 100644 --- a/crates/text/src/engine_data.rs +++ b/crates/text/src/engine_data.rs @@ -11,6 +11,10 @@ pub enum Value { Dict(Vec<(String, Value)>), Array(Vec), String(String), + /// A string the file stored as plain bytes, without the `FE FF` mark (one char per byte). + /// Kept apart from [`Value::String`] so it is written back as the same bytes: Photoshop + /// rejects a `Txt2` whose raw strings come back as UTF-16 (#9091). + RawString(String), Int(i64), Real(f64), Bool(bool), @@ -67,7 +71,7 @@ impl Value { } pub fn as_str(&self) -> Option<&str> { match self { - Value::String(s) | Value::Name(s) => Some(s), + Value::String(s) | Value::RawString(s) | Value::Name(s) => Some(s), _ => None, } } @@ -173,7 +177,7 @@ impl Parser<'_> { } } } - Ok(Value::String(decode_string(&raw))) + Ok(decode_string(&raw)) } Some(b'/') => Ok(Value::Name(self.name())), Some(_) => { @@ -205,12 +209,12 @@ impl Parser<'_> { } } -fn decode_string(raw: &[u8]) -> String { +fn decode_string(raw: &[u8]) -> Value { if raw.len() >= 2 && raw[0] == 0xFE && raw[1] == 0xFF { let u: Vec = raw[2..].as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); - String::from_utf16_lossy(&u) + Value::String(String::from_utf16_lossy(&u)) } else { - raw.iter().map(|&c| c as char).collect() + Value::RawString(raw.iter().map(|&c| c as char).collect()) } } @@ -293,7 +297,18 @@ fn write_value(v: &Value, depth: usize, out: &mut Vec) { } out.extend_from_slice(b" ]"); } - Value::String(s) => { + Value::RawString(s) if s.chars().all(|c| u32::from(c) <= 0xFF) => { + out.push(b'('); + for c in s.chars() { + let c = u32::from(c) as u8; + if matches!(c, b'(' | b')' | b'\\') { + out.push(b'\\'); + } + out.push(c); + } + out.push(b')'); + } + Value::String(s) | Value::RawString(s) => { out.push(b'('); let mut raw = vec![0xFE, 0xFF]; for u in s.encode_utf16() { @@ -382,6 +397,17 @@ mod tests { assert_eq!(v.path(&["EngineDict", "Runs"]).unwrap().as_array().unwrap()[0].get("A").and_then(Value::as_i64), Some(1)); } + #[test] + fn raw_strings_are_written_back_as_the_same_bytes() { + let v = parse(b"<< /2 (0100\\)) /3 (\xFE\xFF\x00h) >>").unwrap(); + assert_eq!(v.get("2"), Some(&Value::RawString("0100)".into()))); + assert_eq!(v.get("2").and_then(Value::as_str), Some("0100)")); + assert_eq!(v.get("3"), Some(&Value::String("h".into()))); + let bytes = write(&v); + assert!(bytes.windows(8).any(|w| w == b"(0100\\))"), "{:?}", String::from_utf8_lossy(&bytes)); + assert_eq!(parse(&bytes).unwrap(), v); + } + #[test] fn write_parse_roundtrip() { let v = parse(SAMPLE).unwrap(); diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 13f3244801..496449f8b3 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -1313,7 +1313,7 @@ fn fits_budget(v: &E, nodes: &mut usize, bytes: &mut usize) -> bool { fits_budget(x, nodes, bytes) }), E::Array(items) => items.iter().all(|x| fits_budget(x, nodes, bytes)), - E::String(s) | E::Name(s) => { + E::String(s) | E::RawString(s) | E::Name(s) => { let Some(rem) = bytes.checked_sub(s.len()) else { return false }; *bytes = rem; true @@ -1430,7 +1430,7 @@ fn td_edited_object(object: &E, text: &str) -> Option { let E::Dict(entries) = model else { return None }; // The text must be there to replace: refitted runs over a text the model doesn't hold would // be a half-edited object. - if !entries.iter().any(|(k, v)| k == "Text" && matches!(v, E::String(_))) { + if !entries.iter().any(|(k, v)| k == "Text" && matches!(v, E::String(_) | E::RawString(_))) { return None; } let total = utf16_len(text) as usize; From cef6f786eab6ce3e0412b6df187fa22088ff04f1 Mon Sep 17 00:00:00 2001 From: Federico Olivares Date: Sat, 24 Oct 2026 15:40:04 -0300 Subject: [PATCH 59/86] Address review of the integration batch Reject invalid UTF-8 in a picked Variable Data Sets file, keep a bare + command in a Windows shortcut set, make the Solid Color preview, thumbnail and OK agree on CMYK inks, and composite Define Pattern a band of rows at a time. --- crates/engine/src/kys.rs | 15 +++++ crates/engine/src/pattern_cmds.rs | 72 +++++++++++++++++++---- crates/ui-egui/src/brush_resize.rs | 2 +- crates/ui-egui/src/menus.rs | 15 ++++- crates/ui-egui/src/solid_fill_ui.rs | 41 +++++++++---- crates/ui-egui/src/solid_fill_ui/tests.rs | 48 +++++++++++++++ 6 files changed, 168 insertions(+), 25 deletions(-) diff --git a/crates/engine/src/kys.rs b/crates/engine/src/kys.rs index a392df24c9..8cbed9c425 100644 --- a/crates/engine/src/kys.rs +++ b/crates/engine/src/kys.rs @@ -131,6 +131,7 @@ pub fn to_ours(set: &KysSet, sc: &str) -> Option { } let (mods, key) = match sc.strip_suffix("++") { Some(m) => (m, "+"), + None if sc == "+" => ("", "+"), None => sc.rsplit_once('+').unwrap_or(("", sc)), }; let mods: Vec<&str> = mods @@ -351,6 +352,20 @@ mod tests { assert!(write("Mine", &[e]).contains(">+")); } + #[test] + fn a_bare_plus_command_survives_a_windows_set_with_a_ctrl_row() { + let xml = "\ + Ctrl+C\ + +\ + "; + let set = parse(xml).unwrap(); + assert!(set.windows); + assert_eq!(to_ours(&set, "+").as_deref(), Some("+")); + let p = plan([c("edit.copy", "Copy", Some("Cmd+C")), c("view.zoomIn", "Zoom In", None)], &set); + assert_eq!(p.set.get("view.zoomIn").map(String::as_str), Some("+")); + assert!(p.unreadable.is_empty()); + } + #[test] fn rows_match_by_label_and_the_default_holder_wins_a_shared_label() { let set = parse(SET).unwrap(); diff --git a/crates/engine/src/pattern_cmds.rs b/crates/engine/src/pattern_cmds.rs index 31f26e4e6c..e250f3fd55 100644 --- a/crates/engine/src/pattern_cmds.rs +++ b/crates/engine/src/pattern_cmds.rs @@ -168,19 +168,41 @@ fn list(s: &mut Session, _: &Value) -> Result { Ok(Value::Array(out)) } -/// The visible composite over `r` as a surface in the document's pixel format (with alpha). -fn composite_surface(doc: &Document, r: Rect) -> Surface { - let buf = photocraft_compose::render(doc, r); +/// Pixels composited at a time by [`composite_surface`]: a band's render and conversion buffers +/// stay a few tens of MB however large the pattern. +const BAND_PIXELS: u32 = 1 << 20; + +/// The visible composite over `r` as a surface in the document's pixel format (with alpha), and +/// whether every pixel of it is opaque. +fn composite_surface(doc: &Document, r: Rect) -> (Surface, bool) { + composite_surface_in_bands(doc, r, (BAND_PIXELS / r.width().max(1)).max(1)) +} + +/// [`composite_surface`], `band_rows` rows at a time. Rendering and converting one band at a +/// time, and noting opacity as it goes, keeps an 8000 × 8000 pattern (64 MP, about 1 GB per full +/// `f32` copy) from holding several full-size copies at once. +fn composite_surface_in_bands(doc: &Document, r: Rect, band_rows: u32) -> (Surface, bool) { let df = doc.pixel_format(); let fmt = PixelFormat::new(df.mode, df.sample, true); let n = fmt.channels(); - let mut px = vec![0.0f32; buf.px.len() * n]; - for (o, p) in px.chunks_exact_mut(n).zip(&buf.px) { - photocraft_raster::from_rgba_into(&fmt, *p, o); - } + let (width, height) = (r.width(), r.height()); let mut s = Surface::new(fmt); - s.write_region(Rect::new(0, 0, r.width() as i32, r.height() as i32), &px); - s + let mut opaque = true; + let mut px = Vec::new(); + let mut top = 0; + while top < height { + let rows = band_rows.min(height - top); + let buf = photocraft_compose::render(doc, Rect::new(r.x0, r.y0 + top as i32, r.x1, r.y0 + (top + rows) as i32)); + px.clear(); + px.resize(buf.px.len() * n, 0.0f32); + for (o, p) in px.chunks_exact_mut(n).zip(&buf.px) { + photocraft_raster::from_rgba_into(&fmt, *p, o); + opaque &= o[n - 1] >= 1.0; + } + s.write_region(Rect::new(0, top as i32, width as i32, (top + rows) as i32), &px); + top += rows; + } + (s, opaque) } /// The name Define Pattern gives a pattern without one: the active document's name without its @@ -218,11 +240,10 @@ fn define(s: &mut Session, p: &Value) -> Result { if r.width() > MAX_DEFINE_EDGE || r.height() > MAX_DEFINE_EDGE { return Err(bad(cmd, "patterns are limited to 8000 × 8000 px")); } - let mut surf = composite_surface(doc, r); + let (mut surf, opaque) = composite_surface(doc, r); // Opaque patterns drop their alpha channel (as Photoshop does). - let fmt = surf.format(); - let px = surf.read_region(Rect::new(0, 0, r.width() as i32, r.height() as i32)); - if px.chunks_exact(fmt.channels()).all(|q| q[fmt.channels() - 1] >= 1.0) { + if opaque { + let fmt = surf.format(); surf = surf.convert(PixelFormat::new(fmt.mode, fmt.sample, false)); } let name = match p.get("name").and_then(Value::as_str) { @@ -535,6 +556,31 @@ mod tests { } } + /// Review of the 8000 px cap: compositing a band of rows at a time gives the pixels and the + /// opacity of one pass, at every depth, whatever the band height. + #[test] + fn banded_compositing_matches_a_single_pass() { + for depth in [8u64, 16, 32] { + for transparent in [false, true] { + let mut s = Session::new(); + let background = if transparent { "transparent" } else { "white" }; + s.execute("file.new", json!({"width": 40, "height": 30, "depth": depth, "background": background})).unwrap(); + s.execute("select.rect", json!({"x": 3, "y": 4, "width": 20, "height": 13})).unwrap(); + s.execute("edit.fill", json!({"color": "#336699"})).unwrap(); + let doc = &s.active().unwrap().doc; + let area = Rect::new(2, 1, 38, 29); + let all = Rect::new(0, 0, 36, 28); + let (whole, whole_opaque) = composite_surface_in_bands(doc, area, 28); + for band_rows in [1, 5, 27] { + let (banded, opaque) = composite_surface_in_bands(doc, area, band_rows); + assert_eq!(opaque, whole_opaque, "depth {depth}, transparent {transparent}, bands of {band_rows}"); + assert_eq!(banded.read_region(all), whole.read_region(all), "depth {depth}, transparent {transparent}, bands of {band_rows}"); + } + assert_eq!(whole_opaque, !transparent); + } + } + } + #[test] fn define_pattern_accepts_edges_over_4000_up_to_8000() { for (w, ok) in [(4100, true), (8000, true), (8001, false)] { diff --git a/crates/ui-egui/src/brush_resize.rs b/crates/ui-egui/src/brush_resize.rs index ec509f189f..14e90d3da9 100644 --- a/crates/ui-egui/src/brush_resize.rs +++ b/crates/ui-egui/src/brush_resize.rs @@ -537,7 +537,6 @@ mod tests { assert_eq!(drag_in_view(90.0, true, 0.0, 30.0), (100.0, 0.2), "rotated and flipped: screen right grows"); } - /// Preferences ▸ Cursors ▸ Brush Preview colour tints the HUD's tip preview (red by default). /// The tip's mesh, painted at the origin: (distance from the centre, alpha) per vertex. fn tip_mesh(radius: f32, zoom: f32, hardness: f32) -> Vec<(f32, u8)> { let ctx = egui::Context::default(); @@ -577,6 +576,7 @@ mod tests { } } + /// Preferences ▸ Cursors ▸ Brush Preview colour tints the HUD's tip preview (red by default). #[test] fn the_tip_preview_uses_the_brush_preview_colour() { let mut app = app(Tool::Brush); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 68304b9005..a21e60c6e1 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -237,7 +237,7 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: let params = params.clone(); app.pick_file_bytes_filtered(&["csv", "txt"], move |app, _name, bytes| { app.refocus(doc)?; - let text = String::from_utf8_lossy(&bytes).into_owned(); + let text = data_sets_text(bytes)?; let r = photocraft_engine::import_data_sets_text(&mut app.session, &text, ¶ms).map_err(|e| e.to_string()); app.sync_views(); r @@ -693,6 +693,13 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: } } +/// The text of a picked Variable Data Sets file. Invalid UTF-8 is refused, as the engine's +/// path-based import refuses it, so a failed read never replaces the document's data sets with +/// U+FFFD-substituted values. +fn data_sets_text(bytes: Vec) -> Result { + String::from_utf8(bytes).map_err(|e| format!("the data set file is not valid UTF-8: {e}")) +} + fn open_path(app: &mut PhotocraftApp, path: &str) -> Result { app.open_path(path).map(|w| json!({"warnings": w})) } @@ -1682,6 +1689,12 @@ pub fn apply_workspace(app: &mut PhotocraftApp) { mod tests { use super::*; + #[test] + fn data_set_files_must_be_valid_utf8() { + assert_eq!(data_sets_text(b"DataSet,headline\nS,Jos\xc3\xa9\n".to_vec()).unwrap(), "DataSet,headline\nS,José\n"); + assert!(data_sets_text(b"DataSet,headline\nS\xff,Jos\xe9\n".to_vec()).is_err()); + } + #[test] fn showing_grid_restores_hidden_extras() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); diff --git a/crates/ui-egui/src/solid_fill_ui.rs b/crates/ui-egui/src/solid_fill_ui.rs index a1f7b5f8d8..37844b86c2 100644 --- a/crates/ui-egui/src/solid_fill_ui.rs +++ b/crates/ui-egui/src/solid_fill_ui.rs @@ -80,14 +80,39 @@ pub(crate) fn thumbnail_color(app: &PhotocraftApp, document: DocId, layer: Layer .filter(|d| owns(&d.fields)) .find_map(|d| { let params = d.fields.get("__params")?; - if params.get("document")?.as_u64()? != document.0 || params.get("layer")?.as_u64()? != layer.0 || d.fields.get("color") == d.fields.get("__orig") { + if params.get("document")?.as_u64()? != document.0 || params.get("layer")?.as_u64()? != layer.0 { return None; } - cmds::color_param(d.fields.get("color")?, original, app.session.documents().iter().find(|st| st.doc.id == document)?.doc.mode).ok() + let mode = app.session.documents().iter().find(|st| st.doc.id == document)?.doc.mode; + picked_color(&d.fields, original, mode) }) .unwrap_or(original) } +/// The colour the picker would commit to a fill of `original` in a `mode` document, built as the +/// engine builds it: the picked hex, and in a CMYK document the inks it was picked as (#8568). +/// `None` while the picker still shows the fill as it is ([`is_edit`]). +fn picked_color(f: &Map, original: Color, mode: photocraft_color::ColorMode) -> Option { + if !is_edit(f, original, mode) { + return None; + } + let color = cmds::color_param(f.get("color")?, original, mode).ok()?; + Some(color_picker_ui::picked_cmyk(f).filter(|_| mode == photocraft_color::ColorMode::Cmyk).map_or(color, |c| Color { + mode: photocraft_color::ColorMode::Cmyk, + c, + alpha: color.alpha, + })) +} + +/// Whether the picker differs from the fill it edits, inks included: different inks can share a +/// display colour (CMYK 0/0/0/100 and 50/50/50/100 both read black), so the hex alone doesn't +/// tell, and an RGB or Lab fill given inks in a CMYK document is an edit too. +fn is_edit(f: &Map, original: Color, mode: photocraft_color::ColorMode) -> bool { + let cmyk = photocraft_color::ColorMode::Cmyk; + let inks_differ = mode == cmyk && color_picker_ui::picked_cmyk(f).is_some_and(|k| original.mode != cmyk || original.c != k); + f.get("color") != f.get("__orig") || inks_differ +} + /// No-op OK preserves imported high-precision / non-RGB colour values rather than quantising /// them to the picker's display hex. A stale document must never receive the edit. pub(crate) fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result { @@ -100,17 +125,13 @@ pub(crate) fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result let Some(LayerContent::Fill(Fill::Solid(original))) = st.doc.layer(layer).map(|l| &l.content) else { return Err("the target Solid Color Fill layer no longer exists".into()); }; - // Different inks can share a display colour (CMYK 0/0/0/100 and 50/50/50/100 are both black): - // only a colour whose inks are unchanged too is no edit (#8568). - let inks = color_picker_ui::picked_cmyk(f); - let same_inks = inks.is_none_or(|k| original.mode != photocraft_color::ColorMode::Cmyk || original.c == k); - if f.get("color") == f.get("__orig") && same_inks { + if !is_edit(f, *original, st.doc.mode) { return Ok(json!({"layer": layer.0, "unchanged": true})); } let color = f.get("color").cloned().ok_or("missing color")?; let object = params.as_object_mut().ok_or("invalid Solid Color target")?; object.insert("color".into(), color); - if let Some(inks) = inks { + if let Some(inks) = color_picker_ui::picked_cmyk(f) { object.insert("cmyk".into(), json!(inks)); } app.run(cmds::SET, params) @@ -124,12 +145,12 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc, inks: [f32; 4]) { + let [r, g, b] = photocraft_color::convert::cmyk_to_rgb(inks).map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8); + f.insert("color".into(), json!(format!("#{r:02x}{g:02x}{b:02x}"))); + f.insert("__cmyk".into(), json!(inks)); +} + +/// Inks that share the picker's hex with the fill's own are still a preview and a thumbnail, as +/// they are an edit on OK (#8568). +#[test] +fn same_hex_inks_preview_and_thumbnail_as_ok_commits_them() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("image.mode.cmyk", json!({})).unwrap(); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + let near = [0.5, 0.5, 0.499, 1.0]; + let id = open_new(&mut app).unwrap(); + let mut f = app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.clone(); + with_inks(&mut f, near); + confirm_new(&mut app, &f).unwrap(); + + let rich = [0.5, 0.5, 0.5, 1.0]; + let id = open(&mut app).unwrap(); + with_inks(&mut app.ui.dialog_mut(id).unwrap().fields, rich); + let fields = &app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields; + assert_eq!(fields.get("color"), fields.get("__orig"), "both inks show as the same colour"); + + let st = app.session.active().unwrap(); + let (document, layer) = (st.doc.id, st.active_layer.unwrap()); + let LayerContent::Fill(Fill::Solid(original)) = st.doc.layer(layer).unwrap().content else { panic!("not a solid fill") }; + assert_eq!(thumbnail_color(&app, document, layer, original).c, rich); + let (shown, _) = display_doc(&mut app, 0).expect("a preview of the new inks"); + let LayerContent::Fill(Fill::Solid(previewed)) = shown.layer(layer).unwrap().content else { panic!("not a solid fill") }; + assert_eq!(previewed.c, rich); +} + +/// An RGB fill inside a CMYK document that the picker gives inks at the unchanged hex is an edit; +/// in an RGB document, which ignores the inks, it is not. +#[test] +fn inks_given_to_a_non_cmyk_fill_in_a_cmyk_document_are_an_edit() { + let original = Color::rgba(0.2, 0.3, 0.4, 1.0); + let mut f = serde_json::Map::new(); + with_inks(&mut f, [0.3, 0.2, 0.1, 0.4]); + f.insert("__orig".into(), f["color"].clone()); + assert!(is_edit(&f, original, ColorMode::Cmyk)); + assert!(!is_edit(&f, original, ColorMode::Rgb)); + assert!(picked_color(&f, original, ColorMode::Cmyk).is_some_and(|c| c.mode == ColorMode::Cmyk && c.c == [0.3, 0.2, 0.1, 0.4])); +} From 43afacaecc0b0f6ae7cc515870098c060c7c646a Mon Sep 17 00:00:00 2001 From: Federico Olivares Date: Sat, 24 Oct 2026 16:53:47 -0300 Subject: [PATCH 60/86] Address the second review of the integration batch Define Pattern accepts 8-bit patterns up to 8000 x 8000: an 8-bit pattern is held at 4 bytes a pixel by the compositor and the GPU, read in bands, and what is paintable follows the working-copy bytes. Shape bounds for Align keep full alpha precision, Define Pattern judges opacity on the stored samples, and a Halftone Gallery stack with an unknown setting beside its effect list stays verbatim. --- crates/compose/src/pattern.rs | 185 ++++++++++++++++++---- crates/doc/src/pattern.rs | 37 ++++- crates/engine/src/layer_multi_cmds.rs | 38 ++++- crates/engine/src/pattern_cmds.rs | 108 +++++++++++-- crates/gpu/src/compose.wgsl | 6 +- crates/gpu/src/lib.rs | 46 ++++-- crates/gpu/tests/it/parity.rs | 6 +- crates/io/src/smart_map.rs | 6 +- crates/io/src/smart_map/halftone_tests.rs | 13 ++ 9 files changed, 372 insertions(+), 73 deletions(-) diff --git a/crates/compose/src/pattern.rs b/crates/compose/src/pattern.rs index 258ef5664a..4016e46097 100644 --- a/crates/compose/src/pattern.rs +++ b/crates/compose/src/pattern.rs @@ -12,12 +12,16 @@ use std::sync::{Arc, Mutex, OnceLock}; use photocraft_doc::{ColorMode, Pattern, PixelFormat, Rect, SampleType}; -pub(crate) const PREPARED_PATTERN_BYTES: usize = 64 << 20; +/// What one rendering call keeps of its patterns' working copies: room for the largest paintable +/// one, so a large pattern is converted once per call instead of once per output tile. +pub(crate) const PREPARED_PATTERN_BYTES: usize = 256 << 20; const MAX_PREPARED_PATTERNS: usize = 64; -/// Absolute cap on a single pattern's RGBA conversion on the uncached path. A pattern this +/// Absolute cap on a single pattern's working copy on the uncached path. A pattern this /// large cannot be cached, so re-expanding it for every output tile would be a DoS; above the /// cap it is skipped instead. Independent of the cache budget so small patterns still convert. const MAX_UNCACHED_PATTERN_BYTES: usize = 256 << 20; +// Every paintable pattern must fit that cap, or its fill would render transparent. +const _: () = assert!(photocraft_doc::pattern::MAX_WORKING_BYTES <= MAX_UNCACHED_PATTERN_BYTES as u64); type PreparedSlot = Arc>>>; struct PreparedState { @@ -54,10 +58,7 @@ impl<'a> PreparedPatterns<'a> { if !p.is_paintable() { return None; } - let bytes = usize::try_from(p.width) - .ok() - .and_then(|w| usize::try_from(p.height).ok().and_then(|h| w.checked_mul(h))) - .and_then(|pixels| pixels.checked_mul(std::mem::size_of::<[f32; 4]>())); + let bytes = usize::try_from(p.working_bytes()).ok(); // The borrowed immutable slice pins these addresses for the complete call. let key = std::ptr::from_ref(p) as usize; let slot = { @@ -103,29 +104,81 @@ impl<'a> PreparedPatterns<'a> { /// pattern in colour in its picker, filled in grey). Empty for a pattern that isn't /// [paintable](Pattern::is_paintable). pub fn pattern_rgba(p: &Pattern, mode: ColorMode) -> Vec<[f32; 4]> { - let Some(n) = p.is_paintable().then(|| usize::try_from(p.pixel_count()).ok()).flatten() else { - return Vec::new(); - }; - let mut px = vec![[0.0f32; 4]; n]; - p.surface.read_rgba_into(p.rect(), &mut px); - let paints_in = matches!(mode, ColorMode::Grayscale | ColorMode::Bitmap | ColorMode::Duotone | ColorMode::Cmyk); - if paints_in && p.surface.format().mode != mode { - let fmt = PixelFormat::new(mode, SampleType::F32, true); - let mut v = [0.0f32; 5]; - for q in &mut px { - let n = photocraft_raster::from_rgba_into(&fmt, *q, &mut v); - *q = photocraft_raster::to_rgba(&fmt, v.get(..n).unwrap_or_default()); - } + let mut px = Vec::new(); + if p.is_paintable() { + pattern_rgba_rows(p, mode, 0, p.height, &mut px); } px } -/// A pattern converted once to straight-alpha RGBA for sampling. +/// Rows `top..bottom` of [`pattern_rgba`] (of a paintable pattern), replacing `out`: a large +/// pattern is read a band at a time instead of as one copy at 16 bytes per pixel. +pub fn pattern_rgba_rows(p: &Pattern, mode: ColorMode, top: u32, bottom: u32, out: &mut Vec<[f32; 4]>) { + let bottom = bottom.min(p.height); + let top = top.min(bottom); + out.clear(); + out.resize(p.width as usize * (bottom - top) as usize, [0.0f32; 4]); + p.surface.read_rgba_into(Rect::new(0, top as i32, p.width as i32, bottom as i32), out); + let paints_in = matches!(mode, ColorMode::Grayscale | ColorMode::Bitmap | ColorMode::Duotone | ColorMode::Cmyk); + if paints_in && p.surface.format().mode != mode { + let fmt = PixelFormat::new(mode, SampleType::F32, true); + let mut v = [0.0f32; 5]; + for q in out.iter_mut() { + let n = photocraft_raster::from_rgba_into(&fmt, *q, &mut v); + *q = photocraft_raster::to_rgba(&fmt, v.get(..n).unwrap_or_default()); + } + } +} + +/// Whether a pattern is kept as 8-bit straight RGBA while it paints (4 bytes per pixel instead of +/// 16): the pattern's samples are 8-bit, so nothing is lost. +pub fn is_compact(p: &Pattern) -> bool { + p.surface.format().sample == SampleType::U8 +} + +/// The straight RGBA8 pixels of a [compact](is_compact) paintable pattern as it paints in a +/// document of colour mode `mode`, a band of rows at a time: `sink(first_row, pixels)`. An 8-bit +/// RGB pattern comes through exactly; a converted one (a grey or CMYK document) is rounded to the +/// 8-bit grid it was drawn on. +pub fn for_each_rgba8_band(p: &Pattern, mode: ColorMode, mut sink: impl FnMut(u32, &[[u8; 4]])) { + if !p.is_paintable() || !is_compact(p) { + return; + } + let (mut band, mut bytes) = (Vec::new(), Vec::new()); + for top in (0..p.height).step_by(photocraft_doc::pattern::BAND_ROWS as usize) { + pattern_rgba_rows(p, mode, top, top + photocraft_doc::pattern::BAND_ROWS, &mut band); + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; + bytes.clear(); + bytes.extend(band.iter().map(|c| [q(c[0]), q(c[1]), q(c[2]), q(c[3])])); + sink(top, &bytes); + } +} + +/// `u8 / 255` as `f32`, for every `u8`: the value an 8-bit sample reads as. +const UNIT8: [f32; 256] = { + let mut table = [0.0f32; 256]; + let mut i = 0; + while i < 256 { + table[i] = i as f32 / 255.0; + i += 1; + } + table +}; + +/// A pattern's pixels as the sampler holds them. +enum Texels { + /// Premultiplied RGBA, 16 bytes per pixel. + Premultiplied(Vec<[f32; 4]>), + /// Straight RGBA8, 4 bytes per pixel, premultiplied as each tap reads it (see [`UNIT8`]): + /// the same values as `Premultiplied` holds for an 8-bit pattern. + Rgba8(Vec<[u8; 4]>), +} + +/// A pattern converted once for sampling. pub struct Tile { w: usize, h: usize, - /// Premultiplied RGBA. - px: Vec<[f32; 4]>, + texels: Texels, } impl Tile { @@ -140,20 +193,35 @@ impl Tile { return None; } let (w, h) = (p.width as usize, p.height as usize); - let mut px = pattern_rgba(p, mode); - for q in &mut px { - for c in 0..3 { - q[c] *= q[3]; + let texels = if is_compact(p) { + let mut px = Vec::with_capacity(w * h); + for_each_rgba8_band(p, mode, |_, band| px.extend_from_slice(band)); + Texels::Rgba8(px) + } else { + let mut px = pattern_rgba(p, mode); + for q in &mut px { + for c in 0..3 { + q[c] *= q[3]; + } } - } - Some(Tile { w, h, px }) + Texels::Premultiplied(px) + }; + Some(Tile { w, h, texels }) } #[inline] fn at(&self, x: i64, y: i64) -> [f32; 4] { let xi = x.rem_euclid(self.w as i64) as usize; let yi = y.rem_euclid(self.h as i64) as usize; - self.px[yi * self.w + xi] + let i = yi * self.w + xi; + match &self.texels { + Texels::Premultiplied(px) => px.get(i).copied().unwrap_or_default(), + Texels::Rgba8(px) => { + let [r, g, b, a] = px.get(i).copied().unwrap_or_default(); + let a = UNIT8[usize::from(a)]; + [UNIT8[usize::from(r)] * a, UNIT8[usize::from(g)] * a, UNIT8[usize::from(b)] * a, a] + } + } } /// Bilinear, wrapping sample at continuous tile coordinates (pixel centres at `i + 0.5`). @@ -329,6 +397,65 @@ mod tests { assert!(PreparedPatterns::new(&list, 0).get("big", "").is_none()); } + /// Review of #8902: what `Pattern::is_paintable` admits, the compositor expands, up to the + /// last pixel. An 8-bit pattern is held at 4 bytes a pixel, so 8000 × 8000 fits; any other is + /// 16 bytes a pixel, so 4096 × 4096 is the most, and one pixel more is skipped by both. (Only + /// the size is declared: the pattern itself is empty.) + #[test] + fn the_most_paintable_pixels_still_expand() { + let float = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true); + let sparse = |name: &str, (width, height): (u32, u32), format: PixelFormat| Pattern { + id: name.into(), + name: name.into(), + width, + height, + surface: Surface::new(format), + }; + let list = [ + sparse("max8", (8000, 8000), PixelFormat::RGBA8), + sparse("max32", (1 << 12, 1 << 12), float), + sparse("over32", ((1 << 12) + 1, 1 << 12), float), + sparse("over8", (9000, 8000), PixelFormat::RGBA8), + ]; + assert_eq!(list.each_ref().map(|p| p.is_paintable()), [true, true, false, false]); + let prepared = PreparedPatterns::new(&list, PREPARED_PATTERN_BYTES); + assert!(prepared.get("max8", "").is_some(), "an 8-bit 8000 x 8000 pattern expands"); + assert!(PreparedPatterns::new(&list, 0).get("max32", "").is_some(), "uncached too, at the cap"); + assert!(prepared.get("over32", "").is_none() && prepared.get("over8", "").is_none()); + } + + /// The 8-bit working copy samples exactly as the premultiplied `f32` one: every 8-bit value + /// survives the round trip through `f32`, and a tap reads the same premultiplied numbers. + #[test] + fn the_compact_tile_reads_the_same_as_the_expanded_one() { + for v in 0..=255u8 { + assert_eq!(((f32::from(v) / 255.0) * 255.0 + 0.5) as u8, v); + assert_eq!(UNIT8[usize::from(v)], f32::from(v) / 255.0); + } + let mut s = Surface::new(PixelFormat::RGBA8); + for y in 0..16i32 { + for x in 0..16i32 { + let (r, g, b, a) = ((x * 17) as u8, (y * 17) as u8, ((x + y) * 8) as u8, (255 - x * 13 - y * 3) as u8); + s.fill_rect(Rect::new(x, y, x + 1, y + 1), &[f32::from(r) / 255.0, f32::from(g) / 255.0, f32::from(b) / 255.0, f32::from(a) / 255.0]); + } + } + let p = Pattern::new("ramp", s, 16, 16); + assert!(is_compact(&p)); + let compact = Tile::new(&p).unwrap(); + assert!(matches!(compact.texels, Texels::Rgba8(_))); + let mut straight = pattern_rgba(&p, ColorMode::Rgb); + for q in &mut straight { + for c in 0..3 { + q[c] *= q[3]; + } + } + for y in 0..16 { + for x in 0..16 { + assert_eq!(compact.at(x, y), straight[(y * 16 + x) as usize], "({x}, {y})"); + } + } + } + #[test] fn link_phase_scale_and_angle_move_the_origin() { let t = Tile::new(&checker()).unwrap(); diff --git a/crates/doc/src/pattern.rs b/crates/doc/src/pattern.rs index f203528c7e..6bbbc1fb91 100644 --- a/crates/doc/src/pattern.rs +++ b/crates/doc/src/pattern.rs @@ -2,16 +2,24 @@ //! and brush textures. Pure data; rendering lives in `photocraft-compose`, the PSD mapping //! (`Patt`/`Pat2`/`Pat3` global blocks) in `photocraft-io`. +use photocraft_color::SampleType; use photocraft_geom::Rect; use photocraft_raster::Surface; /// Largest pattern edge a file may declare (the PSD reader's limit; Define Pattern stops at 8000). pub const MAX_EDGE: u32 = 30_000; -/// Most pixels a pattern may have to be painted. Renderers expand a pattern into a 16-byte-per-pixel -/// working copy, so this keeps that copy at 1 GiB; larger patterns paint nothing. +/// Most pixels a pattern may have to be painted (Define Pattern's 8000 × 8000 fits). pub const MAX_PAINT_PIXELS: u64 = 1 << 26; +/// Most bytes of working copy one pattern may need to be painted, the most the CPU compositor +/// expands for it (`photocraft-compose` checks the two agree at build time). See +/// [`Pattern::working_bytes`]. +pub const MAX_WORKING_BYTES: u64 = 256 << 20; + +/// Rows of a pattern read at a time where a whole-tile `f32` copy would cost 16 bytes per pixel. +pub const BAND_ROWS: u32 = 256; + /// One pattern tile. Pixels cover `0..width × 0..height` of `surface`, in any pixel format /// (depth and colour model are the pattern's own, as in Photoshop). #[derive(Clone, Debug, PartialEq)] @@ -36,13 +44,18 @@ impl Pattern { /// defining a pattern is deterministic and replayable). pub fn new(name: impl Into, surface: Surface, width: u32, height: u32) -> Pattern { let name = name.into(); - let r = Rect::new(0, 0, width as i32, height as i32); - let px = surface.read_region(r); let mut h1 = fnv(0xcbf2_9ce4_8422_2325, name.as_bytes()); h1 = fnv(h1, &width.to_le_bytes()); h1 = fnv(h1, &height.to_le_bytes()); - for v in &px { - h1 = fnv(h1, &v.to_bits().to_le_bytes()); + // The samples row by row, a band at a time: the same sequence as one read of the whole + // tile, without a full-size `f32` copy of a large pattern. + let mut band = Vec::new(); + for top in (0..height).step_by(BAND_ROWS as usize) { + let bottom = (top + BAND_ROWS).min(height); + surface.read_region_into(Rect::new(0, top as i32, width as i32, bottom as i32), &mut band); + for v in &band { + h1 = fnv(h1, &v.to_bits().to_le_bytes()); + } } let h2 = fnv(h1 ^ 0x9e37_79b9_7f4a_7c15, format!("{:?}", surface.format()).as_bytes()); let id = format!("{:08x}-{:04x}-{:04x}-{:04x}-{:012x}", (h1 >> 32) as u32, (h1 >> 16) as u16, h1 as u16, (h2 >> 48) as u16, h2 & 0xffff_ffff_ffff); @@ -70,9 +83,17 @@ impl Pattern { u64::from(self.width) * u64::from(self.height) } - /// Whether the tile has pixels and is small enough to paint (see [`MAX_PAINT_PIXELS`]). + /// Bytes of working copy a renderer holds to paint this pattern: 4 per pixel for an 8-bit + /// pattern (kept as it is), 16 for any other (expanded to `f32` RGBA). + pub fn working_bytes(&self) -> u64 { + let per_pixel = if self.surface.format().sample == SampleType::U8 { 4 } else { 16 }; + self.pixel_count().saturating_mul(per_pixel) + } + + /// Whether the tile has pixels and is small enough to paint (see [`MAX_PAINT_PIXELS`] and + /// [`MAX_WORKING_BYTES`]). pub fn is_paintable(&self) -> bool { - !self.is_empty() && self.pixel_count() <= MAX_PAINT_PIXELS + !self.is_empty() && self.pixel_count() <= MAX_PAINT_PIXELS && self.working_bytes() <= MAX_WORKING_BYTES } } diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index a9fe6d85fb..ae9b92ab8f 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -1136,6 +1136,9 @@ fn unbounded_fill(sh: &photocraft_doc::ShapeLayer) -> bool { /// stroke bounds stand in. const SHAPE_BOUNDS_MAX_PX: u64 = 64 << 20; +/// Pixels [`shape_pixel_bounds`] renders at a time. +const SHAPE_BOUNDS_BAND_PX: u64 = 1 << 20; + /// The pixels a shape layer draws, wherever they fall. Its cache is rendered clipped to the /// canvas, so a shape partly off the canvas measured from it looked smaller and Align moved it /// by the wrong amount, a little more on each click (#8660). The shape is rendered again over @@ -1153,8 +1156,19 @@ fn shape_pixel_bounds(sh: &photocraft_doc::ShapeLayer, canvas: Rect) -> Option (Surface, bool) { } /// [`composite_surface`], `band_rows` rows at a time. Rendering and converting one band at a -/// time, and noting opacity as it goes, keeps an 8000 × 8000 pattern (64 MP, about 1 GB per full -/// `f32` copy) from holding several full-size copies at once. +/// time, and noting opacity as it goes, keeps a large pattern (16 MP is 256 MiB per full `f32` +/// copy) from holding several full-size copies at once. Opacity is judged on the stored samples, +/// as the surface quantizes them: an alpha of 0.999 in an 8-bit document is stored as opaque. fn composite_surface_in_bands(doc: &Document, r: Rect, band_rows: u32) -> (Surface, bool) { let df = doc.pixel_format(); let fmt = PixelFormat::new(df.mode, df.sample, true); @@ -197,9 +198,12 @@ fn composite_surface_in_bands(doc: &Document, r: Rect, band_rows: u32) -> (Surfa px.resize(buf.px.len() * n, 0.0f32); for (o, p) in px.chunks_exact_mut(n).zip(&buf.px) { photocraft_raster::from_rgba_into(&fmt, *p, o); - opaque &= o[n - 1] >= 1.0; } - s.write_region(Rect::new(0, top as i32, width as i32, (top + rows) as i32), &px); + let band = Rect::new(0, top as i32, width as i32, (top + rows) as i32); + s.write_region(band, &px); + if opaque { + opaque = s.read_region(band).chunks_exact(n).all(|q| q[n - 1] >= 1.0); + } top += rows; } (s, opaque) @@ -219,7 +223,9 @@ pub fn default_name(s: &Session) -> String { (2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or(base) } -/// Longest edge Define Pattern accepts; 8000² stays under `MAX_PAINT_PIXELS`, so a defined pattern always paints. +/// Longest edge Define Pattern accepts. Its area is limited too, to what can be painted: above +/// 8 bits per channel a pattern's working copy is 16 bytes a pixel, and a larger one would define +/// but paint nothing (`Pattern::is_paintable`). const MAX_DEFINE_EDGE: u32 = 8000; fn define(s: &mut Session, p: &Value) -> Result { @@ -240,6 +246,10 @@ fn define(s: &mut Session, p: &Value) -> Result { if r.width() > MAX_DEFINE_EDGE || r.height() > MAX_DEFINE_EDGE { return Err(bad(cmd, "patterns are limited to 8000 × 8000 px")); } + let per_pixel = if doc.pixel_format().sample == photocraft_doc::SampleType::U8 { 4 } else { 16 }; + if u64::from(r.width()) * u64::from(r.height()) * per_pixel > photocraft_doc::pattern::MAX_WORKING_BYTES { + return Err(bad(cmd, "patterns above 8 bits per channel are limited to 16,777,216 px in all (4096 × 4096)")); + } let (mut surf, opaque) = composite_surface(doc, r); // Opaque patterns drop their alpha channel (as Photoshop does). if opaque { @@ -374,15 +384,14 @@ fn brush_texture(s: &mut Session, p: &Value) -> Result { return Err(bad("brush.texturePattern", "the pattern is empty or too large to paint")); }; // Brush textures are grey: luminance of the composite over white. - let mut px = vec![[0.0f32; 4]; n]; - pat.surface.read_rgba_into(pat.rect(), &mut px); - let lum: Vec = px - .iter() - .map(|q| { + // Read a band of rows at a time: a whole-tile RGBA copy would cost 16 bytes a pixel. + let mut lum: Vec = Vec::with_capacity(n); + for_each_band(&pat, pat.width, pat.height, |band| { + lum.extend(band.iter().map(|q| { let l = 0.299 * q[0] + 0.587 * q[1] + 0.114 * q[2]; l * q[3] + (1.0 - q[3]) - }) - .collect(); + })); + }); let tile = photocraft_paint::GrayTile::from_f32(pat.width, pat.height, &lum); let t = &mut s.tools.brush.texture; t.pattern = photocraft_paint::Pattern::Tile(tile); @@ -398,13 +407,25 @@ pub fn average_color(p: &Pattern) -> Color { // A pattern too large to paint is averaged over its paintable top-left corner. let side = photocraft_doc::pattern::MAX_PAINT_PIXELS.isqrt() as u32; let (w, h) = (p.width.min(side), p.height.min(side)); - let mut px = vec![[0.0f32; 4]; w as usize * h as usize]; - p.surface.read_rgba_into(Rect::new(0, 0, w as i32, h as i32), &mut px); - let n = px.len().max(1) as f32; - let s = px.iter().fold([0.0f32; 3], |a, q| [a[0] + q[0], a[1] + q[1], a[2] + q[2]]); + let mut s = [0.0f32; 3]; + for_each_band(p, w, h, |band| s = band.iter().fold(s, |a, q| [a[0] + q[0], a[1] + q[1], a[2] + q[2]])); + let n = (u64::from(w) * u64::from(h)).max(1) as f32; Color::rgb(s[0] / n, s[1] / n, s[2] / n) } +/// Calls `f` with the straight RGBA pixels of the top-left `w × h` corner of `p`, a band of rows +/// at a time in row-major order. +fn for_each_band(p: &Pattern, w: u32, h: u32, mut f: impl FnMut(&[[f32; 4]])) { + let mut band = Vec::new(); + for top in (0..h).step_by(photocraft_doc::pattern::BAND_ROWS as usize) { + let bottom = (top + photocraft_doc::pattern::BAND_ROWS).min(h); + band.clear(); + band.resize(w as usize * (bottom - top) as usize, [0.0f32; 4]); + p.surface.read_rgba_into(Rect::new(0, top as i32, w as i32, bottom as i32), &mut band); + f(&band); + } +} + pub fn specs() -> Vec { vec![ CommandSpec { @@ -581,6 +602,61 @@ mod tests { } } + /// Review of the banded composite: opacity is the stored samples', so an alpha an 8-bit + /// document stores as 255 is opaque and a float document's 0.999 is not. + #[test] + fn alpha_that_stores_as_opaque_counts_as_opaque() { + for (depth, opaque) in [(8u64, true), (32, false)] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "depth": depth, "background": "transparent"})).unwrap(); + s.execute("edit.fill", json!({"color": "#336699"})).unwrap(); + s.execute("layer.setProps", json!({"opacity": 0.999})).unwrap(); + let (_, all_opaque) = composite_surface_in_bands(&s.active().unwrap().doc, Rect::new(0, 0, 8, 8), 3); + assert_eq!(all_opaque, opaque, "depth {depth}"); + } + } + + /// Review of #8902: Define Pattern accepts only what the compositor paints. Above 8 bits per + /// channel (16 bytes a pixel of working copy) an area over 4096 × 4096 is refused rather than + /// defined to render transparent; an 8-bit pattern (4 bytes a pixel) goes up to 8000 × 8000, + /// and a large one paints. + #[test] + fn defined_patterns_paint_and_areas_the_compositor_would_skip_are_refused() { + for (w, h) in [(4100, 4100), (8000, 2100)] { + for depth in [16, 32] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": w, "height": h, "depth": depth})).unwrap(); + let e = s.execute("edit.definePattern", json!({})).unwrap_err().to_string(); + assert!(e.contains("16,777,216"), "{w} x {h} at {depth} bits: {e}"); + assert!(s.patterns.items.iter().all(|p| p.name != "Pattern"), "{w} x {h} at {depth} bits: nothing defined"); + } + } + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4100, "height": 4100, "depth": 8})).unwrap(); + s.execute("edit.fill", json!({"color": "#336699"})).unwrap(); + s.execute("edit.definePattern", json!({"name": "Big"})).unwrap(); + assert!(s.patterns.items.iter().any(|p| p.name == "Big" && p.is_paintable())); + s.execute("file.new", json!({"width": 40, "height": 30, "depth": 8})).unwrap(); + s.execute("layer.newFillLayer.pattern", json!({"pattern": "Big"})).unwrap(); + let [r, g, b, a] = pixel(&s, 5, 5); + assert!(a == 1.0 && (r - 0.2).abs() < 0.01 && (g - 0.4).abs() < 0.01 && (b - 0.6).abs() < 0.01, "{:?}", [r, g, b, a]); + } + + /// An 8-bit pattern of the largest area Define Pattern accepts is paintable: it is held at + /// 4 bytes a pixel, 256 MB, as the compositor's cap allows. + #[test] + fn an_8000_by_8000_pattern_is_defined_and_paintable() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8000, "height": 8000, "depth": 8})).unwrap(); + s.execute("edit.definePattern", json!({"name": "Largest"})).unwrap(); + let p = s.patterns.items.iter().find(|p| p.name == "Largest").unwrap(); + assert_eq!((p.width, p.height), (8000, 8000)); + assert!(p.is_paintable() && p.working_bytes() <= photocraft_doc::pattern::MAX_WORKING_BYTES); + let mut s16 = Session::new(); + s16.execute("file.new", json!({"width": 8000, "height": 8000, "depth": 16})).unwrap(); + assert!(s16.execute("edit.definePattern", json!({})).is_err(), "above 8 bits it would need 1 GB"); + } + #[test] fn define_pattern_accepts_edges_over_4000_up_to_8000() { for (w, ok) in [(4100, true), (8000, true), (8001, false)] { diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index 060d12b5d9..78a3a89eb3 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -1117,6 +1117,7 @@ fn wrap(i: i32, n: i32) -> i32 { } // One bilinear, wrapping, premultiplied tap at tile coordinates (u, v) (Tile::sample_premul). +// `pat_tex` holds straight RGBA (RGBA8 or RGBA32F); each texel is premultiplied as it is read. fn pattern_tap(u0: f32, v0: f32) -> vec4 { let u = u0 - 0.5; let v = v0 - 0.5; @@ -1135,14 +1136,15 @@ fn pattern_tap(u0: f32, v0: f32) -> vec4 { if (wy[j] == 0.0) { continue; } for (var i = 0; i < 2; i++) { if (wx[i] == 0.0) { continue; } - acc += textureLoad(pat_tex, vec2(wrap(ix + i, tw), wrap(iy + j, th)), 0) * (wx[i] * wy[j]); + let t = textureLoad(pat_tex, vec2(wrap(ix + i, tw), wrap(iy + j, th)), 0); + acc += vec4(t.rgb * t.a, t.a) * (wx[i] * wy[j]); } } return acc; } // compose::pattern::Tile::sample_area through Placement::map. p3 = (origin, cos, sin), -// p4 = (1 / scale, tile w, tile h); `pat_tex` holds premultiplied RGBA. Below 100 % a pixel +// p4 = (1 / scale, tile w, tile h); `pat_tex` holds straight RGBA. Below 100 % a pixel // averages n x n taps over its footprint (n = ceil(1 / scale), at most 8), like the CPU. fn pattern_sample(d: vec2) -> vec4 { var n = 1; diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index f9b6f11e57..95dcf4934d 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -211,7 +211,7 @@ struct CachedPattern { doc: DocId, /// [`Compositor::frame`] when it last painted. last_used: u64, - /// GPU bytes of the texture (RGBA32F). + /// GPU bytes of the texture (RGBA8, or RGBA32F for a pattern that is not 8-bit). bytes: u64, } @@ -1990,7 +1990,7 @@ impl Compositor { Some((key, [region.x0, region.y0, region.width() as i32, region.height() as i32])) } - /// Premultiplied RGBA32F texture of a pattern as it paints in the document's mode (cached by + /// Straight RGBA texture of a pattern as it paints in the document's mode (cached by /// pixel identity and mode). fn pattern_view(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, pat: &Pattern) -> wgpu::TextureView { let fp = pat.surface.tiles().fold((pat.width as u64) << 32 | pat.height as u64, |acc, (c, t)| { @@ -2012,17 +2012,39 @@ impl Compositor { return cached.texture.view.clone(); } let (w, h) = (pat.width, pat.height); - let px = photocraft_compose::pattern::pattern_rgba(pat, mode); - let bytes: Vec = px.iter().flat_map(|q| [q[0] * q[3], q[1] * q[3], q[2] * q[3], q[3]]).flat_map(f32::to_le_bytes).collect(); - let t = Tex::new(device, "pc_pattern", w, h, wgpu::TextureFormat::Rgba32Float, wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST); - queue.write_texture( - wgpu::TexelCopyTextureInfo { texture: &t.texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, - &bytes, - wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(w * 16), rows_per_image: Some(h) }, - wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, - ); + // An 8-bit pattern stays RGBA8 (4 bytes per texel, the shader premultiplies), any other is + // RGBA32F; both are written a band of rows at a time, so a large pattern never has a + // full-size copy in system memory. + let compact = photocraft_compose::pattern::is_compact(pat); + let (texture_format, texel_bytes) = if compact { (wgpu::TextureFormat::Rgba8Unorm, 4) } else { (wgpu::TextureFormat::Rgba32Float, 16) }; + let t = Tex::new(device, "pc_pattern", w, h, texture_format, wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST); + let write = |top: u32, rows: u32, bytes: &[u8]| { + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &t.texture, + mip_level: 0, + origin: wgpu::Origin3d { x: 0, y: top, z: 0 }, + aspect: wgpu::TextureAspect::All, + }, + bytes, + wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(w * texel_bytes), rows_per_image: Some(rows) }, + wgpu::Extent3d { width: w, height: rows, depth_or_array_layers: 1 }, + ); + }; + if compact { + photocraft_compose::pattern::for_each_rgba8_band(pat, mode, |top, band| write(top, band.len() as u32 / w.max(1), band.as_flattened())); + } else { + let (mut band, mut bytes) = (Vec::new(), Vec::new()); + for top in (0..h).step_by(photocraft_doc::pattern::BAND_ROWS as usize) { + let bottom = (top + photocraft_doc::pattern::BAND_ROWS).min(h); + photocraft_compose::pattern::pattern_rgba_rows(pat, mode, top, bottom, &mut band); + bytes.clear(); + bytes.extend(band.iter().flatten().flat_map(|v| v.to_le_bytes())); + write(top, bottom - top, &bytes); + } + } let v = t.view.clone(); - let bytes = u64::from(w.max(1)) * u64::from(h.max(1)) * 16; + let bytes = u64::from(w.max(1)) * u64::from(h.max(1)) * u64::from(texel_bytes); self.patterns.insert(key, CachedPattern { fingerprint: fp, format, default_bits, texture: t, doc, last_used, bytes }); v } diff --git a/crates/gpu/tests/it/parity.rs b/crates/gpu/tests/it/parity.rs index 135b0bcea1..610a359576 100644 --- a/crates/gpu/tests/it/parity.rs +++ b/crates/gpu/tests/it/parity.rs @@ -2565,10 +2565,10 @@ fn closed_documents_release_their_pattern_textures() { let a = pattern_fill_doc("first", 4); let mut b = pattern_fill_doc("second", 4); b.id = photocraft_doc::DocId(a.id.0 + 1); - let one = 4 * 4 * 16; + let one = 4 * 4 * 4; assert_eq!(g.comp.pattern_cache_bytes(), 0); render_to_vec(&mut g.comp, &g.device, &g.queue, &a, a.bounds()).expect("render a"); - assert_eq!(g.comp.pattern_cache_bytes(), one, "one 4 x 4 RGBA32F texture"); + assert_eq!(g.comp.pattern_cache_bytes(), one, "one 4 x 4 RGBA8 texture (an 8-bit pattern stays 8-bit)"); render_to_vec(&mut g.comp, &g.device, &g.queue, &b, b.bounds()).expect("render b"); assert_eq!(g.comp.pattern_cache_bytes(), 2 * one); g.comp.forget_doc(a.id); @@ -2589,7 +2589,7 @@ fn pattern_textures_are_evicted_over_budget_and_when_idle() { b.id = photocraft_doc::DocId(a.id.0 + 1); let mut plain = base_doc(8, 6); plain.id = photocraft_doc::DocId(a.id.0 + 2); - let one = 4 * 4 * 16; + let one = 4 * 4 * 4; // Room for one pattern and a half: painting the second drops the first. g.comp.set_pattern_budget(one + one / 2); render_to_vec(&mut g.comp, &g.device, &g.queue, &a, a.bounds()).expect("render a"); diff --git a/crates/io/src/smart_map.rs b/crates/io/src/smart_map.rs index 37a4b140d7..4ae9897abf 100644 --- a/crates/io/src/smart_map.rs +++ b/crates/io/src/smart_map.rs @@ -793,7 +793,11 @@ fn gallery_params(item: &Descriptor, f: &Descriptor) -> Option { let mut params = Map::new(); if g.key == "halftonePattern" { // Do not turn an unfamiliar shape or malformed setting into the engine's defaults: - // keeping the whole item opaque preserves it through a subsequent save. + // keeping the whole item opaque preserves it through a subsequent save. That covers + // the stack's own descriptor too: a key beside `GEfs` would not be written back. + if f.get("GEfs").is_some() && !(f.class_id.is(GALLERY_CLASS) && only_keys(f, &["GEfs"])) { + return None; + } if !e.class_id.is(GALLERY_CLASS) || !only_keys(e, &["GEfk", "Sz ", "Cntr", "Ptrn", "GELv"]) || !matches!(e.get("GEfk"), Some(Value::Enumerated { type_id, .. }) if type_id.is("GEft")) diff --git a/crates/io/src/smart_map/halftone_tests.rs b/crates/io/src/smart_map/halftone_tests.rs index 7f53043aa5..a4147ac4f8 100644 --- a/crates/io/src/smart_map/halftone_tests.rs +++ b/crates/io/src/smart_map/halftone_tests.rs @@ -125,6 +125,19 @@ fn halftone_pattern_unknown_nested_class_stays_verbatim() { } } +/// Keeps a Gallery stack opaque when its own descriptor holds a setting beside the effect list, +/// which a save would otherwise drop. +#[test] +fn halftone_pattern_unknown_outer_setting_stays_verbatim() { + let valid = native_effect("Dt ", 2, 5); + let gallery = Descriptor::new(GALLERY_CLASS).with("GEfs", Value::List(vec![Value::Descriptor(valid)])).with("futureSetting", Value::Integer(7)); + let item = native_item(gallery); + let parsed = Descriptor::from_bytes(&item.to_bytes()).unwrap(); + let filter = filter_from_item(&parsed); + assert_eq!(filter.command, UNSUPPORTED_FILTER); + assert_eq!(item_for_filter(&filter).unwrap().to_bytes(), item.to_bytes()); +} + /// Rejects unsupported export values instead of silently changing their meaning. #[test] fn halftone_pattern_invalid_export_settings_are_reported() { From 66466d5dc7af3dcc5fd29a1a9d887c6768ceca72 Mon Sep 17 00:00:00 2001 From: Federico Olivares Date: Sat, 24 Oct 2026 17:19:09 -0300 Subject: [PATCH 61/86] Paint very large 8-bit patterns on the CPU and keep the GPU path as it was The GPU path keeps its RGBA32F pattern textures and shader, and declines a pattern whose texture would pass 256 MiB so the CPU compositor, which holds an 8-bit pattern at 4 bytes a pixel, paints it. Add tests for the fallback, the pattern id staying the same when read in bands, and the paintable limits. --- crates/doc/src/pattern.rs | 39 +++++++++++++++++++ crates/gpu/src/compose.wgsl | 6 +-- crates/gpu/src/lib.rs | 71 ++++++++++++++++------------------- crates/gpu/tests/it/parity.rs | 27 +++++++++++-- 4 files changed, 98 insertions(+), 45 deletions(-) diff --git a/crates/doc/src/pattern.rs b/crates/doc/src/pattern.rs index 6bbbc1fb91..6945d271b4 100644 --- a/crates/doc/src/pattern.rs +++ b/crates/doc/src/pattern.rs @@ -139,4 +139,43 @@ mod tests { assert!(find(&list, "nope", "water").is_some()); assert!(find(&list, "nope", "").is_none()); } + + /// The id is a hash of every sample in row-major order; reading a tall pattern a band of rows + /// at a time must give the id one whole read gave, or saved documents would lose their + /// pattern links. + #[test] + fn the_id_of_a_tall_pattern_matches_a_single_read_of_it() { + let (w, h) = (7u32, BAND_ROWS * 2 + 37); + let mut s = Surface::new(PixelFormat::RGBA8); + for y in 0..h as i32 { + for x in 0..w as i32 { + s.fill_rect(Rect::new(x, y, x + 1, y + 1), &[(x % 5) as f32 / 4.0, (y % 7) as f32 / 6.0, ((x + y) % 3) as f32 / 2.0, 1.0]); + } + } + let name = "tall"; + let mut h1 = fnv(0xcbf2_9ce4_8422_2325, name.as_bytes()); + h1 = fnv(h1, &w.to_le_bytes()); + h1 = fnv(h1, &h.to_le_bytes()); + for v in s.read_region(Rect::new(0, 0, w as i32, h as i32)) { + h1 = fnv(h1, &v.to_bits().to_le_bytes()); + } + let h2 = fnv(h1 ^ 0x9e37_79b9_7f4a_7c15, format!("{:?}", s.format()).as_bytes()); + let expected = + format!("{:08x}-{:04x}-{:04x}-{:04x}-{:012x}", (h1 >> 32) as u32, (h1 >> 16) as u16, h1 as u16, (h2 >> 48) as u16, h2 & 0xffff_ffff_ffff); + assert_eq!(Pattern::new(name, s, w, h).id, expected); + } + + #[test] + fn paintable_follows_the_working_copy_bytes() { + let sparse = + |format: PixelFormat, width: u32, height: u32| Pattern { id: String::new(), name: String::new(), width, height, surface: Surface::new(format) }; + let float = PixelFormat::new(photocraft_color::ColorMode::Rgb, SampleType::F32, true); + assert!(sparse(PixelFormat::RGBA8, 8000, 8000).is_paintable()); + assert!(sparse(float, 4096, 4096).is_paintable()); + assert!(!sparse(float, 4097, 4096).is_paintable()); + assert!(!sparse(PixelFormat::RGBA8, 9000, 8000).is_paintable()); + assert!(!sparse(PixelFormat::RGBA8, 0, 8).is_paintable()); + assert_eq!(sparse(PixelFormat::RGBA8, 10, 10).working_bytes(), 400); + assert_eq!(sparse(float, 10, 10).working_bytes(), 1600); + } } diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index 78a3a89eb3..060d12b5d9 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -1117,7 +1117,6 @@ fn wrap(i: i32, n: i32) -> i32 { } // One bilinear, wrapping, premultiplied tap at tile coordinates (u, v) (Tile::sample_premul). -// `pat_tex` holds straight RGBA (RGBA8 or RGBA32F); each texel is premultiplied as it is read. fn pattern_tap(u0: f32, v0: f32) -> vec4 { let u = u0 - 0.5; let v = v0 - 0.5; @@ -1136,15 +1135,14 @@ fn pattern_tap(u0: f32, v0: f32) -> vec4 { if (wy[j] == 0.0) { continue; } for (var i = 0; i < 2; i++) { if (wx[i] == 0.0) { continue; } - let t = textureLoad(pat_tex, vec2(wrap(ix + i, tw), wrap(iy + j, th)), 0); - acc += vec4(t.rgb * t.a, t.a) * (wx[i] * wy[j]); + acc += textureLoad(pat_tex, vec2(wrap(ix + i, tw), wrap(iy + j, th)), 0) * (wx[i] * wy[j]); } } return acc; } // compose::pattern::Tile::sample_area through Placement::map. p3 = (origin, cos, sin), -// p4 = (1 / scale, tile w, tile h); `pat_tex` holds straight RGBA. Below 100 % a pixel +// p4 = (1 / scale, tile w, tile h); `pat_tex` holds premultiplied RGBA. Below 100 % a pixel // averages n x n taps over its footprint (n = ceil(1 / scale), at most 8), like the CPU. fn pattern_sample(d: vec2) -> vec4 { var n = 1; diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 95dcf4934d..2d60a0503f 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -82,6 +82,9 @@ pub const RESIDENT_BUDGET: u64 = 4 << 30; /// GPU memory pattern textures may hold before the least recently painted are dropped (those the /// current frame paints with always stay). pub const PATTERN_BUDGET: u64 = 256 << 20; +/// The most a single pattern's RGBA32F texture may take. A pattern over it paints on the CPU, which +/// holds an 8-bit pattern at 4 bytes a pixel instead of 16 (`Pattern::is_paintable`). +const MAX_PATTERN_TEXTURE_BYTES: u64 = 256 << 20; /// A pattern texture no frame has painted with for this many frames is dropped. pub const PATTERN_IDLE_FRAMES: u64 = 240; /// Uploads (bytes) [`Compositor::render`] stages before submitting the work recorded so far, so @@ -211,7 +214,7 @@ struct CachedPattern { doc: DocId, /// [`Compositor::frame`] when it last painted. last_used: u64, - /// GPU bytes of the texture (RGBA8, or RGBA32F for a pattern that is not 8-bit). + /// GPU bytes of the texture (RGBA32F). bytes: u64, } @@ -1022,19 +1025,33 @@ impl Compositor { if s.lut.as_ref().is_some_and(|l| l.len() > 4096) { return Err(Unsupported(format!("effect blur on `{}` too wide for the GPU path", f.layer.name))); } + if let Some(pat) = &s.pattern { + self.check_pattern(pat)?; + } } } } for pass in &p.passes { - if let Some(pat) = pass.pattern - && (pat.width > self.device_max || pat.height > self.device_max) - { - return Err(Unsupported(format!("pattern `{}` larger than the GPU texture limit", pat.name))); + if let Some(pat) = pass.pattern { + self.check_pattern(pat)?; } } Ok(()) } + /// A pattern's texture must fit the device and the pattern budget. The CPU compositor holds an + /// 8-bit pattern at a quarter of the texture's 16 bytes a pixel, so a larger one (up to + /// Define Pattern's 8000 × 8000) renders there instead. + fn check_pattern(&self, pat: &Pattern) -> Result<(), Unsupported> { + if pat.width > self.device_max || pat.height > self.device_max { + return Err(Unsupported(format!("pattern `{}` larger than the GPU texture limit", pat.name))); + } + if u64::from(pat.width) * u64::from(pat.height) * 16 > MAX_PATTERN_TEXTURE_BYTES { + return Err(Unsupported(format!("pattern `{}` too large for a GPU texture; the CPU path paints it", pat.name))); + } + Ok(()) + } + /// Drop GPU textures of a closed document. pub fn forget_doc(&mut self, doc: DocId) { self.residents.retain(|_, r| r.doc != doc); @@ -1990,7 +2007,7 @@ impl Compositor { Some((key, [region.x0, region.y0, region.width() as i32, region.height() as i32])) } - /// Straight RGBA texture of a pattern as it paints in the document's mode (cached by + /// Premultiplied RGBA32F texture of a pattern as it paints in the document's mode (cached by /// pixel identity and mode). fn pattern_view(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, pat: &Pattern) -> wgpu::TextureView { let fp = pat.surface.tiles().fold((pat.width as u64) << 32 | pat.height as u64, |acc, (c, t)| { @@ -2012,39 +2029,17 @@ impl Compositor { return cached.texture.view.clone(); } let (w, h) = (pat.width, pat.height); - // An 8-bit pattern stays RGBA8 (4 bytes per texel, the shader premultiplies), any other is - // RGBA32F; both are written a band of rows at a time, so a large pattern never has a - // full-size copy in system memory. - let compact = photocraft_compose::pattern::is_compact(pat); - let (texture_format, texel_bytes) = if compact { (wgpu::TextureFormat::Rgba8Unorm, 4) } else { (wgpu::TextureFormat::Rgba32Float, 16) }; - let t = Tex::new(device, "pc_pattern", w, h, texture_format, wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST); - let write = |top: u32, rows: u32, bytes: &[u8]| { - queue.write_texture( - wgpu::TexelCopyTextureInfo { - texture: &t.texture, - mip_level: 0, - origin: wgpu::Origin3d { x: 0, y: top, z: 0 }, - aspect: wgpu::TextureAspect::All, - }, - bytes, - wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(w * texel_bytes), rows_per_image: Some(rows) }, - wgpu::Extent3d { width: w, height: rows, depth_or_array_layers: 1 }, - ); - }; - if compact { - photocraft_compose::pattern::for_each_rgba8_band(pat, mode, |top, band| write(top, band.len() as u32 / w.max(1), band.as_flattened())); - } else { - let (mut band, mut bytes) = (Vec::new(), Vec::new()); - for top in (0..h).step_by(photocraft_doc::pattern::BAND_ROWS as usize) { - let bottom = (top + photocraft_doc::pattern::BAND_ROWS).min(h); - photocraft_compose::pattern::pattern_rgba_rows(pat, mode, top, bottom, &mut band); - bytes.clear(); - bytes.extend(band.iter().flatten().flat_map(|v| v.to_le_bytes())); - write(top, bottom - top, &bytes); - } - } + let px = photocraft_compose::pattern::pattern_rgba(pat, mode); + let bytes: Vec = px.iter().flat_map(|q| [q[0] * q[3], q[1] * q[3], q[2] * q[3], q[3]]).flat_map(f32::to_le_bytes).collect(); + let t = Tex::new(device, "pc_pattern", w, h, wgpu::TextureFormat::Rgba32Float, wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST); + queue.write_texture( + wgpu::TexelCopyTextureInfo { texture: &t.texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + &bytes, + wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(w * 16), rows_per_image: Some(h) }, + wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + ); let v = t.view.clone(); - let bytes = u64::from(w.max(1)) * u64::from(h.max(1)) * u64::from(texel_bytes); + let bytes = u64::from(w.max(1)) * u64::from(h.max(1)) * 16; self.patterns.insert(key, CachedPattern { fingerprint: fp, format, default_bits, texture: t, doc, last_used, bytes }); v } diff --git a/crates/gpu/tests/it/parity.rs b/crates/gpu/tests/it/parity.rs index 610a359576..9ea6c06c7f 100644 --- a/crates/gpu/tests/it/parity.rs +++ b/crates/gpu/tests/it/parity.rs @@ -2565,10 +2565,10 @@ fn closed_documents_release_their_pattern_textures() { let a = pattern_fill_doc("first", 4); let mut b = pattern_fill_doc("second", 4); b.id = photocraft_doc::DocId(a.id.0 + 1); - let one = 4 * 4 * 4; + let one = 4 * 4 * 16; assert_eq!(g.comp.pattern_cache_bytes(), 0); render_to_vec(&mut g.comp, &g.device, &g.queue, &a, a.bounds()).expect("render a"); - assert_eq!(g.comp.pattern_cache_bytes(), one, "one 4 x 4 RGBA8 texture (an 8-bit pattern stays 8-bit)"); + assert_eq!(g.comp.pattern_cache_bytes(), one, "one 4 x 4 RGBA32F texture"); render_to_vec(&mut g.comp, &g.device, &g.queue, &b, b.bounds()).expect("render b"); assert_eq!(g.comp.pattern_cache_bytes(), 2 * one); g.comp.forget_doc(a.id); @@ -2579,6 +2579,27 @@ fn closed_documents_release_their_pattern_textures() { check(&mut g, &a, "the pattern is uploaded again after being forgotten"); } +/// A pattern whose RGBA32F texture would pass the per-pattern cap (Define Pattern accepts 8-bit +/// ones up to 8000 x 8000) is declined by the GPU path and painted by the CPU compositor, which +/// holds an 8-bit pattern at a quarter of the texture's size. +#[test] +fn a_pattern_too_large_for_a_gpu_texture_falls_back_to_the_cpu() { + let Some(mut g) = gpu() else { return }; + let mut d = Document::new("big pattern", Size::new(8, 6), ColorMode::Rgb, SampleType::U8); + // 100 MB as RGBA8, 400 MB as an RGBA32F texture. + let surface = photocraft_raster::Surface::with_default(PixelFormat::RGBA8, &[1.0, 0.0, 0.0, 1.0]); + let pat = Pattern { id: "big".into(), name: "big".into(), width: 5000, height: 5000, surface }; + assert!(pat.is_paintable()); + d.layers.push(Layer::new( + "pattern", + LayerContent::Fill(Fill::Pattern { name: pat.name.clone(), id: pat.id.clone(), scale: 1.0, angle: 0.0, link: true, phase: (0.0, 0.0) }), + )); + d.patterns.push(pat); + assert!(render_to_vec(&mut g.comp, &g.device, &g.queue, &d, d.bounds()).is_err(), "the GPU path declines it"); + assert_eq!(g.comp.pattern_cache_bytes(), 0, "nothing was uploaded"); + assert!(photocraft_compose::flatten(&d).px.iter().all(|p| *p == [1.0, 0.0, 0.0, 1.0]), "the CPU compositor paints it"); +} + /// #4971: pattern textures are bounded while their documents stay open, by a byte budget and /// by age. What the current frame paints with is never dropped. #[test] @@ -2589,7 +2610,7 @@ fn pattern_textures_are_evicted_over_budget_and_when_idle() { b.id = photocraft_doc::DocId(a.id.0 + 1); let mut plain = base_doc(8, 6); plain.id = photocraft_doc::DocId(a.id.0 + 2); - let one = 4 * 4 * 4; + let one = 4 * 4 * 16; // Room for one pattern and a half: painting the second drops the first. g.comp.set_pattern_budget(one + one / 2); render_to_vec(&mut g.comp, &g.device, &g.queue, &a, a.bounds()).expect("render a"); From 9f92ddd75bcd334e0435d63afae0524c3c77c609 Mon Sep 17 00:00:00 2001 From: Federico Olivares Date: Sat, 24 Oct 2026 18:23:24 -0300 Subject: [PATCH 62/86] Hold large patterns at their own depth, return the batch to the pre-8000 code and fix the review blockers Return the pattern code to the state before the earlier 8000 x 8000 attempts. A pattern that fits the compositor's 256 MiB cap is expanded as before; a larger 8-bit or 16-bit one is held as straight RGBA8 or RGBA16, exactly its values at 4 or 8 bytes a pixel, so Define Pattern's 8000 x 8000 paints at 8 bits. Define Pattern refuses an area the compositor could not hold with its limit, patterns whose RGBA32F texture passes 256 MiB paint on the CPU, shape bounds for Align keep full alpha precision, Define Pattern judges opacity on the stored samples, and a Halftone Gallery stack with an unknown setting beside its effect list stays verbatim. --- crates/compose/src/pattern.rs | 146 ++++++++++++++++-------------- crates/doc/src/pattern.rs | 55 ++++++++--- crates/engine/src/pattern_cmds.rs | 81 +++++++++-------- crates/gpu/src/lib.rs | 7 +- crates/gpu/tests/it/parity.rs | 2 +- 5 files changed, 169 insertions(+), 122 deletions(-) diff --git a/crates/compose/src/pattern.rs b/crates/compose/src/pattern.rs index 4016e46097..d315a69fa9 100644 --- a/crates/compose/src/pattern.rs +++ b/crates/compose/src/pattern.rs @@ -12,11 +12,12 @@ use std::sync::{Arc, Mutex, OnceLock}; use photocraft_doc::{ColorMode, Pattern, PixelFormat, Rect, SampleType}; -/// What one rendering call keeps of its patterns' working copies: room for the largest paintable -/// one, so a large pattern is converted once per call instead of once per output tile. +/// What one rendering call may keep of its patterns' working copies: room for the largest, so a +/// large pattern is converted once per call instead of once per output tile. A ceiling, not an +/// allocation: only the patterns a call paints with are converted. pub(crate) const PREPARED_PATTERN_BYTES: usize = 256 << 20; const MAX_PREPARED_PATTERNS: usize = 64; -/// Absolute cap on a single pattern's working copy on the uncached path. A pattern this +/// Absolute cap on a single pattern's RGBA conversion on the uncached path. A pattern this /// large cannot be cached, so re-expanding it for every output tile would be a DoS; above the /// cap it is skipped instead. Independent of the cache budget so small patterns still convert. const MAX_UNCACHED_PATTERN_BYTES: usize = 256 << 20; @@ -130,28 +131,18 @@ pub fn pattern_rgba_rows(p: &Pattern, mode: ColorMode, top: u32, bottom: u32, ou } } -/// Whether a pattern is kept as 8-bit straight RGBA while it paints (4 bytes per pixel instead of -/// 16): the pattern's samples are 8-bit, so nothing is lost. -pub fn is_compact(p: &Pattern) -> bool { - p.surface.format().sample == SampleType::U8 -} - -/// The straight RGBA8 pixels of a [compact](is_compact) paintable pattern as it paints in a -/// document of colour mode `mode`, a band of rows at a time: `sink(first_row, pixels)`. An 8-bit -/// RGB pattern comes through exactly; a converted one (a grey or CMYK document) is rounded to the -/// 8-bit grid it was drawn on. -pub fn for_each_rgba8_band(p: &Pattern, mode: ColorMode, mut sink: impl FnMut(u32, &[[u8; 4]])) { - if !p.is_paintable() || !is_compact(p) { - return; - } - let (mut band, mut bytes) = (Vec::new(), Vec::new()); +/// The pixels of a paintable pattern as they paint in a document of colour mode `mode`, rounded +/// to the pattern's own sample grid by `quantize`, read a band of rows at a time: no full-size +/// `f32` copy. A pattern with its own colours comes through exactly (its samples are on that +/// grid); one converted to a grey or CMYK document is rounded to the grid it was drawn on. +fn quantized_rgba(p: &Pattern, mode: ColorMode, quantize: impl Fn(f32) -> T) -> Vec<[T; 4]> { + let mut out = Vec::with_capacity(p.width as usize * p.height as usize); + let mut band = Vec::new(); for top in (0..p.height).step_by(photocraft_doc::pattern::BAND_ROWS as usize) { pattern_rgba_rows(p, mode, top, top + photocraft_doc::pattern::BAND_ROWS, &mut band); - let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; - bytes.clear(); - bytes.extend(band.iter().map(|c| [q(c[0]), q(c[1]), q(c[2]), q(c[3])])); - sink(top, &bytes); + out.extend(band.iter().map(|c| [quantize(c[0]), quantize(c[1]), quantize(c[2]), quantize(c[3])])); } + out } /// `u8 / 255` as `f32`, for every `u8`: the value an 8-bit sample reads as. @@ -167,11 +158,13 @@ const UNIT8: [f32; 256] = { /// A pattern's pixels as the sampler holds them. enum Texels { - /// Premultiplied RGBA, 16 bytes per pixel. + /// Premultiplied `f32` RGBA, 16 bytes per pixel: any pattern that fits. Premultiplied(Vec<[f32; 4]>), - /// Straight RGBA8, 4 bytes per pixel, premultiplied as each tap reads it (see [`UNIT8`]): - /// the same values as `Premultiplied` holds for an 8-bit pattern. + /// Straight RGBA8, 4 bytes per pixel, premultiplied as each tap reads it (see [`UNIT8`]): a + /// large 8-bit pattern, with the values the expanded copy would hold. Rgba8(Vec<[u8; 4]>), + /// Straight RGBA16, 8 bytes per pixel, premultiplied as each tap reads it: a large 16-bit one. + Rgba16(Vec<[u16; 4]>), } /// A pattern converted once for sampling. @@ -192,19 +185,25 @@ impl Tile { if !p.is_paintable() { return None; } + Tile::build(p, mode, p.pixel_count().saturating_mul(16) > photocraft_doc::pattern::MAX_WORKING_BYTES) + } + + /// [`Tile::new_in`], held at the pattern's own depth when `compact` and it has a smaller form. + fn build(p: &Pattern, mode: ColorMode, compact: bool) -> Option { let (w, h) = (p.width as usize, p.height as usize); - let texels = if is_compact(p) { - let mut px = Vec::with_capacity(w * h); - for_each_rgba8_band(p, mode, |_, band| px.extend_from_slice(band)); - Texels::Rgba8(px) - } else { - let mut px = pattern_rgba(p, mode); - for q in &mut px { - for c in 0..3 { - q[c] *= q[3]; + let texels = match p.surface.format().sample { + // A large pattern is held at its own depth (`Pattern::working_bytes`). + SampleType::U8 if compact => Texels::Rgba8(quantized_rgba(p, mode, |v| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8)), + SampleType::U16 if compact => Texels::Rgba16(quantized_rgba(p, mode, |v| (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16)), + _ => { + let mut px = pattern_rgba(p, mode); + for q in &mut px { + for c in 0..3 { + q[c] *= q[3]; + } } + Texels::Premultiplied(px) } - Texels::Premultiplied(px) }; Some(Tile { w, h, texels }) } @@ -221,6 +220,12 @@ impl Tile { let a = UNIT8[usize::from(a)]; [UNIT8[usize::from(r)] * a, UNIT8[usize::from(g)] * a, UNIT8[usize::from(b)] * a, a] } + Texels::Rgba16(px) => { + let [r, g, b, a] = px.get(i).copied().unwrap_or_default(); + let unit = |v: u16| f32::from(v) / 65535.0; + let a = unit(a); + [unit(r) * a, unit(g) * a, unit(b) * a, a] + } } } @@ -397,13 +402,11 @@ mod tests { assert!(PreparedPatterns::new(&list, 0).get("big", "").is_none()); } - /// Review of #8902: what `Pattern::is_paintable` admits, the compositor expands, up to the - /// last pixel. An 8-bit pattern is held at 4 bytes a pixel, so 8000 × 8000 fits; any other is - /// 16 bytes a pixel, so 4096 × 4096 is the most, and one pixel more is skipped by both. (Only - /// the size is declared: the pattern itself is empty.) + /// Review of #8902: what `Pattern::is_paintable` admits, the compositor expands. A pattern that + /// fits is expanded as before; a large one is held at its own depth, so Define Pattern's + /// 8000 x 8000 fits at 8 bits. (Only the size is declared: the patterns themselves are empty.) #[test] fn the_most_paintable_pixels_still_expand() { - let float = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true); let sparse = |name: &str, (width, height): (u32, u32), format: PixelFormat| Pattern { id: name.into(), name: name.into(), @@ -411,48 +414,57 @@ mod tests { height, surface: Surface::new(format), }; + let float = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true); let list = [ sparse("max8", (8000, 8000), PixelFormat::RGBA8), - sparse("max32", (1 << 12, 1 << 12), float), - sparse("over32", ((1 << 12) + 1, 1 << 12), float), + sparse("max16", (5792, 5792), PixelFormat::RGBA16), + sparse("max32", (4096, 4096), float), sparse("over8", (9000, 8000), PixelFormat::RGBA8), + sparse("over16", (8000, 8000), PixelFormat::RGBA16), + sparse("over32", (4097, 4096), float), ]; - assert_eq!(list.each_ref().map(|p| p.is_paintable()), [true, true, false, false]); + assert_eq!(list.each_ref().map(|p| p.is_paintable()), [true, true, true, false, false, false]); let prepared = PreparedPatterns::new(&list, PREPARED_PATTERN_BYTES); - assert!(prepared.get("max8", "").is_some(), "an 8-bit 8000 x 8000 pattern expands"); - assert!(PreparedPatterns::new(&list, 0).get("max32", "").is_some(), "uncached too, at the cap"); - assert!(prepared.get("over32", "").is_none() && prepared.get("over8", "").is_none()); + let held = |name: &str| prepared.get(name, "").map(|t| (matches!(t.texels, Texels::Rgba8(_)), matches!(t.texels, Texels::Rgba16(_)))); + assert_eq!(held("max8"), Some((true, false)), "an 8-bit 8000 x 8000 pattern expands to 4 bytes a pixel"); + assert_eq!(held("max16"), Some((false, true)), "a 16-bit one to 8"); + for name in ["over8", "over16", "over32"] { + assert!(prepared.get(name, "").is_none(), "{name} is skipped, not allocated"); + } + assert!(PreparedPatterns::new(&list, 0).get("max8", "").is_some(), "uncached too, at the cap"); } - /// The 8-bit working copy samples exactly as the premultiplied `f32` one: every 8-bit value - /// survives the round trip through `f32`, and a tap reads the same premultiplied numbers. + /// A large pattern's compact copy samples exactly as the expanded one: every 8-bit and 16-bit + /// value survives the round trip through `f32`, and a tap reads the same premultiplied numbers. #[test] - fn the_compact_tile_reads_the_same_as_the_expanded_one() { + fn the_compact_copies_read_the_same_as_the_expanded_one() { for v in 0..=255u8 { assert_eq!(((f32::from(v) / 255.0) * 255.0 + 0.5) as u8, v); assert_eq!(UNIT8[usize::from(v)], f32::from(v) / 255.0); } - let mut s = Surface::new(PixelFormat::RGBA8); - for y in 0..16i32 { - for x in 0..16i32 { - let (r, g, b, a) = ((x * 17) as u8, (y * 17) as u8, ((x + y) * 8) as u8, (255 - x * 13 - y * 3) as u8); - s.fill_rect(Rect::new(x, y, x + 1, y + 1), &[f32::from(r) / 255.0, f32::from(g) / 255.0, f32::from(b) / 255.0, f32::from(a) / 255.0]); - } + for v in 0..=u16::MAX { + assert_eq!(((f32::from(v) / 65535.0) * 65535.0 + 0.5) as u16, v); } - let p = Pattern::new("ramp", s, 16, 16); - assert!(is_compact(&p)); - let compact = Tile::new(&p).unwrap(); - assert!(matches!(compact.texels, Texels::Rgba8(_))); - let mut straight = pattern_rgba(&p, ColorMode::Rgb); - for q in &mut straight { - for c in 0..3 { - q[c] *= q[3]; + for (format, grid) in [(PixelFormat::RGBA8, 255.0f32), (PixelFormat::RGBA16, 65535.0)] { + let mut s = Surface::new(format); + for y in 0..16i32 { + for x in 0..16i32 { + let level = |v: i32| (v as f32 / 255.0 * grid).round() / grid; + s.fill_rect(Rect::new(x, y, x + 1, y + 1), &[level(x * 17), level(y * 17), level((x + y) * 8), level(255 - x * 13 - y * 3)]); + } } - } - for y in 0..16 { - for x in 0..16 { - assert_eq!(compact.at(x, y), straight[(y * 16 + x) as usize], "({x}, {y})"); + let p = Pattern::new("ramp", s, 16, 16); + let (expanded, compact) = (Tile::build(&p, ColorMode::Rgb, false).unwrap(), Tile::build(&p, ColorMode::Rgb, true).unwrap()); + assert!(matches!(expanded.texels, Texels::Premultiplied(_))); + assert!(matches!((&compact.texels, grid as u32), (Texels::Rgba8(_), 255) | (Texels::Rgba16(_), 65535))); + for y in 0..16 { + for x in 0..16 { + assert_eq!(compact.at(x, y), expanded.at(x, y), "{grid} ({x}, {y})"); + } } + // And rendered, scaled, rotated and shifted across the wrap. + let place = Placement::new(Rect::new(3, 5, 40, 40), true, (2.0, 1.0), 0.6, 20.0); + assert_eq!(render(&compact, &place, Rect::new(-9, -7, 30, 25)), render(&expanded, &place, Rect::new(-9, -7, 30, 25))); } } diff --git a/crates/doc/src/pattern.rs b/crates/doc/src/pattern.rs index 6945d271b4..cbdc4f1459 100644 --- a/crates/doc/src/pattern.rs +++ b/crates/doc/src/pattern.rs @@ -9,12 +9,12 @@ use photocraft_raster::Surface; /// Largest pattern edge a file may declare (the PSD reader's limit; Define Pattern stops at 8000). pub const MAX_EDGE: u32 = 30_000; -/// Most pixels a pattern may have to be painted (Define Pattern's 8000 × 8000 fits). +/// Most pixels a pattern may have to be painted (Define Pattern's 8000 × 8000 fits). Larger +/// patterns paint nothing. pub const MAX_PAINT_PIXELS: u64 = 1 << 26; -/// Most bytes of working copy one pattern may need to be painted, the most the CPU compositor -/// expands for it (`photocraft-compose` checks the two agree at build time). See -/// [`Pattern::working_bytes`]. +/// Most bytes of working copy the CPU compositor holds for one pattern (`photocraft-compose` +/// checks the two agree at build time). See [`Pattern::working_bytes`]. pub const MAX_WORKING_BYTES: u64 = 256 << 20; /// Rows of a pattern read at a time where a whole-tile `f32` copy would cost 16 bytes per pixel. @@ -83,11 +83,12 @@ impl Pattern { u64::from(self.width) * u64::from(self.height) } - /// Bytes of working copy a renderer holds to paint this pattern: 4 per pixel for an 8-bit - /// pattern (kept as it is), 16 for any other (expanded to `f32` RGBA). + /// Bytes of working copy the compositor holds to paint this pattern. One that fits is expanded + /// to premultiplied `f32` RGBA (16 per pixel). A pattern too large for that is held at its own + /// depth instead, straight RGBA at 4 bytes per pixel for 8 bits and 8 for 16 bits: exactly its + /// values, in a quarter or half the memory. A float one has no smaller exact form. pub fn working_bytes(&self) -> u64 { - let per_pixel = if self.surface.format().sample == SampleType::U8 { 4 } else { 16 }; - self.pixel_count().saturating_mul(per_pixel) + working_bytes_for(self.pixel_count(), self.surface.format().sample) } /// Whether the tile has pixels and is small enough to paint (see [`MAX_PAINT_PIXELS`] and @@ -97,6 +98,29 @@ impl Pattern { } } +/// [`Pattern::working_bytes`] of a pattern of `pixels` pixels with `sample` samples. +pub fn working_bytes_for(pixels: u64, sample: SampleType) -> u64 { + let expanded = pixels.saturating_mul(16); + if expanded <= MAX_WORKING_BYTES { + return expanded; + } + match sample { + SampleType::U8 => pixels.saturating_mul(4), + SampleType::U16 => pixels.saturating_mul(8), + SampleType::F32 => expanded, + } +} + +/// Most pixels a pattern with `sample` samples may have and still paint: [`MAX_PAINT_PIXELS`] for +/// 8 bits, fewer where the working copy is larger per pixel. +pub fn max_paint_pixels(sample: SampleType) -> u64 { + match sample { + SampleType::U8 => MAX_PAINT_PIXELS, + SampleType::U16 => MAX_PAINT_PIXELS.min(MAX_WORKING_BYTES / 8), + SampleType::F32 => MAX_PAINT_PIXELS.min(MAX_WORKING_BYTES / 16), + } +} + /// Display form of a stored pattern name (see [`Pattern::display_name`]). pub fn display_name(name: &str) -> &str { let n = name.trim_end_matches('\0'); @@ -166,16 +190,21 @@ mod tests { } #[test] - fn paintable_follows_the_working_copy_bytes() { + fn working_bytes_keep_a_pattern_exact_in_less_memory() { let sparse = |format: PixelFormat, width: u32, height: u32| Pattern { id: String::new(), name: String::new(), width, height, surface: Surface::new(format) }; let float = PixelFormat::new(photocraft_color::ColorMode::Rgb, SampleType::F32, true); + // What fits is expanded as before. + assert_eq!(sparse(PixelFormat::RGBA8, 10, 10).working_bytes(), 1600); + assert_eq!(sparse(float, 4096, 4096).working_bytes(), MAX_WORKING_BYTES); + // Past that, a pattern is held at its own depth. + assert_eq!(sparse(PixelFormat::RGBA8, 8000, 8000).working_bytes(), 256_000_000); + assert_eq!(sparse(PixelFormat::RGBA16, 5000, 5000).working_bytes(), 200_000_000); assert!(sparse(PixelFormat::RGBA8, 8000, 8000).is_paintable()); - assert!(sparse(float, 4096, 4096).is_paintable()); - assert!(!sparse(float, 4097, 4096).is_paintable()); + assert!(sparse(PixelFormat::RGBA16, 5792, 5792).is_paintable()); + assert!(!sparse(PixelFormat::RGBA16, 8000, 8000).is_paintable(), "512 MB at 16 bits"); + assert!(!sparse(float, 4097, 4096).is_paintable(), "float has no smaller exact form"); assert!(!sparse(PixelFormat::RGBA8, 9000, 8000).is_paintable()); assert!(!sparse(PixelFormat::RGBA8, 0, 8).is_paintable()); - assert_eq!(sparse(PixelFormat::RGBA8, 10, 10).working_bytes(), 400); - assert_eq!(sparse(float, 10, 10).working_bytes(), 1600); } } diff --git a/crates/engine/src/pattern_cmds.rs b/crates/engine/src/pattern_cmds.rs index e2da7bc08e..455cc8315d 100644 --- a/crates/engine/src/pattern_cmds.rs +++ b/crates/engine/src/pattern_cmds.rs @@ -179,9 +179,9 @@ fn composite_surface(doc: &Document, r: Rect) -> (Surface, bool) { } /// [`composite_surface`], `band_rows` rows at a time. Rendering and converting one band at a -/// time, and noting opacity as it goes, keeps a large pattern (16 MP is 256 MiB per full `f32` -/// copy) from holding several full-size copies at once. Opacity is judged on the stored samples, -/// as the surface quantizes them: an alpha of 0.999 in an 8-bit document is stored as opaque. +/// time, and noting opacity as it goes, keeps an 8000 × 8000 pattern (64 MP, about 1 GB per full +/// `f32` copy) from holding several full-size copies at once. Opacity is judged on the stored +/// samples, as the surface quantizes them: an alpha of 0.999 in an 8-bit document is stored as 255. fn composite_surface_in_bands(doc: &Document, r: Rect, band_rows: u32) -> (Surface, bool) { let df = doc.pixel_format(); let fmt = PixelFormat::new(df.mode, df.sample, true); @@ -223,9 +223,9 @@ pub fn default_name(s: &Session) -> String { (2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or(base) } -/// Longest edge Define Pattern accepts. Its area is limited too, to what can be painted: above -/// 8 bits per channel a pattern's working copy is 16 bytes a pixel, and a larger one would define -/// but paint nothing (`Pattern::is_paintable`). +/// Longest edge Define Pattern accepts. Its area is limited too, to what the compositor holds +/// (`Pattern::working_bytes`): 8000 x 8000 at 8 bits, and fewer pixels at 16 bits and float, +/// where the working copy is larger per pixel. What is accepted always paints. const MAX_DEFINE_EDGE: u32 = 8000; fn define(s: &mut Session, p: &Value) -> Result { @@ -246,9 +246,12 @@ fn define(s: &mut Session, p: &Value) -> Result { if r.width() > MAX_DEFINE_EDGE || r.height() > MAX_DEFINE_EDGE { return Err(bad(cmd, "patterns are limited to 8000 × 8000 px")); } - let per_pixel = if doc.pixel_format().sample == photocraft_doc::SampleType::U8 { 4 } else { 16 }; - if u64::from(r.width()) * u64::from(r.height()) * per_pixel > photocraft_doc::pattern::MAX_WORKING_BYTES { - return Err(bad(cmd, "patterns above 8 bits per channel are limited to 16,777,216 px in all (4096 × 4096)")); + // A pattern the compositor could not hold would define but paint nothing: 8 bits go to the + // edge limit, 16-bit documents to 33,554,432 px in all, float to 16,777,216 (4096 × 4096). + let sample = doc.pixel_format().sample; + let max_px = photocraft_doc::pattern::max_paint_pixels(sample); + if u64::from(r.width()) * u64::from(r.height()) > max_px { + return Err(bad(cmd, format!("patterns in a {}-bit document are limited to {max_px} px in all", sample.bytes() * 8))); } let (mut surf, opaque) = composite_surface(doc, r); // Opaque patterns drop their alpha channel (as Photoshop does). @@ -616,45 +619,49 @@ mod tests { } } - /// Review of #8902: Define Pattern accepts only what the compositor paints. Above 8 bits per - /// channel (16 bytes a pixel of working copy) an area over 4096 × 4096 is refused rather than - /// defined to render transparent; an 8-bit pattern (4 bytes a pixel) goes up to 8000 × 8000, - /// and a large one paints. + /// Review of #8902: what Define Pattern accepts, the compositor paints. Areas over + /// 4096 x 4096, past the compositor's whole-copy cap, define and fill at 8 and 16 bits (held + /// at the pattern's own depth); a depth that has no smaller exact form is refused up front + /// with its limit, instead of defining a pattern that paints nothing. #[test] - fn defined_patterns_paint_and_areas_the_compositor_would_skip_are_refused() { - for (w, h) in [(4100, 4100), (8000, 2100)] { - for depth in [16, 32] { - let mut s = Session::new(); - s.execute("file.new", json!({"width": w, "height": h, "depth": depth})).unwrap(); - let e = s.execute("edit.definePattern", json!({})).unwrap_err().to_string(); - assert!(e.contains("16,777,216"), "{w} x {h} at {depth} bits: {e}"); - assert!(s.patterns.items.iter().all(|p| p.name != "Pattern"), "{w} x {h} at {depth} bits: nothing defined"); - } + fn defined_patterns_over_4096_squared_paint_or_are_refused() { + for depth in [8, 16] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4100, "height": 4100, "depth": depth})).unwrap(); + s.execute("edit.fill", json!({"color": "#336699"})).unwrap(); + s.execute("edit.definePattern", json!({"name": "Big"})).unwrap(); + assert!(s.patterns.items.iter().any(|p| p.name == "Big" && p.is_paintable()), "depth {depth}"); + s.execute("file.new", json!({"width": 40, "height": 30, "depth": depth})).unwrap(); + s.execute("layer.newFillLayer.pattern", json!({"pattern": "Big"})).unwrap(); + let [r, g, b, a] = pixel(&s, 5, 5); + assert!(a == 1.0 && (r - 0.2).abs() < 0.01 && (g - 0.4).abs() < 0.01 && (b - 0.6).abs() < 0.01, "depth {depth}: {:?}", [r, g, b, a]); } let mut s = Session::new(); - s.execute("file.new", json!({"width": 4100, "height": 4100, "depth": 8})).unwrap(); - s.execute("edit.fill", json!({"color": "#336699"})).unwrap(); - s.execute("edit.definePattern", json!({"name": "Big"})).unwrap(); - assert!(s.patterns.items.iter().any(|p| p.name == "Big" && p.is_paintable())); - s.execute("file.new", json!({"width": 40, "height": 30, "depth": 8})).unwrap(); - s.execute("layer.newFillLayer.pattern", json!({"pattern": "Big"})).unwrap(); - let [r, g, b, a] = pixel(&s, 5, 5); - assert!(a == 1.0 && (r - 0.2).abs() < 0.01 && (g - 0.4).abs() < 0.01 && (b - 0.6).abs() < 0.01, "{:?}", [r, g, b, a]); + s.execute("file.new", json!({"width": 4100, "height": 4100, "depth": 32})).unwrap(); + let e = s.execute("edit.definePattern", json!({})).unwrap_err().to_string(); + assert!(e.contains("32-bit") && e.contains("16777216"), "{e}"); + assert!(s.patterns.items.iter().all(|p| p.name != "Pattern"), "nothing was defined"); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 6000, "height": 5600, "depth": 16})).unwrap(); + let e = s.execute("edit.definePattern", json!({})).unwrap_err().to_string(); + assert!(e.contains("16-bit") && e.contains("33554432"), "{e}"); } - /// An 8-bit pattern of the largest area Define Pattern accepts is paintable: it is held at - /// 4 bytes a pixel, 256 MB, as the compositor's cap allows. + /// A pattern of the largest area Define Pattern accepts at 8 bits is paintable: held at 4 + /// bytes a pixel, 256 MB, as the compositor's cap allows, and it renders. #[test] - fn an_8000_by_8000_pattern_is_defined_and_paintable() { + fn an_8000_by_8000_pattern_is_defined_and_paints() { let mut s = Session::new(); s.execute("file.new", json!({"width": 8000, "height": 8000, "depth": 8})).unwrap(); + s.execute("edit.fill", json!({"color": "#336699"})).unwrap(); s.execute("edit.definePattern", json!({"name": "Largest"})).unwrap(); let p = s.patterns.items.iter().find(|p| p.name == "Largest").unwrap(); assert_eq!((p.width, p.height), (8000, 8000)); - assert!(p.is_paintable() && p.working_bytes() <= photocraft_doc::pattern::MAX_WORKING_BYTES); - let mut s16 = Session::new(); - s16.execute("file.new", json!({"width": 8000, "height": 8000, "depth": 16})).unwrap(); - assert!(s16.execute("edit.definePattern", json!({})).is_err(), "above 8 bits it would need 1 GB"); + assert!(p.is_paintable() && p.working_bytes() == 256_000_000); + s.execute("file.new", json!({"width": 40, "height": 30, "depth": 8})).unwrap(); + s.execute("layer.newFillLayer.pattern", json!({"pattern": "Largest"})).unwrap(); + let [r, g, b, a] = pixel(&s, 5, 5); + assert!(a == 1.0 && (r - 0.2).abs() < 0.01 && (g - 0.4).abs() < 0.01 && (b - 0.6).abs() < 0.01, "{:?}", [r, g, b, a]); } #[test] diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 2d60a0503f..c183b46b54 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -83,7 +83,7 @@ pub const RESIDENT_BUDGET: u64 = 4 << 30; /// current frame paints with always stay). pub const PATTERN_BUDGET: u64 = 256 << 20; /// The most a single pattern's RGBA32F texture may take. A pattern over it paints on the CPU, which -/// holds an 8-bit pattern at 4 bytes a pixel instead of 16 (`Pattern::is_paintable`). +/// holds a large 8-bit pattern at 4 bytes a pixel instead of 16 (`Pattern::working_bytes`). const MAX_PATTERN_TEXTURE_BYTES: u64 = 256 << 20; /// A pattern texture no frame has painted with for this many frames is dropped. pub const PATTERN_IDLE_FRAMES: u64 = 240; @@ -1039,9 +1039,8 @@ impl Compositor { Ok(()) } - /// A pattern's texture must fit the device and the pattern budget. The CPU compositor holds an - /// 8-bit pattern at a quarter of the texture's 16 bytes a pixel, so a larger one (up to - /// Define Pattern's 8000 × 8000) renders there instead. + /// A pattern's texture must fit the device and the per-pattern cap; a larger one renders on + /// the CPU compositor. fn check_pattern(&self, pat: &Pattern) -> Result<(), Unsupported> { if pat.width > self.device_max || pat.height > self.device_max { return Err(Unsupported(format!("pattern `{}` larger than the GPU texture limit", pat.name))); diff --git a/crates/gpu/tests/it/parity.rs b/crates/gpu/tests/it/parity.rs index 9ea6c06c7f..b39d3a8c44 100644 --- a/crates/gpu/tests/it/parity.rs +++ b/crates/gpu/tests/it/parity.rs @@ -2581,7 +2581,7 @@ fn closed_documents_release_their_pattern_textures() { /// A pattern whose RGBA32F texture would pass the per-pattern cap (Define Pattern accepts 8-bit /// ones up to 8000 x 8000) is declined by the GPU path and painted by the CPU compositor, which -/// holds an 8-bit pattern at a quarter of the texture's size. +/// holds a large 8-bit pattern at a quarter of the texture's size. #[test] fn a_pattern_too_large_for_a_gpu_texture_falls_back_to_the_cpu() { let Some(mut g) = gpu() else { return }; From 11ae3d026ce982a5ca1235adb405d410afcfb78e Mon Sep 17 00:00:00 2001 From: Federico Olivares Date: Sat, 24 Oct 2026 20:13:56 -0300 Subject: [PATCH 63/86] Address the maintainer's review of the pattern work Align measures shape extents at the document's own sample type, a render keeps two of the largest patterns converted per call instead of rebuilding the second for every tile, a pattern too large to paint is named by every command that would use it, by pattern.list and by a PSD open warning, and a style-only edit restyles the older Photoshop's legacy text objects too, checked against the real psd-tools text.psd. --- crates/compose/src/pattern.rs | 31 +++- crates/doc/src/pattern.rs | 33 +++++ crates/engine/src/layer_multi_cmds.rs | 50 ++++--- crates/engine/src/pattern_cmds.rs | 43 +++++- crates/io/src/pattern_map.rs | 29 ++++ crates/io/src/psd_import.rs | 1 + crates/text/src/psd.rs | 199 +++++++++++++++++++++++--- crates/text/src/tests.rs | 164 +++++++++++++++++++++ 8 files changed, 508 insertions(+), 42 deletions(-) diff --git a/crates/compose/src/pattern.rs b/crates/compose/src/pattern.rs index d315a69fa9..f708842bc9 100644 --- a/crates/compose/src/pattern.rs +++ b/crates/compose/src/pattern.rs @@ -12,10 +12,11 @@ use std::sync::{Arc, Mutex, OnceLock}; use photocraft_doc::{ColorMode, Pattern, PixelFormat, Rect, SampleType}; -/// What one rendering call may keep of its patterns' working copies: room for the largest, so a -/// large pattern is converted once per call instead of once per output tile. A ceiling, not an -/// allocation: only the patterns a call paints with are converted. -pub(crate) const PREPARED_PATTERN_BYTES: usize = 256 << 20; +/// What one rendering call may keep of its patterns' working copies: room for two of the largest +/// (a layer's pattern fill and another layer's pattern overlay, say), so each is converted once +/// per call instead of once per output tile. A ceiling, not an allocation: only the patterns a +/// call paints with are converted, and a third large one past it is converted when it is asked for. +pub(crate) const PREPARED_PATTERN_BYTES: usize = 512 << 20; const MAX_PREPARED_PATTERNS: usize = 64; /// Absolute cap on a single pattern's RGBA conversion on the uncached path. A pattern this /// large cannot be cached, so re-expanding it for every output tile would be a DoS; above the @@ -23,6 +24,8 @@ const MAX_PREPARED_PATTERNS: usize = 64; const MAX_UNCACHED_PATTERN_BYTES: usize = 256 << 20; // Every paintable pattern must fit that cap, or its fill would render transparent. const _: () = assert!(photocraft_doc::pattern::MAX_WORKING_BYTES <= MAX_UNCACHED_PATTERN_BYTES as u64); +// ... and a call keeps two of the largest, or the second one's fill is rebuilt for every tile. +const _: () = assert!(2 * photocraft_doc::pattern::MAX_WORKING_BYTES <= PREPARED_PATTERN_BYTES as u64); type PreparedSlot = Arc>>>; struct PreparedState { @@ -434,6 +437,26 @@ mod tests { assert!(PreparedPatterns::new(&list, 0).get("max8", "").is_some(), "uncached too, at the cap"); } + /// A render that paints with two large patterns (a pattern fill and a pattern overlay, say) + /// converts each once, not once per output tile: both stay in the call's budget. A third that + /// does not fit is converted when asked for (correct, just not kept). + #[test] + fn two_large_patterns_are_each_converted_once_per_call() { + let sparse = |name: &str| Pattern { id: name.into(), name: name.into(), width: 5000, height: 5000, surface: Surface::new(PixelFormat::RGBA8) }; + let list = [sparse("a"), sparse("b"), sparse("c")]; + assert!(list.iter().all(|p| p.working_bytes() == 100_000_000)); + let prepared = PreparedPatterns::new(&list, 250_000_000); + let same = |name: &str| { + let (first, second) = (prepared.get(name, "").unwrap(), prepared.get(name, "").unwrap()); + Arc::ptr_eq(&first, &second) + }; + assert!(same("a"), "the first is kept"); + assert!(same("b"), "and the second"); + assert!(!same("c"), "a third past the budget is converted per use, not kept"); + // The real budget holds two of the largest paintable ones. + assert!(2 * photocraft_doc::pattern::MAX_WORKING_BYTES <= PREPARED_PATTERN_BYTES as u64); + } + /// A large pattern's compact copy samples exactly as the expanded one: every 8-bit and 16-bit /// value survives the round trip through `f32`, and a tap reads the same premultiplied numbers. #[test] diff --git a/crates/doc/src/pattern.rs b/crates/doc/src/pattern.rs index cbdc4f1459..4934e99f3a 100644 --- a/crates/doc/src/pattern.rs +++ b/crates/doc/src/pattern.rs @@ -91,6 +91,26 @@ impl Pattern { working_bytes_for(self.pixel_count(), self.surface.format().sample) } + /// Why this pattern paints nothing, for the person or agent who chose it; `None` when it + /// paints. A pattern too large for the compositor's working copy renders as transparent, so + /// every command that picks a pattern, and the import that brought it in, says so. + pub fn paint_problem(&self) -> Option { + if self.is_paintable() { + return None; + } + if self.is_empty() { + return Some("it is empty".into()); + } + let sample = self.surface.format().sample; + Some(format!( + "it is {} × {} px at {} bits per channel, and a pattern of that depth paints up to {} px in all", + self.width, + self.height, + sample.bytes() * 8, + max_paint_pixels(sample) + )) + } + /// Whether the tile has pixels and is small enough to paint (see [`MAX_PAINT_PIXELS`] and /// [`MAX_WORKING_BYTES`]). pub fn is_paintable(&self) -> bool { @@ -207,4 +227,17 @@ mod tests { assert!(!sparse(PixelFormat::RGBA8, 9000, 8000).is_paintable()); assert!(!sparse(PixelFormat::RGBA8, 0, 8).is_paintable()); } + + #[test] + fn a_pattern_that_paints_nothing_says_why() { + let sparse = + |format: PixelFormat, width: u32, height: u32| Pattern { id: String::new(), name: String::new(), width, height, surface: Surface::new(format) }; + let float = PixelFormat::new(photocraft_color::ColorMode::Rgb, SampleType::F32, true); + assert_eq!(sparse(PixelFormat::RGBA8, 8000, 8000).paint_problem(), None); + assert_eq!(sparse(PixelFormat::RGBA8, 0, 4).paint_problem().as_deref(), Some("it is empty")); + let reason = sparse(float, 5000, 5000).paint_problem().expect("float 5000 x 5000 paints nothing"); + assert!(reason.contains("5000 × 5000") && reason.contains("32 bits") && reason.contains("16777216"), "{reason}"); + let reason = sparse(PixelFormat::RGBA16, 8000, 8000).paint_problem().expect("16-bit 8000 x 8000 paints nothing"); + assert!(reason.contains("16 bits") && reason.contains("33554432"), "{reason}"); + } } diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index ae9b92ab8f..8d45bcbb7f 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -5,7 +5,7 @@ //! The selection itself lives in [`DocState::selected_layers`] (UI state, like the active layer): //! changing it never adds a history step. Every structural change below is one history step. -use photocraft_color::PixelFormat; +use photocraft_color::{PixelFormat, SampleType}; use photocraft_doc::{Document, Layer, LayerContent, LayerId, VideoData}; use photocraft_geom::Rect; use serde_json::{Value, json}; @@ -1112,11 +1112,12 @@ pub fn layer_bounds(l: &Layer) -> Option { /// except that a shape layer is measured over everything it draws, beyond the canvas too (its /// cache is clipped to the canvas, #8660), by rendering it again. Only those commands pay for that /// render, once per click; `canvas` is the frame a shape's gradients are laid out in, so an opacity -/// stop measures as it renders. -pub(crate) fn align_bounds(l: &Layer, canvas: Rect) -> Option { +/// stop measures as it renders, and `sample` is the document's sample type (see +/// [`shape_pixel_bounds`]). +pub(crate) fn align_bounds(l: &Layer, canvas: Rect, sample: SampleType) -> Option { let r = match &l.content { - LayerContent::Group(g) => g.children.iter().filter_map(|c| align_bounds(c, canvas)).fold(Rect::EMPTY, |a, b| a.union(&b)), - LayerContent::Shape(sh) => shape_pixel_bounds(sh, canvas).unwrap_or_else(|| { + LayerContent::Group(g) => g.children.iter().filter_map(|c| align_bounds(c, canvas, sample)).fold(Rect::EMPTY, |a, b| a.union(&b)), + LayerContent::Shape(sh) => shape_pixel_bounds(sh, canvas, sample).unwrap_or_else(|| { let cached = l.surface().map(|s| s.content_bounds()).unwrap_or(Rect::EMPTY); // A fill that shows everywhere covers the canvas, rendered yet or not. if cached.is_empty() && unbounded_fill(sh) { canvas } else { cached } @@ -1145,7 +1146,7 @@ const SHAPE_BOUNDS_BAND_PX: u64 = 1 << 20; /// everything it can draw (its path, stroke and feather), unclipped, and measured from that. A fill /// that shows everywhere (an inverted path, or below 100 % density) has no extent of its own and /// keeps the canvas-clipped measure (`None`). -fn shape_pixel_bounds(sh: &photocraft_doc::ShapeLayer, canvas: Rect) -> Option { +fn shape_pixel_bounds(sh: &photocraft_doc::ShapeLayer, canvas: Rect, sample: SampleType) -> Option { if unbounded_fill(sh) { return None; } @@ -1156,10 +1157,11 @@ fn shape_pixel_bounds(sh: &photocraft_doc::ShapeLayer, canvas: Rect) -> Option Vec<(LayerId, Rect)> { - top_level(doc, sel).into_iter().filter_map(|id| Some((id, align_bounds(doc.layer(id)?, doc.bounds())?))).collect() + top_level(doc, sel).into_iter().filter_map(|id| Some((id, align_bounds(doc.layer(id)?, doc.bounds(), doc.pixel_format().sample)?))).collect() } fn align(s: &mut Session, p: &Value, kind: &str) -> Result { @@ -2516,10 +2518,10 @@ mod tests { #[test] fn align_uses_a_shape_layers_full_extent_off_the_canvas() { let mut s = session(8); - let drawn = |s: &Session, id: LayerId| align_bounds(doc(s).layer(id).unwrap(), doc(s).bounds()).unwrap(); + let drawn = |s: &Session, id: LayerId| align_bounds(doc(s).layer(id).unwrap(), doc(s).bounds(), SampleType::U8).unwrap(); let shape = LayerId(s.execute("shape.create", json!({"kind": "rect", "rect": [-20, 10, 40, 20], "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap()); - let measured = align_bounds(doc(&s).layer(shape).unwrap(), doc(&s).bounds()); + let measured = align_bounds(doc(&s).layer(shape).unwrap(), doc(&s).bounds(), SampleType::U8); assert_eq!(measured, Some(Rect::new(-20, 10, 20, 30)), "Align: the shape's own extent, not the canvas-clipped render"); // Everything else keeps the cheap measure of the cached render (no re-render per layer). assert_eq!(bounds(&s, shape), Rect::new(0, 10, 20, 30), "layer_bounds: the cache, clipped to the canvas"); @@ -2557,20 +2559,26 @@ mod tests { }) .unwrap(); let LayerContent::Shape(sh) = &doc(&s).layer(soft).unwrap().content else { panic!("shape") }; - assert_eq!(shape_pixel_bounds(sh, doc(&s).bounds()), None); + assert_eq!(shape_pixel_bounds(sh, doc(&s).bounds(), SampleType::U8), None); // Without a cached render yet, such a fill still measures as the canvas, not as empty. let mut uncached = doc(&s).layer(soft).unwrap().clone(); if let LayerContent::Shape(sh) = &mut uncached.content { sh.cache = None; } - assert_eq!(align_bounds(&uncached, doc(&s).bounds()), Some(doc(&s).bounds())); + assert_eq!(align_bounds(&uncached, doc(&s).bounds(), SampleType::U8), Some(doc(&s).bounds())); } - /// Review of #8779: a faint stroke of a 16-bit or float document counts in the shape's extent, - /// so Align moves the opaque body by the same amount as the cached render's bounds would. + /// Review of #8779: shape extents are measured at the document's own alpha precision. A faint + /// stroke (0.001 opacity) is stored by a 16-bit or float document, so it counts in the extent + /// and Align moves the opaque body as the document draws it; an 8-bit document stores it as + /// nothing, so it does not widen the measure. #[test] - fn align_bounds_keep_a_faint_stroke_at_high_depth() { - for depth in [16, 32] { + fn align_bounds_follow_the_documents_alpha_precision() { + for (depth, sample, expected) in [ + (8, SampleType::U8, Rect::new(60, 60, 120, 120)), + (16, SampleType::U16, Rect::new(50, 50, 130, 130)), + (32, SampleType::F32, Rect::new(50, 50, 130, 130)), + ] { let mut s = session(depth); let id = LayerId(s.execute("shape.create", json!({"kind": "rect", "rect": [60, 60, 60, 60], "fill": "#000000"})).unwrap()["layer"].as_u64().unwrap()); @@ -2581,8 +2589,10 @@ mod tests { Ok(()) }) .unwrap(); + assert_eq!(doc(&s).pixel_format().sample, sample); let LayerContent::Shape(sh) = &doc(&s).layer(id).unwrap().content else { panic!("shape") }; - assert_eq!(shape_pixel_bounds(sh, doc(&s).bounds()), Some(Rect::new(50, 50, 130, 130)), "depth {depth}"); + assert_eq!(shape_pixel_bounds(sh, doc(&s).bounds(), sample), Some(expected), "depth {depth}"); + assert_eq!(align_bounds(doc(&s).layer(id).unwrap(), doc(&s).bounds(), sample), Some(expected), "depth {depth}"); } } diff --git a/crates/engine/src/pattern_cmds.rs b/crates/engine/src/pattern_cmds.rs index 455cc8315d..7ddea17685 100644 --- a/crates/engine/src/pattern_cmds.rs +++ b/crates/engine/src/pattern_cmds.rs @@ -127,7 +127,17 @@ pub fn resolve(s: &Session, key: &str) -> Option { pub(crate) fn resolve_param(s: &Session, cmd: &str, p: &Value) -> Result { let key = p.get("pattern").and_then(Value::as_str).unwrap_or(""); - resolve(s, key).ok_or_else(|| bad(cmd, format!("no pattern \"{key}\" (see pattern.list)"))) + let pat = resolve(s, key).ok_or_else(|| bad(cmd, format!("no pattern \"{key}\" (see pattern.list)")))?; + paintable(cmd, pat) +} + +/// `pat`, or the reason it would paint nothing: a pattern too large for the compositor renders as +/// transparent, so a command that would use one says so up front. +fn paintable(cmd: &str, pat: Pattern) -> Result { + match pat.paint_problem() { + Some(why) => Err(bad(cmd, format!("pattern \"{}\" would paint nothing: {why}", pat.display_name()))), + None => Ok(pat), + } } /// Copies `pat` into the document's patterns unless it is already there (by id). @@ -146,6 +156,7 @@ pub fn resolve_effect(s: &Session, fx: photocraft_doc::Effect) -> Result<(photoc Effect::PatternOverlay { common, name, id, scale, angle, link, phase } => { let key = if id.is_empty() { name } else { id }; let pat = resolve(s, &key).ok_or_else(|| bad("layer.layerStyle.patternOverlay", format!("no pattern \"{key}\" (see pattern.list)")))?; + let pat = paintable("layer.layerStyle.patternOverlay", pat)?; Ok((Effect::PatternOverlay { common, name: pat.name.clone(), id: pat.id.clone(), scale, angle, link, phase }, Some(pat))) } other => Ok((other, None)), @@ -158,6 +169,7 @@ fn info(p: &Pattern, in_doc: bool, in_library: bool) -> Value { "id": p.id, "name": p.display_name(), "storedName": p.name, "width": p.width, "height": p.height, "mode": format!("{:?}", f.mode), "depth": format!("{:?}", f.sample), "alpha": f.alpha, "inDocument": in_doc, "inLibrary": in_library, + "paintable": p.is_paintable(), "paintProblem": p.paint_problem(), }) } @@ -664,6 +676,33 @@ mod tests { assert!(a == 1.0 && (r - 0.2).abs() < 0.01 && (g - 0.4).abs() < 0.01 && (b - 0.6).abs() < 0.01, "{:?}", [r, g, b, a]); } + /// Review of #8902: a pattern too large to paint is named by every command that would use it + /// and by pattern.list, instead of rendering as transparency with no message. + #[test] + fn a_pattern_that_would_paint_nothing_is_reported() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30, "depth": 8})).unwrap(); + // A 32-bit 5000 x 5000 pattern (declared only: 400 MB as a working copy, past the cap). + let surface = photocraft_raster::Surface::new(photocraft_doc::PixelFormat::RGBA32F); + s.patterns.items.push(Pattern { id: "huge".into(), name: "Huge".into(), width: 5000, height: 5000, surface }); + let list = s.execute("pattern.list", json!({})).unwrap(); + let huge = list.as_array().unwrap().iter().find(|p| p["name"] == "Huge").unwrap().clone(); + assert_eq!(huge["paintable"], json!(false)); + assert!(huge["paintProblem"].as_str().unwrap().contains("32 bits"), "{huge}"); + for (id, params) in [ + ("layer.newFillLayer.pattern", json!({"pattern": "Huge"})), + ("layer.layerStyle.patternOverlay", json!({"pattern": "Huge"})), + ("edit.fill", json!({"contents": "pattern", "pattern": "Huge"})), + ("brush.texturePattern", json!({"pattern": "Huge"})), + ] { + let e = s.execute(id, params).expect_err(id).to_string(); + assert!(e.contains("Huge") && e.contains("would paint nothing"), "{id}: {e}"); + } + let e = s.execute("layer.newFillLayer.pattern", json!({"pattern": "Huge"})).unwrap_err().to_string(); + assert!(e.contains("would paint nothing") && e.contains("5000 × 5000"), "{e}"); + assert!(list.as_array().unwrap().iter().any(|p| p["name"] != "Huge" && p["paintable"] == json!(true)), "built-in patterns paint"); + } + #[test] fn define_pattern_accepts_edges_over_4000_up_to_8000() { for (w, ok) in [(4100, true), (8000, true), (8001, false)] { @@ -766,6 +805,6 @@ mod tests { let huge = Pattern { id: "huge".into(), name: "Huge".into(), width: 65_536, height: 65_536, surface: Surface::new(PixelFormat::RGBA8) }; s.patterns.items.push(huge); let e = s.execute("brush.texturePattern", json!({"pattern": "huge"})).unwrap_err(); - assert!(e.to_string().contains("too large"), "{e}"); + assert!(e.to_string().contains("would paint nothing") && e.to_string().contains("65536 × 65536"), "{e}"); } } diff --git a/crates/io/src/pattern_map.rs b/crates/io/src/pattern_map.rs index eeda1943cc..4f0b5fe54d 100644 --- a/crates/io/src/pattern_map.rs +++ b/crates/io/src/pattern_map.rs @@ -110,6 +110,12 @@ pub fn to_psd(p: &Pattern) -> PsdPattern { } } +/// A warning for each of `patterns` too large to paint: such a pattern fills with transparency, +/// so the file says so on opening. +pub fn unpaintable_warnings(patterns: &[Pattern]) -> Vec { + patterns.iter().filter_map(|p| Some(format!("pattern \"{}\" would paint nothing: {}", p.display_name(), p.paint_problem()?))).collect() +} + /// Patterns decoded from the document's raw pattern blocks (malformed blocks are skipped). All /// blocks share one memory budget, so repeating a block can't multiply it. pub fn from_global_blocks(doc: &Document) -> Vec { @@ -278,3 +284,26 @@ mod tests { assert_eq!(export_global_blocks(&back), back.metadata.psd_global_blocks); } } + +#[cfg(test)] +mod unpaintable_tests { + use super::*; + use photocraft_doc::PixelFormat; + use photocraft_raster::Surface; + + #[test] + fn a_pattern_too_large_to_paint_is_named_on_opening() { + let sparse = |name: &str, format: PixelFormat, side: u32| Pattern { + id: name.into(), + name: name.into(), + width: side, + height: side, + surface: Surface::new(format), + }; + let patterns = + [sparse("Small", PixelFormat::RGBA8, 64), sparse("Huge float", PixelFormat::RGBA32F, 5000), sparse("Largest 8-bit", PixelFormat::RGBA8, 8000)]; + let warnings = unpaintable_warnings(&patterns); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert!(warnings[0].contains("Huge float") && warnings[0].contains("would paint nothing") && warnings[0].contains("32 bits"), "{warnings:?}"); + } +} diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 717f848716..24b3935f40 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -985,6 +985,7 @@ pub(crate) fn psd_to_document_decoded_integrity( )); } doc.patterns = crate::pattern_map::from_global_blocks(&doc); + warnings.extend(crate::pattern_map::unpaintable_warnings(&doc.patterns)); // Notes (`Anno`) and the measurement scale (resource 1074); raw data stays for verbatim export. doc.notes = crate::annotations_map::notes_from_blocks(&doc); if crate::annotations_map::anno_unreadable(&doc) { diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 038df7712b..b36dbd6be6 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -1031,19 +1031,9 @@ fn txt2_mode_runs(layer: &TextLayer) -> Vec<(i64, usize)> { out } -/// `Txt2` style runs for `layer`, one per character run: `base` (one of the file's style runs, -/// `<< /0 << /0 << /6 style >> >> /1 length >>`, every key Photoshop wrote) with its style set -/// from the run's own: font (`/0`, an index into `fonts`, the block's `/0 /1 /0` font list, which -/// a missing font is appended to), size (`/1`), faux bold and italic, leading, scales and tracking -/// (`/2`–`/8`, EngineData's order), fill colour (`/53`), the OpenType keys and the auto-kern mode -/// (`/11`). Photoshop reads the character style from here rather than from EngineData, and quit -/// on a run holding only the auto-kern mode. `None` when `base` has no style dictionary or is -/// larger than a template may be (it is copied once per run). -fn txt2_style_runs(layer: &TextLayer, base: &E, fonts: &mut Vec, dpi: f32) -> Option> { - if !matches!(base.path(&["0", "0", "6"]), Some(E::Dict(_))) || !fits(base, &mut { TEMPLATE_NODES }) { - return None; - } - let k = if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 }; +/// The engine text of `layer` (`\r` breaks and the trailing `\r`) as `(character run, UTF-16 +/// length)` spans: one per run that has text. +fn char_spans(layer: &TextLayer) -> Vec<(usize, i64)> { let runs = layer.char_runs(); let ed_text = format!("{}\r", layer.text.replace('\n', "\r")); let mut spans: Vec<(usize, i64)> = Vec::new(); @@ -1059,6 +1049,24 @@ fn txt2_style_runs(layer: &TextLayer, base: &E, fonts: &mut Vec, dpi: f32) -> _ => spans.push((ri, n)), } } + spans +} + +/// `Txt2` style runs for `layer`, one per character run: `base` (one of the file's style runs, +/// `<< /0 << /0 << /6 style >> >> /1 length >>`, every key Photoshop wrote) with its style set +/// from the run's own: font (`/0`, an index into `fonts`, the block's `/0 /1 /0` font list, which +/// a missing font is appended to), size (`/1`), faux bold and italic, leading, scales and tracking +/// (`/2`–`/8`, EngineData's order), fill colour (`/53`), the OpenType keys and the auto-kern mode +/// (`/11`). Photoshop reads the character style from here rather than from EngineData, and quit +/// on a run holding only the auto-kern mode. `None` when `base` has no style dictionary or is +/// larger than a template may be (it is copied once per run). +fn txt2_style_runs(layer: &TextLayer, base: &E, fonts: &mut Vec, dpi: f32) -> Option> { + if !matches!(base.path(&["0", "0", "6"]), Some(E::Dict(_))) || !fits(base, &mut { TEMPLATE_NODES }) { + return None; + } + let k = if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 }; + let runs = layer.char_runs(); + let spans = char_spans(layer); let default = CharStyle::default(); let mut out = Vec::with_capacity(spans.len()); let mut looked_up = std::collections::HashMap::new(); @@ -1106,7 +1114,24 @@ fn txt2_style_runs(layer: &TextLayer, base: &E, fonts: &mut Vec, dpi: f32) -> fn txt2_style_matches(layer: &TextLayer, model: &E, fonts: &[E], dpi: f32) -> bool { let Some(file_runs) = model.path(&["6", "0"]).and_then(E::as_array) else { return true }; let k = if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 }; - let run_len = |i: usize| file_runs.get(i).and_then(|r| r.get("1")).and_then(E::as_i64).unwrap_or(0); + style_runs_match(layer, file_runs, "1", &["0", "0", "6"], |s, style| txt2_style_agrees(s, style, fonts, k)) +} + +/// [`txt2_style_matches`] for the older Photoshop's object at `/DocumentObjects /TextObjects` +/// (`entry`): its `Model /StyleRun /RunArray` runs hold the style under `RunData /StyleSheet +/// /Features`, named keys, with the run length under `Length`. `fonts` is the block's +/// `DocumentResources /FontSet /Resources`. +fn td_style_matches(layer: &TextLayer, entry: &E, fonts: &[E], dpi: f32) -> bool { + let Some(file_runs) = entry.path(&["Model", "StyleRun", "RunArray"]).and_then(E::as_array) else { return true }; + let k = if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 }; + style_runs_match(layer, file_runs, "Length", &["RunData", "StyleSheet", "Features"], |s, style| td_style_agrees(s, style, fonts, k)) +} + +/// Whether `file_runs` (a run array whose runs hold their length under `len_key` and their style +/// at `style_path`) agree, run for run over the layer's engine text, with the layer's character +/// runs by `agrees`. A run without a style dictionary says nothing. +fn style_runs_match(layer: &TextLayer, file_runs: &[E], len_key: &str, style_path: &[&str], agrees: impl Fn(&CharStyle, &E) -> bool) -> bool { + let run_len = |i: usize| file_runs.get(i).and_then(|r| r.get(len_key)).and_then(E::as_i64).unwrap_or(0); let runs = layer.char_runs(); let default = CharStyle::default(); let ed_text = format!("{}\r", layer.text.replace('\n', "\r")); @@ -1125,8 +1150,8 @@ fn txt2_style_matches(layer: &TextLayer, model: &E, fonts: &[E], dpi: f32) -> bo } if compared != Some((ri, fi)) { let s = runs.get(ri).map_or(&default, |r| &r.style); - if let Some(style) = file_runs.get(fi).and_then(|r| r.path(&["0", "0", "6"])) - && !txt2_style_agrees(s, style, fonts, k) + if let Some(style) = file_runs.get(fi).and_then(|r| r.path(style_path)) + && !agrees(s, style) { return false; } @@ -1180,6 +1205,130 @@ fn txt2_style_agrees(s: &CharStyle, style: &E, fonts: &[E], k: f32) -> bool { style.get(TXT2_STYLISTIC_SETS).and_then(E::as_i64).filter(|m| (0..1 << 20).contains(m)).is_none_or(|m| m == sets) } +/// The OpenType flags of a legacy style (`Features`), by key, and the feature each turns on: the +/// named keys of [`TXT2_FEATURES`]. +const TD_FEATURES: [(&str, &str); 7] = [ + ("ContextualLigatures", "calt"), + ("Fractions", "frac"), + ("Ordinals", "ordn"), + ("Swash", "swsh"), + ("Titling", "titl"), + ("StylisticAlternates", "salt"), + ("Ornaments", "ornm"), +]; + +/// The legacy key of the Stylistic Sets bit mask, as [`TXT2_STYLISTIC_SETS`] is for `Txt2`'s own. +const TD_STYLISTIC_SETS: &str = "StylisticSets"; + +/// The PostScript name of font `index` in a legacy font list (`DocumentResources /FontSet +/// /Resources`: `<< /Resource << /StreamTag /CoolTypeFont /Identifier << /Name (name) /Type n >> >> >>`). +fn td_font_name(fonts: &[E], index: i64) -> Option<&str> { + fonts.get(usize::try_from(index).ok()?)?.path(&["Resource", "Identifier", "Name"])?.as_str() +} + +/// Whether one legacy `Features` dictionary agrees with `s`: [`txt2_style_agrees`] for the named +/// keys. A key the file's run lacks, or holds as another type, is no evidence of an edit. +fn td_style_agrees(s: &CharStyle, style: &E, fonts: &[E], k: f32) -> bool { + let close = |a: f64, b: f64| (a - b).abs() <= 1e-3 * a.abs().max(b.abs()).max(1.0); + let number = |key: &str, want: f32| style.get(key).and_then(E::as_f64).is_none_or(|v| close(v, f64::from(want))); + let flag = |key: &str, want: bool| style.get(key).and_then(E::as_bool).is_none_or(|v| v == want); + if style.get("Font").and_then(E::as_i64).and_then(|i| td_font_name(fonts, i)).is_some_and(|name| name != postscript_for(s)) { + return false; + } + let auto_leading = s.leading_pt.is_none(); + if !(number("FontSize", s.size_pt * k) && flag("FauxBold", s.faux_bold) && flag("FauxItalic", s.faux_italic) && flag("AutoLeading", auto_leading)) { + return false; + } + if style.get("AutoLeading").and_then(E::as_bool) == Some(false) && !auto_leading && !number("Leading", s.leading_pt.unwrap_or(0.0) * k) { + return false; + } + if !(number("HorizontalScale", s.horizontal_scale) && number("VerticalScale", s.vertical_scale) && number("Tracking", s.tracking.round())) { + return false; + } + if let Some(paint) = style.get("FillColor").and_then(|p| p.get("Color")) { + let (kind, values) = fill_color(&s.color); + if paint.get("Type").and_then(E::as_i64).is_some_and(|v| v != kind) { + return false; + } + if let Some(have) = paint.get("Values").and_then(E::as_array) { + let same = + have.len() == values.len() && have.iter().zip(&values).all(|(a, b)| matches!((a.as_f64(), b.as_f64()), (Some(a), Some(b)) if close(a, b))); + if !same { + return false; + } + } + } + let feature = |tag: &str| s.features.iter().find(|f| f.tag == tag).map(|f| f.value > 0); + if !TD_FEATURES.iter().all(|&(key, tag)| flag(key, feature(tag).unwrap_or(tag == "calt"))) { + return false; + } + let sets = (0..20).filter(|n| feature(&format!("ss{:02}", n + 1)) == Some(true)).fold(0i64, |m, n| m | 1 << n); + style.get(TD_STYLISTIC_SETS).and_then(E::as_i64).filter(|m| (0..1 << 20).contains(m)).is_none_or(|m| m == sets) +} + +/// The index of the PostScript font `name` in a legacy font list, appending it (shaped like the +/// list's first entry, with its font type) when missing. `None` for an empty list. +fn td_font(fonts: &mut Vec, name: &str) -> Option { + if let Some(i) = fonts.iter().position(|f| f.path(&["Resource", "Identifier", "Name"]).and_then(E::as_str) == Some(name)) { + return i64::try_from(i).ok(); + } + let mut entry = fonts.first()?.clone(); + entry.get_mut("Resource")?.get_mut("Identifier")?.set("Name", E::String(name.to_string())); + fonts.push(entry); + i64::try_from(fonts.len() - 1).ok() +} + +/// `entry` (an object at `/DocumentObjects /TextObjects`) with its `Model /StyleRun` runs remade +/// from the layer's character runs: the entry's first style run with its `Features` set from each +/// run's own, every other key Photoshop wrote kept, as [`txt2_style_runs`] does for the numbered +/// objects. Photoshop re-renders a type layer from this copy when the file has one, so a +/// style-only edit left here showed the old style on reopen. `None` when the first run has no +/// `Features` dictionary, or is larger than a template may be: the entry is then left as the file +/// has it. +fn td_restyled(entry: &E, layer: &TextLayer, fonts: &mut Vec, dpi: f32) -> Option { + let base = entry.path(&["Model", "StyleRun", "RunArray"])?.as_array()?.first()?; + if !matches!(base.path(&["RunData", "StyleSheet", "Features"]), Some(E::Dict(_))) || !fits(base, &mut { TEMPLATE_NODES }) { + return None; + } + let k = if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 }; + let runs = layer.char_runs(); + let default = CharStyle::default(); + let mut out = Vec::new(); + for (ri, n) in char_spans(layer) { + let s = runs.get(ri).map_or(&default, |r| &r.style); + let mut run = base.clone(); + let features = run.get_mut("RunData")?.get_mut("StyleSheet")?.get_mut("Features")?; + if let Some(font) = td_font(fonts, &postscript_for(s)) { + features.set("Font", E::Int(font)); + } + features.set("FontSize", real(s.size_pt * k)); + features.set("FauxBold", E::Bool(s.faux_bold)); + features.set("FauxItalic", E::Bool(s.faux_italic)); + features.set("AutoLeading", E::Bool(s.leading_pt.is_none())); + features.set("Leading", real(s.leading_pt.unwrap_or(0.0) * k)); + features.set("HorizontalScale", real(s.horizontal_scale)); + features.set("VerticalScale", real(s.vertical_scale)); + features.set("Tracking", E::Int(s.tracking.round() as i64)); + features.set("AutoKern", E::Int(txt2_mode(s))); + if let Some(paint @ E::Dict(_)) = features.get_mut("FillColor").and_then(|p| p.get_mut("Color")) { + let (kind, values) = fill_color(&s.color); + paint.set("Type", E::Int(kind)); + paint.set("Values", E::Array(values)); + } + let feature = |tag: &str| s.features.iter().find(|f| f.tag == tag).map(|f| f.value > 0); + for (key, tag) in TD_FEATURES { + features.set(key, E::Bool(feature(tag).unwrap_or(tag == "calt"))); + } + let sets = (0..20).filter(|n| feature(&format!("ss{:02}", n + 1)) == Some(true)).fold(0i64, |m, n| m | 1 << n); + features.set(TD_STYLISTIC_SETS, E::Int(sets)); + run.set("Length", E::Int(n)); + out.push(run); + } + let mut entry = entry.clone(); + entry.get_mut("Model")?.get_mut("StyleRun")?.set("RunArray", E::Array(out)); + Some(entry) +} + /// The index of the PostScript font `name` in a `Txt2` font list (`<< /0 << /99 /CoolTypeFont /// /0 << /0 name /2 type >> >> >>` entries), appending it (shaped like the list's first entry) /// when missing. `None` for an empty list: there is no entry to shape a new one on. @@ -1250,6 +1399,10 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi // in place, never grown: a layer the block has no entry for has no stale copy to fix. let mut td: Option> = prev.as_ref().and_then(|p| p.path(&["DocumentObjects", "TextObjects"])).and_then(E::as_array).map(<[E]>::to_vec); let mut td_changed = false; + // The legacy block's font list (`DocumentResources /FontSet /Resources`), which its style runs index. + let mut td_fonts: Vec = + prev.as_ref().and_then(|p| p.path(&["DocumentResources", "FontSet", "Resources"])).and_then(E::as_array).map(<[E]>::to_vec).unwrap_or_default(); + let td_fonts_before = td_fonts.len(); let mut fonts: Vec = prev.as_ref().and_then(|p| p.path(&["0", "1", "0"]).and_then(E::as_array)).map(<[E]>::to_vec).unwrap_or_default(); let fonts_before = fonts.len(); // Looked up once per save, not per layer: a crafted block with many objects must not cost @@ -1278,6 +1431,15 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi *slot = fixed; td_changed = true; } + // And its character style, when it no longer says what the layer says: a style-only + // edit (font, size, faux bold, …) left the old style here, and Photoshop reads it back. + if let Some(entry) = entries.get_mut(at) + && !td_style_matches(layer, entry, &td_fonts, dpi) + && let Some(restyled) = td_restyled(entry, layer, &mut td_fonts, dpi) + { + *entry = restyled; + td_changed = true; + } } // A stub an earlier save wrote beside Photoshop's objects counts as no object at all, so // it is replaced like a missing one (below) and re-saving such a file repairs it, whether @@ -1356,6 +1518,11 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi && let Some(objects @ E::Dict(_)) = outer.get_mut("DocumentObjects") { objects.set("TextObjects", E::Array(entries)); + if td_fonts.len() != td_fonts_before + && let Some(set @ E::Dict(_)) = outer.get_mut("DocumentResources").and_then(|r| r.get_mut("FontSet")) + { + set.set("Resources", E::Array(td_fonts)); + } } // Nothing changed: the file's own bytes (writing them back would re-encode strings and numbers). if let (Some(bytes), Some(p)) = (previous, &prev) diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index b46fb5688d..a179bf271f 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -3161,3 +3161,167 @@ fn per_colour_passes_match_full_size_passes() { } } } + +/// A legacy (Photoshop-era) `Txt2` block shaped like a real one (psd-tools' `text.psd`): the +/// fonts in `DocumentResources /FontSet /Resources`, and each object's `Model /StyleRun` runs +/// holding their style under `RunData /StyleSheet /Features` (named keys) beside `Length`. +fn styled_era_txt2(text: &str, font: &str, size: f64) -> Vec { + use crate::engine_data::Value as E; + let font_entry = |name: &str| { + E::Dict(vec![( + "Resource".into(), + E::Dict(vec![ + ("StreamTag".into(), E::Name("CoolTypeFont".into())), + ("Identifier".into(), E::Dict(vec![("Name".into(), E::String(name.into())), ("Type".into(), E::Int(1))])), + ]), + )]) + }; + let features = E::Dict(vec![ + ("Font".into(), E::Int(0)), + ("FontSize".into(), E::Real(size)), + ("FauxBold".into(), E::Bool(false)), + ("FauxItalic".into(), E::Bool(false)), + ("AutoLeading".into(), E::Bool(true)), + ("Leading".into(), E::Real(0.0)), + ("HorizontalScale".into(), E::Real(1.0)), + ("VerticalScale".into(), E::Real(1.0)), + ("Tracking".into(), E::Int(0)), + ("AutoKern".into(), E::Int(1)), + ("Ligatures".into(), E::Bool(true)), + ("ContextualLigatures".into(), E::Bool(true)), + ("StylisticSets".into(), E::Int(0)), + ( + "FillColor".into(), + E::Dict(vec![ + ("StreamTag".into(), E::Name("SimplePaint".into())), + ( + "Color".into(), + E::Dict(vec![("Type".into(), E::Int(1)), ("Values".into(), E::Array(vec![E::Real(1.0), E::Real(0.0), E::Real(0.0), E::Real(0.0)]))]), + ), + ]), + ), + ]); + let total = format!("{text}\r").encode_utf16().count() as i64; + let run = |len: i64, data: E| E::Dict(vec![("RunData".into(), data), ("Length".into(), E::Int(len))]); + let placeholder = || E::Dict(vec![("0".into(), E::String(String::new()))]); + let style_data = E::Dict(vec![( + "StyleSheet".into(), + E::Dict(vec![("Name".into(), E::String(String::new())), ("Parent".into(), E::Int(0)), ("Features".into(), features)]), + )]); + let entry = E::Dict(vec![ + ( + "Model".into(), + E::Dict(vec![ + ("Text".into(), E::String(format!("{text}\r"))), + ("ParagraphRun".into(), E::Dict(vec![("RunArray".into(), E::Array(vec![run(total, placeholder())]))])), + ("StyleRun".into(), E::Dict(vec![("RunArray".into(), E::Array(vec![run(total, style_data)]))])), + ("KernRun".into(), E::Dict(vec![("RunArray".into(), E::Array(vec![run(total, placeholder())]))])), + ]), + ), + ("View".into(), E::Dict(vec![("0".into(), E::Array(vec![E::Dict(vec![("0".into(), E::Int(0))])]))])), + ]); + crate::engine_data::write_bare(&[ + ("DocumentResources".into(), E::Dict(vec![("FontSet".into(), E::Dict(vec![("Resources".into(), E::Array(vec![font_entry(font)]))]))])), + ("DocumentObjects".into(), E::Dict(vec![("TextObjects".into(), E::Array(vec![entry]))])), + ]) +} + +/// The length and `Features` of each style run of the first legacy object. +fn era_runs(block: &crate::engine_data::Value) -> Vec<(i64, crate::engine_data::Value)> { + use crate::engine_data::Value as E; + block + .path(&["DocumentObjects", "TextObjects"]) + .and_then(E::as_array) + .and_then(<[E]>::first) + .and_then(|e| e.path(&["Model", "StyleRun", "RunArray"])) + .and_then(E::as_array) + .map(|rs| { + rs.iter() + .map(|r| (r.get("Length").and_then(E::as_i64).unwrap_or(-1), r.path(&["RunData", "StyleSheet", "Features"]).cloned().unwrap_or(E::Int(0)))) + .collect() + }) + .unwrap_or_default() +} + +/// Review of #8888 (CodeRabbit, psd.rs): a style-only edit (no text change) restyles the older +/// Photoshop's copy too, which Photoshop re-renders from. Its font list gains the new font, the +/// run keeps every key Photoshop wrote, and the lengths still cover the text. +#[test] +fn a_style_only_edit_restyles_the_photoshop_era_objects() { + use crate::engine_data::Value as E; + let prev = styled_era_txt2("Line 1", "ArialMT", 13.0); + let style = CharStyle { + postscript_name: Some("Courier".into()), + size_pt: 20.0, + faux_bold: true, + color: Color::rgb(0.0, 0.5, 1.0), + tracking: 25.0, + ..Default::default() + }; + let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &styled("Line 1", style))], Some(&prev))).unwrap(); + let runs = era_runs(&out); + assert_eq!(runs.len(), 1); + let (length, f) = &runs[0]; + assert_eq!(*length, 7, "the run covers the text and its trailing return"); + assert_eq!(f.get("FontSize").and_then(E::as_f64), Some(20.0)); + assert_eq!(f.get("FauxBold").and_then(E::as_bool), Some(true)); + assert_eq!(f.get("Tracking").and_then(E::as_i64), Some(25)); + assert_eq!(f.path(&["FillColor", "Color", "Type"]).and_then(E::as_i64), Some(1)); + assert_eq!(f.path(&["FillColor", "Color", "Values"]).and_then(E::as_array).map(<[E]>::len), Some(4)); + assert_eq!(f.path(&["FillColor", "StreamTag"]).and_then(E::as_str), Some("SimplePaint"), "a key the file held is kept"); + // The font went into the block's list and the run names it. + let fonts = out.path(&["DocumentResources", "FontSet", "Resources"]).and_then(E::as_array).expect("the font list"); + let index = f.get("Font").and_then(E::as_i64).and_then(|i| usize::try_from(i).ok()).expect("the font index"); + let name = |i: usize| fonts.get(i).and_then(|r| r.path(&["Resource", "Identifier", "Name"])).and_then(E::as_str); + assert_eq!(name(index), Some("Courier")); + assert_eq!(name(0), Some("ArialMT"), "the file's font stays first"); + // The text did not change. + let text = + out.path(&["DocumentObjects", "TextObjects"]).and_then(E::as_array).and_then(<[E]>::first).and_then(|e| e.path(&["Model", "Text"])).and_then(E::as_str); + assert_eq!(text, Some("Line 1\r")); +} + +/// A layer whose style already agrees with the file's run leaves the legacy objects as they are. +#[test] +fn an_unedited_style_leaves_the_photoshop_era_objects_alone() { + let prev = styled_era_txt2("Line 1", "ArialMT", 13.0); + let same = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 13.0, ..Default::default() }; + let out = crate::psd::build_txt2(&[(0, &styled("Line 1", same))], Some(&prev)); + assert_eq!(era_runs(&crate::psd::parse_txt2(&prev).unwrap()), era_runs(&crate::psd::parse_txt2(&out).unwrap())); +} + +/// Both object lists, as CodeRabbit asked: the numbered object and the older copy are restyled by +/// the same style-only edit. +#[test] +fn a_style_only_edit_restyles_both_object_lists() { + use crate::engine_data::Value as E; + let mut prev = photoshop_txt2(&[(4, 1)]); + prev.extend_from_slice(&styled_era_txt2("AVA", "ArialMT", 13.0)); + let style = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 31.0, faux_italic: true, ..Default::default() }; + let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &styled("AVA", style))], Some(&prev))).unwrap(); + let legacy = era_runs(&out); + let first = legacy.first().map(|(l, f)| (*l, f.get("FontSize").and_then(E::as_f64), f.get("FauxItalic").and_then(E::as_bool))); + assert_eq!(first, Some((4, Some(31.0), Some(true)))); + let numbered = out + .path(&["1", "1"]) + .and_then(E::as_array) + .and_then(<[E]>::first) + .and_then(|o| o.path(&["0", "6", "0"])) + .and_then(E::as_array) + .and_then(<[E]>::first) + .and_then(|r| r.path(&["0", "0", "6"])); + assert_eq!(numbered.and_then(|s| s.get("1")).and_then(E::as_f64), Some(31.0), "the numbered object is restyled too"); +} + +/// Two character runs give two style runs in the legacy copy, with the lengths of the runs. +#[test] +fn a_two_run_layer_gives_the_photoshop_era_copy_two_style_runs() { + use crate::engine_data::Value as E; + let prev = styled_era_txt2("Hello", "ArialMT", 13.0); + let small = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 10.0, ..Default::default() }; + let big = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 40.0, ..Default::default() }; + let t = runs_of("Hello", &[(2, small), (3, big)]); + let runs = era_runs(&crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap()); + let seen: Vec<(i64, Option)> = runs.iter().map(|(l, f)| (*l, f.get("FontSize").and_then(E::as_f64))).collect(); + assert_eq!(seen, vec![(2, Some(10.0)), (4, Some(40.0))], "the last run takes the trailing return"); +} From c53d7d3984ba1686cee93182406375edcb61ec74 Mon Sep 17 00:00:00 2001 From: Federico Olivares Date: Sat, 24 Oct 2026 21:47:59 -0300 Subject: [PATCH 64/86] Keep every pattern a render uses, add wasm limits and finish the legacy text style keys A pattern past the per-call budget is converted under one lock and shared while in use, with a one-time warning, and the desktop budget keeps every pattern a call uses (1 GiB). The web build caps a pattern's working copy at 128 MiB and a call at 256 MiB, and the pattern readers reserve their memory fallibly on every target. The legacy text restyle writes and compares BaselineShift, FontCaps, FontBaseline, UnderlinePosition, StrikethroughPosition and the ligatures per run, keeps an automatic leading's own value, and import reads the legacy AutoKern; a corpus test edits the size in psd-tools' text.psd and checks the rest of the legacy object is unchanged. --- crates/compose/src/pattern.rs | 138 ++++++++++++++++++++++-------- crates/doc/src/pattern.rs | 8 +- crates/engine/src/pattern_cmds.rs | 3 +- crates/io/tests/it/text_corpus.rs | 62 ++++++++++++++ crates/text/src/psd.rs | 82 +++++++++++++++--- crates/text/src/tests.rs | 98 +++++++++++++++++++-- 6 files changed, 337 insertions(+), 54 deletions(-) diff --git a/crates/compose/src/pattern.rs b/crates/compose/src/pattern.rs index f708842bc9..8e81638405 100644 --- a/crates/compose/src/pattern.rs +++ b/crates/compose/src/pattern.rs @@ -8,23 +8,26 @@ //! minified tile is box-filtered instead of aliased; at 100 % and above it is a single tap. use std::collections::HashMap; -use std::sync::{Arc, Mutex, OnceLock}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, Weak}; use photocraft_doc::{ColorMode, Pattern, PixelFormat, Rect, SampleType}; -/// What one rendering call may keep of its patterns' working copies: room for two of the largest -/// (a layer's pattern fill and another layer's pattern overlay, say), so each is converted once -/// per call instead of once per output tile. A ceiling, not an allocation: only the patterns a -/// call paints with are converted, and a third large one past it is converted when it is asked for. -pub(crate) const PREPARED_PATTERN_BYTES: usize = 512 << 20; +/// What one rendering call may keep of its patterns' working copies: every pattern it paints with +/// stays converted for the call (four of the largest 8-bit ones on desktop), so none is converted +/// once per output tile. A ceiling, not an allocation: only the patterns a call paints with are +/// converted. Past it a pattern is converted under one lock and shared while in use (see +/// [`PreparedPatterns::get`]). The web build keeps a quarter of that. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) const PREPARED_PATTERN_BYTES: usize = 1 << 30; +/// See the desktop value above. +#[cfg(target_arch = "wasm32")] +pub(crate) const PREPARED_PATTERN_BYTES: usize = 256 << 20; const MAX_PREPARED_PATTERNS: usize = 64; -/// Absolute cap on a single pattern's RGBA conversion on the uncached path. A pattern this -/// large cannot be cached, so re-expanding it for every output tile would be a DoS; above the -/// cap it is skipped instead. Independent of the cache budget so small patterns still convert. -const MAX_UNCACHED_PATTERN_BYTES: usize = 256 << 20; -// Every paintable pattern must fit that cap, or its fill would render transparent. -const _: () = assert!(photocraft_doc::pattern::MAX_WORKING_BYTES <= MAX_UNCACHED_PATTERN_BYTES as u64); -// ... and a call keeps two of the largest, or the second one's fill is rebuilt for every tile. +/// Absolute cap on a single pattern's working copy on the uncached path: the document's own cap +/// on what paints (`Pattern::is_paintable`). A pattern above it is skipped, not converted. +const MAX_UNCACHED_PATTERN_BYTES: usize = photocraft_doc::pattern::MAX_WORKING_BYTES as usize; +// A call keeps at least two of the largest, or a second large fill is converted for every tile. const _: () = assert!(2 * photocraft_doc::pattern::MAX_WORKING_BYTES <= PREPARED_PATTERN_BYTES as u64); type PreparedSlot = Arc>>>; @@ -33,6 +36,15 @@ struct PreparedState { reserved_bytes: usize, } +/// Patterns past the call's budget: converted one at a time under this lock, and shared by the +/// workers using one at the same moment (a `Weak` per pattern), so the extra copies in memory are +/// the patterns in use, not one per worker. +#[derive(Default)] +struct OverBudget { + live: Mutex>>, + warned: AtomicBool, +} + /// Demand-prepared pixels owned by one rendering call, never by the document. pub(crate) struct PreparedPatterns<'a> { patterns: &'a [Pattern], @@ -40,11 +52,18 @@ pub(crate) struct PreparedPatterns<'a> { /// The colour mode the patterns are painted into (see [`pattern_rgba`]). mode: ColorMode, state: Mutex, + over_budget: OverBudget, } impl<'a> PreparedPatterns<'a> { pub(crate) fn new(patterns: &'a [Pattern], budget: usize) -> Self { - Self { patterns, budget, mode: ColorMode::Rgb, state: Mutex::new(PreparedState { slots: HashMap::new(), reserved_bytes: 0 }) } + Self { + patterns, + budget, + mode: ColorMode::Rgb, + state: Mutex::new(PreparedState { slots: HashMap::new(), reserved_bytes: 0 }), + over_budget: OverBudget::default(), + } } /// Patterns painted into a document of colour mode `mode`. @@ -89,14 +108,33 @@ impl<'a> PreparedPatterns<'a> { // Run on the requesting worker to preserve its active CMYK profile. slot.get_or_init(|| Tile::new_in(p, self.mode).map(Arc::new)).clone() } - // Uncached path: a pattern whose full conversion would exceed an absolute cap is - // not materialized at all. Otherwise every output tile would re-expand the entire - // sparse pattern into RGBA floats (and could do so concurrently), exhausting CPU and - // memory even though cache admission already refused to retain it. The cap is - // independent of the (possibly zero) cache budget, so small patterns still convert - // on demand when nothing is cached. + // Uncached path: a pattern whose working copy would exceed an absolute cap is not + // materialized at all. One past the call's budget is converted under one lock, and + // shared while some worker still holds it, so workers painting tiles at the same + // time do not each convert their own copy (CPU and memory), and it says so once. + // The cap is independent of the (possibly zero) cache budget, so small patterns + // still convert on demand when nothing is cached. None if bytes.is_none_or(|b| b > MAX_UNCACHED_PATTERN_BYTES) => None, - None => Tile::new_in(p, self.mode).map(Arc::new), + None => { + let mut live = self.over_budget.live.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(shared) = live.get(&key).and_then(Weak::upgrade) { + return Some(shared); + } + #[cfg(not(target_arch = "wasm32"))] + if !self.over_budget.warned.swap(true, Ordering::Relaxed) { + eprintln!( + "photocraft: pattern \"{}\" is past this render's pattern memory ({} MiB) and is converted again as tiles need it", + p.display_name(), + self.budget >> 20 + ); + } + #[cfg(target_arch = "wasm32")] + self.over_budget.warned.store(true, Ordering::Relaxed); + let tile = Tile::new_in(p, self.mode).map(Arc::new)?; + live.retain(|_, w| w.strong_count() > 0); + live.insert(key, Arc::downgrade(&tile)); + Some(tile) + } } } } @@ -109,19 +147,23 @@ impl<'a> PreparedPatterns<'a> { /// [paintable](Pattern::is_paintable). pub fn pattern_rgba(p: &Pattern, mode: ColorMode) -> Vec<[f32; 4]> { let mut px = Vec::new(); - if p.is_paintable() { - pattern_rgba_rows(p, mode, 0, p.height, &mut px); + if p.is_paintable() && pattern_rgba_rows(p, mode, 0, p.height, &mut px).is_none() { + px = Vec::new(); } px } /// Rows `top..bottom` of [`pattern_rgba`] (of a paintable pattern), replacing `out`: a large -/// pattern is read a band at a time instead of as one copy at 16 bytes per pixel. -pub fn pattern_rgba_rows(p: &Pattern, mode: ColorMode, top: u32, bottom: u32, out: &mut Vec<[f32; 4]>) { +/// pattern is read a band at a time instead of as one copy at 16 bytes per pixel. `None` when the +/// memory for the rows cannot be had (on the web a failed allocation aborts the tab, so it is +/// asked for, not assumed). +pub fn pattern_rgba_rows(p: &Pattern, mode: ColorMode, top: u32, bottom: u32, out: &mut Vec<[f32; 4]>) -> Option<()> { let bottom = bottom.min(p.height); let top = top.min(bottom); out.clear(); - out.resize(p.width as usize * (bottom - top) as usize, [0.0f32; 4]); + let pixels = (p.width as usize).checked_mul((bottom - top) as usize)?; + out.try_reserve_exact(pixels).ok()?; + out.resize(pixels, [0.0f32; 4]); p.surface.read_rgba_into(Rect::new(0, top as i32, p.width as i32, bottom as i32), out); let paints_in = matches!(mode, ColorMode::Grayscale | ColorMode::Bitmap | ColorMode::Duotone | ColorMode::Cmyk); if paints_in && p.surface.format().mode != mode { @@ -132,20 +174,23 @@ pub fn pattern_rgba_rows(p: &Pattern, mode: ColorMode, top: u32, bottom: u32, ou *q = photocraft_raster::to_rgba(&fmt, v.get(..n).unwrap_or_default()); } } + Some(()) } /// The pixels of a paintable pattern as they paint in a document of colour mode `mode`, rounded /// to the pattern's own sample grid by `quantize`, read a band of rows at a time: no full-size /// `f32` copy. A pattern with its own colours comes through exactly (its samples are on that -/// grid); one converted to a grey or CMYK document is rounded to the grid it was drawn on. -fn quantized_rgba(p: &Pattern, mode: ColorMode, quantize: impl Fn(f32) -> T) -> Vec<[T; 4]> { - let mut out = Vec::with_capacity(p.width as usize * p.height as usize); +/// grid); one converted to a grey or CMYK document is rounded to the grid it was drawn on. `None` +/// when the memory cannot be had. +fn quantized_rgba(p: &Pattern, mode: ColorMode, quantize: impl Fn(f32) -> T) -> Option> { + let mut out = Vec::new(); + out.try_reserve_exact((p.width as usize).checked_mul(p.height as usize)?).ok()?; let mut band = Vec::new(); for top in (0..p.height).step_by(photocraft_doc::pattern::BAND_ROWS as usize) { - pattern_rgba_rows(p, mode, top, top + photocraft_doc::pattern::BAND_ROWS, &mut band); + pattern_rgba_rows(p, mode, top, top + photocraft_doc::pattern::BAND_ROWS, &mut band)?; out.extend(band.iter().map(|c| [quantize(c[0]), quantize(c[1]), quantize(c[2]), quantize(c[3])])); } - out + Some(out) } /// `u8 / 255` as `f32`, for every `u8`: the value an 8-bit sample reads as. @@ -196,10 +241,13 @@ impl Tile { let (w, h) = (p.width as usize, p.height as usize); let texels = match p.surface.format().sample { // A large pattern is held at its own depth (`Pattern::working_bytes`). - SampleType::U8 if compact => Texels::Rgba8(quantized_rgba(p, mode, |v| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8)), - SampleType::U16 if compact => Texels::Rgba16(quantized_rgba(p, mode, |v| (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16)), + SampleType::U8 if compact => Texels::Rgba8(quantized_rgba(p, mode, |v| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8)?), + SampleType::U16 if compact => Texels::Rgba16(quantized_rgba(p, mode, |v| (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16)?), _ => { let mut px = pattern_rgba(p, mode); + if px.len() as u64 != p.pixel_count() { + return None; + } for q in &mut px { for c in 0..3 { q[c] *= q[3]; @@ -452,7 +500,14 @@ mod tests { }; assert!(same("a"), "the first is kept"); assert!(same("b"), "and the second"); - assert!(!same("c"), "a third past the budget is converted per use, not kept"); + // A third past the budget is converted under one lock and shared while a worker holds it, + // so concurrent tiles use one copy; it is converted again once nobody holds it. + assert!(same("c"), "shared while in use"); + let held = prepared.get("c", "").unwrap(); + let other = prepared.get("c", "").unwrap(); + assert!(Arc::ptr_eq(&held, &other), "two workers at once get one copy"); + drop((held, other)); + assert!(prepared.get("c", "").is_some(), "and it converts again after"); // The real budget holds two of the largest paintable ones. assert!(2 * photocraft_doc::pattern::MAX_WORKING_BYTES <= PREPARED_PATTERN_BYTES as u64); } @@ -491,6 +546,21 @@ mod tests { } } + /// Memory for the rows is asked for, not assumed: a pattern whose rows cannot be had (here a + /// declared size past what any allocation could be) gives `None`, never an aborted allocation. + #[test] + fn rows_that_cannot_be_allocated_give_none() { + let huge = Pattern { id: "huge".into(), name: "huge".into(), width: u32::MAX, height: u32::MAX, surface: Surface::new(PixelFormat::RGBA8) }; + let mut out = Vec::new(); + assert!(pattern_rgba_rows(&huge, ColorMode::Rgb, 0, u32::MAX, &mut out).is_none()); + assert!(quantized_rgba(&huge, ColorMode::Rgb, |v| v as u8).is_none()); + assert!(pattern_rgba(&huge, ColorMode::Rgb).is_empty()); + assert!(Tile::new(&huge).is_none()); + let small = Pattern { id: "small".into(), name: "small".into(), width: 3, height: 2, surface: Surface::new(PixelFormat::RGBA8) }; + assert_eq!(pattern_rgba_rows(&small, ColorMode::Rgb, 0, 2, &mut out), Some(())); + assert_eq!(out.len(), 6); + } + #[test] fn link_phase_scale_and_angle_move_the_origin() { let t = Tile::new(&checker()).unwrap(); diff --git a/crates/doc/src/pattern.rs b/crates/doc/src/pattern.rs index 4934e99f3a..7d46274638 100644 --- a/crates/doc/src/pattern.rs +++ b/crates/doc/src/pattern.rs @@ -14,8 +14,14 @@ pub const MAX_EDGE: u32 = 30_000; pub const MAX_PAINT_PIXELS: u64 = 1 << 26; /// Most bytes of working copy the CPU compositor holds for one pattern (`photocraft-compose` -/// checks the two agree at build time). See [`Pattern::working_bytes`]. +/// checks the two agree at build time). See [`Pattern::working_bytes`]. A browser tab has far less +/// memory than a desktop process, and a failed allocation there aborts the tab, so the web build +/// holds half as much: an 8-bit pattern paints up to 33,554,432 px (about 5792²) there. +#[cfg(not(target_arch = "wasm32"))] pub const MAX_WORKING_BYTES: u64 = 256 << 20; +/// See the desktop value above. +#[cfg(target_arch = "wasm32")] +pub const MAX_WORKING_BYTES: u64 = 128 << 20; /// Rows of a pattern read at a time where a whole-tile `f32` copy would cost 16 bytes per pixel. pub const BAND_ROWS: u32 = 256; diff --git a/crates/engine/src/pattern_cmds.rs b/crates/engine/src/pattern_cmds.rs index 7ddea17685..9270ad6821 100644 --- a/crates/engine/src/pattern_cmds.rs +++ b/crates/engine/src/pattern_cmds.rs @@ -400,7 +400,8 @@ fn brush_texture(s: &mut Session, p: &Value) -> Result { }; // Brush textures are grey: luminance of the composite over white. // Read a band of rows at a time: a whole-tile RGBA copy would cost 16 bytes a pixel. - let mut lum: Vec = Vec::with_capacity(n); + let mut lum: Vec = Vec::new(); + lum.try_reserve_exact(n).map_err(|_| bad("brush.texturePattern", "not enough memory for the pattern"))?; for_each_band(&pat, pat.width, pat.height, |band| { lum.extend(band.iter().map(|q| { let l = 0.299 * q[0] + 0.587 * q[1] + 0.114 * q[2]; diff --git a/crates/io/tests/it/text_corpus.rs b/crates/io/tests/it/text_corpus.rs index 8597ac49d8..9e4a93f075 100644 --- a/crates/io/tests/it/text_corpus.rs +++ b/crates/io/tests/it/text_corpus.rs @@ -214,3 +214,65 @@ fn created_text_layer_roundtrips_through_psd() { assert_eq!(bt.transform, t.transform); assert!(bt.cache.as_ref().is_some_and(|c| c.content_bounds().width() > 10)); } + +/// Review of #8888: the older Photoshop's text objects (`/DocumentObjects /TextObjects`) in the +/// real psd-tools `text.psd`. Saving the type layer unedited leaves them (and the font list they +/// index) exactly as the file has them; editing only the size changes the size of the legacy style +/// run and nothing else in it, and the object's text, paragraph runs and view are untouched. +#[cfg(feature = "corpus")] +#[test] +fn corpus_text_psd_legacy_objects_are_edited_in_place() { + use photocraft_text::engine_data::Value as E; + + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd-tools/text.psd"); + assert!(path.is_file(), "{} is missing: run `cargo xtask corpus --all`", path.display()); + let bytes = std::fs::read(&path).unwrap(); + let doc = photocraft_io::import("text.psd", &bytes).unwrap().document; + let txt2 = doc.metadata.psd_global_blocks.iter().find(|b| &b.1 == b"Txt2").map(|b| b.2.clone()).expect("text.psd has a Txt2 block"); + let mut all = Vec::new(); + walk(&doc.layers, &mut all); + let layer = all.iter().find_map(|l| if let LayerContent::Text(t) = &l.content { Some(t.clone()) } else { None }).expect("a type layer"); + + let before = photocraft_text::psd::parse_txt2(&txt2).expect("the file's Txt2 parses"); + let legacy = |block: &E| block.path(&["DocumentObjects", "TextObjects"]).and_then(E::as_array).and_then(<[E]>::first).cloned().expect("the legacy object"); + let features = |entry: &E| { + entry + .path(&["Model", "StyleRun", "RunArray"]) + .and_then(E::as_array) + .and_then(<[E]>::first) + .and_then(|r| r.path(&["RunData", "StyleSheet", "Features"])) + .cloned() + }; + let items = |value: &E| if let E::Dict(items) = value { items.clone() } else { Vec::new() }; + + // Unedited: the legacy object and the font list are as the file has them. + let unedited = photocraft_text::psd::parse_txt2(&photocraft_text::psd::build_txt2_at(&[(0, &layer)], Some(&txt2), doc.resolution_dpi)).unwrap(); + assert!(legacy(&unedited) == legacy(&before), "an unedited layer leaves the legacy text object alone"); + assert!(unedited.path(&["DocumentResources", "FontSet"]) == before.path(&["DocumentResources", "FontSet"]), "and the font list it indexes"); + + // Edited: only the size. + let mut bigger = layer.clone(); + for run in &mut bigger.runs { + run.style.size_pt += 7.0; + } + let edited = photocraft_text::psd::parse_txt2(&photocraft_text::psd::build_txt2_at(&[(0, &bigger)], Some(&txt2), doc.resolution_dpi)).unwrap(); + let (was, now) = (features(&legacy(&before)).expect("features"), features(&legacy(&edited)).expect("features")); + let size = |f: &E| f.get("FontSize").and_then(E::as_f64).expect("FontSize"); + assert!((size(&now) - size(&was) - 7.0).abs() < 1e-6, "the legacy size follows the edit: {} -> {}", size(&was), size(&now)); + let rest = |f: &E| items(f).into_iter().filter(|(k, _)| k != "FontSize").collect::>(); + let changed: Vec = + rest(&was).iter().zip(rest(&now).iter()).filter(|(a, b)| a != b).map(|(a, b)| format!("{}: {:?} -> {:?}", a.0, a.1, b.1)).collect(); + assert!(rest(&was) == rest(&now), "every other style key of the run is as the file has it; changed: {changed:?}"); + let others = |entry: &E, key: &str| entry.path(&["Model", key]).cloned(); + for key in ["Text", "ParagraphRun", "KernRun"] { + assert!(others(&legacy(&edited), key) == others(&legacy(&before), key), "{key} is untouched"); + } + assert!(legacy(&edited).get("View") == legacy(&before).get("View"), "the view is untouched"); + let lengths = |entry: &E| { + entry + .path(&["Model", "StyleRun", "RunArray"]) + .and_then(E::as_array) + .map(|rs| rs.iter().map(|r| r.get("Length").and_then(E::as_i64)).collect::>()) + }; + assert!(lengths(&legacy(&edited)) == lengths(&legacy(&before)), "the runs still cover the text"); +} diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index b36dbd6be6..a3cf609c56 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -169,19 +169,32 @@ pub fn apply_txt2(layer: &mut TextLayer, tysh: &[u8], txt2: &E) { }) else { return; }; - let Some(model) = txt2.path(&["1", "1"]).and_then(E::as_array).and_then(|a| a.get(index)).and_then(|o| o.get("0")) else { - return; + // The numbered object, else the older Photoshop's at `/DocumentObjects /TextObjects` (named + // keys: run lengths under `Length`, the style under `RunData /StyleSheet /Features`, the + // auto-kern mode under `AutoKern`). + let numbered = txt2 + .path(&["1", "1"]) + .and_then(E::as_array) + .and_then(|a| a.get(index)) + .and_then(|o| o.get("0")) + .and_then(|model| Some((model.get("0").and_then(E::as_str)?, model.path(&["6", "0"]).and_then(E::as_array)?))); + let legacy = || { + let model = txt2.path(&["DocumentObjects", "TextObjects"]).and_then(E::as_array).and_then(|a| a.get(index))?.get("Model")?; + Some((model.get("Text").and_then(E::as_str)?, model.path(&["StyleRun", "RunArray"]).and_then(E::as_array)?)) }; - let Some(text) = model.get("0").and_then(E::as_str) else { - return; + let (text, style_runs, is_numbered) = match numbered { + Some((text, runs)) => (text, runs, true), + None => { + let Some((text, runs)) = legacy() else { return }; + (text, runs, false) + } }; if text.strip_suffix('\r').unwrap_or(text).replace('\r', "\n") != layer.text { return; } - let Some(style_runs) = model.path(&["6", "0"]).and_then(E::as_array) else { - return; - }; - let styles: Vec<(f64, Option<&E>)> = style_runs.iter().map(|r| (r.get("1").and_then(E::as_f64).unwrap_or(0.0), r.path(&["0", "0", "6"]))).collect(); + let (len_key, style_path, kern_key): (&str, &[&str], &str) = + if is_numbered { ("1", &["0", "0", "6"], "11") } else { ("Length", &["RunData", "StyleSheet", "Features"], "AutoKern") }; + let styles: Vec<(f64, Option<&E>)> = style_runs.iter().map(|r| (r.get(len_key).and_then(E::as_f64).unwrap_or(0.0), r.path(style_path))).collect(); let lens = utf16_to_byte_lengths(text, &styles.iter().map(|m| m.0).collect::>()); // Txt2 style per byte segment, then split the model runs at segment boundaries. let mut segs: Vec<(usize, Option<&E>)> = lens.into_iter().zip(styles.iter().map(|m| m.1)).collect(); @@ -202,13 +215,13 @@ pub fn apply_txt2(layer: &mut TextLayer, tysh: &[u8], txt2: &E) { let txt2_style = segs.get(si).and_then(|s| s.1).filter(|_| at < seg_end); // Txt2 has the exact mode (EngineData's reading also marks neighbours of manual // kerns manual, like Photoshop's legacy reader). - match txt2_style.and_then(|st| st.get("11")).and_then(E::as_i64) { + match txt2_style.and_then(|st| st.get(kern_key)).and_then(E::as_i64) { Some(0) => style.kerning = Kerning::Off, Some(1) => style.kerning = Kerning::Metrics, Some(2) => style.kerning = Kerning::Optical, _ => {} } - if let Some(st) = txt2_style { + if let Some(st) = txt2_style.filter(|_| is_numbered) { add_txt2_features(&mut style, st); } let len = piece_end - at; @@ -1205,6 +1218,24 @@ fn txt2_style_agrees(s: &CharStyle, style: &E, fonts: &[E], k: f32) -> bool { style.get(TXT2_STYLISTIC_SETS).and_then(E::as_i64).filter(|m| (0..1 << 20).contains(m)).is_none_or(|m| m == sets) } +/// EngineData's `FontCaps` code of a style: 0 normal, 1 small caps, 2 all caps. +fn caps_code(s: &CharStyle) -> i64 { + match s.caps { + Caps::Normal => 0, + Caps::SmallCaps => 1, + Caps::AllCaps => 2, + } +} + +/// EngineData's `FontBaseline` code of a style: 0 normal, 1 superscript, 2 subscript. +fn baseline_code(s: &CharStyle) -> i64 { + match s.baseline { + FontBaseline::Normal => 0, + FontBaseline::Superscript => 1, + FontBaseline::Subscript => 2, + } +} + /// The OpenType flags of a legacy style (`Features`), by key, and the feature each turns on: the /// named keys of [`TXT2_FEATURES`]. const TD_FEATURES: [(&str, &str); 7] = [ @@ -1245,6 +1276,22 @@ fn td_style_agrees(s: &CharStyle, style: &E, fonts: &[E], k: f32) -> bool { if !(number("HorizontalScale", s.horizontal_scale) && number("VerticalScale", s.vertical_scale) && number("Tracking", s.tracking.round())) { return false; } + // Per run, like the rest: a baseline shift, the case and baseline position, the underline and + // strikethrough (a non-zero position is on, whichever position the file chose), the ligatures + // and the auto-kern mode. + let whole = |key: &str, want: i64| style.get(key).and_then(E::as_i64).is_none_or(|v| v == want); + let on = |key: &str, want: bool| style.get(key).and_then(E::as_i64).is_none_or(|v| (v != 0) == want); + if !(number("BaselineShift", s.baseline_shift_pt * k) + && whole("FontCaps", caps_code(s)) + && whole("FontBaseline", baseline_code(s)) + && on("UnderlinePosition", s.underline) + && on("StrikethroughPosition", s.strikethrough) + && flag("Ligatures", s.ligatures) + && flag("DiscretionaryLigatures", s.discretionary_ligatures) + && whole("AutoKern", txt2_mode(s))) + { + return false; + } if let Some(paint) = style.get("FillColor").and_then(|p| p.get("Color")) { let (kind, values) = fill_color(&s.color); if paint.get("Type").and_then(E::as_i64).is_some_and(|v| v != kind) { @@ -1305,11 +1352,24 @@ fn td_restyled(entry: &E, layer: &TextLayer, fonts: &mut Vec, dpi: f32) -> Op features.set("FauxBold", E::Bool(s.faux_bold)); features.set("FauxItalic", E::Bool(s.faux_italic)); features.set("AutoLeading", E::Bool(s.leading_pt.is_none())); - features.set("Leading", real(s.leading_pt.unwrap_or(0.0) * k)); + // An automatic leading's value is Photoshop's own (`.01` in the files seen): kept. + if let Some(leading) = s.leading_pt { + features.set("Leading", real(leading * k)); + } features.set("HorizontalScale", real(s.horizontal_scale)); features.set("VerticalScale", real(s.vertical_scale)); features.set("Tracking", E::Int(s.tracking.round() as i64)); features.set("AutoKern", E::Int(txt2_mode(s))); + features.set("BaselineShift", real(s.baseline_shift_pt * k)); + features.set("FontCaps", E::Int(caps_code(s))); + features.set("FontBaseline", E::Int(baseline_code(s))); + // On keeps the position the file chose (a non-zero value), else the first one. + for (key, on) in [("UnderlinePosition", s.underline), ("StrikethroughPosition", s.strikethrough)] { + let kept = features.get(key).and_then(E::as_i64).filter(|v| *v != 0).unwrap_or(1); + features.set(key, E::Int(if on { kept } else { 0 })); + } + features.set("Ligatures", E::Bool(s.ligatures)); + features.set("DiscretionaryLigatures", E::Bool(s.discretionary_ligatures)); if let Some(paint @ E::Dict(_)) = features.get_mut("FillColor").and_then(|p| p.get_mut("Color")) { let (kind, values) = fill_color(&s.color); paint.set("Type", E::Int(kind)); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index a179bf271f..a4d92b7171 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -3165,7 +3165,7 @@ fn per_colour_passes_match_full_size_passes() { /// A legacy (Photoshop-era) `Txt2` block shaped like a real one (psd-tools' `text.psd`): the /// fonts in `DocumentResources /FontSet /Resources`, and each object's `Model /StyleRun` runs /// holding their style under `RunData /StyleSheet /Features` (named keys) beside `Length`. -fn styled_era_txt2(text: &str, font: &str, size: f64) -> Vec { +fn styled_era_txt2(text: &str, font: &str, size: f64, auto_kern: i64) -> Vec { use crate::engine_data::Value as E; let font_entry = |name: &str| { E::Dict(vec![( @@ -3182,12 +3182,18 @@ fn styled_era_txt2(text: &str, font: &str, size: f64) -> Vec { ("FauxBold".into(), E::Bool(false)), ("FauxItalic".into(), E::Bool(false)), ("AutoLeading".into(), E::Bool(true)), - ("Leading".into(), E::Real(0.0)), + ("Leading".into(), E::Real(0.01)), ("HorizontalScale".into(), E::Real(1.0)), ("VerticalScale".into(), E::Real(1.0)), ("Tracking".into(), E::Int(0)), - ("AutoKern".into(), E::Int(1)), + ("AutoKern".into(), E::Int(auto_kern)), + ("BaselineShift".into(), E::Real(0.0)), + ("FontCaps".into(), E::Int(0)), + ("FontBaseline".into(), E::Int(0)), + ("UnderlinePosition".into(), E::Int(0)), + ("StrikethroughPosition".into(), E::Int(0)), ("Ligatures".into(), E::Bool(true)), + ("DiscretionaryLigatures".into(), E::Bool(false)), ("ContextualLigatures".into(), E::Bool(true)), ("StylisticSets".into(), E::Int(0)), ( @@ -3249,7 +3255,7 @@ fn era_runs(block: &crate::engine_data::Value) -> Vec<(i64, crate::engine_data:: #[test] fn a_style_only_edit_restyles_the_photoshop_era_objects() { use crate::engine_data::Value as E; - let prev = styled_era_txt2("Line 1", "ArialMT", 13.0); + let prev = styled_era_txt2("Line 1", "ArialMT", 13.0, 1); let style = CharStyle { postscript_name: Some("Courier".into()), size_pt: 20.0, @@ -3266,6 +3272,7 @@ fn a_style_only_edit_restyles_the_photoshop_era_objects() { assert_eq!(f.get("FontSize").and_then(E::as_f64), Some(20.0)); assert_eq!(f.get("FauxBold").and_then(E::as_bool), Some(true)); assert_eq!(f.get("Tracking").and_then(E::as_i64), Some(25)); + assert_eq!(f.get("Leading").and_then(E::as_f64), Some(0.01), "an automatic leading keeps Photoshop's own value"); assert_eq!(f.path(&["FillColor", "Color", "Type"]).and_then(E::as_i64), Some(1)); assert_eq!(f.path(&["FillColor", "Color", "Values"]).and_then(E::as_array).map(<[E]>::len), Some(4)); assert_eq!(f.path(&["FillColor", "StreamTag"]).and_then(E::as_str), Some("SimplePaint"), "a key the file held is kept"); @@ -3284,7 +3291,7 @@ fn a_style_only_edit_restyles_the_photoshop_era_objects() { /// A layer whose style already agrees with the file's run leaves the legacy objects as they are. #[test] fn an_unedited_style_leaves_the_photoshop_era_objects_alone() { - let prev = styled_era_txt2("Line 1", "ArialMT", 13.0); + let prev = styled_era_txt2("Line 1", "ArialMT", 13.0, 1); let same = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 13.0, ..Default::default() }; let out = crate::psd::build_txt2(&[(0, &styled("Line 1", same))], Some(&prev)); assert_eq!(era_runs(&crate::psd::parse_txt2(&prev).unwrap()), era_runs(&crate::psd::parse_txt2(&out).unwrap())); @@ -3296,7 +3303,7 @@ fn an_unedited_style_leaves_the_photoshop_era_objects_alone() { fn a_style_only_edit_restyles_both_object_lists() { use crate::engine_data::Value as E; let mut prev = photoshop_txt2(&[(4, 1)]); - prev.extend_from_slice(&styled_era_txt2("AVA", "ArialMT", 13.0)); + prev.extend_from_slice(&styled_era_txt2("AVA", "ArialMT", 13.0, 1)); let style = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 31.0, faux_italic: true, ..Default::default() }; let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &styled("AVA", style))], Some(&prev))).unwrap(); let legacy = era_runs(&out); @@ -3317,7 +3324,7 @@ fn a_style_only_edit_restyles_both_object_lists() { #[test] fn a_two_run_layer_gives_the_photoshop_era_copy_two_style_runs() { use crate::engine_data::Value as E; - let prev = styled_era_txt2("Hello", "ArialMT", 13.0); + let prev = styled_era_txt2("Hello", "ArialMT", 13.0, 1); let small = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 10.0, ..Default::default() }; let big = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 40.0, ..Default::default() }; let t = runs_of("Hello", &[(2, small), (3, big)]); @@ -3325,3 +3332,80 @@ fn a_two_run_layer_gives_the_photoshop_era_copy_two_style_runs() { let seen: Vec<(i64, Option)> = runs.iter().map(|(l, f)| (*l, f.get("FontSize").and_then(E::as_f64))).collect(); assert_eq!(seen, vec![(2, Some(10.0)), (4, Some(40.0))], "the last run takes the trailing return"); } + +/// Review of #8888: every key the legacy restyle writes is written per run, so a multi-run edit +/// does not copy the first run's baseline shift, case, underline or strikethrough to the others. +#[test] +fn each_photoshop_era_style_run_gets_its_own_values() { + use crate::engine_data::Value as E; + use photocraft_doc::text::{Caps, FontBaseline}; + let prev = styled_era_txt2("Hello", "ArialMT", 13.0, 1); + let base = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 13.0, ..Default::default() }; + let decorated = CharStyle { baseline_shift_pt: 3.0, caps: Caps::SmallCaps, baseline: FontBaseline::Superscript, underline: true, ..base.clone() }; + let struck = CharStyle { strikethrough: true, caps: Caps::AllCaps, ligatures: false, discretionary_ligatures: true, ..base.clone() }; + let t = runs_of("Hello", &[(2, decorated), (2, base.clone()), (1, struck)]); + let runs = era_runs(&crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap()); + let key = |i: usize, k: &str| runs.get(i).and_then(|(_, f)| f.get(k)).cloned(); + assert_eq!(runs.iter().map(|r| r.0).collect::>(), vec![2, 2, 2]); + assert_eq!(key(0, "BaselineShift").and_then(|v| v.as_f64()), Some(3.0)); + assert_eq!(key(1, "BaselineShift").and_then(|v| v.as_f64()), Some(0.0), "the second run is not the first"); + assert_eq!([0, 1, 2].map(|i| key(i, "FontCaps").and_then(|v| v.as_i64())), [Some(1), Some(0), Some(2)]); + assert_eq!([0, 1, 2].map(|i| key(i, "FontBaseline").and_then(|v| v.as_i64())), [Some(1), Some(0), Some(0)]); + assert_eq!([0, 1, 2].map(|i| key(i, "UnderlinePosition").and_then(|v| v.as_i64()).map(|v| v != 0)), [Some(true), Some(false), Some(false)]); + assert_eq!([0, 1, 2].map(|i| key(i, "StrikethroughPosition").and_then(|v| v.as_i64()).map(|v| v != 0)), [Some(false), Some(false), Some(true)]); + assert_eq!([0, 1, 2].map(|i| key(i, "Ligatures").and_then(|v| v.as_bool())), [Some(true), Some(true), Some(false)]); + assert_eq!([0, 1, 2].map(|i| key(i, "DiscretionaryLigatures").and_then(|v| v.as_bool())), [Some(false), Some(false), Some(true)]); + let _ = E::Int(0); +} + +/// Each of those keys alone is enough to restyle the legacy copy (an edit of only that key), and +/// a layer that agrees on all of them leaves the block's bytes alone. +#[test] +fn every_legacy_style_key_is_compared() { + use photocraft_doc::text::{Caps, FontBaseline, Kerning}; + let prev = styled_era_txt2("Hello", "ArialMT", 13.0, 1); + let base = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 13.0, ..Default::default() }; + let unedited = crate::psd::build_txt2(&[(0, &styled("Hello", base.clone()))], Some(&prev)); + assert!( + era_runs(&crate::psd::parse_txt2(&unedited).unwrap()) == era_runs(&crate::psd::parse_txt2(&prev).unwrap()), + "an agreeing layer leaves the legacy copy as it is" + ); + let edits = [ + CharStyle { baseline_shift_pt: 2.0, ..base.clone() }, + CharStyle { caps: Caps::AllCaps, ..base.clone() }, + CharStyle { baseline: FontBaseline::Subscript, ..base.clone() }, + CharStyle { underline: true, ..base.clone() }, + CharStyle { strikethrough: true, ..base.clone() }, + CharStyle { ligatures: false, ..base.clone() }, + CharStyle { discretionary_ligatures: true, ..base.clone() }, + CharStyle { kerning: Kerning::Optical, ..base.clone() }, + ]; + for (i, edit) in edits.into_iter().enumerate() { + let out = crate::psd::build_txt2(&[(0, &styled("Hello", edit))], Some(&prev)); + assert!(era_runs(&crate::psd::parse_txt2(&out).unwrap()) != era_runs(&crate::psd::parse_txt2(&prev).unwrap()), "edit {i} restyles the legacy copy"); + } +} + +/// Review of #8888: the legacy `AutoKern` mode is read on import, as `/11` is for the numbered +/// objects, so the layer a legacy file opens as agrees with its legacy copy. +#[test] +fn legacy_auto_kern_is_read_on_import() { + use photocraft_doc::text::Kerning; + let t = styled("AVA", CharStyle::default()); + let tysh = with_text_index(&crate::psd::build_tysh(&t, 72.0, None), 0); + for (mode, want) in [(0, Kerning::Off), (1, Kerning::Metrics), (2, Kerning::Optical)] { + let txt2 = crate::psd::parse_txt2(&styled_era_txt2("AVA", "ArialMT", 13.0, mode)).unwrap(); + let mut back = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap(); + crate::psd::apply_txt2(&mut back, &tysh, &txt2); + assert!( + back.char_runs().iter().all(|r| r.style.kerning == want), + "AutoKern {mode}: {:?}", + back.char_runs().iter().map(|r| r.style.kerning).collect::>() + ); + } + // Other text, or another object, changes nothing. + let txt2 = crate::psd::parse_txt2(&styled_era_txt2("AVX", "ArialMT", 13.0, 2)).unwrap(); + let mut back = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap(); + crate::psd::apply_txt2(&mut back, &tysh, &txt2); + assert!(back.char_runs().iter().all(|r| r.style.kerning == Kerning::Metrics)); +} From b6d0f345000f27d87bf89d2eaaad2b1681e133d1 Mon Sep 17 00:00:00 2001 From: Federico Olivares Date: Sun, 25 Oct 2026 07:06:54 -0300 Subject: [PATCH 65/86] Carry #9104 with #8888 and keep the raw strings of a rewritten Txt2 Merge #9104 (raw EngineData strings are written back as the same bytes) so a style-only type edit does not re-encode Photoshop's raw strings as UTF-16, with tests for the numbered and the legacy restyle paths. The over-budget pattern warning is written without a panic path and explained where the lock is held. --- crates/compose/src/pattern.rs | 21 ++++++++++++++------- crates/text/src/tests.rs | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/crates/compose/src/pattern.rs b/crates/compose/src/pattern.rs index 8e81638405..6346421b4b 100644 --- a/crates/compose/src/pattern.rs +++ b/crates/compose/src/pattern.rs @@ -116,20 +116,27 @@ impl<'a> PreparedPatterns<'a> { // still convert on demand when nothing is cached. None if bytes.is_none_or(|b| b > MAX_UNCACHED_PATTERN_BYTES) => None, None => { + // The lock is held through the conversion, so conversions of different + // over-budget patterns run one at a time. That is the point (one extra copy + // being built at once, not one per worker) and fine for a fallback: patterns + // that fit the budget never come here. let mut live = self.over_budget.live.lock().unwrap_or_else(|e| e.into_inner()); if let Some(shared) = live.get(&key).and_then(Weak::upgrade) { return Some(shared); } - #[cfg(not(target_arch = "wasm32"))] if !self.over_budget.warned.swap(true, Ordering::Relaxed) { - eprintln!( - "photocraft: pattern \"{}\" is past this render's pattern memory ({} MiB) and is converted again as tiles need it", - p.display_name(), - self.budget >> 20 + // Written, not printed: a warning must never panic, even with no stderr. + #[cfg(not(target_arch = "wasm32"))] + let _ = std::io::Write::write_fmt( + &mut std::io::stderr(), + format_args!( + "photocraft: pattern \"{}\" is past this render's pattern memory ({} MiB) and is converted again as tiles need it +", + p.display_name(), + self.budget >> 20 + ), ); } - #[cfg(target_arch = "wasm32")] - self.over_budget.warned.store(true, Ordering::Relaxed); let tile = Tile::new_in(p, self.mode).map(Arc::new)?; live.retain(|_, w| w.strong_count() > 0); live.insert(key, Arc::downgrade(&tile)); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index a4d92b7171..0489f83c82 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -3409,3 +3409,37 @@ fn legacy_auto_kern_is_read_on_import() { crate::psd::apply_txt2(&mut back, &tysh, &txt2); assert!(back.char_runs().iter().all(|r| r.style.kerning == Kerning::Metrics)); } + +/// Review of the batch (FredApps, with #9104): a style-only save rewrites `Txt2`, and the raw +/// (non-BOM) strings Photoshop wrote in it, `/2 (0100)` and `/3 (01110)` under `/98`, must come +/// back as the same bytes: re-encoded as `FE FF` UTF-16 they make Photoshop refuse the file. +#[test] +fn a_style_only_save_keeps_the_raw_strings_of_the_file() { + let mut prev = photoshop_txt2(&[(4, 1)]); + let version = b"/98 << /0 14 >>"; + let at = prev.windows(version.len()).position(|w| w == version).expect("the version key"); + prev.splice(at..at + version.len(), b"/98 << /0 14 /2 (0100) /3 (01110) >>".iter().copied()); + // A size the file's runs do not have: the block is rewritten. + let style = CharStyle { size_pt: 31.0, faux_bold: false, ..Default::default() }; + let out = crate::psd::build_txt2(&[(0, &styled("AVA", style))], Some(&prev)); + assert_ne!(out, prev, "the style edit rewrites the block"); + let has = |needle: &[u8]| out.windows(needle.len()).any(|w| w == needle); + assert!(has(b"(0100)") && has(b"(01110)"), "the raw strings come back as the same bytes: {:?}", String::from_utf8_lossy(&out)); + assert!(!has(b"(\xfe\xff\x000\x001\x000\x000)"), "and not as UTF-16"); +} + +/// The same for the older Photoshop's objects: restyling the legacy copy leaves a raw string +/// elsewhere in the block as the file wrote it. +#[test] +fn restyling_the_photoshop_era_objects_keeps_the_raw_strings_of_the_file() { + use crate::engine_data::Value as E; + let era = crate::psd::parse_txt2(&styled_era_txt2("Line 1", "ArialMT", 13.0, 1)).unwrap(); + let E::Dict(mut items) = era else { panic!("a dictionary") }; + items.push(("98".into(), E::Dict(vec![("0".into(), E::Int(14)), ("2".into(), E::RawString("0100".into()))]))); + let prev = crate::engine_data::write_bare(&items); + let style = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 24.0, ..Default::default() }; + let out = crate::psd::build_txt2(&[(0, &styled("Line 1", style))], Some(&prev)); + assert!(out.windows(6).any(|w| w == b"(0100)"), "{:?}", String::from_utf8_lossy(&out)); + let runs = era_runs(&crate::psd::parse_txt2(&out).unwrap()); + assert_eq!(runs.first().and_then(|(_, f)| f.get("FontSize")).and_then(E::as_f64), Some(24.0), "and the legacy copy was restyled"); +} From 971d6a3a1eae9f66e6063d6f467b514c88a15c95 Mon Sep 17 00:00:00 2001 From: Federico Olivares Date: Sun, 25 Oct 2026 08:06:47 -0300 Subject: [PATCH 66/86] Write the over-budget pattern warning with writeln! --- crates/compose/src/pattern.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/compose/src/pattern.rs b/crates/compose/src/pattern.rs index 6346421b4b..b351bdef06 100644 --- a/crates/compose/src/pattern.rs +++ b/crates/compose/src/pattern.rs @@ -127,15 +127,15 @@ impl<'a> PreparedPatterns<'a> { if !self.over_budget.warned.swap(true, Ordering::Relaxed) { // Written, not printed: a warning must never panic, even with no stderr. #[cfg(not(target_arch = "wasm32"))] - let _ = std::io::Write::write_fmt( - &mut std::io::stderr(), - format_args!( - "photocraft: pattern \"{}\" is past this render's pattern memory ({} MiB) and is converted again as tiles need it -", + { + use std::io::Write as _; + let _ = writeln!( + std::io::stderr(), + "photocraft: pattern \"{}\" is past this render's pattern memory ({} MiB) and is converted again as tiles need it", p.display_name(), self.budget >> 20 - ), - ); + ); + } } let tile = Tile::new_in(p, self.mode).map(Arc::new)?; live.retain(|_, w| w.strong_count() > 0); From 280f7efc0f1306afa613b5b8351c9e3655e84853 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Sun, 25 Oct 2026 17:17:47 +0530 Subject: [PATCH 67/86] Save for Web: WebP keeps the photo's EXIF when Metadata is All The WebP branch built its image from meta(), which carries only the resolution and XMP, and embed_metadata followed the XMP alone, so an EXIF-only photo lost its camera data in both lossy and lossless modes. The filtered EXIF now goes onto the image and enables the metadata when either it or the XMP is present. Regression: an EXIF-only photo exported as WebP (both modes) keeps its EXIF with All and drops it for every other choice. --- crates/engine/src/web_cmds.rs | 6 ++++-- crates/engine/src/web_cmds/tests.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/web_cmds.rs b/crates/engine/src/web_cmds.rs index 5df3e5237c..db22aeb20b 100644 --- a/crates/engine/src/web_cmds.rs +++ b/crates/engine/src/web_cmds.rs @@ -512,12 +512,14 @@ pub fn optimize( WebFormat::Webp => { let (data, layout) = true_color(); let icc = icc.filter(|_| st.embed_icc); - let img = photocraft_codecs::Image::from_u8(ww, hh, layout, data).map_err(e)?.with_icc(icc.map(<[u8]>::to_vec)).with_meta(meta()); + // WebP holds EXIF (the metadata choice's "All" passes it in), so it rides along with the XMP. + let webp_meta = photocraft_codecs::Metadata { exif: exif.map(<[u8]>::to_vec), ..meta() }; + let img = photocraft_codecs::Image::from_u8(ww, hh, layout, data).map_err(e)?.with_icc(icc.map(<[u8]>::to_vec)).with_meta(webp_meta); let opts = photocraft_codecs::EncodeOptions { webp_lossless: st.lossless, webp_quality: st.quality.min(100), embed_icc: icc.is_some(), - embed_metadata: xmp.is_some(), + embed_metadata: exif.is_some() || xmp.is_some(), ..Default::default() }; let bytes = photocraft_codecs::encode(&img, photocraft_codecs::Format::WebP, &opts).map_err(e)?; diff --git a/crates/engine/src/web_cmds/tests.rs b/crates/engine/src/web_cmds/tests.rs index 8a87ad58bc..132e3e449a 100644 --- a/crates/engine/src/web_cmds/tests.rs +++ b/crates/engine/src/web_cmds/tests.rs @@ -1191,6 +1191,33 @@ fn save_for_web_all_writes_the_photos_exif() { assert_eq!(WebSettings::from_params(&json!({"metadata": "allExceptCameraInfo"}), "x").unwrap().to_params()["metadata"], "allExceptCameraInfo"); } +/// WebP holds EXIF in both modes: "All" keeps an EXIF-only photo's camera data (no XMP needed to +/// trigger the metadata), every other choice leaves it out. +#[test] +fn save_for_web_webp_all_keeps_an_exif_only_photos_exif() { + let dir = tmp("webp_exif"); + let mut s = session(8); + s.edit("exif", |doc, _| { + doc.metadata.exif = Some(std::sync::Arc::new(camera_exif())); + Ok(()) + }) + .unwrap(); + for lossless in [false, true] { + for metadata in ["all", "none", "copyright", "allExceptCameraInfo"] { + let path = format!("{dir}/{metadata}-{lossless}.webp"); + s.execute("file.export.saveForWebLegacy", json!({"format": "webp", "lossless": lossless, "metadata": metadata, "path": path})).unwrap(); + let meta = decode(&path).meta; + if metadata == "all" { + let exif = meta.exif.unwrap_or_else(|| panic!("EXIF kept (lossless {lossless})")); + assert_eq!(photocraft_algo::exif::read(&exif).make.as_deref(), Some("TestMake"), "lossless {lossless}"); + assert!(meta.xmp.is_none(), "the photo had no XMP"); + } else { + assert!(meta.exif.is_none(), "{metadata} leaves the camera data out (lossless {lossless})"); + } + } + } +} + #[test] fn quick_export_all_writes_the_photos_exif() { let dir = tmp("quick_exif"); From b61622c1e27f03a966c078cfecb505ffd0bf9255 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Sun, 25 Oct 2026 19:27:57 +0530 Subject: [PATCH 68/86] fix(ui): Select and Mask's stale-workspace messages are translated The two reasons stale() gives for closing the workspace were plain English in the status bar and the control error. They go through tl! now, with rows in every catalogue and a test that checks each language's status and error (#8896 review). --- crates/ui-egui/src/i18n/cs.tsv | 2 ++ crates/ui-egui/src/i18n/de.tsv | 2 ++ crates/ui-egui/src/i18n/el.tsv | 2 ++ crates/ui-egui/src/i18n/es.tsv | 2 ++ crates/ui-egui/src/i18n/fr.tsv | 2 ++ crates/ui-egui/src/i18n/hi.tsv | 2 ++ crates/ui-egui/src/i18n/hu.tsv | 2 ++ crates/ui-egui/src/i18n/id.tsv | 2 ++ crates/ui-egui/src/i18n/it.tsv | 2 ++ crates/ui-egui/src/i18n/ja.tsv | 2 ++ crates/ui-egui/src/i18n/ko.tsv | 2 ++ crates/ui-egui/src/i18n/nl.tsv | 2 ++ crates/ui-egui/src/i18n/pl.tsv | 2 ++ crates/ui-egui/src/i18n/pt-br.tsv | 2 ++ crates/ui-egui/src/i18n/ru.tsv | 2 ++ crates/ui-egui/src/i18n/sr.tsv | 2 ++ crates/ui-egui/src/i18n/tr.tsv | 2 ++ crates/ui-egui/src/i18n/uk.tsv | 2 ++ 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/select_mask_ui.rs | 4 +-- crates/ui-egui/src/select_mask_ui_tests.rs | 29 ++++++++++++++++++++++ 23 files changed, 73 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 54c85fe48c..ef178ad10e 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4180,3 +4180,5 @@ cameraRaw Dehaze Odstranění oparu Grayscale Ramp Přechod stupňů šedi Current Colors Aktuální barvy Make Ramp Web Safe Omezit pruh na webové barvy + Select and Mask was closed: its document is no longer the active one Vybrat a maskovat bylo zavřeno: jeho dokument již není aktivní + Select and Mask was closed: another layer was selected Vybrat a maskovat bylo zavřeno: byla vybrána jiná vrstva diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index f235801397..282d86a29e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3448,3 +3448,5 @@ curves Smooth Glätten Grayscale Ramp Graustufenbalken Current Colors Aktuelle Farben Make Ramp Web Safe Farbbalken auf Webfarben beschränken + Select and Mask was closed: its document is no longer the active one Auswählen und maskieren wurde geschlossen: Das zugehörige Dokument ist nicht mehr aktiv + Select and Mask was closed: another layer was selected Auswählen und maskieren wurde geschlossen: Es wurde eine andere Ebene ausgewählt diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index ccb2636fb1..ffd18db540 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4134,3 +4134,5 @@ curves Smooth Εξομάλυνση Grayscale Ramp Διαβάθμιση του γκρι Current Colors Τρέχοντα χρώματα Make Ramp Web Safe Μόνο χρώματα ιστού στη διαβάθμιση + Select and Mask was closed: its document is no longer the active one Η Επιλογή και μάσκα έκλεισε: το έγγραφό της δεν είναι πλέον το ενεργό + Select and Mask was closed: another layer was selected Η Επιλογή και μάσκα έκλεισε: επιλέχθηκε άλλο επίπεδο diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 079704cefb..3244c70bcc 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4396,3 +4396,5 @@ navigator Zoom Zoom Grayscale Ramp Rampa de escala de grises Current Colors Colores actuales Make Ramp Web Safe Hacer rampa segura para web + Select and Mask was closed: its document is no longer the active one Se cerró Seleccionar y aplicar máscara: su documento ya no es el activo + Select and Mask was closed: another layer was selected Se cerró Seleccionar y aplicar máscara: se seleccionó otra capa diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index e05f8a7b43..9f43999a6a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4476,3 +4476,5 @@ shape Appearance Apparence Grayscale Ramp Barre de niveaux de gris Current Colors Couleurs actuelles Make Ramp Web Safe Limiter la barre aux couleurs Web + Select and Mask was closed: its document is no longer the active one Sélectionner et masquer a été fermé : son document n’est plus le document actif + Select and Mask was closed: another layer was selected Sélectionner et masquer a été fermé : un autre calque a été sélectionné diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 131c5aa131..fa5560f04d 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -550,3 +550,5 @@ cameraRaw Yellow पीला View Mode दृश्य मोड Object Aware needs a segmentation model, which isn't available yet ऑब्जेक्ट-आधारित के लिए सेगमेंटेशन मॉडल चाहिए, जो अभी उपलब्ध नहीं है Refine Hair needs hair detection, which isn't available yet बाल परिशोधित करने के लिए बालों की पहचान चाहिए, जो अभी उपलब्ध नहीं है + Select and Mask was closed: its document is no longer the active one चयन और मास्क बंद कर दिया गया: उसका दस्तावेज़ अब सक्रिय नहीं है + Select and Mask was closed: another layer was selected चयन और मास्क बंद कर दिया गया: दूसरी लेयर चुनी गई diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index b533e8233c..2985f6a25e 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4349,3 +4349,5 @@ cameraRaw Yellow Sárga Grayscale Ramp Szürkeárnyalatos sáv Current Colors Aktuális színek Make Ramp Web Safe Sáv korlátozása webes színekre + Select and Mask was closed: its document is no longer the active one A Kijelölés és maszk bezárult: a dokumentuma már nem az aktív + Select and Mask was closed: another layer was selected A Kijelölés és maszk bezárult: másik réteg lett kijelölve diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 04549fe232..dd95ebb781 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3450,3 +3450,5 @@ curves Smooth Haluskan Grayscale Ramp Gradasi Grayscale Current Colors Warna Saat Ini Make Ramp Web Safe Jadikan Gradasi Aman untuk Web + Select and Mask was closed: its document is no longer the active one Seleksi dan Mask ditutup: dokumennya tidak lagi aktif + Select and Mask was closed: another layer was selected Seleksi dan Mask ditutup: layer lain dipilih diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 01cfefe485..c30194c888 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4551,3 +4551,5 @@ shape Appearance Aspetto Grayscale Ramp Barra scala di grigio Current Colors Colori correnti Make Ramp Web Safe Limita la barra ai colori web + Select and Mask was closed: its document is no longer the active one Seleziona e maschera è stato chiuso: il suo documento non è più quello attivo + Select and Mask was closed: another layer was selected Seleziona e maschera è stato chiuso: è stato selezionato un altro livello diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8b3d843a29..0006b55dda 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4114,3 +4114,5 @@ curves Smooth スムーズ Grayscale Ramp グレースケールランプ Current Colors 現在のカラー Make Ramp Web Safe ランプを Web セーフにする + Select and Mask was closed: its document is no longer the active one 「選択とマスク」を閉じました: 対象のドキュメントがアクティブではなくなりました + Select and Mask was closed: another layer was selected 「選択とマスク」を閉じました: 別のレイヤーが選択されました diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index b645045d13..14d429fbfa 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3646,3 +3646,5 @@ curves Smooth 매끄럽게 Grayscale Ramp 회색 음영 램프 Current Colors 현재 색상 Make Ramp Web Safe 램프를 웹 안전 색상으로 만들기 + Select and Mask was closed: its document is no longer the active one 선택 및 마스크가 닫혔습니다: 해당 문서가 더 이상 활성 문서가 아닙니다 + Select and Mask was closed: another layer was selected 선택 및 마스크가 닫혔습니다: 다른 레이어가 선택되었습니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 9a6736410d..1b282f0168 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4194,3 +4194,5 @@ curves Smooth Vloeiend maken Grayscale Ramp Grijswaardenbalk Current Colors Huidige kleuren Make Ramp Web Safe Balk beperken tot webkleuren + Select and Mask was closed: its document is no longer the active one Selecteren en maskeren is gesloten: het document is niet meer het actieve document + Select and Mask was closed: another layer was selected Selecteren en maskeren is gesloten: er is een andere laag geselecteerd diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 41e38d231f..015ee82525 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4052,3 +4052,5 @@ curves Smooth Wygładź Grayscale Ramp Pasek skali szarości Current Colors Bieżące kolory Make Ramp Web Safe Ogranicz pasek do kolorów internetowych + Select and Mask was closed: its document is no longer the active one Zamknięto Zaznacz i maskuj: jego dokument nie jest już aktywny + Select and Mask was closed: another layer was selected Zamknięto Zaznacz i maskuj: wybrano inną warstwę diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index e18daa9010..0c32e27d89 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4364,3 +4364,5 @@ layerLabel No Color Sem cor Grayscale Ramp Rampa de tons de cinza Current Colors Cores atuais Make Ramp Web Safe Tornar rampa segura para a Web + Select and Mask was closed: its document is no longer the active one Selecionar e mascarar foi fechado: o documento dele não está mais ativo + Select and Mask was closed: another layer was selected Selecionar e mascarar foi fechado: outra camada foi selecionada diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index cb79435980..cf7624caf0 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4637,3 +4637,5 @@ shape Appearance Оформление Grayscale Ramp Шкала градаций серого Current Colors Текущие цвета Make Ramp Web Safe Сделать шкалу безопасной для web + Select and Mask was closed: its document is no longer the active one Окно «Выделение и маска» закрыто: его документ больше не активен + Select and Mask was closed: another layer was selected Окно «Выделение и маска» закрыто: выбран другой слой diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index f206fe15e8..58a343f33f 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4507,3 +4507,5 @@ shape Appearance Izgled Grayscale Ramp Skala nijansi sive Current Colors Trenutne boje Make Ramp Web Safe Učini skalu bezbednom za veb + Select and Mask was closed: its document is no longer the active one Izaberi i maskiraj je zatvoren: njegov dokument više nije aktivan + Select and Mask was closed: another layer was selected Izaberi i maskiraj je zatvoren: izabran je drugi sloj diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 2c7e258e69..145297c07d 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4163,3 +4163,5 @@ cameraData Lens Lens Grayscale Ramp Gri Tonlama Şeridi Current Colors Geçerli Renkler Make Ramp Web Safe Şeridi Web Renkleriyle Sınırla + Select and Mask was closed: its document is no longer the active one Seç ve Maskele kapatıldı: belgesi artık etkin değil + Select and Mask was closed: another layer was selected Seç ve Maskele kapatıldı: başka bir katman seçildi diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index bc331838b5..cda7dfe93c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4145,3 +4145,5 @@ curves Smooth Згладити Grayscale Ramp Шкала градацій сірого Current Colors Поточні кольори Make Ramp Web Safe Зробити шкалу безпечною для вебу + Select and Mask was closed: its document is no longer the active one Вікно «Виділити та маскувати» закрито: його документ більше не активний + Select and Mask was closed: another layer was selected Вікно «Виділити та маскувати» закрито: вибрано інший шар diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 1fec6cf359..fd397830d0 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3928,3 +3928,5 @@ curves Smooth Làm mượt Grayscale Ramp Dải thang xám Current Colors Màu hiện tại Make Ramp Web Safe Giới hạn dải màu ở màu web + Select and Mask was closed: its document is no longer the active one Đã đóng Chọn và Mặt nạ: tài liệu của nó không còn là tài liệu đang hoạt động + Select and Mask was closed: another layer was selected Đã đóng Chọn và Mặt nạ: một lớp khác đã được chọn diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 940f3b3dc6..65454a4fb2 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3104,3 +3104,5 @@ curves Smooth 平滑 Exported {path} 已导出 {path} Exported {n} files 已导出 {n} 个文件 Driver: {driver} 驱动程序:{driver} + Select and Mask was closed: its document is no longer the active one 已关闭“选择与蒙版”:其文档不再是当前文档 + Select and Mask was closed: another layer was selected 已关闭“选择与蒙版”:已选择另一个图层 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 2c79e9206b..488761d373 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3060,3 +3060,5 @@ curves Smooth 平滑 Grayscale Ramp 灰階色帶 Current Colors 目前色彩 Make Ramp Web Safe 讓色帶成為網頁安全色 + Select and Mask was closed: its document is no longer the active one 已關閉「選取並遮住」:其文件已不是使用中的文件 + Select and Mask was closed: another layer was selected 已關閉「選取並遮住」:已選取另一個圖層 diff --git a/crates/ui-egui/src/select_mask_ui.rs b/crates/ui-egui/src/select_mask_ui.rs index 8c0295c971..353e57d275 100644 --- a/crates/ui-egui/src/select_mask_ui.rs +++ b/crates/ui-egui/src/select_mask_ui.rs @@ -778,10 +778,10 @@ pub fn open(app: &mut PhotocraftApp) -> Result<(), String> { pub(crate) fn stale(app: &PhotocraftApp) -> Option<&'static str> { let w = app.distort.select_mask.as_ref()?; let Some(st) = app.session.active().filter(|st| st.doc.id == w.document) else { - return Some("Select and Mask was closed: its document is no longer the active one"); + return Some(tl!("Select and Mask was closed: its document is no longer the active one")); }; let layer = st.active_layer.filter(|id| st.doc.layer(*id).is_some()); - (layer != w.layer).then_some("Select and Mask was closed: another layer was selected") + (layer != w.layer).then_some(tl!("Select and Mask was closed: another layer was selected")) } /// Closes a [`stale`] workspace (its work is dropped, the new target untouched): the reason. diff --git a/crates/ui-egui/src/select_mask_ui_tests.rs b/crates/ui-egui/src/select_mask_ui_tests.rs index 2a4c484530..c9b1f17fa8 100644 --- a/crates/ui-egui/src/select_mask_ui_tests.rs +++ b/crates/ui-egui/src/select_mask_ui_tests.rs @@ -743,3 +743,32 @@ fn ok_after_a_layer_switch_leaves_the_new_layer_alone() { assert!(revision_after_select >= revision && st.revision == revision_after_select, "nothing was edited after the switch"); assert!(app.distort.select_mask.is_none()); } + +/// The reason a stale workspace was closed is user-visible text: it is shown in the status bar and +/// returned to the control channel in the language in use, and every catalogue has it (#8896). +#[test] +fn the_reason_a_stale_workspace_closed_is_translated() { + let document = "Select and Mask was closed: its document is no longer the active one"; + let layer = "Select and Mask was closed: another layer was selected"; + for lang in crate::i18n::Lang::all().filter(|l| l.code() != "en") { + for (reason, english) in [("document", document), ("layer", layer)] { + assert!(crate::i18n::has(lang, english), "{}: no row for the {reason} reason", lang.code()); + let shown = crate::i18n::tr(lang, english); + let mut app = if reason == "document" { two_documents() } else { app_with_selection() }; + let first = app.session.active().unwrap().active_layer.unwrap(); + let other = (reason == "layer").then(|| app.run("layer.new.layer", json!({"name": "Other"})).unwrap()["layer"].as_u64().map(LayerId).unwrap()); + app.run("layer.select", json!({"layer": first.0})).unwrap(); + open(&mut app).unwrap(); + crate::i18n::with_language(lang, || { + match other { + Some(other) => drop(app.session.execute("layer.select", json!({"layer": other.0}))), + None => assert!(app.session.set_active(1)), + } + let err = commit(&mut app).expect_err(&format!("{} / {reason}: the stale OK is refused", lang.code())); + assert_eq!(err, shown, "{}: the error is translated", lang.code()); + assert_eq!(app.ui.status, shown, "{}: the status is translated", lang.code()); + }); + assert_ne!(shown, english, "{}: the {reason} reason reads differently from English", lang.code()); + } + } +} From 5295017732f841802504130959d1e8eb7004990f Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Mon, 26 Oct 2026 10:52:23 +0530 Subject: [PATCH 69/86] Adapt to egui 0.37 renames (Id::unique, scope_id, Role, IdSalt, dropdown names) --- crates/ui-egui/src/select_mask_ui.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/select_mask_ui.rs b/crates/ui-egui/src/select_mask_ui.rs index 353e57d275..9fce26f8cc 100644 --- a/crates/ui-egui/src/select_mask_ui.rs +++ b/crates/ui-egui/src/select_mask_ui.rs @@ -1465,7 +1465,7 @@ fn properties(ui: &mut egui::Ui, w: &mut SelectMaskWorkspace, t: &Tokens) -> Opt let labels: Vec = Mode::ALL.iter().map(|m| format!("{} ({:?})", m.label(), m.key())).collect(); let options: Vec<(Mode, &str)> = Mode::ALL.iter().zip(&labels).map(|(m, l)| (*m, l.as_str())).collect(); // The labels carry the view's letter, already translated. - widgets::dropdown_literal(ui, "sm-view-mode", &mut w.opts.view, &options, 112.0); + widgets::dropdown_literal(ui, "sm-view-mode", tl!("View"), &mut w.opts.view, &options, 112.0); }); // Beside the view list, as Photoshop's; long labels are cut rather than widening the // panel past its edge (every row below lines up on that edge). @@ -1492,7 +1492,14 @@ fn properties(ui: &mut egui::Ui, w: &mut SelectMaskWorkspace, t: &Tokens) -> Opt widgets::color_edit_button_srgb(ui, &mut w.opts.overlay_color); ui.add_space(8.0); ui.label(tl!("Indicates")); - widgets::dropdown(ui, "sm-indicates", &mut w.opts.indicates_selected, &[(false, tl!("Masked Areas")), (true, tl!("Selected Areas"))], 120.0); + widgets::dropdown( + ui, + "sm-indicates", + tl!("Indicates"), + &mut w.opts.indicates_selected, + &[(false, tl!("Masked Areas")), (true, tl!("Selected Areas"))], + 120.0, + ); }); } }); @@ -1538,7 +1545,7 @@ fn properties(ui: &mut egui::Ui, w: &mut SelectMaskWorkspace, t: &Tokens) -> Opt .filter(|(o, _)| !w.opts.decontaminate || !matches!(**o, "selection" | "layerMask")) .map(|(o, l)| (o.to_string(), l)) .collect(); - widgets::dropdown(ui, "sm-output-to", &mut w.opts.output, &options, 170.0); + widgets::dropdown(ui, "sm-output-to", tl!("Output To"), &mut w.opts.output, &options, 170.0); }); }); if let Some(e) = w.error.as_ref().or(w.preview.error.as_ref()) { @@ -1581,7 +1588,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let mut mods = egui::Modifiers::NONE; let mut edit: Option = None; let mut do_reset = false; - egui::Area::new(egui::Id::new("select-mask-workspace")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| { + egui::Area::new(egui::Id::unique("select-mask-workspace")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| { let Some(w) = app.distort.select_mask.as_mut() else { return }; let before = (w.opts.clone(), w.sample_all); w.texture_limit = ctx.input(|i| i.max_texture_side); @@ -1669,7 +1676,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { if w.zoom <= 0.0 { w.zoom = ((area.width() - 40.0) / cw.max(1.0)).min((area.height() - 40.0) / chh.max(1.0)).clamp(0.01, 4.0); } - let resp = ui.interact(area, egui::Id::new("select-mask-view"), Sense::click_and_drag()); + let resp = ui.interact(area, egui::Id::unique("select-mask-view"), Sense::click_and_drag()); // Over the image the keys are the workspace's: a panel button clicked last keeps the focus // otherwise, and Space (held to pan) would press it again. A number being typed keeps it. if resp.hovered() From 57fa742f5d9fe4fc014d3636f016dea18ceca050 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Mon, 26 Oct 2026 11:02:16 +0530 Subject: [PATCH 70/86] Save for Web WebP test: the Quality label now names a slider as well --- crates/ui-egui/src/file_ui.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs index 07414a5512..5b0c4685cf 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -2058,11 +2058,11 @@ mod web_playback_tests { fields.insert("format".into(), json!("webp")); assert_eq!(fields["lossless"], false); let mut h = harness(app, fields); - assert!(h.query_by_label("Quality").is_some()); + assert!(h.query_all_by_label("Quality").next().is_some()); h.get_by_label("Lossless").click(); h.run_steps(2); assert_eq!(h.state().1["lossless"], true); - assert!(h.query_by_label("Quality").is_none(), "lossless has no quality"); + assert!(h.query_all_by_label("Quality").next().is_none(), "lossless has no quality"); let v = variants(&json!({"format": "webp", "quality": 80})); assert_eq!((v[1]["format"].as_str(), v[1]["quality"].as_f64(), v[2]["quality"].as_f64()), (Some("webp"), Some(40.0), Some(20.0))); From 37c21e1d7787b859b4800b0ad46337d2f6c120ff Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Tue, 27 Oct 2026 20:09:03 +0530 Subject: [PATCH 71/86] fix(transform): preserve action targets and clipped previews --- crates/engine/src/cmd/actions_cmds/tests.rs | 37 +++++++++++++++++++ .../src/cmd/layer_multi_cmds/selection.rs | 7 +++- .../src/tools/transform_tool/preview.rs | 1 + .../ui-egui/src/tools/transform_tool/tests.rs | 22 +++++++++++ 4 files changed, 66 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/actions_cmds/tests.rs b/crates/engine/src/cmd/actions_cmds/tests.rs index b7974784b6..640573be61 100644 --- a/crates/engine/src/cmd/actions_cmds/tests.rs +++ b/crates/engine/src/cmd/actions_cmds/tests.rs @@ -294,6 +294,43 @@ fn record_explicit_transform_keeps_one_replayable_step() { assert_eq!(pixel(&s, 0, 12, 2), vec![0.0; 4]); } +#[test] +fn recorded_single_layer_transform_replays_after_layer_ids_shift() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 20, "height": 8, "background": "transparent"})).unwrap(); + let source = s.execute("layer.new.layer", json!({"name": "Target"})).unwrap()["layer"].as_u64().unwrap(); + s.edit("source pixel", |doc, _| { + doc.layer_mut(photocraft_doc::LayerId(source)).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 2, 2, 4), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + + s.execute("actions.record", json!({"name": "Move target"})).unwrap(); + s.execute("edit.transform", json!({"layer": source, "dx": 4, "interpolation": "nearest"})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + let recorded = s.execute("actions.get", json!({"action": "Move target"})).unwrap(); + assert_eq!(recorded["steps"][0][1], json!({"dx": 4, "interpolation": "nearest"})); + + // Add a layer before the target, shifting its id; playback must resolve the current + // selection rather than keep the source document's numeric layer id. + s.execute("file.new", json!({"width": 20, "height": 8, "background": "transparent"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Inserted"})).unwrap(); + let target = s.execute("layer.new.layer", json!({"name": "Target"})).unwrap()["layer"].as_u64().unwrap(); + assert_ne!(source, target); + s.edit("target pixel", |doc, _| { + doc.layer_mut(photocraft_doc::LayerId(target)).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 2, 2, 4), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + let target_index = s.active().unwrap().doc.layers.iter().position(|layer| layer.id.0 == target).unwrap(); + let before = s.active().unwrap().history.past_len(); + let played = s.execute("actions.play", json!({"action": "Move target"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert_eq!(s.active().unwrap().history.past_len(), before + 1); + assert_eq!(pixel(&s, target_index, 4, 2), vec![1.0, 0.0, 0.0, 1.0]); + assert_eq!(pixel(&s, target_index, 0, 2), vec![0.0; 4]); +} + /// A Background with a red bar and a slanted ruler line, ready for Straighten. fn ruled_background(s: &mut Session, depth: u32) { s.execute("file.new", json!({"width": 80, "height": 60, "depth": depth})).unwrap(); diff --git a/crates/engine/src/cmd/layer_multi_cmds/selection.rs b/crates/engine/src/cmd/layer_multi_cmds/selection.rs index 6d7fce1c7f..453c1c3061 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/selection.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/selection.rs @@ -140,7 +140,12 @@ pub(crate) fn recorded_step(s: &Session, id: &str, p: Value, pre_selected: Optio return recorded_move(s, p, pre_selected); } if id == "edit.transform" { - return recorded_transform(s, p, pre_selected); + // Multi-layer transforms carry the set of ids separately. A single-layer transform + // uses the ordinary `layer` target, which should follow the sole selected layer when + // the action is replayed in a document with different ids. + if p.get("layers").is_some_and(Value::is_array) { + return recorded_transform(s, p, pre_selected); + } } let Some(layer_id) = p.get("layer").and_then(Value::as_u64).map(LayerId) else { return p }; let is_sole_selected = match pre_selected { diff --git a/crates/ui-egui/src/tools/transform_tool/preview.rs b/crates/ui-egui/src/tools/transform_tool/preview.rs index 2eb96b91d7..9a7a241b4b 100644 --- a/crates/ui-egui/src/tools/transform_tool/preview.rs +++ b/crates/ui-egui/src/tools/transform_tool/preview.rs @@ -214,6 +214,7 @@ pub(super) fn show_only(layers: &mut [photocraft_doc::Layer], keep: &[LayerId]) } match &mut l.content { LayerContent::Group(g) if g.children.iter().any(|c| keep.iter().any(|k| contains(c, *k))) => { + l.clipped = false; // This ancestor only clips the covering children. Its background is already // on the base canvas; drawing it again would cover the moving layer in white. if let Some(board) = &mut g.artboard { diff --git a/crates/ui-egui/src/tools/transform_tool/tests.rs b/crates/ui-egui/src/tools/transform_tool/tests.rs index 2afcd48f9c..e16f81f909 100644 --- a/crates/ui-egui/src/tools/transform_tool/tests.rs +++ b/crates/ui-egui/src/tools/transform_tool/tests.rs @@ -2110,6 +2110,28 @@ fn nested_group_preview_detaches_every_clipped_ancestor() { assert_eq!(image.pixels[8 * image.size[0] + 8], Color32::from_rgb(255, 0, 0), "hidden clipping bases must not make a nested target disappear"); } +#[test] +fn several_preview_detaches_clipped_ancestors() { + use photocraft_color::PixelFormat; + use photocraft_doc::Layer; + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + let st = app.session.active_mut().unwrap(); + let doc = Arc::make_mut(&mut st.doc); + let first = doc.layers.pop().unwrap(); + let first_id = first.id; + let mut second = Layer::raster("second", PixelFormat::RGBA8); + second.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(32, 8, 48, 24), &[0., 1., 0., 1.]); + let second_id = second.id; + let mut group = Layer::group("clipped", vec![first, second]); + group.clipped = true; + doc.layers.push(Layer::raster("hidden base", PixelFormat::RGBA8)); + doc.layers.push(group); + let shown = Several::shown(doc, &[first_id, second_id], &[]); + let (image, _) = composite_image(&shown, photocraft_geom::Rect::new(0, 0, 64, 64), 4096); + assert_eq!(image.pixels[16 * image.size[0] + 16], Color32::from_rgb(255, 0, 0)); + assert_eq!(image.pixels[16 * image.size[0] + 40], Color32::from_rgb(0, 255, 0)); +} + #[test] fn unknown_preview_target_keeps_the_previous_document_fallback() { let app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); From d53e14bcccbdc0bc62e66fc745c827650bac38de Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Tue, 27 Oct 2026 20:10:24 +0530 Subject: [PATCH 72/86] fix(transform): limit ancestor detachment to moving preview --- crates/ui-egui/src/tools/transform_tool.rs | 7 +++++++ crates/ui-egui/src/tools/transform_tool/preview.rs | 1 - 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/tools/transform_tool.rs b/crates/ui-egui/src/tools/transform_tool.rs index b5ec93cbaa..5bdb4d1bd6 100644 --- a/crates/ui-egui/src/tools/transform_tool.rs +++ b/crates/ui-egui/src/tools/transform_tool.rs @@ -170,6 +170,13 @@ impl Several { let mut d = live.clone(); show_only(&mut d.layers, ids); for id in ids { + if let Some(path) = d.path_of(*id) { + for n in 1..path.len() { + if let Some(ancestor) = path.get(..n).and_then(|p| d.layer_at_mut(p)) { + ancestor.clipped = false; + } + } + } if let Some(l) = d.layer_mut(*id) { l.clipped = false; } diff --git a/crates/ui-egui/src/tools/transform_tool/preview.rs b/crates/ui-egui/src/tools/transform_tool/preview.rs index 9a7a241b4b..2eb96b91d7 100644 --- a/crates/ui-egui/src/tools/transform_tool/preview.rs +++ b/crates/ui-egui/src/tools/transform_tool/preview.rs @@ -214,7 +214,6 @@ pub(super) fn show_only(layers: &mut [photocraft_doc::Layer], keep: &[LayerId]) } match &mut l.content { LayerContent::Group(g) if g.children.iter().any(|c| keep.iter().any(|k| contains(c, *k))) => { - l.clipped = false; // This ancestor only clips the covering children. Its background is already // on the base canvas; drawing it again would cover the moving layer in white. if let Some(board) = &mut g.artboard { From a2364b20d0c9e29a76c2ceea2a94015816b69296 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Wed, 28 Oct 2026 09:25:49 +0530 Subject: [PATCH 73/86] Fix imports lost when transform_tool was split into a module tree --- crates/ui-egui/src/tools/transform_tool.rs | 1 - crates/ui-egui/src/tools/transform_tool/begin.rs | 4 ++-- crates/ui-egui/src/tools/transform_tool/commit.rs | 5 +++-- crates/ui-egui/src/tools/transform_tool/drag_modes.rs | 3 ++- crates/ui-egui/src/tools/transform_tool/pointer.rs | 6 +++--- 5 files changed, 10 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/tools/transform_tool.rs b/crates/ui-egui/src/tools/transform_tool.rs index ce857ac8c7..3845437e80 100644 --- a/crates/ui-egui/src/tools/transform_tool.rs +++ b/crates/ui-egui/src/tools/transform_tool.rs @@ -32,7 +32,6 @@ mod preview; mod warp; pub(crate) use begin::begin_content_aware; -use begin::moves_kind; pub(crate) use begin::moving_layers; #[cfg(test)] pub(crate) use begin::moving_texel; diff --git a/crates/ui-egui/src/tools/transform_tool/begin.rs b/crates/ui-egui/src/tools/transform_tool/begin.rs index 303d07c9b8..ba49e27042 100644 --- a/crates/ui-egui/src/tools/transform_tool/begin.rs +++ b/crates/ui-egui/src/tools/transform_tool/begin.rs @@ -1,7 +1,7 @@ -use super::commit::{commit, start_steps}; +use super::commit::{cancel, commit, start_steps}; use super::preview::{Above, preview_image}; use super::{ - Arc, BezierMesh, CAS_ONLY_SCALES, Color32, DocId, Document, Follow, Homography, LOCKED, LayerContent, LayerId, MadeLayer, PhotocraftApp, Steps, + Arc, BezierMesh, CAS_ONLY_SCALES, Color32, DocId, Document, Follow, Homography, LOCKED, LayerContent, LayerId, MadeLayer, PhotocraftApp, Several, Steps, TransformPreview, TransformSession, Warp, WarpStyle, corners, json, }; diff --git a/crates/ui-egui/src/tools/transform_tool/commit.rs b/crates/ui-egui/src/tools/transform_tool/commit.rs index 5b7c4fcc5a..8a26d852f2 100644 --- a/crates/ui-egui/src/tools/transform_tool/commit.rs +++ b/crates/ui-egui/src/tools/transform_tool/commit.rs @@ -1,7 +1,8 @@ -use super::begin::{fold_made, rect_to_quad_affine, take_back_made, warps_type}; +use super::begin::{fold_made, moves_kind, moving_layers, rect_to_quad_affine, take_back_made, warps_type}; use super::options_bar::{rotate_about_pivot, scale_about_pivot}; use super::{ - Arc, DocId, JobEvent, JobId, JobOutcome, LayerContent, LayerId, MadeLayer, PhotocraftApp, Step, Steps, TransformPreview, TransformSession, corners, json, + Arc, DocId, JobEvent, JobId, JobOutcome, LayerContent, LayerId, MadeLayer, PhotocraftApp, Several, Step, Steps, TransformPreview, TransformSession, + corners, json, }; /// Undo and Redo while the commit runs in the background (Free Transform, or Warp). diff --git a/crates/ui-egui/src/tools/transform_tool/drag_modes.rs b/crates/ui-egui/src/tools/transform_tool/drag_modes.rs index 477cd51ac5..5b416845de 100644 --- a/crates/ui-egui/src/tools/transform_tool/drag_modes.rs +++ b/crates/ui-egui/src/tools/transform_tool/drag_modes.rs @@ -1,5 +1,6 @@ +use super::begin::moves_kind; use super::pointer::{Gesture, Hit, carry_pivot}; -use super::{Homography, LayerContent, LayerId, PhotocraftApp, TransformMode, TransformSession}; +use super::{Homography, LayerContent, PhotocraftApp, TransformMode, TransformSession}; /// The same effective modifiers drive the geometry and decide whether Ctrl is a distortion /// gesture rather than the ordinary snap override. Keep type and Content-Aware Scale refusals. diff --git a/crates/ui-egui/src/tools/transform_tool/pointer.rs b/crates/ui-egui/src/tools/transform_tool/pointer.rs index 6860229895..dd76e446c9 100644 --- a/crates/ui-egui/src/tools/transform_tool/pointer.rs +++ b/crates/ui-egui/src/tools/transform_tool/pointer.rs @@ -1,10 +1,10 @@ -use super::begin::warps_type; +use super::begin::{moving_layers, warps_type}; use super::commit::{commit, committing, moves_type}; use super::drag_modes::{apply_drag, apply_mode_drag, drag_mods, handle_indicator, mode_allows, mode_mods}; use super::warp::{quick_split_kind, split_cursor, surface_at, warp_hit, warp_pointer}; use super::{ - CursorIcon, HANDLE_PX, Homography, LayerContent, LayerId, PhotocraftApp, ToolEvent, TransformMode, TransformPreview, TransformSession, ViewXform, - WarpStyle, handle_tolerance, + CursorIcon, HANDLE_PX, Homography, LayerContent, PhotocraftApp, ToolEvent, TransformMode, TransformPreview, TransformSession, ViewXform, WarpStyle, + handle_tolerance, }; /// Which part of the box a document point hits. From 4af5b3a669867d9378e49b44a5d2a4679688100e Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Wed, 28 Oct 2026 09:26:13 +0530 Subject: [PATCH 74/86] Split Select and Mask's workspace closure into paint/cursor/input helpers (cognitive complexity) --- crates/ui-egui/src/dialogs/select_mask_ui.rs | 288 ++++++++++--------- 1 file changed, 157 insertions(+), 131 deletions(-) diff --git a/crates/ui-egui/src/dialogs/select_mask_ui.rs b/crates/ui-egui/src/dialogs/select_mask_ui.rs index c3a63bb7fa..90eaf3fc79 100644 --- a/crates/ui-egui/src/dialogs/select_mask_ui.rs +++ b/crates/ui-egui/src/dialogs/select_mask_ui.rs @@ -1570,6 +1570,160 @@ fn follow_revision(app: &mut PhotocraftApp) { } } +/// Draws the document, the refined edge, the stroke being drawn and the polygon in progress. +fn paint_canvas(ui: &mut egui::Ui, ctx: &egui::Context, w: &mut SelectMaskWorkspace, t: &Tokens, painter: &egui::Painter, resp: &egui::Response, area: ERect) { + let v = View { area, zoom: w.zoom, center: w.center }; + let clip = painter.with_clip_rect(area); + let img = v.rect(w.canvas); + let uv = ERect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + widgets::checker(&clip, img.intersect(area), 8.0); + let preview = w.preview.tex.as_ref().filter(|_| !w.hide_view); + match (preview, &w.image_tex) { + (Some(tex), _) => { + // The reduced copy's last row and column may reach past the canvas. + let covers = ERect::from_min_size(img.min, vec2(w.preview.covers[0] * v.zoom, w.preview.covers[1] * v.zoom)); + clip.with_clip_rect(img.intersect(area)).image(tex.id(), covers, uv, Color32::WHITE); + } + (None, Some(tex)) => { + clip.image(tex.id(), img, uv, Color32::WHITE); + } + (None, None) => {} + } + // Marching Ants: the refined selection's edge, black dashes on white. + if !w.hide_view + && w.opts.view == Mode::MarchingAnts + && let Some(edges) = &w.preview.ants + { + let phase = (ui.input(|i| i.time) * 8.0) as usize; + for e in edges { + let (a, b) = (v.to_screen([f64::from(e[0][0]), f64::from(e[0][1])]), v.to_screen([f64::from(e[1][0]), f64::from(e[1][1])])); + let dark = ((e[0][0] + e[0][1]) as usize / 4 + phase).is_multiple_of(2); + clip.line_segment([a, b], Stroke::new(1.0, if dark { Color32::BLACK } else { Color32::WHITE })); + } + ctx.request_repaint_after(std::time::Duration::from_millis(120)); + } + if w.preview.shown != w.preview.want && !w.pressed { + let sp = ERect::from_min_size(area.right_bottom() - vec2(26.0, 26.0), vec2(18.0, 18.0)); + ui.put(sp, egui::Spinner::new().size(16.0)); + } + // The stroke being drawn: the part the preview doesn't show yet (all of it without + // Real-Time Refinement), joined to the part it does, so the stroke follows the pointer. + let ink = Color32::from_rgba_unmultiplied(t.accent.r(), t.accent.g(), t.accent.b(), 110); + if let Some(g) = w.gesture.as_ref().or(w.trail.as_ref()) { + let line: Vec = g.points.iter().map(|p| v.to_screen(*p)).collect(); + match g.tool { + SmTool::Quick | SmTool::Brush | SmTool::RefineEdge if w.preview.trail != usize::MAX => { + let tail = line.get(w.preview.trail.saturating_sub(1)..).unwrap_or_default(); + let r = w.size() as f32 * 0.5 * v.zoom; + for p in tail { + clip.circle_filled(*p, r, ink); + } + if tail.len() > 1 { + clip.add(egui::Shape::line(tail.to_vec(), Stroke::new(2.0 * r, ink))); + } + } + SmTool::Lasso => { + clip.add(egui::Shape::line(line, Stroke::new(1.0, t.accent))); + } + SmTool::Object => { + if let (Some(a), Some(b)) = (line.first(), line.last()) { + clip.rect_stroke(ERect::from_two_pos(*a, *b), 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Middle); + } + } + _ => {} + } + } + if let Some((_, pts)) = &w.poly { + let mut line: Vec = pts.iter().map(|p| v.to_screen(*p)).collect(); + if let Some(hp) = resp.hover_pos() { + line.push(hp); + } + clip.add(egui::Shape::line(line, Stroke::new(1.0, t.accent))); + } +} + +/// The cursor over the document, and the image's outline. +fn paint_cursor(ctx: &egui::Context, w: &mut SelectMaskWorkspace, clip: &egui::Painter, resp: &egui::Response, v: View, pan: bool, alt: bool) { + let (img, t) = (v.rect(w.canvas), Tokens::get(ctx)); + // The cursor: Photoshop's hand while panning (closed while dragging), the brush's circle + // (with + or −), the zoom glass, else a crosshair. + if let Some(hp) = resp.hover_pos() { + w.hover = Some(v.to_doc(hp)); + } + if pan && (resp.dragged() || resp.is_pointer_button_down_on()) { + ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + } else if let Some(hp) = resp.hover_pos() { + if pan { + ctx.set_cursor_icon(egui::CursorIcon::Grab); + } else if w.tool.brushes() { + let r = (w.size() as f32 * 0.5 * v.zoom).max(2.0); + clip.circle_stroke(hp, r, Stroke::new(1.5, Color32::BLACK)); + clip.circle_stroke(hp, r, Stroke::new(0.75, Color32::WHITE)); + let add = w.add != alt; + for s in [Stroke::new(2.5, Color32::from_black_alpha(140)), Stroke::new(1.0, Color32::from_white_alpha(220))] { + clip.line_segment([hp - vec2(3.0, 0.0), hp + vec2(3.0, 0.0)], s); + if add { + clip.line_segment([hp - vec2(0.0, 3.0), hp + vec2(0.0, 3.0)], s); + } + } + ctx.set_cursor_icon(egui::CursorIcon::None); + } else { + ctx.set_cursor_icon(match w.tool { + SmTool::Zoom if alt => egui::CursorIcon::ZoomOut, + SmTool::Zoom => egui::CursorIcon::ZoomIn, + _ => egui::CursorIcon::Crosshair, + }); + } + } + clip.rect_stroke(img, 0.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Outside); +} + +/// Pan, zoom-click and tool gestures on the document, as events for `pointer`. +#[allow(clippy::too_many_arguments)] +fn canvas_input( + ui: &egui::Ui, + ctx: &egui::Context, + w: &mut SelectMaskWorkspace, + resp: &egui::Response, + v: View, + pan: bool, + events: &mut Vec, + mods: &mut egui::Modifiers, +) { + if pan && resp.dragged() { + let dl = resp.drag_delta(); + w.center = [w.center[0] - f64::from(dl.x / w.zoom), w.center[1] - f64::from(dl.y / w.zoom)]; + } else if w.tool == SmTool::PolygonalLasso && !pan { + *mods = ui.input(|i| i.modifiers); + if resp.double_clicked() { + w.close_polygon(); + } else if resp.clicked() + && let Some(pp) = resp.interact_pointer_pos() + { + let q = v.to_doc(pp); + events.push(ToolEvent::Down { x: q[0], y: q[1], pressure: 1.0 }); + } + } else if !pan + && resp.is_pointer_button_down_on() + && let Some(pp) = resp.interact_pointer_pos() + { + let q = v.to_doc(pp); + *mods = ui.input(|i| i.modifiers); + if w.pressed { + events.push(ToolEvent::Move { x: q[0], y: q[1], pressure: 1.0 }); + ctx.request_repaint(); + } else { + w.pressed = true; + events.push(ToolEvent::Down { x: q[0], y: q[1], pressure: 1.0 }); + } + } + if w.pressed && !resp.is_pointer_button_down_on() { + w.pressed = false; + let q = w.hover.unwrap_or(w.center); + events.push(ToolEvent::Up { x: q[0], y: q[1] }); + } +} + /// Draws the workspace (a full-window layer over the app). pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { follow_revision(app); @@ -1698,140 +1852,12 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { } } let v = View { area, zoom: w.zoom, center: w.center }; - let clip = painter.with_clip_rect(area); - let img = v.rect(w.canvas); - let uv = ERect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); - widgets::checker(&clip, img.intersect(area), 8.0); - let preview = w.preview.tex.as_ref().filter(|_| !w.hide_view); - match (preview, &w.image_tex) { - (Some(tex), _) => { - // The reduced copy's last row and column may reach past the canvas. - let covers = ERect::from_min_size(img.min, vec2(w.preview.covers[0] * v.zoom, w.preview.covers[1] * v.zoom)); - clip.with_clip_rect(img.intersect(area)).image(tex.id(), covers, uv, Color32::WHITE); - } - (None, Some(tex)) => { - clip.image(tex.id(), img, uv, Color32::WHITE); - } - (None, None) => {} - } - // Marching Ants: the refined selection's edge, black dashes on white. - if !w.hide_view - && w.opts.view == Mode::MarchingAnts - && let Some(edges) = &w.preview.ants - { - let phase = (ui.input(|i| i.time) * 8.0) as usize; - for e in edges { - let (a, b) = (v.to_screen([f64::from(e[0][0]), f64::from(e[0][1])]), v.to_screen([f64::from(e[1][0]), f64::from(e[1][1])])); - let dark = ((e[0][0] + e[0][1]) as usize / 4 + phase).is_multiple_of(2); - clip.line_segment([a, b], Stroke::new(1.0, if dark { Color32::BLACK } else { Color32::WHITE })); - } - ctx.request_repaint_after(std::time::Duration::from_millis(120)); - } - if w.preview.shown != w.preview.want && !w.pressed { - let sp = ERect::from_min_size(area.right_bottom() - vec2(26.0, 26.0), vec2(18.0, 18.0)); - ui.put(sp, egui::Spinner::new().size(16.0)); - } - // The stroke being drawn: the part the preview doesn't show yet (all of it without - // Real-Time Refinement), joined to the part it does, so the stroke follows the pointer. - let ink = Color32::from_rgba_unmultiplied(t.accent.r(), t.accent.g(), t.accent.b(), 110); - if let Some(g) = w.gesture.as_ref().or(w.trail.as_ref()) { - let line: Vec = g.points.iter().map(|p| v.to_screen(*p)).collect(); - match g.tool { - SmTool::Quick | SmTool::Brush | SmTool::RefineEdge if w.preview.trail != usize::MAX => { - let tail = line.get(w.preview.trail.saturating_sub(1)..).unwrap_or_default(); - let r = w.size() as f32 * 0.5 * v.zoom; - for p in tail { - clip.circle_filled(*p, r, ink); - } - if tail.len() > 1 { - clip.add(egui::Shape::line(tail.to_vec(), Stroke::new(2.0 * r, ink))); - } - } - SmTool::Lasso => { - clip.add(egui::Shape::line(line, Stroke::new(1.0, t.accent))); - } - SmTool::Object => { - if let (Some(a), Some(b)) = (line.first(), line.last()) { - clip.rect_stroke(ERect::from_two_pos(*a, *b), 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Middle); - } - } - _ => {} - } - } - if let Some((_, pts)) = &w.poly { - let mut line: Vec = pts.iter().map(|p| v.to_screen(*p)).collect(); - if let Some(hp) = resp.hover_pos() { - line.push(hp); - } - clip.add(egui::Shape::line(line, Stroke::new(1.0, t.accent))); - } + paint_canvas(ui, ctx, w, &t, &painter, &resp, area); // Space, the middle button or the Hand pan. The Polygonal Lasso takes clicks: each adds a // vertex, a double-click closes. let pan = ui.input(|i| i.key_down(egui::Key::Space) || i.pointer.middle_down()) || w.tool == SmTool::Hand; - // The cursor: Photoshop's hand while panning (closed while dragging), the brush's circle - // (with + or −), the zoom glass, else a crosshair. - if let Some(hp) = resp.hover_pos() { - w.hover = Some(v.to_doc(hp)); - } - let alt = ui.input(|i| i.modifiers.alt); - if pan && (resp.dragged() || resp.is_pointer_button_down_on()) { - ctx.set_cursor_icon(egui::CursorIcon::Grabbing); - } else if let Some(hp) = resp.hover_pos() { - if pan { - ctx.set_cursor_icon(egui::CursorIcon::Grab); - } else if w.tool.brushes() { - let r = (w.size() as f32 * 0.5 * v.zoom).max(2.0); - clip.circle_stroke(hp, r, Stroke::new(1.5, Color32::BLACK)); - clip.circle_stroke(hp, r, Stroke::new(0.75, Color32::WHITE)); - let add = w.add != alt; - for s in [Stroke::new(2.5, Color32::from_black_alpha(140)), Stroke::new(1.0, Color32::from_white_alpha(220))] { - clip.line_segment([hp - vec2(3.0, 0.0), hp + vec2(3.0, 0.0)], s); - if add { - clip.line_segment([hp - vec2(0.0, 3.0), hp + vec2(0.0, 3.0)], s); - } - } - ctx.set_cursor_icon(egui::CursorIcon::None); - } else { - ctx.set_cursor_icon(match w.tool { - SmTool::Zoom if alt => egui::CursorIcon::ZoomOut, - SmTool::Zoom => egui::CursorIcon::ZoomIn, - _ => egui::CursorIcon::Crosshair, - }); - } - } - clip.rect_stroke(img, 0.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Outside); - if pan && resp.dragged() { - let dl = resp.drag_delta(); - w.center = [w.center[0] - f64::from(dl.x / w.zoom), w.center[1] - f64::from(dl.y / w.zoom)]; - } else if w.tool == SmTool::PolygonalLasso && !pan { - mods = ui.input(|i| i.modifiers); - if resp.double_clicked() { - w.close_polygon(); - } else if resp.clicked() - && let Some(pp) = resp.interact_pointer_pos() - { - let q = v.to_doc(pp); - events.push(ToolEvent::Down { x: q[0], y: q[1], pressure: 1.0 }); - } - } else if !pan - && resp.is_pointer_button_down_on() - && let Some(pp) = resp.interact_pointer_pos() - { - let q = v.to_doc(pp); - mods = ui.input(|i| i.modifiers); - if w.pressed { - events.push(ToolEvent::Move { x: q[0], y: q[1], pressure: 1.0 }); - ctx.request_repaint(); - } else { - w.pressed = true; - events.push(ToolEvent::Down { x: q[0], y: q[1], pressure: 1.0 }); - } - } - if w.pressed && !resp.is_pointer_button_down_on() { - w.pressed = false; - let q = w.hover.unwrap_or(w.center); - events.push(ToolEvent::Up { x: q[0], y: q[1] }); - } + paint_cursor(ctx, w, &painter.with_clip_rect(area), &resp, v, pan, ui.input(|i| i.modifiers.alt)); + canvas_input(ui, ctx, w, &resp, v, pan, &mut events, &mut mods); if (w.opts.clone(), w.sample_all) != before { w.stale(); } From 4e7dc407f81580ea09e308884c63c89629b96b17 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Wed, 28 Oct 2026 15:58:41 +0530 Subject: [PATCH 75/86] Import texture_quad where transform_tool/begin.rs uses it --- crates/ui-egui/src/tools/transform_tool.rs | 2 +- crates/ui-egui/src/tools/transform_tool/begin.rs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/tools/transform_tool.rs b/crates/ui-egui/src/tools/transform_tool.rs index 3845437e80..1a2b741bc2 100644 --- a/crates/ui-egui/src/tools/transform_tool.rs +++ b/crates/ui-egui/src/tools/transform_tool.rs @@ -51,7 +51,7 @@ pub(crate) use draw::{Readout, live_readout, styled_frame}; pub(crate) use draw::{carries_selection_of, draw_selection}; pub use draw::{draw_content, draw_overlay}; #[cfg(test)] -use draw::{front_vertices, handle_anchor, texture_quad}; +use draw::{front_vertices, handle_anchor}; pub(crate) use options_bar::position_size_fields; #[cfg(test)] use options_bar::{ diff --git a/crates/ui-egui/src/tools/transform_tool/begin.rs b/crates/ui-egui/src/tools/transform_tool/begin.rs index ba49e27042..f3da463d2f 100644 --- a/crates/ui-egui/src/tools/transform_tool/begin.rs +++ b/crates/ui-egui/src/tools/transform_tool/begin.rs @@ -1,4 +1,6 @@ use super::commit::{cancel, commit, start_steps}; +#[cfg(test)] +use super::draw::texture_quad; use super::preview::{Above, preview_image}; use super::{ Arc, BezierMesh, CAS_ONLY_SCALES, Color32, DocId, Document, Follow, Homography, LOCKED, LayerContent, LayerId, MadeLayer, PhotocraftApp, Several, Steps, From 73bef48025e04ab448d091fbe152051dc7229774 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Wed, 28 Oct 2026 17:13:52 +0530 Subject: [PATCH 76/86] Fix Select and Mask modal routing and preview memory --- crates/ui-egui/src/control/tests.rs | 3 +- crates/ui-egui/src/dialogs/distort_ui.rs | 4 +- crates/ui-egui/src/dialogs/select_mask_ui.rs | 39 +++++++++++++++++-- .../src/dialogs/select_mask_ui/tests.rs | 20 ++++++++++ 4 files changed, 61 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index c7b9896e2f..be8535367b 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -134,7 +134,7 @@ fn full_window_workspaces_are_modal_for_menu_invocations() { app.run("layer.new.layer", json!({})).unwrap(); app.run("edit.fill", json!({"color":"#808080"})).unwrap(); app.run("select.rect", json!({"x":8,"y":8,"width":8,"height":8})).unwrap(); - for workspace in ["filter.liquify", "edit.contentAwareFill", "filter.filterGallery", "filter.adaptiveWideAngle"] { + for workspace in ["filter.liquify", "edit.contentAwareFill", "filter.filterGallery", "filter.adaptiveWideAngle", "select.selectAndMask"] { let opened = call(&mut app, &ctx, "ui.menu.invoke", json!({"id":workspace})); assert_eq!(opened["ok"], true, "{workspace}: {opened}"); assert_eq!(crate::distort_ui::modal_workspace(&app), Some(workspace)); @@ -144,6 +144,7 @@ fn full_window_workspaces_are_modal_for_menu_invocations() { json!({"id":"select.all"}), json!({"id":"view.zoomIn"}), json!({"id":"filter.cameraRaw"}), + json!({"id":"filter.liquify"}), json!({"id":workspace,"params":{"ui":null}}), ] { let blocked = call(&mut app, &ctx, "ui.menu.invoke", request.clone()); diff --git a/crates/ui-egui/src/dialogs/distort_ui.rs b/crates/ui-egui/src/dialogs/distort_ui.rs index 5b97971519..b5ee13f464 100644 --- a/crates/ui-egui/src/dialogs/distort_ui.rs +++ b/crates/ui-egui/src/dialogs/distort_ui.rs @@ -103,7 +103,9 @@ pub(crate) fn surface_image(surf: &Surface, r: Rect, max_side: usize) -> ColorIm /// Adaptive Wide Angle). These are modal: other menu commands wait for OK or Cancel /// (`menus::modal_allows`). pub(crate) fn modal_workspace(app: &PhotocraftApp) -> Option<&'static str> { - if app.distort.liquify.is_some() { + if app.distort.select_mask.is_some() { + Some("select.selectAndMask") + } else if app.distort.liquify.is_some() { Some("filter.liquify") } else if app.distort.caf.is_some() { Some("edit.contentAwareFill") diff --git a/crates/ui-egui/src/dialogs/select_mask_ui.rs b/crates/ui-egui/src/dialogs/select_mask_ui.rs index 90eaf3fc79..98e536adee 100644 --- a/crates/ui-egui/src/dialogs/select_mask_ui.rs +++ b/crates/ui-egui/src/dialogs/select_mask_ui.rs @@ -48,6 +48,8 @@ const PANEL_W: f32 = 350.0; const UNDO_LIMIT: usize = 100; /// Marching ants with more edges than this are not drawn. const MAX_ANT_EDGES: usize = 200_000; +/// Bound the temporary dense mask used for marching ants. +const MAX_ANT_PIXELS: usize = 4_000_000; /// The `prefs.dialogs` key under which Remember Settings keeps the settings. const REMEMBERED: &str = "select.refineEdge.workspace"; const CMD: &str = crate::select_mask_view::COMMAND; @@ -1103,6 +1105,38 @@ fn ant_edges(m: &[f32], w: usize, h: usize, k: f32) -> Option Some(out) } +/// Count selected pixels without allocating a canvas-sized mask. Missing tiles use the surface's +/// default value, including an inverted selection whose default is selected. +fn selected_pixels(selection: Option<&Surface>, area: Rect) -> usize { + let Some(surface) = selection else { return 0 }; + let default_selected = surface.default_pixel().first().is_some_and(|v| *v >= 0.5); + let area_pixels = (area.width() as usize).saturating_mul(area.height() as usize); + let mut count = if default_selected { area_pixels } else { 0 }; + for (coord, _) in surface.tiles() { + let tile_area = coord.rect().intersect(&area); + if tile_area.is_empty() { + continue; + } + let pixels = (tile_area.width() as usize).saturating_mul(tile_area.height() as usize); + if default_selected { + count = count.saturating_sub(pixels); + } + let values = photocraft_algo::selection::mask_from_surface(Some(surface), tile_area); + count = count.saturating_add(values.iter().filter(|v| **v >= 0.5).count()); + } + count +} + +/// Generate ants only when the dense temporary mask fits the preview budget. +fn preview_ants(selection: Option<&Surface>, area: Rect, k: f32) -> Option> { + let (w, h) = (area.width() as usize, area.height() as usize); + if w.checked_mul(h).is_none_or(|n| n > MAX_ANT_PIXELS) { + return None; + } + let coverage = photocraft_algo::selection::mask_from_surface(selection, area); + ant_edges(&coverage, w, h, k) +} + /// The preview of `job`: the edits applied (from where the cache left off), the edge refined and /// the result shown in the view, as an image no wider than the device allows. fn compute(job: Job, cancel: Option<&photocraft_engine::jobs::JobCtx>) -> Result<(Shown, BaseCache, Arc), String> { @@ -1150,10 +1184,9 @@ fn compute(job: Job, cancel: Option<&photocraft_engine::jobs::JobCtx>) -> Result let (tw, th) = (pw.div_ceil(f.max(1)), ph.div_ceil(f.max(1))); let buf = photocraft_compose::render_reduced(&shown, u32::try_from(tw).unwrap_or(1), u32::try_from(th).unwrap_or(1)); let image = crate::canvas::display_image(job.display.as_deref(), &buf); - let coverage = photocraft_algo::selection::mask_from_surface(shown.selection.as_ref(), shown.bounds()); - let selected = coverage.iter().filter(|v| **v >= 0.5).count(); + let selected = selected_pixels(shown.selection.as_ref(), shown.bounds()); let mode = params.get("__view").and_then(|v| v.get("mode")).and_then(Value::as_str); - let ants = (mode == Some(Mode::MarchingAnts.id())).then(|| ant_edges(&coverage, pw, ph, k as f32)).flatten(); + let ants = if mode == Some(Mode::MarchingAnts.id()) { preview_ants(shown.selection.as_ref(), shown.bounds(), k as f32) } else { None }; let covers = [(pw as f32) * k as f32, (ph as f32) * k as f32]; Ok((Shown { image, ants, selected, trail: job.trail, covers }, cache, proxy)) } diff --git a/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs b/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs index c9b1f17fa8..0a67478d5e 100644 --- a/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs @@ -772,3 +772,23 @@ fn the_reason_a_stale_workspace_closed_is_translated() { } } } + +#[test] +fn sparse_large_preview_counts_coverage_without_a_dense_canvas_mask() { + let area = Rect::new(0, 0, 100_000, 100_000); + let mut selection = Surface::new(photocraft_color::PixelFormat::GRAY8); + selection.write_pixel(99_999, 99_999, &[1.0]); + selection.write_pixel(2, 3, &[0.5]); + selection.write_pixel(-1, -1, &[1.0]); + assert_eq!(selected_pixels(Some(&selection), area), 2); + assert!(preview_ants(Some(&selection), area, 1.0).is_none()); + let small = Rect::new(0, 0, 10, 10); + assert_eq!(selected_pixels(Some(&selection), small), 1); + assert_eq!(preview_ants(Some(&selection), small, 1.0).unwrap().len(), 4); + + let mut inverted = Surface::with_default(photocraft_color::PixelFormat::GRAY8, &[1.0]); + inverted.write_pixel(2, 3, &[0.0]); + let smaller = Rect::new(0, 0, 1000, 1000); + assert_eq!(selected_pixels(Some(&inverted), smaller), 999_999); + assert_eq!(selected_pixels(None, area), 0); +} From 19cc32cb22ff62ed151533cef6e476a6bfa80b06 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Wed, 28 Oct 2026 17:17:42 +0530 Subject: [PATCH 77/86] Preserve marching ants for large sparse selections --- crates/ui-egui/src/dialogs/select_mask_ui.rs | 53 ++++++++++++++++++- .../src/dialogs/select_mask_ui/tests.rs | 2 +- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs/select_mask_ui.rs b/crates/ui-egui/src/dialogs/select_mask_ui.rs index 98e536adee..3430569775 100644 --- a/crates/ui-egui/src/dialogs/select_mask_ui.rs +++ b/crates/ui-egui/src/dialogs/select_mask_ui.rs @@ -1131,12 +1131,63 @@ fn selected_pixels(selection: Option<&Surface>, area: Rect) -> usize { fn preview_ants(selection: Option<&Surface>, area: Rect, k: f32) -> Option> { let (w, h) = (area.width() as usize, area.height() as usize); if w.checked_mul(h).is_none_or(|n| n > MAX_ANT_PIXELS) { - return None; + return sparse_preview_ants(selection?, area, k); } let coverage = photocraft_algo::selection::mask_from_surface(selection, area); ant_edges(&coverage, w, h, k) } +/// Follow sparse selections on large canvases without scanning the empty canvas. A selected +/// default or too many stored pixels falls back to omitting ants, as for too many edges. +fn sparse_preview_ants(surface: &Surface, area: Rect, k: f32) -> Option> { + if surface.default_pixel().first().is_some_and(|v| *v >= 0.5) { + return None; + } + let mut work = 0usize; + let mut edges = Vec::new(); + let mut sample = vec![0.0; surface.channels()]; + for (coord, _) in surface.tiles() { + let tile = coord.rect().intersect(&area); + if tile.is_empty() { + continue; + } + work = work.checked_add((tile.width() as usize).checked_mul(tile.height() as usize)?)?; + if work > MAX_ANT_PIXELS { + return None; + } + let coverage = photocraft_algo::selection::mask_from_surface(Some(surface), tile); + for y in tile.y0..tile.y1 { + for x in tile.x0..tile.x1 { + let index = (y - tile.y0) as usize * tile.width() as usize + (x - tile.x0) as usize; + if !coverage.get(index).is_some_and(|v| *v >= 0.5) { + continue; + } + let (fx, fy) = ((x - area.x0) as f32, (y - area.y0) as f32); + for (nx, ny, a, b) in [ + (x - 1, y, [fx, fy], [fx, fy + 1.0]), + (x + 1, y, [fx + 1.0, fy], [fx + 1.0, fy + 1.0]), + (x, y - 1, [fx, fy], [fx + 1.0, fy]), + (x, y + 1, [fx, fy + 1.0], [fx + 1.0, fy + 1.0]), + ] { + let selected = if nx < area.x0 || nx >= area.x1 || ny < area.y0 || ny >= area.y1 { + false + } else { + surface.read_pixel(nx, ny, &mut sample); + sample.first().is_some_and(|v| *v >= 0.5) + }; + if !selected { + edges.push([[a[0] * k, a[1] * k], [b[0] * k, b[1] * k]]); + if edges.len() > MAX_ANT_EDGES { + return None; + } + } + } + } + } + } + Some(edges) +} + /// The preview of `job`: the edits applied (from where the cache left off), the edge refined and /// the result shown in the view, as an image no wider than the device allows. fn compute(job: Job, cancel: Option<&photocraft_engine::jobs::JobCtx>) -> Result<(Shown, BaseCache, Arc), String> { diff --git a/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs b/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs index 0a67478d5e..8943e178b3 100644 --- a/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs @@ -781,7 +781,7 @@ fn sparse_large_preview_counts_coverage_without_a_dense_canvas_mask() { selection.write_pixel(2, 3, &[0.5]); selection.write_pixel(-1, -1, &[1.0]); assert_eq!(selected_pixels(Some(&selection), area), 2); - assert!(preview_ants(Some(&selection), area, 1.0).is_none()); + assert_eq!(preview_ants(Some(&selection), area, 1.0).unwrap().len(), 8); let small = Rect::new(0, 0, 10, 10); assert_eq!(selected_pixels(Some(&selection), small), 1); assert_eq!(preview_ants(Some(&selection), small, 1.0).unwrap().len(), 4); From 19fa473039ef11deda7c3200c8fac1de41309086 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Wed, 28 Oct 2026 17:41:37 +0530 Subject: [PATCH 78/86] Keep stale workspace test on programmatic document switch --- crates/ui-egui/src/dialogs/select_mask_ui/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs b/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs index 8943e178b3..7df542fe74 100644 --- a/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs @@ -691,7 +691,7 @@ fn ok_after_a_document_switch_changes_neither_document() { open(&mut app).unwrap(); ui(&mut app, json!({"stroke": {"points": [[120.0, 50.0], [130.0, 50.0]]}})); let before = (fingerprint(&app, 0), fingerprint(&app, 1)); - let switched = call(&mut app, "ui.menu.invoke", json!({"command": "window.document.1"})); + let switched = call(&mut app, "engine.execute", json!({"command": "document.activate", "params": {"document": 1}})); assert_eq!(app.session.active_index(), Some(1), "{switched}"); let r = call(&mut app, "ui.menu.invoke", json!({"command": "select.selectAndMask", "params": {"ui": {"commit": true}}})); assert!(r.get("error").is_some(), "the stale OK is refused: {r}"); From 991ef9907b9b1623b665ff859d124231bd2827ef Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Thu, 29 Oct 2026 21:04:13 +0530 Subject: [PATCH 79/86] fix(actions): allow no-op Arrange steps during playback (#8457) --- crates/engine/src/cmd/actions_cmds.rs | 17 ++++++-- crates/engine/src/cmd/actions_cmds/tests.rs | 45 +++++++++++++++++++++ crates/engine/src/cmd/ops.rs | 22 ++++++++++ 3 files changed, 80 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/actions_cmds.rs b/crates/engine/src/cmd/actions_cmds.rs index 4dcb8bb8b9..46536e4024 100644 --- a/crates/engine/src/cmd/actions_cmds.rs +++ b/crates/engine/src/cmd/actions_cmds.rs @@ -367,9 +367,18 @@ fn stop(s: &mut Session, _p: &Value) -> Result { } /// A step Photoshop plays as a silent no-op although its menu item is greyed: Deselect with an -/// open document and nothing selected (actions often start with it to clear any selection, #6744). -fn noop_step(s: &Session, id: &str) -> bool { - id == "select.deselect" && s.active().is_some_and(|d| d.doc.selection.is_none()) +/// open document and nothing selected (actions often start with it to clear any selection, #6744), +/// and an Arrange that can't move the layer, such as Bring to Front on a new adjustment layer that +/// is already on top (#8453). +fn noop_step(s: &Session, id: &str, params: &Value) -> bool { + let Some(d) = s.active() else { return false }; + let dir = match id { + "select.deselect" => return d.doc.selection.is_none(), + "layer.arrange.bringForward" | "layer.arrange.bringToFront" => 1, + "layer.arrange.sendBackward" | "layer.arrange.sendToBack" => -1, + _ => return false, + }; + crate::commands::layer_param(s, params).is_ok_and(|layer| super::ops::arrange_at_end(&d.doc, layer, dir)) } fn run_recorded(s: &mut Session, action: &Action, from: usize) -> (u64, Option) { @@ -386,7 +395,7 @@ fn run_recorded(s: &mut Session, action: &Action, from: usize) -> (u64, Option = s.active().unwrap().doc.walk().into_iter().map(|(_, _, l)| l.id).collect(); + let bg = crate::background_cmds::background_id(&s.active().unwrap().doc); + for id in ids.into_iter().filter(|id| Some(*id) != bg) { + for (cmd, dir) in cmds { + let at_end = super::super::ops::arrange_at_end(&s.active().unwrap().doc, id, dir); + let ran = s.execute(cmd, json!({"layer": id.0})); + assert_eq!(at_end, ran.is_err(), "{cmd} on {id:?}: {ran:?}"); + if ran.is_ok() { + s.execute("edit.undo", json!({})).unwrap(); + } + } + } + + // Arranging the Background still stops playback: Photoshop refuses it too. + let bg = bg.unwrap(); + s.actions.list.push(Action::new("Bg", vec![("layer.arrange.bringToFront".to_string(), json!({"layer": bg.0}))])); + let played = s.execute("actions.play", json!({"action": 1})).unwrap(); + assert_eq!(played["ran"], 0, "{played}"); + assert!(played["failed"].is_object(), "{played}"); +} diff --git a/crates/engine/src/cmd/ops.rs b/crates/engine/src/cmd/ops.rs index c106af6aac..19a62d0a8b 100644 --- a/crates/engine/src/cmd/ops.rs +++ b/crates/engine/src/cmd/ops.rs @@ -2,6 +2,28 @@ use super::enabled::check_all_unlocked; use super::layer_params::{layer_param, mask_at_depth}; use super::{Document, EngineError, Layer, LayerContent, LayerId, LayerMask, Result, Session, Value, json, pixels}; +/// Is layer `id` already as far as Arrange can take it in direction `dir` (up when positive)? True +/// at the top or bottom of its stack, or just above the Background, which stays at the bottom. The +/// Background itself is not: arranging it is refused outright. +pub(crate) fn arrange_at_end(doc: &Document, id: LayerId, dir: i32) -> bool { + let Some(path) = doc.path_of(id) else { return false }; + let Some((&idx, parent)) = path.split_last() else { return false }; + let len = if parent.is_empty() { + doc.layers.len() + } else { + match doc.layer_at(parent).and_then(Layer::children) { + Some(children) => children.len(), + None => return false, + } + }; + let bg = if parent.is_empty() { crate::background_cmds::background_id(doc) } else { None }; + if bg == Some(id) { + return false; + } + let floor = usize::from(bg.is_some()); + if dir > 0 { idx.saturating_add(1) >= len } else { idx <= floor } +} + pub(super) fn arrange(s: &mut Session, p: &Value, delta: i32) -> Result { let id = layer_param(s, p)?; s.edit("Arrange", |doc, _| { From 6b2ad6b620dae0f6cabbd22b3a12ade8d1f1542e Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Thu, 29 Oct 2026 21:05:24 +0530 Subject: [PATCH 80/86] Reduce Noise: reuse guide statistics with bit-identical output (#9639) * perf(algo): reuse Reduce Noise guide statistics * fix(algo): use fixed-size chunks for guide statistics * Reduce Noise: test that the caller routes the cleaned luma guide and both chroma planes Review follow-up: compares reduce_noise's output (Lab, so planes pass through) with the independent guided filters run in order. * test(algo): assert bit-identical output in Reduce Noise routing test --- crates/algo/src/denoise.rs | 179 +++++++++++++++++++++++++++++++------ 1 file changed, 153 insertions(+), 26 deletions(-) diff --git a/crates/algo/src/denoise.rs b/crates/algo/src/denoise.rs index 07f4745635..57260050bd 100644 --- a/crates/algo/src/denoise.rs +++ b/crates/algo/src/denoise.rs @@ -28,28 +28,57 @@ pub(crate) fn reach() -> f32 { (2 * MAX_LUMA_R + 2 * MAX_CHROMA_R + 6) as f32 } -/// Guided filter of `p` with guide `g` (both `w × h`), box radius `r`. -fn guided(g: &[f32], p: &[f32], w: usize, h: usize, r: usize, eps: f32) -> Vec { - let len = w * h; - // Interleave the four box-filtered moments to run one pass. - let mut m = vec![0.0f32; len * 4]; - for i in 0..len { - m[i * 4] = g[i]; - m[i * 4 + 1] = p[i]; - m[i * 4 + 2] = g[i] * g[i]; - m[i * 4 + 3] = g[i] * p[i]; +/// Shared first and second moments of a guide (two filtered channels). +fn guide_moments(g: &[f32], w: usize, h: usize, r: usize) -> Vec { + let mut moments = Vec::with_capacity(g.len() * 2); + for &v in g { + moments.extend_from_slice(&[v, v * v]); } - box_blur_n(&mut m, w, h, 4, r); - let mut ab = vec![0.0f32; len * 2]; - for i in 0..len { - let (mg, mp, mgg, mgp) = (m[i * 4], m[i * 4 + 1], m[i * 4 + 2], m[i * 4 + 3]); - let var = (mgg - mg * mg).max(0.0); - let a = (mgp - mg * mp) / (var + eps); - ab[i * 2] = a; - ab[i * 2 + 1] = mp - a * mg; + box_blur_n(&mut moments, w, h, 2, r); + moments +} + +/// Self-guided luminance: mean(p) = mean(g), mean(g*p) = mean(g*g). +fn guided_luma(g: &[f32], w: usize, h: usize, r: usize, eps: f32) -> Vec { + let mut ab = guide_moments(g, w, h, r); + for pair in ab.as_chunks_mut::<2>().0.iter_mut() { + let mean = pair[0]; + let covariance = pair[1] - mean * mean; + // Keep the original unclamped numerator, including roundoff below zero. + let a = covariance / (covariance.max(0.0) + eps); + pair[0] = a; + pair[1] = mean - a * mean; } box_blur_n(&mut ab, w, h, 2, r); - (0..len).map(|i| ab[i * 2] * g[i] + ab[i * 2 + 1]).collect() + g.iter().zip(ab.as_chunks::<2>().0.iter()).map(|(&v, pair)| pair[0] * v + pair[1]).collect() +} + +/// Chroma planes share the cleaned guide's mean and regularised variance. +/// Two guide channels plus two moments and two coefficients per chroma plane: +/// ten filtered channels, versus twelve for two independent guided filters. +fn guided_chroma(g: &[f32], planes: [&mut [f32]; 2], w: usize, h: usize, r: usize, eps: f32, amount: f32) { + let mut stats = guide_moments(g, w, h, r); + for pair in stats.as_chunks_mut::<2>().0.iter_mut() { + pair[1] = (pair[1] - pair[0] * pair[0]).max(0.0) + eps; + } + for plane in planes { + let mut ab = Vec::with_capacity(g.len() * 2); + for (&guide, &p) in g.iter().zip(plane.iter()) { + ab.extend_from_slice(&[p, guide * p]); + } + box_blur_n(&mut ab, w, h, 2, r); + for (pair, guide) in ab.as_chunks_mut::<2>().0.iter_mut().zip(stats.as_chunks::<2>().0.iter()) { + let mean = pair[0]; + let a = (pair[1] - guide[0] * mean) / guide[1]; + pair[0] = a; + pair[1] = mean - a * guide[0]; + } + box_blur_n(&mut ab, w, h, 2, r); + for ((v, &guide), pair) in plane.iter_mut().zip(g).zip(ab.as_chunks::<2>().0.iter()) { + let filtered = pair[0] * guide + pair[1]; + *v += (filtered - *v) * amount; + } + } } pub(crate) fn reduce_noise(src: &Image, out: Rect, ctx: &Ctx, spec: &DenoiseSpec) -> Vec { @@ -125,7 +154,7 @@ pub(crate) fn reduce_noise(src: &Image, out: Rect, ctx: &Ctx, spec: &DenoiseSpec let r = (1.0 + spec.strength.clamp(0.0, 10.0) * 0.4).round() as usize; let keep = spec.preserve.clamp(0.0, 100.0) / 100.0; let eps = (0.004 + 0.05 * s).powi(2) * (1.05 - keep); - let d = guided(&yv, &yv, ww, wh, r.min(MAX_LUMA_R), eps); + let d = guided_luma(&yv, ww, wh, r.min(MAX_LUMA_R), eps); let k = s.sqrt(); for (v, dv) in yv.iter_mut().zip(d) { *v += (dv - *v) * k; @@ -135,12 +164,7 @@ pub(crate) fn reduce_noise(src: &Image, out: Rect, ctx: &Ctx, spec: &DenoiseSpec if col > 0.0 && model != Model::Gray { let r = (1.0 + col * 8.0).round() as usize; let eps = (0.01 + 0.05 * col).powi(2); - for plane in [&mut c1, &mut c2] { - let d = guided(&yv, plane, ww, wh, r.min(MAX_CHROMA_R), eps); - for (v, dv) in plane.iter_mut().zip(d) { - *v += (dv - *v) * col; - } - } + guided_chroma(&yv, [&mut c1, &mut c2], ww, wh, r.min(MAX_CHROMA_R), eps, col); } // Sharpen Details: unsharp mask on luma. if sh > 0.0 { @@ -178,3 +202,106 @@ pub(crate) fn reduce_noise(src: &Image, out: Rect, ctx: &Ctx, spec: &DenoiseSpec } res } + +#[cfg(test)] +mod tests { + use super::*; + + /// Guided filter of `p` with guide `g` (both `w × h`), box radius `r`. + fn reference_guided(g: &[f32], p: &[f32], w: usize, h: usize, r: usize, eps: f32) -> Vec { + let len = w * h; + // Interleave the four box-filtered moments to run one pass. + let mut m = vec![0.0f32; len * 4]; + for i in 0..len { + m[i * 4] = g[i]; + m[i * 4 + 1] = p[i]; + m[i * 4 + 2] = g[i] * g[i]; + m[i * 4 + 3] = g[i] * p[i]; + } + box_blur_n(&mut m, w, h, 4, r); + let mut ab = vec![0.0f32; len * 2]; + for i in 0..len { + let (mg, mp, mgg, mgp) = (m[i * 4], m[i * 4 + 1], m[i * 4 + 2], m[i * 4 + 3]); + let var = (mgg - mg * mg).max(0.0); + let a = (mgp - mg * mp) / (var + eps); + ab[i * 2] = a; + ab[i * 2 + 1] = mp - a * mg; + } + box_blur_n(&mut ab, w, h, 2, r); + (0..len).map(|i| ab[i * 2] * g[i] + ab[i * 2 + 1]).collect() + } + + #[test] + fn shared_statistics_match_independent_filters_bit_for_bit() { + for (w, h) in [(1, 1), (1, 19), (23, 1), (7, 5), (33, 17)] { + let len = w * h; + for flat in [false, true] { + let g: Vec = (0..len).map(|i| if flat { 0.1 } else { ((i * 37 % 101) as f32 - 20.0) / 63.0 }).collect(); + let p: Vec = (0..len).map(|i| (i * 13 % 71) as f32 / 70.0 - 0.5).collect(); + let q: Vec = (0..len).map(|i| (i * 29 % 83) as f32 / 41.0 - 1.0).collect(); + for r in [0, 1, MAX_LUMA_R, MAX_CHROMA_R, 25] { + for eps in [0.000001, 0.0036] { + let expected = reference_guided(&g, &g, w, h, r, eps); + let actual = guided_luma(&g, w, h, r, eps); + assert_bits(&actual, &expected); + for amount in [0.01, 0.45, 1.0] { + let mut actual_p = p.clone(); + let mut actual_q = q.clone(); + guided_chroma(&g, [&mut actual_p, &mut actual_q], w, h, r, eps, amount); + for (original, actual) in [(&p, &actual_p), (&q, &actual_q)] { + let filtered = reference_guided(&g, original, w, h, r, eps); + let expected: Vec = original.iter().zip(filtered).map(|(&v, d)| v + (d - v) * amount).collect(); + assert_bits(actual, &expected); + } + } + } + } + } + } + } + + #[test] + fn reduce_noise_guides_both_chroma_planes_by_the_cleaned_luma() { + // Caller wiring: Lab keeps its planes as they are, so each output plane is compared with + // the independent filters run on the planes in order (luma by itself, chroma by the + // cleaned luma). + let (w, h) = (19, 13); + let rect = Rect::new(0, 0, w as i32, h as i32); + let mut src = Image::new(rect, 3); + for (i, px) in src.data.as_chunks_mut::<3>().0.iter_mut().enumerate() { + px[0] = (i * 37 % 101) as f32 / 100.0; + px[1] = (i * 13 % 71) as f32 / 70.0 - 0.5; + px[2] = (i * 29 % 83) as f32 / 41.0 - 1.0; + } + let ctx = Ctx { bounds: rect, mode: ColorMode::Lab, alpha: false }; + let spec = DenoiseSpec { strength: 6.0, preserve: 30.0, color: 50.0, sharpen: 0.0, jpeg: false }; + let out = reduce_noise(&src, rect, &ctx, &spec); + + let plane = |c: usize| -> Vec { src.data.as_chunks::<3>().0.iter().map(|px| px[c]).collect() }; + let (luma, chroma) = (plane(0), [plane(1), plane(2)]); + let s = spec.strength / 10.0; + let eps = (0.004 + 0.05 * s).powi(2) * (1.05 - spec.preserve / 100.0); + let smooth = reference_guided(&luma, &luma, w, h, (1.0 + spec.strength * 0.4).round() as usize, eps); + let cleaned: Vec = luma.iter().zip(&smooth).map(|(&v, &d)| v + (d - v) * s.sqrt()).collect(); + let col = spec.color / 100.0; + let chroma_r = (1.0 + col * 8.0).round() as usize; + let chroma_eps = (0.01 + 0.05 * col).powi(2); + let mut expected = vec![cleaned]; + for p in &chroma { + let filtered = reference_guided(&expected[0], p, w, h, chroma_r, chroma_eps); + expected.push(p.iter().zip(filtered).map(|(&v, f)| v + (f - v) * col).collect()); + } + for (c, expected) in expected.iter().enumerate() { + let actual: Vec = out.as_chunks::<3>().0.iter().map(|px| px[c]).collect(); + assert_bits(&actual, expected); + assert!(actual.iter().zip(&plane(c)).any(|(a, o)| (a - o).abs() > 1e-3), "plane {c} was filtered"); + } + } + + fn assert_bits(actual: &[f32], expected: &[f32]) { + assert_eq!(actual.len(), expected.len()); + for (i, (actual, expected)) in actual.iter().zip(expected).enumerate() { + assert_eq!(actual.to_bits(), expected.to_bits(), "pixel {i}: {actual} != {expected}"); + } + } +} From 66ac154d6870a6e7f875c2193796242dc14c874e Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Thu, 29 Oct 2026 21:06:34 +0530 Subject: [PATCH 81/86] perf(algo): select median stack samples without full sorting (#9628) --- crates/algo/src/stack.rs | 14 ++++-- crates/algo/src/stack/tests.rs | 80 ++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 4 deletions(-) diff --git a/crates/algo/src/stack.rs b/crates/algo/src/stack.rs index 3d0c9a87b3..482e8df9a7 100644 --- a/crates/algo/src/stack.rs +++ b/crates/algo/src/stack.rs @@ -99,11 +99,17 @@ fn stat(v: &mut [f32], s: Stat, scratch: &mut EntropyScratch) -> f32 { Stat::Maximum => return v.iter().copied().fold(f32::MIN, f32::max), Stat::Range => return v.iter().copied().fold(f32::MIN, f32::max) - v.iter().copied().fold(f32::MAX, f32::min), Stat::Median => { - // `total_cmp` is a total order whose ties are bit-identical values, so an unstable - // sort gives exactly the stable sort's result. - v.sort_unstable_by(f32::total_cmp); let k = v.len(); - return if k % 2 == 1 { v[k / 2] } else { (v[k / 2 - 1] + v[k / 2]) / 2.0 }; + // Nonempty above, so k / 2 is in bounds. Selection puts the upper middle value in + // place in O(k); the lower partition need not be sorted. Use the same total order + // for its maximum to preserve signed zeros, infinities and NaNs. + let (lower, middle, _) = v.select_nth_unstable_by(k / 2, f32::total_cmp); + return if k % 2 == 1 { + *middle + } else { + // For even k >= 2 the lower partition contains the other middle value. + lower.iter().max_by(|a, b| a.total_cmp(b)).map_or(*middle, |lo| (*lo + *middle) / 2.0) + }; } Stat::Entropy => { if v.len() < 2 { diff --git a/crates/algo/src/stack/tests.rs b/crates/algo/src/stack/tests.rs index 9e14a6be44..3708ceb40d 100644 --- a/crates/algo/src/stack/tests.rs +++ b/crates/algo/src/stack/tests.rs @@ -60,6 +60,86 @@ fn median_removes_outlier() { assert_eq!(combine(&layers, Stat::Median)[0][0], 0.5); } +fn sorted_median(v: &mut [f32]) -> f32 { + v.sort_unstable_by(f32::total_cmp); + let k = v.len(); + if k == 0 { + 0.0 + } else if k % 2 == 1 { + v[k / 2] + } else { + (v[k / 2 - 1] + v[k / 2]) / 2.0 + } +} + +fn assert_median_matches_sort(mut values: Vec) { + let expected = sorted_median(&mut values.clone()); + let actual = stat(&mut values, Stat::Median, &mut EntropyScratch::new()); + if expected.is_nan() { + // IEEE arithmetic does not specify which NaN payload an even median propagates. + assert!(actual.is_nan()); + } else { + assert_eq!(actual.to_bits(), expected.to_bits(), "{values:?}"); + } +} + +#[test] +fn median_selection_matches_sort_for_varied_stack_sizes_and_orders() { + for len in 0..=257 { + let ascending: Vec = (0..len).map(|i| i as f32 / 16.0 - 8.0).collect(); + assert_median_matches_sort(ascending.clone()); + assert_median_matches_sort(ascending.into_iter().rev().collect()); + assert_median_matches_sort(vec![0.25; len]); + assert_median_matches_sort((0..len).map(|i| ((i * 31) % 7) as f32 - 3.0).collect()); + // Deterministic shuffled samples, including float bit patterns outside 0..=1. + let mut seed = 0x9e37_79b9u32; + assert_median_matches_sort( + (0..len) + .map(|_| { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + f32::from_bits(seed) + }) + .collect(), + ); + } +} + +#[test] +fn median_selection_preserves_total_float_order() { + let special = [f32::from_bits(0xffc0_0001), f32::NEG_INFINITY, -f32::MAX, -1.0, -0.0, 0.0, 1.0, f32::MAX, f32::INFINITY, f32::from_bits(0x7fc0_0001)]; + for &a in &special { + // An odd median returns the selected NaN's bits without doing arithmetic. + assert_eq!(stat(&mut [a], Stat::Median, &mut EntropyScratch::new()).to_bits(), a.to_bits()); + for &b in &special { + assert_median_matches_sort(vec![a, b]); + for &c in &special { + let mut values = [a, b, c]; + let expected = sorted_median(&mut values.clone()); + assert_eq!(stat(&mut values, Stat::Median, &mut EntropyScratch::new()).to_bits(), expected.to_bits()); + } + } + } +} + +#[test] +fn deep_median_stack_uses_only_visible_samples_per_channel() { + for count in [32, 33, 128, 129] { + let layers: Vec> = (0..count) + .map(|i| { + let pixel = [((i * 31) % 137) as f32, -(i as f32), (i % 5) as f32, if i % 3 == 0 { 0.0 } else { 0.5 }]; + if i % 7 == 0 { vec![pixel] } else { vec![pixel, [0.0; 4]] } + }) + .collect(); + let actual = combine(&layers, Stat::Median); + for channel in 0..3 { + let mut samples: Vec = layers.iter().filter(|l| l[0][3] > 0.0).map(|l| l[0][channel]).collect(); + assert_eq!(actual[0][channel].to_bits(), sorted_median(&mut samples).to_bits()); + } + assert_eq!(actual[0][3], 0.5); + assert_eq!(actual[1], [0.0; 4]); + } +} + /// Five layers over six pixels (the last layer is short). Covers opaque, partly transparent, /// fully transparent, HDR / negative, single-sample and tied-value pixels. fn fixture() -> Vec> { From 6c6cfc243a1ca8942e40049e00027f0f8331d5b3 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Thu, 29 Oct 2026 21:40:12 +0530 Subject: [PATCH 82/86] perf(stack): cache bounded entropy terms across pixels (#9622) * perf(stack): cache bounded entropy terms across pixels * test(stack): iterate entropy terms directly for Clippy * ci: rerun checks --- crates/algo/examples/bench_stack_entropy.rs | 104 ++++++++++++++++++++ crates/algo/src/stack.rs | 65 ++++++++++-- crates/algo/src/stack/tests.rs | 96 ++++++++++++++++++ 3 files changed, 259 insertions(+), 6 deletions(-) create mode 100644 crates/algo/examples/bench_stack_entropy.rs diff --git a/crates/algo/examples/bench_stack_entropy.rs b/crates/algo/examples/bench_stack_entropy.rs new file mode 100644 index 0000000000..b496b69d3f --- /dev/null +++ b/crates/algo/examples/bench_stack_entropy.rs @@ -0,0 +1,104 @@ +//! Stack Mode Entropy timings and exact output hashes, including lookup preparation. +//! Run the same example on both revisions: `cargo run --release -p photocraft-algo --example +//! bench_stack_entropy -- [--quick] [--reps N] [--threads N] [--json path]`. +//! Full cases use 24/36 MP; quick cases use 1/16 as many pixels. The deep-stack fallback is +//! deliberately small. Input generation, output hashing and deallocation are outside timings. + +use std::path::PathBuf; +use std::time::Instant; + +use photocraft_algo::stack::{Stat, combine}; +use photocraft_testkit::perf::{self, Summary}; +use serde_json::json; + +struct Options { + quick: bool, + reps: usize, + threads: Option, + json: Option, +} + +fn options() -> Result { + let mut o = Options { quick: false, reps: 5, threads: None, json: None }; + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--quick" => o.quick = true, + "--reps" => o.reps = args.next().ok_or("--reps needs an integer")?.parse().map_err(|_| "invalid --reps")?, + "--threads" => o.threads = Some(args.next().ok_or("--threads needs an integer")?.parse().map_err(|_| "invalid --threads")?), + "--json" => o.json = Some(args.next().ok_or("--json needs a path")?.into()), + _ => return Err(format!("unknown option: {arg}")), + } + } + if !(1..=25).contains(&o.reps) || o.threads.is_some_and(|n| !(1..=64).contains(&n)) { + return Err("expected 1..=25 repetitions and 1..=64 threads".into()); + } + Ok(o) +} + +fn benchmark(o: &Options) -> Result<(), String> { + let mut rows = Vec::new(); + let cases = [ + ("3 layers opaque 24 MP", 3, 24_000_000, false), + ("7 layers varying alpha 24 MP", 7, 24_000_000, true), + ("3 layers opaque 36 MP", 3, 36_000_000, false), + ("257 layers varying alpha", 257, 8192, true), + ]; + for (name, count, pixels, masked) in cases { + let pixels = if o.quick && pixels > 8192 { pixels / 16 } else { pixels }; + let layers: Vec> = (0..count) + .map(|k| { + (0..pixels) + .map(|i| { + [ + ((i * 31 + k * 17) % 256) as f32 / 255.0, + ((i / 7 + k * 29) % 32) as f32 / 31.0, + ((i + k * 13) % 1024) as f32 / 255.0 - 0.5, + if masked && k > i % (count + 1) { 0.0 } else { 1.0 }, + ] + }) + .collect() + }) + .collect(); + // Warm the worker pool and input pages before collecting samples. + std::hint::black_box(combine(&layers, Stat::Entropy)); + let mut samples = Vec::with_capacity(o.reps); + let mut hash = 0u64; + for rep in 0..o.reps { + let start = Instant::now(); + let out = std::hint::black_box(combine(&layers, Stat::Entropy)); + samples.push(start.elapsed().as_secs_f64() * 1000.0); + if rep == 0 { + hash = out.iter().flatten().fold(0xcbf29ce484222325, |h, v| (h ^ u64::from(v.to_bits())).wrapping_mul(0x100000001b3)); + } + } + let p50 = Summary::of(&samples).map_or(0.0, |s| s.p50); + println!("{name}: {pixels} pixels, {p50:.3} ms p50, hash {hash:016x}, samples_ms={samples:?}"); + let mut row = perf::row(name, &samples, perf::process_peak_rss_bytes(), None); + row["output_hash"] = json!(format!("{hash:016x}")); + row["pixels"] = json!(pixels); + rows.push(row); + } + if let Some(path) = &o.json { + let report = perf::report("bench_stack_entropy", json!({"quick": o.quick, "threads": o.threads}), rows, None); + std::fs::write(path, serde_json::to_vec_pretty(&report).map_err(|e| e.to_string())?).map_err(|e| format!("write {}: {e}", path.display()))?; + } + Ok(()) +} + +fn run() -> Result<(), String> { + let o = options()?; + #[cfg(not(target_arch = "wasm32"))] + if let Some(threads) = o.threads { + let pool = rayon::ThreadPoolBuilder::new().num_threads(threads).build().map_err(|e| e.to_string())?; + return pool.install(|| benchmark(&o)); + } + benchmark(&o) +} + +fn main() { + if let Err(e) = run() { + eprintln!("{e}"); + std::process::exit(1); + } +} diff --git a/crates/algo/src/stack.rs b/crates/algo/src/stack.rs index 482e8df9a7..52e67a3b71 100644 --- a/crates/algo/src/stack.rs +++ b/crates/algo/src/stack.rs @@ -37,8 +37,54 @@ impl EntropyScratch { } } +/// One shared table per combine, never per pixel or worker. Rows cover the contributing sample +/// count, which can change at every pixel because of transparency or unequal layer lengths. +/// The triangular layout stores log2(n) followed by -p*log2(p) for counts 1..=n. Keeping the +/// division after the sum (rather than normalising each term) preserves the old output bits. +struct EntropyTerms { + values: Vec, +} + +impl EntropyTerms { + // At most 130 KiB, independent of image size and total layer count. Deeper stacks retain + // the original calculation; pixels with fewer contributors can still use a cached row. + const MAX_SAMPLES: usize = 256; + + fn new(samples: usize, pixels: usize) -> Self { + let mut values = Vec::new(); + // Tiny images do not amortise preparation, especially with hundreds of layers. + if samples < 2 || pixels < 256 { + return Self { values }; + } + let max = samples.min(Self::MAX_SAMPLES); + // max is capped at 256 before any size arithmetic or allocation. + let len = (max + 1) * (max + 2) / 2; + if values.try_reserve_exact(len).is_err() { + return Self { values }; + } + values.extend_from_slice(&[0.0; 3]); // Unused rows for zero and one sample. + for samples in 2..=max { + let n = samples as f32; + values.push(n.log2()); + for count in 1..=samples { + let p = count as f32 / n; + values.push(-p * p.log2()); + } + } + Self { values } + } + + fn row(&self, samples: usize) -> Option<&[f32]> { + if !(2..=Self::MAX_SAMPLES).contains(&samples) { + return None; + } + let start = samples * (samples + 1) / 2; + self.values.get(start..start + samples + 1) + } +} + /// Shannon entropy (bits) of the 8-bit values in `v`, over log2(N). `v.len() >= 2`. -fn entropy(v: &[f32], scratch: &mut EntropyScratch) -> f32 { +fn entropy(v: &[f32], scratch: &mut EntropyScratch, terms: Option<&[f32]>) -> f32 { let n = v.len() as f32; for x in v { let bin = (x.clamp(0.0, 1.0) * 255.0).round() as u8; @@ -57,8 +103,11 @@ fn entropy(v: &[f32], scratch: &mut EntropyScratch) -> f32 { .touched .iter() .map(|&bin| { - let p = scratch.counts[usize::from(bin)] as f32 / n; - -p * p.log2() + let count = scratch.counts[usize::from(bin)]; + terms.and_then(|row| row.get(count as usize)).copied().unwrap_or_else(|| { + let p = count as f32 / n; + -p * p.log2() + }) }) .sum(); // Reset only the bins used, so the 256-entry array is never cleared wholesale. @@ -66,7 +115,7 @@ fn entropy(v: &[f32], scratch: &mut EntropyScratch) -> f32 { scratch.counts[usize::from(bin)] = 0; } scratch.touched.clear(); - h / n.log2() + h / terms.and_then(|row| row.first()).copied().unwrap_or_else(|| n.log2()) } /// `x` to the power `k` by repeated squaring. Every step is one IEEE 754 multiplication, so the @@ -115,7 +164,7 @@ fn stat(v: &mut [f32], s: Stat, scratch: &mut EntropyScratch) -> f32 { if v.len() < 2 { return 0.0; } - return entropy(v, scratch); + return entropy(v, scratch, None); } _ => {} } @@ -153,7 +202,11 @@ pub fn combine(layers: &[Vec<[f32; 4]>], s: Stat) -> Vec<[f32; 4]> { // serial loop (Mean, Variance, StdDev, Skewness, Kurtosis); this alone cuts Mean's // instructions by a fifth. match s { - Stat::Entropy => combine_with(layers, |v, sc| stat(v, Stat::Entropy, sc)), + Stat::Entropy => { + let pixels = layers.iter().map(Vec::len).max().unwrap_or(0); + let terms = EntropyTerms::new(layers.len(), pixels); + combine_with(layers, |v, sc| if v.len() < 2 { 0.0 } else { entropy(v, sc, terms.row(v.len())) }) + } Stat::Kurtosis => combine_with(layers, |v, sc| stat(v, Stat::Kurtosis, sc)), Stat::Maximum => combine_with(layers, |v, sc| stat(v, Stat::Maximum, sc)), Stat::Mean => combine_with(layers, |v, sc| stat(v, Stat::Mean, sc)), diff --git a/crates/algo/src/stack/tests.rs b/crates/algo/src/stack/tests.rs index 3708ceb40d..d9f041124d 100644 --- a/crates/algo/src/stack/tests.rs +++ b/crates/algo/src/stack/tests.rs @@ -323,6 +323,102 @@ fn entropy_is_deterministic() { } } +/// The pre-lookup calculation, summing the 8-bit bins in ascending order. This deliberately +/// does not call `entropy` or use its lookup/scratch, so it catches changes in rounding too. +fn entropy_reference(v: &[f32]) -> f32 { + if v.len() < 2 { + return 0.0; + } + let mut counts = [0u32; 256]; + for x in v { + counts[(x.clamp(0.0, 1.0) * 255.0).round() as usize] += 1; + } + let n = v.len() as f32; + let h: f32 = counts + .iter() + .filter(|&&count| count > 0) + .map(|&count| { + let p = count as f32 / n; + -p * p.log2() + }) + .sum(); + h / n.log2() +} + +#[test] +fn entropy_terms_preserve_each_operation_and_bound_storage() { + let terms = EntropyTerms::new(usize::MAX, usize::MAX); + let max = EntropyTerms::MAX_SAMPLES; + assert_eq!(terms.values.len(), (max + 1) * (max + 2) / 2); + assert!(terms.values.len() * size_of::() < 130 * 1024); + for samples in 2..=max { + let row = terms.row(samples).unwrap(); + let n = samples as f32; + assert_eq!(row[0].to_bits(), n.log2().to_bits()); + for (count, &term) in row.iter().enumerate().skip(1) { + let p = count as f32 / n; + assert_eq!(term.to_bits(), (-p * p.log2()).to_bits(), "{count}/{samples}"); + } + } + for samples in [0, 1, max + 1, usize::MAX] { + assert!(terms.row(samples).is_none()); + } + assert!(EntropyTerms::new(1, usize::MAX).values.is_empty()); + assert!(EntropyTerms::new(usize::MAX, 255).values.is_empty()); + assert!(EntropyTerms { values: Vec::new() }.row(2).is_none()); +} + +#[test] +fn entropy_lookup_and_fallback_match_reference_bits() { + let terms = EntropyTerms::new(EntropyTerms::MAX_SAMPLES, CHUNK); + let mut scratch = EntropyScratch::new(); + let special = [f32::NAN, f32::NEG_INFINITY, f32::INFINITY, -3.0, -0.0, 0.0, 0.5 / 255.0, 0.5, 1.0, 4.0]; + // Move repeatedly between counts and distributions to exercise scratch reset, duplicate + // bins, every cached denominator and the uncached side of the table boundary. + for samples in (2..=EntropyTerms::MAX_SAMPLES + 2).chain([513, 3, 256, 2]) { + for pattern in 0..4 { + let v: Vec = (0..samples) + .map(|i| match pattern { + 0 => 0.5, + 1 => (i % 256) as f32 / 255.0, + 2 => ((i * 137 + samples * 29) % 65536) as f32 / 65535.0, + _ => special[i % special.len()], + }) + .collect(); + let expected = entropy_reference(&v).to_bits(); + assert_eq!(entropy(&v, &mut scratch, terms.row(samples)).to_bits(), expected, "cached: n={samples}, pattern={pattern}"); + assert_eq!(entropy(&v, &mut scratch, None).to_bits(), expected, "fallback: n={samples}, pattern={pattern}"); + } + } +} + +#[test] +fn entropy_combine_matches_reference_with_changing_contributor_counts() { + for (count, len) in [(19, 2 * CHUNK + 17), (EntropyTerms::MAX_SAMPLES + 1, 513)] { + let layers: Vec> = (0..count) + .map(|k| { + (0..len.saturating_sub(k % 13)) + .map(|i| { + [ + ((i * 31 + k * 7) % 256) as f32 / 255.0, + ((i * 17 + k) % 65536) as f32 / 65535.0, + if (i + k).is_multiple_of(23) { f32::NAN } else { (i % 7) as f32 - 2.0 }, + if k > i % (count + 1) { 0.0 } else { 0.5 }, + ] + }) + .collect() + }) + .collect(); + let expected = bits(&combine_with(&layers, |v, _| entropy_reference(v))); + assert_eq!(bits(&combine(&layers, Stat::Entropy)), expected); + #[cfg(not(target_arch = "wasm32"))] + for threads in [1, 3] { + let pool = rayon::ThreadPoolBuilder::new().num_threads(threads).build().unwrap(); + assert_eq!(pool.install(|| bits(&combine(&layers, Stat::Entropy))), expected, "{threads} threads"); + } + } +} + /// Chunking is invisible: a multi-chunk image (with a ragged last chunk and layers of unequal /// length) gives, pixel by pixel, what combining that pixel on its own gives. #[test] From 539f5bd58f1ee17f15a02c518d834e1776ccc718 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Thu, 29 Oct 2026 21:41:22 +0530 Subject: [PATCH 83/86] perf(algo): row-parallel local_sharpen with fast-path protect_detail (#5426) (#9651) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(algo): row-parallel local_sharpen with fast-path protect_detail (#5426) Refs #5426. Follow-up to #5499. Cause: While #5499 parallelized local_blur and the per-dab mix in DabTool::Focus, local_sharpen in crates/algo/src/retouch.rs remained single-threaded. In addition, protect_detail redundantly evaluated coordinate clamping and strided memory lookups across 8 neighbors inside the per-channel inner loop for every pixel in the dab. On large brush sizes (e.g. 1000–2500 px), strokes took seconds and stalled the application. Change: - Process local_sharpen window rows in parallel with Rayon across threads, with wasm32 serial fallback matching local_blur. - Hoist protect_detail 3x3 neighborhood offset calculations outside the channel loop. - Fast-path interior pixels (x > 0 && x + 1 < w && y > 0 && y + 1 < h) with direct stride offsets, skipping boundary clamping entirely for >99.9% of pixels in large brush dabs. - Add retouch_sharpen_exact integration test verifying bit-for-bit parity against the frozen scalar implementation across layouts, edges, and channels. * bench(engine): add bench_sharpen_tool example --- crates/algo/src/retouch.rs | 66 ++++++++++++--- crates/algo/tests/it/main.rs | 1 + crates/algo/tests/it/retouch_sharpen_exact.rs | 82 +++++++++++++++++++ crates/engine/examples/bench_sharpen_tool.rs | 62 ++++++++++++++ 4 files changed, 201 insertions(+), 10 deletions(-) create mode 100644 crates/algo/tests/it/retouch_sharpen_exact.rs create mode 100644 crates/engine/examples/bench_sharpen_tool.rs diff --git a/crates/algo/src/retouch.rs b/crates/algo/src/retouch.rs index 00388fe399..78b588f787 100644 --- a/crates/algo/src/retouch.rs +++ b/crates/algo/src/retouch.rs @@ -210,34 +210,80 @@ pub fn local_sharpen( protect_detail: bool, ) -> Vec { let (x0, y0, x1, y1) = win; - let ow = x1 - x0; + let (ow, oh) = (x1 - x0, y1 - y0); + if ow == 0 || oh == 0 || ch == 0 { + return Vec::new(); + } let blur = local_blur(data, w, h, ch, alpha, win, 1.0); let mut out = vec![0.0f32; blur.len()]; - for y in y0..y1 { - for x in x0..x1 { - let o = ((y - y0) * ow + (x - x0)) * ch; + let stride = ow * ch; + + // Rows are independent, so process them in parallel with rayon (#5426). + let process_row = |(oy, dst): (usize, &mut [f32])| { + let y = y0 + oy; + let blur_row = &blur[oy * stride..(oy + 1) * stride]; + for ox in 0..ow { + let x = x0 + ox; + let o = ox * ch; let i = (y * w + x) * ch; + + // Precompute 3×3 neighbourhood offsets once per pixel if protecting detail. + let offsets: [usize; 8] = if protect_detail { + if x > 0 && x + 1 < w && y > 0 && y + 1 < h { + let w_ch = w * ch; + [i - w_ch - ch, i - w_ch, i - w_ch + ch, i - ch, i + ch, i + w_ch - ch, i + w_ch, i + w_ch + ch] + } else { + let get_n_idx = |dx: i32, dy: i32| -> usize { + let nx = (x as i32 + dx).clamp(0, w as i32 - 1) as usize; + let ny = (y as i32 + dy).clamp(0, h as i32 - 1) as usize; + (ny * w + nx) * ch + }; + [ + get_n_idx(-1, -1), + get_n_idx(0, -1), + get_n_idx(1, -1), + get_n_idx(-1, 0), + get_n_idx(1, 0), + get_n_idx(-1, 1), + get_n_idx(0, 1), + get_n_idx(1, 1), + ] + } + } else { + [0; 8] + }; + for c in 0..ch { let v = data[i + c]; if Some(c) == alpha { - out[o + c] = v; + dst[o + c] = v; continue; } - let mut s = v + amount * (v - blur[o + c]); + let mut s = v + amount * (v - blur_row[o + c]); if protect_detail { let (mut lo, mut hi) = (v, v); - for (dx, dy) in [(-1i32, -1i32), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)] { - let (nx, ny) = ((x as i32 + dx).clamp(0, w as i32 - 1) as usize, (y as i32 + dy).clamp(0, h as i32 - 1) as usize); - let nv = data[(ny * w + nx) * ch + c]; + for &n_idx in &offsets { + let nv = data[n_idx + c]; lo = lo.min(nv); hi = hi.max(nv); } s = s.clamp(lo, hi); } - out[o + c] = s; + dst[o + c] = s; } } + }; + + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + out.par_chunks_mut(stride).enumerate().for_each(process_row); } + #[cfg(target_arch = "wasm32")] + { + out.chunks_mut(stride).enumerate().for_each(process_row); + } + out } diff --git a/crates/algo/tests/it/main.rs b/crates/algo/tests/it/main.rs index e611f93cb9..1f26e46b8e 100644 --- a/crates/algo/tests/it/main.rs +++ b/crates/algo/tests/it/main.rs @@ -3,4 +3,5 @@ mod liquify_freeze_formats; mod liquify_source_footprint; mod retouch_blur_exact; +mod retouch_sharpen_exact; mod warp_clipped; diff --git a/crates/algo/tests/it/retouch_sharpen_exact.rs b/crates/algo/tests/it/retouch_sharpen_exact.rs new file mode 100644 index 0000000000..9e75a261ab --- /dev/null +++ b/crates/algo/tests/it/retouch_sharpen_exact.rs @@ -0,0 +1,82 @@ +//! Frozen scalar sharpen from main, used as an exact-output oracle. +use photocraft_algo::retouch::{local_blur, local_sharpen}; + +#[test] +fn matches_scalar_sharpen_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 amount in [0.0, 0.5, 1.0, 2.5] { + for protect_detail in [false, true] { + let expected = scalar_local_sharpen(&data, w, h, ch, alpha, win, amount, protect_detail); + let actual = local_sharpen(&data, w, h, ch, alpha, win, amount, protect_detail); + 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:?} amount={amount} protect={protect_detail} sample={i}" + ); + } + } + } + } + } + } + } +} + +#[test] +fn empty_channel_layout_remains_empty() { + assert!(local_sharpen(&[], 5, 5, 0, None, (0, 0, 5, 5), 1.0, true).is_empty()); +} + +#[allow(clippy::too_many_arguments)] +fn scalar_local_sharpen( + data: &[f32], + w: usize, + h: usize, + ch: usize, + alpha: Option, + win: (usize, usize, usize, usize), + amount: f32, + protect_detail: bool, +) -> 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 blur = local_blur(data, w, h, ch, alpha, win, 1.0); + let mut out = vec![0.0f32; blur.len()]; + for y in y0..y1 { + for x in x0..x1 { + let o = ((y - y0) * ow + (x - x0)) * ch; + let i = (y * w + x) * ch; + for c in 0..ch { + let v = data[i + c]; + if Some(c) == alpha { + out[o + c] = v; + continue; + } + let mut s = v + amount * (v - blur[o + c]); + if protect_detail { + let (mut lo, mut hi) = (v, v); + for (dx, dy) in [(-1i32, -1i32), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)] { + let (nx, ny) = ((x as i32 + dx).clamp(0, w as i32 - 1) as usize, (y as i32 + dy).clamp(0, h as i32 - 1) as usize); + let nv = data[(ny * w + nx) * ch + c]; + lo = lo.min(nv); + hi = hi.max(nv); + } + s = s.clamp(lo, hi); + } + out[o + c] = s; + } + } + } + out +} diff --git a/crates/engine/examples/bench_sharpen_tool.rs b/crates/engine/examples/bench_sharpen_tool.rs new file mode 100644 index 0000000000..5a5788b443 --- /dev/null +++ b/crates/engine/examples/bench_sharpen_tool.rs @@ -0,0 +1,62 @@ +//! Reproducible soft Sharpen 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] { + 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,"protectDetail":true}); + let mut times = Vec::new(); + let mut fingerprint = 0u64; + for sample in 0..6 { + let start = Instant::now(); + session.execute("paint.sharpen", 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 82096cff3a98e2f93a04a983de8c95b19486693f Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Thu, 29 Oct 2026 21:42:33 +0530 Subject: [PATCH 84/86] perf(retouch): avoid duplicate rasterization while drawing (#9532) * perf(retouch): generate live dabs without unused coverage * fix(ui): box live stroke variants after retouch state reduction --- crates/engine/examples/bench_live_dab.rs | 119 ++++++++++++++++++++ crates/engine/src/cmd/retouch_cmds.rs | 2 + crates/engine/src/cmd/retouch_cmds/dab.rs | 51 ++++++--- crates/engine/src/cmd/retouch_cmds/tests.rs | 98 ++++++++++++++++ crates/ui-egui/src/canvas/strokes.rs | 20 ++-- 5 files changed, 262 insertions(+), 28 deletions(-) create mode 100644 crates/engine/examples/bench_live_dab.rs diff --git a/crates/engine/examples/bench_live_dab.rs b/crates/engine/examples/bench_live_dab.rs new file mode 100644 index 0000000000..3e98a44789 --- /dev/null +++ b/crates/engine/examples/bench_live_dab.rs @@ -0,0 +1,119 @@ +//! Live retouch latency on a materialized 24 MP layer. Run the same example at base and head +//! in release mode on one machine. Setup, Undo and the full output fingerprint are not timed. +//! This measures engine stroke work; canvas compositing/upload and presentation are excluded. +use std::time::Instant; + +use photocraft_engine::{EngineError, Session, paint::StrokePoint, retouch_cmds::LiveDab}; +use photocraft_geom::Rect; +use serde_json::json; + +fn elapsed_ms(start: Instant) -> f64 { + start.elapsed().as_secs_f64() * 1000.0 +} + +fn median(values: &mut [f64]) -> f64 { + values.sort_by(f64::total_cmp); + values.get(values.len() / 2).copied().unwrap_or(0.0) +} + +fn run() -> Result<(), Box> { + // Four pointer samples per 60 Hz frame, ~1000 px/s, over a 960 px stroke. + // Large soft tips also exercise pixels beyond the canvas edges. + let points: Vec<_> = (0..=240).map(|i| StrokePoint::new(2500.0 + f64::from(i) * 4.0, 2000.0, 1.0)).collect(); + for depth in [8, 16, 32] { + let mut s = Session::new(); + s.execute("file.new", json!({"width":6000,"height":4000,"depth":depth,"mode":"rgb"}))?; + s.execute("layer.new.layer", json!({}))?; + s.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()))?; + let fmt = surface.format(); + for x in (0..6000).step_by(64) { + let color = if x % 128 == 0 { [0.9, 0.3, 0.1, 0.7] } else { [0.1, 0.4, 0.8, 1.0] }; + surface.fill_rect(Rect::new(x, 0, (x + 64).min(6000), 4000), &photocraft_raster::from_rgba(&fmt, color)); + } + Ok(()) + })?; + for cmd in ["paint.blur", "paint.smudge", "paint.dodge"] { + for size in [300, 600, 1000, 2500] { + for hardness in [0, 100] { + let params = json!({ + "points":points.iter().map(|q| [q.x, q.y]).collect::>(), + "size":size,"hardness":hardness,"strength":50,"spacing":25 + }); + let mut start_params = params.clone(); + start_params["points"] = json!([[2500, 2000]]); + let (mut draws, mut worst_frames, mut releases) = (Vec::new(), Vec::new(), Vec::new()); + let mut fingerprint = None; + // One warm-up followed by three measured strokes, always starting from the same pixels. + for sample in 0..4 { + let start = Instant::now(); + let mut live = LiveDab::begin(&s, cmd, &start_params)?; + let mut draw = elapsed_ms(start); + let mut worst = draw; + for batch in points.get(1..).unwrap_or_default().chunks(4) { + let start = Instant::now(); + live.push(batch)?; + let duration = elapsed_ms(start); + draw += duration; + worst = worst.max(duration); + } + let bounds = live.bounds(); + let start = Instant::now(); + s.offer_live_dab(live)?; + let result = s.execute(cmd, params.clone())?; + let release = elapsed_ms(start); + if result.get("fromLive").and_then(|v| v.as_bool()) != Some(true) { + return Err("the commit did not take the live pixels".into()); + } + let st = s.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")?; + // Include finishing dabs, without depending on a new API unavailable at base. + let damage = result.get("damage").and_then(|v| v.as_array()).ok_or("missing damage")?; + if damage.len() != 4 { + return Err("invalid damage length".into()); + } + let mut coords = [0i32; 4]; + for (coord, value) in coords.iter_mut().zip(damage) { + *coord = i32::try_from(value.as_i64().ok_or("invalid damage")?)?; + } + let [x, y, width, height] = coords; + let bounds = bounds.union(&Rect::new(x, y, x.saturating_add(width), y.saturating_add(height))); + let mut hash = 0xcbf29ce484222325u64; + // Hash every channel of the modified rectangle, one row at a time to bound memory. + for y in bounds.y0..bounds.y1 { + for channel in surface.read_region(Rect::new(bounds.x0, y, bounds.x1, y + 1)) { + hash = (hash ^ u64::from(channel.to_bits())).wrapping_mul(0x100000001b3); + } + } + if fingerprint.is_some_and(|previous| previous != hash) { + return Err("repeated strokes produced different pixels".into()); + } + fingerprint = Some(hash); + s.execute("edit.undo", json!({}))?; + if sample > 0 { + draws.push(draw); + worst_frames.push(worst); + releases.push(release); + } + } + println!( + "{cmd} depth={depth} size={size} hardness={hardness} draw_ms={:.3} worst_frame_ms={:.3} release_ms={:.3} fingerprint={:016x}", + median(&mut draws), + median(&mut worst_frames), + median(&mut releases), + fingerprint.unwrap_or_default() + ); + } + } + } + } + Ok(()) +} + +fn main() { + if let Err(error) = run() { + eprintln!("{error}"); + std::process::exit(1); + } +} diff --git a/crates/engine/src/cmd/retouch_cmds.rs b/crates/engine/src/cmd/retouch_cmds.rs index bb754e13a0..96cb4e0b0b 100644 --- a/crates/engine/src/cmd/retouch_cmds.rs +++ b/crates/engine/src/cmd/retouch_cmds.rs @@ -220,6 +220,8 @@ mod sampling; pub use clone::{LiveRetouch, clone_preview, clone_preview_periodic}; use clone::{blend_param, clamp_samples, clone_stamp}; pub(crate) use dab::DabEffect; +#[cfg(test)] +use dab::DabTool; pub use dab::LiveDab; use dab::dab_cmd; #[cfg(test)] diff --git a/crates/engine/src/cmd/retouch_cmds/dab.rs b/crates/engine/src/cmd/retouch_cmds/dab.rs index 7ff5a600e6..a0f5e3a34d 100644 --- a/crates/engine/src/cmd/retouch_cmds/dab.rs +++ b/crates/engine/src/cmd/retouch_cmds/dab.rs @@ -125,7 +125,7 @@ fn tone_range(p: &Value, cmd: &str) -> Result { /// A sequential-dab tool's settings (Dodge, Burn, Sponge, Blur, Sharpen, Smudge), parsed once and /// turned into its per-dab effect, for the commit and the live preview alike. #[derive(Clone, Debug, PartialEq)] -enum DabTool { +pub(super) enum DabTool { Tone { burn: bool, range: ToneRange, exposure: f32, protect: bool }, Sponge { saturate: bool, vibrance: bool }, Focus { sharpen: bool, strength: f32, protect: bool, sigma: f32 }, @@ -168,7 +168,7 @@ impl DabNeighborhood { impl DabTool { /// The tool for `cmd` and its history label. - fn parse(s: &Session, cmd: &str, p: &Value, stroke: &Stroke) -> Result<(Self, &'static str)> { + pub(super) fn parse(s: &Session, cmd: &str, p: &Value, stroke: &Stroke) -> Result<(Self, &'static str)> { Ok(match cmd { "paint.dodge" | "paint.burn" => { let burn = cmd == "paint.burn"; @@ -216,7 +216,7 @@ impl DabTool { } /// The per-dab effect on a working copy in `fmt`, for a brush of `spacing`. - fn effect(&self, fmt: PixelFormat, spacing: f32) -> DabEffect { + pub(super) fn effect(&self, fmt: PixelFormat, spacing: f32) -> DabEffect { let sp = spacing; match self.clone() { Self::Tone { burn, range, exposure, protect } => tone_stroke(fmt, move |c| dodge_burn(c, exposure, range, burn, protect)), @@ -380,7 +380,11 @@ pub struct LiveDab { /// The active document with the stroke so far. pub doc: std::sync::Arc, cmd: String, - renderer: photocraft_paint::StrokeRenderer, + ctx: photocraft_paint::BrushContext, + generator: photocraft_paint::dynamics::DabGenerator, + /// Only the current batch is retained; sequential effects don't use accumulated coverage. + pending: Vec, + dual_pending: Vec, done: usize, effect: DabEffect, neighborhood: DabNeighborhood, @@ -484,12 +488,17 @@ impl LiveDab { let (surf, lock) = crate::channel_cmds::target_surface(&mut doc, id, p)?; let pre_surf = surf.clone(); let effect = effect(&pre_surf); - // `apply_dab_stroke` generates the same dabs (`dabs`: zoom 1, no smoothing scale). - let renderer = photocraft_paint::StrokeRenderer::new(&stroke.brush, None, 1.0).record_dabs(); + // Generate the same dabs as `apply_dab_stroke` (zoom 1), without a StrokeRenderer: + // its accumulated coverage isn't used here, and each effect rasterizes its own footprint. + let ctx = photocraft_paint::BrushContext::new(&stroke.brush); + let generator = photocraft_paint::dynamics::DabGenerator::new(&stroke.brush, 1.0); let mut live = Self { doc: std::sync::Arc::new(doc), cmd: cmd.into(), - renderer, + ctx, + generator, + pending: Vec::new(), + dual_pending: Vec::new(), done: 0, effect, neighborhood, @@ -523,11 +532,13 @@ impl LiveDab { self.bounds } - /// Run the dabs the renderer has generated since the last call on the working copy; returns - /// the rectangle they changed. + /// Run this batch's primary dabs in order, rasterizing each footprint exactly once. fn run_new_dabs(&mut self) -> Rect { - let ctx = self.renderer.ctx.clone(); - let new: Vec<_> = self.renderer.dabs().get(self.done..).unwrap_or_default().to_vec(); + let ctx = &self.ctx; + let mut new = std::mem::take(&mut self.pending); + // As in `apply_dab_stroke`, sequential effects consume only primary footprints. Keep + // generating dual dabs so the generator's state advances just as it did in StrokeRenderer. + self.dual_pending.clear(); let mut dmg = Rect::EMPTY; for (k, d) in new.iter().enumerate() { let r = ctx.dab_rect(d, false); @@ -535,11 +546,13 @@ impl LiveDab { continue; } let mut region = self.work.read(&self.pre_surf, self.neighborhood.read_rect(r, d)); - (self.effect)(&mut region, &ctx.footprint(d, self.done + k)); + (self.effect)(&mut region, &ctx.footprint(d, self.done.saturating_add(k))); self.work.write(&self.pre_surf, ®ion.crop(r)); dmg = dmg.union(&r); } - self.done += new.len(); + self.done = self.done.saturating_add(new.len()); + new.clear(); + self.pending = new; dmg } @@ -557,18 +570,20 @@ impl LiveDab { crate::brush_cmds::check_coords(pts, &self.cmd)?; 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); + self.generator.push(&pts, &mut self.pending, &mut self.dual_pending); let dmg = self.run_new_dabs(); - let ctx = self.renderer.ctx.clone(); + let ctx = &self.ctx; // The finishing dabs, run on a scratch copy of the working pixels. let mut tail = None; if self.preview_tail { - let ds = self.renderer.tail_dabs(); + let mut generator = self.generator.clone(); + let (mut ds, mut dual) = (Vec::new(), Vec::new()); + generator.finish(&mut ds, &mut dual); let r = ds.iter().fold(Rect::EMPTY, |a, d| a.union(&ctx.dab_rect(d, false))); if !r.is_empty() { let mut region = self.work.read(&self.pre_surf, r.inflate(self.neighborhood.halo())); for (k, d) in ds.iter().enumerate() { - (self.effect)(&mut region, &ctx.footprint(d, self.done + k)); + (self.effect)(&mut region, &ctx.footprint(d, self.done.saturating_add(k))); } tail = Some((r, region.crop(r))); } @@ -605,7 +620,7 @@ impl LiveDab { if now != tool || stroke.brush != *brush || id != self.id { return Err(bad(&self.cmd, "the brush, tool options or layer changed during the stroke")); } - self.renderer.finish(); + self.generator.finish(&mut self.pending, &mut self.dual_pending); let dmg = self.run_new_dabs(); let shown = dmg.union(&self.tail); if !shown.is_empty() { diff --git a/crates/engine/src/cmd/retouch_cmds/tests.rs b/crates/engine/src/cmd/retouch_cmds/tests.rs index a1329064d2..25ebcc8a97 100644 --- a/crates/engine/src/cmd/retouch_cmds/tests.rs +++ b/crates/engine/src/cmd/retouch_cmds/tests.rs @@ -1851,6 +1851,104 @@ fn live_dab_strokes_commit_their_pixels_and_they_are_the_commands() { assert!(layer_pixels(&s) == want, "{cmd} at {depth}-bit, hardness {hardness}: the live pixels aren't the command's"); s.execute("edit.undo", json!({})).unwrap(); assert!(layer_pixels(&s) == before, "{cmd} {depth} {hardness}: undo restores the layer"); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(layer_pixels(&s), want, "{cmd} {depth} {hardness}: redo restores the live commit"); + s.execute("edit.undo", json!({})).unwrap(); + } + } + } +} + +/// Compare every displayed batch and the finishing dabs with the former StrokeRenderer path, +/// including features whose coverage is unnecessary to sequential effects (dual tips/Wet Edges). +#[test] +fn live_dab_batches_match_the_accumulating_renderer() { + use photocraft_paint::{GrayTile, StrokeRenderer, TipShape}; + + for depth in DEPTHS { + let mut s = session(200, 90, depth, "rgb"); + s.execute("layer.new.layer", json!({})).unwrap(); + paint_layer(&mut s, |x, y| { + let mut c = texture(x, y); + c[3] = 0.25 + ((x + y) % 7) as f32 * 0.1; + c + }); + let id = s.active().unwrap().active_layer.unwrap(); + let pre = s.active().unwrap().doc.layer(id).unwrap().surface().unwrap().clone(); + for case in ["soft", "hard", "sampled", "dynamics", "smoothing"] { + s.tools.brush = BrushSettings::default(); + match case { + "sampled" => s.tools.brush.tip = TipShape::Sampled(GrayTile::from_fn(9, 7, |x, y| ((x * 3 + y) % 11) as f32 / 10.0)), + "dynamics" => { + let b = &mut s.tools.brush; + b.scattering.enabled = true; + b.scattering.scatter.jitter = 0.5; + b.scattering.count = 2; + b.shape_dynamics.enabled = true; + b.shape_dynamics.angle.jitter = 0.7; + b.shape_dynamics.size.jitter = 0.3; + b.dual_brush.enabled = true; + b.dual_brush.size = 12.0; + b.wet_edges = true; + b.noise = true; + b.texture.enabled = true; + b.texture.each_tip = true; + b.texture.pattern = photocraft_paint::Pattern::Tile(GrayTile::from_fn(3, 3, |x, y| (x + y) as f32 / 4.0)); + } + "smoothing" => s.tools.brush.smoothing.amount = 0.65, + _ => {} + } + let points = [ + StrokePoint::new(-5.0, 30.0, 0.3), + StrokePoint::new(70.0, 40.0, 1.0), + StrokePoint::new(150.0, 55.0, 0.6), + StrokePoint::new(60.0, 35.0, 0.8), + StrokePoint::new(61.0, 35.5, 0.2), + ]; + for (cmd, extra) in dab_cases() { + let mut p = dab_opts(&[[-5.0, 30.0]], if case == "hard" { 100 } else { 0 }, &extra); + p["points"] = json!([[-5.0, 30.0, 0.3]]); + p["opacity"] = json!(57); + p["flow"] = json!(63); + let (stroke, _) = parse_brush(&s, &p, cmd).unwrap(); + let (tool, _) = DabTool::parse(&s, cmd, &p, &stroke).unwrap(); + let mut effect = tool.effect(pre.format(), stroke.brush.spacing); + let mut reference = StrokeRenderer::new(&stroke.brush, None, 1.0).record_dabs(); + // A fixed region containing all dabs plus their read neighbourhoods. + let orig = Region::read(&pre, Rect::new(-128, -128, 384, 256)); + let mut work = orig.clone(); + let mut shown = pre.clone(); + let mut done = 0; + let mut live = LiveDab::begin(&s, cmd, &p).unwrap(); + let mut bounds = Rect::EMPTY; + let mut render_new = |reference: &StrokeRenderer| { + let mut damage = Rect::EMPTY; + for (i, dab) in reference.dabs().iter().enumerate().skip(done) { + let fp = reference.ctx.footprint(dab, i); + effect(&mut work, &fp); + damage = damage.union(&fp.rect); + } + done = reference.dabs().len(); + if !damage.is_empty() { + photocraft_paint::retouch::mix_back(&mut shown, &orig, &work, damage, stroke.brush.opacity, None, false); + } + bounds = bounds.union(&damage); + (bounds, shown.read_region(bounds)) + }; + for (batch_index, batch) in std::iter::once(&points[..1]).chain(points[1..].chunks(2)).enumerate() { + reference.push(batch); + if batch_index > 0 { + live.push(batch).unwrap(); + } + let (bounds, expected) = render_new(&reference); + let actual = live.doc.layer(id).unwrap().surface().unwrap(); + assert!(actual.read_region(bounds) == expected, "{cmd} {depth} {case} batch={batch_index}"); + } + reference.finish(); + let (bounds, expected) = render_new(&reference); + let commit = live.into_commit(&s).unwrap(); + assert_eq!(commit.bounds, bounds, "{cmd} {depth} {case}: final damage"); + assert!(commit.surface.read_region(bounds) == expected, "{cmd} {depth} {case}: finishing dabs"); } } } diff --git a/crates/ui-egui/src/canvas/strokes.rs b/crates/ui-egui/src/canvas/strokes.rs index 15aa741a82..92b8ee2802 100644 --- a/crates/ui-egui/src/canvas/strokes.rs +++ b/crates/ui-egui/src/canvas/strokes.rs @@ -115,8 +115,8 @@ pub(super) fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painte /// Healing Brush and History Brush composite their source. pub(super) enum EngineStroke { Brush(Box), - Retouch(photocraft_engine::retouch_cmds::LiveRetouch), - Dab(photocraft_engine::retouch_cmds::LiveDab), + Retouch(Box), + Dab(Box), Mixer(Box), } @@ -304,32 +304,32 @@ pub(super) fn begin_live_stroke(app: &PhotocraftApp) -> Option { // The params `retouch_ui::finish_stroke` commits. let mut p = crate::retouch_ui::color_replacement_params(app); p["points"] = json!(d.points); - EngineStroke::Dab(photocraft_engine::brush_cmds::live_color_replacement(&app.session, &p).ok()?) + EngineStroke::Dab(Box::new(photocraft_engine::brush_cmds::live_color_replacement(&app.session, &p).ok()?)) } else if let Some((cmd, mut p)) = crate::retouch_ui::dab_params(app, d.tool) { // The params `retouch_ui::finish_stroke` commits; Sample All Layers isn't previewed. p["points"] = json!(d.points); p["target"] = paint_target(app); - EngineStroke::Dab(photocraft_engine::retouch_cmds::LiveDab::begin(&app.session, cmd, &p).ok()?) + EngineStroke::Dab(Box::new(photocraft_engine::retouch_cmds::LiveDab::begin(&app.session, cmd, &p).ok()?)) } else if d.tool == Tool::PatternStamp { // The params `retouch_ui::finish_stroke` commits. let mut p = crate::retouch_ui::pattern_stamp_params(app, &d.points); p["points"] = json!(d.points); p["target"] = paint_target(app); if app.ui.tool_options.pattern_stamp_impressionist { - EngineStroke::Dab(photocraft_engine::retouch_cmds::live_pattern_impressionist(&app.session, &p).ok()?) + EngineStroke::Dab(Box::new(photocraft_engine::retouch_cmds::live_pattern_impressionist(&app.session, &p).ok()?)) } else { - EngineStroke::Retouch(photocraft_engine::retouch_cmds::LiveRetouch::begin(&app.session, "paint.patternStamp", &p).ok()?) + EngineStroke::Retouch(Box::new(photocraft_engine::retouch_cmds::LiveRetouch::begin(&app.session, "paint.patternStamp", &p).ok()?)) } } else if d.tool == Tool::BackgroundEraser { // The params `eraser_ui::finish_stroke` commits (layer pixels only). let p = crate::eraser_ui::background_eraser_params(app, &d.points); - EngineStroke::Dab(photocraft_engine::eraser_cmds::live_background_eraser(&app.session, &p).ok()?) + EngineStroke::Dab(Box::new(photocraft_engine::eraser_cmds::live_background_eraser(&app.session, &p).ok()?)) } else if let Some(cmd) = live_retouch_command(d.tool) { // The params `retouch_ui::finish_stroke` commits. let mut p = if d.tool == Tool::HistoryBrush { crate::retouch_ui::history_brush_params(app) } else { crate::retouch_ui::clone_params(app)? }; p["points"] = json!(d.points); p["target"] = paint_target(app); - EngineStroke::Retouch(photocraft_engine::retouch_cmds::LiveRetouch::begin(&app.session, cmd, &p).ok()?) + EngineStroke::Retouch(Box::new(photocraft_engine::retouch_cmds::LiveRetouch::begin(&app.session, cmd, &p).ok()?)) } else { let p = stroke_params(app, d, &app.stylus.stroke_points(&d.points)); EngineStroke::Brush(Box::new(photocraft_engine::brush_cmds::LiveStroke::begin_with(&app.session, stroke_command(app, d.tool), &p).ok()?)) @@ -433,12 +433,12 @@ pub(super) fn offer_live_retouch(app: &mut PhotocraftApp, d: &Drag) { match stroke { EngineStroke::Retouch(mut live) if !dab => { if rest.is_empty() || live.push(&rest).is_ok() { - let _ = app.session.offer_live_retouch(live); + let _ = app.session.offer_live_retouch(*live); } } EngineStroke::Dab(mut live) if dab => { if rest.is_empty() || live.push(&rest).is_ok() { - let _ = app.session.offer_live_dab(live); + let _ = app.session.offer_live_dab(*live); } } _ => {} From c9fe0317c2c07d5d92021e4d340046f88e326f48 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 29 Oct 2026 17:45:07 +0100 Subject: [PATCH 85/86] i18n(ar): the Select and Mask workspace strings (#10173) #8896 added 17 strings to every catalogue it knew; ar.tsv landed just before it and got none, so main failed the_reason_a_stale_workspace_closed_is_translated. Adds the 16 missing rows (Remember Settings was there), wording as the catalogue already has it (Select and Mask, Refine Edge, Content-Aware). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/ar.tsv | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 0b29e9ee20..2a5ef902fb 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -546,6 +546,22 @@ Decontaminate إزالة التلوث اللوني Output الإخراج Refine Edge تحسين الحافة + Clear Selection مسح التحديد + Color Aware مراعٍ للألوان + Decontaminate Colors إزالة تلوث الألوان + Edge Detection اكتشاف الحواف + Global Refinements تحسينات عامة + High Quality Preview معاينة عالية الجودة + Object Aware مراعٍ للكائنات + Object Aware needs a segmentation model, which isn't available yet يتطلب «مراعٍ للكائنات» نموذج تجزئة، وهو غير متوفر بعد + Real-Time Refinement تحسين في الوقت الفعلي + Refine Edge Brush Tool أداة فرشاة تحسين الحافة + Refine Hair تحسين الشعر + Refine Hair needs hair detection, which isn't available yet يتطلب «تحسين الشعر» اكتشاف الشعر، وهو غير متوفر بعد + Refine Mode وضع التحسين + Select and Mask was closed: another layer was selected تم إغلاق «تحديد وقناع»: حُدِّدت طبقة أخرى + Select and Mask was closed: its document is no longer the active one تم إغلاق «تحديد وقناع»: لم يعد مستنده هو المستند النشط + View Mode وضع العرض Modify تعديل Border… حدود التحديد… Smooth… تنعيم… From 0d9a0f66137906698525970bb272119bc0cabac4 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 29 Oct 2026 17:24:04 -0400 Subject: [PATCH 86/86] Enable update checks in Flatpak (#10206) --- apps/photocraft/src/services.rs | 8 +------- apps/photocraft/src/services/tests.rs | 5 ++--- docs/releasing.md | 16 +++++++--------- .../flatpak/ai.storyteller.photocraft.bundle.yml | 5 ++--- .../linux/flatpak/ai.storyteller.photocraft.yml | 5 ++--- 5 files changed, 14 insertions(+), 25 deletions(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index c4350141fb..7dec44483b 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -634,12 +634,6 @@ fn shell_word(s: &str) -> String { if s.bytes().all(|b| b.is_ascii_alphanumeric() || b"/._-+:,@".contains(&b)) { s.to_string() } else { format!("'{}'", s.replace('\'', r"'\''")) } } -/// The release-check transport, or `None` inside Flatpak: `flatpak update` owns updates there. -/// The sandbox has network (for the `--control` server), which does not change this. -fn update_transport(flatpak_id: Option<&std::ffi::OsStr>) -> Option { - flatpak_id.is_none().then(crate::updates::service) -} - pub fn native(automation: Option) -> Services { let clip: Rc>> = Rc::default(); let clip_unreadable: Rc> = Rc::default(); @@ -704,7 +698,7 @@ pub fn native(automation: Option) -> inbox: None, open_url: Some(Box::new(|url: &str| open::that(url).map_err(|e| e.to_string()))), beep: Some(platform_beep), - check_updates: update_transport(std::env::var_os("FLATPAK_ID").as_deref()), + check_updates: Some(crate::updates::service()), clipboard_set_image: Some({ let clip = clip.clone(); guarded_clipboard_set(move |w: u32, h: u32, px: &[u8]| { diff --git a/apps/photocraft/src/services/tests.rs b/apps/photocraft/src/services/tests.rs index d39c134e0a..6197d2ba12 100644 --- a/apps/photocraft/src/services/tests.rs +++ b/apps/photocraft/src/services/tests.rs @@ -182,9 +182,8 @@ fn open_filters_keep_literal_extensions_on_windows_and_macos() { } #[test] -fn flatpak_builds_have_no_update_transport() { - assert!(update_transport(Some(std::ffi::OsStr::new("ai.storyteller.photocraft"))).is_none()); - assert!(update_transport(None).is_some()); +fn native_services_provide_update_transport() { + assert!(native(None).check_updates.is_some()); } #[test] diff --git a/docs/releasing.md b/docs/releasing.md index cb115b04ff..3870022e8f 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -20,11 +20,10 @@ in the manual check window. Concurrent requests share one check. HTTPS validates certificates and host names against the operating system's trust store. Tags must be semantic versions (`v0.8.0` or `0.8.0`); only published stable versions newer -than the installed workspace version trigger a notice. The notice opens the official release -page to choose an installer; installation remains a user action. Drafts and prereleases do -not trigger it. Web/headless frontends and the Flatpak (no network in its sandbox; `flatpak update` -owns updates there) have no update transport. No release-workflow changes -are required: publishing a newer stable GitHub Release makes it discoverable. +than the installed workspace version trigger a notice. Drafts and prereleases do not trigger it. +Flatpak builds also check for releases; repository-managed installations are updated through +their package manager. Web/headless frontends have no update transport. No release-workflow +changes are required: publishing a newer stable GitHub Release makes it discoverable. ## Cutting a release @@ -265,10 +264,9 @@ Why these formats: Sandbox permissions (justified in the manifest): Wayland with X11 fallback, IPC (X11 shared memory), `dri` for the GPU, and read/write access to Pictures and Documents. Every - other file goes through the file-chooser and document portals. There's no network, so the - `--control` server is only reachable from inside the sandbox until the user runs - `flatpak override --user --share=network ai.storyteller.photocraft`. Host fonts are read from - `/run/host/fonts` and `/run/host/user-fonts`. Locally (on Linux, with `flatpak` and + other file goes through the file-chooser and document portals. Network access enables update + checks and makes the optional loopback `--control` server reachable from the host. Host fonts + are read from `/run/host/fonts` and `/run/host/user-fonts`. Locally (on Linux, with `flatpak` and `flatpak-builder`): `packaging/linux/package.sh --formats tar && packaging/linux/flatpak-bundle.sh`. - **Flathub**: `packaging/linux/flatpak/ai.storyteller.photocraft.yml` builds from source and is ready for a Flathub submission (it keeps the same runtime and `finish-args` as the bundle diff --git a/packaging/linux/flatpak/ai.storyteller.photocraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.photocraft.bundle.yml index c716cf310f..091e7159eb 100644 --- a/packaging/linux/flatpak/ai.storyteller.photocraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.photocraft.bundle.yml @@ -22,9 +22,8 @@ finish-args: - --socket=fallback-x11 # X11 shared memory (MIT-SHM). Without it the X11 fallback is slow or fails. - --share=ipc - # Network for the optional --control server: it listens on 127.0.0.1 in the sandbox, which with - # network shared is also reachable from the host's network namespace. The in-app update check - # is disabled under Flatpak (flatpak update owns updates), so this does not enable it. + # Network for update checks and the optional --control server. The server listens on 127.0.0.1, + # which with network shared is also reachable from the host's network namespace. - --share=network # GPU access for the wgpu compositor (Vulkan, or GL as a fallback). - --device=dri diff --git a/packaging/linux/flatpak/ai.storyteller.photocraft.yml b/packaging/linux/flatpak/ai.storyteller.photocraft.yml index 7f333cf914..32e81545fb 100644 --- a/packaging/linux/flatpak/ai.storyteller.photocraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.photocraft.yml @@ -23,9 +23,8 @@ finish-args: - --socket=fallback-x11 # X11 shared memory (MIT-SHM). Without it the X11 fallback is slow or fails. - --share=ipc - # Network for the optional --control server: it listens on 127.0.0.1 in the sandbox, which with - # network shared is also reachable from the host's network namespace. The in-app update check - # is disabled under Flatpak (flatpak update owns updates), so this does not enable it. + # Network for update checks and the optional --control server. The server listens on 127.0.0.1, + # which with network shared is also reachable from the host's network namespace. - --share=network # GPU access for the wgpu compositor (Vulkan, or GL as a fallback). - --device=dri