Files
figcraft/crates/render/src/effect_order_tests.rs
T
BFlatandClaude Opus 5.5 70b2666c90 M3: progressive blur, inner shadows under strokes, noise and texture in SVG (#86)
- Inner shadows paint over the fills and under the children and strokes, as documented (canvas,
  PNG, PDF and SVG alike; text fills every run before the inner shadow and the strokes).
- Layer and background blur gain a progressive type: the radius ramps linearly from a start radius
  at a start point to the end radius at an end point (unit-box points, like gradient handles).
  Rendered by blending a few geometric blur levels, each blurred only around the pixels that use
  it; bands and partial renders match one piece. Design panel type menu and Start/End fields,
  on-canvas handles while the settings are open, effect.add/effect.set params, startRadius
  variable bindings, scale tool, REST import (blurType, startRadius, startOffset, endOffset),
  Inspect row; CSS leaves progressive blurs out.
- SVG export embeds renders for what filters can't match: noise grains over the vector layer
  (crisp, blended), texture and progressive layer blur whole, background blur and glass from what
  lies under the layer among the exported layers. PDF and SVG share the renderer's helpers.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:20:07 +07:00

107 lines
4.3 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.
//! Paint order of a layer's effects: drop shadows under everything, then fills, inner shadows,
//! children and strokes, with layer blur over the composite. Strokes draw over inner shadows.
use figcraft_color::{Color, Paint};
use figcraft_doc::{Document, Effect, EffectKind, NodeId, NodeKind, Shadow, StrokeAlign};
use super::*;
const GREEN: [u8; 4] = [0, 255, 0, 255];
const BLUE: [u8; 4] = [0, 0, 255, 255];
fn hex(h: &str) -> Color {
Color::from_hex(h).unwrap()
}
/// A solid red inner shadow that reaches well past a 12-unit stroke.
fn red_inner_shadow() -> Effect {
Effect {
kind: EffectKind::InnerShadow(Shadow { color: hex("FF0000"), offset: (0.0, 0.0), radius: 8.0, spread: 14.0, ..Shadow::default() }),
visible: true,
}
}
/// A white 100 × 100 `kind` at (50, 50) with a 12-unit green inside stroke.
fn stroked(kind: NodeKind, effects: Vec<Effect>) -> (Document, NodeId) {
let mut d = Document::new("order");
let p = d.first_page().unwrap();
let r = d.create(p, kind, "S", 50.0, 50.0, 100.0, 100.0).unwrap();
let n = d.get_mut(r).unwrap();
n.fills = vec![Paint::solid(Color::WHITE)];
n.strokes = vec![Paint::solid(hex("00FF00"))];
n.stroke.weight = 12.0;
n.stroke.align = StrokeAlign::Inside;
n.effects = effects;
(d, r)
}
fn render(d: &Document, id: NodeId) -> Pixmap {
let mut pix = Pixmap::new(200, 200).unwrap();
render_nodes_into(d, &[id], &View { origin: (0.0, 0.0), scale: 1.0, width: 200, height: 200 }, &mut pix);
pix
}
fn px(p: &Pixmap, x: u32, y: u32) -> [u8; 4] {
let i = ((y * p.width() + x) * 4) as usize;
[p.data()[i], p.data()[i + 1], p.data()[i + 2], p.data()[i + 3]]
}
#[test]
fn strokes_draw_over_inner_shadows() {
for kind in [NodeKind::Rectangle, NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }] {
let (d, r) = stroked(kind.clone(), vec![red_inner_shadow()]);
let with = render(&d, r);
let (d0, r0) = stroked(kind, vec![]);
let without = render(&d0, r0);
// Inside the stroke band (50..62 from the left edge, at mid-height): the stroke's own
// green, untouched by the shadow's red.
for x in 52..60 {
assert_eq!(px(&with, x, 100), GREEN, "x {x}");
assert_eq!(px(&with, x, 100), px(&without, x, 100), "x {x}");
}
// Just inside the stroke the shadow shows over the white fill.
let [r_, g, b, _] = px(&with, 64, 100);
assert!(r_ == 255 && g < 200 && b < 200, "{:?}", [r_, g, b]);
assert_eq!(px(&without, 64, 100), [255, 255, 255, 255]);
}
}
#[test]
fn a_frames_children_draw_over_its_inner_shadow() {
let mut d = Document::new("frame order");
let p = d.first_page().unwrap();
let f = d.create(p, NodeKind::Frame, "F", 50.0, 50.0, 100.0, 100.0).unwrap();
let n = d.get_mut(f).unwrap();
n.fills = vec![Paint::solid(Color::WHITE)];
n.clip = true;
n.effects = vec![red_inner_shadow()];
let c = d.create(f, NodeKind::Rectangle, "C", 0.0, 30.0, 40.0, 40.0).unwrap();
d.get_mut(c).unwrap().fills = vec![Paint::solid(hex("0000FF"))];
let pix = render(&d, f);
// The child at the frame's left edge, where the shadow is strongest: pure blue.
assert_eq!(px(&pix, 51, 100), BLUE);
// The frame's fill beside it: shadowed.
assert!(px(&pix, 51, 60)[0] > 200 && px(&pix, 51, 60)[1] < 120, "{:?}", px(&pix, 51, 60));
}
#[test]
fn layer_blur_still_blurs_strokes_and_inner_shadows() {
let blur = Effect { kind: EffectKind::LayerBlur(figcraft_doc::Blur::uniform(10.0)), visible: true };
let (d, r) = stroked(NodeKind::Rectangle, vec![red_inner_shadow(), blur]);
let pix = render(&d, r);
// The blur reaches past the box and mixes the stroke into its neighbours.
assert!(px(&pix, 46, 100)[3] > 0, "{:?}", px(&pix, 46, 100));
assert_ne!(px(&pix, 56, 100), GREEN);
}
#[test]
fn canvas_and_export_agree_on_the_order() {
let (d, r) = stroked(NodeKind::Rectangle, vec![red_inner_shadow()]);
let page = d.first_page().unwrap();
let canvas = render_view(&d, page, &View { origin: (50.0, 50.0), scale: 2.0, width: 200, height: 200 }, &Options::default()).unwrap();
let export = render_node(&d, r, 2.0).unwrap();
for (x, y) in [(4, 100), (20, 100), (100, 4), (100, 100), (30, 30)] {
assert_eq!(px(&canvas, x, y), px(&export, x, y), "({x}, {y})");
}
}