diff --git a/apps/vectorcraft-cli/src/perf.rs b/apps/vectorcraft-cli/src/perf.rs index 9652d4f7..97cd0647 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(Box::new(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)?; 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/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/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..b5446cae 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; @@ -79,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; @@ -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/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 new file mode 100644 index 00000000..1a51c4d7 --- /dev/null +++ b/crates/doc/src/mem.rs @@ -0,0 +1,420 @@ +//! 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 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. + +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::{Affine, Anchor, PathData, SubPath}; + +use crate::live::{BlendSpec, EnvelopeKind}; +use crate::text::{ParaStyle, TextObject, TextRun, TextStyleDef}; +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, repeats' 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 { + 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 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)); + } + } + } + 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)); + } + } + } + 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 + /// [`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: two documents walked side by side. +struct Walk<'a> { + 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<'_> { + /// 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 `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; + } + if a.id != b.id { + self.alone(0, a); + self.alone(1, b); + return; + } + 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)); + } + } + 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)); + } + } + } + + /// 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) { + return; + } + for c in n.children().into_iter().flatten() { + add(c, out); + } + if let Some(m) = &n.extras().mask { + add(&m.art, out); + } + } + let mut out = HashSet::new(); + 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); + } + 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 `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 !other.is_some_and(|m| Shared::ptr_eq(&m.appearance, &n.appearance)) { + b += ALLOC + ARC + appearance_heap(&n.appearance); + } + 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, 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) => ALLOC + size_of::() + 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), about 490 + /// once nodes kept their rarely set parts out of line. + #[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}"); + } + + /// 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); + 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/node.rs b/crates/doc/src/node.rs index 9eb4b0cd..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. @@ -427,8 +429,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 +446,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 +457,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 +476,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 +495,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 +855,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 +942,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 +1023,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 +1033,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 +1057,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 +1154,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 +1174,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 +1290,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 +1575,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 +1609,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 @@ -1385,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)) }; @@ -1454,13 +1976,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 +2118,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(); @@ -1651,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/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/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/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.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/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 55f80371..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", @@ -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() })) @@ -1251,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; } @@ -1268,8 +1269,10 @@ 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) - && let Some(p) = Arc::make_mut(doc).node_mut(id).and_then(|n| n.perspective.as_deref_mut()) + // 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.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; } @@ -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/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/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..bc50e5e7 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -214,12 +214,13 @@ 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); - 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::turned(e.label, doc, selection, &st.doc, costs)); + st.history.trim(); st.revision += 1; st.selection.prune(&st.doc); super::maskedit::follow_history(st, editing); @@ -230,18 +231,27 @@ 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); - 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::turned(e.label, doc, selection, &st.doc, costs)); + 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/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..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 @@ -430,7 +448,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/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 a6f2128b..67cd74a9 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:?}"), } } @@ -412,3 +412,76 @@ 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); +} + +/// 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); +} 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 a6ebd243..4a9016bd 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); } } } @@ -448,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); } @@ -561,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) { @@ -602,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(()) })?; @@ -645,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(()) })?; @@ -798,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/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/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/recovery.rs b/crates/engine/src/cmd/recovery.rs index 83623a50..ce4ce7a5 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,56 @@ 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(); + } + } + 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)?; - Ok(json!({ "file": self.file, "title": self.title, "bytes": bytes.len() })) + 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 })) } /// Once stored: the document (if still open) remembers the copy, so it isn't written again @@ -662,6 +777,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 +972,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 +1042,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/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/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/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/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..f6d3f06e 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,139 @@ 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, + /// 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, } -#[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, 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 › +/// 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.next_bytes) = e.doc.retained_bytes_each(now); + e.next = std::ptr::from_ref(now) as usize; + } + } +} + +/// 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 +205,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 +410,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 +972,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 +1084,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 +1326,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 +1428,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 +1478,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 +1496,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 +1602,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 +1719,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.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 new file mode 100644 index 00000000..0ac0fab5 --- /dev/null +++ b/crates/engine/src/tests_history_mem.rs @@ -0,0 +1,155 @@ +//! 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); +} + +/// 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_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/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..37f5ae81 100644 --- a/crates/engine/src/tests_recovery.rs +++ b/crates/engine/src/tests_recovery.rs @@ -458,3 +458,179 @@ 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]"); +} + +/// 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}"); + } +} 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/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..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) @@ -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/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/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/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..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)? @@ -605,7 +655,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/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..3199bbc5 --- /dev/null +++ b/crates/format/src/unpack.rs @@ -0,0 +1,217 @@ +//! 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; +/// 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}")) +} + +/// 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)?; + // 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)) + } +} 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/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/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 a8a39eba..a65a42d6 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 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 } }; @@ -1088,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(); @@ -1098,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 { @@ -1107,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) } } @@ -1288,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)) } @@ -1307,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/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/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/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 33c57d98..276accfe 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); } @@ -445,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(); @@ -464,25 +501,21 @@ 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)); - 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 { - 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 } @@ -490,18 +523,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 +552,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 +664,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 +710,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 +844,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 +891,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 +964,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..6e86e1d5 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,12 +320,14 @@ 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()); 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 36bc4b06..5410ddd7 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -11,8 +11,10 @@ pub mod encode; mod freeform; mod fx; mod group; +mod images; mod ink; mod live; +mod lru; mod paint; mod pattern; pub mod placed_document; @@ -29,9 +31,11 @@ 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; +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 +123,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 +200,7 @@ impl Default for RenderOptions { isolated: None, precise: false, supersample: false, + image_mips: false, } } } @@ -340,14 +349,16 @@ 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)>, + /// 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). @@ -367,7 +378,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. @@ -385,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, @@ -398,16 +408,12 @@ 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, - /// 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, + clip_paints: FrameCache, /// 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, @@ -476,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(), @@ -490,7 +496,6 @@ impl Renderer { alpha: 1.0, ctx: None, resources: Resources::new(), - images: HashMap::new(), stats: FrameStats::default(), brushes: Default::default(), shadows: PtrMap::default(), @@ -498,16 +503,14 @@ 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(), - ink_images: Default::default(), - cmyk_images: Default::default(), + clip_paints: FrameCache::new(lru::CLIP_BUDGET, 256), stroke_slices: PtrMap::default(), - adjusted: Default::default(), + adjusts: HashMap::new(), dim_images: None, inline_depth: 0, raster: vello_cpu::RasterizerSettings::default(), @@ -547,10 +550,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()) { + 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); return Rendered { width: w as u32, height: h as u32, pixels }; } @@ -564,7 +568,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 +602,12 @@ 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 } + 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. @@ -705,18 +725,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 +765,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() }) } @@ -749,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)); @@ -767,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 } @@ -784,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) } @@ -836,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) @@ -1445,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 } @@ -1503,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; } @@ -1569,14 +1604,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 +1652,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 +1710,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? @@ -1788,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. @@ -1801,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) } @@ -1812,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 @@ -1829,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 @@ -1848,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). @@ -1934,6 +2050,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/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.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 new file mode 100644 index 00000000..acf5848c --- /dev/null +++ b/crates/render/src/tests_memory.rs @@ -0,0 +1,201 @@ +//! 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(Box::new(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); +} + +/// 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()); +} 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/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/canvas.rs b/crates/ui-egui/src/canvas.rs index 9034e1d7..a082d4f9 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. @@ -1227,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()); } @@ -1276,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)) @@ -1289,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); } } @@ -2205,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"); @@ -3644,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/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/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/dialogs/tests_rectangular_grid.rs b/crates/ui-egui/src/dialogs/tests_rectangular_grid.rs index ae6258d9..aa68cfa2 100644 --- a/crates/ui-egui/src/dialogs/tests_rectangular_grid.rs +++ b/crates/ui-egui/src/dialogs/tests_rectangular_grid.rs @@ -113,7 +113,7 @@ struct Snapshot { options: Value, undo: usize, redo: usize, - journal: Vec<(String, Value)>, + journal: vectorcraft_engine::Journal, dirty: bool, } 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..f5ede59b 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}): 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) 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 2def8922..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; @@ -51,6 +52,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; @@ -230,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, @@ -385,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. @@ -481,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, @@ -515,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() { @@ -981,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..8f4e2735 --- /dev/null +++ b/crates/ui-egui/src/opening.rs @@ -0,0 +1,135 @@ +//! 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); + // 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); + } +} + +/// 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); + 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")); + // 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/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/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/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 8cc08516..758a520f 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 }); @@ -718,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)); } } @@ -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/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/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() } 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 c38d7ac7..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(); @@ -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 }), }; @@ -318,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})); @@ -327,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. 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); +} 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"); + } +}