collective

Browse / github.com/storytold/photocraft / patch

Strokes: an outside stroke fills the edge pixels beneath an unfilled line (#9659)

Fix line antialiasing in the drawing canvas - #2485 If i draw a line in the canvas it has no antialiasing. It becomes more visible if I add a stroke on it: <img width="172" height="131" alt="Image" src="https://github.com/user-attachments/assets/6ee21a04-f66d-4f0f-800a-66fb592b3d19" /> In this example i drew a red line with a brown stroke. You can clearly see the dots between the line and the stroke

Status
open
Score
0.00
Adopters
0 other operators
Author
SofiaAFigueredo (imported from pull request #9663, open)
Base
7ac7b8b106acccf6826d45ed5c9e481dfe341035
Size
+116 −25 in 8 files
Review
pending
Shared
2026-10-28
Id
d47e1f2e685bd636e94228d7

How it went

No other operator has reported using it yet.

Diff

raw · contributors/people.toml crates/compose/src/effects.rs crates/compose/src/effects/composite.rs crates/compose/src/stroke_tests.rs crates/compose/src/tests.rs crates/gpu/src/compose.wgsl crates/gpu/src/plan/planner.rs crates/gpu/tests/it/parity.rs

diff --git a/contributors/people.toml b/contributors/people.toml--- a/contributors/people.toml+++ b/contributors/people.toml@@ -282,3 +282,7 @@ sync_github_name = false [people.jasallen] real_name = "Jason DeBoever" sync_github_name = true++[people.SofiaAFigueredo]+real_name = "Sofia Alves Figueredo"+sync_github_name = falsediff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs--- a/crates/compose/src/effects.rs+++ b/crates/compose/src/effects.rs@@ -194,6 +194,9 @@ pub struct FxMaps {     vdout: Option<Vec<f32>>,     /// Shape layers: exact Euclidean distance outside the vector outline (from local coverage).     vdout_exact: Option<Vec<f32>>,+    /// Shape layers: the vector outline's coverage of each edge pixel, estimated from its+    /// neighbours (the share of an edge pixel an outside stroke fills beneath the line).+    vcov: Option<Vec<f32>>,     /// `shape` joins the alpha with a filled shape's outline (`crate::effect_outline`).     outline: bool,     /// Frames of gradient strokes by outside width (bits): see [`stroke_frame`].@@ -217,7 +220,8 @@ impl FxMaps {             + usize::from(self.dout.is_some())             + usize::from(self.dout_exact.is_some())             + usize::from(self.vdout.is_some())-            + usize::from(self.vdout_exact.is_some()))+            + usize::from(self.vdout_exact.is_some())+            + usize::from(self.vcov.is_some()))             * n             * 4     }diff --git a/crates/compose/src/effects/composite.rs b/crates/compose/src/effects/composite.rs--- a/crates/compose/src/effects/composite.rs+++ b/crates/compose/src/effects/composite.rs@@ -27,8 +27,9 @@ pub(crate) fn map_count(layer: &Layer) -> usize {     let has_stroke = items.iter().any(|e| matches!(e, Effect::Stroke(_)));     let outside = usize::from(out_widths.iter().any(|&w| !stroke_outside_exact(w))) + usize::from(out_widths.iter().any(|&w| stroke_outside_exact(w)));     let stroke = if has_stroke { 1 + outside } else { 0 };-    let vector_outline =-        if has_stroke && matches!(layer.content, photocraft_doc::LayerContent::Shape(_)) && crate::effect_outline(layer).is_none() { outside } else { 0 };+    // An unfilled shape's vector outline: its distance fields and its estimated edge coverage.+    let needs_vector_coverage = has_stroke && matches!(layer.content, photocraft_doc::LayerContent::Shape(_)) && crate::effect_outline(layer).is_none();+    let vector_outline = if needs_vector_coverage { outside + 1 } else { 0 };     1 + per + stroke + vector_outline } @@ -85,11 +86,11 @@ pub(crate) fn build_maps_prepared(     // Without an outline (unfilled shapes), estimate the vector outline from local coverage; it     // picks the chamfer or the exact transform the same way.     let outline = crate::effect_outline(layer).is_some();-    let (vdout, vdout_exact) = if has_stroke && vector_shape && !outline {+    let (vdout, vdout_exact, vcov) = if has_stroke && vector_shape && !outline {         let lc = local_coverage(&shape);-        (chamfer_out.then(|| dist_outside_by(&lc, Metric::Chamfer)), exact_out.then(|| dist_outside_by(&lc, Metric::Euclidean)))+        (chamfer_out.then(|| dist_outside_by(&lc, Metric::Chamfer)), exact_out.then(|| dist_outside_by(&lc, Metric::Euclidean)), Some(lc.v))     } else {-        (None, None)+        (None, None, None)     };     let mut frames: Vec<(u32, Rect)> = Vec::new();     for e in &items {@@ -102,7 +103,7 @@ pub(crate) fn build_maps_prepared(             }         }     }-    FxMaps { rect, shape, per, bevel_paint, din, dout, dout_exact, vdout, vdout_exact, outline, frames }+    FxMaps { rect, shape, per, bevel_paint, din, dout, dout_exact, vdout, vdout_exact, vcov, outline, frames } }  /// Far outside any shape (distance fill for cropped distance fields).@@ -362,6 +363,7 @@ pub(crate) fn composite_with_effects_prepared(     // coverage × opacity not yet taken by strokes above; for Normal strokes this equals stacking     // them top over bottom.     if strokes.iter().any(|st| widths(st).1 > 0.0) {+        let estimated = if vector_outline { maps.vcov.as_ref().map(|c| maps.crop_vec(c, big, 1.0)) } else { None };         let base = work.clone();         let mut acc = vec![[0.0f32; 4]; w * h]; // premultiplied Σ share·blended         let mut cover = vec![0.0f32; w * h];@@ -384,8 +386,15 @@ pub(crate) fn composite_with_effects_prepared(             let mut share = vec![0.0f32; w * h];             for (i, sh) in share.iter_mut().enumerate() {                 let band = (out_w + 0.5 - d[i]).clamp(0.0, 1.0);-                let k =-                    if inside(shape.v[i]) { if vector_shape && !maps.outline { 0.0 } else { band * outline_share(shape.v[i], lay.px[i][3]) } } else { band };+                // An unfilled outline has no path to measure an edge pixel against: its coverage is+                // estimated from its neighbours, so the stroke fills the rest of the pixel beneath+                // the edge (a hard 0 here let the backdrop show between a line and its stroke).+                let k = if inside(shape.v[i]) {+                    let cov = estimated.as_ref().and_then(|c| c.get(i)).copied().unwrap_or(shape.v[i]);+                    band * outline_share(cov, lay.px[i][3])+                } else {+                    band+                };                 let mut k = k * st.common.opacity;                 if st.common.blend == BlendMode::Dissolve {                     let (x, y) = (i64::from(big.x0) + (i % w) as i64, i64::from(big.y0) + (i / w) as i64);diff --git a/crates/compose/src/stroke_tests.rs b/crates/compose/src/stroke_tests.rs--- a/crates/compose/src/stroke_tests.rs+++ b/crates/compose/src/stroke_tests.rs@@ -323,3 +323,31 @@ fn outside_stroke_on_partial_fill_raster_layer_keeps_interior_free_of_stroke() {     let stroke_pixel = px(&d, 13, 20);     assert!(close4(stroke_pixel, [0.0, 0.0, 1.0, 1.0]), "outside stroke must be blue: {stroke_pixel:?}"); }++/// An open line (no fill) with an outside stroke: the stroke fills the rest of every anti-aliased+/// edge pixel beneath the line, so the backdrop never shows between them (#9659). Rendered one+/// pixel at a time, as a tile would: the edge coverage comes from the whole layer.+#[test]+fn outside_stroke_fills_the_edge_pixels_beneath_an_unfilled_line() {+    let mut d = doc(80, 50);+    let red = ShapeStroke { width: 6.0, paint: Fill::Solid(Color::rgb(1.0, 0.0, 0.0)), align: StrokeAlign::Center, ..ShapeStroke::default() };+    let path = Path::new(vec![Subpath::polyline(&[(5.0, 12.3), (40.0, 22.7), (75.0, 30.1)])]);+    let mut sh = ShapeLayer { path, fill: None, stroke: Some(red), live: None, cache: None, psd_raw: None, ..ShapeLayer::default() };+    sh.cache = Some(photocraft_vector::render_shape(&sh, PixelFormat::RGBA8, Rect::new(0, 0, 80, 50)));+    let mut layer = Layer::new("line", LayerContent::Shape(sh));+    layer.effects.items = vec![stroke(4.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(0.1, 0.0, 0.0)))];+    d.layers.push(layer);+    let mut light = Vec::new();+    for y in 0..50 {+        for x in 8..72 {+            let xf = x as f32;+            let centre = if xf < 40.0 { 12.3 + (xf - 5.0) * 10.4 / 35.0 } else { 22.7 + (xf - 40.0) * 7.4 / 35.0 };+            // On the line and its stroke: red and the stroke's dark red have no green, the white backdrop does.+            let p = px(&d, x, y);+            if (y as f32 + 0.5 - centre).abs() < 3.6 && p[1] > 0.12 {+                light.push((x, y, p));+            }+        }+    }+    assert!(light.is_empty(), "the backdrop shows between the line and its stroke at {:?}", &light[..light.len().min(6)]);+}diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs--- a/crates/compose/src/tests.rs+++ b/crates/compose/src/tests.rs@@ -2068,6 +2068,17 @@ fn the_effect_map_estimate_counts_the_maps_building_takes() {         l.effects.items = fx;         d.layers.push(l);     }+    // An unfilled shape (an open line) with an outside stroke also builds the vector outline's+    // distance field and coverage (#9659); a filled one doesn't.+    for (fill, name) in [(None, "unfilled line"), (Some(Fill::Solid(Color::rgb(0.3, 0.6, 0.9))), "filled shape")] {+        let path = photocraft_doc::vector::Path::new(vec![photocraft_doc::vector::Subpath::polyline(&[(14.0, 110.0), (30.0, 125.0), (44.0, 112.0)])]);+        let line = photocraft_doc::vector::ShapeStroke { width: 4.0, ..Default::default() };+        let mut sh = photocraft_doc::vector::ShapeLayer { path, fill, stroke: Some(line), live: None, cache: None, psd_raw: None, ..Default::default() };+        sh.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds()));+        let mut l = Layer::new(name, LayerContent::Shape(sh));+        l.effects.items = vec![stroke(2.0, StrokePosition::Outside), stroke(60.0, StrokePosition::Outside)];+        d.layers.push(l);+    }     let patterns = pattern::PreparedPatterns::new(&d.patterns, pattern::PREPARED_PATTERN_BYTES);     let cx = Ctx::for_doc(&d, &patterns);     let mut todo = Vec::new();diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl--- a/crates/gpu/src/compose.wgsl+++ b/crates/gpu/src/compose.wgsl@@ -1262,22 +1262,32 @@ fn fs_fxstroke(in: VOut) -> @location(0) vec4<f32> {     var cover = 0.0;     var lay_a = 0.0;     let vector = (op.flags & F_VECTOR) != 0u;-    if (!first || outline || !vector) {-        let cv = textureLoad(tex_c, p, 0);-        cover = cv.r;-        lay_a = cv.g;-    }+    let cv = textureLoad(tex_c, p, 0);+    cover = cv.r;+    lay_a = cv.g;     var k = map_value(d, 0.0);     if (outline) {         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), none beneath a fully covered pixel.+        // Shape layers without an outline: the same, with the edge pixel's coverage estimated as+        // its alpha relative to the largest around it (`effects::local_coverage`).+        var edge = a;         if (vector) {-            k = 0.0;-        } else {-            k = k * outline_share(a, lay_a);+            let dims = vec2<i32>(textureDimensions(tex_b));+            var mx = a;+            for (var dy = -1; dy <= 1; dy++) {+                for (var dx = -1; dx <= 1; dx++) {+                    let q = p + vec2<i32>(dx, dy);+                    if (q.x >= 0 && q.y >= 0 && q.x < dims.x && q.y < dims.y) {+                        mx = max(mx, textureLoad(tex_b, q, 0).a);+                    }+                }+            }+            edge = min(a / mx, 1.0);         }+        k = k * outline_share(edge, lay_a);     }     var cov = k * op.opacity;     if (op.mode == M_DISSOLVE) {diff --git a/crates/gpu/src/plan/planner.rs b/crates/gpu/src/plan/planner.rs--- a/crates/gpu/src/plan/planner.rs+++ b/crates/gpu/src/plan/planner.rs@@ -42,7 +42,8 @@ pub const F_NO_LAYER: u32 = 32; /// carries the layer's alpha in `.g`). pub const F_OUTLINE: u32 = 131072; -/// Shape layer (outside strokes never show inside it).+/// Shape layer (outside strokes never show inside it, but for the part of an edge pixel the+/// outline's estimated coverage leaves). pub const F_VECTOR: u32 = 64;  /// Merge onto an opaque clipping base, keeping its alpha.@@ -982,13 +983,13 @@ impl<'a> Planner<'a> {             .filter(|(_, st)| stroke_widths(st).1 > 0.0)             .collect();         if !outs.is_empty() {-            let (mut acc, mut cover): (Option<Slot>, Option<Slot>) = (None, None);-            if outline || !vector_shape {-                // The layer's alpha rides along the coverage: `effects::outline_share`, and for-                // pixel layers the stroke's share beneath a partly covering layer (fill < 100 %).-                let ll = self.retain(l);-                cover = Some(init(self, ll, None, INIT_COVER, 1.0));-            }+            // The layer's alpha rides along the coverage: `effects::outline_share`, the stroke's+            // share beneath a partly covering layer (fill < 100 %) and under the edge pixels of+            // a shape's estimated outline. The stroke pass reads it unconditionally, so a shape+            // layer whose only effect is an outside stroke now pays this one init pass too.+            let mut acc: Option<Slot> = None;+            let ll = self.retain(l);+            let mut cover: Option<Slot> = Some(init(self, ll, None, INIT_COVER, 1.0));             for (n, &(i, st)) in outs.iter().enumerate() {                 let paint = self.fx_paint(&st.paint, anchor);                 let flags = F_STROKE_OUT | if vector_shape { F_VECTOR } else { 0 } | if outline { F_OUTLINE } else { 0 } | if n == 0 { F_FIRST } else { 0 };diff --git a/crates/gpu/tests/it/parity.rs b/crates/gpu/tests/it/parity.rs--- a/crates/gpu/tests/it/parity.rs+++ b/crates/gpu/tests/it/parity.rs@@ -2122,6 +2122,30 @@ fn stroke_effects_on_filled_and_stroked_shapes() {     } } +/// An open line (no fill) with Outside strokes: the stroke fills the rest of the line's edge+/// pixels beneath it, as on the CPU (#9659).+#[test]+fn outside_strokes_under_the_edge_of_an_unfilled_line() {+    let Some(mut g) = gpu() else { return };+    use photocraft_doc::vector::{Path, ShapeLayer, ShapeStroke, StrokeAlign, Subpath};+    for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {+        for opacity in [1.0, 0.5] {+            let mut d = fx_doc(90, 70, depth);+            let path = Path::new(vec![Subpath::polyline(&[(8.0, 20.3), (45.0, 38.7), (82.0, 24.1)])]);+            let line = ShapeStroke { width: 6.0, align: StrokeAlign::Center, opacity, paint: Fill::Solid(Color::rgb(0.9, 0.1, 0.1)), ..ShapeStroke::default() };+            let mut sh = ShapeLayer { path, fill: None, stroke: Some(line), live: None, cache: None, psd_raw: None, ..ShapeLayer::default() };+            sh.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds()));+            let mut l = Layer::new("line", LayerContent::Shape(sh));+            l.effects.items = vec![+                Effect::Stroke(stroke(2.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(1.0, 1.0, 1.0)))),+                Effect::Stroke(stroke(4.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(0.1, 0.0, 0.0)))),+            ];+            d.layers.push(l);+            fx_check(&mut g, &d, &format!("{depth:?} unfilled line at {opacity}"));+        }+    }+}+ /// Blend If on the GPU matches `compose::apply_blend_if` (#4395): this-layer and underlying /// ranges, hard and split points, per channel, on raster, masked, adjustment and clipped layers, /// with channel restrictions, and in grayscale. Hard points are compared on 8-bit documents only: