From e37e0bc20ba2f46ef57332c0a101fc78e74b4de2 Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Wed, 14 Oct 2026 18:27:16 +0700 Subject: [PATCH 01/59] perf(render): keep glass shapes a later render asks for again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A glass layer's coverage, distance field and normals depend only on its outline and the zoom. A shape asked for again in a later render (the next frame of a drag, a slider drag, a canvas patch, an export of the same view) is now worked out whole, in its own pixel frame (translation reduced to its fraction, in 1/65536 px with carry), and kept: renders reuse it instead of redoing the distance transform and normals for their crop. A shape asked for only once (each step of a zoom, a pane dragged by fractions of a pixel, a resize) is still worked out for just the part shown, so those don't get slower: working out the whole pane when only a corner is in view cost 1.8x before this rule. The cache is bounded in bytes (128 MB, 32 MB on the web), keeps the least recently used last, skips shapes over 4M px (1M on the web), and parallel bands share one computation (OnceLock slot). Renders count once per render_view / render_view_single; bands don't. Output: digests of 30 renders (5 looks, square and rounded, scales 1, 1.37, 2) are identical to main; a cut through a rounded pane now equals the whole render exactly. Release timings (glass_render_timings, 2400 × 1600 px, min of 5): canvas drag under the glass 49.5 → 26.5 ms, glass slider drag 104.8 → 61.6 ms, same view again 94.8 → 54.0 ms; zoom 105.0 → 93.7 ms (no reuse: each zoom is new). Refs #256 Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/glass.rs | 250 ++++++++++++++++++++++++- crates/render/src/glass_bench_tests.rs | 16 ++ crates/render/src/glass_cut_tests.rs | 82 ++++++++ crates/render/src/lib.rs | 25 ++- 4 files changed, 361 insertions(+), 12 deletions(-) diff --git a/crates/render/src/glass.rs b/crates/render/src/glass.rs index 70e0d582..3555ff44 100644 --- a/crates/render/src/glass.rs +++ b/crates/render/src/glass.rs @@ -6,9 +6,20 @@ //! where it faces `light_angle`, half as bright on the opposite side, spread wider by `splay`. //! `frost` blurs the backdrop first. Distances to the outline come from an exact Euclidean //! distance transform of the shape's coverage. +//! +//! The shape's coverage, distances and normals depend only on the shape and the zoom. Once a +//! 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). + +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_geom::kurbo::Shape as _; use tiny_skia::Pixmap; use crate::fx; @@ -188,6 +199,211 @@ fn light_reach(band: f64, intensity: f64) -> f64 { /// zoom would otherwise need a huge margin of coverage around every cut). const MAX_LIGHT_PX: f64 = 512.0; +/// 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 { + x0: i64, + y0: i64, + w: usize, + h: usize, + cov: Vec, + dist: Vec, + normals: Vec<(f32, f32)>, +} + +/// Bytes a field pixel holds: coverage, distance and a normal. +const FIELD_PX_BYTES: u64 = 1 + 4 + 8; + +impl Fields { + /// 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 { + 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 } + } + + /// 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 { + 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)?)) + } +} + +thread_local! { + static FIELDS_COMPUTED: std::cell::Cell = 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 +/// tests and benchmarks. +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. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct ShapeKey { + path: u64, + linear: [u64; 4], + frac: (i64, i64), + margin: u32, + r: usize, +} + +/// The cached fields of a shape whose window holds at most this many pixels; bigger shapes (a +/// pane far larger than the view) are worked out for the part a render shows each time. +#[cfg(not(target_arch = "wasm32"))] +const MAX_SHAPE_PX: u64 = 4 << 20; +#[cfg(target_arch = "wasm32")] +const MAX_SHAPE_PX: u64 = 1 << 20; +/// Bytes the glass shape cache holds at most (less on the web, where memory never shrinks). +#[cfg(not(target_arch = "wasm32"))] +const MAX_CACHED_BYTES: u64 = 128 << 20; +#[cfg(target_arch = "wasm32")] +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>>>; + +/// 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 +/// dragged by fractions of a pixel) is cheaper worked out for just the part a render shows. +struct ShapeCache { + map: HashMap, + /// Shapes asked for but not kept, by the render that first asked. + seen: HashMap, + held: u64, + tick: u64, + budget: u64, +} + +/// Shapes remembered as asked for once; past this many they are forgotten. +const MAX_SEEN: usize = 256; + +impl ShapeCache { + fn new(budget: u64) -> Self { + Self { map: HashMap::new(), seen: HashMap::new(), held: 0, tick: 0, budget } + } + + /// The slot for `key` (filled or about to be) asked for in render number `render`, making room + /// for `bytes`; `None` when it isn't kept (first asked for in this render, or too big). + fn slot(&mut self, key: &ShapeKey, bytes: u64, render: u64) -> Option { + self.tick += 1; + if let Some((slot, _, used)) = self.map.get_mut(key) { + *used = self.tick; + return Some(slot.clone()); + } + if bytes > self.budget { + return None; + } + match self.seen.get(key) { + Some(first) if *first < render => { + self.seen.remove(key); + } + Some(_) => return None, + None => { + if self.seen.len() >= MAX_SEEN { + self.seen.clear(); + } + self.seen.insert(key.clone(), render); + return None; + } + } + while self.held + bytes > self.budget { + let oldest = self.map.iter().min_by_key(|(_, (_, _, used))| *used).map(|(k, _)| k.clone())?; + if let Some((_, b, _)) = self.map.remove(&oldest) { + self.held -= b; + } + } + let slot = Slot::default(); + self.map.insert(key.clone(), (slot.clone(), bytes, self.tick)); + self.held += bytes; + Some(slot) + } +} + +fn shape_cache() -> &'static Mutex { + static C: OnceLock> = OnceLock::new(); + C.get_or_init(|| Mutex::new(ShapeCache::new(MAX_CACHED_BYTES))) +} + +/// 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. +/// +/// 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, (i64, i64))> { + if let Some(whole) = whole_shape(shape, xf, m, r) { + return Some(whole); + } + 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))) +} + +/// [`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, (i64, i64))> { + let [a, b, c, d, e, f] = xf.as_coeffs(); + // The translation in steps of 1/65536 px (far finer than the rasteriser's 1/64, coarse enough + // that rounding errors in where a render starts land on the same step), its whole pixels (the + // shift) and the fraction left; rounding first carries 99.9999999 over to 100. + const STEPS: f64 = 65536.0; + let q = |v: f64| { + let q = (v * STEPS).round(); + (q.is_finite() && q.abs() < 1e15).then_some(q as i64) + }; + let (qe, qf) = (q(e)?, q(f)?); + let shift = (qe.div_euclid(STEPS as i64), qf.div_euclid(STEPS as i64)); + let frac = (qe.rem_euclid(STEPS as i64), qf.rem_euclid(STEPS as i64)); + let own = Affine::new([a, b, c, d, frac.0 as f64 / STEPS, frac.1 as f64 / STEPS]); + let bb = (own * shape.0.clone()).bounding_box(); + let (x0, y0, x1, y1) = (bb.x0.floor(), bb.y0.floor(), bb.x1.ceil(), bb.y1.ceil()); + if ![x0, y0, x1, y1].iter().all(|v| v.is_finite() && v.abs() < 1e9) { + return None; + } + // The margin in steps of 32 px, so dragging a slider that widens it a little keeps the same + // fields (a wider margin only adds exact distances further out: the same pixels). + let margin = m.div_ceil(32).checked_mul(32)?; + let (rw, rh) = ((x1 - x0) as u64 + 2, (y1 - y0) as u64 + 2); + let (ww, wh) = (rw + 2 * u64::from(margin), rh + 2 * u64::from(margin)); + if ww.checked_mul(wh)? > MAX_SHAPE_PX { + return None; + } + let mut hasher = DefaultHasher::new(); + crate::draw::hash_path(&shape.0, &mut hasher); + matches!(shape.1, tiny_skia::FillRule::EvenOdd).hash(&mut hasher); + let key = ShapeKey { path: hasher.finish(), linear: [a, b, c, d].map(f64::to_bits), frac, margin, r }; + let compute = || { + // Rastered against the shape's own bounds (not the window), so the margin can't move a + // coverage value. + let raster = spread_alpha(std::slice::from_ref(shape), 0.0, Affine::translate((1.0 - x0, 1.0 - y0)) * own, rw as u32, rh as u32)?; + let mut win = Pixmap::new(ww as u32, wh as u32)?; + let (row, at) = (raster.width() as usize * 4, (margin as usize, margin as usize)); + let stride = win.width() as usize * 4; + for (y, src) in raster.data().chunks_exact(row).enumerate() { + let start = (y + at.1) * stride + at.0 * 4; + 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)) + }; + 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)) +} + /// 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. #[allow(clippy::too_many_arguments)] @@ -201,10 +417,7 @@ pub fn glass(g: &Glass, shape: &Outline, back: &Pixmap, pad: u32, w: u32, h: u32 // exact: the bend's (within `depth`), the light's (within `reach`) and those the normals are // smoothed from (2r + 1 further). let m = depth.max(reach).ceil() as u32 + 4 * r as u32 + 4; - 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)?; - let dist = inside_distance(&cov); - let (cw, ch) = (cov.width() as usize, cov.height() as usize); - let normals = smooth_normals(&dist, cw, ch, r); + let (fields, shift) = shape_fields(shape, xf, w, h, m, r)?; let mut frosted = back.clone(); fx::blur(&mut frosted, g.frost / 2.0 * scale); let ang = g.light_angle.to_radians(); @@ -221,17 +434,12 @@ pub fn glass(g: &Glass, shape: &Outline, back: &Pixmap, pad: u32, w: u32, h: u32 let depth = depth as f32; let mut out = Pixmap::new(w, h)?; let dst = out.data_mut(); - let cd = cov.data(); - let d_at = |x: usize, y: usize| dist.get(y * cw + x).copied().unwrap_or(0.0); for y in 0..h as usize { for x in 0..w as usize { - let (mx, my) = (x + m as usize, y + m as usize); - let c = f32::from(cd.get((my * cw + mx) * 4 + 3).copied().unwrap_or(0)) / 255.0; + let Some((c, d, inward)) = fields.at(x as i64 - shift.0, y as i64 - shift.1) else { continue }; if c <= 0.0 { continue; } - let d = d_at(mx, my); - let inward = normals.get(my * cw + mx).copied().unwrap_or((0.0, 0.0)); let t = (d / depth).min(1.0); let bend = bend_max * (1.0 - t) * (1.0 - t); let (bx, by) = (x as f32 + pad as f32 + 0.5, y as f32 + pad as f32 + 0.5); @@ -284,6 +492,28 @@ mod tests { assert!((d[10 * 21 + 5] - 0.5).abs() < 1e-4, "{}", d[10 * 21 + 5]); } + #[test] + fn the_shape_cache_stays_within_its_bytes() { + let key = |i: u64| ShapeKey { path: i, linear: [0; 4], frac: (0, 0), margin: 0, r: 1 }; + let mut c = ShapeCache::new(100); + // Kept from the second render that asks, not the first (nor again within the first). + assert!(c.slot(&key(1), 40, 1).is_none()); + assert!(c.slot(&key(1), 40, 1).is_none()); + assert!(c.slot(&key(1), 40, 2).is_some()); + for k in [2, 3] { + assert!(c.slot(&key(k), 40, 2).is_none()); + } + assert!(c.slot(&key(2), 40, 3).is_some()); + // Using 1 again makes 2 the least recently used: 2 goes to make room for 3. + assert!(c.slot(&key(1), 40, 3).is_some()); + assert!(c.slot(&key(3), 40, 3).is_some()); + assert!(c.held <= 100, "{}", c.held); + assert!(c.map.contains_key(&key(1)) && c.map.contains_key(&key(3)) && !c.map.contains_key(&key(2))); + // More than it holds in all: never kept. + assert!(c.slot(&key(4), 101, 3).is_none() && c.slot(&key(4), 101, 4).is_none()); + assert!(c.held <= 100 && !c.map.contains_key(&key(4))); + } + #[test] fn light_reach_is_where_the_light_falls_under_half_a_level() { let r = light_reach(10.0, 1.0); diff --git a/crates/render/src/glass_bench_tests.rs b/crates/render/src/glass_bench_tests.rs index 64c95835..1a4b4cbe 100644 --- a/crates/render/src/glass_bench_tests.rs +++ b/crates/render/src/glass_bench_tests.rs @@ -80,6 +80,18 @@ fn set_glass(d: &mut Document, pane: NodeId, g: Glass) { d.get_mut(pane).unwrap().effects = vec![Effect { kind: EffectKind::Glass(g), visible: true }]; } +/// Writes the bench scene's render (rendered twice, so a kept shape is read) to the PNG named by +/// `FIGCRAFT_GLASS_PNG`, to compare builds by eye. +#[test] +#[ignore = "writes a PNG: FIGCRAFT_GLASS_PNG="] +fn glass_bench_png() { + let Ok(path) = std::env::var("FIGCRAFT_GLASS_PNG") else { return }; + let (d, p, _, _) = scene(); + let _ = render_view(&d, p, &view((0.0, 0.0), 2.0), &Options::default()); + let pix = render_view(&d, p, &view((0.0, 0.0), 2.0), &Options::default()).unwrap(); + pix.save_png(path).unwrap(); +} + #[test] #[ignore = "timing: run in release with --ignored --nocapture"] fn glass_render_timings() { @@ -96,6 +108,10 @@ fn glass_render_timings() { }), ), ("export: zoom, a new scale every frame", time(&mut d, p, render_view, |_, i| view((0.0, 0.0), 2.0 + 0.013 * f64::from(i)))), + ( + "export: zoomed in, a corner of the pane in view, a new scale every frame", + time(&mut d, p, render_view, |_, i| view((700.0, 400.0), 3.0 + 0.013 * f64::from(i))), + ), ("single: same view again", time(&mut d, p, render_view_single, |_, _| view((0.0, 0.0), 2.0))), ("canvas: drag the card under the glass", canvas(&d, p, 2.0, move_card)), ( diff --git a/crates/render/src/glass_cut_tests.rs b/crates/render/src/glass_cut_tests.rs index 43733c7f..93124377 100644 --- a/crates/render/src/glass_cut_tests.rs +++ b/crates/render/src/glass_cut_tests.rs @@ -99,7 +99,10 @@ fn glass_digests() { 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()); @@ -133,3 +136,82 @@ fn a_cut_through_the_pane_matches_the_whole_render() { } } } + +/// 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. +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.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 +} + +#[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"); + 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"); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 9969a597..cf7a0693 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -99,8 +99,23 @@ pub(crate) fn backdrop_reach(radius: f64, scale: f64) -> f64 { if radius > 0.0 { radius * 1.5 * scale + 3.0 } else { 0.0 } } +/// Renders started (bands of one render count once): glass keeps the shapes a later render asks +/// for again. +static RENDERS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +/// The number of the render that just started. +fn start_render() -> u64 { + RENDERS.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1 +} + +/// The number of the latest render started. +pub(crate) fn render_number() -> u64 { + RENDERS.load(std::sync::atomic::Ordering::Relaxed) +} + /// Render a page region, splitting it into horizontal bands rendered in parallel (desktop). pub fn render_view(doc: &Document, page: NodeId, view: &View, opts: &Options) -> Option { + start_render(); #[cfg(not(target_arch = "wasm32"))] { let threads = rayon::current_num_threads().clamp(1, 16) as u32; @@ -112,7 +127,7 @@ pub fn render_view(doc: &Document, page: NodeId, view: &View, opts: &Options) -> return Some(p); } } - render_view_single(doc, page, view, opts) + render_one(doc, page, view, opts) } /// Render `view` as `bands` horizontal bands in parallel. Bands overlap by what background blurs @@ -137,7 +152,7 @@ fn render_bands(doc: &Document, page: NodeId, view: &View, opts: &Options, bands let top = y0.saturating_sub(overlap); let bottom = (y0 + h + overlap).min(view.height).max(y0 + h); let v = View { origin: (view.origin.0, view.origin.1 + f64::from(top) / s), scale: view.scale, width: view.width, height: bottom - top }; - let part = render_view_single(doc, page, &v, opts); + let part = render_one(doc, page, &v, opts); let own = tiny_skia::IntRect::from_xywh(0, (y0 - top) as i32, view.width.clamp(1, 16_384), h); (y0, if overlap == 0 { part } else { part.zip(own).and_then(|(p, r)| p.clone_rect(r)) }) }) @@ -161,6 +176,12 @@ fn render_bands(doc: &Document, page: NodeId, view: &View, opts: &Options, bands /// Render a page region on the calling thread. pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Options) -> Option { + start_render(); + render_one(doc, page, view, opts) +} + +/// [`render_view_single`] as part of a render already started (one of its bands). +fn render_one(doc: &Document, page: NodeId, view: &View, opts: &Options) -> Option { let mut pix = new_pixmap(view.width, view.height)?; let bg = opts.background.or_else(|| match doc.node(page).map(|p| &p.kind) { Some(NodeKind::Page { background, .. }) => Some(*background), From 95b91e602bdf2e35bcc302e766001e4a9fb16d29 Mon Sep 17 00:00:00 2001 From: Ijas Hussain Date: Sat, 17 Oct 2026 12:31:58 +0530 Subject: [PATCH 02/59] M6: export variables as Dart constants for Flutter variable.export takes format "dart", and Local variables > Export... offers "Dart constants for Flutter...". The file has an abstract final class per collection and mode (the default mode named after the collection, the others after the collection and the mode) with a static const Color, double, String or bool per variable. Names become camelCase Dart identifiers, kept apart when two read the same, with reserved words escaped; strings are escaped for Dart; aliases are resolved to their value in that mode, so each class stands alone. A generated file passes the Flutter analyzer and its constants work in const widgets. --- ROADMAP.md | 2 + crates/engine/src/cmd/variables_io.rs | 134 +++++++++++++++++++++++- crates/engine/src/variables_io_tests.rs | 52 ++++++++- crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/src/variables_panel.rs | 3 +- docs/components-parity.md | 2 +- docs/mcp.md | 7 +- 8 files changed, 193 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index bbde8bc5..bb645c5f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-17** · Variables as Dart constants for Flutter (M6): `variable.export {format: "dart"}` and Local variables ▸ Export… ▸ Dart constants for Flutter write a Dart file with an `abstract final class` per collection and mode (the default mode named after the collection, the others after the collection and the mode) and a `static const` `Color`, `double`, `String` or `bool` per variable. Names become camelCase Dart identifiers (kept apart when two read the same, reserved words escaped), strings are escaped for Dart, and aliases are resolved to their value in that mode, so each class stands alone. Checked with the Flutter analyzer on a generated file used in `const` widgets. Tests in `variables_io_tests`. Numbers unchanged. - **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. - **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged. - **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. @@ -314,6 +315,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Variables as Dart constants for Flutter (M6): `variable.export {format: "dart"}` and Export… ▸ Dart constants for Flutter in Local variables; a class per collection and mode, aliases resolved, names made valid Dart. Numbers unchanged. | | 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | | 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. | diff --git a/crates/engine/src/cmd/variables_io.rs b/crates/engine/src/cmd/variables_io.rs index 99a78640..7cd92ae2 100644 --- a/crates/engine/src/cmd/variables_io.rs +++ b/crates/engine/src/cmd/variables_io.rs @@ -1,6 +1,6 @@ //! Variables import and export: a file's collections as the local variables JSON (the shape the -//! REST importer reads, so exports round-trip) or as CSS custom properties, and such JSON read -//! back into a file. +//! REST importer reads, so exports round-trip), as CSS custom properties or as Dart constants for +//! Flutter, and such JSON read back into a file. use std::collections::{BTreeMap, BTreeSet}; @@ -18,7 +18,7 @@ pub fn specs() -> Vec { spec!( "variable.export", "Export variables", - "{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads | css: custom properties, the default mode on :root and each other mode on [data-=\"\"]), path? (write it there)} → {text}, or {path} when written", + "{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads | css: custom properties, the default mode on :root and each other mode on [data-=\"\"] | dart: a Dart file for Flutter, a class of constants per collection and mode (Color, double, String, bool), aliases resolved), path? (write it there)} → {text}, or {path} when written", false, export ), @@ -47,7 +47,8 @@ fn export(s: &mut Session, p: &Value) -> Result { "json" => serde_json::to_string_pretty(&figcraft_format::rest::export_variables(&doc.variables, &colls)) .map_err(|e| EngineError::Other(e.to_string()))?, "css" => css(&doc.variables, &colls), - _ => return Err(bad(cmd, "format: json|css")), + "dart" => dart(&doc.variables, &colls), + _ => return Err(bad(cmd, "format: json|css|dart")), }; match string(p, "path") { Some(path) => { @@ -93,6 +94,131 @@ fn css(vars: &Variables, colls: &[&Collection]) -> String { out } +/// Words Dart never takes as a name. +const DART_RESERVED: &[&str] = &[ + "assert", "await", "break", "case", "catch", "class", "const", "continue", "default", "do", "else", "enum", "extends", "false", "final", + "finally", "for", "if", "in", "is", "new", "null", "rethrow", "return", "super", "switch", "this", "throw", "true", "try", "var", "void", + "while", "with", "yield", +]; + +/// Words Dart takes as a member's name but not as a class's. +const DART_BUILT_IN: &[&str] = &[ + "abstract", + "as", + "covariant", + "deferred", + "dynamic", + "export", + "extension", + "external", + "factory", + "Function", + "get", + "implements", + "import", + "interface", + "late", + "library", + "mixin", + "operator", + "part", + "required", + "set", + "static", + "typedef", +]; + +/// `name` as a Dart identifier: its words (anything but ASCII letters and digits separates them) +/// in camelCase, or PascalCase for a type; never empty, never starting with a digit or reserved. +fn dart_ident(name: &str, pascal: bool) -> String { + let mut out = String::new(); + for w in name.split(|c: char| !c.is_ascii_alphanumeric()).filter(|w| !w.is_empty()) { + let mut cs = w.chars(); + let Some(first) = cs.next() else { continue }; + if out.is_empty() && !pascal { + out.push(first.to_ascii_lowercase()); + } else { + out.push(first.to_ascii_uppercase()); + } + out.extend(cs); + } + if out.is_empty() || out.starts_with(|c: char| c.is_ascii_digit()) { + out.insert(0, if pascal { 'V' } else { 'v' }); + } + if DART_RESERVED.contains(&out.as_str()) || (pascal && DART_BUILT_IN.contains(&out.as_str())) { + out.push('_'); + } + out +} + +/// `base`, or `base2`, `base3`… when `taken` has it already; the result is taken. +fn dart_unique(base: String, taken: &mut BTreeSet) -> String { + let mut name = base.clone(); + let mut n = 2; + while !taken.insert(name.clone()) { + name = format!("{base}{n}"); + n += 1; + } + name +} + +/// `text` inside a Dart single-quoted string. +fn dart_string(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for c in text.chars() { + match c { + '\\' => out.push_str("\\\\"), + '\'' => out.push_str("\\'"), + '$' => out.push_str("\\$"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + c if c.is_control() => out.push_str(&format!("\\u{{{:x}}}", u32::from(c))), + c => out.push(c), + } + } + out +} + +/// `text` on one line of a Dart comment. +fn dart_comment(text: &str) -> String { + text.chars().map(|c| if c.is_control() { ' ' } else { c }).collect() +} + +/// Each collection's variables as Dart constants for Flutter: a class per mode (the default mode's +/// named after the collection, the others after the collection and the mode) with a `static const` +/// per variable. Aliases are resolved to their value in that mode (a variable of another +/// collection in that collection's default mode), so each class stands alone. +fn dart(vars: &Variables, colls: &[&Collection]) -> String { + let mut out = String::from("// Variables exported from FigCraft.\n\nimport 'dart:ui';\n"); + let mut classes = BTreeSet::new(); + for c in colls { + let base = dart_ident(&c.name, true); + let mut members = BTreeSet::new(); + let names: Vec<(&Variable, String)> = + vars.variables.iter().filter(|v| v.collection == c.id).map(|v| (v, dart_unique(dart_ident(&v.name, false), &mut members))).collect(); + for (i, m) in c.modes.iter().enumerate() { + let class = dart_unique(if i == 0 { base.clone() } else { format!("{base}{}", dart_ident(&m.name, true)) }, &mut classes); + out.push_str(&format!("\n/// {} · {}\nabstract final class {class} {{\n", dart_comment(&c.name), dart_comment(&m.name))); + let modes = BTreeMap::from([(c.id.clone(), m.id.clone())]); + for (v, name) in &names { + let (ty, value) = match vars.resolve(&v.id, &modes) { + Some(VariableValue::Color(col)) => { + let [r, g, b, a] = col.to_rgba8(); + ("Color", format!("Color(0x{a:02X}{r:02X}{g:02X}{b:02X})")) + } + Some(VariableValue::Number(x)) if x.is_finite() => ("double", trim_num(x)), + Some(VariableValue::String(t)) => ("String", format!("'{}'", dart_string(&t))), + Some(VariableValue::Boolean(b)) => ("bool", b.to_string()), + _ => continue, + }; + out.push_str(&format!(" static const {ty} {name} = {value};\n")); + } + out.push_str("}\n"); + } + } + out +} + fn import(s: &mut Session, p: &Value) -> Result { let cmd = "variable.import"; let v = diff --git a/crates/engine/src/variables_io_tests.rs b/crates/engine/src/variables_io_tests.rs index 5c7305bb..cb8d7adb 100644 --- a/crates/engine/src/variables_io_tests.rs +++ b/crates/engine/src/variables_io_tests.rs @@ -1,5 +1,5 @@ //! Extended variable collections (overrides, library updates, reset), variables import and -//! export (JSON round trip, merging by name, CSS, junk), gradient stops bound to variables and +//! export (JSON round trip, merging by name, CSS, Dart, junk), gradient stops bound to variables and //! variant properties driven by string variables. use figcraft_color::{Color, ColorStop, Gradient, GradientKind, Paint, PaintKind}; @@ -371,3 +371,53 @@ fn variant_properties_follow_string_variables() { assert_eq!(main(&s), variants[1]); healthy(&s); } + +#[test] +fn variables_export_as_dart_constants_for_flutter() { + let mut s = Session::new(); + theme(&mut s); + let dart = ex(&mut s, "variable.export", json!({"format": "dart"}))["text"].as_str().unwrap().to_string(); + for want in [ + "import 'dart:ui';\n", + "/// Theme · Light\nabstract final class Theme {\n", + " static const Color colorPrimary = Color(0x800000FF);\n", + " static const double radius = 4;\n", + " static const String label = 'Say \"hi\"';\n", + " static const bool on = false;\n", + " static const Color accent = Color(0xFF000000);\n", + "/// Theme · Dark\nabstract final class ThemeDark {\n", + " static const double radius = 6.5;\n", + // An alias is its target's value in that mode. + " static const Color accent = Color(0xFFFFFFFF);\n", + ] { + assert!(dart.contains(want), "{want:?} in\n{dart}"); + } + // Every class lists every variable once. + assert_eq!(dart.matches("static const").count(), 10, "{dart}"); +} + +#[test] +fn dart_export_names_are_valid_dart() { + let mut s = Session::new(); + ex(&mut s, "variable.createCollection", json!({"name": "2 brand-tokens", "modes": ["Default", "high contrast"]})); + ex(&mut s, "variable.createCollection", json!({"name": "Brand tokens"})); + for name in ["space/2xl", "class", "a b", "a-b", "\u{65e5}\u{672c}", "it's $5\\"] { + ex(&mut s, "variable.create", json!({"collection": "2 brand-tokens", "name": name, "type": "string", "value": name})); + } + let dart = ex(&mut s, "variable.export", json!({"format": "dart"}))["text"].as_str().unwrap().to_string(); + for want in [ + "abstract final class V2BrandTokens {", + "abstract final class V2BrandTokensHighContrast {", + "abstract final class BrandTokens {", + "static const String space2xl = 'space/2xl';", + "static const String class_ = 'class';", + // Two names that read the same in Dart stay apart. + "static const String aB = 'a b';", + "static const String aB2 = 'a-b';", + // Nothing usable in a name: still a name. + "static const String v = '\u{65e5}\u{672c}';", + "static const String itS5 = 'it\\'s \\$5\\\\';", + ] { + assert!(dart.contains(want), "{want:?} in\n{dart}"); + } +} diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index c9b6df62..dba711be 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -204,6 +204,7 @@ name = "English" "Convert to frame" = "Convert to frame" "Convert to section" = "Convert to section" "CSS custom properties…" = "CSS custom properties…" +"Dart constants for Flutter…" = "Dart constants for Flutter…" "Can edit" = "Can edit" "Can't read this library: {e}" = "Can't read this library: {e}" "Cancel" = "Cancel" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index ff3e5a79..f738f4d6 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -204,6 +204,7 @@ status = "draft — needs native review" "Color profile: {name}" = "Perfil de color: {name}" "Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Estilos de color y texto, variables Claro y Oscuro, botones, campos y tarjetas" "CSS custom properties…" = "Propiedades personalizadas CSS…" +"Dart constants for Flutter…" = "Constantes de Dart para Flutter…" "Can edit" = "Puede editar" "Can't read this library: {e}" = "No se puede leer esta biblioteca: {e}" "Cancel" = "Cancelar" diff --git a/crates/ui-egui/src/variables_panel.rs b/crates/ui-egui/src/variables_panel.rs index 5476eaf1..d273ba48 100644 --- a/crates/ui-egui/src/variables_panel.rs +++ b/crates/ui-egui/src/variables_panel.rs @@ -181,7 +181,8 @@ fn header(app: &mut FigApp, ui: &mut Ui, open: &mut bool) { let mut bar = ui.new_child(UiBuilder::new().max_rect(bar).layout(egui::Layout::right_to_left(egui::Align::Center))); let export = widgets::button(&mut bar, tr("Export…"), false, true); crate::right::menu_popup(&export).show(|ui| { - for (label, format) in [(tr("Variables JSON…"), "json"), (tr("CSS custom properties…"), "css")] { + for (label, format) in [(tr("Variables JSON…"), "json"), (tr("CSS custom properties…"), "css"), (tr("Dart constants for Flutter…"), "dart")] + { if menu_item(ui, label, "") { export_dialog(app, format); ui.close(); diff --git a/docs/components-parity.md b/docs/components-parity.md index 659676fd..b973ba20 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -69,7 +69,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Explicit modes per frame, library collections' modes | done | | | Gradient stops and variant properties bound to variables | done | a stop's binding follows it when earlier stops are removed | | Extended collections | done | library collections, including a library's own extended collections (chains through libraries); overrides per mode with reset; follow library updates, variables new to the library collection being an update of their own; modes inherited. Checked with synthetic libraries only ([gaps](gaps.md) #14) | -| Variables import/export (JSON) | done | the REST local variables shape (round-trips; merges by collection, mode and variable name); CSS custom properties export; in the browser the export arrives as a download (import there still needs a file picker) | +| Variables import/export (JSON) | done | the REST local variables shape (round-trips; merges by collection, mode and variable name); CSS custom properties and Dart constants (Flutter) export; in the browser the export arrives as a download (import there still needs a file picker) | | Expressions in prototype set-variable actions | done | see [prototyping-parity](prototyping-parity.md) | ## Revision history diff --git a/docs/mcp.md b/docs/mcp.md index 64228ea7..2b3ef2bd 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -816,8 +816,11 @@ frame back to Auto, inheriting its parent's mode). The same JSON carries variables between FigCraft files: `variable.export {collections?, format?, path?}` writes this file's collections (default: all but library copies) as a local variables response (`format: "json"`, the default) or as CSS custom properties (`format: "css"`: the default -mode on `:root`, each other mode on `[data-=""]`, aliases as `var(--name)`), and -returns `{text}` (or writes `path` and returns `{path}`). `variable.import {variables}` (an object, +mode on `:root`, each other mode on `[data-=""]`, aliases as `var(--name)`) or as +Dart constants for Flutter (`format: "dart"`: an `abstract final class` per collection and mode, the +default mode's named after the collection and the others after the collection and the mode, with a +`static const` `Color`, `double`, `String` or `bool` per variable; names become camelCase Dart +identifiers kept apart, aliases are resolved to their value in that mode), and returns `{text}` (or writes `path` and returns `{path}`). `variable.import {variables}` (an object, JSON text or a path) reads one back: a collection joins this file's collection of the same name (values matched by mode name, new modes and variables added, a same-named variable of another type skipped with a warning; library copies and extended collections are never targets), others are From 987ad67f7e13c6e61f4c6ce066aa2510c24ecc15 Mon Sep 17 00:00:00 2001 From: focuer Date: Sun, 18 Oct 2026 23:16:06 +0900 Subject: [PATCH 03/59] web: window.figcraft, the control channel for the page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same JSON methods as the desktop control server (engine.execute, ui.inspect, ui.screenshot, …), as promises on window.figcraft, like FilmCraft's window.filmcraft: an agent driving the editor in a browser tab or an