mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-30 12:47:13 -06:00
Fix Drop Shadow Darkness in source color space
This commit is contained in:
@@ -93,6 +93,9 @@ fn window(xf: Affine, w: u16, h: u16, b: Rect, cols: u16, rows: u16) -> Option<[
|
||||
/// premultiplied pixmap, colours as `ink` paints them.
|
||||
pub(crate) fn rasterize(g: &GradientPaint, b: Rect, cols: u16, rows: u16, win: [Range<u16>; 2], ink: Ink) -> 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(b, b, cols, rows, win, true, &|c| ink.rgb(c))
|
||||
.map(|[r, gr, bl, a]| {
|
||||
|
||||
@@ -94,11 +94,11 @@ impl Renderer {
|
||||
let whole = fits && k >= 1.0;
|
||||
let in_view = (!whole).then(|| ViewCrop::of(f.view, room));
|
||||
let stamp = self.stamp;
|
||||
let channels = match f.ink {
|
||||
let channels = match f.ink.base() {
|
||||
Ink::Cmy => effects::pixel::Channels::CmyPlane,
|
||||
Ink::K => effects::pixel::Channels::KPlane,
|
||||
Ink::Display | Ink::RichBlack if f.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk => effects::pixel::Channels::Cmyk,
|
||||
Ink::Display | Ink::RichBlack => effects::pixel::Channels::Rgb,
|
||||
_ => effects::pixel::Channels::Rgb,
|
||||
};
|
||||
// De-Interlace's field lines: the rows of the document's raster grid (1 to 2400 ppi).
|
||||
let ppi = doc_raster_ppi(f.doc);
|
||||
|
||||
@@ -11,21 +11,11 @@ pub(super) fn shadow_filter(dx: f64, dy: f64, blur: f64, color: peniko::Color) -
|
||||
})
|
||||
}
|
||||
|
||||
/// The layer filter of a drop shadow with Darkness: the object's own pixels blurred.
|
||||
/// The layer filter of a drop shadow with Darkness: source paints are adjusted before blur.
|
||||
pub(super) fn shade_filter(blur: f64) -> Filter {
|
||||
Filter::from_primitive(FilterPrimitive::GaussianBlur { std_deviation: (blur / 2.0).max(0.0) as f32, edge_mode: EdgeMode::None })
|
||||
}
|
||||
|
||||
/// How much darker (0..1) a drop shadow with Darkness `darkness` makes the values `ink` paints:
|
||||
/// black added leaves the cyan, magenta and yellow plane of a CMYK document as it is and scales
|
||||
/// the complemented black plane, as it scales screen colors, by 1 − `darkness`.
|
||||
pub(super) fn shade(ink: Ink, darkness: f32) -> f32 {
|
||||
match ink {
|
||||
Ink::Cmy => 0.0,
|
||||
Ink::K | Ink::Display | Ink::RichBlack => darkness,
|
||||
}
|
||||
}
|
||||
|
||||
/// What a shadow or glow raster is made from ([`Made::pixels`]): the silhouette's alpha, or its
|
||||
/// premultiplied RGBA when the shadow takes the object's own colors (Darkness).
|
||||
pub(super) fn silhouette(pm: &vello_cpu::Pixmap, colored: bool) -> Vec<u8> {
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::bounds::{cull_bounds, effected_path};
|
||||
use super::entries::{Content, Drawn, FX_RASTER_BUDGET, FxGeom, Inputs, Kept, Made, Reads, Shadow, ShadowEntry};
|
||||
use super::predicates::{evaluates_live, is_container, rasterize_bounds, reads_outside, shifted};
|
||||
use super::raster::{
|
||||
DRAG_PIXEL_BUDGET, MAX_RASTERIZE, PIXEL_BUDGET, ViewCrop, content_changing, doc_raster_ppi, image_size, linear_key, pan_margin, pcolor, shade,
|
||||
DRAG_PIXEL_BUDGET, MAX_RASTERIZE, PIXEL_BUDGET, ViewCrop, content_changing, doc_raster_ppi, image_size, linear_key, pan_margin, pcolor,
|
||||
shade_filter, shadow_filter, silhouette,
|
||||
};
|
||||
use super::{
|
||||
@@ -115,7 +115,8 @@ impl Renderer {
|
||||
let view = Affine::scale(1.0 / scale) * Affine::translate((-x0, -y0)) * f.view;
|
||||
// Art is culled against the raster's own area: members of a group outside the view cast their shadows in it.
|
||||
let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(w), f64::from(h)));
|
||||
let crop = Frame { mt: false, view, visible, ..*f };
|
||||
let ink = darkness.map_or(f.ink, |d| f.ink.darkened(d));
|
||||
let crop = Frame { mt: false, view, visible, ink, ..*f };
|
||||
let before = self.stats.moved;
|
||||
self.inside_layer(|r| paint(r, &mut off, &shifted(&crop, dx, dy)));
|
||||
// A silhouette drawn over moved rasters makes a moved shadow.
|
||||
@@ -128,9 +129,8 @@ impl Renderer {
|
||||
Made { pixels: silhouette(&pm, darkness.is_some()), fx: vec![fx.clone()], size: reach.size(), channels: None, outline: None };
|
||||
let (wu, hu) = (w as usize, h as usize);
|
||||
let sigma = blur / 2.0 / f.px / scale;
|
||||
if let Some(darkness) = darkness {
|
||||
if darkness.is_some() {
|
||||
// The object's own colors, blurred, with the black Darkness adds (premultiplied).
|
||||
let keep = 1.0 - shade(f.ink, darkness);
|
||||
let mut planes: [Vec<f32>; 4] = [
|
||||
pm.data().iter().map(|p| p.r as f32).collect(),
|
||||
pm.data().iter().map(|p| p.g as f32).collect(),
|
||||
@@ -143,7 +143,7 @@ impl Renderer {
|
||||
let [r, g, b, a] = &planes;
|
||||
for (k, px) in pm.data_mut().iter_mut().enumerate() {
|
||||
let al = (a[k] * opacity).clamp(0.0, 255.0).round();
|
||||
let value = |plane: &Vec<f32>| (plane[k] * opacity * keep).round().clamp(0.0, al) as u8;
|
||||
let value = |plane: &Vec<f32>| (plane[k] * opacity).round().clamp(0.0, al) as u8;
|
||||
*px = vello_cpu::color::PremulRgba8 { r: value(r), g: value(g), b: value(b), a: al as u8 };
|
||||
}
|
||||
} else {
|
||||
@@ -219,15 +219,9 @@ impl Renderer {
|
||||
self.with_filters(ctx, f, reach, blur, Some((mode, opacity)), |r, c, fr, comp| {
|
||||
c.set_transform(fr.view);
|
||||
c.push_layer(None, comp.map(|m| blend_mode(m.0)), comp.map(|m| m.1), None, Some(filter));
|
||||
paint(r, c, &shifted(fr, dx, dy));
|
||||
// Darkness: black over the object's own colors, keeping their alpha.
|
||||
if let Some(black) = darkness.map(|d| shade(f.ink, d)).filter(|d| *d > 0.0) {
|
||||
c.set_transform(fr.view);
|
||||
c.set_blend_mode(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcAtop));
|
||||
c.set_paint(peniko::Color::new([0.0, 0.0, 0.0, black]));
|
||||
c.fill_rect(&reach);
|
||||
c.set_blend_mode(blend_mode(vectorcraft_doc::color::BlendMode::Normal));
|
||||
}
|
||||
let ink = darkness.map_or(fr.ink, |d| fr.ink.darkened(d));
|
||||
let source = Frame { ink, ..*fr };
|
||||
paint(r, c, &shifted(&source, dx, dy));
|
||||
c.pop_layer();
|
||||
});
|
||||
}
|
||||
@@ -278,7 +272,11 @@ impl Renderer {
|
||||
let view = Affine::scale(1.0 / scale) * Affine::translate((-(p.x + rel.0), -(p.y + rel.1))) * f.view;
|
||||
// Culled against the raster's area, as `shadow_raster` draws the silhouette.
|
||||
let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(w), f64::from(h)));
|
||||
let crop = Frame { mt: false, view, visible, ..*f };
|
||||
let ink = match fx {
|
||||
RasterFx::DropShadow { darkness: Some(d), .. } => f.ink.darkened(*d),
|
||||
_ => f.ink,
|
||||
};
|
||||
let crop = Frame { mt: false, view, visible, ink, ..*f };
|
||||
self.inside_layer(|r| paint(r, &mut off, &shifted(&crop, dx, dy)));
|
||||
off.flush();
|
||||
let mut pm = vello_cpu::Pixmap::new(w, h);
|
||||
@@ -696,3 +694,44 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::RenderOptions;
|
||||
use vectorcraft_color::Color;
|
||||
use vectorcraft_doc::{Document, color::BlendMode};
|
||||
|
||||
#[test]
|
||||
fn uncached_darkness_shadow_subtracts_before_separate_opacity() {
|
||||
let doc = Document::new(100.0, 100.0);
|
||||
let symbols = doc.symbol_boxes().quick();
|
||||
let opts = RenderOptions::default();
|
||||
let frame = Frame {
|
||||
mt: false,
|
||||
doc: &doc,
|
||||
symbols: &symbols,
|
||||
view: Affine::IDENTITY,
|
||||
visible: Rect::new(0.0, 0.0, 100.0, 100.0),
|
||||
px: 1.0,
|
||||
opts: &opts,
|
||||
ink: Ink::Display,
|
||||
};
|
||||
let mut ctx = frame.offscreen_context(100, 100);
|
||||
let mut r = Renderer::new();
|
||||
let fx =
|
||||
RasterFx::DropShadow { mode: BlendMode::Normal, opacity: 0.5, dx: 40.0, dy: 0.0, blur: 0.0, color: Color::BLACK, darkness: Some(0.25) };
|
||||
let source = Rect::new(10.0, 10.0, 30.0, 30.0);
|
||||
r.draw_shadow_filter(&mut ctx, &frame, &fx, source, &mut |_, c, f| {
|
||||
c.set_transform(f.view);
|
||||
c.set_paint(f.ink.color(&Color::rgb(0.8, 0.4, 0.2), 1.0));
|
||||
c.fill_rect(&source);
|
||||
});
|
||||
ctx.flush();
|
||||
let mut pm = vello_cpu::Pixmap::new(100, 100);
|
||||
ctx.render(&mut pm, &mut r.resources);
|
||||
let px = pm.data()[20 * 100 + 60];
|
||||
assert!(px.r.abs_diff(70) <= 1 && px.g.abs_diff(19) <= 1 && px.b == 0 && px.a.abs_diff(128) <= 1, "{px:?}");
|
||||
assert!(r.shadows.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,7 +278,7 @@ fn shaded_doc(mode: vectorcraft_doc::ColorMode, fill: Color, shade: &str, darkne
|
||||
d
|
||||
}
|
||||
|
||||
/// Darkness: the shadow is the object's own color, each channel times 1 − darkness in an RGB
|
||||
/// Darkness: the shadow is the object's own color, with darkness subtracted from each RGB channel in an RGB
|
||||
/// document (the reference app's guide gives the object's color at 0%,
|
||||
/// <https://help.adobe.com/archive/en/illustrator/cc/2014/illustrator_reference.pdf> page 428), in the cached
|
||||
/// raster and, at 6000 %, in the filter layer that a raster too large to cache is drawn with.
|
||||
@@ -298,9 +298,8 @@ fn drop_shadow_darkness_darkens_the_objects_own_color() {
|
||||
}
|
||||
}
|
||||
|
||||
/// In a CMYK document Darkness adds black ink and keeps the object's cyan, magenta and yellow
|
||||
/// (same page of the guide: at 100% an object with colors other than black gets a shadow of those colors under
|
||||
/// full black, and a black object's shadow is 100% black).
|
||||
/// Intermediate Darkness adds black ink while preserving C/M/Y. The top endpoint becomes
|
||||
/// gray black before ink conversion; its output below uses this application's gray-color model.
|
||||
#[test]
|
||||
fn drop_shadow_darkness_adds_black_in_a_cmyk_document() {
|
||||
let cmyk = vectorcraft_doc::ColorMode::Cmyk;
|
||||
@@ -312,15 +311,31 @@ fn drop_shadow_darkness_adds_black_in_a_cmyk_document() {
|
||||
let red = Color::cmyk(0.0, 1.0, 1.0, 0.0);
|
||||
for (darkness, k) in [(0.0, 0), (50.0, 128), (100.0, 255)] {
|
||||
let got = inks(&shaded_doc(cmyk, red, "darkness", darkness));
|
||||
assert!(got[0] == 0 && got[1] == 255 && got[2] == 255 && got[3].abs_diff(k) <= 1, "{darkness}%: {got:?}");
|
||||
let my = if darkness == 100.0 { 0 } else { 255 };
|
||||
assert!(got[0] == 0 && got[1] == my && got[2] == my && got[3].abs_diff(k) <= 1, "{darkness}%: {got:?}");
|
||||
}
|
||||
// Half black ink, darkened by half: K 50 + 50 × 50% = K 75.
|
||||
// Half black ink plus 50 percentage points clamps at full black.
|
||||
let got = inks(&shaded_doc(cmyk, Color::cmyk(0.0, 0.0, 0.0, 0.5), "darkness", 50.0));
|
||||
assert!(got[..3] == [0, 0, 0] && got[3].abs_diff(191) <= 1, "{got:?}");
|
||||
assert!(got[..3] == [0, 0, 0] && got[3].abs_diff(255) <= 1, "{got:?}");
|
||||
let got = inks(&shaded_doc(cmyk, Color::cmyk(0.0, 0.0, 0.0, 1.0), "darkness", 100.0));
|
||||
assert_eq!(got, [0, 0, 0, 255]);
|
||||
}
|
||||
|
||||
/// The top endpoint is gray black before document/profile conversion, independently of source model.
|
||||
#[test]
|
||||
fn darkness_black_endpoint_and_gray_source_follow_their_stored_models() {
|
||||
for mode in [vectorcraft_doc::ColorMode::Rgb, vectorcraft_doc::ColorMode::Cmyk] {
|
||||
for source in [Color::rgb(0.8, 0.4, 0.2), Color::cmyk(0.1, 0.3, 0.4, 0.2), Color::gray(0.2)] {
|
||||
let got = render(&shaded_doc(mode, source, "darkness", 100.0)).pixel(66, 66);
|
||||
let want = render(&shaded_doc(mode, Color::gray(1.0), "darkness", 0.0)).pixel(66, 66);
|
||||
assert_eq!(got, want, "{mode:?}, {source:?}");
|
||||
}
|
||||
let got = render(&shaded_doc(mode, Color::gray(0.2), "darkness", 25.0)).pixel(66, 66);
|
||||
let want = render(&shaded_doc(mode, Color::gray(0.45), "darkness", 0.0)).pixel(66, 66);
|
||||
assert_eq!(got, want, "gray intermediate in {mode:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Color stays the default: a shadow without the new keys, and one with Color chosen and a
|
||||
/// Darkness value, draw the same pixels, which a shadow with Darkness does not.
|
||||
#[test]
|
||||
|
||||
+161
-4
@@ -18,9 +18,9 @@ use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use vectorcraft_color::Color;
|
||||
use vectorcraft_color::blend::{cmyk_planes, planes_cmyk};
|
||||
use vectorcraft_color::cms::{self, Cms, CmykLut, ProofLut, lab};
|
||||
use vectorcraft_color::{Color, Gradient};
|
||||
use vectorcraft_doc::cmyk::Inks;
|
||||
use vectorcraft_doc::{ColorMode, Document, ImageBlob};
|
||||
use vello_cpu::Pixmap;
|
||||
@@ -42,11 +42,95 @@ pub(crate) enum Ink {
|
||||
K,
|
||||
/// Screen colours, with pure CMYK black as RGB black ([`RenderOptions::rich_black`]).
|
||||
RichBlack,
|
||||
/// Source-color adjustment before the display/ink conversion. Float bits keep cache keys exact.
|
||||
Darkened(InkSpace, u32, bool),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub(crate) enum InkSpace {
|
||||
Display,
|
||||
Cmy,
|
||||
K,
|
||||
RichBlack,
|
||||
}
|
||||
|
||||
impl Ink {
|
||||
pub(crate) fn base(self) -> Self {
|
||||
match self {
|
||||
Self::Darkened(InkSpace::Display, _, _) => Self::Display,
|
||||
Self::Darkened(InkSpace::Cmy, _, _) => Self::Cmy,
|
||||
Self::Darkened(InkSpace::K, _, _) => Self::K,
|
||||
Self::Darkened(InkSpace::RichBlack, _, _) => Self::RichBlack,
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn index(self) -> usize {
|
||||
match self.base() {
|
||||
Self::Cmy => 1,
|
||||
Self::K => 2,
|
||||
Self::RichBlack => 3,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn darkness(self) -> Option<f32> {
|
||||
match self {
|
||||
Self::Darkened(_, bits, _) => Some(f32::from_bits(bits)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn black_endpoint(self) -> bool {
|
||||
matches!(self, Self::Darkened(_, _, true))
|
||||
}
|
||||
|
||||
pub(crate) fn darkened(self, amount: f32) -> Self {
|
||||
let amount = if amount.is_finite() { amount.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let black = self.black_endpoint() || amount >= 0.9999;
|
||||
let amount = (self.darkness().unwrap_or(0.0) + amount).min(1.0);
|
||||
if amount == 0.0 {
|
||||
return self.base();
|
||||
}
|
||||
let space = match self.base() {
|
||||
Self::Cmy => InkSpace::Cmy,
|
||||
Self::K => InkSpace::K,
|
||||
Self::RichBlack => InkSpace::RichBlack,
|
||||
_ => InkSpace::Display,
|
||||
};
|
||||
Self::Darkened(space, amount.to_bits(), black)
|
||||
}
|
||||
|
||||
/// Fixed changes use the stored color's model, before profile conversion or interpolation.
|
||||
fn darkened_color(c: &Color, amount: f32, black: bool) -> Color {
|
||||
if black {
|
||||
return Color::gray(1.0);
|
||||
}
|
||||
match *c {
|
||||
Color::Cmyk { c, m, y, k } => Color::cmyk(c, m, y, (k + amount).min(1.0)),
|
||||
Color::Gray { k } => Color::gray((k + amount).min(1.0)),
|
||||
_ => {
|
||||
let [r, g, b] = c.to_rgb();
|
||||
Color::rgb((r - amount).max(0.0), (g - amount).max(0.0), (b - amount).max(0.0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Transform original stops before a gradient expands its midpoint/perceptual colors.
|
||||
pub(crate) fn gradient(self, g: &Gradient) -> Option<Gradient> {
|
||||
let amount = self.darkness()?;
|
||||
let mut g = g.clone();
|
||||
for stop in &mut g.stops {
|
||||
stop.color = Self::darkened_color(&stop.color, amount, self.black_endpoint());
|
||||
}
|
||||
Some(g)
|
||||
}
|
||||
|
||||
/// The three values painted for colour `c`.
|
||||
pub(crate) fn rgb(self, c: &Color) -> [f32; 3] {
|
||||
if let Some(amount) = self.darkness() {
|
||||
return self.base().rgb(&Self::darkened_color(c, amount, self.black_endpoint()));
|
||||
}
|
||||
match (self, *c) {
|
||||
(Ink::RichBlack, Color::Cmyk { c, m, y, k }) if is_pure_black([c, m, y, k]) => [0.0; 3],
|
||||
(Ink::Display | Ink::RichBlack, _) => c.to_rgb(),
|
||||
@@ -67,7 +151,7 @@ impl Ink {
|
||||
}
|
||||
|
||||
fn fixed8(self, [r, g, b, a]: [u8; 4]) -> [u8; 4] {
|
||||
match self {
|
||||
match self.base() {
|
||||
Ink::Display | Ink::RichBlack => [r, g, b, a],
|
||||
_ => {
|
||||
let lab = lab::srgb_to_lab([r, g, b].map(|v| v as f32 / 255.0));
|
||||
@@ -78,7 +162,7 @@ impl Ink {
|
||||
}
|
||||
|
||||
fn plane(self, inks: [f32; 4]) -> [f32; 3] {
|
||||
cmyk_planes(inks)[(self == Ink::K) as usize]
|
||||
cmyk_planes(inks)[(self.base() == Ink::K) as usize]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,12 +410,68 @@ fn plane_key(key: &ImageKey, plane: Ink, generation: u64) -> ImageKey {
|
||||
|
||||
/// The key [`ink_image`] gives image pixels `key` painted with `ink`.
|
||||
pub(crate) fn ink_key(key: &ImageKey, ink: Ink) -> ImageKey {
|
||||
if matches!(ink, Ink::Display | Ink::RichBlack) { key.clone() } else { plane_key(key, ink, separation().1) }
|
||||
if ink.darkness().is_some() {
|
||||
let generation = if matches!(ink.base(), Ink::Display | Ink::RichBlack) { 0 } else { separation().1 };
|
||||
plane_key(key, ink, generation)
|
||||
} else if matches!(ink, Ink::Display | Ink::RichBlack) {
|
||||
key.clone()
|
||||
} else {
|
||||
plane_key(key, ink, separation().1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Color-adjusted image pixels, cached separately for each source adjustment and output plane.
|
||||
fn darkened_image(key: &ImageKey, pm: &Arc<Pixmap>, ink: Ink, amount: f32, blob: Option<&ImageBlob>, touch: bool) -> (ImageKey, Arc<Pixmap>) {
|
||||
let want = ink_key(key, ink);
|
||||
let hit = {
|
||||
let mut s = images::store();
|
||||
if touch { s.get(&want) } else { s.peek(&want) }
|
||||
};
|
||||
if let Some(p) = hit {
|
||||
return (want, p);
|
||||
}
|
||||
let base = ink.base();
|
||||
let display = matches!(base, Ink::Display | Ink::RichBlack);
|
||||
let raw_cmyk = (key.luts.0 != 0 && key.adjust.is_none() && !key.grey)
|
||||
.then(|| blob.and_then(ImageBlob::cmyk))
|
||||
.flatten()
|
||||
.filter(|i| (i.width, i.height) == (u32::from(pm.width()), u32::from(pm.height())));
|
||||
let sep = (!display).then(separation);
|
||||
let shown = (display && raw_cmyk.is_some()).then(display_lut);
|
||||
let mut out = Pixmap::new(pm.width(), pm.height());
|
||||
for (i, (o, p)) in out.data_mut().iter_mut().zip(pm.data()).enumerate() {
|
||||
if p.a == 0 {
|
||||
continue;
|
||||
}
|
||||
let rgb = if ink.black_endpoint() {
|
||||
base.rgb(&Color::gray(1.0))
|
||||
} else if let Some(raw) = raw_cmyk.as_ref().and_then(|v| v.data.as_chunks::<4>().0.get(i)) {
|
||||
let mut inks = raw.map(|v| f32::from(v) / 255.0);
|
||||
inks[3] = (inks[3] + amount).min(1.0);
|
||||
if base == Ink::RichBlack && is_pure_black(inks) {
|
||||
[0.0; 3]
|
||||
} else if let Some((lut, _)) = &shown {
|
||||
lut.apply(inks)
|
||||
} else {
|
||||
base.plane(inks)
|
||||
}
|
||||
} else {
|
||||
let rgb = [p.r, p.g, p.b].map(|v| (f32::from(v) / f32::from(p.a) - amount).clamp(0.0, 1.0));
|
||||
if let Some((luts, _)) = &sep { luts[(base == Ink::K) as usize].apply(rgb) } else { rgb }
|
||||
};
|
||||
let [r, g, b] = rgb.map(|v| (v.clamp(0.0, 1.0) * f32::from(p.a)).round() as u8);
|
||||
*o = PremulRgba8 { r, g, b, a: p.a };
|
||||
}
|
||||
let out = images::store().insert(want.clone(), Arc::new(out));
|
||||
(want, out)
|
||||
}
|
||||
|
||||
/// Image pixels `pm` (cached as `key`, from `blob` when it is a document's image as it is) as
|
||||
/// painted with `ink`, cached with the images, and their key.
|
||||
pub(crate) fn ink_image(key: &ImageKey, pm: &Arc<Pixmap>, ink: Ink, blob: Option<&ImageBlob>, touch: bool) -> (ImageKey, Arc<Pixmap>) {
|
||||
if let Some(amount) = ink.darkness() {
|
||||
return darkened_image(key, pm, ink, amount, blob, touch);
|
||||
}
|
||||
if matches!(ink, Ink::Display | Ink::RichBlack) {
|
||||
return (key.clone(), pm.clone());
|
||||
}
|
||||
@@ -381,6 +521,23 @@ pub(crate) fn ink_image(key: &ImageKey, pm: &Arc<Pixmap>, ink: Ink, blob: Option
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn darkness_image_cache_keeps_amount_and_coverage() {
|
||||
let key =
|
||||
ImageKey { content: Arc::from("darkness-rgb-cache-regression"), adjust: None, grey: false, ink: Ink::Display, luts: (0, 0), mip: 0 };
|
||||
let pm =
|
||||
Arc::new(Pixmap::from_parts(vec![PremulRgba8 { r: 204, g: 102, b: 51, a: 255 }, PremulRgba8 { r: 102, g: 51, b: 26, a: 128 }], 2, 1));
|
||||
let (k25, p25) = ink_image(&key, &pm, Ink::Display.darkened(0.25), None, false);
|
||||
let (k50, p50) = ink_image(&key, &pm, Ink::Display.darkened(0.5), None, false);
|
||||
assert_ne!(k25, k50);
|
||||
assert_eq!((p25.data()[0].r, p25.data()[0].g, p25.data()[0].b), (140, 38, 0));
|
||||
assert_eq!((p25.data()[1].r, p25.data()[1].g, p25.data()[1].b, p25.data()[1].a), (70, 19, 0, 128));
|
||||
assert_eq!((p50.data()[0].r, p50.data()[0].g, p50.data()[0].b), (77, 0, 0));
|
||||
let (_, warm) = ink_image(&key, &pm, Ink::Display.darkened(0.25), None, false);
|
||||
assert_eq!(p25.data(), warm.data());
|
||||
assert_eq!(pm.data()[0].r, 204, "source pixels remain unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ink_table_matches_the_profile() {
|
||||
let cms = cms::active();
|
||||
|
||||
@@ -20,7 +20,10 @@ pub(crate) fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Fr
|
||||
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, f.ink.color(c, *a)))).collect();
|
||||
let adjusted = f.ink.gradient(&g.gradient);
|
||||
let gradient = adjusted.as_ref().unwrap_or(&g.gradient);
|
||||
let ink = if adjusted.is_some() { f.ink.base() } else { f.ink };
|
||||
let stops: Vec<ColorStop> = gradient.expanded_stops().iter().map(|(o, c, a)| ColorStop::from((*o, ink.color(c, *a)))).collect();
|
||||
if stops.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ const MAX_TILE_PX: f64 = 2048.0;
|
||||
|
||||
/// What a tile is kept under: the pattern's name and the bits of its raster scale
|
||||
/// ([`raster_scale`]).
|
||||
type TileKey = (String, u64);
|
||||
type TileKey = (String, u64, Ink);
|
||||
|
||||
/// What a tile was drawn from besides its pattern's own art, for art that shows more than itself:
|
||||
/// the art of every pattern and symbol and the pixels of every image in the document, the
|
||||
@@ -241,12 +241,12 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine,
|
||||
let (pw, ph) = def.period();
|
||||
let scale = raster_scale(s, (pw, ph));
|
||||
let params = (def.tile, def.tile_type, def.overlap);
|
||||
let key = (name.to_string(), scale.to_bits());
|
||||
let key = (name.to_string(), scale.to_bits(), ink);
|
||||
let cms = vectorcraft_color::cms::active();
|
||||
let hit = CACHE.with(|c| {
|
||||
let mut c = c.borrow_mut();
|
||||
c.check_plugins();
|
||||
let tiles = &mut c.tiles[ink as usize];
|
||||
let tiles = &mut c.tiles[ink.index()];
|
||||
let t = tiles.tick();
|
||||
tiles
|
||||
.get(&key, t)
|
||||
@@ -281,7 +281,7 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine,
|
||||
return;
|
||||
}
|
||||
let mut c = c.borrow_mut();
|
||||
let tiles = &mut c.tiles[ink as usize];
|
||||
let tiles = &mut c.tiles[ink.index()];
|
||||
let (t, bytes) = (tiles.tick(), size_of::<TileEntry>() + w as usize * h as usize * 4);
|
||||
let entry = TileEntry { art: def.art.clone(), deps, cms: Arc::downgrade(&cms), params, sx, sy, pixmap: pm.clone(), failed };
|
||||
tiles.insert(key, entry, bytes, t);
|
||||
|
||||
@@ -219,11 +219,11 @@ fn kept_tiles_stay_within_the_budget() {
|
||||
let d = doc_with_two_scales(0.25);
|
||||
let tile = |side: usize| size_of::<TileEntry>() + side * side * 4;
|
||||
let budget = tile(80) + tile(40) + tile(10);
|
||||
CACHE.with(|c| c.borrow_mut().tiles[Ink::Display as usize] = FrameCache::new(budget, usize::MAX));
|
||||
CACHE.with(|c| c.borrow_mut().tiles[Ink::Display.index()] = FrameCache::new(budget, usize::MAX));
|
||||
let kept = || {
|
||||
CACHE.with(|c| {
|
||||
let c = c.borrow();
|
||||
let tiles = &c.tiles[Ink::Display as usize];
|
||||
let tiles = &c.tiles[Ink::Display.index()];
|
||||
(tiles.len(), tiles.bytes())
|
||||
})
|
||||
};
|
||||
|
||||
@@ -778,3 +778,109 @@ fn a_path_with_its_own_transform_paints_each_copy_with_its_own_gradient() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn darkness_subtracts_from_rgb_before_shadow_opacity() {
|
||||
for opacity in [50.0, 100.0] {
|
||||
let mut n = Node::path(
|
||||
NodeId(0),
|
||||
shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)),
|
||||
Appearance::basic(Paint::solid(Color::rgb(0.8, 0.4, 0.2)), Paint::None, 0.0),
|
||||
);
|
||||
n.appearance
|
||||
.effects
|
||||
.push(fx("stylize.dropShadow", json!({"shade":"darkness", "darkness":25, "x":40, "y":0, "blur":0, "opacity":opacity, "mode":"normal"})));
|
||||
let d = doc_with(Document::new(100.0, 100.0), n);
|
||||
for threads in [0, 2] {
|
||||
let p = render_threads(&d, threads).pixel(60, 20);
|
||||
let expected = if opacity == 100.0 { [140u8, 38, 0, 255] } else { [198, 147, 127, 255] };
|
||||
assert!(p.iter().zip(expected).all(|(a, b)| a.abs_diff(b) <= 2), "{p:?}, expected {expected:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn darkness_adds_black_without_changing_cmy_inks() {
|
||||
let mut n = Node::path(
|
||||
NodeId(0),
|
||||
shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)),
|
||||
Appearance::basic(Paint::solid(Color::cmyk(0.15, 0.3, 0.4, 0.2)), Paint::None, 0.0),
|
||||
);
|
||||
n.appearance
|
||||
.effects
|
||||
.push(fx("stylize.dropShadow", json!({"shade":"darkness", "darkness":25, "x":40, "y":0, "blur":0, "opacity":100, "mode":"normal"})));
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
d.color_mode = vectorcraft_doc::ColorMode::Cmyk;
|
||||
let d = doc_with(d, n);
|
||||
let want = Color::cmyk(0.15, 0.3, 0.4, 0.45).to_rgba8(1.0);
|
||||
for threads in [0, 2] {
|
||||
let got = render_threads(&d, threads).pixel(60, 20);
|
||||
assert!(got.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 3), "{got:?}, expected {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn darkness_uses_the_source_color_model_before_document_conversion() {
|
||||
for (mode, source, dark) in [
|
||||
(vectorcraft_doc::ColorMode::Rgb, Color::cmyk(0.15, 0.3, 0.4, 0.2), Color::cmyk(0.15, 0.3, 0.4, 0.45)),
|
||||
(vectorcraft_doc::ColorMode::Cmyk, Color::rgb(0.8, 0.4, 0.2), Color::rgb(0.55, 0.15, 0.0)),
|
||||
] {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
d.color_mode = mode;
|
||||
let mut n =
|
||||
Node::path(NodeId(0), shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), Appearance::basic(Paint::solid(source), Paint::None, 0.0));
|
||||
n.appearance
|
||||
.effects
|
||||
.push(fx("stylize.dropShadow", json!({"shade":"darkness","darkness":25,"x":40,"y":0,"blur":0,"opacity":100,"mode":"normal"})));
|
||||
let actual = doc_with(d.clone(), n);
|
||||
let mut control =
|
||||
Node::path(NodeId(0), shapes::rectangle(Rect::new(50.0, 10.0, 70.0, 30.0)), Appearance::basic(Paint::solid(dark), Paint::None, 0.0));
|
||||
// The neutral effect selects the same CMYK compositing pipeline without changing paint.
|
||||
control.appearance.effects.push(fx("blur.gaussian", json!({"radius":0})));
|
||||
let expected = doc_with(d, control);
|
||||
for threads in [0, 2] {
|
||||
let got = render_threads(&actual, threads).pixel(60, 20);
|
||||
let want = render_threads(&expected, threads).pixel(60, 20);
|
||||
assert!(got.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 3), "{mode:?}, {source:?}: {got:?} vs {want:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn darkness_adjusts_gradient_stop_colors_before_interpolation() {
|
||||
use vectorcraft_color::GradientMethod;
|
||||
for method in [GradientMethod::Classic, GradientMethod::Perceptual] {
|
||||
for dither in [false, true] {
|
||||
let ramp = |end: Color| {
|
||||
let mut g = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, Color::BLACK), GradientStop::new(1.0, end)]);
|
||||
g.method = method;
|
||||
g.dither = dither;
|
||||
Paint::Gradient(Box::new(GradientPaint::new(g)))
|
||||
};
|
||||
let mut n = Node::path(
|
||||
NodeId(0),
|
||||
shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)),
|
||||
Appearance::basic(ramp(Color::rgb(0.8, 0.4, 0.2)), Paint::None, 0.0),
|
||||
);
|
||||
n.appearance
|
||||
.effects
|
||||
.push(fx("stylize.dropShadow", json!({"shade":"darkness","darkness":25,"x":40,"y":0,"blur":0,"opacity":100,"mode":"normal"})));
|
||||
let actual = doc_with(Document::new(100.0, 100.0), n);
|
||||
let control = Node::path(
|
||||
NodeId(0),
|
||||
shapes::rectangle(Rect::new(50.0, 10.0, 70.0, 30.0)),
|
||||
Appearance::basic(ramp(Color::rgb(0.55, 0.15, 0.0)), Paint::None, 0.0),
|
||||
);
|
||||
let expected = doc_with(Document::new(100.0, 100.0), control);
|
||||
let (got, want) = (render(&actual), render(&expected));
|
||||
for x in 52..68 {
|
||||
assert!(
|
||||
got.pixel(x, 20).iter().zip(want.pixel(x, 20)).all(|(a, b)| a.abs_diff(b) <= if dither { 8 } else { 3 }),
|
||||
"{method:?} dither={dither} x={x}: {:?} vs {:?}",
|
||||
got.pixel(x, 20),
|
||||
want.pixel(x, 20)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user