mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-26 03:16:53 -06:00
- Background blur sees the whole backdrop under the layer: the renderer keeps the stack of isolated surfaces it draws into (clipping frames, rounded clips, faded groups, masks) and flattens it for the layer's area, reading past the layer's own box. A blur inside a clipping frame now shows the frame's fill instead of the sharp layers below, on the canvas and in PNG exports. The PDF export draws background blur and glass through the same code from a render of what lies under the node (`backdrop::render_beneath`, which the PDF's backdrop now shares), and its images match the PNG's pixels. - Shadow spread offsets the outline: the shape filled and stroked twice the spread wide with mitred joins (grow) or cut away (shrink), the stroked bands cached per outline. Ellipses stay ellipses, stars keep sharp points, text keeps its letterforms, rounded corners grow their radius. Inner shadows shrink the outline the same way. Canvas, PNG and PDF share the shadow code; SVG writes spread shadows as the same geometry and now exports inner shadows. - Glass effect (light angle and intensity, refraction, depth, dispersion, frost, splay): the frosted backdrop bent towards the inside within `depth` of the edge, colour fringes, light on the rim, from an exact distance transform with smoothed normals. - Noise: mono, duo and multi colour modes, grain size, density, opacity and blend mode; grains live in the layer's coordinates (stable when panning and zooming). Texture is now drawn (pixels scattered by the radius, grain size, clip to shape). - Design panel rows for noise mode/colours/opacity, texture clip to shape and every glass value; `effect.add` takes `glass`; REST import of NOISE, TEXTURE and GLASS; Inspect rows and CSS; the scale tool scales grain sizes, texture radius, glass depth and frost. - Tests in new modules: render backdrop/spread/grain tests, PDF backdrop tests, SVG, doc, engine and REST effect tests. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
145 lines
6.5 KiB
Rust
145 lines
6.5 KiB
Rust
//! Shadow spread offsets the shape's outline: ellipses grow as ellipses (not squares), star points
|
||
//! stay sharp and reach out along their tips, inner shadows shrink the outline the same way. The
|
||
//! renderer and the PDF export share the shadow code (`shadow`), so they agree.
|
||
|
||
use figcraft_color::{Color, Paint};
|
||
use figcraft_doc::{Document, Effect, EffectKind, NodeId, NodeKind, Shadow};
|
||
|
||
use super::*;
|
||
|
||
fn shadow(spread: f64) -> Shadow {
|
||
Shadow { color: Color::BLACK, offset: (0.0, 0.0), radius: 0.0, spread, show_behind: true, ..Default::default() }
|
||
}
|
||
|
||
/// A white shape of `kind` at (100, 100), 200 × 200, on a transparent page, with `effects`.
|
||
fn shape(kind: NodeKind, effects: Vec<EffectKind>) -> (Document, NodeId) {
|
||
let mut d = Document::new("spread");
|
||
let p = d.first_page().unwrap();
|
||
let n = d.create(p, kind, "S", 100.0, 100.0, 200.0, 200.0).unwrap();
|
||
let node = d.get_mut(n).unwrap();
|
||
node.fills = vec![Paint::solid(Color::WHITE)];
|
||
node.effects = effects.into_iter().map(|kind| Effect { kind, visible: true }).collect();
|
||
(d, n)
|
||
}
|
||
|
||
/// Node `id` drawn on a transparent 400 × 400 view of the page.
|
||
fn render(d: &Document, id: NodeId) -> Pixmap {
|
||
let mut pix = Pixmap::new(400, 400).unwrap();
|
||
render_nodes_into(d, &[id], &View { origin: (0.0, 0.0), scale: 1.0, width: 400, height: 400 }, &mut pix);
|
||
pix
|
||
}
|
||
|
||
/// Alpha at the page point `(x, y)` (pixel centres at +0.5).
|
||
fn alpha(p: &Pixmap, x: f64, y: f64) -> u8 {
|
||
let i = ((y as u32 * p.width() + x as u32) * 4) as usize;
|
||
p.data()[i + 3]
|
||
}
|
||
|
||
fn ellipse() -> NodeKind {
|
||
NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }
|
||
}
|
||
|
||
#[test]
|
||
fn drop_shadow_spread_grows_an_ellipse_as_an_ellipse() {
|
||
let (d, n) = shape(ellipse(), vec![EffectKind::DropShadow(shadow(20.0))]);
|
||
let p = render(&d, n);
|
||
// Along the diagonal, 100 from the centre (200, 200) is the edge: the shadow ends 20 further.
|
||
let at = |r: f64| alpha(&p, 200.0 + r * std::f64::consts::FRAC_1_SQRT_2, 200.0 + r * std::f64::consts::FRAC_1_SQRT_2);
|
||
assert_eq!(at(117.0), 255);
|
||
assert_eq!(at(123.0), 0, "a square spread would reach ~128 along the diagonal");
|
||
// And along the axes.
|
||
assert_eq!(alpha(&p, 200.5, 82.0), 255);
|
||
assert_eq!(alpha(&p, 200.5, 78.0), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn negative_spread_shrinks_an_ellipse_as_an_ellipse() {
|
||
// Shown behind the (half transparent) shape, a shrunk shadow ends 20 inside the edge.
|
||
let (mut d, n) = shape(ellipse(), vec![EffectKind::DropShadow(shadow(-20.0))]);
|
||
d.get_mut(n).unwrap().fills = vec![];
|
||
d.get_mut(n).unwrap().strokes = vec![];
|
||
let sil = shadow::silhouette(&d, d.node(n).unwrap());
|
||
assert!(sil.is_none(), "no fill, no stroke: nothing casts by outline");
|
||
let (d, n) = shape(ellipse(), vec![EffectKind::DropShadow(shadow(-20.0))]);
|
||
let node = d.node(n).unwrap();
|
||
let sil = shadow::silhouette(&d, node).unwrap();
|
||
let xf = Affine::translate((100.0, 100.0));
|
||
let a = shadow::spread_alpha(&sil, -20.0, xf, 400, 400).unwrap();
|
||
let at = |r: f64| alpha(&a, 200.0 + r * std::f64::consts::FRAC_1_SQRT_2, 200.0 + r * std::f64::consts::FRAC_1_SQRT_2);
|
||
assert_eq!(at(77.0), 255);
|
||
assert_eq!(at(83.0), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn spread_keeps_star_points_sharp() {
|
||
let star = NodeKind::Star { count: 5, inner_ratio: 0.381_966 };
|
||
let (d, n) = shape(star, vec![EffectKind::DropShadow(shadow(10.0))]);
|
||
let p = render(&d, n);
|
||
// The top point is at (200, 100) with a 36° tip: offsetting its sides by 10 moves the mitred
|
||
// tip 10 / sin(18°) ≈ 32 up. A square or round spread ends 10 above it.
|
||
assert_eq!(alpha(&p, 200.5, 75.0), 255);
|
||
assert_eq!(alpha(&p, 200.5, 64.0), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn rectangle_spread_grows_the_corner_radius() {
|
||
let (mut d, n) = shape(NodeKind::Rectangle, vec![EffectKind::DropShadow(shadow(20.0))]);
|
||
d.get_mut(n).unwrap().corners.radii = [40.0; 4];
|
||
let p = render(&d, n);
|
||
// Corner centre at (140, 140); the shadow's corner is a radius 60 arc around it.
|
||
let at = |r: f64| alpha(&p, 140.0 - r * std::f64::consts::FRAC_1_SQRT_2, 140.0 - r * std::f64::consts::FRAC_1_SQRT_2);
|
||
assert_eq!(at(57.0), 255);
|
||
assert_eq!(at(63.0), 0);
|
||
// Sharp corners stay sharp (CSS box-shadow spread).
|
||
let (d, n) = shape(NodeKind::Rectangle, vec![EffectKind::DropShadow(shadow(20.0))]);
|
||
let p = render(&d, n);
|
||
assert_eq!(alpha(&p, 81.0, 81.0), 255);
|
||
}
|
||
|
||
#[test]
|
||
fn inner_shadow_spread_shrinks_the_outline() {
|
||
// A black inner shadow, no blur or offset: a ring 20 wide inside the ellipse's edge.
|
||
let (d, n) = shape(ellipse(), vec![EffectKind::InnerShadow(shadow(20.0))]);
|
||
let p = render(&d, n);
|
||
let red = |r: f64| {
|
||
let (x, y) = (200.0 + r * std::f64::consts::FRAC_1_SQRT_2, 200.0 + r * std::f64::consts::FRAC_1_SQRT_2);
|
||
let i = ((y as u32 * p.width() + x as u32) * 4) as usize;
|
||
p.data()[i]
|
||
};
|
||
assert_eq!(red(83.0), 0, "inside the ring: shadow");
|
||
assert_eq!(red(77.0), 255, "past the ring: the white fill");
|
||
}
|
||
|
||
#[test]
|
||
fn spread_of_layers_without_one_outline_grows_their_pixels() {
|
||
// A group has no outline of its own: its pixels spread (square), as before.
|
||
let mut d = Document::new("g");
|
||
let p = d.first_page().unwrap();
|
||
let g = d.create(p, NodeKind::Group, "G", 100.0, 100.0, 200.0, 200.0).unwrap();
|
||
let r = d.create(g, NodeKind::Rectangle, "R", 0.0, 0.0, 200.0, 200.0).unwrap();
|
||
d.get_mut(r).unwrap().fills = vec![Paint::solid(Color::WHITE)];
|
||
d.get_mut(g).unwrap().effects = vec![Effect { kind: EffectKind::DropShadow(shadow(10.0)), visible: true }];
|
||
assert!(shadow::silhouette(&d, d.node(g).unwrap()).is_none());
|
||
let pix = render(&d, g);
|
||
assert_eq!(alpha(&pix, 92.0, 92.0), 255);
|
||
assert_eq!(alpha(&pix, 88.0, 88.0), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn spread_bands_are_cached_and_hostile_values_are_safe() {
|
||
let (d, n) = shape(ellipse(), vec![]);
|
||
let sil = shadow::silhouette(&d, d.node(n).unwrap()).unwrap();
|
||
let a = shadow::spread_alpha(&sil, 7.0, Affine::IDENTITY, 300, 300).unwrap();
|
||
let b = shadow::spread_alpha(&sil, 7.0, Affine::IDENTITY, 300, 300).unwrap();
|
||
assert_eq!(a.data(), b.data());
|
||
for s in [f64::NAN, f64::INFINITY, -f64::INFINITY, 1e12, -1e12, 0.0] {
|
||
assert!(shadow::spread_alpha(&sil, s, Affine::IDENTITY, 50, 50).is_some());
|
||
assert!(shadow::spread_alpha(&sil, 3.0, Affine::scale(s), 50, 50).is_some());
|
||
}
|
||
for s in [f64::NAN, 1e12, -1e12] {
|
||
let (d, n) =
|
||
shape(NodeKind::Star { count: 5, inner_ratio: 0.4 }, vec![EffectKind::DropShadow(shadow(s)), EffectKind::InnerShadow(shadow(s))]);
|
||
let _ = render(&d, n);
|
||
}
|
||
}
|