mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 04:07:12 -06:00
Fix Drop Shadow Darkness in source color space
This commit is contained in:
@@ -264,7 +264,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"stylize.dropShadow",
|
||||
"Drop Shadow…",
|
||||
STYLIZE,
|
||||
"{mode: blend mode (\"multiply\"), opacity: % (75), x: pt (7), y: pt (7), blur: pt (5), shade: \"color\"|\"darkness\" (\"color\"), color: \"#rrggbb\" (\"#000000\"), darkness: % (100)} (shade \"darkness\": the shadow is the object's own colors with `darkness` percent of black added instead of `color`: in a CMYK document their cyan, magenta and yellow stay and their black K becomes K + darkness × (1 − K); in an RGB document each channel is multiplied by 1 − darkness)",
|
||||
"{mode: blend mode (\"multiply\"), opacity: % (75), x: pt (7), y: pt (7), blur: pt (5), shade: \"color\"|\"darkness\" (\"color\"), color: \"#rrggbb\" (\"#000000\"), darkness: % (100)} (shade \"darkness\": use d = darkness / 100 on the source colors before document color conversion and blur; RGB channels become max(0, channel - d), CMYK keeps C/M/Y and sets K to min(1, K + d), and Gray sets its black component to min(1, K + d). The near-100% endpoint is gray black. Shadow opacity remains separate)",
|
||||
json!({"mode": "multiply", "opacity": 75.0, "x": 7.0, "y": 7.0, "blur": 5.0, "shade": "color", "color": "#000000", "darkness": 100.0}),
|
||||
),
|
||||
r(
|
||||
|
||||
@@ -174,7 +174,7 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Enco
|
||||
let regions: Option<Vec<vectorcraft_geom::Rect>> =
|
||||
chosen.then(|| boards.iter().flatten().filter_map(|b| doc.artboards.get(*b)).map(|a| a.rect).collect());
|
||||
let overflow = crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, regions.as_deref());
|
||||
// SVG has no filters for the Photoshop-style effects: their objects go in as images.
|
||||
// Render effects SVG cannot represent faithfully, including source-model Darkness shadows.
|
||||
let flat = crate::cmd::rasterfx::flatten_pixel_effects(doc);
|
||||
let doc = flat.as_ref().unwrap_or(doc);
|
||||
let mut enc = Encoded { warnings, ..Encoded::default() };
|
||||
|
||||
@@ -297,3 +297,95 @@ fn svgz_opens_and_pastes_like_svg() {
|
||||
assert_eq!(r["count"], 2);
|
||||
assert!(s.execute("document.open", &json!({"name": "bad.svgz", "dataBase64": vectorcraft_format::base64_encode(&[0x1f, 0x8b, 1, 2])})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_darkness_preserves_source_model_and_gradient_appearance() {
|
||||
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
use vectorcraft_doc::{ColorMode, Effect};
|
||||
use vectorcraft_geom::Rect;
|
||||
for kind in ["rgb", "cmykSource", "gradient", "cmykDocument"] {
|
||||
let mut s = session(1);
|
||||
s.edit("paint source", |d, _| {
|
||||
if kind == "cmykDocument" {
|
||||
d.color_mode = ColorMode::Cmyk;
|
||||
}
|
||||
let id = d.layers[0].children().unwrap()[0].id;
|
||||
let n = d.node_mut(id).unwrap();
|
||||
n.appearance.fill_mut().unwrap().paint = match kind {
|
||||
"cmykSource" => Paint::solid(Color::cmyk(0.15, 0.3, 0.4, 0.2)),
|
||||
"gradient" => Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(
|
||||
GradientKind::Linear,
|
||||
vec![GradientStop::new(0.0, Color::BLACK), GradientStop::new(1.0, Color::rgb(0.8, 0.4, 0.2))],
|
||||
)))),
|
||||
_ => Paint::solid(Color::rgb(0.8, 0.4, 0.2)),
|
||||
};
|
||||
n.appearance.items.retain(|i| i.is_fill());
|
||||
n.appearance.effects = vec![Effect {
|
||||
id: "stylize.dropShadow".into(),
|
||||
visible: true,
|
||||
params: json!({"shade":"darkness","darkness":25,"x":60,"y":0,"blur":0,"opacity":100,"mode":"normal"}),
|
||||
}];
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let native = s.doc().unwrap().doc.clone();
|
||||
let text = svg(&mut s, json!({}));
|
||||
if kind == "rgb" {
|
||||
assert!(text.contains("feComponentTransfer") && !text.contains("<image"));
|
||||
} else {
|
||||
assert!(text.contains("<image"), "{kind}: the shadow must preserve rendered appearance");
|
||||
}
|
||||
let back = vectorcraft_svg::import(&text).unwrap();
|
||||
let region = Rect::new(0.0, 0.0, 200.0, 100.0);
|
||||
let expected = vectorcraft_render::Renderer::new().render_region(&native, region, 1.0, true);
|
||||
let actual = vectorcraft_render::Renderer::new().render_region(&back, region, 1.0, true);
|
||||
for x in 80..115 {
|
||||
let (a, b) = (actual.pixel(x, 30), expected.pixel(x, 30));
|
||||
assert!(a.iter().zip(b).all(|(a, b)| a.abs_diff(b) <= 8), "{kind} at {x}: {a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_darkness_uses_rendered_shadow_in_non_srgb_working_space() {
|
||||
const CHILD: &str = "VECTORCRAFT_TEST_SHADOW_RGB_PROFILE";
|
||||
if std::env::var_os(CHILD).is_none() {
|
||||
// Color settings are process-wide: exercise this profile in a separate test process.
|
||||
let output = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", "cmd::fileio::tests_svg::svg_darkness_uses_rendered_shadow_in_non_srgb_working_space", "--nocapture"])
|
||||
.env(CHILD, "1")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr));
|
||||
return;
|
||||
}
|
||||
use vectorcraft_color::{Color, Paint, cms};
|
||||
let settings = cms::ColorSettings { rgb: cms::WIDE_GAMUT_RGB.into(), ..Default::default() };
|
||||
cms::set_active(&settings).unwrap();
|
||||
assert!(!cms::active().rgb_is_srgb());
|
||||
let mut s = session(1);
|
||||
s.edit("RGB source", |d, _| {
|
||||
let id = d.layers[0].children().unwrap()[0].id;
|
||||
let n = d.node_mut(id).unwrap();
|
||||
n.appearance.fill_mut().unwrap().paint = Paint::solid(Color::rgb(0.8, 0.4, 0.2));
|
||||
n.appearance.items.retain(|i| i.is_fill());
|
||||
n.appearance.effects = vec![vectorcraft_doc::Effect {
|
||||
id: "stylize.dropShadow".into(),
|
||||
visible: true,
|
||||
params: json!({"shade":"darkness","darkness":25,"x":60,"y":0,"blur":0,"opacity":100,"mode":"normal"}),
|
||||
}];
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let region = vectorcraft_geom::Rect::new(0.0, 0.0, 200.0, 100.0);
|
||||
let expected = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, region, 1.0, true);
|
||||
let text = svg(&mut s, json!({}));
|
||||
assert!(text.contains("<image"), "a non-sRGB source must preserve its adjusted rendered colors");
|
||||
cms::set_active(&cms::ColorSettings::default()).unwrap();
|
||||
let back = vectorcraft_svg::import(&text).unwrap();
|
||||
let actual = vectorcraft_render::Renderer::new().render_region(&back, region, 1.0, true);
|
||||
for x in 80..115 {
|
||||
let (a, b) = (actual.pixel(x, 30), expected.pixel(x, 30));
|
||||
assert!(a.iter().zip(b).all(|(a, b)| a.abs_diff(b) <= 8), "at {x}: {a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
//! images with the same helpers.
|
||||
//!
|
||||
//! SVG has filters for shadows, glows, blurs and feathers but none for the Photoshop-style
|
||||
//! effects (Radial Blur, Unsharp Mask…) or Rasterize: SVG export turns only the objects carrying
|
||||
//! one of those into images the same way ([`flatten_pixel_effects`]).
|
||||
//! effects (Radial Blur, Unsharp Mask…) or Rasterize. Darkness shadows also need rendered images
|
||||
//! when source colors or gradients cannot be represented by an RGB component-transfer filter.
|
||||
//! SVG export prepares these images with [`flatten_pixel_effects`].
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -452,9 +453,8 @@ fn placed_symbol(d: &Document, n: &Node) -> Option<Node> {
|
||||
enum Which {
|
||||
/// Every raster effect (PDF).
|
||||
All,
|
||||
/// Objects with a Photoshop-style effect or Rasterize, which SVG can't draw (with all their
|
||||
/// raster effects).
|
||||
Pixel,
|
||||
/// Effects SVG cannot represent faithfully, with their other raster effects.
|
||||
Pixel { cmyk: bool },
|
||||
}
|
||||
|
||||
/// Has `n` (its own, its fills' or strokes') raster effects `which` turns into an image?
|
||||
@@ -462,10 +462,34 @@ fn imaged(n: &Node, which: Which) -> bool {
|
||||
let fx = raster_fx(n);
|
||||
match which {
|
||||
Which::All => !fx.is_empty(),
|
||||
Which::Pixel => fx.iter().any(|f| matches!(f, RasterFx::Pixel(_) | RasterFx::Rasterize(_))),
|
||||
Which::Pixel { cmyk } => {
|
||||
fx.iter().any(|f| matches!(f, RasterFx::Pixel(_) | RasterFx::Rasterize(_)))
|
||||
|| (fx.iter().any(|f| matches!(f, RasterFx::DropShadow { darkness: Some(_), .. })) && (cmyk || !simple_rgb_shadow_source(n, &fx)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A filter on one uniform RGB paint can express the source-color adjustment exactly.
|
||||
/// Other paints need the existing rendered-effect image to keep their source-model semantics.
|
||||
fn simple_rgb_shadow_source(n: &Node, fx: &[RasterFx]) -> bool {
|
||||
if !vectorcraft_color::cms::active().rgb_is_srgb() || !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) || fx.len() != 1 {
|
||||
return false;
|
||||
}
|
||||
// Other effects can generate varying source colors even from a single plain paint.
|
||||
let only_shadow = std::iter::once(&n.appearance.effects)
|
||||
.chain(n.appearance.items.iter().map(AppearanceItem::effects))
|
||||
.flatten()
|
||||
.all(|e| !e.visible || e.id == "stylize.dropShadow");
|
||||
if !only_shadow || n.extras().mask.is_some() {
|
||||
return false;
|
||||
}
|
||||
let mut paints = n.appearance.items.iter().filter(|i| effects::paints(i));
|
||||
let Some(item) = paints.next() else {
|
||||
return false;
|
||||
};
|
||||
paints.next().is_none() && matches!(item.paint(), Paint::Solid { color: vectorcraft_color::Color::Rgb { .. }, swatch: None, .. })
|
||||
}
|
||||
|
||||
/// Does `n` hold raster effects that `which` turns into images: its own, its fills' and strokes',
|
||||
/// its opacity mask's or (groups and layers) its members'? Hidden objects and template layers
|
||||
/// aren't drawn.
|
||||
@@ -566,7 +590,7 @@ pub fn flatten_raster_effects(doc: &Document) -> Option<Document> {
|
||||
/// Mask…) or Rasterize are images as [`flatten_raster_effects`] makes them (SVG export: its
|
||||
/// filters draw the other raster effects live), or `None` when there are none.
|
||||
pub fn flatten_pixel_effects(doc: &Document) -> Option<Document> {
|
||||
flatten(doc, &mut FlattenReport::new(Which::Pixel, doc))
|
||||
flatten(doc, &mut FlattenReport::new(Which::Pixel { cmyk: doc.color_mode == ColorMode::Cmyk }, doc))
|
||||
}
|
||||
|
||||
fn flatten(doc: &Document, report: &mut FlattenReport) -> Option<Document> {
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,7 +368,9 @@ impl Writer<'_> {
|
||||
|
||||
/// Raster effects (drop shadow, glows, Gaussian blur, feather) as SVG filters: one `<g filter>`
|
||||
/// per effect, the first effect innermost (the reference app's stacking order). A drop shadow
|
||||
/// with Darkness darkens the screen colors, in a CMYK document too. Photoshop-style
|
||||
/// with Darkness can use a live filter for a uniform RGB source. Other source colors and
|
||||
/// gradients require rendered-effect preparation by the caller to preserve their appearance.
|
||||
/// Photoshop-style
|
||||
/// filters have no SVG equivalent: they are left out with a warning (exports from the app turn
|
||||
/// such objects into images first).
|
||||
pub(super) fn filtered(&mut self, n: &Node) {
|
||||
@@ -403,6 +405,19 @@ impl Writer<'_> {
|
||||
self.warn("SVG has no pixel effects such as Radial Blur, Smart Blur or Unsharp Mask: they are left out");
|
||||
continue;
|
||||
}
|
||||
if matches!(f, RasterFx::DropShadow { darkness: Some(_), .. }) {
|
||||
let mut paints = n.appearance.items.iter().filter(|i| vectorcraft_effects::paints(i));
|
||||
let uniform = paints.next().is_some_and(|i| {
|
||||
matches!(i.paint(), vectorcraft_color::Paint::Solid { color: vectorcraft_color::Color::Rgb { .. }, swatch: None, .. })
|
||||
}) && paints.next().is_none();
|
||||
if self.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk
|
||||
|| !vectorcraft_color::cms::active().rgb_is_srgb()
|
||||
|| !uniform
|
||||
|| !matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. } | vectorcraft_doc::NodeKind::Compound { .. })
|
||||
{
|
||||
self.warn("Some Darkness shadows require rendered images to preserve source colors, profiles and gradients");
|
||||
}
|
||||
}
|
||||
let fid = self.fresh_id("filter");
|
||||
let region_attr = match region {
|
||||
Some(r) => format!(
|
||||
@@ -419,14 +434,14 @@ impl Writer<'_> {
|
||||
format!("<feFlood flood-color=\"{}\" flood-opacity=\"{}\"/>", c.to_hex(), fmt_num(o as f64, 3))
|
||||
};
|
||||
let body = match f {
|
||||
// Darkness: the object blurred and moved, each color channel times 1 − darkness.
|
||||
// Subtract in sRGB before blur, preserving coverage and separate opacity.
|
||||
RasterFx::DropShadow { opacity, dx, dy, blur, darkness: Some(darkness), .. } => format!(
|
||||
"<feGaussianBlur in=\"SourceGraphic\" stdDeviation=\"{}\"/><feOffset dx=\"{}\" dy=\"{}\"/><feComponentTransfer result=\"paint\"><feFuncR type=\"linear\" slope=\"{keep}\"/><feFuncG type=\"linear\" slope=\"{keep}\"/><feFuncB type=\"linear\" slope=\"{keep}\"/><feFuncA type=\"linear\" slope=\"{}\"/></feComponentTransfer><feMerge><feMergeNode in=\"paint\"/><feMergeNode in=\"SourceGraphic\"/></feMerge>",
|
||||
"<feComponentTransfer in=\"SourceGraphic\"><feFuncR type=\"linear\" slope=\"1\" intercept=\"{offset}\"/><feFuncG type=\"linear\" slope=\"1\" intercept=\"{offset}\"/><feFuncB type=\"linear\" slope=\"1\" intercept=\"{offset}\"/></feComponentTransfer><feGaussianBlur stdDeviation=\"{}\"/><feOffset dx=\"{}\" dy=\"{}\"/><feComponentTransfer result=\"paint\"><feFuncA type=\"linear\" slope=\"{}\"/></feComponentTransfer><feMerge><feMergeNode in=\"paint\"/><feMergeNode in=\"SourceGraphic\"/></feMerge>",
|
||||
sd(*blur),
|
||||
self.num(*dx),
|
||||
self.num(*dy),
|
||||
fmt_num(*opacity as f64, 3),
|
||||
keep = fmt_num(1.0 - *darkness as f64, 3)
|
||||
offset = fmt_num(-f64::from(*darkness), 4)
|
||||
),
|
||||
RasterFx::DropShadow { opacity, dx, dy, blur, color, .. } => format!(
|
||||
"<feGaussianBlur in=\"SourceAlpha\" stdDeviation=\"{}\"/><feOffset dx=\"{}\" dy=\"{}\" result=\"shadow\"/>{}<feComposite in2=\"shadow\" operator=\"in\" result=\"paint\"/><feMerge><feMergeNode in=\"paint\"/><feMergeNode in=\"SourceGraphic\"/></feMerge>",
|
||||
|
||||
+67
-44
@@ -4,7 +4,7 @@
|
||||
//! Outer Glow, Inner Glow or Feather. That covers `feGaussianBlur`, `feDropShadow`, the usual
|
||||
//! shadow chains (offset, blur, flood or color matrix, composite, merge), blur and shadow graphs
|
||||
//! with `feBlend` and transparent backdrops (as exported by Figma and others), the object moved and
|
||||
//! blurred with its colors scaled by `feComponentTransfer` (a drop shadow with Darkness) and what
|
||||
//! blurred with a fixed amount subtracted from its colors by `feComponentTransfer` (Darkness), and what
|
||||
//! our export writes, so export → import keeps the effects.
|
||||
|
||||
use std::collections::HashMap;
|
||||
@@ -69,14 +69,16 @@ enum V {
|
||||
},
|
||||
/// The source inside its own blurred silhouette (σ).
|
||||
Feathered(f64),
|
||||
/// The source blurred (σ `sd`, on linear RGB values when `linear`), moved, its colors times
|
||||
/// `keep` and its alpha times `opacity`: a drop shadow with Darkness 1 − `keep`.
|
||||
/// The source blurred (σ `sd`, on linear RGB values when `linear`), moved, its colors
|
||||
/// scaled by `keep` with `subtract` removed before blur, and alpha times `opacity`.
|
||||
/// Only fixed subtraction and the black endpoint map to live Darkness.
|
||||
Shade {
|
||||
sd: f64,
|
||||
linear: bool,
|
||||
dx: f64,
|
||||
dy: f64,
|
||||
keep: f32,
|
||||
subtract: f32,
|
||||
opacity: f32,
|
||||
},
|
||||
/// Layers, bottom first.
|
||||
@@ -111,7 +113,9 @@ impl V {
|
||||
V::Paint { shape, color, opacity, clipped: false } => {
|
||||
V::Paint { shape: Shape { sd: add(shape.sd), ..shape }, color, opacity, clipped: false }
|
||||
}
|
||||
V::Shade { sd: s, linear: l, dx, dy, keep, opacity } => V::Shade { sd: add(s), linear: l || linear, dx, dy, keep, opacity },
|
||||
V::Shade { sd: s, linear: l, dx, dy, keep, subtract, opacity } => {
|
||||
V::Shade { sd: add(s), linear: l || linear, dx, dy, keep, subtract, opacity }
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -122,9 +126,11 @@ impl V {
|
||||
V::Alpha(s) => V::Alpha(moved(s)),
|
||||
V::Paint { shape, color, opacity, clipped: false } => V::Paint { shape: moved(shape), color, opacity, clipped: false },
|
||||
v @ (V::Graphic | V::Blurred(..)) if dx == 0.0 && dy == 0.0 => v,
|
||||
V::Graphic => V::Shade { sd: 0.0, linear: false, dx, dy, keep: 1.0, opacity: 1.0 },
|
||||
V::Blurred(sd, linear) => V::Shade { sd, linear, dx, dy, keep: 1.0, opacity: 1.0 },
|
||||
V::Shade { sd, linear, dx: x, dy: y, keep, opacity } => V::Shade { sd, linear, dx: x + dx, dy: y + dy, keep, opacity },
|
||||
V::Graphic => V::Shade { sd: 0.0, linear: false, dx, dy, keep: 1.0, subtract: 0.0, opacity: 1.0 },
|
||||
V::Blurred(sd, linear) => V::Shade { sd, linear, dx, dy, keep: 1.0, subtract: 0.0, opacity: 1.0 },
|
||||
V::Shade { sd, linear, dx: x, dy: y, keep, subtract, opacity } => {
|
||||
V::Shade { sd, linear, dx: x + dx, dy: y + dy, keep, subtract, opacity }
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -132,13 +138,13 @@ impl V {
|
||||
/// The source, blurred or moved, with each color channel times `keep` and its alpha times
|
||||
/// `opacity` (`feComponentTransfer` with linear functions through 0).
|
||||
fn scaled(self, keep: f32, opacity: f32) -> Option<V> {
|
||||
let (sd, linear, dx, dy, k, o) = match self {
|
||||
V::Graphic => (0.0, false, 0.0, 0.0, 1.0, 1.0),
|
||||
V::Blurred(sd, linear) => (sd, linear, 0.0, 0.0, 1.0, 1.0),
|
||||
V::Shade { sd, linear, dx, dy, keep, opacity } => (sd, linear, dx, dy, keep, opacity),
|
||||
let (sd, linear, dx, dy, k, subtract, o) = match self {
|
||||
V::Graphic => (0.0, false, 0.0, 0.0, 1.0, 0.0, 1.0),
|
||||
V::Blurred(sd, linear) => (sd, linear, 0.0, 0.0, 1.0, 0.0, 1.0),
|
||||
V::Shade { sd, linear, dx, dy, keep, subtract, opacity } => (sd, linear, dx, dy, keep, subtract, opacity),
|
||||
_ => return None,
|
||||
};
|
||||
Some(V::Shade { sd, linear, dx, dy, keep: k * keep, opacity: o * opacity })
|
||||
Some(V::Shade { sd, linear, dx, dy, keep: k * keep, subtract: subtract * keep, opacity: o * opacity })
|
||||
}
|
||||
|
||||
/// `self` in the alpha of `mask` (`feComposite operator="in"`).
|
||||
@@ -188,8 +194,48 @@ fn color(c: usvg::Color) -> Color {
|
||||
Color::rgb8(c.red, c.green, c.blue)
|
||||
}
|
||||
|
||||
/// Evaluate one filter's primitives, `k` the scale from the filter's user space to the document;
|
||||
/// `None` when one does something the effects can't.
|
||||
/// Transfer functions on the source or its silhouette, separately from filter graph traversal.
|
||||
fn transferred(v: V, [r, g, b, a]: [&TransferFunction; 4], srgb: bool) -> Option<V> {
|
||||
let identity = [r, g, b].iter().all(|f| matches!(f, TransferFunction::Identity));
|
||||
let invert = match a {
|
||||
TransferFunction::Table(t) => t.as_slice() == [1.0, 0.0],
|
||||
TransferFunction::Linear { slope, intercept } => *slope == -1.0 && *intercept == 1.0,
|
||||
_ => false,
|
||||
};
|
||||
let scale = |f: &TransferFunction| match f {
|
||||
TransferFunction::Identity => Some(1.0),
|
||||
TransferFunction::Linear { slope, intercept } if *intercept == 0.0 && (0.0..=1.0).contains(slope) => Some(*slope),
|
||||
_ => None,
|
||||
};
|
||||
let keep = match [r, g, b].map(scale) {
|
||||
[Some(r), Some(g), Some(b)] if r == g && g == b && (srgb || r == 0.0 || r == 1.0) => Some(r),
|
||||
_ => None,
|
||||
};
|
||||
let solid = matches!(a,TransferFunction::Linear {slope,intercept} if *slope>=255.0 && *intercept==0.0);
|
||||
let subtract = |f: &TransferFunction| match f {
|
||||
TransferFunction::Linear { slope, intercept } if *slope == 1.0 && (-1.0..=0.0).contains(intercept) => Some(-*intercept),
|
||||
_ => None,
|
||||
};
|
||||
let darkness = match [r, g, b].map(subtract) {
|
||||
[Some(r), Some(g), Some(b)] if r == g && g == b && srgb => Some(r),
|
||||
_ => None,
|
||||
};
|
||||
let opacity = scale(a);
|
||||
if matches!(v, V::Graphic)
|
||||
&& let (Some(d), Some(opacity)) = (darkness, opacity)
|
||||
{
|
||||
return Some(V::Shade { sd: 0.0, linear: false, dx: 0.0, dy: 0.0, keep: 1.0, subtract: d, opacity });
|
||||
}
|
||||
match (v, keep, opacity) {
|
||||
(V::Alpha(s), _, _) if identity && invert && s.sd == 0.0 => Some(V::Alpha(Shape { inverted: !s.inverted, ..s })),
|
||||
(V::Alpha(s), _, _) if identity && solid && s.plain() => Some(V::Alpha(s)),
|
||||
(v @ (V::Graphic | V::Blurred(..) | V::Shade { .. }), Some(keep), Some(opacity)) => v.scaled(keep, opacity),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate one filter's primitives, `k` the scale from its user space to the document.
|
||||
/// `None` when one does something the effects cannot represent.
|
||||
fn eval(f: &Filter, k: f64) -> Option<V> {
|
||||
let mut results: HashMap<&str, V> = HashMap::new();
|
||||
let mut last = V::Graphic;
|
||||
@@ -259,33 +305,7 @@ fn eval(f: &Filter, k: f64) -> Option<V> {
|
||||
_ => return None,
|
||||
},
|
||||
Kind::ComponentTransfer(c) => {
|
||||
let identity = [c.func_r(), c.func_g(), c.func_b()].iter().all(|f| matches!(f, TransferFunction::Identity));
|
||||
let invert = match c.func_a() {
|
||||
TransferFunction::Table(t) => t.as_slice() == [1.0, 0.0],
|
||||
TransferFunction::Linear { slope, intercept } => *slope == -1.0 && *intercept == 1.0,
|
||||
_ => false,
|
||||
};
|
||||
// A scale (a linear function through 0, or none) of each channel.
|
||||
let scale = |f: &TransferFunction| match f {
|
||||
TransferFunction::Identity => Some(1.0),
|
||||
TransferFunction::Linear { slope, intercept } if *intercept == 0.0 && (0.0..=1.0).contains(slope) => Some(*slope),
|
||||
_ => None,
|
||||
};
|
||||
// The same scale of R, G and B, on sRGB values (a scale of 0 or 1 is the same on
|
||||
// linear ones).
|
||||
let srgb = p.color_interpolation() == ColorInterpolation::SRGB;
|
||||
let keep = match [c.func_r(), c.func_g(), c.func_b()].map(scale) {
|
||||
[Some(r), Some(g), Some(b)] if r == g && g == b && (srgb || r == 0.0 || r == 1.0) => Some(r),
|
||||
_ => None,
|
||||
};
|
||||
// Our Outer Glow's solid silhouette (alpha × 255): an object's silhouette as far as the effects go.
|
||||
let solid = matches!(c.func_a(), TransferFunction::Linear { slope, intercept } if *slope >= 255.0 && *intercept == 0.0);
|
||||
match (input(c.input())?, keep, scale(c.func_a())) {
|
||||
(V::Alpha(s), _, _) if identity && invert && s.sd == 0.0 => V::Alpha(Shape { inverted: !s.inverted, ..s }),
|
||||
(V::Alpha(s), _, _) if identity && solid && s.plain() => V::Alpha(s),
|
||||
(v @ (V::Graphic | V::Blurred(..) | V::Shade { .. }), Some(keep), Some(opacity)) => v.scaled(keep, opacity)?,
|
||||
_ => return None,
|
||||
}
|
||||
transferred(input(c.input())?, [c.func_r(), c.func_g(), c.func_b(), c.func_a()], p.color_interpolation() == ColorInterpolation::SRGB)?
|
||||
}
|
||||
// Our Outer Glow's silhouette grown by a quarter raster pixel (`export`): a dilation of a
|
||||
// plain silhouette by up to that much at 72 ppi is that step.
|
||||
@@ -340,10 +360,13 @@ pub(super) fn effects(filters: &[Arc<Filter>], ts: Affine, blends: &HashMap<Stri
|
||||
("stylize.outerGlow", json!({ "blur": blur(shape.sd), "color": color, "opacity": opacity, "mode": mode }))
|
||||
}
|
||||
}
|
||||
// A drop shadow with Darkness below 100% blurs the object's sRGB values.
|
||||
[V::Shade { sd, linear, dx, dy, keep, opacity }, V::Graphic] if !*linear || *keep == 0.0 => {
|
||||
// A fixed subtraction before blur maps to Darkness; proportional intermediate
|
||||
// color filters must not be reinterpreted as a different-looking effect.
|
||||
[V::Shade { sd, linear, dx, dy, keep, subtract, opacity }, V::Graphic]
|
||||
if (!*linear || *keep == 0.0) && (*keep == 0.0 || *keep == 1.0) =>
|
||||
{
|
||||
let v = along(*dx, *dy);
|
||||
let (darkness, opacity) = (num((1.0 - *keep as f64) * 100.0), num(*opacity as f64 * 100.0));
|
||||
let (darkness, opacity) = (num(if *keep == 0.0 { 100.0 } else { *subtract as f64 * 100.0 }), num(*opacity as f64 * 100.0));
|
||||
let p = json!({ "x": num(v.x), "y": num(v.y), "blur": blur(*sd), "shade": "darkness", "darkness": darkness, "opacity": opacity, "mode": mode });
|
||||
("stylize.dropShadow", p)
|
||||
}
|
||||
|
||||
@@ -248,12 +248,10 @@ fn drop_shadow_chains_become_drop_shadows() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The object blurred, moved and its colors scaled under it is a drop shadow with Darkness when
|
||||
/// the blur and the scale work on sRGB values, or when the scale is 0; otherwise, on linear RGB
|
||||
/// values (the SVG default), it is reported. A blur on linear values whose result goes unused
|
||||
/// does not count.
|
||||
/// A proportional RGB filter is not additive Darkness. A fully black shadow remains
|
||||
/// recognizable, while other proportional transforms are reported rather than misrepresented.
|
||||
#[test]
|
||||
fn a_darkened_copy_of_the_object_becomes_a_drop_shadow_with_darkness() {
|
||||
fn proportional_filters_are_not_misread_as_additive_darkness() {
|
||||
let chain = |space: &str, transfer: &str, slope: f64| {
|
||||
format!(
|
||||
r##"<defs><filter id="s"{space}><feGaussianBlur in="SourceGraphic" stdDeviation="3" color-interpolation-filters="linearRGB" result="unused"/><feGaussianBlur in="SourceGraphic" stdDeviation="2"/><feOffset dx="4" dy="5"/><feComponentTransfer result="p"{transfer}><feFuncR type="linear" slope="{slope}"/><feFuncG type="linear" slope="{slope}"/><feFuncB type="linear" slope="{slope}"/><feFuncA type="linear" slope="0.6"/></feComponentTransfer><feMerge><feMergeNode in="p"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs><rect x="50" y="30" width="60" height="40" fill="#f00" filter="url(#s)"/>"##
|
||||
@@ -261,7 +259,7 @@ fn a_darkened_copy_of_the_object_becomes_a_drop_shadow_with_darkness() {
|
||||
};
|
||||
let srgb = r#" color-interpolation-filters="sRGB""#;
|
||||
// The scale 0 makes black whatever values it works on.
|
||||
for (space, slope, darkness) in [(srgb, 0.5, 50.0), ("", 0.0, 100.0)] {
|
||||
for (space, slope, darkness) in [(srgb, 0.0, 100.0), ("", 0.0, 100.0)] {
|
||||
let (d, w) = open(&chain(space, "", slope));
|
||||
assert!(w.is_empty(), "{w:?}");
|
||||
let fx = effects(&d);
|
||||
@@ -273,7 +271,7 @@ fn a_darkened_copy_of_the_object_becomes_a_drop_shadow_with_darkness() {
|
||||
assert_eq!((&p["x"], &p["y"], &p["blur"]), (&json!(4.0), &json!(5.0), &json!(4.0)));
|
||||
}
|
||||
// Scaled on linear values, or blurred on them before an sRGB scale.
|
||||
for (space, transfer) in [("", ""), ("", srgb)] {
|
||||
for (space, transfer) in [(srgb, ""), ("", ""), ("", srgb)] {
|
||||
let (d, w) = open(&chain(space, transfer, 0.5));
|
||||
assert!(w.iter().any(|w| w.contains("filter")), "{transfer}: {w:?}");
|
||||
assert!(effects(&d).is_empty());
|
||||
@@ -332,18 +330,18 @@ fn a_drop_shadow_with_darkness_exports_as_the_canvas_draws_it() {
|
||||
let mut n = Node::path(
|
||||
d.alloc_id(),
|
||||
shapes::rectangle(Rect::new(40.0, 20.0, 100.0, 70.0)),
|
||||
Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0),
|
||||
Appearance::basic(Paint::solid(Color::rgb(0.8, 0.4, 0.2)), Paint::None, 0.0),
|
||||
);
|
||||
let shadow = json!({"x": 30.0, "y": 30.0, "blur": 0.0, "opacity": 100.0, "mode": "normal", "shade": "darkness", "darkness": 50.0});
|
||||
let shadow = json!({"x": 30.0, "y": 30.0, "blur": 0.0, "opacity": 100.0, "mode": "normal", "shade": "darkness", "darkness": 25.0});
|
||||
n.appearance.effects = vec![Effect { id: "stylize.dropShadow".into(), params: shadow, visible: true }];
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
let (canvas, exported) = (render_artboard(&d), resvg_render(&svg, 200, 120));
|
||||
// Where only the shadow lies: the red at half its value.
|
||||
// An intermediate color distinguishes subtraction from proportional darkening.
|
||||
for img in [&canvas, &exported] {
|
||||
let [r, g, b] = img.over_white(115, 90);
|
||||
assert!(r.abs_diff(128) <= 2 && g <= 2 && b <= 2, "{:?}", [r, g, b]);
|
||||
assert!(r.abs_diff(140) <= 2 && g.abs_diff(38) <= 2 && b <= 2, "{:?}", [r, g, b]);
|
||||
}
|
||||
assert_similar(&canvas, &exported, 40.0, 0.02);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Effects parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add an overprinting strip of the lighter colour where two solid colours meet; recounted with Hard Mix (#3061) and Soft Mix already in the catalogue: Pathfinder 13/13, Illustrator effects 49/50; previously: Transform effect: Transform Objects, Transform Patterns and Scale Strokes & Effects, #885; G2: the Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step; remaining: filter thumbnails and a preview area inside the dialog; G2 estimate 4–6 → 1–2 h; Raster effects 82% → 85% (power-user view 49% → 52%), estimated, a modest move because the named missing dialog landed without thumbnails or an in-dialog preview; previously:; stacked geometry effects make at most 2 million anchors of a path and about 8 million lines to draw; Roughen, Zig Zag, Free Distort, Twist and the warps build within it, #3578; scores unchanged; G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h); 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; G2: Effect › Sketch › Note Paper; Halftone Pattern and Note Paper defaults documented as unverified VectorCraft choices; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 46/57, 11 remaining (8–14 h); G2: Sketch › Chrome; Sketch › Chalk & Charcoal, Graphic Pen and Reticulation and Artistic › Plastic Wrap, #2972, #2967, #2976, #3034: 37/57 Photoshop effects; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Zig Zag's Smooth points make a continuous wave, #2343; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 46/57, 11 remaining (8–14 h); Round Corners also rounds corners where a curve meets a line or another curve, with Live Corners' cut; straight corners unchanged; score unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; wide shadows and glows at high zoom, #1706; stacked Transform effects, #1705; 3D on nonzero compound paths, #1724; pattern tiles redraw, #1723; scores unchanged; Roughen's Smooth turn-backs, #1643; Scribble per region, #1648; score unchanged; Warp Arc bends in the object's proportions, #1578; Zig Zag ridges evenly spaced, #1558; effect parameters checked, #1592; score unchanged; raster effects after a pattern, radial or freeform gradient fill export at their place and size, #1536; score unchanged; 3D and Materials on outlined type and other shapes of many small curves, #1481; Twist 0° to 360°, #1524; twisted walls keep their faces, #1525; score unchanged; 3D and Materials: Inflate takes Twist, Taper and Cap; score unchanged; 3D and Materials: Perspective as the documented 0°–160° angle; 3D and Materials: the documented light settings) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (Drop Shadow Darkness uses fixed RGB subtraction and CMYK black addition; scores unchanged; Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add an overprinting strip of the lighter colour where two solid colours meet; recounted with Hard Mix (#3061) and Soft Mix already in the catalogue: Pathfinder 13/13, Illustrator effects 49/50; previously: Transform effect: Transform Objects, Transform Patterns and Scale Strokes & Effects, #885; G2: the Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step; remaining: filter thumbnails and a preview area inside the dialog; G2 estimate 4–6 → 1–2 h; Raster effects 82% → 85% (power-user view 49% → 52%), estimated, a modest move because the named missing dialog landed without thumbnails or an in-dialog preview; previously:; stacked geometry effects make at most 2 million anchors of a path and about 8 million lines to draw; Roughen, Zig Zag, Free Distort, Twist and the warps build within it, #3578; scores unchanged; G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h); 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; G2: Effect › Sketch › Note Paper; Halftone Pattern and Note Paper defaults documented as unverified VectorCraft choices; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 46/57, 11 remaining (8–14 h); G2: Sketch › Chrome; Sketch › Chalk & Charcoal, Graphic Pen and Reticulation and Artistic › Plastic Wrap, #2972, #2967, #2976, #3034: 37/57 Photoshop effects; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Zig Zag's Smooth points make a continuous wave, #2343; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 46/57, 11 remaining (8–14 h); Round Corners also rounds corners where a curve meets a line or another curve, with Live Corners' cut; straight corners unchanged; score unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; wide shadows and glows at high zoom, #1706; stacked Transform effects, #1705; 3D on nonzero compound paths, #1724; pattern tiles redraw, #1723; scores unchanged; Roughen's Smooth turn-backs, #1643; Scribble per region, #1648; score unchanged; Warp Arc bends in the object's proportions, #1578; Zig Zag ridges evenly spaced, #1558; effect parameters checked, #1592; score unchanged; raster effects after a pattern, radial or freeform gradient fill export at their place and size, #1536; score unchanged; 3D and Materials on outlined type and other shapes of many small curves, #1481; Twist 0° to 360°, #1524; twisted walls keep their faces, #1525; score unchanged; 3D and Materials: Inflate takes Twist, Taper and Cap; score unchanged; 3D and Materials: Perspective as the documented 0°–160° angle; 3D and Materials: the documented light settings) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Every entry of Illustrator's Effect menu, by its public feature name, against VectorCraft's effects catalogue
|
||||
(`effect_catalog()` in `crates/effects/src/lib.rs`) and the Effect menu built from it
|
||||
@@ -226,3 +226,4 @@ angular cut caps, exact visibility, 3D Classic, Turntable, 3D export) is 25–45
|
||||
| 2026-10-11 | minor | Brush Strokes landed (8 filters): 28/57 Photoshop effects |
|
||||
| 2026-10-10 | minor | Initial Revolve landed (#846) |
|
||||
| 2026-10-10 | major | First checklist, counted from the effects catalogue (20/57 Photoshop effects, 44/50 Illustrator effects) |
|
||||
| 2026-10-28 | minor | Drop Shadow Darkness subtracts the normalized amount from RGB source channels or adds it to CMYK/Gray black before profile conversion and blur; near-100% becomes gray black, with shadow opacity separate. SVG keeps live filters for uniform sRGB sources and renders shadows for other RGB profiles, source models and gradients. Gradient-stop adjustment is an application rule; spot/custom colors and composite-source parity remain open. Scores and estimates unchanged. |
|
||||
|
||||
@@ -829,6 +829,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv
|
||||
| 2026-10-10 | minor | G9 evidence counted from the tracker (28 `.ai`/EPS issues, 20 fixed); impact narrowed to handing files back |
|
||||
| 2026-10-10 | minor | Alpha blockers checked against the core-workflow gate: none |
|
||||
| 2026-10-10 | major | Moved from the ROADMAP's "Where we're lacking"; kept ids G1–G8, added G9–G19 (file formats, stability, bugs, preferences, 30.x additions, pen hardware, performance, localization, ecosystem, AI, accessibility) and re-ranked |
|
||||
| 2026-10-28 | minor | Drop Shadow Darkness subtracts the normalized amount from RGB source channels or adds it to CMYK/Gray black before profile conversion and blur; near-100% becomes gray black, with shadow opacity separate. SVG keeps live filters for uniform sRGB sources and renders shadows for other RGB profiles, source models and gradients. Gradient-stop adjustment is an application rule; spot/custom colors and composite-source parity remain open. Scores and estimates unchanged. |
|
||||
| 2026-10-28 | minor | Blob Brush Shift locks after clear axial movement, keeps earlier samples fixed, and waits without an off-axis trail. Activation uses raw press displacement in logical screen pixels, with exact quarter-turn boundaries. Explicit application rule; scores and estimates unchanged. |
|
||||
| 2026-10-28 | minor | Flare Tool Options opened by a canvas click previews its settings at that click. Reset refreshes the enabled preview while keeping the dialog open. Cancel discards preview art and tentative options; OK accepts one flare in one Undo step. Editing existing flare artwork remains outside this dialog scope. Scores and estimates unchanged. |
|
||||
|
||||
|
||||
+2
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user