Merge current main into trimmed integration, preserving exclusions and interpolation behavior

This commit is contained in:
FredApps
2026-10-29 21:31:18 +01:00
49 changed files with 2327 additions and 215 deletions
+8 -8
View File
@@ -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<_>>());
+104
View File
@@ -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);
}
}
+153 -26
View File
@@ -28,28 +28,57 @@ pub(crate) fn reach() -> f32 {
(2 * MAX_LUMA_R + 2 * MAX_CHROMA_R + 6) as f32
}
/// Guided filter of `p` with guide `g` (both `w × h`), box radius `r`.
fn guided(g: &[f32], p: &[f32], w: usize, h: usize, r: usize, eps: f32) -> Vec<f32> {
let len = w * h;
// Interleave the four box-filtered moments to run one pass.
let mut m = vec![0.0f32; len * 4];
for i in 0..len {
m[i * 4] = g[i];
m[i * 4 + 1] = p[i];
m[i * 4 + 2] = g[i] * g[i];
m[i * 4 + 3] = g[i] * p[i];
/// Shared first and second moments of a guide (two filtered channels).
fn guide_moments(g: &[f32], w: usize, h: usize, r: usize) -> Vec<f32> {
let mut moments = Vec::with_capacity(g.len() * 2);
for &v in g {
moments.extend_from_slice(&[v, v * v]);
}
box_blur_n(&mut m, w, h, 4, r);
let mut ab = vec![0.0f32; len * 2];
for i in 0..len {
let (mg, mp, mgg, mgp) = (m[i * 4], m[i * 4 + 1], m[i * 4 + 2], m[i * 4 + 3]);
let var = (mgg - mg * mg).max(0.0);
let a = (mgp - mg * mp) / (var + eps);
ab[i * 2] = a;
ab[i * 2 + 1] = mp - a * mg;
box_blur_n(&mut moments, w, h, 2, r);
moments
}
/// Self-guided luminance: mean(p) = mean(g), mean(g*p) = mean(g*g).
fn guided_luma(g: &[f32], w: usize, h: usize, r: usize, eps: f32) -> Vec<f32> {
let mut ab = guide_moments(g, w, h, r);
for pair in ab.as_chunks_mut::<2>().0.iter_mut() {
let mean = pair[0];
let covariance = pair[1] - mean * mean;
// Keep the original unclamped numerator, including roundoff below zero.
let a = covariance / (covariance.max(0.0) + eps);
pair[0] = a;
pair[1] = mean - a * mean;
}
box_blur_n(&mut ab, w, h, 2, r);
(0..len).map(|i| ab[i * 2] * g[i] + ab[i * 2 + 1]).collect()
g.iter().zip(ab.as_chunks::<2>().0.iter()).map(|(&v, pair)| pair[0] * v + pair[1]).collect()
}
/// Chroma planes share the cleaned guide's mean and regularised variance.
/// Two guide channels plus two moments and two coefficients per chroma plane:
/// ten filtered channels, versus twelve for two independent guided filters.
fn guided_chroma(g: &[f32], planes: [&mut [f32]; 2], w: usize, h: usize, r: usize, eps: f32, amount: f32) {
let mut stats = guide_moments(g, w, h, r);
for pair in stats.as_chunks_mut::<2>().0.iter_mut() {
pair[1] = (pair[1] - pair[0] * pair[0]).max(0.0) + eps;
}
for plane in planes {
let mut ab = Vec::with_capacity(g.len() * 2);
for (&guide, &p) in g.iter().zip(plane.iter()) {
ab.extend_from_slice(&[p, guide * p]);
}
box_blur_n(&mut ab, w, h, 2, r);
for (pair, guide) in ab.as_chunks_mut::<2>().0.iter_mut().zip(stats.as_chunks::<2>().0.iter()) {
let mean = pair[0];
let a = (pair[1] - guide[0] * mean) / guide[1];
pair[0] = a;
pair[1] = mean - a * guide[0];
}
box_blur_n(&mut ab, w, h, 2, r);
for ((v, &guide), pair) in plane.iter_mut().zip(g).zip(ab.as_chunks::<2>().0.iter()) {
let filtered = pair[0] * guide + pair[1];
*v += (filtered - *v) * amount;
}
}
}
pub(crate) fn reduce_noise(src: &Image, out: Rect, ctx: &Ctx, spec: &DenoiseSpec) -> Vec<f32> {
@@ -125,7 +154,7 @@ pub(crate) fn reduce_noise(src: &Image, out: Rect, ctx: &Ctx, spec: &DenoiseSpec
let r = (1.0 + spec.strength.clamp(0.0, 10.0) * 0.4).round() as usize;
let keep = spec.preserve.clamp(0.0, 100.0) / 100.0;
let eps = (0.004 + 0.05 * s).powi(2) * (1.05 - keep);
let d = guided(&yv, &yv, ww, wh, r.min(MAX_LUMA_R), eps);
let d = guided_luma(&yv, ww, wh, r.min(MAX_LUMA_R), eps);
let k = s.sqrt();
for (v, dv) in yv.iter_mut().zip(d) {
*v += (dv - *v) * k;
@@ -135,12 +164,7 @@ pub(crate) fn reduce_noise(src: &Image, out: Rect, ctx: &Ctx, spec: &DenoiseSpec
if col > 0.0 && model != Model::Gray {
let r = (1.0 + col * 8.0).round() as usize;
let eps = (0.01 + 0.05 * col).powi(2);
for plane in [&mut c1, &mut c2] {
let d = guided(&yv, plane, ww, wh, r.min(MAX_CHROMA_R), eps);
for (v, dv) in plane.iter_mut().zip(d) {
*v += (dv - *v) * col;
}
}
guided_chroma(&yv, [&mut c1, &mut c2], ww, wh, r.min(MAX_CHROMA_R), eps, col);
}
// Sharpen Details: unsharp mask on luma.
if sh > 0.0 {
@@ -178,3 +202,106 @@ pub(crate) fn reduce_noise(src: &Image, out: Rect, ctx: &Ctx, spec: &DenoiseSpec
}
res
}
#[cfg(test)]
mod tests {
use super::*;
/// Guided filter of `p` with guide `g` (both `w × h`), box radius `r`.
fn reference_guided(g: &[f32], p: &[f32], w: usize, h: usize, r: usize, eps: f32) -> Vec<f32> {
let len = w * h;
// Interleave the four box-filtered moments to run one pass.
let mut m = vec![0.0f32; len * 4];
for i in 0..len {
m[i * 4] = g[i];
m[i * 4 + 1] = p[i];
m[i * 4 + 2] = g[i] * g[i];
m[i * 4 + 3] = g[i] * p[i];
}
box_blur_n(&mut m, w, h, 4, r);
let mut ab = vec![0.0f32; len * 2];
for i in 0..len {
let (mg, mp, mgg, mgp) = (m[i * 4], m[i * 4 + 1], m[i * 4 + 2], m[i * 4 + 3]);
let var = (mgg - mg * mg).max(0.0);
let a = (mgp - mg * mp) / (var + eps);
ab[i * 2] = a;
ab[i * 2 + 1] = mp - a * mg;
}
box_blur_n(&mut ab, w, h, 2, r);
(0..len).map(|i| ab[i * 2] * g[i] + ab[i * 2 + 1]).collect()
}
#[test]
fn shared_statistics_match_independent_filters_bit_for_bit() {
for (w, h) in [(1, 1), (1, 19), (23, 1), (7, 5), (33, 17)] {
let len = w * h;
for flat in [false, true] {
let g: Vec<f32> = (0..len).map(|i| if flat { 0.1 } else { ((i * 37 % 101) as f32 - 20.0) / 63.0 }).collect();
let p: Vec<f32> = (0..len).map(|i| (i * 13 % 71) as f32 / 70.0 - 0.5).collect();
let q: Vec<f32> = (0..len).map(|i| (i * 29 % 83) as f32 / 41.0 - 1.0).collect();
for r in [0, 1, MAX_LUMA_R, MAX_CHROMA_R, 25] {
for eps in [0.000001, 0.0036] {
let expected = reference_guided(&g, &g, w, h, r, eps);
let actual = guided_luma(&g, w, h, r, eps);
assert_bits(&actual, &expected);
for amount in [0.01, 0.45, 1.0] {
let mut actual_p = p.clone();
let mut actual_q = q.clone();
guided_chroma(&g, [&mut actual_p, &mut actual_q], w, h, r, eps, amount);
for (original, actual) in [(&p, &actual_p), (&q, &actual_q)] {
let filtered = reference_guided(&g, original, w, h, r, eps);
let expected: Vec<f32> = original.iter().zip(filtered).map(|(&v, d)| v + (d - v) * amount).collect();
assert_bits(actual, &expected);
}
}
}
}
}
}
}
#[test]
fn reduce_noise_guides_both_chroma_planes_by_the_cleaned_luma() {
// Caller wiring: Lab keeps its planes as they are, so each output plane is compared with
// the independent filters run on the planes in order (luma by itself, chroma by the
// cleaned luma).
let (w, h) = (19, 13);
let rect = Rect::new(0, 0, w as i32, h as i32);
let mut src = Image::new(rect, 3);
for (i, px) in src.data.as_chunks_mut::<3>().0.iter_mut().enumerate() {
px[0] = (i * 37 % 101) as f32 / 100.0;
px[1] = (i * 13 % 71) as f32 / 70.0 - 0.5;
px[2] = (i * 29 % 83) as f32 / 41.0 - 1.0;
}
let ctx = Ctx { bounds: rect, mode: ColorMode::Lab, alpha: false };
let spec = DenoiseSpec { strength: 6.0, preserve: 30.0, color: 50.0, sharpen: 0.0, jpeg: false };
let out = reduce_noise(&src, rect, &ctx, &spec);
let plane = |c: usize| -> Vec<f32> { src.data.as_chunks::<3>().0.iter().map(|px| px[c]).collect() };
let (luma, chroma) = (plane(0), [plane(1), plane(2)]);
let s = spec.strength / 10.0;
let eps = (0.004 + 0.05 * s).powi(2) * (1.05 - spec.preserve / 100.0);
let smooth = reference_guided(&luma, &luma, w, h, (1.0 + spec.strength * 0.4).round() as usize, eps);
let cleaned: Vec<f32> = luma.iter().zip(&smooth).map(|(&v, &d)| v + (d - v) * s.sqrt()).collect();
let col = spec.color / 100.0;
let chroma_r = (1.0 + col * 8.0).round() as usize;
let chroma_eps = (0.01 + 0.05 * col).powi(2);
let mut expected = vec![cleaned];
for p in &chroma {
let filtered = reference_guided(&expected[0], p, w, h, chroma_r, chroma_eps);
expected.push(p.iter().zip(filtered).map(|(&v, f)| v + (f - v) * col).collect());
}
for (c, expected) in expected.iter().enumerate() {
let actual: Vec<f32> = out.as_chunks::<3>().0.iter().map(|px| px[c]).collect();
assert_bits(&actual, expected);
assert!(actual.iter().zip(&plane(c)).any(|(a, o)| (a - o).abs() > 1e-3), "plane {c} was filtered");
}
}
fn assert_bits(actual: &[f32], expected: &[f32]) {
assert_eq!(actual.len(), expected.len());
for (i, (actual, expected)) in actual.iter().zip(expected).enumerate() {
assert_eq!(actual.to_bits(), expected.to_bits(), "pixel {i}: {actual} != {expected}");
}
}
}
+69 -10
View File
@@ -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
@@ -99,17 +148,23 @@ fn stat(v: &mut [f32], s: Stat, scratch: &mut EntropyScratch) -> f32 {
Stat::Maximum => return v.iter().copied().fold(f32::MIN, f32::max),
Stat::Range => return v.iter().copied().fold(f32::MIN, f32::max) - v.iter().copied().fold(f32::MAX, f32::min),
Stat::Median => {
// `total_cmp` is a total order whose ties are bit-identical values, so an unstable
// sort gives exactly the stable sort's result.
v.sort_unstable_by(f32::total_cmp);
let k = v.len();
return if k % 2 == 1 { v[k / 2] } else { (v[k / 2 - 1] + v[k / 2]) / 2.0 };
// Nonempty above, so k / 2 is in bounds. Selection puts the upper middle value in
// place in O(k); the lower partition need not be sorted. Use the same total order
// for its maximum to preserve signed zeros, infinities and NaNs.
let (lower, middle, _) = v.select_nth_unstable_by(k / 2, f32::total_cmp);
return if k % 2 == 1 {
*middle
} else {
// For even k >= 2 the lower partition contains the other middle value.
lower.iter().max_by(|a, b| a.total_cmp(b)).map_or(*middle, |lo| (*lo + *middle) / 2.0)
};
}
Stat::Entropy => {
if v.len() < 2 {
return 0.0;
}
return entropy(v, scratch);
return entropy(v, scratch, None);
}
_ => {}
}
@@ -147,7 +202,11 @@ pub fn combine(layers: &[Vec<[f32; 4]>], s: Stat) -> Vec<[f32; 4]> {
// serial loop (Mean, Variance, StdDev, Skewness, Kurtosis); this alone cuts Mean's
// instructions by a fifth.
match s {
Stat::Entropy => combine_with(layers, |v, sc| stat(v, Stat::Entropy, sc)),
Stat::Entropy => {
let pixels = layers.iter().map(Vec::len).max().unwrap_or(0);
let terms = EntropyTerms::new(layers.len(), pixels);
combine_with(layers, |v, sc| if v.len() < 2 { 0.0 } else { entropy(v, sc, terms.row(v.len())) })
}
Stat::Kurtosis => combine_with(layers, |v, sc| stat(v, Stat::Kurtosis, sc)),
Stat::Maximum => combine_with(layers, |v, sc| stat(v, Stat::Maximum, sc)),
Stat::Mean => combine_with(layers, |v, sc| stat(v, Stat::Mean, sc)),
+176
View File
@@ -60,6 +60,86 @@ fn median_removes_outlier() {
assert_eq!(combine(&layers, Stat::Median)[0][0], 0.5);
}
fn sorted_median(v: &mut [f32]) -> f32 {
v.sort_unstable_by(f32::total_cmp);
let k = v.len();
if k == 0 {
0.0
} else if k % 2 == 1 {
v[k / 2]
} else {
(v[k / 2 - 1] + v[k / 2]) / 2.0
}
}
fn assert_median_matches_sort(mut values: Vec<f32>) {
let expected = sorted_median(&mut values.clone());
let actual = stat(&mut values, Stat::Median, &mut EntropyScratch::new());
if expected.is_nan() {
// IEEE arithmetic does not specify which NaN payload an even median propagates.
assert!(actual.is_nan());
} else {
assert_eq!(actual.to_bits(), expected.to_bits(), "{values:?}");
}
}
#[test]
fn median_selection_matches_sort_for_varied_stack_sizes_and_orders() {
for len in 0..=257 {
let ascending: Vec<f32> = (0..len).map(|i| i as f32 / 16.0 - 8.0).collect();
assert_median_matches_sort(ascending.clone());
assert_median_matches_sort(ascending.into_iter().rev().collect());
assert_median_matches_sort(vec![0.25; len]);
assert_median_matches_sort((0..len).map(|i| ((i * 31) % 7) as f32 - 3.0).collect());
// Deterministic shuffled samples, including float bit patterns outside 0..=1.
let mut seed = 0x9e37_79b9u32;
assert_median_matches_sort(
(0..len)
.map(|_| {
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
f32::from_bits(seed)
})
.collect(),
);
}
}
#[test]
fn median_selection_preserves_total_float_order() {
let special = [f32::from_bits(0xffc0_0001), f32::NEG_INFINITY, -f32::MAX, -1.0, -0.0, 0.0, 1.0, f32::MAX, f32::INFINITY, f32::from_bits(0x7fc0_0001)];
for &a in &special {
// An odd median returns the selected NaN's bits without doing arithmetic.
assert_eq!(stat(&mut [a], Stat::Median, &mut EntropyScratch::new()).to_bits(), a.to_bits());
for &b in &special {
assert_median_matches_sort(vec![a, b]);
for &c in &special {
let mut values = [a, b, c];
let expected = sorted_median(&mut values.clone());
assert_eq!(stat(&mut values, Stat::Median, &mut EntropyScratch::new()).to_bits(), expected.to_bits());
}
}
}
}
#[test]
fn deep_median_stack_uses_only_visible_samples_per_channel() {
for count in [32, 33, 128, 129] {
let layers: Vec<Vec<[f32; 4]>> = (0..count)
.map(|i| {
let pixel = [((i * 31) % 137) as f32, -(i as f32), (i % 5) as f32, if i % 3 == 0 { 0.0 } else { 0.5 }];
if i % 7 == 0 { vec![pixel] } else { vec![pixel, [0.0; 4]] }
})
.collect();
let actual = combine(&layers, Stat::Median);
for channel in 0..3 {
let mut samples: Vec<f32> = layers.iter().filter(|l| l[0][3] > 0.0).map(|l| l[0][channel]).collect();
assert_eq!(actual[0][channel].to_bits(), sorted_median(&mut samples).to_bits());
}
assert_eq!(actual[0][3], 0.5);
assert_eq!(actual[1], [0.0; 4]);
}
}
/// Five layers over six pixels (the last layer is short). Covers opaque, partly transparent,
/// fully transparent, HDR / negative, single-sample and tied-value pixels.
fn fixture() -> Vec<Vec<[f32; 4]>> {
@@ -243,6 +323,102 @@ fn entropy_is_deterministic() {
}
}
/// The pre-lookup calculation, summing the 8-bit bins in ascending order. This deliberately
/// does not call `entropy` or use its lookup/scratch, so it catches changes in rounding too.
fn entropy_reference(v: &[f32]) -> f32 {
if v.len() < 2 {
return 0.0;
}
let mut counts = [0u32; 256];
for x in v {
counts[(x.clamp(0.0, 1.0) * 255.0).round() as usize] += 1;
}
let n = v.len() as f32;
let h: f32 = counts
.iter()
.filter(|&&count| count > 0)
.map(|&count| {
let p = count as f32 / n;
-p * p.log2()
})
.sum();
h / n.log2()
}
#[test]
fn entropy_terms_preserve_each_operation_and_bound_storage() {
let terms = EntropyTerms::new(usize::MAX, usize::MAX);
let max = EntropyTerms::MAX_SAMPLES;
assert_eq!(terms.values.len(), (max + 1) * (max + 2) / 2);
assert!(terms.values.len() * size_of::<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]
+119
View File
@@ -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);
}
}
+13 -4
View File
@@ -408,9 +408,18 @@ fn stop(s: &mut Session, _p: &Value) -> Result<Value> {
}
/// A step Photoshop plays as a silent no-op although its menu item is greyed: Deselect with an
/// open document and nothing selected (actions often start with it to clear any selection, #6744).
fn noop_step(s: &Session, id: &str) -> bool {
id == "select.deselect" && s.active().is_some_and(|d| d.doc.selection.is_none())
/// open document and nothing selected (actions often start with it to clear any selection, #6744),
/// and an Arrange that can't move the layer, such as Bring to Front on a new adjustment layer that
/// is already on top (#8453).
fn noop_step(s: &Session, id: &str, params: &Value) -> bool {
let Some(d) = s.active() else { return false };
let dir = match id {
"select.deselect" => return d.doc.selection.is_none(),
"layer.arrange.bringForward" | "layer.arrange.bringToFront" => 1,
"layer.arrange.sendBackward" | "layer.arrange.sendToBack" => -1,
_ => return false,
};
crate::commands::layer_param(s, params).is_ok_and(|layer| super::ops::arrange_at_end(&d.doc, layer, dir))
}
pub const SAVE_CONTENTS: &str = "layer.smartObjects.saveContents";
@@ -432,7 +441,7 @@ fn run_recorded(s: &mut Session, action: &Action, from: usize) -> (u64, Option<V
failed = Some(json!({"step": i, "id": id, "error": e.to_string()}));
break;
}
if noop_step(s, id) {
if noop_step(s, id, params) {
ran += 1;
continue;
}
@@ -339,6 +339,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();
@@ -1085,3 +1122,48 @@ fn button_color_is_one_of_the_photoshop_swatches() {
}
assert!(s.actions.list[0].color.is_empty(), "rejected colours change nothing");
}
#[test]
fn arrange_that_cannot_move_is_a_noop_in_playback() {
// #8453: imported actions make an adjustment layer, then Bring it to Front, where it already is.
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
let steps = vec![
("layer.newAdjustmentLayer.curves".to_string(), json!({})),
("layer.arrange.bringToFront".to_string(), json!({})),
("layer.new.layer".to_string(), json!({"name": "After"})),
];
s.actions.list.push(Action::new("Repro", steps));
let before = s.active().unwrap().history.past_len();
let played = s.execute("actions.play", json!({"action": 0})).unwrap();
assert_eq!(played["ran"], 3, "{played}");
assert!(played.get("failed").is_none(), "{played}");
assert_eq!(s.active().unwrap().history.past_len(), before + 2, "the no-op adds no history step");
// The predicate agrees with the command for every layer: top level, above the Background,
// and inside a group.
let a = s.execute("layer.new.layer", json!({"name": "A"})).unwrap()["layer"].clone();
s.execute("layer.select", json!({"layer": a})).unwrap();
s.execute("layer.groupLayers", json!({})).unwrap();
s.execute("layer.new.layer", json!({"name": "B"})).unwrap();
let cmds = [("layer.arrange.bringForward", 1), ("layer.arrange.bringToFront", 1), ("layer.arrange.sendBackward", -1), ("layer.arrange.sendToBack", -1)];
let ids: Vec<_> = s.active().unwrap().doc.walk().into_iter().map(|(_, _, l)| l.id).collect();
let bg = crate::background_cmds::background_id(&s.active().unwrap().doc);
for id in ids.into_iter().filter(|id| Some(*id) != bg) {
for (cmd, dir) in cmds {
let at_end = super::super::ops::arrange_at_end(&s.active().unwrap().doc, id, dir);
let ran = s.execute(cmd, json!({"layer": id.0}));
assert_eq!(at_end, ran.is_err(), "{cmd} on {id:?}: {ran:?}");
if ran.is_ok() {
s.execute("edit.undo", json!({})).unwrap();
}
}
}
// Arranging the Background still stops playback: Photoshop refuses it too.
let bg = bg.unwrap();
s.actions.list.push(Action::new("Bg", vec![("layer.arrange.bringToFront".to_string(), json!({"layer": bg.0}))]));
let played = s.execute("actions.play", json!({"action": 1})).unwrap();
assert_eq!(played["ran"], 0, "{played}");
assert!(played["failed"].is_object(), "{played}");
}
+9 -7
View File
@@ -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() {
+22
View File
@@ -6,6 +6,28 @@ use super::{Document, EngineError, Layer, LayerContent, LayerId, LayerMask, Resu
/// (a Photoshop action plays it without complaint).
const ALREADY_THERE: &str = "the layer is already at the front or back";
/// Is layer `id` already as far as Arrange can take it in direction `dir` (up when positive)? True
/// at the top or bottom of its stack, or just above the Background, which stays at the bottom. The
/// Background itself is not: arranging it is refused outright.
pub(crate) fn arrange_at_end(doc: &Document, id: LayerId, dir: i32) -> bool {
let Some(path) = doc.path_of(id) else { return false };
let Some((&idx, parent)) = path.split_last() else { return false };
let len = if parent.is_empty() {
doc.layers.len()
} else {
match doc.layer_at(parent).and_then(Layer::children) {
Some(children) => children.len(),
None => return false,
}
};
let bg = if parent.is_empty() { crate::background_cmds::background_id(doc) } else { None };
if bg == Some(id) {
return false;
}
let floor = usize::from(bg.is_some());
if dir > 0 { idx.saturating_add(1) >= len } else { idx <= floor }
}
pub(super) fn arrange(s: &mut Session, p: &Value, delta: i32) -> Result<Value> {
let id = layer_param(s, p)?;
let edited = s.edit("Arrange", |doc, _| {
+2
View File
@@ -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)]
+33 -18
View File
@@ -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, &region.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");
}
}
}
+110 -22
View File
@@ -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]));
}
+1 -1
View File
@@ -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
),
+47 -11
View File
@@ -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() {
+2 -1
View File
@@ -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)?;
+21 -5
View File
@@ -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<_>>());
}
+133 -2
View File
@@ -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, &params, &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");
+3 -8
View File
@@ -118,10 +118,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};
@@ -1364,10 +1361,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).
+5 -3
View File
@@ -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
}
+10 -10
View File
@@ -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);
}
}
_ => {}
@@ -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]
+23 -1
View File
@@ -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
+13 -4
View File
@@ -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));
@@ -349,6 +350,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,
@@ -365,7 +374,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");
+16 -2
View File
@@ -546,6 +546,22 @@
Decontaminate إزالة التلوث اللوني
Output الإخراج
Refine Edge تحسين الحافة
Clear Selection مسح التحديد
Color Aware مراعٍ للألوان
Decontaminate Colors إزالة تلوث الألوان
Edge Detection اكتشاف الحواف
Global Refinements تحسينات عامة
High Quality Preview معاينة عالية الجودة
Object Aware مراعٍ للكائنات
Object Aware needs a segmentation model, which isn't available yet يتطلب «مراعٍ للكائنات» نموذج تجزئة، وهو غير متوفر بعد
Real-Time Refinement تحسين في الوقت الفعلي
Refine Edge Brush Tool أداة فرشاة تحسين الحافة
Refine Hair تحسين الشعر
Refine Hair needs hair detection, which isn't available yet يتطلب «تحسين الشعر» اكتشاف الشعر، وهو غير متوفر بعد
Refine Mode وضع التحسين
Select and Mask was closed: another layer was selected تم إغلاق «تحديد وقناع»: حُدِّدت طبقة أخرى
Select and Mask was closed: its document is no longer the active one تم إغلاق «تحديد وقناع»: لم يعد مستنده هو المستند النشط
View Mode وضع العرض
Modify تعديل
Border… حدود التحديد…
Smooth… تنعيم…
@@ -3536,8 +3552,6 @@ curves Smooth تنعيم
Y Y
# Select and Mask target ownership diagnostics (#8896 integration)
Select and Mask was closed: its document is no longer the active one أُغلقت مساحة تحديد وقناع: لم يعد مستندها هو المستند النشط
Select and Mask was closed: another layer was selected أُغلقت مساحة تحديد وقناع: تم تحديد طبقة أخرى
The path was kept as the Work Path تم الاحتفاظ بالمسار كمسار عمل
The path was discarded تم تجاهل المسار
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -758,6 +758,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)]
+17
View File
@@ -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
+6
View File
@@ -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"))]);
+89 -7
View File
@@ -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, default_interpolation, 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,6 +105,7 @@ 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(())
@@ -112,6 +121,129 @@ pub(super) fn interpolation_preference(interpolation: &str) -> Option<photocraft
}
}
/// 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: default_interpolation(app).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> {
@@ -179,7 +311,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,
@@ -192,6 +324,7 @@ pub(crate) fn begin_content_aware(
path: None,
content_aware: Some(live),
styled: None,
several: None,
});
app.ui.transform = Some(TransformSession {
session,
@@ -208,6 +341,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(())
@@ -318,6 +452,7 @@ fn begin_lone(
tool: app.ui.tool,
path: None,
content_aware: None,
several: None,
});
app.ui.transform = Some(TransformSession {
session,
@@ -334,6 +469,7 @@ fn begin_lone(
mode: Default::default(),
content_aware: None,
skew_v: 0.0,
layers: Vec::new(),
});
start_steps(app);
Ok(())
@@ -371,6 +507,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,
@@ -387,6 +524,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(())
@@ -456,6 +594,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,
@@ -472,6 +611,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(())
@@ -482,9 +622,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.
@@ -498,14 +645,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(),
}
}
@@ -2184,6 +2185,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));
@@ -3240,3 +3263,20 @@ fn applying_the_box_hides_no_pointer() {
assert_eq!(cursor(&app, &xf, [32.0, 32.0], cmd()), Some(CursorIcon::ResizeNwSe));
assert_eq!(cursor(&app, &xf, [96.0, 32.0], cmd()), Some(CursorIcon::ResizeVertical));
}
#[test]
fn several_layers_reuse_the_transform_interpolation_preference() {
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
let st = app.session.active_mut().unwrap();
let first = st.active_layer.unwrap();
let mut second = photocraft_doc::Layer::raster("second", photocraft_color::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;
Arc::make_mut(&mut st.doc).layers.push(second);
st.selected_layers = vec![first, second_id];
app.ui.tool_options.transform_interpolation = Some(photocraft_engine::prefs::Interpolation::Nearest);
begin(&mut app, &egui::Context::default()).unwrap();
let transform = app.ui.transform.as_ref().unwrap();
assert_eq!(transform.layers.len(), 2);
assert_eq!(transform.interpolation, "nearest");
}
@@ -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:?}");
}
+3 -2
View File
@@ -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) |
+4 -1
View File
@@ -270,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.
@@ -409,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 |
@@ -28,8 +28,12 @@ Removed all 21 requested PR contributions from the existing 108-PR integration,
## Validation and testing
New GitHub CI is required, including full-sweep Linux and Windows tests, corpus, WebAssembly and lint. Until that run completes, this trimmed source is not validated. The previous Windows portable ZIP still contains the removed changes and must not be used for acceptance of this branch. Manual UI checks are NOT RUN.
The trimmed head `8c8c4b95` passed [full-sweep fork CI](https://github.com/FredApps/photocraft/actions/runs/40935778575). The subsequent merge of upstream `8a6771a7` requires a fresh run, including Linux and Windows tests, corpus, WebAssembly and lint. Until that run completes, the reconciled source is not validated. The previous Windows portable ZIP still contains the removed changes and must not be used for acceptance of this branch. Manual UI checks are NOT RUN.
Use the retained 87 cases in [the regression appendix](per-pr-regressions.md). Verify basic editing/save/reopen/Undo/Redo, retained layer-name visibility playback, guide rotation/deletion, pattern persistence, gradient editing and type layout after the removals. Verify the removed menus, preferences and shortcuts are absent or return to base behavior. Custom shape import and native layer-identity rejection are excluded; do not mark their old tests as passing.
The previous build records and approval snapshot are retained only as historical provenance. The frozen retained head list is [here](trimmed-selection-2026-10-29.json).
## Upstream reconciliation
Combined the retained Arrange end-of-stack behavior with upstream action playback no-op detection. Combined multi-layer Free Transform with the retained interpolation preference and added a regression for the combined behavior. Both Path Operations and multi-layer transform documentation are retained. The same 21 exclusions remain; no excluded feature was deliberately reintroduced.
+1 -1
View File
@@ -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
View File
@@ -1,6 +1,6 @@
# UI parity: tools, handles, modifiers and feel
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (Path Operations fixes: the first component keeps combining, a hidden component selection is left alone, a dragged live shape stays live, a refused Pen draft ends once; Pen, Freeform Pen, Curvature Pen and Path Selection Path Operations menu, which changes selected components, `+` / `-` keys, #1397; the Pen carries on an open shape or vector mask path; 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; Path Operations fixes: the first component keeps combining, a hidden component selection is left alone, a dragged live shape stays live, a refused Pen draft ends once; Pen, Freeform Pen, Curvature Pen and Path Selection Path Operations menu, which changes selected components, `+` / `-` keys, #1397; the Pen carries on an open shape or vector mask path; 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` |
@@ -266,6 +267,7 @@ Paragraph; the rest open as in-app floating windows.
| 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-19 | minor | Pen, Freeform Pen and Curvature Pen Path Operations menu (#1397): the operation a new path is added with, and Merge Shape Components for paths (`path.mergeShapeComponents`); row stays partial until the Shape tools and Path Selection have it; no percentage changed |
| 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) |