mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-08 11:51:20 +00:00
Merge upstream/main into fix/ui-script-fallbacks-2014
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+3
-1
File diff suppressed because one or more lines are too long
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user