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 <noreply@anthropic.com>
This commit is contained in:
parent
184c226a76
commit
01e330b33d
|
|
@ -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).
|
/// 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).
|
/// 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 {
|
pub(crate) fn clips_individually(layer: &Layer, clipped: &[Layer]) -> bool {
|
||||||
|
|
|
||||||
|
|
@ -460,3 +460,111 @@ fn deep_knockout_reveals_a_localized_background() {
|
||||||
check(&d, 2, 2, [0.0; 4], "an unlocked layer named 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
/// 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]) {
|
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);
|
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(
|
pub(crate) fn composite_with_effects_prepared(
|
||||||
layer: &Layer,
|
layer: &Layer,
|
||||||
content: &Buffer,
|
content: &Buffer,
|
||||||
|
|
@ -1806,6 +1807,7 @@ pub(crate) fn composite_with_effects_prepared(
|
||||||
layer_bounds: Rect,
|
layer_bounds: Rect,
|
||||||
patterns: &PreparedPatterns<'_>,
|
patterns: &PreparedPatterns<'_>,
|
||||||
vstroke: Option<VectorStroke<'_>>,
|
vstroke: Option<VectorStroke<'_>>,
|
||||||
|
fill_map: Option<&[f32]>,
|
||||||
) {
|
) {
|
||||||
let big = content.rect;
|
let big = content.rect;
|
||||||
let (w, h) = (big.width() as usize, big.height() as usize);
|
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;
|
let inside = |a: f32| a > INSIDE_EPS;
|
||||||
// Within an outline (or a split-off vector stroke) the content's own transparency (a fading
|
// 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.
|
// 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| {
|
let lay_alpha = |i: usize, p: &[f32; 4], a: f32| {
|
||||||
if !inside(a) {
|
if !inside(a) {
|
||||||
0.0
|
0.0
|
||||||
} else if relative {
|
} 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 {
|
} 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() };
|
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() };
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ use photocraft_geom::Rect;
|
||||||
use photocraft_raster::{Rgba8Image, Surface};
|
use photocraft_raster::{Rgba8Image, Surface};
|
||||||
use psblend as blend;
|
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.
|
/// Straight-alpha RGBA float buffer covering a rectangle.
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
|
@ -1316,20 +1316,45 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
|
||||||
}
|
}
|
||||||
return;
|
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) {
|
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() {
|
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);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
let Some(mut content) = render_content(layer, rect, cx) else { 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);
|
advanced::composite_clipped(clipped, &mut content, cx);
|
||||||
blend_into_fill(backdrop, &content, layer.blend, layer.opacity, layer.fill_opacity, text_gamma(layer));
|
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<f32> = 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`.
|
/// A layer with effects (and its clipping group) onto `backdrop`.
|
||||||
fn composite_layer_effects(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx) {
|
fn composite_layer_effects(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx) {
|
||||||
let rect = backdrop.rect;
|
let rect = backdrop.rect;
|
||||||
|
|
@ -1343,7 +1368,23 @@ fn composite_layer_effects(layer: &Layer, clipped: &[Layer], backdrop: &mut Buff
|
||||||
(content, None)
|
(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::<Vec<f32>>();
|
||||||
|
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 vstroke = vstroke.as_ref().map(|(s, m)| effects::VectorStroke { stroke: s, mask: m.as_deref() });
|
||||||
let maps = effect_maps(layer, rect, cx);
|
let maps = effect_maps(layer, rect, cx);
|
||||||
effects::composite_with_effects_prepared(
|
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)),
|
paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas)),
|
||||||
cx.patterns,
|
cx.patterns,
|
||||||
vstroke,
|
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)),
|
paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas)),
|
||||||
cx.patterns,
|
cx.patterns,
|
||||||
vstroke,
|
vstroke,
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
if let Some(m) = &hide {
|
if let Some(m) = &hide {
|
||||||
let plain = Buffer { rect, px: base.px.iter().map(|p| [p[0], p[1], p[2], 1.0]).collect() };
|
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 Some(content) = render_content(layer, rect, cx) else { return };
|
||||||
let opacity = layer.opacity * layer.fill_opacity;
|
let opacity = layer.opacity * layer.fill_opacity;
|
||||||
let gamma = text_gamma(layer);
|
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];
|
let alpha = p[3];
|
||||||
if alpha <= 0.0 {
|
if alpha <= 0.0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let s = content.px[i];
|
|
||||||
// Blend as if the base were opaque, then keep the base's alpha.
|
// 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];
|
*p = [r[0], r[1], r[2], alpha];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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_CHANNELS: u32 = 4096u; // lerp: per-channel weights in p0 (channel restrictions)
|
||||||
const F_LAB: u32 = 65536u; // Lab document: Normal mixes in CIELAB
|
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_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_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_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
|
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<f32> {
|
||||||
let base = textureLoad(tex_a, p, 0);
|
let base = textureLoad(tex_a, p, 0);
|
||||||
if (base.a <= 0.0) { return base; }
|
if (base.a <= 0.0) { return base; }
|
||||||
let s = textureLoad(tex_b, p, 0);
|
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);
|
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);
|
return vec4(r.rgb, base.a);
|
||||||
}
|
}
|
||||||
|
|
@ -875,7 +881,11 @@ fn fs_lerp(in: VOut) -> @location(0) vec4<f32> {
|
||||||
if (al <= 0.0) { return vec4(0.0); }
|
if (al <= 0.0) { return vec4(0.0); }
|
||||||
return vec4(clamp(pm.rgb / al, vec3(0.0), vec3(1.0)), al);
|
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 wa = a.a * (1.0 - k);
|
||||||
let wb = b.a * k;
|
let wb = b.a * k;
|
||||||
let alpha = wa + wb;
|
let alpha = wa + wb;
|
||||||
|
|
@ -946,19 +956,27 @@ fn fx_color(d: vec2<i32>) -> vec4<f32> {
|
||||||
return op.color;
|
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<i32>) -> 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
|
// 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
|
// 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
|
// 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
|
// 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
|
// `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
|
@fragment
|
||||||
fn fs_fxinit(in: VOut) -> @location(0) vec4<f32> {
|
fn fs_fxinit(in: VOut) -> @location(0) vec4<f32> {
|
||||||
let p = local(in.pos);
|
let p = local(in.pos);
|
||||||
let c = textureLoad(tex_a, p, 0);
|
let c = textureLoad(tex_a, p, 0);
|
||||||
switch op.kind {
|
switch op.kind {
|
||||||
case 1: { return vec4(c.rgb, 1.0); }
|
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 3: { return vec4(c.rgb, c.a * min(textureLoad(tex_b, p, 0).a, 1.0)); }
|
||||||
case 4: {
|
case 4: {
|
||||||
let a = textureLoad(tex_b, p, 0).a;
|
let a = textureLoad(tex_b, p, 0).a;
|
||||||
|
|
@ -969,10 +987,15 @@ fn fs_fxinit(in: VOut) -> @location(0) vec4<f32> {
|
||||||
case 5: {
|
case 5: {
|
||||||
let a = textureLoad(tex_b, p, 0).a;
|
let a = textureLoad(tex_b, p, 0).a;
|
||||||
if (a <= INSIDE_EPS) { return vec4(c.rgb, 0.0); }
|
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 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 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); }
|
default: { return vec4(c.rgb, c.a * op.opacity); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -389,6 +389,8 @@ pub const F_QUANT: u32 = 32768;
|
||||||
pub const F_ADD_DIFF: u32 = 16384;
|
pub const F_ADD_DIFF: u32 = 16384;
|
||||||
/// Blend / Atop / FxMerge of a type layer: coverage mixed at `psblend::TEXT_GAMMA`.
|
/// Blend / Atop / FxMerge of a type layer: coverage mixed at `psblend::TEXT_GAMMA`.
|
||||||
pub const F_TEXT_GAMMA: u32 = 8192;
|
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`.
|
/// `F_TEXT_GAMMA` for type layers while text gamma blending is on; the gamma goes in `p4.w`.
|
||||||
fn gamma_flag(layer: &Layer) -> u32 {
|
fn gamma_flag(layer: &Layer) -> u32 {
|
||||||
|
|
@ -409,6 +411,13 @@ pub const INIT_RELATIVE: i32 = 5;
|
||||||
pub const INIT_COVER: i32 = 6;
|
pub const INIT_COVER: i32 = 6;
|
||||||
/// A with its alpha joined with the effect shape (the map).
|
/// A with its alpha joined with the effect shape (the map).
|
||||||
pub const INIT_OUTLINE: i32 = 7;
|
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.
|
/// `MapRef::item` of the layer's effect shape (`compose::layer_shape`) instead of an effect map.
|
||||||
pub const SHAPE_MAP: usize = usize::MAX;
|
pub const SHAPE_MAP: usize = usize::MAX;
|
||||||
|
|
@ -669,7 +678,8 @@ impl<'a> Planner<'a> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if has_effects(layer) {
|
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
|
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 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
|
// the stroke is laid over the clipped result, then the masks apply to both
|
||||||
// (compose::shape_parts).
|
// (compose::shape_parts).
|
||||||
|
let by_shape = photocraft_compose::clips_by_shape(layer, clipped);
|
||||||
let mut content = self.shape_part(layer, Role::Content, fill);
|
let mut content = self.shape_part(layer, Role::Content, fill);
|
||||||
for c in visible_clipped {
|
if by_shape {
|
||||||
content = self.atop(c, content)?;
|
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 stroke = self.shape_part(layer, Role::Stroke, stroke);
|
||||||
let mut p = Pass::new(Kernel::Blend, 0);
|
let mut p = Pass::new(Kernel::Blend, 0);
|
||||||
p.a = Some(content);
|
p.a = Some(content);
|
||||||
p.b = Some(stroke);
|
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);
|
let mut content = self.emit(p);
|
||||||
if let Some(m) = self.mask_use(layer) {
|
if let Some(m) = self.mask_use(layer) {
|
||||||
let mut p = Pass::new(Kernel::Mask, 0);
|
let mut p = Pass::new(Kernel::Mask, 0);
|
||||||
|
|
@ -699,7 +718,22 @@ impl<'a> Planner<'a> {
|
||||||
p.b = Some(content);
|
p.b = Some(content);
|
||||||
p.mode = layer.blend;
|
p.mode = layer.blend;
|
||||||
p.opacity = layer.opacity;
|
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));
|
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<Slot, Unsupported> {
|
||||||
|
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 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).
|
/// the canvas (beyond that, compositing everywhere costs less than the copy back).
|
||||||
fn local_clip(&self, layer: &Layer) -> Option<Rect> {
|
fn local_clip(&self, layer: &Layer) -> Option<Rect> {
|
||||||
|
|
@ -919,7 +978,7 @@ impl<'a> Planner<'a> {
|
||||||
return Ok(self.emit(p));
|
return Ok(self.emit(p));
|
||||||
}
|
}
|
||||||
if has_effects(layer) {
|
if has_effects(layer) {
|
||||||
return self.effects(layer, &[], base, true);
|
return self.effects(layer, &[], base, true, false);
|
||||||
}
|
}
|
||||||
let content = self.content(layer)?;
|
let content = self.content(layer)?;
|
||||||
let mut p = Pass::new(Kernel::Atop, 0);
|
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`
|
/// `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).
|
/// (atop = true: effects over the base treated as opaque, keeping the base's alpha).
|
||||||
/// Consumes `backdrop`.
|
/// Consumes `backdrop`.
|
||||||
fn effects(&mut self, layer: &'a Layer, clipped: &[&'a Layer], backdrop: Slot, atop: bool) -> Result<Slot, Unsupported> {
|
/// `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<Slot, Unsupported> {
|
||||||
let canvas = self.cx.canvas;
|
let canvas = self.cx.canvas;
|
||||||
let region = bounds::effect_region(layer, canvas);
|
let region = bounds::effect_region(layer, canvas);
|
||||||
let sb = photocraft_compose::paint_bounds(layer).unwrap_or_else(|| bounds::layer_bounds(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);
|
p.clip = Some(clip);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for c in clipped {
|
// Below 100 % fill (`by_shape`) the content keeps the base's alpha as the effect shape and
|
||||||
content = self.atop(c, content)?;
|
// 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();
|
let fx = self.fx.len();
|
||||||
self.fx.push(FxLayer { layer, region, bounds: sb });
|
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
|
// 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.
|
// relative to the shape (coverage within it), then the shape's alpha applies.
|
||||||
let mut l = if relative {
|
let mut p = Pass::new(Kernel::FxInit, 0);
|
||||||
let c = self.retain(content);
|
p.a = Some(lay_src);
|
||||||
init(self, lay_src, Some(c), INIT_RELATIVE, layer.fill_opacity)
|
if relative {
|
||||||
} else {
|
p.b = Some(self.retain(content));
|
||||||
init(self, lay_src, None, INIT_INSIDE, layer.fill_opacity)
|
}
|
||||||
};
|
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 {
|
for &(_, e) in &rev {
|
||||||
if let Effect::PatternOverlay { common, name, id, scale, angle, link, phase } = e
|
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())
|
&& let Some(pat) = photocraft_doc::pattern::find(self.cx.patterns, id, name).filter(|p| p.is_paintable())
|
||||||
|
|
|
||||||
|
|
@ -767,6 +767,52 @@ fn hard_stop_effects_and_gradient_maps_request_cpu() {
|
||||||
assert!(photocraft_gpu::plan(&d).is_ok());
|
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]
|
#[test]
|
||||||
fn fills_and_dissolve() {
|
fn fills_and_dissolve() {
|
||||||
let Some(mut g) = gpu() else { return };
|
let Some(mut g) = gpu() else { return };
|
||||||
|
|
@ -1971,9 +2017,13 @@ fn stroked_shapes_with_clipped_layers() {
|
||||||
c.clipped = true;
|
c.clipped = true;
|
||||||
c.blend = BlendMode::Screen;
|
c.blend = BlendMode::Screen;
|
||||||
d.layers.extend([l, c]);
|
d.layers.extend([l, c]);
|
||||||
let st = photocraft_gpu::render_to_vec_stats(&mut g.comp, &g.device, &g.queue, &d, d.bounds());
|
// Below 100 % fill the clipped layer is kept inside the shape's alpha, unfaded.
|
||||||
assert!(st.is_ok(), "planned on the GPU");
|
for fill in [1.0, 0.4, 0.0] {
|
||||||
check(&mut g, &d, &format!("{depth:?} stroked shape + clipped {blend:?} masked {masked}"));
|
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}"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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-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-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-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-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-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) |
|
| 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) |
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@ id = "FILE-215-16"
|
||||||
target = "Advanced Blending (knockout, blend as group, mask hides effects) end to end"
|
target = "Advanced Blending (knockout, blend as group, mask hides effects) end to end"
|
||||||
status = "partial"
|
status = "partial"
|
||||||
issue = 215
|
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]]
|
[[item]]
|
||||||
id = "FILE-215-17"
|
id = "FILE-215-17"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue