diff --git a/crates/compose/src/advanced.rs b/crates/compose/src/advanced.rs index 6809c9e865..58ca4d79e0 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 0ca3bdadb9..69b188d026 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 05bd1f10da..9c70c4728b 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 cb88d002d9..e3975234b0 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 edfe140be4..97d7b0d540 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 fb135dc135..1a1fc9d874 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 47837a8b65..be266fb13a 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 78a0864549..c6ed4b5fa7 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 e6943b578c..adbfc85809 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"