mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 00:40:57 -06:00
M3.90: blend modes and opacity composite inks in CMYK documents
A CMYK document that shows transparency is drawn once per ink plane (complemented C, M, Y; complemented K as a grey) so every blend mode, opacity, group, knockout and effect composites inks, PDF-style; the planes' inks are shown through the working CMYK profile (cached 4-D XYZ table). Cyan multiplied over magenta now shows C100 M100. Opacity masks keep screen luminance; RGB colours and placed images are separated first. RGB documents and opaque CMYK documents keep the screen-colour path. PDF export of CMYK documents writes its transparency groups with /CS /DeviceCMYK (luminosity mask groups stay RGB) and puts top-level transparency in a non-isolated page-wide group so it blends in CMYK too. Flatten Transparency composites CMYK documents ink by ink and makes CMYK regions. Non-separable modes no longer give NaN for greys. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
6ee994239d
commit
9537eca5f7
@@ -17,6 +17,8 @@ use vectorcraft_geom::{Affine, Point, Rect};
|
||||
use vello_cpu::peniko::{Extend, ImageQuality, ImageSampler};
|
||||
use vello_cpu::{Pixmap, RenderContext};
|
||||
|
||||
use crate::ink::Ink;
|
||||
|
||||
/// Device pixels per grid cell.
|
||||
const CELL_PX: f64 = 4.0;
|
||||
/// Fewest and most cells along the box's longer side.
|
||||
@@ -38,9 +40,9 @@ fn hash_color(c: &Color, h: &mut impl Hasher) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// Cache key of the grid of `f` on box `b` at `cols` × `rows` painted with `ink` (and the active
|
||||
/// colour settings, which turn colours into display RGB or inks).
|
||||
fn key(f: &Freeform, b: Rect, cols: u16, rows: u16, ink: Ink) -> 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);
|
||||
@@ -48,13 +50,14 @@ fn key(f: &Freeform, b: Rect, cols: u16, rows: u16) -> u64 {
|
||||
}
|
||||
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);
|
||||
(Arc::as_ptr(&vectorcraft_color::cms::active()) as usize, ink).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));
|
||||
/// Sample `f` at the centres of a `cols` × `rows` grid over `b` (premultiplied, colours as `ink`
|
||||
/// paints them).
|
||||
pub(crate) fn rasterize(f: &Freeform, b: Rect, cols: u16, rows: u16, ink: Ink) -> Pixmap {
|
||||
let field = f.field_with(spread_scale(b), &|c| ink.rgb(c));
|
||||
let (cw, ch) = (b.width() / cols as f64, b.height() / rows as f64);
|
||||
let data = (0..rows as usize * cols as usize)
|
||||
.map(|k| {
|
||||
@@ -75,7 +78,7 @@ fn cells_along(len: f64, side: f64, cells: f64) -> 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 {
|
||||
pub(crate) fn set_freeform_paint(ctx: &mut RenderContext, g: &GradientPaint, bounds: Rect, ink: Ink) -> bool {
|
||||
let b = bounds.abs();
|
||||
let side = b.width().max(b.height());
|
||||
if !(side > 1e-9 && side.is_finite()) {
|
||||
@@ -87,9 +90,9 @@ pub(crate) fn set_freeform_paint(ctx: &mut RenderContext, g: &GradientPaint, bou
|
||||
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 k = key(&f, b, cols, rows, ink);
|
||||
let pm = GRIDS.with(|c| c.borrow().get(&k).cloned()).unwrap_or_else(|| {
|
||||
let pm = Arc::new(rasterize(&f, b, cols, rows));
|
||||
let pm = Arc::new(rasterize(&f, b, cols, rows, ink));
|
||||
GRIDS.with(|c| {
|
||||
let mut c = c.borrow_mut();
|
||||
if c.len() >= MAX_GRIDS {
|
||||
|
||||
@@ -29,6 +29,7 @@ use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive};
|
||||
use vello_cpu::RenderContext;
|
||||
use vello_cpu::peniko;
|
||||
|
||||
use crate::ink::Ink;
|
||||
use crate::{Frame, Renderer, blend_mode, fill_rule, paint};
|
||||
|
||||
fn visible(effects: &[Effect]) -> bool {
|
||||
@@ -178,8 +179,8 @@ fn blur_filter(sigma: f64) -> Filter {
|
||||
Filter::from_primitive(FilterPrimitive::GaussianBlur { std_deviation: sigma.max(0.0) as f32, edge_mode: EdgeMode::None })
|
||||
}
|
||||
|
||||
fn pcolor(c: &vectorcraft_doc::color::Color) -> peniko::Color {
|
||||
let [r, g, b] = c.to_rgb();
|
||||
fn pcolor(ink: Ink, c: &vectorcraft_doc::color::Color) -> peniko::Color {
|
||||
let [r, g, b] = ink.rgb(c);
|
||||
peniko::Color::new([r, g, b, 1.0])
|
||||
}
|
||||
|
||||
@@ -285,7 +286,7 @@ impl Renderer {
|
||||
};
|
||||
let c = f.view.as_coeffs();
|
||||
let linear = [c[0].to_bits(), c[1].to_bits(), c[2].to_bits(), c[3].to_bits()];
|
||||
let key = (n as *const Node as usize, i);
|
||||
let key = (n as *const Node as usize, i, f.ink);
|
||||
let stamp = self.stamp;
|
||||
let hit = self.shadows.get_mut(&key).filter(|e| e.linear == linear && e.node == *n);
|
||||
let (image, x, y) = match hit {
|
||||
@@ -314,7 +315,7 @@ impl Renderer {
|
||||
let (wu, hu) = (w as usize, h as usize);
|
||||
let mut a: Vec<f32> = pm.data().iter().map(|p| p.a as f32).collect();
|
||||
blur_alpha(&mut a, wu, hu, blur / 2.0 / f.px);
|
||||
let [cr, cg, cb] = color.to_rgb();
|
||||
let [cr, cg, cb] = f.ink.rgb(&color);
|
||||
let tint = |v: f32, ch: f32| (v * ch).round().clamp(0.0, 255.0) as u8;
|
||||
for (px, av) in pm.data_mut().iter_mut().zip(&a) {
|
||||
let al = (av * opacity).clamp(0.0, 255.0);
|
||||
@@ -466,7 +467,7 @@ impl Renderer {
|
||||
RasterFx::OuterGlow { mode, opacity, blur, color } => (*mode, *opacity, 0.0, 0.0, *blur, *color),
|
||||
_ => continue,
|
||||
};
|
||||
let filter = shadow_filter(0.0, 0.0, blur, pcolor(&color));
|
||||
let filter = shadow_filter(0.0, 0.0, blur, pcolor(f.ink, &color));
|
||||
// The offset moves the content rather than the filter: vello_cpu drops layer content
|
||||
// that lies entirely outside the viewport before filtering.
|
||||
let reach = reach + Vec2::new(dx, dy);
|
||||
@@ -520,7 +521,7 @@ impl Renderer {
|
||||
let Some((g, rule)) = content.outline else { return };
|
||||
for fx in rfx {
|
||||
let RasterFx::InnerGlow { mode, opacity, blur, color, center } = fx else { continue };
|
||||
let filter = shadow_filter(0.0, 0.0, *blur, pcolor(color));
|
||||
let filter = shadow_filter(0.0, 0.0, *blur, pcolor(f.ink, color));
|
||||
self.with_filters(ctx, f, g.bounding_box(), *blur, Some((*mode, *opacity)), |_, c, fr, comp| {
|
||||
// The blend layer goes outside the clip: a clip layer is isolated, so a blend
|
||||
// inside it would mix with nothing instead of the content below.
|
||||
@@ -639,7 +640,7 @@ impl Renderer {
|
||||
ctx.push_layer(None, Some(blend_mode(fl.blend)), Some(fl.opacity), None, None);
|
||||
}
|
||||
ctx.set_transform(f.view);
|
||||
if paint::set_paint(ctx, &fl.paint, ib, f.doc) {
|
||||
if paint::set_paint(ctx, &fl.paint, ib, f) {
|
||||
ctx.set_fill_rule(fill_rule(rule));
|
||||
ctx.fill_path(ig);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
//! Blending in CMYK documents.
|
||||
//!
|
||||
//! In a CMYK document blend modes and opacity composite inks, not screen colours: Multiply of cyan
|
||||
//! over magenta prints both inks (blue, C100 M100 through the CMYK profile), where screen RGB would
|
||||
//! give the product of the two display colours. The renderer composites additive values, so a CMYK
|
||||
//! document that shows transparency is drawn twice, once per ink plane
|
||||
//! ([`vectorcraft_color::blend::cmyk_planes`]): every colour painted as its complemented C, M and
|
||||
//! Y ([`Ink::Cmy`]), then as its complemented K, a grey ([`Ink::K`]). Blend modes, opacity,
|
||||
//! groups, knockouts and effects composite the planes exactly as they composite RGB, which is CMYK
|
||||
//! blending. [`compose`] reads each pixel's inks back and shows them through the working CMYK
|
||||
//! profile (a cached 4-D lookup table). Opacity masks keep taking their luminance from screen
|
||||
//! colours; RGB colours and placed images are separated into the working CMYK space first.
|
||||
//!
|
||||
//! RGB documents, and CMYK documents without transparency, draw screen colours ([`Ink::Display`]).
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use vectorcraft_color::Color;
|
||||
use vectorcraft_color::blend::{cmyk_planes, planes_cmyk};
|
||||
use vectorcraft_color::cms::{self, Cms, ProofLut, lab};
|
||||
use vectorcraft_doc::{ColorMode, Document};
|
||||
use vello_cpu::Pixmap;
|
||||
use vello_cpu::peniko;
|
||||
|
||||
use crate::{RenderOptions, Renderer};
|
||||
|
||||
/// What a frame paints for colours.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub(crate) enum Ink {
|
||||
/// Screen colours.
|
||||
#[default]
|
||||
Display,
|
||||
/// The complemented C, M and Y inks.
|
||||
Cmy,
|
||||
/// The complemented K ink, as a grey.
|
||||
K,
|
||||
}
|
||||
|
||||
impl Ink {
|
||||
/// The three values painted for colour `c`.
|
||||
pub(crate) fn rgb(self, c: &Color) -> [f32; 3] {
|
||||
match self {
|
||||
Ink::Display => c.to_rgb(),
|
||||
_ => self.plane(inks(c)),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::rgb`] as a paint, at `alpha`.
|
||||
pub(crate) fn color(self, c: &Color, alpha: f32) -> peniko::Color {
|
||||
let [r, g, b] = self.rgb(c);
|
||||
peniko::Color::from_rgba8(q(r), q(g), q(b), q(alpha))
|
||||
}
|
||||
|
||||
/// A fixed screen colour (pasteboard, guides, tile edges) as a paint.
|
||||
pub(crate) fn fixed(self, rgba: [u8; 4]) -> peniko::Color {
|
||||
let [r, g, b, a] = self.fixed8(rgba);
|
||||
peniko::Color::from_rgba8(r, g, b, a)
|
||||
}
|
||||
|
||||
fn fixed8(self, [r, g, b, a]: [u8; 4]) -> [u8; 4] {
|
||||
match self {
|
||||
Ink::Display => [r, g, b, a],
|
||||
_ => {
|
||||
let lab = lab::srgb_to_lab([r, g, b].map(|v| v as f32 / 255.0));
|
||||
let [r, g, b] = self.plane(inks(&Color::Lab { l: lab.l, a: lab.a, b: lab.b })).map(q);
|
||||
[r, g, b, a]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn plane(self, inks: [f32; 4]) -> [f32; 3] {
|
||||
cmyk_planes(inks)[(self == Ink::K) as usize]
|
||||
}
|
||||
}
|
||||
|
||||
fn q(v: f32) -> u8 {
|
||||
(v.clamp(0.0, 1.0) * 255.0).round() as u8
|
||||
}
|
||||
|
||||
/// Whether `doc` is drawn by ink planes: a CMYK document that shows transparency, unless drawn
|
||||
/// in Outline view or Separations Preview (which paints plates).
|
||||
pub(crate) fn blends_in_cmyk(doc: &Document, opts: &RenderOptions) -> bool {
|
||||
doc.color_mode == ColorMode::Cmyk
|
||||
&& !opts.outline
|
||||
&& opts.proof.as_ref().is_none_or(|p| p.separations.is_none())
|
||||
&& (doc.layers.iter().any(|l| l.shows_transparency())
|
||||
|| doc.symbols.iter().any(|s| s.art.shows_transparency())
|
||||
|| doc.patterns.iter().any(|p| p.art.iter().any(|a| a.shows_transparency())))
|
||||
}
|
||||
|
||||
/// The inks of `c` in the working CMYK space: CMYK as it is, grey as black ink, RGB and Lab
|
||||
/// separated with the colour settings' intent (cached: separating searches the profile).
|
||||
fn inks(c: &Color) -> [f32; 4] {
|
||||
let key = match *c {
|
||||
Color::Cmyk { c, m, y, k } => return [c, m, y, k],
|
||||
Color::Gray { k } => return [0.0, 0.0, 0.0, k],
|
||||
Color::Rgb { r, g, b } => [0, r.to_bits(), g.to_bits(), b.to_bits()],
|
||||
Color::Lab { l, a, b } => [1, l.to_bits(), a.to_bits(), b.to_bits()],
|
||||
};
|
||||
let cms = cms::active();
|
||||
SEPARATED.with(|s| {
|
||||
let (owner, seen) = &mut *s.borrow_mut();
|
||||
if !owner.as_ref().is_some_and(|o| Arc::ptr_eq(o, &cms)) || seen.len() >= 4096 {
|
||||
seen.clear();
|
||||
*owner = Some(cms.clone());
|
||||
}
|
||||
*seen.entry(key).or_insert_with(|| cms.to_cmyk(c, cms.settings().intent))
|
||||
})
|
||||
}
|
||||
|
||||
/// Separated colours, for the colour settings they were separated with.
|
||||
type Separated = (Option<Arc<Cms>>, HashMap<[u32; 4], [f32; 4]>);
|
||||
|
||||
thread_local! {
|
||||
static SEPARATED: RefCell<Separated> = RefCell::default();
|
||||
}
|
||||
|
||||
/// Grid points per ink of [`InkLut`].
|
||||
const LUT_N: usize = 17;
|
||||
|
||||
/// Inks → display sRGB through the working CMYK profile: a 17⁴ lookup table of XYZ, interpolated
|
||||
/// quadrilinearly (inks mix close to linearly in light, and out-of-gamut colours are clipped
|
||||
/// after interpolating, not before).
|
||||
struct InkLut {
|
||||
data: Vec<[f32; 3]>,
|
||||
}
|
||||
|
||||
impl InkLut {
|
||||
fn build(cms: &Cms) -> Self {
|
||||
let s = (LUT_N - 1) as f32;
|
||||
let mut data = Vec::with_capacity(LUT_N.pow(4));
|
||||
for c in 0..LUT_N {
|
||||
for m in 0..LUT_N {
|
||||
for y in 0..LUT_N {
|
||||
for k in 0..LUT_N {
|
||||
data.push(lab::lab_to_xyz(cms.cmyk_to_lab([c, m, y, k].map(|i| i as f32 / s))));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Self { data }
|
||||
}
|
||||
|
||||
fn apply(&self, inks: [f32; 4]) -> [f32; 3] {
|
||||
const STRIDE: [usize; 4] = [LUT_N * LUT_N * LUT_N, LUT_N * LUT_N, LUT_N, 1];
|
||||
let p = inks.map(|v| v.clamp(0.0, 1.0) * (LUT_N - 1) as f32);
|
||||
let i = p.map(|v| (v as usize).min(LUT_N - 2));
|
||||
let base: usize = (0..4).map(|d| i[d] * STRIDE[d]).sum();
|
||||
let mut out = [0.0f32; 3];
|
||||
for corner in 0..16 {
|
||||
let (mut w, mut at) = (1.0f32, base);
|
||||
for d in 0..4 {
|
||||
let t = p[d] - i[d] as f32;
|
||||
if corner >> d & 1 == 1 {
|
||||
w *= t;
|
||||
at += STRIDE[d];
|
||||
} else {
|
||||
w *= 1.0 - t;
|
||||
}
|
||||
}
|
||||
if w > 0.0 {
|
||||
for (o, v) in out.iter_mut().zip(self.data[at]) {
|
||||
*o += w * v;
|
||||
}
|
||||
}
|
||||
}
|
||||
lab::xyz_to_srgb(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Placed images as painted with [`Ink::Cmy`] and [`Ink::K`], per image key, with the tables they
|
||||
/// were separated by.
|
||||
pub(crate) type InkImages = HashMap<String, (Arc<[ProofLut; 2]>, [Arc<Pixmap>; 2])>;
|
||||
|
||||
/// A table built for the colour settings it was built with.
|
||||
type Cached<T> = Mutex<Option<(Arc<Cms>, Arc<T>)>>;
|
||||
|
||||
static DISPLAY: Cached<InkLut> = Mutex::new(None);
|
||||
/// Screen colours → [`Ink::Cmy`] and [`Ink::K`] values (placed images).
|
||||
static SEPARATE: Cached<[ProofLut; 2]> = Mutex::new(None);
|
||||
|
||||
/// The table in `slot` for the active colour settings, built by `build` when they changed.
|
||||
fn cached<T>(slot: &Cached<T>, build: impl FnOnce(&Cms) -> T) -> Arc<T> {
|
||||
let cms = cms::active();
|
||||
let mut s = slot.lock().unwrap_or_else(|e| e.into_inner());
|
||||
match &*s {
|
||||
Some((owner, t)) if Arc::ptr_eq(owner, &cms) => t.clone(),
|
||||
_ => {
|
||||
let t = Arc::new(build(&cms));
|
||||
*s = Some((cms, t.clone()));
|
||||
t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The picture of a CMYK document from its ink planes (premultiplied RGBA8 drawn with
|
||||
/// [`Ink::Cmy`] and [`Ink::K`]): each pixel's inks shown through the working CMYK profile. Where
|
||||
/// only the frame's `background` shows it keeps its exact screen colour.
|
||||
pub(crate) fn compose(cmy: &[u8], k: &[u8], background: Option<[u8; 4]>) -> Vec<u8> {
|
||||
let lut = cached(&DISPLAY, InkLut::build);
|
||||
let bg = background.map(|b| (Ink::Cmy.fixed8(b), Ink::K.fixed8(b), b));
|
||||
let mut out = Vec::with_capacity(cmy.len());
|
||||
// Runs of one colour convert once.
|
||||
let mut last: Option<([u8; 8], [u8; 4])> = None;
|
||||
for (p, q) in cmy.chunks_exact(4).zip(k.chunks_exact(4)) {
|
||||
let key = [p[0], p[1], p[2], p[3], q[0], q[1], q[2], q[3]];
|
||||
let px = match (&last, &bg) {
|
||||
_ if p[3] == 0 => [0; 4],
|
||||
(Some((k0, v)), _) if *k0 == key => *v,
|
||||
(_, Some((bc, bk, b))) if p == bc && q == bk => *b,
|
||||
_ => {
|
||||
let un = |v: u8, a: u8| if a == 0 { 1.0 } else { (v as f32 / a as f32).min(1.0) };
|
||||
let a = p[3];
|
||||
let rgb = lut.apply(planes_cmyk([un(p[0], a), un(p[1], a), un(p[2], a)], un(q[0], q[3])));
|
||||
let pm = |v: f32| (v.clamp(0.0, 1.0) * a as f32).round() as u8;
|
||||
let v = [pm(rgb[0]), pm(rgb[1]), pm(rgb[2]), a];
|
||||
last = Some((key, v));
|
||||
v
|
||||
}
|
||||
};
|
||||
out.extend_from_slice(&px);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
/// Placed image `key` (decoded as `pm`) as painted with `ink`, cached.
|
||||
pub(crate) fn ink_image(&mut self, key: &str, pm: &Arc<Pixmap>, ink: Ink) -> Arc<Pixmap> {
|
||||
if ink == Ink::Display {
|
||||
return pm.clone();
|
||||
}
|
||||
let luts = cached(&SEPARATE, |cms| {
|
||||
let intent = cms.settings().intent;
|
||||
[Ink::Cmy, Ink::K].map(|ink| ProofLut::build(|rgb| ink.plane(cms.srgb_to_cmyk(rgb, intent))))
|
||||
});
|
||||
let at = (ink == Ink::K) as usize;
|
||||
if let Some((owner, planes)) = self.ink_images.get(key)
|
||||
&& Arc::ptr_eq(owner, &luts)
|
||||
{
|
||||
return planes[at].clone();
|
||||
}
|
||||
let planes = [0, 1].map(|i| {
|
||||
let mut out = Pixmap::new(pm.width(), pm.height());
|
||||
for (o, s) in out.data_mut().iter_mut().zip(pm.data()) {
|
||||
let a = s.a;
|
||||
if a == 0 {
|
||||
continue;
|
||||
}
|
||||
let un = |v: u8| (v as f32 / a as f32).min(1.0);
|
||||
let [r, g, b] = luts[i].apply([un(s.r), un(s.g), un(s.b)]).map(|v| (v.clamp(0.0, 1.0) * a as f32).round() as u8);
|
||||
*o = vello_cpu::color::PremulRgba8 { r, g, b, a };
|
||||
}
|
||||
Arc::new(out)
|
||||
});
|
||||
let out = planes[at].clone();
|
||||
self.ink_images.insert(key.to_string(), (luts, planes));
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_ink_table_matches_the_profile() {
|
||||
let cms = cms::active();
|
||||
let lut = InkLut::build(&cms);
|
||||
let mut worst = 0.0f32;
|
||||
// Between the grid points (where interpolation errs most) and on them.
|
||||
for i in 0..=20 {
|
||||
for j in 0..=20 {
|
||||
let inks = [i as f32 / 20.0, j as f32 / 20.0, ((i * 7 + j * 3) % 21) as f32 / 20.0, ((i + j * 5) % 21) as f32 / 20.0];
|
||||
let (a, b) = (lut.apply(inks), cms.cmyk_to_srgb(inks, false));
|
||||
worst = worst.max((0..3).map(|c| (a[c] - b[c]).abs()).fold(0.0, f32::max));
|
||||
}
|
||||
}
|
||||
// Within two 8-bit levels: far below a visible difference.
|
||||
assert!(worst * 255.0 < 2.5, "worst error {} levels", worst * 255.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_colours_and_paper() {
|
||||
// White is paper on both planes; the display plane keeps screen colours exactly.
|
||||
assert_eq!(Ink::Cmy.fixed8([255; 4]), [255; 4]);
|
||||
assert_eq!(Ink::K.fixed8([255; 4]), [255; 4]);
|
||||
assert_eq!(Ink::Display.fixed8([12, 34, 56, 78]), [12, 34, 56, 78]);
|
||||
assert_eq!(Ink::K.rgb(&Color::gray(0.25)), [0.75; 3]);
|
||||
assert_eq!(Ink::Cmy.rgb(&Color::cmyk(0.1, 0.2, 0.3, 0.4)), [0.9, 0.8, 0.7]);
|
||||
}
|
||||
}
|
||||
+79
-54
@@ -10,6 +10,7 @@ mod brush_fx;
|
||||
mod freeform;
|
||||
mod fx;
|
||||
mod group;
|
||||
mod ink;
|
||||
mod live;
|
||||
mod paint;
|
||||
mod pattern;
|
||||
@@ -25,6 +26,7 @@ use vello_cpu::peniko::{self, BlendMode, Compose, Mix};
|
||||
use vello_cpu::{Pixmap, RenderContext, Resources};
|
||||
|
||||
use group::Composite;
|
||||
use ink::Ink;
|
||||
|
||||
pub use brush_fx::instance_art;
|
||||
pub use effects::stroke::width_outline;
|
||||
@@ -243,7 +245,7 @@ pub struct Renderer {
|
||||
/// Evaluated blends/envelopes and tessellated meshes.
|
||||
live: live::LiveCache,
|
||||
/// Blurred, tinted drop shadow / outer glow rasters per object and effect (see `fx`).
|
||||
shadows: PtrMap<(usize, usize), fx::ShadowEntry>,
|
||||
shadows: PtrMap<(usize, usize, Ink), fx::ShadowEntry>,
|
||||
/// Whether the group being drawn is a knockout group (what its neutral children inherit).
|
||||
knockout: bool,
|
||||
/// Address of the knockout-group element being drawn as its knockout shape: at full object
|
||||
@@ -264,6 +266,8 @@ pub struct Renderer {
|
||||
clip_paths: Vec<(BezPath, FillRule, Affine)>,
|
||||
/// What clipping paths paint, per clipping path (see [`Self::clip_paint_of`]).
|
||||
clip_paints: PtrMap<usize, ClipPaintEntry>,
|
||||
/// Placed images as painted in ink planes (see [`ink`]).
|
||||
ink_images: ink::InkImages,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
@@ -290,6 +294,8 @@ struct Frame<'a> {
|
||||
/// Size of one output pixel in document units.
|
||||
px: f64,
|
||||
opts: &'a RenderOptions,
|
||||
/// What colours paint as: screen colours, or one ink plane of a CMYK document (see [`ink`]).
|
||||
ink: Ink,
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
@@ -319,6 +325,7 @@ impl Renderer {
|
||||
blends: PtrMap::default(),
|
||||
clip_paths: vec![],
|
||||
clip_paints: PtrMap::default(),
|
||||
ink_images: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,48 +343,24 @@ impl Renderer {
|
||||
let inv = view.inverse();
|
||||
let visible = inv.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64));
|
||||
let px = 1.0 / view.determinant().abs().sqrt().max(1e-12);
|
||||
let frame = Frame { mt: threads > 0, doc, view, visible, px, opts };
|
||||
let frame = Frame { mt: threads > 0, doc, view, visible, px, opts, ink: Ink::Display };
|
||||
self.stamp += 1;
|
||||
// View Opacity Mask: the mask's coverage in greyscale instead of the artwork.
|
||||
if let Some(m) = opts.mask_view.and_then(|id| doc.node(id)).and_then(|n| n.mask.as_deref()) {
|
||||
self.stamp += 1;
|
||||
let pixels = self.mask_values(&frame, m, w, h).into_iter().flat_map(|v| [v, v, v, 255]).collect();
|
||||
self.stats.micros = now().saturating_sub(start);
|
||||
return Rendered { width: w as u32, height: h as u32, pixels };
|
||||
}
|
||||
let slot = if threads == 0 { self.ctx_st.take() } else { self.ctx.take() };
|
||||
let mut ctx = match slot {
|
||||
Some(mut c) if c.width() == w && c.height() == h => {
|
||||
c.reset();
|
||||
c
|
||||
}
|
||||
_ => RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: threads, ..Default::default() }),
|
||||
};
|
||||
if let Some(bg) = opts.background {
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.set_paint(peniko::Color::from_rgba8(bg[0], bg[1], bg[2], bg[3]));
|
||||
ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, h as f64));
|
||||
}
|
||||
if opts.artboards && !opts.outline {
|
||||
ctx.set_transform(view);
|
||||
ctx.set_paint(peniko::Color::WHITE);
|
||||
for ab in &doc.artboards {
|
||||
ctx.fill_rect(&ab.rect);
|
||||
}
|
||||
}
|
||||
self.stamp += 1;
|
||||
(self.nested, self.backdrop) = (0, None);
|
||||
self.clip_paths.clear();
|
||||
if opts.trim && !doc.artboards.is_empty() {
|
||||
let mut clip = BezPath::new();
|
||||
for ab in &doc.artboards {
|
||||
clip.extend(ab.rect.path_elements(0.1));
|
||||
}
|
||||
let blends = self.children_blend(&doc.layers);
|
||||
let trim = Composite { clip: Some((&clip, FillRule::NonZero)), blends, ..Default::default() };
|
||||
self.group(&mut ctx, &frame, trim, &mut |r, c, fr| r.draw_page(c, fr));
|
||||
let mut pixels = if ink::blends_in_cmyk(doc, opts) {
|
||||
// Blending in CMYK: one frame per ink plane, then their inks shown on screen.
|
||||
let cmy = self.draw_frame(&Frame { ink: Ink::Cmy, ..frame }, w, h);
|
||||
let stats = self.stats;
|
||||
let k = self.draw_frame(&Frame { ink: Ink::K, ..frame }, w, h);
|
||||
self.stats = stats;
|
||||
ink::compose(&cmy, &k, opts.background)
|
||||
} else {
|
||||
self.draw_page(&mut ctx, &frame);
|
||||
}
|
||||
self.draw_frame(&frame, w, h)
|
||||
};
|
||||
// Drop cache entries not seen for a few frames.
|
||||
let g = self.stamp;
|
||||
if self.geom.len() > 1024 {
|
||||
@@ -389,6 +372,48 @@ impl Renderer {
|
||||
if self.shadows.len() > 256 {
|
||||
self.shadows.retain(|_, e| g - e.stamp <= 3);
|
||||
}
|
||||
proof::post(&mut pixels, opts);
|
||||
self.stats.micros = now().saturating_sub(start);
|
||||
Rendered { width: w as u32, height: h as u32, pixels }
|
||||
}
|
||||
|
||||
/// Draw frame `f` (`w`×`h` pixels): the background, the artboards and the page's art.
|
||||
fn draw_frame(&mut self, f: &Frame, w: u16, h: u16) -> Vec<u8> {
|
||||
let (threads, opts) = (self.threads, f.opts);
|
||||
let slot = if threads == 0 { self.ctx_st.take() } else { self.ctx.take() };
|
||||
let mut ctx = match slot {
|
||||
Some(mut c) if c.width() == w && c.height() == h => {
|
||||
c.reset();
|
||||
c
|
||||
}
|
||||
_ => RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: threads, ..Default::default() }),
|
||||
};
|
||||
if let Some(bg) = opts.background {
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.set_paint(f.ink.fixed(bg));
|
||||
ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, h as f64));
|
||||
}
|
||||
if opts.artboards && !opts.outline {
|
||||
// Paper: white on screen, no ink on the ink planes.
|
||||
ctx.set_transform(f.view);
|
||||
ctx.set_paint(peniko::Color::WHITE);
|
||||
for ab in &f.doc.artboards {
|
||||
ctx.fill_rect(&ab.rect);
|
||||
}
|
||||
}
|
||||
(self.nested, self.backdrop) = (0, None);
|
||||
self.clip_paths.clear();
|
||||
if opts.trim && !f.doc.artboards.is_empty() {
|
||||
let mut clip = BezPath::new();
|
||||
for ab in &f.doc.artboards {
|
||||
clip.extend(ab.rect.path_elements(0.1));
|
||||
}
|
||||
let blends = self.children_blend(&f.doc.layers);
|
||||
let trim = Composite { clip: Some((&clip, FillRule::NonZero)), blends, ..Default::default() };
|
||||
self.group(&mut ctx, f, trim, &mut |r, c, fr| r.draw_page(c, fr));
|
||||
} else {
|
||||
self.draw_page(&mut ctx, f);
|
||||
}
|
||||
ctx.flush();
|
||||
let mut pm = Pixmap::new(w, h);
|
||||
ctx.render(&mut pm, &mut self.resources);
|
||||
@@ -397,10 +422,7 @@ impl Renderer {
|
||||
} else {
|
||||
self.ctx = Some(ctx);
|
||||
}
|
||||
let mut pixels = pm.data_as_u8_slice().to_vec();
|
||||
proof::post(&mut pixels, opts);
|
||||
self.stats.micros = now().saturating_sub(start);
|
||||
Rendered { width: w as u32, height: h as u32, pixels }
|
||||
pm.data_as_u8_slice().to_vec()
|
||||
}
|
||||
|
||||
/// The page's art: the layers (or the pattern being edited), inside the page group when the
|
||||
@@ -456,7 +478,7 @@ impl Renderer {
|
||||
ctx.set_paint(peniko::Color::from_rgba8(bg[0], bg[1], bg[2], bg[3]));
|
||||
ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, w as f64));
|
||||
}
|
||||
let frame = Frame { mt: false, doc, view, visible: b.inflate(1.0, 1.0), px: 1.0 / s, opts: &RenderOptions::default() };
|
||||
let frame = Frame { mt: false, doc, view, visible: b.inflate(1.0, 1.0), px: 1.0 / s, opts: &RenderOptions::default(), ink: Ink::Display };
|
||||
(self.knockout, self.nested, self.backdrop) = (false, 0, None);
|
||||
self.clip_paths.clear();
|
||||
self.draw_node(&mut ctx, &frame, n, true);
|
||||
@@ -588,10 +610,11 @@ impl Renderer {
|
||||
/// as luminance ([`mask_value`]).
|
||||
fn mask_values(&mut self, f: &Frame, m: &vectorcraft_doc::OpacityMask, w: u16, h: u16) -> Vec<u8> {
|
||||
let mut mctx = single_threaded_context(w, h);
|
||||
// Mask art is a picture of its own: it takes no part in a knockout group around the object.
|
||||
// Mask art is a picture of its own: it takes no part in a knockout group around the object,
|
||||
// and its luminance is that of its screen colours.
|
||||
let outer =
|
||||
(std::mem::take(&mut self.knockout), std::mem::take(&mut self.nested), self.backdrop.take(), std::mem::take(&mut self.clip_paths));
|
||||
self.draw_node(&mut mctx, &Frame { mt: false, ..*f }, &m.art, true);
|
||||
self.draw_node(&mut mctx, &Frame { mt: false, ink: Ink::Display, ..*f }, &m.art, true);
|
||||
(self.knockout, self.nested, self.backdrop, self.clip_paths) = outer;
|
||||
mctx.flush();
|
||||
let mut pm = Pixmap::new(w, h);
|
||||
@@ -798,7 +821,7 @@ impl Renderer {
|
||||
let mut screen = bp.clone();
|
||||
screen.apply_affine(f.view);
|
||||
ctx.set_stroke(kurbo::Stroke::new(1.0));
|
||||
ctx.set_paint(peniko::Color::from_rgba8(rgba[0], rgba[1], rgba[2], rgba[3]));
|
||||
ctx.set_paint(f.ink.fixed(rgba));
|
||||
ctx.stroke_path(&screen);
|
||||
}
|
||||
|
||||
@@ -829,8 +852,8 @@ impl Renderer {
|
||||
ctx.push_layer(None, Some(blend_mode(fl.blend)), Some(fl.opacity), None, None);
|
||||
}
|
||||
ctx.set_transform(f.view);
|
||||
if paint::set_paint(ctx, &fl.paint, bounds, f.doc) {
|
||||
self.fold_alpha(ctx, &fl.paint);
|
||||
if paint::set_paint(ctx, &fl.paint, bounds, f) {
|
||||
self.fold_alpha(ctx, f.ink, &fl.paint);
|
||||
ctx.set_fill_rule(fill_rule(rule));
|
||||
ctx.fill_path(bp);
|
||||
}
|
||||
@@ -852,12 +875,11 @@ impl Renderer {
|
||||
}
|
||||
|
||||
/// Multiply the folded object opacity into a solid paint.
|
||||
fn fold_alpha(&self, ctx: &mut RenderContext, p: &vectorcraft_color::Paint) {
|
||||
fn fold_alpha(&self, ctx: &mut RenderContext, ink: Ink, p: &vectorcraft_color::Paint) {
|
||||
if self.alpha < 1.0
|
||||
&& let vectorcraft_color::Paint::Solid { color, .. } = p
|
||||
{
|
||||
let [r, g, b, a] = color.to_rgba8(self.alpha);
|
||||
ctx.set_paint(peniko::Color::from_rgba8(r, g, b, a));
|
||||
ctx.set_paint(ink.color(color, self.alpha));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -886,8 +908,8 @@ impl Renderer {
|
||||
}
|
||||
// One paint box for the line and the heads, so a gradient runs on into the heads.
|
||||
let paint_bounds = bounds.inflate(st.width / 2.0, st.width / 2.0);
|
||||
if paint::set_paint(ctx, &st.paint, paint_bounds, f.doc) {
|
||||
self.fold_alpha(ctx, &st.paint);
|
||||
if paint::set_paint(ctx, &st.paint, paint_bounds, f) {
|
||||
self.fold_alpha(ctx, f.ink, &st.paint);
|
||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||
match self.cached_stroke(f, &pieces.line, st, width) {
|
||||
Some(o) => ctx.fill_path(&o),
|
||||
@@ -994,8 +1016,8 @@ impl Renderer {
|
||||
}
|
||||
ctx.set_transform(xf);
|
||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||
if paint::set_paint(ctx, &run.style.fill, g.bounds, f.doc) {
|
||||
self.fold_alpha(ctx, &run.style.fill);
|
||||
if paint::set_paint(ctx, &run.style.fill, g.bounds, f) {
|
||||
self.fold_alpha(ctx, f.ink, &run.style.fill);
|
||||
overprint(ctx, run.style.overprint_fill);
|
||||
ctx.fill_path(path);
|
||||
}
|
||||
@@ -1024,7 +1046,7 @@ impl Renderer {
|
||||
ctx.push_layer(None, Some(blend_mode(fl.blend)), Some(fl.opacity), None, None);
|
||||
}
|
||||
ctx.set_transform(f.view);
|
||||
if paint::set_paint(ctx, &fl.paint, tb, f.doc) {
|
||||
if paint::set_paint(ctx, &fl.paint, tb, f) {
|
||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||
ctx.fill_path(all);
|
||||
}
|
||||
@@ -1060,6 +1082,7 @@ impl Renderer {
|
||||
pm
|
||||
}
|
||||
};
|
||||
let pm = self.ink_image(&im.key, &pm, f.ink);
|
||||
let sx = im.width as f64 / pm.width().max(1) as f64;
|
||||
let sy = im.height as f64 / pm.height().max(1) as f64;
|
||||
ctx.set_transform(f.view * im.xf);
|
||||
@@ -1213,6 +1236,8 @@ mod tests_charstroke;
|
||||
#[cfg(test)]
|
||||
mod tests_clip;
|
||||
#[cfg(test)]
|
||||
mod tests_cmykblend;
|
||||
#[cfg(test)]
|
||||
mod tests_container;
|
||||
#[cfg(test)]
|
||||
mod tests_freeform;
|
||||
|
||||
@@ -171,8 +171,7 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
bp.close_path();
|
||||
let [r, g, bl, al] = q.color.to_rgba8(q.opacity * alpha);
|
||||
ctx.set_paint(peniko::Color::from_rgba8(r, g, bl, al));
|
||||
ctx.set_paint(f.ink.color(&q.color, q.opacity * alpha));
|
||||
ctx.fill_path(&bp);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,25 @@
|
||||
//! Paint conversion (solid, gradients, patterns) and image decoding.
|
||||
|
||||
use vectorcraft_color::{GradientKind, Paint};
|
||||
use vectorcraft_doc::Document;
|
||||
use vectorcraft_geom::{Affine, Rect, Vec2};
|
||||
use vello_cpu::RenderContext;
|
||||
use vello_cpu::peniko::{self, ColorStop};
|
||||
|
||||
fn color(c: &vectorcraft_color::Color, alpha: f32) -> peniko::Color {
|
||||
let [r, g, b, a] = c.to_rgba8(alpha);
|
||||
peniko::Color::from_rgba8(r, g, b, a)
|
||||
}
|
||||
use crate::Frame;
|
||||
|
||||
/// Set the context paint. Returns false if nothing should be drawn.
|
||||
pub fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, doc: &Document) -> bool {
|
||||
/// Set the context paint (colours as frame `f` paints them). Returns false if nothing should be
|
||||
/// drawn.
|
||||
pub(crate) fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Frame) -> bool {
|
||||
match p {
|
||||
Paint::None => false,
|
||||
Paint::Solid { color: c, .. } => {
|
||||
ctx.set_paint(color(c, 1.0));
|
||||
ctx.set_paint(f.ink.color(c, 1.0));
|
||||
true
|
||||
}
|
||||
Paint::Gradient(g) if g.gradient.kind == GradientKind::Freeform => crate::freeform::set_freeform_paint(ctx, g, bounds),
|
||||
Paint::Gradient(g) if g.gradient.kind == GradientKind::Freeform => crate::freeform::set_freeform_paint(ctx, g, bounds, f.ink),
|
||||
Paint::Gradient(g) => {
|
||||
let geom = g.resolve(bounds);
|
||||
let stops: Vec<ColorStop> = g.gradient.expanded_stops().iter().map(|(o, c, a)| ColorStop::from((*o, color(c, *a)))).collect();
|
||||
let stops: Vec<ColorStop> = g.gradient.expanded_stops().iter().map(|(o, c, a)| ColorStop::from((*o, f.ink.color(c, *a)))).collect();
|
||||
if stops.is_empty() {
|
||||
return false;
|
||||
}
|
||||
@@ -50,7 +47,7 @@ pub fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, doc: &Documen
|
||||
ctx.set_paint(grad);
|
||||
true
|
||||
}
|
||||
Paint::Pattern { pattern, xf } => crate::pattern::set_pattern_paint(ctx, pattern, *xf, doc),
|
||||
Paint::Pattern { pattern, xf } => crate::pattern::set_pattern_paint(ctx, pattern, *xf, f),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
//! ([`PatternDef::period`]) is rasterized once at the current device scale (rounded to a power of
|
||||
//! two, so it is re-rasterized only when the zoom changes by more than 2×) and used as an image
|
||||
//! paint with `Extend::Repeat`, with the paint transform mapping pixels → pattern space →
|
||||
//! document. Tiles are cached per pattern name and invalidated when the definition changes (art
|
||||
//! `Arc` identity, tile, tile type, overlap) — the pattern's "revision".
|
||||
//! document. Tiles are cached per pattern name (and ink plane, see [`crate::ink`]) and invalidated
|
||||
//! when the definition changes (art `Arc` identity, tile, tile type, overlap) — the pattern's
|
||||
//! "revision".
|
||||
//!
|
||||
//! Pattern editing mode (`Document::pattern_edit`) draws only the temporary tile layer, with
|
||||
//! dimmed copies of it at the neighbouring tile positions and the tile edge.
|
||||
@@ -17,9 +18,10 @@ use std::sync::Arc;
|
||||
use vectorcraft_doc::pattern::{Overlap, PatternDef, TileType};
|
||||
use vectorcraft_doc::{Document, Node};
|
||||
use vectorcraft_geom::{Affine, Rect, Shape};
|
||||
use vello_cpu::peniko::{self, Extend, ImageQuality, ImageSampler};
|
||||
use vello_cpu::peniko::{Extend, ImageQuality, ImageSampler};
|
||||
use vello_cpu::{Pixmap, RenderContext};
|
||||
|
||||
use crate::ink::Ink;
|
||||
use crate::{Frame, RenderOptions, Rendered, Renderer};
|
||||
|
||||
/// Largest super-tile raster side (pixels).
|
||||
@@ -36,7 +38,8 @@ struct TileEntry {
|
||||
|
||||
#[derive(Default)]
|
||||
struct PatternCache {
|
||||
tiles: HashMap<String, TileEntry>,
|
||||
/// Per ink ([`Ink`] as index).
|
||||
tiles: [HashMap<String, TileEntry>; 3],
|
||||
renderer: Option<Box<Renderer>>,
|
||||
depth: u32,
|
||||
}
|
||||
@@ -58,8 +61,9 @@ pub(crate) fn raster_scale(s: f64, period: (f64, f64)) -> f64 {
|
||||
p2.min(cap).max(1e-3)
|
||||
}
|
||||
|
||||
/// Rasterize `region` (pattern space) of the infinite tiling into a `w`×`h` pixmap.
|
||||
fn rasterize(r: &mut Renderer, doc: &Document, def: &PatternDef, region: Rect, w: u16, h: u16) -> Pixmap {
|
||||
/// Rasterize `region` (pattern space) of the infinite tiling into a `w`×`h` pixmap, colours as
|
||||
/// `ink` paints them.
|
||||
fn rasterize(r: &mut Renderer, doc: &Document, def: &PatternDef, region: Rect, w: u16, h: u16, ink: Ink) -> Pixmap {
|
||||
let (w, h) = (w.max(1), h.max(1));
|
||||
let mut ctx = crate::single_threaded_context(w, h);
|
||||
let view = Affine::scale_non_uniform(w as f64 / region.width().max(1e-9), h as f64 / region.height().max(1e-9))
|
||||
@@ -76,6 +80,7 @@ fn rasterize(r: &mut Renderer, doc: &Document, def: &PatternDef, region: Rect, w
|
||||
visible: v.inverse().transform_rect_bbox(px_rect),
|
||||
px: 1.0 / v.determinant().abs().sqrt().max(1e-12),
|
||||
opts: &opts,
|
||||
ink,
|
||||
};
|
||||
for a in &def.art {
|
||||
r.draw_arc(&mut ctx, &frame, a);
|
||||
@@ -114,10 +119,13 @@ fn with_tile_renderer<T>(f: impl FnOnce(&mut Renderer) -> T) -> Option<T> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Set a pattern paint on `ctx` (its current transform maps the paint's user space to pixels).
|
||||
pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine, doc: &Document) -> bool {
|
||||
/// Set a pattern paint on `ctx` (its current transform maps the paint's user space to pixels), as
|
||||
/// frame `f` paints it.
|
||||
pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine, f: &Frame) -> bool {
|
||||
let (doc, ink) = (f.doc, f.ink);
|
||||
let missing = || ink.fixed([128, 128, 128, 255]);
|
||||
let Some(def) = doc.pattern(name) else {
|
||||
ctx.set_paint(peniko::Color::from_rgba8(128, 128, 128, 255));
|
||||
ctx.set_paint(missing());
|
||||
return true;
|
||||
};
|
||||
if def.art.is_empty() {
|
||||
@@ -129,8 +137,7 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine,
|
||||
let scale = raster_scale(s, (pw, ph));
|
||||
let params = (def.tile, def.tile_type, def.overlap);
|
||||
let hit = CACHE.with(|c| {
|
||||
c.borrow()
|
||||
.tiles
|
||||
c.borrow().tiles[ink as usize]
|
||||
.get(name)
|
||||
.filter(|e| e.scale == scale && e.params == params && same_art(&e.art, &def.art))
|
||||
.map(|e| (e.pixmap.clone(), e.sx, e.sy))
|
||||
@@ -140,18 +147,19 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine,
|
||||
None => {
|
||||
let w = (pw * scale).ceil().clamp(1.0, 4096.0) as u16;
|
||||
let h = (ph * scale).ceil().clamp(1.0, 4096.0) as u16;
|
||||
let Some(pm) = with_tile_renderer(|r| rasterize(r, doc, def, Rect::new(0.0, 0.0, pw, ph), w, h)) else {
|
||||
ctx.set_paint(peniko::Color::from_rgba8(128, 128, 128, 255));
|
||||
let Some(pm) = with_tile_renderer(|r| rasterize(r, doc, def, Rect::new(0.0, 0.0, pw, ph), w, h, ink)) else {
|
||||
ctx.set_paint(missing());
|
||||
return true;
|
||||
};
|
||||
let pm = Arc::new(pm);
|
||||
let (sx, sy) = (w as f64 / pw, h as f64 / ph);
|
||||
CACHE.with(|c| {
|
||||
let mut c = c.borrow_mut();
|
||||
if c.tiles.len() > 64 {
|
||||
c.tiles.clear();
|
||||
let tiles = &mut c.tiles[ink as usize];
|
||||
if tiles.len() > 64 {
|
||||
tiles.clear();
|
||||
}
|
||||
c.tiles.insert(name.to_string(), TileEntry { art: def.art.clone(), params, scale, sx, sy, pixmap: pm.clone() });
|
||||
tiles.insert(name.to_string(), TileEntry { art: def.art.clone(), params, scale, sx, sy, pixmap: pm.clone() });
|
||||
});
|
||||
(pm, sx, sy)
|
||||
}
|
||||
@@ -169,7 +177,7 @@ pub fn render_pattern_swatch(doc: &Document, name: &str, size: u32) -> Option<Re
|
||||
let side = w.max(h);
|
||||
let region = Rect::from_center_size((w / 2.0, h / 2.0), (side, side));
|
||||
let px = size.clamp(1, 1024) as u16;
|
||||
let pm = with_tile_renderer(|r| rasterize(r, doc, def, region, px, px))?;
|
||||
let pm = with_tile_renderer(|r| rasterize(r, doc, def, region, px, px, Ink::Display))?;
|
||||
Some(Rendered { width: px as u32, height: px as u32, pixels: pm.data_as_u8_slice().to_vec() })
|
||||
}
|
||||
|
||||
@@ -203,7 +211,7 @@ impl Renderer {
|
||||
bounds.apply_affine(f.view);
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.set_stroke(vello_cpu::kurbo::Stroke::new(1.0).with_dashes(0.0, [4.0, 3.0]));
|
||||
ctx.set_paint(peniko::Color::from_rgba8(r, g, b, 255));
|
||||
ctx.set_paint(f.ink.fixed([r, g, b, 255]));
|
||||
ctx.stroke_path(&bounds);
|
||||
}
|
||||
true
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Blending in CMYK documents ([`crate::ink`]): blend modes and opacity composite inks.
|
||||
|
||||
use super::*;
|
||||
use vectorcraft_color::blend::MASK_LUM;
|
||||
use vectorcraft_color::{BlendMode as Blend, Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, ColorMode, ImageBlob, ImageObject, Node, OpacityMask};
|
||||
use vectorcraft_geom::shapes;
|
||||
|
||||
fn rect(d: &mut Document, r: Rect, c: Color) -> Node {
|
||||
Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::solid(c), Paint::None, 0.0))
|
||||
}
|
||||
|
||||
fn add(d: &mut Document, n: Node) {
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), usize::MAX, n).unwrap();
|
||||
}
|
||||
|
||||
/// `below` over the whole 20 pt page and `top` (10 pt in the middle) with `mode` and `opacity`.
|
||||
fn doc(mode: ColorMode, below: Option<Color>, top: Color, blend: Blend, opacity: f32) -> Document {
|
||||
let mut d = Document::new_with_mode(20.0, 20.0, mode);
|
||||
if let Some(b) = below {
|
||||
let n = rect(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0), b);
|
||||
add(&mut d, n);
|
||||
}
|
||||
let mut n = rect(&mut d, Rect::new(5.0, 5.0, 15.0, 15.0), top);
|
||||
(n.blend, n.opacity) = (blend, opacity);
|
||||
add(&mut d, n);
|
||||
d
|
||||
}
|
||||
|
||||
/// The centre pixel, rendered on white.
|
||||
fn centre(d: &Document) -> [u8; 4] {
|
||||
Renderer::new().render_region(d, d.artboards[0].rect, 1.0, true).pixel(10, 10)
|
||||
}
|
||||
|
||||
/// Screen colour of inks `cmyk`.
|
||||
fn shown(cmyk: [f32; 4]) -> [u8; 4] {
|
||||
Color::cmyk(cmyk[0], cmyk[1], cmyk[2], cmyk[3]).to_rgba8(1.0)
|
||||
}
|
||||
|
||||
fn near(a: [u8; 4], b: [u8; 4], tol: i32) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (*x as i32 - y as i32).abs() <= tol)
|
||||
}
|
||||
|
||||
const CYAN: Color = Color::Cmyk { c: 1.0, m: 0.0, y: 0.0, k: 0.0 };
|
||||
const MAGENTA: Color = Color::Cmyk { c: 0.0, m: 1.0, y: 0.0, k: 0.0 };
|
||||
|
||||
#[test]
|
||||
fn cyan_multiplied_over_magenta_prints_both_inks() {
|
||||
let got = centre(&doc(ColorMode::Cmyk, Some(MAGENTA), CYAN, Blend::Multiply, 1.0));
|
||||
let want = shown([1.0, 1.0, 0.0, 0.0]);
|
||||
assert!(near(got, want, 1), "{got:?} ≠ C100 M100 {want:?}");
|
||||
// Multiplying the screen colours instead gives a visibly different blue.
|
||||
let (c, m) = (CYAN.to_rgb(), MAGENTA.to_rgb());
|
||||
let screen = [0, 1, 2].map(|i| (c[i] * m[i] * 255.0).round() as i32);
|
||||
assert!((0..3).any(|i| (screen[i] - want[i] as i32).abs() > 10), "{screen:?} vs {want:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opacity_mixes_inks() {
|
||||
let got = centre(&doc(ColorMode::Cmyk, None, CYAN, Blend::Normal, 0.5));
|
||||
assert!(near(got, shown([0.5, 0.0, 0.0, 0.0]), 2), "{got:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn black_blends_on_its_own_plane() {
|
||||
let k = |k| Color::cmyk(0.0, 0.0, 0.0, k);
|
||||
// Multiply adds black ink: K50 over K50 leaves 25% of the paper.
|
||||
let got = centre(&doc(ColorMode::Cmyk, Some(k(0.5)), k(0.5), Blend::Multiply, 1.0));
|
||||
assert!(near(got, shown([0.0, 0.0, 0.0, 0.75]), 2), "{got:?}");
|
||||
// Luminosity takes the source's black, Color keeps the backdrop's.
|
||||
let got = centre(&doc(ColorMode::Cmyk, Some(CYAN), k(0.6), Blend::Luminosity, 1.0));
|
||||
assert!(near(got, shown([0.0, 0.0, 0.0, 0.6]), 3), "{got:?}");
|
||||
// (Over a backdrop of black ink alone the C, M, Y plane is paper: its luminance takes the
|
||||
// source's colour out.)
|
||||
let got = centre(&doc(ColorMode::Cmyk, Some(k(0.5)), CYAN, Blend::Color, 1.0));
|
||||
assert!(near(got, shown([0.0, 0.0, 0.0, 0.5]), 2), "{got:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_documents_and_opaque_cmyk_documents_keep_screen_colours() {
|
||||
let rgb = |c: &Color| {
|
||||
let [r, g, b] = c.to_rgb();
|
||||
Color::rgb(r, g, b)
|
||||
};
|
||||
// RGB documents multiply screen colours.
|
||||
let got = centre(&doc(ColorMode::Rgb, Some(rgb(&MAGENTA)), rgb(&CYAN), Blend::Multiply, 1.0));
|
||||
let (c, m) = (CYAN.to_rgb(), MAGENTA.to_rgb());
|
||||
let want = [0, 1, 2].map(|i| (c[i] * m[i] * 255.0).round() as u8);
|
||||
assert!(near(got, [want[0], want[1], want[2], 255], 1), "{got:?} ≠ {want:?}");
|
||||
// A CMYK document without transparency paints each colour as it is.
|
||||
let d = doc(ColorMode::Cmyk, Some(MAGENTA), CYAN, Blend::Normal, 1.0);
|
||||
assert!(!ink::blends_in_cmyk(&d, &RenderOptions::default()));
|
||||
assert_eq!(centre(&d), CYAN.to_rgba8(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opacity_masks_take_screen_luminance() {
|
||||
let mut d = Document::new_with_mode(20.0, 20.0, ColorMode::Cmyk);
|
||||
let mut n = rect(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0), Color::cmyk(0.0, 0.0, 0.0, 1.0));
|
||||
let art = rect(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0), CYAN);
|
||||
n.mask = Some(Box::new(OpacityMask::new(art, true)));
|
||||
add(&mut d, n);
|
||||
let [r, g, b] = CYAN.to_rgb();
|
||||
let cover = MASK_LUM[0] * r + MASK_LUM[1] * g + MASK_LUM[2] * b;
|
||||
let got = centre(&d);
|
||||
assert!(near(got, shown([0.0, 0.0, 0.0, cover]), 3), "{got:?} ≠ K{cover}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placed_images_are_separated_not_blackened() {
|
||||
let mut d = doc(ColorMode::Cmyk, None, CYAN, Blend::Normal, 0.5);
|
||||
let img = image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255]));
|
||||
let mut png = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
d.images.insert("red".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) });
|
||||
let n = Node::new(d.alloc_id(), NodeKind::Image(ImageObject { key: "red".into(), width: 4, height: 4, xf: Affine::IDENTITY, link: None }));
|
||||
add(&mut d, n);
|
||||
let got = Renderer::new().render_region(&d, d.artboards[0].rect, 1.0, true).pixel(1, 1);
|
||||
let cms = vectorcraft_color::cms::active();
|
||||
let [r, g, b] = cms.cmyk_to_srgb(cms.srgb_to_cmyk([1.0, 0.0, 0.0], cms.settings().intent), false).map(|v| (v * 255.0).round() as u8);
|
||||
assert!(near(got, [r, g, b, 255], 4), "{got:?} ≠ {:?}", [r, g, b]);
|
||||
}
|
||||
Reference in New Issue
Block a user