mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Outer Glow grows its solid silhouette by a quarter raster pixel before the blur, as the glows AI files export measure
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
committed by
bflatastic
co-authored by
Claude Fable 5.1
parent
12d107cc56
commit
1b8e3d9ae1
@@ -806,6 +806,12 @@ type Places = BTreeMap<Vec<usize>, 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 {
|
||||
|
||||
@@ -594,6 +594,45 @@ pub fn blur_plane(a: &mut Vec<f32>, 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<f32>, 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];
|
||||
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ pub enum RasterFx {
|
||||
/// instead of `color`; `None` when it is `color`.
|
||||
darkness: Option<f32>,
|
||||
},
|
||||
/// 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)
|
||||
|
||||
+20
-2
@@ -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<f32> = 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.
|
||||
|
||||
@@ -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::<f64>() + 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::<f64>()
|
||||
+ 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!(
|
||||
"<feComponentTransfer in=\"SourceAlpha\"><feFuncA type=\"linear\" slope=\"255\"/></feComponentTransfer><feGaussianBlur stdDeviation=\"{}\" result=\"glow\"/>{}<feComposite in2=\"glow\" operator=\"in\" result=\"paint\"/><feMerge><feMergeNode in=\"paint\"/><feMergeNode in=\"SourceGraphic\"/></feMerge>",
|
||||
"<feComponentTransfer in=\"SourceAlpha\"><feFuncA type=\"linear\" slope=\"255\"/></feComponentTransfer><feMorphology operator=\"dilate\" radius=\"{}\"/><feGaussianBlur stdDeviation=\"{}\" result=\"glow\"/>{}<feComposite in2=\"glow\" operator=\"in\" result=\"paint\"/><feMerge><feMergeNode in=\"paint\"/><feMergeNode in=\"SourceGraphic\"/></feMerge>",
|
||||
fmt_num(grow, 3),
|
||||
sd(*blur),
|
||||
flood(color, *opacity)
|
||||
),
|
||||
|
||||
@@ -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<V> {
|
||||
/// 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<V> {
|
||||
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<V> {
|
||||
_ => 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<Filter>], ts: Affine, blends: &HashMap<Stri
|
||||
let blur = |sd: f64| num(2.0 * sd * k);
|
||||
let mode = blends.get(f.id()).map_or("normal", String::as_str);
|
||||
let paint = |c: Color, o: f32| (c.to_hex(), num(o as f64 * 100.0));
|
||||
let (id, params) = match eval(f)? {
|
||||
let (id, params) = match eval(f, k)? {
|
||||
V::Graphic => continue,
|
||||
V::Blurred(sd, _) => ("blur.gaussian", json!({ "radius": blur(sd) })),
|
||||
V::Feathered(sd) => ("stylize.feather", json!({ "radius": blur(sd) })),
|
||||
|
||||
Reference in New Issue
Block a user