Files
vectorcraft/crates/render/tests/dash_align.rs
T
bflatasticandClaude Opus 5.5 454c5f8620 M3.39: width profiles follow the path through dashes, and their corners take the join
Each dash and dot of a profile stroke is as wide as the profile where it sits
along the whole path (before, dashes dropped the profile). Corners of profile
strokes take the stroke's miter (within the miter limit), round or bevel join on
the outer side and stay clean on the inner side, instead of a miter clamped at
4x; smooth points of curves bend round. Canvas, SVG/PDF outlines and Outline
Stroke share the geometry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:40:52 +07:00

79 lines
3.3 KiB
Rust

//! Pixel tests for dashes fitted to corners and for width profiles with dashes and joins, drawn
//! on the canvas from the shared stroke geometry (`vectorcraft_effects::stroke`).
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Dash, Document, LineJoin, StrokeLayer, WidthProfile};
use vectorcraft_geom::{PathData, Point, Rect, SubPath, shapes};
use vectorcraft_testkit::fixtures::DocBuilder;
use vectorcraft_testkit::raster::{Image, render_region};
/// Pixels per point in these tests.
const S: f64 = 4.0;
fn doc(path: PathData, width: f64, f: impl FnOnce(&mut StrokeLayer)) -> Document {
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), width);
f(&mut st);
let mut b = DocBuilder::new(160.0, 120.0);
b.path(path, Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] }, |_| {});
b.build()
}
fn render(d: &Document) -> Image {
render_region(d, Rect::new(0.0, 0.0, 160.0, 120.0), S)
}
/// Is the pixel at document point (x, y) dark?
fn dark(img: &Image, x: f64, y: f64) -> bool {
img.over_white((x * S) as u32, (y * S) as u32)[0] < 128
}
fn rect_doc(align_corners: bool) -> Document {
doc(shapes::rectangle(Rect::new(20.0, 20.0, 120.0, 70.0)), 2.0, |s| s.dash = Some(Dash { pattern: vec![12.0, 6.0], offset: 0.0, align_corners }))
}
#[test]
fn fitted_dashes_wrap_every_corner_of_a_rectangle() {
let img = render(&rect_doc(true));
// Half a dash (6 · 100/108) runs each way from every corner, then a gap of the same length.
let half = 6.0 * 100.0 / 108.0;
for (c, dirs) in [
((20.0, 20.0), [(1.0, 0.0), (0.0, 1.0)]),
((120.0, 20.0), [(-1.0, 0.0), (0.0, 1.0)]),
((120.0, 70.0), [(-1.0, 0.0), (0.0, -1.0)]),
((20.0, 70.0), [(1.0, 0.0), (0.0, -1.0)]),
] {
for (dx, dy) in dirs {
let at = |d: f64| (c.0 + dx * d, c.1 + dy * d);
let (x, y) = at(half / 2.0);
assert!(dark(&img, x, y), "dash beside corner {c:?}");
let (x, y) = at(half * 1.5);
assert!(!dark(&img, x, y), "gap after corner {c:?} along ({dx}, {dy})");
}
}
// Exact dashes keep their lengths instead (the exact golden covers how they look).
assert_ne!(render(&rect_doc(false)).rgba, img.rgba);
}
#[test]
fn dashes_of_a_lens_stroke_widen_towards_the_middle() {
let img = render(&doc(shapes::line(Point::new(30.0, 60.0), Point::new(130.0, 60.0)), 10.0, |s| {
s.profile = Some(WidthProfile::lens());
s.dash = Some(Dash { pattern: vec![10.0, 10.0], offset: 0.0, align_corners: false });
}));
// The dash over 40..50 of the 100 pt line is nearly full width, the one over 0..10 thin.
assert!(dark(&img, 75.0, 60.0 - 3.8) && dark(&img, 75.0, 60.0 + 3.8));
assert!(dark(&img, 35.0, 60.0) && !dark(&img, 35.0, 60.0 - 1.5));
assert!(!dark(&img, 45.0, 60.0), "gap");
}
#[test]
fn a_round_join_on_a_profile_stroke_has_no_spike() {
let l = SubPath::polyline(&[Point::new(20.0, 40.0), Point::new(100.0, 40.0), Point::new(100.0, 110.0)], false);
let img = render(&doc(PathData::single(l), 20.0, |s| {
s.profile = Some(WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] });
s.join = LineJoin::Round;
}));
assert!(dark(&img, 106.0, 34.0), "the round join covers the corner");
assert!(!dark(&img, 109.0, 31.0), "but not the miter's point");
}