Files
figcraft/crates/render/src/effects_behaviour_tests.rs
T
bflatastic 8b2607de8e M7: export and inspect behaviour suite and fixes
A behaviour suite for export and inspect, written from public documentation of design tools'
export settings and developer handoff modes (`crates/engine/src/export_behaviour_tests.rs`,
`crates/engine/src/inspect_behaviour_tests.rs`: 19 tests, 139 table rows and asserts, each with a
one-line derivation; rows where the docs are silent say so and pin today's behaviour). 47 cases
failed on `main`; this PR fixes them in shared code.

**Fixes**
- Export bounds are what a layer paints (`Document::world_render_bounds`, used by every export):
  a drop shadow adds room only in the direction it falls (it was a symmetric margin of blur +
  largest offset on every side); strokes on rectangles, frames and ellipses reach their real width
  (the mitre factor made a centre stroke of 4 add 8 a side); hidden children take no room; a mask
  group exports the mask's area.
- Slices export everything visible in their area (they exported an empty image).
- Slashes in layer names export into folders (`icons/close` → `icons/close.png`, folders created;
  `.` and `..` segments dropped).
- Copy as PNG copies the whole selection (only the first layer was copied), now from shared code
  (`cmd::file::copy_png`).
- Inspect positions are measured from the frame around a layer; groups aren't frames.

**Added (documented options that were missing)**
- Export settings: contents only (off draws every layer showing in the area) and include bounding
  box (the layer's own box); SVG: outline text (off writes `<text>` with a `<tspan>` per line),
  id attributes (unique), simplified strokes (stroke attributes for centred strokes, and for inside
  and outside strokes on boxes and ellipses). Stored per export setting, taken by `file.export`,
  `export.add` / `export.update`, `file.exportLayers` and MCP `export`; a "…" popover on each row of
  the Export section.
- Inspect: colour formats hex / RGB / CSS / HSL / HSB (`inspect.properties {colorFormat}`, a Color
  format dropdown), a Blend row, CSS in rem (`inspect.code {unit, rootFontSize}`, px/rem dropdown),
  CSS for rotation, blend modes, gradient directions and kinds (linear angle, radial, conic), text
  alignment, case and decoration; icons and images in the asset list (`kind`), exported with
  `file.exportLayers {assets: true}`.
2026-10-12 01:32:04 +07:00

380 lines
18 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.
//! Effects behaviour, written from the public documentation of the design tools FigCraft follows:
//! drop and inner shadows (offset, blur, spread, colour, showing behind transparent areas),
//! layer and background blurs, effects on groups, the order effects stack in and what exports
//! include. Each case says in one line why it expects what it does; where the documentation is
//! silent the case says so and pins today's behaviour. Scenes are small and synthetic, drawn on
//! a white page at 1×.
use figcraft_color::{Color, Paint};
use figcraft_doc::{Blur, BlurType, Document, Effect, EffectKind, NodeId, NodeKind, Shadow, StrokeAlign};
use super::paint_behaviour_tests::{Probes, Pt, Px, hex, near, page, px, rect, render, solid_at};
use super::*;
const WHITE: [u8; 3] = [255, 255, 255];
const BLACK: [u8; 3] = [0, 0, 0];
const RED: [u8; 3] = [255, 0, 0];
fn fx(kind: EffectKind) -> Effect {
Effect { kind, visible: true }
}
/// A sharp, opaque black shadow.
fn sharp(offset: (f64, f64), spread: f64) -> Shadow {
Shadow { color: Color::BLACK, offset, radius: 0.0, spread, ..Shadow::default() }
}
/// A 40 × 40 rectangle at (50, 50) with `fills` and `effects`.
fn square(fills: Vec<Paint>, effects: Vec<Effect>) -> (Document, NodeId) {
let (mut d, p) = page();
let r = rect(&mut d, p, (50.0, 50.0, 40.0, 40.0), fills);
d.get_mut(r).unwrap().effects = effects;
(d, r)
}
/// The standard normal distribution function (Abramowitz–Stegun 7.1.26 for erf).
fn phi(z: f64) -> f64 {
let x = z.abs() / std::f64::consts::SQRT_2;
let t = 1.0 / (1.0 + 0.327_591_1 * x);
let poly = t * (0.254_829_592 + t * (-0.284_496_736 + t * (1.421_413_741 + t * (-1.453_152_027 + t * 1.061_405_429))));
let erf = 1.0 - poly * (-x * x).exp();
if z >= 0.0 { 0.5 * (1.0 + erf) } else { 0.5 * (1.0 - erf) }
}
#[test]
fn new_effects_start_from_the_documented_defaults() {
// A new shadow: offset 0, 4, blur 4, spread 0, black at 25%, normal blend, not shown behind
// transparent areas; a new blur: 4, uniform.
let shadow = Shadow { color: Color::new(0.0, 0.0, 0.0, 0.25), offset: (0.0, 4.0), radius: 4.0, spread: 0.0, ..Shadow::default() };
for (ty, want) in [
("dropShadow", EffectKind::DropShadow(shadow.clone())),
("innerShadow", EffectKind::InnerShadow(shadow.clone())),
("layerBlur", EffectKind::LayerBlur(Blur::uniform(4.0))),
("backgroundBlur", EffectKind::BackgroundBlur(Blur::uniform(4.0))),
] {
let e = Effect::of_type(ty).unwrap();
assert_eq!(e.kind, want, "{ty}");
assert!(e.visible, "{ty} starts visible");
}
assert!(!shadow.show_behind && shadow.blend == figcraft_color::BlendMode::Normal);
assert_eq!(Blur::default().blur_type, BlurType::Uniform);
}
#[test]
fn drop_shadows_move_by_the_offset_and_grow_by_the_spread() {
// (why, offset, spread, colour, probes). The red square covers 50..90 on both axes.
let rows: Vec<(&str, Pt, f64, Color, Probes)> = vec![
(
"x moves the shadow right; the layer paints over it",
(10.0, 0.0),
0.0,
Color::BLACK,
vec![((95, 70), BLACK), ((45, 70), WHITE), ((70, 70), RED)],
),
("y moves it down", (0.0, 10.0), 0.0, Color::BLACK, vec![((70, 95), BLACK), ((70, 45), WHITE)]),
("negative offsets move it up and left", (-10.0, -10.0), 0.0, Color::BLACK, vec![((45, 45), BLACK), ((95, 95), WHITE)]),
(
"spread grows the shadow on every side",
(10.0, 0.0),
5.0,
Color::BLACK,
vec![((102, 70), BLACK), ((107, 70), WHITE), ((70, 47), BLACK), ((70, 43), WHITE)],
),
(
"negative spread shrinks it (here, out of sight under the layer)",
(0.0, 0.0),
-5.0,
Color::BLACK,
vec![((48, 70), WHITE), ((92, 70), WHITE)],
),
("the colour's alpha is the shadow's opacity", (10.0, 0.0), 0.0, Color::new(0.0, 0.0, 0.0, 0.25), vec![((95, 70), [191, 191, 191])]),
("the shadow takes its colour", (10.0, 0.0), 0.0, hex("0000FF"), vec![((95, 70), [0, 0, 255])]),
];
for (why, offset, spread, color, probes) in rows {
let (d, _) = square(vec![Paint::solid(hex("FF0000"))], vec![fx(EffectKind::DropShadow(Shadow { color, ..sharp(offset, spread) }))]);
let pix = render(&d);
for ((x, y), want) in probes {
let got = px(&pix, x, y);
assert!(near(got, want, 2), "{why} at ({x}, {y}): {got:?} want {want:?}");
}
}
}
#[test]
fn blurs_spread_by_a_gaussian_of_half_the_radius() {
// The blur value is the CSS blur radius (what code inspection shows for it), which CSS turns
// into a Gaussian with a standard deviation of half the radius: across a straight edge the
// coverage falls off as the normal distribution function. Radius 20: sigma 10.
let sigma = 10.0;
let edge = |d: f64| ((1.0 - phi(-d / sigma)) * 255.0).round() as u8;
// Drop shadow of a white 120 × 120 square at (40, 40), black, no offset.
let (mut d, p) = page();
let r = rect(&mut d, p, (40.0, 40.0, 120.0, 120.0), vec![Paint::solid(Color::WHITE)]);
d.get_mut(r).unwrap().effects = vec![fx(EffectKind::DropShadow(Shadow { radius: 20.0, ..sharp((0.0, 0.0), 0.0) }))];
let shadow = render(&d);
// Layer blur of a black square, same place.
let (mut d, p) = page();
let r = rect(&mut d, p, (40.0, 40.0, 120.0, 120.0), vec![Paint::solid(Color::BLACK)]);
d.get_mut(r).unwrap().effects = vec![fx(EffectKind::LayerBlur(Blur::uniform(20.0)))];
let layer = render(&d);
// Background blur: black left of x = 160 seen through a clear pane across the edge.
let (mut d, p) = page();
rect(&mut d, p, (0.0, 0.0, 160.0, 200.0), vec![Paint::solid(Color::BLACK)]);
let pane = rect(&mut d, p, (110.0, 40.0, 90.0, 120.0), vec![]);
d.get_mut(pane).unwrap().effects = vec![fx(EffectKind::BackgroundBlur(Blur::uniform(20.0)))];
let back = render(&d);
// (blur, pixel column, its centre's distance past the edge at x = 160)
let rows = [
("drop shadow", &shadow, 160, 0.5),
("drop shadow", &shadow, 170, 10.5),
("drop shadow", &shadow, 180, 20.5),
("layer blur", &layer, 150, -9.5),
("layer blur", &layer, 160, 0.5),
("layer blur", &layer, 170, 10.5),
("background blur", &back, 150, -9.5),
("background blur", &back, 160, 0.5),
("background blur", &back, 170, 10.5),
];
for (why, pix, x, dist) in rows {
let (got, want) = (px(pix, x, 100)[1], edge(dist));
assert!(got.abs_diff(want) <= 8, "{why} at x {x}: {got} want {want}");
}
}
#[test]
fn shadows_show_behind_transparent_areas_only_when_asked() {
// Off (the default), a drop shadow doesn't show through the layer: the parts it covers,
// translucent ones included, hide the shadow. On, the shadow shows through them. The shadow
// is as strong as the layer's pixels are opaque (silent, pinned): a 50% fill casts a 50% one.
let stroke_only = |d: &mut Document, r: NodeId| {
let n = d.get_mut(r).unwrap();
n.strokes = vec![Paint::solid(hex("FF0000"))];
(n.stroke.weight, n.stroke.align) = (4.0, StrokeAlign::Inside);
};
type Setup = fn(&mut Document, NodeId);
let half = [128, 128, 128];
// (why, fill opacity (none: no fill), layer opacity, show behind, offset, extra, probes)
type Row<'a> = (&'a str, Option<f32>, f32, bool, Pt, Option<Setup>, Probes);
let rows: Vec<Row> = vec![
("off: a 50% fill shows the page, not the shadow, through it", Some(0.5), 1.0, false, (0.0, 0.0), None, vec![((70, 70), [255, 128, 128])]),
("on: the shadow shows through the 50% fill", Some(0.5), 1.0, true, (0.0, 0.0), None, vec![((70, 70), [191, 64, 64])]),
(
"off: outside the layer the shadow shows as ever",
Some(0.5),
1.0,
false,
(10.0, 0.0),
None,
vec![((95, 70), half), ((85, 70), [255, 128, 128])],
),
("on: offset, it shows under the layer too", Some(0.5), 1.0, true, (10.0, 0.0), None, vec![((95, 70), half), ((85, 70), [191, 64, 64])]),
(
"layer opacity fades the layer and its shadow together",
Some(1.0),
0.5,
false,
(10.0, 0.0),
None,
vec![((70, 70), [255, 128, 128]), ((95, 70), [128, 128, 128])],
),
(
"a stroke without a fill: the empty middle isn't part of the layer",
None,
1.0,
false,
(10.0, 0.0),
Some(stroke_only),
vec![((62, 70), BLACK), ((52, 70), RED)],
),
];
for (why, fill, layer_op, show_behind, offset, extra, probes) in rows {
let fills = fill.map(|o| vec![solid_at("FF0000", o)]).unwrap_or_default();
let (mut d, r) = square(fills, vec![fx(EffectKind::DropShadow(Shadow { show_behind, ..sharp(offset, 0.0) }))]);
d.get_mut(r).unwrap().opacity = layer_op;
if let Some(f) = extra {
f(&mut d, r);
}
let pix = render(&d);
for ((x, y), want) in probes {
let got = px(&pix, x, y);
assert!(near(got, want, 2), "{why} at ({x}, {y}): {got:?} want {want:?}");
}
}
}
#[test]
fn effects_on_a_group_apply_to_the_group_as_a_whole() {
// A red square over a blue one, shadow (10, 10). On the group the pair casts one shadow;
// on each layer the top one's shadow falls on the bottom one.
for (why, on_group, want) in
[("on the group: one shadow, under both", true, [0, 0, 255]), ("on each layer: the top layer shadows the bottom one", false, BLACK)]
{
let (mut d, p) = page();
let g = d.create(p, NodeKind::Group, "G", 0.0, 0.0, 200.0, 200.0).unwrap();
let blue = rect(&mut d, g, (60.0, 60.0, 40.0, 40.0), vec![Paint::solid(hex("0000FF"))]);
let red = rect(&mut d, g, (40.0, 40.0, 40.0, 40.0), vec![Paint::solid(hex("FF0000"))]);
let shadow = vec![fx(EffectKind::DropShadow(sharp((10.0, 10.0), 0.0)))];
if on_group {
d.get_mut(g).unwrap().effects = shadow;
} else {
for id in [blue, red] {
d.get_mut(id).unwrap().effects = shadow.clone();
}
}
let pix = render(&d);
// (85, 70): on the blue square, inside the red one's shadow, outside the red square.
assert!(near(px(&pix, 85, 70), want, 2), "{why}: {:?}", px(&pix, 85, 70));
assert!(near(px(&pix, 105, 105), BLACK, 2), "{why}: the blue square's own shadow");
}
}
#[test]
fn inner_shadows_paint_inside_over_the_fills_under_strokes_and_children() {
let green = [0, 255, 0];
type Setup = fn(&mut Document, NodeId);
let inside_stroke: Setup = |d, r| {
let n = d.get_mut(r).unwrap();
n.strokes = vec![Paint::solid(hex("00FF00"))];
(n.stroke.weight, n.stroke.align) = (4.0, StrokeAlign::Inside);
};
let child: Setup = |d, r| {
rect(d, r, (0.0, 0.0, 40.0, 3.0), vec![Paint::solid(hex("0000FF"))]);
};
// (why, kind, offset, spread, extra, probes) on a white 40 × 40 square at (50, 50).
type Row<'a> = (&'a str, NodeKind, Pt, f64, Option<Setup>, Probes);
let rows: Vec<Row> = vec![
(
"y moves the outside in from the top edge",
NodeKind::Rectangle,
(0.0, 10.0),
0.0,
None,
vec![((70, 55), BLACK), ((70, 65), WHITE), ((70, 85), WHITE)],
),
("x moves it in from the left edge", NodeKind::Rectangle, (10.0, 0.0), 0.0, None, vec![((55, 70), BLACK), ((65, 70), WHITE)]),
(
"spread grows it in from every edge",
NodeKind::Rectangle,
(0.0, 0.0),
5.0,
None,
vec![((52, 70), BLACK), ((57, 70), WHITE), ((70, 52), BLACK), ((87, 70), BLACK)],
),
("it never paints outside the layer", NodeKind::Rectangle, (0.0, 10.0), 0.0, None, vec![((70, 45), WHITE), ((70, 95), WHITE)]),
("strokes paint over it", NodeKind::Rectangle, (0.0, 0.0), 5.0, Some(inside_stroke), vec![((51, 70), green), ((54, 70), BLACK)]),
("a frame's children paint over it", NodeKind::Frame, (0.0, 0.0), 5.0, Some(child), vec![((70, 51), [0, 0, 255]), ((70, 54), BLACK)]),
];
for (why, kind, offset, spread, extra, probes) in rows {
let (mut d, p) = page();
let r = d.create(p, kind, "S", 50.0, 50.0, 40.0, 40.0).unwrap();
let n = d.get_mut(r).unwrap();
n.fills = vec![Paint::solid(Color::WHITE)];
n.effects = vec![fx(EffectKind::InnerShadow(sharp(offset, spread)))];
if let Some(f) = extra {
f(&mut d, r);
}
let pix = render(&d);
for ((x, y), want) in probes {
let got = px(&pix, x, y);
assert!(near(got, want, 2), "{why} at ({x}, {y}): {got:?} want {want:?}");
}
}
}
#[test]
fn effects_stack_in_list_order() {
let red = Shadow { color: hex("FF0000"), ..sharp((10.0, 0.0), 0.0) };
let blue = Shadow { color: hex("0000FF"), ..sharp((10.0, 0.0), 0.0) };
let hidden = Effect { visible: false, ..fx(EffectKind::DropShadow(sharp((10.0, 0.0), 0.0))) };
// (why, effects, pixel, check)
type Check = fn([u8; 3]) -> bool;
let rows: Vec<(&str, Vec<Effect>, Px, Check)> = vec![
(
"two shadows: the later one in the list paints over the earlier (silent, pinned like paints)",
vec![fx(EffectKind::DropShadow(red)), fx(EffectKind::DropShadow(blue))],
(95, 70),
|c| c == [0, 0, 255],
),
("a hidden effect does nothing", vec![hidden], (95, 70), |c| c == WHITE),
(
"a layer blur blurs the layer before it casts its shadow (silent, pinned)",
vec![fx(EffectKind::DropShadow(sharp((20.0, 0.0), 0.0))), fx(EffectKind::LayerBlur(Blur::uniform(10.0)))],
(108, 70),
|c| (30..=220).contains(&c[0]),
),
];
for (why, effects, (x, y), ok) in rows {
let (d, _) = square(vec![Paint::solid(hex("808080"))], effects);
let got = px(&render(&d), x, y);
assert!(ok(got), "{why}: {got:?}");
}
}
#[test]
fn background_blur_shows_through_the_fill_inside_the_shape() {
// Black left of x = 100 under a pane (50..150) with a background blur of 20: at x = 110 the
// blurred backdrop is 218 (sigma 10, 10.5 px past the edge).
let blurred = 218;
// (why, pane fills, pixel, colour there)
let rows: Vec<(&str, Vec<Paint>, Px, [u8; 3])> = vec![
("no fill: the blurred backdrop", vec![], (110, 100), [blurred; 3]),
("a 0% fill: the blurred backdrop", vec![solid_at("FF0000", 0.0)], (110, 100), [blurred; 3]),
("a 50% fill paints over the blurred backdrop", vec![solid_at("FF0000", 0.5)], (110, 100), [236, 109, 109]),
("an opaque fill hides it", vec![Paint::solid(hex("FF0000"))], (110, 100), RED),
("outside the shape nothing is blurred", vec![], (110, 160), WHITE),
("outside the shape nothing is blurred (dark side)", vec![], (90, 160), BLACK),
];
for (why, fills, (x, y), want) in rows {
let (mut d, p) = page();
rect(&mut d, p, (0.0, 0.0, 100.0, 200.0), vec![Paint::solid(Color::BLACK)]);
let pane = rect(&mut d, p, (50.0, 50.0, 100.0, 100.0), fills);
d.get_mut(pane).unwrap().effects = vec![fx(EffectKind::BackgroundBlur(Blur::uniform(20.0)))];
let got = px(&render(&d), x, y);
assert!(near(got, want, 8), "{why}: {got:?} want {want:?}");
}
}
#[test]
fn exports_include_what_effects_paint_outside_the_layer() {
let size = |effects: Vec<Effect>| {
let (d, r) = square(vec![Paint::solid(hex("FF0000"))], effects);
let p = render_node(&d, r, 1.0).unwrap();
(p.width(), p.height())
};
// (why, effects, grows the export)
let rows = [
("nothing: the layer's box", vec![], false),
("a drop shadow reaches out", vec![fx(EffectKind::DropShadow(sharp((0.0, 10.0), 0.0)))], true),
("a layer blur reaches out", vec![fx(EffectKind::LayerBlur(Blur::uniform(4.0)))], true),
("an inner shadow stays inside", vec![fx(EffectKind::InnerShadow(sharp((0.0, 10.0), 0.0)))], false),
("a background blur stays inside", vec![fx(EffectKind::BackgroundBlur(Blur::uniform(4.0)))], false),
("noise stays on the layer's pixels", vec![fx(EffectKind::Noise(figcraft_doc::Noise::default()))], false),
("texture roughens past the edge", vec![fx(EffectKind::Texture(figcraft_doc::Texture::default()))], true),
(
"texture clipped to the shape stays inside",
vec![fx(EffectKind::Texture(figcraft_doc::Texture { clip_to_shape: true, ..Default::default() }))],
false,
),
("glass stays inside", vec![fx(EffectKind::Glass(figcraft_doc::Glass::default()))], false),
];
for (why, effects, grows) in rows {
let (w, h) = size(effects);
// Effects grow the export only on the sides they reach (a shadow falling down grows its height).
assert!(w >= 40 && h >= 40, "{why}: {w} × {h}");
assert_eq!(w > 40 || h > 40, grows, "{why}: {w} × {h}");
}
// The export holds the shadow where the canvas draws it, pixel for pixel.
let (d, r) = square(vec![Paint::solid(hex("FF0000"))], vec![fx(EffectKind::DropShadow(Shadow { radius: 6.0, ..sharp((0.0, 10.0), 2.0) }))]);
let b = export_bounds(&d, r).unwrap();
let export = render_node(&d, r, 1.0).unwrap();
let mut canvas = Pixmap::new(export.width(), export.height()).unwrap();
render_nodes_into(&d, &[r], &View { origin: (b.x0, b.y0), scale: 1.0, width: export.width(), height: export.height() }, &mut canvas);
assert_eq!(export.data(), canvas.data(), "export and canvas agree");
assert!(b.y1 >= 90.0 + 10.0 + 2.0 + 6.0, "the shadow's blur and spread are inside the export: {b:?}");
// A clipping frame exports its shadow too.
let (mut d, p) = page();
let f = d.create(p, NodeKind::Frame, "F", 50.0, 50.0, 40.0, 40.0).unwrap();
d.get_mut(f).unwrap().effects = vec![fx(EffectKind::DropShadow(sharp((0.0, 10.0), 0.0)))];
assert!(render_node(&d, f, 1.0).unwrap().height() >= 50, "frame shadow exported");
}