mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-26 11:26:50 -06:00
Where the 512 px cap held the light short of half a level, it dropped to 0 in one pixel: a ring 512 px inside a deep pane (46 levels at depth 300, splay 1, 200%). The falloff now reaches 0 at the reach. Where the cap doesn't bite this moves the light by at most half a level. Cut tests now also cover rounded corners and a look under the cap. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
136 lines
6.7 KiB
Rust
136 lines
6.7 KiB
Rust
//! 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 pane deeper than the light may reach, over black and lit from the left: across its middle row
|
||
/// each pixel is the light alone. It fades out smoothly where it stops, with no ring.
|
||
#[test]
|
||
fn the_light_fades_out_where_it_stops() {
|
||
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, splay: 1.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]).skip(101).take(598).collect();
|
||
let step = red.windows(2).map(|w| w[0].abs_diff(w[1])).max().unwrap();
|
||
assert!(step <= 2, "the light jumps {step} levels between neighbouring pixels");
|
||
assert!(red[0] > 200 && red[597] < red[0], "lit at the edge, fading in: {} … {}", red[0], red[597]);
|
||
}
|
||
|
||
/// 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 };
|
||
let pix = render_view_single(&d, p, &v, &Options::default()).unwrap();
|
||
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 look is deep enough that the light is held to `MAX_LIGHT_PX`.
|
||
let looks = [
|
||
wide_light(),
|
||
Glass::default(),
|
||
Glass { splay: 0.5, frost: 0.0, refraction: 0.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] {
|
||
for g in &looks {
|
||
let (mut d, p, pane) = pane_over_stripes(g.clone(), radius);
|
||
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:?}");
|
||
}
|
||
}
|
||
}
|
||
}
|