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

239 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Dashes fitted to corners and path ends, and variable-width strokes with dashes and joins.
use kurbo::{BezPath, ParamCurveArclen, PathEl, Point, Rect, Shape};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Dash, LineCap, LineJoin, StrokeLayer, WidthProfile};
use super::*;
fn fitted(pattern: &[f64], offset: f64) -> Dash {
Dash { pattern: pattern.to_vec(), offset, align_corners: true }
}
fn stroke(width: f64, f: impl FnOnce(&mut StrokeLayer)) -> StrokeLayer {
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), width);
f(&mut st);
st
}
fn line(x1: f64) -> BezPath {
let mut b = BezPath::new();
b.move_to((0.0, 0.0));
b.line_to((x1, 0.0));
b
}
fn moves(bp: &BezPath) -> usize {
bp.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count()
}
fn length(bp: &BezPath) -> f64 {
bp.segments().map(|s| s.arclen(1e-9)).sum()
}
fn near(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() <= tol
}
// ---------------------------------------------------------------- dashes fitted to corners
#[test]
fn every_corner_of_a_rectangle_falls_mid_dash() {
// 100 × 50 with 12/6: the long sides hold 6 periods (scaled by 100/108), the short ones 3
// (50/54), so every half dash at a corner is 6 · 100/108 long.
let r = Rect::new(0.0, 0.0, 100.0, 50.0).to_path(0.1);
let d = dash(&r, &fitted(&[12.0, 6.0], 0.0)).unwrap();
assert_eq!(moves(&d.path), 5 + 2 + 5 + 2 + 4, "inner dashes of each side plus one per corner");
assert!(near(length(&d.path), 200.0, 1e-6), "two thirds of the perimeter");
let half = 6.0 * 100.0 / 108.0;
let o = line_outline(&r, &stroke(2.0, |s| s.dash = Some(fitted(&[12.0, 6.0], 0.0))), 2.0, 1e-3);
for (c, along) in [
((0.0, 0.0), [(1.0, 0.0), (0.0, 1.0)]),
((100.0, 0.0), [(-1.0, 0.0), (0.0, 1.0)]),
((100.0, 50.0), [(-1.0, 0.0), (0.0, -1.0)]),
((0.0, 50.0), [(1.0, 0.0), (0.0, -1.0)]),
] {
let c = Point::from(c);
assert!(o.contains(c), "corner {c:?} is painted");
for v in along {
let v = kurbo::Vec2::from(v);
assert!(o.contains(c + v * (half - 0.3)), "dash runs on from {c:?} along {v:?}");
assert!(!o.contains(c + v * (half + 0.3)), "and stops half a dash from {c:?} along {v:?}");
}
}
// The spans of the dashes cover the same length.
let covered: f64 = d.spans.iter().map(|(a, b)| b - a).sum();
assert!(near(covered * 300.0, 200.0, 1e-6), "{:?}", d.spans);
}
#[test]
fn open_paths_start_and_end_mid_dash_and_runs_hold_whole_periods() {
// 100 long, 10/10: five periods, half a dash at each end.
let d = dash(&line(100.0), &fitted(&[10.0, 10.0], 0.0)).unwrap();
assert_eq!(moves(&d.path), 6);
assert!(near(length(&d.path), 50.0, 1e-6));
let ends: Vec<f64> = d.path.elements().iter().filter_map(|e| e.end_point()).map(|p| p.x).collect();
assert!(near(ends[0], 0.0, 1e-9) && near(ends[1], 5.0, 1e-9) && near(*ends.last().unwrap(), 100.0, 1e-9), "{ends:?}");
// 92 long: 4.6 periods round to 5, squeezed to fit.
let d = dash(&line(92.0), &fitted(&[10.0, 10.0], 0.0)).unwrap();
assert_eq!(moves(&d.path), 6);
assert!(near(length(&d.path), 46.0, 1e-6));
// A run shorter than a period still gets one: half dashes at its ends.
let d = dash(&line(4.0), &fitted(&[10.0, 10.0], 0.0)).unwrap();
assert_eq!(moves(&d.path), 2);
assert!(near(length(&d.path), 2.0, 1e-6));
}
#[test]
fn fitted_dashes_ignore_the_offset_and_keep_exact_dashes_unchanged() {
let r = Rect::new(0.0, 0.0, 100.0, 50.0).to_path(0.1);
let a = dash(&r, &fitted(&[12.0, 6.0], 0.0)).unwrap();
let b = dash(&r, &fitted(&[12.0, 6.0], 5.0)).unwrap();
assert_eq!(a.path, b.path);
// Exact dashes still start at the path's start, `offset` into the pattern.
let exact = dash(&line(100.0), &Dash { pattern: vec![10.0, 10.0], offset: 5.0, align_corners: false }).unwrap();
let first: Vec<f64> = exact.path.elements().iter().take(2).filter_map(|e| e.end_point()).map(|p| p.x).collect();
assert_eq!(first, vec![0.0, 5.0]);
assert_eq!(moves(&exact.path), 6);
}
#[test]
fn smooth_closed_paths_hold_a_whole_number_of_dashes() {
// A circle has no corners: one run all the way round, 17 periods of 12/6 (314 / 18 ≈ 17.5).
let c = kurbo::Circle::new((0.0, 0.0), 50.0).to_path(1e-6);
let d = dash(&c, &fitted(&[12.0, 6.0], 0.0)).unwrap();
assert_eq!(moves(&d.path), 17, "the dash on the start point is one dash");
let circumference = 2.0 * std::f64::consts::PI * 50.0;
assert!(near(length(&d.path), circumference * 12.0 / 18.0, 1e-3));
}
#[test]
fn fitted_dots_sit_on_every_corner_and_end_once() {
// 60 pt sides with a dot every 7: 9 periods a side (60 / 63 scale), the corner dots shared.
let sq = Rect::new(0.0, 0.0, 60.0, 60.0).to_path(0.1);
let d = dash(&sq, &fitted(&[0.0, 7.0], 0.0)).unwrap();
assert_eq!(d.dots.len(), 36);
for c in [(0.0, 0.0), (60.0, 0.0), (60.0, 60.0), (0.0, 60.0)] {
assert_eq!(d.dots.iter().filter(|p| p.at.distance(Point::from(c)) < 1e-6).count(), 1, "one dot on {c:?}");
}
let d = dash(&line(60.0), &fitted(&[0.0, 7.0], 0.0)).unwrap();
assert_eq!(d.dots.len(), 10);
assert!(near(d.dots[0].at.x, 0.0, 1e-9) && near(d.dots[9].at.x, 60.0, 1e-9));
assert!(near(d.dots[9].t, 1.0, 1e-9));
}
#[test]
fn fitted_dashes_on_curves_and_degenerate_paths_stay_finite() {
let mut mixed = BezPath::new();
mixed.move_to((0.0, 0.0));
mixed.line_to((0.0, 0.0));
mixed.curve_to((30.0, -40.0), (70.0, -40.0), (100.0, 0.0));
mixed.line_to((100.0, 0.0));
mixed.line_to((50.0, 60.0));
mixed.close_path();
mixed.move_to((200.0, 0.0));
mixed.line_to((200.0, 1e-7));
for pat in [&[12.0, 6.0][..], &[0.0, 5.0], &[3.0], &[5.0, 0.0], &[1e-12, 4.0, 0.0, 2.0]] {
for profile in [None, Some(WidthProfile::lens())] {
let st = stroke(4.0, |s| {
s.dash = Some(fitted(pat, 0.0));
s.cap = LineCap::Round;
s.profile = profile.clone();
});
let o = line_outline(&mixed, &st, 4.0, 0.01);
assert!(o.elements().iter().filter_map(|e| e.end_point()).all(|p| p.x.is_finite() && p.y.is_finite()), "{pat:?}");
}
}
}
// ---------------------------------------------------------------- width profiles with dashes and joins
#[test]
fn each_dash_takes_its_width_from_the_profile_along_the_whole_path() {
let st = stroke(10.0, |s| {
s.profile = Some(WidthProfile::lens());
s.dash = Some(Dash { pattern: vec![10.0, 10.0], offset: 0.0, align_corners: false });
});
let o = line_outline(&line(100.0), &st, 10.0, 1e-3);
// The dash over 40..50 is near the lens's widest point; the one over 0..10 near its tip.
assert!(o.contains(Point::new(45.0, 4.0)) && o.contains(Point::new(45.0, -4.0)), "middle dash is wide");
assert!(!o.contains(Point::new(5.0, 2.0)) && o.contains(Point::new(5.0, 0.3)), "first dash is thin");
assert!(!o.contains(Point::new(15.0, 0.0)), "gaps stay empty");
// Dots too: a round dot near the middle is bigger than one near an end.
let st = stroke(10.0, |s| {
s.profile = Some(WidthProfile::lens());
s.cap = LineCap::Round;
s.dash = Some(Dash { pattern: vec![0.0, 10.0], offset: 0.0, align_corners: false });
});
let o = line_outline(&line(100.0), &st, 10.0, 1e-3);
assert!(o.contains(Point::new(50.0, 4.5)) && !o.contains(Point::new(10.0, 1.5)) && o.contains(Point::new(10.0, 0.5)));
}
/// An L whose corner turns 90° at (100, 0), or a sharp V turning back at (100, 0).
fn bend(sharp: bool) -> BezPath {
let mut b = line(100.0);
b.line_to(if sharp { (0.0, 20.0) } else { (100.0, 100.0) });
b
}
fn uniform(join: LineJoin, miter_limit: f64) -> StrokeLayer {
stroke(20.0, |s| {
s.profile = Some(WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] });
s.join = join;
s.miter_limit = miter_limit;
})
}
#[test]
fn profile_corners_take_the_stroke_join() {
let out = |join, limit, sharp| line_outline(&bend(sharp), &uniform(join, limit), 20.0, 1e-3);
// The L: a miter fills the outer corner, a round join leaves it, a bevel cuts it.
let corner = Point::new(109.0, -9.0);
assert!(out(LineJoin::Miter, 10.0, false).contains(corner));
assert!(!out(LineJoin::Round, 10.0, false).contains(corner));
assert!(out(LineJoin::Round, 10.0, false).contains(Point::new(106.5, -6.5)), "round join reaches 10 out");
assert!(!out(LineJoin::Bevel, 10.0, false).contains(Point::new(106.5, -6.5)));
assert!(out(LineJoin::Bevel, 10.0, false).contains(Point::new(104.5, -4.5)));
// The inside of the L is filled up to the inner miter and no further.
let l = out(LineJoin::Round, 10.0, false);
assert!(l.contains(Point::new(91.0, 9.0)) && !l.contains(Point::new(89.0, 11.0)));
// The sharp V: its miter (about 10 × the half width) is past the limit of 10, so it bevels;
// no join spikes out past the round join's reach.
for join in [LineJoin::Miter, LineJoin::Round, LineJoin::Bevel] {
let x1 = out(join, 10.0, true).bounding_box().x1;
assert!(x1 <= 110.0 + 1e-6, "{join:?}: {x1}");
}
// A limit above it keeps the full miter.
let x1 = out(LineJoin::Miter, 20.0, true).bounding_box().x1;
assert!(x1 > 195.0, "{x1}");
}
#[test]
fn smooth_points_of_curves_stay_smooth_with_any_join() {
// A circle stroked with a uniform profile is the same ring whatever the join.
let c = kurbo::Circle::new((0.0, 0.0), 50.0).to_path(1e-4);
let want = 2.0 * std::f64::consts::PI * 50.0 * 10.0;
for join in [LineJoin::Miter, LineJoin::Round, LineJoin::Bevel] {
let st = stroke(10.0, |s| {
s.profile = Some(WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] });
s.join = join;
});
let a = line_outline(&c, &st, 10.0, 1e-3).area().abs();
assert!((a - want).abs() / want < 0.005, "{join:?}: {a} vs {want}");
}
}
#[test]
fn a_long_pattern_restarted_on_many_short_subpaths_draws_solid() {
// Each subpath walks the zero-length entries in full although it is far shorter than a period.
let mut pattern = vec![0.0; 2000];
pattern[0] = 1.0;
let mut b = BezPath::new();
for i in 0..20_000 {
b.move_to((i as f64 * 2.0, 0.0));
b.line_to((i as f64 * 2.0 + 0.01, 0.0));
}
let d = Dash { pattern, offset: 0.0, align_corners: false };
assert!(dash(&b, &d).is_none());
}