mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 10:02:38 +00:00
A dashed stroke of a path with more than 1,024 path elements was outlined whole in every frame after an edit. It is now outlined in and around the view like a solid stroke. dash_kept runs the pattern along every segment and keeps only the dashes and dots on the segments near the view, so they sit where dashing the whole path puts them and the pixels are the same. A pattern too fine to draw is still drawn solid, and that solid stroke is cropped too. At 1600 x 1200 the frame after one anchor moves takes 14 ms instead of 102 ms on a filled closed path of 50,000 anchors with 6 pt dashes and 4 pt gaps. anchor_bench now includes that document.
504 lines
23 KiB
Rust
504 lines
23 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");
|
|
}
|
|
|
|
#[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);
|
|
assert!(near(lens.area().abs(), 500.0, 1e-6));
|
|
// Each dash takes its widths from where it sits along the lens (0 → 1 → 0): the dash over
|
|
// 0..25 averages a quarter of the width, the one over 50..75 three quarters.
|
|
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(), 62.5 + 187.5, 1e-6));
|
|
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, 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, 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, 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);
|
|
s.end_arrow = Some(kind);
|
|
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");
|
|
}
|
|
}
|