Layer Style › Blending Options › Blend If (#26)
Model Blend If (This Layer / Underlying Layer ranges, split points) as
Layer::blend_if, read and write it as the PSD layer-record blending
ranges (previously parsed but dropped on export), save it in .pcraft,
render it in the CPU compositor (premultiplied mix = compositing at
k × alpha), fall back to the CPU on the GPU path, and drive it from
layer.layerStyle.blendingOptions {"blendIf": …}; document.inspect
reports it.
Co-authored-by: storybored379 <storybored379@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
7ec0b4aa62
commit
11e5af23ad
|
|
@ -470,15 +470,97 @@ pub fn transparent_outside(layer: &Layer) -> bool {
|
|||
}
|
||||
}
|
||||
|
||||
/// Composite `layer` (plus its clipping group) onto `backdrop`, honouring its channel restrictions.
|
||||
fn composite_layer(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx) {
|
||||
match channel_weights(layer, cx.mode) {
|
||||
Some(w) => {
|
||||
let before = backdrop.clone();
|
||||
composite_layer_any(layer, clipped, backdrop, cx);
|
||||
restore_channels(backdrop, &before, w);
|
||||
/// Whether Blending Options › Blend If changes how `layer` composites in a `mode` document.
|
||||
/// RGB documents test Gray and R, G, B; grayscale (and duotone) documents their one channel.
|
||||
/// Other modes composite in display RGB, where ranges over their own channels have no exact
|
||||
/// equivalent, so (like channel restrictions) the setting round-trips but isn't applied there.
|
||||
pub fn blend_if_active(layer: &Layer, mode: photocraft_color::ColorMode) -> bool {
|
||||
use photocraft_color::ColorMode as M;
|
||||
matches!(mode, M::Rgb | M::Grayscale | M::Duotone) && !layer.blend_if.is_default()
|
||||
}
|
||||
|
||||
/// How much of a pixel shows through `layer`'s Blend If ranges, given the layer's own colour
|
||||
/// (`this`, `None` where the layer has no content of its own there) and the colour beneath it
|
||||
/// (`under`, `None` where nothing is beneath). Every range multiplies in.
|
||||
fn blend_if_weight(
|
||||
layer: &Layer,
|
||||
mode: photocraft_color::ColorMode,
|
||||
this: Option<[f32; 4]>,
|
||||
under: Option<[f32; 4]>,
|
||||
) -> f32 {
|
||||
use photocraft_color::ColorMode as M;
|
||||
let bi = &layer.blend_if;
|
||||
let mut k = 1.0;
|
||||
for (side, px) in [this, under].into_iter().enumerate() {
|
||||
let Some(p) = px else { continue };
|
||||
let v = |c: usize| p[c].clamp(0.0, 1.0) * 255.0;
|
||||
match mode {
|
||||
// Gray is the colour channels' luma (Rec. 601 weights), then R, G, B.
|
||||
M::Rgb => {
|
||||
let gray = 0.299 * v(0) + 0.587 * v(1) + 0.114 * v(2);
|
||||
k *= bi.get(0)[side].weight(gray);
|
||||
for c in 0..3 {
|
||||
k *= bi.get(c + 1)[side].weight(v(c));
|
||||
}
|
||||
}
|
||||
// One channel: the PSD spec marks the composite-gray entry irrelevant here, so the
|
||||
// channel's own entry carries the setting; both are honoured.
|
||||
_ => k *= bi.get(0)[side].weight(v(0)) * bi.get(1)[side].weight(v(0)),
|
||||
}
|
||||
if k <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
k
|
||||
}
|
||||
|
||||
/// Apply `layer`'s Blend If to a finished composite: `out` (the backdrop with the layer drawn)
|
||||
/// is mixed back towards `before` (the backdrop without it) where the ranges hide the layer.
|
||||
/// Mixing premultiplied colour by `k` equals compositing the layer at `k` × its alpha, since
|
||||
/// every blend mode's source-over result is linear in the source alpha.
|
||||
fn apply_blend_if(layer: &Layer, before: &Buffer, out: &mut Buffer, cx: &Ctx) {
|
||||
// "This Layer" is the layer's own colour; adjustment layers (no content of their own) are
|
||||
// judged by their result.
|
||||
let own = if matches!(layer.content, LayerContent::Adjustment(_)) {
|
||||
None
|
||||
} else {
|
||||
render_content(layer, out.rect, cx)
|
||||
};
|
||||
for (i, (p, b)) in out.px.iter_mut().zip(&before.px).enumerate() {
|
||||
let this = match &own {
|
||||
Some(o) => Some(o.px[i]).filter(|q| q[3] > 0.0),
|
||||
None => Some(*p),
|
||||
};
|
||||
let under = Some(*b).filter(|q| q[3] > 0.0);
|
||||
let k = blend_if_weight(layer, cx.mode, this, under);
|
||||
if k >= 1.0 {
|
||||
continue;
|
||||
}
|
||||
let a = b[3] + (p[3] - b[3]) * k;
|
||||
*p = if a > 0.0 {
|
||||
let c =
|
||||
|c: usize| ((b[c] * b[3] + (p[c] * p[3] - b[c] * b[3]) * k) / a).clamp(0.0, 1.0);
|
||||
[c(0), c(1), c(2), a]
|
||||
} else {
|
||||
[0.0; 4]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Composite `layer` (plus its clipping group) onto `backdrop`, honouring its channel restrictions
|
||||
/// and Blend If.
|
||||
fn composite_layer(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx) {
|
||||
let w = channel_weights(layer, cx.mode);
|
||||
let blend_if = blend_if_active(layer, cx.mode);
|
||||
let before = (w.is_some() || blend_if).then(|| backdrop.clone());
|
||||
composite_layer_any(layer, clipped, backdrop, cx);
|
||||
if let Some(before) = &before {
|
||||
if let Some(w) = w {
|
||||
restore_channels(backdrop, before, w);
|
||||
}
|
||||
if blend_if {
|
||||
apply_blend_if(layer, before, backdrop, cx);
|
||||
}
|
||||
None => composite_layer_any(layer, clipped, backdrop, cx),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -808,15 +890,19 @@ fn texture_ctx<'a>(layer: &Layer, region: Rect, cx: &Ctx<'a>) -> effects::Textur
|
|||
}
|
||||
|
||||
/// Composite `layer` onto `base` restricted to the base's alpha (clipping mask semantics),
|
||||
/// honouring its channel restrictions.
|
||||
/// honouring its channel restrictions and Blend If.
|
||||
fn composite_atop(layer: &Layer, base: &mut Buffer, cx: &Ctx) {
|
||||
match channel_weights(layer, cx.mode) {
|
||||
Some(w) => {
|
||||
let before = base.clone();
|
||||
composite_atop_any(layer, base, cx);
|
||||
restore_channels(base, &before, w);
|
||||
let w = channel_weights(layer, cx.mode);
|
||||
let blend_if = blend_if_active(layer, cx.mode);
|
||||
let before = (w.is_some() || blend_if).then(|| base.clone());
|
||||
composite_atop_any(layer, base, cx);
|
||||
if let Some(before) = &before {
|
||||
if let Some(w) = w {
|
||||
restore_channels(base, before, w);
|
||||
}
|
||||
if blend_if {
|
||||
apply_blend_if(layer, before, base, cx);
|
||||
}
|
||||
None => composite_atop_any(layer, base, cx),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -777,6 +777,161 @@ fn channel_restrictions_keep_the_backdrop() {
|
|||
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]));
|
||||
}
|
||||
|
||||
#[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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,175 @@
|
|||
//! Layer Style › Blending Options › Blend If: per-channel value ranges that hide a layer's
|
||||
//! pixels by their own value ("This Layer") or by the value beneath them ("Underlying Layer").
|
||||
//!
|
||||
//! Each slider pair is stored as Photoshop stores it in a PSD layer record's blending ranges: a
|
||||
//! black point and a white point, each split into a low and a high half (Alt-drag in the dialog),
|
||||
//! on the 0..=255 scale whatever the document's depth. A value below the black low point or above
|
||||
//! the white high point is hidden; between the two halves of a split point it fades linearly.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// One Blend If slider pair: `black` = (low, high), `white` = (low, high), all on 0..=255.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct BlendRange {
|
||||
pub black: [u8; 2],
|
||||
pub white: [u8; 2],
|
||||
}
|
||||
|
||||
impl Default for BlendRange {
|
||||
fn default() -> Self {
|
||||
Self::FULL
|
||||
}
|
||||
}
|
||||
|
||||
impl BlendRange {
|
||||
/// Every value blends (the sliders at the ends).
|
||||
pub const FULL: BlendRange = BlendRange {
|
||||
black: [0, 0],
|
||||
white: [255, 255],
|
||||
};
|
||||
|
||||
/// Whether this range hides nothing.
|
||||
pub fn is_full(&self) -> bool {
|
||||
*self == Self::FULL
|
||||
}
|
||||
|
||||
/// From PSD's byte order: black low, black high, white low, white high.
|
||||
pub fn from_bytes(b: [u8; 4]) -> Self {
|
||||
BlendRange {
|
||||
black: [b[0], b[1]],
|
||||
white: [b[2], b[3]],
|
||||
}
|
||||
}
|
||||
|
||||
/// PSD's byte order (see [`BlendRange::from_bytes`]).
|
||||
pub fn to_bytes(self) -> [u8; 4] {
|
||||
[self.black[0], self.black[1], self.white[0], self.white[1]]
|
||||
}
|
||||
|
||||
/// How much of a pixel with value `v` (0..=255, fractional for deep documents) shows:
|
||||
/// 0 below the black point or above the white point, 1 between them, and a linear ramp across
|
||||
/// a split point. An unsplit black point at `b` shows `v >= b`; an unsplit white point at `w`
|
||||
/// shows `v <= w`.
|
||||
pub fn weight(&self, v: f32) -> f32 {
|
||||
let ramp = |lo: u8, hi: u8, x: f32| -> f32 {
|
||||
// 0 at or below `lo`, 1 at or above `hi`.
|
||||
let (lo, hi) = (f32::from(lo), f32::from(hi));
|
||||
if hi <= lo {
|
||||
if x >= lo { 1.0 } else { 0.0 }
|
||||
} else {
|
||||
((x - lo) / (hi - lo)).clamp(0.0, 1.0)
|
||||
}
|
||||
};
|
||||
let lo = ramp(self.black[0], self.black[1], v);
|
||||
// Mirror for the white point: 1 at or below its low half, 0 above its high half.
|
||||
let hi = ramp(255 - self.white[1], 255 - self.white[0], 255.0 - v);
|
||||
lo * hi
|
||||
}
|
||||
}
|
||||
|
||||
/// A layer's Blend If settings. `ranges[0]` is the "Gray" entry (composite of the colour
|
||||
/// channels), then one entry per colour channel of the document's mode (R, G, B / C, M, Y, K /
|
||||
/// L, a, b / Gray), each as `[this layer, underlying layer]`. Empty = every pixel blends.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct BlendIf {
|
||||
pub ranges: Vec<[BlendRange; 2]>,
|
||||
}
|
||||
|
||||
impl BlendIf {
|
||||
/// Whether no range hides anything (the Photoshop default).
|
||||
pub fn is_default(&self) -> bool {
|
||||
self.ranges.iter().flatten().all(BlendRange::is_full)
|
||||
}
|
||||
|
||||
/// Entry `i` (0 = Gray), full when not stored.
|
||||
pub fn get(&self, i: usize) -> [BlendRange; 2] {
|
||||
self.ranges.get(i).copied().unwrap_or([BlendRange::FULL; 2])
|
||||
}
|
||||
|
||||
/// Set entry `i` (0 = Gray), growing the list with full ranges as needed. Trailing full
|
||||
/// entries are dropped so an all-default setting compares equal to `BlendIf::default()`.
|
||||
pub fn set(&mut self, i: usize, pair: [BlendRange; 2]) {
|
||||
if self.ranges.len() <= i {
|
||||
self.ranges.resize(i + 1, [BlendRange::FULL; 2]);
|
||||
}
|
||||
self.ranges[i] = pair;
|
||||
while self
|
||||
.ranges
|
||||
.last()
|
||||
.is_some_and(|p| p.iter().all(BlendRange::is_full))
|
||||
{
|
||||
self.ranges.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn full_range_shows_everything() {
|
||||
let r = BlendRange::FULL;
|
||||
assert!(r.is_full());
|
||||
for v in [0.0, 0.5, 127.0, 254.9, 255.0] {
|
||||
assert_eq!(r.weight(v), 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsplit_points_are_hard_thresholds() {
|
||||
let r = BlendRange {
|
||||
black: [50, 50],
|
||||
white: [200, 200],
|
||||
};
|
||||
assert_eq!(r.weight(49.0), 0.0);
|
||||
assert_eq!(r.weight(49.9), 0.0);
|
||||
assert_eq!(r.weight(50.0), 1.0);
|
||||
assert_eq!(r.weight(200.0), 1.0);
|
||||
assert_eq!(r.weight(200.1), 0.0);
|
||||
assert!(!r.is_full());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_points_ramp_linearly() {
|
||||
let r = BlendRange {
|
||||
black: [0, 100],
|
||||
white: [155, 255],
|
||||
};
|
||||
// A split black point fades from its low half (hidden) to its high half (shown).
|
||||
assert_eq!(r.weight(0.0), 0.0);
|
||||
assert!((r.weight(50.0) - 0.5).abs() < 1e-6);
|
||||
assert_eq!(r.weight(100.0), 1.0);
|
||||
assert_eq!(r.weight(155.0), 1.0);
|
||||
assert!((r.weight(205.0) - 0.5).abs() < 1e-6);
|
||||
assert_eq!(r.weight(255.0), 0.0);
|
||||
assert!(!r.is_full());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bytes_round_trip_in_psd_order() {
|
||||
let r = BlendRange {
|
||||
black: [10, 20],
|
||||
white: [230, 240],
|
||||
};
|
||||
assert_eq!(r.to_bytes(), [10, 20, 230, 240]);
|
||||
assert_eq!(BlendRange::from_bytes(r.to_bytes()), r);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_trims_trailing_full_entries() {
|
||||
let mut b = BlendIf::default();
|
||||
assert!(b.is_default());
|
||||
let r = BlendRange {
|
||||
black: [30, 30],
|
||||
white: [255, 255],
|
||||
};
|
||||
b.set(2, [BlendRange::FULL, r]);
|
||||
assert_eq!(b.ranges.len(), 3);
|
||||
assert_eq!(b.get(2), [BlendRange::FULL, r]);
|
||||
assert_eq!(b.get(7), [BlendRange::FULL; 2]);
|
||||
assert!(!b.is_default());
|
||||
b.set(2, [BlendRange::FULL; 2]);
|
||||
assert_eq!(b, BlendIf::default());
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
|
||||
pub mod adjust;
|
||||
pub mod analysis;
|
||||
pub mod blend_if;
|
||||
pub mod comps;
|
||||
pub mod effects;
|
||||
pub mod mode;
|
||||
|
|
@ -25,6 +26,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
|||
|
||||
pub use adjust::Adjustment;
|
||||
pub use analysis::{CountGroup, Measurement, MeasurementScale, Note, Ruler};
|
||||
pub use blend_if::{BlendIf, BlendRange};
|
||||
pub use comps::{Artboard, ArtboardBackground, CompAppearance, CompLayerState, LayerComp};
|
||||
pub use effects::{
|
||||
Bevel, BevelContour, BevelStyle, BevelTechnique, BevelTexture, Contour, Effect, FxCommon, FxPaint, GlobalLight, Glow, GlowSource, GlowTechnique, Gradient,
|
||||
|
|
@ -422,6 +424,10 @@ pub struct Layer {
|
|||
/// document's mode (R, G, B / C, M, Y, K / L, a, b / Gray) is left out of blending, so the
|
||||
/// backdrop's value is kept there. 0 = every channel blends (the default). PSD `brst`.
|
||||
pub excluded_channels: u32,
|
||||
/// Blending Options › Blend If: value ranges of this layer and of the layers beneath it
|
||||
/// outside which the layer's pixels are hidden. Default = everything blends. PSD layer-record
|
||||
/// blending ranges.
|
||||
pub blend_if: BlendIf,
|
||||
/// Layer › Video Layers frame stack (None for a normal layer).
|
||||
pub video: Option<VideoData>,
|
||||
}
|
||||
|
|
@ -447,6 +453,7 @@ impl Layer {
|
|||
fill_cache: None,
|
||||
link_group: None,
|
||||
excluded_channels: 0,
|
||||
blend_if: BlendIf::default(),
|
||||
video: None,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,6 +105,18 @@ fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId]) -> Value {
|
|||
if l.excluded_channels != 0 {
|
||||
v["channels"] = json!((0..4).map(|i| l.excluded_channels & (1 << i) == 0).collect::<Vec<_>>());
|
||||
}
|
||||
// Blending Options › Blend If (only when set): range index (0 = Gray, then the mode's
|
||||
// channels) with This Layer / Underlying Layer as [blackLo, blackHi, whiteLo, whiteHi].
|
||||
if !l.blend_if.is_default() {
|
||||
v["blendIf"] = l
|
||||
.blend_if
|
||||
.ranges
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, r)| !r.iter().all(photocraft_doc::BlendRange::is_full))
|
||||
.map(|(i, [this, under])| json!({"channel": i, "thisLayer": this.to_bytes(), "underlying": under.to_bytes()}))
|
||||
.collect();
|
||||
}
|
||||
// Layer styles, so agents can verify what they applied (full settings via the style commands).
|
||||
if !l.effects.items.is_empty() {
|
||||
v["effects"] = json!({
|
||||
|
|
|
|||
|
|
@ -4,9 +4,10 @@
|
|||
//! just the active layer (Quick Export as PNG, Export As).
|
||||
|
||||
use photocraft_algo::selection::Region;
|
||||
use photocraft_color::BlendMode;
|
||||
use photocraft_color::{BlendMode, ColorMode};
|
||||
use photocraft_doc::{
|
||||
Document, Effect, Layer, LayerContent, LayerId, LayerMask, SmartSource, StackMode,
|
||||
BlendIf, BlendRange, Document, Effect, Layer, LayerContent, LayerId, LayerMask, SmartSource,
|
||||
StackMode,
|
||||
};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::{Surface, from_rgba_into, to_rgba};
|
||||
|
|
@ -504,6 +505,11 @@ fn blending_options(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
.get("fillOpacity")
|
||||
.and_then(Value::as_f64)
|
||||
.map(|v| (v as f32 / 100.0).clamp(0.0, 1.0));
|
||||
let mode = s.active().ok_or(EngineError::NoDocument)?.doc.mode;
|
||||
let blend_if = match p.get("blendIf") {
|
||||
None => None,
|
||||
Some(v) => Some(blend_if_entries(v, mode)?),
|
||||
};
|
||||
s.edit("Blending Options", |doc, _| {
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
if let Some(b) = blend {
|
||||
|
|
@ -518,11 +524,105 @@ fn blending_options(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if let Some(f) = fill {
|
||||
l.fill_opacity = f;
|
||||
}
|
||||
match &blend_if {
|
||||
// `null`: Blend If back to the defaults.
|
||||
Some(None) => l.blend_if = BlendIf::default(),
|
||||
Some(Some(entries)) => {
|
||||
for (i, this, under) in entries {
|
||||
let [cur_this, cur_under] = l.blend_if.get(*i);
|
||||
l.blend_if
|
||||
.set(*i, [this.unwrap_or(cur_this), under.unwrap_or(cur_under)]);
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// One `blendIf` entry: (range index, This Layer, Underlying Layer); `None` = keep.
|
||||
type BlendIfEntry = (usize, Option<BlendRange>, Option<BlendRange>);
|
||||
|
||||
/// Parse `blendIf`: `null` (reset) or one or more `{"channel", "thisLayer"?, "underlying"?}`.
|
||||
fn blend_if_entries(v: &Value, mode: ColorMode) -> Result<Option<Vec<BlendIfEntry>>> {
|
||||
const CMD: &str = "layer.layerStyle.blendingOptions";
|
||||
let items: Vec<&Value> = match v {
|
||||
Value::Null => return Ok(None),
|
||||
Value::Array(a) => a.iter().collect(),
|
||||
Value::Object(_) => vec![v],
|
||||
_ => {
|
||||
return Err(bad(
|
||||
CMD,
|
||||
"blendIf: expected an object, a list of objects or null",
|
||||
));
|
||||
}
|
||||
};
|
||||
let mut out = Vec::with_capacity(items.len());
|
||||
for it in items {
|
||||
let ch = it.get("channel").unwrap_or(&Value::Null);
|
||||
let i = blend_if_channel(ch, mode).ok_or_else(|| {
|
||||
bad(
|
||||
CMD,
|
||||
format!("blendIf: unknown channel {ch} for a {mode:?} document"),
|
||||
)
|
||||
})?;
|
||||
let range = |key: &str| -> Result<Option<BlendRange>> {
|
||||
match it.get(key) {
|
||||
None => Ok(None),
|
||||
Some(r) => blend_range(r).map(Some).ok_or_else(|| {
|
||||
bad(CMD, format!("blendIf.{key}: expected [black, white] or [blackLow, blackHigh, whiteLow, whiteHigh], 0..255, in increasing order (got {r})"))
|
||||
}),
|
||||
}
|
||||
};
|
||||
out.push((i, range("thisLayer")?, range("underlying")?));
|
||||
}
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
/// Blend If channel → range index: 0 = Gray, then the mode's colour channels in order. A
|
||||
/// grayscale document's Gray is its one channel (the PSD spec marks the composite entry
|
||||
/// irrelevant there).
|
||||
fn blend_if_channel(v: &Value, mode: ColorMode) -> Option<usize> {
|
||||
let n = mode.color_channels();
|
||||
if let Some(i) = v.as_u64() {
|
||||
return usize::try_from(i).ok().filter(|i| *i <= n);
|
||||
}
|
||||
let name = v.as_str().unwrap_or("gray").to_ascii_lowercase();
|
||||
let names: &[&str] = match mode {
|
||||
ColorMode::Rgb | ColorMode::Indexed => &["red", "green", "blue"],
|
||||
ColorMode::Cmyk => &["cyan", "magenta", "yellow", "black"],
|
||||
ColorMode::Lab => &["lightness", "a", "b"],
|
||||
_ => &[],
|
||||
};
|
||||
if name == "gray" || name == "grey" {
|
||||
return Some(usize::from(n == 1));
|
||||
}
|
||||
names.iter().position(|c| *c == name).map(|i| i + 1)
|
||||
}
|
||||
|
||||
/// `[black, white]` (unsplit) or `[blackLow, blackHigh, whiteLow, whiteHigh]`, each 0..=255 and
|
||||
/// in increasing order (the dialog's sliders can't cross).
|
||||
fn blend_range(v: &Value) -> Option<BlendRange> {
|
||||
let a = v.as_array()?;
|
||||
let n: Vec<u8> = a
|
||||
.iter()
|
||||
.map(|x| {
|
||||
x.as_f64()
|
||||
.filter(|f| (0.0..=255.0).contains(f))
|
||||
.map(|f| f.round() as u8)
|
||||
})
|
||||
.collect::<Option<_>>()?;
|
||||
let q = match n.as_slice() {
|
||||
[b, w] => [*b, *b, *w, *w],
|
||||
[a, b, c, d] => [*a, *b, *c, *d],
|
||||
_ => return None,
|
||||
};
|
||||
q.windows(2)
|
||||
.all(|w| w[0] <= w[1])
|
||||
.then(|| BlendRange::from_bytes(q))
|
||||
}
|
||||
|
||||
fn global_light(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cur = s.active().ok_or(EngineError::NoDocument)?.doc.global_light;
|
||||
let angle = num(p, "angle", cur.angle);
|
||||
|
|
@ -893,7 +993,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"layer.layerStyle.blendingOptions",
|
||||
"Blending Options…",
|
||||
["Layer", "Layer Style"],
|
||||
r##"{"layer":id?,"blend":"normal|multiply|…"?,"opacity":0..100?,"fillOpacity":0..100?}"##,
|
||||
r##"{"layer":id?,"blend":"normal|multiply|…"?,"opacity":0..100?,"fillOpacity":0..100?,"blendIf":{"channel":"gray|red|green|blue|cyan|…"|index="gray","thisLayer":[black,white]|[blackLo,blackHi,whiteLo,whiteHi]?,"underlying":[…]?}|[{…},…]|null?} (Blend If values 0..255; split points fade; null resets)"##,
|
||||
has_layer,
|
||||
blending_options
|
||||
),
|
||||
|
|
|
|||
|
|
@ -195,6 +195,135 @@ fn global_light_blending_options_scale_effects() {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blending_options_blend_if() {
|
||||
for depth in DEPTHS {
|
||||
let mut s = session(depth, "rgb");
|
||||
// Black on the left, white on the right.
|
||||
paint(&mut s, |x, _| {
|
||||
if x < 20 {
|
||||
[0.0, 0.0, 0.0, 1.0]
|
||||
} else {
|
||||
[1.0; 4]
|
||||
}
|
||||
});
|
||||
let at = |s: &Session, x: i32| photocraft_compose::flatten(doc(s)).get(x, 5);
|
||||
// Gray › This Layer: black point 50 hides the black half.
|
||||
s.execute(
|
||||
"layer.layerStyle.blendingOptions",
|
||||
json!({"blendIf": {"channel": "gray", "thisLayer": [50, 255]}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(at(&s, 5)[3], 0.0, "depth {depth}");
|
||||
assert_eq!(at(&s, 30), [1.0; 4], "depth {depth}");
|
||||
assert_eq!(
|
||||
active(&s).blend_if.get(0),
|
||||
[
|
||||
BlendRange {
|
||||
black: [50, 50],
|
||||
white: [255, 255]
|
||||
},
|
||||
BlendRange::FULL
|
||||
]
|
||||
);
|
||||
// Setting the other slider pair keeps this one; split points are 4 values.
|
||||
s.execute(
|
||||
"layer.layerStyle.blendingOptions",
|
||||
json!({"blendIf": [{"channel": "gray", "underlying": [0, 10, 245, 255]}, {"channel": "blue", "thisLayer": [0, 200]}]}),
|
||||
)
|
||||
.unwrap();
|
||||
let bi = &active(&s).blend_if;
|
||||
assert_eq!(bi.get(0)[0].black, [50, 50]);
|
||||
assert_eq!(
|
||||
bi.get(0)[1],
|
||||
BlendRange {
|
||||
black: [0, 10],
|
||||
white: [245, 255]
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
bi.get(3)[0],
|
||||
BlendRange {
|
||||
black: [0, 0],
|
||||
white: [200, 200]
|
||||
}
|
||||
);
|
||||
let ins = crate::inspect::layer(active(&s));
|
||||
assert_eq!(
|
||||
ins["blendIf"][0],
|
||||
json!({"channel": 0, "thisLayer": [50, 50, 255, 255], "underlying": [0, 10, 245, 255]})
|
||||
);
|
||||
assert_eq!(ins["blendIf"][1]["channel"], 3);
|
||||
// Blending options without `blendIf` (the dialog's blend/opacity/fill) leave it alone.
|
||||
s.execute("layer.layerStyle.blendingOptions", json!({"opacity": 90}))
|
||||
.unwrap();
|
||||
assert!(!active(&s).blend_if.is_default());
|
||||
// One undo step per call.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(active(&s).blend_if.get(0)[1], BlendRange::FULL);
|
||||
assert_eq!(active(&s).blend_if.get(0)[0].black, [50, 50]);
|
||||
// null resets everything.
|
||||
s.execute("layer.layerStyle.blendingOptions", json!({"blendIf": null}))
|
||||
.unwrap();
|
||||
assert!(active(&s).blend_if.is_default());
|
||||
assert!(crate::inspect::layer(active(&s)).get("blendIf").is_none());
|
||||
assert_eq!(at(&s, 5), [0.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_if_rejects_bad_params() {
|
||||
let mut s = session(8, "rgb");
|
||||
paint(&mut s, disc);
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
for bad in [
|
||||
json!({"channel": "cyan", "thisLayer": [10, 255]}),
|
||||
json!({"channel": 4, "thisLayer": [10, 255]}),
|
||||
json!({"channel": "gray", "thisLayer": [200, 100]}),
|
||||
json!({"channel": "gray", "thisLayer": [0, 300]}),
|
||||
json!({"channel": "gray", "thisLayer": [0, 10, 5, 255]}),
|
||||
json!({"channel": "gray", "underlying": [1, 2, 3]}),
|
||||
json!({"channel": "gray", "underlying": "dark"}),
|
||||
json!("gray"),
|
||||
] {
|
||||
assert!(
|
||||
s.execute("layer.layerStyle.blendingOptions", json!({"blendIf": bad}))
|
||||
.is_err(),
|
||||
"{bad}"
|
||||
);
|
||||
}
|
||||
assert!(active(&s).blend_if.is_default());
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_if_channel_names_follow_the_mode() {
|
||||
let mut s = session(8, "cmyk");
|
||||
s.execute(
|
||||
"layer.layerStyle.blendingOptions",
|
||||
json!({"blendIf": {"channel": "black", "underlying": [0, 128]}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(active(&s).blend_if.get(4)[1].white, [128, 128]);
|
||||
assert!(
|
||||
s.execute(
|
||||
"layer.layerStyle.blendingOptions",
|
||||
json!({"blendIf": {"channel": "red", "underlying": [0, 128]}})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
// A grayscale document's Gray is its one channel.
|
||||
let mut s = session(16, "grayscale");
|
||||
s.execute(
|
||||
"layer.layerStyle.blendingOptions",
|
||||
json!({"blendIf": {"channel": "gray", "thisLayer": [30, 255]}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(active(&s).blend_if.get(1)[0].black, [30, 30]);
|
||||
assert_eq!(active(&s).blend_if.get(0), [BlendRange::FULL; 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_layer_splits_effects_and_keeps_the_look() {
|
||||
let mut s = session(8, "rgb");
|
||||
|
|
|
|||
|
|
@ -211,6 +211,7 @@ fn layer_m(l: &Layer, sink: &mut dyn Sink) -> LayerM {
|
|||
}),
|
||||
link_group: l.link_group,
|
||||
excluded_channels: l.excluded_channels,
|
||||
blend_if: l.blend_if.clone(),
|
||||
video: l.video.as_ref().map(|v| video_m(v, sink)),
|
||||
}
|
||||
}
|
||||
|
|
@ -567,6 +568,7 @@ impl Loader<'_> {
|
|||
fill_cache,
|
||||
link_group: m.link_group,
|
||||
excluded_channels: m.excluded_channels,
|
||||
blend_if: m.blend_if.clone(),
|
||||
video: m.video.as_ref().map(|v| self.video(v)).transpose()?,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -221,6 +221,9 @@ pub struct LayerM {
|
|||
/// Advanced Blending channels left out (bit per colour channel; 0 = all blend).
|
||||
#[serde(default, skip_serializing_if = "is_zero_u32")]
|
||||
pub excluded_channels: u32,
|
||||
/// Blend If ranges (empty = everything blends).
|
||||
#[serde(default, skip_serializing_if = "photocraft_doc::BlendIf::is_default")]
|
||||
pub blend_if: photocraft_doc::BlendIf,
|
||||
#[serde(default)]
|
||||
pub video: Option<VideoDataM>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -318,6 +318,39 @@ fn channel_restrictions_and_bevel_elements_roundtrip() {
|
|||
assert_eq!(back.layers[0].excluded_channels, 0b101);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_if_roundtrips() {
|
||||
use photocraft_doc::{BlendIf, BlendRange};
|
||||
let mut doc = rich_doc(ColorMode::Rgb, SampleType::U16);
|
||||
let mut bi = BlendIf::default();
|
||||
bi.set(
|
||||
0,
|
||||
[
|
||||
BlendRange {
|
||||
black: [20, 60],
|
||||
white: [255, 255],
|
||||
},
|
||||
BlendRange::FULL,
|
||||
],
|
||||
);
|
||||
bi.set(
|
||||
3,
|
||||
[
|
||||
BlendRange::FULL,
|
||||
BlendRange {
|
||||
black: [0, 0],
|
||||
white: [180, 220],
|
||||
},
|
||||
],
|
||||
);
|
||||
doc.layers[0].blend_if = bi.clone();
|
||||
let back = load_from_bytes(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()).unwrap();
|
||||
assert_eq!(back, doc);
|
||||
assert_eq!(back.layers[0].blend_if, bi);
|
||||
// Layers without Blend If keep the default (the field is omitted from the manifest).
|
||||
assert!(back.layers.iter().skip(1).all(|l| l.blend_if.is_default()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_layer_frames_survive_roundtrip() {
|
||||
use photocraft_doc::{Timeline, VideoData, VideoSource};
|
||||
|
|
|
|||
|
|
@ -446,8 +446,20 @@ impl<'a> Planner<'a> {
|
|||
Some(MaskUse { layer: layer.id, surface: SurfaceRef::Doc(&m.surface), density: m.density, default: m.surface.default_pixel().first().copied().unwrap_or(1.0) })
|
||||
}
|
||||
|
||||
/// Blending Options › Blend If has no GPU pass yet: such documents use the CPU compositor.
|
||||
fn check_blend_if(&self, layer: &Layer) -> Result<(), Unsupported> {
|
||||
if photocraft_compose::blend_if_active(layer, self.cx.mode) {
|
||||
return Err(Unsupported(format!(
|
||||
"Blend If on `{}` (composited on the CPU)",
|
||||
layer.name
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// composite_layer, honouring the layer's channel restrictions.
|
||||
fn layer(&mut self, layer: &'a Layer, clipped: &'a [Layer], backdrop: Slot) -> Result<Slot, Unsupported> {
|
||||
self.check_blend_if(layer)?;
|
||||
match photocraft_compose::channel_weights(layer, self.cx.mode) {
|
||||
Some(w) => {
|
||||
let before = self.retain(backdrop);
|
||||
|
|
@ -728,6 +740,7 @@ impl<'a> Planner<'a> {
|
|||
/// composite_atop: `layer` onto `base`, restricted to the base's alpha, honouring the
|
||||
/// layer's channel restrictions.
|
||||
fn atop(&mut self, layer: &'a Layer, base: Slot) -> Result<Slot, Unsupported> {
|
||||
self.check_blend_if(layer)?;
|
||||
match photocraft_compose::channel_weights(layer, self.cx.mode) {
|
||||
Some(w) => {
|
||||
let before = self.retain(base);
|
||||
|
|
@ -1327,4 +1340,33 @@ mod tests {
|
|||
assert_eq!(last.dst, p.root);
|
||||
assert!(p.slots <= 6, "{} slots", p.slots);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_if_falls_back_to_the_cpu() {
|
||||
let mut d = Document::with_background(
|
||||
"t",
|
||||
Size::new(8, 8),
|
||||
ColorMode::Rgb,
|
||||
SampleType::U8,
|
||||
Color::WHITE,
|
||||
);
|
||||
let mut l = Layer::raster("bi", d.pixel_format());
|
||||
l.blend_if.set(
|
||||
0,
|
||||
[
|
||||
photocraft_doc::BlendRange {
|
||||
black: [40, 40],
|
||||
white: [255, 255],
|
||||
},
|
||||
photocraft_doc::BlendRange::FULL,
|
||||
],
|
||||
);
|
||||
d.layers.push(l.clone());
|
||||
assert!(plan(&d).unwrap_err().0.contains("Blend If"));
|
||||
// Clipped layers too.
|
||||
l.clipped = true;
|
||||
d.layers[1].blend_if = Default::default();
|
||||
d.layers.push(l);
|
||||
assert!(plan(&d).is_err());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@
|
|||
//! smart objects, locks and label colors.
|
||||
|
||||
use photocraft_color::{Color, ColorMode};
|
||||
use photocraft_doc::{Fill, GradientStyle, LabelColor, Locks};
|
||||
use photocraft_doc::{BlendIf, BlendRange, Fill, GradientStyle, LabelColor, Locks};
|
||||
use photocraft_geom::Affine;
|
||||
use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor};
|
||||
use photocraft_psd::layer::BlendingRanges;
|
||||
|
||||
/// `brst` (channel blending restrictions): a list of big-endian u32 channel indices left out of
|
||||
/// blending → a bit mask (bit `i` = channel `i`; indices above 31 are ignored).
|
||||
|
|
@ -17,6 +18,47 @@ pub fn brst_data(mask: u32) -> Option<Vec<u8>> {
|
|||
(mask != 0).then(|| (0..32u32).filter(|i| mask & 1 << i != 0).flat_map(u32::to_be_bytes).collect())
|
||||
}
|
||||
|
||||
/// Layer-record blending ranges (Adobe PSD spec, "Layer blending ranges data": the composite
|
||||
/// gray source and destination ranges, then a source and destination range per channel, each
|
||||
/// as black low, black high, white low, white high) → Blend If. Full ranges map to the
|
||||
/// default, so files without Blend If keep an empty setting.
|
||||
pub fn blend_if_from_ranges(r: &BlendingRanges) -> BlendIf {
|
||||
let mut ranges: Vec<[BlendRange; 2]> = r
|
||||
.ranges()
|
||||
.iter()
|
||||
.map(|e| {
|
||||
[
|
||||
BlendRange::from_bytes(e.source),
|
||||
BlendRange::from_bytes(e.dest),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
// Trailing full entries carry nothing (export pads them back), so they are dropped, as
|
||||
// `BlendIf::set` does.
|
||||
let keep = ranges
|
||||
.iter()
|
||||
.rposition(|p| !p.iter().all(BlendRange::is_full))
|
||||
.map_or(0, |i| i + 1);
|
||||
ranges.truncate(keep);
|
||||
BlendIf { ranges }
|
||||
}
|
||||
|
||||
/// Inverse of [`blend_if_from_ranges`] for a document with `channels` colour channels: one
|
||||
/// entry for gray plus one per channel (more when the setting stores more), unset entries full.
|
||||
pub fn ranges_from_blend_if(b: &BlendIf, channels: usize) -> BlendingRanges {
|
||||
if b.is_default() {
|
||||
return BlendingRanges::full(channels);
|
||||
}
|
||||
let n = b.ranges.len().max(channels + 1);
|
||||
let data = (0..n).flat_map(|i| {
|
||||
let [src, dst] = b.get(i);
|
||||
src.to_bytes().into_iter().chain(dst.to_bytes())
|
||||
});
|
||||
BlendingRanges {
|
||||
data: data.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `lspf` bits (Adobe spec: bit 0 transparency, 1 composite, 2 position).
|
||||
/// Artboard (bit 3... here `0x10` as observed by ag-psd) and "all" (bit 31)
|
||||
/// are undocumented.
|
||||
|
|
@ -419,6 +461,69 @@ pub fn effects_enabled(lfx2: &[u8]) -> bool {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn blending_ranges_map_to_blend_if() {
|
||||
use photocraft_doc::{BlendIf, BlendRange};
|
||||
use photocraft_psd::layer::BlendingRanges;
|
||||
// Full ranges (what every layer without Blend If carries) → the default setting.
|
||||
assert_eq!(
|
||||
super::blend_if_from_ranges(&BlendingRanges::full(3)),
|
||||
BlendIf::default()
|
||||
);
|
||||
assert_eq!(
|
||||
super::blend_if_from_ranges(&BlendingRanges::default()),
|
||||
BlendIf::default()
|
||||
);
|
||||
// Gray: This Layer black split 10/40; Blue (entry 3): Underlying white at 200.
|
||||
let mut data = BlendingRanges::full(3).data;
|
||||
data[..4].copy_from_slice(&[10, 40, 255, 255]);
|
||||
data[3 * 8 + 4..3 * 8 + 8].copy_from_slice(&[0, 0, 200, 200]);
|
||||
let b = super::blend_if_from_ranges(&BlendingRanges { data: data.clone() });
|
||||
assert_eq!(
|
||||
b.get(0),
|
||||
[
|
||||
BlendRange {
|
||||
black: [10, 40],
|
||||
white: [255, 255]
|
||||
},
|
||||
BlendRange::FULL
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
b.get(3),
|
||||
[
|
||||
BlendRange::FULL,
|
||||
BlendRange {
|
||||
black: [0, 0],
|
||||
white: [200, 200]
|
||||
}
|
||||
]
|
||||
);
|
||||
assert_eq!(b.ranges.len(), 4);
|
||||
assert_eq!(super::ranges_from_blend_if(&b, 3).data, data);
|
||||
// The default writes full ranges for gray + each channel.
|
||||
assert_eq!(
|
||||
super::ranges_from_blend_if(&BlendIf::default(), 4),
|
||||
BlendingRanges::full(4)
|
||||
);
|
||||
// A trimmed setting is padded to the document's channel count.
|
||||
let mut short = BlendIf::default();
|
||||
short.set(
|
||||
0,
|
||||
[
|
||||
BlendRange {
|
||||
black: [5, 5],
|
||||
white: [255, 255],
|
||||
},
|
||||
BlendRange::FULL,
|
||||
],
|
||||
);
|
||||
let r = super::ranges_from_blend_if(&short, 3);
|
||||
assert_eq!(r.data.len(), 4 * 8);
|
||||
assert_eq!(&r.data[..8], &[5, 5, 255, 255, 0, 0, 255, 255]);
|
||||
assert_eq!(&r.data[8..], &BlendingRanges::full(2).data[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brst_round_trip() {
|
||||
assert_eq!(super::parse_brst(&[0, 0, 0, 2]), 0b100);
|
||||
|
|
|
|||
|
|
@ -199,7 +199,7 @@ impl Ex {
|
|||
flags,
|
||||
filler: 0,
|
||||
mask,
|
||||
blending_ranges: BlendingRanges::full(self.cc),
|
||||
blending_ranges: crate::blocks::ranges_from_blend_if(&l.blend_if, self.cc),
|
||||
name: legacy_name(&l.name),
|
||||
blocks,
|
||||
extra_trailing: Vec::new(),
|
||||
|
|
|
|||
|
|
@ -191,6 +191,8 @@ impl Ctx<'_> {
|
|||
if let Some(b) = rec.block(b"brst") {
|
||||
l.excluded_channels = crate::blocks::parse_brst(&b.data);
|
||||
}
|
||||
// Blend If lives in the layer record's blending ranges.
|
||||
l.blend_if = crate::blocks::blend_if_from_ranges(&rec.blending_ranges);
|
||||
l.psd_id = rec.layer_id();
|
||||
let name = l.name.clone();
|
||||
l.mask = self.record_mask(rec, &name);
|
||||
|
|
|
|||
|
|
@ -423,3 +423,73 @@ fn channel_restrictions_round_trip_as_brst() {
|
|||
let f = document_to_psd(&cleared);
|
||||
assert!(f.layers().iter().find(|r| r.name() == "a").unwrap().block(b"brst").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_if_round_trips_as_blending_ranges() {
|
||||
use photocraft_doc::{BlendIf, BlendRange, Document, Layer, Size};
|
||||
let mut d = Document::new("b", Size::new(8, 8), ColorMode::Rgb, SampleType::U8);
|
||||
let mut a = Layer::raster("a", d.pixel_format());
|
||||
a.surface_mut().unwrap().fill_rect(
|
||||
photocraft_geom::Rect::new(0, 0, 4, 4),
|
||||
&[1.0, 1.0, 1.0, 1.0],
|
||||
);
|
||||
let mut bi = BlendIf::default();
|
||||
// Gray › This Layer: hide the whites, fading from 200 to 230.
|
||||
bi.set(
|
||||
0,
|
||||
[
|
||||
BlendRange {
|
||||
black: [0, 0],
|
||||
white: [200, 230],
|
||||
},
|
||||
BlendRange::FULL,
|
||||
],
|
||||
);
|
||||
// Green › Underlying Layer: show only over values from 64 up.
|
||||
bi.set(
|
||||
2,
|
||||
[
|
||||
BlendRange::FULL,
|
||||
BlendRange {
|
||||
black: [64, 64],
|
||||
white: [255, 255],
|
||||
},
|
||||
],
|
||||
);
|
||||
a.blend_if = bi.clone();
|
||||
let mut g = Layer::group("g", vec![Layer::raster("c", d.pixel_format())]);
|
||||
g.blend_if.set(
|
||||
1,
|
||||
[
|
||||
BlendRange {
|
||||
black: [1, 2],
|
||||
white: [3, 4],
|
||||
},
|
||||
BlendRange::FULL,
|
||||
],
|
||||
);
|
||||
let b = Layer::raster("b", d.pixel_format());
|
||||
d.layers = vec![a, b, g];
|
||||
let f = document_to_psd(&d);
|
||||
let rec = f.layers().iter().find(|r| r.name() == "a").unwrap();
|
||||
let full = [0u8, 0, 255, 255, 0, 0, 255, 255];
|
||||
let mut want = Vec::new();
|
||||
want.extend_from_slice(&[0, 0, 200, 230, 0, 0, 255, 255]);
|
||||
want.extend_from_slice(&full);
|
||||
want.extend_from_slice(&[0, 0, 255, 255, 64, 64, 255, 255]);
|
||||
want.extend_from_slice(&full);
|
||||
assert_eq!(rec.blending_ranges.data, want);
|
||||
// Layers without Blend If keep full ranges for gray + R, G, B.
|
||||
let rb = f.layers().iter().find(|r| r.name() == "b").unwrap();
|
||||
assert_eq!(rb.blending_ranges, photocraft_psd::BlendingRanges::full(3));
|
||||
let back = roundtrip(&d);
|
||||
assert_eq!(back.layers[0].blend_if, bi);
|
||||
assert!(back.layers[1].blend_if.is_default());
|
||||
assert_eq!(
|
||||
back.layers[2].blend_if.get(1)[0],
|
||||
BlendRange {
|
||||
black: [1, 2],
|
||||
white: [3, 4]
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue