M3: progressive blur, inner shadows under strokes, noise and texture in SVG (#86)

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat 2026-10-10 17:20:07 +07:00 committed by GitHub
parent 36a7c6f892
commit 70b2666c90
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 1485 additions and 217 deletions

View File

@ -92,7 +92,7 @@
</td>
<td width="50%" valign="top">
<h3>Paints and effects</h3>
Solid, linear, radial, angular and diamond gradients with on-canvas handles, image fills (fill, fit, crop, tile) with adjustments and presets, multiple fills and strokes, every blend mode, drop and inner shadows (spread offsets the outline exactly), layer and background blur, noise (mono, duo, multi), texture and glass (refraction, dispersion, frost, light), masks. Files are sRGB or Display P3, with exports tagged with the file's colour profile.
Solid, linear, radial, angular and diamond gradients with on-canvas handles, image fills (fill, fit, crop, tile) with adjustments and presets, multiple fills and strokes, every blend mode, drop and inner shadows (spread offsets the outline exactly), layer and background blur (uniform or progressive), noise (mono, duo, multi), texture and glass (refraction, dispersion, frost, light), masks. Files are sRGB or Display P3, with exports tagged with the file's colour profile.
</td>
</tr>
<tr>

View File

@ -207,7 +207,7 @@ dimension measured side by side.
### Where we're going
- **Next:** the fidelity pass (7) and hardening (9: a real-file corpus, platform QA); effects:
inner shadows under strokes (render order), progressive blur, glass tuned side by side; the last
glass and progressive blur tuned side by side; the last
text details (right-to-left list markers) and auto layout details (grid `fr` rows around
fixed-size children).
- **Then:** interop depth (6: .fig best effort), plugins and multiplayer (8; comments and version
@ -226,10 +226,13 @@ dimension measured side by side.
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;
layer blur; background blur that sees the whole backdrop, inside clipping frames, faded groups
and masks too; noise in mono, duo and multi colour with grain size, density and blend mode;
texture with clip to shape; glass with light angle and intensity, refraction, depth, dispersion,
frost and splay; the same in PNG, PDF and SVG exports); corner radii with
inner shadows over the fills and under the children and strokes; 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;
glass with light angle and intensity, refraction, depth, dispersion, frost and splay; the same in
PNG, PDF and SVG exports, SVG embedding renders of noise grains, texture, progressive and
background blur and glass); corner radii with
smoothing; constraints; auto layout (horizontal, vertical, wrap, grid; hug/fill/fixed; min/max;
absolute children); layout grids; export settings; prototype interactions, flows and transitions;
paint/text/effect/grid styles; variables with collections, modes and aliases.
@ -276,7 +279,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
| Shapes & vector | 8 | 76% | live boolean editing, Design panel arc fields, shape builder on strokes and open paths, one-sided widths | 4–8 |
| Paint | 7 | 78% | a wide-gamut canvas (egui draws sRGB, so Display P3 colours outside sRGB clip on screen; exports keep them), images' own embedded colour profiles (images are taken as sRGB) | 1–3 |
| Strokes | 3 | 82% | stroke styles | 1–2 |
| Effects | 4 | 80% | inner shadows under strokes, progressive blur, noise and texture in SVG, glass tuned side by side | 3–5 |
| Effects | 4 | 84% | glass and progressive blur tuned side by side; SVG draws noise, texture, progressive and background blur as embedded images | 2–4 |
| Frames, groups, sections, masks | 5 | 80% | comparing what changed in ready-for-dev frames and sections, visual QA | 1–2 |
| Auto layout & constraints | 9 | 76% | grid `fr` rows around fixed-size children, QA with real-world files | 1–2 |
| Text | 8 | 78% | list markers on the right of right-to-left paragraphs, visual QA | 1–2 |

View File

