From 96eed15993b1bbaf5bf31ead089effbdda1f430c Mon Sep 17 00:00:00 2001 From: BFlat Date: Thu, 29 Oct 2026 21:33:46 +0700 Subject: [PATCH] M8: mask groups draw over their own area, nested layers share one memory budget (#1061) (#1535) A mask group's pixmaps cover only where its mask or content can paint within the view, rounded out to whole pixels, drawing the same pixels (a 40x40 mask on a 3000x2000 view: 96 MB -> 61.5 kB). Isolated layers share a 2 GiB per-thread budget; a layer past it is left out with a warning. --- ROADMAP.md | 3 +- crates/render/src/draw.rs | 112 ++++++++++++++------ crates/render/src/layer_budget.rs | 59 +++++++++++ crates/render/src/lib.rs | 3 + crates/render/src/mask_bounds_tests.rs | 141 +++++++++++++++++++++++++ docs/roadmap.md | 3 +- 6 files changed, 287 insertions(+), 34 deletions(-) create mode 100644 crates/render/src/layer_budget.rs create mode 100644 crates/render/src/mask_bounds_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 9622e3d7..5e6eea88 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M8, #1061: a mask group's pixmaps (the mask, its own layer, the content and the pixels it goes onto) cover only where the mask or its content can paint (effects included) within the view, rounded out to whole pixels, instead of the whole view: a 40 × 40 mask on a 3000 × 2000 view held 96 MB, now 61.5 kB, drawing the same pixels. Isolated layers (effects, opacity, masks, clipping frames) also share one budget of 2 GiB held at once per render thread (`figcraft_render::layer_budget`); a layer past it is left out with a warning, so 64 nested full-view layers no longer ask for tens of GB. Tests: `mask_bounds_tests`. Numbers unchanged | | 2026-10-29 | minor | M2: components can't contain their own instances (#1084). Any edit that would make a component hold an instance of itself, directly or through other components (an instance-swap property's current value included), is refused with the loop named: creating, placing, swapping, switching variants, moving, pasting and instance-swap values. Each variant counts as a component of its own, so a variant holding a sibling variant's instance is fine unless that one leads back to it (`components::check_component_cycles`, beside the instance structure check); a dropped instance lands on the page instead. A file holding such a loop (opened or imported) has one instance per loop detached, with a note; a loop merged in a live session is reported, and a rebuild never expands an instance inside a copy of itself, so its copies stop growing (12 rebuilds of a two-component loop: 25 → 113 layers before, 25 throughout now). Tests: `component_cycle_tests`. Numbers unchanged | | 2026-10-29 | minor | Which copy is which (M8, #995): `edit.duplicate` with `map: true` also returns `map`, each original layer's id, descendants included (nested frames, groups, instances and their sublayers), paired with its copy's id, so a batch can edit inside a copy through a step reference (`"field": "map/6"`) instead of inspecting it. Without `map` the result is unchanged. Regression tests in `crates/engine/src/duplicate_map_tests.rs`. Numbers unchanged | | 2026-10-29 | minor | Bounded replay log (#1069, from a security audit): the batches a session keeps for apps that reconnect were bounded only by count (10,000 batches, 1,000,000 changes) on a desktop host and a private `figcraft-server`, so a long busy session could hold any number of bytes. `Limits::max_log_bytes` now defaults to 64 MiB, and `figcraft-server` takes it from a new `max_log_mb` setting (default 64; open to the public, also at most a quarter of `max_file_mb`, as before). An app further behind than the trimmed log catches up with the whole file, as it already did past the count limits. Nine 8 MiB renames with default limits kept all 75.5 MB before; now 58.7 MB, and a client that missed them gets a snapshot. The protocol is unchanged. | diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 34bb5645..4ef427e4 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -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 = 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 { + 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 { + 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 diff --git a/crates/render/src/layer_budget.rs b/crates/render/src/layer_budget.rs new file mode 100644 index 00000000..975ee6c6 --- /dev/null +++ b/crates/render/src/layer_budget.rs @@ -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 = const { Cell::new(0) }; + static PEAK: Cell = const { Cell::new(0) }; + static LIMIT: Cell = 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 { + 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(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)) +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index a357033d..9f2064fc 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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; @@ -505,6 +506,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; diff --git a/crates/render/src/mask_bounds_tests.rs b/crates/render/src/mask_bounds_tests.rs new file mode 100644 index 00000000..841542e1 --- /dev/null +++ b/crates/render/src/mask_bounds_tests.rs @@ -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]); +} diff --git a/docs/roadmap.md b/docs/roadmap.md index df1ab2d4..2d4d0633 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M8, #1061: a mask group's pixmaps cover only where its mask or content can paint within the view (a 40 × 40 mask on a 3000 × 2000 view: 96 MB → 61.5 kB, same pixels), and isolated layers share a 2 GiB budget held at once per render thread; a layer past it is left out with a warning. Tests: `mask_bounds_tests` | | 2026-10-29 | minor | M2, #1084: a component can't contain its own instance, directly or through other components (each variant counts as its own component, so a variant may hold a sibling variant's instance unless that one leads back to it). Edits that would close such a loop are refused with it named, a file holding one opens with an instance detached and a note, and a rebuild never expands an instance inside a copy of itself (a loop merged in a live session stops growing). Tests: `component_cycle_tests` | | 2026-10-29 | minor | Which copy is which (M8, #995): `edit.duplicate` with `map: true` also returns `map`, each original layer's id, descendants included (nested frames, groups, instances and their sublayers), paired with its copy's id, so a batch can edit inside a copy through a step reference (`"field": "map/6"`) instead of inspecting it. Without `map` the result is unchanged. Regression tests in `crates/engine/src/duplicate_map_tests.rs`. Numbers unchanged | | 2026-10-29 | minor | Bounded replay log (#1069, from a security audit): the batches a session keeps for apps that reconnect were bounded only by count (10,000 batches, 1,000,000 changes) on a desktop host and a private `figcraft-server`, so a long busy session could hold any number of bytes. `Limits::max_log_bytes` now defaults to 64 MiB, and `figcraft-server` takes it from a new `max_log_mb` setting (default 64; open to the public, also at most a quarter of `max_file_mb`, as before). An app further behind than the trimmed log catches up with the whole file, as it already did past the count limits. Nine 8 MiB renames with default limits kept all 75.5 MB before; now 58.7 MB, and a client that missed them gets a snapshot. The protocol is unchanged. |