diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 1e50fabe7..4e72a6cae 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -806,6 +806,12 @@ type Places = BTreeMap, Vec<(usize, usize)>>; pub const FORMAT_VERSION: u32 = 1; impl Document { + /// Document Raster Effects Settings › Resolution in ppi, 1 to 2400 (72 when the stored value + /// isn't a number). + pub fn raster_ppi(&self) -> f64 { + Some(self.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0) + } + /// The ruler zero point, or zero on both axes when either stored coordinate is invalid. /// Rulers, coordinate fields and guide snapping use the same supported range. pub fn ruler_origin_or_zero(&self) -> Point { diff --git a/crates/effects/src/pixel/mod.rs b/crates/effects/src/pixel/mod.rs index 7554efbca..ee25c9ca6 100644 --- a/crates/effects/src/pixel/mod.rs +++ b/crates/effects/src/pixel/mod.rs @@ -594,6 +594,45 @@ pub fn blur_plane(a: &mut Vec, w: usize, h: usize, sigma: f64) { } } +/// Grow a `w` × `h` plane by `radius` pixels each way, the box of SVG's feMorphology: each value +/// becomes the largest within the radius. A fractional radius blends the two whole radii around +/// it, so a silhouette grows smoothly as the raster scales. Does nothing for a radius of zero or +/// less, or when the sizes don't match. +pub fn dilate_plane(a: &mut Vec, w: usize, h: usize, radius: f64) { + if radius.is_nan() || radius <= 0.0 || w == 0 || h == 0 || w.checked_mul(h) != Some(a.len()) { + return; + } + let radius = radius.min(MAX_BOX as f64); + let whole = radius.floor() as usize; + let frac = (radius - whole as f64) as f32; + let mut tmp = vec![0.0; a.len()]; + for (len, other) in [(w, h), (h, w)] { + max_plane(a, &mut tmp, len, whole, frac); + std::mem::swap(a, &mut tmp); + transpose(a, &mut tmp, len, other); + std::mem::swap(a, &mut tmp); + } +} + +/// One horizontal pass of [`dilate_plane`] over the rows of `w` values of a plane: the largest +/// value within `whole`, blended by `frac` towards the largest within `whole + 1`. +fn max_plane(src: &[f32], dst: &mut [f32], w: usize, whole: usize, frac: f32) { + for (row, out) in src.chunks_exact(w).zip(dst.chunks_exact_mut(w)) { + for (x, o) in out.iter_mut().enumerate() { + let lo = x.saturating_sub(whole + 1); + let hi = (x + whole + 1).min(w - 1); + let (mut inner, mut ring) = (row[x], row[x]); + for (j, v) in row[lo..=hi].iter().enumerate() { + if (lo + j).abs_diff(x) <= whole { + inner = inner.max(*v); + } + ring = ring.max(*v); + } + *o = inner + (ring - inner) * frac; + } + } +} + /// Premultiplied RGBA at 16 bits (×257): blurs and averages that keep their tones. pub(super) type Px16 = [u16; 4]; diff --git a/crates/effects/src/pixel/tests.rs b/crates/effects/src/pixel/tests.rs index 41300884f..361c50733 100644 --- a/crates/effects/src/pixel/tests.rs +++ b/crates/effects/src/pixel/tests.rs @@ -41,6 +41,26 @@ fn fx(id: &str, p: serde_json::Value) -> PixelFx { } } +#[test] +fn dilate_plane_grows_by_its_radius_and_blends_a_fraction() { + // GUARDS: an Outer Glow grows its silhouette by a quarter raster pixel before the blur; a step in + // that growth as the raster scales would show as the glow's width jumping with the zoom. + let mut dot = vec![0.0f32; 7 * 7]; + dot[3 * 7 + 3] = 255.0; + let mut grown = dot.clone(); + dilate_plane(&mut grown, 7, 7, 1.0); + assert_eq!(grown[2 * 7 + 2], 255.0); + assert_eq!(grown[7 + 3], 0.0); + let mut half = dot.clone(); + dilate_plane(&mut half, 7, 7, 1.5); + assert_eq!(half[3 * 7 + 4], 255.0); + assert_eq!(half[3 * 7 + 5], 127.5); + assert_eq!(half[3 * 7 + 6], 0.0); + let mut same = dot.clone(); + dilate_plane(&mut same, 7, 7, 0.0); + assert_eq!(same, dot); +} + fn grey(v: u8) -> [u8; 4] { [v, v, v, 255] } diff --git a/crates/effects/src/raster.rs b/crates/effects/src/raster.rs index 23d67dff6..79ad9c25e 100644 --- a/crates/effects/src/raster.rs +++ b/crates/effects/src/raster.rs @@ -29,6 +29,8 @@ pub enum RasterFx { /// instead of `color`; `None` when it is `color`. darkness: Option, }, + /// Blurs the art's solid silhouette grown by a quarter of a pixel of the document's raster grid, + /// as the glows AI files export measure. OuterGlow { mode: BlendMode, opacity: f32, @@ -93,6 +95,10 @@ impl RasterFx { pub fn spread(&self, bounds: Rect, line: f64) -> Rect { match self { RasterFx::Rasterize(r) => r.image(bounds, line), + RasterFx::OuterGlow { .. } => { + let o = self.outset(bounds) + 0.25 * line; + bounds.inflate(o, o) + } fx => { let o = fx.outset(bounds); bounds.inflate(o, o) diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index a73a215b1..0d0adf794 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -584,7 +584,7 @@ thread_local! { /// Document Raster Effects Settings › Resolution of `doc`, in ppi (1 to 2400; 72 when it isn't a /// number). fn doc_raster_ppi(doc: &Document) -> f64 { - Some(doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0) + doc.raster_ppi() } /// The effects [`Renderer::pixel_content`] runs on the content's pixels (those changing the @@ -1052,7 +1052,9 @@ impl Renderer { (e.image.clone(), (p.x + e.rel.0).round(), (p.y + e.rel.1).round(), e.scale, e.moved) } None => { - let spread = blur.max(0.0) * 1.5 / f.px + 2.0; + // An Outer Glow's silhouette grows by a quarter of a pixel of the document's raster grid first. + let grow = if matches!(fx, RasterFx::OuterGlow { .. }) { 18.0 / doc_raster_ppi(f.doc) } else { 0.0 }; + let spread = (blur.max(0.0) * 1.5 + grow) / f.px + 2.0; let reach = reach + Vec2::new(dx, dy); let r = f.view.transform_rect_bbox(reach).inflate(spread, spread); let (x0, y0) = (r.x0.floor(), r.y0.floor()); @@ -1112,6 +1114,7 @@ impl Renderer { // An Outer Glow blurs a solid silhouette: wherever the art has any alpha, as AI files draw a glow round a glow. let solid = matches!(fx, RasterFx::OuterGlow { .. }); let mut a: Vec = pm.data().iter().map(|p| if solid && p.a > 0 { 255.0 } else { p.a as f32 }).collect(); + effects::pixel::dilate_plane(&mut a, wu, hu, grow / f.px / scale); effects::pixel::blur_plane(&mut a, wu, hu, sigma); let [cr, cg, cb] = f.ink.rgb(&color); let tint = |v: f32, ch: f32| (v * ch).round().clamp(0.0, 255.0) as u8; @@ -2589,6 +2592,21 @@ mod tests { assert!(faint[1] < 210, "the glow reaches past the edge: {faint:?}"); } + #[test] + fn an_outer_glow_grows_its_silhouette_by_a_quarter_raster_pixel() { + // GUARDS: the glows AI files export measure as the solid silhouette grown by a quarter of a pixel + // of the document's raster grid, then blurred: a quarter point at 72 ppi, next to nothing at 720. + let r = Rect::new(30.0, 30.0, 70.0, 70.0); + let green_outside = |ppi: f64| { + let mut d = + doc_with(r, Color::BLACK, vec![("stylize.outerGlow", json!({"color": "#ff0000", "mode": "normal", "opacity": 100, "blur": 4}))]); + d.raster_effects_ppi = ppi; + render(&d).pixel(27, 50)[1] + }; + let (coarse, fine) = (green_outside(72.0), green_outside(720.0)); + assert!(coarse + 3 < fine, "the red glow is stronger at 72 ppi: 72 ppi {coarse}, 720 ppi {fine}"); + } + #[test] fn a_glow_glows_round_the_glows_before_it_in_the_stack() { // GUARDS: the second glow is of the art with the first glow, not of the bare art, as AI files and the SVG export draw it. diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 9cba6e501..661b6381e 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -1651,7 +1651,11 @@ impl Writer<'_> { return 0; } let bounds = n.visual_bounds_with(&self.doc.symbol_boxes()); - let reach: f64 = fx.iter().map(|f| f.outset(bounds.unwrap_or_default())).sum::() + 2.0; + // An Outer Glow's silhouette grows by a quarter of a pixel of the document's raster grid first. + let grow = 18.0 / self.doc.raster_ppi(); + let reach: f64 = + fx.iter().map(|f| f.outset(bounds.unwrap_or_default()) + if matches!(f, RasterFx::OuterGlow { .. }) { grow } else { 0.0 }).sum::() + + 2.0; let region = bounds.map(|b| self.xf.transform_rect_bbox(b).inflate(reach, reach)); let mut opened = 0; for f in fx.iter().rev() { @@ -1691,9 +1695,10 @@ impl Writer<'_> { self.num(*dy), flood(color, *opacity) ), - // The glow blurs a solid silhouette: alpha × 255 makes any alpha full. + // The glow blurs a solid silhouette (alpha × 255 makes any alpha full) grown by a quarter raster pixel. RasterFx::OuterGlow { opacity, blur, color, .. } => format!( - "{}", + "{}", + fmt_num(grow, 3), sd(*blur), flood(color, *opacity) ), diff --git a/crates/svg/src/import/fx.rs b/crates/svg/src/import/fx.rs index 072e702a0..2f94537f5 100644 --- a/crates/svg/src/import/fx.rs +++ b/crates/svg/src/import/fx.rs @@ -10,7 +10,7 @@ use std::collections::HashMap; use std::sync::Arc; use serde_json::json; -use usvg::filter::{ColorInterpolation, ColorMatrixKind, CompositeOperator, Filter, Input, Kind, TransferFunction}; +use usvg::filter::{ColorInterpolation, ColorMatrixKind, CompositeOperator, Filter, Input, Kind, MorphologyOperator, TransferFunction}; use usvg::roxmltree; use vectorcraft_color::Color; use vectorcraft_doc::Effect; @@ -177,8 +177,9 @@ fn color(c: usvg::Color) -> Color { Color::rgb8(c.red, c.green, c.blue) } -/// Evaluate one filter's primitives; `None` when one does something the effects can't. -fn eval(f: &Filter) -> Option { +/// 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. +fn eval(f: &Filter, k: f64) -> Option { let mut results: HashMap<&str, V> = HashMap::new(); let mut last = V::Graphic; for p in f.primitives() { @@ -238,6 +239,18 @@ fn eval(f: &Filter) -> Option { _ => return None, } } + // 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. + Kind::Morphology(m) + if m.operator() == MorphologyOperator::Dilate + && m.radius_x().get() == m.radius_y().get() + && f64::from(m.radius_x().get()) * k <= 0.25 + 1e-6 => + { + match input(m.input())? { + V::Alpha(s) if s.plain() => V::Alpha(s), + _ => return None, + } + } Kind::DropShadow(d) if matches!(input(d.input())?, V::Graphic) => { let shape = Shape { sd: sd(d.std_dev_x().get(), d.std_dev_y().get()), dx: d.dx() as f64, dy: d.dy() as f64, moved: true, inverted: false }; @@ -264,7 +277,7 @@ pub(super) fn effects(filters: &[Arc], ts: Affine, blends: &HashMap continue, V::Blurred(sd, _) => ("blur.gaussian", json!({ "radius": blur(sd) })), V::Feathered(sd) => ("stylize.feather", json!({ "radius": blur(sd) })),