diff --git a/crates/compose/src/advanced.rs b/crates/compose/src/advanced.rs new file mode 100644 index 0000000000..4a9cb3e89b --- /dev/null +++ b/crates/compose/src/advanced.rs @@ -0,0 +1,278 @@ +//! Layer Style › Blending Options › Advanced Blending in the CPU compositor (the oracle): +//! +//! - **Knockout.** Inside the layer's shape the backdrop is replaced by the knockout target +//! before the layer composites: for Shallow the bottom of the enclosing group (the backdrop the +//! group's stack started from: transparent in an isolated group, the layers beneath in a +//! pass-through one) or of the clipping group (the base layer's content); for Deep, and for +//! Shallow at the top level, the Background layer (transparency without one). Isolated groups +//! and grouped clipping layers stop a deep knockout at their bottom, as Photoshop renders it +//! (see [`Scope`]); pass-through groups let it through. Fill opacity sets how much of the layer +//! shows over the knocked-out area, layer opacity how strong the whole knockout is. +//! - **Blend Clipped Layers as Group off.** The base composites alone; each clipped layer then +//! composites onto the result in its own mode, kept inside the base's shape (its alpha with +//! masks, times its opacity). +//! - **Transparency Shapes Layer off.** The layer's effects and knockout cover the whole layer +//! (its masks still apply) instead of following its transparency. +//! - **Layer / Vector Mask Hides Effects.** The effects are built from the layer without that +//! mask, and the mask is applied to the finished layer and effects. +//! +//! Blend Interior Effects as Group lives in [`crate::effects`], where interior effects are +//! painted. Every path here is skipped for layers at Photoshop's defaults, so documents that +//! don't use these options render exactly as before at no extra cost. +//! +//! Measured on the psd-tools knockout files (deep/shallow, nested, pass-through, no Background, +//! groups with knockout). Not yet measured against Photoshop: the clip of individually blended +//! clipped layers (base alpha × opacity, ignoring its fill), Transparency Shapes Layer off, and the +//! masks hiding effects. + +use std::borrow::Cow; + +use photocraft_doc::{Knockout, Layer, LayerContent}; +use photocraft_geom::Rect; + +use crate::{Buffer, Ctx, composite_atop, composite_layer, effects, mask_vals, render_content}; + +/// Where a layer stack sits, for knockouts: at the document's top level, and/or inside an +/// isolated (non-pass-through) group. Photoshop stops a deep knockout at the bottom of the +/// innermost isolated group around it (psd-tools knockout-deep-nested renders exactly like +/// knockout-shallow-nested), while pass-through groups let it reach the Background +/// (knockout-deep-nested-pt renders like knockout-deep-normal). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Scope { + /// The document's top-level stack: a shallow knockout there reaches the Background. + pub root: bool, + /// Inside an isolated group: deep knockouts stop at its (transparent) bottom. + pub isolated: bool, +} + +impl Scope { + pub const ROOT: Scope = Scope { root: true, isolated: false }; + /// An isolated group's own stack (it starts from a transparent buffer). + pub const ISOLATED: Scope = Scope { root: false, isolated: true }; + + /// The stack of a pass-through group inside this one. + pub fn pass_through(self) -> Scope { + Scope { root: false, isolated: self.isolated } + } +} + +/// Premultiplied linear interpolation between two straight-alpha pixels. +#[inline] +fn mix(a: [f32; 4], b: [f32; 4], k: f32) -> [f32; 4] { + let alpha = a[3] + (b[3] - a[3]) * k; + if alpha <= 0.0 { + return [0.0; 4]; + } + let c = |c: usize| ((a[c] * a[3] + (b[c] * b[3] - a[c] * a[3]) * k) / alpha).clamp(0.0, 1.0); + [c(0), c(1), c(2), alpha.min(1.0)] +} + +/// Whether any Advanced Blending option changes how `layer` (with its clipping group `clipped`) +/// composites. The GPU compositor sends such documents to this CPU path. +pub fn advanced_active(layer: &Layer, clipped: &[Layer]) -> bool { + let a = &layer.advanced; + if a.is_default() { + return false; + } + let fx = effects::has_effects(layer); + a.knockout != Knockout::None || clips_individually(layer, clipped) || (a.blend_interior && fx) || (!a.transparency_shapes && fx) || hides_effects(layer) +} + +/// Whether the layer's effects (or knockout) cover the whole layer: Transparency Shapes Layer +/// off on a layer that has either. +pub fn shapeless(layer: &Layer) -> bool { + !layer.advanced.transparency_shapes && (layer.advanced.knockout != Knockout::None || effects::has_effects(layer)) +} + +/// The Background layer of a stack: the bottom layer, a locked, opaque-by-convention pixel layer +/// named "Background" (how PSD import and File › New create it). +pub fn is_background(l: &Layer) -> bool { + l.name == "Background" && l.locks.transparency && !l.clipped && matches!(l.content, LayerContent::Raster(_)) +} + +/// What a deep knockout reveals over `rect`: the document's initial backdrop with its Background +/// layer (transparency when it has none, or when it is hidden). +pub(crate) fn deep_target(cx: &Ctx, rect: Rect) -> Buffer { + let Some(doc) = cx.doc else { return Buffer::transparent(rect) }; + let mut b = crate::multichannel::backdrop(doc, rect); + if let Some(bg) = doc.layers.first().filter(|l| l.visible && is_background(l)) { + composite_layer(bg, &[], &mut b, cx, None, Scope::ROOT); + } + b +} + +/// The knockout target of a layer of a stack in `scope`, if it knocks out. `floor` is the +/// stack's starting backdrop (`None` at the top level, where a shallow knockout reaches the +/// Background too). +pub(crate) fn knockout_target<'b>(layer: &Layer, floor: Option<&'b Buffer>, rect: Rect, cx: &Ctx, scope: Scope) -> Option> { + let deep = || if scope.isolated { Buffer::transparent(rect) } else { deep_target(cx, rect) }; + match layer.advanced.knockout { + Knockout::None => None, + Knockout::Shallow => Some(floor.map_or_else(|| Cow::Owned(deep()), Cow::Borrowed)), + Knockout::Deep => Some(Cow::Owned(deep())), + } +} + +/// Whether `layers` holds a visible unclipped shallow knockout (its stack then keeps a copy of +/// its starting backdrop). +pub(crate) fn needs_floor(layers: &[Layer]) -> bool { + layers.iter().any(|l| l.visible && !l.clipped && l.advanced.knockout == Knockout::Shallow) +} + +/// The shape a layer knocks out over `rect` (row-major): its content's alpha with its masks; +/// with Transparency Shapes Layer off (and for adjustment layers) just the masks. +fn knockout_shape(layer: &Layer, rect: Rect, cx: &Ctx) -> Vec { + let n = rect.width() as usize * rect.height() as usize; + let masks = || mask_vals(layer, rect, cx).unwrap_or_else(|| vec![1.0; n]); + if !layer.advanced.transparency_shapes { + return masks(); + } + match render_content(layer, rect, cx) { + Some(b) => b.px.iter().map(|p| p[3]).collect(), + None => masks(), + } +} + +/// Composite `layer` with `draw` as a knockout onto `target`: inside its shape the backdrop +/// becomes the target, then the layer draws (at its fill opacity), and layer opacity mixes the +/// result with the untouched backdrop. Compositing at opacity `o` is linear in `o` +/// (premultiplied), so with `B` the backdrop, `B'` the knocked-out one and `R'` what `draw` +/// produced at the layer's opacity, the result is `R' + (1 − o)(B − B')`. +/// +/// `clip_alpha` keeps alpha from growing (clipped layers stay inside their base's alpha; a +/// knockout to transparency may still lower it). +pub(crate) fn knockout(layer: &Layer, backdrop: &mut Buffer, target: &Buffer, clip_alpha: bool, cx: &Ctx, draw: impl FnOnce(&mut Buffer)) { + // Coverage knocks out proportionally. (Photoshop knocks out a little more at anti-aliased + // shape edges: psd-tools knockout-isolated-groups differs on 0.45 % of its pixels, along + // ellipse edges; a binary shape matched worse.) + let shape = knockout_shape(layer, backdrop.rect, cx); + if target.rect != backdrop.rect || shape.len() != backdrop.px.len() { + // Never reached (targets are built over the backdrop's rect); compositing without the + // knockout beats a wrong pixel or a panic. + draw(backdrop); + return; + } + let before = backdrop.clone(); + for ((p, t), k) in backdrop.px.iter_mut().zip(&target.px).zip(&shape) { + if *k > 0.0 { + let a = p[3]; + *p = mix(*p, *t, k.min(1.0)); + if clip_alpha { + p[3] = p[3].min(a); + } + } + } + let knocked = (layer.opacity < 1.0).then(|| backdrop.clone()); + draw(backdrop); + let Some(knocked) = knocked else { return }; + let rest = 1.0 - layer.opacity.clamp(0.0, 1.0); + for (((p, b), k), s) in backdrop.px.iter_mut().zip(&before.px).zip(&knocked.px).zip(&shape) { + if *s <= 0.0 { + continue; + } + let mut pm = [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]; + for c in 0..3 { + pm[c] += rest * (b[c] * b[3] - k[c] * k[3]); + } + pm[3] += rest * (b[3] - k[3]); + let a = pm[3].clamp(0.0, 1.0); + *p = if a > 0.0 { [(pm[0] / a).clamp(0.0, 1.0), (pm[1] / a).clamp(0.0, 1.0), (pm[2] / a).clamp(0.0, 1.0), a] } else { [0.0; 4] }; + } +} + +/// Composite a clipping group's clipped layers atop `base` (the base's isolated content, Blend +/// Clipped Layers as Group on). The grouped clipping layers composite in isolation, like an +/// isolated group, so a clipped layer's knockout (shallow or deep) stops at the bottom of the +/// clipping group: the base's content. +pub(crate) fn composite_clipped(clipped: &[Layer], base: &mut Buffer, cx: &Ctx) { + let floor = clipped.iter().any(|c| c.visible && c.advanced.knockout != Knockout::None).then(|| base.clone()); + for c in clipped.iter().filter(|c| c.visible) { + match &floor { + Some(f) if c.advanced.knockout != Knockout::None => knockout(c, base, f, true, cx, |b| composite_atop(c, b, cx)), + _ => composite_atop(c, base, cx), + } + } +} + +/// Whether `layer`'s clipped layers blend one by one (Blend Clipped Layers as Group off). +/// Adjustment layers keep the grouped path (they have no pixels of their own to clip to). +pub(crate) fn clips_individually(layer: &Layer, clipped: &[Layer]) -> bool { + !layer.advanced.blend_clipped && !matches!(layer.content, LayerContent::Adjustment(_)) && clipped.iter().any(|c| c.visible) +} + +/// Blend Clipped Layers as Group off: the base alone (with its knockout `ko`), then each visible +/// clipped layer composited in its own mode onto the result, inside the base's shape (its alpha +/// with masks, times its opacity). A clipped layer's shallow knockout stops at the backdrop +/// beneath the base (the bottom of the clipping group). +pub(crate) fn composite_clipped_individually(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx, ko: Option<&Buffer>, scope: Scope) { + let rect = backdrop.rect; + let n = backdrop.px.len(); + let floor = clipped.iter().any(|c| c.visible && c.advanced.knockout == Knockout::Shallow).then(|| backdrop.clone()); + composite_layer(layer, &[], backdrop, cx, ko, scope); + let op = layer.opacity.clamp(0.0, 1.0); + let clip: Vec = match render_content(layer, rect, cx) { + Some(b) => b.px.iter().map(|p| p[3] * op).collect(), + None => mask_vals(layer, rect, cx).unwrap_or_else(|| vec![1.0; n]).iter().map(|m| m * op).collect(), + }; + if clip.len() != n { + return; + } + for c in clipped.iter().filter(|c| c.visible) { + let before = backdrop.clone(); + let t = knockout_target(c, floor.as_ref(), rect, cx, Scope { root: false, ..scope }); + composite_layer(c, &[], backdrop, cx, t.as_deref(), scope); + for ((p, b), k) in backdrop.px.iter_mut().zip(&before.px).zip(&clip) { + if *k < 1.0 { + *p = mix(*b, *p, k.max(0.0)); + } + } + } +} + +/// Whether a mask of `layer` hides its effects instead of shaping them (Layer / Vector Mask +/// Hides Effects on an enabled mask of a layer with effects). +pub(crate) fn hides_effects(layer: &Layer) -> bool { + let a = &layer.advanced; + effects::has_effects(layer) + && ((a.layer_mask_hides_effects && layer.mask.as_ref().is_some_and(|m| m.enabled)) + || (a.vector_mask_hides_effects && layer.vector_mask.as_ref().is_some_and(|m| m.enabled))) +} + +/// The layer whose content and effect maps are composited: `layer` itself, or (when a mask hides +/// its effects) a copy without that mask. Pixels are shared copy-on-write, so the copy costs a +/// tile table, and its effect maps cache under their own key. +pub(crate) fn effects_source(layer: &Layer) -> Cow<'_, Layer> { + if !hides_effects(layer) { + return Cow::Borrowed(layer); + } + let mut l = layer.clone(); + if l.advanced.layer_mask_hides_effects { + l.mask = None; + } + if l.advanced.vector_mask_hides_effects { + l.vector_mask = None; + } + Cow::Owned(l) +} + +/// The masks that hide `layer`'s effects over `rect` (row-major; `None` when none do). +pub(crate) fn hiding_mask(layer: &Layer, rect: Rect, cx: &Ctx) -> Option> { + if !hides_effects(layer) { + return None; + } + let a = &layer.advanced; + let mut only = Layer::new(layer.name.clone(), LayerContent::Raster(photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA32F))); + only.id = layer.id; + only.mask = layer.mask.clone().filter(|_| a.layer_mask_hides_effects); + only.vector_mask = layer.vector_mask.clone().filter(|_| a.vector_mask_hides_effects); + mask_vals(&only, rect, cx) +} + +/// Mix `out` back towards `before` where `mask` hides the layer: `out ← mix(before, out, mask)`. +pub(crate) fn apply_hiding_mask(out: &mut Buffer, before: &Buffer, mask: &[f32]) { + for ((p, b), k) in out.px.iter_mut().zip(&before.px).zip(mask) { + if *k < 1.0 { + *p = mix(*b, *p, k.max(0.0)); + } + } +} diff --git a/crates/compose/src/advanced_tests.rs b/crates/compose/src/advanced_tests.rs new file mode 100644 index 0000000000..493ecf7d86 --- /dev/null +++ b/crates/compose/src/advanced_tests.rs @@ -0,0 +1,351 @@ +//! Advanced Blending (knockout, blend clipped / interior as group, transparency shapes, masks +//! hide effects) on small synthetic documents with hand-computed pixels, at 8, 16 and 32 bits. + +use super::*; +use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Effect, FxCommon, FxPaint, Knockout, LayerMask, StrokeFx, StrokePosition}; +use photocraft_geom::Size; + +const E: f32 = 2.0 / 255.0; +const DEPTHS: [SampleType; 3] = [SampleType::U8, SampleType::U16, SampleType::F32]; + +const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0]; +const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0]; +const GREEN: [f32; 4] = [0.0, 1.0, 0.0, 1.0]; +const BLUE: [f32; 4] = [0.0, 0.0, 1.0, 1.0]; +const GRAY: [f32; 4] = [0.5, 0.5, 0.5, 1.0]; + +fn close(a: [f32; 4], b: [f32; 4]) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() <= E) +} + +#[track_caller] +fn check(d: &Document, x: i32, y: i32, want: [f32; 4], what: &str) { + let got = render(d, Rect::from_xywh(x, y, 1, 1)).px[0]; + assert!(close(got, want), "{what} ({:?}) at ({x},{y}): got {got:?}, want {want:?}", d.depth); + // Tiled and whole-canvas renders agree (knockout targets are per tile). + let tiled = render_tiled(d, d.bounds(), 3); + let whole = render(d, d.bounds()); + assert!(tiled.px.iter().zip(&whole.px).all(|(a, b)| close(*a, *b)), "{what}: tiles differ from the whole render"); +} + +/// A 12×8 document at `depth` with a white Background (or none). +fn doc(depth: SampleType, background: bool) -> Document { + let size = Size::new(12, 8); + if background { Document::with_background("t", size, ColorMode::Rgb, depth, Color::WHITE) } else { Document::new("t", size, ColorMode::Rgb, depth) } +} + +fn solid(d: &Document, name: &str, r: Rect, c: [f32; 4]) -> Layer { + let mut l = Layer::raster(name, d.pixel_format()); + l.surface_mut().unwrap().fill_rect(r, &photocraft_raster::from_rgba(&d.pixel_format(), c)); + l +} + +fn full(d: &Document) -> Rect { + d.bounds() +} + +/// The knocking-out layer: blue over the left half (x < 6), fill 0 unless set. +fn knocker(d: &Document, k: Knockout) -> Layer { + let mut l = solid(d, "knock", Rect::new(0, 0, 6, 8), BLUE); + l.advanced.knockout = k; + l.fill_opacity = 0.0; + l +} + +#[test] +fn shallow_knockout_stops_at_the_bottom_of_an_isolated_group() { + for depth in DEPTHS { + let mut d = doc(depth, true); + let green = solid(&d, "green", full(&d), GREEN); + let red = solid(&d, "red", full(&d), RED); + let mut g = Layer::group("g", vec![red, knocker(&d, Knockout::Shallow)]); + g.blend = BlendMode::Normal; + d.layers.push(green); + d.layers.push(g); + // The group's own buffer starts transparent: the layers beneath the group show. + check(&d, 2, 2, GREEN, "shallow, fill 0"); + check(&d, 9, 2, RED, "outside the shape"); + d.layers[2].children_mut().unwrap()[1].fill_opacity = 0.5; + // Blue at 50 % over the knocked-out (transparent) area, then the group over green. + check(&d, 2, 2, [0.0, 0.5, 0.5, 1.0], "shallow, fill 50 %"); + // No knockout: blue at 50 % over red. + d.layers[2].children_mut().unwrap()[1].advanced.knockout = Knockout::None; + check(&d, 2, 2, [0.5, 0.0, 0.5, 1.0], "no knockout"); + } +} + +#[test] +fn deep_knockout_reaches_the_background_through_groups() { + for depth in DEPTHS { + let mut d = doc(depth, true); + let green = solid(&d, "green", full(&d), GREEN); + let red = solid(&d, "red", full(&d), RED); + // Pass-through groups let a deep knockout through to the Background. + let inner = Layer::group("inner", vec![knocker(&d, Knockout::Deep)]); + let outer = Layer::group("outer", vec![red, inner]); + d.layers.push(green); + d.layers.push(outer); + check(&d, 2, 2, WHITE, "deep reveals the Background"); + check(&d, 9, 2, RED, "outside the shape"); + // An isolated group stops it at its own (transparent) bottom, like a shallow knockout + // (psd-tools knockout-deep-nested): the layers beneath the group show. + d.layers[2].blend = BlendMode::Normal; + check(&d, 2, 2, GREEN, "deep inside an isolated group"); + d.layers[2].blend = BlendMode::PassThrough; + d.layers[2].children_mut().unwrap()[1].blend = BlendMode::Normal; + // The knockout is the inner group's only layer: its buffer stays transparent, red shows. + check(&d, 2, 2, RED, "deep inside an isolated inner group"); + } +} + +#[test] +fn top_level_shallow_knockout_reaches_the_background_or_transparency() { + for depth in DEPTHS { + let mut d = doc(depth, true); + let green = solid(&d, "green", full(&d), GREEN); + d.layers.push(green); + d.layers.push(knocker(&d, Knockout::Shallow)); + check(&d, 2, 2, WHITE, "top-level shallow"); + // Layer opacity sets the knockout's strength. + d.layers[2].opacity = 0.5; + check(&d, 2, 2, [0.5, 1.0, 0.5, 1.0], "half-strength knockout"); + // Without a Background a deep knockout reveals transparency. + let mut nobg = doc(depth, false); + let green = solid(&nobg, "green", full(&nobg), GREEN); + nobg.layers.push(green); + nobg.layers.push(knocker(&nobg, Knockout::Deep)); + check(&nobg, 2, 2, [0.0; 4], "deep without a Background"); + check(&nobg, 9, 2, GREEN, "outside the shape"); + // A hidden Background doesn't show through either. + d.layers[0].visible = false; + d.layers[2].opacity = 1.0; + check(&d, 2, 2, [0.0; 4], "hidden Background"); + } +} + +#[test] +fn shallow_knockout_in_a_pass_through_group_reveals_what_is_beneath_it() { + for depth in DEPTHS { + let mut d = doc(depth, true); + let green = solid(&d, "green", full(&d), GREEN); + let red = solid(&d, "red", full(&d), RED); + let g = Layer::group("pt", vec![red, knocker(&d, Knockout::Shallow)]); + assert_eq!(g.blend, BlendMode::PassThrough); + d.layers.push(green); + d.layers.push(g); + check(&d, 2, 2, GREEN, "pass-through shallow"); + d.layers[2].children_mut().unwrap()[1].advanced.knockout = Knockout::Deep; + check(&d, 2, 2, WHITE, "pass-through deep"); + } +} + +#[test] +fn knockout_on_a_group_uses_its_composite_shape_and_fill() { + // psd-tools knockout-deep-normal: a group with Deep knockout at 50 % fill. + for depth in DEPTHS { + let mut d = doc(depth, true); + let green = solid(&d, "green", full(&d), GREEN); + let blue = solid(&d, "blue", Rect::new(0, 0, 6, 8), BLUE); + let mut g = Layer::group("g", vec![blue]); + g.blend = BlendMode::Normal; + g.fill_opacity = 0.5; + g.advanced.knockout = Knockout::Deep; + d.layers.push(green); + d.layers.push(g); + check(&d, 2, 2, [0.5, 0.5, 1.0, 1.0], "blue at 50 % over the Background"); + check(&d, 9, 2, GREEN, "outside the group's pixels"); + } +} + +#[test] +fn knockout_follows_masks_and_transparency_shapes() { + for depth in DEPTHS { + let mut d = doc(depth, true); + let green = solid(&d, "green", full(&d), GREEN); + d.layers.push(green); + let mut k = knocker(&d, Knockout::Deep); + // A mask hiding x >= 3: only x < 3 knocks out. + let mut m = photocraft_raster::Surface::new(PixelFormat::new(ColorMode::Grayscale, depth, false)); + m.fill_rect(Rect::new(0, 0, 3, 8), &photocraft_raster::from_rgba(&m.format(), [1.0; 4])); + k.mask = Some(LayerMask { surface: m, enabled: true, linked: true, density: 1.0, feather: 0.0 }); + d.layers.push(k); + check(&d, 1, 2, WHITE, "inside the mask"); + check(&d, 4, 2, GREEN, "masked out"); + // Transparency Shapes Layer off: the knockout covers the whole layer (masks still apply). + d.layers[2].mask = None; + d.layers[2].advanced.transparency_shapes = false; + check(&d, 9, 2, WHITE, "beyond the pixels, tsly off"); + } +} + +#[test] +fn clipped_layer_shallow_knockout_stops_at_the_base() { + for depth in DEPTHS { + let mut d = doc(depth, true); + let base = solid(&d, "base", full(&d), RED); + let mut mid = solid(&d, "mid", full(&d), GREEN); + mid.clipped = true; + let mut k = knocker(&d, Knockout::Shallow); + k.clipped = true; + d.layers.push(base); + d.layers.push(mid); + d.layers.push(k); + check(&d, 2, 2, RED, "the base shows through the clipped layer above it"); + check(&d, 9, 2, GREEN, "outside"); + d.layers[3].advanced.knockout = Knockout::Deep; + check(&d, 2, 2, RED, "clipped deep stops at the base too"); + } +} + +#[test] +fn blend_clipped_layers_individually() { + for depth in DEPTHS { + let mut d = doc(depth, false); + let gray = solid(&d, "gray", full(&d), GRAY); + let mut base = solid(&d, "base", Rect::new(0, 0, 6, 8), RED); + base.blend = BlendMode::Screen; + let mut clip = solid(&d, "clip", full(&d), BLUE); + clip.clipped = true; + clip.blend = BlendMode::Multiply; + d.layers.push(gray); + d.layers.push(base); + d.layers.push(clip); + // As a group: red × blue = black, screened onto gray = gray. + check(&d, 2, 2, GRAY, "as group"); + // One by one: red screened onto gray = (1, .5, .5), then × blue = (0, 0, .5). + d.layers[1].advanced.blend_clipped = false; + check(&d, 2, 2, [0.0, 0.0, 0.5, 1.0], "individually"); + check(&d, 9, 2, GRAY, "outside the base"); + // The base's opacity limits the clipped layers too. + d.layers[1].opacity = 0.5; + // Base at 50 %: (.75, .5, .5); the multiply then mixes in at 50 %: (.375, .25, .5). + check(&d, 2, 2, [0.375, 0.25, 0.5, 1.0], "half-opacity base"); + } +} + +fn overlay(c: [f32; 3]) -> Effect { + Effect::ColorOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), color: Color::rgb(c[0], c[1], c[2]) } +} + +#[test] +fn blend_interior_effects_as_group_puts_overlays_under_fill_opacity() { + for depth in DEPTHS { + let mut d = doc(depth, true); + let mut l = solid(&d, "l", Rect::new(0, 0, 6, 8), BLUE); + l.effects.items = vec![overlay([1.0, 0.0, 0.0])]; + l.fill_opacity = 0.0; + d.layers.push(l); + check(&d, 2, 2, RED, "default: overlays ignore fill"); + d.layers[1].advanced.blend_interior = true; + check(&d, 2, 2, WHITE, "as group at fill 0"); + d.layers[1].fill_opacity = 0.5; + check(&d, 2, 2, [1.0, 0.5, 0.5, 1.0], "as group at fill 50 %"); + d.layers[1].advanced.blend_interior = false; + check(&d, 2, 2, RED, "default at fill 50 %"); + } +} + +#[test] +fn transparency_shapes_off_spreads_effects_over_the_whole_layer() { + for depth in DEPTHS { + let mut d = doc(depth, true); + let mut l = solid(&d, "l", Rect::new(0, 0, 6, 8), BLUE); + l.effects.items = vec![overlay([1.0, 0.0, 0.0])]; + d.layers.push(l); + check(&d, 9, 2, WHITE, "default: the overlay follows the pixels"); + d.layers[1].advanced.transparency_shapes = false; + check(&d, 9, 2, RED, "tsly off: the overlay covers the layer"); + check(&d, 2, 2, RED, "inside"); + // At fill 0 the content's transparency no longer matters. + d.layers[1].fill_opacity = 0.0; + check(&d, 9, 2, RED, "tsly off, fill 0"); + } +} + +fn inside_stroke() -> Effect { + Effect::Stroke(StrokeFx { + common: FxCommon::new(BlendMode::Normal, 1.0), + size: 2.0, + position: StrokePosition::Inside, + paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)), + }) +} + +#[test] +fn layer_mask_hides_effects() { + for depth in DEPTHS { + let mut d = doc(depth, true); + let mut l = solid(&d, "l", full(&d), BLUE); + l.effects.items = vec![inside_stroke()]; + // The mask shows x < 8. + let mut m = photocraft_raster::Surface::new(PixelFormat::new(ColorMode::Grayscale, depth, false)); + m.fill_rect(Rect::new(0, 0, 8, 8), &photocraft_raster::from_rgba(&m.format(), [1.0; 4])); + l.mask = Some(LayerMask { surface: m, enabled: true, linked: true, density: 1.0, feather: 0.0 }); + d.layers.push(l); + // Default: the stroke follows the masked shape, so it runs along the mask's edge. + check(&d, 7, 4, RED, "stroke along the mask edge"); + check(&d, 9, 4, WHITE, "masked out"); + check(&d, 4, 4, BLUE, "interior"); + // Layer Mask Hides Effects: the stroke is built from the whole layer (canvas edges only) + // and the mask then hides the result. + d.layers[1].advanced.layer_mask_hides_effects = true; + check(&d, 7, 4, BLUE, "no stroke at the mask edge"); + check(&d, 4, 0, RED, "the canvas-edge stroke stays"); + check(&d, 9, 4, WHITE, "still masked out"); + check(&d, 11, 0, WHITE, "the stroke beyond the mask is hidden"); + // The vector-mask switch alone changes nothing for a pixel mask. + d.layers[1].advanced.layer_mask_hides_effects = false; + d.layers[1].advanced.vector_mask_hides_effects = true; + check(&d, 7, 4, RED, "vmgm without a vector mask"); + } +} + +#[test] +fn vector_mask_hides_effects() { + use photocraft_doc::{Path, Subpath, VectorMask}; + for depth in DEPTHS { + let mut d = doc(depth, true); + let mut l = solid(&d, "l", full(&d), BLUE); + l.effects.items = vec![inside_stroke()]; + let path = Path::new(vec![Subpath::polygon(&[(-4.0, -4.0), (8.0, -4.0), (8.0, 12.0), (-4.0, 12.0)])]); + l.vector_mask = Some(VectorMask::new(path)); + d.layers.push(l); + check(&d, 7, 4, RED, "stroke along the vector mask"); + d.layers[1].advanced.vector_mask_hides_effects = true; + check(&d, 7, 4, BLUE, "vector mask hides the stroke"); + check(&d, 9, 4, WHITE, "masked out"); + } +} + +#[test] +fn defaults_take_the_plain_paths() { + let d = doc(SampleType::U8, true); + let mut l = solid(&d, "l", full(&d), BLUE); + assert!(!advanced_active(&l, &[])); + l.advanced.knockout = Knockout::Shallow; + assert!(advanced_active(&l, &[])); + l.advanced.knockout = Knockout::None; + // Switches that change nothing for this layer (no effects, no clipped layers) stay inactive. + l.advanced.blend_interior = true; + l.advanced.layer_mask_hides_effects = true; + l.advanced.blend_clipped = false; + assert!(!advanced_active(&l, &[])); + let mut c = solid(&d, "c", full(&d), RED); + c.clipped = true; + assert!(advanced_active(&l, std::slice::from_ref(&c))); + c.visible = false; + assert!(!advanced_active(&l, std::slice::from_ref(&c))); + l.effects.items = vec![overlay([1.0, 0.0, 0.0])]; + assert!(advanced_active(&l, &[]), "blend interior with effects"); +} + +#[test] +fn a_knockout_background_does_not_recurse() { + // A Background carrying a knockout (only reachable through a file) renders without looping. + let mut d = doc(SampleType::U8, true); + d.layers[0].advanced.knockout = Knockout::Deep; + d.layers[0].fill_opacity = 0.0; + let got = render(&d, Rect::from_xywh(0, 0, 1, 1)).px[0]; + assert!(got[3] <= 1.0); +} diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index c4f7d93cac..6fcfe9ecf0 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -1206,13 +1206,18 @@ pub(crate) fn composite_with_effects_prepared( // unmasked fill and the mask applies to fill ∪ stroke), joined with a filled shape's outline. let kmask = |i: usize| vstroke.as_ref().and_then(|v| v.mask).and_then(|m| m.get(i)).copied().unwrap_or(1.0); let union = |i: usize, a: f32| vstroke.as_ref().and_then(|v| v.stroke.px.get(i)).map_or(a, |s| kmask(i) * (a + s[3] * (1.0 - a))); - let shape = if maps.outline { + // Transparency Shapes Layer off: the shape is the whole layer (its masks, as the maps were + // built), and the content's own transparency acts like fill opacity within it. + let shapeless = !layer.advanced.transparency_shapes; + let shape = if shapeless { + maps.crop(&maps.shape, big, 0.0) + } else if maps.outline { let o = maps.crop(&maps.shape, big, 0.0); Map { w, h, v: o.v.iter().zip(&content.px).enumerate().map(|(i, (o, p))| o.max(union(i, p[3]))).collect() } } else { Map { w, h, v: content.px.iter().enumerate().map(|(i, p)| union(i, p[3])).collect() } }; - let relative = maps.outline || vstroke.is_some(); + let relative = shapeless || maps.outline || vstroke.is_some(); let fx = |i: usize, k: usize| maps.crop(&maps.per[i][k], big, 0.0); // Layer bounds (gradients aligned with the layer use the whole layer, // independent of the render rect). @@ -1259,6 +1264,10 @@ pub(crate) fn composite_with_effects_prepared( // applies: a colour overlay at 100 % replaces the colour of a half-transparent edge pixel and // keeps its alpha, as in Photoshop. let fill = layer.fill_opacity; + // Blend Interior Effects as Group: the interior effects (overlays, satin, inner glow) are + // combined with the content first, and fill opacity applies to the combination. + let interior_group = layer.advanced.blend_interior && fill < 1.0; + let content_fill = if interior_group { 1.0 } else { fill }; let inside = |a: f32| a > INSIDE_EPS; // Within an outline (or a split-off vector stroke) the content's own transparency (a fading // gradient fill) acts like fill opacity: the effects still cover the whole shape. @@ -1266,9 +1275,9 @@ pub(crate) fn composite_with_effects_prepared( if !inside(a) { 0.0 } else if relative { - fill * (kmask(i) * p[3] / a).min(1.0) + content_fill * (kmask(i) * p[3] / a).min(1.0) } else { - fill + content_fill } }; let mut lay = Buffer { rect: big, px: content.px.iter().zip(&shape.v).enumerate().map(|(i, (p, a))| [p[0], p[1], p[2], lay_alpha(i, p, *a)]).collect() }; @@ -1303,6 +1312,11 @@ pub(crate) fn composite_with_effects_prepared( paint_glow(&mut lay, &rel(fx(i, 0)), g, sb, anchor, big, patterns); } } + if interior_group { + for p in &mut lay.px { + p[3] *= fill.max(0.0); + } + } for (i, e) in rev() { if let Effect::InnerShadow(s) = e { let m = rel(fx(i, 0)); diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index d1d44e9755..9a986dbf3d 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -17,6 +17,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod adjust; +pub mod advanced; pub mod bounds; pub mod effects; pub mod fill_layout; @@ -34,6 +35,8 @@ use photocraft_geom::Rect; use photocraft_raster::{Rgba8Image, Surface}; use psblend as blend; +pub use advanced::advanced_active; + /// Straight-alpha RGBA float buffer covering a rectangle. #[derive(Clone, Debug, PartialEq)] pub struct Buffer { @@ -105,7 +108,7 @@ fn render_tiled_with(doc: &Document, rect: Rect, tile: i32, cx: &Ctx) -> Buffer return photocraft_color::convert::with_cmyk_space(cmyk, || { let mut buf = multichannel::backdrop(doc, rect); psblend::LAB_MIX.with(|l| l.set(lab)); - composite_stack(&doc.layers, &mut buf, cx); + composite_stack_at(&doc.layers, &mut buf, cx, advanced::Scope::ROOT); psblend::LAB_MIX.with(|l| l.set(false)); buf }); @@ -122,7 +125,7 @@ fn render_tiled_with(doc: &Document, rect: Rect, tile: i32, cx: &Ctx) -> Buffer photocraft_color::convert::with_cmyk_space(cmyk, || { let mut b = multichannel::backdrop(doc, t); psblend::LAB_MIX.with(|l| l.set(lab)); - composite_stack(&doc.layers, &mut b, cx); + composite_stack_at(&doc.layers, &mut b, cx, advanced::Scope::ROOT); psblend::LAB_MIX.with(|l| l.set(false)); b }) @@ -257,7 +260,7 @@ pub fn flatten(doc: &Document) -> Buffer { pub fn render_layer(layer: &Layer, rect: Rect) -> Buffer { let mut buf = Buffer::transparent(rect); let patterns = pattern::PreparedPatterns::new(&[], pattern::PREPARED_PATTERN_BYTES); - composite_stack( + composite_stack_at( std::slice::from_ref(layer), &mut buf, &Ctx { @@ -269,7 +272,9 @@ pub fn render_layer(layer: &Layer, rect: Rect) -> Buffer { depth: photocraft_color::SampleType::F32, vector_masks: RenderVectorMasks::default(), fx_maps: Default::default(), + doc: None, }, + advanced::Scope::ROOT, ); buf } @@ -439,11 +444,14 @@ struct Ctx<'a> { /// Effect maps used by this render, by cache key: built before the parallel tiles (see /// [`prepare_effects`]) and held for the whole render, so eviction can't force a rebuild. fx_maps: std::sync::Mutex>>, + /// The document being rendered (deep knockouts reveal its Background); `None` for a lone layer. + doc: Option<&'a Document>, } impl<'a> Ctx<'a> { - fn for_doc(doc: &Document, patterns: &'a pattern::PreparedPatterns<'a>) -> Self { + fn for_doc(doc: &'a Document, patterns: &'a pattern::PreparedPatterns<'a>) -> Self { Self { + doc: Some(doc), canvas: doc.bounds(), transfer: adjust::Transfer::for_document(doc.mode, doc.depth), light: doc.global_light, @@ -467,7 +475,7 @@ const PREPARE_DEPTH: u32 = 64; fn prepare_effects(layers: &[Layer], rect: Rect, cx: &Ctx, setting: impl Fn(&mut dyn FnMut()) + Sync) { let mut todo = Vec::new(); effect_layers(layers, rect, 0, &mut todo); - let build = |l: &&Layer| { + let build = |l: &std::borrow::Cow| { setting(&mut || { let _ = effect_maps(l, cx); }); @@ -482,7 +490,7 @@ fn prepare_effects(layers: &[Layer], rect: Rect, cx: &Ctx, setting: impl Fn(&mut } /// The visible layers of `layers` (groups included) with effects that can reach `rect`. -fn effect_layers<'l>(layers: &'l [Layer], rect: Rect, depth: u32, out: &mut Vec<&'l Layer>) { +fn effect_layers<'l>(layers: &'l [Layer], rect: Rect, depth: u32, out: &mut Vec>) { let mut base_visible = true; for l in layers { if !l.clipped { @@ -493,7 +501,8 @@ fn effect_layers<'l>(layers: &'l [Layer], rect: Rect, depth: u32, out: &mut Vec< continue; } if effects::has_effects(l) && !empty_in(l, rect) { - out.push(l); + // A mask that hides the effects is left out of the shape they are built from. + out.push(advanced::effects_source(l)); } if let LayerContent::Group(g) = &l.content && depth < PREPARE_DEPTH @@ -503,8 +512,16 @@ fn effect_layers<'l>(layers: &'l [Layer], rect: Rect, depth: u32, out: &mut Vec< } } -/// Composite a sibling list (bottom→top) onto `backdrop`. +/// Composite an isolated group's sibling list (bottom→top) onto `backdrop` (its own buffer). fn composite_stack(layers: &[Layer], backdrop: &mut Buffer, cx: &Ctx) { + composite_stack_at(layers, backdrop, cx, advanced::Scope::ISOLATED); +} + +/// Composite a sibling list (bottom→top) onto `backdrop`; `scope` says where the stack sits (see +/// [`advanced::Scope`]: it decides what knockouts reveal). +fn composite_stack_at(layers: &[Layer], backdrop: &mut Buffer, cx: &Ctx, scope: advanced::Scope) { + // Shallow knockouts stop at the bottom of this stack: the backdrop it starts from. + let floor = (!scope.root && advanced::needs_floor(layers)).then(|| backdrop.clone()); let mut i = 0; while i < layers.len() { let base = &layers[i]; @@ -515,7 +532,8 @@ fn composite_stack(layers: &[Layer], backdrop: &mut Buffer, cx: &Ctx) { } let clipped = &layers[i + 1..j]; if base.visible { - composite_layer(base, clipped, backdrop, cx); + let ko = advanced::knockout_target(base, floor.as_ref(), backdrop.rect, cx, scope); + composite_layer(base, clipped, backdrop, cx, ko.as_deref(), scope); if let (LayerContent::Adjustment(_), Some(q)) = (&base.content, adjustment_quantum(cx.depth)) { quantize(backdrop, q); } @@ -559,6 +577,10 @@ fn dissolve_noise(x: i32, y: i32) -> f32 { /// Bounds of a layer's own pixels (union over group children; the canvas /// for fill layers; an artboard's board). pub fn layer_bounds(layer: &Layer, canvas: Rect) -> Rect { + // Transparency Shapes Layer off: effects and knockout cover the whole layer (the canvas). + if advanced::shapeless(layer) { + return canvas; + } match &layer.content { LayerContent::Group(g) if g.artboard.is_some() => g.artboard.as_ref().map_or(Rect::EMPTY, |a| a.rect), LayerContent::Group(g) => g.children.iter().filter(|c| c.visible).fold(Rect::EMPTY, |acc, c| { @@ -603,6 +625,11 @@ pub fn effect_outline(layer: &Layer) -> Option<&photocraft_doc::vector::Path> { /// path where the fill fades out (psd-tools stroke-effects). fn effect_shape(layer: &Layer, rect: Rect, cx: &Ctx) -> Vec { let n = rect.width() as usize * rect.height() as usize; + // Transparency Shapes Layer off: the effects take the whole layer as their shape (its masks + // still apply). + if !layer.advanced.transparency_shapes { + return mask_vals(layer, rect, cx).unwrap_or_else(|| vec![1.0; n]); + } let Some(path) = effect_outline(layer).filter(|_| n > 0) else { return render_content(layer, rect, cx).map(|b| b.px.iter().map(|p| p[3]).collect()).unwrap_or_else(|| vec![0.0; n]); }; @@ -994,12 +1021,20 @@ fn apply_blend_if(layer: &Layer, before: &Buffer, out: &mut Buffer, cx: &Ctx) { } /// 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) { +/// and Blend If, and knocking out to `ko` when it has a knockout (see [`advanced`]). +fn composite_layer(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx, ko: Option<&Buffer>, scope: advanced::Scope) { + // Blend Clipped Layers as Group off: the base alone, then each clipped layer in its own mode. + if advanced::clips_individually(layer, clipped) { + advanced::composite_clipped_individually(layer, clipped, backdrop, cx, ko, scope); + return; + } 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); + match ko { + Some(target) => advanced::knockout(layer, backdrop, target, false, cx, |b| composite_layer_any(layer, clipped, b, cx, scope)), + None => composite_layer_any(layer, clipped, backdrop, cx, scope), + } if let Some(before) = &before { if let Some(w) = w { restore_channels(backdrop, before, w); @@ -1010,19 +1045,19 @@ fn composite_layer(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: } } -fn composite_layer_any(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx) { +fn composite_layer_any(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx, scope: advanced::Scope) { if let LayerContent::Group(g) = &layer.content && let Some(ab) = &g.artboard { - composite_artboard(layer, ab, clipped, backdrop, cx); + composite_artboard(layer, ab, clipped, backdrop, cx, scope); return; } - composite_layer_plain(layer, clipped, backdrop, cx); + composite_layer_plain(layer, clipped, backdrop, cx, scope); } /// An artboard: its background and the group, composited only inside the board (contents and /// effects outside it are clipped away; the backdrop there is untouched). -fn composite_artboard(layer: &Layer, ab: &photocraft_doc::Artboard, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx) { +fn composite_artboard(layer: &Layer, ab: &photocraft_doc::Artboard, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx, scope: advanced::Scope) { let board = ab.rect.intersect(&backdrop.rect); if board.is_empty() { return; @@ -1038,7 +1073,7 @@ fn composite_artboard(layer: &Layer, ab: &photocraft_doc::Artboard, clipped: &[L if let Some(bg) = ab.background.rgba() { blend_into(&mut sub, &Buffer::filled(board, bg), BlendMode::Normal, 1.0); } - composite_layer_plain(layer, clipped, &mut sub, cx); + composite_layer_plain(layer, clipped, &mut sub, cx, scope); for y in board.y0..board.y1 { let o = row0(y); let so = (y - board.y0) as usize * bw; @@ -1046,7 +1081,7 @@ fn composite_artboard(layer: &Layer, ab: &photocraft_doc::Artboard, clipped: &[L } } -fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx) { +fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx, scope: advanced::Scope) { let rect = backdrop.rect; if empty_in(layer, rect) { return; @@ -1065,7 +1100,7 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer let has_clipped = clipped.iter().any(|c| c.visible); // Children can draw directly unless mixing or clipping needs the original backdrop. let before = (needs_mix || has_clipped).then(|| backdrop.clone()); - composite_stack(&g.children, backdrop, cx); + composite_stack_at(&g.children, backdrop, cx, scope.pass_through()); if needs_mix && let Some(before) = &before { let mv = mask_vals(layer, rect, cx); for (i, (p, a)) in backdrop.px.iter_mut().zip(&before.px).enumerate() { @@ -1090,9 +1125,7 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer // otherwise (matches psd-tools clipping-mask3/4/5). if has_clipped && let (Some(before), Some(iso)) = (before, render_content(layer, rect, cx)) { let mut clipped_iso = iso.clone(); - for c in clipped.iter().filter(|c| c.visible) { - composite_atop(c, &mut clipped_iso, cx); - } + advanced::composite_clipped(clipped, &mut clipped_iso, cx); let mut without = before.clone(); blend_into(&mut without, &iso, BlendMode::Normal, opacity); let mut with = before; @@ -1117,9 +1150,7 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer let mut adjusted = backdrop.clone(); adjust::apply_depth(adj, &mut adjusted, cx.transfer, Some(cx.depth)); // Clipped layers onto an adjustment are uncommon; they composite atop the adjusted result. - for c in clipped.iter().filter(|c| c.visible) { - composite_atop(c, &mut adjusted, cx); - } + advanced::composite_clipped(clipped, &mut adjusted, cx); let mv = mask_vals(layer, rect, cx); for y in rect.y0..rect.y1 { for x in rect.x0..rect.x1 { @@ -1139,36 +1170,19 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer } if effects::has_effects(layer) { - // Neighbourhoods are already captured by the full-region effect maps; - // content and effect application only need the output rectangle. - // A stroked shape's vector stroke stays above its clipped layers and interior effects. - let (mut content, vstroke) = match split_parts(layer, rect, cx) { - Some((fill, stroke, mask)) => (fill, Some((stroke, mask))), - None => { - let Some(content) = render_content(layer, rect, cx) else { return }; - (content, None) - } - }; - for c in clipped.iter().filter(|c| c.visible) { - composite_atop(c, &mut content, cx); + // Layer / Vector Mask Hides Effects: the effects are built without that mask, which then + // hides the finished layer and effects. + let src = advanced::effects_source(layer); + let hide = advanced::hiding_mask(layer, rect, cx); + let before = hide.as_ref().map(|_| backdrop.clone()); + composite_layer_effects(&src, clipped, backdrop, cx); + if let (Some(m), Some(b)) = (&hide, &before) { + advanced::apply_hiding_mask(backdrop, b, m); } - let vstroke = vstroke.as_ref().map(|(s, m)| effects::VectorStroke { stroke: s, mask: m.as_deref() }); - let maps = effect_maps(layer, cx); - effects::composite_with_effects_prepared( - layer, - &content, - backdrop, - &maps, - paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas)), - cx.patterns, - vstroke, - ); return; } if let Some((mut content, stroke, mask)) = shape_parts(layer, clipped, rect, cx) { - for c in clipped.iter().filter(|c| c.visible) { - composite_atop(c, &mut content, cx); - } + advanced::composite_clipped(clipped, &mut content, cx); for (i, (p, s)) in content.px.iter_mut().zip(&stroke.px).enumerate() { *p = psblend::composite(BlendMode::Normal, *p, *s, 1.0); p[3] *= mask_k(&mask, i); @@ -1177,12 +1191,37 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer return; } let Some(mut content) = render_content(layer, rect, cx) else { return }; - for c in clipped.iter().filter(|c| c.visible) { - composite_atop(c, &mut content, cx); - } + advanced::composite_clipped(clipped, &mut content, cx); blend_into_g(backdrop, &content, layer.blend, opacity, text_gamma(layer)); } +/// A layer with effects (and its clipping group) onto `backdrop`. +fn composite_layer_effects(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx) { + let rect = backdrop.rect; + // Neighbourhoods are already captured by the full-region effect maps; + // content and effect application only need the output rectangle. + // A stroked shape's vector stroke stays above its clipped layers and interior effects. + let (mut content, vstroke) = match split_parts(layer, rect, cx) { + Some((fill, stroke, mask)) => (fill, Some((stroke, mask))), + None => { + let Some(content) = render_content(layer, rect, cx) else { return }; + (content, None) + } + }; + advanced::composite_clipped(clipped, &mut content, cx); + let vstroke = vstroke.as_ref().map(|(s, m)| effects::VectorStroke { stroke: s, mask: m.as_deref() }); + let maps = effect_maps(layer, cx); + effects::composite_with_effects_prepared( + layer, + &content, + backdrop, + &maps, + paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas)), + cx.patterns, + vstroke, + ); +} + /// The coverage-mixing gamma of a layer: the text blending gamma /// ([`psblend::set_text_gamma`], Photoshop's default 1.45) for type layers, else 1. pub fn text_gamma(layer: &Layer) -> f32 { @@ -1443,6 +1482,10 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) { // Effects of a clipped layer are clipped to the base too: render // them over the base (treated as opaque) and keep the base's alpha. // The full-region effect maps already capture their neighbourhoods. + // A mask that hides the effects applies to the finished result instead. + let hide = advanced::hiding_mask(layer, rect, cx); + let src = advanced::effects_source(layer); + let layer = src.as_ref(); let (content, vstroke) = match split_parts(layer, rect, cx) { Some((fill, stroke, mask)) => (fill, Some((stroke, mask))), None => { @@ -1462,6 +1505,10 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) { cx.patterns, vstroke, ); + if let Some(m) = &hide { + let plain = Buffer { rect, px: base.px.iter().map(|p| [p[0], p[1], p[2], 1.0]).collect() }; + advanced::apply_hiding_mask(&mut opaque, &plain, m); + } for (p, o) in base.px.iter_mut().zip(&opaque.px) { if p[3] > 0.0 { *p = [o[0], o[1], o[2], p[3]]; @@ -1515,3 +1562,6 @@ fn blend_into_g(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f mod stroke_tests; #[cfg(test)] mod tests; + +#[cfg(test)] +mod advanced_tests; diff --git a/crates/doc/src/advanced.rs b/crates/doc/src/advanced.rs new file mode 100644 index 0000000000..073b6f8b85 --- /dev/null +++ b/crates/doc/src/advanced.rs @@ -0,0 +1,152 @@ +//! Layer Style › Blending Options › Advanced Blending: knockout and the "as group" / "hides +//! effects" switches. Fill opacity ([`crate::Layer::fill_opacity`]), the channel restrictions +//! ([`crate::Layer::excluded_channels`]) and Blend If ([`crate::Layer::blend_if`]) live on the +//! layer itself; this struct holds the rest of the section. +//! +//! Defaults are Photoshop's: no knockout, interior effects blended separately, clipped layers +//! blended as a group, transparency shaping the layer's effects and knockout, and masks applied +//! before the effects are built (so the effects follow the masked shape). +//! +//! PSD: `knko` (0 none, 1 shallow, 2 deep), `infx`, `clbl`, `tsly`, `lmgm`, `vmgm`, each a +//! one-byte flag followed by three bytes of padding (Adobe Photoshop File Format specification, +//! "Additional Layer Information"). + +use serde::{Deserialize, Serialize}; + +/// Blending Options › Advanced Blending › Knockout: which layers beneath this one its shape +/// punches through. Fill opacity controls the strength (0 % fill shows the knocked-out area bare). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Knockout { + /// The layer blends with everything beneath it (the default). + #[default] + None, + /// Knocks out to the bottom of the enclosing layer group or clipping group (for a layer at + /// the top level: to the Background layer, or to transparency without one). + Shallow, + /// Knocks out to the Background layer (to transparency without one), through every group. + Deep, +} + +impl Knockout { + /// The PSD `knko` value (0 none, 1 shallow, 2 deep). + pub fn to_psd(self) -> u8 { + match self { + Knockout::None => 0, + Knockout::Shallow => 1, + Knockout::Deep => 2, + } + } + + /// From a PSD `knko` value; values above 2 (not written by Photoshop) read as deep. + pub fn from_psd(v: u8) -> Self { + match v { + 0 => Knockout::None, + 1 => Knockout::Shallow, + _ => Knockout::Deep, + } + } + + /// The parameter / UI name (`none`, `shallow`, `deep`). + pub fn name(self) -> &'static str { + match self { + Knockout::None => "none", + Knockout::Shallow => "shallow", + Knockout::Deep => "deep", + } + } + + /// Inverse of [`Knockout::name`] (case-insensitive). + pub fn from_name(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "none" => Some(Knockout::None), + "shallow" => Some(Knockout::Shallow), + "deep" => Some(Knockout::Deep), + _ => None, + } + } +} + +/// The Advanced Blending switches of a layer (see the module docs). `Default` is Photoshop's. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct AdvancedBlending { + /// Knockout: None / Shallow / Deep. PSD `knko`. + pub knockout: Knockout, + /// Blend Interior Effects as Group: Inner Glow, Satin and the Color, Gradient and Pattern + /// Overlays are combined with the layer's content before its fill opacity and blend mode + /// apply (off: fill opacity leaves them alone). PSD `infx`, default off. + pub blend_interior: bool, + /// Blend Clipped Layers as Group: the clipping group composites as a whole in the base + /// layer's blend mode (off: each clipped layer blends with the layers beneath the base in its + /// own mode, inside the base's shape). PSD `clbl`, default on. + pub blend_clipped: bool, + /// Transparency Shapes Layer: the layer's transparency limits its effects and knockout (off: + /// they cover the whole layer). PSD `tsly`, default on. + pub transparency_shapes: bool, + /// Layer Mask Hides Effects: the layer mask hides the effects instead of shaping them. PSD + /// `lmgm` ("layer mask as global mask"), default off. + pub layer_mask_hides_effects: bool, + /// Vector Mask Hides Effects: the vector mask hides the effects instead of shaping them. PSD + /// `vmgm`, default off. + pub vector_mask_hides_effects: bool, +} + +impl Default for AdvancedBlending { + fn default() -> Self { + AdvancedBlending { + knockout: Knockout::None, + blend_interior: false, + blend_clipped: true, + transparency_shapes: true, + layer_mask_hides_effects: false, + vector_mask_hides_effects: false, + } + } +} + +impl AdvancedBlending { + /// Whether every switch is at Photoshop's default. + pub fn is_default(&self) -> bool { + *self == Self::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_match_photoshop() { + let a = AdvancedBlending::default(); + assert_eq!(a.knockout, Knockout::None); + assert!(!a.blend_interior); + assert!(a.blend_clipped); + assert!(a.transparency_shapes); + assert!(!a.layer_mask_hides_effects); + assert!(!a.vector_mask_hides_effects); + assert!(a.is_default()); + assert!(!AdvancedBlending { blend_clipped: false, ..a }.is_default()); + } + + #[test] + fn knockout_psd_values_and_names() { + for k in [Knockout::None, Knockout::Shallow, Knockout::Deep] { + assert_eq!(Knockout::from_psd(k.to_psd()), k); + assert_eq!(Knockout::from_name(k.name()), Some(k)); + } + assert_eq!(Knockout::from_psd(7), Knockout::Deep); + assert_eq!(Knockout::from_name("DEEP"), Some(Knockout::Deep)); + assert_eq!(Knockout::from_name("medium"), None); + } + + #[test] + fn partial_json_keeps_photoshop_defaults() { + let a: AdvancedBlending = serde_json::from_str(r#"{"knockout":"deep"}"#).unwrap(); + assert_eq!(a, AdvancedBlending { knockout: Knockout::Deep, ..Default::default() }); + let b: AdvancedBlending = serde_json::from_str("{}").unwrap(); + assert!(b.is_default()); + let json = serde_json::to_string(&AdvancedBlending { layer_mask_hides_effects: true, ..Default::default() }).unwrap(); + assert!(json.contains("\"layerMaskHidesEffects\":true"), "{json}"); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index ca77a9a2f8..ba0715962a 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -9,6 +9,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod adjust; +pub mod advanced; pub mod analysis; pub mod blend_if; pub mod comps; @@ -26,6 +27,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; pub use adjust::Adjustment; +pub use advanced::{AdvancedBlending, Knockout}; pub use analysis::{CountGroup, Measurement, MeasurementScale, Note, Ruler}; pub use blend_if::{BlendIf, BlendRange}; pub use comps::{Artboard, ArtboardBackground, CompAppearance, CompLayerState, LayerComp}; @@ -495,6 +497,9 @@ pub struct Layer { /// outside which the layer's pixels are hidden. Default = everything blends. PSD layer-record /// blending ranges. pub blend_if: BlendIf, + /// Blending Options › Advanced Blending: knockout, blend interior effects / clipped layers as + /// group, transparency shapes layer, layer / vector mask hides effects. Default = Photoshop's. + pub advanced: AdvancedBlending, /// Layer › Video Layers frame stack (None for a normal layer). pub video: Option, } @@ -521,6 +526,7 @@ impl Layer { link_group: None, excluded_channels: 0, blend_if: BlendIf::default(), + advanced: AdvancedBlending::default(), video: None, } } diff --git a/crates/engine/src/extra_cmds.rs b/crates/engine/src/extra_cmds.rs index 0c566964ea..9b70a04e0a 100644 --- a/crates/engine/src/extra_cmds.rs +++ b/crates/engine/src/extra_cmds.rs @@ -664,7 +664,7 @@ pub fn specs() -> Vec { spec!("layer.layerStyle.copyLayerStyle", "Copy Layer Style", &["Layer", "Layer Style"], None, r##"{"layer":id?}"##, has_layer, |s, p| { let id = layer_param(s, p)?; let l = s.active().and_then(|d| d.doc.layer(id)).ok_or(EngineError::NoLayer(id))?; - let style = (l.effects.clone(), l.blend, l.fill_opacity); + let style = (l.effects.clone(), l.blend, l.fill_opacity, l.advanced); s.style_clipboard = Some(style); Ok(Value::Null) }), @@ -680,12 +680,13 @@ pub fn specs() -> Vec { }, |s, p| { let id = layer_param(s, p)?; - let (fx, blend, fill) = s.style_clipboard.clone().ok_or(EngineError::Other("no layer style has been copied".into()))?; + let (fx, blend, fill, advanced) = s.style_clipboard.clone().ok_or(EngineError::Other("no layer style has been copied".into()))?; s.edit("Paste Layer Style", |doc, _| { let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; l.effects = fx; l.blend = blend; l.fill_opacity = fill; + l.advanced = advanced; Ok(()) })?; Ok(Value::Null) diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 9ff5df29c9..022f5d2188 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -112,6 +112,10 @@ 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::>()); } + // Blending Options › Advanced Blending switches (only when one differs from Photoshop's default). + if !l.advanced.is_default() { + v["advancedBlending"] = serde_json::to_value(l.advanced).unwrap_or(Value::Null); + } // 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() { diff --git a/crates/engine/src/layer_menu_cmds.rs b/crates/engine/src/layer_menu_cmds.rs index 3571d38528..f16442a23a 100644 --- a/crates/engine/src/layer_menu_cmds.rs +++ b/crates/engine/src/layer_menu_cmds.rs @@ -5,7 +5,7 @@ use photocraft_algo::selection::Region; use photocraft_color::{BlendMode, ColorMode}; -use photocraft_doc::{BlendIf, BlendRange, Document, Effect, Layer, LayerContent, LayerId, LayerMask, SmartSource, StackMode}; +use photocraft_doc::{BlendIf, BlendRange, Document, Effect, Knockout, Layer, LayerContent, LayerId, LayerMask, SmartSource, StackMode}; use photocraft_geom::Rect; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; use serde_json::{Value, json}; @@ -396,6 +396,7 @@ fn blending_options(s: &mut Session, p: &Value) -> Result { None => None, Some(v) => Some(blend_if_entries(v, mode)?), }; + let adv = advanced_params(p, mode)?; s.edit("Blending Options", |doc, _| { let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; if let Some(b) = blend { @@ -421,11 +422,92 @@ fn blending_options(s: &mut Session, p: &Value) -> Result { } None => {} } + adv.apply(l); Ok(()) })?; Ok(Value::Null) } +/// The Advanced Blending parameters of `layer.layerStyle.blendingOptions`; `None` = keep. +#[derive(Default)] +struct AdvancedParams { + knockout: Option, + blend_interior: Option, + blend_clipped: Option, + transparency_shapes: Option, + layer_mask_hides: Option, + vector_mask_hides: Option, + /// Excluded-channel bit mask (from `channels`). + excluded: Option, +} + +impl AdvancedParams { + fn apply(&self, l: &mut Layer) { + let a = &mut l.advanced; + if let Some(k) = self.knockout { + a.knockout = k; + } + let set = |dst: &mut bool, v: Option| { + if let Some(v) = v { + *dst = v; + } + }; + set(&mut a.blend_interior, self.blend_interior); + set(&mut a.blend_clipped, self.blend_clipped); + set(&mut a.transparency_shapes, self.transparency_shapes); + set(&mut a.layer_mask_hides_effects, self.layer_mask_hides); + set(&mut a.vector_mask_hides_effects, self.vector_mask_hides); + if let Some(x) = self.excluded { + l.excluded_channels = x; + } + } +} + +/// Parse and validate the Advanced Blending keys (wrong types and unknown values are errors). +fn advanced_params(p: &Value, mode: ColorMode) -> Result { + const CMD: &str = "layer.layerStyle.blendingOptions"; + let flag = |key: &str| -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::Bool(b)) => Ok(Some(*b)), + Some(v) => Err(bad(CMD, format!("{key}: expected true or false (got {v})"))), + } + }; + let knockout = match p.get("knockout") { + None | Some(Value::Null) => None, + Some(Value::String(s)) => Some(Knockout::from_name(s).ok_or_else(|| bad(CMD, format!("knockout: expected none, shallow or deep (got `{s}`)")))?), + Some(v) => return Err(bad(CMD, format!("knockout: expected \"none\", \"shallow\" or \"deep\" (got {v})"))), + }; + let excluded = match p.get("channels") { + None | Some(Value::Null) => None, + Some(Value::Array(a)) => { + let n = mode.color_channels().max(1); + if a.len() > n { + return Err(bad(CMD, format!("channels: a {mode:?} document has {n} colour channel(s), got {}", a.len()))); + } + let mut mask = 0u32; + for (i, v) in a.iter().enumerate() { + match v { + Value::Bool(true) => {} + Value::Bool(false) => mask |= 1 << i, + other => return Err(bad(CMD, format!("channels[{i}]: expected true or false (got {other})"))), + } + } + Some(mask) + } + Some(v) => return Err(bad(CMD, format!("channels: expected a list of true/false per colour channel (got {v})"))), + }; + Ok(AdvancedParams { + knockout, + blend_interior: flag("blendInteriorEffectsAsGroup")?, + blend_clipped: flag("blendClippedLayersAsGroup")?, + transparency_shapes: flag("transparencyShapesLayer")?, + layer_mask_hides: flag("layerMaskHidesEffects")?, + vector_mask_hides: flag("vectorMaskHidesEffects")?, + excluded, + }) +} + /// One `blendIf` entry: (range index, This Layer, Underlying Layer); `None` = keep. type BlendIfEntry = (usize, Option, Option); @@ -761,7 +843,7 @@ pub fn specs() -> Vec { "layer.layerStyle.blendingOptions", "Blending Options…", ["Layer", "Layer Style"], - 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)"##, + 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?,"channels":[bool,…]?,"knockout":"none|shallow|deep"?,"blendInteriorEffectsAsGroup":bool?,"blendClippedLayersAsGroup":bool?,"transparencyShapesLayer":bool?,"layerMaskHidesEffects":bool?,"vectorMaskHidesEffects":bool?} (Blend If values 0..255; split points fade; null resets. Advanced Blending: channels = which colour channels blend (R G B / C M Y K / L a b); Photoshop's defaults are knockout none, interior effects off, clipped layers as group on, transparency shapes on, masks hide effects off)"##, has_layer, blending_options ), diff --git a/crates/engine/src/layer_menu_cmds/tests.rs b/crates/engine/src/layer_menu_cmds/tests.rs index c2d82fb18f..f8e88dcc32 100644 --- a/crates/engine/src/layer_menu_cmds/tests.rs +++ b/crates/engine/src/layer_menu_cmds/tests.rs @@ -202,6 +202,101 @@ fn blend_if_rejects_bad_params() { assert_eq!(s.active().unwrap().history.past_len(), steps); } +#[test] +fn blending_options_advanced_blending() { + use photocraft_doc::{AdvancedBlending, Knockout}; + for depth in DEPTHS { + let mut s = session(depth, "rgb"); + paint(&mut s, |x, _| if x < 20 { [0.0, 0.0, 1.0, 1.0] } else { [0.0; 4] }); + assert!(active(&s).advanced.is_default()); + s.execute( + "layer.layerStyle.blendingOptions", + json!({ + "knockout": "deep", + "fillOpacity": 0, + "blendInteriorEffectsAsGroup": true, + "blendClippedLayersAsGroup": false, + "transparencyShapesLayer": false, + "layerMaskHidesEffects": true, + "vectorMaskHidesEffects": true, + "channels": [true, false, true], + }), + ) + .unwrap(); + let l = active(&s); + assert_eq!( + l.advanced, + AdvancedBlending { + knockout: Knockout::Deep, + blend_interior: true, + blend_clipped: false, + transparency_shapes: false, + layer_mask_hides_effects: true, + vector_mask_hides_effects: true, + } + ); + assert_eq!(l.excluded_channels, 0b010); + // A deep knockout at fill 0 with no Background reveals transparency where the layer has + // pixels. + let p = photocraft_compose::flatten(doc(&s)).get(5, 5); + assert!(p[3] < 2.0 / 255.0, "{depth}: {p:?}"); + // Inspect reports the switches; omitted keys keep their values; one undo step each. + let info = crate::inspect::layer(active(&s)); + assert_eq!(info["advancedBlending"]["knockout"], json!("deep"), "{info}"); + s.execute("layer.layerStyle.blendingOptions", json!({"knockout": "Shallow"})).unwrap(); + assert_eq!(active(&s).advanced.knockout, Knockout::Shallow); + assert!(!active(&s).advanced.blend_clipped); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(active(&s).advanced.knockout, Knockout::Deep); + s.execute("edit.undo", json!({})).unwrap(); + assert!(active(&s).advanced.is_default()); + assert_eq!(active(&s).excluded_channels, 0); + } +} + +#[test] +fn blending_options_advanced_rejects_bad_params() { + let mut s = session(8, "rgb"); + paint(&mut s, |_, _| [1.0; 4]); + let steps = s.active().unwrap().history.past_len(); + for bad in [ + json!({"knockout": "medium"}), + json!({"knockout": 2}), + json!({"knockout": true}), + json!({"blendInteriorEffectsAsGroup": "yes"}), + json!({"blendClippedLayersAsGroup": 1}), + json!({"transparencyShapesLayer": []}), + json!({"layerMaskHidesEffects": {}}), + json!({"vectorMaskHidesEffects": 0.5}), + json!({"channels": [true, true, true, true, true]}), + json!({"channels": [1, 0, 1]}), + json!({"channels": "rgb"}), + // A bad key fails the whole call: nothing else is applied. + json!({"fillOpacity": 10, "knockout": "nope"}), + ] { + assert!(s.execute("layer.layerStyle.blendingOptions", bad.clone()).is_err(), "{bad}"); + } + assert!(active(&s).advanced.is_default()); + assert_eq!(active(&s).fill_opacity, 1.0); + assert_eq!(s.active().unwrap().history.past_len(), steps); + // `null` keeps the current value. + s.execute("layer.layerStyle.blendingOptions", json!({"knockout": null, "channels": null})).unwrap(); + assert!(active(&s).advanced.is_default()); +} + +#[test] +fn copy_paste_layer_style_carries_advanced_blending() { + let mut s = session(8, "rgb"); + paint(&mut s, |_, _| [1.0; 4]); + s.execute("layer.layerStyle.blendingOptions", json!({"knockout": "shallow", "blendClippedLayersAsGroup": false})).unwrap(); + let a = active(&s).advanced; + s.execute("layer.layerStyle.copyLayerStyle", json!({})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + assert!(active(&s).advanced.is_default()); + s.execute("layer.layerStyle.pasteLayerStyle", json!({})).unwrap(); + assert_eq!(active(&s).advanced, a); +} + #[test] fn blend_if_channel_names_follow_the_mode() { let mut s = session(8, "cmyk"); diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index cb79ae53d0..a0f33b117b 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -477,6 +477,7 @@ fn layer_pixels_equal(a: &Layer, b: &Layer) -> bool { link_group: _, excluded_channels, blend_if, + advanced, video: _, } = a; *id == b.id @@ -490,6 +491,7 @@ fn layer_pixels_equal(a: &Layer, b: &Layer) -> bool { && *effects == b.effects && *excluded_channels == b.excluded_channels && *blend_if == b.blend_if + && *advanced == b.advanced && match (content, &b.content) { (LayerContent::Raster(x), LayerContent::Raster(y)) => surfaces_equal(x, y), // Children are walked separately; only the group's own clipping frame counts. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b3b94c564c..18ef8bc849 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -272,8 +272,9 @@ pub struct Session { coalesce_request: Option, /// Pixels copied with Edit › Copy / Cut (shared by all documents, like Photoshop). pub clipboard: Option, - /// Layer › Layer Style › Copy Layer Style: effects, blend mode and fill opacity. - pub style_clipboard: Option<(photocraft_doc::Effects, photocraft_color::BlendMode, f32)>, + /// Layer › Layer Style › Copy Layer Style: effects, blend mode, fill opacity and the Advanced + /// Blending switches. + pub style_clipboard: Option<(photocraft_doc::Effects, photocraft_color::BlendMode, f32, photocraft_doc::AdvancedBlending)>, /// Shape path context menu: copied vector fill and stroke styles. pub path_fill_clipboard: Option, pub path_stroke_clipboard: Option, diff --git a/crates/format/src/convert.rs b/crates/format/src/convert.rs index 086f03d268..7fabfa8aba 100644 --- a/crates/format/src/convert.rs +++ b/crates/format/src/convert.rs @@ -197,6 +197,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(), + advanced: l.advanced, video: l.video.as_ref().map(|v| video_m(v, sink)), } } @@ -488,6 +489,7 @@ impl Loader<'_> { link_group: m.link_group, excluded_channels: m.excluded_channels, blend_if: m.blend_if.clone(), + advanced: m.advanced, video: m.video.as_ref().map(|v| self.video(v)).transpose()?, }) } diff --git a/crates/format/src/manifest.rs b/crates/format/src/manifest.rs index fc8cda5440..0ffb7c2367 100644 --- a/crates/format/src/manifest.rs +++ b/crates/format/src/manifest.rs @@ -223,6 +223,10 @@ pub struct LayerM { /// Blend If ranges (empty = everything blends). #[serde(default, skip_serializing_if = "photocraft_doc::BlendIf::is_default")] pub blend_if: photocraft_doc::BlendIf, + /// Advanced Blending switches (knockout, as-group, transparency shapes, masks hide effects); + /// omitted at Photoshop's defaults, and missing in older files = the defaults. + #[serde(default, skip_serializing_if = "photocraft_doc::AdvancedBlending::is_default")] + pub advanced: photocraft_doc::AdvancedBlending, #[serde(default)] pub video: Option, } diff --git a/crates/format/tests/roundtrip.rs b/crates/format/tests/roundtrip.rs index 778598e9d4..4acf97de7b 100644 --- a/crates/format/tests/roundtrip.rs +++ b/crates/format/tests/roundtrip.rs @@ -391,6 +391,45 @@ fn blend_if_roundtrips() { assert!(back.layers.iter().skip(1).all(|l| l.blend_if.is_default())); } +/// Advanced Blending (knockout, as-group switches, transparency shapes, masks hide effects) +/// survives .pcraft at every depth; manifests written before the field existed load with +/// Photoshop's defaults, and default layers don't write it. +#[test] +fn advanced_blending_roundtrips_and_defaults_when_absent() { + use photocraft_doc::{AdvancedBlending, Knockout}; + for depth in SampleType::ALL { + let mut doc = rich_doc(ColorMode::Rgb, depth); + let a = AdvancedBlending { + knockout: Knockout::Deep, + blend_interior: true, + blend_clipped: false, + transparency_shapes: false, + layer_mask_hides_effects: true, + vector_mask_hides_effects: true, + }; + doc.layers[0].advanced = a; + if let Some(l) = doc.layers.get_mut(1) { + l.advanced.knockout = Knockout::Shallow; + } + let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap(); + let back = load_from_bytes(&bytes).unwrap(); + assert_eq!(back, doc, "{depth:?}"); + assert_eq!(back.layers[0].advanced, a); + // Only the non-default layers carry the field. + let m = read_manifest(&bytes).unwrap(); + let v = serde_json::to_value(&m.document).unwrap(); + let with = v["layers"].as_array().unwrap().iter().filter(|l| l.get("advanced").is_some()).count(); + assert_eq!(with, if doc.layers.len() > 1 { 2 } else { 1 }); + // An older manifest (no `advanced`) loads with the defaults. + let mut old = v.clone(); + for l in old["layers"].as_array_mut().unwrap() { + l.as_object_mut().unwrap().remove("advanced"); + } + let old: photocraft_format::manifest::DocM = serde_json::from_value(old).unwrap(); + assert!(old.layers.iter().all(|l| l.advanced.is_default())); + } +} + #[test] fn video_layer_frames_survive_roundtrip() { use photocraft_doc::{Timeline, VideoData, VideoSource}; diff --git a/crates/gpu/src/plan.rs b/crates/gpu/src/plan.rs index a439a9499a..ea9df089c8 100644 --- a/crates/gpu/src/plan.rs +++ b/crates/gpu/src/plan.rs @@ -489,9 +489,21 @@ impl<'a> Planner<'a> { Ok(()) } + /// Blending Options › Advanced Blending (knockout, clipped layers blended individually, blend + /// interior effects as group, transparency shapes off, masks hiding effects) has no GPU pass: + /// documents using it are composited on the CPU (`photocraft_compose::advanced`). Layers at + /// Photoshop's defaults pass this check for free. + fn check_advanced(&self, layer: &Layer, clipped: &[Layer]) -> Result<(), Unsupported> { + if photocraft_compose::advanced_active(layer, clipped) { + return Err(Unsupported(format!("Advanced Blending 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 { self.check_blend_if(layer)?; + self.check_advanced(layer, clipped)?; match photocraft_compose::channel_weights(layer, self.cx.mode) { Some(w) => { let before = self.retain(backdrop); @@ -665,6 +677,7 @@ impl<'a> Planner<'a> { // Nothing to draw (an empty layer, or a group of them). for c in &visible_clipped { self.check_blend_if(c)?; + self.check_advanced(c, &[])?; } return Ok(backdrop); } @@ -836,6 +849,7 @@ impl<'a> Planner<'a> { /// layer's channel restrictions. fn atop(&mut self, layer: &'a Layer, base: Slot) -> Result { self.check_blend_if(layer)?; + self.check_advanced(layer, &[])?; match photocraft_compose::channel_weights(layer, self.cx.mode) { Some(w) => { let before = self.retain(base); @@ -1739,4 +1753,35 @@ mod tests { d.layers.push(l); assert!(plan(&d).is_err()); } + + #[test] + fn advanced_blending_falls_back_to_the_cpu() { + use photocraft_doc::Knockout; + let mut d = Document::with_background("t", Size::new(8, 8), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut l = Layer::raster("ko", d.pixel_format()); + l.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, 4, 4), &[1.0, 0.0, 0.0, 1.0]); + d.layers.push(l.clone()); + assert!(plan(&d).is_ok(), "defaults stay on the GPU"); + // Switches with nothing to act on (no effects, no clipped layers) stay on the GPU too. + d.layers[1].advanced.blend_interior = true; + d.layers[1].advanced.blend_clipped = false; + assert!(plan(&d).is_ok()); + d.layers[1].advanced.knockout = Knockout::Deep; + assert!(plan(&d).unwrap_err().0.contains("Advanced Blending")); + // Inside groups and clipping groups. + d.layers[1].advanced.knockout = Knockout::None; + let mut c = l.clone(); + c.clipped = true; + d.layers.push(c.clone()); + assert!(plan(&d).is_err(), "clipped layers blended individually"); + d.layers[1].advanced.blend_clipped = true; + assert!(plan(&d).is_ok()); + d.layers[2].advanced.knockout = Knockout::Shallow; + assert!(plan(&d).is_err(), "clipped knockout"); + d.layers.truncate(1); + let mut inner = l; + inner.advanced.knockout = Knockout::Shallow; + d.layers.push(Layer::group("g", vec![inner])); + assert!(plan(&d).is_err(), "knockout inside a group"); + } } diff --git a/crates/io/src/blocks.rs b/crates/io/src/blocks.rs index bb13932038..08dc493d23 100644 --- a/crates/io/src/blocks.rs +++ b/crates/io/src/blocks.rs @@ -2,7 +2,7 @@ //! smart objects, locks and label colors. use photocraft_color::{Color, ColorMode}; -use photocraft_doc::{BlendIf, BlendRange, Fill, GradientStyle, LabelColor, Locks}; +use photocraft_doc::{AdvancedBlending, BlendIf, BlendRange, Fill, GradientStyle, Knockout, LabelColor, Locks}; use photocraft_geom::Affine; use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor}; use photocraft_psd::layer::BlendingRanges; @@ -18,6 +18,60 @@ pub fn brst_data(mask: u32) -> Option> { (mask != 0).then(|| (0..32u32).filter(|i| mask & 1 << i != 0).flat_map(u32::to_be_bytes).collect()) } +/// The Advanced Blending blocks, as (key, value for `a`, Photoshop's default value): `knko` +/// (0 none, 1 shallow, 2 deep), `infx`, `clbl`, `tsly`, `lmgm`, `vmgm`. Each is stored as one +/// byte followed by three bytes of padding (Adobe PSD spec, "Additional Layer Information"). +pub fn advanced_flags(a: &AdvancedBlending) -> [([u8; 4], u8, u8); 6] { + [ + (*b"knko", a.knockout.to_psd(), 0), + (*b"infx", u8::from(a.blend_interior), 0), + (*b"clbl", u8::from(a.blend_clipped), 1), + (*b"tsly", u8::from(a.transparency_shapes), 1), + (*b"lmgm", u8::from(a.layer_mask_hides_effects), 0), + (*b"vmgm", u8::from(a.vector_mask_hides_effects), 0), + ] +} + +/// Advanced Blending from a layer record's blocks (`block(key)` gives a block's data). A missing +/// or empty block keeps Photoshop's default. +pub fn advanced_from_blocks<'a>(block: impl Fn(&[u8; 4]) -> Option<&'a [u8]>) -> AdvancedBlending { + let mut a = AdvancedBlending::default(); + let flag = |k: &[u8; 4]| block(k).and_then(|d| d.first().copied()); + if let Some(v) = flag(b"knko") { + a.knockout = Knockout::from_psd(v); + } + let set = |k: &[u8; 4], f: &mut bool| { + if let Some(v) = flag(k) { + *f = v != 0; + } + }; + set(b"infx", &mut a.blend_interior); + set(b"clbl", &mut a.blend_clipped); + set(b"tsly", &mut a.transparency_shapes); + set(b"lmgm", &mut a.layer_mask_hides_effects); + set(b"vmgm", &mut a.vector_mask_hides_effects); + a +} + +/// Writes the Advanced Blending blocks into an exported layer's preserved block list: a block +/// already there is rewritten in place (keeping Photoshop's block order); a missing one is added +/// only when its value differs from the default, so plain layers gain no blocks. +pub fn put_advanced(a: &AdvancedBlending, raw: &mut Vec<([u8; 4], Vec)>) { + for (key, v, default) in advanced_flags(a) { + match raw.iter_mut().find(|(k, _)| *k == key) { + Some(e) => { + // Keep any extra bytes a reader wrote; the flag is the first byte. + if e.1.len() < 4 { + e.1.resize(4, 0); + } + e.1[0] = v; + } + None if v != default => raw.push((key, vec![v, 0, 0, 0])), + None => {} + } + } +} + /// 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 @@ -637,6 +691,37 @@ mod tests { assert_eq!(&r.data[8..], &BlendingRanges::full(2).data[..]); } + #[test] + fn advanced_blending_blocks_round_trip() { + use photocraft_doc::{AdvancedBlending, Knockout}; + let a = AdvancedBlending { + knockout: Knockout::Deep, + blend_interior: true, + blend_clipped: false, + transparency_shapes: false, + layer_mask_hides_effects: true, + vector_mask_hides_effects: true, + }; + let mut raw = Vec::new(); + super::put_advanced(&a, &mut raw); + assert_eq!(raw.len(), 6); + assert!(raw.contains(&(*b"knko", vec![2, 0, 0, 0]))); + assert!(raw.contains(&(*b"clbl", vec![0, 0, 0, 0]))); + let get = |k: &[u8; 4]| raw.iter().find(|(bk, _)| bk == k).map(|(_, d)| d.as_slice()); + assert_eq!(super::advanced_from_blocks(get), a); + // Defaults: nothing added to a plain layer; existing blocks rewritten in place, in order. + let mut plain = Vec::new(); + super::put_advanced(&AdvancedBlending::default(), &mut plain); + assert!(plain.is_empty()); + let mut kept = vec![(*b"luni", vec![0; 4]), (*b"knko", vec![1, 0, 0, 0]), (*b"clbl", vec![1])]; + super::put_advanced(&AdvancedBlending::default(), &mut kept); + assert_eq!(kept, vec![(*b"luni", vec![0; 4]), (*b"knko", vec![0, 0, 0, 0]), (*b"clbl", vec![1, 0, 0, 0])]); + // Missing and empty blocks read as Photoshop's defaults. + assert!(super::advanced_from_blocks(|_| None).is_default()); + assert!(super::advanced_from_blocks(|_| Some(&[][..])).is_default()); + assert_eq!(super::advanced_from_blocks(|k| (k == b"knko").then_some(&[1u8][..])).knockout, Knockout::Shallow); + } + #[test] fn brst_round_trip() { assert_eq!(super::parse_brst(&[0, 0, 0, 2]), 0b100); diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index f8f0337339..525d6bad23 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -497,6 +497,8 @@ impl Ex { }, None => raw.retain(|(k, _)| k != b"brst"), } + // Advanced Blending: written from the field (in place when the block was imported). + crate::blocks::put_advanced(&l.advanced, &mut raw); if !matches!(l.content, LayerContent::Shape(_)) { self.vector_mask_block(l, &mut raw); } diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 63d2f25e91..18351953c6 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -246,6 +246,9 @@ impl Ctx<'_> { if let Some(b) = rec.block(b"brst") { l.excluded_channels = crate::blocks::parse_brst(&b.data); } + // Advanced Blending (`knko`, `infx`, `clbl`, `tsly`, `lmgm`, `vmgm`); the blocks stay in + // `psd_blocks`, where export rewrites them from the field in place. + l.advanced = crate::blocks::advanced_from_blocks(|k| rec.block(k).map(|b| b.data.as_slice())); // 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(); diff --git a/crates/io/tests/common/mod.rs b/crates/io/tests/common/mod.rs index ec1ff94eaf..882fa42469 100644 --- a/crates/io/tests/common/mod.rs +++ b/crates/io/tests/common/mod.rs @@ -89,6 +89,9 @@ pub fn gen_doc(mode: ColorMode, depth: SampleType, f: Features) -> Document { neg.fill_opacity = g(128); neg.blend = BlendMode::Multiply; neg.label = LabelColor::Blue; + // Advanced Blending switches round-trip as `infx` / `tsly` (and the rest below). + neg.advanced.blend_interior = true; + neg.advanced.transparency_shapes = false; d.layers.push(neg); let mut hidden = raster("Hidden", fmt, Rect::new(3, 3, 9, 9), 3, true); @@ -105,6 +108,8 @@ pub fn gen_doc(mode: ColorMode, depth: SampleType, f: Features) -> Document { if f.masks { let mut m = raster("Masked", fmt, Rect::new(2, 2, 14, 12), 5, true); m.mask = Some(LayerMask { surface: mask_surface(depth, Rect::new(4, 4, 10, 9), 1.0, 1), enabled: true, linked: true, density: 1.0, feather: 0.0 }); + m.advanced.layer_mask_hides_effects = true; + m.advanced.vector_mask_hides_effects = true; d.layers.push(m); let mut m2 = raster("Masked2", fmt, Rect::new(-2, 1, 6, 5), 6, true); m2.mask = @@ -120,10 +125,13 @@ pub fn gen_doc(mode: ColorMode, depth: SampleType, f: Features) -> Document { if let LayerContent::Group(gr) = &mut inner.content { gr.expanded = false; } + inner.advanced.knockout = photocraft_doc::Knockout::Shallow; let a = raster("Outer child", fmt, Rect::new(6, 2, 12, 8), 8, true); let mut outer = Layer::group("Outer", vec![a, inner]); outer.locks.position = true; outer.label = LabelColor::Red; + outer.advanced.knockout = photocraft_doc::Knockout::Deep; + outer.advanced.blend_clipped = false; d.layers.push(outer); d.layers.push(Layer::group("Empty group", vec![])); } @@ -235,6 +243,9 @@ pub fn assert_layers_eq(a: &Layer, b: &Layer, path: &str) { assert_eq!(a.fill_opacity, b.fill_opacity, "{ctx}: fill"); assert_eq!(a.clipped, b.clipped, "{ctx}: clipped"); assert_eq!(a.label, b.label, "{ctx}: label"); + assert_eq!(a.advanced, b.advanced, "{ctx}: advanced blending"); + assert_eq!(a.excluded_channels, b.excluded_channels, "{ctx}: channel restrictions"); + assert_eq!(a.blend_if, b.blend_if, "{ctx}: blend if"); assert_eq!(a.effects.psd_raw, b.effects.psd_raw, "{ctx}: effects raw"); assert_eq!(a.effects.enabled, b.effects.enabled, "{ctx}: effects enabled"); if a.psd_id.is_some() { diff --git a/crates/io/tests/corpus.rs b/crates/io/tests/corpus.rs index c997fe9b0a..728f235ee1 100644 --- a/crates/io/tests/corpus.rs +++ b/crates/io/tests/corpus.rs @@ -151,8 +151,8 @@ struct Source { const MIXED: Source = Source { label: "io corpus", dir: "corpus/psd", group_depth: 0, pass_floor: 146, roundtrip_floor: 169 }; /// The full psd-tools test set (309 files at the pinned commit; see `xtask/psd-tools-corpus.sha256`): -/// 219 vs the merged image + 10 vs the thumbnail. -const PSD_TOOLS: Source = Source { label: "psd-tools corpus", dir: "corpus/psd-tools", group_depth: 0, pass_floor: 229, roundtrip_floor: 307 }; +/// 226 vs the merged image + 10 vs the thumbnail (Advanced Blending knockouts: +7). +const PSD_TOOLS: Source = Source { label: "psd-tools corpus", dir: "corpus/psd-tools", group_depth: 0, pass_floor: 236, roundtrip_floor: 307 }; /// Our Photoshop-authored oracles (256 files; https://github.com/storytold/photocraft-corpus), /// grouped by feature (`smart-filters`, `effects`, `text`, `adjustments/`). diff --git a/crates/io/tests/roundtrip.rs b/crates/io/tests/roundtrip.rs index 2bd9bb67e4..fc84700e62 100644 --- a/crates/io/tests/roundtrip.rs +++ b/crates/io/tests/roundtrip.rs @@ -161,6 +161,8 @@ fn raster_layer_blocks_preserved() { let blocks = vec![(*b"vmsk", std::sync::Arc::new(vec![0u8; 12])), (*b"clbl", std::sync::Arc::new(vec![0u8, 0, 0, 0])), (*b"Zzzz", std::sync::Arc::new(vec![1u8]))]; d.layers[1].psd_blocks = blocks.clone(); + // `clbl` is written from the field (in place), so the field agrees with the preserved block. + d.layers[1].advanced = photocraft_doc::AdvancedBlending { blend_clipped: false, ..Default::default() }; d.layers[1].vector_mask = photocraft_io::vector_map::vector_mask_from_block(&[0; 12], d.size.width, d.size.height); let back = roundtrip(&d); // An odd-length layer block comes back with its pad byte inside the length (as Photoshop @@ -464,6 +466,56 @@ fn channel_restrictions_round_trip_as_brst() { assert!(f.layers().iter().find(|r| r.name() == "a").unwrap().block(b"brst").is_none()); } +/// Advanced Blending maps to `knko` / `infx` / `clbl` / `tsly` / `lmgm` / `vmgm` (one byte + +/// three of padding), at every depth: non-default values are written, default layers gain no +/// blocks, and imported blocks are rewritten in place. +#[test] +fn advanced_blending_round_trips_as_tagged_blocks() { + use photocraft_doc::{AdvancedBlending, Document, Knockout, Layer, Size}; + for depth in SampleType::ALL { + let mut d = Document::new("a", Size::new(8, 8), ColorMode::Rgb, depth); + 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, 0.0, 0.0, 1.0]); + let adv = AdvancedBlending { + knockout: Knockout::Deep, + blend_interior: true, + blend_clipped: false, + transparency_shapes: false, + layer_mask_hides_effects: true, + vector_mask_hides_effects: true, + }; + a.advanced = adv; + let mut g = Layer::group("g", vec![Layer::raster("c", d.pixel_format())]); + g.advanced.knockout = Knockout::Shallow; + g.fill_opacity = 128.0 / 255.0; + let b = Layer::raster("b", d.pixel_format()); + d.layers = vec![a, b, g]; + let f = document_to_psd(&d); + let rec = |name: &str| f.layers().iter().find(|r| r.name() == name).unwrap().clone(); + let ra = rec("a"); + for (k, v) in [(b"knko", 2u8), (b"infx", 1), (b"clbl", 0), (b"tsly", 0), (b"lmgm", 1), (b"vmgm", 1)] { + assert_eq!(ra.block(k).unwrap().data, vec![v, 0, 0, 0], "{depth:?} {}", String::from_utf8_lossy(k)); + } + for k in [b"knko", b"infx", b"clbl", b"tsly", b"lmgm", b"vmgm"] { + assert!(rec("b").block(k).is_none(), "default layer gained {}", String::from_utf8_lossy(k)); + } + assert_eq!(rec("g").block(b"knko").unwrap().data, vec![1, 0, 0, 0]); + let back = roundtrip(&d); + assert_eq!(back.layers[0].advanced, adv, "{depth:?}"); + assert!(back.layers[1].advanced.is_default()); + assert_eq!(back.layers[2].advanced.knockout, Knockout::Shallow); + assert_eq!(back.layers[2].fill_opacity, 128.0 / 255.0); + // Reset to the defaults: the imported blocks stay (in place) with default values. + let mut reset = back.clone(); + reset.layers[0].advanced = AdvancedBlending::default(); + let f = document_to_psd(&reset); + let ra = f.layers().iter().find(|r| r.name() == "a").unwrap(); + assert_eq!(ra.block(b"knko").unwrap().data, vec![0, 0, 0, 0]); + assert_eq!(ra.block(b"clbl").unwrap().data, vec![1, 0, 0, 0]); + assert!(roundtrip(&reset).layers[0].advanced.is_default()); + } +} + #[test] fn blend_if_round_trips_as_blending_ranges() { use photocraft_doc::{BlendIf, BlendRange, Document, Layer, Size}; diff --git a/crates/io/tests/tiff_layers.rs b/crates/io/tests/tiff_layers.rs index 068413f175..7a8b09da94 100644 --- a/crates/io/tests/tiff_layers.rs +++ b/crates/io/tests/tiff_layers.rs @@ -228,6 +228,54 @@ fn psdtags_fixtures_open_with_their_layers() { } } +/// Advanced Blending (`knko`, `infx`, `clbl`, `tsly`, `lmgm`, `vmgm`), fill opacity (`iOpa`), +/// channel restrictions (`brst`) and Blend If (the records' blending ranges) survive a layered +/// TIFF with its layer data in either byte order: the encoder's own, and the other one made by +/// transcoding the tag (the same length, spliced in place). +#[test] +fn advanced_blending_survives_layered_tiff_in_both_byte_orders() { + use photocraft_doc::{AdvancedBlending, BlendRange, Knockout}; + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut d = gen_doc(ColorMode::Rgb, depth, Features::PIXELS); + let adv = AdvancedBlending { + knockout: Knockout::Deep, + blend_interior: true, + blend_clipped: false, + transparency_shapes: false, + layer_mask_hides_effects: true, + vector_mask_hides_effects: true, + }; + d.layers[1].advanced = adv; + d.layers[1].fill_opacity = 64.0 / 255.0; + d.layers[1].excluded_channels = 0b010; + d.layers[1].blend_if.set(0, [BlendRange { black: [10, 40], white: [200, 250] }, BlendRange { black: [0, 0], white: [128, 128] }]); + d.layers[2].advanced.knockout = Knockout::Shallow; + let r = tiff(&d, true); + let (_, layers) = tags(&r.bytes); + let layers = layers.expect("layer data"); + let (isd, w) = ImageSourceData::from_bytes(&layers).expect("parse"); + assert!(w.is_empty(), "{w:?}"); + let native = isd.byte_order; + let other = if native == ByteOrder::Little { ByteOrder::Big } else { ByteOrder::Little }; + let (swapped, w) = isd.to_bytes(other).expect("transcode"); + assert!(w.is_empty(), "{depth:?}: blocks dropped while transcoding: {w:?}"); + assert_eq!(swapped.len(), layers.len()); + let at = r.bytes.windows(layers.len()).position(|x| x == &layers[..]).expect("tag data in the file"); + let mut flipped = r.bytes.clone(); + flipped[at..at + layers.len()].copy_from_slice(&swapped); + for (order, bytes) in [(native, &r.bytes), (other, &flipped)] { + let back = import("x.tif", bytes).unwrap_or_else(|e| panic!("{depth:?} {order:?}: {e}")).document; + let l = &back.layers[1]; + assert_eq!(l.advanced, adv, "{depth:?} {order:?}"); + assert_eq!(l.fill_opacity, 64.0 / 255.0, "{depth:?} {order:?}"); + assert_eq!(l.excluded_channels, 0b010, "{depth:?} {order:?}"); + assert_eq!(l.blend_if, d.layers[1].blend_if, "{depth:?} {order:?}"); + assert_eq!(back.layers[2].advanced.knockout, Knockout::Shallow, "{depth:?} {order:?}"); + assert_docs_eq(&expected(&d), &back); + } + } +} + #[test] fn damaged_layer_data_opens_flattened() { let d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS); diff --git a/crates/psd/src/tagged.rs b/crates/psd/src/tagged.rs index 9545a4c999..97501e2401 100644 --- a/crates/psd/src/tagged.rs +++ b/crates/psd/src/tagged.rs @@ -115,8 +115,14 @@ pub enum BlockData<'a> { BlendClippedAsGroup(bool), /// `infx` BlendInteriorElements(bool), - /// `knko` + /// `knko`: 0 none, 1 shallow, 2 deep. Knockout(u8), + /// `tsly`: Transparency Shapes Layer. + TransparencyShapes(bool), + /// `lmgm`: Layer Mask Hides Effects ("layer mask as global mask"). + LayerMaskHidesEffects(bool), + /// `vmgm`: Vector Mask Hides Effects. + VectorMaskHidesEffects(bool), /// `lspf`: bit 0 transparency, bit 1 composite, bit 2 position locked. Protection(u32), /// `lclr`: sheet color index (0 none, 1 red, 2 orange, 3 yellow, 4 green, @@ -170,6 +176,9 @@ impl TaggedBlock { b"clbl" => r.u8().map(|v| BlockData::BlendClippedAsGroup(v != 0)), b"infx" => r.u8().map(|v| BlockData::BlendInteriorElements(v != 0)), b"knko" => r.u8().map(BlockData::Knockout), + b"tsly" => r.u8().map(|v| BlockData::TransparencyShapes(v != 0)), + b"lmgm" => r.u8().map(|v| BlockData::LayerMaskHidesEffects(v != 0)), + b"vmgm" => r.u8().map(|v| BlockData::VectorMaskHidesEffects(v != 0)), b"lspf" => r.u32().map(BlockData::Protection), b"lclr" => r.u16().map(BlockData::SheetColor), b"iOpa" => r.u8().map(BlockData::FillOpacity), @@ -225,6 +234,18 @@ impl TaggedBlock { pub fn knockout(v: u8) -> Self { Self::flag(*b"knko", v) } + /// `tsly` block. + pub fn transparency_shapes(v: bool) -> Self { + Self::flag(*b"tsly", u8::from(v)) + } + /// `lmgm` block. + pub fn layer_mask_hides_effects(v: bool) -> Self { + Self::flag(*b"lmgm", u8::from(v)) + } + /// `vmgm` block. + pub fn vector_mask_hides_effects(v: bool) -> Self { + Self::flag(*b"vmgm", u8::from(v)) + } /// `lspf` block. pub fn protection(flags: u32) -> Self { Self::new(*b"lspf", flags.to_be_bytes().to_vec()) @@ -507,6 +528,11 @@ mod tests { assert_eq!(TaggedBlock::blend_clipped_as_group(true).parsed(), Some(Ok(BlockData::BlendClippedAsGroup(true)))); assert_eq!(TaggedBlock::blend_interior_elements(false).parsed(), Some(Ok(BlockData::BlendInteriorElements(false)))); assert_eq!(TaggedBlock::knockout(2).parsed(), Some(Ok(BlockData::Knockout(2)))); + assert_eq!(TaggedBlock::transparency_shapes(false).parsed(), Some(Ok(BlockData::TransparencyShapes(false)))); + assert_eq!(TaggedBlock::layer_mask_hides_effects(true).parsed(), Some(Ok(BlockData::LayerMaskHidesEffects(true)))); + assert_eq!(TaggedBlock::vector_mask_hides_effects(true).parsed(), Some(Ok(BlockData::VectorMaskHidesEffects(true)))); + assert_eq!(TaggedBlock::vector_mask_hides_effects(true).data, vec![1, 0, 0, 0]); + assert!(matches!(TaggedBlock::new(*b"tsly", vec![]).parsed(), Some(Err(_))), "truncated flag is an error, not a panic"); assert_eq!(TaggedBlock::protection(0x8000_0000).parsed(), Some(Ok(BlockData::Protection(0x8000_0000)))); assert_eq!(TaggedBlock::sheet_color(3).parsed(), Some(Ok(BlockData::SheetColor(3)))); assert_eq!(TaggedBlock::fill_opacity(128).parsed(), Some(Ok(BlockData::FillOpacity(128)))); diff --git a/crates/psd/src/tiff.rs b/crates/psd/src/tiff.rs index d91e0821c0..15867ee137 100644 --- a/crates/psd/src/tiff.rs +++ b/crates/psd/src/tiff.rs @@ -776,8 +776,8 @@ pub mod transcode { self.u32()?; } } - // lyvr layer version, sn2P and vowv (shape and vector flags), lmgm. - b"lyid" | b"lspf" | b"lyvr" | b"sn2P" | b"vowv" | b"lmgm" => { + // lyvr layer version, sn2P and vowv (shape and vector flags). + b"lyid" | b"lspf" | b"lyvr" | b"sn2P" | b"vowv" => { self.u32()?; } // Text-engine global data: a textual structure, no byte order. @@ -785,7 +785,9 @@ pub mod transcode { b"lnsr" => { self.key()?; } - b"clbl" | b"infx" | b"knko" | b"tsly" | b"iOpa" => { + // Advanced Blending flags and fill opacity: one byte, then padding (Adobe spec, + // "Additional Layer Information"; `lmgm`/`vmgm` = layer/vector mask hides effects). + b"clbl" | b"infx" | b"knko" | b"tsly" | b"lmgm" | b"vmgm" | b"iOpa" => { self.u8()?; } b"lclr" => self.u16s_upto(4)?, @@ -1895,6 +1897,13 @@ mod tests { (*b"lspf", vec![0, 0, 0, 5]), (*b"lnsr", b"layr".to_vec()), (*b"knko", vec![1, 0, 0, 0]), + (*b"knko", vec![2, 0, 0, 0]), + (*b"infx", vec![1, 0, 0, 0]), + (*b"clbl", vec![0, 0, 0, 0]), + (*b"tsly", vec![0, 0, 0, 0]), + (*b"lmgm", vec![1, 0, 0, 0]), + (*b"vmgm", vec![1, 0, 0, 0]), + (*b"iOpa", vec![128, 0, 0, 0]), ] { let (big, little) = block_rt(key, data.clone()); if data.len() >= 2 && key != *b"nvrt" { diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index bf614d3202..8cd00bc7bf 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2145,6 +2145,10 @@ cameraRaw Color Barvy % of all commits % všech commitů Lines +/− Řádky +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: PR: {prs}, commity: {commits}, řádky +{added} / −{deleted} (Δ {delta}), binární soubory +{binAdded} / −{binDeleted}, {first} – {last} +# Layer Style > Blending Options (Advanced Blending) + Blend If: Prolnout, pokud: + Channels: Kanály: + Knockout Vyřazení Apply At Canvas Bounds Použít efekt na hranicích plátna # Fill dialog, layer style contours and Image Size diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index e33e26dcdb..e5b6335e25 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2184,6 +2184,11 @@ % of all commits % aller Commits Lines +/− Zeilen +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PRs, {commits} Commits, +{added} / −{deleted} Zeilen (Δ {delta}), +{binAdded} / −{binDeleted} Binärdateien, {first} – {last} + +# Layer Style > Blending Options (Advanced Blending) + Blend If: Farbbereich: + Channels: Kanäle: + Knockout Aussparung Apply At Canvas Bounds Effekt an Arbeitsflächenbegrenzungen anwenden # Fill dialog, layer style contours and Image Size diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 486b0fed0f..2a3e00560f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2216,6 +2216,10 @@ cameraRaw Amount Cantidad % of all commits % de todos los commits Lines +/− Líneas +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PR, {commits} commits, +{added} / −{deleted} líneas (Δ {delta}), +{binAdded} / −{binDeleted} archivos binarios, {first} – {last} +# Layer Style > Blending Options (Advanced Blending) + Blend If: Combinar si: + Channels: Canales: + Knockout Cobertura Apply At Canvas Bounds Aplicar efecto en los límites del lienzo # Fill dialog, layer style contours and Image Size diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 825aeeaf96..541424ffce 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2576,6 +2576,10 @@ cameraRaw Blending Fusion % of all commits % de tous les commits Lines +/− Lignes +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login} : {prs} PR, {commits} commits, +{added} / −{deleted} lignes (Δ {delta}), +{binAdded} / −{binDeleted} fichiers binaires, {first} – {last} +# Layer Style > Blending Options (Advanced Blending) + Blend If: Fusion si : + Channels: Couches : + Knockout Masquage Apply At Canvas Bounds Appliquer l’effet aux limites de la zone de travail # Fill dialog, layer style contours and Image Size diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 329e252ea9..88013908cc 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2563,6 +2563,10 @@ cameraRaw Blending Pembauran % of all commits % dari semua commit Lines +/− Baris +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PR, {commits} commit, +{added} / −{deleted} baris (Δ {delta}), +{binAdded} / −{binDeleted} berkas biner, {first} – {last} +# Layer Style > Blending Options (Advanced Blending) + Blend If: Blend If: + Channels: Channel: + Knockout Knockout Apply At Canvas Bounds Terapkan efek di batas kanvas # Fill dialog, layer style contours and Image Size diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 1ee4350564..16e149b2b5 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2217,6 +2217,10 @@ cameraRaw Amount Quantità % of all commits % di tutti i commit Lines +/− Righe +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PR, {commits} commit, +{added} / −{deleted} righe (Δ {delta}), +{binAdded} / −{binDeleted} risorse binarie, {first} – {last} +# Layer Style > Blending Options (Advanced Blending) + Blend If: Fondi se: + Channels: Canali: + Knockout Foratura Convert to Layers Converti in livelli Apply At Canvas Bounds Applica effetto ai limiti dell’area di lavoro diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c8f1b79877..cf7aff07f7 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2128,6 +2128,10 @@ cameraRaw Color カラー % of all commits 全コミットに占める割合 (%) Lines +/− 行数 +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: PR {prs} 件、コミット {commits} 件、行 +{added} / −{deleted} (Δ {delta})、バイナリ +{binAdded} / −{binDeleted}、{first} – {last} +# Layer Style > Blending Options (Advanced Blending) + Blend If: 条件付き合成: + Channels: チャンネル: + Knockout ノックアウト Apply At Canvas Bounds カンバスの境界に効果を適用 # Fill dialog, layer style contours and Image Size diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 1f83a09729..8c94b4c652 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2729,6 +2729,10 @@ cameraRaw Color 색상 % of all commits 전체 커밋 대비 % Lines +/− 줄 +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: PR {prs}개, 커밋 {commits}개, 줄 +{added} / −{deleted} (Δ {delta}), 바이너리 +{binAdded} / −{binDeleted}, {first} – {last} +# Layer Style > Blending Options (Advanced Blending) + Blend If: 혼합 조건: + Channels: 채널: + Knockout 녹아웃 Apply At Canvas Bounds 캔버스 경계에 효과 적용 Good 좋음 Best 최적 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ef7e48ca46..4916f0864d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3184,6 +3184,9 @@ cameraRaw Roughness Chropowatość More documents Więcej dokumentów Developing {name} Wywoływanie {name} Camera Raw ({name}) Camera Raw ({name}) + Blend If: Mieszaj, jeśli: + Channels: Kanały: + Knockout Wycięcie New Brush Preset… Nowe ustawienie wstępne pędzla… Brush Preset Options Opcje ustawień wstępnych pędzla diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 35b9f3ad27..1bdfc3bc57 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2583,6 +2583,11 @@ cameraRaw Whites Brancos % of all commits % de todos os commits Lines +/− Linhas +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} linhas (Δ {delta}), +{binAdded} / −{binDeleted} arquivos binários, {first} – {last} + +# Layer Style > Blending Options (Advanced Blending) + Blend If: Mesclar se: + Channels: Canais: + Knockout Vazado Apply At Canvas Bounds Aplicar efeito nos limites da tela # Fill dialog, layer style contours and Image Size diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 1b7924c085..3042abcc96 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2422,6 +2422,10 @@ cameraRaw Roughness Неровность % of all commits % от всех коммитов Lines +/− Строки +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: PR: {prs}, коммиты: {commits}, строки +{added} / −{deleted} (Δ {delta}), двоичные файлы +{binAdded} / −{binDeleted}, {first} – {last} +# Layer Style > Blending Options (Advanced Blending) + Blend If: Изображение если: + Channels: Каналы: + Knockout Просвечивание Apply At Canvas Bounds Применить эффект на границах холста # Fill dialog, layer style contours and Image Size diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 6786e59506..08bfaa9a07 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2153,6 +2153,10 @@ cameraRaw Blending 混合 % of all commits 占全部提交的 % Lines +/− 行数 +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}:{prs} 个 PR,{commits} 次提交,行 +{added} / −{deleted}(Δ {delta}),二进制文件 +{binAdded} / −{binDeleted},{first} – {last} +# Layer Style > Blending Options (Advanced Blending) + Blend If: 混合颜色带: + Channels: 通道: + Knockout 挖空 Apply At Canvas Bounds 应用画布边界的效果 # Fill dialog, layer style contours and Image Size diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 9acdd90ed1..21f6bb1e93 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2122,6 +2122,10 @@ cameraRaw Blending 混合 % of all commits 佔全部提交的 % Lines +/− 行數 +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}:{prs} 個 PR,{commits} 次提交,行 +{added} / −{deleted}(Δ {delta}),二進位檔 +{binAdded} / −{binDeleted},{first} – {last} +# Layer Style > Blending Options (Advanced Blending) + Blend If: 混合範圍: + Channels: 色版: + Knockout 去底色 Apply At Canvas Bounds 套用效果至版面邊界 # Fill dialog, layer style contours and Image Size diff --git a/crates/ui-egui/src/layer_style.rs b/crates/ui-egui/src/layer_style.rs index c4698cd00b..5e50c736e1 100644 --- a/crates/ui-egui/src/layer_style.rs +++ b/crates/ui-egui/src/layer_style.rs @@ -9,7 +9,9 @@ //! snapshot with `params` (engine-side overlay), so everything the dialog //! doesn't model — gradient strokes, imported contours, PSD data — survives. //! An entry without `fx` is a new instance built from `params`. -//! - `p:blendingOptions`: the layer's blend mode, opacity and fill opacity. +//! - `p:blendingOptions`: the parameters of `layer.layerStyle.blendingOptions` (blend mode, +//! opacity, fill opacity, channels, knockout, the Advanced Blending switches, Blend If) plus +//! the page-only `blendIfChannel`; `channelNames`: the document's colour channels. use egui::{Color32, RichText, Sense, Stroke, StrokeKind, vec2}; use photocraft_doc::effects::{FxPaint, StrokePosition}; @@ -445,10 +447,7 @@ pub fn initial_fields(layer: &Layer, select: Option<&str>, light: f32) -> Map) -> Option { let st = app.session.active()?; let layer = st.doc.layer(st.active_layer?)?.clone(); let light = st.doc.global_light.angle; + let mode = st.doc.mode; let mut f = initial_fields(&layer, select, light); + // Channels and Blend If list the document's own colour channels. + set_blending_fields(&mut f, &layer, mode); f.insert("patternList".into(), pattern_list(app)); Some(app.ui.open_dialog(crate::state::DialogKind::LayerStyle, f)) } @@ -548,10 +550,11 @@ fn apply(f: &Map, mut run: impl FnMut(&str, Value) -> Result Vec<&'static str> { + use photocraft_color::ColorMode as M; + match mode { + M::Rgb | M::Indexed | M::Multichannel => vec!["R", "G", "B"], + M::Cmyk => vec!["C", "M", "Y", "K"], + M::Lab => vec!["L", "a", "b"], + _ => vec!["Gray"], + } +} + +const KNOCKOUT: &[(&str, &str)] = &[("none", "None"), ("shallow", "Shallow"), ("deep", "Deep")]; + +/// (parameter key, label) of the Advanced Blending check boxes, in Photoshop's order. +const ADVANCED_CHECKS: &[(&str, &str)] = &[ + ("blendInteriorEffectsAsGroup", "Blend Interior Effects as Group"), + ("blendClippedLayersAsGroup", "Blend Clipped Layers as Group"), + ("transparencyShapesLayer", "Transparency Shapes Layer"), + ("layerMaskHidesEffects", "Layer Mask Hides Effects"), + ("vectorMaskHidesEffects", "Vector Mask Hides Effects"), +]; + +/// The Blending Options page's fields for `layer` in a `mode` document (the parameters of +/// `layer.layerStyle.blendingOptions`, plus the page's Blend If channel), and the channel names. +pub fn set_blending_fields(f: &mut Map, layer: &Layer, mode: photocraft_color::ColorMode) { + let names = channel_names(mode); + let n = names.len(); + let a = &layer.advanced; + // Blend If entries in the command's form: index 0 is Gray for colour documents; a one-channel + // document's Gray is its channel's own entry (index 1). + let first = if n == 1 { 1 } else { 0 }; + let blend_if: Vec = (first..=n) + .map(|i| { + let [this, under] = layer.blend_if.get(i); + json!({"channel": i, "thisLayer": this.to_bytes(), "underlying": under.to_bytes()}) + }) + .collect(); + f.insert( + format!("p:{BLENDING}"), + json!({ + "blend": layer.blend.label(), + "opacity": (layer.opacity * 100.0).round(), + "fillOpacity": (layer.fill_opacity * 100.0).round(), + "channels": (0..n).map(|i| layer.excluded_channels & (1 << i) == 0).collect::>(), + "knockout": a.knockout.name(), + "blendInteriorEffectsAsGroup": a.blend_interior, + "blendClippedLayersAsGroup": a.blend_clipped, + "transparencyShapesLayer": a.transparency_shapes, + "layerMaskHidesEffects": a.layer_mask_hides_effects, + "vectorMaskHidesEffects": a.vector_mask_hides_effects, + "blendIfChannel": 0, + "blendIf": blend_if, + }), + ); + f.insert("channelNames".into(), json!(names)); +} + +fn channel_names_of(f: &Map) -> Vec { + f.get("channelNames") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect::>()) + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| ["R", "G", "B"].iter().map(|s| (*s).to_string()).collect()) +} + +/// The Blending Options page: General Blending, Advanced Blending and Blend If, as in Photoshop. +fn blending_page(ui: &mut egui::Ui, p: &mut Value, names: &[String]) { + let t = Tokens::get(ui.ctx()); + let num = |p: &Value, k: &str, d: f64| p.get(k).and_then(Value::as_f64).unwrap_or(d) as f32; + widgets::section_label(ui, "General Blending"); + ui.horizontal(|ui| { + ui.label(RichText::new(tl!("Blend Mode")).color(t.text_dim)); + let mut cur = p.get("blend").and_then(Value::as_str).unwrap_or("Normal").to_string(); + let opts: Vec<(String, &str)> = photocraft_color::BlendMode::LAYER_MODES.iter().map(|m| (m.label().to_string(), m.label())).collect(); + if widgets::dropdown(ui, "fx-blend-blendingOptions", &mut cur, &opts, 150.0) { + p["blend"] = json!(cur); + } + }); + let mut v = num(p, "opacity", 100.0); + if widgets::slider_row(ui, "Opacity", &mut v, 0.0..=100.0, "%", None).changed() { + p["opacity"] = json!(v.round()); + } + widgets::hairline(ui); + widgets::section_label(ui, "Advanced Blending"); + let mut v = num(p, "fillOpacity", 100.0); + if widgets::slider_row(ui, "Fill Opacity", &mut v, 0.0..=100.0, "%", None).changed() { + p["fillOpacity"] = json!(v.round()); + } + // Channels: one check box per colour channel (checked = blends). + ui.horizontal(|ui| { + ui.label(RichText::new(tl!("Channels:")).color(t.text_dim)); + let mut ch: Vec = + p.get("channels").and_then(Value::as_array).map(|a| a.iter().map(|v| v.as_bool().unwrap_or(true)).collect()).unwrap_or_default(); + ch.resize(names.len(), true); + let mut changed = false; + for (i, name) in names.iter().enumerate() { + if let Some(c) = ch.get_mut(i) { + changed |= widgets::checkbox(ui, c, name).changed(); + } + } + if changed { + p["channels"] = json!(ch); + } + }); + ui.horizontal(|ui| { + ui.label(RichText::new(tl!("Knockout")).color(t.text_dim)); + let mut cur = p.get("knockout").and_then(Value::as_str).unwrap_or("none").to_string(); + let opts: Vec<(String, &str)> = KNOCKOUT.iter().map(|(v, l)| (v.to_string(), *l)).collect(); + if widgets::dropdown(ui, "fx-blendingOptions-knockout", &mut cur, &opts, 150.0) { + p["knockout"] = json!(cur); + } + }); + for &(key, label) in ADVANCED_CHECKS { + let default = matches!(key, "blendClippedLayersAsGroup" | "transparencyShapesLayer"); + let mut b = p.get(key).and_then(Value::as_bool).unwrap_or(default); + if widgets::checkbox(ui, &mut b, label).changed() { + p[key] = json!(b); + } + } + widgets::hairline(ui); + // Blend If: the channel, then This Layer / Underlying Layer split sliders (Alt-drag splits). + let entries = p.get("blendIf").and_then(Value::as_array).map_or(0, Vec::len); + if entries == 0 { + return; + } + let sel = p.get("blendIfChannel").and_then(Value::as_u64).map_or(0, |v| v as usize).min(entries - 1); + ui.horizontal(|ui| { + ui.label(RichText::new(tl!("Blend If:")).color(t.text_dim)); + // A colour document lists Gray first; a one-channel document only its channel (Gray). + let label = |i: usize| -> String { + if entries == names.len() { + names.get(i).cloned().unwrap_or_default() + } else if i == 0 { + "Gray".to_string() + } else { + names.get(i - 1).cloned().unwrap_or_default() + } + }; + let labels: Vec = (0..entries).map(label).collect(); + let opts: Vec<(usize, &str)> = labels.iter().enumerate().map(|(i, l)| (i, l.as_str())).collect(); + let mut cur = sel; + if widgets::dropdown(ui, "fx-blendingOptions-blendIf", &mut cur, &opts, 110.0) { + p["blendIfChannel"] = json!(cur); + } + }); + for (key, label) in [("thisLayer", "This Layer:"), ("underlying", "Underlying Layer:")] { + let mut q = blend_quad(p.get("blendIf").and_then(|a| a.get(sel)).and_then(|e| e.get(key))); + ui.horizontal(|ui| { + ui.label(RichText::new(tl!(label)).color(t.text_dim)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let side = |lo: u8, hi: u8| if lo == hi { lo.to_string() } else { format!("{lo} / {hi}") }; + ui.label(RichText::new(format!("{} {}", side(q[0], q[1]), side(q[2], q[3]))).color(t.text)); + }); + }); + if blend_if_bar(ui, ui.id().with(("blendIf", key)), &mut q) + && let Some(e) = p.get_mut("blendIf").and_then(|a| a.get_mut(sel)) + { + e[key] = json!(q); + } + ui.add_space(4.0); + } +} + +/// A Blend If range from the dialog's JSON (`[blackLo, blackHi, whiteLo, whiteHi]`), full when absent. +fn blend_quad(v: Option<&Value>) -> [u8; 4] { + let mut q = [0u8, 0, 255, 255]; + if let Some(a) = v.and_then(Value::as_array) { + for (dst, x) in q.iter_mut().zip(a) { + *dst = x.as_f64().map_or(*dst, |f| f.clamp(0.0, 255.0).round() as u8); + } + } + // The sliders never cross. + for i in 1..4 { + q[i] = q[i].max(q[i - 1]); + } + q +} + +/// A Blend If slider: a black-to-white ramp with a black and a white handle, each split in two +/// halves by Alt-dragging (the layer fades between them). Returns whether `q` changed. +fn blend_if_bar(ui: &mut egui::Ui, id: egui::Id, q: &mut [u8; 4]) -> bool { + let t = Tokens::get(ui.ctx()); + let w = ui.available_width().clamp(120.0, 320.0); + let (rect, resp) = ui.allocate_exact_size(vec2(w, 26.0), Sense::click_and_drag()); + let ramp = egui::Rect::from_min_size(rect.min + vec2(6.0, 0.0), vec2(w - 12.0, 12.0)); + let x_of = |v: u8| ramp.left() + ramp.width() * f32::from(v) / 255.0; + let v_of = |x: f32| (((x - ramp.left()) / ramp.width().max(1.0)) * 255.0).round().clamp(0.0, 255.0) as u8; + // The ramp, drawn as vertical strips. + let strips = 32; + for s in 0..strips { + let x0 = ramp.left() + ramp.width() * s as f32 / strips as f32; + let x1 = ramp.left() + ramp.width() * (s + 1) as f32 / strips as f32; + let g = (255.0 * (s as f32 + 0.5) / strips as f32) as u8; + ui.painter().rect_filled(egui::Rect::from_x_y_ranges(x0..=x1, ramp.y_range()), 0.0, Color32::from_gray(g)); + } + ui.painter().rect_stroke(ramp, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside); + // Handles: triangles under the ramp; a split handle shows two half triangles. + let mut changed = false; + let drag_key = id.with("handle"); + if resp.drag_started() + && let Some(pos) = resp.interact_pointer_pos() + { + let near = (0..4).min_by(|a, b| (x_of(q[*a]) - pos.x).abs().total_cmp(&(x_of(q[*b]) - pos.x).abs())).unwrap_or(0); + // Between two coinciding halves, pick by the side of the pointer. + let near = match near { + 0 | 1 if q[0] == q[1] => usize::from(pos.x > x_of(q[0])), + 2 | 3 if q[2] == q[3] => 2 + usize::from(pos.x > x_of(q[2])), + n => n, + }; + ui.data_mut(|d| d.insert_temp(drag_key, near)); + } + if resp.dragged() + && let (Some(h), Some(pos)) = (ui.data(|d| d.get_temp::(drag_key)), resp.interact_pointer_pos()) + && let Some(cur) = q.get(h).copied() + { + let target = v_of(pos.x); + let split = ui.input(|i| i.modifiers.alt); + let mut n = *q; + let pair = if h < 2 { 0 } else { 2 }; + if split { + n[h] = target; + } else { + // Unsplit: the pair moves together, keeping its spread. + let delta = i16::from(target) - i16::from(cur); + for v in &mut n[pair..pair + 2] { + *v = (i16::from(*v) + delta).clamp(0, 255) as u8; + } + } + // Keep the order black low <= black high <= white low <= white high, pushing the others. + for i in 0..4 { + if i < h { + n[i] = n[i].min(n[h]); + } else if i > h { + n[i] = n[i].max(n[h]); + } + } + for i in 1..4 { + n[i] = n[i].max(n[i - 1]); + } + if n != *q { + *q = n; + changed = true; + } + } + let base = ramp.bottom() + 1.0; + let tri = |x: f32, half: i8, fill: Color32| { + let (l, r) = match half { + -1 => (x - 5.0, x), + 1 => (x, x + 5.0), + _ => (x - 5.0, x + 5.0), + }; + let pts = vec![egui::pos2(x, base), egui::pos2(r, base + 10.0), egui::pos2(l, base + 10.0)]; + ui.painter().add(egui::Shape::convex_polygon(pts, fill, Stroke::new(1.0, t.text_dim))); + }; + let dark = Color32::from_gray(20); + let light = Color32::from_gray(240); + for (pair, fill) in [(0usize, dark), (2, light)] { + let (a, b) = (q[pair], q[pair + 1]); + if a == b { + tri(x_of(a), 0, fill); + } else { + tri(x_of(a), -1, fill); + tri(x_of(b), 1, fill); + } + } + changed +} + /// Light direction uses document angles: counter-clockwise from three o'clock. fn angle_row(ui: &mut egui::Ui, label: &str, angle: &mut f32) -> egui::Response { let t = Tokens::get(ui.ctx()); @@ -1267,6 +1547,32 @@ mod tests { } } + #[test] + fn blending_page_renders_for_every_mode_and_odd_fields() { + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + let mut layer = photocraft_doc::Layer::new("L", photocraft_doc::LayerContent::Adjustment(photocraft_doc::Adjustment::Invert)); + layer.advanced.knockout = photocraft_doc::Knockout::Deep; + for mode in + [photocraft_color::ColorMode::Rgb, photocraft_color::ColorMode::Cmyk, photocraft_color::ColorMode::Lab, photocraft_color::ColorMode::Grayscale] + { + let mut f = initial_fields(&layer, Some(BLENDING), 120.0); + set_blending_fields(&mut f, &layer, mode); + let mut out = ctx.run_ui(Default::default(), |ui| body(&mut app, ui, &mut f)); + out.textures_delta.clear(); + assert_eq!(f["p:blendingOptions"]["knockout"], "deep", "{mode:?}"); + // Hostile dialog state (only reachable through `ui.dialog.set`) still renders. + let bo = f.get_mut("p:blendingOptions").unwrap(); + bo["blendIfChannel"] = json!(99); + bo["channels"] = json!("x"); + bo["blendIf"] = json!([{"thisLayer": [300, -5, "a"]}]); + f.insert("channelNames".into(), json!([])); + let mut out = ctx.run_ui(Default::default(), |ui| body(&mut app, ui, &mut f)); + out.textures_delta.clear(); + } + } + #[test] fn confirm_rejects_invalid_params_and_applies_valid_effects() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); @@ -1284,6 +1590,55 @@ mod tests { assert_eq!(app.session.active().unwrap().doc.layer(layer.id).unwrap().effects.items.len(), 1); } + #[test] + fn blending_page_round_trips_advanced_blending_and_blend_if() { + use photocraft_doc::{BlendRange, Knockout}; + for mode in ["rgb", "cmyk", "grayscale"] { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16, "mode": mode})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + let st = s.active().unwrap(); + let id = st.active_layer.unwrap(); + let mut l = st.doc.layer(id).unwrap().clone(); + l.advanced.knockout = Knockout::Shallow; + l.advanced.blend_clipped = false; + l.excluded_channels = 0b1; + let gray = if st.doc.mode.color_channels() == 1 { 1 } else { 0 }; + l.blend_if.set(gray, [BlendRange { black: [10, 30], white: [255, 255] }, BlendRange::FULL]); + let mut f = initial_fields(&l, None, 120.0); + set_blending_fields(&mut f, &l, st.doc.mode); + let names = channel_names(st.doc.mode); + assert_eq!(f["channelNames"], json!(names), "{mode}"); + let p = &f[&format!("p:{BLENDING}")]; + assert_eq!(p["knockout"], "shallow"); + assert_eq!(p["blendClippedLayersAsGroup"], false); + assert_eq!(p["transparencyShapesLayer"], true); + assert_eq!(p["channels"].as_array().unwrap().len(), names.len()); + assert_eq!(p["channels"][0], false); + assert_eq!(p["blendIf"][0]["thisLayer"], json!([10, 30, 255, 255]), "{mode}"); + // Applying the page sets every switch through the engine command. + let mut f2 = f.clone(); + let bo = f2.get_mut(&format!("p:{BLENDING}")).unwrap(); + bo["knockout"] = json!("deep"); + bo["vectorMaskHidesEffects"] = json!(true); + bo["blendIfChannel"] = json!(names.len().min(1)); + let shown = preview_document(&st.doc, &s.patterns, &f2).unwrap(); + let got = shown.layer(id).unwrap(); + assert_eq!(got.advanced.knockout, Knockout::Deep, "{mode}"); + assert!(got.advanced.vector_mask_hides_effects); + assert!(!got.advanced.blend_clipped); + assert_eq!(got.excluded_channels, 0b1); + assert_eq!(got.blend_if.get(gray)[0], BlendRange { black: [10, 30], white: [255, 255] }, "{mode}"); + } + } + + #[test] + fn blend_if_values_stay_ordered() { + assert_eq!(blend_quad(Some(&json!([200, 10, 5, 300]))), [200, 200, 200, 255]); + assert_eq!(blend_quad(None), [0, 0, 255, 255]); + assert_eq!(blend_quad(Some(&json!(["x", null]))), [0, 0, 255, 255]); + } + #[test] fn percent_fields_round_trip_through_the_engine() { let mut s = session(); diff --git a/docs/roadmap.md b/docs/roadmap.md index 7b1d8f73d9..8da587f0f0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -65,7 +65,7 @@ Confidence: moderate — the next users of 0.2.x will move these numbers either | Dimension | Measured / evidence (2026-10-05) | Grade | Notes | |---|---|---|---| | Menu wiring | 626/626 menu items dispatch a command (`parity.md`) | high but shallow | Says nothing about behaviour. | -| PSD fidelity (rendering) | Corpus oracle 115/170 (68%): 30 differ, 26 have no usable reference, 1 import error | medium | Push to 170/170 under way (effects/strokes, multi-instance effects, 16/32-bit and colour modes, references for skipped files). | +| PSD fidelity (rendering) | Corpus oracle 115/170 (68%): 30 differ, 26 have no usable reference, 1 import error. 2026-10-07: io corpus 146/170, psd-tools corpus 236/309 (was 229: Advanced Blending knockouts), Photoshop oracles 132/258 | medium | Push to 170/170 under way (effects/strokes, multi-instance effects, 16/32-bit and colour modes, references for skipped files). | | PSD round trip | 169/169 re-import identically; every adjustment layer and blend mode round-trips | high (within corpus) | Floors in `crates/io/tests/corpus.rs`; raise, never lower. Corpora: `cargo xtask corpus --all` (ours: https://github.com/storytold/photocraft-corpus). | | Smart filters / text / effect shapes in PSDs | Measured on our Photoshop-authored set (https://github.com/storytold/photocraft-corpus, `corpus/photoshop`, 258 files): see the per-group floors in `crates/io/tests/corpus.rs` and `crates/engine/tests/photoshop_oracles.rs`. Smart objects and smart filters now survive PSD save and open (41 corpus files, 206 smart objects, round trip strict; Photoshop opens our exports with live filters); re-rendering Photoshop's smart filters with ours matches 5/30 (was 1/30) | low–medium | Remaining re-render gaps are filter maths (Gaussian/Motion Blur, Unsharp Mask, Emboss, Add Noise RNG) and bicubic placement. | | Core editing (layers, masks, selections, adjustments, filters, transforms) | Broad engine coverage; many interaction bugs fixed after 0.2.0 (adjustment dialogs, Curves, crop, Move/Transform modifiers, gesture origin) | medium | Fixes not yet user-validated. | diff --git a/docs/scorecard.md b/docs/scorecard.md index 30f6d6ce43..282a3cdd60 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -7,8 +7,8 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | Area | Done | Partial | Missing | Key numbers | |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 13 | 47 scenarios: 3 within budget, 22 over budget or failing, 15 tracked only, 6 not run, 7 not measurable yet (baseline 2026-10-05) | -| [File compatibility](#file-compatibility) | 3 | 11 | 13 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 229/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | -| [Tools](#tools) | 8 | 14 | 8 | | +| [File compatibility](#file-compatibility) | 3 | 12 | 12 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | +| [Tools](#tools) | 9 | 14 | 8 | | | [Workspace and UI](#workspace-and-ui) | 0 | 5 | 18 | settings that do nothing: 56 of 140 (target 0) | | [Type](#type) | 0 | 3 | 6 | | | [Automation](#automation) | 3 | 2 | 6 | 627 of 627 menu items live (100.0%) | @@ -84,7 +84,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | Corpus | Directory | Files | Oracle pass floor | Round-trip floor | |---|---|---:|---:|---:| | io corpus | `corpus/psd` | 170 | 146 | 169 | -| psd-tools corpus | `corpus/psd-tools` | 309 | 229 | 307 | +| psd-tools corpus | `corpus/psd-tools` | 309 | 236 | 307 | | photoshop oracles | `corpus/photoshop` | 256 | 133 | 258 | | Id | Target | Status | Issue | Note | @@ -104,7 +104,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | FILE-215-13 | Video layer frames persist in .pcraft; PSD video layers mapped | partial | [#215](https://github.com/storytold/photocraft/issues/215) | format convert.rs:89 + tests/roundtrip.rs:282 persist frames; no PSD video mapping | | FILE-215-14 | User swatch model with ACO/ASE import and export | missing | [#215](https://github.com/storytold/photocraft/issues/215) | panels.rs:954 const SWATCHES: [[u8; 3]; 40] | | FILE-215-15 | Layer Style contour picker, curve editor, presets, .shc import/export | missing | [#215](https://github.com/storytold/photocraft/issues/215) | doc effects.rs:57 Contour model only | -| FILE-215-16 | Advanced Blending (knockout, blend as group, mask hides effects) end to end | missing | [#215](https://github.com/storytold/photocraft/issues/215) | No knockout / blend_interior / transparency_shapes in doc, compose or io | +| FILE-215-16 | Advanced Blending (knockout, blend as group, mask hides effects) end to end | partial | [#215](https://github.com/storytold/photocraft/issues/215) | doc advanced.rs + .pcraft + PSD/layered TIFF (knko infx clbl tsly lmgm vmgm), CPU compose advanced.rs (GPU falls back), blendingOptions command + dialog; 7 psd-tools knockout oracles now pass; knockout-isolated-groups (0.45 % AA edges), advanced-blending, blend-and-clipping still differ | | FILE-215-17 | CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | compose multichannel.rs:1 spot preview for Multichannel only | | FILE-215-18 | Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling | partial | [#215](https://github.com/storytold/photocraft/issues/215) | file_ui.rs:188 last_web in-session only; export_dialog.rs:16 fixed defaults | | FILE-215-19 | Save As JPEG opens a JPEG Options dialog (quality 0-12, format options) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | ExportSettings has jpeg_quality 1-100 only (lib.rs:118) | @@ -151,6 +151,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-214-14 | Vanishing Point window: draw and edit planes, paste, clone in perspective | partial | [#214](https://github.com/storytold/photocraft/issues/214) | filter.vanishingPoint (vp_cmds.rs:15) behind a parameter dialog | | TOOL-214-15 | Content-Aware Fill workspace: paint the sampling area, preview, rotate/scale/mirror, new layer | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Engine sampling auto, rect, custom (edit_menu_cmds.rs:324); generic dialog | | TOOL-214-16 | Liquify: redo (Cmd+Shift+Z) | done | [#214](https://github.com/storytold/photocraft/issues/214) | liquify_ui.rs supports bounded redo through Cmd+Shift+Z and the control channel; new strokes clear redo | +| TOOL-213-17 | Red Eye tool: click a pupil to neutralize the red reflection | done | [#213](https://github.com/storytold/photocraft/issues/213) | paint.redEye (redeye_cmds.rs) + Tool::RedEye in the J flyout; Pupil Size and Darken Amount; catchlights kept; a miss is an error | ## Workspace and UI diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index 3b5546298f..065263bfae 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -113,9 +113,9 @@ note = "doc effects.rs:57 Contour model only" [[item]] id = "FILE-215-16" target = "Advanced Blending (knockout, blend as group, mask hides effects) end to end" -status = "missing" +status = "partial" issue = 215 -note = "No knockout / blend_interior / transparency_shapes in doc, compose or io" +note = "doc advanced.rs + .pcraft + PSD/layered TIFF (knko infx clbl tsly lmgm vmgm), CPU compose advanced.rs (GPU falls back), blendingOptions command + dialog; 7 psd-tools knockout oracles now pass; knockout-isolated-groups (0.45 % AA edges), advanced-blending, blend-and-clipping still differ" [[item]] id = "FILE-215-17"