@ -0,0 +1,52 @@
//! Blur settings: files from before progressive blur open as uniform blurs, progressive fields
//! round-trip (camel-case names too), hostile values are made safe, and reach covers the largest
//! radius.
use crate::*;
#[test]
fn old_blur_files_open_as_uniform() {
let e: Effect = serde_json::from_str(r#"{"type":"layerBlur","radius":6.0}"#).unwrap();
assert_eq!(e.kind, EffectKind::LayerBlur(Blur::uniform(6.0)));
let e: Effect = serde_json::from_str(r#"{"type":"backgroundBlur","radius":3.0,"visible":false}"#).unwrap();
assert_eq!((e.blur().map(|b| (b.radius, b.blur_type)), e.visible), (Some((3.0, BlurType::Uniform)), false));
}
#[test]
fn progressive_fields_round_trip() {
let e: Effect = serde_json::from_str(
r#"{"type":"layerBlur","radius":20,"blurType":"PROGRESSIVE","startRadius":2,"startOffset":[0.1,0.2],"endOffset":[0.9,0.8]}"#,
)
.unwrap();
let b = e.blur().unwrap();
assert_eq!((b.blur_type, b.radius, b.start_radius, b.start, b.end), (BlurType::Progressive, 20.0, 2.0, (0.1, 0.2), (0.9, 0.8)));
let back: Effect = serde_json::from_str(&serde_json::to_string(&e).unwrap()).unwrap();
assert_eq!(back, e);
// The snake-case names the file format writes, and "normal" as uniform.
let e: Effect = serde_json::from_str(r#"{"type":"backgroundBlur","radius":4,"blur_type":"normal","start_radius":9}"#).unwrap();
assert!(!e.blur().unwrap().is_progressive());
}
#[test]
fn hostile_blur_values_are_made_safe() {
let b =
Blur { radius: f64::INFINITY, blur_type: BlurType::Progressive, start_radius: -5.0, start: (f64::NAN, 1e9), end: (f64::NEG_INFINITY, 0.5) }
.sanitized();
assert_eq!((b.radius, b.start_radius), (0.0, 0.0));
assert_eq!((b.start, b.end), ((0.5, 1000.0), (0.5, 0.5)));
let huge = Blur { radius: 1e300, ..Blur::default() };
assert_eq!(huge.max_radius(), MAX_BLUR_RADIUS);
}
#[test]
fn reach_and_backdrop_cover_the_largest_radius() {
let b = Blur { radius: 2.0, blur_type: BlurType::Progressive, start_radius: 30.0, ..Blur::default() };
assert_eq!(b.max_radius(), 30.0);
// A uniform blur ignores the progressive start radius.
assert_eq!(Blur { blur_type: BlurType::Uniform, ..b.clone() }.max_radius(), 2.0);
assert_eq!(Effect { kind: EffectKind::LayerBlur(b.clone()), visible: true }.reach(), 45.0);
assert_eq!(Effect { kind: EffectKind::BackgroundBlur(b.clone()), visible: true }.backdrop_radius(), 30.0);
// Scaling scales both radii, never the unit-box points.
let s = b.scaled(2.0);
assert_eq!((s.radius, s.start_radius, s.start, s.end), (4.0, 60.0, b.start, b.end));
}

View File

@ -840,6 +840,8 @@ fn remap_action(a: &mut proto::Action, map: &BTreeMap<NodeId, NodeId>) {
}
}
#[cfg(test)]
mod blur_tests;
#[cfg(test)]
mod colors_tests;
#[cfg(test)]

View File

@ -491,18 +491,106 @@ impl Glass {
}
}
/// How a blur's radius varies across the layer.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum BlurType {
/// The same radius everywhere.
#[default]
#[serde(alias = "normal", alias = "NORMAL", alias = "UNIFORM")]
Uniform,
/// The radius ramps from `start_radius` at `start` to `radius` at `end`.
#[serde(alias = "PROGRESSIVE")]
Progressive,
}
impl BlurType {
pub const ALL: [BlurType; 2] = [BlurType::Uniform, BlurType::Progressive];
pub fn label(self) -> &'static str {
match self {
BlurType::Uniform => "Uniform",
BlurType::Progressive => "Progressive",
}
}
}
/// The largest blur radius drawn (document units); larger ones look the same anyway.
pub const MAX_BLUR_RADIUS: f64 = 10_000.0;
/// A layer or background blur: uniform, or progressive (the radius ramps linearly along a line
/// between two points in the layer's unit box, like a linear gradient's handles).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Blur {
/// The blur radius (progressive: at `end` and past it).
pub radius: f64,
#[serde(alias = "blurType")]
pub blur_type: BlurType,
/// Progressive: the radius at `start` and before it.
#[serde(alias = "startRadius")]
pub start_radius: f64,
/// Progressive: where the ramp starts, in the layer's unit box (0..1 across and down).
#[serde(alias = "startOffset")]
pub start: (f64, f64),
/// Progressive: where the ramp ends, in the layer's unit box.
#[serde(alias = "endOffset")]
pub end: (f64, f64),
}
impl Default for Blur {
fn default() -> Self {
Self { radius: 4.0, blur_type: BlurType::Uniform, start_radius: 0.0, start: (0.5, 0.0), end: (0.5, 1.0) }
}
}
impl Blur {
/// A uniform blur of `radius`.
pub fn uniform(radius: f64) -> Self {
Self { radius, ..Self::default() }
}
pub fn is_progressive(&self) -> bool {
self.blur_type == BlurType::Progressive
}
/// The values made safe to draw: radii finite in 0..[`MAX_BLUR_RADIUS`], points finite
/// (non-finite ones at their defaults) within a thousand boxes of the layer.
pub fn sanitized(&self) -> Blur {
let d = Blur::default();
let radius = |v: f64| if v.is_finite() { v.clamp(0.0, MAX_BLUR_RADIUS) } else { 0.0 };
let point = |p: (f64, f64), def: (f64, f64)| {
let ok = |v: f64, dv: f64| if v.is_finite() { v.clamp(-1000.0, 1000.0) } else { dv };
(ok(p.0, def.0), ok(p.1, def.1))
};
Blur {
radius: radius(self.radius),
blur_type: self.blur_type,
start_radius: radius(self.start_radius),
start: point(self.start, d.start),
end: point(self.end, d.end),
}
}
/// The largest radius anywhere on the layer (sanitised).
pub fn max_radius(&self) -> f64 {
let b = self.sanitized();
if b.is_progressive() { b.radius.max(b.start_radius) } else { b.radius }
}
/// The same blur scaled by `k` (radii; the unit-box points stay).
pub fn scaled(&self, k: f64) -> Blur {
Blur { radius: self.radius * k, start_radius: self.start_radius * k, ..self.clone() }
}
}
/// One effect in a node's effect list.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum EffectKind {
DropShadow(Shadow),
InnerShadow(Shadow),
LayerBlur {
radius: f64,
},
BackgroundBlur {
radius: f64,
},
LayerBlur(Blur),
BackgroundBlur(Blur),
/// Grain over the layer.
Noise(Noise),
/// Roughened edges.
@ -545,10 +633,10 @@ impl Effect {
Self::of(EffectKind::InnerShadow(Shadow { offset: (0.0, 4.0), ..Shadow::default() }))
}
pub fn layer_blur() -> Self {
Self::of(EffectKind::LayerBlur { radius: 4.0 })
Self::of(EffectKind::LayerBlur(Blur::default()))
}
pub fn background_blur() -> Self {
Self::of(EffectKind::BackgroundBlur { radius: 4.0 })
Self::of(EffectKind::BackgroundBlur(Blur::default()))
}
pub fn noise() -> Self {
Self::of(EffectKind::Noise(Noise::default()))
@ -579,8 +667,8 @@ impl Effect {
match self.kind {
EffectKind::DropShadow(_) => "Drop shadow",
EffectKind::InnerShadow(_) => "Inner shadow",
EffectKind::LayerBlur { .. } => "Layer blur",
EffectKind::BackgroundBlur { .. } => "Background blur",
EffectKind::LayerBlur(_) => "Layer blur",
EffectKind::BackgroundBlur(_) => "Background blur",
EffectKind::Noise(_) => "Noise",
EffectKind::Texture(_) => "Texture",
EffectKind::Glass(_) => "Glass",
@ -594,11 +682,25 @@ impl Effect {
}
match &self.kind {
EffectKind::DropShadow(s) => s.radius.abs() * 1.5 + s.spread.max(0.0) + s.offset.0.abs().max(s.offset.1.abs()),
EffectKind::LayerBlur { radius } => radius.abs() * 1.5,
EffectKind::LayerBlur(b) => b.max_radius() * 1.5,
EffectKind::Texture(t) if !t.clip_to_shape => t.radius.abs(),
_ => 0.0,
}
}
/// A layer or background blur's settings.
pub fn blur(&self) -> Option<&Blur> {
match &self.kind {
EffectKind::LayerBlur(b) | EffectKind::BackgroundBlur(b) => Some(b),
_ => None,
}
}
/// Mutable access to [`Effect::blur`].
pub fn blur_mut(&mut self) -> Option<&mut Blur> {
match &mut self.kind {
EffectKind::LayerBlur(b) | EffectKind::BackgroundBlur(b) => Some(b),
_ => None,
}
}
/// The effect's colour (`secondary`: a duo noise's second colour), if it has one.
pub fn color(&self, secondary: bool) -> Option<Color> {
match (&self.kind, secondary) {
@ -624,7 +726,7 @@ impl Effect {
return 0.0;
}
let r = match &self.kind {
EffectKind::BackgroundBlur { radius } => radius.abs(),
EffectKind::BackgroundBlur(b) => b.max_radius(),
// The frost blur plus the furthest bend, in blur radii (a blur reads 1.5 radii).
EffectKind::Glass(g) => g.sanitized().frost + g.max_bend() / 1.5,
_ => 0.0,

View File

@ -28,7 +28,7 @@ fn setup() -> (Session, NodeId) {
fn blur(s: &Session, r: NodeId, i: usize) -> f64 {
match s.doc().unwrap().node(r).unwrap().effects.get(i).map(|e| &e.kind) {
Some(EffectKind::LayerBlur { radius } | EffectKind::BackgroundBlur { radius }) => *radius,
Some(EffectKind::LayerBlur(b) | EffectKind::BackgroundBlur(b)) => b.radius,
k => panic!("{k:?}"),
}
}

View File

@ -378,7 +378,7 @@ pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> {
sh.spread *= k;
sh.offset = (sh.offset.0 * k, sh.offset.1 * k);
}
EffectKind::LayerBlur { radius } | EffectKind::BackgroundBlur { radius } => *radius *= k,
EffectKind::LayerBlur(b) | EffectKind::BackgroundBlur(b) => *b = b.scaled(k),
EffectKind::Noise(nz) => {
nz.size *= k;
nz.size_y = nz.size_y.map(|v| v * k);

View File

@ -147,7 +147,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"variable.bind",
"Bind variable",
"{ids?, field (fill|stroke (first paint)|fills/<i>/color|strokes/<i>/color|effects/<i>/<offsetX|offsetY|radius|spread|color>|layoutGrids/<i>/<size|count|gutter|offset>|componentProperties/<name> (instances: boolean/text properties)|width|height|opacity (percent)|cornerRadius|gap|paddingHorizontal|characters|visible|fontSize…; list indices bottom first), variable (null unbinds)}",
"{ids?, field (fill|stroke (first paint)|fills/<i>/color|strokes/<i>/color|effects/<i>/<offsetX|offsetY|radius|startRadius|spread|color>|layoutGrids/<i>/<size|count|gutter|offset>|componentProperties/<name> (instances: boolean/text properties)|width|height|opacity (percent)|cornerRadius|gap|paddingHorizontal|characters|visible|fontSize…; list indices bottom first), variable (null unbinds)}",
true,
bind
),
@ -750,7 +750,8 @@ fn apply_effect_bindings(doc: &mut Document, id: NodeId, sets: &[(usize, String,
(EffectKind::DropShadow(s) | EffectKind::InnerShadow(s), "radius", Some(x), _) => s.radius = x.max(0.0),
(EffectKind::DropShadow(s) | EffectKind::InnerShadow(s), "spread", Some(x), _) => s.spread = x,
(EffectKind::DropShadow(s) | EffectKind::InnerShadow(s), "color", _, VariableValue::Color(c)) => s.color = *c,
(EffectKind::LayerBlur { radius } | EffectKind::BackgroundBlur { radius }, "radius", Some(x), _) => *radius = x.max(0.0),
(EffectKind::LayerBlur(b) | EffectKind::BackgroundBlur(b), "radius", Some(x), _) => b.radius = x.max(0.0),
(EffectKind::LayerBlur(b) | EffectKind::BackgroundBlur(b), "startRadius", Some(x), _) => b.start_radius = x.max(0.0),
_ => {}
}
}

View File

@ -340,8 +340,9 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo
}
for e in n.effects.iter().filter(|e| e.visible) {
match e.kind {
EffectKind::LayerBlur { radius } => put("filter", format!("blur({}px)", num(radius / 2.0))),
EffectKind::BackgroundBlur { radius } => put("backdrop-filter", format!("blur({}px)", num(radius / 2.0))),
// CSS blurs are uniform: a progressive blur has no CSS equivalent and is left out.
EffectKind::LayerBlur(ref b) if !b.is_progressive() => put("filter", format!("blur({}px)", num(b.radius / 2.0))),
EffectKind::BackgroundBlur(ref b) if !b.is_progressive() => put("backdrop-filter", format!("blur({}px)", num(b.radius / 2.0))),
// CSS has no refraction: the frost is the nearest.
EffectKind::Glass(ref g) => put("backdrop-filter", format!("blur({}px)", num(g.sanitized().frost / 2.0))),
_ => {}

View File

@ -257,8 +257,16 @@ pub fn properties(doc: &Document, id: NodeId) -> Vec<Section> {
let v = format!("{} {} {} {} {}", num(s.offset.0), num(s.offset.1), num(s.radius), num(s.spread), color_text(s.color));
prop(label, v).color(s.color)
}
EffectKind::LayerBlur { radius } => prop("Layer blur", num(*radius)),
EffectKind::BackgroundBlur { radius } => prop("Background blur", num(*radius)),
EffectKind::LayerBlur(b) | EffectKind::BackgroundBlur(b) => {
let label = if matches!(e.kind, EffectKind::LayerBlur(_)) { "Layer blur" } else { "Background blur" };
let at = |(x, y): (f64, f64)| format!("{}% {}%", pct(x), pct(y));
let v = if b.is_progressive() {
format!("Progressive {} → {} from {} to {}", num(b.start_radius), num(b.radius), at(b.start), at(b.end))
} else {
num(b.radius)
};
prop(label, v)
}
EffectKind::Noise(nz) => {
let colors = match nz.mode {
NoiseMode::Mono => color_text(nz.color),

View File

@ -983,6 +983,8 @@ mod paint_tests;
#[cfg(test)]
mod pixel_preview_tests;
#[cfg(test)]
mod progressive_blur_tests;
#[cfg(test)]
mod proto_conditional_tests;
#[cfg(test)]
mod proto_multi_tests;

View File

@ -0,0 +1,109 @@
//! Progressive blur through the session: adding and editing it, scaling, variable bindings on its
//! radii, Inspect and generated CSS, and hostile values that still render.
use serde_json::json;
use crate::Session;
use crate::codegen::{CodeLang, code_for};
use crate::params::id_of;
use figcraft_doc::{Blur, BlurType, EffectKind, MAX_BLUR_RADIUS};
fn rect(s: &mut Session) -> figcraft_doc::NodeId {
id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 60})).unwrap()["id"]).unwrap()
}
fn blur(s: &Session, id: figcraft_doc::NodeId, i: usize) -> Blur {
s.doc().unwrap().node(id).unwrap().effects[i].blur().cloned().unwrap()
}
const PROGRESSIVE: &str = "progressive";
#[test]
fn effect_add_and_set_take_progressive_blurs() {
let mut s = Session::new();
let r = rect(&mut s);
s.execute(
"effect.add",
&json!({"ids": [r], "effect": {"type": "layerBlur", "radius": 12, "blurType": PROGRESSIVE, "startRadius": 2, "startOffset": [0.5, 0.1], "endOffset": [0.5, 0.9]}}),
)
.unwrap();
let b = blur(&s, r, 0);
assert_eq!((b.blur_type, b.radius, b.start_radius, b.start, b.end), (BlurType::Progressive, 12.0, 2.0, (0.5, 0.1), (0.5, 0.9)));
// A background blur, then back to uniform: one undo step each.
s.execute("effect.set", &json!({"ids": [r], "index": 0, "effect": {"type": "backgroundBlur", "radius": 8, "blur_type": PROGRESSIVE}})).unwrap();
let n = s.doc().unwrap().node(r).unwrap();
assert!(matches!(&n.effects[0].kind, EffectKind::BackgroundBlur(b) if b.is_progressive() && b.start_radius == 0.0));
s.undo().unwrap();
assert_eq!(blur(&s, r, 0).start_radius, 2.0);
// Junk is refused, not stored.
assert!(s.execute("effect.set", &json!({"ids": [r], "index": 0, "effect": {"type": "layerBlur", "blurType": "wavy"}})).is_err());
assert!(s.execute("effect.set", &json!({"ids": [r], "index": 0, "effect": {"type": "layerBlur", "startOffset": "top"}})).is_err());
assert_eq!(blur(&s, r, 0).start_radius, 2.0);
}
#[test]
fn scaling_scales_both_radii_and_keeps_the_line() {
let mut s = Session::new();
let r = rect(&mut s);
s.execute("effect.add", &json!({"ids": [r], "effect": {"type": "layerBlur", "radius": 10, "blurType": PROGRESSIVE, "startRadius": 3}})).unwrap();
s.execute("transform.scale", &json!({"ids": [r], "factor": 2})).unwrap();
let b = blur(&s, r, 0);
assert_eq!((b.radius, b.start_radius, b.start, b.end), (20.0, 6.0, Blur::default().start, Blur::default().end));
}
#[test]
fn both_radii_bind_to_number_variables() {
let mut s = Session::new();
let r = rect(&mut s);
s.execute("effect.add", &json!({"ids": [r], "effect": {"type": "layerBlur", "radius": 10, "blurType": PROGRESSIVE}})).unwrap();
s.execute("variable.createCollection", &json!({"name": "Tokens"})).unwrap();
for (name, v) in [("soft", 4), ("strong", 24)] {
s.execute("variable.create", &json!({"collection": "Tokens", "name": name, "type": "number", "value": v})).unwrap();
}
s.execute("variable.bind", &json!({"ids": [r], "field": "effects/0/startRadius", "variable": "soft"})).unwrap();
s.execute("variable.bind", &json!({"ids": [r], "field": "effects/0/radius", "variable": "strong"})).unwrap();
let b = blur(&s, r, 0);
assert_eq!((b.start_radius, b.radius), (4.0, 24.0));
s.execute("variable.set", &json!({"variable": "soft", "value": -3})).unwrap();
assert_eq!(blur(&s, r, 0).start_radius, 0.0, "clamped");
}
#[test]
fn inspect_shows_the_ramp_and_css_leaves_it_out() {
let mut s = Session::new();
let r = rect(&mut s);
s.execute("effect.add", &json!({"ids": [r], "effect": {"type": "backgroundBlur", "radius": 16, "blurType": PROGRESSIVE, "startRadius": 2}}))
.unwrap();
s.execute("effect.add", &json!({"ids": [r], "effect": {"type": "layerBlur", "radius": 6}})).unwrap();
let doc = s.doc().unwrap();
let sections = crate::devinspect::properties(doc, r);
let fx = sections.iter().find(|s| s.title == "Effects").unwrap();
assert_eq!(fx.rows[0].label, "Background blur");
assert_eq!(fx.rows[0].value, "Progressive 2 → 16 from 50% 0% to 50% 100%");
assert_eq!(fx.rows[1].value, "6");
// CSS blurs are uniform: the progressive one has no CSS, the uniform one does.
let css = code_for(doc, r, CodeLang::Css);
assert!(!css.contains("backdrop-filter"), "{css}");
assert!(css.contains("filter: blur(3px);"), "{css}");
}
#[test]
fn hostile_progressive_values_are_capped_and_render() {
let mut s = Session::new();
let r = rect(&mut s);
for effect in [
json!({"type": "layerBlur", "radius": 1e308, "blurType": PROGRESSIVE, "startRadius": -1e308, "startOffset": [1e308, -1e308], "endOffset": [0, 0]}),
json!({"type": "backgroundBlur", "radius": -5, "blurType": PROGRESSIVE, "startRadius": 1e12, "startOffset": [0.5, 0.5], "endOffset": [0.5, 0.5]}),
] {
s.execute("effect.add", &json!({"ids": [r], "effect": effect})).unwrap();
}
let doc = s.doc().unwrap();
for e in &doc.node(r).unwrap().effects {
assert!(e.blur().unwrap().max_radius() <= MAX_BLUR_RADIUS);
}
let page = doc.first_page().unwrap();
let view = figcraft_render::View { origin: (-20.0, -20.0), scale: 1.0, width: 160, height: 120 };
assert!(figcraft_render::render_view(doc, page, &view, &figcraft_render::Options::default()).is_some());
let props = crate::devinspect::properties(doc, r);
assert!(props.iter().any(|s| s.title == "Effects"));
}

View File

@ -109,9 +109,9 @@ fn effects_follow_their_variables() {
}
k => panic!("{k:?}"),
}
assert!(matches!(fx(&s)[1].kind, figcraft_doc::EffectKind::LayerBlur { radius } if radius == 12.0));
assert!(matches!(fx(&s)[1].kind, figcraft_doc::EffectKind::LayerBlur(ref b) if b.radius == 12.0));
s.execute("variable.set", &json!({"variable": "blur", "value": -3})).unwrap();
assert!(matches!(fx(&s)[1].kind, figcraft_doc::EffectKind::LayerBlur { radius } if radius == 0.0), "clamped");
assert!(matches!(fx(&s)[1].kind, figcraft_doc::EffectKind::LayerBlur(ref b) if b.radius == 0.0), "clamped");
// Pickers offer effect-scoped variables for these fields.
let doc = s.doc().unwrap();
assert_eq!(crate::cmd::variables::field_scope(doc, r, "effects/0/color"), Some("EFFECT_COLOR"));

View File

@ -0,0 +1,46 @@
//! REST import of progressive blurs (`blurType`, `startRadius`, `startOffset`, `endOffset`).
//! Fixtures are synthetic, written by hand from the published schema.
use super::*;
use figcraft_doc::{BlurType, EffectKind};
use serde_json::json;
fn imported(effects: Value) -> Vec<figcraft_doc::Effect> {
let file = json!({"name": "Fixture", "schemaVersion": 0, "document": {"id": "0:0", "name": "Document", "type": "DOCUMENT", "children": [
{"id": "0:1", "name": "Page", "type": "CANVAS", "children": [
{"id": "1", "name": "S", "type": "RECTANGLE", "relativeTransform": [[1, 0, 0], [0, 1, 0]], "size": {"x": 10, "y": 10}, "effects": effects}
]}
]}});
let (doc, warnings) = import(&file, &no_images).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
doc.nodes().find(|n| n.name == "S").unwrap().effects.clone()
}
#[test]
fn progressive_blurs_import() {
let fx = imported(json!([
{"type": "LAYER_BLUR", "radius": 24, "blurType": "PROGRESSIVE", "startRadius": 4, "startOffset": {"x": 0.5, "y": 0.2}, "endOffset": {"x": 0.5, "y": 1}},
{"type": "BACKGROUND_BLUR", "radius": 10, "blurType": "PROGRESSIVE"},
{"type": "LAYER_BLUR", "radius": 6, "blurType": "NORMAL", "startRadius": 3}
]));
let EffectKind::LayerBlur(a) = &fx[0].kind else { panic!("{fx:?}") };
assert_eq!((a.blur_type, a.radius, a.start_radius, a.start, a.end), (BlurType::Progressive, 24.0, 4.0, (0.5, 0.2), (0.5, 1.0)));
// Missing fields take the defaults: from the top (no blur) to the bottom.
let EffectKind::BackgroundBlur(b) = &fx[1].kind else { panic!("{fx:?}") };
assert_eq!((b.blur_type, b.start_radius, b.start, b.end), (BlurType::Progressive, 0.0, (0.5, 0.0), (0.5, 1.0)));
let EffectKind::LayerBlur(c) = &fx[2].kind else { panic!("{fx:?}") };
assert!(!c.is_progressive());
assert_eq!(c.radius, 6.0);
}
#[test]
fn hostile_progressive_fields_are_clamped() {
let fx = imported(json!([
{"type": "LAYER_BLUR", "radius": 1e300, "blurType": "PROGRESSIVE", "startRadius": -7, "startOffset": {"x": "a", "y": 0}, "endOffset": {"x": 1e300, "y": -1e300}},
{"type": "BACKGROUND_BLUR", "radius": 5, "blurType": 7, "startOffset": [1, 2]}
]));
let a = fx[0].blur().unwrap();
assert_eq!((a.radius, a.start_radius, a.start, a.end), (figcraft_doc::MAX_BLUR_RADIUS, 0.0, (0.5, 0.0), (1000.0, -1000.0)));
let b = fx[1].blur().unwrap();
assert!(!b.is_progressive());
}

View File

@ -18,6 +18,8 @@
//! main component's (`source`) and deriving overrides is left to the caller (the engine does it
//! after loading), since that needs the component model.
#[cfg(test)]
mod blur_tests;
#[cfg(test)]
mod conditional_tests;
#[cfg(test)]

View File

@ -4,9 +4,9 @@
use figcraft_doc::color::{BlendMode, Color, ColorStop, Gradient, GradientKind, ImageFilters, ImagePaint, ImageScaleMode, Paint, PaintKind};
use figcraft_doc::geom::Affine;
use figcraft_doc::{
AutoLayout, ChildLayout, Constraints, CrossAlign, Effect, EffectKind, ExportFormat, ExportSetting, ExportSize, Glass, GridAlign, GridKind,
HConstraint, LayoutGrid, LayoutMode, MainAlign, Noise, NoiseMode, Shadow, Sizing, StrokeAlign, StrokeCap, StrokeJoin, StrokeProps, Texture,
VConstraint,
AutoLayout, Blur, BlurType, ChildLayout, Constraints, CrossAlign, Effect, EffectKind, ExportFormat, ExportSetting, ExportSize, Glass, GridAlign,
GridKind, HConstraint, LayoutGrid, LayoutMode, MainAlign, Noise, NoiseMode, Shadow, Sizing, StrokeAlign, StrokeCap, StrokeJoin, StrokeProps,
Texture, VConstraint,
};
use serde_json::Value;
@ -215,8 +215,8 @@ fn effect(cx: &mut Ctx, e: &Value) -> Option<Effect> {
let kind = match text(e, "type")? {
"DROP_SHADOW" => EffectKind::DropShadow(shadow()),
"INNER_SHADOW" => EffectKind::InnerShadow(shadow()),
"LAYER_BLUR" => EffectKind::LayerBlur { radius },
"BACKGROUND_BLUR" => EffectKind::BackgroundBlur { radius },
"LAYER_BLUR" => EffectKind::LayerBlur(blur(e, radius)),
"BACKGROUND_BLUR" => EffectKind::BackgroundBlur(blur(e, radius)),
"NOISE" => {
let d = Noise::default();
let (size, size_y) = noise_size(e, d.size);
@ -268,6 +268,21 @@ fn effect(cx: &mut Ctx, e: &Value) -> Option<Effect> {
Some(Effect { kind, visible: flag(e, "visible").unwrap_or(true) })
}
/// A layer or background blur of `radius`: progressive (`blurType` `PROGRESSIVE`) from
/// `startRadius` at `startOffset` to `radius` at `endOffset`, else uniform.
fn blur(e: &Value, radius: f64) -> Blur {
let d = Blur::default();
let blur_type = if text(e, "blurType") == Some("PROGRESSIVE") { BlurType::Progressive } else { BlurType::Uniform };
Blur {
radius,
blur_type,
start_radius: len(e, "startRadius").unwrap_or(d.start_radius),
start: e.get("startOffset").and_then(vec2).unwrap_or(d.start),
end: e.get("endOffset").and_then(vec2).unwrap_or(d.end),
}
.sanitized()
}
/// A noise or texture effect's grain size: `noiseSize` on both axes, or `noiseSizeVector`.
fn noise_size(e: &Value, default: f64) -> (f64, Option<f64>) {
let size = len(e, "noiseSize").unwrap_or(default);

View File

@ -237,8 +237,8 @@ fn strokes_and_effects() {
assert_eq!((d.offset, d.radius, d.spread, d.show_behind), ((2.0, 6.0), 8.0, 3.0, true));
assert!(close(f64::from(d.color.a), 0.5));
assert!(matches!(&s.effects[1].kind, EffectKind::InnerShadow(i) if i.radius == 2.0) && !s.effects[1].visible);
assert!(matches!(s.effects[2].kind, EffectKind::LayerBlur { radius } if radius == 5.0));
assert!(matches!(s.effects[3].kind, EffectKind::BackgroundBlur { radius } if radius == 9.0));
assert!(matches!(s.effects[2].kind, EffectKind::LayerBlur(ref b) if b.radius == 5.0));
assert!(matches!(s.effects[3].kind, EffectKind::BackgroundBlur(ref b) if b.radius == 9.0));
assert!(warnings.iter().any(|w| w.contains("SPARKLE")), "{warnings:?}");
let l = named(&doc, "L");
assert!(matches!(l.kind, NodeKind::Line));

View File

@ -13,7 +13,7 @@ fn solid(d: &mut Document, id: NodeId, hex: &str) {
}
/// A red clipping frame with stripes and a transparent pane (80..200 × 40..120) with `effect`.
fn scene(effect: EffectKind) -> (Document, NodeId, NodeId) {
pub(crate) fn scene(effect: EffectKind) -> (Document, NodeId, NodeId) {
let mut d = Document::new("pdf backdrop");
let p = d.first_page().unwrap();
let f = d.create(p, NodeKind::Frame, "Frame", 0.0, 0.0, 240.0, 160.0).unwrap();
@ -32,7 +32,7 @@ fn scene(effect: EffectKind) -> (Document, NodeId, NodeId) {
/// The PDF's backdrop image of `pane` and the PNG export of `frame` at the same scale: the largest
/// channel difference over the pane's inside (2 page units in from its edges).
fn pdf_vs_png(d: &Document, frame: NodeId, pane: NodeId) -> u8 {
pub(crate) fn pdf_vs_png(d: &Document, frame: NodeId, pane: NodeId) -> u8 {
let opts = PdfOptions::default();
let mut ex = Exporter::new(d, &opts);
let b = figcraft_render::export_bounds(d, frame).unwrap();
@ -60,7 +60,7 @@ fn pdf_vs_png(d: &Document, frame: NodeId, pane: NodeId) -> u8 {
#[test]
fn pdf_background_blur_matches_the_png() {
let (d, f, pane) = scene(EffectKind::BackgroundBlur { radius: 12.0 });
let (d, f, pane) = scene(EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(12.0)));
let diff = pdf_vs_png(&d, f, pane);
assert!(diff <= 3, "{diff}");
}
@ -78,7 +78,7 @@ fn pdf_places_the_backdrop_image() {
let pdf = crate::export_pdf(d, &[f], &PdfOptions::default()).unwrap();
String::from_utf8_lossy(&pdf).matches("/Subtype/Image").count()
};
let (mut d, f, pane) = scene(EffectKind::BackgroundBlur { radius: 12.0 });
let (mut d, f, pane) = scene(EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(12.0)));
let with = images(&d, f);
d.get_mut(pane).unwrap().effects.clear();
let without = images(&d, f);

View File

@ -6,6 +6,8 @@ use figcraft_color::{BlendMode, Color, Gradient, GradientKind, ImagePaint, Image
use figcraft_doc::{Document, EffectKind, MaskType as DocMaskType, Node, NodeId, NodeKind, StrokeAlign};
use figcraft_geom::{Affine, BezPath, Rect, Shape};
use krilla::color::rgb;
use crate::raster::Raster;
use krilla::geom::{Path, PathBuilder, Size, Transform};
use krilla::image::Image;
use krilla::mask::{Mask, MaskType};
@ -245,7 +247,7 @@ impl<'a> Exporter<'a> {
if let Some(b) = &back {
self.place_raster(s, b, op, n.blend);
}
self.raster_node(s, id, as_mask);
self.raster_node(s, id);
return;
}
let shadows = if fx.iter().any(|k| matches!(k, EffectKind::DropShadow(_) | EffectKind::InnerShadow(_))) { self.shadows(id) } else { None };
@ -266,12 +268,7 @@ impl<'a> Exporter<'a> {
if let Some(b) = &back {
self.place_raster(s, b, 1.0, BlendMode::Normal);
}
self.content(s, n, xf, depth);
if let Some(sh) = &shadows {
for r in &sh.inner {
self.place_raster(s, r, 1.0, BlendMode::Normal);
}
}
self.content(s, n, xf, depth, shadows.as_ref().map_or(&[], |sh| sh.inner.as_slice()));
if op < 1.0 {
s.pop();
}
@ -281,12 +278,13 @@ impl<'a> Exporter<'a> {
}
}
/// Fills, children (clipped), strokes and text of a node, without opacity/effects.
fn content(&mut self, s: &mut Surface, n: &Node, xf: Affine, depth: usize) {
/// Fills, `inner` shadows (over the fills, under the children and strokes), children
/// (clipped), strokes and text of a node, without opacity/effects.
fn content(&mut self, s: &mut Surface, n: &Node, xf: Affine, depth: usize, inner: &[Raster]) {
let (w, h) = (n.width, n.height);
match &n.kind {
NodeKind::Page { .. } | NodeKind::Group => self.children(s, n, xf, depth),
NodeKind::Text(_) => crate::text::draw_text(self, s, n, xf),
NodeKind::Text(_) => crate::text::draw_text(self, s, n, xf, inner),
_ => {
let outline = figcraft_render::node_outline(self.doc, n);
let is_bool = matches!(n.kind, NodeKind::BooleanOp { .. });
@ -298,6 +296,7 @@ impl<'a> Exporter<'a> {
self.fill(s, path, rule(*r), p, w, h, xf);
}
}
self.place_inner(s, inner);
if !is_bool && !n.children.is_empty() {
if n.clip && n.kind.is_frame_like() {
// Clip content: children outside the frame's outline are cut off (and

View File

@ -0,0 +1,68 @@
//! Effects in the PDF: inner shadow images go over the fills and under the children and strokes
//! (the renderer's order); progressive blurs are drawn by the renderer like the PNG export.
use super::*;
use crate::backdrop_tests::{pdf_vs_png, scene};
use crate::tests::inflated;
use figcraft_color::{Color, Paint};
use figcraft_doc::{Blur, BlurType, Effect, EffectKind, NodeKind, Shadow, StrokeAlign};
const FILL: &str = "0.2 0.4 0.8 rg";
const CHILD: &str = "1 0 0 rg";
const STROKE: &str = "0 1 0 rg";
/// A blue 100 × 100 `kind` with a green inside stroke and an inner shadow (and a red child when a
/// frame); the page content stream.
fn content(kind: NodeKind) -> String {
let mut d = Document::new("pdf order");
let p = d.first_page().unwrap();
let r = d.create(p, kind, "R", 0.0, 0.0, 100.0, 100.0).unwrap();
let n = d.get_mut(r).unwrap();
n.fills = vec![Paint::solid(Color::from_hex("3366CC").unwrap())];
n.strokes = vec![Paint::solid(Color::from_hex("00FF00").unwrap())];
n.stroke.weight = 10.0;
n.stroke.align = StrokeAlign::Inside;
n.effects = vec![Effect { kind: EffectKind::InnerShadow(Shadow::default()), visible: true }];
if n.kind.is_frame_like() {
let c = d.create(r, NodeKind::Rectangle, "C", 20.0, 20.0, 40.0, 40.0).unwrap();
d.get_mut(c).unwrap().fills = vec![Paint::solid(Color::from_hex("FF0000").unwrap())];
}
inflated(&export_pdf(&d, &[r], &PdfOptions::default()).unwrap())
}
#[test]
fn inner_shadows_go_over_fills_and_under_strokes() {
let c = content(NodeKind::Rectangle);
let (fill, shadow, stroke) = (c.find(FILL).unwrap(), c.find(" Do").unwrap(), c.find(STROKE).unwrap());
assert!(fill < shadow && shadow < stroke, "{c}");
}
#[test]
fn a_frames_children_go_over_its_inner_shadow() {
let c = content(NodeKind::Frame);
let (fill, shadow, child, stroke) = (c.find(FILL).unwrap(), c.find(" Do").unwrap(), c.find(CHILD).unwrap(), c.find(STROKE).unwrap());
assert!(fill < shadow && shadow < child && child < stroke, "{c}");
}
#[test]
fn pdf_progressive_background_blur_matches_the_png() {
let b = Blur { radius: 16.0, blur_type: BlurType::Progressive, start_radius: 2.0, start: (0.0, 0.5), end: (1.0, 0.5) };
let (d, f, pane) = scene(EffectKind::BackgroundBlur(b));
let diff = pdf_vs_png(&d, f, pane);
assert!(diff <= 3, "{diff}");
}
#[test]
fn progressive_layer_blur_is_rasterised() {
let images = |d: &Document, id| count_images(&export_pdf(d, &[id], &PdfOptions::default()).unwrap());
let (mut d, f, pane) = scene(EffectKind::LayerBlur(Blur::uniform(0.0)));
d.get_mut(pane).unwrap().fills = vec![Paint::solid(Color::WHITE)];
let plain = images(&d, f);
let b = Blur { radius: 12.0, blur_type: BlurType::Progressive, ..Blur::default() };
d.get_mut(pane).unwrap().effects = vec![Effect { kind: EffectKind::LayerBlur(b), visible: true }];
assert!(images(&d, f) > plain);
}
fn count_images(pdf: &[u8]) -> usize {
String::from_utf8_lossy(pdf).matches("/Subtype/Image").count()
}

View File

@ -90,7 +90,7 @@ fn frosted_doc(radius: f64) -> (Document, NodeId, NodeId) {
let n = d.get_mut(panel).unwrap();
n.fills = vec![Paint::solid(Color::from_hex("FFFFFF40").unwrap())];
n.corners.radii = [12.0; 4];
n.effects = vec![Effect { kind: EffectKind::BackgroundBlur { radius }, visible: true }];
n.effects = vec![Effect { kind: EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(radius)), visible: true }];
}
// Drawn after the panel: never part of its backdrop.
let above = d.create(f, NodeKind::Rectangle, "Above", 0.0, 0.0, 30.0, 30.0).unwrap();
@ -260,7 +260,7 @@ fn hostile_effects_and_masks_never_panic() {
}
// Background blur on a page root itself, and nested three groups deep.
let (mut d, f, _) = frosted_doc(8.0);
d.get_mut(f).unwrap().effects = vec![Effect { kind: EffectKind::BackgroundBlur { radius: 4.0 }, visible: true }];
d.get_mut(f).unwrap().effects = vec![Effect { kind: EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(4.0)), visible: true }];
assert!(export_pdf(&d, &[f], &PdfOptions::default()).is_ok());
}

View File

@ -174,6 +174,8 @@ fn write(doc: &Document, pages: &[(Vec<NodeId>, Rect)], opts: &PdfOptions) -> Re
#[cfg(test)]
mod backdrop_tests;
#[cfg(test)]
mod effect_order_tests;
#[cfg(test)]
mod export_tests;
#[cfg(test)]
mod tests;

View File

@ -5,14 +5,15 @@
//! as one image.
//! - Drop and inner shadows are computed from a render of the node without its effects (the
//! renderer's own shadows: spread offsets the outline, blur, tint, offset, knock-out), so the
//! node itself stays vector: drop shadows go under it, inner shadows over it.
//! node itself stays vector: drop shadows go under it, inner shadows over its fills (under its
//! children and strokes).
//! - Background blur and glass are drawn by the renderer from a render of what lies under the
//! node on the page, and placed under its content.
use figcraft_color::BlendMode;
use figcraft_doc::{EffectKind, NodeId};
use figcraft_geom::{Affine, Rect};
use figcraft_render::{Pixmap, View, backdrop, shadow};
use figcraft_render::{Alone, Pixmap, View, backdrop, shadow};
use krilla::geom::Size;
use krilla::image::Image;
use krilla::surface::Surface;
@ -86,42 +87,21 @@ impl Exporter<'_> {
}
}
/// Inner shadow images, over a node's fills.
pub(crate) fn place_inner(&self, s: &mut Surface, inner: &[Raster]) {
for r in inner {
self.place_raster(s, r, 1.0, BlendMode::Normal);
}
}
/// Render node `id` whole (with its effects, opacity and blend) and place it as an image.
pub(crate) fn raster_node(&mut self, s: &mut Surface, id: NodeId, as_mask: bool) {
pub(crate) fn raster_node(&mut self, s: &mut Surface, id: NodeId) {
let doc = self.doc;
let Some(n) = doc.node(id) else { return };
let mut tmp = doc.clone();
if let Some(tn) = tmp.node_mut(id) {
if as_mask {
tn.is_mask = false;
}
// Its backdrop effects are placed separately (see `backdrop`): an isolated render has
// no backdrop to draw them from.
tn.effects.retain(|e| !matches!(e.kind, EffectKind::BackgroundBlur { .. } | EffectKind::Glass(_)));
}
let world = doc.world_transform(id);
let sc = world.determinant().abs().sqrt();
let sc = if sc.is_finite() && sc > 0.0 { sc } else { 1.0 };
// Blurs read (and spread) beyond the painted bounds.
let margin = n
.effects
.iter()
.filter(|e| e.visible)
.map(|e| match &e.kind {
EffectKind::LayerBlur { radius } => radius.abs() * 2.0,
EffectKind::DropShadow(sh) => {
sh.radius.abs() * 2.0 + sh.spread.abs() * figcraft_doc::SPREAD_MITER_LIMIT + sh.offset.0.abs() + sh.offset.1.abs()
}
EffectKind::Texture(t) => t.radius.abs(),
_ => 0.0,
})
.filter(|m| m.is_finite())
.fold(0.0, f64::max)
* sc;
let Some(b) = tmp.world_render_bounds(id) else { return };
let Some(view) = self.raster_view(b.inflate(margin, margin)) else { return };
let Some(mut pix) = Pixmap::new(view.width, view.height) else { return };
figcraft_render::render_nodes_into(&tmp, &[id], &view, &mut pix);
// Blurs, shadows and texture reach beyond the painted bounds.
let Some(view) = figcraft_render::raster_bounds(doc, id).and_then(|b| self.raster_view(b)) else { return };
// Its backdrop effects are placed separately (see `backdrop`).
let Some(pix) = figcraft_render::render_alone(doc, id, &view, Alone::WithEffects) else { return };
// The renderer applied opacity; the blend mode applies against the PDF backdrop.
self.place_raster(s, &raster(&pix, Self::view_rect(&view)), 1.0, n.blend);
}
@ -136,30 +116,9 @@ impl Exporter<'_> {
/// [`Exporter::backdrop`]'s pixels and the page area they cover.
pub(crate) fn backdrop_pixels(&self, id: NodeId) -> Option<(Pixmap, View)> {
let doc = self.doc;
let n = doc.node(id)?;
let shape = figcraft_render::node_outline(doc, n)?;
let world = doc.world_transform(id);
let bounds = figcraft_geom::transform_rect_bbox(world, figcraft_geom::Shape::bounding_box(&shape.0));
let view = self.raster_view(bounds)?;
let pad = backdrop::pad(n, view.scale);
let p = f64::from(pad) / view.scale.max(1e-9);
let around = View {
origin: (view.origin.0 - p, view.origin.1 - p),
scale: view.scale,
width: view.width.saturating_add(pad * 2),
height: view.height.saturating_add(pad * 2),
};
let mut back = backdrop::render_beneath(doc, &self.page_ids, id, &around)?;
let view = self.raster_view(backdrop::shape_bounds(self.doc, id)?)?;
// Nothing is exported past the page's edge (the renderer's export ends there too).
let page = Rect::new(
(self.page_rect.x0 - around.origin.0) * view.scale,
(self.page_rect.y0 - around.origin.1) * view.scale,
(self.page_rect.x1 - around.origin.0) * view.scale,
(self.page_rect.y1 - around.origin.1) * view.scale,
);
keep_inside(&mut back, page);
let fx = backdrop::effects(n, &shape, &back, pad, view.width, view.height, view.transform() * world, view.scale)?;
let fx = backdrop::effects_beneath(self.doc, &self.page_ids, id, &view, Some(self.page_rect))?;
Some((fx, view))
}
@ -167,14 +126,6 @@ impl Exporter<'_> {
pub(crate) fn shadows(&mut self, id: NodeId) -> Option<Shadows> {
let doc = self.doc;
let n = doc.node(id)?;
let mut tmp = doc.clone();
{
let tn = tmp.node_mut(id)?;
tn.effects.clear();
tn.opacity = 1.0;
tn.blend = BlendMode::Normal;
tn.is_mask = false;
}
let world = doc.world_transform(id);
let sc = world.determinant().abs().sqrt();
let sc = if sc.is_finite() && sc > 0.0 { sc } else { 1.0 };
@ -192,18 +143,16 @@ impl Exporter<'_> {
.filter(|m| m.is_finite())
.fold(0.0, f64::max)
* sc;
let b = tmp.world_render_bounds(id)?.inflate(reach + 1.0, reach + 1.0);
let b = doc.world_render_bounds(id)?.inflate(reach + 1.0, reach + 1.0);
let view = self.raster_view(b)?;
let rect = Self::view_rect(&view);
let (w, h) = (view.width, view.height);
let mut layer = Pixmap::new(w, h)?;
figcraft_render::render_nodes_into(&tmp, &[id], &view, &mut layer);
let tn = tmp.node(id)?;
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(&tmp, tn) } else { None };
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 shape = figcraft_render::node_outline(&tmp, tn);
let shape = figcraft_render::node_outline(doc, n);
let mut out = Shadows::default();
for e in n.effects.iter().filter(|e| e.visible) {
match &e.kind {
@ -223,14 +172,3 @@ impl Exporter<'_> {
Some(out)
}
}
/// Clear the pixels of `p` outside `r` (pixel coordinates).
fn keep_inside(p: &mut Pixmap, r: Rect) {
let w = p.width() as usize;
for (i, px) in p.data_mut().as_chunks_mut::<4>().0.iter_mut().enumerate() {
let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
if !(x >= r.x0 && x < r.x1 && y >= r.y0 && y < r.y1) {
*px = [0; 4];
}
}
}

View File

@ -125,7 +125,7 @@ fn sample_doc() -> (Document, Vec<NodeId>) {
n.opacity = 0.7;
}
let blur = d.create(f2, NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, "Blurred", 120.0, 160.0, 60.0, 60.0).unwrap();
d.get_mut(blur).unwrap().effects = vec![Effect { kind: EffectKind::LayerBlur { radius: 6.0 }, visible: true }];
d.get_mut(blur).unwrap().effects = vec![Effect { kind: EffectKind::LayerBlur(figcraft_doc::Blur::uniform(6.0)), visible: true }];
let frames = d.top_frames(p);
(d, frames)
}

View File

@ -17,6 +17,7 @@ use krilla::text::{Font, GlyphId, KrillaGlyph};
use figcraft_color::{GradientKind, Paint, PaintKind};
use crate::draw::{Exporter, above, affine_ok, blend, to_xf, vector_paint};
use crate::raster::Raster;
/// Glyphs of one face and size on one line, written as one text run.
struct Run {
@ -33,7 +34,7 @@ struct Run {
}
/// Draw a text node; `xf` maps its local space to the page.
pub(crate) fn draw_text(ex: &mut Exporter, s: &mut Surface, n: &Node, xf: Affine) {
pub(crate) fn draw_text(ex: &mut Exporter, s: &mut Surface, n: &Node, xf: Affine, inner: &[Raster]) {
let NodeKind::Text(t) = &n.kind else { return };
let Some((layout, dy)) = figcraft_text::layout_node(n) else { return };
let gxf = xf * Affine::translate((0.0, dy));
@ -46,6 +47,8 @@ pub(crate) fn draw_text(ex: &mut Exporter, s: &mut Surface, n: &Node, xf: Affine
let (w, h) = (n.width, n.height);
let chars: Vec<char> = t.characters.chars().collect();
let styles: BTreeSet<usize> = layout.glyphs.iter().map(|g| g.style).chain(layout.decorations.iter().map(|d| d.style)).collect();
// Every run's fills, then the inner shadows over them, then the strokes on top.
let mut stroked = Vec::new();
for k in styles {
let fills = layout.styles.get(k).and_then(|st| st.fills.as_ref()).unwrap_or(&n.fills);
let mut decorations = BezPath::new();
@ -75,11 +78,15 @@ pub(crate) fn draw_text(ex: &mut Exporter, s: &mut Surface, n: &Node, xf: Affine
}
}
if n.has_visible_stroke() {
let props = StrokeProps { align: StrokeAlign::Center, ..n.stroke.clone() };
let area = figcraft_pathops::stroke_center(&outlines, props.weight, &props);
for p in &n.strokes {
ex.fill(s, &area, FillRule::NonZero, p, w, h, gxf);
}
stroked.push(outlines);
}
}
ex.place_inner(s, inner);
let props = StrokeProps { align: StrokeAlign::Center, ..n.stroke.clone() };
for outlines in &stroked {
let area = figcraft_pathops::stroke_center(outlines, props.weight, &props);
for p in &n.strokes {
ex.fill(s, &area, FillRule::NonZero, p, w, h, gxf);
}
}
}

View File

@ -6,11 +6,11 @@
//! will be: the frame's own fill under a clipped child, the page under that. [`Under`] records the
//! stack of surfaces as the render descends; [`backdrop`] flattens it for a rectangle.
//!
//! The PDF export, which has no pixels to read, renders what lies under the layer instead
//! ([`render_beneath`]) and draws the same [`effects`] from it.
//! The PDF and SVG exports, which have no pixels to read, render what lies under the layer instead
//! ([`effects_beneath`]) and draw the same [`effects`] from it.
use figcraft_doc::{Document, EffectKind, Node, NodeId};
use figcraft_geom::Affine;
use figcraft_geom::{Affine, Rect};
use tiny_skia::{Mask, Pixmap, PixmapPaint, Transform};
use crate::shadow::{Outline, spread_alpha};
@ -93,9 +93,9 @@ pub fn effects(n: &Node, shape: &Outline, back: &Pixmap, pad: u32, w: u32, h: u3
let mut cov: Option<Pixmap> = None;
for e in n.effects.iter().filter(|e| e.visible) {
let layer = match &e.kind {
EffectKind::BackgroundBlur { radius } if *radius > 0.0 => {
EffectKind::BackgroundBlur(blur) if blur.max_radius() > 0.0 => {
let mut b = back.clone();
fx::blur(&mut b, radius / 2.0 * scale);
crate::blur::apply(&mut b, blur, (n.width, n.height), Affine::translate((f64::from(pad), f64::from(pad))) * xf, scale);
let mut l = Pixmap::new(w, h)?;
let copy = PixmapPaint { blend_mode: tiny_skia::BlendMode::Source, ..Default::default() };
l.draw_pixmap(-pad_i, -pad_i, b.as_ref(), &copy, Transform::identity(), None);
@ -153,3 +153,44 @@ pub fn render_beneath(doc: &Document, roots: &[NodeId], id: NodeId, view: &View)
crate::render_nodes_into(&beneath, roots, view, &mut pix);
Some(pix)
}
/// Where node `id`'s shape lies on its page (what its backdrop effects cover).
pub fn shape_bounds(doc: &Document, id: NodeId) -> Option<Rect> {
let shape = crate::node_outline(doc, doc.node(id)?)?;
Some(figcraft_geom::transform_rect_bbox(doc.world_transform(id), figcraft_geom::Shape::bounding_box(&shape.0)))
}
/// Node `id`'s backdrop effects as `view`'s pixels (a view of its [`shape_bounds`]), drawn from a
/// render of what lies under it when `roots` are drawn ([`render_beneath`]): how exports, which
/// have no canvas to read, draw background blur and glass. Nothing lies outside `keep` (page
/// coordinates), when given.
pub fn effects_beneath(doc: &Document, roots: &[NodeId], id: NodeId, view: &View, keep: Option<Rect>) -> Option<Pixmap> {
let n = doc.node(id)?;
let shape = crate::node_outline(doc, n)?;
let pad = pad(n, view.scale);
let p = f64::from(pad) / view.scale.max(1e-9);
let around = View {
origin: (view.origin.0 - p, view.origin.1 - p),
scale: view.scale,
width: view.width.saturating_add(pad * 2),
height: view.height.saturating_add(pad * 2),
};
let mut back = render_beneath(doc, roots, id, &around)?;
if let Some(k) = keep {
let s = view.scale;
let (ox, oy) = around.origin;
keep_inside(&mut back, Rect::new((k.x0 - ox) * s, (k.y0 - oy) * s, (k.x1 - ox) * s, (k.y1 - oy) * s));
}
effects(n, &shape, &back, pad, view.width, view.height, view.transform() * doc.world_transform(id), view.scale)
}
/// Clear the pixels of `p` outside `r` (pixel coordinates).
fn keep_inside(p: &mut Pixmap, r: Rect) {
let w = p.width() as usize;
for (i, px) in p.data_mut().as_chunks_mut::<4>().0.iter_mut().enumerate() {
let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
if !(x >= r.x0 && x < r.x1 && y >= r.y0 && y < r.y1) {
*px = [0; 4];
}
}
}

View File

@ -52,7 +52,7 @@ fn scene(holder: Holder) -> (Document, NodeId, NodeId) {
let pane = d.create(parent, NodeKind::Rectangle, "Pane", 60.0, 40.0, 120.0, 80.0).unwrap();
let n = d.get_mut(pane).unwrap();
n.fills = vec![];
n.effects = vec![Effect { kind: EffectKind::BackgroundBlur { radius: BLUR }, visible: true }];
n.effects = vec![Effect { kind: EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(BLUR)), visible: true }];
(d, f, pane)
}
@ -276,8 +276,8 @@ fn hostile_backdrop_values_do_not_crash() {
assert!(render_node(&d, f, 1.0).is_some());
}
let (mut d, f, pane) = scene(Holder::ClipFrame);
d.get_mut(pane).unwrap().effects = vec![Effect { kind: EffectKind::BackgroundBlur { radius: f64::NAN }, visible: true }];
d.get_mut(pane).unwrap().effects = vec![Effect { kind: EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(f64::NAN)), visible: true }];
assert!(render_node(&d, f, 1.0).is_some());
d.get_mut(pane).unwrap().effects = vec![Effect { kind: EffectKind::BackgroundBlur { radius: 1e12 }, visible: true }];
d.get_mut(pane).unwrap().effects = vec![Effect { kind: EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(1e12)), visible: true }];
assert!(render_node(&d, f, 0.5).is_some());
}

View File

@ -47,7 +47,7 @@ fn scene(shadows: bool, glass: bool) -> (Document, NodeId) {
let g = d.create(p, NodeKind::Rectangle, "Glass", 40.0, 60.0, 320.0, 120.0).unwrap();
let n = d.get_mut(g).unwrap();
n.fills = vec![Paint::solid(Color::from_hex("FFFFFF33").unwrap())];
n.effects = vec![Effect { kind: EffectKind::BackgroundBlur { radius: 12.0 }, visible: true }];
n.effects = vec![Effect { kind: EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(12.0)), visible: true }];
}
(d, p)
}
@ -121,7 +121,7 @@ fn inner_shadow_leaves_the_interior_alone() {
fn huge_backdrop_blur_renders_in_one_piece() {
let (mut d, p) = scene(true, true);
let g = d.find_by_name("Glass", None)[0];
d.get_mut(g).unwrap().effects = vec![Effect { kind: EffectKind::BackgroundBlur { radius: 1e6 }, visible: true }];
d.get_mut(g).unwrap().effects = vec![Effect { kind: EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(1e6)), visible: true }];
assert!(render_bands(&d, p, &view(1.0), &Options::default(), 4).is_none());
assert!(render_view(&d, p, &view(1.0), &Options::default()).is_some());
}

164
crates/render/src/blur.rs Normal file
View File

@ -0,0 +1,164 @@
//! Layer and background blurs: uniform (one Gaussian) or progressive (the radius ramps linearly
//! along a line, from a start radius at its start point to an end radius at its end point).
//!
//! A progressive blur blurs the pixels at a few radii between its smallest and largest radius,
//! spaced geometrically (steps between small radii show the most), and blends every pixel between
//! the two levels around its own radius. Each level is one Gaussian-like blur (three box passes,
//! linear in the pixel count whatever the radius), so the cost is a handful of uniform blurs.
use figcraft_doc::Blur;
use figcraft_geom::{Affine, Point};
use tiny_skia::Pixmap;
use crate::fx;
/// Most blur levels a progressive blur draws.
const MAX_LEVELS: usize = 8;
/// Ratio between neighbouring levels' radii (above one pixel).
const LEVEL_RATIO: f64 = 1.5;
/// Blur `p` with `b`. `size` is the layer's box (local units, for the progressive line's
/// unit-box points), `xf` maps local units to `p`'s pixels and `scale` is device pixels per unit.
pub fn apply(p: &mut Pixmap, b: &Blur, size: (f64, f64), xf: Affine, scale: f64) {
let b = b.sanitized();
let sigma = |r: f64| r / 2.0 * scale;
if !b.is_progressive() {
fx::blur(p, sigma(b.radius));
return;
}
let at = |u: (f64, f64)| xf * Point::new(u.0 * size.0, u.1 * size.1);
progressive(p, sigma(b.start_radius), sigma(b.radius), at(b.start), at(b.end));
}
/// The blur's standard deviation along the line: `s0` at `a` and before it, `s1` at `b` and past
/// it, linear in between (pixels).
struct Ramp {
s0: f64,
s1: f64,
/// t at pixel (0, 0)'s centre and its change per pixel across and down.
t0: f64,
tx: f64,
ty: f64,
}
impl Ramp {
fn new(s0: f64, s1: f64, a: Point, b: Point) -> Option<Ramp> {
let d = b - a;
let len2 = d.hypot2();
if !(len2.is_finite() && len2 > 1e-12 && a.x.is_finite() && a.y.is_finite()) {
return None;
}
let (tx, ty) = (d.x / len2, d.y / len2);
Some(Ramp { s0, s1, t0: (0.5 - a.x) * tx + (0.5 - a.y) * ty, tx, ty })
}
fn sigma(&self, x: f64, y: f64) -> f64 {
let t = (self.t0 + x * self.tx + y * self.ty).clamp(0.0, 1.0);
self.s0 + (self.s1 - self.s0) * t
}
}
/// The radii blurred for a ramp from `lo` to `hi` (pixels): `lo`, then geometric steps from one
/// pixel (or `lo`) up to `hi`, at most [`MAX_LEVELS`].
pub(crate) fn levels(lo: f64, hi: f64) -> Vec<f64> {
let mut out = Vec::with_capacity(MAX_LEVELS);
let base = lo.max(1.0);
if lo < base {
out.push(lo);
}
if hi <= base {
if hi > lo || out.is_empty() {
out.push(hi);
}
return out;
}
let steps = ((hi / base).ln() / LEVEL_RATIO.ln()).ceil().clamp(1.0, (MAX_LEVELS - 2) as f64) as usize;
out.extend((0..steps).map(|k| base * (hi / base).powf(k as f64 / steps as f64)));
out.push(hi);
out
}
/// The level below `s` and the weight (0–255) of the level above it.
fn bracket(levels: &[f64], s: f64) -> (u8, u8) {
let n = levels.len();
if n < 2 {
return (0, 0);
}
let k = levels.partition_point(|v| *v <= s).saturating_sub(1).min(n - 2);
let (Some(lo), Some(hi)) = (levels.get(k), levels.get(k + 1)) else { return (0, 0) };
let f = if hi > lo { ((s - lo) / (hi - lo)).clamp(0.0, 1.0) } else { 0.0 };
(k as u8, (f * 255.0).round() as u8)
}
/// A progressive blur of `p`: standard deviation `s0` at pixel point `a` (and before it) to `s1`
/// at `b` (and past it). A degenerate line blurs with `s1` everywhere.
pub fn progressive(p: &mut Pixmap, s0: f64, s1: f64, a: Point, b: Point) {
let cap = |s: f64| if s.is_finite() { s.clamp(0.0, fx::MAX_SIGMA) } else { 0.0 };
let (s0, s1) = (cap(s0), cap(s1));
let Some(ramp) = Ramp::new(s0, s1, a, b).filter(|_| (s1 - s0).abs() >= 0.05) else {
fx::blur(p, s1);
return;
};
let (w, h) = (p.width(), p.height());
// The levels span the whole ramp, not just the part on this pixmap: a layer cut by a band or
// the view blends the same levels as the whole layer would.
let (lo, hi) = (s0.min(s1), s0.max(s1));
let lv = levels(lo, hi);
// Each level's pixels: the box they lie in (inclusive), where it is used at all.
let mut used: [Option<[u32; 4]>; MAX_LEVELS] = [None; MAX_LEVELS];
let mut mark = |k: usize, x: u32, y: u32| {
if let Some(u) = used.get_mut(k) {
*u = Some(match *u {
Some([x0, y0, x1, y1]) => [x0.min(x), y0.min(y), x1.max(x), y1.max(y)],
None => [x, y, x, y],
});
}
};
let mut sel = Vec::with_capacity(w as usize * h as usize);
for y in 0..h {
for x in 0..w {
let (k, f) = bracket(&lv, ramp.sigma(f64::from(x), f64::from(y)));
mark(usize::from(k), x, y);
if f > 0 {
mark(usize::from(k) + 1, x, y);
}
sel.push((k, f));
}
}
// Each pixel: Σ weight × level value, the weights of its two levels summing to 255.
let mut acc = vec![0u16; p.data().len()];
for (k, s) in lv.iter().enumerate() {
let Some(Some([x0, y0, x1, y1])) = used.get(k).copied() else { continue };
// A level is blurred only around the pixels that use it: a pixel's blur reads at most
// 3 sigma + 3 pixels away (see `crate::backdrop_reach`), so the result is exact.
let reach = (s * 3.0).ceil() as u32 + 3;
let (cx, cy) = (x0.saturating_sub(reach), y0.saturating_sub(reach));
let (cw, ch) = ((x1 + reach + 1).min(w) - cx, (y1 + reach + 1).min(h) - cy);
let Some(mut level) = tiny_skia::IntRect::from_xywh(cx as i32, cy as i32, cw, ch).and_then(|r| p.clone_rect(r)) else { continue };
fx::blur(&mut level, *s);
let ld = level.data();
let row = cw as usize * 4;
for y in y0..=y1 {
for x in x0..=x1 {
let i = (y * w + x) as usize;
let Some(&(j, f)) = sel.get(i) else { continue };
let j = usize::from(j);
let wgt = if j == k {
255 - u16::from(f)
} else if j + 1 == k {
u16::from(f)
} else {
continue;
};
let li = (y - cy) as usize * row + (x - cx) as usize * 4;
if let (Some(px), Some(out)) = (ld.get(li..li + 4), acc.get_mut(i * 4..i * 4 + 4)) {
for (o, c) in out.iter_mut().zip(px) {
*o += wgt * u16::from(*c);
}
}
}
}
}
for (d, a) in p.data_mut().iter_mut().zip(&acc) {
*d = ((u32::from(*a) + 127) / 255) as u8;
}
}

View File

@ -11,7 +11,7 @@ use tiny_skia::{FillRule, IntRect, Mask, MaskType, Pixmap, PixmapPaint, Transfor
use crate::backdrop::{self, Under};
use crate::paint::{prepare, ts_blend};
use crate::{fx, grain, lod, shadow};
use crate::{blur, grain, lod, shadow};
/// Shared state for one render.
#[derive(Clone, Copy)]
@ -302,7 +302,7 @@ pub fn effect_margin(n: &Node) -> f64 {
.iter()
.filter(|e| e.visible)
.map(|e| match &e.kind {
EffectKind::LayerBlur { radius } => *radius * 2.0,
EffectKind::LayerBlur(b) => b.max_radius() * 2.0,
EffectKind::BackgroundBlur { .. } | EffectKind::Glass(_) => e.backdrop_radius() * 2.0,
EffectKind::DropShadow(s) => s.radius * 2.0 + s.spread.abs() * figcraft_doc::SPREAD_MITER_LIMIT + s.offset.0.abs() + s.offset.1.abs(),
EffectKind::Texture(t) => t.radius.abs(),
@ -368,7 +368,7 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep
return;
}
if !needs_layer(n) {
draw_content(ctx, pix, n, xf, depth);
draw_content(ctx, pix, n, xf, depth, None);
return;
}
// Isolated layer covering the node's paint bounds, clipped to the visible area + margin. Its
@ -401,22 +401,26 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep
}
let op = n.opacity.clamp(0.0, 1.0);
{
// Inner shadows paint over the fills, under the children and strokes.
let inner = |l: &mut Pixmap| {
for e in n.effects.iter().filter(|e| e.visible) {
if let EffectKind::InnerShadow(s) = &e.kind
&& let Some(sh) = &shape
&& let Some(inner) = shadow::inner_shadow(sh, lw, lh, lxf, ctx.scale, s)
{
l.draw_pixmap(0, 0, inner.as_ref(), &PixmapPaint::default(), Transform::identity(), None);
}
}
};
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) };
draw_content(&Ctx { under: Some(&under), ..*ctx }, &mut layer, n, lxf, depth);
}
for e in n.effects.iter().filter(|e| e.visible) {
if let EffectKind::InnerShadow(s) = &e.kind
&& let Some(sh) = &shape
&& let Some(inner) = shadow::inner_shadow(sh, lw, lh, lxf, ctx.scale, s)
{
layer.draw_pixmap(0, 0, inner.as_ref(), &PixmapPaint::default(), Transform::identity(), None);
}
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.
for e in n.effects.iter().filter(|e| e.visible) {
match &e.kind {
EffectKind::LayerBlur { radius } => fx::blur(&mut layer, radius / 2.0 * ctx.scale),
EffectKind::LayerBlur(b) => blur::apply(&mut layer, b, (n.width, n.height), lxf, ctx.scale),
EffectKind::Noise(nz) => grain::noise(&mut layer, nz, lxf, id.0 as u32),
EffectKind::Texture(t) => grain::texture(&mut layer, t, lxf, ctx.scale, id.0 as u32),
_ => {}
@ -454,8 +458,11 @@ fn draw_outline(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize)
}
}
/// Fills, children (clipped), strokes and text of a node, without opacity/effects.
fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize) {
/// What paints over a node's fills, under its children and strokes (its inner shadows).
type OverFills<'a> = Option<&'a dyn Fn(&mut Pixmap)>;
/// Fills, `over_fills`, children (clipped), strokes and text of a node, without opacity/effects.
fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize, over_fills: OverFills) {
let doc = ctx.doc;
let (w, h) = (n.width, n.height);
match &n.kind {
@ -463,7 +470,12 @@ fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize)
draw_children(ctx, pix, n, xf, depth);
}
NodeKind::Group => draw_children(ctx, pix, n, xf, depth),
NodeKind::Text(_) if ctx.lod && lod::greeked(n, ctx.scale) => lod::greek_text(pix, n, xf, text_box_bounds(n)),
NodeKind::Text(_) if ctx.lod && lod::greeked(n, ctx.scale) => {
lod::greek_text(pix, n, xf, text_box_bounds(n));
if let Some(f) = over_fills {
f(pix);
}
}
NodeKind::Text(t) => {
let Some((layout, dy)) = figcraft_text::layout_node(n) else { return };
let gxf = xf * Affine::translate((0.0, dy));
@ -479,13 +491,18 @@ fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize)
}
let mut keys: Vec<usize> = by_style.keys().copied().collect();
keys.sort_unstable();
for k in keys {
let Some(path) = by_style.get(&k) else { continue };
let Some(tp) = ts_path(path) else { continue };
let fills = layout.styles.get(k).and_then(|s| s.fills.as_ref()).unwrap_or(&n.fills);
// Every run's fills, then what paints over the fills, then the strokes on top.
for k in &keys {
let Some(tp) = by_style.get(k).and_then(ts_path) else { continue };
let fills = layout.styles.get(*k).and_then(|s| s.fills.as_ref()).unwrap_or(&n.fills);
fill_with(pix, &tp, fills, w, h, gxf, FillRule::Winding, doc, None);
if n.has_visible_stroke() {
let props = figcraft_doc::StrokeProps { align: StrokeAlign::Center, ..n.stroke.clone() };
}
if let Some(f) = over_fills {
f(pix);
}
if n.has_visible_stroke() {
let props = figcraft_doc::StrokeProps { align: StrokeAlign::Center, ..n.stroke.clone() };
for path in keys.iter().filter_map(|k| by_style.get(k)) {
let s = figcraft_pathops::stroke_center(path, props.weight, &props);
if let Some(ts) = ts_path(&s) {
fill_with(pix, &ts, &n.strokes, w, h, gxf, FillRule::Winding, doc, None);
@ -507,6 +524,9 @@ fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize)
{
fill_with(pix, &tp, &n.fills, w, h, xf, *rule, doc, None);
}
if let Some(f) = over_fills {
f(pix);
}
if !matches!(n.kind, NodeKind::BooleanOp { .. }) && !n.children.is_empty() && !n.hides_contents() {
if clips {
draw_children_clipped(ctx, pix, n, xf, depth, outline.as_ref().map(|(p, _)| p));
@ -573,11 +593,11 @@ fn draw_masked(ctx: &Ctx, pix: &mut Pixmap, mask_id: NodeId, content: &[NodeId],
let paint = PixmapPaint { opacity: m2.opacity, ..Default::default() };
let mut tmp = Pixmap::new(w, h);
if let Some(tmp) = tmp.as_mut() {
draw_content(ctx, tmp, &m2, mxf, depth + 1);
draw_content(ctx, tmp, &m2, mxf, depth + 1, None);
mask_px.draw_pixmap(0, 0, tmp.as_ref(), &paint, Transform::identity(), None);
}
} else {
draw_content(ctx, &mut mask_px, &m2, mxf, depth + 1);
draw_content(ctx, &mut mask_px, &m2, mxf, depth + 1, None);
}
}
let Some(mut layer) = Pixmap::new(w, h) else { return };

View File

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

View File

@ -70,12 +70,15 @@ fn transpose(src: &[u8], dst: &mut [u8], w: usize, h: usize) {
}
}
/// The largest blur standard deviation drawn (pixels): wider blurs look the same.
pub const MAX_SIGMA: f64 = 500.0;
/// Gaussian-like blur of the whole pixmap with standard deviation `sigma` (pixels).
pub fn blur(p: &mut Pixmap, sigma: f64) {
if !(sigma.is_finite() && sigma > 0.3) {
return;
}
let sigma = sigma.min(500.0);
let sigma = sigma.min(MAX_SIGMA);
let (w, h) = (p.width() as usize, p.height() as usize);
let radii = box_sizes(sigma);
let mut a = p.data().to_vec();

View File

@ -54,14 +54,23 @@ impl Cells {
/// alpha of the pixel under it, blended with the noise's blend mode. Grains are twice the noise
/// 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);
}
}
/// `nz`'s grains for the pixels of `layer` (see [`noise`]), to draw over it with the noise's blend
/// mode; `None` when there are none.
pub fn grains(layer: &Pixmap, nz: &Noise, xf: Affine, seed: u32) -> Option<Pixmap> {
let (sx, sy) = nz.grain();
let Some(cells) = Cells::new(xf, (sx * 2.0, sy * 2.0)) else { return };
let cells = Cells::new(xf, (sx * 2.0, sy * 2.0))?;
let density = if nz.density.is_finite() { nz.density.clamp(0.0, 1.0) as f32 } else { 0.0 };
if density <= 0.0 {
return;
return None;
}
let (w, h) = (layer.width(), layer.height());
let Some(mut grains) = Pixmap::new(w, h) else { return };
let mut grains = Pixmap::new(w, h)?;
let (c1, c2) = (nz.color.sanitized(), nz.secondary.sanitized());
let multi_alpha = if nz.opacity.is_finite() { nz.opacity.clamp(0.0, 1.0) } else { 0.0 };
let src = layer.data();
@ -91,8 +100,7 @@ pub fn noise(layer: &mut Pixmap, nz: &Noise, xf: Affine, seed: u32) {
}
}
}
let paint = PixmapPaint { blend_mode: ts_blend(nz.blend), ..Default::default() };
layer.draw_pixmap(0, 0, grains.as_ref(), &paint, Transform::identity(), None);
Some(grains)
}
/// Scatter `layer`'s pixels by up to the texture's radius (`scale` device pixels per unit), one

View File

@ -6,6 +6,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod backdrop;
pub mod blur;
pub mod draw;
pub mod fx;
pub mod glass;
@ -165,6 +166,48 @@ pub fn render_nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut
}
}
/// How an export draws a node by itself (see [`render_alone`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Alone {
/// With its effects, opacity and blend, but its backdrop effects (background blur and glass
/// read what lies under it: see [`backdrop::effects_beneath`]).
WithEffects,
/// Its content only: no effects, opacity or blend.
Plain,
}
/// Node `id` drawn by itself into `view` (a mask layer as a plain layer), for exports that draw
/// what vectors can't (blurs, grain, shadows) as images.
pub fn render_alone(doc: &Document, id: NodeId, view: &View, how: Alone) -> Option<Pixmap> {
let mut tmp = doc.clone();
let n = tmp.node_mut(id)?;
n.is_mask = false;
match how {
Alone::WithEffects => {
n.effects.retain(|e| !matches!(e.kind, figcraft_doc::EffectKind::BackgroundBlur(_) | figcraft_doc::EffectKind::Glass(_)))
}
Alone::Plain => {
n.effects.clear();
n.opacity = 1.0;
n.blend = figcraft_color::BlendMode::Normal;
}
}
let mut pix = Pixmap::new(view.width.clamp(1, 16_384), view.height.clamp(1, 16_384))?;
render_nodes_into(&tmp, &[id], view, &mut pix);
Some(pix)
}
/// The page area node `id` paints into with its effects (blurs, shadows and texture reach past
/// its bounds): what an export rasterises of it.
pub fn raster_bounds(doc: &Document, id: NodeId) -> Option<Rect> {
let n = doc.node(id)?;
let sc = doc.world_transform(id).determinant().abs().sqrt();
let sc = if sc.is_finite() && sc > 0.0 { sc } else { 1.0 };
let m = draw::effect_margin(n).abs() * sc;
let m = if m.is_finite() { m } else { 0.0 };
Some(doc.world_render_bounds(id)?.inflate(m, m))
}
/// The page-space rectangle a node exports: a clipping frame's box (plus its own effects), else
/// everything it paints.
pub fn export_bounds(doc: &Document, id: NodeId) -> Option<Rect> {
@ -322,10 +365,14 @@ mod backdrop_tests;
#[cfg(test)]
mod band_tests;
#[cfg(test)]
mod effect_order_tests;
#[cfg(test)]
mod grain_tests;
#[cfg(test)]
mod lod_tests;
#[cfg(test)]
mod progressive_tests;
#[cfg(test)]
mod retained_tests;
#[cfg(test)]
mod spread_tests;

View File

@ -0,0 +1,166 @@
//! Progressive blurs: the radius ramps from the start radius at the start point to the end radius
//! at the end point (layer and background blur), matching a uniform blur where the ramp is flat;
//! levels, degenerate lines, bands and hostile values.
use figcraft_color::{Color, Paint};
use figcraft_doc::{Blur, BlurType, Document, Effect, EffectKind, NodeId, NodeKind};
use super::*;
fn progressive(start_radius: f64, radius: f64, start: (f64, f64), end: (f64, f64)) -> Blur {
Blur { radius, blur_type: BlurType::Progressive, start_radius, start, end }
}
/// A white 200 × 200 frame (not clipping) with black vertical stripes 4 wide every 8.
fn stripes(d: &mut Document, parent: NodeId) -> NodeId {
let f = d.create(parent, NodeKind::Frame, "Stripes", 0.0, 0.0, 200.0, 200.0).unwrap();
d.get_mut(f).unwrap().fills = vec![Paint::solid(Color::WHITE)];
for i in 0..25 {
let s = d.create(f, NodeKind::Rectangle, "S", f64::from(i) * 8.0, 0.0, 4.0, 200.0).unwrap();
d.get_mut(s).unwrap().fills = vec![Paint::solid(Color::BLACK)];
}
f
}
/// The stripes with a layer blur `kind` on the frame.
fn blurred(kind: Option<Blur>) -> (Document, NodeId) {
let mut d = Document::new("progressive");
let p = d.first_page().unwrap();
let f = stripes(&mut d, p);
if let Some(b) = kind {
d.get_mut(f).unwrap().effects = vec![Effect { kind: EffectKind::LayerBlur(b), visible: true }];
}
(d, f)
}
fn render(d: &Document, id: NodeId) -> Pixmap {
let mut pix = Pixmap::new(200, 200).unwrap();
render_nodes_into(d, &[id], &View { origin: (0.0, 0.0), scale: 1.0, width: 200, height: 200 }, &mut pix);
pix
}
fn red(p: &Pixmap, x: u32, y: u32) -> u8 {
p.data()[((y * p.width() + x) * 4) as usize]
}
/// Brightest minus darkest red across the middle of row `y` (columns 40..160).
fn contrast(p: &Pixmap, y: u32) -> u8 {
let row: Vec<u8> = (40..160).map(|x| red(p, x, y)).collect();
row.iter().max().unwrap() - row.iter().min().unwrap()
}
fn row(p: &Pixmap, y: u32) -> Vec<u8> {
let w = p.width() as usize;
p.data()[y as usize * w * 4..(y as usize + 1) * w * 4].to_vec()
}
#[test]
fn layer_blur_ramps_from_sharp_to_the_end_radius() {
// Sharp above a quarter of the way down, radius 16 below three quarters.
let (d, f) = blurred(Some(progressive(0.0, 16.0, (0.5, 0.25), (0.5, 0.75))));
let pix = render(&d, f);
let (sharp_d, sharp_f) = blurred(None);
let sharp = render(&sharp_d, sharp_f);
let (uni_d, uni_f) = blurred(Some(Blur::uniform(16.0)));
let uniform = render(&uni_d, uni_f);
// Before the start: untouched; past the end: exactly the uniform blur.
for y in [10, 30, 45] {
assert_eq!(row(&pix, y)[40 * 4..160 * 4], row(&sharp, y)[40 * 4..160 * 4], "row {y}");
}
for y in [155, 175, 190] {
assert_eq!(row(&pix, y)[40 * 4..160 * 4], row(&uniform, y)[40 * 4..160 * 4], "row {y}");
}
// In between the stripes fade steadily.
let c: Vec<u8> = [45, 70, 90, 110, 130, 155].iter().map(|y| contrast(&pix, *y)).collect();
assert_eq!(c[0], 255, "{c:?}");
assert!(c.windows(2).all(|w| w[1] <= w[0]), "{c:?}");
assert!(c[2] < c[0] && c[4] < c[2] && c[5] < 40, "{c:?}");
}
#[test]
fn the_ramp_follows_the_line_and_runs_either_way() {
// Left to right, from radius 20 down to 0: blurred on the left, sharp on the right.
let (d, f) = blurred(Some(progressive(20.0, 0.0, (0.0, 0.5), (1.0, 0.5))));
let pix = render(&d, f);
let span = |x0: u32| {
let v: Vec<u8> = (x0..x0 + 16).map(|x| red(&pix, x, 100)).collect();
v.iter().max().unwrap() - v.iter().min().unwrap()
};
assert!(span(10) < 60, "{}", span(10));
assert_eq!(span(176), 255);
}
#[test]
fn a_degenerate_line_blurs_with_the_end_radius() {
let (d, f) = blurred(Some(progressive(0.0, 10.0, (0.5, 0.5), (0.5, 0.5))));
let (u, uf) = blurred(Some(Blur::uniform(10.0)));
assert_eq!(render(&d, f).data(), render(&u, uf).data());
// A flat ramp is a uniform blur.
let (d, f) = blurred(Some(progressive(10.0, 10.0, (0.0, 0.0), (1.0, 1.0))));
assert_eq!(render(&d, f).data(), render(&u, uf).data());
}
#[test]
fn levels_span_the_ramp_and_stay_few() {
for (lo, hi) in [(0.0, 0.5), (0.0, 8.0), (0.2, 3.0), (2.0, 500.0), (0.0, 500.0), (5.0, 5.5)] {
let l = crate::blur::levels(lo, hi);
assert!(l.len() >= 2 && l.len() <= 8, "{lo}..{hi}: {l:?}");
assert_eq!((l[0], *l.last().unwrap()), (lo, hi), "{l:?}");
assert!(l.windows(2).all(|w| w[1] > w[0]), "{l:?}");
}
}
#[test]
fn background_blur_ramps_across_the_backdrop() {
let mut d = Document::new("progressive backdrop");
let p = d.first_page().unwrap();
let _ = stripes(&mut d, p);
let pane = d.create(p, NodeKind::Rectangle, "Pane", 20.0, 40.0, 160.0, 120.0).unwrap();
let n = d.get_mut(pane).unwrap();
n.fills = vec![];
// Sharp at the top of the pane, radius 16 at its bottom.
n.effects = vec![Effect { kind: EffectKind::BackgroundBlur(progressive(0.0, 16.0, (0.5, 0.0), (0.5, 1.0))), visible: true }];
let pix = render_view(&d, p, &View { origin: (0.0, 0.0), scale: 1.0, width: 200, height: 200 }, &Options::default()).unwrap();
assert_eq!(contrast(&pix, 42), 255, "the top of the pane is sharp");
assert!(contrast(&pix, 100) < 200, "{}", contrast(&pix, 100));
assert!(contrast(&pix, 157) < 40, "{}", contrast(&pix, 157));
// Outside the pane the stripes stay sharp.
assert_eq!(contrast(&pix, 20), 255);
assert_eq!(contrast(&pix, 180), 255);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn bands_match_one_piece() {
let mut d = Document::new("progressive bands");
let p = d.first_page().unwrap();
for (i, x) in [0.0, 220.0, 440.0].into_iter().enumerate() {
let f = stripes(&mut d, p);
d.get_mut(f).unwrap().transform = Affine::translate((x, 20.0));
let b = progressive(0.0, 12.0, (0.0, 0.0), (1.0, 1.0));
let kind = if i == 1 { EffectKind::BackgroundBlur(b) } else { EffectKind::LayerBlur(b) };
d.get_mut(f).unwrap().effects = vec![Effect { kind, visible: true }];
}
let v = View { origin: (-10.0, 0.0), scale: 1.5, width: 1000, height: 400 };
let opts = Options::default();
let one = render_view_single(&d, p, &v, &opts).unwrap();
let banded = render_view(&d, p, &v, &opts).unwrap();
assert_eq!(one.data(), banded.data());
}
#[test]
fn hostile_progressive_values_render() {
// Absurd radii make the largest layers there are, so layer and background blur take turns.
let bad = [f64::NAN, f64::INFINITY, -1e12, 1e12, 0.0, 1e12];
for (i, v) in bad.into_iter().enumerate() {
let kind = if i % 2 == 0 { EffectKind::LayerBlur as fn(Blur) -> EffectKind } else { EffectKind::BackgroundBlur };
let mut d = Document::new("hostile");
let p = d.first_page().unwrap();
let f = stripes(&mut d, p);
d.get_mut(f).unwrap().effects = vec![Effect { kind: kind(progressive(v, -v, (v, 0.5), (0.5, v))), visible: true }];
let pix = render_view(&d, p, &View { origin: (0.0, 0.0), scale: 1.0, width: 120, height: 120 }, &Options::default());
assert!(pix.is_some(), "{v}");
// Too large to export at this scale is refused, not drawn.
assert!(render_node(&d, f, 1.0).is_none_or(|p| p.width() <= 16_384));
}
}

View File

@ -64,7 +64,7 @@ fn scene(cut_sensitive: bool) -> Scene {
});
let glass = d.create(page, NodeKind::Rectangle, "Glass", 120.0, 280.0, 120.0, 40.0).unwrap();
d.get_mut(glass).unwrap().fills = vec![Paint::solid(Color::new(1.0, 1.0, 1.0, 0.3))];
d.get_mut(glass).unwrap().effects.push(Effect { kind: EffectKind::BackgroundBlur { radius: 8.0 }, visible: true });
d.get_mut(glass).unwrap().effects.push(Effect { kind: EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(8.0)), visible: true });
}
Scene { doc: Arc::new(d), page, rect, text, frame, child }
}

View File

@ -281,7 +281,7 @@ fn hostile_sizes() {
let n = d.get_mut(r).unwrap();
n.width = f64::NAN;
n.height = 1e12;
n.effects.push(Effect { kind: EffectKind::LayerBlur { radius: 1e9 }, visible: true });
n.effects.push(Effect { kind: EffectKind::LayerBlur(figcraft_doc::Blur::uniform(1e9)), visible: true });
let _ = render_view(&d, p, &view(64, 64), &Options::default());
let _ = render_node(&d, r, f64::NAN);
let _ = render_view(&d, p, &View { origin: (f64::NAN, 0.0), scale: 0.0, width: 0, height: 0 }, &Options::default());

View File

@ -6,7 +6,12 @@
//! alignment survives exactly, text is outlined, images are embedded as data URIs, and shadows
//! and blurs become SVG filters. Shadow spread offsets the outline like the renderer (the shape
//! stroked twice the spread wide with mitred joins, cut away with a mask to shrink); inner shadows
//! are masked, blurred rectangles clipped to the shape.
//! are masked, blurred rectangles clipped to the shape, over the fills and under the strokes.
//!
//! What SVG filters can't match is drawn by the renderer and embedded as PNG images, so it looks
//! like the canvas: noise grains over the vector layer (crisp, blended with the noise's blend
//! mode), background blur and glass from a render of what lies under the layer among the exported
//! layers, and layers with texture or a progressive layer blur whole.
//!
//! Import (via usvg) turns an SVG file into a frame of vector nodes.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
@ -21,6 +26,12 @@ use figcraft_color::{Color, GradientKind, Paint, PaintKind};
use figcraft_doc::{Document, EffectKind, Node, NodeId, NodeKind, SPREAD_MITER_LIMIT, Shadow};
use figcraft_geom::{Affine, BezPath, Rect};
use figcraft_render::shadow::Outline;
use figcraft_render::{Alone, Pixmap, View, backdrop};
/// Device pixels per page unit of embedded renders (blurs, glass, texture).
const RASTER_SCALE: f64 = 2.0;
/// Largest side of an embedded render, in pixels.
const MAX_RASTER_SIDE: f64 = 4096.0;
#[derive(Clone, Debug)]
pub struct SvgOptions {
@ -38,6 +49,10 @@ impl Default for SvgOptions {
struct W<'a> {
doc: &'a Document,
/// The exported layers, in z-order (what backdrops see).
roots: &'a [NodeId],
/// Page coordinates → SVG coordinates.
page: Affine,
defs: String,
body: String,
next: u32,
@ -81,6 +96,27 @@ fn matrix(a: Affine) -> String {
format!("matrix({} {} {} {} {} {})", num(c[0]), num(c[1]), num(c[2]), num(c[3]), num(c[4]), num(c[5]))
}
/// A view of page area `b` at `k` device pixels per unit (fewer when that makes it larger than
/// [`MAX_RASTER_SIDE`] a side).
fn raster_view(b: Rect, k: f64) -> Option<View> {
if !(b.width() > 0.0 && b.height() > 0.0 && [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) && k.is_finite() && k > 0.0) {
return None;
}
let side = b.width().max(b.height()) * k;
let k = if side > MAX_RASTER_SIDE { k * MAX_RASTER_SIDE / side } else { k };
let (w, h) = ((b.width() * k).ceil().max(1.0), (b.height() * k).ceil().max(1.0));
Some(View { origin: (b.x0, b.y0), scale: k, width: w as u32, height: h as u32 })
}
/// ` style="mix-blend-mode:…"` for a blend mode other than normal (else nothing).
fn blend_style(b: figcraft_color::BlendMode) -> String {
if matches!(b, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) {
String::new()
} else {
format!(" style=\"mix-blend-mode:{}\"", b.css())
}
}
fn esc(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
@ -100,11 +136,7 @@ impl W<'_> {
if !p.visible || p.opacity <= 0.0 {
return None;
}
let blend = if matches!(p.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) {
String::new()
} else {
format!(" style=\"mix-blend-mode:{}\"", p.blend.css())
};
let blend = blend_style(p.blend);
match &p.kind {
PaintKind::Solid { color } => {
let a = color.a * p.opacity;
@ -217,8 +249,8 @@ impl W<'_> {
);
merge.push(format!("s{i}"));
}
EffectKind::LayerBlur { radius } => {
let _ = write!(f, "<feGaussianBlur in=\"SourceGraphic\" stdDeviation=\"{}\" result=\"blur\"/>", num(radius / 2.0));
EffectKind::LayerBlur(b) => {
let _ = write!(f, "<feGaussianBlur in=\"SourceGraphic\" stdDeviation=\"{}\" result=\"blur\"/>", num(b.sanitized().radius / 2.0));
}
_ => {}
}
@ -242,15 +274,21 @@ impl W<'_> {
return;
}
let xf = xf * n.transform;
let mut attrs = String::new();
if self.opts.include_ids {
let _ = write!(attrs, " id=\"{}\"", esc(&n.name.replace(' ', "_")));
let id_attr = if self.opts.include_ids { format!(" id=\"{}\"", esc(&n.name.replace(' ', "_"))) } else { String::new() };
if self.raster_whole(id, n, &id_attr) {
return;
}
let mut attrs = id_attr;
if n.opacity < 0.999 {
let _ = write!(attrs, " opacity=\"{}\"", num(f64::from(n.opacity)));
}
if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) {
let _ = write!(attrs, " style=\"mix-blend-mode:{}\"", n.blend.css());
let grains = self.grain_images(id, n);
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.
attrs.push_str(" style=\"isolation:isolate\"");
}
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 };
@ -261,6 +299,11 @@ impl W<'_> {
let _ = write!(attrs, " filter=\"url(#{fid})\"");
}
let _ = write!(self.body, "<g{attrs}>");
if backdrop::has_effects(n)
&& let Some(img) = self.backdrop_image(id, None)
{
self.body.push_str(&img);
}
let (w, h) = (n.width, n.height);
let outline = match &n.kind {
NodeKind::Group | NodeKind::Page { .. } => None,
@ -277,6 +320,14 @@ impl W<'_> {
}
}
}
// Inner shadows paint over the fills, under the children and strokes.
if let Some(o) = &outline {
for e in n.effects.iter().filter(|e| e.visible) {
if let EffectKind::InnerShadow(s) = &e.kind {
self.inner_shadow(o, s, xf);
}
}
}
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 {
@ -337,14 +388,92 @@ impl W<'_> {
}
}
}
if let Some(o) = &outline {
for e in n.effects.iter().filter(|e| e.visible) {
if let EffectKind::InnerShadow(s) = &e.kind {
self.inner_shadow(o, s, xf);
}
self.body.push_str(&grains);
self.body.push_str("</g>");
}
/// `<image>` markup of `pix`, a render of `view`'s page area, with `attrs`; `None` when it is
/// empty.
fn image(&self, pix: &Pixmap, view: &View, attrs: &str) -> Option<String> {
if pix.data().iter().skip(3).step_by(4).all(|a| *a == 0) {
return None;
}
let png = figcraft_render::encode_png(pix).ok()?;
let b64 = base64::engine::general_purpose::STANDARD.encode(png);
let t = self.page * Affine::translate(view.origin) * Affine::scale(1.0 / view.scale.max(1e-9));
Some(format!(
"<image transform=\"{}\" width=\"{}\" height=\"{}\"{attrs} href=\"data:image/png;base64,{b64}\"/>",
matrix(t),
pix.width(),
pix.height()
))
}
/// Node `id`'s background blurs and glass as an image over its shape, drawn by the renderer
/// from what lies under it among the exported layers (`opacity`: the layer's, when the image
/// isn't inside its group).
fn backdrop_image(&self, id: NodeId, opacity: Option<f32>) -> Option<String> {
let view = raster_view(backdrop::shape_bounds(self.doc, id)?, RASTER_SCALE)?;
let pix = backdrop::effects_beneath(self.doc, self.roots, id, &view, None)?;
let op = opacity.filter(|o| *o < 0.999).map(|o| format!(" opacity=\"{}\"", num(f64::from(o)))).unwrap_or_default();
self.image(&pix, &view, &op)
}
/// Layers whose look SVG can't build (texture scatters their pixels, a progressive blur varies
/// across them, noise over a blur depends on their order) are drawn by the renderer and
/// embedded whole, over their backdrop effects. False when the layer isn't one of them (or
/// can't be rendered): it is written as vectors.
fn raster_whole(&mut self, id: NodeId, n: &Node, id_attr: &str) -> bool {
let has = |f: &dyn Fn(&EffectKind) -> bool| n.effects.iter().any(|e| e.visible && f(&e.kind));
let whole = has(&|k| matches!(k, EffectKind::Texture(_)))
|| has(&|k| matches!(k, EffectKind::LayerBlur(b) if b.is_progressive()))
|| (has(&|k| matches!(k, EffectKind::Noise(_))) && has(&|k| matches!(k, EffectKind::LayerBlur(_))));
if !whole {
return false;
}
let Some(view) = figcraft_render::raster_bounds(self.doc, id).and_then(|b| raster_view(b, RASTER_SCALE)) else { return false };
// The renderer applies the layer's opacity; its blend mode applies to the SVG beneath.
let Some(img) = figcraft_render::render_alone(self.doc, id, &view, Alone::WithEffects).and_then(|p| self.image(&p, &view, "")) else {
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());
true
}
/// Node `id`'s noise grains as images over the layer (crisp pixels, the noise's blend mode),
/// drawn by the renderer from the layer's content (empty without noise).
fn grain_images(&self, id: NodeId, n: &Node) -> String {
let noises: Vec<&figcraft_doc::Noise> = n
.effects
.iter()
.filter(|e| e.visible)
.filter_map(|e| match &e.kind {
EffectKind::Noise(nz) => Some(nz),
_ => None,
})
.collect();
if noises.is_empty() {
return String::new();
}
// At least two pixels a grain (grains are twice the noise size across), up to 4 per unit.
let world = self.doc.world_transform(id);
let sc = world.determinant().abs().sqrt();
let cell = noises.iter().map(|nz| nz.grain().0.min(nz.grain().1) * 2.0).fold(f64::INFINITY, f64::min) * sc;
let k = if cell.is_finite() && cell > 0.0 { (2.0 / cell).clamp(RASTER_SCALE, 4.0) } else { RASTER_SCALE };
let Some(view) = self.doc.world_render_bounds(id).and_then(|b| raster_view(b, k)) else { return String::new() };
let Some(layer) = figcraft_render::render_alone(self.doc, id, &view, Alone::Plain) else { return String::new() };
let xf = view.transform() * world;
let mut out = String::new();
for nz in noises {
let blend = blend_style(nz.blend);
let blend = blend.strip_prefix(" style=\"").and_then(|b| b.strip_suffix('"')).map(|b| format!(";{b}")).unwrap_or_default();
let attrs = format!(" style=\"image-rendering:pixelated{blend}\"");
if let Some(img) = figcraft_render::grain::grains(&layer, nz, xf, id.0 as u32).and_then(|g| self.image(&g, &view, &attrs)) {
out.push_str(&img);
}
}
self.body.push_str("</g>");
out
}
/// Markup painting `outlines` (local, through `xf`) grown (`spread > 0`) or shrunk by `spread`
@ -489,8 +618,8 @@ fn sniff_mime(b: &[u8]) -> &'static str {
/// Export `ids` (in z-order) as one SVG document covering their export bounds.
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 mut w = W { doc, defs: String::new(), body: String::new(), next: 0, opts };
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 };
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);
@ -511,4 +640,6 @@ pub fn export_svg(doc: &Document, ids: &[NodeId], opts: &SvgOptions) -> Option<S
#[cfg(test)]
mod effects_tests;
#[cfg(test)]
mod raster_fx_tests;
#[cfg(test)]
mod tests;

View File

@ -0,0 +1,116 @@
//! Effects SVG filters can't match, embedded as renders: noise grains over the vector layer,
//! texture and progressive layer blur whole, background blur from what lies beneath; inner shadows
//! over the fills and under the strokes. Every export stays valid SVG and is deterministic.
use figcraft_color::{BlendMode, Color, Paint};
use figcraft_doc::{Blur, BlurType, Document, Effect, EffectKind, NodeId, NodeKind, Noise, Shadow, StrokeAlign, Texture};
use super::*;
const FILL: &str = "#3366CC";
const STROKE: &str = "#00FF00";
fn layer(effects: Vec<EffectKind>) -> (Document, NodeId) {
let mut d = Document::new("svg raster fx");
let p = d.first_page().unwrap();
let n = d.create(p, NodeKind::Rectangle, "S", 10.0, 10.0, 100.0, 80.0).unwrap();
let node = d.get_mut(n).unwrap();
node.fills = vec![Paint::solid(Color::from_hex(FILL).unwrap())];
node.effects = effects.into_iter().map(|kind| Effect { kind, visible: true }).collect();
(d, n)
}
/// Export and check the result parses as SVG.
fn export(d: &Document, ids: &[NodeId]) -> String {
let svg = export_svg(d, ids, &SvgOptions::default()).unwrap();
let mut d2 = Document::new("back");
let p2 = d2.first_page().unwrap();
import_svg(&mut d2, svg.as_bytes(), p2, 0.0, 0.0, "Imported").unwrap();
svg
}
fn progressive() -> Blur {
Blur { radius: 12.0, blur_type: BlurType::Progressive, ..Blur::default() }
}
#[test]
fn noise_is_a_crisp_grain_image_over_the_vector_layer() {
let (d, n) = layer(vec![EffectKind::Noise(Noise { density: 0.6, blend: BlendMode::Multiply, ..Noise::default() })]);
let svg = export(&d, &[n]);
assert!(svg.contains(&format!("fill=\"{FILL}\"")), "the layer stays vector: {svg}");
assert_eq!(svg.matches("<image").count(), 1, "{svg}");
assert!(svg.contains("style=\"image-rendering:pixelated;mix-blend-mode:multiply\""), "{svg}");
assert!(svg.contains("isolation:isolate"), "grains blend with the layer only: {svg}");
// Two pixels a grain: the image is twice the layer's size.
assert!(svg.contains("width=\"200\" height=\"160\""), "{svg}");
// The grains are the layer's own (stable across exports).
assert_eq!(svg, export(&d, &[n]));
}
#[test]
fn texture_and_progressive_layer_blur_embed_the_layer_whole() {
for kind in [EffectKind::Texture(Texture { radius: 3.0, ..Texture::default() }), EffectKind::LayerBlur(progressive())] {
let (d, n) = layer(vec![kind.clone()]);
let svg = export(&d, &[n]);
assert!(!svg.contains("<path"), "{kind:?}: {svg}");
assert!(!svg.contains("feGaussianBlur"), "{kind:?}: {svg}");
assert_eq!(svg.matches("<image").count(), 1, "{kind:?}: {svg}");
assert_eq!(svg, export(&d, &[n]), "deterministic");
}
// Noise over a uniform blur depends on their order: whole too. A uniform blur alone stays a filter.
let (d, n) = layer(vec![EffectKind::LayerBlur(Blur::uniform(6.0)), EffectKind::Noise(Noise::default())]);
assert!(!export(&d, &[n]).contains("<path"));
let (d, n) = layer(vec![EffectKind::LayerBlur(Blur::uniform(6.0))]);
let svg = export(&d, &[n]);
assert!(svg.contains("<feGaussianBlur in=\"SourceGraphic\" stdDeviation=\"3\"") && !svg.contains("<image"), "{svg}");
}
#[test]
fn background_blur_is_drawn_from_what_lies_beneath() {
for b in [Blur::uniform(10.0), progressive()] {
let (mut d, under) = layer(vec![]);
let p = d.first_page().unwrap();
let pane = d.create(p, NodeKind::Rectangle, "Pane", 40.0, 30.0, 100.0, 60.0).unwrap();
let n = d.get_mut(pane).unwrap();
n.fills = vec![Paint::solid(Color::from_hex("FFFFFF40").unwrap())];
n.effects = vec![Effect { kind: EffectKind::BackgroundBlur(b.clone()), visible: true }];
let svg = export(&d, &[under, pane]);
let img = svg.find("<image").unwrap_or_else(|| panic!("{b:?}: {svg}"));
// Under the pane's own fill.
let fill = svg.find("fill=\"#FFFFFF\"").unwrap();
assert!(img < fill, "{svg}");
// Exported alone, nothing lies beneath: no image.
assert!(!export(&d, &[pane]).contains("<image"));
}
}
#[test]
fn inner_shadows_go_over_fills_and_under_strokes() {
let shadow = Shadow { color: Color::from_hex("FF0000").unwrap(), offset: (0.0, 0.0), radius: 8.0, spread: 4.0, ..Shadow::default() };
let (mut d, n) = layer(vec![EffectKind::InnerShadow(shadow)]);
let node = d.get_mut(n).unwrap();
node.strokes = vec![Paint::solid(Color::from_hex(STROKE).unwrap())];
node.stroke.weight = 10.0;
node.stroke.align = StrokeAlign::Inside;
let svg = export(&d, &[n]);
let fill = svg.find(&format!("fill=\"{FILL}\"")).unwrap();
let shadow = svg.find("<g clip-path=").unwrap();
let stroke = svg.find(&format!("fill=\"{STROKE}\"")).unwrap();
assert!(fill < shadow && shadow < stroke, "{svg}");
}
#[test]
fn hostile_raster_effects_still_export() {
for v in [f64::NAN, f64::INFINITY, -1e12, 1e12] {
let (d, n) = layer(vec![
EffectKind::Noise(Noise { size: v, density: v, ..Noise::default() }),
EffectKind::BackgroundBlur(Blur { radius: v, start_radius: v, start: (v, v), ..progressive() }),
]);
assert!(export_svg(&d, &[n], &SvgOptions::default()).is_some());
let (d, n) = layer(vec![
EffectKind::Texture(Texture { radius: v, size: v, ..Texture::default() }),
EffectKind::LayerBlur(Blur { radius: v, end: (v, 0.0), ..progressive() }),
]);
assert!(export_svg(&d, &[n], &SvgOptions::default()).is_some());
}
}

View File

@ -1,17 +1,18 @@
//! On-canvas editing handles that sit above the pointer tools: gradient handles (while a
//! gradient fill is open in the colour picker), image crop handles (while a Crop-mode image
//! fill is open) and auto layout padding/gap handles (item gaps, wrapped row gaps, grid column
//! fill is open), progressive blur handles (while a progressive blur's settings are open) and
//! auto layout padding/gap handles (item gaps, wrapped row gaps, grid column
//! and row gaps, with the grid's tracks outlined).
use egui::{Align2, Color32, CornerRadius, Id, Pos2, Rect, Shape, Stroke, StrokeKind, pos2, vec2};
use figcraft_color::{ImagePaint, ImageScaleMode, Paint, PaintKind};
use figcraft_doc::{LayoutMode, NodeId};
use figcraft_doc::{Blur, Effect, LayoutMode, NodeId};
use figcraft_geom::Point;
use serde_json::json;
use crate::FigApp;
use crate::colorpicker::PickerTarget;
use crate::theme::{ACCENT, semibold};
use crate::{FigApp, RightTab};
const PINK: Color32 = Color32::from_rgb(0xF2, 0x48, 0x8C);
@ -23,6 +24,8 @@ enum Drag {
Padding { id: NodeId, side: usize, start: Pos2, value: f64 },
/// A gap band: `key` is the prop it edits (`gap` or `crossGap`), `vertical` the drag axis.
Gap { id: NodeId, start: Pos2, value: f64, vertical: bool, key: &'static str },
/// Progressive blur of effect `index`: 0 = start point, 1 = end point.
Blur { id: NodeId, index: usize, which: usize },
/// Image crop: corner 0..4 (tl, tr, br, bl) scales about the opposite corner, 4 moves.
Crop { id: NodeId, fills: bool, index: usize, which: usize, start: (f64, f64), rect: (f64, f64, f64, f64) },
}
@ -76,6 +79,26 @@ fn gradient_target(app: &FigApp) -> Option<(NodeId, bool, usize, figcraft_color:
}
}
/// The progressive blur being edited: a single selected layer's first visible progressive blur
/// whose settings are open in the Design panel (layer index, effect).
fn blur_target(app: &FigApp, ctx: &egui::Context) -> Option<(NodeId, usize, Effect)> {
if app.ui.right_tab != RightTab::Design {
return None;
}
let sel = app.session.selection();
let [id] = sel.as_slice() else { return None };
let n = app.session.doc().ok()?.node(*id)?;
let (i, e) =
n.effects.iter().enumerate().find(|(i, e)| e.visible && e.blur().is_some_and(Blur::is_progressive) && crate::right::effect_open(ctx, *i))?;
Some((*id, i, e.clone()))
}
/// A progressive blur's start and end points on screen.
fn blur_points(app: &FigApp, id: NodeId, e: &Effect) -> Option<[Pos2; 2]> {
let b = e.blur()?.sanitized();
Some([unit_to_screen(app, id, b.start)?, unit_to_screen(app, id, b.end)?])
}
/// Where `p` projects onto segment a→b: (t in 0..1, distance in points).
fn line_param(a: Pos2, b: Pos2, p: Pos2) -> Option<(f64, f32)> {
let ab = b - a;
@ -264,6 +287,17 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) {
}
return;
}
if let Some((id, _, e)) = blur_target(app, painter.ctx()) {
let Some([a, b]) = blur_points(app, id, &e) else { return };
painter.line_segment([a, b], Stroke::new(2.0, Color32::WHITE));
painter.line_segment([a, b], Stroke::new(1.0, Color32::from_black_alpha(120)));
// The start is hollow (least blur), the end filled (most blur by default).
painter.circle_filled(a, 5.0, Color32::WHITE);
painter.circle_stroke(a, 5.0, Stroke::new(1.5, ACCENT));
painter.circle_filled(b, 5.0, ACCENT);
painter.circle_stroke(b, 5.0, Stroke::new(1.5, Color32::WHITE));
return;
}
if let Some((id, _, _, _, r)) = crop_target(app) {
let Some(c) = crop_corners(app, id, r) else { return };
let outline = vec![c[0], c[1], c[2], c[3], c[0]];
@ -389,6 +423,17 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool {
}
return true;
}
if let Some((id, index, e)) = blur_target(app, ctx) {
let hit = blur_points(app, id, &e).and_then(|pts| pts.iter().position(|p| (*p - pos).length() <= 8.0));
if let Some(which) = hit {
ctx.set_cursor_icon(egui::CursorIcon::Grab);
if pressed {
let _ = app.session.begin_interaction("Edit progressive blur");
ctx.data_mut(|d| d.insert_temp(drag_id(), Drag::Blur { id, index, which }));
}
return true;
}
}
if let Some((id, fills, index, _, rect)) = crop_target(app) {
let Some(c) = crop_corners(app, id, rect) else { return false };
let start = screen_to_unit(app, id, pos).unwrap_or((0.0, 0.0));
@ -466,6 +511,18 @@ fn apply(app: &mut FigApp, drag: &Drag, pos: Pos2, shift: bool) {
let _ = app.session.execute(cmd, &json!({"ids": [id], "index": index, "paint": paint}));
let _: Option<Paint> = None;
}
Drag::Blur { id, index, which } => {
let Some(u) = screen_to_unit(app, *id, pos) else { return };
let Some(mut e) = app.session.doc().ok().and_then(|d| d.node(*id)).and_then(|n| n.effects.get(*index)).cloned() else { return };
if let Some(b) = e.blur_mut() {
if *which == 0 {
b.start = u;
} else {
b.end = u;
}
}
let _ = app.session.execute("effect.set", &json!({"ids": [id], "index": index, "effect": e}));
}
Drag::Padding { id, side, start, value } => {
let d = f64::from(match side {
0 => pos.y - start.y,

View File

@ -1299,11 +1299,11 @@ fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
let Some(list) = list else { return };
let mut drag = ListDrag::new(ui, "effects");
for (i, e) in list.iter().enumerate().rev() {
let mut open = ui.data(|d| d.get_temp::<bool>(Id::new(("fx_open", i)))).unwrap_or(false);
let mut open = effect_open(ui.ctx(), i);
let header = row_rect(ui, 32.0, |ui| {
if icons::button(ui, Icon::Effects, 24.0, open, "Effect settings").clicked() {
open = !open;
ui.data_mut(|d| d.insert_temp(Id::new(("fx_open", i)), open));
ui.data_mut(|d| d.insert_temp(Id::new((FX_OPEN, i)), open));
}
let w = ui.available_width() - 56.0;
let resp = widgets::dropdown(ui, e.label(), w, None);
@ -1333,13 +1333,21 @@ fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
}
if let Some((from, to)) = drag.finish(ui) {
app.run("effect.move", json!({"ids": ids, "from": from, "to": to}));
move_flags(ui, "fx_open", list.len(), from, to);
move_flags(ui, FX_OPEN, list.len(), from, to);
if let Some(crate::colorpicker::PickerTarget::Effect { index, .. }) = &mut app.ui.picker {
*index = moved_index(*index, from, to);
}
}
}
/// Salt of the flags that keep effect rows' settings open.
const FX_OPEN: &str = "fx_open";
/// Whether effect `i`'s settings are open in the Design panel.
pub(crate) fn effect_open(ctx: &egui::Context, i: usize) -> bool {
ctx.data(|d| d.get_temp::<bool>(Id::new((FX_OPEN, i)))).unwrap_or(false)
}
/// A number field of effect `i` that can be bound to a variable (`effects/<i>/<prop>`).
fn fx_field(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, prop: &str, lead: Lead, value: f64, width: f32) -> Option<FieldEdit> {
crate::variable_ui::number_field(app, ui, ids, &format!("effects/{i}/{prop}"), Id::new(("fx", prop, i)), lead, Some(value), "", width)
@ -1406,15 +1414,37 @@ fn effect_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &
});
}
}
EffectKind::LayerBlur { radius } | EffectKind::BackgroundBlur { radius } => {
let bg = matches!(e.kind, EffectKind::BackgroundBlur { .. });
EffectKind::LayerBlur(b) | EffectKind::BackgroundBlur(b) => {
let with = |f: &dyn Fn(&mut Blur)| {
let mut e2 = e.clone();
if let Some(b2) = e2.blur_mut() {
f(b2);
}
e2
};
row(ui, 32.0, |ui| {
let ed = fx_field(app, ui, ids, i, "radius", Lead::Text("Blur"), *radius, half * 2.0 + 8.0);
edit(app, ed, &|v| Effect {
kind: if bg { EffectKind::BackgroundBlur { radius: v.max(0.0) } } else { EffectKind::LayerBlur { radius: v.max(0.0) } },
visible: e.visible,
let resp = widgets::dropdown(ui, b.blur_type.label(), half * 2.0 + 8.0, None);
menu_popup(&resp).show(|ui| {
for t in BlurType::ALL {
if menu_item(ui, t.label(), "") {
set(app, with(&|b2| b2.blur_type = t), None);
ui.close();
}
}
});
});
row(ui, 32.0, |ui| {
if b.is_progressive() {
// The ramp's start and end points are dragged on the canvas.
let ed = fx_field(app, ui, ids, i, "startRadius", Lead::Text("Start"), b.start_radius, half);
edit(app, ed, &|v| with(&|b2| b2.start_radius = v.clamp(0.0, MAX_BLUR_RADIUS)));
let ed = fx_field(app, ui, ids, i, "radius", Lead::Text("End"), b.radius, half);
edit(app, ed, &|v| with(&|b2| b2.radius = v.clamp(0.0, MAX_BLUR_RADIUS)));
} else {
let ed = fx_field(app, ui, ids, i, "radius", Lead::Text("Blur"), b.radius, half * 2.0 + 8.0);
edit(app, ed, &|v| with(&|b2| b2.radius = v.clamp(0.0, MAX_BLUR_RADIUS)));
}
});
}
EffectKind::Noise(nz) => {
let with = |f: &dyn Fn(&mut Noise)| {

View File

@ -442,6 +442,28 @@ and a file keeps the newest 20 autosaves (user versions are never dropped for th
order: `{items: [{id, name, page, status}]}`. The Inspect tab sets the status of the selected
frames and, with nothing selected, lists them with a status filter (click one to go to it).
### Effects
`effect.add {ids?, type? | effect?}` adds an effect (`dropShadow`, `innerShadow`, `layerBlur`,
`backgroundBlur`, `noise`, `texture`, `glass`) with its defaults, or the full `effect` object;
`effect.set {ids?, index, effect}` replaces one. A layer paints its drop shadows first, then its
fills, inner shadows, children and strokes (strokes draw over inner shadows), with layer blur,
noise and texture over the result.
- **Blurs** (`layerBlur`, `backgroundBlur`) are uniform (`{"radius": 8}`) or progressive:
`{"type": "layerBlur", "blurType": "progressive", "startRadius": 0, "radius": 24, "startOffset":
[0.5, 0], "endOffset": [0.5, 1]}` ramps the radius linearly from `startRadius` at `startOffset` to
`radius` at `endOffset`, points in the layer's unit box (0–1 across and down, like a gradient's
handles; before the start and past the end the radius stays). Radii above 10,000 draw as
10,000. In the app, the Design panel's blur row picks Uniform or Progressive, and while a
progressive blur's settings are open its start and end points are handles on the canvas.
Generated CSS leaves progressive blurs out (CSS blurs are uniform); Inspect lists the ramp.
- **Exports:** PNG, PDF and SVG draw the same pixels as the canvas. PDF and SVG keep layers vector
and embed images for what vectors can't show: background blur and glass (from what lies under
the layer: on the page in PDF, among the exported layers in SVG), layer blur, noise and texture
in PDF; noise grains (an image over the vector layer), texture and progressive layer blur (the
layer whole) in SVG, where uniform layer blur and shadows stay SVG filters.
### Paint, effect and layout grid lists
A layer's fills, strokes, effects and layout grids are lists kept **bottom first**: index 0 paints
@ -456,7 +478,7 @@ first, the last one is on top (the Design panel lists them the other way round,
- **Variable bindings follow their items** when a list is reordered or an item removed
(`fill.remove`, `stroke.remove`, `effect.remove`, `grid.remove`); a removed item's bindings go
with it. Binding fields (`variable.bind {field}`): `fill` / `stroke` (the first paint's colour) or
`fills/<i>/color` / `strokes/<i>/color`, `effects/<i>/<offsetX|offsetY|radius|spread|color>`,
`fills/<i>/color` / `strokes/<i>/color`, `effects/<i>/<offsetX|offsetY|radius|startRadius|spread|color>`,
`layoutGrids/<i>/<size|count|gutter|offset>` (number variables; count is kept to 1–1000, size and
gutter are never negative). Moving a bound paint rewrites its key (`fill` becomes `fills/2/color`).
- **Instance properties:** `variable.bind {ids: [instance], field: "componentProperties/<name>",