Files
figcraft/crates/engine/src/masks_behaviour_tests.rs
T
bflatastic e917d3c2e6 M1: frames, groups, sections and masks behaviour suite and fixes
**A frames, groups, sections and masks behaviour suite written from the public documentation, and fixes for every divergence it found.** Each documented rule (frames vs. groups when resized, constraints, Cmd/Ctrl-resize, clip content, Frame selection, grouping and ungrouping, group bounds and opacity, X/Y in groups, click targets, drawing a frame or section around layers, dropping into and out of frames, where sections may go, resizing sections, top-level frames as screens and exports, Use as mask, what a mask covers, alpha / vector / luminance masks, groups as masks, clicks on masked-out areas, a mask group's box and exports) is a table-driven case with the result the rule implies and a one-line derivation; where the documentation is silent the case says so and pins today's behaviour.

**Fixes (the suite failed on 7 tests before them)**
- **Masked-out areas don't take clicks**: hit testing and Select layer skip what a visible mask hides (the nearest mask below a layer; for a group or frame used as a mask, what its layers draw), so a click there reaches what's behind. Checked only once something is hit, and skipped in files without a mask (`Document::has_masks`, worked out once after each change), so hit testing costs what it did.
- **A mask group's box and exports are the mask's**: a group's bounds and export area count a mask for the layers it masks (layers under the mask still count), so the selection box, W/H, snapping and PNG/SVG/PDF exports crop to the mask instead of the whole masked layer (`Document::box_children`).
- **Sections stay on the page or in sections**: moving a section into a frame or group (`node.reparent`, refused at the root in `Document::reparent`), dragging one over a frame (it lands in the section around the frame, or on the page), grouping or framing a section, creating a section in a frame and pasting one into a selected frame (it goes on the page) no longer put sections where they can't be.
- **Drawing a frame or section around layers puts them in it**: the layers fully inside the drawn box (its siblings; visible and unlocked; a frame never takes a section; not in auto layout) move into it, in their order, without moving on the page; one undo step.
- **Resizing a section leaves its contents** where they are on the page (no constraints in sections), from a field, a handle (also the left/top edges) or `transform.resize` with any anchor.
- **Cmd/Ctrl while resizing a frame leaves its layers as they are**: #133 (merged in) keeps their places; this branch also keeps their sizes (the frame is resized without running constraints, so a left & right or scale child no longer stretches and is only moved back).
- **X/Y are measured in the frame, not the group**: groups and boolean groups have no coordinate space of their own, so a layer in a group shows (Design panel, `node.get`, `inspect_document`) and takes (`node.set`, `node.create` with a group parent) X/Y in the frame the group is in, or on the page (`Document::frame_relative`). Rotated groups too.

Already there and now covered by the suite: constraints when a frame resizes, groups scaling their layers, clip content (drawing, hits, export size), Frame selection's size and place, grouping/ungrouping keeping world transforms (rotated groups too), nested groups, empty groups going away, group opacity on the composite, click / Cmd-click / double-click targets (frames in sections count as top-level frames), dropping by the pointer and pulling out, group layers staying in their group when dragged, top-level frames as prototype screens and export boards, Use as mask (bottom layer, "Mask group", at the topmost layer's place, single-layer toggle), masks covering every layer above them, hidden masks, alpha / vector / luminance masks, a group as a mask, masks in SVG and PDF exports.

No new commands or UI strings (no i18n changes); each edit stays one undo step.
2026-10-12 00:57:43 +07:00

