mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
Integration: no Outside stroke share under opaque pixels on the GPU too (#5937)
The PR gives an Outside stroke no share beneath fully covered pixels on the CPU, but no test failed without it: under a Normal layer the opaque pixel hides the stroke anyway. It shows with Blend Interior Effects as Group on a Multiply layer: main multiplied the red interior with the blue stroke beneath it (black). New test outside_stroke_under_multiply_layer_leaves_interior_to_backdrop (raster and filled shape) fails on main's effects.rs, passes with the PR. The GPU compositor mirrored the old math (outline_share 1 at full coverage, and 1 under an opaque pixel layer); compose.wgsl now matches the CPU. Blend Interior Effects as Group itself renders on the CPU, so on the GPU this is consistency; a parity case pins non-Normal layers with an Outside stroke. Co-authored-by: seotarek <30214297+seotarek@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
seotarek
Claude Opus 5.5
parent
f9efad49d6
commit
2e29033b50
@@ -272,6 +272,28 @@ fn outside_stroke_on_raster_layer_does_not_paint_inside_opaque_pixels() {
|
||||
assert!(close4(stroke_pixel, [0.0, 0.0, 1.0, 1.0]), "outside stroke must be blue, got: {stroke_pixel:?}");
|
||||
}
|
||||
|
||||
/// Beneath an opaque pixel the Outside stroke has no share, so a Multiply layer blended as a
|
||||
/// group with its interior effects (where the layer's mode meets what lies beneath it inside the
|
||||
/// style) multiplies the backdrop there, not the stroke colour (red × blue would be black), for a
|
||||
/// raster layer and a filled shape alike.
|
||||
#[test]
|
||||
fn outside_stroke_under_multiply_layer_leaves_interior_to_backdrop() {
|
||||
let mut pixels = photocraft_raster::Surface::new(PixelFormat::RGBA8);
|
||||
pixels.fill_rect(Rect::new(15, 15, 25, 25), &[1.0, 0.0, 0.0, 1.0]);
|
||||
let layers = [("raster", Layer::new("raster", LayerContent::Raster(pixels))), ("shape", shape(15.0, 25.0, Fill::Solid(Color::rgb(1.0, 0.0, 0.0)), None))];
|
||||
for (what, mut l) in layers {
|
||||
let mut d = doc(40, 40);
|
||||
l.blend = BlendMode::Multiply;
|
||||
l.advanced.blend_interior = true;
|
||||
l.effects.items = vec![stroke(3.0, StrokePosition::Outside, blue())];
|
||||
d.layers.push(l);
|
||||
let center = px(&d, 20, 20);
|
||||
assert!(close4(center, [1.0, 0.0, 0.0, 1.0]), "{what}: interior must be red × white, got: {center:?}");
|
||||
let stroke_pixel = px(&d, 13, 20);
|
||||
assert!(close4(stroke_pixel, [0.0, 0.0, 1.0, 1.0]), "{what}: outside stroke must be blue, got: {stroke_pixel:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outside_stroke_on_zero_fill_raster_layer_keeps_interior_clear() {
|
||||
let mut d = Document::new("t", Size::new(40, 40), ColorMode::Rgb, SampleType::U8);
|
||||
|
||||
@@ -1187,6 +1187,7 @@ fn fs_fxinit(in: VOut) -> @location(0) vec4<f32> {
|
||||
// beneath a layer of alpha `l` (disjoint areas).
|
||||
fn outline_share(cov: f32, l: f32) -> f32 {
|
||||
let outside = max(1.0 - clamp(cov, 0.0, 1.0), 0.0);
|
||||
if (outside <= 1e-6) { return 0.0; }
|
||||
let rest = 1.0 - clamp(l, 0.0, 1.0);
|
||||
if (rest <= 1e-6) { return 1.0; }
|
||||
return min(outside / rest, 1.0);
|
||||
@@ -1252,13 +1253,11 @@ fn fs_fxstroke(in: VOut) -> @location(0) vec4<f32> {
|
||||
k = k * outline_share(a, lay_a);
|
||||
} else if (a > INSIDE_EPS) {
|
||||
// Pixel layers: the stroke shows beneath only through the part the shape leaves
|
||||
// (`effects.rs`): (1 - a) / (1 - layer alpha), 1 under an opaque layer.
|
||||
// (`effects.rs`): (1 - a) / (1 - layer alpha), none beneath a fully covered pixel.
|
||||
if (vector) {
|
||||
k = 0.0;
|
||||
} else if (lay_a >= 1.0) {
|
||||
k = 1.0;
|
||||
} else {
|
||||
k = clamp((1.0 - clamp(a, 0.0, 1.0)) / (1.0 - lay_a), 0.0, 1.0);
|
||||
k = k * outline_share(a, lay_a);
|
||||
}
|
||||
}
|
||||
var cov = k * op.opacity;
|
||||
|
||||
@@ -1877,6 +1877,24 @@ fn layer_effects_opacity_fill_blend_and_off_canvas() {
|
||||
d.layers.push(l);
|
||||
fx_check(&mut g, &d, &format!("outside stroke at fill {fill}"));
|
||||
}
|
||||
// An Outside stroke has no share beneath fully opaque pixels (#5861): non-Normal layers with
|
||||
// an Outside stroke on a soft blob and a hard square. (Blend Interior Effects as Group, where
|
||||
// that share shows through the layer's mode, is composited on the CPU.)
|
||||
for blend in [BlendMode::Multiply, BlendMode::Screen] {
|
||||
let mut d = fx_doc(80, 80, SampleType::U8);
|
||||
let mut l = blob("ring", d.pixel_format(), 40.0, 40.0, 20.0, [0.9, 0.1, 0.1]);
|
||||
l.effects.items = vec![Effect::Stroke(stroke(4.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(0.1, 0.2, 0.9))))];
|
||||
l.blend = blend;
|
||||
d.layers.push(l);
|
||||
fx_check(&mut g, &d, &format!("outside stroke under a {blend:?} blob"));
|
||||
let mut d = fx_doc(40, 40, SampleType::U8);
|
||||
let mut l = Layer::raster("square", d.pixel_format());
|
||||
l.surface_mut().unwrap().fill_rect(Rect::new(15, 15, 25, 25), &[1.0, 0.0, 0.0, 1.0]);
|
||||
l.effects.items = vec![Effect::Stroke(stroke(3.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(0.0, 0.0, 1.0))))];
|
||||
l.blend = blend;
|
||||
d.layers.push(l);
|
||||
fx_check(&mut g, &d, &format!("outside stroke under a {blend:?} square"));
|
||||
}
|
||||
// Shapes partly off the canvas (their effects reach back in) and a masked effect layer.
|
||||
let mut d = fx_doc(90, 70, SampleType::U8);
|
||||
let mut l = blob("edge", d.pixel_format(), 4.0, 66.0, 18.0, [0.9, 0.9, 0.2]);
|
||||
|
||||
Reference in New Issue
Block a user