diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c10966c1d..2599c9b56 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -2229,14 +2229,16 @@ fn single_plain_fill(n: &Node) -> bool { matches!((painted.next(), painted.next()), (Some(AppearanceItem::Fill(f)), None) if f.blend == vectorcraft_color::BlendMode::Normal && f.opacity >= 1.0 && !matches!(f.paint, vectorcraft_color::Paint::Pattern { .. })) } -/// Number of visible painted fill/stroke items (opacity folding is exact only for one). +/// Number of visible painted fill/stroke items (opacity folding is exact only for one). A dashed +/// stroke is one paint: its dashes are outlined into one path and filled once, so overlapping +/// dashes don't darken where they overlap. fn painted_items(n: &Node) -> usize { n.appearance .items .iter() .filter(|i| match i { AppearanceItem::Fill(f) => f.visible && !f.paint.is_none() && matches!(f.paint, vectorcraft_color::Paint::Solid { .. }), - AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0 && matches!(s.paint, vectorcraft_color::Paint::Solid { .. }) && s.dash.is_none(), + AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0 && matches!(s.paint, vectorcraft_color::Paint::Solid { .. }), }) .count() .max(if n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if f.visible && !matches!(f.paint, vectorcraft_color::Paint::Solid { .. } | vectorcraft_color::Paint::None)) || matches!(i, AppearanceItem::Stroke(s) if s.visible && !matches!(s.paint, vectorcraft_color::Paint::Solid { .. } | vectorcraft_color::Paint::None))) { 2 } else { 0 }) diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 57e0e5304..f41c29dc1 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -956,3 +956,34 @@ fn zero_length_subpaths_draw_dots_with_their_cap() { let butt = dots(vectorcraft_doc::LineCap::Butt); assert_eq!(butt.pixels.iter().filter(|&&p| p != 255).count(), 0, "a butt cap draws no dot"); } + +/// #2788: a dashed solid stroke is one paint, so its object opacity folds into its colour instead +/// of compositing a viewport-wide layer; the result matches the layered one, overlapping dashes +/// included. +#[test] +fn dashed_stroke_opacity_folds_into_its_colour() { + let dashed = |d: &mut Document, object: f32, stroke: f32| { + let mut n = rect_node(d, Rect::new(20.0, 20.0, 80.0, 60.0), Paint::None, Paint::solid(Color::rgb(0.2, 0.4, 0.9)), 6.0); + let st = n.appearance.stroke_mut().unwrap(); + st.dash = Some(vectorcraft_doc::Dash { pattern: vec![4.0, 1.0], ..Default::default() }); + st.cap = vectorcraft_doc::LineCap::Round; + st.opacity = stroke; + n.opacity = object; + n + }; + let doc = |object: f32, stroke: f32| { + let mut d = Document::new(100.0, 100.0); + let n = dashed(&mut d, object, stroke); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + d + }; + let mut d = Document::new(100.0, 100.0); + assert_eq!(painted_items(&dashed(&mut d, 0.6, 1.0)), 1, "a dashed stroke takes the folded fast path"); + let folded = render(&doc(0.6, 1.0)); + let layered = render(&doc(1.0, 0.6)); + assert_ne!(folded.pixel(20, 40), [255, 255, 255, 255], "the stroke is drawn"); + for (a, b) in folded.pixels.chunks(4).zip(layered.pixels.chunks(4)) { + assert!(a.iter().zip(b).all(|(x, y)| (*x as i32 - *y as i32).abs() <= 1), "folded {a:?} vs layered {b:?}"); + } +}