mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-14 06:12:28 +00:00
1309 lines
62 KiB
Rust
1309 lines
62 KiB
Rust
//! The glass effect: the backdrop seen through a pane whose edges curve down like a lens.
|
||
//!
|
||
//! Within `depth` of the outline the surface curves like a thick lens: the backdrop there is bent
|
||
//! towards the inside, most a [`BEVEL`] in from the edge (`refraction` × `depth` far), falling off
|
||
//! as the 3.5th power of the way out of the curve and gone `depth` further in, with a fifth as
|
||
//! much on the bevel itself ([`bend_at`]). `splay` turns which way it bends, from towards the
|
||
//! shape's core (0; along the edge's normal for a shape too big to keep, see [`MAX_SHAPE_PX`]) to
|
||
//! away from the centre of the layer's bounds (1), as the reference tool's: on a ring or an L,
|
||
//! whose centre lies outside the shape, an inner edge bends towards the hole. Each colour channel
|
||
//! bends by a different amount (`dispersion`, a rainbow fringe: red furthest, blue least, each
|
||
//! smeared along the bend). The bevel catches the light as a thin line added to the backdrop
|
||
//! ([`rim_light_at`]): fully where the edge faces `light_angle`, none below 0.35 of facing it, 0.6
|
||
//! as much on the opposite side; and a faint gradient runs across the curve, 5% darker where it
|
||
//! faces the light and lighter opposite. `frost` blurs the backdrop first. Distances to the outline come from an exact Euclidean
|
||
//! distance transform of the shape's coverage.
|
||
//!
|
||
//! The shape's coverage, distances and normals depend only on the shape and the zoom. Once a
|
||
//! later render asks for the same shape at the same zoom they are worked out for the whole shape,
|
||
//! in its own pixel frame, and kept in a cache of at most [`MAX_CACHED_BYTES`]: moving or editing
|
||
//! what lies under the glass, dragging its sliders, panning and patching the canvas reuse them.
|
||
//! Zooming doesn't (each zoom is new). The bend directions towards the core depend on the depth
|
||
//! too: they are kept beside them for the last two depths asked for, so dragging the Depth slider
|
||
//! works out only those again.
|
||
|
||
use std::collections::HashMap;
|
||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||
use std::sync::{Arc, Mutex, OnceLock};
|
||
|
||
use figcraft_doc::{Glass, Node, NodeKind};
|
||
use figcraft_geom::kurbo::Shape as _;
|
||
use figcraft_geom::{Affine, Point};
|
||
use tiny_skia::Pixmap;
|
||
|
||
use crate::backdrop::EffectFrame;
|
||
use crate::fx;
|
||
use crate::shadow::{Outline, spread_alpha};
|
||
|
||
/// Squared distance transform of one row or column (Felzenszwalb & Huttenlocher): `f` holds 0 at
|
||
/// sources and a large value elsewhere; `d` gets each sample's squared distance to the nearest
|
||
/// source, and `src`, when given, that source's index. `v` and `z` are scratch space (`f.len()` and
|
||
/// `f.len() + 1` long).
|
||
fn edt_1d(f: &[f64], d: &mut [f64], v: &mut [usize], z: &mut [f64], mut src: Option<&mut [usize]>) {
|
||
let n = f.len();
|
||
// Every index below stays under these lengths (k <= q < n), checked once here.
|
||
if n == 0 || d.len() < n || v.len() < n || z.len() < n + 1 {
|
||
return;
|
||
}
|
||
let mut k = 0usize;
|
||
v[0] = 0;
|
||
z[0] = f64::NEG_INFINITY;
|
||
z[1] = f64::INFINITY;
|
||
for q in 1..n {
|
||
let qf = q as f64;
|
||
let mut s;
|
||
loop {
|
||
let vk = v[k] as f64;
|
||
s = ((f[q] + qf * qf) - (f[v[k]] + vk * vk)) / (2.0 * qf - 2.0 * vk);
|
||
if s <= z[k] && k > 0 {
|
||
k -= 1;
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
if s <= z[k] {
|
||
// Only the first parabola is left and this one hides it.
|
||
v[0] = q;
|
||
z[1] = f64::INFINITY;
|
||
} else {
|
||
k += 1;
|
||
v[k] = q;
|
||
z[k] = s;
|
||
z[k + 1] = f64::INFINITY;
|
||
}
|
||
}
|
||
let mut k = 0usize;
|
||
for q in 0..n {
|
||
let qf = q as f64;
|
||
while z[k + 1] < qf {
|
||
k += 1;
|
||
}
|
||
let dv = qf - v[k] as f64;
|
||
d[q] = dv * dv + f[v[k]];
|
||
if let Some(o) = src.as_deref_mut().and_then(|s| s.get_mut(q)) {
|
||
*o = v[k];
|
||
}
|
||
}
|
||
}
|
||
|
||
/// For a `w` × `h` coverage pixmap: each pixel's distance (pixels) from its centre to the edge of
|
||
/// the shape (pixels at least half covered), 0 outside.
|
||
pub fn inside_distance(cov: &Pixmap) -> Vec<f32> {
|
||
let (w, h) = (cov.width() as usize, cov.height() as usize);
|
||
const FAR: f64 = 1e20;
|
||
let mut g: Vec<f64> = cov.data().chunks_exact(4).map(|p| if p.get(3).copied().unwrap_or(0) >= 128 { FAR } else { 0.0 }).collect();
|
||
let n = w.max(h);
|
||
let (mut f, mut d, mut v, mut z) = (vec![0f64; n], vec![0f64; n], vec![0usize; n], vec![0f64; n + 1]);
|
||
for x in 0..w {
|
||
for y in 0..h {
|
||
if let (Some(fy), Some(gv)) = (f.get_mut(y), g.get(y * w + x)) {
|
||
*fy = *gv;
|
||
}
|
||
}
|
||
edt_1d(f.get(..h).unwrap_or(&[]), d.get_mut(..h).unwrap_or(&mut []), &mut v, &mut z, None);
|
||
for y in 0..h {
|
||
if let (Some(gv), Some(dy)) = (g.get_mut(y * w + x), d.get(y)) {
|
||
*gv = *dy;
|
||
}
|
||
}
|
||
}
|
||
for y in 0..h {
|
||
let row = y * w;
|
||
if let (Some(src), Some(dst)) = (g.get(row..row + w), f.get_mut(..w)) {
|
||
dst.copy_from_slice(src);
|
||
}
|
||
edt_1d(f.get(..w).unwrap_or(&[]), d.get_mut(..w).unwrap_or(&mut []), &mut v, &mut z, None);
|
||
if let (Some(dst), Some(src)) = (g.get_mut(row..row + w), d.get(..w)) {
|
||
dst.copy_from_slice(src);
|
||
}
|
||
}
|
||
// Centre-to-centre distance to the nearest outside pixel → distance to the edge between.
|
||
g.iter().map(|s| if *s > 0.0 { (s.sqrt() - 0.5).max(0.0) as f32 } else { 0.0 }).collect()
|
||
}
|
||
|
||
/// [`nearest_sites`]' answer for a pixel with no site.
|
||
const NO_SITE: u32 = u32::MAX;
|
||
|
||
/// For each pixel of a `w` × `h` grid, the nearest pixel (centre to centre) where `site` is true, as
|
||
/// its index, or [`NO_SITE`] when there is none: the exact Euclidean feature transform (the distance
|
||
/// transform above, keeping where each distance comes from). 16 bytes a pixel at most.
|
||
fn nearest_sites(site: &[bool], w: usize, h: usize) -> Vec<u32> {
|
||
const FAR: f64 = 1e20;
|
||
let n = w.max(h);
|
||
let (mut f, mut d, mut v, mut z, mut src) = (vec![0f64; n], vec![0f64; n], vec![0usize; n], vec![0f64; n + 1], vec![0usize; n]);
|
||
// Down each column: the squared distance to its nearest site, and that site's row.
|
||
let (mut col_d, mut col_row) = (vec![FAR; w * h], vec![NO_SITE; w * h]);
|
||
for x in 0..w {
|
||
for y in 0..h {
|
||
if let Some(fy) = f.get_mut(y) {
|
||
*fy = if site.get(y * w + x).copied().unwrap_or(false) { 0.0 } else { FAR };
|
||
}
|
||
}
|
||
edt_1d(f.get(..h).unwrap_or(&[]), d.get_mut(..h).unwrap_or(&mut []), &mut v, &mut z, src.get_mut(..h));
|
||
for y in 0..h {
|
||
if let (Some(cd), Some(cr), Some(dy), Some(sy)) = (col_d.get_mut(y * w + x), col_row.get_mut(y * w + x), d.get(y), src.get(y)) {
|
||
(*cd, *cr) = (*dy, u32::try_from(*sy).unwrap_or(NO_SITE));
|
||
}
|
||
}
|
||
}
|
||
// Along each row: the nearest of those, by the column it is in.
|
||
let mut out = vec![NO_SITE; w * h];
|
||
for y in 0..h {
|
||
let row = y * w;
|
||
if let (Some(dst), Some(from)) = (f.get_mut(..w), col_d.get(row..row + w)) {
|
||
dst.copy_from_slice(from);
|
||
}
|
||
edt_1d(f.get(..w).unwrap_or(&[]), d.get_mut(..w).unwrap_or(&mut []), &mut v, &mut z, src.get_mut(..w));
|
||
for x in 0..w {
|
||
if let (Some(dx), Some(sx)) = (d.get(x), src.get(x))
|
||
&& *dx < FAR / 2.0
|
||
&& let (Some(o), Some(&sy)) = (out.get_mut(row + x), col_row.get(row + *sx))
|
||
&& sy != NO_SITE
|
||
{
|
||
*o = u32::try_from(sy as usize * w + sx).unwrap_or(NO_SITE);
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// The largest of `src` (`w` × `h`) within `r` pixels across and down of each pixel (a square
|
||
/// window): running maxima along the rows, then the columns.
|
||
fn max_within(src: &[f32], w: usize, h: usize, r: usize) -> Vec<f32> {
|
||
let (mut rows, mut out) = (vec![0f32; w * h], vec![0f32; w * h]);
|
||
max_pass(src, &mut rows, w, h, r, true);
|
||
max_pass(&rows, &mut out, w, h, r, false);
|
||
out
|
||
}
|
||
|
||
/// One pass of [`max_within`] along rows (`rows`) or columns: a queue holds the window's candidates
|
||
/// for its largest, falling from the front, so each value enters and leaves it once.
|
||
fn max_pass(src: &[f32], dst: &mut [f32], w: usize, h: usize, r: usize, rows: bool) {
|
||
let (lines, len, step, across) = if rows { (h, w, 1, w) } else { (w, h, w, 1) };
|
||
let mut queue = std::collections::VecDeque::new();
|
||
for l in 0..lines {
|
||
let base = l * across;
|
||
let at = |i: usize| src.get(base + i * step).copied().unwrap_or(0.0);
|
||
queue.clear();
|
||
let mut next = 0;
|
||
for i in 0..len {
|
||
while next < len && next <= i + r {
|
||
let v = at(next);
|
||
while queue.back().is_some_and(|&b| at(b) <= v) {
|
||
queue.pop_back();
|
||
}
|
||
queue.push_back(next);
|
||
next += 1;
|
||
}
|
||
while queue.front().is_some_and(|&f| f + r < i) {
|
||
queue.pop_front();
|
||
}
|
||
if let (Some(o), Some(&f)) = (dst.get_mut(base + i * step), queue.front()) {
|
||
*o = at(f);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// How far around (pixels) a pixel counts as the middle of a thin part: no deeper than the pixels
|
||
/// within this many across and down, give or take [`RIDGE_SLACK`].
|
||
const RIDGE_PX: usize = 2;
|
||
|
||
/// How much shallower than the deepest pixel within [`RIDGE_PX`] a pixel can be and still count as
|
||
/// a thin part's middle: half a pixel per pixel, so a gently tapering stroke keeps its middle while
|
||
/// the diagonal running into a corner (depth rising by 0.7 px per pixel, more in the window) and
|
||
/// the band along an edge (1 px per pixel) do not.
|
||
const RIDGE_SLACK: f32 = 0.5 * RIDGE_PX as f32;
|
||
|
||
/// Unit inward directions from each pixel of a shape (`dist`, its inside distance field, `w` × `h`)
|
||
/// to the nearest point of its core, which the glass bends the backdrop towards: where the shape
|
||
/// is `reach` pixels deep or more, and the middle of each part thinner than that (a capsule, a
|
||
/// letter's stroke: pixels about as deep as any within [`RIDGE_PX`]). Bending towards the core
|
||
/// rather than along the edge's normal rounds a sharp corner's bend (no seam along its diagonal)
|
||
/// and makes a thin part bend across itself, each part towards its own middle. Box-blurred once
|
||
/// over `r` pixels (the normals twice: these are smooth already, but turn round at a thin part's
|
||
/// middle); zero on the core and outside.
|
||
fn bend_directions(dist: &[f32], w: usize, h: usize, reach: f32, r: usize) -> Vec<(f32, f32)> {
|
||
// The maxima and the core are dropped before the directions are made, keeping the peak down.
|
||
let near = {
|
||
let around = max_within(dist, w, h, RIDGE_PX);
|
||
let core: Vec<bool> = dist.iter().zip(&around).map(|(d, m)| *d > 0.0 && (*d >= reach - 1e-3 || *d >= m - RIDGE_SLACK - 1e-3)).collect();
|
||
nearest_sites(&core, w, h)
|
||
};
|
||
let (mut gx, mut gy) = (vec![0f32; w * h], vec![0f32; w * h]);
|
||
for (i, &s) in near.iter().enumerate() {
|
||
if s != NO_SITE
|
||
&& let Some(d) = dist.get(i)
|
||
&& *d > 0.0
|
||
&& let (Some(a), Some(b)) = (gx.get_mut(i), gy.get_mut(i))
|
||
{
|
||
let s = s as usize;
|
||
let (dx, dy) = ((s % w) as f32 - (i % w) as f32, (s / w) as f32 - (i / w) as f32);
|
||
let len = (dx * dx + dy * dy).sqrt();
|
||
if len > 1e-6 {
|
||
(*a, *b) = (dx / len, dy / len);
|
||
}
|
||
}
|
||
}
|
||
smoothed_units(gx, gy, w, h, r, 1)
|
||
}
|
||
|
||
/// Running-sum box blur of a `w` × `h` field along rows (`stride` 1) or columns (`stride` `w`),
|
||
/// radius `r`, zero outside.
|
||
fn box_pass(src: &[f32], dst: &mut [f32], w: usize, h: usize, r: usize, rows: bool) {
|
||
let (lines, len, step, across) = if rows { (h, w, 1, w) } else { (w, h, w, 1) };
|
||
let norm = 1.0 / (2 * r + 1) as f32;
|
||
for l in 0..lines {
|
||
let base = l * across;
|
||
let at = |i: usize| src.get(base + i * step).copied().unwrap_or(0.0);
|
||
let mut acc: f32 = (0..=r.min(len.saturating_sub(1))).map(at).sum();
|
||
for i in 0..len {
|
||
if let Some(o) = dst.get_mut(base + i * step) {
|
||
*o = acc * norm;
|
||
}
|
||
if i + r + 1 < len {
|
||
acc += at(i + r + 1);
|
||
}
|
||
if i >= r {
|
||
acc -= at(i - r);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Unit inward normals of the shape from its inside `dist`ance field (`w` × `h`): the distance's
|
||
/// gradient, smoothed over `r` pixels so curved edges bend the backdrop smoothly instead of in the
|
||
/// steps of the pixel grid. Zero where there is no direction (outside, or a flat middle).
|
||
fn smooth_normals(dist: &[f32], w: usize, h: usize, r: usize) -> Vec<(f32, f32)> {
|
||
let at = |x: usize, y: usize| dist.get(y * w + x).copied().unwrap_or(0.0);
|
||
let mut gx = vec![0f32; w * h];
|
||
let mut gy = vec![0f32; w * h];
|
||
for y in 1..h.saturating_sub(1) {
|
||
for x in 1..w.saturating_sub(1) {
|
||
if let (Some(a), Some(b)) = (gx.get_mut(y * w + x), gy.get_mut(y * w + x)) {
|
||
*a = (at(x + 1, y) - at(x - 1, y)) * 0.5;
|
||
*b = (at(x, y + 1) - at(x, y - 1)) * 0.5;
|
||
}
|
||
}
|
||
}
|
||
smoothed_units(gx, gy, w, h, r, 2)
|
||
}
|
||
|
||
/// The vectors (`gx`, `gy`) of a `w` × `h` field box-blurred `times` over `r` pixels and made unit
|
||
/// length; zero where they cancel out (no direction).
|
||
fn smoothed_units(mut gx: Vec<f32>, mut gy: Vec<f32>, w: usize, h: usize, r: usize, times: usize) -> Vec<(f32, f32)> {
|
||
let mut tmp = vec![0f32; w * h];
|
||
for g in [&mut gx, &mut gy] {
|
||
for _ in 0..times {
|
||
box_pass(g, &mut tmp, w, h, r, true);
|
||
box_pass(&tmp, g, w, h, r, false);
|
||
}
|
||
}
|
||
gx.iter()
|
||
.zip(&gy)
|
||
.map(|(x, y)| {
|
||
let len = (x * x + y * y).sqrt();
|
||
if len > 1e-4 { (x / len, y / len) } else { (0.0, 0.0) }
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// The bevel along a glass's edge (document units): where the bend eases off before the edge.
|
||
pub(crate) const BEVEL: f64 = 1.0;
|
||
|
||
/// How sharply the bend falls off going in: the 3.5th power of the way out of the curve.
|
||
const PROFILE_EXP: f32 = 3.5;
|
||
|
||
/// The furthest the backdrop moves, as a share of `depth`.
|
||
const MAX_BEND: f32 = 0.999;
|
||
|
||
/// The bend on the bevel itself, as a share of [`MAX_BEND`] × `depth` × `refraction`.
|
||
const RIM_BEND: f32 = 0.2;
|
||
|
||
/// Steps of the dispersion window: each channel is sampled this many times along the bend, weighted
|
||
/// by a triangle (the two end steps weigh nothing and are skipped).
|
||
const DISPERSION_STEPS: usize = 8;
|
||
|
||
/// The least an edge faces the light (the cosine between its outward normal and the light) and
|
||
/// still catches some.
|
||
const HL_LO: f32 = 0.35;
|
||
|
||
/// How much of the light the side opposite it catches.
|
||
const HL_SECONDARY: f32 = 0.6;
|
||
|
||
/// The gradient across the curve at its strongest, a share of the light's intensity.
|
||
const BROAD: f32 = 0.05;
|
||
|
||
/// `x` from `a` to `b` as 0 to 1, clamped (a span under 1e-5 counts as 1e-5).
|
||
fn lin(a: f32, b: f32, x: f32) -> f32 {
|
||
((x - a) / (b - a).max(1e-5)).clamp(0.0, 1.0)
|
||
}
|
||
|
||
/// `x` from `a` to `b` as a smooth 0-to-1 step (Hermite), clamped.
|
||
fn smoothstep(a: f32, b: f32, x: f32) -> f32 {
|
||
let t = ((x - a) / (b - a).max(1e-5)).clamp(0.0, 1.0);
|
||
t * t * (3.0 - 2.0 * t)
|
||
}
|
||
|
||
/// How much of the rim light falls `x` pixels in from the edge, for a `bevel` pixels deep: all
|
||
/// of it within a fifth of the bevel, none a bevel in.
|
||
pub(crate) fn rim_light_at(x: f32, bevel: f32) -> f32 {
|
||
smoothstep(-bevel - 0.001, -0.2 * bevel, -x)
|
||
}
|
||
|
||
/// How much light an edge catches, `facing` being the cosine between its outward normal and the
|
||
/// light: from [`HL_LO`] to fully facing it, and [`HL_SECONDARY`] as much facing away.
|
||
fn catches(facing: f32) -> f32 {
|
||
smoothstep(HL_LO, 1.0, facing) + HL_SECONDARY * smoothstep(HL_LO, 1.0, -facing)
|
||
}
|
||
|
||
/// How strongly the gradient across the curve shows `x` pixels in from the edge (`depth` and
|
||
/// `bevel` in pixels): fully on the bevel, falling as the square of the way in to none at `depth`.
|
||
pub(crate) fn sheen_at(x: f32, depth: f32, bevel: f32) -> f32 {
|
||
lin(-depth, -bevel + 1e-4, -x).powi(2)
|
||
}
|
||
|
||
/// How far the backdrop moves at `x` pixels in from the edge, for a glass `depth` and [`BEVEL`]
|
||
/// `bevel` deep (pixels) and `refraction` (0–1): a thick lens's profile, the most a bevel in from
|
||
/// the edge, falling off as the 3.5th power of the way out of the curve to nothing `depth` further
|
||
/// in, and a fifth as much on the bevel itself, eased between the two over its outer 70%.
|
||
pub(crate) fn bend_at(x: f32, depth: f32, bevel: f32, refraction: f32) -> f32 {
|
||
// Past the curve (and the bevel's easing) nothing bends: the flat inside of the pane.
|
||
if x >= depth.max(1e-4) + bevel {
|
||
return 0.0;
|
||
}
|
||
let sd = -x;
|
||
let t = lin(-depth - bevel, -bevel, sd);
|
||
let most = MAX_BEND * depth;
|
||
let core = (t.powf(PROFILE_EXP) * depth * refraction).clamp(-most, most);
|
||
let rim = refraction * RIM_BEND * most;
|
||
let u = lin(-bevel - 1e-4, -0.3 * bevel, sd);
|
||
core + (rim - core) * u
|
||
}
|
||
|
||
/// Below this split (pixels: the move's length × `k`) the dispersion window takes one sample: its
|
||
/// steps lie within 1/256 px of the moved point, so one sample is within one level (of 255) of the
|
||
/// window even across the hardest edge, from black to white or opaque to clear in a pixel.
|
||
const ONE_SAMPLE_SPLIT_PX: f32 = 1.0 / 256.0;
|
||
|
||
/// The backdrop seen at pixel coordinates (`x`, `y`) moved by (`dx`, `dy`), its channels split by
|
||
/// `k` (dispersion, 0–1): red moves up to `k` further, blue up to `k` less, each smeared along the
|
||
/// move by a triangular window of [`DISPERSION_STEPS`]. One sample when the split is too small to
|
||
/// show: `k` under 0.001, or the channels under [`ONE_SAMPLE_SPLIT_PX`] apart (where the curve
|
||
/// flattens out and the move is short).
|
||
fn dispersed(frosted: &Pixmap, x: f32, y: f32, dx: f32, dy: f32, k: f32) -> [f32; 4] {
|
||
if k < 0.001 || (dx * dx + dy * dy).sqrt() * k < ONE_SAMPLE_SPLIT_PX {
|
||
let s = sample(frosted, x + dx, y + dy);
|
||
return [s[0].min(s[3]), s[1].min(s[3]), s[2].min(s[3]), s[3]];
|
||
}
|
||
dispersion_window(frosted, x, y, dx, dy, k)
|
||
}
|
||
|
||
/// [`dispersed`]'s triangular window, every step sampled.
|
||
fn dispersion_window(frosted: &Pixmap, x: f32, y: f32, dx: f32, dy: f32, k: f32) -> [f32; 4] {
|
||
let at = |s: f32| sample(frosted, x + dx * s, y + dy * s);
|
||
let last = (DISPERSION_STEPS - 1) as f32;
|
||
let (mut acc, mut sum) = ([0.0f32; 4], 0.0f32);
|
||
for i in 1..DISPERSION_STEPS - 1 {
|
||
let t = i as f32 / last - 0.5;
|
||
let w = 1.0 - (2.0 * t).abs();
|
||
let (r, g, b) = (at(1.0 + (t + 0.5) * k), at(1.0 + t * k), at(1.0 + (t - 0.5) * k));
|
||
acc[0] += r[0] * w;
|
||
acc[1] += g[1] * w;
|
||
acc[2] += b[2] * w;
|
||
acc[3] += r[3].max(g[3]).max(b[3]) * w;
|
||
sum += w;
|
||
}
|
||
let a = acc[3] / sum;
|
||
[(acc[0] / sum).min(a), (acc[1] / sum).min(a), (acc[2] / sum).min(a), a]
|
||
}
|
||
|
||
/// `base`, a premultiplied backdrop pixel (channels 0–255), with `add` (levels, below 0 darkens)
|
||
/// added to its colour, at coverage `c`. Where that is more than the backdrop's alpha holds (all of
|
||
/// it over a transparent backdrop, an export with nothing under the glass), the alpha rises with
|
||
/// it, so the light shows there too.
|
||
fn lit(base: [f32; 4], add: f32, c: f32) -> [u8; 4] {
|
||
let [r, g, b, a] = base;
|
||
let (r, g, b) = (r + add, g + add, b + add);
|
||
let a = a.max(0.0).max(r).max(g).max(b).min(255.0);
|
||
[r, g, b, a].map(|v| (v.clamp(0.0, a) * c).round().clamp(0.0, 255.0) as u8)
|
||
}
|
||
|
||
/// Bilinear sample of premultiplied `p` at pixel coordinates `(x, y)` (centres at +0.5), each
|
||
/// channel 0..=255; transparent outside.
|
||
fn sample(p: &Pixmap, x: f32, y: f32) -> [f32; 4] {
|
||
let (w, h) = (p.width() as i64, p.height() as i64);
|
||
let (x, y) = (x - 0.5, y - 0.5);
|
||
let (x0, y0) = (x.floor(), y.floor());
|
||
let (tx, ty) = (x - x0, y - y0);
|
||
let (x0, y0) = (x0 as i64, y0 as i64);
|
||
let data = p.data();
|
||
let px = |xi: i64, yi: i64| -> [f32; 4] {
|
||
if xi < 0 || yi < 0 || xi >= w || yi >= h {
|
||
return [0.0; 4];
|
||
}
|
||
let i = ((yi * w + xi) * 4) as usize;
|
||
match data.get(i..i + 4) {
|
||
Some(s) => [f32::from(s[0]), f32::from(s[1]), f32::from(s[2]), f32::from(s[3])],
|
||
None => [0.0; 4],
|
||
}
|
||
};
|
||
let (a, b, c, d) = (px(x0, y0), px(x0 + 1, y0), px(x0, y0 + 1), px(x0 + 1, y0 + 1));
|
||
std::array::from_fn(|k| {
|
||
let top = a[k] + (b[k] - a[k]) * tx;
|
||
let bot = c[k] + (d[k] - c[k]) * tx;
|
||
top + (bot - top) * ty
|
||
})
|
||
}
|
||
|
||
/// A shape's coverage (alpha), distance to its edge and smoothed inward normals (which the light
|
||
/// reads) over a window of pixels whose top-left pixel is (`x0`, `y0`): what the shape and the zoom
|
||
/// alone decide, so it is kept whatever the depth ([`whole_shape`]).
|
||
struct EdgeFields {
|
||
x0: i64,
|
||
y0: i64,
|
||
w: usize,
|
||
h: usize,
|
||
cov: Vec<u8>,
|
||
dist: Vec<f32>,
|
||
normals: Vec<(f32, f32)>,
|
||
/// How far the normals and the bend directions are smoothed (pixels).
|
||
r: usize,
|
||
/// The bend directions for the last [`KEPT_BENDS`] core depths asked for, by their bits, the
|
||
/// most recent last ([`EdgeFields::bends()`]).
|
||
bends: Mutex<Vec<(u32, Bends)>>,
|
||
}
|
||
|
||
/// How many depths' bend directions a kept shape holds: two glass layers of the same shape with
|
||
/// different depths don't take turns working theirs out.
|
||
const KEPT_BENDS: usize = 2;
|
||
|
||
/// Inward bend directions ([`bend_directions`]) over an [`EdgeFields`] window.
|
||
type Bends = Arc<Vec<(f32, f32)>>;
|
||
|
||
/// The fields `glass` reads: a shape's [`EdgeFields`] and its bend directions for one depth.
|
||
struct Fields {
|
||
edge: Arc<EdgeFields>,
|
||
/// `None` for a shape too big to work out a core ([`fits_whole`]): it bends along its normals.
|
||
bends: Option<Bends>,
|
||
}
|
||
|
||
/// Bytes a field pixel holds: coverage, distance, a normal and [`KEPT_BENDS`] bend directions.
|
||
const FIELD_PX_BYTES: u64 = 1 + 4 + 8 + 8 * KEPT_BENDS as u64;
|
||
|
||
/// A pixel of [`Fields`]: coverage (0–1), distance to the edge, inward normal, inward bend direction.
|
||
type FieldPx = (f32, f32, (f32, f32), (f32, f32));
|
||
|
||
impl EdgeFields {
|
||
/// The fields of `cov`, the shape's coverage on the window at (`x0`, `y0`), normals smoothed
|
||
/// over `r` pixels.
|
||
fn new(cov: &Pixmap, x0: i64, y0: i64, r: usize) -> EdgeFields {
|
||
FIELDS_COMPUTED.with(|n| n.set(n.get() + 1));
|
||
let dist = inside_distance(cov);
|
||
let (w, h) = (cov.width() as usize, cov.height() as usize);
|
||
let normals = smooth_normals(&dist, w, h, r);
|
||
let cov = cov.data().chunks_exact(4).map(|p| p.get(3).copied().unwrap_or(0)).collect();
|
||
EdgeFields { x0, y0, w, h, cov, dist, normals, r, bends: Mutex::new(Vec::new()) }
|
||
}
|
||
|
||
/// The bend directions towards the core `reach` pixels in: kept from a recent render at the
|
||
/// same depth, else worked out and kept in place of the least recent (bands of one render
|
||
/// asking at once wait for the first).
|
||
fn bends(&self, reach: f32) -> Bends {
|
||
let bits = reach.to_bits();
|
||
let mut kept = self.bends.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||
if let Some(i) = kept.iter().position(|(b, _)| *b == bits) {
|
||
let hit = kept.remove(i);
|
||
let bends = hit.1.clone();
|
||
kept.push(hit);
|
||
return bends;
|
||
}
|
||
BENDS_COMPUTED.with(|n| n.set(n.get() + 1));
|
||
let bends = Arc::new(bend_directions(&self.dist, self.w, self.h, reach, self.r));
|
||
if kept.len() >= KEPT_BENDS {
|
||
kept.remove(0);
|
||
}
|
||
kept.push((bits, bends.clone()));
|
||
bends
|
||
}
|
||
}
|
||
|
||
impl Fields {
|
||
/// The fields of `cov`, the shape's coverage on the window at (`x0`, `y0`), normals and bend
|
||
/// directions smoothed over `r` pixels, bending towards the core `reach` pixels in (along the
|
||
/// normals without a `reach`).
|
||
fn new(cov: &Pixmap, x0: i64, y0: i64, r: usize, reach: Option<f32>) -> Fields {
|
||
Fields::of(Arc::new(EdgeFields::new(cov, x0, y0, r)), reach)
|
||
}
|
||
|
||
/// `edge`'s fields bending towards the core `reach` pixels in (along the normals without one).
|
||
fn of(edge: Arc<EdgeFields>, reach: Option<f32>) -> Fields {
|
||
let bends = reach.map(|reach| edge.bends(reach));
|
||
Fields { edge, bends }
|
||
}
|
||
|
||
/// The fields at pixel (`x`, `y`); `None` outside the window.
|
||
fn at(&self, x: i64, y: i64) -> Option<FieldPx> {
|
||
let e = &*self.edge;
|
||
let (fx, fy) = (x.checked_sub(e.x0)?, y.checked_sub(e.y0)?);
|
||
if fx < 0 || fy < 0 || fx >= e.w as i64 || fy >= e.h as i64 {
|
||
return None;
|
||
}
|
||
let i = fy as usize * e.w + fx as usize;
|
||
let normal = *e.normals.get(i)?;
|
||
let bend = match &self.bends {
|
||
Some(bends) => *bends.get(i)?,
|
||
None => normal,
|
||
};
|
||
Some((f32::from(*e.cov.get(i)?) / 255.0, *e.dist.get(i)?, normal, bend))
|
||
}
|
||
}
|
||
|
||
thread_local! {
|
||
static FIELDS_COMPUTED: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||
static BENDS_COMPUTED: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||
}
|
||
|
||
/// How many times this thread has worked out a glass shape's fields: what a cache miss costs. For
|
||
/// tests and benchmarks.
|
||
pub fn glass_fields_computed() -> u64 {
|
||
FIELDS_COMPUTED.with(std::cell::Cell::get)
|
||
}
|
||
|
||
/// How many times this thread has worked out a glass shape's bend directions towards its core: a
|
||
/// new shape, or a kept one at a new depth. For tests and benchmarks.
|
||
pub fn glass_bends_computed() -> u64 {
|
||
BENDS_COMPUTED.with(std::cell::Cell::get)
|
||
}
|
||
|
||
/// What a glass shape's [`EdgeFields`] depend on, with the transform's translation reduced to its
|
||
/// fraction of a pixel: the same shape at the same zoom has the same key wherever it is and however
|
||
/// a render cuts it. Not the depth (beyond the margin and smoothing): the bend directions that
|
||
/// depend on it are worked out from the kept fields ([`EdgeFields::bends()`]).
|
||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||
struct ShapeKey {
|
||
path: u64,
|
||
linear: [u64; 4],
|
||
frac: (i64, i64),
|
||
margin: u32,
|
||
r: usize,
|
||
}
|
||
|
||
/// The cached fields of a shape whose window holds at most this many pixels: 4M on the desktop, 1M
|
||
/// on the web. Bigger shapes (a pane far larger than the view) are worked out for the part a render
|
||
/// shows each time, with no core: they bend along their edge normals. So a large glass text or
|
||
/// vector can bend differently once zoomed into, in a 2× export than on the canvas, and on the web
|
||
/// than on the desktop. A rounded rectangle works out its core in closed form at any size.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
const MAX_SHAPE_PX: u64 = 4 << 20;
|
||
#[cfg(target_arch = "wasm32")]
|
||
const MAX_SHAPE_PX: u64 = 1 << 20;
|
||
/// Bytes the glass shape cache holds at most (less on the web, where memory never shrinks).
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
const MAX_CACHED_BYTES: u64 = 128 << 20;
|
||
#[cfg(target_arch = "wasm32")]
|
||
const MAX_CACHED_BYTES: u64 = 32 << 20;
|
||
|
||
/// A shape's fields once worked out; parallel bands wanting the same shape wait for one of them.
|
||
type Slot = Arc<OnceLock<Option<Arc<EdgeFields>>>>;
|
||
|
||
/// The shapes' fields by key, at most `budget` bytes; the least recently used go first. A shape is
|
||
/// kept from the second render that asks for it: one asked for once (each step of a zoom, a pane
|
||
/// dragged by fractions of a pixel) is cheaper worked out for just the part a render shows.
|
||
struct ShapeCache {
|
||
map: HashMap<ShapeKey, (Slot, u64, u64)>,
|
||
/// Shapes asked for but not kept, by the render that first asked.
|
||
seen: HashMap<ShapeKey, u64>,
|
||
held: u64,
|
||
tick: u64,
|
||
budget: u64,
|
||
}
|
||
|
||
/// Shapes remembered as asked for once; past this many they are forgotten.
|
||
const MAX_SEEN: usize = 256;
|
||
|
||
impl ShapeCache {
|
||
fn new(budget: u64) -> Self {
|
||
Self { map: HashMap::new(), seen: HashMap::new(), held: 0, tick: 0, budget }
|
||
}
|
||
|
||
/// The slot for `key` (filled or about to be) asked for in render number `render`, making room
|
||
/// for `bytes`; `None` when it isn't kept (first asked for in this render, or too big).
|
||
fn slot(&mut self, key: &ShapeKey, bytes: u64, render: u64) -> Option<Slot> {
|
||
self.tick += 1;
|
||
if let Some((slot, _, used)) = self.map.get_mut(key) {
|
||
*used = self.tick;
|
||
return Some(slot.clone());
|
||
}
|
||
if bytes > self.budget {
|
||
return None;
|
||
}
|
||
match self.seen.get(key) {
|
||
Some(first) if *first < render => {
|
||
self.seen.remove(key);
|
||
}
|
||
Some(_) => return None,
|
||
None => {
|
||
if self.seen.len() >= MAX_SEEN {
|
||
self.seen.clear();
|
||
}
|
||
self.seen.insert(key.clone(), render);
|
||
return None;
|
||
}
|
||
}
|
||
while self.held + bytes > self.budget {
|
||
let oldest = self.map.iter().min_by_key(|(_, (_, _, used))| *used).map(|(k, _)| k.clone())?;
|
||
if let Some((_, b, _)) = self.map.remove(&oldest) {
|
||
self.held -= b;
|
||
}
|
||
}
|
||
let slot = Slot::default();
|
||
self.map.insert(key.clone(), (slot.clone(), bytes, self.tick));
|
||
self.held += bytes;
|
||
Some(slot)
|
||
}
|
||
}
|
||
|
||
fn shape_cache() -> &'static Mutex<ShapeCache> {
|
||
static C: OnceLock<Mutex<ShapeCache>> = OnceLock::new();
|
||
C.get_or_init(|| Mutex::new(ShapeCache::new(MAX_CACHED_BYTES)))
|
||
}
|
||
|
||
/// Whether `shape` (through `xf`) fits [`MAX_SHAPE_PX`] whole with a margin `m` round it: the
|
||
/// window a kept shape's fields cover ([`whole_shape`]). Depends on the shape and the zoom alone, so
|
||
/// every band and patch of a render answers the same.
|
||
fn fits_whole(shape: &Outline, xf: Affine, m: u32) -> bool {
|
||
let Some(own) = own_frame(xf) else { return false };
|
||
let bb = (own.xf * shape.0.clone()).bounding_box();
|
||
let margin = f64::from(m.div_ceil(32).saturating_mul(32));
|
||
let (w, h) = (bb.x1.ceil() - bb.x0.floor() + 2.0 + 2.0 * margin, bb.y1.ceil() - bb.y0.floor() + 2.0 + 2.0 * margin);
|
||
let px = w * h;
|
||
px.is_finite() && px <= MAX_SHAPE_PX as f64
|
||
}
|
||
|
||
/// The fields `glass` reads for a `w` × `h` layer whose `shape` maps through `xf` (local → layer
|
||
/// pixels), exact for distances up to the margin `m`, normals and bend directions smoothed over
|
||
/// `r` pixels, bending towards the core `reach` pixels in, and the shift from layer pixels to the
|
||
/// fields' pixels.
|
||
///
|
||
/// A shape asked for again in a later render, whose window fits [`MAX_SHAPE_PX`], is worked out
|
||
/// whole, in its own frame (the translation's fraction of a pixel), and kept: a band, a canvas
|
||
/// patch or the next frame of a drag reads the same fields. Otherwise it is worked out for the part
|
||
/// shown plus the margin.
|
||
fn shape_fields(shape: &Outline, xf: Affine, w: u32, h: u32, m: u32, r: usize, reach: Option<f32>) -> Option<(Fields, (i64, i64))> {
|
||
if let Some((edge, shift)) = whole_shape(shape, xf, m, r) {
|
||
return Some((Fields::of(edge, reach), shift));
|
||
}
|
||
let cov = spread_alpha(std::slice::from_ref(shape), 0.0, Affine::translate((f64::from(m), f64::from(m))) * xf, w + 2 * m, h + 2 * m)?;
|
||
Some((Fields::new(&cov, -i64::from(m), -i64::from(m), r, reach), (0, 0)))
|
||
}
|
||
|
||
/// A layer transform split so the same shape at the same zoom lands on the same pixels of its own
|
||
/// frame wherever a render puts it or cuts it (see [`own_frame`]).
|
||
struct OwnFrame {
|
||
/// The whole pixels of the translation: the shift from layer pixels to the shape's own frame.
|
||
shift: (i64, i64),
|
||
/// The translation's fraction of a pixel, in steps of 1/65536.
|
||
frac: (i64, i64),
|
||
/// Local coordinates → the shape's own frame: the linear part and that fraction.
|
||
xf: Affine,
|
||
}
|
||
|
||
/// `xf` (local → layer pixels) split into its [`OwnFrame`]; `None` for a translation too far out.
|
||
fn own_frame(xf: Affine) -> Option<OwnFrame> {
|
||
let [a, b, c, d, e, f] = xf.as_coeffs();
|
||
// The translation in steps of 1/65536 px (far finer than the rasteriser's 1/64, coarse enough
|
||
// that rounding errors in where a render starts land on the same step), its whole pixels (the
|
||
// shift) and the fraction left; rounding first carries 99.9999999 over to 100.
|
||
const STEPS: f64 = 65536.0;
|
||
let q = |v: f64| {
|
||
let q = (v * STEPS).round();
|
||
(q.is_finite() && q.abs() < 1e15).then_some(q as i64)
|
||
};
|
||
let (qe, qf) = (q(e)?, q(f)?);
|
||
let shift = (qe.div_euclid(STEPS as i64), qf.div_euclid(STEPS as i64));
|
||
let frac = (qe.rem_euclid(STEPS as i64), qf.rem_euclid(STEPS as i64));
|
||
Some(OwnFrame { shift, frac, xf: Affine::new([a, b, c, d, frac.0 as f64 / STEPS, frac.1 as f64 / STEPS]) })
|
||
}
|
||
|
||
/// [`shape_fields`]' [`EdgeFields`] for a whole shape, from the cache; `None` when it isn't kept
|
||
/// (first asked for in this render, too big, or its transform too far out).
|
||
fn whole_shape(shape: &Outline, xf: Affine, m: u32, r: usize) -> Option<(Arc<EdgeFields>, (i64, i64))> {
|
||
let [a, b, c, d, _, _] = xf.as_coeffs();
|
||
let OwnFrame { shift, frac, xf: own } = own_frame(xf)?;
|
||
let bb = (own * shape.0.clone()).bounding_box();
|
||
let (x0, y0, x1, y1) = (bb.x0.floor(), bb.y0.floor(), bb.x1.ceil(), bb.y1.ceil());
|
||
if ![x0, y0, x1, y1].iter().all(|v| v.is_finite() && v.abs() < 1e9) {
|
||
return None;
|
||
}
|
||
// The margin in steps of 32 px, so dragging a slider that widens it a little keeps the same
|
||
// fields (a wider margin only adds exact distances further out: the same pixels).
|
||
let margin = m.div_ceil(32).checked_mul(32)?;
|
||
let (rw, rh) = ((x1 - x0) as u64 + 2, (y1 - y0) as u64 + 2);
|
||
let (ww, wh) = (rw + 2 * u64::from(margin), rh + 2 * u64::from(margin));
|
||
if ww.checked_mul(wh)? > MAX_SHAPE_PX {
|
||
return None;
|
||
}
|
||
let mut hasher = DefaultHasher::new();
|
||
crate::draw::hash_path(&shape.0, &mut hasher);
|
||
matches!(shape.1, tiny_skia::FillRule::EvenOdd).hash(&mut hasher);
|
||
let key = ShapeKey { path: hasher.finish(), linear: [a, b, c, d].map(f64::to_bits), frac, margin, r };
|
||
let compute = || {
|
||
// Rastered against the shape's own bounds (not the window), so the margin can't move a
|
||
// coverage value.
|
||
let raster = spread_alpha(std::slice::from_ref(shape), 0.0, Affine::translate((1.0 - x0, 1.0 - y0)) * own, rw as u32, rh as u32)?;
|
||
let mut win = Pixmap::new(ww as u32, wh as u32)?;
|
||
let (row, at) = (raster.width() as usize * 4, (margin as usize, margin as usize));
|
||
let stride = win.width() as usize * 4;
|
||
for (y, src) in raster.data().chunks_exact(row).enumerate() {
|
||
let start = (y + at.1) * stride + at.0 * 4;
|
||
win.data_mut().get_mut(start..start + row)?.copy_from_slice(src);
|
||
}
|
||
let origin = (x0 as i64 - 1 - i64::from(margin), y0 as i64 - 1 - i64::from(margin));
|
||
Some(EdgeFields::new(&win, origin.0, origin.1, r))
|
||
};
|
||
let slot =
|
||
shape_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).slot(&key, ww * wh * FIELD_PX_BYTES, crate::render_number())?;
|
||
Some((slot.get_or_init(|| compute().map(Arc::new)).clone()?, shift))
|
||
}
|
||
|
||
/// A layer whose glass shape is a rectangle with circular corners: its size and corner radii
|
||
/// (top-left, top-right, bottom-right, bottom-left), as [`figcraft_geom::rounded_rect`] draws them.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub struct RoundedRect {
|
||
pub width: f64,
|
||
pub height: f64,
|
||
pub radii: [f64; 4],
|
||
}
|
||
|
||
impl RoundedRect {
|
||
/// The rounded rectangle `n`'s glass is the shape of, when its outline is one: rectangles,
|
||
/// frames, components, instances and sections whose corners aren't smoothed.
|
||
pub fn of(n: &Node) -> Option<RoundedRect> {
|
||
let rect_like = matches!(
|
||
n.kind,
|
||
NodeKind::Rectangle
|
||
| NodeKind::Frame
|
||
| NodeKind::Component { .. }
|
||
| NodeKind::ComponentSet { .. }
|
||
| NodeKind::Instance { .. }
|
||
| NodeKind::Section { .. }
|
||
);
|
||
let sized = n.width.is_finite() && n.height.is_finite() && n.width > 0.0 && n.height > 0.0;
|
||
(rect_like && sized && n.corners.smoothing == 0.0).then(|| {
|
||
// Clamped as `rounded_rect` clamps them: each radius to half the shorter side.
|
||
let most = n.width.min(n.height) / 2.0;
|
||
let radii = n.corners.radii.map(|r| if r.is_finite() { r.clamp(0.0, most) } else { 0.0 });
|
||
RoundedRect { width: n.width, height: n.height, radii }
|
||
})
|
||
}
|
||
}
|
||
|
||
/// Signed distance from `q` (relative to the centre) to a rectangle reaching `half` each way with
|
||
/// corner radius `r` (negative inside), and its outward gradient: the unit normal along the sides,
|
||
/// pointing away from the corner's centre round the corners.
|
||
fn rounded_rect_sd(q: (f64, f64), half: (f64, f64), r: f64) -> (f64, (f64, f64)) {
|
||
let (sx, sy) = (if q.0 < 0.0 { -1.0 } else { 1.0 }, if q.1 < 0.0 { -1.0 } else { 1.0 });
|
||
let k = (q.0.abs() - half.0 + r, q.1.abs() - half.1 + r);
|
||
if k.0 > 0.0 && k.1 > 0.0 {
|
||
let len = (k.0 * k.0 + k.1 * k.1).sqrt();
|
||
(len - r, (sx * k.0 / len, sy * k.1 / len))
|
||
} else if k.0 > k.1 {
|
||
(k.0 - r, (sx, 0.0))
|
||
} else {
|
||
(k.1 - r, (0.0, sy))
|
||
}
|
||
}
|
||
|
||
/// The radius of the corner of `radii` (top-left, top-right, bottom-right, bottom-left) whose
|
||
/// quarter `q` (relative to the centre, y down) lies in.
|
||
fn corner_of(radii: [f64; 4], q: (f64, f64)) -> f64 {
|
||
let i = match (q.0 < 0.0, q.1 < 0.0) {
|
||
(true, true) => 0,
|
||
(false, true) => 1,
|
||
(false, false) => 2,
|
||
(true, false) => 3,
|
||
};
|
||
radii.get(i).copied().unwrap_or(0.0)
|
||
}
|
||
|
||
/// A rounded rectangle's glass fields worked out pixel by pixel, in closed form: no coverage to
|
||
/// rasterise, no distance transform, nothing to cache. The same fields as [`Fields`] (the bend
|
||
/// directions round the corners towards the rectangle shrunk by the core's depth, the middle line
|
||
/// of one thinner than twice that), exact rather than from pixels, and the same wherever a render
|
||
/// cuts it: each pixel is placed in the layer's own frame.
|
||
struct AnalyticRect {
|
||
/// Layer pixels → the shape's own frame.
|
||
shift: (i64, i64),
|
||
/// The shape's own frame → local coordinates.
|
||
inv: Affine,
|
||
/// Local → device directions (the transform's linear part), and its scale.
|
||
linear: [f64; 4],
|
||
scale: f64,
|
||
half: (f64, f64),
|
||
radii: [f64; 4],
|
||
/// The corners the light's normals round to: each radius at least the bevel (a sharp corner's
|
||
/// bevel catches the light along its diagonal), at most the half side.
|
||
bevels: [f64; 4],
|
||
/// The corners the bend rounds to: each radius at least the core's depth, at most the half
|
||
/// side ([`bend_directions`]).
|
||
rounds: [f64; 4],
|
||
}
|
||
|
||
impl AnalyticRect {
|
||
/// `rect`'s fields for a layer whose `xf` maps local coordinates to its pixels, with a `bevel`
|
||
/// and bending towards the core `reach` pixels in; `None` when `xf` isn't a rotation, flip and
|
||
/// even scale (skewed or stretched rectangles go through the distance transform) or its
|
||
/// translation is too far out.
|
||
fn new(rect: RoundedRect, xf: Affine, bevel: f64, reach: f64) -> Option<AnalyticRect> {
|
||
let own = own_frame(xf)?;
|
||
let [a, b, c, d, _, _] = own.xf.as_coeffs();
|
||
let (sq_x, sq_y) = (a * a + b * b, c * c + d * d);
|
||
let even = (sq_x - sq_y).abs() <= 1e-9 * sq_x && (a * c + b * d).abs() <= 1e-9 * sq_x;
|
||
let scale = sq_x.sqrt();
|
||
if !(even && scale.is_finite() && scale > 1e-9) {
|
||
return None;
|
||
}
|
||
let half = (rect.width / 2.0, rect.height / 2.0);
|
||
let at_least = |least: f64| rect.radii.map(|r| r.max(least / scale).min(half.0.min(half.1)));
|
||
let (bevels, rounds) = (at_least(bevel), at_least(reach));
|
||
Some(AnalyticRect { shift: own.shift, inv: own.xf.inverse(), linear: [a, b, c, d], scale, half, radii: rect.radii, bevels, rounds })
|
||
}
|
||
|
||
/// The fields at layer pixel (`x`, `y`): coverage (box-filtered from the signed distance),
|
||
/// distance in, inward normal and inward bend direction.
|
||
fn at(&self, x: i64, y: i64) -> FieldPx {
|
||
let p = self.inv * Point::new((x - self.shift.0) as f64 + 0.5, (y - self.shift.1) as f64 + 0.5);
|
||
let q = (p.x - self.half.0, p.y - self.half.1);
|
||
let (sd, _) = rounded_rect_sd(q, self.half, corner_of(self.radii, q));
|
||
let (_, normal) = rounded_rect_sd(q, self.half, corner_of(self.bevels, q));
|
||
let (_, round) = rounded_rect_sd(q, self.half, corner_of(self.rounds, q));
|
||
let [a, b, c, d] = self.linear;
|
||
let inward = |n: (f64, f64)| (-((a * n.0 + c * n.1) / self.scale) as f32, -((b * n.0 + d * n.1) / self.scale) as f32);
|
||
let sd = sd * self.scale;
|
||
((0.5 - sd).clamp(0.0, 1.0) as f32, (-sd).max(0.0) as f32, inward(normal), inward(round))
|
||
}
|
||
}
|
||
|
||
/// Where a glass reads its fields: worked out in closed form for a rounded rectangle, from its
|
||
/// coverage otherwise.
|
||
enum Geometry {
|
||
Rect(AnalyticRect),
|
||
Fields(Fields, (i64, i64)),
|
||
}
|
||
|
||
impl Geometry {
|
||
/// The fields at layer pixel (`x`, `y`); `None` where there are none.
|
||
fn at(&self, x: i64, y: i64) -> Option<FieldPx> {
|
||
match self {
|
||
Geometry::Rect(r) => Some(r.at(x, y)),
|
||
Geometry::Fields(f, shift) => f.at(x - shift.0, y - shift.1),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The glass of a `w` × `h` layer whose `shape` maps through `xf` (local → layer pixels), from
|
||
/// `back`, its backdrop padded by `pad` pixels each way, at `scale` device pixels per unit. `rect`:
|
||
/// the rounded rectangle the shape is, when it is one, so its fields are worked out in closed form.
|
||
pub fn glass(g: &Glass, frame: EffectFrame<'_>, rect: Option<RoundedRect>) -> Option<Pixmap> {
|
||
let EffectFrame { shape, back, pad, size: (w, h), xf, scale } = frame;
|
||
let g = g.sanitized();
|
||
let depth = (g.depth * scale).clamp(1.0, 1024.0);
|
||
let bevel = (BEVEL * scale).min(1024.0);
|
||
let r = (depth * 0.1).clamp(1.0, 8.0) as usize;
|
||
// The core the backdrop bends towards lies `reach` in from the edge ([`bend_directions`]).
|
||
let reach = (depth + bevel) as f32;
|
||
// Coverage with a margin, so wherever the layer is cut every field the pixels read is the
|
||
// whole render's: the distances the bend and the gradient read (within `depth` + `bevel`; the
|
||
// light lies on the bevel), the core they bend towards (its nearest pixel up to √2 × `reach`
|
||
// further, round a sharp corner, so 1.5 × `reach`; its depths exact within `reach` and
|
||
// [`RIDGE_PX`] of it), and those the normals and bends are smoothed from (2r + 1 further).
|
||
let plain = (depth + bevel).ceil() as u32 + 4 * r as u32 + 4;
|
||
let cored = plain + (1.5 * reach).ceil() as u32 + RIDGE_PX as u32;
|
||
// A core takes a wider margin and more bytes a pixel, so only a shape whose whole window fits
|
||
// [`MAX_SHAPE_PX`] works one out: a bigger one bends along its edge normals, so no render
|
||
// allocates a core's fields over more pixels than a kept shape holds.
|
||
let core = fits_whole(shape, xf, cored).then_some(reach);
|
||
let m = if core.is_some() { cored } else { plain };
|
||
let geometry = match rect.and_then(|rect| AnalyticRect::new(rect, xf, bevel, f64::from(reach))) {
|
||
Some(analytic) => Geometry::Rect(analytic),
|
||
None => {
|
||
let (fields, shift) = shape_fields(shape, xf, w, h, m, r, core)?;
|
||
Geometry::Fields(fields, shift)
|
||
}
|
||
};
|
||
let mut frosted = back.clone();
|
||
fx::blur(&mut frosted, g.frost / 2.0 * scale);
|
||
let ang = g.light_angle.to_radians();
|
||
let light = (ang.sin() as f32, -ang.cos() as f32);
|
||
// The centre of the layer's bounds in its own frame (see `own_frame`), which `splay` bends away
|
||
// from: the direction to it is the same wherever a render puts or cuts the layer.
|
||
let local_centre = shape.0.bounding_box().center();
|
||
let ((sx, sy), centre) = match own_frame(xf) {
|
||
Some(own) => (own.shift, own.xf * local_centre),
|
||
None => ((0, 0), xf * local_centre),
|
||
};
|
||
let (refraction, dispersion, bevel) = (g.refraction as f32, (g.dispersion * Glass::DISPERSION_SPREAD) as f32, bevel as f32);
|
||
let (splay, intensity, depth) = (g.splay as f32, g.light_intensity as f32, depth as f32);
|
||
let mut out = Pixmap::new(w, h)?;
|
||
let dst = out.data_mut();
|
||
for y in 0..h as usize {
|
||
for x in 0..w as usize {
|
||
let Some((c, d, inward, toward)) = geometry.at(x as i64, y as i64) else { continue };
|
||
if c <= 0.0 {
|
||
continue;
|
||
}
|
||
let (bx, by) = (x as f32 + pad as f32 + 0.5, y as f32 + pad as f32 + 0.5);
|
||
let (base, add) = if d >= depth + bevel {
|
||
// Past the curve nothing bends and no light falls (the rim light and the gradient
|
||
// are 0 there): the channels' samples are at the same point, so one sample.
|
||
(dispersed(&frosted, bx, by, 0.0, 0.0, 0.0), 0.0)
|
||
} else {
|
||
// The curve's outward direction: away from its core, turned by `splay` towards the
|
||
// way away from the centre (not renormalised: where the two part, the bend is less).
|
||
let away = if splay > 0.0 {
|
||
let px = ((x as i64 - sx) as f64 + 0.5 - centre.x) as f32;
|
||
let py = ((y as i64 - sy) as f64 + 0.5 - centre.y) as f32;
|
||
let len = (px * px + py * py).sqrt();
|
||
if len > 1e-6 { (px / len, py / len) } else { (0.0, 0.0) }
|
||
} else {
|
||
(0.0, 0.0)
|
||
};
|
||
let out_dir = (-toward.0 * (1.0 - splay) + away.0 * splay, -toward.1 * (1.0 - splay) + away.1 * splay);
|
||
// No refraction: nothing bends, so one sample (`dispersed` with no move).
|
||
let bend = bend_at(d, depth, bevel, refraction);
|
||
let k = if bend == 0.0 { 0.0 } else { dispersion };
|
||
let base = dispersed(&frosted, bx, by, -out_dir.0 * bend, -out_dir.1 * bend, k);
|
||
// The bevel catches the light as a line added to the backdrop, by how much the edge
|
||
// faces it; across the curve a faint gradient, darker on the side facing it.
|
||
let facing = -(inward.0 * light.0 + inward.1 * light.1);
|
||
let rim = rim_light_at(d, bevel) * catches(facing);
|
||
let sheen = -(out_dir.0 * light.0 + out_dir.1 * light.1) * sheen_at(d, depth, bevel) * BROAD;
|
||
(base, 255.0 * intensity * (rim + sheen))
|
||
};
|
||
let i = (y * w as usize + x) * 4;
|
||
if let Some(p) = dst.get_mut(i..i + 4) {
|
||
p.copy_from_slice(&lit(base, add, c));
|
||
}
|
||
}
|
||
}
|
||
Some(out)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// The bend at depth 20, refraction 0.8 and a 1-unit bevel, x units in from the edge: the
|
||
/// reference profile's values.
|
||
#[test]
|
||
fn the_bend_follows_the_thick_lens_profile() {
|
||
let table = [
|
||
(0.10, 3.1968),
|
||
(0.25, 3.1968),
|
||
(0.50, 6.8543),
|
||
(0.75, 11.4263),
|
||
(1.00, 15.9982),
|
||
(2.0, 13.3707),
|
||
(4.0, 9.0591),
|
||
(6.0, 5.8457),
|
||
(8.0, 3.5426),
|
||
(10.0, 1.9742),
|
||
(16.0, 0.1250),
|
||
(20.0, 0.0004),
|
||
(21.0, 0.0),
|
||
(40.0, 0.0),
|
||
];
|
||
for (x, want) in table {
|
||
let got = bend_at(x, 20.0, 1.0, 0.8);
|
||
assert!((got - want).abs() < 1e-3, "x {x}: {got} vs {want}");
|
||
}
|
||
// No refraction, no bend; never more than 0.999 × depth, never outwards.
|
||
assert_eq!(bend_at(0.5, 20.0, 1.0, 0.0), 0.0);
|
||
for i in 0..400 {
|
||
let x = i as f32 * 0.1;
|
||
let b = bend_at(x, 20.0, 1.0, 1.0);
|
||
assert!((0.0..=0.999 * 20.0).contains(&b), "x {x}: {b}");
|
||
}
|
||
// A depth thinner than the bevel (zoomed far in) still gives numbers.
|
||
assert!(bend_at(0.5, 1.0, 8.0, 1.0).is_finite());
|
||
// `Glass::max_bend`, which sizes the backdrop read around a pane, bounds the whole bend.
|
||
for refraction in [0.3, 0.8, 1.0] {
|
||
let most = (0..800).map(|i| bend_at(i as f32 * 0.05, 20.0, 1.0, refraction as f32)).fold(0.0f32, f32::max);
|
||
let bound = Glass { refraction, depth: 20.0, dispersion: 0.0, ..Glass::default() }.max_bend();
|
||
assert!(f64::from(most) <= bound + 1e-4, "refraction {refraction}: {most} past {bound}");
|
||
}
|
||
}
|
||
|
||
/// Dispersion off is one sample at the moved point; on, the channels split along the move, red
|
||
/// further and blue less, and a flat backdrop stays flat.
|
||
#[test]
|
||
fn dispersion_splits_the_channels_along_the_bend() {
|
||
// A 40 × 1 ramp: red, green and blue all rise with x.
|
||
let mut p = Pixmap::new(40, 1).unwrap();
|
||
for (i, px) in p.data_mut().chunks_exact_mut(4).enumerate() {
|
||
let v = (i * 6) as u8;
|
||
px.copy_from_slice(&[v, v, v, 255]);
|
||
}
|
||
let one = sample(&p, 10.5 + 8.0, 0.5);
|
||
let off = dispersed(&p, 10.5, 0.5, 8.0, 0.0, 0.0);
|
||
assert_eq!(off, [one[0], one[1], one[2], one[3]]);
|
||
let on = dispersed(&p, 10.5, 0.5, 8.0, 0.0, 1.0);
|
||
assert!(on[0] > on[1] && on[1] > on[2], "{on:?}");
|
||
assert!((on[1] - one[1]).abs() < 1e-3, "green stays centred on the move: {on:?} vs {one:?}");
|
||
let mut flat = Pixmap::new(40, 1).unwrap();
|
||
flat.fill(tiny_skia::Color::from_rgba8(90, 120, 200, 255));
|
||
let f = dispersed(&flat, 10.5, 0.5, 8.0, 0.0, 1.0);
|
||
assert!(f.iter().zip([90.0, 120.0, 200.0, 255.0]).all(|(a, b)| (a - b).abs() < 1e-3), "{f:?}");
|
||
// One white pixel on black: only the weighted steps that reach it pick it up, red and green
|
||
// from opposite ends of their windows (the same amount), blue none.
|
||
let mut impulse = Pixmap::new(40, 1).unwrap();
|
||
impulse.fill(tiny_skia::Color::BLACK);
|
||
impulse.data_mut()[20 * 4..21 * 4].copy_from_slice(&[255, 255, 255, 255]);
|
||
let spread = dispersed(&impulse, 10.5, 0.5, 8.0, 0.0, 1.0);
|
||
assert!((spread[0] - 33.392857).abs() < 1e-3 && (spread[1] - 33.392857).abs() < 1e-3, "{spread:?}");
|
||
assert_eq!(spread[2], 0.0);
|
||
}
|
||
|
||
/// Where the channels split under [`ONE_SAMPLE_SPLIT_PX`], one sample stands in for the window
|
||
/// within one level, even over the hardest backdrops: 1 px columns of black and white, and of
|
||
/// white and nothing.
|
||
#[test]
|
||
fn a_split_too_narrow_to_show_takes_one_sample() {
|
||
for clear in [false, true] {
|
||
let mut p = Pixmap::new(64, 4).unwrap();
|
||
for (i, px) in p.data_mut().chunks_exact_mut(4).enumerate() {
|
||
let on = i % 2 == 1;
|
||
let v = if on { 255 } else { 0 };
|
||
px.copy_from_slice(&[v, v, v, if clear { v } else { 255 }]);
|
||
}
|
||
let mut worst = 0.0f32;
|
||
for step in 0..400 {
|
||
let x = 20.0 + step as f32 / 100.0;
|
||
// Along the columns' rows and across them, and out past the backdrop's last row.
|
||
for (dx, dy) in [(2.0f32, 0.0f32), (-2.0, 0.0), (1.2, 1.6), (0.0, 2.0), (40.0, 0.0)] {
|
||
let k = ONE_SAMPLE_SPLIT_PX * 0.999 / (dx * dx + dy * dy).sqrt();
|
||
let one = dispersed(&p, x, 2.0, dx, dy, k);
|
||
assert_eq!(one, dispersed(&p, x, 2.0, dx, dy, 0.0), "one sample at {x}");
|
||
let window = dispersion_window(&p, x, 2.0, dx, dy, k);
|
||
worst = one.iter().zip(window).fold(worst, |m, (a, b)| m.max((a - b).abs()));
|
||
}
|
||
}
|
||
assert!(worst <= 1.0, "clear {clear}: {worst}");
|
||
}
|
||
// A wider split still takes the window.
|
||
let mut p = Pixmap::new(64, 4).unwrap();
|
||
p.data_mut().chunks_exact_mut(4).step_by(2).for_each(|px| px.copy_from_slice(&[255; 4]));
|
||
let k = ONE_SAMPLE_SPLIT_PX * 2.0 / 2.0;
|
||
assert_eq!(dispersed(&p, 20.3, 2.0, 2.0, 0.0, k), dispersion_window(&p, 20.3, 2.0, 2.0, 0.0, k));
|
||
assert_ne!(dispersed(&p, 20.3, 2.0, 2.0, 0.0, k), dispersed(&p, 20.3, 2.0, 2.0, 0.0, 0.0));
|
||
}
|
||
|
||
/// A `w` × `h` coverage with the rectangles `rects` (x, y, width, height) filled.
|
||
fn coverage(w: u32, h: u32, rects: &[(f32, f32, f32, f32)]) -> Pixmap {
|
||
let mut p = Pixmap::new(w, h).unwrap();
|
||
let paint = tiny_skia::Paint { shader: tiny_skia::Shader::SolidColor(tiny_skia::Color::WHITE), ..Default::default() };
|
||
for (x, y, rw, rh) in rects {
|
||
p.fill_rect(tiny_skia::Rect::from_xywh(*x, *y, *rw, *rh).unwrap(), &paint, tiny_skia::Transform::identity(), None);
|
||
}
|
||
p
|
||
}
|
||
|
||
fn bend_dir(f: &Fields, x: i64, y: i64) -> (f32, f32) {
|
||
f.at(x, y).unwrap().3
|
||
}
|
||
|
||
fn close(a: (f32, f32), b: (f32, f32), tol: f32) -> bool {
|
||
(a.0 - b.0).abs() <= tol && (a.1 - b.1).abs() <= tol
|
||
}
|
||
|
||
#[test]
|
||
fn a_sharp_corner_bends_round_towards_the_core() {
|
||
// A 120 × 80 rectangle at (20, 20), bending towards its core 20 px in.
|
||
let f = Fields::new(&coverage(160, 120, &[(20.0, 20.0, 120.0, 80.0)]), 0, 0, 1, Some(20.0));
|
||
let s = std::f32::consts::FRAC_1_SQRT_2;
|
||
// Halfway down the left side: straight in. In the corner: diagonally, towards the core's own
|
||
// corner 20 px in.
|
||
assert!(close(bend_dir(&f, 21, 60), (1.0, 0.0), 0.02), "{:?}", bend_dir(&f, 21, 60));
|
||
assert!(close(bend_dir(&f, 22, 22), (s, s), 0.05), "{:?}", bend_dir(&f, 22, 22));
|
||
// Mirror images across the corner's diagonal, with no seam along it: neighbours agree.
|
||
let (a, b) = (bend_dir(&f, 24, 29), bend_dir(&f, 29, 24));
|
||
assert!(close(a, (b.1, b.0), 1e-4), "{a:?} vs {b:?}");
|
||
let (on, off) = (bend_dir(&f, 26, 26), bend_dir(&f, 27, 26));
|
||
assert!(close(on, off, 0.1), "{on:?} vs {off:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn a_shape_too_big_to_keep_bends_along_its_normals() {
|
||
// A small shape fits whole with a core's margin; one wider than the kept-shape limit
|
||
// doesn't, at this zoom or with this margin, wherever it sits.
|
||
let shape = |w: f64, h: f64| -> Outline { (figcraft_geom::kurbo::Rect::new(0.0, 0.0, w, h).to_path(0.1), tiny_skia::FillRule::Winding) };
|
||
assert!(fits_whole(&shape(200.0, 120.0), Affine::translate((10.5, 3.0)), 64));
|
||
assert!(!fits_whole(&shape(4000.0, 3000.0), Affine::translate((10.5, 3.0)), 64));
|
||
assert!(!fits_whole(&shape(200.0, 120.0), Affine::scale(40.0), 64));
|
||
// Without a core the fields hold no bend directions, and bend along the normals.
|
||
let f = Fields::new(&coverage(160, 120, &[(20.0, 20.0, 120.0, 80.0)]), 0, 0, 1, None);
|
||
assert!(f.bends.is_none());
|
||
for (x, y) in [(21, 60), (24, 24), (80, 21)] {
|
||
let (_, _, normal, bend) = f.at(x, y).unwrap();
|
||
assert_eq!(normal, bend, "({x}, {y})");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_thin_part_bends_across_itself() {
|
||
// A 200 × 12 bar, thinner than twice the reach: its core is its middle line, and each side
|
||
// bends towards it.
|
||
let f = Fields::new(&coverage(240, 40, &[(20.0, 14.0, 200.0, 12.0)]), 0, 0, 1, Some(20.0));
|
||
assert!(close(bend_dir(&f, 120, 14), (0.0, 1.0), 0.02), "top: {:?}", bend_dir(&f, 120, 14));
|
||
assert!(close(bend_dir(&f, 120, 25), (0.0, -1.0), 0.02), "bottom: {:?}", bend_dir(&f, 120, 25));
|
||
assert!(f.bends.as_ref().unwrap().iter().all(|(x, y)| x.is_finite() && y.is_finite()));
|
||
}
|
||
|
||
#[test]
|
||
fn neighbouring_strokes_bend_towards_their_own_middles() {
|
||
// Two 8 px strokes 2 px apart, like letters: the facing edges bend away from each other,
|
||
// each into its own stroke.
|
||
let f = Fields::new(&coverage(60, 60, &[(10.0, 10.0, 8.0, 40.0), (20.0, 10.0, 8.0, 40.0)]), 0, 0, 1, Some(20.0));
|
||
assert!(bend_dir(&f, 17, 30).0 < -0.9, "{:?}", bend_dir(&f, 17, 30));
|
||
assert!(bend_dir(&f, 20, 30).0 > 0.9, "{:?}", bend_dir(&f, 20, 30));
|
||
}
|
||
|
||
#[test]
|
||
fn rounded_rectangle_distances_and_normals() {
|
||
let half = (60.0, 40.0);
|
||
// Halfway along the left side, 3 in: 3 deep, the normal straight out.
|
||
let (sd, n) = rounded_rect_sd((-57.0, 0.0), half, 10.0);
|
||
assert!((sd + 3.0).abs() < 1e-9 && n == (-1.0, 0.0), "{sd} {n:?}");
|
||
// Round a 10-unit corner: the distance to its arc, the normal away from its centre.
|
||
let (sd, n) = rounded_rect_sd((-57.0, -37.0), half, 10.0);
|
||
let (cx, cy) = (-50.0, -30.0);
|
||
let len = ((-57.0f64 - cx).powi(2) + (-37.0f64 - cy).powi(2)).sqrt();
|
||
assert!((sd - (len - 10.0)).abs() < 1e-9, "{sd}");
|
||
assert!((n.0 + 7.0 / len).abs() < 1e-9 && (n.1 + 7.0 / len).abs() < 1e-9, "{n:?}");
|
||
// Outside, past the right side.
|
||
assert!((rounded_rect_sd((65.0, 0.0), half, 10.0).0 - 5.0).abs() < 1e-9);
|
||
assert_eq!([(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)].map(|q| corner_of([1.0, 2.0, 3.0, 4.0], q)), [1.0, 2.0, 3.0, 4.0]);
|
||
}
|
||
|
||
#[test]
|
||
fn a_rounded_rectangle_in_closed_form_matches_its_distance_transform() {
|
||
// A 120 × 80 rectangle at (20, 20), bending towards its core 21 px in, both ways.
|
||
let fields = Fields::new(&coverage(160, 120, &[(20.0, 20.0, 120.0, 80.0)]), 0, 0, 2, Some(21.0));
|
||
let rect = RoundedRect { width: 120.0, height: 80.0, radii: [0.0; 4] };
|
||
let closed = AnalyticRect::new(rect, Affine::translate((20.0, 20.0)), 1.0, 21.0).unwrap();
|
||
for (x, y) in [(21, 60), (60, 21), (138, 60), (80, 98), (24, 24), (30, 26), (135, 95), (80, 60)] {
|
||
let (a, b) = (fields.at(x, y).unwrap(), closed.at(x, y));
|
||
assert!((a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3, "({x}, {y}) coverage and depth: {a:?} vs {b:?}");
|
||
assert!(a.1 >= 21.0 || close(a.3, b.3, 0.08), "({x}, {y}) bend: {a:?} vs {b:?}");
|
||
}
|
||
// Rotated a quarter turn, the fields turn with it; skewed or stretched, no closed form.
|
||
let turned = AnalyticRect::new(rect, Affine::translate((100.0, 20.0)) * Affine::rotate(std::f64::consts::FRAC_PI_2), 1.0, 21.0).unwrap();
|
||
assert!(close(turned.at(98, 60).2, (-1.0, 0.0), 1e-6), "{:?}", turned.at(98, 60));
|
||
assert!(AnalyticRect::new(rect, Affine::new([1.0, 0.0, 0.3, 1.0, 0.0, 0.0]), 1.0, 21.0).is_none());
|
||
assert!(AnalyticRect::new(rect, Affine::scale_non_uniform(2.0, 1.0), 1.0, 21.0).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn nearest_sites_and_running_maxima() {
|
||
// Sites at (1, 1) and (8, 3) on a 10 × 5 grid.
|
||
let mut site = vec![false; 50];
|
||
site[11] = true;
|
||
site[38] = true;
|
||
let near = nearest_sites(&site, 10, 5);
|
||
assert_eq!((near[0], near[4], near[49], near[37]), (11, 11, 38, 38));
|
||
assert!(nearest_sites(&[false; 6], 3, 2).iter().all(|s| *s == NO_SITE));
|
||
let m = max_within(&[1.0, 5.0, 2.0, 0.0, 0.0, 3.0], 6, 1, 1);
|
||
assert_eq!(m, [5.0, 5.0, 5.0, 2.0, 3.0, 3.0]);
|
||
}
|
||
|
||
/// At full dispersion red's furthest step stays within `Glass::max_bend` of the pixel, all
|
||
/// along the bend, so the backdrop read around a pane covers it: one white pixel further out
|
||
/// than that is never seen.
|
||
#[test]
|
||
fn full_dispersion_reads_no_further_than_max_bend() {
|
||
let bound = Glass { refraction: 1.0, depth: 20.0, dispersion: 1.0, ..Glass::default() }.max_bend() as f32;
|
||
let k = Glass::DISPERSION_SPREAD as f32;
|
||
let mut furthest = 0usize;
|
||
for i in 0..100 {
|
||
let bend = bend_at(i as f32 * 0.25, 20.0, 1.0, 1.0);
|
||
for at in 0..40 {
|
||
let mut impulse = Pixmap::new(40, 1).unwrap();
|
||
impulse.fill(tiny_skia::Color::BLACK);
|
||
impulse.data_mut()[at * 4..at * 4 + 4].copy_from_slice(&[255, 255, 255, 255]);
|
||
let s = dispersed(&impulse, 0.5, 0.5, bend, 0.0, k);
|
||
if s[0].max(s[1]).max(s[2]) > 0.0 {
|
||
furthest = furthest.max(at);
|
||
}
|
||
}
|
||
}
|
||
// A sample `o` pixels along picks up the pixel `at` while |o − at| < 1.
|
||
assert!(furthest > 0 && (furthest as f32) < bound + 1.0, "pixel {furthest} seen, past {bound}");
|
||
}
|
||
|
||
#[test]
|
||
fn distance_of_a_square() {
|
||
let mut p = Pixmap::new(21, 21).unwrap();
|
||
let r = tiny_skia::Rect::from_xywh(5.0, 5.0, 11.0, 11.0).unwrap();
|
||
let paint = tiny_skia::Paint { shader: tiny_skia::Shader::SolidColor(tiny_skia::Color::WHITE), ..Default::default() };
|
||
p.fill_rect(r, &paint, tiny_skia::Transform::identity(), None);
|
||
let d = inside_distance(&p);
|
||
// Outside: 0. The centre pixel (10, 10) is 5.5 px from each edge.
|
||
assert_eq!(d[0], 0.0);
|
||
assert!((d[10 * 21 + 10] - 5.5).abs() < 1e-4, "{}", d[10 * 21 + 10]);
|
||
// A pixel on the edge is half a pixel from it.
|
||
assert!((d[10 * 21 + 5] - 0.5).abs() < 1e-4, "{}", d[10 * 21 + 5]);
|
||
}
|
||
|
||
#[test]
|
||
fn the_shape_cache_stays_within_its_bytes() {
|
||
let key = |i: u64| ShapeKey { path: i, linear: [0; 4], frac: (0, 0), margin: 0, r: 1 };
|
||
let mut c = ShapeCache::new(100);
|
||
// Kept from the second render that asks, not the first (nor again within the first).
|
||
assert!(c.slot(&key(1), 40, 1).is_none());
|
||
assert!(c.slot(&key(1), 40, 1).is_none());
|
||
assert!(c.slot(&key(1), 40, 2).is_some());
|
||
for k in [2, 3] {
|
||
assert!(c.slot(&key(k), 40, 2).is_none());
|
||
}
|
||
assert!(c.slot(&key(2), 40, 3).is_some());
|
||
// Using 1 again makes 2 the least recently used: 2 goes to make room for 3.
|
||
assert!(c.slot(&key(1), 40, 3).is_some());
|
||
assert!(c.slot(&key(3), 40, 3).is_some());
|
||
assert!(c.held <= 100, "{}", c.held);
|
||
assert!(c.map.contains_key(&key(1)) && c.map.contains_key(&key(3)) && !c.map.contains_key(&key(2)));
|
||
// More than it holds in all: never kept.
|
||
assert!(c.slot(&key(4), 101, 3).is_none() && c.slot(&key(4), 101, 4).is_none());
|
||
assert!(c.held <= 100 && !c.map.contains_key(&key(4)));
|
||
}
|
||
|
||
/// The rim light and the gradient across the curve at depth 20 and a 1-unit bevel, x units in
|
||
/// from the edge: the reference profile's values.
|
||
#[test]
|
||
fn the_light_lies_on_the_bevel_and_the_gradient_across_the_curve() {
|
||
for (x, rim, sheen) in [
|
||
(0.10, 1.0, 1.0),
|
||
(0.25, 0.9888, 1.0),
|
||
(0.50, 0.6843, 1.0),
|
||
(0.75, 0.2330, 1.0),
|
||
(1.00, 0.0, 1.0),
|
||
(2.0, 0.0, 0.8975),
|
||
(4.0, 0.0, 0.7091),
|
||
(6.0, 0.0, 0.5429),
|
||
(8.0, 0.0, 0.3989),
|
||
(16.0, 0.0, 0.0443),
|
||
(20.0, 0.0, 0.0),
|
||
(30.0, 0.0, 0.0),
|
||
] {
|
||
assert!((rim_light_at(x, 1.0) - rim).abs() < 1e-3, "rim at {x}: {}", rim_light_at(x, 1.0));
|
||
assert!((sheen_at(x, 20.0, 1.0) - sheen).abs() < 1e-3, "gradient at {x}: {}", sheen_at(x, 20.0, 1.0));
|
||
}
|
||
// Facing the light fully, at 45° (cos 0.707) and side on; the opposite side 0.6 as much.
|
||
assert!((catches(1.0) - 1.0).abs() < 1e-6);
|
||
assert!((catches(std::f32::consts::FRAC_1_SQRT_2) - 0.574).abs() < 1e-3, "{}", catches(std::f32::consts::FRAC_1_SQRT_2));
|
||
assert_eq!(catches(0.0), 0.0);
|
||
assert!((catches(-1.0) - 0.6).abs() < 1e-6);
|
||
assert!((catches(-std::f32::consts::FRAC_1_SQRT_2) - 0.6 * 0.574).abs() < 1e-3);
|
||
}
|
||
}
|