mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
The Gradient panel's Stroke buttons now work: with the Stroke proxy on a
linear or radial gradient they lay it within the stroke (placed on the
page, as before), along the path (start to end of each subpath) or across
it (left edge to right edge; inside/outside strokes span their visible
side). `paint.editGradient {stroke: true, strokeMode}` sets it (new art
too with nothing selected); `document.inspect` reports
strokeOptions.gradientMode. Model: StrokeLayer.gradient_mode (default
Within, not written).
Shared geometry in effects::stroke::gradient: ribs on the bisectors of
the flattened path (fans round sharp corners, widened under arrowheads,
following width profiles) cut slices each painted with a linear piece of
the gradient (Gradient::span). The canvas and raster export clip them to
the stroke's outline and arrowheads, grown a pixel and composited with
copy so there are no seams and translucent stops paint once. SVG and PDF
write the same slices clipped to the outline, with a warning (new
vectorcraft_svg::export_with_report). Outline Stroke and Expand make
gradient meshes clipped to the outline. Mesh quads now grow along their
edges so thin quads show no seams. Dashes, profiles and arrowheads keep
working in both modes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
115 lines
5.2 KiB
Rust
115 lines
5.2 KiB
Rust
//! Gradients along and across strokes: positions along the path and across the stroke, no seams
|
||
//! between the slices, translucent stops painted once.
|
||
|
||
use vectorcraft_color::{Gradient, GradientPaint, Paint};
|
||
use vectorcraft_doc::{Appearance, AppearanceItem, Node, StrokeGradientMode, StrokeLayer};
|
||
use vectorcraft_geom::PathData;
|
||
|
||
use super::*;
|
||
|
||
const SIZE: u32 = 200;
|
||
|
||
/// `bp` stroked 20 pt with `g` laid `mode`, on a 200×200 page (white, or transparent).
|
||
fn render(bp: &BezPath, g: Gradient, mode: StrokeGradientMode, white: bool) -> Rendered {
|
||
let mut st = StrokeLayer::new(Paint::Gradient(Box::new(GradientPaint::new(g))), 20.0);
|
||
st.gradient_mode = mode;
|
||
let mut d = Document::new(SIZE as f64, SIZE as f64);
|
||
let n = Node::path(d.alloc_id(), PathData::from_bezpath(bp), Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() });
|
||
let l = d.layers[0].id;
|
||
d.insert(Some(l), 0, n).unwrap();
|
||
let background = white.then_some([255, 255, 255, 255]);
|
||
Renderer::new().render(&d, SIZE, SIZE, Affine::IDENTITY, &RenderOptions { background, ..Default::default() })
|
||
}
|
||
|
||
fn line(a: (f64, f64), b: (f64, f64)) -> BezPath {
|
||
let mut p = BezPath::new();
|
||
p.move_to(a);
|
||
p.line_to(b);
|
||
p
|
||
}
|
||
|
||
/// Grey level (0..1) at the centre of pixel (x, y).
|
||
fn grey(img: &Rendered, x: f64, y: f64) -> f64 {
|
||
img.pixel(x as u32, y as u32)[0] as f64 / 255.0
|
||
}
|
||
|
||
#[test]
|
||
fn along_a_quarter_of_the_length_is_a_quarter_of_the_gradient_in_any_direction() {
|
||
for (a, b) in [((20.0, 100.0), (180.0, 100.0)), ((100.0, 180.0), (100.0, 20.0)), ((30.0, 30.0), (170.0, 170.0))] {
|
||
let img = render(&line(a, b), Gradient::default(), StrokeGradientMode::Along, true);
|
||
for (f, off) in [(0.25, 0.0), (0.25, 6.0), (0.75, -6.0), (0.5, 0.0)] {
|
||
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
|
||
let len = (dx * dx + dy * dy).sqrt();
|
||
let (x, y) = (a.0 + dx * f - dy / len * off, a.1 + dy * f + dx / len * off);
|
||
let want = 1.0 - f;
|
||
assert!((grey(&img, x, y) - want).abs() < 0.03, "{a:?}→{b:?} at {f}: {} vs {want}", grey(&img, x, y));
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn across_runs_from_the_left_edge_to_the_right() {
|
||
// Travelling right, the left edge is the top one (y 90), the right edge the bottom (y 110).
|
||
let img = render(&line((20.0, 100.0), (180.0, 100.0)), Gradient::default(), StrokeGradientMode::Across, true);
|
||
for (y, want) in [(91.0, 0.925), (95.0, 0.725), (100.0, 0.475), (108.0, 0.075)] {
|
||
for x in [40.0, 100.0, 160.0] {
|
||
assert!((grey(&img, x, y) - want).abs() < 0.03, "({x}, {y}): {} vs {want}", grey(&img, x, y));
|
||
}
|
||
}
|
||
// Within (the default) the gradient lies on the page: left to right here.
|
||
let within = render(&line((20.0, 100.0), (180.0, 100.0)), Gradient::default(), StrokeGradientMode::Within, true);
|
||
assert!(grey(&within, 40.0, 91.0) > grey(&within, 160.0, 91.0) + 0.5);
|
||
}
|
||
|
||
#[test]
|
||
fn slices_meet_without_seams_and_translucent_stops_paint_once() {
|
||
let circle = kurbo::Circle::new((100.0, 100.0), 60.0).to_path(0.01);
|
||
let mut half = Gradient::default();
|
||
for s in &mut half.stops {
|
||
s.opacity = 0.5;
|
||
}
|
||
for mode in [StrokeGradientMode::Along, StrokeGradientMode::Across] {
|
||
let solid = render(&circle, Gradient::default(), mode, false);
|
||
let translucent = render(&circle, half.clone(), mode, false);
|
||
for k in 0..720 {
|
||
let a = k as f64 * std::f64::consts::TAU / 720.0;
|
||
for r in [55.0, 60.0, 65.0] {
|
||
let (x, y) = (100.0 + r * a.cos(), 100.0 + r * a.sin());
|
||
assert_eq!(solid.pixel(x as u32, y as u32)[3], 255, "{mode:?}: a seam at ({x:.1}, {y:.1})");
|
||
let alpha = translucent.pixel(x as u32, y as u32)[3] as i32;
|
||
assert!((alpha - 128).abs() <= 2, "{mode:?}: alpha {alpha} at ({x:.1}, {y:.1})");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn corners_and_tight_turns_have_no_holes() {
|
||
let mut corner = line((40.0, 40.0), (160.0, 40.0));
|
||
corner.line_to((160.0, 160.0));
|
||
corner.line_to((60.0, 120.0));
|
||
let tight = kurbo::Circle::new((100.0, 100.0), 6.0).to_path(0.01);
|
||
let mut half = Gradient::default();
|
||
for s in &mut half.stops {
|
||
s.opacity = 0.5;
|
||
}
|
||
for mode in [StrokeGradientMode::Within, StrokeGradientMode::Along, StrokeGradientMode::Across] {
|
||
for (bp, probes) in [
|
||
(
|
||
&corner,
|
||
vec![(160.0, 40.0), (156.0, 36.0), (165.0, 45.0), (163.0, 37.0), (157.0, 43.0), (160.0, 160.0), (166.0, 158.0), (152.0, 157.0)],
|
||
),
|
||
// The stroke's inside edge folds over the centre: a 4 pt hole stays there.
|
||
(&tight, vec![(105.5, 100.0), (95.0, 103.0), (108.0, 100.0), (100.0, 113.0), (91.0, 92.0), (100.0, 94.0)]),
|
||
] {
|
||
let solid = render(bp, Gradient::default(), mode, false);
|
||
let translucent = render(bp, half.clone(), mode, false);
|
||
for (x, y) in probes {
|
||
assert_eq!(solid.pixel(x as u32, y as u32)[3], 255, "{mode:?}: a hole at ({x}, {y})");
|
||
let alpha = translucent.pixel(x as u32, y as u32)[3] as i32;
|
||
assert!((alpha - 128).abs() <= 2, "{mode:?}: alpha {alpha} at ({x}, {y})");
|
||
}
|
||
}
|
||
}
|
||
}
|