227 lines
11 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.
//! Masks behaviour suite: each case is a rule from the public documentation of masks (Use as
//! mask, what a mask covers, alpha / vector / luminance masks, groups as masks, clicks on masked
//! out areas, the mask group's box and exports) with the result the rule implies. The comment on
//! each case is the one-line derivation. Where the documentation is silent the case says so and
//! pins today's behaviour.
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use figcraft_doc::NodeId;
use figcraft_geom::Point;
fn id(v: Value) -> NodeId {
id_of(&v["id"]).unwrap()
}
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64, fill: &str, parent: Option<NodeId>) -> NodeId {
let mut p = json!({"x": x, "y": y, "width": w, "height": h, "props": {"fills": [fill]}});
if let Some(parent) = parent {
p["parent"] = json!(parent);
}
id(ex(s, "shape.rectangle", p))
}
/// A white frame to draw a case in.
fn board(s: &mut Session, w: f64, h: f64) -> NodeId {
id(ex(s, "frame.create", json!({"x": 0, "y": 0, "width": w, "height": h})))
}
/// The RGB of `node` rendered at 1x, at (`x`, `y`) of its export box.
fn rgb(s: &Session, node: NodeId, x: u32, y: u32) -> [u8; 3] {
let pix = figcraft_render::render_node(s.doc().unwrap(), node, 1.0).unwrap();
let i = ((y * pix.width() + x) * 4) as usize;
let d = pix.data();
[d[i], d[i + 1], d[i + 2]]
}
const WHITE: [u8; 3] = [255, 255, 255];
const RED: [u8; 3] = [255, 0, 0];
const GREEN: [u8; 3] = [0, 255, 0];
const BLUE: [u8; 3] = [0, 0, 255];
fn wb(s: &Session, n: NodeId) -> (f64, f64, f64, f64) {
let b = s.doc().unwrap().world_bounds(n).unwrap();
(b.x0, b.y0, b.width(), b.height())
}
#[test]
fn use_as_mask_groups_the_layers_over_the_bottom_one() {
// Documented: with several layers selected, Use as mask makes the bottom one the mask and puts
// them in a "Mask group" (where the topmost was); with one layer it turns it into a mask (or
// back); one undo step.
let mut s = Session::new();
let a = rect(&mut s, 0.0, 0.0, 50.0, 50.0, "#000000", None);
let b = rect(&mut s, 10.0, 10.0, 50.0, 50.0, "#FF0000", None);
let c = rect(&mut s, 20.0, 20.0, 50.0, 50.0, "#00FF00", None);
let top = rect(&mut s, 200.0, 0.0, 10.0, 10.0, "#0000FF", None);
let g = id(ex(&mut s, "object.mask", json!({"ids": [c, a, b]})));
let doc = s.doc().unwrap();
let gn = doc.node(g).unwrap();
assert_eq!((gn.name.as_str(), gn.children.clone()), ("Mask group", vec![a, b, c]));
assert!(doc.node(a).unwrap().is_mask && !doc.node(b).unwrap().is_mask);
let page = s.page().unwrap();
assert_eq!(doc.node(page).unwrap().children, vec![g, top]);
assert_eq!(s.selection(), vec![g]);
s.undo().unwrap();
assert!(s.doc().unwrap().node(g).is_none() && !s.doc().unwrap().node(a).unwrap().is_mask);
// One layer: a toggle.
ex(&mut s, "object.mask", json!({"ids": [a]}));
assert!(s.doc().unwrap().node(a).unwrap().is_mask);
ex(&mut s, "object.mask", json!({"ids": [a]}));
assert!(!s.doc().unwrap().node(a).unwrap().is_mask);
// Release mask: the layer is a plain layer again; silent on the group: pinned (it stays).
let g = id(ex(&mut s, "object.mask", json!({"ids": [a, b]})));
ex(&mut s, "object.releaseMask", json!({"ids": [a]}));
assert!(!s.doc().unwrap().node(a).unwrap().is_mask);
assert_eq!(s.doc().unwrap().node(a).unwrap().parent, Some(g));
}
#[test]
fn a_mask_covers_every_layer_above_it_in_its_parent() {
// Documented: a mask shows the layers above it (in its group or frame) only where it is, and
// isn't drawn itself; layers below it aren't affected. A 200×100 white board: a blue band
// under the mask, the mask at x 50..100, red and green bands above it.
let mut s = Session::new();
let f = board(&mut s, 200.0, 100.0);
rect(&mut s, 0.0, 0.0, 200.0, 20.0, "#0000FF", Some(f));
let m = rect(&mut s, 50.0, 0.0, 50.0, 100.0, "#888888", Some(f));
let red = rect(&mut s, 0.0, 40.0, 200.0, 20.0, "#FF0000", Some(f));
rect(&mut s, 0.0, 70.0, 200.0, 20.0, "#00FF00", Some(f));
ex(&mut s, "object.mask", json!({"ids": [m]}));
let cases = [
// Below the mask: drawn everywhere, and the mask's own fill isn't drawn over it.
((10, 10), BLUE),
((75, 10), BLUE),
// Above the mask: inside it, drawn; outside it, hidden.
((75, 50), RED),
((10, 50), WHITE),
((75, 80), GREEN),
((150, 80), WHITE),
];
for ((x, y), want) in cases {
assert_eq!(rgb(&s, f, x, y), want, "({x}, {y})");
}
// A hidden mask masks nothing (silent on whether it's drawn: it isn't).
ex(&mut s, "node.set", json!({"id": m, "props": {"visible": false}}));
assert_eq!(rgb(&s, f, 10, 50), RED);
ex(&mut s, "node.set", json!({"id": m, "props": {"visible": true}}));
// Silent on a second mask higher up: pinned, it starts its own masked run (the green band is
// masked by it alone).
let m2 = rect(&mut s, 120.0, 0.0, 50.0, 100.0, "#888888", Some(f));
ex(&mut s, "node.reorder", json!({"id": m2, "index": 3}));
ex(&mut s, "object.mask", json!({"ids": [m2]}));
assert_eq!(s.doc().unwrap().node(f).unwrap().children.get(3), Some(&m2));
assert_eq!((rgb(&s, f, 75, 50), rgb(&s, f, 75, 80), rgb(&s, f, 150, 80), rgb(&s, f, 150, 50)), (RED, WHITE, GREEN, WHITE));
let _ = red;
}
#[test]
fn alpha_vector_and_luminance_masks() {
// Documented: an alpha mask uses the mask's opacity, a vector mask its outline (fully), a
// luminance mask its brightness. A red layer over a white board, masked by a full-size layer.
let cases: [(&str, &str, f64, std::ops::RangeInclusive<u8>); 6] = [
// Alpha, half opacity: red at 50% over white (G and B ≈ 128).
("alpha", "#000000", 0.5, 126..=129),
// Alpha, opaque (the colour doesn't matter): red.
("alpha", "#000000", 1.0, 0..=0),
// Vector: the outline counts fully, whatever the opacity.
("vector", "#000000", 0.5, 0..=0),
// Luminance: white shows the layer, black hides it, mid grey half shows it.
("luminance", "#FFFFFF", 1.0, 0..=0),
("luminance", "#000000", 1.0, 255..=255),
("luminance", "#808080", 1.0, 100..=160),
];
for (kind, fill, opacity, gb) in cases {
let mut s = Session::new();
let f = board(&mut s, 100.0, 20.0);
let m = rect(&mut s, 0.0, 0.0, 100.0, 20.0, fill, Some(f));
rect(&mut s, 0.0, 0.0, 100.0, 20.0, "#FF0000", Some(f));
ex(&mut s, "node.set", json!({"id": m, "props": {"opacity": opacity, "maskType": kind}}));
ex(&mut s, "object.mask", json!({"ids": [m]}));
let [r, g, b] = rgb(&s, f, 50, 10);
assert!(r == 255 && gb.contains(&g) && gb.contains(&b), "{kind} {fill} {opacity}: {r} {g} {b}");
}
}
#[test]
fn a_group_can_be_the_mask() {
// Documented: any layer can be a mask, a group too: what it draws is the mask. Two squares at
// 0..20 and 60..80 mask a red band.
let mut s = Session::new();
let f = board(&mut s, 100.0, 20.0);
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0, "#000000", Some(f));
let b = rect(&mut s, 60.0, 0.0, 20.0, 20.0, "#000000", Some(f));
let g = id(ex(&mut s, "object.group", json!({"ids": [a, b]})));
let red = rect(&mut s, 0.0, 0.0, 100.0, 20.0, "#FF0000", Some(f));
ex(&mut s, "object.mask", json!({"ids": [g, red]}));
assert!(s.doc().unwrap().node(g).unwrap().is_mask);
assert_eq!([10, 40, 70, 90].map(|x| rgb(&s, f, x, 10)), [RED, WHITE, RED, WHITE]);
}
#[test]
fn masked_out_areas_dont_take_clicks() {
// Documented: what a mask hides can't be clicked; a click there reaches what's behind. A
// 50×50 mask over a 100×100 layer, a blue square behind the group at 60..100.
let mut s = Session::new();
let page = s.page().unwrap();
let behind = rect(&mut s, 60.0, 60.0, 40.0, 40.0, "#0000FF", None);
let m = rect(&mut s, 0.0, 0.0, 50.0, 50.0, "#000000", None);
let c = rect(&mut s, 0.0, 0.0, 100.0, 100.0, "#FF0000", None);
let hit = |s: &Session, x: f64, y: f64| s.doc().unwrap().hit_test(page, Point::new(x, y), 0.0, None);
// Before there's a mask, the layer is hit everywhere (and the next edit makes the mask count).
assert_eq!(hit(&s, 75.0, 75.0), Some(c));
let g = id(ex(&mut s, "object.mask", json!({"ids": [m, c]})));
let cases = [
// Inside the mask: the masked layer.
((25.0, 25.0), Some(c)),
// Outside the mask, over the blue square: the square.
((75.0, 75.0), Some(behind)),
// Outside the mask, over nothing else: nothing.
((75.0, 10.0), None),
];
for ((x, y), want) in cases {
assert_eq!(hit(&s, x, y), want, "({x}, {y})");
}
// Select layer lists only what's there.
let layers = ex(&mut s, "select.layersAt", json!({"x": 75, "y": 75}));
let got: Vec<NodeId> = layers["layers"].as_array().unwrap().iter().map(|l| id_of(&l["id"]).unwrap()).collect();
assert_eq!(got, vec![behind]);
// A click selects the mask group (a group is picked whole).
s.pointer(&crate::tools::PointerEvent::at(crate::tools::PointerKind::Down, 25.0, 25.0, 1.0)).unwrap();
s.pointer(&crate::tools::PointerEvent::at(crate::tools::PointerKind::Up, 25.0, 25.0, 1.0)).unwrap();
assert_eq!(s.selection(), vec![g]);
}
#[test]
fn a_mask_groups_box_and_exports_are_the_masks() {
// Silent in the help centre on the box; pinned to what exports show: a mask group is as big as
// its mask (plus any layers under the mask), and exports crop to it.
let mut s = Session::new();
let m = rect(&mut s, 0.0, 0.0, 50.0, 40.0, "#000000", None);
let c = rect(&mut s, -30.0, -30.0, 200.0, 200.0, "#FF0000", None);
let g = id(ex(&mut s, "object.mask", json!({"ids": [m, c]})));
assert_eq!(wb(&s, g), (0.0, 0.0, 50.0, 40.0));
let px = figcraft_render::render_node(s.doc().unwrap(), g, 1.0).unwrap();
assert_eq!((px.width(), px.height()), (50, 40));
assert_eq!(rgb(&s, g, 25, 20), RED);
let svg = figcraft_svg::export_svg(s.doc().unwrap(), &[g], &figcraft_svg::SvgOptions::default()).unwrap();
assert!(svg.contains("viewBox=\"0 0 50 40\""), "{svg}");
assert!(figcraft_pdf::export_nodes_pdf(s.doc().unwrap(), &[g]).is_ok());
// Moving the masked layer doesn't change the box; moving the mask does.
ex(&mut s, "transform.move", json!({"ids": [c], "dx": 10, "dy": 0}));
assert_eq!(wb(&s, g), (0.0, 0.0, 50.0, 40.0));
ex(&mut s, "transform.move", json!({"ids": [m], "dx": 10, "dy": 0}));
assert_eq!(wb(&s, g), (10.0, 0.0, 50.0, 40.0));
// A layer under the mask counts.
let under = rect(&mut s, -40.0, 0.0, 10.0, 10.0, "#0000FF", None);
ex(&mut s, "node.reparent", json!({"ids": [under], "parent": g, "index": 0}));
assert_eq!(wb(&s, g), (-40.0, 0.0, 100.0, 40.0));
let _ = (GREEN, BLUE);
}