From 2e29033b50fcab39ce7521f75d4f40e57b96badd Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 22 Oct 2026 21:41:01 +0200 Subject: [PATCH] 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) --- crates/compose/src/stroke_tests.rs | 22 ++++++++++++++++++++++ crates/gpu/src/compose.wgsl | 7 +++---- crates/gpu/tests/it/parity.rs | 18 ++++++++++++++++++ 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/crates/compose/src/stroke_tests.rs b/crates/compose/src/stroke_tests.rs index f5d87a9fea..3608327628 100644 --- a/crates/compose/src/stroke_tests.rs +++ b/crates/compose/src/stroke_tests.rs @@ -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); diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index a6c0c63579..119b8e274a 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -1187,6 +1187,7 @@ fn fs_fxinit(in: VOut) -> @location(0) vec4 { // 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 { 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; diff --git a/crates/gpu/tests/it/parity.rs b/crates/gpu/tests/it/parity.rs index 464ad1f1a2..a1af01aa25 100644 --- a/crates/gpu/tests/it/parity.rs +++ b/crates/gpu/tests/it/parity.rs @@ -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]);