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.
This commit is contained in:
parent
e917d3c2e6
commit
a2602071d7
16
ROADMAP.md
16
ROADMAP.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
**Stage: alpha** · next: beta, ~26 points and ~173–310 h away (one Opus 5.5 agent; ~35–70 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
|
||||
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
|
||||
|
|
@ -19,7 +19,7 @@ start with [`docs/gaps.md`](docs/gaps.md).
|
|||
| Essentials user | **~57%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
|
||||
| Mainstream practitioner | **~48%** | estimated: weekly areas 69.1% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) |
|
||||
| Remaining to beta | **~173–310 h** one agent | estimated |
|
||||
| Remaining to full parity | **~314–606 h** one agent (~86–150 h wall-clock with 5 agents) | estimated |
|
||||
| Remaining to full parity | **~312–604 h** one agent (~86–150 h wall-clock with 5 agents) | estimated |
|
||||
|
||||
### Readiness by audience
|
||||
|
||||
|
|
@ -56,7 +56,7 @@ Figma's own languages on the landed localization layer (15–25 h).
|
|||
|
||||
| Dimension | Ready | Hours | Doc |
|
||||
|---|---|---|---|
|
||||
| Features (depth) | 57% (core editor 69%) | 53–103 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Features (depth) | 57% (core editor 69%) | 51–101 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) |
|
||||
| File formats | 55% (`.fig` read best effort, no write) | 8–15 | [file-format-parity](docs/file-format-parity.md) |
|
||||
| Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, the browser joins, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 49–98 | [collaboration-parity](docs/collaboration-parity.md) |
|
||||
|
|
@ -76,9 +76,9 @@ Figma's own languages on the landed localization layer (15–25 h).
|
|||
| Selection & transform | 97% | 72% | 3–5 | [ui-parity](docs/ui-parity.md) |
|
||||
| Layers panel | 98% | 82% | 1–2 | [ui-parity](docs/ui-parity.md) |
|
||||
| Shapes & vector | 97% | 68% | 1–3 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Paint | 90% | 67% | 3–6 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Paint | 90% | 71% | 2–5 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Strokes | 95% | 75% | 1–2 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Effects | 100% | 72% | 3–6 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Effects | 100% | 75% | 2–5 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Frames, groups, sections, masks | 97% | 80% | 1–3 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Auto layout & constraints | 98% | 74% | 3–6 | [auto-layout-parity](docs/auto-layout-parity.md) |
|
||||
| Text | 87% | 68% | 5–10 | [target-app-parity](docs/target-app-parity.md) |
|
||||
|
|
@ -128,6 +128,11 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
|||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-11** · Paint and effects behaviour suite: every documented rule for fills, blend modes,
|
||||
gradients, images, colour models, shadows, blurs and effect exports is a table-driven case. Fixes: Normal
|
||||
groups isolate and clipping frames don't, plus darker is drawn, drop shadows hide behind translucent
|
||||
layers in every export, Fill images reach the box's edges, "Normal" no longer means pass-through.
|
||||
Paint 70%, effects 75%.
|
||||
- **2026-10-11** · `.fig` import, best effort (gap 1): `file.open`, `file.import`, drag and drop, the CLI and
|
||||
MCP open `.fig` files. A generic kiwi decoder reads each file's own schema, and the decoded nodes map by field name
|
||||
onto the REST importer: layers, paints and styles, text runs, vectors as outlines, auto layout, components and
|
||||
|
|
@ -195,6 +200,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Paint and effects behaviour suite written from the public documentation and its fixes: Normal groups and frames isolate their children and clipping frames don't (canvas, SVG, PDF); plus darker drawn on the canvas and in PNG exports; drop shadows hidden behind what the layer covers unless shown behind transparent areas, SVG included, with filter regions that reach as far as the effects; Fill images cover the box to its edges; "Normal" names Normal in commands and the blend menu. Paint 85%/66% → 87%/70% (breadth from the GIF/TIFF row), 5–9 h → 3–6 h; effects 72% → 75%, 3–6 h → 2–5 h; percentages unchanged at this rounding; full parity ~321–618 h → ~319–616 h |
|
||||
| 2026-10-11 | minor | Frames, groups, sections and masks behaviour suite written from the public documentation (15 frame, group and section tests, 6 mask tests) and its fixes: a layer a mask hides can't be clicked there (clicks reach what's behind; Select layer agrees), a mask group's box and exports are the mask's (plus any layers under it), sections stay on the page or in sections (moves, drags, pastes, grouping and new sections in frames refused or kept on the page), drawing a frame or section around layers puts those fully inside it in it, resizing a section leaves its contents where they are (and a frame resized with Cmd/Ctrl held keeps its layers' sizes too), and X/Y of layers in groups are measured in the frame (groups have no coordinate space). Frames, groups, sections, masks 95%/75% → 97%/80%, 2–4 h → 1–3 h; percentages unchanged at this rounding; full parity ~323–621 h → ~322–620 h |
|
||||
| 2026-10-11 | minor | Styles and variables behaviour suite written from the public documentation (18 command-level tests) and its fixes: editing a styled fill, stroke, effect or layout grid list (directly, a paint row, an added fill or a bound variable) unlinks that style instead of being overwritten by the next style edit; applying a style replaces variables bound to what it sets; text styles go on text layers only; deleting a variable (or its collection) unbinds the layers using it, which keep their last value, and aliases to it take its values; bindings are type-checked (a colour variable can't drive a width) and number variables fill text content; aliases are checked when a variable is created. Added: duplicate, move (reorder, into a folder) and rename folder for styles, with a Local styles list grouped by type and folder (click to edit, right-click, drag to reorder); duplicate mode, set as default and move column for modes; per-platform code syntax (Web, iOS, Android) used by generated CSS and the variables JSON; font weight, paragraph spacing/indent and text content scopes for number variables. Found missing: variables inside styles. Styles & variables 92%/72% → 94%/76%, 1–2 h → 3–5 h; percentages unchanged at this rounding; full parity ~323–621 h → ~325–624 h |
|
||||
| 2026-10-11 | minor | Prototyping behaviour suite written from the public documentation (7 model tests, 12 player tests with 21 gestures) and its fixes: on drag starts only for a drag its animation's way and the animation follows the pointer, key combinations (`Shift+K`) fire (and are recorded and imported), an overlay's after-delay timer starts when it opens, instances of interactive components play their variant's interactions and "Change to" changes the right instance, spring presets are physical springs that play until they settle, smart animate overshoots with spring and back easings, fixed layers draw above scrolling content; added scroll to offsets. Prototyping 88%/70% → 90%/74%, 5–9 h → 3–6 h; mainstream weekly areas 68.1% → 68.4%, mainstream ~46% → ~47%; other percentages unchanged at this rounding; full parity ~320–615 h → ~318–612 h |
|
||||
|
|
|
|||
|
|
@ -100,8 +100,13 @@ impl BlendMode {
|
|||
}
|
||||
}
|
||||
|
||||
/// The mode named by its label or CSS keyword, ignoring case, spaces and dashes. Labels come
|
||||
/// first: "normal" is Normal, not pass-through (whose CSS keyword is also `normal`).
|
||||
pub fn from_name(s: &str) -> Option<Self> {
|
||||
let key: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
|
||||
Self::ALL.into_iter().find(|m| m.css().replace('-', "") == key || m.label().replace(' ', "").to_ascii_lowercase() == key)
|
||||
Self::ALL
|
||||
.into_iter()
|
||||
.find(|m| m.label().replace(' ', "").to_ascii_lowercase() == key)
|
||||
.or_else(|| Self::ALL.into_iter().find(|m| m.css().replace('-', "") == key))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -684,6 +684,12 @@ impl Node {
|
|||
(0..n).filter_map(move |i| self.children.get(if rev { n - 1 - i } else { i }).copied())
|
||||
}
|
||||
|
||||
/// Whether this group or frame blends its children among themselves before the result meets
|
||||
/// what is behind it: set to Normal rather than pass-through (with children to blend).
|
||||
pub fn isolates_children(&self) -> bool {
|
||||
self.blend == BlendMode::Normal && self.kind.is_container() && !matches!(self.kind, NodeKind::BooleanOp { .. }) && !self.children.is_empty()
|
||||
}
|
||||
|
||||
/// Whether painted strokes are present.
|
||||
pub fn has_visible_stroke(&self) -> bool {
|
||||
self.strokes.iter().any(|p| p.visible) && self.stroke.weight > 0.0
|
||||
|
|
|
|||
|
|
@ -0,0 +1,129 @@
|
|||
//! 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}");
|
||||
}
|
||||
|
|
@ -250,6 +250,9 @@ fn paints(p: &[figcraft_color::Paint]) -> Value {
|
|||
if x.opacity < 1.0 {
|
||||
v["opacity"] = json!(x.opacity);
|
||||
}
|
||||
if x.blend != figcraft_color::BlendMode::Normal {
|
||||
v["blend"] = json!(x.blend);
|
||||
}
|
||||
if !x.visible {
|
||||
v["visible"] = json!(false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1253,6 +1253,8 @@ mod frame_tests;
|
|||
#[cfg(test)]
|
||||
mod frames_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod fx_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod gesture_preview_tests;
|
||||
#[cfg(test)]
|
||||
mod html_codegen_tests;
|
||||
|
|
|
|||
|
|
@ -265,6 +265,10 @@ impl<'a> Exporter<'a> {
|
|||
let nb = blend(n.blend);
|
||||
if let Some(b) = nb {
|
||||
s.push_blend_mode(b);
|
||||
}
|
||||
// A blending layer, or a group set to Normal, composites its content in a group of its own.
|
||||
let isolated = nb.is_some() || n.isolates_children();
|
||||
if isolated {
|
||||
s.push_isolated();
|
||||
}
|
||||
if op < 1.0 {
|
||||
|
|
@ -277,8 +281,10 @@ impl<'a> Exporter<'a> {
|
|||
if op < 1.0 {
|
||||
s.pop();
|
||||
}
|
||||
if nb.is_some() {
|
||||
if isolated {
|
||||
s.pop();
|
||||
}
|
||||
if nb.is_some() {
|
||||
s.pop();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -153,9 +153,7 @@ impl Exporter<'_> {
|
|||
let (w, h) = (view.width, view.height);
|
||||
let layer = figcraft_render::render_alone(doc, id, &view, Alone::Plain)?;
|
||||
let xf = view.transform() * world;
|
||||
let spreads = n.effects.iter().any(|e| e.visible && matches!(&e.kind, EffectKind::DropShadow(s) if s.spread != 0.0));
|
||||
let sil = if spreads { shadow::silhouette(doc, n) } else { None };
|
||||
let caster = shadow::Caster { layer: &layer, xf, scale: view.scale, silhouette: sil.as_deref() };
|
||||
let caster = shadow::Caster::new(doc, n, &layer, xf, view.scale);
|
||||
let shape = figcraft_render::node_outline(doc, n);
|
||||
let mut out = Shadows::default();
|
||||
for e in n.effects.iter().filter(|e| e.visible) {
|
||||
|
|
|
|||
|
|
@ -27,9 +27,12 @@ pub struct Under<'a> {
|
|||
/// Where this surface's top-left pixel lands on `pix`.
|
||||
pub at: (i32, i32),
|
||||
pub opacity: f32,
|
||||
pub blend: tiny_skia::BlendMode,
|
||||
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>>,
|
||||
}
|
||||
|
|
@ -37,7 +40,7 @@ pub struct 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: tiny_skia::BlendMode::SourceOver, clip: None, below }
|
||||
Under { pix, at, opacity: 1.0, blend: figcraft_color::BlendMode::Normal, clip: None, replaces: false, below }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -60,18 +63,53 @@ fn clip_at(p: &mut Pixmap, m: &Mask, x: i32, y: i32) {
|
|||
/// 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 mut out = backdrop(u.pix, u.below, x.checked_add(u.at.0)?, y.checked_add(u.at.1)?, w, h)?;
|
||||
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);
|
||||
}
|
||||
out.draw_pixmap(0, 0, part.as_ref(), &PixmapPaint { opacity: u.opacity, blend_mode: u.blend, ..Default::default() }, Transform::identity(), None);
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -141,6 +141,27 @@ fn background_blur_inside_a_faded_group_blends_with_the_group() {
|
|||
assert!(diff <= 3, "differs by {diff}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_blur_in_a_clipping_frame_inside_a_faded_group() {
|
||||
// The clipping frame's children are drawn on a copy of the group's pixels: the pane's
|
||||
// backdrop is that copy where the group puts it, at the group's 50% (half the frame), not the
|
||||
// copy at full strength.
|
||||
let (mut d, f, pane) = scene(Holder::ClipFrame);
|
||||
let p = d.first_page().unwrap();
|
||||
let g = d.create(p, NodeKind::Group, "Faded", 0.0, 0.0, 240.0, 160.0).unwrap();
|
||||
d.get_mut(g).unwrap().opacity = 0.5;
|
||||
d.reparent(f, g, None).unwrap();
|
||||
let got = render_node(&d, g, 1.0).unwrap();
|
||||
// The pane draws the blurred half-strength frame over the frame, and the group halves both.
|
||||
let (frame, blurred) = expected_blurred(&d, f, pane, 1.0);
|
||||
let mut want = Pixmap::new(frame.width(), frame.height()).unwrap();
|
||||
want.draw_pixmap(0, 0, frame.as_ref(), &PixmapPaint { opacity: 0.25, ..Default::default() }, Transform::identity(), None);
|
||||
let plus = PixmapPaint { opacity: 0.25, blend_mode: tiny_skia::BlendMode::Plus, ..Default::default() };
|
||||
want.draw_pixmap(0, 0, blurred.as_ref(), &plus, Transform::identity(), None);
|
||||
let diff = max_diff_in_pane(&got, &want, 1.0);
|
||||
assert!(diff <= 3, "differs by {diff}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_blur_on_the_canvas_matches_the_export() {
|
||||
let (d, f, _) = scene(Holder::ClipFrame);
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ use tiny_skia::{FillRule, IntRect, Mask, MaskType, Pixmap, PixmapPaint, Transfor
|
|||
|
||||
use crate::backdrop::{self, Under};
|
||||
use crate::boolean::{PathCache, locked};
|
||||
use crate::paint::{prepare_at, ts_blend};
|
||||
use crate::paint::{composite, prepare_at, ts_blend};
|
||||
use crate::{blur, grain, lod, shadow};
|
||||
|
||||
/// Shared state for one render.
|
||||
|
|
@ -59,6 +59,19 @@ fn fill_with(pix: &mut Pixmap, path: &tiny_skia::Path, paints: &[Paint], w: f64,
|
|||
for p in paints {
|
||||
let Some(prep) = prepare_at(p, w, h, scale, ctx.doc, ctx.time) else { continue };
|
||||
let paint = tiny_skia::Paint { shader: prep.shader(), anti_alias: true, blend_mode: ts_blend(p.blend), ..Default::default() };
|
||||
if p.blend == BlendMode::PlusDarker {
|
||||
// No tiny-skia mode: the paint alone over its pixels, then blended in.
|
||||
let area = path
|
||||
.bounds()
|
||||
.transform(ts_transform(xf))
|
||||
.and_then(|b| int_rect(Rect::new(b.left().into(), b.top().into(), b.right().into(), b.bottom().into()), (pix.width(), pix.height())));
|
||||
let Some(mut alone) = area.and_then(|r| Pixmap::new(r.width(), r.height())) else { continue };
|
||||
let Some(r) = area else { continue };
|
||||
let t = ts_transform(Affine::translate((-f64::from(r.x()), -f64::from(r.y()))) * xf);
|
||||
alone.fill_path(path, &tiny_skia::Paint { blend_mode: tiny_skia::BlendMode::SourceOver, ..paint }, rule, t, None);
|
||||
crate::paint::composite(pix, (r.x(), r.y()), &alone, 1.0, p.blend);
|
||||
continue;
|
||||
}
|
||||
pix.fill_path(path, &paint, rule, ts_transform(xf), mask);
|
||||
}
|
||||
}
|
||||
|
|
@ -306,7 +319,10 @@ pub fn paint_bounds(doc: &Document, id: NodeId) -> Option<Rect> {
|
|||
}
|
||||
|
||||
fn needs_layer(n: &Node) -> bool {
|
||||
n.opacity < 0.999 || !matches!(n.blend, BlendMode::Normal | BlendMode::PassThrough) || n.effects.iter().any(|e| e.visible)
|
||||
n.opacity < 0.999
|
||||
|| !matches!(n.blend, BlendMode::Normal | BlendMode::PassThrough)
|
||||
|| n.effects.iter().any(|e| e.visible)
|
||||
|| n.isolates_children()
|
||||
}
|
||||
|
||||
/// `v` on the whole pixel that only float noise keeps it off: an edge on a pixel boundary comes out
|
||||
|
|
@ -406,7 +422,7 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep
|
|||
};
|
||||
let has_inner = shape.is_some() && n.effects.iter().any(|e| e.visible && matches!(e.kind, EffectKind::InnerShadow(_)));
|
||||
// Backdrops further down see this layer composited onto `pix`.
|
||||
let under = Under { opacity: op, blend: ts_blend(n.blend), ..Under::new(pix, (lx, ly), ctx.under) };
|
||||
let under = Under { opacity: op, blend: n.blend, ..Under::new(pix, (lx, ly), ctx.under) };
|
||||
draw_content(&Ctx { under: Some(&under), ..*ctx }, &mut layer, n, lxf, depth, has_inner.then_some(&inner as &dyn Fn(&mut Pixmap)));
|
||||
}
|
||||
// Layer blur, noise and texture, in list order, over everything else.
|
||||
|
|
@ -419,19 +435,15 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep
|
|||
}
|
||||
}
|
||||
// Drop shadows go under the layer, in effect-list order.
|
||||
let spreads = n.effects.iter().any(|e| e.visible && matches!(&e.kind, EffectKind::DropShadow(s) if s.spread != 0.0));
|
||||
let sil = if spreads { shadow::silhouette(ctx.doc, n) } else { None };
|
||||
let caster = shadow::Caster { layer: &layer, xf: lxf, scale: ctx.scale, silhouette: sil.as_deref() };
|
||||
let caster = shadow::Caster::new(ctx.doc, n, &layer, lxf, ctx.scale);
|
||||
for e in n.effects.iter().filter(|e| e.visible) {
|
||||
if let EffectKind::DropShadow(s) = &e.kind
|
||||
&& let Some(sh) = shadow::drop_shadow(&caster, s)
|
||||
{
|
||||
let paint = PixmapPaint { opacity: op, blend_mode: ts_blend(s.blend), ..Default::default() };
|
||||
pix.draw_pixmap(lx, ly, sh.as_ref(), &paint, Transform::identity(), None);
|
||||
composite(pix, (lx, ly), &sh, op, s.blend);
|
||||
}
|
||||
}
|
||||
let paint = PixmapPaint { opacity: op, blend_mode: ts_blend(n.blend), ..Default::default() };
|
||||
pix.draw_pixmap(lx, ly, layer.as_ref(), &paint, Transform::identity(), None);
|
||||
composite(pix, (lx, ly), &layer, op, n.blend);
|
||||
}
|
||||
|
||||
fn draw_outline(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize) {
|
||||
|
|
@ -602,7 +614,10 @@ fn draw_children_clipped(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, dept
|
|||
let dev = device_rect(xf, n.local_rect());
|
||||
let Some(ir) = int_rect(dev, (pw, ph)) else { return };
|
||||
let (lx, ly) = (ir.x(), ir.y());
|
||||
let Some(mut layer) = Pixmap::new(ir.width(), ir.height()) else { return };
|
||||
// The layer starts as what lies under it and goes back in its place through the clip:
|
||||
// clipping cuts the children off without isolating them, so their blend modes reach the
|
||||
// layers behind the frame.
|
||||
let Some(mut layer) = pix.clone_rect(ir) else { return };
|
||||
let lxf = Affine::translate((-f64::from(lx), -f64::from(ly))) * xf;
|
||||
let rect_clip = n.corners.uniform() == Some(0.0) && is_axis_aligned(xf);
|
||||
// The clip (a rectangular one is the layer's own box) is known before the children draw, so
|
||||
|
|
@ -616,22 +631,14 @@ fn draw_children_clipped(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, dept
|
|||
Some(m)
|
||||
})
|
||||
};
|
||||
{
|
||||
let under = Under { clip: mask.as_ref(), ..Under::new(pix, (lx, ly), ctx.under) };
|
||||
draw_children(&Ctx { under: Some(&under), ..*ctx }, &mut layer, n, lxf, depth);
|
||||
}
|
||||
if rect_clip {
|
||||
if ctx.lod {
|
||||
lod::blit_over(pix, lx, ly, &layer);
|
||||
} else {
|
||||
pix.draw_pixmap(lx, ly, layer.as_ref(), &PixmapPaint::default(), Transform::identity(), None);
|
||||
}
|
||||
if !rect_clip && mask.is_none() {
|
||||
return;
|
||||
}
|
||||
if let Some(m) = &mask {
|
||||
layer.apply_mask(m);
|
||||
{
|
||||
let under = Under { clip: mask.as_ref(), replaces: true, ..Under::new(pix, (lx, ly), ctx.under) };
|
||||
draw_children(&Ctx { under: Some(&under), ..*ctx }, &mut layer, n, lxf, depth);
|
||||
}
|
||||
pix.draw_pixmap(lx, ly, layer.as_ref(), &PixmapPaint::default(), Transform::identity(), None);
|
||||
backdrop::replace(pix, (lx, ly), &layer, mask.as_ref().map(|m| (m, (0, 0))));
|
||||
}
|
||||
|
||||
fn is_axis_aligned(xf: Affine) -> bool {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,377 @@
|
|||
//! 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);
|
||||
assert_eq!((w > 40 && h > 40, w == 40 && h == 40), (grows, !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");
|
||||
}
|
||||
|
|
@ -183,6 +183,52 @@ pub fn mask_by(p: &mut Pixmap, m: &Pixmap, invert: bool) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Call `f(dst_row, src_row, row, first_col)` for each row where `src`, with its top-left at `at`
|
||||
/// on `dst`, overlaps it (premultiplied RGBA bytes of the overlapping columns; `row` and
|
||||
/// `first_col` in `src`'s pixels). Nothing past either edge is visited.
|
||||
pub(crate) fn overlap_rows(dst: &mut Pixmap, at: (i32, i32), src: &Pixmap, mut f: impl FnMut(&mut [u8], &[u8], i64, i64)) {
|
||||
let (dw, dh) = (i64::from(dst.width()), i64::from(dst.height()));
|
||||
let (sw, sh) = (i64::from(src.width()), i64::from(src.height()));
|
||||
let (ax, ay) = (i64::from(at.0), i64::from(at.1));
|
||||
let (c0, c1) = ((-ax).max(0), sw.min(dw - ax));
|
||||
let (r0, r1) = ((-ay).max(0), sh.min(dh - ay));
|
||||
if c1 <= c0 || r1 <= r0 {
|
||||
return;
|
||||
}
|
||||
let len = (c1 - c0) as usize * 4;
|
||||
let s = src.data();
|
||||
let d = dst.data_mut();
|
||||
for r in r0..r1 {
|
||||
let (si, di) = ((r * sw + c0) as usize * 4, ((r + ay) * dw + ax + c0) as usize * 4);
|
||||
if let (Some(sp), Some(dp)) = (s.get(si..si + len), d.get_mut(di..di + len)) {
|
||||
f(dp, sp, r, c0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Composite `src` at `opacity` onto `dst` (top-left at `at`) with plus-darker (linear burn:
|
||||
/// `max(0, backdrop + source - 1)`, through the W3C general compositing formula), which
|
||||
/// tiny-skia lacks.
|
||||
pub fn plus_darker(dst: &mut Pixmap, at: (i32, i32), src: &Pixmap, opacity: f32) {
|
||||
let o = if opacity.is_finite() { opacity.clamp(0.0, 1.0) } else { 0.0 };
|
||||
overlap_rows(dst, at, src, |dp, sp, _, _| {
|
||||
for (d, s) in dp.chunks_exact_mut(4).zip(sp.chunks_exact(4)) {
|
||||
let f = |v: u8| f32::from(v) / 255.0;
|
||||
let sa = f(s[3]) * o;
|
||||
if sa <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let da = f(d[3]);
|
||||
for c in 0..3 {
|
||||
let (sc, dc) = (f(s[c]) * o, f(d[c]));
|
||||
let v = sc * (1.0 - da) + dc * (1.0 - sa) + (sa * dc + da * sc - sa * da).max(0.0);
|
||||
d[c] = (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
}
|
||||
d[3] = ((sa + da - sa * da).clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -7,9 +7,7 @@
|
|||
|
||||
use figcraft_doc::{Noise, NoiseMode, Texture};
|
||||
use figcraft_geom::Affine;
|
||||
use tiny_skia::{Pixmap, PixmapPaint, Transform};
|
||||
|
||||
use crate::paint::ts_blend;
|
||||
use tiny_skia::Pixmap;
|
||||
|
||||
/// A well-mixed 64-bit hash of a grain cell.
|
||||
fn hash(ix: i64, iy: i64, seed: u32) -> u64 {
|
||||
|
|
@ -55,8 +53,7 @@ impl Cells {
|
|||
/// size across.
|
||||
pub fn noise(layer: &mut Pixmap, nz: &Noise, xf: Affine, seed: u32) {
|
||||
if let Some(g) = grains(layer, nz, xf, seed) {
|
||||
let paint = PixmapPaint { blend_mode: ts_blend(nz.blend), ..Default::default() };
|
||||
layer.draw_pixmap(0, 0, g.as_ref(), &paint, Transform::identity(), None);
|
||||
crate::paint::composite(layer, (0, 0), &g, 1.0, nz.blend);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -404,6 +404,8 @@ mod boolean_cache_tests;
|
|||
#[cfg(test)]
|
||||
mod effect_order_tests;
|
||||
#[cfg(test)]
|
||||
mod effects_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod gif_tests;
|
||||
#[cfg(test)]
|
||||
mod grain_tests;
|
||||
|
|
@ -412,6 +414,8 @@ mod image_profile_tests;
|
|||
#[cfg(test)]
|
||||
mod lod_tests;
|
||||
#[cfg(test)]
|
||||
mod paint_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod progressive_tests;
|
||||
#[cfg(test)]
|
||||
mod retained_edit_tests;
|
||||
|
|
|
|||
|
|
@ -8,8 +8,7 @@
|
|||
//! - Text below [`GREEK_PX`] (its largest font size on screen) is *greeked*: one bar per line,
|
||||
//! across the line's glyphs, at about the ink density of text.
|
||||
//!
|
||||
//! - Small axis-aligned rectangles with solid fills are splatted instead of filled as paths, and
|
||||
//! rectangular clipping layers are composited without a full blending pipeline.
|
||||
//! - Small axis-aligned rectangles with solid fills are splatted instead of filled as paths.
|
||||
//!
|
||||
//! Only views at [`MAX_SCALE`] or below are approximated: 1:1 and closer previews are exact, and
|
||||
//! so are exports, thumbnails and every other render. Whatever LOD can't approximate faithfully
|
||||
|
|
@ -337,42 +336,3 @@ pub fn splat(pix: &mut Pixmap, r: Rect, color: [f32; 4]) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Composite premultiplied `src` over `dst` with its top-left at `(x, y)` (source-over, the
|
||||
/// pixels outside `dst` dropped): what `draw_pixmap` does for a plain layer, without its pipeline
|
||||
/// set-up, which dominates for the many small layers of a zoomed-out page.
|
||||
pub fn blit_over(dst: &mut Pixmap, x: i32, y: i32, src: &Pixmap) {
|
||||
let (dw, dh) = (i64::from(dst.width()), i64::from(dst.height()));
|
||||
let (sw, sh) = (i64::from(src.width()), i64::from(src.height()));
|
||||
let (x, y) = (i64::from(x), i64::from(y));
|
||||
// The overlap, in source columns and rows.
|
||||
let (c0, c1) = ((-x).max(0), sw.min(dw - x));
|
||||
let (r0, r1) = ((-y).max(0), sh.min(dh - y));
|
||||
if c1 <= c0 || r1 <= r0 {
|
||||
return;
|
||||
}
|
||||
let (sstride, dstride) = (sw as usize * 4, dw as usize * 4);
|
||||
let (c0, c1) = (c0 as usize * 4, c1 as usize * 4);
|
||||
let dx = (x * 4) as isize;
|
||||
let s = src.data();
|
||||
let d = dst.data_mut();
|
||||
for r in r0..r1 {
|
||||
let srow = r as usize * sstride;
|
||||
let drow = (r + y) as usize * dstride;
|
||||
let (Some(sp), Some(dp)) = (s.get(srow + c0..srow + c1), d.get_mut((drow + c0).wrapping_add_signed(dx)..(drow + c1).wrapping_add_signed(dx)))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for (sp, dp) in sp.chunks_exact(4).zip(dp.chunks_exact_mut(4)) {
|
||||
let a = sp[3];
|
||||
if a == 255 {
|
||||
dp.copy_from_slice(sp);
|
||||
} else if a > 0 {
|
||||
let keep = 255 - u32::from(a);
|
||||
for (d, s) in dp.iter_mut().zip(sp) {
|
||||
*d = (u32::from(*s) + (u32::from(*d) * keep + 127) / 255).min(255) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use figcraft_doc::{Document, ImageData, NodeId, NodeKind, TextData};
|
|||
use figcraft_geom::{Affine, Rect};
|
||||
|
||||
use crate::retained_tests::assert_same;
|
||||
use crate::{Options, Pixmap, Retained, View, lod, render_node, render_view, render_view_single};
|
||||
use crate::{Options, Pixmap, Retained, View, backdrop, lod, render_node, render_view, render_view_single};
|
||||
|
||||
fn fonts() {
|
||||
static ONCE: Once = Once::new();
|
||||
|
|
@ -304,6 +304,6 @@ fn splat_and_blit_survive_junk() {
|
|||
let src = Pixmap::new(3, 3).unwrap();
|
||||
let mut dst = Pixmap::new(4, 4).unwrap();
|
||||
for (x, y) in [(-2, -2), (3, 3), (-10, 0), (0, 10), (i32::MIN, i32::MAX), (i32::MAX, i32::MIN)] {
|
||||
lod::blit_over(&mut dst, x, y, &src);
|
||||
backdrop::replace(&mut dst, (x, y), &src, None);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ pub fn ts_blend(b: BlendMode) -> tiny_skia::BlendMode {
|
|||
BlendMode::PassThrough | BlendMode::Normal => T::SourceOver,
|
||||
BlendMode::Darken => T::Darken,
|
||||
BlendMode::Multiply => T::Multiply,
|
||||
// Plus darker: approximate with multiply-ish darken.
|
||||
// Plus darker has no tiny-skia mode: `composite` draws it; elsewhere the nearest one.
|
||||
BlendMode::PlusDarker => T::Multiply,
|
||||
BlendMode::ColorBurn => T::ColorBurn,
|
||||
BlendMode::Lighten => T::Lighten,
|
||||
|
|
@ -35,6 +35,16 @@ pub fn ts_blend(b: BlendMode) -> tiny_skia::BlendMode {
|
|||
}
|
||||
}
|
||||
|
||||
/// Draw `src` (top-left at `at`) onto `dst` at `opacity`, blended by `blend`.
|
||||
pub fn composite(dst: &mut Pixmap, at: (i32, i32), src: &Pixmap, opacity: f32, blend: BlendMode) {
|
||||
if blend == BlendMode::PlusDarker {
|
||||
crate::fx::plus_darker(dst, at, src, opacity);
|
||||
} else {
|
||||
let paint = tiny_skia::PixmapPaint { opacity, blend_mode: ts_blend(blend), ..Default::default() };
|
||||
dst.draw_pixmap(at.0, at.1, src.as_ref(), &paint, Transform::identity(), None);
|
||||
}
|
||||
}
|
||||
|
||||
fn stops(g: &Gradient, opacity: f32) -> Vec<GradientStop> {
|
||||
g.sorted_stops().into_iter().map(|s| GradientStop::new(s.position, ts_color(s.color.with_alpha(s.color.a * opacity)))).collect()
|
||||
}
|
||||
|
|
@ -136,7 +146,12 @@ pub fn prepare_at(paint: &Paint, w: f64, h: f64, scale: f64, doc: &Document, tim
|
|||
if img.scale_mode == ImageScaleMode::Tile {
|
||||
return Some(Prepared::Pattern { pixmap: pm, mode: SpreadMode::Repeat, quality: FilterQuality::Bicubic, opacity: op, transform });
|
||||
}
|
||||
// Fit/Fill/Crop: a transparent 1-px border so Pad spreads transparency, not edge pixels.
|
||||
// An image covering the whole box fills it to its edges (a transparent border would
|
||||
// fade them where the image is scaled up).
|
||||
if covers_box(img, f64::from(pm.width()), f64::from(pm.height()), w, h) {
|
||||
return Some(Prepared::Pattern { pixmap: pm, mode: SpreadMode::Pad, quality: FilterQuality::Bicubic, opacity: op, transform });
|
||||
}
|
||||
// Fit/Crop: a transparent 1-px border so Pad spreads transparency, not edge pixels.
|
||||
// A GIF's later frames each show once per loop: not worth caching padded.
|
||||
let padded = match moving {
|
||||
Some(_) => Arc::new(crate::images::pad(&pm)?),
|
||||
|
|
@ -149,6 +164,19 @@ pub fn prepare_at(paint: &Paint, w: f64, h: f64, scale: f64, doc: &Document, tim
|
|||
}
|
||||
}
|
||||
|
||||
/// Whether an image paint covers its whole `w` × `h` box: Fill always, Crop when its frame does.
|
||||
fn covers_box(img: &ImagePaint, iw: f64, ih: f64, w: f64, h: f64) -> bool {
|
||||
match img.scale_mode {
|
||||
ImageScaleMode::Fill => true,
|
||||
ImageScaleMode::Crop => {
|
||||
let m = img.effective_crop(iw, ih, w, h);
|
||||
let (x0, y0, x1, y1) = ImagePaint::crop_rect(m);
|
||||
m[1] == 0.0 && m[2] == 0.0 && x0.min(x1) <= 0.0 && y0.min(y1) <= 0.0 && x0.max(x1) >= 1.0 && y0.max(y1) >= 1.0
|
||||
}
|
||||
ImageScaleMode::Fit | ImageScaleMode::Tile => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Mapping from image pixels to the node's local box for each scale mode.
|
||||
pub fn image_transform(img: &ImagePaint, iw: f64, ih: f64, w: f64, h: f64) -> Transform {
|
||||
let iw = iw.max(1.0);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,632 @@
|
|||
//! Paint behaviour, written from the public documentation of the design tools FigCraft follows:
|
||||
//! fill stacking and opacity, paint and layer blend modes (pass-through and isolating groups),
|
||||
//! the four gradient kinds and their handles, image scale modes, rotation and adjustments, the
|
||||
//! colour models and the document colour profile. 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::{BlendMode, Color, ColorProfile, ColorStop, Gradient, GradientKind, Hsb, Hsl, ImageFilters, ImageScaleMode, Paint, PaintKind};
|
||||
use figcraft_doc::{Document, ImageData, NodeId, NodeKind, StrokeAlign};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A pixel, and pixels with the colour each must have.
|
||||
pub(super) type Px = (u32, u32);
|
||||
pub(super) type Probes = Vec<(Px, [u8; 3])>;
|
||||
/// A point or size in document units.
|
||||
pub(super) type Pt = (f64, f64);
|
||||
|
||||
pub(super) fn hex(h: &str) -> Color {
|
||||
Color::from_hex(h).unwrap()
|
||||
}
|
||||
|
||||
fn rgb(h: &str) -> [u8; 3] {
|
||||
let [r, g, b, _] = hex(h).to_rgba8();
|
||||
[r, g, b]
|
||||
}
|
||||
|
||||
pub(super) fn page() -> (Document, NodeId) {
|
||||
let d = Document::new("paint");
|
||||
let p = d.first_page().unwrap();
|
||||
(d, p)
|
||||
}
|
||||
|
||||
pub(super) fn rect(d: &mut Document, parent: NodeId, (x, y, w, h): (f64, f64, f64, f64), fills: Vec<Paint>) -> NodeId {
|
||||
let r = d.create(parent, NodeKind::Rectangle, "R", x, y, w, h).unwrap();
|
||||
d.get_mut(r).unwrap().fills = fills;
|
||||
r
|
||||
}
|
||||
|
||||
/// The page's layers on a white 200 × 200 view at 1×.
|
||||
pub(super) fn render(d: &Document) -> Pixmap {
|
||||
let p = d.first_page().unwrap();
|
||||
let kids = d.node(p).unwrap().children.clone();
|
||||
let mut pix = Pixmap::new(200, 200).unwrap();
|
||||
pix.fill(tiny_skia::Color::WHITE);
|
||||
render_nodes_into(d, &kids, &View { origin: (0.0, 0.0), scale: 1.0, width: 200, height: 200 }, &mut pix);
|
||||
pix
|
||||
}
|
||||
|
||||
pub(super) fn px(p: &Pixmap, x: u32, y: u32) -> [u8; 3] {
|
||||
let i = ((y * p.width() + x) * 4) as usize;
|
||||
[p.data()[i], p.data()[i + 1], p.data()[i + 2]]
|
||||
}
|
||||
|
||||
pub(super) fn near(a: [u8; 3], b: [u8; 3], tol: u8) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= tol)
|
||||
}
|
||||
|
||||
pub(super) fn solid_at(c: &str, opacity: f32) -> Paint {
|
||||
Paint { opacity, ..Paint::solid(hex(c)) }
|
||||
}
|
||||
|
||||
// ---- fills: stacking and opacity ----
|
||||
|
||||
#[test]
|
||||
fn fills_stack_bottom_to_top_with_their_own_opacity() {
|
||||
let hidden = Paint { visible: false, ..Paint::solid(hex("0000FF")) };
|
||||
let rows: Vec<(&str, Vec<Paint>, [u8; 3])> = vec![
|
||||
("one fill paints its colour", vec![Paint::solid(hex("FF0000"))], [255, 0, 0]),
|
||||
(
|
||||
"the list's last paint is the panel's top row and covers the rest",
|
||||
vec![Paint::solid(hex("FF0000")), Paint::solid(hex("0000FF"))],
|
||||
[0, 0, 255],
|
||||
),
|
||||
("a top fill at 50% shows the fill under it through", vec![Paint::solid(hex("FF0000")), solid_at("0000FF", 0.5)], [128, 0, 128]),
|
||||
("a hidden fill paints nothing", vec![Paint::solid(hex("FF0000")), hidden], [255, 0, 0]),
|
||||
("a fill at 0% paints nothing", vec![Paint::solid(hex("FF0000")), solid_at("0000FF", 0.0)], [255, 0, 0]),
|
||||
("a fill's opacity lets the page through", vec![solid_at("FF0000", 0.5)], [255, 128, 128]),
|
||||
(
|
||||
"a colour's own alpha and the paint opacity multiply",
|
||||
vec![Paint { kind: PaintKind::Solid { color: hex("FF0000").with_alpha(0.5) }, ..solid_at("FF0000", 0.5) }],
|
||||
[255, 191, 191],
|
||||
),
|
||||
("no fills: nothing", vec![], [255, 255, 255]),
|
||||
];
|
||||
for (why, fills, want) in rows {
|
||||
let (mut d, p) = page();
|
||||
rect(&mut d, p, (20.0, 20.0, 60.0, 60.0), fills);
|
||||
let got = px(&render(&d), 50, 50);
|
||||
assert!(near(got, want, 2), "{why}: {got:?} want {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paint_opacity_and_layer_opacity_differ() {
|
||||
// A red fill under a blue 10-unit centre stroke; (22, 50) is inside both.
|
||||
let rows = [
|
||||
("paint opacities: each paint shows what is under it, fill and page included", 0.5, 1.0, [128, 64, 191]),
|
||||
("layer opacity: the layer is composited first, so the stroke hides the fill and only the page shows through", 1.0, 0.5, [128, 128, 255]),
|
||||
("both at 100%: the stroke covers the fill", 1.0, 1.0, [0, 0, 255]),
|
||||
];
|
||||
for (why, paint_op, layer_op, want) in rows {
|
||||
let (mut d, p) = page();
|
||||
let r = rect(&mut d, p, (20.0, 20.0, 60.0, 60.0), vec![solid_at("FF0000", paint_op)]);
|
||||
let n = d.get_mut(r).unwrap();
|
||||
n.strokes = vec![solid_at("0000FF", paint_op)];
|
||||
n.stroke.weight = 10.0;
|
||||
n.stroke.align = StrokeAlign::Center;
|
||||
n.opacity = layer_op;
|
||||
let got = px(&render(&d), 22, 50);
|
||||
assert!(near(got, want, 2), "{why}: {got:?} want {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- blend modes ----
|
||||
|
||||
/// The separable and non-separable blend functions of the W3C compositing spec (which the
|
||||
/// documented blend modes are), plus plus-darker / plus-lighter (linear burn / dodge), for
|
||||
/// opaque colours in 0..1.
|
||||
fn blend_ref(mode: BlendMode, cb: [f64; 3], cs: [f64; 3]) -> [f64; 3] {
|
||||
let sep = |f: &dyn Fn(f64, f64) -> f64| [f(cb[0], cs[0]), f(cb[1], cs[1]), f(cb[2], cs[2])];
|
||||
let screen = |b: f64, s: f64| b + s - b * s;
|
||||
let hard = |b: f64, s: f64| if s <= 0.5 { b * 2.0 * s } else { screen(b, 2.0 * s - 1.0) };
|
||||
let lum = |c: [f64; 3]| 0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2];
|
||||
let clip = |c: [f64; 3]| {
|
||||
let (l, n, x) = (lum(c), c[0].min(c[1]).min(c[2]), c[0].max(c[1]).max(c[2]));
|
||||
let mut c = c;
|
||||
if n < 0.0 {
|
||||
c = c.map(|v| l + (v - l) * l / (l - n));
|
||||
}
|
||||
if x > 1.0 {
|
||||
c = c.map(|v| l + (v - l) * (1.0 - l) / (x - l));
|
||||
}
|
||||
c
|
||||
};
|
||||
let set_lum = |c: [f64; 3], l: f64| {
|
||||
let d = l - lum(c);
|
||||
clip(c.map(|v| v + d))
|
||||
};
|
||||
let sat = |c: [f64; 3]| c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2]);
|
||||
let set_sat = |c: [f64; 3], s: f64| {
|
||||
let (mx, mn) = (c[0].max(c[1]).max(c[2]), c[0].min(c[1]).min(c[2]));
|
||||
c.map(|v| if mx > mn { (v - mn) * s / (mx - mn) } else { 0.0 })
|
||||
};
|
||||
match mode {
|
||||
BlendMode::Normal | BlendMode::PassThrough => cs,
|
||||
BlendMode::Multiply => sep(&|b, s| b * s),
|
||||
BlendMode::Screen => sep(&screen),
|
||||
BlendMode::Overlay => sep(&|b, s| hard(s, b)),
|
||||
BlendMode::Darken => sep(&f64::min),
|
||||
BlendMode::Lighten => sep(&f64::max),
|
||||
BlendMode::ColorDodge => sep(&|b, s| {
|
||||
if b == 0.0 {
|
||||
0.0
|
||||
} else if s >= 1.0 {
|
||||
1.0
|
||||
} else {
|
||||
(b / (1.0 - s)).min(1.0)
|
||||
}
|
||||
}),
|
||||
BlendMode::ColorBurn => sep(&|b, s| {
|
||||
if b >= 1.0 {
|
||||
1.0
|
||||
} else if s <= 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
1.0 - ((1.0 - b) / s).min(1.0)
|
||||
}
|
||||
}),
|
||||
BlendMode::HardLight => sep(&hard),
|
||||
BlendMode::SoftLight => sep(&|b, s| {
|
||||
if s <= 0.5 {
|
||||
b - (1.0 - 2.0 * s) * b * (1.0 - b)
|
||||
} else {
|
||||
let d = if b <= 0.25 { ((16.0 * b - 12.0) * b + 4.0) * b } else { b.sqrt() };
|
||||
b + (2.0 * s - 1.0) * (d - b)
|
||||
}
|
||||
}),
|
||||
BlendMode::Difference => sep(&|b, s| (b - s).abs()),
|
||||
BlendMode::Exclusion => sep(&|b, s| b + s - 2.0 * b * s),
|
||||
BlendMode::PlusDarker => sep(&|b, s| (b + s - 1.0).max(0.0)),
|
||||
BlendMode::PlusLighter => sep(&|b, s| (b + s).min(1.0)),
|
||||
BlendMode::Hue => set_lum(set_sat(cs, sat(cb)), lum(cb)),
|
||||
BlendMode::Saturation => set_lum(set_sat(cb, sat(cs)), lum(cb)),
|
||||
BlendMode::Color => set_lum(cs, lum(cb)),
|
||||
BlendMode::Luminosity => set_lum(cb, lum(cs)),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_blend_mode_blends_a_paint_and_a_layer_by_its_formula() {
|
||||
// Every mode is found by its menu label; "Normal" is Normal (not pass-through, whose CSS
|
||||
// keyword is also `normal`).
|
||||
for mode in BlendMode::ALL {
|
||||
assert_eq!(BlendMode::from_name(mode.label()), Some(mode), "{mode:?} by its label");
|
||||
}
|
||||
let (bottom, top) = ("CC6633", "66CCEE");
|
||||
let unit = |h: &str| rgb(h).map(|v| f64::from(v) / 255.0);
|
||||
for mode in BlendMode::ALL {
|
||||
let want = blend_ref(mode, unit(bottom), unit(top)).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8);
|
||||
// A paint's blend mode blends it with the paints under it in the same layer.
|
||||
let (mut d, p) = page();
|
||||
rect(&mut d, p, (20.0, 20.0, 60.0, 60.0), vec![Paint::solid(hex(bottom)), Paint { blend: mode, ..Paint::solid(hex(top)) }]);
|
||||
let paint_px = px(&render(&d), 50, 50);
|
||||
// A layer's blend mode blends it with the layers under it.
|
||||
let (mut d, p) = page();
|
||||
rect(&mut d, p, (20.0, 20.0, 60.0, 60.0), vec![Paint::solid(hex(bottom))]);
|
||||
let r = rect(&mut d, p, (20.0, 20.0, 60.0, 60.0), vec![Paint::solid(hex(top))]);
|
||||
d.get_mut(r).unwrap().blend = mode;
|
||||
let layer_px = px(&render(&d), 50, 50);
|
||||
assert!(near(paint_px, want, 3), "paint {mode:?}: {paint_px:?} want {want:?}");
|
||||
assert!(near(layer_px, want, 3), "layer {mode:?}: {layer_px:?} want {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Holder {
|
||||
Group,
|
||||
ClipFrame,
|
||||
RoundedClipFrame,
|
||||
OpenFrame,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pass_through_containers_let_children_blend_with_what_is_behind_and_normal_ones_isolate() {
|
||||
// A yellow layer under a container holding a cyan layer set to Multiply: reaching the yellow,
|
||||
// multiply gives green; isolated, the cyan meets only what the container itself draws.
|
||||
let rows = [
|
||||
("pass-through group: children blend with the layers below it", Holder::Group, BlendMode::PassThrough, None, [0, 255, 0]),
|
||||
("a group set to Normal blends its children among themselves first", Holder::Group, BlendMode::Normal, None, [0, 255, 255]),
|
||||
("pass-through frame that clips: clipping doesn't isolate", Holder::ClipFrame, BlendMode::PassThrough, None, [0, 255, 0]),
|
||||
("pass-through frame with rounded corners that clips", Holder::RoundedClipFrame, BlendMode::PassThrough, None, [0, 255, 0]),
|
||||
("pass-through frame that doesn't clip", Holder::OpenFrame, BlendMode::PassThrough, None, [0, 255, 0]),
|
||||
("a frame set to Normal isolates its children", Holder::ClipFrame, BlendMode::Normal, None, [0, 255, 255]),
|
||||
("a clipping frame's own fill is under its children", Holder::ClipFrame, BlendMode::PassThrough, Some("FF00FF"), [0, 0, 255]),
|
||||
("an isolating frame's own fill is inside the isolation", Holder::ClipFrame, BlendMode::Normal, Some("FF00FF"), [0, 0, 255]),
|
||||
];
|
||||
for (why, holder, blend, fill, want) in rows {
|
||||
let (mut d, p) = page();
|
||||
rect(&mut d, p, (0.0, 0.0, 200.0, 200.0), vec![Paint::solid(hex("FFFF00"))]);
|
||||
let kind = if matches!(holder, Holder::Group) { NodeKind::Group } else { NodeKind::Frame };
|
||||
let c = d.create(p, kind, "C", 40.0, 40.0, 120.0, 120.0).unwrap();
|
||||
let n = d.get_mut(c).unwrap();
|
||||
n.blend = blend;
|
||||
n.fills = fill.map(|f| vec![Paint::solid(hex(f))]).unwrap_or_default();
|
||||
n.clip = matches!(holder, Holder::ClipFrame | Holder::RoundedClipFrame);
|
||||
if matches!(holder, Holder::RoundedClipFrame) {
|
||||
n.corners.radii = [20.0; 4];
|
||||
}
|
||||
let k = rect(&mut d, c, (10.0, 10.0, 100.0, 100.0), vec![Paint::solid(hex("00FFFF"))]);
|
||||
d.get_mut(k).unwrap().blend = BlendMode::Multiply;
|
||||
let got = px(&render(&d), 100, 100);
|
||||
assert!(near(got, want, 2), "{why} ({holder:?}): {got:?} want {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipping_still_clips_blending_children() {
|
||||
// The multiply child reaches past its clipping frame: outside, the yellow stays untouched.
|
||||
for radius in [0.0, 20.0] {
|
||||
let (mut d, p) = page();
|
||||
rect(&mut d, p, (0.0, 0.0, 200.0, 200.0), vec![Paint::solid(hex("FFFF00"))]);
|
||||
let f = d.create(p, NodeKind::Frame, "F", 40.0, 40.0, 100.0, 100.0).unwrap();
|
||||
let n = d.get_mut(f).unwrap();
|
||||
(n.fills, n.clip, n.corners.radii) = (vec![], true, [radius; 4]);
|
||||
let k = rect(&mut d, f, (50.0, 50.0, 100.0, 100.0), vec![Paint::solid(hex("00FFFF"))]);
|
||||
d.get_mut(k).unwrap().blend = BlendMode::Multiply;
|
||||
let pix = render(&d);
|
||||
assert_eq!(px(&pix, 160, 160), [255, 255, 0], "radius {radius}: clipped part");
|
||||
assert!(near(px(&pix, 120, 120), [0, 255, 0], 2), "radius {radius}: inside");
|
||||
if radius > 0.0 {
|
||||
// The rounded corner clips too.
|
||||
assert_eq!(px(&pix, 138, 138), [255, 255, 0], "rounded corner");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- gradients ----
|
||||
|
||||
fn grey(t: f64) -> [u8; 3] {
|
||||
let v = (t.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
[v, v, v]
|
||||
}
|
||||
|
||||
fn black_to_white(kind: GradientKind, from: (f64, f64), to: (f64, f64), width: (f64, f64)) -> Paint {
|
||||
Paint::gradient(Gradient {
|
||||
kind,
|
||||
stops: vec![ColorStop { position: 0.0, color: Color::BLACK }, ColorStop { position: 1.0, color: Color::WHITE }],
|
||||
from,
|
||||
to,
|
||||
width,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_handles_place_the_stops() {
|
||||
use GradientKind::*;
|
||||
let c = (0.5, 0.5);
|
||||
// (why, kind, from, to, width, box w × h, pixel, t there). The handles are in the layer's
|
||||
// unit box: the first is where stop 0 sits (the centre for the radial kinds), the second
|
||||
// where stop 1 sits, the third the extent across.
|
||||
type Row<'a> = (&'a str, GradientKind, Pt, Pt, Pt, Pt, Px, f64);
|
||||
let rows: Vec<Row> = vec![
|
||||
("linear: t runs from the first handle to the second", Linear, (0.0, 0.5), (1.0, 0.5), (0.0, 1.0), (100.0, 100.0), (25, 50), 0.255),
|
||||
("linear: before the first handle, the first stop", Linear, (0.25, 0.5), (0.75, 0.5), (0.25, 1.0), (100.0, 100.0), (10, 50), 0.0),
|
||||
("linear: past the second handle, the last stop", Linear, (0.25, 0.5), (0.75, 0.5), (0.25, 1.0), (100.0, 100.0), (90, 50), 1.0),
|
||||
("linear: top to bottom", Linear, (0.5, 0.0), (0.5, 1.0), (0.0, 0.0), (100.0, 100.0), (50, 75), 0.755),
|
||||
("linear: the bands are square to the axis", Linear, (0.5, 0.0), (0.5, 1.0), (0.0, 0.0), (100.0, 100.0), (5, 75), 0.755),
|
||||
("radial: t is the distance from the centre over the radius", Radial, c, (1.0, 0.5), (0.5, 1.0), (100.0, 100.0), (75, 50), 0.51),
|
||||
("radial: elliptical, by the second and third handles (across)", Radial, c, (1.0, 0.5), (0.5, 1.0), (200.0, 100.0), (150, 50), 0.505),
|
||||
("radial: elliptical (down)", Radial, c, (1.0, 0.5), (0.5, 1.0), (200.0, 100.0), (100, 74), 0.49),
|
||||
("radial: past the radius, the last stop", Radial, c, (1.0, 0.5), (0.5, 1.0), (100.0, 100.0), (2, 2), 1.0),
|
||||
("angular: a quarter turn from the second handle towards the third", Angular, c, (1.0, 0.5), (0.5, 1.0), (100.0, 100.0), (50, 90), 0.25),
|
||||
("angular: half a turn", Angular, c, (1.0, 0.5), (0.5, 1.0), (100.0, 100.0), (10, 50), 0.5),
|
||||
("angular: three quarters", Angular, c, (1.0, 0.5), (0.5, 1.0), (100.0, 100.0), (50, 10), 0.75),
|
||||
("angular: the third handle on the other side turns it the other way", Angular, c, (1.0, 0.5), (0.5, 0.0), (100.0, 100.0), (50, 90), 0.75),
|
||||
("diamond: t is the sum of the distances along both axes", Diamond, c, (1.0, 0.5), (0.5, 1.0), (100.0, 100.0), (75, 50), 0.52),
|
||||
("diamond: on the diagonal", Diamond, c, (1.0, 0.5), (0.5, 1.0), (100.0, 100.0), (62, 62), 0.5),
|
||||
("diamond: past the edge, the last stop", Diamond, c, (1.0, 0.5), (0.5, 1.0), (100.0, 100.0), (2, 2), 1.0),
|
||||
];
|
||||
for (why, kind, from, to, width, (w, h), (x, y), t) in rows {
|
||||
let (mut d, p) = page();
|
||||
rect(&mut d, p, (0.0, 0.0, w, h), vec![black_to_white(kind, from, to, width)]);
|
||||
let got = px(&render(&d), x, y);
|
||||
assert!(near(got, grey(t), 4), "{why}: {got:?} want {:?}", grey(t));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_stops_rules() {
|
||||
let stop = |position: f32, c: &str| ColorStop { position, color: hex(c) };
|
||||
let lin = |stops: Vec<ColorStop>| Paint::gradient(Gradient { stops, ..Gradient::default_for(GradientKind::Linear, Color::BLACK) });
|
||||
let horizontal = |mut p: Paint| {
|
||||
if let PaintKind::Gradient(g) = &mut p.kind {
|
||||
(g.from, g.to, g.width) = ((0.0, 0.5), (1.0, 0.5), (0.0, 1.0));
|
||||
}
|
||||
p
|
||||
};
|
||||
// (why, paint on a 100 × 100 box, pixel, colour there)
|
||||
let rows: Vec<(&str, Paint, Px, [u8; 3])> = vec![
|
||||
(
|
||||
"stops are used in position order, whatever order they're listed in",
|
||||
horizontal(lin(vec![stop(1.0, "0000FF"), stop(0.0, "FF0000")])),
|
||||
(0, 50),
|
||||
[254, 0, 1],
|
||||
),
|
||||
(
|
||||
"colours mix in the stored (gamma-encoded) RGB values; silent on the space, pinned",
|
||||
horizontal(lin(vec![stop(0.0, "FF0000"), stop(1.0, "0000FF")])),
|
||||
(49, 50),
|
||||
[128, 0, 127],
|
||||
),
|
||||
(
|
||||
"two stops at one position make a hard edge (before)",
|
||||
horizontal(lin(vec![stop(0.0, "FF0000"), stop(0.5, "FF0000"), stop(0.5, "0000FF"), stop(1.0, "0000FF")])),
|
||||
(45, 50),
|
||||
[255, 0, 0],
|
||||
),
|
||||
(
|
||||
"two stops at one position make a hard edge (after)",
|
||||
horizontal(lin(vec![stop(0.0, "FF0000"), stop(0.5, "FF0000"), stop(0.5, "0000FF"), stop(1.0, "0000FF")])),
|
||||
(55, 50),
|
||||
[0, 0, 255],
|
||||
),
|
||||
(
|
||||
"a paint's opacity fades every stop",
|
||||
Paint { opacity: 0.5, ..horizontal(lin(vec![stop(0.0, "000000"), stop(1.0, "000000")])) },
|
||||
(50, 50),
|
||||
[128, 128, 128],
|
||||
),
|
||||
("a stop's own alpha fades the colour there", horizontal(lin(vec![stop(0.0, "FF000080"), stop(1.0, "FF000080")])), (50, 50), [255, 127, 127]),
|
||||
];
|
||||
for (why, paint, (x, y), want) in rows {
|
||||
let (mut d, p) = page();
|
||||
rect(&mut d, p, (0.0, 0.0, 100.0, 100.0), vec![paint]);
|
||||
let got = px(&render(&d), x, y);
|
||||
assert!(near(got, want, 3), "{why}: {got:?} want {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adding_a_stop_takes_the_colour_there_so_the_gradient_looks_the_same() {
|
||||
let g = Gradient {
|
||||
stops: vec![ColorStop { position: 0.0, color: hex("FF0000") }, ColorStop { position: 1.0, color: hex("0000FF") }],
|
||||
from: (0.0, 0.5),
|
||||
to: (1.0, 0.5),
|
||||
width: (0.0, 1.0),
|
||||
kind: GradientKind::Linear,
|
||||
};
|
||||
let mut more = g.clone();
|
||||
more.stops.push(ColorStop { position: 0.3, color: g.sample(0.3) });
|
||||
let draw = |g: Gradient| {
|
||||
let (mut d, p) = page();
|
||||
rect(&mut d, p, (0.0, 0.0, 100.0, 100.0), vec![Paint::gradient(g)]);
|
||||
render(&d)
|
||||
};
|
||||
let (a, b) = (draw(g), draw(more));
|
||||
for x in (0..100).step_by(7) {
|
||||
assert!(near(px(&a, x, 50), px(&b, x, 50), 2), "x {x}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradients_stretch_with_the_layer() {
|
||||
// The handles are in the layer's unit box: a wider layer stretches the gradient with it.
|
||||
let paint = black_to_white(GradientKind::Linear, (0.0, 0.5), (1.0, 0.5), (0.0, 1.0));
|
||||
for (w, x) in [(50.0, 12), (100.0, 25), (200.0, 50)] {
|
||||
let (mut d, p) = page();
|
||||
rect(&mut d, p, (0.0, 0.0, w, 50.0), vec![paint.clone()]);
|
||||
let t = (f64::from(x) + 0.5) / w;
|
||||
assert!(near(px(&render(&d), x, 20), grey(t), 3), "width {w}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- images ----
|
||||
|
||||
/// A 20 × 10 image, red on the left half and blue on the right, as `name` in a fresh document.
|
||||
fn image_doc(name: &str) -> (Document, NodeId) {
|
||||
let mut img = Pixmap::new(20, 10).unwrap();
|
||||
for (i, c) in img.data_mut().chunks_exact_mut(4).enumerate() {
|
||||
c.copy_from_slice(if i % 20 < 10 { &[255, 0, 0, 255] } else { &[0, 0, 255, 255] });
|
||||
}
|
||||
let (mut d, p) = page();
|
||||
d.images.insert(name.into(), ImageData { bytes: std::sync::Arc::new(encode_png(&img).unwrap()), width: 20, height: 10 });
|
||||
(d, p)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_scale_modes_place_the_image() {
|
||||
use ImageScaleMode::*;
|
||||
const RED: [u8; 3] = [255, 0, 0];
|
||||
const BLUE: [u8; 3] = [0, 0, 255];
|
||||
const PAGE: [u8; 3] = [255, 255, 255];
|
||||
let crop = |x0, x1| Some(figcraft_color::ImagePaint::crop_from_rect(x0, 0.0, x1, 1.0));
|
||||
// (why, mode, rotation, scale, crop, box, probes). The 20 × 10 image on a box at (0, 0).
|
||||
type Row<'a> = (&'a str, ImageScaleMode, i32, f64, Option<[f64; 6]>, Pt, Probes);
|
||||
let rows: Vec<Row> = vec![
|
||||
(
|
||||
"fill covers the box to its edges, centred, cropping the overflow (×10: 200 × 100, 50 cut each side)",
|
||||
Fill,
|
||||
0,
|
||||
1.0,
|
||||
None,
|
||||
(100.0, 100.0),
|
||||
vec![((25, 50), RED), ((75, 50), BLUE), ((10, 0), RED), ((0, 50), RED), ((99, 99), BLUE)],
|
||||
),
|
||||
(
|
||||
"fit shows the whole image, centred, with empty bands (×5: 100 × 50 at y 25)",
|
||||
Fit,
|
||||
0,
|
||||
1.0,
|
||||
None,
|
||||
(100.0, 100.0),
|
||||
vec![((50, 10), PAGE), ((25, 50), RED), ((75, 50), BLUE), ((50, 90), PAGE)],
|
||||
),
|
||||
(
|
||||
"tile repeats the image at its own size from the top-left",
|
||||
Tile,
|
||||
0,
|
||||
1.0,
|
||||
None,
|
||||
(40.0, 10.0),
|
||||
vec![((5, 5), RED), ((15, 5), BLUE), ((25, 5), RED), ((35, 5), BLUE)],
|
||||
),
|
||||
(
|
||||
"tile scale sizes the tiles (50%: 10 × 5)",
|
||||
Tile,
|
||||
0,
|
||||
0.5,
|
||||
None,
|
||||
(40.0, 10.0),
|
||||
vec![((2, 2), RED), ((7, 2), BLUE), ((12, 7), RED), ((17, 7), BLUE)],
|
||||
),
|
||||
(
|
||||
"crop shows the part of the image framed by the crop (its left half)",
|
||||
Crop,
|
||||
0,
|
||||
1.0,
|
||||
crop(0.0, 2.0),
|
||||
(100.0, 100.0),
|
||||
vec![((10, 50), RED), ((90, 50), RED)],
|
||||
),
|
||||
(
|
||||
"crop shows the part of the image framed by the crop (its right half)",
|
||||
Crop,
|
||||
0,
|
||||
1.0,
|
||||
crop(-1.0, 1.0),
|
||||
(100.0, 100.0),
|
||||
vec![((10, 50), BLUE), ((90, 50), BLUE)],
|
||||
),
|
||||
(
|
||||
"a quarter turn turns the image clockwise before it is fitted (silent on the direction, pinned)",
|
||||
Fill,
|
||||
1,
|
||||
1.0,
|
||||
None,
|
||||
(100.0, 100.0),
|
||||
vec![((50, 25), RED), ((50, 75), BLUE)],
|
||||
),
|
||||
("half a turn swaps the halves", Fill, 2, 1.0, None, (100.0, 100.0), vec![((25, 50), BLUE), ((75, 50), RED)]),
|
||||
];
|
||||
for (i, (why, mode, rotation, scale, crop, (w, h), probes)) in rows.into_iter().enumerate() {
|
||||
let (mut d, p) = image_doc("pb-image");
|
||||
let mut paint = Paint::image("pb-image", mode);
|
||||
if let PaintKind::Image(img) = &mut paint.kind {
|
||||
(img.rotation, img.scale, img.crop) = (rotation, scale, crop);
|
||||
}
|
||||
rect(&mut d, p, (0.0, 0.0, w, h), vec![paint]);
|
||||
let pix = render(&d);
|
||||
for ((x, y), want) in probes {
|
||||
let got = px(&pix, x, y);
|
||||
assert!(near(got, want, 6), "row {i} {why} at ({x}, {y}): {got:?} want {want:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_opacity_and_adjustments() {
|
||||
let (mut d, p) = image_doc("pb-opacity");
|
||||
rect(&mut d, p, (0.0, 0.0, 100.0, 100.0), vec![Paint { opacity: 0.5, ..Paint::image("pb-opacity", ImageScaleMode::Fill) }]);
|
||||
assert!(near(px(&render(&d), 25, 50), [255, 128, 128], 2), "an image paint's opacity lets the page through");
|
||||
// The adjustments' maths is not documented: only their direction is checked.
|
||||
let adjust = |c: &str, f: ImageFilters| {
|
||||
let mut p = Pixmap::new(1, 1).unwrap();
|
||||
p.fill(paint::ts_color(hex(c)));
|
||||
images::apply_filters(&mut p, &f);
|
||||
[p.data()[0], p.data()[1], p.data()[2]]
|
||||
};
|
||||
let none = ImageFilters::NONE;
|
||||
type Check = fn([u8; 3], [u8; 3]) -> bool;
|
||||
let rows: Vec<(&str, &str, ImageFilters, Check)> = vec![
|
||||
("no adjustment leaves the pixels alone", "CC6633", none, |a, b| a == b),
|
||||
("exposure up brightens", "808080", ImageFilters { exposure: 0.5, ..none }, |a, b| a[0] > b[0]),
|
||||
("exposure down darkens", "808080", ImageFilters { exposure: -0.5, ..none }, |a, b| a[0] < b[0]),
|
||||
("contrast up darkens the darks", "404040", ImageFilters { contrast: 0.5, ..none }, |a, b| a[0] < b[0]),
|
||||
("contrast up brightens the lights", "C0C0C0", ImageFilters { contrast: 0.5, ..none }, |a, b| a[0] > b[0]),
|
||||
("saturation -100% is greyscale", "CC6633", ImageFilters { saturation: -1.0, ..none }, |a, _| {
|
||||
a[0].abs_diff(a[1]) <= 1 && a[1].abs_diff(a[2]) <= 1
|
||||
}),
|
||||
("warmer: more red, less blue", "808080", ImageFilters { temperature: 1.0, ..none }, |a, b| a[0] > b[0] && a[2] < b[2]),
|
||||
("cooler: less red, more blue", "808080", ImageFilters { temperature: -1.0, ..none }, |a, b| a[0] < b[0] && a[2] > b[2]),
|
||||
("highlights up brighten the lights", "D0D0D0", ImageFilters { highlights: 1.0, ..none }, |a, b| a[0] > b[0]),
|
||||
("shadows up brighten the darks", "303030", ImageFilters { shadows: 1.0, ..none }, |a, b| a[0] > b[0]),
|
||||
];
|
||||
for (why, c, f, ok) in rows {
|
||||
let (got, was) = (adjust(c, f), rgb(c));
|
||||
assert!(ok(got, was), "{why}: {was:?} -> {got:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- colour models ----
|
||||
|
||||
#[test]
|
||||
fn colour_models_agree() {
|
||||
// (hex, HSB as shown: degrees and percent, HSL as shown)
|
||||
let rows = [
|
||||
("FF0000", (0, 100, 100), (0, 100, 50)),
|
||||
("00FF00", (120, 100, 100), (120, 100, 50)),
|
||||
("0000FF", (240, 100, 100), (240, 100, 50)),
|
||||
("808080", (0, 0, 50), (0, 0, 50)),
|
||||
("FFFFFF", (0, 0, 100), (0, 0, 100)),
|
||||
("000000", (0, 0, 0), (0, 0, 0)),
|
||||
("0D99FF", (205, 95, 100), (205, 100, 53)),
|
||||
("CC6633", (20, 75, 80), (20, 60, 50)),
|
||||
];
|
||||
let pct = |v: f32| (v * 100.0).round() as i32;
|
||||
for (h, hsb, hsl) in rows {
|
||||
let c = hex(h);
|
||||
let b = Hsb::from_color(c);
|
||||
let l = Hsl::from_color(c);
|
||||
assert_eq!((b.h.round() as i32, pct(b.s), pct(b.b)), hsb, "{h} as HSB");
|
||||
assert_eq!((l.h.round() as i32, pct(l.s), pct(l.l)), hsl, "{h} as HSL");
|
||||
assert_eq!(b.to_color().to_hex(), h, "{h} back from HSB");
|
||||
assert_eq!(l.to_color().to_hex(), h, "{h} back from HSL");
|
||||
}
|
||||
// Every colour on a 4-bit grid survives a trip through both models.
|
||||
for i in 0..4096u32 {
|
||||
let c = Color::from_rgba8(((i >> 8) * 17) as u8, (((i >> 4) & 15) * 17) as u8, ((i & 15) * 17) as u8, 255);
|
||||
assert_eq!(Hsb::from_color(c).to_color().to_hex(), c.to_hex());
|
||||
assert_eq!(Hsl::from_color(c).to_color().to_hex(), c.to_hex());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_entry_and_alpha_rounding() {
|
||||
// (typed, hex shown, opacity shown in percent)
|
||||
let rows = [
|
||||
("F", Some(("FFFFFF", 100))),
|
||||
("AB", Some(("ABABAB", 100))),
|
||||
("ABC", Some(("AABBCC", 100))),
|
||||
("#0d99ff", Some(("0D99FF", 100))),
|
||||
("0D99FF80", Some(("0D99FF", 50))),
|
||||
("0D99FF7F", Some(("0D99FF", 50))),
|
||||
("0D99FF00", Some(("0D99FF", 0))),
|
||||
("0D99FF03", Some(("0D99FF", 1))),
|
||||
("GG", None),
|
||||
("12345", None),
|
||||
];
|
||||
for (typed, want) in rows {
|
||||
let got = Color::from_hex(typed).map(|c| (c.to_hex(), (c.a * 100.0).round() as i32));
|
||||
assert_eq!(got, want.map(|(h, a)| (h.to_string(), a)), "{typed}");
|
||||
}
|
||||
// A fill typed with alpha keeps the colour opaque and moves the alpha to the paint's opacity.
|
||||
let p = Paint::solid(hex("FF000080"));
|
||||
assert_eq!((p.solid_color().map(|c| c.to_hex()), (p.opacity * 100.0).round()), (Some("FF0000".into()), 50.0));
|
||||
}
|
||||
|
||||
// ---- colour profile ----
|
||||
|
||||
#[test]
|
||||
fn changing_the_colour_profile_converts_or_keeps_values() {
|
||||
let draw = |d: &Document| {
|
||||
let mut pix = render(d);
|
||||
to_srgb(&mut pix, d.color_profile);
|
||||
px(&pix, 50, 50)
|
||||
};
|
||||
let (mut d, p) = page();
|
||||
rect(&mut d, p, (20.0, 20.0, 60.0, 60.0), vec![Paint::solid(hex("CC6633"))]);
|
||||
let before = draw(&d);
|
||||
// Converting keeps the look: the values change so the colour seen stays.
|
||||
let mut conv = d.clone();
|
||||
conv.set_color_profile(ColorProfile::DisplayP3, true);
|
||||
assert!(near(draw(&conv), before, 2), "converted: {:?} vs {before:?}", draw(&conv));
|
||||
// Keeping the values reinterprets them in the wider space: the colour seen changes.
|
||||
let mut kept = d.clone();
|
||||
kept.set_color_profile(ColorProfile::DisplayP3, false);
|
||||
let r = kept.node(kept.node(p).unwrap().children[0]).unwrap();
|
||||
assert_eq!(r.fills[0].solid_color().map(|c| c.to_hex()), Some("CC6633".into()));
|
||||
let seen = draw(&kept);
|
||||
assert!(seen[0] > before[0] && seen[2] < before[2], "kept values look more saturated: {seen:?} vs {before:?}");
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ use std::collections::HashMap;
|
|||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use figcraft_doc::{Document, Node, NodeKind, SPREAD_MITER_LIMIT, Shadow};
|
||||
use figcraft_doc::{Document, EffectKind, Node, NodeKind, SPREAD_MITER_LIMIT, Shadow};
|
||||
use figcraft_geom::{Affine, BezPath};
|
||||
use tiny_skia::{FillRule, Pixmap, PixmapPaint, Transform};
|
||||
|
||||
|
|
@ -29,7 +29,18 @@ pub type Outline = (BezPath, FillRule);
|
|||
/// (when a fill shows) and its stroke area. `None` when the layer's pixels aren't those outlines:
|
||||
/// groups, frames that don't clip their children or show no fill, text with a stroke.
|
||||
pub fn silhouette(doc: &Document, n: &Node) -> Option<Vec<Outline>> {
|
||||
let has_fill = n.fills.iter().any(|f| f.visible && f.opacity > 0.0);
|
||||
outlines(doc, n, |f| f.visible && f.opacity > 0.0)
|
||||
}
|
||||
|
||||
/// The area a node's paints cover, in local space: the outline of its visible fills (translucent
|
||||
/// or at 0% too) and its stroke area; what hides its drop shadows unless they show behind
|
||||
/// transparent areas. `None` where [`silhouette`] is.
|
||||
pub fn cover(doc: &Document, n: &Node) -> Option<Vec<Outline>> {
|
||||
outlines(doc, n, |f| f.visible)
|
||||
}
|
||||
|
||||
fn outlines(doc: &Document, n: &Node, fill_counts: fn(&figcraft_color::Paint) -> bool) -> Option<Vec<Outline>> {
|
||||
let has_fill = n.fills.iter().any(fill_counts);
|
||||
let is_bool = matches!(n.kind, NodeKind::BooleanOp { .. });
|
||||
let is_text = matches!(n.kind, NodeKind::Text(_));
|
||||
let kids = !is_bool && n.children.iter().any(|c| doc.node(*c).is_some_and(|c| c.visible));
|
||||
|
|
@ -142,17 +153,44 @@ pub struct Caster<'a> {
|
|||
pub xf: Affine,
|
||||
/// Device pixels per document unit.
|
||||
pub scale: f64,
|
||||
/// See [`silhouette`]; needed only for spread.
|
||||
pub silhouette: Option<&'a [Outline]>,
|
||||
/// See [`silhouette`]; only when a shadow spreads.
|
||||
pub silhouette: Option<Vec<Outline>>,
|
||||
/// The node's [`cover`] in the layer's pixels; only when a shadow hides behind the layer.
|
||||
pub cover: Option<Pixmap>,
|
||||
}
|
||||
|
||||
fn drop_shadows(n: &Node) -> impl Iterator<Item = &Shadow> {
|
||||
n.effects.iter().filter(|e| e.visible).filter_map(|e| if let EffectKind::DropShadow(s) = &e.kind { Some(s) } else { None })
|
||||
}
|
||||
|
||||
/// What hides node `n`'s drop shadows that don't show behind transparent areas: its [`cover`],
|
||||
/// or `None` when its pixels do that (layers that aren't one outline; effects that move the
|
||||
/// pixels past the paints) or no shadow hides.
|
||||
pub fn hiding_cover(doc: &Document, n: &Node) -> Option<Vec<Outline>> {
|
||||
let moved = n.effects.iter().any(|e| matches!(e.kind, EffectKind::LayerBlur(_) | EffectKind::Texture(_)) && e.reach() > 0.0);
|
||||
if moved || !drop_shadows(n).any(|s| !s.show_behind) {
|
||||
return None;
|
||||
}
|
||||
cover(doc, n)
|
||||
}
|
||||
|
||||
impl<'a> Caster<'a> {
|
||||
/// What casts node `n`'s drop shadows from its `layer` (local → pixels through `xf`).
|
||||
pub fn new(doc: &Document, n: &Node, layer: &'a Pixmap, xf: Affine, scale: f64) -> Self {
|
||||
let silhouette = if drop_shadows(n).any(|s| s.spread != 0.0) { silhouette(doc, n) } else { None };
|
||||
let cover = hiding_cover(doc, n).and_then(|c| spread_alpha(&c, 0.0, xf, layer.width(), layer.height()));
|
||||
Caster { layer, xf, scale, silhouette, cover }
|
||||
}
|
||||
}
|
||||
|
||||
/// A drop shadow in the layer's pixels: offset, spread, blurred, tinted and (unless it shows
|
||||
/// behind the layer) hidden behind the layer's own pixels.
|
||||
/// behind the layer) hidden behind the layer: behind what its paints cover, translucent parts
|
||||
/// included, or (layers that aren't one outline) behind its pixels.
|
||||
pub fn drop_shadow(c: &Caster, s: &Shadow) -> Option<Pixmap> {
|
||||
let (w, h) = (c.layer.width(), c.layer.height());
|
||||
let (dx, dy) = (s.offset.0 * c.scale, s.offset.1 * c.scale);
|
||||
let spread = if s.spread.is_finite() { s.spread } else { 0.0 };
|
||||
let mut sh = match c.silhouette {
|
||||
let mut sh = match c.silhouette.as_deref() {
|
||||
Some(sil) if spread != 0.0 && dx.is_finite() && dy.is_finite() => spread_alpha(sil, spread, Affine::translate((dx, dy)) * c.xf, w, h)?,
|
||||
_ => {
|
||||
let mut base = c.layer.clone();
|
||||
|
|
@ -165,7 +203,7 @@ pub fn drop_shadow(c: &Caster, s: &Shadow) -> Option<Pixmap> {
|
|||
fx::blur(&mut sh, s.radius / 2.0 * c.scale);
|
||||
fx::tint(&mut sh, color4(s.color));
|
||||
if !s.show_behind {
|
||||
fx::mask_by(&mut sh, c.layer, true);
|
||||
fx::mask_by(&mut sh, c.cover.as_ref().unwrap_or(c.layer), true);
|
||||
}
|
||||
Some(sh)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,9 @@ fn drop_shadow_spread_offsets_the_outline() {
|
|||
let svg = export(&d, n);
|
||||
assert!(!svg.contains("feMorphology"), "{svg}");
|
||||
assert!(svg.contains("stroke-width=\"12\"") && svg.contains("stroke-linejoin=\"miter\"") && svg.contains("stroke-miterlimit=\"4\""), "{svg}");
|
||||
assert!(svg.contains("<feGaussianBlur stdDeviation=\"4\"/>"), "{svg}");
|
||||
assert!(svg.contains("<feGaussianBlur stdDeviation=\"4\" result=\"b\"/>"), "{svg}");
|
||||
// Hidden behind the layer: cut out by an image of its outline.
|
||||
assert!(svg.contains("<feComposite in=\"b\" in2=\"cover\" operator=\"out\"/>"), "{svg}");
|
||||
assert!(svg.contains("opacity=\"0.502\""), "{svg}");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -60,6 +60,15 @@ struct W<'a> {
|
|||
opts: &'a SvgOptions,
|
||||
/// The mask layer being drawn as a mask's content (drawn like any layer, not skipped).
|
||||
mask: Option<NodeId>,
|
||||
/// The clip path of the clipping frames around the layers being written. A clipped group
|
||||
/// isolates what it holds (it is a stacking context), so clips go on what blends instead: a
|
||||
/// layer that isolates anyway, or each paint and child of a plain group.
|
||||
clip: Option<String>,
|
||||
}
|
||||
|
||||
/// ` clip-path="url(#…)"` for a clip path id (else nothing).
|
||||
fn clip_attr(clip: &Option<String>) -> String {
|
||||
clip.as_ref().map(|c| format!(" clip-path=\"url(#{c})\"")).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn num(v: f64) -> String {
|
||||
|
|
@ -260,7 +269,7 @@ impl W<'_> {
|
|||
|
||||
/// The layer's filter: drop shadows (but those drawn as `exact` geometry: see
|
||||
/// [`W::drop_shadows`]) and layer blur.
|
||||
fn filter_for(&mut self, n: &Node, exact: bool) -> Option<String> {
|
||||
fn filter_for(&mut self, n: &Node, exact: bool, xf: Affine) -> Option<String> {
|
||||
let fx: Vec<&figcraft_doc::Effect> = n
|
||||
.effects
|
||||
.iter()
|
||||
|
|
@ -272,7 +281,30 @@ impl W<'_> {
|
|||
return None;
|
||||
}
|
||||
let fid = self.id("filter");
|
||||
let mut f = format!("<filter id=\"{fid}\" x=\"-50%\" y=\"-50%\" width=\"200%\" height=\"200%\" color-interpolation-filters=\"sRGB\">");
|
||||
// The region holds everything the layer and its effects paint (a fixed share of the
|
||||
// layer's box cuts off shadows and blurs that reach further).
|
||||
let k = xf.determinant().abs().sqrt();
|
||||
let m = figcraft_render::draw::effect_margin(n) * if k.is_finite() { k } else { 1.0 };
|
||||
let region = self
|
||||
.doc
|
||||
.world_render_bounds(n.id)
|
||||
.map(|b| figcraft_geom::transform_rect_bbox(xf * self.doc.world_transform(n.id).inverse(), b).inflate(m, m))
|
||||
.filter(|r| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()));
|
||||
let mut f = match region {
|
||||
Some(r) => format!(
|
||||
"<filter id=\"{fid}\" filterUnits=\"userSpaceOnUse\" x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" color-interpolation-filters=\"sRGB\">",
|
||||
num(r.x0),
|
||||
num(r.y0),
|
||||
num(r.width()),
|
||||
num(r.height())
|
||||
),
|
||||
None => format!("<filter id=\"{fid}\" x=\"-50%\" y=\"-50%\" width=\"200%\" height=\"200%\" color-interpolation-filters=\"sRGB\">"),
|
||||
};
|
||||
// Shadows that don't show behind transparent areas are cut out where the layer is: by
|
||||
// what its paints cover, or by its pixels (like the renderer).
|
||||
let cover = self.cover_input(n, xf);
|
||||
let knock = if cover.is_some() { "cover" } else { "SourceAlpha" };
|
||||
f.push_str(cover.as_deref().unwrap_or_default());
|
||||
let mut merge = Vec::new();
|
||||
for (i, e) in fx.iter().enumerate() {
|
||||
match &e.kind {
|
||||
|
|
@ -290,14 +322,19 @@ impl W<'_> {
|
|||
let src = if morph.is_empty() { "SourceAlpha".to_string() } else { format!("s{i}m") };
|
||||
let _ = write!(
|
||||
f,
|
||||
"{morph}<feOffset in=\"{src}\" dx=\"{}\" dy=\"{}\"/><feGaussianBlur stdDeviation=\"{}\"/><feColorMatrix type=\"matrix\" values=\"0 0 0 0 {} 0 0 0 0 {} 0 0 0 0 {} 0 0 0 {} 0\" result=\"s{i}\"/>",
|
||||
"{morph}<feOffset in=\"{src}\" dx=\"{}\" dy=\"{}\"/><feGaussianBlur stdDeviation=\"{}\"/><feColorMatrix type=\"matrix\" values=\"0 0 0 0 {} 0 0 0 0 {} 0 0 0 0 {} 0 0 0 {} 0\" result=\"s{i}\"/>{}",
|
||||
num(s.offset.0),
|
||||
num(s.offset.1),
|
||||
num(s.radius / 2.0),
|
||||
num(f64::from(s.color.r)),
|
||||
num(f64::from(s.color.g)),
|
||||
num(f64::from(s.color.b)),
|
||||
num(f64::from(s.color.a))
|
||||
num(f64::from(s.color.a)),
|
||||
if s.show_behind {
|
||||
String::new()
|
||||
} else {
|
||||
format!("<feComposite in=\"s{i}\" in2=\"{knock}\" operator=\"out\" result=\"s{i}\"/>")
|
||||
}
|
||||
);
|
||||
merge.push(format!("s{i}"));
|
||||
}
|
||||
|
|
@ -338,18 +375,28 @@ impl W<'_> {
|
|||
let blend = blend_style(n.blend);
|
||||
if !blend.is_empty() {
|
||||
attrs.push_str(&blend);
|
||||
} else if !grains.is_empty() {
|
||||
// Grains blend with the layer only, like the canvas's isolated layer.
|
||||
} else if !grains.is_empty() || n.isolates_children() {
|
||||
// Grains blend with the layer only, like the canvas's isolated layer; a Normal group
|
||||
// blends its children among themselves first.
|
||||
attrs.push_str(" style=\"isolation:isolate\"");
|
||||
}
|
||||
let inherited = self.clip.clone();
|
||||
let spreads = n.effects.iter().any(|e| e.visible && matches!(&e.kind, EffectKind::DropShadow(s) if s.spread != 0.0));
|
||||
let sil = if spreads { figcraft_render::shadow::silhouette(self.doc, n) } else { None };
|
||||
if let Some(sil) = &sil {
|
||||
self.drop_shadows(n, sil, xf);
|
||||
self.drop_shadows(n, sil, xf, &clip_attr(&inherited));
|
||||
}
|
||||
if let Some(fid) = self.filter_for(n, sil.is_some()) {
|
||||
if let Some(fid) = self.filter_for(n, sil.is_some(), xf) {
|
||||
let _ = write!(attrs, " filter=\"url(#{fid})\"");
|
||||
}
|
||||
let leaf = matches!(n.kind, NodeKind::BooleanOp { .. }) || n.children.is_empty();
|
||||
let isolated = n.opacity < 0.999 || attrs.contains(" style=") || attrs.contains(" filter=") || n.effects.iter().any(|e| e.visible);
|
||||
let (paint_clip, kid_clip) = if isolated || leaf {
|
||||
attrs.push_str(&clip_attr(&inherited));
|
||||
(String::new(), None)
|
||||
} else {
|
||||
(clip_attr(&inherited), inherited.clone())
|
||||
};
|
||||
let _ = write!(self.body, "<g{attrs}>");
|
||||
if backdrop::has_effects(n)
|
||||
&& let Some(img) = self.backdrop_image(id, None)
|
||||
|
|
@ -368,7 +415,7 @@ impl W<'_> {
|
|||
let fr = if *rule == figcraft_render::FillRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" };
|
||||
for p in &n.fills {
|
||||
if let Some(a) = self.paint_attr(p, w, h) {
|
||||
let _ = write!(self.body, "<path transform=\"{}\" d=\"{}\" {a}{fr}/>", matrix(xf), path_d(path));
|
||||
let _ = write!(self.body, "<path transform=\"{}\" d=\"{}\" {a}{fr}{paint_clip}/>", matrix(xf), path_d(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -381,14 +428,19 @@ impl W<'_> {
|
|||
}
|
||||
}
|
||||
if !matches!(n.kind, NodeKind::BooleanOp { .. }) && !n.children.is_empty() {
|
||||
let clip = n.clip && n.kind.is_frame_like();
|
||||
if clip && let Some((p, _)) = &outline {
|
||||
let mut kid_clip = kid_clip;
|
||||
if n.clip
|
||||
&& n.kind.is_frame_like()
|
||||
&& let Some((p, _)) = &outline
|
||||
{
|
||||
// Its own clip, within the clips around it.
|
||||
let cid = self.id("clip");
|
||||
let mut cp = p.clone();
|
||||
cp.apply_affine(xf);
|
||||
let _ = write!(self.defs, "<clipPath id=\"{cid}\"><path d=\"{}\"/></clipPath>", path_d(&cp));
|
||||
let _ = write!(self.body, "<g clip-path=\"url(#{cid})\">");
|
||||
let _ = write!(self.defs, "<clipPath id=\"{cid}\"{}><path d=\"{}\"/></clipPath>", clip_attr(&kid_clip), path_d(&cp));
|
||||
kid_clip = Some(cid);
|
||||
}
|
||||
self.clip = kid_clip;
|
||||
let kids = n.children.clone();
|
||||
for run in self.doc.paint_runs(n) {
|
||||
let Some(&c) = kids.get(run.start) else { continue };
|
||||
|
|
@ -413,7 +465,9 @@ impl W<'_> {
|
|||
let mid = self.id("mask");
|
||||
let saved = std::mem::take(&mut self.body);
|
||||
let outer = self.mask.replace(c);
|
||||
let clip = self.clip.take();
|
||||
self.node(c, xf, depth + 1);
|
||||
self.clip = clip;
|
||||
self.mask = outer;
|
||||
let mask_body = std::mem::replace(&mut self.body, saved);
|
||||
let alpha = if kind == figcraft_doc::MaskType::Alpha { " style=\"mask-type:alpha\"" } else { "" };
|
||||
|
|
@ -428,9 +482,7 @@ impl W<'_> {
|
|||
self.node(c, xf, depth + 1);
|
||||
}
|
||||
}
|
||||
if clip && outline.is_some() {
|
||||
self.body.push_str("</g>");
|
||||
}
|
||||
self.clip = inherited;
|
||||
}
|
||||
if n.has_visible_stroke() {
|
||||
let geom = match &n.kind {
|
||||
|
|
@ -443,7 +495,7 @@ impl W<'_> {
|
|||
let fr = if even { " fill-rule=\"evenodd\"" } else { "" };
|
||||
for p in &n.strokes {
|
||||
if let Some(a) = self.paint_attr(p, w, h) {
|
||||
let _ = write!(self.body, "<path transform=\"{}\" d=\"{}\" {a}{fr}/>", matrix(xf), path_d(&area));
|
||||
let _ = write!(self.body, "<path transform=\"{}\" d=\"{}\" {a}{fr}{paint_clip}/>", matrix(xf), path_d(&area));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -497,7 +549,7 @@ impl W<'_> {
|
|||
return false;
|
||||
};
|
||||
let back = if backdrop::has_effects(n) { self.backdrop_image(id, Some(n.opacity.clamp(0.0, 1.0))) } else { None };
|
||||
let _ = write!(self.body, "<g{id_attr}{}>{}{img}</g>", blend_style(n.blend), back.unwrap_or_default());
|
||||
let _ = write!(self.body, "<g{id_attr}{}{}>{}{img}</g>", blend_style(n.blend), clip_attr(&self.clip), back.unwrap_or_default());
|
||||
true
|
||||
}
|
||||
|
||||
|
|
@ -580,27 +632,72 @@ impl W<'_> {
|
|||
format!("<g mask=\"url(#{mid})\">{}</g>", paths(color, None))
|
||||
}
|
||||
|
||||
/// A Gaussian blur filter over `region` (page units); `None` for no blur.
|
||||
fn blur_filter(&mut self, radius: f64, region: Rect) -> Option<String> {
|
||||
if !(radius.is_finite() && radius > 0.0) {
|
||||
/// A Gaussian blur filter over `region` (page units), cut out by `cover` (a filter input
|
||||
/// named `cover`, see [`W::cover_input`]); `None` for no blur and no cover.
|
||||
fn blur_filter(&mut self, radius: f64, region: Rect, cover: Option<&str>) -> Option<String> {
|
||||
let blur = radius.is_finite() && radius > 0.0;
|
||||
if !blur && cover.is_none() {
|
||||
return None;
|
||||
}
|
||||
let fid = self.id("filter");
|
||||
let _ = write!(
|
||||
self.defs,
|
||||
"<filter id=\"{fid}\" filterUnits=\"userSpaceOnUse\" x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" color-interpolation-filters=\"sRGB\"><feGaussianBlur stdDeviation=\"{}\"/></filter>",
|
||||
"<filter id=\"{fid}\" filterUnits=\"userSpaceOnUse\" x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" color-interpolation-filters=\"sRGB\">",
|
||||
num(region.x0),
|
||||
num(region.y0),
|
||||
num(region.width()),
|
||||
num(region.height()),
|
||||
num(radius / 2.0)
|
||||
);
|
||||
if blur {
|
||||
let _ = write!(self.defs, "<feGaussianBlur stdDeviation=\"{}\" result=\"b\"/>", num(radius / 2.0));
|
||||
}
|
||||
if let Some(c) = cover {
|
||||
let _ = write!(self.defs, "{c}<feComposite in=\"{}\" in2=\"cover\" operator=\"out\"/>", if blur { "b" } else { "SourceGraphic" });
|
||||
}
|
||||
self.defs.push_str("</filter>");
|
||||
Some(fid)
|
||||
}
|
||||
|
||||
/// The area `n`'s paints cover, as a filter input named `cover` (an image of its outlines
|
||||
/// through `xf`), when its drop shadows hide behind it there (see
|
||||
/// [`shadow::hiding_cover`](figcraft_render::shadow::hiding_cover)).
|
||||
fn cover_input(&self, n: &Node, xf: Affine) -> Option<String> {
|
||||
let cover = figcraft_render::shadow::hiding_cover(self.doc, n)?;
|
||||
let b = cover
|
||||
.iter()
|
||||
.map(|(p, _)| figcraft_geom::transform_rect_bbox(xf, figcraft_geom::Shape::bounding_box(p)))
|
||||
.reduce(|a, b| a.union(b))?
|
||||
.inflate(1.0, 1.0);
|
||||
if !(b.width() > 0.0 && b.height() > 0.0 && [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite())) {
|
||||
return None;
|
||||
}
|
||||
let mut img = format!(
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"{} {} {} {}\" width=\"{}\" height=\"{}\">",
|
||||
num(b.x0),
|
||||
num(b.y0),
|
||||
num(b.width()),
|
||||
num(b.height()),
|
||||
num(b.width()),
|
||||
num(b.height())
|
||||
);
|
||||
for (p, rule) in &cover {
|
||||
let fr = if *rule == figcraft_render::FillRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" };
|
||||
let _ = write!(img, "<path transform=\"{}\" d=\"{}\"{fr}/>", matrix(xf), path_d(p));
|
||||
}
|
||||
img.push_str("</svg>");
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(img);
|
||||
Some(format!(
|
||||
"<feImage href=\"data:image/svg+xml;base64,{b64}\" x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" preserveAspectRatio=\"none\" result=\"cover\"/>",
|
||||
num(b.x0),
|
||||
num(b.y0),
|
||||
num(b.width()),
|
||||
num(b.height())
|
||||
))
|
||||
}
|
||||
|
||||
/// Drop shadows with spread, as geometry under the layer: its silhouette offset by the
|
||||
/// spread (exactly, like the renderer), moved, blurred and faded.
|
||||
fn drop_shadows(&mut self, n: &Node, sil: &[Outline], xf: Affine) {
|
||||
fn drop_shadows(&mut self, n: &Node, sil: &[Outline], xf: Affine, clip: &str) {
|
||||
for e in n.effects.iter().filter(|e| e.visible) {
|
||||
let EffectKind::DropShadow(s) = &e.kind else { continue };
|
||||
if s.spread == 0.0 || !(s.offset.0.is_finite() && s.offset.1.is_finite()) {
|
||||
|
|
@ -613,7 +710,9 @@ impl W<'_> {
|
|||
continue;
|
||||
};
|
||||
let reach = s.spread.abs() * SPREAD_MITER_LIMIT + s.radius.abs() * 1.5 + 2.0;
|
||||
let filter = self.blur_filter(s.radius, b.inflate(reach, reach)).map(|f| format!(" filter=\"url(#{f})\"")).unwrap_or_default();
|
||||
let cover = if s.show_behind { None } else { self.cover_input(n, xf) };
|
||||
let filter =
|
||||
self.blur_filter(s.radius, b.inflate(reach, reach), cover.as_deref()).map(|f| format!(" filter=\"url(#{f})\"")).unwrap_or_default();
|
||||
let op = f64::from(s.color.a * n.opacity.clamp(0.0, 1.0));
|
||||
let blend = if matches!(s.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) {
|
||||
String::new()
|
||||
|
|
@ -621,7 +720,7 @@ impl W<'_> {
|
|||
format!(" style=\"mix-blend-mode:{}\"", s.blend.css())
|
||||
};
|
||||
let body = self.spread_paths(sil, s.spread, at, &hex(s.color));
|
||||
let _ = write!(self.body, "<g opacity=\"{}\"{blend}><g{filter}>{body}</g></g>", num(op));
|
||||
let _ = write!(self.body, "<g opacity=\"{}\"{blend}{clip}><g{filter}>{body}</g></g>", num(op));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -651,7 +750,7 @@ impl W<'_> {
|
|||
num(r.height()),
|
||||
rect("white")
|
||||
);
|
||||
let filter = self.blur_filter(s.radius, r).map(|f| format!(" filter=\"url(#{f})\"")).unwrap_or_default();
|
||||
let filter = self.blur_filter(s.radius, r, None).map(|f| format!(" filter=\"url(#{f})\"")).unwrap_or_default();
|
||||
let _ = write!(
|
||||
self.body,
|
||||
"<g clip-path=\"url(#{cid})\"><g opacity=\"{}\"{filter}><g mask=\"url(#{mid})\">{}</g></g></g>",
|
||||
|
|
@ -679,7 +778,7 @@ fn sniff_mime(b: &[u8]) -> &'static str {
|
|||
pub fn export_svg(doc: &Document, ids: &[NodeId], opts: &SvgOptions) -> Option<String> {
|
||||
let bounds: Rect = ids.iter().filter_map(|i| figcraft_render::export_bounds(doc, *i)).reduce(|a, b| a.union(b))?;
|
||||
let origin = Affine::translate((-bounds.x0, -bounds.y0));
|
||||
let mut w = W { doc, roots: ids, page: origin, defs: String::new(), body: String::new(), next: 0, opts, mask: None };
|
||||
let mut w = W { doc, roots: ids, page: origin, defs: String::new(), body: String::new(), next: 0, opts, mask: None, clip: None };
|
||||
for id in ids {
|
||||
let parent_xf = doc.node(*id).and_then(|n| n.parent).map(|p| doc.world_transform(p)).unwrap_or(Affine::IDENTITY);
|
||||
w.node(*id, origin * parent_xf, 0);
|
||||
|
|
|
|||
|
|
@ -459,6 +459,19 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
|||
}
|
||||
});
|
||||
}
|
||||
// The paint's own blend mode, with the paints and layers under it.
|
||||
if !matches!(target, PickerTarget::Effect { .. } | PickerTarget::PageBackground | PickerTarget::Variable { .. }) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(16.0);
|
||||
let resp = widgets::dropdown(ui, tr(paint.blend.label()), 208.0, Some(Icon::Blend)).on_hover_text(tr("Blend mode"));
|
||||
crate::right::menu_popup(&resp).width(200.0).show(|ui| {
|
||||
if let Some(b) = crate::right::blend_menu(ui, false) {
|
||||
paint.blend = b;
|
||||
write(app, &target, &paint, false);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
// Gradient stops.
|
||||
let mut edit_color: Option<Color> = match &paint.kind {
|
||||
PaintKind::Solid { color } => Some(color.with_alpha(color.a * paint.opacity)),
|
||||
|
|
|
|||
|
|
@ -205,6 +205,21 @@ pub(crate) fn menu_style(s: &mut egui::Style) {
|
|||
/// (else its 600 px default area width) and keeps the size measured; rows ([`menu_row`]) take the
|
||||
/// larger of that width and their content, so the menu fits its content. Chain `.width(w)` for a
|
||||
/// menu at least `w` wide.
|
||||
/// The blend modes as menu items in groups (pass-through only for layers); the one picked.
|
||||
pub fn blend_menu(ui: &mut Ui, with_pass_through: bool) -> Option<BlendMode> {
|
||||
let mut picked = None;
|
||||
for b in BlendMode::ALL.into_iter().filter(|b| with_pass_through || *b != BlendMode::PassThrough) {
|
||||
if menu_item(ui, tr(b.label()), "") {
|
||||
picked = Some(b);
|
||||
ui.close();
|
||||
}
|
||||
if matches!(b, BlendMode::Normal | BlendMode::ColorBurn | BlendMode::ColorDodge | BlendMode::HardLight | BlendMode::Exclusion) {
|
||||
ui.separator();
|
||||
}
|
||||
}
|
||||
picked
|
||||
}
|
||||
|
||||
pub fn menu_popup(anchor: &egui::Response) -> egui::Popup<'static> {
|
||||
egui::Popup::menu(anchor).width(anchor.rect.width().max(MENU_MIN_W)).style(menu_style)
|
||||
}
|
||||
|
|
@ -829,14 +844,8 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
|||
let label = blend.map(|b| tr(b.label())).unwrap_or(tr("Mixed"));
|
||||
let resp = widgets::dropdown(ui, label, col * 2.0 + 8.0, Some(Icon::Blend));
|
||||
menu_popup(&resp).width(200.0).show(|ui| {
|
||||
for b in BlendMode::ALL {
|
||||
if menu_item(ui, tr(b.label()), "") {
|
||||
app.set_props(&ids, json!({"blend": b.css()}));
|
||||
ui.close();
|
||||
}
|
||||
if matches!(b, BlendMode::Normal | BlendMode::ColorBurn | BlendMode::ColorDodge | BlendMode::HardLight | BlendMode::Exclusion) {
|
||||
ui.separator();
|
||||
}
|
||||
if let Some(b) = blend_menu(ui, true) {
|
||||
app.set_props(&ids, json!({"blend": b.label()}));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
12
docs/mcp.md
12
docs/mcp.md
|
|
@ -103,6 +103,18 @@ Or in a project's `.mcp.json`:
|
|||
tools, `color` may also be a hex string and gradients can be written
|
||||
`{"type": "linearGradient", "stops": ["#7048E8", "#1098AD"]}`; the server converts them to the
|
||||
engine's paint JSON.
|
||||
- **Blend modes**: `blend` on a layer (`node.set`) or a paint (`fill.set` / `stroke.set`) is a
|
||||
menu label or CSS keyword (`"Normal"`, `"pass through"`, `"multiply"`, `"plusDarker"`, ...;
|
||||
`normal` is Normal). Groups and frames start as pass-through: their children blend with what is
|
||||
behind them, through clipping too (SVG exports put a clipping frame's clip on what it holds, since
|
||||
a clipped group would isolate it); Normal blends the children among themselves first (SVG
|
||||
`isolation:isolate`, a PDF isolated group). Plus darker is exact in renders and pixel exports;
|
||||
PDF has no such mode (multiply) and SVG writes the CSS `plus-darker` keyword. Inspecting a layer
|
||||
reports a paint's `blend` when it isn't normal.
|
||||
- **Drop shadows** hide behind what their layer covers, its fills (translucent and 0% ones too)
|
||||
and strokes, unless the effect has `showBehind: true`; groups, and layers whose pixels a layer
|
||||
blur or texture moves, hide them behind their pixels instead. SVG exports cut them out the same
|
||||
way, with filter regions as large as the effects reach.
|
||||
- **Colour profile**: colour values are in the file's profile, sRGB (default) or Display P3
|
||||
(`inspect_document` reports `colorProfile`). `file.colorProfile {profile: srgb|displayP3,
|
||||
convert?}` changes it: `convert` (default true) converts every colour value so colours keep their
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# FigCraft parity with Figma
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
|
||||
|
||||
The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md)
|
||||
summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is
|
||||
|
|
@ -21,7 +21,7 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c
|
|||
| Essentials user | **~57%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.92 × 0.88) |
|
||||
| Mainstream practitioner | **~48%** | estimated ([below](#mainstream-practitioner): weekly areas 69.1% × discounts 0.9 × 0.9 × 0.85) |
|
||||
| Remaining to beta | **~173–310 h** one Opus 5.5 agent (~35–70 h wall-clock with 5 parallel agents) | estimated |
|
||||
| Remaining to full parity | **~314–606 h** one agent (~86–150 h wall-clock with 5 parallel agents) | estimated |
|
||||
| Remaining to full parity | **~312–604 h** one agent (~86–150 h wall-clock with 5 parallel agents) | estimated |
|
||||
| Stage | **alpha**: the alpha gate passes ([roadmap](roadmap.md#alpha-gate)); multiplayer and checking the best-effort `.fig` import on real files are beta gaps (see [ROADMAP](../ROADMAP.md#stage)) | |
|
||||
|
||||
The previous figures (ROADMAP of 2026-10-10, "feature depth ~79%", "full parity 115–195 h") were
|
||||
|
|
@ -98,9 +98,9 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
|
|||
| 2 | Selection & transform | 8 | 97% | 72% | Handles, measuring and snapping checked tool by tool against Figma and real files (snap distance, handle reach, cursors); children turned off a right angle scale without skew, approximately (behaviour suite from the documentation, rotated groups and mixed selections, equal spacing snapping, Cmd/Ctrl to ignore constraints and marquees inside frames landed) | 3–5 |
|
||||
| 3 | Layers panel | 5 | 98% | 82% | QA with long real files (keyboard box selection, F2 rename, Shift+F10 context menu and 10k-layer reveal/expand/collapse/arrow timings landed) | 1–2 |
|
||||
| 4 | Shapes & vector | 8 | 97% | 68% | Vector QA with real files (live boolean editing, boolean hit testing on the result, arc fields, shape builder on strokes and open paths and one-sided widths landed) | 1–2 |
|
||||
| 5 | Paint | 7 | 90% | 67% | Video fills, wide-gamut canvas, HEIC images (animated GIF fills, TIFF images and embedded image colour profiles landed) | 3–6 |
|
||||
| 5 | Paint | 7 | 90% | 71% | Video fills, wide-gamut canvas, HEIC images; plus darker in PDF (behaviour suite from the documentation, pass-through and isolating groups, clipping frames that don't isolate, plus darker, animated GIF fills, TIFF images and embedded image colour profiles landed) | 2–5 |
|
||||
| 6 | Strokes | 3 | 95% | 75% | Stroke QA with real files (colour styles on strokes, stroke weight and per-side weight variables, brush strokes landed, see 20) | 1–2 |
|
||||
| 7 | Effects | 4 | 100% | 72% | Glass and progressive blur tuned side by side | 3–6 |
|
||||
| 7 | Effects | 4 | 100% | 75% | Glass and progressive blur tuned side by side; inner shadow and background blur on groups (behaviour suite from the documentation, shadows hidden behind translucent layers on the canvas and in every export landed) | 2–5 |
|
||||
| 8 | Frames, groups, sections, masks | 5 | 97% | 80% | Visual QA with real files; a one-step frame/group convert (behaviour suite from the documentation landed: masked-out areas don't take clicks, mask group bounds and exports follow the mask, sections kept out of frames and groups, frames and sections drawn around layers take them, section resizing leaves contents in place, X/Y measured in the frame) | 1–3 |
|
||||
| 9 | Auto layout & constraints | 9 | 98% | 74% | Behaviour against real Figma files; wrapped-line content alignment (behaviour suite from the documentation, `fr` tracks like CSS, vertical wrap, auto gaps evenly/around and cell alignment landed) ([auto-layout-parity](auto-layout-parity.md)) | 3–6 |
|
||||
| 10 | Text | 8 | 87% | 68% | Spell check, real-file text metrics, list spacing, hanging punctuation, underline styles (behaviour suite with CJK line breaking, vertical trim and carets by grapheme cluster landed) | 5–10 |
|
||||
|
|
@ -115,14 +115,14 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
|
|||
| 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4–8 |
|
||||
| 20 | Figma Draw extras | 2 | 80% | 55% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses). Missing: multi-edit, pressure from macOS/Linux tablets | 2–5 |
|
||||
| 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 |
|
||||
| | **Weighted (÷113)** | 113 | **~80%** | **~58%** | | **156–320** |
|
||||
| | Core editor only (1–16, ÷94) | 94 | ~91% | ~69% | | 51–101 |
|
||||
| | **Weighted (÷113)** | 113 | **~80%** | **~58%** | | **154–318** |
|
||||
| | Core editor only (1–16, ÷94) | 94 | ~91% | ~69% | | 49–99 |
|
||||
|
||||
## By dimension (ready for real work)
|
||||
|
||||
| Dimension | Weight | Ready | Hours | Evidence | Doc |
|
||||
|---|---|---|---|---|---|
|
||||
| Features (depth) | 35 | 57% | 53–103 (areas 1–13, 15, 16, 20) | The table above; core editor ~69% | this file |
|
||||
| Features (depth) | 35 | 57% | 51–101 (areas 1–13, 15, 16, 20) | The table above; core editor ~69% | this file |
|
||||
| UI/UX fidelity | 15 | 58% | 40–60 | Look & feel compared side by side with UI3 (~65% by the builders); tool-by-tool interaction (cursors, modifiers while dragging, hover states, popovers) not yet compared; accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) |
|
||||
| File formats | 12 | 55% | 8–15 | `.fig` read, best effort (tested on generated fixtures, not real files), no `.fig` write; REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid | [file-format-parity](file-format-parity.md) |
|
||||
| Collaboration | 10 | 13% | 55–110 | Local comments, version history and branches with review and merge; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) |
|
||||
|
|
@ -133,7 +133,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
|
|||
| Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) |
|
||||
| Ecosystem/plugins | 3 | 28% | 15–40 | Own WASM plugins with a Rust SDK and two examples (#101), plugin panels and settings (ABI v2); no Figma plugin compatibility, no widgets, no distribution or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) |
|
||||
| AI features | 2 | 0% | 30–60 | None | [gaps](gaps.md) |
|
||||
| **Ready for real work** | 100 | **~49%** | **~314–606** | | |
|
||||
| **Ready for real work** | 100 | **~49%** | **~312–604** | | |
|
||||
|
||||
Dimension hours add up to the full-parity total without double counting: the feature-area rows
|
||||
for import/export (14), multiplayer and team libraries (17, 19), plugins (18) and AI (21) are
|
||||
|
|
@ -550,7 +550,8 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
|
|||
diamond gradients, images with fill/fit/crop/tile + adjustments), multiple strokes with
|
||||
inside/center/outside alignment, per-side weights, dashes, caps/arrowheads, joins; effects (drop
|
||||
and inner shadow with spread that offsets the outline exactly: ellipses, stars, vectors, text;
|
||||
inner shadows over the fills and under the children and strokes; layer blur; background blur
|
||||
inner shadows over the fills and under the children and strokes; drop shadows hidden behind what
|
||||
the layer covers, translucent fills included, unless shown behind transparent areas; layer blur; background blur
|
||||
that sees the whole backdrop, inside clipping frames, faded groups and masks too; both blurs
|
||||
uniform or progressive (the radius ramps between two points, dragged on the canvas); noise in
|
||||
mono, duo and multi colour with grain size, density and blend mode; texture with clip to shape;
|
||||
|
|
@ -600,6 +601,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Paint and effects behaviour suite written from the public documentation (14 paint and 9 effects tests with table rows for fills, opacity, every blend mode by its formula, pass-through and Normal containers, gradient handles and stops, image scale modes and adjustments, colour models, the colour profile, shadows, blurs, effect order and exports; 3 command tests) and its fixes: groups and frames set to Normal blend their children among themselves first, and clipping frames no longer isolate their children (blend modes inside a clipping frame met only the frame's children, not its fill or what is behind it) — canvas, SVG (`isolation`; clips put on what blends, since a clipped group isolates) and PDF (isolated groups); plus darker is drawn (it was multiply) on the canvas and in PNG exports; drop shadows hide behind what the layer covers, translucent fills included, unless shown behind transparent areas, in SVG too (it never cut them out) and the SVG filter region reaches as far as the effects; Fill images cover the box to its edges (upscaled ones faded out at the edge); "Normal" names Normal, not pass-through, in commands and the blend menu (the menu's Normal set pass-through). Added: a blend menu for each paint in the colour picker. Paint 66% → 70%, 4–7 h → 3–6 h; effects 72% → 75%, 3–6 h → 2–5 h; percentages unchanged at this rounding; full parity ~321–618 h → ~319–616 h |
|
||||
| 2026-10-11 | minor | Frames, groups, sections and masks behaviour suite written from the public documentation (15 frame, group and section tests, 6 mask tests) and its fixes: a layer a mask hides can't be clicked there (clicks reach what's behind; Select layer agrees), a mask group's box and exports are the mask's (plus any layers under it), sections stay on the page or in sections (moves, drags, pastes, grouping and new sections in frames refused or kept on the page), drawing a frame or section around layers puts those fully inside it in it, resizing a section leaves its contents where they are (and a frame resized with Cmd/Ctrl held keeps its layers' sizes too), and X/Y of layers in groups are measured in the frame (groups have no coordinate space). Frames, groups, sections, masks 95%/75% → 97%/80%, 2–4 h → 1–3 h; percentages unchanged at this rounding; full parity ~323–621 h → ~322–620 h |
|
||||
| 2026-10-11 | minor | Styles and variables behaviour suite written from the public documentation (18 command-level tests) and its fixes: editing a styled fill, stroke, effect or layout grid list (directly, a paint row, an added fill or a bound variable) unlinks that style instead of being overwritten by the next style edit; applying a style replaces variables bound to what it sets; text styles go on text layers only; deleting a variable (or its collection) unbinds the layers using it, which keep their last value, and aliases to it take its values; bindings are type-checked (a colour variable can't drive a width) and number variables fill text content; aliases are checked when a variable is created. Added: duplicate, move (reorder, into a folder) and rename folder for styles, with a Local styles list grouped by type and folder (click to edit, right-click, drag to reorder); duplicate mode, set as default and move column for modes; per-platform code syntax (Web, iOS, Android) used by generated CSS and the variables JSON; font weight, paragraph spacing/indent and text content scopes for number variables. Found missing: variables inside styles. Styles & variables 92%/72% → 94%/76%, 1–2 h → 3–5 h; percentages unchanged at this rounding; full parity ~323–621 h → ~325–624 h |
|
||||
| 2026-10-11 | minor | Prototyping behaviour suite written from the public documentation (7 model tests, 12 player tests with 21 gestures) and its fixes: on drag starts only for a drag its animation's way and the animation follows the pointer, key combinations (`Shift+K`) fire (and are recorded and imported), an overlay's after-delay timer starts when it opens, instances of interactive components play their variant's interactions and "Change to" changes the right instance, spring presets are physical springs that play until they settle, smart animate overshoots with spring and back easings, fixed layers draw above scrolling content; added scroll to offsets. Prototyping 88%/70% → 90%/74%, 5–9 h → 3–6 h; mainstream weekly areas 68.1% → 68.4%, mainstream ~46% → ~47%; other percentages unchanged at this rounding; full parity ~320–615 h → ~318–612 h |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7)
|
||||
|
||||
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
|
||||
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
|
||||
|
|
@ -25,6 +25,7 @@ interaction feel not yet compared.
|
|||
| Keyboard shortcuts panel | done | docked, tabbed by group, searchable, per-platform keycaps |
|
||||
| Several windows | partial | File ▸ New window, Open in new window on a tab, a tab dragged off the strip; screen-reader tree in the main window only; positions not restored |
|
||||
| Multiplayer avatars, Share button | missing | waits for multiplayer |
|
||||
| Blend mode menus: the layer's (Design panel) and each paint's (colour picker) | done | Pass through and Normal are distinct (Normal isolates a group's or frame's children); paints have no pass-through; plus darker drawn exactly on the canvas |
|
||||
|
||||
## Selection and transform
|
||||
|
||||
|
|
@ -61,7 +62,7 @@ interaction feel not yet compared.
|
|||
| Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | |
|
||||
| Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | |
|
||||
| Text: point, auto height, fixed; caret, words, lines, IME | done | carets step over whole emoji and accented letters; CJK words by script run (no dictionary); vertical trim in Type settings; no spell check |
|
||||
| Eyedropper (I) | done | |
|
||||
| Eyedropper (I) | done | picks the colour seen under the pointer (opaque) into the selection's fill and the recent colours, one undo step (`fx_behaviour_tests`) |
|
||||
| Comment (C), Shift+C to show/hide | done | local only |
|
||||
| Text on a path (Figma Draw) | done | put on from the Design panel or `text.onPath`, not by clicking a path with the Text tool; the path is edited by resizing only |
|
||||
| Stretch and scatter brushes (Figma Draw) | done | eight presets drawn in code; the Pencil's brush is set by `tool.pencilBrush`, not from the toolbar |
|
||||
|
|
@ -102,6 +103,7 @@ interaction feel not yet compared.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Paint and effects behaviour suite: the blend menu's Normal sets Normal (it set pass-through), a blend menu for each paint in the colour picker, the eyedropper's rules covered by tests |
|
||||
| 2026-10-11 | minor | Frames, groups, sections and masks: drawing a frame or section around layers takes them, sections stay out of frames, section resizing leaves contents in place, masked-out areas don't take clicks, a mask group's box follows the mask, X/Y of layers in groups are measured in the frame |
|
||||
| 2026-10-11 | minor | Layers panel keyboard: F2 renames (also from a row with the keyboard, which gets it back), Shift+F10 opens the context menu at the focused row or below the canvas selection, F8 draws a selection box the arrows move and size; stroke weights bind to variables side by side |
|
||||
| 2026-10-11 | minor | Selection and transform behaviour suite and fixes: Alt shrinks, groups and mixed rotations scale each layer along its own axes, several layers flip together, whole-pixel resizes, small layers move from inside, locked layers stay put, marquees from a top-level frame's background, equal spacing snapping, Cmd/Ctrl ignores constraints while resizing a frame |
|
||||
|
|
|
|||
Loading…
Reference in New Issue