Files
vectorcraft/crates/render/src/dither.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

158 lines
8.0 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.
//! Dithered gradient fills, and diamond gradients, in the renderer.
//!
//! A dithered linear or radial gradient, and a diamond gradient (which has no vector gradient to
//! draw it), is drawn as pixels: the gradient sampled on a grid over
//! the painted box with a cell per device pixel or finer (in quarter-octave steps, so a pan reuses
//! the grid and a zoom rarely misses), each colour channel given fine noise of up to a level
//! ([`vectorcraft_color::gradient::dither_noise`]) before it is rounded, and drawn as an image
//! paint that the fill clips. Past [`MAX_CELLS`] a side (a box larger than that on screen), cells
//! grow beyond a pixel and are mixed bilinearly, so the gradient stays smooth. Only the cells the
//! render target can show are sampled ([`window`]), so a large box mostly off-screen costs what
//! its visible part does.
use std::cell::RefCell;
use std::hash::{Hash, Hasher};
use std::ops::Range;
use std::sync::Arc;
use vectorcraft_color::GradientPaint;
use vectorcraft_color::freeform::painted_box;
use vectorcraft_geom::{Affine, Rect};
use vello_cpu::peniko::{Extend, ImageQuality, ImageSampler};
use vello_cpu::{Pixmap, RenderContext};
use crate::ink::Ink;
use crate::lru::{self, FrameCache};
/// Most cells along a side of a dithered gradient's grid (64 MiB for a square grid).
pub(crate) const MAX_CELLS: f64 = 4096.0;
/// Cells along a side of the part of a grid that is sampled snap out to multiples of this
/// ([`window`]), so a pan within them keeps the sampled part.
const TILE: f64 = 256.0;
thread_local! {
/// Grids by [`key`], within [`lru::DITHER_BUDGET`], least recently used first out.
static GRIDS: RefCell<FrameCache<u64, Arc<Pixmap>>> = RefCell::new(FrameCache::new(lru::DITHER_BUDGET, usize::MAX));
}
/// Bytes of the grids this thread keeps, after `f` runs on an empty cache.
#[cfg(test)]
pub(crate) fn grid_bytes_after(f: impl FnOnce()) -> usize {
GRIDS.with(|c| c.borrow_mut().clear());
f();
GRIDS.with(|c| c.borrow().bytes())
}
/// Cells along a side `px` device pixels long: at least one per pixel, rounded up to a
/// quarter-octave step, 1 to [`MAX_CELLS`].
fn cells(px: f64) -> u16 {
let steps = (4.0 * px.max(1.0).log2()).ceil();
2f64.powf(steps / 4.0).ceil().clamp(1.0, MAX_CELLS) as u16
}
/// Cache key of gradient `g` placed on box `place`, sampled as a `cols` × `rows` grid over box
/// `b`, cells `win` of it, painting a fill (`fill`) with `ink` (and the active color settings,
/// which turn colors into display RGB or inks).
#[allow(clippy::too_many_arguments)]
fn key(g: &GradientPaint, place: Rect, b: Rect, cols: u16, rows: u16, win: &[Range<u16>; 2], ink: Ink, fill: bool) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
let gr = &g.gradient;
(gr.kind as u8, gr.dither && fill, gr.method as u8, gr.painted_spread()).hash(&mut h);
for s in &gr.stops {
(s.offset.to_bits(), s.opacity.to_bits(), s.midpoint.to_bits()).hash(&mut h);
s.color.key().hash(&mut h);
}
let geom = g.resolve(place);
(geom.start.x.to_bits(), geom.start.y.to_bits(), geom.end.x.to_bits(), geom.end.y.to_bits(), geom.aspect.to_bits()).hash(&mut h);
geom.focal.map(|f| (f.x.to_bits(), f.y.to_bits())).hash(&mut h);
(b.x0.to_bits(), b.y0.to_bits(), b.x1.to_bits(), b.y1.to_bits(), cols, rows).hash(&mut h);
win.hash(&mut h);
(Arc::as_ptr(&vectorcraft_color::cms::active()) as usize, ink).hash(&mut h);
h.finish()
}
/// The cells of a `cols` × `rows` grid over box `b` that a `w` × `h` render target, which
/// transform `xf` maps the box onto, can show: (columns, rows), a cell more each way for the
/// bilinear mix, out to whole [`TILE`]s. The whole grid where `xf` has no inverse; None where
/// no cell shows.
fn window(xf: Affine, w: u16, h: u16, b: Rect, cols: u16, rows: u16) -> Option<[Range<u16>; 2]> {
let seen = xf.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(w), f64::from(h)));
let span = |lo: f64, hi: f64, start: f64, len: f64, n: u16| {
let cell = len / f64::from(n);
let (first, last) = (((lo - start) / cell).floor() - 1.0, ((hi - start) / cell).ceil() + 1.0);
if !(first.is_finite() && last.is_finite()) {
return Some(0..n);
}
let a = ((first / TILE).floor() * TILE).clamp(0.0, f64::from(n));
let z = ((last / TILE).ceil() * TILE).clamp(0.0, f64::from(n));
(a < z).then_some(a as u16..z as u16)
};
Some([span(seen.x0, seen.x1, b.x0, b.width(), cols)?, span(seen.y0, seen.y1, b.y0, b.height(), rows)?])
}
/// Cells `win` (columns, rows) of `g` placed on `place`, sampled as a `cols` × `rows` grid over
/// `b`, as a premultiplied pixmap, colors as `ink` paints them, with dither's noise when it
/// paints a fill (`fill`).
#[allow(clippy::too_many_arguments)]
pub(crate) fn rasterize(g: &GradientPaint, place: Rect, b: Rect, cols: u16, rows: u16, win: [Range<u16>; 2], ink: Ink, fill: bool) -> Pixmap {
let (w, h) = (win[0].len() as u16, win[1].len() as u16);
let adjusted = ink.gradient(&g.gradient).map(|gradient| GradientPaint { gradient, ..g.clone() });
let ink = if adjusted.is_some() { ink.base() } else { ink };
let g = adjusted.as_ref().unwrap_or(g);
let data = g
.pixels_within(place, b, cols, rows, win, fill, &|c| ink.rgb(c))
.map(|[r, gr, bl, a]| {
let pm = |v: u8| ((u16::from(v) * u16::from(a) + 127) / 255) as u8;
vello_cpu::color::PremulRgba8 { r: pm(r), g: pm(gr), b: pm(bl), a }
})
.collect();
Pixmap::from_parts(data, w, h)
}
/// Set gradient `g` placed on box `bounds` (in the paint's space, which the context's current
/// transform maps to pixels) as the context paint, drawn as pixels over that box and box `area`
/// (a stroke's outline, whose miters, caps and arrowheads can reach past `bounds`), with dither's
/// noise when it paints a fill (`fill`). Returns false for an empty box or one off the render
/// target. Past both boxes, the pixels at their edge continue.
pub(crate) fn set_dithered_paint(ctx: &mut RenderContext, g: &GradientPaint, bounds: Rect, area: Rect, ink: Ink, fill: bool) -> bool {
let Some(place) = painted_box(bounds) else { return false };
let b = Some(place.union(area.abs())).filter(|r| r.is_finite()).unwrap_or(place);
// Device pixels per unit along the box's sides.
let [m0, m1, m2, m3, _, _] = ctx.transform().as_coeffs();
let (sx, sy) = (m0.hypot(m1), m2.hypot(m3));
let (wpx, hpx) = (b.width() * sx, b.height() * sy);
if !(wpx.is_finite() && hpx.is_finite()) {
return false;
}
let (cols, rows) = (cells(wpx), cells(hpx));
let Some(win) = window(*ctx.transform(), ctx.width(), ctx.height(), b, cols, rows) else { return false };
let (first_col, first_row) = (win[0].start, win[1].start);
let k = key(g, place, b, cols, rows, &win, ink, fill);
let pm = GRIDS
.with(|c| {
let mut c = c.borrow_mut();
let t = c.tick();
c.get(&k, t).cloned()
})
.unwrap_or_else(|| {
let pm = Arc::new(rasterize(g, place, b, cols, rows, win, ink, fill));
GRIDS.with(|c| {
let mut c = c.borrow_mut();
let t = c.tick();
c.insert(k, pm.clone(), usize::from(pm.width()) * usize::from(pm.height()) * 4, t);
});
pm
});
// A cell per pixel or finer: each pixel takes one cell, its noise intact. Coarser (a box past
// the grid's largest size on screen): mixed bilinearly, so no cell shows as a block.
let fine = f64::from(cols) >= wpx && f64::from(rows) >= hpx;
let quality = if fine { ImageQuality::Low } else { ImageQuality::Medium };
let sampler = ImageSampler { x_extend: Extend::Pad, y_extend: Extend::Pad, quality, alpha: 1.0 };
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler });
let (cw, ch) = (b.width() / f64::from(cols), b.height() / f64::from(rows));
let origin = (b.x0 + f64::from(first_col) * cw, b.y0 + f64::from(first_row) * ch);
ctx.set_paint_transform(Affine::translate(origin) * Affine::scale_non_uniform(cw, ch));
true
}