Shapes: a hairline vector stroke no longer darkens the shape's edge pixels (#397)
* Shapes: a hairline vector stroke no longer darkens the shape's edge pixels A stroked shape with layer effects (or clipped layers) is drawn as our fill plus a vector stroke fitted to Photoshop's own pixels: where those pixels reach past our fill, the stroke takes the alpha the fill doesn't explain, `(pixels - fill) / (1 - fill)`. It kept our stroke's colour for that alpha. When Photoshop's fill covers more of an edge pixel than ours (shapes snapped to whole pixels with Align Edges, our path a fraction off the grid), the fitted alpha is mostly Photoshop's fill, so a 0.25 px grey border on a white card was painted as a solid grey line: the edge came out at the stroke's full colour (122) instead of Photoshop's 220. The fitted stroke now takes the colour that, drawn Normal over our fill, gives back Photoshop's pixel (un-compositing the premultiplied colour), and keeps our colour only where the fitted alpha is negligible. Where Photoshop's pixels agree with our fill this is our stroke's colour, as before. Test: hairline_vector_stroke_keeps_photoshops_edge_pixel_colour (edge pixel 0.478 before, 0.86 after). The compose, gpu, io and engine tests and the psd/io/engine corpus tests pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Shapes: neutral wording in the hairline-stroke fix; test it at 8, 16 and 32 bits Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: EthanSuquet <292885762+EthanSuquet@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Tanish Manku <tmanku.dev@gmail.com>
This commit is contained in:
parent
a1f4387e63
commit
fb924dad16
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue