Files
figcraft/crates/render/src/fx.rs
T
bflatastic a2602071d7 M3: paint and effects behaviour suite and fixes
**A paint and effects behaviour suite written from the public documentation, and fixes for every divergence it found.** Each documented rule (fill stacking and opacity, paint vs layer opacity, every blend mode per paint and per layer, pass-through and Normal containers, the four gradient kinds and their handles, stops, image scale modes, rotation and adjustments, the colour models and hex entry, the document colour profile, drop and inner shadows, showing shadows behind transparent areas, layer and background blurs, effects on groups, effect order, what exports include) is a table-driven case with a one-line derivation; where the documentation is silent the case says so and pins today's behaviour. Scenes are small and synthetic; pixel cases use tolerances (blurs are checked against the normal distribution with sigma = radius / 2).

**Fixes (13 render cases and 8 command/export cases fail on the old code)**
- **Pass-through and Normal containers**: a group or frame set to Normal now blends its children among themselves first (it never isolated), and a **clipping frame no longer isolates** its children: they were drawn on a fresh layer, so a Multiply child inside a frame (frames clip by default) met neither the frame's fill nor what is behind it. The clipped layer now starts as a copy of what lies under it and goes back in its place through the clip (a memcpy for rectangular clips, so no slower); backdrop effects see the right composite, also for a clipping frame inside a faded group. Same in exports: SVG gives Normal groups `isolation:isolate` and puts a clipping frame's clip on what blends instead of a clipped `<g>` (a clipped group is a stacking context, which isolates); PDF wraps Normal groups in an isolated group (`Node::isolates_children`, shared).
- **Plus darker** (linear burn) is drawn by its formula on the canvas and in pixel exports for paints, layers, shadows and noise (it was multiply); tiny-skia lacks the mode, so `fx::plus_darker` composites it and `paint::composite` routes every layer, shadow and noise composite through it.
- **Shadows hidden behind the layer**: a drop shadow that doesn't show behind transparent areas is now hidden behind what the layer covers, translucent and 0% fills included (it was cut out by the layer's alpha, so it showed through a 50% fill anyway); groups and layers whose pixels a layer blur or texture moves still hide it behind their pixels. One `shadow::Caster::new` serves the canvas, PNG and the PDF's shadow rasters. **SVG** never cut shadows out: its filters now composite them `out` of an image of the layer's outline (or `SourceAlpha` for groups), and the filter region reaches as far as the effects (a fixed 200% box cut off a small layer's long shadow).
- **Fill images cover the box to its edges**: an upscaled Fill (or covering Crop) image faded to transparent over a source pixel at the box's edge.
- **"Normal" meant pass-through** when named: `BlendMode::from_name("normal")` found pass-through first (its CSS keyword is also `normal`), so `node.set {blend: "normal"}` and the blend menu's Normal set pass-through. Labels now win, and the menu sends labels.

**Added**
- A blend mode menu for each paint in the colour picker (no pass-through for paints), sharing `right::blend_menu` with the Design panel's layer menu; strings already translated.
- Inspect (`node.get`, MCP) reports a solid paint's `blend` when it isn't normal.
2026-10-12 01:32:04 +07:00

270 lines
9.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Raster effects on premultiplied RGBA8 pixmaps: blur (3× box ≈ Gaussian), dilate/erode (spread),
//! alpha extraction and tinting.
use tiny_skia::Pixmap;
/// Box radii for three passes approximating a Gaussian of `sigma` (Kovesi).
fn box_sizes(sigma: f64) -> [usize; 3] {
let n = 3.0;
let w_ideal = (12.0 * sigma * sigma / n + 1.0).sqrt();
let mut wl = w_ideal.floor() as i64;
if wl % 2 == 0 {
wl -= 1;
}
let wu = wl + 2;
let m_ideal = (12.0 * sigma * sigma - n * (wl * wl) as f64 - 4.0 * n * wl as f64 - 3.0 * n) / (-4.0 * wl as f64 - 4.0);
let m = m_ideal.round() as i64;
let r = |i: i64| -> usize {
let w = if i < m { wl } else { wu };
((w.max(1) - 1) / 2) as usize
};
[r(0), r(1), r(2)]
}
fn box_blur_h(src: &[u8], dst: &mut [u8], w: usize, h: usize, r: usize) {
if r == 0 {
dst.copy_from_slice(src);
return;
}
let div = (2 * r + 1) as u32;
for y in 0..h {
let row = y * w * 4;
let mut acc = [0u32; 4];
// Edge: transparent outside.
for x in 0..=r.min(w.saturating_sub(1)) {
for c in 0..4 {
acc[c] += u32::from(src.get(row + x * 4 + c).copied().unwrap_or(0));
}
}
for x in 0..w {
for c in 0..4 {
if let Some(d) = dst.get_mut(row + x * 4 + c) {
*d = (acc[c] / div) as u8;
}
}
let add = x + r + 1;
if add < w {
for c in 0..4 {
acc[c] += u32::from(src.get(row + add * 4 + c).copied().unwrap_or(0));
}
}
if x >= r {
let sub = x - r;
for c in 0..4 {
acc[c] = acc[c].saturating_sub(u32::from(src.get(row + sub * 4 + c).copied().unwrap_or(0)));
}
}
}
}
}
fn transpose(src: &[u8], dst: &mut [u8], w: usize, h: usize) {
for y in 0..h {
for x in 0..w {
let s = (y * w + x) * 4;
let d = (x * h + y) * 4;
if let (Some(sp), Some(dp)) = (src.get(s..s + 4), dst.get_mut(d..d + 4)) {
dp.copy_from_slice(sp);
}
}
}
}
/// The largest blur standard deviation drawn (pixels): wider blurs look the same.
pub const MAX_SIGMA: f64 = 500.0;
/// Gaussian-like blur of the whole pixmap with standard deviation `sigma` (pixels).
pub fn blur(p: &mut Pixmap, sigma: f64) {
if !(sigma.is_finite() && sigma > 0.3) {
return;
}
let sigma = sigma.min(MAX_SIGMA);
let (w, h) = (p.width() as usize, p.height() as usize);
let radii = box_sizes(sigma);
let mut a = p.data().to_vec();
let mut b = vec![0u8; a.len()];
for r in radii {
box_blur_h(&a, &mut b, w, h, r);
std::mem::swap(&mut a, &mut b);
}
transpose(&a, &mut b, w, h);
std::mem::swap(&mut a, &mut b);
for r in radii {
box_blur_h(&a, &mut b, h, w, r);
std::mem::swap(&mut a, &mut b);
}
transpose(&a, &mut b, h, w);
p.data_mut().copy_from_slice(&b);
}
/// Grow (`r > 0`) or shrink (`r < 0`) the alpha of a pixmap by `r` pixels (square structuring
/// element, separable max/min). Colour channels follow alpha (tinted afterwards anyway).
pub fn morph(p: &mut Pixmap, r: i32) {
if r == 0 {
return;
}
let grow = r > 0;
let r = r.unsigned_abs().min(400) as usize;
let (w, h) = (p.width() as usize, p.height() as usize);
let alpha: Vec<u8> = p.data().as_chunks::<4>().0.iter().map(|c| c.get(3).copied().unwrap_or(0)).collect();
let pass = |src: &[u8], w: usize, h: usize, horizontal: bool| -> Vec<u8> {
let mut out = vec![0u8; src.len()];
for y in 0..h {
for x in 0..w {
let mut v: u8 = if grow { 0 } else { 255 };
let (lo, hi) = (x.saturating_sub(r), (x + r).min(w.saturating_sub(1)));
for k in lo..=hi {
let idx = if horizontal { y * w + k } else { k * h + y };
let s = src.get(idx).copied().unwrap_or(0);
v = if grow { v.max(s) } else { v.min(s) };
}
// Shrinking treats outside as empty.
if !grow && (x < r || x + r >= w) {
v = 0;
}
let o = if horizontal { y * w + x } else { x * h + y };
if let Some(d) = out.get_mut(o) {
*d = v;
}
}
}
out
};
let a1 = pass(&alpha, w, h, true);
// Vertical pass: treat columns as rows by swapping roles.
let mut a2 = vec![0u8; a1.len()];
for x in 0..w {
for y in 0..h {
let mut v: u8 = if grow { 0 } else { 255 };
let (lo, hi) = (y.saturating_sub(r), (y + r).min(h.saturating_sub(1)));
for k in lo..=hi {
let s = a1.get(k * w + x).copied().unwrap_or(0);
v = if grow { v.max(s) } else { v.min(s) };
}
if !grow && (y < r || y + r >= h) {
v = 0;
}
if let Some(d) = a2.get_mut(y * w + x) {
*d = v;
}
}
}
for (px, a) in p.data_mut().as_chunks_mut::<4>().0.iter_mut().zip(a2) {
px.copy_from_slice(&[a, a, a, a]);
}
}
/// Replace every pixel with `color` (non-premultiplied RGBA 0..1) at the pixel's alpha × color alpha.
pub fn tint(p: &mut Pixmap, color: [f32; 4]) {
let [r, g, b, a] = color;
for px in p.data_mut().as_chunks_mut::<4>().0 {
let alpha = f32::from(px.get(3).copied().unwrap_or(0)) / 255.0 * a;
let q = |c: f32| (c * alpha * 255.0).round().clamp(0.0, 255.0) as u8;
px.copy_from_slice(&[q(r), q(g), q(b), (alpha * 255.0).round().clamp(0.0, 255.0) as u8]);
}
}
/// Invert the alpha (premultiplied white with alpha 1-a): used for inner shadows.
pub fn invert_alpha(p: &mut Pixmap) {
for px in p.data_mut().as_chunks_mut::<4>().0 {
let a = 255 - px.get(3).copied().unwrap_or(0);
px.copy_from_slice(&[a, a, a, a]);
}
}
/// Multiply every pixel of `p` by the alpha of `m` (same size); `invert` uses 1-alpha.
pub fn mask_by(p: &mut Pixmap, m: &Pixmap, invert: bool) {
for (px, mp) in p.data_mut().as_chunks_mut::<4>().0.iter_mut().zip(m.data().chunks_exact(4)) {
let ma = u32::from(mp.get(3).copied().unwrap_or(0));
let ma = if invert { 255 - ma } else { ma };
for c in px.iter_mut() {
*c = ((u32::from(*c) * ma + 127) / 255) as u8;
}
}
}
/// Call `f(dst_row, src_row, row, first_col)` for each row where `src`, with its top-left at `at`
/// on `dst`, overlaps it (premultiplied RGBA bytes of the overlapping columns; `row` and
/// `first_col` in `src`'s pixels). Nothing past either edge is visited.
pub(crate) fn overlap_rows(dst: &mut Pixmap, at: (i32, i32), src: &Pixmap, mut f: impl FnMut(&mut [u8], &[u8], i64, i64)) {
let (dw, dh) = (i64::from(dst.width()), i64::from(dst.height()));
let (sw, sh) = (i64::from(src.width()), i64::from(src.height()));
let (ax, ay) = (i64::from(at.0), i64::from(at.1));
let (c0, c1) = ((-ax).max(0), sw.min(dw - ax));
let (r0, r1) = ((-ay).max(0), sh.min(dh - ay));
if c1 <= c0 || r1 <= r0 {
return;
}
let len = (c1 - c0) as usize * 4;
let s = src.data();
let d = dst.data_mut();
for r in r0..r1 {
let (si, di) = ((r * sw + c0) as usize * 4, ((r + ay) * dw + ax + c0) as usize * 4);
if let (Some(sp), Some(dp)) = (s.get(si..si + len), d.get_mut(di..di + len)) {
f(dp, sp, r, c0);
}
}
}
/// Composite `src` at `opacity` onto `dst` (top-left at `at`) with plus-darker (linear burn:
/// `max(0, backdrop + source - 1)`, through the W3C general compositing formula), which
/// tiny-skia lacks.
pub fn plus_darker(dst: &mut Pixmap, at: (i32, i32), src: &Pixmap, opacity: f32) {
let o = if opacity.is_finite() { opacity.clamp(0.0, 1.0) } else { 0.0 };
overlap_rows(dst, at, src, |dp, sp, _, _| {
for (d, s) in dp.chunks_exact_mut(4).zip(sp.chunks_exact(4)) {
let f = |v: u8| f32::from(v) / 255.0;
let sa = f(s[3]) * o;
if sa <= 0.0 {
continue;
}
let da = f(d[3]);
for c in 0..3 {
let (sc, dc) = (f(s[c]) * o, f(d[c]));
let v = sc * (1.0 - da) + dc * (1.0 - sa) + (sa * dc + da * sc - sa * da).max(0.0);
d[c] = (v.clamp(0.0, 1.0) * 255.0).round() as u8;
}
d[3] = ((sa + da - sa * da).clamp(0.0, 1.0) * 255.0).round() as u8;
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn blur_spreads_and_conserves() {
let mut p = Pixmap::new(64, 64).unwrap();
let i = (32 * 64 + 32) * 4;
p.data_mut()[i..i + 4].copy_from_slice(&[255, 255, 255, 255]);
let before: u32 = p.data().iter().map(|v| u32::from(*v)).sum();
blur(&mut p, 3.0);
let after: u32 = p.data().iter().map(|v| u32::from(*v)).sum();
assert!(p.data()[i + 3] < 255);
// Integer rounding loses a little.
assert!(after <= before && after > 0, "{before} {after}");
}
#[test]
fn morph_grows() {
let mut p = Pixmap::new(20, 20).unwrap();
let i = (10 * 20 + 10) * 4;
p.data_mut()[i..i + 4].copy_from_slice(&[255, 255, 255, 255]);
morph(&mut p, 2);
let count = p.data().as_chunks::<4>().0.iter().filter(|c| c[3] == 255).count();
assert_eq!(count, 25);
morph(&mut p, -2);
let count = p.data().as_chunks::<4>().0.iter().filter(|c| c[3] == 255).count();
assert_eq!(count, 1);
}
#[test]
fn box_sizes_sane() {
for s in [0.5, 1.0, 4.0, 50.0] {
let b = box_sizes(s);
assert!(b.iter().all(|r| *r < 1000));
}
}
}