mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-12 17:01:09 +00: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.
235 lines
11 KiB
Rust
235 lines
11 KiB
Rust
//! Backdrops: what lies under a layer, for the effects that show it (background blur and glass).
|
||
//!
|
||
//! A render draws some layers into surfaces of their own (opacity, blend modes and effects,
|
||
//! clipping frames, masks) and composites each onto the surface below once it is done. A layer's
|
||
//! backdrop is everything drawn under it so far on all of those surfaces, composited the way they
|
||
//! will be: the frame's own fill under a clipped child, the page under that. [`Under`] records the
|
||
//! stack of surfaces as the render descends; [`backdrop`] flattens it for a rectangle.
|
||
//!
|
||
//! The PDF and SVG exports, which have no pixels to read, render what lies under the layer instead
|
||
//! ([`effects_beneath`]) and draw the same [`effects`] from it.
|
||
|
||
use figcraft_doc::{Document, EffectKind, Node, NodeId};
|
||
use figcraft_geom::{Affine, Rect};
|
||
use tiny_skia::{Mask, Pixmap, PixmapPaint, Transform};
|
||
|
||
use crate::shadow::{Outline, spread_alpha};
|
||
|
||
/// Deepest ancestry walked (the renderer's nesting limit).
|
||
const MAX_DEPTH: usize = 256;
|
||
use crate::{View, fx, glass};
|
||
|
||
/// One isolated surface of a render, and how it will be composited onto the surface below.
|
||
#[derive(Clone, Copy)]
|
||
pub struct Under<'a> {
|
||
/// The surface below.
|
||
pub pix: &'a Pixmap,
|
||
/// Where this surface's top-left pixel lands on `pix`.
|
||
pub at: (i32, i32),
|
||
pub opacity: f32,
|
||
pub blend: figcraft_color::BlendMode,
|
||
/// What this surface is clipped by when composited, in its own pixels.
|
||
pub clip: Option<&'a Mask>,
|
||
/// The surface started as a copy of `pix` there and goes back in its place through `clip`
|
||
/// (a clipping frame's children: clipping doesn't isolate them), instead of over it.
|
||
pub replaces: bool,
|
||
/// The surface under `pix`, if `pix` is itself an isolated surface.
|
||
pub below: Option<&'a Under<'a>>,
|
||
}
|
||
|
||
impl<'a> Under<'a> {
|
||
/// A surface drawn at `at` onto `pix` (itself over `below`), as is.
|
||
pub fn new(pix: &'a Pixmap, at: (i32, i32), below: Option<&'a Under<'a>>) -> Self {
|
||
Under { pix, at, opacity: 1.0, blend: figcraft_color::BlendMode::Normal, clip: None, replaces: false, below }
|
||
}
|
||
}
|
||
|
||
/// Multiply `p` (the pixels at `(x, y)` of a surface) by the coverage of `m` (that surface's clip).
|
||
fn clip_at(p: &mut Pixmap, m: &Mask, x: i32, y: i32) {
|
||
let (mw, mh) = (i64::from(m.width()), i64::from(m.height()));
|
||
let w = p.width() as usize;
|
||
let md = m.data();
|
||
for (i, px) in p.data_mut().as_chunks_mut::<4>().0.iter_mut().enumerate() {
|
||
let (mx, my) = (i64::from(x) + (i % w) as i64, i64::from(y) + (i / w) as i64);
|
||
let c = if mx >= 0 && my >= 0 && mx < mw && my < mh { u32::from(md.get((my * mw + mx) as usize).copied().unwrap_or(0)) } else { 0 };
|
||
if c < 255 {
|
||
for v in px.iter_mut() {
|
||
*v = ((u32::from(*v) * c + 127) / 255) as u8;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The backdrop of the `w` × `h` pixel rectangle at `(x, y)` of `pix`: what is drawn on `pix` so
|
||
/// far, over everything under it.
|
||
pub fn backdrop(pix: &Pixmap, under: Option<&Under>, x: i32, y: i32, w: u32, h: u32) -> Option<Pixmap> {
|
||
onto(copy_rect(pix, x, y, w, h)?, under, x, y)
|
||
}
|
||
|
||
/// The `w` × `h` pixels of `pix` at `(x, y)` (transparent past its edges).
|
||
fn copy_rect(pix: &Pixmap, x: i32, y: i32, w: u32, h: u32) -> Option<Pixmap> {
|
||
let mut part = Pixmap::new(w, h)?;
|
||
let copy = PixmapPaint { blend_mode: tiny_skia::BlendMode::Source, ..Default::default() };
|
||
part.draw_pixmap(x.checked_neg()?, y.checked_neg()?, pix.as_ref(), ©, Transform::identity(), None);
|
||
Some(part)
|
||
}
|
||
|
||
/// `part`, the pixels at `(x, y)` of a surface, composited onto the surfaces `under` it.
|
||
fn onto(mut part: Pixmap, under: Option<&Under>, x: i32, y: i32) -> Option<Pixmap> {
|
||
let Some(u) = under else { return Some(part) };
|
||
let (w, h) = (part.width(), part.height());
|
||
let (ux, uy) = (x.checked_add(u.at.0)?, y.checked_add(u.at.1)?);
|
||
if u.replaces {
|
||
// `part` takes the place of `u.pix`'s pixels through the clip, then goes where they go.
|
||
let mut merged = copy_rect(u.pix, ux, uy, w, h)?;
|
||
replace(&mut merged, (0, 0), &part, u.clip.map(|m| (m, (x, y))));
|
||
return onto(merged, u.below, ux, uy);
|
||
}
|
||
let mut out = backdrop(u.pix, u.below, ux, uy, w, h)?;
|
||
if let Some(m) = u.clip {
|
||
clip_at(&mut part, m, x, y);
|
||
}
|
||
crate::paint::composite(&mut out, (0, 0), &part, u.opacity, u.blend);
|
||
Some(out)
|
||
}
|
||
|
||
/// Put `src` in place of the pixels of `dst` at `at`, through `clip` (a mask and where `src`'s
|
||
/// top-left lies on it): `dst` keeps what the clip leaves out, and pixels past either edge are
|
||
/// dropped.
|
||
pub(crate) fn replace(dst: &mut Pixmap, at: (i32, i32), src: &Pixmap, clip: Option<(&Mask, (i32, i32))>) {
|
||
fx::overlap_rows(dst, at, src, |dp, sp, r, c0| {
|
||
let Some((m, (mx, my))) = clip else { return dp.copy_from_slice(sp) };
|
||
let (mw, mh, my) = (i64::from(m.width()), i64::from(m.height()), r + i64::from(my));
|
||
for (c, (d, s)) in (c0..).zip(dp.chunks_exact_mut(4).zip(sp.chunks_exact(4))) {
|
||
let mx = c + i64::from(mx);
|
||
let k = if mx >= 0 && my >= 0 && mx < mw && my < mh { u32::from(m.data().get((my * mw + mx) as usize).copied().unwrap_or(0)) } else { 0 };
|
||
for (d, s) in d.iter_mut().zip(s) {
|
||
*d = ((u32::from(*d) * (255 - k) + u32::from(*s) * k + 127) / 255) as u8;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Whether a node has a visible background blur or glass.
|
||
pub fn has_effects(n: &Node) -> bool {
|
||
n.effects.iter().any(|e| (e.visible && matches!(e.kind, EffectKind::Glass(_))) || e.backdrop_radius() > 0.0)
|
||
}
|
||
|
||
/// Pixels around a layer its backdrop effects read at `scale`.
|
||
pub fn pad(n: &Node, scale: f64) -> u32 {
|
||
let r = n.effects.iter().map(|e| crate::backdrop_reach(e.backdrop_radius(), scale)).fold(0.0, f64::max);
|
||
if r.is_finite() { r.ceil().clamp(0.0, 2048.0) as u32 } else { 0 }
|
||
}
|
||
|
||
/// A node's backdrop effects (background blurs and glass, in list order) for its `w` × `h` layer,
|
||
/// from `back`: its backdrop around the layer, `pad` pixels more each way. `shape` maps through
|
||
/// `xf` (local → layer pixels) at `scale` device pixels per unit. `None` when nothing shows.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub fn effects(n: &Node, shape: &Outline, back: &Pixmap, pad: u32, w: u32, h: u32, xf: Affine, scale: f64) -> Option<Pixmap> {
|
||
let pad_i = i32::try_from(pad).ok()?;
|
||
let mut out: Option<Pixmap> = None;
|
||
let mut cov: Option<Pixmap> = None;
|
||
for e in n.effects.iter().filter(|e| e.visible) {
|
||
let layer = match &e.kind {
|
||
EffectKind::BackgroundBlur(blur) if blur.max_radius() > 0.0 => {
|
||
let mut b = back.clone();
|
||
crate::blur::apply(&mut b, blur, (n.width, n.height), Affine::translate((f64::from(pad), f64::from(pad))) * xf, scale);
|
||
let mut l = Pixmap::new(w, h)?;
|
||
let copy = PixmapPaint { blend_mode: tiny_skia::BlendMode::Source, ..Default::default() };
|
||
l.draw_pixmap(-pad_i, -pad_i, b.as_ref(), ©, Transform::identity(), None);
|
||
if cov.is_none() {
|
||
cov = spread_alpha(std::slice::from_ref(shape), 0.0, xf, w, h);
|
||
}
|
||
fx::mask_by(&mut l, cov.as_ref()?, false);
|
||
l
|
||
}
|
||
EffectKind::Glass(g) => glass::glass(g, shape, back, pad, w, h, xf, scale)?,
|
||
_ => continue,
|
||
};
|
||
match out.as_mut() {
|
||
Some(o) => o.draw_pixmap(0, 0, layer.as_ref(), &PixmapPaint::default(), Transform::identity(), None),
|
||
None => out = Some(layer),
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Hide node `id` and everything painted after it (later siblings up its ancestry, the later
|
||
/// `roots` and the strokes its ancestors paint over their children): what is left is its backdrop
|
||
/// on the page made of `roots` (in z-order).
|
||
pub fn hide_above(doc: &mut Document, id: NodeId, roots: &[NodeId]) {
|
||
let mut hide: Vec<NodeId> = vec![id];
|
||
let mut cur = id;
|
||
for _ in 0..=MAX_DEPTH {
|
||
if let Some(i) = roots.iter().position(|r| *r == cur) {
|
||
hide.extend(roots.iter().skip(i + 1));
|
||
break;
|
||
}
|
||
let Some(parent) = doc.node(cur).and_then(|n| n.parent) else { break };
|
||
if let Some(p) = doc.node(parent) {
|
||
hide.extend(p.paint_order().skip_while(|c| *c != cur).skip(1));
|
||
}
|
||
if let Some(pn) = doc.node_mut(parent) {
|
||
pn.strokes.clear();
|
||
}
|
||
cur = parent;
|
||
}
|
||
for h in hide {
|
||
if let Some(n) = doc.node_mut(h) {
|
||
n.visible = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// What lies under node `id` when `roots` (in z-order) are drawn into `view`: every layer drawn
|
||
/// before it, its ancestors' own fills included (see [`hide_above`]), the way the renderer's
|
||
/// [`backdrop`] sees it.
|
||
pub fn render_beneath(doc: &Document, roots: &[NodeId], id: NodeId, view: &View) -> Option<Pixmap> {
|
||
let mut beneath = doc.clone();
|
||
hide_above(&mut beneath, id, roots);
|
||
let mut pix = Pixmap::new(view.width.clamp(1, 16_384), view.height.clamp(1, 16_384))?;
|
||
crate::render_nodes_into(&beneath, roots, view, &mut pix);
|
||
Some(pix)
|
||
}
|
||
|
||
/// Where node `id`'s shape lies on its page (what its backdrop effects cover).
|
||
pub fn shape_bounds(doc: &Document, id: NodeId) -> Option<Rect> {
|
||
let shape = crate::node_outline(doc, doc.node(id)?)?;
|
||
Some(figcraft_geom::transform_rect_bbox(doc.world_transform(id), figcraft_geom::Shape::bounding_box(&shape.0)))
|
||
}
|
||
|
||
/// Node `id`'s backdrop effects as `view`'s pixels (a view of its [`shape_bounds`]), drawn from a
|
||
/// render of what lies under it when `roots` are drawn ([`render_beneath`]): how exports, which
|
||
/// have no canvas to read, draw background blur and glass. Nothing lies outside `keep` (page
|
||
/// coordinates), when given.
|
||
pub fn effects_beneath(doc: &Document, roots: &[NodeId], id: NodeId, view: &View, keep: Option<Rect>) -> Option<Pixmap> {
|
||
let n = doc.node(id)?;
|
||
let shape = crate::node_outline(doc, n)?;
|
||
let pad = pad(n, view.scale);
|
||
let p = f64::from(pad) / view.scale.max(1e-9);
|
||
let around = View {
|
||
origin: (view.origin.0 - p, view.origin.1 - p),
|
||
scale: view.scale,
|
||
width: view.width.saturating_add(pad * 2),
|
||
height: view.height.saturating_add(pad * 2),
|
||
};
|
||
let mut back = render_beneath(doc, roots, id, &around)?;
|
||
if let Some(k) = keep {
|
||
let s = view.scale;
|
||
let (ox, oy) = around.origin;
|
||
keep_inside(&mut back, Rect::new((k.x0 - ox) * s, (k.y0 - oy) * s, (k.x1 - ox) * s, (k.y1 - oy) * s));
|
||
}
|
||
effects(n, &shape, &back, pad, view.width, view.height, view.transform() * doc.world_transform(id), view.scale)
|
||
}
|
||
|
||
/// Clear the pixels of `p` outside `r` (pixel coordinates).
|
||
fn keep_inside(p: &mut Pixmap, r: Rect) {
|
||
let w = p.width() as usize;
|
||
for (i, px) in p.data_mut().as_chunks_mut::<4>().0.iter_mut().enumerate() {
|
||
let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
|
||
if !(x >= r.x0 && x < r.x1 && y >= r.y0 && y < r.y1) {
|
||
*px = [0; 4];
|
||
}
|
||
}
|
||
}
|