Fidelity, GPU fallbacks, interactive performance; print spool-file fix
PSD oracle 111 -> 113 (tent effect blurs, bevel height/shading, glow range, spread rounding, text gamma, gradient span, Lab blending, colour burn/dodge edges), channel restrictions (brst), bevel contour/texture, chisel techniques. Vector masks, pass-through clips, artboards, pattern fills and bevel textures render on the GPU (parity <=1/255). PNG export 7x, PSD save 4x, .pcraft save 3.6x faster; CPU effects refresh 11x; deadlock in the effect-map blur fixed. interactive_bench example. Print: unique spool file per call (concurrent prints of same-named documents overwrote each other). Workspace: 2,040 tests pass, clippy clean, layers + wasm OK, parity 600/625. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a9c3efbb51
commit
42266291d5
|
|
@ -98,13 +98,41 @@ fn box_rows(buf: &mut [f32], w: usize, n: usize, r: usize) {
|
|||
}
|
||||
let norm = 1.0 / (2 * r + 1) as f32;
|
||||
let blur_row = |row: &mut [f32]| {
|
||||
let src = row.to_vec();
|
||||
let at = |x: isize, c: usize| src[(x.clamp(0, w as isize - 1) as usize) * n + c];
|
||||
for c in 0..n {
|
||||
let mut acc: f32 = (-(r as isize)..=r as isize).map(|x| at(x, c)).sum();
|
||||
for x in 0..w {
|
||||
row[x * n + c] = acc * norm;
|
||||
acc += at(x as isize + r as isize + 1, c) - at(x as isize - r as isize, c);
|
||||
// The row with clamped edges materialised (r + 1 pixels each side), so the running sum
|
||||
// reads without bounds clamping, all channels per step.
|
||||
let pad = r + 1;
|
||||
let mut src = Vec::with_capacity((w + 2 * pad) * n);
|
||||
for _ in 0..pad {
|
||||
src.extend_from_slice(&row[..n]);
|
||||
}
|
||||
src.extend_from_slice(row);
|
||||
for _ in 0..pad {
|
||||
src.extend_from_slice(&row[(w - 1) * n..w * n]);
|
||||
}
|
||||
let mut acc = [0.0f32; 8];
|
||||
let acc = &mut acc[..n.min(8)];
|
||||
if n > 8 {
|
||||
// Unusual channel counts: per channel.
|
||||
for c in 0..n {
|
||||
let mut a: f32 = (0..=2 * r).map(|i| src[(pad - r + i) * n + c]).sum();
|
||||
for x in 0..w {
|
||||
row[x * n + c] = a * norm;
|
||||
a += src[(x + pad + r + 1) * n + c] - src[(x + pad - r) * n + c];
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
for i in 0..=2 * r {
|
||||
let o = (pad - r + i) * n;
|
||||
for c in 0..n {
|
||||
acc[c] += src[o + c];
|
||||
}
|
||||
}
|
||||
for x in 0..w {
|
||||
let (add, sub) = ((x + pad + r + 1) * n, (x + pad - r) * n);
|
||||
for c in 0..n {
|
||||
row[x * n + c] = acc[c] * norm;
|
||||
acc[c] += src[add + c] - src[sub + c];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -119,17 +147,29 @@ fn box_rows(buf: &mut [f32], w: usize, n: usize, r: usize) {
|
|||
|
||||
fn transpose(buf: &[f32], w: usize, h: usize, n: usize) -> Vec<f32> {
|
||||
let mut out = vec![0.0f32; buf.len()];
|
||||
if w == 0 || h == 0 {
|
||||
return out;
|
||||
}
|
||||
const B: usize = 32; // cache-friendly blocks
|
||||
for by in (0..h).step_by(B) {
|
||||
for bx in (0..w).step_by(B) {
|
||||
// Bands of B output rows (= input columns) are independent: transpose them in parallel.
|
||||
let band = |(i, chunk): (usize, &mut [f32])| {
|
||||
let bx = i * B;
|
||||
for by in (0..h).step_by(B) {
|
||||
for y in by..(by + B).min(h) {
|
||||
for x in bx..(bx + B).min(w) {
|
||||
let (s, d) = ((y * w + x) * n, (x * h + y) * n);
|
||||
out[d..d + n].copy_from_slice(&buf[s..s + n]);
|
||||
let (s, d) = ((y * w + x) * n, ((x - bx) * h + y) * n);
|
||||
chunk[d..d + n].copy_from_slice(&buf[s..s + n]);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
out.par_chunks_mut(B * h * n).enumerate().for_each(band);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
out.chunks_mut(B * h * n).enumerate().for_each(band);
|
||||
out
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -200,6 +200,10 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
|||
writer
|
||||
.write_chunk(png::chunk::IDAT, &idat)
|
||||
.map_err(|e| CodecError::encode(F, e))?;
|
||||
} else if let Some(idat) = parallel_idat(&data, w as usize, h as usize, img.layout().channels() * img.sample_type().bytes(), opts.png_compression) {
|
||||
writer
|
||||
.write_chunk(png::chunk::IDAT, &idat)
|
||||
.map_err(|e| CodecError::encode(F, e))?;
|
||||
} else {
|
||||
writer
|
||||
.write_image_data(&data)
|
||||
|
|
@ -256,6 +260,165 @@ fn adam7_idat(
|
|||
z.finish().map_err(|e| CodecError::encode(F, e))
|
||||
}
|
||||
|
||||
/// Images at least this large (bytes) are filtered and deflated on all cores.
|
||||
const PARALLEL_MIN_BYTES: usize = 4 << 20;
|
||||
|
||||
/// The zlib stream of a non-interlaced image, built in parallel (`None` below
|
||||
/// [`PARALLEL_MIN_BYTES`], on wasm, or without compression): rows get the adaptive filter
|
||||
/// (minimum sum of absolute differences over the five PNG filters, as libpng and the `png`
|
||||
/// crate do), then bands of rows are deflated independently at the requested level, each
|
||||
/// ending on a sync flush so the raw deflate streams concatenate into one (as `pigz` does).
|
||||
/// A 36 MP RGB export drops from ~9 s to well under a second on 12 cores; the output is a few
|
||||
/// percent larger (no dictionary across bands).
|
||||
fn parallel_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompression) -> Option<Vec<u8>> {
|
||||
if cfg!(target_arch = "wasm32") || data.len() < PARALLEL_MIN_BYTES || level == PngCompression::None || w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
let threads = std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, 32);
|
||||
if threads < 2 {
|
||||
return None;
|
||||
}
|
||||
let stride = w * bpp;
|
||||
let band_rows = h.div_ceil(threads * 4).max(1);
|
||||
let bands: Vec<(usize, usize)> = (0..h).step_by(band_rows).map(|y| (y, (y + band_rows).min(h))).collect();
|
||||
let lvl = match level {
|
||||
PngCompression::Fast => flate2::Compression::fast(),
|
||||
PngCompression::Best => flate2::Compression::best(),
|
||||
_ => flate2::Compression::default(),
|
||||
};
|
||||
let next = std::sync::atomic::AtomicUsize::new(0);
|
||||
type Band = (usize, Vec<u8>, u32, usize);
|
||||
let mut parts: Vec<Band> = std::thread::scope(|sc| {
|
||||
let workers: Vec<_> = (0..threads.min(bands.len()))
|
||||
.map(|_| {
|
||||
sc.spawn(|| {
|
||||
let mut out: Vec<Band> = Vec::new();
|
||||
loop {
|
||||
let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let Some(&(y0, y1)) = bands.get(i) else { break };
|
||||
let mut filtered = Vec::with_capacity((y1 - y0) * (stride + 1));
|
||||
let mut scratch = vec![0u8; stride];
|
||||
for y in y0..y1 {
|
||||
let row = &data[y * stride..(y + 1) * stride];
|
||||
let prev = (y > 0).then(|| &data[(y - 1) * stride..y * stride]);
|
||||
filter_row(row, prev, bpp, &mut scratch, &mut filtered);
|
||||
}
|
||||
let adler = adler32(1, &filtered);
|
||||
let mut c = flate2::Compress::new(lvl, false);
|
||||
let mut z = Vec::with_capacity(filtered.len() / 2 + 64);
|
||||
let last = y1 == h;
|
||||
let flush = if last { flate2::FlushCompress::Finish } else { flate2::FlushCompress::Sync };
|
||||
loop {
|
||||
let consumed = c.total_in() as usize;
|
||||
if z.capacity() - z.len() < 64 * 1024 {
|
||||
z.reserve(z.capacity().max(64 * 1024));
|
||||
}
|
||||
match c.compress_vec(&filtered[consumed..], &mut z, flush) {
|
||||
Ok(flate2::Status::StreamEnd) => break,
|
||||
Ok(_) if !last && c.total_in() as usize == filtered.len() && z.capacity() > z.len() => break,
|
||||
Ok(_) => {}
|
||||
Err(_) => return Vec::new(),
|
||||
}
|
||||
}
|
||||
out.push((i, z, adler, filtered.len()));
|
||||
}
|
||||
out
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
workers.into_iter().flat_map(|w| w.join().unwrap_or_default()).collect()
|
||||
});
|
||||
if parts.len() != bands.len() {
|
||||
return None;
|
||||
}
|
||||
parts.sort_by_key(|p| p.0);
|
||||
let mut out = Vec::with_capacity(parts.iter().map(|p| p.1.len()).sum::<usize>() + 6);
|
||||
// zlib header: deflate, 32 K window; FLEVEL only advertises the effort.
|
||||
out.extend_from_slice(match level {
|
||||
PngCompression::Fast => &[0x78, 0x01],
|
||||
PngCompression::Best => &[0x78, 0xDA],
|
||||
_ => &[0x78, 0x9C],
|
||||
});
|
||||
let mut adler = 1u32;
|
||||
for (_, z, a, len) in &parts {
|
||||
out.extend_from_slice(z);
|
||||
adler = adler32_combine(adler, *a, *len);
|
||||
}
|
||||
out.extend_from_slice(&adler.to_be_bytes());
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// One scanline with the adaptive filter choice, appended to `out` (type byte + data).
|
||||
fn filter_row(row: &[u8], prev: Option<&[u8]>, bpp: usize, scratch: &mut [u8], out: &mut Vec<u8>) {
|
||||
let n = row.len();
|
||||
let up = |i: usize| prev.map_or(0, |p| p[i]);
|
||||
let left = |i: usize| if i >= bpp { row[i - bpp] } else { 0 };
|
||||
let ul = |i: usize| if i >= bpp { prev.map_or(0, |p| p[i - bpp]) } else { 0 };
|
||||
let paeth = |a: u8, b: u8, c: u8| {
|
||||
let p = i16::from(a) + i16::from(b) - i16::from(c);
|
||||
let (pa, pb, pc) = ((p - i16::from(a)).abs(), (p - i16::from(b)).abs(), (p - i16::from(c)).abs());
|
||||
if pa <= pb && pa <= pc { a } else if pb <= pc { b } else { c }
|
||||
};
|
||||
let cost = |v: &[u8]| v.iter().map(|&b| u64::from((b as i8).unsigned_abs())).sum::<u64>();
|
||||
let mut best = (cost(row), 0u8);
|
||||
for kind in 1u8..=4 {
|
||||
for i in 0..n {
|
||||
let x = row[i];
|
||||
scratch[i] = match kind {
|
||||
1 => x.wrapping_sub(left(i)),
|
||||
2 => x.wrapping_sub(up(i)),
|
||||
3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8),
|
||||
_ => x.wrapping_sub(paeth(left(i), up(i), ul(i))),
|
||||
};
|
||||
}
|
||||
let c = cost(&scratch[..n]);
|
||||
if c < best.0 {
|
||||
best = (c, kind);
|
||||
}
|
||||
}
|
||||
out.push(best.1);
|
||||
if best.1 == 0 {
|
||||
out.extend_from_slice(row);
|
||||
return;
|
||||
}
|
||||
for i in 0..n {
|
||||
let x = row[i];
|
||||
scratch[i] = match best.1 {
|
||||
1 => x.wrapping_sub(left(i)),
|
||||
2 => x.wrapping_sub(up(i)),
|
||||
3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8),
|
||||
_ => x.wrapping_sub(paeth(left(i), up(i), ul(i))),
|
||||
};
|
||||
}
|
||||
out.extend_from_slice(&scratch[..n]);
|
||||
}
|
||||
|
||||
const ADLER_MOD: u32 = 65_521;
|
||||
|
||||
fn adler32(start: u32, data: &[u8]) -> u32 {
|
||||
let (mut a, mut b) = (start & 0xffff, start >> 16);
|
||||
for chunk in data.chunks(5552) {
|
||||
for &x in chunk {
|
||||
a += u32::from(x);
|
||||
b += a;
|
||||
}
|
||||
a %= ADLER_MOD;
|
||||
b %= ADLER_MOD;
|
||||
}
|
||||
(b << 16) | a
|
||||
}
|
||||
|
||||
/// Adler-32 of A ‖ B from adler(A), adler(B) and |B| (zlib's `adler32_combine`).
|
||||
fn adler32_combine(a1: u32, a2: u32, len2: usize) -> u32 {
|
||||
let rem = (len2 % ADLER_MOD as usize) as u64;
|
||||
let m = u64::from(ADLER_MOD);
|
||||
let (s1a, s2a) = (u64::from(a1 & 0xffff), u64::from(a1 >> 16));
|
||||
let (s1b, s2b) = (u64::from(a2 & 0xffff), u64::from(a2 >> 16));
|
||||
let s1 = (s1a + s1b + m - 1) % m;
|
||||
let s2 = (s2a + s2b + (rem * s1a) % m + m - rem) % m;
|
||||
((s2 as u32) << 16) | s1 as u32
|
||||
}
|
||||
|
||||
/// Writes an 8-bit palette PNG (PNG-8): `indices` row-major, `palette` RGB entries (≤ 256),
|
||||
/// optional fully transparent entry (`tRNS`). Used for Indexed Color documents.
|
||||
pub fn encode_indexed(width: u32, height: u32, indices: &[u8], palette: &[[u8; 3]], transparent: Option<u8>) -> Result<Vec<u8>, CodecError> {
|
||||
|
|
@ -302,3 +465,37 @@ mod indexed_tests {
|
|||
assert!(encode_indexed(3, 3, &idx, &pal, None).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod parallel_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn adler_combine_matches_one_pass() {
|
||||
let a: Vec<u8> = (0..100_000u32).map(|i| (i * 7 + i / 13) as u8).collect();
|
||||
let b: Vec<u8> = (0..70_001u32).map(|i| (i * 31) as u8).collect();
|
||||
let whole: Vec<u8> = a.iter().chain(&b).copied().collect();
|
||||
assert_eq!(adler32_combine(adler32(1, &a), adler32(1, &b), b.len()), adler32(1, &whole));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_images_roundtrip_through_the_parallel_encoder() {
|
||||
for (layout, sample, ch, bytes) in [(ChannelLayout::Rgb, SampleType::U8, 3usize, 1usize), (ChannelLayout::Rgba, SampleType::U16, 4, 2)] {
|
||||
let (w, h) = (1531u32, 977u32);
|
||||
let n = w as usize * h as usize * ch * bytes;
|
||||
let px: Vec<u8> = (0..n).map(|i| ((i / 3) as u32).wrapping_mul(2_654_435_761).rotate_left(i as u32 % 7) as u8 / 3 + (i % 97) as u8).collect();
|
||||
let img = Image::from_raw(w, h, layout, sample, px.clone()).unwrap();
|
||||
assert!(n >= PARALLEL_MIN_BYTES || sample == SampleType::U8);
|
||||
for level in [PngCompression::Fast, PngCompression::Default] {
|
||||
let opts = EncodeOptions { png_compression: level, ..Default::default() };
|
||||
let bytes = crate::encode(&img, Format::Png, &opts).unwrap();
|
||||
let back = decode(&bytes, &Limits::default()).unwrap();
|
||||
assert_eq!(back.dimensions(), (w, h));
|
||||
assert_eq!(back.convert(layout, sample).data(), img.data(), "{layout:?} {sample:?} {level:?}");
|
||||
// The oracle decoder agrees.
|
||||
let o = image::load_from_memory_with_format(&bytes, image::ImageFormat::Png).unwrap();
|
||||
assert_eq!((o.width(), o.height()), (w, h));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -385,7 +385,23 @@ impl Image {
|
|||
if layout == self.layout && sample == self.sample {
|
||||
return self.clone();
|
||||
}
|
||||
let data = if layout == self.layout {
|
||||
let data = if sample == self.sample && !self.layout.has_alpha() && layout == self.layout.with_alpha() {
|
||||
// Fast path (opening any opaque file): copy the samples, append an opaque alpha.
|
||||
let bps = sample.bytes();
|
||||
let src = self.layout.channels() * bps;
|
||||
let one: Vec<u8> = match sample {
|
||||
SampleType::U8 => vec![u8::MAX],
|
||||
SampleType::U16 => u16::MAX.to_ne_bytes().to_vec(),
|
||||
SampleType::F16 => f16::ONE.to_bits().to_ne_bytes().to_vec(),
|
||||
SampleType::F32 => 1.0f32.to_ne_bytes().to_vec(),
|
||||
};
|
||||
let mut out = Vec::with_capacity(self.data.len() / src * (src + bps));
|
||||
for px in self.data.chunks_exact(src) {
|
||||
out.extend_from_slice(px);
|
||||
out.extend_from_slice(&one);
|
||||
}
|
||||
out
|
||||
} else if layout == self.layout {
|
||||
quantize(&self.to_normalized(), sample)
|
||||
} else {
|
||||
let src = self.to_normalized();
|
||||
|
|
@ -550,6 +566,20 @@ pub(crate) fn convert_layout(src: &[f32], from: ChannelLayout, to: ChannelLayout
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn adding_alpha_fast_path_matches_the_general_conversion() {
|
||||
for sample in SampleType::ALL {
|
||||
for layout in [ChannelLayout::Gray, ChannelLayout::Rgb, ChannelLayout::Cmyk] {
|
||||
let n = 7 * 3 * layout.channels();
|
||||
let vals: Vec<f32> = (0..n).map(|i| (i as f32 * 0.137) % 1.0).collect();
|
||||
let img = super::Image::from_normalized(7, 3, layout, sample, &vals).unwrap();
|
||||
let fast = img.convert(layout.with_alpha(), sample);
|
||||
let slow = super::quantize(&super::convert_layout(&img.to_normalized(), layout, layout.with_alpha()), sample);
|
||||
assert_eq!(fast.data(), &slow[..], "{layout:?} {sample:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
//! `Surface::content_bounds` with the per-tile scan cached by copy-on-write tile identity, so
|
||||
//! layer bounds cost a hash lookup per tile instead of a pixel scan per render tile (the CPU
|
||||
//! compositor asks for an effect layer's bounds in every tile; the GPU planner every frame).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, Weak};
|
||||
|
||||
use photocraft_geom::{Rect, TILE_SIZE};
|
||||
use photocraft_raster::{Surface, Tile};
|
||||
|
||||
struct Entry {
|
||||
tile: Weak<Tile>,
|
||||
/// Content bounds inside the tile (tile-local), for one default pixel.
|
||||
default: Box<[u8]>,
|
||||
bounds: Option<(u16, u16, u16, u16)>,
|
||||
}
|
||||
|
||||
fn cache() -> &'static Mutex<HashMap<usize, Entry>> {
|
||||
static C: std::sync::OnceLock<Mutex<HashMap<usize, Entry>>> = std::sync::OnceLock::new();
|
||||
C.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
/// Pixels of `t` that differ from `dp` (tile-local half-open bounds).
|
||||
fn scan(t: &Tile, dp: &[u8]) -> Option<(u16, u16, u16, u16)> {
|
||||
let bpp = dp.len();
|
||||
let ts = TILE_SIZE as usize;
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0usize, 0usize);
|
||||
for (y, row) in t.bytes().chunks_exact(ts * bpp).enumerate() {
|
||||
let Some(first) = row.chunks_exact(bpp).position(|px| px != dp) else { continue };
|
||||
let last = ts - 1 - row.chunks_exact(bpp).rev().position(|px| px != dp).unwrap_or(0);
|
||||
x0 = x0.min(first);
|
||||
x1 = x1.max(last + 1);
|
||||
y0 = y0.min(y);
|
||||
y1 = y + 1;
|
||||
}
|
||||
(x0 != usize::MAX).then_some((x0 as u16, y0 as u16, x1 as u16, y1 as u16))
|
||||
}
|
||||
|
||||
/// `Surface::content_bounds`, cached per tile.
|
||||
pub fn content_bounds(s: &Surface) -> Rect {
|
||||
if s.tile_count() == 0 {
|
||||
return Rect::EMPTY;
|
||||
}
|
||||
let fmt = s.format();
|
||||
let mut dp = vec![0u8; fmt.bytes_per_pixel()];
|
||||
photocraft_raster::encode_pixel(&fmt, &s.default_pixel(), &mut dp);
|
||||
let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
|
||||
if c.len() > 1 << 16 {
|
||||
c.retain(|_, e| e.tile.strong_count() > 0);
|
||||
}
|
||||
let mut out = Rect::EMPTY;
|
||||
for (coord, t) in s.tiles() {
|
||||
let key = Arc::as_ptr(t) as usize;
|
||||
let hit = c.get(&key).filter(|e| e.tile.upgrade().is_some_and(|u| Arc::ptr_eq(&u, t)) && *e.default == *dp).map(|e| e.bounds);
|
||||
let b = match hit {
|
||||
Some(b) => b,
|
||||
None => {
|
||||
let b = scan(t, &dp);
|
||||
c.insert(key, Entry { tile: Arc::downgrade(t), default: dp.clone().into_boxed_slice(), bounds: b });
|
||||
b
|
||||
}
|
||||
};
|
||||
if let Some((x0, y0, x1, y1)) = b {
|
||||
let o = coord.rect();
|
||||
let r = Rect::new(o.x0 + x0 as i32, o.y0 + y0 as i32, o.x0 + x1 as i32, o.y0 + y1 as i32);
|
||||
out = if out.is_empty() { r } else { out.union(&r) };
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
|
|
@ -335,7 +335,9 @@ fn dilate(s: &Map, r: f32) -> Map {
|
|||
let d = dist_outside(s);
|
||||
let mut out = s.clone();
|
||||
for (o, d) in out.v.iter_mut().zip(d) {
|
||||
*o = o.max((r + 0.5 - d).clamp(0.0, 1.0));
|
||||
// Partly covered pixels (distance sentinel < 0) grow by `r` from their own coverage, so a
|
||||
// 2 % spread doesn't make every anti-aliased edge pixel opaque (ag-psd drop shadow).
|
||||
*o = if d < 0.0 { (*o + r).min(1.0) } else { o.max((r + 0.5 - d).clamp(0.0, 1.0)) };
|
||||
}
|
||||
out
|
||||
}
|
||||
|
|
@ -422,9 +424,14 @@ pub fn gradient_t(style: GradientStyle, angle: f32, scale: f32, reverse: bool, o
|
|||
// norm (a unit gradient scaled to the bounds); fitted on psd-tools
|
||||
// gradient-styles.psd (cached Photoshop renderings of every style).
|
||||
let len = ((c * w).powi(2) + (s * h).powi(2)).sqrt().max(1.0) * scale.max(1e-3);
|
||||
// Linear / Reflected span the chord of the bounds through their centre along the angle:
|
||||
// min(w / |cos|, h / |sin|). A 45° gradient on a 29 px square runs 41 px (psd-tools
|
||||
// shape-fx2), an 87° one on a 600 × 60 text line 60 px (layer_effects); axis-aligned angles
|
||||
// span the width / height.
|
||||
let chord = (w / c.abs().max(1e-6)).min(h / s.abs().max(1e-6)).max(1.0) * scale.max(1e-3);
|
||||
let mut t = match style {
|
||||
GradientStyle::Linear => along / len + 0.5,
|
||||
GradientStyle::Reflected => (along / (len / 2.0)).abs(),
|
||||
GradientStyle::Linear => along / chord + 0.5,
|
||||
GradientStyle::Reflected => (along / (chord / 2.0)).abs(),
|
||||
GradientStyle::Radial => (dx * dx + dy * dy).sqrt() / (len / 2.0),
|
||||
GradientStyle::Diamond => (along.abs() + across.abs()) / (len / 2.0),
|
||||
// Clockwise sweep starting at the gradient angle.
|
||||
|
|
@ -480,13 +487,21 @@ fn paint_color(dst: &mut Buffer, m: &Map, c: [f32; 3], blend: BlendMode, opacity
|
|||
paint(dst, m, |_| [c[0], c[1], c[2], 1.0], blend, opacity);
|
||||
}
|
||||
|
||||
/// Spread / choke of a soft effect: (dilation radius, blur width). The dilation is whole pixels
|
||||
/// (`size × spread` rounded: a 2 % spread of a 5 px shadow doesn't grow it at all), the blur
|
||||
/// keeps `size × (1 − spread)` (ag-psd effects 10.6 → 3.8 % of pixels off).
|
||||
pub fn spread_split(size: f32, spread: f32) -> (f32, f32) {
|
||||
((size * spread).round(), size * (1.0 - spread))
|
||||
}
|
||||
|
||||
fn shadow_map(shape: &Map, s: &Shadow, light: &GlobalLight, inner: bool) -> Map {
|
||||
let angle = if s.use_global_light { light.angle } else { s.angle };
|
||||
let (dx, dy) = offset(angle, s.distance);
|
||||
let src = if inner { shape.clone().map(|a| 1.0 - a) } else { shape.clone() };
|
||||
let mut m = src.shifted(dx, dy, if inner { 1.0 } else { 0.0 });
|
||||
m = dilate(&m, s.size * s.spread);
|
||||
blur(&mut m, s.size * (1.0 - s.spread));
|
||||
let (r, bw) = spread_split(s.size, s.spread);
|
||||
m = dilate(&m, r);
|
||||
blur(&mut m, bw);
|
||||
let mut m = apply_contour(m, &s.contour);
|
||||
if inner {
|
||||
for (v, a) in m.v.iter_mut().zip(&shape.v) {
|
||||
|
|
@ -520,12 +535,13 @@ fn glow_map(shape: &Map, g: &Glow, inner: bool) -> Map {
|
|||
m
|
||||
}
|
||||
GlowTechnique::Softer => {
|
||||
let mut m = dilate(&src, g.size * g.spread);
|
||||
blur(&mut m, g.size * (1.0 - g.spread));
|
||||
let (r, bw) = spread_split(g.size, g.spread);
|
||||
let mut m = dilate(&src, r);
|
||||
blur(&mut m, bw);
|
||||
if inner && g.source == GlowSource::Center {
|
||||
// Brightest in the middle: coverage from the distance to the edge.
|
||||
let mut e = dilate(&shape.clone().map(|a| 1.0 - a), g.size * g.spread);
|
||||
blur(&mut e, g.size * (1.0 - g.spread));
|
||||
let mut e = dilate(&shape.clone().map(|a| 1.0 - a), r);
|
||||
blur(&mut e, bw);
|
||||
m = e.map(|v| 1.0 - v);
|
||||
}
|
||||
m
|
||||
|
|
@ -607,41 +623,138 @@ pub fn tent_kernel(w: f32) -> (i32, Vec<f32>) {
|
|||
((n as i32 - 1) / 2, t)
|
||||
}
|
||||
|
||||
/// Separable convolution with a symmetric 1D kernel (outside reads 0).
|
||||
fn convolve(m: &mut Map, r: i64, kernel: &[f32]) {
|
||||
let (w, h) = (m.w as i64, m.h as i64);
|
||||
let mut tmp = vec![0.0f32; m.v.len()];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let mut acc = 0.0;
|
||||
for (k, kv) in kernel.iter().enumerate() {
|
||||
let xx = x + k as i64 - r;
|
||||
if xx >= 0 && xx < w {
|
||||
acc += m.v[(y * w + xx) as usize] * kv;
|
||||
/// Box geometry for a (fractional) width: (`r`, end-tap weight `f`, 1 / width) — the
|
||||
/// [`box_weights`] taps are `r - 1` full ones each side of the centre plus the two end taps at `f`.
|
||||
fn box_geom(bw: f32) -> (i64, f64, f64) {
|
||||
let half = bw.max(1.0) / 2.0;
|
||||
let r = (half - 0.5).ceil().max(0.0) as i64;
|
||||
let f = f64::from((half - (r as f32 - 0.5)).clamp(0.0, 1.0));
|
||||
(r, f, 1.0 / f64::from(bw.max(1.0)))
|
||||
}
|
||||
|
||||
/// One box pass over `src` (zero outside it) evaluated at `x0 .. x0 + dst.len()`, as a running
|
||||
/// sum (O(1) per sample whatever the width).
|
||||
fn box_line(src: &[f64], x0: i64, dst: &mut [f64], bw: f32) {
|
||||
let n = src.len() as i64;
|
||||
let (r, f, norm) = box_geom(bw);
|
||||
let at = |i: i64| if i >= 0 && i < n { src[i as usize] } else { 0.0 };
|
||||
if r == 0 {
|
||||
for (k, d) in dst.iter_mut().enumerate() {
|
||||
*d = at(x0 + k as i64);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mut inner: f64 = (x0 - (r - 1)..=x0 + (r - 1)).map(at).sum();
|
||||
for (k, d) in dst.iter_mut().enumerate() {
|
||||
let x = x0 + k as i64;
|
||||
*d = (inner + f * (at(x - r) + at(x + r))) * norm;
|
||||
inner += at(x + r) - at(x - r + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Two box passes along a line, exactly the convolution with [`tent_kernel`] (zero outside): the
|
||||
/// first pass is evaluated `r` samples beyond each end so the second sees what lies there.
|
||||
fn tent_line(src: &[f32], dst: &mut [f32], bw: f32) {
|
||||
let (r, _, _) = box_geom(bw);
|
||||
let s: Vec<f64> = src.iter().map(|v| f64::from(*v)).collect();
|
||||
let mut mid = vec![0.0f64; src.len() + 2 * r as usize];
|
||||
box_line(&s, -r, &mut mid, bw);
|
||||
let mut out = vec![0.0f64; src.len()];
|
||||
// `mid[k]` holds position k - r: output position p reads index p + r.
|
||||
box_line(&mid, r, &mut out, bw);
|
||||
for (d, v) in dst.iter_mut().zip(out) {
|
||||
*d = v as f32;
|
||||
}
|
||||
}
|
||||
|
||||
/// Rows of a `w`-wide row-major buffer, each through `f(src_row, dst_row)`, in parallel on
|
||||
/// scoped OS threads. Not rayon: maps are built inside a `OnceLock` while rayon renders tiles,
|
||||
/// and a rayon worker that stole another tile here would block on that same lock (deadlock).
|
||||
fn rows_par(src: &[f32], dst: &mut [f32], w: usize, f: impl Fn(&[f32], &mut [f32]) + Sync) {
|
||||
if w == 0 {
|
||||
return;
|
||||
}
|
||||
let threads = if cfg!(target_arch = "wasm32") || src.len() < 1 << 16 { 1 } else { std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, 16) };
|
||||
if threads == 1 {
|
||||
dst.chunks_mut(w).zip(src.chunks(w)).for_each(|(d, s)| f(s, d));
|
||||
return;
|
||||
}
|
||||
let rows = (src.len() / w).div_ceil(threads);
|
||||
std::thread::scope(|sc| {
|
||||
for (d, s) in dst.chunks_mut(rows * w).zip(src.chunks(rows * w)) {
|
||||
let f = &f;
|
||||
sc.spawn(move || d.chunks_mut(w).zip(s.chunks(w)).for_each(|(d, s)| f(s, d)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn transpose_map(v: &[f32], w: usize, h: usize) -> Vec<f32> {
|
||||
let mut out = vec![0.0f32; v.len()];
|
||||
const B: usize = 32;
|
||||
for by in (0..h).step_by(B) {
|
||||
for bx in (0..w).step_by(B) {
|
||||
for y in by..(by + B).min(h) {
|
||||
for x in bx..(bx + B).min(w) {
|
||||
out[x * h + y] = v[y * w + x];
|
||||
}
|
||||
}
|
||||
tmp[(y * w + x) as usize] = acc;
|
||||
}
|
||||
}
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let mut acc = 0.0;
|
||||
for (k, kv) in kernel.iter().enumerate() {
|
||||
let yy = y + k as i64 - r;
|
||||
if yy >= 0 && yy < h {
|
||||
acc += tmp[(yy * w + x) as usize] * kv;
|
||||
out
|
||||
}
|
||||
|
||||
/// Two box passes of width `w` along each axis (the tent [`tent_kernel`] describes, which the GPU
|
||||
/// convolves directly).
|
||||
fn tent(m: &mut Map, w: f32) {
|
||||
if tent_kernel(w).0 == 0 || m.v.is_empty() {
|
||||
return;
|
||||
}
|
||||
tent_fast(m, w);
|
||||
}
|
||||
|
||||
/// [`tent`] as a direct convolution with [`tent_kernel`] (the GPU's way; tests compare).
|
||||
#[cfg(test)]
|
||||
fn tent_direct(m: &mut Map, w: f32) {
|
||||
{
|
||||
let (r, k) = tent_kernel(w);
|
||||
let (mw, mh) = (m.w as i64, m.h as i64);
|
||||
let mut tmp = vec![0.0f32; m.v.len()];
|
||||
for y in 0..mh {
|
||||
for x in 0..mw {
|
||||
let mut acc = 0.0;
|
||||
for (i, kv) in k.iter().enumerate() {
|
||||
let xx = x + i as i64 - r as i64;
|
||||
if xx >= 0 && xx < mw {
|
||||
acc += m.v[(y * mw + xx) as usize] * kv;
|
||||
}
|
||||
}
|
||||
tmp[(y * mw + x) as usize] = acc;
|
||||
}
|
||||
}
|
||||
for y in 0..mh {
|
||||
for x in 0..mw {
|
||||
let mut acc = 0.0;
|
||||
for (i, kv) in k.iter().enumerate() {
|
||||
let yy = y + i as i64 - r as i64;
|
||||
if yy >= 0 && yy < mh {
|
||||
acc += tmp[(yy * mw + x) as usize] * kv;
|
||||
}
|
||||
}
|
||||
m.v[(y * mw + x) as usize] = acc;
|
||||
}
|
||||
m.v[(y * w + x) as usize] = acc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tent(m: &mut Map, w: f32) {
|
||||
let (r, k) = tent_kernel(w);
|
||||
if r > 0 {
|
||||
convolve(m, i64::from(r), &k);
|
||||
}
|
||||
fn tent_fast(m: &mut Map, w: f32) {
|
||||
let (mw, mh) = (m.w, m.h);
|
||||
let twice = |s: &[f32], d: &mut [f32]| tent_line(s, d, w);
|
||||
let mut a = vec![0.0f32; m.v.len()];
|
||||
rows_par(&m.v, &mut a, mw, twice);
|
||||
let t = transpose_map(&a, mw, mh);
|
||||
let mut b = vec![0.0f32; t.len()];
|
||||
rows_par(&t, &mut b, mh, twice);
|
||||
m.v = transpose_map(&b, mh, mw);
|
||||
}
|
||||
|
||||
/// Where a bevel's maps paint: inside the shape (with the layer), outside it (onto the
|
||||
|
|
@ -653,6 +766,10 @@ pub enum BevelPaint {
|
|||
Both,
|
||||
}
|
||||
|
||||
/// Height below which an outer bevel pixel counts as off the bevel (beyond the blur's reach):
|
||||
/// above float noise of the running-sum blur (~3e-7), far below anything that shades.
|
||||
pub const BEVEL_H_EPS: f32 = 1e-5;
|
||||
|
||||
/// Bevel geometry shared by the CPU maps and the GPU programs.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct BevelGeom {
|
||||
|
|
@ -778,7 +895,7 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx) ->
|
|||
let n = [-gx, -gy, 1.0];
|
||||
let len = (n[0] * n[0] + n[1] * n[1] + 1.0).sqrt();
|
||||
let shade = (n[0] * light_v[0] + n[1] * light_v[1] + n[2] * light_v[2]) / len;
|
||||
let region = if outer { f32::from(shape.v[i] < 1.0 - INSIDE_EPS && hmap.v[i] > 0.0) } else { shape.v[i] };
|
||||
let region = if outer { f32::from(shape.v[i] < 1.0 - INSIDE_EPS && hmap.v[i] > BEVEL_H_EPS) } else { shape.v[i] };
|
||||
let k = shade - se;
|
||||
if k > 0.0 {
|
||||
hi.v[i] = (k / (1.0 - se).max(1e-3)).clamp(0.0, 1.0) * region;
|
||||
|
|
@ -1232,6 +1349,21 @@ mod tests {
|
|||
assert!(near.iter().all(|&n| n == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_sum_tent_matches_the_kernel() {
|
||||
let (w, h) = (37usize, 23usize);
|
||||
let src: Vec<f32> = (0..w * h).map(|i| ((i * 7919) % 101) as f32 / 100.0).collect();
|
||||
for width in [1.0f32, 2.0, 3.5, 5.0, 8.25, 41.0] {
|
||||
let mut m = Map { w, h, v: src.clone() };
|
||||
tent(&mut m, width);
|
||||
let mut want = Map { w, h, v: src.clone() };
|
||||
tent_direct(&mut want, width);
|
||||
for (a, b) in m.v.iter().zip(&want.v) {
|
||||
assert!((a - b).abs() < 1e-5, "width {width}: {a} vs {b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tent_kernel_is_two_boxes() {
|
||||
let (r, k) = tent_kernel(5.0);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod adjust;
|
||||
pub mod bounds;
|
||||
pub mod effects;
|
||||
pub mod masks;
|
||||
pub mod multichannel;
|
||||
|
|
@ -79,10 +80,14 @@ pub fn render(doc: &Document, rect: Rect) -> Buffer {
|
|||
|
||||
/// [`render`] with an explicit tile size (tests check tile independence).
|
||||
pub fn render_tiled(doc: &Document, rect: Rect, tile: i32) -> Buffer {
|
||||
let cx = Ctx { canvas: doc.bounds(), transfer: adjust::Transfer::for_mode(doc.mode), light: doc.global_light, patterns: &doc.patterns, mode: doc.mode };
|
||||
let cx = Ctx { canvas: doc.bounds(), transfer: adjust::Transfer::for_mode(doc.mode), light: doc.global_light, patterns: &doc.patterns, mode: doc.mode, depth: doc.depth };
|
||||
// Lab documents mix Normal blending in CIELAB, as Photoshop does (psblend::LAB_MIX).
|
||||
let lab = doc.mode == photocraft_color::ColorMode::Lab;
|
||||
if rect.width() as i32 <= tile && rect.height() as i32 <= tile {
|
||||
let mut buf = multichannel::backdrop(doc, rect);
|
||||
psblend::LAB_MIX.with(|l| l.set(lab));
|
||||
composite_stack(&doc.layers, &mut buf, &cx);
|
||||
psblend::LAB_MIX.with(|l| l.set(false));
|
||||
return buf;
|
||||
}
|
||||
let mut tiles = Vec::new();
|
||||
|
|
@ -97,7 +102,9 @@ pub fn render_tiled(doc: &Document, rect: Rect, tile: i32) -> Buffer {
|
|||
}
|
||||
let run = |t: &Rect| {
|
||||
let mut b = multichannel::backdrop(doc, *t);
|
||||
psblend::LAB_MIX.with(|l| l.set(lab));
|
||||
composite_stack(&doc.layers, &mut b, &cx);
|
||||
psblend::LAB_MIX.with(|l| l.set(false));
|
||||
b
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
|
@ -127,7 +134,7 @@ pub fn flatten(doc: &Document) -> Buffer {
|
|||
/// Render an arbitrary subset: a single layer (e.g. for thumbnails), isolated.
|
||||
pub fn render_layer(layer: &Layer, rect: Rect) -> Buffer {
|
||||
let mut buf = Buffer::transparent(rect);
|
||||
composite_stack(std::slice::from_ref(layer), &mut buf, &Ctx { canvas: rect, transfer: adjust::Transfer::Srgb, light: photocraft_doc::GlobalLight::default(), patterns: &[], mode: photocraft_color::ColorMode::Rgb });
|
||||
composite_stack(std::slice::from_ref(layer), &mut buf, &Ctx { canvas: rect, transfer: adjust::Transfer::Srgb, light: photocraft_doc::GlobalLight::default(), patterns: &[], mode: photocraft_color::ColorMode::Rgb, depth: photocraft_color::SampleType::F32 });
|
||||
buf
|
||||
}
|
||||
|
||||
|
|
@ -182,6 +189,7 @@ struct Ctx<'a> {
|
|||
patterns: &'a [Pattern],
|
||||
/// The document's colour mode (channel restrictions name its channels).
|
||||
mode: photocraft_color::ColorMode,
|
||||
depth: photocraft_color::SampleType,
|
||||
}
|
||||
|
||||
fn composite_stack(layers: &[Layer], backdrop: &mut Buffer, cx: &Ctx) {
|
||||
|
|
@ -196,11 +204,36 @@ fn composite_stack(layers: &[Layer], backdrop: &mut Buffer, cx: &Ctx) {
|
|||
let clipped = &layers[i + 1..j];
|
||||
if base.visible {
|
||||
composite_layer(base, clipped, backdrop, cx);
|
||||
if let (LayerContent::Adjustment(_), Some(q)) = (&base.content, adjustment_quantum(cx.depth)) {
|
||||
quantize(backdrop, q);
|
||||
}
|
||||
}
|
||||
i = j.max(i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Steps per unit of an integer document's samples: Photoshop applies adjustment layers to
|
||||
/// buffers of the document's depth (8-bit: 255 levels, 16-bit: 32768), so their result is
|
||||
/// rounded there; a steep curve then amplifies the rounding of its input exactly as in
|
||||
/// Photoshop (psd-tools adjustment_nested_composition_4: 9.6 → 5.1 % of pixels off;
|
||||
/// exposure_grayscale passes). Blends stay in float (quantising them too made other files
|
||||
/// worse).
|
||||
pub fn adjustment_quantum(depth: photocraft_color::SampleType) -> Option<f32> {
|
||||
match depth {
|
||||
photocraft_color::SampleType::U8 => Some(255.0),
|
||||
photocraft_color::SampleType::U16 => Some(32768.0),
|
||||
photocraft_color::SampleType::F32 => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn quantize(b: &mut Buffer, q: f32) {
|
||||
for p in &mut b.px {
|
||||
for v in p.iter_mut() {
|
||||
*v = (*v * q + 0.5).floor() / q;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic hash for Dissolve (document-coordinate based, so it is stable under tiling).
|
||||
#[inline]
|
||||
fn dissolve_noise(x: i32, y: i32) -> f32 {
|
||||
|
|
@ -212,8 +245,8 @@ fn dissolve_noise(x: i32, y: i32) -> f32 {
|
|||
}
|
||||
|
||||
/// Bounds of a layer's own pixels (union over group children; the canvas
|
||||
/// for fill layers).
|
||||
fn layer_bounds(layer: &Layer, canvas: Rect) -> Rect {
|
||||
/// for fill layers; an artboard's board).
|
||||
pub fn layer_bounds(layer: &Layer, canvas: Rect) -> Rect {
|
||||
match &layer.content {
|
||||
LayerContent::Group(g) if g.artboard.is_some() => g.artboard.as_ref().map_or(Rect::EMPTY, |a| a.rect),
|
||||
LayerContent::Group(g) => g.children.iter().filter(|c| c.visible).fold(Rect::EMPTY, |acc, c| {
|
||||
|
|
@ -221,10 +254,21 @@ fn layer_bounds(layer: &Layer, canvas: Rect) -> Rect {
|
|||
if b.is_empty() { acc } else if acc.is_empty() { b } else { acc.union(&b) }
|
||||
}),
|
||||
LayerContent::Fill(_) => canvas,
|
||||
_ => layer.surface().map_or(Rect::EMPTY, Surface::content_bounds),
|
||||
_ => layer.surface().map_or(Rect::EMPTY, bounds::content_bounds),
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame layer-effect gradients and linked patterns are laid out in, when it isn't the
|
||||
/// layer's pixel bounds: a shape layer's path bounds (rounded out to whole pixels). Its rendered
|
||||
/// pixels can extend past the path (transparent anti-aliasing margin), which Photoshop ignores:
|
||||
/// psd-tools shape-fx2's 45° overlay spans the 29 px path, not the 32 px of pixels.
|
||||
pub fn paint_bounds(layer: &Layer) -> Option<Rect> {
|
||||
let LayerContent::Shape(sh) = &layer.content else { return None };
|
||||
let (x0, y0, x1, y1) = sh.path.control_bounds()?;
|
||||
let r = Rect::new(x0.floor() as i32, y0.floor() as i32, x1.ceil() as i32, y1.ceil() as i32);
|
||||
(!r.is_empty()).then_some(r)
|
||||
}
|
||||
|
||||
/// The layer's effective mask over `rect` (row-major), read once per tile: the pixel mask
|
||||
/// times the rasterized vector mask.
|
||||
fn mask_vals(layer: &Layer, rect: Rect) -> Option<Vec<f32>> {
|
||||
|
|
@ -276,7 +320,7 @@ fn render_content(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<Buffer> {
|
|||
/// The alpha of `layer`'s own content over `rect` (masks applied, row-major): the shape its
|
||||
/// effect maps are built from (for the GPU compositor). Zero for adjustment layers.
|
||||
pub fn layer_shape(doc: &Document, layer: &Layer, rect: Rect) -> Vec<f32> {
|
||||
let cx = Ctx { canvas: doc.bounds(), transfer: adjust::Transfer::for_mode(doc.mode), light: doc.global_light, patterns: &doc.patterns, mode: doc.mode };
|
||||
let cx = Ctx { canvas: doc.bounds(), transfer: adjust::Transfer::for_mode(doc.mode), light: doc.global_light, patterns: &doc.patterns, mode: doc.mode, depth: doc.depth };
|
||||
render_content(layer, rect, &cx).map(|b| b.px.iter().map(|p| p[3]).collect()).unwrap_or_else(|| vec![0.0; rect.width() as usize * rect.height() as usize])
|
||||
}
|
||||
|
||||
|
|
@ -295,7 +339,7 @@ pub fn fill_frame(layer: &Layer, canvas: Rect) -> Rect {
|
|||
&& m.enabled
|
||||
&& m.surface.default_pixel().first().is_some_and(|v| *v <= 0.0)
|
||||
{
|
||||
let b = m.surface.content_bounds().intersect(&canvas);
|
||||
let b = bounds::content_bounds(&m.surface).intersect(&canvas);
|
||||
if !b.is_empty() {
|
||||
frame = b;
|
||||
}
|
||||
|
|
@ -370,7 +414,10 @@ fn sample_stops(stops: &[(f32, photocraft_color::Color)], t: f32) -> [f32; 4] {
|
|||
/// vanish with it.
|
||||
fn empty_in(layer: &Layer, rect: Rect) -> bool {
|
||||
if effects::has_effects(layer) {
|
||||
return false;
|
||||
// Effects reach at most `margin` beyond the layer's pixels (when it is transparent
|
||||
// outside them): render tiles away from a small text layer skip it entirely.
|
||||
let canvas = Rect::new(i32::MIN / 4, i32::MIN / 4, i32::MAX / 4, i32::MAX / 4);
|
||||
return transparent_outside(layer) && layer_bounds(layer, canvas).inflate(effects::margin(layer)).intersect(&rect).is_empty();
|
||||
}
|
||||
match &layer.content {
|
||||
LayerContent::Raster(_) | LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Smart(_) => match layer.surface() {
|
||||
|
|
@ -411,6 +458,18 @@ fn restore_channels(out: &mut Buffer, before: &Buffer, w: [f32; 3]) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Whether the layer's content is transparent outside its bounds (every surface it draws from
|
||||
/// has a transparent default pixel), so its effects can't change pixels beyond its bounds grown
|
||||
/// by their reach.
|
||||
pub fn transparent_outside(layer: &Layer) -> bool {
|
||||
match &layer.content {
|
||||
LayerContent::Group(g) => g.children.iter().filter(|c| c.visible).all(transparent_outside),
|
||||
// Fill layers cover the canvas (their bounds); adjustments draw nothing of their own.
|
||||
LayerContent::Fill(_) | LayerContent::Adjustment(_) => true,
|
||||
_ => layer.surface().is_none_or(|s| s.format().alpha && s.default_pixel().last().is_some_and(|a| *a <= 0.0)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Composite `layer` (plus its clipping group) onto `backdrop`, honouring its channel restrictions.
|
||||
fn composite_layer(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx) {
|
||||
match channel_weights(layer, cx.mode) {
|
||||
|
|
@ -552,7 +611,7 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
|
|||
composite_atop(c, &mut content, cx);
|
||||
}
|
||||
let maps = effect_maps(layer, cx);
|
||||
effects::composite_with_effects(layer, &content, backdrop, &maps, layer_bounds(layer, cx.canvas), cx.patterns);
|
||||
effects::composite_with_effects(layer, &content, backdrop, &maps, paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas)), cx.patterns);
|
||||
return;
|
||||
}
|
||||
if let Some((mut content, stroke)) = shape_parts(layer, clipped, rect, cx) {
|
||||
|
|
@ -572,9 +631,10 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
|
|||
blend_into_g(backdrop, &content, layer.blend, opacity, text_gamma(layer));
|
||||
}
|
||||
|
||||
/// The coverage-mixing gamma of a layer: [`psblend::TEXT_GAMMA`] for type layers, else 1.
|
||||
/// The coverage-mixing gamma of a layer: the text blending gamma
|
||||
/// ([`psblend::set_text_gamma`], Photoshop's default 1.45) for type layers, else 1.
|
||||
pub fn text_gamma(layer: &Layer) -> f32 {
|
||||
if matches!(layer.content, LayerContent::Text(_)) { psblend::TEXT_GAMMA } else { 1.0 }
|
||||
if matches!(layer.content, LayerContent::Text(_)) { psblend::text_gamma() } else { 1.0 }
|
||||
}
|
||||
|
||||
/// A stroked shape layer with visible clipped layers: Photoshop draws the shape's vector stroke
|
||||
|
|
@ -743,7 +803,7 @@ fn effect_maps(layer: &Layer, cx: &Ctx) -> std::sync::Arc<effects::FxMaps> {
|
|||
}
|
||||
|
||||
fn texture_ctx<'a>(layer: &Layer, region: Rect, cx: &Ctx<'a>) -> effects::TextureCtx<'a> {
|
||||
let sb = layer_bounds(layer, cx.canvas);
|
||||
let sb = paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas));
|
||||
effects::TextureCtx { rect: region, patterns: cx.patterns, anchor: layer.effects.reference.unwrap_or((f64::from(sb.x0), f64::from(sb.y0))) }
|
||||
}
|
||||
|
||||
|
|
@ -783,7 +843,7 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) {
|
|||
let Some(content) = render_content(layer, big, cx) else { return };
|
||||
let mut opaque = Buffer { rect, px: base.px.iter().map(|p| [p[0], p[1], p[2], 1.0]).collect() };
|
||||
let maps = effect_maps(layer, cx);
|
||||
effects::composite_with_effects(layer, &content, &mut opaque, &maps, layer_bounds(layer, cx.canvas), cx.patterns);
|
||||
effects::composite_with_effects(layer, &content, &mut opaque, &maps, paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas)), cx.patterns);
|
||||
for (p, o) in base.px.iter_mut().zip(&opaque.px) {
|
||||
if p[3] > 0.0 {
|
||||
*p = [o[0], o[1], o[2], p[3]];
|
||||
|
|
|
|||
|
|
@ -97,6 +97,11 @@ impl Placement {
|
|||
Placement { origin, cs: (a.cos(), a.sin()), inv_scale }
|
||||
}
|
||||
|
||||
/// (origin, inverse rotation (cos, sin), inverse scale), for GPU shaders.
|
||||
pub fn parts(&self) -> ((f64, f64), (f64, f64), f64) {
|
||||
(self.origin, self.cs, self.inv_scale)
|
||||
}
|
||||
|
||||
/// Tile coordinates of the document point `(x, y)`.
|
||||
#[inline]
|
||||
pub fn map(&self, x: f64, y: f64) -> (f64, f64) {
|
||||
|
|
|
|||
|
|
@ -71,6 +71,13 @@ fn hard_mix_ps(cb: f32, cs: f32) -> f32 {
|
|||
if vivid_light_generic(cb, cs) >= 0.5 - 1e-6 { 1.0 } else { 0.0 }
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// Set while rendering a Lab document: Normal blending mixes backdrop and source in CIELAB
|
||||
/// (Photoshop composites Lab documents in Lab; an anti-aliased edge between two colours
|
||||
/// differs by up to 14 / 255 from an sRGB mix: psd-tools stroke-color-descriptors-lab).
|
||||
pub static LAB_MIX: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
/// `B(Cb, Cs)` with Photoshop's variants.
|
||||
pub fn blend_rgb(mode: BlendMode, cb: [f32; 3], cs: [f32; 3]) -> [f32; 3] {
|
||||
match mode {
|
||||
|
|
@ -91,6 +98,15 @@ pub fn composite(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], opacity:
|
|||
return backdrop;
|
||||
}
|
||||
let ao = as_ + ab * (1.0 - as_);
|
||||
if mode == BlendMode::Normal && LAB_MIX.with(|l| l.get()) && ab > 0.0 && ao > 0.0 {
|
||||
let lb = photocraft_color::convert::srgb_to_lab([backdrop[0], backdrop[1], backdrop[2]]);
|
||||
let ls = photocraft_color::convert::srgb_to_lab([source[0], source[1], source[2]]);
|
||||
let kb = ab * (1.0 - as_) / ao;
|
||||
let k = as_ / ao;
|
||||
let m: [f32; 3] = std::array::from_fn(|i| lb[i] * kb + ls[i] * k);
|
||||
let r = photocraft_color::convert::lab_to_srgb(m);
|
||||
return [r[0], r[1], r[2], ao];
|
||||
}
|
||||
if mode == BlendMode::Normal {
|
||||
// Fast path: B(Cb, Cs) = Cs.
|
||||
if ao <= 0.0 {
|
||||
|
|
@ -126,6 +142,21 @@ pub fn composite(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], opacity:
|
|||
/// a linear mix gives 40).
|
||||
pub const TEXT_GAMMA: f32 = 1.45;
|
||||
|
||||
static TEXT_GAMMA_SETTING: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0x3fb9_999a); // 1.45f32
|
||||
|
||||
/// Sets Color Settings › "Blend Text Colors Using Gamma" (1 = off). It is an application
|
||||
/// setting, not stored in documents: files saved with it off (ag-psd float-color) mix type
|
||||
/// linearly. Values are clamped to Photoshop's 1.00–2.20.
|
||||
pub fn set_text_gamma(gamma: f32) {
|
||||
let g = if gamma.is_finite() { gamma.clamp(1.0, 2.2) } else { TEXT_GAMMA };
|
||||
TEXT_GAMMA_SETTING.store(g.to_bits(), std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// The current text blending gamma (see [`set_text_gamma`]; 1 = off).
|
||||
pub fn text_gamma() -> f32 {
|
||||
f32::from_bits(TEXT_GAMMA_SETTING.load(std::sync::atomic::Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// [`composite`] with the coverage mix done in a `gamma` space (`gamma == 1` is [`composite`]).
|
||||
pub fn composite_gamma(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], opacity: f32, gamma: f32) -> [f32; 4] {
|
||||
if gamma == 1.0 {
|
||||
|
|
|
|||
|
|
@ -644,7 +644,7 @@ fn effect_maps_are_cached_and_invalidated_by_pixel_changes() {
|
|||
let mut l = solid_layer("fx", Rect::new(16, 16, 48, 48), [1.0, 0.0, 0.0, 1.0]);
|
||||
l.effects.items.push(photocraft_doc::Effect::default_drop_shadow());
|
||||
doc.layers.push(l);
|
||||
let cx = Ctx { canvas: doc.bounds(), transfer: adjust::Transfer::Srgb, light: doc.global_light, patterns: &doc.patterns, mode: doc.mode };
|
||||
let cx = Ctx { canvas: doc.bounds(), transfer: adjust::Transfer::Srgb, light: doc.global_light, patterns: &doc.patterns, mode: doc.mode, depth: doc.depth };
|
||||
let a = effect_maps(&doc.layers[1], &cx);
|
||||
let b = effect_maps(&doc.layers[1], &cx);
|
||||
assert!(std::sync::Arc::ptr_eq(&a, &b), "second request hits the cache");
|
||||
|
|
@ -776,3 +776,31 @@ fn channel_restrictions_keep_the_backdrop() {
|
|||
assert_eq!(channel_weights(&d.layers[1], ColorMode::Cmyk), None);
|
||||
assert_eq!(channel_weights(&d.layers[1], ColorMode::Rgb), Some([1.0, 1.0, 0.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_maps_built_inside_parallel_tiles_do_not_deadlock() {
|
||||
// A map large enough for the blur to go multi-threaded, built while rayon renders tiles that
|
||||
// all wait on the same map (a rayon-parallel blur deadlocked here).
|
||||
let mut d = doc_white(900, 700);
|
||||
let mut l = solid_layer("fx", Rect::new(100, 100, 800, 600), [0.2, 0.4, 0.9, 1.0]);
|
||||
l.effects.items = vec![photocraft_doc::Effect::default_drop_shadow()];
|
||||
if let photocraft_doc::Effect::DropShadow(s) = &mut l.effects.items[0] {
|
||||
s.size = 30.0;
|
||||
}
|
||||
d.layers.push(l);
|
||||
let out = render(&d, d.bounds());
|
||||
assert_eq!(out.px.len(), 900 * 700);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjustment_results_are_rounded_to_the_document_depth() {
|
||||
for (depth, q) in [(SampleType::U8, Some(255.0f32)), (SampleType::U16, Some(32768.0)), (SampleType::F32, None)] {
|
||||
let mut d = Document::with_background("q", Size::new(4, 4), ColorMode::Rgb, depth, Color::rgb(0.3, 0.6, 0.9));
|
||||
d.layers.push(Layer::new("lv", LayerContent::Adjustment(Adjustment::Exposure { exposure: 0.37, offset: 0.0, gamma: 1.0 })));
|
||||
let p = px(&d, 1, 1);
|
||||
match q {
|
||||
Some(q) => assert!(p.iter().all(|v| ((v * q).round() - v * q).abs() < 1e-3), "{depth:?} {p:?}"),
|
||||
None => assert!(p[..3].iter().any(|v| ((v * 255.0).round() - v * 255.0).abs() > 1e-3), "{p:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -120,6 +120,9 @@ pub struct ColorSettings {
|
|||
pub intent: String,
|
||||
pub bpc: bool,
|
||||
pub dither: bool,
|
||||
/// Advanced › "Blend Text Colors Using Gamma" (1 = off; Photoshop's default 1.45): type
|
||||
/// layers mix anti-aliased edges in this gamma (`photocraft_compose::psblend::set_text_gamma`).
|
||||
pub blend_text_gamma: f32,
|
||||
}
|
||||
|
||||
impl Default for ColorSettings {
|
||||
|
|
@ -137,6 +140,7 @@ impl Default for ColorSettings {
|
|||
intent: Intent::RelativeColorimetric.id().into(),
|
||||
bpc: true,
|
||||
dither: true,
|
||||
blend_text_gamma: photocraft_compose::psblend::TEXT_GAMMA,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -781,8 +785,18 @@ fn color_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if let Some(v) = str_of("intent") {
|
||||
next.intent = Intent::parse(&v).map(|i| i.id().to_string()).unwrap_or(v);
|
||||
}
|
||||
match p.get("blendTextGamma") {
|
||||
Some(Value::Bool(false)) => next.blend_text_gamma = 1.0,
|
||||
Some(Value::Bool(true)) => next.blend_text_gamma = photocraft_compose::psblend::TEXT_GAMMA,
|
||||
Some(v) => {
|
||||
let g = v.as_f64().filter(|g| (1.0..=2.2).contains(g)).ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: "`blendTextGamma` must be 1.0..2.2 or a bool".into() })?;
|
||||
next.blend_text_gamma = g as f32;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
validate_settings(&next).map_err(|msg| EngineError::BadParams { cmd: cmd.into(), msg })?;
|
||||
if next != s.color.settings {
|
||||
photocraft_compose::psblend::set_text_gamma(next.blend_text_gamma);
|
||||
s.color.settings = next;
|
||||
// Persisted with the preferences.
|
||||
s.prefs.edit(|_| ());
|
||||
|
|
@ -855,7 +869,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"edit.colorSettings",
|
||||
"Color Settings…",
|
||||
["Edit"],
|
||||
r##"{"workingRgb":"srgb|display-p3|adobe-rgb-compat|prophoto-compat|linear-srgb|rec2020","workingCmyk":"coated-cmyk","workingGray":"sgray|gray-gamma-2.2","policyRgb":"preserve|convert|off","policyCmyk":"preserve|convert|off","policyGray":"preserve|convert|off","askOnMismatch":bool=true,"askOnPaste":bool=true,"askOnMissing":bool=false,"intent":"relative|perceptual|saturation|absolute","bpc":bool=true,"dither":bool=true,"reset":bool=false} (working spaces also accept .icc paths)"##,
|
||||
r##"{"workingRgb":"srgb|display-p3|adobe-rgb-compat|prophoto-compat|linear-srgb|rec2020","workingCmyk":"coated-cmyk","workingGray":"sgray|gray-gamma-2.2","policyRgb":"preserve|convert|off","policyCmyk":"preserve|convert|off","policyGray":"preserve|convert|off","askOnMismatch":bool=true,"askOnPaste":bool=true,"askOnMissing":bool=false,"intent":"relative|perceptual|saturation|absolute","blendTextGamma":1.0..2.2|bool=1.45,"bpc":bool=true,"dither":bool=true,"reset":bool=false} (working spaces also accept .icc paths)"##,
|
||||
always,
|
||||
color_settings,
|
||||
true,
|
||||
|
|
@ -1097,6 +1111,20 @@ mod settings_tests {
|
|||
assert_eq!(t.color.settings, s.color.settings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_text_gamma_setting() {
|
||||
let mut s = Session::new();
|
||||
assert!((s.color.settings.blend_text_gamma - 1.45).abs() < 1e-6);
|
||||
assert!(s.execute("edit.colorSettings", json!({"blendTextGamma": 3.0})).is_err());
|
||||
// The compositor's (application-wide) gamma follows; restored to the default at once.
|
||||
s.execute("edit.colorSettings", json!({"blendTextGamma": 1.45})).unwrap();
|
||||
assert!((photocraft_compose::psblend::text_gamma() - 1.45).abs() < 1e-6);
|
||||
let r = s.execute("edit.colorSettings", json!({"blendTextGamma": false})).unwrap();
|
||||
assert_eq!(r["settings"]["blendTextGamma"], 1.0);
|
||||
s.execute("edit.colorSettings", json!({"blendTextGamma": true})).unwrap();
|
||||
assert!((s.color.settings.blend_text_gamma - 1.45).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_policies() {
|
||||
let mut s = Session::new();
|
||||
|
|
|
|||
|
|
@ -461,7 +461,7 @@ fn build() -> Vec<CommandSpec> {
|
|||
Ok(Value::Null)
|
||||
}),
|
||||
cmd!("layer.select", "Select Layer", [], None, r##"{"layer":id,"mode":"replace|toggle|range|add"="replace"} (toggle = ⌘-click, range = ⇧-click)"##, has_doc, crate::layer_multi_cmds::select),
|
||||
cmd!("layer.setProps", "Layer Properties", [], None, r##"{"layer":id?,"name":str?,"visible":bool?,"opacity":0..1?,"fill":0..1?,"blend":"Multiply|…"?,"clipped":bool?,"locked":bool?,"locks":{"transparency","pixels","position","artboard","all":bool}?}"##, has_layer, |s, p| {
|
||||
cmd!("layer.setProps", "Layer Properties", [], None, r##"{"layer":id?,"name":str?,"visible":bool?,"opacity":0..1?,"fill":0..1?,"blend":"Multiply|…"?,"clipped":bool?,"locked":bool?,"locks":{"transparency","pixels","position","artboard","all":bool}?,"channels":[bool,…]? (Advanced Blending: which colour channels blend, R G B / C M Y K / L a b)}"##, has_layer, |s, p| {
|
||||
let id = layer_param(s, p)?;
|
||||
let label = if p.get("visible").is_some() && p.as_object().is_some_and(|o| o.len() <= 2) { "Layer Visibility" } else { "Layer Properties" };
|
||||
s.edit(label, |doc, _| {
|
||||
|
|
@ -487,6 +487,10 @@ fn build() -> Vec<CommandSpec> {
|
|||
if let Some(v) = p.get("locked").and_then(Value::as_bool) {
|
||||
l.locks.all = v;
|
||||
}
|
||||
if let Some(Value::Array(ch)) = p.get("channels") {
|
||||
// Blending Options › Advanced Blending › Channels: unchecked = left out.
|
||||
l.excluded_channels = ch.iter().take(32).enumerate().filter(|(_, v)| v.as_bool() == Some(false)).fold(0, |m, (i, _)| m | 1 << i);
|
||||
}
|
||||
if let Some(Value::Object(m)) = p.get("locks") {
|
||||
for (k, v) in m {
|
||||
let v = v.as_bool().unwrap_or(false);
|
||||
|
|
|
|||
|
|
@ -95,6 +95,10 @@ fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId]) -> Value {
|
|||
}
|
||||
_ => {}
|
||||
}
|
||||
// Advanced Blending › Channels (only when some channel is left out).
|
||||
if l.excluded_channels != 0 {
|
||||
v["channels"] = json!((0..4).map(|i| l.excluded_channels & (1 << i) == 0).collect::<Vec<_>>());
|
||||
}
|
||||
// Layer styles, so agents can verify what they applied (full settings via the style commands).
|
||||
if !l.effects.items.is_empty() {
|
||||
v["effects"] = json!({
|
||||
|
|
|
|||
|
|
@ -149,8 +149,16 @@ pub fn effect_from_params(kind: &str, p: &Value) -> Option<Effect> {
|
|||
highlight_color: Color::WHITE,
|
||||
shadow: FxCommon::new(BlendMode::Multiply, 0.75),
|
||||
shadow_color: Color::BLACK,
|
||||
contour: None,
|
||||
texture: None,
|
||||
contour: b(p, "contour", false).then(|| photocraft_doc::BevelContour { contour: Contour::Linear, range: (f(p, "contourRange", 50.0) / 100.0).clamp(0.01, 1.0), anti_alias: false }),
|
||||
texture: p.get("texture").and_then(Value::as_str).filter(|t| !t.is_empty()).map(|t| photocraft_doc::BevelTexture {
|
||||
name: t.to_string(),
|
||||
id: t.to_string(),
|
||||
scale: (f(p, "textureScale", 100.0) / 100.0).clamp(0.01, 10.0),
|
||||
depth: (f(p, "textureDepth", 100.0) / 100.0).clamp(-10.0, 10.0),
|
||||
invert: b(p, "textureInvert", false),
|
||||
link: b(p, "textureLink", true),
|
||||
phase: (0.0, 0.0),
|
||||
}),
|
||||
}),
|
||||
_ => return None,
|
||||
})
|
||||
|
|
@ -215,7 +223,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
style_cmd!("colorOverlay", "Color Overlay…", r##"{"color":"#rrggbb","opacity":0..100=100,"blend":str,"add":bool}"##),
|
||||
style_cmd!("gradientOverlay", "Gradient Overlay…", r##"{"from":"#rrggbb","to":"#rrggbb","style":"linear|radial|angle|reflected|diamond","angle":deg=90,"scale":10..150=100,"reverse":bool,"opacity":0..100,"blend":str,"add":bool}"##),
|
||||
style_cmd!("patternOverlay", "Pattern Overlay…", r##"{"pattern":id|name?=first library pattern,"opacity":0..100=100,"blend":str,"scale":1..1000=100,"angle":deg=0,"link":bool=true,"phaseX":px,"phaseY":px,"add":bool}"##),
|
||||
style_cmd!("bevelEmboss", "Bevel & Emboss…", r##"{"style":"inner|outer|emboss|pillow|stroke","technique":"smooth|chiselHard|chiselSoft","depth":1..1000=100,"direction":"up|down","size":px=5,"soften":px,"angle":deg,"altitude":deg,"add":bool}"##),
|
||||
style_cmd!("bevelEmboss", "Bevel & Emboss…", r##"{"style":"inner|outer|emboss|pillow|stroke","technique":"smooth|chiselHard|chiselSoft","contour":bool,"contourRange":1..100=50,"texture":pattern id|name?,"textureScale":1..1000=100,"textureDepth":-1000..1000=100,"textureInvert":bool,"textureLink":bool=true,"depth":1..1000=100,"direction":"up|down","size":px=5,"soften":px,"angle":deg,"altitude":deg,"add":bool}"##),
|
||||
style_cmd!("satin", "Satin…", r##"{"color":"#rrggbb","opacity":0..100=50,"blend":str,"angle":deg,"distance":px,"size":px,"invert":bool,"add":bool}"##),
|
||||
CommandSpec {
|
||||
id: "layer.layerStyle.clear",
|
||||
|
|
@ -258,6 +266,18 @@ mod tests {
|
|||
d.doc.layer(d.active_layer.unwrap()).unwrap().effects.items.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bevel_command_sets_technique_contour_and_texture() {
|
||||
let mut s = session();
|
||||
s.execute("layer.layerStyle.bevelEmboss", json!({"style": "pillow", "technique": "chiselHard", "contour": true, "contourRange": 70, "texture": "Bubbles", "textureDepth": -200, "textureInvert": true})).unwrap();
|
||||
let fx = effects(&s);
|
||||
let Effect::BevelEmboss(b) = &fx[0] else { panic!("{fx:?}") };
|
||||
assert_eq!((b.style, b.technique), (photocraft_doc::BevelStyle::PillowEmboss, BevelTechnique::ChiselHard));
|
||||
assert!((b.contour.as_ref().unwrap().range - 0.7).abs() < 1e-6);
|
||||
let t = b.texture.as_ref().unwrap();
|
||||
assert_eq!((t.name.as_str(), t.depth, t.invert), ("Bubbles", -2.0, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_style_command_adds_its_effect() {
|
||||
for kind in ["dropShadow", "innerShadow", "outerGlow", "innerGlow", "stroke", "colorOverlay", "gradientOverlay", "bevelEmboss", "satin"] {
|
||||
|
|
|
|||
|
|
@ -1015,6 +1015,7 @@ impl Session {
|
|||
let prefs: Preferences = serde_json::from_value(v).map_err(|e| format!("preferences: {e}"))?;
|
||||
if let Some(c) = color {
|
||||
self.color.settings = serde_json::from_value(c).unwrap_or_default();
|
||||
photocraft_compose::psblend::set_text_gamma(self.color.settings.blend_text_gamma);
|
||||
}
|
||||
self.prefs.edit(|p| *p = prefs);
|
||||
if let Some(v) = presets {
|
||||
|
|
|
|||
|
|
@ -298,8 +298,11 @@ fn do_print(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
|||
let pdf_path = match &output {
|
||||
Some(o) => o.clone(),
|
||||
None => {
|
||||
// Unique per call: concurrent prints of same-named documents must not share a spool file.
|
||||
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let tmp = std::env::temp_dir().to_string_lossy().into_owned();
|
||||
join(&tmp, &format!("{}-print-{}.pdf", crate::file_cmds::sanitize(&stem(&doc.name)), std::process::id()))
|
||||
join(&tmp, &format!("{}-print-{}-{n}.pdf", crate::file_cmds::sanitize(&stem(&doc.name)), std::process::id()))
|
||||
}
|
||||
};
|
||||
write_file(&pdf_path, &pdf)?;
|
||||
|
|
|
|||
|
|
@ -482,3 +482,16 @@ fn duplicating_the_background_unlocks_the_copy() {
|
|||
assert_eq!(copy.locks, photocraft_doc::Locks::default());
|
||||
assert!(st.doc.layers[0].locks.transparency);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_blending_channels() {
|
||||
let mut s = session_with_doc();
|
||||
let r = s.execute("layer.new.layer", json!({})).unwrap();
|
||||
let id = r["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setProps", json!({"channels": [true, true, false]})).unwrap();
|
||||
assert_eq!(s.active().unwrap().doc.layer(LayerId(id)).unwrap().excluded_channels, 0b100);
|
||||
let ins = crate::inspect::layer(s.active().unwrap().doc.layer(LayerId(id)).unwrap());
|
||||
assert_eq!(ins["channels"], json!([true, true, false, true]));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.active().unwrap().doc.layer(LayerId(id)).unwrap().excluded_channels, 0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -301,6 +301,10 @@ pub struct ChannelM {
|
|||
pub indicates: photocraft_doc::ColorIndicates,
|
||||
}
|
||||
|
||||
fn is_zero_u32(v: &u32) -> bool {
|
||||
*v == 0
|
||||
}
|
||||
|
||||
fn default_channel_color() -> Color {
|
||||
photocraft_doc::AlphaChannel::DEFAULT_COLOR
|
||||
}
|
||||
|
|
@ -337,7 +341,3 @@ mod channel_tests {
|
|||
assert_eq!(c.indicates, photocraft_doc::ColorIndicates::MaskedAreas);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_zero_u32(v: &u32) -> bool {
|
||||
*v == 0
|
||||
}
|
||||
|
|
|
|||
|
|
@ -321,6 +321,31 @@ impl Object {
|
|||
}
|
||||
}
|
||||
|
||||
/// Compresses `objects` on scoped worker threads (sequentially on wasm).
|
||||
fn par_compress<'a>(objects: &[(&'a String, &'a Object)]) -> Vec<(&'a String, Arc<Vec<u8>>)> {
|
||||
let threads = if cfg!(target_arch = "wasm32") { 1 } else { std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, 32) };
|
||||
if threads < 2 || objects.len() < 2 {
|
||||
return objects.iter().map(|(p, o)| (*p, Arc::new(o.compressed()))).collect();
|
||||
}
|
||||
let next = std::sync::atomic::AtomicUsize::new(0);
|
||||
std::thread::scope(|sc| {
|
||||
let hs: Vec<_> = (0..threads.min(objects.len()))
|
||||
.map(|_| {
|
||||
sc.spawn(|| {
|
||||
let mut out = Vec::new();
|
||||
loop {
|
||||
let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let Some((p, o)) = objects.get(i) else { break };
|
||||
out.push((*p, Arc::new(o.compressed())));
|
||||
}
|
||||
out
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
hs.into_iter().flat_map(|h| h.join().expect("compress worker")).collect()
|
||||
})
|
||||
}
|
||||
|
||||
impl PcraftWriter {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
|
|
@ -379,6 +404,9 @@ impl PcraftWriter {
|
|||
z.add(name, data)?;
|
||||
}
|
||||
let mut next = HashMap::with_capacity(p.objects.len());
|
||||
// New objects are compressed on all cores first (zstd dominates a full save).
|
||||
let todo: Vec<(&String, &Object)> = p.objects.iter().filter(|(path, _)| !self.compressed.contains_key(*path)).collect();
|
||||
let mut fresh: HashMap<&String, Arc<Vec<u8>>> = par_compress(&todo).into_iter().collect();
|
||||
for (path, obj) in &p.objects {
|
||||
let data = match self.compressed.get(path) {
|
||||
Some(d) => {
|
||||
|
|
@ -392,7 +420,7 @@ impl PcraftWriter {
|
|||
Object::Tile(..) => stats.tiles_written += 1,
|
||||
Object::Blob(_) => stats.blobs_written += 1,
|
||||
}
|
||||
Arc::new(obj.compressed())
|
||||
fresh.remove(path).unwrap_or_else(|| Arc::new(obj.compressed()))
|
||||
}
|
||||
};
|
||||
z.add(path, &data)?;
|
||||
|
|
|
|||
|
|
@ -2,91 +2,16 @@
|
|||
//! content scan cached by copy-on-write tile identity, so an effect layer's region costs a hash
|
||||
//! lookup per tile instead of a pixel scan per frame.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, Weak};
|
||||
use photocraft_doc::Layer;
|
||||
use photocraft_geom::Rect;
|
||||
#[cfg(test)]
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
use photocraft_doc::{Layer, LayerContent};
|
||||
use photocraft_geom::{Rect, TILE_SIZE};
|
||||
use photocraft_raster::{Surface, Tile};
|
||||
pub use photocraft_compose::bounds::content_bounds;
|
||||
|
||||
struct Entry {
|
||||
tile: Weak<Tile>,
|
||||
/// Content bounds inside the tile (tile-local), for one default pixel.
|
||||
default: Box<[u8]>,
|
||||
bounds: Option<(u16, u16, u16, u16)>,
|
||||
}
|
||||
|
||||
fn cache() -> &'static Mutex<HashMap<usize, Entry>> {
|
||||
static C: std::sync::OnceLock<Mutex<HashMap<usize, Entry>>> = std::sync::OnceLock::new();
|
||||
C.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
/// Pixels of `t` that differ from `dp` (tile-local half-open bounds).
|
||||
fn scan(t: &Tile, dp: &[u8]) -> Option<(u16, u16, u16, u16)> {
|
||||
let bpp = dp.len();
|
||||
let ts = TILE_SIZE as usize;
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0usize, 0usize);
|
||||
for (y, row) in t.bytes().chunks_exact(ts * bpp).enumerate() {
|
||||
let Some(first) = row.chunks_exact(bpp).position(|px| px != dp) else { continue };
|
||||
let last = ts - 1 - row.chunks_exact(bpp).rev().position(|px| px != dp).unwrap_or(0);
|
||||
x0 = x0.min(first);
|
||||
x1 = x1.max(last + 1);
|
||||
y0 = y0.min(y);
|
||||
y1 = y + 1;
|
||||
}
|
||||
(x0 != usize::MAX).then_some((x0 as u16, y0 as u16, x1 as u16, y1 as u16))
|
||||
}
|
||||
|
||||
/// `Surface::content_bounds`, cached per tile.
|
||||
pub fn content_bounds(s: &Surface) -> Rect {
|
||||
if s.tile_count() == 0 {
|
||||
return Rect::EMPTY;
|
||||
}
|
||||
let fmt = s.format();
|
||||
let mut dp = vec![0u8; fmt.bytes_per_pixel()];
|
||||
photocraft_raster::encode_pixel(&fmt, &s.default_pixel(), &mut dp);
|
||||
let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
|
||||
if c.len() > 1 << 16 {
|
||||
c.retain(|_, e| e.tile.strong_count() > 0);
|
||||
}
|
||||
let mut out = Rect::EMPTY;
|
||||
for (coord, t) in s.tiles() {
|
||||
let key = Arc::as_ptr(t) as usize;
|
||||
let hit = c.get(&key).filter(|e| e.tile.upgrade().is_some_and(|u| Arc::ptr_eq(&u, t)) && *e.default == *dp).map(|e| e.bounds);
|
||||
let b = match hit {
|
||||
Some(b) => b,
|
||||
None => {
|
||||
let b = scan(t, &dp);
|
||||
c.insert(key, Entry { tile: Arc::downgrade(t), default: dp.clone().into_boxed_slice(), bounds: b });
|
||||
b
|
||||
}
|
||||
};
|
||||
if let Some((x0, y0, x1, y1)) = b {
|
||||
let o = coord.rect();
|
||||
let r = Rect::new(o.x0 + x0 as i32, o.y0 + y0 as i32, o.x0 + x1 as i32, o.y0 + y1 as i32);
|
||||
out = if out.is_empty() { r } else { out.union(&r) };
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Bounds of a layer's own pixels (union over visible group children; the canvas for fill
|
||||
/// layers): `photocraft_compose::layer_bounds`.
|
||||
/// Bounds of a layer's own pixels: `photocraft_compose::layer_bounds`.
|
||||
pub fn layer_bounds(layer: &Layer, canvas: Rect) -> Rect {
|
||||
match &layer.content {
|
||||
LayerContent::Group(g) => g.children.iter().filter(|c| c.visible).fold(Rect::EMPTY, |acc, c| {
|
||||
let b = layer_bounds(c, canvas);
|
||||
if b.is_empty() {
|
||||
acc
|
||||
} else if acc.is_empty() {
|
||||
b
|
||||
} else {
|
||||
acc.union(&b)
|
||||
}
|
||||
}),
|
||||
LayerContent::Fill(_) => canvas,
|
||||
_ => layer.surface().map_or(Rect::EMPTY, content_bounds),
|
||||
}
|
||||
photocraft_compose::layer_bounds(layer, canvas)
|
||||
}
|
||||
|
||||
/// The region a layer's effect maps cover: its bounds grown by the effect reach, within the
|
||||
|
|
@ -96,15 +21,9 @@ pub fn effect_region(layer: &Layer, canvas: Rect) -> Rect {
|
|||
layer_bounds(layer, canvas).inflate(m).intersect(&canvas.inflate(m))
|
||||
}
|
||||
|
||||
/// Whether the layer's content is transparent outside its bounds (every surface it draws from
|
||||
/// has a transparent default pixel), so its effects can't change pixels outside the effect region.
|
||||
/// `photocraft_compose::transparent_outside`.
|
||||
pub fn transparent_outside(layer: &Layer) -> bool {
|
||||
match &layer.content {
|
||||
LayerContent::Group(g) => g.children.iter().filter(|c| c.visible).all(transparent_outside),
|
||||
// Fill layers cover the canvas; their region contains it.
|
||||
LayerContent::Fill(_) | LayerContent::Adjustment(_) => true,
|
||||
_ => layer.surface().is_none_or(|s| s.format().alpha && s.default_pixel().last().is_some_and(|a| *a <= 0.0)),
|
||||
}
|
||||
photocraft_compose::transparent_outside(layer)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -45,9 +45,10 @@ const F_REL: u32 = 512u; // effect paint: coverage relative to the layer
|
|||
const F_STROKE_OUT: u32 = 1024u; // effect paint: outside stroke band
|
||||
const F_FIRST: u32 = 2048u; // outside strokes: nothing accumulated yet
|
||||
const F_CHANNELS: u32 = 4096u; // lerp: per-channel weights in p0 (channel restrictions)
|
||||
const F_LAB: u32 = 65536u; // Lab document: Normal mixes in CIELAB
|
||||
const F_QUANT: u32 = 32768u; // lerp: A rounded to p0.x steps (adjustment results, integer docs)
|
||||
const F_ADD_DIFF: u32 = 16384u; // lerp: A + (B - C) premultiplied (clips on pass-through groups)
|
||||
const F_TEXT_GAMMA: u32 = 8192u; // blend / atop / fx merge: type layer, mix coverage at gamma 1.45
|
||||
const TEXT_GAMMA: f32 = 1.45;
|
||||
const F_TEXT_GAMMA: u32 = 8192u; // blend / atop / fx merge: type layer, mix coverage at gamma p4.w
|
||||
|
||||
@group(0) @binding(0) var<uniform> chunk: Chunk;
|
||||
@group(0) @binding(1) var<uniform> op: Op;
|
||||
|
|
@ -226,6 +227,12 @@ fn composite(mode: i32, b: vec4<f32>, s: vec4<f32>, opacity: f32) -> vec4<f32> {
|
|||
let ab = b.a;
|
||||
let as_ = s.a * opacity;
|
||||
if (as_ <= 0.0) { return b; }
|
||||
if ((op.flags & F_LAB) != 0u && mode == M_NORMAL && ab > 0.0) {
|
||||
// psblend::composite on Lab documents: Normal mixes in CIELAB.
|
||||
let ao = as_ + ab * (1.0 - as_);
|
||||
let m = srgb_to_lab(b.rgb) * (ab * (1.0 - as_) / ao) + srgb_to_lab(s.rgb) * (as_ / ao);
|
||||
return vec4(lab_to_srgb(m), ao);
|
||||
}
|
||||
let bl = blend_rgb(mode, b.rgb, s.rgb);
|
||||
let ao = as_ + ab * (1.0 - as_);
|
||||
if (ao <= 0.0) { return vec4(0.0); }
|
||||
|
|
@ -242,11 +249,42 @@ fn composite_g(mode: i32, b: vec4<f32>, s: vec4<f32>, opacity: f32, gamma_on: bo
|
|||
let bl = blend_rgb(mode, b.rgb, s.rgb);
|
||||
let ao = as_ + ab * (1.0 - as_);
|
||||
if (ao <= 0.0) { return vec4(0.0); }
|
||||
let g = TEXT_GAMMA;
|
||||
let g = op.p4.w; // psblend::text_gamma
|
||||
let pw = (1.0 - as_) * ab * pow(max(b.rgb, vec3(0.0)), vec3(g)) + (1.0 - ab) * as_ * pow(max(s.rgb, vec3(0.0)), vec3(g)) + as_ * ab * pow(max(bl, vec3(0.0)), vec3(g));
|
||||
return vec4(pow(pw / ao, vec3(1.0 / g)), ao);
|
||||
}
|
||||
|
||||
// photocraft_color::convert::{srgb_to_lab, lab_to_srgb} (D50, Bradford to sRGB).
|
||||
const D50: vec3<f32> = vec3(0.96422, 1.0, 0.82521);
|
||||
fn lab_f(t: f32) -> f32 {
|
||||
if (t > 0.008856452) { return pow(t, 1.0 / 3.0); } // (6/29)^3
|
||||
return t / 0.12841855 + 0.13793103; // 3 (6/29)^2, 4/29
|
||||
}
|
||||
fn lab_finv(t: f32) -> f32 {
|
||||
if (t > 0.20689656) { return t * t * t; } // 6/29
|
||||
return 0.12841855 * (t - 0.13793103);
|
||||
}
|
||||
fn srgb_to_lab(c: vec3<f32>) -> vec3<f32> {
|
||||
let lin = vec3(srgb_to_linear(c.r), srgb_to_linear(c.g), srgb_to_linear(c.b));
|
||||
let x = (0.436074 * lin.r + 0.385064 * lin.g + 0.143080 * lin.b) / D50.x;
|
||||
let y = (0.222504 * lin.r + 0.716878 * lin.g + 0.060618 * lin.b) / D50.y;
|
||||
let z = (0.013932 * lin.r + 0.097104 * lin.g + 0.714173 * lin.b) / D50.z;
|
||||
let fx = lab_f(x);
|
||||
let fy = lab_f(y);
|
||||
let fz = lab_f(z);
|
||||
return vec3(116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz));
|
||||
}
|
||||
fn lab_to_srgb(lab: vec3<f32>) -> vec3<f32> {
|
||||
let fy = (lab.x + 16.0) / 116.0;
|
||||
let fx = fy + lab.y / 500.0;
|
||||
let fz = fy - lab.z / 200.0;
|
||||
let xyz = vec3(lab_finv(fx) * D50.x, lab_finv(fy) * D50.y, lab_finv(fz) * D50.z);
|
||||
let r = 3.133856 * xyz.x - 1.616867 * xyz.y - 0.490615 * xyz.z;
|
||||
let g = -0.978768 * xyz.x + 1.916142 * xyz.y + 0.033454 * xyz.z;
|
||||
let b = 0.071945 * xyz.x - 0.228991 * xyz.y + 1.405243 * xyz.z;
|
||||
return vec3(linear_to_srgb(clamp(r, 0.0, 1.0)), linear_to_srgb(clamp(g, 0.0, 1.0)), linear_to_srgb(clamp(b, 0.0, 1.0)));
|
||||
}
|
||||
|
||||
fn dissolve_noise(d: vec2<i32>) -> f32 {
|
||||
var h = (bitcast<u32>(d.x) * 0x8da6b343u) ^ (bitcast<u32>(d.y) * 0xd8163841u) ^ 0x9e3779b9u;
|
||||
h = h ^ (h >> 15u);
|
||||
|
|
@ -513,13 +551,15 @@ fn gradient_t(d: vec2<i32>) -> f32 {
|
|||
let along = dx * c - dy * s;
|
||||
let across = dx * s + dy * c;
|
||||
let len = max(sqrt((c * w) * (c * w) + (s * h) * (s * h)), 1.0) * max(op.p0.y, 1e-3);
|
||||
// effects::gradient_t: Linear / Reflected span the bounds' chord along the angle.
|
||||
let chord = max(min(w / max(abs(c), 1e-6), h / max(abs(s), 1e-6)), 1.0) * max(op.p0.y, 1e-3);
|
||||
var t: f32;
|
||||
switch i32(op.p0.w) {
|
||||
case 1: { t = sqrt(dx * dx + dy * dy) / (len / 2.0); } // Radial
|
||||
case 2: { t = rem_euclid((a - atan2(-dy, dx)) / 6.28318530718, 1.0); } // Angle
|
||||
case 3: { t = abs(along / (len / 2.0)); } // Reflected
|
||||
case 3: { t = abs(along / (chord / 2.0)); } // Reflected
|
||||
case 4: { t = (abs(along) + abs(across)) / (len / 2.0); } // Diamond
|
||||
default: { t = along / len + 0.5; } // Linear
|
||||
default: { t = along / chord + 0.5; } // Linear
|
||||
}
|
||||
t = clamp(t, 0.0, 1.0);
|
||||
if (op.p0.z > 0.5) { t = 1.0 - t; }
|
||||
|
|
@ -613,6 +653,9 @@ fn fs_lerp(in: VOut) -> @location(0) vec4<f32> {
|
|||
let p = local(in.pos);
|
||||
let a = textureLoad(tex_a, p, 0);
|
||||
let b = textureLoad(tex_b, p, 0);
|
||||
if ((op.flags & F_QUANT) != 0u) {
|
||||
return floor(a * op.p0.x + 0.5) / op.p0.x;
|
||||
}
|
||||
if ((op.flags & F_CHANNELS) != 0u) {
|
||||
return a + (b - a) * op.p0;
|
||||
}
|
||||
|
|
@ -827,12 +870,14 @@ fn fs_mshift(in: VOut) -> @location(0) vec4<f32> {
|
|||
return mout(v);
|
||||
}
|
||||
|
||||
// effects::dilate: max(A, clamp(r + 0.5 - dist_outside)), B = distances, p0.x = r.
|
||||
// effects::dilate: inside (distance < 0) min(A + r, 1), else max(A, clamp(r + 0.5 - dist_outside));
|
||||
// B = distances, p0.x = r.
|
||||
@fragment
|
||||
fn fs_mdilate(in: VOut) -> @location(0) vec4<f32> {
|
||||
let p = local(in.pos);
|
||||
let a = textureLoad(tex_a, p, 0).r;
|
||||
let d = textureLoad(tex_b, p, 0).r;
|
||||
if (d < 0.0) { return mout(min(a + op.p0.x, 1.0)); }
|
||||
return mout(max(a, clamp(op.p0.x + 0.5 - d, 0.0, 1.0)));
|
||||
}
|
||||
|
||||
|
|
@ -922,7 +967,7 @@ fn fs_mbevelshade(in: VOut) -> @location(0) vec4<f32> {
|
|||
var region = s;
|
||||
if (op.p1.y > 0.5) {
|
||||
// Outside parts paint at full strength wherever the shape isn't opaque (edge pixels too).
|
||||
region = select(0.0, 1.0, s < 1.0 - INSIDE_EPS && textureLoad(tex_a, p, 0).r > 0.0);
|
||||
region = select(0.0, 1.0, s < 1.0 - INSIDE_EPS && textureLoad(tex_a, p, 0).r > 1e-5); // effects::BEVEL_H_EPS
|
||||
}
|
||||
let k = shade - se;
|
||||
var v = 0.0;
|
||||
|
|
@ -935,6 +980,17 @@ fn fs_mbevelshade(in: VOut) -> @location(0) vec4<f32> {
|
|||
return mout(v);
|
||||
}
|
||||
|
||||
// Bevel texture (`effects::bevel_height`): A + k × luminance of the pattern (× its alpha; 1 −
|
||||
// that when inverted); p0 = (k, invert), placement in p3 / p4 as for pattern paints.
|
||||
@fragment
|
||||
fn fs_mbeveltex(in: VOut) -> @location(0) vec4<f32> {
|
||||
let p = local(in.pos);
|
||||
let c = pattern_sample(doc_px(p));
|
||||
var l = (0.299 * c.r + 0.587 * c.g + 0.114 * c.b) * c.a;
|
||||
if (op.p0.y > 0.5) { l = 1.0 - l; }
|
||||
return mout(textureLoad(tex_a, p, 0).r + op.p0.x * l);
|
||||
}
|
||||
|
||||
// Stroke band from a distance map: clamp(width + 0.5 - d), p0.x = width.
|
||||
@fragment
|
||||
fn fs_mstroke(in: VOut) -> @location(0) vec4<f32> {
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ use std::sync::Arc;
|
|||
|
||||
use photocraft_compose::effects::FieldKind;
|
||||
use photocraft_compose::effects::BevelPaint;
|
||||
use photocraft_doc::{BevelTechnique, Contour, Effect, GlobalLight, GlowSource, GlowTechnique, Layer, LayerContent};
|
||||
use photocraft_doc::{BevelTechnique, Contour, Pattern, Effect, GlobalLight, GlowSource, GlowTechnique, Layer, LayerContent};
|
||||
use photocraft_geom::{Rect, TileCoord};
|
||||
use photocraft_raster::{Surface, Tile};
|
||||
|
||||
|
|
@ -53,6 +53,11 @@ pub(crate) struct Stage {
|
|||
pub lut: Option<Arc<Vec<f32>>>,
|
||||
pub p0: [f32; 4],
|
||||
pub p1: [f32; 4],
|
||||
/// Pattern placement (`p3`: origin, rotation) and `p4` (inverse scale, tile size) for stages
|
||||
/// that sample `pattern` (bevel texture).
|
||||
pub p3: [f32; 4],
|
||||
pub p4: [f32; 4],
|
||||
pub pattern: Option<Pattern>,
|
||||
/// How far (px) the stage reads from its inputs around the output pixel.
|
||||
pub radius: i32,
|
||||
/// Final map index this stage writes (else a temporary).
|
||||
|
|
@ -164,7 +169,7 @@ struct B {
|
|||
}
|
||||
|
||||
fn stage(kernel: Kernel, a: Option<In>, b: Option<In>, p0: [f32; 4], radius: i32) -> Stage {
|
||||
Stage { kernel, a, b, s: None, lut: None, p0, p1: [0.0; 4], radius, out: None }
|
||||
Stage { kernel, a, b, s: None, lut: None, p0, p1: [0.0; 4], p3: [0.0; 4], p4: [0.0; 4], pattern: None, radius, out: None }
|
||||
}
|
||||
|
||||
impl B {
|
||||
|
|
@ -250,8 +255,15 @@ fn offset(angle: f32, distance: f32) -> (f32, f32) {
|
|||
((-a.cos() * distance).round(), (a.sin() * distance).round())
|
||||
}
|
||||
|
||||
/// The map program of one enabled effect (`build_maps`).
|
||||
/// The map program of one enabled effect (`build_maps`), without pattern sources.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn program(e: &Effect, light: &GlobalLight, vector_shape: bool) -> MapProgram {
|
||||
program_with(e, light, vector_shape, &[], (0.0, 0.0))
|
||||
}
|
||||
|
||||
/// The map program of one enabled effect (`build_maps`); bevel textures tile `patterns` from
|
||||
/// `anchor` (`effects::TextureCtx`).
|
||||
pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool, patterns: &[Pattern], anchor: (f64, f64)) -> MapProgram {
|
||||
let mut b = B { stages: Vec::new(), fields: Vec::new() };
|
||||
let maps = match e {
|
||||
Effect::DropShadow(s) | Effect::InnerShadow(s) => {
|
||||
|
|
@ -260,7 +272,7 @@ pub(crate) fn program(e: &Effect, light: &GlobalLight, vector_shape: bool) -> Ma
|
|||
let angle = if s.use_global_light { light.angle } else { s.angle };
|
||||
let (dx, dy) = offset(angle, s.distance);
|
||||
let src = b.shift(In::Shape, dx, dy, if inner { 1.0 } else { 0.0 }, inner);
|
||||
let r = s.size * s.spread;
|
||||
let (r, bw) = photocraft_compose::effects::spread_split(s.size, s.spread);
|
||||
let mut m = src;
|
||||
if r > 0.0 {
|
||||
// dist_outside of the shifted map is the shifted field (integer offsets; shifted-in
|
||||
|
|
@ -269,7 +281,7 @@ pub(crate) fn program(e: &Effect, light: &GlobalLight, vector_shape: bool) -> Ma
|
|||
let d = if dx == 0.0 && dy == 0.0 { f } else { b.push(stage(Kernel::MShift, Some(f), None, [dx, dy, if inner { -0.5 } else { 1e10 }, 0.0], dx.abs().max(dy.abs()) as i32)) };
|
||||
m = b.dilate(src, d, r);
|
||||
}
|
||||
let m = b.blur(m, s.size * (1.0 - s.spread));
|
||||
let m = b.blur(m, bw);
|
||||
b.finish(m, None, false, &s.contour, inner, 0);
|
||||
1
|
||||
}
|
||||
|
|
@ -289,13 +301,13 @@ pub(crate) fn program(e: &Effect, light: &GlobalLight, vector_shape: bool) -> Ma
|
|||
GlowTechnique::Softer => {
|
||||
// Inner glows (edge, and centre as 1 - the edge result) spread 1 - alpha.
|
||||
let src = if inner { b.shift(In::Shape, 0.0, 0.0, 0.0, true) } else { In::Shape };
|
||||
let r = g.size * g.spread;
|
||||
let (r, bw) = photocraft_compose::effects::spread_split(g.size, g.spread);
|
||||
let mut m = src;
|
||||
if r > 0.0 {
|
||||
let d = b.field(if inner { FieldKind::OutsideInverse } else { FieldKind::Outside }, r);
|
||||
m = b.dilate(src, d, r);
|
||||
}
|
||||
let m = b.blur(m, g.size * (1.0 - g.spread));
|
||||
let m = b.blur(m, bw);
|
||||
b.finish_lut(m, None, center, photocraft_compose::effects::glow_lut(g), inner, 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -336,6 +348,19 @@ pub(crate) fn program(e: &Effect, light: &GlobalLight, vector_shape: bool) -> Ma
|
|||
st.lut = Some(Arc::new(lut));
|
||||
h = b.push(st);
|
||||
}
|
||||
if let Some(t) = &bv.texture
|
||||
&& let Some(pat) = photocraft_doc::pattern::find(patterns, &t.id, &t.name).filter(|p| !p.is_empty())
|
||||
{
|
||||
// Texture element (`effects::bevel_height`): luminance × depth / unit added.
|
||||
let unit = if bv.depth.abs() > 1e-6 { (g.depth / bv.depth).abs().max(1e-3) } else { g.width.max(1.0) };
|
||||
let pl = photocraft_compose::pattern::Placement::anchored(anchor, t.link, t.phase, t.scale, 0.0);
|
||||
let (origin, cs, inv) = pl.parts();
|
||||
let mut st = stage(Kernel::MBevelTex, Some(h), None, [t.depth / unit, f32::from(u8::from(t.invert)), 0.0, 0.0], 0);
|
||||
st.p3 = [origin.0 as f32, origin.1 as f32, cs.0 as f32, cs.1 as f32];
|
||||
st.p4 = [inv as f32, pat.width as f32, pat.height as f32, 0.0];
|
||||
st.pattern = Some(pat.clone());
|
||||
h = b.push(st);
|
||||
}
|
||||
if bv.soften >= 1.0 {
|
||||
h = b.conv(h, photocraft_compose::effects::tent_kernel(bv.soften));
|
||||
}
|
||||
|
|
@ -384,9 +409,15 @@ pub(crate) fn program(e: &Effect, light: &GlobalLight, vector_shape: bool) -> Ma
|
|||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
for s in &b.stages {
|
||||
(format!("{:?}", s.kernel), s.a, s.b, s.s, s.radius, s.out).hash(&mut h);
|
||||
for v in s.p0.iter().chain(&s.p1) {
|
||||
for v in s.p0.iter().chain(&s.p1).chain(&s.p3).chain(&s.p4) {
|
||||
v.to_bits().hash(&mut h);
|
||||
}
|
||||
if let Some(p) = &s.pattern {
|
||||
(&p.id, &p.name, p.width, p.height).hash(&mut h);
|
||||
for (c, t) in p.surface.tiles() {
|
||||
(c.tx, c.ty, Arc::as_ptr(t) as usize).hash(&mut h);
|
||||
}
|
||||
}
|
||||
if let Some(l) = &s.lut {
|
||||
for v in l.iter() {
|
||||
v.to_bits().hash(&mut h);
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@
|
|||
//! Vector masks (rasterised once per mask state into a combined mask texture), layers clipped to
|
||||
//! pass-through groups, stroked shapes with clipped layers (fill and stroke split once per shape
|
||||
//! state), pattern fills and artboards are planned like everything else. What remains
|
||||
//! (Multichannel documents, bevels with a texture, documents or effect regions larger than the
|
||||
//! device's texture limit) returns [`Unsupported`]; callers fall back to the CPU compositor.
|
||||
//! (Multichannel documents, documents or effect regions larger than the device's texture limit)
|
||||
//! returns [`Unsupported`]; callers fall back to the CPU compositor.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod bounds;
|
||||
|
|
@ -397,7 +397,7 @@ impl Compositor {
|
|||
return Err(Unsupported(format!("effect region of `{}` larger than the GPU texture limit ({})", f.layer.name, self.max_dim)));
|
||||
}
|
||||
for e in f.layer.effects.items.iter().filter(|e| e.enabled()) {
|
||||
for s in fx::program(e, &doc.global_light, false).stages {
|
||||
for s in fx::program_with(e, &doc.global_light, false, &doc.patterns, (0.0, 0.0)).stages {
|
||||
if s.lut.as_ref().is_some_and(|l| l.len() > 4096) {
|
||||
return Err(Unsupported(format!("effect blur on `{}` too wide for the GPU path", f.layer.name)));
|
||||
}
|
||||
|
|
@ -804,7 +804,8 @@ impl Compositor {
|
|||
|
||||
// Programs, and the distance fields they read (max reach per field).
|
||||
let vector_shape = matches!(layer.content, LayerContent::Shape(_));
|
||||
let progs: Vec<fx::MapProgram> = layer.effects.items.iter().filter(|e| e.enabled()).map(|e| fx::program(e, &doc.global_light, vector_shape)).collect();
|
||||
let anchor = layer.effects.reference.unwrap_or((f64::from(f.bounds.x0), f64::from(f.bounds.y0)));
|
||||
let progs: Vec<fx::MapProgram> = layer.effects.items.iter().filter(|e| e.enabled()).map(|e| fx::program_with(e, &doc.global_light, vector_shape, &doc.patterns, anchor)).collect();
|
||||
let mut want: HashMap<FieldKind, i32> = HashMap::new();
|
||||
for (k, r) in progs.iter().flat_map(|p| p.fields.iter()) {
|
||||
let w = want.entry(*k).or_insert(0);
|
||||
|
|
@ -859,6 +860,7 @@ impl Compositor {
|
|||
/// Record the passes of `prog` (maps of item `item` of `layer`) recomputing damage `d`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn run_program(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, encoder: &mut wgpu::CommandEncoder, layer: LayerId, item: usize, prog: &fx::MapProgram, d: Rect, stats: &mut Stats) {
|
||||
let pattern_views: Vec<Option<wgpu::TextureView>> = prog.stages.iter().map(|s| s.pattern.as_ref().map(|p| self.pattern_view(device, queue, p))).collect();
|
||||
let e = &self.fx[&layer];
|
||||
let region = e.region;
|
||||
let shape = e.shape.view.clone();
|
||||
|
|
@ -895,6 +897,8 @@ impl Compositor {
|
|||
let mut p = plan::Pass::new(s.kernel, 0);
|
||||
p.params[0] = s.p0;
|
||||
p.params[1] = s.p1;
|
||||
p.params[3] = s.p3;
|
||||
p.extra = s.p4;
|
||||
draws.push(MapDraw {
|
||||
kernel: s.kernel,
|
||||
format,
|
||||
|
|
@ -902,7 +906,7 @@ impl Compositor {
|
|||
scissor: [(w.x0 - region.x0) as u32, (w.y0 - region.y0) as u32, w.width(), w.height()],
|
||||
space: [region.x0, region.y0, region.width() as i32, region.height() as i32],
|
||||
op: op_words(&p, None, None, None),
|
||||
views: [view_of(s.a), view_of(s.b), view_of(s.s), None, None, None, None, None],
|
||||
views: [view_of(s.a), view_of(s.b), view_of(s.s), None, None, None, None, pattern_views[i].clone()],
|
||||
lut: s.lut.clone(),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ pub enum Kernel {
|
|||
MBevelH,
|
||||
MBevelShade,
|
||||
MStroke,
|
||||
MBevelTex,
|
||||
}
|
||||
|
||||
impl Kernel {
|
||||
|
|
@ -87,6 +88,7 @@ impl Kernel {
|
|||
Kernel::MBevelH => "fs_mbevelh",
|
||||
Kernel::MBevelShade => "fs_mbevelshade",
|
||||
Kernel::MStroke => "fs_mstroke",
|
||||
Kernel::MBevelTex => "fs_mbeveltex",
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -102,11 +104,12 @@ impl Kernel {
|
|||
| Kernel::MBevelH
|
||||
| Kernel::MBevelShade
|
||||
| Kernel::MStroke
|
||||
| Kernel::MBevelTex
|
||||
)
|
||||
}
|
||||
|
||||
/// Every kernel with a pipeline.
|
||||
pub const DRAWN: [Kernel; 20] = [
|
||||
pub const DRAWN: [Kernel; 21] = [
|
||||
Kernel::Content,
|
||||
Kernel::Mask,
|
||||
Kernel::Blend,
|
||||
|
|
@ -127,6 +130,7 @@ impl Kernel {
|
|||
Kernel::MBevelH,
|
||||
Kernel::MBevelShade,
|
||||
Kernel::MStroke,
|
||||
Kernel::MBevelTex,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -283,11 +287,13 @@ pub struct DocCtx<'a> {
|
|||
pub patterns: &'a [Pattern],
|
||||
/// Colour mode (channel restrictions name its channels).
|
||||
pub mode: photocraft_color::ColorMode,
|
||||
/// Sample depth (adjustment results are rounded to it).
|
||||
pub depth: photocraft_color::SampleType,
|
||||
}
|
||||
|
||||
impl<'a> DocCtx<'a> {
|
||||
pub fn of(doc: &'a Document) -> Self {
|
||||
DocCtx { canvas: doc.bounds(), transfer: Transfer::for_mode(doc.mode), light: doc.global_light, patterns: &doc.patterns, mode: doc.mode }
|
||||
DocCtx { canvas: doc.bounds(), transfer: Transfer::for_mode(doc.mode), light: doc.global_light, patterns: &doc.patterns, mode: doc.mode, depth: doc.depth }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -335,11 +341,16 @@ pub const F_STROKE_OUT: u32 = 1024;
|
|||
pub const F_FIRST: u32 = 2048;
|
||||
/// `Lerp` per channel: weights in `p0` (channel restrictions), no opacity or mask.
|
||||
pub const F_CHANNELS: u32 = 4096;
|
||||
/// Lab document: Normal blending mixes in CIELAB (`psblend::LAB_MIX`).
|
||||
pub const F_LAB: u32 = 65536;
|
||||
/// `Lerp`: A rounded to `p0.x` steps per unit (adjustment results on integer documents).
|
||||
pub const F_QUANT: u32 = 32768;
|
||||
/// `Lerp` as A + (B − C) premultiplied (layers clipped to pass-through groups).
|
||||
pub const F_ADD_DIFF: u32 = 16384;
|
||||
/// Blend / Atop / FxMerge of a type layer: coverage mixed at `psblend::TEXT_GAMMA`.
|
||||
pub const F_TEXT_GAMMA: u32 = 8192;
|
||||
|
||||
/// `F_TEXT_GAMMA` for type layers while text gamma blending is on; the gamma goes in `p4.w`.
|
||||
fn gamma_flag(layer: &Layer) -> u32 {
|
||||
if photocraft_compose::text_gamma(layer) != 1.0 { F_TEXT_GAMMA } else { 0 }
|
||||
}
|
||||
|
|
@ -383,6 +394,9 @@ impl<'a> Planner<'a> {
|
|||
fn emit(&mut self, mut pass: Pass<'a>) -> Slot {
|
||||
let dst = self.alloc();
|
||||
pass.dst = dst;
|
||||
if self.cx.mode == photocraft_color::ColorMode::Lab {
|
||||
pass.flags |= F_LAB;
|
||||
}
|
||||
let (a, b, c, d) = (pass.a, pass.b, pass.c, pass.d);
|
||||
self.passes.push(pass);
|
||||
for s in [a, b, c, d].into_iter().flatten() {
|
||||
|
|
@ -406,6 +420,14 @@ impl<'a> Planner<'a> {
|
|||
let clipped = &layers[i + 1..j];
|
||||
if base.visible {
|
||||
backdrop = self.layer(base, clipped, backdrop)?;
|
||||
if let (LayerContent::Adjustment(_), Some(q)) = (&base.content, photocraft_compose::adjustment_quantum(self.cx.depth)) {
|
||||
// compose: adjustment results are rounded to the document's depth.
|
||||
let mut p = Pass::new(Kernel::Lerp, 0);
|
||||
p.a = Some(backdrop);
|
||||
p.flags = F_QUANT;
|
||||
p.params[0] = [q, 0.0, 0.0, 0.0];
|
||||
backdrop = self.emit(p);
|
||||
}
|
||||
}
|
||||
i = j.max(i + 1);
|
||||
}
|
||||
|
|
@ -601,6 +623,7 @@ impl<'a> Planner<'a> {
|
|||
p.mode = layer.blend;
|
||||
p.opacity = opacity;
|
||||
p.flags = gamma_flag(layer);
|
||||
p.extra[3] = photocraft_compose::text_gamma(layer);
|
||||
Ok(self.emit(p))
|
||||
}
|
||||
|
||||
|
|
@ -738,6 +761,7 @@ impl<'a> Planner<'a> {
|
|||
p.mode = layer.blend;
|
||||
p.opacity = opacity;
|
||||
p.flags = gamma_flag(layer);
|
||||
p.extra[3] = photocraft_compose::text_gamma(layer);
|
||||
Ok(self.emit(p))
|
||||
}
|
||||
|
||||
|
|
@ -756,12 +780,9 @@ impl<'a> Planner<'a> {
|
|||
/// (atop = true: effects over the base treated as opaque, keeping the base's alpha).
|
||||
/// Consumes `backdrop`.
|
||||
fn effects(&mut self, layer: &'a Layer, clipped: &[&'a Layer], backdrop: Slot, atop: bool) -> Result<Slot, Unsupported> {
|
||||
if layer.effects.items.iter().any(|e| matches!(e, Effect::BevelEmboss(b) if b.enabled && b.texture.is_some())) {
|
||||
return Err(Unsupported(format!("bevel texture on `{}` (rendered on the CPU)", layer.name)));
|
||||
}
|
||||
let canvas = self.cx.canvas;
|
||||
let region = bounds::effect_region(layer, canvas);
|
||||
let sb = bounds::layer_bounds(layer, canvas);
|
||||
let sb = photocraft_compose::paint_bounds(layer).unwrap_or_else(|| bounds::layer_bounds(layer, canvas));
|
||||
let clip = if bounds::transparent_outside(layer) { region } else { canvas };
|
||||
let mut content = self.content(layer)?;
|
||||
if !matches!(layer.content, LayerContent::Group(_))
|
||||
|
|
@ -925,6 +946,7 @@ impl<'a> Planner<'a> {
|
|||
p.mode = if layer.blend == BlendMode::PassThrough { BlendMode::Normal } else { layer.blend };
|
||||
p.opacity = layer.opacity;
|
||||
p.flags = if atop { F_ATOP } else { 0 } | gamma_flag(layer);
|
||||
p.extra[3] = photocraft_compose::text_gamma(layer);
|
||||
p.clip = Some(clip);
|
||||
let merged = self.emit(p);
|
||||
self.release(content);
|
||||
|
|
|
|||
|
|
@ -159,6 +159,14 @@ fn lookup(n: usize, tetrahedral: bool, dither: bool) -> Adjustment {
|
|||
#[test]
|
||||
fn adjustment_layers() {
|
||||
let Some(mut g) = gpu() else { return };
|
||||
// 16-bit and float documents (adjustment results round to 1/32768 resp. not at all).
|
||||
for depth in [SampleType::U16, SampleType::F32] {
|
||||
let mut d = Document::new("t", Size::new(40, 30), ColorMode::Rgb, depth);
|
||||
let fmt = d.pixel_format();
|
||||
d.layers.push(noise_layer("bg", fmt, Rect::from_xywh(0, 0, 40, 30), 71, 0.5));
|
||||
d.layers.push(Layer::new("ex", LayerContent::Adjustment(Adjustment::Exposure { exposure: 0.4, offset: 0.01, gamma: 1.1 })));
|
||||
check(&mut g, &d, &format!("adjustment {depth:?}"));
|
||||
}
|
||||
for adj in adjustments() {
|
||||
for (mode, opacity, masked) in [(BlendMode::Normal, 1.0, false), (BlendMode::Multiply, 0.7, true)] {
|
||||
let mut d = base_doc(48, 40);
|
||||
|
|
@ -464,6 +472,10 @@ fn effect_cases() -> Vec<(&'static str, Vec<Effect>)> {
|
|||
("bevel pillow chisel hard", vec![Effect::BevelEmboss(Bevel { technique: BevelTechnique::ChiselHard, ..bevel(BevelStyle::PillowEmboss, false, 7.0, 0.0) })]),
|
||||
("bevel emboss smooth wide", vec![Effect::BevelEmboss(bevel(BevelStyle::Emboss, true, 21.0, 0.0))]),
|
||||
("bevel stroke emboss", vec![Effect::BevelEmboss(bevel(BevelStyle::StrokeEmboss, true, 6.0, 0.0))]),
|
||||
("bevel texture", vec![Effect::BevelEmboss(Bevel {
|
||||
texture: Some(photocraft_doc::BevelTexture { name: "checker".into(), id: String::new(), scale: 1.4, depth: -1.5, invert: true, link: true, phase: (2.0, 1.0) }),
|
||||
..bevel(BevelStyle::InnerBevel, true, 7.0, 1.0)
|
||||
})]),
|
||||
("bevel contour", vec![Effect::BevelEmboss(Bevel { contour: Some(photocraft_doc::BevelContour { contour: contour(), range: 0.6, anti_alias: false }), ..bevel(BevelStyle::Emboss, true, 9.0, 1.0) })]),
|
||||
("bevel contour own light", vec![Effect::BevelEmboss(bevel_contour)]),
|
||||
("satin", vec![Effect::Satin(satin)]),
|
||||
|
|
@ -775,6 +787,12 @@ fn type_layers_blend_with_text_gamma() {
|
|||
c.blend = mode;
|
||||
d.layers.push(c);
|
||||
check(&mut g, &d, &format!("text {mode:?}"));
|
||||
// Another gamma (Color Settings), and off.
|
||||
for gamma in [1.8, 1.0] {
|
||||
photocraft_compose::psblend::set_text_gamma(gamma);
|
||||
check(&mut g, &d, &format!("text {mode:?} gamma {gamma}"));
|
||||
}
|
||||
photocraft_compose::psblend::set_text_gamma(photocraft_compose::psblend::TEXT_GAMMA);
|
||||
}
|
||||
// With effects (the merge of the layer onto its exterior effects).
|
||||
let mut d = fx_doc(96, 80, SampleType::U8);
|
||||
|
|
@ -870,7 +888,7 @@ fn artboards() {
|
|||
let mut ab = Layer::group("Artboard", vec![child, fx]);
|
||||
ab.blend = blend;
|
||||
if let LayerContent::Group(gr) = &mut ab.content {
|
||||
gr.artboard = Some(Artboard { rect: Rect::new(12, 6, 60, 40), background: bg.clone(), preset: String::new() });
|
||||
gr.artboard = Some(Artboard { rect: Rect::new(12, 6, 60, 40), background: bg, preset: String::new() });
|
||||
}
|
||||
d.layers.push(ab);
|
||||
// A second, partly off-canvas board.
|
||||
|
|
@ -913,3 +931,33 @@ fn pattern_fill_layers() {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn lab_documents_mix_in_lab() {
|
||||
let Some(mut g) = gpu() else { return };
|
||||
for depth in [SampleType::U8, SampleType::U16] {
|
||||
let mut d = Document::new("lab", Size::new(48, 40), ColorMode::Lab, depth);
|
||||
let fmt = d.pixel_format();
|
||||
d.layers.push(noise_layer("bg", fmt, Rect::new(0, 0, 48, 40), 81, 0.7));
|
||||
let mut top = noise_layer("top", fmt, Rect::new(4, 4, 44, 36), 82, 0.0);
|
||||
top.opacity = 0.8;
|
||||
d.layers.push(top);
|
||||
let mut m = noise_layer("mul", fmt, Rect::new(10, 2, 30, 38), 83, 0.3);
|
||||
m.blend = BlendMode::Multiply;
|
||||
d.layers.push(m);
|
||||
let mut fx = blob("fx", fmt, 24.0, 20.0, 12.0, [0.7, 0.3, 0.2]);
|
||||
fx.effects.items = vec![Effect::DropShadow(shadow(BlendMode::Normal, 0.6, 120.0, 4.0, 5.0, 0.0))];
|
||||
d.layers.push(fx);
|
||||
fx_check(&mut g, &d, &format!("lab {depth:?}"));
|
||||
}
|
||||
// The mix really is in Lab: a half-transparent edge differs from an sRGB mix.
|
||||
let mut d = Document::new("lab", Size::new(2, 1), ColorMode::Lab, SampleType::U8);
|
||||
let fmt = d.pixel_format();
|
||||
let mut a = Layer::raster("a", fmt);
|
||||
a.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 1), &photocraft_raster::from_rgba(&fmt, [0.0, 0.0, 1.0, 1.0]));
|
||||
let mut b = Layer::raster("b", fmt);
|
||||
b.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 1), &photocraft_raster::from_rgba(&fmt, [1.0, 1.0, 0.0, 0.5]));
|
||||
d.layers = vec![a, b];
|
||||
let p = photocraft_compose::flatten(&d).px[0];
|
||||
assert!((p[0] - 0.5).abs() > 0.05 || (p[2] - 0.5).abs() > 0.05, "{p:?}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -287,7 +287,8 @@ fn corpus_summary(root: &std::path::Path) {
|
|||
collect(root, &mut files);
|
||||
files.sort();
|
||||
const TOL: f32 = 2.0 / 255.0;
|
||||
let row = |p: &std::path::PathBuf| -> (String, Option<(f32, f32)>) {
|
||||
type Row = (String, Option<(f32, f32)>);
|
||||
let row = |p: &std::path::PathBuf| -> Row {
|
||||
let name = p.strip_prefix(root).unwrap_or(p).display().to_string();
|
||||
let Ok(bytes) = std::fs::read(p) else { return (name, None) };
|
||||
let Ok(file) = photocraft_psd::PsdFile::from_bytes(&bytes) else { return (name, None) };
|
||||
|
|
@ -310,7 +311,7 @@ fn corpus_summary(root: &std::path::Path) {
|
|||
};
|
||||
// Files in parallel (a few threads; flatten itself is tile-parallel).
|
||||
let next = std::sync::atomic::AtomicUsize::new(0);
|
||||
let mut rows: Vec<(usize, (String, Option<(f32, f32)>))> = std::thread::scope(|sc| {
|
||||
let mut rows: Vec<(usize, Row)> = std::thread::scope(|sc| {
|
||||
let hs: Vec<_> = (0..4)
|
||||
.map(|_| {
|
||||
sc.spawn(|| {
|
||||
|
|
@ -327,7 +328,7 @@ fn corpus_summary(root: &std::path::Path) {
|
|||
hs.into_iter().flat_map(|h| h.join().unwrap()).collect()
|
||||
});
|
||||
rows.sort_by_key(|r| r.0);
|
||||
let rows: Vec<(String, Option<(f32, f32)>)> = rows.into_iter().map(|r| r.1).collect();
|
||||
let rows: Vec<Row> = rows.into_iter().map(|r| r.1).collect();
|
||||
let (mut pass, mut diff, mut skip) = (0, 0, 0);
|
||||
for (name, r) in &rows {
|
||||
match r {
|
||||
|
|
|
|||
|
|
@ -81,7 +81,9 @@ pub fn color_from_desc(d: &Descriptor) -> Option<Color> {
|
|||
let id = d.class_id.as_bytes();
|
||||
let g = |k: &str| num(d.get(k)).map(|v| v as f32);
|
||||
Some(match id {
|
||||
b"RGBC" => Color::rgb(g("Rd ")? / 255.0, g("Grn ")? / 255.0, g("Bl ").or_else(|| g("Bl "))? / 255.0),
|
||||
// Newer Photoshop writes 0..1 floats (`RGBC` with redFloat / greenFloat / blueFloat).
|
||||
b"RGBC" if d.get("redFloat").is_some() => Color::rgb(g("redFloat")?, g("greenFloat")?, g("blueFloat")?),
|
||||
b"RGBC" => Color::rgb(g("Rd ")? / 255.0, g("Grn ")? / 255.0, g("Bl ")? / 255.0),
|
||||
b"CMYC" => Color {
|
||||
mode: ColorMode::Cmyk,
|
||||
c: [g("Cyn ")? / 100.0, g("Mgnt")? / 100.0, g("Ylw ")? / 100.0, g("Blck")? / 100.0],
|
||||
|
|
@ -510,6 +512,13 @@ mod tests {
|
|||
mod more_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn float_rgb_colors_parse() {
|
||||
let d = Descriptor::new("RGBC").with("redFloat", Value::Double(1.0)).with("greenFloat", Value::Double(0.5)).with("blueFloat", Value::Double(0.25));
|
||||
let c = color_from_desc(&d).unwrap();
|
||||
assert_eq!(c.to_rgb(), [1.0, 0.5, 0.25]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_transform_from_sold() {
|
||||
let pts = [10.0, 20.0, 110.0, 20.0, 110.0, 70.0, 10.0, 70.0].map(Value::Double).to_vec();
|
||||
|
|
|
|||
|
|
@ -120,18 +120,38 @@ pub fn document_to_image(doc: &Document, warnings: &mut Vec<String>) -> Result<I
|
|||
icc = Some(photocraft_cms::Builtin::Srgb.profile().to_bytes().to_vec());
|
||||
}
|
||||
let layout = layout_for(if gray { ColorMode::Grayscale } else { ColorMode::Rgb }, !opaque);
|
||||
let mut data = Vec::with_capacity(n * layout.channels());
|
||||
for p in &buf.px {
|
||||
if gray {
|
||||
data.push(photocraft_color::convert::rgb_to_gray([p[0], p[1], p[2]]));
|
||||
} else {
|
||||
data.extend_from_slice(&p[..3]);
|
||||
}
|
||||
if !opaque {
|
||||
data.push(p[3]);
|
||||
// Quantised straight from the composite in bands on all cores (the same rounding as
|
||||
// `Image::from_normalized`, without a full-size f32 copy).
|
||||
let cs = csample(fmt.sample);
|
||||
let parts = crate::pixels::par_map(crate::pixels::bands(n), |range| {
|
||||
let mut out = Vec::with_capacity(range.len() * layout.channels() * cs.bytes());
|
||||
let mut put = |v: f32| {
|
||||
let v = if v.is_nan() { 0.0 } else { v };
|
||||
match cs {
|
||||
CSample::U8 => out.push((v.clamp(0.0, 1.0) * 255.0).round() as u8),
|
||||
CSample::U16 => out.extend_from_slice(&((v.clamp(0.0, 1.0) * 65535.0).round() as u16).to_ne_bytes()),
|
||||
CSample::F16 | CSample::F32 => out.extend_from_slice(&v.to_ne_bytes()),
|
||||
}
|
||||
};
|
||||
for p in &buf.px[range] {
|
||||
if gray {
|
||||
put(photocraft_color::convert::rgb_to_gray([p[0], p[1], p[2]]));
|
||||
} else {
|
||||
put(p[0]);
|
||||
put(p[1]);
|
||||
put(p[2]);
|
||||
}
|
||||
if !opaque {
|
||||
put(p[3]);
|
||||
}
|
||||
}
|
||||
out
|
||||
});
|
||||
let mut data = Vec::with_capacity(parts.iter().map(Vec::len).sum());
|
||||
for part in parts {
|
||||
data.extend_from_slice(&part);
|
||||
}
|
||||
Image::from_normalized(w, h, layout, csample(fmt.sample), &data)?
|
||||
Image::from_raw(w, h, layout, cs, data)?
|
||||
};
|
||||
let meta = codecs::Metadata {
|
||||
exif: doc.metadata.exif.as_ref().map(|e| e.to_vec()),
|
||||
|
|
|
|||
|
|
@ -69,6 +69,13 @@ pub fn interleave(planes: &[Option<&[u8]>], fill: &[Vec<u8>], n: usize, s: Sampl
|
|||
pub fn deinterleave(bytes: &[u8], ch: usize, s: SampleType, invert: &[bool]) -> Vec<Vec<u8>> {
|
||||
let bps = s.bytes();
|
||||
let n = bytes.len() / (ch * bps).max(1);
|
||||
if s == SampleType::U8 {
|
||||
// Fast path: one plane per thread, no per-sample copies.
|
||||
return par_map((0..ch).collect(), |c| {
|
||||
let it = bytes.iter().skip(c).step_by(ch).take(n);
|
||||
if invert[c] { it.map(|v| 255 - v).collect() } else { it.copied().collect() }
|
||||
});
|
||||
}
|
||||
let mut planes = vec![Vec::with_capacity(n * bps); ch];
|
||||
let mut tmp = [0u8; 4];
|
||||
for i in 0..n {
|
||||
|
|
@ -136,6 +143,26 @@ pub fn unmatte(m: f32, a: f32, white: f32) -> f32 {
|
|||
if a <= 0.0 { white } else { ((m - white * (1.0 - a)) / a).clamp(0.0, 1.0) }
|
||||
}
|
||||
|
||||
/// `items.map(f)` on scoped threads (one per item; callers pass a handful of channels or
|
||||
/// bands), in order; sequential on wasm or for a single item.
|
||||
pub fn par_map<T: Send, R: Send>(items: Vec<T>, f: impl Fn(T) -> R + Sync) -> Vec<R> {
|
||||
if cfg!(target_arch = "wasm32") || items.len() < 2 {
|
||||
return items.into_iter().map(f).collect();
|
||||
}
|
||||
std::thread::scope(|sc| {
|
||||
let f = &f;
|
||||
let hs: Vec<_> = items.into_iter().map(|t| sc.spawn(move || f(t))).collect();
|
||||
hs.into_iter().map(|h| h.join().expect("worker panicked")).collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// `0..n` split into about one band per core.
|
||||
pub fn bands(n: usize) -> Vec<std::ops::Range<usize>> {
|
||||
let k = if cfg!(target_arch = "wasm32") { 1 } else { std::thread::available_parallelism().map_or(1, |v| v.get()).clamp(1, 32) };
|
||||
let step = n.div_ceil(k).max(1);
|
||||
(0..n).step_by(step).map(|a| a..(a + step).min(n)).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -177,3 +204,4 @@ mod tests {
|
|||
assert_eq!(decode_be(&f, 0, SampleType::F32), 2.5);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -95,10 +95,9 @@ impl Ex {
|
|||
invert.push(false);
|
||||
let planes = deinterleave(&bytes, self.cc + 1, self.fmt.sample, &invert);
|
||||
let (w, h) = (r.width() as usize, r.height() as usize);
|
||||
let mut ch = vec![self.encode(-1, &planes[self.cc], w, h)];
|
||||
for (c, plane) in planes.iter().take(self.cc).enumerate() {
|
||||
ch.push(self.encode(c as i16, plane, w, h));
|
||||
}
|
||||
// Alpha (-1) first, then the colour channels; each compressed on its own thread.
|
||||
let order: Vec<(i16, &Vec<u8>)> = std::iter::once((-1, &planes[self.cc])).chain(planes.iter().take(self.cc).enumerate().map(|(c, p)| (c as i16, p))).collect();
|
||||
let ch = crate::pixels::par_map(order, |(id, plane)| self.encode(id, plane, w, h));
|
||||
(to_psd_rect(r), ch)
|
||||
}
|
||||
|
||||
|
|
@ -559,15 +558,27 @@ pub fn document_to_psd_with(doc: &Document, opts: &PsdExportOptions) -> (PsdFile
|
|||
let comp = photocraft_compose::flatten(doc);
|
||||
let n = comp.px.len();
|
||||
let has_alpha = comp.px.iter().any(|p| q255(p[3]) < 255 || (sample == SampleType::F32 && p[3] < 1.0));
|
||||
let mut color_planes: Vec<Vec<u8>> = vec![Vec::with_capacity(n * sample.bytes()); cc + 1];
|
||||
let white = photocraft_raster::from_rgba(&fmt, [1.0, 1.0, 1.0, 1.0]);
|
||||
for p in &comp.px {
|
||||
let v = photocraft_raster::from_rgba(&fmt, *p);
|
||||
for c in 0..=cc {
|
||||
// Matte against white like Photoshop (see `pixels::matte`).
|
||||
let m = if c < cc && has_alpha { crate::pixels::matte(v[c], v[cc], white[c]) } else { v[c] };
|
||||
let x = if ex.cmyk && c < cc { 1.0 - m } else { m };
|
||||
encode_be(x, sample, &mut color_planes[c]);
|
||||
let cmyk = ex.cmyk;
|
||||
// Converted and encoded in bands on all cores, then joined per plane.
|
||||
let parts = crate::pixels::par_map(crate::pixels::bands(n), |range| {
|
||||
let mut planes: Vec<Vec<u8>> = vec![Vec::with_capacity(range.len() * sample.bytes()); cc + 1];
|
||||
let mut v = [0.0f32; 5];
|
||||
for p in &comp.px[range] {
|
||||
photocraft_raster::from_rgba_into(&fmt, *p, &mut v);
|
||||
for c in 0..=cc {
|
||||
// Matte against white like Photoshop (see `pixels::matte`).
|
||||
let m = if c < cc && has_alpha { crate::pixels::matte(v[c], v[cc], white[c]) } else { v[c] };
|
||||
let x = if cmyk && c < cc { 1.0 - m } else { m };
|
||||
encode_be(x, sample, &mut planes[c]);
|
||||
}
|
||||
}
|
||||
planes
|
||||
});
|
||||
let mut color_planes: Vec<Vec<u8>> = vec![Vec::with_capacity(n * sample.bytes()); cc + 1];
|
||||
for part in parts {
|
||||
for (dst, src) in color_planes.iter_mut().zip(part) {
|
||||
dst.extend_from_slice(&src);
|
||||
}
|
||||
}
|
||||
let mut planes = Vec::new();
|
||||
|
|
|
|||
|
|
@ -268,6 +268,12 @@ fn main() {
|
|||
s2.open_document(d, None);
|
||||
ms(t) + b.refresh(&s2, true)
|
||||
});
|
||||
b.time("flatten (CPU compositor, 3 layers)", |_| {
|
||||
let d = doc(&s);
|
||||
let t = Instant::now();
|
||||
std::hint::black_box(photocraft_compose::flatten(&d));
|
||||
ms(t)
|
||||
});
|
||||
b.time("export PNG (flatten + encode)", |_| {
|
||||
let d = doc(&s);
|
||||
let t = Instant::now();
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ the live command registry (`menus::is_live`) and rewrites [`docs/parity.md`](par
|
|||
## Performance notes
|
||||
|
||||
- The canvas is presented by a custom WGSL shader (`ui-egui/src/gpu_canvas.rs`): mip-mapped/nearest sampling, procedural checkerboard, pixel grid, tiling. Brush strokes upload only their damage rect.
|
||||
- **The canvas composites on the GPU** (`photocraft-gpu`, driven from `gpu_canvas.rs`), layer effects included. What the planner can't express returns `Unsupported` and the canvas falls back to the CPU compositor (`photocraft-compose`, also the reference for export and tests): vector masks, layers clipped to pass-through groups, stroked shapes with clipped layers, pattern fills without Photoshop's cached pixels, artboards, and documents or effect regions over the texture limit. `ui.inspect` → `perf.timings.gpuFallback` names the reason (`null` on the GPU path).
|
||||
- **The canvas composites on the GPU** (`photocraft-gpu`, driven from `gpu_canvas.rs`), layer effects included. What the planner can't express returns `Unsupported` and the canvas falls back to the CPU compositor (`photocraft-compose`, also the reference for export and tests): Multichannel documents, and documents or effect regions over the texture limit. Pieces the GPU can't derive itself are rasterised once on the CPU and cached (`compose::masks` for vector masks, `compose::shape_split` for stroked shapes with clipped layers, effect distance fields). Timings of the interactive paths: `cargo run --release -p photocraft-ui-egui --example interactive_bench`; effects: `--example fx_bench` (`--compare files…` for GPU vs CPU). Rendering fidelity: `cargo run --release -p photocraft-io --example oracle_diff -- corpus/psd` (the whole PSD oracle table in seconds). `ui.inspect` → `perf.timings.gpuFallback` names the reason (`null` on the GPU path).
|
||||
- **Layer effects on the GPU** (`gpu/src/fx.rs`, kernels in `gpu/src/compose.wgsl`). Every enabled effect becomes a *map program* over the layer's effect region (shift, dilate, Gaussian blur, glow ramp, bevel height and shading, contour, stroke band), mirroring `compose::effects` step by step; the chunked composite then paints through the maps, clipped to that region, and copies the result back into the backdrop in place, so a small text layer costs only its own pixels. The layer's shape (`compose::layer_shape`) and its distance fields (`compose::effects::distance_field`: a sequential transform whose tie-breaking a parallel GPU pass can't reproduce bit for bit) come from compose on the CPU, computed in parallel bands. Everything is cached per layer state: an unrelated edit, or an effect's colour or opacity, rebuilds nothing; a brush dab recomputes the touched 256² tiles grown by the effect reach; changing one effect's geometry rebuilds that effect only. Cache budget `gpu::FX_BUDGET` (1.5 GB, least recently drawn layers evicted first). `PHOTOCRAFT_FX_TRACE=1` prints the CPU time of each rebuild.
|
||||
- The canvas grows a stroke's damage rect by the effect reach of the layers around it (`canvas::effect_reach`), so effects beyond the dab refresh too (on both paths).
|
||||
- **Numbers** (7360 × 4912, 8 text layers + one painted layer with drop shadow + stroke + bevel, Hue/Saturation on top; M4 Pro; `cargo run --release -p photocraft-ui-egui --example fx_bench`), CPU fallback → GPU: full refresh with warm effect maps 5.7 s → 47 ms; Hue/Saturation tweak above the effects 4.7 s → 31 ms; brush dab on a plain layer 32 → 1.7 ms; brush dab on the effect layer (its maps rebuilt around the dab) 659 → 3.7 ms; first refresh (all maps built) 6.4 s → 0.39 s. With the CPU ~14× oversubscribed by parallel builds (min of 7 runs): 8.1 s → 0.33 s, 9.9 s → 0.28 s, 28 → 2.2 ms, 1.7 s → 89 ms; moving a text layer 7 px 336 → 13 ms. `fx_bench --compare corpus/psd/…/*.psd` reports the GPU vs CPU difference on real files (30 of the 31 corpus files with effects render on the GPU, worst 0.12/255).
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ composite oracle and the test count.
|
|||
| M2 PSD v1 | ✅ | photocraft-psd: 134/135 real files byte-exact round trip |
|
||||
| M3 Viewer app | ✅ | egui shell (Pro / Studio / Classic themes), 13+ codecs, native and web (trunk) builds |
|
||||
| M4 Native format + engine | ✅ | 500+ commands, `.pcraft` (incremental, autosave, crash recovery), CLI, persistent preferences |
|
||||
| M5 GPU compositor | 🟡 | wgpu compositor drives the canvas, layer effects included (≤1/255 vs CPU); vector masks, uncached pattern fills, artboards and a few clip cases fall back to the CPU |
|
||||
| M5 GPU compositor | 🟡 | wgpu compositor drives the canvas, layer effects, vector masks, artboards, pattern fills and every clip case included (≤1/255 vs CPU); Multichannel documents fall back to the CPU |
|
||||
| M6 Paint + select | 🟡 | brush engine, all selection tools, multi-layer selection, snapping + smart guides, free transform + warp; native tablet backends pending |
|
||||
| M7 Adjust + filters | 🟡 | 16 adjustment layers + destructive-only adjustments, 70+ filters incl. Blur Gallery, Actions record/replay, Fade |
|
||||
| M8 PSD v2 | 🟡 | adjustments (incl. Selective Color, Color Lookup), fills, effects, patterns, text, shapes, smart objects, alpha channels; oracle 111/170 |
|
||||
|
|
@ -36,11 +36,10 @@ Landed on 2026-10-01:
|
|||
Next:
|
||||
1. **First signed release**: push to `release`, verify notarization and the installers, and fix CI
|
||||
(`docs/releasing.md`). Windows code-signing material still needs to be obtained.
|
||||
2. **Fidelity**: the PSD oracle (111/170). Bevel/emboss, satin, inner glow; modern
|
||||
Brightness/Contrast and grayscale Levels curves; channel restrictions (`brst`); Lab-space
|
||||
blending; Photoshop smart filters in `SoLd`. Also the GPU/CPU Color Burn edge case (51/255 at
|
||||
backdrop 1, source 0).
|
||||
3. **Remaining GPU fallbacks**: vector masks, layers clipped to pass-through groups, artboards.
|
||||
2. **Fidelity**: the PSD oracle (113/170). Modern Brightness/Contrast and grayscale Levels
|
||||
curves; chisel-soft / stroke-emboss bevel shapes; Photoshop's 8-bit blend rounding; non-Normal
|
||||
modes in Lab documents; Photoshop smart filters in `SoLd`.
|
||||
3. **GPU**: only Multichannel documents and regions over the texture limit fall back to the CPU.
|
||||
4. **Vanishing Point, Camera Raw / Lens Correction, Face-Aware Liquify** (needs a landmark model).
|
||||
5. **Panels**: Patterns, Styles, Glyphs, Character/Paragraph Styles, Timeline; Custom Shape tool.
|
||||
6. Print, Photomerge, Merge to HDR, video layers.
|
||||
|
|
|
|||
Loading…
Reference in New Issue