mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-17 20:56:50 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01e330b33d |
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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<VectorStroke<'_>>,
|
||||
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() };
|
||||
|
||||
@@ -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<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`.
|
||||
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::<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 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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<f32> {
|
||||
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<f32> {
|
||||
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<i32>) -> vec4<f32> {
|
||||
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
|
||||
// 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<f32> {
|
||||
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<f32> {
|
||||
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); }
|
||||
}
|
||||
}
|
||||
|
||||
+105
-14
@@ -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<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 canvas (beyond that, compositing everywhere costs less than the copy back).
|
||||
fn local_clip(&self, layer: &Layer) -> Option<Rect> {
|
||||
@@ -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<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 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())
|
||||
|
||||
@@ -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}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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) |
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user