diff --git a/apps/vectorcraft-cli/src/perf.rs b/apps/vectorcraft-cli/src/perf.rs index c76f8553..97cd0647 100644 --- a/apps/vectorcraft-cli/src/perf.rs +++ b/apps/vectorcraft-cli/src/perf.rs @@ -220,7 +220,7 @@ fn deep(n: usize) -> Result<(), String> { let xf = Affine::translate((f64::from(i % 2) * 800.0, f64::from(i / 2) * 600.0)) * Affine::scale(800.0 / 6000.0); let image = vectorcraft_doc::ImageObject { key, width: 6000, height: 4000, xf, link: None, placement: Default::default() }; let id = d.alloc_id(); - d.insert(l, usize::MAX, Node::new(id, vectorcraft_doc::NodeKind::Image(image))).map_err(|e| e.to_string())?; + d.insert(l, usize::MAX, Node::new(id, vectorcraft_doc::NodeKind::Image(Box::new(image)))).map_err(|e| e.to_string())?; } note("4 embedded 24 MP photos"); let mut renderers = [Renderer::new(), Renderer::new(), Renderer::new()]; diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index a8a9aa1d..6310acf4 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -349,7 +349,7 @@ impl<'a> Scene<'a> { if n.blend != vectorcraft_color::BlendMode::Normal { self.warn("blending modes are left out"); } - if n.mask.as_ref().is_some_and(|m| !m.disabled) { + if n.extras().mask.as_ref().is_some_and(|m| !m.disabled) { self.warn("opacity masks are left out"); } let raster = |fx: &[vectorcraft_doc::Effect]| fx.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)); diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 87023aa3..0bb612f5 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -355,7 +355,7 @@ fn images_link_png_or_jpeg_files() { link: None, placement: Default::default(), }; - vec![Node::new(d.alloc_id(), NodeKind::Image(im))] + vec![Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)))] }); d.images.insert("raster-1".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png), proxy: None }); let out = export(&d, &opts(&d)).unwrap(); diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index e52bd366..1849077a 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -562,7 +562,7 @@ impl Lerp { } } } - n.mask = None; + n.extras_mut().mask = None; n } Shape::Path { pair, rules, members } => { @@ -590,7 +590,7 @@ impl Lerp { (NodeKind::PlacedDocument(o), NodeKind::PlacedDocument(x), NodeKind::PlacedDocument(y)) => o.xf = lerp_affine(x.xf, y.xf, t), _ => {} } - n.mask = None; + n.extras_mut().mask = None; n } Shape::Other => { @@ -614,7 +614,7 @@ impl Lerp { for c in n.children_mut().into_iter().flatten() { zero_ids(Arc::make_mut(c)); } - n.mask = None; + n.extras_mut().mask = None; n } }; @@ -994,7 +994,7 @@ fn paints_opaquely(n: &Node) -> bool { }; n.opacity >= 1.0 && n.blend == vectorcraft_color::BlendMode::Normal - && n.mask.is_none() + && n.extras().mask.is_none() && n.appearance.effects.is_empty() && n.appearance .items diff --git a/crates/doc/src/corners.rs b/crates/doc/src/corners.rs index a618ee1b..986442b2 100644 --- a/crates/doc/src/corners.rs +++ b/crates/doc/src/corners.rs @@ -60,7 +60,7 @@ impl<'a> LiveCorners<'a> { /// The Live Corners of a path object. pub fn of(node: &'a Node) -> Option { let NodeKind::Path { path, live, .. } = &node.kind else { return None }; - Self::new(path, live.as_ref()) + Self::new(path, live.as_deref()) } /// The corner at anchor `index` of `base`. @@ -127,7 +127,7 @@ impl<'a> LiveCorners<'a> { /// A path that isn't a live shape keeps its uncut outline as a [`LiveShape::Path`], which is a /// plain path again once no corner is cut. Ellipses and lines have no corners: they stay as they /// are. -pub fn set_corners(path: &mut PathData, live: &mut Option, corners: &BTreeSet, radius: Option, kind: Option) { +pub fn set_corners(path: &mut PathData, live: &mut Option>, corners: &BTreeSet, radius: Option, kind: Option) { fn edit(radii: &mut [f64], kinds: &mut [CornerKind], corners: &BTreeSet, radius: Option, kind: Option) { for k in corners { if let (Some(r), Some(slot)) = (radius, radii.get_mut(*k)) { @@ -158,17 +158,17 @@ pub fn set_corners(path: &mut PathData, live: &mut Option, corners: & kinds.clear(); } } - let l = live.get_or_insert_with(|| LiveShape::Path { base: path.clone(), radii: vec![], kinds: vec![] }); + let l = live.get_or_insert_with(|| Box::new(LiveShape::Path { base: path.clone(), radii: vec![], kinds: vec![] })); // Radii are document lengths and corners circular, also on a rectangle from a file that kept // an uneven scale in its transform (#442). l.fold_scale(); - match l { + match &mut **l { LiveShape::Rectangle { radii, kinds, .. } => edit(radii, kinds, corners, radius, kind), LiveShape::Polygon { sides, radii, kinds, .. } => edit_vec(*sides as usize, radii, kinds, corners, radius, kind), LiveShape::Path { base, radii, kinds } => edit_vec(base.anchor_count(), radii, kinds, corners, radius, kind), LiveShape::Ellipse { .. } | LiveShape::Line { .. } => return, } - match live { + match live.as_deref_mut() { Some(LiveShape::Path { base, radii, kinds }) if radii.is_empty() && kinds.is_empty() => { *path = std::mem::take(base); *live = None; @@ -220,15 +220,15 @@ mod tests { let star = shapes::star(Point::new(100.0, 100.0), 50.0, 25.0, 5, 0.0); let (mut path, mut live) = (star.clone(), None); set_corners(&mut path, &mut live, &set(&[0, 2]), Some(5.0), None); - let Some(LiveShape::Path { base, radii, kinds }) = &live else { panic!("live corners: {live:?}") }; + let Some(LiveShape::Path { base, radii, kinds }) = live.as_deref() else { panic!("live corners: {live:?}") }; assert_eq!((base, radii.len(), kinds.len()), (&star, 10, 0)); assert_eq!(path.anchor_count(), 12, "two corners cut"); - let c = LiveCorners::new(&path, live.as_ref()).unwrap(); + let c = LiveCorners::new(&path, live.as_deref()).unwrap(); assert_eq!(c.style(&set(&[0, 2])), (Some(5.0), Some(CornerKind::Round))); assert_eq!(c.corners_of(&anchors(&[0, 11])), set(&[0]), "the first corner's cut ends the path"); // Kinds alone keep it live; squaring every corner makes it the plain star again. set_corners(&mut path, &mut live, &set(&[0, 2]), Some(0.0), Some(CornerKind::Chamfer)); - assert!(matches!(&live, Some(LiveShape::Path { radii, .. }) if radii.is_empty())); + assert!(matches!(live.as_deref(), Some(LiveShape::Path { radii, .. }) if radii.is_empty())); set_corners(&mut path, &mut live, &set(&[0, 2]), None, Some(CornerKind::Round)); assert_eq!((path, live), (star, None)); } @@ -237,16 +237,16 @@ mod tests { #[test] fn polygon_corners_follow_their_sides() { let mut l = LiveShape::Polygon { radius: 50.0, sides: 6, xf: Affine::translate((100.0, 100.0)), radii: vec![], kinds: vec![] }; - let (mut path, mut live) = (l.to_path(), Some(l.clone())); + let (mut path, mut live) = (l.to_path(), Some(Box::new(l.clone()))); set_corners(&mut path, &mut live, &set(&[0, 1, 2, 3, 4, 5]), Some(8.0), None); assert_eq!(path.anchor_count(), 12); - l = live.clone().unwrap(); + l = *live.clone().unwrap(); l.set_sides(8); assert!(matches!(&l, LiveShape::Polygon { radii, .. } if *radii == vec![8.0; 8])); assert_eq!(l.to_path().anchor_count(), 16); // Mixed radii: the new corners are sharp. set_corners(&mut path, &mut live, &set(&[1]), Some(3.0), None); - let mut l = live.unwrap(); + let mut l = *live.unwrap(); l.set_sides(7); assert!(matches!(&l, LiveShape::Polygon { radii, .. } if *radii == [8.0, 3.0, 8.0, 8.0, 8.0, 8.0, 0.0])); // An uneven scale keeps the cuts circular: the radius scales by the mean scale. diff --git a/crates/doc/src/graph.rs b/crates/doc/src/graph.rs index abb40fe8..5dc01840 100644 --- a/crates/doc/src/graph.rs +++ b/crates/doc/src/graph.rs @@ -104,8 +104,11 @@ impl GraphDesign { /// graph of its own (that its commands would then edit). pub fn plain_art(art: &crate::Node) -> crate::Node { let mut n = art.clone(); - n.graph = None; - n.series_index = None; + { + let mut e = n.extras_mut(); + e.graph = None; + e.series_index = None; + } if let Some(ch) = n.children_mut() { let old = std::mem::take(ch); *ch = old.iter().map(|c| std::sync::Arc::new(Self::plain_art(c))).collect(); diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index d11c8983..226241c8 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -157,7 +157,7 @@ fn hit_children( return None; } for (i, c) in children.iter().enumerate().rev() { - if parent.shaper.is_some() { + if parent.extras().shaper.is_some() { let construction = children.first().is_some_and(|source| Some(source.id) == opt.scope); if (i == 0) != construction { continue; @@ -169,7 +169,7 @@ fn hit_children( } // (An envelope's content sits where it was, not where the envelope draws it.) if !(edits_contents(c) - || c.shaper.is_some() && c.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == opt.scope)) + || c.extras().shaper.is_some() && c.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == opt.scope)) && let Some(b) = c.reach_bounds() && !b.inflate(opt.tol, opt.tol).contains(p) { @@ -534,7 +534,7 @@ mod tests { let l = d.layers[0].id; let mut add = |t: TextObject, perspective: Option| { let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); - n.perspective = perspective.map(|a| { + n.extras_mut().perspective = perspective.map(|a| { Box::new(PerspectiveAttachment { projection: Some(Homography::from_affine(a).to_array()), ..PerspectiveAttachment::new("left", 0.0) }) }); let id = n.id; diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 26351869..b5446cae 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -86,8 +86,8 @@ pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, Gradient pub use metadata::{CopyrightStatus, DocMetadata}; pub use node::Knockout; pub use node::Scaling; +pub use node::{Extras, ExtrasMut, ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeExtras, NodeId, NodeKind, OpacityMask}; pub use node::{ImageMap, ObjectAttributes}; -pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind, OpacityMask}; pub use orient::OrientedBox; pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType}; pub use perspective::PerspectiveAttachment; diff --git a/crates/doc/src/links.rs b/crates/doc/src/links.rs index 43159b0f..7abcec7c 100644 --- a/crates/doc/src/links.rs +++ b/crates/doc/src/links.rs @@ -136,7 +136,7 @@ fn proxy_png(bytes: &[u8]) -> Option> { /// `n` and every descendant, opacity-mask art included. fn walk_all<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) { f(n); - if let Some(m) = &n.mask { + if let Some(m) = &n.extras().mask { walk_all(&m.art, f); } for c in n.children().into_iter().flatten() { @@ -295,7 +295,7 @@ impl Document { NodeKind::PlacedDocument(placed) => f(&mut placed.link), _ => {} } - if let Some(mask) = &mut n.mask { + if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { walk(&mut mask.art, f); } for child in n.children_mut().into_iter().flatten() { @@ -331,7 +331,7 @@ impl Document { { f(im); } - if let Some(m) = &mut n.mask { + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { update(&mut m.art, pick, f); } for c in n.children_mut().into_iter().flatten() { @@ -370,7 +370,14 @@ mod tests { 0, Node::new( id, - NodeKind::Image(ImageObject { key: key.into(), width: 4, height: 4, xf: Affine::IDENTITY, link, placement: Default::default() }), + NodeKind::Image(Box::new(ImageObject { + key: key.into(), + width: 4, + height: 4, + xf: Affine::IDENTITY, + link, + placement: Default::default(), + })), ), ) .unwrap() diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 0759e293..d7d7d1e9 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -1222,7 +1222,7 @@ pub fn envelope_of(doc: &crate::Document, id: NodeId) -> Option<&Node> { /// edit (its points and handles). pub fn envelope_overlay(n: &Node) -> Option<(PathData, Option)> { let NodeKind::Envelope { kind, .. } = &n.kind else { return None }; - match kind { + match &**kind { EnvelopeKind::Mesh { rows, cols, points, handles } => { let grid = envelope_grid(*rows, *cols, points, handles)?; Some((grid.lines(), Some(grid))) @@ -1270,7 +1270,7 @@ impl<'a> EnvelopeMap<'a> { /// The map of envelope node `n` (`None` for other nodes). pub fn of(n: &'a Node) -> Option { match &n.kind { - NodeKind::Envelope { content, kind, frame, .. } => Self::new(content, kind, *frame), + NodeKind::Envelope { content, kind, frame, .. } => Self::new(content, kind, **frame), _ => None, } } @@ -1388,7 +1388,7 @@ fn carry_look(from: &Node, mut to: Node) -> Node { to.isolate = from.isolate; to.knockout = from.knockout; to.knockout_shape = from.knockout_shape; - to.mask = from.mask.clone(); + to.extras_mut().mask = from.extras().mask.clone(); to.appearance.effects = from.appearance.effects.clone(); to } @@ -1417,7 +1417,7 @@ fn map_node(n: &Node, w: &Warper) -> Node { { out.appearance.warp_linear_gradients(b, &|p| affine_near(w.f, p, w.eps)); } - if let Some(m) = &mut out.mask { + if let Some(m) = out.extras_if_any().and_then(|e| e.mask.as_mut()) { m.art = Arc::new(map_node(&m.art, w)); } match &mut out.kind { @@ -1494,7 +1494,7 @@ fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node { if let NodeKind::Path { clipping, .. } = &mut clip.kind { *clipping = true; } - let image = Node::new(n.id, NodeKind::Image(ImageObject { xf, ..im.clone() })); + let image = Node::new(n.id, NodeKind::Image(Box::new(ImageObject { xf, ..im.clone() }))); pieces.push(Arc::new(Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true }))); } } @@ -1523,17 +1523,18 @@ fn pattern_art(n: &Node, tiles: PatternTiles) -> Option { _ => None, }; let plain = |items: Vec| { - Arc::new(Node { + let mut p = Node { appearance: Appearance { items, ..Default::default() }.into(), opacity: 1.0, blend: Default::default(), isolate: false, knockout: Default::default(), knockout_shape: false, - mask: None, name: None, ..n.clone() - }) + }; + p.extras_mut().mask = None; + Arc::new(p) }; let mut pieces = vec![]; let mut rest = vec![]; @@ -1640,7 +1641,7 @@ pub fn expand_live_with(n: &Node, outline: Outliner) -> Vec { pub fn expand_live_hooks(n: &Node, hooks: Hooks) -> Vec { match &n.kind { NodeKind::Blend { children, spec } => blend_expand(children, spec), - NodeKind::Envelope { content, kind, fidelity, options, frame, .. } => expand_envelope(content, kind, *fidelity, *options, *frame, hooks), + NodeKind::Envelope { content, kind, fidelity, options, frame, .. } => expand_envelope(content, kind, *fidelity, *options, **frame, hooks), NodeKind::Mesh(m) => mesh_quad_nodes(m, 8), NodeKind::Repeat(r) => r.expand(), NodeKind::PlacedDocument(p) => p.art(n), @@ -1669,7 +1670,7 @@ pub fn expanded_group_hooks(n: &Node, hooks: Hooks) -> Node { g.knockout = crate::Knockout::Off; } g.knockout_shape = n.knockout_shape; - g.mask = n.mask.clone(); + g.extras_mut().mask = n.extras().mask.clone(); g } diff --git a/crates/doc/src/mem.rs b/crates/doc/src/mem.rs index 1162e8a6..1a51c4d7 100644 --- a/crates/doc/src/mem.rs +++ b/crates/doc/src/mem.rs @@ -2,9 +2,11 @@ //! //! Documents share structure (`Arc` children, [`crate::Shared`] resources), so an old //! version kept in the undo history holds only what the edits after it replaced. The estimate -//! walks the old version beside the newer one and visits only what isn't shared: a subtree whose -//! `Arc` the newer document also holds costs nothing and isn't entered, so the walk is about as -//! long as the edit was (plus a pass over each changed child list). It counts the heap the values +//! walks two versions side by side and visits only what isn't shared: a subtree whose `Arc` both +//! hold costs nothing and isn't entered, so the walk is about as long as the edit was (plus a pass +//! over each changed child list). One walk gives both directions: what the older version keeps +//! that the newer one doesn't (the undo step's cost) and the other way round (the cost of the redo +//! step that undoing it makes), so undo and redo needn't walk again. It counts the heap the values //! own (struct sizes, vector capacities, strings, JSON) plus a typical allocator overhead per //! allocation, without following every last field: an estimate in the right ballpark, for a //! memory budget, not an exact figure. @@ -15,17 +17,18 @@ use std::sync::Arc; use serde_json::Value; use vectorcraft_color::{Paint, Swatch, SwatchGroup}; -use vectorcraft_geom::{Anchor, PathData, SubPath}; +use vectorcraft_geom::{Affine, Anchor, PathData, SubPath}; +use crate::live::{BlendSpec, EnvelopeKind}; use crate::text::{ParaStyle, TextObject, TextRun, TextStyleDef}; -use crate::{Appearance, AppearanceItem, Document, Effect, GraphicStyle, Node, NodeKind, SavedSelection, Shared}; +use crate::{Appearance, AppearanceItem, Document, Effect, GraphicStyle, ImageObject, Node, NodeExtras, NodeKind, SavedSelection, Shared}; /// Bookkeeping and rounding a typical allocator adds to each allocation. const ALLOC: usize = 16; /// An `Arc`'s two reference counts. const ARC: usize = 16; -/// The kinds of node this doesn't break down (meshes, envelopes' settings, placed documents…): -/// a typical size of what they own besides the node. +/// The kinds of node this doesn't break down (meshes, repeats' settings, placed documents…): a +/// typical size of what they own besides the node. const OTHER_KIND: usize = 256; impl Document { @@ -33,23 +36,48 @@ impl Document { /// keeping this version in the undo history costs while `newer` (a later version) is kept too. /// Visits only the parts that aren't shared (see the [module docs](self)). pub fn retained_bytes(&self, newer: &Document) -> usize { - if std::ptr::eq(self, newer) { - return 0; + self.retained_bytes_each(newer).0 + } + + /// [`Document::retained_bytes`] both ways in one walk: what this document keeps that `other` + /// doesn't share, and what `other` keeps that this one doesn't. + pub fn retained_bytes_each(&self, other: &Document) -> (usize, usize) { + if std::ptr::eq(self, other) { + return (0, 0); } - let mut w = Walk { newer, seen: None, bytes: size_of::() + ALLOC + ARC }; - w.list(&self.layers, Some(&newer.layers)); - for (i, s) in self.symbols.iter().enumerate() { - w.bytes += s.name.capacity(); - w.tree(&s.art, newer.symbols.get(i).map(|n| &n.art)); + let own = size_of::() + ALLOC + ARC; + let mut w = Walk { docs: [self, other], seen: [None, None], bytes: [own, own] }; + w.list(&self.layers, &other.layers); + for i in 0..self.symbols.len().max(other.symbols.len()) { + match (self.symbols.get(i), other.symbols.get(i)) { + (Some(a), Some(b)) => w.pair(&a.art, &b.art), + (a, b) => { + a.into_iter().for_each(|a| w.alone(0, &a.art)); + b.into_iter().for_each(|b| w.alone(1, &b.art)); + } + } } - w.bytes += vec_bytes(&self.symbols); - for (i, p) in self.patterns.iter().enumerate() { - w.bytes += p.name.capacity(); - w.list(&p.art, newer.patterns.get(i).map(|n| &n.art)); + for i in 0..self.patterns.len().max(other.patterns.len()) { + match (self.patterns.get(i), other.patterns.get(i)) { + (Some(a), Some(b)) => w.list(&a.art, &b.art), + (a, b) => { + a.into_iter().flat_map(|a| &a.art).for_each(|n| w.alone(0, n)); + b.into_iter().flat_map(|b| &b.art).for_each(|n| w.alone(1, n)); + } + } } - w.bytes += vec_bytes(&self.patterns); - w.bytes += self.resources_bytes(newer); - w.bytes + let [mut a, mut b] = w.bytes; + a += self.lists_bytes() + self.resources_bytes(other); + b += other.lists_bytes() + other.resources_bytes(self); + (a, b) + } + + /// The symbol and pattern lists themselves (their art is counted as nodes). + fn lists_bytes(&self) -> usize { + vec_bytes(&self.symbols) + + self.symbols.iter().map(|s| s.name.capacity()).sum::() + + vec_bytes(&self.patterns) + + self.patterns.iter().map(|p| p.name.capacity()).sum::() } /// The document-wide lists and settings: a copy of the document copies those that aren't @@ -92,47 +120,99 @@ impl Document { } } -/// One estimate in progress. +/// One estimate in progress: two documents walked side by side. struct Walk<'a> { - newer: &'a Document, - /// Every node the newer document holds (by address), built the first time a node has no - /// counterpart in place: then a node moved elsewhere (grouped, say) is still found shared. - seen: Option>, - bytes: usize, + docs: [&'a Document; 2], + /// For each side, every node the other document holds (by address), built the first time a + /// node of that side that the other may share has no counterpart in place: then a node moved + /// elsewhere (grouped, say) is still found shared. + seen: [Option>; 2], + /// For each side, what it keeps that the other doesn't. + bytes: [usize; 2], } impl Walk<'_> { - /// The nodes of `list` that `newer` (the counterpart list, if any) doesn't share. - fn list(&mut self, list: &[Arc], newer: Option<&Vec>>) { - for (i, n) in list.iter().enumerate() { - self.tree(n, newer.and_then(|l| l.get(i))); + /// Two versions of a child list: the nodes in the same place on both sides (the same `Arc`, or + /// versions of the same object) are compared in place; the others (inserted, deleted, moved) + /// may still be shared from elsewhere. + fn list(&mut self, a: &[Arc], b: &[Arc]) { + let same = |x: &Arc, y: &Arc| Arc::ptr_eq(x, y) || x.id == y.id; + // In place from the start and from the end: an insertion or deletion shifts only what is + // between. + let head = a.iter().zip(b).take_while(|(x, y)| same(x, y)).count(); + let (a_rest, b_rest) = (a.get(head..).unwrap_or_default(), b.get(head..).unwrap_or_default()); + let tail = a_rest.iter().rev().zip(b_rest.iter().rev()).take_while(|(x, y)| same(x, y)).count(); + for (x, y) in a.iter().zip(b).take(head).chain(a_rest.iter().rev().zip(b_rest.iter().rev()).take(tail)) { + self.pair(x, y); + } + let a_mid = a_rest.get(..a_rest.len().saturating_sub(tail)).unwrap_or_default(); + let b_mid = b_rest.get(..b_rest.len().saturating_sub(tail)).unwrap_or_default(); + for i in 0..a_mid.len().max(b_mid.len()) { + match (a_mid.get(i), b_mid.get(i)) { + (Some(x), Some(y)) if same(x, y) => self.pair(x, y), + (x, y) => { + x.into_iter().for_each(|x| self.alone(0, x)); + y.into_iter().for_each(|y| self.alone(1, y)); + } + } } } - /// Node `n` and what under it isn't shared; `newer` is the node in its place in the newer - /// document, if any. - fn tree(&mut self, n: &Arc, newer: Option<&Arc>) { - if newer.is_some_and(|m| Arc::ptr_eq(n, m)) { + /// Node `a` and `b` in the same place on the two sides: nothing when they are the same `Arc`, + /// both nodes and what under them isn't shared when they are versions of the same object. + fn pair(&mut self, a: &Arc, b: &Arc) { + if Arc::ptr_eq(a, b) { return; } - // In its place is another version of the same object (edited, moved): compare their - // children in place. Else it may sit elsewhere in the newer document. - let counterpart = newer.filter(|m| m.id == n.id); - if counterpart.is_none() && self.newer_holds(n) { + if a.id != b.id { + self.alone(0, a); + self.alone(1, b); return; } - self.bytes += node_bytes(n, counterpart.map(|m| &**m)); - if let Some(ch) = n.children() { - self.list(ch, counterpart.and_then(|m| m.children())); + self.bytes[0] += node_bytes(a, Some(b)); + self.bytes[1] += node_bytes(b, Some(a)); + match (a.children(), b.children()) { + (Some(x), Some(y)) => self.list(x, y), + (x, y) => { + x.into_iter().flatten().for_each(|c| self.alone(0, c)); + y.into_iter().flatten().for_each(|c| self.alone(1, c)); + } } - if let Some(mask) = &n.mask { - self.tree(&mask.art, counterpart.and_then(|m| m.mask.as_ref()).map(|m| &m.art)); + match (&a.extras().mask, &b.extras().mask) { + (Some(x), Some(y)) => self.pair(&x.art, &y.art), + (x, y) => { + x.iter().for_each(|m| self.alone(0, &m.art)); + y.iter().for_each(|m| self.alone(1, &m.art)); + } } } - fn newer_holds(&mut self, n: &Arc) -> bool { - let newer = self.newer; - let seen = self.seen.get_or_insert_with(|| { + /// Node `n` of side `side` without a counterpart in place, and what under it the other side + /// doesn't hold. + fn alone(&mut self, side: usize, n: &Arc) { + if self.other_holds(side, n) { + return; + } + if let Some(b) = self.bytes.get_mut(side) { + *b += node_bytes(n, None); + } + for c in n.children().into_iter().flatten() { + self.alone(side, c); + } + if let Some(m) = &n.extras().mask { + self.alone(side, &m.art); + } + } + + /// Whether the document across from `side` holds node `n` somewhere. + fn other_holds(&mut self, side: usize, n: &Arc) -> bool { + // Held only where it was found: not in the other document (which would hold it too). So + // new art inserted by the edit is told apart without looking. + if Arc::strong_count(n) == 1 { + return false; + } + let (Some(other), Some(seen)) = (self.docs.get(1 - side.min(1)), self.seen.get_mut(side)) else { return false }; + let seen = seen.get_or_insert_with(|| { fn add(n: &Arc, out: &mut HashSet) { // A node already seen was entered already (a node shared within the document). if !out.insert(Arc::as_ptr(n) as usize) { @@ -141,12 +221,12 @@ impl Walk<'_> { for c in n.children().into_iter().flatten() { add(c, out); } - if let Some(m) = &n.mask { + if let Some(m) = &n.extras().mask { add(&m.art, out); } } let mut out = HashSet::new(); - let roots = newer.layers.iter().chain(newer.symbols.iter().map(|s| &s.art)).chain(newer.patterns.iter().flat_map(|p| &p.art)); + let roots = other.layers.iter().chain(other.symbols.iter().map(|s| &s.art)).chain(other.patterns.iter().flat_map(|p| &p.art)); for n in roots { add(n, &mut out); } @@ -157,28 +237,29 @@ impl Walk<'_> { } /// One node's own heap: the node, and what it owns besides its children and mask art (counted -/// as nodes of their own). Its appearance counts unless `newer`, its newer version, shares it. -fn node_bytes(n: &Node, newer: Option<&Node>) -> usize { +/// as nodes of their own). Its appearance counts unless `other`, its other version, shares it. +fn node_bytes(n: &Node, other: Option<&Node>) -> usize { fn boxed(b: &Option>) -> usize { b.as_ref().map_or(0, |_| ALLOC + size_of::()) } let mut b = ALLOC + ARC + size_of::(); b += n.name.as_ref().map_or(0, String::capacity); - if !newer.is_some_and(|m| Shared::ptr_eq(&m.appearance, &n.appearance)) { + if !other.is_some_and(|m| Shared::ptr_eq(&m.appearance, &n.appearance)) { b += ALLOC + ARC + appearance_heap(&n.appearance); } - b += boxed(&n.mask) + boxed(&n.graph) + boxed(&n.shaper) + boxed(&n.attrs) + boxed(&n.slice) + boxed(&n.perspective); - b += n.trace.as_ref().map_or(0, |t| ALLOC + json_bytes(t)); + if let Some(e) = n.extras_box() { + b += ALLOC + size_of::(); + b += boxed(&e.mask) + boxed(&e.graph) + boxed(&e.shaper) + boxed(&e.attrs) + boxed(&e.slice) + boxed(&e.perspective); + b += e.trace.as_ref().map_or(0, |t| ALLOC + size_of::() + json_bytes(t)); + } b += match &n.kind { - NodeKind::Path { path, .. } => path_bytes(path), - NodeKind::Layer { children, .. } - | NodeKind::Group { children, .. } - | NodeKind::Compound { children, .. } - | NodeKind::Blend { children, .. } - | NodeKind::Envelope { content: children, .. } => vec_bytes(children), + NodeKind::Path { path, live, .. } => path_bytes(path) + boxed(live), + NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => vec_bytes(children), + NodeKind::Blend { children, .. } => vec_bytes(children) + ALLOC + size_of::(), + NodeKind::Envelope { content, .. } => vec_bytes(content) + 2 * ALLOC + size_of::() + size_of::(), NodeKind::Repeat(r) => vec_bytes(&r.source) + OTHER_KIND, NodeKind::Text(t) => ALLOC + text_bytes(t), - NodeKind::Image(im) => im.key.capacity(), + NodeKind::Image(im) => ALLOC + size_of::() + im.key.capacity(), NodeKind::SymbolInstance { symbol, .. } => symbol.capacity(), _ => OTHER_KIND, }; @@ -273,7 +354,8 @@ mod tests { } /// Moving every rectangle: each keeps its old node and anchors (its appearance stays shared). - /// The process grew by about 775 bytes per moved rectangle when measured (#1244). + /// The process grew by about 775 bytes per moved rectangle when measured (#1244), about 490 + /// once nodes kept their rarely set parts out of line. #[test] fn moving_rectangles_costs_their_nodes() { let n = 1000; @@ -307,6 +389,26 @@ mod tests { assert!(bytes < 8192, "only the old layer and its child list: {bytes}"); } + /// One walk gives both ways, each what a walk the other way gives; an insertion and a deletion + /// cost the side that has the objects. + #[test] + fn both_ways_at_once() { + let before = doc_with_rects(100); + let mut after = before.clone(); + let layer = after.layers[0].id; + for at in [0, 50, usize::MAX] { + let id = after.alloc_id(); + let r = shapes::rectangle(kurbo::Rect::new(0.0, 0.0, 10.0, 10.0)); + after.insert(Some(layer), at, Node::path(id, r, Appearance::default_art())).unwrap(); + } + let (kept, added) = before.retained_bytes_each(&after); + assert_eq!((kept, added), (before.retained_bytes(&after), after.retained_bytes(&before))); + assert_eq!(after.retained_bytes_each(&before), (added, kept)); + // The old layer and its list are the old version's own; the new one holds three new nodes. + assert!(kept < 4096, "{kept}"); + assert!(added > kept + 3 * 300, "{added} for three new rectangles"); + } + #[test] fn a_resource_counts_only_when_changed() { let before = doc_with_rects(1); diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 9eb4b0cd..06db3cc8 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -427,8 +427,9 @@ pub enum NodeKind { path: PathData, #[serde(default, skip_serializing_if = "crate::skip::is_default")] rule: FillRule, + /// Boxed: few paths are live shapes, and the shape is larger than the rest of the variant. #[serde(default, skip_serializing_if = "Option::is_none")] - live: Option, + live: Option>, /// This path acts as a clipping path inside a clip group. #[serde(default, skip_serializing_if = "crate::skip::is_default")] clipping: bool, @@ -443,7 +444,8 @@ pub enum NodeKind { rule: FillRule, }, Text(Box), - Image(ImageObject), + /// Boxed, as the other large kinds: a node is as large as its largest kind. + Image(Box), SymbolInstance { symbol: String, xf: Affine, @@ -453,12 +455,12 @@ pub enum NodeKind { Blend { children: Vec>, #[serde(default)] - spec: BlendSpec, + spec: Box, }, /// Live envelope distortion of `content`. Envelope { content: Vec>, - kind: EnvelopeKind, + kind: Box, /// Envelope Options → Fidelity (0–100). #[serde(default = "crate::live::default_fidelity")] fidelity: f64, @@ -472,7 +474,7 @@ pub enum NodeKind { /// the page axes can't stand for (rotation, shear, reflection). The content maps from its /// bounds in this frame, and a warp bends along it. #[serde(default, skip_serializing_if = "crate::skip::is_default")] - frame: Affine, + frame: Box, }, /// Gradient mesh. Mesh(GradientMesh), @@ -491,72 +493,328 @@ fn one() -> f32 { } /// One object in the tree. Children are `Arc`s so documents share structure between undo states. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +/// +/// What most objects have is kept inline; what few have is in [`NodeExtras`], allocated only for +/// the objects that have any of it: every undo step of an edit copies the nodes it changed, so a +/// small node is a cheap step. Files keep the flat field list either way: a node is written field +/// by field from where each lives, and read through a flat `NodeWire`. +#[derive(Clone, Debug, PartialEq, Deserialize)] +#[serde(from = "NodeWire")] pub struct Node { pub id: NodeId, - #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, - #[serde(default = "yes", skip_serializing_if = "crate::skip::is_true")] pub visible: bool, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub locked: bool, - #[serde(default = "one", skip_serializing_if = "crate::skip::is_one")] pub opacity: f32, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub blend: BlendMode, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub isolate: bool, /// Knockout Group: whether a container's children knock each other out (see [`Knockout`]). - #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub knockout: Knockout, /// Opacity & Mask Define Knockout Shape: inside a knockout group, this object's opacity and /// opacity mask scale how much of the objects below it in the group it knocks out. - #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub knockout_shape: bool, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub appearance: crate::Shared, - /// Opacity mask (Transparency panel). Its art lives here, outside the layer tree. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub mask: Option>, - /// Image Trace object: `{preset, params, view?}` it was traced with (the Image Trace panel shows - /// them; `view` is its [`crate::TraceView`] id, absent for the tracing result). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub trace: Option>, - /// Object → Text Wrap: area type below this object (in the same layer) flows around it. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub wrap: Option, - /// Graph object: the group's children are generated from this spec (Object → Graph). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub graph: Option>, - /// Index of the graph series this generated group belongs to. Axes, the legend and other - /// children leave it unset, so a series named "Legend" or "Axes" is not mistaken for them. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub series_index: Option, - /// Editable Shaper composition; the original art is retained in its source child. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub shaper: Option>, pub kind: NodeKind, - /// The [`crate::GraphicStyle::id`] last applied to this object. It stays linked while it keeps - /// that style's look: editing its appearance or transparency breaks the link. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub graphic_style: Option, - /// Attributes panel: centre point display, image map, URL and note (`None`: all defaults). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub attrs: Option>, - /// Object → Slice → Make: the object is an object slice (its slice follows its bounds) with - /// these options. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub slice: Option>, /// The angle of the object's own axes, counter-clockwise degrees (0: square to the page): its /// bounding box and handles stand at this angle. Transforms turn it with the object (see /// [`crate::orient`]); Reset Bounding Box sets it back to 0. - #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub bbox_angle: f64, + /// The rarely set parts, read with [`Node::extras`] and changed with [`Node::extras_mut`] + /// (public so that `Node { opacity: 1.0, ..node }` works). + pub extras: Extras, +} + +/// The parts of a [`Node`] that few objects have: kept out of line, so that the nodes without +/// them stay small ([`Node::extras`]). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct NodeExtras { + /// Opacity mask (Transparency panel). Its art lives here, outside the layer tree. + pub mask: Option>, + /// Image Trace object: `{preset, params, view?}` it was traced with (the Image Trace panel shows + /// them; `view` is its [`crate::TraceView`] id, absent for the tracing result). + pub trace: Option>, + /// Object → Text Wrap: area type below this object (in the same layer) flows around it. + pub wrap: Option, + /// Graph object: the group's children are generated from this spec (Object → Graph). + pub graph: Option>, + /// Index of the graph series this generated group belongs to. Axes, the legend and other + /// children leave it unset, so a series named "Legend" or "Axes" is not mistaken for them. + pub series_index: Option, + /// Editable Shaper composition; the original art is retained in its source child. + pub shaper: Option>, + /// The [`crate::GraphicStyle::id`] last applied to this object. It stays linked while it keeps + /// that style's look: editing its appearance or transparency breaks the link. + pub graphic_style: Option, + /// Attributes panel: centre point display, image map, URL and note (`None`: all defaults). + pub attrs: Option>, + /// Object → Slice → Make: the object is an object slice (its slice follows its bounds) with + /// these options. + pub slice: Option>, /// Object › Perspective: the perspective grid plane the object is attached to. - #[serde(default, skip_serializing_if = "Option::is_none")] pub perspective: Option>, } +/// No extras: what [`Node::extras`] gives for a node without any. +static NO_EXTRAS: NodeExtras = NodeExtras { + mask: None, + trace: None, + wrap: None, + graph: None, + series_index: None, + shaper: None, + graphic_style: None, + attrs: None, + slice: None, + perspective: None, +}; + +/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal +/// none. +#[derive(Clone, Debug, Default)] +pub struct Extras(Option>); + +impl Extras { + fn get(&self) -> &NodeExtras { + self.0.as_deref().unwrap_or(&NO_EXTRAS) + } +} + +impl PartialEq for Extras { + fn eq(&self, other: &Self) -> bool { + match (&self.0, &other.0) { + (Some(a), Some(b)) => a == b, + _ => self.get() == other.get(), + } + } +} + +impl Node { + /// The rarely set parts of this node (all unset when it has none). + #[inline] + pub fn extras(&self) -> &NodeExtras { + self.extras.get() + } + /// The rarely set parts of this node, to change: when the change is done (the guard dropped), + /// extras that were set are allocated and extras left all unset are let go. Setting one to + /// `None` on a node without extras allocates nothing. + #[inline] + pub fn extras_mut(&mut self) -> ExtrasMut<'_> { + ExtrasMut { slot: &mut self.extras.0, local: NodeExtras::default() } + } + /// The rarely set parts of this node, if it has any: to change what is there in place. + #[inline] + pub fn extras_if_any(&mut self) -> Option<&mut NodeExtras> { + self.extras.0.as_deref_mut() + } + /// The live shape of a path object, if it is one. + pub fn live_shape(&self) -> Option<&LiveShape> { + match &self.kind { + NodeKind::Path { live, .. } => live.as_deref(), + _ => None, + } + } + /// The live shape of a path object, if it is one, to change. + pub fn live_shape_mut(&mut self) -> Option<&mut LiveShape> { + match &mut self.kind { + NodeKind::Path { live, .. } => live.as_deref_mut(), + _ => None, + } + } + /// This node with opacity mask `mask` (or none). + pub fn with_mask(mut self, mask: Option>) -> Self { + self.extras_mut().mask = mask; + self + } + /// The extras, if this node has them allocated (for [`crate::mem`]'s estimate). + pub(crate) fn extras_box(&self) -> Option<&NodeExtras> { + self.extras.0.as_deref() + } +} + +/// [`Node::extras_mut`]: the node's extras while they change. +pub struct ExtrasMut<'a> { + slot: &'a mut Option>, + /// Stands for the extras of a node without any, boxed into it when dropped if one was set. + local: NodeExtras, +} + +impl std::ops::Deref for ExtrasMut<'_> { + type Target = NodeExtras; + fn deref(&self) -> &NodeExtras { + self.slot.as_deref().unwrap_or(&self.local) + } +} + +impl std::ops::DerefMut for ExtrasMut<'_> { + fn deref_mut(&mut self) -> &mut NodeExtras { + match self.slot.as_deref_mut() { + Some(e) => e, + None => &mut self.local, + } + } +} + +impl Drop for ExtrasMut<'_> { + fn drop(&mut self) { + match self.slot.as_deref() { + Some(e) if *e == NO_EXTRAS => *self.slot = None, + Some(_) => {} + None if self.local != NO_EXTRAS => *self.slot = Some(Box::new(std::mem::take(&mut self.local))), + None => {} + } + } +} + +/// How a [`Node`] is written: the flat field list of the file format, in its order, borrowed from +/// the node and its extras (nothing is copied). The node reads back through `NodeWire`. +impl Serialize for Node { + fn serialize(&self, s: S) -> Result { + use crate::skip::{is_default, is_one, is_true}; + use serde::ser::SerializeStruct; + let e = self.extras(); + // As the derive did: the fields written, then each field written or skipped in order. + let skips = [ + false, + self.name.is_none(), + is_true(&self.visible), + is_default(&self.locked), + is_one(&self.opacity), + is_default(&self.blend), + is_default(&self.isolate), + is_default(&self.knockout), + is_default(&self.knockout_shape), + is_default(&self.appearance), + e.mask.is_none(), + e.trace.is_none(), + e.wrap.is_none(), + e.graph.is_none(), + e.series_index.is_none(), + e.shaper.is_none(), + false, + e.graphic_style.is_none(), + e.attrs.is_none(), + e.slice.is_none(), + is_default(&self.bbox_angle), + e.perspective.is_none(), + ]; + let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?; + let mut skip = skips.into_iter(); + macro_rules! field { + ($name:literal, $v:expr) => { + if skip.next().unwrap_or(false) { + st.skip_field($name)?; + } else { + st.serialize_field($name, $v)?; + } + }; + } + field!("id", &self.id); + field!("name", &self.name); + field!("visible", &self.visible); + field!("locked", &self.locked); + field!("opacity", &self.opacity); + field!("blend", &self.blend); + field!("isolate", &self.isolate); + field!("knockout", &self.knockout); + field!("knockout_shape", &self.knockout_shape); + field!("appearance", &self.appearance); + field!("mask", &e.mask); + field!("trace", &e.trace); + field!("wrap", &e.wrap); + field!("graph", &e.graph); + field!("series_index", &e.series_index); + field!("shaper", &e.shaper); + field!("kind", &self.kind); + field!("graphic_style", &e.graphic_style); + field!("attrs", &e.attrs); + field!("slice", &e.slice); + field!("bbox_angle", &self.bbox_angle); + field!("perspective", &e.perspective); + st.end() + } +} + +/// How a [`Node`] is read: the flat field list of the file format. Fields move into the node +/// (and its extras, allocated only when one is set) without a copy of what they own. +#[derive(Deserialize)] +#[serde(rename = "Node")] +struct NodeWire { + id: NodeId, + #[serde(default)] + name: Option, + #[serde(default = "yes")] + visible: bool, + #[serde(default)] + locked: bool, + #[serde(default = "one")] + opacity: f32, + #[serde(default)] + blend: BlendMode, + #[serde(default)] + isolate: bool, + #[serde(default)] + knockout: Knockout, + #[serde(default)] + knockout_shape: bool, + #[serde(default)] + appearance: crate::Shared, + #[serde(default)] + mask: Option>, + #[serde(default)] + trace: Option>, + #[serde(default)] + wrap: Option, + #[serde(default)] + graph: Option>, + #[serde(default)] + series_index: Option, + #[serde(default)] + shaper: Option>, + kind: NodeKind, + #[serde(default)] + graphic_style: Option, + #[serde(default)] + attrs: Option>, + #[serde(default)] + slice: Option>, + #[serde(default)] + bbox_angle: f64, + #[serde(default)] + perspective: Option>, +} + +impl From for Node { + fn from(w: NodeWire) -> Self { + let extras = NodeExtras { + mask: w.mask, + trace: w.trace, + wrap: w.wrap, + graph: w.graph, + series_index: w.series_index, + shaper: w.shaper, + graphic_style: w.graphic_style, + attrs: w.attrs, + slice: w.slice, + perspective: w.perspective, + }; + Node { + id: w.id, + name: w.name, + visible: w.visible, + locked: w.locked, + opacity: w.opacity, + blend: w.blend, + isolate: w.isolate, + knockout: w.knockout, + knockout_shape: w.knockout_shape, + appearance: w.appearance, + kind: w.kind, + bbox_angle: w.bbox_angle, + extras: Extras((extras != NO_EXTRAS).then(|| Box::new(extras))), + } + } +} + /// Opacity mask: the luminance of the mask art sets the object's opacity (white = opaque). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct OpacityMask { @@ -595,18 +853,9 @@ impl Node { knockout: Knockout::Neutral, knockout_shape: false, appearance: Appearance::default().into(), - mask: None, - trace: None, - wrap: None, - graph: None, - series_index: None, - shaper: None, kind, - graphic_style: None, - attrs: None, - slice: None, bbox_angle: 0.0, - perspective: None, + extras: Extras::default(), } } pub fn path(id: NodeId, path: PathData, appearance: Appearance) -> Self { @@ -691,7 +940,7 @@ impl Node { && !self.isolate && self.knockout == Knockout::Neutral && !self.knockout_shape - && self.mask.is_none() + && self.extras().mask.is_none() } /// Kind name as the Layers panel / Properties panel shows it. pub fn kind_label(&self) -> &'static str { @@ -772,7 +1021,7 @@ impl Node { } NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => children .iter() - .skip(usize::from(self.shaper.is_some())) + .skip(usize::from(self.extras().shaper.is_some())) .filter(|c| c.visible || !matches!(self.kind, NodeKind::Layer { .. })) .fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric_bounds())), NodeKind::Text(t) => self.projected(t.bounds()), @@ -782,7 +1031,7 @@ impl Node { let b = crate::live::nodes_bounds(children); vectorcraft_geom::union_opt(b, spec.spine.as_ref().and_then(|s| s.bounds())) } - NodeKind::Envelope { content, kind, frame, .. } => crate::live::envelope_bounds(content, kind, *frame), + NodeKind::Envelope { content, kind, frame, .. } => crate::live::envelope_bounds(content, kind, **frame), NodeKind::Mesh(m) => m.bounds(), NodeKind::Repeat(r) => r.bounds(), NodeKind::PlacedDocument(p) => Some(p.bounds()), @@ -806,7 +1055,7 @@ impl Node { let o = self.appearance.outset(); children .iter() - .skip(usize::from(self.shaper.is_some())) + .skip(usize::from(self.extras().shaper.is_some())) .fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds())) .map(|b| b.inflate(o, o)) } @@ -903,8 +1152,8 @@ impl Node { for c in content.iter_mut() { Arc::make_mut(c).transform_scaled(a, sc); } - *frame = crate::live::envelope_frame(a * *frame); - match kind { + **frame = crate::live::envelope_frame(a * **frame); + match &mut **kind { EnvelopeKind::Mesh { points, handles, .. } => { for p in points.iter_mut() { *p = a * *p; @@ -923,7 +1172,7 @@ impl Node { NodeKind::Mesh(m) => m.transform(a), NodeKind::Repeat(r) => r.transform(a, *sc), } - if let Some(m) = &mut self.mask + if let Some(m) = self.extras_if_any().and_then(|e| e.mask.as_mut()) && m.linked { Arc::make_mut(&mut m.art).transform_scaled(a, sc); @@ -1039,7 +1288,7 @@ impl Node { } } NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { - for c in children.iter().skip(usize::from(self.shaper.is_some())).filter(|c| c.visible) { + for c in children.iter().skip(usize::from(self.extras().shaper.is_some())).filter(|c| c.visible) { c.push_clip_shapes(text, out); } } @@ -1324,25 +1573,25 @@ impl ImageMap { impl Node { /// The URL this object links to, if any. pub fn url(&self) -> Option<&str> { - self.attrs.as_deref().map(|a| a.url.as_str()).filter(|u| !u.is_empty()) + self.extras().attrs.as_deref().map(|a| a.url.as_str()).filter(|u| !u.is_empty()) } /// Whether the canvas shows this object's centre point when it is selected: as set in the /// Attributes panel, else [`Self::shows_center_by_default`]. pub fn shows_center(&self) -> bool { - self.attrs.as_deref().and_then(|a| a.show_center).unwrap_or_else(|| self.shows_center_by_default()) + self.extras().attrs.as_deref().and_then(|a| a.show_center).unwrap_or_else(|| self.shows_center_by_default()) } /// Shapes drawn with the shape tools (live rectangles, ellipses and polygons) show their centre. pub fn shows_center_by_default(&self) -> bool { - matches!(&self.kind, NodeKind::Path { live: Some(l), .. } if !matches!(l, LiveShape::Line { .. } | LiveShape::Path { .. })) + matches!(&self.kind, NodeKind::Path { live: Some(l), .. } if !matches!(**l, LiveShape::Line { .. } | LiveShape::Path { .. })) } /// Change this object's attributes with `f`; all-default attributes are dropped. pub fn edit_attrs(&mut self, f: impl FnOnce(&mut ObjectAttributes) -> R) -> R { - let mut a = self.attrs.take().unwrap_or_default(); + let mut a = self.extras_mut().attrs.take().unwrap_or_default(); let r = f(&mut a); - self.attrs = (*a != ObjectAttributes::default()).then_some(a); + self.extras_mut().attrs = (*a != ObjectAttributes::default()).then_some(a); r } } @@ -1358,7 +1607,7 @@ impl Node { self.visible && (self.opacity < 1.0 || self.blend != BlendMode::Normal - || self.mask.as_ref().is_some_and(|m| !m.disabled) + || self.extras().mask.as_ref().is_some_and(|m| !m.disabled) || effects(&self.appearance.effects) || self .appearance @@ -1454,13 +1703,14 @@ mod tests { let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), xf }; let mut n = Node::path(NodeId(1), live.to_path(), Appearance::default_art()); if let NodeKind::Path { live: slot, .. } = &mut n.kind { - *slot = Some(live); + *slot = Some(Box::new(live)); } n } fn live_of(n: &Node) -> (f64, f64, [f64; 4], Affine) { - let NodeKind::Path { live: Some(LiveShape::Rectangle { w, h, radii, xf, .. }), .. } = &n.kind else { panic!("not a live rectangle") }; + let NodeKind::Path { live: Some(l), .. } = &n.kind else { panic!("not a live shape") }; + let LiveShape::Rectangle { w, h, radii, xf, .. } = &**l else { panic!("not a live rectangle") }; (*w, *h, *radii, *xf) } @@ -1595,7 +1845,7 @@ mod tests { |n| n.knockout = Knockout::On, |n| n.knockout = Knockout::Off, |n| n.knockout_shape = true, - |n| n.mask = Some(Box::new(OpacityMask::new(n.clone(), true))), + |n| n.extras_mut().mask = Some(Box::new(OpacityMask::new(n.clone(), true))), ]; for tweak in tweaks { let mut n = p(); diff --git a/crates/doc/src/perspective.rs b/crates/doc/src/perspective.rs index 4e1e24ef..4de3823d 100644 --- a/crates/doc/src/perspective.rs +++ b/crates/doc/src/perspective.rs @@ -46,7 +46,7 @@ impl Node { /// The projective map type or a symbol instance in perspective is drawn through (not while /// Edit Text shows it flat). pub fn projection(&self) -> Option { - let a = self.perspective.as_deref().filter(|a| !a.editing)?; + let a = self.extras().perspective.as_deref().filter(|a| !a.editing)?; matches!(self.kind, NodeKind::Text(_) | NodeKind::SymbolInstance { .. }).then(|| a.homography()).flatten() } @@ -60,7 +60,7 @@ impl Node { /// Keep a projection drawing the same picture moved by `a` (the flat art moves by `a` too). pub(crate) fn transform_projection(&mut self, a: vectorcraft_geom::Affine) { - if let Some(p) = self.perspective.as_deref_mut() + if let Some(p) = self.extras_if_any().and_then(|e| e.perspective.as_deref_mut()) && let Some(h) = p.homography().and_then(|h| h.conjugated(a)) { p.projection = Some(h.to_array()); diff --git a/crates/doc/src/placed_document.rs b/crates/doc/src/placed_document.rs index 4d1be2d4..c399bf20 100644 --- a/crates/doc/src/placed_document.rs +++ b/crates/doc/src/placed_document.rs @@ -119,7 +119,7 @@ fn prefix(key: &str) -> String { /// `n` and every descendant, opacity-mask art included. fn walk_all<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) { f(n); - if let Some(m) = &n.mask { + if let Some(m) = &n.extras().mask { walk_all(&m.art, f); } for c in n.children().into_iter().flatten() { @@ -184,7 +184,7 @@ impl Document { } // The art shown may hold placed documents too. let n = Arc::make_mut(n); - if let Some(m) = &mut n.mask { + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { update(&mut m.art); } for c in n.children_mut().into_iter().flatten() { @@ -210,11 +210,11 @@ impl Document { && keys.contains(&p.key) { if let Some(im) = images.get(&p.key).and_then(|b| p.preview_image(&b.bytes)) { - n.kind = NodeKind::Image(im); + n.kind = NodeKind::Image(Box::new(im)); } return; } - if let Some(m) = &mut n.mask { + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { update(&mut m.art, keys, images); } for c in n.children_mut().into_iter().flatten() { @@ -406,7 +406,7 @@ fn read_preview(p: &PlacedDocument, bytes: &Arc>) -> Option crate::NodeId { /// Visit `n` and every descendant (opacity-mask art included) mutably. fn each_mut(n: &mut Node, f: &mut impl FnMut(&mut Node)) { f(n); - if let Some(m) = &mut n.mask { + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { each_mut(Arc::make_mut(&mut m.art), f); } for c in n.children_mut().into_iter().flatten() { @@ -590,7 +590,14 @@ mod tests { d.layers.clear(); let art = Node::new( NodeId(50), - NodeKind::Image(ImageObject { key: "pic".into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default() }), + NodeKind::Image(Box::new(ImageObject { + key: "pic".into(), + width: 1, + height: 1, + xf: Affine::IDENTITY, + link: None, + placement: Default::default(), + })), ); d.symbols.push(Symbol { name: "Star".into(), art: Arc::new(art) }); d.images.insert("pic".into(), ImageBlob::new("image/png", vec![1, 2, 3])); diff --git a/crates/doc/src/slices.rs b/crates/doc/src/slices.rs index f62d7fa0..8deba728 100644 --- a/crates/doc/src/slices.rs +++ b/crates/doc/src/slices.rs @@ -177,13 +177,13 @@ impl Document { } /// Is `id` a user slice or an object with slice options? pub fn is_slice(&self, id: NodeId) -> bool { - self.slice(id).is_some() || self.node(id).is_some_and(|n| n.slice.is_some()) + self.slice(id).is_some() || self.node(id).is_some_and(|n| n.extras().slice.is_some()) } /// The options of slice `id` (user or object). pub fn slice_options(&self, id: NodeId) -> Option<&SliceOptions> { match self.slice(id) { Some(s) => Some(&s.options), - None => self.node(id)?.slice.as_deref(), + None => self.node(id)?.extras().slice.as_deref(), } } /// The rectangle of slice `id` before clipping: a user slice's own, an object slice's object's @@ -191,14 +191,14 @@ impl Document { pub fn slice_bounds(&self, id: NodeId) -> Option { match self.slice(id) { Some(s) => Some(s.rect), - None => self.node(id).filter(|n| n.slice.is_some())?.visual_bounds(), + None => self.node(id).filter(|n| n.extras().slice.is_some())?.visual_bounds(), } } /// The visible objects with slice options and their visual bounds, in paint order. pub fn object_slices(&self) -> Vec<(NodeId, Rect)> { fn walk(nodes: &[Arc], out: &mut Vec<(NodeId, Rect)>) { for n in nodes.iter().filter(|n| n.visible) { - if n.slice.is_some() + if n.extras().slice.is_some() && let Some(b) = n.visual_bounds() { out.push((n.id, b)); diff --git a/crates/doc/src/tests_slices.rs b/crates/doc/src/tests_slices.rs index 3a63fe0c..86a0cee8 100644 --- a/crates/doc/src/tests_slices.rs +++ b/crates/doc/src/tests_slices.rs @@ -30,7 +30,7 @@ fn an_object_slice_follows_its_object_and_numbers_run_by_rows() { let mut d = Document::new(300.0, 200.0); let a = rect(&mut d, Rect::new(100.0, 50.0, 200.0, 150.0)); assert!(d.slice_layout().is_empty(), "no slices, no auto slices"); - d.node_mut(a).unwrap().slice = Some(Box::default()); + d.node_mut(a).unwrap().extras_mut().slice = Some(Box::default()); let lay = d.slice_layout(); let own = lay.iter().find(|s| s.id == Some(a)).unwrap(); assert_eq!((own.source, own.rect), (SliceSource::Object, Rect::new(100.0, 50.0, 200.0, 150.0))); @@ -69,14 +69,15 @@ fn clip_to_artboard_clips_slices_and_off_covers_the_art() { fn slices_survive_a_native_round_trip_and_old_files_load() { let mut d = Document::new(100.0, 100.0); let a = rect(&mut d, Rect::new(10.0, 10.0, 20.0, 20.0)); - d.node_mut(a).unwrap().slice = Some(Box::new(SliceOptions { kind: crate::SliceKind::NoImage, text: "hi".into(), ..Default::default() })); + d.node_mut(a).unwrap().extras_mut().slice = + Some(Box::new(SliceOptions { kind: crate::SliceKind::NoImage, text: "hi".into(), ..Default::default() })); let s = user(&mut d, Rect::new(30.0, 30.0, 60.0, 60.0)); d.slice_mut(s).unwrap().options.url = "https://example.com".into(); d.slices_clip_to_artboard = false; let json = serde_json::to_string(&d).unwrap(); let back: Document = serde_json::from_str(&json).unwrap(); assert_eq!(back.slices, d.slices); - assert_eq!(back.node(a).unwrap().slice, d.node(a).unwrap().slice); + assert_eq!(back.node(a).unwrap().extras().slice, d.node(a).unwrap().extras().slice); assert!(!back.slices_clip_to_artboard); // A document without slices writes none of the new keys (older readers see what they knew). let plain = serde_json::to_string(&Document::new(10.0, 10.0)).unwrap(); diff --git a/crates/doc/src/trace.rs b/crates/doc/src/trace.rs index 72dc470c..5155cc21 100644 --- a/crates/doc/src/trace.rs +++ b/crates/doc/src/trace.rs @@ -67,7 +67,7 @@ impl TraceView { impl Node { /// The View of an Image Trace object (the tracing result for anything else, or an unknown id). pub fn trace_view(&self) -> TraceView { - self.trace.as_deref().map(TraceView::of).unwrap_or_default() + self.extras().trace.as_deref().map(TraceView::of).unwrap_or_default() } } @@ -84,9 +84,9 @@ mod tests { } let mut g = Node::group(crate::NodeId(1), vec![]); assert_eq!(g.trace_view(), TraceView::Result); - g.trace = Some(Box::new(serde_json::json!({"view": "outlines"}))); + g.extras_mut().trace = Some(Box::new(serde_json::json!({"view": "outlines"}))); assert_eq!(g.trace_view(), TraceView::Outlines); - g.trace = Some(Box::new(serde_json::json!({"view": "sepia"}))); + g.extras_mut().trace = Some(Box::new(serde_json::json!({"view": "sepia"}))); assert_eq!(g.trace_view(), TraceView::Result); } } diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 6538b2de..1ca740ae 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -163,24 +163,25 @@ pub fn fresh_ids(d: &mut Document, n: &mut Node, seen: &mut std::collections::Ha fn piece(d: &mut Document, n: &Node, path: PathData, rule: FillRule, item: AppearanceItem) -> Node { let id = d.alloc_id(); let kind = path_kind(d, path, rule); - Node { - id, - name: None, - appearance: Appearance { items: vec![item], ..Default::default() }.into(), - opacity: 1.0, - blend: Default::default(), - isolate: false, - knockout: Default::default(), - knockout_shape: false, - mask: None, - trace: None, - wrap: None, - graph: None, - // The group keeps the URL (one link around the pieces). - attrs: None, - kind, - ..n.clone() - } + let mut out = n.clone(); + out.id = id; + out.name = None; + out.appearance = Appearance { items: vec![item], ..Default::default() }.into(); + out.opacity = 1.0; + out.blend = Default::default(); + out.isolate = false; + out.knockout = Default::default(); + out.knockout_shape = false; + out.kind = kind; + let mut extras = out.extras_mut(); + extras.mask = None; + extras.trace = None; + extras.wrap = None; + extras.graph = None; + // The group keeps the URL (one link around the pieces). + extras.attrs = None; + drop(extras); + out } /// `n`'s geometry with its object-level geometry effects applied, its fill rule, the context of diff --git a/crates/effects/src/marks.rs b/crates/effects/src/marks.rs index 4009cad3..b349d620 100644 --- a/crates/effects/src/marks.rs +++ b/crates/effects/src/marks.rs @@ -32,8 +32,8 @@ pub fn crop_marks_art(n: &Node) -> Option { g.opacity = std::mem::replace(&mut object.opacity, 1.0); g.blend = std::mem::take(&mut object.blend); g.isolate = std::mem::take(&mut object.isolate); - g.mask = object.mask.take(); - g.attrs = object.attrs.take(); + g.extras_mut().mask = object.extras_mut().mask.take(); + g.extras_mut().attrs = object.extras_mut().attrs.take(); if let Some(ch) = g.children_mut() { *ch = vec![Arc::new(object), Arc::new(marks)]; } diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index e37a0d20..05b686b5 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -92,7 +92,7 @@ pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option { art.isolate = false; // One element: its pieces never knock each other out. art.knockout = vectorcraft_doc::Knockout::Off; - art.mask = None; + art.extras_mut().mask = None; art.appearance.effects.clear(); Some(art) } @@ -141,7 +141,7 @@ pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option { if let NodeKind::Path { clipping, .. } = &mut clip.kind { *clipping = true; } - let image = Node { appearance: Appearance::default().into(), ..Node::new(n.id, n.kind.clone()) }; + let image = Node::new(n.id, n.kind.clone()); Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true }) } _ => outline_art(n, symbol_art)?, @@ -159,7 +159,7 @@ pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option { out.isolate = n.isolate; // One element: its pieces never knock each other out. out.knockout = vectorcraft_doc::Knockout::Off; - out.mask = n.mask.clone(); + out.extras_mut().mask = n.extras().mask.clone(); out.appearance.effects = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect(); Some(out) } diff --git a/crates/engine/src/cmd/attributes.rs b/crates/engine/src/cmd/attributes.rs index ec328917..80dac69b 100644 --- a/crates/engine/src/cmd/attributes.rs +++ b/crates/engine/src/cmd/attributes.rs @@ -82,7 +82,7 @@ fn fill_rule_owner(d: &Document, id: NodeId) -> Option<&Node> { /// The Attributes panel's values of `info.ids` beyond overprinting. pub(super) fn fill_info(d: &Document, info: &mut AttributesInfo) { let nodes: Vec<&Node> = info.ids.iter().filter_map(|id| d.node(*id)).collect(); - let attrs = || nodes.iter().map(|n| n.attrs.as_deref().cloned().unwrap_or_default()); + let attrs = || nodes.iter().map(|n| n.extras().attrs.as_deref().cloned().unwrap_or_default()); info.show_center = same(nodes.iter().map(|n| n.shows_center())); info.image_map = same(attrs().map(|a| a.image_map)); info.url = same(attrs().map(|a| a.url)); @@ -126,7 +126,7 @@ impl Edits { /// `n`'s attributes with the edits applied (Show Center kept only where it isn't the default). fn apply(&self, n: &Node) -> ObjectAttributes { - let mut a = n.attrs.as_deref().cloned().unwrap_or_default(); + let mut a = n.extras().attrs.as_deref().cloned().unwrap_or_default(); if let Some(v) = self.show_center { a.show_center = (v != n.shows_center_by_default()).then_some(v); } @@ -158,7 +158,7 @@ fn set(s: &mut Session, p: &Value) -> Result { for id in &ids { let Some(n) = d.node(*id) else { continue }; let new = edits.apply(n); - if new != n.attrs.as_deref().cloned().unwrap_or_default() + if new != n.extras().attrs.as_deref().cloned().unwrap_or_default() && let Some(n) = d.node_mut(*id) { n.edit_attrs(|a| *a = new); diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 38ef5993..c15ace9d 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -553,7 +553,7 @@ fn lp_expand(s: &mut Session, p: &Value) -> Result { for g in &targets { let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?; n.name = None; - n.trace = None; + n.extras_mut().trace = None; if let Some(ch) = n.children_mut() { ch.retain(|c| match c.name.as_deref() { Some(SOURCES_NAME) => false, @@ -790,7 +790,7 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result { if !expand { // Tracing again keeps the object's view unless another is asked for. let view = view.unwrap_or_else(|| src.trace_view()); - g.trace = Some(Box::new(json!({ "preset": preset_label, "params": params_json, "view": view.id() }))); + g.extras_mut().trace = Some(Box::new(json!({ "preset": preset_label, "params": params_json, "view": view.id() }))); } let (par, idx, _) = d.position(target).ok_or(EngineError::NoNode(target))?; d.remove(target)?; @@ -821,10 +821,10 @@ fn trace_set_view(s: &mut Session, p: &Value) -> Result { s.edit("Image Trace View", |d, _| { for g in &targets { let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?; - if let Some(t) = n.trace.as_deref_mut().and_then(Value::as_object_mut) { + if let Some(t) = n.extras_if_any().and_then(|e| e.trace.as_deref_mut()).and_then(Value::as_object_mut) { t.insert("view".into(), json!(view.id())); } else { - n.trace = Some(Box::new(json!({ "preset": "Custom", "view": view.id() }))); + n.extras_mut().trace = Some(Box::new(json!({ "preset": "Custom", "view": view.id() }))); } } Ok(()) @@ -854,7 +854,7 @@ fn trace_expand(s: &mut Session, p: &Value) -> Result { for g in &targets { let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?; n.name = None; - n.trace = None; + n.extras_mut().trace = None; if let Some(ch) = n.children_mut() { ch.retain(|c| !matches!(c.kind, NodeKind::Image(_))); } diff --git a/crates/engine/src/cmd/clipboard/flavours.rs b/crates/engine/src/cmd/clipboard/flavours.rs index d9825d39..2ae87825 100644 --- a/crates/engine/src/cmd/clipboard/flavours.rs +++ b/crates/engine/src/cmd/clipboard/flavours.rs @@ -315,7 +315,7 @@ fn import_image(s: &mut Session, p: &Value) -> Result { let (w, h) = (img.width, img.height); let image = ImageObject { key: img.key.clone(), width: w, height: h, xf: Affine::scale_non_uniform(sx, sy), link: None, placement: Default::default() }; - let mut clip = Clipboard { nodes: vec![Node::new(NodeId(1), NodeKind::Image(image))], ..Default::default() }; + let mut clip = Clipboard { nodes: vec![Node::new(NodeId(1), NodeKind::Image(Box::new(image)))], ..Default::default() }; clip.images.insert(img.key, img.blob); let count = s.load_clipboard(clip, p)?; Ok(json!({ "count": count, "width": w as f64 * sx, "height": h as f64 * sy, "pixelWidth": w, "pixelHeight": h })) diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index baf5b62d..032ee02f 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -107,10 +107,10 @@ fn attrs_refs(attrs: &Map, f: Visit) { fn node_refs(n: &Node, f: Visit) { n.walk(&mut |m| { appearance_refs(&m.appearance, f); - if let Some(id) = m.graphic_style { + if let Some(id) = m.extras().graphic_style { f(Ref::Style(id)); } - if let Some(mask) = &m.mask { + if let Some(mask) = &m.extras().mask { node_refs(&mask.art, f); } match &m.kind { @@ -427,8 +427,8 @@ impl Relink { /// Relink a node tree (the mirror of [`node_refs`]). fn node(&self, n: &mut Node) { self.appearance(&mut n.appearance); - n.graphic_style = n.graphic_style.and_then(|id| self.styles.get(&id).copied()); - if let Some(m) = &mut n.mask { + n.extras_mut().graphic_style = n.extras().graphic_style.and_then(|id| self.styles.get(&id).copied()); + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { self.node(Arc::make_mut(&mut m.art)); } match &mut n.kind { diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 84370f57..8f07dd2a 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -157,7 +157,7 @@ fn draw_mode(s: &mut Session, p: &Value) -> Result { } fn path_kind(path: PathData, live: Option) -> NodeKind { - NodeKind::Path { path, rule: FillRule::NonZero, live, clipping: false, guide: false } + NodeKind::Path { path, rule: FillRule::NonZero, live: live.map(Box::new), clipping: false, guide: false } } fn rect_of(p: &Value, cmd: &str) -> Result { diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index e4aeea52..dda0e7c6 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -439,7 +439,7 @@ struct Targets { /// What `n` is when Liquify can't distort it (and doesn't look inside it). fn not_liquified(n: &Node) -> Option<&'static str> { Some(match &n.kind { - NodeKind::Group { .. } if n.graph.is_some() => "graphs", + NodeKind::Group { .. } if n.extras().graph.is_some() => "graphs", NodeKind::Text(_) => "type", NodeKind::SymbolInstance { .. } => "symbols", NodeKind::Image(_) => "images", @@ -1252,7 +1252,7 @@ fn edit_text(s: &mut Session, p: &Value) -> Result { let (Some(flat), Some(drawn)) = (t.bounds(), t.bounds().and_then(|b| h.map_rect_bbox(b))) else { return Ok(()) }; let shift = vectorcraft_geom::Affine::translate(drawn.center() - flat.center()); t.transform(shift); - if let Some(rec) = n.perspective.as_deref_mut() { + if let Some(rec) = n.extras_if_any().and_then(|e| e.perspective.as_deref_mut()) { rec.projection = Some(h.then_after(&Homography::from_affine(shift.inverse())).to_array()); rec.editing = true; } @@ -1271,8 +1271,8 @@ pub(crate) fn finish_edit_text(st: &mut crate::DocState, id: NodeId) { let clear = |doc: &mut Arc| { // Undo steps keep no lookup cache: look without building one, and drop the one an edit // builds (the document now keeps its own). - if doc.node_uncached(id).and_then(|n| n.perspective.as_deref()).is_some_and(|p| p.editing) - && let Some(p) = Arc::make_mut(doc).node_mut(id).and_then(|n| n.perspective.as_deref_mut()) + if doc.node_uncached(id).and_then(|n| n.extras().perspective.as_deref()).is_some_and(|p| p.editing) + && let Some(p) = Arc::make_mut(doc).node_mut(id).and_then(|n| n.extras_if_any().and_then(|e| e.perspective.as_deref_mut())) { p.editing = false; } diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index 5af0106f..d0f7cf3d 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -290,7 +290,7 @@ fn info(s: &mut Session, p: &Value) -> Result { spot.insert(name.clone()); } } - if n.mask.is_some() { + if n.extras().mask.is_some() { *counts.entry("opacityMasks").or_default() += 1; } if !n.appearance.effects.is_empty() { diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 7a7f8f4e..bc50e5e7 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -214,11 +214,12 @@ fn undo(s: &mut Session, _: &Value) -> Result { let st = s.doc_mut()?; let e = st.history.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?; let label = e.label.clone(); + let costs = e.costs(); let editing = st.doc.mask_edit.map(|m| m.layer); let doc = std::mem::replace(&mut st.doc, e.doc); let selection = std::mem::replace(&mut st.selection, Arc::unwrap_or_clone(e.selection)); let selection = shared_selection(selection, st.history.redo.last()); - st.history.redo.push(crate::HistoryEntry::kept(e.label, doc, selection, &st.doc)); + st.history.redo.push(crate::HistoryEntry::turned(e.label, doc, selection, &st.doc, costs)); st.history.trim(); st.revision += 1; st.selection.prune(&st.doc); @@ -230,11 +231,12 @@ fn redo(s: &mut Session, _: &Value) -> Result { let st = s.doc_mut()?; let e = st.history.redo.pop().ok_or_else(|| EngineError::Other("nothing to redo".into()))?; let label = e.label.clone(); + let costs = e.costs(); let editing = st.doc.mask_edit.map(|m| m.layer); let doc = std::mem::replace(&mut st.doc, e.doc); let selection = std::mem::replace(&mut st.selection, Arc::unwrap_or_clone(e.selection)); let selection = shared_selection(selection, st.history.undo.last()); - st.history.undo.push(crate::HistoryEntry::kept(e.label, doc, selection, &st.doc)); + st.history.undo.push(crate::HistoryEntry::turned(e.label, doc, selection, &st.doc, costs)); st.history.trim(); st.revision += 1; st.selection.prune(&st.doc); diff --git a/crates/engine/src/cmd/fileio/affinity.rs b/crates/engine/src/cmd/fileio/affinity.rs index 70dff78f..f9c5f85d 100644 --- a/crates/engine/src/cmd/fileio/affinity.rs +++ b/crates/engine/src/cmd/fileio/affinity.rs @@ -289,7 +289,7 @@ impl Builder { // shows outside it, as an opacity mask that clips. if let Some(kind) = self.image(img, to_doc) { let art = Node::new(self.doc.alloc_id(), kind); - out.mask = Some(Box::new(OpacityMask::new(art, true))); + out.extras_mut().mask = Some(Box::new(OpacityMask::new(art, true))); } } if let Some(mask) = &n.mask { @@ -514,7 +514,7 @@ impl Builder { let sy = img.height as f64 / f64::from(r.height.max(1)); let xf = to_doc * affine(img.transform) * Affine::scale_non_uniform(sx, sy); self.doc.images.entry(r.key.clone()).or_insert(r.blob); - Some(NodeKind::Image(ImageObject { key: r.key, width: r.width, height: r.height, xf, link: None, placement: Default::default() })) + Some(NodeKind::Image(Box::new(ImageObject { key: r.key, width: r.width, height: r.height, xf, link: None, placement: Default::default() }))) } } diff --git a/crates/engine/src/cmd/fileio/imagemap.rs b/crates/engine/src/cmd/fileio/imagemap.rs index 6974fe8d..ecf5a8b3 100644 --- a/crates/engine/src/cmd/fileio/imagemap.rs +++ b/crates/engine/src/cmd/fileio/imagemap.rs @@ -82,7 +82,7 @@ fn collect(n: &Node, region: Rect, scale: f64, out: &mut Vec) { if !n.visible || matches!(n.kind, NodeKind::Layer { template: true, .. }) { return; } - let shape = n.attrs.as_deref().filter(|a| !a.url.is_empty()).map(|a| a.image_map).unwrap_or_default(); + let shape = n.extras().attrs.as_deref().filter(|a| !a.url.is_empty()).map(|a| a.image_map).unwrap_or_default(); let px = |x: f64, y: f64| [((x - region.x0) * scale).round() as i64, ((y - region.y0) * scale).round() as i64]; if let (Some(href), Some(b)) = (n.url(), n.visual_bounds().filter(|b| b.overlaps(region))) && shape != ImageMap::None diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index e492e18c..534d86c8 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -430,7 +430,8 @@ pub(super) fn raster_doc(name: &str, bytes: &[u8]) -> Result { let layer = d.layers[0].id; let id = d.alloc_id(); let xf = Affine::scale_non_uniform(sx, sy); - let mut n = Node::new(id, NodeKind::Image(ImageObject { key: key.clone(), width, height, xf, link: None, placement: Default::default() })); + let mut n = + Node::new(id, NodeKind::Image(Box::new(ImageObject { key: key.clone(), width, height, xf, link: None, placement: Default::default() }))); n.name = Some(name.to_string()); d.images.insert(key, blob); d.insert(Some(layer), 0, n).map_err(err)?; diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index a6f2128b..d4f6554e 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -43,7 +43,7 @@ fn image_bytes(w: u32, h: u32, format: image::ImageFormat) -> Vec { fn image_of(s: &Session) -> vectorcraft_doc::ImageObject { let doc = &s.doc().unwrap().doc; match &doc.layers[0].children().unwrap()[0].kind { - NodeKind::Image(im) => im.clone(), + NodeKind::Image(im) => (**im).clone(), other => panic!("expected an image, got {other:?}"), } } diff --git a/crates/engine/src/cmd/fileio/tests_svgimport.rs b/crates/engine/src/cmd/fileio/tests_svgimport.rs index 46fc2f88..d898d389 100644 --- a/crates/engine/src/cmd/fileio/tests_svgimport.rs +++ b/crates/engine/src/cmd/fileio/tests_svgimport.rs @@ -53,7 +53,7 @@ fn images(s: &Session) -> Vec { let mut out = vec![]; s.documents()[s.active_index().unwrap()].doc.walk(|n| { if let NodeKind::Image(im) = &n.kind { - out.push(im.clone()); + out.push((**im).clone()); } }); out diff --git a/crates/engine/src/cmd/flatten.rs b/crates/engine/src/cmd/flatten.rs index 8d11365b..2378b99c 100644 --- a/crates/engine/src/cmd/flatten.rs +++ b/crates/engine/src/cmd/flatten.rs @@ -1093,7 +1093,7 @@ fn raster_node(d: &mut Document, r: Raster) -> Node { d.images.insert(key.clone(), ImageBlob::new("image/png", r.png)); let image = Node::new( d.alloc_id(), - NodeKind::Image(ImageObject { key, width: r.width, height: r.height, xf: r.xf, link: None, placement: Default::default() }), + NodeKind::Image(Box::new(ImageObject { key, width: r.width, height: r.height, xf: r.xf, link: None, placement: Default::default() })), ); let Some(path) = r.clip else { return image }; super::rasterfx::clip_group(d, path, FillRule::NonZero, image) @@ -1224,7 +1224,7 @@ fn carry(from: &Node, mut to: Node) -> Node { to.blend = from.blend; to.isolate = from.isolate; to.knockout_shape = from.knockout_shape; - to.mask = from.mask.clone(); + to.extras_mut().mask = from.extras().mask.clone(); to.appearance.effects = from.appearance.effects.clone(); to } @@ -1258,7 +1258,7 @@ fn has_effects(n: &Node) -> bool { } fn masked(n: &Node) -> bool { - n.mask.as_ref().is_some_and(|m| !m.disabled) + n.extras().mask.as_ref().is_some_and(|m| !m.disabled) } /// Where `n` can paint: its visual bounds grown by its effects (and a margin for antialiasing). diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index 796814ac..bb114b8a 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -389,7 +389,7 @@ impl Gen<'_> { fn series(&mut self, name: &str, index: usize, children: Vec>) -> Arc { let mut g = Node::group(self.d.alloc_id(), children); g.name = Some(name.into()); - g.series_index = u32::try_from(index).ok(); + g.extras_mut().series_index = u32::try_from(index).ok(); Arc::new(g) } } @@ -1146,7 +1146,7 @@ fn children_bounds(children: &[Arc]) -> Option { /// generation, which is recorded in the spec's `placed` bounds). fn regenerate(d: &mut Document, id: NodeId, mut spec: GraphSpec) -> Result<()> { let n = d.node(id).ok_or(EngineError::NoNode(id))?; - if let (Some(placed), Some(now)) = (n.graph.as_ref().and_then(|g| g.placed), n.children().and_then(|c| children_bounds(c))) + if let (Some(placed), Some(now)) = (n.extras().graph.as_ref().and_then(|g| g.placed), n.children().and_then(|c| children_bounds(c))) && placed.width() > 1e-9 && placed.height() > 1e-9 && (placed.x0 - now.x0).abs() + (placed.y0 - now.y0).abs() + (placed.x1 - now.x1).abs() + (placed.y1 - now.y1).abs() > 1e-6 @@ -1162,7 +1162,7 @@ fn regenerate(d: &mut Document, id: NodeId, mut spec: GraphSpec) -> Result<()> { if let Some(ch) = n.children_mut() { *ch = children; } - n.graph = Some(Box::new(spec)); + n.extras_mut().graph = Some(Box::new(spec)); Ok(()) } @@ -1199,7 +1199,7 @@ fn target(s: &Session, p: &Value, cmd: &str) -> Result { for id in start { let mut cur = Some(id); while let Some(c) = cur { - if st.doc.node(c).is_some_and(|n| n.graph.is_some()) { + if st.doc.node(c).is_some_and(|n| n.extras().graph.is_some()) { return Ok(c); } cur = st.doc.parent_of(c); @@ -1209,7 +1209,7 @@ fn target(s: &Session, p: &Value, cmd: &str) -> Result { } fn spec_of(s: &Session, id: NodeId) -> Result { - s.doc()?.doc.node(id).and_then(|n| n.graph.as_deref().cloned()).ok_or(EngineError::NoNode(id)) + s.doc()?.doc.node(id).and_then(|n| n.extras().graph.as_deref().cloned()).ok_or(EngineError::NoNode(id)) } /// Where a node inside a graph's generated series art belongs: the graph, the series group and @@ -1227,10 +1227,10 @@ struct SeriesRef { fn series_members(doc: &Document) -> HashMap { let mut out = HashMap::new(); doc.walk(|n| { - let Some(spec) = n.graph.as_deref() else { return }; + let Some(spec) = n.extras().graph.as_deref() else { return }; let count = spec.rows.iter().map(Vec::len).max().unwrap_or(0).clamp(1, vectorcraft_doc::MAX_GRAPH_SERIES); for group in n.children().into_iter().flatten() { - let Some(index) = group.series_index.and_then(|i| usize::try_from(i).ok()).filter(|i| *i < count) else { continue }; + let Some(index) = group.extras().series_index.and_then(|i| usize::try_from(i).ok()).filter(|i| *i < count) else { continue }; let r = SeriesRef { graph: n.id, group: group.id, index }; group.walk(&mut |m| { out.insert(m.id, r); @@ -1288,7 +1288,7 @@ pub(crate) fn capture_series_paints(doc: &mut Document, ids: &[NodeId], fill: bo .collect(); let mut changed = false; for (graph, index, paint, width) in captures { - let Some(spec) = doc.node_mut(graph).and_then(|n| n.graph.as_deref_mut()) else { continue }; + let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; let was = spec.series_paints.clone(); // `index` is below the graph's series count (`series_members`). if spec.series_paints.len() <= index { @@ -1342,9 +1342,9 @@ fn switch_xy(g: &mut GraphSpec) { fn graph_labels(doc: &Document) -> HashMap { let mut out = HashMap::new(); doc.walk(|n| { - let Some(g) = n.graph.as_deref() else { return }; + let Some(g) = n.extras().graph.as_deref() else { return }; for c in n.children().into_iter().flatten() { - match (&c.kind, c.name.as_deref(), c.series_index) { + match (&c.kind, c.name.as_deref(), c.extras().series_index) { (NodeKind::Text(_), _, _) if g.kind == GraphKind::Pie => { out.insert(c.id, (n.id, Role::Category)); } @@ -1389,7 +1389,7 @@ pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], change: & kinds.dedup(); let mut changed = false; for (graph, role) in kinds { - let Some(spec) = doc.node_mut(graph).and_then(|n| n.graph.as_deref_mut()) else { continue }; + let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; let mut style = label_style(&spec.label_styles, role); change(&mut style); let slot = match role { @@ -1470,7 +1470,7 @@ fn reselect_series(d: &Document, sel: &mut vectorcraft_doc::Selection, id: NodeI .and_then(Node::children) .into_iter() .flatten() - .filter(|c| c.series_index.is_some_and(|i| indexes.contains(&i))) + .filter(|c| c.extras().series_index.is_some_and(|i| indexes.contains(&i))) .map(|c| c.id) .collect(); sel.set(groups); @@ -1485,12 +1485,12 @@ fn design_names(d: &Document) -> Vec { fn refresh_graphs_using(d: &mut Document, name: &str) -> Result<()> { let mut ids = vec![]; d.walk(|n| { - if n.graph.as_deref().is_some_and(|g| g.series_markers.iter().flatten().any(|m| m == name)) { + if n.extras().graph.as_deref().is_some_and(|g| g.series_markers.iter().flatten().any(|m| m == name)) { ids.push(n.id); } }); for id in ids { - if let Some(spec) = d.node(id).and_then(|n| n.graph.as_deref().cloned()) { + if let Some(spec) = d.node(id).and_then(|n| n.extras().graph.as_deref().cloned()) { regenerate(d, id, spec)?; } } @@ -1501,7 +1501,7 @@ fn refresh_graphs_using(d: &mut Document, name: &str) -> Result<()> { fn unused_designs(d: &Document) -> Vec { let mut used = std::collections::HashSet::new(); d.walk(|n| { - if let Some(g) = n.graph.as_deref() { + if let Some(g) = n.extras().graph.as_deref() { used.extend(g.series_markers.iter().flatten().cloned()); } }); @@ -1576,12 +1576,12 @@ fn design(s: &mut Session, p: &Value) -> Result { } let mut graphs = vec![]; d.walk(|n| { - if n.graph.as_deref().is_some_and(|g| g.series_markers.iter().flatten().any(|m| *m == old)) { + if n.extras().graph.as_deref().is_some_and(|g| g.series_markers.iter().flatten().any(|m| *m == old)) { graphs.push(n.id); } }); for id in graphs { - if let Some(g) = d.node_mut(id).and_then(|n| n.graph.as_deref_mut()) { + if let Some(g) = d.node_mut(id).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) { for m in g.series_markers.iter_mut().flatten().filter(|m| **m == old) { *m = new.clone(); } @@ -1921,7 +1921,7 @@ mod tests { /// The generated group stored for series `index`, not a sibling that happens to share its name. fn series(n: &vectorcraft_doc::Node, index: u32) -> &vectorcraft_doc::Node { - n.children().unwrap().iter().find(|c| c.series_index == Some(index)).unwrap_or_else(|| panic!("no series {index}")) + n.children().unwrap().iter().find(|c| c.extras().series_index == Some(index)).unwrap_or_else(|| panic!("no series {index}")) } #[test] @@ -1931,7 +1931,7 @@ mod tests { for ty in ["column", "stackedColumn", "bar", "stackedBar", "line", "area", "scatter", "pie", "radar"] { let id = graph(&mut s, ty); let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - assert!(n.graph.is_some(), "{ty}"); + assert!(n.extras().graph.is_some(), "{ty}"); if ty != "scatter" { assert!(!group(&n, "2024").children().unwrap().is_empty(), "{ty}"); assert!(!group(&n, "2025").children().unwrap().is_empty(), "{ty}"); @@ -1966,7 +1966,7 @@ mod tests { assert!((s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0 - x0).abs() < 1.0); // Type change keeps the data; undo restores the columns. s.execute("graph.setType", &json!({"type": "pie"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.node(id).unwrap().graph.as_ref().unwrap().kind, vectorcraft_doc::GraphKind::Pie); + assert_eq!(s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().kind, vectorcraft_doc::GraphKind::Pie); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["type"], json!("column")); // Selecting a bar inside still targets the graph. @@ -2068,7 +2068,7 @@ mod tests { let id = graph(&mut s, ty.id()); let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; s.execute("paint.setFill", &json!({"ids": [series_id.0], "color": "#ff0000"})).unwrap(); - let spec = s.doc().unwrap().doc.node(id).unwrap().graph.as_deref().unwrap().clone(); + let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone(); let restored: GraphSpec = serde_json::from_value(serde_json::to_value(&spec).unwrap()).unwrap(); assert_eq!(spec, restored); let red = Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)); @@ -2077,7 +2077,7 @@ mod tests { assert!(selected_group.children().unwrap().iter().all(|n| n.appearance.fill_paint() == red), "{ty:?}"); s.execute("graph.setData", &json!({"rows": [[2, 3], [4, 5]]})).unwrap(); s.execute("graph.setType", &json!({"type": "line"})).unwrap(); - let spec = s.doc().unwrap().doc.node(id).unwrap().graph.as_deref().unwrap(); + let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap(); assert_eq!(spec.series_paints.first().and_then(|p| p.fill.clone()), Some(red.clone()), "{ty:?}"); let series = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap(); assert!(series.iter().any(|n| n.appearance.fill_paint() == red), "{ty:?}"); @@ -2092,18 +2092,28 @@ mod tests { let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; s.execute("paint.setFill", &json!({"ids": [series_id.0], "color": "#e8573f"})).unwrap(); assert_eq!( - s.doc().unwrap().doc.node(id).unwrap().graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color).unwrap().to_hex(), + s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints[0] + .fill + .as_ref() + .and_then(Paint::color) + .unwrap() + .to_hex(), "#e8573f" ); s.execute("edit.undo", &json!({})).unwrap(); - assert!(s.doc().unwrap().doc.node(id).unwrap().graph.as_ref().unwrap().series_paints.is_empty()); + assert!(s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints.is_empty()); assert_eq!( group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0].appearance.fill_paint().color(), Some(super::default_series_color(0)) ); s.execute("edit.redo", &json!({})).unwrap(); assert_eq!( - s.doc().unwrap().doc.node(id).unwrap().graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color).unwrap().to_hex(), + s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints[0] + .fill + .as_ref() + .and_then(Paint::color) + .unwrap() + .to_hex(), "#e8573f" ); } @@ -2123,7 +2133,7 @@ mod tests { s.execute("paint.setFill", &fill).unwrap(); // The graph keeps nothing, as after a command that paints only the art. s.edit("Forget", |d, _| { - let g = d.node_mut(id).unwrap().graph.as_deref_mut().unwrap(); + let g = d.node_mut(id).unwrap().extras_if_any().unwrap().graph.as_deref_mut().unwrap(); g.series_paints.clear(); g.label_styles = vectorcraft_doc::LabelStyles::default(); Ok(()) @@ -2152,7 +2162,7 @@ mod tests { s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); let node = s.doc().unwrap().doc.node(id).unwrap(); let green = vectorcraft_color::Color::rgb(18.0 / 255.0, 171.0 / 255.0, 52.0 / 255.0); - assert_eq!(node.graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color), Some(green)); + assert_eq!(node.extras().graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color), Some(green)); assert!(group(node, "2024").children().unwrap().iter().all(|n| n.appearance.fill_paint().color() == Some(green))); } @@ -2165,14 +2175,14 @@ mod tests { let id = graph(&mut s, "column"); let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; s.edit("Junk", |d, _| { - d.node_mut(series_id).unwrap().series_index = Some(u32::MAX); + d.node_mut(series_id).unwrap().extras_mut().series_index = Some(u32::MAX); Ok(()) }) .unwrap(); let bar = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0].id; s.execute("paint.setFill", &json!({"ids": [bar.0, series_id.0], "color": "#12ab34"})).unwrap(); let node = s.doc().unwrap().doc.node(id).unwrap(); - assert!(node.graph.as_ref().unwrap().series_paints.is_empty()); + assert!(node.extras().graph.as_ref().unwrap().series_paints.is_empty()); assert_eq!(group(node, "2024").children().unwrap()[0].appearance.fill_paint().color().map(|c| c.to_hex()), Some("#12ab34".into())); } @@ -2185,10 +2195,10 @@ mod tests { s.execute("paint.setStroke", &json!({"ids": [series_id.0], "color": "#3751c8"})).unwrap(); let blue = vectorcraft_color::Color::rgb(55.0 / 255.0, 81.0 / 255.0, 200.0 / 255.0); assert_eq!( - s.doc().unwrap().doc.node(id).unwrap().graph.as_ref().unwrap().series_paints[0].stroke.as_ref().and_then(Paint::color), + s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints[0].stroke.as_ref().and_then(Paint::color), Some(blue) ); - assert!(s.doc().unwrap().doc.node(id).unwrap().graph.as_ref().unwrap().series_paints[0].fill.is_none()); + assert!(s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints[0].fill.is_none()); s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); assert!( group(s.doc().unwrap().doc.node(id).unwrap(), "2024") @@ -2212,7 +2222,7 @@ mod tests { let path = dir.join("palette.vectorcraft"); s.execute("document.save", &json!({"path": path})).unwrap(); s.execute("document.open", &json!({"path": path})).unwrap(); - let paints = &s.doc().unwrap().doc.node(id).unwrap().graph.as_ref().unwrap().series_paints; + let paints = &s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints; assert_eq!(paints.iter().map(|p| p.fill.as_ref().and_then(Paint::color).unwrap().to_hex()).collect::>(), vec!["#ff0000", "#008080"]); s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); s.execute("graph.setData", &json!({"id": id.0, "rows": [[3, 4, 5]]})).unwrap(); @@ -2241,7 +2251,7 @@ mod tests { let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; s.execute("paint.setStroke", &json!({"ids": [series_id.0], "color": "#0000ff"})).unwrap(); s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); - let spec = s.doc().unwrap().doc.node(id).unwrap().graph.as_deref().unwrap().clone(); + let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone(); let blue = Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0)); assert_eq!(spec.series_paints[0].stroke, Some(blue.clone())); assert!(spec.series_paints[0].fill.is_none()); @@ -2253,7 +2263,7 @@ mod tests { let marker = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[1].id; s.execute("paint.setFill", &json!({"ids": [marker.0], "color": "#ff0000"})).unwrap(); s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); - let spec = s.doc().unwrap().doc.node(id).unwrap().graph.as_deref().unwrap().clone(); + let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone(); assert!(spec.series_paints[0].stroke.is_none()); let series = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap(); assert_eq!(series[0].appearance.stroke_paint().color(), Some(super::default_series_color(0))); @@ -2272,7 +2282,7 @@ mod tests { ) .unwrap(); s.execute("graph.setData", &json!({"rows": [[2, 3], [4, 5]]})).unwrap(); - let spec = s.doc().unwrap().doc.node(id).unwrap().graph.as_deref().unwrap().clone(); + let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone(); assert!(matches!(spec.series_paints[0].fill, Some(Paint::Gradient(_)))); let bar = &group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0]; assert!(matches!(bar.appearance.fill_paint(), Paint::Gradient(_))); @@ -2288,15 +2298,15 @@ mod tests { let n = s.doc().unwrap().doc.node(id).unwrap(); let legend = series(n, 0); assert_eq!(legend.name.as_deref(), Some("Legend")); - assert!(n.children().unwrap().iter().any(|c| c.name.as_deref() == Some("Legend") && c.series_index.is_none())); - assert!(n.children().unwrap().iter().any(|c| c.name.as_deref() == Some("Axes") && c.series_index.is_none())); + assert!(n.children().unwrap().iter().any(|c| c.name.as_deref() == Some("Legend") && c.extras().series_index.is_none())); + assert!(n.children().unwrap().iter().any(|c| c.name.as_deref() == Some("Axes") && c.extras().series_index.is_none())); legend.id }; s.execute("paint.setFill", &json!({"ids": [legend_id.0], "color": "#ff0000"})).unwrap(); s.execute("graph.setData", &json!({"csv": csv})).unwrap(); let n = s.doc().unwrap().doc.node(id).unwrap(); - assert_eq!(n.graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color).unwrap().to_hex(), "#ff0000"); - assert!(n.graph.as_ref().unwrap().series_paints.get(1).is_none_or(|p| p.fill.is_none())); + assert_eq!(n.extras().graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color).unwrap().to_hex(), "#ff0000"); + assert!(n.extras().graph.as_ref().unwrap().series_paints.get(1).is_none_or(|p| p.fill.is_none())); assert_eq!(series(n, 0).children().unwrap()[0].appearance.fill_paint().color().unwrap().to_hex(), "#ff0000"); assert_eq!(series(n, 1).name.as_deref(), Some("Axes")); assert_ne!(series(n, 1).children().unwrap()[0].appearance.fill_paint().color().unwrap().to_hex(), "#ff0000"); @@ -2311,8 +2321,8 @@ mod tests { s.execute("paint.setFill", &json!({"ids": [keep.0], "color": "#00ff00"})).unwrap(); s.execute("graph.setData", &json!({"id": id.0, "rows": [[8, 0], [1, 0]]})).unwrap(); let n = s.doc().unwrap().doc.node(id).unwrap(); - assert_eq!(group(n, "Keep").series_index, Some(0)); - assert_eq!(n.graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color).unwrap().to_hex(), "#00ff00"); + assert_eq!(group(n, "Keep").extras().series_index, Some(0)); + assert_eq!(n.extras().graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color).unwrap().to_hex(), "#00ff00"); assert!(n.children().unwrap().iter().all(|c| c.name.as_deref() != Some("Drop"))); } @@ -2395,7 +2405,7 @@ mod tests { let r = s.execute("graph.create", &json!({"type": "column", "x": 0, "y": 0, "width": 300, "height": 200, "csv": ",a\nQ1,1\nQ2\nQ3,3"})).unwrap(); let id = NodeId(r["id"].as_u64().unwrap()); - let spec = s.doc().unwrap().doc.node(id).unwrap().graph.clone().unwrap(); + let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.clone().unwrap(); assert_eq!(spec.categories, vec!["Q1", "Q2", "Q3"]); assert_eq!(spec.cells(), vec![vec![Some(1.0)], vec![None], vec![Some(3.0)]]); } @@ -2423,7 +2433,7 @@ mod tests { // A data window round trip keeps the blanks. let csv = s.execute("graph.setData", &json!({})).unwrap()["csv"].as_str().unwrap().to_string(); assert_eq!(csv.lines().nth(2), Some(",2"), "{csv}"); - let spec = s.doc().unwrap().doc.node(id).unwrap().graph.clone().unwrap(); + let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.clone().unwrap(); assert_eq!(spec.cells()[1], vec![None, Some(2.0)]); // In the file a blank is a 0 placeholder plus its place in `blanks`, so a version without blanks reads // numbers (as zeros); a graph without blanks writes no `blanks` at all. @@ -2434,7 +2444,7 @@ mod tests { } fn spec(s: &Session, id: NodeId) -> GraphSpec { - s.doc().unwrap().doc.node(id).unwrap().graph.as_deref().unwrap().clone() + s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone() } /// Bounds of a series' marks, without its legend swatch (the last child). @@ -2462,12 +2472,12 @@ mod tests { assert!((line[1].center().x - cols[0].center().x).abs() < 1e-6); // The line series is drawn after (in front of) the column series. let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let order: Vec = n.children().unwrap().iter().filter_map(|c| c.series_index).collect(); + let order: Vec = n.children().unwrap().iter().filter_map(|c| c.extras().series_index).collect(); assert_eq!(order, [0, 1]); s.execute("graph.setType", &json!({"seriesIndexes": [0], "type": "line"})).unwrap(); s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "column"})).unwrap(); let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let order: Vec = n.children().unwrap().iter().filter_map(|c| c.series_index).collect(); + let order: Vec = n.children().unwrap().iter().filter_map(|c| c.extras().series_index).collect(); assert_eq!(order, [1, 0]); // Back to the graph's own type: nothing is stored for that series. assert_eq!(spec(&s, id).series_kinds, [Some(GraphKind::Line)]); @@ -2562,7 +2572,7 @@ mod tests { assert!((area.x0 - cols[0].center().x).abs() < 1e-6 && (area.x1 - cols[2].center().x).abs() < 1e-6, "{area:?}"); // The area is drawn behind the columns. let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let order: Vec = n.children().unwrap().iter().filter_map(|c| c.series_index).collect(); + let order: Vec = n.children().unwrap().iter().filter_map(|c| c.extras().series_index).collect(); assert_eq!(order, [0, 1]); // An area graph alone still spans the plot. s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "area"})).unwrap(); @@ -3173,7 +3183,7 @@ mod tests { let dot = vectorcraft_doc::Node::group(d.alloc_id(), vec![]); d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Dot".into(), art: std::sync::Arc::new(dot) }); let art = super::generate(&mut d, &g); - let s0 = art.iter().find(|n| n.series_index == Some(0)).unwrap(); + let s0 = art.iter().find(|n| n.extras().series_index == Some(0)).unwrap(); assert!(s0.children().unwrap().iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); } @@ -3188,13 +3198,13 @@ mod tests { s.execute("graph.design", &json!({"new": "Chart"})).unwrap(); let art = s.doc().unwrap().doc.graph_designs[1].art.clone(); let mut graphs = 0; - art.walk(&mut |n| graphs += usize::from(n.graph.is_some() || n.series_index.is_some())); + art.walk(&mut |n| graphs += usize::from(n.extras().graph.is_some() || n.extras().series_index.is_some())); assert_eq!(graphs, 0, "no graph inside a design"); // The graph drawn with itself as a marker, then the design it used deleted: still one graph to edit. s.execute("graph.marker", &json!({"id": id.0, "seriesIndexes": [1], "design": "Chart"})).unwrap(); s.execute("graph.design", &json!({"delete": "Pill"})).unwrap(); let mut found = 0; - s.doc().unwrap().doc.walk(|n| found += usize::from(n.graph.is_some())); + s.doc().unwrap().doc.walk(|n| found += usize::from(n.extras().graph.is_some())); assert_eq!(found, 1); // A deleted design reads as none in the query, so the dialog opens on the default marker. s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); @@ -3226,7 +3236,7 @@ mod tests { g.series_markers = vec![Some("Big".into())]; let mut d = (*s.doc().unwrap().doc).clone(); let art = super::generate(&mut d, &g); - let s0 = art.iter().find(|n| n.series_index == Some(0)).unwrap(); + let s0 = art.iter().find(|n| n.extras().series_index == Some(0)).unwrap(); assert!(s0.children().unwrap().iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); // Too many objects for one design. let ids: Vec = (0..2001) @@ -3241,7 +3251,7 @@ mod tests { let mut d = vectorcraft_doc::Document::new(100.0, 100.0); let art = |d: &mut vectorcraft_doc::Document| std::sync::Arc::new(vectorcraft_doc::Node::group(d.alloc_id(), vec![])); let mut graphy = vectorcraft_doc::Node::group(d.alloc_id(), vec![]); - graphy.graph = Some(Box::new(GraphSpec::default())); + graphy.extras_mut().graph = Some(Box::new(GraphSpec::default())); for name in [" A\u{7} ", "A", "", " ", &"x".repeat(100)] { let a = art(&mut d); d.graph_designs.push(vectorcraft_doc::GraphDesign { name: name.into(), art: a }); @@ -3250,7 +3260,7 @@ mod tests { d.tidy_graph_designs(); let names: Vec<_> = d.graph_designs.iter().map(|x| x.name.clone()).collect(); assert_eq!(names, ["A".to_string(), "x".repeat(64), "G".to_string()]); - assert!(d.graph_designs[2].art.graph.is_none()); + assert!(d.graph_designs[2].art.extras().graph.is_none()); } #[test] @@ -3511,7 +3521,7 @@ mod tests { assert!(min > 1.0, "the ring keeps clear of the centre: {near:?}"); // The names are drawn over the pies: after every series group. let kids = n.children().unwrap(); - let last_series = kids.iter().rposition(|c| c.series_index.is_some()).unwrap(); + let last_series = kids.iter().rposition(|c| c.extras().series_index.is_some()).unwrap(); let names: Vec = kids.iter().enumerate().filter(|(_, c)| matches!(c.kind, NodeKind::Text(_))).map(|(i, _)| i).collect(); assert_eq!(names.len(), 2, "of two equal pies, one has no ring and no name"); assert!(names.iter().all(|i| *i > last_series), "{names:?} after {last_series}"); @@ -3576,7 +3586,7 @@ mod tests { let n = s.doc().unwrap().doc.node(id).unwrap().clone(); let kids = n.children().unwrap(); let at = kids.iter().position(|c| c.name.as_deref() == Some("Drop Shadow")).unwrap(); - assert!(kids.iter().position(|c| c.series_index.is_some()).unwrap() > at, "behind the series"); + assert!(kids.iter().position(|c| c.extras().series_index.is_some()).unwrap() > at, "behind the series"); let shadow = &kids[at]; assert!((shadow.opacity - 0.35).abs() < 1e-6); let copies: Vec<_> = shadow.children().unwrap().iter().filter_map(|c| c.geometric_bounds()).collect(); @@ -3639,8 +3649,12 @@ mod tests { s.execute("graph.setType", &json!({"seriesIndexes": [0], "type": "area"})).unwrap(); s.execute("graph.setType", &json!({"seriesIndexes": [2], "type": "line", "dropShadow": true})).unwrap(); let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let order: Vec = - n.children().unwrap().iter().map(|c| c.series_index.map_or_else(|| c.name.clone().unwrap_or_default(), |i| format!("s{i}"))).collect(); + let order: Vec = n + .children() + .unwrap() + .iter() + .map(|c| c.extras().series_index.map_or_else(|| c.name.clone().unwrap_or_default(), |i| format!("s{i}"))) + .collect(); // Areas cast none; the columns' shadows sit between the area and the columns, the line's between the columns // and the line. let at = |k: &str| order.iter().position(|o| o == k).unwrap(); @@ -3702,8 +3716,9 @@ mod tests { assert!(a[0].x1 > b[0].x0, "the columns of a cluster overlap: {:?} {:?}", a[0], b[0]); assert!(b[0].x1 > a[1].x0, "and so do the clusters: {:?} {:?}", b[0], a[1]); assert_eq!(s.execute("graph.setType", &json!({"clusterWidth": 5000})).map(|_| spec(&s, id).cluster_width).unwrap(), 1000.0); - let order = - |s: &Session| -> Vec { s.doc().unwrap().doc.node(id).unwrap().children().unwrap().iter().filter_map(|c| c.series_index).collect() }; + let order = |s: &Session| -> Vec { + s.doc().unwrap().doc.node(id).unwrap().children().unwrap().iter().filter_map(|c| c.extras().series_index).collect() + }; assert_eq!(order(&s), [0, 1]); s.execute("graph.setType", &json!({"firstColumnInFront": true})).unwrap(); assert_eq!(order(&s), [1, 0], "the first series drawn last, on top"); diff --git a/crates/engine/src/cmd/live.rs b/crates/engine/src/cmd/live.rs index 70797f5e..2aadffa7 100644 --- a/crates/engine/src/cmd/live.rs +++ b/crates/engine/src/cmd/live.rs @@ -535,7 +535,7 @@ fn blend_make(s: &mut Session, p: &Value) -> Result { extend_spine(&mut spec, old, old_spec, &children); } n.id = id; - n.kind = NodeKind::Blend { children, spec }; + n.kind = NodeKind::Blend { children, spec: Box::new(spec) }; n }) })?; @@ -563,7 +563,7 @@ fn blend_release(s: &mut Session, _: &Value) -> Result { || n.opacity < 1.0 || n.blend != vectorcraft_color::BlendMode::Normal || n.isolate - || n.mask.is_some() + || n.extras().mask.is_some() || !n.appearance.items.is_empty() || !n.appearance.effects.is_empty() { @@ -714,7 +714,7 @@ fn edit_blends(s: &mut Session, label: &str, f: impl Fn(&mut Vec>, &mu fn selected_spec(s: &Session) -> Option<(NodeId, BlendSpec, Vec)> { let b = *selected_of(s, is_blend).first()?; match &s.doc().ok()?.doc.node(b)?.kind { - NodeKind::Blend { spec, children } => Some((b, spec.clone(), children.iter().map(|c| c.id).collect())), + NodeKind::Blend { spec, children } => Some((b, (**spec).clone(), children.iter().map(|c| c.id).collect())), _ => None, } } @@ -952,7 +952,7 @@ fn envelope_defaults(s: &Session) -> (EnvelopeOptions, f64) { } fn envelope(id: NodeId, content: Vec>, kind: EnvelopeKind, (options, fidelity): (EnvelopeOptions, f64)) -> Node { - Node::new(id, NodeKind::Envelope { content, kind, fidelity, editing: false, options, frame: Affine::IDENTITY }) + Node::new(id, NodeKind::Envelope { content, kind: Box::new(kind), fidelity, editing: false, options, frame: Box::new(Affine::IDENTITY) }) } /// `rows` and `cols` (or `columns`), 1..50 each, defaulting to `default`. @@ -1048,7 +1048,7 @@ fn set_kinds(s: &mut Session, label: &str, kinds: Vec<(NodeId, EnvelopeKind)>) - s.edit(label, |d, _| { for (e, k) in kinds { if let Some(NodeKind::Envelope { kind, .. }) = d.node_mut(e).map(|n| &mut n.kind) { - *kind = k; + **kind = k; } } Ok(()) @@ -1110,7 +1110,7 @@ fn env_info(s: &mut Session, _: &Value) -> Result { let (options, fidelity) = envelope_defaults(s); return Ok(envelope_options_json(json!({ "id": null }), &options, fidelity)); }; - let v = match kind { + let v = match &**kind { EnvelopeKind::Warp { style, bend, h, v, horizontal } => { json!({"type": "warp", "style": style, "bend": bend, "h": h, "v": v, "horizontal": horizontal}) } @@ -1154,7 +1154,7 @@ fn carry_transparency(d: &mut Document, env: &Node, mut nodes: Vec) -> Vec n.isolate = env.isolate; n.knockout = env.knockout; n.knockout_shape = env.knockout_shape; - n.mask = env.mask.clone(); + n.extras_mut().mask = env.extras().mask.clone(); }; if let [one] = nodes.as_mut_slice() && one.has_default_transparency() @@ -1205,8 +1205,8 @@ fn env_options(s: &mut Session, p: &Value) -> Result { let mut updates = vec![]; for e in &envs { if let Some(NodeKind::Envelope { kind, options, fidelity, .. }) = doc.node(*e).map(|n| &n.kind) { - let kind = match kind { - EnvelopeKind::Warp { .. } => warp_kind(p, C, Some(kind))?, + let kind = match &**kind { + k @ EnvelopeKind::Warp { .. } => warp_kind(p, C, Some(k))?, k => k.clone(), }; updates.push((*e, kind, options_param(p, C, *options)?, fid.unwrap_or(*fidelity))); @@ -1215,7 +1215,7 @@ fn env_options(s: &mut Session, p: &Value) -> Result { s.edit("Envelope Options", |d, _| { for (e, k, o, f) in updates { if let Some(NodeKind::Envelope { kind, options, fidelity, .. }) = d.node_mut(e).map(|n| &mut n.kind) { - (*kind, *options, *fidelity) = (k, o, f); + (**kind, *options, *fidelity) = (k, o, f); } } Ok(()) @@ -1300,7 +1300,10 @@ fn env_set_mesh_point(s: &mut Session, p: &Value) -> Result { let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?); let handle = handle_param(p, C)?; s.edit("Move Envelope Point", |d, _| { - let Some(NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows, cols, points, handles }, .. }) = d.node_mut(id).map(|n| &mut n.kind) else { + let Some(NodeKind::Envelope { kind, .. }) = d.node_mut(id).map(|n| &mut n.kind) else { + return Err(bad(C, "not a mesh envelope point")); + }; + let EnvelopeKind::Mesh { rows, cols, points, handles } = &mut **kind else { return Err(bad(C, "not a mesh envelope point")); }; let at = *points.get(index).ok_or_else(|| bad(C, "not a mesh envelope point"))?; @@ -1384,7 +1387,10 @@ fn with_mesh(s: &mut Session, p: &Value, cmd: &str, label: &str, envelopes: b let c = cmd.to_string(); s.edit(label, move |d, _| match d.node_mut(id).map(|n| &mut n.kind) { Some(NodeKind::Mesh(m)) => f(m), - Some(NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows, cols, points, handles }, .. }) if envelopes => { + Some(NodeKind::Envelope { kind, .. }) if envelopes && matches!(**kind, EnvelopeKind::Mesh { .. }) => { + let EnvelopeKind::Mesh { rows, cols, points, handles } = &mut **kind else { + return Err(bad(&c, "not a gradient mesh or mesh envelope")); + }; let mut m = live::envelope_grid(*rows, *cols, points, handles).ok_or_else(|| bad(&c, "the envelope's mesh is malformed"))?; let r = f(&mut m)?; (*rows, *cols) = (m.rows, m.cols); diff --git a/crates/engine/src/cmd/maskedit.rs b/crates/engine/src/cmd/maskedit.rs index a94f3838..54d21415 100644 --- a/crates/engine/src/cmd/maskedit.rs +++ b/crates/engine/src/cmd/maskedit.rs @@ -90,7 +90,7 @@ fn enter(s: &mut Session, p: &Value) -> Result { /// The first selected object with an opacity mask. fn first_masked(st: &crate::DocState) -> Option { - st.selection.subjects().iter().copied().find(|i| st.doc.node(*i).is_some_and(|n| n.mask.is_some())) + st.selection.subjects().iter().copied().find(|i| st.doc.node(*i).is_some_and(|n| n.extras().mask.is_some())) } /// Enter mask editing for `id` (one undo step) and isolate the editing layer. Returns the layer. @@ -127,8 +127,11 @@ fn view_mask(s: &mut Session, p: &Value) -> Result { /// Enter mask editing for `id` inside an edit: copy its mask art onto a new editing layer and /// select it. Returns the layer. pub(crate) fn begin(d: &mut Document, sel: &mut Selection, id: NodeId) -> Result { - let art = - d.node(id).and_then(|n| n.mask.as_ref()).map(|m| m.art.clone()).ok_or_else(|| EngineError::Other("the object has no opacity mask".into()))?; + let art = d + .node(id) + .and_then(|n| n.extras().mask.as_ref()) + .map(|m| m.art.clone()) + .ok_or_else(|| EngineError::Other("the object has no opacity mask".into()))?; let layer = d.add_layer(Some(MASK_EDIT_LAYER)); let parts = mask_parts(&art); let mut ids = Vec::with_capacity(parts.len()); @@ -202,7 +205,7 @@ pub(crate) fn follow_history(st: &mut crate::DocState, was: Option) { pub(crate) fn sync(d: &mut Document) { let Some(me) = d.mask_edit else { return }; let art: Option>> = d.node(me.layer).and_then(|l| l.children().cloned()); - let (Some(art), true) = (art, d.node(me.object).is_some_and(|n| n.mask.is_some())) else { + let (Some(art), true) = (art, d.node(me.object).is_some_and(|n| n.extras().mask.is_some())) else { d.mask_edit = None; let _ = d.remove(me.layer); return; @@ -212,12 +215,12 @@ pub(crate) fn sync(d: &mut Document) { // Several objects (or none): a group, so the mask keeps one art node. many => Arc::new(mask_group(many.to_vec())), }; - if let Some(m) = d.node(me.object).and_then(|n| n.mask.as_ref()) + if let Some(m) = d.node(me.object).and_then(|n| n.extras().mask.as_ref()) && Arc::ptr_eq(&m.art, &new_art) { return; } - if let Some(m) = d.node_mut(me.object).and_then(|n| n.mask.as_mut()) { + if let Some(m) = d.node_mut(me.object).and_then(|n| n.extras_if_any().and_then(|e| e.mask.as_mut())) { m.art = new_art; } } @@ -241,7 +244,8 @@ mod tests { assert_eq!(s.doc().unwrap().isolation, Some(NodeId(layer))); // Edit the mask art with ordinary commands: widen it; the object's mask follows live. s.execute("object.move", &json!({"dx": 50, "dy": 0})).unwrap(); - let mask_bounds = |s: &Session| s.doc().unwrap().doc.node(NodeId(obj)).unwrap().mask.as_ref().unwrap().art.geometric_bounds().unwrap(); + let mask_bounds = + |s: &Session| s.doc().unwrap().doc.node(NodeId(obj)).unwrap().extras().mask.as_ref().unwrap().art.geometric_bounds().unwrap(); assert!((mask_bounds(&s).x0 - 50.0).abs() < 1e-6); // Drawing while editing adds to the mask. s.execute("shape.ellipse", &json!({"x": 180, "y": 150, "width": 40, "height": 40})).unwrap(); diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 7b6ad172..4a9016bd 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -449,7 +449,8 @@ fn rasterize(s: &mut Session, p: &Value) -> Result { d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png)); let id = d.alloc_id(); let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale); - let mut node = Node::new(id, NodeKind::Image(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() })); + let mut node = + Node::new(id, NodeKind::Image(Box::new(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() }))); if let Some((path, rule)) = clip { node = super::rasterfx::clip_group(d, path, rule, node); } @@ -562,7 +563,7 @@ fn crop_image(s: &mut Session, p: &Value) -> Result { let node = st.doc.node(id).cloned().ok_or(EngineError::NoNode(id))?; let NodeKind::Image(im) = &node.kind else { return Err(bad(C, "select an image")) }; if p.get("trim").and_then(Value::as_bool) == Some(true) { - return trim_image(s, id, im.clone()); + return trim_image(s, id, (**im).clone()); } let ib = node.geometric_bounds().ok_or_else(|| bad(C, "image has no bounds"))?; let rect = match p.get("rect").and_then(Value::as_array) { @@ -603,14 +604,14 @@ fn crop_image(s: &mut Session, p: &Value) -> Result { let key = unique_key(d, "crop"); d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png)); if let Some(n) = d.node_mut(id) { - n.kind = NodeKind::Image(ImageObject { + n.kind = NodeKind::Image(Box::new(ImageObject { key, width: w, height: h, xf: Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale), link: None, placement: Default::default(), - }); + })); } Ok(()) })?; @@ -646,14 +647,14 @@ fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result { let key = unique_key(d, "crop"); d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png)); if let Some(n) = d.node_mut(id) { - n.kind = NodeKind::Image(ImageObject { + n.kind = NodeKind::Image(Box::new(ImageObject { key, width: cw, height: ch, xf: im.xf * Affine::translate((f64::from(x0), f64::from(y0))), link: None, placement: Default::default(), - }); + })); } Ok(()) })?; @@ -799,7 +800,7 @@ fn convert_to_shape(s: &mut Session, _: &Value) -> Result { } if let Some(l) = detect_shape(path) { *path = l.to_path(); - *live = Some(l); + *live = Some(Box::new(l)); n += 1; } } diff --git a/crates/engine/src/cmd/newart.rs b/crates/engine/src/cmd/newart.rs index 429ac840..40424da5 100644 --- a/crates/engine/src/cmd/newart.rs +++ b/crates/engine/src/cmd/newart.rs @@ -105,7 +105,7 @@ impl NewArt { return; } (n.opacity, n.blend) = (self.opacity, self.blend); - n.graphic_style = self.style.as_deref().and_then(|name| d.graphic_style_index(name)).map(|i| d.graphic_style_id(i)); + n.extras_mut().graphic_style = self.style.as_deref().and_then(|name| d.graphic_style_index(name)).map(|i| d.graphic_style_id(i)); } } diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 8895e2a7..8138e352 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -307,7 +307,7 @@ pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec, xf /// reflows at its size. False for anything else (and type in perspective), which transforms as /// usual. fn resize_type_area(n: &mut Node, xf: Affine) -> bool { - if n.perspective.is_some() { + if n.extras().perspective.is_some() { return false; } let NodeKind::Text(t) = &mut n.kind else { return false }; @@ -860,7 +860,7 @@ fn isolate(s: &mut Session, p: &Value) -> Result { if st.doc.node(id).is_none_or(|n| !n.is_container()) { return Err(bad("object.isolate", "only groups and layers can be isolated")); } - let isolation = st.doc.node(id).filter(|n| n.shaper.is_some()).and_then(|n| n.children()).and_then(|c| c.first()).map_or(id, |n| n.id); + let isolation = st.doc.node(id).filter(|n| n.extras().shaper.is_some()).and_then(|n| n.children()).and_then(|c| c.first()).map_or(id, |n| n.id); st.isolation = Some(isolation); st.selection.clear(); st.revision += 1; @@ -871,7 +871,7 @@ fn exit_isolation(s: &mut Session, _: &Value) -> Result { let st = s.doc_mut()?; if let Some(i) = st.isolation.take() { super::distortcmds::finish_edit_text(st, i); - let target = st.doc.parent_of(i).filter(|p| st.doc.node(*p).is_some_and(|n| n.shaper.is_some())).unwrap_or(i); + let target = st.doc.parent_of(i).filter(|p| st.doc.node(*p).is_some_and(|n| n.extras().shaper.is_some())).unwrap_or(i); st.selection.set([target]); } st.revision += 1; @@ -936,10 +936,9 @@ fn set_props(s: &mut Session, p: &Value) -> Result { && flag("isolate").is_none_or(|v| n.isolate == v) && knockout.is_none_or(|k| n.knockout == k) && flag("knockoutShape").is_none_or(|v| n.knockout_shape == v) - && data - .iter() - .flatten() - .all(|(k, v)| n.attrs.as_ref().and_then(|a| a.data.iter().find(|(key, _)| key == k)).map(|(_, now)| now.as_str()) == v.as_deref()) + && data.iter().flatten().all(|(k, v)| { + n.extras().attrs.as_ref().and_then(|a| a.data.iter().find(|(key, _)| key == k)).map(|(_, now)| now.as_str()) == v.as_deref() + }) }; let d = &s.doc()?.doc; if ids.iter().all(|id| d.node(*id).is_some_and(has_values)) { @@ -1410,7 +1409,7 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let has_values = |id: &NodeId| { let Some(NodeKind::Path { path, live, .. }) = st.doc.node(*id).map(|n| &n.kind) else { return true }; - match live { + match live.as_deref() { Some(l @ LiveShape::Polygon { sides: now, .. }) if !(sides.is_none_or(|n| n.clamp(3, 1000) == u64::from(*now)) && (!equal || l.polygon_sides_equal()) @@ -1425,11 +1424,11 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result { if radius.is_none() && kind.is_none() { return true; } - let Some(c) = LiveCorners::new(path, live.as_ref()) else { return true }; + let Some(c) = LiveCorners::new(path, live.as_deref()) else { return true }; let picked = corners_of(*id).unwrap_or_else(|| c.picked(st.selection.anchors.get(id))); // Setting corners folds a scale left in a rectangle's transform into its size, which // changes it. - live.as_ref().is_none_or(|l| l.folded() == *l) + live.as_deref().is_none_or(|l| l.folded() == *l) && picked .iter() .all(|k| *k < c.base.anchor_count() && radius.is_none_or(|r| c.radius(*k) == r.max(0.0)) && kind.is_none_or(|kd| c.kind(*k) == kd)) @@ -1440,7 +1439,7 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result { s.edit("Live Shape", |d, sel| { for id in &ids { let Some(NodeKind::Path { path, live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; - if let Some(l @ LiveShape::Polygon { .. }) = live.as_mut() + if let Some(l @ LiveShape::Polygon { .. }) = live.as_deref_mut() && (sides.is_some() || polygon_angle.is_some() || polygon_radius.is_some() || side.is_some() || equal) { if let Some(n) = sides { @@ -1458,7 +1457,7 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result { } *path = l.to_path(); } - if let Some(l @ LiveShape::Ellipse { .. }) = live.as_mut() + if let Some(l @ LiveShape::Ellipse { .. }) = live.as_deref_mut() && (pie_start.is_some() || pie_end.is_some() || invert) { if let LiveShape::Ellipse { pie, .. } = l { @@ -1471,7 +1470,7 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result { } let partial = sel.anchors.get_mut(id); // The corners to edit, the Direct-Selected ones and the anchor layout before the edit. - let Some(c) = LiveCorners::new(path, live.as_ref()) else { continue }; + let Some(c) = LiveCorners::new(path, live.as_deref()) else { continue }; let corners = corners_of(*id); let picked = match &corners { Some(k) if k.last().is_some_and(|k| *k >= c.base.anchor_count()) => { @@ -1486,7 +1485,7 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result { set_corners(path, live, &picked, radius, kind); // Direct-Selected corners stay selected as they gain or lose anchors. if let (Some(anchors), Some(selected)) = (partial, selected) - && let Some(c) = LiveCorners::new(path, live.as_ref()) + && let Some(c) = LiveCorners::new(path, live.as_deref()) && c.sources() != layout { *anchors = c.anchors_of(&selected); diff --git a/crates/engine/src/cmd/opacitymask.rs b/crates/engine/src/cmd/opacitymask.rs index 135e50ff..254b97fe 100644 --- a/crates/engine/src/cmd/opacitymask.rs +++ b/crates/engine/src/cmd/opacitymask.rs @@ -154,7 +154,7 @@ pub(crate) fn percent(o: f64) -> f32 { /// The [`transparency_targets`] that have an opacity mask. fn masked_targets(s: &Session, p: &Value) -> Result> { let d = &s.doc()?.doc; - let ids: Vec = transparency_targets(s, p)?.into_iter().filter(|id| d.node(*id).is_some_and(|n| n.mask.is_some())).collect(); + let ids: Vec = transparency_targets(s, p)?.into_iter().filter(|id| d.node(*id).is_some_and(|n| n.extras().mask.is_some())).collect(); if ids.is_empty() { return Err(EngineError::Other("no target object has an opacity mask: select one or give `id`".into())); } @@ -200,12 +200,12 @@ fn make(s: &mut Session, p: &Value) -> Result { gid }; let n = d.node_mut(target).ok_or(EngineError::NoNode(target))?; - if n.mask.is_some() { + if n.extras().mask.is_some() { return Err(EngineError::Other("the object already has an opacity mask".into())); } let mut m = OpacityMask::new(mask_art, clip); m.invert = invert; - n.mask = Some(Box::new(m)); + n.extras_mut().mask = Some(Box::new(m)); sel.set_target(d, target); let layer = if mask_id.is_none() { Some(begin(d, sel, target)?) } else { None }; Ok((target, layer)) @@ -224,7 +224,7 @@ fn release(s: &mut Session, p: &Value) -> Result { finish(d, sel); let mut out = vec![]; for id in &ids { - let Some(m) = d.node_mut(*id).and_then(|n| n.mask.take()) else { continue }; + let Some(m) = d.node_mut(*id).and_then(|n| n.extras_mut().mask.take()) else { continue }; let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?; out.push(*id); for (k, part) in mask_parts(&m.art).iter().enumerate() { @@ -259,12 +259,12 @@ fn set_flags(s: &mut Session, target: &Value, label: &str, p: &Value) -> Result< && linked.is_none_or(|v| m.linked == v) }; let d = &s.doc()?.doc; - if ids.iter().all(|id| d.node(*id).and_then(|n| n.mask.as_deref()).is_none_or(has_flags)) { + if ids.iter().all(|id| d.node(*id).and_then(|n| n.extras().mask.as_deref()).is_none_or(has_flags)) { return ok(); } s.edit(label, |d, _| { for id in &ids { - if let Some(m) = d.node_mut(*id).and_then(|n| n.mask.as_mut()) { + if let Some(m) = d.node_mut(*id).and_then(|n| n.extras_if_any().and_then(|e| e.mask.as_mut())) { m.clip = clip.unwrap_or(m.clip); m.invert = invert.unwrap_or(m.invert); m.disabled = disabled.unwrap_or(m.disabled); @@ -391,7 +391,7 @@ fn mask_info(s: &mut Session, p: &Value) -> Result { let v: Vec = nodes_of(d, &transparency_targets(s, p)?) .into_iter() .filter_map(|n| { - let m = n.mask.as_deref()?; + let m = n.extras().mask.as_deref()?; Some(json!({ "id": n.id.0, "clip": m.clip, "invert": m.invert, "disabled": m.disabled, "linked": m.linked, "art": m.art.id.0 })) }) .collect(); @@ -424,7 +424,7 @@ mod tests { assert_eq!(r["editing"], false); let d = &s.doc().unwrap().doc; assert!(d.node(NodeId(b)).is_none(), "mask art left the layer tree"); - let m = d.node(NodeId(a)).unwrap().mask.as_deref().unwrap(); + let m = d.node(NodeId(a)).unwrap().extras().mask.as_deref().unwrap(); assert!(m.clip && m.linked && !m.invert && !m.disabled); assert_eq!(m.art.id, NodeId(b)); } @@ -438,7 +438,7 @@ mod tests { let d = &s.doc().unwrap().doc; let n = d.node(NodeId(g)).unwrap(); assert_eq!(n.children().unwrap().len(), 2); - assert!(!n.mask.as_ref().unwrap().clip); + assert!(!n.extras().mask.as_ref().unwrap().clip); } #[test] @@ -448,11 +448,11 @@ mod tests { s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); s.execute("transparency.releaseOpacityMask", &json!({})).unwrap(); let st = s.doc().unwrap(); - assert!(st.doc.node(NodeId(a)).unwrap().mask.is_none()); + assert!(st.doc.node(NodeId(a)).unwrap().extras().mask.is_none()); assert_eq!(st.selection.len(), 2); assert_eq!(st.doc.layers[0].children().unwrap().len(), 2); s.execute("edit.undo", &json!({})).unwrap(); - assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().mask.is_some()); + assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().extras().mask.is_some()); } #[test] @@ -469,13 +469,13 @@ mod tests { assert_eq!(info[0]["disabled"], true); assert_eq!(info[0]["linked"], false); // Unlinked: moving the object leaves the mask art where it was. - let before = s.doc().unwrap().doc.node(NodeId(a)).unwrap().mask.as_ref().unwrap().art.clone(); + let before = s.doc().unwrap().doc.node(NodeId(a)).unwrap().extras().mask.as_ref().unwrap().art.clone(); s.execute("object.move", &json!({"dx": 30, "dy": 0})).unwrap(); - let after = s.doc().unwrap().doc.node(NodeId(a)).unwrap().mask.as_ref().unwrap().art.clone(); + let after = s.doc().unwrap().doc.node(NodeId(a)).unwrap().extras().mask.as_ref().unwrap().art.clone(); assert_eq!(before.geometric_bounds(), after.geometric_bounds()); s.execute("transparency.linkOpacityMask", &json!({})).unwrap(); s.execute("object.move", &json!({"dx": 30, "dy": 0})).unwrap(); - let moved = s.doc().unwrap().doc.node(NodeId(a)).unwrap().mask.as_ref().unwrap().art.clone(); + let moved = s.doc().unwrap().doc.node(NodeId(a)).unwrap().extras().mask.as_ref().unwrap().art.clone(); assert!((moved.geometric_bounds().unwrap().x0 - after.geometric_bounds().unwrap().x0 - 30.0).abs() < 1e-9); } @@ -485,7 +485,7 @@ mod tests { assert_eq!(s.execute("transparency.toggleNewMasksClipping", &json!({})).unwrap()["value"], false); select(&mut s, &[a, b]); s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); - assert!(!s.doc().unwrap().doc.node(NodeId(a)).unwrap().mask.as_ref().unwrap().clip); + assert!(!s.doc().unwrap().doc.node(NodeId(a)).unwrap().extras().mask.as_ref().unwrap().clip); s.execute("transparency.toggleNewMasksInverted", &json!({})).unwrap(); assert_eq!(s.new_mask_defaults(), (false, true)); } @@ -498,6 +498,6 @@ mod tests { let d = s.doc().unwrap().doc.clone(); let bytes = vectorcraft_format::save(&d, false); let back = vectorcraft_format::load(&bytes).unwrap(); - assert_eq!(back.node(NodeId(a)).unwrap().mask, d.node(NodeId(a)).unwrap().mask); + assert_eq!(back.node(NodeId(a)).unwrap().extras().mask, d.node(NodeId(a)).unwrap().extras().mask); } } diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index 5a084eb0..28772e80 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -558,7 +558,7 @@ pub(crate) fn styled_copy(d: &mut Document, l: &Node, items: Vec f.opacity = 1.0; f.blend = BlendMode::Normal; f.name = None; - f.mask = None; + f.extras_mut().mask = None; if let NodeKind::Path { live, .. } = &mut f.kind { *live = None; } @@ -575,7 +575,7 @@ pub(crate) fn assemble(d: &mut Document, l: &Node, mut parts: Vec) -> Opti }; let mut root = with_transparency(d, root, l.opacity, l.blend); root.name = l.name.clone(); - root.mask = l.mask.clone(); + root.extras_mut().mask = l.extras().mask.clone(); root.appearance.effects = l.appearance.effects.clone(); Some(root) } diff --git a/crates/engine/src/cmd/place/adopt.rs b/crates/engine/src/cmd/place/adopt.rs index 928a4baf..3493dbd0 100644 --- a/crates/engine/src/cmd/place/adopt.rs +++ b/crates/engine/src/cmd/place/adopt.rs @@ -16,7 +16,7 @@ use super::unique_name; /// Visit `n` and every descendant (children and opacity-mask art) mutably. pub(super) fn each_mut(n: &mut Node, f: &mut impl FnMut(&mut Node)) { f(n); - if let Some(m) = &mut n.mask { + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { each_mut(Arc::make_mut(&mut m.art), f); } if let Some(ch) = n.children_mut() { diff --git a/crates/engine/src/cmd/place/mod.rs b/crates/engine/src/cmd/place/mod.rs index a9177c10..941011f9 100644 --- a/crates/engine/src/cmd/place/mod.rs +++ b/crates/engine/src/cmd/place/mod.rs @@ -285,7 +285,7 @@ fn build(d: &mut Document, l: Loaded, link: Option) -> Node { link, placement: Default::default(), }; - Node::new(d.alloc_id(), NodeKind::Image(im)) + Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))) } Art::Text(t) => Node::new(d.alloc_id(), NodeKind::Text(Box::new(text::area_text(t, l.natural)))), Art::Document(s, link, bounding) => document::node(d, &s, link, bounding), diff --git a/crates/engine/src/cmd/printadvanced.rs b/crates/engine/src/cmd/printadvanced.rs index ef684b00..8a9340e9 100644 --- a/crates/engine/src/cmd/printadvanced.rs +++ b/crates/engine/src/cmd/printadvanced.rs @@ -96,7 +96,7 @@ fn bitmap_document(cmd: &str, doc: &Document, set: &PrintSettings) -> Result Option> { /// effects (they get images of their own). pub(crate) fn raster_image(d: &mut Document, n: &Node, below: bool, members: bool) -> Option { let mut whole = n.clone(); - (whole.opacity, whole.blend, whole.mask) = (1.0, Default::default(), None); + (whole.opacity, whole.blend) = (1.0, Default::default()); + whole.extras_mut().mask = None; if below && members && is_container(&whole) { for c in whole.children_mut().into_iter().flatten() { strip_raster(Arc::make_mut(c), true); @@ -317,7 +320,8 @@ pub(crate) fn put_below(d: &mut Document, m: &mut Node, image: Node) { _ => { let mut inner = m.clone(); inner.id = d.alloc_id(); - (inner.name, inner.opacity, inner.blend, inner.isolate, inner.mask) = (None, 1.0, Default::default(), false, None); + (inner.name, inner.opacity, inner.blend, inner.isolate) = (None, 1.0, Default::default(), false); + inner.extras_mut().mask = None; // Knockout is between the object's own parts (a blend's steps): it stays with them, // so the image doesn't knock out the art over it. m.knockout = Default::default(); @@ -379,7 +383,9 @@ fn placed_symbol(d: &Document, n: &Node) -> Option { let NodeKind::SymbolInstance { symbol, xf } = &n.kind else { return None }; let mut art = vectorcraft_render::instance_art(&d.symbols.iter().find(|s| s.name == *symbol)?.art, n); art.transform(*xf, false); - Some(Node { kind: NodeKind::Group { children: vec![Arc::new(art)], clip: false }, ..n.clone() }) + let mut g = n.clone(); + g.kind = NodeKind::Group { children: vec![Arc::new(art)], clip: false }; + Some(g) } /// Which objects' raster effects become images. @@ -407,13 +413,13 @@ fn needs(n: &Node, which: Which) -> bool { n.visible && !matches!(n.kind, NodeKind::Layer { template: true, .. }) && (imaged(n, which) - || n.mask.as_ref().is_some_and(|m| needs(&m.art, which)) + || n.extras().mask.as_ref().is_some_and(|m| needs(&m.art, which)) || (is_container(n) && n.children().is_some_and(|ch| ch.iter().any(|c| needs(c, which))))) } /// The art of `n`'s opacity mask with its raster effects as images. fn walk_mask(out: &mut Document, n: &mut Node, report: &mut FlattenReport) { - if let Some(m) = n.mask.as_mut() + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) && let Some(art) = walk(out, &m.art, report) { m.art = Arc::new(art); diff --git a/crates/engine/src/cmd/rasterfx/report.rs b/crates/engine/src/cmd/rasterfx/report.rs index e04fcd9d..e263b03f 100644 --- a/crates/engine/src/cmd/rasterfx/report.rs +++ b/crates/engine/src/cmd/rasterfx/report.rs @@ -85,14 +85,14 @@ mod tests { fn image(ppi: f64) -> Node { Node::new( NodeId(999), - NodeKind::Image(ImageObject { + NodeKind::Image(Box::new(ImageObject { key: "effect".into(), width: 8000, height: 8000, xf: Affine::scale(72.0 / ppi), link: None, placement: Default::default(), - }), + })), ) } diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index 12597bb8..36d6fdb5 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -547,7 +547,7 @@ fn same_graphic_style(s: &mut Session, _: &Value) -> Result { .and_then(|id| super::style::linked_style(&st.doc, st.doc.node(*id)?)) .map(|g| g.id) .ok_or_else(|| bad(cmd, "the selection has no graphic style"))?; - select_where(s, cmd, &json!({}), |d, n| n.graphic_style == Some(id) && super::style::linked_style(d, n).is_some()) + select_where(s, cmd, &json!({}), |d, n| n.extras().graphic_style == Some(id) && super::style::linked_style(d, n).is_some()) } /// An appearance attribute Select > Same > Appearance Attribute matches. diff --git a/crates/engine/src/cmd/shaper.rs b/crates/engine/src/cmd/shaper.rs index b7863217..24f96620 100644 --- a/crates/engine/src/cmd/shaper.rs +++ b/crates/engine/src/cmd/shaper.rs @@ -56,7 +56,7 @@ pub fn specs() -> Vec { } fn source_group(n: &Node) -> Option<&Node> { - n.shaper.as_ref()?; + n.extras().shaper.as_ref()?; n.children()?.first().map(AsRef::as_ref).filter(|g| g.name.as_deref() == Some(SOURCES)) } fn sources(n: &Node) -> Vec> { @@ -376,7 +376,7 @@ fn generated(d: &mut Document, src: &[Arc], spec: &ShaperSpec, map: &Build pub(crate) fn refresh(before: &Document, d: &mut Document) -> Result<()> { let mut ids = vec![]; d.walk(|n| { - if n.shaper.is_some() { + if n.extras().shaper.is_some() { ids.push(n.id) } }); @@ -399,7 +399,7 @@ pub(crate) fn refresh(before: &Document, d: &mut Document) -> Result<()> { continue; } let map = map_of(&src)?; - let mut spec = current.shaper.as_deref().cloned().unwrap_or_default(); + let mut spec = current.extras().shaper.as_deref().cloned().unwrap_or_default(); let previous = sources(old); if previous.iter().map(|s| s.id).ne(src.iter().map(|s| s.id)) { // Source insertion, deletion or reordering updates the index-based recipes. @@ -489,7 +489,7 @@ pub(crate) fn refresh(before: &Document, d: &mut Document) -> Result<()> { children.push(n); } let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; - n.shaper = Some(Box::new(spec)); + n.extras_mut().shaper = Some(Box::new(spec)); if let Some(ch) = n.children_mut() { *ch = children; } @@ -509,7 +509,7 @@ fn candidates(s: &Session, p: &Value, points: &[Point], tolerance: f64) -> Resul st.doc.is_editable(*id) && st.doc.node(*id).is_some_and(|n| { !matches!(n.kind, NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. }) - && (n.shaper.is_some() || b::node_outline(n).is_some()) + && (n.extras().shaper.is_some() || b::node_outline(n).is_some()) }) }) .collect(); @@ -578,7 +578,7 @@ fn scribble(s: &mut Session, p: &Value) -> Result { let mut next_merge = 1u64; for id in &roots { let n = s.doc()?.doc.node(*id).ok_or(EngineError::NoNode(*id))?; - if let Some(previous) = n.shaper.as_deref() { + if let Some(previous) = n.extras().shaper.as_deref() { let offset = src.len(); let merge_offset = next_merge; for mut e in previous.faces.clone() { @@ -669,7 +669,7 @@ fn scribble(s: &mut Session, p: &Value) -> Result { let id = d.alloc_id(); let mut group = Node::group(id, children); group.name = Some("Shaper Group".into()); - group.shaper = Some(Box::new(spec)); + group.extras_mut().shaper = Some(Box::new(spec)); for id in &roots { d.remove(*id)?; } @@ -684,7 +684,7 @@ fn scribble(s: &mut Session, p: &Value) -> Result { fn group_at(d: &Document, point: Point, construction: bool) -> Option { let mut groups = vec![]; d.walk(|n| { - if n.shaper.is_some() + if n.extras().shaper.is_some() && d.is_editable(n.id) && (if construction { source_group(n).and_then(Node::geometric_bounds) } else { n.geometric_bounds() }) .is_some_and(|b| b.inflate(6.0, 6.0).contains(point)) @@ -733,7 +733,7 @@ fn target(s: &Session, p: &Value) -> Result { let id = id_param(p, "id") .or_else(|| s.active().and_then(|d| d.selection.objects.first().copied())) .ok_or_else(|| bad("shaper.release", "select a Shaper group"))?; - s.doc()?.doc.node(id).filter(|n| n.shaper.is_some()).ok_or_else(|| bad("shaper.release", "select a Shaper group"))?; + s.doc()?.doc.node(id).filter(|n| n.extras().shaper.is_some()).ok_or_else(|| bad("shaper.release", "select a Shaper group"))?; Ok(id) } fn release(s: &mut Session, p: &Value) -> Result { @@ -756,7 +756,7 @@ fn expand(s: &mut Session, p: &Value) -> Result { let id = target(s, p)?; s.edit("Expand Shaper Group", |d, sel| { let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; - n.shaper = None; + n.extras_mut().shaper = None; n.name = None; if let Some(children) = n.children_mut() && children.first().is_some_and(|c| c.name.as_deref() == Some(SOURCES)) diff --git a/crates/engine/src/cmd/slices.rs b/crates/engine/src/cmd/slices.rs index b5b57af2..cda0e158 100644 --- a/crates/engine/src/cmd/slices.rs +++ b/crates/engine/src/cmd/slices.rs @@ -210,7 +210,7 @@ fn selected(s: &Session) -> Result> { } pub(crate) fn selected_slices(d: &Document, sel: &vectorcraft_doc::Selection) -> Vec { - let objects = sel.objects.iter().filter(|id| d.node(**id).is_some_and(|n| n.slice.is_some())); + let objects = sel.objects.iter().filter(|id| d.node(**id).is_some_and(|n| n.extras().slice.is_some())); dedup(sel.slices.iter().chain(objects).copied().collect()) } @@ -234,7 +234,7 @@ fn doc_has_slices(s: &Session) -> std::result::Result<(), String> { fn object_slice_ids(d: &Document) -> Vec { let mut ids = vec![]; d.walk(|n| { - if n.slice.is_some() { + if n.extras().slice.is_some() { ids.push(n.id); } }); @@ -260,7 +260,7 @@ pub(crate) fn remove_slice(d: &mut Document, id: NodeId) { if d.slices.len() == before && let Some(n) = d.node_mut(id) { - n.slice = None; + n.extras_mut().slice = None; } } @@ -284,14 +284,14 @@ fn make(s: &mut Session, p: &Value) -> Result { None => super::edit::selected_roots(s)?, }; let d = &s.doc()?.doc; - let made: Vec = ids.into_iter().filter(|id| d.node(*id).is_some_and(|n| !n.is_layer() && n.slice.is_none())).collect(); + let made: Vec = ids.into_iter().filter(|id| d.node(*id).is_some_and(|n| !n.is_layer() && n.extras().slice.is_none())).collect(); if made.is_empty() { return Ok(json!({ "ids": [] })); } s.edit("Make Slice", |d, _| { for id in &made { if let Some(n) = d.node_mut(*id) { - n.slice = Some(Box::default()); + n.extras_mut().slice = Some(Box::default()); } } Ok(()) @@ -559,7 +559,7 @@ fn options(s: &mut Session, p: &Value) -> Result { for id in &targets { let o = match d.slice_mut(*id) { Some(sl) => Some(&mut sl.options), - None => d.node_mut(*id).and_then(|n| n.slice.as_deref_mut()), + None => d.node_mut(*id).and_then(|n| n.extras_if_any().and_then(|e| e.slice.as_deref_mut())), }; if let Some(o) = o { edits.apply(o); diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index c6f720fc..18231883 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -175,7 +175,7 @@ pub(crate) fn in_sync(n: &Node, g: &GraphicStyle) -> bool { /// The style `n` is linked to (and still looks like). pub(crate) fn linked_style<'a>(d: &'a Document, n: &Node) -> Option<&'a GraphicStyle> { - d.graphic_style_by_id(n.graphic_style?).filter(|g| in_sync(n, g)) + d.graphic_style_by_id(n.extras().graphic_style?).filter(|g| in_sync(n, g)) } /// Objects anywhere in the art that `f` accepts. @@ -272,14 +272,14 @@ fn style_node(n: &mut Node, g: &GraphicStyle, chars: bool) { fn apply_style(d: &mut Document, id: NodeId, g: &GraphicStyle, chars: bool) { if let Some(n) = d.node_mut(id) { style_node(n, g, chars); - n.graphic_style = Some(g.id); + n.extras_mut().graphic_style = Some(g.id); } } fn set_link(d: &mut Document, ids: &[NodeId], link: Option) { for id in ids { if let Some(n) = d.node_mut(*id) { - n.graphic_style = link; + n.extras_mut().graphic_style = link; } } } @@ -303,7 +303,7 @@ impl Session { pub fn selection_graphic_style(&self) -> Option<(&GraphicStyle, bool)> { let st = self.active()?; let n = st.doc.node(*st.selection.subjects().first()?)?; - let g = st.doc.graphic_style_by_id(n.graphic_style?)?; + let g = st.doc.graphic_style_by_id(n.extras().graphic_style?)?; Some((g, in_sync(n, g))) } @@ -341,7 +341,7 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { let ap = g.appearance_on(n).into_owned(); n.appearance.items.extend(ap.items); n.appearance.effects.extend(ap.effects); - n.graphic_style = None; + n.extras_mut().graphic_style = None; } } } else { @@ -383,7 +383,7 @@ fn style_delete(s: &mut Session, p: &Value) -> Result { let i = style_index(d, name)?; let id = d.graphic_styles.remove(i).id; if id != 0 { - let ids = objects_where(d, |n| n.graphic_style == Some(id)); + let ids = objects_where(d, |n| n.extras().graphic_style == Some(id)); set_link(d, &ids, None); } } @@ -447,6 +447,7 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { let i = match str_param(p, "name") { Some(name) => style_index(d, name)?, None => n + .extras() .graphic_style .and_then(|id| d.graphic_style_by_id(id)) .and_then(|g| d.graphic_style_index(&g.name)) @@ -460,7 +461,7 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { let old = std::mem::replace(&mut d.graphic_styles[i], GraphicStyle { name: name.clone(), id, ..look }); // Objects still linked follow the new definition; edited ones are unlinked (they keep // their look), and the source keeps its own. - let linked = objects_where(d, |n| n.graphic_style == Some(id) && n.id != src); + let linked = objects_where(d, |n| n.extras().graphic_style == Some(id) && n.id != src); let (follow, edited): (Vec, Vec) = linked.into_iter().partition(|nid| d.node(*nid).is_some_and(|n| in_sync(n, &old))); set_link(d, &edited, None); let g = d.graphic_styles[i].clone(); diff --git a/crates/engine/src/cmd/textwrap.rs b/crates/engine/src/cmd/textwrap.rs index e916f65c..862d447d 100644 --- a/crates/engine/src/cmd/textwrap.rs +++ b/crates/engine/src/cmd/textwrap.rs @@ -48,7 +48,7 @@ fn make(s: &mut Session, p: &Value) -> Result { if matches!(n.kind, NodeKind::Layer { .. }) { return Err(EngineError::Other("layers can't be wrap objects".into())); } - n.wrap = Some(w); + n.extras_mut().wrap = Some(w); } Ok(()) })?; @@ -58,7 +58,10 @@ fn make(s: &mut Session, p: &Value) -> Result { fn release(s: &mut Session, p: &Value) -> Result { let ids: Vec = { let d = &s.doc()?.doc; - checked_ids_or_selection(s, p, "object.textWrap.release")?.into_iter().filter(|i| d.node(*i).is_some_and(|n| n.wrap.is_some())).collect() + checked_ids_or_selection(s, p, "object.textWrap.release")? + .into_iter() + .filter(|i| d.node(*i).is_some_and(|n| n.extras().wrap.is_some())) + .collect() }; if ids.is_empty() { return Err(EngineError::Other("Text Wrap: select a wrap object".into())); @@ -66,7 +69,7 @@ fn release(s: &mut Session, p: &Value) -> Result { s.edit("Release Text Wrap", |d, _| { for id in &ids { if let Some(n) = d.node_mut(*id) { - n.wrap = None; + n.extras_mut().wrap = None; } } Ok(()) @@ -78,7 +81,7 @@ fn options(s: &mut Session, p: &Value) -> Result { let ids = checked_ids_or_selection(s, p, "object.textWrap.options")?; let current = { let d = &s.doc()?.doc; - ids.iter().find_map(|i| d.node(*i).and_then(|n| n.wrap)).unwrap_or_default() + ids.iter().find_map(|i| d.node(*i).and_then(|n| n.extras().wrap)).unwrap_or_default() }; if p.get("offset").is_none() && p.get("invert").is_none() { return Ok(json!({ "offset": current.offset, "invert": current.invert })); @@ -89,7 +92,7 @@ fn options(s: &mut Session, p: &Value) -> Result { if let Some(n) = d.node_mut(*id) && !matches!(n.kind, NodeKind::Layer { .. }) { - n.wrap = Some(w); + n.extras_mut().wrap = Some(w); } } Ok(()) @@ -131,7 +134,7 @@ fn collect<'a>(n: &'a Node, out: &mut Vec>) { if !n.visible { return; } - if n.wrap.is_some() && !matches!(n.kind, NodeKind::Layer { .. }) { + if n.extras().wrap.is_some() && !matches!(n.kind, NodeKind::Layer { .. }) { out.push(Item::Wrap(n)); return; } @@ -147,7 +150,7 @@ fn collect<'a>(n: &'a Node, out: &mut Vec>) { } fn any_wrap_state(n: &Node) -> bool { - n.wrap.is_some() + n.extras().wrap.is_some() || matches!(&n.kind, NodeKind::Text(t) if !t.wrap.is_empty()) || n.children().is_some_and(|ch| ch.iter().any(|c| any_wrap_state(c))) } @@ -173,7 +176,7 @@ pub(crate) fn refresh(doc: &mut Document) { outline(n, &mut pd); (!pd.is_empty()).then(|| { pd.transform(inv); - WrapShape { path: pd, wrap: n.wrap.unwrap_or_default() } + WrapShape { path: pd, wrap: n.extras().wrap.unwrap_or_default() } }) } Item::Text(_) => None, @@ -219,18 +222,18 @@ mod tests { let e = s.execute(command, &json!({"ids": ids, "offset": 12})).unwrap_err().to_string(); assert!(e.contains("id") || e.contains("array"), "{command}: {e}"); assert_eq!(s.doc().unwrap().history.undo.len(), before, "{command} added history on error"); - assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.is_none()); - assert!(s.doc().unwrap().doc.node(NodeId(other)).unwrap().wrap.is_none()); + assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().extras().wrap.is_none()); + assert!(s.doc().unwrap().doc.node(NodeId(other)).unwrap().extras().wrap.is_none()); } } s.execute("object.textWrap.make", &json!({"ids": [id]})).unwrap(); - assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.is_some()); - assert!(s.doc().unwrap().doc.node(NodeId(other)).unwrap().wrap.is_none()); + assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().extras().wrap.is_some()); + assert!(s.doc().unwrap().doc.node(NodeId(other)).unwrap().extras().wrap.is_none()); s.execute("object.textWrap.options", &json!({"ids": [id], "offset": 12})).unwrap(); - assert_eq!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.unwrap().offset, 12.0); + assert_eq!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().extras().wrap.unwrap().offset, 12.0); s.execute("object.textWrap.release", &json!({"ids": [id]})).unwrap(); - assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.is_none()); + assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().extras().wrap.is_none()); } #[test] diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 7615a800..0350cee1 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -683,7 +683,7 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result { } let d = Vec2::new(f64_req(p, "dx", C)?, f64_req(p, "dy", C)?); let node = s.doc()?.doc.node(id); - if area_text(node).is_none() || node.is_some_and(|n| n.perspective.is_some()) { + if area_text(node).is_none() || node.is_some_and(|n| n.extras().perspective.is_some()) { return Err(bad(C, "not area type (text in a frame) outside perspective")); } s.edit("Reshape Type Area", |doc, _| { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 58b89bd9..f6d3f06e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -73,15 +73,47 @@ pub struct HistoryEntry { /// Roughly the memory this step keeps of its own: what of `doc` the step after it (or the /// document now) doesn't share ([`Document::retained_bytes`]). pub bytes: usize, + /// The same the other way round: what that next state keeps that `doc` doesn't share. Undoing + /// (redoing) this step makes a step that keeps the next state: this is its `bytes`. + next_bytes: usize, + /// Which document the figures were taken against (its address): the next state. + next: usize, } impl HistoryEntry { /// A step keeping `doc` and `selection`, as they were before the edit that made `next`. pub(crate) fn kept(label: String, doc: Arc, selection: Arc, next: &Document) -> Self { doc.drop_lookup_cache(); - let bytes = doc.retained_bytes(next); - HistoryEntry { label, doc, selection, bytes } + let (bytes, next_bytes) = doc.retained_bytes_each(next); + HistoryEntry { label, doc, selection, bytes, next_bytes, next: std::ptr::from_ref(next) as usize } } + + /// The step that undoing (redoing) the step `costs` came from makes: it keeps `doc`, the + /// document before the undo, whose next state is now `now` (that step's document). Its figures + /// are that step's the other way round when `doc` is still the document they were taken + /// against (nothing replaced it since), so undo and redo needn't walk the documents again. A + /// change made to it in place without an undo step (a setting, a refresh) goes unnoticed: a + /// small error, for an estimate. + pub(crate) fn turned(label: String, doc: Arc, selection: Arc, now: &Arc, costs: StepCosts) -> Self { + if costs.next != Arc::as_ptr(&doc) as usize { + return Self::kept(label, doc, selection, now); + } + doc.drop_lookup_cache(); + HistoryEntry { label, doc, selection, bytes: costs.next_bytes, next_bytes: costs.bytes, next: Arc::as_ptr(now) as usize } + } + + /// The figures [`HistoryEntry::turned`] reuses. + pub(crate) fn costs(&self) -> StepCosts { + StepCosts { bytes: self.bytes, next_bytes: self.next_bytes, next: self.next } + } +} + +/// A step's memory figures ([`HistoryEntry::costs`]). +#[derive(Clone, Copy, Debug)] +pub(crate) struct StepCosts { + bytes: usize, + next_bytes: usize, + next: usize, } /// The undo history of a document: bounded by a number of steps (Preferences › Performance › @@ -141,7 +173,8 @@ impl History { /// merged into it, or an undo group extended it). pub(crate) fn recost_last(&mut self, now: &Document) { if let Some(e) = self.undo.last_mut() { - e.bytes = e.doc.retained_bytes(now); + (e.bytes, e.next_bytes) = e.doc.retained_bytes_each(now); + e.next = std::ptr::from_ref(now) as usize; } } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a8505146..585ffd12 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -981,7 +981,7 @@ fn live_shape_properties_already_set_record_no_undo_step() { let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); let NodeKind::Path { path, live: Some(live), .. } = &mut d.node_mut(r).unwrap().kind else { panic!("not a live rectangle") }; let xf = Affine::scale_non_uniform(2.0, 1.0); - *live = vectorcraft_doc::LiveShape::Rectangle { w: 50.0, h: 60.0, radii: [10.0; 4], kinds: Default::default(), xf }; + **live = vectorcraft_doc::LiveShape::Rectangle { w: 50.0, h: 60.0, radii: [10.0; 4], kinds: Default::default(), xf }; *path = live.to_path(); } let shown = vectorcraft_doc::LiveCorners::of(s.doc().unwrap().doc.node(r).unwrap()).unwrap().radius(0); @@ -1762,8 +1762,8 @@ fn graph_tool_drag_creates_a_graph_and_asks_for_data() { let st = s.doc().unwrap(); assert_eq!(st.history.undo.len(), undo_before + 1); let g = st.doc.node(st.selection.objects[0]).unwrap(); - assert_eq!(g.graph.as_ref().unwrap().kind, vectorcraft_doc::GraphKind::Pie); - assert_eq!(g.graph.as_ref().unwrap().rect, vectorcraft_geom::Rect::new(100.0, 100.0, 300.0, 250.0)); + assert_eq!(g.extras().graph.as_ref().unwrap().kind, vectorcraft_doc::GraphKind::Pie); + assert_eq!(g.extras().graph.as_ref().unwrap().rect, vectorcraft_geom::Rect::new(100.0, 100.0, 300.0, 250.0)); } #[test] @@ -1857,17 +1857,13 @@ fn shaper_turns_rough_strokes_into_live_shapes_and_scribbles_punch_fills() { stroke(&mut s, &rect); let id = s.doc().unwrap().selection.objects[0]; let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - assert!( - matches!(n.kind, vectorcraft_doc::NodeKind::Path { live: Some(vectorcraft_doc::LiveShape::Rectangle { .. }), .. }), - "{:?}", - n.kind_label() - ); + assert!(matches!(n.live_shape(), Some(vectorcraft_doc::LiveShape::Rectangle { .. })), "{:?}", n.kind_label()); // A zig-zag inside it clears its fill while retaining the editable original and stroke. let zig: Vec<(f64, f64)> = (0..30).map(|i| (150.0 + (i % 2) as f64 * 80.0, 120.0 + i as f64 * 2.0)).collect(); stroke(&mut s, &zig); assert_eq!(s.doc().unwrap().doc.node(id).unwrap(), &n); let group = s.doc().unwrap().doc.node(s.doc().unwrap().selection.objects[0]).unwrap(); - assert!(group.shaper.is_some()); + assert!(group.extras().shaper.is_some()); assert!(group.children().unwrap().iter().skip(1).all(|n| n.appearance.fill_paint().is_none())); assert!(group.children().unwrap().iter().skip(1).any(|n| !n.appearance.stroke_paint().is_none())); } @@ -1889,10 +1885,7 @@ fn shaper_square_started_mid_edge_closes_on_pointer_release_and_undoes_once() { s.pointer(&PointerEvent::new(kind, x, y), v).unwrap(); } let id = *s.doc().unwrap().selection.objects.first().expect("recognized square"); - assert!(matches!( - s.doc().unwrap().doc.node(id).unwrap().kind, - vectorcraft_doc::NodeKind::Path { live: Some(vectorcraft_doc::LiveShape::Rectangle { .. }), .. } - )); + assert!(matches!(s.doc().unwrap().doc.node(id).unwrap().live_shape(), Some(vectorcraft_doc::LiveShape::Rectangle { .. }))); assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); s.execute("edit.undo", &json!({})).unwrap(); assert!(s.doc().unwrap().doc.node(id).is_none()); @@ -1928,7 +1921,7 @@ fn shaper_creates_upright_or_inverted_live_triangles_with_one_undo_step() { s.pointer(&PointerEvent::new(PointerKind::Up, pts[0].x, pts[0].y), v).unwrap(); let id = *s.doc().unwrap().selection.objects.first().expect("recognized triangle"); let node = s.doc().unwrap().doc.node(id).unwrap().clone(); - let vectorcraft_doc::NodeKind::Path { live: Some(vectorcraft_doc::LiveShape::Polygon { sides: 3, xf, .. }), .. } = &node.kind else { + let Some(vectorcraft_doc::LiveShape::Polygon { sides: 3, xf, .. }) = node.live_shape() else { panic!("expected live triangle, got {:?}", node.kind_label()); }; let [a, b, c, d, _, _] = xf.as_coeffs(); diff --git a/crates/engine/src/tests_adjust.rs b/crates/engine/src/tests_adjust.rs index 040a4924..9649e12b 100644 --- a/crates/engine/src/tests_adjust.rs +++ b/crates/engine/src/tests_adjust.rs @@ -43,7 +43,7 @@ fn red_image(s: &mut Session) -> NodeId { link: None, placement: Default::default(), }; - d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(im)))?; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; sel.set([id]); Ok(id) }) diff --git a/crates/engine/src/tests_attributes.rs b/crates/engine/src/tests_attributes.rs index 562c342a..17bcb26f 100644 --- a/crates/engine/src/tests_attributes.rs +++ b/crates/engine/src/tests_attributes.rs @@ -52,13 +52,13 @@ fn each_attribute_sets_reads_back_and_undoes_in_one_step() { assert_eq!(i[k], v, "{k}"); } let n = s.doc().unwrap().doc.node(a).unwrap().clone(); - let at = n.attrs.as_deref().unwrap(); + let at = n.extras().attrs.as_deref().unwrap(); assert_eq!((at.show_center, at.image_map, n.url()), (Some(false), ImageMap::Rectangle, Some("https://example.com"))); assert!(!n.shows_center()); // One undo step takes everything back, and the attributes go away entirely. run(&mut s, "edit.undo", json!({})); let n = s.doc().unwrap().doc.node(a).unwrap().clone(); - assert!(n.attrs.is_none() && !n.has_overprint()); + assert!(n.extras().attrs.is_none() && !n.has_overprint()); // Nothing to change: no undo step; bad values are refused. let before = s.doc().unwrap().revision; assert_eq!(run(&mut s, "attributes.set", json!({"showCenter": true}))["changed"], 0); @@ -68,7 +68,7 @@ fn each_attribute_sets_reads_back_and_undoes_in_one_step() { // Clearing the URL and note drops the attributes. run(&mut s, "attributes.set", json!({"url": "u", "note": "n"})); run(&mut s, "attributes.set", json!({"url": "", "note": ""})); - assert!(s.doc().unwrap().doc.node(a).unwrap().attrs.is_none()); + assert!(s.doc().unwrap().doc.node(a).unwrap().extras().attrs.is_none()); } #[test] @@ -168,8 +168,8 @@ fn attributes_survive_a_native_round_trip() { run(&mut s, "attributes.set", json!({"showCenter": false, "imageMap": "polygon", "url": "https://example.com/x", "note": "keep"})); let doc = s.doc().unwrap().doc.clone(); let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap(); - assert_eq!(back.node(a).unwrap().attrs, doc.node(a).unwrap().attrs); - assert!(back.node(a).unwrap().attrs.is_some()); + assert_eq!(back.node(a).unwrap().extras().attrs, doc.node(a).unwrap().extras().attrs); + assert!(back.node(a).unwrap().extras().attrs.is_some()); } #[test] @@ -197,7 +197,7 @@ fn svg_export_links_objects_and_import_reads_the_links_back() { fn object_data_is_set_saved_and_written_to_svg() { let mut s = session(); let a = rect(&mut s, 10.0); - let data = |s: &Session| s.doc().unwrap().doc.node(a).unwrap().attrs.as_deref().map(|x| x.data.clone()).unwrap_or_default(); + let data = |s: &Session| s.doc().unwrap().doc.node(a).unwrap().extras().attrs.as_deref().map(|x| x.data.clone()).unwrap_or_default(); let pair = |k: &str, v: &str| (k.to_string(), v.to_string()); run(&mut s, "object.setProps", json!({"data": {"pivot": "60,100", "z": 3}})); assert_eq!(data(&s), [pair("pivot", "60,100"), pair("z", "3")], "values are text"); @@ -212,5 +212,5 @@ fn object_data_is_set_saved_and_written_to_svg() { assert!(svg.contains("data-pivot=\"60,100\" data-z=\"3\""), "{svg}"); let native = vectorcraft_format::save(&s.doc().unwrap().doc, false); let back = vectorcraft_format::load(&native).unwrap(); - assert_eq!(back.node(a).unwrap().attrs, s.doc().unwrap().doc.node(a).unwrap().attrs); + assert_eq!(back.node(a).unwrap().extras().attrs, s.doc().unwrap().doc.node(a).unwrap().extras().attrs); } diff --git a/crates/engine/src/tests_blendfidelity.rs b/crates/engine/src/tests_blendfidelity.rs index b71f6eb6..a3c06c95 100644 --- a/crates/engine/src/tests_blendfidelity.rs +++ b/crates/engine/src/tests_blendfidelity.rs @@ -166,7 +166,7 @@ fn expand_and_release_keep_what_the_blend_carries() { s.edit("mask", |d, _| { let art = Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 10.0)), Default::default()); let mask = vectorcraft_doc::OpacityMask::new(art, false); - d.node_mut(g).unwrap().mask = Some(Box::new(mask)); + d.node_mut(g).unwrap().extras_mut().mask = Some(Box::new(mask)); Ok(()) }) .unwrap(); @@ -176,14 +176,14 @@ fn expand_and_release_keep_what_the_blend_carries() { let e = node(&s, g); assert!(matches!(e.kind, NodeKind::Group { .. })); assert_eq!((e.name.as_deref(), e.opacity, e.isolate, e.knockout), (Some("Fade"), before.opacity, true, Knockout::On)); - assert_eq!(e.mask, before.mask); + assert_eq!(e.extras().mask, before.extras().mask); s.execute("edit.undo", &json!({})).unwrap(); s.execute("select.set", &json!({"ids": [g.0]})).unwrap(); let r = s.execute("object.blend.release", &json!({})).unwrap(); let grp = NodeId(r["groups"][0].as_u64().unwrap()); let n = node(&s, grp); assert_eq!((n.name.as_deref(), n.opacity, n.isolate), (Some("Fade"), before.opacity, true)); - assert_eq!(n.mask, before.mask); + assert_eq!(n.extras().mask, before.extras().mask); assert_eq!(n.children().unwrap().len(), 3, "spine and keys inside"); } diff --git a/crates/engine/src/tests_blendopts.rs b/crates/engine/src/tests_blendopts.rs index 7e08912c..ab70333d 100644 --- a/crates/engine/src/tests_blendopts.rs +++ b/crates/engine/src/tests_blendopts.rs @@ -28,7 +28,7 @@ fn node(s: &Session, id: NodeId) -> Node { fn spec(s: &Session, id: NodeId) -> (Vec, BlendSpec) { match node(s, id).kind { - NodeKind::Blend { children, spec } => (children.iter().map(|c| c.id).collect(), spec), + NodeKind::Blend { children, spec } => (children.iter().map(|c| c.id).collect(), *spec), _ => panic!("not a blend"), } } diff --git a/crates/engine/src/tests_blendspine.rs b/crates/engine/src/tests_blendspine.rs index ab9fc831..f2a42f7e 100644 --- a/crates/engine/src/tests_blendspine.rs +++ b/crates/engine/src/tests_blendspine.rs @@ -26,7 +26,7 @@ fn node(s: &Session, id: NodeId) -> Node { fn spec(s: &Session, id: NodeId) -> BlendSpec { match node(s, id).kind { - NodeKind::Blend { spec, .. } => spec, + NodeKind::Blend { spec, .. } => *spec, _ => panic!("not a blend"), } } diff --git a/crates/engine/src/tests_build.rs b/crates/engine/src/tests_build.rs index a79f0048..8cf3e4e0 100644 --- a/crates/engine/src/tests_build.rs +++ b/crates/engine/src/tests_build.rs @@ -592,7 +592,10 @@ fn add_image(s: &mut Session, r: &vectorcraft_trace::Raster, xf: Affine) -> Node d.insert( l, 0, - Node::new(id, NodeKind::Image(ImageObject { key: "img1".into(), width: w, height: h, xf, link: None, placement: Default::default() })), + Node::new( + id, + NodeKind::Image(Box::new(ImageObject { key: "img1".into(), width: w, height: h, xf, link: None, placement: Default::default() })), + ), )?; sel.set([id]); Ok(id) @@ -687,7 +690,7 @@ fn image_trace_palettes_and_swatch_libraries() { let doc = crate::cmd::swatchlib::library_colors(&s, "document").unwrap(); let got = fills(&s, &r["id"]); assert!(!got.is_empty() && got.iter().all(|c| doc.contains(c)), "{got:?}"); - let t = node(&s, NodeId(r["id"].as_u64().unwrap())).trace.unwrap(); + let t = node(&s, NodeId(r["id"].as_u64().unwrap())).extras().trace.clone().unwrap(); assert_eq!((t["params"]["palette"].as_str(), t["params"]["library"].as_str()), (Some("documentLibrary"), Some("document"))); assert!(t["params"].get("swatches").is_none(), "looked up again at every trace"); // A swatch library by id: the web-safe colours. @@ -733,7 +736,7 @@ fn image_trace_create_strokes_makes_stroked_centre_lines() { assert!(!sp.closed); let (a, b) = (sp.anchors[0].p, sp.anchors[sp.anchors.len() - 1].p); assert!((a.y - 201.0).abs() < 1.5 && (b.y - 201.0).abs() < 1.5 && (a.x - b.x).abs() > 140.0, "{a:?} … {b:?}"); - let t = node(&s, g).trace.unwrap(); + let t = node(&s, g).extras().trace.clone().unwrap(); assert_eq!((t["params"]["strokes"].as_bool(), t["params"]["strokeWidth"].as_f64()), (Some(true), Some(5.0))); // Expand keeps the stroked line. s.execute("imageTrace.expand", &json!({})).unwrap(); @@ -777,7 +780,7 @@ fn image_trace_flat_logo_preset_makes_a_true_circle() { assert!(r["anchors"].as_u64().unwrap() <= 6, "a circle is four anchors: {r}"); let g = NodeId(r["id"].as_u64().unwrap()); let n = node(&s, g); - let t = n.trace.clone().expect("settings stored"); + let t = n.extras().trace.clone().expect("settings stored"); assert_eq!((t["preset"].as_str(), t["params"]["mode"].as_str()), (Some("Flat Logo"), Some("logo"))); let paths = traced_paths(&n); let (p, rule) = outline(&paths[0]); @@ -811,7 +814,7 @@ fn image_trace_object_remembers_its_settings() { image_doc(&mut s); let r = s.execute("imageTrace.make", &json!({"preset": "6 Colors"})).unwrap(); let g = NodeId(r["id"].as_u64().unwrap()); - let t = node(&s, g).trace.clone().expect("settings stored"); + let t = node(&s, g).extras().trace.clone().expect("settings stored"); assert_eq!(t["preset"], "6 Colors"); assert_eq!(t["params"]["colors"], 6); // Changing a parameter makes it Custom; the settings survive save/open. @@ -819,13 +822,13 @@ fn image_trace_object_remembers_its_settings() { let g = NodeId(r["id"].as_u64().unwrap()); let d = s.doc().unwrap().doc.clone(); let back = vectorcraft_format::load(&vectorcraft_format::save(&d, false)).unwrap(); - let t = back.node(g).unwrap().trace.clone().unwrap(); + let t = back.node(g).unwrap().extras().trace.clone().unwrap(); assert_eq!((t["preset"].as_str(), t["params"]["colors"].as_u64()), (Some("Custom"), Some(9))); // Expanded traces are plain groups. s.execute("imageTrace.expand", &json!({})).unwrap(); let st = s.doc().unwrap(); let id = st.selection.in_paint_order(&st.doc)[0]; - assert!(st.doc.node(id).unwrap().trace.is_none()); + assert!(st.doc.node(id).unwrap().extras().trace.is_none()); } /// A black disc (r = 30 px) on light blue, 100 × 100 px, placed at 2× from (50, 50): traced in @@ -852,7 +855,7 @@ fn render_doc(s: &Session, trace_views: bool) -> vectorcraft_render::Rendered { fn image_trace_view_is_stored_kept_and_validated() { let mut s = session(); let g = disc_on_blue(&mut s); - assert_eq!(node(&s, g).trace.unwrap()["view"], "tracingResult"); + assert_eq!(node(&s, g).extras().trace.clone().unwrap()["view"], "tracingResult"); let r = s.execute("imageTrace.setView", &json!({"view": "outlinesWithSourceImage"})).unwrap(); assert_eq!((ids(&r), r["view"].as_str()), (vec![g], Some("outlinesWithSourceImage"))); assert_eq!(node(&s, g).trace_view(), vectorcraft_doc::TraceView::OutlinesWithSource); diff --git a/crates/engine/src/tests_clipboard.rs b/crates/engine/src/tests_clipboard.rs index 1a1d9cfc..48c8912b 100644 --- a/crates/engine/src/tests_clipboard.rs +++ b/crates/engine/src/tests_clipboard.rs @@ -294,7 +294,7 @@ fn graphic_style_links_follow_the_style_into_the_document() { s.execute("paint.setFill", &json!({"ids": [a.0], "color": "#ffcc00"})).unwrap(); sel(&mut s, &[a]); s.execute("graphicStyle.new", &json!({"name": "Sun"})).unwrap(); - let src_id = node(&s, a).graphic_style.unwrap(); + let src_id = node(&s, a).extras().graphic_style.unwrap(); copy(&mut s, &[a]); assert_eq!(s.clipboard.graphic_styles.iter().map(|g| g.name.as_str()).collect::>(), ["Sun"]); @@ -307,17 +307,17 @@ fn graphic_style_links_follow_the_style_into_the_document() { let v = s.execute("edit.paste", &json!({})).unwrap(); assert_eq!(renamed(&v), [("graphicStyle".to_string(), "Sun".to_string(), "Sun 2".to_string())]); let d = doc(&s); - let linked_to = d.graphic_style_by_id(node(&s, ids_of(&v)[0]).graphic_style.unwrap()).unwrap().clone(); + let linked_to = d.graphic_style_by_id(node(&s, ids_of(&v)[0]).extras().graphic_style.unwrap()).unwrap().clone(); assert_eq!(linked_to.name, "Sun 2"); assert_eq!(linked_to.appearance.fill_paint(), Paint::solid(hex("#ffcc00"))); assert!(d.graphic_styles.iter().filter(|g| g.id == linked_to.id).count() == 1, "a fresh id"); // Pasting again finds the same style. let v = s.execute("edit.paste", &json!({})).unwrap(); - assert_eq!((v["added"].as_u64(), node(&s, ids_of(&v)[0]).graphic_style), (Some(0), Some(linked_to.id))); + assert_eq!((v["added"].as_u64(), node(&s, ids_of(&v)[0]).extras().graphic_style), (Some(0), Some(linked_to.id))); // Back in the source document the link is kept as it was. s.execute("document.activate", &json!({"index": 0})).unwrap(); let v = s.execute("edit.paste", &json!({})).unwrap(); - assert_eq!(node(&s, ids_of(&v)[0]).graphic_style, Some(src_id)); + assert_eq!(node(&s, ids_of(&v)[0]).extras().graphic_style, Some(src_id)); } #[test] diff --git a/crates/engine/src/tests_editcolors.rs b/crates/engine/src/tests_editcolors.rs index 93ef8faa..0dd51c98 100644 --- a/crates/engine/src/tests_editcolors.rs +++ b/crates/engine/src/tests_editcolors.rs @@ -179,7 +179,7 @@ fn image_node(s: &mut Session) -> NodeId { link: None, placement: Default::default(), }; - d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(im)))?; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; sel.set([id]); Ok(id) }) diff --git a/crates/engine/src/tests_envelope.rs b/crates/engine/src/tests_envelope.rs index f1609d89..ea3ce828 100644 --- a/crates/engine/src/tests_envelope.rs +++ b/crates/engine/src/tests_envelope.rs @@ -40,7 +40,7 @@ fn enveloped(s: &mut Session, cmd: &str, params: Value) -> (NodeId, NodeId) { fn kind(s: &Session, id: NodeId) -> EnvelopeKind { match node(s, id).kind { - NodeKind::Envelope { kind, .. } => kind, + NodeKind::Envelope { kind, .. } => *kind, k => panic!("{k:?}"), } } @@ -130,7 +130,7 @@ fn release_gives_back_the_warp_shape_as_a_mesh_and_keeps_the_transparency() { s.execute("transparency.set", &json!({"opacity": 40, "blend": "multiply"})).unwrap(); let mask = Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Default::default()); s.edit("mask", |d, _| { - d.node_mut(e).unwrap().mask = Some(Box::new(OpacityMask::new(mask, true))); + d.node_mut(e).unwrap().extras_mut().mask = Some(Box::new(OpacityMask::new(mask, true))); Ok(()) }) .unwrap(); @@ -139,7 +139,7 @@ fn release_gives_back_the_warp_shape_as_a_mesh_and_keeps_the_transparency() { assert_eq!(ids.len(), 2); assert_eq!(ids[0], a); let content = node(&s, a); - assert_eq!((content.opacity, content.blend, content.mask.is_some()), (0.4, BlendMode::Multiply, true), "the content keeps the look"); + assert_eq!((content.opacity, content.blend, content.extras().mask.is_some()), (0.4, BlendMode::Multiply, true), "the content keeps the look"); let NodeKind::Mesh(m) = node(&s, ids[1]).kind else { panic!("the warp's shape comes back as a mesh") }; // The mesh follows the arc: its outline reaches below the content's bottom edge at the sides. let b = m.outline().bounds().unwrap(); @@ -172,14 +172,14 @@ fn expand_keeps_name_transparency_knockout_and_mask() { s.edit("name and mask", |d, _| { let n = d.node_mut(e).unwrap(); n.name = Some("Banner".into()); - n.mask = Some(Box::new(OpacityMask::new(mask, true))); + n.extras_mut().mask = Some(Box::new(OpacityMask::new(mask, true))); Ok(()) }) .unwrap(); s.execute("object.envelope.expand", &json!({})).unwrap(); let g = node(&s, e); assert!(matches!(g.kind, NodeKind::Group { .. })); - assert_eq!((g.name.as_deref(), g.opacity, g.isolate, g.knockout, g.mask.is_some()), (Some("Banner"), 0.7, true, Knockout::On, true)); + assert_eq!((g.name.as_deref(), g.opacity, g.isolate, g.knockout, g.extras().mask.is_some()), (Some("Banner"), 0.7, true, Knockout::On, true)); } #[test] diff --git a/crates/engine/src/tests_envelope_distort.rs b/crates/engine/src/tests_envelope_distort.rs index 300a6ec5..b02fdb12 100644 --- a/crates/engine/src/tests_envelope_distort.rs +++ b/crates/engine/src/tests_envelope_distort.rs @@ -63,7 +63,7 @@ fn anchors(nodes: &[Node]) -> Vec { fn frame(s: &Session, e: NodeId) -> Affine { match node(s, e).kind { - NodeKind::Envelope { frame, .. } => frame, + NodeKind::Envelope { frame, .. } => *frame, k => panic!("{k:?}"), } } @@ -110,7 +110,7 @@ fn the_frame_round_trips_natively() { let mut old = serde_json::to_value(node(&s, e)).unwrap(); old["kind"].as_object_mut().unwrap().remove("frame"); let n: Node = serde_json::from_value(old).unwrap(); - assert!(matches!(n.kind, NodeKind::Envelope { frame, .. } if frame == Affine::IDENTITY), "older envelopes stand square to the page"); + assert!(matches!(n.kind, NodeKind::Envelope { frame, .. } if frame == Box::new(Affine::IDENTITY)), "older envelopes stand square to the page"); } /// A selected embedded 40×40 pt red image at (100, 100). @@ -123,7 +123,7 @@ fn red_image(s: &mut Session) -> NodeId { let id = d.alloc_id(); let xf = Affine::translate((100.0, 100.0)) * Affine::scale(10.0); let im = vectorcraft_doc::ImageObject { key: "pic".into(), width: 4, height: 4, xf, link: None, placement: Default::default() }; - d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(im)))?; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; sel.set([id]); Ok(id) }) diff --git a/crates/engine/src/tests_envelope_edit.rs b/crates/engine/src/tests_envelope_edit.rs index 1cccb9b8..26e20f43 100644 --- a/crates/engine/src/tests_envelope_edit.rs +++ b/crates/engine/src/tests_envelope_edit.rs @@ -32,7 +32,10 @@ fn enveloped(s: &mut Session, cmd: &str, params: Value) -> (NodeId, NodeId) { fn mesh(s: &Session, e: NodeId) -> (u32, u32, Vec, Vec<[Vec2; 4]>) { match node(s, e).kind { - NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows, cols, points, handles }, .. } => (rows, cols, points, handles), + NodeKind::Envelope { kind, .. } => match *kind { + EnvelopeKind::Mesh { rows, cols, points, handles } => (rows, cols, points, handles), + k => panic!("{k:?}"), + }, k => panic!("{k:?}"), } } @@ -120,7 +123,7 @@ fn mesh_handles_turn_with_the_envelope_and_round_trip() { let mut old = serde_json::to_value(node(&s, e)).unwrap(); old["kind"]["kind"].as_object_mut().unwrap().remove("handles"); let n: Node = serde_json::from_value(old).unwrap(); - assert!(matches!(n.kind, NodeKind::Envelope { kind: EnvelopeKind::Mesh { handles, .. }, .. } if handles.is_empty())); + assert!(matches!(n.kind, NodeKind::Envelope { kind, .. } if matches!(&*kind, EnvelopeKind::Mesh { handles, .. } if handles.is_empty()))); } #[test] diff --git a/crates/engine/src/tests_eyedropper.rs b/crates/engine/src/tests_eyedropper.rs index ef5d7d8b..9b30500a 100644 --- a/crates/engine/src/tests_eyedropper.rs +++ b/crates/engine/src/tests_eyedropper.rs @@ -161,7 +161,7 @@ fn clicking_an_image_samples_its_pixel() { let id = d.alloc_id(); let xf = vectorcraft_geom::Affine::translate((300.0, 0.0)) * vectorcraft_geom::Affine::scale(100.0); let im = ImageObject { key: "px".into(), width: 2, height: 1, xf, link: None, placement: Default::default() }; - d.insert(Some(l), 0, Node::new(id, NodeKind::Image(im))).map_err(|e| crate::EngineError::Other(e.to_string())) + d.insert(Some(l), 0, Node::new(id, NodeKind::Image(Box::new(im)))).map_err(|e| crate::EngineError::Other(e.to_string())) }) .unwrap(); let dst = rect(&mut s, 10.0); diff --git a/crates/engine/src/tests_flatten.rs b/crates/engine/src/tests_flatten.rs index a014b1db..b6d617ad 100644 --- a/crates/engine/src/tests_flatten.rs +++ b/crates/engine/src/tests_flatten.rs @@ -451,13 +451,13 @@ fn opacity_masks_are_rasterized_with_their_look() { json!({"ids": [m], "gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]}}), ); run(&mut s, "transparency.makeOpacityMask", json!({"ids": [a, m]})); - assert!(node(&s, a).mask.is_some()); + assert!(node(&s, a).extras().mask.is_some()); let before = picture(&s); run(&mut s, "select.all", json!({})); let (ids, _, rasterized) = flatten(&mut s, json!({"lineArtPpi": 144})); assert_eq!((ids.len(), rasterized), (1, 1)); let mut masks = 0; - node(&s, ids[0]).walk(&mut |c| masks += c.mask.is_some() as usize); + node(&s, ids[0]).walk(&mut |c| masks += c.extras().mask.is_some() as usize); assert_eq!(masks, 0, "no transparency is left"); assert!(changed(&before, &picture(&s)) < 0.02); } diff --git a/crates/engine/src/tests_history_mem.rs b/crates/engine/src/tests_history_mem.rs index dd190700..0ac0fab5 100644 --- a/crates/engine/src/tests_history_mem.rs +++ b/crates/engine/src/tests_history_mem.rs @@ -124,3 +124,32 @@ fn steps_share_an_unchanged_selection() { assert!(Arc::ptr_eq(&st.history.redo[0].selection, &st.history.redo[1].selection)); assert_eq!(st.selection.objects.len(), 100); } + +/// Undo and redo don't estimate the steps again: the step an undo (redo) makes takes the undone +/// step's figures the other way round, which are what a fresh estimate gives. +#[test] +fn undo_and_redo_reuse_the_estimates() { + let mut s = session_with_rects(200); + s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); + // A deletion is lopsided: the step before it keeps the objects, the one after keeps nothing. + s.execute("edit.clear", &json!({})).unwrap(); + let fresh = |e: &HistoryEntry, now: &Document| e.doc.retained_bytes(now); + let st = s.doc().unwrap(); + let delete = st.history.undo.last().unwrap(); + assert!(delete.bytes > 200 * 300, "the deleted objects: {}", delete.bytes); + s.execute("edit.undo", &json!({})).unwrap(); + let st = s.doc().unwrap(); + let redo = st.history.redo.last().unwrap(); + assert_eq!(redo.bytes, fresh(redo, &st.doc)); + assert!(redo.bytes < 4096, "the document without the objects keeps little of its own: {}", redo.bytes); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.redo", &json!({})).unwrap(); + let st = s.doc().unwrap(); + let undo = st.history.undo.last().unwrap(); + assert_eq!(undo.label, "Move"); + assert_eq!(undo.bytes, fresh(undo, &st.doc)); + s.execute("edit.redo", &json!({})).unwrap(); + let st = s.doc().unwrap(); + let delete = st.history.undo.last().unwrap(); + assert_eq!(delete.bytes, fresh(delete, &st.doc)); +} diff --git a/crates/engine/src/tests_links.rs b/crates/engine/src/tests_links.rs index 9734a319..a1ca2592 100644 --- a/crates/engine/src/tests_links.rs +++ b/crates/engine/src/tests_links.rs @@ -64,7 +64,7 @@ pub(crate) fn place(s: &mut Session, path: &str) -> NodeId { pub(crate) fn image(s: &Session, id: NodeId) -> ImageObject { match &s.doc().unwrap().doc.node(id).unwrap().kind { - NodeKind::Image(im) => im.clone(), + NodeKind::Image(im) => (**im).clone(), k => panic!("not an image: {k:?}"), } } @@ -323,7 +323,7 @@ fn relative_paths_are_written_against_the_saved_file() { link: Some(LinkInfo::new(format!("{root}{sep}art{sep}a.png"))), placement: Default::default(), }; - d.insert(Some(lid), 0, vectorcraft_doc::Node::new(nid, NodeKind::Image(im))).unwrap(); + d.insert(Some(lid), 0, vectorcraft_doc::Node::new(nid, NodeKind::Image(Box::new(im)))).unwrap(); let rel = |dest: String| { let d = cmd::links::with_relative_paths(&d, &dest).unwrap_or_else(|| d.clone()); match &d.node(nid).unwrap().kind { diff --git a/crates/engine/src/tests_live.rs b/crates/engine/src/tests_live.rs index a26495d2..54e523a4 100644 --- a/crates/engine/src/tests_live.rs +++ b/crates/engine/src/tests_live.rs @@ -413,7 +413,8 @@ fn envelope_options_and_edit_contents() { let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 50})).unwrap()); s.execute("object.envelope.options", &json!({"fidelity": 90, "bend": -30})).unwrap(); match &node(&s, e).kind { - NodeKind::Envelope { fidelity, kind: EnvelopeKind::Warp { bend, style, .. }, .. } => { + NodeKind::Envelope { fidelity, kind, .. } => { + let EnvelopeKind::Warp { bend, style, .. } = &**kind else { panic!("{kind:?}") }; assert!(close(*fidelity, 90.0) && close(*bend, -30.0) && style == "arc"); } k => panic!("{k:?}"), diff --git a/crates/engine/src/tests_livecorners.rs b/crates/engine/src/tests_livecorners.rs index b7ed7404..96cdf0b1 100644 --- a/crates/engine/src/tests_livecorners.rs +++ b/crates/engine/src/tests_livecorners.rs @@ -25,7 +25,8 @@ fn run(s: &mut Session, p: Value) { fn corners(s: &Session, id: NodeId) -> ([f64; 4], [CornerKind; 4], usize) { let n = s.doc().unwrap().doc.node(id).unwrap(); - let NodeKind::Path { live: Some(LiveShape::Rectangle { radii, kinds, .. }), path, .. } = &n.kind else { panic!("not a live rectangle") }; + let NodeKind::Path { live: Some(l), path, .. } = &n.kind else { panic!("not a live shape") }; + let LiveShape::Rectangle { radii, kinds, .. } = &**l else { panic!("not a live rectangle") }; (*radii, *kinds, path.anchor_count()) } @@ -157,7 +158,7 @@ fn corners_of_a_non_square_rectangle_are_alike_circles() { let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); let NodeKind::Path { path, live: Some(live), .. } = &mut d.node_mut(id).unwrap().kind else { panic!("not live") }; let xf = Affine::translate((10.0, 10.0)) * Affine::scale_non_uniform(2.0, 1.0); - *live = LiveShape::Rectangle { w: 50.0, h: 60.0, radii: [10.0; 4], kinds: Default::default(), xf }; + **live = LiveShape::Rectangle { w: 50.0, h: 60.0, radii: [10.0; 4], kinds: Default::default(), xf }; *path = live.to_path(); } assert_eq!(spans(&s, id)[0], (20.0, 10.0)); @@ -178,7 +179,7 @@ fn star(s: &mut Session) -> NodeId { fn node_path(s: &Session, id: NodeId) -> (vectorcraft_geom::PathData, Option) { let NodeKind::Path { path, live, .. } = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("not a path") }; - (path.clone(), live.clone()) + (path.clone(), live.as_deref().cloned()) } /// The radius and kind every corner of `id` shares, and how many corners it has. @@ -307,7 +308,10 @@ fn an_ellipse_takes_pie_angles_and_inverts() { s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap(); let id = NodeId(s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap()); let pie = |s: &Session| match &s.doc().unwrap().doc.node(id).unwrap().kind { - NodeKind::Path { live: Some(LiveShape::Ellipse { pie, .. }), path, .. } => (*pie, path.bounds().unwrap()), + NodeKind::Path { live: Some(l), path, .. } => match &**l { + LiveShape::Ellipse { pie, .. } => (*pie, path.bounds().unwrap()), + k => panic!("{k:?}"), + }, k => panic!("{k:?}"), }; s.execute("object.setLiveShape", &json!({"pieStart": 0, "pieEnd": 90})).unwrap(); @@ -335,7 +339,7 @@ fn a_polygon_takes_its_angle_radius_side_length_and_equal_sides() { s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); let id = NodeId(s.execute("shape.polygon", &json!({"cx": 200, "cy": 200, "radius": 50, "sides": 6})).unwrap()["id"].as_u64().unwrap()); let live = |s: &Session| match &s.doc().unwrap().doc.node(id).unwrap().kind { - NodeKind::Path { live: Some(l @ LiveShape::Polygon { .. }), path, .. } => (l.clone(), path.bounds().unwrap()), + NodeKind::Path { live: Some(l), path, .. } if matches!(**l, LiveShape::Polygon { .. }) => ((**l).clone(), path.bounds().unwrap()), k => panic!("{k:?}"), }; let close = |a: f64, b: f64| (a - b).abs() < 1e-6; diff --git a/crates/engine/src/tests_menucmds.rs b/crates/engine/src/tests_menucmds.rs index 5883d220..3a23dda1 100644 --- a/crates/engine/src/tests_menucmds.rs +++ b/crates/engine/src/tests_menucmds.rs @@ -426,7 +426,8 @@ fn convert_rectangle_path_to_live_shape() { ); sel(&mut s, &[p]); assert_eq!(s.execute("object.shape.convertToShape", &json!({})).unwrap()["converted"], 1); - let NodeKind::Path { live: Some(LiveShape::Rectangle { w, h, .. }), path, .. } = &node(&s, p).kind else { panic!() }; + let n = node(&s, p); + let (Some(LiveShape::Rectangle { w, h, .. }), NodeKind::Path { path, .. }) = (n.live_shape(), &n.kind) else { panic!() }; assert!(close(*w, 40.0) && close(*h, 20.0)); assert_eq!(path.bounds(), Some(Rect::new(0.0, 0.0, 40.0, 20.0))); } @@ -448,7 +449,7 @@ fn convert_rotated_rectangle_and_ellipse() { assert_eq!(s.execute("object.shape.convertToShape", &json!({})).unwrap()["converted"], 2); let b = bounds(&s, p); assert!(close(b.x0, 0.0) && close(b.x1, 100.0) && close(b.y1, 100.0), "{b:?}"); - assert!(matches!(node(&s, e).kind, NodeKind::Path { live: Some(LiveShape::Ellipse { .. }), .. })); + assert!(matches!(node(&s, e).live_shape(), Some(LiveShape::Ellipse { .. }))); } #[test] diff --git a/crates/engine/src/tests_opacitymask.rs b/crates/engine/src/tests_opacitymask.rs index d18e033f..a2fe33ae 100644 --- a/crates/engine/src/tests_opacitymask.rs +++ b/crates/engine/src/tests_opacitymask.rs @@ -35,7 +35,7 @@ fn doc(s: &Session) -> &Document { } fn mask(s: &Session, id: NodeId) -> Option<&vectorcraft_doc::OpacityMask> { - doc(s).node(id).and_then(|n| n.mask.as_deref()) + doc(s).node(id).and_then(|n| n.extras().mask.as_deref()) } fn has_edit_layer(d: &Document) -> bool { @@ -272,7 +272,7 @@ fn saving_while_editing_writes_no_editing_layer() { assert!(back.mask_edit.is_none() && !has_edit_layer(&back)); assert_eq!(back.layers.len(), 1); // The edit made while editing is in the saved mask. - assert_eq!(back.node(obj).unwrap().mask.as_ref().unwrap().art.geometric_bounds().unwrap().x0, 50.0); + assert_eq!(back.node(obj).unwrap().extras().mask.as_ref().unwrap().art.geometric_bounds().unwrap().x0, 50.0); // Still editing in the session. assert!(doc(&s).mask_edit.is_some() && has_edit_layer(doc(&s))); // A file saved mid-edit by an older version (editing record and layer written) opens clean. diff --git a/crates/engine/src/tests_pdfraster.rs b/crates/engine/src/tests_pdfraster.rs index 2eb3787a..a3de3deb 100644 --- a/crates/engine/src/tests_pdfraster.rs +++ b/crates/engine/src/tests_pdfraster.rs @@ -103,7 +103,7 @@ fn groups_layers_type_images_and_symbols_keep_their_raster_effects() { let xf = Affine::translate((40.0, 50.0)) * Affine::scale(2.5); let im = Node::new( d.alloc_id(), - NodeKind::Image(ImageObject { key: "pic".into(), width: 40, height: 30, xf, link: None, placement: Default::default() }), + NodeKind::Image(Box::new(ImageObject { key: "pic".into(), width: 40, height: 30, xf, link: None, placement: Default::default() })), ); add(&mut d, with(im, shadow())); assert_written("image", &d); diff --git a/crates/engine/src/tests_persp_text.rs b/crates/engine/src/tests_persp_text.rs index 96fe97c2..597c2ba8 100644 --- a/crates/engine/src/tests_persp_text.rs +++ b/crates/engine/src/tests_persp_text.rs @@ -117,7 +117,7 @@ fn edit_text_shows_it_flat_in_isolation_then_projects_it_again() { assert!(n.projection().is_some(), "projected again"); // Nothing left flat anywhere in the history. for e in &s.doc().unwrap().history.undo { - if let Some(p) = e.doc.node(id).and_then(|n| n.perspective.as_deref()) { + if let Some(p) = e.doc.node(id).and_then(|n| n.extras().perspective.as_deref()) { assert!(!p.editing); } } diff --git a/crates/engine/src/tests_recovery.rs b/crates/engine/src/tests_recovery.rs index 80922774..cf46b91d 100644 --- a/crates/engine/src/tests_recovery.rs +++ b/crates/engine/src/tests_recovery.rs @@ -458,3 +458,119 @@ fn without_a_store_the_commands_say_where_to_set_one() { assert!(!area.join("Untitled-1-1.vectorcraft").exists()); let _ = std::fs::remove_dir_all(&dir); } + +/// A store in memory that notes the entries written to it. +#[derive(Default)] +struct Counting { + inner: MemoryStore, + written: std::sync::Mutex>, +} + +impl Counting { + /// The image entries written since the last call, with their sizes. + fn images_written(&self) -> Vec<(String, usize)> { + std::mem::take(&mut *self.written.lock().unwrap()).into_iter().filter(|(n, _)| n.ends_with(".blob")).collect() + } + + fn images(&self) -> Vec { + self.inner.list().unwrap().into_iter().filter(|n| n.ends_with(".blob")).collect() + } +} + +impl RecoveryStore for Counting { + fn list(&self) -> std::result::Result, String> { + self.inner.list() + } + fn read(&self, name: &str) -> std::result::Result, String> { + self.inner.read(name) + } + fn write(&self, name: &str, bytes: &[u8]) -> std::result::Result<(), String> { + self.written.lock().unwrap().push((name.to_string(), bytes.len())); + self.inner.write(name, bytes) + } + fn remove(&self, name: &str) -> std::result::Result<(), String> { + self.inner.remove(name) + } + fn location(&self) -> String { + self.inner.location() + } + fn lock(&self, area: &str) -> std::result::Result { + self.inner.lock(area) + } + fn now(&self) -> Option { + self.inner.now() + } +} + +fn place_png(s: &mut Session, name: &str, rgb: [u8; 3]) -> Vec { + let png = crate::tests_links::png(64, 48, rgb); + s.execute("file.place", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&png), "link": false})).unwrap(); + png +} + +/// The document's images: their bytes by key. +fn images_of(s: &Session) -> std::collections::BTreeMap> { + s.doc().unwrap().doc.images.iter().map(|(k, b)| (k.clone(), b.bytes.to_vec())).collect() +} + +#[test] +fn images_are_kept_apart_and_written_again_only_when_they_change() { + let store = Arc::new(Counting::default()); + let mut s = Session::new(); + s.recovery.set_store(store.clone()); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + let red = place_png(&mut s, "red.png", crate::tests_links::RED); + let r = s.execute("file.recovery.save", &json!({})).unwrap(); + assert_eq!(r["saved"][0]["imageBytes"], red.len(), "{r}"); + assert_eq!(store.images_written().len(), 1); + let area = s.recovery.own_area().unwrap().to_string(); + // The document entry doesn't hold the image. + let doc = store.read(&format!("{area}/Untitled-1-1.vectorcraft")).unwrap(); + assert!(vectorcraft_format::load(&doc).unwrap().images.is_empty()); + // An edit that leaves the image alone: the copy is written again, its image isn't. + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap(); + let r = s.execute("file.recovery.save", &json!({})).unwrap(); + assert_eq!((r["saved"].as_array().unwrap().len(), r["saved"][0]["imageBytes"].as_u64()), (1, Some(0)), "{r}"); + assert!(store.images_written().is_empty(), "the image isn't written again"); + // A new image: only it is written. + let blue = place_png(&mut s, "blue.png", crate::tests_links::BLUE); + let r = s.execute("file.recovery.save", &json!({})).unwrap(); + assert_eq!(r["saved"][0]["imageBytes"], blue.len()); + assert_eq!(store.images_written().iter().map(|(_, n)| *n).collect::>(), [blue.len()]); + assert_eq!(store.images().len(), 2); + let images = images_of(&s); + assert_eq!(images.len(), 2); + + // A crash: the copy restores with its images. + drop(s); + let mut s = Session::new(); + s.recovery.set_store(store.clone()); + assert_eq!(restored(s.execute("file.recovery.restore", &json!({})).unwrap()), 1); + assert_eq!(images_of(&s), images); + let mine = s.recovery.own_area().unwrap().to_string(); + assert!(store.images().iter().all(|n| n.starts_with(&format!("{mine}/"))), "the images moved with the copy, the old area went"); + assert_eq!(store.images().len(), 2); + // Its copy and another document's: once the first is closed, the next copy sweeps its images. + s.execute("file.new", &json!({"width": 50, "height": 50})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 1, "y": 1, "width": 5, "height": 5})).unwrap(); + s.execute("file.close", &json!({"index": 0})).unwrap(); + s.execute("file.recovery.save", &json!({})).unwrap(); + assert!(store.images().is_empty(), "{:?}", store.list()); +} + +#[test] +fn copies_from_before_images_were_kept_apart_still_restore() { + let store = Arc::new(MemoryStore::default()); + // The document entry is a whole native file, images inside; the details say nothing of them. + let mut s = session_in(&store); + place_png(&mut s, "red.png", crate::tests_links::RED); + let d = vectorcraft_doc::Document::clone(&s.doc().unwrap().doc); + let images = images_of(&s); + drop(s); + store.write("1-1/Old-1.vectorcraft", &vectorcraft_format::save_file(&d)).unwrap(); + store.write("1-1/Old-1.json", br#"{"title": "Old", "format": "vectorcraft", "saved": 5}"#).unwrap(); + let mut s = relaunch(&store); + assert_eq!(restored(s.execute("file.recovery.restore", &json!({})).unwrap()), 1); + assert_eq!(images_of(&s), images); + assert_eq!(s.doc().unwrap().title(), "Untitled-1 [Recovered]"); +} diff --git a/crates/engine/src/tests_scalestrokes.rs b/crates/engine/src/tests_scalestrokes.rs index 4f981444..36163600 100644 --- a/crates/engine/src/tests_scalestrokes.rs +++ b/crates/engine/src/tests_scalestrokes.rs @@ -138,8 +138,8 @@ fn group_appearance_scales_with_its_members() { #[test] fn scale_corners_keeps_or_scales_live_corner_radii() { - let radius = |s: &Session, id| match &node(s, id).kind { - NodeKind::Path { live: Some(LiveShape::Rectangle { radii, xf, .. }), .. } => radii[0] * xf.as_coeffs()[0], + let radius = |s: &Session, id| match node(s, id).live_shape() { + Some(LiveShape::Rectangle { radii, xf, .. }) => radii[0] * xf.as_coeffs()[0], _ => panic!("not a live rectangle"), }; let mut s = session(); @@ -175,7 +175,7 @@ fn live_corners_stay_circular_when_a_rectangle_is_scaled_unevenly() { let n = node(&s, id); assert!((n.geometric_bounds().unwrap().width() - 300.0).abs() < 1e-9); assert!(round_corners(&n, 10.0), "{:?}", corner_spans(&n)); - let NodeKind::Path { live: Some(LiveShape::Rectangle { w, h, radii, .. }), .. } = &n.kind else { panic!("not live") }; + let Some(LiveShape::Rectangle { w, h, radii, .. }) = n.live_shape() else { panic!("not live") }; assert!((w - 300.0).abs() < 1e-9 && (h - 100.0).abs() < 1e-9 && (radii[0] - 10.0).abs() < 1e-9, "the panels read document units"); // Scale Corners on: the radius scales by the mean scale (√(2 × 0.5) = 1 here, 2 below). run(&mut s, "object.scale", json!({"sx": 200, "sy": 50, "corners": true})); diff --git a/crates/engine/src/tests_shaper.rs b/crates/engine/src/tests_shaper.rs index 77e8da0a..0ad4be22 100644 --- a/crates/engine/src/tests_shaper.rs +++ b/crates/engine/src/tests_shaper.rs @@ -1,7 +1,7 @@ use super::*; use serde_json::json; use vectorcraft_doc::shaper::{FILL_PREFIX, SOURCES, STROKE_PREFIX}; -use vectorcraft_doc::{LiveShape, Node, NodeKind}; +use vectorcraft_doc::{LiveShape, Node}; use vectorcraft_geom::{Point, Shape as _}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; @@ -134,7 +134,7 @@ fn shaper_erases_only_protruding_stroke_pieces_keeps_fill_and_original_line() { assert!(stroke(&s, g, 225.0, 150.0)); assert_eq!(fill(&s, g, 150.0, 130.0).as_deref(), Some("#ffff00")); assert_eq!(node(&s, line), &original_line); - assert!(matches!(node(&s, circle).kind, NodeKind::Path { live: Some(LiveShape::Ellipse { .. }), .. })); + assert!(matches!(node(&s, circle).live_shape(), Some(LiveShape::Ellipse { .. }))); let g = scribble(&mut s, &[[210.0, 140.0], [220.0, 160.0], [230.0, 140.0], [240.0, 160.0]]); assert!(!stroke(&s, g, 225.0, 150.0)); assert!(stroke(&s, g, 150.0, 150.0)); @@ -152,7 +152,7 @@ fn shaper_source_move_resize_native_roundtrip_and_release_preserve_live_art() { assert_eq!(node(&s, a).geometric_bounds().unwrap().x0, 20.0); s.execute("select.set", &json!({"ids":[a.0]})).unwrap(); s.execute("object.setBounds", &json!({"width":120.0,"height":110.0,"reference":0})).unwrap(); - assert!(matches!(node(&s, a).kind, NodeKind::Path { live: Some(LiveShape::Rectangle { .. }), .. })); + assert!(matches!(node(&s, a).live_shape(), Some(LiveShape::Rectangle { .. }))); let doc = &s.doc().unwrap().doc; let back = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap(); assert_eq!(back.layers, doc.layers); @@ -175,7 +175,7 @@ fn shaper_face_paint_survives_source_edits_and_expand_discards_only_sources() { s.execute("object.move", &json!({"ids":[a.0],"dx":5,"dy":0})).unwrap(); assert_eq!(fill(&s, g, 75.0, 50.0).as_deref(), Some("#00ff00")); s.execute("shaper.expand", &json!({"id":g.0})).unwrap(); - assert!(node(&s, g).shaper.is_none()); + assert!(node(&s, g).extras().shaper.is_none()); assert!(s.doc().unwrap().doc.node(a).is_none()); assert_eq!(fill(&s, g, 75.0, 50.0).as_deref(), Some("#00ff00")); } @@ -211,7 +211,7 @@ fn shaper_lines_keep_free_angles_with_or_without_shift() { s.pointer(&PointerEvent::new(kind, x, y).with_mods(Mods { shift, ..Default::default() }), v).unwrap(); } let n = node(&s, s.doc().unwrap().selection.objects[0]); - assert!(matches!(n.kind,NodeKind::Path{live:Some(LiveShape::Line{a,b}),..} if a==Point::new(30.0,40.0) && b==Point::new(230.0,100.0))); + assert!(matches!(n.live_shape(), Some(LiveShape::Line { a, b }) if *a == Point::new(30.0, 40.0) && *b == Point::new(230.0, 100.0))); } } @@ -258,7 +258,7 @@ fn shaper_tool_trims_both_diagonal_ends_and_only_construction_can_pick_them() { } } let g = s.doc().unwrap().selection.objects[0]; - assert!(node(&s, g).shaper.is_some()); + assert!(node(&s, g).extras().shaper.is_some()); assert_eq!(node(&s, line), &original); assert!(stroke(&s, g, 150.0, 150.0)); assert_eq!(fill(&s, g, 150.0, 130.0).as_deref(), Some("#ffff00")); diff --git a/crates/engine/src/tests_slices.rs b/crates/engine/src/tests_slices.rs index ab0a4c08..24674a1f 100644 --- a/crates/engine/src/tests_slices.rs +++ b/crates/engine/src/tests_slices.rs @@ -64,10 +64,10 @@ fn an_object_slice_follows_its_object_and_releases_back_to_it() { // Make again changes nothing; Release leaves the object. assert_eq!(run(&mut s, "object.slice.make", json!({}))["ids"], json!([])); assert_eq!(run(&mut s, "object.slice.release", json!({}))["ids"], json!([a])); - assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().slice.is_none()); + assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().extras().slice.is_none()); assert!(slices(&mut s).is_empty()); run(&mut s, "edit.undo", json!({})); - assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().slice.is_some()); + assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().extras().slice.is_some()); } #[test] @@ -155,7 +155,7 @@ fn combine_and_duplicate() { let c = run(&mut s, "object.slice.combine", json!({}))["id"].as_u64().unwrap(); assert_eq!(rect_of(&mut s, c), [0.0, 0.0, 60.0, 50.0]); assert_eq!(user_count(&s), 1); - assert!(s.doc().unwrap().doc.node(NodeId(b)).unwrap().slice.is_none(), "the object slice was released"); + assert!(s.doc().unwrap().doc.node(NodeId(b)).unwrap().extras().slice.is_none(), "the object slice was released"); assert_eq!(s.doc().unwrap().selection.slices, vec![NodeId(c)]); let dup = run(&mut s, "object.slice.duplicate", json!({}))["ids"][0].as_u64().unwrap(); assert_eq!(rect_of(&mut s, dup), [10.0, 10.0, 60.0, 50.0]); @@ -191,7 +191,7 @@ fn slice_options_set_read_and_undo_in_one_step() { run(&mut s, "select.set", json!({"ids": [t]})); let o = run(&mut s, "object.slice.options", json!({"kind": "htmlText"})); assert_eq!((o["kind"].clone(), o["htmlText"].clone()), (json!("htmlText"), json!(true))); - assert_eq!(s.doc().unwrap().doc.node(NodeId(t)).unwrap().slice.as_ref().unwrap().kind, SliceKind::HtmlText); + assert_eq!(s.doc().unwrap().doc.node(NodeId(t)).unwrap().extras().slice.as_ref().unwrap().kind, SliceKind::HtmlText); } #[test] @@ -225,7 +225,7 @@ fn clip_to_artboard_hide_lock_select_and_delete_all() { assert_eq!(run(&mut s, "object.slice.deleteAll", json!({}))["count"], 2); assert_eq!(user_count(&s), 0); - assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().slice.is_none()); + assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().extras().slice.is_none()); assert!(s.execute("object.slice.deleteAll", &json!({})).is_err(), "nothing to delete"); } @@ -243,7 +243,7 @@ fn slices_survive_a_native_round_trip() { vectorcraft_testkit::invariants::check_native_roundtrip_exact(&doc).unwrap(); let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap(); assert_eq!(back.slices, doc.slices); - assert_eq!(back.node(NodeId(a)).unwrap().slice.as_ref().unwrap().alt, "logo"); + assert_eq!(back.node(NodeId(a)).unwrap().extras().slice.as_ref().unwrap().alt, "logo"); assert!(!back.slices_clip_to_artboard); // A copy of an object slice is an object slice too, under its own id. run(&mut s, "select.set", json!({"ids": [a]})); diff --git a/crates/engine/src/tests_stylelib.rs b/crates/engine/src/tests_stylelib.rs index 4ab70936..072668a6 100644 --- a/crates/engine/src/tests_stylelib.rs +++ b/crates/engine/src/tests_stylelib.rs @@ -107,7 +107,7 @@ fn add_with_apply_styles_the_selection_in_the_same_undo_step() { assert!((geom.start - Point::new(10.0 + 0.35 * 100.0, 20.0 + 0.3 * 50.0)).hypot() < 1e-6, "{geom:?}"); run(&mut s, "edit.undo", json!({})); assert!(doc(&s).graphic_style("Spotlight").is_none()); - assert_eq!(doc(&s).node(id).unwrap().graphic_style, None); + assert_eq!(doc(&s).node(id).unwrap().extras().graphic_style, None); // Without a selection it only adds; `ids` targets objects; an existing style is applied as is. run(&mut s, "select.none", json!({})); let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "blends-transparency", "name": "Ghosted", "apply": true})); @@ -224,7 +224,7 @@ fn every_builtin_style_paints_visibly_on_a_rectangle() { run(&mut s, "file.new", json!({"width": 100, "height": 100})); let id = rect(&mut s, 25.0, 30.0, 50.0, 40.0); run(&mut s, "graphicStyle.addFromLibrary", json!({"library": b.id, "name": g.name, "apply": true})); - assert_eq!(doc(&s).node(id).unwrap().graphic_style, doc(&s).graphic_style(&g.name).map(|x| x.id), "{}", g.name); + assert_eq!(doc(&s).node(id).unwrap().extras().graphic_style, doc(&s).graphic_style(&g.name).map(|x| x.id), "{}", g.name); let img = renderer.render_region(doc(&s), Rect::new(0.0, 0.0, 100.0, 100.0), 0.5, true); let painted = img.pixels.as_chunks::<4>().0.iter().filter(|p| p[..3] != [255, 255, 255]).count(); assert!(painted > 40, "{} / {} paints {painted} pixels", b.id, g.name); diff --git a/crates/engine/src/tests_styles.rs b/crates/engine/src/tests_styles.rs index bb1643e3..faa05f3a 100644 --- a/crates/engine/src/tests_styles.rs +++ b/crates/engine/src/tests_styles.rs @@ -68,7 +68,7 @@ fn styles_carry_opacity_and_blend_through_a_save() { let doc = s.doc().unwrap().doc.clone(); let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap(); assert_eq!(back.graphic_styles, doc.graphic_styles); - assert_eq!(back.node(b).unwrap().graphic_style, Some(g.id)); + assert_eq!(back.node(b).unwrap().extras().graphic_style, Some(g.id)); // Styles and objects from files without links still load (and get an id when first linked). let old: GraphicStyle = serde_json::from_value(json!({"name": "Old", "appearance": {"items": []}})).unwrap(); assert_eq!((old.id, old.opacity, old.blend, old.isolate), (0, 1.0, BlendMode::Normal, false)); @@ -106,7 +106,7 @@ fn a_style_from_a_group_or_type_is_not_empty() { run(&mut s, "graphicStyle.apply", json!({"name": "Black Outline", "ids": [g.0]})); assert_eq!(node(&s, g).appearance, style(&s, "Black Outline").appearance); assert_eq!(node(&s, a).appearance, before); - assert_eq!(node(&s, g).graphic_style, Some(style(&s, "Black Outline").id)); + assert_eq!(node(&s, g).extras().graphic_style, Some(style(&s, "Black Outline").id)); assert_eq!(linked(&mut s, "Black Outline"), vec![g.0]); assert_ne!(node(&s, c).appearance, style(&s, "Black Outline").appearance); run(&mut s, "graphicStyle.apply", json!({"name": "Black Outline", "ids": [g.0], "target": "contents"})); @@ -130,7 +130,7 @@ fn alt_apply_adds_the_style_on_top() { assert_eq!(after.items[..before.items.len()], before.items[..]); assert_eq!(after.items.last(), rough.appearance.items.last()); assert_eq!(after.effects.len(), before.effects.len() + 1); - assert_eq!(node(&s, b).graphic_style, None); + assert_eq!(node(&s, b).extras().graphic_style, None); // One undo step. run(&mut s, "edit.undo", json!({})); assert_eq!(node(&s, b).appearance, before); @@ -184,7 +184,7 @@ fn redefine_leaves_unlinked_and_edited_objects_alone() { let mut s = session(); let (a, b, c) = linked_three(&mut s); run(&mut s, "graphicStyle.breakLink", json!({"ids": [c.0]})); - assert_eq!(node(&s, c).graphic_style, None); + assert_eq!(node(&s, c).extras().graphic_style, None); let d = rect(&mut s, 300.0); run(&mut s, "graphicStyle.apply", json!({"name": "S"})); set_fill(&mut s, d, "#00ff00"); @@ -194,7 +194,7 @@ fn redefine_leaves_unlinked_and_edited_objects_alone() { assert_eq!(fill(&s, c), Paint::solid(Color::WHITE)); // The edited object keeps its look and is unlinked. assert_eq!(fill(&s, d), Paint::solid(Color::from_hex("#00ff00").unwrap())); - assert_eq!(node(&s, d).graphic_style, None); + assert_eq!(node(&s, d).extras().graphic_style, None); assert!(s.execute("graphicStyle.redefine", &json!({"id": c.0})).is_err()); } @@ -208,7 +208,7 @@ fn deleting_a_style_unlinks_and_renaming_keeps_links() { assert_eq!(run(&mut s, "select.same.graphicStyle", json!({}))["count"], 3); let look = node(&s, c).appearance; run(&mut s, "graphicStyle.delete", json!({"name": "T"})); - assert_eq!((node(&s, b).graphic_style, node(&s, c).graphic_style), (None, None)); + assert_eq!((node(&s, b).extras().graphic_style, node(&s, c).extras().graphic_style), (None, None)); assert_eq!(node(&s, c).appearance, look); run(&mut s, "select.set", json!({"ids": [b.0]})); assert!(s.execute("select.same.graphicStyle", &json!({})).is_err()); diff --git a/crates/engine/src/tests_targeting.rs b/crates/engine/src/tests_targeting.rs index 73e41f78..82abd73e 100644 --- a/crates/engine/src/tests_targeting.rs +++ b/crates/engine/src/tests_targeting.rs @@ -79,7 +79,7 @@ fn a_targeted_layer_gets_an_opacity_mask() { run(&mut s, "layer.target", json!({"id": layer.0})); assert_eq!(run(&mut s, "transparency.makeOpacityMask", json!({}))["id"], json!(layer.0)); run(&mut s, "transparency.stopEditingOpacityMask", json!({})); - assert!(node(&s, layer).mask.is_some()); + assert!(node(&s, layer).extras().mask.is_some()); assert_eq!(s.doc().unwrap().selection.target, Some(layer), "the masked layer stays targeted"); } diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index f61825a5..072a7497 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -1054,7 +1054,7 @@ impl<'a> Reader<'a> { true }); } - if let Some(m) = &mut n.mask { + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { self.reid(Arc::make_mut(&mut m.art)); } if let Some(children) = n.children_mut() { @@ -1437,7 +1437,7 @@ impl<'a> Reader<'a> { self.doc.images.entry(key.clone()).or_insert(blob); let gs = self.gs.clone(); let im = ImageObject { key, width: w as u32, height: h as u32, xf, link: None, placement: Default::default() }; - let mut node = self.new_node(NodeKind::Image(im), &gs); + let mut node = self.new_node(NodeKind::Image(Box::new(im)), &gs); node.opacity = gs.opacity; node.blend = gs.blend; self.add(node, false) diff --git a/crates/eps/src/import/data.rs b/crates/eps/src/import/data.rs index ec7c11dc..fa05ee13 100644 --- a/crates/eps/src/import/data.rs +++ b/crates/eps/src/import/data.rs @@ -973,7 +973,7 @@ impl Interp<'_> { self.out.doc.images.entry(key.clone()).or_insert(blob); let im = ImageObject { key, width: img.w, height: img.h, xf, link: None, placement: Default::default() }; let clips = self.g.clips.clone(); - self.out.push(Node::new(NodeId(0), NodeKind::Image(im)), &clips); + self.out.push(Node::new(NodeId(0), NodeKind::Image(Box::new(im))), &clips); Ok(()) } } diff --git a/crates/eps/src/import/preview.rs b/crates/eps/src/import/preview.rs index b19acfdb..046043c3 100644 --- a/crates/eps/src/import/preview.rs +++ b/crates/eps/src/import/preview.rs @@ -48,7 +48,8 @@ fn image_doc(img: image::RgbaImage, frame: Rect) -> Option { let key = blob.content_key(); doc.images.insert(key.clone(), blob); let xf = Affine::scale_non_uniform(frame.width() / f64::from(w), frame.height() / f64::from(h)); - let mut n = Node::new(doc.alloc_id(), NodeKind::Image(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() })); + let mut n = + Node::new(doc.alloc_id(), NodeKind::Image(Box::new(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() }))); n.name = Some("Preview".into()); let layer = doc.layers.first().map(|l| l.id)?; doc.insert(Some(layer), 0, n).ok()?; diff --git a/crates/eps/src/import/tests.rs b/crates/eps/src/import/tests.rs index 8408d030..77a02bb2 100644 --- a/crates/eps/src/import/tests.rs +++ b/crates/eps/src/import/tests.rs @@ -472,7 +472,7 @@ fn rich_doc() -> Document { link: None, placement: Default::default(), }; - let n = Node::new(d.alloc_id(), NodeKind::Image(im)); + let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); add(&mut d, n); d.swatches.push(Swatch { name: "Spot".into(), paint: Paint::solid(Color::cmyk(0.0, 0.5, 1.0, 0.0)), global: true, spot: true }); let spot = Paint::Solid { color: Color::cmyk(0.0, 0.5, 1.0, 0.0), swatch: Some("Spot".into()), tint: 1.0 }; diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index 99f4012a..54139c99 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -322,7 +322,7 @@ impl<'a> Scene<'a> { /// Warn about what EPS can't hold in `n`'s look. fn note_losses(&mut self, n: &Node) { - if n.opacity < 1.0 || n.blend != BlendMode::Normal || n.mask.as_ref().is_some_and(|m| !m.disabled) { + if n.opacity < 1.0 || n.blend != BlendMode::Normal || n.extras().mask.as_ref().is_some_and(|m| !m.disabled) { self.warn(TRANSPARENCY); } let raster = |fx: &[Effect]| fx.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)); diff --git a/crates/eps/src/tests.rs b/crates/eps/src/tests.rs index 567cdce9..b1d1cf9b 100644 --- a/crates/eps/src/tests.rs +++ b/crates/eps/src/tests.rs @@ -269,7 +269,7 @@ fn png_doc() -> Document { link: None, placement: Default::default(), }; - let n = Node::new(d.alloc_id(), NodeKind::Image(im)); + let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); let layer = d.layers[0].id; d.insert(Some(layer), 0, n).unwrap(); d diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index b0a7e3f3..bf859b73 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -605,7 +605,7 @@ mod tests { // Only images in use are saved. let img = d.alloc_id(); let im = ImageObject { key: "k".into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default() }; - d.insert(Some(l), 1, Node::new(img, NodeKind::Image(im))).unwrap(); + d.insert(Some(l), 1, Node::new(img, NodeKind::Image(Box::new(im)))).unwrap(); let bytes = save(&d, true); assert!(sniff(&bytes)); let back = load(&bytes).unwrap(); diff --git a/crates/format/tests/links.rs b/crates/format/tests/links.rs index e65685ef..8b8fff3a 100644 --- a/crates/format/tests/links.rs +++ b/crates/format/tests/links.rs @@ -13,7 +13,7 @@ use vectorcraft_geom::Affine; fn image(d: &mut Document, key: &str, link: Option) { let (id, layer) = (d.alloc_id(), d.layers[0].id); let im = ImageObject { key: key.into(), width: 600, height: 300, xf: Affine::IDENTITY, link, placement: Default::default() }; - d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(im))).unwrap(); + d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); } fn blob(full: &[u8], proxy: Option<&[u8]>) -> ImageBlob { diff --git a/crates/format/tests/native_v3.rs b/crates/format/tests/native_v3.rs index 7e643de4..fcbc43d3 100644 --- a/crates/format/tests/native_v3.rs +++ b/crates/format/tests/native_v3.rs @@ -35,7 +35,14 @@ fn image_node(d: &mut Document, key: &str) -> Node { let id = d.alloc_id(); Node::new( id, - NodeKind::Image(ImageObject { key: key.into(), width: 2, height: 2, xf: Affine::IDENTITY, link: None, placement: Default::default() }), + NodeKind::Image(Box::new(ImageObject { + key: key.into(), + width: 2, + height: 2, + xf: Affine::IDENTITY, + link: None, + placement: Default::default(), + })), ) } @@ -120,7 +127,7 @@ fn unused_images_are_pruned() { d.images.insert(k.into(), blob(b)); } let mut n = image_node(&mut d, "used"); - n.mask = Some(Box::new(OpacityMask::new(image_node(&mut d, "in mask"), false))); + n.extras_mut().mask = Some(Box::new(OpacityMask::new(image_node(&mut d, "in mask"), false))); d.insert(Some(layer), 0, n).unwrap(); let art = image_node(&mut d, "in symbol"); d.symbols.push(Symbol { name: "S".into(), art: Arc::new(art) }); diff --git a/crates/format/tests/native_v4.rs b/crates/format/tests/native_v4.rs index 34f6c6a8..57f29c27 100644 --- a/crates/format/tests/native_v4.rs +++ b/crates/format/tests/native_v4.rs @@ -18,7 +18,7 @@ fn doc_with_image(key: &str, len: usize) -> Document { let l = d.layers[0].id; let id = d.alloc_id(); let im = ImageObject { key: key.into(), width: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default() }; - d.insert(Some(l), 0, Node::new(id, NodeKind::Image(im))).unwrap(); + d.insert(Some(l), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); d } diff --git a/crates/format/tests/prop_format.rs b/crates/format/tests/prop_format.rs index 67159d78..365af85f 100644 --- a/crates/format/tests/prop_format.rs +++ b/crates/format/tests/prop_format.rs @@ -76,7 +76,7 @@ proptest! { let layer = d.layers[0].id; let id = d.alloc_id(); let im = ImageObject { key: key.clone(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default() }; - d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(im))).unwrap(); + d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); for pretty in [false, true] { let back = load(&save(&d, pretty)).unwrap(); prop_assert_eq!(back.images.get(&key).map(|b| b.bytes.as_ref().clone()), Some(data.clone())); diff --git a/crates/format/tests/save_options.rs b/crates/format/tests/save_options.rs index 766f4f67..44920d0b 100644 --- a/crates/format/tests/save_options.rs +++ b/crates/format/tests/save_options.rs @@ -29,7 +29,7 @@ fn linked_doc() -> Document { link: Some(LinkInfo::new("/art/photo.png")), placement: Default::default(), }; - d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(im))).unwrap(); + d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); let mut blob = ImageBlob::new("image/png", vec![1, 2, 3, 4, 5, 6]); blob.proxy = Some(Arc::new(vec![9, 9])); d.images.insert("k".into(), blob); diff --git a/crates/metafile/src/import.rs b/crates/metafile/src/import.rs index bee9d0a0..047a59f5 100644 --- a/crates/metafile/src/import.rs +++ b/crates/metafile/src/import.rs @@ -789,7 +789,7 @@ impl Player { return; } let im = ImageObject { key, width: img.width, height: img.height, xf, link: None, placement: Default::default() }; - let mut n = Node::new(vectorcraft_doc::NodeId(0), NodeKind::Image(im)); + let mut n = Node::new(vectorcraft_doc::NodeId(0), NodeKind::Image(Box::new(im))); n.opacity = opacity.clamp(0.0, 1.0) as f32; self.push(n); } diff --git a/crates/metafile/src/scene.rs b/crates/metafile/src/scene.rs index b19545b6..265cf66b 100644 --- a/crates/metafile/src/scene.rs +++ b/crates/metafile/src/scene.rs @@ -236,7 +236,7 @@ impl<'a> Scene<'a> { if n.blend != BlendMode::Normal { self.warn(BLENDING); } - if n.mask.as_ref().is_some_and(|m| !m.disabled) { + if n.extras().mask.as_ref().is_some_and(|m| !m.disabled) { self.warn(MASKS); } let raster = |fx: &[vectorcraft_doc::Effect]| fx.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)); diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index 878906ad..27d14fe1 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -49,7 +49,7 @@ pub(crate) fn image_node(d: &mut Document, bytes: Vec, w: u32, h: u32, xf: A let blob = ImageBlob::new("image/png", bytes); let key = blob.content_key(); d.images.insert(key.clone(), blob); - Node::new(NodeId(0), NodeKind::Image(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() })) + Node::new(NodeId(0), NodeKind::Image(Box::new(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() }))) } pub(crate) fn u32_at(b: &[u8], at: usize) -> u32 { diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index d0ed2471..15a1e213 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -880,7 +880,13 @@ impl Exporter<'_> { /// A luminosity mask that is 1 − the knockout shape of `c`: white, then black through an alpha /// mask of `c` (at full object opacity without its own mask, unless those define its shape). fn knockout_mask(&mut self, s: &mut Surface, c: &Node, page: Rect) -> krilla::mask::Mask { - let shape = if c.knockout_shape { c.clone() } else { Node { opacity: 1.0, mask: None, ..c.clone() } }; + let shape = if c.knockout_shape { + c.clone() + } else { + let mut s = c.clone().with_mask(None); + s.opacity = 1.0; + s + }; let mut sb = s.stream_builder(); let mut ms = sb.surface(); cover(&mut ms, page, 255, 1.0); @@ -948,7 +954,7 @@ impl Exporter<'_> { } pushes += 1; } - if let Some(m) = n.mask.as_deref() + if let Some(m) = n.extras().mask.as_deref() && !m.disabled { let mask = self.soft_mask(s, m, page); diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index d61e06f8..a65a42d6 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -1016,17 +1016,17 @@ impl<'p> Builder<'p> { MaskSpec::Mask(m) => { let m = self.fresh_mask(m.clone()); let node = match <[Arc; 1]>::try_from(art) { - Ok([one]) if one.mask.is_none() => { - fold_stop_opacity(&one, &m).unwrap_or_else(|| Node { mask: Some(Box::new(m)), ..Arc::unwrap_or_clone(one) }) + Ok([one]) if one.extras().mask.is_none() => { + fold_stop_opacity(&one, &m).unwrap_or_else(|| Arc::unwrap_or_clone(one).with_mask(Some(Box::new(m)))) } Ok([one]) => { let mut g = Node::group(self.id(), vec![one]); - g.mask = Some(Box::new(m)); + g.extras_mut().mask = Some(Box::new(m)); g } Err(all) => { let mut g = Node::group(self.id(), all); - g.mask = Some(Box::new(m)); + g.extras_mut().mask = Some(Box::new(m)); g } }; @@ -1089,7 +1089,7 @@ impl<'p> Builder<'p> { && !only.is_container() && !isolate && !knockout - && (mask.is_none() || only.mask.is_none()) + && (mask.is_none() || only.extras().mask.is_none()) { // A single object in a group: fold opacity, blend and mask into the object. let mut only = (**only).clone(); @@ -1099,7 +1099,7 @@ impl<'p> Builder<'p> { } if let Some(m) = mask { let m = self.fresh_mask(m); - only = fold_stop_opacity(&only, &m).unwrap_or(Node { mask: Some(Box::new(m)), ..only }); + only = fold_stop_opacity(&only, &m).unwrap_or_else(|| only.with_mask(Some(Box::new(m)))); } Some(only) } else { @@ -1108,7 +1108,7 @@ impl<'p> Builder<'p> { if knockout { g.knockout = Knockout::On; } - g.mask = mask.map(|m| Box::new(self.fresh_mask(m))); + g.extras_mut().mask = mask.map(|m| Box::new(self.fresh_mask(m))); Some(g) } } @@ -1289,7 +1289,10 @@ impl<'p> Builder<'p> { (k, a.width, a.height) }; let xf = transform * Affine::scale_non_uniform(f64::from(w) / f64::from(mw.max(1)), f64::from(h) / f64::from(mh.max(1))); - let art = Node::new(self.id(), NodeKind::Image(ImageObject { key: k, width: mw, height: mh, xf, link: None, placement: Default::default() })); + let art = Node::new( + self.id(), + NodeKind::Image(Box::new(ImageObject { key: k, width: mw, height: mh, xf, link: None, placement: Default::default() })), + ); Some(OpacityMask::new(art, true)) } @@ -1308,8 +1311,9 @@ impl<'p> Builder<'p> { (k, w, h) }; let id = self.id(); - let mut n = Node::new(id, NodeKind::Image(ImageObject { key: k, width: w, height: h, xf, link: None, placement: Default::default() })); - n.mask = mask.map(Box::new); + let mut n = + Node::new(id, NodeKind::Image(Box::new(ImageObject { key: k, width: w, height: h, xf, link: None, placement: Default::default() }))); + n.extras_mut().mask = mask.map(Box::new); self.push_node(n); } diff --git a/crates/pdf/src/import_lines.rs b/crates/pdf/src/import_lines.rs index 36cf5ea9..15294ad3 100644 --- a/crates/pdf/src/import_lines.rs +++ b/crates/pdf/src/import_lines.rs @@ -45,7 +45,7 @@ fn outline_stroke(nodes: &[Arc], ink: &[Rect]) -> Option { let NodeKind::Path { path, clipping: false, guide: false, live: None, .. } = &n.kind else { return false }; let b = path.to_bezpath().bounding_box(); n.appearance == first.appearance - && n.mask.is_none() + && n.extras().mask.is_none() && n.opacity >= 0.999 && n.blend == BlendMode::Normal && [(b.x0, r.x0), (b.y0, r.y0), (b.x1, r.x1), (b.y1, r.y1)].iter().all(|(a, b)| (a - b).abs() <= tol) @@ -87,7 +87,7 @@ fn join_strokes(nodes: &mut Vec>, lines: &HashMap) /// `n` as a line that can be part of area type: horizontal point type, with what its line told. fn line<'a>(n: &'a Node, lines: &'a HashMap) -> Option<(&'a TextObject, &'a LineFacts)> { let NodeKind::Text(t) = &n.kind else { return None }; - let point = matches!(t.kind, TextKind::Point) && !t.vertical && n.mask.is_none() && !t.runs.is_empty(); + let point = matches!(t.kind, TextKind::Point) && !t.vertical && n.extras().mask.is_none() && !t.runs.is_empty(); point.then_some(())?; Some((t, lines.get(&n.id)?)) } diff --git a/crates/pdf/src/import_mask.rs b/crates/pdf/src/import_mask.rs index eda4bdb4..9bdea94d 100644 --- a/crates/pdf/src/import_mask.rs +++ b/crates/pdf/src/import_mask.rs @@ -39,7 +39,7 @@ fn cover(n: &Node, page: Rect) -> Option<(Color, f32)> { let Paint::Solid { color, .. } = &f.paint else { return None }; let b = path.to_bezpath(); let covers = page.area() > 1.0 && contains(b.bounding_box(), page.inset(-0.5)) && is_rectangle(&b); - (covers && n.mask.is_none()).then_some((*color, f.opacity * n.opacity)) + (covers && n.extras().mask.is_none()).then_some((*color, f.opacity * n.opacity)) } /// An opaque white rectangle covering `page`, filled only. diff --git a/crates/pdf/src/import_shading.rs b/crates/pdf/src/import_shading.rs index b3356e6c..6330184e 100644 --- a/crates/pdf/src/import_shading.rs +++ b/crates/pdf/src/import_shading.rs @@ -195,7 +195,7 @@ fn only_leaf(n: &Node) -> Option<&Node> { NodeKind::Group { children, clip } => { let rest = if *clip { children.get(1..)? } else { children.as_slice() }; match rest { - [one] if n.opacity >= 0.999 && n.mask.is_none() => only_leaf(one), + [one] if n.opacity >= 0.999 && n.extras().mask.is_none() => only_leaf(one), _ => None, } } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 99c69e7d..f80b7dd8 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -378,7 +378,7 @@ fn image_roundtrip() { link: None, placement: Default::default(), }; - add(&mut d, Node::new(NodeId(0), NodeKind::Image(im))); + add(&mut d, Node::new(NodeId(0), NodeKind::Image(Box::new(im)))); let out = roundtrip(&d); let l = leaves(&out); assert_eq!(l.len(), 1); @@ -606,7 +606,7 @@ fn opacity_mask_exports_as_luminosity_soft_mask() { let mut d = doc(100.0, 100.0); let mut n = rect_node(Rect::new(10.0, 10.0, 90.0, 90.0), Color::rgb(1.0, 0.0, 0.0)); let art = rect_node(Rect::new(10.0, 10.0, 50.0, 90.0), Color::WHITE); - n.mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(art, true))); + n.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(art, true))); add(&mut d, n); let bytes = export(&d, &PdfOptions::uncompressed()).unwrap(); let text = String::from_utf8_lossy(&bytes); diff --git a/crates/pdf/src/tests_cmykblend.rs b/crates/pdf/src/tests_cmykblend.rs index c450b7e0..f76d9081 100644 --- a/crates/pdf/src/tests_cmykblend.rs +++ b/crates/pdf/src/tests_cmykblend.rs @@ -70,7 +70,7 @@ fn rgb_documents_and_opaque_cmyk_pages_are_unchanged() { fn soft_mask_groups_keep_screen_luminance() { let mut d = Document::new_with_mode(100.0, 100.0, ColorMode::Cmyk); let mut n = rect(Rect::new(0.0, 0.0, 100.0, 100.0), Color::cmyk(0.0, 0.0, 0.0, 1.0)); - n.mask = Some(Box::new(OpacityMask::new(rect(Rect::new(0.0, 0.0, 50.0, 100.0), Color::WHITE), true))); + n.extras_mut().mask = Some(Box::new(OpacityMask::new(rect(Rect::new(0.0, 0.0, 50.0, 100.0), Color::WHITE), true))); add(&mut d, n); let t = text(&d); // The page's group blends in CMYK; the mask's group measures luminance in RGB. diff --git a/crates/pdf/src/tests_cmykimages.rs b/crates/pdf/src/tests_cmykimages.rs index 7ca5acfe..cfbced73 100644 --- a/crates/pdf/src/tests_cmykimages.rs +++ b/crates/pdf/src/tests_cmykimages.rs @@ -229,7 +229,7 @@ fn masked(d: &Document) -> Vec<(&ImageBlob, Option>)> { .iter() .filter_map(|n| { let NodeKind::Image(im) = &n.kind else { return None }; - let mask = n.mask.as_ref().map(|m| match &m.art.kind { + let mask = n.extras().mask.as_ref().map(|m| match &m.art.kind { NodeKind::Image(a) => (&d.images[&a.key], a.width, a.height), other => panic!("mask art is an image: {other:?}"), }); @@ -266,7 +266,7 @@ fn masked_cmyk_images_keep_their_inks_and_carry_the_mask_as_an_opacity_mask() { // The mask art covers the image exactly. let NodeKind::Layer { children, .. } = &r.document.layers[0].kind else { panic!() }; let img = children.iter().find(|n| matches!(n.kind, NodeKind::Image(_))).unwrap(); - assert_eq!(img.geometric_bounds(), img.mask.as_ref().unwrap().art.geometric_bounds()); + assert_eq!(img.geometric_bounds(), img.extras().mask.as_ref().unwrap().art.geometric_bounds()); // Written as CMYK with the mask in grey; it opens with the same inks. let out = export_with_report(&r.document, &opts(json!({}))).unwrap(); let w = written(&out.bytes); @@ -291,7 +291,7 @@ fn all_images(d: &Document) -> Vec<&ImageBlob> { if let NodeKind::Image(im) = &n.kind { keys.push(im.key.clone()); } - if let Some(m) = &n.mask { + if let Some(m) = &n.extras().mask { m.art.walk(&mut |a| { if let NodeKind::Image(im) = &a.kind { keys.push(im.key.clone()); @@ -307,7 +307,7 @@ fn image_doc(blob: ImageBlob, w: u32, h: u32) -> Document { let mut d = Document::new_with_mode(100.0, 100.0, ColorMode::Cmyk); d.images.insert("img".into(), blob); let image = ImageObject { key: "img".into(), width: w, height: h, xf: Affine::scale(40.0 / w as f64), link: None, placement: Default::default() }; - let n = Node::new(d.alloc_id(), NodeKind::Image(image)); + let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image))); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); d diff --git a/crates/pdf/src/tests_compression.rs b/crates/pdf/src/tests_compression.rs index 596984cc..2954f91c 100644 --- a/crates/pdf/src/tests_compression.rs +++ b/crates/pdf/src/tests_compression.rs @@ -29,7 +29,7 @@ fn doc(bytes: Vec, mime: &str) -> Document { let xf = Affine::scale(72.0 / 600.0); let n = Node::new( d.alloc_id(), - NodeKind::Image(ImageObject { key: "img".into(), width: 600, height: 600, xf, link: None, placement: Default::default() }), + NodeKind::Image(Box::new(ImageObject { key: "img".into(), width: 600, height: 600, xf, link: None, placement: Default::default() })), ); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); diff --git a/crates/pdf/src/tests_import_fidelity.rs b/crates/pdf/src/tests_import_fidelity.rs index 0721fef3..a05176f2 100644 --- a/crates/pdf/src/tests_import_fidelity.rs +++ b/crates/pdf/src/tests_import_fidelity.rs @@ -72,8 +72,8 @@ fn masked(smask: &str, mask: &str) -> Vec { } fn the_mask(d: &Document) -> (Node, OpacityMask) { - let n = all(d).into_iter().find(|n| n.mask.is_some()).expect("a masked object"); - let m = n.mask.as_deref().unwrap().clone(); + let n = all(d).into_iter().find(|n| n.extras().mask.is_some()).expect("a masked object"); + let m = n.extras().mask.as_deref().unwrap().clone(); (n, m) } @@ -121,7 +121,7 @@ fn opacity_masks_round_trip_through_pdf() { let mut d = Document::new(100.0, 100.0); let mut n = rect(Rect::new(10.0, 10.0, 90.0, 90.0), Color::rgb(1.0, 0.0, 0.0)); let art = rect(Rect::new(10.0, 10.0, 50.0, 90.0), Color::gray(0.25)); - n.mask = Some(Box::new(OpacityMask { invert, ..OpacityMask::new(art, clip) })); + n.extras_mut().mask = Some(Box::new(OpacityMask { invert, ..OpacityMask::new(art, clip) })); add(&mut d, n); let back = roundtrip(&d); let (n, m) = the_mask(&back); @@ -152,14 +152,14 @@ fn isolated_and_non_isolated_groups_round_trip() { let back = roundtrip(&half_group(true)); let groups: Vec = all(&back).into_iter().filter(|n| matches!(n.kind, NodeKind::Group { clip: false, .. })).collect(); let [g] = groups.as_slice() else { panic!("one group: {groups:?}") }; - assert!(g.isolate && g.mask.is_none()); + assert!(g.isolate && g.extras().mask.is_none()); assert!((g.opacity - 0.5).abs() < 0.01, "{}", g.opacity); assert_eq!(g.children().unwrap()[0].blend, BlendMode::Multiply); // Not isolated: its 50% (a constant alpha mask in the file) is opacity again, here on the // only object inside, which blends with the backdrop as before. let back = roundtrip(&half_group(false)); let nodes = all(&back); - assert!(nodes.iter().all(|n| !n.isolate && n.mask.is_none())); + assert!(nodes.iter().all(|n| !n.isolate && n.extras().mask.is_none())); let m = nodes.iter().find(|n| n.blend == BlendMode::Multiply).unwrap(); let opacity: f32 = nodes.iter().filter(|n| n.children().is_some_and(|c| c.iter().any(|c| c.id == m.id))).map(|g| g.opacity).product::() * m.opacity; @@ -320,7 +320,7 @@ fn gradient_stop_opacity_round_trips() { add(&mut d, n); let back = roundtrip(&d); let nodes = all(&back); - assert!(nodes.iter().all(|n| n.mask.is_none()), "the opacity is in the stops, not a mask"); + assert!(nodes.iter().all(|n| n.extras().mask.is_none()), "the opacity is in the stops, not a mask"); let g = nodes.iter().find_map(|n| match n.appearance.fill().map(|f| &f.paint) { Some(Paint::Gradient(g)) => Some(g.gradient.clone()), _ => None, @@ -358,7 +358,7 @@ fn appearance_items_are_untouched_for_plain_art() { let d = open(&one_page("q 1 0 0 rg 10 10 30 30 re f Q 0 0 1 RG 2 w 50 50 30 30 re S", "", &[])); let nodes = all(&d); assert_eq!(nodes.iter().filter(|n| n.path_data().is_some()).count(), 2); - assert!(nodes.iter().all(|n| n.mask.is_none() && !matches!(n.kind, NodeKind::Group { .. }))); + assert!(nodes.iter().all(|n| n.extras().mask.is_none() && !matches!(n.kind, NodeKind::Group { .. }))); assert!(matches!(nodes.iter().find(|n| n.appearance.stroke().is_some()).unwrap().appearance.items[0], AppearanceItem::Stroke(_))); } diff --git a/crates/pdf/src/tests_output.rs b/crates/pdf/src/tests_output.rs index 442b4290..3413816a 100644 --- a/crates/pdf/src/tests_output.rs +++ b/crates/pdf/src/tests_output.rs @@ -198,7 +198,7 @@ fn image_doc(bytes: Vec) -> Document { let mut d = Document::new(100.0, 100.0); d.images.insert("img".into(), ImageBlob::new("image/png", bytes)); let image = ImageObject { key: "img".into(), width: 4, height: 4, xf: Affine::scale(10.0), link: None, placement: Default::default() }; - let n = Node::new(d.alloc_id(), NodeKind::Image(image)); + let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image))); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); d diff --git a/crates/pdf/src/tests_pdfx.rs b/crates/pdf/src/tests_pdfx.rs index dd4a48dd..d7dbee69 100644 --- a/crates/pdf/src/tests_pdfx.rs +++ b/crates/pdf/src/tests_pdfx.rs @@ -123,7 +123,7 @@ fn pdfx_1a_files_have_no_rgb() { link: None, placement: Default::default(), }; - let n = Node::new(d.alloc_id(), NodeKind::Image(im)); + let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); d.insert(Some(l), 0, n).unwrap(); let t = text(&export(&d, &opts(Standard::PdfX1a, |_| {})).unwrap()); for no in ["/DeviceRGB", "/ICCBased", "/Lab", "/CalRGB"] { diff --git a/crates/pdf/src/tests_webview.rs b/crates/pdf/src/tests_webview.rs index d4dd3611..dcee6baa 100644 --- a/crates/pdf/src/tests_webview.rs +++ b/crates/pdf/src/tests_webview.rs @@ -49,7 +49,7 @@ fn doc(n: usize, image: bool, on_first: bool) -> Document { let xf = Affine::translate((x + 60.0, 60.0)) * Affine::scale(4.0); let img = ImageObject { key: "img".into(), width: 8, height: 8, xf, link: None, placement: Default::default() }; let id = d.alloc_id(); - d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(img))).unwrap(); + d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(img)))).unwrap(); } } d diff --git a/crates/render/src/encode/psd.rs b/crates/render/src/encode/psd.rs index 304e53a5..f03e7378 100644 --- a/crates/render/src/encode/psd.rs +++ b/crates/render/src/encode/psd.rs @@ -520,7 +520,7 @@ impl Parts<'_> { /// Can layer `n` be written as a group of its parts without changing its look? fn splits(n: &Node) -> bool { - matches!(n.kind, NodeKind::Layer { clip: false, .. }) && n.mask.is_none() && n.knockout != Knockout::On && !fx::has_object_fx(n) + matches!(n.kind, NodeKind::Layer { clip: false, .. }) && n.extras().mask.is_none() && n.knockout != Knockout::On && !fx::has_object_fx(n) } /// Does anything in `n` carry effects of its own that may paint outside the art's bounds? diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index db417efd..276accfe 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -505,16 +505,14 @@ impl Renderer { // A path's geometry effects still apply when it is drawn. let path = matches!(art.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }); art.appearance.effects.retain(|e| path && effects::is_geometry(&e.id)); - let art = Arc::new(Node { - opacity: 1.0, - blend: Default::default(), - isolate: false, - // One element: its pieces never knock each other out (a group's members still do as - // its knockout setting says). - knockout: if container { a.knockout } else { vectorcraft_doc::Knockout::Off }, - mask: None, - ..art - }); + let mut art = art.with_mask(None); + art.opacity = 1.0; + art.blend = Default::default(); + art.isolate = false; + // One element: its pieces never knock each other out (a group's members still do as its + // knockout setting says). + art.knockout = if container { a.knockout } else { vectorcraft_doc::Knockout::Off }; + let art = Arc::new(art); if cache { let bytes = crate::lru::node_bytes(&art); self.fx_arts.insert(key, (a.clone(), art.clone()), bytes, self.stamp); diff --git a/crates/render/src/ink.rs b/crates/render/src/ink.rs index 48e0fe49..6e86e1d5 100644 --- a/crates/render/src/ink.rs +++ b/crates/render/src/ink.rs @@ -327,7 +327,7 @@ mod tests { d.images.insert("img".into(), ImageBlob::cmyk_tiff(&Inks::new(2, 2, ink.repeat(4)).unwrap()).unwrap()); let l = d.layers[0].id; let im = ImageObject { key: "img".into(), width: 2, height: 2, xf: Affine::scale(5.0), link: None, placement: Default::default() }; - let n = Node::new(d.alloc_id(), NodeKind::Image(im)); + let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); d.insert(Some(l), 0, n).unwrap(); // The same inks as a fill beside it. let fill = Color::cmyk(0.0, 0.4, 1.0, 25.0 / 255.0); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index b62f06ce..5410ddd7 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -552,7 +552,7 @@ impl Renderer { let frame = Frame { mt: threads > 0, doc, view, visible, px, opts, ink: Ink::Display }; self.begin_frame(); // View Opacity Mask: the mask's coverage in greyscale instead of the artwork. - if let Some(m) = opts.mask_view.and_then(|id| doc.node(id)).and_then(|n| n.mask.as_deref()) { + if let Some(m) = opts.mask_view.and_then(|id| doc.node(id)).and_then(|n| n.extras().mask.as_deref()) { let pixels = self.mask_values(&frame, m, w, h).into_iter().flat_map(|v| [v, v, v, 255]).collect(); self.end_frame(); self.stats.micros = now().saturating_sub(start); @@ -790,7 +790,7 @@ impl Renderer { NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } if !fx::has_object_fx(a) => { let mut acc: Option = None; // An Image Trace object's hidden source image shows in some of its views. - let source = a.trace.is_some().then(|| children.first()).flatten(); + let source = a.extras().trace.is_some().then(|| children.first()).flatten(); for c in children { if c.visible || source.is_some_and(|s| Arc::ptr_eq(s, c)) { acc = vectorcraft_geom::union_opt(acc, self.bounds_of(c)); @@ -873,7 +873,7 @@ impl Renderer { if self.skipped(f, a) { return; } - if let Some(m) = a.mask.as_deref() + if let Some(m) = a.extras().mask.as_deref() && !m.disabled && !f.opts.outline && !self.is_shape(a) diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index b94ce1f3..70533ae8 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -221,7 +221,7 @@ fn masked_doc(clip: bool, invert: bool) -> Document { let art = Node::group(gid, vec![Arc::new(white), Arc::new(grey)]); let mut m = vectorcraft_doc::OpacityMask::new(art, clip); m.invert = invert; - n.mask = Some(Box::new(m)); + n.extras_mut().mask = Some(Box::new(m)); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); d @@ -256,7 +256,7 @@ fn disabled_or_outline_mask_draws_unmasked() { let mut d = masked_doc(true, false); let id = d.layers[0].children().unwrap()[0].id; let n = d.node_mut(id).unwrap(); - n.mask.as_mut().unwrap().disabled = true; + n.extras_mut().mask.as_mut().unwrap().disabled = true; let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts); assert_eq!(img.pixel(80, 50), [255, 0, 0, 255]); @@ -376,7 +376,7 @@ fn images_take_their_nearest_pixel_without_smoothing() { d.images.insert("bw".into(), ImageBlob::new("image/png", png)); let id = d.alloc_id(); let im = ImageObject { key: "bw".into(), width: 2, height: 1, xf: Affine::scale(20.0), link: None, placement: Default::default() }; - d.insert(Some(d.layers[0].id), 0, Node::new(id, NodeKind::Image(im))).unwrap(); + d.insert(Some(d.layers[0].id), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); // How many pixels across the image are neither black nor white. let greys = |smooth_images| { let r = Renderer::new().render(&d, 40, 20, Affine::IDENTITY, &RenderOptions { smooth_images, ..Default::default() }); diff --git a/crates/render/src/tests_adjust.rs b/crates/render/src/tests_adjust.rs index 8e5690a6..5ff5a35d 100644 --- a/crates/render/src/tests_adjust.rs +++ b/crates/render/src/tests_adjust.rs @@ -86,7 +86,14 @@ fn an_embedded_image_is_recoloured_and_the_original_pixels_stay() { d.images.insert(key.clone(), blob.clone()); let mut n = Node::new( NodeId(0), - NodeKind::Image(ImageObject { key, width: 30, height: 30, xf: Affine::translate((20.0, 20.0)), link: None, placement: Default::default() }), + NodeKind::Image(Box::new(ImageObject { + key, + width: 30, + height: 30, + xf: Affine::translate((20.0, 20.0)), + link: None, + placement: Default::default(), + })), ); n.appearance.effects.push(hue()); d = { diff --git a/crates/render/src/tests_cmykblend.rs b/crates/render/src/tests_cmykblend.rs index 1eda1150..63b765a4 100644 --- a/crates/render/src/tests_cmykblend.rs +++ b/crates/render/src/tests_cmykblend.rs @@ -99,7 +99,7 @@ fn opacity_masks_take_screen_luminance() { let mut d = Document::new_with_mode(20.0, 20.0, ColorMode::Cmyk); let mut n = rect(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0), Color::cmyk(0.0, 0.0, 0.0, 1.0)); let art = rect(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0), CYAN); - n.mask = Some(Box::new(OpacityMask::new(art, true))); + n.extras_mut().mask = Some(Box::new(OpacityMask::new(art, true))); add(&mut d, n); let [r, g, b] = CYAN.to_rgb(); let cover = MASK_LUM[0] * r + MASK_LUM[1] * g + MASK_LUM[2] * b; @@ -116,7 +116,14 @@ fn placed_images_are_separated_not_blackened() { d.images.insert("red".into(), ImageBlob::new("image/png", png)); let n = Node::new( d.alloc_id(), - NodeKind::Image(ImageObject { key: "red".into(), width: 4, height: 4, xf: Affine::IDENTITY, link: None, placement: Default::default() }), + NodeKind::Image(Box::new(ImageObject { + key: "red".into(), + width: 4, + height: 4, + xf: Affine::IDENTITY, + link: None, + placement: Default::default(), + })), ); add(&mut d, n); let got = Renderer::new().render_region(&d, d.artboards[0].rect, 1.0, true).pixel(1, 1); diff --git a/crates/render/src/tests_isolation.rs b/crates/render/src/tests_isolation.rs index 138bb14b..b38ee470 100644 --- a/crates/render/src/tests_isolation.rs +++ b/crates/render/src/tests_isolation.rs @@ -138,7 +138,7 @@ fn a_masked_object_blends_with_the_backdrop() { let mut g = group(d, vec![m], 1.0, Blend::Normal, false); // A 50% grey mask over the whole group. let art = rect(d, Rect::new(0.0, 0.0, 100.0, 100.0), [0.5; 3]); - g.mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(art, true))); + g.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(art, true))); g }); assert_near(render(&d).pixel(50, 50), lerp(B, blend_rgb(Blend::Multiply, B, S), 0.5), "masked multiply"); diff --git a/crates/render/src/tests_knockout.rs b/crates/render/src/tests_knockout.rs index a5176608..d364080f 100644 --- a/crates/render/src/tests_knockout.rs +++ b/crates/render/src/tests_knockout.rs @@ -77,7 +77,7 @@ fn opacity_defines_the_knockout_shape() { let blue = Arc::make_mut(&mut g.children_mut().unwrap()[1]); blue.opacity = 1.0; blue.knockout_shape = shape; - blue.mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(white, true))); + blue.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(white, true))); })); let want = if shape { HALF_KNOCKED } else { BLUE_OVER_WHITE }; // A mid-grey mask is ~50% (luminance 0.5, not gamma-corrected): allow a little more slack. diff --git a/crates/render/src/tests_layeropts.rs b/crates/render/src/tests_layeropts.rs index db8b6eb2..c20e191d 100644 --- a/crates/render/src/tests_layeropts.rs +++ b/crates/render/src/tests_layeropts.rs @@ -20,14 +20,14 @@ fn doc(layer: impl FnOnce(&mut NodeKind)) -> Document { d.insert(Some(l), 0, sq).unwrap(); let mut im = Node::new( NodeId(0), - NodeKind::Image(ImageObject { + NodeKind::Image(Box::new(ImageObject { key: "red".into(), width: 30, height: 30, xf: Affine::translate((50.0, 10.0)), link: None, placement: Default::default(), - }), + })), ); im.id = d.alloc_id(); d.insert(Some(l), 9, im).unwrap(); diff --git a/crates/render/src/tests_memory.rs b/crates/render/src/tests_memory.rs index e9b9d7b2..acf5848c 100644 --- a/crates/render/src/tests_memory.rs +++ b/crates/render/src/tests_memory.rs @@ -24,7 +24,7 @@ fn doc_with_image(key: &str, bytes: Vec, w: u32, h: u32) -> Document { d.images.insert(key.into(), ImageBlob::new("image/png", bytes)); let (id, layer) = (d.alloc_id(), d.layers[0].id); let im = ImageObject { key: key.into(), width: w, height: h, xf: Affine::IDENTITY, link: None, placement: Default::default() }; - d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(im))).unwrap(); + d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); d } diff --git a/crates/render/src/tests_objectfx.rs b/crates/render/src/tests_objectfx.rs index 9041b107..0f69e3f7 100644 --- a/crates/render/src/tests_objectfx.rs +++ b/crates/render/src/tests_objectfx.rs @@ -112,14 +112,14 @@ fn image_doc(effects: Vec) -> Document { d.images.insert("red".into(), ImageBlob::new("image/png", png)); let mut n = Node::new( NodeId(0), - NodeKind::Image(ImageObject { + NodeKind::Image(Box::new(ImageObject { key: "red".into(), width: 30, height: 30, xf: Affine::translate((20.0, 20.0)), link: None, placement: Default::default(), - }), + })), ); n.appearance.effects = effects; doc_with(d, n) diff --git a/crates/render/src/tests_setup.rs b/crates/render/src/tests_setup.rs index d64ffcdd..e8129f46 100644 --- a/crates/render/src/tests_setup.rs +++ b/crates/render/src/tests_setup.rs @@ -11,7 +11,7 @@ fn doc_with_red_image() -> Document { d.images.insert("red".into(), ImageBlob::new("image/png", png)); let (id, layer) = (d.alloc_id(), d.layers[0].id); let im = ImageObject { key: "red".into(), width: 40, height: 40, xf: Affine::IDENTITY, link: None, placement: Default::default() }; - d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(im))).unwrap(); + d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); d } diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index 8775a97d..58dc6508 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -312,7 +312,8 @@ impl Rules<'_> { if !n.visible { return; } - let plain_group = n.name.is_none() && transparency(n).is_empty() && !n.appearance.effects.iter().any(|e| e.visible) && n.mask.is_none(); + let plain_group = + n.name.is_none() && transparency(n).is_empty() && !n.appearance.effects.iter().any(|e| e.visible) && n.extras().mask.is_none(); match &n.kind { NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. } => {} NodeKind::Layer { children, clip: false, .. } => children.iter().for_each(|c| self.object(c)), @@ -394,7 +395,7 @@ impl Rules<'_> { let mut radius = None; let text = match &n.kind { NodeKind::Path { path, live, .. } => { - match corners(path, live.as_ref()) { + match corners(path, live.as_deref()) { Some(Corners::Square) => {} Some(Corners::Round(r)) => radius = Some(r.iter().map(|r| self.len(*r)).collect::>().join(" ")), Some(Corners::Ellipse) => radius = Some("50%".into()), @@ -420,7 +421,7 @@ impl Rules<'_> { } art.props.extend(radius.map(|r| ("border-radius", r))); self.effects(n, text, &mut art); - if n.mask.as_ref().is_some_and(|m| !m.disabled) { + if n.extras().mask.as_ref().is_some_and(|m| !m.disabled) { art.unsupported("an opacity mask"); } art diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index ef558c88..702fe9ff 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -356,7 +356,7 @@ impl Reuse { fn scan(&mut self, doc: &Document, n: &Node, depth: u32) { let pattern = |p: &Paint| matches!(p, Paint::Pattern { .. }); let text_patterns = matches!(&n.kind, NodeKind::Text(t) if t.runs.iter().any(|r| pattern(&r.style.fill) || pattern(&r.style.stroke))); - let unlinked_mask = n.mask.as_ref().is_some_and(|m| !m.linked); + let unlinked_mask = n.extras().mask.as_ref().is_some_and(|m| !m.linked); if depth > MAX_SYMBOL_NEST || text_patterns || unlinked_mask || vectorcraft_doc::pattern::uses_pattern(n, None) { *self = Self::NONE; return; @@ -387,7 +387,7 @@ impl Reuse { } _ => {} } - if let Some(m) = &n.mask { + if let Some(m) = &n.extras().mask { self.scan(doc, &m.art, depth); } for c in n.children().into_iter().flatten() { @@ -412,7 +412,7 @@ impl Reuse { /// and the art of unlinked opacity masks. fn pin_to_page(n: &mut Node, m: Affine) { vectorcraft_doc::pattern::transform_pattern_paints(n, m); - if let Some(mask) = n.mask.as_deref_mut() { + if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) { let linked = mask.linked; let art = std::sync::Arc::make_mut(&mut mask.art); if !linked { @@ -600,7 +600,7 @@ impl Writer<'_> { out.push_str(&format!(" {}=\"sublayer\"", crate::import::SUBLAYER)); } let mut keys = HashSet::new(); - for (k, v) in n.attrs.as_deref().map_or(&[][..], |a| a.data.as_slice()) { + for (k, v) in n.extras().attrs.as_deref().map_or(&[][..], |a| a.data.as_slice()) { // Ours (`name`, `vc-…`) are written by the export itself. if k == "name" || k.starts_with("vc-") { continue; @@ -1155,7 +1155,7 @@ impl Writer<'_> { if self.tiny() { self.warn(TINY_MASKS); let mut bare = n.clone(); - bare.mask = None; + bare.extras_mut().mask = None; return self.node_body(&bare); } let mid = self.fresh_id("mask"); @@ -1189,7 +1189,7 @@ impl Writer<'_> { self.line(&format!("")); self.depth += 1; let mut bare = n.clone(); - bare.mask = None; + bare.extras_mut().mask = None; self.node_body(&bare); self.depth -= 1; self.line(""); @@ -1263,7 +1263,13 @@ impl Writer<'_> { f } }; - let shape = if c.knockout_shape { c.clone() } else { Node { opacity: 1.0, mask: None, ..c.clone() } }; + let shape = if c.knockout_shape { + c.clone() + } else { + let mut s = c.clone().with_mask(None); + s.opacity = 1.0; + s + }; let anonymous = std::mem::replace(&mut self.anonymous, true); let art = self.detached(3, |w| w.node(&shape)); self.anonymous = anonymous; @@ -1303,7 +1309,7 @@ impl Writer<'_> { if has_raster(&n.appearance.effects) { return self.filtered(n); } - if let Some(m) = n.mask.as_deref() + if let Some(m) = n.extras().mask.as_deref() && !m.disabled { return self.masked(n, m); @@ -1720,7 +1726,8 @@ impl Writer<'_> { }; glyphs(self, below); // The group carries the id and the object's transparency. - let bare = Node { opacity: 1.0, blend: BlendMode::Normal, isolate: false, ..n.clone() }; + let mut bare = n.clone(); + (bare.opacity, bare.blend, bare.isolate) = (1.0, BlendMode::Normal, false); let anonymous = std::mem::replace(&mut self.anonymous, true); chars(self, &bare); for ig in &lay.inlines { diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 67983cfb..39204efd 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -301,7 +301,7 @@ struct SymbolMade { fn without_ids(n: &Node) -> Node { let mut n = n.clone(); n.id = vectorcraft_doc::NodeId(0); - if let Some(m) = n.mask.as_deref_mut() { + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) { m.art = Arc::new(without_ids(&m.art)); } for c in n.children_mut().into_iter().flatten() { @@ -713,7 +713,7 @@ impl Importer { /// Give `n` the `data-*` attributes of element `id`, unless it has data already (what an /// inner element made of it keeps). fn keep_data(&self, id: &str, n: &mut Node) { - if let Some(data) = self.data.get(id).filter(|_| n.attrs.as_ref().is_none_or(|a| a.data.is_empty())) { + if let Some(data) = self.data.get(id).filter(|_| n.extras().attrs.as_ref().is_none_or(|a| a.data.is_empty())) { n.edit_attrs(|a| a.data = data.clone()); } } @@ -799,7 +799,7 @@ impl Importer { n.opacity = g.opacity().get(); n.blend = blend(g.blend_mode()); n.isolate = g.isolate(); - n.mask = mask; + n.extras_mut().mask = mask; Some(n) } @@ -912,7 +912,7 @@ impl Importer { if (is_text || id.is_empty() && mask.is_none()) && children.len() == 1 && (g.blend_mode() == usvg::BlendMode::Normal || children[0].blend == BlendMode::Normal) - && (effects.is_empty() || children[0].blend == BlendMode::Normal && children[0].mask.is_none()) + && (effects.is_empty() || children[0].blend == BlendMode::Normal && children[0].extras().mask.is_none()) && let Some(only) = children.pop() { let mut c = Arc::unwrap_or_clone(only); @@ -922,7 +922,7 @@ impl Importer { } c.isolate |= g.isolate(); if mask.is_some() { - c.mask = mask; + c.extras_mut().mask = mask; } c.appearance.effects.extend(effects); return Some(c); @@ -934,7 +934,7 @@ impl Importer { n.opacity = g.opacity().get(); n.blend = blend(g.blend_mode()); n.isolate = g.isolate(); - n.mask = mask; + n.extras_mut().mask = mask; if !is_text && self.sublayers.contains(g.id()) { self.sublayer_groups.insert(n.id); } @@ -1229,7 +1229,7 @@ impl Importer { if link.is_some() && !missing { blob.with_proxy() } else { blob } }); let xf = acc * Affine::scale_non_uniform(size.width() as f64 / pw as f64, size.height() as f64 / ph as f64); - Some(self.named(i.id(), NodeKind::Image(ImageObject { key, width: pw, height: ph, xf, link, placement: Default::default() }))) + Some(self.named(i.id(), NodeKind::Image(Box::new(ImageObject { key, width: pw, height: ph, xf, link, placement: Default::default() })))) } /// An SVG shown as an image (a data URL or a linked file) → its art, `acc` mapping its size. diff --git a/crates/svg/tests/blend_export.rs b/crates/svg/tests/blend_export.rs index 7cf88f62..ec50a4f4 100644 --- a/crates/svg/tests/blend_export.rs +++ b/crates/svg/tests/blend_export.rs @@ -72,7 +72,7 @@ fn masked(clip: bool, invert: bool) -> Document { let mut red = rect(&mut d, Rect::new(0.0, 0.0, 40.0, 20.0), [255, 0, 0]); let mut m = OpacityMask::new(art, clip); m.invert = invert; - red.mask = Some(Box::new(m)); + red.extras_mut().mask = Some(Box::new(m)); add(&mut d, red); d } diff --git a/crates/svg/tests/css_rules.rs b/crates/svg/tests/css_rules.rs index 4004c04c..e50d29a0 100644 --- a/crates/svg/tests/css_rules.rs +++ b/crates/svg/tests/css_rules.rs @@ -30,7 +30,7 @@ fn rect(d: &mut Document, (x, y, w, h): (f64, f64, f64, f64), radius: f64) -> No Appearance::basic(fill, Paint::solid(Color::rgb8(0, 0, 255)), 2.0), ); if let NodeKind::Path { live, .. } = &mut n.kind { - *live = Some(LiveShape::Rectangle { w, h, radii: [radius; 4], kinds: Default::default(), xf: Affine::translate((x, y)) }); + *live = Some(Box::new(LiveShape::Rectangle { w, h, radii: [radius; 4], kinds: Default::default(), xf: Affine::translate((x, y)) })); } n } @@ -74,19 +74,19 @@ fn rectangle_gives_background_border_and_radius() { fn live_corners_give_per_corner_radii_and_only_round_ones() { let mut d = doc(); let mut n = rect(&mut d, (0.0, 0.0, 100.0, 50.0), 0.0); - if let NodeKind::Path { live: Some(LiveShape::Rectangle { radii, kinds, .. }), .. } = &mut n.kind { + if let Some(LiveShape::Rectangle { radii, kinds, .. }) = n.live_shape_mut() { *radii = [0.0, 8.0, 0.0, 4.0]; kinds[0] = CornerKind::Chamfer; } let id = add(&mut d, n); assert_eq!(prop(&one(&d, id, &CssOptions::default()), "border-radius"), Some("0 8px 0 4px"), "a square corner's kind doesn't matter"); - if let Some(NodeKind::Path { live: Some(LiveShape::Rectangle { kinds, .. }), .. }) = d.node_mut(id).map(|n| &mut n.kind) { + if let Some(LiveShape::Rectangle { kinds, .. }) = d.node_mut(id).and_then(|n| n.live_shape_mut()) { kinds[1] = CornerKind::InvertedRound; } assert!(prop(&one(&d, id, &CssOptions::default()), "border-radius").is_none(), "CSS can't cut a corner in"); // A radius past half the shorter side is drawn at half of it (#442), and exported so: CSS // would draw it larger, its neighbours leaving room. - if let Some(NodeKind::Path { live: Some(LiveShape::Rectangle { radii, kinds, .. }), .. }) = d.node_mut(id).map(|n| &mut n.kind) { + if let Some(LiveShape::Rectangle { radii, kinds, .. }) = d.node_mut(id).and_then(|n| n.live_shape_mut()) { *radii = [60.0, 8.0, 0.0, 4.0]; *kinds = Default::default(); } @@ -99,7 +99,7 @@ fn ellipses_round_fully_and_options_change_units_and_names() { let mut e = Node::path(d.alloc_id(), shapes::ellipse(Rect::new(0.0, 0.0, 72.0, 36.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); if let NodeKind::Path { live, .. } = &mut e.kind { - *live = Some(LiveShape::Ellipse { w: 72.0, h: 36.0, pie: (0.0, 360.0), xf: Affine::IDENTITY }); + *live = Some(Box::new(LiveShape::Ellipse { w: 72.0, h: 36.0, pie: (0.0, 360.0), xf: Affine::IDENTITY })); } let e = add(&mut d, e); let a = rect(&mut d, (0.0, 0.0, 10.0, 10.0), 0.0); diff --git a/crates/svg/tests/data_attributes.rs b/crates/svg/tests/data_attributes.rs index a8927970..651a3447 100644 --- a/crates/svg/tests/data_attributes.rs +++ b/crates/svg/tests/data_attributes.rs @@ -35,7 +35,7 @@ fn native_data(raw: &str) -> Document { fn imported_decimal_whitespace_references_survive_export() { let doc = import(&svg(r#""#)).unwrap(); assert_eq!( - named(&doc, "shape").attrs.as_deref().unwrap().data, + named(&doc, "shape").extras().attrs.as_deref().unwrap().data, [("value".into(), "a\nb".into()), ("tab".into(), "a\tb".into()), ("cr".into(), "a\rb".into())] ); for styling in styles() { @@ -47,7 +47,7 @@ fn imported_decimal_whitespace_references_survive_export() { assert_eq!(n.attribute(key), Some(value)); } let back = import(&text).unwrap(); - assert_eq!(named(&back, "shape").attrs.as_deref().unwrap().data, named(&doc, "shape").attrs.as_deref().unwrap().data); + assert_eq!(named(&back, "shape").extras().attrs.as_deref().unwrap().data, named(&doc, "shape").extras().attrs.as_deref().unwrap().data); } } } @@ -70,7 +70,7 @@ fn unicode_data_names_survive_each_encoding() { r#""#, )) .unwrap(); - assert_eq!(named(&doc, "shape").attrs.as_deref().unwrap().data.len(), 5); + assert_eq!(named(&doc, "shape").extras().attrs.as_deref().unwrap().data.len(), 5); for styling in styles() { for encoding in [Encoding::Utf8, Encoding::Utf16, Encoding::Latin1] { for minify in [false, true] { @@ -89,7 +89,10 @@ fn unicode_data_names_survive_each_encoding() { assert_eq!(out.encoding, encoding); } let back = import(&out.svg).unwrap(); - assert_eq!(named(&back, "shape").attrs.as_deref().unwrap().data, named(&doc, "shape").attrs.as_deref().unwrap().data); + assert_eq!( + named(&back, "shape").extras().attrs.as_deref().unwrap().data, + named(&doc, "shape").extras().attrs.as_deref().unwrap().data + ); } } } @@ -199,7 +202,7 @@ fn reid_imported_nodes_keeps_unique_ids_references_and_data() { let back = import(&text).unwrap(); let mut copies = 0; back.walk(|n| { - if n.attrs.as_deref().is_some_and(|a| a.data.iter().any(|(k, v)| k == "配方" && v == value)) { + if n.extras().attrs.as_deref().is_some_and(|a| a.data.iter().any(|(k, v)| k == "配方" && v == value)) { copies += 1; } }); diff --git a/crates/svg/tests/encoding_profile_fonts.rs b/crates/svg/tests/encoding_profile_fonts.rs index 3f080ccd..c805548c 100644 --- a/crates/svg/tests/encoding_profile_fonts.rs +++ b/crates/svg/tests/encoding_profile_fonts.rs @@ -138,7 +138,7 @@ fn tiny_has_no_filters_masks_symbols_or_style_sheets() { add(&mut d, a); let mut b = Node::path(vectorcraft_doc::NodeId(0), shapes::rectangle(Rect::new(80.0, 10.0, 130.0, 60.0)), fill(0, 0, 255)); b.blend = BlendMode::Multiply; - b.mask = + b.extras_mut().mask = Some(Box::new(OpacityMask::new(Node::path(d.alloc_id(), shapes::ellipse(Rect::new(80.0, 10.0, 130.0, 60.0)), fill(255, 255, 255)), true))); add(&mut d, b); let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(-5.0, -5.0, 5.0, 5.0)), fill(0, 128, 0)); diff --git a/crates/svg/tests/import_details.rs b/crates/svg/tests/import_details.rs index 13031e05..b6cbf72c 100644 --- a/crates/svg/tests/import_details.rs +++ b/crates/svg/tests/import_details.rs @@ -30,7 +30,7 @@ fn images(d: &Document) -> Vec<(&Node, &ImageObject)> { art(d) .into_iter() .filter_map(|n| match &n.kind { - NodeKind::Image(im) => Some((n, im)), + NodeKind::Image(im) => Some((n, &**im)), _ => None, }) .collect() diff --git a/crates/svg/tests/import_text.rs b/crates/svg/tests/import_text.rs index 73fca990..234eaf12 100644 --- a/crates/svg/tests/import_text.rs +++ b/crates/svg/tests/import_text.rs @@ -100,7 +100,7 @@ fn text_keeps_its_parent_group_clip_and_mask() { assert!(matches!(children[1].kind, NodeKind::Text(_)) && children[1].name.as_deref() == Some("own")); // The text's own mask. assert!(matches!(inner[3].kind, NodeKind::Text(_))); - assert!(inner[3].mask.is_some()); + assert!(inner[3].extras().mask.is_some()); assert_eq!(inner[3].name.as_deref(), Some("masked")); } diff --git a/crates/svg/tests/mask_options.rs b/crates/svg/tests/mask_options.rs index 9084aeb3..2ead42f1 100644 --- a/crates/svg/tests/mask_options.rs +++ b/crates/svg/tests/mask_options.rs @@ -19,7 +19,7 @@ fn masked(clip: bool, invert: bool) -> Document { let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 90.0)), white); let mut m = OpacityMask::new(art, clip); m.invert = invert; - n.mask = Some(Box::new(m)); + n.extras_mut().mask = Some(Box::new(m)); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); d @@ -27,7 +27,7 @@ fn masked(clip: bool, invert: bool) -> Document { fn masks(d: &Document) -> Vec { let mut v = vec![]; - d.walk(|n| v.extend(n.mask.as_deref().cloned())); + d.walk(|n| v.extend(n.extras().mask.as_deref().cloned())); v } diff --git a/crates/svg/tests/options.rs b/crates/svg/tests/options.rs index d0e863bb..d2c8cfda 100644 --- a/crates/svg/tests/options.rs +++ b/crates/svg/tests/options.rs @@ -56,7 +56,7 @@ fn image_doc(key: &str, bytes: Vec) -> Document { d.images.insert(key.into(), ImageBlob::new("image/png", bytes)); let l = d.layers[0].id; let im = ImageObject { key: key.into(), width: 2, height: 2, xf: Affine::IDENTITY, link: None, placement: Default::default() }; - let n = Node::new(d.alloc_id(), NodeKind::Image(im)); + let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); d.insert(Some(l), usize::MAX, n).unwrap(); d } diff --git a/crates/svg/tests/roundtrip.rs b/crates/svg/tests/roundtrip.rs index 53c732ee..8439e720 100644 --- a/crates/svg/tests/roundtrip.rs +++ b/crates/svg/tests/roundtrip.rs @@ -316,7 +316,7 @@ fn stroke_alignment_approximations() { assert!(s.contains(" imports as an opacity mask"); + assert!(a.iter().any(|n| n.extras().mask.is_some()), " imports as an opacity mask"); assert!(!w.iter().any(|w| w.contains("mask")), "{w:?}"); // becomes a pattern swatch. assert_eq!(d.patterns.len(), 1, "{w:?}"); @@ -862,7 +862,7 @@ fn masked_doc(clip: bool, invert: bool) -> Document { let art = rect_node(&mut d, Rect::new(10.0, 10.0, 50.0, 90.0), white); let mut m = vectorcraft_doc::OpacityMask::new(art, clip); m.invert = invert; - n.mask = Some(Box::new(m)); + n.extras_mut().mask = Some(Box::new(m)); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); d @@ -875,9 +875,9 @@ fn opacity_mask_exports_as_svg_mask_and_imports_back() { assert!(s.contains(" = art(&back).into_iter().filter(|n| n.mask.is_some()).collect(); + let masked: Vec<&Node> = art(&back).into_iter().filter(|n| n.extras().mask.is_some()).collect(); assert_eq!(masked.len(), 1, "{s}"); - let m = masked[0].mask.as_deref().unwrap(); + let m = masked[0].extras().mask.as_deref().unwrap(); assert!(close_rect(m.art.geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 50.0, 90.0), 1e-6)); assert!(close_rect(masked[0].geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 90.0, 90.0), 1e-6)); } @@ -890,7 +890,7 @@ fn unclipped_inverted_mask_exports_backdrop_and_filter() { let disabled = { let mut d = masked_doc(true, false); let id = d.layers[0].children().unwrap()[0].id; - d.node_mut(id).unwrap().mask.as_mut().unwrap().disabled = true; + d.node_mut(id).unwrap().extras_mut().mask.as_mut().unwrap().disabled = true; export(&d, &ExportOptions::default()) }; assert!(!disabled.contains(" Vec<(String, String)> { let mut found = vec![]; d.walk(|n| { if n.name.as_deref() == Some(name) { - found = n.attrs.as_deref().map(|a| a.data.clone()).unwrap_or_default(); + found = n.extras().attrs.as_deref().map(|a| a.data.clone()).unwrap_or_default(); } }); found diff --git a/crates/tools/src/builder.rs b/crates/tools/src/builder.rs index 885987f2..6286e1c1 100644 --- a/crates/tools/src/builder.rs +++ b/crates/tools/src/builder.rs @@ -105,7 +105,7 @@ pub fn leaf_shapes<'a>(n: &'a Node, out: &mut Vec<&'a Node>) { if !n.visible { return; } - if n.shaper.is_some() { + if n.extras().shaper.is_some() { for c in n.children().into_iter().flatten().skip(1) { leaf_shapes(c, out); } diff --git a/crates/tools/src/corners.rs b/crates/tools/src/corners.rs index ee9c6843..d156db14 100644 --- a/crates/tools/src/corners.rs +++ b/crates/tools/src/corners.rs @@ -58,10 +58,10 @@ impl PathWidgets { return None; } let NodeKind::Path { path, live, .. } = &doc.node(id)?.kind else { return None }; - if !any_path && !matches!(live, Some(LiveShape::Rectangle { .. } | LiveShape::Polygon { .. })) { + if !any_path && !matches!(live.as_deref(), Some(LiveShape::Rectangle { .. } | LiveShape::Polygon { .. })) { return None; } - let lc = LiveCorners::new(path, live.as_ref())?; + let lc = LiveCorners::new(path, live.as_deref())?; let det = lc.xf.determinant().abs(); if det < 1e-12 { return None; @@ -366,7 +366,7 @@ mod tests { let id = d.alloc_id(); let mut n = Node::path(id, path, Appearance::default_art()); if let NodeKind::Path { live: slot, .. } = &mut n.kind { - *slot = live; + *slot = live.map(Box::new); } d.insert(Some(l), 0, n).unwrap(); (d, id) @@ -440,7 +440,7 @@ mod tests { let b = d.alloc_id(); let mut n = Node::path(b, live.to_path(), Appearance::default_art()); if let NodeKind::Path { live: slot, .. } = &mut n.kind { - *slot = Some(live); + *slot = Some(Box::new(live)); } let l = d.layers[0].id; d.insert(Some(l), 1, n).unwrap(); diff --git a/crates/tools/src/cropimage.rs b/crates/tools/src/cropimage.rs index c0711639..85555f3f 100644 --- a/crates/tools/src/cropimage.rs +++ b/crates/tools/src/cropimage.rs @@ -273,7 +273,7 @@ mod tests { link: None, placement: Default::default(), }; - d.insert(Some(l), 0, Node::new(id, NodeKind::Image(im))).unwrap(); + d.insert(Some(l), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); let mut s = Selection::default(); s.add(id); (d, s) diff --git a/crates/tools/src/distort.rs b/crates/tools/src/distort.rs index 0437ffff..55fa9a9b 100644 --- a/crates/tools/src/distort.rs +++ b/crates/tools/src/distort.rs @@ -59,7 +59,8 @@ fn warp_node(n: &mut Node, f: &dyn Fn(Point) -> Point, h: Option<&Homography>) { if let Some(h) = h && matches!(n.kind, NodeKind::Text(_) | NodeKind::SymbolInstance { .. }) { - let rec = n.perspective.get_or_insert_with(Default::default); + let mut extras = n.extras_mut(); + let rec = extras.perspective.get_or_insert_with(Default::default); let had = rec.homography().unwrap_or(Homography::IDENTITY); rec.projection = Some(h.then_after(&had).to_array()); return; diff --git a/crates/tools/src/distort/perspective.rs b/crates/tools/src/distort/perspective.rs index d1193ee6..88a54b07 100644 --- a/crates/tools/src/distort/perspective.rs +++ b/crates/tools/src/distort/perspective.rs @@ -287,9 +287,9 @@ impl PerspectiveGrid { continue; } if let Some(n) = k.parse::().ok().and_then(|id| doc.node_mut(NodeId(id))) - && n.perspective.is_none() + && n.extras().perspective.is_none() { - n.perspective = Some(Box::new(PerspectiveAttachment::new(pl.id(), 0.0))); + n.extras_mut().perspective = Some(Box::new(PerspectiveAttachment::new(pl.id(), 0.0))); } } if let Some(v) = doc.unknown.get_mut(DOC_KEY).and_then(Value::as_object_mut) { @@ -676,7 +676,7 @@ pub enum Axis { /// The plane and depth object `n` is attached to (its own record). pub fn attachment(n: &Node) -> Option<(Plane, f64)> { - let a = n.perspective.as_deref()?; + let a = n.extras().perspective.as_deref()?; Plane::parse(&a.plane).filter(|p| *p != Plane::None).map(|p| (p, a.depth)) } @@ -697,7 +697,8 @@ pub fn attached_roots(doc: &Document) -> Vec<(NodeId, Plane, f64)> { /// Attach `n` to `plane` at `depth` (type and symbols keep their projection). pub fn set_attachment(n: &mut Node, plane: Plane, depth: f64) { - let rec = n.perspective.get_or_insert_with(Default::default); + let mut extras = n.extras_mut(); + let rec = extras.perspective.get_or_insert_with(Default::default); rec.plane = plane.id().into(); rec.depth = if depth.is_finite() { depth } else { 0.0 }; } @@ -705,9 +706,10 @@ pub fn set_attachment(n: &mut Node, plane: Plane, depth: f64) { /// Release `n` from the grid (Release with Perspective): it keeps its look, type and symbols their /// projection. pub fn release(n: &mut Node) { - match n.perspective.as_deref_mut() { + let mut extras = n.extras_mut(); + match extras.perspective.as_deref_mut() { Some(rec) if rec.projection.is_some() => *rec = PerspectiveAttachment { projection: rec.projection, ..Default::default() }, - _ => n.perspective = None, + _ => extras.perspective = None, } } @@ -1460,7 +1462,7 @@ mod tests { old.attached.insert(id2.0.to_string(), Plane::Left); d2.unknown.insert(DOC_KEY.into(), serde_json::to_value(&old).unwrap()); grid(2).store(&mut d2); - assert!(d2.node(id2).unwrap().perspective.is_none()); + assert!(d2.node(id2).unwrap().extras().perspective.is_none()); } #[test] diff --git a/crates/tools/src/draw2.rs b/crates/tools/src/draw2.rs index 77731260..a060e148 100644 --- a/crates/tools/src/draw2.rs +++ b/crates/tools/src/draw2.rs @@ -111,7 +111,7 @@ pub(crate) fn shown_paths(cx: &ToolContext) -> Vec { NodeKind::Path { guide: false, .. } => v.push(n.id), // A Shape Builder group's first child is its shaper, not art. NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => { - for c in children.iter().skip(usize::from(n.shaper.is_some())) { + for c in children.iter().skip(usize::from(n.extras().shaper.is_some())) { inside(c, v); } } diff --git a/crates/tools/src/meshedit.rs b/crates/tools/src/meshedit.rs index 1af0998c..adb3adcd 100644 --- a/crates/tools/src/meshedit.rs +++ b/crates/tools/src/meshedit.rs @@ -19,9 +19,10 @@ const FEEDBACK: [u8; 3] = [0x4f, 0x9d, 0xff]; pub fn grid_of(n: &Node) -> Option> { match &n.kind { NodeKind::Mesh(m) => Some(Cow::Borrowed(m)), - NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows, cols, points, handles }, editing: false, .. } => { - envelope_grid(*rows, *cols, points, handles).map(Cow::Owned) - } + NodeKind::Envelope { kind, editing: false, .. } => match &**kind { + EnvelopeKind::Mesh { rows, cols, points, handles } => envelope_grid(*rows, *cols, points, handles).map(Cow::Owned), + _ => None, + }, _ => None, } } @@ -132,11 +133,11 @@ mod tests { let kind = EnvelopeKind::Mesh { rows: 1, cols: 1, points: grid_points(Rect::new(100.0, 100.0, 200.0, 200.0), 1, 1), handles: vec![] }; let env = NodeKind::Envelope { content: vec![Arc::new(rect)], - kind, + kind: Box::new(kind), fidelity: 50.0, editing: false, options: EnvelopeOptions::NEW, - frame: Affine::IDENTITY, + frame: Box::new(Affine::IDENTITY), }; let l = d.layers[0].id; d.insert(Some(l), 0, Node::new(id, env)).unwrap(); diff --git a/crates/tools/src/pathtype.rs b/crates/tools/src/pathtype.rs index 61fee7c2..8bf33382 100644 --- a/crates/tools/src/pathtype.rs +++ b/crates/tools/src/pathtype.rs @@ -54,7 +54,7 @@ impl Brackets { } fn of_node(cx: &ToolContext, id: NodeId) -> Option { - let n = cx.doc.node(id).filter(|n| n.perspective.is_none() && cx.doc.is_editable(id))?; + let n = cx.doc.node(id).filter(|n| n.extras().perspective.is_none() && cx.doc.is_editable(id))?; let NodeKind::Text(t) = &n.kind else { return None }; let TextKind::OnPath { path, end, .. } = &t.kind else { return None }; let det = t.xf.determinant(); diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index e12054c6..3cae0937 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -540,7 +540,8 @@ fn can_be_key(cx: &ToolContext, id: NodeId) -> bool { /// Is `n` area type (text in a frame) whose frame the tools reshape? Type in perspective isn't: /// it transforms whole. pub(crate) fn is_area_type(n: &vectorcraft_doc::Node) -> bool { - n.perspective.is_none() && matches!(&n.kind, vectorcraft_doc::NodeKind::Text(t) if matches!(t.kind, vectorcraft_doc::TextKind::Area { .. })) + n.extras().perspective.is_none() + && matches!(&n.kind, vectorcraft_doc::NodeKind::Text(t) if matches!(t.kind, vectorcraft_doc::TextKind::Area { .. })) } /// The resize cursor for handle `h` of a box turned by `angle` (counter-clockwise degrees): the diff --git a/crates/tools/src/typewidget.rs b/crates/tools/src/typewidget.rs index cce2801b..13753fb0 100644 --- a/crates/tools/src/typewidget.rs +++ b/crates/tools/src/typewidget.rs @@ -37,7 +37,7 @@ impl TypeWidget { if !doc.is_editable(id) || !zoom.is_finite() || zoom <= 0.0 { return None; } - let n = doc.node(id).filter(|n| n.perspective.is_none())?; + let n = doc.node(id).filter(|n| n.extras().perspective.is_none())?; let NodeKind::Text(t) = &n.kind else { return None }; let area = match t.kind { TextKind::Point => false, diff --git a/crates/tools/src/xform/eyedropper.rs b/crates/tools/src/xform/eyedropper.rs index d1dc10bf..0cdfbaa8 100644 --- a/crates/tools/src/xform/eyedropper.rs +++ b/crates/tools/src/xform/eyedropper.rs @@ -213,7 +213,7 @@ mod tests { link: None, placement: Default::default(), }; - d.insert(Some(l), 0, Node::new(id, NodeKind::Image(im))).unwrap(); + d.insert(Some(l), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); let (s, p) = (Selection::default(), paint()); let mut cx = cx(&d, &s, &p); let click = PointerEvent::new(PointerKind::Down, 15.0, 5.0); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f51c6992..a082d4f9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1229,7 +1229,7 @@ fn node_outline(n: &Node) -> BezPath { NodeKind::Path { path, .. } => bp.extend(path.to_bezpath()), // Area type shows its frame: the type area Direct Selection reshapes; type on a path its // path, which the selection tools' brackets sit on. - NodeKind::Text(t) if c.perspective.is_none() && !matches!(t.kind, vectorcraft_doc::TextKind::Point) => { + NodeKind::Text(t) if c.extras().perspective.is_none() && !matches!(t.kind, vectorcraft_doc::TextKind::Point) => { if let Some(path) = t.area_frame().or_else(|| t.type_path()) { bp.extend(path.to_bezpath()); } @@ -1278,7 +1278,7 @@ fn too_many_anchors<'a>(nodes: impl IntoIterator) -> bool { } } !matches!(n.kind, NodeKind::Envelope { .. }) - && n.children().into_iter().flatten().skip(usize::from(n.shaper.is_some())).any(|c| over(c, left)) + && n.children().into_iter().flatten().skip(usize::from(n.extras().shaper.is_some())).any(|c| over(c, left)) } let mut left = OVERLAY_MAX_ANCHORS; nodes.into_iter().any(|n| over(n, &mut left)) @@ -1291,7 +1291,7 @@ fn walk_drawn<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) { if matches!(n.kind, NodeKind::Envelope { .. }) { return; } - for c in n.children().into_iter().flatten().skip(usize::from(n.shaper.is_some())) { + for c in n.children().into_iter().flatten().skip(usize::from(n.extras().shaper.is_some())) { walk_drawn(c, f); } } @@ -2207,7 +2207,7 @@ mod tests { } let st = s.active().unwrap(); let g = st.doc.node(st.selection.objects[0]).unwrap(); - assert!(g.shaper.is_some()); + assert!(g.extras().shaper.is_some()); let bounds = node_outline(g).bounding_box(); assert!((bounds.x0 - 100.0).abs() < 0.01 && (bounds.x1 - 200.0).abs() < 0.01, "{bounds:?}"); assert_eq!(node_outline(st.doc.node(line).unwrap()).bounding_box().x0, 50.0, "an isolated original still shows its full outline"); @@ -3646,9 +3646,9 @@ mod tests { let st = app.session.active().unwrap(); let g = st.doc.graphic_style(&name).unwrap(); // `graphicStyle.apply` with the hit object's id: linked, the selection left alone. - assert_eq!(st.doc.node(b).unwrap().graphic_style, Some(g.id)); + assert_eq!(st.doc.node(b).unwrap().extras().graphic_style, Some(g.id)); assert_eq!(st.doc.node(b).unwrap().appearance, g.appearance); - assert_eq!(st.doc.node(a).unwrap().graphic_style, None); + assert_eq!(st.doc.node(a).unwrap().extras().graphic_style, None); assert_eq!(st.selection.objects, [a]); } diff --git a/crates/ui-egui/src/dialogs/blend_options.rs b/crates/ui-egui/src/dialogs/blend_options.rs index 86deabcb..0ee43fc4 100644 --- a/crates/ui-egui/src/dialogs/blend_options.rs +++ b/crates/ui-egui/src/dialogs/blend_options.rs @@ -147,7 +147,7 @@ mod tests { fn spec(app: &VectorcraftApp, id: u64) -> vectorcraft_doc::BlendSpec { match &app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind { - NodeKind::Blend { spec, .. } => spec.clone(), + NodeKind::Blend { spec, .. } => (**spec).clone(), _ => panic!("not a blend"), } } diff --git a/crates/ui-egui/src/dialogs/corners.rs b/crates/ui-egui/src/dialogs/corners.rs index 27405382..8f1342eb 100644 --- a/crates/ui-egui/src/dialogs/corners.rs +++ b/crates/ui-egui/src/dialogs/corners.rs @@ -120,7 +120,7 @@ mod tests { let Some(NodeKind::Path { live: Some(l), .. }) = app.session.active().unwrap().doc.node(NodeId(id)).map(|n| n.kind.clone()) else { panic!("not live") }; - l + *l } fn corners(app: &VectorcraftApp, id: u64) -> ([f64; 4], [CornerKind; 4]) { @@ -239,7 +239,7 @@ mod tests { d.fields.insert("radius".into(), json!(5)); super::super::confirm(&mut app).unwrap(); let n = app.session.active().unwrap().doc.node(NodeId(id)).unwrap().clone(); - assert!(matches!(&n.kind, NodeKind::Path { live: Some(LiveShape::Path { .. }), .. })); + assert!(matches!(&n.kind, NodeKind::Path { live: Some(l), .. } if matches!(**l, LiveShape::Path { .. }))); assert_eq!(crate::panels::corner_radius(&app, &n), Some(5.0)); } } diff --git a/crates/ui-egui/src/dialogs/envelope.rs b/crates/ui-egui/src/dialogs/envelope.rs index 0a4d4aa3..0cdb2b24 100644 --- a/crates/ui-egui/src/dialogs/envelope.rs +++ b/crates/ui-egui/src/dialogs/envelope.rs @@ -238,7 +238,7 @@ fn bar_of(app: &VectorcraftApp) -> Option<(bool, Bar)> { let st = app.session.active()?; let env = st.selection.objects.first().and_then(|id| vectorcraft_doc::live::envelope_of(&st.doc, *id))?; let NodeKind::Envelope { kind, editing, .. } = &env.kind else { return None }; - let bar = match kind { + let bar = match &**kind { EnvelopeKind::Warp { style, bend, h, v, horizontal } => { Bar::Warp { style: style.clone(), bend: *bend, h: *h, v: *v, horizontal: *horizontal } } @@ -362,7 +362,8 @@ mod tests { assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); let (_, n) = envelope(&app); match &n.kind { - NodeKind::Envelope { kind: EnvelopeKind::Warp { style, bend, horizontal, .. }, content, .. } => { + NodeKind::Envelope { kind, content, .. } => { + let EnvelopeKind::Warp { style, bend, horizontal, .. } = &**kind else { panic!("{kind:?}") }; assert_eq!((style.as_str(), *bend, *horizontal), ("flag", -30.0, false)); assert_eq!(content[0].id, rect); } @@ -373,7 +374,10 @@ mod tests { let d = app.ui.dialog.as_ref().unwrap(); assert_eq!((d.fields["reset"].clone(), d.fields["style"].clone(), d.fields["bend"].clone()), (json!(true), json!("flag"), json!(-30.0))); crate::dialogs::cancel(&mut app); - assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { kind: EnvelopeKind::Warp { .. }, .. }), "cancel rolls back"); + assert!( + matches!(envelope(&app).1.kind, NodeKind::Envelope { ref kind, .. } if matches!(**kind, EnvelopeKind::Warp { .. })), + "cancel rolls back" + ); } #[test] @@ -404,13 +408,18 @@ mod tests { app.run("ui.menuDialog", json!({"command": RESET_MESH})).unwrap(); assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(MESH)); frame(&mut app); - assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows: 4, cols: 4, .. }, .. }), "previewed"); + assert!( + matches!(envelope(&app).1.kind, NodeKind::Envelope { ref kind, .. } if matches!(**kind, EnvelopeKind::Mesh { rows: 4, cols: 4, .. })), + "previewed" + ); crate::dialogs::cancel(&mut app); - assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { kind: EnvelopeKind::Warp { .. }, .. })); + assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { ref kind, .. } if matches!(**kind, EnvelopeKind::Warp { .. }))); app.run("ui.menuDialog", json!({"command": RESET_MESH})).unwrap(); app.ui.dialog.as_mut().unwrap().fields.insert("rows".into(), json!(2)); super::super::confirm(&mut app).unwrap(); - assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows: 2, cols: 4, .. }, .. })); + assert!( + matches!(envelope(&app).1.kind, NodeKind::Envelope { ref kind, .. } if matches!(**kind, EnvelopeKind::Mesh { rows: 2, cols: 4, .. })) + ); } #[test] diff --git a/crates/ui-egui/src/dialogs/graph_data.rs b/crates/ui-egui/src/dialogs/graph_data.rs index efcd65c7..fa9f5e65 100644 --- a/crates/ui-egui/src/dialogs/graph_data.rs +++ b/crates/ui-egui/src/dialogs/graph_data.rs @@ -52,8 +52,9 @@ pub fn open(app: &mut VectorcraftApp) -> Result { // The query fails without a graph selected, with the engine's own message. let v = app.session.execute("graph.setData", &json!({})).map_err(|e| e.to_string())?; let graph = app.session.active().and_then(|st| st.selection.objects.iter().find_map(|id| graph_of(&st.doc, *id)).map(|id| (st, id))); - let scatter = - graph.is_some_and(|(st, id)| st.doc.node(id).and_then(|n| n.graph.as_deref()).is_some_and(|g| g.kind == vectorcraft_doc::GraphKind::Scatter)); + let scatter = graph.is_some_and(|(st, id)| { + st.doc.node(id).and_then(|n| n.extras().graph.as_deref()).is_some_and(|g| g.kind == vectorcraft_doc::GraphKind::Scatter) + }); let id = graph.map(|(_, id)| id.0); let grid = grid_from_data(&v); let entry = cell(&grid, 0, 1); @@ -68,7 +69,7 @@ pub fn open(app: &mut VectorcraftApp) -> Result { fn graph_of(doc: &vectorcraft_doc::Document, id: vectorcraft_doc::NodeId) -> Option { let mut cur = Some(id); while let Some(c) = cur { - if doc.node(c).is_some_and(|n| n.graph.is_some()) { + if doc.node(c).is_some_and(|n| n.extras().graph.is_some()) { return Some(c); } cur = doc.parent_of(c); diff --git a/crates/ui-egui/src/panels/graphic_styles.rs b/crates/ui-egui/src/panels/graphic_styles.rs index f2f848a6..dcb0b6bb 100644 --- a/crates/ui-egui/src/panels/graphic_styles.rs +++ b/crates/ui-egui/src/panels/graphic_styles.rs @@ -935,7 +935,7 @@ mod tests { frame_with(&ctx, release(pos2(60.0, 116.0)), |ui| show(&mut app, ui)); let d = app.session.active().unwrap().doc.clone(); assert_eq!(d.graphic_styles.len(), n0.len() + 2); - assert_eq!(d.node(rect).unwrap().graphic_style, d.graphic_styles.last().map(|g| g.id)); + assert_eq!(d.node(rect).unwrap().extras().graphic_style, d.graphic_styles.last().map(|g| g.id)); } #[test] diff --git a/crates/ui-egui/src/panels/image_trace.rs b/crates/ui-egui/src/panels/image_trace.rs index f3d5cf7f..a46d852c 100644 --- a/crates/ui-egui/src/panels/image_trace.rs +++ b/crates/ui-egui/src/panels/image_trace.rs @@ -76,7 +76,7 @@ pub(crate) fn is_trace(n: &Node) -> bool { /// What the selection is: an Image Trace object (with its stored settings), a plain image, or neither. fn target(app: &VectorcraftApp) -> (bool, bool, Option) { let Some(n) = first_selected(app) else { return (false, false, None) }; - (is_trace(&n), matches!(n.kind, NodeKind::Image(_)), n.trace.map(|t| *t)) + (is_trace(&n), matches!(n.kind, NodeKind::Image(_)), n.extras().trace.as_deref().cloned()) } /// The one selected Image Trace object's preset ("Custom" once its settings were changed) and view. @@ -84,7 +84,7 @@ pub(crate) fn selected_trace(app: &VectorcraftApp) -> Option<(String, TraceView) let st = app.session.active()?; let [id] = st.selection.objects[..] else { return None }; let n = st.doc.node(id).filter(|n| is_trace(n))?; - Some((n.trace.as_ref().and_then(|t| t["preset"].as_str()).unwrap_or("Custom").to_string(), n.trace_view())) + Some((n.extras().trace.as_ref().and_then(|t| t["preset"].as_str()).unwrap_or("Custom").to_string(), n.trace_view())) } /// Image Trace › View, in the UI language. diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 43d62fcd..758a520f 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -723,7 +723,7 @@ fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) { // A clipping path's name is underlined, a masked object's with a dashed line. if clip_path { painter.line_segment([text.left_bottom(), text.right_bottom()], Stroke::new(1.0, t.text)); - } else if n.mask.is_some() { + } else if n.extras().mask.is_some() { painter.extend(egui::Shape::dashed_line(&[text.left_bottom(), text.right_bottom()], Stroke::new(1.0, t.text), 3.0, 2.0)); } } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 769d5b99..24159a5a 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -97,7 +97,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { divider(ui); section_header(ui, tl!("Quick Actions")); let is_group = matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Group { .. })); - let is_live = matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Path { live: Some(l), .. }) if !matches!(l, LiveShape::Path { .. })); + let is_live = matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Path { live: Some(l), .. }) if !matches!(**l, LiveShape::Path { .. })); let mut actions: Vec<(&str, &str)> = vec![]; if n_sel > 1 { actions.push((tl!("Group"), "object.group")); @@ -465,7 +465,7 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) { if let Some(n) = first_selected(app) && let NodeKind::Path { live: Some(live), .. } = &n.kind { - match live { + match &**live { vectorcraft_doc::LiveShape::Rectangle { .. } => corner_radius_row(app, ui, &n, "radius"), vectorcraft_doc::LiveShape::Polygon { .. } => super::transform::polygon_rows(app, ui, &n, live, "props-polygon"), vectorcraft_doc::LiveShape::Ellipse { pie, .. } => super::transform::pie_rows(app, ui, *pie, "props-pie"), diff --git a/crates/ui-egui/src/panels/transform.rs b/crates/ui-egui/src/panels/transform.rs index 949da2ac..6c3b5ed6 100644 --- a/crates/ui-egui/src/panels/transform.rs +++ b/crates/ui-egui/src/panels/transform.rs @@ -179,10 +179,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // Live shape properties (a path with live corners is no shape). if let Some(n) = first_selected(app) && let NodeKind::Path { live: Some(live), .. } = &n.kind - && !matches!(live, vectorcraft_doc::LiveShape::Path { .. }) + && !matches!(**live, vectorcraft_doc::LiveShape::Path { .. }) { widgets::divider(ui); - match live { + match &**live { vectorcraft_doc::LiveShape::Rectangle { .. } => { widgets::subheader(ui, tl!("Rectangle Properties:")); corner_radius_row(app, ui, &n, "xfp-radius"); diff --git a/crates/ui-egui/src/panels/transparency.rs b/crates/ui-egui/src/panels/transparency.rs index 7a5375d1..4e11bb01 100644 --- a/crates/ui-egui/src/panels/transparency.rs +++ b/crates/ui-egui/src/panels/transparency.rs @@ -65,7 +65,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { widgets::divider(ui); // Thumbnails and the opacity-mask controls. let hide_thumbs: bool = pstate(ui.ctx(), "tr-hide-thumbs"); - let mask = n.as_ref().and_then(|n| n.mask.as_deref()); + let mask = n.as_ref().and_then(|n| n.extras().mask.as_deref()); let (new_clip, new_invert) = app.session.new_mask_defaults(); ui.horizontal(|ui| { if !hide_thumbs { @@ -79,7 +79,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } if let Some(n) = &n { let mut bare = n.clone(); - bare.mask = None; + bare.extras_mut().mask = None; if !node_thumb(app, ui, "tr-obj", &bare, r.shrink(3.0), None) { let (f, s) = current_paints(app); let inner = r.shrink2(vec2(6.0, 10.0)); @@ -195,7 +195,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { // The masked object's mask, also while editing it (the selection then is the mask art). let editing = app.session.active().is_some_and(|d| d.doc.mask_edit.is_some()); let (_, n) = state(app, ui.ctx()); - let mask = n.as_ref().and_then(|n| n.mask.as_deref()); + let mask = n.as_ref().and_then(|n| n.extras().mask.as_deref()); for (label, id, enabled) in mask_items(mask, n.is_some() && !editing) { if menu_item(ui, tl!(label), enabled, false) { app.run(id, json!({})).ok(); @@ -320,7 +320,7 @@ mod tests { let (info, n) = state(&app, &egui::Context::default()); assert_eq!((info.ids, n.as_ref().map(|n| n.id.0)), (vec![vectorcraft_doc::NodeId(a)], Some(a))); // The menu is enabled for the masked object while editing (Make is not). - let items = mask_items(n.as_ref().and_then(|n| n.mask.as_deref()), false); + let items = mask_items(n.as_ref().and_then(|n| n.extras().mask.as_deref()), false); assert_eq!(items.map(|(_, _, on)| on), [false, true, true, true]); // The panel's controls run without ids and reach the masked object. run(&mut app, "transparency.setOpacityMask", json!({"clip": false})); @@ -329,7 +329,7 @@ mod tests { frame(&mut app); let st = app.session.active().unwrap(); let obj = st.doc.node(vectorcraft_doc::NodeId(a)).unwrap(); - let m = obj.mask.as_ref().unwrap(); + let m = obj.extras().mask.as_ref().unwrap(); assert!(!m.clip && !m.linked && obj.blend == BlendMode::Multiply); assert!(st.doc.mask_edit.is_some()); // Release leaves editing and puts the drawn mask art back.