Files
vectorcraft/crates/effects/src/stroke/tests.rs
T

547 lines
25 KiB
Rust

use kurbo::{BezPath, PathEl, Point, Rect, Shape, Vec2};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{ArrowAlign, Arrowhead, Dash, LineCap, StrokeLayer, WidthProfile};
use super::*;
fn line(x0: f64, x1: f64) -> BezPath {
let mut b = BezPath::new();
b.move_to((x0, 0.0));
b.line_to((x1, 0.0));
b
}
fn square(size: f64) -> BezPath {
Rect::new(0.0, 0.0, size, size).to_path(0.1)
}
fn stroke(width: f64, f: impl FnOnce(&mut StrokeLayer)) -> StrokeLayer {
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), width);
f(&mut st);
st
}
#[test]
fn dash_period_overflowing_to_infinity_is_solid() {
assert!(dash(&line(0.0, 60.0), &dashed(&[f64::MAX, f64::MAX], -1.0)).is_none());
}
fn dashed(pattern: &[f64], offset: f64) -> Dash {
Dash { pattern: pattern.to_vec(), offset, align_corners: false }
}
fn length(bp: &BezPath) -> f64 {
bp.segments().map(|s| kurbo::ParamCurveArclen::arclen(&s, 1e-9)).sum()
}
fn moves(bp: &BezPath) -> usize {
bp.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count()
}
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]
fn zero_length_dashes_are_dots_along_the_tangent() {
let d = dash(&line(0.0, 60.0), &dashed(&[0.0, 6.0], 0.0)).unwrap();
assert!(d.path.elements().is_empty(), "no dash has length");
let xs: Vec<f64> = d.dots.iter().map(|p| p.at.x).collect();
assert_eq!(d.dots.len(), 11, "{xs:?}");
for (i, dot) in d.dots.iter().enumerate() {
assert!(near(dot.at.x, 6.0 * i as f64, 1e-9) && near(dot.at.y, 0.0, 1e-12));
assert!(near(dot.dir.x, 1.0, 1e-12));
}
// The offset shifts the dots along the path.
let d = dash(&line(0.0, 60.0), &dashed(&[0.0, 6.0], 3.0)).unwrap();
assert_eq!(d.dots.len(), 10);
assert!(near(d.dots[0].at.x, 3.0, 1e-9));
// Dots on a curve follow its direction.
let c = kurbo::Circle::new((0.0, 0.0), 50.0).to_path(1e-6);
let d = dash(&c, &dashed(&[0.0, 10.0], 0.0)).unwrap();
for dot in &d.dots {
assert!(dot.dir.dot(dot.at.to_vec2()).abs() < 1e-3, "tangent ⟂ radius at {:?}", dot.at);
}
}
#[test]
fn dots_on_a_closed_path_are_not_doubled_at_the_start() {
let d = dash(&square(60.0), &dashed(&[0.0, 6.0], 0.0)).unwrap();
assert_eq!(d.dots.len(), 40);
}
#[test]
fn dashes_follow_arc_length_and_join_across_the_start_of_closed_paths() {
let d = dash(&line(10.0, 90.0), &dashed(&[10.0, 10.0], 0.0)).unwrap();
assert_eq!(moves(&d.path), 4);
assert!(near(length(&d.path), 40.0, 1e-6));
// An odd pattern repeats with dashes and gaps swapped: 10 on, 10 off, 10 on…
let odd = dash(&line(0.0, 80.0), &dashed(&[10.0], 0.0)).unwrap();
assert_eq!(moves(&odd.path), 4);
// Perimeter 400, dashes of 20 starting 10 into the pattern: the dash at 390..410 runs
// through the start point and is one dash.
let sq = dash(&square(100.0), &dashed(&[20.0, 20.0], 10.0)).unwrap();
assert_eq!(moves(&sq.path), 10);
assert!(near(length(&sq.path), 200.0, 1e-6));
// A pattern without length is a solid line; one dash round a closed path stays closed.
assert!(dash(&line(0.0, 10.0), &dashed(&[0.0, 0.0], 0.0)).is_none());
let whole = dash(&square(10.0), &dashed(&[100.0, 1.0], 0.0)).unwrap();
assert!(is_closed(&whole.path));
// A pattern far too fine for the path is drawn solid rather than as millions of dashes.
assert!(dash(&line(0.0, 1e6), &dashed(&[0.001, 0.001], 0.0)).is_none());
}
/// The dashes of `d` as (start x, end x), for dashes along the x axis.
fn spans_x(d: &Dashed) -> Vec<(f64, f64)> {
let mut out = vec![];
for el in d.path.elements() {
match el {
PathEl::MoveTo(p) => out.push((p.x, p.x)),
PathEl::LineTo(p) => {
if let Some(last) = out.last_mut() {
last.1 = p.x;
}
}
_ => {}
}
}
out
}
/// Ten segments of 10 pt along the x axis from 0 to 100.
fn steps() -> BezPath {
let mut b = BezPath::new();
b.move_to((0.0, 0.0));
for i in 1..=10 {
b.line_to((f64::from(i) * 10.0, 0.0));
}
b
}
/// Whether a segment lies outside x 30..60.
fn outside(s: &PathSeg) -> bool {
s.start().x >= 60.0 || s.end().x <= 30.0
}
#[test]
fn dashes_kept_on_some_segments_keep_the_phase_of_the_whole_line() {
// A period of 7 starting 2 into it: dashes at -2..2, 5..9, 12..16, …
let pattern = dashed(&[4.0, 3.0], 2.0);
let all = dash(&steps(), &pattern).unwrap();
let kept = dash_kept(&steps(), &pattern, &|s| outside(s)).unwrap();
// The dashes of the whole line, cut at x 30 and 60.
let cut: Vec<(f64, f64)> =
spans_x(&all).into_iter().flat_map(|(a, b)| [(a, b.min(30.0)), (a.max(60.0), b)]).filter(|(a, b)| b - a > 1e-9).collect();
let got = spans_x(&kept);
assert_eq!(got.len(), cut.len(), "{got:?} against {cut:?}");
for ((a, b), (c, d)) in got.iter().zip(&cut) {
assert!(near(*a, *c, 1e-9) && near(*b, *d, 1e-9), "{got:?} against {cut:?}");
}
// The dash after the gap starts where the whole line's does, at 61, not at 60.
assert!(got.iter().any(|(a, _)| near(*a, 61.0, 1e-9)), "{got:?}");
// Dots on segments left out are left out (the dot at 60 ends a segment left out).
let dots = dash_kept(&steps(), &dashed(&[0.0, 5.0], 0.0), &|s| outside(s)).unwrap();
let xs: Vec<f64> = dots.dots.iter().map(|d| d.at.x).collect();
let want = [0.0, 5.0, 10.0, 15.0, 20.0, 25.0, 30.0, 65.0, 70.0, 75.0, 80.0, 85.0, 90.0, 95.0, 100.0];
assert!(xs.len() == want.len() && xs.iter().zip(want).all(|(x, w)| near(*x, w, 1e-9)), "{xs:?}");
// Keeping every segment is the whole line's dashing, and keeping none leaves nothing.
assert_eq!(dash_kept(&steps(), &pattern, &|_| true).unwrap().path, all.path);
let none = dash_kept(&steps(), &pattern, &|_| false).unwrap();
assert!(none.path.elements().is_empty() && none.dots.is_empty());
// A pattern too fine for the whole line draws it solid, however few segments are kept.
assert!(dash_kept(&line(0.0, 1e6), &dashed(&[0.001, 0.001], 0.0), &|s| s.start().x < 1.0).is_none());
}
#[test]
fn a_closed_path_with_segments_left_out_is_dashed_in_open_pieces() {
// One dash all the way round: with the right side left out it is no longer closed.
let keep_left = |s: &PathSeg| s.start().x < 5.0 || s.end().x < 5.0;
let whole = dash_kept(&square(10.0), &dashed(&[100.0, 1.0], 0.0), &keep_left).unwrap();
assert!(!is_closed(&whole.path), "{:?}", whole.path.to_svg());
// The bottom side left out: of the 10 dashes, the one through the start point stays one dash,
// the one into the bottom side is cut and the two on it are left out.
let kept = dash_kept(&square(100.0), &dashed(&[20.0, 20.0], 10.0), &|s| s.start().y < 50.0 || s.end().y < 50.0).unwrap();
assert_eq!(moves(&kept.path), 8, "{}", kept.path.to_svg());
assert!(!kept.path.elements().iter().any(|e| matches!(e, PathEl::MoveTo(p) if p.distance(Point::ZERO) < 1e-9)), "{}", kept.path.to_svg());
}
#[test]
fn dot_outlines_take_the_cap_shape_and_wind_like_stroked_dashes() {
let dots = [Dot { at: Point::new(10.0, 0.0), dir: Vec2::new(1.0, 0.0), t: 0.0 }];
let round = dot_outline(&dots, 4.0, LineCap::Round, 1e-4);
assert!(near(round.area(), std::f64::consts::PI * 4.0, 1e-3));
let sq = dot_outline(&dots, 4.0, LineCap::Square, 1e-4);
assert!(near(sq.area(), 16.0, 1e-9));
assert!(sq.contains(Point::new(11.9, 1.9)) && !round.contains(Point::new(11.9, 1.9)));
assert!(dot_outline(&dots, 4.0, LineCap::Butt, 1e-4).elements().is_empty());
// A dot overlapping a dash adds to it under the non-zero rule instead of cancelling it.
let dash_outline = kurbo::stroke(line(0.0, 10.0).iter(), &kurbo::Stroke::new(4.0), &Default::default(), 1e-4);
for o in [&round, &sq] {
assert_eq!(o.area().signum(), dash_outline.area().signum());
}
let st = stroke(4.0, |s| {
s.cap = LineCap::Round;
s.dash = Some(dashed(&[10.0, 1.0, 0.0, 1.0], 0.0));
});
let o = line_outline(&line(0.0, 12.0), &st, 4.0, 1e-3);
assert!(o.contains(Point::new(10.5, 0.0)), "the dot overlapping the dash end is painted");
}
#[test]
fn expand_stroke_region_of_a_round_line_stays_compact() {
use std::f64::consts::PI;
use vectorcraft_geom::{FillRule, PathData, Point, SubPath};
let path = PathData::single(SubPath::polyline(&[Point::new(0.0, 50.0), Point::new(100.0, 50.0)], false));
let st = stroke(10.0, |s| s.cap = vectorcraft_doc::LineCap::Round);
let region = crate::stroke::outline::outline_region(&path, FillRule::NonZero, &st);
let anchors = region.subpaths.iter().map(|s| s.anchors.len()).sum::<usize>();
assert!(anchors <= 8, "expand stroke should use few anchors for a round line, got {anchors}");
let area = region.subpaths.iter().map(|s| s.area()).sum::<f64>().abs();
assert!(near(area, 100.0 * 10.0 + PI * 5.0 * 5.0, 1.0), "{area}");
// Extra colinear anchors along the spine must not multiply outline anchors (#3446).
let dense: Vec<Point> = (0..=20).map(|i| Point::new(i as f64 * 5.0, 50.0)).collect();
let dense_path = PathData::single(SubPath::polyline(&dense, false));
let dense_region = crate::stroke::outline::outline_region(&dense_path, FillRule::NonZero, &st);
let dense_anchors = dense_region.subpaths.iter().map(|s| s.anchors.len()).sum::<usize>();
assert_eq!(dense_anchors, anchors, "colinear spine points should not add outline anchors");
}
/// A line's outline takes its width profile, its dashes (each reading the profile where it sits) or neither.
#[test]
fn line_outline_uses_the_profile_dashes_or_a_plain_stroke() {
let l = line(0.0, 100.0);
let plain = line_outline(&l, &stroke(10.0, |_| {}), 10.0, 1e-3);
assert!(near(plain.area().abs(), 1000.0, 1e-6));
let lens = line_outline(&l, &stroke(10.0, |s| s.profile = Some(WidthProfile::lens())), 10.0, 1e-3);
// The lens is a parabola between its ends: 2/3 of the full-width band.
assert!(near(lens.area().abs(), 2000.0 / 3.0, 0.1));
// Each dash takes its widths from where it sits along the lens (0 → 1 → 0).
let st = stroke(10.0, |s| {
s.profile = Some(WidthProfile::lens());
s.dash = Some(dashed(&[25.0, 25.0], 0.0));
});
assert!(near(line_outline(&l, &st, 10.0, 1e-3).area().abs(), 1000.0 / 3.0, 0.1));
assert!(line_outline(&BezPath::new(), &st, 10.0, 1e-3).elements().is_empty());
}
#[test]
fn aligned_width_doubles_only_closed_inside_and_outside_strokes() {
let mut st = stroke(3.0, |s| s.align = vectorcraft_doc::StrokeAlign::Inside);
assert_eq!(aligned_width(&st, true), 6.0);
assert_eq!(aligned_width(&st, false), 3.0);
st.align = vectorcraft_doc::StrokeAlign::Center;
assert_eq!(aligned_width(&st, true), 3.0);
}
// ---------------------------------------------------------------- arrowheads
fn arrows(kind: Arrowhead, align: ArrowAlign, cap: LineCap) -> StrokeLayer {
stroke(2.0, |s| {
s.end_arrow = Some(kind);
s.arrow_align = align;
s.cap = cap;
})
}
#[test]
fn no_heads_borrows_the_path_and_closed_paths_get_none() {
let l = line(0.0, 50.0);
assert!(matches!(stroke_pieces(&l, &stroke(2.0, |_| {})).line, Cow::Borrowed(_)));
let st = stroke(2.0, |s| {
s.start_arrow = Some(Arrowhead::Triangle);
s.end_arrow = Some(Arrowhead::Triangle);
s.arrow_align = ArrowAlign::Tip;
});
let sq = square(50.0);
let p = stroke_pieces(&sq, &st);
assert!(p.heads.is_empty());
assert_eq!(p.line.as_ref(), &sq);
}
#[test]
fn tip_mode_shortens_the_stroke_by_the_head_and_puts_the_tip_on_the_end_point() {
for kind in Arrowhead::ALL {
let st = arrows(kind.clone(), ArrowAlign::Tip, LineCap::Butt);
let l = line(0.0, 100.0);
let p = stroke_pieces(&l, &st);
let [h] = &p.heads[..] else { panic!("one head") };
assert_eq!(h.tip, Point::new(100.0, 0.0), "{kind:?}");
assert!(near(h.dir.x, 1.0, 1e-9), "{kind:?}");
assert!(h.inset > 0.0 && h.inset <= 8.0, "{kind:?} inset {}", h.inset);
assert!(near(length(&p.line), 100.0 - h.inset, 1e-6), "{kind:?}");
// Nothing reaches past the tip.
assert!(h.outline.bounding_box().x1 <= 100.0 + 0.01, "{kind:?}");
assert!(h.outline.area().abs() > 0.5, "{kind:?}");
}
// Both ends, on a curve: the start head points back along the path.
let mut c = BezPath::new();
c.move_to((0.0, 0.0));
c.curve_to((30.0, -40.0), (70.0, -40.0), (100.0, 0.0));
let st = stroke(2.0, |s| {
s.start_arrow = Some(Arrowhead::Triangle);
s.end_arrow = Some(Arrowhead::Arrow);
s.arrow_align = ArrowAlign::Tip;
});
let p = stroke_pieces(&c, &st);
assert_eq!(p.heads.len(), 2);
assert_eq!(p.heads[0].tip, Point::new(0.0, 0.0));
assert!(p.heads[0].dir.x < 0.0 && p.heads[0].dir.y > 0.0, "{:?}", p.heads[0].dir);
let total = length(&c);
assert!(near(length(&p.line), total - p.heads[0].inset - p.heads[1].inset, 1e-5));
// A path shorter than its heads leaves no line.
let short = line(0.0, 3.0);
let p = stroke_pieces(&short, &arrows(Arrowhead::Triangle, ArrowAlign::Tip, LineCap::Butt));
assert!(p.line.elements().is_empty());
assert_eq!(p.heads.len(), 1);
}
#[test]
fn extend_mode_keeps_the_path_and_overhangs_by_the_inset() {
let l = line(0.0, 100.0);
let st = arrows(Arrowhead::Triangle, ArrowAlign::Extend, LineCap::Butt);
let p = stroke_pieces(&l, &st);
assert!(matches!(p.line, Cow::Borrowed(_)));
let h = &p.heads[0];
assert!(near(h.tip.x, 100.0 + h.inset, 1e-9));
// The same head as in tip mode, moved by the overhang.
let tip = stroke_pieces(&l, &arrows(Arrowhead::Triangle, ArrowAlign::Tip, LineCap::Butt));
let mut moved = tip.heads[0].outline.clone();
moved.apply_affine(kurbo::Affine::translate((h.inset, 0.0)));
let pts = |b: &BezPath| b.elements().iter().filter_map(|e| e.end_point()).collect::<Vec<_>>();
let (a, b) = (pts(&moved), pts(&h.outline));
assert_eq!(a.len(), b.len());
assert!(a.iter().zip(&b).all(|(p, q)| p.distance(*q) < 1e-9));
}
#[test]
fn hollow_heads_are_rings_and_the_stroke_stays_out_of_the_hole() {
for (kind, cap) in [(Arrowhead::CircleOpen, LineCap::Round), (Arrowhead::SquareOpen, LineCap::Square), (Arrowhead::TriangleOpen, LineCap::Butt)] {
let st = arrows(kind.clone(), ArrowAlign::Tip, cap);
let l = line(0.0, 100.0);
let p = stroke_pieces(&l, &st);
let h = &p.heads[0];
let line_o = line_outline(&p.line, &st, st.width, 1e-3);
// A point inside the hole is neither head nor line.
let hole = Point::new(100.0 - 8.0 * 0.45, 0.0);
assert_eq!(h.outline.winding(hole), 0, "{kind:?} hole");
assert_eq!(line_o.winding(hole), 0, "{kind:?} line in the hole");
// The line reaches the back wall, so they overlap (no seam).
let back_wall = Point::new(100.0 - 8.0 + 0.25, 0.0);
assert_ne!(h.outline.winding(back_wall), 0, "{kind:?} wall");
assert_ne!(line_o.winding(back_wall - Vec2::new(0.2, 0.0)), 0, "{kind:?} line reaches the wall");
}
// The open arrow is a chevron: open between its arms.
let l = line(0.0, 100.0);
let p = stroke_pieces(&l, &arrows(Arrowhead::ArrowOpen, ArrowAlign::Tip, LineCap::Butt));
let h = &p.heads[0];
assert_eq!(h.outline.winding(Point::new(93.0, 0.0)), 0);
assert_ne!(h.outline.winding(Point::new(99.0, 0.0)), 0);
}
#[test]
fn round_caps_never_poke_past_the_tip() {
for kind in Arrowhead::ALL {
for scale in [10.0, 100.0, 300.0] {
let mut st = arrows(kind.clone(), ArrowAlign::Tip, LineCap::Round);
st.arrow_scale = (scale, scale);
let l = line(0.0, 100.0);
let p = stroke_pieces(&l, &st);
let o = line_outline(&p.line, &st, st.width, 1e-3);
assert!(o.bounding_box().x1 <= 100.0 + 1e-3, "{kind:?} at {scale}%: {}", o.bounding_box().x1);
}
}
}
// ---------------------------------------------------------------- robustness
#[test]
fn degenerate_paths_and_patterns_neither_panic_nor_hang() {
let finite = |b: &BezPath| b.elements().iter().filter_map(|e| e.end_point()).all(|q| q.x.is_finite() && q.y.is_finite());
let mut point = BezPath::new();
point.move_to((0.0, 0.0));
let lone = point.clone();
point.line_to((0.0, 0.0));
let mut still_curve = BezPath::new();
still_curve.move_to((0.0, 0.0));
still_curve.curve_to((0.0, 0.0), (0.0, 0.0), (0.0, 0.0));
// Drawing on after a ClosePath, and curves whose handles sit on their end points.
let mut mixed = BezPath::new();
mixed.move_to((0.0, 0.0));
mixed.line_to((10.0, 0.0));
mixed.close_path();
mixed.line_to((5.0, 5.0));
mixed.move_to((20.0, 0.0));
mixed.curve_to((20.0, 0.0), (50.0, 50.0), (50.0, 50.0));
mixed.quad_to((50.0, 50.0), (90.0, 0.0));
let paths = [BezPath::new(), lone, point, still_curve, mixed, square(20.0)];
// Negative, non-finite and sub-tolerance entries (the last used to loop forever on a
// zero-length path), and offsets far outside the pattern.
let patterns: [&[f64]; 6] = [&[0.0, 6.0], &[-1.0, 3.0], &[f64::NAN, 1.0], &[1e-12, 1e-12], &[0.0], &[3.0, 0.0]];
for bp in &paths {
for kind in Arrowhead::ALL {
for align in [ArrowAlign::Extend, ArrowAlign::Tip] {
for cap in [LineCap::Butt, LineCap::Round, LineCap::Square] {
for pat in patterns {
for off in [0.0, -7.0, 1e12, f64::INFINITY] {
let st = stroke(2.0, |s| {
s.start_arrow = Some(kind.clone());
s.end_arrow = Some(kind.clone());
s.arrow_align = align;
s.cap = cap;
s.dash = Some(dashed(pat, off));
});
let p = stroke_pieces(bp, &st);
assert!(p.heads.iter().all(|h| finite(&h.outline)), "{bp:?} {kind:?}");
assert!(finite(&line_outline(&p.line, &st, 2.0, 0.01)), "{bp:?} {pat:?} {off}");
}
}
}
}
}
}
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");
}
}
/// A subpixel stroke must retain its geometric thickness even when the normal display tolerance
/// is close to its weight. Original uneven circular arcs, independent of any user fixture.
#[test]
fn thin_curved_strokes_keep_their_width_between_notches() {
use kurbo::{CubicBez, ParamCurve, ParamCurveDeriv, Shape};
for (radius, degrees) in [(450.0, 47.0_f64), (438.0, 77.0), (450.0, 54.0)] {
let a = degrees.to_radians();
let k = 4.0 / 3.0 * (a / 4.0).tan();
let curve = CubicBez::new(
(radius, 0.0),
(radius, k * radius),
(radius * a.cos() + k * radius * a.sin(), radius * a.sin() - k * radius * a.cos()),
(radius * a.cos(), radius * a.sin()),
);
let mut line = BezPath::new();
line.move_to(curve.p0);
line.curve_to(curve.p1, curve.p2, curve.p3);
for width in [0.25, 1.0 / 3.0, 0.5, 1.0] {
let st = StrokeLayer::new(Paint::solid(Color::BLACK), width);
let outline = line_outline(&line, &st, width, 0.25);
for step in 1..40 {
let t = step as f64 / 40.0;
let p = curve.eval(t);
let tangent = curve.deriv().eval(t).to_vec2().normalize();
let normal = kurbo::Vec2::new(-tangent.y, tangent.x);
for side in [-1.0, 1.0] {
assert_ne!(
outline.winding(p + normal * (side * width * 0.45)),
0,
"missing inner band at radius {radius}, {degrees} degrees, width {width}, t {t}"
);
assert_eq!(
outline.winding(p + normal * (side * width * 0.55)),
0,
"outside intended band at radius {radius}, {degrees} degrees, width {width}, t {t}"
);
}
}
}
}
}