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
@@ -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 = false@@ -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 }@@ -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);@@ -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)]);+}@@ -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();@@ -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) {@@ -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 };@@ -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: