Files
vectorcraft/crates/render/src/freeform.rs
T
bflatasticandClaude Opus 5.5 ea61d34a93 M3.85: spot colours defined in Lab, with Spot Colors options for Lab values or CMYK equivalents
Colours gain a CIE Lab model (`Color::Lab {l,a,b}`, serde tag `lab`), handled through the
CMS (display, separation, conversions, gamut), tints (toward paper white), keep_model, harmonies,
recolouring, blends, Info and the Color panel. Swatch Options gets a Lab mode
(`swatch.edit {mode: "lab"}` / `{color: {l,a,b}}`), and colour params accept `{l,a,b}`.

Swatches panel → Spot Colors… (`ui.spotColors`, dialog `spotColors`) runs `swatch.spotOptions
{useLab}` (Document.spot_use_lab, default on): Lab spot colours show and separate from their Lab
values, or art linked to them takes their working-CMYK equivalents, as one undo step. PDF export
writes Lab spot inks as Separation spaces with a Lab alternate (tint function from paper white),
or a DeviceCMYK one with CMYK equivalents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:46:57 +07:00

107 lines
4.9 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.
//! Freeform gradients in the renderer.
//!
//! vello has no freeform shading, so the colour field ([`vectorcraft_color::freeform`]) is
//! sampled on a grid of cells over the painted box and drawn as an image paint (bilinear, edges
//! padded): the fill or stroke being painted clips it. The grid is sized from the box's size on
//! screen (a cell per few device pixels, in power-of-two steps so zooming reuses it) and cached by
//! the gradient's content, so panning and redraws don't resample it.
use std::cell::RefCell;
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use vectorcraft_color::freeform::spread_scale;
use vectorcraft_color::{Color, Freeform, GradientPaint};
use vectorcraft_geom::{Affine, Point, Rect};
use vello_cpu::peniko::{Extend, ImageQuality, ImageSampler};
use vello_cpu::{Pixmap, RenderContext};
/// Device pixels per grid cell.
const CELL_PX: f64 = 4.0;
/// Fewest and most cells along the box's longer side.
const MIN_CELLS: f64 = 8.0;
const MAX_CELLS: f64 = 256.0;
/// Grids kept before the cache starts over.
const MAX_GRIDS: usize = 128;
thread_local! {
static GRIDS: RefCell<HashMap<u64, Arc<Pixmap>>> = RefCell::new(HashMap::new());
}
fn hash_color(c: &Color, h: &mut impl Hasher) {
match *c {
Color::Rgb { r, g, b } => (0u8, r.to_bits(), g.to_bits(), b.to_bits()).hash(h),
Color::Cmyk { c, m, y, k } => (1u8, c.to_bits(), m.to_bits(), y.to_bits(), k.to_bits()).hash(h),
Color::Gray { k } => (2u8, k.to_bits()).hash(h),
Color::Lab { l, a, b } => (3u8, l.to_bits(), a.to_bits(), b.to_bits()).hash(h),
}
}
/// Cache key of the grid of `f` on box `b` at `cols` × `rows` (and the active colour settings,
/// which turn colours into display RGB).
fn key(f: &Freeform, b: Rect, cols: u16, rows: u16) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
for p in &f.points {
(p.at.x.to_bits(), p.at.y.to_bits(), p.opacity.to_bits(), p.spread.to_bits()).hash(&mut h);
hash_color(&p.color, &mut h);
}
f.lines.hash(&mut h);
(b.x0.to_bits(), b.y0.to_bits(), b.x1.to_bits(), b.y1.to_bits(), cols, rows).hash(&mut h);
(Arc::as_ptr(&vectorcraft_color::cms::active()) as usize).hash(&mut h);
h.finish()
}
/// Sample `f` at the centres of a `cols` × `rows` grid over `b` (premultiplied).
pub(crate) fn rasterize(f: &Freeform, b: Rect, cols: u16, rows: u16) -> Pixmap {
let field = f.field(spread_scale(b));
let (cw, ch) = (b.width() / cols as f64, b.height() / rows as f64);
let data = (0..rows as usize * cols as usize)
.map(|k| {
let (i, j) = (k % cols as usize, k / cols as usize);
let ([r, g, bl], a) = field.sample(Point::new(b.x0 + (i as f64 + 0.5) * cw, b.y0 + (j as f64 + 0.5) * ch));
let a = a.clamp(0.0, 1.0);
let q = |v: f32| (v.clamp(0.0, 1.0) * a * 255.0).round() as u8;
vello_cpu::color::PremulRgba8 { r: q(r), g: q(g), b: q(bl), a: (a * 255.0).round() as u8 }
})
.collect();
Pixmap::from_parts(data, cols, rows)
}
/// Cells along a side `len` (document units) long whose box's longer side `side` gets `cells`.
fn cells_along(len: f64, side: f64, cells: f64) -> u16 {
(cells * len / side).ceil().clamp(2.0, MAX_CELLS) as u16
}
/// Set freeform gradient `g` on box `bounds` (in the paint's space, which the context's current
/// transform maps to pixels) as the context paint. Returns false for an empty box.
pub(crate) fn set_freeform_paint(ctx: &mut RenderContext, g: &GradientPaint, bounds: Rect) -> bool {
let b = bounds.abs();
let side = b.width().max(b.height());
if !(side > 1e-9 && side.is_finite()) {
return false;
}
// A box without height (or width) gets a sliver of it, so the grid maps back invertibly.
let b = Rect::from_center_size(b.center(), (b.width().max(side * 1e-3), b.height().max(side * 1e-3)));
let device = side * ctx.transform().determinant().abs().sqrt();
let cells = 2f64.powf((device / CELL_PX).max(1.0).log2().ceil()).clamp(MIN_CELLS, MAX_CELLS);
let (cols, rows) = (cells_along(b.width(), side, cells), cells_along(b.height(), side, cells));
let f = g.freeform_on(b);
let k = key(&f, b, cols, rows);
let pm = GRIDS.with(|c| c.borrow().get(&k).cloned()).unwrap_or_else(|| {
let pm = Arc::new(rasterize(&f, b, cols, rows));
GRIDS.with(|c| {
let mut c = c.borrow_mut();
if c.len() >= MAX_GRIDS {
c.clear();
}
c.insert(k, pm.clone());
});
pm
});
let sampler = ImageSampler { x_extend: Extend::Pad, y_extend: Extend::Pad, quality: ImageQuality::Medium, alpha: 1.0 };
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler });
ctx.set_paint_transform(Affine::translate(b.origin().to_vec2()) * Affine::scale_non_uniform(b.width() / cols as f64, b.height() / rows as f64));
true
}