Merge pull request #1409 from vbasky/fix/1396-fat-stroke
Fix #1396: a stroke wider than its closed shape paints solid
This commit is contained in:
commit
54afb67b4f
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<Point> {
|
||||
let b = path.bounding_box().inflate(reach, reach);
|
||||
let ends: Vec<Point> = 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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue