Files
vectorcraft/crates/render/tests/it/spread.rs
T
dotCooCoo 91d705e155 Gradients: repeat or reflect a gradient's stops a set number of times
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.
2026-11-12 06:52:38 -08:00

94 lines
4.6 KiB
Rust

//! Gradients that repeat or reflect: the canvas draws each kind's stops `repeats` times along its
//! vector and on past its ends, as the model's parameter gives them, on fills and strokes.
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientSpread, Paint};
use vectorcraft_doc::{Appearance, Document};
use vectorcraft_geom::{Point, Rect, Vec2, shapes};
use vectorcraft_testkit::fixtures::DocBuilder;
use vectorcraft_testkit::raster::{Image, render_view};
/// A white-to-black gradient of `kind` on `geom` that spreads by `spread`, twice.
fn gradient(kind: GradientKind, spread: GradientSpread, geom: GradientGeom, dither: bool) -> GradientPaint {
let mut gp = GradientPaint::new(Gradient { kind, spread, repeats: 2, dither, ..Gradient::default() });
gp.geom = Some(geom);
gp
}
/// A 100 pt square filled with `fill` and stroked `width` wide with `stroke`.
fn square(fill: Paint, stroke: Paint, width: f64) -> Document {
let mut b = DocBuilder::new(100.0, 100.0);
b.path(shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 90.0)), Appearance::basic(fill, stroke, width), |_| {});
b.build()
}
/// Every 5th pixel inside `inside`, away from where Repeat jumps from black back to white and
/// from a conic's center, has the gray the model gives it (pixel centers), within what a pixel's
/// step on the ramp and dither's noise allow; at least `least` pixels are checked.
fn assert_follows_model(img: &Image, g: &GradientPaint, inside: Rect, least: usize, what: &str) {
let geom = g.geom.unwrap();
let kind = g.gradient.kind;
let mut checked = 0;
for y in (2..100).step_by(5) {
for x in (2..100).step_by(5) {
let p = Point::new(x as f64 + 0.5, y as f64 + 0.5);
if !inside.contains(p) || (kind == GradientKind::Conic && p.distance(geom.start) < 12.0) {
continue;
}
let at = |q: Point| g.gradient.spread_param(geom.param_at(kind, q));
let t = at(p);
if g.gradient.spread == GradientSpread::Repeat && !(0.06..0.94).contains(&t) {
continue;
}
// How far the gray moves across a pixel there: pixels drawn from a grid of cells (a
// diamond's, a dithered fill's) are sampled up to half a pixel away.
let step = [Vec2::new(0.5, 0.0), Vec2::new(0.0, 0.5), Vec2::new(-0.5, 0.0), Vec2::new(0.0, -0.5)]
.map(|d| 2.0 * (at(p + d) - t).abs())
.into_iter()
.fold(0.0, f64::max);
let tolerance = 6 + (step * 255.0).ceil() as u8;
let want = ((1.0 - t.clamp(0.0, 1.0)) * 255.0).round() as u8;
let got = img.over_white(x, y)[0];
assert!(got.abs_diff(want) <= tolerance, "{what} ({x},{y}) t={t:.3}: got {got}, want {want} (within {tolerance})");
checked += 1;
}
}
assert!(checked >= least, "{what}: only {checked} pixels checked");
}
fn geoms() -> [(GradientKind, GradientGeom); 5] {
let c = Point::new(50.0, 50.0);
let level = GradientGeom { start: c, end: c + Vec2::new(36.0, 12.0), aspect: 0.7, focal: None };
[
(GradientKind::Linear, GradientGeom { start: Point::new(20.0, 30.0), end: Point::new(70.0, 45.0), aspect: 1.0, focal: None }),
(GradientKind::Radial, level),
(GradientKind::Radial, GradientGeom { focal: Some(c + Vec2::new(-10.0, 6.0)), ..level }),
(GradientKind::Conic, level),
(GradientKind::Diamond, level),
]
}
/// Linear, radial (centered and off-center), conic and diamond fills repeat and reflect as the
/// model says, inside the vector and past its ends; so does a dithered fill, drawn as pixels.
#[test]
fn fills_repeat_and_reflect_for_every_kind() {
for (kind, geom) in geoms() {
for spread in [GradientSpread::Repeat, GradientSpread::Reflect] {
for dither in [false, true] {
let g = gradient(kind, spread, geom, dither);
let img = render_view(&square(Paint::Gradient(Box::new(g.clone())), Paint::None, 0.0), 100, 100);
assert_follows_model(&img, &g, Rect::new(12.0, 12.0, 88.0, 88.0), 100, &format!("{kind:?} {spread:?} dither {dither}"));
}
}
}
}
/// A stroke's gradient repeats out to its miters too.
#[test]
fn a_stroke_repeats_out_past_the_paint_box() {
for (kind, geom) in geoms() {
let g = gradient(kind, GradientSpread::Reflect, geom, false);
let img = render_view(&square(Paint::None, Paint::Gradient(Box::new(g.clone())), 12.0), 100, 100);
assert_follows_model(&img, &g, Rect::new(5.0, 5.0, 15.0, 95.0), 20, &format!("{kind:?} stroke"));
}
}