Files
figcraft/crates/render/src/glass_cut_tests.rs
T
8eb0b87646 M3: glass bends towards its core, and rounded rectangles work it out in closed form (#1889)
* M3: glass bends with the reference tool's thick-lens profile and splits its colours over 8 steps (#1885)

The backdrop bends most a 1-unit bevel in from the edge, falls off as the
3.5th power of the way out of the curve to nothing `depth` further in, and
moves a fifth as much on the bevel itself (it fell off as the square of the
way in from the very edge). Dispersion samples each channel at 8 steps along
the bend with a triangular window, red moving furthest and blue least (it was
three samples, blue furthest), as far apart as the reference tool's: at 100%
red and blue move 0.16 of the bend apart (`Glass::DISPERSION_SPREAD`).
`Glass::max_bend` bounds red's furthest step, so the backdrop read around a
pane covers every sample, and the field margin covers the bevel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M3: glass's bend stays within `max_bend`, checked on a single bright pixel too (#1885)

Review follow-up: the bend reaches `depth` × `refraction` a bevel in from
the edge, so `Glass::max_bend` drops its 0.999 and bounds it, as a test now
checks for several refractions. A single white pixel on black pins the
dispersion's triangular window, which a ramp and a flat backdrop can't tell
from one centred sample. The parity assessment records the bend, the
dispersion and how they were measured against the reference tool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M3: glass's light is a line on its bevel, and splay turns the bend towards the centre (#1885)

The light is a line on the 1-unit bevel added to the backdrop, as the
reference tool's: fully where the edge faces the light, none below 0.35 of
facing it and 0.6 as much on the opposite side, with a faint gradient across
the curve, 5% darker facing the light and lighter opposite. It was a glow
mixed towards white, as deep as `splay` made it, so `light_reach` goes.

`splay` turns the bend from along the edge's normal towards the way away
from the layer's centre, and no longer widens the light: files with a splay
above 0 look different. The direction to the centre is worked out in the
shape's own frame (`own_frame`, shared with the field cache), so cut renders
match the whole one byte for byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M3: glass bends towards its core, and rounded rectangles work it out in closed form (#1885)

The backdrop bends towards the glass's core, the shape shrunk by `depth`
and the bevel, instead of along the smoothed edge normal: round a sharp
corner the bend turns smoothly instead of meeting along the diagonal, and a
part thinner than twice that (a capsule, a letter's stroke) bends across
itself towards its own middle, pixels about as deep as any within 2 px. The
fields keep a bend direction beside the normal (an exact feature transform
from the core, box-blurred once), their key includes the core's depth, and
their margin covers the core.

Rectangles, frames, components, instances and sections whose corners aren't
smoothed, under a rotation, flip and even scale, work their glass out in
closed form (`AnalyticRect`): signed distance, the light's normal (corners
rounded to at least the bevel) and the bend direction (corners rounded to
the core) per pixel, in the layer's own frame, with nothing to rasterise,
transform or keep. A zoom frame draws in 40 ms instead of 54 and a resize
frame in 46 instead of 67 (`glass_render_timings`, release, 2×).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M3: cut renders of glass match with splay turning a dispersed bend (#1885)

Review follow-up: the cut-versus-whole test had splay only on looks without
refraction, so no turned bend reached the dispersion. A look with splay 1 and
the default bend and dispersion now renders the same however it is cut. The
parity assessment records the light on the bevel and splay's new meaning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M8: a glass too big to keep bends along its normals, and the core's fields take less memory (#1885)

Review follow-up: a shape that doesn't use the closed form and whose whole
window at the zoom, with the core's margin, would pass `MAX_SHAPE_PX`
(a large vector, text or smoothed rectangle, or a deep glass far zoomed in)
bends along its edge normals instead of working out a core, with the plain
margin. The choice depends on the shape and the zoom alone, so every band and
patch of a render agrees, and no render works out a core over more pixels
than a kept shape holds. The core's feature transform keeps `u32` indices
(16 bytes a pixel) and drops its maxima and core before making the
directions.

The edge normals are box-blurred twice again and the bend directions once,
as their docs say: the two were swapped.

The parity assessment records the bend towards the core and the closed form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M3: glass's flat inside skips the bend's curve, and full dispersion stays within `max_bend` (#1885)

Review follow-up: `bend_at` returns 0 straight away past `depth` + the
bevel, where the curve and the bevel's easing are both 0, instead of
working out a power of 0 for every pixel of a pane's flat inside. A test
moves one white pixel along a row under every bend of the profile at full
dispersion and checks that no channel ever reads it further out than
`Glass::max_bend`, which sizes the backdrop read around a pane.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M3: glass's light shows over a transparent backdrop, and its flat inside skips the light (#1885)

Review follow-up: the light was added to the backdrop's colour and then
clamped to the backdrop's alpha, so with nothing under the glass (an
export of the pane alone) the line on the bevel vanished, where the old
glow raised the alpha and showed. Now the alpha rises as far as the lit
colour needs (`lit`), and stays the backdrop's wherever the light fits
under it, so renders over an opaque backdrop don't change.

Past `depth` + the bevel the rim light, the gradient and the bend are all
0, so those pixels take one sample of the backdrop and skip the light's
direction, facing and splay work, as the old glow's `reach` early-out did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M8: a kept glass shape works out only its bend again when the depth changes, and a core gets a wider margin (#1885)

Review follow-up. The field cache was keyed on the core's exact depth, so
every step of the Depth slider missed it and worked the whole shape out
again over a window twice as wide: coverage, distances, normals, the
feature transform and the blur, every frame, for every shape that isn't a
plain rounded rectangle. Only the bend directions depend on the depth. The
kept fields (`EdgeFields`: coverage, distances, normals) are now keyed
without it, and hold the bend directions for the last two depths asked
for beside them, so a new depth works out only those (`EdgeFields::bends`).
Two glass layers of one shape at different depths each keep theirs.

The margin a core takes was `reach` + 2 px past the plain one, but the
nearest core pixel can be √2 × `reach` away round a sharp corner, past
what that covers once `reach` is over about 91 px. It is now
1.5 × `reach` + 2 px past the plain margin. The cut-through-the-pane test
gets a 60-unit-deep look (a core 122 px in at 2×).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Glass: splay's doc says splay 0 bends towards the core

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Oleksii Sukholita <oleksii.sukholita@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-05 22:59:27 +01:00

249 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Glass renders the same however a render is cut: a band or a canvas patch whose edge runs
//! through a pane shows exactly the pixels of the whole render.
use figcraft_color::{Color, Paint};
use figcraft_doc::{Document, Effect, EffectKind, Glass, NodeId, NodeKind};
use figcraft_geom::Affine;
use tiny_skia::Pixmap;
use crate::{Options, View, render_view_single};
/// Stripes under a 600 × 360 pane at (300, 220) with corner `radius` and `g`: (document, page, pane).
fn pane_over_stripes(g: Glass, radius: f64) -> (Document, NodeId, NodeId) {
let mut d = Document::new("glass");
let p = d.first_page().unwrap();
let colours = ["E03030", "2040E0", "20C060", "F0C830"].map(|h| Color::from_hex(h).unwrap());
for i in 0..24u32 {
let s = d.create(p, NodeKind::Rectangle, "Stripe", f64::from(i) * 50.0, 0.0, 50.0, 800.0).unwrap();
d.get_mut(s).unwrap().fills = vec![Paint::solid(colours[i as usize % 4])];
}
let pane = d.create(p, NodeKind::Rectangle, "Glass", 0.0, 0.0, 600.0, 360.0).unwrap();
let n = d.get_mut(pane).unwrap();
n.transform = Affine::translate((300.0, 220.0));
n.corners.radii = [radius; 4];
n.fills = vec![Paint::solid(Color { a: 0.1, ..Color::WHITE })];
n.effects = vec![Effect { kind: EffectKind::Glass(g), visible: true }];
(d, p, pane)
}
/// Rows `y0..` of `full` (as wide as `part`) equal `part`, byte for byte: the largest channel
/// difference and how many bytes differ.
fn diff_rows(full: &Pixmap, y0: u32, part: &Pixmap) -> (u8, usize) {
let row = full.width() as usize * 4;
let a = &full.data()[y0 as usize * row..][..part.data().len()];
a.iter().zip(part.data()).fold((0, 0), |(m, n), (x, y)| (m.max(x.abs_diff(*y)), n + usize::from(x != y)))
}
/// A strong, wide light and nothing that blurs or bends: the light reaches furthest in, and the
/// backdrop effects read nothing past the layer, so a cut through the pane gets no margin.
fn wide_light() -> Glass {
Glass { splay: 1.0, frost: 0.0, refraction: 0.0, light_intensity: 1.0, ..Glass::default() }
}
/// The largest channel difference and bytes differing between the whole render of `d` and renders
/// cut 50 to 80 px inside the pane's top edge (y = 440 px), as bands and canvas patches cut it:
/// rendered with the backdrop reach as a margin above and below, keeping 400 rows from the cut.
fn cut_diffs(d: &Document, p: NodeId) -> Vec<(u32, (u8, usize))> {
let opts = Options::default();
let full = render_view_single(d, p, &View { origin: (0.0, 0.0), scale: 2.0, width: 2400, height: 1600 }, &opts).unwrap();
let margin = crate::backdrop_reach(crate::max_backdrop_blur(d), 2.0).ceil() as u32 + 1;
[490u32, 505, 520]
.into_iter()
.map(|cut| {
let top = cut - margin;
let v = View { origin: (0.0, f64::from(top) / 2.0), scale: 2.0, width: 2400, height: 400 + 2 * margin };
let part = render_view_single(d, p, &v, &opts).unwrap();
let kept = part.clone_rect(tiny_skia::IntRect::from_xywh(0, margin as i32, 2400, 400).unwrap()).unwrap();
(cut, diff_rows(&full, cut, &kept))
})
.collect()
}
/// A deep pane over black, lit from the left, at 2× (its 1-unit bevel 2 px wide): across its middle
/// row the left rim catches the light, the right rim 0.6 as much, and in between only the faint
/// gradient across the curve shows (black can't darken, so the lit half stays black), changing
/// smoothly, with no ring.
#[test]
fn the_light_is_a_line_on_the_bevel() {
let mut d = Document::new("glass");
let p = d.first_page().unwrap();
let back = d.create(p, NodeKind::Rectangle, "Back", 0.0, 0.0, 700.0, 700.0).unwrap();
d.get_mut(back).unwrap().fills = vec![Paint::solid(Color::BLACK)];
let pane = d.create(p, NodeKind::Rectangle, "Glass", 50.0, 50.0, 600.0, 600.0).unwrap();
let g = Glass { depth: 300.0, light_angle: -90.0, light_intensity: 1.0, frost: 0.0, refraction: 0.0, ..Glass::default() };
let n = d.get_mut(pane).unwrap();
n.fills.clear();
n.effects = vec![Effect { kind: EffectKind::Glass(g), visible: true }];
let full = render_view_single(&d, p, &View { origin: (0.0, 0.0), scale: 2.0, width: 1400, height: 1400 }, &Options::default()).unwrap();
let row = &full.data()[700 * 1400 * 4..][..1400 * 4];
let red: Vec<u8> = row.chunks(4).map(|px| px[0]).collect();
// The pane covers pixels 100..1300 of the row.
let (left, right) = (red[100], red[1299]);
assert!(left > 230, "the left rim catches the light, less the gradient: {left}");
assert!((140..=175).contains(&right), "the right rim 0.6 as much, plus the gradient: {right}");
assert!(red[106] <= 14 && red[1293] <= 14, "3 units in, the gradient only: {} {}", red[106], red[1293]);
let step = red[104..1296].windows(2).map(|w| w[0].abs_diff(w[1])).max().unwrap();
assert!(step <= 2, "the gradient jumps {step} levels between neighbouring pixels");
}
/// Digests of glass renders over a spread of settings, scales and corners: compare two builds'
/// output with `cargo test --release -p figcraft-render --lib glass_digests -- --ignored --nocapture`.
#[test]
#[ignore = "prints digests to compare builds"]
fn glass_digests() {
use std::hash::{DefaultHasher, Hash, Hasher};
let looks = [
Glass::default(),
wide_light(),
Glass { splay: 0.5, frost: 0.0, refraction: 0.0, ..Glass::default() },
Glass { refraction: 1.0, dispersion: 1.0, frost: 10.0, depth: 60.0, light_intensity: 0.0, ..Glass::default() },
Glass { depth: 1.0, splay: 0.3, light_angle: 120.0, ..Glass::default() },
];
let mut all = DefaultHasher::new();
for (i, g) in looks.into_iter().enumerate() {
for radius in [0.0, 40.0] {
let (d, p, _) = pane_over_stripes(g.clone(), radius);
for scale in [1.0, 1.37, 2.0] {
let v = View { origin: (100.0, 100.0), scale, width: (1000.0 * scale) as u32, height: (700.0 * scale) as u32 };
// Rendered twice: the second reads the whole shape the first asked for.
let first = render_view_single(&d, p, &v, &Options::default()).unwrap();
let pix = render_view_single(&d, p, &v, &Options::default()).unwrap();
assert!(first.data() == pix.data(), "glass {i} radius {radius} scale {scale}: the kept shape renders differently");
let mut h = DefaultHasher::new();
pix.data().hash(&mut h);
println!("glass {i} radius {radius} scale {scale}: {:016x}", h.finish());
pix.data().hash(&mut all);
}
}
}
println!("all: {:016x}", all.finish());
}
#[test]
fn a_cut_through_the_pane_matches_the_whole_render() {
// 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.
// 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}, 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. 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();
for i in 0..8u32 {
let s = d.create(p, NodeKind::Rectangle, "Stripe", f64::from(i) * 40.0, 0.0, 20.0, 400.0).unwrap();
d.get_mut(s).unwrap().fills = vec![Paint::solid(Color::from_hex("2040E0").unwrap())];
}
let pane = d.create(p, NodeKind::Rectangle, "Glass", 0.0, 0.0, w, h).unwrap();
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)
}
fn render(d: &Document, p: NodeId, origin: (f64, f64), scale: f64) -> Pixmap {
render_view_single(d, p, &View { origin, scale, width: 400, height: 300 }, &Options::default()).unwrap()
}
/// How many glass shapes `f` works out on this thread.
fn computed(f: impl FnOnce()) -> u64 {
let before = crate::glass::glass_fields_computed();
f();
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);
let mut shots = Vec::new();
// The first render works out the part it shows, the next the whole shape (and keeps it), the
// ones after reuse it.
for want in [1, 1, 0] {
assert_eq!(computed(|| shots.push(render(&d, p, (0.0, 0.0), 1.5))), want, "render {}", shots.len() + 1);
}
assert!(shots.windows(2).all(|w| w[0].data() == w[1].data()));
}
#[test]
fn what_the_shape_depends_on_works_it_out_again_and_nothing_else_does() {
let (mut d, p, pane) = pane(Glass::default(), 40.0, 30.0, 223.0, 149.0);
let set = |d: &mut Document, g: Glass| d.get_mut(pane).unwrap().effects = vec![Effect { kind: EffectKind::Glass(g), visible: true }];
// Asked for twice: kept.
let _ = render(&d, p, (0.0, 0.0), 1.0);
let _ = render(&d, p, (0.0, 0.0), 1.0);
// Reused: the look apart from depth, what lies under the glass, panning by whole pixels and
// moving the pane by whole pixels.
let reused = computed(|| {
set(&mut d, Glass { light_angle: 30.0, refraction: 0.2, dispersion: 1.0, frost: 9.0, ..Glass::default() });
let _ = render(&d, p, (0.0, 0.0), 1.0);
let stripe = d.node(p).unwrap().children[0];
d.get_mut(stripe).unwrap().transform = Affine::translate((5.0, 0.0));
let _ = render(&d, p, (0.0, 0.0), 1.0);
let _ = render(&d, p, (7.0, -3.0), 1.0);
d.get_mut(pane).unwrap().transform = Affine::translate((52.0, 31.0));
let _ = render(&d, p, (0.0, 0.0), 1.0);
});
assert_eq!(reused, 0);
// Worked out again: another zoom, a move by part of a pixel, another shape, a much deeper edge.
assert_eq!(computed(|| drop(render(&d, p, (0.0, 0.0), 1.25))), 1, "zoom");
d.get_mut(pane).unwrap().transform = Affine::translate((52.5, 31.0));
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");
}
#[test]
fn a_pane_too_big_to_keep_still_renders() {
// 400 × 300 units at 8x: far past what is kept whole, so each render works out its part.
let (d, p, _) = pane(Glass { frost: 0.0, ..Glass::default() }, 0.0, 0.0, 400.0, 300.0);
let shown = render(&d, p, (10.0, 10.0), 8.0);
assert_eq!(computed(|| drop(render(&d, p, (10.0, 10.0), 8.0))), 1);
assert!(shown.data().chunks_exact(4).any(|px| px[2] > 200), "the stripes show through");
}