Merge upstream/main into fix/ui-script-fallbacks-2014

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Trevor Brown
2026-11-05 17:38:30 -05:00
co-authored by Claude Fable 5.1
6 changed files with 579 additions and 56 deletions
+3 -1
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -662,8 +662,8 @@ pub struct Glass {
pub dispersion: f64,
/// Blur of the backdrop (a blur radius, document units, 0–[`Self::MAX_FROST`]).
pub frost: f64,
/// Which way the curved edge bends the backdrop: along the edge's normal (0) to away from the
/// layer's centre (1).
/// Which way the curved edge bends the backdrop: towards the shape's core (0; along the edge's
/// normal for a shape too big to work one out) to away from the layer's centre (1).
pub splay: f64,
}
+1 -1
View File
@@ -160,7 +160,7 @@ pub fn effects(n: &Node, frame: EffectFrame<'_>) -> Option<Pixmap> {
fx::mask_by(&mut l, cov.as_ref()?, false);
l
}
EffectKind::Glass(g) => glass::glass(g, frame)?,
EffectKind::Glass(g) => glass::glass(g, frame, glass::RoundedRect::of(n))?,
_ => continue,
};
match out.as_mut() {
+541 -46
View File
@@ -16,15 +16,17 @@
//! 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).
//! 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;
use figcraft_geom::Affine;
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;
@@ -33,8 +35,9 @@ 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. `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]) {
/// 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 {
@@ -75,6 +78,9 @@ fn edt_1d(f: &[f64], d: &mut [f64], v: &mut [usize], z: &mut [f64]) {
}
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];
}
}
}
@@ -92,7 +98,7 @@ pub fn inside_distance(cov: &Pixmap) -> Vec<f32> {
*fy = *gv;
}
}
edt_1d(f.get(..h).unwrap_or(&[]), d.get_mut(..h).unwrap_or(&mut []), &mut v, &mut z);
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;
@@ -104,7 +110,7 @@ pub fn inside_distance(cov: &Pixmap) -> Vec<f32> {
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);
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);
}
@@ -113,6 +119,133 @@ pub fn inside_distance(cov: &Pixmap) -> Vec<f32> {
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) {
@@ -151,9 +284,15 @@ fn smooth_normals(dist: &[f32], w: usize, h: usize, r: usize) -> Vec<(f32, f32)>
}
}
}
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..2 {
for _ in 0..times {
box_pass(g, &mut tmp, w, h, r, true);
box_pass(&tmp, g, w, h, r, false);
}
@@ -303,9 +442,10 @@ fn sample(p: &Pixmap, x: f32, y: f32) -> [f32; 4] {
})
}
/// A shape's coverage (alpha), distance to its edge and smoothed inward normals over a window of
/// pixels whose top-left pixel is (`x0`, `y0`).
struct Fields {
/// 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,
@@ -313,36 +453,101 @@ struct Fields {
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)>>,
}
/// Bytes a field pixel holds: coverage, distance and a normal.
const FIELD_PX_BYTES: u64 = 1 + 4 + 8;
/// 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;
impl Fields {
/// 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) -> Fields {
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();
Fields { x0, y0, w, h, cov, dist, normals }
EdgeFields { x0, y0, w, h, cov, dist, normals, r, bends: Mutex::new(Vec::new()) }
}
/// Coverage (0–1), distance and inward normal at pixel (`x`, `y`); `None` outside the window.
fn at(&self, x: i64, y: i64) -> Option<(f32, f32, (f32, f32))> {
let (fx, fy) = (x.checked_sub(self.x0)?, y.checked_sub(self.y0)?);
if fx < 0 || fy < 0 || fx >= self.w as i64 || fy >= self.h as i64 {
/// 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 * self.w + fx as usize;
Some((f32::from(*self.cov.get(i)?) / 255.0, *self.dist.get(i)?, *self.normals.get(i)?))
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
@@ -351,9 +556,16 @@ pub fn glass_fields_computed() -> u64 {
FIELDS_COMPUTED.with(std::cell::Cell::get)
}
/// What a glass shape's fields 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.
/// 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,
@@ -376,7 +588,7 @@ const MAX_CACHED_BYTES: u64 = 128 << 20;
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<Fields>>>>;
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
@@ -440,20 +652,33 @@ fn shape_cache() -> &'static Mutex<ShapeCache> {
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 smoothed over `r` pixels, and the
/// shift from layer pixels to the fields' pixels.
/// 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) -> Option<(Arc<Fields>, (i64, i64))> {
if let Some(whole) = whole_shape(shape, xf, m, r) {
return Some(whole);
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((Arc::new(Fields::new(&cov, -i64::from(m), -i64::from(m), r)), (0, 0)))
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
@@ -484,9 +709,9 @@ fn own_frame(xf: Affine) -> Option<OwnFrame> {
Some(OwnFrame { shift, frac, xf: Affine::new([a, b, c, d, frac.0 as f64 / STEPS, frac.1 as f64 / STEPS]) })
}
/// [`shape_fields`] 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<Fields>, (i64, i64))> {
/// [`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();
@@ -518,26 +743,178 @@ fn whole_shape(shape: &Outline, xf: Affine, m: u32, r: usize) -> Option<(Arc<Fie
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(Fields::new(&win, origin.0, origin.1, r))
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.
pub fn glass(g: &Glass, frame: EffectFrame<'_>) -> Option<Pixmap> {
/// `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;
// Coverage with a margin, so wherever the layer is cut every distance the pixels read is
// exact: the bend's and the gradient's (within `depth` + `bevel`; the light lies on the bevel)
// and those the normals are smoothed from (2r + 1 further).
let m = (depth + bevel).ceil() as u32 + 4 * r as u32 + 4;
let (fields, shift) = shape_fields(shape, xf, w, h, m, r)?;
// 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();
@@ -555,7 +932,7 @@ pub fn glass(g: &Glass, frame: EffectFrame<'_>) -> Option<Pixmap> {
let dst = out.data_mut();
for y in 0..h as usize {
for x in 0..w as usize {
let Some((c, d, inward)) = fields.at(x as i64 - shift.0, y as i64 - shift.1) else { continue };
let Some((c, d, inward, toward)) = geometry.at(x as i64, y as i64) else { continue };
if c <= 0.0 {
continue;
}
@@ -565,7 +942,7 @@ pub fn glass(g: &Glass, frame: EffectFrame<'_>) -> Option<Pixmap> {
// 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: the edge's normal, turned by `splay` towards the
// 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;
@@ -575,7 +952,7 @@ pub fn glass(g: &Glass, frame: EffectFrame<'_>) -> Option<Pixmap> {
} else {
(0.0, 0.0)
};
let out_dir = (-inward.0 * (1.0 - splay) + away.0 * splay, -inward.1 * (1.0 - splay) + away.1 * splay);
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 };
@@ -671,6 +1048,124 @@ mod tests {
assert_eq!(spread[2], 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.
+27 -4
View File
@@ -121,32 +121,39 @@ fn glass_digests() {
#[test]
fn a_cut_through_the_pane_matches_the_whole_render() {
// The last look is deep (300 units), so the curve reaches far in from every cut.
// The last two looks are deep, so the curve reaches far in from every cut: 60 units (at 2× a
// core 122 px in, which takes the widest margin a core gets) and 300 (too big for a core).
let looks = [
wide_light(),
Glass::default(),
Glass { splay: 0.5, frost: 0.0, refraction: 0.0, ..Glass::default() },
// Splay with the bend and dispersion on: the dispersed samples follow the turned bend.
Glass { splay: 1.0, frost: 0.0, ..Glass::default() },
Glass { depth: 60.0, ..Glass::default() },
Glass { depth: 300.0, ..wide_light() },
];
// Rounded corners without the pane's fill: a fill's curved edge rasters 1–2 levels apart in a
// cut, and that is the fill, not the glass.
for radius in [0.0, 40.0] {
// Plain corners work out in closed form; smoothed ones (sharp ones too: any smoothing counts)
// from the coverage, the deepest look too big for a core and bending along its normals.
for (radius, smoothing) in [(0.0, 0.0), (40.0, 0.0), (0.0, 0.6), (40.0, 0.6)] {
for g in &looks {
let (mut d, p, pane) = pane_over_stripes(g.clone(), radius);
d.get_mut(pane).unwrap().corners.smoothing = smoothing;
if radius > 0.0 {
d.get_mut(pane).unwrap().fills.clear();
}
for (cut, diff) in cut_diffs(&d, p) {
assert_eq!(diff, (0, 0), "cut at {cut} px, radius {radius}, with {g:?}");
assert_eq!(diff, (0, 0), "cut at {cut} px, radius {radius}, smoothing {smoothing}, with {g:?}");
}
}
}
}
/// A `w` × `h` pane of glass `g` at (`x`, `y`) over stripes, alone on its page: (document, page,
/// pane). Each test gives its own size, so tests running at once don't share cached shapes.
/// pane). Each test gives its own size, so tests running at once don't share cached shapes. Its
/// corners are smoothed, so its fields come from its coverage and are kept (a plain rounded
/// rectangle's are worked out in closed form, with nothing to keep).
fn pane(g: Glass, x: f64, y: f64, w: f64, h: f64) -> (Document, NodeId, NodeId) {
let mut d = Document::new("glass");
let p = d.first_page().unwrap();
@@ -158,6 +165,7 @@ fn pane(g: Glass, x: f64, y: f64, w: f64, h: f64) -> (Document, NodeId, NodeId)
let n = d.get_mut(pane).unwrap();
n.transform = Affine::translate((x, y));
n.corners.radii = [12.0; 4];
n.corners.smoothing = 0.6;
n.effects = vec![Effect { kind: EffectKind::Glass(g), visible: true }];
(d, p, pane)
}
@@ -173,6 +181,14 @@ fn computed(f: impl FnOnce()) -> u64 {
crate::glass::glass_fields_computed() - before
}
/// How many glass shapes, and how many shapes' bend directions towards their core, `f` works out
/// on this thread.
fn computed_and_bent(f: impl FnOnce()) -> (u64, u64) {
let before = (crate::glass::glass_fields_computed(), crate::glass::glass_bends_computed());
f();
(crate::glass::glass_fields_computed() - before.0, crate::glass::glass_bends_computed() - before.1)
}
#[test]
fn kept_fields_render_the_same_pixels_as_fresh_ones() {
let (d, p, _) = pane(Glass::default(), 40.0, 30.0, 211.0, 137.0);
@@ -211,6 +227,13 @@ fn what_the_shape_depends_on_works_it_out_again_and_nothing_else_does() {
assert_eq!(computed(|| drop(render(&d, p, (0.0, 0.0), 1.0))), 1, "part of a pixel");
d.get_mut(pane).unwrap().width = 230.0;
assert_eq!(computed(|| drop(render(&d, p, (0.0, 0.0), 1.0))), 1, "shape");
let _ = render(&d, p, (0.0, 0.0), 1.0);
// A depth a unit more keeps the same margin: the kept shape is reused, and only the bend
// towards the core, which moves with the depth, is worked out again, once (a Depth slider
// dragged works out nothing else).
set(&mut d, Glass { depth: 21.0, ..Glass::default() });
assert_eq!(computed_and_bent(|| drop(render(&d, p, (0.0, 0.0), 1.0))), (0, 1), "depth, within the same margin");
assert_eq!(computed_and_bent(|| drop(render(&d, p, (0.0, 0.0), 1.0))), (0, 0), "the same depth again");
set(&mut d, Glass { depth: 70.0, ..Glass::default() });
assert_eq!(computed(|| drop(render(&d, p, (0.0, 0.0), 1.0))), 1, "depth");
}
File diff suppressed because one or more lines are too long