Files
vectorcraft/crates/render/tests/stroke_geometry.rs
T
bflatasticandClaude Opus 5.5 5ae7bfb8ca M3.20: arrowheads: hollow outlines, tip/extend alignment, one opacity for line and head
Hollow heads draw as rings (the open arrow as a chevron) and closed paths get
no heads. The stroke stops under each head, so it never shows through a hollow
head or past the tip; stroke.set {arrowAlign: "extend"|"tip"} chooses whether
the tip extends past the end point (default) or sits on it with the stroke
shortened. Line and heads share one paint box (a gradient runs on into the
head) and take the stroke and object opacity once, as one layer on the canvas
and one group in PDF. StrokeLayer.arrow_align is a new defaulted field, so
older files load unchanged. The Stroke panel's Align buttons get two generated
icons and are disabled when the stroke has no head.

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

173 lines
7.2 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.
//! Pixel tests for the shared stroke geometry (`vectorcraft_effects::stroke`) on the canvas:
//! dotted lines (zero-length dashes) and arrowheads (hollow kinds, alignment, compositing).
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Document, LineCap, StrokeLayer};
use vectorcraft_geom::{Point, Rect, Shape, shapes};
use vectorcraft_render::{Renderer, effects};
use vectorcraft_testkit::fixtures::DocBuilder;
use vectorcraft_testkit::raster::{Image, assert_similar, render_region};
use vectorcraft_testkit::svg;
/// Pixels per point in these tests.
const S: f64 = 4.0;
fn stroked(width: f64, f: impl FnOnce(&mut StrokeLayer)) -> Appearance {
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), width);
f(&mut st);
Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] }
}
/// A horizontal line from x0 to x1 at y = 50 on a 100 × 100 artboard.
fn line_doc(x0: f64, x1: f64, app: Appearance, opacity: f32) -> Document {
let mut b = DocBuilder::new(100.0, 100.0);
b.path(shapes::line(Point::new(x0, 50.0), Point::new(x1, 50.0)), app, |n| n.opacity = opacity);
b.build()
}
fn render(d: &Document) -> Image {
render_region(d, Rect::new(0.0, 0.0, 100.0, 100.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 dotted(cap: LineCap) -> Document {
line_doc(
20.0,
80.0,
stroked(4.0, |s| {
s.cap = cap;
s.dash = Some(Dash { pattern: vec![0.0, 6.0], offset: 0.0, align_corners: false });
}),
1.0,
)
}
#[test]
fn zero_length_dashes_draw_dots_with_a_round_cap_and_squares_with_a_projecting_cap() {
let round = render(&dotted(LineCap::Round));
// 60 pt with a dot every 6 pt: 11 separate dots along the centre line.
let row: Vec<bool> = (0..round.width).map(|x| round.over_white(x, (50.0 * S) as u32)[0] < 128).collect();
let runs = row.windows(2).filter(|w| !w[0] && w[1]).count();
assert_eq!(runs, 11, "dots along the line");
for k in 0..=10 {
let x = 20.0 + 6.0 * k as f64;
assert!(dark(&round, x, 50.0), "dot {k}");
assert!(!dark(&round, x + 3.0, 50.0), "gap after dot {k}");
}
// A disc's corner region is empty; a square fills it.
assert!(!dark(&round, 20.0 + 1.8, 50.0 + 1.8));
let square = render(&dotted(LineCap::Square));
assert!(dark(&square, 20.0 + 1.8, 50.0 + 1.8));
assert!(dark(&square, 26.0 - 1.8, 50.0 - 1.8));
// A butt cap gives a zero-length dash no area at all.
assert_eq!(render(&dotted(LineCap::Butt)).ink(), 0);
}
#[test]
fn dotted_lines_survive_svg_export_and_render_the_same() {
for cap in [LineCap::Round, LineCap::Square] {
let d = dotted(cap);
let text = svg::export(&d, &Default::default());
let back = svg::import(&text).unwrap();
assert_similar(&render(&d), &render(&back), 0.05, 0.001);
}
}
fn arrow_doc(kind: Arrowhead, align: ArrowAlign, opacity: f32, f: impl FnOnce(&mut StrokeLayer)) -> Document {
line_doc(
20.0,
80.0,
stroked(4.0, |s| {
s.end_arrow = Some(kind);
s.arrow_align = align;
f(s);
}),
opacity,
)
}
/// The arrowhead the shared geometry computes for the line of `d`.
fn head_of(d: &Document) -> effects::stroke::Arrow {
let n = d.layers[0].children().unwrap()[0].clone();
let bp = n.path_data().unwrap().to_bezpath();
effects::stroke::stroke_pieces(&bp, n.appearance.stroke().unwrap()).heads.remove(0)
}
#[test]
fn a_hollow_circle_head_is_empty_inside() {
let d = arrow_doc(Arrowhead::CircleOpen, ArrowAlign::Extend, 1.0, |_| {});
let h = head_of(&d);
let centre = h.tip - h.dir * 8.0;
let img = render(&d);
assert!(!dark(&img, centre.x, centre.y), "the centre of an open circle is empty");
assert!(dark(&img, h.tip.x - 1.0, 50.0), "its wall is painted");
// The filled circle is solid.
let solid = render(&arrow_doc(Arrowhead::Circle, ArrowAlign::Extend, 1.0, |_| {}));
assert!(dark(&solid, centre.x, centre.y));
}
#[test]
fn heads_are_drawn_where_the_shared_geometry_puts_them_and_never_past_the_tip() {
for align in [ArrowAlign::Extend, ArrowAlign::Tip] {
for kind in Arrowhead::ALL {
let d = arrow_doc(kind, align, 1.0, |s| s.cap = LineCap::Round);
let h = head_of(&d);
if align == ArrowAlign::Tip {
assert_eq!(h.tip, Point::new(80.0, 50.0));
} else {
assert!((h.tip.x - (80.0 + h.inset)).abs() < 1e-9);
}
let img = render(&d);
// The rightmost ink is the tip (allowing for antialiasing).
let right = (0..img.width).rev().find(|&x| (0..img.height).any(|y| img.over_white(x, y)[0] < 250)).unwrap();
let bb = h.outline.bounding_box();
assert!((right as f64 / S - bb.x1).abs() <= 0.5, "{kind:?} {align:?}: ink ends at {} vs head {}", right as f64 / S, bb.x1);
assert!(!dark(&img, h.tip.x + 0.5, 50.0), "{kind:?} {align:?}: nothing past the tip");
}
}
}
/// Straight-alpha pixel at document point (x, y), rendered on transparent.
fn alpha_at(d: &Document, x: f64, y: f64) -> u8 {
let r = Renderer::new().render_region(d, Rect::new(0.0, 0.0, 100.0, 100.0), S, false);
r.pixel((x * S) as u32, (y * S) as u32)[3]
}
#[test]
fn line_and_head_take_the_opacity_once() {
for (node, stroke) in [(0.5, 1.0), (1.0, 0.5)] {
let d = arrow_doc(Arrowhead::Triangle, ArrowAlign::Extend, node, |s| s.opacity = stroke);
let h = head_of(&d);
let line = alpha_at(&d, 40.0, 50.0);
assert!((120..=135).contains(&line), "half-opaque line: {line}");
// Where the line runs under the head, and in the head beside it.
let overlap = alpha_at(&d, h.tip.x - h.inset - 1.0, 50.0);
let beside = alpha_at(&d, h.tip.x - 12.0, 50.0 + 4.5);
assert_eq!(overlap, line, "no double darkening where line and head overlap");
assert_eq!(beside, line, "the head has the line's opacity");
}
}
#[test]
fn a_gradient_runs_on_into_the_head() {
let stops = vec![
GradientStop { offset: 0.0, color: Color::rgb(1.0, 0.0, 0.0), opacity: 1.0, midpoint: 0.5 },
GradientStop { offset: 1.0, color: Color::rgb(0.0, 0.0, 1.0), opacity: 1.0, midpoint: 0.5 },
];
let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops })));
let d = arrow_doc(Arrowhead::Square, ArrowAlign::Tip, 1.0, |s| s.paint = paint);
let h = head_of(&d);
let img = render(&d);
// Just behind the head's back edge (line) and just inside it (head): nearly the same colour.
let back = h.tip.x - 16.0;
let (a, b) = (img.over_white(((back - 0.5) * S) as u32, (50.0 * S) as u32), img.over_white(((back + 0.5) * S) as u32, (50.0 * S) as u32));
assert!(a[0].abs_diff(b[0]) < 12 && a[2].abs_diff(b[2]) < 12, "{a:?} vs {b:?}");
// The head is part of the same gradient: bluer than the line's start.
let start = img.over_white((22.0 * S) as u32, (50.0 * S) as u32);
assert!(b[2] > start[2] + 100, "{b:?} vs {start:?}");
}