Files
vectorcraft/crates/render/tests/stroke_geometry.rs
T
bflatasticandClaude Opus 5.5 b2ececc7b4 M3.59: tints of global and spot colours, tint swatches and Global colour balance
A colour linked to a global or spot swatch keeps a tint (Paint::Solid.tint, 1 by default and
left out of files at 100 %): paint.setFill/setStroke {swatch, tint} apply the swatch at that
percentage, swatch edits recolour every linked colour at its own tint, and Color::tinted
resolves it (CMYK and Gray inks scale, RGB mixes toward white). swatch.new saves a tint as a
tint swatch ("Ink 40%") linked to its base. Spot tints separate as that percentage of their
plate (Separations Preview, PDF Separation value) instead of an ink-ratio guess. Adjust Color
Balance's Global mode (tint) shifts the tints of linked colours. The Color panel shows Tint
mode (the swatch chip, its name and a T slider) for linked colours; the Swatches panel
highlights the tint swatch in use. Gradient stops get the same link and tint fields (the
swatch walks cover them), so GradientStop is no longer Copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:29:17 +07:00

182 lines
7.7 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)], ..Default::default() }
}
/// 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::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
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:?}");
}
#[test]
fn a_head_past_the_end_counts_in_the_visual_bounds_and_is_never_culled() {
let d = arrow_doc(Arrowhead::Triangle, ArrowAlign::Extend, 1.0, |s| s.join = vectorcraft_doc::LineJoin::Round);
let h = head_of(&d);
let vb = d.layers[0].children().unwrap()[0].visual_bounds().unwrap();
let head = h.outline.bounding_box();
assert_eq!(vb.union(head), vb, "{vb:?} covers the head {head:?}");
// A view that shows only the overhanging part of the head (the line ends at 80).
let img = render_region(&d, Rect::new(83.0, 40.0, 100.0, 60.0), S);
assert!(img.ink() > 0, "the head is drawn");
}