diff --git a/README.md b/README.md index 08812b1..654c4d4 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@

Paints and effects

- 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. diff --git a/ROADMAP.md b/ROADMAP.md index 596c1c2..909b5ac 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/crates/doc/src/blur_tests.rs b/crates/doc/src/blur_tests.rs new file mode 100644 index 0000000..c371998 --- /dev/null +++ b/crates/doc/src/blur_tests.rs @@ -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)); +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 8089baf..69d6ad5 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -840,6 +840,8 @@ fn remap_action(a: &mut proto::Action, map: &BTreeMap) { } } +#[cfg(test)] +mod blur_tests; #[cfg(test)] mod colors_tests; #[cfg(test)] diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index 224b987..d7506d0 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -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 { 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, diff --git a/crates/engine/src/binding_lists_tests.rs b/crates/engine/src/binding_lists_tests.rs index 8e83c86..493c8f6 100644 --- a/crates/engine/src/binding_lists_tests.rs +++ b/crates/engine/src/binding_lists_tests.rs @@ -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:?}"), } } diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index ad0f97c..23ccaed 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -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); diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index d8b338c..87ffa9a 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -147,7 +147,7 @@ pub fn specs() -> Vec { spec!( "variable.bind", "Bind variable", - "{ids?, field (fill|stroke (first paint)|fills//color|strokes//color|effects//|layoutGrids//|componentProperties/ (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//color|strokes//color|effects//|layoutGrids//|componentProperties/ (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), _ => {} } } diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index 86b2fed..f0046f3 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -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))), _ => {} diff --git a/crates/engine/src/devinspect.rs b/crates/engine/src/devinspect.rs index ac5cbb1..0bb619e 100644 --- a/crates/engine/src/devinspect.rs +++ b/crates/engine/src/devinspect.rs @@ -257,8 +257,16 @@ pub fn properties(doc: &Document, id: NodeId) -> Vec
{ 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), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d4108bb..a04d9f8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/progressive_blur_tests.rs b/crates/engine/src/progressive_blur_tests.rs new file mode 100644 index 0000000..3a531ed --- /dev/null +++ b/crates/engine/src/progressive_blur_tests.rs @@ -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")); +} diff --git a/crates/engine/src/variable_tests.rs b/crates/engine/src/variable_tests.rs index 60d9785..536b269 100644 --- a/crates/engine/src/variable_tests.rs +++ b/crates/engine/src/variable_tests.rs @@ -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")); diff --git a/crates/format/src/rest/blur_tests.rs b/crates/format/src/rest/blur_tests.rs new file mode 100644 index 0000000..fb4bf75 --- /dev/null +++ b/crates/format/src/rest/blur_tests.rs @@ -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 { + 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()); +} diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index f005bfe..22b03f7 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -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)] diff --git a/crates/format/src/rest/props.rs b/crates/format/src/rest/props.rs index 72642ba..8f868ff 100644 --- a/crates/format/src/rest/props.rs +++ b/crates/format/src/rest/props.rs @@ -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 { 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 { 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) { let size = len(e, "noiseSize").unwrap_or(default); diff --git a/crates/format/src/rest/tests.rs b/crates/format/src/rest/tests.rs index 8fc5faf..4239043 100644 --- a/crates/format/src/rest/tests.rs +++ b/crates/format/src/rest/tests.rs @@ -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)); diff --git a/crates/pdf/src/backdrop_tests.rs b/crates/pdf/src/backdrop_tests.rs index 13545ed..d543dd4 100644 --- a/crates/pdf/src/backdrop_tests.rs +++ b/crates/pdf/src/backdrop_tests.rs @@ -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); diff --git a/crates/pdf/src/draw.rs b/crates/pdf/src/draw.rs index df41438..90dec3a 100644 --- a/crates/pdf/src/draw.rs +++ b/crates/pdf/src/draw.rs @@ -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 diff --git a/crates/pdf/src/effect_order_tests.rs b/crates/pdf/src/effect_order_tests.rs new file mode 100644 index 0000000..a082c49 --- /dev/null +++ b/crates/pdf/src/effect_order_tests.rs @@ -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() +} diff --git a/crates/pdf/src/export_tests.rs b/crates/pdf/src/export_tests.rs index 3d8c8e6..259e583 100644 --- a/crates/pdf/src/export_tests.rs +++ b/crates/pdf/src/export_tests.rs @@ -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()); } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 9fb61ff..39d014c 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -174,6 +174,8 @@ fn write(doc: &Document, pages: &[(Vec, Rect)], opts: &PdfOptions) -> Re #[cfg(test)] mod backdrop_tests; #[cfg(test)] +mod effect_order_tests; +#[cfg(test)] mod export_tests; #[cfg(test)] mod tests; diff --git a/crates/pdf/src/raster.rs b/crates/pdf/src/raster.rs index 26ba353..d385cd8 100644 --- a/crates/pdf/src/raster.rs +++ b/crates/pdf/src/raster.rs @@ -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 { 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]; - } - } -} diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 86057ac..f101ce9 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -125,7 +125,7 @@ fn sample_doc() -> (Document, Vec) { 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) } diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index 7114ffb..57d8cfd 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -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 = t.characters.chars().collect(); let styles: BTreeSet = 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); } } } diff --git a/crates/render/src/backdrop.rs b/crates/render/src/backdrop.rs index 4fb22e0..42c915f 100644 --- a/crates/render/src/backdrop.rs +++ b/crates/render/src/backdrop.rs @@ -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 = 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(), ©, 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 { + 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) -> Option { + 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]; + } + } +} diff --git a/crates/render/src/backdrop_tests.rs b/crates/render/src/backdrop_tests.rs index 0e9a15a..5d28317 100644 --- a/crates/render/src/backdrop_tests.rs +++ b/crates/render/src/backdrop_tests.rs @@ -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()); } diff --git a/crates/render/src/band_tests.rs b/crates/render/src/band_tests.rs index 1659d13..b9a7bfb 100644 --- a/crates/render/src/band_tests.rs +++ b/crates/render/src/band_tests.rs @@ -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()); } diff --git a/crates/render/src/blur.rs b/crates/render/src/blur.rs new file mode 100644 index 0000000..51fa18e --- /dev/null +++ b/crates/render/src/blur.rs @@ -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 { + 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 { + 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; + } +} diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index b1b41c1..2f49e13 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -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 = 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 }; diff --git a/crates/render/src/effect_order_tests.rs b/crates/render/src/effect_order_tests.rs new file mode 100644 index 0000000..c0a8ecc --- /dev/null +++ b/crates/render/src/effect_order_tests.rs @@ -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) -> (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})"); + } +} diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 78e5681..7098a10 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -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(); diff --git a/crates/render/src/grain.rs b/crates/render/src/grain.rs index 6917a5b..34c0597 100644 --- a/crates/render/src/grain.rs +++ b/crates/render/src/grain.rs @@ -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 { 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 diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 81cfaf2..fc375a7 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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 { + 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 { + 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 { @@ -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; diff --git a/crates/render/src/progressive_tests.rs b/crates/render/src/progressive_tests.rs new file mode 100644 index 0000000..bb8531f --- /dev/null +++ b/crates/render/src/progressive_tests.rs @@ -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) -> (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 = (40..160).map(|x| red(p, x, y)).collect(); + row.iter().max().unwrap() - row.iter().min().unwrap() +} + +fn row(p: &Pixmap, y: u32) -> Vec { + 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 = [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 = (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)); + } +} diff --git a/crates/render/src/retained_tests.rs b/crates/render/src/retained_tests.rs index 62a184b..958da37 100644 --- a/crates/render/src/retained_tests.rs +++ b/crates/render/src/retained_tests.rs @@ -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 } } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 0eae222..167cc74 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -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()); diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 57b0e03..13ca130 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -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 { + 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('&', "&").replace('<', "<").replace('>', ">").replace('"', """) } @@ -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, "", num(radius / 2.0)); + EffectKind::LayerBlur(b) => { + let _ = write!(f, "", 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, ""); + 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(""); + } + + /// `` 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 { + 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!( + "", + 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) -> Option { + 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, "{}{img}", 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(""); + 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 { 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) -> (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(" 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 = 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, diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 4a60dc8..953c537 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -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::(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::(Id::new((FX_OPEN, i)))).unwrap_or(false) +} + /// A number field of effect `i` that can be bound to a variable (`effects//`). fn fx_field(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, prop: &str, lead: Lead, value: f64, width: f32) -> Option { 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)| { diff --git a/docs/mcp.md b/docs/mcp.md index e5d21df..7b75898 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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//color` / `strokes//color`, `effects//`, + `fills//color` / `strokes//color`, `effects//`, `layoutGrids//` (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/",