mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-12 21:23:02 +00:00
Merge remote-tracking branch 'origin/main' into HEAD
This commit is contained in:
commit
b60b6471d4
84 files changed
+5418
-225
No files matched your search
@@ -253,7 +253,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 +266,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::<Vec<_>>());
|
||||
|
||||
@@ -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<usize>,
|
||||
json: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn options() -> Result<Options, String> {
|
||||
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<Vec<[f32; 4]>> = (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);
|
||||
}
|
||||
}
|
||||
+56
-10
@@ -210,34 +210,80 @@ pub fn local_sharpen(
|
||||
protect_detail: bool,
|
||||
) -> Vec<f32> {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<f32>,
|
||||
}
|
||||
|
||||
impl EntropyTerms {
|
||||
// At most 130 KiB, independent of image size and total layer count. Deeper stacks retain
|
||||
// the original calculation; pixels with fewer contributors can still use a cached row.
|
||||
const MAX_SAMPLES: usize = 256;
|
||||
|
||||
fn new(samples: usize, pixels: usize) -> Self {
|
||||
let mut values = Vec::new();
|
||||
// Tiny images do not amortise preparation, especially with hundreds of layers.
|
||||
if samples < 2 || pixels < 256 {
|
||||
return Self { values };
|
||||
}
|
||||
let max = samples.min(Self::MAX_SAMPLES);
|
||||
// max is capped at 256 before any size arithmetic or allocation.
|
||||
let len = (max + 1) * (max + 2) / 2;
|
||||
if values.try_reserve_exact(len).is_err() {
|
||||
return Self { values };
|
||||
}
|
||||
values.extend_from_slice(&[0.0; 3]); // Unused rows for zero and one sample.
|
||||
for samples in 2..=max {
|
||||
let n = samples as f32;
|
||||
values.push(n.log2());
|
||||
for count in 1..=samples {
|
||||
let p = count as f32 / n;
|
||||
values.push(-p * p.log2());
|
||||
}
|
||||
}
|
||||
Self { values }
|
||||
}
|
||||
|
||||
fn row(&self, samples: usize) -> Option<&[f32]> {
|
||||
if !(2..=Self::MAX_SAMPLES).contains(&samples) {
|
||||
return None;
|
||||
}
|
||||
let start = samples * (samples + 1) / 2;
|
||||
self.values.get(start..start + samples + 1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shannon entropy (bits) of the 8-bit values in `v`, over log2(N). `v.len() >= 2`.
|
||||
fn entropy(v: &[f32], scratch: &mut EntropyScratch) -> f32 {
|
||||
fn entropy(v: &[f32], scratch: &mut EntropyScratch, terms: Option<&[f32]>) -> f32 {
|
||||
let n = v.len() as f32;
|
||||
for x in v {
|
||||
let bin = (x.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
@@ -57,8 +103,11 @@ fn entropy(v: &[f32], scratch: &mut EntropyScratch) -> f32 {
|
||||
.touched
|
||||
.iter()
|
||||
.map(|&bin| {
|
||||
let p = scratch.counts[usize::from(bin)] as f32 / n;
|
||||
-p * p.log2()
|
||||
let count = scratch.counts[usize::from(bin)];
|
||||
terms.and_then(|row| row.get(count as usize)).copied().unwrap_or_else(|| {
|
||||
let p = count as f32 / n;
|
||||
-p * p.log2()
|
||||
})
|
||||
})
|
||||
.sum();
|
||||
// Reset only the bins used, so the 256-entry array is never cleared wholesale.
|
||||
@@ -66,7 +115,7 @@ fn entropy(v: &[f32], scratch: &mut EntropyScratch) -> f32 {
|
||||
scratch.counts[usize::from(bin)] = 0;
|
||||
}
|
||||
scratch.touched.clear();
|
||||
h / n.log2()
|
||||
h / terms.and_then(|row| row.first()).copied().unwrap_or_else(|| n.log2())
|
||||
}
|
||||
|
||||
/// `x` to the power `k` by repeated squaring. Every step is one IEEE 754 multiplication, so the
|
||||
@@ -115,7 +164,7 @@ fn stat(v: &mut [f32], s: Stat, scratch: &mut EntropyScratch) -> f32 {
|
||||
if v.len() < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
return entropy(v, scratch);
|
||||
return entropy(v, scratch, None);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -153,7 +202,11 @@ pub fn combine(layers: &[Vec<[f32; 4]>], s: Stat) -> Vec<[f32; 4]> {
|
||||
// serial loop (Mean, Variance, StdDev, Skewness, Kurtosis); this alone cuts Mean's
|
||||
// instructions by a fifth.
|
||||
match s {
|
||||
Stat::Entropy => combine_with(layers, |v, sc| stat(v, Stat::Entropy, sc)),
|
||||
Stat::Entropy => {
|
||||
let pixels = layers.iter().map(Vec::len).max().unwrap_or(0);
|
||||
let terms = EntropyTerms::new(layers.len(), pixels);
|
||||
combine_with(layers, |v, sc| if v.len() < 2 { 0.0 } else { entropy(v, sc, terms.row(v.len())) })
|
||||
}
|
||||
Stat::Kurtosis => combine_with(layers, |v, sc| stat(v, Stat::Kurtosis, sc)),
|
||||
Stat::Maximum => combine_with(layers, |v, sc| stat(v, Stat::Maximum, sc)),
|
||||
Stat::Mean => combine_with(layers, |v, sc| stat(v, Stat::Mean, sc)),
|
||||
|
||||
@@ -323,6 +323,102 @@ fn entropy_is_deterministic() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The pre-lookup calculation, summing the 8-bit bins in ascending order. This deliberately
|
||||
/// does not call `entropy` or use its lookup/scratch, so it catches changes in rounding too.
|
||||
fn entropy_reference(v: &[f32]) -> f32 {
|
||||
if v.len() < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
let mut counts = [0u32; 256];
|
||||
for x in v {
|
||||
counts[(x.clamp(0.0, 1.0) * 255.0).round() as usize] += 1;
|
||||
}
|
||||
let n = v.len() as f32;
|
||||
let h: f32 = counts
|
||||
.iter()
|
||||
.filter(|&&count| count > 0)
|
||||
.map(|&count| {
|
||||
let p = count as f32 / n;
|
||||
-p * p.log2()
|
||||
})
|
||||
.sum();
|
||||
h / n.log2()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entropy_terms_preserve_each_operation_and_bound_storage() {
|
||||
let terms = EntropyTerms::new(usize::MAX, usize::MAX);
|
||||
let max = EntropyTerms::MAX_SAMPLES;
|
||||
assert_eq!(terms.values.len(), (max + 1) * (max + 2) / 2);
|
||||
assert!(terms.values.len() * size_of::<f32>() < 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<f32> = (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<Vec<[f32; 4]>> = (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]
|
||||
|
||||
@@ -3,4 +3,5 @@
|
||||
mod liquify_freeze_formats;
|
||||
mod liquify_source_footprint;
|
||||
mod retouch_blur_exact;
|
||||
mod retouch_sharpen_exact;
|
||||
mod warp_clipped;
|
||||
@@ -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<f32> = (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<usize>,
|
||||
win: (usize, usize, usize, usize),
|
||||
amount: f32,
|
||||
protect_detail: bool,
|
||||
) -> Vec<f32> {
|
||||
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
|
||||
}
|
||||
@@ -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<dyn std::error::Error>> {
|
||||
// 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::<Vec<_>>(),
|
||||
"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);
|
||||
}
|
||||
}
|
||||
@@ -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<dyn std::error::Error>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -294,6 +294,43 @@ fn record_explicit_transform_keeps_one_replayable_step() {
|
||||
assert_eq!(pixel(&s, 0, 12, 2), vec![0.0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorded_single_layer_transform_replays_after_layer_ids_shift() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 20, "height": 8, "background": "transparent"})).unwrap();
|
||||
let source = s.execute("layer.new.layer", json!({"name": "Target"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.edit("source pixel", |doc, _| {
|
||||
doc.layer_mut(photocraft_doc::LayerId(source)).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 2, 2, 4), &[1.0, 0.0, 0.0, 1.0]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
s.execute("actions.record", json!({"name": "Move target"})).unwrap();
|
||||
s.execute("edit.transform", json!({"layer": source, "dx": 4, "interpolation": "nearest"})).unwrap();
|
||||
s.execute("actions.stop", json!({})).unwrap();
|
||||
let recorded = s.execute("actions.get", json!({"action": "Move target"})).unwrap();
|
||||
assert_eq!(recorded["steps"][0][1], json!({"dx": 4, "interpolation": "nearest"}));
|
||||
|
||||
// Add a layer before the target, shifting its id; playback must resolve the current
|
||||
// selection rather than keep the source document's numeric layer id.
|
||||
s.execute("file.new", json!({"width": 20, "height": 8, "background": "transparent"})).unwrap();
|
||||
s.execute("layer.new.layer", json!({"name": "Inserted"})).unwrap();
|
||||
let target = s.execute("layer.new.layer", json!({"name": "Target"})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert_ne!(source, target);
|
||||
s.edit("target pixel", |doc, _| {
|
||||
doc.layer_mut(photocraft_doc::LayerId(target)).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 2, 2, 4), &[1.0, 0.0, 0.0, 1.0]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let target_index = s.active().unwrap().doc.layers.iter().position(|layer| layer.id.0 == target).unwrap();
|
||||
let before = s.active().unwrap().history.past_len();
|
||||
let played = s.execute("actions.play", json!({"action": "Move target"})).unwrap();
|
||||
assert!(played.get("failed").is_none(), "{played}");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), before + 1);
|
||||
assert_eq!(pixel(&s, target_index, 4, 2), vec![1.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(pixel(&s, target_index, 0, 2), vec![0.0; 4]);
|
||||
}
|
||||
|
||||
/// A Background with a red bar and a slanted ruler line, ready for Straighten.
|
||||
fn ruled_background(s: &mut Session, depth: u32) {
|
||||
s.execute("file.new", json!({"width": 80, "height": 60, "depth": depth})).unwrap();
|
||||
|
||||
@@ -746,9 +746,10 @@ fn toggle_last_state(s: &mut Session) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
/// 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<Value> {
|
||||
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<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `edit.transform` params that repeat the last transform on the active layer.
|
||||
fn transform_again_params(s: &Session) -> Result<Value> {
|
||||
/// 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<Value> {
|
||||
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<Value> {
|
||||
)
|
||||
.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()));
|
||||
}
|
||||
|
||||
@@ -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::<Option<Vec<_>>>() 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<LayerId> {
|
||||
let Some(name) = p.get("name") else {
|
||||
if p.get("occurrence").is_some() {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -125,7 +125,7 @@ fn tone_range(p: &Value, cmd: &str) -> Result<ToneRange> {
|
||||
/// 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<Document>,
|
||||
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<photocraft_paint::Dab>,
|
||||
dual_pending: Vec<photocraft_paint::Dab>,
|
||||
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() {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<LayerId> {
|
||||
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<LayerId>) -> Vec<LayerId> {
|
||||
if selected.len() < 2 {
|
||||
return active.into_iter().collect();
|
||||
}
|
||||
let ids: Vec<LayerId> = 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<Option<Vec<LayerId>>> {
|
||||
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<Value> {
|
||||
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<LayerId> = 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<f64> = 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<Value> {
|
||||
(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<u64> = 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<LayerId>,
|
||||
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<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod multi_tests;
|
||||
@@ -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<f64> = 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::<Vec<u64>>(), "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]));
|
||||
}
|
||||
@@ -80,7 +80,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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
|
||||
),
|
||||
|
||||
@@ -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<u8> {
|
||||
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() {
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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::<Vec<_>>());
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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};
|
||||
@@ -1361,10 +1359,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).
|
||||
|
||||
@@ -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<f32> {
|
||||
pub(crate) fn default_opacity(self) -> Option<f32> {
|
||||
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<LayerId>, params: &Value, k: u32, cancel: Option<&photocraft_engine::jobs::JobCtx>) -> Option<Document> {
|
||||
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<LayerId>,
|
||||
params: &Value,
|
||||
k: u32,
|
||||
cancel: Option<&photocraft_engine::jobs::JobCtx>,
|
||||
) -> Option<Document> {
|
||||
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<LayerId>, 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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -115,8 +115,8 @@ pub(super) fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painte
|
||||
/// Healing Brush and History Brush composite their source.
|
||||
pub(super) enum EngineStroke {
|
||||
Brush(Box<photocraft_engine::brush_cmds::LiveStroke>),
|
||||
Retouch(photocraft_engine::retouch_cmds::LiveRetouch),
|
||||
Dab(photocraft_engine::retouch_cmds::LiveDab),
|
||||
Retouch(Box<photocraft_engine::retouch_cmds::LiveRetouch>),
|
||||
Dab(Box<photocraft_engine::retouch_cmds::LiveDab>),
|
||||
Mixer(Box<photocraft_engine::brush_cmds::mixer_live::LiveMixer>),
|
||||
}
|
||||
|
||||
@@ -304,32 +304,32 @@ pub(super) fn begin_live_stroke(app: &PhotocraftApp) -> Option<LiveStroke> {
|
||||
// The params `retouch_ui::finish_stroke` commits.
|
||||
let mut p = crate::retouch_ui::color_replacement_params(app);
|
||||
p["points"] = json!(d.points);
|
||||
EngineStroke::Dab(photocraft_engine::brush_cmds::live_color_replacement(&app.session, &p).ok()?)
|
||||
EngineStroke::Dab(Box::new(photocraft_engine::brush_cmds::live_color_replacement(&app.session, &p).ok()?))
|
||||
} else if let Some((cmd, mut p)) = crate::retouch_ui::dab_params(app, d.tool) {
|
||||
// The params `retouch_ui::finish_stroke` commits; Sample All Layers isn't previewed.
|
||||
p["points"] = json!(d.points);
|
||||
p["target"] = paint_target(app);
|
||||
EngineStroke::Dab(photocraft_engine::retouch_cmds::LiveDab::begin(&app.session, cmd, &p).ok()?)
|
||||
EngineStroke::Dab(Box::new(photocraft_engine::retouch_cmds::LiveDab::begin(&app.session, cmd, &p).ok()?))
|
||||
} else if d.tool == Tool::PatternStamp {
|
||||
// The params `retouch_ui::finish_stroke` commits.
|
||||
let mut p = crate::retouch_ui::pattern_stamp_params(app, &d.points);
|
||||
p["points"] = json!(d.points);
|
||||
p["target"] = paint_target(app);
|
||||
if app.ui.tool_options.pattern_stamp_impressionist {
|
||||
EngineStroke::Dab(photocraft_engine::retouch_cmds::live_pattern_impressionist(&app.session, &p).ok()?)
|
||||
EngineStroke::Dab(Box::new(photocraft_engine::retouch_cmds::live_pattern_impressionist(&app.session, &p).ok()?))
|
||||
} else {
|
||||
EngineStroke::Retouch(photocraft_engine::retouch_cmds::LiveRetouch::begin(&app.session, "paint.patternStamp", &p).ok()?)
|
||||
EngineStroke::Retouch(Box::new(photocraft_engine::retouch_cmds::LiveRetouch::begin(&app.session, "paint.patternStamp", &p).ok()?))
|
||||
}
|
||||
} else if d.tool == Tool::BackgroundEraser {
|
||||
// The params `eraser_ui::finish_stroke` commits (layer pixels only).
|
||||
let p = crate::eraser_ui::background_eraser_params(app, &d.points);
|
||||
EngineStroke::Dab(photocraft_engine::eraser_cmds::live_background_eraser(&app.session, &p).ok()?)
|
||||
EngineStroke::Dab(Box::new(photocraft_engine::eraser_cmds::live_background_eraser(&app.session, &p).ok()?))
|
||||
} else if let Some(cmd) = live_retouch_command(d.tool) {
|
||||
// The params `retouch_ui::finish_stroke` commits.
|
||||
let mut p = if d.tool == Tool::HistoryBrush { crate::retouch_ui::history_brush_params(app) } else { crate::retouch_ui::clone_params(app)? };
|
||||
p["points"] = json!(d.points);
|
||||
p["target"] = paint_target(app);
|
||||
EngineStroke::Retouch(photocraft_engine::retouch_cmds::LiveRetouch::begin(&app.session, cmd, &p).ok()?)
|
||||
EngineStroke::Retouch(Box::new(photocraft_engine::retouch_cmds::LiveRetouch::begin(&app.session, cmd, &p).ok()?))
|
||||
} else {
|
||||
let p = stroke_params(app, d, &app.stylus.stroke_points(&d.points));
|
||||
EngineStroke::Brush(Box::new(photocraft_engine::brush_cmds::LiveStroke::begin_with(&app.session, stroke_command(app, d.tool), &p).ok()?))
|
||||
@@ -433,12 +433,12 @@ pub(super) fn offer_live_retouch(app: &mut PhotocraftApp, d: &Drag) {
|
||||
match stroke {
|
||||
EngineStroke::Retouch(mut live) if !dab => {
|
||||
if rest.is_empty() || live.push(&rest).is_ok() {
|
||||
let _ = app.session.offer_live_retouch(live);
|
||||
let _ = app.session.offer_live_retouch(*live);
|
||||
}
|
||||
}
|
||||
EngineStroke::Dab(mut live) if dab => {
|
||||
if rest.is_empty() || live.push(&rest).is_ok() {
|
||||
let _ = app.session.offer_live_dab(live);
|
||||
let _ = app.session.offer_live_dab(*live);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Rect> {
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -24,6 +24,8 @@ pub struct Distort {
|
||||
pub liquify: Option<crate::liquify_ui::LiquifyDialog>,
|
||||
/// Edit › Content-Aware Fill… (caf_ui).
|
||||
pub caf: Option<crate::caf_ui::CafWorkspace>,
|
||||
/// Select › Select and Mask… (select_mask_ui).
|
||||
pub select_mask: Option<crate::select_mask_ui::SelectMaskWorkspace>,
|
||||
pub puppet: Option<crate::puppet_ui::PuppetSession>,
|
||||
pub perspective: Option<crate::perspective_ui::PerspSession>,
|
||||
pub vanishing: Option<crate::vp_ui::VpSession>,
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Value> {
|
||||
pub(super) fn variants(p: &Value) -> Vec<Value> {
|
||||
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<Value> {
|
||||
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));
|
||||
@@ -350,6 +351,14 @@ pub(super) fn web_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<S
|
||||
check(ui, f, "optimized", tl!("Optimized"), true);
|
||||
check(ui, f, "embedIcc", tl!("Embed Color Profile"), false);
|
||||
}
|
||||
"webp" => {
|
||||
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 +375,7 @@ pub(super) fn web_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<S
|
||||
check(ui, f, "interlaced", tl!("Interlaced"), false);
|
||||
}
|
||||
}
|
||||
if fmt != "png24" || !b(f, "transparency", true) {
|
||||
if !matches!(fmt.as_str(), "png24" | "webp") || !b(f, "transparency", true) {
|
||||
ui.horizontal(|ui| {
|
||||
let matte_label = ui.label(egui::RichText::new(tl!("Matte")).color(t.text_dim));
|
||||
let mut m = s(f, "matte", "#ffffff");
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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<f32>, Option<f32>) {
|
||||
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);
|
||||
}
|
||||
@@ -4199,6 +4199,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
|
||||
@@ -4216,6 +4230,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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3467,6 +3467,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
|
||||
@@ -3484,6 +3499,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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4154,6 +4154,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 Κύβος φωτεινότητας
|
||||
@@ -4170,6 +4184,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 Σφιχτό κοντό
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 1042 and column 18.
|
@@ -4416,6 +4416,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
|
||||
@@ -4432,6 +4446,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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -4496,6 +4496,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é
|
||||
@@ -4512,6 +4526,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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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 शैली
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4368,6 +4368,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
|
||||
@@ -4384,6 +4398,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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3469,6 +3469,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
|
||||
@@ -3485,6 +3500,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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -4571,6 +4571,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à
|
||||
@@ -4587,6 +4601,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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2407 and column 12.
|
@@ -4133,6 +4133,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 明るさキューブ
|
||||
@@ -4149,6 +4164,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 タイト・ショート
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3665,6 +3665,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 밝기 큐브
|
||||
@@ -3682,6 +3697,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 아트 히스토리 브러시 도구
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2954 and column 12.
|
@@ -4214,6 +4214,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
|
||||
@@ -4230,6 +4244,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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -4071,6 +4071,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
|
||||
@@ -4087,6 +4102,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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4384,6 +4384,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
|
||||
@@ -4400,6 +4414,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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4657,6 +4657,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 Куб яркости
|
||||
@@ -4673,6 +4687,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 Плотный короткий
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4513,6 +4513,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
|
||||
@@ -4542,6 +4556,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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4182,6 +4182,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ü
|
||||
@@ -4198,6 +4212,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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -4164,6 +4164,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 Куб яскравості
|
||||
@@ -4180,6 +4195,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 Щільний короткий
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 1410 and column 18.
|
@@ -3947,6 +3947,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
|
||||
@@ -3964,6 +3979,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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3116,6 +3116,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 色相立方体
|
||||
@@ -3141,6 +3156,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 艺术历史画笔工具
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2382 and column 12.
|
@@ -3080,6 +3080,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 亮度立方體
|
||||
@@ -3097,6 +3112,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 藝術歷史畫筆工具
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -552,6 +552,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
|
||||
}
|
||||
|
||||
@@ -559,7 +563,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);
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -405,7 +405,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}))
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<u64>,
|
||||
}
|
||||
|
||||
/// Content-Aware Scale's options bar: Amount, Protect and Protect Skin Tones.
|
||||
|
||||
@@ -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"))]);
|
||||
|
||||
@@ -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<JobId>,
|
||||
/// Content-Aware Scale's live preview (cas_transform.rs).
|
||||
pub(crate) content_aware: Option<crate::cas_transform::Live>,
|
||||
/// A box over several layers (#6501): their composite, which `texture` holds.
|
||||
several: Option<Several>,
|
||||
}
|
||||
|
||||
/// 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<LayerId>,
|
||||
/// 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<LayerId>,
|
||||
}
|
||||
|
||||
/// 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]]]
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Document>, revision: u64, active: LayerId, ids: Vec<LayerId>) -> 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<Color32>, [[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<LayerId> {
|
||||
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)
|
||||
|
||||
@@ -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<bool> {
|
||||
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<bool> {
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -138,6 +138,7 @@ pub(crate) fn frame(app: &mut PhotocraftApp) -> Option<TransformSession> {
|
||||
mode: Default::default(),
|
||||
content_aware: None,
|
||||
skew_v: 0.0,
|
||||
layers: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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) |
|
||||
|
||||
+9
-3
@@ -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.
|
||||
|
||||
@@ -398,6 +402,7 @@ what an agent can't finish alone.
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
@@ -406,6 +411,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 |
|
||||
|
||||
+1
-1
@@ -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 |
|
||||
|
||||
@@ -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) |
|
||||
@@ -581,6 +581,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md).
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ curl -sfL -o assets/icons/<name>.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. |
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
# UI parity: tools, handles, modifiers and feel
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (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-27 · **Change:** minor (Free Transform of several selected layers, #6501; 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` |
|
||||
@@ -262,6 +263,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) |
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user