Merge main into #1496

This commit is contained in:
bflatastic
2026-10-29 23:25:44 +07:00
29 changed files with 1088 additions and 142 deletions
+80 -32
View File
@@ -594,6 +594,8 @@ fn draw_layer(ctx: &Ctx, pix: &mut Pixmap, n: &Node, parent_xf: Affine, depth: u
let (lx, ly) = (x0 as i32, y0 as i32);
let lw = w.min(16_384.0) as u32;
let lh = h.min(16_384.0) as u32;
// Nested layers hold their pixmaps while the ones inside them draw: within one budget.
let Some(_held) = crate::layer_budget::Held::take(lw, lh, 1) else { return };
let Some(mut layer) = Pixmap::new(lw, lh) else { return };
let lxf = Affine::translate((-f64::from(lx), -f64::from(ly))) * xf;
let shape = node_outline(ctx.doc, n);
@@ -894,52 +896,98 @@ fn draw_children(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize
}
}
fn draw_masked(ctx: &Ctx, pix: &mut Pixmap, mask_id: NodeId, content: &[NodeId], xf: Affine, depth: usize) {
let (w, h) = (pix.width(), pix.height());
let Some(mut mask_px) = Pixmap::new(w, h) else { return };
let mask_type = ctx.doc.node(mask_id).map(|m| m.mask_type).unwrap_or_default();
// Draw the mask node as if it weren't a mask (a vector mask: its outline, fully opaque).
if let Some(m) = ctx.doc.node(mask_id) {
let mut m2 = m.clone();
m2.is_mask = false;
let mxf = xf * m2.transform;
if mask_type == figcraft_doc::MaskType::Vector {
if let Some((outline, rule)) = subtree_outline(ctx.doc, &m2, depth + 1)
&& let Some(path) = ts_path(&outline)
{
let mut paint = tiny_skia::Paint::default();
paint.set_color_rgba8(255, 255, 255, 255);
paint.anti_alias = true;
mask_px.fill_path(&path, &paint, rule, ts_transform(mxf), None);
}
} else if needs_layer(&m2) {
let paint = PixmapPaint { opacity: m2.opacity, ..Default::default() };
let mut tmp = Pixmap::new(w, h);
if let Some(tmp) = tmp.as_mut() {
draw_content(ctx, tmp, &m2, mxf, depth + 1, None);
mask_px.draw_pixmap(0, 0, tmp.as_ref(), &paint, Transform::identity(), None);
}
} else {
draw_content(ctx, &mut mask_px, &m2, mxf, depth + 1, None);
}
#[cfg(test)]
thread_local! {
/// Tests: mask groups draw over the whole pixmap, as they did before [`masked_area`].
pub(crate) static WHOLE_MASK_AREA: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// The pixels of `pix` a mask group (mask `mask_id`, then `content`, drawn at `xf`) can paint,
/// rounded out to whole pixels: where the mask or the content reach, effects included (#1061).
fn masked_area(ctx: &Ctx, pix: &Pixmap, mask_id: NodeId, content: &[NodeId], xf: Affine) -> Option<IntRect> {
let size = (pix.width(), pix.height());
let whole = Rect::new(0.0, 0.0, f64::from(size.0), f64::from(size.1));
#[cfg(test)]
if WHOLE_MASK_AREA.with(std::cell::Cell::get) {
return int_rect(whole, size);
}
let reach = |id: &NodeId| {
let n = ctx.bounds.node(ctx.doc, *id)?;
let (local, levels) = bounds_within(ctx.doc, n, 0, ctx.lod.then_some(ctx.scale), Some(ctx.bounds));
// Bounds the nesting cap cut short could miss a layer: anywhere then.
if levels.is_none() {
return Some(whole);
}
// A pixel more for antialiasing.
let m = (effect_margin(n) * ctx.scale).min(MAX_EFFECT_MARGIN_PX) + 1.0;
Some(device_rect(xf * n.transform, local).inflate(m, m))
};
let area = std::iter::once(&mask_id).chain(content).filter_map(reach).reduce(|a, b| a.union(b))?;
int_rect(area, size)
}
/// Mask `m` drawn as if it weren't a mask into a `w` × `h` pixmap at `xf` (a vector mask: its
/// outline, fully opaque).
fn mask_pixels(ctx: &Ctx, m: &Node, (w, h): (u32, u32), xf: Affine, depth: usize) -> Option<Pixmap> {
let mut mask_px = Pixmap::new(w, h)?;
let mut m2 = m.clone();
m2.is_mask = false;
let mxf = xf * m2.transform;
if m.mask_type == figcraft_doc::MaskType::Vector {
if let Some((outline, rule)) = subtree_outline(ctx.doc, &m2, depth + 1)
&& let Some(path) = ts_path(&outline)
{
let mut paint = tiny_skia::Paint::default();
paint.set_color_rgba8(255, 255, 255, 255);
paint.anti_alias = true;
mask_px.fill_path(&path, &paint, rule, ts_transform(mxf), None);
}
} else if needs_layer(&m2) {
let paint = PixmapPaint { opacity: m2.opacity, ..Default::default() };
if let Some(mut tmp) = Pixmap::new(w, h) {
draw_content(ctx, &mut tmp, &m2, mxf, depth + 1, None);
mask_px.draw_pixmap(0, 0, tmp.as_ref(), &paint, Transform::identity(), None);
}
} else {
draw_content(ctx, &mut mask_px, &m2, mxf, depth + 1, None);
}
Some(mask_px)
}
fn draw_masked(ctx: &Ctx, pix: &mut Pixmap, mask_id: NodeId, content: &[NodeId], xf: Affine, depth: usize) {
// Only over the pixels the group can paint, not the whole pixmap.
let Some(area) = masked_area(ctx, pix, mask_id, content, xf) else { return };
let (ax, ay, w, h) = (area.x(), area.y(), area.width(), area.height());
// The mask (and its own layer), the content, and the pixels it goes onto.
let Some(_held) = crate::layer_budget::Held::take(w, h, 4) else { return };
let axf = Affine::translate((-f64::from(ax), -f64::from(ay))) * xf;
let m = ctx.doc.node(mask_id);
let mask_px = match m {
Some(m) => mask_pixels(ctx, m, (w, h), axf, depth),
None => Pixmap::new(w, h),
};
let Some(mask_px) = mask_px else { return };
let Some(mut layer) = Pixmap::new(w, h) else { return };
let kind = if mask_type == figcraft_doc::MaskType::Luminance { MaskType::Luminance } else { MaskType::Alpha };
let kind = if m.is_some_and(|m| m.mask_type == figcraft_doc::MaskType::Luminance) { MaskType::Luminance } else { MaskType::Alpha };
let mask = Mask::from_pixmap(mask_px.as_ref(), kind);
{
let under = Under { clip: Some(&mask), ..Under::new(pix, (0, 0), ctx.under) };
let under = Under { clip: Some(&mask), ..Under::new(pix, (ax, ay), ctx.under) };
let sub = Ctx { under: Some(&under), ..*ctx };
for c in content {
draw_node(&sub, &mut layer, *c, xf, depth + 1);
draw_node(&sub, &mut layer, *c, axf, depth + 1);
}
}
pix.draw_pixmap(0, 0, layer.as_ref(), &PixmapPaint::default(), Transform::identity(), Some(&mask));
// A mask applies to a pixmap of its own size: through it onto a copy of the area, put back.
let Some(mut onto) = pix.clone_rect(area) else { return };
onto.draw_pixmap(0, 0, layer.as_ref(), &PixmapPaint::default(), Transform::identity(), Some(&mask));
backdrop::replace(pix, (ax, ay), &onto, None);
}
fn draw_children_clipped(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize, clip: Option<&BezPath>) {
let (pw, ph) = (pix.width(), pix.height());
let dev = device_rect(xf, n.local_rect());
let Some(ir) = int_rect(dev, (pw, ph)) else { return };
let Some(_held) = crate::layer_budget::Held::take(ir.width(), ir.height(), 1) else { return };
let (lx, ly) = (ir.x(), ir.y());
// The layer starts as what lies under it and goes back in its place through the clip:
// clipping cuts the children off without isolating them, so their blend modes reach the
+59
View File
@@ -0,0 +1,59 @@
//! One budget for the isolated layers a render holds at once (#1061): each nested layer with
//! effects, opacity or a mask keeps its pixmaps while the layers inside it draw, so a deep nest of
//! large ones could ask for more memory than there is. A layer past the budget is left out (with
//! a warning, once) instead.
use std::cell::Cell;
use std::sync::atomic::{AtomicBool, Ordering};
/// Bytes the isolated layers being drawn on one thread may hold at once: two of the largest layer
/// there is (16 384 px square).
pub const BUDGET: u64 = 2 << 30;
thread_local! {
/// Bytes held now on this thread, the most held in this thread's life, and the budget
/// (tests lower it).
static HELD: Cell<u64> = const { Cell::new(0) };
static PEAK: Cell<u64> = const { Cell::new(0) };
static LIMIT: Cell<u64> = const { Cell::new(BUDGET) };
}
static WARNED: AtomicBool = AtomicBool::new(false);
/// A share of the budget, given back when dropped (on unwinding too).
pub(crate) struct Held(u64);
impl Held {
/// Room for `count` RGBA pixmaps of `w` × `h` pixels, if the budget has it.
pub(crate) fn take(w: u32, h: u32, count: u64) -> Option<Held> {
let bytes = u64::from(w) * u64::from(h) * 4 * count;
let held = HELD.with(Cell::get);
let total = held.saturating_add(bytes);
if total > LIMIT.with(Cell::get) {
if !WARNED.swap(true, Ordering::Relaxed) {
log::warn!("render: nested layers need more than {} MB at once; the innermost are left out", LIMIT.with(Cell::get) >> 20);
}
return None;
}
HELD.with(|c| c.set(total));
PEAK.with(|c| c.set(c.get().max(total)));
Some(Held(bytes))
}
}
impl Drop for Held {
fn drop(&mut self) {
HELD.with(|c| c.set(c.get().saturating_sub(self.0)));
}
}
/// Tests: run `f` with a budget of `limit` bytes on this thread; returns what it gave and the most
/// it held at once.
#[cfg(test)]
pub(crate) fn within<T>(limit: u64, f: impl FnOnce() -> T) -> (T, u64) {
let was = LIMIT.with(|c| c.replace(limit));
PEAK.with(|c| c.set(HELD.with(Cell::get)));
let out = f();
LIMIT.with(|c| c.set(was));
(out, PEAK.with(Cell::get))
}
+3
View File
@@ -16,6 +16,7 @@ pub mod glass;
pub mod grain;
pub mod image_profile;
pub mod images;
mod layer_budget;
pub mod lod;
pub mod paint;
pub mod pattern;
@@ -524,6 +525,8 @@ mod lod_tests;
#[cfg(test)]
mod magnified_image_tests;
#[cfg(test)]
mod mask_bounds_tests;
#[cfg(test)]
mod paint_behaviour_tests;
#[cfg(test)]
mod progressive_tests;
+141
View File
@@ -0,0 +1,141 @@
//! Mask groups draw only over the pixels they can paint, and nested isolated layers share one
//! memory budget (#1061). Each scene renders the same with a mask's pixmaps cut to its area as with
//! the whole view (how masks drew before).
use figcraft_color::{Color, Paint};
use figcraft_doc::{Document, Effect, EffectKind, MaskType, NodeId, NodeKind, Shadow};
use figcraft_geom::Affine;
use tiny_skia::Pixmap;
use crate::draw::WHOLE_MASK_AREA;
use crate::layer_budget::{BUDGET, within};
use crate::{Options, View, render_view_single};
const ELLIPSE: NodeKind = NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 };
fn shape(d: &mut Document, parent: NodeId, kind: NodeKind, (x, y, w, h): (f64, f64, f64, f64), hex: &str) -> NodeId {
let id = d.create(parent, kind, "Shape", x, y, w, h).unwrap();
d.get_mut(id).unwrap().fills = vec![Paint::solid(Color::from_hex(hex).unwrap())];
id
}
/// A group under `parent` whose first layer, `mask`, masks the rest.
fn mask_group(d: &mut Document, parent: NodeId, kind: NodeKind, rect: (f64, f64, f64, f64), mask_type: MaskType) -> (NodeId, NodeId) {
let g = d.create(parent, NodeKind::Group, "Mask group", 0.0, 0.0, 1.0, 1.0).unwrap();
let m = shape(d, g, kind, rect, "808080");
let n = d.get_mut(m).unwrap();
n.is_mask = true;
n.mask_type = mask_type;
(g, m)
}
fn turn(d: &mut Document, id: NodeId, degrees: f64) {
let n = d.get_mut(id).unwrap();
let (cx, cy) = (n.width / 2.0, n.height / 2.0);
n.transform = n.transform * Affine::translate((cx, cy)) * Affine::rotate(degrees.to_radians()) * Affine::translate((-cx, -cy));
}
/// Nested masks (alpha, luminance, vector, a mask with opacity), rotated masks and content,
/// content with opacity, a drop shadow and a background blur, and a mask group running off the
/// right and bottom of an 800 × 600 view.
fn scene() -> (Document, NodeId) {
let mut d = Document::new("masks");
let p = d.first_page().unwrap();
shape(&mut d, p, NodeKind::Rectangle, (0.0, 0.0, 800.0, 600.0), "EEEEEE");
let (g1, _) = mask_group(&mut d, p, ELLIPSE, (100.0, 80.0, 320.0, 220.0), MaskType::Alpha);
let c1 = shape(&mut d, g1, NodeKind::Rectangle, (60.0, 50.0, 400.0, 300.0), "E03030");
let n = d.get_mut(c1).unwrap();
n.opacity = 0.6;
n.effects = vec![Effect {
kind: EffectKind::DropShadow(Shadow { color: Color::BLACK, offset: (8.0, 12.0), radius: 10.0, ..Shadow::default() }),
visible: true,
}];
let c2 = shape(&mut d, g1, NodeKind::Rectangle, (150.0, 120.0, 160.0, 90.0), "2040E0");
turn(&mut d, c2, 30.0);
let pane = shape(&mut d, g1, NodeKind::Rectangle, (120.0, 100.0, 140.0, 80.0), "FFFFFF33");
d.get_mut(pane).unwrap().effects = vec![Effect { kind: EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(6.0)), visible: true }];
// Nested: a turned luminance mask inside the first group.
let (g2, m2) = mask_group(&mut d, g1, NodeKind::Rectangle, (200.0, 140.0, 150.0, 110.0), MaskType::Luminance);
turn(&mut d, m2, 20.0);
let c3 = shape(&mut d, g2, ELLIPSE, (180.0, 120.0, 200.0, 160.0), "20C060");
d.get_mut(c3).unwrap().opacity = 0.8;
// A vector mask with opacity running off the view.
let (g3, m3) = mask_group(&mut d, p, NodeKind::Star { count: 5, inner_ratio: 0.5 }, (640.0, 420.0, 300.0, 300.0), MaskType::Vector);
d.get_mut(m3).unwrap().opacity = 0.5;
let c4 = shape(&mut d, g3, NodeKind::Rectangle, (560.0, 380.0, 500.0, 400.0), "F0A020");
turn(&mut d, c4, -15.0);
// An alpha mask with opacity: its own layer.
let (g4, m4) = mask_group(&mut d, p, NodeKind::Rectangle, (20.0, 420.0, 200.0, 150.0), MaskType::Alpha);
d.get_mut(m4).unwrap().opacity = 0.4;
shape(&mut d, g4, ELLIPSE, (0.0, 400.0, 260.0, 190.0), "8020C0");
(d, p)
}
fn render(d: &Document, page: NodeId, view: &View, whole: bool) -> Pixmap {
WHOLE_MASK_AREA.with(|c| c.set(whole));
let pix = render_view_single(d, page, view, &Options { background: Some(Color::WHITE), ..Default::default() });
WHOLE_MASK_AREA.with(|c| c.set(false));
pix.unwrap()
}
#[test]
fn masks_cut_to_their_area_draw_the_same() {
let (d, page) = scene();
let views = [
View { origin: (0.0, 0.0), scale: 1.0, width: 800, height: 600 },
// Fractional origin and zoom: the area's edges fall between pixels.
View { origin: (-37.3, 12.6), scale: 1.7, width: 900, height: 700 },
View { origin: (300.0, 200.0), scale: 0.45, width: 320, height: 240 },
// Only part of each group in view.
View { origin: (250.0, 250.0), scale: 2.0, width: 500, height: 400 },
];
for v in &views {
let (before, after) = (render(&d, page, v, true), render(&d, page, v, false));
let differ = before.data().chunks(4).zip(after.data().chunks(4)).filter(|(a, b)| a != b).count();
assert_eq!(differ, 0, "{v:?}: {differ} pixels differ");
let colours: std::collections::BTreeSet<&[u8]> = after.data().chunks(4).collect();
assert!(colours.len() > 3, "{v:?}: the masks show their content");
}
}
#[test]
fn a_small_mask_holds_a_small_area() {
let mut d = Document::new("small mask");
let p = d.first_page().unwrap();
let (g, _) = mask_group(&mut d, p, ELLIPSE, (100.0, 100.0, 40.0, 40.0), MaskType::Alpha);
shape(&mut d, g, NodeKind::Rectangle, (90.0, 90.0, 60.0, 60.0), "E03030");
let v = View { origin: (0.0, 0.0), scale: 1.0, width: 3000, height: 2000 };
let (before, whole) = within(BUDGET, || render(&d, p, &v, true));
let (after, cut) = within(BUDGET, || render(&d, p, &v, false));
assert_eq!(before.data(), after.data());
// Four 3000 × 2000 pixmaps before (96 MB), four 62 × 62 now (61.5 kB).
assert_eq!(whole, 4 * 4 * 3000 * 2000);
assert_eq!(cut, 4 * 4 * 62 * 62, "the mask's 40 × 40 plus its content, rounded out and a pixel round");
}
#[test]
fn a_deep_nest_of_isolated_layers_stays_within_the_budget() {
let mut d = Document::new("deep");
let p = d.first_page().unwrap();
let mut parent = p;
// 64 levels, each a full-view group at half opacity or a mask group, every one isolating.
for i in 0..64 {
parent = if i % 2 == 0 {
let g = d.create(parent, NodeKind::Group, "Group", 0.0, 0.0, 1.0, 1.0).unwrap();
d.get_mut(g).unwrap().opacity = 0.5;
g
} else {
mask_group(&mut d, parent, NodeKind::Rectangle, (0.0, 0.0, 1000.0, 1000.0), MaskType::Alpha).0
};
shape(&mut d, parent, NodeKind::Rectangle, (0.0, 0.0, 1000.0, 1000.0), "2040E0");
}
let v = View { origin: (0.0, 0.0), scale: 1.0, width: 500, height: 500 };
// Unbounded, the nest holds all its levels at once.
let (_, all) = within(u64::MAX, || render(&d, p, &v, false));
assert_eq!(all, 32 * 4 * 500 * 500 * 5, "32 groups of one layer, 32 mask groups of four");
let limit = 16 << 20;
let (pix, peak) = within(limit, || render(&d, p, &v, false));
assert!(peak <= limit, "held {peak} bytes at once");
// The outer levels still draw (the innermost, past the budget, are left out).
assert_ne!(&pix.data()[..4], &[255, 255, 255, 255]);
}