diff --git a/Cargo.lock b/Cargo.lock index e532978516..2b8affbfd9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5023,6 +5023,7 @@ dependencies = [ name = "photocraft-algo" version = "0.17.0" dependencies = [ + "criterion", "photocraft-color", "photocraft-geom", "photocraft-raster", diff --git a/apps/photocraft-cli/src/batch.rs b/apps/photocraft-cli/src/batch.rs index bdaf249bc9..02bb1bb702 100644 --- a/apps/photocraft-cli/src/batch.rs +++ b/apps/photocraft-cli/src/batch.rs @@ -83,7 +83,10 @@ pub(crate) fn batch(a: &BatchArgs, out: &mut dyn Write, err: &mut dyn Write) -> h.session.actions.list = set.clone(); h.open(input)?; 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, p) + { continue; } let r = h.command_run(id, p.clone()).map_err(|source| CliError::Command { id: id.clone(), source })?; diff --git a/apps/photocraft-cli/src/document.rs b/apps/photocraft-cli/src/document.rs index 9d730b4635..95d4c28288 100644 --- a/apps/photocraft-cli/src/document.rs +++ b/apps/photocraft-cli/src/document.rs @@ -93,6 +93,11 @@ pub(crate) fn run_cmds(a: &RunArgs, out: &mut dyn Write, err: &mut dyn Write) -> } let cmds = command_list(a)?; for (id, params) in cmds { + // A step that is a silent no-op in action playback (Deselect with nothing selected, an Arrange that cannot move the layer) is one in a script too (#8802). + if photocraft_engine::actions_cmds::noop_step(&h.session, &id, ¶ms) { + print_json(out, &json!({"command": id, "result": null}), JsonStyle::Compact)?; + continue; + } let r = h.command_run(&id, params).map_err(|source| CliError::Command { id: id.clone(), source })?; if let Some(message) = photocraft_engine::actions_cmds::nested_failure(&id, &r) { return Err(CliError::NestedFailure { id, message }); diff --git a/apps/photocraft-cli/tests/it/cli.rs b/apps/photocraft-cli/tests/it/cli.rs index 2c06a488fd..e592d552e2 100644 --- a/apps/photocraft-cli/tests/it/cli.rs +++ b/apps/photocraft-cli/tests/it/cli.rs @@ -1421,3 +1421,78 @@ fn control_token_env_vars_are_the_flags_fallbacks() { let err = serve_tcp_stderr(bin().args(["serve", "--port"]).arg(free_port().to_string()).env("PHOTOCRAFT_CONTROL_TOKEN", "")); assert!(err.contains("photocraft-cli serving on") && err.contains("photocraft-cli control token: "), "{err}"); } + +/// #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 = tmp("deselect-run"); + let native = folder.join("out.pcraft").to_string_lossy().to_string(); + let Invoke { 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 Invoke { 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 Invoke { 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 = tmp("deselect-batch"); + let path = |name: &str| dir.join(name).to_string_lossy().to_string(); + std::fs::create_dir_all(dir.join("in")).unwrap(); + let Invoke { 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 Invoke { 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 Invoke { 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/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 c71074572e..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] @@ -253,7 +252,7 @@ fn every_save_dialog_leads_with_its_own_extension() { 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(); @@ -266,13 +265,13 @@ fn every_save_dialog_leads_with_its_own_extension() { 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/algo/Cargo.toml b/crates/algo/Cargo.toml index af50c1d20e..767c8044d9 100644 --- a/crates/algo/Cargo.toml +++ b/crates/algo/Cargo.toml @@ -23,3 +23,8 @@ rayon = { workspace = true } [dev-dependencies] photocraft-testkit = { workspace = true } 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); 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/blur.rs b/crates/algo/src/blur.rs index bd762d073f..98054c3fea 100644 --- a/crates/algo/src/blur.rs +++ b/crates/algo/src/blur.rs @@ -31,6 +31,43 @@ 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::() + }; + // 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(); + 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(&sampled(mid)) < target { + lo = mid; + } else { + hi = mid; + } + } + sampled(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 +148,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) } } diff --git a/crates/algo/src/blur/tests.rs b/crates/algo/src/blur/tests.rs index 32256bc954..028ac65dcc 100644 --- a/crates/algo/src/blur/tests.rs +++ b/crates/algo/src/blur/tests.rs @@ -2,6 +2,76 @@ use super::*; 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] +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)); + // 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); + 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}"); + } + 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); 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}"); + } + } +} 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/src/stack.rs b/crates/algo/src/stack.rs index 3d0c9a87b3..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 @@ -99,17 +148,23 @@ 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 { return 0.0; } - return entropy(v, scratch); + return entropy(v, scratch, None); } _ => {} } @@ -147,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 9e14a6be44..d9f041124d 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> { @@ -243,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] 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/compose/src/effects/bevel.rs b/crates/compose/src/effects/bevel.rs index 6d2352622d..ea07fbd709 100644 --- a/crates/compose/src/effects/bevel.rs +++ b/crates/compose/src/effects/bevel.rs @@ -108,8 +108,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/effects/paint.rs b/crates/compose/src/effects/paint.rs index 6bd78df7de..99b0c6aa0b 100644 --- a/crates/compose/src/effects/paint.rs +++ b/crates/compose/src/effects/paint.rs @@ -339,8 +339,7 @@ pub(super) 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, diff --git a/crates/compose/src/pattern.rs b/crates/compose/src/pattern.rs index b66ab0ad1e..b351bdef06 100644 --- a/crates/compose/src/pattern.rs +++ b/crates/compose/src/pattern.rs @@ -3,19 +3,32 @@ //! 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}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, Weak}; use photocraft_doc::{ColorMode, Pattern, PixelFormat, Rect, SampleType}; -pub(crate) const PREPARED_PATTERN_BYTES: usize = 64 << 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; +/// 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>>>; struct PreparedState { @@ -23,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], @@ -30,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`. @@ -52,10 +81,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 = { @@ -82,14 +108,40 @@ 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 => { + // 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); + } + 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"))] + { + 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); + live.insert(key, Arc::downgrade(&tile)); + Some(tile) + } } } } @@ -101,29 +153,80 @@ 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).is_none() { + px = Vec::new(); } 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. `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(); + 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 { + 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()); + } + } + 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. `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)?; + out.extend(band.iter().map(|c| [quantize(c[0]), quantize(c[1]), quantize(c[2]), quantize(c[3])])); + } + Some(out) +} + +/// `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 `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`]): 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. pub struct Tile { w: usize, h: usize, - /// Premultiplied RGBA. - px: Vec<[f32; 4]>, + texels: Texels, } impl Tile { @@ -137,26 +240,83 @@ 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 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); + if px.len() as u64 != p.pixel_count() { + return None; + } + for q in &mut px { + for c in 0..3 { + q[c] *= q[3]; + } + } + Texels::Premultiplied(px) } - } - Some(Tile { w, h, 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] + } + 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] + } + } } /// 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 +337,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 +413,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 @@ -282,6 +460,114 @@ mod tests { assert!(PreparedPatterns::new(&list, 0).get("big", "").is_none()); } + /// 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 sparse = |name: &str, (width, height): (u32, u32), format: PixelFormat| Pattern { + id: name.into(), + name: name.into(), + width, + height, + surface: Surface::new(format), + }; + let float = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true); + let list = [ + sparse("max8", (8000, 8000), PixelFormat::RGBA8), + 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, true, false, false, false]); + let prepared = PreparedPatterns::new(&list, PREPARED_PATTERN_BYTES); + 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"); + } + + /// 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"); + // 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); + } + + /// 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_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); + } + for v in 0..=u16::MAX { + assert_eq!(((f32::from(v) / 65535.0) * 65535.0 + 0.5) as u16, v); + } + 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)]); + } + } + 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))); + } + } + + /// 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(); @@ -299,6 +585,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/doc/src/pattern.rs b/crates/doc/src/pattern.rs index 9a43c0a467..7d46274638 100644 --- a/crates/doc/src/pattern.rs +++ b/crates/doc/src/pattern.rs @@ -2,16 +2,30 @@ //! 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 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 -/// 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). Larger +/// patterns paint nothing. 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`]. 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; + /// 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 +50,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 +89,61 @@ 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 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 { + 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 { - !self.is_empty() && self.pixel_count() <= MAX_PAINT_PIXELS + !self.is_empty() && self.pixel_count() <= MAX_PAINT_PIXELS && self.working_bytes() <= MAX_WORKING_BYTES + } +} + +/// [`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), } } @@ -118,4 +189,61 @@ 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 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(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()); + } + + #[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/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/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); + } +} diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 8f15d528f9..27c7ca3ce3 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -168,7 +168,7 @@ pub(crate) use layer_params::{NEW_LAYER_PATH, explicit_layer, layer_listing, lay use ops::{arrange, merge_group, set_mask, set_mask_and_deselect}; pub(crate) use ops::{layer_copy, translate_layer}; use params::{bad, set_clipped}; -pub(crate) use params::{check_f32_values, color_param, int, int_i32, parse_hex, u32_id, u32_id_param}; +pub(crate) use params::{check_f32_values, cmyk_param, color_param, int, int_i32, parse_hex, u32_id, u32_id_param}; pub fn command_specs() -> &'static [CommandSpec] { ®istry().specs @@ -792,13 +792,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 = crate::layer_place::insert_new(doc, *active, l, crate::layer_place::Place::Auto); @@ -937,17 +946,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/cmd/actions_cmds.rs b/crates/engine/src/cmd/actions_cmds.rs index 4dcb8bb8b9..5467e0bdbe 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). The CLI's `run` and `batch` scripts skip these too (#8802). +pub 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/brush_cmds.rs b/crates/engine/src/cmd/brush_cmds.rs index f501b27c40..e0f165afcd 100644 --- a/crates/engine/src/cmd/brush_cmds.rs +++ b/crates/engine/src/cmd/brush_cmds.rs @@ -291,12 +291,16 @@ 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); 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()); let damage = if let Some(axis) = &symmetry { let reflected = axis.reflected_passes(&pts); let copies: Vec<_> = reflected @@ -321,6 +325,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), Some(pre)) = (&table, &unpainted) { + crate::mode_cmds::keep_in_color_table(table, pre, surf, damage); + } Ok(damage) }) })?; @@ -600,9 +607,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); @@ -676,7 +685,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)) { @@ -722,7 +731,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, &self.pre, surf, dmg); + } + Ok(dmg) } } diff --git a/crates/engine/src/cmd/edit_cmds.rs b/crates/engine/src/cmd/edit_cmds.rs index 71800c63c6..ba875b9a2b 100644 --- a/crates/engine/src/cmd/edit_cmds.rs +++ b/crates/engine/src/cmd/edit_cmds.rs @@ -746,9 +746,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) } @@ -756,8 +757,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. @@ -780,8 +781,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()); @@ -800,8 +802,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/cmd/layer_multi_cmds/align.rs b/crates/engine/src/cmd/layer_multi_cmds/align.rs index e7f77fc8fb..e33b11b24a 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/align.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/align.rs @@ -1,4 +1,4 @@ -use super::damage::layer_bounds; +use super::damage::align_bounds; use super::moves::{move_layers, with_links}; use super::selection::{selected, top_level}; use super::{Document, EngineError, LayerId, Rect, Result, Session, Value, bad, json}; @@ -44,7 +44,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, align_bounds(doc.layer(id)?, doc.bounds(), doc.pixel_format().sample)?))).collect() } pub(super) fn align(s: &mut Session, p: &Value, kind: &str) -> Result { diff --git a/crates/engine/src/cmd/layer_multi_cmds/damage.rs b/crates/engine/src/cmd/layer_multi_cmds/damage.rs index bad2821d5e..8440fab75b 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/damage.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/damage.rs @@ -1,3 +1,5 @@ +use photocraft_color::{PixelFormat, SampleType}; + use super::{Document, Layer, LayerContent, Rect, Session, VideoData}; /// Calls `f` with the rect of every tile that differs between two surfaces of the same format and @@ -580,8 +582,9 @@ 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 { let r = match &l.content { LayerContent::Group(g) => g.children.iter().filter_map(layer_bounds).fold(Rect::EMPTY, |a, b| a.union(&b)), @@ -589,3 +592,68 @@ pub fn layer_bounds(l: &Layer) -> Option { }; (!r.is_empty()).then_some(r) } + +/// 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, 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, 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 } + }), + _ => return layer_bounds(l), + }; + (!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; + +/// 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 +/// 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`). +pub(super) fn shape_pixel_bounds(sh: &photocraft_doc::ShapeLayer, canvas: Rect, sample: SampleType) -> Option { + if unbounded_fill(sh) { + return None; + } + 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); + } + // 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 shape = photocraft_vector::CompiledShape::with_frames(sh, photocraft_vector::DEFAULT_TOLERANCE, reach, canvas); + // Alpha at the document's own precision, as the document stores it: an 8-bit render drops the + // faint stroke of a 16-bit or float document (the opaque body then aligns by a different + // extent than the document draws), and a float one would find alpha an 8-bit document never + // stores. A band of rows at a time keeps the working copy a few MB however large the shape. + let format = PixelFormat::new(photocraft_color::ColorMode::Grayscale, sample, true); + let band_rows = i32::try_from((SHAPE_BOUNDS_BAND_PX / u64::from(reach.width().max(1))).max(1)).unwrap_or(i32::MAX); + let mut bounds = Rect::EMPTY; + let mut top = reach.y0; + while top < reach.y1 { + let bottom = top.saturating_add(band_rows).min(reach.y1); + bounds = bounds.union(&shape.render(format, Rect::new(reach.x0, top, reach.x1, bottom)).content_bounds()); + top = bottom; + } + (!bounds.is_empty()).then_some(bounds) +} diff --git a/crates/engine/src/cmd/layer_multi_cmds/layers_panel_parity_tests.rs b/crates/engine/src/cmd/layer_multi_cmds/layers_panel_parity_tests.rs index 3982eb9cff..f58ea7d6ae 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/layers_panel_parity_tests.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/layers_panel_parity_tests.rs @@ -242,16 +242,18 @@ fn fill_layers_without_default_masks_keep_selection_and_explicit_path_masks() { 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/cmd/layer_multi_cmds/selection.rs b/crates/engine/src/cmd/layer_multi_cmds/selection.rs index 8f66dfab7c..a0b5f76a77 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/selection.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/selection.rs @@ -139,6 +139,14 @@ 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" { + // 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 { Some(sel) => sel == [layer_id], @@ -154,6 +162,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/cmd/layer_multi_cmds/tests.rs b/crates/engine/src/cmd/layer_multi_cmds/tests.rs index a983837cc6..a3c055cf7b 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/tests.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/tests.rs @@ -1,3 +1,6 @@ +use photocraft_color::SampleType; + +use super::damage::{align_bounds, shape_pixel_bounds}; use super::*; #[test] @@ -636,6 +639,86 @@ fn align_each_edge() { } } +/// #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 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(), 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"); + 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!(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!(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 = 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(); + // Measured as Align does, in the document's frame: the feather off the canvas still counts. + 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) { + 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, 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(), SampleType::U8), Some(doc(&s).bounds())); +} + +/// 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_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()); + s.edit("faint stroke", |doc, _| { + if let Some(LayerContent::Shape(sh)) = doc.layer_mut(id).map(|l| &mut l.content) { + sh.stroke = Some(photocraft_doc::ShapeStroke { width: 20.0, opacity: 0.001, ..Default::default() }); + } + 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(), sample), Some(expected), "depth {depth}"); + assert_eq!(align_bounds(doc(&s).layer(id).unwrap(), doc(&s).bounds(), sample), Some(expected), "depth {depth}"); + } +} + #[test] fn align_single_layer_to_selection() { for depth in [8, 16, 32] { diff --git a/crates/engine/src/cmd/layer_params.rs b/crates/engine/src/cmd/layer_params.rs index dddac7ab62..f298893b33 100644 --- a/crates/engine/src/cmd/layer_params.rs +++ b/crates/engine/src/cmd/layer_params.rs @@ -63,7 +63,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() } @@ -88,9 +88,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"))?), @@ -101,9 +102,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/cmd/mode_cmds.rs b/crates/engine/src/cmd/mode_cmds.rs index 7691941d73..ac30023d92 100644 --- a/crates/engine/src/cmd/mode_cmds.rs +++ b/crates/engine/src/cmd/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}; @@ -423,6 +423,62 @@ 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) +} + +/// 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. +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 n = fmt.channels(); + let mut enc = [0.0f32; 8]; + 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); + } +} + // ---------- Bitmap ---------- /// Bitmap's Output resolution range (ppi): what a new document may have. diff --git a/crates/engine/src/cmd/mode_cmds/tests.rs b/crates/engine/src/cmd/mode_cmds/tests.rs index 0a0495ca6a..0d3f9b3cea 100644 --- a/crates/engine/src/cmd/mode_cmds/tests.rs +++ b/crates/engine/src/cmd/mode_cmds/tests.rs @@ -849,3 +849,95 @@ 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})"); + } +} + +#[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]); +} + +#[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]); +} 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, _| { diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index 7d78bacccc..f5d644dae7 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -69,6 +69,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 => { diff --git a/crates/engine/src/cmd/pattern_cmds.rs b/crates/engine/src/cmd/pattern_cmds.rs index 4bc972556a..447e4c8dc7 100644 --- a/crates/engine/src/cmd/pattern_cmds.rs +++ b/crates/engine/src/cmd/pattern_cmds.rs @@ -149,7 +149,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). @@ -168,6 +178,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)), @@ -180,6 +191,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(), }) } @@ -190,19 +202,45 @@ 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. 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); 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); + } + 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) } /// The name Define Pattern gives a pattern without one: the active document's name without its @@ -219,6 +257,11 @@ 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 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 { let cmd = "edit.definePattern"; let st = s.active().ok_or(EngineError::NoDocument)?; @@ -234,14 +277,20 @@ 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); + // 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). - 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) { @@ -369,15 +418,15 @@ 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::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]; 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); @@ -393,13 +442,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 { @@ -551,6 +612,128 @@ 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); + } + } + } + + /// 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: 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_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": 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}"); + } + + /// 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_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() == 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]); + } + + /// 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)] { + 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] { @@ -642,6 +825,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/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/engine/src/cmd/solid_fill_cmds.rs b/crates/engine/src/cmd/solid_fill_cmds.rs index efa43f6116..85d941760d 100644 --- a/crates/engine/src/cmd/solid_fill_cmds.rs +++ b/crates/engine/src/cmd/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/engine/src/cmd/transform_cmds.rs b/crates/engine/src/cmd/transform_cmds.rs index d1b9690312..520f61e506 100644 --- a/crates/engine/src/cmd/transform_cmds.rs +++ b/crates/engine/src/cmd/transform_cmds.rs @@ -265,6 +265,56 @@ 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 { + 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 active.into_iter().collect(); + } + 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 +/// 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 memory (bytes) a single Free Transform warp's result may take: Preferences › Performance › /// Memory Usage, which [`Session::apply_prefs`] sets, rather than a fixed 2³⁰ px, so a stitched /// layer of a few gigapixels transforms on a machine that has the memory for it (#7807). Only the @@ -734,6 +784,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); @@ -744,23 +795,40 @@ 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() + && (!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)?; + 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())); @@ -790,22 +858,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, @@ -834,21 +905,35 @@ 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))?; - let layer_sel = if is_group { None } else { sel.as_ref() }; - check_layer_budget(layer_sel, l, h)?; - let taken = if h.is_identity() { Vec::new() } else { crate::smart_cmds::take_caches(std::slice::from_mut(l)) }; - apply_transform(layer_sel, 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)?; + } + let mut taken = Vec::new(); + 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); + if !h.is_identity() { + taken.extend(crate::smart_cmds::take_caches(std::slice::from_mut(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); + } } crate::smart_cmds::restore_unrendered(doc, taken, |c| warp_surface(c, c.content_bounds(), h, interp))?; // The selection outline moves with the pixels. @@ -865,7 +950,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|bicubicSharper|bicubicSmoother|bicubicAutomatic"="bicubic" (Photoshop's bicubic variants render as 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|bicubicSharper|bicubicSmoother|bicubicAutomatic"="bicubic" (Photoshop's bicubic variants render as 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, @@ -874,3 +959,6 @@ pub fn specs() -> Vec { #[cfg(test)] mod tests; + +#[cfg(test)] +mod multi_tests; diff --git a/crates/engine/src/cmd/transform_cmds/multi_tests.rs b/crates/engine/src/cmd/transform_cmds/multi_tests.rs new file mode 100644 index 0000000000..e26572d6e7 --- /dev/null +++ b/crates/engine/src/cmd/transform_cmds/multi_tests.rs @@ -0,0 +1,283 @@ +//! 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 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(); + 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); +} + +#[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"); +} + +#[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])); +} diff --git a/crates/engine/src/cmd/transform_cmds/tests.rs b/crates/engine/src/cmd/transform_cmds/tests.rs index f8ff204257..e9ed16f245 100644 --- a/crates/engine/src/cmd/transform_cmds/tests.rs +++ b/crates/engine/src/cmd/transform_cmds/tests.rs @@ -345,7 +345,6 @@ fn cached_gradient_fill() -> Session { 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/src/cmd/web_cmds.rs b/crates/engine/src/cmd/web_cmds.rs index 470edd3067..15ea4ae143 100644 --- a/crates/engine/src/cmd/web_cmds.rs +++ b/crates/engine/src/cmd/web_cmds.rs @@ -80,7 +80,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|allExceptCameraInfo|all"="copyright" (EXIF only with all),"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, warnings}"##, + 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|allExceptCameraInfo|all"="copyright" (EXIF only with all),"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, warnings}"##, has_doc, save_for_web ), diff --git a/crates/engine/src/cmd/web_cmds/optimizer.rs b/crates/engine/src/cmd/web_cmds/optimizer.rs index 14e5fd311d..2186f3b70a 100644 --- a/crates/engine/src/cmd/web_cmds/optimizer.rs +++ b/crates/engine/src/cmd/web_cmds/optimizer.rs @@ -128,21 +128,34 @@ pub fn optimize( 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(), @@ -151,6 +164,29 @@ pub fn optimize( }; (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); + // 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: exif.is_some() || 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() { diff --git a/crates/engine/src/cmd/web_cmds/save_web.rs b/crates/engine/src/cmd/web_cmds/save_web.rs index 1e263c2c25..aa8f63dd04 100644 --- a/crates/engine/src/cmd/web_cmds/save_web.rs +++ b/crates/engine/src/cmd/web_cmds/save_web.rs @@ -172,10 +172,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/cmd/web_cmds/settings.rs b/crates/engine/src/cmd/web_cmds/settings.rs index a819cf6864..053604a389 100644 --- a/crates/engine/src/cmd/web_cmds/settings.rs +++ b/crates/engine/src/cmd/web_cmds/settings.rs @@ -10,6 +10,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 { @@ -20,6 +23,7 @@ impl WebFormat { "png24" | "png-24" | "png" | "png32" => WebFormat::Png24, "jpeg" | "jpg" => WebFormat::Jpeg, "wbmp" => WebFormat::Wbmp, + "webp" => WebFormat::Webp, _ => return None, }) } @@ -29,6 +33,7 @@ impl WebFormat { WebFormat::Png8 | WebFormat::Png24 => "png", WebFormat::Jpeg => "jpg", WebFormat::Wbmp => "wbmp", + WebFormat::Webp => "webp", } } pub fn indexed(self) -> bool { @@ -68,8 +73,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, @@ -92,6 +99,7 @@ impl Default for WebSettings { interlaced: false, web_snap: 0.0, quality: 60, + lossless: false, progressive: false, optimized: true, embed_icc: false, @@ -123,7 +131,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 { @@ -148,7 +156,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") { @@ -159,11 +173,12 @@ 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), ] { - if let Some(b) = p.get(k).and_then(Value::as_bool) { + if let Some(b) = flag(k)? { *slot = b; } } @@ -217,6 +232,7 @@ impl WebSettings { WebFormat::Png24 => "png24", WebFormat::Jpeg => "jpeg", WebFormat::Wbmp => "wbmp", + WebFormat::Webp => "webp", }; let palette = match self.palette { PaletteKind::Exact => "exact", @@ -242,7 +258,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::>()); } diff --git a/crates/engine/src/cmd/web_cmds/tests.rs b/crates/engine/src/cmd/web_cmds/tests.rs index cdc7d576e7..04a74da722 100644 --- a/crates/engine/src/cmd/web_cmds/tests.rs +++ b/crates/engine/src/cmd/web_cmds/tests.rs @@ -201,7 +201,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}"); @@ -299,6 +299,107 @@ 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 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] +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"); @@ -859,6 +960,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"); @@ -871,7 +975,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, None, 72.0, true).unwrap(); assert_eq!(o.preview.len(), 64 * 48 * 4, "{f}"); @@ -1092,6 +1196,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"); diff --git a/crates/engine/src/kys.rs b/crates/engine/src/kys.rs index 643ed46057..1406e0a384 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() { @@ -119,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 @@ -262,6 +275,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); @@ -309,6 +323,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 @@ -323,6 +344,28 @@ 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 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/tests.rs b/crates/engine/src/tests.rs index e9c1bbb691..64ed09e9e2 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/tests/it/integration_batch.rs b/crates/engine/tests/it/integration_batch.rs new file mode 100644 index 0000000000..14977beece --- /dev/null +++ b/crates/engine/tests/it/integration_batch.rs @@ -0,0 +1,34 @@ +//! 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; + +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 gaussian_blur_accepts_radii_below_one_without_failing() { + 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(); + } +} 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/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; 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/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index 1a475827e8..060d12b5d9 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -1116,15 +1116,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; @@ -1143,6 +1138,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/src/consts.rs b/crates/gpu/src/consts.rs index 40081a55c8..536aea8a49 100644 --- a/crates/gpu/src/consts.rs +++ b/crates/gpu/src/consts.rs @@ -33,6 +33,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 a large 8-bit pattern at 4 bytes a pixel instead of 16 (`Pattern::working_bytes`). +pub(crate) 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; diff --git a/crates/gpu/src/setup.rs b/crates/gpu/src/setup.rs index c5b5c90384..1eef2147b5 100644 --- a/crates/gpu/src/setup.rs +++ b/crates/gpu/src/setup.rs @@ -1,5 +1,5 @@ use super::batch::tile_grid; -use super::consts::{ACC_FORMAT, CHUNK, CHUNK_UNIFORM, FX_BUDGET, MAP16, MAP32, OP_UNIFORM, PAGE, PATTERN_BUDGET, RESIDENT_BUDGET}; +use super::consts::{ACC_FORMAT, CHUNK, CHUNK_UNIFORM, FX_BUDGET, MAP16, MAP32, MAX_PATTERN_TEXTURE_BYTES, OP_UNIFORM, PAGE, PATTERN_BUDGET, RESIDENT_BUDGET}; use super::kit::{Kit, SHADER, tex_entry, uniform_entry}; use super::paging::{cell_rect, field_reaches, first_error, grid_rects}; use super::textures::{FxEntry, Resident, Tex, TexKind}; @@ -372,19 +372,32 @@ 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 per-pattern cap; a larger one renders on + /// the CPU compositor. + fn check_pattern(&self, pat: &photocraft_doc::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); diff --git a/crates/gpu/tests/it/parity.rs b/crates/gpu/tests/it/parity.rs index 901a21365b..ea1af9951e 100644 --- a/crates/gpu/tests/it/parity.rs +++ b/crates/gpu/tests/it/parity.rs @@ -2609,6 +2609,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 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 }; + 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] @@ -2688,7 +2709,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) }; diff --git a/crates/io/src/effects_map.rs b/crates/io/src/effects_map.rs index 36aff72a3a..175ce73c94 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 { diff --git a/crates/io/src/effects_map/tests.rs b/crates/io/src/effects_map/tests.rs index 3944739fb8..d2488ae82e 100644 --- a/crates/io/src/effects_map/tests.rs +++ b/crates/io/src/effects_map/tests.rs @@ -198,6 +198,35 @@ fn blend_names_are_unique() { } } +#[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`) 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 633d7f19c2..a88773e05c 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -1122,6 +1122,7 @@ fn read_initial_metadata(file: &PsdFile, global: &[PsdGlobalBlock], doc: &mut Do )); } doc.patterns = crate::pattern_map::from_global_blocks(doc); + warnings.extend(crate::pattern_map::unpaintable_warnings(&doc.patterns)); doc.notes = crate::annotations_map::notes_from_blocks(doc); if crate::annotations_map::anno_unreadable(doc) { warnings.push("the notes (Anno block) could not be read; they are kept unchanged on PSD save unless a note is added".into()); diff --git a/crates/io/src/smart_map/halftone_tests.rs b/crates/io/src/smart_map/halftone_tests.rs index 931483c55b..d4f67dbfec 100644 --- a/crates/io/src/smart_map/halftone_tests.rs +++ b/crates/io/src/smart_map/halftone_tests.rs @@ -154,6 +154,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("ScrD", 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() { diff --git a/crates/io/src/smart_map/import.rs b/crates/io/src/smart_map/import.rs index 55bd79ec06..f8f3f50e3f 100644 --- a/crates/io/src/smart_map/import.rs +++ b/crates/io/src/smart_map/import.rs @@ -710,7 +710,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", "HlSz", "Cntr", "ScrT", "GELv"]) || !matches!(e.get("GEfk"), Some(Value::Enumerated { type_id, .. }) if type_id.is("GEft")) diff --git a/crates/io/tests/it/psd_kerning.rs b/crates/io/tests/it/psd_kerning.rs index 8b85c4f3ba..d873d4a3e2 100644 --- a/crates/io/tests/it/psd_kerning.rs +++ b/crates/io/tests/it/psd_kerning.rs @@ -199,14 +199,22 @@ 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::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)))]; @@ -228,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(usize::try_from(i).ok()?)).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 = usize::try_from(layers.iter().find(|l| l.0 == "Hi").and_then(|l| l.1).unwrap()).unwrap(); 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) diff --git a/crates/io/tests/it/text_corpus.rs b/crates/io/tests/it/text_corpus.rs index 32e914d051..b0deed7746 100644 --- a/crates/io/tests/it/text_corpus.rs +++ b/crates/io/tests/it/text_corpus.rs @@ -315,3 +315,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/ops/src/lib.rs b/crates/ops/src/lib.rs index 98194ccb68..e0c3bd24b9 100644 --- a/crates/ops/src/lib.rs +++ b/crates/ops/src/lib.rs @@ -587,10 +587,10 @@ impl History { /// 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; @@ -624,7 +624,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)); } diff --git a/crates/ops/src/tests.rs b/crates/ops/src/tests.rs index 2bd941336d..2a10eaea48 100644 --- a/crates/ops/src/tests.rs +++ b/crates/ops/src/tests.rs @@ -197,6 +197,31 @@ fn clear_states_resets_the_brush_source_until_undo() { 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(); 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/build.rs b/crates/text/src/psd/build.rs index e4097c7de8..c92a3cffea 100644 --- a/crates/text/src/psd/build.rs +++ b/crates/text/src/psd/build.rs @@ -155,19 +155,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(); @@ -183,6 +173,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(); @@ -220,6 +228,278 @@ 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 }; + 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")); + 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(style_path)) + && !agrees(s, style) + { + 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) +} + +/// 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] = [ + ("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; + } + // 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) { + 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())); + // 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)); + 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. @@ -290,6 +570,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 @@ -332,12 +616,27 @@ 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 // 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 @@ -444,6 +743,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/psd/edit.rs b/crates/text/src/psd/edit.rs index 1b1b90e0ff..ba08ab1e79 100644 --- a/crates/text/src/psd/edit.rs +++ b/crates/text/src/psd/edit.rs @@ -36,7 +36,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 @@ -150,7 +150,7 @@ pub(super) 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; diff --git a/crates/text/src/psd/import.rs b/crates/text/src/psd/import.rs index 374c7408e5..e29ec75fb6 100644 --- a/crates/text/src/psd/import.rs +++ b/crates/text/src/psd/import.rs @@ -32,19 +32,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(); @@ -65,13 +78,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; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 9dd58a55d4..b63a45b4ae 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1436,6 +1436,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. @@ -3158,3 +3208,285 @@ 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, auto_kern: i64) -> 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.01)), + ("HorizontalScale".into(), E::Real(1.0)), + ("VerticalScale".into(), E::Real(1.0)), + ("Tracking".into(), E::Int(0)), + ("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)), + ( + "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, 1); + 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.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"); + // 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, 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())); +} + +/// 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, 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); + 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, 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)]); + 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"); +} + +/// 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)); +} + +/// 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"); +} diff --git a/crates/ui-egui/examples/snapshot.rs b/crates/ui-egui/examples/snapshot.rs index 7fa07a10bc..8429b1c466 100644 --- a/crates/ui-egui/examples/snapshot.rs +++ b/crates/ui-egui/examples/snapshot.rs @@ -22,6 +22,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}; @@ -170,6 +172,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/canvas.rs b/crates/ui-egui/src/canvas.rs index 3896b84cf6..f479e67510 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -101,6 +101,7 @@ use strokes::{HOLD_REPEAT_MS, draw_clone_preview, feed_live_stroke, pointer_butt #[cfg(test)] pub(crate) use strokes::{SPOT_HEALING_TRAIL, live_retouch_points}; pub(crate) use tabs::arranged_cells; +pub(crate) use tabs::tab_title_at; #[cfg(test)] use tabs::{TEAR_OFF, pro_tab_title, tab_context_items, tab_drop_index, tab_move_target, tabs, torn_off}; pub use tabs::{TabStrip, document_area, extra_windows}; @@ -115,10 +116,7 @@ use textures::{ensure_gpu, redraw_focus, visible_doc_rect}; pub use tool_input::{ToolEvent, selection_drag_kind, tool_event}; pub(crate) use tool_input::{command_guide_at, command_moves_layer, event_tool}; pub(crate) use transform_controls::{LAYER_EDGES, layer_edges_rect}; -use transform_controls::{ - begin_transform_controls_at, draw_transform_controls, transform_controls_cursor, transform_controls_rect, transform_controls_several, - transform_controls_turn, -}; +use transform_controls::{begin_transform_controls_at, draw_transform_controls, transform_controls_cursor, transform_controls_rect, transform_controls_turn}; #[cfg(test)] use transform_controls::{transform_controls_hit, transform_controls_layers}; pub use view_xform::{ViewXform, doc_point_at, fill_view, fit_view}; @@ -1360,10 +1358,8 @@ fn draw_canvas_tool_cursor(app: &mut PhotocraftApp, ui: &mut egui::Ui, input: &C // 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). diff --git a/crates/ui-egui/src/canvas/select_mask_view.rs b/crates/ui-egui/src/canvas/select_mask_view.rs index e60da1054d..b4962d512d 100644 --- a/crates/ui-egui/src/canvas/select_mask_view.rs +++ b/crates/ui-egui/src/canvas/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) } @@ -438,6 +438,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 { @@ -449,6 +453,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"), } } } @@ -461,16 +475,31 @@ 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())); 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 { @@ -559,7 +588,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 => { @@ -705,6 +737,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/canvas/snap_ui.rs b/crates/ui-egui/src/canvas/snap_ui.rs index 05365dab54..4e1537d41b 100644 --- a/crates/ui-egui/src/canvas/snap_ui.rs +++ b/crates/ui-egui/src/canvas/snap_ui.rs @@ -216,15 +216,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/canvas/strokes.rs b/crates/ui-egui/src/canvas/strokes.rs index 15aa741a82..32e9b19fcc 100644 --- a/crates/ui-egui/src/canvas/strokes.rs +++ b/crates/ui-egui/src/canvas/strokes.rs @@ -6,14 +6,73 @@ use super::{Color32, DRect, PhotocraftApp, Pos2, Stroke, Tool, json, pos2}; pub(crate) struct ClonePreviewCache { pub(super) key: String, - texture: egui::TextureHandle, + /// The brush a clipped overlay took its coverage from: a hardness or tip change redraws it. + pub(super) brush: Option, + /// The document area the texture covers. + pub(super) rect: DRect, + pub(super) 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 `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, 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 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 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 { + 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. pub(super) 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(); @@ -22,30 +81,43 @@ pub(super) fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painte 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, &map) 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 { @@ -59,55 +131,43 @@ pub(super) fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painte // 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)); } @@ -115,8 +175,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 +364,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 +493,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); } } _ => {} diff --git a/crates/ui-egui/src/canvas/tabs.rs b/crates/ui-egui/src/canvas/tabs.rs index 82a1e2089f..a3d18fe285 100644 --- a/crates/ui-egui/src/canvas/tabs.rs +++ b/crates/ui-egui/src/canvas/tabs.rs @@ -436,9 +436,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. pub(super) 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/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 2f4d2bdfc5..80b9c502a9 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -1175,70 +1175,151 @@ fn dragging_a_tab_off_the_strip_floats_it_in_a_window() { } } +/// 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::unique("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::unique("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::unique("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::unique("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"); +} + +/// 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] +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}"); @@ -1249,27 +1330,20 @@ fn clone_overlay_keeps_magnified_pixels_crisp_on_upload_and_refresh() { #[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::unique("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); @@ -1281,32 +1355,14 @@ fn clone_overlay_blend_mode_mixes_with_the_image_under_the_brush() { #[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::unique("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/canvas/transform_controls.rs b/crates/ui-egui/src/canvas/transform_controls.rs index fccc2c9751..7e00fd8cb3 100644 --- a/crates/ui-egui/src/canvas/transform_controls.rs +++ b/crates/ui-egui/src/canvas/transform_controls.rs @@ -40,12 +40,6 @@ pub(super) fn transform_controls_layers(app: &PhotocraftApp) -> Vec<&photocraft_ 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. -pub(super) 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. pub(super) fn transform_controls_rect(app: &PhotocraftApp, xf: &ViewXform) -> Option { @@ -110,11 +104,9 @@ pub(super) 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). pub(super) 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; @@ -128,8 +120,8 @@ pub(super) fn begin_transform_controls_at(app: &mut PhotocraftApp, ctx: &egui::C } } -/// 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. pub(super) 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; @@ -140,9 +132,6 @@ pub(super) fn draw_transform_controls(app: &mut PhotocraftApp, painter: &egui::P } } 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); diff --git a/crates/ui-egui/src/canvas/transform_controls_tests.rs b/crates/ui-egui/src/canvas/transform_controls_tests.rs index 957ae3589d..19fd52f080 100644 --- a/crates/ui-egui/src/canvas/transform_controls_tests.rs +++ b/crates/ui-egui/src/canvas/transform_controls_tests.rs @@ -115,7 +115,7 @@ fn a_floating_piece_among_several_selected_layers_keeps_the_shared_frame() { 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 = @@ -126,34 +126,36 @@ fn a_floating_piece_among_several_selected_layers_keeps_the_shared_frame() { } #[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/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 1db2ec45ad..441dd31433 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -167,7 +167,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)); @@ -177,6 +177,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.rs b/crates/ui-egui/src/dialogs.rs index a14b42c37f..89954c52ea 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1122,6 +1122,7 @@ pub mod rasterize_prompt; pub mod replace_prompt; pub(super) mod rgb_histogram; pub mod screen_picker; +pub mod select_mask_ui; pub mod shape_dialog; pub(crate) mod shape_fill_ui; pub(super) mod sizing; diff --git a/crates/ui-egui/src/dialogs/color_picker_ui.rs b/crates/ui-egui/src/dialogs/color_picker_ui.rs index 46ecbce0e8..ac026d9e6a 100644 --- a/crates/ui-egui/src/dialogs/color_picker_ui.rs +++ b/crates/ui-egui/src/dialogs/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/dialogs/distort_ui.rs b/crates/ui-egui/src/dialogs/distort_ui.rs index 3a5c765da6..b5ee13f464 100644 --- a/crates/ui-egui/src/dialogs/distort_ui.rs +++ b/crates/ui-egui/src/dialogs/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()), @@ -95,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") @@ -116,7 +126,7 @@ fn on_doc(app: &PhotocraftApp, document: DocId) -> bool { active_doc(app) == Some(document) } -/// Drops Liquify / Puppet / Perspective Warp / Vanishing Point / Filter Gallery when the +/// Drops Liquify / Select and Mask / Puppet / Perspective Warp / Vanishing Point / Filter Gallery when the /// document they were opened on is no longer active, so they cannot steal undo, block layer /// delete, or draw another document's pixels (#5115). pub fn cancel_stale(app: &mut PhotocraftApp) { @@ -136,6 +146,10 @@ pub fn cancel_stale(app: &mut PhotocraftApp) { if app.distort.gallery.as_ref().is_some_and(|g| Some(g.document) != active) { app.distort.gallery = None; } + // Its document, or the layer it refines, is no longer the active one (#8896 review). + if crate::select_mask_ui::stale(app).is_some() { + crate::select_mask_ui::cancel(app); + } } /// The document shown on the canvas while Puppet or Perspective Warp previews a layer, or @@ -171,6 +185,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"]}))) } @@ -196,6 +213,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.as_ref().is_some_and(|w| on_doc(app, w.document)) { + crate::select_mask_ui::pointer(app, ev, mods); + return true; + } if app.distort.liquify.as_ref().is_some_and(|d| on_doc(app, d.document)) { crate::liquify_ui::pointer(app, ev, mods); return true; @@ -238,6 +259,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); @@ -348,6 +378,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; @@ -363,6 +394,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/dialogs/export_dialog.rs b/crates/ui-egui/src/dialogs/export_dialog.rs index 7fd65f68c8..b949b3b293 100644 --- a/crates/ui-egui/src/dialogs/export_dialog.rs +++ b/crates/ui-egui/src/dialogs/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,42 +113,54 @@ 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); - 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(src_w), f64::from(src_h)); + 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). 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` 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"); - } } Ok(id) } @@ -342,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 }; diff --git a/crates/ui-egui/src/dialogs/export_dialog/tests.rs b/crates/ui-egui/src/dialogs/export_dialog/tests.rs index b015c97f85..cf79a0e38c 100644 --- a/crates/ui-egui/src/dialogs/export_dialog/tests.rs +++ b/crates/ui-egui/src/dialogs/export_dialog/tests.rs @@ -74,8 +74,9 @@ fn a_replayed_export_needs_a_path_and_a_known_format() { } /// 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()); @@ -94,10 +95,105 @@ fn layer_export_as_opens_with_the_layer_size() { 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))); +} + +/// 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 diff --git a/crates/ui-egui/src/dialogs/file_ui.rs b/crates/ui-egui/src/dialogs/file_ui.rs index 425d7eae44..ac92f17278 100644 --- a/crates/ui-egui/src/dialogs/file_ui.rs +++ b/crates/ui-egui/src/dialogs/file_ui.rs @@ -24,7 +24,7 @@ use print::{drag_print_position, driver_paper_is_wide, print_params, print_previ use print::{print_body, print_confirm}; pub use web::open_web; #[cfg(test)] -use web::{WEB_COMMAND, WebCache, WebProxy, advance_preview, download_for_web, web_frame_document}; +use web::{WEB_COMMAND, WebCache, WebProxy, advance_preview, download_for_web, variants, web_frame_document}; use web::{web_body, web_confirm}; fn no_params(p: &Value) -> bool { @@ -273,6 +273,28 @@ mod web_playback_tests { h } + /// WebP (#6391): Quality until Lossless is ticked; 4-Up halves a lossy quality like JPEG's. + #[test] + fn webp_settings_show_quality_until_lossless() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 32, "height": 24})).unwrap(); + let id = open_web(&mut app); + let mut fields = app.ui.dialog_mut(id).unwrap().fields.clone(); + fields.insert("format".into(), json!("webp")); + assert_eq!(fields["lossless"], false); + let mut h = harness(app, fields); + 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_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))); + let v = variants(&json!({"format": "webp", "lossless": true})); + assert_eq!((v[1]["format"].as_str(), v[2]["format"].as_str()), (Some("png8"), Some("gif"))); + } + /// #9174: Save for Web reopens on the preview tab chosen last, as Photoshop's does; 2-Up at /// first, and for a remembered value it doesn't know. #[test] diff --git a/crates/ui-egui/src/dialogs/file_ui/tests.rs b/crates/ui-egui/src/dialogs/file_ui/tests.rs index 19f7b1ceff..8fae0d975a 100644 --- a/crates/ui-egui/src/dialogs/file_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/file_ui/tests.rs @@ -256,6 +256,16 @@ fn save_for_web_dialog_runs_the_command() { let d = app.ui.dialog_mut(r["dialog"].as_u64().unwrap()).unwrap(); assert_eq!(d.fields["format"], "gif"); assert!(!d.fields.contains_key("path")); + // WebP, with the dialog's own defaults (lossy at quality 60). + let id = r["dialog"].as_u64().unwrap(); + let d = app.ui.dialog_mut(id).unwrap(); + let path = dir.join("web.webp").to_string_lossy().into_owned(); + d.fields.insert("format".into(), json!("webp")); + d.fields.insert("path".into(), json!(path)); + crate::dialogs::confirm(&mut app, id).unwrap(); + let bytes = std::fs::read(&path).unwrap(); + assert_eq!(&bytes[8..12], b"WEBP"); + assert!(bytes.windows(4).any(|w| w == b"VP8 "), "lossy by default"); } /// A fresh session's dialog seeds from the preferences: the last export before a restart is diff --git a/crates/ui-egui/src/dialogs/file_ui/web.rs b/crates/ui-egui/src/dialogs/file_ui/web.rs index a370da2003..62082b4f8d 100644 --- a/crates/ui-egui/src/dialogs/file_ui/web.rs +++ b/crates/ui-egui/src/dialogs/file_ui/web.rs @@ -1,6 +1,6 @@ use super::{Arc, DialogKind, Map, PhotocraftApp, Tokens, Value, WebSettings, b, check, dropdown_str, json, n, number, params, s, web_cmds, 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")]; pub(super) const WEB_COMMAND: &str = "file.export.saveForWebLegacy"; @@ -35,6 +35,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")), @@ -57,7 +58,7 @@ pub fn open_web(app: &mut PhotocraftApp) -> u64 { } /// Settings variants shown in 4-Up (current, then two smaller alternatives). -fn variants(p: &Value) -> Vec { +pub(super) fn variants(p: &Value) -> Vec { let mut out = vec![p.clone()]; let fmt = p["format"].as_str().unwrap_or("jpeg"); for k in [2.0, 4.0] { @@ -66,7 +67,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)); @@ -138,10 +139,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); @@ -350,6 +350,14 @@ pub(super) 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, @@ -366,7 +374,7 @@ pub(super) fn web_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = 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, + /// The document revision the base was computed from. + revision: u64, + 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 { + /// The document and layer the workspace was opened on: its work applies to them only. + layer: Option, + pub(crate) document: DocId, + 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() + } + + /// 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.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.reject(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.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.reject(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> { + // 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})")); + } + // 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; + 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, + document, + 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(()) +} + +/// 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(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(tl!("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(); + 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 { + 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(); + 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})); + } + drop_stale(app)?; + { + 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)?; + 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; + if let Some(add) = add { + w.add = add; + } + 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((pts, alt)) = stroke { + 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 { + 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(); + 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) +} + +/// 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 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> { + 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, 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 && 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, 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, + }; + // 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 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 = 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)) +} + +/// 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>) { + // 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)) => { + 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> { + 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); + 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) { + 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 => { + 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", 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). + 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", + tl!("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", tl!("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 +} + +/// 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 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); + upload(app, ctx); + 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; + 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::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); + 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::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() + && !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 }; + 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; + 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(); + } + }); + 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)] +mod tests; diff --git a/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs b/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs new file mode 100644 index 0000000000..7df542fe74 --- /dev/null +++ b/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs @@ -0,0 +1,794 @@ +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"); + } +} + +/// 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] +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)); +} + +/// 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)); +} + +/// 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 }; + 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); + } +} + +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, "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}"); + 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()); +} + +/// 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()); + } + } +} + +#[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_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); + + 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); +} diff --git a/crates/ui-egui/src/dialogs/solid_fill_ui.rs b/crates/ui-egui/src/dialogs/solid_fill_ui.rs index 5d9112abab..37844b86c2 100644 --- a/crates/ui-egui/src/dialogs/solid_fill_ui.rs +++ b/crates/ui-egui/src/dialogs/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 { @@ -72,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 { @@ -89,15 +122,18 @@ 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") { + }; + 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) = color_picker_ui::picked_cmyk(f) { + object.insert("cmyk".into(), json!(inks)); + } app.run(cmds::SET, params) } @@ -109,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)); + }; + 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); + + // 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); +} + +fn with_inks(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)); +} + +/// 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])); +} 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/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… تنعيم… diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index ad024ccd97..97a77d2dab 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4200,6 +4200,20 @@ cameraRaw Dehaze Odstranění oparu Not rendered: this adjustment ({key}) is kept as imported. Nevykresleno: tato úprava ({key}) je ponechána tak, jak byla importována. # 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 # Color panel menu: modes, copy commands and colour ramps (#6000) Hue Cube Krychle odstínu Brightness Cube Krychle jasu @@ -4217,6 +4231,8 @@ 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 # Art History Brush (#213). Art History Brush Štětec umělecké historie Art History Brush Tool Nástroj štětec umělecké historie diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index c1dfc027ed..7c27df11cd 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3468,6 +3468,21 @@ curves Smooth Glätten Not rendered: this adjustment ({key}) is kept as imported. Nicht dargestellt: Diese Einstellung ({key}) wird wie importiert beibehalten. # 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 # Color panel menu: modes, copy commands and colour ramps (#6000) Hue Cube Farbtonwürfel Brightness Cube Helligkeitswürfel @@ -3485,6 +3500,8 @@ 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 # Art History Brush (#213). Art History Brush Künstlerischer Protokollpinsel Art History Brush Tool Künstlerischer-Protokollpinsel-Werkzeug diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index b2b35dbbdc..c3facf56a0 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4155,6 +4155,20 @@ curves Smooth Εξομάλυνση Not rendered: this adjustment ({key}) is kept as imported. Δεν αποδίδεται: αυτή η προσαρμογή ({key}) διατηρείται όπως εισήχθη. # 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 Η Βελτίωση μαλλιών χρειάζεται ανίχνευση μαλλιών, που δεν είναι ακόμη διαθέσιμη # Color panel menu: modes, copy commands and colour ramps (#6000) Hue Cube Κύβος απόχρωσης Brightness Cube Κύβος φωτεινότητας @@ -4171,6 +4185,8 @@ 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 Η Επιλογή και μάσκα έκλεισε: επιλέχθηκε άλλο επίπεδο # Art History Brush (#213). Art History Brush Tool Πινέλο ιστορικού τέχνης (Art History Brush Tool) Tight Short Σφιχτό κοντό diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b42f3be02c..4299cc0496 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4417,6 +4417,20 @@ navigator Zoom Zoom Not rendered: this adjustment ({key}) is kept as imported. No se renderiza: este ajuste ({key}) se conserva tal como se importó. # 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 # Color panel menu: modes, copy commands and colour ramps (#6000) Hue Cube Cubo de tono Brightness Cube Cubo de brillo @@ -4433,6 +4447,8 @@ 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 # Art History Brush (#213). Art History Brush Tool Herramienta Pincel histórico artístico Tight Short Corto apretado diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 3bad30d474..4fbc29fe39 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4497,6 +4497,20 @@ shape Appearance Apparence Not rendered: this adjustment ({key}) is kept as imported. Non rendu : ce réglage ({key}) est conservé tel qu'importé. # 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 # Color panel menu: modes, copy commands and colour ramps (#6000) Hue Cube Cube de teinte Brightness Cube Cube de luminosité @@ -4513,6 +4527,8 @@ 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é # Art History Brush (#213). Art History Brush Tool Outil Pinceau d'historique artistique Tight Short Serré court diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 3d6ddab7cb..8e304dc355 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -539,6 +539,23 @@ 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 बाल परिशोधित करने के लिए बालों की पहचान चाहिए, जो अभी उपलब्ध नहीं है + Select and Mask was closed: its document is no longer the active one चयन और मास्क बंद कर दिया गया: उसका दस्तावेज़ अब सक्रिय नहीं है + Select and Mask was closed: another layer was selected चयन और मास्क बंद कर दिया गया: दूसरी लेयर चुनी गई # Art History Brush (#213). Art History Brush आर्ट हिस्ट्री ब्रश Style शैली diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index d632cb52f1..59c727ab13 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4369,6 +4369,20 @@ cameraRaw Yellow Sárga Not rendered: this adjustment ({key}) is kept as imported. Nem jelenik meg: ez a korrekció ({key}) importált állapotában marad. # 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 # Color panel menu: modes, copy commands and colour ramps (#6000) Hue Cube Színárnyalat-kocka Brightness Cube Fényerő-kocka @@ -4385,6 +4399,8 @@ 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 # Art History Brush (#213). Art History Brush Tool Művészi előzményecset eszköz Tight Short Szoros, rövid diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 771e04e80a..e68b0a7886 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3470,6 +3470,21 @@ curves Smooth Haluskan Not rendered: this adjustment ({key}) is kept as imported. Tidak dirender: penyesuaian ini ({key}) disimpan seperti saat diimpor. # 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 # Color panel menu: modes, copy commands and colour ramps (#6000) Hue Cube Kubus Hue Brightness Cube Kubus Kecerahan @@ -3486,6 +3501,8 @@ 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 # Art History Brush (#213). Art History Brush Tool Alat Kuas riwayat artistik Tight Short Rapet pendek diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index b1598ac237..c1cb47ef59 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4572,6 +4572,20 @@ shape Appearance Aspetto Not rendered: this adjustment ({key}) is kept as imported. Non renderizzato: questa regolazione ({key}) viene conservata come importata. # 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 # Color panel menu: modes, copy commands and colour ramps (#6000) Hue Cube Cubo tonalità Brightness Cube Cubo luminosità @@ -4588,6 +4602,8 @@ 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 # Art History Brush (#213). Art History Brush Tool Strumento Pennello storia artistica Tight Short Corto stretto diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 435813b0c7..708f63a6d1 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4134,6 +4134,21 @@ curves Smooth スムーズ Not rendered: this adjustment ({key}) is kept as imported. レンダリングされません: この調整({key})はインポートされたまま保持されます。 # 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 髪の毛を調整には髪の毛の検出が必要ですが、まだ利用できません # Color panel menu: modes, copy commands and colour ramps (#6000) Hue Cube 色相キューブ Brightness Cube 明るさキューブ @@ -4150,6 +4165,8 @@ 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 「選択とマスク」を閉じました: 別のレイヤーが選択されました # Art History Brush (#213). Art History Brush Tool アートヒストリーブラシツール Tight Short タイト・ショート diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 30ab8cb165..51804edaa1 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3666,6 +3666,21 @@ curves Smooth 매끄럽게 Not rendered: this adjustment ({key}) is kept as imported. 렌더링되지 않음: 이 조정({key})은 가져온 상태 그대로 유지됩니다. # 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 머리카락 다듬기에는 머리카락 감지가 필요하지만 아직 사용할 수 없습니다 # Color panel menu: modes, copy commands and colour ramps (#6000) Hue Cube 색조 큐브 Brightness Cube 밝기 큐브 @@ -3683,6 +3698,8 @@ 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 선택 및 마스크가 닫혔습니다: 다른 레이어가 선택되었습니다 # Art History Brush (#213). Art History Brush 아트 히스토리 브러시 Art History Brush Tool 아트 히스토리 브러시 도구 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index d03260ecaf..5a2164c97e 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4215,6 +4215,20 @@ curves Smooth Vloeiend maken Not rendered: this adjustment ({key}) is kept as imported. Niet weergegeven: deze aanpassing ({key}) blijft behouden zoals geïmporteerd. # 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 # Color panel menu: modes, copy commands and colour ramps (#6000) Hue Cube Kleurtoonkubus Brightness Cube Helderheidskubus @@ -4231,6 +4245,8 @@ 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 # Art History Brush (#213). Art History Brush Tool Artistiek historiepenseel Tight Short Strak kort diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ba41a4e34d..679d90ed34 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4072,6 +4072,21 @@ curves Smooth Wygładź Not rendered: this adjustment ({key}) is kept as imported. Nie jest renderowana: ta korekta ({key}) jest zachowana w postaci zaimportowanej. # 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 # Color panel menu: modes, copy commands and colour ramps (#6000) Hue Cube Sześcian odcienia Brightness Cube Sześcian jasności @@ -4088,6 +4103,8 @@ 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ę # Art History Brush (#213). Art History Brush Tool Narzędzie Pędzel historii artystycznej Tight Short Ścisły krótki diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f3335cd9c6..57d0fcc985 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4385,6 +4385,20 @@ layerLabel No Color Sem cor Not rendered: this adjustment ({key}) is kept as imported. Não renderizado: este ajuste ({key}) é mantido como foi importado. # 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 # Color panel menu: modes, copy commands and colour ramps (#6000) Hue Cube Cubo de matiz Brightness Cube Cubo de brilho @@ -4401,6 +4415,8 @@ 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 # Art History Brush (#213). Art History Brush Tool Ferramenta Pincel do histórico da arte Tight Short Curto cerrado diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b74c398312..c1be00b842 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4658,6 +4658,20 @@ shape Appearance Оформление Not rendered: this adjustment ({key}) is kept as imported. Не отображается: эта коррекция ({key}) сохранена как при импорте. # 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 Для уточнения волос нужно распознавание волос, которое пока недоступно # Color panel menu: modes, copy commands and colour ramps (#6000) Hue Cube Куб цветового тона Brightness Cube Куб яркости @@ -4674,6 +4688,8 @@ 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 Окно «Выделение и маска» закрыто: выбран другой слой # Art History Brush (#213). Art History Brush Tool Инструмент «Кисть истории искусств» Tight Short Плотный короткий diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index a81eb06da2..94ac9b7f2b 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4514,6 +4514,20 @@ shape Appearance Izgled Not rendered: this adjustment ({key}) is kept as imported. Није приказано: ово прилагођавање ({key}) је задржано онако како је увезено. # 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 # PhotoCraft release checks. Check for Updates… Proveri ažuriranja… PhotoCraft Updates Ažuriranja za PhotoCraft @@ -4543,6 +4557,8 @@ 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 # Art History Brush (#213). Art History Brush Tool Alatka umetnička četkica istorije Tight Short Usko kratko diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index f5b9d13fd6..b02cd742b7 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4183,6 +4183,20 @@ 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 Baskı Önizleme Görüntüsü + 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 # Color panel menu: modes, copy commands and colour ramps (#6000) Hue Cube Renk Tonu Küpü Brightness Cube Parlaklık Küpü @@ -4199,6 +4213,8 @@ 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 # Art History Brush (#213). Art History Brush Tool Sanat Tarihi Fırçası Aracı Tight Short Sıkı Kısa diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8d911b19a8..00d192a037 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4165,6 +4165,21 @@ curves Smooth Згладити Not rendered: this adjustment ({key}) is kept as imported. Не відображається: це коригування ({key}) збережено як було імпортовано. # 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 Для уточнення волосся потрібне розпізнавання волосся, яке ще недоступне # Color panel menu: modes, copy commands and colour ramps (#6000) Hue Cube Куб тону Brightness Cube Куб яскравості @@ -4181,6 +4196,8 @@ 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 Вікно «Виділити та маскувати» закрито: вибрано інший шар # Art History Brush (#213). Art History Brush Tool Інструмент «Художній пензель історії» Tight Short Щільний короткий diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f348662975..00bf1b520c 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3948,6 +3948,21 @@ curves Smooth Làm mượt Not rendered: this adjustment ({key}) is kept as imported. Không được kết xuất: điều chỉnh này ({key}) được giữ nguyên như khi nhập. # 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ó # Color panel menu: modes, copy commands and colour ramps (#6000) Hue Cube Khối sắc màu Brightness Cube Khối độ sáng @@ -3965,6 +3980,8 @@ 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 # Art History Brush (#213). Art History Brush Cọ lịch sử nghệ thuật Art History Brush Tool Công cụ Cọ lịch sử nghệ thuật diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d9f437214a..1b423c1c29 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3117,6 +3117,21 @@ curves Smooth 平滑 Not rendered: this adjustment ({key}) is kept as imported. 未渲染:此调整({key})按导入时的原样保留。 # Print preview image (#8236) 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 调整细线需要毛发检测,目前尚不可用 # Color panel menu: modes, copy commands and colour ramps (#6000) Hue Cube 色相立方体 @@ -3142,6 +3157,8 @@ 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 已关闭“选择与蒙版”:已选择另一个图层 # Art History Brush (#213). Art History Brush 艺术历史画笔 Art History Brush Tool 艺术历史画笔工具 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 9d46dcb21e..b44c261364 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3081,6 +3081,21 @@ curves Smooth 平滑 Not rendered: this adjustment ({key}) is kept as imported. 未算繪:此調整({key})依匯入時的原樣保留。 # 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 調整毛髮需要毛髮偵測,目前尚無法使用 # Color panel menu: modes, copy commands and colour ramps (#6000) Hue Cube 色相立方體 Brightness Cube 亮度立方體 @@ -3098,6 +3113,8 @@ 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 已關閉「選取並遮住」:已選取另一個圖層 # Art History Brush (#213). Art History Brush 藝術歷史畫筆 Art History Brush Tool 藝術歷史畫筆工具 diff --git a/crates/ui-egui/src/input/shortcut_dispatch/tests.rs b/crates/ui-egui/src/input/shortcut_dispatch/tests.rs index df8eb96608..3536d58afe 100644 --- a/crates/ui-egui/src/input/shortcut_dispatch/tests.rs +++ b/crates/ui-egui/src/input/shortcut_dispatch/tests.rs @@ -638,10 +638,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/input/shortcuts.rs b/crates/ui-egui/src/input/shortcuts.rs index 4aaec3a4e4..fff3299099 100644 --- a/crates/ui-egui/src/input/shortcuts.rs +++ b/crates/ui-egui/src/input/shortcuts.rs @@ -555,6 +555,10 @@ fn preflight_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { 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); + } true } @@ -562,7 +566,7 @@ fn handle_modal_canvas_keys(app: &mut PhotocraftApp, ctx: &egui::Context, focus: use crate::shortcut_dispatch::{Focus, dispatch_pressed}; // 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/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 22d3565ffa..35a3b0d851 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -149,6 +149,7 @@ pub use dialogs::rasterize_prompt; pub use dialogs::replace_prompt; use dialogs::rgb_histogram; pub use dialogs::screen_picker; +pub use dialogs::select_mask_ui; pub use dialogs::shape_dialog; pub(crate) use dialogs::shape_fill_ui; use dialogs::sizing; @@ -748,6 +749,9 @@ mod transform_undo_tests; #[cfg(test)] mod transform_type_tests; +#[cfg(test)] +mod transform_several_tests; + #[cfg(test)] mod psd_open_tests; #[cfg(test)] diff --git a/crates/ui-egui/src/menus/enable_rules.rs b/crates/ui-egui/src/menus/enable_rules.rs index e3712c1a9a..6abf64fd1d 100644 --- a/crates/ui-egui/src/menus/enable_rules.rs +++ b/crates/ui-egui/src/menus/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/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 850ac92fdd..fdafe87eae 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -214,6 +214,18 @@ fn invoke_file_commands(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, "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 = 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 + }) + } "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). @@ -405,7 +417,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})) @@ -444,6 +457,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. +pub(super) 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})) } diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 2527b0fc3f..c3ec327b15 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -1,5 +1,12 @@ +use super::invoke::data_sets_text; 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/panels.rs b/crates/ui-egui/src/panels.rs index 2b9c365a9d..523fc92ec8 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1952,6 +1952,45 @@ mod sampler_readout_tests { } } +#[cfg(test)] +mod brush_chip_tests { + use super::brush_controls::brush_preset_chip; + + /// 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"); + } +} + #[cfg(test)] mod panel_state_migration_tests { use super::{migrate_panel_state, panel_state_id}; diff --git a/crates/ui-egui/src/panels/brush_controls.rs b/crates/ui-egui/src/panels/brush_controls.rs index d6a96112a7..49cc070205 100644 --- a/crates/ui-egui/src/panels/brush_controls.rs +++ b/crates/ui-egui/src/panels/brush_controls.rs @@ -1,18 +1,5 @@ use super::{Align2, BlendMode, Color32, PhotocraftApp, Rect, Sense, Stroke, StrokeKind, Tokens, c32, icons, json, pos2, vec2, widgets}; -/// 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)); - } -} - /// A painting tool's Mode dropdown over `modes` (`None` is Clear); the wheel over it steps the /// mode. Returns the mode picked this frame. pub(super) fn tool_mode_dropdown(ui: &mut egui::Ui, id: &str, modes: &[Option], current: Option) -> Option> { @@ -114,7 +101,11 @@ pub(super) fn brush_preset_chip(ui: &mut egui::Ui, b: &photocraft_engine::BrushS 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::Role::Button, true, tl!("Brush Preset picker"))); diff --git a/crates/ui-egui/src/panels/layer_row_ui/edit_tests.rs b/crates/ui-egui/src/panels/layer_row_ui/edit_tests.rs index 0c217dd3d0..65496cbe30 100644 --- a/crates/ui-egui/src/panels/layer_row_ui/edit_tests.rs +++ b/crates/ui-egui/src/panels/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/panels/mask_properties_tests.rs b/crates/ui-egui/src/panels/mask_properties_tests.rs index 782337ed36..0fcc9de195 100644 --- a/crates/ui-egui/src/panels/mask_properties_tests.rs +++ b/crates/ui-egui/src/panels/mask_properties_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/state/transform.rs b/crates/ui-egui/src/state/transform.rs index ad8efb7183..fe381c336b 100644 --- a/crates/ui-egui/src/state/transform.rs +++ b/crates/ui-egui/src/state/transform.rs @@ -71,6 +71,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/tools/align_bar.rs b/crates/ui-egui/src/tools/align_bar.rs index d663283e02..0a8ca4b3be 100644 --- a/crates/ui-egui/src/tools/align_bar.rs +++ b/crates/ui-egui/src/tools/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/tools/brush_cursor.rs b/crates/ui-egui/src/tools/brush_cursor.rs index 6bc71a78f7..1710679e95 100644 --- a/crates/ui-egui/src/tools/brush_cursor.rs +++ b/crates/ui-egui/src/tools/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/tools/brush_resize.rs b/crates/ui-egui/src/tools/brush_resize.rs index 1afba1bd1c..0878c6167e 100644 --- a/crates/ui-egui/src/tools/brush_resize.rs +++ b/crates/ui-egui/src/tools/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); } diff --git a/crates/ui-egui/src/tools/brush_resize/tests.rs b/crates/ui-egui/src/tools/brush_resize/tests.rs index 6967c7bb9a..bf6cacc396 100644 --- a/crates/ui-egui/src/tools/brush_resize/tests.rs +++ b/crates/ui-egui/src/tools/brush_resize/tests.rs @@ -344,6 +344,45 @@ fn a_rotated_view_keeps_horizontal_for_size_and_vertical_for_hardness() { assert_eq!(drag_in_view(90.0, true, 0.0, 30.0), (100.0, 0.2), "rotated and flipped: screen right grows"); } +/// 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"); + } +} + /// Preferences ▸ Cursors ▸ Brush Preview colour tints the HUD's tip preview (red by default). #[test] fn the_tip_preview_uses_the_brush_preview_colour() { diff --git a/crates/ui-egui/src/tools/cam_transform.rs b/crates/ui-egui/src/tools/cam_transform.rs index 1914dc883a..9f853b153c 100644 --- a/crates/ui-egui/src/tools/cam_transform.rs +++ b/crates/ui-egui/src/tools/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/tools/retouch_ui/tests.rs b/crates/ui-egui/src/tools/retouch_ui/tests.rs index d157336960..fa291e7417 100644 --- a/crates/ui-egui/src/tools/retouch_ui/tests.rs +++ b/crates/ui-egui/src/tools/retouch_ui/tests.rs @@ -52,6 +52,28 @@ fn clone_stamp_in_pattern_preview_wraps_a_dab_across_the_right_edge() { } } +/// 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)] { diff --git a/crates/ui-egui/src/tools/transform_tool.rs b/crates/ui-egui/src/tools/transform_tool.rs index 672bd82868..1a2b741bc2 100644 --- a/crates/ui-egui/src/tools/transform_tool.rs +++ b/crates/ui-egui/src/tools/transform_tool.rs @@ -32,7 +32,10 @@ mod preview; mod warp; pub(crate) use begin::begin_content_aware; -pub use begin::{begin, begin_copy, begin_placed, begin_selection, begin_warp, enter_warp, leave_warp, warps_type}; +pub(crate) use begin::moving_layers; +#[cfg(test)] +pub(crate) use begin::moving_texel; +pub use begin::{WARP_ONE_LAYER, begin, begin_copy, begin_placed, begin_selection, begin_warp, enter_warp, leave_warp, warps_type}; pub(crate) use commit::{SAVE_FOR_WEB, committing, nudge}; pub use commit::{cancel, commit, end_if_left, follow_edits, intercept, is_enabled, on_job_event, track_steps}; #[cfg(test)] @@ -60,10 +63,10 @@ pub(crate) use pointer::{Gesture, Hit, hit, rotate_cursor_dir}; #[cfg(test)] use pointer::{accepts_outside_click, carry_pivot, near_rotate_corner, point_on_quad, rotation_degrees}; pub use pointer::{cursor, pointer}; -use preview::Above; pub(crate) use preview::solo_layer; +use preview::{Above, composite_image, show_only}; #[cfg(test)] -use preview::{covering, preview_image, show_only}; +use preview::{covering, preview_image}; pub use warp::{edit_session_warp, split}; #[cfg(test)] use warp::{on_section_edge, single_patch, split_cursor, surface_at, warp_hit, warp_pointer}; @@ -137,15 +140,94 @@ 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(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; + } + } + 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) @@ -229,7 +311,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]]] } diff --git a/crates/ui-egui/src/tools/transform_tool/begin.rs b/crates/ui-egui/src/tools/transform_tool/begin.rs index 44bea250aa..f3da463d2f 100644 --- a/crates/ui-egui/src/tools/transform_tool/begin.rs +++ b/crates/ui-egui/src/tools/transform_tool/begin.rs @@ -1,7 +1,9 @@ -use super::commit::{commit, start_steps}; +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, Steps, + Arc, BezierMesh, CAS_ONLY_SCALES, Color32, DocId, Document, Follow, Homography, LOCKED, LayerContent, LayerId, MadeLayer, PhotocraftApp, Several, Steps, TransformPreview, TransformSession, Warp, WarpStyle, corners, json, }; @@ -22,6 +24,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). @@ -69,7 +76,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, @@ -81,6 +88,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, @@ -97,11 +105,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? +pub(super) 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> { @@ -169,7 +301,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, @@ -182,6 +314,7 @@ pub(crate) fn begin_content_aware( path: None, content_aware: Some(live), styled: None, + several: None, }); app.ui.transform = Some(TransformSession { session, @@ -198,6 +331,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(()) @@ -308,6 +442,7 @@ fn begin_lone( tool: app.ui.tool, path: None, content_aware: None, + several: None, }); app.ui.transform = Some(TransformSession { session, @@ -324,6 +459,7 @@ fn begin_lone( mode: Default::default(), content_aware: None, skew_v: 0.0, + layers: Vec::new(), }); start_steps(app); Ok(()) @@ -361,6 +497,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, @@ -377,6 +514,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(()) @@ -446,6 +584,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, @@ -462,6 +601,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(()) @@ -472,9 +612,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. @@ -488,14 +635,19 @@ 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); } 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) diff --git a/crates/ui-egui/src/tools/transform_tool/commit.rs b/crates/ui-egui/src/tools/transform_tool/commit.rs index eebc7120ce..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). @@ -69,7 +70,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(); } @@ -164,7 +170,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 switched_away(app) { commit(app); @@ -194,18 +200,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); } @@ -280,6 +301,17 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> Option { SAVE_FOR_WEB => 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, @@ -288,10 +320,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)? pub(super) 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 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 0114e57754..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. @@ -52,10 +53,7 @@ pub(super) fn handle_indicator(app: &PhotocraftApp, t: &TransformSession, h: Hit /// 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 diff --git a/crates/ui-egui/src/tools/transform_tool/draw.rs b/crates/ui-egui/src/tools/transform_tool/draw.rs index fc1babfbdb..fde7b73cfc 100644 --- a/crates/ui-egui/src/tools/transform_tool/draw.rs +++ b/crates/ui-egui/src/tools/transform_tool/draw.rs @@ -110,7 +110,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(); @@ -147,6 +147,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. +pub(super) 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) { diff --git a/crates/ui-egui/src/tools/transform_tool/pointer.rs b/crates/ui-egui/src/tools/transform_tool/pointer.rs index 87ccfe2f49..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. @@ -137,8 +137,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; } diff --git a/crates/ui-egui/src/tools/transform_tool/preview.rs b/crates/ui-egui/src/tools/transform_tool/preview.rs index b9edbc67e8..9c344caa4a 100644 --- a/crates/ui-egui/src/tools/transform_tool/preview.rs +++ b/crates/ui-egui/src/tools/transform_tool/preview.rs @@ -136,7 +136,7 @@ impl Above { /// `rect` of `doc`'s composite as premultiplied texels: full resolution when it fits in /// `max_side`, composited in bands (no full-size float copy), else area-averaged down to fit. -fn composite_image(doc: &Document, rect: photocraft_geom::Rect, max_side: usize) -> (egui::ColorImage, [f32; 2]) { +pub(super) fn composite_image(doc: &Document, rect: photocraft_geom::Rect, max_side: usize) -> (egui::ColorImage, [f32; 2]) { let (w, h) = (rect.width() as usize, rect.height() as usize); let to_color = |p: &[u8; 4]| Color32::from_rgba_unmultiplied(p[0], p[1], p[2], p[3]); if w.max(h) <= max_side.max(1) { diff --git a/crates/ui-egui/src/tools/transform_tool/tests.rs b/crates/ui-egui/src/tools/transform_tool/tests.rs index 1646fa0775..74411efb38 100644 --- a/crates/ui-egui/src/tools/transform_tool/tests.rs +++ b/crates/ui-egui/src/tools/transform_tool/tests.rs @@ -16,6 +16,7 @@ fn session() -> TransformSession { mode: Default::default(), content_aware: None, skew_v: 0.0, + layers: Vec::new(), } } @@ -2143,6 +2144,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)); diff --git a/crates/ui-egui/src/tools/type_transform.rs b/crates/ui-egui/src/tools/type_transform.rs index 5c8a148eea..b725bbb0b8 100644 --- a/crates/ui-egui/src/tools/type_transform.rs +++ b/crates/ui-egui/src/tools/type_transform.rs @@ -138,6 +138,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/crates/ui-egui/src/tools/vector_ui/tests.rs b/crates/ui-egui/src/tools/vector_ui/tests.rs index 202c2fd184..31de3650e9 100644 --- a/crates/ui-egui/src/tools/vector_ui/tests.rs +++ b/crates/ui-egui/src/tools/vector_ui/tests.rs @@ -1039,11 +1039,14 @@ fn new_fill_layer_takes_the_selected_path_as_its_vector_mask() { // 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")); } 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..ee593e341f --- /dev/null +++ b/crates/ui-egui/src/transform_several_tests.rs @@ -0,0 +1,318 @@ +//! 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 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(); + 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/widgets/tab_strip.rs b/crates/ui-egui/src/widgets/tab_strip.rs index 728486e973..eb40e09309 100644 --- a/crates/ui-egui/src/widgets/tab_strip.rs +++ b/crates/ui-egui/src/widgets/tab_strip.rs @@ -44,7 +44,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::Role::Button, ui.is_enabled(), tip)); let resp = resp.on_hover_text(tip); egui::Popup::menu(&resp).show(|ui| { diff --git a/crates/vector/src/lib.rs b/crates/vector/src/lib.rs index d6c21e3ac3..7ccaa1b103 100644 --- a/crates/vector/src/lib.rs +++ b/crates/vector/src/lib.rs @@ -567,14 +567,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)) }); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e13f64fedc..a99b863d31 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -72,6 +72,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`, `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. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index f49fa461de..ff3d18f593 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-24 · **Change:** minor (PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; 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; 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; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-24 · **Change:** minor (PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; 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; 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; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 **PSD Filter Gallery (#8940, 2026-10-23):** Halftone Pattern maps its size, contrast and dot/circle/line pattern as editable settings on import and export, with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`: `ScrD`/`ScrC`/`ScrL`). PSD/PSB round trips pass at 8/16/32 bits. This does not claim pixel equivalence for the existing renderer. Unknown or malformed settings remain verbatim on import. @@ -31,7 +31,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); Save keeps a stereo PNG's `sTER` pairing (#6063) | | 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 and Indexed Color documents save as palette BMPs (no transparency) | -| 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). File › Open keeps an animation's first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the durations (#1198) | +| 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). File › Open keeps an animation's first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the durations (#1198) | | 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 | @@ -150,6 +150,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | 2026-10-23 | minor | TIFF: alpha and spot channels are written and read as extra samples (at most 16) with Photoshop's channel resources (1006, 1045, 1077, 1053), spot ink stored 0 = solid as Photoshop 27.11's TIFFs store it; PSD output is unchanged. FILE-215-7 done, FILE-215-17 partial (its TIFF part) (#7415, supersedes #3121) | | 2026-10-23 | patch | PSD and layered TIFF: the `MTrn` block Photoshop writes in a layered TIFF saved with Save Transparency (a positive layer count, `nrTM` in an `II` file) marks the merged image's first extra channel as its transparency, like `Mtrn`/`Mt16`/`Mt32`: such a file no longer opens with its transparency also as an alpha channel (named "Transparency" in Photoshop 27.11's file), and a PSD save no longer writes it twice. A PSD save with an opaque composite drops any of these markers kept from the opened file, so its first alpha channel no longer reopens as transparency and vanishes (split from #7415; credit #3121) | | 2026-10-23 | minor | Amiga IFF write: Save As `.iff` stores a 24-plane ILBM (ByteRun1, alpha as a 1-bit mask plane), and 24-plane ILBMs open | +| 2026-10-16 | minor | Save for Web (Legacy) offers WebP, lossy or lossless, with its own preview and size estimate (#2415, #6391) | | 2026-10-23 | minor | #8940: Filter Gallery Halftone Pattern maps size, contrast and dot/circle/line both ways with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`); PSD/PSB round trips at 8/16/32 bits, malformed descriptors preserved verbatim. Other gallery effects remain partial; no floor or estimate changed. | | 2026-10-22 | minor | 32-bit Hue/Saturation as Photoshop does it: the raised saturation isn't clipped (saturated colours leave 0..1), the chroma is scaled around the lightness by Photoshop's saturation factor (measured at every whole percent), so colours at or above lightness 1 keep their hue, and Colorize builds its colour with the saturation times 1 − \|Lightness\| (8 and 16 bits unchanged): photoshop rgb32 hue-saturation and hue-saturation-colorize pass; Photoshop-oracle floor 188 → 190 of 258 (#5781) | | 2026-10-19 | minor | JPEG Options on File › Save, Save As and Save a Copy (FILE-215-19 partial: no Matte, progressive Scans or size preview) | diff --git a/docs/gaps.md b/docs/gaps.md index 30b8b328e9..ecd19c55c3 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -73,8 +73,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 @@ -268,7 +270,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. @@ -405,6 +409,7 @@ what an agent can't finish alone. | 2026-10-27 | minor | Smooth/Feather command reads and replacement use selection bounds plus blur reach; full-canvas reference and release comparisons extended; percentages and scorecard budgets unchanged | | 2026-10-27 | minor | G9: 12 of 181 preferences do nothing, the scorecard's count with integration batch 9 (#9775) on main; no estimate changed | | 2026-10-26 | minor | Expand/Contract/Border distance work, mask reads and replacement cropped to nonzero bounds; bounded distances for radii ≤5; 36 MP exact-output comparisons improve 1.54–37.82× across eight cases; percentages, estimates and scorecard budgets unchanged | +| 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-25 | minor | G9: the count of preferences that do nothing is the scorecard's again, 12 of 178 (it still said 24 of 171, from 2026-10-15); no estimate changed | | 2026-10-25 | minor | G6: Art History Brush landed as partial (TOOL-213-20, #1610), so no toolbar tool is missing; no percentage or estimate changed | | 2026-10-23 | minor | G4: hard-round built-in presets use 5% spacing (#8842), guarded by large-stroke outline and pressure-opacity pixel regressions at 8/16/32-bit; percentages and estimates unchanged | @@ -413,6 +418,7 @@ what an agent can't finish alone. | 2026-10-23 | minor | G10: Amiga IFF write landed (24-plane ILBM, Save As `.iff`) and 24-plane ILBMs open | | 2026-10-23 | minor | #8940: Filter Gallery Halftone Pattern maps size, contrast and dot/circle/line both ways with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`); PSD/PSB round trips at 8/16/32 bits, malformed descriptors preserved verbatim. Other gallery effects remain partial; no floor or estimate changed. | | 2026-10-22 | minor | G17: Help › Check for Updates and an opt-out check once per launch (#5485), DIST-220-1 partial; estimate unchanged | +| 2026-10-19 | minor | G5: Free Transform of several selected layers (#6501): one box moves them all; 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 | G9: adjustment clipping remains visible in compact Properties (#5231); no readiness percentage or estimate changed | | 2026-10-21 | minor | #8304: compound layer properties export as separate Set events, rename last; independent Photoshop replay pending; percentages and estimates unchanged | 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/docs/scorecard.md b/docs/scorecard.md index 076537a3a2..a58474a1c0 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -170,7 +170,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) | 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/docs/target-app-parity.md b/docs/target-app-parity.md index c878ff24b5..25f54a8093 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.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 5 / 5 / 0 (2026-10-22, area type partial), 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 partial; paragraph direction buttons exist but the visual RTL caret is 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 5 / 5 / 0 (2026-10-22, area type partial), 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 partial; paragraph direction buttons exist but the visual RTL caret is 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); 12 of 178 preferences do nothing (scorecard, 2026-10-25; was 27 of 156); 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) | @@ -588,6 +588,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). |---|---|---| | 2026-10-27 | minor | Smooth/Feather command reads and replacement use selection bounds plus blur reach; full-canvas reference and release comparisons extended; percentages and scorecard budgets unchanged | | 2026-10-26 | minor | Expand/Contract/Border distance work, mask reads and replacement cropped to nonzero bounds; bounded distances for radii ≤5; 36 MP exact-output comparisons improve 1.54–37.82× across eight cases; percentages, estimates and scorecard budgets unchanged | +| 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-25 | minor | UI / UX and Workspace rows: 12 of 178 preferences do nothing, the scorecard's count. Camera Raw row: the Calibration panel landed on 2026-10-13 (#5128); what is missing is per-camera colour for non-DNG cameras ([gaps.md](gaps.md) G14). Percentages and estimates unchanged | | 2026-10-23 | minor | Colour management and modes: PSD spot plates are stored as Photoshop stores them (ink 0 = solid), and PSDs from earlier PhotoCraft versions keep their ink (#7415 split, part 4); estimates unchanged | | 2026-10-23 | minor | Colour management and modes: spot plates now save and open in TIFF (#7415); estimates unchanged | diff --git a/docs/ui-design.md b/docs/ui-design.md index 58e07207f1..fa9ebf1a23 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 1157762509..863bae16b8 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-29 · **Change:** minor (selection-aware font controls, pending caret fonts and focused commit, plus direct font-name editing with a compact no-match message, #9338; New Group dialog and button modifiers; new layers go into an open active group and join a clipping mask, except that type, shapes, pastes and placed files go above a group whose lock would refuse them; Color panel Hue and Brightness Cube show the out-of-gamut ⚠ and nearest printable colour when the field is at least 53 pt tall, #8672; 12 of 181 preferences do nothing, from the scorecard; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; 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; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (Free Transform of several selected layers, #6501; selection-aware font controls, pending caret fonts and focused commit, plus direct font-name editing with a compact no-match message, #9338; New Group dialog and button modifiers; new layers go into an open active group and join a clipping mask, except that type, shapes, pastes and placed files go above a group whose lock would refuse them; Color panel Hue and Brightness Cube show the out-of-gamut ⚠ and nearest printable colour when the field is at least 53 pt tall, #8672; 12 of 181 preferences do nothing, from the scorecard; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; 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; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **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 | @@ -99,7 +100,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | 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) | | View › Extras (⌘H) hides the Move tool's transform controls; Show Transform Controls keeps its setting | done | `canvas.rs` `transform_controls_layers`, `hiding_extras_hides_the_move_transform_controls` (#6699) | -| 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` | @@ -263,6 +264,7 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-24 | minor | Properties › Transform shows and edits a shape layer's path geometry (W/H/X/Y), not the pixel bounds its stroke adds, as in Photoshop; W/H scale the path to exactly the typed size. Scores unchanged. | | 2026-10-22 | minor | Crop default mode: a drag inside an upright box snaps the frame's edges and centre to the document, layers, guides and grid, as a Classic Mode frame's move does (#8580); no percentage changed | | 2026-10-21 | minor | Adjustment clipping moved above Properties editors to remain visible in compact docks (#5231); scope, undo/redo and 8/16/32-bit pixel regressions; readiness estimate unchanged | +| 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-19 | minor | A guide being moved or dragged out of a ruler shows an `X:` / `Y:` position readout beside the pointer, from the zero point in the ruler unit (#7680) | | 2026-10-17 | minor | The wheel steps value fields: a focused field takes it wherever the pointer is (the canvas and panels don't scroll), an unfocused one only a gesture that starts over it (#5473, #4061); no percentage changed | | 2026-10-17 | minor | Object Finder foreground/background discrimination (#6548) | 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 diff --git a/scorecard/tools.toml b/scorecard/tools.toml index 9311cc1869..2eb93d4c7e 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 = "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"