Files
vectorcraft/crates/render/src/dither.rs
T
dotCooCoo 66d21471ae Gradients: diamond gradients run the stops from the center out to a square
A new gradient kind, `diamond`, runs its stops from the center (the
start) out to a square with a corner at the end of the vector; the
aspect stretches the square across the vector into a rhombus. The
Gradient panel's Type row has a Diamond button, the Gradient tool draws
the square with the aspect handle and center ring, and a gradient
fitted to an object covers its box.

- Model and canvas: the level is |x| + |y| in the square's frame; a
  square with no area (no vector, an aspect of 0, numbers out of range)
  paints the last stop. The canvas draws it as pixels, out to a
  stroke's miters, caps and arrowheads. Transforms keep the vector and
  take the mapped size across it as the aspect.
- Commands: `paint.setFill`, `paint.editGradient` and
  `paint.setGradientGeom` take `kind: "diamond"`; a negative aspect or
  a focal point is an error. A conic or diamond gradient that a plug-in
  returns with a focal point loses the focal point.
- Gradient tool: dragging the aspect handle of a radial or diamond
  gradient whose file gave it a negative aspect now sets a positive
  aspect; only a conic's negative aspect mirrors it.
- Export: PDF, SVG, EPS and legacy .ai write four linear gradients, one
  for each quarter of the square, clipped to the shape (PDF as axial
  shadings). PDF and SVG write an image for translucent stops, a
  dithered fill or a flat square, and for the Perceptual method unless
  PDF writes the stops as spot color shadings. SVG writes a diamond on
  type characters as a radial gradient through the corners, and so
  does CSS. A swatch library saved as CSS gets four corner layers, a
  value for the `background` property (a circle when the swatch is
  turned by an angle that is not a multiple of 90 degrees). The .ai
  editing data holds a radial gradient through the corners. The
  document exports give a warning.
- SVG export: a stroke drawn as an image, as conic wedges or as diamond
  quarters now covers the miters, caps and arrowheads that reach past
  its paint box.
- PDF import reads a function-based shading whose color is the same
  along each square about the origin of its space as a diamond.
- Object > Expand gives nested squares or a mesh of rings.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-11-11 20:27:48 -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).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
}