mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 07:46:07 +00:00
A linear, radial, conic or diamond gradient has a spread: Pad (the default, as before), Repeat or Reflect, with a count of 1 to 100 (VectorCraft's own limit). Repeat and Reflect run the stops that many times along the vector (around the turn for a conic) and keep going past both ends, Reflect running every other run backwards, as SVG's spreadMethod does. The Gradient panel has a Spread menu and a Repeats field, and the Gradient tool marks where the runs meet. paint.setFill and paint.editGradient take `spread` and `repeats`; bad values are errors. Presets, swatches, blends, strokes along or across a path, Outline Stroke and Object › Expand follow the spread; Expand gives each run the same whole number of objects, and Outline Stroke's mesh keeps the color on each side of a sharp step between two stops at one offset. SVG writes and reads spreadMethod: SVG import now reads a repeating or reflecting gradient as one that repeats or reflects once, instead of expanding its stops over the shape. CSS writes repeating gradients. PDF writes a conic's sweep shading over one run. PDF, EPS, legacy .ai and the .ai editing data write the other kinds with their stops repeated across the shape: at most 256 runs and 16,384 stops (256 stops in .ai files), and for a radial or diamond no farther than 65,536 runs from its center, with a warning past those. PDF import now finds sharp steps in any axial or radial shading instead of blurring them. Docs: docs/mcp.md (Spread), docs/target-app-parity.md, docs/gaps.md.
127 lines
5.8 KiB
Rust
127 lines
5.8 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 crate::*;
|
||
|
||
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));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A gradient that reflects twice runs its stops twice along the stroke, the second run
|
||
/// backwards.
|
||
#[test]
|
||
fn along_a_reflecting_gradient_runs_its_stops_that_many_times() {
|
||
let g = Gradient { spread: vectorcraft_color::GradientSpread::Reflect, repeats: 2, ..Gradient::default() };
|
||
let img = render(&line((20.0, 100.0), (180.0, 100.0)), g, StrokeGradientMode::Along, true);
|
||
for (f, want) in [(0.125, 0.75), (0.375, 0.25), (0.625, 0.25), (0.875, 0.75)] {
|
||
let x = 20.0 + 160.0 * f;
|
||
assert!((grey(&img, x, 100.0) - want).abs() < 0.03, "at {f}: {} vs {want}", grey(&img, x, 100.0));
|
||
}
|
||
}
|
||
|
||
#[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})");
|
||
}
|
||
}
|
||
}
|
||
}
|