mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 02:07:09 -06:00
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>
79 lines
3.3 KiB
Rust
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");
|
|
}
|