M3.83: gradients within, along or across a stroke

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>
This commit is contained in:
bflatastic
2026-10-05 05:47:54 +07:00
co-authored by Claude Opus 5.5
parent ccdb1b30f6
commit 025d758abe
26 changed files with 1317 additions and 32 deletions
+91 -1
View File
@@ -202,6 +202,16 @@ struct StrokeEntry {
stamp: u64,
}
/// The slices of a stroke whose gradient runs along or across it, each with its paint and bounds.
type Slices = Arc<Vec<(BezPath, vectorcraft_color::Paint, Rect)>>;
/// [`Slices`] cached for a stroke: (owning node, slices, last frame used).
struct SliceEntry {
node: Arc<Node>,
slices: Slices,
stamp: u64,
}
/// Cached per-node geometry, keyed by `Arc` identity. Structural sharing means an unchanged node
/// keeps its allocation across edits, so a pointer match (with the Arc kept alive here so the
/// address can't be reused) is an exact cache hit — no invalidation logic needed.
@@ -268,6 +278,8 @@ pub struct Renderer {
clip_paints: PtrMap<usize, ClipPaintEntry>,
/// Placed images as painted in ink planes (see [`ink`]).
ink_images: ink::InkImages,
/// Gradients along or across strokes, keyed like `strokes` (see [`Self::fill_path_gradient`]).
stroke_slices: PtrMap<(usize, i32), SliceEntry>,
}
#[derive(Clone, Copy, Debug, Default)]
@@ -326,6 +338,7 @@ impl Renderer {
clip_paths: vec![],
clip_paints: PtrMap::default(),
ink_images: Default::default(),
stroke_slices: PtrMap::default(),
}
}
@@ -369,6 +382,9 @@ impl Renderer {
if self.strokes.len() > 1024 {
self.strokes.retain(|_, e| g - e.stamp <= 3);
}
if self.stroke_slices.len() > 256 {
self.stroke_slices.retain(|_, e| g - e.stamp <= 3);
}
if self.shadows.len() > 256 {
self.shadows.retain(|_, e| g - e.stamp <= 3);
}
@@ -908,7 +924,9 @@ impl Renderer {
}
// One paint box for the line and the heads, so a gradient runs on into the heads.
let paint_bounds = bounds.inflate(st.width / 2.0, st.width / 2.0);
if paint::set_paint(ctx, &st.paint, paint_bounds, f) {
if let Some(g) = st.path_gradient() {
self.fill_path_gradient(ctx, f, bp, rule, st, &pieces, width, &g.gradient);
} else if paint::set_paint(ctx, &st.paint, paint_bounds, f) {
self.fold_alpha(ctx, f.ink, &st.paint);
ctx.set_fill_rule(peniko::Fill::NonZero);
match self.cached_stroke(f, &pieces.line, st, width) {
@@ -938,6 +956,76 @@ impl Renderer {
}
}
/// Paint the line and arrowheads (`pieces`) of stroke `st` of `bp` (filled with `rule`) with
/// its gradient `g` laid along or across the path ([`effects::stroke::gradient_slices`]). Each
/// piece clips the slices, grown a pixel into each other and each replacing what it covers, so
/// neighbours meet without seams and translucent colours paint once.
#[allow(clippy::too_many_arguments)]
fn fill_path_gradient(
&mut self,
ctx: &mut RenderContext,
f: &Frame,
bp: &BezPath,
rule: FillRule,
st: &StrokeLayer,
pieces: &effects::stroke::StrokePieces,
width: f64,
g: &vectorcraft_color::Gradient,
) {
let level = (f.px * 0.25).log2().floor() as i32;
let make = || -> Slices {
let tol = 2f64.powi(level);
// Reach a pixel past the stroke, so its antialiased edge has colour under it.
let slices = effects::stroke::gradient_slices(bp, rule, st, tol, f.px + tol, f.px);
Arc::new(
slices
.into_iter()
.map(|s| {
let (b, paint) = (s.shape.bounding_box(), s.paint(g));
(s.shape, paint, b)
})
.collect(),
)
};
let slices = match self.cur.clone() {
Some(node) => {
let key = (st as *const StrokeLayer as usize, level);
match self.stroke_slices.get_mut(&key) {
Some(e) if Arc::ptr_eq(&e.node, &node) => {
e.stamp = self.stamp;
e.slices.clone()
}
_ => {
let slices = make();
self.stroke_slices.insert(key, SliceEntry { node, slices: slices.clone(), stamp: self.stamp });
slices
}
}
}
None => make(),
};
let line = match self.cached_stroke(f, &pieces.line, st, width) {
Some(o) => o,
None => Arc::new(effects::stroke::line_outline(&pieces.line, st, width, f.px * 0.25)),
};
ctx.set_fill_rule(peniko::Fill::NonZero);
for clip in std::iter::once(&*line).chain(pieces.heads.iter().map(|h| &h.outline)) {
let cb = clip.bounding_box();
if clip.elements().is_empty() || !cb.is_finite() {
continue;
}
ctx.push_clip_layer(clip);
ctx.set_blend_mode(BlendMode::new(Mix::Normal, Compose::Copy));
for (shape, paint, b) in slices.iter() {
if rects_overlap(*b, cb) && paint::set_paint(ctx, paint, *b, f) {
ctx.fill_path(shape);
}
}
ctx.set_blend_mode(BlendMode::default());
ctx.pop_layer();
}
}
/// The line part of a stroke of the fast-path node `self.cur` (see
/// [`effects::stroke::line_outline`]), cached across frames. The tolerance (vello's 0.25
/// device px) is bucketed to powers of two, never coarser than needed, so zooming reuses
@@ -1248,4 +1336,6 @@ mod tests_knockout;
#[cfg(test)]
mod tests_objectfx;
#[cfg(test)]
mod tests_strokegradient;
#[cfg(test)]
mod tests_tileedge;
+11 -6
View File
@@ -4,14 +4,15 @@
//! by `Arc` identity of the live node (so the steps keep stable `Arc`s and hit the geometry cache
//! frame after frame). Gradient meshes are tessellated into many small solid-colour quads (vello
//! has no mesh shading), with the density chosen from the on-screen patch size; each quad is
//! grown by half a device pixel so neighbours overlap and no antialiasing seams show.
//! grown by half a device pixel along its edges so neighbours overlap and no antialiasing seams
//! show.
use std::collections::HashMap;
use std::sync::Arc;
use vectorcraft_doc::live::{self, GradientMesh, MeshQuad};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_geom::{BezPath, PathData, Point};
use vectorcraft_geom::{BezPath, PathData, Vec2};
use vello_cpu::RenderContext;
use vello_cpu::peniko;
@@ -158,12 +159,16 @@ impl Renderer {
if q.opacity <= 0.0 {
continue;
}
let c = Point::new((q.pts[0].x + q.pts[1].x + q.pts[2].x + q.pts[3].x) / 4.0, (q.pts[0].y + q.pts[1].y + q.pts[2].y + q.pts[3].y) / 4.0);
let mut bp = BezPath::new();
for (i, p) in q.pts.iter().enumerate() {
let d = *p - c;
let len = d.hypot();
let p2 = if len > 1e-12 { *p + d * (grow / len) } else { *p };
// Out along both edges at the corner, so thin quads grow across too.
let d: Vec2 = [q.pts[(i + 3) % 4], q.pts[(i + 1) % 4]]
.iter()
.map(|n| *p - *n)
.filter(|e| e.hypot() > 1e-12)
.map(|e| e / e.hypot())
.fold(Vec2::ZERO, |a, e| a + e);
let p2 = *p + d * grow;
if i == 0 {
bp.move_to(p2);
} else {
+114
View File
@@ -0,0 +1,114 @@
//! 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})");
}
}
}
}