From e37e0bc20ba2f46ef57332c0a101fc78e74b4de2 Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Wed, 14 Oct 2026 18:27:16 +0700 Subject: [PATCH 001/229] 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 002/229] 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 9834f77db77d505845447d06d78f64cb5c885490 Mon Sep 17 00:00:00 2001 From: Mateus Villain Date: Sun, 18 Oct 2026 11:38:24 -0300 Subject: [PATCH 003/229] M1: LCH and OKLCH models in the colour picker The picker's model menu offers LCH and OKLCH beside Hex, RGB, HSL, HSB and CSS, on desktop and web. Lightness, chroma and hue fields; an edit outside sRGB lowers chroma and keeps lightness and hue (CSS gamut mapping). The CSS field and Color::parse_css read lch() and oklch(). --- ROADMAP.md | 3 +- crates/color/Cargo.toml | 2 +- crates/color/src/hsx.rs | 3 +- crates/color/src/lch.rs | 183 ++++++++++++++++++++++++++++++ crates/color/src/lch_tests.rs | 67 +++++++++++ crates/color/src/lib.rs | 34 +++++- crates/ui-egui/src/colorpicker.rs | 93 +++++++++------ crates/ui-egui/src/field_tests.rs | 24 ++++ docs/architecture.md | 2 +- docs/ui-parity.md | 4 +- 10 files changed, 377 insertions(+), 38 deletions(-) create mode 100644 crates/color/src/lch.rs create mode 100644 crates/color/src/lch_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 8a587286..aab4e3fc 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-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (colour picker LCH and OKLCH models; community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **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 @@ -316,6 +316,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Colour picker models LCH and OKLCH beside Hex, RGB, HSL, HSB and CSS (desktop and web): lightness, chroma and hue fields (OKLCH lightness in percent, chroma to two places), an edit keeps lightness and hue and lowers chroma until the colour fits sRGB, as CSS gamut mapping does; the CSS field reads `lch()` and `oklch()` too. Values read the stored channels as sRGB, like the other models (a Display P3 file's colours aren't converted first). Conversions checked against the CSS Color 4 and OKLab reference values; checked in the desktop app over the control channel. Numbers unchanged | | 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged | | 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. | | 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. | diff --git a/crates/color/Cargo.toml b/crates/color/Cargo.toml index 0bd58852..755e35a1 100644 --- a/crates/color/Cargo.toml +++ b/crates/color/Cargo.toml @@ -3,7 +3,7 @@ name = "figcraft-color" version.workspace = true edition.workspace = true license.workspace = true -description = "FigCraft colour and paint: RGBA colours, HSB/HSL, hex/CSS parsing, solid/gradient/image paints, blend modes" +description = "FigCraft colour and paint: RGBA colours, HSB/HSL/LCH/OKLCH, hex/CSS parsing, solid/gradient/image paints, blend modes" [lints] workspace = true diff --git a/crates/color/src/hsx.rs b/crates/color/src/hsx.rs index a19a5fd5..01f31d31 100644 --- a/crates/color/src/hsx.rs +++ b/crates/color/src/hsx.rs @@ -1,4 +1,5 @@ -//! HSB (HSV) and HSL, the models the colour picker offers besides RGB and hex. +//! HSB (HSV) and HSL, two of the models the colour picker offers besides RGB and hex (LCH and +//! OKLCH are in `lch.rs`). use crate::Color; use serde::{Deserialize, Serialize}; diff --git a/crates/color/src/lch.rs b/crates/color/src/lch.rs new file mode 100644 index 00000000..75dd4b89 --- /dev/null +++ b/crates/color/src/lch.rs @@ -0,0 +1,183 @@ +//! CIE LCH (CSS `lch()`, Lab with a D50 white) and OKLCH (CSS `oklch()`), the perceptual models +//! the colour picker offers. Colours are read as sRGB, like the hex, RGB, HSL and HSB fields. +//! +//! The matrices are the ones CSS Color 4 publishes (sRGB ↔ XYZ D65, Bradford D65 ↔ D50) and +//! Björn Ottosson's published OKLab matrices. + +use crate::Color; +use serde::{Deserialize, Serialize}; + +/// CIE LCH: lightness 0..100, chroma 0.. (about 0..150 for real colours), hue 0..360. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Lch { + pub l: f32, + pub c: f32, + pub h: f32, +} + +/// OKLCH: lightness 0..1, chroma 0.. (about 0..0.4 for real colours), hue 0..360. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Oklch { + pub l: f32, + pub c: f32, + pub h: f32, +} + +type V3 = [f64; 3]; + +fn mul(m: &[V3; 3], v: V3) -> V3 { + let row = |r: &V3| r[0] * v[0] + r[1] * v[1] + r[2] * v[2]; + [row(&m[0]), row(&m[1]), row(&m[2])] +} + +fn to_linear(c: f64) -> f64 { + let a = c.abs(); + let l = if a <= 0.04045 { a / 12.92 } else { ((a + 0.055) / 1.055).powf(2.4) }; + l.copysign(c) +} + +fn from_linear(c: f64) -> f64 { + let a = c.abs(); + let e = if a <= 0.0031308 { a * 12.92 } else { 1.055 * a.powf(1.0 / 2.4) - 0.055 }; + e.copysign(c) +} + +fn linear_rgb(c: Color) -> V3 { + [to_linear(f64::from(c.r)), to_linear(f64::from(c.g)), to_linear(f64::from(c.b))] +} + +const SRGB_TO_XYZ: [V3; 3] = [ + [0.412_390_799_265_959_34, 0.357_584_339_383_878, 0.180_480_788_401_834_3], + [0.212_639_005_871_510_27, 0.715_168_678_767_756, 0.072_192_315_360_733_71], + [0.019_330_818_715_591_82, 0.119_194_779_794_625_98, 0.950_532_152_249_660_7], +]; +const XYZ_TO_SRGB: [V3; 3] = [ + [3.240_969_941_904_522_6, -1.537_383_177_570_094, -0.498_610_760_293_003_4], + [-0.969_243_636_280_879_6, 1.875_967_501_507_720_2, 0.041_555_057_407_175_59], + [0.055_630_079_696_993_66, -0.203_976_958_888_976_52, 1.056_971_514_242_878_6], +]; +const D65_TO_D50: [V3; 3] = [ + [1.047_929_820_840_548_8, 0.022_946_793_341_019_088, -0.050_192_229_543_135_57], + [0.029_627_815_688_159_344, 0.990_434_484_573_249, -0.017_073_825_029_385_14], + [-0.009_243_058_152_591_178, 0.015_055_144_896_577_895, 0.751_874_289_958_000_8], +]; +const D50_TO_D65: [V3; 3] = [ + [0.955_473_452_704_218_2, -0.023_098_536_874_261_423, 0.063_259_308_661_021_7], + [-0.028_369_706_963_208_136, 1.009_995_458_005_822_6, 0.021_041_398_966_943_008], + [0.012_314_001_688_319_899, -0.020_507_696_433_477_912, 1.330_365_936_608_075_3], +]; +const D50_WHITE: V3 = [0.3457 / 0.3585, 1.0, (1.0 - 0.3457 - 0.3585) / 0.3585]; +const KAPPA: f64 = 24389.0 / 27.0; +const EPSILON: f64 = 216.0 / 24389.0; + +const SRGB_TO_LMS: [V3; 3] = [ + [0.412_221_470_8, 0.536_332_536_3, 0.051_445_992_9], + [0.211_903_498_2, 0.680_699_545_1, 0.107_396_956_6], + [0.088_302_461_9, 0.281_718_837_6, 0.629_978_700_5], +]; +const LMS_TO_OKLAB: [V3; 3] = [ + [0.210_454_255_3, 0.793_617_785_0, -0.004_072_046_8], + [1.977_998_495_1, -2.428_592_205_0, 0.450_593_709_9], + [0.025_904_037_1, 0.782_771_766_2, -0.808_675_766_0], +]; +const OKLAB_TO_LMS: [V3; 3] = + [[1.0, 0.396_337_777_4, 0.215_803_757_3], [1.0, -0.105_561_345_8, -0.063_854_172_8], [1.0, -0.089_484_177_5, -1.291_485_548_0]]; +const LMS_TO_SRGB: [V3; 3] = [ + [4.076_741_662_1, -3.307_711_591_3, 0.230_969_929_2], + [-1.268_438_004_6, 2.609_757_401_1, -0.341_319_396_5], + [-0.004_196_086_3, -0.703_418_614_7, 1.707_614_701_0], +]; + +fn lab_from_color(c: Color) -> V3 { + let xyz = mul(&D65_TO_D50, mul(&SRGB_TO_XYZ, linear_rgb(c))); + let f = |v: f64| if v > EPSILON { v.cbrt() } else { (KAPPA * v + 16.0) / 116.0 }; + let [fx, fy, fz] = [f(xyz[0] / D50_WHITE[0]), f(xyz[1] / D50_WHITE[1]), f(xyz[2] / D50_WHITE[2])]; + [116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)] +} + +fn linear_from_lab([l, a, b]: V3) -> V3 { + let fy = (l + 16.0) / 116.0; + let fx = fy + a / 500.0; + let fz = fy - b / 200.0; + let inv = |f: f64| if f.powi(3) > EPSILON { f.powi(3) } else { (116.0 * f - 16.0) / KAPPA }; + let y = if l > KAPPA * EPSILON { fy.powi(3) } else { l / KAPPA }; + let xyz = [inv(fx) * D50_WHITE[0], y * D50_WHITE[1], inv(fz) * D50_WHITE[2]]; + mul(&XYZ_TO_SRGB, mul(&D50_TO_D65, xyz)) +} + +fn oklab_from_color(c: Color) -> V3 { + let lms = mul(&SRGB_TO_LMS, linear_rgb(c)); + mul(&LMS_TO_OKLAB, [lms[0].cbrt(), lms[1].cbrt(), lms[2].cbrt()]) +} + +fn linear_from_oklab(lab: V3) -> V3 { + let lms = mul(&OKLAB_TO_LMS, lab); + mul(&LMS_TO_SRGB, [lms[0].powi(3), lms[1].powi(3), lms[2].powi(3)]) +} + +/// Lab → (lightness, chroma, hue); grey has hue 0. +fn polar([l, a, b]: V3, grey: f64) -> (f32, f32, f32) { + let c = a.hypot(b); + let h = if c < grey { 0.0 } else { b.atan2(a).to_degrees().rem_euclid(360.0) }; + (l as f32, c as f32, h as f32) +} + +fn finite(v: f32) -> f64 { + if v.is_finite() { f64::from(v) } else { 0.0 } +} + +fn in_gamut(rgb: V3) -> bool { + rgb.iter().all(|v| (-1e-6..=1.0 + 1e-6).contains(v)) +} + +/// The sRGB colour for a lightness, chroma and hue: out of gamut, the chroma comes down (a binary +/// search) until it fits, so lightness and hue stay what was asked for, as CSS gamut mapping does. +fn map_to_srgb(l: f64, c: f64, h: f64, to_linear_rgb: impl Fn(V3) -> V3) -> Color { + let lab = |c: f64| { + let (s, co) = h.to_radians().sin_cos(); + [l, c * co, c * s] + }; + let mut rgb = to_linear_rgb(lab(c)); + if !in_gamut(rgb) { + let (mut lo, mut hi) = (0.0, c); + for _ in 0..24 { + let mid = (lo + hi) / 2.0; + if in_gamut(to_linear_rgb(lab(mid))) { + lo = mid; + } else { + hi = mid; + } + } + rgb = to_linear_rgb(lab(lo)); + } + let ch = |v: f64| from_linear(v.clamp(0.0, 1.0)) as f32; + Color::new(ch(rgb[0]), ch(rgb[1]), ch(rgb[2]), 1.0) +} + +impl Lch { + pub fn from_color(c: Color) -> Self { + let (l, c, h) = polar(lab_from_color(c), 0.02); + Self { l, c, h } + } + + /// Opaque sRGB; out-of-gamut chroma is reduced to the nearest colour sRGB can show. + pub fn to_color(self) -> Color { + let l = finite(self.l).clamp(0.0, 100.0); + let c = finite(self.c).clamp(0.0, 230.0); + map_to_srgb(l, c, finite(self.h).rem_euclid(360.0), linear_from_lab) + } +} + +impl Oklch { + pub fn from_color(c: Color) -> Self { + let (l, c, h) = polar(oklab_from_color(c), 0.000_2); + Self { l, c, h } + } + + /// Opaque sRGB; out-of-gamut chroma is reduced to the nearest colour sRGB can show. + pub fn to_color(self) -> Color { + let l = finite(self.l).clamp(0.0, 1.0); + let c = finite(self.c).clamp(0.0, 0.5); + map_to_srgb(l, c, finite(self.h).rem_euclid(360.0), linear_from_oklab) + } +} diff --git a/crates/color/src/lch_tests.rs b/crates/color/src/lch_tests.rs new file mode 100644 index 00000000..72a52a2c --- /dev/null +++ b/crates/color/src/lch_tests.rs @@ -0,0 +1,67 @@ +use super::*; + +fn close(a: f32, b: f32, tol: f32) -> bool { + (a - b).abs() <= tol +} + +#[test] +fn lch_matches_css_reference_values() { + // CSS Color 4 values for sRGB primaries and white (D50 Lab). + let red = Lch::from_color(Color::from_hex("FF0000").unwrap()); + assert!(close(red.l, 54.29, 0.05) && close(red.c, 106.84, 0.1) && close(red.h, 40.85, 0.1), "{red:?}"); + let white = Lch::from_color(Color::WHITE); + assert!(close(white.l, 100.0, 0.01) && white.c < 0.05, "{white:?}"); + assert_eq!(Lch::from_color(Color::BLACK).l.round(), 0.0); +} + +#[test] +fn oklch_matches_reference_values() { + let red = Oklch::from_color(Color::from_hex("FF0000").unwrap()); + assert!(close(red.l, 0.628, 0.001) && close(red.c, 0.2577, 0.001) && close(red.h, 29.23, 0.1), "{red:?}"); + let white = Oklch::from_color(Color::WHITE); + assert!(close(white.l, 1.0, 0.001) && white.c < 0.001 && white.h == 0.0, "{white:?}"); +} + +#[test] +fn lch_and_oklch_round_trip_through_srgb() { + for hex in ["0D99FF", "FF0000", "00FF00", "0000FF", "FFFFFF", "000000", "808080", "663399", "F24E1E", "1ABC9C"] { + let c = Color::from_hex(hex).unwrap(); + assert_eq!(Lch::from_color(c).to_color().to_hex(), hex, "{hex} via LCH"); + assert_eq!(Oklch::from_color(c).to_color().to_hex(), hex, "{hex} via OKLCH"); + } +} + +#[test] +fn out_of_gamut_keeps_lightness_and_hue() { + // Far more chroma than sRGB holds: the chroma comes down, lightness and hue stay. + let asked = Oklch { l: 0.7, c: 0.45, h: 150.0 }; + let got = Oklch::from_color(asked.to_color()); + assert!(close(got.l, 0.7, 0.01) && close(got.h, 150.0, 1.5) && got.c < 0.45, "{got:?}"); + let asked = Lch { l: 50.0, c: 200.0, h: 300.0 }; + let got = Lch::from_color(asked.to_color()); + assert!(close(got.l, 50.0, 1.0) && close(got.h, 300.0, 2.0) && got.c < 200.0, "{got:?}"); +} + +#[test] +fn junk_values_never_panic() { + for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -1e30, 1e30, -5.0, 0.0] { + let a = Lch { l: v, c: v, h: v }.to_color(); + let b = Oklch { l: v, c: v, h: v }.to_color(); + for x in [a.r, a.g, a.b, b.r, b.g, b.b] { + assert!(x.is_finite() && (0.0..=1.0).contains(&x)); + } + } + let nan = Color { r: f32::NAN, g: 2.0, b: -1.0, a: 1.0 }; + assert!(Lch::from_color(nan.sanitized()).l.is_finite()); + assert!(Oklch::from_color(nan.sanitized()).l.is_finite()); +} + +#[test] +fn css_reads_lch_and_oklch() { + assert_eq!(Color::parse_css("oklch(62.8% 0.2577 29.23)").unwrap().to_hex(), "FF0000"); + assert_eq!(Color::parse_css("oklch(0.628 0.2577 29.23deg / 50%)").unwrap().to_rgba8(), [255, 0, 0, 128]); + assert_eq!(Color::parse_css("lch(54.29 106.84 40.85)").unwrap().to_hex(), "FF0000"); + assert_eq!(Color::parse_css("LCH(100% 0 0)").unwrap().to_hex(), "FFFFFF"); + assert!(Color::parse_css("oklch(nan 0 0)").is_none()); + assert!(Color::parse_css("lch(50)").is_none()); +} diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index d6859a51..a19c2a3e 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -11,10 +11,12 @@ use serde::{Deserialize, Serialize}; mod blend; mod hsx; mod icc; +mod lch; mod named; mod profile; pub use blend::BlendMode; pub use hsx::*; +pub use lch::{Lch, Oklch}; pub use profile::{ColorProfile, ProfileConversion}; /// A non-premultiplied sRGB colour with alpha, components in `0..=1`. Deserializes from @@ -146,7 +148,8 @@ impl Color { Self::from_hex(s).or_else(|| named::lookup(s).map(|[r, g, b]| Self::from_rgba8(r, g, b, 255))) } - /// Parse a CSS colour: hex, `rgb()/rgba()`, `hsl()/hsla()`, a CSS colour name or `transparent`. + /// Parse a CSS colour: hex, `rgb()/rgba()`, `hsl()/hsla()`, `lch()`, `oklch()`, a CSS colour + /// name or `transparent`. LCH and OKLCH outside sRGB come back as the nearest sRGB colour. pub fn parse_css(s: &str) -> Option { let t = s.trim().to_ascii_lowercase(); if t.starts_with('#') { @@ -173,6 +176,24 @@ impl Color { let alpha = parts.get(3).and_then(|a| parse_alpha(a)).unwrap_or(1.0); return Some(Hsl { h, s, l }.to_color().with_alpha(alpha)); } + if let Some(inner) = t.strip_prefix("oklch(").and_then(|r| r.strip_suffix(')')) { + let parts = css_args(inner); + // Percentages: 100% lightness is 1, 100% chroma is 0.4 (CSS Color 4). + let l = css_number(parts.first()?, 1.0)?; + let c = css_number(parts.get(1)?, 0.4)?; + let h = parts.get(2)?.trim_end_matches("deg").parse::().ok()?; + let alpha = parts.get(3).and_then(|a| parse_alpha(a)).unwrap_or(1.0); + return Some(Oklch { l, c, h }.to_color().with_alpha(alpha)); + } + if let Some(inner) = t.strip_prefix("lch(").and_then(|r| r.strip_suffix(')')) { + let parts = css_args(inner); + // Percentages: 100% lightness is 100, 100% chroma is 150 (CSS Color 4). + let l = css_number(parts.first()?, 100.0)?; + let c = css_number(parts.get(1)?, 150.0)?; + let h = parts.get(2)?.trim_end_matches("deg").parse::().ok()?; + let alpha = parts.get(3).and_then(|a| parse_alpha(a)).unwrap_or(1.0); + return Some(Lch { l, c, h }.to_color().with_alpha(alpha)); + } if t == "transparent" { return Some(Color::TRANSPARENT); } @@ -213,6 +234,15 @@ fn css_args(inner: &str) -> Vec { inner.replace('/', ",").split([',', ' ']).map(str::trim).filter(|p| !p.is_empty()).map(str::to_string).collect() } +/// A CSS number, or a percentage of `full`; finite only. +fn css_number(p: &str, full: f32) -> Option { + let v = match p.strip_suffix('%') { + Some(pc) => pc.trim().parse::().ok()? / 100.0 * full, + None => p.parse::().ok()?, + }; + v.is_finite().then_some(v) +} + fn parse_alpha(a: &str) -> Option { if let Some(pc) = a.strip_suffix('%') { pc.parse::().ok().map(|v| v / 100.0) } else { a.parse::().ok() } } @@ -628,6 +658,8 @@ impl Paint { #[cfg(test)] mod gradient_tests; #[cfg(test)] +mod lch_tests; +#[cfg(test)] mod pattern_tests; #[cfg(test)] mod profile_tests; diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index be164835..6ddebc4b 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -236,7 +236,7 @@ fn page_colors(app: &FigApp, ui: &Ui) -> Vec { } /// Colour models the value row can show. -const MODELS: [&str; 5] = ["Hex", "RGB", "HSL", "HSB", "CSS"]; +const MODELS: [&str; 7] = ["Hex", "RGB", "HSL", "HSB", "LCH", "OKLCH", "CSS"]; /// The model dropdown with its fields and the alpha field. Returns a new colour and whether the /// edit is done (false while a field is being scrubbed). @@ -248,7 +248,9 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option<(Color, bool)> { ui.horizontal(|ui| { ui.add_space(16.0); ui.spacing_mut().item_spacing.x = 4.0; - let resp = widgets::dropdown(ui, MODELS.get(model).copied().unwrap_or("Hex"), 52.0, None); + // "LCH" and "OKLCH" need a wider dropdown; their fields and alpha give up the room. + let wide = matches!(MODELS.get(model).copied(), Some("LCH" | "OKLCH")); + let resp = widgets::dropdown(ui, MODELS.get(model).copied().unwrap_or("Hex"), if wide { 66.0 } else { 52.0 }, None); crate::right::menu_popup(&resp).show(|ui| { for (i, m) in MODELS.iter().enumerate() { if crate::right::menu_item(ui, m, "") { @@ -257,25 +259,22 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option<(Color, bool)> { } } }); - let num = |ui: &mut Ui, id: &str, v: f64, w: f32| match widgets::number_field( - ui, - Id::new(("cp_f", id)), - widgets::Lead::None, - Some(v.round()), - "", - w, - 1.0, - ) { - Some(widgets::FieldEdit::Set(x)) => Some((x, true)), - Some(widgets::FieldEdit::Scrub(x, end)) => Some((x, end)), - None => None, + // `decimals` places shown; `step` is what a scrub or arrow moves by. + let num = |ui: &mut Ui, id: &str, v: f64, w: f32, decimals: i32, step: f64| { + let p = 10f64.powi(decimals); + match widgets::number_field(ui, Id::new(("cp_f", id)), widgets::Lead::None, Some((v * p).round() / p), "", w, step) { + Some(widgets::FieldEdit::Set(x)) => Some((x, true)), + Some(widgets::FieldEdit::Scrub(x, end)) => Some((x, end)), + None => None, + } }; - match MODELS.get(model).copied().unwrap_or("Hex") { + let name = MODELS.get(model).copied().unwrap_or("Hex"); + match name { "RGB" => { let [r, g, b, _] = c.to_rgba8(); let vals = [f64::from(r), f64::from(g), f64::from(b)]; for (k, v) in vals.iter().enumerate() { - if let Some((x, end)) = num(ui, ["r", "g", "b"][k.min(2)], *v, 31.0) { + if let Some((x, end)) = num(ui, ["r", "g", "b"][k.min(2)], *v, 31.0, 0, 1.0) { done = end; let mut rgb = vals; if let Some(slot) = rgb.get_mut(k) { @@ -285,25 +284,55 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option<(Color, bool)> { } } } - "HSL" | "HSB" => { - let hsl = model == 2; - let (h, s2, l2) = if hsl { - let x = figcraft_color::Hsl::from_color(c); - (x.h, x.s, x.l) - } else { - let x = Hsb::from_color(c); - (x.h, x.s, x.b) + "HSL" | "HSB" | "LCH" | "OKLCH" => { + // Shown as: hue/lightness/chroma in their CSS units; OKLCH lightness as a percent. + let vals: [f64; 3] = match name { + "HSL" => { + let x = figcraft_color::Hsl::from_color(c); + [f64::from(x.h), f64::from(x.s) * 100.0, f64::from(x.l) * 100.0] + } + "LCH" => { + let x = figcraft_color::Lch::from_color(c); + [f64::from(x.l), f64::from(x.c), f64::from(x.h)] + } + "OKLCH" => { + let x = figcraft_color::Oklch::from_color(c); + [f64::from(x.l) * 100.0, f64::from(x.c), f64::from(x.h)] + } + _ => { + let x = Hsb::from_color(c); + [f64::from(x.h), f64::from(x.s) * 100.0, f64::from(x.b) * 100.0] + } }; - let vals = [f64::from(h), f64::from(s2) * 100.0, f64::from(l2) * 100.0]; - for (k, v) in vals.iter().enumerate() { - if let Some((x, end)) = num(ui, ["h", "s", "l"][k.min(2)], *v, 31.0) { + let lch = matches!(name, "LCH" | "OKLCH"); + // (field id, decimals, step, max) per field; the hue wraps. + let fields: [(&str, i32, f64, f64); 3] = match name { + "LCH" => [("l", 0, 1.0, 100.0), ("c", 0, 1.0, 230.0), ("h", 0, 1.0, 360.0)], + "OKLCH" => [("l", 0, 1.0, 100.0), ("ok_c", 2, 0.01, 0.5), ("h", 0, 1.0, 360.0)], + _ => [("h", 0, 1.0, 360.0), ("s", 0, 1.0, 100.0), ("l", 0, 1.0, 100.0)], + }; + let hue = if lch { 2 } else { 0 }; + for (k, (v, (id, decimals, step, max))) in vals.iter().zip(fields).enumerate() { + let w = if !wide { + 31.0 + } else if decimals > 0 { + 30.0 + } else { + 27.0 + }; + if let Some((x, end)) = num(ui, id, *v, w, decimals, step) { done = end; let mut v3 = vals; if let Some(slot) = v3.get_mut(k) { - *slot = if k == 0 { x.rem_euclid(360.0) } else { x.clamp(0.0, 100.0) }; + *slot = if k == hue { x.rem_euclid(360.0) } else { x.clamp(0.0, max) }; } - let (h, a, b) = (v3[0] as f32, v3[1] as f32 / 100.0, v3[2] as f32 / 100.0); - let col = if hsl { figcraft_color::Hsl { h, s: a, l: b }.to_color() } else { Hsb { h, s: a, b }.to_color() }; + let [a, b, d] = v3.map(|v| v as f32); + let col = match name { + "HSL" => figcraft_color::Hsl { h: a, s: b / 100.0, l: d / 100.0 }.to_color(), + "LCH" => figcraft_color::Lch { l: a, c: b, h: d }.to_color(), + "OKLCH" => figcraft_color::Oklch { l: a / 100.0, c: b, h: d }.to_color(), + _ => Hsb { h: a, s: b / 100.0, b: d / 100.0 }.to_color(), + }; out = Some(col.with_alpha(c.a)); } } @@ -321,7 +350,7 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option<(Color, bool)> { } if MODELS.get(model) != Some(&"CSS") { let alpha = Some((f64::from(out.unwrap_or(c).a) * 100.0).round()); - let edit = match widgets::number_field(ui, Id::new("cp_a"), widgets::Lead::None, alpha, "%", 44.0, 1.0) { + let edit = match widgets::number_field(ui, Id::new("cp_a"), widgets::Lead::None, alpha, "%", if wide { 40.0 } else { 44.0 }, 1.0) { Some(widgets::FieldEdit::Set(v)) => Some((v, true)), Some(widgets::FieldEdit::Scrub(v, end)) => Some((v, end)), None => None, @@ -791,7 +820,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { changed = true; done |= d; } - // Colour model fields (Hex, RGB, HSL, HSB or CSS) + alpha. + // Colour model fields (Hex, RGB, HSL, HSB, LCH, OKLCH or CSS) + alpha. if let Some((c, d)) = model_fields(ui, nc) { nc = c; hsb = Hsb::from_color(nc); diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index 9ddc48e5..e6c4f42d 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -438,6 +438,30 @@ fn scrubbing_colour_picker_fields_is_live_and_one_undo_step() { assert_eq!(undo_steps(&u), steps + 2, "alpha"); } +#[test] +fn lch_and_oklch_picker_fields_edit_lightness_keeping_hue() { + for (model, name) in [(4usize, "LCH"), (5, "OKLCH")] { + let (mut u, a, _) = setup(); + let color = |u: &Ui| u.app.session.doc().unwrap().node(a).unwrap().fills[0].solid_color().unwrap(); + u.app.set_props(&[a], json!({"fills": [{"type": "solid", "color": "#0D99FF"}]})); + let before = color(&u); + assert_eq!(before.to_hex(), "0D99FF"); + u.ctx.data_mut(|d| d.insert_temp(Id::new("cp_model"), model)); + u.app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills: true, index: 0, ids: vec![a] }); + for _ in 0..3 { + u.frame(vec![]); + } + let steps = undo_steps(&u); + let from = lead(&u, Id::new(("cp_f", "l"))); + drag(&mut u, from, -20.0, Modifiers::NONE); + let after = color(&u); + let hue = |c| figcraft_color::Oklch::from_color(c).h; + assert!(after.luminance() < before.luminance(), "{name}: darker"); + assert!((hue(after) - hue(before)).abs() < 3.0, "{name}: hue kept {} → {}", hue(before), hue(after)); + assert_eq!(undo_steps(&u), steps + 1, "{name}: one undo step"); + } +} + #[test] fn scrubbing_a_prototype_delay_sets_it_once_when_let_go() { let mut s = Session::new(); diff --git a/docs/architecture.md b/docs/architecture.md index 766d2a6c..74c5a15e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,7 +15,7 @@ replaced. | Layer | Crate | Lines | Role | |---|---|---|---| | L0 | `figcraft-geom` | ~0.5k | Affine transforms, shape path builders (rounded and smoothed corners, polygons, stars, arcs) on kurbo | -| L0 | `figcraft-color` | ~1.3k | Colours (HSB/HSL, hex/CSS, Display P3), paints (solid, gradients, image), blend modes | +| L0 | `figcraft-color` | ~1.3k | Colours (HSB/HSL, LCH/OKLCH, hex/CSS, Display P3), paints (solid, gradients, image), blend modes | | L1 | `figcraft-doc` | ~8.5k | Document model: pages, node tree in a persistent map (`imbl`, stable ids), properties, components, styles, variables, prototype data, comments, versions. Serde everywhere | | L2 | `figcraft-layout` | ~1.8k | Auto layout (horizontal, vertical, wrap, grid; hug/fill/fixed; min/max), constraints, layout grid geometry | | L2 | `figcraft-text` | ~3.1k | Font database (optional embedded craft-fonts + system scan), shaping (harfrust), bidi, line layout, glyph outlines (skrifa), variable fonts | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 766284f1..a89f7182 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (colour picker LCH and OKLCH models; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -28,6 +28,7 @@ interaction feel not yet compared. | Multiplayer avatars, Share button | missing | waits for multiplayer | | Colour names in hex fields | done | every hex field (fills, strokes, text ranges, effects, layout grids, colour variables, page and prototype backgrounds, the colour picker's Hex) takes a CSS colour name as well: `red` becomes `FF0000`; case, spaces and hyphens are ignored (`Light Blue`); names are opaque and keep the colour's opacity; hex wins where both could match. The picker's CSS field takes every CSS name too (`Color::from_hex_or_name`, `color::named`) | | Fields select their text on focus | done | a click or Tab into a one-line field selects all of its text, so typing replaces the value: hex and colour fields, number fields, inline fields (a fill's hex, variable values, renames), names, search boxes and dialog fields; a drag from the press selects only what it covers; multi-line fields (descriptions, comments) keep the caret where clicked. The first click into an inline field (a fill's hex) now takes the keyboard; it took a second click. A rename (double-click, F2, File ▸ Rename, a page) selects the whole name before its field first draws, so a key typed in that same frame replaces the name; it was appended (`widgets::select_all_on_focus`, `select_all_tests`, `layers_rename_tests`) | +| Colour picker models | done | Hex, RGB, HSL, HSB, LCH, OKLCH and CSS; LCH and OKLCH keep lightness and hue when a colour leaves sRGB (chroma comes down); CSS reads `lch()` and `oklch()` (`figcraft_color::{Lch, Oklch}`). Values are the stored channels read as sRGB | | Colour picker on a new fill or stroke | done | + opens the picker on the new paint when the selection shares one paint list; on a mixed selection + adds a paint to each layer and no picker opens (#404) | | Blend mode menus: the layer's (Design panel) and each paint's (colour picker) | done | Pass through and Normal are distinct (Normal isolates a group's or frame's children); paints have no pass-through; plus darker drawn exactly on the canvas | @@ -172,6 +173,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Colour picker models LCH and OKLCH beside Hex, RGB, HSL, HSB and CSS (desktop and web): lightness, chroma and hue fields (OKLCH lightness in percent, chroma to two places), an edit keeps lightness and hue and lowers chroma until the colour fits sRGB, as CSS gamut mapping does; the CSS field reads `lch()` and `oklch()` too. Values read the stored channels as sRGB, like the other models (a Display P3 file's colours aren't converted first). Conversions checked against the CSS Color 4 and OKLab reference values; checked in the desktop app over the control channel. Numbers unchanged | | 2026-10-18 | minor | A rename (layers double-click, F2, File ▸ Rename, a page) selects the whole name in the frame its field first draws, so the first key replaces the name instead of being appended. Checked in the app on Windows over the control channel | | 2026-10-17 | minor | Escape or Enter while the pen draws ends the path and keeps its vector in edit mode with the pen, so the next path is drawn into the same layer (unconnected); another Escape leaves edit mode (#760). Entering edit mode on a layer that already is a vector no longer records an empty undo step. Numbers unchanged | | 2026-10-17 | minor | Text layers keep the platform IME on for every frame of an edit, not only frames with input (an idle frame turned it off and macOS dropped each composition, #747); the candidate window opens at the caret instead of the canvas corner; keys pressed while composing go to the IME | From 987ad67f7e13c6e61f4c6ce066aa2510c24ecc15 Mon Sep 17 00:00:00 2001 From: focuer Date: Sun, 18 Oct 2026 23:16:06 +0900 Subject: [PATCH 004/229] 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