mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-27 09:42:00 +01:00
crates/io/tests/corpus.rs: kept main's pass_floor 188 (TAKE-FIX: drop the PR's 186 hunk) docs/file-format-parity.md: kept main's header and PSD/PSB rows; added the PR's change-log row without its floor figure docs/gaps.md: kept main's header and evidence line; added the PR's change-log row without its floor figure docs/scorecard.md: ours
3769 lines
181 KiB
Rust
3769 lines
181 KiB
Rust
use super::*;
|
||
use photocraft_color::{Color, ColorMode, PixelFormat, SampleType};
|
||
use photocraft_doc::adjust::{CurvePoint, LevelsChannel, ToneSpace};
|
||
use photocraft_doc::{Adjustment, Document, Fill, Layer, LayerContent, LayerMask};
|
||
use photocraft_geom::Size;
|
||
|
||
const E: f32 = 2.0 / 255.0;
|
||
|
||
fn close4(a: [f32; 4], b: [f32; 4]) -> bool {
|
||
a.iter().zip(b).all(|(x, y)| (x - y).abs() <= E)
|
||
}
|
||
|
||
fn doc_white(w: u32, h: u32) -> Document {
|
||
Document::with_background("t", Size::new(w, h), ColorMode::Rgb, SampleType::U8, Color::WHITE)
|
||
}
|
||
|
||
#[test]
|
||
fn prepared_tones_match_fresh_tables_and_stay_bounded() {
|
||
use photocraft_doc::adjust::ToneSpace;
|
||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||
for space in [ToneSpace::Rgb, ToneSpace::Cmyk, ToneSpace::Lab] {
|
||
for (mut adj, edited) in [
|
||
(Adjustment::identity_levels(), false),
|
||
(Adjustment::identity_levels(), true),
|
||
(Adjustment::identity_curves(), false),
|
||
(Adjustment::identity_curves(), true),
|
||
] {
|
||
match &mut adj {
|
||
Adjustment::Levels { master, space: s, .. } => {
|
||
if edited {
|
||
master.gamma = 1.4;
|
||
}
|
||
*s = space;
|
||
}
|
||
Adjustment::Curves { master, space: s, .. } => {
|
||
if edited {
|
||
master[1].output = 0.8;
|
||
}
|
||
*s = space;
|
||
}
|
||
_ => {}
|
||
}
|
||
let mut doc = Document::new("tones", Size::new(3, 1), ColorMode::Rgb, depth);
|
||
doc.layers.push(Layer::group("group", vec![Layer::new("tone", LayerContent::Adjustment(adj.clone()))]));
|
||
let patterns = pattern::PreparedPatterns::new(&[], pattern::PREPARED_PATTERN_BYTES);
|
||
let cx = Ctx::for_doc(&doc, &patterns);
|
||
let cached_adj = cx.tone_luts[0].0;
|
||
let input = Buffer { rect: doc.bounds(), px: vec![[0.2, 0.4, 0.7, 0.6], [2.0, -0.2, 0.5, 0.5], [2.0, -0.2, 0.5, 0.0]] };
|
||
let mut expected = input.clone();
|
||
adjust::apply_depth(&adj, &mut expected, cx.transfer, Some(depth));
|
||
for a in [cached_adj, cached_adj, &adj] {
|
||
let mut actual = input.clone();
|
||
cx.apply_adjustment(a, &mut actual);
|
||
assert_eq!(actual.px, expected.px);
|
||
}
|
||
let direct = depth == SampleType::F32 && matches!(adj, Adjustment::Levels { space: ToneSpace::Rgb, .. });
|
||
let identity = adjust::float_tone_identity(&adj, Some(depth)) == [true; 3];
|
||
assert_eq!(cx.tone_luts[0].1.get().is_none(), direct || identity);
|
||
}
|
||
}
|
||
}
|
||
let mut layers: Vec<_> = (0..20).map(|_| Layer::new("tone", LayerContent::Adjustment(Adjustment::identity_levels()))).collect();
|
||
layers[0].visible = false;
|
||
let tables = prepare_tones(&layers, 0);
|
||
assert_eq!(tables.len(), 16);
|
||
assert!(tables.iter().all(|(_, cell)| cell.get().is_none()));
|
||
assert!(matches!(&layers[1].content, LayerContent::Adjustment(a) if std::ptr::eq(tables[0].0, a)));
|
||
assert!(prepare_tones(&[Layer::group("deep", layers)], PREPARE_DEPTH).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn prepared_adjustments_keep_document_channel_semantics() {
|
||
for mode in [ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Lab] {
|
||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||
let doc = Document::new("channels", Size::new(1, 1), mode, depth);
|
||
let patterns = pattern::PreparedPatterns::new(&[], pattern::PREPARED_PATTERN_BYTES);
|
||
let cx = Ctx::for_doc(&doc, &patterns);
|
||
for adj in [Adjustment::Invert, Adjustment::BrightnessContrast { brightness: 20.0, contrast: 30.0, legacy: false }] {
|
||
let mut actual = Buffer::filled(doc.bounds(), [0.2, 0.4, 0.7, 0.6]);
|
||
let mut expected = actual.clone();
|
||
adjust::apply_in(&adj, &mut expected, cx.transfer, Some(depth), mode);
|
||
cx.apply_adjustment(&adj, &mut actual);
|
||
assert_eq!(actual.px, expected.px);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn identity_float_curves_match_previous_mapping_bits() {
|
||
let adj = Adjustment::identity_curves();
|
||
let input = Buffer {
|
||
rect: Rect::new(0, 0, 4, 1),
|
||
px: vec![[2.0, -0.0, -0.2, 1.0], [f32::from_bits(0x7fc01234), f32::INFINITY, f32::NEG_INFINITY, 0.6], [0.7, -0.0, 1.5, 0.0], [0.1; 4]],
|
||
};
|
||
let mut previous = input.clone();
|
||
adjust::apply_tone(&mut previous, ToneSpace::Rgb, &adjust::tone_luts_depth(&adj, Some(SampleType::F32)), [true; 3]);
|
||
let mut actual = input.clone();
|
||
adjust::apply_depth(&adj, &mut actual, adjust::Transfer::Gamma(1.0), Some(SampleType::F32));
|
||
assert_buffer_bits(&actual, &previous);
|
||
assert_buffer_bits(&actual, &input);
|
||
}
|
||
|
||
fn solid_layer(name: &str, rect: Rect, rgba: [f32; 4]) -> Layer {
|
||
let mut l = Layer::raster(name, PixelFormat::RGBA8);
|
||
l.surface_mut().unwrap().fill_rect(rect, &rgba);
|
||
l
|
||
}
|
||
|
||
fn px(doc: &Document, x: i32, y: i32) -> [f32; 4] {
|
||
render(doc, Rect::from_xywh(x, y, 1, 1)).px[0]
|
||
}
|
||
|
||
#[test]
|
||
fn background_only() {
|
||
let d = doc_white(8, 8);
|
||
assert!(close4(px(&d, 3, 3), [1.0; 4]));
|
||
// outside canvas the background layer has no pixels
|
||
assert!(close4(px(&d, 20, 3), [0.0; 4]));
|
||
}
|
||
|
||
#[test]
|
||
fn normal_layer_over_background() {
|
||
let mut d = doc_white(8, 8);
|
||
d.layers.push(solid_layer("red", Rect::new(0, 0, 4, 8), [1.0, 0.0, 0.0, 1.0]));
|
||
assert!(close4(px(&d, 1, 1), [1.0, 0.0, 0.0, 1.0]));
|
||
assert!(close4(px(&d, 5, 1), [1.0; 4]));
|
||
}
|
||
|
||
// The pre-optimisation Normal formula, independent of the production helper. Its operation
|
||
// order matters: an algebraically equivalent premultiplied mix can round differently.
|
||
fn original_normal_rgb(backdrop: [f32; 4], source: [f32; 4], opacity: f32) -> [f32; 4] {
|
||
let ab = backdrop[3];
|
||
let as_ = source[3] * opacity;
|
||
if as_ <= 0.0 {
|
||
return backdrop;
|
||
}
|
||
let ao = as_ + ab * (1.0 - as_);
|
||
if ao <= 0.0 {
|
||
return [0.0; 4];
|
||
}
|
||
let kb = ab * (1.0 - as_);
|
||
let inv = 1.0 / ao;
|
||
[(kb * backdrop[0] + as_ * source[0]) * inv, (kb * backdrop[1] + as_ * source[1]) * inv, (kb * backdrop[2] + as_ * source[2]) * inv, ao]
|
||
}
|
||
|
||
#[test]
|
||
fn normal_rgb_kernel_preserves_scalar_bits() {
|
||
let subnormal = f32::from_bits(1);
|
||
let backdrops = [[0.0, -0.0, 2.0, 0.0], [0.2, 0.4, 0.6, 0.5], [2.0, -0.25, 4.0, 1.0], [-1.0, 0.01, 1.0, -0.5], [1.0, -0.0, f32::MIN_POSITIVE, subnormal]];
|
||
let sources = [
|
||
[0.9, 0.1, 0.3, 1.0],
|
||
[0.25, 2.0, -1.0, 0.5],
|
||
[subnormal, 1e-20, 1.0, subnormal],
|
||
[0.5, 0.2, 0.7, -0.0],
|
||
[0.5, 0.2, 0.7, -0.5],
|
||
[f32::NAN, 0.2, 0.7, 0.0],
|
||
];
|
||
for b in backdrops {
|
||
for s in sources {
|
||
for opacity in [-1.0, -0.0, 0.0, 0.5, 1.0, 2.0] {
|
||
let want = original_normal_rgb(b, s, opacity);
|
||
let got = psblend::composite_normal_rgb(b, s, opacity);
|
||
assert_eq!(got.map(f32::to_bits), want.map(f32::to_bits), "{b:?} / {s:?} / {opacity}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The vectorized row kernel against the scalar loop it replaced, over row lengths that cover
|
||
/// whole blocks, tails and both, with samples full of NaN, signed zeros, infinities, subnormals
|
||
/// and values outside 0..1: the same bits, except that a NaN only has to stay NaN.
|
||
#[test]
|
||
fn normal_rgb_span_preserves_scalar_bits() {
|
||
let specials = [0.0, -0.0, 1.0, 0.5, 0.03, -0.5, 2.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::from_bits(1), f32::MIN_POSITIVE, 1e-30];
|
||
let mut seed = 0x9e37_79b9u32;
|
||
let mut next = move || {
|
||
seed ^= seed << 13;
|
||
seed ^= seed >> 17;
|
||
seed ^= seed << 5;
|
||
seed
|
||
};
|
||
let mut value = || {
|
||
let r = next();
|
||
if r % 3 == 0 { specials[(r / 3) as usize % specials.len()] } else { (r % 1000) as f32 / 999.0 }
|
||
};
|
||
for len in 0..41 {
|
||
for _ in 0..40 {
|
||
let backdrop: Vec<[f32; 4]> = (0..len).map(|_| [value(), value(), value(), value()]).collect();
|
||
let source: Vec<[f32; 4]> = (0..len).map(|_| [value(), value(), value(), value()]).collect();
|
||
let opacity = value();
|
||
let mut want = backdrop.clone();
|
||
for (b, s) in want.iter_mut().zip(&source) {
|
||
if s[3] <= 0.0 {
|
||
continue;
|
||
}
|
||
*b = original_normal_rgb(*b, *s, opacity);
|
||
}
|
||
let mut got = backdrop;
|
||
psblend::composite_normal_rgb_span(&mut got, &source, opacity);
|
||
// IEEE 754 leaves open which operand's NaN an operation passes on (x86 and the
|
||
// vector units differ in its sign), so any NaN counts as the same NaN.
|
||
let bits = |p: &[f32; 4]| p.map(|v| if v.is_nan() { u32::MAX } else { v.to_bits() });
|
||
for (i, (g, w)) in got.iter().zip(&want).enumerate() {
|
||
assert_eq!(bits(g), bits(w), "len {len}, pixel {i}, opacity {opacity}: {g:?} vs {w:?}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A pixel layer read and blended a row at a time gives exactly what blending a tile-sized copy
|
||
/// of it gave, across depths, modes (Dissolve's per-pixel noise and the fill-special modes
|
||
/// included), fill, masks (feathered too) and a rect that is not tile-aligned and runs off the
|
||
/// layer's pixels.
|
||
#[test]
|
||
fn pixel_layer_rows_match_tile_copy() {
|
||
let rect = Rect::new(-3, 2, 37, 21);
|
||
let modes = [
|
||
BlendMode::Normal,
|
||
BlendMode::Multiply,
|
||
BlendMode::Dissolve,
|
||
BlendMode::Overlay,
|
||
BlendMode::ColorBurn,
|
||
BlendMode::LinearBurn,
|
||
BlendMode::VividLight,
|
||
BlendMode::Difference,
|
||
BlendMode::Hue,
|
||
];
|
||
for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] {
|
||
let mut l = Layer::raster("p", fmt);
|
||
for y in 0..20 {
|
||
for x in 0..30 {
|
||
let t = (x * 7 + y * 13) as f32 / 400.0;
|
||
let a = [0.0, 0.03, 0.5, 1.0][(x + y) as usize % 4];
|
||
l.surface_mut().unwrap().write_pixel(x, y, &photocraft_raster::from_rgba(&fmt, [t.fract(), 1.0 - t.fract(), 0.3, a]));
|
||
}
|
||
}
|
||
let mut sharp = LayerMask::reveal_all();
|
||
sharp.surface.fill_rect(Rect::new(5, 0, 12, 30), &[0.25]);
|
||
let mut feathered = sharp.clone();
|
||
feathered.feather = 2.5;
|
||
for mask in [None, Some(sharp), Some(feathered)] {
|
||
l.mask = mask;
|
||
for mode in modes {
|
||
for fill in [1.0, 0.4] {
|
||
l.blend = mode;
|
||
l.opacity = 0.8;
|
||
l.fill_opacity = fill;
|
||
let mut doc = doc_white(40, 30);
|
||
doc.layers.push(l.clone());
|
||
let patterns = pattern::PreparedPatterns::new(&doc.patterns, pattern::PREPARED_PATTERN_BYTES).in_mode(doc.pixel_format().mode);
|
||
let cx = Ctx::for_doc(&doc, &patterns);
|
||
let layer = &doc.layers[1];
|
||
let backdrop = Buffer {
|
||
rect,
|
||
px: (0..rect.width() * rect.height())
|
||
.map(|i| [(i % 11) as f32 / 10.0, (i % 5) as f32 / 4.0, 0.6, [1.0, 0.5, 0.0][i as usize % 3]])
|
||
.collect(),
|
||
};
|
||
let mut want = backdrop.clone();
|
||
let content = render_content(layer, rect, &cx).unwrap();
|
||
blend_into_fill(&mut want, &content, layer.blend, layer.opacity, layer.fill_opacity, text_gamma(layer));
|
||
let mut got = backdrop;
|
||
composite_pixel_layer(layer, pixel_surface(layer).unwrap(), &mut got, &cx);
|
||
assert_buffer_bits(&got, &want);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn normal_rgb_test_buffers(fmt: PixelFormat) -> (Buffer, Buffer) {
|
||
let rect = Rect::new(-3, -2, 5, 0);
|
||
let mut bottom = Surface::new(fmt);
|
||
let mut top = Surface::new(fmt);
|
||
for y in rect.y0..rect.y1 {
|
||
for x in rect.x0..rect.x1 {
|
||
let i = ((y - rect.y0) * rect.width() as i32 + x - rect.x0) as usize;
|
||
let t = i as f32 / 16.0;
|
||
let b = [2.0 * t, 0.4, -0.25 + t, [0.0, 0.25, 0.75, 1.0][i % 4]];
|
||
let s = [0.9 - t, 0.1 + t, 0.3, [0.0, -0.0, 0.5, 1.0][i % 4]];
|
||
bottom.write_pixel(x, y, &photocraft_raster::from_rgba(&fmt, b));
|
||
top.write_pixel(x, y, &photocraft_raster::from_rgba(&fmt, s));
|
||
}
|
||
}
|
||
(surface_to_buffer(&bottom, rect), surface_to_buffer(&top, rect))
|
||
}
|
||
|
||
// Keep the old buffer dispatch as an oracle for the specialised loop and all of its fallbacks.
|
||
fn original_blend_into_g(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f32, gamma: f32) {
|
||
let rect = backdrop.rect;
|
||
let w = rect.width() as i32;
|
||
for (i, b) in backdrop.px.iter_mut().enumerate() {
|
||
let mut s = src.px[i];
|
||
if s[3] <= 0.0 {
|
||
continue;
|
||
}
|
||
let mut mode = mode;
|
||
if mode == BlendMode::Dissolve {
|
||
let x = rect.x0 + (i as i32 % w);
|
||
let y = rect.y0 + (i as i32 / w);
|
||
s[3] = if dissolve_noise(x, y) < s[3] * opacity { 1.0 } else { 0.0 };
|
||
mode = BlendMode::Normal;
|
||
*b = blend::composite(mode, *b, s, 1.0);
|
||
continue;
|
||
}
|
||
*b = blend::composite_gamma(mode, *b, s, opacity, gamma);
|
||
}
|
||
}
|
||
|
||
fn assert_buffer_bits(got: &Buffer, want: &Buffer) {
|
||
assert_eq!(got.rect, want.rect);
|
||
assert_eq!(got.px.len(), want.px.len());
|
||
for (i, (got, want)) in got.px.iter().zip(&want.px).enumerate() {
|
||
assert_eq!(got.map(f32::to_bits), want.map(f32::to_bits), "pixel {i}: {got:?} vs {want:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn normal_rgb_buffer_dispatch_and_fallbacks_preserve_bits() {
|
||
for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] {
|
||
let (bottom, top) = normal_rgb_test_buffers(fmt);
|
||
for lab in [false, true] {
|
||
psblend::with_lab_mix(lab, || {
|
||
psblend::with_hdr(fmt.sample == SampleType::F32, || {
|
||
for mode in BlendMode::LAYER_MODES {
|
||
for gamma in [1.0, psblend::TEXT_GAMMA] {
|
||
for opacity in [0.0, 0.5, 1.0] {
|
||
let mut got = bottom.clone();
|
||
let mut want = bottom.clone();
|
||
original_blend_into_g(&mut want, &top, mode, opacity, gamma);
|
||
blend_into_g(&mut got, &top, mode, opacity, gamma);
|
||
assert_buffer_bits(&got, &want);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn normal_rgb_buffer_preserves_alpha_skip_and_nested_scopes() {
|
||
let rect = Rect::new(-1, -1, 2, 0);
|
||
let bottom = Buffer::filled(rect, [-0.0, 0.4, 2.0, 0.75]);
|
||
let top = Buffer { rect, px: vec![[f32::NAN, 0.3, 0.1, -0.5], [0.2, 0.3, 0.1, -0.0], [0.2, 0.3, 0.1, f32::NAN]] };
|
||
psblend::with_lab_mix(true, || {
|
||
psblend::with_hdr(true, || {
|
||
psblend::with_lab_mix(false, || {
|
||
psblend::with_hdr(false, || {
|
||
let mut got = bottom.clone();
|
||
let mut want = bottom.clone();
|
||
original_blend_into_g(&mut want, &top, BlendMode::Normal, -1.0, 1.0);
|
||
blend_into_g(&mut got, &top, BlendMode::Normal, -1.0, 1.0);
|
||
assert_buffer_bits(&got, &want);
|
||
assert_eq!(got.px[0].map(f32::to_bits), bottom.px[0].map(f32::to_bits));
|
||
assert_eq!(got.px[1].map(f32::to_bits), bottom.px[1].map(f32::to_bits));
|
||
});
|
||
assert!(psblend::HDR.with(|h| h.get()));
|
||
});
|
||
assert!(psblend::LAB_MIX.with(|l| l.get()));
|
||
assert!(psblend::HDR.with(|h| h.get()));
|
||
let mut got = bottom.clone();
|
||
let mut want = bottom.clone();
|
||
original_blend_into_g(&mut want, &top, BlendMode::Normal, 0.5, 1.0);
|
||
blend_into_g(&mut got, &top, BlendMode::Normal, 0.5, 1.0);
|
||
assert_buffer_bits(&got, &want);
|
||
});
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn opacity_and_fill_multiply() {
|
||
let mut d = doc_white(4, 4);
|
||
let mut l = solid_layer("k", Rect::new(0, 0, 4, 4), [0.0, 0.0, 0.0, 1.0]);
|
||
l.opacity = 0.5;
|
||
l.fill_opacity = 0.5;
|
||
d.layers.push(l);
|
||
let p = px(&d, 0, 0);
|
||
assert!((p[0] - 0.75).abs() <= E, "{p:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn hidden_layers_skipped() {
|
||
let mut d = doc_white(4, 4);
|
||
let mut l = solid_layer("k", Rect::new(0, 0, 4, 4), [0.0, 0.0, 0.0, 1.0]);
|
||
l.visible = false;
|
||
d.layers.push(l);
|
||
assert!(close4(px(&d, 0, 0), [1.0; 4]));
|
||
}
|
||
|
||
#[test]
|
||
fn every_blend_mode_matches_reference_on_opaque_pixels() {
|
||
let backdrop = [0.6, 0.3, 0.2, 1.0];
|
||
let source = [0.2, 0.7, 0.5, 1.0];
|
||
for mode in BlendMode::LAYER_MODES {
|
||
if mode == BlendMode::Dissolve {
|
||
continue;
|
||
}
|
||
let mut d = Document::new("b", Size::new(2, 2), ColorMode::Rgb, SampleType::F32);
|
||
let mut bottom = Layer::raster("b", PixelFormat::RGBA32F);
|
||
bottom.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 2), &backdrop);
|
||
let mut top = Layer::raster("t", PixelFormat::RGBA32F);
|
||
top.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 2), &source);
|
||
top.blend = mode;
|
||
d.layers = vec![bottom, top];
|
||
let got = px(&d, 0, 0);
|
||
// A 32-bit document: Add / Divide don't clip at 1 (Divide gives 0.6 / 0.2 = 3 in red).
|
||
psblend::HDR.with(|h| h.set(true));
|
||
let want = blend::blend_rgb(mode, [0.6, 0.3, 0.2], [0.2, 0.7, 0.5]);
|
||
psblend::HDR.with(|h| h.set(false));
|
||
for i in 0..3 {
|
||
assert!((got[i] - want[i]).abs() < 1e-5, "{mode:?}: {got:?} vs {want:?}");
|
||
}
|
||
assert!((got[3] - 1.0).abs() < 1e-6);
|
||
}
|
||
}
|
||
|
||
/// One opaque pixel: `top` in `mode` over `bottom`, in a document of `depth`.
|
||
fn blend_px(depth: SampleType, mode: BlendMode, bottom: [f32; 3], top: [f32; 3]) -> [f32; 4] {
|
||
let mut d = Document::new("b", Size::new(1, 1), ColorMode::Rgb, depth);
|
||
let fmt = d.pixel_format();
|
||
let mut b = Layer::raster("b", fmt);
|
||
b.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 1, 1), &photocraft_raster::from_rgba(&fmt, [bottom[0], bottom[1], bottom[2], 1.0]));
|
||
let mut t = Layer::raster("t", fmt);
|
||
t.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 1, 1), &photocraft_raster::from_rgba(&fmt, [top[0], top[1], top[2], 1.0]));
|
||
t.blend = mode;
|
||
d.layers = vec![b, t];
|
||
px(&d, 0, 0)
|
||
}
|
||
|
||
#[test]
|
||
fn non_separable_modes_keep_values_above_one_in_32_bit() {
|
||
use photocraft_color::blend::{blend_rgb_within, lum};
|
||
let modes = [BlendMode::Hue, BlendMode::Saturation, BlendMode::Color, BlendMode::Luminosity];
|
||
// Hue over a grey of 2: the grey (the backdrop has no saturation), not ClipColor dividing
|
||
// by the rounding noise of `max − lum` (Hue over (4, 3.9999, 4) gave 27.7 in blue).
|
||
for bg in [[2.0, 2.0, 2.0], [2.0, 2.0, 1.999_99], [4.0, 3.999_9, 4.0]] {
|
||
for m in modes {
|
||
for top in [[3.0, 0.25, 0.125], [2.0, 2.0, 2.0], [4.0, 3.999_9, 4.0]] {
|
||
let got = blend_px(SampleType::F32, m, bg, top);
|
||
let want = blend_rgb_within(m, bg, top, f32::MAX);
|
||
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1e-4 * w.abs().max(1.0)), "{m:?} {bg:?} {top:?}: {got:?} vs {want:?}");
|
||
assert!(got.iter().all(|v| v.is_finite() && *v >= -1e-5), "{m:?} {bg:?} {top:?}: {got:?}");
|
||
}
|
||
}
|
||
}
|
||
// A saturated colour keeps its brightness above 1 in 32-bit, and is clipped into 0..1
|
||
// (exactly as before) in 8/16-bit.
|
||
let (bg, top) = ([0.5, 0.5, 0.5], [1.0, 0.0, 0.0]);
|
||
let hdr = blend_px(SampleType::F32, BlendMode::Color, bg, top);
|
||
assert!((hdr[0] - 1.2).abs() < 1e-4 && (lum([hdr[0], hdr[1], hdr[2]]) - 0.5).abs() < 1e-4, "{hdr:?}");
|
||
for depth in [SampleType::U8, SampleType::U16] {
|
||
for m in modes {
|
||
let got = blend_px(depth, m, bg, top);
|
||
let want = photocraft_color::blend::blend_rgb(m, bg, top);
|
||
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1.0 / 255.0), "{m:?} {depth:?}: {got:?} vs {want:?}");
|
||
}
|
||
let got = blend_px(depth, BlendMode::Color, bg, top);
|
||
assert!(got[0] <= 1.0 && got[0] > 0.9, "{depth:?}: {got:?}");
|
||
}
|
||
// A 32-bit document's in-range colours that stay in range are unchanged.
|
||
for m in modes {
|
||
let got = blend_px(SampleType::F32, m, [0.6, 0.3, 0.2], [0.2, 0.7, 0.5]);
|
||
let want = photocraft_color::blend::blend_rgb(m, [0.6, 0.3, 0.2], [0.2, 0.7, 0.5]);
|
||
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1e-5), "{m:?}: {got:?} vs {want:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn layer_mask_hides_pixels() {
|
||
let mut d = doc_white(4, 4);
|
||
let mut l = solid_layer("k", Rect::new(0, 0, 4, 4), [0.0, 0.0, 0.0, 1.0]);
|
||
let mut m = LayerMask::reveal_all();
|
||
m.surface.fill_rect(Rect::new(0, 0, 2, 4), &[0.0]);
|
||
l.mask = Some(m);
|
||
d.layers.push(l);
|
||
assert!(close4(px(&d, 0, 0), [1.0; 4]));
|
||
assert!(close4(px(&d, 3, 0), [0.0, 0.0, 0.0, 1.0]));
|
||
}
|
||
|
||
#[test]
|
||
fn disabled_mask_is_ignored() {
|
||
let mut d = doc_white(2, 2);
|
||
let mut l = solid_layer("k", Rect::new(0, 0, 2, 2), [0.0, 0.0, 0.0, 1.0]);
|
||
let mut m = LayerMask::hide_all();
|
||
m.enabled = false;
|
||
l.mask = Some(m);
|
||
d.layers.push(l);
|
||
// CMYK displays through the built-in CMYK profile: 100 % K alone is a dark neutral
|
||
// (as in any real CMYK profile), not pure black.
|
||
let p = px(&d, 0, 0);
|
||
assert!(p[0] < 0.3 && (p[0] - p[1]).abs() < 0.05 && (p[1] - p[2]).abs() < 0.05 && p[3] == 1.0, "{p:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn clipping_mask_restricts_to_base_alpha() {
|
||
let mut d = doc_white(8, 8);
|
||
d.layers.push(solid_layer("base", Rect::new(0, 0, 4, 8), [0.0, 0.0, 1.0, 1.0]));
|
||
let mut clip = solid_layer("clip", Rect::new(0, 0, 8, 8), [1.0, 0.0, 0.0, 1.0]);
|
||
clip.clipped = true;
|
||
d.layers.push(clip);
|
||
assert!(close4(px(&d, 1, 1), [1.0, 0.0, 0.0, 1.0]), "inside base: clipped layer visible");
|
||
assert!(close4(px(&d, 6, 1), [1.0; 4]), "outside base: clipped layer hidden");
|
||
}
|
||
|
||
#[test]
|
||
fn hidden_base_hides_clipping_group() {
|
||
let mut d = doc_white(4, 4);
|
||
let mut base = solid_layer("base", Rect::new(0, 0, 4, 4), [0.0, 0.0, 1.0, 1.0]);
|
||
base.visible = false;
|
||
d.layers.push(base);
|
||
let mut clip = solid_layer("clip", Rect::new(0, 0, 4, 4), [1.0, 0.0, 0.0, 1.0]);
|
||
clip.clipped = true;
|
||
d.layers.push(clip);
|
||
assert!(close4(px(&d, 1, 1), [1.0; 4]));
|
||
}
|
||
|
||
#[test]
|
||
fn isolated_group_differs_from_pass_through() {
|
||
// A Multiply layer inside a group: pass-through multiplies with the background,
|
||
// an isolated (Normal) group multiplies only with its own (empty) contents.
|
||
let make = |blend: BlendMode| {
|
||
let mut d = doc_white(2, 2);
|
||
d.layers[0].surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 2), &[0.5, 0.5, 0.5, 1.0]);
|
||
let mut m = solid_layer("m", Rect::new(0, 0, 2, 2), [0.5, 0.5, 0.5, 1.0]);
|
||
m.blend = BlendMode::Multiply;
|
||
let mut g = Layer::group("g", vec![m]);
|
||
g.blend = blend;
|
||
d.layers.push(g);
|
||
px(&d, 0, 0)
|
||
};
|
||
let pass = make(BlendMode::PassThrough);
|
||
let iso = make(BlendMode::Normal);
|
||
assert!((pass[0] - 0.25).abs() <= E, "{pass:?}");
|
||
assert!((iso[0] - 0.5).abs() <= E, "{iso:?}");
|
||
}
|
||
|
||
/// psd-tools passthrough_fill_adjustment.psd: below 100% fill a pass-through group is isolated,
|
||
/// so an adjustment inside it no longer changes the layers beneath.
|
||
#[test]
|
||
fn pass_through_group_below_full_fill_is_isolated() {
|
||
let make = |fill: f32| {
|
||
let mut d = doc_white(2, 2);
|
||
let mut g = Layer::group("g", vec![Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))]);
|
||
g.blend = BlendMode::PassThrough;
|
||
g.fill_opacity = fill;
|
||
d.layers.push(g);
|
||
px(&d, 0, 0)
|
||
};
|
||
assert!(close4(make(1.0), [0.0, 0.0, 0.0, 1.0]), "full fill: the invert reaches the background");
|
||
assert!(close4(make(0.4), [1.0; 4]), "isolated: nothing beneath to invert");
|
||
}
|
||
|
||
/// psd-tools layer_mask_data.psd: a mask feather blurs the mask edge (CPU and the GPU's
|
||
/// combined mask share `masks::combined_mask`).
|
||
#[test]
|
||
fn mask_feather_blurs_the_edge() {
|
||
let mut d = doc_white(64, 8);
|
||
let mut l = solid_layer("k", Rect::new(0, 0, 64, 8), [0.0, 0.0, 0.0, 1.0]);
|
||
let mut m = LayerMask::hide_all();
|
||
m.surface.fill_rect(Rect::new(0, 0, 32, 8), &[1.0]);
|
||
l.mask = Some(m);
|
||
d.layers.push(l.clone());
|
||
assert!(px(&d, 31, 4)[0] < 0.01 && px(&d, 32, 4)[0] > 0.99, "sharp without feather");
|
||
if let Some(m) = &mut d.layers[1].mask {
|
||
m.feather = 8.0;
|
||
}
|
||
let (a, b) = (px(&d, 31, 4)[0], px(&d, 32, 4)[0]);
|
||
assert!(a > 0.3 && a < 0.5 && b > 0.5 && b < 0.7, "soft edge around 50%: {a} {b}");
|
||
assert!(px(&d, 4, 4)[0] < 0.01 && px(&d, 60, 4)[0] > 0.99, "far from the edge unchanged");
|
||
}
|
||
|
||
#[test]
|
||
fn group_opacity_applies_once() {
|
||
let mut d = doc_white(2, 2);
|
||
let a = solid_layer("a", Rect::new(0, 0, 2, 2), [0.0, 0.0, 0.0, 1.0]);
|
||
let b = solid_layer("b", Rect::new(0, 0, 2, 2), [0.0, 0.0, 0.0, 1.0]);
|
||
let mut g = Layer::group("g", vec![a, b]);
|
||
g.blend = BlendMode::Normal;
|
||
g.opacity = 0.5;
|
||
d.layers.push(g);
|
||
assert!((px(&d, 0, 0)[0] - 0.5).abs() <= E);
|
||
}
|
||
|
||
#[test]
|
||
fn pass_through_group_opacity_mixes() {
|
||
let mut d = doc_white(2, 2);
|
||
let a = solid_layer("a", Rect::new(0, 0, 2, 2), [0.0, 0.0, 0.0, 1.0]);
|
||
let mut g = Layer::group("g", vec![a]);
|
||
g.opacity = 0.25;
|
||
d.layers.push(g);
|
||
assert!((px(&d, 0, 0)[0] - 0.75).abs() <= E);
|
||
|
||
// Opaque backdrops hide straight-alpha interpolation errors.
|
||
let mut transparent = Document::new("coverage", Size::new(2, 2), ColorMode::Rgb, SampleType::F32);
|
||
let mut red = Layer::raster("HDR red", transparent.pixel_format());
|
||
red.surface_mut().unwrap().fill_rect(transparent.bounds(), &[2.0, 0.0, 0.0, 1.0]);
|
||
let mut group = Layer::group("half", vec![red]);
|
||
group.opacity = 0.5;
|
||
transparent.layers.push(group);
|
||
let actual = px(&transparent, 0, 0);
|
||
assert!(close4(actual, [2.0, 0.0, 0.0, 0.5]), "{actual:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn invert_adjustment_layer() {
|
||
let mut d = doc_white(2, 2);
|
||
d.layers[0].surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 2), &[0.2, 0.4, 0.6, 1.0]);
|
||
d.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)));
|
||
assert!(close4(px(&d, 0, 0), [0.8, 0.6, 0.4, 1.0]));
|
||
}
|
||
|
||
#[test]
|
||
fn adjustment_opacity_and_mask() {
|
||
let mut d = doc_white(4, 1);
|
||
d.layers[0].surface_mut().unwrap().fill_rect(Rect::new(0, 0, 4, 1), &[0.0, 0.0, 0.0, 1.0]);
|
||
let mut adj = Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert));
|
||
adj.opacity = 0.5;
|
||
let mut m = LayerMask::reveal_all();
|
||
m.surface.fill_rect(Rect::new(2, 0, 4, 1), &[0.0]);
|
||
adj.mask = Some(m);
|
||
d.layers.push(adj);
|
||
assert!((px(&d, 0, 0)[0] - 0.5).abs() <= E);
|
||
assert!(px(&d, 3, 0)[0].abs() <= E);
|
||
}
|
||
|
||
#[test]
|
||
fn clipped_adjustment_only_affects_base() {
|
||
let mut d = doc_white(4, 1);
|
||
d.layers.push(solid_layer("base", Rect::new(0, 0, 2, 1), [0.0, 0.0, 0.0, 1.0]));
|
||
let mut adj = Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert));
|
||
adj.clipped = true;
|
||
d.layers.push(adj);
|
||
assert!(close4(px(&d, 0, 0), [1.0; 4]), "base inverted to white");
|
||
assert!(close4(px(&d, 3, 0), [1.0; 4]), "background untouched (white)");
|
||
}
|
||
|
||
/// #6535: showing or hiding a clipped adjustment changes only its base's pixels, so its change
|
||
/// bounds are the base's; it still reaches everywhere without a base, or clipped to an
|
||
/// adjustment, or to a pass-through group whose adjustment it isolates.
|
||
#[test]
|
||
fn clipped_adjustment_changes_only_its_base() {
|
||
let canvas = Rect::new(0, 0, 8, 6);
|
||
let base_rect = Rect::new(2, 1, 5, 4);
|
||
let mut d = doc_white(8, 6);
|
||
d.layers.push(solid_layer("base", base_rect, [0.2, 0.4, 0.6, 1.0]));
|
||
let mut over = solid_layer("over", Rect::new(3, 2, 4, 3), [0.9, 0.1, 0.1, 1.0]);
|
||
over.clipped = true;
|
||
d.layers.push(over);
|
||
let mut adj = Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert));
|
||
adj.clipped = true;
|
||
d.layers.push(adj);
|
||
assert_eq!(change_bounds(&d.layers[3], canvas), None);
|
||
assert_eq!(change_bounds_in(&d.layers, 3, None, canvas), Some(base_rect));
|
||
// Every pixel the toggle changes lies inside those bounds.
|
||
let shown = render(&d, canvas);
|
||
d.layers[3].visible = false;
|
||
let hidden = render(&d, canvas);
|
||
let mut changed = Rect::EMPTY;
|
||
for (i, (a, b)) in shown.px.iter().zip(&hidden.px).enumerate() {
|
||
if !close4(*a, *b) {
|
||
let (x, y) = ((i % 8) as i32, (i / 8) as i32);
|
||
assert!(base_rect.contains(x, y), "pixel {x},{y} changed outside the base");
|
||
let p = Rect::from_xywh(x, y, 1, 1);
|
||
changed = if changed.is_empty() { p } else { changed.union(&p) };
|
||
}
|
||
}
|
||
assert_eq!(changed, base_rect);
|
||
// Other layers keep their own bounds.
|
||
assert_eq!(change_bounds_in(&d.layers, 2, None, canvas), Some(Rect::new(3, 2, 4, 3)));
|
||
assert_eq!(change_bounds_in(&d.layers, 9, None, canvas), None);
|
||
|
||
// Unclipped, or with no base beneath it, it reaches everything.
|
||
let mut free = d.layers.clone();
|
||
free[3].clipped = false;
|
||
assert_eq!(change_bounds_in(&free, 3, None, canvas), None);
|
||
let orphan = vec![d.layers[3].clone()];
|
||
assert_eq!(change_bounds_in(&orphan, 0, None, canvas), None);
|
||
// Clipped layers on an adjustment composite atop the adjusted backdrop.
|
||
let on_adj = vec![d.layers[0].clone(), Layer::new("lv", LayerContent::Adjustment(Adjustment::Invert)), d.layers[3].clone()];
|
||
assert_eq!(change_bounds_in(&on_adj, 2, None, canvas), None);
|
||
// A pass-through group holding an adjustment is isolated only while it has visible clipped
|
||
// layers, so they change whether its adjustment reaches the layers beneath.
|
||
let mut g = Layer::group("g", vec![solid_layer("in", base_rect, [0.2, 0.4, 0.6, 1.0]), Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))]);
|
||
g.blend = BlendMode::PassThrough;
|
||
let on_group = vec![d.layers[0].clone(), g, d.layers[3].clone()];
|
||
assert_eq!(change_bounds_in(&on_group, 2, None, canvas), None);
|
||
}
|
||
|
||
/// #6535: a clipped adjustment with no base beneath it, inside a pass-through group, composites
|
||
/// as an ordinary adjustment, so hiding the group or the layer clipped to it (which ends its
|
||
/// isolation) changes the whole canvas, not just the group's pixels.
|
||
#[test]
|
||
fn orphan_clipped_adjustment_in_pass_through_base_reaches_everything() {
|
||
let canvas = Rect::new(0, 0, 8, 6);
|
||
let mut d = doc_white(8, 6);
|
||
let mut orphan = Layer::new("A", LayerContent::Adjustment(Adjustment::Invert));
|
||
orphan.clipped = true;
|
||
let mut g = Layer::group("G", vec![orphan, solid_layer("P", Rect::new(2, 1, 4, 3), [0.2, 0.4, 0.6, 1.0])]);
|
||
g.blend = BlendMode::PassThrough;
|
||
d.layers.push(g);
|
||
let mut x = Layer::new("X", LayerContent::Adjustment(Adjustment::Invert));
|
||
x.clipped = true;
|
||
d.layers.push(x);
|
||
let shown = px(&d, 0, 0);
|
||
d.layers[2].visible = false;
|
||
assert!(!close4(shown, px(&d, 0, 0)), "the toggle changes pixels outside the group");
|
||
assert_eq!(change_bounds_in(&d.layers, 2, None, canvas), None);
|
||
assert_eq!(change_bounds(&d.layers[1], canvas), None);
|
||
// With a base beneath it the clipped adjustment stays inside the group.
|
||
let LayerContent::Group(g) = &mut d.layers[1].content else { panic!("group") };
|
||
g.children.swap(0, 1);
|
||
assert_eq!(change_bounds_in(&d.layers, 2, None, canvas), Some(Rect::new(2, 1, 4, 3)));
|
||
}
|
||
|
||
/// A pass-through group that is a clip base renders isolated while it holds a visible
|
||
/// adjustment, so hiding a clipped adjustment inside it changes the group's other children too:
|
||
/// its damage is the group's, not just its base's.
|
||
#[test]
|
||
fn clipped_adjustment_in_pass_through_clip_base_reaches_its_group() {
|
||
let canvas = Rect::new(0, 0, 10, 8);
|
||
let mut d = doc_white(10, 8);
|
||
d.layers.push(solid_layer("bg", canvas, [0.5, 0.5, 0.5, 1.0]));
|
||
let mut a = Layer::new("A", LayerContent::Adjustment(Adjustment::Invert));
|
||
a.clipped = true;
|
||
let mut q = solid_layer("Q", Rect::new(6, 4, 8, 6), [0.5, 0.5, 0.5, 1.0]);
|
||
q.blend = BlendMode::Multiply;
|
||
let mut g = Layer::group("G", vec![solid_layer("P", Rect::new(2, 1, 4, 3), [0.2, 0.4, 0.6, 1.0]), a, q]);
|
||
g.blend = BlendMode::PassThrough;
|
||
d.layers.push(g);
|
||
let mut x = solid_layer("X", Rect::new(2, 1, 3, 2), [1.0, 0.0, 0.0, 1.0]);
|
||
x.clipped = true;
|
||
d.layers.push(x);
|
||
let shown = px(&d, 7, 5);
|
||
let LayerContent::Group(g) = &mut d.layers[2].content else { panic!("group") };
|
||
g.children[1].visible = false;
|
||
assert!(!close4(shown, px(&d, 7, 5)), "hiding A changes Q, outside A's base");
|
||
let group = Rect::new(2, 1, 8, 6);
|
||
let LayerContent::Group(g) = &d.layers[2].content else { panic!("group") };
|
||
assert_eq!(change_bounds_in(&g.children, 1, Some((&d.layers, 2)), canvas), Some(group));
|
||
// Not a clip base: the group stays pass-through either way, and A changes only its base.
|
||
d.layers.truncate(3);
|
||
let LayerContent::Group(g) = &d.layers[2].content else { panic!("group") };
|
||
assert_eq!(change_bounds_in(&g.children, 1, Some((&d.layers, 2)), canvas), Some(Rect::new(2, 1, 4, 3)));
|
||
}
|
||
|
||
#[test]
|
||
fn threshold_posterize_levels_curves() {
|
||
let mut buf = Buffer::filled(Rect::new(0, 0, 1, 1), [0.3, 0.3, 0.3, 1.0]);
|
||
adjust::apply(&Adjustment::Threshold { level: 0.5 }, &mut buf);
|
||
assert!(close4(buf.px[0], [0.0, 0.0, 0.0, 1.0]));
|
||
|
||
let mut buf = Buffer::filled(Rect::new(0, 0, 1, 1), [0.3, 0.6, 0.9, 1.0]);
|
||
adjust::apply(&Adjustment::Posterize { levels: 2 }, &mut buf);
|
||
assert!(close4(buf.px[0], [0.0, 1.0, 1.0, 1.0]));
|
||
|
||
let lv = LevelsChannel { in_black: 0.2, in_white: 0.8, ..Default::default() };
|
||
assert!((adjust::levels(&lv, 0.5) - 0.5).abs() < 1e-6);
|
||
assert!((adjust::levels(&lv, 0.2)).abs() < 1e-6);
|
||
assert!((adjust::levels(&lv, 0.9) - 1.0).abs() < 1e-6);
|
||
|
||
let identity = adjust::curve_lut(&[CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }]);
|
||
for (i, v) in identity.iter().enumerate().step_by(97) {
|
||
assert!((v - i as f32 / (identity.len() - 1) as f32).abs() < 1e-4);
|
||
}
|
||
let s = adjust::curve_lut(&[
|
||
CurvePoint { input: 0.0, output: 0.0 },
|
||
CurvePoint { input: 0.25, output: 0.15 },
|
||
CurvePoint { input: 0.75, output: 0.85 },
|
||
CurvePoint { input: 1.0, output: 1.0 },
|
||
]);
|
||
assert!(s.windows(2).all(|w| w[1] >= w[0] - 1e-6), "monotone");
|
||
assert!(s[s.len() / 4] < 0.25 && s[3 * s.len() / 4] > 0.75);
|
||
}
|
||
|
||
#[test]
|
||
fn hue_saturation_roundtrips_and_desaturates() {
|
||
let c = [0.8, 0.3, 0.1];
|
||
let (h, s, l) = adjust::rgb_to_hsl(c);
|
||
let back = adjust::hsl_to_rgb(h, s, l);
|
||
for i in 0..3 {
|
||
assert!((back[i] - c[i]).abs() < 1e-5);
|
||
}
|
||
let mut buf = Buffer::filled(Rect::new(0, 0, 1, 1), [0.8, 0.3, 0.1, 1.0]);
|
||
adjust::apply(
|
||
&Adjustment::HueSaturation { hue: 0.0, saturation: -100.0, lightness: 0.0, colorize: false, ranges: photocraft_doc::adjust::HueRange::defaults() },
|
||
&mut buf,
|
||
);
|
||
let p = buf.px[0];
|
||
assert!((p[0] - p[1]).abs() < 1e-5 && (p[1] - p[2]).abs() < 1e-5);
|
||
let mut buf = Buffer::filled(Rect::new(0, 0, 1, 1), [1.0, 0.0, 0.0, 1.0]);
|
||
adjust::apply(
|
||
&Adjustment::HueSaturation { hue: 120.0, saturation: 0.0, lightness: 0.0, colorize: false, ranges: photocraft_doc::adjust::HueRange::defaults() },
|
||
&mut buf,
|
||
);
|
||
assert!(close4(buf.px[0], [0.0, 1.0, 0.0, 1.0]));
|
||
}
|
||
|
||
#[test]
|
||
fn solid_and_gradient_fill_layers() {
|
||
let mut d = doc_white(10, 1);
|
||
d.layers.push(Layer::new("fill", LayerContent::Fill(Fill::Solid(Color::rgb(0.0, 0.0, 1.0)))));
|
||
assert!(close4(px(&d, 5, 0), [0.0, 0.0, 1.0, 1.0]));
|
||
|
||
let g = Fill::gradient(vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], 0.0, 1.0, photocraft_doc::GradientStyle::Linear, false);
|
||
let patterns = pattern::PreparedPatterns::new(&[], pattern::PREPARED_PATTERN_BYTES);
|
||
let buf = render_fill(&g, Rect::new(0, 0, 10, 1), Rect::new(0, 0, 10, 1), &patterns, None);
|
||
// tile independence: a 1px render of the right edge equals the full render
|
||
let one = render_fill(&g, Rect::new(9, 0, 10, 1), Rect::new(0, 0, 10, 1), &patterns, None);
|
||
assert_eq!(one.px[0], buf.px[9]);
|
||
assert!(buf.px[0][0] < buf.px[9][0], "left dark, right light");
|
||
}
|
||
|
||
fn dithered_gradient(stops: Vec<(f32, Color)>, angle: f32) -> Fill {
|
||
let mut f = Fill::gradient(stops, angle, 1.0, photocraft_doc::GradientStyle::Linear, false);
|
||
if let Fill::Gradient { dither, .. } = &mut f {
|
||
*dither = true;
|
||
}
|
||
f
|
||
}
|
||
|
||
/// #2755: a dithered gradient fill under a bright Color Dodge gradient. Dodge divides by
|
||
/// `1 − cs`, so the dither's fraction of a level, left in float, came out as speckle several
|
||
/// levels high (a black channel lifted to 4/255: coloured fringes). On an 8-bit document the
|
||
/// dithered pixels sit on levels, as a painted gradient's do, so a channel at 0 stays 0.
|
||
#[test]
|
||
fn dithered_gradient_fills_under_color_dodge_keep_black_channels() {
|
||
let mut d = doc_white(128, 32);
|
||
// Red to blue: green is 0 everywhere.
|
||
let bottom = dithered_gradient(vec![(0.0, Color::rgb(1.0, 0.0, 0.0)), (1.0, Color::rgb(0.0, 0.0, 1.0))], 0.0);
|
||
d.layers.push(Layer::new("bottom", LayerContent::Fill(bottom)));
|
||
let top = dithered_gradient(vec![(0.0, Color::rgb(0.88, 0.88, 0.88)), (1.0, Color::rgb(0.97, 0.97, 0.97))], 90.0);
|
||
let mut l = Layer::new("top", LayerContent::Fill(top));
|
||
l.blend = BlendMode::ColorDodge;
|
||
d.layers.push(l);
|
||
for (i, p) in flatten(&d).px.iter().enumerate() {
|
||
assert_eq!(p[1], 0.0, "pixel {i}: green lifted off black to {} levels", p[1] * 255.0);
|
||
}
|
||
}
|
||
|
||
/// Dithered gradient pixels land on the depth's levels; float documents and undithered
|
||
/// gradients keep their float values.
|
||
#[test]
|
||
fn dithered_gradient_fill_is_rounded_to_the_document_depth() {
|
||
let stops = vec![(0.0, Color::rgb(0.1, 0.4, 0.0)), (1.0, Color::rgb(0.9, 0.2, 1.0))];
|
||
let rect = Rect::new(0, 0, 64, 8);
|
||
let f = dithered_gradient(stops.clone(), 0.0);
|
||
for depth in [SampleType::U8, SampleType::U16] {
|
||
let q = adjustment_quantum(depth).unwrap();
|
||
for p in gradient_fill::render_quantized(&f, rect, rect, Some(q)) {
|
||
for v in &p[..3] {
|
||
assert_eq!((v * q).round() / q, *v, "{depth:?}: {v} is between levels");
|
||
}
|
||
}
|
||
}
|
||
assert_eq!(gradient_fill::render_quantized(&f, rect, rect, None), gradient_fill::render(&f, rect, rect));
|
||
let plain = Fill::gradient(stops, 0.0, 1.0, photocraft_doc::GradientStyle::Linear, false);
|
||
assert_eq!(gradient_fill::render_quantized(&plain, rect, rect, Some(255.0)), gradient_fill::render(&plain, rect, rect));
|
||
}
|
||
|
||
#[test]
|
||
fn dissolve_coverage_matches_opacity() {
|
||
let mut d = Document::new("d", Size::new(64, 64), ColorMode::Rgb, SampleType::U8);
|
||
let mut l = solid_layer("k", Rect::new(0, 0, 64, 64), [0.0, 0.0, 0.0, 1.0]);
|
||
l.blend = BlendMode::Dissolve;
|
||
l.opacity = 0.3;
|
||
d.layers.push(l);
|
||
let b = flatten(&d);
|
||
let covered = b.px.iter().filter(|p| p[3] > 0.5).count() as f32 / b.px.len() as f32;
|
||
assert!((covered - 0.3).abs() < 0.05, "{covered}");
|
||
// deterministic
|
||
assert_eq!(flatten(&d), b);
|
||
}
|
||
|
||
#[test]
|
||
fn render_is_tile_independent() {
|
||
// Rendering a sub-rect must equal the same region of a full render.
|
||
let mut d = doc_white(300, 300);
|
||
let mut l = solid_layer("a", Rect::new(20, 20, 280, 280), [0.1, 0.5, 0.9, 0.7]);
|
||
l.blend = BlendMode::Overlay;
|
||
d.layers.push(l);
|
||
let full = flatten(&d);
|
||
let sub = render(&d, Rect::new(250, 250, 270, 262));
|
||
for y in 250..262 {
|
||
for x in 250..270 {
|
||
assert_eq!(full.get(x, y), sub.get(x, y));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A Lab Invert layer in a 16-bit document keeps a grey neutral (the compositor passes the
|
||
/// document's depth to `adjust::apply_in`).
|
||
#[test]
|
||
fn a_16_bit_lab_invert_layer_keeps_greys_neutral() {
|
||
let mut d = Document::new("lab", Size::new(2, 2), ColorMode::Lab, SampleType::U16);
|
||
let mut bg = Layer::raster("bg", d.pixel_format());
|
||
bg.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 2), &[0.25, 128.0 / 255.0, 128.0 / 255.0, 1.0]);
|
||
d.layers.push(bg);
|
||
d.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)));
|
||
let p = px(&d, 1, 1);
|
||
let lab = photocraft_color::convert::srgb_to_lab([p[0], p[1], p[2]]);
|
||
assert!((lab[0] - 75.0).abs() < 0.1 && lab[1].abs() < 0.05 && lab[2].abs() < 0.05, "{lab:?}");
|
||
}
|
||
|
||
/// A CMYK document of one opaque raster layer holding `ink` everywhere, at `depth`.
|
||
fn cmyk_doc(ink: [f32; 4], depth: SampleType) -> Document {
|
||
let mut d = Document::new("c", Size::new(4, 4), ColorMode::Cmyk, depth);
|
||
let mut l = Layer::raster("bg", d.pixel_format());
|
||
l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 4, 4), &[ink[0], ink[1], ink[2], ink[3], 1.0]);
|
||
d.layers.push(l);
|
||
d
|
||
}
|
||
|
||
/// Adjustment layers Photoshop evaluates on the inks composite on the document's own inks, not on
|
||
/// a separation of the RGB composite: exact for Invert, Posterize, Threshold, Brightness/Contrast
|
||
/// and CMYK Levels / Curves (photoshop corpus cmyk8 and cmyk16 oracles), through opacity and an
|
||
/// (unfeathered) mask as linear ink mixes.
|
||
#[test]
|
||
fn cmyk_ink_adjustments_composite_on_the_documents_inks() {
|
||
use photocraft_color::convert::cmyk_to_rgb;
|
||
let ink = [0.2, 0.4, 0.6, 0.1];
|
||
let rgb = |i: [f32; 4]| {
|
||
let c = cmyk_to_rgb(i);
|
||
[c[0], c[1], c[2], 1.0]
|
||
};
|
||
for depth in [SampleType::U8, SampleType::U16] {
|
||
let mut d = cmyk_doc(ink, depth);
|
||
// Stored inks, as the document's surface rounds them.
|
||
let stored: [f32; 4] = std::array::from_fn(|c| d.layers[0].surface().unwrap().pixel(0, 0)[c]);
|
||
d.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)));
|
||
assert!(close4(px(&d, 1, 1), rgb(stored.map(|v| 1.0 - v))), "{depth:?} invert");
|
||
// Half opacity and a half mask mix the inks linearly: a quarter of the way.
|
||
d.layers[1].opacity = 0.5;
|
||
let mut m = LayerMask::reveal_all();
|
||
m.surface.fill_rect(Rect::new(0, 0, 4, 4), &[0.5]);
|
||
d.layers[1].mask = Some(m);
|
||
let k = 0.5 * d.layers[1].mask.as_ref().unwrap().value(1, 1);
|
||
assert!(close4(px(&d, 1, 1), rgb(stored.map(|v| v + (1.0 - 2.0 * v) * k))), "{depth:?} mixed");
|
||
// Brightness/Contrast curves each ink's stored value.
|
||
let table = adjust::ink_brightness_contrast_lut(25.0, 30.0, true);
|
||
d.layers[1] = Layer::new("bc", LayerContent::Adjustment(Adjustment::BrightnessContrast { brightness: 25.0, contrast: 30.0, legacy: true }));
|
||
assert!(close4(px(&d, 1, 1), rgb(stored.map(|v| adjust::ink_through(&table, v)))), "{depth:?} brightness/contrast");
|
||
// Flattening keeps the adjusted inks exactly, with no trip through RGB.
|
||
let flat = flatten_to_surface(&d, d.pixel_format(), None);
|
||
let got = flat.pixel(2, 2);
|
||
for c in 0..4 {
|
||
let want = adjust::ink_through(&table, stored[c]);
|
||
let q = if depth == SampleType::U8 { 255.0 } else { 65535.0 };
|
||
assert!((got[c] - want).abs() <= 0.5 / q + 1e-6, "{depth:?} flattened ink {c}: {} vs {want}", got[c]);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Only stacks with ink adjustments take the ink compositor for display: plain CMYK layers keep
|
||
/// the RGB compositor (which the GPU matches), and so does any stack with an adjustment the inks
|
||
/// don't carry or a layer they can't composite.
|
||
#[test]
|
||
fn only_cmyk_stacks_with_ink_adjustments_render_on_their_inks() {
|
||
let r = Rect::new(0, 0, 4, 4);
|
||
let mut d = cmyk_doc([0.2, 0.4, 0.6, 0.1], SampleType::U8);
|
||
assert!(native::render_cmyk(&d, r, 64).is_none(), "plain layers");
|
||
d.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)));
|
||
assert!(native::render_cmyk(&d, r, 64).is_some());
|
||
let exposure = Adjustment::Exposure { exposure: 0.5, offset: 0.0, gamma: 1.0 };
|
||
d.layers.push(Layer::new("exposure", LayerContent::Adjustment(exposure)));
|
||
assert!(native::render_cmyk(&d, r, 64).is_none(), "an adjustment the inks don't carry");
|
||
d.layers.pop();
|
||
d.layers[1].blend = BlendMode::Multiply;
|
||
assert!(native::render_cmyk(&d, r, 64).is_none(), "a blend mode");
|
||
d.layers[1].blend = BlendMode::Normal;
|
||
let mut rgb = cmyk_doc([0.2, 0.4, 0.6, 0.1], SampleType::U8);
|
||
rgb.mode = ColorMode::Rgb;
|
||
rgb.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)));
|
||
assert!(native::render_cmyk(&rgb, r, 64).is_none(), "not a CMYK document");
|
||
// Tiles render alike whatever their size.
|
||
let whole = render_tiled(&d, d.bounds(), 64);
|
||
assert_eq!(render_tiled(&d, d.bounds(), 1).px, whole.px);
|
||
}
|
||
|
||
/// Hue/Saturation (not Colorize) and Color Balance work on C, M and Y as inverted RGB with the
|
||
/// black ink kept (photoshop corpus cmyk16 hue-saturation.psd: every ink within half a level).
|
||
#[test]
|
||
fn cmyk_hue_saturation_and_color_balance_keep_the_black_ink() {
|
||
use photocraft_color::convert::cmyk_to_rgb;
|
||
let ranges = photocraft_doc::adjust::HueRange::defaults();
|
||
let hue = Adjustment::HueSaturation { hue: 40.0, saturation: 25.0, lightness: -10.0, colorize: false, ranges: ranges.clone() };
|
||
let balance =
|
||
Adjustment::ColorBalance { shadows: [20.0, -10.0, 0.0], midtones: [-15.0, 10.0, 30.0], highlights: [0.0, 0.0, -20.0], preserve_luminosity: true };
|
||
for adj in [hue, balance] {
|
||
assert!(adjust::cmy_adjustment(&adj) && adjust::cmyk_channel_adjustment(&adj));
|
||
let mut d = cmyk_doc([0.2, 0.7, 0.1, 0.3], SampleType::U16);
|
||
let stored: [f32; 4] = std::array::from_fn(|c| d.layers[0].surface().unwrap().pixel(0, 0)[c]);
|
||
d.layers.push(Layer::new("adj", LayerContent::Adjustment(adj.clone())));
|
||
// The RGB adjustment on the inks' complement, black untouched.
|
||
let mut rgb = Buffer::filled(Rect::new(0, 0, 1, 1), [1.0 - stored[0], 1.0 - stored[1], 1.0 - stored[2], 1.0]);
|
||
adjust::apply(&adj, &mut rgb);
|
||
let want = [1.0 - rgb.px[0][0], 1.0 - rgb.px[0][1], 1.0 - rgb.px[0][2], stored[3]];
|
||
assert!(native::render_cmyk(&d, d.bounds(), 64).is_some(), "{}", adj.label());
|
||
let c = cmyk_to_rgb(want);
|
||
assert!(close4(px(&d, 1, 1), [c[0], c[1], c[2], 1.0]), "{}", adj.label());
|
||
let flat = flatten_to_surface(&d, d.pixel_format(), None);
|
||
let got = flat.pixel(1, 1);
|
||
assert!((got[3] - stored[3]).abs() < 1e-4, "{}: black kept", adj.label());
|
||
assert!((0..3).all(|i| (got[i] - want[i]).abs() < 1e-3), "{}: {got:?} vs {want:?}", adj.label());
|
||
}
|
||
// Colorize separates its colour again (black included), so it stays on the screen colour.
|
||
let colorize = Adjustment::HueSaturation { hue: -160.0, saturation: 40.0, lightness: 5.0, colorize: true, ranges };
|
||
assert!(!adjust::cmy_adjustment(&colorize) && !adjust::cmyk_channel_adjustment(&colorize));
|
||
}
|
||
|
||
/// Groups at full opacity without a mask are inlined: a pass-through group's adjustment reaches
|
||
/// the layers below it, and an isolated group of raster layers composites as its children would.
|
||
/// An isolated group's adjustment (which only sees the group) or a translucent group keeps the
|
||
/// RGB compositor.
|
||
#[test]
|
||
fn cmyk_ink_compositing_inlines_open_groups() {
|
||
use photocraft_color::convert::cmyk_to_rgb;
|
||
let r = Rect::new(0, 0, 4, 4);
|
||
let mut d = cmyk_doc([0.2, 0.4, 0.6, 0.1], SampleType::U16);
|
||
let stored: [f32; 4] = std::array::from_fn(|c| d.layers[0].surface().unwrap().pixel(0, 0)[c]);
|
||
let mut g = Layer::group("g", vec![Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))]);
|
||
g.blend = BlendMode::PassThrough;
|
||
d.layers.push(g);
|
||
let c = cmyk_to_rgb(stored.map(|v| 1.0 - v));
|
||
assert!(close4(px(&d, 1, 1), [c[0], c[1], c[2], 1.0]), "a pass-through group's Invert reaches the inks below");
|
||
d.layers[1].blend = BlendMode::Normal;
|
||
assert!(native::render_cmyk(&d, r, 64).is_none(), "an isolated group's adjustment");
|
||
d.layers[1].blend = BlendMode::PassThrough;
|
||
d.layers[1].opacity = 0.5;
|
||
assert!(native::render_cmyk(&d, r, 64).is_none(), "a translucent group");
|
||
// An isolated group of raster layers, with an adjustment above it, still composites on the inks.
|
||
let mut top = Layer::raster("top", d.pixel_format());
|
||
top.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 4), &[0.9, 0.0, 0.0, 0.0, 1.0]);
|
||
d.layers[1] = Layer::group("raster", vec![top]);
|
||
d.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)));
|
||
assert!(native::render_cmyk(&d, r, 64).is_some());
|
||
let c = cmyk_to_rgb(stored.map(|v| 1.0 - v));
|
||
assert!(close4(px(&d, 3, 1), [c[0], c[1], c[2], 1.0]), "outside the group's layer");
|
||
}
|
||
|
||
#[test]
|
||
fn cmyk_document_renders_via_rgb() {
|
||
let mut d = Document::new("c", Size::new(2, 2), ColorMode::Cmyk, SampleType::U8);
|
||
let mut l = Layer::raster("k", d.pixel_format());
|
||
l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 2), &[0.0, 0.0, 0.0, 1.0, 1.0]);
|
||
d.layers.push(l);
|
||
// CMYK displays through the built-in CMYK profile: 100 % K alone is a dark neutral
|
||
// (as in any real CMYK profile), not pure black.
|
||
let p = px(&d, 0, 0);
|
||
assert!(p[0] < 0.3 && (p[0] - p[1]).abs() < 0.05 && (p[1] - p[2]).abs() < 0.05 && p[3] == 1.0, "{p:?}");
|
||
}
|
||
|
||
/// Transparent pixels convert to colour in CMYK (K = 100%) and Lab (a/b = 0.5). A flattened
|
||
/// surface must still treat them as empty, or its content spans the whole canvas.
|
||
#[test]
|
||
fn flatten_to_surface_leaves_transparent_pixels_empty_in_every_mode() {
|
||
let modes = [
|
||
(ColorMode::Rgb, [SampleType::U8, SampleType::U16, SampleType::F32].as_slice()),
|
||
(ColorMode::Grayscale, &[SampleType::U8, SampleType::U16, SampleType::F32]),
|
||
(ColorMode::Cmyk, &[SampleType::U8, SampleType::U16]),
|
||
(ColorMode::Lab, &[SampleType::U8, SampleType::U16]),
|
||
];
|
||
for (mode, samples) in modes {
|
||
for &sample in samples {
|
||
let mut d = Document::new("t", Size::new(300, 200), mode, sample);
|
||
let fmt = PixelFormat::new(mode, sample, true);
|
||
let mut l = Layer::raster("dot", fmt);
|
||
let opaque: Vec<f32> = photocraft_raster::from_rgba(&fmt, [0.9, 0.4, 0.1, 1.0]);
|
||
l.surface_mut().unwrap().fill_rect(Rect::new(120, 80, 140, 90), &opaque);
|
||
d.layers.push(l);
|
||
let s = flatten_to_surface(&d, fmt, None);
|
||
assert_eq!(s.content_bounds(), Rect::new(120, 80, 140, 90), "{mode:?} {sample:?}");
|
||
// Over a background everything is content, as before.
|
||
let fmt_opaque = PixelFormat::new(mode, sample, false);
|
||
let s = flatten_to_surface(&d, fmt_opaque, Some([1.0, 1.0, 1.0]));
|
||
assert_eq!(s.rgba(5, 5)[3], 1.0, "{mode:?} {sample:?}: background stays opaque");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn sixteen_bit_and_float_layers_composite() {
|
||
for fmt in [PixelFormat::RGBA16, PixelFormat::RGBA32F] {
|
||
let mut d = Document::new("x", Size::new(2, 2), ColorMode::Rgb, fmt.sample);
|
||
let mut l = Layer::raster("a", fmt);
|
||
l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 2), &[0.25, 0.5, 0.75, 1.0]);
|
||
d.layers.push(l);
|
||
assert!(close4(px(&d, 1, 1), [0.25, 0.5, 0.75, 1.0]), "{fmt:?}");
|
||
}
|
||
}
|
||
|
||
/// `top` blended with `mode` over an opaque `bg`, both solid, in a 4×4 RGB document of `depth`.
|
||
fn two_solid_layers(depth: SampleType, mode: BlendMode, bg: [f32; 3], top: [f32; 3]) -> Document {
|
||
let mut d = Document::new("hdr", Size::new(4, 4), ColorMode::Rgb, depth);
|
||
let fmt = d.pixel_format();
|
||
let mut b = Layer::raster("bg", fmt);
|
||
b.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 4, 4), &photocraft_raster::from_rgba(&fmt, [bg[0], bg[1], bg[2], 1.0]));
|
||
let mut t = Layer::raster("top", fmt);
|
||
t.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 4, 4), &photocraft_raster::from_rgba(&fmt, [top[0], top[1], top[2], 1.0]));
|
||
t.blend = mode;
|
||
d.layers.push(b);
|
||
d.layers.push(t);
|
||
d
|
||
}
|
||
|
||
#[test]
|
||
fn float_documents_add_and_divide_past_white() {
|
||
// 32-bit: Linear Dodge (Add) and Divide keep HDR values (adding light never darkens).
|
||
let (bg, top) = ([2.0, 0.5, 4.0], [0.5, 0.25, 0.5]);
|
||
for (mode, want) in [(BlendMode::LinearDodge, [2.5, 0.75, 4.5]), (BlendMode::Divide, [4.0, 2.0, 8.0]), (BlendMode::Multiply, [1.0, 0.125, 2.0])] {
|
||
let d = two_solid_layers(SampleType::F32, mode, bg, top);
|
||
for buf in [flatten(&d), render_tiled(&d, d.bounds(), 2)] {
|
||
for p in &buf.px {
|
||
assert!(close4(*p, [want[0], want[1], want[2], 1.0]), "{mode:?}: {p:?}");
|
||
}
|
||
}
|
||
}
|
||
// The flag doesn't leak into later renders on this thread.
|
||
let d = two_solid_layers(SampleType::U16, BlendMode::LinearDodge, [0.8, 0.5, 0.6], top);
|
||
assert!(close4(flatten(&d).px[0], [1.0, 0.75, 1.0, 1.0]));
|
||
}
|
||
|
||
#[test]
|
||
fn integer_documents_clip_add_and_divide_at_white() {
|
||
let (bg, top) = ([0.8, 0.5, 0.6], [0.5, 0.25, 0.5]);
|
||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||
let f32_doc = depth == SampleType::F32;
|
||
for (mode, clipped, hdr) in [(BlendMode::LinearDodge, [1.0, 0.75, 1.0], [1.3, 0.75, 1.1]), (BlendMode::Divide, [1.0, 1.0, 1.0], [1.6, 2.0, 1.2])] {
|
||
let want = if f32_doc { hdr } else { clipped };
|
||
let p = flatten(&two_solid_layers(depth, mode, bg, top)).px[5];
|
||
assert!(close4(p, [want[0], want[1], want[2], 1.0]), "{mode:?} {depth:?}: {p:?}");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn thumbnail_dimensions() {
|
||
let d = doc_white(400, 200);
|
||
let t = thumbnail(&d, 100);
|
||
assert_eq!((t.width, t.height), (100, 50));
|
||
assert_eq!(&t.pixels[0..4], &[255, 255, 255, 255]);
|
||
let small = doc_white(10, 5);
|
||
let t = thumbnail(&small, 100);
|
||
assert_eq!((t.width, t.height), (10, 5));
|
||
}
|
||
|
||
#[test]
|
||
fn buffer_over_background() {
|
||
let b = Buffer::filled(Rect::new(0, 0, 1, 1), [0.0, 0.0, 0.0, 0.5]);
|
||
let o = b.over_background([1.0, 1.0, 1.0]);
|
||
assert!(close4(o.px[0], [0.5, 0.5, 0.5, 1.0]));
|
||
assert_eq!(b.to_rgba8().pixels, vec![0, 0, 0, 128]);
|
||
}
|
||
|
||
// ---------- layer effects ----------
|
||
|
||
use photocraft_doc::{Effect, FxCommon, FxPaint, Glow, GlowSource, GlowTechnique, Gradient, GradientStyle, Satin, Shadow, StrokeFx, StrokePosition};
|
||
|
||
fn fx_doc(effects: Vec<Effect>) -> Document {
|
||
let mut d = doc_white(40, 40);
|
||
let mut l = solid_layer("sq", Rect::new(10, 10, 30, 30), [1.0, 0.0, 0.0, 1.0]);
|
||
l.effects.items = effects;
|
||
d.layers.push(l);
|
||
d
|
||
}
|
||
|
||
fn stroke(size: f32, position: StrokePosition) -> Effect {
|
||
Effect::Stroke(StrokeFx {
|
||
common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0),
|
||
size,
|
||
position,
|
||
paint: FxPaint::Color(Color::rgb(0.0, 0.0, 1.0)),
|
||
overprint: false,
|
||
})
|
||
}
|
||
|
||
#[test]
|
||
fn outside_stroke_width() {
|
||
let d = fx_doc(vec![stroke(3.0, StrokePosition::Outside)]);
|
||
assert!(close4(px(&d, 8, 20), [0.0, 0.0, 1.0, 1.0]));
|
||
assert!(close4(px(&d, 7, 20), [0.0, 0.0, 1.0, 1.0]));
|
||
assert!(close4(px(&d, 5, 20), [1.0; 4]), "{:?}", px(&d, 5, 20));
|
||
assert!(close4(px(&d, 20, 20), [1.0, 0.0, 0.0, 1.0]), "interior untouched");
|
||
}
|
||
|
||
#[test]
|
||
fn outside_stroke_at_zero_fill_leaves_the_interior_clear() {
|
||
// Fill 0 % + an Outside stroke is the classic "outline only" look: the stroke lies outside
|
||
// the layer's pixels, so the interior shows the backdrop, not the stroke colour.
|
||
for (depth, fmt) in [(SampleType::U8, PixelFormat::RGBA8), (SampleType::U16, PixelFormat::RGBA16), (SampleType::F32, PixelFormat::RGBA32F)] {
|
||
let mut d = Document::with_background("t", Size::new(40, 40), ColorMode::Rgb, depth, Color::WHITE);
|
||
let mut l = Layer::raster("sq", fmt);
|
||
l.surface_mut().unwrap().fill_rect(Rect::new(10, 10, 30, 30), &[1.0, 0.0, 0.0, 1.0]);
|
||
l.effects.items = vec![stroke(3.0, StrokePosition::Outside)];
|
||
l.fill_opacity = 0.0;
|
||
d.layers.push(l);
|
||
assert!(close4(px(&d, 20, 20), [1.0; 4]), "{depth:?}: interior shows the white backdrop: {:?}", px(&d, 20, 20));
|
||
assert!(close4(px(&d, 11, 20), [1.0; 4]), "{depth:?}: just inside the edge too: {:?}", px(&d, 11, 20));
|
||
assert!(close4(px(&d, 8, 20), [0.0, 0.0, 1.0, 1.0]), "{depth:?}: the stroke itself stays: {:?}", px(&d, 8, 20));
|
||
// Partial fill: the interior is the layer at that fill over the backdrop, still no stroke.
|
||
d.layers[1].fill_opacity = 0.5;
|
||
assert!(close4(px(&d, 20, 20), [1.0, 0.5, 0.5, 1.0]), "{depth:?}: {:?}", px(&d, 20, 20));
|
||
// 100 % fill is unchanged.
|
||
d.layers[1].fill_opacity = 1.0;
|
||
assert!(close4(px(&d, 20, 20), [1.0, 0.0, 0.0, 1.0]), "{depth:?}: {:?}", px(&d, 20, 20));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn inside_and_center_strokes() {
|
||
let d = fx_doc(vec![stroke(2.0, StrokePosition::Inside)]);
|
||
assert!(close4(px(&d, 10, 20), [0.0, 0.0, 1.0, 1.0]));
|
||
assert!(close4(px(&d, 11, 20), [0.0, 0.0, 1.0, 1.0]));
|
||
assert!(close4(px(&d, 13, 20), [1.0, 0.0, 0.0, 1.0]));
|
||
assert!(close4(px(&d, 9, 20), [1.0; 4]));
|
||
let d = fx_doc(vec![stroke(4.0, StrokePosition::Center)]);
|
||
assert!(close4(px(&d, 8, 20), [0.0, 0.0, 1.0, 1.0]));
|
||
assert!(close4(px(&d, 11, 20), [0.0, 0.0, 1.0, 1.0]));
|
||
assert!(close4(px(&d, 13, 20), [1.0, 0.0, 0.0, 1.0]));
|
||
}
|
||
|
||
#[test]
|
||
fn master_switch_and_per_effect_enable() {
|
||
let mut d = fx_doc(vec![stroke(3.0, StrokePosition::Outside)]);
|
||
d.layers[1].effects.enabled = false;
|
||
assert!(close4(px(&d, 8, 20), [1.0; 4]));
|
||
let mut d = fx_doc(vec![stroke(3.0, StrokePosition::Outside)]);
|
||
if let Effect::Stroke(s) = &mut d.layers[1].effects.items[0] {
|
||
s.common.enabled = false;
|
||
}
|
||
assert!(close4(px(&d, 8, 20), [1.0; 4]));
|
||
}
|
||
|
||
#[test]
|
||
fn color_overlay_ignores_fill_opacity_but_not_opacity() {
|
||
let mut d = fx_doc(vec![Effect::ColorOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), color: Color::rgb(0.0, 1.0, 0.0) }]);
|
||
d.layers[1].fill_opacity = 0.0;
|
||
assert!(close4(px(&d, 20, 20), [0.0, 1.0, 0.0, 1.0]), "overlay shows at fill 0");
|
||
d.layers[1].opacity = 0.5;
|
||
assert!(close4(px(&d, 20, 20), [0.5, 1.0, 0.5, 1.0]), "{:?}", px(&d, 20, 20));
|
||
}
|
||
|
||
fn shadow(distance: f32, angle: f32) -> Shadow {
|
||
Shadow {
|
||
common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0),
|
||
color: Color::BLACK,
|
||
angle,
|
||
use_global_light: false,
|
||
distance,
|
||
spread: 1.0,
|
||
size: 0.0,
|
||
contour: photocraft_doc::Contour::Linear,
|
||
anti_alias: false,
|
||
noise: 0.0,
|
||
knocks_out: true,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn drop_shadow_falls_away_from_light() {
|
||
// Light from the top (90°): shadow below the square.
|
||
let d = fx_doc(vec![Effect::DropShadow(shadow(5.0, 90.0))]);
|
||
assert!(close4(px(&d, 20, 32), [0.0, 0.0, 0.0, 1.0]), "{:?}", px(&d, 20, 32));
|
||
assert!(close4(px(&d, 20, 8), [1.0; 4]));
|
||
// Light from the right (0°, 3 o'clock): shadow to the left.
|
||
let d = fx_doc(vec![Effect::DropShadow(shadow(5.0, 0.0))]);
|
||
assert!(close4(px(&d, 8, 20), [0.0, 0.0, 0.0, 1.0]));
|
||
assert!(close4(px(&d, 32, 20), [1.0; 4]));
|
||
}
|
||
|
||
#[test]
|
||
fn group_effects_reach_past_children_effects() {
|
||
// A child's outside stroke reaches further than its group's drop shadow: the group's shadow
|
||
// follows the stroke, not the child's pixels alone (#3274).
|
||
let mut d = doc_white(48, 48);
|
||
let mut sq = solid_layer("sq", Rect::new(14, 14, 34, 34), [1.0, 0.0, 0.0, 1.0]);
|
||
sq.effects.items = vec![stroke(6.0, StrokePosition::Outside)];
|
||
let mut grp = Layer::group("g", vec![sq]);
|
||
grp.effects.items = vec![Effect::DropShadow(shadow(2.0, 90.0))];
|
||
d.layers.push(grp);
|
||
assert!(close4(px(&d, 9, 24), [0.0, 0.0, 1.0, 1.0]), "stroke left of the pixels: {:?}", px(&d, 9, 24));
|
||
assert!(close4(px(&d, 24, 38), [0.0, 0.0, 1.0, 1.0]), "stroke below the pixels: {:?}", px(&d, 24, 38));
|
||
assert!(close4(px(&d, 24, 41), [0.0, 0.0, 0.0, 1.0]), "shadow below the stroke: {:?}", px(&d, 24, 41));
|
||
assert!(close4(px(&d, 24, 43), [1.0; 4]), "nothing past the shadow: {:?}", px(&d, 24, 43));
|
||
}
|
||
|
||
#[test]
|
||
fn drop_shadow_uses_global_light_and_knockout() {
|
||
let mut d = fx_doc(vec![Effect::DropShadow(Shadow { use_global_light: true, ..shadow(5.0, 0.0) })]);
|
||
d.global_light.angle = 90.0;
|
||
assert!(close4(px(&d, 20, 32), [0.0, 0.0, 0.0, 1.0]));
|
||
// Knock-out: with fill 0 the shadow does not show through the layer.
|
||
d.layers[1].fill_opacity = 0.0;
|
||
assert!(close4(px(&d, 20, 28), [1.0; 4]), "{:?}", px(&d, 20, 28));
|
||
if let Effect::DropShadow(s) = &mut d.layers[1].effects.items[0] {
|
||
s.knocks_out = false;
|
||
}
|
||
assert!(close4(px(&d, 20, 28), [0.0, 0.0, 0.0, 1.0]));
|
||
}
|
||
|
||
#[test]
|
||
fn soft_shadow_is_blurred_and_bounded() {
|
||
let d = fx_doc(vec![Effect::DropShadow(Shadow { spread: 0.0, size: 6.0, ..shadow(0.0, 90.0) })]);
|
||
let edge = px(&d, 30, 20); // just outside the right edge
|
||
let far = px(&d, 39, 20);
|
||
assert!(edge[0] < 0.8 && edge[0] > 0.2, "{edge:?}");
|
||
assert!(far[0] > 0.99, "{far:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn inner_shadow_only_inside() {
|
||
let d = fx_doc(vec![Effect::InnerShadow(Shadow { knocks_out: false, ..shadow(4.0, 90.0) })]);
|
||
// Top rows of the square are shadowed (light from the top pushes the
|
||
// outside's shadow down into the shape).
|
||
assert!(close4(px(&d, 20, 11), [0.0, 0.0, 0.0, 1.0]), "{:?}", px(&d, 20, 11));
|
||
assert!(close4(px(&d, 20, 25), [1.0, 0.0, 0.0, 1.0]));
|
||
assert!(close4(px(&d, 20, 5), [1.0; 4]));
|
||
}
|
||
|
||
fn glow(technique: GlowTechnique, source: GlowSource) -> Glow {
|
||
Glow {
|
||
common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0),
|
||
paint: FxPaint::Color(Color::rgb(0.0, 1.0, 0.0)),
|
||
technique,
|
||
spread: 0.0,
|
||
size: 4.0,
|
||
contour: photocraft_doc::Contour::Linear,
|
||
anti_alias: false,
|
||
range: 0.5,
|
||
jitter: 0.0,
|
||
noise: 0.0,
|
||
source,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn outer_and_inner_glow_regions() {
|
||
let d = fx_doc(vec![Effect::OuterGlow(glow(GlowTechnique::Precise, GlowSource::Edge))]);
|
||
let near = px(&d, 9, 20);
|
||
assert!(near[1] > 0.7 && near[0] < 0.3, "{near:?}");
|
||
assert!(close4(px(&d, 3, 20), [1.0; 4]));
|
||
assert!(close4(px(&d, 20, 20), [1.0, 0.0, 0.0, 1.0]));
|
||
let d = fx_doc(vec![Effect::InnerGlow(glow(GlowTechnique::Softer, GlowSource::Edge))]);
|
||
let edge = px(&d, 10, 20);
|
||
assert!(edge[1] > 0.3, "{edge:?}");
|
||
assert!(close4(px(&d, 20, 20), [1.0, 0.0, 0.0, 1.0]));
|
||
assert!(close4(px(&d, 5, 20), [1.0; 4]));
|
||
}
|
||
|
||
// A Precise inner glow from the centre is brightest in the middle and fades towards the edge,
|
||
// like the Softer one; it used to paint nothing at all (#966).
|
||
#[test]
|
||
fn precise_inner_glow_from_center_paints_the_middle() {
|
||
let d = fx_doc(vec![Effect::InnerGlow(glow(GlowTechnique::Precise, GlowSource::Center))]);
|
||
let (center, edge) = (px(&d, 20, 20), px(&d, 10, 20));
|
||
assert!(center[1] > 0.9 && center[0] < 0.1, "green in the middle: {center:?}");
|
||
assert!(edge[0] > center[0] && edge[1] < center[1], "fades towards the edge: {edge:?} vs {center:?}");
|
||
assert!(close4(px(&d, 5, 20), [1.0; 4]));
|
||
}
|
||
|
||
// A glow gradient runs along the glow, not across the canvas: the first stop hugs the shape on
|
||
// every side and later stops lie further out, opaque until the glow fades (#443).
|
||
#[test]
|
||
fn glow_gradient_follows_the_shape() {
|
||
let red_to_blue = Gradient { stops: vec![(0.0, Color::rgb(1.0, 0.0, 0.0)), (1.0, Color::rgb(0.0, 0.0, 1.0))], ..Gradient::default() };
|
||
let g = Glow { paint: FxPaint::Gradient(red_to_blue), technique: GlowTechnique::Softer, size: 8.0, ..glow(GlowTechnique::Softer, GlowSource::Edge) };
|
||
let d = fx_doc(vec![Effect::OuterGlow(g.clone())]);
|
||
let near = px(&d, 9, 20);
|
||
for p in [px(&d, 30, 20), px(&d, 20, 9), px(&d, 20, 30)] {
|
||
assert!((0..4).all(|c| (p[c] - near[c]).abs() < 1.0 / 255.0), "{p:?} vs {near:?}");
|
||
}
|
||
assert!(near[0] > 0.8 && near[2] < 0.2 && near[1] < 0.1, "red at the edge: {near:?}");
|
||
let out = px(&d, 5, 20);
|
||
assert!(out[2] > out[0] && out[1] < 0.9, "bluer and fading further out: {out:?}");
|
||
// An inner glow's gradient also starts at the edge.
|
||
let d = fx_doc(vec![Effect::InnerGlow(g)]);
|
||
let (edge, top) = (px(&d, 10, 20), px(&d, 20, 10));
|
||
assert!((0..4).all(|c| (edge[c] - top[c]).abs() < 1.0 / 255.0), "{edge:?} vs {top:?}");
|
||
assert!(edge[0] > 0.8 && edge[2] < 0.2, "{edge:?}");
|
||
}
|
||
|
||
// A Gradient Overlay spans the layer's frame: growing the layer repaints all of it, not just the
|
||
// new pixels (#4053). Effects that don't depend on the frame repaint only what changed.
|
||
#[test]
|
||
fn relayout_damage_covers_a_layer_whose_gradient_frame_moved() {
|
||
let overlay = Effect::GradientOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), gradient: Gradient::default(), dither: false };
|
||
let grown = |a: &Document, rect: Rect, rgba: [f32; 4]| {
|
||
let mut b = a.clone();
|
||
let old = b.layers.last().unwrap().clone();
|
||
let mut l = solid_layer("sq", rect, rgba);
|
||
(l.id, l.effects) = (old.id, old.effects);
|
||
*b.layers.last_mut().unwrap() = l;
|
||
b
|
||
};
|
||
let a = fx_doc(vec![overlay.clone()]);
|
||
let b = grown(&a, Rect::new(10, 10, 36, 34), [1.0, 0.0, 0.0, 1.0]);
|
||
let d = crate::relayout_damage(&a, &b);
|
||
assert!(d.contains_rect(&Rect::new(10, 10, 36, 34)), "{d:?}");
|
||
// Same bounds, other pixels: the frame stays, the pixels' own damage is enough.
|
||
assert_eq!(crate::relayout_damage(&a, &grown(&a, Rect::new(10, 10, 30, 30), [0.0, 1.0, 0.0, 1.0])), Rect::EMPTY);
|
||
// A Color Overlay doesn't depend on the frame.
|
||
let c = fx_doc(vec![Effect::ColorOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), color: Color::BLACK }]);
|
||
assert_eq!(crate::relayout_damage(&c, &grown(&c, Rect::new(10, 10, 36, 34), [1.0, 0.0, 0.0, 1.0])), Rect::EMPTY);
|
||
// Nor does a disabled overlay.
|
||
let mut off = a.clone();
|
||
off.layers.last_mut().unwrap().effects.enabled = false;
|
||
assert_eq!(crate::relayout_damage(&off, &grown(&off, Rect::new(10, 10, 36, 34), [1.0, 0.0, 0.0, 1.0])), Rect::EMPTY);
|
||
}
|
||
|
||
#[test]
|
||
fn gradient_overlay_follows_angle_and_reverse() {
|
||
let g = Gradient { stops: vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], angle: 0.0, ..Gradient::default() };
|
||
let d = fx_doc(vec![Effect::GradientOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), gradient: g.clone(), dither: false }]);
|
||
let (l, r) = (px(&d, 10, 20), px(&d, 29, 20));
|
||
assert!(l[0] < 0.1 && r[0] > 0.9, "{l:?} {r:?}");
|
||
let d = fx_doc(vec![Effect::GradientOverlay {
|
||
common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0),
|
||
gradient: Gradient { reverse: true, style: GradientStyle::Linear, ..g },
|
||
dither: false,
|
||
}]);
|
||
assert!(px(&d, 10, 20)[0] > 0.9);
|
||
}
|
||
|
||
#[test]
|
||
fn satin_and_bevel_stay_inside_shape() {
|
||
let satin = Effect::Satin(Satin {
|
||
common: FxCommon::new(photocraft_color::BlendMode::Multiply, 1.0),
|
||
color: Color::BLACK,
|
||
angle: 19.0,
|
||
distance: 4.0,
|
||
size: 4.0,
|
||
contour: photocraft_doc::Contour::Linear,
|
||
anti_alias: true,
|
||
invert: false,
|
||
});
|
||
let d = fx_doc(vec![
|
||
satin,
|
||
Effect::BevelEmboss(photocraft_doc::Bevel {
|
||
enabled: true,
|
||
style: photocraft_doc::BevelStyle::InnerBevel,
|
||
technique: photocraft_doc::BevelTechnique::Smooth,
|
||
depth: 1.0,
|
||
up: true,
|
||
size: 4.0,
|
||
soften: 0.0,
|
||
angle: 90.0,
|
||
altitude: 30.0,
|
||
use_global_light: false,
|
||
gloss_contour: photocraft_doc::Contour::Linear,
|
||
gloss_anti_alias: false,
|
||
highlight: FxCommon::new(photocraft_color::BlendMode::Screen, 0.75),
|
||
highlight_color: Color::WHITE,
|
||
shadow: FxCommon::new(photocraft_color::BlendMode::Multiply, 0.75),
|
||
shadow_color: Color::BLACK,
|
||
contour: None,
|
||
texture: None,
|
||
}),
|
||
]);
|
||
for (x, y) in [(5, 5), (35, 20), (20, 35)] {
|
||
assert!(close4(px(&d, x, y), [1.0; 4]), "({x},{y}) {:?}", px(&d, x, y));
|
||
}
|
||
// Light from the top: the top bevel edge is brighter than the bottom one.
|
||
let (top, bot) = (px(&d, 20, 11), px(&d, 20, 28));
|
||
assert!(top[1] > bot[1], "{top:?} {bot:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn clipped_layer_effects_are_clipped_to_base() {
|
||
let mut d = doc_white(40, 40);
|
||
d.layers.push(solid_layer("base", Rect::new(10, 10, 30, 30), [1.0, 0.0, 0.0, 1.0]));
|
||
let mut c = solid_layer("clip", Rect::new(20, 10, 40, 30), [0.0, 1.0, 0.0, 1.0]);
|
||
c.clipped = true;
|
||
c.effects.items.push(stroke(3.0, StrokePosition::Outside));
|
||
d.layers.push(c);
|
||
assert!(close4(px(&d, 18, 20), [0.0, 0.0, 1.0, 1.0]), "stroke inside base");
|
||
assert!(close4(px(&d, 20, 8), [1.0; 4]), "stroke clipped outside base");
|
||
}
|
||
|
||
#[test]
|
||
fn group_effects_apply_to_group_shape() {
|
||
let mut d = doc_white(40, 40);
|
||
let mut g = Layer::group(
|
||
"g",
|
||
vec![solid_layer("a", Rect::new(10, 10, 20, 30), [1.0, 0.0, 0.0, 1.0]), solid_layer("b", Rect::new(20, 10, 30, 30), [1.0, 0.0, 0.0, 1.0])],
|
||
);
|
||
g.effects.items.push(stroke(2.0, StrokePosition::Outside));
|
||
d.layers.push(g);
|
||
assert!(close4(px(&d, 9, 20), [0.0, 0.0, 1.0, 1.0]));
|
||
assert!(close4(px(&d, 20, 20), [1.0, 0.0, 0.0, 1.0]), "no stroke at the seam between children");
|
||
}
|
||
|
||
#[test]
|
||
fn effects_render_identically_in_tiles() {
|
||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||
let mut d = fx_doc(vec![Effect::DropShadow(Shadow { spread: 0.0, size: 5.0, ..shadow(4.0, 120.0) }), stroke(2.0, StrokePosition::Outside)]);
|
||
d.depth = depth;
|
||
for layer in &mut d.layers {
|
||
if let Some(surface) = layer.surface_mut() {
|
||
*surface = surface.convert(photocraft_color::PixelFormat { sample: depth, ..surface.format() });
|
||
}
|
||
}
|
||
let full = flatten(&d);
|
||
// Compare every boundary pixel: a few sampled points miss application-halo regressions.
|
||
for tile in [1, 7, 33] {
|
||
assert_eq!(render_tiled(&d, d.bounds(), tile), full, "{depth:?}, tile {tile}");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn parallel_tiles_match_single_pass() {
|
||
// A document exercising masks, clipping, groups, adjustments, fills and effects.
|
||
let mut d = doc_white(97, 61);
|
||
let mut a = solid_layer("a", Rect::new(5, 5, 70, 50), [1.0, 0.2, 0.1, 0.8]);
|
||
let mut m = LayerMask::reveal_all();
|
||
m.surface.fill_rect(Rect::new(0, 0, 40, 61), &[0.25]);
|
||
m.density = 0.8;
|
||
a.mask = Some(m);
|
||
a.effects.items.push(stroke(3.0, StrokePosition::Outside));
|
||
d.layers.push(a);
|
||
let mut clip = solid_layer("clip", Rect::new(30, 0, 97, 61), [0.0, 1.0, 0.0, 1.0]);
|
||
clip.clipped = true;
|
||
clip.blend = photocraft_color::BlendMode::Multiply;
|
||
d.layers.push(clip);
|
||
let mut g = Layer::group("g", vec![solid_layer("in", Rect::new(50, 20, 90, 60), [0.2, 0.3, 0.9, 1.0])]);
|
||
g.opacity = 0.6;
|
||
d.layers.push(g);
|
||
d.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)));
|
||
let mut f = Layer::new("fill", LayerContent::Fill(Fill::Solid(Color::rgb(0.5, 0.5, 0.0))));
|
||
f.opacity = 0.3;
|
||
d.layers.push(f);
|
||
let r = d.bounds();
|
||
let whole = render_tiled(&d, r, 10_000);
|
||
for tile in [7, 16, 33] {
|
||
assert_eq!(render_tiled(&d, r, tile), whole, "tile {tile}");
|
||
}
|
||
assert_eq!(render(&d, r), whole);
|
||
}
|
||
|
||
#[test]
|
||
fn vector_mask_combines_with_pixel_mask() {
|
||
use photocraft_doc::{Path, Subpath, VectorMask};
|
||
let mut d = doc_white(8, 8);
|
||
let mut l = solid_layer("k", Rect::new(0, 0, 8, 8), [0.0, 0.0, 0.0, 1.0]);
|
||
// Vector mask reveals x in 0..4 (and half of column 4); the pixel mask hides rows 0..2.
|
||
l.vector_mask = Some(VectorMask::new(Path::new(vec![Subpath::polygon(&[(0.0, 0.0), (4.5, 0.0), (4.5, 8.0), (0.0, 8.0)])])));
|
||
let mut m = LayerMask::reveal_all();
|
||
m.surface.fill_rect(Rect::new(0, 0, 8, 2), &[0.0]);
|
||
l.mask = Some(m);
|
||
d.layers.push(l);
|
||
assert!(close4(px(&d, 1, 4), [0.0, 0.0, 0.0, 1.0]));
|
||
assert!(close4(px(&d, 6, 4), [1.0; 4]));
|
||
assert!(close4(px(&d, 4, 4), [0.5, 0.5, 0.5, 1.0]));
|
||
assert!(close4(px(&d, 1, 1), [1.0; 4]));
|
||
// Tiled rendering gives the same result.
|
||
let whole = render_tiled(&d, d.bounds(), 3);
|
||
assert_eq!(whole.px, render_tiled(&d, d.bounds(), 256).px);
|
||
// Density and disabling.
|
||
let top = d.layers.len() - 1;
|
||
let vm = d.layers[top].vector_mask.as_mut().unwrap();
|
||
vm.density = 0.5;
|
||
assert!(close4(px(&d, 6, 4), [0.5, 0.5, 0.5, 1.0]));
|
||
d.layers[top].vector_mask.as_mut().unwrap().enabled = false;
|
||
assert!(close4(px(&d, 6, 4), [0.0, 0.0, 0.0, 1.0]));
|
||
// Without a pixel mask the vector mask alone applies.
|
||
d.layers[top].mask = None;
|
||
d.layers[top].vector_mask.as_mut().unwrap().enabled = true;
|
||
d.layers[top].vector_mask.as_mut().unwrap().density = 1.0;
|
||
assert!(close4(px(&d, 1, 1), [0.0, 0.0, 0.0, 1.0]));
|
||
assert!(close4(px(&d, 6, 1), [1.0; 4]));
|
||
}
|
||
|
||
#[test]
|
||
fn effect_maps_are_cached_and_invalidated_by_pixel_changes() {
|
||
let mut doc = doc_white(64, 64);
|
||
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 patterns = pattern::PreparedPatterns::new(&doc.patterns, pattern::PREPARED_PATTERN_BYTES);
|
||
let cx = Ctx::for_doc(&doc, &patterns);
|
||
let a = effect_maps(&doc.layers[1], doc.bounds(), &cx);
|
||
let b = effect_maps(&doc.layers[1], doc.bounds(), &cx);
|
||
assert!(std::sync::Arc::ptr_eq(&a, &b), "second request hits the cache");
|
||
let first = flatten(&doc);
|
||
// Editing the layer's pixels changes its tiles, so the maps are rebuilt.
|
||
doc.layers[1].surface_mut().unwrap().fill_rect(Rect::new(8, 8, 20, 20), &[0.0, 0.0, 1.0, 1.0]);
|
||
let patterns = pattern::PreparedPatterns::new(&doc.patterns, pattern::PREPARED_PATTERN_BYTES);
|
||
let cx = Ctx::for_doc(&doc, &patterns);
|
||
let c = effect_maps(&doc.layers[1], doc.bounds(), &cx);
|
||
assert!(!std::sync::Arc::ptr_eq(&a, &c), "pixel edit invalidates");
|
||
// Cached rendering equals a fresh build (tiled and full renders agree too).
|
||
let again = flatten(&doc);
|
||
let region = doc.bounds();
|
||
let tiled = render_tiled(&doc, region, 16);
|
||
for (p, q) in again.px.iter().zip(&tiled.px) {
|
||
assert!(close4(*p, *q));
|
||
}
|
||
assert_ne!(first.px, again.px);
|
||
|
||
// Tileless masks differ only in their defaults; reusing their effect maps leaves a ghost shadow.
|
||
let mut masked = fx_doc(vec![Effect::DropShadow(shadow(5.0, 90.0))]);
|
||
masked.layers[1].mask = Some(photocraft_doc::LayerMask::reveal_all());
|
||
assert!(close4(px(&masked, 20, 32), [0.0, 0.0, 0.0, 1.0]));
|
||
masked.layers[1].mask = Some(photocraft_doc::LayerMask::hide_all());
|
||
let warm = flatten(&masked);
|
||
let mut cold = masked.clone();
|
||
cold.layers[1].id = Layer::raster("uncached", cold.pixel_format()).id;
|
||
assert!(warm.px == flatten(&cold).px, "default-only mask changes must invalidate cached effects");
|
||
assert!(close4(px(&masked, 20, 32), [1.0; 4]));
|
||
}
|
||
|
||
// ---------- Transparency Shapes Layer off on tall documents (#1909) ----------
|
||
|
||
fn bevel_fx(style: photocraft_doc::BevelStyle, soften: f32) -> Effect {
|
||
Effect::BevelEmboss(photocraft_doc::Bevel {
|
||
enabled: true,
|
||
style,
|
||
technique: photocraft_doc::BevelTechnique::Smooth,
|
||
depth: 1.0,
|
||
up: true,
|
||
size: 5.0,
|
||
soften,
|
||
angle: 120.0,
|
||
altitude: 30.0,
|
||
use_global_light: false,
|
||
gloss_contour: photocraft_doc::Contour::Linear,
|
||
gloss_anti_alias: false,
|
||
highlight: FxCommon::new(photocraft_color::BlendMode::Screen, 0.75),
|
||
highlight_color: Color::WHITE,
|
||
shadow: FxCommon::new(photocraft_color::BlendMode::Multiply, 0.75),
|
||
shadow_color: Color::BLACK,
|
||
contour: None,
|
||
texture: None,
|
||
})
|
||
}
|
||
|
||
/// A tall page of layers with Transparency Shapes Layer off, one effect stack each (every local
|
||
/// kind), most masked to a soft-edged block so their effect shapes vary down the page.
|
||
fn shapeless_fx_doc(depth: SampleType) -> Document {
|
||
use photocraft_doc::BevelStyle;
|
||
let mut d = Document::with_background("t", Size::new(48, 640), ColorMode::Rgb, depth, Color::WHITE);
|
||
let satin = Effect::Satin(Satin {
|
||
common: FxCommon::new(photocraft_color::BlendMode::Multiply, 1.0),
|
||
color: Color::BLACK,
|
||
angle: 19.0,
|
||
distance: 4.0,
|
||
size: 5.0,
|
||
contour: photocraft_doc::Contour::Linear,
|
||
anti_alias: true,
|
||
invert: false,
|
||
});
|
||
let stacks = vec![
|
||
vec![stroke(3.0, StrokePosition::Outside)],
|
||
vec![stroke(2.0, StrokePosition::Inside), Effect::DropShadow(Shadow { size: 6.0, spread: 0.3, noise: 0.2, ..shadow(5.0, 120.0) })],
|
||
vec![stroke(4.0, StrokePosition::Center)],
|
||
vec![Effect::InnerShadow(Shadow { size: 4.0, spread: 0.0, ..shadow(3.0, 30.0) })],
|
||
vec![Effect::OuterGlow(Glow { size: 6.0, spread: 0.2, noise: 0.1, ..glow(GlowTechnique::Softer, GlowSource::Edge) })],
|
||
vec![Effect::InnerGlow(glow(GlowTechnique::Softer, GlowSource::Center)), satin],
|
||
vec![bevel_fx(BevelStyle::Emboss, 2.0)],
|
||
vec![bevel_fx(BevelStyle::PillowEmboss, 0.0), bevel_fx(BevelStyle::OuterBevel, 1.0)],
|
||
vec![stroke(3.0, StrokePosition::Outside), Effect::InnerGlow(glow(GlowTechnique::Softer, GlowSource::Edge))],
|
||
];
|
||
let mask_fmt = PixelFormat::new(ColorMode::Grayscale, depth, false);
|
||
for (i, fx) in stacks.into_iter().enumerate() {
|
||
let y = 24 + i as i32 * 66;
|
||
let mut l = Layer::raster(format!("fx{i}"), d.pixel_format());
|
||
let c = [0.2 + 0.08 * i as f32, 0.5, 1.0 - 0.1 * i as f32, 1.0];
|
||
l.surface_mut().unwrap().fill_rect(Rect::new(10, y, 38, y + 18), &photocraft_raster::from_rgba(&d.pixel_format(), c));
|
||
l.effects.items = fx;
|
||
l.advanced.transparency_shapes = false;
|
||
l.opacity = 0.8;
|
||
// The last layer stays unmasked: its shape is the whole canvas, edged only at its sides.
|
||
if i < 8 {
|
||
let mut m = photocraft_raster::Surface::new(mask_fmt);
|
||
m.fill_rect(Rect::new(4 + i as i32, y - 12, 44, y + 30), &[1.0]);
|
||
m.fill_rect(Rect::new(4 + i as i32, y + 30, 30, y + 34), &[0.5]);
|
||
l.mask = Some(LayerMask { surface: m, enabled: true, linked: true, density: 1.0, feather: 0.0 });
|
||
}
|
||
d.layers.push(l);
|
||
}
|
||
// A tsly-off stroked layer inside a group whose own effects reach much further: the group's
|
||
// effect shape composites its children over the group's whole region, far past any band.
|
||
let mut inner = Layer::raster("inner", d.pixel_format());
|
||
inner.surface_mut().unwrap().fill_rect(Rect::new(12, 300, 30, 310), &photocraft_raster::from_rgba(&d.pixel_format(), [0.1, 0.9, 0.3, 1.0]));
|
||
inner.effects.items = vec![stroke(3.0, StrokePosition::Outside)];
|
||
inner.advanced.transparency_shapes = false;
|
||
let mut m = photocraft_raster::Surface::new(mask_fmt);
|
||
m.fill_rect(Rect::new(8, 240, 36, 380), &[1.0]);
|
||
inner.mask = Some(LayerMask { surface: m, enabled: true, linked: true, density: 1.0, feather: 0.0 });
|
||
let mut g = Layer::group("g", vec![inner]);
|
||
g.effects.items = vec![Effect::DropShadow(Shadow { size: 40.0, spread: 0.1, ..shadow(90.0, 70.0) }), stroke(120.0, StrokePosition::Outside)];
|
||
g.opacity = 0.6;
|
||
d.layers.push(g);
|
||
// A clipping mask: a tsly-off stroked layer clipped to a base with large effects of its own.
|
||
let mut base = Layer::raster("base", d.pixel_format());
|
||
base.surface_mut().unwrap().fill_rect(Rect::new(6, 420, 42, 470), &photocraft_raster::from_rgba(&d.pixel_format(), [0.9, 0.9, 0.2, 1.0]));
|
||
base.effects.items = vec![Effect::OuterGlow(Glow { size: 150.0, ..glow(GlowTechnique::Softer, GlowSource::Edge) })];
|
||
let mut clip = Layer::raster("clip", d.pixel_format());
|
||
clip.surface_mut().unwrap().fill_rect(Rect::new(14, 430, 34, 450), &photocraft_raster::from_rgba(&d.pixel_format(), [0.6, 0.1, 0.6, 1.0]));
|
||
clip.effects.items = vec![stroke(2.0, StrokePosition::Center), Effect::InnerShadow(Shadow { size: 3.0, spread: 0.0, ..shadow(2.0, 45.0) })];
|
||
clip.advanced.transparency_shapes = false;
|
||
clip.clipped = true;
|
||
d.layers.push(base);
|
||
d.layers.push(clip);
|
||
d
|
||
}
|
||
|
||
#[test]
|
||
fn shapeless_effects_render_the_same_in_bands_and_parts() {
|
||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||
let d = shapeless_fx_doc(depth);
|
||
// Bands and parts first, from an empty cache: maps a band builds (a group's, say) must not
|
||
// borrow from a whole render's.
|
||
purge_effect_cache();
|
||
let mut banded = Vec::new();
|
||
render_bands(&d, d.bounds(), 64, |b| -> Result<(), ()> {
|
||
banded.extend_from_slice(&b.px);
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
purge_effect_cache();
|
||
let parts: Vec<(Rect, Buffer)> =
|
||
[Rect::new(3, 290, 41, 333), Rect::new(0, 0, 48, 20), Rect::new(20, 600, 48, 640), Rect::new(0, 296, 20, 312), Rect::new(10, 425, 40, 445)]
|
||
.into_iter()
|
||
.map(|r| (r, render(&d, r)))
|
||
.collect();
|
||
let whole = render(&d, d.bounds());
|
||
assert_eq!(banded.len(), whole.px.len());
|
||
let diff = banded.iter().zip(&whole.px).position(|(a, b)| a != b);
|
||
assert!(diff.is_none(), "{depth:?}: band render differs at {diff:?}");
|
||
// A small part (an edit's damage) and a tiled render agree with the whole render too.
|
||
for (part, p) in &parts {
|
||
let part = *part;
|
||
for y in part.y0..part.y1 {
|
||
for x in part.x0..part.x1 {
|
||
assert_eq!(p.get(x, y), whole.get(x, y), "{depth:?}: part {part:?} differs at ({x},{y})");
|
||
}
|
||
}
|
||
}
|
||
assert!(render_tiled(&d, d.bounds(), 16).px == whole.px, "{depth:?}: tiled render differs");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn shapeless_effect_maps_cover_only_the_band_being_rendered() {
|
||
// Transparency Shapes Layer off makes an effect region the whole canvas: on a 1920 × 103675
|
||
// page each stroked type layer's maps took ~2.4 GB, for every layer at once (#1909). Rendering
|
||
// a band builds them over that band (plus the effects' reach) only.
|
||
let d = shapeless_fx_doc(SampleType::U8);
|
||
let patterns = pattern::PreparedPatterns::new(&d.patterns, pattern::PREPARED_PATTERN_BYTES);
|
||
let cx = Ctx::for_doc(&d, &patterns);
|
||
let l = &d.layers[9];
|
||
let whole = effect_maps(l, Rect::new(0, 256, 48, 320), &cx).bytes();
|
||
cx.set_fx_clip(Rect::new(0, 256, 48, 320));
|
||
let band = effect_maps(l, Rect::new(0, 256, 48, 320), &cx).bytes();
|
||
assert!(band * 4 < whole, "band maps {band} B vs whole-canvas maps {whole} B");
|
||
// The next band lets the previous band's maps go.
|
||
cx.set_fx_clip(Rect::new(0, 320, 48, 384));
|
||
let _ = effect_maps(l, Rect::new(0, 320, 48, 384), &cx);
|
||
assert_eq!(cx.fx_maps.lock().unwrap().len(), 2, "the unclipped maps and this band's");
|
||
// Composited over more than the band (a group's effect shape renders its children over the
|
||
// group's whole region): the whole region.
|
||
let full = effect_region(l, Rect::new(0, 320, 48, 384), &cx).0;
|
||
assert_eq!(
|
||
effect_region(l, Rect::new(0, 200, 48, 500), &cx),
|
||
(layer_bounds(l, cx.canvas).inflate(effects::margin(l)).intersect(&cx.canvas.inflate(effects::margin(l))), false)
|
||
);
|
||
assert!(full.height() < 100, "{full:?}");
|
||
// Effects whose maps aren't local keep the whole region.
|
||
let mut g = l.clone();
|
||
g.effects.items = vec![Effect::OuterGlow(glow(GlowTechnique::Precise, GlowSource::Edge))];
|
||
assert_eq!(
|
||
effect_region(&g, Rect::new(0, 320, 48, 384), &cx),
|
||
(layer_bounds(&g, cx.canvas).inflate(effects::margin(&g)).intersect(&cx.canvas.inflate(effects::margin(&g))), false)
|
||
);
|
||
}
|
||
|
||
// ---------- PSD-fidelity effect semantics (fitted on Photoshop composites) ----------
|
||
|
||
#[test]
|
||
fn stroke_edges_use_the_chamfer_when_narrow_and_the_exact_distance_when_wide() {
|
||
// #3646: a narrow stroke's outer edge keeps the 5 × 5 chamfer, whose sub-pixel seeding
|
||
// Photoshop matches at small offsets (see `stroke_outside_exact`); only a wide one — where the
|
||
// chamfer's octagon facets (see `a_wide_outside_stroke_is_round_not_a_polygon`) — uses the
|
||
// exact Euclidean transform.
|
||
// Square 10..30, outside stroke 3: (9, 7) is offset (1, 3) from the corner pixel. Photoshop
|
||
// measures √5 + 1 = 3.236 there, so coverage is 3 + 1 − 3.236 = 0.764 (the exact distance is
|
||
// 3.162, coverage 0.838).
|
||
let d = fx_doc(vec![stroke(3.0, StrokePosition::Outside)]);
|
||
let a = 1.0 - px(&d, 9, 7)[0]; // blue stroke over white: red channel drops by coverage
|
||
assert!((a - 0.764).abs() < 0.01, "{a}");
|
||
// (2, 2) is the same in both metrics (two diagonal steps).
|
||
let a = 1.0 - px(&d, 8, 8)[0];
|
||
assert!((a - 1.0).abs() < 0.01, "{a}");
|
||
}
|
||
|
||
#[test]
|
||
fn the_stroke_metric_switches_at_the_fitted_threshold() {
|
||
// The widest outside stroke any Photoshop oracle matches with the chamfer is 18 px
|
||
// (ag-psd read-write/strokes): its octagon deviates 0.48 px from a circle there; 20 px keeps
|
||
// it. Wider strokes (the 60 px #3646 case) are round.
|
||
assert!(!crate::effects::stroke_outside_exact(18.0));
|
||
assert!(crate::effects::stroke_outside_exact(crate::effects::STROKE_CHAMFER_MAX));
|
||
assert!(crate::effects::stroke_outside_exact(60.0));
|
||
}
|
||
|
||
#[test]
|
||
fn a_wide_outside_stroke_is_round_not_a_polygon() {
|
||
// A point source dilated by 60 px must stay a circle. The 5 × 5 chamfer's octagonal level set
|
||
// made the boundary swing ~2.5 px at this radius (~10 px at 245 px) — the "low-poly" #3646.
|
||
let mut d = doc_white(170, 170);
|
||
let mut l = solid_layer("dot", Rect::new(85, 85, 86, 86), [1.0, 0.0, 0.0, 1.0]);
|
||
l.effects.items = vec![stroke(60.0, StrokePosition::Outside)];
|
||
d.layers.push(l);
|
||
let buf = render(&d, Rect::new(0, 0, 170, 170));
|
||
let at = |x: i32, y: i32| buf.px[(y as usize) * 170 + x as usize];
|
||
let (mut lo, mut hi) = (f64::MAX, 0.0f64);
|
||
let stroke_at = |x: i32, y: i32| x >= 0 && y >= 0 && x < 170 && y < 170 && at(x, y)[2] > 0.5 && at(x, y)[0] < 0.5;
|
||
for k in 0..72 {
|
||
let th = (k as f64) * std::f64::consts::TAU / 72.0;
|
||
let (dx, dy) = (th.cos(), th.sin());
|
||
let mut r = 0.0f64;
|
||
let mut t = 0.0f64;
|
||
while t < 84.0 {
|
||
if stroke_at((85.0 + dx * t).round() as i32, (85.0 + dy * t).round() as i32) {
|
||
r = t;
|
||
}
|
||
t += 0.25;
|
||
}
|
||
lo = lo.min(r);
|
||
hi = hi.max(r);
|
||
}
|
||
assert!(hi - lo <= 2.0, "the 60 px stroke boundary swings {lo}..{hi} px — a polygon, not a circle (#3646)");
|
||
}
|
||
|
||
#[test]
|
||
fn inside_stroke_respects_partial_edge_coverage() {
|
||
// A 25 %-alpha edge column puts the edge 0.75 px further out: a 3 px inside stroke then
|
||
// reaches 75 % into the fourth column, and the edge column takes the stroke colour at 25 %.
|
||
let mut d = doc_white(40, 40);
|
||
let mut l = solid_layer("sq", Rect::new(10, 10, 30, 30), [1.0, 0.0, 0.0, 1.0]);
|
||
l.surface_mut().unwrap().fill_rect(Rect::new(10, 10, 11, 30), &[1.0, 0.0, 0.0, 0.25]);
|
||
l.effects.items = vec![stroke(3.0, StrokePosition::Inside)];
|
||
d.layers.push(l);
|
||
let edge = px(&d, 10, 20);
|
||
assert!(close4(edge, [0.75, 0.75, 1.0, 1.0]), "edge = blue stroke at 25 % over white: {edge:?}");
|
||
let p = px(&d, 13, 20);
|
||
assert!((p[2] - 0.75).abs() < 0.02 && (p[0] - 0.25).abs() < 0.02, "{p:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn emboss_shades_the_composited_layer() {
|
||
// Emboss styles paint over the layer as composited (for type: mixed at the text gamma), so a
|
||
// Normal white highlight `k` on an edge pixel keeps (1 − R) = (1 − k)(1 − C) whatever C is:
|
||
// the ratio of type to raster results equals that of their plain composites.
|
||
let emboss = Effect::BevelEmboss(photocraft_doc::Bevel {
|
||
enabled: true,
|
||
style: photocraft_doc::BevelStyle::Emboss,
|
||
technique: photocraft_doc::BevelTechnique::Smooth,
|
||
depth: 1.0,
|
||
up: true,
|
||
size: 6.0,
|
||
soften: 0.0,
|
||
angle: 90.0,
|
||
altitude: 30.0,
|
||
use_global_light: false,
|
||
gloss_contour: photocraft_doc::Contour::Linear,
|
||
gloss_anti_alias: false,
|
||
highlight: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0),
|
||
highlight_color: Color::WHITE,
|
||
shadow: FxCommon::new(photocraft_color::BlendMode::Multiply, 0.0),
|
||
shadow_color: Color::BLACK,
|
||
contour: None,
|
||
texture: None,
|
||
});
|
||
let make = |text: bool, fx: bool| {
|
||
let mut d = doc_white(40, 40);
|
||
let mut l = solid_layer("sq", Rect::new(10, 10, 30, 30), [0.2, 0.1, 0.6, 1.0]);
|
||
l.surface_mut().unwrap().fill_rect(Rect::new(10, 10, 30, 11), &[0.2, 0.1, 0.6, 0.5]);
|
||
if text {
|
||
let t = photocraft_doc::TextLayer { cache: l.surface().cloned(), ..Default::default() };
|
||
l = Layer::new("t", LayerContent::Text(t));
|
||
}
|
||
if fx {
|
||
l.effects.items = vec![emboss.clone()];
|
||
}
|
||
d.layers.push(l);
|
||
px(&d, 20, 10)
|
||
};
|
||
let (ct, cr, rt, rr) = (make(true, false), make(false, false), make(true, true), make(false, true));
|
||
assert!(rr[1] > cr[1] + 0.05, "the top edge is lit: {rr:?} vs {cr:?}");
|
||
assert!((ct[1] - cr[1]).abs() > 0.02, "text gamma changes the edge: {ct:?} {cr:?}");
|
||
for c in 0..3 {
|
||
let want = (1.0 - ct[c]) / (1.0 - cr[c]);
|
||
assert!(((1.0 - rt[c]) / (1.0 - rr[c]) - want).abs() < 2e-3, "channel {c}: {rt:?} {rr:?} {ct:?} {cr:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn interior_effects_keep_the_layer_alpha() {
|
||
// A colour overlay replaces a half-transparent pixel's colour without adding coverage.
|
||
let mut d = Document::new("t", Size::new(40, 40), ColorMode::Rgb, SampleType::U8);
|
||
let mut l = solid_layer("sq", Rect::new(10, 10, 30, 30), [1.0, 0.0, 0.0, 0.5]);
|
||
l.effects.items = vec![Effect::ColorOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), color: Color::rgb(0.0, 1.0, 0.0) }];
|
||
d.layers.push(l);
|
||
assert!(close4(px(&d, 20, 20), [0.0, 1.0, 0.0, 0.5]), "{:?}", px(&d, 20, 20));
|
||
}
|
||
|
||
#[test]
|
||
fn upper_stroke_blends_with_the_backdrop_not_the_lower_stroke() {
|
||
// Multiply yellow (3 px) listed above a Normal blue (8 px): the inner ring multiplies the
|
||
// white backdrop (stays yellow), the outer ring is blue.
|
||
let yellow = Effect::Stroke(StrokeFx {
|
||
common: FxCommon::new(photocraft_color::BlendMode::Multiply, 1.0),
|
||
size: 3.0,
|
||
position: StrokePosition::Outside,
|
||
paint: FxPaint::Color(Color::rgb(1.0, 1.0, 0.0)),
|
||
overprint: false,
|
||
});
|
||
let d = fx_doc(vec![yellow, stroke(8.0, StrokePosition::Outside)]);
|
||
assert!(close4(px(&d, 8, 20), [1.0, 1.0, 0.0, 1.0]), "{:?}", px(&d, 8, 20));
|
||
assert!(close4(px(&d, 4, 20), [0.0, 0.0, 1.0, 1.0]), "{:?}", px(&d, 4, 20));
|
||
}
|
||
|
||
#[test]
|
||
fn shadow_knockout_needs_see_through_fill() {
|
||
// At 100 % fill an anti-aliased edge is not attenuated twice: the result equals no knockout.
|
||
let mut base = Document::new("t", Size::new(40, 40), ColorMode::Rgb, SampleType::U8);
|
||
base.layers.push(solid_layer("bg", Rect::new(0, 0, 40, 40), [1.0; 4]));
|
||
let mut l = solid_layer("sq", Rect::new(10, 10, 30, 30), [1.0, 0.0, 0.0, 1.0]);
|
||
l.surface_mut().unwrap().fill_rect(Rect::new(10, 10, 11, 30), &[1.0, 0.0, 0.0, 0.5]);
|
||
l.effects.items = vec![Effect::DropShadow(Shadow { distance: 0.0, spread: 1.0, size: 4.0, ..shadow(0.0, 0.0) })];
|
||
base.layers.push(l);
|
||
let on = px(&base, 10, 20);
|
||
if let Effect::DropShadow(s) = &mut base.layers[1].effects.items[0] {
|
||
s.knocks_out = false;
|
||
}
|
||
assert!(close4(on, px(&base, 10, 20)), "{on:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn shadow_knockout_beneath_a_layer_in_a_blending_mode() {
|
||
// Layer Knocks Out Drop Shadow keeps the shadow out from under the layer's shape. In Normal
|
||
// mode an opaque layer covers it anyway; in a mode that blends with what's beneath, the shadow
|
||
// would show through: under a white Exclusion square on white, it read as white (white over
|
||
// the black shadow) instead of black (white over white), so a logo's Exclusion bar with a
|
||
// drop shadow came out blank. A red Multiply square came out black instead of red.
|
||
for (blend, colour, inside, depth) in [
|
||
(photocraft_color::BlendMode::Exclusion, [1.0, 1.0, 1.0, 1.0], [0.0, 0.0, 0.0, 1.0]),
|
||
(photocraft_color::BlendMode::Multiply, [1.0, 0.0, 0.0, 1.0], [1.0, 0.0, 0.0, 1.0]),
|
||
]
|
||
.into_iter()
|
||
.flat_map(|c| SampleType::ALL.map(|depth| (c, depth)))
|
||
.map(|((b, c, i), depth)| (b, c, i, depth))
|
||
{
|
||
let mut d = Document::new("t", Size::new(40, 40), ColorMode::Rgb, depth);
|
||
d.layers.push(solid_layer("bg", Rect::new(0, 0, 40, 40), [1.0; 4]));
|
||
let mut l = solid_layer("sq", Rect::new(10, 10, 30, 30), colour);
|
||
l.blend = blend;
|
||
l.effects.items = vec![Effect::DropShadow(shadow(0.0, 90.0))];
|
||
d.layers.push(l);
|
||
assert!(close4(px(&d, 20, 20), inside), "{blend:?} {depth:?}: {:?}", px(&d, 20, 20));
|
||
// Not knocked out, the shadow shows through the blend, as in Photoshop with the box unticked.
|
||
if let Effect::DropShadow(s) = &mut d.layers[1].effects.items[0] {
|
||
s.knocks_out = false;
|
||
}
|
||
assert!(!close4(px(&d, 20, 20), inside), "{blend:?} {depth:?} without knockout: {:?}", px(&d, 20, 20));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn linked_pattern_overlay_anchors_at_the_effects_reference_point() {
|
||
use photocraft_doc::pattern::Pattern;
|
||
// 2 × 1 tile: red, blue. Anchored at x = 11 (reference point), x = 11 is red, 12 blue.
|
||
let mut tile = photocraft_raster::Surface::new(PixelFormat::RGBA8);
|
||
tile.fill_rect(Rect::new(0, 0, 1, 1), &[1.0, 0.0, 0.0, 1.0]);
|
||
tile.fill_rect(Rect::new(1, 0, 2, 1), &[0.0, 0.0, 1.0, 1.0]);
|
||
let pat = Pattern::new("rb", tile, 2, 1);
|
||
let overlay = Effect::PatternOverlay {
|
||
common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0),
|
||
name: "rb".into(),
|
||
id: pat.id.clone(),
|
||
scale: 1.0,
|
||
angle: 0.0,
|
||
link: true,
|
||
phase: (0.0, 0.0),
|
||
};
|
||
let mut d = fx_doc(vec![overlay]);
|
||
d.patterns.push(pat);
|
||
// Without a reference point the layer's top-left (10) anchors the tiling.
|
||
assert!(close4(px(&d, 10, 20), [1.0, 0.0, 0.0, 1.0]), "{:?}", px(&d, 10, 20));
|
||
d.layers[1].effects.reference = Some((11.0, 0.0));
|
||
assert!(close4(px(&d, 11, 20), [1.0, 0.0, 0.0, 1.0]), "{:?}", px(&d, 11, 20));
|
||
assert!(close4(px(&d, 12, 20), [0.0, 0.0, 1.0, 1.0]), "{:?}", px(&d, 12, 20));
|
||
}
|
||
|
||
#[test]
|
||
fn channel_restrictions_keep_the_backdrop() {
|
||
// Blue left out (Photoshop's Advanced Blending › Channels: R, G only).
|
||
let mut d = doc_white(8, 8);
|
||
let mut l = solid_layer("dark", Rect::new(0, 0, 8, 8), [0.2, 0.3, 0.4, 1.0]);
|
||
l.excluded_channels = 0b100;
|
||
d.layers.push(l);
|
||
assert!(close4(px(&d, 1, 1), [0.2, 0.3, 1.0, 1.0]));
|
||
// Adjustment layers honour it too.
|
||
let mut inv = Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert));
|
||
inv.excluded_channels = 0b001;
|
||
d.layers.push(inv);
|
||
assert!(close4(px(&d, 1, 1), [0.2, 0.7, 0.0, 1.0]));
|
||
// CMYK documents composite in display RGB: no exact equivalent, ignored.
|
||
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]));
|
||
}
|
||
|
||
fn range(black: [u8; 2], white: [u8; 2]) -> photocraft_doc::BlendRange {
|
||
photocraft_doc::BlendRange { black, white }
|
||
}
|
||
|
||
const FULL: photocraft_doc::BlendRange = photocraft_doc::BlendRange::FULL;
|
||
|
||
#[test]
|
||
fn blend_if_this_layer_hides_by_the_layers_own_value() {
|
||
// Mid-grey backdrop; the layer is black on the left, white on the right.
|
||
let mut d = doc_white(8, 8);
|
||
d.layers.push(solid_layer("grey", Rect::new(0, 0, 8, 8), [0.5, 0.5, 0.5, 1.0]));
|
||
let mut l = solid_layer("bw", Rect::new(0, 0, 4, 8), [0.0, 0.0, 0.0, 1.0]);
|
||
l.surface_mut().unwrap().fill_rect(Rect::new(4, 0, 8, 8), &[1.0, 1.0, 1.0, 1.0]);
|
||
// Gray › This Layer: black point at 50 hides the blacks.
|
||
l.blend_if.set(0, [range([50, 50], [255, 255]), FULL]);
|
||
d.layers.push(l);
|
||
assert!(close4(px(&d, 1, 1), [0.5, 0.5, 0.5, 1.0]), "{:?}", px(&d, 1, 1));
|
||
assert!(close4(px(&d, 6, 1), [1.0, 1.0, 1.0, 1.0]));
|
||
// White point at 200 hides the whites as well.
|
||
d.layers[2].blend_if.set(0, [range([50, 50], [200, 200]), FULL]);
|
||
assert!(close4(px(&d, 6, 1), [0.5, 0.5, 0.5, 1.0]));
|
||
// Back to the defaults: everything shows again.
|
||
d.layers[2].blend_if.set(0, [FULL, FULL]);
|
||
assert!(d.layers[2].blend_if.is_default());
|
||
assert!(close4(px(&d, 1, 1), [0.0, 0.0, 0.0, 1.0]));
|
||
}
|
||
|
||
#[test]
|
||
fn blend_if_underlying_layer_hides_by_the_backdrop_value() {
|
||
// Backdrop black on the left, white on the right; a red layer over all of it.
|
||
let mut d = doc_white(8, 8);
|
||
d.layers.push(solid_layer("black", Rect::new(0, 0, 4, 8), [0.0, 0.0, 0.0, 1.0]));
|
||
let mut l = solid_layer("red", Rect::new(0, 0, 8, 8), [1.0, 0.0, 0.0, 1.0]);
|
||
// Gray › Underlying Layer: white point at 128 = only over the darks (sky-replacement style).
|
||
l.blend_if.set(0, [FULL, range([0, 0], [128, 128])]);
|
||
d.layers.push(l);
|
||
assert!(close4(px(&d, 1, 1), [1.0, 0.0, 0.0, 1.0]));
|
||
assert!(close4(px(&d, 6, 1), [1.0, 1.0, 1.0, 1.0]));
|
||
}
|
||
|
||
/// An 8-bit backdrop at level `k` counts as `k` for a hard Underlying black point at `k`, also
|
||
/// over a semi-transparent backdrop, where compositing leaves the value a few millionths off the
|
||
/// level (19.999998 for 20 before #4395); the GPU compositor agrees.
|
||
#[test]
|
||
fn blend_if_hard_point_includes_its_own_level() {
|
||
for k in 0..=255u8 {
|
||
let v = f32::from(k) / 255.0;
|
||
let mut d = Document::new("t", Size::new(2, 1), ColorMode::Rgb, SampleType::U8);
|
||
d.layers.push(solid_layer("under", Rect::new(0, 0, 2, 1), [v, v, v, 184.0 / 255.0]));
|
||
let mut l = solid_layer("top", Rect::new(0, 0, 2, 1), [1.0, 0.0, 0.0, 1.0]);
|
||
l.blend_if.set(1, [FULL, range([k, k], [255, 255])]);
|
||
d.layers.push(l);
|
||
assert!(close4(px(&d, 0, 0), [1.0, 0.0, 0.0, 1.0]), "level {k} is hidden by a black point at {k}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn blend_if_split_point_fades_like_opacity() {
|
||
// A split black point (Alt-drag) at 50/150 shows a value-100 layer at half strength, which
|
||
// is exactly the layer at 50% opacity, also over a semi-transparent backdrop.
|
||
let v = 100.0 / 255.0;
|
||
let mut d = doc_white(4, 4);
|
||
d.layers[0].surface_mut().unwrap().fill_rect(Rect::new(0, 0, 4, 4), &[0.0, 0.2, 1.0, 0.5]);
|
||
let mut l = solid_layer("v", Rect::new(0, 0, 4, 4), [v, v, v, 1.0]);
|
||
l.blend = BlendMode::Multiply;
|
||
let mut half = l.clone();
|
||
half.opacity = 0.5;
|
||
l.blend_if.set(0, [range([50, 150], [255, 255]), FULL]);
|
||
let mut d2 = d.clone();
|
||
d.layers.push(l);
|
||
d2.layers.push(half);
|
||
let (a, b) = (px(&d, 1, 1), px(&d2, 1, 1));
|
||
assert!(a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-4), "{a:?} vs {b:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn blend_if_per_channel_ranges() {
|
||
// Blue › This Layer: hide pixels whose blue is above 100.
|
||
let mut d = doc_white(8, 8);
|
||
let mut l = solid_layer("c", Rect::new(0, 0, 4, 8), [1.0, 0.0, 0.0, 1.0]);
|
||
l.surface_mut().unwrap().fill_rect(Rect::new(4, 0, 8, 8), &[0.0, 0.0, 1.0, 1.0]);
|
||
l.blend_if.set(3, [range([0, 0], [100, 100]), FULL]);
|
||
d.layers.push(l);
|
||
assert!(close4(px(&d, 1, 1), [1.0, 0.0, 0.0, 1.0]));
|
||
assert!(close4(px(&d, 6, 1), [1.0, 1.0, 1.0, 1.0]));
|
||
// Red › Underlying Layer: the backdrop's red (255) is above 254 → hidden everywhere.
|
||
d.layers[1].blend_if = Default::default();
|
||
d.layers[1].blend_if.set(1, [FULL, range([0, 0], [254, 254])]);
|
||
assert!(close4(px(&d, 1, 1), [1.0; 4]));
|
||
}
|
||
|
||
#[test]
|
||
fn blend_if_on_adjustment_and_clipped_layers() {
|
||
let mut d = doc_white(8, 8);
|
||
d.layers.push(solid_layer("dark", Rect::new(0, 0, 4, 8), [0.1, 0.1, 0.1, 1.0]));
|
||
// Invert, but only where the backdrop is dark (Underlying white point at 128).
|
||
let mut inv = Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert));
|
||
inv.blend_if.set(0, [FULL, range([0, 0], [128, 128])]);
|
||
d.layers.push(inv);
|
||
assert!(close4(px(&d, 1, 1), [0.9, 0.9, 0.9, 1.0]));
|
||
assert!(close4(px(&d, 6, 1), [1.0; 4]));
|
||
// A clipped layer judges "underlying" by its clipping base.
|
||
let mut d = doc_white(8, 8);
|
||
let mut base = solid_layer("base", Rect::new(0, 0, 8, 8), [0.0, 0.0, 0.0, 1.0]);
|
||
base.surface_mut().unwrap().fill_rect(Rect::new(4, 0, 8, 8), &[0.9, 0.9, 0.9, 1.0]);
|
||
let mut clip = solid_layer("clip", Rect::new(0, 0, 8, 8), [0.0, 1.0, 0.0, 1.0]);
|
||
clip.clipped = true;
|
||
clip.blend_if.set(0, [FULL, range([128, 128], [255, 255])]);
|
||
d.layers.push(base);
|
||
d.layers.push(clip);
|
||
assert!(close4(px(&d, 1, 1), [0.0, 0.0, 0.0, 1.0]));
|
||
assert!(close4(px(&d, 6, 1), [0.0, 1.0, 0.0, 1.0]));
|
||
}
|
||
|
||
#[test]
|
||
fn blend_if_modes() {
|
||
// Grayscale documents: the single channel's entry (or the gray entry) applies.
|
||
let mut d = Document::with_background("g", Size::new(4, 4), ColorMode::Grayscale, SampleType::U8, Color::WHITE);
|
||
let mut l = Layer::raster("k", d.pixel_format());
|
||
l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 4, 4), &[0.0, 1.0]);
|
||
l.blend_if.set(1, [range([10, 10], [255, 255]), FULL]);
|
||
d.layers.push(l);
|
||
assert!(close4(px(&d, 1, 1), [1.0; 4]), "{:?}", px(&d, 1, 1));
|
||
// CMYK/Lab composite in display RGB: kept for round trip, not applied.
|
||
assert!(blend_if_active(&d.layers[1], ColorMode::Rgb));
|
||
assert!(blend_if_active(&d.layers[1], ColorMode::Grayscale));
|
||
assert!(!blend_if_active(&d.layers[1], ColorMode::Cmyk));
|
||
assert!(!blend_if_active(&d.layers[1], ColorMode::Lab));
|
||
assert!(!blend_if_active(&d.layers[0], ColorMode::Rgb));
|
||
}
|
||
|
||
#[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:?}"),
|
||
}
|
||
}
|
||
}
|
||
|
||
// Ground truth captured from Adobe Photoshop 2026 (27.10.0): a 0..255 ramp pushed through modern
|
||
// (non-legacy) Brightness/Contrast via `executeAction("BrgC", ... useLegacy=false)`. These pin our
|
||
// reverse-engineered curves (see crates/compose/src/adjust.rs modern_* and log/devlog.md).
|
||
#[test]
|
||
fn modern_contrast_matches_photoshop() {
|
||
// (contrast, [out at x = 0,16,32,64,96,128,160,192,224,255])
|
||
let cases: [(f32, [u8; 10]); 4] = [
|
||
(50.0, [0, 11, 23, 52, 87, 128, 169, 204, 233, 255]),
|
||
(-50.0, [0, 21, 41, 76, 105, 128, 151, 180, 215, 255]),
|
||
(100.0, [0, 5, 14, 40, 78, 128, 178, 216, 242, 255]),
|
||
(-25.0, [0, 19, 37, 70, 101, 128, 155, 186, 220, 255]),
|
||
];
|
||
let xs = [0usize, 16, 32, 64, 96, 128, 160, 192, 224, 255];
|
||
for (c, out) in cases {
|
||
for (i, &x) in xs.iter().enumerate() {
|
||
let got = adjust::modern_contrast(x as f32 / 255.0, c) * 255.0;
|
||
let err = (got - out[i] as f32).abs();
|
||
assert!(err <= 2.0, "contrast {c} x={x}: got {got:.1} want {} (err {err:.1})", out[i]);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Over the whole slider range the curve stays monotone and inside 0..1, lowering it undoes
|
||
/// raising it by as much, and odd input never panics.
|
||
#[test]
|
||
fn modern_brightness_is_monotone_and_lowering_inverts_raising() {
|
||
for b in (-150..=150).step_by(5).map(|b| b as f32) {
|
||
let mut prev = 0.0f32;
|
||
for k in 0..=1020 {
|
||
let v = adjust::modern_brightness(k as f32 / 1020.0, b);
|
||
assert!((0.0..=1.0).contains(&v) && v >= prev - 1e-6, "brightness {b} at {k}/1020: {v} after {prev}");
|
||
prev = v;
|
||
}
|
||
assert!(
|
||
(adjust::modern_brightness(1.0, b) - 1.0).abs() < 1e-5 && adjust::modern_brightness(0.0, b).abs() < 1e-5,
|
||
"brightness {b} pins black and white"
|
||
);
|
||
for k in 0..=50 {
|
||
let x = k as f32 / 50.0;
|
||
let back = adjust::modern_brightness(adjust::modern_brightness(x, b), -b);
|
||
assert!((back - x).abs() < 2e-3, "brightness {b}: {x} -> {back}");
|
||
}
|
||
}
|
||
for v in [f32::NAN, f32::INFINITY, -3.0, 7.0] {
|
||
let _ = adjust::modern_brightness(v, 40.0);
|
||
let _ = adjust::modern_brightness(0.5, v);
|
||
}
|
||
assert_eq!(adjust::modern_lut(0.0, 0.0).len(), 4096);
|
||
// The table agrees with the curves it samples, lowered (inverted in one pass) or raised.
|
||
for (b, c) in [(-50.0, 30.0), (-150.0, -50.0), (40.0, 0.0), (0.0, 100.0)] {
|
||
let t = adjust::modern_lut(b, c);
|
||
for k in (0..4096).step_by(37) {
|
||
let x = k as f32 / 4095.0;
|
||
let want = adjust::modern_contrast(adjust::modern_brightness(x, b), c);
|
||
assert!((t[k] - want).abs() < 1e-3, "({b}, {c}) at {k}: {} vs {want}", t[k]);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn modern_brightness_matches_photoshop() {
|
||
let cases: [(f32, [u8; 10]); 2] = [(50.0, [0, 22, 44, 88, 132, 171, 203, 228, 246, 255]), (-50.0, [0, 12, 23, 47, 70, 93, 118, 148, 186, 255])];
|
||
let xs = [0usize, 16, 32, 64, 96, 128, 160, 192, 224, 255];
|
||
for (b, out) in cases {
|
||
for (i, &x) in xs.iter().enumerate() {
|
||
let got = adjust::modern_brightness(x as f32 / 255.0, b) * 255.0;
|
||
let err = (got - out[i] as f32).abs();
|
||
// Within about a level of Photoshop's (rounded) output everywhere.
|
||
assert!(err <= 1.2, "brightness {b} x={x}: got {got:.1} want {} (err {err:.1})", out[i]);
|
||
}
|
||
}
|
||
// Photoshop 27.11's brightness +25 with contrast +30 (photoshop corpus rgb8
|
||
// brightness-contrast.psd): what the oracle's levels become.
|
||
let oracle = [
|
||
(8, 7),
|
||
(24, 23),
|
||
(48, 49),
|
||
(72, 78),
|
||
(88, 99),
|
||
(104, 120),
|
||
(128, 154),
|
||
(144, 174),
|
||
(160, 192),
|
||
(176, 208),
|
||
(192, 222),
|
||
(200, 228),
|
||
(216, 239),
|
||
(232, 247),
|
||
(240, 250),
|
||
(248, 253),
|
||
];
|
||
for (x, want) in oracle {
|
||
let got = adjust::modern_contrast(adjust::modern_brightness(x as f32 / 255.0, 25.0), 30.0) * 255.0;
|
||
assert!((got - want as f32).abs() <= 1.0, "+25/+30 x={x}: got {got:.2} want {want}");
|
||
}
|
||
// psd-tools' brightnesscontrast_rgb.psd masks four layers to their own columns: the brightness
|
||
// each applies, read back through its (exact) contrast curve.
|
||
// (brightness, tolerance in levels, [(input level, Photoshop's output level)])
|
||
type Measured = (f32, f32, &'static [(u32, f32)]);
|
||
let measured: [Measured; 4] = [
|
||
(-95.0, 1.5, &[(24, 13.3), (64, 35.1), (96, 52.9), (128, 69.7), (160, 89.6), (192, 114.4), (224, 151.6)]),
|
||
(-31.0, 1.5, &[(24, 20.3), (64, 53.4), (96, 79.0), (128, 105.6), (160, 132.3), (192, 161.8), (224, 197.4)]),
|
||
(23.0, 1.5, &[(24, 27.9), (64, 74.0), (96, 110.9), (128, 147.7), (160, 182.9), (192, 213.7), (224, 239.6)]),
|
||
(150.0, 6.0, &[(24, 60.3), (64, 162.1), (96, 214.5), (128, 238.4), (160, 248.9)]),
|
||
];
|
||
for (b, tol, points) in measured {
|
||
for &(x, want) in points {
|
||
let got = adjust::modern_brightness(x as f32 / 255.0, b) * 255.0;
|
||
assert!((got - want).abs() <= tol, "brightness {b} x={x}: got {got:.1} want {want}");
|
||
}
|
||
}
|
||
// Endpoints and the zero slider are exact.
|
||
assert_eq!(adjust::modern_brightness(0.0, 120.0), 0.0);
|
||
assert_eq!(adjust::modern_brightness(1.0, -120.0), 1.0);
|
||
assert_eq!(adjust::modern_brightness(0.37, 0.0), 0.37);
|
||
assert_eq!(adjust::modern_contrast(0.37, 0.0), 0.37);
|
||
}
|
||
|
||
// Levels ground truth from Adobe Photoshop 2026 (adjustLevels on a 0..255 ramp). Verifies our
|
||
// `levels()` matches the real app, including the gamma>1 shadow toe (initial slope 2^gamma).
|
||
#[test]
|
||
fn levels_matches_photoshop() {
|
||
let ident = |g: f32| LevelsChannel { in_black: 0.0, in_white: 1.0, gamma: g, out_black: 0.0, out_white: 1.0 };
|
||
// gamma 2.0 (lifts shadows): x -> Photoshop out. The deep shadow is the toe-bounded region.
|
||
let g2: [(usize, u8); 8] = [(1, 4), (8, 30), (16, 55), (32, 90), (64, 128), (128, 181), (192, 221), (224, 239)];
|
||
for (x, ps) in g2 {
|
||
let got = (adjust::levels(&ident(2.0), x as f32 / 255.0) * 255.0).round();
|
||
assert!((got - ps as f32).abs() <= 4.0, "levels gamma 2.0 x={x}: got {got} want {ps}");
|
||
}
|
||
// gamma 0.5 (darkens midtones) is exact: out = (x/255)^2.
|
||
for x in [0usize, 32, 64, 128, 192, 255] {
|
||
let got = (adjust::levels(&ident(0.5), x as f32 / 255.0) * 255.0).round();
|
||
let want = ((x as f32 / 255.0).powi(2) * 255.0).round();
|
||
assert_eq!(got, want, "levels gamma 0.5 x={x}");
|
||
}
|
||
// Endpoints pinned; identity is identity. (The gamma>1 toe ends before white, so white
|
||
// stays exactly 1.0.)
|
||
assert_eq!(adjust::levels(&ident(2.5), 0.0), 0.0);
|
||
assert_eq!((adjust::levels(&ident(2.5), 1.0) * 255.0).round(), 255.0);
|
||
assert!((adjust::levels(&ident(2.5), 1.0) - 1.0).abs() < 5e-4);
|
||
assert!((adjust::levels(&ident(1.0), 0.37) - 0.37).abs() < 1e-6);
|
||
}
|
||
|
||
// Photoshop (psd-tools levels_grayscale, 8-bit): input range 44..214, gamma 1.78. The stretch
|
||
// is rounded to whole levels before the gamma (45 → 1.5 → 2, 47 → 4.5 → 5), so the shadows
|
||
// step unevenly: 45, 46, 47, 48, 52, 69 → 7, 10, 17, 20, 37, 88.
|
||
#[test]
|
||
fn levels_work_on_whole_levels() {
|
||
let ch = LevelsChannel { in_black: 44.0 / 255.0, in_white: 214.0 / 255.0, gamma: 1.78, out_black: 0.0, out_white: 1.0 };
|
||
let adj = Adjustment::Levels {
|
||
master: LevelsChannel::default(),
|
||
per_channel: [ch.clone(), ch.clone(), ch.clone()],
|
||
space: Default::default(),
|
||
black: LevelsChannel::default(),
|
||
};
|
||
let luts = adjust::tone_luts_q(&adj, Some(255.0));
|
||
let at = |v: u8| {
|
||
let x = f32::from(v) / 255.0 * 4095.0;
|
||
let (i, f) = (x.floor() as usize, x.fract());
|
||
let y = luts[0][i] * (1.0 - f) + luts[0][(i + 1).min(4095)] * f;
|
||
(y * 255.0).round()
|
||
};
|
||
for (v, ps) in [(44u8, 0.0f32), (45, 7.0), (46, 10.0), (47, 17.0), (48, 20.0), (52, 37.0), (69, 88.0), (100, 137.0), (214, 255.0)] {
|
||
assert!((at(v) - ps).abs() <= 1.0, "levels {v}: got {} want {ps}", at(v));
|
||
}
|
||
// 16-bit and float stay smooth (no 8-bit staircase).
|
||
for q in [Some(32768.0), None] {
|
||
let a = adjust::levels_q(&ch, 0.2, q);
|
||
let b = adjust::levels_q(&ch, 0.2 + 1.0 / 2048.0, q);
|
||
assert!(b > a && b - a < 2.0 / 255.0, "{q:?}: {a} {b}");
|
||
}
|
||
// Hostile quantum values fall back to the continuous curve.
|
||
for q in [Some(0.0), Some(f32::NAN), Some(-3.0)] {
|
||
assert_eq!(adjust::levels_q(&ch, 0.4, q), adjust::levels(&ch, 0.4));
|
||
}
|
||
}
|
||
|
||
// Every Levels step lands on a whole level in 8 bits, and the gamma's shadow toe starts at
|
||
// slope 2^gamma (`adjust::levels_gamma`). Level → Photoshop's level, exact, from:
|
||
// - psd-tools levels_rgb.psd, Levels 2 (each channel, then the master): master 0..213, gamma
|
||
// 1.22, output 48..255; R gamma 0.47 to 0..194; B 61..255, gamma 0.47, to 0..213. Narrow
|
||
// output ranges merge neighbouring levels (R 43..48 → 58, G 8, 9 → 63, B 89, 90 → 54).
|
||
// - the photoshop corpus's rgb8 levels.psd: master 15..230, gamma 1.3, output 10..245.
|
||
// - psd-tools levels_grayscale.psd, Levels 3: 44..214, gamma 1.78 (52 → 37 is one level off).
|
||
#[test]
|
||
fn levels_steps_match_photoshop() {
|
||
let ch = |ib: f32, iw: f32, gamma: f32, ob: f32, ow: f32| LevelsChannel {
|
||
in_black: ib / 255.0,
|
||
in_white: iw / 255.0,
|
||
gamma,
|
||
out_black: ob / 255.0,
|
||
out_white: ow / 255.0,
|
||
};
|
||
let id = LevelsChannel::default();
|
||
let levels = |master: LevelsChannel, per_channel: [LevelsChannel; 3]| Adjustment::Levels {
|
||
master,
|
||
per_channel,
|
||
space: Default::default(),
|
||
black: LevelsChannel::default(),
|
||
};
|
||
let run = |adj: &Adjustment, xs: &[u8]| {
|
||
let px = xs.iter().map(|&x| f32::from(x) / 255.0).map(|v| [v, v, v, 1.0]).collect();
|
||
let mut b = Buffer { rect: Rect::new(0, 0, xs.len() as i32, 1), px };
|
||
adjust::apply_depth(adj, &mut b, adjust::Transfer::Srgb, Some(SampleType::U8));
|
||
b.px
|
||
};
|
||
let rgb = levels(ch(0.0, 213.0, 1.22, 48.0, 255.0), [ch(0.0, 255.0, 0.47, 0.0, 194.0), id.clone(), ch(61.0, 255.0, 0.47, 0.0, 213.0)]);
|
||
let xs = [2, 8, 9, 15, 16, 43, 45, 48, 55, 61, 72, 79, 84, 89, 90, 101, 106, 120, 150, 180, 200];
|
||
let want: [[u8; 21]; 3] = [
|
||
[48, 48, 48, 50, 50, 58, 58, 58, 63, 63, 69, 72, 76, 79, 79, 86, 89, 100, 123, 153, 174],
|
||
[51, 63, 63, 72, 72, 103, 106, 109, 116, 122, 133, 141, 145, 149, 150, 160, 165, 178, 204, 228, 244],
|
||
[48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 50, 51, 54, 54, 54, 63, 63, 76, 102, 136, 164],
|
||
];
|
||
for (k, p) in run(&rgb, &xs).iter().enumerate() {
|
||
for c in 0..3 {
|
||
assert_eq!((p[c] * 255.0).round(), f32::from(want[c][k]), "levels_rgb channel {c} at {}", xs[k]);
|
||
}
|
||
}
|
||
let single = |c: LevelsChannel| levels(c, [id.clone(), id.clone(), id.clone()]);
|
||
let oracles: [(Adjustment, &[u8], &[u8]); 2] = [
|
||
(
|
||
single(ch(15.0, 230.0, 1.3, 10.0, 245.0)),
|
||
&[0, 21, 22, 49, 82, 118, 154, 176, 195, 215, 235, 251],
|
||
&[10, 25, 27, 66, 106, 144, 178, 198, 215, 232, 245, 245],
|
||
),
|
||
(
|
||
single(ch(44.0, 214.0, 1.78, 0.0, 255.0)),
|
||
&[44, 45, 46, 47, 48, 49, 50, 51, 53, 55, 57, 60, 65, 70, 80, 100, 150, 200, 214],
|
||
&[0, 7, 10, 17, 20, 26, 29, 35, 43, 51, 58, 66, 79, 89, 107, 137, 196, 243, 255],
|
||
),
|
||
];
|
||
for (adj, xs, want) in &oracles {
|
||
for ((p, x), w) in run(adj, xs).iter().zip(*xs).zip(*want) {
|
||
assert_eq!((p[0] * 255.0).round(), f32::from(*w), "levels at {x}");
|
||
}
|
||
}
|
||
}
|
||
|
||
// The shadow toe stays a well-behaved curve for any gamma: from 0 to 1, rising, and without a
|
||
// jump where it meets the power curve.
|
||
#[test]
|
||
fn levels_gamma_toe_is_monotone_for_any_gamma() {
|
||
for g in [1.0001, 1.05, 1.5, 3.0, 9.99, 1e6, f32::INFINITY] {
|
||
let mut last = 0.0f32;
|
||
for k in 0..=4096 {
|
||
let u = k as f32 / 4096.0;
|
||
let t = adjust::levels_gamma(u, g);
|
||
assert!(t.is_finite() && (0.0..=1.0 + 1e-6).contains(&t), "gamma {g} at {u}: {t}");
|
||
assert!(t >= last - 1e-6, "gamma {g} falls at {u}: {last} -> {t}");
|
||
// Never steeper than the toe's initial slope 2^g.
|
||
assert!(k == 0 || t - last <= 2f32.powf(g) / 4096.0 + 1e-4, "gamma {g} jumps at {u}: {last} -> {t}");
|
||
last = t;
|
||
}
|
||
assert!((adjust::levels_gamma(1.0, g) - 1.0).abs() < 1e-6, "gamma {g}: white");
|
||
}
|
||
}
|
||
|
||
// The oracle corpus' rgb32 levels.psd: input 15..230, gamma 1.3, output 10..245. In a
|
||
// 32-bit document neither range clips and the gamma is a plain power curve mirrored below black:
|
||
// 0.0497 → 3 (the clipped curve gives 10), 0.9473 → 255 (245). Integer documents still clip.
|
||
#[test]
|
||
fn levels_dont_clip_in_32_bit() {
|
||
let ch = LevelsChannel { in_black: 15.0 / 255.0, in_white: 230.0 / 255.0, gamma: 1.3, out_black: 10.0 / 255.0, out_white: 245.0 / 255.0 };
|
||
let adj = Adjustment::Levels { master: ch, per_channel: Default::default(), space: Default::default(), black: LevelsChannel::default() };
|
||
let level = |depth, v| {
|
||
let mut b = Buffer::filled(Rect::new(0, 0, 1, 1), [v, v, v, 1.0]);
|
||
adjust::apply_depth(&adj, &mut b, adjust::Transfer::Srgb, Some(depth));
|
||
(b.px[0][0] * 255.0).round()
|
||
};
|
||
for (v, want) in [(0.0497, 3.0), (0.0976, 32.0), (0.4508, 140.0), (0.9473, 255.0)] {
|
||
assert_eq!(level(SampleType::F32, v), want, "{v}");
|
||
}
|
||
assert!(level(SampleType::F32, 0.0) < 0.0);
|
||
assert!(level(SampleType::F32, 1.0) > 255.0);
|
||
assert_eq!(level(SampleType::U8, 0.0), 10.0);
|
||
assert_eq!(level(SampleType::U8, 1.0), 245.0);
|
||
}
|
||
|
||
// Exposure linearises RGB documents through a 2.2 power, not the sRGB curve: Photoshop lifts 76
|
||
// to 134 with an offset of 0.1738 (psd-tools adjustment_nested_composition_4).
|
||
#[test]
|
||
fn exposure_offset_uses_gamma_2_2_in_rgb() {
|
||
let mut b = Buffer::filled(Rect::new(0, 0, 1, 1), [76.0 / 255.0, 76.0 / 255.0, 76.0 / 255.0, 1.0]);
|
||
adjust::apply_with(&Adjustment::Exposure { exposure: 0.0, offset: 0.1738, gamma: 1.0 }, &mut b, adjust::Transfer::Srgb);
|
||
assert_eq!((b.px[0][0] * 255.0).round(), 134.0);
|
||
assert_eq!(adjust::Transfer::Gamma(1.732).for_exposure(), adjust::Transfer::Gamma(1.732));
|
||
}
|
||
|
||
// 32-bit samples are linear light: +1 stop doubles them as stored, where an 8-bit document goes
|
||
// through its tone curve first (photoshop corpus rgb32 and gray32 exposure.psd).
|
||
#[test]
|
||
fn exposure_scales_32_bit_samples_as_stored() {
|
||
let plus_one_stop = Adjustment::Exposure { exposure: 1.0, offset: 0.0, gamma: 1.0 };
|
||
for (depth, fmt, want) in [(SampleType::F32, PixelFormat::RGBA32F, 0.4), (SampleType::U8, PixelFormat::RGBA8, 0.2744)] {
|
||
let mut d = Document::new("e", Size::new(1, 1), ColorMode::Rgb, depth);
|
||
let mut l = Layer::raster("px", fmt);
|
||
l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 1, 1), &[0.2, 0.2, 0.2, 1.0]);
|
||
d.layers = vec![l, Layer::new("exp", LayerContent::Adjustment(plus_one_stop.clone()))];
|
||
let got = px(&d, 0, 0)[0];
|
||
assert!((got - want).abs() < 2e-3, "{depth:?}: {got}");
|
||
}
|
||
for mode in [ColorMode::Rgb, ColorMode::Grayscale] {
|
||
assert_eq!(adjust::Transfer::for_document(mode, SampleType::F32), adjust::Transfer::Gamma(1.0));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn float_adjustments_preserve_hdr_range() {
|
||
let identity = vec![photocraft_doc::adjust::CurvePoint { input: 0.0, output: 0.0 }, photocraft_doc::adjust::CurvePoint { input: 1.0, output: 1.0 }];
|
||
let levels = LevelsChannel { in_black: 0.25, in_white: 0.75, gamma: 2.0, out_black: -0.1, out_white: 1.5 };
|
||
let adjustments = [
|
||
(Adjustment::Exposure { exposure: 0.0, offset: 0.0, gamma: 1.0 }, [4.0, 2.0, 0.5]),
|
||
(Adjustment::Exposure { exposure: 1.0, offset: 0.0, gamma: 1.0 }, [8.0, 4.0, 1.0]),
|
||
(
|
||
Adjustment::Levels { master: Default::default(), per_channel: Default::default(), space: Default::default(), black: Default::default() },
|
||
[4.0, 2.0, 0.5],
|
||
),
|
||
(
|
||
Adjustment::Levels { master: levels, per_channel: Default::default(), space: Default::default(), black: Default::default() },
|
||
[4.0, 2.0, 0.5].map(|v| -0.1 + ((v - 0.25_f32) / 0.5).sqrt() * 1.6),
|
||
),
|
||
(
|
||
Adjustment::Curves {
|
||
master: identity.clone(),
|
||
per_channel: std::array::from_fn(|_| identity.clone()),
|
||
space: Default::default(),
|
||
black: Vec::new(),
|
||
pencil: false,
|
||
},
|
||
[4.0, 2.0, 0.5],
|
||
),
|
||
(
|
||
Adjustment::Curves { master: Vec::new(), per_channel: Default::default(), space: Default::default(), black: Vec::new(), pencil: false },
|
||
[4.0, 2.0, 0.5],
|
||
),
|
||
];
|
||
for (adj, want) in adjustments {
|
||
let mut d = Document::new("HDR", Size::new(1, 1), ColorMode::Rgb, SampleType::F32);
|
||
let mut l = Layer::raster("pixels", PixelFormat::RGBA32F);
|
||
l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 1, 1), &[4.0, 2.0, 0.5, 0.75]);
|
||
d.layers = vec![l, Layer::new("adjustment", LayerContent::Adjustment(adj.clone()))];
|
||
let got = px(&d, 0, 0);
|
||
for (a, b) in got[..3].iter().zip(want) {
|
||
assert!((a - b).abs() < 1e-5, "{adj:?}: {got:?} vs {want:?}");
|
||
}
|
||
assert_eq!(got[3], 0.75);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn float_tone_adjustments_preserve_negative_and_untouched_channels() {
|
||
let identity = vec![photocraft_doc::adjust::CurvePoint { input: 0.0, output: 0.0 }, photocraft_doc::adjust::CurvePoint { input: 1.0, output: 1.0 }];
|
||
let edited = vec![photocraft_doc::adjust::CurvePoint { input: 0.0, output: 0.0 }, photocraft_doc::adjust::CurvePoint { input: 1.0, output: 0.5 }];
|
||
for (adj, want) in [
|
||
(
|
||
Adjustment::Levels { master: Default::default(), per_channel: Default::default(), space: Default::default(), black: Default::default() },
|
||
[-0.1, 4.0, 0.5],
|
||
),
|
||
(
|
||
Adjustment::Curves {
|
||
master: identity.clone(),
|
||
per_channel: [identity.clone(), Vec::new(), edited],
|
||
space: Default::default(),
|
||
black: Vec::new(),
|
||
pencil: false,
|
||
},
|
||
[-0.1, 4.0, 0.25],
|
||
),
|
||
] {
|
||
let mut b = Buffer::filled(Rect::new(0, 0, 1, 1), [-0.1, 4.0, 0.5, 0.75]);
|
||
adjust::apply_depth(&adj, &mut b, adjust::Transfer::Gamma(1.0), Some(SampleType::F32));
|
||
for (a, b) in b.px[0][..3].iter().zip(want) {
|
||
assert!((a - b).abs() < 1e-5, "{adj:?}: {:?} vs {want:?}", b);
|
||
}
|
||
assert_eq!(b.px[0][3], 0.75);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn float_exposure_stops_round_trip_without_losing_highlights() {
|
||
let input = [4.0, -2.0, 0.5, 0.75];
|
||
let mut b = Buffer::filled(Rect::new(0, 0, 1, 1), input);
|
||
for exposure in [1.0, -1.0] {
|
||
adjust::apply_depth(&Adjustment::Exposure { exposure, offset: 0.0, gamma: 1.0 }, &mut b, adjust::Transfer::Gamma(1.0), Some(SampleType::F32));
|
||
}
|
||
assert_eq!(b.px[0], input);
|
||
}
|
||
|
||
#[test]
|
||
fn float_exposure_preserves_signed_gamma_and_offset() {
|
||
let mut b = Buffer::filled(Rect::new(0, 0, 1, 1), [-0.5, 0.0, 4.0, 0.75]);
|
||
adjust::apply_depth(&Adjustment::Exposure { exposure: 1.0, offset: -0.25, gamma: 2.0 }, &mut b, adjust::Transfer::Gamma(1.0), Some(SampleType::F32));
|
||
for (got, want) in b.px[0].iter().zip([-1.25_f32.sqrt(), -0.5, 7.75_f32.sqrt(), 0.75]) {
|
||
assert!((got - want).abs() < 1e-6, "{:?}", b.px[0]);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn float_levels_apply_channel_before_master_outside_unit_range() {
|
||
let channel = LevelsChannel { in_black: 0.5, in_white: 1.5, ..Default::default() };
|
||
let master = LevelsChannel { out_black: -0.1, out_white: 1.5, ..Default::default() };
|
||
let adj = Adjustment::Levels { master, per_channel: std::array::from_fn(|_| channel.clone()), space: Default::default(), black: Default::default() };
|
||
let mut b = Buffer::filled(Rect::new(0, 0, 1, 1), [4.0, 2.0, -0.1, 1.0]);
|
||
adjust::apply_depth(&adj, &mut b, adjust::Transfer::Gamma(1.0), Some(SampleType::F32));
|
||
for (got, want) in b.px[0].iter().zip([5.5, 2.3, -1.06, 1.0]) {
|
||
assert!((got - want).abs() < 1e-6, "{:?}", b.px[0]);
|
||
}
|
||
}
|
||
|
||
// A 30° Reflected gradient fill on a 4 × 4 canvas renders as Photoshop's (its end point snaps to
|
||
// the corner: t = |x − y| / 4) at every depth.
|
||
#[test]
|
||
fn small_gradient_fill_matches_photoshop_at_all_depths() {
|
||
let stops = vec![(0.0, Color::rgb(0.0, 0.0, 0.0)), (1.0, Color::rgb(1.0, 1.0, 1.0))];
|
||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||
let mut d = Document::new("g", Size::new(4, 4), ColorMode::Rgb, depth);
|
||
let fill = Fill::gradient(stops.clone(), 30.0, 1.0, photocraft_doc::GradientStyle::Reflected, false);
|
||
d.layers.push(Layer::new("g", LayerContent::Fill(fill)));
|
||
let out = flatten(&d);
|
||
for y in 0..4usize {
|
||
for x in 0..4usize {
|
||
let want = (x as f32 - y as f32).abs() / 4.0;
|
||
let got = out.px[y * 4 + x][0];
|
||
assert!((got - want).abs() < 1.0 / 255.0, "{depth:?} ({x},{y}): {got} vs {want}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A shape layer's gradient fill must render the same pixels as the compositor's gradient
|
||
/// fill layers, for every non-default gradient field (midpoints, opacity stops, centre offset,
|
||
/// dither, unsorted stops) and all five styles, in both frames: "Align with layer" on lays the
|
||
/// gradient out over the shape's bounds, off over the canvas. `photocraft-vector` cannot
|
||
/// depend on this crate, so it carries a copy of this ramp and geometry — this test is what
|
||
/// keeps the two in step (only interior pixels, where the shape's coverage is exactly 1).
|
||
#[test]
|
||
fn shape_layer_gradients_match_fill_layers() {
|
||
use photocraft_doc::GradientStyle;
|
||
let canvas = Rect::new(0, 0, 64, 48);
|
||
// rect(x, y, w, h): the shape spans (16, 8)–(64, 48), so its whole-pixel bounds sit strictly
|
||
// inside the canvas — "Align with layer" then picks a different frame than the canvas.
|
||
let path = photocraft_vector::shapes::rect(16.0, 8.0, 48.0, 40.0);
|
||
let layer_frame = Rect::new(16, 8, 64, 48);
|
||
let grad = |style: GradientStyle, midpoints: Vec<f32>, opacity_stops: Vec<(f32, f32)>, offset: (f32, f32), dither: bool, unsorted: bool, align: bool| {
|
||
let mut stops = vec![(0.0, Color::BLACK), (0.5, Color::rgb(1.0, 0.25, 0.5)), (1.0, Color::WHITE)];
|
||
if unsorted {
|
||
stops.reverse();
|
||
}
|
||
Fill::Gradient { stops, angle: 30.0, scale: 1.7, style, reverse: false, opacity_stops, midpoints, offset, dither, align }
|
||
};
|
||
let styles = [GradientStyle::Linear, GradientStyle::Radial, GradientStyle::Angle, GradientStyle::Reflected, GradientStyle::Diamond];
|
||
// (name, midpoints, opacity stops, centre offset, dither, unsorted stops) per case.
|
||
type Case = (&'static str, Vec<f32>, Vec<(f32, f32)>, (f32, f32), bool, bool);
|
||
let cases: Vec<Case> = vec![
|
||
("plain", vec![], vec![], (0.0, 0.0), false, false),
|
||
("midpoints", vec![0.25, 0.9], vec![], (0.0, 0.0), false, false),
|
||
("opacity stops", vec![], vec![(0.0, 1.0), (0.5, 0.2), (1.0, 0.9)], (0.0, 0.0), false, false),
|
||
("offset", vec![], vec![], (0.3, -0.2), false, false),
|
||
("dither", vec![], vec![], (0.0, 0.0), true, false),
|
||
("all of them", vec![0.75], vec![(0.0, 1.0), (1.0, 0.4)], (0.1, 0.1), true, true),
|
||
];
|
||
for style in styles {
|
||
for (name, mids, opac, offset, dither, unsorted) in &cases {
|
||
for align in [true, false] {
|
||
let f = grad(style, mids.clone(), opac.clone(), *offset, *dither, *unsorted, align);
|
||
let frame = if align { layer_frame } else { canvas };
|
||
let want = gradient_fill::render(&f, canvas, frame);
|
||
let sh = photocraft_doc::vector::ShapeLayer { path: path.clone(), fill: Some(f), ..Default::default() };
|
||
let s = photocraft_vector::render_shape(&sh, PixelFormat::RGBA8, canvas);
|
||
let mut worst = 0.0f32;
|
||
for y in 9..47 {
|
||
for x in 17..63 {
|
||
let p = want[(y as usize) * canvas.width() as usize + x as usize];
|
||
for (got, want_ch) in (0..4usize).map(|ch| (s.sample_channel(x, y, ch), p[ch])) {
|
||
worst = worst.max((got - want_ch).abs());
|
||
}
|
||
}
|
||
}
|
||
assert!(worst <= 1.5 / 255.0, "{style:?} {name} align={align}: worst delta {worst}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A tall document with soft content, a translucent region and an adjustment.
|
||
fn tall_doc(w: u32, h: u32) -> Document {
|
||
let mut d = doc_white(w, h);
|
||
let mut l = Layer::raster("grad", PixelFormat::RGBA8);
|
||
let s = l.surface_mut().unwrap();
|
||
for y in 0..h as i32 {
|
||
s.fill_rect(Rect::new(0, y, w as i32 / 2, y + 1), &[y as f32 / h as f32, 0.3, 1.0 - y as f32 / h as f32, 1.0]);
|
||
}
|
||
s.fill_rect(Rect::new(w as i32 / 2, 0, w as i32, h as i32 / 3), &[0.9, 0.1, 0.1, 0.4]);
|
||
d.layers.push(l);
|
||
d.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)));
|
||
d
|
||
}
|
||
|
||
#[test]
|
||
fn render_tiled_in_place_matches_one_tile_with_many_bands() {
|
||
// Enough bands for the band-parallel path, and few for the tile-parallel one.
|
||
let d = tall_doc(37, 900);
|
||
let whole = render_tiled(&d, d.bounds(), 10_000);
|
||
for tile in [5, 64, 300] {
|
||
assert_eq!(render_tiled(&d, d.bounds(), tile), whole, "tile {tile}");
|
||
}
|
||
let r = Rect::new(3, 17, 30, 811);
|
||
assert_eq!(render_tiled(&d, r, 4).px, render_tiled(&d, r, 10_000).px);
|
||
assert!(render_tiled(&d, Rect::new(5, 5, 5, 900), 4).px.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn render_bands_cover_the_rect_in_order() {
|
||
let d = tall_doc(20, 700);
|
||
let r = Rect::new(2, 10, 18, 690);
|
||
let mut rows = Vec::new();
|
||
let mut px = Vec::new();
|
||
render_bands(&d, r, 100, |b| -> Result<(), ()> {
|
||
assert_eq!((b.rect.x0, b.rect.x1), (r.x0, r.x1));
|
||
rows.push((b.rect.y0, b.rect.y1));
|
||
px.extend(b.px);
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
// 100 rows round up to one 256-row tile band.
|
||
assert_eq!(rows, vec![(10, 266), (266, 522), (522, 690)]);
|
||
assert_eq!(px, render(&d, r).px);
|
||
// The sink's error stops the render.
|
||
let mut n = 0;
|
||
assert_eq!(
|
||
render_bands(&d, r, 1, |_| {
|
||
n += 1;
|
||
Err("stop")
|
||
}),
|
||
Err("stop")
|
||
);
|
||
assert_eq!(n, 1);
|
||
assert_eq!(band_rows_for(14_000, 0) % RENDER_TILE, 0);
|
||
assert!(band_rows_for(1, 0) > 0 && band_rows_for(0, -5) > 0);
|
||
}
|
||
|
||
/// The previous thumbnail: flatten, then area-average (premultiplied).
|
||
fn reference_reduce(doc: &Document, w: usize, h: usize) -> Vec<[f32; 4]> {
|
||
let full = flatten(doc);
|
||
let (fw, fh) = (full.rect.width() as usize, full.rect.height() as usize);
|
||
let mut out = Vec::new();
|
||
for ty in 0..h {
|
||
let (y0, y1) = (ty * fh / h, ((ty + 1) * fh / h).max(ty * fh / h + 1).min(fh));
|
||
for tx in 0..w {
|
||
let (x0, x1) = (tx * fw / w, ((tx + 1) * fw / w).max(tx * fw / w + 1).min(fw));
|
||
let mut acc = [0.0f32; 4];
|
||
for y in y0..y1 {
|
||
for x in x0..x1 {
|
||
let p = full.px[y * fw + x];
|
||
for c in 0..3 {
|
||
acc[c] += p[c] * p[3];
|
||
}
|
||
acc[3] += p[3];
|
||
}
|
||
}
|
||
let n = ((y1 - y0) * (x1 - x0)).max(1) as f32;
|
||
out.push(if acc[3] > 0.0 { [acc[0] / acc[3], acc[1] / acc[3], acc[2] / acc[3], acc[3] / n] } else { [0.0; 4] });
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
#[test]
|
||
fn reduced_render_matches_the_full_composite_averaged() {
|
||
let d = tall_doc(53, 1000);
|
||
for (w, h, band) in [(10, 190, 0), (7, 33, 256), (53, 999, 512), (1, 1, 256)] {
|
||
let got = render_reduced_in_bands(&d, d.bounds(), w, h, None, band);
|
||
assert_eq!((got.rect.width(), got.rect.height()), (w, h));
|
||
assert_eq!(got.px, reference_reduce(&d, w as usize, h as usize), "{w}x{h} band {band}");
|
||
}
|
||
// Full size is the composite itself; an empty document reduces to transparency.
|
||
assert_eq!(render_reduced(&d, 53, 1000).px, flatten(&d).px);
|
||
let empty = Document::new("e", Size::new(0, 0), ColorMode::Rgb, SampleType::U8);
|
||
assert_eq!(render_reduced(&empty, 4, 4).px, vec![[0.0; 4]]);
|
||
}
|
||
|
||
#[test]
|
||
fn reduced_damage_matches_the_whole_reduction() {
|
||
// A reduced canvas texture updates only what a stroke touched: those pixels must equal the
|
||
// whole reduction's, for uneven factors, damage on span edges and bands smaller than the area.
|
||
let d = tall_doc(53, 1000);
|
||
for (w, h, band) in [(10, 190, 0), (7, 33, 256), (53, 999, 64), (53, 1000, 0), (1, 1, 256)] {
|
||
let all = render_reduced_in_bands(&d, d.bounds(), w, h, None, band);
|
||
for dmg in [Rect::new(0, 0, 1, 1), Rect::new(5, 17, 6, 18), Rect::new(12, 300, 40, 701), Rect::new(-9, 990, 80, 2000), Rect::new(0, 0, 53, 1000)] {
|
||
let part = render_reduced_in_bands(&d, d.bounds(), w, h, Some(dmg), band);
|
||
let r = part.rect;
|
||
assert!(!r.is_empty() && r.x0 >= 0 && r.y0 >= 0 && r.x1 as u32 <= w && r.y1 as u32 <= h, "{w}x{h} {dmg:?} -> {r:?}");
|
||
for y in r.y0..r.y1 {
|
||
for x in r.x0..r.x1 {
|
||
assert_eq!(part.get(x, y), all.get(x, y), "{w}x{h} band {band} {dmg:?} at {x},{y}");
|
||
}
|
||
}
|
||
// Exactly the output pixels whose source area meets the damage.
|
||
let span = |t: u32, f: u32, n: u32| (t * f / n, ((t + 1) * f / n).max(t * f / n + 1).min(f));
|
||
let d = dmg.intersect(&Rect::new(0, 0, 53, 1000));
|
||
for y in 0..h {
|
||
for x in 0..w {
|
||
let ((x0, x1), (y0, y1)) = (span(x, 53, w), span(y, 1000, h));
|
||
let meets = !Rect::new(x0 as i32, y0 as i32, x1 as i32, y1 as i32).intersect(&d).is_empty();
|
||
assert_eq!(r.contains(x as i32, y as i32), meets, "{w}x{h} {dmg:?} at {x},{y}");
|
||
}
|
||
}
|
||
}
|
||
assert!(render_reduced_in_bands(&d, d.bounds(), w, h, Some(Rect::new(60, 0, 70, 10)), band).rect.is_empty());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn reduced_render_of_an_area_past_the_canvas() {
|
||
// The Crop tool shows layer pixels beyond the canvas: rendered in place, they must equal the
|
||
// same pixels on a canvas that holds them (adjustment layers apply out there too).
|
||
let paint = |d: &mut Document, dx: i32, dy: i32| {
|
||
let mut l = Layer::raster("big", PixelFormat::RGBA8);
|
||
let s = l.surface_mut().unwrap();
|
||
for y in -10..40 {
|
||
for x in -20..60 {
|
||
let a = if x < 0 || y >= 30 { 0.6 } else { 1.0 };
|
||
s.fill_rect(Rect::new(x + dx, y + dy, x + dx + 1, y + dy + 1), &[(x + 20) as f32 / 80.0, (y + 10) as f32 / 50.0, 0.5, a]);
|
||
}
|
||
}
|
||
d.layers.push(l);
|
||
d.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)));
|
||
};
|
||
let mut d = Document::new("d", Size::new(40, 30), ColorMode::Rgb, SampleType::U8);
|
||
paint(&mut d, 0, 0);
|
||
let mut whole = Document::new("w", Size::new(80, 50), ColorMode::Rgb, SampleType::U8);
|
||
paint(&mut whole, 20, 10);
|
||
let area = Rect::new(-20, -10, 60, 40);
|
||
for (w, h) in [(80, 50), (8, 5), (27, 13), (1, 1)] {
|
||
let got = render_reduced_rect(&d, area, w, h);
|
||
assert_eq!(got.rect, Rect::new(0, 0, w as i32, h as i32));
|
||
assert_eq!(got.px, render_reduced(&whole, w, h).px, "{w}x{h}");
|
||
}
|
||
// Past every layer there is nothing to show.
|
||
assert!(render_reduced_rect(&d, Rect::new(100, 100, 120, 110), 20, 10).px.iter().all(|p| p[3] == 0.0));
|
||
}
|
||
|
||
#[test]
|
||
fn large_documents_thumbnail_from_a_proxy() {
|
||
// 4200 × 4200 (17.6 MP) is above PROXY_THUMBNAIL_PIXELS: the proxy path must still give a
|
||
// thumbnail of the right size whose colours match the exact reduction.
|
||
let mut d = doc_white(4200, 4200);
|
||
d.layers.push(solid_layer("red", Rect::new(0, 0, 2100, 4200), [1.0, 0.0, 0.0, 1.0]));
|
||
let t = thumbnail(&d, 100);
|
||
assert_eq!((t.width, t.height), (100, 100));
|
||
let at = |x: usize, y: usize| t.pixels[(y * 100 + x) * 4..(y * 100 + x) * 4 + 4].to_vec();
|
||
assert_eq!(at(10, 50), vec![255, 0, 0, 255]);
|
||
assert_eq!(at(90, 50), vec![255, 255, 255, 255]);
|
||
// Effects don't scale with a proxy: such documents reduce the exact composite.
|
||
assert!(proxy::proxy_faithful(&d));
|
||
d.layers[1].effects.items.push(photocraft_doc::Effect::default_drop_shadow());
|
||
assert!(!proxy::proxy_faithful(&d));
|
||
}
|
||
|
||
/// Layers whose effect maps are built with nested Rayon work (a gradient fill's rows render in
|
||
/// parallel), each carrying a different effect.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn nested_parallel_fx_doc() -> Document {
|
||
use photocraft_doc::{Bevel, BevelStyle, BevelTechnique, Contour, Effect, FxCommon, FxPaint, Glow, GlowSource, GlowTechnique, StrokeFx, StrokePosition};
|
||
let mut d = doc_white(512, 512);
|
||
let g = |a: f32| Fill::gradient(vec![(0.0, Color::BLACK), (1.0, Color::rgb(0.2, 0.5, 0.9))], a, 1.0, photocraft_doc::GradientStyle::Linear, false);
|
||
let mut shadow = Layer::new("shadow", LayerContent::Fill(g(0.0)));
|
||
shadow.effects.items = vec![Effect::default_drop_shadow()];
|
||
let mut stroke = Layer::new("stroke", LayerContent::Fill(g(45.0)));
|
||
stroke.opacity = 0.6;
|
||
stroke.effects.items = vec![Effect::Stroke(StrokeFx {
|
||
common: FxCommon::new(BlendMode::Normal, 1.0),
|
||
size: 3.0,
|
||
position: StrokePosition::Inside,
|
||
paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)),
|
||
overprint: false,
|
||
})];
|
||
let mut glow = Layer::new("glow", LayerContent::Fill(g(90.0)));
|
||
glow.opacity = 0.5;
|
||
glow.effects.items = vec![Effect::InnerGlow(Glow {
|
||
common: FxCommon::new(BlendMode::Screen, 0.75),
|
||
paint: FxPaint::Color(Color::rgb(1.0, 1.0, 0.7)),
|
||
technique: GlowTechnique::Softer,
|
||
spread: 0.0,
|
||
size: 9.0,
|
||
contour: Contour::Linear,
|
||
anti_alias: false,
|
||
range: 0.5,
|
||
jitter: 0.0,
|
||
noise: 0.0,
|
||
source: GlowSource::Edge,
|
||
})];
|
||
// A chiselled bevel: its distance transforms run on threads of their own, inside the tile that builds them.
|
||
let mut chisel = solid_layer("chisel", Rect::new(60, 60, 450, 450), [0.8, 0.6, 0.2, 1.0]);
|
||
chisel.effects.items = vec![Effect::BevelEmboss(Bevel {
|
||
enabled: true,
|
||
style: BevelStyle::InnerBevel,
|
||
technique: BevelTechnique::ChiselHard,
|
||
depth: 1.5,
|
||
up: true,
|
||
size: 9.0,
|
||
soften: 0.0,
|
||
angle: 120.0,
|
||
altitude: 32.0,
|
||
use_global_light: false,
|
||
gloss_contour: Contour::Linear,
|
||
gloss_anti_alias: false,
|
||
highlight: FxCommon::new(BlendMode::Screen, 0.75),
|
||
highlight_color: Color::WHITE,
|
||
shadow: FxCommon::new(BlendMode::Multiply, 0.75),
|
||
shadow_color: Color::BLACK,
|
||
contour: None,
|
||
texture: None,
|
||
})];
|
||
// Unaffected layers whose rows also render in parallel keep both workers stealing.
|
||
let base = Layer::new("base", LayerContent::Fill(g(30.0)));
|
||
let mut top = Layer::new("top", LayerContent::Fill(g(60.0)));
|
||
top.opacity = 0.3;
|
||
d.layers.extend([base, shadow, stroke, glow, chisel, top]);
|
||
d
|
||
}
|
||
|
||
#[test]
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn effect_maps_with_nested_rayon_work_do_not_deadlock() {
|
||
// #276: a tile building a layer's effect maps inside a once-init ran Rayon work; waiting on
|
||
// it, its worker stole another tile of the same layer, which then blocked on the init held
|
||
// further up its own stack. Eight threads (two rarely steal across that wait) and 64 tiles make that stealing likely; the cache is
|
||
// purged every round so the maps are rebuilt. A watchdog turns a hang into a failure.
|
||
let (tx, rx) = std::sync::mpsc::channel();
|
||
std::thread::spawn(move || {
|
||
let Ok(pool) = rayon::ThreadPoolBuilder::new().num_threads(8).build() else { return };
|
||
let d = nested_parallel_fx_doc();
|
||
let first = pool.install(|| render_tiled(&d, d.bounds(), 64));
|
||
for _ in 0..100 {
|
||
purge_effect_cache();
|
||
let out = pool.install(|| render_tiled(&d, d.bounds(), 64));
|
||
assert!(out.px == first.px, "renders differ between rounds");
|
||
}
|
||
let _ = tx.send(());
|
||
});
|
||
match rx.recv_timeout(std::time::Duration::from_secs(120)) {
|
||
Ok(()) => {}
|
||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => panic!("effect-map rendering deadlocked (#276)"),
|
||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => panic!("render thread panicked"),
|
||
}
|
||
}
|
||
|
||
// Photo Filter multiplies D50 XYZ (relative to white) by the filter colour's, then restores the
|
||
// encoded luminosity like the Luminosity blend mode; 32-bit documents keep luminance Y instead.
|
||
// Samples from the photoshop corpus photo-filter.psd (Warming Filter (85), density 40 %, Preserve
|
||
// Luminosity): pure blue gains red, and white stays white (#442).
|
||
#[test]
|
||
fn photo_filter_matches_photoshop() {
|
||
let filter = |color: [f32; 3], depth, v: [f32; 3]| {
|
||
let adj = Adjustment::PhotoFilter { color, density: 0.4, preserve_luminosity: true };
|
||
let mut b = Buffer::filled(Rect::new(0, 0, 1, 1), [v[0], v[1], v[2], 1.0]);
|
||
adjust::apply_depth(&adj, &mut b, adjust::Transfer::for_document(ColorMode::Rgb, depth), Some(depth));
|
||
[b.px[0][0], b.px[0][1], b.px[0][2]].map(|c| c * 255.0)
|
||
};
|
||
let warming = [236.0 / 255.0, 138.0 / 255.0, 0.0];
|
||
for depth in [SampleType::U8, SampleType::U16] {
|
||
for (v, ps) in [
|
||
([1.0, 0.0, 0.0], [239.0, 7.0, 7.0]),
|
||
([0.0, 1.0, 0.0], [69.0, 220.0, 0.0]),
|
||
([0.0, 0.0, 1.0], [46.0, 0.0, 130.0]),
|
||
([1.0, 1.0, 0.0], [255.0, 240.0, 80.0]),
|
||
([0.5019, 0.5019, 0.5019], [140.0, 124.0, 116.0]),
|
||
([0.949, 0.949, 0.949], [255.0, 238.0, 229.0]),
|
||
([1.0, 1.0, 1.0], [255.0, 255.0, 255.0]),
|
||
([0.0, 0.0, 0.0], [0.0, 0.0, 0.0]),
|
||
] {
|
||
let got = filter(warming, depth, v);
|
||
assert!(got.iter().zip(ps).all(|(g, p)| (g - p).abs() <= 1.0), "{depth:?} {v:?}: got {got:?} want {ps:?}");
|
||
}
|
||
}
|
||
// The 32-bit file picks the same numbers as linear values (Lab 81.5, 9.41, 82.19). Its fit is
|
||
// looser (mean 0.9/255 over the file, 6.9/255 with a plain RGB multiply), and Photoshop's
|
||
// results there are unclipped: above 1 and below 0.
|
||
let warming32 = [0.966_445_6, 0.761_806_3, 0.0];
|
||
for (v, ps) in [([0.2159, 0.2159, 0.2159], [65.7, 53.4, 39.5]), ([1.0, 1.0, 0.0], [271.7, 250.9, -9.0]), ([1.0, 0.0, 0.0], [262.2, -2.0, -1.2])] {
|
||
let got = filter(warming32, SampleType::F32, v);
|
||
assert!(got.iter().zip(ps).all(|(g, p)| (g - p).abs() <= 2.5), "F32 {v:?}: got {got:?} want {ps:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn rendering_restores_the_lab_mix_flag_it_found() {
|
||
// #1112: a render set LAB_MIX for its document, then cleared it. A nested rayon job (a
|
||
// gradient fill, say) can let a thread render another tile in the middle of an outer Lab
|
||
// tile, and clearing the flag made the rest of that outer tile mix in sRGB.
|
||
for mode in [ColorMode::Lab, ColorMode::Rgb] {
|
||
let doc = Document::with_background("t", Size::new(8, 8), mode, SampleType::U8, Color::WHITE);
|
||
// Single-tile and multi-tile paths (the latter also prepares effect maps under the flag).
|
||
for tile in [RENDER_TILE, 4] {
|
||
for outer in [true, false] {
|
||
psblend::LAB_MIX.with(|l| l.set(outer));
|
||
let _ = render_tiled(&doc, doc.bounds(), tile);
|
||
let after = psblend::LAB_MIX.with(|l| l.get());
|
||
psblend::LAB_MIX.with(|l| l.set(false));
|
||
assert_eq!(after, outer, "{mode:?} with tile {tile} inside a tile that had LAB_MIX = {outer}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Vibrance of an 8-bit sRGB colour (0–255 in and out).
|
||
fn vibrance_255(c: [f32; 3], vibrance: f32, saturation: f32) -> [f32; 3] {
|
||
let mut buf = Buffer::filled(Rect::new(0, 0, 1, 1), [c[0] / 255.0, c[1] / 255.0, c[2] / 255.0, 1.0]);
|
||
adjust::apply(&Adjustment::Vibrance { vibrance, saturation }, &mut buf);
|
||
let p = buf.px[0];
|
||
[p[0] * 255.0, p[1] * 255.0, p[2] * 255.0]
|
||
}
|
||
|
||
fn assert_near_255(got: [f32; 3], want: [f32; 3], tol: f32, what: &str) {
|
||
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= tol), "{what}: got {got:?}, Photoshop {want:?}");
|
||
}
|
||
|
||
// Photoshop 25.4 references (16-bit runs of 8-bit colours in an sRGB document).
|
||
|
||
#[test]
|
||
fn vibrance_saturation_matches_photoshop() {
|
||
// −100 greys to 0.288 R + 0.712 G in linear light: blue has no weight, yellow stays bright.
|
||
for (c, g) in [([251.0, 201.0, 0.0], 217.0), ([255.0, 0.0, 0.0], 146.0), ([0.0, 255.0, 0.0], 219.4), ([0.0, 0.0, 255.0], 0.0), ([255.0, 255.0, 0.0], 255.0)]
|
||
{
|
||
assert_near_255(vibrance_255(c, 0.0, -100.0), [g; 3], 0.5, "saturation -100");
|
||
}
|
||
for (c, s, want) in [
|
||
([255.0, 128.0, 0.0], -50.0, [220.7, 155.2, 129.4]),
|
||
([224.0, 176.0, 144.0], -50.0, [208.6, 183.9, 169.9]),
|
||
([64.0, 128.0, 192.0], 50.0, [0.0, 134.3, 218.7]),
|
||
([224.0, 176.0, 144.0], 100.0, [251.3, 158.5, 51.7]),
|
||
([32.0, 96.0, 64.0], 100.0, [0.0, 106.8, 32.8]),
|
||
] {
|
||
assert_near_255(vibrance_255(c, 0.0, s), want, 0.5, "saturation");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn negative_vibrance_matches_photoshop() {
|
||
for (c, v, want) in [
|
||
([255.0, 0.0, 0.0], -100.0, [255.0, 137.0, 137.0]),
|
||
([128.0, 0.0, 0.0], -100.0, [128.0, 65.7, 65.7]),
|
||
([224.0, 176.0, 144.0], -100.0, [213.2, 197.4, 188.8]),
|
||
([64.0, 128.0, 192.0], -100.0, [124.4, 149.0, 185.0]),
|
||
([255.0, 128.0, 0.0], -50.0, [255.0, 152.0, 99.1]),
|
||
([251.0, 201.0, 0.0], -50.0, [251.0, 208.1, 97.4]),
|
||
([32.0, 96.0, 64.0], -50.0, [50.1, 91.7, 68.6]),
|
||
] {
|
||
assert_near_255(vibrance_255(c, v, 0.0), want, 0.6, "vibrance");
|
||
}
|
||
// Vibrance applies first, then Saturation.
|
||
assert_near_255(vibrance_255([64.0, 128.0, 192.0], 50.0, -50.0), [86.5, 117.8, 158.4], 3.0, "vibrance then saturation");
|
||
}
|
||
|
||
#[test]
|
||
fn positive_vibrance_follows_photoshop_closely() {
|
||
// A fit, not exact: within a few levels, saturated colours untouched, skin damped.
|
||
for (c, v, want, tol) in [
|
||
([255.0, 128.0, 0.0], 50.0, [255.0, 128.0, 0.0], 0.5),
|
||
([144.0, 160.0, 176.0], 100.0, [121.2, 152.6, 180.5], 1.5),
|
||
([64.0, 128.0, 192.0], 50.0, [50.6, 125.1, 192.8], 4.0),
|
||
([224.0, 176.0, 144.0], 50.0, [224.9, 173.4, 138.0], 3.0),
|
||
([224.0, 176.0, 144.0], 100.0, [226.6, 166.8, 122.4], 5.0),
|
||
] {
|
||
assert_near_255(vibrance_255(c, v, 0.0), want, tol, "vibrance");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn clipped_brightness_and_desaturation_whiten_a_lighter_color_logo() {
|
||
// постер.psd: a yellow logo in Lighter Color over the same yellow, with Brightness/Contrast +150
|
||
// and Vibrance › Saturation −100 clipped to it. Photoshop shows it white (254); 0.5.0 left it
|
||
// yellow (its Saturation −100 greyed (255, 255, 0) to 128, darker than the yellow beneath).
|
||
let yellow = [251.0 / 255.0, 201.0 / 255.0, 0.0, 1.0];
|
||
let mut d = doc_white(2, 1);
|
||
d.layers[0].surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 1), &yellow);
|
||
let mut logo = solid_layer("logo", Rect::new(0, 0, 1, 1), yellow);
|
||
logo.blend = BlendMode::LighterColor;
|
||
d.layers.push(logo);
|
||
for adj in [Adjustment::BrightnessContrast { brightness: 150.0, contrast: 0.0, legacy: false }, Adjustment::Vibrance { vibrance: 0.0, saturation: -100.0 }]
|
||
{
|
||
let mut l = Layer::new("adj", LayerContent::Adjustment(adj));
|
||
l.clipped = true;
|
||
d.layers.push(l);
|
||
}
|
||
let p = px(&d, 0, 0);
|
||
assert!(p[..3].iter().all(|v| *v * 255.0 >= 252.0), "logo whitened: {p:?}");
|
||
assert!(close4(px(&d, 1, 0), yellow), "the yellow beside it is untouched");
|
||
}
|
||
|
||
#[test]
|
||
fn outer_glow_does_not_paint_through_a_zero_fill_layer() {
|
||
use photocraft_doc::{Contour, Effect, FxCommon, FxPaint, Glow, GlowSource, GlowTechnique};
|
||
|
||
let background = [0.18, 0.42, 0.85, 1.0];
|
||
let rect = Rect::new(24, 24, 72, 72);
|
||
for technique in [GlowTechnique::Softer, GlowTechnique::Precise] {
|
||
let mut d = doc_white(96, 96);
|
||
let bounds = d.bounds();
|
||
d.layers[0].surface_mut().unwrap().fill_rect(bounds, &background);
|
||
let mut layer = solid_layer("glow", rect, [1.0, 0.92, 0.25, 1.0]);
|
||
layer.fill_opacity = 0.0;
|
||
layer.effects.items.push(Effect::OuterGlow(Glow {
|
||
common: FxCommon::new(BlendMode::Normal, 1.0),
|
||
paint: FxPaint::Color(Color::rgb(1.0, 0.94, 0.28)),
|
||
technique,
|
||
spread: 0.5,
|
||
size: 12.0,
|
||
contour: Contour::Linear,
|
||
anti_alias: false,
|
||
range: 0.5,
|
||
jitter: 0.0,
|
||
noise: 0.0,
|
||
source: GlowSource::Edge,
|
||
}));
|
||
d.layers.push(layer);
|
||
|
||
let center = px(&d, 48, 48);
|
||
assert!(close4(center, background), "{technique:?}: glow leaked into fully transparent fill: {center:?}");
|
||
let distant = px(&d, 5, 5);
|
||
assert!(close4(distant, background), "{technique:?}: changed distant background: {distant:?}");
|
||
let halo = px(&d, 22, 48);
|
||
assert!(halo[0] > background[0] + 0.1, "{technique:?}: exterior halo disappeared: {halo:?}");
|
||
|
||
d.layers[1].fill_opacity = 1.0;
|
||
let opaque = px(&d, 48, 48);
|
||
assert!(opaque[0] > 0.9 && opaque[1] > 0.8, "{technique:?}: opaque fill disappeared: {opaque:?}");
|
||
}
|
||
}
|
||
|
||
// Lab documents: Invert mirrors L, a and b; Threshold splits on L with a neutral result; Gradient
|
||
// Map follows L. Rendered through the document compositor at every depth.
|
||
#[test]
|
||
fn lab_documents_evaluate_invert_threshold_and_gradient_map_on_lab() {
|
||
use photocraft_color::convert::{lab_to_srgb, srgb_to_lab};
|
||
let c = lab_to_srgb([30.0, 12.0, -18.0]);
|
||
for depth in SampleType::ALL {
|
||
let render = |adj: Adjustment| {
|
||
let mut d = Document::with_background("t", Size::new(4, 4), ColorMode::Lab, depth, Color::rgb(c[0], c[1], c[2]));
|
||
d.layers.push(Layer::new("adj", LayerContent::Adjustment(adj)));
|
||
let p = flatten(&d).px[5];
|
||
srgb_to_lab([p[0], p[1], p[2]])
|
||
};
|
||
let near = |got: [f32; 3], want: [f32; 3], tol: f32| got.iter().zip(want).all(|(g, w)| (g - w).abs() <= tol);
|
||
let inv = render(Adjustment::Invert);
|
||
// 8-bit bytes become 255 - v (a, b one step past the mirror); 16 bits mirror them exactly.
|
||
let want = if depth == SampleType::U8 { [70.0, -13.0, 17.0] } else { [70.0, -12.0, 18.0] };
|
||
assert!(near(inv, want, 0.5), "{depth:?}: {inv:?}");
|
||
let th = render(Adjustment::Threshold { level: 0.25 });
|
||
assert!(near(th, [100.0, 0.0, 0.0], 0.5), "{depth:?}: L 30 is above level 64: {th:?}");
|
||
let gm = render(Adjustment::GradientMap { stops: vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])], reverse: false, dither: false });
|
||
let want = srgb_to_lab([0.3; 3]);
|
||
assert!(near(gm, want, 1.0), "{depth:?}: L 30 → 30 % of the way: {gm:?} vs {want:?}");
|
||
}
|
||
}
|
||
|
||
/// `adj` over a one-colour Lab document (background `rgb`) at `depth`, read back as Lab.
|
||
fn lab_doc_adjusted(rgb: [f32; 3], adj: Adjustment, depth: SampleType) -> [f32; 3] {
|
||
let mut doc = Document::with_background("lab", Size::new(4, 4), ColorMode::Lab, depth, Color::rgb(rgb[0], rgb[1], rgb[2]));
|
||
doc.layers.push(Layer::new("adj", LayerContent::Adjustment(adj)));
|
||
let p = px(&doc, 1, 1);
|
||
photocraft_color::convert::srgb_to_lab([p[0], p[1], p[2]])
|
||
}
|
||
|
||
fn near3(got: [f32; 3], want: [f32; 3], tol: f32, what: &str) {
|
||
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= tol), "{what}: got {got:?}, want {want:?}");
|
||
}
|
||
|
||
/// A muted, in-gamut colour (L ≈ 46, a ≈ 10, b ≈ -20) and its Lab coordinates.
|
||
const MUTED: [f32; 3] = [0.40, 0.42, 0.60];
|
||
|
||
#[test]
|
||
fn lab_documents_invert_and_posterize_l_a_and_b() {
|
||
let lab = photocraft_color::convert::srgb_to_lab(MUTED);
|
||
for depth in SampleType::ALL {
|
||
// Invert: L -> 100 - L and a, b -> their opposites (-a - 1 on the 8-bit 0..255 grid).
|
||
let inv = lab_doc_adjusted(MUTED, Adjustment::Invert, depth);
|
||
let step = if depth == SampleType::U8 { 1.0 } else { 0.0 };
|
||
near3(inv, [100.0 - lab[0], -lab[1] - step, -lab[2] - step], 1.0, &format!("{depth:?} invert"));
|
||
// An RGB invert would land elsewhere: lightness 100 - L is the Lab rule.
|
||
let rgb_inv = photocraft_color::convert::srgb_to_lab(MUTED.map(|v| 1.0 - v));
|
||
assert!((rgb_inv[0] - inv[0]).abs() > 2.0, "{depth:?}: {rgb_inv:?} vs {inv:?}");
|
||
// Posterize, three levels: the same steps on L, a and b (here all land on the middle
|
||
// step: L ≈ 50 and a, b ≈ -1 (0 at 16 bits, whose middle level is 128), a neutral grey).
|
||
let post = lab_doc_adjusted(MUTED, Adjustment::Posterize { levels: 3 }, depth);
|
||
let n = [lab[0] / 100.0, (lab[1] + 128.0) / 255.0, (lab[2] + 128.0) / 255.0].map(|v| adjust::posterize_in(v, 3, Some(depth)));
|
||
let want = [n[0] * 100.0, n[1] * 255.0 - 128.0, n[2] * 255.0 - 128.0];
|
||
assert!(want[0] > 45.0 && want[1].abs() < 2.0 && want[2].abs() < 2.0, "{want:?}");
|
||
near3(post, want, 1.0, &format!("{depth:?} posterize"));
|
||
}
|
||
}
|
||
|
||
/// A Lab document of one opaque layer holding stored L, a, b `lab` (normalised as Lab surfaces
|
||
/// store them), with `adjustments` above it.
|
||
fn lab_stack(lab: [f32; 3], depth: SampleType, adjustments: Vec<Adjustment>) -> Document {
|
||
let mut doc = Document::new("lab", Size::new(4, 4), ColorMode::Lab, depth);
|
||
let fmt = doc.pixel_format();
|
||
let mut layer = Layer::raster("bg", fmt);
|
||
if let Some(s) = layer.surface_mut() {
|
||
s.fill_rect(Rect::new(0, 0, 4, 4), &[lab[0], lab[1], lab[2], 1.0][..fmt.channels()]);
|
||
}
|
||
doc.layers.push(layer);
|
||
doc.layers.extend(adjustments.into_iter().map(|a| Layer::new("adj", LayerContent::Adjustment(a))));
|
||
doc
|
||
}
|
||
|
||
/// Display RGB of stored Lab `n`.
|
||
fn lab_display(n: [f32; 3]) -> [f32; 3] {
|
||
photocraft_color::convert::lab_to_srgb([n[0] * 100.0, n[1] * 255.0 - 128.0, n[2] * 255.0 - 128.0])
|
||
}
|
||
|
||
// A saturated purple far outside sRGB (L 60, a +90, b -90). Read through display RGB it clips,
|
||
// so Invert and Levels on L, a and b saw the clipped colour: the photoshop corpus's lab8 and
|
||
// lab16 invert.psd were up to 11 levels off there. Worked out on the stored values, Invert gives
|
||
// the bytes' complements (L 153 → 102, a 218 → 37, b 38 → 217), as Photoshop does.
|
||
#[test]
|
||
fn lab_adjustments_work_on_the_stored_values() {
|
||
let byte = |v: u8| f32::from(v) / 255.0;
|
||
let doc = lab_stack([byte(153), byte(218), byte(38)], SampleType::U8, vec![Adjustment::Invert]);
|
||
let p = px(&doc, 1, 1);
|
||
let want = lab_display([byte(102), byte(37), byte(217)]);
|
||
near3([p[0], p[1], p[2]], want, 1e-4, "inverted out-of-gamut purple");
|
||
// Through clipped display RGB the result is another colour.
|
||
let clipped = photocraft_color::convert::srgb_to_lab(lab_display([byte(153), byte(218), byte(38)]));
|
||
let via_rgb = lab_display([1.0 - clipped[0] / 100.0, (-clipped[1] - 1.0 + 128.0) / 255.0, (-clipped[2] - 1.0 + 128.0) / 255.0]);
|
||
assert!(via_rgb.iter().zip(want).any(|(a, b)| (a - b).abs() > 4.0 / 255.0), "the clipped path differs: {via_rgb:?} vs {want:?}");
|
||
// Levels on L only (a Lab record), half masked by opacity: L 0.6 → 0.6^(1/2) mixed halfway.
|
||
let ch = LevelsChannel { gamma: 2.0, ..Default::default() };
|
||
let levels = Adjustment::Levels {
|
||
master: LevelsChannel::default(),
|
||
per_channel: [ch.clone(), LevelsChannel::default(), LevelsChannel::default()],
|
||
space: ToneSpace::Lab,
|
||
black: LevelsChannel::default(),
|
||
};
|
||
let mut doc = lab_stack([0.6, byte(218), byte(38)], SampleType::U16, vec![levels]);
|
||
if let Some(l) = doc.layers.last_mut() {
|
||
l.opacity = 0.5;
|
||
}
|
||
let p = px(&doc, 1, 1);
|
||
let l = 0.6 + (adjust::levels_q(&ch, 0.6, Some(32768.0)) - 0.6) * 0.5;
|
||
near3([p[0], p[1], p[2]], lab_display([l, byte(218), byte(38)]), 1e-3, "levels on L at half opacity");
|
||
}
|
||
|
||
// An adjustment's result in a Lab document is whole samples, as Photoshop stores it: Curves
|
||
// taking L 100 to 120 of 255 shows L 120 exactly (the photoshop corpus's lab8 curves.psd is exact
|
||
// so, where the unrounded result left saturated colours a level off on screen), at 8 bits; 16 bits
|
||
// round to 1/32768.
|
||
#[test]
|
||
fn lab_adjustment_results_are_whole_samples() {
|
||
let pts = |v: &[(f32, f32)]| v.iter().map(|&(input, output)| CurvePoint { input, output }).collect::<Vec<_>>();
|
||
let curves = |l: &[(f32, f32)]| Adjustment::Curves {
|
||
master: Vec::new(),
|
||
per_channel: [pts(l), Vec::new(), Vec::new()],
|
||
space: ToneSpace::Lab,
|
||
black: Vec::new(),
|
||
pencil: false,
|
||
};
|
||
// A straight line from (0, 0) through (100, 120.4): L 100 lands between two bytes.
|
||
let adj = curves(&[(0.0, 0.0), (100.0 / 255.0, 120.4 / 255.0), (1.0, 1.0)]);
|
||
for (depth, q) in [(SampleType::U8, 255.0f32), (SampleType::U16, 32768.0)] {
|
||
let doc = lab_stack([100.0 / 255.0, 0.5, 0.5], depth, vec![adj.clone()]);
|
||
let p = px(&doc, 1, 1);
|
||
let stack = native_lab::LabStack::new(&doc);
|
||
assert!(stack.is_some_and(|s| s.adjusts()), "{depth:?}: the native path");
|
||
let l = photocraft_color::convert::srgb_to_lab([p[0], p[1], p[2]])[0] / 100.0;
|
||
assert!((l * q - (l * q).round()).abs() < 0.02, "{depth:?}: L {} is between two samples", l * q);
|
||
}
|
||
}
|
||
|
||
// Flattening a Lab document keeps its stored values, out-of-gamut colours included (the RGB
|
||
// compositor clipped them on the way through display RGB).
|
||
#[test]
|
||
fn lab_flatten_keeps_the_stored_values() {
|
||
let lab = [0.6, 218.0 / 255.0, 38.0 / 255.0];
|
||
for depth in [SampleType::U8, SampleType::U16] {
|
||
let doc = lab_stack(lab, depth, vec![]);
|
||
let s = flatten_to_surface(&doc, doc.pixel_format(), None);
|
||
let got = s.pixel(2, 2);
|
||
assert!(got.iter().zip(lab).all(|(g, w)| (g - w).abs() < 1e-3), "{depth:?}: {got:?}");
|
||
}
|
||
}
|
||
|
||
// Anything the stored values can't carry exactly keeps the RGB compositor.
|
||
#[test]
|
||
fn lab_stacks_with_other_layers_keep_the_rgb_compositor() {
|
||
let lab = [0.6, 0.5, 0.5];
|
||
assert!(native_lab::LabStack::new(&lab_stack(lab, SampleType::U8, vec![Adjustment::Invert])).is_some_and(|s| s.adjusts()));
|
||
let hue = Adjustment::HueSaturation { hue: 30.0, saturation: 0.0, lightness: 0.0, colorize: false, ranges: photocraft_doc::adjust::HueRange::defaults() };
|
||
assert!(native_lab::LabStack::new(&lab_stack(lab, SampleType::U8, vec![hue])).is_none());
|
||
let mut multiply = lab_stack(lab, SampleType::U8, vec![Adjustment::Invert]);
|
||
if let Some(l) = multiply.layers.last_mut() {
|
||
l.blend = photocraft_color::blend::BlendMode::Multiply;
|
||
}
|
||
assert!(native_lab::LabStack::new(&multiply).is_none());
|
||
let rgb = Document::with_background("rgb", Size::new(4, 4), ColorMode::Rgb, SampleType::U8, Color::rgb(0.5, 0.5, 0.5));
|
||
assert!(native_lab::LabStack::new(&rgb).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn lab_documents_brightness_contrast_moves_lightness_only() {
|
||
let lab = photocraft_color::convert::srgb_to_lab(MUTED);
|
||
for depth in SampleType::ALL {
|
||
for legacy in [true, false] {
|
||
let got = lab_doc_adjusted(MUTED, Adjustment::BrightnessContrast { brightness: 40.0, contrast: 20.0, legacy }, depth);
|
||
assert!(got[0] > lab[0] + 5.0, "{depth:?} legacy {legacy}: {got:?}");
|
||
near3([lab[0], got[1], got[2]], lab, 0.75, &format!("{depth:?} legacy {legacy}: a, b kept"));
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn lab_documents_threshold_on_lightness_with_a_neutral_result() {
|
||
for depth in SampleType::ALL {
|
||
// A saturated yellow is light (L ≈ 97): white, not a coloured result.
|
||
let yellow = lab_doc_adjusted([1.0, 1.0, 0.0], Adjustment::Threshold { level: 0.5 }, depth);
|
||
near3(yellow, [100.0, 0.0, 0.0], 0.5, &format!("{depth:?} yellow"));
|
||
// Pure blue has L ≈ 30: black.
|
||
let blue = lab_doc_adjusted([0.0, 0.0, 1.0], Adjustment::Threshold { level: 0.5 }, depth);
|
||
near3(blue, [0.0, 0.0, 0.0], 0.5, &format!("{depth:?} blue"));
|
||
// Mid grey L 53.4 against level 128 (n 0.502): Lab L decides, not RGB luminance.
|
||
let level = 0.52;
|
||
let grey = lab_doc_adjusted([0.5; 3], Adjustment::Threshold { level }, depth);
|
||
near3(grey, [100.0, 0.0, 0.0], 0.5, &format!("{depth:?} grey"));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn lab_documents_gradient_map_follows_lightness() {
|
||
let stops = vec![(0.0, [0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 0.0])];
|
||
// A grey and a saturated colour of the same L map to the same colour.
|
||
let red = [0.9, 0.1, 0.1];
|
||
let l = photocraft_color::convert::srgb_to_lab(red)[0];
|
||
let grey = photocraft_color::convert::lab_to_srgb([l, 0.0, 0.0]);
|
||
for depth in SampleType::ALL {
|
||
for reverse in [false, true] {
|
||
let adj = Adjustment::GradientMap { stops: stops.clone(), reverse, dither: false };
|
||
let a = lab_doc_adjusted(red, adj.clone(), depth);
|
||
let b = lab_doc_adjusted(grey, adj.clone(), depth);
|
||
near3(a, b, 1.0, &format!("{depth:?} reverse {reverse}"));
|
||
let t = if reverse { 1.0 - l / 100.0 } else { l / 100.0 };
|
||
let want = photocraft_color::convert::srgb_to_lab([t, t, 1.0 - t]);
|
||
near3(a, want, 1.5, &format!("{depth:?} reverse {reverse}"));
|
||
}
|
||
}
|
||
// In RGB documents the map still follows RGB luminance: red and grey differ there.
|
||
let mut buf = Buffer::filled(Rect::new(0, 0, 1, 1), [red[0], red[1], red[2], 1.0]);
|
||
let adj = Adjustment::GradientMap { stops, reverse: false, dither: false };
|
||
adjust::apply_in(&adj, &mut buf, adjust::Transfer::Srgb, None, ColorMode::Rgb);
|
||
let mut grey_buf = Buffer::filled(Rect::new(0, 0, 1, 1), [grey[0], grey[1], grey[2], 1.0]);
|
||
adjust::apply_in(&adj, &mut grey_buf, adjust::Transfer::Srgb, None, ColorMode::Rgb);
|
||
assert!((buf.px[0][0] - grey_buf.px[0][0]).abs() > 0.05, "{:?} {:?}", buf.px[0], grey_buf.px[0]);
|
||
}
|
||
|
||
// ---------- Effect-map identity is computed once per layer per render (#4437) ----------
|
||
|
||
/// Effect layers of every kind the cache key walks: a plain layer, a styled group with a styled
|
||
/// child, and a layer whose mask hides its effects (rendered from a temporary copy).
|
||
fn identity_doc(depth: SampleType) -> Document {
|
||
let mut d = Document::with_background("t", Size::new(96, 96), ColorMode::Rgb, depth, Color::WHITE);
|
||
let fmt = d.pixel_format();
|
||
let mut sq = Layer::raster("sq", fmt);
|
||
sq.surface_mut().unwrap().fill_rect(Rect::new(8, 8, 40, 40), &[1.0, 0.0, 0.0, 1.0]);
|
||
sq.effects.items = vec![stroke(3.0, StrokePosition::Outside), Effect::DropShadow(shadow(4.0, 120.0))];
|
||
d.layers.push(sq);
|
||
let mut child = Layer::raster("child", fmt);
|
||
child.surface_mut().unwrap().fill_rect(Rect::new(50, 10, 86, 46), &[0.0, 0.6, 0.2, 1.0]);
|
||
child.effects.items = vec![stroke(2.0, StrokePosition::Outside)];
|
||
let mut group = Layer::group("g", vec![child]);
|
||
group.effects.items = vec![Effect::DropShadow(shadow(5.0, 60.0))];
|
||
d.layers.push(group);
|
||
let mut hidden = Layer::raster("hidden", fmt);
|
||
hidden.surface_mut().unwrap().fill_rect(Rect::new(10, 54, 46, 90), &[0.1, 0.2, 0.9, 1.0]);
|
||
hidden.effects.items = vec![stroke(3.0, StrokePosition::Outside)];
|
||
hidden.mask = Some(LayerMask::reveal_all());
|
||
hidden.advanced.layer_mask_hides_effects = true;
|
||
d.layers.push(hidden);
|
||
d
|
||
}
|
||
|
||
#[test]
|
||
fn memoised_effect_identity_leaves_every_render_unchanged() {
|
||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||
let d = identity_doc(depth);
|
||
let b = d.bounds();
|
||
// One tile for the whole canvas never goes through `prepare_effects`; small tiles do.
|
||
purge_effect_cache();
|
||
let whole = render_tiled(&d, b, 4096);
|
||
purge_effect_cache();
|
||
let tiled = render_tiled(&d, b, 16);
|
||
let warm = render_tiled(&d, b, 24);
|
||
assert!(whole.px == tiled.px, "{depth:?}: tiled differs from one tile");
|
||
assert!(whole.px == warm.px, "{depth:?}: warm differs from cold");
|
||
assert!(whole.px.iter().any(|p| *p != [1.0; 4]), "{depth:?}: the effects should draw");
|
||
// Edits still invalidate: a changed layer must not keep its old maps.
|
||
let mut e = d.clone();
|
||
e.layers[1].effects.items = vec![Effect::DropShadow(shadow(9.0, 200.0))];
|
||
let edited = render_tiled(&e, b, 16);
|
||
purge_effect_cache();
|
||
assert!(edited.px == render_tiled(&e, b, 4096).px, "{depth:?}: stale maps after an edit");
|
||
assert!(edited.px != whole.px, "{depth:?}: the edit should show");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn effect_identity_is_built_once_per_layer_not_per_tile() {
|
||
let mut d = identity_doc(SampleType::U8);
|
||
// Leave out the temporary-copy layer: it is rebuilt per call by design.
|
||
d.layers.pop();
|
||
let patterns = pattern::PreparedPatterns::new(&d.patterns, pattern::PREPARED_PATTERN_BYTES);
|
||
let cx = Ctx::for_doc(&d, &patterns);
|
||
let b = d.bounds();
|
||
let tiles = 6 * 6;
|
||
let first = render_tiled_with(&d, b, 16, &cx);
|
||
let built = cx.layer_keys.built.load(std::sync::atomic::Ordering::Relaxed);
|
||
// sq, the group and its child: three effect layers, however many tiles.
|
||
assert_eq!(built, 3, "identity built {built} times over {tiles} tiles");
|
||
// A second render with the same context finds all of them memoised.
|
||
let second = render_tiled_with(&d, b, 16, &cx);
|
||
assert_eq!(cx.layer_keys.built.load(std::sync::atomic::Ordering::Relaxed), 3);
|
||
assert!(first.px == second.px);
|
||
}
|
||
|
||
#[test]
|
||
fn render_refuses_rects_beyond_the_pixel_cap() {
|
||
let doc = Document::new("c", Size::new(4, 4), ColorMode::Rgb, SampleType::U8);
|
||
// 2^32 - 1 px wide: the width wraps negative as an i32, which once took the single-tile path.
|
||
for tile in [crate::RENDER_TILE, 1 << 20] {
|
||
let b = crate::render_tiled(&doc, Rect::new(i32::MIN, 0, i32::MAX, 1), tile);
|
||
assert!(b.px.is_empty() && b.rect.is_empty());
|
||
}
|
||
}
|
||
|
||
// The effect-map cache key covers Transparency Shapes Layer and Blend If: both change the shape
|
||
// the maps are built from (a layer's own, or a styled group's children composite) without
|
||
// touching pixels, masks or effects, and stale maps kept stroking a hole the layer no longer
|
||
// had. Each case renders with the cache warm, edits, renders again and compares with a cold
|
||
// render. (A purge by a concurrent test only makes the warm render cold, never a false failure.)
|
||
#[test]
|
||
fn effect_cache_follows_transparency_shapes_layer() {
|
||
let mut d = doc_white(32, 32);
|
||
let mut l = solid_layer("holed", Rect::new(0, 0, 32, 32), [0.9, 0.2, 0.2, 1.0]);
|
||
l.surface_mut().unwrap().fill_rect(Rect::new(12, 12, 20, 20), &[0.0, 0.0, 0.0, 0.0]);
|
||
l.effects.items = vec![stroke(2.0, StrokePosition::Outside)];
|
||
assert!(l.advanced.transparency_shapes);
|
||
d.layers.push(l);
|
||
let on = render(&d, d.bounds());
|
||
d.layers[1].advanced.transparency_shapes = false;
|
||
let warm = render(&d, d.bounds());
|
||
purge_effect_cache();
|
||
let cold = render(&d, d.bounds());
|
||
assert!(on.px != cold.px, "the stroke should stop rimming the hole");
|
||
assert!(warm.px == cold.px, "stale effect maps after turning Transparency Shapes Layer off");
|
||
}
|
||
|
||
#[test]
|
||
fn effect_cache_follows_a_group_childs_blend_if() {
|
||
let mut d = doc_white(32, 32);
|
||
let mut child = solid_layer("bw", Rect::new(4, 4, 16, 28), [0.0, 0.0, 0.0, 1.0]);
|
||
child.surface_mut().unwrap().fill_rect(Rect::new(16, 4, 28, 28), &[1.0, 1.0, 1.0, 1.0]);
|
||
let mut g = Layer::group("g", vec![child]);
|
||
g.effects.items = vec![Effect::DropShadow(Shadow { size: 2.0, spread: 0.0, ..shadow(3.0, 90.0) })];
|
||
d.layers.push(g);
|
||
let before = render(&d, d.bounds());
|
||
let LayerContent::Group(grp) = &mut d.layers[1].content else { panic!("not a group") };
|
||
// Gray › This Layer: black point at 128 hides the black half.
|
||
grp.children[0].blend_if.set(0, [range([128, 128], [255, 255]), FULL]);
|
||
let warm = render(&d, d.bounds());
|
||
purge_effect_cache();
|
||
let cold = render(&d, d.bounds());
|
||
assert!(before.px != cold.px, "Blend If should change the group's shadow");
|
||
assert!(warm.px == cold.px, "stale group effect maps after a child's Blend If changed");
|
||
}
|
||
|
||
/// A document whose one-colour areas are found: shared background tiles, a painted patch over
|
||
/// tile edges, a uniform mask tile, an adjustment, a group, a 16-bit layer and a hidden one.
|
||
fn sparse_doc(w: u32, h: u32) -> Document {
|
||
let mut d = doc_white(w, h);
|
||
let mut paint = Layer::raster("paint", PixelFormat::RGBA8);
|
||
let s = paint.surface_mut().unwrap();
|
||
for y in 200..330 {
|
||
s.fill_rect(Rect::new(240, y, 520, y + 1), &[(y - 200) as f32 / 130.0, 0.4, 0.7, 0.8]);
|
||
}
|
||
s.fill_rect(Rect::new(0, 600, w as i32 / 2, 900), &[0.2, 0.9, 0.1, 0.5]);
|
||
let mut m = LayerMask::reveal_all();
|
||
m.surface.fill_rect(Rect::new(0, 512, 256, 768), &[0.25]);
|
||
m.surface.fill_rect(Rect::new(300, 700, 310, 710), &[0.0]);
|
||
paint.mask = Some(m);
|
||
paint.blend = BlendMode::Multiply;
|
||
let mut deep = solid_layer("deep", Rect::new(100, 100, 700, 400), [0.3, 0.3, 0.9, 0.6]);
|
||
deep.content = LayerContent::Raster(deep.surface().unwrap().convert(PixelFormat::new(ColorMode::Rgb, SampleType::U16, true)));
|
||
let mut hidden = solid_layer("hidden", Rect::new(0, 0, 50, 50), [1.0, 0.0, 0.0, 1.0]);
|
||
hidden.visible = false;
|
||
d.layers.push(Layer::group("g", vec![paint, deep, hidden]));
|
||
d.layers.push(Layer::new("levels", LayerContent::Adjustment(Adjustment::Exposure { exposure: 0.4, offset: 0.0, gamma: 1.1 })));
|
||
d
|
||
}
|
||
|
||
#[test]
|
||
fn one_colour_areas_reduce_like_composited_ones() {
|
||
// #4017: one-colour tiles are composited once and summed per run of columns: the same
|
||
// averages as compositing every pixel (to float rounding), and a damage render gives exactly
|
||
// the whole reduction's pixels, so a reduced canvas updated in parts shows no seams.
|
||
let d = sparse_doc(2000, 2100);
|
||
for (b, w, h, band) in [
|
||
(d.bounds(), 37, 41, 0),
|
||
(d.bounds(), 250, 2100, 256),
|
||
(d.bounds(), 1999, 3, 512),
|
||
(Rect::new(-300, -77, 2200, 2300), 61, 29, 0),
|
||
(Rect::new(13, 5, 2000, 2100), 1, 1, 256),
|
||
] {
|
||
let reference = render_reduced_with(&d, b, w, h, None, band, false, &mut ReducedStats::default());
|
||
let mut stats = ReducedStats::default();
|
||
let all = render_reduced_with(&d, b, w, h, None, band, true, &mut stats);
|
||
assert!(stats.uniform);
|
||
assert!(stats.composited < u64::from(b.width()) * u64::from(b.height()) / 2, "{b:?}: {stats:?}");
|
||
assert_eq!(all.rect, reference.rect);
|
||
for (p, q) in all.px.iter().zip(&reference.px) {
|
||
assert!(p.iter().zip(q).all(|(a, b)| (a - b).abs() < 1e-4), "{b:?} {w}x{h} band {band}: {p:?} vs {q:?}");
|
||
}
|
||
for dmg in [Rect::new(250, 210, 260, 220), Rect::new(0, 0, 2000, 2100), Rect::new(-50, 640, 3000, 641), Rect::new(1500, 1500, 1600, 1900)] {
|
||
let part = render_reduced_with(&d, b, w, h, Some(dmg), band, true, &mut ReducedStats::default());
|
||
for y in part.rect.y0..part.rect.y1 {
|
||
for x in part.rect.x0..part.rect.x1 {
|
||
assert_eq!(part.get(x, y), all.get(x, y), "{b:?} {w}x{h} band {band} {dmg:?} at {x},{y}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn layers_that_read_other_pixels_are_composited_pixel_by_pixel() {
|
||
let base = sparse_doc(300, 300);
|
||
let edits: [fn(&mut Layer); 4] = [
|
||
|l| l.blend = BlendMode::Dissolve,
|
||
|l| l.mask.get_or_insert_with(LayerMask::reveal_all).feather = 3.0,
|
||
|l| l.effects.items.push(stroke(2.0, StrokePosition::Outside)),
|
||
|l| l.content = LayerContent::Fill(Fill::Solid(Color::BLACK)),
|
||
];
|
||
for edit in edits {
|
||
let mut d = base.clone();
|
||
edit(&mut d.layers[0]);
|
||
let mut stats = ReducedStats::default();
|
||
render_reduced_with(&d, d.bounds(), 30, 30, None, 0, true, &mut stats);
|
||
assert!(!stats.uniform);
|
||
assert_eq!(stats.composited, 300 * 300);
|
||
}
|
||
let mut d = base.clone();
|
||
d.layers.push(Layer::new("map", LayerContent::Adjustment(Adjustment::GradientMap { stops: vec![], reverse: false, dither: true })));
|
||
let mut stats = ReducedStats::default();
|
||
render_reduced_with(&d, d.bounds(), 30, 30, None, 0, true, &mut stats);
|
||
assert!(!stats.uniform);
|
||
}
|
||
|
||
#[test]
|
||
fn a_gigapixel_new_document_reduces_without_compositing_its_canvas() {
|
||
// #4017: the reduced canvas of a 10 x 5 m, 200 ppi document (3.1 GP) composited every pixel
|
||
// (25 s on 32 threads, all cores busy). Only the partial tiles at its right and bottom edges
|
||
// hold two values; the rest is one shared white tile.
|
||
let (w, h) = (78_740u32, 39_370u32);
|
||
for fill in [Some(Color::WHITE), Some(Color::rgba(0.2, 0.4, 0.6, 1.0)), None] {
|
||
let mut d = match fill {
|
||
Some(c) => Document::with_background("big", Size::new(w, h), ColorMode::Rgb, SampleType::U8, c),
|
||
None => Document::new("big", Size::new(w, h), ColorMode::Rgb, SampleType::U8),
|
||
};
|
||
d.layers.push(Layer::raster("Layer 1", PixelFormat::RGBA8));
|
||
let mut stats = ReducedStats::default();
|
||
let r = render_reduced_with(&d, d.bounds(), 3937, 1969, None, 0, true, &mut stats);
|
||
let edge_tiles = u64::from(w.div_ceil(256) + h.div_ceil(256) - 1);
|
||
assert!(stats.composited <= edge_tiles * 256 * 256, "{fill:?}: {stats:?}");
|
||
let want = fill.map_or([0.0; 4], |c| [c.c[0], c.c[1], c.c[2], 1.0]);
|
||
assert!(r.px.iter().all(|p| close4(*p, want)), "{fill:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn shape_layer_bounds_includes_vector_bounds_clipped_to_canvas_and_preserves_effects() {
|
||
let canvas = Rect::new(0, 0, 100, 100);
|
||
let subpath = photocraft_doc::vector::Subpath::polygon(&[(-20.0, -20.0), (50.0, -20.0), (50.0, 50.0), (-20.0, 50.0)]);
|
||
let path = photocraft_doc::vector::Path { subpaths: vec![subpath], ..Default::default() };
|
||
let sh = photocraft_doc::vector::ShapeLayer {
|
||
path,
|
||
fill: Some(photocraft_doc::Fill::Solid(photocraft_doc::Color::BLACK)),
|
||
stroke: Some(photocraft_doc::vector::ShapeStroke { width: 4.0, opacity: 1.0, ..Default::default() }),
|
||
..Default::default()
|
||
};
|
||
let mut layer = Layer::new("shape", LayerContent::Shape(sh));
|
||
|
||
// Vector bounds reach outside canvas (-24..54), but layer_bounds clips vector bounds to canvas:
|
||
let b = layer_bounds(&layer, canvas);
|
||
assert_eq!(b, Rect::new(0, 0, 54, 54), "shape vector bounds must clip to canvas and not reach past it");
|
||
|
||
// When the shape has layer effects, layer_bounds preserves effects union handling:
|
||
layer.effects.items = vec![Effect::DropShadow(Shadow { size: 5.0, spread: 0.0, ..shadow(5.0, 90.0) })];
|
||
let b_fx = layer_bounds(&layer, canvas);
|
||
assert!(!b_fx.is_empty());
|
||
assert!(b_fx.contains_rect(&b));
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "timing: cargo test --release -p photocraft-compose modern_lut_timing -- --ignored --nocapture"]
|
||
fn modern_lut_timing() {
|
||
let t = std::time::Instant::now();
|
||
for _ in 0..100 {
|
||
std::hint::black_box(adjust::modern_lut(-50.0, 30.0));
|
||
}
|
||
println!("modern_lut(-50, 30): {:?} each", t.elapsed() / 100);
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "timing: cargo test --release -p photocraft-compose cmyk_ink_render_timing -- --ignored --nocapture"]
|
||
fn cmyk_ink_render_timing() {
|
||
// 24 MP CMYK8 photo-sized noise with one adjustment layer: the ink path (Invert) against the
|
||
// RGB compositor (Exposure, which the inks don't carry).
|
||
let (w, h) = (6000, 4000);
|
||
let mut d = Document::new("c", Size::new(w, h), ColorMode::Cmyk, SampleType::U8);
|
||
let mut l = Layer::raster("bg", d.pixel_format());
|
||
let data: Vec<f32> = (0..(w * h) as usize)
|
||
.flat_map(|i| [(i % 251) as f32 / 255.0, (i % 241) as f32 / 255.0, (i % 239) as f32 / 255.0, (i % 97) as f32 / 255.0, 1.0])
|
||
.collect();
|
||
l.surface_mut().unwrap().write_region(Rect::new(0, 0, w as i32, h as i32), &data);
|
||
d.layers.push(l);
|
||
for adj in [Adjustment::Invert, Adjustment::Exposure { exposure: 0.5, offset: 0.0, gamma: 1.0 }] {
|
||
d.layers.truncate(1);
|
||
d.layers.push(Layer::new("adj", LayerContent::Adjustment(adj.clone())));
|
||
let _ = render(&d, Rect::new(0, 0, 512, 512));
|
||
let t = std::time::Instant::now();
|
||
let b = render(&d, d.bounds());
|
||
println!("{}: {:?} ({} px)", adj.label(), t.elapsed(), b.px.len());
|
||
}
|
||
}
|
||
|
||
// A pass-through group with an adjustment layer reaches the layers beneath, unless the group is
|
||
// the base of a clipping group: then Photoshop renders it isolated, and an adjustment with no
|
||
// content of its own in the group changes nothing (psd-tools passthrough_clipping_mask_adjustment).
|
||
#[test]
|
||
fn a_pass_through_clipping_base_keeps_its_adjustments_inside() {
|
||
let base = || {
|
||
let mut d = doc_white(4, 4);
|
||
let group = Layer::group("group", vec![Layer::new("invert", LayerContent::Adjustment(Adjustment::Invert))]);
|
||
d.layers.push(group);
|
||
d
|
||
};
|
||
// Unclipped: the invert reaches the white background.
|
||
assert!(close4(px(&base(), 1, 1), [0.0, 0.0, 0.0, 1.0]), "{:?}", px(&base(), 1, 1));
|
||
// With a layer clipped to the group: the background stays white.
|
||
let mut d = base();
|
||
let mut clip = Layer::raster("clipped", PixelFormat::RGBA8);
|
||
clip.clipped = true;
|
||
d.layers.push(clip);
|
||
assert!(close4(px(&d, 1, 1), [1.0, 1.0, 1.0, 1.0]), "{:?}", px(&d, 1, 1));
|
||
}
|
||
|
||
// A cache entry reserves the addresses of the tiles its key hashes, not their pixels (#4530): once
|
||
// the document lets go of a styled layer or group (an edit replaced its tiles, or it was
|
||
// deleted), the effect cache keeps none of its pixel memory alive, and the maps are still
|
||
// reused while the layer is unchanged.
|
||
#[test]
|
||
fn effect_cache_keeps_no_pixels_of_a_dropped_layer() {
|
||
let mut d = doc_white(300, 300);
|
||
let mut l = solid_layer("styled", Rect::new(20, 20, 280, 280), [0.9, 0.2, 0.2, 1.0]);
|
||
l.effects.items = vec![stroke(3.0, StrokePosition::Outside)];
|
||
d.layers.push(l);
|
||
let mut g = Layer::group("g", vec![solid_layer("child", Rect::new(40, 40, 120, 120), [0.1, 0.2, 0.9, 1.0])]);
|
||
g.effects.items = vec![Effect::DropShadow(Shadow { size: 2.0, spread: 0.0, ..shadow(3.0, 90.0) })];
|
||
d.layers.push(g);
|
||
let first = render(&d, d.bounds());
|
||
let tile = |l: &Layer| l.surface().and_then(|s| s.tiles().next()).map(|(_, t)| t.clone()).unwrap();
|
||
let own = tile(&d.layers[1]);
|
||
let LayerContent::Group(grp) = &d.layers[2].content else { panic!("not a group") };
|
||
let child = tile(&grp.children[0]);
|
||
assert_eq!(render(&d, d.bounds()).px, first.px, "warm render");
|
||
drop(d);
|
||
// Only this test's handles are left: the cache pins neither the layer's nor the child's pixels.
|
||
assert_eq!(std::sync::Arc::strong_count(&own), 1);
|
||
assert_eq!(std::sync::Arc::strong_count(&child), 1);
|
||
}
|
||
|
||
// Concurrent callers share one memoized effect identity.
|
||
#[test]
|
||
fn concurrent_effect_identity_misses_compute_once() {
|
||
let mut doc = identity_doc(SampleType::U8);
|
||
// Make the existing pure identity hash long enough to expose overlapping misses.
|
||
doc.layers[1].effects.items = vec![Effect::DropShadow(shadow(4.0, 120.0)); 512];
|
||
let layer = &doc.layers[1];
|
||
let keys = LayerKeys::default();
|
||
let start = std::sync::Barrier::new(8);
|
||
std::thread::scope(|scope| {
|
||
let handles: Vec<_> = (0..8)
|
||
.map(|_| {
|
||
scope.spawn(|| {
|
||
start.wait();
|
||
keys.identity(layer, Some(&doc))
|
||
})
|
||
})
|
||
.collect();
|
||
let ids: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
|
||
assert!(ids.iter().all(|id| *id == ids[0]));
|
||
});
|
||
assert_eq!(keys.built.load(std::sync::atomic::Ordering::Relaxed), 1, "concurrent cache misses must compute one memoized identity");
|
||
}
|
||
|
||
#[test]
|
||
fn reused_render_matches_previous_allocating_stack() {
|
||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||
let mut doc = Document::new("equivalence", Size::new(513, 7), ColorMode::Rgb, depth);
|
||
let rect = doc.bounds();
|
||
for (name, color) in [("bottom", [0.2, 0.4, 0.8, 1.0]), ("top", [1.5, -0.25, 0.6, 0.5])] {
|
||
let mut layer = Layer::raster(name, doc.pixel_format());
|
||
layer.surface_mut().unwrap().fill_rect(rect, &color);
|
||
doc.layers.push(layer);
|
||
}
|
||
let mut levels = Adjustment::identity_levels();
|
||
if let Adjustment::Levels { master, .. } = &mut levels {
|
||
master.gamma = 1.4;
|
||
}
|
||
for adj in [levels, Adjustment::identity_curves()] {
|
||
let mut layer = Layer::new("tone", LayerContent::Adjustment(adj));
|
||
layer.opacity = 0.6;
|
||
doc.layers.push(layer);
|
||
}
|
||
let mut previous = Buffer::transparent(rect);
|
||
in_blend_space(false, depth == SampleType::F32, || {
|
||
for layer in &doc.layers {
|
||
if let LayerContent::Adjustment(adj) = &layer.content {
|
||
let mut adjusted = previous.clone();
|
||
adjust::apply_in(adj, &mut adjusted, adjust::Transfer::for_document(doc.mode, depth), Some(depth), doc.mode);
|
||
for (b, a) in previous.px.iter_mut().zip(&adjusted.px) {
|
||
let blended = blend::blend_rgb(layer.blend, [b[0], b[1], b[2]], [a[0], a[1], a[2]]);
|
||
*b = [
|
||
b[0] + (blended[0] - b[0]) * layer.opacity,
|
||
b[1] + (blended[1] - b[1]) * layer.opacity,
|
||
b[2] + (blended[2] - b[2]) * layer.opacity,
|
||
b[3],
|
||
];
|
||
}
|
||
if let Some(q) = adjustment_quantum(depth) {
|
||
quantize(&mut previous, q);
|
||
}
|
||
} else {
|
||
let content = surface_to_buffer(layer.surface().unwrap(), rect);
|
||
blend_into_fill(&mut previous, &content, layer.blend, layer.opacity, layer.fill_opacity, 1.0);
|
||
}
|
||
}
|
||
});
|
||
for tile in [1, 127, 256, 1024] {
|
||
assert_buffer_bits(&render_tiled(&doc, rect, tile), &previous);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn fully_masked_adjustment_tiles_preserve_output_and_skip_tone_work() {
|
||
let rect = Rect::new(-1, -1, 2, 1);
|
||
for depth in SampleType::ALL {
|
||
for partial in [false, true] {
|
||
let mut doc = Document::new("masked", Size::new(3, 2), ColorMode::Rgb, depth);
|
||
let mut adj = Adjustment::identity_curves();
|
||
if let Adjustment::Curves { master, .. } = &mut adj {
|
||
master[1].output = 0.8;
|
||
}
|
||
let mut layer = Layer::new("curves", LayerContent::Adjustment(adj.clone()));
|
||
layer.opacity = 0.6;
|
||
let mut mask = LayerMask::hide_all();
|
||
if partial {
|
||
mask.surface.write_pixel(0, 0, &[0.75]);
|
||
}
|
||
layer.mask = Some(mask);
|
||
doc.layers.push(layer);
|
||
let patterns = pattern::PreparedPatterns::new(&[], pattern::PREPARED_PATTERN_BYTES);
|
||
let cx = Ctx::for_doc(&doc, &patterns);
|
||
let before = Buffer::filled(rect, [0.2, -0.0, 0.7, 0.6]);
|
||
let mut adjusted = before.clone();
|
||
adjust::apply_in(&adj, &mut adjusted, cx.transfer, Some(depth), doc.mode);
|
||
let mv = mask_vals(&doc.layers[0], rect, &cx);
|
||
let mut expected = before.clone();
|
||
for (i, (b, a)) in expected.px.iter_mut().zip(&adjusted.px).enumerate() {
|
||
let k = 0.6 * mask_k(&mv, i);
|
||
if k > 0.0 {
|
||
for c in 0..3 {
|
||
b[c] += (a[c] - b[c]) * k;
|
||
}
|
||
}
|
||
}
|
||
let clipped = if partial { vec![] } else { vec![solid_layer("clipped", rect, [0.9, 0.1, 0.2, 1.0])] };
|
||
let mut actual = before;
|
||
composite_layer_plain(&doc.layers[0], &clipped, &mut actual, &cx, advanced::Scope::ROOT);
|
||
assert_buffer_bits(&actual, &expected);
|
||
assert_eq!(cx.tone_luts[0].1.get().is_some(), partial);
|
||
}
|
||
}
|
||
}
|