From 01e330b33d204edf6c6ce67e45c4191fae88f001 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 05:45:27 -0400 Subject: [PATCH] fix(compose): clipped layers take the base's shape, not its fill; clipped Dissolve dissolves (#3052) In Photoshop a clipping base's Fill opacity fades only the base's own pixels: the clipped layers show at full strength inside the base's shape (its alpha with masks), and the whole group then blends in the base's mode at the base's opacity. A base at 0 % fill (an invisible shape or text with photos clipped to it) still shows its clipped layers. PhotoCraft composited the clipped layers into the base's content and faded the result by the base's fill, so they vanished. - CPU: plain, shape and effects bases below 100 % fill build the clipping group in the base's shape (`clips_by_shape`, `composite_clipped_in_shape`); with effects the base's alpha stays the effect shape and the group's own alpha becomes a per-pixel fill. - GPU: the same model in the planner (Lerp by the base's alpha, per-pixel fill in the effect init), so these documents stay on the GPU and match the CPU oracle. - A clipped Dissolve layer draws each pixel fully or not at all with the unclipped layer's noise (CPU atop path and GPU atop kernel), instead of blending as Normal. Tests: 1x1 expected values (fill 0/50/100 %, group on/off, base opacity, base mask, effects) at 8/16/32-bit; clipped Dissolve pixel counts, including a Solid Color fill clipped to the Background; GPU/CPU parity across depths, base modes, opacity, masks and effects. Co-Authored-By: Claude Opus 5.5 --- crates/compose/src/advanced.rs | 44 ++++++++++ crates/compose/src/advanced_tests.rs | 108 ++++++++++++++++++++++++ crates/compose/src/effects.rs | 11 ++- crates/compose/src/lib.rs | 67 +++++++++++++-- crates/gpu/src/compose.wgsl | 31 ++++++- crates/gpu/src/plan.rs | 119 +++++++++++++++++++++++---- crates/gpu/tests/parity.rs | 56 ++++++++++++- docs/scorecard.md | 2 +- scorecard/file_compat.toml | 2 +- 9 files changed, 406 insertions(+), 34 deletions(-) diff --git a/crates/compose/src/advanced.rs b/crates/compose/src/advanced.rs index 6809c9e8..58ca4d79 100644 --- a/crates/compose/src/advanced.rs +++ b/crates/compose/src/advanced.rs @@ -194,6 +194,50 @@ pub(crate) fn composite_clipped(clipped: &[Layer], base: &mut Buffer, cx: &Ctx) } } +/// Whether `layer`'s clipping group is built in the base's *shape* rather than inside its faded +/// content: Blend Clipped Layers as Group on, the base below 100 % fill, and something clipped +/// to it. Photoshop fades only the base's own pixels by fill; the clipped layers show at full +/// strength inside the base's alpha with its masks (a 0 %-fill base still shows them). At 100 % +/// fill the two models agree, and the cheaper atop path is kept. +pub fn clips_by_shape(layer: &Layer, clipped: &[Layer]) -> bool { + layer.fill_opacity < 1.0 + && !matches!(layer.content, LayerContent::Adjustment(_)) + && !clips_individually(layer, clipped) + && clipped.iter().any(|c| c.visible) +} + +/// Fade a layer's isolated content by its fill opacity (Normal coverage). +pub(crate) fn fade(buf: &mut Buffer, fill: f32) { + let f = fill.clamp(0.0, 1.0); + for p in &mut buf.px { + p[3] *= f; + } +} + +/// The clipping group of a base below 100 % fill ([`clips_by_shape`]): `group` starts as the +/// base's content at its fill opacity; each visible clipped layer composites onto it as an +/// ordinary layer (its own mode, opacity, fill, masks, effects, Dissolve), and its change is +/// kept only inside `shape` (the base's alpha with masks, unfaded). The caller then blends +/// `group` in the base's mode at the base's opacity. A clipped layer's knockout stops at the +/// bottom of the clipping group (the base at fill). +pub(crate) fn composite_clipped_in_shape(clipped: &[Layer], group: &mut Buffer, shape: &[f32], cx: &Ctx) { + if shape.len() != group.px.len() { + // Never reached (the shape is read from the same rect); keep the base alone. + return; + } + let floor = clipped.iter().any(|c| c.visible && c.advanced.knockout != Knockout::None).then(|| group.clone()); + for c in clipped.iter().filter(|c| c.visible) { + let before = group.clone(); + let ko = floor.as_ref().filter(|_| c.advanced.knockout != Knockout::None); + composite_layer(c, &[], group, cx, ko, Scope::ISOLATED); + for ((p, b), k) in group.px.iter_mut().zip(&before.px).zip(shape) { + if *k < 1.0 { + *p = mix(*b, *p, k.max(0.0)); + } + } + } +} + /// 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 { diff --git a/crates/compose/src/advanced_tests.rs b/crates/compose/src/advanced_tests.rs index 0ca3bdad..69b188d0 100644 --- a/crates/compose/src/advanced_tests.rs +++ b/crates/compose/src/advanced_tests.rs @@ -460,3 +460,111 @@ fn deep_knockout_reveals_a_localized_background() { check(&d, 2, 2, [0.0; 4], "an unlocked layer named Background"); } } + +/// White Background, a red base over the left half (x < 6), a blue Multiply layer clipped to it. +fn clip_doc(depth: SampleType) -> Document { + let mut d = doc(depth, true); + let base = solid(&d, "base", Rect::new(0, 0, 6, 8), RED); + let mut clip = solid(&d, "clip", full(&d), BLUE); + clip.clipped = true; + clip.blend = BlendMode::Multiply; + d.layers.push(base); + d.layers.push(clip); + d +} + +#[test] +fn clipped_layers_take_the_base_shape_not_its_fill() { + for depth in DEPTHS { + let mut d = clip_doc(depth); + check(&d, 2, 2, [0.0, 0.0, 0.0, 1.0], "fill 100 %: blue × red"); + check(&d, 9, 2, WHITE, "outside the base"); + // Fill 0 %: the base's pixels vanish, the clipped Multiply lands on transparency inside + // the base's shape and shows its own colour. + d.layers[1].fill_opacity = 0.0; + check(&d, 2, 2, BLUE, "fill 0 %, as group"); + check(&d, 9, 2, WHITE, "fill 0 %, outside the base"); + // Fill 50 %: half red under a full-strength multiply of blue. + d.layers[1].fill_opacity = 0.5; + check(&d, 2, 2, [0.0, 0.0, 0.5, 1.0], "fill 50 %, as group"); + // The base's opacity still limits the whole group: (0, 0, .5) at 50 % over white. + d.layers[1].opacity = 0.5; + check(&d, 2, 2, [0.5, 0.5, 0.75, 1.0], "fill 50 %, opacity 50 %"); + d.layers[1].opacity = 1.0; + // Blend Clipped Layers as Group off: the base alone, then blue multiplied onto the result. + d.layers[1].advanced.blend_clipped = false; + d.layers[1].fill_opacity = 0.0; + check(&d, 2, 2, BLUE, "fill 0 %, individually"); + d.layers[1].fill_opacity = 0.5; + check(&d, 2, 2, [0.0, 0.0, 0.5, 1.0], "fill 50 %, individually"); + } +} + +#[test] +fn clipped_layers_follow_the_base_mask_at_zero_fill() { + for depth in DEPTHS { + let mut d = clip_doc(depth); + let mut m = photocraft_raster::Surface::with_default(PixelFormat::GRAY8, &[1.0]); + m.fill_rect(Rect::new(0, 0, 3, 8), &[0.0]); + d.layers[1].mask = Some(LayerMask { surface: m, enabled: true, linked: true, density: 1.0, feather: 0.0 }); + d.layers[1].fill_opacity = 0.0; + check(&d, 1, 2, WHITE, "masked out"); + check(&d, 4, 2, BLUE, "inside base and mask"); + } +} + +#[test] +fn clipped_layers_of_a_zero_fill_base_with_effects() { + for depth in DEPTHS { + let mut d = clip_doc(depth); + d.layers[1].effects.items = vec![outside_stroke()]; + d.layers[1].fill_opacity = 0.0; + check(&d, 2, 2, BLUE, "inside the base: the clipped layer at full strength"); + check(&d, 8, 2, RED, "the outside stroke"); + d.layers[1].fill_opacity = 0.5; + check(&d, 2, 2, [0.0, 0.0, 0.5, 1.0], "fill 50 %"); + } +} + +#[test] +fn clipped_dissolve_layer_dissolves() { + for depth in DEPTHS { + let mut d = doc(depth, true); + let base = solid(&d, "base", full(&d), RED); + let mut clip = solid(&d, "clip", full(&d), BLUE); + clip.clipped = true; + clip.blend = BlendMode::Dissolve; + clip.opacity = 0.5; + d.layers.push(base); + d.layers.push(clip); + for (fill, under) in [(1.0, RED), (0.0, WHITE)] { + d.layers[1].fill_opacity = fill; + let out = render(&d, d.bounds()); + let blue = out.px.iter().filter(|p| close(**p, BLUE)).count(); + let rest = out.px.iter().filter(|p| close(**p, under)).count(); + assert_eq!(blue + rest, out.px.len(), "{depth:?} base fill {fill}: only blue or the base, never a mix"); + assert!(blue > 10 && rest > 10, "{depth:?} base fill {fill}: both show ({blue} blue, {rest} other)"); + } + } +} + +#[test] +fn clipped_dissolve_fill_on_the_background_dissolves() { + // #3052: a black Solid Color fill at 50 % in Dissolve, clipped to an opaque white + // Background, draws black or white pixels, never grey (the same pixels as unclipped). + for depth in DEPTHS { + let mut d = doc(depth, true); + let mut fill = Layer::new("fill", LayerContent::Fill(photocraft_doc::Fill::Solid(Color::rgb(0.0, 0.0, 0.0)))); + fill.blend = BlendMode::Dissolve; + fill.opacity = 0.5; + d.layers.push(fill); + let unclipped = render(&d, d.bounds()); + d.layers[1].clipped = true; + let out = render(&d, d.bounds()); + let black = out.px.iter().filter(|p| close(**p, [0.0, 0.0, 0.0, 1.0])).count(); + let white = out.px.iter().filter(|p| close(**p, WHITE)).count(); + assert_eq!(black + white, out.px.len(), "{depth:?}: only black or white, never grey"); + assert!(black > 10 && white > 10, "{depth:?}: both show ({black} black, {white} white)"); + assert!(out.px.iter().zip(&unclipped.px).all(|(a, b)| close(*a, *b)), "{depth:?}: clipping to a full opaque base keeps the same pixels"); + } +} diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index 05bd1f10..9c70c472 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -1795,9 +1795,10 @@ fn knock_out_exterior(map: &mut Map, shape: &Map, fill_opacity: f32) { /// masked, clipped layers applied) plus its effects into `backdrop`. pub fn composite_with_effects(layer: &Layer, content: &Buffer, backdrop: &mut Buffer, maps: &FxMaps, layer_bounds: Rect, patterns: &[Pattern]) { let prepared = PreparedPatterns::new(patterns, PREPARED_PATTERN_BYTES); - composite_with_effects_prepared(layer, content, backdrop, maps, layer_bounds, &prepared, None); + composite_with_effects_prepared(layer, content, backdrop, maps, layer_bounds, &prepared, None, None); } +#[allow(clippy::too_many_arguments)] pub(crate) fn composite_with_effects_prepared( layer: &Layer, content: &Buffer, @@ -1806,6 +1807,7 @@ pub(crate) fn composite_with_effects_prepared( layer_bounds: Rect, patterns: &PreparedPatterns<'_>, vstroke: Option>, + fill_map: Option<&[f32]>, ) { let big = content.rect; let (w, h) = (big.width() as usize, big.height() as usize); @@ -1879,13 +1881,16 @@ pub(crate) fn composite_with_effects_prepared( 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. + // A base below 100 % fill with clipped layers: the clipping group's own alpha per pixel + // (the base at fill, its clipped layers at full strength inside its shape). + let fill_at = |i: usize| fill_map.and_then(|m| m.get(i)).copied().unwrap_or(content_fill); let lay_alpha = |i: usize, p: &[f32; 4], a: f32| { if !inside(a) { 0.0 } else if relative { - content_fill * (kmask(i) * p[3] / a).min(1.0) + fill_at(i) * (kmask(i) * p[3] / a).min(1.0) } else { - content_fill + fill_at(i) } }; 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() }; diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index cb88d002..e3975234 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -36,7 +36,7 @@ use photocraft_geom::Rect; use photocraft_raster::{Rgba8Image, Surface}; use psblend as blend; -pub use advanced::advanced_active; +pub use advanced::{advanced_active, clips_by_shape}; /// Straight-alpha RGBA float buffer covering a rectangle. #[derive(Clone, Debug, PartialEq)] @@ -1316,20 +1316,45 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer } return; } + // Below 100 % fill the clipped layers are built in the base's shape, not its faded content + // (`advanced::clips_by_shape`); the group then blends at the base's opacity alone. + let by_shape = advanced::clips_by_shape(layer, clipped); if let Some((mut content, stroke, mask)) = shape_parts(layer, clipped, rect, cx) { - advanced::composite_clipped(clipped, &mut content, cx); + let fill = if by_shape { + clip_in_shape(layer, clipped, &mut content, cx); + 1.0 + } else { + advanced::composite_clipped(clipped, &mut content, cx); + layer.fill_opacity + }; + let stroke_fill = if by_shape { layer.fill_opacity.clamp(0.0, 1.0) } else { 1.0 }; for (i, (p, s)) in content.px.iter_mut().zip(&stroke.px).enumerate() { - *p = psblend::composite(BlendMode::Normal, *p, *s, 1.0); + *p = psblend::composite(BlendMode::Normal, *p, [s[0], s[1], s[2], s[3] * stroke_fill], 1.0); p[3] *= mask_k(&mask, i); } - blend_into_fill(backdrop, &content, layer.blend, layer.opacity, layer.fill_opacity, 1.0); + blend_into_fill(backdrop, &content, layer.blend, layer.opacity, fill, 1.0); return; } let Some(mut content) = render_content(layer, rect, cx) else { return }; + if by_shape { + clip_in_shape(layer, clipped, &mut content, cx); + blend_into_g(backdrop, &content, layer.blend, layer.opacity, text_gamma(layer)); + return; + } advanced::composite_clipped(clipped, &mut content, cx); blend_into_fill(backdrop, &content, layer.blend, layer.opacity, layer.fill_opacity, text_gamma(layer)); } +/// The clipping group of a base below 100 % fill, in place: `content` (the base's isolated +/// content) is faded by the base's fill, then the clipped layers composite inside its unfaded +/// alpha (`advanced::composite_clipped_in_shape`). Photoshop's special-eight fill blending of +/// the base's own pixels is not applied to the group: the group blends at layer opacity. +fn clip_in_shape(layer: &Layer, clipped: &[Layer], content: &mut Buffer, cx: &Ctx) { + let shape: Vec = content.px.iter().map(|p| p[3]).collect(); + advanced::fade(content, layer.fill_opacity); + advanced::composite_clipped_in_shape(clipped, content, &shape, cx); +} + /// 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; @@ -1343,7 +1368,23 @@ fn composite_layer_effects(layer: &Layer, clipped: &[Layer], backdrop: &mut Buff (content, None) } }; - advanced::composite_clipped(clipped, &mut content, cx); + // Below 100 % fill (and Blend Interior Effects as Group off, where fill fades the content + // alone) the clipped layers are built in the base's shape: the content keeps the base's + // alpha as the effect shape, its colour is the clipping group's, and the group's own alpha + // (base at fill, clipped layers at full strength) replaces fill opacity per pixel. + let fill_map = if advanced::clips_by_shape(layer, clipped) && !layer.advanced.blend_interior { + let mut group = content.clone(); + clip_in_shape(layer, clipped, &mut group, cx); + let fill = layer.fill_opacity.clamp(0.0, 1.0); + let map = content.px.iter().zip(&group.px).map(|(c, g)| if c[3] > 0.0 { (g[3] / c[3]).clamp(fill, 1.0) } else { fill }).collect::>(); + for (c, g) in content.px.iter_mut().zip(&group.px) { + *c = [g[0], g[1], g[2], c[3]]; + } + Some(map) + } else { + advanced::composite_clipped(clipped, &mut content, cx); + None + }; let vstroke = vstroke.as_ref().map(|(s, m)| effects::VectorStroke { stroke: s, mask: m.as_deref() }); let maps = effect_maps(layer, rect, cx); effects::composite_with_effects_prepared( @@ -1354,6 +1395,7 @@ fn composite_layer_effects(layer: &Layer, clipped: &[Layer], backdrop: &mut Buff paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas)), cx.patterns, vstroke, + fill_map.as_deref(), ); } @@ -1662,6 +1704,7 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) { paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas)), cx.patterns, vstroke, + None, ); if let Some(m) = &hide { let plain = Buffer { rect, px: base.px.iter().map(|p| [p[0], p[1], p[2], 1.0]).collect() }; @@ -1677,14 +1720,22 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) { let Some(content) = render_content(layer, rect, cx) else { return }; let opacity = layer.opacity * layer.fill_opacity; let gamma = text_gamma(layer); - for (i, p) in base.px.iter_mut().enumerate() { + let w = rect.width().max(1) as usize; + for (i, (p, s)) in base.px.iter_mut().zip(&content.px).enumerate() { let alpha = p[3]; if alpha <= 0.0 { continue; } - let s = content.px[i]; // Blend as if the base were opaque, then keep the base's alpha. - let r = blend::composite_gamma(layer.blend, [p[0], p[1], p[2], 1.0], s, opacity, gamma); + let r = if layer.blend == BlendMode::Dissolve { + // Dissolve draws each pixel fully or not at all (the same document-coordinate noise + // as an unclipped layer); clipping only limits where it can draw. + let x = rect.x0.saturating_add((i % w) as i32); + let y = rect.y0.saturating_add((i / w) as i32); + if dissolve_noise(x, y) < s[3] * opacity { [s[0], s[1], s[2], 1.0] } else { continue } + } else { + blend::composite_gamma(layer.blend, [p[0], p[1], p[2], 1.0], *s, opacity, gamma) + }; *p = [r[0], r[1], r[2], alpha]; } } diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index edfe140b..97d7b0d5 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -49,6 +49,7 @@ const F_FIRST: u32 = 2048u; // outside strokes: nothing accumulated yet const F_CHANNELS: u32 = 4096u; // lerp: per-channel weights in p0 (channel restrictions) const F_LAB: u32 = 65536u; // Lab document: Normal mixes in CIELAB const F_HDR: u32 = 262144u; // 32-bit float document: HDR blends and Exposure retain values above 1 +const F_SHAPE: u32 = 524288u; // lerp: k = C's alpha (clipped layers kept inside a base's shape) const F_QUANT: u32 = 32768u; // lerp: A rounded to p0.x steps (adjustment results, integer docs) const F_ADD_DIFF: u32 = 16384u; // lerp: A + (B - C) premultiplied (clips on pass-through groups) const F_TEXT_GAMMA: u32 = 8192u; // blend / atop / fx merge: type layer, mix coverage at gamma p4.w @@ -829,6 +830,11 @@ fn fs_atop(in: VOut) -> @location(0) vec4 { let base = textureLoad(tex_a, p, 0); if (base.a <= 0.0) { return base; } let s = textureLoad(tex_b, p, 0); + if (op.mode == M_DISSOLVE) { + // Each pixel fully drawn or not at all (the unclipped layer's noise), inside the base. + if (dissolve_noise(doc_px(p)) < s.a * op.opacity) { return vec4(s.rgb, base.a); } + return base; + } let r = composite_g(op.mode, vec4(base.rgb, 1.0), s, op.opacity, (op.flags & F_TEXT_GAMMA) != 0u); return vec4(r.rgb, base.a); } @@ -875,7 +881,11 @@ fn fs_lerp(in: VOut) -> @location(0) vec4 { if (al <= 0.0) { return vec4(0.0); } return vec4(clamp(pm.rgb / al, vec3(0.0), vec3(1.0)), al); } - let k = op.opacity * mask_value(doc_px(p)); + var k = op.opacity * mask_value(doc_px(p)); + if ((op.flags & F_SHAPE) != 0u) { + // compose::advanced::composite_clipped_in_shape: keep B only inside C's alpha. + k = clamp(textureLoad(tex_c, p, 0).a, 0.0, 1.0); + } let wa = a.a * (1.0 - k); let wb = b.a * k; let alpha = wa + wb; @@ -946,19 +956,27 @@ fn fx_color(d: vec2) -> vec4 { return op.color; } +// The layer's fill inside its effect shape: `opacity`, or per pixel from C's alpha with F_SHAPE +// (a clipping group built in the base's shape below 100 % fill). +fn fx_fill(p: vec2) -> f32 { + if ((op.flags & F_SHAPE) != 0u) { return textureLoad(tex_c, p, 0).a; } + return op.opacity; +} + // Effect chain steps (`kind`): 0 A at `opacity` × alpha; 1 an opaque copy of A (a clipping base // for clipped layers' effects); 2 A's colour with alpha `opacity` inside B's shape (the layer // before its interior effects); 3 A with its alpha × B's alpha (the shape's own alpha); 4 the // vector stroke C over A, relative to the shape B, at `opacity`; 5 A's colour with alpha // `opacity` × A's alpha relative to B's (the layer within a filled shape's outline); 6 the -// outside-stroke coverage seed (0, A's alpha); 7 A with alpha joined with the effect shape (map). +// outside-stroke coverage seed (0, A's alpha); 7 A with alpha joined with the effect shape (map); +// 8 B's colour with A's alpha; 9 B's alpha relative to A's, at least `opacity` (a per-pixel fill). @fragment fn fs_fxinit(in: VOut) -> @location(0) vec4 { let p = local(in.pos); let c = textureLoad(tex_a, p, 0); switch op.kind { case 1: { return vec4(c.rgb, 1.0); } - case 2: { return vec4(c.rgb, select(0.0, op.opacity, c.a > INSIDE_EPS)); } + case 2: { return vec4(c.rgb, select(0.0, fx_fill(p), c.a > INSIDE_EPS)); } case 3: { return vec4(c.rgb, c.a * min(textureLoad(tex_b, p, 0).a, 1.0)); } case 4: { let a = textureLoad(tex_b, p, 0).a; @@ -969,10 +987,15 @@ fn fs_fxinit(in: VOut) -> @location(0) vec4 { case 5: { let a = textureLoad(tex_b, p, 0).a; if (a <= INSIDE_EPS) { return vec4(c.rgb, 0.0); } - return vec4(c.rgb, op.opacity * min(c.a / a, 1.0)); + return vec4(c.rgb, fx_fill(p) * min(c.a / a, 1.0)); } case 6: { return vec4(0.0, c.a, 0.0, 0.0); } case 7: { return vec4(c.rgb, max(c.a, map_value(doc_px(p), 0.0))); } + case 8: { return vec4(textureLoad(tex_b, p, 0).rgb, c.a); } + case 9: { + if (c.a <= 0.0) { return vec4(0.0, 0.0, 0.0, op.opacity); } + return vec4(0.0, 0.0, 0.0, clamp(textureLoad(tex_b, p, 0).a / c.a, op.opacity, 1.0)); + } default: { return vec4(c.rgb, c.a * op.opacity); } } } diff --git a/crates/gpu/src/plan.rs b/crates/gpu/src/plan.rs index fb135dc1..1a1fc9d8 100644 --- a/crates/gpu/src/plan.rs +++ b/crates/gpu/src/plan.rs @@ -389,6 +389,8 @@ pub const F_QUANT: u32 = 32768; pub const F_ADD_DIFF: u32 = 16384; /// Blend / Atop / FxMerge of a type layer: coverage mixed at `psblend::TEXT_GAMMA`. pub const F_TEXT_GAMMA: u32 = 8192; +/// `Lerp` from A to B by C's alpha (clipped layers kept inside a base's shape). +pub const F_SHAPE: u32 = 524288; /// `F_TEXT_GAMMA` for type layers while text gamma blending is on; the gamma goes in `p4.w`. fn gamma_flag(layer: &Layer) -> u32 { @@ -409,6 +411,13 @@ pub const INIT_RELATIVE: i32 = 5; pub const INIT_COVER: i32 = 6; /// A with its alpha joined with the effect shape (the map). pub const INIT_OUTLINE: i32 = 7; +/// B's colour with A's alpha (a clipping group built in the base's shape, over the base's own +/// effect shape). +pub const INIT_GROUP_COLOR: i32 = 8; +/// The clipping group's own alpha relative to the base's (A the base, B the group), at least +/// `opacity` (the base's fill): the per-pixel fill of `INIT_INSIDE` / `INIT_RELATIVE` with +/// `F_SHAPE` (`compose::composite_layer_effects`). +pub const INIT_GROUP_FILL: i32 = 9; /// `MapRef::item` of the layer's effect shape (`compose::layer_shape`) instead of an effect map. pub const SHAPE_MAP: usize = usize::MAX; @@ -669,7 +678,8 @@ impl<'a> Planner<'a> { } if has_effects(layer) { - return self.effects(layer, &visible_clipped, backdrop, false); + let by_shape = photocraft_compose::clips_by_shape(layer, clipped) && !layer.advanced.blend_interior; + return self.effects(layer, &visible_clipped, backdrop, false, by_shape); } if let LayerContent::Shape(sh) = &layer.content @@ -679,14 +689,23 @@ impl<'a> Planner<'a> { // The vector stroke goes above the clipped layers: the fill is the clipping base, // the stroke is laid over the clipped result, then the masks apply to both // (compose::shape_parts). + let by_shape = photocraft_compose::clips_by_shape(layer, clipped); let mut content = self.shape_part(layer, Role::Content, fill); - for c in visible_clipped { - content = self.atop(c, content)?; + if by_shape { + content = self.clip_in_shape(layer, &visible_clipped, content)?; + } else { + for c in visible_clipped { + content = self.atop(c, content)?; + } } let stroke = self.shape_part(layer, Role::Stroke, stroke); let mut p = Pass::new(Kernel::Blend, 0); p.a = Some(content); p.b = Some(stroke); + if by_shape { + // The base's fill fades its stroke; the group then blends at layer opacity. + p.opacity = layer.fill_opacity.clamp(0.0, 1.0); + } let mut content = self.emit(p); if let Some(m) = self.mask_use(layer) { let mut p = Pass::new(Kernel::Mask, 0); @@ -699,7 +718,22 @@ impl<'a> Planner<'a> { p.b = Some(content); p.mode = layer.blend; p.opacity = layer.opacity; - p.fill = layer.fill_opacity; + p.fill = if by_shape { 1.0 } else { layer.fill_opacity }; + return Ok(self.emit(p)); + } + + // Below 100 % fill the clipping group is built in the base's shape, not its faded + // content (compose::clips_by_shape), and blends at the base's opacity alone. + if photocraft_compose::clips_by_shape(layer, clipped) { + let content = self.content(layer)?; + let group = self.clip_in_shape(layer, &visible_clipped, content)?; + let mut p = Pass::new(Kernel::Blend, 0); + p.a = Some(backdrop); + p.b = Some(group); + p.mode = layer.blend; + p.opacity = layer.opacity; + p.flags = gamma_flag(layer); + p.extra[3] = photocraft_compose::text_gamma(layer); return Ok(self.emit(p)); } @@ -744,6 +778,31 @@ impl<'a> Planner<'a> { } } + /// compose::clip_in_shape: the base's `content` at its fill opacity, then each clipped layer + /// composited onto it as an ordinary layer and kept only inside the base's unfaded alpha. + /// Consumes `content`. + fn clip_in_shape(&mut self, layer: &'a Layer, clipped: &[&'a Layer], content: Slot) -> Result { + let shape = self.retain(content); + let empty = self.clear(); + let mut p = Pass::new(Kernel::Blend, 0); + p.a = Some(empty); + p.b = Some(content); + p.opacity = layer.fill_opacity.clamp(0.0, 1.0); + let mut group = self.emit(p); + for &c in clipped { + let before = self.retain(group); + let after = self.layer(c, &[], group)?; + let mut p = Pass::new(Kernel::Lerp, 0); + p.a = Some(before); + p.b = Some(after); + p.c = Some(self.retain(shape)); + p.flags = F_SHAPE; + group = self.emit(p); + } + self.release(shape); + Ok(group) + } + /// The rect a plain layer composites over: its composite bounds when they cover at most half /// the canvas (beyond that, compositing everywhere costs less than the copy back). fn local_clip(&self, layer: &Layer) -> Option { @@ -919,7 +978,7 @@ impl<'a> Planner<'a> { return Ok(self.emit(p)); } if has_effects(layer) { - return self.effects(layer, &[], base, true); + return self.effects(layer, &[], base, true, false); } let content = self.content(layer)?; let mut p = Pass::new(Kernel::Atop, 0); @@ -952,7 +1011,9 @@ impl<'a> Planner<'a> { /// `composite_with_effects` (atop = false) or the clipped-layer variant of `composite_atop` /// (atop = true: effects over the base treated as opaque, keeping the base's alpha). /// Consumes `backdrop`. - fn effects(&mut self, layer: &'a Layer, clipped: &[&'a Layer], backdrop: Slot, atop: bool) -> Result { + /// `by_shape`: the clipped layers are built in the base's shape below 100 % fill + /// (`compose::clips_by_shape`, Blend Interior Effects as Group off). + fn effects(&mut self, layer: &'a Layer, clipped: &[&'a Layer], backdrop: Slot, atop: bool, by_shape: bool) -> Result { let canvas = self.cx.canvas; let region = bounds::effect_region(layer, canvas); let sb = photocraft_compose::paint_bounds(layer).unwrap_or_else(|| bounds::layer_bounds(layer, canvas)); @@ -976,8 +1037,30 @@ impl<'a> Planner<'a> { p.clip = Some(clip); } } - for c in clipped { - content = self.atop(c, content)?; + // Below 100 % fill (`by_shape`) the content keeps the base's alpha as the effect shape and + // takes the clipping group's colour; the group's own alpha becomes a per-pixel fill + // (compose::composite_layer_effects). + let mut fill_map = None; + if by_shape { + let base = self.retain(content); + let group = self.clip_in_shape(layer, clipped, base)?; + let g2 = self.retain(group); + let c2 = self.retain(content); + let mut p = Pass::new(Kernel::FxInit, 0); + p.a = Some(c2); + p.b = Some(g2); + p.adjust_kind = INIT_GROUP_FILL; + p.opacity = layer.fill_opacity.clamp(0.0, 1.0); + fill_map = Some(self.emit(p)); + let mut p = Pass::new(Kernel::FxInit, 0); + p.a = Some(content); + p.b = Some(group); + p.adjust_kind = INIT_GROUP_COLOR; + content = self.emit(p); + } else { + for c in clipped { + content = self.atop(c, content)?; + } } let fx = self.fx.len(); self.fx.push(FxLayer { layer, region, bounds: sb }); @@ -1089,12 +1172,20 @@ impl<'a> Planner<'a> { // The layer: its colour at fill opacity inside its shape; interior effects are painted // relative to the shape (coverage within it), then the shape's alpha applies. - let mut l = if relative { - let c = self.retain(content); - init(self, lay_src, Some(c), INIT_RELATIVE, layer.fill_opacity) - } else { - init(self, lay_src, None, INIT_INSIDE, layer.fill_opacity) - }; + let mut p = Pass::new(Kernel::FxInit, 0); + p.a = Some(lay_src); + if relative { + p.b = Some(self.retain(content)); + } + p.adjust_kind = if relative { INIT_RELATIVE } else { INIT_INSIDE }; + p.opacity = layer.fill_opacity; + p.clip = Some(clip); + if let Some(m) = fill_map { + // The per-pixel fill of a clipping group built in the base's shape. + p.c = Some(m); + p.flags = F_SHAPE; + } + let mut l = self.emit(p); for &(_, e) in &rev { if let Effect::PatternOverlay { common, name, id, scale, angle, link, phase } = e && let Some(pat) = photocraft_doc::pattern::find(self.cx.patterns, id, name).filter(|p| p.is_paintable()) diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index 47837a8b..be266fb1 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -767,6 +767,52 @@ fn hard_stop_effects_and_gradient_maps_request_cpu() { assert!(photocraft_gpu::plan(&d).is_ok()); } +#[test] +fn clipped_layers_take_the_base_shape_below_full_fill() { + // compose::clips_by_shape: a base below 100 % fill keeps its clipped layers at full + // strength inside its alpha × mask; the group blends at the base's mode and opacity. A + // clipped Dissolve layer dissolves (#3052). + let Some(mut g) = gpu() else { return }; + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for (base_blend, opacity, masked) in [(BlendMode::Normal, 1.0, false), (BlendMode::Multiply, 0.7, true), (BlendMode::Screen, 0.9, false)] { + for fill in [1.0, 0.5, 0.0] { + let mut d = Document::new("t", Size::new(64, 48), ColorMode::Rgb, depth); + d.layers.push(noise_layer("bg", d.pixel_format(), d.bounds(), 1, 0.3)); + let mut base = noise_layer("base", d.pixel_format(), Rect::new(8, 6, 50, 40), 16, 0.0); + base.blend = base_blend; + base.opacity = opacity; + base.fill_opacity = fill; + if masked { + base.mask = Some(mask(Rect::new(0, 0, 64, 48), 22, 1.0)); + } + let mut c1 = noise_layer("multiply", d.pixel_format(), Rect::new(0, 0, 40, 48), 17, 0.4); + c1.clipped = true; + c1.blend = BlendMode::Multiply; + c1.opacity = 0.8; + let mut c2 = noise_layer("dissolve", d.pixel_format(), Rect::new(20, 0, 64, 48), 18, 0.3); + c2.clipped = true; + c2.blend = BlendMode::Dissolve; + c2.opacity = 0.6; + let mut c3 = Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)); + c3.clipped = true; + c3.opacity = 0.5; + d.layers.extend([base, c1, c2, c3]); + check(&mut g, &d, &format!("{depth:?} base {base_blend:?} opacity {opacity} fill {fill} masked {masked}")); + // With effects: the base's alpha stays the effect shape, the group's own alpha + // its per-pixel fill. + d.layers[1].effects.items = vec![ + Effect::Stroke(stroke(3.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(0.9, 0.8, 0.1)))), + Effect::InnerShadow(shadow(BlendMode::Multiply, 0.6, 120.0, 3.0, 4.0, 0.0)), + Effect::DropShadow(shadow(BlendMode::Multiply, 0.7, 90.0, 4.0, 5.0, 0.0)), + ]; + let st = photocraft_gpu::render_to_vec_stats(&mut g.comp, &g.device, &g.queue, &d, d.bounds()); + assert!(st.is_ok(), "planned on the GPU"); + check(&mut g, &d, &format!("{depth:?} fx base {base_blend:?} opacity {opacity} fill {fill} masked {masked}")); + } + } + } +} + #[test] fn fills_and_dissolve() { let Some(mut g) = gpu() else { return }; @@ -1971,9 +2017,13 @@ fn stroked_shapes_with_clipped_layers() { c.clipped = true; c.blend = BlendMode::Screen; d.layers.extend([l, c]); - let st = photocraft_gpu::render_to_vec_stats(&mut g.comp, &g.device, &g.queue, &d, d.bounds()); - assert!(st.is_ok(), "planned on the GPU"); - check(&mut g, &d, &format!("{depth:?} stroked shape + clipped {blend:?} masked {masked}")); + // Below 100 % fill the clipped layer is kept inside the shape's alpha, unfaded. + for fill in [1.0, 0.4, 0.0] { + d.layers[1].fill_opacity = fill; + let st = photocraft_gpu::render_to_vec_stats(&mut g.comp, &g.device, &g.queue, &d, d.bounds()); + assert!(st.is_ok(), "planned on the GPU"); + check(&mut g, &d, &format!("{depth:?} stroked shape + clipped {blend:?} masked {masked} fill {fill}")); + } } } } diff --git a/docs/scorecard.md b/docs/scorecard.md index 78a08645..c6ed4b5f 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -113,7 +113,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 | done | [#215](https://github.com/storytold/photocraft/issues/215) | engine presets/swatches.rs (RGB/HSB/CMYK/Lab/Gray + verbatim colour books, persisted in prefs) + swatch_cmds.rs (swatches.*); psd aco.rs (v1/v2) and ase.rs (groups, spot/global) read+write; ui swatches_ui.rs, Color Picker Add to Swatches | | FILE-215-15 | Layer Style contour picker, curve editor, presets, .shc import/export | partial | [#2912](https://github.com/storytold/photocraft/issues/2912) | Layer Style dialog: contour thumbnail + preset picker (12 standard shapes, own control points) and Anti-aliased on Drop/Inner Shadow, Outer/Inner Glow, Satin, Bevel Gloss Contour and Contour element; Contour Editor (add/drag/delete, Corner, Input/Output, Preset, Load…/Save… .shc per Adobe's public spec, psd shc.rs); corner points render (compose contour_curve_lut) and map to PSD Cnty. Missing: a user contour library (New…, picker Load/Save/Replace Contours), Anti-aliased doesn't change the render yet, .shc not yet checked against a Photoshop-written file | -| 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-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; clipped layers take the base's shape below 100 % fill and a clipped Dissolve layer dissolves (CPU + GPU, 2026-10-12); 7 psd-tools knockout oracles now pass; knockout-isolated-groups (0.45 % AA edges), advanced-blending (0.01 % of pixels), blend-and-clipping (thumbnail, 2.4 %) 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) | diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index e6943b57..adbfc858 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -115,7 +115,7 @@ id = "FILE-215-16" target = "Advanced Blending (knockout, blend as group, mask hides effects) end to end" status = "partial" issue = 215 -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" +note = "doc advanced.rs + .pcraft + PSD/layered TIFF (knko infx clbl tsly lmgm vmgm), CPU compose advanced.rs (GPU falls back), blendingOptions command + dialog; clipped layers take the base's shape below 100 % fill and a clipped Dissolve layer dissolves (CPU + GPU, 2026-10-12); 7 psd-tools knockout oracles now pass; knockout-isolated-groups (0.45 % AA edges), advanced-blending (0.01 % of pixels), blend-and-clipping (thumbnail, 2.4 %) still differ" [[item]] id = "FILE-215-17"