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:
storybored379 2026-10-04 07:13:27 -07:00 committed by GitHub
parent 7ec0b4aa62
commit 11e5af23ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 941 additions and 20 deletions

View File

@ -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),
}
}

View File

@ -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

175
crates/doc/src/blend_if.rs Normal file
View File

@ -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());
}
}

View File

@ -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,
}
}

View File

@ -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!({

View File

@ -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
),

View File

@ -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");

View File

@ -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()?,
})
}

View File

@ -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>,
}

View File

@ -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};

View File

@ -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());
}
}

View File

@ -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);

View File

@ -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(),

View File

@ -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);

View File

@ -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]
}
);
}