diff --git a/crates/compose/src/shape_split.rs b/crates/compose/src/shape_split.rs index 28cedb6d..3c90249a 100644 --- a/crates/compose/src/shape_split.rs +++ b/crates/compose/src/shape_split.rs @@ -67,7 +67,11 @@ pub fn split(sh: &ShapeLayer, canvas: Rect) -> Option<(Surface, Surface)> { /// Fits the stroke part to the shape's own pixels: where they reach past the fill, the stroke /// covers what the fill doesn't (`(pixels - fill) / (1 - fill)`), in the pixels' colour where we -/// drew no stroke. +/// drew no stroke, and elsewhere in the colour that, drawn over the fill, gives back the pixels' +/// colour. Our stroke's own colour would be wrong where the file's fill covers more of the pixel +/// than ours (shapes snapped to whole pixels with a stroke thinner than a pixel): the fitted +/// stroke is then mostly the file's fill, and an edge drawn in the stroke's full colour is far +/// too dark. fn fit_stroke(fill: &Surface, stroke: &mut Surface, cache: &Surface, canvas: Rect) { // Where either has pixels (the stroke may reach past the shape's pixels, and vice versa). let (cb, sb) = (crate::bounds::content_bounds(cache), crate::bounds::content_bounds(stroke)); @@ -92,7 +96,16 @@ fn fit_stroke(fill: &Surface, stroke: &mut Surface, cache: &Surface, canvas: Rec let mut p = *sp; if fp[3] < 1.0 - 1e-3 { let a = ((cp[3] - fp[3]) / (1.0 - fp[3])).clamp(0.0, 1.0); - p = if sp[3] <= 0.0 { [cp[0], cp[1], cp[2], a] } else { [sp[0], sp[1], sp[2], a] }; + p = if sp[3] <= 0.0 { + [cp[0], cp[1], cp[2], a] + } else if a > 1e-3 { + // Normal over the fill: a·p + f·(1 - a)·fill = pixels (premultiplied). + let k = fp[3] * (1.0 - a); + let un = |i: usize| ((cp[3] * cp[i] - k * fp[i]) / a).clamp(0.0, 1.0); + [un(0), un(1), un(2), a] + } else { + [sp[0], sp[1], sp[2], a] + }; } out.extend_from_slice(&p); } diff --git a/crates/compose/src/stroke_tests.rs b/crates/compose/src/stroke_tests.rs index 6a74758c..eaa4a619 100644 --- a/crates/compose/src/stroke_tests.rs +++ b/crates/compose/src/stroke_tests.rs @@ -165,3 +165,31 @@ fn several_strokes_stack_top_instance_first() { assert!(close4(px(&d, 20, 30), [1.0, 1.0, 0.0, 1.0])); assert!(close4(px(&d, 14, 30), [1.0; 4])); } + +#[test] +fn hairline_vector_stroke_keeps_the_files_edge_pixel_colour() { + // The file's shape was snapped to whole pixels (Align Edges) with a 0.25 px stroke: its edge + // pixels are opaque and mostly fill. Our path sits off the pixel grid, so our fill covers only + // part of an edge pixel; fitting the stroke to the file's pixels must keep their colour there, + // not paint the edge in the stroke's full colour. At every depth. + for (sample, fmt) in [(SampleType::U8, PixelFormat::RGBA8), (SampleType::U16, PixelFormat::RGBA16), (SampleType::F32, PixelFormat::RGBA32F)] { + let mut d = Document::with_background("t", Size::new(40, 40), ColorMode::Rgb, sample, Color::WHITE); + let edge = 0.86; + let vs = ShapeStroke { width: 0.25, paint: Fill::Solid(Color::rgb(0.48, 0.48, 0.48)), ..ShapeStroke::default() }; + let path = Path::new(vec![Subpath::polygon(&[(10.3, 10.3), (29.7, 10.3), (29.7, 29.7), (10.3, 29.7)])]); + let mut cache = photocraft_raster::Surface::new(fmt); + cache.fill_rect(Rect::new(10, 10, 30, 30), &[edge, edge, edge, 1.0]); + cache.fill_rect(Rect::new(11, 11, 29, 29), &[1.0, 1.0, 1.0, 1.0]); + let sh = ShapeLayer { path, fill: Some(Fill::Solid(Color::WHITE)), stroke: Some(vs), live: None, cache: Some(cache), psd_raw: None }; + let mut l = Layer::new("shape", LayerContent::Shape(sh)); + // Any layer effect sends the shape through the fill/stroke split. + l.effects.items = vec![Effect::default_drop_shadow()]; + d.layers.push(l); + for (x, y) in [(10, 20), (29, 20), (20, 10), (20, 29)] { + let p = px(&d, x, y); + assert!(close4(p, [edge, edge, edge, 1.0]), "{sample:?} edge pixel ({x}, {y}): {p:?}"); + } + // The interior keeps the fill. + assert!(close4(px(&d, 20, 20), [1.0, 1.0, 1.0, 1.0]), "{sample:?} {:?}", px(&d, 20, 20)); + } +}