From fe8a6983c54db1ce03787f191217c0a522876dd5 Mon Sep 17 00:00:00 2001 From: arhammusheer Date: Sun, 11 Oct 2026 23:28:10 +0530 Subject: [PATCH 01/12] #1244: one shared, byte-budgeted store for decoded images; render caches that sweep and stay bounded Decoded images were kept per Renderer at full resolution and never evicted, so the canvas, render worker, thumbnails and panels each held their own copy (96 MB per 24 MP photo). They now live once in a process-wide LRU store keyed by content, with optional mip levels (RenderOptions::image_mips, off by default so exports stay identical). Thumbnail renders now sweep their caches, and shadow/effect rasters share a 512 MiB budget and match by Arc. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc --- crates/render/src/fx.rs | 124 +++++++++++-- crates/render/src/images.rs | 282 ++++++++++++++++++++++++++++++ crates/render/src/ink.rs | 152 ++++++++-------- crates/render/src/lib.rs | 223 +++++++++++++++-------- crates/render/src/tests_memory.rs | 105 +++++++++++ 5 files changed, 720 insertions(+), 166 deletions(-) create mode 100644 crates/render/src/images.rs create mode 100644 crates/render/src/tests_memory.rs diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 33c57d98..0398338d 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -73,6 +73,8 @@ fn is_container(n: &Node) -> bool { struct Content<'a> { /// The object the effects belong to (cached shadows are checked against it). node: &'a Node, + /// The `Arc` holding `node`, when known: cached rasters keep it and match it by identity. + owner: Option<&'a Arc>, /// First shadow-cache slot of these effects (`None`: don't cache). cache: Option, /// Document-space bounds of the painted content. @@ -222,7 +224,7 @@ fn looks_around(fx: &RasterFx) -> Option { /// The content of an object with Photoshop-style effects as filtered pixels, drawn at /// (`anchor`'s view position + `rel`) scaled by 1 / `k` (see [`Renderer::pixel_content`]). pub(crate) struct PixelEntry { - node: Node, + node: Drawn, linear: [u64; 4], anchor: vectorcraft_geom::Point, rel: (f64, f64), @@ -252,7 +254,7 @@ fn draw_scaled(ctx: &mut RenderContext, image: std::sync::Arc /// A cached shadow / glow raster: premultiplied, already tinted and faded, positioned relative to /// the view-space position of `anchor` (a document point). pub(crate) struct ShadowEntry { - node: Node, + node: Drawn, linear: [u64; 4], anchor: vectorcraft_geom::Point, /// Pixel offset of the raster's top-left from the anchor's view position. @@ -261,6 +263,38 @@ pub(crate) struct ShadowEntry { pub(crate) stamp: u64, } +/// The object a cached raster was made for: the drawn `Arc` itself when known (kept alive, so its +/// address, the cache key, names it alone and a key match is exact), else a copy compared by value. +pub(crate) enum Drawn { + Shared(Arc), + Copy(Box), +} + +impl Drawn { + fn of(n: &Node, owner: Option<&Arc>) -> Self { + match owner { + Some(a) => Self::Shared(a.clone()), + None => Self::Copy(Box::new(n.clone())), + } + } + + /// Is `n` the object drawn? + fn is(&self, n: &Node) -> bool { + match self { + Self::Shared(a) => std::ptr::eq(&**a, n), + Self::Copy(c) => **c == *n, + } + } +} + +/// Most bytes the shadow and filtered-content rasters of one renderer hold together: the least +/// recently drawn go beyond it. +pub(crate) const FX_RASTER_BUDGET: usize = 512 << 20; + +fn raster_bytes(pm: &vello_cpu::Pixmap) -> usize { + pm.width() as usize * pm.height() as usize * 4 +} + /// Live-effect geometry of a node, kept across frames: the object-level result and each /// appearance item's own result on it (filled in as items are drawn). The node is kept alive, so /// its address names it alone and a hit is exact. @@ -285,6 +319,7 @@ impl Renderer { i: usize, fx: &RasterFx, reach: Rect, + owner: Option<&Arc>, paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), ) -> bool { let (mode, opacity, dx, dy, blur, color) = match fx { @@ -295,7 +330,7 @@ impl Renderer { let linear = linear_key(f.view); let key = (n as *const Node as usize, i, f.ink); let stamp = self.stamp; - let hit = self.shadows.get_mut(&key).filter(|e| e.linear == linear && e.node == *n); + let hit = self.shadows.get_mut(&key).filter(|e| e.linear == linear && e.node.is(n)); let (image, x, y) = match hit { Some(e) => { e.stamp = stamp; @@ -331,7 +366,9 @@ impl Renderer { let image = std::sync::Arc::new(pm); let anchor = reach.origin(); let p = f.view * anchor; - self.shadows.insert(key, ShadowEntry { node: n.clone(), linear, anchor, rel: (x0 - p.x, y0 - p.y), image: image.clone(), stamp }); + let node = Drawn::of(n, owner); + self.shadows.insert(key, ShadowEntry { node, linear, anchor, rel: (x0 - p.x, y0 - p.y), image: image.clone(), stamp }); + self.trim_fx_rasters(FX_RASTER_BUDGET); (image, x0, y0) } }; @@ -394,7 +431,7 @@ impl Renderer { // Document-space reach of the painted geometry (strokes, arrowheads…). let outset = n.appearance.outset(); let reach = g.bounding_box().inflate(outset, outset); - let content = Content { node: n, cache: Some(0), reach, outline: Some((g, rule)) }; + let content = Content { node: n, owner: None, cache: Some(0), reach, outline: Some((g, rule)) }; let mut items = items; self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.paint_items(c, fr, n, g, rule, &gctx, items.as_deref_mut())); ctx.set_transform(Affine::IDENTITY); @@ -429,7 +466,7 @@ impl Renderer { let region = if clipped { self.clip_of(&art) } else { None }; // A symbol's art can change while the instance doesn't: no cached shadows. let cache = (cache && !matches!(a.kind, NodeKind::SymbolInstance { .. })).then_some(0); - let content = Content { node: a, cache, reach, outline: region.as_deref().map(|r| (&r.0, r.1)) }; + let content = Content { node: a, owner: Some(a), cache, reach, outline: region.as_deref().map(|r| (&r.0, r.1)) }; self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.draw_node(c, fr, &art, true)); ctx.set_transform(Affine::IDENTITY); } @@ -464,7 +501,7 @@ impl Renderer { None => effects::outline_art(a, symbol.as_ref()).unwrap_or_else(|| Node::group(a.id, vec![])), }; let art = if effects::has_revolve(a) { effects::evaluate_container(&art).unwrap_or(art) } else { art }; - let mut art = self.adjusted_art(doc, art, effects::color_map(&a.appearance.effects).as_ref()); + let mut art = self.adjusted_art(art, effects::color_map(&a.appearance.effects).as_ref()); // A path's geometry effects still apply when it is drawn. let path = matches!(art.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }); art.appearance.effects.retain(|e| path && effects::is_geometry(&e.id)); @@ -490,18 +527,19 @@ impl Renderer { /// `art` with the colour adjustments inside it applied, then `outer` (its object's own) on /// everything ([`effects::adjust`]). Embedded images become recoloured copies, cached with the /// decoded images under a key naming the adjustment. - fn adjusted_art(&mut self, doc: &vectorcraft_doc::Document, mut art: Node, outer: Option<&effects::ColorMap>) -> Node { + fn adjusted_art(&mut self, mut art: Node, outer: Option<&effects::ColorMap>) -> Node { art.appearance.effects.retain(|e| !effects::is_adjustment(&e.id)); - let mut wanted: Vec<(String, String, effects::ColorMap)> = vec![]; + let mut wanted: Vec<(String, String, u64, effects::ColorMap)> = vec![]; let adjusted = effects::adjust(&art, outer, &mut |key, map| { let mut h = std::hash::DefaultHasher::new(); std::hash::Hash::hash(&format!("{map:?}"), &mut h); - let derived = format!("{key}\u{0}adjust\u{0}{:016x}", std::hash::Hasher::finish(&h)); - wanted.push((key.to_string(), derived.clone(), map.clone())); + let hash = std::hash::Hasher::finish(&h); + let derived = format!("{key}\u{0}adjust\u{0}{hash:016x}"); + wanted.push((key.to_string(), derived.clone(), hash, map.clone())); Some(derived) }); - for (key, derived, map) in wanted { - self.adjusted_image(doc, &key, derived, &map); + for (key, derived, hash, map) in wanted { + self.adjusted_image(&key, derived, hash, &map); } adjusted.unwrap_or(art) } @@ -518,10 +556,12 @@ impl Renderer { paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), ) { let reach = content.reach; + // The `Arc` drawn: the caller's, or the node `draw_arc` is drawing. + let owner = content.owner.cloned().or_else(|| self.fx_owner.clone().filter(|o| std::ptr::eq(&**o, content.node))); // Below the content: shadows and outer glows. for (i, fx) in rfx.iter().enumerate().filter(|(_, x)| x.is_below()) { if let Some(slot) = content.cache - && self.draw_shadow_cached(ctx, f, content.node, slot + i, fx, reach, paint) + && self.draw_shadow_cached(ctx, f, content.node, slot + i, fx, reach, owner.as_ref(), paint) { continue; } @@ -628,7 +668,7 @@ impl Renderer { let linear = linear_key(f.view); let key = content.cache.filter(|_| fits && k >= 1.0).map(|slot| (content.node as *const Node as usize, slot, f.ink)); let stamp = self.stamp; - if let Some(e) = key.and_then(|key| self.pixel_fx.get_mut(&key)).filter(|e| e.linear == linear && e.node == *content.node) { + if let Some(e) = key.and_then(|key| self.pixel_fx.get_mut(&key)).filter(|e| e.linear == linear && e.node.is(content.node)) { e.stamp = stamp; let p = f.view * e.anchor; let (image, at, k) = (e.image.clone(), ((p.x + e.rel.0).round(), (p.y + e.rel.1).round()), e.k); @@ -674,11 +714,38 @@ impl Renderer { let anchor = content.reach.origin(); let p = f.view * anchor; let rel = (x0 - p.x, y0 - p.y); - self.pixel_fx.insert(key, PixelEntry { node: content.node.clone(), linear, anchor, rel, k, image: image.clone(), stamp }); + let owner = content.owner.cloned().or_else(|| self.fx_owner.clone().filter(|o| std::ptr::eq(&**o, content.node))); + let node = Drawn::of(content.node, owner.as_ref()); + self.pixel_fx.insert(key, PixelEntry { node, linear, anchor, rel, k, image: image.clone(), stamp }); + self.trim_fx_rasters(FX_RASTER_BUDGET); } draw_scaled(ctx, image, (x0, y0), k); } + /// Drop the least recently drawn shadow and filtered-content rasters beyond `budget` bytes + /// ([`FX_RASTER_BUDGET`]). + fn trim_fx_rasters(&mut self, budget: usize) { + let shadows = self.shadows.iter().map(|(k, e)| (e.stamp, false, *k, raster_bytes(&e.image))); + let pixels = self.pixel_fx.iter().map(|(k, e)| (e.stamp, true, *k, raster_bytes(&e.image))); + let mut all: Vec<_> = shadows.chain(pixels).collect(); + let mut total: usize = all.iter().map(|e| e.3).sum(); + if total <= budget { + return; + } + all.sort_unstable_by_key(|e| e.0); + for (_, pixel, key, bytes) in all { + if total <= budget { + break; + } + if pixel { + self.pixel_fx.remove(&key); + } else { + self.shadows.remove(&key); + } + total -= bytes; + } + } + /// Keep `pm`'s pixels only inside `g` filled by `rule` as `frame` draws it (Feather's clip). fn clip_pixels(&mut self, pm: &mut vello_cpu::Pixmap, frame: &Frame, g: &BezPath, rule: FillRule) { let mut m = frame.offscreen_context(pm.width(), pm.height()); @@ -781,7 +848,8 @@ impl Renderer { let outset = n.appearance.outset(); let orule = if item.is_fill() { rule } else { FillRule::NonZero }; // Each item's shadows get their own cache slots. - let content = Content { node: n, cache: Some((j + 1) << 8), reach: ib.inflate(outset, outset), outline: Some((&outline, orule)) }; + let content = + Content { node: n, owner: None, cache: Some((j + 1) << 8), reach: ib.inflate(outset, outset), outline: Some((&outline, orule)) }; self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.paint_item(c, fr, n, item, ig, rule, ib)); ctx.set_transform(Affine::IDENTITY); } @@ -827,6 +895,7 @@ mod tests { use vectorcraft_doc::{Appearance, Document, Effect, Node}; use vectorcraft_geom::{Affine, Rect, shapes}; + use super::{Drawn, raster_bytes}; use crate::{RenderOptions, Rendered, Renderer}; fn doc_with(r: Rect, fill: Color, effects: Vec<(&str, serde_json::Value)>) -> Document { @@ -899,6 +968,27 @@ mod tests { assert!(edited.pixels == Renderer::new().render(&e, 400, 400, view, &opts).pixels, "and matches a fresh render"); } + /// Shadow rasters of objects in the tree are matched by the node's `Arc` (kept alive), not by + /// comparing nodes; over the byte budget the least recently drawn go first. + #[test] + fn shadow_rasters_keep_their_node_and_a_byte_budget() { + let d = effects_page(); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut r = Renderer::new(); + let first = r.render(&d, 400, 400, Affine::scale(2.0), &opts); + assert!(!r.shadows.is_empty() && r.shadows.values().all(|e| matches!(e.node, Drawn::Shared(_)))); + // A frame of other objects makes rasters of its own; the older ones go first. + let _ = r.render(&effects_page(), 400, 400, Affine::scale(2.0), &opts); + let newest = r.stamp; + let bytes = |r: &Renderer| r.shadows.values().map(|e| raster_bytes(&e.image)).sum::(); + let latest = r.shadows.values().filter(|e| e.stamp == newest).map(|e| raster_bytes(&e.image)).sum::(); + assert!(bytes(&r) > latest); + r.trim_fx_rasters(latest); + assert_eq!(bytes(&r), latest); + assert!(r.shadows.values().all(|e| e.stamp == newest)); + assert!(r.render(&d, 400, 400, Affine::scale(2.0), &opts).pixels == first.pixels, "made again alike"); + } + /// The multithreaded rasterizer draws the same pixels with any number of workers, so the /// automatic thread count can follow the machine. #[test] diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs new file mode 100644 index 00000000..4c41f71c --- /dev/null +++ b/crates/render/src/images.rs @@ -0,0 +1,282 @@ +//! Decoded images, shared by every [`Renderer`](crate::Renderer) in the process. +//! +//! The canvas, the render worker, thumbnails and panels each keep a renderer; were the decoded +//! pixels cached per renderer, a 24-megapixel photo would take 96 MB in each. Here they are kept +//! once, keyed by the image's content (not the document's key for it, which another document may +//! use for other pixels) and by what was made of them: greyscale (outline mode), recoloured by a +//! colour adjustment, separated into ink planes, or shrunk ([`RenderOptions::image_mips`](crate::RenderOptions::image_mips)). +//! +//! The store holds at most [`ImageStore::budget`] bytes, dropping the least recently used entries +//! beyond that, except ones still held elsewhere (a render context drawing them): dropping those +//! would free nothing and decode them again on the next request. + +use std::collections::HashMap; +use std::sync::{Arc, LazyLock, Mutex, MutexGuard, Weak}; + +use vectorcraft_doc::ImageBlob; +use vello_cpu::Pixmap; +use vello_cpu::color::PremulRgba8; + +use crate::ink::Ink; + +/// Largest default budget of the shared image store, in bytes; less on machines with under 8 GiB +/// of memory (an eighth of it). [`set_image_cache_budget`] changes it. +pub const IMAGE_CACHE_MAX: usize = 1 << 30; + +/// The default budget on the web (a tab's memory is tight). +#[cfg(target_arch = "wasm32")] +fn default_budget() -> usize { + 256 << 20 +} + +#[cfg(not(target_arch = "wasm32"))] +fn default_budget() -> usize { + physical_memory().map_or(IMAGE_CACHE_MAX, |m| usize::try_from(m / 8).unwrap_or(usize::MAX).min(IMAGE_CACHE_MAX)) +} + +/// Installed memory in bytes, where the system says without a native call (Linux). +#[cfg(target_os = "linux")] +fn physical_memory() -> Option { + let info = std::fs::read_to_string("/proc/meminfo").ok()?; + let line = info.lines().find(|l| l.starts_with("MemTotal:"))?; + let kib: u64 = line.split_whitespace().nth(1)?.parse().ok()?; + kib.checked_mul(1024) +} + +#[cfg(not(any(target_os = "linux", target_arch = "wasm32")))] +fn physical_memory() -> Option { + None +} + +/// What an image entry holds: an image's pixels as decoded or made from them. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) struct ImageKey { + /// The encoded image's content ([`ImageBlob::content_key`] and length). + pub content: Arc, + /// Recoloured by the colour adjustment with this hash (see `Renderer::adjusted_image`). + pub adjust: Option, + /// Greyscale (outline mode). + pub grey: bool, + /// The ink plane painted ([`Ink::Display`]: the colours themselves). + pub ink: Ink, + /// Generations of the colour tables the pixels were made with (0 = none): a CMYK image's + /// display table, and the separation table of ink planes (see `ink`). + pub luts: (u64, u64), + /// Halved this many times ([`half`]). + pub mip: u8, +} + +/// A blob's content key and whether it holds CMYK inks, worked out once per blob allocation. +#[derive(Clone)] +pub(crate) struct BlobId { + pub content: Arc, + pub cmyk: bool, +} + +struct Entry { + pm: Arc, + /// [`ImageStore::tick`] of the last request. + used: u64, +} + +/// Decoded images within a byte budget (see the module docs). +pub(crate) struct ImageStore { + entries: HashMap, + /// Bytes of pixels in `entries`. + bytes: usize, + budget: usize, + /// Request counter: the least recently used entries go first. + tick: u64, + /// [`BlobId`]s per blob byte buffer, by address (the `Weak` keeps it from being reused). + ids: HashMap>, BlobId)>, +} + +fn pixmap_bytes(pm: &Pixmap) -> usize { + pm.width() as usize * pm.height() as usize * 4 +} + +impl ImageStore { + pub(crate) fn new(budget: usize) -> Self { + Self { entries: HashMap::new(), bytes: 0, budget, tick: 0, ids: HashMap::new() } + } + + /// The pixels of `key`, marked as just used. + pub(crate) fn get(&mut self, key: &ImageKey) -> Option> { + self.tick += 1; + let e = self.entries.get_mut(key)?; + e.used = self.tick; + Some(e.pm.clone()) + } + + /// The pixels of `key` without marking them used: a shrunk copy is drawn instead, so the full + /// image may be dropped first. + pub(crate) fn peek(&self, key: &ImageKey) -> Option> { + self.entries.get(key).map(|e| e.pm.clone()) + } + + /// Keep `pm` as `key` (or, when another renderer made it meanwhile, return theirs), then drop + /// entries over budget. + pub(crate) fn insert(&mut self, key: ImageKey, pm: Arc) -> Arc { + self.tick += 1; + let tick = self.tick; + if let Some(e) = self.entries.get_mut(&key) { + e.used = tick; + return e.pm.clone(); + } + self.bytes += pixmap_bytes(&pm); + self.entries.insert(key, Entry { pm: pm.clone(), used: tick }); + self.evict(); + pm + } + + /// Drop the least recently used entries no one else holds until within budget. + fn evict(&mut self) { + if self.bytes <= self.budget { + return; + } + let mut free: Vec<(u64, ImageKey)> = + self.entries.iter().filter(|(_, e)| Arc::strong_count(&e.pm) == 1).map(|(k, e)| (e.used, k.clone())).collect(); + free.sort_unstable_by_key(|(used, _)| *used); + for (_, k) in free { + if self.bytes <= self.budget { + break; + } + if let Some(e) = self.entries.remove(&k) { + self.bytes = self.bytes.saturating_sub(pixmap_bytes(&e.pm)); + } + } + } + + pub(crate) fn set_budget(&mut self, budget: usize) { + self.budget = budget; + self.evict(); + } + + pub(crate) fn bytes(&self) -> usize { + self.bytes + } + + fn known_id(&self, bytes: &Arc>) -> Option { + let (w, id) = self.ids.get(&(Arc::as_ptr(bytes) as usize))?; + std::ptr::eq(w.as_ptr(), Arc::as_ptr(bytes)).then(|| id.clone()) + } + + fn remember_id(&mut self, bytes: &Arc>, id: BlobId) { + if self.ids.len() > 1024 { + self.ids.retain(|_, (w, _)| w.strong_count() > 0); + } + self.ids.insert(Arc::as_ptr(bytes) as usize, (Arc::downgrade(bytes), id)); + } +} + +static STORE: LazyLock> = LazyLock::new(|| Mutex::new(ImageStore::new(default_budget()))); + +/// The process's image store (still usable after a panic elsewhere while it was locked: every +/// change to it is complete when made). +pub(crate) fn store() -> MutexGuard<'static, ImageStore> { + STORE.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// Set the byte budget of the decoded images all renderers share (see [`IMAGE_CACHE_MAX`]). +pub fn set_image_cache_budget(bytes: usize) { + store().set_budget(bytes); +} + +/// Bytes of decoded images the renderers share now. +pub fn image_cache_bytes() -> usize { + store().bytes() +} + +/// `blob`'s content key and kind, hashed and sniffed once per blob allocation. +pub(crate) fn blob_id(blob: &ImageBlob) -> BlobId { + if let Some(id) = store().known_id(&blob.bytes) { + return id; + } + // Outside the lock: hashing a large image takes a while. + let id = BlobId { content: format!("{}:{}", blob.content_key(), blob.bytes.len()).into(), cmyk: blob.is_cmyk() }; + store().remember_id(&blob.bytes, id.clone()); + id +} + +/// `pm` at half the size (rounded up), each pixel the mean of the 2×2 it covers (the last row and +/// column repeat on odd sizes). +pub(crate) fn half(pm: &Pixmap) -> Pixmap { + let (w, h) = (pm.width() as usize, pm.height() as usize); + let (ow, oh) = (w.div_ceil(2).max(1), h.div_ceil(2).max(1)); + let src = pm.data(); + let at = |x: usize, y: usize| { + src.get(y.min(h.saturating_sub(1)) * w + x.min(w.saturating_sub(1))).copied().unwrap_or(PremulRgba8 { r: 0, g: 0, b: 0, a: 0 }) + }; + let mut data = Vec::with_capacity(ow * oh); + for y in 0..oh { + for x in 0..ow { + let q = [at(2 * x, 2 * y), at(2 * x + 1, 2 * y), at(2 * x, 2 * y + 1), at(2 * x + 1, 2 * y + 1)]; + let mean = |c: fn(&PremulRgba8) -> u8| ((q.iter().map(|p| u32::from(c(p))).sum::() + 2) / 4) as u8; + data.push(PremulRgba8 { r: mean(|p| p.r), g: mean(|p| p.g), b: mean(|p| p.b), a: mean(|p| p.a) }); + } + } + Pixmap::from_parts(data, ow as u16, oh as u16) +} + +/// How many times to halve an image drawn with `m` (image pixels → device pixels): while a pixel +/// of the halved image still covers at most one device pixel each way, at most down to one pixel. +pub(crate) fn mip_level(m: vectorcraft_geom::Affine, w: u16, h: u16) -> u8 { + let [a, b, c, d, _, _] = m.as_coeffs(); + // The largest stretch of the linear part (its greater singular value). + let (p, q) = (a * a + b * b + c * c + d * d, a * d - b * c); + let s = ((p + ((p * p - 4.0 * q * q).max(0.0)).sqrt()) / 2.0).sqrt(); + if !(s.is_finite() && s > 0.0 && s <= 0.5) { + return 0; + } + // Halvings until the longer side is one pixel: ⌈log₂ side⌉. + let most = (u32::BITS - (u32::from(w.max(h).max(1)) - 1).leading_zeros()) as u8; + ((-s.log2()).floor().clamp(0.0, 15.0) as u8).min(most) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(content: &str) -> ImageKey { + ImageKey { content: content.into(), adjust: None, grey: false, ink: Ink::Display, luts: (0, 0), mip: 0 } + } + + #[test] + fn budget_drops_least_recently_used_unless_held() { + // 10×10 pixmaps: 400 bytes each; room for two. + let mut s = ImageStore::new(800); + let a = s.insert(key("a"), Arc::new(Pixmap::new(10, 10))); + drop(a); + let held = s.insert(key("b"), Arc::new(Pixmap::new(10, 10))); + assert!(s.get(&key("a")).is_some(), "a is now the most recent"); + let _ = s.insert(key("c"), Arc::new(Pixmap::new(10, 10))); + assert_eq!(s.bytes(), 800); + assert!(s.peek(&key("b")).is_some(), "b is held outside the store: kept though least recently used"); + assert!(s.peek(&key("a")).is_none(), "a went instead"); + drop(held); + s.set_budget(400); + assert!(s.peek(&key("b")).is_none() && s.peek(&key("c")).is_some(), "now b goes first"); + assert_eq!(s.bytes(), 400); + } + + #[test] + fn half_averages_and_rounds_up_odd_sizes() { + let px = |v: u8| PremulRgba8 { r: v, g: v, b: v, a: 255 }; + let pm = Pixmap::from_parts(vec![px(0), px(100), px(200), px(40), px(60), px(80)], 3, 2); + let h = half(&pm); + assert_eq!((h.width(), h.height()), (2, 1)); + assert_eq!(h.data()[0].r, 50); + assert_eq!(h.data()[1].r, 140, "the last column repeats"); + } + + #[test] + fn mip_level_follows_the_largest_stretch() { + use vectorcraft_geom::Affine; + assert_eq!(mip_level(Affine::scale(1.0), 1000, 1000), 0); + assert_eq!(mip_level(Affine::scale(0.6), 1000, 1000), 0); + assert_eq!(mip_level(Affine::scale(0.5), 1000, 1000), 1); + assert_eq!(mip_level(Affine::scale(0.2), 1000, 1000), 2); + assert_eq!(mip_level(Affine::scale_non_uniform(0.1, 0.9), 1000, 1000), 0, "never blurrier than one axis needs"); + assert_eq!(mip_level(Affine::scale(0.001), 8, 8), 3, "down to one pixel at most"); + } +} diff --git a/crates/render/src/ink.rs b/crates/render/src/ink.rs index 144f818a..48e0fe49 100644 --- a/crates/render/src/ink.rs +++ b/crates/render/src/ink.rs @@ -15,6 +15,7 @@ use std::cell::RefCell; use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use vectorcraft_color::Color; @@ -26,7 +27,8 @@ use vello_cpu::Pixmap; use vello_cpu::color::PremulRgba8; use vello_cpu::peniko; -use crate::{RenderOptions, Renderer}; +use crate::RenderOptions; +use crate::images::{self, ImageKey}; /// What a frame paints for colours. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] @@ -138,27 +140,29 @@ impl InkLut { } } -/// Placed images as painted with [`Ink::Cmy`] and [`Ink::K`], per image key, with the tables they -/// were separated by. -pub(crate) type InkImages = HashMap, [Arc; 2])>; +/// A table built for the colour settings it was built with, and its generation (see [`cached`]). +type Cached = Mutex, Arc, u64)>>; -/// A table built for the colour settings it was built with. -type Cached = Mutex, Arc)>>; +/// The last table generation handed out: images made with a table are cached under its +/// generation (see [`crate::images`]), and none is ever reused. +static GENERATION: AtomicU64 = AtomicU64::new(0); static DISPLAY: Cached = Mutex::new(None); /// Screen colours → [`Ink::Cmy`] and [`Ink::K`] values (placed images). static SEPARATE: Cached<[ProofLut; 2]> = Mutex::new(None); -/// The table in `slot` for the active colour settings, built by `build` when they changed. -fn cached(slot: &Cached, build: impl FnOnce(&Cms) -> T) -> Arc { +/// The table in `slot` for the active colour settings, built by `build` when they changed, and +/// its generation. +fn cached(slot: &Cached, build: impl FnOnce(&Cms) -> T) -> (Arc, u64) { let cms = cms::active(); let mut s = slot.lock().unwrap_or_else(|e| e.into_inner()); match &*s { - Some((owner, t)) if Arc::ptr_eq(owner, &cms) => t.clone(), + Some((owner, t, g)) if Arc::ptr_eq(owner, &cms) => (t.clone(), *g), _ => { let t = Arc::new(build(&cms)); - *s = Some((cms, t.clone())); - t + let g = GENERATION.fetch_add(1, Ordering::Relaxed) + 1; + *s = Some((cms, t.clone(), g)); + (t, g) } } } @@ -167,7 +171,7 @@ fn cached(slot: &Cached, build: impl FnOnce(&Cms) -> T) -> Arc { /// [`Ink::Cmy`] and [`Ink::K`]): each pixel's inks shown through the working CMYK profile. Where /// only the frame's `background` shows it keeps its exact screen colour. pub(crate) fn compose(cmy: &[u8], k: &[u8], background: Option<[u8; 4]>) -> Vec { - let lut = cached(&DISPLAY, InkLut::build); + let (lut, _) = cached(&DISPLAY, InkLut::build); let bg = background.map(|b| (Ink::Cmy.fixed8(b), Ink::K.fixed8(b), b)); let mut out = Vec::with_capacity(cmy.len()); // Runs of one colour convert once. @@ -205,9 +209,6 @@ pub(crate) fn amounts(cmy: &[u8], k: &[u8]) -> Vec { .collect() } -/// CMYK images as shown, per image key, with the table they were shown through. -pub(crate) type CmykImages = HashMap, Arc)>; - /// Ink amounts `inks` shown through `lut` (opaque). fn inks_pixmap(inks: &Inks, lut: &InkLut) -> Option { let (w, h) = (u16::try_from(inks.width).ok()?, u16::try_from(inks.height).ok()?); @@ -231,70 +232,65 @@ fn inks_pixmap(inks: &Inks, lut: &InkLut) -> Option { Some(Pixmap::from_parts(data, w, h)) } -impl Renderer { - /// The pixels of CMYK image `blob` (cached as `cache_key`): its inks shown through the working - /// CMYK profile, as CMYK colours are, again when the colour settings change. `None` for - /// other images. - pub(crate) fn cmyk_image(&mut self, blob: &ImageBlob, cache_key: &str) -> Option> { - // Other images are cached as they are once decoded: only CMYK ones come here again. - let shown = self.cmyk_images.get(cache_key).cloned(); - if shown.is_none() && !blob.is_cmyk() { - return None; - } - let lut = cached(&DISPLAY, InkLut::build); - if let Some((owner, pm)) = shown - && Arc::ptr_eq(&owner, &lut) - { - return Some(pm); - } - let pm = Arc::new(inks_pixmap(&blob.cmyk()?, &lut)?); - self.cmyk_images.insert(cache_key.to_string(), (lut, pm.clone())); - Some(pm) - } +/// The working CMYK profile's display table (what CMYK images show through) and its generation. +pub(crate) fn display_lut() -> (Arc, u64) { + cached(&DISPLAY, InkLut::build) +} - /// Placed image `key` (decoded as `pm`, from `blob`) as painted with `ink`, cached. - pub(crate) fn ink_image(&mut self, key: &str, pm: &Arc, ink: Ink, blob: Option<&ImageBlob>) -> Arc { - if ink == Ink::Display { - return pm.clone(); - } - let luts = cached(&SEPARATE, |cms| { - let intent = cms.settings().intent; - [Ink::Cmy, Ink::K].map(|ink| ProofLut::build(|rgb| ink.plane(cms.srgb_to_cmyk(rgb, intent)))) - }); - let at = (ink == Ink::K) as usize; - if let Some((owner, planes)) = self.ink_images.get(key) - && Arc::ptr_eq(owner, &luts) - { - return planes[at].clone(); - } - // CMYK images paint their own inks; the others are separated. - let size = (pm.width() as u32, pm.height() as u32); - let inks = blob.filter(|_| self.cmyk_images.contains_key(key)).and_then(ImageBlob::cmyk).filter(|i| (i.width, i.height) == size); - let planes = [Ink::Cmy, Ink::K].map(|plane| { - let mut out = Pixmap::new(pm.width(), pm.height()); - if let Some(inks) = &inks { - for (o, p) in out.data_mut().iter_mut().zip(inks.data.as_chunks::<4>().0) { - let [r, g, b] = plane.plane(p.map(|v| v as f32 / 255.0)).map(q); - *o = PremulRgba8 { r, g, b, a: 255 }; - } - return Arc::new(out); - } - let lut = &luts[(plane == Ink::K) as usize]; - for (o, s) in out.data_mut().iter_mut().zip(pm.data()) { - let a = s.a; - if a == 0 { - continue; - } - let un = |v: u8| (v as f32 / a as f32).min(1.0); - let [r, g, b] = lut.apply([un(s.r), un(s.g), un(s.b)]).map(|v| (v.clamp(0.0, 1.0) * a as f32).round() as u8); - *o = PremulRgba8 { r, g, b, a }; - } - Arc::new(out) - }); - let out = planes[at].clone(); - self.ink_images.insert(key.to_string(), (luts, planes)); - out +/// The pixels of CMYK image `blob`: its inks shown through `lut`. `None` for other images. +pub(crate) fn cmyk_pixmap(blob: &ImageBlob, lut: &InkLut) -> Option { + inks_pixmap(&blob.cmyk()?, lut) +} + +/// Image pixels `pm` (cached as `key`, from `blob` when it is a document's image as it is) as +/// painted with `ink`, cached with the images, and their key. +pub(crate) fn ink_image(key: &ImageKey, pm: &Arc, ink: Ink, blob: Option<&ImageBlob>, touch: bool) -> (ImageKey, Arc) { + if ink == Ink::Display { + return (key.clone(), pm.clone()); } + let (luts, generation) = cached(&SEPARATE, |cms| { + let intent = cms.settings().intent; + [Ink::Cmy, Ink::K].map(|ink| ProofLut::build(|rgb| ink.plane(cms.srgb_to_cmyk(rgb, intent)))) + }); + let plane_key = |plane: Ink| ImageKey { ink: plane, luts: (key.luts.0, generation), ..key.clone() }; + let want = plane_key(ink); + let hit = { + let mut s = images::store(); + if touch { s.get(&want) } else { s.peek(&want) } + }; + if let Some(p) = hit { + return (want, p); + } + // CMYK images (shown through the display table) paint their own inks; the others are separated. + let size = (pm.width() as u32, pm.height() as u32); + let cmyk = key.luts.0 != 0 && key.adjust.is_none() && !key.grey; + let inks = blob.filter(|_| cmyk).and_then(ImageBlob::cmyk).filter(|i| (i.width, i.height) == size); + let planes = [Ink::Cmy, Ink::K].map(|plane| { + let mut out = Pixmap::new(pm.width(), pm.height()); + if let Some(inks) = &inks { + for (o, p) in out.data_mut().iter_mut().zip(inks.data.as_chunks::<4>().0) { + let [r, g, b] = plane.plane(p.map(|v| v as f32 / 255.0)).map(q); + *o = PremulRgba8 { r, g, b, a: 255 }; + } + return Arc::new(out); + } + let lut = &luts[(plane == Ink::K) as usize]; + for (o, s) in out.data_mut().iter_mut().zip(pm.data()) { + let a = s.a; + if a == 0 { + continue; + } + let un = |v: u8| (v as f32 / a as f32).min(1.0); + let [r, g, b] = lut.apply([un(s.r), un(s.g), un(s.b)]).map(|v| (v.clamp(0.0, 1.0) * a as f32).round() as u8); + *o = PremulRgba8 { r, g, b, a }; + } + Arc::new(out) + }); + let [cmy, k] = planes; + let mut s = images::store(); + let cmy = s.insert(plane_key(Ink::Cmy), cmy); + let k = s.insert(plane_key(Ink::K), k); + (want, if ink == Ink::K { k } else { cmy }) } #[cfg(test)] @@ -324,6 +320,8 @@ mod tests { use vectorcraft_doc::{Appearance, ImageObject, Node, NodeKind}; use vectorcraft_geom::{Affine, Rect, shapes}; + use crate::Renderer; + let ink = [0u8, 102, 255, 25]; let mut d = Document::new_with_mode(20.0, 10.0, ColorMode::Cmyk); d.images.insert("img".into(), ImageBlob::cmyk_tiff(&Inks::new(2, 2, ink.repeat(4)).unwrap()).unwrap()); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 36bc4b06..cca21739 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -11,6 +11,7 @@ pub mod encode; mod freeform; mod fx; mod group; +mod images; mod ink; mod live; mod paint; @@ -32,6 +33,7 @@ use ink::Ink; pub use brush_fx::instance_art; pub use effects::stroke::width_outline; +pub use images::{IMAGE_CACHE_MAX, image_cache_bytes, set_image_cache_budget}; pub use live::expand_live; pub use pattern::render_pattern_swatch; pub use vectorcraft_effects as effects; @@ -119,6 +121,10 @@ pub struct RenderOptions { /// edge leave no seam of the background between them (#983). Off, edges are smoothed shape by /// shape (the canvas). pub supersample: bool, + /// Screen views: an image drawn at half its size or smaller samples a copy shrunk by halves + /// (box-filtered from the full image, kept with the decoded images), so the full image needn't + /// stay in memory or be sampled. Off, images always sample their full pixels (exports). + pub image_mips: bool, } /// The opacity of the art around an isolated group or layer. @@ -192,6 +198,7 @@ impl Default for RenderOptions { isolated: None, precise: false, supersample: false, + image_mips: false, } } } @@ -340,7 +347,8 @@ type ClipEntry = (Arc, Option>); /// A clipping path (kept alive) and what it paints: its fills and its strokes ([`Node::clip_paint`]). type ClipPaintEntry = (Arc, Option>, Option>); -/// Reusable renderer (keeps the render context, decoded images and glyph caches between frames). +/// Reusable renderer (keeps the render context, geometry and glyph caches between frames; decoded +/// images are shared by all renderers, see [`set_image_cache_budget`]). pub struct Renderer { texts: PtrMap, Arc)>, /// The [`vectorcraft_text::FontDb::generation`] `texts` was laid out with: type lays out @@ -367,7 +375,6 @@ pub struct Renderer { alpha: f32, ctx: Option, resources: Resources, - images: HashMap>, /// Statistics of the last frame. pub stats: FrameStats, /// Brush art per brushed stroke. @@ -399,15 +406,11 @@ pub struct Renderer { clip_paths: Vec<(BezPath, FillRule, Affine)>, /// What clipping paths paint, per clipping path (see [`Self::clip_paint_of`]). clip_paints: PtrMap, - /// Placed images as painted in ink planes (see [`ink`]). - ink_images: ink::InkImages, - /// CMYK images as shown through the working CMYK profile (see [`ink`]). - cmyk_images: ink::CmykImages, /// Gradients along or across strokes, keyed like `strokes` (see [`Self::fill_path_gradient`]). stroke_slices: PtrMap<(usize, i32), SliceEntry>, - /// The keys and sizes of the recoloured images colour adjustments made in `images`, oldest - /// first (see [`Self::adjusted_image`]). - adjusted: std::collections::VecDeque<(String, usize)>, + /// What the image keys colour adjustments give their recoloured images name: the image + /// recoloured and the adjustment, with its hash (see [`Self::adjusted_image`]). + adjusts: HashMap, /// Layer Options → Dim Images to, of the layer being drawn (screen views only): images show /// faded to this opacity over white. dim_images: Option, @@ -490,7 +493,6 @@ impl Renderer { alpha: 1.0, ctx: None, resources: Resources::new(), - images: HashMap::new(), stats: FrameStats::default(), brushes: Default::default(), shadows: PtrMap::default(), @@ -504,10 +506,8 @@ impl Renderer { blends: PtrMap::default(), clip_paths: vec![], clip_paints: PtrMap::default(), - ink_images: Default::default(), - cmyk_images: Default::default(), stroke_slices: PtrMap::default(), - adjusted: Default::default(), + adjusts: HashMap::new(), dim_images: None, inline_depth: 0, raster: vello_cpu::RasterizerSettings::default(), @@ -547,10 +547,11 @@ impl Renderer { let visible = inv.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64)); let px = 1.0 / view.determinant().abs().sqrt().max(1e-12); let frame = Frame { mt: threads > 0, doc, view, visible, px, opts, ink: Ink::Display }; - self.stamp += 1; + self.begin_frame(); // View Opacity Mask: the mask's coverage in greyscale instead of the artwork. if let Some(m) = opts.mask_view.and_then(|id| doc.node(id)).and_then(|n| n.mask.as_deref()) { let pixels = self.mask_values(&frame, m, w, h).into_iter().flat_map(|v| [v, v, v, 255]).collect(); + self.end_frame(); self.stats.micros = now().saturating_sub(start); return Rendered { width: w as u32, height: h as u32, pixels }; } @@ -564,7 +565,22 @@ impl Renderer { } else { self.draw_frame(&frame, w, h) }; - // Drop cache entries not seen for a few frames. + self.end_frame(); + if !inks { + proof::post(&mut pixels, opts); + } + self.stats.micros = now().saturating_sub(start); + Rendered { width: w as u32, height: h as u32, pixels } + } + + /// Start a frame: cache entries used from now on are marked with its stamp. + fn begin_frame(&mut self) { + self.stamp += 1; + } + + /// End a frame: drop cache entries not used for a few frames (every render, thumbnails too, + /// so the nodes they hold go with them). + fn end_frame(&mut self) { let g = self.stamp; if self.geom.len() > 1024 { self.geom.retain(|_, e| g - e.stamp <= 3); @@ -583,11 +599,6 @@ impl Renderer { } // Filtered rasters are large: only those of the last frame stay. self.pixel_fx.retain(|_, e| e.stamp == g); - if !inks { - proof::post(&mut pixels, opts); - } - self.stats.micros = now().saturating_sub(start); - Rendered { width: w as u32, height: h as u32, pixels } } /// Draw frame `f` (`w`×`h` pixels): the background, the artboards and the page's art. @@ -705,18 +716,37 @@ impl Renderer { let mut ctx = single_threaded_context(w, h); let opts = RenderOptions::default(); let frame = Frame { mt: false, doc, view, visible, px, opts: &opts, ink: Ink::Display }; + self.begin_frame(); (self.knockout, self.nested, self.backdrop) = (false, 0, None); self.clip_paths.clear(); self.draw_arc(&mut ctx, &frame, n); ctx.flush(); let mut pm = Pixmap::new(w, h); ctx.render(&mut pm, &mut self.resources); + self.end_frame(); Some(Rendered { width: w as u32, height: h as u32, pixels: pm.data_as_u8_slice().to_vec() }) } /// Render any node (also one outside the tree, e.g. opacity-mask art) fitted into /// `size`×`size` pixels, optionally over a solid premultiplied background. pub fn render_node_thumbnail(&mut self, doc: &Document, n: &Node, size: u32, background: Option<[u8; 4]>) -> Option { + self.render_node_thumbnail_with(doc, n, size, background, &RenderOptions::default()) + } + + /// [`Self::render_thumbnail`] with `opts` (e.g. [`RenderOptions::image_mips`]). + pub fn render_thumbnail_with(&mut self, doc: &Document, id: NodeId, size: u32, opts: &RenderOptions) -> Option { + self.render_node_thumbnail_with(doc, doc.node(id)?, size, None, opts) + } + + /// [`Self::render_node_thumbnail`] with `opts` (e.g. [`RenderOptions::image_mips`]). + pub fn render_node_thumbnail_with( + &mut self, + doc: &Document, + n: &Node, + size: u32, + background: Option<[u8; 4]>, + opts: &RenderOptions, + ) -> Option { let b = brush_fx::cull_bounds(n)?; let s = (size as f64 - 2.0) / b.width().max(b.height()).max(1e-6); let view = Affine::translate((size as f64 / 2.0, size as f64 / 2.0)) * Affine::scale(s) * Affine::translate(-b.center().to_vec2()); @@ -726,13 +756,15 @@ impl Renderer { ctx.set_paint(peniko::Color::from_rgba8(bg[0], bg[1], bg[2], bg[3])); ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, w as f64)); } - let frame = Frame { mt: false, doc, view, visible: b.inflate(1.0, 1.0), px: 1.0 / s, opts: &RenderOptions::default(), ink: Ink::Display }; + let frame = Frame { mt: false, doc, view, visible: b.inflate(1.0, 1.0), px: 1.0 / s, opts, ink: Ink::Display }; + self.begin_frame(); (self.knockout, self.nested, self.backdrop) = (false, 0, None); self.clip_paths.clear(); self.draw_node(&mut ctx, &frame, n, true); ctx.flush(); let mut pm = Pixmap::new(w, w); ctx.render(&mut pm, &mut self.resources); + self.end_frame(); Some(Rendered { width: size, height: size, pixels: pm.data_as_u8_slice().to_vec() }) } @@ -1569,14 +1601,12 @@ impl Renderer { // Outline mode draws the image's frame, and with Document Setup → Show Images in Outline // Mode its pixels in greyscale under the frame. let outline = f.opts.outline; - // A linked image's preview is cached apart from the file's pixels, which share its key. - let cache_key = match f.doc.images.get(&im.key) { - Some(b) if b.is_proxy() => std::borrow::Cow::Owned(format!("{}\u{0}proxy", im.key)), - _ => std::borrow::Cow::Borrowed(im.key.as_str()), - }; - let pixels = if outline && !f.doc.setup.outline_images { None } else { self.image_pixmap(f.doc, &im.key, &cache_key, outline) }; - if let Some(pm) = pixels { - let pm = if outline { pm } else { self.ink_image(&cache_key, &pm, f.ink, f.doc.images.get(&im.key)) }; + // With mips the full pixels are only looked at (not marked used) until they're drawn. + let mips = f.opts.image_mips; + let pixels = if outline && !f.doc.setup.outline_images { None } else { self.image_pixmap(f.doc, &im.key, outline, !mips) }; + if let Some((key, pm)) = pixels { + let (key, pm) = if outline { (key, pm) } else { ink::ink_image(&key, &pm, f.ink, f.doc.images.get(&im.key), !mips) }; + let pm = if mips { image_mip(&key, pm, f.view * im.xf * Affine::scale_non_uniform(im.width as f64, im.height as f64)) } else { pm }; let sx = im.width as f64 / pm.width().max(1) as f64; let sy = im.height as f64 / pm.height().max(1) as f64; let quality = if f.opts.smooth_images { peniko::ImageQuality::Medium } else { peniko::ImageQuality::Low }; @@ -1619,60 +1649,57 @@ impl Renderer { } } - /// Cache the pixels of image blob `key` recoloured by `map` as `derived` (the key the adjusted - /// art's image takes). The latest ones are kept up to a memory budget: dropping one drops the - /// cached art too, so it is made again. - fn adjusted_image(&mut self, doc: &Document, key: &str, derived: String, map: &vectorcraft_effects::ColorMap) { - const BUDGET: usize = 256 << 20; - if self.images.contains_key(&derived) { + /// Name image `key` recoloured by `map` (its hash `hash`) as `derived` (the key the adjusted + /// art's image takes): drawing `derived` draws the recoloured pixels, made when first drawn and + /// kept with the decoded images. Names past a limit drop the art made with them too, so it is + /// made again. + fn adjusted_image(&mut self, key: &str, derived: String, hash: u64, map: &vectorcraft_effects::ColorMap) { + if self.adjusts.contains_key(&derived) { return; } - let Some(src) = self.image_pixmap(doc, key, key, false) else { return }; - let mut pm = (*src).clone(); - let mut memo: HashMap<[u8; 4], [u8; 3]> = HashMap::new(); - for px in pm.data_mut().iter_mut().filter(|p| p.a > 0) { - let a = px.a as u32; - let [r, g, b] = *memo.entry([px.r, px.g, px.b, px.a]).or_insert_with(|| { - // Premultiplied to straight, adjusted, then premultiplied again. - let straight = [px.r, px.g, px.b].map(|v| ((v as u32 * 255 + a / 2) / a).min(255) as u8); - map.apply_rgb8(straight).map(|v| ((v as u32 * a + 127) / 255) as u8) - }); - (px.r, px.g, px.b) = (r, g, b); - } - let bytes = pm.data().len() * 4; - self.images.insert(derived.clone(), Arc::new(pm)); - self.adjusted.push_back((derived, bytes)); - let mut total: usize = self.adjusted.iter().map(|(_, b)| b).sum(); - while total > BUDGET && self.adjusted.len() > 1 { - let Some((old, b)) = self.adjusted.pop_front() else { break }; - self.images.remove(&old); + if self.adjusts.len() >= 1024 { + self.adjusts.clear(); self.fx_arts.clear(); - total -= b; } + self.adjusts.insert(derived, (key.to_string(), hash, map.clone())); } - /// The decoded pixels of image blob `key` (cached as `cache_key`), or a greyscale copy of them. - fn image_pixmap(&mut self, doc: &Document, key: &str, cache_key: &str, grey: bool) -> Option> { - let colour = match self.images.get(cache_key) { - Some(p) => p.clone(), + /// The pixels of image `key` (a blob of `doc`, or a recoloured one named by + /// [`Self::adjusted_image`]), or a greyscale copy of them, with their key in the shared store. + /// `touch` marks them used there. + fn image_pixmap(&mut self, doc: &Document, key: &str, grey: bool, touch: bool) -> Option<(images::ImageKey, Arc)> { + let (key, colour) = match doc.images.get(key) { + Some(blob) => blob_pixmap(blob, touch)?, None => { - let blob = doc.images.get(key)?; - match self.cmyk_image(blob, cache_key) { - Some(pm) => pm, + let (src, hash, map) = self.adjusts.get(key)?.clone(); + let (src_key, src) = self.image_pixmap(doc, &src, false, touch)?; + let key = images::ImageKey { adjust: Some(hash), ..src_key }; + match lookup(&key, touch) { + Some(pm) => (key, pm), None => { - let pm = Arc::new(paint::decode_pixmap(&blob.bytes)?); - self.images.insert(cache_key.to_string(), pm.clone()); - pm + let mut pm = (*src).clone(); + let mut memo: HashMap<[u8; 4], [u8; 3]> = HashMap::new(); + for px in pm.data_mut().iter_mut().filter(|p| p.a > 0) { + let a = px.a as u32; + let [r, g, b] = *memo.entry([px.r, px.g, px.b, px.a]).or_insert_with(|| { + // Premultiplied to straight, adjusted, then premultiplied again. + let straight = [px.r, px.g, px.b].map(|v| ((v as u32 * 255 + a / 2) / a).min(255) as u8); + map.apply_rgb8(straight).map(|v| ((v as u32 * a + 127) / 255) as u8) + }); + (px.r, px.g, px.b) = (r, g, b); + } + let pm = images::store().insert(key.clone(), Arc::new(pm)); + (key, pm) } } } }; if !grey { - return Some(colour); + return Some((key, colour)); } - let grey_key = format!("{cache_key}\u{0}grey"); - if let Some(p) = self.images.get(&grey_key) { - return Some(p.clone()); + let key = images::ImageKey { grey: true, ..key }; + if let Some(p) = lookup(&key, touch) { + return Some((key, p)); } let mut pm = (*colour).clone(); for px in pm.data_mut() { @@ -1680,12 +1707,62 @@ impl Renderer { let l = (0.2126 * px.r as f32 + 0.7152 * px.g as f32 + 0.0722 * px.b as f32 + 0.5) as u8; (px.r, px.g, px.b) = (l, l, l); } - let pm = Arc::new(pm); - self.images.insert(grey_key, pm.clone()); - Some(pm) + let pm = images::store().insert(key.clone(), Arc::new(pm)); + Some((key, pm)) } } +/// `key`'s pixels in the shared image store; `touch` marks them used. +fn lookup(key: &images::ImageKey, touch: bool) -> Option> { + let mut s = images::store(); + if touch { s.get(key) } else { s.peek(key) } +} + +/// The decoded pixels of `blob` (a CMYK image's inks shown through the working CMYK profile, again +/// when the colour settings change), from the shared store, and their key there. +fn blob_pixmap(blob: &vectorcraft_doc::ImageBlob, touch: bool) -> Option<(images::ImageKey, Arc)> { + let id = images::blob_id(blob); + let lut = id.cmyk.then(ink::display_lut); + let key = + images::ImageKey { content: id.content, adjust: None, grey: false, ink: Ink::Display, luts: (lut.as_ref().map_or(0, |l| l.1), 0), mip: 0 }; + if let Some(pm) = lookup(&key, touch) { + return Some((key, pm)); + } + // Decoded outside the lock: other renderers carry on meanwhile. + let pm = match lut.and_then(|(lut, _)| ink::cmyk_pixmap(blob, &lut)) { + Some(pm) => pm, + None => paint::decode_pixmap(&blob.bytes)?, + }; + let pm = images::store().insert(key.clone(), Arc::new(pm)); + Some((key, pm)) +} + +/// Image pixels `pm` (stored as `key`) to draw through `m` (unit square → device pixels), shrunk +/// by halves while still at least as fine as the device pixels ([`images::mip_level`]); the +/// pixels drawn are marked used in the store. +fn image_mip(key: &images::ImageKey, pm: Arc, m: Affine) -> Arc { + let (w, h) = (pm.width(), pm.height()); + let level = images::mip_level(m * Affine::scale_non_uniform(1.0 / w.max(1) as f64, 1.0 / h.max(1) as f64), w, h); + let mut s = images::store(); + if level == 0 { + let _ = s.get(key); // Marks the full pixels used (they were only looked at). + return pm; + } + let at = |l: u8| images::ImageKey { mip: l, ..key.clone() }; + if let Some(p) = s.get(&at(level)) { + return p; + } + // From the finest level kept below it. + let (mut l, mut cur) = (1..level).rev().find_map(|l| s.peek(&at(l)).map(|p| (l, p))).unwrap_or((0, pm)); + drop(s); + while l < level { + l += 1; + let half = Arc::new(images::half(&cur)); + cur = images::store().insert(at(l), half); + } + cur +} + /// Can image node `n` (`im`) clipped by `region` be painted straight into the region: visible, /// with no transparency, effects or paint of its own, and the region inside the image's frame /// (within a pixel), so no paint past the image's edge shows? @@ -1934,6 +2011,8 @@ mod tests_knockout; #[cfg(test)] mod tests_layeropts; #[cfg(test)] +mod tests_memory; +#[cfg(test)] mod tests_objectfx; #[cfg(test)] mod tests_placed_document; diff --git a/crates/render/src/tests_memory.rs b/crates/render/src/tests_memory.rs new file mode 100644 index 00000000..bd091bf7 --- /dev/null +++ b/crates/render/src/tests_memory.rs @@ -0,0 +1,105 @@ +//! What renderers keep between frames: decoded images shared by all of them, image mips, and +//! caches swept by every kind of render. + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind}; + +use super::*; + +/// A `w`×`h` PNG whose pixels depend on `seed` (each test its own image: the store is shared by +/// the tests running alongside). +fn png(w: u32, h: u32, seed: u8) -> Vec { + let img = image::RgbaImage::from_fn(w, h, |x, y| { + let v = (x * 7 + y * 13) as u8; + image::Rgba([v ^ seed, v.wrapping_mul(3), seed, 255]) + }); + let mut out = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +/// A document showing `bytes` as image `key` of `w`×`h` pixels, one point a pixel. +fn doc_with_image(key: &str, bytes: Vec, w: u32, h: u32) -> Document { + let mut d = Document::new(w as f64, h as f64); + d.images.insert(key.into(), ImageBlob::new("image/png", bytes)); + let (id, layer) = (d.alloc_id(), d.layers[0].id); + let im = ImageObject { key: key.into(), width: w, height: h, xf: Affine::IDENTITY, link: None, placement: Default::default() }; + d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(im))).unwrap(); + d +} + +#[test] +fn renderers_share_one_decode_of_an_image() { + let bytes = png(16, 16, 101); + let a = doc_with_image("img", bytes.clone(), 16, 16); + // Another document holding the same image under another key (its own copy of the bytes). + let b = doc_with_image("photo", bytes, 16, 16); + let (mut r1, mut r2) = (Renderer::new(), Renderer::new()); + let (k1, p1) = r1.image_pixmap(&a, "img", false, true).unwrap(); + let (k2, p2) = r2.image_pixmap(&b, "photo", false, true).unwrap(); + assert_eq!(k1, k2); + assert!(Arc::ptr_eq(&p1, &p2), "decoded once for both renderers"); + // Drawing goes through the store too: the pixels stay the ones decoded. + let _ = r2.render(&b, 16, 16, Affine::IDENTITY, &RenderOptions::default()); + assert!(Arc::ptr_eq(&r1.image_pixmap(&b, "photo", false, true).unwrap().1, &p1)); + // A document using the same key for other pixels gets its own. + let c = doc_with_image("img", png(16, 16, 102), 16, 16); + let (k3, p3) = r1.image_pixmap(&c, "img", false, true).unwrap(); + assert!(k3 != k1 && !Arc::ptr_eq(&p3, &p1)); + assert!( + r1.render(&c, 16, 16, Affine::IDENTITY, &RenderOptions::default()).pixels + != r1.render(&a, 16, 16, Affine::IDENTITY, &RenderOptions::default()).pixels + ); +} + +/// Thumbnails and single-node renders end their frame as full renders do: entries of nodes no +/// longer drawn are dropped, with the nodes they kept alive. +#[test] +fn node_renders_sweep_stale_cache_entries() { + let mut d = Document::new(10.0, 10.0); + let id = d.alloc_id(); + let square = |x: f64| { + let p = vectorcraft_geom::shapes::rectangle(Rect::new(x, 0.0, x + 5.0, 5.0)); + Arc::new(Node::path(id, p, vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0))) + }; + let first = square(0.0); + let mut r = Renderer::new(); + r.render_node(&d, &first, 10, 10, Affine::IDENTITY).unwrap(); + assert!(Arc::strong_count(&first) > 1, "cached while drawn"); + for i in 0..1100 { + let n = square(i as f64); + if i % 2 == 0 { + r.render_node(&d, &n, 10, 10, Affine::IDENTITY).unwrap(); + } else { + // A thumbnail of a group caches its member's geometry. + r.render_node_thumbnail(&d, &Node::group(id, vec![n]), 16, None).unwrap(); + } + } + assert_eq!(Arc::strong_count(&first), 1, "swept: the renderer no longer keeps the node"); + assert!(r.geom.len() <= 1025, "{} geometry entries", r.geom.len()); +} + +/// With `image_mips` off (the default; exports) an image draws from its full pixels whatever the +/// store holds; on, a reduced image draws from a box-filtered half (quarter…) instead, and at +/// more than half size from the full pixels as before. +#[test] +fn image_mips_are_opt_in_and_change_nothing_off() { + let d = doc_with_image("img", png(64, 64, 103), 64, 64); + let (off, on) = (RenderOptions::default(), RenderOptions { image_mips: true, ..Default::default() }); + let quarter = Affine::scale(0.25); + let cold = Renderer::new().render(&d, 16, 16, quarter, &off); + // Mips made by another renderer don't change what this one draws with them off. + let mipped = Renderer::new().render(&d, 16, 16, quarter, &on); + let mut warm = Renderer::new(); + assert!(warm.render(&d, 16, 16, quarter, &off).pixels == cold.pixels); + assert!(warm.render(&d, 16, 16, quarter, &off).pixels == cold.pixels, "warm"); + // On, the quarter-size image samples the quarter-size mip: close to the full pixels' average + // but not the same pixels. + assert!(mipped.pixels != cold.pixels); + let (key, _) = warm.image_pixmap(&d, "img", false, true).unwrap(); + let mip = images::store().peek(&images::ImageKey { mip: 2, ..key }).expect("quarter mip kept"); + assert_eq!((mip.width(), mip.height()), (16, 16)); + // Above half size, mips on draw the full pixels. + let near = Affine::scale(0.75); + assert!(Renderer::new().render(&d, 48, 48, near, &on).pixels == Renderer::new().render(&d, 48, 48, near, &off).pixels); +} From bbbafdef6b68ff5c0a702a56a16d5895387b1531 Mon Sep 17 00:00:00 2001 From: arhammusheer Date: Sun, 11 Oct 2026 23:28:11 +0530 Subject: [PATCH 02/12] #1244: undo history bounded by bytes, without lookup tables or repeated selections in its steps History was limited by step count only; a whole-selection move of 50k paths keeps ~40 MB per step. Steps now carry a size estimate (only unshared subtrees are visited) and the oldest are dropped beyond a byte budget, keeping the last 8. Documents entering history drop their id index, selections are shared between steps, the command journal is bounded (held while an Action records), and document resources are copy-on-write. Saved files are byte-identical. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc --- crates/doc/src/id_index.rs | 13 + crates/doc/src/lib.rs | 70 ++-- crates/doc/src/mem.rs | 318 ++++++++++++++++++ crates/doc/src/shared.rs | 102 ++++++ crates/engine/src/cmd/clipboard.rs | 4 +- crates/engine/src/cmd/distortcmds.rs | 6 +- crates/engine/src/cmd/docmenu.rs | 4 +- crates/engine/src/cmd/edit.rs | 28 +- crates/engine/src/cmd/menucmds.rs | 1 + crates/engine/src/cmd/prefscmds.rs | 3 +- crates/engine/src/cmd/swatchlib.rs | 2 +- crates/engine/src/cmd/textedit.rs | 2 +- crates/engine/src/journal.rs | 197 +++++++++++ crates/engine/src/lib.rs | 152 +++++++-- crates/engine/src/tests_history_mem.rs | 126 +++++++ crates/engine/src/tests_journal.rs | 6 +- crates/engine/tests/pen_endpoint_picking.rs | 10 +- crates/eps/src/import/ai/read.rs | 3 +- crates/eps/src/import/mod.rs | 3 +- crates/pdf/src/import.rs | 3 +- .../src/dialogs/tests_rectangular_grid.rs | 2 +- crates/ui-egui/src/panels/actions.rs | 6 +- crates/ui-egui/src/panels/swatches.rs | 4 +- 23 files changed, 989 insertions(+), 76 deletions(-) create mode 100644 crates/doc/src/mem.rs create mode 100644 crates/engine/src/journal.rs create mode 100644 crates/engine/src/tests_history_mem.rs diff --git a/crates/doc/src/id_index.rs b/crates/doc/src/id_index.rs index cdb32c32..582e4c25 100644 --- a/crates/doc/src/id_index.rs +++ b/crates/doc/src/id_index.rs @@ -93,6 +93,19 @@ impl IdIndex { self.0.read().ok().and_then(|g| g.clone()) } + /// Let go of the cache (a document kept in the undo history): the next lookup builds it again. + pub(crate) fn clear(&self) { + if let Ok(mut g) = self.0.write() { + *g = None; + } + self.1.store(0, std::sync::atomic::Ordering::Relaxed); + } + + /// Whether a cache was built (and not cleared since). + pub(crate) fn is_built(&self) -> bool { + self.0.read().is_ok_and(|g| g.is_some()) + } + /// Rebuild the cache from `layers` (one walk) and return it. pub(crate) fn rebuild(&self, layers: &[Arc]) -> Arc { fn walk(nodes: &[Arc], parent: u32, out: &mut Paths) { diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index d1742933..26351869 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -22,6 +22,7 @@ pub use shared::Shared; pub mod links; pub mod live; pub mod marks; +mod mem; pub mod metadata; pub mod node; pub mod orient; @@ -60,6 +61,12 @@ pub(crate) mod skip { pub fn is_one(v: &f32) -> bool { *v == 1.0 } + pub fn is_empty_vec(v: &[T]) -> bool { + v.is_empty() + } + pub fn is_empty_map(v: &serde_json::Map) -> bool { + v.is_empty() + } } pub use appearance::StrokeGradientMode; @@ -549,18 +556,21 @@ pub struct Document { pub artboards: Vec, /// Top-level layers, bottom first. pub layers: Vec>, + // The document-wide resources below are [`Shared`]: an edit copies the document (copy-on-write) + // and the undo history keeps every version, so what an edit doesn't change stays one copy. + // Reads, writes and the saved form are those of the value itself. #[serde(default)] - pub swatches: Vec, + pub swatches: Shared>, #[serde(default)] - pub swatch_groups: Vec, + pub swatch_groups: Shared>, #[serde(default)] - pub graphic_styles: Vec, + pub graphic_styles: Shared>, /// Character styles (besides the built-in [Normal Character Style], which may be redefined here). - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub char_styles: Vec, + #[serde(default, skip_serializing_if = "skip::is_empty_vec")] + pub char_styles: Shared>, /// Paragraph styles (besides the built-in [Normal Paragraph Style]). - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub para_styles: Vec, + #[serde(default, skip_serializing_if = "skip::is_empty_vec")] + pub para_styles: Shared>, /// Threaded text: area-type frames one story flows through, in order. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub text_threads: Vec>, @@ -575,11 +585,11 @@ pub struct Document { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub views: Vec, /// Select → Save Selection… (at most [`SavedSelection::MAX`]); saved with the document. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub saved_selections: Vec, + #[serde(default, skip_serializing_if = "skip::is_empty_vec")] + pub saved_selections: Shared>, /// Variables (data merge): named values bound to objects, applied per dataset. #[serde(default, skip_serializing_if = "crate::skip::is_default")] - pub variables: Variables, + pub variables: Shared, #[serde(default)] pub grid: GridPrefs, #[serde(default = "ppi72")] @@ -598,8 +608,8 @@ pub struct Document { pub mask_edit: Option, next_id: u64, /// Foreign data preserved on round-trip. - #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] - pub unknown: serde_json::Map, + #[serde(default, skip_serializing_if = "skip::is_empty_map")] + pub unknown: Shared>, /// Transparency panel → Page Isolated Blending: the page is an isolated transparency group, /// so top-level blend modes don't blend with what lies under the page. #[serde(default, skip_serializing_if = "std::ops::Not::not")] @@ -705,18 +715,18 @@ impl Document { show_cross_hairs: false, }], layers: vec![], - swatches, - swatch_groups, - graphic_styles: default_graphic_styles(), - char_styles: vec![], - para_styles: vec![], + swatches: swatches.into(), + swatch_groups: swatch_groups.into(), + graphic_styles: default_graphic_styles().into(), + char_styles: Default::default(), + para_styles: Default::default(), text_threads: vec![], symbols: vec![], graph_designs: vec![], guides: vec![], views: vec![], - saved_selections: vec![], - variables: Variables::default(), + saved_selections: Default::default(), + variables: Default::default(), grid: GridPrefs::default(), raster_effects_ppi: 72.0, images: BTreeMap::new(), @@ -775,6 +785,26 @@ impl Document { self.index_path(id).and_then(|p| self.node_at(&p)) } + /// [`Document::node`] without building the lookup cache when there is none: for a look at a + /// document kept in the undo history ([`Document::drop_lookup_cache`]). + pub fn node_uncached(&self, id: NodeId) -> Option<&Node> { + match self.index.is_built() { + true => self.node(id), + false => self.walk_index_path(id).and_then(|p| self.node_at(&p)), + } + } + + /// Let go of the lookup cache ([`id_index`]), as a document kept in the undo history does: a + /// table per step would add up. Lookups still work; the first one builds the cache again. + pub fn drop_lookup_cache(&self) { + self.index.clear(); + } + + /// Whether the lookup cache ([`id_index`]) is built (see [`Document::drop_lookup_cache`]). + pub fn has_lookup_cache(&self) -> bool { + self.index.is_built() + } + /// Index path from the top-level layer list to `id` (e.g. `[0, 3, 1]`): the first place in /// tree order where `id` appears. pub fn index_path(&self, id: NodeId) -> Option> { @@ -1249,7 +1279,7 @@ impl Document { let objects = s.objects.into_iter().filter(|id| ids.contains(id) && seen.insert(*id)).collect(); kept.push(SavedSelection { name, objects }); } - self.saved_selections = kept; + self.saved_selections = kept.into(); } /// Leave opacity-mask editing: drop the temporary editing layer, a working copy of art the diff --git a/crates/doc/src/mem.rs b/crates/doc/src/mem.rs new file mode 100644 index 00000000..1162e8a6 --- /dev/null +++ b/crates/doc/src/mem.rs @@ -0,0 +1,318 @@ +//! How much memory a document keeps that another doesn't share: what an undo step costs. +//! +//! Documents share structure (`Arc` children, [`crate::Shared`] resources), so an old +//! version kept in the undo history holds only what the edits after it replaced. The estimate +//! walks the old version beside the newer one and visits only what isn't shared: a subtree whose +//! `Arc` the newer document also holds costs nothing and isn't entered, so the walk is about as +//! long as the edit was (plus a pass over each changed child list). It counts the heap the values +//! own (struct sizes, vector capacities, strings, JSON) plus a typical allocator overhead per +//! allocation, without following every last field: an estimate in the right ballpark, for a +//! memory budget, not an exact figure. + +use std::collections::HashSet; +use std::mem::size_of; +use std::sync::Arc; + +use serde_json::Value; +use vectorcraft_color::{Paint, Swatch, SwatchGroup}; +use vectorcraft_geom::{Anchor, PathData, SubPath}; + +use crate::text::{ParaStyle, TextObject, TextRun, TextStyleDef}; +use crate::{Appearance, AppearanceItem, Document, Effect, GraphicStyle, Node, NodeKind, SavedSelection, Shared}; + +/// Bookkeeping and rounding a typical allocator adds to each allocation. +const ALLOC: usize = 16; +/// An `Arc`'s two reference counts. +const ARC: usize = 16; +/// The kinds of node this doesn't break down (meshes, envelopes' settings, placed documents…): +/// a typical size of what they own besides the node. +const OTHER_KIND: usize = 256; + +impl Document { + /// Roughly how many bytes of heap this document keeps that `newer` doesn't share with it: what + /// keeping this version in the undo history costs while `newer` (a later version) is kept too. + /// Visits only the parts that aren't shared (see the [module docs](self)). + pub fn retained_bytes(&self, newer: &Document) -> usize { + if std::ptr::eq(self, newer) { + return 0; + } + let mut w = Walk { newer, seen: None, bytes: size_of::() + ALLOC + ARC }; + w.list(&self.layers, Some(&newer.layers)); + for (i, s) in self.symbols.iter().enumerate() { + w.bytes += s.name.capacity(); + w.tree(&s.art, newer.symbols.get(i).map(|n| &n.art)); + } + w.bytes += vec_bytes(&self.symbols); + for (i, p) in self.patterns.iter().enumerate() { + w.bytes += p.name.capacity(); + w.list(&p.art, newer.patterns.get(i).map(|n| &n.art)); + } + w.bytes += vec_bytes(&self.patterns); + w.bytes += self.resources_bytes(newer); + w.bytes + } + + /// The document-wide lists and settings: a copy of the document copies those that aren't + /// [`Shared`]; the shared ones count only when `newer` has its own. + fn resources_bytes(&self, newer: &Document) -> usize { + fn shared(a: &Shared, b: &Shared, f: impl FnOnce(&T) -> usize) -> usize { + if Shared::ptr_eq(a, b) { 0 } else { ALLOC + ARC + f(a) } + } + let mut n = 0; + n += vec_bytes(&self.artboards) + self.artboards.iter().map(|a| a.name.capacity()).sum::(); + n += vec_bytes(&self.guides) + vec_bytes(&self.views) + vec_bytes(&self.slices) + vec_bytes(&self.assets); + n += vec_bytes(&self.text_threads) + self.text_threads.iter().map(vec_bytes).sum::(); + n += self.images.iter().map(|(k, b)| 4 * size_of::() + k.capacity() + b.mime.capacity() + size_of_val(b)).sum::(); + n += map_bytes(&self.export_settings) + map_bytes(&self.extra) + self.print_setup.as_ref().map_or(0, json_bytes); + n += self.title.capacity(); + n += shared(&self.swatches, &newer.swatches, |s| swatches_bytes(s)); + n += shared(&self.swatch_groups, &newer.swatch_groups, |g| { + vec_bytes(g) + g.iter().map(|g: &SwatchGroup| g.name.capacity() + swatches_bytes(&g.swatches)).sum::() + }); + n += shared(&self.graphic_styles, &newer.graphic_styles, |g| { + vec_bytes(g) + g.iter().map(|g: &GraphicStyle| g.name.capacity() + appearance_heap(&g.appearance)).sum::() + }); + let styles = |s: &Vec| vec_bytes(s) + s.iter().map(|s| s.name.capacity() + map_bytes(&s.attrs)).sum::(); + n += shared(&self.char_styles, &newer.char_styles, styles); + n += shared(&self.para_styles, &newer.para_styles, styles); + n += shared(&self.saved_selections, &newer.saved_selections, |s| { + vec_bytes(s) + s.iter().map(|s: &SavedSelection| s.name.capacity() + vec_bytes(&s.objects)).sum::() + }); + n += shared(&self.variables, &newer.variables, |v| { + // Map entries: the key and value plus a share of the tree's nodes. + let entry = 2 * size_of::(); + vec_bytes(&v.variables) + + v.variables.iter().map(|v| v.name.capacity()).sum::() + + vec_bytes(&v.datasets) + + v.datasets.iter().map(|d| d.name.capacity() + d.values.keys().map(|k| entry + k.capacity() + 32).sum::()).sum::() + + v.bindings.values().map(|b| entry + 8 + b.iter().map(|s| entry + s.capacity()).sum::()).sum::() + }); + n += shared(&self.unknown, &newer.unknown, map_bytes); + n + } +} + +/// One estimate in progress. +struct Walk<'a> { + newer: &'a Document, + /// Every node the newer document holds (by address), built the first time a node has no + /// counterpart in place: then a node moved elsewhere (grouped, say) is still found shared. + seen: Option>, + bytes: usize, +} + +impl Walk<'_> { + /// The nodes of `list` that `newer` (the counterpart list, if any) doesn't share. + fn list(&mut self, list: &[Arc], newer: Option<&Vec>>) { + for (i, n) in list.iter().enumerate() { + self.tree(n, newer.and_then(|l| l.get(i))); + } + } + + /// Node `n` and what under it isn't shared; `newer` is the node in its place in the newer + /// document, if any. + fn tree(&mut self, n: &Arc, newer: Option<&Arc>) { + if newer.is_some_and(|m| Arc::ptr_eq(n, m)) { + return; + } + // In its place is another version of the same object (edited, moved): compare their + // children in place. Else it may sit elsewhere in the newer document. + let counterpart = newer.filter(|m| m.id == n.id); + if counterpart.is_none() && self.newer_holds(n) { + return; + } + self.bytes += node_bytes(n, counterpart.map(|m| &**m)); + if let Some(ch) = n.children() { + self.list(ch, counterpart.and_then(|m| m.children())); + } + if let Some(mask) = &n.mask { + self.tree(&mask.art, counterpart.and_then(|m| m.mask.as_ref()).map(|m| &m.art)); + } + } + + fn newer_holds(&mut self, n: &Arc) -> bool { + let newer = self.newer; + let seen = self.seen.get_or_insert_with(|| { + fn add(n: &Arc, out: &mut HashSet) { + // A node already seen was entered already (a node shared within the document). + if !out.insert(Arc::as_ptr(n) as usize) { + return; + } + for c in n.children().into_iter().flatten() { + add(c, out); + } + if let Some(m) = &n.mask { + add(&m.art, out); + } + } + let mut out = HashSet::new(); + let roots = newer.layers.iter().chain(newer.symbols.iter().map(|s| &s.art)).chain(newer.patterns.iter().flat_map(|p| &p.art)); + for n in roots { + add(n, &mut out); + } + out + }); + seen.contains(&(Arc::as_ptr(n) as usize)) + } +} + +/// One node's own heap: the node, and what it owns besides its children and mask art (counted +/// as nodes of their own). Its appearance counts unless `newer`, its newer version, shares it. +fn node_bytes(n: &Node, newer: Option<&Node>) -> usize { + fn boxed(b: &Option>) -> usize { + b.as_ref().map_or(0, |_| ALLOC + size_of::()) + } + let mut b = ALLOC + ARC + size_of::(); + b += n.name.as_ref().map_or(0, String::capacity); + if !newer.is_some_and(|m| Shared::ptr_eq(&m.appearance, &n.appearance)) { + b += ALLOC + ARC + appearance_heap(&n.appearance); + } + b += boxed(&n.mask) + boxed(&n.graph) + boxed(&n.shaper) + boxed(&n.attrs) + boxed(&n.slice) + boxed(&n.perspective); + b += n.trace.as_ref().map_or(0, |t| ALLOC + json_bytes(t)); + b += match &n.kind { + NodeKind::Path { path, .. } => path_bytes(path), + NodeKind::Layer { children, .. } + | NodeKind::Group { children, .. } + | NodeKind::Compound { children, .. } + | NodeKind::Blend { children, .. } + | NodeKind::Envelope { content: children, .. } => vec_bytes(children), + NodeKind::Repeat(r) => vec_bytes(&r.source) + OTHER_KIND, + NodeKind::Text(t) => ALLOC + text_bytes(t), + NodeKind::Image(im) => im.key.capacity(), + NodeKind::SymbolInstance { symbol, .. } => symbol.capacity(), + _ => OTHER_KIND, + }; + b +} + +fn path_bytes(p: &PathData) -> usize { + let anchors: usize = + p.subpaths.iter().map(|s| if s.anchors.capacity() == 0 { 0 } else { ALLOC + s.anchors.capacity() * size_of::() }).sum(); + anchors + if p.subpaths.capacity() == 0 { 0 } else { ALLOC + p.subpaths.capacity() * size_of::() } +} + +fn text_bytes(t: &TextObject) -> usize { + size_of::() + + vec_bytes(&t.runs) + + t.runs.iter().map(|r: &TextRun| r.text.capacity()).sum::() + + t.paras.capacity() * size_of::() + + vec_bytes(&t.wrap) + + vec_bytes(&t.cached_baselines) +} + +/// The heap an appearance owns (not the appearance itself). +fn appearance_heap(a: &Appearance) -> usize { + size_of::() + + vec_bytes(&a.items) + + a.items + .iter() + .map(|i| match i { + AppearanceItem::Fill(f) => paint_heap(&f.paint), + AppearanceItem::Stroke(s) => paint_heap(&s.paint), + }) + .sum::() + + vec_bytes(&a.effects) + + a.effects.iter().map(|e: &Effect| e.id.capacity() + json_bytes(&e.params)).sum::() +} + +fn paint_heap(p: &Paint) -> usize { + match p { + Paint::Solid { swatch, .. } => swatch.as_ref().map_or(0, String::capacity), + Paint::Gradient(g) => ALLOC + size_of_val(&**g) + vec_bytes(&g.gradient.stops), + Paint::Pattern { pattern, .. } => pattern.capacity(), + Paint::None => 0, + } +} + +fn swatches_bytes(s: &[Swatch]) -> usize { + ALLOC + std::mem::size_of_val(s) + s.iter().map(|w| w.name.capacity() + paint_heap(&w.paint)).sum::() +} + +/// A vector's buffer (not what its elements own). +fn vec_bytes(v: &Vec) -> usize { + if v.capacity() == 0 { 0 } else { ALLOC + v.capacity() * size_of::() } +} + +fn map_bytes(m: &serde_json::Map) -> usize { + m.iter().map(|(k, v)| ALLOC + 4 * size_of::() + k.capacity() + size_of::() + json_bytes(v)).sum() +} + +/// The heap a JSON value owns. +fn json_bytes(v: &Value) -> usize { + match v { + Value::String(s) => s.capacity(), + Value::Array(a) => vec_bytes(a) + a.iter().map(json_bytes).sum::(), + Value::Object(m) => map_bytes(m), + _ => 0, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_geom::shapes; + + fn doc_with_rects(n: usize) -> Document { + let mut d = Document::new(1000.0, 1000.0); + let layer = d.layers[0].id; + for i in 0..n { + let id = d.alloc_id(); + let r = shapes::rectangle(kurbo::Rect::new(i as f64, 0.0, i as f64 + 10.0, 10.0)); + d.insert(Some(layer), usize::MAX, Node::path(id, r, Appearance::default_art())).unwrap(); + } + d + } + + #[test] + fn an_unchanged_copy_keeps_nothing_of_its_own() { + let d = doc_with_rects(10); + let copy = d.clone(); + // Only the document itself and its own lists: no node, no shared resource. + assert!(d.retained_bytes(©) < 2048, "{}", d.retained_bytes(©)); + assert_eq!(d.retained_bytes(&d), 0); + } + + /// Moving every rectangle: each keeps its old node and anchors (its appearance stays shared). + /// The process grew by about 775 bytes per moved rectangle when measured (#1244). + #[test] + fn moving_rectangles_costs_their_nodes() { + let n = 1000; + let before = doc_with_rects(n); + let mut after = before.clone(); + let ids: Vec<_> = after.layers[0].children().unwrap().iter().map(|c| c.id).collect(); + for id in ids { + let node = after.node_mut(id).unwrap(); + if let NodeKind::Path { path, .. } = &mut node.kind { + for a in path.subpaths.iter_mut().flat_map(|s| s.anchors.iter_mut()) { + a.p.x += 5.0; + } + } + } + let per = before.retained_bytes(&after) / n; + assert!((400..1200).contains(&per), "{per} bytes per moved rectangle"); + } + + #[test] + fn a_grouped_object_is_found_shared() { + let before = doc_with_rects(100); + let mut after = before.clone(); + let ids: Vec<_> = after.layers[0].children().unwrap().iter().map(|c| c.id).collect(); + // Put the rectangles in a group (the same Arcs, elsewhere). + let gid = after.alloc_id(); + let layer = Arc::make_mut(&mut after.layers[0]); + let kids = std::mem::take(layer.children_mut().unwrap()); + layer.children_mut().unwrap().push(Arc::new(Node::group(gid, kids))); + assert_eq!(ids.len(), 100); + let bytes = before.retained_bytes(&after); + assert!(bytes < 8192, "only the old layer and its child list: {bytes}"); + } + + #[test] + fn a_resource_counts_only_when_changed() { + let before = doc_with_rects(1); + let mut after = before.clone(); + let same = before.retained_bytes(&after); + after.swatches.push(before.swatches[1].clone()); + assert!(before.retained_bytes(&after) > same + 1000, "the old swatch list is the old version's own"); + } +} diff --git a/crates/doc/src/shared.rs b/crates/doc/src/shared.rs index 208ccd4c..0a7eefc3 100644 --- a/crates/doc/src/shared.rs +++ b/crates/doc/src/shared.rs @@ -34,6 +34,46 @@ impl std::ops::DerefMut for Shared { } } +impl Shared { + /// The value, taken out (copied only when another copy still shares it). + pub fn into_inner(self) -> T { + Arc::unwrap_or_clone(self.0) + } +} + +/// `for x in std::mem::take(&mut doc.swatches)` takes the value, as it would the value itself. +impl IntoIterator for Shared { + type Item = T::Item; + type IntoIter = T::IntoIter; + fn into_iter(self) -> Self::IntoIter { + self.into_inner().into_iter() + } +} + +/// `for x in &doc.swatches` iterates the shared value, as it would the value itself. +impl<'a, T> IntoIterator for &'a Shared +where + &'a T: IntoIterator, +{ + type Item = <&'a T as IntoIterator>::Item; + type IntoIter = <&'a T as IntoIterator>::IntoIter; + fn into_iter(self) -> Self::IntoIter { + (&*self.0).into_iter() + } +} + +/// `for x in &mut doc.swatches` makes the value this copy's own first, as any change does. +impl<'a, T: Clone> IntoIterator for &'a mut Shared +where + &'a mut T: IntoIterator, +{ + type Item = <&'a mut T as IntoIterator>::Item; + type IntoIter = <&'a mut T as IntoIterator>::IntoIter; + fn into_iter(self) -> Self::IntoIter { + Arc::make_mut(&mut self.0).into_iter() + } +} + impl From for Shared { fn from(v: T) -> Self { Shared::new(v) @@ -70,3 +110,65 @@ impl<'de, T: Deserialize<'de>> Deserialize<'de> for Shared { T::deserialize(d).map(Shared::new) } } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::text::TextStyleDef; + use crate::variables::{Variable, VariableKind}; + use crate::{Document, NodeId, SavedSelection}; + + /// A document with every shared resource set, changed only through `push`/`insert` (as code + /// written for the plain values does). + fn doc_with_resources() -> Document { + let mut d = Document::new(100.0, 100.0); + let swatch = d.swatches[1].clone(); + d.swatches.push(swatch); + d.swatch_groups.truncate(1); + d.graphic_styles.truncate(2); + d.char_styles.push(TextStyleDef { name: "Emphasis".into(), attrs: json!({"size": 14}).as_object().cloned().unwrap_or_default() }); + d.para_styles.push(TextStyleDef { name: "Body".into(), attrs: Default::default() }); + d.saved_selections.push(SavedSelection { name: "Saved".into(), objects: vec![NodeId(1)] }); + d.variables.variables.push(Variable { name: "Name".into(), kind: VariableKind::Text }); + d.variables.bindings.entry(NodeId(1)).or_default().insert("Name".into()); + d.unknown.insert("foreign".into(), json!({"keep": [1, 2]})); + d + } + + /// Shared document resources save as the values themselves: the saved form is unchanged. + #[test] + fn shared_resources_save_and_load_as_their_values() { + let d = doc_with_resources(); + let v = serde_json::to_value(&d).unwrap(); + assert_eq!(v["swatches"], serde_json::to_value(&*d.swatches).unwrap()); + assert_eq!(v["swatch_groups"], serde_json::to_value(&*d.swatch_groups).unwrap()); + assert_eq!(v["graphic_styles"], serde_json::to_value(&*d.graphic_styles).unwrap()); + assert_eq!(v["char_styles"], json!([{"name": "Emphasis", "attrs": {"size": 14}}])); + assert_eq!(v["para_styles"], json!([{"name": "Body", "attrs": {}}])); + assert_eq!(v["saved_selections"], json!([{"name": "Saved", "objects": [1]}])); + assert_eq!(v["variables"], json!({"variables": [{"name": "Name", "kind": "text"}], "bindings": {"1": ["Name"]}})); + assert_eq!(v["unknown"], json!({"foreign": {"keep": [1, 2]}})); + let text = serde_json::to_string(&d).unwrap(); + let back: Document = serde_json::from_str(&text).unwrap(); + assert_eq!(back, d); + assert_eq!(serde_json::to_string(&back).unwrap(), text, "saving again gives the same bytes"); + // Empty ones are left out, as before. + let v = serde_json::to_value(Document::new(10.0, 10.0)).unwrap(); + for k in ["char_styles", "para_styles", "saved_selections", "variables", "unknown"] { + assert!(v.get(k).is_none(), "{k} is left out when empty"); + } + } + + /// A copy of a document shares its resources until one copy changes one of them. + #[test] + fn copies_share_resources_until_changed() { + let a = doc_with_resources(); + let mut b = a.clone(); + assert!(super::Shared::ptr_eq(&a.swatches, &b.swatches) && super::Shared::ptr_eq(&a.unknown, &b.unknown)); + b.swatches.pop(); + assert!(!super::Shared::ptr_eq(&a.swatches, &b.swatches)); + assert_eq!(a.swatches.len(), b.swatches.len() + 1, "the other copy keeps its own"); + assert!(super::Shared::ptr_eq(&a.graphic_styles, &b.graphic_styles), "the rest stays shared"); + } +} diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 84569f88..faaa1245 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -142,8 +142,8 @@ impl Clipboard { d.patterns = self.patterns.clone(); d.swatches.extend(self.swatches.iter().cloned()); d.graphic_styles.extend(self.graphic_styles.iter().cloned()); - d.char_styles = self.char_styles.clone(); - d.para_styles = self.para_styles.clone(); + d.char_styles = self.char_styles.clone().into(); + d.para_styles = self.para_styles.clone().into(); if !self.brushes.is_empty() { let mut lib = vectorcraft_brush::library(&d); for b in &self.brushes { diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index 55f80371..e4aeea52 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -1198,6 +1198,7 @@ fn persp_draw(s: &mut Session, p: &Value) -> Result { if let Some(e) = st.history.undo.last_mut() { e.label = "Draw in Perspective".into(); } + st.history.recost_last(&st.doc); } res?; Ok(json!({ "id": id.0, "plane": plane.id() })) @@ -1268,7 +1269,9 @@ fn edit_text(s: &mut Session, p: &Value) -> Result { /// in every undo state (the flat view is never an undo step of its own). pub(crate) fn finish_edit_text(st: &mut crate::DocState, id: NodeId) { let clear = |doc: &mut Arc| { - if doc.node(id).and_then(|n| n.perspective.as_deref()).is_some_and(|p| p.editing) + // Undo steps keep no lookup cache: look without building one, and drop the one an edit + // builds (the document now keeps its own). + if doc.node_uncached(id).and_then(|n| n.perspective.as_deref()).is_some_and(|p| p.editing) && let Some(p) = Arc::make_mut(doc).node_mut(id).and_then(|n| n.perspective.as_deref_mut()) { p.editing = false; @@ -1281,6 +1284,7 @@ pub(crate) fn finish_edit_text(st: &mut crate::DocState, id: NodeId) { } for e in st.history.undo.iter_mut().chain(st.history.redo.iter_mut()) { clear(&mut e.doc); + e.doc.drop_lookup_cache(); } if let Some(it) = &mut st.interaction { clear(&mut it.doc); diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index eef34b40..20de95d5 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -402,9 +402,9 @@ fn edit_saved(s: &mut Session, p: &Value) -> Result { return Err(bad(C, format!("two saved selections would be named `{}`", x.name))); } } - if after != before { + if *before != after { s.edit("Edit Selection", |d, _| { - d.saved_selections = after; + d.saved_selections = after.into(); Ok(()) })?; } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 7adb48b6..7a7f8f4e 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -215,11 +215,11 @@ fn undo(s: &mut Session, _: &Value) -> Result { let e = st.history.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?; let label = e.label.clone(); let editing = st.doc.mask_edit.map(|m| m.layer); - st.history.redo.push(crate::HistoryEntry { - label: e.label, - doc: std::mem::replace(&mut st.doc, e.doc), - selection: std::mem::replace(&mut st.selection, e.selection), - }); + let doc = std::mem::replace(&mut st.doc, e.doc); + let selection = std::mem::replace(&mut st.selection, Arc::unwrap_or_clone(e.selection)); + let selection = shared_selection(selection, st.history.redo.last()); + st.history.redo.push(crate::HistoryEntry::kept(e.label, doc, selection, &st.doc)); + st.history.trim(); st.revision += 1; st.selection.prune(&st.doc); super::maskedit::follow_history(st, editing); @@ -231,17 +231,25 @@ fn redo(s: &mut Session, _: &Value) -> Result { let e = st.history.redo.pop().ok_or_else(|| EngineError::Other("nothing to redo".into()))?; let label = e.label.clone(); let editing = st.doc.mask_edit.map(|m| m.layer); - st.history.undo.push(crate::HistoryEntry { - label: e.label, - doc: std::mem::replace(&mut st.doc, e.doc), - selection: std::mem::replace(&mut st.selection, e.selection), - }); + let doc = std::mem::replace(&mut st.doc, e.doc); + let selection = std::mem::replace(&mut st.selection, Arc::unwrap_or_clone(e.selection)); + let selection = shared_selection(selection, st.history.undo.last()); + st.history.undo.push(crate::HistoryEntry::kept(e.label, doc, selection, &st.doc)); + st.history.trim(); st.revision += 1; st.selection.prune(&st.doc); super::maskedit::follow_history(st, editing); Ok(json!({ "redone": label })) } +/// `selection` for an undo (redo) step, sharing that of `like`, the step beside it, when the same. +fn shared_selection(selection: vectorcraft_doc::Selection, like: Option<&crate::HistoryEntry>) -> Arc { + match like { + Some(l) if *l.selection == selection => l.selection.clone(), + _ => Arc::new(selection), + } +} + /// Selected top-level objects in paint order, dropping any whose ancestor is also selected. pub(crate) fn selected_roots(s: &Session) -> Result> { let st = s.doc()?; diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index a6ebd243..7b6ad172 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -180,6 +180,7 @@ pub(crate) fn squash(s: &mut Session, from: usize, label: &str) { if h.len() > from { h.truncate(from + 1); h[from].label = label.to_string(); + st.history.recost_last(&st.doc); } } } diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index 59e5ae77..1a30edbf 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -543,8 +543,7 @@ impl Session { for st in &mut self.docs { if history_changed { st.history.limit = self.prefs.history_states as usize; - let over = st.history.undo.len().saturating_sub(st.history.limit); - st.history.undo.drain(..over); + st.history.trim(); } // Documents in pattern editing mode redraw their tile edge. if tile_edge_changed && st.doc.pattern_edit.is_some() { diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index 33adf24f..ddf8ccd9 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -606,7 +606,7 @@ fn reset_defaults(s: &mut Session, p: &Value) -> Result { let mut added = vec![]; if replace { let before: Vec = d.swatches_iter().map(|w| w.name.clone()).collect(); - (d.swatches, d.swatch_groups) = (defaults, groups); + (d.swatches, d.swatch_groups) = (defaults.into(), groups.into()); added.extend(d.swatches_iter().filter(|w| !before.contains(&w.name)).map(|w| w.name.clone())); live.extend(d.swatches_iter().filter(|w| w.global).map(|w| w.name.clone())); d.map_solid_paints(&mut |_, l, _| unlink_dead(&live, l)); diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index d830d824..d783cd59 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -755,7 +755,7 @@ fn discard_empty(s: &mut Session, p: &Value) -> Result { // plus the text (and its id), roll back to it without a trace. let st = s.doc_mut()?; if st.interaction.is_none() - && let Some(k) = st.history.undo.iter().rposition(|e| e.doc.node(id).is_none()) + && let Some(k) = st.history.undo.iter().rposition(|e| e.doc.node_uncached(id).is_none()) && let Some(created) = st.history.undo.get(k) { let mut now = (*st.doc).clone(); diff --git a/crates/engine/src/journal.rs b/crates/engine/src/journal.rs new file mode 100644 index 00000000..96cfe7ae --- /dev/null +++ b/crates/engine/src/journal.rs @@ -0,0 +1,197 @@ +//! The session's journal of executed commands: what the Actions panel records an action from +//! (and what a replay runs), kept within a memory bound. +//! +//! Entries are numbered from the session's first command on, and a number keeps naming the same +//! entry when older ones are let go: [`Journal::len`] counts every entry ever journaled, so a +//! recording that remembers where it started finds its steps by that number. Only the most recent +//! entries are kept (at most [`Journal::MAX_ENTRIES`] and about [`Journal::MAX_BYTES`], always the +//! last [`Journal::MIN_ENTRIES`]): a long session of select-alls on a large document no longer +//! grows the journal by a copy of every id list forever. A recording that must keep everything +//! from its start holds the journal there ([`Journal::hold`]). + +use serde_json::Value; + +/// One journaled command: its id and params (with the values it noted, see +/// [`crate::Session::note_journal`]). +pub type JournalEntry = (String, Value); + +#[derive(Clone, Debug, Default)] +pub struct Journal { + /// How many entries were let go from the front; the first kept one has this number. + dropped: usize, + entries: Vec, + /// Each kept entry's estimated size, and their sum. + sizes: Vec, + bytes: usize, + /// Keep every entry from this number on ([`Journal::hold`]). + held: Option, +} + +/// Journals with the same entries (by number) are equal, whatever their history. +impl PartialEq for Journal { + fn eq(&self, other: &Self) -> bool { + self.dropped == other.dropped && self.entries == other.entries + } +} + +impl Journal { + /// Most entries kept (the oldest go first). + pub const MAX_ENTRIES: usize = 10_000; + /// About the most memory the kept entries take. + pub const MAX_BYTES: usize = 32 << 20; + /// The most recent entries are kept whatever they take. + pub const MIN_ENTRIES: usize = 64; + + /// Every entry ever journaled, kept or let go: the number the next one gets. + pub fn len(&self) -> usize { + self.dropped + self.entries.len() + } + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + /// The number of the oldest entry kept. + pub fn first_kept(&self) -> usize { + self.dropped + } + /// The kept entries, oldest first. + pub fn iter(&self) -> std::slice::Iter<'_, JournalEntry> { + self.entries.iter() + } + /// The kept entries from number `start` on (all kept ones when older ones were let go). + pub fn since(&self, start: usize) -> &[JournalEntry] { + self.entries.get(start.saturating_sub(self.dropped)..).unwrap_or_default() + } + /// Entry number `i`, if kept. + pub fn get(&self, i: usize) -> Option<&JournalEntry> { + self.entries.get(i.checked_sub(self.dropped)?) + } + pub fn get_mut(&mut self, i: usize) -> Option<&mut JournalEntry> { + self.entries.get_mut(i.checked_sub(self.dropped)?) + } + pub fn last(&self) -> Option<&JournalEntry> { + self.entries.last() + } + pub fn last_mut(&mut self) -> Option<&mut JournalEntry> { + self.entries.last_mut() + } + pub fn push(&mut self, e: JournalEntry) { + let size = entry_bytes(&e); + self.entries.push(e); + self.sizes.push(size); + self.bytes += size; + self.trim(); + } + /// Drop the entries from number `len` on (a cancelled undo group's). + pub fn truncate(&mut self, len: usize) { + let keep = len.saturating_sub(self.dropped); + self.entries.truncate(keep); + self.sizes.truncate(keep); + self.bytes = self.sizes.iter().sum(); + self.dropped = self.dropped.min(len); + } + /// Keep every entry from number `from` on (a recording in progress), or stop (`None`). + pub fn hold(&mut self, from: Option) { + self.held = from; + self.trim(); + } + + /// Let the oldest entries go while over the bounds (in batches: a quarter of the entry bound + /// at a time, so a push doesn't shift the whole list every time). + fn trim(&mut self) { + let over_count = self.entries.len() > Self::MAX_ENTRIES + Self::MAX_ENTRIES / 4; + if !over_count && self.bytes <= Self::MAX_BYTES { + return; + } + // The entries that may go: not the last few, nor any a hold keeps. + let mut may = self.entries.len().saturating_sub(Self::MIN_ENTRIES); + if let Some(h) = self.held { + may = may.min(h.saturating_sub(self.dropped)); + } + let mut n = 0; + let mut bytes = self.bytes; + while n < may && (self.entries.len() - n > Self::MAX_ENTRIES || bytes > Self::MAX_BYTES) { + bytes -= self.sizes.get(n).copied().unwrap_or(0); + n += 1; + } + if n > 0 { + self.entries.drain(..n); + self.sizes.drain(..n); + self.bytes = bytes; + self.dropped += n; + } + } +} + +impl<'a> IntoIterator for &'a Journal { + type Item = &'a JournalEntry; + type IntoIter = std::slice::Iter<'a, JournalEntry>; + fn into_iter(self) -> Self::IntoIter { + self.entries.iter() + } +} + +/// Roughly the heap an entry takes: its id, and its params as `serde_json` holds them. +fn entry_bytes((id, p): &JournalEntry) -> usize { + fn json(v: &Value) -> usize { + const VALUE: usize = std::mem::size_of::(); + match v { + Value::String(s) => s.capacity(), + Value::Array(a) => 16 + a.capacity() * VALUE + a.iter().map(json).sum::(), + Value::Object(m) => m.iter().map(|(k, v)| 48 + k.capacity() + VALUE + json(v)).sum(), + _ => 0, + } + } + std::mem::size_of::() + id.capacity() + json(p) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn big(i: usize) -> JournalEntry { + ("select.set".into(), json!({"ids": (0..50_000u64).collect::>(), "n": i})) + } + + #[test] + fn numbers_survive_letting_entries_go() { + let mut j = Journal::default(); + for i in 0..200 { + j.push(big(i)); + } + assert_eq!(j.len(), 200, "every entry counts"); + assert!(j.first_kept() > 0, "big entries were let go"); + assert!(j.iter().count() >= Journal::MIN_ENTRIES); + assert_eq!(j.get(199).map(|e| e.1["n"].clone()), Some(json!(199))); + assert_eq!(j.since(150).len(), 50); + assert!(j.get(0).is_none()); + } + + #[test] + fn a_hold_keeps_a_recording_whole() { + let mut j = Journal::default(); + j.push(("file.new".into(), json!({}))); + let start = j.len(); + j.hold(Some(start)); + for i in 0..200 { + j.push(big(i)); + } + assert_eq!(j.since(start).len(), 200, "nothing recorded was let go"); + assert_eq!(j.first_kept(), 1, "only what came before the recording may go"); + j.hold(None); + j.push(big(200)); + assert!(j.first_kept() > start); + } + + #[test] + fn many_small_entries_are_bounded_too() { + let mut j = Journal::default(); + for i in 0..30_000 { + j.push(("object.move".into(), json!({"dx": i}))); + } + assert_eq!(j.len(), 30_000); + assert!(j.iter().count() <= Journal::MAX_ENTRIES + Journal::MAX_ENTRIES / 4); + j.truncate(29_990); + assert_eq!((j.len(), j.last().map(|e| e.1["dx"].clone())), (29_990, Some(json!(29_989)))); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 307c4084..58b89bd9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -10,6 +10,7 @@ pub mod cmd; pub mod file_access; pub mod guard; pub mod inspect; +pub mod journal; pub mod link_watch; pub mod steps; mod tooling; @@ -28,6 +29,7 @@ pub use cmd::clipboard::Clipboard; pub use cmd::distortcmds::perspective_click; pub use cmd::rasterfx::{export_pdf, flatten_raster_effects}; pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command}; +pub use journal::Journal; pub use tooling::{UiRequest, ViewInfo}; pub use vectorcraft_doc as doc; pub use vectorcraft_tools as tools; @@ -63,16 +65,106 @@ pub type Result = std::result::Result; #[derive(Clone, Debug)] pub struct HistoryEntry { pub label: String, + /// The document as it was. It keeps no lookup cache ([`Document::drop_lookup_cache`]): a + /// table per step would add up, and undoing builds it again once. pub doc: Arc, - pub selection: Selection, + /// The selection as it was, shared with the neighbouring steps that have the same one. + pub selection: Arc, + /// Roughly the memory this step keeps of its own: what of `doc` the step after it (or the + /// document now) doesn't share ([`Document::retained_bytes`]). + pub bytes: usize, } -#[derive(Clone, Debug, Default)] +impl HistoryEntry { + /// A step keeping `doc` and `selection`, as they were before the edit that made `next`. + pub(crate) fn kept(label: String, doc: Arc, selection: Arc, next: &Document) -> Self { + doc.drop_lookup_cache(); + let bytes = doc.retained_bytes(next); + HistoryEntry { label, doc, selection, bytes } + } +} + +/// The undo history of a document: bounded by a number of steps (Preferences › Performance › +/// History States) and by the memory the steps keep ([`History::byte_budget`]). +#[derive(Clone, Debug)] pub struct History { pub undo: Vec, pub redo: Vec, /// Maximum undo depth (Illustrator has no hard limit; snapshots are cheap here). pub limit: usize, + /// About the most memory the steps may keep ([`HistoryEntry::bytes`]): past it the oldest + /// steps go, but never the last [`History::MIN_STEPS`] undo (and redo) steps. + pub byte_budget: usize, +} + +impl Default for History { + fn default() -> Self { + History { undo: vec![], redo: vec![], limit: 0, byte_budget: default_history_budget() } + } +} + +impl History { + /// Undo (and redo) steps kept whatever memory they take. + pub const MIN_STEPS: usize = 8; + + /// Roughly the memory the steps keep of their own ([`HistoryEntry::bytes`]). + pub fn bytes(&self) -> usize { + self.undo.iter().chain(&self.redo).map(|e| e.bytes).sum() + } + + /// Let the oldest steps go while over the step limit or the memory budget: undo steps first + /// (the oldest), then redo steps (the furthest ahead). + pub fn trim(&mut self) { + let over = self.undo.len().saturating_sub(self.limit); + if over > 0 { + self.undo.drain(..over); + } + let mut total = self.bytes(); + if total <= self.byte_budget { + return; + } + let mut n = 0; + while total > self.byte_budget && self.undo.len() - n > Self::MIN_STEPS { + total -= self.undo.get(n).map_or(0, |e| e.bytes); + n += 1; + } + self.undo.drain(..n); + let mut n = 0; + while total > self.byte_budget && self.redo.len() - n > Self::MIN_STEPS { + total -= self.redo.get(n).map_or(0, |e| e.bytes); + n += 1; + } + self.redo.drain(..n); + } + + /// Estimate the newest undo step again, its next state now being `now` (the steps after it were + /// merged into it, or an undo group extended it). + pub(crate) fn recost_last(&mut self, now: &Document) { + if let Some(e) = self.undo.last_mut() { + e.bytes = e.doc.retained_bytes(now); + } + } +} + +/// The default [`History::byte_budget`]: an eighth of the machine's memory where it is known +/// (Linux), at most 1 GiB (the figure elsewhere); 256 MiB on the web, whose memory is small. +pub fn default_history_budget() -> usize { + const MAX: usize = 1 << 30; + if cfg!(target_arch = "wasm32") { + return 256 << 20; + } + static BUDGET: std::sync::OnceLock = std::sync::OnceLock::new(); + *BUDGET.get_or_init(|| physical_memory().map_or(MAX, |m| (m / 8).clamp(64 << 20, MAX))) +} + +/// The machine's memory, where cheap to learn without platform calls. +fn physical_memory() -> Option { + if !cfg!(target_os = "linux") { + return None; + } + let info = std::fs::read_to_string("/proc/meminfo").ok()?; + let kb: usize = info.lines().find_map(|l| l.strip_prefix("MemTotal:"))?.trim().trim_end_matches("kB").trim().parse().ok()?; + kb.checked_mul(1024) } /// An in-progress drag: the snapshot the previews are applied to. @@ -80,7 +172,8 @@ pub struct History { pub struct Interaction { pub label: String, pub doc: Arc, - pub selection: Selection, + /// The selection the previews start from (shared, so a preview frame copies nothing). + pub selection: Arc, pub preview: Option<(String, Value)>, /// Per-document state restored on cancel (current layer, highlighted Layers panel rows, /// isolation). @@ -284,34 +377,40 @@ impl DocState { /// Keep what interaction `it` (taken from this document) changed, as one undo step. pub(crate) fn keep_interaction(&mut self, it: Interaction) { if !Arc::ptr_eq(&it.doc, &self.doc) { - self.push_undo(HistoryEntry { label: it.label, doc: it.doc, selection: it.selection }); + let selection = match self.history.undo.last() { + Some(l) if l.selection == it.selection => l.selection.clone(), + _ => it.selection, + }; + self.push_undo(it.label, it.doc, selection); self.revision += 1; } } - /// Record undo step `e` (the document before an edit). In an undo group only the group's first - /// edit records one: the edits after it extend that step. - fn push_undo(&mut self, mut e: HistoryEntry) { + /// Record an undo step: `doc` and `selection` as they were before an edit (the document now is + /// the one after it). In an undo group only the group's first edit records one: the edits + /// after it extend that step. + fn push_undo(&mut self, mut label: String, doc: Arc, selection: Arc) { if let Some(g) = &mut self.undo_group { let recorded = |first: &Arc| self.history.undo.last().is_some_and(|l| Arc::ptr_eq(&l.doc, first)); if g.first.as_ref().is_some_and(recorded) { + self.history.recost_last(&self.doc); + self.history.trim(); return; } - g.first = Some(e.doc.clone()); + g.first = Some(doc.clone()); if let Some(l) = &g.label { - e.label = l.clone(); + label = l.clone(); } } + let e = HistoryEntry::kept(label, doc, selection, &self.doc); self.history.undo.push(e); - if self.history.undo.len() > self.history.limit { - self.history.undo.remove(0); - } self.history.redo.clear(); + self.history.trim(); } /// End the interaction in progress, undoing what it changed. pub(crate) fn undo_interaction(&mut self) { if let Some(it) = self.interaction.take() { self.doc = it.doc; - self.selection = it.selection; + self.selection = Arc::unwrap_or_clone(it.selection); self.active_layer = it.active_layer; self.layer_rows = it.layer_rows; self.variables_highlight = it.variables_highlight; @@ -840,8 +939,8 @@ pub struct Session { pub fill_active: bool, /// Internal clipboard: the copied objects and the document resources they use. pub clipboard: Clipboard, - /// Executed commands (for actions and debugging). - pub journal: Vec<(String, Value)>, + /// Executed commands (for actions and debugging), the most recent ones kept ([`Journal`]). + pub journal: Journal, pub(crate) tool: Box, /// While Cmd lends `tool` (a selection tool) for a drag: the tool it was lent to, which comes /// back as it was at the release ([`Session::pointer`]). @@ -952,7 +1051,7 @@ impl Session { paint: PaintDefaults::default(), fill_active: true, clipboard: Clipboard::default(), - journal: vec![], + journal: Journal::default(), tool: vectorcraft_tools::create("selection"), lender: None, last_selection_tool: None, @@ -1194,8 +1293,8 @@ impl Session { /// pin a replay to the original run ([`REPLAY_ONLY`]), so playing the action later acts now. pub fn journal_for_action(&self, start: usize) -> Vec<(String, Value)> { self.journal + .since(start) .iter() - .skip(start) .cloned() .map(|(id, mut p)| { strip_replay_only(&id, &mut p); @@ -1296,7 +1395,11 @@ impl Session { st.selection.prune(&st.doc); st.revision += 1; if st.interaction.is_none() { - st.push_undo(HistoryEntry { label: label.to_string(), doc: before, selection: before_sel }); + let selection = match st.history.undo.last() { + Some(l) if *l.selection == before_sel => l.selection.clone(), + _ => Arc::new(before_sel), + }; + st.push_undo(label.to_string(), before, selection); } Ok(v) } @@ -1342,7 +1445,7 @@ impl Session { st.interaction = Some(Interaction { label: label.to_string(), doc: st.doc.clone(), - selection: st.selection.clone(), + selection: Arc::new(st.selection.clone()), preview: None, active_layer: st.active_layer, layer_rows: st.layer_rows.clone(), @@ -1360,11 +1463,14 @@ impl Session { let Some(it) = &st.interaction else { return Err(EngineError::Other("no interaction in progress".into())) }; // Undoing the previous preview counts as a change, so views redraw even when the command // below returns without an edit. - if !Arc::ptr_eq(&st.doc, &it.doc) || st.selection != it.selection { + let selection_changed = st.selection != *it.selection; + if !Arc::ptr_eq(&st.doc, &it.doc) || selection_changed { st.revision += 1; } st.doc = it.doc.clone(); - st.selection = it.selection.clone(); + if selection_changed { + st.selection.clone_from(&it.selection); + } } let rw = cmd::distortcmds::perspective_rewrite(self, cmd, params); let (cmd, params) = rw.as_ref().map_or((cmd, params), |(c, p)| (c.as_str(), p)); @@ -1463,7 +1569,7 @@ impl Session { && let Some(e) = st.history.undo.pop() { st.doc = e.doc; - st.selection = e.selection; + st.selection = Arc::unwrap_or_clone(e.selection); st.revision += 1; journal = Some(g.journal); } @@ -1580,6 +1686,8 @@ mod tests_gradpanel; #[cfg(test)] mod tests_halftone; #[cfg(test)] +mod tests_history_mem; +#[cfg(test)] mod tests_inline; #[cfg(test)] mod tests_journal; diff --git a/crates/engine/src/tests_history_mem.rs b/crates/engine/src/tests_history_mem.rs new file mode 100644 index 00000000..dd190700 --- /dev/null +++ b/crates/engine/src/tests_history_mem.rs @@ -0,0 +1,126 @@ +//! The undo history's memory (#1244): steps keep no lookup tables, unchanged selections are +//! shared between steps, and past a memory budget the oldest steps go (never the last few). + +use serde_json::json; +use vectorcraft_doc::{Appearance, Node}; +use vectorcraft_geom::shapes; + +use super::*; + +/// A session with one document holding `n` rectangles, all selected. +fn session_with_rects(n: usize) -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 2000, "height": 2000})).unwrap(); + s.edit("Add", |d, _| { + let layer = d.layers[0].id; + for i in 0..n { + let id = d.alloc_id(); + let r = shapes::rectangle(kurbo::Rect::new(i as f64, 0.0, i as f64 + 10.0, 10.0)); + d.insert(Some(layer), usize::MAX, Node::path(id, r, Appearance::default_art()))?; + } + Ok(()) + }) + .unwrap(); + s.execute("select.all", &json!({})).unwrap(); + s +} + +fn first_x(s: &Session) -> f64 { + let d = &s.doc().unwrap().doc; + let id = d.layers[0].children().unwrap()[0].id; + d.node(id).unwrap().geometric_bounds().unwrap().x0 +} + +#[test] +fn past_the_budget_the_oldest_steps_go_but_the_last_few_stay() { + let n = 500; + let mut s = session_with_rects(n); + s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap(); + let step = s.doc().unwrap().history.undo.last().unwrap().bytes; + // Each move keeps every rectangle's old node: about 700 bytes each (measured: ~775). + assert!((300 * n..1500 * n).contains(&step), "{step} bytes for moving {n} rectangles"); + // A budget of about three moves. + s.active_mut().unwrap().history.byte_budget = 3 * step; + for _ in 0..20 { + s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap(); + } + let st = s.doc().unwrap(); + assert_eq!(st.history.undo.len(), History::MIN_STEPS, "only the last few steps are kept"); + assert!(st.history.undo.iter().all(|e| e.label == "Move"), "the newest ones: {:?}", st.history.undo.iter().map(|e| &e.label).collect::>()); + // The kept steps undo as before. + let x = first_x(&s); + for _ in 0..History::MIN_STEPS { + s.execute("edit.undo", &json!({})).unwrap(); + } + assert_eq!(first_x(&s), x - History::MIN_STEPS as f64); + assert!(s.execute("edit.undo", &json!({})).is_err(), "the older steps went"); + // Redo too stays within the budget, keeping the next few steps. + let st = s.doc().unwrap(); + assert_eq!(st.history.redo.len(), History::MIN_STEPS); + for _ in 0..History::MIN_STEPS { + s.execute("edit.redo", &json!({})).unwrap(); + } + assert_eq!(first_x(&s), x); +} + +#[test] +fn within_the_budget_the_step_limit_still_counts() { + let mut s = session_with_rects(10); + s.active_mut().unwrap().history.limit = 12; + for _ in 0..30 { + s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap(); + } + let st = s.doc().unwrap(); + assert_eq!(st.history.undo.len(), 12); + assert!(st.history.bytes() < st.history.byte_budget); +} + +#[test] +fn undo_steps_keep_no_lookup_tables() { + let mut s = session_with_rects(200); + for _ in 0..5 { + s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap(); + // Lookups build the current document's table. + first_x(&s); + assert!(s.doc().unwrap().doc.has_lookup_cache()); + } + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let st = s.doc().unwrap(); + assert!(!st.history.undo.is_empty() && !st.history.redo.is_empty()); + for e in st.history.undo.iter().chain(&st.history.redo) { + assert!(!e.doc.has_lookup_cache(), "step {} keeps a lookup table", e.label); + } + // The document undone to builds its table again on its first lookup. + first_x(&s); + assert!(s.doc().unwrap().doc.has_lookup_cache()); + // A drag's snapshot goes into the history without its table too. + s.begin_interaction("Move").unwrap(); + s.preview("object.move", &json!({"dx": 3, "dy": 0})).unwrap(); + first_x(&s); + s.commit_interaction().unwrap(); + assert!(!s.doc().unwrap().history.undo.last().unwrap().doc.has_lookup_cache()); +} + +#[test] +fn steps_share_an_unchanged_selection() { + let mut s = session_with_rects(100); + for _ in 0..4 { + s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap(); + } + s.begin_interaction("Move").unwrap(); + s.preview("object.move", &json!({"dx": 3, "dy": 0})).unwrap(); + s.preview("object.move", &json!({"dx": 4, "dy": 0})).unwrap(); + s.commit_interaction().unwrap(); + let st = s.doc().unwrap(); + let moves: Vec<_> = st.history.undo.iter().filter(|e| e.label == "Move").collect(); + assert_eq!(moves.len(), 5); + assert_eq!(moves[0].selection.objects.len(), 100); + assert!(moves.windows(2).all(|w| Arc::ptr_eq(&w[0].selection, &w[1].selection)), "one selection for all the moves"); + // Undo and redo keep it shared, and restore it. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let st = s.doc().unwrap(); + assert!(Arc::ptr_eq(&st.history.redo[0].selection, &st.history.redo[1].selection)); + assert_eq!(st.selection.objects.len(), 100); +} diff --git a/crates/engine/src/tests_journal.rs b/crates/engine/src/tests_journal.rs index 832e1722..810be573 100644 --- a/crates/engine/src/tests_journal.rs +++ b/crates/engine/src/tests_journal.rs @@ -41,7 +41,7 @@ fn replay_reproduces_the_created_date_of_an_earlier_run() { s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap(); s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); // As if the original run happened long ago: a replay now must still land on its date. - s.journal[0].1["created"] = json!(1_234_567_890); + s.journal.get_mut(0).unwrap().1["created"] = json!(1_234_567_890); let mut r = Session::new(); for (id, p) in &s.journal { r.execute(id, p).unwrap(); @@ -63,7 +63,7 @@ fn recorded_actions_leave_out_replay_only_values() { // …while values resolved from the preferences stay recorded. let scale = steps.iter().find(|(id, _)| id == "object.scale").unwrap(); assert!(scale.1.get("strokes").is_some(), "{scale:?}"); - assert_eq!(s.journal_for_action(1), s.journal[1..].to_vec()); + assert_eq!(s.journal_for_action(1), s.journal.since(1).to_vec()); } /// The artboard the app passes to Paste in Place, in Front and in Back, to Align to Artboard, to @@ -87,7 +87,7 @@ fn recorded_actions_leave_out_the_artboard_the_app_passes() { for (id, p) in &steps { s.execute(id, p).unwrap(); } - assert!(s.journal[start..].iter().all(|(_, p)| p["artboard"] == json!(1)), "the journal keeps it: {:?}", &s.journal[start..]); + assert!(s.journal.since(start).iter().all(|(_, p)| p["artboard"] == json!(1)), "the journal keeps it: {:?}", s.journal.since(start)); let action = s.journal_for_action(start); assert_eq!(action.len(), steps.len()); for ((id, mut p), (step, recorded)) in steps.into_iter().zip(action) { diff --git a/crates/engine/tests/pen_endpoint_picking.rs b/crates/engine/tests/pen_endpoint_picking.rs index b6f29353..0d372a64 100644 --- a/crates/engine/tests/pen_endpoint_picking.rs +++ b/crates/engine/tests/pen_endpoint_picking.rs @@ -148,7 +148,7 @@ fn grid_continue(preselected: bool, snap_to_grid: bool) { assert_eq!(path_ids(&s), vec![id], "clicking the advertised endpoint must continue it, not create a snapped path"); assert_eq!(node(&s, id), &original); assert_eq!(s.doc().unwrap().selection.objects, vec![id]); - assert!(!s.journal[journal_start..].iter().any(|(command, _)| command == "path.create")); + assert!(!s.journal.since(journal_start).iter().any(|(command, _)| command == "path.create")); let before_append = s.doc().unwrap().history.undo.len(); gesture(&mut s, view, &[(200.0, 300.0)], Mods::default()); assert_eq!(path_ids(&s), vec![id]); @@ -328,7 +328,7 @@ fn snapped_only_continue(preselected: bool) { assert_eq!(s.doc().unwrap().selection.objects, vec![id]); let expected = if first { [(200.0, 300.0), (100.0, 300.0)] } else { [(100.0, 300.0), (200.0, 300.0)] }; assert_eq!(points(&s, id), expected, "continue from the snapped end: selected={preselected}, first={first}"); - assert!(!s.journal[journal_start..].iter().any(|(command, _)| command == "path.create")); + assert!(!s.journal.since(journal_start).iter().any(|(command, _)| command == "path.create")); assert!(!s.in_interaction() && !s.tool_busy()); assert_eq!(s.doc().unwrap().history.undo.len(), before_continue + usize::from(first)); let continued = node(&s, id).clone(); @@ -342,7 +342,7 @@ fn snapped_only_continue(preselected: bool) { assert_eq!(path_ids(&s), vec![id]); assert_eq!(s.doc().unwrap().selection.objects, vec![id]); assert_eq!(points(&s, id), [expected[0], expected[1], next]); - assert!(!s.journal[journal_start..].iter().any(|(command, _)| command == "path.create")); + assert!(!s.journal.since(journal_start).iter().any(|(command, _)| command == "path.create")); assert!(!s.in_interaction() && !s.tool_busy()); assert_eq!(s.doc().unwrap().history.undo.len(), before_append + 1); let extended = node(&s, id).clone(); @@ -393,7 +393,7 @@ fn raw_unselected_endpoint_precedes_snapped_selected_endpoint() { assert_eq!(path_ids(&s), vec![selected, raw]); assert_eq!(node(&s, selected), &original_selected); assert_eq!(node(&s, raw), &original_raw); - assert!(!s.journal[journal_start..].iter().any(|(command, _)| command == "path.create")); + assert!(!s.journal.since(journal_start).iter().any(|(command, _)| command == "path.create")); assert!(!s.in_interaction() && !s.tool_busy()); assert_eq!(s.doc().unwrap().history.undo.len(), before_append); @@ -402,7 +402,7 @@ fn raw_unselected_endpoint_precedes_snapped_selected_endpoint() { assert_eq!(s.doc().unwrap().selection.objects, vec![raw]); assert_eq!(node(&s, selected), &original_selected); assert_eq!(points(&s, raw), [(13.0, 303.0), (63.0, 303.0), (200.0, 400.0)]); - assert!(!s.journal[journal_start..].iter().any(|(command, _)| command == "path.create")); + assert!(!s.journal.since(journal_start).iter().any(|(command, _)| command == "path.create")); assert!(!s.in_interaction() && !s.tool_busy()); assert_eq!(s.doc().unwrap().history.undo.len(), before_append + 1); let extended = node(&s, raw).clone(); diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 2a3204aa..f61825a5 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -1528,7 +1528,8 @@ impl<'a> Reader<'a> { } if self.color_model.unwrap_or(self.cmyk_colors > self.rgb_colors) { doc.color_mode = ColorMode::Cmyk; - (doc.swatches, doc.swatch_groups) = vectorcraft_color::default_swatches(ColorMode::Cmyk.model()); + let (swatches, groups) = vectorcraft_color::default_swatches(ColorMode::Cmyk.model()); + (doc.swatches, doc.swatch_groups) = (swatches.into(), groups.into()); } for n in &self.spots { if doc.swatch(&n.name).is_none() { diff --git a/crates/eps/src/import/mod.rs b/crates/eps/src/import/mod.rs index 02906f66..d6e9f92a 100644 --- a/crates/eps/src/import/mod.rs +++ b/crates/eps/src/import/mod.rs @@ -250,7 +250,8 @@ fn finish_with(out: Out, layers: Vec>) -> Imported { let mut doc = out.doc; if out.cmyk > out.rgb { doc.color_mode = ColorMode::Cmyk; - (doc.swatches, doc.swatch_groups) = vectorcraft_color::default_swatches(ColorMode::Cmyk.model()); + let (swatches, groups) = vectorcraft_color::default_swatches(ColorMode::Cmyk.model()); + (doc.swatches, doc.swatch_groups) = (swatches.into(), groups.into()); } for (name, color) in out.spots { if doc.swatch(&name).is_none() { diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index a8a39eba..d61e06f8 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -336,7 +336,8 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result, + journal: vectorcraft_engine::Journal, dirty: bool, } diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index b211a5cf..543da8ac 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -98,6 +98,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { && let Some((set, name, start)) = app.ui.recording.take() { let steps = app.session.journal_for_action(start); + app.session.journal.hold(None); if let Some(s) = app.ui.action_sets.get_mut(set) { s.actions.push(Action { name, steps }); } @@ -110,7 +111,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { app.ui.action_sets.push(ActionSet { name: "Set 1".into(), actions: vec![] }); } let set = selected.map(|s| s.0).unwrap_or(0); - app.ui.recording = Some((set, format!("Action {n}"), app.session.journal.len())); + let start = app.session.journal.len(); + // Keep every step from here on until recording stops, however long it runs. + app.session.journal.hold(Some(start)); + app.ui.recording = Some((set, format!("Action {n}"), start)); } if widgets::icon_button(ui, "dc-actions", tl!("Play Current Selection"), false, 24.0).clicked() && let Some(s) = selected diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 518d5e5e..122a4001 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -514,7 +514,7 @@ fn move_params(d: &Document, names: &[String], target: Option<&str>, after: bool Some(t) => { let (group, list) = match d.swatch_group_of(t) { Some(g) => (Some(d.swatch_groups[g].name.as_str()), &d.swatch_groups[g].swatches), - None => (None, &d.swatches), + None => (None, &*d.swatches), }; (group, list.iter().position(|w| w.name == t).map(|i| i + usize::from(after))) } @@ -1415,7 +1415,7 @@ mod tests { let d = &app.session.active().unwrap().doc; let list = match group { Some(g) => &d.swatch_groups.iter().find(|x| x.name == g).unwrap().swatches, - None => &d.swatches, + None => &*d.swatches, }; list.iter().map(|w| w.name.clone()).collect() } From 9139883075f84fe26e80950a90d5ac29139286cb Mon Sep 17 00:00:00 2001 From: arhammusheer Date: Sun, 11 Oct 2026 23:28:11 +0530 Subject: [PATCH 03/12] #1244: navigator and layer thumbnails render off the UI thread and wait for drags to end The navigator re-rendered the whole document on the UI thread on every revision, including each drag step, and layer thumbnails re-rendered whole layers the same way. Both now keep the last image while an interaction runs, render on a background worker once the document settles (synchronously on wasm and in tests), and turn on image mips. Thumbnails are keyed by node id with a Weak, not by a reusable address. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc --- crates/ui-egui/src/canvas.rs | 2 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/panels/asset_export.rs | 4 +- crates/ui-egui/src/panels/layers.rs | 275 +++++++++++++++++++--- crates/ui-egui/src/panels/links.rs | 8 +- crates/ui-egui/src/panels/navigator.rs | 199 ++++++++++++++-- crates/ui-egui/src/panels/transparency.rs | 14 +- crates/ui-egui/src/thumb_worker.rs | 177 ++++++++++++++ 8 files changed, 613 insertions(+), 67 deletions(-) create mode 100644 crates/ui-egui/src/thumb_worker.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9034e1d7..f51c6992 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -305,6 +305,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { smooth_images, // Isolation mode: the art around the isolated group or layer is dimmed (#833). isolated: key.isolated.map(vectorcraft_doc::NodeId), + // Images far smaller on screen than they are sample their mip levels (#1244). + image_mips: true, ..opts }; // Light documents render synchronously (no lag vs overlays); heavy ones go to the worker. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2def8922..f2a5cbc0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -51,6 +51,7 @@ pub mod shortcuts; pub mod state; pub mod sysclip; pub mod theme; +pub mod thumb_worker; pub mod titlebar; pub mod toolbar; mod touch; diff --git a/crates/ui-egui/src/panels/asset_export.rs b/crates/ui-egui/src/panels/asset_export.rs index ced2d907..2583f74a 100644 --- a/crates/ui-egui/src/panels/asset_export.rs +++ b/crates/ui-egui/src/panels/asset_export.rs @@ -68,10 +68,10 @@ pub(crate) fn paint_thumb(ui: &Ui, st: &DocState, a: &ExportAsset, r: Rect) { let tex = CACHE.with(|c| c.borrow().get(&key).cloned()).or_else(|| { let art: Vec<&Node> = ids().iter().filter_map(|id| st.doc.node(*id)).collect(); let img = RENDERER.with(|rr| match art.as_slice() { - [one] => rr.borrow_mut().render_node_thumbnail(&st.doc, one, px, None), + [one] => rr.borrow_mut().render_node_thumbnail_with(&st.doc, one, px, None, &super::layers::thumb_options()), many => { let group = Node::group(NodeId(0), many.iter().map(|n| Arc::new((*n).clone())).collect()); - rr.borrow_mut().render_node_thumbnail(&st.doc, &group, px, None) + rr.borrow_mut().render_node_thumbnail_with(&st.doc, &group, px, None, &super::layers::thumb_options()) } })?; let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 8cc08516..43d62fcd 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -30,8 +30,8 @@ //! those scrolled into view are laid out, so a document of many thousand objects costs no more //! per frame than a small one. -use std::collections::HashSet; -use std::sync::Arc; +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Weak}; use egui::{Color32, Sense, Stroke, StrokeKind, Ui, vec2}; use serde::{Deserialize, Serialize}; @@ -40,6 +40,7 @@ use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; use vectorcraft_engine::OpenRows; use crate::theme::Tokens; +use crate::thumb_worker::{Settle, ThumbWorker}; use crate::widgets::{Live, PanelDrag}; use crate::{VectorcraftApp, icons, widgets}; @@ -136,12 +137,12 @@ fn colour_bar(ui: &Ui, n: &Node, r: egui::Rect, x: f32, colour: Color32) { } /// The thumbnail of row `n` (rect `r`) at `x`; returns where it went. -fn row_thumb(ui: &Ui, view: &View, n: &Node, r: egui::Rect, x: f32) -> egui::Rect { +fn row_thumb(ui: &Ui, view: &View, n: &Arc, r: egui::Rect, x: f32) -> egui::Rect { let s = (view.h - 4.0).min(40.0); let th = egui::Rect::from_min_size(egui::pos2(x, r.center().y - s / 2.0), vec2(s, s)); ui.painter().rect_filled(th, 0.0, Color32::WHITE); ui.painter().rect_stroke(th, 0.0, Stroke::new(1.0, view.t.border), StrokeKind::Outside); - if !real_thumb(ui, view.doc, n, th) { + if !real_thumb(ui, &view.thumbs, n, th) { thumb(ui, n, th); } th @@ -151,7 +152,8 @@ fn row_thumb(ui: &Ui, view: &View, n: &Node, r: egui::Rect, x: f32) -> egui::Rec /// colour bar, thumbnail and name. fn drag_ghost(ui: &Ui, view: &View, src: NodeId, pos: egui::Pos2) { let (doc, t, h) = (view.doc, &view.t, view.h); - let Some(n) = doc.node(src) else { return }; + let Some(n) = node_arc(doc, src) else { return }; + let n = &n; let (grab, width, depth) = ui.data(|d| d.get_temp::<(egui::Vec2, f32, usize)>(key("grab"))).unwrap_or((vec2(h, h / 2.0), 240.0, 0)); // A plain Ui on the tooltip layer, not an Area: a new Area is invisible for a frame and fades in. let r = egui::Rect::from_min_size(pos - grab, vec2(width, h)); @@ -312,7 +314,9 @@ fn rows_of_doc(ui: &Ui, st: &vectorcraft_engine::DocState, opts: &PanelOptions, /// Everything one frame of the panel reads. struct View<'a> { - doc: &'a Document, + doc: &'a Arc, + /// Where the row thumbnails come from. + thumbs: ThumbSource<'a>, sel: HashSet, /// The selected ruler guides. guides: Vec, @@ -375,6 +379,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let list = rows_of_doc(ui, st, &app.ui.layers_panel, &query); let view = View { doc: &doc, + thumbs: ThumbSource::of(app, st, &doc), sel: st.selection.objects.iter().copied().collect(), guides: st.selection.guides.clone(), target: st.selection.target, @@ -676,7 +681,7 @@ fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) { x += 16.0; // Thumbnail. let thumb_rect = view.opts.thumb(n).then(|| { - let th = row_thumb(ui, view, n, r, x); + let th = row_thumb(ui, view, &item.node, r, x); x = th.right() + 4.0; th }); @@ -941,35 +946,215 @@ fn has_styled_target(n: &Node) -> bool { || !n.has_default_transparency() } -/// A real rendered thumbnail, cached by node identity (unchanged nodes keep their `Arc` -/// allocation, so the address is a free change detector). Only rendered for visible rows. -pub(crate) fn real_thumb(ui: &Ui, doc: &vectorcraft_doc::Document, n: &Node, r: egui::Rect) -> bool { - use std::cell::RefCell; - use std::collections::HashMap; - thread_local! { - static RENDERER: RefCell = RefCell::new(vectorcraft_render::Renderer::new()); - static CACHE: crate::graphics::TexCache> = crate::graphics::TexCache::default(); +/// Where row thumbnails come from: document `uid` at `revision`, and whether a drag is in +/// progress (the thumbnails shown then stay until it ends). +pub(crate) struct ThumbSource<'a> { + pub uid: u64, + pub revision: u64, + pub doc: &'a Arc, + pub busy: bool, +} + +impl<'a> ThumbSource<'a> { + /// The thumbnails of the document `st` holds (`doc`, its document). + pub(crate) fn of(app: &VectorcraftApp, st: &vectorcraft_engine::DocState, doc: &'a Arc) -> Self { + Self { uid: st.uid, revision: st.revision, doc, busy: st.interaction.is_some() || app.session.tool_busy() } } +} + +/// Node `id` of `doc`, as the `Arc` its parent holds. +pub(crate) fn node_arc(doc: &Document, id: NodeId) -> Option> { + let path = doc.index_path(id)?; + let (first, rest) = path.split_first()?; + let mut cur = doc.layers.get(*first)?; + for &i in rest { + cur = cur.children()?.get(i)?; + } + Some(cur.clone()) +} + +/// A row thumbnail's image. +struct ThumbEntry { + /// The node it shows. Weak, so the cache doesn't keep old art alive, but its allocation stays + /// and no other node can come to have its address. + node: Weak, + px: u32, + /// None: the node draws nothing. + tex: Option, + /// How long it took to render (ms). + ms: f64, + /// The pass it was last drawn in. + used: u64, +} + +/// A row thumbnail job: the row (document uid, node), the node and the size rendered. +type ThumbJob = ((u64, NodeId), Weak, u32); + +#[derive(Default)] +struct Thumbs { + entries: HashMap<(u64, NodeId), ThumbEntry>, + /// What the worker is rendering, by row. + pending: HashMap<(u64, NodeId), (Weak, u32)>, + worker: Option>, + worker_started: bool, + settle: Settle, + /// (pass, ms, count) rendered on the UI thread in that pass. + spent: (u64, f64, u32), + /// Thumbnails rendered on the UI thread (tests count them). + renders: u64, +} + +thread_local! { + static THUMBS: crate::graphics::TexCache = crate::graphics::TexCache::default(); +} + +/// The most row thumbnails kept: past it the ones drawn longest ago go. +const MAX_THUMBS: usize = 512; +/// Rendering a thumbnail that took longer than this (ms) again goes to the worker. +const LIGHT_THUMB_MS: f64 = 4.0; +/// A node with more objects than this inside has its first thumbnail made on the worker. +const LIGHT_THUMB_NODES: usize = 200; +/// At most this long (ms) and this many thumbnails are rendered on the UI thread in a pass; the +/// rest go to the worker, or wait for the next pass where there is none. +const THUMB_BUDGET_MS: f64 = 8.0; +const THUMB_BUDGET_COUNT: u32 = 16; + +/// How row thumbnails render: images far smaller than they are sample their mip levels. +pub(crate) fn thumb_options() -> vectorcraft_render::RenderOptions { + vectorcraft_render::RenderOptions { image_mips: true, ..Default::default() } +} + +/// Has `n` at most `limit` objects inside it (counting stops there)? +fn small_node(n: &Node, limit: usize) -> bool { + fn count(n: &Node, left: &mut usize) -> bool { + for c in n.children().into_iter().flatten() { + if *left == 0 { + return false; + } + *left -= 1; + if !count(c, left) { + return false; + } + } + true + } + let mut left = limit; + count(n, &mut left) +} + +impl Thumbs { + /// Keep `img` (None: nothing drawn) as the thumbnail of row `key` showing `node`. + fn upload(&mut self, ctx: &egui::Context, key: (u64, NodeId), node: Weak, px: u32, img: Option<&vectorcraft_render::Rendered>, ms: f64) { + let color = img.map(|img| egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels)); + let used = ctx.cumulative_pass_nr(); + let name = format!("layer-thumb-{}", key.1.0); + match self.entries.get_mut(&key) { + Some(e) => { + match (color, &mut e.tex) { + (Some(color), Some(tex)) => tex.set(color, egui::TextureOptions::LINEAR), + (Some(color), tex) => *tex = Some(ctx.load_texture(name, color, egui::TextureOptions::LINEAR)), + (None, tex) => *tex = None, + } + (e.node, e.px, e.ms, e.used) = (node, px, ms, used); + } + None => { + let tex = color.map(|color| ctx.load_texture(name, color, egui::TextureOptions::LINEAR)); + self.entries.insert(key, ThumbEntry { node, px, tex, ms, used }); + if self.entries.len() > MAX_THUMBS { + // Keep the three quarters drawn last. + let mut by_use: Vec = self.entries.values().map(|e| e.used).collect(); + by_use.sort_unstable(); + let cut = by_use.get(by_use.len() - MAX_THUMBS * 3 / 4).copied().unwrap_or(0); + self.entries.retain(|_, e| e.used >= cut); + } + } + } + } + + /// The texture of row `n` (`px` pixels square) to draw: made or updated now, or on the + /// worker (the old one is drawn meanwhile). Thumbnails shown stay during a drag and until + /// the document settles. + fn texture(&mut self, ctx: &egui::Context, src: &ThumbSource, n: &Arc, px: u32) -> Option { + if !self.worker_started { + self.worker_started = true; + self.worker = ThumbWorker::spawn(ctx, "thumbnails"); + } + let done = match self.worker.as_ref().map(ThumbWorker::poll) { + Some(Some(done)) => done, + Some(None) => { + // The thread is gone: render here from now on. + self.worker = None; + self.pending.clear(); + vec![] + } + None => vec![], + }; + for d in done { + let (key, node, px) = d.key; + if self.pending.get(&key).is_some_and(|(p, ppx)| p.ptr_eq(&node) && *ppx == px) { + self.pending.remove(&key); + } + self.upload(ctx, key, node, px, d.img.as_ref(), d.ms); + } + let key = (src.uid, n.id); + let pass = ctx.cumulative_pass_nr(); + let (fresh, light) = match self.entries.get_mut(&key) { + Some(e) => { + e.used = pass; + (std::ptr::eq(e.node.as_ptr(), Arc::as_ptr(n)) && e.px == px, Some(e.ms <= LIGHT_THUMB_MS)) + } + None => (false, None), + }; + let shown = self.entries.get(&key).and_then(|e| e.tex.clone()); + if fresh || self.pending.get(&key).is_some_and(|(p, ppx)| std::ptr::eq(p.as_ptr(), Arc::as_ptr(n)) && *ppx == px) { + return shown; + } + if light.is_some() && (src.busy || !self.settle.settled(ctx, src.uid, src.revision, ctx.input(|i| i.time))) { + return shown; + } + if self.spent.0 != pass { + self.spent = (pass, 0.0, 0); + } + let in_budget = self.spent.1 < THUMB_BUDGET_MS && self.spent.2 < THUMB_BUDGET_COUNT; + let light = light.unwrap_or_else(|| small_node(n, LIGHT_THUMB_NODES)) && in_budget; + if !light && let Some(w) = &self.worker { + let (doc, node) = (src.doc.clone(), n.clone()); + let job: ThumbJob = (key, Arc::downgrade(n), px); + if w.submit(key, job, Box::new(move |r| r.render_node_thumbnail_with(&doc, &node, px, None, &thumb_options()))) { + self.pending.insert(key, (Arc::downgrade(n), px)); + return shown; + } + self.worker = None; + self.pending.clear(); + } + if !in_budget { + ctx.request_repaint(); + return shown; + } + thread_local! { + static RENDERER: std::cell::RefCell = std::cell::RefCell::new(vectorcraft_render::Renderer::new()); + } + let t0 = crate::now_ms(); + let img = RENDERER.with(|r| r.borrow_mut().render_node_thumbnail_with(src.doc, n, px, None, &thumb_options())); + let ms = crate::now_ms() - t0; + self.spent.1 += ms; + self.spent.2 += 1; + self.renders += 1; + self.upload(ctx, key, Arc::downgrade(n), px, img.as_ref(), ms); + self.entries.get(&key).and_then(|e| e.tex.clone()) + } +} + +/// A real rendered thumbnail of row `n`, cached by node identity (unchanged nodes keep their `Arc` +/// allocation, so the address is a free change detector). Only rendered for visible rows; see +/// [`Thumbs::texture`] for when. +pub(crate) fn real_thumb(ui: &Ui, src: &ThumbSource, n: &Arc, r: egui::Rect) -> bool { if !ui.is_rect_visible(r) { return true; } - let key = (n as *const Node as usize, n.id.0); let ppp = ui.ctx().pixels_per_point(); - let px = (r.width() * ppp).round() as u32; - let tex = CACHE.with(|c| c.borrow().get(&key).cloned()).or_else(|| { - let img = RENDERER.with(|rr| rr.borrow_mut().render_thumbnail(doc, n.id, px.max(8)))?; - let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); - let tex = ui.ctx().load_texture(format!("layer-thumb-{}", n.id.0), color, egui::TextureOptions::LINEAR); - CACHE.with(|c| { - let mut c = c.borrow_mut(); - if c.len() > 2000 { - c.clear(); - } - c.insert(key, tex.clone()); - }); - Some(tex) - }); - match tex { + let px = ((r.width() * ppp).round() as u32).max(8); + match THUMBS.with(|c| c.borrow_mut().texture(ui.ctx(), src, n, px)) { Some(t) => { ui.painter().image(t.id(), r, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE); true @@ -1303,6 +1488,34 @@ mod tests { (app, layer, [NodeId(a), NodeId(b)]) } + #[test] + fn row_thumbnails_wait_for_the_end_of_a_drag() { + let (mut app, _, _) = two_rects(); + app.session.execute("select.all", &json!({})).unwrap(); + let ctx = egui::Context::default(); + let renders = || THUMBS.with(|c| c.borrow().renders); + frame(&mut app, &ctx, vec![], false); + let first = renders(); + assert!(first >= 1, "the rows' thumbnails render at once"); + frame(&mut app, &ctx, vec![], false); + assert_eq!(renders(), first, "and stay"); + // A move drag: the layer (and the moved objects) change on every preview. + app.session.begin_interaction("Move").unwrap(); + for i in 1..=10 { + app.session.preview("object.transform", &json!({"matrix": [1, 0, 0, 1, i, 0]})).unwrap(); + frame(&mut app, &ctx, vec![], false); + } + assert_eq!(renders(), first, "nothing renders while the drag goes on"); + app.session.commit_interaction().unwrap(); + for _ in 0..12 { + frame(&mut app, &ctx, vec![], false); + } + assert!(renders() > first, "the changed rows render after it"); + let after = renders(); + frame(&mut app, &ctx, vec![], false); + assert_eq!(renders(), after, "once"); + } + #[test] fn clicking_a_target_circle_targets_the_layer() { let (mut app, layer, [a, b]) = two_rects(); diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs index 154b26eb..8ba4fb17 100644 --- a/crates/ui-egui/src/panels/links.rs +++ b/crates/ui-egui/src/panels/links.rs @@ -234,7 +234,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { widgets::dim_label(ui, tl!("No document")); return; }; - let Some(doc) = app.session.active().map(|st| st.doc.clone()) else { return }; + let Some(st) = app.session.active() else { return }; + let doc = st.doc.clone(); + let thumbs = super::layers::ThumbSource::of(app, st, &doc); let selected = selected_images(app); let rows: &[Value] = cache.list["links"].as_array().map_or(&[], Vec::as_slice); let mut clicked: Option<(u64, bool, bool)> = None; @@ -267,8 +269,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if o.thumb > 0.0 { let tr = egui::Rect::from_min_size(egui::pos2(x, rect.center().y - o.thumb / 2.0), vec2(o.thumb, o.thumb)); ui.painter().rect_filled(tr, 0.0, egui::Color32::WHITE); - if let Some(n) = doc.node(NodeId(id)) { - super::layers::real_thumb(ui, &doc, n, tr); + if let Some(n) = super::layers::node_arc(&doc, NodeId(id)) { + super::layers::real_thumb(ui, &thumbs, &n, tr); } ui.painter().rect_stroke(tr, 0.0, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Inside); x = tr.right() + 6.0; diff --git a/crates/ui-egui/src/panels/navigator.rs b/crates/ui-egui/src/panels/navigator.rs index 70ef3144..d2ac1603 100644 --- a/crates/ui-egui/src/panels/navigator.rs +++ b/crates/ui-egui/src/panels/navigator.rs @@ -1,7 +1,10 @@ -//! Navigator panel: a thumbnail of the document (rendered with `Renderer::render_region`, cached by -//! document revision) with the red view box you drag to pan, zoom out/in buttons, a zoom slider +//! Navigator panel: a thumbnail of the document (rendered with `Renderer::render_region_with`, +//! cached by document revision, off the UI thread for heavy documents and not during drags: see +//! [`crate::thumb_worker`]) with the red view box you drag to pan, zoom out/in buttons, a zoom slider //! and a zoom field. +use std::sync::Arc; + use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::json; use vectorcraft_geom::{Point, Rect as DRect}; @@ -9,19 +12,135 @@ use vectorcraft_geom::{Point, Rect as DRect}; use super::{pstate, set_pstate}; use crate::VectorcraftApp; use crate::theme::Tokens; +use crate::thumb_worker::{Settle, ThumbWorker}; use crate::widgets::{self, menu_item}; +/// What a navigator image shows: document `uid` at `revision`, `w`×`h` pixels, the artboards only. +#[derive(Clone, Copy, Debug, PartialEq)] +struct ImageKey { + uid: u64, + revision: u64, + w: u32, + h: u32, + artboard_only: bool, +} + #[derive(Default)] struct Cache { - renderer: Option, - key: Option<(usize, u64, u32, u32, bool)>, + /// Renders heavy documents' images (None: on this thread). + worker: Option>, + worker_started: bool, + /// The region shown for (uid, revision, artboard only): the art bounds walk the whole document. + region: Option<((u64, u64, bool), DRect)>, + /// The image shown: what it shows and the document rect it covers. + shown: Option<(ImageKey, DRect)>, tex: Option, + /// The image the worker is making. + pending: Option, + /// How long the last image took: past [`HEAVY_MS`] the next goes to the worker. + last_ms: f64, + settle: Settle, + /// Images made (tests count them). + renders: u64, } +/// An image that took longer than this (ms) makes the next one render off the UI thread. +const HEAVY_MS: f64 = 8.0; + thread_local! { static CACHE: crate::graphics::TexCache = crate::graphics::TexCache::default(); } +impl Cache { + /// Upload image `r` showing `key` over `region`. + fn upload(&mut self, ctx: &egui::Context, key: ImageKey, region: DRect, r: &vectorcraft_render::Rendered, ms: f64) { + let img = egui::ColorImage::from_rgba_premultiplied([r.width as usize, r.height as usize], &r.pixels); + match &mut self.tex { + Some(tx) => tx.set(img, egui::TextureOptions::LINEAR), + None => self.tex = Some(ctx.load_texture("navigator", img, egui::TextureOptions::LINEAR)), + } + self.shown = Some((key, region)); + self.last_ms = ms; + self.renders += 1; + } + + /// The region (artboards, and the art unless `artboard_only`, with a margin) to show + /// `doc` (`uid` at `revision`) in. While `busy` (a drag) the last one of the document stays, + /// rather than walking the whole document every frame. + fn region(&mut self, doc: &vectorcraft_doc::Document, uid: u64, revision: u64, artboard_only: bool, busy: bool) -> DRect { + let key = (uid, revision, artboard_only); + if let Some((k, r)) = self.region + && (k == key || (busy && k.0 == uid && k.2 == artboard_only)) + { + return r; + } + let mut region = doc.artboards.iter().map(|a| a.rect).reduce(|a, b| a.union(b)).unwrap_or(DRect::new(0.0, 0.0, 612.0, 792.0)); + if !artboard_only && let Some(b) = doc.art_bounds() { + region = region.union(b); + } + let region = region.inflate(region.width() * 0.04 + 4.0, region.height() * 0.04 + 4.0); + self.region = Some((key, region)); + region + } + + /// Bring the image up to `key` (`doc` over `region`): heavy documents render on the worker + /// while the last image stays, and nothing renders during a drag (`busy`) or before the + /// document has settled, unless there's no image of it yet. + fn update(&mut self, ctx: &egui::Context, doc: &Arc, key: ImageKey, region: DRect, busy: bool) { + if !self.worker_started { + self.worker_started = true; + self.worker = ThumbWorker::spawn(ctx, "navigator"); + } + let done = match self.worker.as_ref().map(ThumbWorker::poll) { + Some(Some(done)) => done, + Some(None) => { + // The thread is gone: render here from now on. + (self.worker, self.pending) = (None, None); + vec![] + } + None => vec![], + }; + for d in done { + if self.pending == Some(d.key.0) { + self.pending = None; + } + if let Some(img) = d.img { + self.upload(ctx, d.key.0, d.key.1, &img, d.ms); + } + } + let shown = self.shown.map(|(k, _)| k); + if shown == Some(key) || self.pending == Some(key) { + return; + } + let have = self.tex.is_some() && shown.is_some_and(|k| k.uid == key.uid); + let now = ctx.input(|i| i.time); + if have && (busy || !self.settle.settled(ctx, key.uid, key.revision, now)) { + return; + } + let s = (key.w as f64 / region.width()).min(key.h as f64 / region.height()); + // As exported (template layers and guides left out), transparent; images far smaller than + // they are sample their mip levels. + let opts = vectorcraft_render::RenderOptions { skip_templates: true, image_mips: true, ..Default::default() }; + let heavy = self.last_ms > HEAVY_MS && have; + if heavy && let Some(w) = &self.worker { + let (doc, opts) = (doc.clone(), opts.clone()); + if w.submit((), (key, region), Box::new(move |r| Some(r.render_region_with(&doc, region, s, &opts)))) { + self.pending = Some(key); + return; + } + self.worker = None; + } + let t0 = crate::now_ms(); + let img = NAV_RENDERER.with(|r| r.borrow_mut().render_region_with(doc, region, s, &opts)); + self.upload(ctx, key, region, &img, crate::now_ms() - t0); + } +} + +thread_local! { + /// Renders light documents' images on the UI thread. + static NAV_RENDERER: std::cell::RefCell = std::cell::RefCell::new(vectorcraft_render::Renderer::new()); +} + /// Map a document point into the thumbnail rect (region → rect, aspect preserved). pub fn doc_to_thumb(region: DRect, thumb: Rect, p: Point) -> egui::Pos2 { let s = (thumb.width() as f64 / region.width()).min(thumb.height() as f64 / region.height()); @@ -54,33 +173,25 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }; let artboard_only: bool = pstate(ui.ctx(), "nav-ab-only"); let doc = st.doc.clone(); - let rev = st.revision; - let doc_idx = app.session.documents().iter().position(|d| std::sync::Arc::ptr_eq(&d.doc, &doc)).unwrap_or(0); - let mut region = doc.artboards.iter().map(|a| a.rect).reduce(|a, b| a.union(b)).unwrap_or(DRect::new(0.0, 0.0, 612.0, 792.0)); - if !artboard_only && let Some(b) = doc.art_bounds() { - region = region.union(b); - } - let region = region.inflate(region.width() * 0.04 + 4.0, region.height() * 0.04 + 4.0); + let (uid, revision) = (st.uid, st.revision); + // A drag in progress: every pointer move makes a new revision. The image waits for its end. + let busy = st.interaction.is_some() || app.session.tool_busy(); + let region = CACHE.with(|c| c.borrow_mut().region(&doc, uid, revision, artboard_only, busy)); let w = ui.available_width(); let h = (w * 0.72).clamp(100.0, 200.0); let (thumb, resp) = ui.allocate_exact_size(vec2(w, h), Sense::click_and_drag()); ui.painter().rect_filled(thumb, 0.0, t.pasteboard); let ppp = ui.ctx().pixels_per_point(); let (pw, ph) = ((w * ppp) as u32, (h * ppp) as u32); - let key = (doc_idx, rev, pw, ph, artboard_only); - let tex = CACHE.with(|c| { + let key = ImageKey { uid, revision, w: pw, h: ph, artboard_only }; + let (tex, tex_region) = CACHE.with(|c| { let mut c = c.borrow_mut(); - if c.key != Some(key) || c.tex.is_none() { - let s = (pw as f64 / region.width()).min(ph as f64 / region.height()); - let r = c.renderer.get_or_insert_with(vectorcraft_render::Renderer::new).render_region(&doc, region, s, false); - let img = egui::ColorImage::from_rgba_premultiplied([r.width as usize, r.height as usize], &r.pixels); - match &mut c.tex { - Some(tx) => tx.set(img, egui::TextureOptions::LINEAR), - None => c.tex = Some(ui.ctx().load_texture("navigator", img, egui::TextureOptions::LINEAR)), - } - c.key = Some(key); + c.update(ui.ctx(), &doc, key, region, busy); + // Never another document's image. + match c.shown { + Some((k, r)) if k.uid == uid => (c.tex.clone(), r), + _ => (None, region), } - c.tex.clone() }); // Artboards (white) under the art thumbnail, like the canvas. for a in &doc.artboards { @@ -88,8 +199,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.painter().rect_filled(r, 0.0, Color32::WHITE); } if let Some(tex) = tex { - let tl = doc_to_thumb(region, thumb, region.origin()); - let br = doc_to_thumb(region, thumb, Point::new(region.x1, region.y1)); + let tl = doc_to_thumb(region, thumb, tex_region.origin()); + let br = doc_to_thumb(region, thumb, Point::new(tex_region.x1, tex_region.y1)); ui.painter().image(tex.id(), Rect::from_min_max(tl, br), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } // The view box. @@ -164,4 +275,42 @@ mod tests { assert!((zoom_to_slider(64000.0) - 1.0).abs() < 1e-9); assert!((slider_to_zoom(zoom_to_slider(100.0)) - 100.0).abs() < 1e-6); } + + /// The navigator images made on this thread so far. + fn renders() -> u64 { + CACHE.with(|c| c.borrow().renders) + } + + #[test] + fn no_image_is_rendered_during_a_drag_and_one_is_after_it() { + use vectorcraft_engine::Session; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + // One context, so its clock runs on (1/60 s a pass) and the document can settle. + let ctx = egui::Context::default(); + let frame = |app: &mut VectorcraftApp| { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); + out.textures_delta.clear(); + }; + frame(&mut app); + let first = renders(); + assert!(first >= 1, "the first image renders at once"); + // A move drag: every preview is a new revision. + app.session.begin_interaction("Move").unwrap(); + for i in 1..=20 { + app.session.preview("object.transform", &json!({"matrix": [1, 0, 0, 1, i, 0]})).unwrap(); + frame(&mut app); + } + assert_eq!(renders(), first, "nothing renders while the drag goes on"); + app.session.commit_interaction().unwrap(); + frame(&mut app); + assert_eq!(renders(), first, "the document settles first"); + for _ in 0..12 { + frame(&mut app); + } + assert_eq!(renders(), first + 1, "one image after the drag"); + let shown = CACHE.with(|c| c.borrow().shown.map(|(k, _)| k.revision)); + assert_eq!(shown, Some(app.session.active().unwrap().revision)); + } } diff --git a/crates/ui-egui/src/panels/transparency.rs b/crates/ui-egui/src/panels/transparency.rs index c38d7ac7..7a5375d1 100644 --- a/crates/ui-egui/src/panels/transparency.rs +++ b/crates/ui-egui/src/panels/transparency.rs @@ -248,7 +248,8 @@ fn mask_items(mask: Option<&OpacityMask>, can_make: bool) -> [(&'static str, &'s } /// A rendered thumbnail of `n` (which may live outside the tree, like mask art). One texture per -/// slot, re-rendered only when the document revision, object or size changes. +/// slot, re-rendered only when the document revision, object or size changes, and not during a +/// drag (every pointer move is a new revision): the last one stays until it ends. fn node_thumb(app: &VectorcraftApp, ui: &Ui, slot: &str, n: &vectorcraft_doc::Node, r: egui::Rect, bg: Option<[u8; 4]>) -> bool { use std::cell::RefCell; thread_local! { @@ -256,14 +257,15 @@ fn node_thumb(app: &VectorcraftApp, ui: &Ui, slot: &str, n: &vectorcraft_doc::No } let Some(st) = app.session.active() else { return false }; let px = (r.width().min(r.height()) * ui.ctx().pixels_per_point()).round().max(8.0) as u32; - let (slot_id, key) = (egui::Id::new(slot), egui::Id::new((st.uid, st.revision, n.id, bg, px))); - let cached: Option<(egui::Id, egui::TextureHandle)> = ui.ctx().data(|d| d.get_temp(slot_id)); + let (slot_id, key) = (egui::Id::new(slot), egui::Id::new((st.uid, n.id, bg, px))); + let busy = st.interaction.is_some() || app.session.tool_busy(); + let cached: Option<(egui::Id, u64, egui::TextureHandle)> = ui.ctx().data(|d| d.get_temp(slot_id)); let tex = match cached { - Some((k, tex)) if k == key => Some(tex), - _ => RENDERER.with(|rr| rr.borrow_mut().render_node_thumbnail(&st.doc, n, px, bg)).map(|img| { + Some((k, rev, tex)) if k == key && (rev == st.revision || busy) => Some(tex), + _ => RENDERER.with(|rr| rr.borrow_mut().render_node_thumbnail_with(&st.doc, n, px, bg, &super::layers::thumb_options())).map(|img| { let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); let tex = ui.ctx().load_texture(slot, color, egui::TextureOptions::LINEAR); - ui.ctx().data_mut(|d| d.insert_temp(slot_id, (key, tex.clone()))); + ui.ctx().data_mut(|d| d.insert_temp(slot_id, (key, st.revision, tex.clone()))); tex }), }; diff --git a/crates/ui-egui/src/thumb_worker.rs b/crates/ui-egui/src/thumb_worker.rs new file mode 100644 index 00000000..31ee3dd0 --- /dev/null +++ b/crates/ui-egui/src/thumb_worker.rs @@ -0,0 +1,177 @@ +//! Panel thumbnails (the Navigator's view of the document, the Layers and Links rows) rendered +//! off the UI thread on native builds, so a heavy document never stalls a frame. The panels keep +//! showing their last image until the new one is back, and leave thumbnails alone while the user +//! drags (every pointer move makes a new document revision): they update once the drag ends and +//! the document has [`Settle`]d. +//! +//! On wasm (no threads) and in unit tests [`ThumbWorker::spawn`] gives `None` and the panels +//! render on the UI thread, with the same rules about drags and settling. + +use std::collections::VecDeque; +use std::sync::mpsc::{Receiver, Sender, TryRecvError, channel}; + +use vectorcraft_render::{Rendered, Renderer}; + +/// How long (seconds) the document must stay unchanged before a thumbnail that is already shown +/// is drawn again. +pub const SETTLE_SECS: f64 = 0.1; + +/// A render a panel asks for: run on the worker's own [`Renderer`]. +pub type JobFn = Box Option + Send>; + +/// A finished job: the key it was submitted with, its image and how long it took. +pub struct Done { + pub key: K, + pub img: Option, + pub ms: f64, +} + +struct Job { + slot: S, + key: K, + run: JobFn, +} + +/// A render thread for one panel's thumbnails. Jobs go in by slot (the Navigator has one, the +/// Layers panel one per row): a job not started yet is replaced by a newer one for its slot, so +/// only the latest image of anything is drawn. +pub struct ThumbWorker { + tx: Sender>, + rx: Receiver>, +} + +impl ThumbWorker { + /// Spawn the worker thread, `None` where rendering stays on the UI thread: on wasm, in unit + /// tests (they look at the panel right after the frame that asks), with + /// `VECTORCRAFT_SYNC_RENDER` set, or if the thread can't start. + pub fn spawn(ctx: &egui::Context, name: &str) -> Option { + if cfg!(target_arch = "wasm32") || cfg!(test) || std::env::var_os("VECTORCRAFT_SYNC_RENDER").is_some() { + return None; + } + Self::start(ctx, name) + } + + /// Spawn the thread. + fn start(ctx: &egui::Context, name: &str) -> Option { + let (tx, jobs) = channel::>(); + let (done_tx, rx) = channel::>(); + let ctx = ctx.clone(); + std::thread::Builder::new() + .name(format!("vectorcraft-{name}")) + .spawn(move || { + let mut r = Renderer::new(); + let mut queue: VecDeque> = VecDeque::new(); + let add = |queue: &mut VecDeque>, job: Job| match queue.iter_mut().find(|j| j.slot == job.slot) { + Some(old) => *old = job, + None => queue.push_back(job), + }; + loop { + if queue.is_empty() { + // The panel is gone (its sender dropped): end the thread. + let Ok(job) = jobs.recv() else { break }; + add(&mut queue, job); + } + while let Ok(job) = jobs.try_recv() { + add(&mut queue, job); + } + let Some(job) = queue.pop_front() else { continue }; + let t0 = crate::now_ms(); + let img = (job.run)(&mut r); + if done_tx.send(Done { key: job.key, img, ms: crate::now_ms() - t0 }).is_err() { + break; + } + ctx.request_repaint(); + } + }) + .ok()?; + Some(Self { tx, rx }) + } + + /// Queue `run` for `slot` (replacing a job for the slot that hasn't started). False when the + /// thread is gone: the caller renders on the UI thread from now on. + #[must_use] + pub fn submit(&self, slot: S, key: K, run: JobFn) -> bool { + self.tx.send(Job { slot, key, run }).is_ok() + } + + /// The jobs finished since the last call, oldest first; None when the thread is gone (the + /// jobs it had will never finish). + pub fn poll(&self) -> Option>> { + let mut done = vec![]; + loop { + match self.rx.try_recv() { + Ok(d) => done.push(d), + Err(TryRecvError::Empty) => return Some(done), + Err(TryRecvError::Disconnected) => return None, + } + } + } +} + +/// When the document last changed, for [`SETTLE_SECS`]: thumbnails already shown are drawn again +/// only once it has stayed the same that long. +#[derive(Default)] +pub struct Settle { + seen: Option<(u64, u64)>, + since: f64, +} + +impl Settle { + /// Note that document `uid` is at `revision` at time `now` (seconds, egui's input time) → + /// whether it has stayed there [`SETTLE_SECS`]. Otherwise `ctx` is asked to draw again when + /// it will have. + pub fn settled(&mut self, ctx: &egui::Context, uid: u64, revision: u64, now: f64) -> bool { + if self.seen != Some((uid, revision)) { + self.seen = Some((uid, revision)); + self.since = now; + } + let waited = now - self.since; + // The clock went back (a new egui context): don't wait for it to catch up. + if waited >= SETTLE_SECS || waited < 0.0 { + return true; + } + ctx.request_repaint_after(std::time::Duration::from_secs_f64((SETTLE_SECS - waited).max(0.0))); + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn jobs_come_back_newest_last_per_slot() { + let Some(w) = ThumbWorker::::start(&egui::Context::default(), "test") else { return }; + let job = |n: u32| -> JobFn { Box::new(move |_| Some(Rendered { width: n, height: 1, pixels: vec![0; 4] })) }; + for k in 1..=3 { + assert!(w.submit(0, k, job(k))); + } + assert!(w.submit(1, 10, job(10))); + let mut got = vec![]; + let t0 = std::time::Instant::now(); + while !(got.contains(&3) && got.contains(&10)) && t0.elapsed().as_secs() < 30 { + got.extend(w.poll().unwrap().into_iter().map(|d| { + assert_eq!(d.img.map(|i| i.width), Some(d.key)); + d.key + })); + std::thread::yield_now(); + } + let slot0: Vec = got.iter().copied().filter(|k| *k < 10).collect(); + assert_eq!(slot0.last(), Some(&3), "the newest job of a slot is the last done: {got:?}"); + assert!(slot0.windows(2).all(|p| p[0] < p[1]), "{got:?}"); + assert!(got.contains(&10)); + drop(w); + } + + #[test] + fn settles_after_the_document_stays_unchanged() { + let ctx = egui::Context::default(); + let mut s = Settle::default(); + assert!(!s.settled(&ctx, 1, 1, 10.0), "a change starts the wait"); + assert!(!s.settled(&ctx, 1, 1, 10.05)); + assert!(s.settled(&ctx, 1, 1, 10.15)); + assert!(!s.settled(&ctx, 1, 2, 10.2), "a new revision starts it again"); + assert!(!s.settled(&ctx, 2, 2, 10.25), "so does another document"); + assert!(s.settled(&ctx, 2, 2, 1.0), "a clock that went back doesn't hold it up"); + } +} From 454f1737a31fb15a96d5482b5e2cfdb49b8ad82b Mon Sep 17 00:00:00 2001 From: arhammusheer Date: Sun, 11 Oct 2026 23:28:11 +0530 Subject: [PATCH 04/12] #1244: perf --deep measures four embedded photos drawn by three renderers Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc --- apps/vectorcraft-cli/src/perf.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/apps/vectorcraft-cli/src/perf.rs b/apps/vectorcraft-cli/src/perf.rs index 9652d4f7..c76f8553 100644 --- a/apps/vectorcraft-cli/src/perf.rs +++ b/apps/vectorcraft-cli/src/perf.rs @@ -207,6 +207,31 @@ fn deep(n: usize) -> Result<(), String> { }), )); note("after typing"); + // Four embedded 24 MP photos drawn at fit page by three renderers, as the app's canvas, render + // worker and a panel each draw them. + { + let art = synthetic(3000)?; + let mut d = Document::new(1600.0, 1200.0); + let l = d.layers.first().map(|l| l.id); + for i in 0..4u32 { + let photo = Renderer::new().render(&art, 6000, 4000, Affine::scale(3.75 + f64::from(i) * 0.1), &opts).to_png()?; + let key = format!("photo {i}"); + d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", photo)); + let xf = Affine::translate((f64::from(i % 2) * 800.0, f64::from(i / 2) * 600.0)) * Affine::scale(800.0 / 6000.0); + let image = vectorcraft_doc::ImageObject { key, width: 6000, height: 4000, xf, link: None, placement: Default::default() }; + let id = d.alloc_id(); + d.insert(l, usize::MAX, Node::new(id, vectorcraft_doc::NodeKind::Image(image))).map_err(|e| e.to_string())?; + } + note("4 embedded 24 MP photos"); + let mut renderers = [Renderer::new(), Renderer::new(), Renderer::new()]; + let ms = median_ms(1, || { + for r in &mut renderers { + r.render(&d, 2880, 1800, Affine::scale(1.6), &opts); + } + }); + rows.push(("render 4 embedded 24 MP photos with 3 renderers (cold)".into(), ms)); + note("after rendering the photos with 3 renderers"); + } // Output hashes, to compare builds: the SVG export and a save → open → save round trip. let fnv = |b: &[u8]| b.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, &x| (h ^ u64::from(x)).wrapping_mul(0x100_0000_01b3)); let doc = synthetic(n)?; From c4214be9d724503acce62335c6c115dc4dd3ee50 Mon Sep 17 00:00:00 2001 From: arhammusheer Date: Mon, 12 Oct 2026 02:32:50 +0530 Subject: [PATCH 05/12] #1244: render caches bounded by bytes, evicting the least recently used instead of clearing Text geometry, clips, clip paints, effect art, brushes, live effects, pattern tiles and freeform grids were capped by entry count and cleared wholesale on overflow, so a document past the cap (e.g. more than 4,096 type objects) recomputed every entry on every frame. A FrameCache keeps per-entry byte sizes and last-used frames, drops the least recently used entries of earlier frames, and sweeps stale ones. Text geometry keeps its glyph outlines once instead of twice. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc --- crates/render/src/brush_fx.rs | 26 +-- crates/render/src/freeform.rs | 30 ++-- crates/render/src/fx.rs | 8 +- crates/render/src/lib.rs | 111 ++++++++----- crates/render/src/live.rs | 47 +++--- crates/render/src/lru.rs | 257 ++++++++++++++++++++++++++++++ crates/render/src/pattern.rs | 27 ++-- crates/render/src/tests_memory.rs | 96 +++++++++++ 8 files changed, 498 insertions(+), 104 deletions(-) create mode 100644 crates/render/src/lru.rs diff --git a/crates/render/src/brush_fx.rs b/crates/render/src/brush_fx.rs index 0d02f13e..0f8c8b31 100644 --- a/crates/render/src/brush_fx.rs +++ b/crates/render/src/brush_fx.rs @@ -13,25 +13,30 @@ use vectorcraft_doc::{AppearanceItem, Document, Node, StrokeLayer}; use vectorcraft_geom::{BezPath, Rect}; use vello_cpu::RenderContext; +use crate::lru::{self, FrameCache}; use crate::{Frame, Renderer}; -struct Entry { +pub(crate) struct Entry { path: BezPath, stroke: StrokeLayer, brush: Arc, pieces: Arc>, - stamp: u64, } /// Parsed brush library and generated brush art. -#[derive(Default)] pub(crate) struct BrushCache { /// The document value the library was parsed from (`None` = defaults). src: Option>, /// (frame stamp, document address) the library was last validated for. checked: (u64, usize), lib: HashMap>, - entries: HashMap<(u64, usize), Entry>, + pub(crate) entries: FrameCache<(u64, usize), Entry>, +} + +impl Default for BrushCache { + fn default() -> Self { + Self { src: None, checked: (0, 0), lib: HashMap::new(), entries: FrameCache::new(lru::BRUSH_BUDGET, 512) } + } } impl BrushCache { @@ -73,17 +78,12 @@ impl Renderer { let idx = n.appearance.items.iter().position(|i| matches!(i, AppearanceItem::Stroke(s) if std::ptr::eq(s, st))).unwrap_or(usize::MAX); let key = (n.id.0, idx); let stamp = self.stamp; - let pieces = match self.brushes.entries.get_mut(&key) { - Some(e) if Arc::ptr_eq(&e.brush, &brush) && e.stroke == *st && e.path == *bp => { - e.stamp = stamp; - e.pieces.clone() - } + let pieces = match self.brushes.entries.get(&key, stamp) { + Some(e) if Arc::ptr_eq(&e.brush, &brush) && e.stroke == *st && e.path == *bp => e.pieces.clone(), _ => { let pieces = Arc::new(vectorcraft_brush::stroke_pieces(&brush, bp, st)); - if self.brushes.entries.len() > 2048 { - self.brushes.entries.retain(|_, e| stamp.saturating_sub(e.stamp) <= 3); - } - self.brushes.entries.insert(key, Entry { path: bp.clone(), stroke: st.clone(), brush, pieces: pieces.clone(), stamp }); + let bytes = size_of::() + lru::path_bytes(bp) + pieces.iter().map(lru::node_bytes).sum::(); + self.brushes.entries.insert(key, Entry { path: bp.clone(), stroke: st.clone(), brush, pieces: pieces.clone() }, bytes, stamp); pieces } }; diff --git a/crates/render/src/freeform.rs b/crates/render/src/freeform.rs index f583af20..ece0a0fa 100644 --- a/crates/render/src/freeform.rs +++ b/crates/render/src/freeform.rs @@ -7,7 +7,6 @@ //! the gradient's content, so panning and redraws don't resample it. use std::cell::RefCell; -use std::collections::HashMap; use std::hash::{Hash, Hasher}; use std::sync::Arc; @@ -18,12 +17,11 @@ use vello_cpu::peniko::{Extend, ImageQuality, ImageSampler}; use vello_cpu::{Pixmap, RenderContext}; use crate::ink::Ink; - -/// Grids kept before the cache starts over. -const MAX_GRIDS: usize = 128; +use crate::lru::{self, FrameCache}; thread_local! { - static GRIDS: RefCell>> = RefCell::new(HashMap::new()); + /// Grids by [`key`], within [`lru::FREEFORM_BUDGET`], least recently used first out. + static GRIDS: RefCell>> = RefCell::new(FrameCache::new(lru::FREEFORM_BUDGET, usize::MAX)); } fn hash_color(c: &Color, h: &mut impl Hasher) { @@ -72,17 +70,21 @@ pub(crate) fn set_freeform_paint(ctx: &mut RenderContext, g: &GradientPaint, bou let (cols, rows) = grid_size(b, device); let f = g.freeform_on(b); let k = key(&f, b, cols, rows, ink); - let pm = GRIDS.with(|c| c.borrow().get(&k).cloned()).unwrap_or_else(|| { - let pm = Arc::new(rasterize(&f, b, cols, rows, ink)); - GRIDS.with(|c| { + let pm = GRIDS + .with(|c| { let mut c = c.borrow_mut(); - if c.len() >= MAX_GRIDS { - c.clear(); - } - c.insert(k, pm.clone()); + let t = c.tick(); + c.get(&k, t).cloned() + }) + .unwrap_or_else(|| { + let pm = Arc::new(rasterize(&f, b, cols, rows, ink)); + GRIDS.with(|c| { + let mut c = c.borrow_mut(); + let t = c.tick(); + c.insert(k, pm.clone(), cols as usize * rows as usize * 4, t); + }); + pm }); - pm - }); let sampler = ImageSampler { x_extend: Extend::Pad, y_extend: Extend::Pad, quality: ImageQuality::Medium, alpha: 1.0 }; ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler }); ctx.set_paint_transform(Affine::translate(b.origin().to_vec2()) * Affine::scale_non_uniform(b.width() / cols as f64, b.height() / rows as f64)); diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 0398338d..db417efd 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -482,7 +482,7 @@ impl Renderer { fn fx_art(&mut self, doc: &vectorcraft_doc::Document, a: &Arc, cache: bool) -> Arc { let key = Arc::as_ptr(a) as usize; if cache - && let Some((node, art)) = self.fx_arts.get(&key) + && let Some((node, art)) = self.fx_arts.get(&key, self.stamp) && Arc::ptr_eq(node, a) { return art.clone(); @@ -516,10 +516,8 @@ impl Renderer { ..art }); if cache { - if self.fx_arts.len() > 1024 { - self.fx_arts.clear(); - } - self.fx_arts.insert(key, (a.clone(), art.clone())); + let bytes = crate::lru::node_bytes(&art); + self.fx_arts.insert(key, (a.clone(), art.clone()), bytes, self.stamp); } art } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index cca21739..b62f06ce 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -14,6 +14,7 @@ mod group; mod images; mod ink; mod live; +mod lru; mod paint; mod pattern; pub mod placed_document; @@ -30,6 +31,7 @@ use vello_cpu::{Pixmap, RenderContext, Resources}; use group::Composite; use ink::Ink; +use lru::FrameCache; pub use brush_fx::instance_art; pub use effects::stroke::width_outline; @@ -350,12 +352,13 @@ type ClipPaintEntry = (Arc, Option>, Option>); /// Reusable renderer (keeps the render context, geometry and glyph caches between frames; decoded /// images are shared by all renderers, see [`set_image_cache_budget`]). pub struct Renderer { - texts: PtrMap, Arc)>, + /// Glyph geometry per text object, keyed by `Arc` identity like `geom` (see [`Self::text_geom_of`]). + texts: FrameCache, Arc)>, /// The [`vectorcraft_text::FontDb::generation`] `texts` was laid out with: type lays out /// again when fonts are added or rescanned. text_fonts: u64, /// Clip regions per clipping path (see [`Self::clip_of`]). - clips: PtrMap, + clips: FrameCache, /// Context reused when rendering single-threaded (`threads == 0`). ctx_st: Option, /// Worker threads for the multithreaded rasterizer (0 = single-threaded). @@ -392,7 +395,7 @@ pub struct Renderer { /// opacity and without its opacity mask (see [`Self::draw_knockout`]); 0 = none. shape_of: usize, /// The art drawn for objects with effects that apply through it (see `fx::has_object_fx`). - fx_arts: PtrMap, Arc)>, + fx_arts: FrameCache, Arc)>, /// Layers open around the drawing point in the current context: a non-isolated group can copy /// its backdrop only when there are none (see [`Self::group`]). nested: u32, @@ -405,7 +408,7 @@ pub struct Renderer { /// again from a picture of it pushes them again (see [`Self::group`]). clip_paths: Vec<(BezPath, FillRule, Affine)>, /// What clipping paths paint, per clipping path (see [`Self::clip_paint_of`]). - clip_paints: PtrMap, + clip_paints: FrameCache, /// Gradients along or across strokes, keyed like `strokes` (see [`Self::fill_path_gradient`]). stroke_slices: PtrMap<(usize, i32), SliceEntry>, /// What the image keys colour adjustments give their recoloured images name: the image @@ -479,9 +482,9 @@ impl Frame<'_> { impl Renderer { pub fn new() -> Self { Self { - texts: PtrMap::default(), + texts: FrameCache::new(lru::TEXT_BUDGET, 1024), text_fonts: 0, - clips: PtrMap::default(), + clips: FrameCache::new(lru::CLIP_BUDGET, 256), ctx_st: None, threads: default_threads(), geom: PtrMap::default(), @@ -500,12 +503,12 @@ impl Renderer { live: live::LiveCache::default(), knockout: false, shape_of: 0, - fx_arts: PtrMap::default(), + fx_arts: FrameCache::new(lru::FX_ART_BUDGET, 256), nested: 0, backdrop: None, blends: PtrMap::default(), clip_paths: vec![], - clip_paints: PtrMap::default(), + clip_paints: FrameCache::new(lru::CLIP_BUDGET, 256), stroke_slices: PtrMap::default(), adjusts: HashMap::new(), dim_images: None, @@ -599,6 +602,12 @@ impl Renderer { } // Filtered rasters are large: only those of the last frame stay. self.pixel_fx.retain(|_, e| e.stamp == g); + self.texts.sweep(g); + self.clips.sweep(g); + self.clip_paints.sweep(g); + self.fx_arts.sweep(g); + self.brushes.entries.sweep(g); + self.live.sweep(g); } /// Draw frame `f` (`w`×`h` pixels): the background, the artboards and the page's art. @@ -799,16 +808,14 @@ impl Renderer { /// outlining text and uniting shapes is too slow to repeat every frame. fn clip_of(&mut self, a: &Arc) -> Option> { let key = Arc::as_ptr(a) as usize; - if let Some((node, region)) = self.clips.get(&key) + if let Some((node, region)) = self.clips.get(&key, self.stamp) && Arc::ptr_eq(node, a) { return region.clone(); } let region = effects::clip_outline(a).map(Arc::new); - if self.clips.len() > 1024 { - self.clips.clear(); - } - self.clips.insert(key, (a.clone(), region.clone())); + let bytes = size_of::() + region.as_ref().map_or(0, |r| lru::path_bytes(&r.0)); + self.clips.insert(key, (a.clone(), region.clone()), bytes, self.stamp); region } @@ -816,17 +823,15 @@ impl Renderer { /// allocations between frames, so their own geometry stays cached. fn clip_paint_of(&mut self, a: &Arc) -> (Option>, Option>) { let key = Arc::as_ptr(a) as usize; - if let Some((node, fill, stroke)) = self.clip_paints.get(&key) + if let Some((node, fill, stroke)) = self.clip_paints.get(&key, self.stamp) && Arc::ptr_eq(node, a) { return (fill.clone(), stroke.clone()); } let paint = a.clip_paint(); let (fill, stroke) = (paint.fill.map(Arc::new), paint.stroke.map(Arc::new)); - if self.clip_paints.len() > 1024 { - self.clip_paints.clear(); - } - self.clip_paints.insert(key, (a.clone(), fill.clone(), stroke.clone())); + let bytes = size_of::() + [&fill, &stroke].iter().filter_map(|p| p.as_deref()).map(lru::node_bytes).sum::(); + self.clip_paints.insert(key, (a.clone(), fill.clone(), stroke.clone()), bytes, self.stamp); (fill, stroke) } @@ -1477,16 +1482,13 @@ impl Renderer { self.text_fonts = fonts; } let key = Arc::as_ptr(a) as usize; - if let Some((node, g)) = self.texts.get(&key) + if let Some((node, g)) = self.texts.get(&key, self.stamp) && Arc::ptr_eq(node, a) { return g.clone(); } let g = Arc::new(text_geom(t)); - if self.texts.len() > 4096 { - self.texts.clear(); - } - self.texts.insert(key, (a.clone(), g.clone())); + self.texts.insert(key, (a.clone(), g.clone()), g.bytes(), self.stamp); g } @@ -1535,7 +1537,8 @@ impl Renderer { } } for (i, run) in t.runs.iter().enumerate() { - let Some(path) = g.runs.get(i) else { continue }; + let path = g.run(i); + let path = path.as_ref(); if path.elements().is_empty() { continue; } @@ -1865,10 +1868,12 @@ pub fn default_threads() -> u16 { } } -/// Glyph outlines grouped by run, plus the whole text as one path. +/// The whole text's glyph outlines as one path, and which of them each run draws. struct TextGeom { - runs: Vec, all: BezPath, + /// Spans of `all`'s elements drawn by a run, in order: (run, start, end). Outlines of runs + /// the text doesn't have are in `all` only. + spans: Vec<(usize, usize, usize)>, bounds: Rect, /// The boxes of characters whose font is missing (drawn in a substitute), and of glyphs the /// font lacks (drawn from a fallback font): Document Setup's substitution highlights. @@ -1878,6 +1883,33 @@ struct TextGeom { inlines: Vec, } +impl TextGeom { + /// The outlines run `i` draws: the glyphs of `all` it has, in their order. Kept once rather + /// than per run as well, which held every outline twice. + fn run(&self, i: usize) -> std::borrow::Cow<'_, BezPath> { + let els = self.all.elements(); + if let [(r, 0, end)] = self.spans[..] + && r == i + && end == els.len() + { + return std::borrow::Cow::Borrowed(&self.all); + } + let mut p = BezPath::new(); + for &(_, start, end) in self.spans.iter().filter(|s| s.0 == i) { + p.extend(els.get(start..end).unwrap_or_default().iter().copied()); + } + std::borrow::Cow::Owned(p) + } + + /// Approximate heap bytes, for the cache's budget. + fn bytes(&self) -> usize { + size_of::() + + [&self.all, &self.substituted_fonts, &self.substituted_glyphs].into_iter().map(lru::path_bytes).sum::() + + self.spans.len() * size_of::<(usize, usize, usize)>() + + self.inlines.len() * size_of::() + } +} + fn text_geom(t: &TextObject) -> TextGeom { text_geom_snapped(t, None) } @@ -1889,8 +1921,21 @@ fn text_geom_snapped(t: &TextObject, snap: Option) -> TextGeom { if let Some(xf) = snap { layout.snap_to_pixels(xf); } - let mut runs = vec![BezPath::new(); t.runs.len()]; let mut all = BezPath::new(); + let mut spans: Vec<(usize, usize, usize)> = vec![]; + // Append `outline` to `all`, drawn by `run` when the text has that run. + let mut push = |all: &mut BezPath, run: usize, outline: &BezPath| { + let start = all.elements().len(); + all.extend(outline.iter()); + let end = all.elements().len(); + if run >= t.runs.len() || start == end { + return; + } + match spans.last_mut() { + Some((r, _, e)) if *r == run && *e == start => *e = end, + _ => spans.push((run, start, end)), + } + }; // Per run: is its family missing, and the face its glyphs should come from. let faces: Vec<(bool, Option)> = t .runs @@ -1906,10 +1951,7 @@ fn text_geom_snapped(t: &TextObject, snap: Option) -> TextGeom { .collect(); let (mut substituted_fonts, mut substituted_glyphs) = (BezPath::new(), BezPath::new()); for g in &layout.glyphs { - if let Some(r) = runs.get_mut(g.run) { - r.extend(g.outline.iter()); - } - all.extend(g.outline.iter()); + push(&mut all, g.run, &g.outline); let Some(&(missing, face)) = faces.get(g.run) else { continue }; let target = if missing { &mut substituted_fonts @@ -1925,12 +1967,9 @@ fn text_geom_snapped(t: &TextObject, snap: Option) -> TextGeom { } // Underline and strikethrough bars, painted as their run's type is (#847). for (run, bar) in vectorcraft_text::decorations(&layout, db, t) { - if let Some(r) = runs.get_mut(run) { - r.extend(bar.iter()); - } - all.extend(bar.iter()); + push(&mut all, run, &bar); } - TextGeom { runs, all, bounds: layout.bounds, substituted_fonts, substituted_glyphs, inlines: layout.inlines } + TextGeom { all, spans, bounds: layout.bounds, substituted_fonts, substituted_glyphs, inlines: layout.inlines } } /// Document Setup's highlight behind substituted fonts and glyphs (screen only). diff --git a/crates/render/src/live.rs b/crates/render/src/live.rs index 5b870d2d..2b35b752 100644 --- a/crates/render/src/live.rs +++ b/crates/render/src/live.rs @@ -9,7 +9,6 @@ //! grown by half a device pixel along its edges so neighbours overlap and no antialiasing seams //! show. -use std::collections::HashMap; use std::sync::Arc; use vectorcraft_doc::live::{GradientMesh, MeshQuad}; @@ -18,28 +17,30 @@ use vectorcraft_geom::{BezPath, Vec2}; use vello_cpu::RenderContext; use vello_cpu::peniko; +use crate::lru::{self, FrameCache}; use crate::{Frame, Renderer}; type Expanded = Arc>>; -type MeshEntry = (Arc, Arc>, u64); +type Quads = Arc>; -/// Per-renderer cache of evaluated live objects. -#[derive(Default)] +/// Per-renderer cache of evaluated live objects: (live node, its evaluation, [`resources_key`]), +/// and (mesh node, its quads) per node and subdivision level. pub(crate) struct LiveCache { - expanded: HashMap, Expanded, u64, u64)>, - meshes: HashMap<(usize, usize), MeshEntry>, - stamp: u64, + expanded: FrameCache, Expanded, u64)>, + meshes: FrameCache<(usize, usize), (Arc, Quads)>, +} + +impl Default for LiveCache { + fn default() -> Self { + Self { expanded: FrameCache::new(lru::LIVE_BUDGET, 256), meshes: FrameCache::new(lru::MESH_BUDGET, 256) } + } } impl LiveCache { - fn tick(&mut self, stamp: u64) { - self.stamp = stamp; - if self.expanded.len() > 256 { - self.expanded.retain(|_, e| stamp - e.2 <= 3); - } - if self.meshes.len() > 256 { - self.meshes.retain(|_, e| stamp - e.2 <= 3); - } + /// End of frame `stamp` (see [`FrameCache::sweep`]). + pub(crate) fn sweep(&mut self, stamp: u64) { + self.expanded.sweep(stamp); + self.meshes.sweep(stamp); } } @@ -84,11 +85,10 @@ impl Renderer { let key = Arc::as_ptr(a) as usize; let resources = resources_key(doc, a); if cache - && let Some(e) = self.live.expanded.get_mut(&key) + && let Some(e) = self.live.expanded.get(&key, self.stamp) && Arc::ptr_eq(&e.0, a) - && e.3 == resources + && e.2 == resources { - e.2 = self.live.stamp; return e.1.clone(); } let v: Expanded = match crate::effects::pathfinder_children(a, crate::effects::text_outliner()) { @@ -96,7 +96,8 @@ impl Renderer { None => Arc::new(crate::effects::expand_live(Some(doc), a).into_iter().map(Arc::new).collect()), }; if cache { - self.live.expanded.insert(key, (a.clone(), v.clone(), self.live.stamp, resources)); + let bytes = size_of::() + v.iter().map(|n| lru::node_bytes(n)).sum::(); + self.live.expanded.insert(key, (a.clone(), v.clone(), resources), bytes, self.stamp); } v } @@ -104,23 +105,21 @@ impl Renderer { fn mesh_quads(&mut self, a: &Arc, m: &GradientMesh, level: usize, cache: bool) -> Arc> { let key = (Arc::as_ptr(a) as usize, level); if cache - && let Some(e) = self.live.meshes.get_mut(&key) + && let Some(e) = self.live.meshes.get(&key, self.stamp) && Arc::ptr_eq(&e.0, a) { - e.2 = self.live.stamp; return e.1.clone(); } let q = Arc::new(m.quads(level)); if cache { - self.live.meshes.insert(key, (a.clone(), q.clone(), self.live.stamp)); + let bytes = q.len() * size_of::(); + self.live.meshes.insert(key, (a.clone(), q.clone()), bytes, self.stamp); } q } /// Draw a live node reached through the tree walk (handles its transparency group). pub(crate) fn draw_live(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc) { - let stamp = self.stamp; - self.live.tick(stamp); let opacity = self.opacity_of(a); // A knockout blend's steps knock each other out, as in the exports (opaque ones can't show it). let knockout = !f.opts.outline diff --git a/crates/render/src/lru.rs b/crates/render/src/lru.rs new file mode 100644 index 00000000..d96dbc68 --- /dev/null +++ b/crates/render/src/lru.rs @@ -0,0 +1,257 @@ +//! Caches bounded by frames and by bytes. +//! +//! A renderer keeps what it works out per object between frames (glyph outlines, clip regions, +//! brush art, evaluated blends…). Each entry records the frame that last used it and roughly how +//! many bytes it holds. [`FrameCache::sweep`] (at the end of every frame) drops entries unused for +//! a few frames once the cache holds more than a handful, so the nodes they keep alive go with +//! them; [`FrameCache::insert`] keeps the total under a byte budget by dropping the least recently +//! used entries of earlier frames. +//! +//! Entries used in the frame being drawn are never dropped for a new one: when they alone fill the +//! budget the new entry isn't kept (it is worked out again when next needed). A document needing +//! more than the budget then keeps the same entries frame after frame and works out only the rest +//! again, where dropping everything (or the oldest entry, in a least-recently-used order) whenever +//! the cache filled worked out every entry again every frame. + +use std::borrow::Borrow; +use std::collections::HashMap; +use std::hash::{BuildHasher, Hash}; +use std::mem::size_of; + +use vectorcraft_doc::Node; +use vectorcraft_geom::{Anchor, BezPath, SubPath}; + +use crate::PtrHasher; + +/// Glyph outlines per text object (`Renderer::text_geom_of`). +pub(crate) const TEXT_BUDGET: usize = 128 << 20; +/// Clip regions per clipping path (`Renderer::clip_of`), and separately what clipping paths +/// paint (`Renderer::clip_paint_of`). +pub(crate) const CLIP_BUDGET: usize = 32 << 20; +/// The art drawn for objects with effects that apply through it (`Renderer::fx_art`). +pub(crate) const FX_ART_BUDGET: usize = 64 << 20; +/// Brush art per brushed stroke (`Renderer::draw_brush`). +pub(crate) const BRUSH_BUDGET: usize = 64 << 20; +/// Evaluated blends, envelopes and repeats (`Renderer::live_expanded`). +pub(crate) const LIVE_BUDGET: usize = 64 << 20; +/// Tessellated gradient meshes (`Renderer::mesh_quads`). +pub(crate) const MESH_BUDGET: usize = 32 << 20; +/// Pattern tile rasters, per thread and ink plane (`pattern::set_pattern_paint`): a tile is at +/// most 4096² pixels, 64 MiB. +pub(crate) const PATTERN_BUDGET: usize = 128 << 20; +/// Freeform gradient grids, per thread (`freeform::set_freeform_paint`). +pub(crate) const FREEFORM_BUDGET: usize = 32 << 20; + +/// Frames an entry may go unused before [`FrameCache::sweep`] drops it. +const AGE: u64 = 3; + +/// Bookkeeping of a node beyond its own struct: its allocation, `Arc` counts, appearance. +const NODE_OVERHEAD: usize = 128; + +pub(crate) type PtrBuild = std::hash::BuildHasherDefault; + +struct Slot { + value: V, + bytes: usize, + /// Frame (or, for caches without frames, request) that last used it. + stamp: u64, + /// [`FrameCache::clock`] when last used: the order entries are dropped in. + used: u64, +} + +/// Entries swept by frame and kept within a byte budget (see the module docs). +pub(crate) struct FrameCache { + map: HashMap, S>, + /// Sum of the entries' sizes. + bytes: usize, + budget: usize, + /// Entries kept whatever their age. + soft: usize, + /// Request counter. + clock: u64, + /// The frame whose own entries were found to fill the budget: nothing more is kept in it. + full: Option, +} + +impl FrameCache { + /// A cache holding at most `budget` bytes, sweeping entries of past frames beyond `soft` entries. + pub(crate) fn new(budget: usize, soft: usize) -> Self { + Self { map: HashMap::default(), bytes: 0, budget, soft, clock: 0, full: None } + } + + /// The entry for `k`, marked as used in frame `stamp`. + pub(crate) fn get(&mut self, k: &Q, stamp: u64) -> Option<&V> + where + K: Borrow, + Q: Hash + Eq + ?Sized, + { + self.clock += 1; + let s = self.map.get_mut(k)?; + (s.stamp, s.used) = (stamp, self.clock); + Some(&s.value) + } + + /// Keep `value` (about `bytes` bytes) as `k`, used in frame `stamp`, replacing any entry for + /// `k`. Least recently used entries of earlier frames make room; returns false when they can't + /// (the value isn't kept then). + pub(crate) fn insert(&mut self, k: K, value: V, bytes: usize, stamp: u64) -> bool { + self.clock += 1; + if let Some(old) = self.map.remove(&k) { + self.bytes -= old.bytes; + } + if self.bytes + bytes > self.budget { + if bytes > self.budget || self.full == Some(stamp) { + return false; + } + // Make room down to seven eighths of the budget, so the next few don't search again. + let target = self.budget - self.budget / 8; + let mut old: Vec<(u64, K)> = self.map.iter().filter(|(_, s)| s.stamp < stamp).map(|(k, s)| (s.used, k.clone())).collect(); + old.sort_unstable_by_key(|(used, _)| *used); + for (_, key) in old { + if self.bytes + bytes <= target { + break; + } + if let Some(s) = self.map.remove(&key) { + self.bytes -= s.bytes; + } + } + if self.bytes + bytes > self.budget { + self.full = Some(stamp); + return false; + } + } + self.bytes += bytes; + self.map.insert(k, Slot { value, bytes, stamp, used: self.clock }); + true + } + + /// End of frame `stamp`: past [`Self::new`]'s `soft` entries, drop those unused for a few frames. + pub(crate) fn sweep(&mut self, stamp: u64) { + if self.map.len() > self.soft { + let bytes = &mut self.bytes; + self.map.retain(|_, s| { + let keep = stamp.saturating_sub(s.stamp) <= AGE; + if !keep { + *bytes -= s.bytes; + } + keep + }); + } + } + + /// A new stamp for caches without frames: each request its own, so every other entry may make + /// room for it (least recently used first). + pub(crate) fn tick(&mut self) -> u64 { + self.clock += 1; + self.clock + } + + pub(crate) fn clear(&mut self) { + self.map.clear(); + self.bytes = 0; + self.full = None; + } + + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.map.len() + } + + #[cfg(test)] + pub(crate) fn is_empty(&self) -> bool { + self.map.is_empty() + } + + /// The bytes the entries hold (as estimated when they were kept). + #[cfg(test)] + pub(crate) fn bytes(&self) -> usize { + self.bytes + } + + #[cfg(test)] + pub(crate) fn contains_key(&self, k: &Q) -> bool + where + K: Borrow, + Q: Hash + Eq + ?Sized, + { + self.map.contains_key(k) + } +} + +/// Heap bytes of a path's elements. +pub(crate) fn path_bytes(p: &BezPath) -> usize { + size_of_val(p.elements()) +} + +/// Approximate bytes of node `n` and its members: the nodes and their path anchors. +pub(crate) fn node_bytes(n: &Node) -> usize { + let mut b = 0; + n.walk(&mut |c| { + b += size_of::() + NODE_OVERHEAD; + if let Some(p) = c.path_data() { + b += p.subpaths.iter().map(|s| size_of::() + s.anchors.len() * size_of::()).sum::(); + } + }); + b +} + +#[cfg(test)] +mod tests { + use super::*; + + type Cache = FrameCache; + + #[test] + fn over_budget_drops_the_least_recently_used_entries_of_past_frames() { + let mut c = Cache::new(100, 0); + for k in 0..10 { + assert!(c.insert(k, "x", 10, 1)); + } + assert_eq!(c.bytes(), 100); + // Frame 2 uses 0..5 (so 5..10 are now the least recently used), then needs room. + for k in 0..5 { + assert!(c.get(&k, 2).is_some()); + } + assert!(c.insert(10, "y", 10, 2)); + assert!((0..5).all(|k| c.contains_key(&k)) && c.contains_key(&10), "recently used entries stay"); + assert!(c.bytes() <= 100 && c.bytes() < 110, "bytes went down: {}", c.bytes()); + assert!(!c.contains_key(&5), "the least recently used entry went first"); + } + + #[test] + fn entries_of_the_frame_being_drawn_stay() { + let mut c = Cache::new(100, 0); + // More than the budget, every frame: the first entries stay and only the rest are redone. + for frame in 1..4 { + let mut hits = 0; + for k in 0..20 { + if c.get(&k, frame).is_some() { + hits += 1; + } else { + c.insert(k, "x", 10, frame); + } + } + assert_eq!(c.len(), 10); + assert_eq!(hits, if frame == 1 { 0 } else { 10 }, "frame {frame}"); + } + // Too large for the budget: not kept. + assert!(!c.insert(99, "big", 1000, 5)); + } + + #[test] + fn sweep_drops_old_entries_past_the_soft_limit() { + let mut c = Cache::new(1000, 2); + for k in 0..4 { + c.insert(k, "x", 10, 1); + } + assert!(c.get(&0, 5).is_some()); + c.sweep(5); + assert_eq!((c.len(), c.bytes()), (1, 10)); + c.sweep(100); + assert_eq!(c.len(), 1, "within the soft limit nothing goes"); + let t = c.tick(); + assert!(c.insert(7, "y", 10, t)); + c.clear(); + assert!(c.is_empty() && c.bytes() == 0); + } +} diff --git a/crates/render/src/pattern.rs b/crates/render/src/pattern.rs index 0a4e05b7..c83adf7d 100644 --- a/crates/render/src/pattern.rs +++ b/crates/render/src/pattern.rs @@ -12,7 +12,6 @@ //! dimmed copies of it at the neighbouring tile positions and the tile edge. use std::cell::RefCell; -use std::collections::HashMap; use std::sync::Arc; use vectorcraft_doc::pattern::{Overlap, PatternDef, TileType}; @@ -22,6 +21,7 @@ use vello_cpu::peniko::{Extend, ImageQuality, ImageSampler}; use vello_cpu::{Pixmap, RenderContext}; use crate::ink::Ink; +use crate::lru::{self, FrameCache}; use crate::{Frame, RenderOptions, Rendered, Renderer}; /// Largest super-tile raster side (pixels). @@ -36,14 +36,19 @@ struct TileEntry { pixmap: Arc, } -#[derive(Default)] struct PatternCache { - /// Per ink ([`Ink`] as index). - tiles: [HashMap; 3], + /// Per ink ([`Ink`] as index), each within [`lru::PATTERN_BUDGET`], least recently used first out. + tiles: [FrameCache; 3], renderer: Option>, depth: u32, } +impl Default for PatternCache { + fn default() -> Self { + Self { tiles: std::array::from_fn(|_| FrameCache::new(lru::PATTERN_BUDGET, usize::MAX)), renderer: None, depth: 0 } + } +} + thread_local! { static CACHE: RefCell = RefCell::new(PatternCache::default()); } @@ -137,10 +142,10 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine, let scale = raster_scale(s, (pw, ph)); let params = (def.tile, def.tile_type, def.overlap); let hit = CACHE.with(|c| { - c.borrow().tiles[ink as usize] - .get(name) - .filter(|e| e.scale == scale && e.params == params && same_art(&e.art, &def.art)) - .map(|e| (e.pixmap.clone(), e.sx, e.sy)) + let mut c = c.borrow_mut(); + let tiles = &mut c.tiles[ink as usize]; + let t = tiles.tick(); + tiles.get(name, t).filter(|e| e.scale == scale && e.params == params && same_art(&e.art, &def.art)).map(|e| (e.pixmap.clone(), e.sx, e.sy)) }); let (pm, sx, sy) = match hit { Some(h) => h, @@ -156,10 +161,8 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine, CACHE.with(|c| { let mut c = c.borrow_mut(); let tiles = &mut c.tiles[ink as usize]; - if tiles.len() > 64 { - tiles.clear(); - } - tiles.insert(name.to_string(), TileEntry { art: def.art.clone(), params, scale, sx, sy, pixmap: pm.clone() }); + let (t, bytes) = (tiles.tick(), size_of::() + w as usize * h as usize * 4); + tiles.insert(name.to_string(), TileEntry { art: def.art.clone(), params, scale, sx, sy, pixmap: pm.clone() }, bytes, t); }); (pm, sx, sy) } diff --git a/crates/render/src/tests_memory.rs b/crates/render/src/tests_memory.rs index bd091bf7..e9b9d7b2 100644 --- a/crates/render/src/tests_memory.rs +++ b/crates/render/src/tests_memory.rs @@ -103,3 +103,99 @@ fn image_mips_are_opt_in_and_change_nothing_off() { let near = Affine::scale(0.75); assert!(Renderer::new().render(&d, 48, 48, near, &on).pixels == Renderer::new().render(&d, 48, 48, near, &off).pixels); } + +/// A document of `n` point type objects reading `word`, in rows. +fn text_doc(n: usize, word: &str) -> Document { + use vectorcraft_doc::text::{CharStyle, TextObject}; + let mut d = Document::new(400.0, 400.0); + let l = d.layers[0].id; + for i in 0..n { + let at = vectorcraft_geom::Point::new(10.0 + (i % 8) as f64 * 48.0, 30.0 + (i / 8) as f64 * 30.0); + let style = CharStyle { size: 18.0, ..Default::default() }; + let mut t = Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(at, word, style)))); + t.id = d.alloc_id(); + d.insert(Some(l), 0, t).unwrap(); + } + d +} + +/// The cache keys of `d`'s type objects (their nodes' addresses). +fn text_keys(d: &Document) -> Vec { + let mut keys = vec![]; + d.walk(|n| { + if matches!(n.kind, NodeKind::Text(_)) { + keys.push(n as *const Node as usize); + } + }); + keys +} + +/// More type than the glyph cache's budget holds: the entries kept stay from frame to frame (the +/// rest is laid out again), where emptying the cache when full laid out every object every frame. +#[test] +fn type_over_the_text_budget_keeps_its_entries_between_frames() { + let d = text_doc(40, "Type"); + let opts = RenderOptions::default(); + let mut r = Renderer::new(); + let want = r.render(&d, 400, 400, Affine::IDENTITY, &opts).pixels; + assert_eq!(r.texts.len(), 40); + let all = r.texts.bytes(); + let mut r = Renderer::new(); + r.texts = FrameCache::new(all / 2, 1024); + let keys = text_keys(&d); + let kept = |r: &Renderer| keys.iter().filter(|k| r.texts.contains_key(*k)).copied().collect::>(); + assert_eq!(r.render(&d, 400, 400, Affine::IDENTITY, &opts).pixels, want); + let first = kept(&r); + assert!(!first.is_empty() && first.len() < 40 && r.texts.bytes() <= all / 2, "{} entries, {} bytes", first.len(), r.texts.bytes()); + for _ in 0..3 { + assert_eq!(r.render(&d, 400, 400, Affine::IDENTITY, &opts).pixels, want); + assert_eq!(kept(&r), first, "the same entries stay"); + } + // Other type: its entries replace the earlier frames' ones, within the budget. + let e = text_doc(40, "Other"); + r.render(&e, 400, 400, Affine::IDENTITY, &opts); + assert!(kept(&r).is_empty(), "entries of earlier frames went first"); + assert!(!r.texts.is_empty() && r.texts.bytes() <= all / 2); +} + +/// Type no longer drawn leaves the glyph cache after a few frames, and its bytes with it. +#[test] +fn swept_type_entries_give_back_their_bytes() { + let d = text_doc(12, "Swept"); + let mut r = Renderer::new(); + r.texts = FrameCache::new(lru::TEXT_BUDGET, 0); + r.render(&d, 400, 400, Affine::IDENTITY, &RenderOptions::default()); + let full = r.texts.bytes(); + assert!(full > 0 && r.texts.len() == 12); + let empty = Document::new(10.0, 10.0); + for _ in 0..5 { + r.render(&empty, 10, 10, Affine::IDENTITY, &RenderOptions::default()); + } + assert_eq!((r.texts.len(), r.texts.bytes()), (0, 0)); +} + +/// Each run draws exactly the outlines it drew when kept as a path of its own: its glyphs and +/// decorations in layout order (the whole text's path is kept once, the runs as spans of it). +#[test] +fn text_runs_draw_their_own_glyphs_from_the_shared_outlines() { + use vectorcraft_doc::text::{CharStyle, TextObject, TextRun}; + let plain = CharStyle { size: 24.0, ..Default::default() }; + let under = CharStyle { underline: true, ..plain.clone() }; + let mut t = TextObject::point(vectorcraft_geom::Point::new(10.0, 40.0), "Ab", plain.clone()); + t.runs.push(TextRun::new("cd", under)); + t.runs.push(TextRun::new("ef", plain)); + let g = text_geom(&t); + let db = vectorcraft_text::FontDb::global(); + let layout = vectorcraft_text::layout(db, &t); + let mut want = vec![BezPath::new(); t.runs.len()]; + for gl in &layout.glyphs { + want[gl.run].extend(gl.outline.iter()); + } + for (run, bar) in vectorcraft_text::decorations(&layout, db, &t) { + want[run].extend(bar.iter()); + } + for (i, w) in want.iter().enumerate() { + assert_eq!(g.run(i).elements(), w.elements(), "run {i}"); + } + assert!(g.run(7).elements().is_empty()); +} From 5beac4f12238167095e534c9c2902667c22abd29 Mon Sep 17 00:00:00 2001 From: arhammusheer Date: Mon, 12 Oct 2026 02:32:50 +0530 Subject: [PATCH 06/12] #1244: smaller objects: Node 352 -> 152 bytes, NodeKind 200 -> 80, and a cheaper undo estimate Rarely set fields move into one optional boxed NodeExtras, and large NodeKind payloads (images, live shapes, blends, envelopes) are boxed. Every object, and every undo step that copies it, shrinks: a 50k-path move keeps ~24.5 MB per step instead of ~40 MB. Files stay byte-identical (Node keeps its flat JSON through a borrowed Serialize and a from-wire Deserialize, no flatten), and loading is slightly faster. Undo and redo reuse their step's size estimate instead of walking the document again (8.3 -> 5.6 ms for a 50k-path move). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc # Conflicts: # crates/engine/src/cmd/graph.rs # crates/svg/src/export.rs --- apps/vectorcraft-cli/src/perf.rs | 2 +- crates/cad/src/scene.rs | 2 +- crates/cad/src/tests.rs | 2 +- crates/doc/src/blend.rs | 8 +- crates/doc/src/corners.rs | 22 +- crates/doc/src/graph.rs | 7 +- crates/doc/src/hit.rs | 6 +- crates/doc/src/lib.rs | 2 +- crates/doc/src/links.rs | 15 +- crates/doc/src/live.rs | 21 +- crates/doc/src/mem.rs | 226 +++++++--- crates/doc/src/node.rs | 400 ++++++++++++++---- crates/doc/src/perspective.rs | 4 +- crates/doc/src/placed_document.rs | 21 +- crates/doc/src/slices.rs | 8 +- crates/doc/src/tests_slices.rs | 7 +- crates/doc/src/trace.rs | 6 +- crates/effects/src/bake.rs | 37 +- crates/effects/src/marks.rs | 4 +- crates/effects/src/reshape.rs | 6 +- crates/engine/src/cmd/attributes.rs | 6 +- crates/engine/src/cmd/buildcmds.rs | 10 +- crates/engine/src/cmd/clipboard/flavours.rs | 2 +- crates/engine/src/cmd/clipboard/resources.rs | 8 +- crates/engine/src/cmd/create.rs | 2 +- crates/engine/src/cmd/distortcmds.rs | 8 +- crates/engine/src/cmd/docinfo.rs | 2 +- crates/engine/src/cmd/edit.rs | 6 +- crates/engine/src/cmd/fileio/affinity.rs | 4 +- crates/engine/src/cmd/fileio/imagemap.rs | 2 +- crates/engine/src/cmd/fileio/load.rs | 3 +- crates/engine/src/cmd/fileio/tests.rs | 2 +- .../engine/src/cmd/fileio/tests_svgimport.rs | 2 +- crates/engine/src/cmd/flatten.rs | 6 +- crates/engine/src/cmd/graph.rs | 133 +++--- crates/engine/src/cmd/live.rs | 30 +- crates/engine/src/cmd/maskedit.rs | 18 +- crates/engine/src/cmd/menucmds.rs | 15 +- crates/engine/src/cmd/newart.rs | 2 +- crates/engine/src/cmd/object.rs | 27 +- crates/engine/src/cmd/opacitymask.rs | 32 +- crates/engine/src/cmd/pathops.rs | 4 +- crates/engine/src/cmd/place/adopt.rs | 2 +- crates/engine/src/cmd/place/mod.rs | 2 +- crates/engine/src/cmd/printadvanced.rs | 2 +- crates/engine/src/cmd/rasterfx.rs | 20 +- crates/engine/src/cmd/rasterfx/report.rs | 4 +- crates/engine/src/cmd/select.rs | 2 +- crates/engine/src/cmd/shaper.rs | 20 +- crates/engine/src/cmd/slices.rs | 12 +- crates/engine/src/cmd/style.rs | 15 +- crates/engine/src/cmd/textwrap.rs | 31 +- crates/engine/src/cmd/typecmd.rs | 2 +- crates/engine/src/lib.rs | 39 +- crates/engine/src/tests.rs | 21 +- crates/engine/src/tests_adjust.rs | 2 +- crates/engine/src/tests_attributes.rs | 14 +- crates/engine/src/tests_blendfidelity.rs | 6 +- crates/engine/src/tests_blendopts.rs | 2 +- crates/engine/src/tests_blendspine.rs | 2 +- crates/engine/src/tests_build.rs | 19 +- crates/engine/src/tests_clipboard.rs | 8 +- crates/engine/src/tests_editcolors.rs | 2 +- crates/engine/src/tests_envelope.rs | 10 +- crates/engine/src/tests_envelope_distort.rs | 6 +- crates/engine/src/tests_envelope_edit.rs | 7 +- crates/engine/src/tests_eyedropper.rs | 2 +- crates/engine/src/tests_flatten.rs | 4 +- crates/engine/src/tests_history_mem.rs | 29 ++ crates/engine/src/tests_links.rs | 4 +- crates/engine/src/tests_live.rs | 3 +- crates/engine/src/tests_livecorners.rs | 14 +- crates/engine/src/tests_menucmds.rs | 5 +- crates/engine/src/tests_opacitymask.rs | 4 +- crates/engine/src/tests_pdfraster.rs | 2 +- crates/engine/src/tests_persp_text.rs | 2 +- crates/engine/src/tests_recovery.rs | 116 +++++ crates/engine/src/tests_scalestrokes.rs | 6 +- crates/engine/src/tests_shaper.rs | 12 +- crates/engine/src/tests_slices.rs | 12 +- crates/engine/src/tests_stylelib.rs | 4 +- crates/engine/src/tests_styles.rs | 12 +- crates/engine/src/tests_targeting.rs | 2 +- crates/eps/src/import/ai/read.rs | 4 +- crates/eps/src/import/data.rs | 2 +- crates/eps/src/import/preview.rs | 3 +- crates/eps/src/import/tests.rs | 2 +- crates/eps/src/scene.rs | 2 +- crates/eps/src/tests.rs | 2 +- crates/format/src/lib.rs | 2 +- crates/format/tests/links.rs | 2 +- crates/format/tests/native_v3.rs | 11 +- crates/format/tests/native_v4.rs | 2 +- crates/format/tests/prop_format.rs | 2 +- crates/format/tests/save_options.rs | 2 +- crates/metafile/src/import.rs | 2 +- crates/metafile/src/scene.rs | 2 +- crates/metafile/src/tests.rs | 2 +- crates/pdf/src/export.rs | 10 +- crates/pdf/src/import.rs | 24 +- crates/pdf/src/import_lines.rs | 4 +- crates/pdf/src/import_mask.rs | 2 +- crates/pdf/src/import_shading.rs | 2 +- crates/pdf/src/tests.rs | 4 +- crates/pdf/src/tests_cmykblend.rs | 2 +- crates/pdf/src/tests_cmykimages.rs | 8 +- crates/pdf/src/tests_compression.rs | 2 +- crates/pdf/src/tests_import_fidelity.rs | 14 +- crates/pdf/src/tests_output.rs | 2 +- crates/pdf/src/tests_pdfx.rs | 2 +- crates/pdf/src/tests_webview.rs | 2 +- crates/render/src/encode/psd.rs | 2 +- crates/render/src/fx.rs | 18 +- crates/render/src/ink.rs | 2 +- crates/render/src/lib.rs | 6 +- crates/render/src/tests.rs | 6 +- crates/render/src/tests_adjust.rs | 9 +- crates/render/src/tests_cmykblend.rs | 11 +- crates/render/src/tests_isolation.rs | 2 +- crates/render/src/tests_knockout.rs | 2 +- crates/render/src/tests_layeropts.rs | 4 +- crates/render/src/tests_memory.rs | 2 +- crates/render/src/tests_objectfx.rs | 4 +- crates/render/src/tests_setup.rs | 2 +- crates/svg/src/css.rs | 7 +- crates/svg/src/export.rs | 25 +- crates/svg/src/import.rs | 14 +- crates/svg/tests/blend_export.rs | 2 +- crates/svg/tests/css_rules.rs | 10 +- crates/svg/tests/data_attributes.rs | 13 +- crates/svg/tests/encoding_profile_fonts.rs | 2 +- crates/svg/tests/import_details.rs | 2 +- crates/svg/tests/import_text.rs | 2 +- crates/svg/tests/mask_options.rs | 4 +- crates/svg/tests/options.rs | 2 +- crates/svg/tests/roundtrip.rs | 16 +- crates/svg/tests/symbols_layers.rs | 4 +- crates/tools/src/builder.rs | 2 +- crates/tools/src/corners.rs | 8 +- crates/tools/src/cropimage.rs | 2 +- crates/tools/src/distort.rs | 3 +- crates/tools/src/distort/perspective.rs | 16 +- crates/tools/src/draw2.rs | 2 +- crates/tools/src/meshedit.rs | 11 +- crates/tools/src/pathtype.rs | 2 +- crates/tools/src/select.rs | 3 +- crates/tools/src/typewidget.rs | 2 +- crates/tools/src/xform/eyedropper.rs | 2 +- crates/ui-egui/src/canvas.rs | 12 +- crates/ui-egui/src/dialogs/blend_options.rs | 2 +- crates/ui-egui/src/dialogs/corners.rs | 4 +- crates/ui-egui/src/dialogs/envelope.rs | 21 +- crates/ui-egui/src/dialogs/graph_data.rs | 7 +- crates/ui-egui/src/panels/graphic_styles.rs | 2 +- crates/ui-egui/src/panels/image_trace.rs | 4 +- crates/ui-egui/src/panels/layers.rs | 2 +- crates/ui-egui/src/panels/properties.rs | 4 +- crates/ui-egui/src/panels/transform.rs | 4 +- crates/ui-egui/src/panels/transparency.rs | 10 +- 159 files changed, 1340 insertions(+), 691 deletions(-) diff --git a/apps/vectorcraft-cli/src/perf.rs b/apps/vectorcraft-cli/src/perf.rs index c76f8553..97cd0647 100644 --- a/apps/vectorcraft-cli/src/perf.rs +++ b/apps/vectorcraft-cli/src/perf.rs @@ -220,7 +220,7 @@ fn deep(n: usize) -> Result<(), String> { let xf = Affine::translate((f64::from(i % 2) * 800.0, f64::from(i / 2) * 600.0)) * Affine::scale(800.0 / 6000.0); let image = vectorcraft_doc::ImageObject { key, width: 6000, height: 4000, xf, link: None, placement: Default::default() }; let id = d.alloc_id(); - d.insert(l, usize::MAX, Node::new(id, vectorcraft_doc::NodeKind::Image(image))).map_err(|e| e.to_string())?; + d.insert(l, usize::MAX, Node::new(id, vectorcraft_doc::NodeKind::Image(Box::new(image)))).map_err(|e| e.to_string())?; } note("4 embedded 24 MP photos"); let mut renderers = [Renderer::new(), Renderer::new(), Renderer::new()]; diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index a8a9aa1d..6310acf4 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -349,7 +349,7 @@ impl<'a> Scene<'a> { if n.blend != vectorcraft_color::BlendMode::Normal { self.warn("blending modes are left out"); } - if n.mask.as_ref().is_some_and(|m| !m.disabled) { + if n.extras().mask.as_ref().is_some_and(|m| !m.disabled) { self.warn("opacity masks are left out"); } let raster = |fx: &[vectorcraft_doc::Effect]| fx.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)); diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 87023aa3..0bb612f5 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -355,7 +355,7 @@ fn images_link_png_or_jpeg_files() { link: None, placement: Default::default(), }; - vec![Node::new(d.alloc_id(), NodeKind::Image(im))] + vec![Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)))] }); d.images.insert("raster-1".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png), proxy: None }); let out = export(&d, &opts(&d)).unwrap(); diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index e52bd366..1849077a 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -562,7 +562,7 @@ impl Lerp { } } } - n.mask = None; + n.extras_mut().mask = None; n } Shape::Path { pair, rules, members } => { @@ -590,7 +590,7 @@ impl Lerp { (NodeKind::PlacedDocument(o), NodeKind::PlacedDocument(x), NodeKind::PlacedDocument(y)) => o.xf = lerp_affine(x.xf, y.xf, t), _ => {} } - n.mask = None; + n.extras_mut().mask = None; n } Shape::Other => { @@ -614,7 +614,7 @@ impl Lerp { for c in n.children_mut().into_iter().flatten() { zero_ids(Arc::make_mut(c)); } - n.mask = None; + n.extras_mut().mask = None; n } }; @@ -994,7 +994,7 @@ fn paints_opaquely(n: &Node) -> bool { }; n.opacity >= 1.0 && n.blend == vectorcraft_color::BlendMode::Normal - && n.mask.is_none() + && n.extras().mask.is_none() && n.appearance.effects.is_empty() && n.appearance .items diff --git a/crates/doc/src/corners.rs b/crates/doc/src/corners.rs index a618ee1b..986442b2 100644 --- a/crates/doc/src/corners.rs +++ b/crates/doc/src/corners.rs @@ -60,7 +60,7 @@ impl<'a> LiveCorners<'a> { /// The Live Corners of a path object. pub fn of(node: &'a Node) -> Option { let NodeKind::Path { path, live, .. } = &node.kind else { return None }; - Self::new(path, live.as_ref()) + Self::new(path, live.as_deref()) } /// The corner at anchor `index` of `base`. @@ -127,7 +127,7 @@ impl<'a> LiveCorners<'a> { /// A path that isn't a live shape keeps its uncut outline as a [`LiveShape::Path`], which is a /// plain path again once no corner is cut. Ellipses and lines have no corners: they stay as they /// are. -pub fn set_corners(path: &mut PathData, live: &mut Option, corners: &BTreeSet, radius: Option, kind: Option) { +pub fn set_corners(path: &mut PathData, live: &mut Option>, corners: &BTreeSet, radius: Option, kind: Option) { fn edit(radii: &mut [f64], kinds: &mut [CornerKind], corners: &BTreeSet, radius: Option, kind: Option) { for k in corners { if let (Some(r), Some(slot)) = (radius, radii.get_mut(*k)) { @@ -158,17 +158,17 @@ pub fn set_corners(path: &mut PathData, live: &mut Option, corners: & kinds.clear(); } } - let l = live.get_or_insert_with(|| LiveShape::Path { base: path.clone(), radii: vec![], kinds: vec![] }); + let l = live.get_or_insert_with(|| Box::new(LiveShape::Path { base: path.clone(), radii: vec![], kinds: vec![] })); // Radii are document lengths and corners circular, also on a rectangle from a file that kept // an uneven scale in its transform (#442). l.fold_scale(); - match l { + match &mut **l { LiveShape::Rectangle { radii, kinds, .. } => edit(radii, kinds, corners, radius, kind), LiveShape::Polygon { sides, radii, kinds, .. } => edit_vec(*sides as usize, radii, kinds, corners, radius, kind), LiveShape::Path { base, radii, kinds } => edit_vec(base.anchor_count(), radii, kinds, corners, radius, kind), LiveShape::Ellipse { .. } | LiveShape::Line { .. } => return, } - match live { + match live.as_deref_mut() { Some(LiveShape::Path { base, radii, kinds }) if radii.is_empty() && kinds.is_empty() => { *path = std::mem::take(base); *live = None; @@ -220,15 +220,15 @@ mod tests { let star = shapes::star(Point::new(100.0, 100.0), 50.0, 25.0, 5, 0.0); let (mut path, mut live) = (star.clone(), None); set_corners(&mut path, &mut live, &set(&[0, 2]), Some(5.0), None); - let Some(LiveShape::Path { base, radii, kinds }) = &live else { panic!("live corners: {live:?}") }; + let Some(LiveShape::Path { base, radii, kinds }) = live.as_deref() else { panic!("live corners: {live:?}") }; assert_eq!((base, radii.len(), kinds.len()), (&star, 10, 0)); assert_eq!(path.anchor_count(), 12, "two corners cut"); - let c = LiveCorners::new(&path, live.as_ref()).unwrap(); + let c = LiveCorners::new(&path, live.as_deref()).unwrap(); assert_eq!(c.style(&set(&[0, 2])), (Some(5.0), Some(CornerKind::Round))); assert_eq!(c.corners_of(&anchors(&[0, 11])), set(&[0]), "the first corner's cut ends the path"); // Kinds alone keep it live; squaring every corner makes it the plain star again. set_corners(&mut path, &mut live, &set(&[0, 2]), Some(0.0), Some(CornerKind::Chamfer)); - assert!(matches!(&live, Some(LiveShape::Path { radii, .. }) if radii.is_empty())); + assert!(matches!(live.as_deref(), Some(LiveShape::Path { radii, .. }) if radii.is_empty())); set_corners(&mut path, &mut live, &set(&[0, 2]), None, Some(CornerKind::Round)); assert_eq!((path, live), (star, None)); } @@ -237,16 +237,16 @@ mod tests { #[test] fn polygon_corners_follow_their_sides() { let mut l = LiveShape::Polygon { radius: 50.0, sides: 6, xf: Affine::translate((100.0, 100.0)), radii: vec![], kinds: vec![] }; - let (mut path, mut live) = (l.to_path(), Some(l.clone())); + let (mut path, mut live) = (l.to_path(), Some(Box::new(l.clone()))); set_corners(&mut path, &mut live, &set(&[0, 1, 2, 3, 4, 5]), Some(8.0), None); assert_eq!(path.anchor_count(), 12); - l = live.clone().unwrap(); + l = *live.clone().unwrap(); l.set_sides(8); assert!(matches!(&l, LiveShape::Polygon { radii, .. } if *radii == vec![8.0; 8])); assert_eq!(l.to_path().anchor_count(), 16); // Mixed radii: the new corners are sharp. set_corners(&mut path, &mut live, &set(&[1]), Some(3.0), None); - let mut l = live.unwrap(); + let mut l = *live.unwrap(); l.set_sides(7); assert!(matches!(&l, LiveShape::Polygon { radii, .. } if *radii == [8.0, 3.0, 8.0, 8.0, 8.0, 8.0, 0.0])); // An uneven scale keeps the cuts circular: the radius scales by the mean scale. diff --git a/crates/doc/src/graph.rs b/crates/doc/src/graph.rs index abb40fe8..5dc01840 100644 --- a/crates/doc/src/graph.rs +++ b/crates/doc/src/graph.rs @@ -104,8 +104,11 @@ impl GraphDesign { /// graph of its own (that its commands would then edit). pub fn plain_art(art: &crate::Node) -> crate::Node { let mut n = art.clone(); - n.graph = None; - n.series_index = None; + { + let mut e = n.extras_mut(); + e.graph = None; + e.series_index = None; + } if let Some(ch) = n.children_mut() { let old = std::mem::take(ch); *ch = old.iter().map(|c| std::sync::Arc::new(Self::plain_art(c))).collect(); diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index d11c8983..226241c8 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -157,7 +157,7 @@ fn hit_children( return None; } for (i, c) in children.iter().enumerate().rev() { - if parent.shaper.is_some() { + if parent.extras().shaper.is_some() { let construction = children.first().is_some_and(|source| Some(source.id) == opt.scope); if (i == 0) != construction { continue; @@ -169,7 +169,7 @@ fn hit_children( } // (An envelope's content sits where it was, not where the envelope draws it.) if !(edits_contents(c) - || c.shaper.is_some() && c.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == opt.scope)) + || c.extras().shaper.is_some() && c.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == opt.scope)) && let Some(b) = c.reach_bounds() && !b.inflate(opt.tol, opt.tol).contains(p) { @@ -534,7 +534,7 @@ mod tests { let l = d.layers[0].id; let mut add = |t: TextObject, perspective: Option| { let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); - n.perspective = perspective.map(|a| { + n.extras_mut().perspective = perspective.map(|a| { Box::new(PerspectiveAttachment { projection: Some(Homography::from_affine(a).to_array()), ..PerspectiveAttachment::new("left", 0.0) }) }); let id = n.id; diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 26351869..b5446cae 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -86,8 +86,8 @@ pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, Gradient pub use metadata::{CopyrightStatus, DocMetadata}; pub use node::Knockout; pub use node::Scaling; +pub use node::{Extras, ExtrasMut, ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeExtras, NodeId, NodeKind, OpacityMask}; pub use node::{ImageMap, ObjectAttributes}; -pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind, OpacityMask}; pub use orient::OrientedBox; pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType}; pub use perspective::PerspectiveAttachment; diff --git a/crates/doc/src/links.rs b/crates/doc/src/links.rs index 43159b0f..7abcec7c 100644 --- a/crates/doc/src/links.rs +++ b/crates/doc/src/links.rs @@ -136,7 +136,7 @@ fn proxy_png(bytes: &[u8]) -> Option> { /// `n` and every descendant, opacity-mask art included. fn walk_all<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) { f(n); - if let Some(m) = &n.mask { + if let Some(m) = &n.extras().mask { walk_all(&m.art, f); } for c in n.children().into_iter().flatten() { @@ -295,7 +295,7 @@ impl Document { NodeKind::PlacedDocument(placed) => f(&mut placed.link), _ => {} } - if let Some(mask) = &mut n.mask { + if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { walk(&mut mask.art, f); } for child in n.children_mut().into_iter().flatten() { @@ -331,7 +331,7 @@ impl Document { { f(im); } - if let Some(m) = &mut n.mask { + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { update(&mut m.art, pick, f); } for c in n.children_mut().into_iter().flatten() { @@ -370,7 +370,14 @@ mod tests { 0, Node::new( id, - NodeKind::Image(ImageObject { key: key.into(), width: 4, height: 4, xf: Affine::IDENTITY, link, placement: Default::default() }), + NodeKind::Image(Box::new(ImageObject { + key: key.into(), + width: 4, + height: 4, + xf: Affine::IDENTITY, + link, + placement: Default::default(), + })), ), ) .unwrap() diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 0759e293..d7d7d1e9 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -1222,7 +1222,7 @@ pub fn envelope_of(doc: &crate::Document, id: NodeId) -> Option<&Node> { /// edit (its points and handles). pub fn envelope_overlay(n: &Node) -> Option<(PathData, Option)> { let NodeKind::Envelope { kind, .. } = &n.kind else { return None }; - match kind { + match &**kind { EnvelopeKind::Mesh { rows, cols, points, handles } => { let grid = envelope_grid(*rows, *cols, points, handles)?; Some((grid.lines(), Some(grid))) @@ -1270,7 +1270,7 @@ impl<'a> EnvelopeMap<'a> { /// The map of envelope node `n` (`None` for other nodes). pub fn of(n: &'a Node) -> Option { match &n.kind { - NodeKind::Envelope { content, kind, frame, .. } => Self::new(content, kind, *frame), + NodeKind::Envelope { content, kind, frame, .. } => Self::new(content, kind, **frame), _ => None, } } @@ -1388,7 +1388,7 @@ fn carry_look(from: &Node, mut to: Node) -> Node { to.isolate = from.isolate; to.knockout = from.knockout; to.knockout_shape = from.knockout_shape; - to.mask = from.mask.clone(); + to.extras_mut().mask = from.extras().mask.clone(); to.appearance.effects = from.appearance.effects.clone(); to } @@ -1417,7 +1417,7 @@ fn map_node(n: &Node, w: &Warper) -> Node { { out.appearance.warp_linear_gradients(b, &|p| affine_near(w.f, p, w.eps)); } - if let Some(m) = &mut out.mask { + if let Some(m) = out.extras_if_any().and_then(|e| e.mask.as_mut()) { m.art = Arc::new(map_node(&m.art, w)); } match &mut out.kind { @@ -1494,7 +1494,7 @@ fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node { if let NodeKind::Path { clipping, .. } = &mut clip.kind { *clipping = true; } - let image = Node::new(n.id, NodeKind::Image(ImageObject { xf, ..im.clone() })); + let image = Node::new(n.id, NodeKind::Image(Box::new(ImageObject { xf, ..im.clone() }))); pieces.push(Arc::new(Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true }))); } } @@ -1523,17 +1523,18 @@ fn pattern_art(n: &Node, tiles: PatternTiles) -> Option { _ => None, }; let plain = |items: Vec| { - Arc::new(Node { + let mut p = Node { appearance: Appearance { items, ..Default::default() }.into(), opacity: 1.0, blend: Default::default(), isolate: false, knockout: Default::default(), knockout_shape: false, - mask: None, name: None, ..n.clone() - }) + }; + p.extras_mut().mask = None; + Arc::new(p) }; let mut pieces = vec![]; let mut rest = vec![]; @@ -1640,7 +1641,7 @@ pub fn expand_live_with(n: &Node, outline: Outliner) -> Vec { pub fn expand_live_hooks(n: &Node, hooks: Hooks) -> Vec { match &n.kind { NodeKind::Blend { children, spec } => blend_expand(children, spec), - NodeKind::Envelope { content, kind, fidelity, options, frame, .. } => expand_envelope(content, kind, *fidelity, *options, *frame, hooks), + NodeKind::Envelope { content, kind, fidelity, options, frame, .. } => expand_envelope(content, kind, *fidelity, *options, **frame, hooks), NodeKind::Mesh(m) => mesh_quad_nodes(m, 8), NodeKind::Repeat(r) => r.expand(), NodeKind::PlacedDocument(p) => p.art(n), @@ -1669,7 +1670,7 @@ pub fn expanded_group_hooks(n: &Node, hooks: Hooks) -> Node { g.knockout = crate::Knockout::Off; } g.knockout_shape = n.knockout_shape; - g.mask = n.mask.clone(); + g.extras_mut().mask = n.extras().mask.clone(); g } diff --git a/crates/doc/src/mem.rs b/crates/doc/src/mem.rs index 1162e8a6..1a51c4d7 100644 --- a/crates/doc/src/mem.rs +++ b/crates/doc/src/mem.rs @@ -2,9 +2,11 @@ //! //! Documents share structure (`Arc` children, [`crate::Shared`] resources), so an old //! version kept in the undo history holds only what the edits after it replaced. The estimate -//! walks the old version beside the newer one and visits only what isn't shared: a subtree whose -//! `Arc` the newer document also holds costs nothing and isn't entered, so the walk is about as -//! long as the edit was (plus a pass over each changed child list). It counts the heap the values +//! walks two versions side by side and visits only what isn't shared: a subtree whose `Arc` both +//! hold costs nothing and isn't entered, so the walk is about as long as the edit was (plus a pass +//! over each changed child list). One walk gives both directions: what the older version keeps +//! that the newer one doesn't (the undo step's cost) and the other way round (the cost of the redo +//! step that undoing it makes), so undo and redo needn't walk again. It counts the heap the values //! own (struct sizes, vector capacities, strings, JSON) plus a typical allocator overhead per //! allocation, without following every last field: an estimate in the right ballpark, for a //! memory budget, not an exact figure. @@ -15,17 +17,18 @@ use std::sync::Arc; use serde_json::Value; use vectorcraft_color::{Paint, Swatch, SwatchGroup}; -use vectorcraft_geom::{Anchor, PathData, SubPath}; +use vectorcraft_geom::{Affine, Anchor, PathData, SubPath}; +use crate::live::{BlendSpec, EnvelopeKind}; use crate::text::{ParaStyle, TextObject, TextRun, TextStyleDef}; -use crate::{Appearance, AppearanceItem, Document, Effect, GraphicStyle, Node, NodeKind, SavedSelection, Shared}; +use crate::{Appearance, AppearanceItem, Document, Effect, GraphicStyle, ImageObject, Node, NodeExtras, NodeKind, SavedSelection, Shared}; /// Bookkeeping and rounding a typical allocator adds to each allocation. const ALLOC: usize = 16; /// An `Arc`'s two reference counts. const ARC: usize = 16; -/// The kinds of node this doesn't break down (meshes, envelopes' settings, placed documents…): -/// a typical size of what they own besides the node. +/// The kinds of node this doesn't break down (meshes, repeats' settings, placed documents…): a +/// typical size of what they own besides the node. const OTHER_KIND: usize = 256; impl Document { @@ -33,23 +36,48 @@ impl Document { /// keeping this version in the undo history costs while `newer` (a later version) is kept too. /// Visits only the parts that aren't shared (see the [module docs](self)). pub fn retained_bytes(&self, newer: &Document) -> usize { - if std::ptr::eq(self, newer) { - return 0; + self.retained_bytes_each(newer).0 + } + + /// [`Document::retained_bytes`] both ways in one walk: what this document keeps that `other` + /// doesn't share, and what `other` keeps that this one doesn't. + pub fn retained_bytes_each(&self, other: &Document) -> (usize, usize) { + if std::ptr::eq(self, other) { + return (0, 0); } - let mut w = Walk { newer, seen: None, bytes: size_of::() + ALLOC + ARC }; - w.list(&self.layers, Some(&newer.layers)); - for (i, s) in self.symbols.iter().enumerate() { - w.bytes += s.name.capacity(); - w.tree(&s.art, newer.symbols.get(i).map(|n| &n.art)); + let own = size_of::() + ALLOC + ARC; + let mut w = Walk { docs: [self, other], seen: [None, None], bytes: [own, own] }; + w.list(&self.layers, &other.layers); + for i in 0..self.symbols.len().max(other.symbols.len()) { + match (self.symbols.get(i), other.symbols.get(i)) { + (Some(a), Some(b)) => w.pair(&a.art, &b.art), + (a, b) => { + a.into_iter().for_each(|a| w.alone(0, &a.art)); + b.into_iter().for_each(|b| w.alone(1, &b.art)); + } + } } - w.bytes += vec_bytes(&self.symbols); - for (i, p) in self.patterns.iter().enumerate() { - w.bytes += p.name.capacity(); - w.list(&p.art, newer.patterns.get(i).map(|n| &n.art)); + for i in 0..self.patterns.len().max(other.patterns.len()) { + match (self.patterns.get(i), other.patterns.get(i)) { + (Some(a), Some(b)) => w.list(&a.art, &b.art), + (a, b) => { + a.into_iter().flat_map(|a| &a.art).for_each(|n| w.alone(0, n)); + b.into_iter().flat_map(|b| &b.art).for_each(|n| w.alone(1, n)); + } + } } - w.bytes += vec_bytes(&self.patterns); - w.bytes += self.resources_bytes(newer); - w.bytes + let [mut a, mut b] = w.bytes; + a += self.lists_bytes() + self.resources_bytes(other); + b += other.lists_bytes() + other.resources_bytes(self); + (a, b) + } + + /// The symbol and pattern lists themselves (their art is counted as nodes). + fn lists_bytes(&self) -> usize { + vec_bytes(&self.symbols) + + self.symbols.iter().map(|s| s.name.capacity()).sum::() + + vec_bytes(&self.patterns) + + self.patterns.iter().map(|p| p.name.capacity()).sum::() } /// The document-wide lists and settings: a copy of the document copies those that aren't @@ -92,47 +120,99 @@ impl Document { } } -/// One estimate in progress. +/// One estimate in progress: two documents walked side by side. struct Walk<'a> { - newer: &'a Document, - /// Every node the newer document holds (by address), built the first time a node has no - /// counterpart in place: then a node moved elsewhere (grouped, say) is still found shared. - seen: Option>, - bytes: usize, + docs: [&'a Document; 2], + /// For each side, every node the other document holds (by address), built the first time a + /// node of that side that the other may share has no counterpart in place: then a node moved + /// elsewhere (grouped, say) is still found shared. + seen: [Option>; 2], + /// For each side, what it keeps that the other doesn't. + bytes: [usize; 2], } impl Walk<'_> { - /// The nodes of `list` that `newer` (the counterpart list, if any) doesn't share. - fn list(&mut self, list: &[Arc], newer: Option<&Vec>>) { - for (i, n) in list.iter().enumerate() { - self.tree(n, newer.and_then(|l| l.get(i))); + /// Two versions of a child list: the nodes in the same place on both sides (the same `Arc`, or + /// versions of the same object) are compared in place; the others (inserted, deleted, moved) + /// may still be shared from elsewhere. + fn list(&mut self, a: &[Arc], b: &[Arc]) { + let same = |x: &Arc, y: &Arc| Arc::ptr_eq(x, y) || x.id == y.id; + // In place from the start and from the end: an insertion or deletion shifts only what is + // between. + let head = a.iter().zip(b).take_while(|(x, y)| same(x, y)).count(); + let (a_rest, b_rest) = (a.get(head..).unwrap_or_default(), b.get(head..).unwrap_or_default()); + let tail = a_rest.iter().rev().zip(b_rest.iter().rev()).take_while(|(x, y)| same(x, y)).count(); + for (x, y) in a.iter().zip(b).take(head).chain(a_rest.iter().rev().zip(b_rest.iter().rev()).take(tail)) { + self.pair(x, y); + } + let a_mid = a_rest.get(..a_rest.len().saturating_sub(tail)).unwrap_or_default(); + let b_mid = b_rest.get(..b_rest.len().saturating_sub(tail)).unwrap_or_default(); + for i in 0..a_mid.len().max(b_mid.len()) { + match (a_mid.get(i), b_mid.get(i)) { + (Some(x), Some(y)) if same(x, y) => self.pair(x, y), + (x, y) => { + x.into_iter().for_each(|x| self.alone(0, x)); + y.into_iter().for_each(|y| self.alone(1, y)); + } + } } } - /// Node `n` and what under it isn't shared; `newer` is the node in its place in the newer - /// document, if any. - fn tree(&mut self, n: &Arc, newer: Option<&Arc>) { - if newer.is_some_and(|m| Arc::ptr_eq(n, m)) { + /// Node `a` and `b` in the same place on the two sides: nothing when they are the same `Arc`, + /// both nodes and what under them isn't shared when they are versions of the same object. + fn pair(&mut self, a: &Arc, b: &Arc) { + if Arc::ptr_eq(a, b) { return; } - // In its place is another version of the same object (edited, moved): compare their - // children in place. Else it may sit elsewhere in the newer document. - let counterpart = newer.filter(|m| m.id == n.id); - if counterpart.is_none() && self.newer_holds(n) { + if a.id != b.id { + self.alone(0, a); + self.alone(1, b); return; } - self.bytes += node_bytes(n, counterpart.map(|m| &**m)); - if let Some(ch) = n.children() { - self.list(ch, counterpart.and_then(|m| m.children())); + self.bytes[0] += node_bytes(a, Some(b)); + self.bytes[1] += node_bytes(b, Some(a)); + match (a.children(), b.children()) { + (Some(x), Some(y)) => self.list(x, y), + (x, y) => { + x.into_iter().flatten().for_each(|c| self.alone(0, c)); + y.into_iter().flatten().for_each(|c| self.alone(1, c)); + } } - if let Some(mask) = &n.mask { - self.tree(&mask.art, counterpart.and_then(|m| m.mask.as_ref()).map(|m| &m.art)); + match (&a.extras().mask, &b.extras().mask) { + (Some(x), Some(y)) => self.pair(&x.art, &y.art), + (x, y) => { + x.iter().for_each(|m| self.alone(0, &m.art)); + y.iter().for_each(|m| self.alone(1, &m.art)); + } } } - fn newer_holds(&mut self, n: &Arc) -> bool { - let newer = self.newer; - let seen = self.seen.get_or_insert_with(|| { + /// Node `n` of side `side` without a counterpart in place, and what under it the other side + /// doesn't hold. + fn alone(&mut self, side: usize, n: &Arc) { + if self.other_holds(side, n) { + return; + } + if let Some(b) = self.bytes.get_mut(side) { + *b += node_bytes(n, None); + } + for c in n.children().into_iter().flatten() { + self.alone(side, c); + } + if let Some(m) = &n.extras().mask { + self.alone(side, &m.art); + } + } + + /// Whether the document across from `side` holds node `n` somewhere. + fn other_holds(&mut self, side: usize, n: &Arc) -> bool { + // Held only where it was found: not in the other document (which would hold it too). So + // new art inserted by the edit is told apart without looking. + if Arc::strong_count(n) == 1 { + return false; + } + let (Some(other), Some(seen)) = (self.docs.get(1 - side.min(1)), self.seen.get_mut(side)) else { return false }; + let seen = seen.get_or_insert_with(|| { fn add(n: &Arc, out: &mut HashSet) { // A node already seen was entered already (a node shared within the document). if !out.insert(Arc::as_ptr(n) as usize) { @@ -141,12 +221,12 @@ impl Walk<'_> { for c in n.children().into_iter().flatten() { add(c, out); } - if let Some(m) = &n.mask { + if let Some(m) = &n.extras().mask { add(&m.art, out); } } let mut out = HashSet::new(); - let roots = newer.layers.iter().chain(newer.symbols.iter().map(|s| &s.art)).chain(newer.patterns.iter().flat_map(|p| &p.art)); + let roots = other.layers.iter().chain(other.symbols.iter().map(|s| &s.art)).chain(other.patterns.iter().flat_map(|p| &p.art)); for n in roots { add(n, &mut out); } @@ -157,28 +237,29 @@ impl Walk<'_> { } /// One node's own heap: the node, and what it owns besides its children and mask art (counted -/// as nodes of their own). Its appearance counts unless `newer`, its newer version, shares it. -fn node_bytes(n: &Node, newer: Option<&Node>) -> usize { +/// as nodes of their own). Its appearance counts unless `other`, its other version, shares it. +fn node_bytes(n: &Node, other: Option<&Node>) -> usize { fn boxed(b: &Option>) -> usize { b.as_ref().map_or(0, |_| ALLOC + size_of::()) } let mut b = ALLOC + ARC + size_of::(); b += n.name.as_ref().map_or(0, String::capacity); - if !newer.is_some_and(|m| Shared::ptr_eq(&m.appearance, &n.appearance)) { + if !other.is_some_and(|m| Shared::ptr_eq(&m.appearance, &n.appearance)) { b += ALLOC + ARC + appearance_heap(&n.appearance); } - b += boxed(&n.mask) + boxed(&n.graph) + boxed(&n.shaper) + boxed(&n.attrs) + boxed(&n.slice) + boxed(&n.perspective); - b += n.trace.as_ref().map_or(0, |t| ALLOC + json_bytes(t)); + if let Some(e) = n.extras_box() { + b += ALLOC + size_of::(); + b += boxed(&e.mask) + boxed(&e.graph) + boxed(&e.shaper) + boxed(&e.attrs) + boxed(&e.slice) + boxed(&e.perspective); + b += e.trace.as_ref().map_or(0, |t| ALLOC + size_of::() + json_bytes(t)); + } b += match &n.kind { - NodeKind::Path { path, .. } => path_bytes(path), - NodeKind::Layer { children, .. } - | NodeKind::Group { children, .. } - | NodeKind::Compound { children, .. } - | NodeKind::Blend { children, .. } - | NodeKind::Envelope { content: children, .. } => vec_bytes(children), + NodeKind::Path { path, live, .. } => path_bytes(path) + boxed(live), + NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => vec_bytes(children), + NodeKind::Blend { children, .. } => vec_bytes(children) + ALLOC + size_of::(), + NodeKind::Envelope { content, .. } => vec_bytes(content) + 2 * ALLOC + size_of::() + size_of::(), NodeKind::Repeat(r) => vec_bytes(&r.source) + OTHER_KIND, NodeKind::Text(t) => ALLOC + text_bytes(t), - NodeKind::Image(im) => im.key.capacity(), + NodeKind::Image(im) => ALLOC + size_of::() + im.key.capacity(), NodeKind::SymbolInstance { symbol, .. } => symbol.capacity(), _ => OTHER_KIND, }; @@ -273,7 +354,8 @@ mod tests { } /// Moving every rectangle: each keeps its old node and anchors (its appearance stays shared). - /// The process grew by about 775 bytes per moved rectangle when measured (#1244). + /// The process grew by about 775 bytes per moved rectangle when measured (#1244), about 490 + /// once nodes kept their rarely set parts out of line. #[test] fn moving_rectangles_costs_their_nodes() { let n = 1000; @@ -307,6 +389,26 @@ mod tests { assert!(bytes < 8192, "only the old layer and its child list: {bytes}"); } + /// One walk gives both ways, each what a walk the other way gives; an insertion and a deletion + /// cost the side that has the objects. + #[test] + fn both_ways_at_once() { + let before = doc_with_rects(100); + let mut after = before.clone(); + let layer = after.layers[0].id; + for at in [0, 50, usize::MAX] { + let id = after.alloc_id(); + let r = shapes::rectangle(kurbo::Rect::new(0.0, 0.0, 10.0, 10.0)); + after.insert(Some(layer), at, Node::path(id, r, Appearance::default_art())).unwrap(); + } + let (kept, added) = before.retained_bytes_each(&after); + assert_eq!((kept, added), (before.retained_bytes(&after), after.retained_bytes(&before))); + assert_eq!(after.retained_bytes_each(&before), (added, kept)); + // The old layer and its list are the old version's own; the new one holds three new nodes. + assert!(kept < 4096, "{kept}"); + assert!(added > kept + 3 * 300, "{added} for three new rectangles"); + } + #[test] fn a_resource_counts_only_when_changed() { let before = doc_with_rects(1); diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 9eb4b0cd..06db3cc8 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -427,8 +427,9 @@ pub enum NodeKind { path: PathData, #[serde(default, skip_serializing_if = "crate::skip::is_default")] rule: FillRule, + /// Boxed: few paths are live shapes, and the shape is larger than the rest of the variant. #[serde(default, skip_serializing_if = "Option::is_none")] - live: Option, + live: Option>, /// This path acts as a clipping path inside a clip group. #[serde(default, skip_serializing_if = "crate::skip::is_default")] clipping: bool, @@ -443,7 +444,8 @@ pub enum NodeKind { rule: FillRule, }, Text(Box), - Image(ImageObject), + /// Boxed, as the other large kinds: a node is as large as its largest kind. + Image(Box), SymbolInstance { symbol: String, xf: Affine, @@ -453,12 +455,12 @@ pub enum NodeKind { Blend { children: Vec>, #[serde(default)] - spec: BlendSpec, + spec: Box, }, /// Live envelope distortion of `content`. Envelope { content: Vec>, - kind: EnvelopeKind, + kind: Box, /// Envelope Options → Fidelity (0–100). #[serde(default = "crate::live::default_fidelity")] fidelity: f64, @@ -472,7 +474,7 @@ pub enum NodeKind { /// the page axes can't stand for (rotation, shear, reflection). The content maps from its /// bounds in this frame, and a warp bends along it. #[serde(default, skip_serializing_if = "crate::skip::is_default")] - frame: Affine, + frame: Box, }, /// Gradient mesh. Mesh(GradientMesh), @@ -491,72 +493,328 @@ fn one() -> f32 { } /// One object in the tree. Children are `Arc`s so documents share structure between undo states. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +/// +/// What most objects have is kept inline; what few have is in [`NodeExtras`], allocated only for +/// the objects that have any of it: every undo step of an edit copies the nodes it changed, so a +/// small node is a cheap step. Files keep the flat field list either way: a node is written field +/// by field from where each lives, and read through a flat `NodeWire`. +#[derive(Clone, Debug, PartialEq, Deserialize)] +#[serde(from = "NodeWire")] pub struct Node { pub id: NodeId, - #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, - #[serde(default = "yes", skip_serializing_if = "crate::skip::is_true")] pub visible: bool, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub locked: bool, - #[serde(default = "one", skip_serializing_if = "crate::skip::is_one")] pub opacity: f32, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub blend: BlendMode, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub isolate: bool, /// Knockout Group: whether a container's children knock each other out (see [`Knockout`]). - #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub knockout: Knockout, /// Opacity & Mask Define Knockout Shape: inside a knockout group, this object's opacity and /// opacity mask scale how much of the objects below it in the group it knocks out. - #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub knockout_shape: bool, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub appearance: crate::Shared, - /// Opacity mask (Transparency panel). Its art lives here, outside the layer tree. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub mask: Option>, - /// Image Trace object: `{preset, params, view?}` it was traced with (the Image Trace panel shows - /// them; `view` is its [`crate::TraceView`] id, absent for the tracing result). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub trace: Option>, - /// Object → Text Wrap: area type below this object (in the same layer) flows around it. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub wrap: Option, - /// Graph object: the group's children are generated from this spec (Object → Graph). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub graph: Option>, - /// Index of the graph series this generated group belongs to. Axes, the legend and other - /// children leave it unset, so a series named "Legend" or "Axes" is not mistaken for them. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub series_index: Option, - /// Editable Shaper composition; the original art is retained in its source child. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub shaper: Option>, pub kind: NodeKind, - /// The [`crate::GraphicStyle::id`] last applied to this object. It stays linked while it keeps - /// that style's look: editing its appearance or transparency breaks the link. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub graphic_style: Option, - /// Attributes panel: centre point display, image map, URL and note (`None`: all defaults). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub attrs: Option>, - /// Object → Slice → Make: the object is an object slice (its slice follows its bounds) with - /// these options. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub slice: Option>, /// The angle of the object's own axes, counter-clockwise degrees (0: square to the page): its /// bounding box and handles stand at this angle. Transforms turn it with the object (see /// [`crate::orient`]); Reset Bounding Box sets it back to 0. - #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub bbox_angle: f64, + /// The rarely set parts, read with [`Node::extras`] and changed with [`Node::extras_mut`] + /// (public so that `Node { opacity: 1.0, ..node }` works). + pub extras: Extras, +} + +/// The parts of a [`Node`] that few objects have: kept out of line, so that the nodes without +/// them stay small ([`Node::extras`]). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct NodeExtras { + /// Opacity mask (Transparency panel). Its art lives here, outside the layer tree. + pub mask: Option>, + /// Image Trace object: `{preset, params, view?}` it was traced with (the Image Trace panel shows + /// them; `view` is its [`crate::TraceView`] id, absent for the tracing result). + pub trace: Option>, + /// Object → Text Wrap: area type below this object (in the same layer) flows around it. + pub wrap: Option, + /// Graph object: the group's children are generated from this spec (Object → Graph). + pub graph: Option>, + /// Index of the graph series this generated group belongs to. Axes, the legend and other + /// children leave it unset, so a series named "Legend" or "Axes" is not mistaken for them. + pub series_index: Option, + /// Editable Shaper composition; the original art is retained in its source child. + pub shaper: Option>, + /// The [`crate::GraphicStyle::id`] last applied to this object. It stays linked while it keeps + /// that style's look: editing its appearance or transparency breaks the link. + pub graphic_style: Option, + /// Attributes panel: centre point display, image map, URL and note (`None`: all defaults). + pub attrs: Option>, + /// Object → Slice → Make: the object is an object slice (its slice follows its bounds) with + /// these options. + pub slice: Option>, /// Object › Perspective: the perspective grid plane the object is attached to. - #[serde(default, skip_serializing_if = "Option::is_none")] pub perspective: Option>, } +/// No extras: what [`Node::extras`] gives for a node without any. +static NO_EXTRAS: NodeExtras = NodeExtras { + mask: None, + trace: None, + wrap: None, + graph: None, + series_index: None, + shaper: None, + graphic_style: None, + attrs: None, + slice: None, + perspective: None, +}; + +/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal +/// none. +#[derive(Clone, Debug, Default)] +pub struct Extras(Option>); + +impl Extras { + fn get(&self) -> &NodeExtras { + self.0.as_deref().unwrap_or(&NO_EXTRAS) + } +} + +impl PartialEq for Extras { + fn eq(&self, other: &Self) -> bool { + match (&self.0, &other.0) { + (Some(a), Some(b)) => a == b, + _ => self.get() == other.get(), + } + } +} + +impl Node { + /// The rarely set parts of this node (all unset when it has none). + #[inline] + pub fn extras(&self) -> &NodeExtras { + self.extras.get() + } + /// The rarely set parts of this node, to change: when the change is done (the guard dropped), + /// extras that were set are allocated and extras left all unset are let go. Setting one to + /// `None` on a node without extras allocates nothing. + #[inline] + pub fn extras_mut(&mut self) -> ExtrasMut<'_> { + ExtrasMut { slot: &mut self.extras.0, local: NodeExtras::default() } + } + /// The rarely set parts of this node, if it has any: to change what is there in place. + #[inline] + pub fn extras_if_any(&mut self) -> Option<&mut NodeExtras> { + self.extras.0.as_deref_mut() + } + /// The live shape of a path object, if it is one. + pub fn live_shape(&self) -> Option<&LiveShape> { + match &self.kind { + NodeKind::Path { live, .. } => live.as_deref(), + _ => None, + } + } + /// The live shape of a path object, if it is one, to change. + pub fn live_shape_mut(&mut self) -> Option<&mut LiveShape> { + match &mut self.kind { + NodeKind::Path { live, .. } => live.as_deref_mut(), + _ => None, + } + } + /// This node with opacity mask `mask` (or none). + pub fn with_mask(mut self, mask: Option>) -> Self { + self.extras_mut().mask = mask; + self + } + /// The extras, if this node has them allocated (for [`crate::mem`]'s estimate). + pub(crate) fn extras_box(&self) -> Option<&NodeExtras> { + self.extras.0.as_deref() + } +} + +/// [`Node::extras_mut`]: the node's extras while they change. +pub struct ExtrasMut<'a> { + slot: &'a mut Option>, + /// Stands for the extras of a node without any, boxed into it when dropped if one was set. + local: NodeExtras, +} + +impl std::ops::Deref for ExtrasMut<'_> { + type Target = NodeExtras; + fn deref(&self) -> &NodeExtras { + self.slot.as_deref().unwrap_or(&self.local) + } +} + +impl std::ops::DerefMut for ExtrasMut<'_> { + fn deref_mut(&mut self) -> &mut NodeExtras { + match self.slot.as_deref_mut() { + Some(e) => e, + None => &mut self.local, + } + } +} + +impl Drop for ExtrasMut<'_> { + fn drop(&mut self) { + match self.slot.as_deref() { + Some(e) if *e == NO_EXTRAS => *self.slot = None, + Some(_) => {} + None if self.local != NO_EXTRAS => *self.slot = Some(Box::new(std::mem::take(&mut self.local))), + None => {} + } + } +} + +/// How a [`Node`] is written: the flat field list of the file format, in its order, borrowed from +/// the node and its extras (nothing is copied). The node reads back through `NodeWire`. +impl Serialize for Node { + fn serialize(&self, s: S) -> Result { + use crate::skip::{is_default, is_one, is_true}; + use serde::ser::SerializeStruct; + let e = self.extras(); + // As the derive did: the fields written, then each field written or skipped in order. + let skips = [ + false, + self.name.is_none(), + is_true(&self.visible), + is_default(&self.locked), + is_one(&self.opacity), + is_default(&self.blend), + is_default(&self.isolate), + is_default(&self.knockout), + is_default(&self.knockout_shape), + is_default(&self.appearance), + e.mask.is_none(), + e.trace.is_none(), + e.wrap.is_none(), + e.graph.is_none(), + e.series_index.is_none(), + e.shaper.is_none(), + false, + e.graphic_style.is_none(), + e.attrs.is_none(), + e.slice.is_none(), + is_default(&self.bbox_angle), + e.perspective.is_none(), + ]; + let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?; + let mut skip = skips.into_iter(); + macro_rules! field { + ($name:literal, $v:expr) => { + if skip.next().unwrap_or(false) { + st.skip_field($name)?; + } else { + st.serialize_field($name, $v)?; + } + }; + } + field!("id", &self.id); + field!("name", &self.name); + field!("visible", &self.visible); + field!("locked", &self.locked); + field!("opacity", &self.opacity); + field!("blend", &self.blend); + field!("isolate", &self.isolate); + field!("knockout", &self.knockout); + field!("knockout_shape", &self.knockout_shape); + field!("appearance", &self.appearance); + field!("mask", &e.mask); + field!("trace", &e.trace); + field!("wrap", &e.wrap); + field!("graph", &e.graph); + field!("series_index", &e.series_index); + field!("shaper", &e.shaper); + field!("kind", &self.kind); + field!("graphic_style", &e.graphic_style); + field!("attrs", &e.attrs); + field!("slice", &e.slice); + field!("bbox_angle", &self.bbox_angle); + field!("perspective", &e.perspective); + st.end() + } +} + +/// How a [`Node`] is read: the flat field list of the file format. Fields move into the node +/// (and its extras, allocated only when one is set) without a copy of what they own. +#[derive(Deserialize)] +#[serde(rename = "Node")] +struct NodeWire { + id: NodeId, + #[serde(default)] + name: Option, + #[serde(default = "yes")] + visible: bool, + #[serde(default)] + locked: bool, + #[serde(default = "one")] + opacity: f32, + #[serde(default)] + blend: BlendMode, + #[serde(default)] + isolate: bool, + #[serde(default)] + knockout: Knockout, + #[serde(default)] + knockout_shape: bool, + #[serde(default)] + appearance: crate::Shared, + #[serde(default)] + mask: Option>, + #[serde(default)] + trace: Option>, + #[serde(default)] + wrap: Option, + #[serde(default)] + graph: Option>, + #[serde(default)] + series_index: Option, + #[serde(default)] + shaper: Option>, + kind: NodeKind, + #[serde(default)] + graphic_style: Option, + #[serde(default)] + attrs: Option>, + #[serde(default)] + slice: Option>, + #[serde(default)] + bbox_angle: f64, + #[serde(default)] + perspective: Option>, +} + +impl From for Node { + fn from(w: NodeWire) -> Self { + let extras = NodeExtras { + mask: w.mask, + trace: w.trace, + wrap: w.wrap, + graph: w.graph, + series_index: w.series_index, + shaper: w.shaper, + graphic_style: w.graphic_style, + attrs: w.attrs, + slice: w.slice, + perspective: w.perspective, + }; + Node { + id: w.id, + name: w.name, + visible: w.visible, + locked: w.locked, + opacity: w.opacity, + blend: w.blend, + isolate: w.isolate, + knockout: w.knockout, + knockout_shape: w.knockout_shape, + appearance: w.appearance, + kind: w.kind, + bbox_angle: w.bbox_angle, + extras: Extras((extras != NO_EXTRAS).then(|| Box::new(extras))), + } + } +} + /// Opacity mask: the luminance of the mask art sets the object's opacity (white = opaque). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct OpacityMask { @@ -595,18 +853,9 @@ impl Node { knockout: Knockout::Neutral, knockout_shape: false, appearance: Appearance::default().into(), - mask: None, - trace: None, - wrap: None, - graph: None, - series_index: None, - shaper: None, kind, - graphic_style: None, - attrs: None, - slice: None, bbox_angle: 0.0, - perspective: None, + extras: Extras::default(), } } pub fn path(id: NodeId, path: PathData, appearance: Appearance) -> Self { @@ -691,7 +940,7 @@ impl Node { && !self.isolate && self.knockout == Knockout::Neutral && !self.knockout_shape - && self.mask.is_none() + && self.extras().mask.is_none() } /// Kind name as the Layers panel / Properties panel shows it. pub fn kind_label(&self) -> &'static str { @@ -772,7 +1021,7 @@ impl Node { } NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => children .iter() - .skip(usize::from(self.shaper.is_some())) + .skip(usize::from(self.extras().shaper.is_some())) .filter(|c| c.visible || !matches!(self.kind, NodeKind::Layer { .. })) .fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric_bounds())), NodeKind::Text(t) => self.projected(t.bounds()), @@ -782,7 +1031,7 @@ impl Node { let b = crate::live::nodes_bounds(children); vectorcraft_geom::union_opt(b, spec.spine.as_ref().and_then(|s| s.bounds())) } - NodeKind::Envelope { content, kind, frame, .. } => crate::live::envelope_bounds(content, kind, *frame), + NodeKind::Envelope { content, kind, frame, .. } => crate::live::envelope_bounds(content, kind, **frame), NodeKind::Mesh(m) => m.bounds(), NodeKind::Repeat(r) => r.bounds(), NodeKind::PlacedDocument(p) => Some(p.bounds()), @@ -806,7 +1055,7 @@ impl Node { let o = self.appearance.outset(); children .iter() - .skip(usize::from(self.shaper.is_some())) + .skip(usize::from(self.extras().shaper.is_some())) .fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds())) .map(|b| b.inflate(o, o)) } @@ -903,8 +1152,8 @@ impl Node { for c in content.iter_mut() { Arc::make_mut(c).transform_scaled(a, sc); } - *frame = crate::live::envelope_frame(a * *frame); - match kind { + **frame = crate::live::envelope_frame(a * **frame); + match &mut **kind { EnvelopeKind::Mesh { points, handles, .. } => { for p in points.iter_mut() { *p = a * *p; @@ -923,7 +1172,7 @@ impl Node { NodeKind::Mesh(m) => m.transform(a), NodeKind::Repeat(r) => r.transform(a, *sc), } - if let Some(m) = &mut self.mask + if let Some(m) = self.extras_if_any().and_then(|e| e.mask.as_mut()) && m.linked { Arc::make_mut(&mut m.art).transform_scaled(a, sc); @@ -1039,7 +1288,7 @@ impl Node { } } NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { - for c in children.iter().skip(usize::from(self.shaper.is_some())).filter(|c| c.visible) { + for c in children.iter().skip(usize::from(self.extras().shaper.is_some())).filter(|c| c.visible) { c.push_clip_shapes(text, out); } } @@ -1324,25 +1573,25 @@ impl ImageMap { impl Node { /// The URL this object links to, if any. pub fn url(&self) -> Option<&str> { - self.attrs.as_deref().map(|a| a.url.as_str()).filter(|u| !u.is_empty()) + self.extras().attrs.as_deref().map(|a| a.url.as_str()).filter(|u| !u.is_empty()) } /// Whether the canvas shows this object's centre point when it is selected: as set in the /// Attributes panel, else [`Self::shows_center_by_default`]. pub fn shows_center(&self) -> bool { - self.attrs.as_deref().and_then(|a| a.show_center).unwrap_or_else(|| self.shows_center_by_default()) + self.extras().attrs.as_deref().and_then(|a| a.show_center).unwrap_or_else(|| self.shows_center_by_default()) } /// Shapes drawn with the shape tools (live rectangles, ellipses and polygons) show their centre. pub fn shows_center_by_default(&self) -> bool { - matches!(&self.kind, NodeKind::Path { live: Some(l), .. } if !matches!(l, LiveShape::Line { .. } | LiveShape::Path { .. })) + matches!(&self.kind, NodeKind::Path { live: Some(l), .. } if !matches!(**l, LiveShape::Line { .. } | LiveShape::Path { .. })) } /// Change this object's attributes with `f`; all-default attributes are dropped. pub fn edit_attrs(&mut self, f: impl FnOnce(&mut ObjectAttributes) -> R) -> R { - let mut a = self.attrs.take().unwrap_or_default(); + let mut a = self.extras_mut().attrs.take().unwrap_or_default(); let r = f(&mut a); - self.attrs = (*a != ObjectAttributes::default()).then_some(a); + self.extras_mut().attrs = (*a != ObjectAttributes::default()).then_some(a); r } } @@ -1358,7 +1607,7 @@ impl Node { self.visible && (self.opacity < 1.0 || self.blend != BlendMode::Normal - || self.mask.as_ref().is_some_and(|m| !m.disabled) + || self.extras().mask.as_ref().is_some_and(|m| !m.disabled) || effects(&self.appearance.effects) || self .appearance @@ -1454,13 +1703,14 @@ mod tests { let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), xf }; let mut n = Node::path(NodeId(1), live.to_path(), Appearance::default_art()); if let NodeKind::Path { live: slot, .. } = &mut n.kind { - *slot = Some(live); + *slot = Some(Box::new(live)); } n } fn live_of(n: &Node) -> (f64, f64, [f64; 4], Affine) { - let NodeKind::Path { live: Some(LiveShape::Rectangle { w, h, radii, xf, .. }), .. } = &n.kind else { panic!("not a live rectangle") }; + let NodeKind::Path { live: Some(l), .. } = &n.kind else { panic!("not a live shape") }; + let LiveShape::Rectangle { w, h, radii, xf, .. } = &**l else { panic!("not a live rectangle") }; (*w, *h, *radii, *xf) } @@ -1595,7 +1845,7 @@ mod tests { |n| n.knockout = Knockout::On, |n| n.knockout = Knockout::Off, |n| n.knockout_shape = true, - |n| n.mask = Some(Box::new(OpacityMask::new(n.clone(), true))), + |n| n.extras_mut().mask = Some(Box::new(OpacityMask::new(n.clone(), true))), ]; for tweak in tweaks { let mut n = p(); diff --git a/crates/doc/src/perspective.rs b/crates/doc/src/perspective.rs index 4e1e24ef..4de3823d 100644 --- a/crates/doc/src/perspective.rs +++ b/crates/doc/src/perspective.rs @@ -46,7 +46,7 @@ impl Node { /// The projective map type or a symbol instance in perspective is drawn through (not while /// Edit Text shows it flat). pub fn projection(&self) -> Option { - let a = self.perspective.as_deref().filter(|a| !a.editing)?; + let a = self.extras().perspective.as_deref().filter(|a| !a.editing)?; matches!(self.kind, NodeKind::Text(_) | NodeKind::SymbolInstance { .. }).then(|| a.homography()).flatten() } @@ -60,7 +60,7 @@ impl Node { /// Keep a projection drawing the same picture moved by `a` (the flat art moves by `a` too). pub(crate) fn transform_projection(&mut self, a: vectorcraft_geom::Affine) { - if let Some(p) = self.perspective.as_deref_mut() + if let Some(p) = self.extras_if_any().and_then(|e| e.perspective.as_deref_mut()) && let Some(h) = p.homography().and_then(|h| h.conjugated(a)) { p.projection = Some(h.to_array()); diff --git a/crates/doc/src/placed_document.rs b/crates/doc/src/placed_document.rs index 4d1be2d4..c399bf20 100644 --- a/crates/doc/src/placed_document.rs +++ b/crates/doc/src/placed_document.rs @@ -119,7 +119,7 @@ fn prefix(key: &str) -> String { /// `n` and every descendant, opacity-mask art included. fn walk_all<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) { f(n); - if let Some(m) = &n.mask { + if let Some(m) = &n.extras().mask { walk_all(&m.art, f); } for c in n.children().into_iter().flatten() { @@ -184,7 +184,7 @@ impl Document { } // The art shown may hold placed documents too. let n = Arc::make_mut(n); - if let Some(m) = &mut n.mask { + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { update(&mut m.art); } for c in n.children_mut().into_iter().flatten() { @@ -210,11 +210,11 @@ impl Document { && keys.contains(&p.key) { if let Some(im) = images.get(&p.key).and_then(|b| p.preview_image(&b.bytes)) { - n.kind = NodeKind::Image(im); + n.kind = NodeKind::Image(Box::new(im)); } return; } - if let Some(m) = &mut n.mask { + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { update(&mut m.art, keys, images); } for c in n.children_mut().into_iter().flatten() { @@ -406,7 +406,7 @@ fn read_preview(p: &PlacedDocument, bytes: &Arc>) -> Option crate::NodeId { /// Visit `n` and every descendant (opacity-mask art included) mutably. fn each_mut(n: &mut Node, f: &mut impl FnMut(&mut Node)) { f(n); - if let Some(m) = &mut n.mask { + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { each_mut(Arc::make_mut(&mut m.art), f); } for c in n.children_mut().into_iter().flatten() { @@ -590,7 +590,14 @@ mod tests { d.layers.clear(); let art = Node::new( NodeId(50), - NodeKind::Image(ImageObject { key: "pic".into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default() }), + NodeKind::Image(Box::new(ImageObject { + key: "pic".into(), + width: 1, + height: 1, + xf: Affine::IDENTITY, + link: None, + placement: Default::default(), + })), ); d.symbols.push(Symbol { name: "Star".into(), art: Arc::new(art) }); d.images.insert("pic".into(), ImageBlob::new("image/png", vec![1, 2, 3])); diff --git a/crates/doc/src/slices.rs b/crates/doc/src/slices.rs index f62d7fa0..8deba728 100644 --- a/crates/doc/src/slices.rs +++ b/crates/doc/src/slices.rs @@ -177,13 +177,13 @@ impl Document { } /// Is `id` a user slice or an object with slice options? pub fn is_slice(&self, id: NodeId) -> bool { - self.slice(id).is_some() || self.node(id).is_some_and(|n| n.slice.is_some()) + self.slice(id).is_some() || self.node(id).is_some_and(|n| n.extras().slice.is_some()) } /// The options of slice `id` (user or object). pub fn slice_options(&self, id: NodeId) -> Option<&SliceOptions> { match self.slice(id) { Some(s) => Some(&s.options), - None => self.node(id)?.slice.as_deref(), + None => self.node(id)?.extras().slice.as_deref(), } } /// The rectangle of slice `id` before clipping: a user slice's own, an object slice's object's @@ -191,14 +191,14 @@ impl Document { pub fn slice_bounds(&self, id: NodeId) -> Option { match self.slice(id) { Some(s) => Some(s.rect), - None => self.node(id).filter(|n| n.slice.is_some())?.visual_bounds(), + None => self.node(id).filter(|n| n.extras().slice.is_some())?.visual_bounds(), } } /// The visible objects with slice options and their visual bounds, in paint order. pub fn object_slices(&self) -> Vec<(NodeId, Rect)> { fn walk(nodes: &[Arc], out: &mut Vec<(NodeId, Rect)>) { for n in nodes.iter().filter(|n| n.visible) { - if n.slice.is_some() + if n.extras().slice.is_some() && let Some(b) = n.visual_bounds() { out.push((n.id, b)); diff --git a/crates/doc/src/tests_slices.rs b/crates/doc/src/tests_slices.rs index 3a63fe0c..86a0cee8 100644 --- a/crates/doc/src/tests_slices.rs +++ b/crates/doc/src/tests_slices.rs @@ -30,7 +30,7 @@ fn an_object_slice_follows_its_object_and_numbers_run_by_rows() { let mut d = Document::new(300.0, 200.0); let a = rect(&mut d, Rect::new(100.0, 50.0, 200.0, 150.0)); assert!(d.slice_layout().is_empty(), "no slices, no auto slices"); - d.node_mut(a).unwrap().slice = Some(Box::default()); + d.node_mut(a).unwrap().extras_mut().slice = Some(Box::default()); let lay = d.slice_layout(); let own = lay.iter().find(|s| s.id == Some(a)).unwrap(); assert_eq!((own.source, own.rect), (SliceSource::Object, Rect::new(100.0, 50.0, 200.0, 150.0))); @@ -69,14 +69,15 @@ fn clip_to_artboard_clips_slices_and_off_covers_the_art() { fn slices_survive_a_native_round_trip_and_old_files_load() { let mut d = Document::new(100.0, 100.0); let a = rect(&mut d, Rect::new(10.0, 10.0, 20.0, 20.0)); - d.node_mut(a).unwrap().slice = Some(Box::new(SliceOptions { kind: crate::SliceKind::NoImage, text: "hi".into(), ..Default::default() })); + d.node_mut(a).unwrap().extras_mut().slice = + Some(Box::new(SliceOptions { kind: crate::SliceKind::NoImage, text: "hi".into(), ..Default::default() })); let s = user(&mut d, Rect::new(30.0, 30.0, 60.0, 60.0)); d.slice_mut(s).unwrap().options.url = "https://example.com".into(); d.slices_clip_to_artboard = false; let json = serde_json::to_string(&d).unwrap(); let back: Document = serde_json::from_str(&json).unwrap(); assert_eq!(back.slices, d.slices); - assert_eq!(back.node(a).unwrap().slice, d.node(a).unwrap().slice); + assert_eq!(back.node(a).unwrap().extras().slice, d.node(a).unwrap().extras().slice); assert!(!back.slices_clip_to_artboard); // A document without slices writes none of the new keys (older readers see what they knew). let plain = serde_json::to_string(&Document::new(10.0, 10.0)).unwrap(); diff --git a/crates/doc/src/trace.rs b/crates/doc/src/trace.rs index 72dc470c..5155cc21 100644 --- a/crates/doc/src/trace.rs +++ b/crates/doc/src/trace.rs @@ -67,7 +67,7 @@ impl TraceView { impl Node { /// The View of an Image Trace object (the tracing result for anything else, or an unknown id). pub fn trace_view(&self) -> TraceView { - self.trace.as_deref().map(TraceView::of).unwrap_or_default() + self.extras().trace.as_deref().map(TraceView::of).unwrap_or_default() } } @@ -84,9 +84,9 @@ mod tests { } let mut g = Node::group(crate::NodeId(1), vec![]); assert_eq!(g.trace_view(), TraceView::Result); - g.trace = Some(Box::new(serde_json::json!({"view": "outlines"}))); + g.extras_mut().trace = Some(Box::new(serde_json::json!({"view": "outlines"}))); assert_eq!(g.trace_view(), TraceView::Outlines); - g.trace = Some(Box::new(serde_json::json!({"view": "sepia"}))); + g.extras_mut().trace = Some(Box::new(serde_json::json!({"view": "sepia"}))); assert_eq!(g.trace_view(), TraceView::Result); } } diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 6538b2de..1ca740ae 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -163,24 +163,25 @@ pub fn fresh_ids(d: &mut Document, n: &mut Node, seen: &mut std::collections::Ha fn piece(d: &mut Document, n: &Node, path: PathData, rule: FillRule, item: AppearanceItem) -> Node { let id = d.alloc_id(); let kind = path_kind(d, path, rule); - Node { - id, - name: None, - appearance: Appearance { items: vec![item], ..Default::default() }.into(), - opacity: 1.0, - blend: Default::default(), - isolate: false, - knockout: Default::default(), - knockout_shape: false, - mask: None, - trace: None, - wrap: None, - graph: None, - // The group keeps the URL (one link around the pieces). - attrs: None, - kind, - ..n.clone() - } + let mut out = n.clone(); + out.id = id; + out.name = None; + out.appearance = Appearance { items: vec![item], ..Default::default() }.into(); + out.opacity = 1.0; + out.blend = Default::default(); + out.isolate = false; + out.knockout = Default::default(); + out.knockout_shape = false; + out.kind = kind; + let mut extras = out.extras_mut(); + extras.mask = None; + extras.trace = None; + extras.wrap = None; + extras.graph = None; + // The group keeps the URL (one link around the pieces). + extras.attrs = None; + drop(extras); + out } /// `n`'s geometry with its object-level geometry effects applied, its fill rule, the context of diff --git a/crates/effects/src/marks.rs b/crates/effects/src/marks.rs index 4009cad3..b349d620 100644 --- a/crates/effects/src/marks.rs +++ b/crates/effects/src/marks.rs @@ -32,8 +32,8 @@ pub fn crop_marks_art(n: &Node) -> Option { g.opacity = std::mem::replace(&mut object.opacity, 1.0); g.blend = std::mem::take(&mut object.blend); g.isolate = std::mem::take(&mut object.isolate); - g.mask = object.mask.take(); - g.attrs = object.attrs.take(); + g.extras_mut().mask = object.extras_mut().mask.take(); + g.extras_mut().attrs = object.extras_mut().attrs.take(); if let Some(ch) = g.children_mut() { *ch = vec![Arc::new(object), Arc::new(marks)]; } diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index e37a0d20..05b686b5 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -92,7 +92,7 @@ pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option { art.isolate = false; // One element: its pieces never knock each other out. art.knockout = vectorcraft_doc::Knockout::Off; - art.mask = None; + art.extras_mut().mask = None; art.appearance.effects.clear(); Some(art) } @@ -141,7 +141,7 @@ pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option { if let NodeKind::Path { clipping, .. } = &mut clip.kind { *clipping = true; } - let image = Node { appearance: Appearance::default().into(), ..Node::new(n.id, n.kind.clone()) }; + let image = Node::new(n.id, n.kind.clone()); Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true }) } _ => outline_art(n, symbol_art)?, @@ -159,7 +159,7 @@ pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option { out.isolate = n.isolate; // One element: its pieces never knock each other out. out.knockout = vectorcraft_doc::Knockout::Off; - out.mask = n.mask.clone(); + out.extras_mut().mask = n.extras().mask.clone(); out.appearance.effects = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect(); Some(out) } diff --git a/crates/engine/src/cmd/attributes.rs b/crates/engine/src/cmd/attributes.rs index ec328917..80dac69b 100644 --- a/crates/engine/src/cmd/attributes.rs +++ b/crates/engine/src/cmd/attributes.rs @@ -82,7 +82,7 @@ fn fill_rule_owner(d: &Document, id: NodeId) -> Option<&Node> { /// The Attributes panel's values of `info.ids` beyond overprinting. pub(super) fn fill_info(d: &Document, info: &mut AttributesInfo) { let nodes: Vec<&Node> = info.ids.iter().filter_map(|id| d.node(*id)).collect(); - let attrs = || nodes.iter().map(|n| n.attrs.as_deref().cloned().unwrap_or_default()); + let attrs = || nodes.iter().map(|n| n.extras().attrs.as_deref().cloned().unwrap_or_default()); info.show_center = same(nodes.iter().map(|n| n.shows_center())); info.image_map = same(attrs().map(|a| a.image_map)); info.url = same(attrs().map(|a| a.url)); @@ -126,7 +126,7 @@ impl Edits { /// `n`'s attributes with the edits applied (Show Center kept only where it isn't the default). fn apply(&self, n: &Node) -> ObjectAttributes { - let mut a = n.attrs.as_deref().cloned().unwrap_or_default(); + let mut a = n.extras().attrs.as_deref().cloned().unwrap_or_default(); if let Some(v) = self.show_center { a.show_center = (v != n.shows_center_by_default()).then_some(v); } @@ -158,7 +158,7 @@ fn set(s: &mut Session, p: &Value) -> Result { for id in &ids { let Some(n) = d.node(*id) else { continue }; let new = edits.apply(n); - if new != n.attrs.as_deref().cloned().unwrap_or_default() + if new != n.extras().attrs.as_deref().cloned().unwrap_or_default() && let Some(n) = d.node_mut(*id) { n.edit_attrs(|a| *a = new); diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 38ef5993..c15ace9d 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -553,7 +553,7 @@ fn lp_expand(s: &mut Session, p: &Value) -> Result { for g in &targets { let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?; n.name = None; - n.trace = None; + n.extras_mut().trace = None; if let Some(ch) = n.children_mut() { ch.retain(|c| match c.name.as_deref() { Some(SOURCES_NAME) => false, @@ -790,7 +790,7 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result { if !expand { // Tracing again keeps the object's view unless another is asked for. let view = view.unwrap_or_else(|| src.trace_view()); - g.trace = Some(Box::new(json!({ "preset": preset_label, "params": params_json, "view": view.id() }))); + g.extras_mut().trace = Some(Box::new(json!({ "preset": preset_label, "params": params_json, "view": view.id() }))); } let (par, idx, _) = d.position(target).ok_or(EngineError::NoNode(target))?; d.remove(target)?; @@ -821,10 +821,10 @@ fn trace_set_view(s: &mut Session, p: &Value) -> Result { s.edit("Image Trace View", |d, _| { for g in &targets { let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?; - if let Some(t) = n.trace.as_deref_mut().and_then(Value::as_object_mut) { + if let Some(t) = n.extras_if_any().and_then(|e| e.trace.as_deref_mut()).and_then(Value::as_object_mut) { t.insert("view".into(), json!(view.id())); } else { - n.trace = Some(Box::new(json!({ "preset": "Custom", "view": view.id() }))); + n.extras_mut().trace = Some(Box::new(json!({ "preset": "Custom", "view": view.id() }))); } } Ok(()) @@ -854,7 +854,7 @@ fn trace_expand(s: &mut Session, p: &Value) -> Result { for g in &targets { let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?; n.name = None; - n.trace = None; + n.extras_mut().trace = None; if let Some(ch) = n.children_mut() { ch.retain(|c| !matches!(c.kind, NodeKind::Image(_))); } diff --git a/crates/engine/src/cmd/clipboard/flavours.rs b/crates/engine/src/cmd/clipboard/flavours.rs index d9825d39..2ae87825 100644 --- a/crates/engine/src/cmd/clipboard/flavours.rs +++ b/crates/engine/src/cmd/clipboard/flavours.rs @@ -315,7 +315,7 @@ fn import_image(s: &mut Session, p: &Value) -> Result { let (w, h) = (img.width, img.height); let image = ImageObject { key: img.key.clone(), width: w, height: h, xf: Affine::scale_non_uniform(sx, sy), link: None, placement: Default::default() }; - let mut clip = Clipboard { nodes: vec![Node::new(NodeId(1), NodeKind::Image(image))], ..Default::default() }; + let mut clip = Clipboard { nodes: vec![Node::new(NodeId(1), NodeKind::Image(Box::new(image)))], ..Default::default() }; clip.images.insert(img.key, img.blob); let count = s.load_clipboard(clip, p)?; Ok(json!({ "count": count, "width": w as f64 * sx, "height": h as f64 * sy, "pixelWidth": w, "pixelHeight": h })) diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index baf5b62d..032ee02f 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -107,10 +107,10 @@ fn attrs_refs(attrs: &Map, f: Visit) { fn node_refs(n: &Node, f: Visit) { n.walk(&mut |m| { appearance_refs(&m.appearance, f); - if let Some(id) = m.graphic_style { + if let Some(id) = m.extras().graphic_style { f(Ref::Style(id)); } - if let Some(mask) = &m.mask { + if let Some(mask) = &m.extras().mask { node_refs(&mask.art, f); } match &m.kind { @@ -427,8 +427,8 @@ impl Relink { /// Relink a node tree (the mirror of [`node_refs`]). fn node(&self, n: &mut Node) { self.appearance(&mut n.appearance); - n.graphic_style = n.graphic_style.and_then(|id| self.styles.get(&id).copied()); - if let Some(m) = &mut n.mask { + n.extras_mut().graphic_style = n.extras().graphic_style.and_then(|id| self.styles.get(&id).copied()); + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { self.node(Arc::make_mut(&mut m.art)); } match &mut n.kind { diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 84370f57..8f07dd2a 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -157,7 +157,7 @@ fn draw_mode(s: &mut Session, p: &Value) -> Result { } fn path_kind(path: PathData, live: Option) -> NodeKind { - NodeKind::Path { path, rule: FillRule::NonZero, live, clipping: false, guide: false } + NodeKind::Path { path, rule: FillRule::NonZero, live: live.map(Box::new), clipping: false, guide: false } } fn rect_of(p: &Value, cmd: &str) -> Result { diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index e4aeea52..dda0e7c6 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -439,7 +439,7 @@ struct Targets { /// What `n` is when Liquify can't distort it (and doesn't look inside it). fn not_liquified(n: &Node) -> Option<&'static str> { Some(match &n.kind { - NodeKind::Group { .. } if n.graph.is_some() => "graphs", + NodeKind::Group { .. } if n.extras().graph.is_some() => "graphs", NodeKind::Text(_) => "type", NodeKind::SymbolInstance { .. } => "symbols", NodeKind::Image(_) => "images", @@ -1252,7 +1252,7 @@ fn edit_text(s: &mut Session, p: &Value) -> Result { let (Some(flat), Some(drawn)) = (t.bounds(), t.bounds().and_then(|b| h.map_rect_bbox(b))) else { return Ok(()) }; let shift = vectorcraft_geom::Affine::translate(drawn.center() - flat.center()); t.transform(shift); - if let Some(rec) = n.perspective.as_deref_mut() { + if let Some(rec) = n.extras_if_any().and_then(|e| e.perspective.as_deref_mut()) { rec.projection = Some(h.then_after(&Homography::from_affine(shift.inverse())).to_array()); rec.editing = true; } @@ -1271,8 +1271,8 @@ pub(crate) fn finish_edit_text(st: &mut crate::DocState, id: NodeId) { let clear = |doc: &mut Arc| { // Undo steps keep no lookup cache: look without building one, and drop the one an edit // builds (the document now keeps its own). - if doc.node_uncached(id).and_then(|n| n.perspective.as_deref()).is_some_and(|p| p.editing) - && let Some(p) = Arc::make_mut(doc).node_mut(id).and_then(|n| n.perspective.as_deref_mut()) + if doc.node_uncached(id).and_then(|n| n.extras().perspective.as_deref()).is_some_and(|p| p.editing) + && let Some(p) = Arc::make_mut(doc).node_mut(id).and_then(|n| n.extras_if_any().and_then(|e| e.perspective.as_deref_mut())) { p.editing = false; } diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index 5af0106f..d0f7cf3d 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -290,7 +290,7 @@ fn info(s: &mut Session, p: &Value) -> Result { spot.insert(name.clone()); } } - if n.mask.is_some() { + if n.extras().mask.is_some() { *counts.entry("opacityMasks").or_default() += 1; } if !n.appearance.effects.is_empty() { diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 7a7f8f4e..bc50e5e7 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -214,11 +214,12 @@ fn undo(s: &mut Session, _: &Value) -> Result { let st = s.doc_mut()?; let e = st.history.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?; let label = e.label.clone(); + let costs = e.costs(); let editing = st.doc.mask_edit.map(|m| m.layer); let doc = std::mem::replace(&mut st.doc, e.doc); let selection = std::mem::replace(&mut st.selection, Arc::unwrap_or_clone(e.selection)); let selection = shared_selection(selection, st.history.redo.last()); - st.history.redo.push(crate::HistoryEntry::kept(e.label, doc, selection, &st.doc)); + st.history.redo.push(crate::HistoryEntry::turned(e.label, doc, selection, &st.doc, costs)); st.history.trim(); st.revision += 1; st.selection.prune(&st.doc); @@ -230,11 +231,12 @@ fn redo(s: &mut Session, _: &Value) -> Result { let st = s.doc_mut()?; let e = st.history.redo.pop().ok_or_else(|| EngineError::Other("nothing to redo".into()))?; let label = e.label.clone(); + let costs = e.costs(); let editing = st.doc.mask_edit.map(|m| m.layer); let doc = std::mem::replace(&mut st.doc, e.doc); let selection = std::mem::replace(&mut st.selection, Arc::unwrap_or_clone(e.selection)); let selection = shared_selection(selection, st.history.undo.last()); - st.history.undo.push(crate::HistoryEntry::kept(e.label, doc, selection, &st.doc)); + st.history.undo.push(crate::HistoryEntry::turned(e.label, doc, selection, &st.doc, costs)); st.history.trim(); st.revision += 1; st.selection.prune(&st.doc); diff --git a/crates/engine/src/cmd/fileio/affinity.rs b/crates/engine/src/cmd/fileio/affinity.rs index 70dff78f..f9c5f85d 100644 --- a/crates/engine/src/cmd/fileio/affinity.rs +++ b/crates/engine/src/cmd/fileio/affinity.rs @@ -289,7 +289,7 @@ impl Builder { // shows outside it, as an opacity mask that clips. if let Some(kind) = self.image(img, to_doc) { let art = Node::new(self.doc.alloc_id(), kind); - out.mask = Some(Box::new(OpacityMask::new(art, true))); + out.extras_mut().mask = Some(Box::new(OpacityMask::new(art, true))); } } if let Some(mask) = &n.mask { @@ -514,7 +514,7 @@ impl Builder { let sy = img.height as f64 / f64::from(r.height.max(1)); let xf = to_doc * affine(img.transform) * Affine::scale_non_uniform(sx, sy); self.doc.images.entry(r.key.clone()).or_insert(r.blob); - Some(NodeKind::Image(ImageObject { key: r.key, width: r.width, height: r.height, xf, link: None, placement: Default::default() })) + Some(NodeKind::Image(Box::new(ImageObject { key: r.key, width: r.width, height: r.height, xf, link: None, placement: Default::default() }))) } } diff --git a/crates/engine/src/cmd/fileio/imagemap.rs b/crates/engine/src/cmd/fileio/imagemap.rs index 6974fe8d..ecf5a8b3 100644 --- a/crates/engine/src/cmd/fileio/imagemap.rs +++ b/crates/engine/src/cmd/fileio/imagemap.rs @@ -82,7 +82,7 @@ fn collect(n: &Node, region: Rect, scale: f64, out: &mut Vec) { if !n.visible || matches!(n.kind, NodeKind::Layer { template: true, .. }) { return; } - let shape = n.attrs.as_deref().filter(|a| !a.url.is_empty()).map(|a| a.image_map).unwrap_or_default(); + let shape = n.extras().attrs.as_deref().filter(|a| !a.url.is_empty()).map(|a| a.image_map).unwrap_or_default(); let px = |x: f64, y: f64| [((x - region.x0) * scale).round() as i64, ((y - region.y0) * scale).round() as i64]; if let (Some(href), Some(b)) = (n.url(), n.visual_bounds().filter(|b| b.overlaps(region))) && shape != ImageMap::None diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index e492e18c..534d86c8 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -430,7 +430,8 @@ pub(super) fn raster_doc(name: &str, bytes: &[u8]) -> Result { let layer = d.layers[0].id; let id = d.alloc_id(); let xf = Affine::scale_non_uniform(sx, sy); - let mut n = Node::new(id, NodeKind::Image(ImageObject { key: key.clone(), width, height, xf, link: None, placement: Default::default() })); + let mut n = + Node::new(id, NodeKind::Image(Box::new(ImageObject { key: key.clone(), width, height, xf, link: None, placement: Default::default() }))); n.name = Some(name.to_string()); d.images.insert(key, blob); d.insert(Some(layer), 0, n).map_err(err)?; diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index a6f2128b..d4f6554e 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -43,7 +43,7 @@ fn image_bytes(w: u32, h: u32, format: image::ImageFormat) -> Vec { fn image_of(s: &Session) -> vectorcraft_doc::ImageObject { let doc = &s.doc().unwrap().doc; match &doc.layers[0].children().unwrap()[0].kind { - NodeKind::Image(im) => im.clone(), + NodeKind::Image(im) => (**im).clone(), other => panic!("expected an image, got {other:?}"), } } diff --git a/crates/engine/src/cmd/fileio/tests_svgimport.rs b/crates/engine/src/cmd/fileio/tests_svgimport.rs index 46fc2f88..d898d389 100644 --- a/crates/engine/src/cmd/fileio/tests_svgimport.rs +++ b/crates/engine/src/cmd/fileio/tests_svgimport.rs @@ -53,7 +53,7 @@ fn images(s: &Session) -> Vec { let mut out = vec![]; s.documents()[s.active_index().unwrap()].doc.walk(|n| { if let NodeKind::Image(im) = &n.kind { - out.push(im.clone()); + out.push((**im).clone()); } }); out diff --git a/crates/engine/src/cmd/flatten.rs b/crates/engine/src/cmd/flatten.rs index 8d11365b..2378b99c 100644 --- a/crates/engine/src/cmd/flatten.rs +++ b/crates/engine/src/cmd/flatten.rs @@ -1093,7 +1093,7 @@ fn raster_node(d: &mut Document, r: Raster) -> Node { d.images.insert(key.clone(), ImageBlob::new("image/png", r.png)); let image = Node::new( d.alloc_id(), - NodeKind::Image(ImageObject { key, width: r.width, height: r.height, xf: r.xf, link: None, placement: Default::default() }), + NodeKind::Image(Box::new(ImageObject { key, width: r.width, height: r.height, xf: r.xf, link: None, placement: Default::default() })), ); let Some(path) = r.clip else { return image }; super::rasterfx::clip_group(d, path, FillRule::NonZero, image) @@ -1224,7 +1224,7 @@ fn carry(from: &Node, mut to: Node) -> Node { to.blend = from.blend; to.isolate = from.isolate; to.knockout_shape = from.knockout_shape; - to.mask = from.mask.clone(); + to.extras_mut().mask = from.extras().mask.clone(); to.appearance.effects = from.appearance.effects.clone(); to } @@ -1258,7 +1258,7 @@ fn has_effects(n: &Node) -> bool { } fn masked(n: &Node) -> bool { - n.mask.as_ref().is_some_and(|m| !m.disabled) + n.extras().mask.as_ref().is_some_and(|m| !m.disabled) } /// Where `n` can paint: its visual bounds grown by its effects (and a margin for antialiasing). diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index 796814ac..bb114b8a 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -389,7 +389,7 @@ impl Gen<'_> { fn series(&mut self, name: &str, index: usize, children: Vec>) -> Arc { let mut g = Node::group(self.d.alloc_id(), children); g.name = Some(name.into()); - g.series_index = u32::try_from(index).ok(); + g.extras_mut().series_index = u32::try_from(index).ok(); Arc::new(g) } } @@ -1146,7 +1146,7 @@ fn children_bounds(children: &[Arc]) -> Option { /// generation, which is recorded in the spec's `placed` bounds). fn regenerate(d: &mut Document, id: NodeId, mut spec: GraphSpec) -> Result<()> { let n = d.node(id).ok_or(EngineError::NoNode(id))?; - if let (Some(placed), Some(now)) = (n.graph.as_ref().and_then(|g| g.placed), n.children().and_then(|c| children_bounds(c))) + if let (Some(placed), Some(now)) = (n.extras().graph.as_ref().and_then(|g| g.placed), n.children().and_then(|c| children_bounds(c))) && placed.width() > 1e-9 && placed.height() > 1e-9 && (placed.x0 - now.x0).abs() + (placed.y0 - now.y0).abs() + (placed.x1 - now.x1).abs() + (placed.y1 - now.y1).abs() > 1e-6 @@ -1162,7 +1162,7 @@ fn regenerate(d: &mut Document, id: NodeId, mut spec: GraphSpec) -> Result<()> { if let Some(ch) = n.children_mut() { *ch = children; } - n.graph = Some(Box::new(spec)); + n.extras_mut().graph = Some(Box::new(spec)); Ok(()) } @@ -1199,7 +1199,7 @@ fn target(s: &Session, p: &Value, cmd: &str) -> Result { for id in start { let mut cur = Some(id); while let Some(c) = cur { - if st.doc.node(c).is_some_and(|n| n.graph.is_some()) { + if st.doc.node(c).is_some_and(|n| n.extras().graph.is_some()) { return Ok(c); } cur = st.doc.parent_of(c); @@ -1209,7 +1209,7 @@ fn target(s: &Session, p: &Value, cmd: &str) -> Result { } fn spec_of(s: &Session, id: NodeId) -> Result { - s.doc()?.doc.node(id).and_then(|n| n.graph.as_deref().cloned()).ok_or(EngineError::NoNode(id)) + s.doc()?.doc.node(id).and_then(|n| n.extras().graph.as_deref().cloned()).ok_or(EngineError::NoNode(id)) } /// Where a node inside a graph's generated series art belongs: the graph, the series group and @@ -1227,10 +1227,10 @@ struct SeriesRef { fn series_members(doc: &Document) -> HashMap { let mut out = HashMap::new(); doc.walk(|n| { - let Some(spec) = n.graph.as_deref() else { return }; + let Some(spec) = n.extras().graph.as_deref() else { return }; let count = spec.rows.iter().map(Vec::len).max().unwrap_or(0).clamp(1, vectorcraft_doc::MAX_GRAPH_SERIES); for group in n.children().into_iter().flatten() { - let Some(index) = group.series_index.and_then(|i| usize::try_from(i).ok()).filter(|i| *i < count) else { continue }; + let Some(index) = group.extras().series_index.and_then(|i| usize::try_from(i).ok()).filter(|i| *i < count) else { continue }; let r = SeriesRef { graph: n.id, group: group.id, index }; group.walk(&mut |m| { out.insert(m.id, r); @@ -1288,7 +1288,7 @@ pub(crate) fn capture_series_paints(doc: &mut Document, ids: &[NodeId], fill: bo .collect(); let mut changed = false; for (graph, index, paint, width) in captures { - let Some(spec) = doc.node_mut(graph).and_then(|n| n.graph.as_deref_mut()) else { continue }; + let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; let was = spec.series_paints.clone(); // `index` is below the graph's series count (`series_members`). if spec.series_paints.len() <= index { @@ -1342,9 +1342,9 @@ fn switch_xy(g: &mut GraphSpec) { fn graph_labels(doc: &Document) -> HashMap { let mut out = HashMap::new(); doc.walk(|n| { - let Some(g) = n.graph.as_deref() else { return }; + let Some(g) = n.extras().graph.as_deref() else { return }; for c in n.children().into_iter().flatten() { - match (&c.kind, c.name.as_deref(), c.series_index) { + match (&c.kind, c.name.as_deref(), c.extras().series_index) { (NodeKind::Text(_), _, _) if g.kind == GraphKind::Pie => { out.insert(c.id, (n.id, Role::Category)); } @@ -1389,7 +1389,7 @@ pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], change: & kinds.dedup(); let mut changed = false; for (graph, role) in kinds { - let Some(spec) = doc.node_mut(graph).and_then(|n| n.graph.as_deref_mut()) else { continue }; + let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; let mut style = label_style(&spec.label_styles, role); change(&mut style); let slot = match role { @@ -1470,7 +1470,7 @@ fn reselect_series(d: &Document, sel: &mut vectorcraft_doc::Selection, id: NodeI .and_then(Node::children) .into_iter() .flatten() - .filter(|c| c.series_index.is_some_and(|i| indexes.contains(&i))) + .filter(|c| c.extras().series_index.is_some_and(|i| indexes.contains(&i))) .map(|c| c.id) .collect(); sel.set(groups); @@ -1485,12 +1485,12 @@ fn design_names(d: &Document) -> Vec { fn refresh_graphs_using(d: &mut Document, name: &str) -> Result<()> { let mut ids = vec![]; d.walk(|n| { - if n.graph.as_deref().is_some_and(|g| g.series_markers.iter().flatten().any(|m| m == name)) { + if n.extras().graph.as_deref().is_some_and(|g| g.series_markers.iter().flatten().any(|m| m == name)) { ids.push(n.id); } }); for id in ids { - if let Some(spec) = d.node(id).and_then(|n| n.graph.as_deref().cloned()) { + if let Some(spec) = d.node(id).and_then(|n| n.extras().graph.as_deref().cloned()) { regenerate(d, id, spec)?; } } @@ -1501,7 +1501,7 @@ fn refresh_graphs_using(d: &mut Document, name: &str) -> Result<()> { fn unused_designs(d: &Document) -> Vec { let mut used = std::collections::HashSet::new(); d.walk(|n| { - if let Some(g) = n.graph.as_deref() { + if let Some(g) = n.extras().graph.as_deref() { used.extend(g.series_markers.iter().flatten().cloned()); } }); @@ -1576,12 +1576,12 @@ fn design(s: &mut Session, p: &Value) -> Result { } let mut graphs = vec![]; d.walk(|n| { - if n.graph.as_deref().is_some_and(|g| g.series_markers.iter().flatten().any(|m| *m == old)) { + if n.extras().graph.as_deref().is_some_and(|g| g.series_markers.iter().flatten().any(|m| *m == old)) { graphs.push(n.id); } }); for id in graphs { - if let Some(g) = d.node_mut(id).and_then(|n| n.graph.as_deref_mut()) { + if let Some(g) = d.node_mut(id).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) { for m in g.series_markers.iter_mut().flatten().filter(|m| **m == old) { *m = new.clone(); } @@ -1921,7 +1921,7 @@ mod tests { /// The generated group stored for series `index`, not a sibling that happens to share its name. fn series(n: &vectorcraft_doc::Node, index: u32) -> &vectorcraft_doc::Node { - n.children().unwrap().iter().find(|c| c.series_index == Some(index)).unwrap_or_else(|| panic!("no series {index}")) + n.children().unwrap().iter().find(|c| c.extras().series_index == Some(index)).unwrap_or_else(|| panic!("no series {index}")) } #[test] @@ -1931,7 +1931,7 @@ mod tests { for ty in ["column", "stackedColumn", "bar", "stackedBar", "line", "area", "scatter", "pie", "radar"] { let id = graph(&mut s, ty); let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - assert!(n.graph.is_some(), "{ty}"); + assert!(n.extras().graph.is_some(), "{ty}"); if ty != "scatter" { assert!(!group(&n, "2024").children().unwrap().is_empty(), "{ty}"); assert!(!group(&n, "2025").children().unwrap().is_empty(), "{ty}"); @@ -1966,7 +1966,7 @@ mod tests { assert!((s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0 - x0).abs() < 1.0); // Type change keeps the data; undo restores the columns. s.execute("graph.setType", &json!({"type": "pie"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.node(id).unwrap().graph.as_ref().unwrap().kind, vectorcraft_doc::GraphKind::Pie); + assert_eq!(s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().kind, vectorcraft_doc::GraphKind::Pie); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["type"], json!("column")); // Selecting a bar inside still targets the graph. @@ -2068,7 +2068,7 @@ mod tests { let id = graph(&mut s, ty.id()); let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; s.execute("paint.setFill", &json!({"ids": [series_id.0], "color": "#ff0000"})).unwrap(); - let spec = s.doc().unwrap().doc.node(id).unwrap().graph.as_deref().unwrap().clone(); + let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone(); let restored: GraphSpec = serde_json::from_value(serde_json::to_value(&spec).unwrap()).unwrap(); assert_eq!(spec, restored); let red = Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)); @@ -2077,7 +2077,7 @@ mod tests { assert!(selected_group.children().unwrap().iter().all(|n| n.appearance.fill_paint() == red), "{ty:?}"); s.execute("graph.setData", &json!({"rows": [[2, 3], [4, 5]]})).unwrap(); s.execute("graph.setType", &json!({"type": "line"})).unwrap(); - let spec = s.doc().unwrap().doc.node(id).unwrap().graph.as_deref().unwrap(); + let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap(); assert_eq!(spec.series_paints.first().and_then(|p| p.fill.clone()), Some(red.clone()), "{ty:?}"); let series = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap(); assert!(series.iter().any(|n| n.appearance.fill_paint() == red), "{ty:?}"); @@ -2092,18 +2092,28 @@ mod tests { let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; s.execute("paint.setFill", &json!({"ids": [series_id.0], "color": "#e8573f"})).unwrap(); assert_eq!( - s.doc().unwrap().doc.node(id).unwrap().graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color).unwrap().to_hex(), + s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints[0] + .fill + .as_ref() + .and_then(Paint::color) + .unwrap() + .to_hex(), "#e8573f" ); s.execute("edit.undo", &json!({})).unwrap(); - assert!(s.doc().unwrap().doc.node(id).unwrap().graph.as_ref().unwrap().series_paints.is_empty()); + assert!(s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints.is_empty()); assert_eq!( group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0].appearance.fill_paint().color(), Some(super::default_series_color(0)) ); s.execute("edit.redo", &json!({})).unwrap(); assert_eq!( - s.doc().unwrap().doc.node(id).unwrap().graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color).unwrap().to_hex(), + s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints[0] + .fill + .as_ref() + .and_then(Paint::color) + .unwrap() + .to_hex(), "#e8573f" ); } @@ -2123,7 +2133,7 @@ mod tests { s.execute("paint.setFill", &fill).unwrap(); // The graph keeps nothing, as after a command that paints only the art. s.edit("Forget", |d, _| { - let g = d.node_mut(id).unwrap().graph.as_deref_mut().unwrap(); + let g = d.node_mut(id).unwrap().extras_if_any().unwrap().graph.as_deref_mut().unwrap(); g.series_paints.clear(); g.label_styles = vectorcraft_doc::LabelStyles::default(); Ok(()) @@ -2152,7 +2162,7 @@ mod tests { s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); let node = s.doc().unwrap().doc.node(id).unwrap(); let green = vectorcraft_color::Color::rgb(18.0 / 255.0, 171.0 / 255.0, 52.0 / 255.0); - assert_eq!(node.graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color), Some(green)); + assert_eq!(node.extras().graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color), Some(green)); assert!(group(node, "2024").children().unwrap().iter().all(|n| n.appearance.fill_paint().color() == Some(green))); } @@ -2165,14 +2175,14 @@ mod tests { let id = graph(&mut s, "column"); let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; s.edit("Junk", |d, _| { - d.node_mut(series_id).unwrap().series_index = Some(u32::MAX); + d.node_mut(series_id).unwrap().extras_mut().series_index = Some(u32::MAX); Ok(()) }) .unwrap(); let bar = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0].id; s.execute("paint.setFill", &json!({"ids": [bar.0, series_id.0], "color": "#12ab34"})).unwrap(); let node = s.doc().unwrap().doc.node(id).unwrap(); - assert!(node.graph.as_ref().unwrap().series_paints.is_empty()); + assert!(node.extras().graph.as_ref().unwrap().series_paints.is_empty()); assert_eq!(group(node, "2024").children().unwrap()[0].appearance.fill_paint().color().map(|c| c.to_hex()), Some("#12ab34".into())); } @@ -2185,10 +2195,10 @@ mod tests { s.execute("paint.setStroke", &json!({"ids": [series_id.0], "color": "#3751c8"})).unwrap(); let blue = vectorcraft_color::Color::rgb(55.0 / 255.0, 81.0 / 255.0, 200.0 / 255.0); assert_eq!( - s.doc().unwrap().doc.node(id).unwrap().graph.as_ref().unwrap().series_paints[0].stroke.as_ref().and_then(Paint::color), + s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints[0].stroke.as_ref().and_then(Paint::color), Some(blue) ); - assert!(s.doc().unwrap().doc.node(id).unwrap().graph.as_ref().unwrap().series_paints[0].fill.is_none()); + assert!(s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints[0].fill.is_none()); s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); assert!( group(s.doc().unwrap().doc.node(id).unwrap(), "2024") @@ -2212,7 +2222,7 @@ mod tests { let path = dir.join("palette.vectorcraft"); s.execute("document.save", &json!({"path": path})).unwrap(); s.execute("document.open", &json!({"path": path})).unwrap(); - let paints = &s.doc().unwrap().doc.node(id).unwrap().graph.as_ref().unwrap().series_paints; + let paints = &s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints; assert_eq!(paints.iter().map(|p| p.fill.as_ref().and_then(Paint::color).unwrap().to_hex()).collect::>(), vec!["#ff0000", "#008080"]); s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); s.execute("graph.setData", &json!({"id": id.0, "rows": [[3, 4, 5]]})).unwrap(); @@ -2241,7 +2251,7 @@ mod tests { let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; s.execute("paint.setStroke", &json!({"ids": [series_id.0], "color": "#0000ff"})).unwrap(); s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); - let spec = s.doc().unwrap().doc.node(id).unwrap().graph.as_deref().unwrap().clone(); + let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone(); let blue = Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0)); assert_eq!(spec.series_paints[0].stroke, Some(blue.clone())); assert!(spec.series_paints[0].fill.is_none()); @@ -2253,7 +2263,7 @@ mod tests { let marker = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[1].id; s.execute("paint.setFill", &json!({"ids": [marker.0], "color": "#ff0000"})).unwrap(); s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); - let spec = s.doc().unwrap().doc.node(id).unwrap().graph.as_deref().unwrap().clone(); + let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone(); assert!(spec.series_paints[0].stroke.is_none()); let series = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap(); assert_eq!(series[0].appearance.stroke_paint().color(), Some(super::default_series_color(0))); @@ -2272,7 +2282,7 @@ mod tests { ) .unwrap(); s.execute("graph.setData", &json!({"rows": [[2, 3], [4, 5]]})).unwrap(); - let spec = s.doc().unwrap().doc.node(id).unwrap().graph.as_deref().unwrap().clone(); + let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone(); assert!(matches!(spec.series_paints[0].fill, Some(Paint::Gradient(_)))); let bar = &group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0]; assert!(matches!(bar.appearance.fill_paint(), Paint::Gradient(_))); @@ -2288,15 +2298,15 @@ mod tests { let n = s.doc().unwrap().doc.node(id).unwrap(); let legend = series(n, 0); assert_eq!(legend.name.as_deref(), Some("Legend")); - assert!(n.children().unwrap().iter().any(|c| c.name.as_deref() == Some("Legend") && c.series_index.is_none())); - assert!(n.children().unwrap().iter().any(|c| c.name.as_deref() == Some("Axes") && c.series_index.is_none())); + assert!(n.children().unwrap().iter().any(|c| c.name.as_deref() == Some("Legend") && c.extras().series_index.is_none())); + assert!(n.children().unwrap().iter().any(|c| c.name.as_deref() == Some("Axes") && c.extras().series_index.is_none())); legend.id }; s.execute("paint.setFill", &json!({"ids": [legend_id.0], "color": "#ff0000"})).unwrap(); s.execute("graph.setData", &json!({"csv": csv})).unwrap(); let n = s.doc().unwrap().doc.node(id).unwrap(); - assert_eq!(n.graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color).unwrap().to_hex(), "#ff0000"); - assert!(n.graph.as_ref().unwrap().series_paints.get(1).is_none_or(|p| p.fill.is_none())); + assert_eq!(n.extras().graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color).unwrap().to_hex(), "#ff0000"); + assert!(n.extras().graph.as_ref().unwrap().series_paints.get(1).is_none_or(|p| p.fill.is_none())); assert_eq!(series(n, 0).children().unwrap()[0].appearance.fill_paint().color().unwrap().to_hex(), "#ff0000"); assert_eq!(series(n, 1).name.as_deref(), Some("Axes")); assert_ne!(series(n, 1).children().unwrap()[0].appearance.fill_paint().color().unwrap().to_hex(), "#ff0000"); @@ -2311,8 +2321,8 @@ mod tests { s.execute("paint.setFill", &json!({"ids": [keep.0], "color": "#00ff00"})).unwrap(); s.execute("graph.setData", &json!({"id": id.0, "rows": [[8, 0], [1, 0]]})).unwrap(); let n = s.doc().unwrap().doc.node(id).unwrap(); - assert_eq!(group(n, "Keep").series_index, Some(0)); - assert_eq!(n.graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color).unwrap().to_hex(), "#00ff00"); + assert_eq!(group(n, "Keep").extras().series_index, Some(0)); + assert_eq!(n.extras().graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color).unwrap().to_hex(), "#00ff00"); assert!(n.children().unwrap().iter().all(|c| c.name.as_deref() != Some("Drop"))); } @@ -2395,7 +2405,7 @@ mod tests { let r = s.execute("graph.create", &json!({"type": "column", "x": 0, "y": 0, "width": 300, "height": 200, "csv": ",a\nQ1,1\nQ2\nQ3,3"})).unwrap(); let id = NodeId(r["id"].as_u64().unwrap()); - let spec = s.doc().unwrap().doc.node(id).unwrap().graph.clone().unwrap(); + let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.clone().unwrap(); assert_eq!(spec.categories, vec!["Q1", "Q2", "Q3"]); assert_eq!(spec.cells(), vec![vec![Some(1.0)], vec![None], vec![Some(3.0)]]); } @@ -2423,7 +2433,7 @@ mod tests { // A data window round trip keeps the blanks. let csv = s.execute("graph.setData", &json!({})).unwrap()["csv"].as_str().unwrap().to_string(); assert_eq!(csv.lines().nth(2), Some(",2"), "{csv}"); - let spec = s.doc().unwrap().doc.node(id).unwrap().graph.clone().unwrap(); + let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.clone().unwrap(); assert_eq!(spec.cells()[1], vec![None, Some(2.0)]); // In the file a blank is a 0 placeholder plus its place in `blanks`, so a version without blanks reads // numbers (as zeros); a graph without blanks writes no `blanks` at all. @@ -2434,7 +2444,7 @@ mod tests { } fn spec(s: &Session, id: NodeId) -> GraphSpec { - s.doc().unwrap().doc.node(id).unwrap().graph.as_deref().unwrap().clone() + s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone() } /// Bounds of a series' marks, without its legend swatch (the last child). @@ -2462,12 +2472,12 @@ mod tests { assert!((line[1].center().x - cols[0].center().x).abs() < 1e-6); // The line series is drawn after (in front of) the column series. let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let order: Vec = n.children().unwrap().iter().filter_map(|c| c.series_index).collect(); + let order: Vec = n.children().unwrap().iter().filter_map(|c| c.extras().series_index).collect(); assert_eq!(order, [0, 1]); s.execute("graph.setType", &json!({"seriesIndexes": [0], "type": "line"})).unwrap(); s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "column"})).unwrap(); let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let order: Vec = n.children().unwrap().iter().filter_map(|c| c.series_index).collect(); + let order: Vec = n.children().unwrap().iter().filter_map(|c| c.extras().series_index).collect(); assert_eq!(order, [1, 0]); // Back to the graph's own type: nothing is stored for that series. assert_eq!(spec(&s, id).series_kinds, [Some(GraphKind::Line)]); @@ -2562,7 +2572,7 @@ mod tests { assert!((area.x0 - cols[0].center().x).abs() < 1e-6 && (area.x1 - cols[2].center().x).abs() < 1e-6, "{area:?}"); // The area is drawn behind the columns. let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let order: Vec = n.children().unwrap().iter().filter_map(|c| c.series_index).collect(); + let order: Vec = n.children().unwrap().iter().filter_map(|c| c.extras().series_index).collect(); assert_eq!(order, [0, 1]); // An area graph alone still spans the plot. s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "area"})).unwrap(); @@ -3173,7 +3183,7 @@ mod tests { let dot = vectorcraft_doc::Node::group(d.alloc_id(), vec![]); d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Dot".into(), art: std::sync::Arc::new(dot) }); let art = super::generate(&mut d, &g); - let s0 = art.iter().find(|n| n.series_index == Some(0)).unwrap(); + let s0 = art.iter().find(|n| n.extras().series_index == Some(0)).unwrap(); assert!(s0.children().unwrap().iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); } @@ -3188,13 +3198,13 @@ mod tests { s.execute("graph.design", &json!({"new": "Chart"})).unwrap(); let art = s.doc().unwrap().doc.graph_designs[1].art.clone(); let mut graphs = 0; - art.walk(&mut |n| graphs += usize::from(n.graph.is_some() || n.series_index.is_some())); + art.walk(&mut |n| graphs += usize::from(n.extras().graph.is_some() || n.extras().series_index.is_some())); assert_eq!(graphs, 0, "no graph inside a design"); // The graph drawn with itself as a marker, then the design it used deleted: still one graph to edit. s.execute("graph.marker", &json!({"id": id.0, "seriesIndexes": [1], "design": "Chart"})).unwrap(); s.execute("graph.design", &json!({"delete": "Pill"})).unwrap(); let mut found = 0; - s.doc().unwrap().doc.walk(|n| found += usize::from(n.graph.is_some())); + s.doc().unwrap().doc.walk(|n| found += usize::from(n.extras().graph.is_some())); assert_eq!(found, 1); // A deleted design reads as none in the query, so the dialog opens on the default marker. s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); @@ -3226,7 +3236,7 @@ mod tests { g.series_markers = vec![Some("Big".into())]; let mut d = (*s.doc().unwrap().doc).clone(); let art = super::generate(&mut d, &g); - let s0 = art.iter().find(|n| n.series_index == Some(0)).unwrap(); + let s0 = art.iter().find(|n| n.extras().series_index == Some(0)).unwrap(); assert!(s0.children().unwrap().iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); // Too many objects for one design. let ids: Vec = (0..2001) @@ -3241,7 +3251,7 @@ mod tests { let mut d = vectorcraft_doc::Document::new(100.0, 100.0); let art = |d: &mut vectorcraft_doc::Document| std::sync::Arc::new(vectorcraft_doc::Node::group(d.alloc_id(), vec![])); let mut graphy = vectorcraft_doc::Node::group(d.alloc_id(), vec![]); - graphy.graph = Some(Box::new(GraphSpec::default())); + graphy.extras_mut().graph = Some(Box::new(GraphSpec::default())); for name in [" A\u{7} ", "A", "", " ", &"x".repeat(100)] { let a = art(&mut d); d.graph_designs.push(vectorcraft_doc::GraphDesign { name: name.into(), art: a }); @@ -3250,7 +3260,7 @@ mod tests { d.tidy_graph_designs(); let names: Vec<_> = d.graph_designs.iter().map(|x| x.name.clone()).collect(); assert_eq!(names, ["A".to_string(), "x".repeat(64), "G".to_string()]); - assert!(d.graph_designs[2].art.graph.is_none()); + assert!(d.graph_designs[2].art.extras().graph.is_none()); } #[test] @@ -3511,7 +3521,7 @@ mod tests { assert!(min > 1.0, "the ring keeps clear of the centre: {near:?}"); // The names are drawn over the pies: after every series group. let kids = n.children().unwrap(); - let last_series = kids.iter().rposition(|c| c.series_index.is_some()).unwrap(); + let last_series = kids.iter().rposition(|c| c.extras().series_index.is_some()).unwrap(); let names: Vec = kids.iter().enumerate().filter(|(_, c)| matches!(c.kind, NodeKind::Text(_))).map(|(i, _)| i).collect(); assert_eq!(names.len(), 2, "of two equal pies, one has no ring and no name"); assert!(names.iter().all(|i| *i > last_series), "{names:?} after {last_series}"); @@ -3576,7 +3586,7 @@ mod tests { let n = s.doc().unwrap().doc.node(id).unwrap().clone(); let kids = n.children().unwrap(); let at = kids.iter().position(|c| c.name.as_deref() == Some("Drop Shadow")).unwrap(); - assert!(kids.iter().position(|c| c.series_index.is_some()).unwrap() > at, "behind the series"); + assert!(kids.iter().position(|c| c.extras().series_index.is_some()).unwrap() > at, "behind the series"); let shadow = &kids[at]; assert!((shadow.opacity - 0.35).abs() < 1e-6); let copies: Vec<_> = shadow.children().unwrap().iter().filter_map(|c| c.geometric_bounds()).collect(); @@ -3639,8 +3649,12 @@ mod tests { s.execute("graph.setType", &json!({"seriesIndexes": [0], "type": "area"})).unwrap(); s.execute("graph.setType", &json!({"seriesIndexes": [2], "type": "line", "dropShadow": true})).unwrap(); let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let order: Vec = - n.children().unwrap().iter().map(|c| c.series_index.map_or_else(|| c.name.clone().unwrap_or_default(), |i| format!("s{i}"))).collect(); + let order: Vec = n + .children() + .unwrap() + .iter() + .map(|c| c.extras().series_index.map_or_else(|| c.name.clone().unwrap_or_default(), |i| format!("s{i}"))) + .collect(); // Areas cast none; the columns' shadows sit between the area and the columns, the line's between the columns // and the line. let at = |k: &str| order.iter().position(|o| o == k).unwrap(); @@ -3702,8 +3716,9 @@ mod tests { assert!(a[0].x1 > b[0].x0, "the columns of a cluster overlap: {:?} {:?}", a[0], b[0]); assert!(b[0].x1 > a[1].x0, "and so do the clusters: {:?} {:?}", b[0], a[1]); assert_eq!(s.execute("graph.setType", &json!({"clusterWidth": 5000})).map(|_| spec(&s, id).cluster_width).unwrap(), 1000.0); - let order = - |s: &Session| -> Vec { s.doc().unwrap().doc.node(id).unwrap().children().unwrap().iter().filter_map(|c| c.series_index).collect() }; + let order = |s: &Session| -> Vec { + s.doc().unwrap().doc.node(id).unwrap().children().unwrap().iter().filter_map(|c| c.extras().series_index).collect() + }; assert_eq!(order(&s), [0, 1]); s.execute("graph.setType", &json!({"firstColumnInFront": true})).unwrap(); assert_eq!(order(&s), [1, 0], "the first series drawn last, on top"); diff --git a/crates/engine/src/cmd/live.rs b/crates/engine/src/cmd/live.rs index 70797f5e..2aadffa7 100644 --- a/crates/engine/src/cmd/live.rs +++ b/crates/engine/src/cmd/live.rs @@ -535,7 +535,7 @@ fn blend_make(s: &mut Session, p: &Value) -> Result { extend_spine(&mut spec, old, old_spec, &children); } n.id = id; - n.kind = NodeKind::Blend { children, spec }; + n.kind = NodeKind::Blend { children, spec: Box::new(spec) }; n }) })?; @@ -563,7 +563,7 @@ fn blend_release(s: &mut Session, _: &Value) -> Result { || n.opacity < 1.0 || n.blend != vectorcraft_color::BlendMode::Normal || n.isolate - || n.mask.is_some() + || n.extras().mask.is_some() || !n.appearance.items.is_empty() || !n.appearance.effects.is_empty() { @@ -714,7 +714,7 @@ fn edit_blends(s: &mut Session, label: &str, f: impl Fn(&mut Vec>, &mu fn selected_spec(s: &Session) -> Option<(NodeId, BlendSpec, Vec)> { let b = *selected_of(s, is_blend).first()?; match &s.doc().ok()?.doc.node(b)?.kind { - NodeKind::Blend { spec, children } => Some((b, spec.clone(), children.iter().map(|c| c.id).collect())), + NodeKind::Blend { spec, children } => Some((b, (**spec).clone(), children.iter().map(|c| c.id).collect())), _ => None, } } @@ -952,7 +952,7 @@ fn envelope_defaults(s: &Session) -> (EnvelopeOptions, f64) { } fn envelope(id: NodeId, content: Vec>, kind: EnvelopeKind, (options, fidelity): (EnvelopeOptions, f64)) -> Node { - Node::new(id, NodeKind::Envelope { content, kind, fidelity, editing: false, options, frame: Affine::IDENTITY }) + Node::new(id, NodeKind::Envelope { content, kind: Box::new(kind), fidelity, editing: false, options, frame: Box::new(Affine::IDENTITY) }) } /// `rows` and `cols` (or `columns`), 1..50 each, defaulting to `default`. @@ -1048,7 +1048,7 @@ fn set_kinds(s: &mut Session, label: &str, kinds: Vec<(NodeId, EnvelopeKind)>) - s.edit(label, |d, _| { for (e, k) in kinds { if let Some(NodeKind::Envelope { kind, .. }) = d.node_mut(e).map(|n| &mut n.kind) { - *kind = k; + **kind = k; } } Ok(()) @@ -1110,7 +1110,7 @@ fn env_info(s: &mut Session, _: &Value) -> Result { let (options, fidelity) = envelope_defaults(s); return Ok(envelope_options_json(json!({ "id": null }), &options, fidelity)); }; - let v = match kind { + let v = match &**kind { EnvelopeKind::Warp { style, bend, h, v, horizontal } => { json!({"type": "warp", "style": style, "bend": bend, "h": h, "v": v, "horizontal": horizontal}) } @@ -1154,7 +1154,7 @@ fn carry_transparency(d: &mut Document, env: &Node, mut nodes: Vec) -> Vec n.isolate = env.isolate; n.knockout = env.knockout; n.knockout_shape = env.knockout_shape; - n.mask = env.mask.clone(); + n.extras_mut().mask = env.extras().mask.clone(); }; if let [one] = nodes.as_mut_slice() && one.has_default_transparency() @@ -1205,8 +1205,8 @@ fn env_options(s: &mut Session, p: &Value) -> Result { let mut updates = vec![]; for e in &envs { if let Some(NodeKind::Envelope { kind, options, fidelity, .. }) = doc.node(*e).map(|n| &n.kind) { - let kind = match kind { - EnvelopeKind::Warp { .. } => warp_kind(p, C, Some(kind))?, + let kind = match &**kind { + k @ EnvelopeKind::Warp { .. } => warp_kind(p, C, Some(k))?, k => k.clone(), }; updates.push((*e, kind, options_param(p, C, *options)?, fid.unwrap_or(*fidelity))); @@ -1215,7 +1215,7 @@ fn env_options(s: &mut Session, p: &Value) -> Result { s.edit("Envelope Options", |d, _| { for (e, k, o, f) in updates { if let Some(NodeKind::Envelope { kind, options, fidelity, .. }) = d.node_mut(e).map(|n| &mut n.kind) { - (*kind, *options, *fidelity) = (k, o, f); + (**kind, *options, *fidelity) = (k, o, f); } } Ok(()) @@ -1300,7 +1300,10 @@ fn env_set_mesh_point(s: &mut Session, p: &Value) -> Result { let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?); let handle = handle_param(p, C)?; s.edit("Move Envelope Point", |d, _| { - let Some(NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows, cols, points, handles }, .. }) = d.node_mut(id).map(|n| &mut n.kind) else { + let Some(NodeKind::Envelope { kind, .. }) = d.node_mut(id).map(|n| &mut n.kind) else { + return Err(bad(C, "not a mesh envelope point")); + }; + let EnvelopeKind::Mesh { rows, cols, points, handles } = &mut **kind else { return Err(bad(C, "not a mesh envelope point")); }; let at = *points.get(index).ok_or_else(|| bad(C, "not a mesh envelope point"))?; @@ -1384,7 +1387,10 @@ fn with_mesh(s: &mut Session, p: &Value, cmd: &str, label: &str, envelopes: b let c = cmd.to_string(); s.edit(label, move |d, _| match d.node_mut(id).map(|n| &mut n.kind) { Some(NodeKind::Mesh(m)) => f(m), - Some(NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows, cols, points, handles }, .. }) if envelopes => { + Some(NodeKind::Envelope { kind, .. }) if envelopes && matches!(**kind, EnvelopeKind::Mesh { .. }) => { + let EnvelopeKind::Mesh { rows, cols, points, handles } = &mut **kind else { + return Err(bad(&c, "not a gradient mesh or mesh envelope")); + }; let mut m = live::envelope_grid(*rows, *cols, points, handles).ok_or_else(|| bad(&c, "the envelope's mesh is malformed"))?; let r = f(&mut m)?; (*rows, *cols) = (m.rows, m.cols); diff --git a/crates/engine/src/cmd/maskedit.rs b/crates/engine/src/cmd/maskedit.rs index a94f3838..54d21415 100644 --- a/crates/engine/src/cmd/maskedit.rs +++ b/crates/engine/src/cmd/maskedit.rs @@ -90,7 +90,7 @@ fn enter(s: &mut Session, p: &Value) -> Result { /// The first selected object with an opacity mask. fn first_masked(st: &crate::DocState) -> Option { - st.selection.subjects().iter().copied().find(|i| st.doc.node(*i).is_some_and(|n| n.mask.is_some())) + st.selection.subjects().iter().copied().find(|i| st.doc.node(*i).is_some_and(|n| n.extras().mask.is_some())) } /// Enter mask editing for `id` (one undo step) and isolate the editing layer. Returns the layer. @@ -127,8 +127,11 @@ fn view_mask(s: &mut Session, p: &Value) -> Result { /// Enter mask editing for `id` inside an edit: copy its mask art onto a new editing layer and /// select it. Returns the layer. pub(crate) fn begin(d: &mut Document, sel: &mut Selection, id: NodeId) -> Result { - let art = - d.node(id).and_then(|n| n.mask.as_ref()).map(|m| m.art.clone()).ok_or_else(|| EngineError::Other("the object has no opacity mask".into()))?; + let art = d + .node(id) + .and_then(|n| n.extras().mask.as_ref()) + .map(|m| m.art.clone()) + .ok_or_else(|| EngineError::Other("the object has no opacity mask".into()))?; let layer = d.add_layer(Some(MASK_EDIT_LAYER)); let parts = mask_parts(&art); let mut ids = Vec::with_capacity(parts.len()); @@ -202,7 +205,7 @@ pub(crate) fn follow_history(st: &mut crate::DocState, was: Option) { pub(crate) fn sync(d: &mut Document) { let Some(me) = d.mask_edit else { return }; let art: Option>> = d.node(me.layer).and_then(|l| l.children().cloned()); - let (Some(art), true) = (art, d.node(me.object).is_some_and(|n| n.mask.is_some())) else { + let (Some(art), true) = (art, d.node(me.object).is_some_and(|n| n.extras().mask.is_some())) else { d.mask_edit = None; let _ = d.remove(me.layer); return; @@ -212,12 +215,12 @@ pub(crate) fn sync(d: &mut Document) { // Several objects (or none): a group, so the mask keeps one art node. many => Arc::new(mask_group(many.to_vec())), }; - if let Some(m) = d.node(me.object).and_then(|n| n.mask.as_ref()) + if let Some(m) = d.node(me.object).and_then(|n| n.extras().mask.as_ref()) && Arc::ptr_eq(&m.art, &new_art) { return; } - if let Some(m) = d.node_mut(me.object).and_then(|n| n.mask.as_mut()) { + if let Some(m) = d.node_mut(me.object).and_then(|n| n.extras_if_any().and_then(|e| e.mask.as_mut())) { m.art = new_art; } } @@ -241,7 +244,8 @@ mod tests { assert_eq!(s.doc().unwrap().isolation, Some(NodeId(layer))); // Edit the mask art with ordinary commands: widen it; the object's mask follows live. s.execute("object.move", &json!({"dx": 50, "dy": 0})).unwrap(); - let mask_bounds = |s: &Session| s.doc().unwrap().doc.node(NodeId(obj)).unwrap().mask.as_ref().unwrap().art.geometric_bounds().unwrap(); + let mask_bounds = + |s: &Session| s.doc().unwrap().doc.node(NodeId(obj)).unwrap().extras().mask.as_ref().unwrap().art.geometric_bounds().unwrap(); assert!((mask_bounds(&s).x0 - 50.0).abs() < 1e-6); // Drawing while editing adds to the mask. s.execute("shape.ellipse", &json!({"x": 180, "y": 150, "width": 40, "height": 40})).unwrap(); diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 7b6ad172..4a9016bd 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -449,7 +449,8 @@ fn rasterize(s: &mut Session, p: &Value) -> Result { d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png)); let id = d.alloc_id(); let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale); - let mut node = Node::new(id, NodeKind::Image(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() })); + let mut node = + Node::new(id, NodeKind::Image(Box::new(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() }))); if let Some((path, rule)) = clip { node = super::rasterfx::clip_group(d, path, rule, node); } @@ -562,7 +563,7 @@ fn crop_image(s: &mut Session, p: &Value) -> Result { let node = st.doc.node(id).cloned().ok_or(EngineError::NoNode(id))?; let NodeKind::Image(im) = &node.kind else { return Err(bad(C, "select an image")) }; if p.get("trim").and_then(Value::as_bool) == Some(true) { - return trim_image(s, id, im.clone()); + return trim_image(s, id, (**im).clone()); } let ib = node.geometric_bounds().ok_or_else(|| bad(C, "image has no bounds"))?; let rect = match p.get("rect").and_then(Value::as_array) { @@ -603,14 +604,14 @@ fn crop_image(s: &mut Session, p: &Value) -> Result { let key = unique_key(d, "crop"); d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png)); if let Some(n) = d.node_mut(id) { - n.kind = NodeKind::Image(ImageObject { + n.kind = NodeKind::Image(Box::new(ImageObject { key, width: w, height: h, xf: Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale), link: None, placement: Default::default(), - }); + })); } Ok(()) })?; @@ -646,14 +647,14 @@ fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result { let key = unique_key(d, "crop"); d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png)); if let Some(n) = d.node_mut(id) { - n.kind = NodeKind::Image(ImageObject { + n.kind = NodeKind::Image(Box::new(ImageObject { key, width: cw, height: ch, xf: im.xf * Affine::translate((f64::from(x0), f64::from(y0))), link: None, placement: Default::default(), - }); + })); } Ok(()) })?; @@ -799,7 +800,7 @@ fn convert_to_shape(s: &mut Session, _: &Value) -> Result { } if let Some(l) = detect_shape(path) { *path = l.to_path(); - *live = Some(l); + *live = Some(Box::new(l)); n += 1; } } diff --git a/crates/engine/src/cmd/newart.rs b/crates/engine/src/cmd/newart.rs index 429ac840..40424da5 100644 --- a/crates/engine/src/cmd/newart.rs +++ b/crates/engine/src/cmd/newart.rs @@ -105,7 +105,7 @@ impl NewArt { return; } (n.opacity, n.blend) = (self.opacity, self.blend); - n.graphic_style = self.style.as_deref().and_then(|name| d.graphic_style_index(name)).map(|i| d.graphic_style_id(i)); + n.extras_mut().graphic_style = self.style.as_deref().and_then(|name| d.graphic_style_index(name)).map(|i| d.graphic_style_id(i)); } } diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 8895e2a7..8138e352 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -307,7 +307,7 @@ pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec, xf /// reflows at its size. False for anything else (and type in perspective), which transforms as /// usual. fn resize_type_area(n: &mut Node, xf: Affine) -> bool { - if n.perspective.is_some() { + if n.extras().perspective.is_some() { return false; } let NodeKind::Text(t) = &mut n.kind else { return false }; @@ -860,7 +860,7 @@ fn isolate(s: &mut Session, p: &Value) -> Result { if st.doc.node(id).is_none_or(|n| !n.is_container()) { return Err(bad("object.isolate", "only groups and layers can be isolated")); } - let isolation = st.doc.node(id).filter(|n| n.shaper.is_some()).and_then(|n| n.children()).and_then(|c| c.first()).map_or(id, |n| n.id); + let isolation = st.doc.node(id).filter(|n| n.extras().shaper.is_some()).and_then(|n| n.children()).and_then(|c| c.first()).map_or(id, |n| n.id); st.isolation = Some(isolation); st.selection.clear(); st.revision += 1; @@ -871,7 +871,7 @@ fn exit_isolation(s: &mut Session, _: &Value) -> Result { let st = s.doc_mut()?; if let Some(i) = st.isolation.take() { super::distortcmds::finish_edit_text(st, i); - let target = st.doc.parent_of(i).filter(|p| st.doc.node(*p).is_some_and(|n| n.shaper.is_some())).unwrap_or(i); + let target = st.doc.parent_of(i).filter(|p| st.doc.node(*p).is_some_and(|n| n.extras().shaper.is_some())).unwrap_or(i); st.selection.set([target]); } st.revision += 1; @@ -936,10 +936,9 @@ fn set_props(s: &mut Session, p: &Value) -> Result { && flag("isolate").is_none_or(|v| n.isolate == v) && knockout.is_none_or(|k| n.knockout == k) && flag("knockoutShape").is_none_or(|v| n.knockout_shape == v) - && data - .iter() - .flatten() - .all(|(k, v)| n.attrs.as_ref().and_then(|a| a.data.iter().find(|(key, _)| key == k)).map(|(_, now)| now.as_str()) == v.as_deref()) + && data.iter().flatten().all(|(k, v)| { + n.extras().attrs.as_ref().and_then(|a| a.data.iter().find(|(key, _)| key == k)).map(|(_, now)| now.as_str()) == v.as_deref() + }) }; let d = &s.doc()?.doc; if ids.iter().all(|id| d.node(*id).is_some_and(has_values)) { @@ -1410,7 +1409,7 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let has_values = |id: &NodeId| { let Some(NodeKind::Path { path, live, .. }) = st.doc.node(*id).map(|n| &n.kind) else { return true }; - match live { + match live.as_deref() { Some(l @ LiveShape::Polygon { sides: now, .. }) if !(sides.is_none_or(|n| n.clamp(3, 1000) == u64::from(*now)) && (!equal || l.polygon_sides_equal()) @@ -1425,11 +1424,11 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result { if radius.is_none() && kind.is_none() { return true; } - let Some(c) = LiveCorners::new(path, live.as_ref()) else { return true }; + let Some(c) = LiveCorners::new(path, live.as_deref()) else { return true }; let picked = corners_of(*id).unwrap_or_else(|| c.picked(st.selection.anchors.get(id))); // Setting corners folds a scale left in a rectangle's transform into its size, which // changes it. - live.as_ref().is_none_or(|l| l.folded() == *l) + live.as_deref().is_none_or(|l| l.folded() == *l) && picked .iter() .all(|k| *k < c.base.anchor_count() && radius.is_none_or(|r| c.radius(*k) == r.max(0.0)) && kind.is_none_or(|kd| c.kind(*k) == kd)) @@ -1440,7 +1439,7 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result { s.edit("Live Shape", |d, sel| { for id in &ids { let Some(NodeKind::Path { path, live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; - if let Some(l @ LiveShape::Polygon { .. }) = live.as_mut() + if let Some(l @ LiveShape::Polygon { .. }) = live.as_deref_mut() && (sides.is_some() || polygon_angle.is_some() || polygon_radius.is_some() || side.is_some() || equal) { if let Some(n) = sides { @@ -1458,7 +1457,7 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result { } *path = l.to_path(); } - if let Some(l @ LiveShape::Ellipse { .. }) = live.as_mut() + if let Some(l @ LiveShape::Ellipse { .. }) = live.as_deref_mut() && (pie_start.is_some() || pie_end.is_some() || invert) { if let LiveShape::Ellipse { pie, .. } = l { @@ -1471,7 +1470,7 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result { } let partial = sel.anchors.get_mut(id); // The corners to edit, the Direct-Selected ones and the anchor layout before the edit. - let Some(c) = LiveCorners::new(path, live.as_ref()) else { continue }; + let Some(c) = LiveCorners::new(path, live.as_deref()) else { continue }; let corners = corners_of(*id); let picked = match &corners { Some(k) if k.last().is_some_and(|k| *k >= c.base.anchor_count()) => { @@ -1486,7 +1485,7 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result { set_corners(path, live, &picked, radius, kind); // Direct-Selected corners stay selected as they gain or lose anchors. if let (Some(anchors), Some(selected)) = (partial, selected) - && let Some(c) = LiveCorners::new(path, live.as_ref()) + && let Some(c) = LiveCorners::new(path, live.as_deref()) && c.sources() != layout { *anchors = c.anchors_of(&selected); diff --git a/crates/engine/src/cmd/opacitymask.rs b/crates/engine/src/cmd/opacitymask.rs index 135e50ff..254b97fe 100644 --- a/crates/engine/src/cmd/opacitymask.rs +++ b/crates/engine/src/cmd/opacitymask.rs @@ -154,7 +154,7 @@ pub(crate) fn percent(o: f64) -> f32 { /// The [`transparency_targets`] that have an opacity mask. fn masked_targets(s: &Session, p: &Value) -> Result> { let d = &s.doc()?.doc; - let ids: Vec = transparency_targets(s, p)?.into_iter().filter(|id| d.node(*id).is_some_and(|n| n.mask.is_some())).collect(); + let ids: Vec = transparency_targets(s, p)?.into_iter().filter(|id| d.node(*id).is_some_and(|n| n.extras().mask.is_some())).collect(); if ids.is_empty() { return Err(EngineError::Other("no target object has an opacity mask: select one or give `id`".into())); } @@ -200,12 +200,12 @@ fn make(s: &mut Session, p: &Value) -> Result { gid }; let n = d.node_mut(target).ok_or(EngineError::NoNode(target))?; - if n.mask.is_some() { + if n.extras().mask.is_some() { return Err(EngineError::Other("the object already has an opacity mask".into())); } let mut m = OpacityMask::new(mask_art, clip); m.invert = invert; - n.mask = Some(Box::new(m)); + n.extras_mut().mask = Some(Box::new(m)); sel.set_target(d, target); let layer = if mask_id.is_none() { Some(begin(d, sel, target)?) } else { None }; Ok((target, layer)) @@ -224,7 +224,7 @@ fn release(s: &mut Session, p: &Value) -> Result { finish(d, sel); let mut out = vec![]; for id in &ids { - let Some(m) = d.node_mut(*id).and_then(|n| n.mask.take()) else { continue }; + let Some(m) = d.node_mut(*id).and_then(|n| n.extras_mut().mask.take()) else { continue }; let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?; out.push(*id); for (k, part) in mask_parts(&m.art).iter().enumerate() { @@ -259,12 +259,12 @@ fn set_flags(s: &mut Session, target: &Value, label: &str, p: &Value) -> Result< && linked.is_none_or(|v| m.linked == v) }; let d = &s.doc()?.doc; - if ids.iter().all(|id| d.node(*id).and_then(|n| n.mask.as_deref()).is_none_or(has_flags)) { + if ids.iter().all(|id| d.node(*id).and_then(|n| n.extras().mask.as_deref()).is_none_or(has_flags)) { return ok(); } s.edit(label, |d, _| { for id in &ids { - if let Some(m) = d.node_mut(*id).and_then(|n| n.mask.as_mut()) { + if let Some(m) = d.node_mut(*id).and_then(|n| n.extras_if_any().and_then(|e| e.mask.as_mut())) { m.clip = clip.unwrap_or(m.clip); m.invert = invert.unwrap_or(m.invert); m.disabled = disabled.unwrap_or(m.disabled); @@ -391,7 +391,7 @@ fn mask_info(s: &mut Session, p: &Value) -> Result { let v: Vec = nodes_of(d, &transparency_targets(s, p)?) .into_iter() .filter_map(|n| { - let m = n.mask.as_deref()?; + let m = n.extras().mask.as_deref()?; Some(json!({ "id": n.id.0, "clip": m.clip, "invert": m.invert, "disabled": m.disabled, "linked": m.linked, "art": m.art.id.0 })) }) .collect(); @@ -424,7 +424,7 @@ mod tests { assert_eq!(r["editing"], false); let d = &s.doc().unwrap().doc; assert!(d.node(NodeId(b)).is_none(), "mask art left the layer tree"); - let m = d.node(NodeId(a)).unwrap().mask.as_deref().unwrap(); + let m = d.node(NodeId(a)).unwrap().extras().mask.as_deref().unwrap(); assert!(m.clip && m.linked && !m.invert && !m.disabled); assert_eq!(m.art.id, NodeId(b)); } @@ -438,7 +438,7 @@ mod tests { let d = &s.doc().unwrap().doc; let n = d.node(NodeId(g)).unwrap(); assert_eq!(n.children().unwrap().len(), 2); - assert!(!n.mask.as_ref().unwrap().clip); + assert!(!n.extras().mask.as_ref().unwrap().clip); } #[test] @@ -448,11 +448,11 @@ mod tests { s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); s.execute("transparency.releaseOpacityMask", &json!({})).unwrap(); let st = s.doc().unwrap(); - assert!(st.doc.node(NodeId(a)).unwrap().mask.is_none()); + assert!(st.doc.node(NodeId(a)).unwrap().extras().mask.is_none()); assert_eq!(st.selection.len(), 2); assert_eq!(st.doc.layers[0].children().unwrap().len(), 2); s.execute("edit.undo", &json!({})).unwrap(); - assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().mask.is_some()); + assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().extras().mask.is_some()); } #[test] @@ -469,13 +469,13 @@ mod tests { assert_eq!(info[0]["disabled"], true); assert_eq!(info[0]["linked"], false); // Unlinked: moving the object leaves the mask art where it was. - let before = s.doc().unwrap().doc.node(NodeId(a)).unwrap().mask.as_ref().unwrap().art.clone(); + let before = s.doc().unwrap().doc.node(NodeId(a)).unwrap().extras().mask.as_ref().unwrap().art.clone(); s.execute("object.move", &json!({"dx": 30, "dy": 0})).unwrap(); - let after = s.doc().unwrap().doc.node(NodeId(a)).unwrap().mask.as_ref().unwrap().art.clone(); + let after = s.doc().unwrap().doc.node(NodeId(a)).unwrap().extras().mask.as_ref().unwrap().art.clone(); assert_eq!(before.geometric_bounds(), after.geometric_bounds()); s.execute("transparency.linkOpacityMask", &json!({})).unwrap(); s.execute("object.move", &json!({"dx": 30, "dy": 0})).unwrap(); - let moved = s.doc().unwrap().doc.node(NodeId(a)).unwrap().mask.as_ref().unwrap().art.clone(); + let moved = s.doc().unwrap().doc.node(NodeId(a)).unwrap().extras().mask.as_ref().unwrap().art.clone(); assert!((moved.geometric_bounds().unwrap().x0 - after.geometric_bounds().unwrap().x0 - 30.0).abs() < 1e-9); } @@ -485,7 +485,7 @@ mod tests { assert_eq!(s.execute("transparency.toggleNewMasksClipping", &json!({})).unwrap()["value"], false); select(&mut s, &[a, b]); s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); - assert!(!s.doc().unwrap().doc.node(NodeId(a)).unwrap().mask.as_ref().unwrap().clip); + assert!(!s.doc().unwrap().doc.node(NodeId(a)).unwrap().extras().mask.as_ref().unwrap().clip); s.execute("transparency.toggleNewMasksInverted", &json!({})).unwrap(); assert_eq!(s.new_mask_defaults(), (false, true)); } @@ -498,6 +498,6 @@ mod tests { let d = s.doc().unwrap().doc.clone(); let bytes = vectorcraft_format::save(&d, false); let back = vectorcraft_format::load(&bytes).unwrap(); - assert_eq!(back.node(NodeId(a)).unwrap().mask, d.node(NodeId(a)).unwrap().mask); + assert_eq!(back.node(NodeId(a)).unwrap().extras().mask, d.node(NodeId(a)).unwrap().extras().mask); } } diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index 5a084eb0..28772e80 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -558,7 +558,7 @@ pub(crate) fn styled_copy(d: &mut Document, l: &Node, items: Vec f.opacity = 1.0; f.blend = BlendMode::Normal; f.name = None; - f.mask = None; + f.extras_mut().mask = None; if let NodeKind::Path { live, .. } = &mut f.kind { *live = None; } @@ -575,7 +575,7 @@ pub(crate) fn assemble(d: &mut Document, l: &Node, mut parts: Vec) -> Opti }; let mut root = with_transparency(d, root, l.opacity, l.blend); root.name = l.name.clone(); - root.mask = l.mask.clone(); + root.extras_mut().mask = l.extras().mask.clone(); root.appearance.effects = l.appearance.effects.clone(); Some(root) } diff --git a/crates/engine/src/cmd/place/adopt.rs b/crates/engine/src/cmd/place/adopt.rs index 928a4baf..3493dbd0 100644 --- a/crates/engine/src/cmd/place/adopt.rs +++ b/crates/engine/src/cmd/place/adopt.rs @@ -16,7 +16,7 @@ use super::unique_name; /// Visit `n` and every descendant (children and opacity-mask art) mutably. pub(super) fn each_mut(n: &mut Node, f: &mut impl FnMut(&mut Node)) { f(n); - if let Some(m) = &mut n.mask { + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { each_mut(Arc::make_mut(&mut m.art), f); } if let Some(ch) = n.children_mut() { diff --git a/crates/engine/src/cmd/place/mod.rs b/crates/engine/src/cmd/place/mod.rs index a9177c10..941011f9 100644 --- a/crates/engine/src/cmd/place/mod.rs +++ b/crates/engine/src/cmd/place/mod.rs @@ -285,7 +285,7 @@ fn build(d: &mut Document, l: Loaded, link: Option) -> Node { link, placement: Default::default(), }; - Node::new(d.alloc_id(), NodeKind::Image(im)) + Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))) } Art::Text(t) => Node::new(d.alloc_id(), NodeKind::Text(Box::new(text::area_text(t, l.natural)))), Art::Document(s, link, bounding) => document::node(d, &s, link, bounding), diff --git a/crates/engine/src/cmd/printadvanced.rs b/crates/engine/src/cmd/printadvanced.rs index ef684b00..8a9340e9 100644 --- a/crates/engine/src/cmd/printadvanced.rs +++ b/crates/engine/src/cmd/printadvanced.rs @@ -96,7 +96,7 @@ fn bitmap_document(cmd: &str, doc: &Document, set: &PrintSettings) -> Result Option> { /// effects (they get images of their own). pub(crate) fn raster_image(d: &mut Document, n: &Node, below: bool, members: bool) -> Option { let mut whole = n.clone(); - (whole.opacity, whole.blend, whole.mask) = (1.0, Default::default(), None); + (whole.opacity, whole.blend) = (1.0, Default::default()); + whole.extras_mut().mask = None; if below && members && is_container(&whole) { for c in whole.children_mut().into_iter().flatten() { strip_raster(Arc::make_mut(c), true); @@ -317,7 +320,8 @@ pub(crate) fn put_below(d: &mut Document, m: &mut Node, image: Node) { _ => { let mut inner = m.clone(); inner.id = d.alloc_id(); - (inner.name, inner.opacity, inner.blend, inner.isolate, inner.mask) = (None, 1.0, Default::default(), false, None); + (inner.name, inner.opacity, inner.blend, inner.isolate) = (None, 1.0, Default::default(), false); + inner.extras_mut().mask = None; // Knockout is between the object's own parts (a blend's steps): it stays with them, // so the image doesn't knock out the art over it. m.knockout = Default::default(); @@ -379,7 +383,9 @@ fn placed_symbol(d: &Document, n: &Node) -> Option { let NodeKind::SymbolInstance { symbol, xf } = &n.kind else { return None }; let mut art = vectorcraft_render::instance_art(&d.symbols.iter().find(|s| s.name == *symbol)?.art, n); art.transform(*xf, false); - Some(Node { kind: NodeKind::Group { children: vec![Arc::new(art)], clip: false }, ..n.clone() }) + let mut g = n.clone(); + g.kind = NodeKind::Group { children: vec![Arc::new(art)], clip: false }; + Some(g) } /// Which objects' raster effects become images. @@ -407,13 +413,13 @@ fn needs(n: &Node, which: Which) -> bool { n.visible && !matches!(n.kind, NodeKind::Layer { template: true, .. }) && (imaged(n, which) - || n.mask.as_ref().is_some_and(|m| needs(&m.art, which)) + || n.extras().mask.as_ref().is_some_and(|m| needs(&m.art, which)) || (is_container(n) && n.children().is_some_and(|ch| ch.iter().any(|c| needs(c, which))))) } /// The art of `n`'s opacity mask with its raster effects as images. fn walk_mask(out: &mut Document, n: &mut Node, report: &mut FlattenReport) { - if let Some(m) = n.mask.as_mut() + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) && let Some(art) = walk(out, &m.art, report) { m.art = Arc::new(art); diff --git a/crates/engine/src/cmd/rasterfx/report.rs b/crates/engine/src/cmd/rasterfx/report.rs index e04fcd9d..e263b03f 100644 --- a/crates/engine/src/cmd/rasterfx/report.rs +++ b/crates/engine/src/cmd/rasterfx/report.rs @@ -85,14 +85,14 @@ mod tests { fn image(ppi: f64) -> Node { Node::new( NodeId(999), - NodeKind::Image(ImageObject { + NodeKind::Image(Box::new(ImageObject { key: "effect".into(), width: 8000, height: 8000, xf: Affine::scale(72.0 / ppi), link: None, placement: Default::default(), - }), + })), ) } diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index 12597bb8..36d6fdb5 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -547,7 +547,7 @@ fn same_graphic_style(s: &mut Session, _: &Value) -> Result { .and_then(|id| super::style::linked_style(&st.doc, st.doc.node(*id)?)) .map(|g| g.id) .ok_or_else(|| bad(cmd, "the selection has no graphic style"))?; - select_where(s, cmd, &json!({}), |d, n| n.graphic_style == Some(id) && super::style::linked_style(d, n).is_some()) + select_where(s, cmd, &json!({}), |d, n| n.extras().graphic_style == Some(id) && super::style::linked_style(d, n).is_some()) } /// An appearance attribute Select > Same > Appearance Attribute matches. diff --git a/crates/engine/src/cmd/shaper.rs b/crates/engine/src/cmd/shaper.rs index b7863217..24f96620 100644 --- a/crates/engine/src/cmd/shaper.rs +++ b/crates/engine/src/cmd/shaper.rs @@ -56,7 +56,7 @@ pub fn specs() -> Vec { } fn source_group(n: &Node) -> Option<&Node> { - n.shaper.as_ref()?; + n.extras().shaper.as_ref()?; n.children()?.first().map(AsRef::as_ref).filter(|g| g.name.as_deref() == Some(SOURCES)) } fn sources(n: &Node) -> Vec> { @@ -376,7 +376,7 @@ fn generated(d: &mut Document, src: &[Arc], spec: &ShaperSpec, map: &Build pub(crate) fn refresh(before: &Document, d: &mut Document) -> Result<()> { let mut ids = vec![]; d.walk(|n| { - if n.shaper.is_some() { + if n.extras().shaper.is_some() { ids.push(n.id) } }); @@ -399,7 +399,7 @@ pub(crate) fn refresh(before: &Document, d: &mut Document) -> Result<()> { continue; } let map = map_of(&src)?; - let mut spec = current.shaper.as_deref().cloned().unwrap_or_default(); + let mut spec = current.extras().shaper.as_deref().cloned().unwrap_or_default(); let previous = sources(old); if previous.iter().map(|s| s.id).ne(src.iter().map(|s| s.id)) { // Source insertion, deletion or reordering updates the index-based recipes. @@ -489,7 +489,7 @@ pub(crate) fn refresh(before: &Document, d: &mut Document) -> Result<()> { children.push(n); } let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; - n.shaper = Some(Box::new(spec)); + n.extras_mut().shaper = Some(Box::new(spec)); if let Some(ch) = n.children_mut() { *ch = children; } @@ -509,7 +509,7 @@ fn candidates(s: &Session, p: &Value, points: &[Point], tolerance: f64) -> Resul st.doc.is_editable(*id) && st.doc.node(*id).is_some_and(|n| { !matches!(n.kind, NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. }) - && (n.shaper.is_some() || b::node_outline(n).is_some()) + && (n.extras().shaper.is_some() || b::node_outline(n).is_some()) }) }) .collect(); @@ -578,7 +578,7 @@ fn scribble(s: &mut Session, p: &Value) -> Result { let mut next_merge = 1u64; for id in &roots { let n = s.doc()?.doc.node(*id).ok_or(EngineError::NoNode(*id))?; - if let Some(previous) = n.shaper.as_deref() { + if let Some(previous) = n.extras().shaper.as_deref() { let offset = src.len(); let merge_offset = next_merge; for mut e in previous.faces.clone() { @@ -669,7 +669,7 @@ fn scribble(s: &mut Session, p: &Value) -> Result { let id = d.alloc_id(); let mut group = Node::group(id, children); group.name = Some("Shaper Group".into()); - group.shaper = Some(Box::new(spec)); + group.extras_mut().shaper = Some(Box::new(spec)); for id in &roots { d.remove(*id)?; } @@ -684,7 +684,7 @@ fn scribble(s: &mut Session, p: &Value) -> Result { fn group_at(d: &Document, point: Point, construction: bool) -> Option { let mut groups = vec![]; d.walk(|n| { - if n.shaper.is_some() + if n.extras().shaper.is_some() && d.is_editable(n.id) && (if construction { source_group(n).and_then(Node::geometric_bounds) } else { n.geometric_bounds() }) .is_some_and(|b| b.inflate(6.0, 6.0).contains(point)) @@ -733,7 +733,7 @@ fn target(s: &Session, p: &Value) -> Result { let id = id_param(p, "id") .or_else(|| s.active().and_then(|d| d.selection.objects.first().copied())) .ok_or_else(|| bad("shaper.release", "select a Shaper group"))?; - s.doc()?.doc.node(id).filter(|n| n.shaper.is_some()).ok_or_else(|| bad("shaper.release", "select a Shaper group"))?; + s.doc()?.doc.node(id).filter(|n| n.extras().shaper.is_some()).ok_or_else(|| bad("shaper.release", "select a Shaper group"))?; Ok(id) } fn release(s: &mut Session, p: &Value) -> Result { @@ -756,7 +756,7 @@ fn expand(s: &mut Session, p: &Value) -> Result { let id = target(s, p)?; s.edit("Expand Shaper Group", |d, sel| { let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; - n.shaper = None; + n.extras_mut().shaper = None; n.name = None; if let Some(children) = n.children_mut() && children.first().is_some_and(|c| c.name.as_deref() == Some(SOURCES)) diff --git a/crates/engine/src/cmd/slices.rs b/crates/engine/src/cmd/slices.rs index b5b57af2..cda0e158 100644 --- a/crates/engine/src/cmd/slices.rs +++ b/crates/engine/src/cmd/slices.rs @@ -210,7 +210,7 @@ fn selected(s: &Session) -> Result> { } pub(crate) fn selected_slices(d: &Document, sel: &vectorcraft_doc::Selection) -> Vec { - let objects = sel.objects.iter().filter(|id| d.node(**id).is_some_and(|n| n.slice.is_some())); + let objects = sel.objects.iter().filter(|id| d.node(**id).is_some_and(|n| n.extras().slice.is_some())); dedup(sel.slices.iter().chain(objects).copied().collect()) } @@ -234,7 +234,7 @@ fn doc_has_slices(s: &Session) -> std::result::Result<(), String> { fn object_slice_ids(d: &Document) -> Vec { let mut ids = vec![]; d.walk(|n| { - if n.slice.is_some() { + if n.extras().slice.is_some() { ids.push(n.id); } }); @@ -260,7 +260,7 @@ pub(crate) fn remove_slice(d: &mut Document, id: NodeId) { if d.slices.len() == before && let Some(n) = d.node_mut(id) { - n.slice = None; + n.extras_mut().slice = None; } } @@ -284,14 +284,14 @@ fn make(s: &mut Session, p: &Value) -> Result { None => super::edit::selected_roots(s)?, }; let d = &s.doc()?.doc; - let made: Vec = ids.into_iter().filter(|id| d.node(*id).is_some_and(|n| !n.is_layer() && n.slice.is_none())).collect(); + let made: Vec = ids.into_iter().filter(|id| d.node(*id).is_some_and(|n| !n.is_layer() && n.extras().slice.is_none())).collect(); if made.is_empty() { return Ok(json!({ "ids": [] })); } s.edit("Make Slice", |d, _| { for id in &made { if let Some(n) = d.node_mut(*id) { - n.slice = Some(Box::default()); + n.extras_mut().slice = Some(Box::default()); } } Ok(()) @@ -559,7 +559,7 @@ fn options(s: &mut Session, p: &Value) -> Result { for id in &targets { let o = match d.slice_mut(*id) { Some(sl) => Some(&mut sl.options), - None => d.node_mut(*id).and_then(|n| n.slice.as_deref_mut()), + None => d.node_mut(*id).and_then(|n| n.extras_if_any().and_then(|e| e.slice.as_deref_mut())), }; if let Some(o) = o { edits.apply(o); diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index c6f720fc..18231883 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -175,7 +175,7 @@ pub(crate) fn in_sync(n: &Node, g: &GraphicStyle) -> bool { /// The style `n` is linked to (and still looks like). pub(crate) fn linked_style<'a>(d: &'a Document, n: &Node) -> Option<&'a GraphicStyle> { - d.graphic_style_by_id(n.graphic_style?).filter(|g| in_sync(n, g)) + d.graphic_style_by_id(n.extras().graphic_style?).filter(|g| in_sync(n, g)) } /// Objects anywhere in the art that `f` accepts. @@ -272,14 +272,14 @@ fn style_node(n: &mut Node, g: &GraphicStyle, chars: bool) { fn apply_style(d: &mut Document, id: NodeId, g: &GraphicStyle, chars: bool) { if let Some(n) = d.node_mut(id) { style_node(n, g, chars); - n.graphic_style = Some(g.id); + n.extras_mut().graphic_style = Some(g.id); } } fn set_link(d: &mut Document, ids: &[NodeId], link: Option) { for id in ids { if let Some(n) = d.node_mut(*id) { - n.graphic_style = link; + n.extras_mut().graphic_style = link; } } } @@ -303,7 +303,7 @@ impl Session { pub fn selection_graphic_style(&self) -> Option<(&GraphicStyle, bool)> { let st = self.active()?; let n = st.doc.node(*st.selection.subjects().first()?)?; - let g = st.doc.graphic_style_by_id(n.graphic_style?)?; + let g = st.doc.graphic_style_by_id(n.extras().graphic_style?)?; Some((g, in_sync(n, g))) } @@ -341,7 +341,7 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { let ap = g.appearance_on(n).into_owned(); n.appearance.items.extend(ap.items); n.appearance.effects.extend(ap.effects); - n.graphic_style = None; + n.extras_mut().graphic_style = None; } } } else { @@ -383,7 +383,7 @@ fn style_delete(s: &mut Session, p: &Value) -> Result { let i = style_index(d, name)?; let id = d.graphic_styles.remove(i).id; if id != 0 { - let ids = objects_where(d, |n| n.graphic_style == Some(id)); + let ids = objects_where(d, |n| n.extras().graphic_style == Some(id)); set_link(d, &ids, None); } } @@ -447,6 +447,7 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { let i = match str_param(p, "name") { Some(name) => style_index(d, name)?, None => n + .extras() .graphic_style .and_then(|id| d.graphic_style_by_id(id)) .and_then(|g| d.graphic_style_index(&g.name)) @@ -460,7 +461,7 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { let old = std::mem::replace(&mut d.graphic_styles[i], GraphicStyle { name: name.clone(), id, ..look }); // Objects still linked follow the new definition; edited ones are unlinked (they keep // their look), and the source keeps its own. - let linked = objects_where(d, |n| n.graphic_style == Some(id) && n.id != src); + let linked = objects_where(d, |n| n.extras().graphic_style == Some(id) && n.id != src); let (follow, edited): (Vec, Vec) = linked.into_iter().partition(|nid| d.node(*nid).is_some_and(|n| in_sync(n, &old))); set_link(d, &edited, None); let g = d.graphic_styles[i].clone(); diff --git a/crates/engine/src/cmd/textwrap.rs b/crates/engine/src/cmd/textwrap.rs index e916f65c..862d447d 100644 --- a/crates/engine/src/cmd/textwrap.rs +++ b/crates/engine/src/cmd/textwrap.rs @@ -48,7 +48,7 @@ fn make(s: &mut Session, p: &Value) -> Result { if matches!(n.kind, NodeKind::Layer { .. }) { return Err(EngineError::Other("layers can't be wrap objects".into())); } - n.wrap = Some(w); + n.extras_mut().wrap = Some(w); } Ok(()) })?; @@ -58,7 +58,10 @@ fn make(s: &mut Session, p: &Value) -> Result { fn release(s: &mut Session, p: &Value) -> Result { let ids: Vec = { let d = &s.doc()?.doc; - checked_ids_or_selection(s, p, "object.textWrap.release")?.into_iter().filter(|i| d.node(*i).is_some_and(|n| n.wrap.is_some())).collect() + checked_ids_or_selection(s, p, "object.textWrap.release")? + .into_iter() + .filter(|i| d.node(*i).is_some_and(|n| n.extras().wrap.is_some())) + .collect() }; if ids.is_empty() { return Err(EngineError::Other("Text Wrap: select a wrap object".into())); @@ -66,7 +69,7 @@ fn release(s: &mut Session, p: &Value) -> Result { s.edit("Release Text Wrap", |d, _| { for id in &ids { if let Some(n) = d.node_mut(*id) { - n.wrap = None; + n.extras_mut().wrap = None; } } Ok(()) @@ -78,7 +81,7 @@ fn options(s: &mut Session, p: &Value) -> Result { let ids = checked_ids_or_selection(s, p, "object.textWrap.options")?; let current = { let d = &s.doc()?.doc; - ids.iter().find_map(|i| d.node(*i).and_then(|n| n.wrap)).unwrap_or_default() + ids.iter().find_map(|i| d.node(*i).and_then(|n| n.extras().wrap)).unwrap_or_default() }; if p.get("offset").is_none() && p.get("invert").is_none() { return Ok(json!({ "offset": current.offset, "invert": current.invert })); @@ -89,7 +92,7 @@ fn options(s: &mut Session, p: &Value) -> Result { if let Some(n) = d.node_mut(*id) && !matches!(n.kind, NodeKind::Layer { .. }) { - n.wrap = Some(w); + n.extras_mut().wrap = Some(w); } } Ok(()) @@ -131,7 +134,7 @@ fn collect<'a>(n: &'a Node, out: &mut Vec>) { if !n.visible { return; } - if n.wrap.is_some() && !matches!(n.kind, NodeKind::Layer { .. }) { + if n.extras().wrap.is_some() && !matches!(n.kind, NodeKind::Layer { .. }) { out.push(Item::Wrap(n)); return; } @@ -147,7 +150,7 @@ fn collect<'a>(n: &'a Node, out: &mut Vec>) { } fn any_wrap_state(n: &Node) -> bool { - n.wrap.is_some() + n.extras().wrap.is_some() || matches!(&n.kind, NodeKind::Text(t) if !t.wrap.is_empty()) || n.children().is_some_and(|ch| ch.iter().any(|c| any_wrap_state(c))) } @@ -173,7 +176,7 @@ pub(crate) fn refresh(doc: &mut Document) { outline(n, &mut pd); (!pd.is_empty()).then(|| { pd.transform(inv); - WrapShape { path: pd, wrap: n.wrap.unwrap_or_default() } + WrapShape { path: pd, wrap: n.extras().wrap.unwrap_or_default() } }) } Item::Text(_) => None, @@ -219,18 +222,18 @@ mod tests { let e = s.execute(command, &json!({"ids": ids, "offset": 12})).unwrap_err().to_string(); assert!(e.contains("id") || e.contains("array"), "{command}: {e}"); assert_eq!(s.doc().unwrap().history.undo.len(), before, "{command} added history on error"); - assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.is_none()); - assert!(s.doc().unwrap().doc.node(NodeId(other)).unwrap().wrap.is_none()); + assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().extras().wrap.is_none()); + assert!(s.doc().unwrap().doc.node(NodeId(other)).unwrap().extras().wrap.is_none()); } } s.execute("object.textWrap.make", &json!({"ids": [id]})).unwrap(); - assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.is_some()); - assert!(s.doc().unwrap().doc.node(NodeId(other)).unwrap().wrap.is_none()); + assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().extras().wrap.is_some()); + assert!(s.doc().unwrap().doc.node(NodeId(other)).unwrap().extras().wrap.is_none()); s.execute("object.textWrap.options", &json!({"ids": [id], "offset": 12})).unwrap(); - assert_eq!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.unwrap().offset, 12.0); + assert_eq!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().extras().wrap.unwrap().offset, 12.0); s.execute("object.textWrap.release", &json!({"ids": [id]})).unwrap(); - assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.is_none()); + assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().extras().wrap.is_none()); } #[test] diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 7615a800..0350cee1 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -683,7 +683,7 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result { } let d = Vec2::new(f64_req(p, "dx", C)?, f64_req(p, "dy", C)?); let node = s.doc()?.doc.node(id); - if area_text(node).is_none() || node.is_some_and(|n| n.perspective.is_some()) { + if area_text(node).is_none() || node.is_some_and(|n| n.extras().perspective.is_some()) { return Err(bad(C, "not area type (text in a frame) outside perspective")); } s.edit("Reshape Type Area", |doc, _| { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 58b89bd9..f6d3f06e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -73,15 +73,47 @@ pub struct HistoryEntry { /// Roughly the memory this step keeps of its own: what of `doc` the step after it (or the /// document now) doesn't share ([`Document::retained_bytes`]). pub bytes: usize, + /// The same the other way round: what that next state keeps that `doc` doesn't share. Undoing + /// (redoing) this step makes a step that keeps the next state: this is its `bytes`. + next_bytes: usize, + /// Which document the figures were taken against (its address): the next state. + next: usize, } impl HistoryEntry { /// A step keeping `doc` and `selection`, as they were before the edit that made `next`. pub(crate) fn kept(label: String, doc: Arc, selection: Arc, next: &Document) -> Self { doc.drop_lookup_cache(); - let bytes = doc.retained_bytes(next); - HistoryEntry { label, doc, selection, bytes } + let (bytes, next_bytes) = doc.retained_bytes_each(next); + HistoryEntry { label, doc, selection, bytes, next_bytes, next: std::ptr::from_ref(next) as usize } } + + /// The step that undoing (redoing) the step `costs` came from makes: it keeps `doc`, the + /// document before the undo, whose next state is now `now` (that step's document). Its figures + /// are that step's the other way round when `doc` is still the document they were taken + /// against (nothing replaced it since), so undo and redo needn't walk the documents again. A + /// change made to it in place without an undo step (a setting, a refresh) goes unnoticed: a + /// small error, for an estimate. + pub(crate) fn turned(label: String, doc: Arc, selection: Arc, now: &Arc, costs: StepCosts) -> Self { + if costs.next != Arc::as_ptr(&doc) as usize { + return Self::kept(label, doc, selection, now); + } + doc.drop_lookup_cache(); + HistoryEntry { label, doc, selection, bytes: costs.next_bytes, next_bytes: costs.bytes, next: Arc::as_ptr(now) as usize } + } + + /// The figures [`HistoryEntry::turned`] reuses. + pub(crate) fn costs(&self) -> StepCosts { + StepCosts { bytes: self.bytes, next_bytes: self.next_bytes, next: self.next } + } +} + +/// A step's memory figures ([`HistoryEntry::costs`]). +#[derive(Clone, Copy, Debug)] +pub(crate) struct StepCosts { + bytes: usize, + next_bytes: usize, + next: usize, } /// The undo history of a document: bounded by a number of steps (Preferences › Performance › @@ -141,7 +173,8 @@ impl History { /// merged into it, or an undo group extended it). pub(crate) fn recost_last(&mut self, now: &Document) { if let Some(e) = self.undo.last_mut() { - e.bytes = e.doc.retained_bytes(now); + (e.bytes, e.next_bytes) = e.doc.retained_bytes_each(now); + e.next = std::ptr::from_ref(now) as usize; } } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a8505146..585ffd12 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -981,7 +981,7 @@ fn live_shape_properties_already_set_record_no_undo_step() { let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); let NodeKind::Path { path, live: Some(live), .. } = &mut d.node_mut(r).unwrap().kind else { panic!("not a live rectangle") }; let xf = Affine::scale_non_uniform(2.0, 1.0); - *live = vectorcraft_doc::LiveShape::Rectangle { w: 50.0, h: 60.0, radii: [10.0; 4], kinds: Default::default(), xf }; + **live = vectorcraft_doc::LiveShape::Rectangle { w: 50.0, h: 60.0, radii: [10.0; 4], kinds: Default::default(), xf }; *path = live.to_path(); } let shown = vectorcraft_doc::LiveCorners::of(s.doc().unwrap().doc.node(r).unwrap()).unwrap().radius(0); @@ -1762,8 +1762,8 @@ fn graph_tool_drag_creates_a_graph_and_asks_for_data() { let st = s.doc().unwrap(); assert_eq!(st.history.undo.len(), undo_before + 1); let g = st.doc.node(st.selection.objects[0]).unwrap(); - assert_eq!(g.graph.as_ref().unwrap().kind, vectorcraft_doc::GraphKind::Pie); - assert_eq!(g.graph.as_ref().unwrap().rect, vectorcraft_geom::Rect::new(100.0, 100.0, 300.0, 250.0)); + assert_eq!(g.extras().graph.as_ref().unwrap().kind, vectorcraft_doc::GraphKind::Pie); + assert_eq!(g.extras().graph.as_ref().unwrap().rect, vectorcraft_geom::Rect::new(100.0, 100.0, 300.0, 250.0)); } #[test] @@ -1857,17 +1857,13 @@ fn shaper_turns_rough_strokes_into_live_shapes_and_scribbles_punch_fills() { stroke(&mut s, &rect); let id = s.doc().unwrap().selection.objects[0]; let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - assert!( - matches!(n.kind, vectorcraft_doc::NodeKind::Path { live: Some(vectorcraft_doc::LiveShape::Rectangle { .. }), .. }), - "{:?}", - n.kind_label() - ); + assert!(matches!(n.live_shape(), Some(vectorcraft_doc::LiveShape::Rectangle { .. })), "{:?}", n.kind_label()); // A zig-zag inside it clears its fill while retaining the editable original and stroke. let zig: Vec<(f64, f64)> = (0..30).map(|i| (150.0 + (i % 2) as f64 * 80.0, 120.0 + i as f64 * 2.0)).collect(); stroke(&mut s, &zig); assert_eq!(s.doc().unwrap().doc.node(id).unwrap(), &n); let group = s.doc().unwrap().doc.node(s.doc().unwrap().selection.objects[0]).unwrap(); - assert!(group.shaper.is_some()); + assert!(group.extras().shaper.is_some()); assert!(group.children().unwrap().iter().skip(1).all(|n| n.appearance.fill_paint().is_none())); assert!(group.children().unwrap().iter().skip(1).any(|n| !n.appearance.stroke_paint().is_none())); } @@ -1889,10 +1885,7 @@ fn shaper_square_started_mid_edge_closes_on_pointer_release_and_undoes_once() { s.pointer(&PointerEvent::new(kind, x, y), v).unwrap(); } let id = *s.doc().unwrap().selection.objects.first().expect("recognized square"); - assert!(matches!( - s.doc().unwrap().doc.node(id).unwrap().kind, - vectorcraft_doc::NodeKind::Path { live: Some(vectorcraft_doc::LiveShape::Rectangle { .. }), .. } - )); + assert!(matches!(s.doc().unwrap().doc.node(id).unwrap().live_shape(), Some(vectorcraft_doc::LiveShape::Rectangle { .. }))); assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); s.execute("edit.undo", &json!({})).unwrap(); assert!(s.doc().unwrap().doc.node(id).is_none()); @@ -1928,7 +1921,7 @@ fn shaper_creates_upright_or_inverted_live_triangles_with_one_undo_step() { s.pointer(&PointerEvent::new(PointerKind::Up, pts[0].x, pts[0].y), v).unwrap(); let id = *s.doc().unwrap().selection.objects.first().expect("recognized triangle"); let node = s.doc().unwrap().doc.node(id).unwrap().clone(); - let vectorcraft_doc::NodeKind::Path { live: Some(vectorcraft_doc::LiveShape::Polygon { sides: 3, xf, .. }), .. } = &node.kind else { + let Some(vectorcraft_doc::LiveShape::Polygon { sides: 3, xf, .. }) = node.live_shape() else { panic!("expected live triangle, got {:?}", node.kind_label()); }; let [a, b, c, d, _, _] = xf.as_coeffs(); diff --git a/crates/engine/src/tests_adjust.rs b/crates/engine/src/tests_adjust.rs index 040a4924..9649e12b 100644 --- a/crates/engine/src/tests_adjust.rs +++ b/crates/engine/src/tests_adjust.rs @@ -43,7 +43,7 @@ fn red_image(s: &mut Session) -> NodeId { link: None, placement: Default::default(), }; - d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(im)))?; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; sel.set([id]); Ok(id) }) diff --git a/crates/engine/src/tests_attributes.rs b/crates/engine/src/tests_attributes.rs index 562c342a..17bcb26f 100644 --- a/crates/engine/src/tests_attributes.rs +++ b/crates/engine/src/tests_attributes.rs @@ -52,13 +52,13 @@ fn each_attribute_sets_reads_back_and_undoes_in_one_step() { assert_eq!(i[k], v, "{k}"); } let n = s.doc().unwrap().doc.node(a).unwrap().clone(); - let at = n.attrs.as_deref().unwrap(); + let at = n.extras().attrs.as_deref().unwrap(); assert_eq!((at.show_center, at.image_map, n.url()), (Some(false), ImageMap::Rectangle, Some("https://example.com"))); assert!(!n.shows_center()); // One undo step takes everything back, and the attributes go away entirely. run(&mut s, "edit.undo", json!({})); let n = s.doc().unwrap().doc.node(a).unwrap().clone(); - assert!(n.attrs.is_none() && !n.has_overprint()); + assert!(n.extras().attrs.is_none() && !n.has_overprint()); // Nothing to change: no undo step; bad values are refused. let before = s.doc().unwrap().revision; assert_eq!(run(&mut s, "attributes.set", json!({"showCenter": true}))["changed"], 0); @@ -68,7 +68,7 @@ fn each_attribute_sets_reads_back_and_undoes_in_one_step() { // Clearing the URL and note drops the attributes. run(&mut s, "attributes.set", json!({"url": "u", "note": "n"})); run(&mut s, "attributes.set", json!({"url": "", "note": ""})); - assert!(s.doc().unwrap().doc.node(a).unwrap().attrs.is_none()); + assert!(s.doc().unwrap().doc.node(a).unwrap().extras().attrs.is_none()); } #[test] @@ -168,8 +168,8 @@ fn attributes_survive_a_native_round_trip() { run(&mut s, "attributes.set", json!({"showCenter": false, "imageMap": "polygon", "url": "https://example.com/x", "note": "keep"})); let doc = s.doc().unwrap().doc.clone(); let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap(); - assert_eq!(back.node(a).unwrap().attrs, doc.node(a).unwrap().attrs); - assert!(back.node(a).unwrap().attrs.is_some()); + assert_eq!(back.node(a).unwrap().extras().attrs, doc.node(a).unwrap().extras().attrs); + assert!(back.node(a).unwrap().extras().attrs.is_some()); } #[test] @@ -197,7 +197,7 @@ fn svg_export_links_objects_and_import_reads_the_links_back() { fn object_data_is_set_saved_and_written_to_svg() { let mut s = session(); let a = rect(&mut s, 10.0); - let data = |s: &Session| s.doc().unwrap().doc.node(a).unwrap().attrs.as_deref().map(|x| x.data.clone()).unwrap_or_default(); + let data = |s: &Session| s.doc().unwrap().doc.node(a).unwrap().extras().attrs.as_deref().map(|x| x.data.clone()).unwrap_or_default(); let pair = |k: &str, v: &str| (k.to_string(), v.to_string()); run(&mut s, "object.setProps", json!({"data": {"pivot": "60,100", "z": 3}})); assert_eq!(data(&s), [pair("pivot", "60,100"), pair("z", "3")], "values are text"); @@ -212,5 +212,5 @@ fn object_data_is_set_saved_and_written_to_svg() { assert!(svg.contains("data-pivot=\"60,100\" data-z=\"3\""), "{svg}"); let native = vectorcraft_format::save(&s.doc().unwrap().doc, false); let back = vectorcraft_format::load(&native).unwrap(); - assert_eq!(back.node(a).unwrap().attrs, s.doc().unwrap().doc.node(a).unwrap().attrs); + assert_eq!(back.node(a).unwrap().extras().attrs, s.doc().unwrap().doc.node(a).unwrap().extras().attrs); } diff --git a/crates/engine/src/tests_blendfidelity.rs b/crates/engine/src/tests_blendfidelity.rs index b71f6eb6..a3c06c95 100644 --- a/crates/engine/src/tests_blendfidelity.rs +++ b/crates/engine/src/tests_blendfidelity.rs @@ -166,7 +166,7 @@ fn expand_and_release_keep_what_the_blend_carries() { s.edit("mask", |d, _| { let art = Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 10.0)), Default::default()); let mask = vectorcraft_doc::OpacityMask::new(art, false); - d.node_mut(g).unwrap().mask = Some(Box::new(mask)); + d.node_mut(g).unwrap().extras_mut().mask = Some(Box::new(mask)); Ok(()) }) .unwrap(); @@ -176,14 +176,14 @@ fn expand_and_release_keep_what_the_blend_carries() { let e = node(&s, g); assert!(matches!(e.kind, NodeKind::Group { .. })); assert_eq!((e.name.as_deref(), e.opacity, e.isolate, e.knockout), (Some("Fade"), before.opacity, true, Knockout::On)); - assert_eq!(e.mask, before.mask); + assert_eq!(e.extras().mask, before.extras().mask); s.execute("edit.undo", &json!({})).unwrap(); s.execute("select.set", &json!({"ids": [g.0]})).unwrap(); let r = s.execute("object.blend.release", &json!({})).unwrap(); let grp = NodeId(r["groups"][0].as_u64().unwrap()); let n = node(&s, grp); assert_eq!((n.name.as_deref(), n.opacity, n.isolate), (Some("Fade"), before.opacity, true)); - assert_eq!(n.mask, before.mask); + assert_eq!(n.extras().mask, before.extras().mask); assert_eq!(n.children().unwrap().len(), 3, "spine and keys inside"); } diff --git a/crates/engine/src/tests_blendopts.rs b/crates/engine/src/tests_blendopts.rs index 7e08912c..ab70333d 100644 --- a/crates/engine/src/tests_blendopts.rs +++ b/crates/engine/src/tests_blendopts.rs @@ -28,7 +28,7 @@ fn node(s: &Session, id: NodeId) -> Node { fn spec(s: &Session, id: NodeId) -> (Vec, BlendSpec) { match node(s, id).kind { - NodeKind::Blend { children, spec } => (children.iter().map(|c| c.id).collect(), spec), + NodeKind::Blend { children, spec } => (children.iter().map(|c| c.id).collect(), *spec), _ => panic!("not a blend"), } } diff --git a/crates/engine/src/tests_blendspine.rs b/crates/engine/src/tests_blendspine.rs index ab9fc831..f2a42f7e 100644 --- a/crates/engine/src/tests_blendspine.rs +++ b/crates/engine/src/tests_blendspine.rs @@ -26,7 +26,7 @@ fn node(s: &Session, id: NodeId) -> Node { fn spec(s: &Session, id: NodeId) -> BlendSpec { match node(s, id).kind { - NodeKind::Blend { spec, .. } => spec, + NodeKind::Blend { spec, .. } => *spec, _ => panic!("not a blend"), } } diff --git a/crates/engine/src/tests_build.rs b/crates/engine/src/tests_build.rs index a79f0048..8cf3e4e0 100644 --- a/crates/engine/src/tests_build.rs +++ b/crates/engine/src/tests_build.rs @@ -592,7 +592,10 @@ fn add_image(s: &mut Session, r: &vectorcraft_trace::Raster, xf: Affine) -> Node d.insert( l, 0, - Node::new(id, NodeKind::Image(ImageObject { key: "img1".into(), width: w, height: h, xf, link: None, placement: Default::default() })), + Node::new( + id, + NodeKind::Image(Box::new(ImageObject { key: "img1".into(), width: w, height: h, xf, link: None, placement: Default::default() })), + ), )?; sel.set([id]); Ok(id) @@ -687,7 +690,7 @@ fn image_trace_palettes_and_swatch_libraries() { let doc = crate::cmd::swatchlib::library_colors(&s, "document").unwrap(); let got = fills(&s, &r["id"]); assert!(!got.is_empty() && got.iter().all(|c| doc.contains(c)), "{got:?}"); - let t = node(&s, NodeId(r["id"].as_u64().unwrap())).trace.unwrap(); + let t = node(&s, NodeId(r["id"].as_u64().unwrap())).extras().trace.clone().unwrap(); assert_eq!((t["params"]["palette"].as_str(), t["params"]["library"].as_str()), (Some("documentLibrary"), Some("document"))); assert!(t["params"].get("swatches").is_none(), "looked up again at every trace"); // A swatch library by id: the web-safe colours. @@ -733,7 +736,7 @@ fn image_trace_create_strokes_makes_stroked_centre_lines() { assert!(!sp.closed); let (a, b) = (sp.anchors[0].p, sp.anchors[sp.anchors.len() - 1].p); assert!((a.y - 201.0).abs() < 1.5 && (b.y - 201.0).abs() < 1.5 && (a.x - b.x).abs() > 140.0, "{a:?} … {b:?}"); - let t = node(&s, g).trace.unwrap(); + let t = node(&s, g).extras().trace.clone().unwrap(); assert_eq!((t["params"]["strokes"].as_bool(), t["params"]["strokeWidth"].as_f64()), (Some(true), Some(5.0))); // Expand keeps the stroked line. s.execute("imageTrace.expand", &json!({})).unwrap(); @@ -777,7 +780,7 @@ fn image_trace_flat_logo_preset_makes_a_true_circle() { assert!(r["anchors"].as_u64().unwrap() <= 6, "a circle is four anchors: {r}"); let g = NodeId(r["id"].as_u64().unwrap()); let n = node(&s, g); - let t = n.trace.clone().expect("settings stored"); + let t = n.extras().trace.clone().expect("settings stored"); assert_eq!((t["preset"].as_str(), t["params"]["mode"].as_str()), (Some("Flat Logo"), Some("logo"))); let paths = traced_paths(&n); let (p, rule) = outline(&paths[0]); @@ -811,7 +814,7 @@ fn image_trace_object_remembers_its_settings() { image_doc(&mut s); let r = s.execute("imageTrace.make", &json!({"preset": "6 Colors"})).unwrap(); let g = NodeId(r["id"].as_u64().unwrap()); - let t = node(&s, g).trace.clone().expect("settings stored"); + let t = node(&s, g).extras().trace.clone().expect("settings stored"); assert_eq!(t["preset"], "6 Colors"); assert_eq!(t["params"]["colors"], 6); // Changing a parameter makes it Custom; the settings survive save/open. @@ -819,13 +822,13 @@ fn image_trace_object_remembers_its_settings() { let g = NodeId(r["id"].as_u64().unwrap()); let d = s.doc().unwrap().doc.clone(); let back = vectorcraft_format::load(&vectorcraft_format::save(&d, false)).unwrap(); - let t = back.node(g).unwrap().trace.clone().unwrap(); + let t = back.node(g).unwrap().extras().trace.clone().unwrap(); assert_eq!((t["preset"].as_str(), t["params"]["colors"].as_u64()), (Some("Custom"), Some(9))); // Expanded traces are plain groups. s.execute("imageTrace.expand", &json!({})).unwrap(); let st = s.doc().unwrap(); let id = st.selection.in_paint_order(&st.doc)[0]; - assert!(st.doc.node(id).unwrap().trace.is_none()); + assert!(st.doc.node(id).unwrap().extras().trace.is_none()); } /// A black disc (r = 30 px) on light blue, 100 × 100 px, placed at 2× from (50, 50): traced in @@ -852,7 +855,7 @@ fn render_doc(s: &Session, trace_views: bool) -> vectorcraft_render::Rendered { fn image_trace_view_is_stored_kept_and_validated() { let mut s = session(); let g = disc_on_blue(&mut s); - assert_eq!(node(&s, g).trace.unwrap()["view"], "tracingResult"); + assert_eq!(node(&s, g).extras().trace.clone().unwrap()["view"], "tracingResult"); let r = s.execute("imageTrace.setView", &json!({"view": "outlinesWithSourceImage"})).unwrap(); assert_eq!((ids(&r), r["view"].as_str()), (vec![g], Some("outlinesWithSourceImage"))); assert_eq!(node(&s, g).trace_view(), vectorcraft_doc::TraceView::OutlinesWithSource); diff --git a/crates/engine/src/tests_clipboard.rs b/crates/engine/src/tests_clipboard.rs index 1a1d9cfc..48c8912b 100644 --- a/crates/engine/src/tests_clipboard.rs +++ b/crates/engine/src/tests_clipboard.rs @@ -294,7 +294,7 @@ fn graphic_style_links_follow_the_style_into_the_document() { s.execute("paint.setFill", &json!({"ids": [a.0], "color": "#ffcc00"})).unwrap(); sel(&mut s, &[a]); s.execute("graphicStyle.new", &json!({"name": "Sun"})).unwrap(); - let src_id = node(&s, a).graphic_style.unwrap(); + let src_id = node(&s, a).extras().graphic_style.unwrap(); copy(&mut s, &[a]); assert_eq!(s.clipboard.graphic_styles.iter().map(|g| g.name.as_str()).collect::>(), ["Sun"]); @@ -307,17 +307,17 @@ fn graphic_style_links_follow_the_style_into_the_document() { let v = s.execute("edit.paste", &json!({})).unwrap(); assert_eq!(renamed(&v), [("graphicStyle".to_string(), "Sun".to_string(), "Sun 2".to_string())]); let d = doc(&s); - let linked_to = d.graphic_style_by_id(node(&s, ids_of(&v)[0]).graphic_style.unwrap()).unwrap().clone(); + let linked_to = d.graphic_style_by_id(node(&s, ids_of(&v)[0]).extras().graphic_style.unwrap()).unwrap().clone(); assert_eq!(linked_to.name, "Sun 2"); assert_eq!(linked_to.appearance.fill_paint(), Paint::solid(hex("#ffcc00"))); assert!(d.graphic_styles.iter().filter(|g| g.id == linked_to.id).count() == 1, "a fresh id"); // Pasting again finds the same style. let v = s.execute("edit.paste", &json!({})).unwrap(); - assert_eq!((v["added"].as_u64(), node(&s, ids_of(&v)[0]).graphic_style), (Some(0), Some(linked_to.id))); + assert_eq!((v["added"].as_u64(), node(&s, ids_of(&v)[0]).extras().graphic_style), (Some(0), Some(linked_to.id))); // Back in the source document the link is kept as it was. s.execute("document.activate", &json!({"index": 0})).unwrap(); let v = s.execute("edit.paste", &json!({})).unwrap(); - assert_eq!(node(&s, ids_of(&v)[0]).graphic_style, Some(src_id)); + assert_eq!(node(&s, ids_of(&v)[0]).extras().graphic_style, Some(src_id)); } #[test] diff --git a/crates/engine/src/tests_editcolors.rs b/crates/engine/src/tests_editcolors.rs index 93ef8faa..0dd51c98 100644 --- a/crates/engine/src/tests_editcolors.rs +++ b/crates/engine/src/tests_editcolors.rs @@ -179,7 +179,7 @@ fn image_node(s: &mut Session) -> NodeId { link: None, placement: Default::default(), }; - d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(im)))?; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; sel.set([id]); Ok(id) }) diff --git a/crates/engine/src/tests_envelope.rs b/crates/engine/src/tests_envelope.rs index f1609d89..ea3ce828 100644 --- a/crates/engine/src/tests_envelope.rs +++ b/crates/engine/src/tests_envelope.rs @@ -40,7 +40,7 @@ fn enveloped(s: &mut Session, cmd: &str, params: Value) -> (NodeId, NodeId) { fn kind(s: &Session, id: NodeId) -> EnvelopeKind { match node(s, id).kind { - NodeKind::Envelope { kind, .. } => kind, + NodeKind::Envelope { kind, .. } => *kind, k => panic!("{k:?}"), } } @@ -130,7 +130,7 @@ fn release_gives_back_the_warp_shape_as_a_mesh_and_keeps_the_transparency() { s.execute("transparency.set", &json!({"opacity": 40, "blend": "multiply"})).unwrap(); let mask = Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Default::default()); s.edit("mask", |d, _| { - d.node_mut(e).unwrap().mask = Some(Box::new(OpacityMask::new(mask, true))); + d.node_mut(e).unwrap().extras_mut().mask = Some(Box::new(OpacityMask::new(mask, true))); Ok(()) }) .unwrap(); @@ -139,7 +139,7 @@ fn release_gives_back_the_warp_shape_as_a_mesh_and_keeps_the_transparency() { assert_eq!(ids.len(), 2); assert_eq!(ids[0], a); let content = node(&s, a); - assert_eq!((content.opacity, content.blend, content.mask.is_some()), (0.4, BlendMode::Multiply, true), "the content keeps the look"); + assert_eq!((content.opacity, content.blend, content.extras().mask.is_some()), (0.4, BlendMode::Multiply, true), "the content keeps the look"); let NodeKind::Mesh(m) = node(&s, ids[1]).kind else { panic!("the warp's shape comes back as a mesh") }; // The mesh follows the arc: its outline reaches below the content's bottom edge at the sides. let b = m.outline().bounds().unwrap(); @@ -172,14 +172,14 @@ fn expand_keeps_name_transparency_knockout_and_mask() { s.edit("name and mask", |d, _| { let n = d.node_mut(e).unwrap(); n.name = Some("Banner".into()); - n.mask = Some(Box::new(OpacityMask::new(mask, true))); + n.extras_mut().mask = Some(Box::new(OpacityMask::new(mask, true))); Ok(()) }) .unwrap(); s.execute("object.envelope.expand", &json!({})).unwrap(); let g = node(&s, e); assert!(matches!(g.kind, NodeKind::Group { .. })); - assert_eq!((g.name.as_deref(), g.opacity, g.isolate, g.knockout, g.mask.is_some()), (Some("Banner"), 0.7, true, Knockout::On, true)); + assert_eq!((g.name.as_deref(), g.opacity, g.isolate, g.knockout, g.extras().mask.is_some()), (Some("Banner"), 0.7, true, Knockout::On, true)); } #[test] diff --git a/crates/engine/src/tests_envelope_distort.rs b/crates/engine/src/tests_envelope_distort.rs index 300a6ec5..b02fdb12 100644 --- a/crates/engine/src/tests_envelope_distort.rs +++ b/crates/engine/src/tests_envelope_distort.rs @@ -63,7 +63,7 @@ fn anchors(nodes: &[Node]) -> Vec { fn frame(s: &Session, e: NodeId) -> Affine { match node(s, e).kind { - NodeKind::Envelope { frame, .. } => frame, + NodeKind::Envelope { frame, .. } => *frame, k => panic!("{k:?}"), } } @@ -110,7 +110,7 @@ fn the_frame_round_trips_natively() { let mut old = serde_json::to_value(node(&s, e)).unwrap(); old["kind"].as_object_mut().unwrap().remove("frame"); let n: Node = serde_json::from_value(old).unwrap(); - assert!(matches!(n.kind, NodeKind::Envelope { frame, .. } if frame == Affine::IDENTITY), "older envelopes stand square to the page"); + assert!(matches!(n.kind, NodeKind::Envelope { frame, .. } if frame == Box::new(Affine::IDENTITY)), "older envelopes stand square to the page"); } /// A selected embedded 40×40 pt red image at (100, 100). @@ -123,7 +123,7 @@ fn red_image(s: &mut Session) -> NodeId { let id = d.alloc_id(); let xf = Affine::translate((100.0, 100.0)) * Affine::scale(10.0); let im = vectorcraft_doc::ImageObject { key: "pic".into(), width: 4, height: 4, xf, link: None, placement: Default::default() }; - d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(im)))?; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; sel.set([id]); Ok(id) }) diff --git a/crates/engine/src/tests_envelope_edit.rs b/crates/engine/src/tests_envelope_edit.rs index 1cccb9b8..26e20f43 100644 --- a/crates/engine/src/tests_envelope_edit.rs +++ b/crates/engine/src/tests_envelope_edit.rs @@ -32,7 +32,10 @@ fn enveloped(s: &mut Session, cmd: &str, params: Value) -> (NodeId, NodeId) { fn mesh(s: &Session, e: NodeId) -> (u32, u32, Vec, Vec<[Vec2; 4]>) { match node(s, e).kind { - NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows, cols, points, handles }, .. } => (rows, cols, points, handles), + NodeKind::Envelope { kind, .. } => match *kind { + EnvelopeKind::Mesh { rows, cols, points, handles } => (rows, cols, points, handles), + k => panic!("{k:?}"), + }, k => panic!("{k:?}"), } } @@ -120,7 +123,7 @@ fn mesh_handles_turn_with_the_envelope_and_round_trip() { let mut old = serde_json::to_value(node(&s, e)).unwrap(); old["kind"]["kind"].as_object_mut().unwrap().remove("handles"); let n: Node = serde_json::from_value(old).unwrap(); - assert!(matches!(n.kind, NodeKind::Envelope { kind: EnvelopeKind::Mesh { handles, .. }, .. } if handles.is_empty())); + assert!(matches!(n.kind, NodeKind::Envelope { kind, .. } if matches!(&*kind, EnvelopeKind::Mesh { handles, .. } if handles.is_empty()))); } #[test] diff --git a/crates/engine/src/tests_eyedropper.rs b/crates/engine/src/tests_eyedropper.rs index ef5d7d8b..9b30500a 100644 --- a/crates/engine/src/tests_eyedropper.rs +++ b/crates/engine/src/tests_eyedropper.rs @@ -161,7 +161,7 @@ fn clicking_an_image_samples_its_pixel() { let id = d.alloc_id(); let xf = vectorcraft_geom::Affine::translate((300.0, 0.0)) * vectorcraft_geom::Affine::scale(100.0); let im = ImageObject { key: "px".into(), width: 2, height: 1, xf, link: None, placement: Default::default() }; - d.insert(Some(l), 0, Node::new(id, NodeKind::Image(im))).map_err(|e| crate::EngineError::Other(e.to_string())) + d.insert(Some(l), 0, Node::new(id, NodeKind::Image(Box::new(im)))).map_err(|e| crate::EngineError::Other(e.to_string())) }) .unwrap(); let dst = rect(&mut s, 10.0); diff --git a/crates/engine/src/tests_flatten.rs b/crates/engine/src/tests_flatten.rs index a014b1db..b6d617ad 100644 --- a/crates/engine/src/tests_flatten.rs +++ b/crates/engine/src/tests_flatten.rs @@ -451,13 +451,13 @@ fn opacity_masks_are_rasterized_with_their_look() { json!({"ids": [m], "gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]}}), ); run(&mut s, "transparency.makeOpacityMask", json!({"ids": [a, m]})); - assert!(node(&s, a).mask.is_some()); + assert!(node(&s, a).extras().mask.is_some()); let before = picture(&s); run(&mut s, "select.all", json!({})); let (ids, _, rasterized) = flatten(&mut s, json!({"lineArtPpi": 144})); assert_eq!((ids.len(), rasterized), (1, 1)); let mut masks = 0; - node(&s, ids[0]).walk(&mut |c| masks += c.mask.is_some() as usize); + node(&s, ids[0]).walk(&mut |c| masks += c.extras().mask.is_some() as usize); assert_eq!(masks, 0, "no transparency is left"); assert!(changed(&before, &picture(&s)) < 0.02); } diff --git a/crates/engine/src/tests_history_mem.rs b/crates/engine/src/tests_history_mem.rs index dd190700..0ac0fab5 100644 --- a/crates/engine/src/tests_history_mem.rs +++ b/crates/engine/src/tests_history_mem.rs @@ -124,3 +124,32 @@ fn steps_share_an_unchanged_selection() { assert!(Arc::ptr_eq(&st.history.redo[0].selection, &st.history.redo[1].selection)); assert_eq!(st.selection.objects.len(), 100); } + +/// Undo and redo don't estimate the steps again: the step an undo (redo) makes takes the undone +/// step's figures the other way round, which are what a fresh estimate gives. +#[test] +fn undo_and_redo_reuse_the_estimates() { + let mut s = session_with_rects(200); + s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); + // A deletion is lopsided: the step before it keeps the objects, the one after keeps nothing. + s.execute("edit.clear", &json!({})).unwrap(); + let fresh = |e: &HistoryEntry, now: &Document| e.doc.retained_bytes(now); + let st = s.doc().unwrap(); + let delete = st.history.undo.last().unwrap(); + assert!(delete.bytes > 200 * 300, "the deleted objects: {}", delete.bytes); + s.execute("edit.undo", &json!({})).unwrap(); + let st = s.doc().unwrap(); + let redo = st.history.redo.last().unwrap(); + assert_eq!(redo.bytes, fresh(redo, &st.doc)); + assert!(redo.bytes < 4096, "the document without the objects keeps little of its own: {}", redo.bytes); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.redo", &json!({})).unwrap(); + let st = s.doc().unwrap(); + let undo = st.history.undo.last().unwrap(); + assert_eq!(undo.label, "Move"); + assert_eq!(undo.bytes, fresh(undo, &st.doc)); + s.execute("edit.redo", &json!({})).unwrap(); + let st = s.doc().unwrap(); + let delete = st.history.undo.last().unwrap(); + assert_eq!(delete.bytes, fresh(delete, &st.doc)); +} diff --git a/crates/engine/src/tests_links.rs b/crates/engine/src/tests_links.rs index 9734a319..a1ca2592 100644 --- a/crates/engine/src/tests_links.rs +++ b/crates/engine/src/tests_links.rs @@ -64,7 +64,7 @@ pub(crate) fn place(s: &mut Session, path: &str) -> NodeId { pub(crate) fn image(s: &Session, id: NodeId) -> ImageObject { match &s.doc().unwrap().doc.node(id).unwrap().kind { - NodeKind::Image(im) => im.clone(), + NodeKind::Image(im) => (**im).clone(), k => panic!("not an image: {k:?}"), } } @@ -323,7 +323,7 @@ fn relative_paths_are_written_against_the_saved_file() { link: Some(LinkInfo::new(format!("{root}{sep}art{sep}a.png"))), placement: Default::default(), }; - d.insert(Some(lid), 0, vectorcraft_doc::Node::new(nid, NodeKind::Image(im))).unwrap(); + d.insert(Some(lid), 0, vectorcraft_doc::Node::new(nid, NodeKind::Image(Box::new(im)))).unwrap(); let rel = |dest: String| { let d = cmd::links::with_relative_paths(&d, &dest).unwrap_or_else(|| d.clone()); match &d.node(nid).unwrap().kind { diff --git a/crates/engine/src/tests_live.rs b/crates/engine/src/tests_live.rs index a26495d2..54e523a4 100644 --- a/crates/engine/src/tests_live.rs +++ b/crates/engine/src/tests_live.rs @@ -413,7 +413,8 @@ fn envelope_options_and_edit_contents() { let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 50})).unwrap()); s.execute("object.envelope.options", &json!({"fidelity": 90, "bend": -30})).unwrap(); match &node(&s, e).kind { - NodeKind::Envelope { fidelity, kind: EnvelopeKind::Warp { bend, style, .. }, .. } => { + NodeKind::Envelope { fidelity, kind, .. } => { + let EnvelopeKind::Warp { bend, style, .. } = &**kind else { panic!("{kind:?}") }; assert!(close(*fidelity, 90.0) && close(*bend, -30.0) && style == "arc"); } k => panic!("{k:?}"), diff --git a/crates/engine/src/tests_livecorners.rs b/crates/engine/src/tests_livecorners.rs index b7ed7404..96cdf0b1 100644 --- a/crates/engine/src/tests_livecorners.rs +++ b/crates/engine/src/tests_livecorners.rs @@ -25,7 +25,8 @@ fn run(s: &mut Session, p: Value) { fn corners(s: &Session, id: NodeId) -> ([f64; 4], [CornerKind; 4], usize) { let n = s.doc().unwrap().doc.node(id).unwrap(); - let NodeKind::Path { live: Some(LiveShape::Rectangle { radii, kinds, .. }), path, .. } = &n.kind else { panic!("not a live rectangle") }; + let NodeKind::Path { live: Some(l), path, .. } = &n.kind else { panic!("not a live shape") }; + let LiveShape::Rectangle { radii, kinds, .. } = &**l else { panic!("not a live rectangle") }; (*radii, *kinds, path.anchor_count()) } @@ -157,7 +158,7 @@ fn corners_of_a_non_square_rectangle_are_alike_circles() { let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); let NodeKind::Path { path, live: Some(live), .. } = &mut d.node_mut(id).unwrap().kind else { panic!("not live") }; let xf = Affine::translate((10.0, 10.0)) * Affine::scale_non_uniform(2.0, 1.0); - *live = LiveShape::Rectangle { w: 50.0, h: 60.0, radii: [10.0; 4], kinds: Default::default(), xf }; + **live = LiveShape::Rectangle { w: 50.0, h: 60.0, radii: [10.0; 4], kinds: Default::default(), xf }; *path = live.to_path(); } assert_eq!(spans(&s, id)[0], (20.0, 10.0)); @@ -178,7 +179,7 @@ fn star(s: &mut Session) -> NodeId { fn node_path(s: &Session, id: NodeId) -> (vectorcraft_geom::PathData, Option) { let NodeKind::Path { path, live, .. } = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("not a path") }; - (path.clone(), live.clone()) + (path.clone(), live.as_deref().cloned()) } /// The radius and kind every corner of `id` shares, and how many corners it has. @@ -307,7 +308,10 @@ fn an_ellipse_takes_pie_angles_and_inverts() { s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap(); let id = NodeId(s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap()); let pie = |s: &Session| match &s.doc().unwrap().doc.node(id).unwrap().kind { - NodeKind::Path { live: Some(LiveShape::Ellipse { pie, .. }), path, .. } => (*pie, path.bounds().unwrap()), + NodeKind::Path { live: Some(l), path, .. } => match &**l { + LiveShape::Ellipse { pie, .. } => (*pie, path.bounds().unwrap()), + k => panic!("{k:?}"), + }, k => panic!("{k:?}"), }; s.execute("object.setLiveShape", &json!({"pieStart": 0, "pieEnd": 90})).unwrap(); @@ -335,7 +339,7 @@ fn a_polygon_takes_its_angle_radius_side_length_and_equal_sides() { s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); let id = NodeId(s.execute("shape.polygon", &json!({"cx": 200, "cy": 200, "radius": 50, "sides": 6})).unwrap()["id"].as_u64().unwrap()); let live = |s: &Session| match &s.doc().unwrap().doc.node(id).unwrap().kind { - NodeKind::Path { live: Some(l @ LiveShape::Polygon { .. }), path, .. } => (l.clone(), path.bounds().unwrap()), + NodeKind::Path { live: Some(l), path, .. } if matches!(**l, LiveShape::Polygon { .. }) => ((**l).clone(), path.bounds().unwrap()), k => panic!("{k:?}"), }; let close = |a: f64, b: f64| (a - b).abs() < 1e-6; diff --git a/crates/engine/src/tests_menucmds.rs b/crates/engine/src/tests_menucmds.rs index 5883d220..3a23dda1 100644 --- a/crates/engine/src/tests_menucmds.rs +++ b/crates/engine/src/tests_menucmds.rs @@ -426,7 +426,8 @@ fn convert_rectangle_path_to_live_shape() { ); sel(&mut s, &[p]); assert_eq!(s.execute("object.shape.convertToShape", &json!({})).unwrap()["converted"], 1); - let NodeKind::Path { live: Some(LiveShape::Rectangle { w, h, .. }), path, .. } = &node(&s, p).kind else { panic!() }; + let n = node(&s, p); + let (Some(LiveShape::Rectangle { w, h, .. }), NodeKind::Path { path, .. }) = (n.live_shape(), &n.kind) else { panic!() }; assert!(close(*w, 40.0) && close(*h, 20.0)); assert_eq!(path.bounds(), Some(Rect::new(0.0, 0.0, 40.0, 20.0))); } @@ -448,7 +449,7 @@ fn convert_rotated_rectangle_and_ellipse() { assert_eq!(s.execute("object.shape.convertToShape", &json!({})).unwrap()["converted"], 2); let b = bounds(&s, p); assert!(close(b.x0, 0.0) && close(b.x1, 100.0) && close(b.y1, 100.0), "{b:?}"); - assert!(matches!(node(&s, e).kind, NodeKind::Path { live: Some(LiveShape::Ellipse { .. }), .. })); + assert!(matches!(node(&s, e).live_shape(), Some(LiveShape::Ellipse { .. }))); } #[test] diff --git a/crates/engine/src/tests_opacitymask.rs b/crates/engine/src/tests_opacitymask.rs index d18e033f..a2fe33ae 100644 --- a/crates/engine/src/tests_opacitymask.rs +++ b/crates/engine/src/tests_opacitymask.rs @@ -35,7 +35,7 @@ fn doc(s: &Session) -> &Document { } fn mask(s: &Session, id: NodeId) -> Option<&vectorcraft_doc::OpacityMask> { - doc(s).node(id).and_then(|n| n.mask.as_deref()) + doc(s).node(id).and_then(|n| n.extras().mask.as_deref()) } fn has_edit_layer(d: &Document) -> bool { @@ -272,7 +272,7 @@ fn saving_while_editing_writes_no_editing_layer() { assert!(back.mask_edit.is_none() && !has_edit_layer(&back)); assert_eq!(back.layers.len(), 1); // The edit made while editing is in the saved mask. - assert_eq!(back.node(obj).unwrap().mask.as_ref().unwrap().art.geometric_bounds().unwrap().x0, 50.0); + assert_eq!(back.node(obj).unwrap().extras().mask.as_ref().unwrap().art.geometric_bounds().unwrap().x0, 50.0); // Still editing in the session. assert!(doc(&s).mask_edit.is_some() && has_edit_layer(doc(&s))); // A file saved mid-edit by an older version (editing record and layer written) opens clean. diff --git a/crates/engine/src/tests_pdfraster.rs b/crates/engine/src/tests_pdfraster.rs index 2eb3787a..a3de3deb 100644 --- a/crates/engine/src/tests_pdfraster.rs +++ b/crates/engine/src/tests_pdfraster.rs @@ -103,7 +103,7 @@ fn groups_layers_type_images_and_symbols_keep_their_raster_effects() { let xf = Affine::translate((40.0, 50.0)) * Affine::scale(2.5); let im = Node::new( d.alloc_id(), - NodeKind::Image(ImageObject { key: "pic".into(), width: 40, height: 30, xf, link: None, placement: Default::default() }), + NodeKind::Image(Box::new(ImageObject { key: "pic".into(), width: 40, height: 30, xf, link: None, placement: Default::default() })), ); add(&mut d, with(im, shadow())); assert_written("image", &d); diff --git a/crates/engine/src/tests_persp_text.rs b/crates/engine/src/tests_persp_text.rs index 96fe97c2..597c2ba8 100644 --- a/crates/engine/src/tests_persp_text.rs +++ b/crates/engine/src/tests_persp_text.rs @@ -117,7 +117,7 @@ fn edit_text_shows_it_flat_in_isolation_then_projects_it_again() { assert!(n.projection().is_some(), "projected again"); // Nothing left flat anywhere in the history. for e in &s.doc().unwrap().history.undo { - if let Some(p) = e.doc.node(id).and_then(|n| n.perspective.as_deref()) { + if let Some(p) = e.doc.node(id).and_then(|n| n.extras().perspective.as_deref()) { assert!(!p.editing); } } diff --git a/crates/engine/src/tests_recovery.rs b/crates/engine/src/tests_recovery.rs index 80922774..cf46b91d 100644 --- a/crates/engine/src/tests_recovery.rs +++ b/crates/engine/src/tests_recovery.rs @@ -458,3 +458,119 @@ fn without_a_store_the_commands_say_where_to_set_one() { assert!(!area.join("Untitled-1-1.vectorcraft").exists()); let _ = std::fs::remove_dir_all(&dir); } + +/// A store in memory that notes the entries written to it. +#[derive(Default)] +struct Counting { + inner: MemoryStore, + written: std::sync::Mutex>, +} + +impl Counting { + /// The image entries written since the last call, with their sizes. + fn images_written(&self) -> Vec<(String, usize)> { + std::mem::take(&mut *self.written.lock().unwrap()).into_iter().filter(|(n, _)| n.ends_with(".blob")).collect() + } + + fn images(&self) -> Vec { + self.inner.list().unwrap().into_iter().filter(|n| n.ends_with(".blob")).collect() + } +} + +impl RecoveryStore for Counting { + fn list(&self) -> std::result::Result, String> { + self.inner.list() + } + fn read(&self, name: &str) -> std::result::Result, String> { + self.inner.read(name) + } + fn write(&self, name: &str, bytes: &[u8]) -> std::result::Result<(), String> { + self.written.lock().unwrap().push((name.to_string(), bytes.len())); + self.inner.write(name, bytes) + } + fn remove(&self, name: &str) -> std::result::Result<(), String> { + self.inner.remove(name) + } + fn location(&self) -> String { + self.inner.location() + } + fn lock(&self, area: &str) -> std::result::Result { + self.inner.lock(area) + } + fn now(&self) -> Option { + self.inner.now() + } +} + +fn place_png(s: &mut Session, name: &str, rgb: [u8; 3]) -> Vec { + let png = crate::tests_links::png(64, 48, rgb); + s.execute("file.place", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&png), "link": false})).unwrap(); + png +} + +/// The document's images: their bytes by key. +fn images_of(s: &Session) -> std::collections::BTreeMap> { + s.doc().unwrap().doc.images.iter().map(|(k, b)| (k.clone(), b.bytes.to_vec())).collect() +} + +#[test] +fn images_are_kept_apart_and_written_again_only_when_they_change() { + let store = Arc::new(Counting::default()); + let mut s = Session::new(); + s.recovery.set_store(store.clone()); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + let red = place_png(&mut s, "red.png", crate::tests_links::RED); + let r = s.execute("file.recovery.save", &json!({})).unwrap(); + assert_eq!(r["saved"][0]["imageBytes"], red.len(), "{r}"); + assert_eq!(store.images_written().len(), 1); + let area = s.recovery.own_area().unwrap().to_string(); + // The document entry doesn't hold the image. + let doc = store.read(&format!("{area}/Untitled-1-1.vectorcraft")).unwrap(); + assert!(vectorcraft_format::load(&doc).unwrap().images.is_empty()); + // An edit that leaves the image alone: the copy is written again, its image isn't. + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap(); + let r = s.execute("file.recovery.save", &json!({})).unwrap(); + assert_eq!((r["saved"].as_array().unwrap().len(), r["saved"][0]["imageBytes"].as_u64()), (1, Some(0)), "{r}"); + assert!(store.images_written().is_empty(), "the image isn't written again"); + // A new image: only it is written. + let blue = place_png(&mut s, "blue.png", crate::tests_links::BLUE); + let r = s.execute("file.recovery.save", &json!({})).unwrap(); + assert_eq!(r["saved"][0]["imageBytes"], blue.len()); + assert_eq!(store.images_written().iter().map(|(_, n)| *n).collect::>(), [blue.len()]); + assert_eq!(store.images().len(), 2); + let images = images_of(&s); + assert_eq!(images.len(), 2); + + // A crash: the copy restores with its images. + drop(s); + let mut s = Session::new(); + s.recovery.set_store(store.clone()); + assert_eq!(restored(s.execute("file.recovery.restore", &json!({})).unwrap()), 1); + assert_eq!(images_of(&s), images); + let mine = s.recovery.own_area().unwrap().to_string(); + assert!(store.images().iter().all(|n| n.starts_with(&format!("{mine}/"))), "the images moved with the copy, the old area went"); + assert_eq!(store.images().len(), 2); + // Its copy and another document's: once the first is closed, the next copy sweeps its images. + s.execute("file.new", &json!({"width": 50, "height": 50})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 1, "y": 1, "width": 5, "height": 5})).unwrap(); + s.execute("file.close", &json!({"index": 0})).unwrap(); + s.execute("file.recovery.save", &json!({})).unwrap(); + assert!(store.images().is_empty(), "{:?}", store.list()); +} + +#[test] +fn copies_from_before_images_were_kept_apart_still_restore() { + let store = Arc::new(MemoryStore::default()); + // The document entry is a whole native file, images inside; the details say nothing of them. + let mut s = session_in(&store); + place_png(&mut s, "red.png", crate::tests_links::RED); + let d = vectorcraft_doc::Document::clone(&s.doc().unwrap().doc); + let images = images_of(&s); + drop(s); + store.write("1-1/Old-1.vectorcraft", &vectorcraft_format::save_file(&d)).unwrap(); + store.write("1-1/Old-1.json", br#"{"title": "Old", "format": "vectorcraft", "saved": 5}"#).unwrap(); + let mut s = relaunch(&store); + assert_eq!(restored(s.execute("file.recovery.restore", &json!({})).unwrap()), 1); + assert_eq!(images_of(&s), images); + assert_eq!(s.doc().unwrap().title(), "Untitled-1 [Recovered]"); +} diff --git a/crates/engine/src/tests_scalestrokes.rs b/crates/engine/src/tests_scalestrokes.rs index 4f981444..36163600 100644 --- a/crates/engine/src/tests_scalestrokes.rs +++ b/crates/engine/src/tests_scalestrokes.rs @@ -138,8 +138,8 @@ fn group_appearance_scales_with_its_members() { #[test] fn scale_corners_keeps_or_scales_live_corner_radii() { - let radius = |s: &Session, id| match &node(s, id).kind { - NodeKind::Path { live: Some(LiveShape::Rectangle { radii, xf, .. }), .. } => radii[0] * xf.as_coeffs()[0], + let radius = |s: &Session, id| match node(s, id).live_shape() { + Some(LiveShape::Rectangle { radii, xf, .. }) => radii[0] * xf.as_coeffs()[0], _ => panic!("not a live rectangle"), }; let mut s = session(); @@ -175,7 +175,7 @@ fn live_corners_stay_circular_when_a_rectangle_is_scaled_unevenly() { let n = node(&s, id); assert!((n.geometric_bounds().unwrap().width() - 300.0).abs() < 1e-9); assert!(round_corners(&n, 10.0), "{:?}", corner_spans(&n)); - let NodeKind::Path { live: Some(LiveShape::Rectangle { w, h, radii, .. }), .. } = &n.kind else { panic!("not live") }; + let Some(LiveShape::Rectangle { w, h, radii, .. }) = n.live_shape() else { panic!("not live") }; assert!((w - 300.0).abs() < 1e-9 && (h - 100.0).abs() < 1e-9 && (radii[0] - 10.0).abs() < 1e-9, "the panels read document units"); // Scale Corners on: the radius scales by the mean scale (√(2 × 0.5) = 1 here, 2 below). run(&mut s, "object.scale", json!({"sx": 200, "sy": 50, "corners": true})); diff --git a/crates/engine/src/tests_shaper.rs b/crates/engine/src/tests_shaper.rs index 77e8da0a..0ad4be22 100644 --- a/crates/engine/src/tests_shaper.rs +++ b/crates/engine/src/tests_shaper.rs @@ -1,7 +1,7 @@ use super::*; use serde_json::json; use vectorcraft_doc::shaper::{FILL_PREFIX, SOURCES, STROKE_PREFIX}; -use vectorcraft_doc::{LiveShape, Node, NodeKind}; +use vectorcraft_doc::{LiveShape, Node}; use vectorcraft_geom::{Point, Shape as _}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; @@ -134,7 +134,7 @@ fn shaper_erases_only_protruding_stroke_pieces_keeps_fill_and_original_line() { assert!(stroke(&s, g, 225.0, 150.0)); assert_eq!(fill(&s, g, 150.0, 130.0).as_deref(), Some("#ffff00")); assert_eq!(node(&s, line), &original_line); - assert!(matches!(node(&s, circle).kind, NodeKind::Path { live: Some(LiveShape::Ellipse { .. }), .. })); + assert!(matches!(node(&s, circle).live_shape(), Some(LiveShape::Ellipse { .. }))); let g = scribble(&mut s, &[[210.0, 140.0], [220.0, 160.0], [230.0, 140.0], [240.0, 160.0]]); assert!(!stroke(&s, g, 225.0, 150.0)); assert!(stroke(&s, g, 150.0, 150.0)); @@ -152,7 +152,7 @@ fn shaper_source_move_resize_native_roundtrip_and_release_preserve_live_art() { assert_eq!(node(&s, a).geometric_bounds().unwrap().x0, 20.0); s.execute("select.set", &json!({"ids":[a.0]})).unwrap(); s.execute("object.setBounds", &json!({"width":120.0,"height":110.0,"reference":0})).unwrap(); - assert!(matches!(node(&s, a).kind, NodeKind::Path { live: Some(LiveShape::Rectangle { .. }), .. })); + assert!(matches!(node(&s, a).live_shape(), Some(LiveShape::Rectangle { .. }))); let doc = &s.doc().unwrap().doc; let back = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap(); assert_eq!(back.layers, doc.layers); @@ -175,7 +175,7 @@ fn shaper_face_paint_survives_source_edits_and_expand_discards_only_sources() { s.execute("object.move", &json!({"ids":[a.0],"dx":5,"dy":0})).unwrap(); assert_eq!(fill(&s, g, 75.0, 50.0).as_deref(), Some("#00ff00")); s.execute("shaper.expand", &json!({"id":g.0})).unwrap(); - assert!(node(&s, g).shaper.is_none()); + assert!(node(&s, g).extras().shaper.is_none()); assert!(s.doc().unwrap().doc.node(a).is_none()); assert_eq!(fill(&s, g, 75.0, 50.0).as_deref(), Some("#00ff00")); } @@ -211,7 +211,7 @@ fn shaper_lines_keep_free_angles_with_or_without_shift() { s.pointer(&PointerEvent::new(kind, x, y).with_mods(Mods { shift, ..Default::default() }), v).unwrap(); } let n = node(&s, s.doc().unwrap().selection.objects[0]); - assert!(matches!(n.kind,NodeKind::Path{live:Some(LiveShape::Line{a,b}),..} if a==Point::new(30.0,40.0) && b==Point::new(230.0,100.0))); + assert!(matches!(n.live_shape(), Some(LiveShape::Line { a, b }) if *a == Point::new(30.0, 40.0) && *b == Point::new(230.0, 100.0))); } } @@ -258,7 +258,7 @@ fn shaper_tool_trims_both_diagonal_ends_and_only_construction_can_pick_them() { } } let g = s.doc().unwrap().selection.objects[0]; - assert!(node(&s, g).shaper.is_some()); + assert!(node(&s, g).extras().shaper.is_some()); assert_eq!(node(&s, line), &original); assert!(stroke(&s, g, 150.0, 150.0)); assert_eq!(fill(&s, g, 150.0, 130.0).as_deref(), Some("#ffff00")); diff --git a/crates/engine/src/tests_slices.rs b/crates/engine/src/tests_slices.rs index ab0a4c08..24674a1f 100644 --- a/crates/engine/src/tests_slices.rs +++ b/crates/engine/src/tests_slices.rs @@ -64,10 +64,10 @@ fn an_object_slice_follows_its_object_and_releases_back_to_it() { // Make again changes nothing; Release leaves the object. assert_eq!(run(&mut s, "object.slice.make", json!({}))["ids"], json!([])); assert_eq!(run(&mut s, "object.slice.release", json!({}))["ids"], json!([a])); - assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().slice.is_none()); + assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().extras().slice.is_none()); assert!(slices(&mut s).is_empty()); run(&mut s, "edit.undo", json!({})); - assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().slice.is_some()); + assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().extras().slice.is_some()); } #[test] @@ -155,7 +155,7 @@ fn combine_and_duplicate() { let c = run(&mut s, "object.slice.combine", json!({}))["id"].as_u64().unwrap(); assert_eq!(rect_of(&mut s, c), [0.0, 0.0, 60.0, 50.0]); assert_eq!(user_count(&s), 1); - assert!(s.doc().unwrap().doc.node(NodeId(b)).unwrap().slice.is_none(), "the object slice was released"); + assert!(s.doc().unwrap().doc.node(NodeId(b)).unwrap().extras().slice.is_none(), "the object slice was released"); assert_eq!(s.doc().unwrap().selection.slices, vec![NodeId(c)]); let dup = run(&mut s, "object.slice.duplicate", json!({}))["ids"][0].as_u64().unwrap(); assert_eq!(rect_of(&mut s, dup), [10.0, 10.0, 60.0, 50.0]); @@ -191,7 +191,7 @@ fn slice_options_set_read_and_undo_in_one_step() { run(&mut s, "select.set", json!({"ids": [t]})); let o = run(&mut s, "object.slice.options", json!({"kind": "htmlText"})); assert_eq!((o["kind"].clone(), o["htmlText"].clone()), (json!("htmlText"), json!(true))); - assert_eq!(s.doc().unwrap().doc.node(NodeId(t)).unwrap().slice.as_ref().unwrap().kind, SliceKind::HtmlText); + assert_eq!(s.doc().unwrap().doc.node(NodeId(t)).unwrap().extras().slice.as_ref().unwrap().kind, SliceKind::HtmlText); } #[test] @@ -225,7 +225,7 @@ fn clip_to_artboard_hide_lock_select_and_delete_all() { assert_eq!(run(&mut s, "object.slice.deleteAll", json!({}))["count"], 2); assert_eq!(user_count(&s), 0); - assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().slice.is_none()); + assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().extras().slice.is_none()); assert!(s.execute("object.slice.deleteAll", &json!({})).is_err(), "nothing to delete"); } @@ -243,7 +243,7 @@ fn slices_survive_a_native_round_trip() { vectorcraft_testkit::invariants::check_native_roundtrip_exact(&doc).unwrap(); let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap(); assert_eq!(back.slices, doc.slices); - assert_eq!(back.node(NodeId(a)).unwrap().slice.as_ref().unwrap().alt, "logo"); + assert_eq!(back.node(NodeId(a)).unwrap().extras().slice.as_ref().unwrap().alt, "logo"); assert!(!back.slices_clip_to_artboard); // A copy of an object slice is an object slice too, under its own id. run(&mut s, "select.set", json!({"ids": [a]})); diff --git a/crates/engine/src/tests_stylelib.rs b/crates/engine/src/tests_stylelib.rs index 4ab70936..072668a6 100644 --- a/crates/engine/src/tests_stylelib.rs +++ b/crates/engine/src/tests_stylelib.rs @@ -107,7 +107,7 @@ fn add_with_apply_styles_the_selection_in_the_same_undo_step() { assert!((geom.start - Point::new(10.0 + 0.35 * 100.0, 20.0 + 0.3 * 50.0)).hypot() < 1e-6, "{geom:?}"); run(&mut s, "edit.undo", json!({})); assert!(doc(&s).graphic_style("Spotlight").is_none()); - assert_eq!(doc(&s).node(id).unwrap().graphic_style, None); + assert_eq!(doc(&s).node(id).unwrap().extras().graphic_style, None); // Without a selection it only adds; `ids` targets objects; an existing style is applied as is. run(&mut s, "select.none", json!({})); let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "blends-transparency", "name": "Ghosted", "apply": true})); @@ -224,7 +224,7 @@ fn every_builtin_style_paints_visibly_on_a_rectangle() { run(&mut s, "file.new", json!({"width": 100, "height": 100})); let id = rect(&mut s, 25.0, 30.0, 50.0, 40.0); run(&mut s, "graphicStyle.addFromLibrary", json!({"library": b.id, "name": g.name, "apply": true})); - assert_eq!(doc(&s).node(id).unwrap().graphic_style, doc(&s).graphic_style(&g.name).map(|x| x.id), "{}", g.name); + assert_eq!(doc(&s).node(id).unwrap().extras().graphic_style, doc(&s).graphic_style(&g.name).map(|x| x.id), "{}", g.name); let img = renderer.render_region(doc(&s), Rect::new(0.0, 0.0, 100.0, 100.0), 0.5, true); let painted = img.pixels.as_chunks::<4>().0.iter().filter(|p| p[..3] != [255, 255, 255]).count(); assert!(painted > 40, "{} / {} paints {painted} pixels", b.id, g.name); diff --git a/crates/engine/src/tests_styles.rs b/crates/engine/src/tests_styles.rs index bb1643e3..faa05f3a 100644 --- a/crates/engine/src/tests_styles.rs +++ b/crates/engine/src/tests_styles.rs @@ -68,7 +68,7 @@ fn styles_carry_opacity_and_blend_through_a_save() { let doc = s.doc().unwrap().doc.clone(); let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap(); assert_eq!(back.graphic_styles, doc.graphic_styles); - assert_eq!(back.node(b).unwrap().graphic_style, Some(g.id)); + assert_eq!(back.node(b).unwrap().extras().graphic_style, Some(g.id)); // Styles and objects from files without links still load (and get an id when first linked). let old: GraphicStyle = serde_json::from_value(json!({"name": "Old", "appearance": {"items": []}})).unwrap(); assert_eq!((old.id, old.opacity, old.blend, old.isolate), (0, 1.0, BlendMode::Normal, false)); @@ -106,7 +106,7 @@ fn a_style_from_a_group_or_type_is_not_empty() { run(&mut s, "graphicStyle.apply", json!({"name": "Black Outline", "ids": [g.0]})); assert_eq!(node(&s, g).appearance, style(&s, "Black Outline").appearance); assert_eq!(node(&s, a).appearance, before); - assert_eq!(node(&s, g).graphic_style, Some(style(&s, "Black Outline").id)); + assert_eq!(node(&s, g).extras().graphic_style, Some(style(&s, "Black Outline").id)); assert_eq!(linked(&mut s, "Black Outline"), vec![g.0]); assert_ne!(node(&s, c).appearance, style(&s, "Black Outline").appearance); run(&mut s, "graphicStyle.apply", json!({"name": "Black Outline", "ids": [g.0], "target": "contents"})); @@ -130,7 +130,7 @@ fn alt_apply_adds_the_style_on_top() { assert_eq!(after.items[..before.items.len()], before.items[..]); assert_eq!(after.items.last(), rough.appearance.items.last()); assert_eq!(after.effects.len(), before.effects.len() + 1); - assert_eq!(node(&s, b).graphic_style, None); + assert_eq!(node(&s, b).extras().graphic_style, None); // One undo step. run(&mut s, "edit.undo", json!({})); assert_eq!(node(&s, b).appearance, before); @@ -184,7 +184,7 @@ fn redefine_leaves_unlinked_and_edited_objects_alone() { let mut s = session(); let (a, b, c) = linked_three(&mut s); run(&mut s, "graphicStyle.breakLink", json!({"ids": [c.0]})); - assert_eq!(node(&s, c).graphic_style, None); + assert_eq!(node(&s, c).extras().graphic_style, None); let d = rect(&mut s, 300.0); run(&mut s, "graphicStyle.apply", json!({"name": "S"})); set_fill(&mut s, d, "#00ff00"); @@ -194,7 +194,7 @@ fn redefine_leaves_unlinked_and_edited_objects_alone() { assert_eq!(fill(&s, c), Paint::solid(Color::WHITE)); // The edited object keeps its look and is unlinked. assert_eq!(fill(&s, d), Paint::solid(Color::from_hex("#00ff00").unwrap())); - assert_eq!(node(&s, d).graphic_style, None); + assert_eq!(node(&s, d).extras().graphic_style, None); assert!(s.execute("graphicStyle.redefine", &json!({"id": c.0})).is_err()); } @@ -208,7 +208,7 @@ fn deleting_a_style_unlinks_and_renaming_keeps_links() { assert_eq!(run(&mut s, "select.same.graphicStyle", json!({}))["count"], 3); let look = node(&s, c).appearance; run(&mut s, "graphicStyle.delete", json!({"name": "T"})); - assert_eq!((node(&s, b).graphic_style, node(&s, c).graphic_style), (None, None)); + assert_eq!((node(&s, b).extras().graphic_style, node(&s, c).extras().graphic_style), (None, None)); assert_eq!(node(&s, c).appearance, look); run(&mut s, "select.set", json!({"ids": [b.0]})); assert!(s.execute("select.same.graphicStyle", &json!({})).is_err()); diff --git a/crates/engine/src/tests_targeting.rs b/crates/engine/src/tests_targeting.rs index 73e41f78..82abd73e 100644 --- a/crates/engine/src/tests_targeting.rs +++ b/crates/engine/src/tests_targeting.rs @@ -79,7 +79,7 @@ fn a_targeted_layer_gets_an_opacity_mask() { run(&mut s, "layer.target", json!({"id": layer.0})); assert_eq!(run(&mut s, "transparency.makeOpacityMask", json!({}))["id"], json!(layer.0)); run(&mut s, "transparency.stopEditingOpacityMask", json!({})); - assert!(node(&s, layer).mask.is_some()); + assert!(node(&s, layer).extras().mask.is_some()); assert_eq!(s.doc().unwrap().selection.target, Some(layer), "the masked layer stays targeted"); } diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index f61825a5..072a7497 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -1054,7 +1054,7 @@ impl<'a> Reader<'a> { true }); } - if let Some(m) = &mut n.mask { + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { self.reid(Arc::make_mut(&mut m.art)); } if let Some(children) = n.children_mut() { @@ -1437,7 +1437,7 @@ impl<'a> Reader<'a> { self.doc.images.entry(key.clone()).or_insert(blob); let gs = self.gs.clone(); let im = ImageObject { key, width: w as u32, height: h as u32, xf, link: None, placement: Default::default() }; - let mut node = self.new_node(NodeKind::Image(im), &gs); + let mut node = self.new_node(NodeKind::Image(Box::new(im)), &gs); node.opacity = gs.opacity; node.blend = gs.blend; self.add(node, false) diff --git a/crates/eps/src/import/data.rs b/crates/eps/src/import/data.rs index ec7c11dc..fa05ee13 100644 --- a/crates/eps/src/import/data.rs +++ b/crates/eps/src/import/data.rs @@ -973,7 +973,7 @@ impl Interp<'_> { self.out.doc.images.entry(key.clone()).or_insert(blob); let im = ImageObject { key, width: img.w, height: img.h, xf, link: None, placement: Default::default() }; let clips = self.g.clips.clone(); - self.out.push(Node::new(NodeId(0), NodeKind::Image(im)), &clips); + self.out.push(Node::new(NodeId(0), NodeKind::Image(Box::new(im))), &clips); Ok(()) } } diff --git a/crates/eps/src/import/preview.rs b/crates/eps/src/import/preview.rs index b19acfdb..046043c3 100644 --- a/crates/eps/src/import/preview.rs +++ b/crates/eps/src/import/preview.rs @@ -48,7 +48,8 @@ fn image_doc(img: image::RgbaImage, frame: Rect) -> Option { let key = blob.content_key(); doc.images.insert(key.clone(), blob); let xf = Affine::scale_non_uniform(frame.width() / f64::from(w), frame.height() / f64::from(h)); - let mut n = Node::new(doc.alloc_id(), NodeKind::Image(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() })); + let mut n = + Node::new(doc.alloc_id(), NodeKind::Image(Box::new(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() }))); n.name = Some("Preview".into()); let layer = doc.layers.first().map(|l| l.id)?; doc.insert(Some(layer), 0, n).ok()?; diff --git a/crates/eps/src/import/tests.rs b/crates/eps/src/import/tests.rs index 8408d030..77a02bb2 100644 --- a/crates/eps/src/import/tests.rs +++ b/crates/eps/src/import/tests.rs @@ -472,7 +472,7 @@ fn rich_doc() -> Document { link: None, placement: Default::default(), }; - let n = Node::new(d.alloc_id(), NodeKind::Image(im)); + let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); add(&mut d, n); d.swatches.push(Swatch { name: "Spot".into(), paint: Paint::solid(Color::cmyk(0.0, 0.5, 1.0, 0.0)), global: true, spot: true }); let spot = Paint::Solid { color: Color::cmyk(0.0, 0.5, 1.0, 0.0), swatch: Some("Spot".into()), tint: 1.0 }; diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index 99f4012a..54139c99 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -322,7 +322,7 @@ impl<'a> Scene<'a> { /// Warn about what EPS can't hold in `n`'s look. fn note_losses(&mut self, n: &Node) { - if n.opacity < 1.0 || n.blend != BlendMode::Normal || n.mask.as_ref().is_some_and(|m| !m.disabled) { + if n.opacity < 1.0 || n.blend != BlendMode::Normal || n.extras().mask.as_ref().is_some_and(|m| !m.disabled) { self.warn(TRANSPARENCY); } let raster = |fx: &[Effect]| fx.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)); diff --git a/crates/eps/src/tests.rs b/crates/eps/src/tests.rs index 567cdce9..b1d1cf9b 100644 --- a/crates/eps/src/tests.rs +++ b/crates/eps/src/tests.rs @@ -269,7 +269,7 @@ fn png_doc() -> Document { link: None, placement: Default::default(), }; - let n = Node::new(d.alloc_id(), NodeKind::Image(im)); + let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); let layer = d.layers[0].id; d.insert(Some(layer), 0, n).unwrap(); d diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index b0a7e3f3..bf859b73 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -605,7 +605,7 @@ mod tests { // Only images in use are saved. let img = d.alloc_id(); let im = ImageObject { key: "k".into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default() }; - d.insert(Some(l), 1, Node::new(img, NodeKind::Image(im))).unwrap(); + d.insert(Some(l), 1, Node::new(img, NodeKind::Image(Box::new(im)))).unwrap(); let bytes = save(&d, true); assert!(sniff(&bytes)); let back = load(&bytes).unwrap(); diff --git a/crates/format/tests/links.rs b/crates/format/tests/links.rs index e65685ef..8b8fff3a 100644 --- a/crates/format/tests/links.rs +++ b/crates/format/tests/links.rs @@ -13,7 +13,7 @@ use vectorcraft_geom::Affine; fn image(d: &mut Document, key: &str, link: Option) { let (id, layer) = (d.alloc_id(), d.layers[0].id); let im = ImageObject { key: key.into(), width: 600, height: 300, xf: Affine::IDENTITY, link, placement: Default::default() }; - d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(im))).unwrap(); + d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); } fn blob(full: &[u8], proxy: Option<&[u8]>) -> ImageBlob { diff --git a/crates/format/tests/native_v3.rs b/crates/format/tests/native_v3.rs index 7e643de4..fcbc43d3 100644 --- a/crates/format/tests/native_v3.rs +++ b/crates/format/tests/native_v3.rs @@ -35,7 +35,14 @@ fn image_node(d: &mut Document, key: &str) -> Node { let id = d.alloc_id(); Node::new( id, - NodeKind::Image(ImageObject { key: key.into(), width: 2, height: 2, xf: Affine::IDENTITY, link: None, placement: Default::default() }), + NodeKind::Image(Box::new(ImageObject { + key: key.into(), + width: 2, + height: 2, + xf: Affine::IDENTITY, + link: None, + placement: Default::default(), + })), ) } @@ -120,7 +127,7 @@ fn unused_images_are_pruned() { d.images.insert(k.into(), blob(b)); } let mut n = image_node(&mut d, "used"); - n.mask = Some(Box::new(OpacityMask::new(image_node(&mut d, "in mask"), false))); + n.extras_mut().mask = Some(Box::new(OpacityMask::new(image_node(&mut d, "in mask"), false))); d.insert(Some(layer), 0, n).unwrap(); let art = image_node(&mut d, "in symbol"); d.symbols.push(Symbol { name: "S".into(), art: Arc::new(art) }); diff --git a/crates/format/tests/native_v4.rs b/crates/format/tests/native_v4.rs index 34f6c6a8..57f29c27 100644 --- a/crates/format/tests/native_v4.rs +++ b/crates/format/tests/native_v4.rs @@ -18,7 +18,7 @@ fn doc_with_image(key: &str, len: usize) -> Document { let l = d.layers[0].id; let id = d.alloc_id(); let im = ImageObject { key: key.into(), width: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default() }; - d.insert(Some(l), 0, Node::new(id, NodeKind::Image(im))).unwrap(); + d.insert(Some(l), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); d } diff --git a/crates/format/tests/prop_format.rs b/crates/format/tests/prop_format.rs index 67159d78..365af85f 100644 --- a/crates/format/tests/prop_format.rs +++ b/crates/format/tests/prop_format.rs @@ -76,7 +76,7 @@ proptest! { let layer = d.layers[0].id; let id = d.alloc_id(); let im = ImageObject { key: key.clone(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default() }; - d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(im))).unwrap(); + d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); for pretty in [false, true] { let back = load(&save(&d, pretty)).unwrap(); prop_assert_eq!(back.images.get(&key).map(|b| b.bytes.as_ref().clone()), Some(data.clone())); diff --git a/crates/format/tests/save_options.rs b/crates/format/tests/save_options.rs index 766f4f67..44920d0b 100644 --- a/crates/format/tests/save_options.rs +++ b/crates/format/tests/save_options.rs @@ -29,7 +29,7 @@ fn linked_doc() -> Document { link: Some(LinkInfo::new("/art/photo.png")), placement: Default::default(), }; - d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(im))).unwrap(); + d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); let mut blob = ImageBlob::new("image/png", vec![1, 2, 3, 4, 5, 6]); blob.proxy = Some(Arc::new(vec![9, 9])); d.images.insert("k".into(), blob); diff --git a/crates/metafile/src/import.rs b/crates/metafile/src/import.rs index bee9d0a0..047a59f5 100644 --- a/crates/metafile/src/import.rs +++ b/crates/metafile/src/import.rs @@ -789,7 +789,7 @@ impl Player { return; } let im = ImageObject { key, width: img.width, height: img.height, xf, link: None, placement: Default::default() }; - let mut n = Node::new(vectorcraft_doc::NodeId(0), NodeKind::Image(im)); + let mut n = Node::new(vectorcraft_doc::NodeId(0), NodeKind::Image(Box::new(im))); n.opacity = opacity.clamp(0.0, 1.0) as f32; self.push(n); } diff --git a/crates/metafile/src/scene.rs b/crates/metafile/src/scene.rs index b19545b6..265cf66b 100644 --- a/crates/metafile/src/scene.rs +++ b/crates/metafile/src/scene.rs @@ -236,7 +236,7 @@ impl<'a> Scene<'a> { if n.blend != BlendMode::Normal { self.warn(BLENDING); } - if n.mask.as_ref().is_some_and(|m| !m.disabled) { + if n.extras().mask.as_ref().is_some_and(|m| !m.disabled) { self.warn(MASKS); } let raster = |fx: &[vectorcraft_doc::Effect]| fx.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)); diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index 878906ad..27d14fe1 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -49,7 +49,7 @@ pub(crate) fn image_node(d: &mut Document, bytes: Vec, w: u32, h: u32, xf: A let blob = ImageBlob::new("image/png", bytes); let key = blob.content_key(); d.images.insert(key.clone(), blob); - Node::new(NodeId(0), NodeKind::Image(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() })) + Node::new(NodeId(0), NodeKind::Image(Box::new(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() }))) } pub(crate) fn u32_at(b: &[u8], at: usize) -> u32 { diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index d0ed2471..15a1e213 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -880,7 +880,13 @@ impl Exporter<'_> { /// A luminosity mask that is 1 − the knockout shape of `c`: white, then black through an alpha /// mask of `c` (at full object opacity without its own mask, unless those define its shape). fn knockout_mask(&mut self, s: &mut Surface, c: &Node, page: Rect) -> krilla::mask::Mask { - let shape = if c.knockout_shape { c.clone() } else { Node { opacity: 1.0, mask: None, ..c.clone() } }; + let shape = if c.knockout_shape { + c.clone() + } else { + let mut s = c.clone().with_mask(None); + s.opacity = 1.0; + s + }; let mut sb = s.stream_builder(); let mut ms = sb.surface(); cover(&mut ms, page, 255, 1.0); @@ -948,7 +954,7 @@ impl Exporter<'_> { } pushes += 1; } - if let Some(m) = n.mask.as_deref() + if let Some(m) = n.extras().mask.as_deref() && !m.disabled { let mask = self.soft_mask(s, m, page); diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index d61e06f8..a65a42d6 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -1016,17 +1016,17 @@ impl<'p> Builder<'p> { MaskSpec::Mask(m) => { let m = self.fresh_mask(m.clone()); let node = match <[Arc; 1]>::try_from(art) { - Ok([one]) if one.mask.is_none() => { - fold_stop_opacity(&one, &m).unwrap_or_else(|| Node { mask: Some(Box::new(m)), ..Arc::unwrap_or_clone(one) }) + Ok([one]) if one.extras().mask.is_none() => { + fold_stop_opacity(&one, &m).unwrap_or_else(|| Arc::unwrap_or_clone(one).with_mask(Some(Box::new(m)))) } Ok([one]) => { let mut g = Node::group(self.id(), vec![one]); - g.mask = Some(Box::new(m)); + g.extras_mut().mask = Some(Box::new(m)); g } Err(all) => { let mut g = Node::group(self.id(), all); - g.mask = Some(Box::new(m)); + g.extras_mut().mask = Some(Box::new(m)); g } }; @@ -1089,7 +1089,7 @@ impl<'p> Builder<'p> { && !only.is_container() && !isolate && !knockout - && (mask.is_none() || only.mask.is_none()) + && (mask.is_none() || only.extras().mask.is_none()) { // A single object in a group: fold opacity, blend and mask into the object. let mut only = (**only).clone(); @@ -1099,7 +1099,7 @@ impl<'p> Builder<'p> { } if let Some(m) = mask { let m = self.fresh_mask(m); - only = fold_stop_opacity(&only, &m).unwrap_or(Node { mask: Some(Box::new(m)), ..only }); + only = fold_stop_opacity(&only, &m).unwrap_or_else(|| only.with_mask(Some(Box::new(m)))); } Some(only) } else { @@ -1108,7 +1108,7 @@ impl<'p> Builder<'p> { if knockout { g.knockout = Knockout::On; } - g.mask = mask.map(|m| Box::new(self.fresh_mask(m))); + g.extras_mut().mask = mask.map(|m| Box::new(self.fresh_mask(m))); Some(g) } } @@ -1289,7 +1289,10 @@ impl<'p> Builder<'p> { (k, a.width, a.height) }; let xf = transform * Affine::scale_non_uniform(f64::from(w) / f64::from(mw.max(1)), f64::from(h) / f64::from(mh.max(1))); - let art = Node::new(self.id(), NodeKind::Image(ImageObject { key: k, width: mw, height: mh, xf, link: None, placement: Default::default() })); + let art = Node::new( + self.id(), + NodeKind::Image(Box::new(ImageObject { key: k, width: mw, height: mh, xf, link: None, placement: Default::default() })), + ); Some(OpacityMask::new(art, true)) } @@ -1308,8 +1311,9 @@ impl<'p> Builder<'p> { (k, w, h) }; let id = self.id(); - let mut n = Node::new(id, NodeKind::Image(ImageObject { key: k, width: w, height: h, xf, link: None, placement: Default::default() })); - n.mask = mask.map(Box::new); + let mut n = + Node::new(id, NodeKind::Image(Box::new(ImageObject { key: k, width: w, height: h, xf, link: None, placement: Default::default() }))); + n.extras_mut().mask = mask.map(Box::new); self.push_node(n); } diff --git a/crates/pdf/src/import_lines.rs b/crates/pdf/src/import_lines.rs index 36cf5ea9..15294ad3 100644 --- a/crates/pdf/src/import_lines.rs +++ b/crates/pdf/src/import_lines.rs @@ -45,7 +45,7 @@ fn outline_stroke(nodes: &[Arc], ink: &[Rect]) -> Option { let NodeKind::Path { path, clipping: false, guide: false, live: None, .. } = &n.kind else { return false }; let b = path.to_bezpath().bounding_box(); n.appearance == first.appearance - && n.mask.is_none() + && n.extras().mask.is_none() && n.opacity >= 0.999 && n.blend == BlendMode::Normal && [(b.x0, r.x0), (b.y0, r.y0), (b.x1, r.x1), (b.y1, r.y1)].iter().all(|(a, b)| (a - b).abs() <= tol) @@ -87,7 +87,7 @@ fn join_strokes(nodes: &mut Vec>, lines: &HashMap) /// `n` as a line that can be part of area type: horizontal point type, with what its line told. fn line<'a>(n: &'a Node, lines: &'a HashMap) -> Option<(&'a TextObject, &'a LineFacts)> { let NodeKind::Text(t) = &n.kind else { return None }; - let point = matches!(t.kind, TextKind::Point) && !t.vertical && n.mask.is_none() && !t.runs.is_empty(); + let point = matches!(t.kind, TextKind::Point) && !t.vertical && n.extras().mask.is_none() && !t.runs.is_empty(); point.then_some(())?; Some((t, lines.get(&n.id)?)) } diff --git a/crates/pdf/src/import_mask.rs b/crates/pdf/src/import_mask.rs index eda4bdb4..9bdea94d 100644 --- a/crates/pdf/src/import_mask.rs +++ b/crates/pdf/src/import_mask.rs @@ -39,7 +39,7 @@ fn cover(n: &Node, page: Rect) -> Option<(Color, f32)> { let Paint::Solid { color, .. } = &f.paint else { return None }; let b = path.to_bezpath(); let covers = page.area() > 1.0 && contains(b.bounding_box(), page.inset(-0.5)) && is_rectangle(&b); - (covers && n.mask.is_none()).then_some((*color, f.opacity * n.opacity)) + (covers && n.extras().mask.is_none()).then_some((*color, f.opacity * n.opacity)) } /// An opaque white rectangle covering `page`, filled only. diff --git a/crates/pdf/src/import_shading.rs b/crates/pdf/src/import_shading.rs index b3356e6c..6330184e 100644 --- a/crates/pdf/src/import_shading.rs +++ b/crates/pdf/src/import_shading.rs @@ -195,7 +195,7 @@ fn only_leaf(n: &Node) -> Option<&Node> { NodeKind::Group { children, clip } => { let rest = if *clip { children.get(1..)? } else { children.as_slice() }; match rest { - [one] if n.opacity >= 0.999 && n.mask.is_none() => only_leaf(one), + [one] if n.opacity >= 0.999 && n.extras().mask.is_none() => only_leaf(one), _ => None, } } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 99c69e7d..f80b7dd8 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -378,7 +378,7 @@ fn image_roundtrip() { link: None, placement: Default::default(), }; - add(&mut d, Node::new(NodeId(0), NodeKind::Image(im))); + add(&mut d, Node::new(NodeId(0), NodeKind::Image(Box::new(im)))); let out = roundtrip(&d); let l = leaves(&out); assert_eq!(l.len(), 1); @@ -606,7 +606,7 @@ fn opacity_mask_exports_as_luminosity_soft_mask() { let mut d = doc(100.0, 100.0); let mut n = rect_node(Rect::new(10.0, 10.0, 90.0, 90.0), Color::rgb(1.0, 0.0, 0.0)); let art = rect_node(Rect::new(10.0, 10.0, 50.0, 90.0), Color::WHITE); - n.mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(art, true))); + n.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(art, true))); add(&mut d, n); let bytes = export(&d, &PdfOptions::uncompressed()).unwrap(); let text = String::from_utf8_lossy(&bytes); diff --git a/crates/pdf/src/tests_cmykblend.rs b/crates/pdf/src/tests_cmykblend.rs index c450b7e0..f76d9081 100644 --- a/crates/pdf/src/tests_cmykblend.rs +++ b/crates/pdf/src/tests_cmykblend.rs @@ -70,7 +70,7 @@ fn rgb_documents_and_opaque_cmyk_pages_are_unchanged() { fn soft_mask_groups_keep_screen_luminance() { let mut d = Document::new_with_mode(100.0, 100.0, ColorMode::Cmyk); let mut n = rect(Rect::new(0.0, 0.0, 100.0, 100.0), Color::cmyk(0.0, 0.0, 0.0, 1.0)); - n.mask = Some(Box::new(OpacityMask::new(rect(Rect::new(0.0, 0.0, 50.0, 100.0), Color::WHITE), true))); + n.extras_mut().mask = Some(Box::new(OpacityMask::new(rect(Rect::new(0.0, 0.0, 50.0, 100.0), Color::WHITE), true))); add(&mut d, n); let t = text(&d); // The page's group blends in CMYK; the mask's group measures luminance in RGB. diff --git a/crates/pdf/src/tests_cmykimages.rs b/crates/pdf/src/tests_cmykimages.rs index 7ca5acfe..cfbced73 100644 --- a/crates/pdf/src/tests_cmykimages.rs +++ b/crates/pdf/src/tests_cmykimages.rs @@ -229,7 +229,7 @@ fn masked(d: &Document) -> Vec<(&ImageBlob, Option>)> { .iter() .filter_map(|n| { let NodeKind::Image(im) = &n.kind else { return None }; - let mask = n.mask.as_ref().map(|m| match &m.art.kind { + let mask = n.extras().mask.as_ref().map(|m| match &m.art.kind { NodeKind::Image(a) => (&d.images[&a.key], a.width, a.height), other => panic!("mask art is an image: {other:?}"), }); @@ -266,7 +266,7 @@ fn masked_cmyk_images_keep_their_inks_and_carry_the_mask_as_an_opacity_mask() { // The mask art covers the image exactly. let NodeKind::Layer { children, .. } = &r.document.layers[0].kind else { panic!() }; let img = children.iter().find(|n| matches!(n.kind, NodeKind::Image(_))).unwrap(); - assert_eq!(img.geometric_bounds(), img.mask.as_ref().unwrap().art.geometric_bounds()); + assert_eq!(img.geometric_bounds(), img.extras().mask.as_ref().unwrap().art.geometric_bounds()); // Written as CMYK with the mask in grey; it opens with the same inks. let out = export_with_report(&r.document, &opts(json!({}))).unwrap(); let w = written(&out.bytes); @@ -291,7 +291,7 @@ fn all_images(d: &Document) -> Vec<&ImageBlob> { if let NodeKind::Image(im) = &n.kind { keys.push(im.key.clone()); } - if let Some(m) = &n.mask { + if let Some(m) = &n.extras().mask { m.art.walk(&mut |a| { if let NodeKind::Image(im) = &a.kind { keys.push(im.key.clone()); @@ -307,7 +307,7 @@ fn image_doc(blob: ImageBlob, w: u32, h: u32) -> Document { let mut d = Document::new_with_mode(100.0, 100.0, ColorMode::Cmyk); d.images.insert("img".into(), blob); let image = ImageObject { key: "img".into(), width: w, height: h, xf: Affine::scale(40.0 / w as f64), link: None, placement: Default::default() }; - let n = Node::new(d.alloc_id(), NodeKind::Image(image)); + let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image))); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); d diff --git a/crates/pdf/src/tests_compression.rs b/crates/pdf/src/tests_compression.rs index 596984cc..2954f91c 100644 --- a/crates/pdf/src/tests_compression.rs +++ b/crates/pdf/src/tests_compression.rs @@ -29,7 +29,7 @@ fn doc(bytes: Vec, mime: &str) -> Document { let xf = Affine::scale(72.0 / 600.0); let n = Node::new( d.alloc_id(), - NodeKind::Image(ImageObject { key: "img".into(), width: 600, height: 600, xf, link: None, placement: Default::default() }), + NodeKind::Image(Box::new(ImageObject { key: "img".into(), width: 600, height: 600, xf, link: None, placement: Default::default() })), ); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); diff --git a/crates/pdf/src/tests_import_fidelity.rs b/crates/pdf/src/tests_import_fidelity.rs index 0721fef3..a05176f2 100644 --- a/crates/pdf/src/tests_import_fidelity.rs +++ b/crates/pdf/src/tests_import_fidelity.rs @@ -72,8 +72,8 @@ fn masked(smask: &str, mask: &str) -> Vec { } fn the_mask(d: &Document) -> (Node, OpacityMask) { - let n = all(d).into_iter().find(|n| n.mask.is_some()).expect("a masked object"); - let m = n.mask.as_deref().unwrap().clone(); + let n = all(d).into_iter().find(|n| n.extras().mask.is_some()).expect("a masked object"); + let m = n.extras().mask.as_deref().unwrap().clone(); (n, m) } @@ -121,7 +121,7 @@ fn opacity_masks_round_trip_through_pdf() { let mut d = Document::new(100.0, 100.0); let mut n = rect(Rect::new(10.0, 10.0, 90.0, 90.0), Color::rgb(1.0, 0.0, 0.0)); let art = rect(Rect::new(10.0, 10.0, 50.0, 90.0), Color::gray(0.25)); - n.mask = Some(Box::new(OpacityMask { invert, ..OpacityMask::new(art, clip) })); + n.extras_mut().mask = Some(Box::new(OpacityMask { invert, ..OpacityMask::new(art, clip) })); add(&mut d, n); let back = roundtrip(&d); let (n, m) = the_mask(&back); @@ -152,14 +152,14 @@ fn isolated_and_non_isolated_groups_round_trip() { let back = roundtrip(&half_group(true)); let groups: Vec = all(&back).into_iter().filter(|n| matches!(n.kind, NodeKind::Group { clip: false, .. })).collect(); let [g] = groups.as_slice() else { panic!("one group: {groups:?}") }; - assert!(g.isolate && g.mask.is_none()); + assert!(g.isolate && g.extras().mask.is_none()); assert!((g.opacity - 0.5).abs() < 0.01, "{}", g.opacity); assert_eq!(g.children().unwrap()[0].blend, BlendMode::Multiply); // Not isolated: its 50% (a constant alpha mask in the file) is opacity again, here on the // only object inside, which blends with the backdrop as before. let back = roundtrip(&half_group(false)); let nodes = all(&back); - assert!(nodes.iter().all(|n| !n.isolate && n.mask.is_none())); + assert!(nodes.iter().all(|n| !n.isolate && n.extras().mask.is_none())); let m = nodes.iter().find(|n| n.blend == BlendMode::Multiply).unwrap(); let opacity: f32 = nodes.iter().filter(|n| n.children().is_some_and(|c| c.iter().any(|c| c.id == m.id))).map(|g| g.opacity).product::() * m.opacity; @@ -320,7 +320,7 @@ fn gradient_stop_opacity_round_trips() { add(&mut d, n); let back = roundtrip(&d); let nodes = all(&back); - assert!(nodes.iter().all(|n| n.mask.is_none()), "the opacity is in the stops, not a mask"); + assert!(nodes.iter().all(|n| n.extras().mask.is_none()), "the opacity is in the stops, not a mask"); let g = nodes.iter().find_map(|n| match n.appearance.fill().map(|f| &f.paint) { Some(Paint::Gradient(g)) => Some(g.gradient.clone()), _ => None, @@ -358,7 +358,7 @@ fn appearance_items_are_untouched_for_plain_art() { let d = open(&one_page("q 1 0 0 rg 10 10 30 30 re f Q 0 0 1 RG 2 w 50 50 30 30 re S", "", &[])); let nodes = all(&d); assert_eq!(nodes.iter().filter(|n| n.path_data().is_some()).count(), 2); - assert!(nodes.iter().all(|n| n.mask.is_none() && !matches!(n.kind, NodeKind::Group { .. }))); + assert!(nodes.iter().all(|n| n.extras().mask.is_none() && !matches!(n.kind, NodeKind::Group { .. }))); assert!(matches!(nodes.iter().find(|n| n.appearance.stroke().is_some()).unwrap().appearance.items[0], AppearanceItem::Stroke(_))); } diff --git a/crates/pdf/src/tests_output.rs b/crates/pdf/src/tests_output.rs index 442b4290..3413816a 100644 --- a/crates/pdf/src/tests_output.rs +++ b/crates/pdf/src/tests_output.rs @@ -198,7 +198,7 @@ fn image_doc(bytes: Vec) -> Document { let mut d = Document::new(100.0, 100.0); d.images.insert("img".into(), ImageBlob::new("image/png", bytes)); let image = ImageObject { key: "img".into(), width: 4, height: 4, xf: Affine::scale(10.0), link: None, placement: Default::default() }; - let n = Node::new(d.alloc_id(), NodeKind::Image(image)); + let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image))); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); d diff --git a/crates/pdf/src/tests_pdfx.rs b/crates/pdf/src/tests_pdfx.rs index dd4a48dd..d7dbee69 100644 --- a/crates/pdf/src/tests_pdfx.rs +++ b/crates/pdf/src/tests_pdfx.rs @@ -123,7 +123,7 @@ fn pdfx_1a_files_have_no_rgb() { link: None, placement: Default::default(), }; - let n = Node::new(d.alloc_id(), NodeKind::Image(im)); + let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); d.insert(Some(l), 0, n).unwrap(); let t = text(&export(&d, &opts(Standard::PdfX1a, |_| {})).unwrap()); for no in ["/DeviceRGB", "/ICCBased", "/Lab", "/CalRGB"] { diff --git a/crates/pdf/src/tests_webview.rs b/crates/pdf/src/tests_webview.rs index d4dd3611..dcee6baa 100644 --- a/crates/pdf/src/tests_webview.rs +++ b/crates/pdf/src/tests_webview.rs @@ -49,7 +49,7 @@ fn doc(n: usize, image: bool, on_first: bool) -> Document { let xf = Affine::translate((x + 60.0, 60.0)) * Affine::scale(4.0); let img = ImageObject { key: "img".into(), width: 8, height: 8, xf, link: None, placement: Default::default() }; let id = d.alloc_id(); - d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(img))).unwrap(); + d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(img)))).unwrap(); } } d diff --git a/crates/render/src/encode/psd.rs b/crates/render/src/encode/psd.rs index 304e53a5..f03e7378 100644 --- a/crates/render/src/encode/psd.rs +++ b/crates/render/src/encode/psd.rs @@ -520,7 +520,7 @@ impl Parts<'_> { /// Can layer `n` be written as a group of its parts without changing its look? fn splits(n: &Node) -> bool { - matches!(n.kind, NodeKind::Layer { clip: false, .. }) && n.mask.is_none() && n.knockout != Knockout::On && !fx::has_object_fx(n) + matches!(n.kind, NodeKind::Layer { clip: false, .. }) && n.extras().mask.is_none() && n.knockout != Knockout::On && !fx::has_object_fx(n) } /// Does anything in `n` carry effects of its own that may paint outside the art's bounds? diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index db417efd..276accfe 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -505,16 +505,14 @@ impl Renderer { // A path's geometry effects still apply when it is drawn. let path = matches!(art.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }); art.appearance.effects.retain(|e| path && effects::is_geometry(&e.id)); - let art = Arc::new(Node { - opacity: 1.0, - blend: Default::default(), - isolate: false, - // One element: its pieces never knock each other out (a group's members still do as - // its knockout setting says). - knockout: if container { a.knockout } else { vectorcraft_doc::Knockout::Off }, - mask: None, - ..art - }); + let mut art = art.with_mask(None); + art.opacity = 1.0; + art.blend = Default::default(); + art.isolate = false; + // One element: its pieces never knock each other out (a group's members still do as its + // knockout setting says). + art.knockout = if container { a.knockout } else { vectorcraft_doc::Knockout::Off }; + let art = Arc::new(art); if cache { let bytes = crate::lru::node_bytes(&art); self.fx_arts.insert(key, (a.clone(), art.clone()), bytes, self.stamp); diff --git a/crates/render/src/ink.rs b/crates/render/src/ink.rs index 48e0fe49..6e86e1d5 100644 --- a/crates/render/src/ink.rs +++ b/crates/render/src/ink.rs @@ -327,7 +327,7 @@ mod tests { d.images.insert("img".into(), ImageBlob::cmyk_tiff(&Inks::new(2, 2, ink.repeat(4)).unwrap()).unwrap()); let l = d.layers[0].id; let im = ImageObject { key: "img".into(), width: 2, height: 2, xf: Affine::scale(5.0), link: None, placement: Default::default() }; - let n = Node::new(d.alloc_id(), NodeKind::Image(im)); + let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); d.insert(Some(l), 0, n).unwrap(); // The same inks as a fill beside it. let fill = Color::cmyk(0.0, 0.4, 1.0, 25.0 / 255.0); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index b62f06ce..5410ddd7 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -552,7 +552,7 @@ impl Renderer { let frame = Frame { mt: threads > 0, doc, view, visible, px, opts, ink: Ink::Display }; self.begin_frame(); // View Opacity Mask: the mask's coverage in greyscale instead of the artwork. - if let Some(m) = opts.mask_view.and_then(|id| doc.node(id)).and_then(|n| n.mask.as_deref()) { + if let Some(m) = opts.mask_view.and_then(|id| doc.node(id)).and_then(|n| n.extras().mask.as_deref()) { let pixels = self.mask_values(&frame, m, w, h).into_iter().flat_map(|v| [v, v, v, 255]).collect(); self.end_frame(); self.stats.micros = now().saturating_sub(start); @@ -790,7 +790,7 @@ impl Renderer { NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } if !fx::has_object_fx(a) => { let mut acc: Option = None; // An Image Trace object's hidden source image shows in some of its views. - let source = a.trace.is_some().then(|| children.first()).flatten(); + let source = a.extras().trace.is_some().then(|| children.first()).flatten(); for c in children { if c.visible || source.is_some_and(|s| Arc::ptr_eq(s, c)) { acc = vectorcraft_geom::union_opt(acc, self.bounds_of(c)); @@ -873,7 +873,7 @@ impl Renderer { if self.skipped(f, a) { return; } - if let Some(m) = a.mask.as_deref() + if let Some(m) = a.extras().mask.as_deref() && !m.disabled && !f.opts.outline && !self.is_shape(a) diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index b94ce1f3..70533ae8 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -221,7 +221,7 @@ fn masked_doc(clip: bool, invert: bool) -> Document { let art = Node::group(gid, vec![Arc::new(white), Arc::new(grey)]); let mut m = vectorcraft_doc::OpacityMask::new(art, clip); m.invert = invert; - n.mask = Some(Box::new(m)); + n.extras_mut().mask = Some(Box::new(m)); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); d @@ -256,7 +256,7 @@ fn disabled_or_outline_mask_draws_unmasked() { let mut d = masked_doc(true, false); let id = d.layers[0].children().unwrap()[0].id; let n = d.node_mut(id).unwrap(); - n.mask.as_mut().unwrap().disabled = true; + n.extras_mut().mask.as_mut().unwrap().disabled = true; let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts); assert_eq!(img.pixel(80, 50), [255, 0, 0, 255]); @@ -376,7 +376,7 @@ fn images_take_their_nearest_pixel_without_smoothing() { d.images.insert("bw".into(), ImageBlob::new("image/png", png)); let id = d.alloc_id(); let im = ImageObject { key: "bw".into(), width: 2, height: 1, xf: Affine::scale(20.0), link: None, placement: Default::default() }; - d.insert(Some(d.layers[0].id), 0, Node::new(id, NodeKind::Image(im))).unwrap(); + d.insert(Some(d.layers[0].id), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); // How many pixels across the image are neither black nor white. let greys = |smooth_images| { let r = Renderer::new().render(&d, 40, 20, Affine::IDENTITY, &RenderOptions { smooth_images, ..Default::default() }); diff --git a/crates/render/src/tests_adjust.rs b/crates/render/src/tests_adjust.rs index 8e5690a6..5ff5a35d 100644 --- a/crates/render/src/tests_adjust.rs +++ b/crates/render/src/tests_adjust.rs @@ -86,7 +86,14 @@ fn an_embedded_image_is_recoloured_and_the_original_pixels_stay() { d.images.insert(key.clone(), blob.clone()); let mut n = Node::new( NodeId(0), - NodeKind::Image(ImageObject { key, width: 30, height: 30, xf: Affine::translate((20.0, 20.0)), link: None, placement: Default::default() }), + NodeKind::Image(Box::new(ImageObject { + key, + width: 30, + height: 30, + xf: Affine::translate((20.0, 20.0)), + link: None, + placement: Default::default(), + })), ); n.appearance.effects.push(hue()); d = { diff --git a/crates/render/src/tests_cmykblend.rs b/crates/render/src/tests_cmykblend.rs index 1eda1150..63b765a4 100644 --- a/crates/render/src/tests_cmykblend.rs +++ b/crates/render/src/tests_cmykblend.rs @@ -99,7 +99,7 @@ fn opacity_masks_take_screen_luminance() { let mut d = Document::new_with_mode(20.0, 20.0, ColorMode::Cmyk); let mut n = rect(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0), Color::cmyk(0.0, 0.0, 0.0, 1.0)); let art = rect(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0), CYAN); - n.mask = Some(Box::new(OpacityMask::new(art, true))); + n.extras_mut().mask = Some(Box::new(OpacityMask::new(art, true))); add(&mut d, n); let [r, g, b] = CYAN.to_rgb(); let cover = MASK_LUM[0] * r + MASK_LUM[1] * g + MASK_LUM[2] * b; @@ -116,7 +116,14 @@ fn placed_images_are_separated_not_blackened() { d.images.insert("red".into(), ImageBlob::new("image/png", png)); let n = Node::new( d.alloc_id(), - NodeKind::Image(ImageObject { key: "red".into(), width: 4, height: 4, xf: Affine::IDENTITY, link: None, placement: Default::default() }), + NodeKind::Image(Box::new(ImageObject { + key: "red".into(), + width: 4, + height: 4, + xf: Affine::IDENTITY, + link: None, + placement: Default::default(), + })), ); add(&mut d, n); let got = Renderer::new().render_region(&d, d.artboards[0].rect, 1.0, true).pixel(1, 1); diff --git a/crates/render/src/tests_isolation.rs b/crates/render/src/tests_isolation.rs index 138bb14b..b38ee470 100644 --- a/crates/render/src/tests_isolation.rs +++ b/crates/render/src/tests_isolation.rs @@ -138,7 +138,7 @@ fn a_masked_object_blends_with_the_backdrop() { let mut g = group(d, vec![m], 1.0, Blend::Normal, false); // A 50% grey mask over the whole group. let art = rect(d, Rect::new(0.0, 0.0, 100.0, 100.0), [0.5; 3]); - g.mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(art, true))); + g.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(art, true))); g }); assert_near(render(&d).pixel(50, 50), lerp(B, blend_rgb(Blend::Multiply, B, S), 0.5), "masked multiply"); diff --git a/crates/render/src/tests_knockout.rs b/crates/render/src/tests_knockout.rs index a5176608..d364080f 100644 --- a/crates/render/src/tests_knockout.rs +++ b/crates/render/src/tests_knockout.rs @@ -77,7 +77,7 @@ fn opacity_defines_the_knockout_shape() { let blue = Arc::make_mut(&mut g.children_mut().unwrap()[1]); blue.opacity = 1.0; blue.knockout_shape = shape; - blue.mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(white, true))); + blue.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(white, true))); })); let want = if shape { HALF_KNOCKED } else { BLUE_OVER_WHITE }; // A mid-grey mask is ~50% (luminance 0.5, not gamma-corrected): allow a little more slack. diff --git a/crates/render/src/tests_layeropts.rs b/crates/render/src/tests_layeropts.rs index db8b6eb2..c20e191d 100644 --- a/crates/render/src/tests_layeropts.rs +++ b/crates/render/src/tests_layeropts.rs @@ -20,14 +20,14 @@ fn doc(layer: impl FnOnce(&mut NodeKind)) -> Document { d.insert(Some(l), 0, sq).unwrap(); let mut im = Node::new( NodeId(0), - NodeKind::Image(ImageObject { + NodeKind::Image(Box::new(ImageObject { key: "red".into(), width: 30, height: 30, xf: Affine::translate((50.0, 10.0)), link: None, placement: Default::default(), - }), + })), ); im.id = d.alloc_id(); d.insert(Some(l), 9, im).unwrap(); diff --git a/crates/render/src/tests_memory.rs b/crates/render/src/tests_memory.rs index e9b9d7b2..acf5848c 100644 --- a/crates/render/src/tests_memory.rs +++ b/crates/render/src/tests_memory.rs @@ -24,7 +24,7 @@ fn doc_with_image(key: &str, bytes: Vec, w: u32, h: u32) -> Document { d.images.insert(key.into(), ImageBlob::new("image/png", bytes)); let (id, layer) = (d.alloc_id(), d.layers[0].id); let im = ImageObject { key: key.into(), width: w, height: h, xf: Affine::IDENTITY, link: None, placement: Default::default() }; - d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(im))).unwrap(); + d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); d } diff --git a/crates/render/src/tests_objectfx.rs b/crates/render/src/tests_objectfx.rs index 9041b107..0f69e3f7 100644 --- a/crates/render/src/tests_objectfx.rs +++ b/crates/render/src/tests_objectfx.rs @@ -112,14 +112,14 @@ fn image_doc(effects: Vec) -> Document { d.images.insert("red".into(), ImageBlob::new("image/png", png)); let mut n = Node::new( NodeId(0), - NodeKind::Image(ImageObject { + NodeKind::Image(Box::new(ImageObject { key: "red".into(), width: 30, height: 30, xf: Affine::translate((20.0, 20.0)), link: None, placement: Default::default(), - }), + })), ); n.appearance.effects = effects; doc_with(d, n) diff --git a/crates/render/src/tests_setup.rs b/crates/render/src/tests_setup.rs index d64ffcdd..e8129f46 100644 --- a/crates/render/src/tests_setup.rs +++ b/crates/render/src/tests_setup.rs @@ -11,7 +11,7 @@ fn doc_with_red_image() -> Document { d.images.insert("red".into(), ImageBlob::new("image/png", png)); let (id, layer) = (d.alloc_id(), d.layers[0].id); let im = ImageObject { key: "red".into(), width: 40, height: 40, xf: Affine::IDENTITY, link: None, placement: Default::default() }; - d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(im))).unwrap(); + d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); d } diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index 8775a97d..58dc6508 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -312,7 +312,8 @@ impl Rules<'_> { if !n.visible { return; } - let plain_group = n.name.is_none() && transparency(n).is_empty() && !n.appearance.effects.iter().any(|e| e.visible) && n.mask.is_none(); + let plain_group = + n.name.is_none() && transparency(n).is_empty() && !n.appearance.effects.iter().any(|e| e.visible) && n.extras().mask.is_none(); match &n.kind { NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. } => {} NodeKind::Layer { children, clip: false, .. } => children.iter().for_each(|c| self.object(c)), @@ -394,7 +395,7 @@ impl Rules<'_> { let mut radius = None; let text = match &n.kind { NodeKind::Path { path, live, .. } => { - match corners(path, live.as_ref()) { + match corners(path, live.as_deref()) { Some(Corners::Square) => {} Some(Corners::Round(r)) => radius = Some(r.iter().map(|r| self.len(*r)).collect::>().join(" ")), Some(Corners::Ellipse) => radius = Some("50%".into()), @@ -420,7 +421,7 @@ impl Rules<'_> { } art.props.extend(radius.map(|r| ("border-radius", r))); self.effects(n, text, &mut art); - if n.mask.as_ref().is_some_and(|m| !m.disabled) { + if n.extras().mask.as_ref().is_some_and(|m| !m.disabled) { art.unsupported("an opacity mask"); } art diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index ef558c88..702fe9ff 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -356,7 +356,7 @@ impl Reuse { fn scan(&mut self, doc: &Document, n: &Node, depth: u32) { let pattern = |p: &Paint| matches!(p, Paint::Pattern { .. }); let text_patterns = matches!(&n.kind, NodeKind::Text(t) if t.runs.iter().any(|r| pattern(&r.style.fill) || pattern(&r.style.stroke))); - let unlinked_mask = n.mask.as_ref().is_some_and(|m| !m.linked); + let unlinked_mask = n.extras().mask.as_ref().is_some_and(|m| !m.linked); if depth > MAX_SYMBOL_NEST || text_patterns || unlinked_mask || vectorcraft_doc::pattern::uses_pattern(n, None) { *self = Self::NONE; return; @@ -387,7 +387,7 @@ impl Reuse { } _ => {} } - if let Some(m) = &n.mask { + if let Some(m) = &n.extras().mask { self.scan(doc, &m.art, depth); } for c in n.children().into_iter().flatten() { @@ -412,7 +412,7 @@ impl Reuse { /// and the art of unlinked opacity masks. fn pin_to_page(n: &mut Node, m: Affine) { vectorcraft_doc::pattern::transform_pattern_paints(n, m); - if let Some(mask) = n.mask.as_deref_mut() { + if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) { let linked = mask.linked; let art = std::sync::Arc::make_mut(&mut mask.art); if !linked { @@ -600,7 +600,7 @@ impl Writer<'_> { out.push_str(&format!(" {}=\"sublayer\"", crate::import::SUBLAYER)); } let mut keys = HashSet::new(); - for (k, v) in n.attrs.as_deref().map_or(&[][..], |a| a.data.as_slice()) { + for (k, v) in n.extras().attrs.as_deref().map_or(&[][..], |a| a.data.as_slice()) { // Ours (`name`, `vc-…`) are written by the export itself. if k == "name" || k.starts_with("vc-") { continue; @@ -1155,7 +1155,7 @@ impl Writer<'_> { if self.tiny() { self.warn(TINY_MASKS); let mut bare = n.clone(); - bare.mask = None; + bare.extras_mut().mask = None; return self.node_body(&bare); } let mid = self.fresh_id("mask"); @@ -1189,7 +1189,7 @@ impl Writer<'_> { self.line(&format!("")); self.depth += 1; let mut bare = n.clone(); - bare.mask = None; + bare.extras_mut().mask = None; self.node_body(&bare); self.depth -= 1; self.line(""); @@ -1263,7 +1263,13 @@ impl Writer<'_> { f } }; - let shape = if c.knockout_shape { c.clone() } else { Node { opacity: 1.0, mask: None, ..c.clone() } }; + let shape = if c.knockout_shape { + c.clone() + } else { + let mut s = c.clone().with_mask(None); + s.opacity = 1.0; + s + }; let anonymous = std::mem::replace(&mut self.anonymous, true); let art = self.detached(3, |w| w.node(&shape)); self.anonymous = anonymous; @@ -1303,7 +1309,7 @@ impl Writer<'_> { if has_raster(&n.appearance.effects) { return self.filtered(n); } - if let Some(m) = n.mask.as_deref() + if let Some(m) = n.extras().mask.as_deref() && !m.disabled { return self.masked(n, m); @@ -1720,7 +1726,8 @@ impl Writer<'_> { }; glyphs(self, below); // The group carries the id and the object's transparency. - let bare = Node { opacity: 1.0, blend: BlendMode::Normal, isolate: false, ..n.clone() }; + let mut bare = n.clone(); + (bare.opacity, bare.blend, bare.isolate) = (1.0, BlendMode::Normal, false); let anonymous = std::mem::replace(&mut self.anonymous, true); chars(self, &bare); for ig in &lay.inlines { diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 67983cfb..39204efd 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -301,7 +301,7 @@ struct SymbolMade { fn without_ids(n: &Node) -> Node { let mut n = n.clone(); n.id = vectorcraft_doc::NodeId(0); - if let Some(m) = n.mask.as_deref_mut() { + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) { m.art = Arc::new(without_ids(&m.art)); } for c in n.children_mut().into_iter().flatten() { @@ -713,7 +713,7 @@ impl Importer { /// Give `n` the `data-*` attributes of element `id`, unless it has data already (what an /// inner element made of it keeps). fn keep_data(&self, id: &str, n: &mut Node) { - if let Some(data) = self.data.get(id).filter(|_| n.attrs.as_ref().is_none_or(|a| a.data.is_empty())) { + if let Some(data) = self.data.get(id).filter(|_| n.extras().attrs.as_ref().is_none_or(|a| a.data.is_empty())) { n.edit_attrs(|a| a.data = data.clone()); } } @@ -799,7 +799,7 @@ impl Importer { n.opacity = g.opacity().get(); n.blend = blend(g.blend_mode()); n.isolate = g.isolate(); - n.mask = mask; + n.extras_mut().mask = mask; Some(n) } @@ -912,7 +912,7 @@ impl Importer { if (is_text || id.is_empty() && mask.is_none()) && children.len() == 1 && (g.blend_mode() == usvg::BlendMode::Normal || children[0].blend == BlendMode::Normal) - && (effects.is_empty() || children[0].blend == BlendMode::Normal && children[0].mask.is_none()) + && (effects.is_empty() || children[0].blend == BlendMode::Normal && children[0].extras().mask.is_none()) && let Some(only) = children.pop() { let mut c = Arc::unwrap_or_clone(only); @@ -922,7 +922,7 @@ impl Importer { } c.isolate |= g.isolate(); if mask.is_some() { - c.mask = mask; + c.extras_mut().mask = mask; } c.appearance.effects.extend(effects); return Some(c); @@ -934,7 +934,7 @@ impl Importer { n.opacity = g.opacity().get(); n.blend = blend(g.blend_mode()); n.isolate = g.isolate(); - n.mask = mask; + n.extras_mut().mask = mask; if !is_text && self.sublayers.contains(g.id()) { self.sublayer_groups.insert(n.id); } @@ -1229,7 +1229,7 @@ impl Importer { if link.is_some() && !missing { blob.with_proxy() } else { blob } }); let xf = acc * Affine::scale_non_uniform(size.width() as f64 / pw as f64, size.height() as f64 / ph as f64); - Some(self.named(i.id(), NodeKind::Image(ImageObject { key, width: pw, height: ph, xf, link, placement: Default::default() }))) + Some(self.named(i.id(), NodeKind::Image(Box::new(ImageObject { key, width: pw, height: ph, xf, link, placement: Default::default() })))) } /// An SVG shown as an image (a data URL or a linked file) → its art, `acc` mapping its size. diff --git a/crates/svg/tests/blend_export.rs b/crates/svg/tests/blend_export.rs index 7cf88f62..ec50a4f4 100644 --- a/crates/svg/tests/blend_export.rs +++ b/crates/svg/tests/blend_export.rs @@ -72,7 +72,7 @@ fn masked(clip: bool, invert: bool) -> Document { let mut red = rect(&mut d, Rect::new(0.0, 0.0, 40.0, 20.0), [255, 0, 0]); let mut m = OpacityMask::new(art, clip); m.invert = invert; - red.mask = Some(Box::new(m)); + red.extras_mut().mask = Some(Box::new(m)); add(&mut d, red); d } diff --git a/crates/svg/tests/css_rules.rs b/crates/svg/tests/css_rules.rs index 4004c04c..e50d29a0 100644 --- a/crates/svg/tests/css_rules.rs +++ b/crates/svg/tests/css_rules.rs @@ -30,7 +30,7 @@ fn rect(d: &mut Document, (x, y, w, h): (f64, f64, f64, f64), radius: f64) -> No Appearance::basic(fill, Paint::solid(Color::rgb8(0, 0, 255)), 2.0), ); if let NodeKind::Path { live, .. } = &mut n.kind { - *live = Some(LiveShape::Rectangle { w, h, radii: [radius; 4], kinds: Default::default(), xf: Affine::translate((x, y)) }); + *live = Some(Box::new(LiveShape::Rectangle { w, h, radii: [radius; 4], kinds: Default::default(), xf: Affine::translate((x, y)) })); } n } @@ -74,19 +74,19 @@ fn rectangle_gives_background_border_and_radius() { fn live_corners_give_per_corner_radii_and_only_round_ones() { let mut d = doc(); let mut n = rect(&mut d, (0.0, 0.0, 100.0, 50.0), 0.0); - if let NodeKind::Path { live: Some(LiveShape::Rectangle { radii, kinds, .. }), .. } = &mut n.kind { + if let Some(LiveShape::Rectangle { radii, kinds, .. }) = n.live_shape_mut() { *radii = [0.0, 8.0, 0.0, 4.0]; kinds[0] = CornerKind::Chamfer; } let id = add(&mut d, n); assert_eq!(prop(&one(&d, id, &CssOptions::default()), "border-radius"), Some("0 8px 0 4px"), "a square corner's kind doesn't matter"); - if let Some(NodeKind::Path { live: Some(LiveShape::Rectangle { kinds, .. }), .. }) = d.node_mut(id).map(|n| &mut n.kind) { + if let Some(LiveShape::Rectangle { kinds, .. }) = d.node_mut(id).and_then(|n| n.live_shape_mut()) { kinds[1] = CornerKind::InvertedRound; } assert!(prop(&one(&d, id, &CssOptions::default()), "border-radius").is_none(), "CSS can't cut a corner in"); // A radius past half the shorter side is drawn at half of it (#442), and exported so: CSS // would draw it larger, its neighbours leaving room. - if let Some(NodeKind::Path { live: Some(LiveShape::Rectangle { radii, kinds, .. }), .. }) = d.node_mut(id).map(|n| &mut n.kind) { + if let Some(LiveShape::Rectangle { radii, kinds, .. }) = d.node_mut(id).and_then(|n| n.live_shape_mut()) { *radii = [60.0, 8.0, 0.0, 4.0]; *kinds = Default::default(); } @@ -99,7 +99,7 @@ fn ellipses_round_fully_and_options_change_units_and_names() { let mut e = Node::path(d.alloc_id(), shapes::ellipse(Rect::new(0.0, 0.0, 72.0, 36.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); if let NodeKind::Path { live, .. } = &mut e.kind { - *live = Some(LiveShape::Ellipse { w: 72.0, h: 36.0, pie: (0.0, 360.0), xf: Affine::IDENTITY }); + *live = Some(Box::new(LiveShape::Ellipse { w: 72.0, h: 36.0, pie: (0.0, 360.0), xf: Affine::IDENTITY })); } let e = add(&mut d, e); let a = rect(&mut d, (0.0, 0.0, 10.0, 10.0), 0.0); diff --git a/crates/svg/tests/data_attributes.rs b/crates/svg/tests/data_attributes.rs index a8927970..651a3447 100644 --- a/crates/svg/tests/data_attributes.rs +++ b/crates/svg/tests/data_attributes.rs @@ -35,7 +35,7 @@ fn native_data(raw: &str) -> Document { fn imported_decimal_whitespace_references_survive_export() { let doc = import(&svg(r#""#)).unwrap(); assert_eq!( - named(&doc, "shape").attrs.as_deref().unwrap().data, + named(&doc, "shape").extras().attrs.as_deref().unwrap().data, [("value".into(), "a\nb".into()), ("tab".into(), "a\tb".into()), ("cr".into(), "a\rb".into())] ); for styling in styles() { @@ -47,7 +47,7 @@ fn imported_decimal_whitespace_references_survive_export() { assert_eq!(n.attribute(key), Some(value)); } let back = import(&text).unwrap(); - assert_eq!(named(&back, "shape").attrs.as_deref().unwrap().data, named(&doc, "shape").attrs.as_deref().unwrap().data); + assert_eq!(named(&back, "shape").extras().attrs.as_deref().unwrap().data, named(&doc, "shape").extras().attrs.as_deref().unwrap().data); } } } @@ -70,7 +70,7 @@ fn unicode_data_names_survive_each_encoding() { r#""#, )) .unwrap(); - assert_eq!(named(&doc, "shape").attrs.as_deref().unwrap().data.len(), 5); + assert_eq!(named(&doc, "shape").extras().attrs.as_deref().unwrap().data.len(), 5); for styling in styles() { for encoding in [Encoding::Utf8, Encoding::Utf16, Encoding::Latin1] { for minify in [false, true] { @@ -89,7 +89,10 @@ fn unicode_data_names_survive_each_encoding() { assert_eq!(out.encoding, encoding); } let back = import(&out.svg).unwrap(); - assert_eq!(named(&back, "shape").attrs.as_deref().unwrap().data, named(&doc, "shape").attrs.as_deref().unwrap().data); + assert_eq!( + named(&back, "shape").extras().attrs.as_deref().unwrap().data, + named(&doc, "shape").extras().attrs.as_deref().unwrap().data + ); } } } @@ -199,7 +202,7 @@ fn reid_imported_nodes_keeps_unique_ids_references_and_data() { let back = import(&text).unwrap(); let mut copies = 0; back.walk(|n| { - if n.attrs.as_deref().is_some_and(|a| a.data.iter().any(|(k, v)| k == "配方" && v == value)) { + if n.extras().attrs.as_deref().is_some_and(|a| a.data.iter().any(|(k, v)| k == "配方" && v == value)) { copies += 1; } }); diff --git a/crates/svg/tests/encoding_profile_fonts.rs b/crates/svg/tests/encoding_profile_fonts.rs index 3f080ccd..c805548c 100644 --- a/crates/svg/tests/encoding_profile_fonts.rs +++ b/crates/svg/tests/encoding_profile_fonts.rs @@ -138,7 +138,7 @@ fn tiny_has_no_filters_masks_symbols_or_style_sheets() { add(&mut d, a); let mut b = Node::path(vectorcraft_doc::NodeId(0), shapes::rectangle(Rect::new(80.0, 10.0, 130.0, 60.0)), fill(0, 0, 255)); b.blend = BlendMode::Multiply; - b.mask = + b.extras_mut().mask = Some(Box::new(OpacityMask::new(Node::path(d.alloc_id(), shapes::ellipse(Rect::new(80.0, 10.0, 130.0, 60.0)), fill(255, 255, 255)), true))); add(&mut d, b); let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(-5.0, -5.0, 5.0, 5.0)), fill(0, 128, 0)); diff --git a/crates/svg/tests/import_details.rs b/crates/svg/tests/import_details.rs index 13031e05..b6cbf72c 100644 --- a/crates/svg/tests/import_details.rs +++ b/crates/svg/tests/import_details.rs @@ -30,7 +30,7 @@ fn images(d: &Document) -> Vec<(&Node, &ImageObject)> { art(d) .into_iter() .filter_map(|n| match &n.kind { - NodeKind::Image(im) => Some((n, im)), + NodeKind::Image(im) => Some((n, &**im)), _ => None, }) .collect() diff --git a/crates/svg/tests/import_text.rs b/crates/svg/tests/import_text.rs index 73fca990..234eaf12 100644 --- a/crates/svg/tests/import_text.rs +++ b/crates/svg/tests/import_text.rs @@ -100,7 +100,7 @@ fn text_keeps_its_parent_group_clip_and_mask() { assert!(matches!(children[1].kind, NodeKind::Text(_)) && children[1].name.as_deref() == Some("own")); // The text's own mask. assert!(matches!(inner[3].kind, NodeKind::Text(_))); - assert!(inner[3].mask.is_some()); + assert!(inner[3].extras().mask.is_some()); assert_eq!(inner[3].name.as_deref(), Some("masked")); } diff --git a/crates/svg/tests/mask_options.rs b/crates/svg/tests/mask_options.rs index 9084aeb3..2ead42f1 100644 --- a/crates/svg/tests/mask_options.rs +++ b/crates/svg/tests/mask_options.rs @@ -19,7 +19,7 @@ fn masked(clip: bool, invert: bool) -> Document { let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 90.0)), white); let mut m = OpacityMask::new(art, clip); m.invert = invert; - n.mask = Some(Box::new(m)); + n.extras_mut().mask = Some(Box::new(m)); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); d @@ -27,7 +27,7 @@ fn masked(clip: bool, invert: bool) -> Document { fn masks(d: &Document) -> Vec { let mut v = vec![]; - d.walk(|n| v.extend(n.mask.as_deref().cloned())); + d.walk(|n| v.extend(n.extras().mask.as_deref().cloned())); v } diff --git a/crates/svg/tests/options.rs b/crates/svg/tests/options.rs index d0e863bb..d2c8cfda 100644 --- a/crates/svg/tests/options.rs +++ b/crates/svg/tests/options.rs @@ -56,7 +56,7 @@ fn image_doc(key: &str, bytes: Vec) -> Document { d.images.insert(key.into(), ImageBlob::new("image/png", bytes)); let l = d.layers[0].id; let im = ImageObject { key: key.into(), width: 2, height: 2, xf: Affine::IDENTITY, link: None, placement: Default::default() }; - let n = Node::new(d.alloc_id(), NodeKind::Image(im)); + let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); d.insert(Some(l), usize::MAX, n).unwrap(); d } diff --git a/crates/svg/tests/roundtrip.rs b/crates/svg/tests/roundtrip.rs index 53c732ee..8439e720 100644 --- a/crates/svg/tests/roundtrip.rs +++ b/crates/svg/tests/roundtrip.rs @@ -316,7 +316,7 @@ fn stroke_alignment_approximations() { assert!(s.contains(" imports as an opacity mask"); + assert!(a.iter().any(|n| n.extras().mask.is_some()), " imports as an opacity mask"); assert!(!w.iter().any(|w| w.contains("mask")), "{w:?}"); // becomes a pattern swatch. assert_eq!(d.patterns.len(), 1, "{w:?}"); @@ -862,7 +862,7 @@ fn masked_doc(clip: bool, invert: bool) -> Document { let art = rect_node(&mut d, Rect::new(10.0, 10.0, 50.0, 90.0), white); let mut m = vectorcraft_doc::OpacityMask::new(art, clip); m.invert = invert; - n.mask = Some(Box::new(m)); + n.extras_mut().mask = Some(Box::new(m)); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); d @@ -875,9 +875,9 @@ fn opacity_mask_exports_as_svg_mask_and_imports_back() { assert!(s.contains(" = art(&back).into_iter().filter(|n| n.mask.is_some()).collect(); + let masked: Vec<&Node> = art(&back).into_iter().filter(|n| n.extras().mask.is_some()).collect(); assert_eq!(masked.len(), 1, "{s}"); - let m = masked[0].mask.as_deref().unwrap(); + let m = masked[0].extras().mask.as_deref().unwrap(); assert!(close_rect(m.art.geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 50.0, 90.0), 1e-6)); assert!(close_rect(masked[0].geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 90.0, 90.0), 1e-6)); } @@ -890,7 +890,7 @@ fn unclipped_inverted_mask_exports_backdrop_and_filter() { let disabled = { let mut d = masked_doc(true, false); let id = d.layers[0].children().unwrap()[0].id; - d.node_mut(id).unwrap().mask.as_mut().unwrap().disabled = true; + d.node_mut(id).unwrap().extras_mut().mask.as_mut().unwrap().disabled = true; export(&d, &ExportOptions::default()) }; assert!(!disabled.contains(" Vec<(String, String)> { let mut found = vec![]; d.walk(|n| { if n.name.as_deref() == Some(name) { - found = n.attrs.as_deref().map(|a| a.data.clone()).unwrap_or_default(); + found = n.extras().attrs.as_deref().map(|a| a.data.clone()).unwrap_or_default(); } }); found diff --git a/crates/tools/src/builder.rs b/crates/tools/src/builder.rs index 885987f2..6286e1c1 100644 --- a/crates/tools/src/builder.rs +++ b/crates/tools/src/builder.rs @@ -105,7 +105,7 @@ pub fn leaf_shapes<'a>(n: &'a Node, out: &mut Vec<&'a Node>) { if !n.visible { return; } - if n.shaper.is_some() { + if n.extras().shaper.is_some() { for c in n.children().into_iter().flatten().skip(1) { leaf_shapes(c, out); } diff --git a/crates/tools/src/corners.rs b/crates/tools/src/corners.rs index ee9c6843..d156db14 100644 --- a/crates/tools/src/corners.rs +++ b/crates/tools/src/corners.rs @@ -58,10 +58,10 @@ impl PathWidgets { return None; } let NodeKind::Path { path, live, .. } = &doc.node(id)?.kind else { return None }; - if !any_path && !matches!(live, Some(LiveShape::Rectangle { .. } | LiveShape::Polygon { .. })) { + if !any_path && !matches!(live.as_deref(), Some(LiveShape::Rectangle { .. } | LiveShape::Polygon { .. })) { return None; } - let lc = LiveCorners::new(path, live.as_ref())?; + let lc = LiveCorners::new(path, live.as_deref())?; let det = lc.xf.determinant().abs(); if det < 1e-12 { return None; @@ -366,7 +366,7 @@ mod tests { let id = d.alloc_id(); let mut n = Node::path(id, path, Appearance::default_art()); if let NodeKind::Path { live: slot, .. } = &mut n.kind { - *slot = live; + *slot = live.map(Box::new); } d.insert(Some(l), 0, n).unwrap(); (d, id) @@ -440,7 +440,7 @@ mod tests { let b = d.alloc_id(); let mut n = Node::path(b, live.to_path(), Appearance::default_art()); if let NodeKind::Path { live: slot, .. } = &mut n.kind { - *slot = Some(live); + *slot = Some(Box::new(live)); } let l = d.layers[0].id; d.insert(Some(l), 1, n).unwrap(); diff --git a/crates/tools/src/cropimage.rs b/crates/tools/src/cropimage.rs index c0711639..85555f3f 100644 --- a/crates/tools/src/cropimage.rs +++ b/crates/tools/src/cropimage.rs @@ -273,7 +273,7 @@ mod tests { link: None, placement: Default::default(), }; - d.insert(Some(l), 0, Node::new(id, NodeKind::Image(im))).unwrap(); + d.insert(Some(l), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); let mut s = Selection::default(); s.add(id); (d, s) diff --git a/crates/tools/src/distort.rs b/crates/tools/src/distort.rs index 0437ffff..55fa9a9b 100644 --- a/crates/tools/src/distort.rs +++ b/crates/tools/src/distort.rs @@ -59,7 +59,8 @@ fn warp_node(n: &mut Node, f: &dyn Fn(Point) -> Point, h: Option<&Homography>) { if let Some(h) = h && matches!(n.kind, NodeKind::Text(_) | NodeKind::SymbolInstance { .. }) { - let rec = n.perspective.get_or_insert_with(Default::default); + let mut extras = n.extras_mut(); + let rec = extras.perspective.get_or_insert_with(Default::default); let had = rec.homography().unwrap_or(Homography::IDENTITY); rec.projection = Some(h.then_after(&had).to_array()); return; diff --git a/crates/tools/src/distort/perspective.rs b/crates/tools/src/distort/perspective.rs index d1193ee6..88a54b07 100644 --- a/crates/tools/src/distort/perspective.rs +++ b/crates/tools/src/distort/perspective.rs @@ -287,9 +287,9 @@ impl PerspectiveGrid { continue; } if let Some(n) = k.parse::().ok().and_then(|id| doc.node_mut(NodeId(id))) - && n.perspective.is_none() + && n.extras().perspective.is_none() { - n.perspective = Some(Box::new(PerspectiveAttachment::new(pl.id(), 0.0))); + n.extras_mut().perspective = Some(Box::new(PerspectiveAttachment::new(pl.id(), 0.0))); } } if let Some(v) = doc.unknown.get_mut(DOC_KEY).and_then(Value::as_object_mut) { @@ -676,7 +676,7 @@ pub enum Axis { /// The plane and depth object `n` is attached to (its own record). pub fn attachment(n: &Node) -> Option<(Plane, f64)> { - let a = n.perspective.as_deref()?; + let a = n.extras().perspective.as_deref()?; Plane::parse(&a.plane).filter(|p| *p != Plane::None).map(|p| (p, a.depth)) } @@ -697,7 +697,8 @@ pub fn attached_roots(doc: &Document) -> Vec<(NodeId, Plane, f64)> { /// Attach `n` to `plane` at `depth` (type and symbols keep their projection). pub fn set_attachment(n: &mut Node, plane: Plane, depth: f64) { - let rec = n.perspective.get_or_insert_with(Default::default); + let mut extras = n.extras_mut(); + let rec = extras.perspective.get_or_insert_with(Default::default); rec.plane = plane.id().into(); rec.depth = if depth.is_finite() { depth } else { 0.0 }; } @@ -705,9 +706,10 @@ pub fn set_attachment(n: &mut Node, plane: Plane, depth: f64) { /// Release `n` from the grid (Release with Perspective): it keeps its look, type and symbols their /// projection. pub fn release(n: &mut Node) { - match n.perspective.as_deref_mut() { + let mut extras = n.extras_mut(); + match extras.perspective.as_deref_mut() { Some(rec) if rec.projection.is_some() => *rec = PerspectiveAttachment { projection: rec.projection, ..Default::default() }, - _ => n.perspective = None, + _ => extras.perspective = None, } } @@ -1460,7 +1462,7 @@ mod tests { old.attached.insert(id2.0.to_string(), Plane::Left); d2.unknown.insert(DOC_KEY.into(), serde_json::to_value(&old).unwrap()); grid(2).store(&mut d2); - assert!(d2.node(id2).unwrap().perspective.is_none()); + assert!(d2.node(id2).unwrap().extras().perspective.is_none()); } #[test] diff --git a/crates/tools/src/draw2.rs b/crates/tools/src/draw2.rs index 77731260..a060e148 100644 --- a/crates/tools/src/draw2.rs +++ b/crates/tools/src/draw2.rs @@ -111,7 +111,7 @@ pub(crate) fn shown_paths(cx: &ToolContext) -> Vec { NodeKind::Path { guide: false, .. } => v.push(n.id), // A Shape Builder group's first child is its shaper, not art. NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => { - for c in children.iter().skip(usize::from(n.shaper.is_some())) { + for c in children.iter().skip(usize::from(n.extras().shaper.is_some())) { inside(c, v); } } diff --git a/crates/tools/src/meshedit.rs b/crates/tools/src/meshedit.rs index 1af0998c..adb3adcd 100644 --- a/crates/tools/src/meshedit.rs +++ b/crates/tools/src/meshedit.rs @@ -19,9 +19,10 @@ const FEEDBACK: [u8; 3] = [0x4f, 0x9d, 0xff]; pub fn grid_of(n: &Node) -> Option> { match &n.kind { NodeKind::Mesh(m) => Some(Cow::Borrowed(m)), - NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows, cols, points, handles }, editing: false, .. } => { - envelope_grid(*rows, *cols, points, handles).map(Cow::Owned) - } + NodeKind::Envelope { kind, editing: false, .. } => match &**kind { + EnvelopeKind::Mesh { rows, cols, points, handles } => envelope_grid(*rows, *cols, points, handles).map(Cow::Owned), + _ => None, + }, _ => None, } } @@ -132,11 +133,11 @@ mod tests { let kind = EnvelopeKind::Mesh { rows: 1, cols: 1, points: grid_points(Rect::new(100.0, 100.0, 200.0, 200.0), 1, 1), handles: vec![] }; let env = NodeKind::Envelope { content: vec![Arc::new(rect)], - kind, + kind: Box::new(kind), fidelity: 50.0, editing: false, options: EnvelopeOptions::NEW, - frame: Affine::IDENTITY, + frame: Box::new(Affine::IDENTITY), }; let l = d.layers[0].id; d.insert(Some(l), 0, Node::new(id, env)).unwrap(); diff --git a/crates/tools/src/pathtype.rs b/crates/tools/src/pathtype.rs index 61fee7c2..8bf33382 100644 --- a/crates/tools/src/pathtype.rs +++ b/crates/tools/src/pathtype.rs @@ -54,7 +54,7 @@ impl Brackets { } fn of_node(cx: &ToolContext, id: NodeId) -> Option { - let n = cx.doc.node(id).filter(|n| n.perspective.is_none() && cx.doc.is_editable(id))?; + let n = cx.doc.node(id).filter(|n| n.extras().perspective.is_none() && cx.doc.is_editable(id))?; let NodeKind::Text(t) = &n.kind else { return None }; let TextKind::OnPath { path, end, .. } = &t.kind else { return None }; let det = t.xf.determinant(); diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index e12054c6..3cae0937 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -540,7 +540,8 @@ fn can_be_key(cx: &ToolContext, id: NodeId) -> bool { /// Is `n` area type (text in a frame) whose frame the tools reshape? Type in perspective isn't: /// it transforms whole. pub(crate) fn is_area_type(n: &vectorcraft_doc::Node) -> bool { - n.perspective.is_none() && matches!(&n.kind, vectorcraft_doc::NodeKind::Text(t) if matches!(t.kind, vectorcraft_doc::TextKind::Area { .. })) + n.extras().perspective.is_none() + && matches!(&n.kind, vectorcraft_doc::NodeKind::Text(t) if matches!(t.kind, vectorcraft_doc::TextKind::Area { .. })) } /// The resize cursor for handle `h` of a box turned by `angle` (counter-clockwise degrees): the diff --git a/crates/tools/src/typewidget.rs b/crates/tools/src/typewidget.rs index cce2801b..13753fb0 100644 --- a/crates/tools/src/typewidget.rs +++ b/crates/tools/src/typewidget.rs @@ -37,7 +37,7 @@ impl TypeWidget { if !doc.is_editable(id) || !zoom.is_finite() || zoom <= 0.0 { return None; } - let n = doc.node(id).filter(|n| n.perspective.is_none())?; + let n = doc.node(id).filter(|n| n.extras().perspective.is_none())?; let NodeKind::Text(t) = &n.kind else { return None }; let area = match t.kind { TextKind::Point => false, diff --git a/crates/tools/src/xform/eyedropper.rs b/crates/tools/src/xform/eyedropper.rs index d1dc10bf..0cdfbaa8 100644 --- a/crates/tools/src/xform/eyedropper.rs +++ b/crates/tools/src/xform/eyedropper.rs @@ -213,7 +213,7 @@ mod tests { link: None, placement: Default::default(), }; - d.insert(Some(l), 0, Node::new(id, NodeKind::Image(im))).unwrap(); + d.insert(Some(l), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); let (s, p) = (Selection::default(), paint()); let mut cx = cx(&d, &s, &p); let click = PointerEvent::new(PointerKind::Down, 15.0, 5.0); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f51c6992..a082d4f9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1229,7 +1229,7 @@ fn node_outline(n: &Node) -> BezPath { NodeKind::Path { path, .. } => bp.extend(path.to_bezpath()), // Area type shows its frame: the type area Direct Selection reshapes; type on a path its // path, which the selection tools' brackets sit on. - NodeKind::Text(t) if c.perspective.is_none() && !matches!(t.kind, vectorcraft_doc::TextKind::Point) => { + NodeKind::Text(t) if c.extras().perspective.is_none() && !matches!(t.kind, vectorcraft_doc::TextKind::Point) => { if let Some(path) = t.area_frame().or_else(|| t.type_path()) { bp.extend(path.to_bezpath()); } @@ -1278,7 +1278,7 @@ fn too_many_anchors<'a>(nodes: impl IntoIterator) -> bool { } } !matches!(n.kind, NodeKind::Envelope { .. }) - && n.children().into_iter().flatten().skip(usize::from(n.shaper.is_some())).any(|c| over(c, left)) + && n.children().into_iter().flatten().skip(usize::from(n.extras().shaper.is_some())).any(|c| over(c, left)) } let mut left = OVERLAY_MAX_ANCHORS; nodes.into_iter().any(|n| over(n, &mut left)) @@ -1291,7 +1291,7 @@ fn walk_drawn<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) { if matches!(n.kind, NodeKind::Envelope { .. }) { return; } - for c in n.children().into_iter().flatten().skip(usize::from(n.shaper.is_some())) { + for c in n.children().into_iter().flatten().skip(usize::from(n.extras().shaper.is_some())) { walk_drawn(c, f); } } @@ -2207,7 +2207,7 @@ mod tests { } let st = s.active().unwrap(); let g = st.doc.node(st.selection.objects[0]).unwrap(); - assert!(g.shaper.is_some()); + assert!(g.extras().shaper.is_some()); let bounds = node_outline(g).bounding_box(); assert!((bounds.x0 - 100.0).abs() < 0.01 && (bounds.x1 - 200.0).abs() < 0.01, "{bounds:?}"); assert_eq!(node_outline(st.doc.node(line).unwrap()).bounding_box().x0, 50.0, "an isolated original still shows its full outline"); @@ -3646,9 +3646,9 @@ mod tests { let st = app.session.active().unwrap(); let g = st.doc.graphic_style(&name).unwrap(); // `graphicStyle.apply` with the hit object's id: linked, the selection left alone. - assert_eq!(st.doc.node(b).unwrap().graphic_style, Some(g.id)); + assert_eq!(st.doc.node(b).unwrap().extras().graphic_style, Some(g.id)); assert_eq!(st.doc.node(b).unwrap().appearance, g.appearance); - assert_eq!(st.doc.node(a).unwrap().graphic_style, None); + assert_eq!(st.doc.node(a).unwrap().extras().graphic_style, None); assert_eq!(st.selection.objects, [a]); } diff --git a/crates/ui-egui/src/dialogs/blend_options.rs b/crates/ui-egui/src/dialogs/blend_options.rs index 86deabcb..0ee43fc4 100644 --- a/crates/ui-egui/src/dialogs/blend_options.rs +++ b/crates/ui-egui/src/dialogs/blend_options.rs @@ -147,7 +147,7 @@ mod tests { fn spec(app: &VectorcraftApp, id: u64) -> vectorcraft_doc::BlendSpec { match &app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind { - NodeKind::Blend { spec, .. } => spec.clone(), + NodeKind::Blend { spec, .. } => (**spec).clone(), _ => panic!("not a blend"), } } diff --git a/crates/ui-egui/src/dialogs/corners.rs b/crates/ui-egui/src/dialogs/corners.rs index 27405382..8f1342eb 100644 --- a/crates/ui-egui/src/dialogs/corners.rs +++ b/crates/ui-egui/src/dialogs/corners.rs @@ -120,7 +120,7 @@ mod tests { let Some(NodeKind::Path { live: Some(l), .. }) = app.session.active().unwrap().doc.node(NodeId(id)).map(|n| n.kind.clone()) else { panic!("not live") }; - l + *l } fn corners(app: &VectorcraftApp, id: u64) -> ([f64; 4], [CornerKind; 4]) { @@ -239,7 +239,7 @@ mod tests { d.fields.insert("radius".into(), json!(5)); super::super::confirm(&mut app).unwrap(); let n = app.session.active().unwrap().doc.node(NodeId(id)).unwrap().clone(); - assert!(matches!(&n.kind, NodeKind::Path { live: Some(LiveShape::Path { .. }), .. })); + assert!(matches!(&n.kind, NodeKind::Path { live: Some(l), .. } if matches!(**l, LiveShape::Path { .. }))); assert_eq!(crate::panels::corner_radius(&app, &n), Some(5.0)); } } diff --git a/crates/ui-egui/src/dialogs/envelope.rs b/crates/ui-egui/src/dialogs/envelope.rs index 0a4d4aa3..0cdb2b24 100644 --- a/crates/ui-egui/src/dialogs/envelope.rs +++ b/crates/ui-egui/src/dialogs/envelope.rs @@ -238,7 +238,7 @@ fn bar_of(app: &VectorcraftApp) -> Option<(bool, Bar)> { let st = app.session.active()?; let env = st.selection.objects.first().and_then(|id| vectorcraft_doc::live::envelope_of(&st.doc, *id))?; let NodeKind::Envelope { kind, editing, .. } = &env.kind else { return None }; - let bar = match kind { + let bar = match &**kind { EnvelopeKind::Warp { style, bend, h, v, horizontal } => { Bar::Warp { style: style.clone(), bend: *bend, h: *h, v: *v, horizontal: *horizontal } } @@ -362,7 +362,8 @@ mod tests { assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); let (_, n) = envelope(&app); match &n.kind { - NodeKind::Envelope { kind: EnvelopeKind::Warp { style, bend, horizontal, .. }, content, .. } => { + NodeKind::Envelope { kind, content, .. } => { + let EnvelopeKind::Warp { style, bend, horizontal, .. } = &**kind else { panic!("{kind:?}") }; assert_eq!((style.as_str(), *bend, *horizontal), ("flag", -30.0, false)); assert_eq!(content[0].id, rect); } @@ -373,7 +374,10 @@ mod tests { let d = app.ui.dialog.as_ref().unwrap(); assert_eq!((d.fields["reset"].clone(), d.fields["style"].clone(), d.fields["bend"].clone()), (json!(true), json!("flag"), json!(-30.0))); crate::dialogs::cancel(&mut app); - assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { kind: EnvelopeKind::Warp { .. }, .. }), "cancel rolls back"); + assert!( + matches!(envelope(&app).1.kind, NodeKind::Envelope { ref kind, .. } if matches!(**kind, EnvelopeKind::Warp { .. })), + "cancel rolls back" + ); } #[test] @@ -404,13 +408,18 @@ mod tests { app.run("ui.menuDialog", json!({"command": RESET_MESH})).unwrap(); assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(MESH)); frame(&mut app); - assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows: 4, cols: 4, .. }, .. }), "previewed"); + assert!( + matches!(envelope(&app).1.kind, NodeKind::Envelope { ref kind, .. } if matches!(**kind, EnvelopeKind::Mesh { rows: 4, cols: 4, .. })), + "previewed" + ); crate::dialogs::cancel(&mut app); - assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { kind: EnvelopeKind::Warp { .. }, .. })); + assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { ref kind, .. } if matches!(**kind, EnvelopeKind::Warp { .. }))); app.run("ui.menuDialog", json!({"command": RESET_MESH})).unwrap(); app.ui.dialog.as_mut().unwrap().fields.insert("rows".into(), json!(2)); super::super::confirm(&mut app).unwrap(); - assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows: 2, cols: 4, .. }, .. })); + assert!( + matches!(envelope(&app).1.kind, NodeKind::Envelope { ref kind, .. } if matches!(**kind, EnvelopeKind::Mesh { rows: 2, cols: 4, .. })) + ); } #[test] diff --git a/crates/ui-egui/src/dialogs/graph_data.rs b/crates/ui-egui/src/dialogs/graph_data.rs index efcd65c7..fa9f5e65 100644 --- a/crates/ui-egui/src/dialogs/graph_data.rs +++ b/crates/ui-egui/src/dialogs/graph_data.rs @@ -52,8 +52,9 @@ pub fn open(app: &mut VectorcraftApp) -> Result { // The query fails without a graph selected, with the engine's own message. let v = app.session.execute("graph.setData", &json!({})).map_err(|e| e.to_string())?; let graph = app.session.active().and_then(|st| st.selection.objects.iter().find_map(|id| graph_of(&st.doc, *id)).map(|id| (st, id))); - let scatter = - graph.is_some_and(|(st, id)| st.doc.node(id).and_then(|n| n.graph.as_deref()).is_some_and(|g| g.kind == vectorcraft_doc::GraphKind::Scatter)); + let scatter = graph.is_some_and(|(st, id)| { + st.doc.node(id).and_then(|n| n.extras().graph.as_deref()).is_some_and(|g| g.kind == vectorcraft_doc::GraphKind::Scatter) + }); let id = graph.map(|(_, id)| id.0); let grid = grid_from_data(&v); let entry = cell(&grid, 0, 1); @@ -68,7 +69,7 @@ pub fn open(app: &mut VectorcraftApp) -> Result { fn graph_of(doc: &vectorcraft_doc::Document, id: vectorcraft_doc::NodeId) -> Option { let mut cur = Some(id); while let Some(c) = cur { - if doc.node(c).is_some_and(|n| n.graph.is_some()) { + if doc.node(c).is_some_and(|n| n.extras().graph.is_some()) { return Some(c); } cur = doc.parent_of(c); diff --git a/crates/ui-egui/src/panels/graphic_styles.rs b/crates/ui-egui/src/panels/graphic_styles.rs index f2f848a6..dcb0b6bb 100644 --- a/crates/ui-egui/src/panels/graphic_styles.rs +++ b/crates/ui-egui/src/panels/graphic_styles.rs @@ -935,7 +935,7 @@ mod tests { frame_with(&ctx, release(pos2(60.0, 116.0)), |ui| show(&mut app, ui)); let d = app.session.active().unwrap().doc.clone(); assert_eq!(d.graphic_styles.len(), n0.len() + 2); - assert_eq!(d.node(rect).unwrap().graphic_style, d.graphic_styles.last().map(|g| g.id)); + assert_eq!(d.node(rect).unwrap().extras().graphic_style, d.graphic_styles.last().map(|g| g.id)); } #[test] diff --git a/crates/ui-egui/src/panels/image_trace.rs b/crates/ui-egui/src/panels/image_trace.rs index f3d5cf7f..a46d852c 100644 --- a/crates/ui-egui/src/panels/image_trace.rs +++ b/crates/ui-egui/src/panels/image_trace.rs @@ -76,7 +76,7 @@ pub(crate) fn is_trace(n: &Node) -> bool { /// What the selection is: an Image Trace object (with its stored settings), a plain image, or neither. fn target(app: &VectorcraftApp) -> (bool, bool, Option) { let Some(n) = first_selected(app) else { return (false, false, None) }; - (is_trace(&n), matches!(n.kind, NodeKind::Image(_)), n.trace.map(|t| *t)) + (is_trace(&n), matches!(n.kind, NodeKind::Image(_)), n.extras().trace.as_deref().cloned()) } /// The one selected Image Trace object's preset ("Custom" once its settings were changed) and view. @@ -84,7 +84,7 @@ pub(crate) fn selected_trace(app: &VectorcraftApp) -> Option<(String, TraceView) let st = app.session.active()?; let [id] = st.selection.objects[..] else { return None }; let n = st.doc.node(id).filter(|n| is_trace(n))?; - Some((n.trace.as_ref().and_then(|t| t["preset"].as_str()).unwrap_or("Custom").to_string(), n.trace_view())) + Some((n.extras().trace.as_ref().and_then(|t| t["preset"].as_str()).unwrap_or("Custom").to_string(), n.trace_view())) } /// Image Trace › View, in the UI language. diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 43d62fcd..758a520f 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -723,7 +723,7 @@ fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) { // A clipping path's name is underlined, a masked object's with a dashed line. if clip_path { painter.line_segment([text.left_bottom(), text.right_bottom()], Stroke::new(1.0, t.text)); - } else if n.mask.is_some() { + } else if n.extras().mask.is_some() { painter.extend(egui::Shape::dashed_line(&[text.left_bottom(), text.right_bottom()], Stroke::new(1.0, t.text), 3.0, 2.0)); } } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 769d5b99..24159a5a 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -97,7 +97,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { divider(ui); section_header(ui, tl!("Quick Actions")); let is_group = matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Group { .. })); - let is_live = matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Path { live: Some(l), .. }) if !matches!(l, LiveShape::Path { .. })); + let is_live = matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Path { live: Some(l), .. }) if !matches!(**l, LiveShape::Path { .. })); let mut actions: Vec<(&str, &str)> = vec![]; if n_sel > 1 { actions.push((tl!("Group"), "object.group")); @@ -465,7 +465,7 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) { if let Some(n) = first_selected(app) && let NodeKind::Path { live: Some(live), .. } = &n.kind { - match live { + match &**live { vectorcraft_doc::LiveShape::Rectangle { .. } => corner_radius_row(app, ui, &n, "radius"), vectorcraft_doc::LiveShape::Polygon { .. } => super::transform::polygon_rows(app, ui, &n, live, "props-polygon"), vectorcraft_doc::LiveShape::Ellipse { pie, .. } => super::transform::pie_rows(app, ui, *pie, "props-pie"), diff --git a/crates/ui-egui/src/panels/transform.rs b/crates/ui-egui/src/panels/transform.rs index 949da2ac..6c3b5ed6 100644 --- a/crates/ui-egui/src/panels/transform.rs +++ b/crates/ui-egui/src/panels/transform.rs @@ -179,10 +179,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // Live shape properties (a path with live corners is no shape). if let Some(n) = first_selected(app) && let NodeKind::Path { live: Some(live), .. } = &n.kind - && !matches!(live, vectorcraft_doc::LiveShape::Path { .. }) + && !matches!(**live, vectorcraft_doc::LiveShape::Path { .. }) { widgets::divider(ui); - match live { + match &**live { vectorcraft_doc::LiveShape::Rectangle { .. } => { widgets::subheader(ui, tl!("Rectangle Properties:")); corner_radius_row(app, ui, &n, "xfp-radius"); diff --git a/crates/ui-egui/src/panels/transparency.rs b/crates/ui-egui/src/panels/transparency.rs index 7a5375d1..4e11bb01 100644 --- a/crates/ui-egui/src/panels/transparency.rs +++ b/crates/ui-egui/src/panels/transparency.rs @@ -65,7 +65,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { widgets::divider(ui); // Thumbnails and the opacity-mask controls. let hide_thumbs: bool = pstate(ui.ctx(), "tr-hide-thumbs"); - let mask = n.as_ref().and_then(|n| n.mask.as_deref()); + let mask = n.as_ref().and_then(|n| n.extras().mask.as_deref()); let (new_clip, new_invert) = app.session.new_mask_defaults(); ui.horizontal(|ui| { if !hide_thumbs { @@ -79,7 +79,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } if let Some(n) = &n { let mut bare = n.clone(); - bare.mask = None; + bare.extras_mut().mask = None; if !node_thumb(app, ui, "tr-obj", &bare, r.shrink(3.0), None) { let (f, s) = current_paints(app); let inner = r.shrink2(vec2(6.0, 10.0)); @@ -195,7 +195,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { // The masked object's mask, also while editing it (the selection then is the mask art). let editing = app.session.active().is_some_and(|d| d.doc.mask_edit.is_some()); let (_, n) = state(app, ui.ctx()); - let mask = n.as_ref().and_then(|n| n.mask.as_deref()); + let mask = n.as_ref().and_then(|n| n.extras().mask.as_deref()); for (label, id, enabled) in mask_items(mask, n.is_some() && !editing) { if menu_item(ui, tl!(label), enabled, false) { app.run(id, json!({})).ok(); @@ -320,7 +320,7 @@ mod tests { let (info, n) = state(&app, &egui::Context::default()); assert_eq!((info.ids, n.as_ref().map(|n| n.id.0)), (vec![vectorcraft_doc::NodeId(a)], Some(a))); // The menu is enabled for the masked object while editing (Make is not). - let items = mask_items(n.as_ref().and_then(|n| n.mask.as_deref()), false); + let items = mask_items(n.as_ref().and_then(|n| n.extras().mask.as_deref()), false); assert_eq!(items.map(|(_, _, on)| on), [false, true, true, true]); // The panel's controls run without ids and reach the masked object. run(&mut app, "transparency.setOpacityMask", json!({"clip": false})); @@ -329,7 +329,7 @@ mod tests { frame(&mut app); let st = app.session.active().unwrap(); let obj = st.doc.node(vectorcraft_doc::NodeId(a)).unwrap(); - let m = obj.mask.as_ref().unwrap(); + let m = obj.extras().mask.as_ref().unwrap(); assert!(!m.clip && !m.linked && obj.blend == BlendMode::Multiply); assert!(st.doc.mask_edit.is_some()); // Release leaves editing and puts the drawn mask art back. From 023c8e3edd7a266cae9d6827f85cf453178b8cd1 Mon Sep 17 00:00:00 2001 From: arhammusheer Date: Mon, 12 Oct 2026 02:32:50 +0530 Subject: [PATCH 07/12] #1244: streaming open and save, per-section limits, recovery that keeps images apart, background open Save serializes JSON straight into the compressor and writes images from their Arc into the file; open reads each image into its own buffer, and NodeKind reads its tag first instead of buffering every node. Already-compressed images are stored rather than deflated again in the same gzip stream. Limits apply per section, so any file VectorCraft writes opens again. Data Recovery writes each image once, content-addressed, instead of every interval. Native files open on a background thread. Files stay readable by main both ways; JSON-only saves are identical. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc --- apps/vectorcraft/src/main.rs | 4 + crates/doc/src/node.rs | 321 +++++++++++++++++++++- crates/engine/src/cmd/fileio/load.rs | 20 +- crates/engine/src/cmd/fileio/mod.rs | 21 +- crates/engine/src/cmd/fileio/native.rs | 10 +- crates/engine/src/cmd/fileio/save.rs | 92 ++++++- crates/engine/src/cmd/fileio/tests.rs | 30 +++ crates/engine/src/cmd/recovery.rs | 155 ++++++++++- crates/format/src/lib.rs | 312 ++++++++++++--------- crates/format/src/stream.rs | 358 +++++++++++++++++++++++++ crates/format/src/unpack.rs | 214 +++++++++++++++ crates/format/tests/streaming.rs | 199 ++++++++++++++ crates/ui-egui/src/background.rs | 5 + crates/ui-egui/src/chrome.rs | 10 +- crates/ui-egui/src/control.rs | 2 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/io.rs | 49 +++- crates/ui-egui/src/lib.rs | 16 +- crates/ui-egui/src/opening.rs | 130 +++++++++ crates/ui-egui/src/tests_save.rs | 24 ++ 29 files changed, 1838 insertions(+), 164 deletions(-) create mode 100644 crates/format/src/stream.rs create mode 100644 crates/format/src/unpack.rs create mode 100644 crates/format/tests/streaming.rs create mode 100644 crates/ui-egui/src/opening.rs diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index af4726e4..07a5d94f 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -501,6 +501,10 @@ fn services(parent: Parent, ui: egui::Context) -> Services { write: Some(Box::new(write_file)), // Background Save and Export write from a worker thread. write_shared: Some(std::sync::Arc::new(write_file)), + // Native saves stream into the file as they are encoded. + stream_shared: Some(std::sync::Arc::new(|p: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>| { + fileio::write_file_with(p, fill).map_err(|e| e.to_string()) + })), // Every format Copy offers and Paste reads (menu-bar Paste never sees egui's Paste event). system_clipboard: Some(clipboard::system_clipboard()), // Linux checks whether Paste has something to take on a background thread: an X11 clipboard diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 06db3cc8..47a6085c 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -394,7 +394,9 @@ pub struct ImageObject { pub placement: crate::links::PlacementOptions, } -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +/// Written with its `type` first, then its fields (an internally tagged enum); read by +/// [`kind_de`], which takes the fields straight from the file when `type` comes first. +#[derive(Clone, Debug, PartialEq, Serialize)] #[serde(tag = "type", rename_all = "lowercase")] pub enum NodeKind { /// A top-level layer or sublayer. @@ -1634,10 +1636,281 @@ impl Node { } } +/// Reading a [`NodeKind`]. Derived, an internally tagged enum buffers each kind (with every +/// object inside a group, again at each level of nesting) before reading it, since `type` may come +/// anywhere among its fields. Files have it first (it is written first): then the kind is read +/// straight from the file. Otherwise (hand-written files, values whose keys are sorted) the +/// fields before `type` are buffered, and only those. +mod kind_de { + use std::fmt; + use std::sync::Arc; + + use serde::de::value::{MapAccessDeserializer, StringDeserializer}; + use serde::de::{self, DeserializeSeed, Deserializer, EnumAccess, IntoDeserializer, MapAccess, VariantAccess, Visitor}; + use serde::{Deserialize, forward_to_deserialize_any}; + use vectorcraft_geom::{Affine, FillRule, PathData}; + + use super::{LayerColor, LiveShape, Node, NodeKind, yes}; + use crate::live::{BlendSpec, EnvelopeKind, GradientMesh}; + use crate::pattern::RepeatSpec; + use crate::text::TextObject; + + /// The kinds and their fields as [`NodeKind`] has them, with the defaults its field attributes + /// note (the compiler checks that every field is here), read externally tagged: the kind from + /// `type`, its fields from the rest of the object ([`Tagged`]). + #[derive(Deserialize)] + #[serde(remote = "NodeKind", rename_all = "lowercase")] + enum KindDef { + Layer { + color: LayerColor, + #[serde(default)] + template: bool, + #[serde(default = "yes")] + printable: bool, + children: Vec>, + #[serde(default)] + clip: bool, + #[serde(default = "yes")] + preview: bool, + #[serde(default)] + dim_images: Option, + }, + Group { + children: Vec>, + #[serde(default)] + clip: bool, + }, + Path { + path: PathData, + #[serde(default)] + rule: FillRule, + #[serde(default)] + live: Option>, + #[serde(default)] + clipping: bool, + #[serde(default)] + guide: bool, + }, + Compound { + children: Vec>, + #[serde(default)] + rule: FillRule, + }, + Text(Box), + Image(Box), + SymbolInstance { + symbol: String, + xf: Affine, + }, + Blend { + children: Vec>, + #[serde(default)] + spec: Box, + }, + Envelope { + content: Vec>, + kind: Box, + #[serde(default = "crate::live::default_fidelity")] + fidelity: f64, + #[serde(default)] + editing: bool, + #[serde(default)] + options: crate::live::EnvelopeOptions, + #[serde(default)] + frame: Box, + }, + Mesh(GradientMesh), + Repeat(RepeatSpec), + PlacedDocument(Box), + } + + impl<'de> Deserialize<'de> for NodeKind { + fn deserialize>(d: D) -> Result { + d.deserialize_map(KindVisitor) + } + } + + struct KindVisitor; + + impl<'de> Visitor<'de> for KindVisitor { + type Value = NodeKind; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("an object kind (a map with `type`)") + } + + fn visit_map>(self, mut map: A) -> Result { + let mut before = vec![]; + while let Some(key) = map.next_key::()? { + if key == "type" { + let tag: String = map.next_value()?; + return KindDef::deserialize(Tagged { tag, map: Buffered { before: before.into_iter(), value: None, map } }); + } + before.push((key, map.next_value::()?)); + } + Err(de::Error::missing_field("type")) + } + } + + /// The fields read before `type` (buffered), then the rest of the object. + struct Buffered { + before: std::vec::IntoIter<(String, serde_json::Value)>, + value: Option, + map: A, + } + + impl<'de, A: MapAccess<'de>> MapAccess<'de> for Buffered { + type Error = A::Error; + + fn next_key_seed>(&mut self, seed: K) -> Result, A::Error> { + match self.before.next() { + Some((key, value)) => { + self.value = Some(value); + seed.deserialize(StringDeserializer::::new(key)).map(Some) + } + None => self.map.next_key_seed(seed), + } + } + + fn next_value_seed>(&mut self, seed: V) -> Result { + match self.value.take() { + Some(value) => seed.deserialize(value).map_err(de::Error::custom), + None => self.map.next_value_seed(seed), + } + } + } + + /// An object whose `type` was read: the kind is `tag`, its fields are the rest of `map`. + struct Tagged { + tag: String, + map: A, + } + + impl<'de, A: MapAccess<'de>> Deserializer<'de> for Tagged { + type Error = A::Error; + + fn deserialize_any>(self, visitor: V) -> Result { + visitor.visit_enum(self) + } + + forward_to_deserialize_any! { + bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string bytes byte_buf + option unit unit_struct newtype_struct seq tuple tuple_struct map struct enum + identifier ignored_any + } + } + + impl<'de, A: MapAccess<'de>> EnumAccess<'de> for Tagged { + type Error = A::Error; + type Variant = Fields; + + fn variant_seed>(self, seed: V) -> Result<(V::Value, Fields), A::Error> { + let kind = seed.deserialize(self.tag.into_deserializer())?; + Ok((kind, Fields(self.map))) + } + } + + /// A kind's fields: the rest of the object. + struct Fields(A); + + impl<'de, A: MapAccess<'de>> VariantAccess<'de> for Fields { + type Error = A::Error; + + fn unit_variant(self) -> Result<(), A::Error> { + Err(de::Error::invalid_type(de::Unexpected::UnitVariant, &"an object kind with fields")) + } + + fn newtype_variant_seed>(self, seed: T) -> Result { + seed.deserialize(MapAccessDeserializer::new(self.0)) + } + + fn tuple_variant>(self, _: usize, _: V) -> Result { + Err(de::Error::invalid_type(de::Unexpected::TupleVariant, &"an object kind with fields")) + } + + fn struct_variant>(self, _: &'static [&'static str], visitor: V) -> Result { + visitor.visit_map(self.0) + } + } +} + #[cfg(test)] mod tests { use super::*; + /// A node writes its rarely set parts among the others, in the file's field order, and reads + /// them back into its extras; a node without any keeps none. + #[test] + fn extras_are_written_flat_in_order() { + let mut n = Node::path(NodeId(7), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art()); + n.name = Some("n".into()); + n.knockout_shape = true; + n.bbox_angle = 30.0; + { + let mut e = n.extras_mut(); + e.mask = Some(Box::new(OpacityMask::new(Node::group(NodeId(8), vec![]), true))); + e.trace = Some(Box::new(serde_json::json!({"preset": "p"}))); + e.series_index = Some(2); + e.graphic_style = Some(3); + e.attrs = Some(Box::default()); + e.slice = Some(Box::default()); + e.perspective = Some(Box::new(crate::PerspectiveAttachment::new("left", 1.0))); + } + /// The top-level keys of a JSON object, in the order written. + struct Keys(Vec); + impl<'de> Deserialize<'de> for Keys { + fn deserialize>(d: D) -> Result { + struct V; + impl<'de> serde::de::Visitor<'de> for V { + type Value = Keys; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("an object") + } + fn visit_map>(self, mut a: A) -> Result { + let mut out = vec![]; + while let Some(k) = a.next_key::()? { + a.next_value::()?; + out.push(k); + } + Ok(Keys(out)) + } + } + d.deserialize_map(V) + } + } + let text = serde_json::to_string(&n).unwrap(); + let keys = serde_json::from_str::(&text).unwrap().0; + let v: serde_json::Value = serde_json::from_str(&text).unwrap(); + let order = [ + "id", + "name", + "knockout_shape", + "appearance", + "mask", + "trace", + "series_index", + "kind", + "graphic_style", + "attrs", + "slice", + "bbox_angle", + "perspective", + ]; + assert_eq!(keys, order); + assert_eq!(serde_json::from_value::(v).unwrap(), n); + let plain = Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art()); + let back: Node = serde_json::from_value(serde_json::to_value(&plain).unwrap()).unwrap(); + assert!(back.extras_box().is_none()); + // Clearing the last extra lets them go; clearing one a node doesn't have allocates none. + n.extras_mut().mask = None; + assert!(n.extras_box().is_some()); + *n.extras_mut() = NodeExtras::default(); + assert!(n.extras_box().is_none()); + n.extras_mut().mask = None; + assert!(n.extras_box().is_none()); + assert_eq!(n.extras(), &NodeExtras::default()); + } + #[test] fn rect_live_shape_roundtrip() { let l = LiveShape::Rectangle { w: 10.0, h: 20.0, radii: [0.0; 4], kinds: Default::default(), xf: Affine::translate((5.0, 5.0)) }; @@ -1901,4 +2174,50 @@ mod tests { p.appearance.stroke_mut().unwrap().join = crate::appearance::LineJoin::Round; assert_eq!(p.visual_bounds(), Some(Rect::new(-0.5, -0.5, 10.5, 10.5))); } + + /// A group holding a path, a compound, an image and a nested group, as a file writes it. + fn nested_group() -> Node { + let path = |id| Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + let image = ImageObject { key: "k".into(), width: 2, height: 3, xf: Affine::IDENTITY, link: None, placement: Default::default() }; + let inner = Node::new(NodeId(5), NodeKind::Group { children: vec![Arc::new(path(6))], clip: true }); + let compound = Node::new(NodeId(3), NodeKind::Compound { children: vec![Arc::new(path(4))], rule: FillRule::EvenOdd }); + let kids = vec![Arc::new(path(2)), Arc::new(compound), Arc::new(Node::new(NodeId(7), NodeKind::Image(Box::new(image)))), Arc::new(inner)]; + Node::new(NodeId(1), NodeKind::Group { children: kids, clip: false }) + } + + #[test] + fn kinds_read_back_with_type_first_or_anywhere() { + let n = nested_group(); + let json = serde_json::to_string(&n).unwrap(); + assert!(json.contains(r#""kind":{"type":"group","children":["#), "written with `type` first: {json}"); + assert_eq!(serde_json::from_str::(&json).unwrap(), n); + // Through a JSON value, whose keys are sorted: `type` comes after `children`. + let v = serde_json::to_value(&n).unwrap(); + assert_eq!(serde_json::from_value::(v.clone()).unwrap(), n); + assert_eq!(serde_json::from_str::(&v.to_string()).unwrap(), n); + // `type` last, in the middle, and keys a newer version added (ignored, as before). + let kind = r#"{"children":[],"x":{"deep":[1,2]},"clip":true,"type":"group"}"#; + assert_eq!(serde_json::from_str::(kind).unwrap(), NodeKind::Group { children: vec![], clip: true }); + let kind = r#"{"symbol":"S","type":"symbolinstance","later":1,"xf":[1,0,0,1,5,6]}"#; + assert_eq!( + serde_json::from_str::(kind).unwrap(), + NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((5.0, 6.0)) } + ); + let kind = r#"{"width":2,"type":"image","key":"k","height":3,"xf":[1,0,0,1,0,0]}"#; + assert!(matches!(serde_json::from_str::(kind).unwrap(), NodeKind::Image(i) if i.key == "k" && i.height == 3)); + // Defaults as before. + let NodeKind::Layer { printable, preview, template, .. } = + serde_json::from_str(r#"{"type":"layer","color":{"Preset":0},"children":[]}"#).unwrap() + else { + panic!("a layer") + }; + assert!(printable && preview && !template); + } + + #[test] + fn bad_kinds_are_errors() { + for bad in [r#"{"children":[]}"#, r#"{"type":"nothing"}"#, r#"{"type":"group"}"#, r#"{"type":3,"children":[]}"#, r#"[]"#, r#""group""#] { + assert!(serde_json::from_str::(bad).is_err(), "{bad}"); + } + } } diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 534d86c8..6d44effc 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -283,8 +283,26 @@ pub fn open_bytes(s: &mut Session, name: &str, bytes: &[u8], path: Option, p: &Value) -> Result { + open_read(s, read_for_open(name, bytes, p)?, path) +} + +/// A file read for `document.open` but not opened yet ([`read_for_open`], [`open_read`]). +pub struct ReadFile { + loaded: Loaded, + opts: LoadOptions, +} + +/// The first half of [`open_bytes_with`]: read and decode the file, which needs no session (an +/// app may do it on another thread) → what [`open_read`] opens. +pub fn read_for_open(name: &str, bytes: &[u8], p: &Value) -> Result { let opts = LoadOptions::from_params("document.open", p)?; - open_loaded(s, load_with(name, bytes, &opts)?, path, &opts, false) + Ok(ReadFile { loaded: load_with(name, bytes, &opts)?, opts }) +} + +/// The second half of [`open_bytes_with`]: open a file [`read_for_open`] read as the new active +/// document (`path`: where it was read from) → `document.open`'s result. +pub fn open_read(s: &mut Session, file: ReadFile, path: Option) -> Result { + open_loaded(s, file.loaded, path, &file.opts, false) } /// File → New from Template: any readable file (at `path`, if it is one: its links are looked for diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index bfa892dd..8d53dd3c 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -53,15 +53,18 @@ use load::err; pub(crate) use load::import_svg; pub(crate) use load::native_file; pub(crate) use load::source; -pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_template, raster_image}; +pub use load::{ + Loaded, RasterImage, ReadFile, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_read, open_template, raster_image, + read_for_open, +}; pub use load::{losses_summary, overwrite_losses}; pub(crate) use native::preview_png; pub use native::with_compression_pref; pub use pdfimport::{LoadOptions, page_document}; pub(crate) use save::job_for; pub use save::{ - SAVE_FORMATS, SaveJob, SaveMode, SavePlan, export_folder, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, - templates_dialog_folder, templates_folder, + SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, export_folder, save_filters, save_format, save_job, save_plan, save_with, + stamp_save_dates, templates_dialog_folder, templates_folder, }; pub use screens::{PRESETS as SCREEN_PRESETS, ScreenSize, preset_rows as screen_preset_rows}; pub(crate) use screens::{ @@ -769,6 +772,13 @@ pub(crate) fn write_file(path: &str, bytes: &[u8]) -> Result<()> { write_atomic(std::path::Path::new(path), bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))) } +/// [`write_file`] with what `fill` writes into the file (a save streamed into it). +#[cfg(not(target_arch = "wasm32"))] +pub fn write_file_with(path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()> { + may_write(path)?; + vectorcraft_format::write_atomic_with(std::path::Path::new(path), |f| fill(f)).map_err(|e| EngineError::Other(format!("{path}: {e}"))) +} + #[cfg(not(target_arch = "wasm32"))] pub(crate) fn create_dir(path: &str) -> Result<()> { may_write(path)?; @@ -938,6 +948,11 @@ pub(crate) fn write_file(path: &str, _: &[u8]) -> Result<()> { Err(no_fs(path)) } +#[cfg(target_arch = "wasm32")] +pub fn write_file_with(path: &str, _: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()> { + Err(no_fs(path)) +} + #[cfg(target_arch = "wasm32")] pub(crate) fn create_dir(path: &str) -> Result<()> { Err(no_fs(path)) diff --git a/crates/engine/src/cmd/fileio/native.rs b/crates/engine/src/cmd/fileio/native.rs index 289bfcff..83d9ab45 100644 --- a/crates/engine/src/cmd/fileio/native.rs +++ b/crates/engine/src/cmd/fileio/native.rs @@ -135,6 +135,14 @@ pub(super) fn save_options(cmd: &str, f: &Format, doc: &Document, p: &Value) -> /// The `.vectorcraft` file of `doc` with the options in `p`. pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result> { + let mut out = Vec::new(); + encode_to(cmd, f, doc, p, &mut out)?; + Ok(out) +} + +/// [`encode`], written to `w` as it is made ([`vectorcraft_format::save_to`]: give it a buffered +/// writer). +pub(super) fn encode_to(cmd: &str, f: &Format, doc: &Document, p: &Value, w: impl std::io::Write) -> Result<()> { let (mut so, extras) = save_options(cmd, f, doc, p)?; // Include Linked Files keeps placed documents' files; older versions get their art. let full = if so.include_linked || so.version < vectorcraft_format::VERSION { @@ -158,7 +166,7 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result } other => other, }; - vectorcraft_format::save_with(&native, &so).map_err(|e| bad(cmd, e.to_string())) + vectorcraft_format::save_to(&native, &so, w).map_err(|e| bad(cmd, e.to_string())) } /// The ICC files of the profiles `doc` is tagged with that were loaded from files (the built-in diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 75a179cf..923f7c80 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -6,6 +6,7 @@ //! the caller's writer (the file system here; a save panel or a browser download in the apps) and //! makes the file the document's own (path, format, options, saved state) for Save and Save As. +use std::io::Write as _; use std::sync::Arc; use serde_json::{Map, Value, json}; @@ -14,7 +15,6 @@ use vectorcraft_doc::Document; use super::super::*; use super::{ ArtboardPick, Encoded, Format, Loaded, encode_all, file_stem, format, format_for_name, load, read_file, with_compression_pref, write_encoded, - write_file, }; use crate::{DocState, Prefs}; @@ -300,6 +300,31 @@ pub struct SaveJob { boards: Vec, } +/// Writes the file at a path with what the filler it is given writes into it +/// ([`SaveJob::write_streamed`]). +pub type StreamWrite<'a> = &'a mut dyn FnMut(&str, &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()>; + +/// Bytes gathered before a streamed save writes them to the file. +const STREAM_BUFFER: usize = 256 << 10; + +/// A writer that counts the bytes written through it. +struct Counted { + inner: W, + n: u64, +} + +impl std::io::Write for Counted { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let n = self.inner.write(buf)?; + self.n += n as u64; + Ok(n) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.inner.flush() + } +} + /// The artboards a native or `.ai` save also writes to files of their own: with /// `separateArtboards`, those `range` names (default all). fn separate_boards(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result> { @@ -443,6 +468,16 @@ impl SaveJob { &self.snapshot } + /// The document the job writes (the snapshot, as the file keeps it). + pub(crate) fn doc(&self) -> &Arc { + &self.doc + } + + /// Encode `doc` (the job's document, changed) as the job encodes its own: one file. + pub(crate) fn encode_doc(&self, doc: &Document) -> Result { + self.encode_one(doc) + } + /// Encode the file → it (and, with `separateArtboards`, a file per artboard after it), with /// the warnings (what the format loses first, then the encoder's own notes: PDF options not /// applied yet…). @@ -500,6 +535,53 @@ impl SaveJob { Ok(out) } + /// Is the file written as it is encoded by [`SaveJob::write_streamed`]? A native file (or + /// template) to a path, without a file per artboard. + pub fn streams(&self) -> bool { + self.plan.path.is_some() && matches!(self.plan.format.id, "vectorcraft" | "template") && self.boards.is_empty() + } + + /// [`SaveJob::write`] through `stream`, which writes the file at a path with what the filler + /// it is given writes (atomically, as [`vectorcraft_format::write_atomic_with`]). A native + /// file goes into it as it is encoded ([`SaveJob::streams`]), never held whole in memory; + /// other files are encoded first, then written into it. + pub fn write_streamed(&self, stream: StreamWrite) -> Result { + let (Some(path), true) = (&self.plan.path, self.streams()) else { + return self.write(|p, b| stream(p, &mut |w| w.write_all(b))); + }; + let (cmd, f) = (self.plan.mode.command(), self.plan.format); + let doc = self.doc.without_edit_modes(); + let doc = match f.id { + // A native file flagged so opening it starts a new untitled document (as + // `encode_all` writes it). + "template" => { + let mut d = doc.into_owned(); + d.template = true; + std::borrow::Cow::Owned(d) + } + _ => doc, + }; + // The encoder's failure, kept apart from the writer's (which `stream` reports). + let mut failed = None; + let mut written = 0u64; + let mut fill = |w: &mut dyn std::io::Write| { + let mut out = Counted { inner: std::io::BufWriter::with_capacity(STREAM_BUFFER, w), n: 0 }; + let r = super::native::encode_to(cmd, f, &doc, &self.params, &mut out); + written = out.n; + match r { + Ok(()) => out.inner.flush(), + Err(e) => { + failed = Some(e); + Err(std::io::Error::other("the file couldn't be encoded")) + } + } + }; + let r = stream(path, &mut fill); + r.map_err(|e| failed.take().unwrap_or(e))?; + let warnings: Vec = fidelity_warning(f).into_iter().collect(); + Ok(json!({ "path": path, "bytes": written, "format": f.id, "warnings": warnings })) + } + /// Once written: Save and Save As (except to a template) make the file `st`'s own: path, /// title, format, options, and the snapshot as the saved state (edits made since the job was /// made keep the document modified). @@ -543,10 +625,14 @@ pub fn save_with(s: &mut Session, plan: SavePlan, write: impl FnMut(&str, &[u8]) Ok(out) } -/// A save command: plan, then write with the file system. +/// A save command: plan, then write with the file system (a native file as it is encoded). fn save(s: &mut Session, mode: SaveMode, p: &Value) -> Result { let plan = save_plan(s, mode, p)?; - save_with(s, plan, write_file) + let job = save_job(s, plan)?; + let uid = s.doc()?.uid; + let out = job.write_streamed(&mut super::write_file_with)?; + job.complete(s, uid); + Ok(out) } fn can_revert(s: &Session) -> std::result::Result<(), String> { diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index d4f6554e..5af1e823 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -412,3 +412,33 @@ fn new_files_never_replace_a_file() { assert_eq!(std::fs::read(&path).unwrap(), b"first"); let _ = std::fs::remove_dir_all(d); } + +#[test] +fn a_native_save_streams_the_bytes_the_encoder_makes() { + let d = tmp_dir("stream"); + let mut s = session(200.0, 100.0, 1); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap(); + for (name, compress) in [("a.vectorcraft", false), ("b.vectorcraft", true), ("c.vctemplate", true)] { + let path = d.join(name).to_string_lossy().to_string(); + let p = json!({"path": path, "compress": compress}); + let r = s.execute("file.saveCopy", &p).unwrap(); + let written = std::fs::read(&path).unwrap(); + assert_eq!(r["bytes"], written.len(), "{r}"); + let plan = save_plan(&s, SaveMode::Copy, &p).unwrap(); + let job = save_job(&mut s, plan).unwrap(); + assert!(job.streams()); + assert_eq!(written, job.encode().unwrap().files[0].1, "{name}"); + assert_eq!(vectorcraft_format::load(&written).unwrap().template, name.ends_with("vctemplate")); + } + // A writer that fails: the save fails with its error. + let plan = save_plan(&s, SaveMode::Copy, &json!({"path": "/x/y.vectorcraft"})).unwrap(); + let job = save_job(&mut s, plan).unwrap(); + let e = job + .write_streamed(&mut |_, fill| { + fill(&mut std::io::sink()).unwrap(); + Err(EngineError::Other("disk full".into())) + }) + .unwrap_err(); + assert_eq!(e.to_string(), "disk full"); + let _ = std::fs::remove_dir_all(&d); +} diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index 83623a50..f466355c 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -26,17 +26,21 @@ //! again. //! //! Each copy is two entries: `/.vectorcraft` (the native document, written as a save -//! writes it) and `/.json` (its title, original path and format, and when it was -//! written). The store is a folder on the desktop ([`FolderStore`]: the `recoveryFolder` +//! writes it, without its images) and `/.json` (its title, original path and format, +//! when it was written, and its images). The images are entries of their own, `/img-…blob`, +//! named by their content and shared by the area's copies: a copy written again writes only the +//! images that changed, and the images no copy names any more go. Copies written before images +//! were kept apart (the images in the document entry) still restore. The store is a folder on the desktop ([`FolderStore`]: the `recoveryFolder` //! preference, else the folder the app sets), browser storage on the web, or any //! [`RecoveryStore`] the host installs (tests use [`MemoryStore`]). use std::collections::{BTreeMap, BTreeSet}; -use std::sync::{Arc, Mutex}; +use std::hash::{Hash, Hasher}; +use std::sync::{Arc, Mutex, Weak}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use vectorcraft_doc::Document; +use vectorcraft_doc::{Document, ImageBlob}; use super::fileio::{self, SAVE_FORMATS, SaveJob, SaveMode, SavePlan}; use super::*; @@ -50,6 +54,8 @@ pub const COMPLEX_OBJECTS: usize = 20_000; const DOC_EXT: &str = ".vectorcraft"; /// The extension of a copy's details entry. const META_EXT: &str = ".json"; +/// The extension of an image entry (see [`blob_name`]). +const BLOB_EXT: &str = ".blob"; /// The heartbeat entry of an area in a store without locks: when its app last said it runs (Unix /// seconds, as text). const BEAT: &str = "heartbeat"; @@ -527,6 +533,73 @@ struct Meta { /// (see [`crate::DocState::import_losses`]): a restored copy still refuses to replace it. imported_from: Option, import_losses: Vec, + /// The document's images by key, in entries of the area (none in copies written before + /// images were kept apart: the document entry holds them). + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + images: BTreeMap, +} + +/// One of a copy's images: its entry in the copy's area. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(default)] +struct ImageRef { + mime: String, + /// The entry's name in the area ([`blob_name`]). + blob: String, + /// A linked image's preview, as a save writes it ([`ImageBlob::proxy`]). + #[serde(skip_serializing_if = "std::ops::Not::not")] + proxy: bool, +} + +/// Writes of image entries and their sweeping ([`sweep`]) take turns, in this app (the background +/// worker and the UI thread), so an image is never swept between being written and named. +static WRITES: Mutex<()> = Mutex::new(()); + +/// Image bytes and the name of their entry ([`blob_name`]). +type Named = (Weak>, String); + +/// The entry names of image bytes already hashed ([`blob_name`]), so an image is hashed once, +/// not at every interval. Bytes behind a live `Weak` never change (`Arc::make_mut` copies them). +static NAMES: Mutex> = Mutex::new(Vec::new()); + +/// The name of the entry holding `bytes`: from their content (128 bits of hash and the length), so +/// identical images share one entry and one written before isn't written again. +fn blob_name(bytes: &Arc>) -> String { + let mut names = NAMES.lock().unwrap_or_else(|e| e.into_inner()); + names.retain(|(w, _)| w.strong_count() > 0); + if let Some((_, name)) = names.iter().find(|(w, _)| std::ptr::eq(w.as_ptr(), Arc::as_ptr(bytes))) { + return name.clone(); + } + let hash = |seed: u8| { + let mut h = std::collections::hash_map::DefaultHasher::new(); + seed.hash(&mut h); + bytes.as_slice().hash(&mut h); + h.finish() + }; + let name = format!("img-{:016x}{:016x}-{}{BLOB_EXT}", hash(1), hash(2), bytes.len()); + names.push((Arc::downgrade(bytes), name.clone())); + name +} + +/// The MIME type of a linked image's preview (JPEG or PNG), as a save names it. +fn proxy_mime(bytes: &[u8]) -> &'static str { + if bytes.starts_with(&[0xff, 0xd8]) { "image/jpeg" } else { vectorcraft_doc::links::PROXY_MIME } +} + +/// Remove the image entries of `area` no copy's details name any more. Best effort: an entry left +/// behind goes at the next sweep, or with the area. +fn sweep(store: &dyn RecoveryStore, area: &str) { + let Ok(names) = store.list() else { return }; + let in_area = |n: &&String| n.split_once('/').is_some_and(|(a, _)| a == area); + let mut named = BTreeSet::new(); + for meta in names.iter().filter(in_area).filter_map(|n| n.strip_suffix(META_EXT)) { + named.extend(read_meta(store, meta).images.into_values().map(|r| r.blob)); + } + for n in names.iter().filter(in_area).filter(|n| n.ends_with(BLOB_EXT)) { + if n.split_once('/').is_some_and(|(_, b)| !named.contains(b)) { + let _ = store.remove(n); + } + } } fn read_meta(store: &dyn RecoveryStore, file: &str) -> Meta { @@ -548,14 +621,44 @@ pub struct RecoveryJob { } impl RecoveryJob { - /// Encode the snapshot and store it with its details → `{file, title, bytes}`. + /// Encode the snapshot and store it with its details and the images the area doesn't have + /// yet → `{file, title, bytes (the document's), imageBytes (images written now)}`. pub fn write(&self, store: &dyn RecoveryStore) -> std::result::Result { - let enc = self.save.encode().map_err(|e| e.to_string())?; + let doc = self.save.doc(); + // A linked image keeps its preview, as a save writes it. + let linked = doc.linked_only_images(); + let mut lean = Document::clone(doc); + let blobs = std::mem::take(&mut lean.images); + let enc = self.save.encode_doc(&lean).map_err(|e| e.to_string())?; let bytes = enc.files.first().map(|f| f.1.as_slice()).ok_or("nothing encoded")?; - let meta = serde_json::to_vec_pretty(&self.meta).map_err(|e| e.to_string())?; + let mut meta = self.meta.clone(); + let mut images = vec![]; + for (key, b) in &blobs { + let (mime, data, proxy) = match b.proxy.as_ref().filter(|_| linked.contains(key)) { + Some(p) => (proxy_mime(p), p, true), + None => (b.mime.as_str(), &b.bytes, false), + }; + let blob = blob_name(data); + meta.images.insert(key.clone(), ImageRef { mime: mime.into(), blob: blob.clone(), proxy }); + images.push((blob, data)); + } + let meta_bytes = serde_json::to_vec_pretty(&meta).map_err(|e| e.to_string())?; + let area = self.file.split_once('/').map_or("", |(a, _)| a); + let _turn = WRITES.lock().unwrap_or_else(|e| e.into_inner()); + let have: BTreeSet = if images.is_empty() { BTreeSet::new() } else { store.list()?.into_iter().collect() }; + let mut image_bytes = 0; + for (blob, data) in images { + let name = format!("{area}/{blob}"); + if !have.contains(&name) { + store.write(&name, data)?; + image_bytes += data.len(); + } + } store.write(&format!("{}{DOC_EXT}", self.file), bytes)?; - store.write(&format!("{}{META_EXT}", self.file), &meta)?; - Ok(json!({ "file": self.file, "title": self.title, "bytes": bytes.len() })) + store.write(&format!("{}{META_EXT}", self.file), &meta_bytes)?; + // Images this copy no longer shows, nor any other. + sweep(store, area); + Ok(json!({ "file": self.file, "title": self.title, "bytes": bytes.len(), "imageBytes": image_bytes })) } /// Once stored: the document (if still open) remembers the copy, so it isn't written again @@ -662,6 +765,8 @@ pub fn jobs(s: &mut Session, store: &Arc) -> Result<(Vec, file: &str) -> R let bytes = store.read(&format!("{file}{DOC_EXT}")).map_err(EngineError::Other)?; let meta_bytes = store.read(&format!("{file}{META_EXT}")).unwrap_or_default(); let meta: Meta = serde_json::from_slice(&meta_bytes).unwrap_or_default(); - let (mut doc, _, warnings) = fileio::native_file(&bytes).map_err(|e| EngineError::Other(format!("recovery copy `{file}`: {e}")))?; + let (mut doc, _, mut warnings) = fileio::native_file(&bytes).map_err(|e| EngineError::Other(format!("recovery copy `{file}`: {e}")))?; + // The images, from the entries of the copy's area. + let from = file.split_once('/').map_or("", |(a, _)| a); + let mut blobs = vec![]; + for (key, r) in &meta.images { + match store.read(&format!("{from}/{}", r.blob)) { + Ok(data) => { + let mut blob = ImageBlob::new(r.mime.clone(), data); + // Until the linked file is read, the preview stands in for it. + if r.proxy { + blob.proxy = Some(blob.bytes.clone()); + } + blobs.push((r.blob.clone(), blob.bytes.clone())); + doc.images.insert(key.clone(), blob); + } + Err(e) => warnings.push(format!("the image “{key}” of the recovered document couldn't be read: {e}")), + } + } let area = claim(s, store)?; let mut taken: Vec = copies_in(&store.list().map_err(EngineError::Other)?).into_iter().map(|(_, f)| f).collect(); taken.extend(open_copies(s)); @@ -838,8 +960,15 @@ fn restore_one(s: &mut Session, store: &Arc, file: &str) -> R f if taken.contains(&f) => fresh_name(&area, stem, &taken), f => f, }; - store.write(&format!("{mine}{DOC_EXT}"), &bytes).map_err(EngineError::Other)?; - store.write(&format!("{mine}{META_EXT}"), &meta_bytes).map_err(EngineError::Other)?; + { + let _turn = WRITES.lock().unwrap_or_else(|e| e.into_inner()); + for (blob, data) in &blobs { + store.write(&format!("{area}/{blob}"), data).map_err(EngineError::Other)?; + } + store.write(&format!("{mine}{DOC_EXT}"), &bytes).map_err(EngineError::Other)?; + store.write(&format!("{mine}{META_EXT}"), &meta_bytes).map_err(EngineError::Other)?; + } + // The copy's images in its old area go with the area, once no copy is left there. store.remove(&format!("{file}{DOC_EXT}")).map_err(EngineError::Other)?; store.remove(&format!("{file}{META_EXT}")).map_err(EngineError::Other)?; if doc.title.is_empty() { @@ -901,7 +1030,7 @@ pub fn specs() -> Vec { "Save Recovery Data", [], None, - "{} write a recovery copy of every modified document changed since its last copy, in this app's own area of the store (the app does this every autosaveInterval minutes while autosaveRecovery is on; documents of more than 20000 objects are skipped while recoveryOffForComplex is on). A copy goes when its document is saved, reverted or closed → {saved: [{file, title, bytes}], skipped: [{title, reason}], location} (location: the recovery folder, or browser storage on the web)", + "{} write a recovery copy of every modified document changed since its last copy, in this app's own area of the store (the app does this every autosaveInterval minutes while autosaveRecovery is on; documents of more than 20000 objects are skipped while recoveryOffForComplex is on). Images are stored apart, each once while unchanged. A copy goes when its document is saved, reverted or closed → {saved: [{file, title, bytes, imageBytes (images written this time)}], skipped: [{title, reason}], location} (location: the recovery folder, or browser storage on the web)", has_store, save ), diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index bf859b73..5046d6ed 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -34,16 +34,19 @@ #[cfg(not(target_arch = "wasm32"))] mod atomic; mod legacy; +mod stream; +mod unpack; use std::borrow::Cow; use std::collections::{BTreeMap, HashSet}; -use std::io::{Read as _, Write as _}; +use std::io::{Read as _, Write}; use serde::{Deserialize, Serialize}; use vectorcraft_doc::{Document, ImageBlob}; #[cfg(not(target_arch = "wasm32"))] pub use atomic::{write_atomic, write_atomic_with, write_new_with}; +use stream::{Gz, Indent, KeyScan, Out}; /// v4: large data after the JSON (v1 to v3 files still load). pub const VERSION: u32 = 4; @@ -60,7 +63,17 @@ pub const PREVIEW_MAX: u32 = 256; pub const EXTENSION: &str = "vectorcraft"; /// Extension and format name from before the project was renamed (DrawCraft): still opened. pub const LEGACY_EXTENSION: &str = "drawcraft"; -/// Most bytes a compressed file may unpack to (a guard against decompression bombs). +/// Most bytes the JSON of a compressed file may unpack to (a guard against decompression bombs, +/// as are [`MAX_BLOB`] and [`MAX_BLOBS`]). A compressed save refuses a document past any of them, +/// so every file VectorCraft writes reads back. +pub const MAX_JSON: u64 = 1 << 30; +/// Most bytes one entry of the data after the JSON (an image, a placed document, the PDF, a +/// profile) may have in a compressed file. +pub const MAX_BLOB: u64 = 1 << 30; +/// Most bytes the entries after the JSON may have together in a compressed file. +pub const MAX_BLOBS: u64 = 4 << 30; +/// Most bytes a compressed file whose JSON isn't followed by the marker where VectorCraft puts it +/// (hand-made or damaged files) may unpack to: it is unpacked whole. const MAX_UNPACKED: u64 = 512 << 20; /// Is `ext` (without the dot, any case) a native document extension? @@ -85,8 +98,10 @@ pub enum FormatError { BadVersion(u32), #[error("version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files")] CompressedTooOld(u32), - #[error("the compressed file unpacks to more than {} MB", MAX_UNPACKED >> 20)] + #[error("the compressed file unpacks to more than VectorCraft reads ({} MB of document, {} MB per image)", MAX_JSON >> 20, MAX_BLOB >> 20)] TooBig, + #[error("the document is too large to save compressed ({0}): save it with Use Compression off")] + TooBigToCompress(String), #[error("can't encode the document: {0}")] Encode(String), } @@ -106,37 +121,43 @@ struct Image { } impl Image { - /// Its bytes: the base64 `data`, or its part of `tail` (the data after the JSON). - fn bytes(&self, tail: &[u8]) -> Option> { + /// Its bytes when they are in the JSON (base64 `data`); `None` when they come after it + /// ([`Image::blob`]) or don't decode. + fn inline(&self) -> Option> { match self.blob { - Some([offset, len]) => { - let start = usize::try_from(offset).ok()?; - let end = start.checked_add(usize::try_from(len).ok()?)?; - tail.get(start..end).map(<[u8]>::to_vec) - } + Some(_) => None, None => base64_decode(&self.data), } } } /// Entries written for one file: large bytes go to the data after the JSON when the version keeps -/// them there. -struct Blobs { - tail: Vec, +/// them there (in the order they are added, each at the offset it is given). +struct Blobs<'a> { + /// The bytes, and whether they are compressed already ([`is_packed`]). + tail: Vec<(&'a [u8], bool)>, + at: u64, outside: bool, } -impl Blobs { - fn image(&mut self, mime: &str, bytes: &[u8], proxy: bool) -> Image { +impl<'a> Blobs<'a> { + fn image(&mut self, mime: &str, bytes: &'a [u8], proxy: bool) -> Image { if self.outside && bytes.len() >= INLINE_MAX { - let at = self.tail.len() as u64; - self.tail.extend_from_slice(bytes); - return Image { mime: mime.into(), data: String::new(), blob: Some([at, bytes.len() as u64]), proxy }; + let len = bytes.len() as u64; + let at = self.at; + self.at += len; + self.tail.push((bytes, is_packed(mime))); + return Image { mime: mime.into(), data: String::new(), blob: Some([at, len]), proxy }; } Image { mime: mime.into(), data: base64_encode(bytes), blob: None, proxy } } } +/// Is data of this MIME type compressed already (no use compressing it again)? +fn is_packed(mime: &str) -> bool { + matches!(mime, "image/jpeg" | "image/png" | "image/gif" | "image/webp") +} + /// The MIME type of a preview's bytes (JPEG or PNG). fn preview_mime(bytes: &[u8]) -> &'static str { if bytes.starts_with(&[0xff, 0xd8]) { "image/jpeg" } else { vectorcraft_doc::links::PROXY_MIME } @@ -247,6 +268,19 @@ pub fn save_file(doc: &Document) -> Vec { /// Serialize a document as `o` says: the version, compression, layout and preview. Editing-mode /// working copies and images nothing uses are left out. pub fn save_with(doc: &Document, o: &SaveOptions) -> Result, FormatError> { + let mut out = Vec::new(); + save_to(doc, o, &mut out)?; + Ok(out) +} + +/// Bytes of JSON serialized at a time on their way to the output. +const JSON_CHUNK: usize = 64 << 10; + +/// [`save_with`], written to `w` as it is made: the JSON is serialized straight into it (through +/// the compressor when compressing) and the images are copied in from the document, so the file +/// is never held in memory as a whole. Give it a buffered writer. On an error `w` has part of a +/// file: write to a temporary file ([`write_atomic_with`]). +pub fn save_to(doc: &Document, o: &SaveOptions, w: impl Write) -> Result<(), FormatError> { o.check()?; let pretty = o.pretty && !o.compress; let mut d = doc.without_edit_modes().into_owned(); @@ -264,62 +298,103 @@ pub fn save_with(doc: &Document, o: &SaveOptions) -> Result, FormatError if o.version < 3 { d.store_legacy_color_profiles(); } - let body = if o.version == 1 { + let v1 = if o.version == 1 { let mut v = serde_json::to_value(&d).map_err(encode_err)?; legacy::anchors_to_v1(&mut v); - to_json(&v, pretty)? + Some(v) } else { - to_json(&d, pretty)? + None }; + let entries = Entries { blobs: &blobs, linked: &linked, include_linked: o.include_linked }; + let mut out_blobs = Blobs { tail: vec![], at: 0, outside: o.version >= 4 }; // Images are only referenced by image objects' `key` (wherever the objects are: layers, - // symbols, patterns, masks, or foreign data). - let used = if blobs.is_empty() { HashSet::new() } else { string_members(&body, "key") }; - let mut out_blobs = Blobs { tail: vec![], outside: o.version >= 4 }; - let images: BTreeMap<&str, Image> = blobs - .iter() - .filter(|(k, _)| used.contains(k.as_str())) - .map(|(k, b)| { - // Include Linked Files keeps the file's bytes whenever they are loaded. - let preview_only = linked.contains(k) && (!o.include_linked || b.is_proxy()); - let image = match b.proxy.as_ref().filter(|_| preview_only) { - Some(p) => out_blobs.image(preview_mime(p), p, true), - None => out_blobs.image(&b.mime, &b.bytes, false), - }; - (k.as_str(), image) - }) - .collect(); + // symbols, patterns, masks, or foreign data). The data after the JSON has the images first, + // then the profiles and the PDF, which the file lists before the document: with those, the + // images in use are looked for before the document is written (it is serialized twice). + let mut images = None; + if !blobs.is_empty() && (!o.profiles.is_empty() || o.pdf.is_some()) { + let mut scan = KeyScan::new(std::io::sink()); + write_body(std::io::BufWriter::with_capacity(JSON_CHUNK, &mut scan), v1.as_ref(), &d, pretty)?; + images = Some(entries.images(&scan.keys, &mut out_blobs)); + } let profiles: BTreeMap<&str, Image> = o.profiles.iter().map(|(name, icc)| (name.as_str(), out_blobs.image(ICC_MIME, icc, false))).collect(); let pdf = o.pdf.as_deref().map(|p| out_blobs.image("application/pdf", p, false)); let blob = |mime: &str, bytes: &[u8]| Image { mime: mime.into(), data: base64_encode(bytes), blob: None, proxy: false }; - let carried = profiles.values().chain(&pdf).map(|i| i.data.len()).sum::(); - let size = body.len() + images.values().map(|i| i.data.len()).sum::() + carried + 256; - let mut w = Envelope { out: Vec::with_capacity(size), pretty }; + let mut out = if o.compress { Out::Gz(Box::new(Gz::new(w).map_err(encode_err)?)) } else { Out::Plain(w) }; + let mut env = Envelope { pretty, first: true }; // Older apps only know the name from before the rename. - w.field("format", &if o.version < 3 { LEGACY_EXTENSION } else { EXTENSION })?; - w.field("version", &o.version)?; - w.field("generator", &format!("VectorCraft {}", env!("CARGO_PKG_VERSION")))?; + env.field(&mut out, "format", &if o.version < 3 { LEGACY_EXTENSION } else { EXTENSION })?; + env.field(&mut out, "version", &o.version)?; + env.field(&mut out, "generator", &format!("VectorCraft {}", env!("CARGO_PKG_VERSION")))?; if let Some(png) = &o.preview { - w.field("preview", &blob("image/png", png))?; + env.field(&mut out, "preview", &blob("image/png", png))?; } if !profiles.is_empty() { - w.field("profiles", &profiles)?; + env.field(&mut out, "profiles", &profiles)?; } if let Some(pdf) = &pdf { - w.field("pdf", pdf)?; + env.field(&mut out, "pdf", pdf)?; + } + env.key(&mut out, "document")?; + let mut scan = KeyScan::new(Indent { inner: &mut out, on: pretty }); + // Without blobs no key matters. + scan.active = images.is_none() && !blobs.is_empty(); + let mut buffered = std::io::BufWriter::with_capacity(JSON_CHUNK, &mut scan); + write_body(&mut buffered, v1.as_ref(), &d, pretty)?; + buffered.flush().map_err(encode_err)?; + drop(buffered); + let used = std::mem::take(&mut scan.keys); + let images = images.unwrap_or_else(|| entries.images(&used, &mut out_blobs)); + env.field(&mut out, "images", &images)?; + env.finish(&mut out)?; + if o.compress { + let json = out.written().unwrap_or(0); + let tail: Vec = out_blobs.tail.iter().map(|(b, _)| b.len() as u64).collect(); + if json > MAX_JSON { + return Err(FormatError::TooBigToCompress(format!("{} MB of document, at most {} MB", json >> 20, MAX_JSON >> 20))); + } + if let Some(big) = tail.iter().find(|&&n| n > MAX_BLOB) { + return Err(FormatError::TooBigToCompress(format!("an image of {} MB, at most {} MB", big >> 20, MAX_BLOB >> 20))); + } + if out_blobs.at > MAX_BLOBS { + return Err(FormatError::TooBigToCompress(format!("{} MB of images, at most {} MB", out_blobs.at >> 20, MAX_BLOBS >> 20))); + } } - w.raw("document", &body); - w.field("images", &images)?; - let mut out = w.finish(); if !out_blobs.tail.is_empty() { - out.extend_from_slice(BLOB_MAGIC); - out.extend_from_slice(&out_blobs.tail); + out.write_all(BLOB_MAGIC).map_err(encode_err)?; + for &(bytes, packed) in &out_blobs.tail { + out.packed(packed).map_err(encode_err)?; + out.write_all(bytes).map_err(encode_err)?; + } } - if !o.compress { - return Ok(out); + out.finish().map_err(encode_err) +} + +/// The images a file lists, from the document's blobs. +struct Entries<'a> { + blobs: &'a BTreeMap, + /// Images only linked images show (see [`Document::linked_only_images`]). + linked: &'a std::collections::BTreeSet, + include_linked: bool, +} + +impl<'a> Entries<'a> { + /// The entries of the images named in `used`, in the order of their names. + fn images(&self, used: &HashSet, out: &mut Blobs<'a>) -> BTreeMap<&'a str, Image> { + self.blobs + .iter() + .filter(|(k, _)| used.contains(k.as_str())) + .map(|(k, b)| { + // Include Linked Files keeps the file's bytes whenever they are loaded. + let preview_only = self.linked.contains(k) && (!self.include_linked || b.is_proxy()); + let image = match b.proxy.as_ref().filter(|_| preview_only) { + Some(p) => out.image(preview_mime(p), p, true), + None => out.image(&b.mime, &b.bytes, false), + }; + (k.as_str(), image) + }) + .collect() } - let mut gz = flate2::write::GzEncoder::new(Vec::with_capacity(out.len() / 4), flate2::Compression::default()); - gz.write_all(&out).map_err(encode_err)?; - gz.finish().map_err(encode_err) } fn encode_err(e: impl std::fmt::Display) -> FormatError { @@ -330,85 +405,51 @@ fn to_json(v: &impl Serialize, pretty: bool) -> Result, FormatError> { if pretty { serde_json::to_vec_pretty(v) } else { serde_json::to_vec(v) }.map_err(encode_err) } -/// The file's top-level object, written member by member (the document goes in pre-serialized). +/// The document's JSON: its version 1 form `v1`, else `d` (generic, not `dyn Write`: serde_json +/// writes token by token). +fn write_body(w: impl Write, v1: Option<&serde_json::Value>, d: &Document, pretty: bool) -> Result<(), FormatError> { + match v1 { + Some(v) => write_json(w, v, pretty), + None => write_json(w, d, pretty), + } +} + +fn write_json(w: impl Write, v: &impl Serialize, pretty: bool) -> Result<(), FormatError> { + if pretty { serde_json::to_writer_pretty(w, v) } else { serde_json::to_writer(w, v) }.map_err(encode_err) +} + +/// The file's top-level object, written member by member (the document is streamed in). struct Envelope { - out: Vec, pretty: bool, + first: bool, } impl Envelope { - fn field(&mut self, key: &str, value: &impl Serialize) -> Result<(), FormatError> { + fn field(&mut self, out: &mut impl Write, key: &str, value: &impl Serialize) -> Result<(), FormatError> { let json = to_json(value, self.pretty)?; - self.raw(key, &json); - Ok(()) + self.key(out, key)?; + Indent { inner: out, on: self.pretty }.write_all(&json).map_err(encode_err) } - /// Member `key` (a plain name) with the JSON text `json`, indented one level when pretty (as - /// serde_json would). - fn raw(&mut self, key: &str, json: &[u8]) { - let out = &mut self.out; - out.push(if out.is_empty() { b'{' } else { b',' }); - if self.pretty { - out.extend_from_slice(b"\n "); - } - out.push(b'"'); - out.extend_from_slice(key.as_bytes()); - out.extend_from_slice(if self.pretty { b"\": " } else { b"\":" }); - // Newlines only occur between tokens (inside strings they are escaped). - let mut lines = json.split(|&b| b == b'\n'); - out.extend_from_slice(lines.next().unwrap_or_default()); - for line in lines { - out.extend_from_slice(b"\n "); - out.extend_from_slice(line); - } + /// The start of member `key` (a plain name): its value follows, indented one level when + /// pretty (as serde_json would: see [`Indent`]). + fn key(&mut self, out: &mut impl Write, key: &str) -> Result<(), FormatError> { + let sep: &[u8] = if std::mem::take(&mut self.first) { b"{" } else { b"," }; + let indent: &[u8] = if self.pretty { b"\n " } else { b"" }; + let colon: &[u8] = if self.pretty { b"\": " } else { b"\":" }; + [sep, indent, b"\"", key.as_bytes(), colon].iter().try_for_each(|part| out.write_all(part)).map_err(encode_err) } - fn finish(mut self) -> Vec { - self.out.extend_from_slice(if self.pretty { b"\n}" } else { b"}" }); - self.out + fn finish(self, out: &mut impl Write) -> Result<(), FormatError> { + out.write_all(if self.pretty { b"\n}" } else { b"}" }).map_err(encode_err) } } -/// The string values of every `"": "…"` member at any depth of JSON text. Quotes inside JSON -/// strings are escaped, so every match is a real member. -fn string_members(json: &[u8], name: &str) -> HashSet { - let tag = format!("\"{name}\":"); - let tag = tag.as_bytes(); - let mut out = HashSet::new(); - let mut rest = json; - while let Some(at) = rest.windows(tag.len()).position(|w| w == tag) { - rest = rest.get(at + tag.len()..).unwrap_or_default(); - let value = rest.trim_ascii_start(); - if value.first() == Some(&b'"') - && let Some(Ok(s)) = serde_json::Deserializer::from_slice(value).into_iter::().next() - { - out.insert(s); - } - } - out -} - /// Is this gzip data (a compressed file)? pub fn is_compressed(bytes: &[u8]) -> bool { bytes.starts_with(&[0x1f, 0x8b]) } -/// The JSON text of a file, unpacked when compressed. -fn unpack(bytes: &[u8]) -> Result, FormatError> { - if !is_compressed(bytes) { - return Ok(Cow::Borrowed(bytes)); - } - let mut raw = Vec::new(); - flate2::read::GzDecoder::new(bytes) - .take(MAX_UNPACKED + 1) - .read_to_end(&mut raw) - .map_err(|e| FormatError::NotVectorcraft(format!("damaged compressed data: {e}")))?; - if raw.len() as u64 > MAX_UNPACKED { - return Err(FormatError::TooBig); - } - Ok(Cow::Owned(raw)) -} - pub fn load(bytes: &[u8]) -> Result { load_info(bytes).map(|(doc, _)| doc) } @@ -445,18 +486,24 @@ pub fn load_info(bytes: &[u8]) -> Result<(Document, FileInfo), FormatError> { /// [`load`], with everything else the file carries. pub fn load_file(bytes: &[u8]) -> Result { - let text = unpack(bytes)?; - let (json, tail) = split_tail(&text); - let f: File = serde_json::from_slice(json).map_err(|e| FormatError::NotVectorcraft(e.to_string()))?; + let file = unpack::Unpacked::new(bytes)?; + let f: File = serde_json::from_slice(file.json()).map_err(|e| FormatError::NotVectorcraft(e.to_string()))?; if f.format != EXTENSION && f.format != LEGACY_EXTENSION { return Err(FormatError::NotVectorcraft(format!("format is `{}`", f.format))); } if f.version > VERSION { return Err(FormatError::TooNew(f.version)); } + // The bytes after the JSON, read in one pass: the images' first, then the profiles'. + let entries: Vec<[u64; 2]> = f.images.values().chain(f.profiles.values()).filter_map(|i| i.blob).collect(); + let mut tail = file.entries(&entries)?.into_iter(); + let mut bytes_of = |img: &Image| match img.blob { + Some(_) => tail.next().flatten(), + None => img.inline(), + }; let mut doc = f.document; for (k, img) in f.images { - let bytes = img.bytes(tail).ok_or_else(|| FormatError::BadImage(k.clone()))?; + let bytes = bytes_of(&img).ok_or_else(|| FormatError::BadImage(k.clone()))?; let mut blob = ImageBlob::new(img.mime, bytes); // Until the linked file is read, the preview stands in for it. if img.proxy { @@ -474,7 +521,7 @@ pub fn load_file(bytes: &[u8]) -> Result { // Untrusted: capped, named and pointing at objects the file has. doc.tidy_saved_selections(); doc.tidy_graph_designs(); - let profiles = f.profiles.into_iter().filter_map(|(name, icc)| Some((name, icc.bytes(tail)?))).collect(); + let profiles = f.profiles.into_iter().filter_map(|(name, icc)| Some((name, bytes_of(&icc)?))).collect(); Ok(NativeFile { doc, info: FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION }, profiles }) } @@ -485,24 +532,27 @@ struct Head { pdf: Option, } -/// The file's head and the data after its JSON. -fn head(bytes: &[u8]) -> Option<(Head, Vec)> { - let text = unpack(bytes).ok()?; - let (json, tail) = split_tail(&text); - Some((serde_json::from_slice(json).ok()?, tail.to_vec())) +/// The bytes of the member of the file's head `pick` picks (only that entry of the data after +/// the JSON is kept). +fn head_member(bytes: &[u8], pick: impl FnOnce(Head) -> Option) -> Option> { + let file = unpack::Unpacked::new(bytes).ok()?; + let head: Head = serde_json::from_slice(file.json()).ok()?; + let img = pick(head)?; + match img.blob { + Some(at) => file.entries(&[at]).ok()?.pop().flatten(), + None => img.inline(), + } } /// The preview PNG embedded in a native file (`None`: it has none, or isn't a native file). pub fn preview(bytes: &[u8]) -> Option> { - let (h, tail) = head(bytes)?; - h.preview?.bytes(&tail) + head_member(bytes, |h| h.preview) } /// The PDF a native file saved with Create PDF-Compatible File carries (`None`: it has none, or /// isn't a native file). pub fn pdf_content(bytes: &[u8]) -> Option> { - let (h, tail) = head(bytes)?; - h.pdf?.bytes(&tail) + head_member(bytes, |h| h.pdf) } /// Does this look like a `.vectorcraft` file (compressed or not)? diff --git a/crates/format/src/stream.rs b/crates/format/src/stream.rs new file mode 100644 index 00000000..60879c07 --- /dev/null +++ b/crates/format/src/stream.rs @@ -0,0 +1,358 @@ +//! Writing a file as a stream: the JSON is serialized straight into the output (compressed or +//! not) and the data after it is copied in from the document's own buffers, so a save holds no +//! copy of the file in memory besides what the caller's writer keeps. + +use std::collections::HashSet; +use std::io::{self, Write}; + +use flate2::{Compress, Compression, Crc, FlushCompress, Status}; + +/// The output of a save: plain, or gzip ([`Gz`]). +pub(crate) enum Out { + Plain(W), + Gz(Box>), +} + +impl Out { + /// What is written next is compressed already (JPEG, PNG…) or not ([`Gz::packed`]). + pub(crate) fn packed(&mut self, packed: bool) -> io::Result<()> { + match self { + Out::Plain(_) => Ok(()), + Out::Gz(gz) => gz.packed(packed), + } + } + + /// Bytes written so far (before compression). + pub(crate) fn written(&self) -> Option { + match self { + Out::Plain(_) => None, + Out::Gz(gz) => Some(gz.total), + } + } + + /// Everything written: the gzip trailer goes out, and the writer is flushed. + pub(crate) fn finish(self) -> io::Result<()> { + match self { + Out::Plain(mut w) => w.flush(), + Out::Gz(gz) => gz.finish(), + } + } +} + +impl Write for Out { + fn write(&mut self, buf: &[u8]) -> io::Result { + match self { + Out::Plain(w) => w.write(buf), + Out::Gz(gz) => gz.write(buf), + } + } + + fn write_all(&mut self, buf: &[u8]) -> io::Result<()> { + match self { + Out::Plain(w) => w.write_all(buf), + Out::Gz(gz) => gz.write_all(buf), + } + } + + fn flush(&mut self) -> io::Result<()> { + match self { + Out::Plain(w) => w.flush(), + Out::Gz(gz) => gz.flush(), + } + } +} + +/// Bytes of compressed output gathered before they go to the writer. +const OUT_CHUNK: usize = 64 << 10; + +/// A gzip stream, written as [`flate2::write::GzEncoder`] at the default level writes it (the +/// same header, and the same bytes for a file without compressed images), except that data which +/// is compressed already ([`Gz::packed`]) is stored as it is: the default level spends a second +/// or more per photo on it for next to nothing, and makes it slower to unpack. Where the level +/// changes the deflate blocks so far end with a sync flush and a fresh compressor writes on, +/// which makes one valid deflate stream that every reader unpacks. +pub(crate) struct Gz { + out: W, + deflate: Compress, + /// What `deflate` writes: stored blocks (packed data), else the default level. + stored: bool, + crc: Crc, + buf: Vec, + /// Bytes written (unpacked). + total: u64, +} + +impl Gz { + pub(crate) fn new(mut out: W) -> io::Result { + // flate2's header for the default level: no name, no time, unknown OS. + out.write_all(&[0x1f, 0x8b, 8, 0, 0, 0, 0, 0, 0, 255])?; + Ok(Self { + out, + deflate: Compress::new(Compression::default(), false), + stored: false, + crc: Crc::new(), + buf: Vec::with_capacity(OUT_CHUNK), + total: 0, + }) + } + + /// Run the compressor over `input` with `flush` until it has taken it all (and, flushing, + /// written everything out). + fn deflate(&mut self, mut input: &[u8], flush: FlushCompress) -> io::Result<()> { + loop { + if self.buf.len() == self.buf.capacity() { + self.out.write_all(&self.buf)?; + self.buf.clear(); + } + let (before_in, before_out) = (self.deflate.total_in(), self.deflate.total_out()); + let status = self.deflate.compress_vec(input, &mut self.buf, flush).map_err(io::Error::other)?; + let taken = usize::try_from(self.deflate.total_in() - before_in).unwrap_or(input.len()).min(input.len()); + input = input.get(taken..).unwrap_or_default(); + let made = self.deflate.total_out() - before_out; + let full = self.buf.len() == self.buf.capacity(); + match flush { + FlushCompress::None if input.is_empty() => return Ok(()), + FlushCompress::Finish if status == Status::StreamEnd => return Ok(()), + // A flush is done once it left room in the output. + FlushCompress::Sync if input.is_empty() && !full => return Ok(()), + _ => {} + } + if taken == 0 && made == 0 && !full { + return Err(io::Error::other("the compressor made no progress")); + } + } + } + + /// What is written next is compressed already (stored as it is), or not (compressed at the + /// default level). + fn packed(&mut self, packed: bool) -> io::Result<()> { + if packed == self.stored { + return Ok(()); + } + self.deflate(&[], FlushCompress::Sync)?; + self.deflate = Compress::new(if packed { Compression::none() } else { Compression::default() }, false); + self.stored = packed; + Ok(()) + } + + fn finish(mut self) -> io::Result<()> { + self.deflate(&[], FlushCompress::Finish)?; + self.out.write_all(&self.buf)?; + self.out.write_all(&self.crc.sum().to_le_bytes())?; + self.out.write_all(&self.crc.amount().to_le_bytes())?; + self.out.flush() + } +} + +impl Write for Gz { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.write_all(buf)?; + Ok(buf.len()) + } + + fn write_all(&mut self, buf: &[u8]) -> io::Result<()> { + self.crc.update(buf); + self.total += buf.len() as u64; + self.deflate(buf, FlushCompress::None) + } + + fn flush(&mut self) -> io::Result<()> { + // Flushing would end a deflate block: the stream is flushed when it is finished. + Ok(()) + } +} + +/// Indents JSON text written through it one level (two spaces after each newline), as a member +/// of the file's top-level object; newlines only occur between tokens, inside strings they are +/// escaped. +pub(crate) struct Indent { + pub(crate) inner: W, + pub(crate) on: bool, +} + +impl Write for Indent { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.write_all(buf)?; + Ok(buf.len()) + } + + fn write_all(&mut self, buf: &[u8]) -> io::Result<()> { + if !self.on { + return self.inner.write_all(buf); + } + let mut lines = buf.split(|&b| b == b'\n'); + self.inner.write_all(lines.next().unwrap_or_default())?; + for line in lines { + self.inner.write_all(b"\n ")?; + self.inner.write_all(line)?; + } + Ok(()) + } + + fn flush(&mut self) -> io::Result<()> { + self.inner.flush() + } +} + +/// The member that names an image. +const TAG: &[u8] = b"\"key\":"; + +/// Passes JSON text through, collecting the string values of every `"key": "…"` member at any +/// depth (what the images a document uses are named by). Quotes inside JSON strings are escaped, +/// so every match is a real member. +pub(crate) struct KeyScan { + pub(crate) inner: W, + pub(crate) keys: HashSet, + /// Looking for keys (else it only passes the text on). + pub(crate) active: bool, + /// How much of [`TAG`] the last bytes matched. + matched: usize, + /// Right after a tag: skipping whitespace to its value. + after_tag: bool, + /// The value string so far, quotes included. + value: Option>, + escaped: bool, +} + +impl KeyScan { + pub(crate) fn new(inner: W) -> Self { + Self { inner, keys: HashSet::new(), active: true, matched: 0, after_tag: false, value: None, escaped: false } + } + + fn scan(&mut self, buf: &[u8]) { + if !self.active { + return; + } + let mut i = 0; + while let Some(&b) = buf.get(i) { + // Nothing in progress: skip to the next quote. + if self.matched == 0 && !self.after_tag && self.value.is_none() { + match buf.get(i..).and_then(|r| r.iter().position(|&c| c == b'"')) { + Some(at) => i += at, + None => return, + } + self.matched = 1; + i += 1; + continue; + } + self.step(b); + i += 1; + } + } + + fn step(&mut self, b: u8) { + if let Some(v) = self.value.as_mut() { + v.push(b); + if self.escaped { + self.escaped = false; + } else if b == b'\\' { + self.escaped = true; + } else if b == b'"' + && let Some(v) = self.value.take() + && let Ok(s) = serde_json::from_slice::(&v) + { + self.keys.insert(s); + } + } else if self.after_tag && !b.is_ascii_whitespace() { + self.after_tag = false; + if b == b'"' { + self.value = Some(vec![b]); + } + } + if TAG.get(self.matched) == Some(&b) { + self.matched += 1; + if self.matched == TAG.len() { + self.matched = 0; + self.after_tag = true; + self.value = None; + self.escaped = false; + } + } else { + self.matched = usize::from(b == b'"'); + } + } +} + +impl Write for KeyScan { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.write_all(buf)?; + Ok(buf.len()) + } + + fn write_all(&mut self, buf: &[u8]) -> io::Result<()> { + self.scan(buf); + self.inner.write_all(buf) + } + + fn flush(&mut self) -> io::Result<()> { + self.inner.flush() + } +} + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + + use super::*; + + /// How the keys were found before saves were streamed: in the whole JSON text at once. + fn string_members(json: &[u8]) -> HashSet { + let mut out = HashSet::new(); + let mut rest = json; + while let Some(at) = rest.windows(TAG.len()).position(|w| w == TAG) { + rest = rest.get(at + TAG.len()..).unwrap_or_default(); + let value = rest.trim_ascii_start(); + if value.first() == Some(&b'"') + && let Some(Ok(s)) = serde_json::Deserializer::from_slice(value).into_iter::().next() + { + out.insert(s); + } + } + out + } + + /// The keys `KeyScan` finds in `json` written in pieces of `chunk` bytes. + fn scanned(json: &[u8], chunk: usize) -> HashSet { + let mut scan = KeyScan::new(Vec::new()); + for piece in json.chunks(chunk.max(1)) { + scan.write_all(piece).unwrap(); + } + assert_eq!(scan.inner, json, "the text passes through"); + scan.keys + } + + #[test] + fn finds_the_keys_the_whole_text_search_found() { + let v = serde_json::json!({"key": "a", "x": [{"key": "b \"q\" \\ é"}, {"monkey": "no"}, {"k": "key"}], "s": "\"key\": \"no\"", "key2": "n", "o": {"key": 3}}); + for json in [serde_json::to_vec(&v).unwrap(), serde_json::to_vec_pretty(&v).unwrap()] { + let want = string_members(&json); + assert_eq!(want, HashSet::from(["a".to_string(), "b \"q\" \\ é".to_string()])); + for chunk in [1, 2, 3, 7, 64, json.len()] { + assert_eq!(scanned(&json, chunk), want, "chunk {chunk}"); + } + } + } + + fn arb_json() -> impl Strategy { + let key = prop::sample::select(vec!["key", "k", "kkey", "key ", "x"]).prop_map(str::to_string); + let leaf = prop_oneof![ + Just(serde_json::Value::Null), + any::().prop_map(serde_json::Value::from), + "[a-z\"\\\\: ké\n]{0,8}".prop_map(serde_json::Value::from), + ]; + leaf.prop_recursive(4, 32, 6, move |inner| { + prop_oneof![ + prop::collection::vec(inner.clone(), 0..5).prop_map(serde_json::Value::from), + prop::collection::vec((key.clone(), inner), 0..5).prop_map(|m| serde_json::Value::Object(m.into_iter().collect())), + ] + }) + } + + proptest! { + #[test] + fn scanning_in_pieces_finds_what_the_whole_text_search_found(v in arb_json(), chunk in 1usize..40, pretty in any::()) { + let json = if pretty { serde_json::to_vec_pretty(&v) } else { serde_json::to_vec(&v) }.unwrap(); + prop_assert_eq!(scanned(&json, chunk), string_members(&json)); + } + } +} diff --git a/crates/format/src/unpack.rs b/crates/format/src/unpack.rs new file mode 100644 index 00000000..360b756e --- /dev/null +++ b/crates/format/src/unpack.rs @@ -0,0 +1,214 @@ +//! Reading a file: its JSON, then the entries of the data after it (v4) one by one. A compressed +//! file is unpacked as a stream: the JSON into one buffer, each entry straight into its own, so +//! the file is never held unpacked as a whole. Limits apply per part ([`MAX_JSON`], [`MAX_BLOB`], +//! [`MAX_BLOBS`]), guarding against decompression bombs. + +use std::io::{self, Read}; + +use flate2::read::GzDecoder; + +use crate::{BLOB_MAGIC, FormatError, MAX_BLOB, MAX_BLOBS, MAX_JSON, MAX_UNPACKED, is_compressed, split_tail}; + +/// Bytes unpacked at a time while looking for the end of the JSON. +const CHUNK: u64 = 64 << 10; + +fn damaged(e: impl std::fmt::Display) -> FormatError { + FormatError::NotVectorcraft(format!("damaged compressed data: {e}")) +} + +/// A file opened for reading: its JSON, and where the data after it is. +pub(crate) struct Unpacked<'a> { + json: Json<'a>, + tail: Tail<'a>, +} + +enum Json<'a> { + Borrowed(&'a [u8]), + Owned(Vec), + /// Unpacked whole (a compressed file whose JSON isn't followed by the marker where the writer + /// puts it): `buf[..json]` is the JSON ([`Tail::Whole`] says where the data after it is). + Whole { + buf: Vec, + json: usize, + }, +} + +enum Tail<'a> { + /// The data after the JSON, in memory (an uncompressed file, or one unpacked whole). + Slice(&'a [u8]), + /// In memory, in [`Json::Whole`]'s buffer from this offset. + Whole(usize), + /// Still to unpack from `file`: it starts at `at` in the unpacked stream; `read` holds what + /// was unpacked past the marker already, and `rest` the stream from there. + Stream { + file: &'a [u8], + at: u64, + read: Vec, + rest: Box>, + }, + None, +} + +impl<'a> Unpacked<'a> { + /// Open `bytes` (compressed or not). + pub(crate) fn new(bytes: &'a [u8]) -> Result { + if !is_compressed(bytes) { + let (json, tail) = split_tail(bytes); + return Ok(Self { json: Json::Borrowed(json), tail: Tail::Slice(tail) }); + } + let mut dec = GzDecoder::new(bytes); + let mut buf = Vec::new(); + let mut nul = None; + loop { + let start = buf.len(); + let n = (&mut dec).take(CHUNK).read_to_end(&mut buf).map_err(damaged)?; + if nul.is_none() { + nul = buf.get(start..).and_then(|b| b.iter().position(|&c| c == 0)).map(|p| p + start); + } + if n == 0 || nul.is_some_and(|z| buf.len() >= z + BLOB_MAGIC.len()) { + break; + } + if buf.len() as u64 > MAX_JSON + CHUNK { + return Err(FormatError::TooBig); + } + } + let Some(z) = nul else { + // No NUL, so no marker: all of it is JSON. + if buf.len() as u64 > MAX_JSON { + return Err(FormatError::TooBig); + } + return Ok(Self { json: Json::Owned(buf), tail: Tail::None }); + }; + // JSON holds no raw NUL, so the marker (which has one) starts right before the first NUL; + // the writer puts it right after the JSON's last byte. + if let Some(end) = z.checked_sub(1) + && buf.get(end..).is_some_and(|r| r.starts_with(BLOB_MAGIC)) + && end.checked_sub(1).and_then(|k| buf.get(k)).is_some_and(|b| matches!(b, b'}' | b']' | b'"')) + { + if end as u64 > MAX_JSON { + return Err(FormatError::TooBig); + } + let read = buf.split_off(end + BLOB_MAGIC.len()); + buf.truncate(end); + let at = (end + BLOB_MAGIC.len()) as u64; + return Ok(Self { json: Json::Owned(buf), tail: Tail::Stream { file: bytes, at, read, rest: Box::new(dec) } }); + } + // Anything else (hand-made or damaged files) is unpacked whole and split as an + // uncompressed file is. + dec.take(MAX_UNPACKED + 1 - (buf.len() as u64).min(MAX_UNPACKED)).read_to_end(&mut buf).map_err(damaged)?; + if buf.len() as u64 > MAX_UNPACKED { + return Err(FormatError::TooBig); + } + let (json, tail) = split_tail(&buf); + let (json, tail) = (json.len(), buf.len() - tail.len()); + Ok(Self { json: Json::Whole { buf, json }, tail: Tail::Whole(tail) }) + } + + /// The JSON text. + pub(crate) fn json(&self) -> &[u8] { + match &self.json { + Json::Borrowed(j) => j, + Json::Owned(j) => j, + Json::Whole { buf, json, .. } => buf.get(..*json).unwrap_or_default(), + } + } + + /// The entries `[offset, length]` of the data after the JSON → each one's bytes, `None` for + /// one that isn't there (the data ends before it). Errors: the compressed data is damaged, or + /// the entries are larger than [`MAX_BLOB`] each or [`MAX_BLOBS`] together. Entries are read + /// in the order of their offsets, each straight into its own buffer. + pub(crate) fn entries(self, wanted: &[[u64; 2]]) -> Result>>, FormatError> { + let mut out: Vec>> = vec![None; wanted.len()]; + let in_memory = |tail: &[u8], out: &mut Vec>>| { + for (slot, &[offset, len]) in out.iter_mut().zip(wanted) { + *slot = range(offset, len).and_then(|(a, b)| tail.get(a..b)).map(<[u8]>::to_vec); + } + }; + match self.tail { + Tail::Slice(tail) => in_memory(tail, &mut out), + Tail::Whole(at) => { + let buf = match &self.json { + Json::Whole { buf, .. } => buf.as_slice(), + _ => &[], + }; + in_memory(buf.get(at..).unwrap_or_default(), &mut out); + } + Tail::None => in_memory(&[], &mut out), + Tail::Stream { file, at, read, rest } => { + let mut total = 0u64; + for &[_, len] in wanted { + if len > MAX_BLOB { + return Err(FormatError::TooBig); + } + total = total.saturating_add(len); + } + if total > MAX_BLOBS { + return Err(FormatError::TooBig); + } + drop(self.json); + let mut order: Vec = (0..wanted.len()).collect(); + order.sort_by_key(|&i| wanted.get(i).map_or(0, |w| w[0])); + let mut s = Stream { r: Box::new(io::Cursor::new(read).chain(rest)), pos: 0, file, at }; + for i in order { + let Some(&[offset, len]) = wanted.get(i) else { continue }; + if let Some(slot) = out.get_mut(i) { + *slot = s.entry(offset, len)?; + } + } + // To the end, so the stream's checksum is verified as before. + s.skip(MAX_BLOBS)?; + } + } + Ok(out) + } +} + +/// `offset..offset + len` as indices, when they fit. +fn range(offset: u64, len: u64) -> Option<(usize, usize)> { + let a = usize::try_from(offset).ok()?; + Some((a, a.checked_add(usize::try_from(len).ok()?)?)) +} + +/// The data after a compressed file's JSON, being unpacked. +struct Stream<'a> { + r: Box, + /// Where `r` is in the data after the JSON. + pos: u64, + file: &'a [u8], + /// Where the data after the JSON starts in the unpacked stream. + at: u64, +} + +impl Stream<'_> { + /// Unpack and drop up to `n` bytes → how many there were. + fn skip(&mut self, n: u64) -> Result { + let skipped = io::copy(&mut (&mut self.r).take(n), &mut io::sink()).map_err(damaged)?; + self.pos += skipped; + Ok(skipped) + } + + /// The entry at `offset`, `len` bytes long (`None`: the data ends before it does). + fn entry(&mut self, offset: u64, len: u64) -> Result>, FormatError> { + if offset < self.pos { + // Entries out of order or overlapping (only hand-made files): unpack again from the + // start. + let mut dec = GzDecoder::new(self.file); + let skipped = io::copy(&mut (&mut dec).take(self.at), &mut io::sink()).map_err(damaged)?; + if skipped < self.at { + return Ok(None); + } + self.r = Box::new(dec); + self.pos = 0; + } + let gap = offset - self.pos; + if self.skip(gap)? < gap { + return Ok(None); + } + let mut bytes = Vec::new(); + let n = usize::try_from(len).map_err(|_| FormatError::TooBig)?; + bytes.try_reserve_exact(n).map_err(|_| FormatError::TooBig)?; + let got = (&mut self.r).take(len).read_to_end(&mut bytes).map_err(damaged)?; + self.pos += got as u64; + Ok((got as u64 == len).then_some(bytes)) + } +} diff --git a/crates/format/tests/streaming.rs b/crates/format/tests/streaming.rs new file mode 100644 index 00000000..81d935f8 --- /dev/null +++ b/crates/format/tests/streaming.rs @@ -0,0 +1,199 @@ +//! Saving and opening as streams: the bytes a save writes are those an in-memory save wrote +//! before (the same JSON and data after it, unpacked; the same gzip bytes when no image is +//! compressed already), files written the old way still open, entries are read whatever their +//! order, and the limits apply per part of the file. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::collections::BTreeMap; +use std::io::{Read, Write}; + +use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind}; +use vectorcraft_format::{BLOB_MAGIC, FormatError, INLINE_MAX, MAX_BLOB, SaveOptions, load, load_file, pdf_content, preview, save_to, save_with}; +use vectorcraft_geom::Affine; + +/// A document showing images `(key, mime, len)`. +fn doc_with(images: &[(&str, &str, usize)]) -> Document { + let mut d = Document::new(100.0, 100.0); + let l = d.layers[0].id; + for (n, &(key, mime, len)) in images.iter().enumerate() { + let bytes: Vec = (0..len).map(|i| ((i * 7 + n * 13) % 251) as u8).collect(); + d.images.insert(key.into(), ImageBlob::new(mime, bytes)); + let id = d.alloc_id(); + let im = ImageObject { key: key.into(), width: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default() }; + d.insert(Some(l), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); + } + d +} + +/// gzip as the app compressed files before saves were streamed (the whole file at once). +fn gzip(bytes: &[u8]) -> Vec { + let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + gz.write_all(bytes).unwrap(); + gz.finish().unwrap() +} + +fn gunzip(bytes: &[u8]) -> Vec { + let mut out = vec![]; + flate2::read::GzDecoder::new(bytes).read_to_end(&mut out).unwrap(); + out +} + +fn mixed() -> Document { + doc_with(&[ + ("photo", "image/jpeg", INLINE_MAX * 3), + ("scan", "image/tiff", INLINE_MAX * 2), + ("icon", "image/png", 100), + ("art", "image/png", INLINE_MAX + 1), + ]) +} + +#[test] +fn compressed_files_unpack_to_the_plain_file() { + let pdf = vec![b'%'; INLINE_MAX * 2]; + let profiles = BTreeMap::from([("Mine".to_string(), vec![7u8; INLINE_MAX + 3])]); + for d in [Document::new(10.0, 10.0), mixed()] { + let o = SaveOptions { pdf: Some(pdf.clone()), profiles: profiles.clone(), preview: Some(vec![1, 2, 3]), ..SaveOptions::default() }; + let plain = save_with(&d, &o).unwrap(); + let packed = save_with(&d, &SaveOptions { compress: true, ..o }).unwrap(); + assert_eq!(gunzip(&packed), plain, "the same file, compressed"); + let back = load_file(&packed).unwrap(); + for (k, b) in &d.images { + assert_eq!(back.doc.images[k].bytes, b.bytes, "{k}"); + } + assert_eq!(back.profiles, profiles); + assert_eq!(pdf_content(&packed).unwrap(), pdf); + assert_eq!(preview(&packed).unwrap(), vec![1, 2, 3]); + } +} + +#[test] +fn without_compressed_images_the_gzip_bytes_are_the_old_ones() { + let mut d = doc_with(&[("scan", "image/tiff", INLINE_MAX * 2), ("icon", "image/png", 100)]); + d.title = "No photos".into(); + for o in [SaveOptions::default(), SaveOptions { pdf: Some(vec![b'%'; INLINE_MAX]), ..SaveOptions::default() }] { + let plain = save_with(&d, &o).unwrap(); + assert_eq!(save_with(&d, &SaveOptions { compress: true, ..o }).unwrap(), gzip(&plain)); + } +} + +#[test] +fn files_compressed_the_old_way_still_open() { + let d = mixed(); + let old = gzip(&save_with(&d, &SaveOptions::default()).unwrap()); + let back = load(&old).unwrap(); + assert_eq!(back.images.len(), d.images.len()); + for (k, b) in &d.images { + assert_eq!(back.images[k].bytes, b.bytes, "{k}"); + } + assert_eq!(back.node_count(), d.node_count()); +} + +#[test] +fn save_to_writes_what_save_with_returns() { + let d = mixed(); + for compress in [false, true] { + let o = SaveOptions { compress, ..SaveOptions::default() }; + let mut streamed = vec![]; + save_to(&d, &o, &mut streamed).unwrap(); + assert_eq!(streamed, save_with(&d, &o).unwrap()); + let back = load(&streamed).unwrap(); + assert_eq!(back.images["photo"].bytes, d.images["photo"].bytes); + } + // A failing writer fails the save. + struct Full; + impl Write for Full { + fn write(&mut self, _: &[u8]) -> std::io::Result { + Err(std::io::Error::other("disk full")) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + assert!(save_to(&d, &SaveOptions::default(), Full).is_err()); + assert!(save_to(&d, &SaveOptions { compress: true, ..SaveOptions::default() }, Full).is_err()); +} + +/// `plain` with its JSON changed by `edit` (the data after it kept). +fn with_json(plain: &[u8], edit: impl FnOnce(&mut serde_json::Value)) -> Vec { + let at = plain.windows(BLOB_MAGIC.len()).position(|w| w == BLOB_MAGIC).unwrap(); + let mut v: serde_json::Value = serde_json::from_slice(&plain[..at]).unwrap(); + edit(&mut v); + let mut out = serde_json::to_vec(&v).unwrap(); + out.extend_from_slice(&plain[at..]); + out +} + +#[test] +fn entries_in_any_order_or_shared_read_back() { + let d = doc_with(&[("a", "image/png", INLINE_MAX), ("b", "image/png", INLINE_MAX + 5)]); + let plain = save_with(&d, &SaveOptions::default()).unwrap(); + // Swap the entries' places (b's bytes listed first), and point a third image at a's bytes. + let file = with_json(&plain, |v| { + let (a, b) = (v["images"]["a"]["blob"].clone(), v["images"]["b"]["blob"].clone()); + v["images"]["a"]["blob"] = b.clone(); + v["images"]["b"]["blob"] = a.clone(); + v["images"]["c"] = serde_json::json!({"mime": "image/png", "blob": b}); + }); + for bytes in [file.clone(), gzip(&file)] { + let back = load(&bytes).unwrap(); + assert_eq!(back.images["a"].bytes, d.images["b"].bytes); + assert_eq!(back.images["b"].bytes, d.images["a"].bytes); + assert_eq!(back.images["c"].bytes, d.images["b"].bytes); + } +} + +#[test] +fn limits_apply_per_entry_and_to_all_entries() { + let d = doc_with(&[("a", "image/png", INLINE_MAX), ("b", "image/png", INLINE_MAX)]); + let plain = save_with(&d, &SaveOptions::default()).unwrap(); + // One entry past the limit: refused before anything is allocated. + let huge = with_json(&plain, |v| v["images"]["a"]["blob"] = serde_json::json!([0, MAX_BLOB + 1])); + assert!(matches!(load_file(&gzip(&huge)), Err(FormatError::TooBig))); + // Each within it, together past the limit for all. + let many = with_json(&plain, |v| { + for i in 0..5 { + v["images"][format!("x{i}")] = serde_json::json!({"mime": "image/png", "blob": [0, MAX_BLOB]}); + } + }); + assert!(matches!(load_file(&gzip(&many)), Err(FormatError::TooBig))); + // A length the data doesn't have: the image is damaged, nothing more. + let short = with_json(&plain, |v| v["images"]["a"]["blob"] = serde_json::json!([0, 1u64 << 20])); + assert!(matches!(load_file(&gzip(&short)), Err(FormatError::BadImage(_)))); + // Uncompressed files have no limits to apply (the file is all there is). + assert!(matches!(load_file(&huge), Err(FormatError::BadImage(_)))); +} + +#[test] +fn a_damaged_compressed_file_is_refused() { + let bytes = save_with(&mixed(), &SaveOptions { compress: true, ..SaveOptions::default() }).unwrap(); + // The data after the JSON cut short, then its checksum wrong. + assert!(load_file(&bytes[..bytes.len() - 4000]).is_err()); + let mut bad = bytes.clone(); + let n = bad.len(); + bad[n - 6] ^= 0xff; + assert!(load_file(&bad).is_err(), "the checksum is checked to the end"); +} + +#[test] +fn documents_read_whatever_their_key_order_and_keep_keys_they_dont_know() { + let d = mixed(); + let plain = save_with(&d, &SaveOptions::default()).unwrap(); + let file = with_json(&plain, |v| { + // serde_json's map keeps keys sorted: `type` is no longer first in the node kinds (a + // layer's `children`, `clip` and `color` come before it), and the document's fields come + // in another order. + v["document"]["aaa_from_a_newer_version"] = serde_json::json!({"x": [1, 2]}); + v["document"]["zzz_too"] = serde_json::json!("kept"); + }); + let text = String::from_utf8_lossy(&file); + assert!(!text.contains(r#"{"type":"layer""#) && text.contains(r#""type":"layer""#), "`type` isn't first"); + for bytes in [file.clone(), gzip(&file)] { + let back = load(&bytes).unwrap(); + assert_eq!(back.layers, load(&plain).unwrap().layers); + assert_eq!(back.extra["aaa_from_a_newer_version"]["x"], serde_json::json!([1, 2])); + assert_eq!(back.extra["zzz_too"], "kept"); + // Saved again, they are kept. + assert_eq!(load(&save_with(&back, &SaveOptions::default()).unwrap()).unwrap().extra["zzz_too"], "kept"); + } +} diff --git a/crates/ui-egui/src/background.rs b/crates/ui-egui/src/background.rs index a1940a7b..f0245387 100644 --- a/crates/ui-egui/src/background.rs +++ b/crates/ui-egui/src/background.rs @@ -16,6 +16,11 @@ use crate::VectorcraftApp; /// Writes a file; callable from any thread. pub type SharedWriteFn = std::sync::Arc Result<(), String> + Send + Sync>; +/// Writes the file at a path with what the filler it is given writes into it (atomically, as +/// `vectorcraft_format::write_atomic_with`); callable from any thread. +pub type SharedStreamFn = + std::sync::Arc std::io::Result<()>) -> Result<(), String> + Send + Sync>; + /// A writer for the work: the host's file writer (or browser download) on the UI thread, the /// shared one on the worker. pub type Writer<'a> = &'a mut dyn FnMut(&str, &[u8]) -> Result<(), String>; diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 60c056bd..3b5b6947 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -578,10 +578,12 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) { egui::RichText::new(format!("X: {} Y: {}", u.readout(p.x), u.readout(p.y))).font(theme::mono(10.5)).color(t.text_dim), ); } - // Background saves and exports in progress, else the last message. - if let Some(job) = app.background.jobs.first() { - let more = app.background.jobs.len() - 1; - let label = crate::i18n::msg(&job.label); + // Background saves, exports and opens in progress, else the last message. + let busy: Vec<&str> = + app.background.jobs.iter().map(|j| j.label.as_str()).chain(app.opening.pending.iter().map(|p| p.label.as_str())).collect(); + if let Some(job) = busy.first() { + let more = busy.len() - 1; + let label = crate::i18n::msg(job); let text = if more > 0 { format!("{label}… (+{more})") } else { format!("{label}…") }; ui.add(egui::Spinner::new().size(12.0).color(t.accent)); ui.add(egui::Label::new(egui::RichText::new(text).size(11.0).color(t.text)).truncate()); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index d10c6bcf..4d223143 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -104,6 +104,8 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { "nativeMenuBar": app.services.native_menu.is_some(), // Saves and exports still being written in the background (Background Save / Export). "background": app.background.jobs.iter().map(|j| j.label.as_str()).collect::>(), + // Documents still being read off the UI thread (File › Open, Open Recent): their paths. + "opening": app.opening.pending.iter().map(|p| p.path.as_str()).collect::>(), // A native file dialog open off the UI thread (`open`, `place`, `save`, `folder`), waiting // for the person at the computer: what asked for it runs again when it answers. "fileDialog": app.picks.waiting_kind(), diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 8d698932..17fa3e96 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4493,3 +4493,6 @@ diagnostics ui rozhraní @plural {n} commit|{n} commits {n} commit|{n} commity|{n} commitů gradient-kind Radial Kruhový mark-style Japanese Japonské +@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) komprimovaný soubor se rozbalí na více, než VectorCraft přečte ({_1} MB dokumentu, {_2} MB na obrázek) +@msg the document is too large to save compressed ({0}): save it with Use Compression off dokument je příliš velký pro komprimované uložení ({0}): uložte ho s vypnutou volbou Použít kompresi +@msg the file stopped being read (please report this bug) čtení souboru se přerušilo (nahlaste prosím tuto chybu) diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index ffa02707..e0aad1bf 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4144,3 +4144,6 @@ theme Appearance Erscheinungsbild Film Grain… Filmkörnung… Grain Körnung Highlight Area Lichterbereich +@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) die komprimierte Datei entpackt sich auf mehr, als VectorCraft liest ({_1} MB Dokument, {_2} MB pro Bild) +@msg the document is too large to save compressed ({0}): save it with Use Compression off das Dokument ist zu groß, um komprimiert gespeichert zu werden ({0}): speichern Sie es mit deaktivierter Option „Komprimierung verwenden“ +@msg the file stopped being read (please report this bug) das Lesen der Datei wurde abgebrochen (bitte melden Sie diesen Fehler) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 52683d4d..7399ca1d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4187,3 +4187,6 @@ lighting Light Luz Artistic Artístico Film Grain… Granulado de película… Highlight Area Área de iluminaciones +@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) el archivo comprimido ocupa al descomprimirse más de lo que VectorCraft lee ({_1} MB de documento, {_2} MB por imagen) +@msg the document is too large to save compressed ({0}): save it with Use Compression off el documento es demasiado grande para guardarlo comprimido ({0}): guárdelo con Usar compresión desactivado +@msg the file stopped being read (please report this bug) se dejó de leer el archivo (informe de este error) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ce3fc5da..2dd06234 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4197,3 +4197,6 @@ lighting Light Lumière Artistic Artistique Film Grain… Grain photographique… Highlight Area Zone des tons clairs +@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) le fichier compressé dépasse une fois décompressé ce que VectorCraft lit ({_1} Mo de document, {_2} Mo par image) +@msg the document is too large to save compressed ({0}): save it with Use Compression off le document est trop volumineux pour être enregistré compressé ({0}) : enregistrez-le avec Utiliser la compression désactivé +@msg the file stopped being read (please report this bug) la lecture du fichier s’est interrompue (veuillez signaler ce bogue) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 594a70e1..b4597c0a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4587,3 +4587,6 @@ theme Appearance Kinézet @msg {path}: {e} {path}: {e} @msg {status} ({e}) {status} ({e}) @msg {tool} isn't in a toolbar group of several tools az eszköz nincs többeszközös eszköztár-csoportban: {tool} +@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) a tömörített fájl kicsomagolva nagyobb lenne, mint amennyit a VectorCraft beolvas ({_1} MB dokumentum, képenként {_2} MB) +@msg the document is too large to save compressed ({0}): save it with Use Compression off a dokumentum túl nagy a tömörített mentéshez ({0}): mentse a Tömörítés használata beállítás kikapcsolásával +@msg the file stopped being read (please report this bug) a fájl beolvasása megszakadt (kérjük, jelentse ezt a hibát) diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index d234ac57..dbcbaf32 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4189,3 +4189,6 @@ lighting Light Luce Artistic Artistico Film Grain… Grana della pellicola… Highlight Area Area luci +@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) il file compresso, una volta decompresso, supera ciò che VectorCraft legge ({_1} MB di documento, {_2} MB per immagine) +@msg the document is too large to save compressed ({0}): save it with Use Compression off il documento è troppo grande per essere salvato compresso ({0}): salvalo con Usa compressione disattivato +@msg the file stopped being read (please report this bug) la lettura del file si è interrotta (segnala questo errore) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 534dd4ec..b3080a7f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4183,3 +4183,6 @@ lighting Light ライト Artistic アーティスティック Film Grain… フィルム粒子… Highlight Area ハイライト領域 +@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) 圧縮ファイルを展開すると VectorCraft が読み込める量を超えます (ドキュメント {_1} MB、画像 1 枚あたり {_2} MB) +@msg the document is too large to save compressed ({0}): save it with Use Compression off ドキュメントが大きすぎて圧縮保存できません ({0}): 「圧縮を使用」をオフにして保存してください +@msg the file stopped being read (please report this bug) ファイルの読み込みが中断されました (このバグを報告してください) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 9f8a97af..3b96c208 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3672,3 +3672,6 @@ axis Both Ambos Film Grain… Granulação de filme… Grain Grão Highlight Area Área de realces +@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) o arquivo compactado se expande para mais do que o VectorCraft lê ({_1} MB de documento, {_2} MB por imagem) +@msg the document is too large to save compressed ({0}): save it with Use Compression off o documento é grande demais para ser salvo compactado ({0}): salve-o com Usar Compressão desativado +@msg the file stopped being read (please report this bug) a leitura do arquivo foi interrompida (informe este erro) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 48922b74..40c12825 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4185,3 +4185,6 @@ lighting Light Свет Artistic Художественные Film Grain… Зернистость плёнки… Highlight Area Область светов +@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) сжатый файл распаковывается в объём больше, чем читает VectorCraft ({_1} МБ документа, {_2} МБ на изображение) +@msg the document is too large to save compressed ({0}): save it with Use Compression off документ слишком велик для сохранения со сжатием ({0}): сохраните его с выключенным параметром «Использовать сжатие» +@msg the file stopped being read (please report this bug) чтение файла прервалось (пожалуйста, сообщите об этой ошибке) diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index c37ee4fd..e55e57db 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4169,3 +4169,6 @@ lighting Light Світло Artistic Художні Film Grain… Зернистість плівки… Highlight Area Область світлих тонів +@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) стислий файл розпаковується в обсяг більший, ніж читає VectorCraft ({_1} МБ документа, {_2} МБ на зображення) +@msg the document is too large to save compressed ({0}): save it with Use Compression off документ завеликий для збереження зі стиском ({0}): збережіть його з вимкненим параметром «Використовувати стиск» +@msg the file stopped being read (please report this bug) читання файлу перервалося (будь ласка, повідомте про цю помилку) diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 21f7f373..331e96a6 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -104,12 +104,18 @@ pub fn opens_as(name: &str) -> Option { /// `document.open`'s result. pub fn open_document(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option, p: &Value) -> Result { let r = fileio::open_bytes_with(&mut app.session, name, bytes, path, p).map_err(|e| e.to_string())?; + opened(app, &r); + Ok(r) +} + +/// A document just opened (`r`: `document.open`'s result): the views follow, the status bar says +/// what didn't come in as it was, and missing links are asked about. +pub(crate) fn opened(app: &mut VectorcraftApp, r: &Value) { app.sync_views(); if let Some(w) = r["warnings"].as_array().filter(|w| !w.is_empty()) { app.status(format!("Opened with {} note(s): {}", w.len(), w[0].as_str().unwrap_or_default())); } - crate::dialogs::missing_links::after_open(app, &r); - Ok(r) + crate::dialogs::missing_links::after_open(app, r); } /// A path from the open dialog, or "cancelled". @@ -148,9 +154,12 @@ pub fn report_open_error(app: &mut VectorcraftApp, name: &str, e: &str) { dialogs::confirm::tell(app, &message, &crate::i18n::message(crate::i18n::current(), e)); } -/// [`open_path`] for a file the user asked for, a failure reported ([`report_open_error`]). +/// [`open_path`] for a file the user asked for, a failure reported ([`report_open_error`]). On +/// the desktop a document is decoded off the UI thread ([`crate::opening`]): the result is then +/// `{background: true, status}`, and the document opens in its tab (or the failure is reported) +/// once it is ready. pub fn open_reporting(app: &mut VectorcraftApp, path: &str) -> Result { - let r = open_path(app, path); + let r = open_file(app, path, crate::opening::available()); if let Err(e) = &r { report_open_error(app, &fileio::file_name(path), e); } @@ -163,7 +172,27 @@ fn read(app: &VectorcraftApp, path: &str) -> Result, String> { /// Open the file at `path` (see [`open_bytes`]) → `document.open`'s result, or null. pub fn open_path(app: &mut VectorcraftApp, path: &str) -> Result { - let bytes = read(app, path)?; + open_file(app, path, false) +} + +/// [`open_path`]; with `background`, a document (not a file that installs, imports or asks +/// first) is decoded off the UI thread ([`crate::opening::start`]). +fn open_file(app: &mut VectorcraftApp, path: &str, background: bool) -> Result { + let mut bytes = read(app, path)?; + if background && opens_as(path).is_none() { + // A PDF with several pages or a password, or a DXF drawing, asks first, as at once. + if crate::dialogs::import_pdf::offer(app, path, &bytes, Some(path.to_string()), None) + || crate::dialogs::dxf_import::offer(app, path, &bytes, Some(path.to_string()), None) + { + note_recent(app, path); + return Ok(Value::Null); + } + match crate::opening::start(app, path, bytes) { + Ok(r) => return Ok(r), + // No thread: at once. + Err(back) => bytes = back, + } + } let r = open_bytes(app, path, &bytes, Some(path.to_string()))?; note_recent(app, path); Ok(r) @@ -459,7 +488,15 @@ fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result let enabled = app.session.prefs.background_save && target.is_some(); // The work writes the file; the copy makes it the document's own afterwards. let done = job.clone(); - let work = move |write: Writer| job.write(|p, b| write(p, b).map_err(EngineError::Other)).map_err(|e| e.to_string()); + // A native file streams into the file as it is encoded (desktop; not to a download). + let stream = app.services.stream_shared.clone().filter(|_| job.streams() && !is_web(app)); + let work = move |write: Writer| { + match stream { + Some(stream) => job.write_streamed(&mut |p, fill| stream(p, fill).map_err(EngineError::Other)), + None => job.write(|p, b| write(p, b).map_err(EngineError::Other)), + } + .map_err(|e| e.to_string()) + }; let then = move |app: &mut VectorcraftApp, r: Result| { let r = r?; // The document may have closed meanwhile: the file is written all the same. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f2a5cbc0..c69442d2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -36,6 +36,7 @@ pub mod icons; pub mod io; pub mod menus; pub mod native_menu; +pub mod opening; pub mod palette; mod panel_docking; pub mod panels; @@ -231,6 +232,10 @@ pub struct Services { /// Write a file from any thread (desktop): lets Background Save and Export write off the UI /// thread ([`background`]). Without it they run at once. pub write_shared: Option, + /// Write a file from any thread with what a filler writes into it (desktop): native saves + /// stream into the file as they are encoded instead of being held whole in memory first. + /// Without it they are encoded, then written with `write` / `write_shared`. + pub stream_shared: Option, /// Open a file (or a folder) in the system's default app for it (desktop: Edit Original, Show /// Package). Without it (the web) those answer with an error. pub open_file: Option, @@ -386,6 +391,8 @@ pub struct VectorcraftApp { pub(crate) paste_chord: shortcuts::PasteChord, /// Saves and exports running in the background (Preferences → File Handling). pub background: background::Background, + /// Documents being read off the UI thread ([`opening`]). + pub opening: opening::Opening, /// Data Recovery's timer and startup question ([`recovery`]). pub recovery: recovery::Timer, /// Modifiers the keyboard holds (from the host's input), given back after synthetic input. @@ -482,6 +489,7 @@ impl VectorcraftApp { font_check: None, paste_chord: Default::default(), background: Default::default(), + opening: Default::default(), recovery: Default::default(), host_modifiers: Default::default(), synthetic_modifiers: false, @@ -516,6 +524,11 @@ impl VectorcraftApp { if let Some(write) = s.write_shared.take() { s.write_shared = Some(Arc::new(move |p: &str, b: &[u8]| file_access::check_write(p).and_then(|()| write(p, b)))); } + if let Some(stream) = s.stream_shared.take() { + s.stream_shared = Some(Arc::new(move |p: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>| { + file_access::check_write(p).and_then(|()| stream(p, fill)) + })); + } // Edit Original and Show in Folder hand a file to another app: only one automation may read. for service in [&mut s.open_file, &mut s.reveal] { if let Some(mut open) = service.take() { @@ -982,9 +995,10 @@ impl VectorcraftApp { self.poll_font_check(ctx); picks::poll(self, ctx); background::poll(self); + opening::poll(self); #[cfg(not(target_arch = "wasm32"))] self.tick_link_updates(ctx, now); - if !self.background.jobs.is_empty() { + if !self.background.jobs.is_empty() || !self.opening.pending.is_empty() { // Keep the status bar's progress moving and pick the result up when it arrives. ctx.request_repaint_after(std::time::Duration::from_millis(100)); } diff --git a/crates/ui-egui/src/opening.rs b/crates/ui-egui/src/opening.rs new file mode 100644 index 00000000..b978d11e --- /dev/null +++ b/crates/ui-egui/src/opening.rs @@ -0,0 +1,130 @@ +//! Opening documents off the UI thread (desktop): a file the user asks to open (File › Open, Open +//! Recent, the Home screen, files handed to the app) is decoded on a thread of its own while the +//! status bar shows it, and opens in a new tab once it is ready, as it would at once: the same +//! notes, recent files and errors. On wasm (no threads) and in unit tests it opens at once +//! ([`available`]). + +use std::sync::Arc; +use std::sync::mpsc::{Receiver, TryRecvError, channel}; + +use serde_json::{Value, json}; +use vectorcraft_engine::cmd::fileio::{self, ReadFile}; +use vectorcraft_engine::file_access::ScopeHandle; + +use crate::VectorcraftApp; + +/// A file being read. +pub struct Pending { + /// Where it is. + pub path: String, + /// What the status bar shows ("Opening Poster.vectorcraft"). + pub label: String, + done: Receiver>, +} + +/// The files being read. +#[derive(Default)] +pub struct Opening { + pub pending: Vec, +} + +/// Does opening happen off the UI thread here? Not on wasm (no threads), in unit tests (they look +/// at the document right after asking for it), or with `VECTORCRAFT_SYNC_OPEN` set. +pub fn available() -> bool { + !(cfg!(target_arch = "wasm32") || cfg!(test) || std::env::var_os("VECTORCRAFT_SYNC_OPEN").is_some()) +} + +/// Decode `bytes`, the file at `path`, on a thread of its own → `{background: true, status}`, or +/// the bytes back when no thread could start (the caller opens the file at once). +pub fn start(app: &mut VectorcraftApp, path: &str, bytes: Vec) -> Result> { + let (tx, done) = channel(); + let bytes = Arc::new(bytes); + let (name, file) = (path.to_string(), bytes.clone()); + // The automation roots in force here are in force there. + let scope = ScopeHandle::capture(); + let spawned = std::thread::Builder::new().name("vectorcraft-open".into()).spawn(move || { + let r = scope + .run(|| vectorcraft_engine::guard::catch_panic(|| fileio::read_for_open(&name, &file, &Value::Null).map_err(|e| e.to_string()))) + .unwrap_or_else(|msg| Err(format!("internal error: {msg} (please report this bug)"))); + // The file's bytes go before the document is handed over. + drop(file); + // The app may have quit meanwhile: nothing to hand it to. + let _ = tx.send(r); + }); + if spawned.is_err() { + return Err(Arc::try_unwrap(bytes).unwrap_or_else(|b| b.to_vec())); + } + let label = format!("Opening {}", fileio::file_name(path)); + app.opening.pending.push(Pending { path: path.to_string(), label: label.clone(), done }); + let status = format!("{label}…"); + app.status(status.clone()); + Ok(json!({ "background": true, "status": status })) +} + +/// Open the files that are ready (each frame). +pub fn poll(app: &mut VectorcraftApp) { + let mut i = 0; + while let Some(p) = app.opening.pending.get(i) { + let r = match p.done.try_recv() { + Ok(r) => r, + Err(TryRecvError::Empty) => { + i += 1; + continue; + } + Err(TryRecvError::Disconnected) => Err("the file stopped being read (please report this bug)".to_string()), + }; + let p = app.opening.pending.remove(i); + finish(app, &p.path, r); + } +} + +/// Open a file read off the UI thread, as [`crate::io::open_reporting`] opens one read at once. +fn finish(app: &mut VectorcraftApp, path: &str, r: Result) { + let opened = r.and_then(|file| fileio::open_read(&mut app.session, file, Some(path.to_string())).map_err(|e| e.to_string())); + match opened { + Ok(r) => { + crate::io::opened(app, &r); + crate::io::note_recent(app, path); + } + Err(e) => crate::io::report_open_error(app, &fileio::file_name(path), &e), + } +} + +#[cfg(test)] +mod tests { + use vectorcraft_engine::Session; + + use super::*; + use crate::Services; + + #[test] + fn a_file_read_off_the_ui_thread_opens_as_one_read_at_once() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + let mut d = vectorcraft_doc::Document::new(120.0, 80.0); + d.title = "Poster".into(); + let bytes = vectorcraft_format::save(&d, false); + let before = app.session.documents().len(); + let r = start(&mut app, "/art/Poster.vectorcraft", bytes).unwrap(); + assert_eq!(r["background"], true); + assert_eq!(app.ui.status, "Opening Poster.vectorcraft…"); + let t0 = std::time::Instant::now(); + while !app.opening.pending.is_empty() && t0.elapsed().as_secs() < 30 { + poll(&mut app); + std::thread::yield_now(); + } + assert_eq!(app.session.documents().len(), before + 1); + let st = app.session.doc().unwrap(); + assert_eq!((st.path.as_deref(), st.doc.artboards[0].rect.width()), (Some("/art/Poster.vectorcraft"), 120.0)); + assert_eq!(app.ui.recent_files.first().map(String::as_str), Some("/art/Poster.vectorcraft")); + // A file that can't be read: reported, nothing opened, not a recent file. + start(&mut app, "/art/Broken.vectorcraft", b"{\"format\":\"vectorcraft\"".to_vec()).unwrap(); + let t0 = std::time::Instant::now(); + while !app.opening.pending.is_empty() && t0.elapsed().as_secs() < 30 { + poll(&mut app); + std::thread::yield_now(); + } + assert_eq!(app.session.documents().len(), before + 1); + assert!(app.ui.status.starts_with("Couldn't open Broken.vectorcraft"), "{}", app.ui.status); + assert_ne!(app.ui.recent_files.first().map(String::as_str), Some("/art/Broken.vectorcraft")); + } +} diff --git a/crates/ui-egui/src/tests_save.rs b/crates/ui-egui/src/tests_save.rs index 8a39c50a..cf200fe5 100644 --- a/crates/ui-egui/src/tests_save.rs +++ b/crates/ui-egui/src/tests_save.rs @@ -205,3 +205,27 @@ fn replacing_a_file_that_opening_left_things_out_of_asks_first() { assert!(!app.session.active().unwrap().is_dirty()); let _ = std::fs::remove_dir_all(d); } + +#[test] +fn native_saves_stream_into_the_file() { + let d = dir("stream"); + let streamed = std::sync::Arc::new(std::sync::Mutex::new(vec![])); + let log = streamed.clone(); + let mut app = app(); + app.services.stream_shared = Some(std::sync::Arc::new(move |p: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>| { + log.lock().unwrap().push(p.to_string()); + let mut f = std::fs::File::create(p).map_err(|e| e.to_string())?; + fill(&mut f).map_err(|e| e.to_string()) + })); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + rect(&mut app); + let native = d.join("a.vectorcraft").to_string_lossy().to_string(); + let r = app.run("file.saveAs", json!({"path": native, "compress": true})).unwrap(); + let bytes = std::fs::read(&native).unwrap(); + assert_eq!(r["bytes"], bytes.len(), "{r}"); + assert!(vectorcraft_format::is_compressed(&bytes)); + assert_eq!(vectorcraft_format::load(&bytes).unwrap().node_count(), objects(&app)); + assert!(!app.session.doc().unwrap().is_dirty(), "the file is the document's own"); + assert_eq!(*streamed.lock().unwrap(), [native]); + let _ = std::fs::remove_dir_all(&d); +} From ecb4be19ba4a43e82c9ca4a74a7d408f0758f391 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 12 Oct 2026 10:36:26 +0700 Subject: [PATCH 08/12] #1244: a crash between a recovery copy's document and its details keeps every image The details name the copy's images, so the new document written before them paired with the old details lost the images placed since the last copy (and a first copy lost all of them). Until the new document is in, the details name the old and the new images. --- crates/engine/src/cmd/recovery.rs | 14 ++++++- crates/engine/src/tests_recovery.rs | 60 +++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index f466355c..ce4ce7a5 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -654,8 +654,20 @@ impl RecoveryJob { image_bytes += data.len(); } } + let meta_name = format!("{}{META_EXT}", self.file); + // The details name the images, so a crash between writing the document and its details + // would pair the new document with the old copy's images: when the new document has + // images the old details don't name, they name both until the new document is in. + let old = read_meta(store, &self.file).images; + if meta.images.iter().any(|(k, r)| old.get(k).is_none_or(|o| o.blob != r.blob)) { + let mut both = meta.clone(); + for (k, r) in old { + both.images.entry(k).or_insert(r); + } + store.write(&meta_name, &serde_json::to_vec_pretty(&both).map_err(|e| e.to_string())?)?; + } store.write(&format!("{}{DOC_EXT}", self.file), bytes)?; - store.write(&format!("{}{META_EXT}", self.file), &meta_bytes)?; + store.write(&meta_name, &meta_bytes)?; // Images this copy no longer shows, nor any other. sweep(store, area); Ok(json!({ "file": self.file, "title": self.title, "bytes": bytes.len(), "imageBytes": image_bytes })) diff --git a/crates/engine/src/tests_recovery.rs b/crates/engine/src/tests_recovery.rs index cf46b91d..37f5ae81 100644 --- a/crates/engine/src/tests_recovery.rs +++ b/crates/engine/src/tests_recovery.rs @@ -574,3 +574,63 @@ fn copies_from_before_images_were_kept_apart_still_restore() { assert_eq!(images_of(&s), images); assert_eq!(s.doc().unwrap().title(), "Untitled-1 [Recovered]"); } + +/// A store whose app dies right after writing a copy's document entry: later writes fail, so the +/// copy's details stay as the last interval left them. +struct DiesAfterDocument { + inner: Arc, + dead: std::sync::atomic::AtomicBool, +} + +impl RecoveryStore for DiesAfterDocument { + fn list(&self) -> std::result::Result, String> { + self.inner.list() + } + fn read(&self, name: &str) -> std::result::Result, String> { + self.inner.read(name) + } + fn write(&self, name: &str, bytes: &[u8]) -> std::result::Result<(), String> { + if self.dead.load(std::sync::atomic::Ordering::Relaxed) { + return Err("the app is gone".into()); + } + self.inner.write(name, bytes)?; + if name.ends_with(".vectorcraft") { + self.dead.store(true, std::sync::atomic::Ordering::Relaxed); + } + Ok(()) + } + fn remove(&self, name: &str) -> std::result::Result<(), String> { + self.inner.remove(name) + } + fn location(&self) -> String { + self.inner.location() + } + fn lock(&self, area: &str) -> std::result::Result { + self.inner.lock(area) + } + fn now(&self) -> Option { + self.inner.now() + } +} + +/// A crash between writing a copy's document and its details restores the new document with +/// every image, the one placed since the last copy too (and the first copy likewise). +#[test] +fn a_crash_between_a_copys_document_and_its_details_keeps_every_image() { + for first in [true, false] { + let store = Arc::new(MemoryStore::default()); + let mut s = session_in(&store); + if !first { + place_png(&mut s, "red.png", crate::tests_links::RED); + s.execute("file.recovery.save", &json!({})).unwrap(); + } + place_png(&mut s, "blue.png", crate::tests_links::BLUE); + let images = images_of(&s); + s.recovery.set_store(Arc::new(DiesAfterDocument { inner: store.clone(), dead: Default::default() })); + assert!(s.execute("file.recovery.save", &json!({})).is_err(), "the details weren't written"); + drop(s); + let mut s = relaunch(&store); + assert_eq!(restored(s.execute("file.recovery.restore", &json!({})).unwrap()), 1, "first copy: {first}"); + assert_eq!(images_of(&s), images, "first copy: {first}"); + } +} From ecf361c9d706e6012a7e5a62b72089cbc72d3531 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 12 Oct 2026 10:36:27 +0700 Subject: [PATCH 09/12] #1244: reserve at most 64 MB for a file's entry before its data is read The entry's length comes from the file: a damaged or hostile one claiming 1 GB had it reserved before a byte was unpacked. --- crates/format/src/unpack.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/format/src/unpack.rs b/crates/format/src/unpack.rs index 360b756e..3199bbc5 100644 --- a/crates/format/src/unpack.rs +++ b/crates/format/src/unpack.rs @@ -11,6 +11,8 @@ use crate::{BLOB_MAGIC, FormatError, MAX_BLOB, MAX_BLOBS, MAX_JSON, MAX_UNPACKED /// Bytes unpacked at a time while looking for the end of the JSON. const CHUNK: u64 = 64 << 10; +/// Most bytes reserved for an entry before any of it is unpacked. +const RESERVE_MAX: usize = 64 << 20; fn damaged(e: impl std::fmt::Display) -> FormatError { FormatError::NotVectorcraft(format!("damaged compressed data: {e}")) @@ -206,7 +208,8 @@ impl Stream<'_> { } let mut bytes = Vec::new(); let n = usize::try_from(len).map_err(|_| FormatError::TooBig)?; - bytes.try_reserve_exact(n).map_err(|_| FormatError::TooBig)?; + // The length is the file's word: reserve up to a bound, and let real data grow the rest. + bytes.try_reserve_exact(n.min(RESERVE_MAX)).map_err(|_| FormatError::TooBig)?; let got = (&mut self.r).take(len).read_to_end(&mut bytes).map_err(damaged)?; self.pos += got as u64; Ok((got as u64 == len).then_some(bytes)) From e2f0aed1bf5ef003997f6258a7385c4c7335dc78 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 12 Oct 2026 10:42:13 +0700 Subject: [PATCH 10/12] #1244: test that a native save failing partway leaves the file as it was --- crates/engine/src/cmd/fileio/tests.rs | 43 +++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index 5af1e823..67cd74a9 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -442,3 +442,46 @@ fn a_native_save_streams_the_bytes_the_encoder_makes() { assert_eq!(e.to_string(), "disk full"); let _ = std::fs::remove_dir_all(&d); } + +/// A native save whose file write fails partway (a full disk) leaves the file as it was, and no +/// temporary file behind. +#[test] +fn a_save_that_fails_while_streaming_leaves_the_file_as_it_was() { + /// Takes `left` bytes, then fails as a full disk does. + struct FailAfter<'a> { + inner: &'a mut dyn std::io::Write, + left: usize, + } + impl std::io::Write for FailAfter<'_> { + fn write(&mut self, b: &[u8]) -> std::io::Result { + if self.left == 0 { + return Err(std::io::Error::other("disk full")); + } + let n = b.len().min(self.left); + self.left -= n; + self.inner.write(&b[..n]) + } + fn flush(&mut self) -> std::io::Result<()> { + self.inner.flush() + } + } + let d = tmp_dir("stream-fail"); + let mut s = session(2000.0, 2000.0, 1); + // Enough objects that the file is written in several pieces, the failure in the middle. + for i in 0..1500 { + s.execute("shape.rectangle", &json!({"x": i % 100 * 20, "y": i / 100 * 20, "width": 10, "height": 10})).unwrap(); + } + for compress in [false, true] { + let path = d.join("a.vectorcraft").to_string_lossy().to_string(); + s.execute("file.saveCopy", &json!({"path": path, "compress": compress})).unwrap(); + let before = std::fs::read(&path).unwrap(); + s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap(); + let plan = save_plan(&s, SaveMode::Copy, &json!({"path": path, "compress": compress})).unwrap(); + let job = save_job(&mut s, plan).unwrap(); + let e = job.write_streamed(&mut |p, fill| super::write_file_with(p, &mut |w| fill(&mut FailAfter { inner: w, left: 10_000 }))).unwrap_err(); + assert!(e.to_string().contains("disk full") || e.to_string().contains("couldn't be encoded"), "{e}"); + assert_eq!(std::fs::read(&path).unwrap(), before, "compress: {compress}"); + assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1, "no temporary file is left"); + } + let _ = std::fs::remove_dir_all(&d); +} From 6fcdb421d1c8f8c6ca9dc8721464afb156deb826 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 12 Oct 2026 10:56:03 +0700 Subject: [PATCH 11/12] #1244: the status bar stops saying a file is opening once it has opened --- crates/ui-egui/src/opening.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/ui-egui/src/opening.rs b/crates/ui-egui/src/opening.rs index b978d11e..8f4e2735 100644 --- a/crates/ui-egui/src/opening.rs +++ b/crates/ui-egui/src/opening.rs @@ -74,6 +74,10 @@ pub fn poll(app: &mut VectorcraftApp) { Err(TryRecvError::Disconnected) => Err("the file stopped being read (please report this bug)".to_string()), }; let p = app.opening.pending.remove(i); + // The status bar no longer says it is opening. + if app.ui.status == format!("{}…", p.label) { + app.ui.status.clear(); + } finish(app, &p.path, r); } } @@ -113,6 +117,7 @@ mod tests { std::thread::yield_now(); } assert_eq!(app.session.documents().len(), before + 1); + assert!(!app.ui.status.starts_with("Opening"), "{}", app.ui.status); let st = app.session.doc().unwrap(); assert_eq!((st.path.as_deref(), st.doc.artboards[0].rect.width()), (Some("/art/Poster.vectorcraft"), 120.0)); assert_eq!(app.ui.recent_files.first().map(String::as_str), Some("/art/Poster.vectorcraft")); From d63c57d1e1e15aa79a04e06823ecc425ba4bce83 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 12 Oct 2026 11:03:03 +0700 Subject: [PATCH 12/12] #1244: Hungarian rows for the new messages in the catalog's own voice (mentsd, jelentsd) --- crates/ui-egui/src/i18n/hu.tsv | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index b4597c0a..f5ede59b 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4588,5 +4588,5 @@ theme Appearance Kinézet @msg {status} ({e}) {status} ({e}) @msg {tool} isn't in a toolbar group of several tools az eszköz nincs többeszközös eszköztár-csoportban: {tool} @msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) a tömörített fájl kicsomagolva nagyobb lenne, mint amennyit a VectorCraft beolvas ({_1} MB dokumentum, képenként {_2} MB) -@msg the document is too large to save compressed ({0}): save it with Use Compression off a dokumentum túl nagy a tömörített mentéshez ({0}): mentse a Tömörítés használata beállítás kikapcsolásával -@msg the file stopped being read (please report this bug) a fájl beolvasása megszakadt (kérjük, jelentse ezt a hibát) +@msg the document is too large to save compressed ({0}): save it with Use Compression off a dokumentum túl nagy a tömörített mentéshez ({0}): mentsd a Tömörítés használata beállítás kikapcsolásával +@msg the file stopped being read (please report this bug) a fájl beolvasása megszakadt (kérjük, jelentsd ezt a hibát)