Files
figcraft/crates/engine/src/fx_behaviour_tests.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

130 lines
7.0 KiB
Rust

//! Paint and effects behaviour through commands (the rules of `figcraft_render`'s paint and
//! effects suites that involve edits or exports): the eyedropper, blend modes and effects set
//! through commands, and how the SVG and PDF exports carry isolating groups and shadows that hide
//! behind their layer.
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use crate::tools::{PointerEvent, PointerKind, ToolId};
use figcraft_color::BlendMode;
use figcraft_doc::{EffectKind, NodeId};
fn id(v: Value) -> NodeId {
id_of(&v["id"]).unwrap()
}
fn rect(s: &mut Session, parent: Option<NodeId>, (x, y, w, h): (f64, f64, f64, f64), fill: &str) -> NodeId {
let mut p = json!({"x": x, "y": y, "width": w, "height": h, "props": {"fill": fill}});
if let Some(parent) = parent {
p["parent"] = json!(parent);
}
id(s.execute("shape.rectangle", &p).unwrap())
}
fn set(s: &mut Session, node: NodeId, props: Value) {
s.execute("node.set", &json!({"id": node, "props": props})).unwrap();
}
fn svg(s: &Session, ids: &[NodeId]) -> String {
figcraft_svg::export_svg(s.doc().unwrap(), ids, &figcraft_svg::SvgOptions::default()).unwrap()
}
#[test]
fn the_eyedropper_picks_the_colour_seen_and_fills_the_selection() {
// Documented: the eyedropper picks the colour under the pointer as it looks on the canvas and
// applies it to the selected layer's fill (opaque: the colour seen, not a layer's alpha).
let mut s = Session::new();
s.execute("page.setBackground", &json!({"color": "#FFFFFF"})).unwrap();
let a = rect(&mut s, None, (0.0, 0.0, 100.0, 100.0), "#FF0000");
set(&mut s, a, json!({"opacity": 0.5}));
let b = rect(&mut s, None, (200.0, 0.0, 50.0, 50.0), "#00FF00");
s.execute("select.set", &json!({"ids": [b]})).unwrap();
s.execute("edit.pickColor", &json!({})).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Down, 50.0, 50.0, 1.0)).unwrap();
let fill = s.doc().unwrap().node(b).unwrap().fills[0].solid_color().unwrap();
let [r, g, bl, al] = fill.to_rgba8();
assert!(r == 255 && g.abs_diff(128) <= 1 && bl.abs_diff(128) <= 1 && al == 255, "picked {}", fill.to_css_hex());
assert_eq!(s.recent_colors.first(), Some(&fill), "the picked colour joins the recent colours");
assert_eq!(s.tool.tool, ToolId::Move, "picking ends the eyedropper");
// One undo step takes the fill back.
s.undo().unwrap();
assert_eq!(s.doc().unwrap().node(b).unwrap().fills[0].solid_color().map(|c| c.to_hex()), Some("00FF00".into()));
}
#[test]
fn blend_modes_and_effects_through_commands() {
let mut s = Session::new();
let r = rect(&mut s, None, (0.0, 0.0, 40.0, 40.0), "#CC6633");
s.execute("fill.add", &json!({"ids": [r], "paint": "#66CCEE"})).unwrap();
s.execute("fill.set", &json!({"ids": [r], "index": 1, "blend": "plusDarker", "opacity": 50})).unwrap();
let n = s.doc().unwrap().node(r).unwrap();
assert_eq!((n.fills[1].blend, n.fills[1].opacity), (BlendMode::PlusDarker, 0.5));
// Inspecting the layer reports the paint's blend mode.
let fills = s.execute("node.get", &json!({"id": r})).unwrap()["fills"].clone();
assert_eq!((fills[0].get("blend"), fills[1]["blend"].as_str()), (None, Some("plusDarker")));
// A new group passes through; Normal is a choice.
let g = id(s.execute("object.group", &json!({"ids": [r]})).unwrap());
assert_eq!(s.doc().unwrap().node(g).unwrap().blend, BlendMode::PassThrough);
set(&mut s, g, json!({"blend": "normal"}));
assert!(s.doc().unwrap().node(g).unwrap().isolates_children());
// A new drop shadow hides behind its layer; the option turns that off.
s.execute("effect.add", &json!({"ids": [r], "type": "dropShadow"})).unwrap();
let shown = |s: &Session| match &s.doc().unwrap().node(r).unwrap().effects[0].kind {
EffectKind::DropShadow(sh) => sh.show_behind,
_ => panic!("not a shadow"),
};
assert!(!shown(&s));
s.execute("effect.set", &json!({"ids": [r], "index": 0, "effect": {"type": "dropShadow", "showBehind": true}})).unwrap();
assert!(shown(&s));
}
#[test]
fn exports_isolate_normal_groups_and_hide_shadows_behind_their_layer() {
let mut s = Session::new();
let g = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
let k = rect(&mut s, Some(g), (10.0, 10.0, 50.0, 50.0), "#00FFFF");
set(&mut s, k, json!({"blend": "multiply"}));
// Pass-through: no isolation; Normal: an isolated group, in SVG and PDF.
let out = svg(&s, &[g]);
assert!(!out.contains("isolation:isolate"));
// The frame clips (by default) without a clipped group around its children, which would
// isolate them: the blending child carries the clip itself.
assert!(!out.contains("<g clip-path") && out.contains("mix-blend-mode:multiply\" clip-path=\"url(#"), "{out}");
set(&mut s, g, json!({"blend": "normal"}));
assert!(svg(&s, &[g]).contains("isolation:isolate"));
assert!(figcraft_pdf::export_nodes_pdf(s.doc().unwrap(), &[g]).is_ok());
// A translucent layer's drop shadow is cut out by what the layer covers: the shadow's
// filter composites it "out" of an image of the layer's outline.
let r = rect(&mut s, None, (200.0, 0.0, 40.0, 40.0), "#FF0000");
set(&mut s, r, json!({"effects": [{"type": "dropShadow", "offset": [10, 0], "radius": 0, "spread": 0}]}));
s.execute("fill.set", &json!({"ids": [r], "opacity": 50})).unwrap();
let hidden = svg(&s, &[r]);
assert!(hidden.contains("result=\"cover\"") && hidden.contains("in2=\"cover\" operator=\"out\""), "{hidden}");
// Shown behind transparent areas: no cut-out.
set(&mut s, r, json!({"effects": [{"type": "dropShadow", "offset": [10, 0], "radius": 0, "spread": 0, "showBehind": true}]}));
assert!(!svg(&s, &[r]).contains("operator=\"out\""));
// With spread (drawn as geometry), the cut-out goes into its blur filter.
set(&mut s, r, json!({"effects": [{"type": "dropShadow", "offset": [10, 0], "radius": 4, "spread": 3}]}));
assert!(svg(&s, &[r]).contains("in2=\"cover\" operator=\"out\""));
// A group (no one outline) cuts its shadow out by its pixels.
let grp = id(s.execute("object.group", &json!({"ids": [r]})).unwrap());
set(&mut s, r, json!({"effects": []}));
set(&mut s, grp, json!({"effects": [{"type": "dropShadow", "offset": [10, 0], "radius": 0, "spread": 0}]}));
assert!(svg(&s, &[grp]).contains("in2=\"SourceAlpha\" operator=\"out\""));
assert!(figcraft_pdf::export_nodes_pdf(s.doc().unwrap(), &[grp]).is_ok());
// The shadow's filter region reaches as far as the shadow (a share of a small layer's box
// would cut it off).
let small = rect(&mut s, None, (400.0, 0.0, 10.0, 10.0), "#000000");
set(&mut s, small, json!({"effects": [{"type": "dropShadow", "offset": [30, 0], "radius": 4, "spread": 0}]}));
let out = svg(&s, &[small]);
let width = out
.split("filterUnits=\"userSpaceOnUse\"")
.nth(1)
.and_then(|t| t.split("width=\"").nth(1))
.and_then(|t| t.split('"').next())
.and_then(|w| w.parse::<f64>().ok());
assert!(width.is_some_and(|w| w >= 10.0 + 30.0 + 6.0), "{out}");
}