Fix Drop Shadow Darkness in source color space

This commit is contained in:
dotCooCoo
2026-10-29 14:36:41 +07:00
committed by bflatastic
parent f55cbf313c
commit dfa9f883c0
21 changed files with 587 additions and 118 deletions
+3
View File
@@ -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]| {
+2 -2
View File
@@ -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);
+1 -11
View File
@@ -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> {
+54 -15
View File
@@ -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());
}
}
+22 -7
View File
@@ -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
View File
@@ -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();
+4 -1
View File
@@ -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;
}
+4 -4
View File
@@ -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);
+2 -2
View File
@@ -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())
})
};
+106
View File
@@ -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)
);
}
}
}
}