mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-25 19:06:47 -06:00
**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.
142 lines
5.8 KiB
Rust
142 lines
5.8 KiB
Rust
//! Grain effects: noise (grains over the layer's pixels) and texture (the layer's pixels scattered
|
|
//! into a rough edge).
|
|
//!
|
|
//! Grains are cells of the effect's size in the layer's own coordinates, each with a random value
|
|
//! hashed from its position and the layer: they stay put when the canvas pans or renders in pieces,
|
|
//! scale with the layer when zooming, and every export draws the same grains.
|
|
|
|
use figcraft_doc::{Noise, NoiseMode, Texture};
|
|
use figcraft_geom::Affine;
|
|
use tiny_skia::Pixmap;
|
|
|
|
/// A well-mixed 64-bit hash of a grain cell.
|
|
fn hash(ix: i64, iy: i64, seed: u32) -> u64 {
|
|
let mut h = (ix as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15)
|
|
^ (iy as u64).wrapping_mul(0xC2B2_AE3D_27D4_EB4F)
|
|
^ u64::from(seed).wrapping_mul(0x1656_67B1_9E37_79F9);
|
|
h ^= h >> 33;
|
|
h = h.wrapping_mul(0xFF51_AFD7_ED55_8CCD);
|
|
h ^= h >> 33;
|
|
h = h.wrapping_mul(0xC4CE_B9FE_1A85_EC53);
|
|
h ^ (h >> 33)
|
|
}
|
|
|
|
/// 16 bits of `h` from bit `at`, as 0..1 (1 excluded: a density of 1 draws every grain).
|
|
fn unit(h: u64, at: u32) -> f32 {
|
|
f32::from(((h >> at) & 0xFFFF) as u16) / 65536.0
|
|
}
|
|
|
|
/// Maps layer pixels to the grain cells under their centres.
|
|
struct Cells {
|
|
/// Layer pixels → local units, divided by the grain size: `[a, b, c, d, e, f]`.
|
|
m: [f64; 6],
|
|
}
|
|
|
|
impl Cells {
|
|
/// `xf` maps local units to layer pixels; `grain` is the cell size in local units.
|
|
fn new(xf: Affine, grain: (f64, f64)) -> Option<Cells> {
|
|
let inv = xf.inverse();
|
|
let c = inv.as_coeffs();
|
|
let (gx, gy) = grain;
|
|
let m = [c[0] / gx, c[1] / gy, c[2] / gx, c[3] / gy, c[4] / gx, c[5] / gy];
|
|
m.iter().all(|v| v.is_finite()).then_some(Cells { m })
|
|
}
|
|
fn at(&self, x: u32, y: u32) -> (i64, i64) {
|
|
let (px, py) = (f64::from(x) + 0.5, f64::from(y) + 0.5);
|
|
let [a, b, c, d, e, f] = self.m;
|
|
((a * px + c * py + e).floor() as i64, (b * px + d * py + f).floor() as i64)
|
|
}
|
|
}
|
|
|
|
/// Draw `nz`'s grains over the pixels of `layer` (local → layer pixels `xf`), each grain at the
|
|
/// alpha of the pixel under it, blended with the noise's blend mode. Grains are twice the noise
|
|
/// size across.
|
|
pub fn noise(layer: &mut Pixmap, nz: &Noise, xf: Affine, seed: u32) {
|
|
if let Some(g) = grains(layer, nz, xf, seed) {
|
|
crate::paint::composite(layer, (0, 0), &g, 1.0, nz.blend);
|
|
}
|
|
}
|
|
|
|
/// `nz`'s grains for the pixels of `layer` (see [`noise`]), to draw over it with the noise's blend
|
|
/// mode; `None` when there are none.
|
|
pub fn grains(layer: &Pixmap, nz: &Noise, xf: Affine, seed: u32) -> Option<Pixmap> {
|
|
let (sx, sy) = nz.grain();
|
|
let cells = Cells::new(xf, (sx * 2.0, sy * 2.0))?;
|
|
let density = if nz.density.is_finite() { nz.density.clamp(0.0, 1.0) as f32 } else { 0.0 };
|
|
if density <= 0.0 {
|
|
return None;
|
|
}
|
|
let (w, h) = (layer.width(), layer.height());
|
|
let mut grains = Pixmap::new(w, h)?;
|
|
let (c1, c2) = (nz.color.sanitized(), nz.secondary.sanitized());
|
|
let multi_alpha = if nz.opacity.is_finite() { nz.opacity.clamp(0.0, 1.0) } else { 0.0 };
|
|
let src = layer.data();
|
|
let dst = grains.data_mut();
|
|
for y in 0..h {
|
|
for x in 0..w {
|
|
let i = ((y * w + x) * 4) as usize;
|
|
let a = f32::from(src.get(i + 3).copied().unwrap_or(0)) / 255.0;
|
|
if a <= 0.0 {
|
|
continue;
|
|
}
|
|
let (ix, iy) = cells.at(x, y);
|
|
let hv = hash(ix, iy, seed);
|
|
if unit(hv, 0) >= density {
|
|
continue;
|
|
}
|
|
let [r, g, b, ca] = match nz.mode {
|
|
NoiseMode::Mono => [c1.r, c1.g, c1.b, c1.a],
|
|
NoiseMode::Duo if hv >> 63 == 1 => [c2.r, c2.g, c2.b, c2.a],
|
|
NoiseMode::Duo => [c1.r, c1.g, c1.b, c1.a],
|
|
NoiseMode::Multi => [unit(hv, 16), unit(hv, 32), unit(hv, 48), multi_alpha],
|
|
};
|
|
let al = ca * a;
|
|
let q = |v: f32| (v * al * 255.0).round().clamp(0.0, 255.0) as u8;
|
|
if let Some(px) = dst.get_mut(i..i + 4) {
|
|
px.copy_from_slice(&[q(r), q(g), q(b), (al * 255.0).round().clamp(0.0, 255.0) as u8]);
|
|
}
|
|
}
|
|
}
|
|
Some(grains)
|
|
}
|
|
|
|
/// Scatter `layer`'s pixels by up to the texture's radius (`scale` device pixels per unit), one
|
|
/// random move per grain (twice the texture size across): edges turn rough and grainy. Clipped to
|
|
/// the shape, the result stays within the layer's own pixels.
|
|
pub fn texture(layer: &mut Pixmap, t: &Texture, xf: Affine, scale: f64, seed: u32) {
|
|
let r = t.radius * scale;
|
|
if !(r.is_finite() && r >= 0.5) {
|
|
return;
|
|
}
|
|
let r = r.min(2048.0) as f32;
|
|
let (sx, sy) = t.grain();
|
|
let Some(cells) = Cells::new(xf, (sx * 2.0, sy * 2.0)) else { return };
|
|
let (w, h) = (layer.width(), layer.height());
|
|
let src = layer.data().to_vec();
|
|
let dst = layer.data_mut();
|
|
for y in 0..h {
|
|
for x in 0..w {
|
|
let (ix, iy) = cells.at(x, y);
|
|
let hv = hash(ix, iy, seed ^ 0x7E57_u32);
|
|
// A uniform point in the disc of radius r.
|
|
let (ang, dist) = (unit(hv, 0) * std::f32::consts::TAU, unit(hv, 16).sqrt() * r);
|
|
let (fx, fy) = (x as f32 + 0.5 + ang.cos() * dist, y as f32 + 0.5 + ang.sin() * dist);
|
|
let i = ((y * w + x) * 4) as usize;
|
|
let mut px = [0u8; 4];
|
|
if fx >= 0.0 && fy >= 0.0 && (fx as u32) < w && (fy as u32) < h {
|
|
let j = (((fy as u32) * w + fx as u32) * 4) as usize;
|
|
if let Some(s) = src.get(j..j + 4) {
|
|
px.copy_from_slice(s);
|
|
}
|
|
}
|
|
if t.clip_to_shape {
|
|
let keep = u32::from(src.get(i + 3).copied().unwrap_or(0));
|
|
px = px.map(|c| ((u32::from(c) * keep + 127) / 255) as u8);
|
|
}
|
|
if let Some(d) = dst.get_mut(i..i + 4) {
|
|
d.copy_from_slice(&px);
|
|
}
|
|
}
|
|
}
|
|
}
|