diff --git a/crates/effects/src/stroke.rs b/crates/effects/src/stroke.rs index 5dc045dd..6980c002 100644 --- a/crates/effects/src/stroke.rs +++ b/crates/effects/src/stroke.rs @@ -19,7 +19,7 @@ mod width; use std::borrow::Cow; -use kurbo::{BezPath, PathEl, PathSeg}; +use kurbo::{BezPath, ParamCurve, ParamCurveNearest, PathEl, PathSeg, Shape}; use vectorcraft_doc::{LineCap, LineJoin, StrokeAlign, StrokeLayer}; pub(crate) use vectorcraft_geom::bez::{kink, segments, subpaths, tangent, unit}; @@ -116,10 +116,58 @@ pub fn line_outline(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64) -> B out.extend(dot_outline(&d.dots, width, st.cap, tol).iter()); out } - (None, None) => kurbo::stroke(line.iter(), &style(st, width), &kurbo::StrokeOpts::default(), tol), + (None, None) => plain_outline(line, &style(st, width), tol), } } +/// `line` stroked with `style`, to fill with the non-zero rule. +/// +/// kurbo offsets a closed subpath to both sides, as two contours. Once half the weight is more +/// than the shape can hold (its inradius), the inner one has crossed the middle and comes out +/// the far side: wound against the outer contour it would cut a hole where the pen in fact +/// overlaps itself (#1396). Such a contour is left out, so the stroke paints solid. +// shortcut: closed subpaths of plain strokes only. An open path that turns back on itself inside +// a wider stroke (and dashes on such a shape) can still cancel; fix it in the offsetting if reported. +fn plain_outline(line: &BezPath, style: &kurbo::Stroke, tol: f64) -> BezPath { + let stroke = |els: &[PathEl]| kurbo::stroke(els.iter().copied(), style, &kurbo::StrokeOpts::default(), tol); + let els = line.elements(); + // Only a stroke at least as wide as a closed subpath's bounds can cross over: everything + // else takes the one call it always did. + let may_cross = |sub: &[PathEl]| { + let b = BezPath::from_vec(sub.to_vec()).bounding_box(); + style.width >= b.width().min(b.height()) + }; + let subs = subpaths(line); + if !subs.iter().any(|(r, closed)| *closed && els.get(r.clone()).is_some_and(may_cross)) { + return stroke(els); + } + let reach = style.width / 2.0 - 2.0 * tol; + let mut out = BezPath::new(); + for (r, closed) in subs { + let Some(sub) = els.get(r) else { continue }; + let outline = stroke(sub); + if !(closed && may_cross(sub)) { + out.extend(outline.iter()); + continue; + } + let sub = BezPath::from_vec(sub.to_vec()); + for (c, _) in subpaths(&outline) { + let Some(contour) = outline.elements().get(c) else { continue }; + if reaches_edge(contour, &sub, reach) { + out.extend(contour.iter().copied()); + } + } + } + out +} + +/// Whether some point of `contour` lies at least `reach` from `path`: an offset contour that +/// never does is nowhere the edge of the stroke. +fn reaches_edge(contour: &[PathEl], path: &BezPath, reach: f64) -> bool { + let far = |p: kurbo::Point| path.segments().all(|s| s.nearest(p, 1e-6).distance_sq >= reach * reach); + kurbo::segments(contour.iter().copied()).any(|s| far(s.eval(0.0)) || far(s.eval(0.5))) +} + /// Append `seg` to `out` (which must already have a current point at its start). pub(crate) fn push_seg(out: &mut BezPath, seg: &PathSeg) { match seg { diff --git a/crates/effects/src/stroke/tests.rs b/crates/effects/src/stroke/tests.rs index acc8a757..a7d8b403 100644 --- a/crates/effects/src/stroke/tests.rs +++ b/crates/effects/src/stroke/tests.rs @@ -37,6 +37,23 @@ fn near(a: f64, b: f64, tol: f64) -> bool { (a - b).abs() <= tol } +/// #1396: a stroke wider than the shape still covers the interior and reaches half the weight past it. +/// A thinner stroke keeps the hole in the middle. +#[test] +fn a_stroke_wider_than_the_shape_covers_the_interior() { + let circle = kurbo::Circle::new(Point::new(0.0, 0.0), 5.0).to_path(0.1); + let st = stroke(40.0, |_| {}); + let outline = line_outline(&circle, &st, 40.0, 0.25); + let b = outline.bounding_box(); + assert!(b.x0 < -24.0 && b.x1 > 24.0, "half the weight past the radius: {b:?}"); + assert!(kurbo::Shape::contains(&outline, Point::ZERO), "the middle of a fat stroke is painted"); + let thin = line_outline(&circle, &stroke(4.0, |_| {}), 4.0, 0.25); + assert!(!kurbo::Shape::contains(&thin, Point::ZERO), "a narrow stroke stays a ring"); + assert!(kurbo::Shape::contains(&thin, Point::new(5.0, 0.0))); + let square = line_outline(&Rect::new(0.0, 0.0, 8.0, 8.0).to_path(0.1), &st, 40.0, 0.25); + assert!(kurbo::Shape::contains(&square, Point::new(4.0, 4.0)), "a fat stroke on a small rectangle covers it"); +} + // ---------------------------------------------------------------- dashes and dots #[test] @@ -304,3 +321,85 @@ fn degenerate_paths_and_patterns_neither_panic_nor_hang() { } assert!(dash(&line(0.0, 1e-9), &dashed(&[1e-12, 1e-12], 0.0)).is_none(), "too fine to advance: solid"); } + +/// Grid points within `reach` of the inside of `path` (not past an open end) that `outline` +/// leaves unpainted. +fn unpainted_near(path: &BezPath, outline: &BezPath, reach: f64) -> Vec { + let b = path.bounding_box().inflate(reach, reach); + let ends: Vec = if is_closed(path) { vec![] } else { path.segments().flat_map(|s| [s.start(), s.end()]).collect() }; + let ends = [ends.first().copied(), ends.last().copied()]; + let mut missed = Vec::new(); + for i in 0..=60 { + for j in 0..=60 { + let p = Point::new(b.x0 + b.width() * f64::from(i) / 60.0, b.y0 + b.height() * f64::from(j) / 60.0); + let nearest = path + .segments() + .map(|s| (s, kurbo::ParamCurveNearest::nearest(&s, p, 1e-6))) + .min_by(|a, b| a.1.distance_sq.total_cmp(&b.1.distance_sq)) + .map(|(s, n)| (s.eval(n.t), n.distance_sq.sqrt())); + let Some((at, dist)) = nearest else { continue }; + let past_end = ends.iter().flatten().any(|e| e.distance(at) < 1e-6); + if dist <= reach && !past_end && outline.winding(p) == 0 { + missed.push(p); + } + } + } + missed +} + +/// #1396: whatever the weight is next to the size of the shape, every point within half the +/// weight of the path is painted (the stroke is the path swept by a pen that wide). +#[test] +fn a_fat_stroke_paints_everything_within_half_its_weight() { + let mut shapes: Vec<(String, BezPath)> = Vec::new(); + for r in [3.0, 6.0, 10.0, 15.0, 20.0, 24.0, 40.0] { + let circle = kurbo::Circle::new(Point::ZERO, r).to_path(0.01); + let mut ellipse = kurbo::Ellipse::new(Point::ZERO, (r, r / 2.0), 0.3).to_path(0.01); + ellipse.close_path(); + let arc = BezPath::from_vec(circle.elements().iter().take(3).copied().collect()); + let mut l = BezPath::new(); + l.move_to((0.0, 0.0)); + for p in [(r * 2.0, 0.0), (r * 2.0, r), (r, r), (r, r * 2.0), (0.0, r * 2.0)] { + l.line_to(p); + } + l.close_path(); + let mut two = circle.clone(); + two.extend(kurbo::Circle::new(Point::new(200.0, 0.0), 60.0).to_path(0.01).iter()); + shapes.extend([ + (format!("circle r={r}"), circle), + (format!("ellipse {r}"), ellipse), + (format!("rect {r}"), Rect::new(0.0, 0.0, r, r * 1.5).to_path(0.1)), + (format!("open arc r={r}"), arc), + (format!("L {r}"), l), + (format!("circle r={r} beside a large one"), two), + ]); + } + let mut failures = Vec::new(); + for (name, path) in &shapes { + for join in [LineJoin::Miter, LineJoin::Round, LineJoin::Bevel] { + let outline = line_outline(path, &stroke(50.0, |s| s.join = join), 50.0, 0.05); + // A bevel stops short of the pen's reach at a corner. + let reach = if join == LineJoin::Bevel { 16.0 } else { 24.5 }; + let missed = unpainted_near(path, &outline, reach); + if !missed.is_empty() { + failures.push(format!("{name} {join:?}: {} unpainted, e.g. {:?} ({} contours)", missed.len(), missed.first(), moves(&outline))); + } + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +/// A stroke the shape has room for keeps its hole, also beside a subpath that has none. +#[test] +fn a_stroke_narrower_than_the_shape_keeps_its_hole() { + let mut path = kurbo::Circle::new(Point::ZERO, 30.0).to_path(0.01); + path.extend(kurbo::Circle::new(Point::new(200.0, 0.0), 10.0).to_path(0.01).iter()); + for join in [LineJoin::Miter, LineJoin::Round, LineJoin::Bevel] { + let outline = line_outline(&path, &stroke(50.0, |s| s.join = join), 50.0, 0.05); + assert_eq!(outline.winding(Point::ZERO), 0, "{join:?}: the hole of the wide ring"); + assert_eq!(outline.winding(Point::new(4.0, 0.0)), 0, "{join:?}: up to its inner edge"); + assert_ne!(outline.winding(Point::new(6.0, 0.0)), 0, "{join:?}"); + assert_ne!(outline.winding(Point::new(200.0, 0.0)), 0, "{join:?}: the small circle is solid"); + assert_eq!(moves(&outline), 3, "{join:?}: two contours for the ring, one for the blob"); + } +} diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 70533ae8..f60a9a11 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -50,6 +50,50 @@ fn stroke_draws_outline_only() { assert_eq!(r.pixel(50, 50), [255, 255, 255, 255]); } +/// #1396: a stroke wider than its ellipse is a solid blob, pixel for pixel the disc a renderer +/// with a native stroke (the PDF and SVG exports) draws, at every alignment and zoom. +#[test] +fn a_stroke_wider_than_its_ellipse_paints_solid() { + let doc = |r: f64, fill: Paint, stroke: Paint, w: f64, align: StrokeAlign| { + let mut d = Document::new(100.0, 100.0); + let id = d.alloc_id(); + let mut n = Node::path(id, shapes::ellipse(Rect::new(50.0 - r, 50.0 - r, 50.0 + r, 50.0 + r)), Appearance::basic(fill, stroke, w)); + if let Some(st) = n.appearance.stroke_mut() { + st.align = align; + } + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + d + }; + let black = || Paint::solid(Color::BLACK); + let render = |d: &Document, zoom: f64| { + let view = Affine::translate((50.0, 50.0)) * Affine::scale(zoom) * Affine::translate((-50.0, -50.0)); + Renderer::new().render(d, 100, 100, view, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }) + }; + // (ellipse radius, weight, alignment, radius of the disc it paints) + for (r, w, align, disc) in [ + (10.0, 60.0, StrokeAlign::Center, 40.0), + (6.0, 50.0, StrokeAlign::Center, 31.0), + (15.0, 50.0, StrokeAlign::Center, 40.0), + (10.0, 30.0, StrokeAlign::Outside, 40.0), + (10.0, 30.0, StrokeAlign::Inside, 10.0), + ] { + for zoom in [0.5, 1.0, 2.0] { + let got = render(&doc(r, Paint::None, black(), w, align), zoom); + if align == StrokeAlign::Outside { + // The shape itself stays unpainted, and the stroke around it has no gap. + assert_eq!(got.pixel(50, 50), [255, 255, 255, 255], "{r} {w} {align:?} at {zoom}"); + assert_eq!(got.pixel(50 + (12.0 * zoom) as u32, 50), [0, 0, 0, 255], "{r} {w} {align:?} at {zoom}"); + continue; + } + let want = render(&doc(disc, black(), Paint::None, 0.0, StrokeAlign::Center), zoom); + let off = + (0..100).flat_map(|y| (0..100).map(move |x| (x, y))).filter(|&(x, y)| got.pixel(x, y)[0].abs_diff(want.pixel(x, y)[0]) > 40).count(); + assert!(off <= 8, "{r} {w} {align:?} at {zoom}: {off} pixels differ from the disc"); + } + } +} + #[test] fn inside_stroke_stays_inside() { let mut d = Document::new(100.0, 100.0);