From 5beac4f12238167095e534c9c2902667c22abd29 Mon Sep 17 00:00:00 2001 From: arhammusheer Date: Mon, 12 Oct 2026 02:32:50 +0530 Subject: [PATCH] #1244: smaller objects: Node 352 -> 152 bytes, NodeKind 200 -> 80, and a cheaper undo estimate Rarely set fields move into one optional boxed NodeExtras, and large NodeKind payloads (images, live shapes, blends, envelopes) are boxed. Every object, and every undo step that copies it, shrinks: a 50k-path move keeps ~24.5 MB per step instead of ~40 MB. Files stay byte-identical (Node keeps its flat JSON through a borrowed Serialize and a from-wire Deserialize, no flatten), and loading is slightly faster. Undo and redo reuse their step's size estimate instead of walking the document again (8.3 -> 5.6 ms for a 50k-path move). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc # Conflicts: # crates/engine/src/cmd/graph.rs # crates/svg/src/export.rs --- apps/vectorcraft-cli/src/perf.rs | 2 +- crates/cad/src/scene.rs | 2 +- crates/cad/src/tests.rs | 2 +- crates/doc/src/blend.rs | 8 +- crates/doc/src/corners.rs | 22 +- crates/doc/src/graph.rs | 7 +- crates/doc/src/hit.rs | 6 +- crates/doc/src/lib.rs | 2 +- crates/doc/src/links.rs | 15 +- crates/doc/src/live.rs | 21 +- crates/doc/src/mem.rs | 226 +++++++--- crates/doc/src/node.rs | 400 ++++++++++++++---- crates/doc/src/perspective.rs | 4 +- crates/doc/src/placed_document.rs | 21 +- crates/doc/src/slices.rs | 8 +- crates/doc/src/tests_slices.rs | 7 +- crates/doc/src/trace.rs | 6 +- crates/effects/src/bake.rs | 37 +- crates/effects/src/marks.rs | 4 +- crates/effects/src/reshape.rs | 6 +- crates/engine/src/cmd/attributes.rs | 6 +- crates/engine/src/cmd/buildcmds.rs | 10 +- crates/engine/src/cmd/clipboard/flavours.rs | 2 +- crates/engine/src/cmd/clipboard/resources.rs | 8 +- crates/engine/src/cmd/create.rs | 2 +- crates/engine/src/cmd/distortcmds.rs | 8 +- crates/engine/src/cmd/docinfo.rs | 2 +- crates/engine/src/cmd/edit.rs | 6 +- crates/engine/src/cmd/fileio/affinity.rs | 4 +- crates/engine/src/cmd/fileio/imagemap.rs | 2 +- crates/engine/src/cmd/fileio/load.rs | 3 +- crates/engine/src/cmd/fileio/tests.rs | 2 +- .../engine/src/cmd/fileio/tests_svgimport.rs | 2 +- crates/engine/src/cmd/flatten.rs | 6 +- crates/engine/src/cmd/graph.rs | 133 +++--- crates/engine/src/cmd/live.rs | 30 +- crates/engine/src/cmd/maskedit.rs | 18 +- crates/engine/src/cmd/menucmds.rs | 15 +- crates/engine/src/cmd/newart.rs | 2 +- crates/engine/src/cmd/object.rs | 27 +- crates/engine/src/cmd/opacitymask.rs | 32 +- crates/engine/src/cmd/pathops.rs | 4 +- crates/engine/src/cmd/place/adopt.rs | 2 +- crates/engine/src/cmd/place/mod.rs | 2 +- crates/engine/src/cmd/printadvanced.rs | 2 +- crates/engine/src/cmd/rasterfx.rs | 20 +- crates/engine/src/cmd/rasterfx/report.rs | 4 +- crates/engine/src/cmd/select.rs | 2 +- crates/engine/src/cmd/shaper.rs | 20 +- crates/engine/src/cmd/slices.rs | 12 +- crates/engine/src/cmd/style.rs | 15 +- crates/engine/src/cmd/textwrap.rs | 31 +- crates/engine/src/cmd/typecmd.rs | 2 +- crates/engine/src/lib.rs | 39 +- crates/engine/src/tests.rs | 21 +- crates/engine/src/tests_adjust.rs | 2 +- crates/engine/src/tests_attributes.rs | 14 +- crates/engine/src/tests_blendfidelity.rs | 6 +- crates/engine/src/tests_blendopts.rs | 2 +- crates/engine/src/tests_blendspine.rs | 2 +- crates/engine/src/tests_build.rs | 19 +- crates/engine/src/tests_clipboard.rs | 8 +- crates/engine/src/tests_editcolors.rs | 2 +- crates/engine/src/tests_envelope.rs | 10 +- crates/engine/src/tests_envelope_distort.rs | 6 +- crates/engine/src/tests_envelope_edit.rs | 7 +- crates/engine/src/tests_eyedropper.rs | 2 +- crates/engine/src/tests_flatten.rs | 4 +- crates/engine/src/tests_history_mem.rs | 29 ++ crates/engine/src/tests_links.rs | 4 +- crates/engine/src/tests_live.rs | 3 +- crates/engine/src/tests_livecorners.rs | 14 +- crates/engine/src/tests_menucmds.rs | 5 +- crates/engine/src/tests_opacitymask.rs | 4 +- crates/engine/src/tests_pdfraster.rs | 2 +- crates/engine/src/tests_persp_text.rs | 2 +- crates/engine/src/tests_recovery.rs | 116 +++++ crates/engine/src/tests_scalestrokes.rs | 6 +- crates/engine/src/tests_shaper.rs | 12 +- crates/engine/src/tests_slices.rs | 12 +- crates/engine/src/tests_stylelib.rs | 4 +- crates/engine/src/tests_styles.rs | 12 +- crates/engine/src/tests_targeting.rs | 2 +- crates/eps/src/import/ai/read.rs | 4 +- crates/eps/src/import/data.rs | 2 +- crates/eps/src/import/preview.rs | 3 +- crates/eps/src/import/tests.rs | 2 +- crates/eps/src/scene.rs | 2 +- crates/eps/src/tests.rs | 2 +- crates/format/src/lib.rs | 2 +- crates/format/tests/links.rs | 2 +- crates/format/tests/native_v3.rs | 11 +- crates/format/tests/native_v4.rs | 2 +- crates/format/tests/prop_format.rs | 2 +- crates/format/tests/save_options.rs | 2 +- crates/metafile/src/import.rs | 2 +- crates/metafile/src/scene.rs | 2 +- crates/metafile/src/tests.rs | 2 +- crates/pdf/src/export.rs | 10 +- crates/pdf/src/import.rs | 24 +- crates/pdf/src/import_lines.rs | 4 +- crates/pdf/src/import_mask.rs | 2 +- crates/pdf/src/import_shading.rs | 2 +- crates/pdf/src/tests.rs | 4 +- crates/pdf/src/tests_cmykblend.rs | 2 +- crates/pdf/src/tests_cmykimages.rs | 8 +- crates/pdf/src/tests_compression.rs | 2 +- crates/pdf/src/tests_import_fidelity.rs | 14 +- crates/pdf/src/tests_output.rs | 2 +- crates/pdf/src/tests_pdfx.rs | 2 +- crates/pdf/src/tests_webview.rs | 2 +- crates/render/src/encode/psd.rs | 2 +- crates/render/src/fx.rs | 18 +- crates/render/src/ink.rs | 2 +- crates/render/src/lib.rs | 6 +- crates/render/src/tests.rs | 6 +- crates/render/src/tests_adjust.rs | 9 +- crates/render/src/tests_cmykblend.rs | 11 +- crates/render/src/tests_isolation.rs | 2 +- crates/render/src/tests_knockout.rs | 2 +- crates/render/src/tests_layeropts.rs | 4 +- crates/render/src/tests_memory.rs | 2 +- crates/render/src/tests_objectfx.rs | 4 +- crates/render/src/tests_setup.rs | 2 +- crates/svg/src/css.rs | 7 +- crates/svg/src/export.rs | 25 +- crates/svg/src/import.rs | 14 +- crates/svg/tests/blend_export.rs | 2 +- crates/svg/tests/css_rules.rs | 10 +- crates/svg/tests/data_attributes.rs | 13 +- crates/svg/tests/encoding_profile_fonts.rs | 2 +- crates/svg/tests/import_details.rs | 2 +- crates/svg/tests/import_text.rs | 2 +- crates/svg/tests/mask_options.rs | 4 +- crates/svg/tests/options.rs | 2 +- crates/svg/tests/roundtrip.rs | 16 +- crates/svg/tests/symbols_layers.rs | 4 +- crates/tools/src/builder.rs | 2 +- crates/tools/src/corners.rs | 8 +- crates/tools/src/cropimage.rs | 2 +- crates/tools/src/distort.rs | 3 +- crates/tools/src/distort/perspective.rs | 16 +- crates/tools/src/draw2.rs | 2 +- crates/tools/src/meshedit.rs | 11 +- crates/tools/src/pathtype.rs | 2 +- crates/tools/src/select.rs | 3 +- crates/tools/src/typewidget.rs | 2 +- crates/tools/src/xform/eyedropper.rs | 2 +- crates/ui-egui/src/canvas.rs | 12 +- crates/ui-egui/src/dialogs/blend_options.rs | 2 +- crates/ui-egui/src/dialogs/corners.rs | 4 +- crates/ui-egui/src/dialogs/envelope.rs | 21 +- crates/ui-egui/src/dialogs/graph_data.rs | 7 +- crates/ui-egui/src/panels/graphic_styles.rs | 2 +- crates/ui-egui/src/panels/image_trace.rs | 4 +- crates/ui-egui/src/panels/layers.rs | 2 +- crates/ui-egui/src/panels/properties.rs | 4 +- crates/ui-egui/src/panels/transform.rs | 4 +- crates/ui-egui/src/panels/transparency.rs | 10 +- 159 files changed, 1340 insertions(+), 691 deletions(-) diff --git a/apps/vectorcraft-cli/src/perf.rs b/apps/vectorcraft-cli/src/perf.rs index c76f8553..97cd0647 100644 --- a/apps/vectorcraft-cli/src/perf.rs +++ b/apps/vectorcraft-cli/src/perf.rs @@ -220,7 +220,7 @@ fn deep(n: usize) -> Result<(), String> { let xf = Affine::translate((f64::from(i % 2) * 800.0, f64::from(i / 2) * 600.0)) * Affine::scale(800.0 / 6000.0); let image = vectorcraft_doc::ImageObject { key, width: 6000, height: 4000, xf, link: None, placement: Default::default() }; let id = d.alloc_id(); - d.insert(l, usize::MAX, Node::new(id, vectorcraft_doc::NodeKind::Image(image))).map_err(|e| e.to_string())?; + d.insert(l, usize::MAX, Node::new(id, vectorcraft_doc::NodeKind::Image(Box::new(image)))).map_err(|e| e.to_string())?; } note("4 embedded 24 MP photos"); let mut renderers = [Renderer::new(), Renderer::new(), Renderer::new()]; diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index a8a9aa1d..6310acf4 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -349,7 +349,7 @@ impl<'a> Scene<'a> { if n.blend != vectorcraft_color::BlendMode::Normal { self.warn("blending modes are left out"); } - if n.mask.as_ref().is_some_and(|m| !m.disabled) { + if n.extras().mask.as_ref().is_some_and(|m| !m.disabled) { self.warn("opacity masks are left out"); } let raster = |fx: &[vectorcraft_doc::Effect]| fx.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)); diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 87023aa3..0bb612f5 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -355,7 +355,7 @@ fn images_link_png_or_jpeg_files() { link: None, placement: Default::default(), }; - vec![Node::new(d.alloc_id(), NodeKind::Image(im))] + vec![Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)))] }); d.images.insert("raster-1".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png), proxy: None }); let out = export(&d, &opts(&d)).unwrap(); diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index e52bd366..1849077a 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -562,7 +562,7 @@ impl Lerp { } } } - n.mask = None; + n.extras_mut().mask = None; n } Shape::Path { pair, rules, members } => { @@ -590,7 +590,7 @@ impl Lerp { (NodeKind::PlacedDocument(o), NodeKind::PlacedDocument(x), NodeKind::PlacedDocument(y)) => o.xf = lerp_affine(x.xf, y.xf, t), _ => {} } - n.mask = None; + n.extras_mut().mask = None; n } Shape::Other => { @@ -614,7 +614,7 @@ impl Lerp { for c in n.children_mut().into_iter().flatten() { zero_ids(Arc::make_mut(c)); } - n.mask = None; + n.extras_mut().mask = None; n } }; @@ -994,7 +994,7 @@ fn paints_opaquely(n: &Node) -> bool { }; n.opacity >= 1.0 && n.blend == vectorcraft_color::BlendMode::Normal - && n.mask.is_none() + && n.extras().mask.is_none() && n.appearance.effects.is_empty() && n.appearance .items diff --git a/crates/doc/src/corners.rs b/crates/doc/src/corners.rs index a618ee1b..986442b2 100644 --- a/crates/doc/src/corners.rs +++ b/crates/doc/src/corners.rs @@ -60,7 +60,7 @@ impl<'a> LiveCorners<'a> { /// The Live Corners of a path object. pub fn of(node: &'a Node) -> Option { let NodeKind::Path { path, live, .. } = &node.kind else { return None }; - Self::new(path, live.as_ref()) + Self::new(path, live.as_deref()) } /// The corner at anchor `index` of `base`. @@ -127,7 +127,7 @@ impl<'a> LiveCorners<'a> { /// A path that isn't a live shape keeps its uncut outline as a [`LiveShape::Path`], which is a /// plain path again once no corner is cut. Ellipses and lines have no corners: they stay as they /// are. -pub fn set_corners(path: &mut PathData, live: &mut Option, corners: &BTreeSet, radius: Option, kind: Option) { +pub fn set_corners(path: &mut PathData, live: &mut Option>, corners: &BTreeSet, radius: Option, kind: Option) { fn edit(radii: &mut [f64], kinds: &mut [CornerKind], corners: &BTreeSet, radius: Option, kind: Option) { for k in corners { if let (Some(r), Some(slot)) = (radius, radii.get_mut(*k)) { @@ -158,17 +158,17 @@ pub fn set_corners(path: &mut PathData, live: &mut Option, corners: & kinds.clear(); } } - let l = live.get_or_insert_with(|| LiveShape::Path { base: path.clone(), radii: vec![], kinds: vec![] }); + let l = live.get_or_insert_with(|| Box::new(LiveShape::Path { base: path.clone(), radii: vec![], kinds: vec![] })); // Radii are document lengths and corners circular, also on a rectangle from a file that kept // an uneven scale in its transform (#442). l.fold_scale(); - match l { + match &mut **l { LiveShape::Rectangle { radii, kinds, .. } => edit(radii, kinds, corners, radius, kind), LiveShape::Polygon { sides, radii, kinds, .. } => edit_vec(*sides as usize, radii, kinds, corners, radius, kind), LiveShape::Path { base, radii, kinds } => edit_vec(base.anchor_count(), radii, kinds, corners, radius, kind), LiveShape::Ellipse { .. } | LiveShape::Line { .. } => return, } - match live { + match live.as_deref_mut() { Some(LiveShape::Path { base, radii, kinds }) if radii.is_empty() && kinds.is_empty() => { *path = std::mem::take(base); *live = None; @@ -220,15 +220,15 @@ mod tests { let star = shapes::star(Point::new(100.0, 100.0), 50.0, 25.0, 5, 0.0); let (mut path, mut live) = (star.clone(), None); set_corners(&mut path, &mut live, &set(&[0, 2]), Some(5.0), None); - let Some(LiveShape::Path { base, radii, kinds }) = &live else { panic!("live corners: {live:?}") }; + let Some(LiveShape::Path { base, radii, kinds }) = live.as_deref() else { panic!("live corners: {live:?}") }; assert_eq!((base, radii.len(), kinds.len()), (&star, 10, 0)); assert_eq!(path.anchor_count(), 12, "two corners cut"); - let c = LiveCorners::new(&path, live.as_ref()).unwrap(); + let c = LiveCorners::new(&path, live.as_deref()).unwrap(); assert_eq!(c.style(&set(&[0, 2])), (Some(5.0), Some(CornerKind::Round))); assert_eq!(c.corners_of(&anchors(&[0, 11])), set(&[0]), "the first corner's cut ends the path"); // Kinds alone keep it live; squaring every corner makes it the plain star again. set_corners(&mut path, &mut live, &set(&[0, 2]), Some(0.0), Some(CornerKind::Chamfer)); - assert!(matches!(&live, Some(LiveShape::Path { radii, .. }) if radii.is_empty())); + assert!(matches!(live.as_deref(), Some(LiveShape::Path { radii, .. }) if radii.is_empty())); set_corners(&mut path, &mut live, &set(&[0, 2]), None, Some(CornerKind::Round)); assert_eq!((path, live), (star, None)); } @@ -237,16 +237,16 @@ mod tests { #[test] fn polygon_corners_follow_their_sides() { let mut l = LiveShape::Polygon { radius: 50.0, sides: 6, xf: Affine::translate((100.0, 100.0)), radii: vec![], kinds: vec![] }; - let (mut path, mut live) = (l.to_path(), Some(l.clone())); + let (mut path, mut live) = (l.to_path(), Some(Box::new(l.clone()))); set_corners(&mut path, &mut live, &set(&[0, 1, 2, 3, 4, 5]), Some(8.0), None); assert_eq!(path.anchor_count(), 12); - l = live.clone().unwrap(); + l = *live.clone().unwrap(); l.set_sides(8); assert!(matches!(&l, LiveShape::Polygon { radii, .. } if *radii == vec![8.0; 8])); assert_eq!(l.to_path().anchor_count(), 16); // Mixed radii: the new corners are sharp. set_corners(&mut path, &mut live, &set(&[1]), Some(3.0), None); - let mut l = live.unwrap(); + let mut l = *live.unwrap(); l.set_sides(7); assert!(matches!(&l, LiveShape::Polygon { radii, .. } if *radii == [8.0, 3.0, 8.0, 8.0, 8.0, 8.0, 0.0])); // An uneven scale keeps the cuts circular: the radius scales by the mean scale. diff --git a/crates/doc/src/graph.rs b/crates/doc/src/graph.rs index abb40fe8..5dc01840 100644 --- a/crates/doc/src/graph.rs +++ b/crates/doc/src/graph.rs @@ -104,8 +104,11 @@ impl GraphDesign { /// graph of its own (that its commands would then edit). pub fn plain_art(art: &crate::Node) -> crate::Node { let mut n = art.clone(); - n.graph = None; - n.series_index = None; + { + let mut e = n.extras_mut(); + e.graph = None; + e.series_index = None; + } if let Some(ch) = n.children_mut() { let old = std::mem::take(ch); *ch = old.iter().map(|c| std::sync::Arc::new(Self::plain_art(c))).collect(); diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index d11c8983..226241c8 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -157,7 +157,7 @@ fn hit_children( return None; } for (i, c) in children.iter().enumerate().rev() { - if parent.shaper.is_some() { + if parent.extras().shaper.is_some() { let construction = children.first().is_some_and(|source| Some(source.id) == opt.scope); if (i == 0) != construction { continue; @@ -169,7 +169,7 @@ fn hit_children( } // (An envelope's content sits where it was, not where the envelope draws it.) if !(edits_contents(c) - || c.shaper.is_some() && c.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == opt.scope)) + || c.extras().shaper.is_some() && c.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == opt.scope)) && let Some(b) = c.reach_bounds() && !b.inflate(opt.tol, opt.tol).contains(p) { @@ -534,7 +534,7 @@ mod tests { let l = d.layers[0].id; let mut add = |t: TextObject, perspective: Option| { let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); - n.perspective = perspective.map(|a| { + n.extras_mut().perspective = perspective.map(|a| { Box::new(PerspectiveAttachment { projection: Some(Homography::from_affine(a).to_array()), ..PerspectiveAttachment::new("left", 0.0) }) }); let id = n.id; diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 26351869..b5446cae 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -86,8 +86,8 @@ pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, Gradient pub use metadata::{CopyrightStatus, DocMetadata}; pub use node::Knockout; pub use node::Scaling; +pub use node::{Extras, ExtrasMut, ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeExtras, NodeId, NodeKind, OpacityMask}; pub use node::{ImageMap, ObjectAttributes}; -pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind, OpacityMask}; pub use orient::OrientedBox; pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType}; pub use perspective::PerspectiveAttachment; diff --git a/crates/doc/src/links.rs b/crates/doc/src/links.rs index 43159b0f..7abcec7c 100644 --- a/crates/doc/src/links.rs +++ b/crates/doc/src/links.rs @@ -136,7 +136,7 @@ fn proxy_png(bytes: &[u8]) -> Option> { /// `n` and every descendant, opacity-mask art included. fn walk_all<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) { f(n); - if let Some(m) = &n.mask { + if let Some(m) = &n.extras().mask { walk_all(&m.art, f); } for c in n.children().into_iter().flatten() { @@ -295,7 +295,7 @@ impl Document { NodeKind::PlacedDocument(placed) => f(&mut placed.link), _ => {} } - if let Some(mask) = &mut n.mask { + if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { walk(&mut mask.art, f); } for child in n.children_mut().into_iter().flatten() { @@ -331,7 +331,7 @@ impl Document { { f(im); } - if let Some(m) = &mut n.mask { + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { update(&mut m.art, pick, f); } for c in n.children_mut().into_iter().flatten() { @@ -370,7 +370,14 @@ mod tests { 0, Node::new( id, - NodeKind::Image(ImageObject { key: key.into(), width: 4, height: 4, xf: Affine::IDENTITY, link, placement: Default::default() }), + NodeKind::Image(Box::new(ImageObject { + key: key.into(), + width: 4, + height: 4, + xf: Affine::IDENTITY, + link, + placement: Default::default(), + })), ), ) .unwrap() diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 0759e293..d7d7d1e9 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -1222,7 +1222,7 @@ pub fn envelope_of(doc: &crate::Document, id: NodeId) -> Option<&Node> { /// edit (its points and handles). pub fn envelope_overlay(n: &Node) -> Option<(PathData, Option)> { let NodeKind::Envelope { kind, .. } = &n.kind else { return None }; - match kind { + match &**kind { EnvelopeKind::Mesh { rows, cols, points, handles } => { let grid = envelope_grid(*rows, *cols, points, handles)?; Some((grid.lines(), Some(grid))) @@ -1270,7 +1270,7 @@ impl<'a> EnvelopeMap<'a> { /// The map of envelope node `n` (`None` for other nodes). pub fn of(n: &'a Node) -> Option { match &n.kind { - NodeKind::Envelope { content, kind, frame, .. } => Self::new(content, kind, *frame), + NodeKind::Envelope { content, kind, frame, .. } => Self::new(content, kind, **frame), _ => None, } } @@ -1388,7 +1388,7 @@ fn carry_look(from: &Node, mut to: Node) -> Node { to.isolate = from.isolate; to.knockout = from.knockout; to.knockout_shape = from.knockout_shape; - to.mask = from.mask.clone(); + to.extras_mut().mask = from.extras().mask.clone(); to.appearance.effects = from.appearance.effects.clone(); to } @@ -1417,7 +1417,7 @@ fn map_node(n: &Node, w: &Warper) -> Node { { out.appearance.warp_linear_gradients(b, &|p| affine_near(w.f, p, w.eps)); } - if let Some(m) = &mut out.mask { + if let Some(m) = out.extras_if_any().and_then(|e| e.mask.as_mut()) { m.art = Arc::new(map_node(&m.art, w)); } match &mut out.kind { @@ -1494,7 +1494,7 @@ fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node { if let NodeKind::Path { clipping, .. } = &mut clip.kind { *clipping = true; } - let image = Node::new(n.id, NodeKind::Image(ImageObject { xf, ..im.clone() })); + let image = Node::new(n.id, NodeKind::Image(Box::new(ImageObject { xf, ..im.clone() }))); pieces.push(Arc::new(Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true }))); } } @@ -1523,17 +1523,18 @@ fn pattern_art(n: &Node, tiles: PatternTiles) -> Option { _ => None, }; let plain = |items: Vec| { - Arc::new(Node { + let mut p = Node { appearance: Appearance { items, ..Default::default() }.into(), opacity: 1.0, blend: Default::default(), isolate: false, knockout: Default::default(), knockout_shape: false, - mask: None, name: None, ..n.clone() - }) + }; + p.extras_mut().mask = None; + Arc::new(p) }; let mut pieces = vec![]; let mut rest = vec![]; @@ -1640,7 +1641,7 @@ pub fn expand_live_with(n: &Node, outline: Outliner) -> Vec { pub fn expand_live_hooks(n: &Node, hooks: Hooks) -> Vec { match &n.kind { NodeKind::Blend { children, spec } => blend_expand(children, spec), - NodeKind::Envelope { content, kind, fidelity, options, frame, .. } => expand_envelope(content, kind, *fidelity, *options, *frame, hooks), + NodeKind::Envelope { content, kind, fidelity, options, frame, .. } => expand_envelope(content, kind, *fidelity, *options, **frame, hooks), NodeKind::Mesh(m) => mesh_quad_nodes(m, 8), NodeKind::Repeat(r) => r.expand(), NodeKind::PlacedDocument(p) => p.art(n), @@ -1669,7 +1670,7 @@ pub fn expanded_group_hooks(n: &Node, hooks: Hooks) -> Node { g.knockout = crate::Knockout::Off; } g.knockout_shape = n.knockout_shape; - g.mask = n.mask.clone(); + g.extras_mut().mask = n.extras().mask.clone(); g } diff --git a/crates/doc/src/mem.rs b/crates/doc/src/mem.rs index 1162e8a6..1a51c4d7 100644 --- a/crates/doc/src/mem.rs +++ b/crates/doc/src/mem.rs @@ -2,9 +2,11 @@ //! //! Documents share structure (`Arc` children, [`crate::Shared`] resources), so an old //! version kept in the undo history holds only what the edits after it replaced. The estimate -//! walks the old version beside the newer one and visits only what isn't shared: a subtree whose -//! `Arc` the newer document also holds costs nothing and isn't entered, so the walk is about as -//! long as the edit was (plus a pass over each changed child list). It counts the heap the values +//! walks two versions side by side and visits only what isn't shared: a subtree whose `Arc` both +//! hold costs nothing and isn't entered, so the walk is about as long as the edit was (plus a pass +//! over each changed child list). One walk gives both directions: what the older version keeps +//! that the newer one doesn't (the undo step's cost) and the other way round (the cost of the redo +//! step that undoing it makes), so undo and redo needn't walk again. It counts the heap the values //! own (struct sizes, vector capacities, strings, JSON) plus a typical allocator overhead per //! allocation, without following every last field: an estimate in the right ballpark, for a //! memory budget, not an exact figure. @@ -15,17 +17,18 @@ use std::sync::Arc; use serde_json::Value; use vectorcraft_color::{Paint, Swatch, SwatchGroup}; -use vectorcraft_geom::{Anchor, PathData, SubPath}; +use vectorcraft_geom::{Affine, Anchor, PathData, SubPath}; +use crate::live::{BlendSpec, EnvelopeKind}; use crate::text::{ParaStyle, TextObject, TextRun, TextStyleDef}; -use crate::{Appearance, AppearanceItem, Document, Effect, GraphicStyle, Node, NodeKind, SavedSelection, Shared}; +use crate::{Appearance, AppearanceItem, Document, Effect, GraphicStyle, ImageObject, Node, NodeExtras, NodeKind, SavedSelection, Shared}; /// Bookkeeping and rounding a typical allocator adds to each allocation. const ALLOC: usize = 16; /// An `Arc`'s two reference counts. const ARC: usize = 16; -/// The kinds of node this doesn't break down (meshes, envelopes' settings, placed documents…): -/// a typical size of what they own besides the node. +/// The kinds of node this doesn't break down (meshes, repeats' settings, placed documents…): a +/// typical size of what they own besides the node. const OTHER_KIND: usize = 256; impl Document { @@ -33,23 +36,48 @@ impl Document { /// keeping this version in the undo history costs while `newer` (a later version) is kept too. /// Visits only the parts that aren't shared (see the [module docs](self)). pub fn retained_bytes(&self, newer: &Document) -> usize { - if std::ptr::eq(self, newer) { - return 0; + self.retained_bytes_each(newer).0 + } + + /// [`Document::retained_bytes`] both ways in one walk: what this document keeps that `other` + /// doesn't share, and what `other` keeps that this one doesn't. + pub fn retained_bytes_each(&self, other: &Document) -> (usize, usize) { + if std::ptr::eq(self, other) { + return (0, 0); } - let mut w = Walk { newer, seen: None, bytes: size_of::() + ALLOC + ARC }; - w.list(&self.layers, Some(&newer.layers)); - for (i, s) in self.symbols.iter().enumerate() { - w.bytes += s.name.capacity(); - w.tree(&s.art, newer.symbols.get(i).map(|n| &n.art)); + let own = size_of::() + ALLOC + ARC; + let mut w = Walk { docs: [self, other], seen: [None, None], bytes: [own, own] }; + w.list(&self.layers, &other.layers); + for i in 0..self.symbols.len().max(other.symbols.len()) { + match (self.symbols.get(i), other.symbols.get(i)) { + (Some(a), Some(b)) => w.pair(&a.art, &b.art), + (a, b) => { + a.into_iter().for_each(|a| w.alone(0, &a.art)); + b.into_iter().for_each(|b| w.alone(1, &b.art)); + } + } } - w.bytes += vec_bytes(&self.symbols); - for (i, p) in self.patterns.iter().enumerate() { - w.bytes += p.name.capacity(); - w.list(&p.art, newer.patterns.get(i).map(|n| &n.art)); + for i in 0..self.patterns.len().max(other.patterns.len()) { + match (self.patterns.get(i), other.patterns.get(i)) { + (Some(a), Some(b)) => w.list(&a.art, &b.art), + (a, b) => { + a.into_iter().flat_map(|a| &a.art).for_each(|n| w.alone(0, n)); + b.into_iter().flat_map(|b| &b.art).for_each(|n| w.alone(1, n)); + } + } } - w.bytes += vec_bytes(&self.patterns); - w.bytes += self.resources_bytes(newer); - w.bytes + let [mut a, mut b] = w.bytes; + a += self.lists_bytes() + self.resources_bytes(other); + b += other.lists_bytes() + other.resources_bytes(self); + (a, b) + } + + /// The symbol and pattern lists themselves (their art is counted as nodes). + fn lists_bytes(&self) -> usize { + vec_bytes(&self.symbols) + + self.symbols.iter().map(|s| s.name.capacity()).sum::() + + vec_bytes(&self.patterns) + + self.patterns.iter().map(|p| p.name.capacity()).sum::() } /// The document-wide lists and settings: a copy of the document copies those that aren't @@ -92,47 +120,99 @@ impl Document { } } -/// One estimate in progress. +/// One estimate in progress: two documents walked side by side. struct Walk<'a> { - newer: &'a Document, - /// Every node the newer document holds (by address), built the first time a node has no - /// counterpart in place: then a node moved elsewhere (grouped, say) is still found shared. - seen: Option>, - bytes: usize, + docs: [&'a Document; 2], + /// For each side, every node the other document holds (by address), built the first time a + /// node of that side that the other may share has no counterpart in place: then a node moved + /// elsewhere (grouped, say) is still found shared. + seen: [Option>; 2], + /// For each side, what it keeps that the other doesn't. + bytes: [usize; 2], } impl Walk<'_> { - /// The nodes of `list` that `newer` (the counterpart list, if any) doesn't share. - fn list(&mut self, list: &[Arc], newer: Option<&Vec>>) { - for (i, n) in list.iter().enumerate() { - self.tree(n, newer.and_then(|l| l.get(i))); + /// Two versions of a child list: the nodes in the same place on both sides (the same `Arc`, or + /// versions of the same object) are compared in place; the others (inserted, deleted, moved) + /// may still be shared from elsewhere. + fn list(&mut self, a: &[Arc], b: &[Arc]) { + let same = |x: &Arc, y: &Arc| Arc::ptr_eq(x, y) || x.id == y.id; + // In place from the start and from the end: an insertion or deletion shifts only what is + // between. + let head = a.iter().zip(b).take_while(|(x, y)| same(x, y)).count(); + let (a_rest, b_rest) = (a.get(head..).unwrap_or_default(), b.get(head..).unwrap_or_default()); + let tail = a_rest.iter().rev().zip(b_rest.iter().rev()).take_while(|(x, y)| same(x, y)).count(); + for (x, y) in a.iter().zip(b).take(head).chain(a_rest.iter().rev().zip(b_rest.iter().rev()).take(tail)) { + self.pair(x, y); + } + let a_mid = a_rest.get(..a_rest.len().saturating_sub(tail)).unwrap_or_default(); + let b_mid = b_rest.get(..b_rest.len().saturating_sub(tail)).unwrap_or_default(); + for i in 0..a_mid.len().max(b_mid.len()) { + match (a_mid.get(i), b_mid.get(i)) { + (Some(x), Some(y)) if same(x, y) => self.pair(x, y), + (x, y) => { + x.into_iter().for_each(|x| self.alone(0, x)); + y.into_iter().for_each(|y| self.alone(1, y)); + } + } } } - /// Node `n` and what under it isn't shared; `newer` is the node in its place in the newer - /// document, if any. - fn tree(&mut self, n: &Arc, newer: Option<&Arc>) { - if newer.is_some_and(|m| Arc::ptr_eq(n, m)) { + /// Node `a` and `b` in the same place on the two sides: nothing when they are the same `Arc`, + /// both nodes and what under them isn't shared when they are versions of the same object. + fn pair(&mut self, a: &Arc, b: &Arc) { + if Arc::ptr_eq(a, b) { return; } - // In its place is another version of the same object (edited, moved): compare their - // children in place. Else it may sit elsewhere in the newer document. - let counterpart = newer.filter(|m| m.id == n.id); - if counterpart.is_none() && self.newer_holds(n) { + if a.id != b.id { + self.alone(0, a); + self.alone(1, b); return; } - self.bytes += node_bytes(n, counterpart.map(|m| &**m)); - if let Some(ch) = n.children() { - self.list(ch, counterpart.and_then(|m| m.children())); + self.bytes[0] += node_bytes(a, Some(b)); + self.bytes[1] += node_bytes(b, Some(a)); + match (a.children(), b.children()) { + (Some(x), Some(y)) => self.list(x, y), + (x, y) => { + x.into_iter().flatten().for_each(|c| self.alone(0, c)); + y.into_iter().flatten().for_each(|c| self.alone(1, c)); + } } - if let Some(mask) = &n.mask { - self.tree(&mask.art, counterpart.and_then(|m| m.mask.as_ref()).map(|m| &m.art)); + match (&a.extras().mask, &b.extras().mask) { + (Some(x), Some(y)) => self.pair(&x.art, &y.art), + (x, y) => { + x.iter().for_each(|m| self.alone(0, &m.art)); + y.iter().for_each(|m| self.alone(1, &m.art)); + } } } - fn newer_holds(&mut self, n: &Arc) -> bool { - let newer = self.newer; - let seen = self.seen.get_or_insert_with(|| { + /// Node `n` of side `side` without a counterpart in place, and what under it the other side + /// doesn't hold. + fn alone(&mut self, side: usize, n: &Arc) { + if self.other_holds(side, n) { + return; + } + if let Some(b) = self.bytes.get_mut(side) { + *b += node_bytes(n, None); + } + for c in n.children().into_iter().flatten() { + self.alone(side, c); + } + if let Some(m) = &n.extras().mask { + self.alone(side, &m.art); + } + } + + /// Whether the document across from `side` holds node `n` somewhere. + fn other_holds(&mut self, side: usize, n: &Arc) -> bool { + // Held only where it was found: not in the other document (which would hold it too). So + // new art inserted by the edit is told apart without looking. + if Arc::strong_count(n) == 1 { + return false; + } + let (Some(other), Some(seen)) = (self.docs.get(1 - side.min(1)), self.seen.get_mut(side)) else { return false }; + let seen = seen.get_or_insert_with(|| { fn add(n: &Arc, out: &mut HashSet) { // A node already seen was entered already (a node shared within the document). if !out.insert(Arc::as_ptr(n) as usize) { @@ -141,12 +221,12 @@ impl Walk<'_> { for c in n.children().into_iter().flatten() { add(c, out); } - if let Some(m) = &n.mask { + if let Some(m) = &n.extras().mask { add(&m.art, out); } } let mut out = HashSet::new(); - let roots = newer.layers.iter().chain(newer.symbols.iter().map(|s| &s.art)).chain(newer.patterns.iter().flat_map(|p| &p.art)); + let roots = other.layers.iter().chain(other.symbols.iter().map(|s| &s.art)).chain(other.patterns.iter().flat_map(|p| &p.art)); for n in roots { add(n, &mut out); } @@ -157,28 +237,29 @@ impl Walk<'_> { } /// One node's own heap: the node, and what it owns besides its children and mask art (counted -/// as nodes of their own). Its appearance counts unless `newer`, its newer version, shares it. -fn node_bytes(n: &Node, newer: Option<&Node>) -> usize { +/// as nodes of their own). Its appearance counts unless `other`, its other version, shares it. +fn node_bytes(n: &Node, other: Option<&Node>) -> usize { fn boxed(b: &Option>) -> usize { b.as_ref().map_or(0, |_| ALLOC + size_of::()) } let mut b = ALLOC + ARC + size_of::(); b += n.name.as_ref().map_or(0, String::capacity); - if !newer.is_some_and(|m| Shared::ptr_eq(&m.appearance, &n.appearance)) { + if !other.is_some_and(|m| Shared::ptr_eq(&m.appearance, &n.appearance)) { b += ALLOC + ARC + appearance_heap(&n.appearance); } - b += boxed(&n.mask) + boxed(&n.graph) + boxed(&n.shaper) + boxed(&n.attrs) + boxed(&n.slice) + boxed(&n.perspective); - b += n.trace.as_ref().map_or(0, |t| ALLOC + json_bytes(t)); + if let Some(e) = n.extras_box() { + b += ALLOC + size_of::(); + b += boxed(&e.mask) + boxed(&e.graph) + boxed(&e.shaper) + boxed(&e.attrs) + boxed(&e.slice) + boxed(&e.perspective); + b += e.trace.as_ref().map_or(0, |t| ALLOC + size_of::() + json_bytes(t)); + } b += match &n.kind { - NodeKind::Path { path, .. } => path_bytes(path), - NodeKind::Layer { children, .. } - | NodeKind::Group { children, .. } - | NodeKind::Compound { children, .. } - | NodeKind::Blend { children, .. } - | NodeKind::Envelope { content: children, .. } => vec_bytes(children), + NodeKind::Path { path, live, .. } => path_bytes(path) + boxed(live), + NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => vec_bytes(children), + NodeKind::Blend { children, .. } => vec_bytes(children) + ALLOC + size_of::(), + NodeKind::Envelope { content, .. } => vec_bytes(content) + 2 * ALLOC + size_of::() + size_of::(), NodeKind::Repeat(r) => vec_bytes(&r.source) + OTHER_KIND, NodeKind::Text(t) => ALLOC + text_bytes(t), - NodeKind::Image(im) => im.key.capacity(), + NodeKind::Image(im) => ALLOC + size_of::() + im.key.capacity(), NodeKind::SymbolInstance { symbol, .. } => symbol.capacity(), _ => OTHER_KIND, }; @@ -273,7 +354,8 @@ mod tests { } /// Moving every rectangle: each keeps its old node and anchors (its appearance stays shared). - /// The process grew by about 775 bytes per moved rectangle when measured (#1244). + /// The process grew by about 775 bytes per moved rectangle when measured (#1244), about 490 + /// once nodes kept their rarely set parts out of line. #[test] fn moving_rectangles_costs_their_nodes() { let n = 1000; @@ -307,6 +389,26 @@ mod tests { assert!(bytes < 8192, "only the old layer and its child list: {bytes}"); } + /// One walk gives both ways, each what a walk the other way gives; an insertion and a deletion + /// cost the side that has the objects. + #[test] + fn both_ways_at_once() { + let before = doc_with_rects(100); + let mut after = before.clone(); + let layer = after.layers[0].id; + for at in [0, 50, usize::MAX] { + let id = after.alloc_id(); + let r = shapes::rectangle(kurbo::Rect::new(0.0, 0.0, 10.0, 10.0)); + after.insert(Some(layer), at, Node::path(id, r, Appearance::default_art())).unwrap(); + } + let (kept, added) = before.retained_bytes_each(&after); + assert_eq!((kept, added), (before.retained_bytes(&after), after.retained_bytes(&before))); + assert_eq!(after.retained_bytes_each(&before), (added, kept)); + // The old layer and its list are the old version's own; the new one holds three new nodes. + assert!(kept < 4096, "{kept}"); + assert!(added > kept + 3 * 300, "{added} for three new rectangles"); + } + #[test] fn a_resource_counts_only_when_changed() { let before = doc_with_rects(1); diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 9eb4b0cd..06db3cc8 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -427,8 +427,9 @@ pub enum NodeKind { path: PathData, #[serde(default, skip_serializing_if = "crate::skip::is_default")] rule: FillRule, + /// Boxed: few paths are live shapes, and the shape is larger than the rest of the variant. #[serde(default, skip_serializing_if = "Option::is_none")] - live: Option, + live: Option>, /// This path acts as a clipping path inside a clip group. #[serde(default, skip_serializing_if = "crate::skip::is_default")] clipping: bool, @@ -443,7 +444,8 @@ pub enum NodeKind { rule: FillRule, }, Text(Box), - Image(ImageObject), + /// Boxed, as the other large kinds: a node is as large as its largest kind. + Image(Box), SymbolInstance { symbol: String, xf: Affine, @@ -453,12 +455,12 @@ pub enum NodeKind { Blend { children: Vec>, #[serde(default)] - spec: BlendSpec, + spec: Box, }, /// Live envelope distortion of `content`. Envelope { content: Vec>, - kind: EnvelopeKind, + kind: Box, /// Envelope Options → Fidelity (0–100). #[serde(default = "crate::live::default_fidelity")] fidelity: f64, @@ -472,7 +474,7 @@ pub enum NodeKind { /// the page axes can't stand for (rotation, shear, reflection). The content maps from its /// bounds in this frame, and a warp bends along it. #[serde(default, skip_serializing_if = "crate::skip::is_default")] - frame: Affine, + frame: Box, }, /// Gradient mesh. Mesh(GradientMesh), @@ -491,72 +493,328 @@ fn one() -> f32 { } /// One object in the tree. Children are `Arc`s so documents share structure between undo states. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +/// +/// What most objects have is kept inline; what few have is in [`NodeExtras`], allocated only for +/// the objects that have any of it: every undo step of an edit copies the nodes it changed, so a +/// small node is a cheap step. Files keep the flat field list either way: a node is written field +/// by field from where each lives, and read through a flat `NodeWire`. +#[derive(Clone, Debug, PartialEq, Deserialize)] +#[serde(from = "NodeWire")] pub struct Node { pub id: NodeId, - #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, - #[serde(default = "yes", skip_serializing_if = "crate::skip::is_true")] pub visible: bool, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub locked: bool, - #[serde(default = "one", skip_serializing_if = "crate::skip::is_one")] pub opacity: f32, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub blend: BlendMode, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub isolate: bool, /// Knockout Group: whether a container's children knock each other out (see [`Knockout`]). - #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub knockout: Knockout, /// Opacity & Mask Define Knockout Shape: inside a knockout group, this object's opacity and /// opacity mask scale how much of the objects below it in the group it knocks out. - #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub knockout_shape: bool, - #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub appearance: crate::Shared, - /// Opacity mask (Transparency panel). Its art lives here, outside the layer tree. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub mask: Option>, - /// Image Trace object: `{preset, params, view?}` it was traced with (the Image Trace panel shows - /// them; `view` is its [`crate::TraceView`] id, absent for the tracing result). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub trace: Option>, - /// Object → Text Wrap: area type below this object (in the same layer) flows around it. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub wrap: Option, - /// Graph object: the group's children are generated from this spec (Object → Graph). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub graph: Option>, - /// Index of the graph series this generated group belongs to. Axes, the legend and other - /// children leave it unset, so a series named "Legend" or "Axes" is not mistaken for them. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub series_index: Option, - /// Editable Shaper composition; the original art is retained in its source child. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub shaper: Option>, pub kind: NodeKind, - /// The [`crate::GraphicStyle::id`] last applied to this object. It stays linked while it keeps - /// that style's look: editing its appearance or transparency breaks the link. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub graphic_style: Option, - /// Attributes panel: centre point display, image map, URL and note (`None`: all defaults). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub attrs: Option>, - /// Object → Slice → Make: the object is an object slice (its slice follows its bounds) with - /// these options. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub slice: Option>, /// The angle of the object's own axes, counter-clockwise degrees (0: square to the page): its /// bounding box and handles stand at this angle. Transforms turn it with the object (see /// [`crate::orient`]); Reset Bounding Box sets it back to 0. - #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub bbox_angle: f64, + /// The rarely set parts, read with [`Node::extras`] and changed with [`Node::extras_mut`] + /// (public so that `Node { opacity: 1.0, ..node }` works). + pub extras: Extras, +} + +/// The parts of a [`Node`] that few objects have: kept out of line, so that the nodes without +/// them stay small ([`Node::extras`]). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct NodeExtras { + /// Opacity mask (Transparency panel). Its art lives here, outside the layer tree. + pub mask: Option>, + /// Image Trace object: `{preset, params, view?}` it was traced with (the Image Trace panel shows + /// them; `view` is its [`crate::TraceView`] id, absent for the tracing result). + pub trace: Option>, + /// Object → Text Wrap: area type below this object (in the same layer) flows around it. + pub wrap: Option, + /// Graph object: the group's children are generated from this spec (Object → Graph). + pub graph: Option>, + /// Index of the graph series this generated group belongs to. Axes, the legend and other + /// children leave it unset, so a series named "Legend" or "Axes" is not mistaken for them. + pub series_index: Option, + /// Editable Shaper composition; the original art is retained in its source child. + pub shaper: Option>, + /// The [`crate::GraphicStyle::id`] last applied to this object. It stays linked while it keeps + /// that style's look: editing its appearance or transparency breaks the link. + pub graphic_style: Option, + /// Attributes panel: centre point display, image map, URL and note (`None`: all defaults). + pub attrs: Option>, + /// Object → Slice → Make: the object is an object slice (its slice follows its bounds) with + /// these options. + pub slice: Option>, /// Object › Perspective: the perspective grid plane the object is attached to. - #[serde(default, skip_serializing_if = "Option::is_none")] pub perspective: Option>, } +/// No extras: what [`Node::extras`] gives for a node without any. +static NO_EXTRAS: NodeExtras = NodeExtras { + mask: None, + trace: None, + wrap: None, + graph: None, + series_index: None, + shaper: None, + graphic_style: None, + attrs: None, + slice: None, + perspective: None, +}; + +/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal +/// none. +#[derive(Clone, Debug, Default)] +pub struct Extras(Option>); + +impl Extras { + fn get(&self) -> &NodeExtras { + self.0.as_deref().unwrap_or(&NO_EXTRAS) + } +} + +impl PartialEq for Extras { + fn eq(&self, other: &Self) -> bool { + match (&self.0, &other.0) { + (Some(a), Some(b)) => a == b, + _ => self.get() == other.get(), + } + } +} + +impl Node { + /// The rarely set parts of this node (all unset when it has none). + #[inline] + pub fn extras(&self) -> &NodeExtras { + self.extras.get() + } + /// The rarely set parts of this node, to change: when the change is done (the guard dropped), + /// extras that were set are allocated and extras left all unset are let go. Setting one to + /// `None` on a node without extras allocates nothing. + #[inline] + pub fn extras_mut(&mut self) -> ExtrasMut<'_> { + ExtrasMut { slot: &mut self.extras.0, local: NodeExtras::default() } + } + /// The rarely set parts of this node, if it has any: to change what is there in place. + #[inline] + pub fn extras_if_any(&mut self) -> Option<&mut NodeExtras> { + self.extras.0.as_deref_mut() + } + /// The live shape of a path object, if it is one. + pub fn live_shape(&self) -> Option<&LiveShape> { + match &self.kind { + NodeKind::Path { live, .. } => live.as_deref(), + _ => None, + } + } + /// The live shape of a path object, if it is one, to change. + pub fn live_shape_mut(&mut self) -> Option<&mut LiveShape> { + match &mut self.kind { + NodeKind::Path { live, .. } => live.as_deref_mut(), + _ => None, + } + } + /// This node with opacity mask `mask` (or none). + pub fn with_mask(mut self, mask: Option>) -> Self { + self.extras_mut().mask = mask; + self + } + /// The extras, if this node has them allocated (for [`crate::mem`]'s estimate). + pub(crate) fn extras_box(&self) -> Option<&NodeExtras> { + self.extras.0.as_deref() + } +} + +/// [`Node::extras_mut`]: the node's extras while they change. +pub struct ExtrasMut<'a> { + slot: &'a mut Option>, + /// Stands for the extras of a node without any, boxed into it when dropped if one was set. + local: NodeExtras, +} + +impl std::ops::Deref for ExtrasMut<'_> { + type Target = NodeExtras; + fn deref(&self) -> &NodeExtras { + self.slot.as_deref().unwrap_or(&self.local) + } +} + +impl std::ops::DerefMut for ExtrasMut<'_> { + fn deref_mut(&mut self) -> &mut NodeExtras { + match self.slot.as_deref_mut() { + Some(e) => e, + None => &mut self.local, + } + } +} + +impl Drop for ExtrasMut<'_> { + fn drop(&mut self) { + match self.slot.as_deref() { + Some(e) if *e == NO_EXTRAS => *self.slot = None, + Some(_) => {} + None if self.local != NO_EXTRAS => *self.slot = Some(Box::new(std::mem::take(&mut self.local))), + None => {} + } + } +} + +/// How a [`Node`] is written: the flat field list of the file format, in its order, borrowed from +/// the node and its extras (nothing is copied). The node reads back through `NodeWire`. +impl Serialize for Node { + fn serialize(&self, s: S) -> Result { + use crate::skip::{is_default, is_one, is_true}; + use serde::ser::SerializeStruct; + let e = self.extras(); + // As the derive did: the fields written, then each field written or skipped in order. + let skips = [ + false, + self.name.is_none(), + is_true(&self.visible), + is_default(&self.locked), + is_one(&self.opacity), + is_default(&self.blend), + is_default(&self.isolate), + is_default(&self.knockout), + is_default(&self.knockout_shape), + is_default(&self.appearance), + e.mask.is_none(), + e.trace.is_none(), + e.wrap.is_none(), + e.graph.is_none(), + e.series_index.is_none(), + e.shaper.is_none(), + false, + e.graphic_style.is_none(), + e.attrs.is_none(), + e.slice.is_none(), + is_default(&self.bbox_angle), + e.perspective.is_none(), + ]; + let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?; + let mut skip = skips.into_iter(); + macro_rules! field { + ($name:literal, $v:expr) => { + if skip.next().unwrap_or(false) { + st.skip_field($name)?; + } else { + st.serialize_field($name, $v)?; + } + }; + } + field!("id", &self.id); + field!("name", &self.name); + field!("visible", &self.visible); + field!("locked", &self.locked); + field!("opacity", &self.opacity); + field!("blend", &self.blend); + field!("isolate", &self.isolate); + field!("knockout", &self.knockout); + field!("knockout_shape", &self.knockout_shape); + field!("appearance", &self.appearance); + field!("mask", &e.mask); + field!("trace", &e.trace); + field!("wrap", &e.wrap); + field!("graph", &e.graph); + field!("series_index", &e.series_index); + field!("shaper", &e.shaper); + field!("kind", &self.kind); + field!("graphic_style", &e.graphic_style); + field!("attrs", &e.attrs); + field!("slice", &e.slice); + field!("bbox_angle", &self.bbox_angle); + field!("perspective", &e.perspective); + st.end() + } +} + +/// How a [`Node`] is read: the flat field list of the file format. Fields move into the node +/// (and its extras, allocated only when one is set) without a copy of what they own. +#[derive(Deserialize)] +#[serde(rename = "Node")] +struct NodeWire { + id: NodeId, + #[serde(default)] + name: Option, + #[serde(default = "yes")] + visible: bool, + #[serde(default)] + locked: bool, + #[serde(default = "one")] + opacity: f32, + #[serde(default)] + blend: BlendMode, + #[serde(default)] + isolate: bool, + #[serde(default)] + knockout: Knockout, + #[serde(default)] + knockout_shape: bool, + #[serde(default)] + appearance: crate::Shared, + #[serde(default)] + mask: Option>, + #[serde(default)] + trace: Option>, + #[serde(default)] + wrap: Option, + #[serde(default)] + graph: Option>, + #[serde(default)] + series_index: Option, + #[serde(default)] + shaper: Option>, + kind: NodeKind, + #[serde(default)] + graphic_style: Option, + #[serde(default)] + attrs: Option>, + #[serde(default)] + slice: Option>, + #[serde(default)] + bbox_angle: f64, + #[serde(default)] + perspective: Option>, +} + +impl From for Node { + fn from(w: NodeWire) -> Self { + let extras = NodeExtras { + mask: w.mask, + trace: w.trace, + wrap: w.wrap, + graph: w.graph, + series_index: w.series_index, + shaper: w.shaper, + graphic_style: w.graphic_style, + attrs: w.attrs, + slice: w.slice, + perspective: w.perspective, + }; + Node { + id: w.id, + name: w.name, + visible: w.visible, + locked: w.locked, + opacity: w.opacity, + blend: w.blend, + isolate: w.isolate, + knockout: w.knockout, + knockout_shape: w.knockout_shape, + appearance: w.appearance, + kind: w.kind, + bbox_angle: w.bbox_angle, + extras: Extras((extras != NO_EXTRAS).then(|| Box::new(extras))), + } + } +} + /// Opacity mask: the luminance of the mask art sets the object's opacity (white = opaque). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct OpacityMask { @@ -595,18 +853,9 @@ impl Node { knockout: Knockout::Neutral, knockout_shape: false, appearance: Appearance::default().into(), - mask: None, - trace: None, - wrap: None, - graph: None, - series_index: None, - shaper: None, kind, - graphic_style: None, - attrs: None, - slice: None, bbox_angle: 0.0, - perspective: None, + extras: Extras::default(), } } pub fn path(id: NodeId, path: PathData, appearance: Appearance) -> Self { @@ -691,7 +940,7 @@ impl Node { && !self.isolate && self.knockout == Knockout::Neutral && !self.knockout_shape - && self.mask.is_none() + && self.extras().mask.is_none() } /// Kind name as the Layers panel / Properties panel shows it. pub fn kind_label(&self) -> &'static str { @@ -772,7 +1021,7 @@ impl Node { } NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => children .iter() - .skip(usize::from(self.shaper.is_some())) + .skip(usize::from(self.extras().shaper.is_some())) .filter(|c| c.visible || !matches!(self.kind, NodeKind::Layer { .. })) .fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric_bounds())), NodeKind::Text(t) => self.projected(t.bounds()), @@ -782,7 +1031,7 @@ impl Node { let b = crate::live::nodes_bounds(children); vectorcraft_geom::union_opt(b, spec.spine.as_ref().and_then(|s| s.bounds())) } - NodeKind::Envelope { content, kind, frame, .. } => crate::live::envelope_bounds(content, kind, *frame), + NodeKind::Envelope { content, kind, frame, .. } => crate::live::envelope_bounds(content, kind, **frame), NodeKind::Mesh(m) => m.bounds(), NodeKind::Repeat(r) => r.bounds(), NodeKind::PlacedDocument(p) => Some(p.bounds()), @@ -806,7 +1055,7 @@ impl Node { let o = self.appearance.outset(); children .iter() - .skip(usize::from(self.shaper.is_some())) + .skip(usize::from(self.extras().shaper.is_some())) .fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds())) .map(|b| b.inflate(o, o)) } @@ -903,8 +1152,8 @@ impl Node { for c in content.iter_mut() { Arc::make_mut(c).transform_scaled(a, sc); } - *frame = crate::live::envelope_frame(a * *frame); - match kind { + **frame = crate::live::envelope_frame(a * **frame); + match &mut **kind { EnvelopeKind::Mesh { points, handles, .. } => { for p in points.iter_mut() { *p = a * *p; @@ -923,7 +1172,7 @@ impl Node { NodeKind::Mesh(m) => m.transform(a), NodeKind::Repeat(r) => r.transform(a, *sc), } - if let Some(m) = &mut self.mask + if let Some(m) = self.extras_if_any().and_then(|e| e.mask.as_mut()) && m.linked { Arc::make_mut(&mut m.art).transform_scaled(a, sc); @@ -1039,7 +1288,7 @@ impl Node { } } NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { - for c in children.iter().skip(usize::from(self.shaper.is_some())).filter(|c| c.visible) { + for c in children.iter().skip(usize::from(self.extras().shaper.is_some())).filter(|c| c.visible) { c.push_clip_shapes(text, out); } } @@ -1324,25 +1573,25 @@ impl ImageMap { impl Node { /// The URL this object links to, if any. pub fn url(&self) -> Option<&str> { - self.attrs.as_deref().map(|a| a.url.as_str()).filter(|u| !u.is_empty()) + self.extras().attrs.as_deref().map(|a| a.url.as_str()).filter(|u| !u.is_empty()) } /// Whether the canvas shows this object's centre point when it is selected: as set in the /// Attributes panel, else [`Self::shows_center_by_default`]. pub fn shows_center(&self) -> bool { - self.attrs.as_deref().and_then(|a| a.show_center).unwrap_or_else(|| self.shows_center_by_default()) + self.extras().attrs.as_deref().and_then(|a| a.show_center).unwrap_or_else(|| self.shows_center_by_default()) } /// Shapes drawn with the shape tools (live rectangles, ellipses and polygons) show their centre. pub fn shows_center_by_default(&self) -> bool { - matches!(&self.kind, NodeKind::Path { live: Some(l), .. } if !matches!(l, LiveShape::Line { .. } | LiveShape::Path { .. })) + matches!(&self.kind, NodeKind::Path { live: Some(l), .. } if !matches!(**l, LiveShape::Line { .. } | LiveShape::Path { .. })) } /// Change this object's attributes with `f`; all-default attributes are dropped. pub fn edit_attrs(&mut self, f: impl FnOnce(&mut ObjectAttributes) -> R) -> R { - let mut a = self.attrs.take().unwrap_or_default(); + let mut a = self.extras_mut().attrs.take().unwrap_or_default(); let r = f(&mut a); - self.attrs = (*a != ObjectAttributes::default()).then_some(a); + self.extras_mut().attrs = (*a != ObjectAttributes::default()).then_some(a); r } } @@ -1358,7 +1607,7 @@ impl Node { self.visible && (self.opacity < 1.0 || self.blend != BlendMode::Normal - || self.mask.as_ref().is_some_and(|m| !m.disabled) + || self.extras().mask.as_ref().is_some_and(|m| !m.disabled) || effects(&self.appearance.effects) || self .appearance @@ -1454,13 +1703,14 @@ mod tests { let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), xf }; let mut n = Node::path(NodeId(1), live.to_path(), Appearance::default_art()); if let NodeKind::Path { live: slot, .. } = &mut n.kind { - *slot = Some(live); + *slot = Some(Box::new(live)); } n } fn live_of(n: &Node) -> (f64, f64, [f64; 4], Affine) { - let NodeKind::Path { live: Some(LiveShape::Rectangle { w, h, radii, xf, .. }), .. } = &n.kind else { panic!("not a live rectangle") }; + let NodeKind::Path { live: Some(l), .. } = &n.kind else { panic!("not a live shape") }; + let LiveShape::Rectangle { w, h, radii, xf, .. } = &**l else { panic!("not a live rectangle") }; (*w, *h, *radii, *xf) } @@ -1595,7 +1845,7 @@ mod tests { |n| n.knockout = Knockout::On, |n| n.knockout = Knockout::Off, |n| n.knockout_shape = true, - |n| n.mask = Some(Box::new(OpacityMask::new(n.clone(), true))), + |n| n.extras_mut().mask = Some(Box::new(OpacityMask::new(n.clone(), true))), ]; for tweak in tweaks { let mut n = p(); diff --git a/crates/doc/src/perspective.rs b/crates/doc/src/perspective.rs index 4e1e24ef..4de3823d 100644 --- a/crates/doc/src/perspective.rs +++ b/crates/doc/src/perspective.rs @@ -46,7 +46,7 @@ impl Node { /// The projective map type or a symbol instance in perspective is drawn through (not while /// Edit Text shows it flat). pub fn projection(&self) -> Option { - let a = self.perspective.as_deref().filter(|a| !a.editing)?; + let a = self.extras().perspective.as_deref().filter(|a| !a.editing)?; matches!(self.kind, NodeKind::Text(_) | NodeKind::SymbolInstance { .. }).then(|| a.homography()).flatten() } @@ -60,7 +60,7 @@ impl Node { /// Keep a projection drawing the same picture moved by `a` (the flat art moves by `a` too). pub(crate) fn transform_projection(&mut self, a: vectorcraft_geom::Affine) { - if let Some(p) = self.perspective.as_deref_mut() + if let Some(p) = self.extras_if_any().and_then(|e| e.perspective.as_deref_mut()) && let Some(h) = p.homography().and_then(|h| h.conjugated(a)) { p.projection = Some(h.to_array()); diff --git a/crates/doc/src/placed_document.rs b/crates/doc/src/placed_document.rs index 4d1be2d4..c399bf20 100644 --- a/crates/doc/src/placed_document.rs +++ b/crates/doc/src/placed_document.rs @@ -119,7 +119,7 @@ fn prefix(key: &str) -> String { /// `n` and every descendant, opacity-mask art included. fn walk_all<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) { f(n); - if let Some(m) = &n.mask { + if let Some(m) = &n.extras().mask { walk_all(&m.art, f); } for c in n.children().into_iter().flatten() { @@ -184,7 +184,7 @@ impl Document { } // The art shown may hold placed documents too. let n = Arc::make_mut(n); - if let Some(m) = &mut n.mask { + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { update(&mut m.art); } for c in n.children_mut().into_iter().flatten() { @@ -210,11 +210,11 @@ impl Document { && keys.contains(&p.key) { if let Some(im) = images.get(&p.key).and_then(|b| p.preview_image(&b.bytes)) { - n.kind = NodeKind::Image(im); + n.kind = NodeKind::Image(Box::new(im)); } return; } - if let Some(m) = &mut n.mask { + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { update(&mut m.art, keys, images); } for c in n.children_mut().into_iter().flatten() { @@ -406,7 +406,7 @@ fn read_preview(p: &PlacedDocument, bytes: &Arc>) -> Option crate::NodeId { /// Visit `n` and every descendant (opacity-mask art included) mutably. fn each_mut(n: &mut Node, f: &mut impl FnMut(&mut Node)) { f(n); - if let Some(m) = &mut n.mask { + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { each_mut(Arc::make_mut(&mut m.art), f); } for c in n.children_mut().into_iter().flatten() { @@ -590,7 +590,14 @@ mod tests { d.layers.clear(); let art = Node::new( NodeId(50), - NodeKind::Image(ImageObject { key: "pic".into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default() }), + NodeKind::Image(Box::new(ImageObject { + key: "pic".into(), + width: 1, + height: 1, + xf: Affine::IDENTITY, + link: None, + placement: Default::default(), + })), ); d.symbols.push(Symbol { name: "Star".into(), art: Arc::new(art) }); d.images.insert("pic".into(), ImageBlob::new("image/png", vec![1, 2, 3])); diff --git a/crates/doc/src/slices.rs b/crates/doc/src/slices.rs index f62d7fa0..8deba728 100644 --- a/crates/doc/src/slices.rs +++ b/crates/doc/src/slices.rs @@ -177,13 +177,13 @@ impl Document { } /// Is `id` a user slice or an object with slice options? pub fn is_slice(&self, id: NodeId) -> bool { - self.slice(id).is_some() || self.node(id).is_some_and(|n| n.slice.is_some()) + self.slice(id).is_some() || self.node(id).is_some_and(|n| n.extras().slice.is_some()) } /// The options of slice `id` (user or object). pub fn slice_options(&self, id: NodeId) -> Option<&SliceOptions> { match self.slice(id) { Some(s) => Some(&s.options), - None => self.node(id)?.slice.as_deref(), + None => self.node(id)?.extras().slice.as_deref(), } } /// The rectangle of slice `id` before clipping: a user slice's own, an object slice's object's @@ -191,14 +191,14 @@ impl Document { pub fn slice_bounds(&self, id: NodeId) -> Option { match self.slice(id) { Some(s) => Some(s.rect), - None => self.node(id).filter(|n| n.slice.is_some())?.visual_bounds(), + None => self.node(id).filter(|n| n.extras().slice.is_some())?.visual_bounds(), } } /// The visible objects with slice options and their visual bounds, in paint order. pub fn object_slices(&self) -> Vec<(NodeId, Rect)> { fn walk(nodes: &[Arc], out: &mut Vec<(NodeId, Rect)>) { for n in nodes.iter().filter(|n| n.visible) { - if n.slice.is_some() + if n.extras().slice.is_some() && let Some(b) = n.visual_bounds() { out.push((n.id, b)); diff --git a/crates/doc/src/tests_slices.rs b/crates/doc/src/tests_slices.rs index 3a63fe0c..86a0cee8 100644 --- a/crates/doc/src/tests_slices.rs +++ b/crates/doc/src/tests_slices.rs @@ -30,7 +30,7 @@ fn an_object_slice_follows_its_object_and_numbers_run_by_rows() { let mut d = Document::new(300.0, 200.0); let a = rect(&mut d, Rect::new(100.0, 50.0, 200.0, 150.0)); assert!(d.slice_layout().is_empty(), "no slices, no auto slices"); - d.node_mut(a).unwrap().slice = Some(Box::default()); + d.node_mut(a).unwrap().extras_mut().slice = Some(Box::default()); let lay = d.slice_layout(); let own = lay.iter().find(|s| s.id == Some(a)).unwrap(); assert_eq!((own.source, own.rect), (SliceSource::Object, Rect::new(100.0, 50.0, 200.0, 150.0))); @@ -69,14 +69,15 @@ fn clip_to_artboard_clips_slices_and_off_covers_the_art() { fn slices_survive_a_native_round_trip_and_old_files_load() { let mut d = Document::new(100.0, 100.0); let a = rect(&mut d, Rect::new(10.0, 10.0, 20.0, 20.0)); - d.node_mut(a).unwrap().slice = Some(Box::new(SliceOptions { kind: crate::SliceKind::NoImage, text: "hi".into(), ..Default::default() })); + d.node_mut(a).unwrap().extras_mut().slice = + Some(Box::new(SliceOptions { kind: crate::SliceKind::NoImage, text: "hi".into(), ..Default::default() })); let s = user(&mut d, Rect::new(30.0, 30.0, 60.0, 60.0)); d.slice_mut(s).unwrap().options.url = "https://example.com".into(); d.slices_clip_to_artboard = false; let json = serde_json::to_string(&d).unwrap(); let back: Document = serde_json::from_str(&json).unwrap(); assert_eq!(back.slices, d.slices); - assert_eq!(back.node(a).unwrap().slice, d.node(a).unwrap().slice); + assert_eq!(back.node(a).unwrap().extras().slice, d.node(a).unwrap().extras().slice); assert!(!back.slices_clip_to_artboard); // A document without slices writes none of the new keys (older readers see what they knew). let plain = serde_json::to_string(&Document::new(10.0, 10.0)).unwrap(); diff --git a/crates/doc/src/trace.rs b/crates/doc/src/trace.rs index 72dc470c..5155cc21 100644 --- a/crates/doc/src/trace.rs +++ b/crates/doc/src/trace.rs @@ -67,7 +67,7 @@ impl TraceView { impl Node { /// The View of an Image Trace object (the tracing result for anything else, or an unknown id). pub fn trace_view(&self) -> TraceView { - self.trace.as_deref().map(TraceView::of).unwrap_or_default() + self.extras().trace.as_deref().map(TraceView::of).unwrap_or_default() } } @@ -84,9 +84,9 @@ mod tests { } let mut g = Node::group(crate::NodeId(1), vec![]); assert_eq!(g.trace_view(), TraceView::Result); - g.trace = Some(Box::new(serde_json::json!({"view": "outlines"}))); + g.extras_mut().trace = Some(Box::new(serde_json::json!({"view": "outlines"}))); assert_eq!(g.trace_view(), TraceView::Outlines); - g.trace = Some(Box::new(serde_json::json!({"view": "sepia"}))); + g.extras_mut().trace = Some(Box::new(serde_json::json!({"view": "sepia"}))); assert_eq!(g.trace_view(), TraceView::Result); } } diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 6538b2de..1ca740ae 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -163,24 +163,25 @@ pub fn fresh_ids(d: &mut Document, n: &mut Node, seen: &mut std::collections::Ha fn piece(d: &mut Document, n: &Node, path: PathData, rule: FillRule, item: AppearanceItem) -> Node { let id = d.alloc_id(); let kind = path_kind(d, path, rule); - Node { - id, - name: None, - appearance: Appearance { items: vec![item], ..Default::default() }.into(), - opacity: 1.0, - blend: Default::default(), - isolate: false, - knockout: Default::default(), - knockout_shape: false, - mask: None, - trace: None, - wrap: None, - graph: None, - // The group keeps the URL (one link around the pieces). - attrs: None, - kind, - ..n.clone() - } + let mut out = n.clone(); + out.id = id; + out.name = None; + out.appearance = Appearance { items: vec![item], ..Default::default() }.into(); + out.opacity = 1.0; + out.blend = Default::default(); + out.isolate = false; + out.knockout = Default::default(); + out.knockout_shape = false; + out.kind = kind; + let mut extras = out.extras_mut(); + extras.mask = None; + extras.trace = None; + extras.wrap = None; + extras.graph = None; + // The group keeps the URL (one link around the pieces). + extras.attrs = None; + drop(extras); + out } /// `n`'s geometry with its object-level geometry effects applied, its fill rule, the context of diff --git a/crates/effects/src/marks.rs b/crates/effects/src/marks.rs index 4009cad3..b349d620 100644 --- a/crates/effects/src/marks.rs +++ b/crates/effects/src/marks.rs @@ -32,8 +32,8 @@ pub fn crop_marks_art(n: &Node) -> Option { g.opacity = std::mem::replace(&mut object.opacity, 1.0); g.blend = std::mem::take(&mut object.blend); g.isolate = std::mem::take(&mut object.isolate); - g.mask = object.mask.take(); - g.attrs = object.attrs.take(); + g.extras_mut().mask = object.extras_mut().mask.take(); + g.extras_mut().attrs = object.extras_mut().attrs.take(); if let Some(ch) = g.children_mut() { *ch = vec![Arc::new(object), Arc::new(marks)]; } diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index e37a0d20..05b686b5 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -92,7 +92,7 @@ pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option { art.isolate = false; // One element: its pieces never knock each other out. art.knockout = vectorcraft_doc::Knockout::Off; - art.mask = None; + art.extras_mut().mask = None; art.appearance.effects.clear(); Some(art) } @@ -141,7 +141,7 @@ pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option { if let NodeKind::Path { clipping, .. } = &mut clip.kind { *clipping = true; } - let image = Node { appearance: Appearance::default().into(), ..Node::new(n.id, n.kind.clone()) }; + let image = Node::new(n.id, n.kind.clone()); Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true }) } _ => outline_art(n, symbol_art)?, @@ -159,7 +159,7 @@ pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option { out.isolate = n.isolate; // One element: its pieces never knock each other out. out.knockout = vectorcraft_doc::Knockout::Off; - out.mask = n.mask.clone(); + out.extras_mut().mask = n.extras().mask.clone(); out.appearance.effects = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect(); Some(out) } diff --git a/crates/engine/src/cmd/attributes.rs b/crates/engine/src/cmd/attributes.rs index ec328917..80dac69b 100644 --- a/crates/engine/src/cmd/attributes.rs +++ b/crates/engine/src/cmd/attributes.rs @@ -82,7 +82,7 @@ fn fill_rule_owner(d: &Document, id: NodeId) -> Option<&Node> { /// The Attributes panel's values of `info.ids` beyond overprinting. pub(super) fn fill_info(d: &Document, info: &mut AttributesInfo) { let nodes: Vec<&Node> = info.ids.iter().filter_map(|id| d.node(*id)).collect(); - let attrs = || nodes.iter().map(|n| n.attrs.as_deref().cloned().unwrap_or_default()); + let attrs = || nodes.iter().map(|n| n.extras().attrs.as_deref().cloned().unwrap_or_default()); info.show_center = same(nodes.iter().map(|n| n.shows_center())); info.image_map = same(attrs().map(|a| a.image_map)); info.url = same(attrs().map(|a| a.url)); @@ -126,7 +126,7 @@ impl Edits { /// `n`'s attributes with the edits applied (Show Center kept only where it isn't the default). fn apply(&self, n: &Node) -> ObjectAttributes { - let mut a = n.attrs.as_deref().cloned().unwrap_or_default(); + let mut a = n.extras().attrs.as_deref().cloned().unwrap_or_default(); if let Some(v) = self.show_center { a.show_center = (v != n.shows_center_by_default()).then_some(v); } @@ -158,7 +158,7 @@ fn set(s: &mut Session, p: &Value) -> Result { for id in &ids { let Some(n) = d.node(*id) else { continue }; let new = edits.apply(n); - if new != n.attrs.as_deref().cloned().unwrap_or_default() + if new != n.extras().attrs.as_deref().cloned().unwrap_or_default() && let Some(n) = d.node_mut(*id) { n.edit_attrs(|a| *a = new); diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 38ef5993..c15ace9d 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -553,7 +553,7 @@ fn lp_expand(s: &mut Session, p: &Value) -> Result { for g in &targets { let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?; n.name = None; - n.trace = None; + n.extras_mut().trace = None; if let Some(ch) = n.children_mut() { ch.retain(|c| match c.name.as_deref() { Some(SOURCES_NAME) => false, @@ -790,7 +790,7 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result { if !expand { // Tracing again keeps the object's view unless another is asked for. let view = view.unwrap_or_else(|| src.trace_view()); - g.trace = Some(Box::new(json!({ "preset": preset_label, "params": params_json, "view": view.id() }))); + g.extras_mut().trace = Some(Box::new(json!({ "preset": preset_label, "params": params_json, "view": view.id() }))); } let (par, idx, _) = d.position(target).ok_or(EngineError::NoNode(target))?; d.remove(target)?; @@ -821,10 +821,10 @@ fn trace_set_view(s: &mut Session, p: &Value) -> Result { s.edit("Image Trace View", |d, _| { for g in &targets { let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?; - if let Some(t) = n.trace.as_deref_mut().and_then(Value::as_object_mut) { + if let Some(t) = n.extras_if_any().and_then(|e| e.trace.as_deref_mut()).and_then(Value::as_object_mut) { t.insert("view".into(), json!(view.id())); } else { - n.trace = Some(Box::new(json!({ "preset": "Custom", "view": view.id() }))); + n.extras_mut().trace = Some(Box::new(json!({ "preset": "Custom", "view": view.id() }))); } } Ok(()) @@ -854,7 +854,7 @@ fn trace_expand(s: &mut Session, p: &Value) -> Result { for g in &targets { let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?; n.name = None; - n.trace = None; + n.extras_mut().trace = None; if let Some(ch) = n.children_mut() { ch.retain(|c| !matches!(c.kind, NodeKind::Image(_))); } diff --git a/crates/engine/src/cmd/clipboard/flavours.rs b/crates/engine/src/cmd/clipboard/flavours.rs index d9825d39..2ae87825 100644 --- a/crates/engine/src/cmd/clipboard/flavours.rs +++ b/crates/engine/src/cmd/clipboard/flavours.rs @@ -315,7 +315,7 @@ fn import_image(s: &mut Session, p: &Value) -> Result { let (w, h) = (img.width, img.height); let image = ImageObject { key: img.key.clone(), width: w, height: h, xf: Affine::scale_non_uniform(sx, sy), link: None, placement: Default::default() }; - let mut clip = Clipboard { nodes: vec![Node::new(NodeId(1), NodeKind::Image(image))], ..Default::default() }; + let mut clip = Clipboard { nodes: vec![Node::new(NodeId(1), NodeKind::Image(Box::new(image)))], ..Default::default() }; clip.images.insert(img.key, img.blob); let count = s.load_clipboard(clip, p)?; Ok(json!({ "count": count, "width": w as f64 * sx, "height": h as f64 * sy, "pixelWidth": w, "pixelHeight": h })) diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index baf5b62d..032ee02f 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -107,10 +107,10 @@ fn attrs_refs(attrs: &Map, f: Visit) { fn node_refs(n: &Node, f: Visit) { n.walk(&mut |m| { appearance_refs(&m.appearance, f); - if let Some(id) = m.graphic_style { + if let Some(id) = m.extras().graphic_style { f(Ref::Style(id)); } - if let Some(mask) = &m.mask { + if let Some(mask) = &m.extras().mask { node_refs(&mask.art, f); } match &m.kind { @@ -427,8 +427,8 @@ impl Relink { /// Relink a node tree (the mirror of [`node_refs`]). fn node(&self, n: &mut Node) { self.appearance(&mut n.appearance); - n.graphic_style = n.graphic_style.and_then(|id| self.styles.get(&id).copied()); - if let Some(m) = &mut n.mask { + n.extras_mut().graphic_style = n.extras().graphic_style.and_then(|id| self.styles.get(&id).copied()); + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { self.node(Arc::make_mut(&mut m.art)); } match &mut n.kind { diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 84370f57..8f07dd2a 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -157,7 +157,7 @@ fn draw_mode(s: &mut Session, p: &Value) -> Result { } fn path_kind(path: PathData, live: Option) -> NodeKind { - NodeKind::Path { path, rule: FillRule::NonZero, live, clipping: false, guide: false } + NodeKind::Path { path, rule: FillRule::NonZero, live: live.map(Box::new), clipping: false, guide: false } } fn rect_of(p: &Value, cmd: &str) -> Result { diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index e4aeea52..dda0e7c6 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -439,7 +439,7 @@ struct Targets { /// What `n` is when Liquify can't distort it (and doesn't look inside it). fn not_liquified(n: &Node) -> Option<&'static str> { Some(match &n.kind { - NodeKind::Group { .. } if n.graph.is_some() => "graphs", + NodeKind::Group { .. } if n.extras().graph.is_some() => "graphs", NodeKind::Text(_) => "type", NodeKind::SymbolInstance { .. } => "symbols", NodeKind::Image(_) => "images", @@ -1252,7 +1252,7 @@ fn edit_text(s: &mut Session, p: &Value) -> Result { let (Some(flat), Some(drawn)) = (t.bounds(), t.bounds().and_then(|b| h.map_rect_bbox(b))) else { return Ok(()) }; let shift = vectorcraft_geom::Affine::translate(drawn.center() - flat.center()); t.transform(shift); - if let Some(rec) = n.perspective.as_deref_mut() { + if let Some(rec) = n.extras_if_any().and_then(|e| e.perspective.as_deref_mut()) { rec.projection = Some(h.then_after(&Homography::from_affine(shift.inverse())).to_array()); rec.editing = true; } @@ -1271,8 +1271,8 @@ pub(crate) fn finish_edit_text(st: &mut crate::DocState, id: NodeId) { let clear = |doc: &mut Arc| { // Undo steps keep no lookup cache: look without building one, and drop the one an edit // builds (the document now keeps its own). - if doc.node_uncached(id).and_then(|n| n.perspective.as_deref()).is_some_and(|p| p.editing) - && let Some(p) = Arc::make_mut(doc).node_mut(id).and_then(|n| n.perspective.as_deref_mut()) + if doc.node_uncached(id).and_then(|n| n.extras().perspective.as_deref()).is_some_and(|p| p.editing) + && let Some(p) = Arc::make_mut(doc).node_mut(id).and_then(|n| n.extras_if_any().and_then(|e| e.perspective.as_deref_mut())) { p.editing = false; } diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index 5af0106f..d0f7cf3d 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -290,7 +290,7 @@ fn info(s: &mut Session, p: &Value) -> Result { spot.insert(name.clone()); } } - if n.mask.is_some() { + if n.extras().mask.is_some() { *counts.entry("opacityMasks").or_default() += 1; } if !n.appearance.effects.is_empty() { diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 7a7f8f4e..bc50e5e7 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -214,11 +214,12 @@ fn undo(s: &mut Session, _: &Value) -> Result { let st = s.doc_mut()?; let e = st.history.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?; let label = e.label.clone(); + let costs = e.costs(); let editing = st.doc.mask_edit.map(|m| m.layer); let doc = std::mem::replace(&mut st.doc, e.doc); let selection = std::mem::replace(&mut st.selection, Arc::unwrap_or_clone(e.selection)); let selection = shared_selection(selection, st.history.redo.last()); - st.history.redo.push(crate::HistoryEntry::kept(e.label, doc, selection, &st.doc)); + st.history.redo.push(crate::HistoryEntry::turned(e.label, doc, selection, &st.doc, costs)); st.history.trim(); st.revision += 1; st.selection.prune(&st.doc); @@ -230,11 +231,12 @@ fn redo(s: &mut Session, _: &Value) -> Result { let st = s.doc_mut()?; let e = st.history.redo.pop().ok_or_else(|| EngineError::Other("nothing to redo".into()))?; let label = e.label.clone(); + let costs = e.costs(); let editing = st.doc.mask_edit.map(|m| m.layer); let doc = std::mem::replace(&mut st.doc, e.doc); let selection = std::mem::replace(&mut st.selection, Arc::unwrap_or_clone(e.selection)); let selection = shared_selection(selection, st.history.undo.last()); - st.history.undo.push(crate::HistoryEntry::kept(e.label, doc, selection, &st.doc)); + st.history.undo.push(crate::HistoryEntry::turned(e.label, doc, selection, &st.doc, costs)); st.history.trim(); st.revision += 1; st.selection.prune(&st.doc); diff --git a/crates/engine/src/cmd/fileio/affinity.rs b/crates/engine/src/cmd/fileio/affinity.rs index 70dff78f..f9c5f85d 100644 --- a/crates/engine/src/cmd/fileio/affinity.rs +++ b/crates/engine/src/cmd/fileio/affinity.rs @@ -289,7 +289,7 @@ impl Builder { // shows outside it, as an opacity mask that clips. if let Some(kind) = self.image(img, to_doc) { let art = Node::new(self.doc.alloc_id(), kind); - out.mask = Some(Box::new(OpacityMask::new(art, true))); + out.extras_mut().mask = Some(Box::new(OpacityMask::new(art, true))); } } if let Some(mask) = &n.mask { @@ -514,7 +514,7 @@ impl Builder { let sy = img.height as f64 / f64::from(r.height.max(1)); let xf = to_doc * affine(img.transform) * Affine::scale_non_uniform(sx, sy); self.doc.images.entry(r.key.clone()).or_insert(r.blob); - Some(NodeKind::Image(ImageObject { key: r.key, width: r.width, height: r.height, xf, link: None, placement: Default::default() })) + Some(NodeKind::Image(Box::new(ImageObject { key: r.key, width: r.width, height: r.height, xf, link: None, placement: Default::default() }))) } } diff --git a/crates/engine/src/cmd/fileio/imagemap.rs b/crates/engine/src/cmd/fileio/imagemap.rs index 6974fe8d..ecf5a8b3 100644 --- a/crates/engine/src/cmd/fileio/imagemap.rs +++ b/crates/engine/src/cmd/fileio/imagemap.rs @@ -82,7 +82,7 @@ fn collect(n: &Node, region: Rect, scale: f64, out: &mut Vec) { if !n.visible || matches!(n.kind, NodeKind::Layer { template: true, .. }) { return; } - let shape = n.attrs.as_deref().filter(|a| !a.url.is_empty()).map(|a| a.image_map).unwrap_or_default(); + let shape = n.extras().attrs.as_deref().filter(|a| !a.url.is_empty()).map(|a| a.image_map).unwrap_or_default(); let px = |x: f64, y: f64| [((x - region.x0) * scale).round() as i64, ((y - region.y0) * scale).round() as i64]; if let (Some(href), Some(b)) = (n.url(), n.visual_bounds().filter(|b| b.overlaps(region))) && shape != ImageMap::None diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index e492e18c..534d86c8 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -430,7 +430,8 @@ pub(super) fn raster_doc(name: &str, bytes: &[u8]) -> Result { let layer = d.layers[0].id; let id = d.alloc_id(); let xf = Affine::scale_non_uniform(sx, sy); - let mut n = Node::new(id, NodeKind::Image(ImageObject { key: key.clone(), width, height, xf, link: None, placement: Default::default() })); + let mut n = + Node::new(id, NodeKind::Image(Box::new(ImageObject { key: key.clone(), width, height, xf, link: None, placement: Default::default() }))); n.name = Some(name.to_string()); d.images.insert(key, blob); d.insert(Some(layer), 0, n).map_err(err)?; diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index a6f2128b..d4f6554e 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -43,7 +43,7 @@ fn image_bytes(w: u32, h: u32, format: image::ImageFormat) -> Vec { fn image_of(s: &Session) -> vectorcraft_doc::ImageObject { let doc = &s.doc().unwrap().doc; match &doc.layers[0].children().unwrap()[0].kind { - NodeKind::Image(im) => im.clone(), + NodeKind::Image(im) => (**im).clone(), other => panic!("expected an image, got {other:?}"), } } diff --git a/crates/engine/src/cmd/fileio/tests_svgimport.rs b/crates/engine/src/cmd/fileio/tests_svgimport.rs index 46fc2f88..d898d389 100644 --- a/crates/engine/src/cmd/fileio/tests_svgimport.rs +++ b/crates/engine/src/cmd/fileio/tests_svgimport.rs @@ -53,7 +53,7 @@ fn images(s: &Session) -> Vec { let mut out = vec![]; s.documents()[s.active_index().unwrap()].doc.walk(|n| { if let NodeKind::Image(im) = &n.kind { - out.push(im.clone()); + out.push((**im).clone()); } }); out diff --git a/crates/engine/src/cmd/flatten.rs b/crates/engine/src/cmd/flatten.rs index 8d11365b..2378b99c 100644 --- a/crates/engine/src/cmd/flatten.rs +++ b/crates/engine/src/cmd/flatten.rs @@ -1093,7 +1093,7 @@ fn raster_node(d: &mut Document, r: Raster) -> Node { d.images.insert(key.clone(), ImageBlob::new("image/png", r.png)); let image = Node::new( d.alloc_id(), - NodeKind::Image(ImageObject { key, width: r.width, height: r.height, xf: r.xf, link: None, placement: Default::default() }), + NodeKind::Image(Box::new(ImageObject { key, width: r.width, height: r.height, xf: r.xf, link: None, placement: Default::default() })), ); let Some(path) = r.clip else { return image }; super::rasterfx::clip_group(d, path, FillRule::NonZero, image) @@ -1224,7 +1224,7 @@ fn carry(from: &Node, mut to: Node) -> Node { to.blend = from.blend; to.isolate = from.isolate; to.knockout_shape = from.knockout_shape; - to.mask = from.mask.clone(); + to.extras_mut().mask = from.extras().mask.clone(); to.appearance.effects = from.appearance.effects.clone(); to } @@ -1258,7 +1258,7 @@ fn has_effects(n: &Node) -> bool { } fn masked(n: &Node) -> bool { - n.mask.as_ref().is_some_and(|m| !m.disabled) + n.extras().mask.as_ref().is_some_and(|m| !m.disabled) } /// Where `n` can paint: its visual bounds grown by its effects (and a margin for antialiasing). diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index 796814ac..bb114b8a 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -389,7 +389,7 @@ impl Gen<'_> { fn series(&mut self, name: &str, index: usize, children: Vec>) -> Arc { let mut g = Node::group(self.d.alloc_id(), children); g.name = Some(name.into()); - g.series_index = u32::try_from(index).ok(); + g.extras_mut().series_index = u32::try_from(index).ok(); Arc::new(g) } } @@ -1146,7 +1146,7 @@ fn children_bounds(children: &[Arc]) -> Option { /// generation, which is recorded in the spec's `placed` bounds). fn regenerate(d: &mut Document, id: NodeId, mut spec: GraphSpec) -> Result<()> { let n = d.node(id).ok_or(EngineError::NoNode(id))?; - if let (Some(placed), Some(now)) = (n.graph.as_ref().and_then(|g| g.placed), n.children().and_then(|c| children_bounds(c))) + if let (Some(placed), Some(now)) = (n.extras().graph.as_ref().and_then(|g| g.placed), n.children().and_then(|c| children_bounds(c))) && placed.width() > 1e-9 && placed.height() > 1e-9 && (placed.x0 - now.x0).abs() + (placed.y0 - now.y0).abs() + (placed.x1 - now.x1).abs() + (placed.y1 - now.y1).abs() > 1e-6 @@ -1162,7 +1162,7 @@ fn regenerate(d: &mut Document, id: NodeId, mut spec: GraphSpec) -> Result<()> { if let Some(ch) = n.children_mut() { *ch = children; } - n.graph = Some(Box::new(spec)); + n.extras_mut().graph = Some(Box::new(spec)); Ok(()) } @@ -1199,7 +1199,7 @@ fn target(s: &Session, p: &Value, cmd: &str) -> Result { for id in start { let mut cur = Some(id); while let Some(c) = cur { - if st.doc.node(c).is_some_and(|n| n.graph.is_some()) { + if st.doc.node(c).is_some_and(|n| n.extras().graph.is_some()) { return Ok(c); } cur = st.doc.parent_of(c); @@ -1209,7 +1209,7 @@ fn target(s: &Session, p: &Value, cmd: &str) -> Result { } fn spec_of(s: &Session, id: NodeId) -> Result { - s.doc()?.doc.node(id).and_then(|n| n.graph.as_deref().cloned()).ok_or(EngineError::NoNode(id)) + s.doc()?.doc.node(id).and_then(|n| n.extras().graph.as_deref().cloned()).ok_or(EngineError::NoNode(id)) } /// Where a node inside a graph's generated series art belongs: the graph, the series group and @@ -1227,10 +1227,10 @@ struct SeriesRef { fn series_members(doc: &Document) -> HashMap { let mut out = HashMap::new(); doc.walk(|n| { - let Some(spec) = n.graph.as_deref() else { return }; + let Some(spec) = n.extras().graph.as_deref() else { return }; let count = spec.rows.iter().map(Vec::len).max().unwrap_or(0).clamp(1, vectorcraft_doc::MAX_GRAPH_SERIES); for group in n.children().into_iter().flatten() { - let Some(index) = group.series_index.and_then(|i| usize::try_from(i).ok()).filter(|i| *i < count) else { continue }; + let Some(index) = group.extras().series_index.and_then(|i| usize::try_from(i).ok()).filter(|i| *i < count) else { continue }; let r = SeriesRef { graph: n.id, group: group.id, index }; group.walk(&mut |m| { out.insert(m.id, r); @@ -1288,7 +1288,7 @@ pub(crate) fn capture_series_paints(doc: &mut Document, ids: &[NodeId], fill: bo .collect(); let mut changed = false; for (graph, index, paint, width) in captures { - let Some(spec) = doc.node_mut(graph).and_then(|n| n.graph.as_deref_mut()) else { continue }; + let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; let was = spec.series_paints.clone(); // `index` is below the graph's series count (`series_members`). if spec.series_paints.len() <= index { @@ -1342,9 +1342,9 @@ fn switch_xy(g: &mut GraphSpec) { fn graph_labels(doc: &Document) -> HashMap { let mut out = HashMap::new(); doc.walk(|n| { - let Some(g) = n.graph.as_deref() else { return }; + let Some(g) = n.extras().graph.as_deref() else { return }; for c in n.children().into_iter().flatten() { - match (&c.kind, c.name.as_deref(), c.series_index) { + match (&c.kind, c.name.as_deref(), c.extras().series_index) { (NodeKind::Text(_), _, _) if g.kind == GraphKind::Pie => { out.insert(c.id, (n.id, Role::Category)); } @@ -1389,7 +1389,7 @@ pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], change: & kinds.dedup(); let mut changed = false; for (graph, role) in kinds { - let Some(spec) = doc.node_mut(graph).and_then(|n| n.graph.as_deref_mut()) else { continue }; + let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; let mut style = label_style(&spec.label_styles, role); change(&mut style); let slot = match role { @@ -1470,7 +1470,7 @@ fn reselect_series(d: &Document, sel: &mut vectorcraft_doc::Selection, id: NodeI .and_then(Node::children) .into_iter() .flatten() - .filter(|c| c.series_index.is_some_and(|i| indexes.contains(&i))) + .filter(|c| c.extras().series_index.is_some_and(|i| indexes.contains(&i))) .map(|c| c.id) .collect(); sel.set(groups); @@ -1485,12 +1485,12 @@ fn design_names(d: &Document) -> Vec { fn refresh_graphs_using(d: &mut Document, name: &str) -> Result<()> { let mut ids = vec![]; d.walk(|n| { - if n.graph.as_deref().is_some_and(|g| g.series_markers.iter().flatten().any(|m| m == name)) { + if n.extras().graph.as_deref().is_some_and(|g| g.series_markers.iter().flatten().any(|m| m == name)) { ids.push(n.id); } }); for id in ids { - if let Some(spec) = d.node(id).and_then(|n| n.graph.as_deref().cloned()) { + if let Some(spec) = d.node(id).and_then(|n| n.extras().graph.as_deref().cloned()) { regenerate(d, id, spec)?; } } @@ -1501,7 +1501,7 @@ fn refresh_graphs_using(d: &mut Document, name: &str) -> Result<()> { fn unused_designs(d: &Document) -> Vec { let mut used = std::collections::HashSet::new(); d.walk(|n| { - if let Some(g) = n.graph.as_deref() { + if let Some(g) = n.extras().graph.as_deref() { used.extend(g.series_markers.iter().flatten().cloned()); } }); @@ -1576,12 +1576,12 @@ fn design(s: &mut Session, p: &Value) -> Result { } let mut graphs = vec![]; d.walk(|n| { - if n.graph.as_deref().is_some_and(|g| g.series_markers.iter().flatten().any(|m| *m == old)) { + if n.extras().graph.as_deref().is_some_and(|g| g.series_markers.iter().flatten().any(|m| *m == old)) { graphs.push(n.id); } }); for id in graphs { - if let Some(g) = d.node_mut(id).and_then(|n| n.graph.as_deref_mut()) { + if let Some(g) = d.node_mut(id).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) { for m in g.series_markers.iter_mut().flatten().filter(|m| **m == old) { *m = new.clone(); } @@ -1921,7 +1921,7 @@ mod tests { /// The generated group stored for series `index`, not a sibling that happens to share its name. fn series(n: &vectorcraft_doc::Node, index: u32) -> &vectorcraft_doc::Node { - n.children().unwrap().iter().find(|c| c.series_index == Some(index)).unwrap_or_else(|| panic!("no series {index}")) + n.children().unwrap().iter().find(|c| c.extras().series_index == Some(index)).unwrap_or_else(|| panic!("no series {index}")) } #[test] @@ -1931,7 +1931,7 @@ mod tests { for ty in ["column", "stackedColumn", "bar", "stackedBar", "line", "area", "scatter", "pie", "radar"] { let id = graph(&mut s, ty); let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - assert!(n.graph.is_some(), "{ty}"); + assert!(n.extras().graph.is_some(), "{ty}"); if ty != "scatter" { assert!(!group(&n, "2024").children().unwrap().is_empty(), "{ty}"); assert!(!group(&n, "2025").children().unwrap().is_empty(), "{ty}"); @@ -1966,7 +1966,7 @@ mod tests { assert!((s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0 - x0).abs() < 1.0); // Type change keeps the data; undo restores the columns. s.execute("graph.setType", &json!({"type": "pie"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.node(id).unwrap().graph.as_ref().unwrap().kind, vectorcraft_doc::GraphKind::Pie); + assert_eq!(s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().kind, vectorcraft_doc::GraphKind::Pie); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["type"], json!("column")); // Selecting a bar inside still targets the graph. @@ -2068,7 +2068,7 @@ mod tests { let id = graph(&mut s, ty.id()); let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; s.execute("paint.setFill", &json!({"ids": [series_id.0], "color": "#ff0000"})).unwrap(); - let spec = s.doc().unwrap().doc.node(id).unwrap().graph.as_deref().unwrap().clone(); + let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone(); let restored: GraphSpec = serde_json::from_value(serde_json::to_value(&spec).unwrap()).unwrap(); assert_eq!(spec, restored); let red = Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)); @@ -2077,7 +2077,7 @@ mod tests { assert!(selected_group.children().unwrap().iter().all(|n| n.appearance.fill_paint() == red), "{ty:?}"); s.execute("graph.setData", &json!({"rows": [[2, 3], [4, 5]]})).unwrap(); s.execute("graph.setType", &json!({"type": "line"})).unwrap(); - let spec = s.doc().unwrap().doc.node(id).unwrap().graph.as_deref().unwrap(); + let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap(); assert_eq!(spec.series_paints.first().and_then(|p| p.fill.clone()), Some(red.clone()), "{ty:?}"); let series = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap(); assert!(series.iter().any(|n| n.appearance.fill_paint() == red), "{ty:?}"); @@ -2092,18 +2092,28 @@ mod tests { let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; s.execute("paint.setFill", &json!({"ids": [series_id.0], "color": "#e8573f"})).unwrap(); assert_eq!( - s.doc().unwrap().doc.node(id).unwrap().graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color).unwrap().to_hex(), + s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints[0] + .fill + .as_ref() + .and_then(Paint::color) + .unwrap() + .to_hex(), "#e8573f" ); s.execute("edit.undo", &json!({})).unwrap(); - assert!(s.doc().unwrap().doc.node(id).unwrap().graph.as_ref().unwrap().series_paints.is_empty()); + assert!(s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints.is_empty()); assert_eq!( group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0].appearance.fill_paint().color(), Some(super::default_series_color(0)) ); s.execute("edit.redo", &json!({})).unwrap(); assert_eq!( - s.doc().unwrap().doc.node(id).unwrap().graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color).unwrap().to_hex(), + s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints[0] + .fill + .as_ref() + .and_then(Paint::color) + .unwrap() + .to_hex(), "#e8573f" ); } @@ -2123,7 +2133,7 @@ mod tests { s.execute("paint.setFill", &fill).unwrap(); // The graph keeps nothing, as after a command that paints only the art. s.edit("Forget", |d, _| { - let g = d.node_mut(id).unwrap().graph.as_deref_mut().unwrap(); + let g = d.node_mut(id).unwrap().extras_if_any().unwrap().graph.as_deref_mut().unwrap(); g.series_paints.clear(); g.label_styles = vectorcraft_doc::LabelStyles::default(); Ok(()) @@ -2152,7 +2162,7 @@ mod tests { s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); let node = s.doc().unwrap().doc.node(id).unwrap(); let green = vectorcraft_color::Color::rgb(18.0 / 255.0, 171.0 / 255.0, 52.0 / 255.0); - assert_eq!(node.graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color), Some(green)); + assert_eq!(node.extras().graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color), Some(green)); assert!(group(node, "2024").children().unwrap().iter().all(|n| n.appearance.fill_paint().color() == Some(green))); } @@ -2165,14 +2175,14 @@ mod tests { let id = graph(&mut s, "column"); let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; s.edit("Junk", |d, _| { - d.node_mut(series_id).unwrap().series_index = Some(u32::MAX); + d.node_mut(series_id).unwrap().extras_mut().series_index = Some(u32::MAX); Ok(()) }) .unwrap(); let bar = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0].id; s.execute("paint.setFill", &json!({"ids": [bar.0, series_id.0], "color": "#12ab34"})).unwrap(); let node = s.doc().unwrap().doc.node(id).unwrap(); - assert!(node.graph.as_ref().unwrap().series_paints.is_empty()); + assert!(node.extras().graph.as_ref().unwrap().series_paints.is_empty()); assert_eq!(group(node, "2024").children().unwrap()[0].appearance.fill_paint().color().map(|c| c.to_hex()), Some("#12ab34".into())); } @@ -2185,10 +2195,10 @@ mod tests { s.execute("paint.setStroke", &json!({"ids": [series_id.0], "color": "#3751c8"})).unwrap(); let blue = vectorcraft_color::Color::rgb(55.0 / 255.0, 81.0 / 255.0, 200.0 / 255.0); assert_eq!( - s.doc().unwrap().doc.node(id).unwrap().graph.as_ref().unwrap().series_paints[0].stroke.as_ref().and_then(Paint::color), + s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints[0].stroke.as_ref().and_then(Paint::color), Some(blue) ); - assert!(s.doc().unwrap().doc.node(id).unwrap().graph.as_ref().unwrap().series_paints[0].fill.is_none()); + assert!(s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints[0].fill.is_none()); s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); assert!( group(s.doc().unwrap().doc.node(id).unwrap(), "2024") @@ -2212,7 +2222,7 @@ mod tests { let path = dir.join("palette.vectorcraft"); s.execute("document.save", &json!({"path": path})).unwrap(); s.execute("document.open", &json!({"path": path})).unwrap(); - let paints = &s.doc().unwrap().doc.node(id).unwrap().graph.as_ref().unwrap().series_paints; + let paints = &s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_ref().unwrap().series_paints; assert_eq!(paints.iter().map(|p| p.fill.as_ref().and_then(Paint::color).unwrap().to_hex()).collect::>(), vec!["#ff0000", "#008080"]); s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); s.execute("graph.setData", &json!({"id": id.0, "rows": [[3, 4, 5]]})).unwrap(); @@ -2241,7 +2251,7 @@ mod tests { let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id; s.execute("paint.setStroke", &json!({"ids": [series_id.0], "color": "#0000ff"})).unwrap(); s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); - let spec = s.doc().unwrap().doc.node(id).unwrap().graph.as_deref().unwrap().clone(); + let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone(); let blue = Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0)); assert_eq!(spec.series_paints[0].stroke, Some(blue.clone())); assert!(spec.series_paints[0].fill.is_none()); @@ -2253,7 +2263,7 @@ mod tests { let marker = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[1].id; s.execute("paint.setFill", &json!({"ids": [marker.0], "color": "#ff0000"})).unwrap(); s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); - let spec = s.doc().unwrap().doc.node(id).unwrap().graph.as_deref().unwrap().clone(); + let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone(); assert!(spec.series_paints[0].stroke.is_none()); let series = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap(); assert_eq!(series[0].appearance.stroke_paint().color(), Some(super::default_series_color(0))); @@ -2272,7 +2282,7 @@ mod tests { ) .unwrap(); s.execute("graph.setData", &json!({"rows": [[2, 3], [4, 5]]})).unwrap(); - let spec = s.doc().unwrap().doc.node(id).unwrap().graph.as_deref().unwrap().clone(); + let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone(); assert!(matches!(spec.series_paints[0].fill, Some(Paint::Gradient(_)))); let bar = &group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0]; assert!(matches!(bar.appearance.fill_paint(), Paint::Gradient(_))); @@ -2288,15 +2298,15 @@ mod tests { let n = s.doc().unwrap().doc.node(id).unwrap(); let legend = series(n, 0); assert_eq!(legend.name.as_deref(), Some("Legend")); - assert!(n.children().unwrap().iter().any(|c| c.name.as_deref() == Some("Legend") && c.series_index.is_none())); - assert!(n.children().unwrap().iter().any(|c| c.name.as_deref() == Some("Axes") && c.series_index.is_none())); + assert!(n.children().unwrap().iter().any(|c| c.name.as_deref() == Some("Legend") && c.extras().series_index.is_none())); + assert!(n.children().unwrap().iter().any(|c| c.name.as_deref() == Some("Axes") && c.extras().series_index.is_none())); legend.id }; s.execute("paint.setFill", &json!({"ids": [legend_id.0], "color": "#ff0000"})).unwrap(); s.execute("graph.setData", &json!({"csv": csv})).unwrap(); let n = s.doc().unwrap().doc.node(id).unwrap(); - assert_eq!(n.graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color).unwrap().to_hex(), "#ff0000"); - assert!(n.graph.as_ref().unwrap().series_paints.get(1).is_none_or(|p| p.fill.is_none())); + assert_eq!(n.extras().graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color).unwrap().to_hex(), "#ff0000"); + assert!(n.extras().graph.as_ref().unwrap().series_paints.get(1).is_none_or(|p| p.fill.is_none())); assert_eq!(series(n, 0).children().unwrap()[0].appearance.fill_paint().color().unwrap().to_hex(), "#ff0000"); assert_eq!(series(n, 1).name.as_deref(), Some("Axes")); assert_ne!(series(n, 1).children().unwrap()[0].appearance.fill_paint().color().unwrap().to_hex(), "#ff0000"); @@ -2311,8 +2321,8 @@ mod tests { s.execute("paint.setFill", &json!({"ids": [keep.0], "color": "#00ff00"})).unwrap(); s.execute("graph.setData", &json!({"id": id.0, "rows": [[8, 0], [1, 0]]})).unwrap(); let n = s.doc().unwrap().doc.node(id).unwrap(); - assert_eq!(group(n, "Keep").series_index, Some(0)); - assert_eq!(n.graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color).unwrap().to_hex(), "#00ff00"); + assert_eq!(group(n, "Keep").extras().series_index, Some(0)); + assert_eq!(n.extras().graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color).unwrap().to_hex(), "#00ff00"); assert!(n.children().unwrap().iter().all(|c| c.name.as_deref() != Some("Drop"))); } @@ -2395,7 +2405,7 @@ mod tests { let r = s.execute("graph.create", &json!({"type": "column", "x": 0, "y": 0, "width": 300, "height": 200, "csv": ",a\nQ1,1\nQ2\nQ3,3"})).unwrap(); let id = NodeId(r["id"].as_u64().unwrap()); - let spec = s.doc().unwrap().doc.node(id).unwrap().graph.clone().unwrap(); + let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.clone().unwrap(); assert_eq!(spec.categories, vec!["Q1", "Q2", "Q3"]); assert_eq!(spec.cells(), vec![vec![Some(1.0)], vec![None], vec![Some(3.0)]]); } @@ -2423,7 +2433,7 @@ mod tests { // A data window round trip keeps the blanks. let csv = s.execute("graph.setData", &json!({})).unwrap()["csv"].as_str().unwrap().to_string(); assert_eq!(csv.lines().nth(2), Some(",2"), "{csv}"); - let spec = s.doc().unwrap().doc.node(id).unwrap().graph.clone().unwrap(); + let spec = s.doc().unwrap().doc.node(id).unwrap().extras().graph.clone().unwrap(); assert_eq!(spec.cells()[1], vec![None, Some(2.0)]); // In the file a blank is a 0 placeholder plus its place in `blanks`, so a version without blanks reads // numbers (as zeros); a graph without blanks writes no `blanks` at all. @@ -2434,7 +2444,7 @@ mod tests { } fn spec(s: &Session, id: NodeId) -> GraphSpec { - s.doc().unwrap().doc.node(id).unwrap().graph.as_deref().unwrap().clone() + s.doc().unwrap().doc.node(id).unwrap().extras().graph.as_deref().unwrap().clone() } /// Bounds of a series' marks, without its legend swatch (the last child). @@ -2462,12 +2472,12 @@ mod tests { assert!((line[1].center().x - cols[0].center().x).abs() < 1e-6); // The line series is drawn after (in front of) the column series. let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let order: Vec = n.children().unwrap().iter().filter_map(|c| c.series_index).collect(); + let order: Vec = n.children().unwrap().iter().filter_map(|c| c.extras().series_index).collect(); assert_eq!(order, [0, 1]); s.execute("graph.setType", &json!({"seriesIndexes": [0], "type": "line"})).unwrap(); s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "column"})).unwrap(); let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let order: Vec = n.children().unwrap().iter().filter_map(|c| c.series_index).collect(); + let order: Vec = n.children().unwrap().iter().filter_map(|c| c.extras().series_index).collect(); assert_eq!(order, [1, 0]); // Back to the graph's own type: nothing is stored for that series. assert_eq!(spec(&s, id).series_kinds, [Some(GraphKind::Line)]); @@ -2562,7 +2572,7 @@ mod tests { assert!((area.x0 - cols[0].center().x).abs() < 1e-6 && (area.x1 - cols[2].center().x).abs() < 1e-6, "{area:?}"); // The area is drawn behind the columns. let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let order: Vec = n.children().unwrap().iter().filter_map(|c| c.series_index).collect(); + let order: Vec = n.children().unwrap().iter().filter_map(|c| c.extras().series_index).collect(); assert_eq!(order, [0, 1]); // An area graph alone still spans the plot. s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "area"})).unwrap(); @@ -3173,7 +3183,7 @@ mod tests { let dot = vectorcraft_doc::Node::group(d.alloc_id(), vec![]); d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Dot".into(), art: std::sync::Arc::new(dot) }); let art = super::generate(&mut d, &g); - let s0 = art.iter().find(|n| n.series_index == Some(0)).unwrap(); + let s0 = art.iter().find(|n| n.extras().series_index == Some(0)).unwrap(); assert!(s0.children().unwrap().iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); } @@ -3188,13 +3198,13 @@ mod tests { s.execute("graph.design", &json!({"new": "Chart"})).unwrap(); let art = s.doc().unwrap().doc.graph_designs[1].art.clone(); let mut graphs = 0; - art.walk(&mut |n| graphs += usize::from(n.graph.is_some() || n.series_index.is_some())); + art.walk(&mut |n| graphs += usize::from(n.extras().graph.is_some() || n.extras().series_index.is_some())); assert_eq!(graphs, 0, "no graph inside a design"); // The graph drawn with itself as a marker, then the design it used deleted: still one graph to edit. s.execute("graph.marker", &json!({"id": id.0, "seriesIndexes": [1], "design": "Chart"})).unwrap(); s.execute("graph.design", &json!({"delete": "Pill"})).unwrap(); let mut found = 0; - s.doc().unwrap().doc.walk(|n| found += usize::from(n.graph.is_some())); + s.doc().unwrap().doc.walk(|n| found += usize::from(n.extras().graph.is_some())); assert_eq!(found, 1); // A deleted design reads as none in the query, so the dialog opens on the default marker. s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); @@ -3226,7 +3236,7 @@ mod tests { g.series_markers = vec![Some("Big".into())]; let mut d = (*s.doc().unwrap().doc).clone(); let art = super::generate(&mut d, &g); - let s0 = art.iter().find(|n| n.series_index == Some(0)).unwrap(); + let s0 = art.iter().find(|n| n.extras().series_index == Some(0)).unwrap(); assert!(s0.children().unwrap().iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); // Too many objects for one design. let ids: Vec = (0..2001) @@ -3241,7 +3251,7 @@ mod tests { let mut d = vectorcraft_doc::Document::new(100.0, 100.0); let art = |d: &mut vectorcraft_doc::Document| std::sync::Arc::new(vectorcraft_doc::Node::group(d.alloc_id(), vec![])); let mut graphy = vectorcraft_doc::Node::group(d.alloc_id(), vec![]); - graphy.graph = Some(Box::new(GraphSpec::default())); + graphy.extras_mut().graph = Some(Box::new(GraphSpec::default())); for name in [" A\u{7} ", "A", "", " ", &"x".repeat(100)] { let a = art(&mut d); d.graph_designs.push(vectorcraft_doc::GraphDesign { name: name.into(), art: a }); @@ -3250,7 +3260,7 @@ mod tests { d.tidy_graph_designs(); let names: Vec<_> = d.graph_designs.iter().map(|x| x.name.clone()).collect(); assert_eq!(names, ["A".to_string(), "x".repeat(64), "G".to_string()]); - assert!(d.graph_designs[2].art.graph.is_none()); + assert!(d.graph_designs[2].art.extras().graph.is_none()); } #[test] @@ -3511,7 +3521,7 @@ mod tests { assert!(min > 1.0, "the ring keeps clear of the centre: {near:?}"); // The names are drawn over the pies: after every series group. let kids = n.children().unwrap(); - let last_series = kids.iter().rposition(|c| c.series_index.is_some()).unwrap(); + let last_series = kids.iter().rposition(|c| c.extras().series_index.is_some()).unwrap(); let names: Vec = kids.iter().enumerate().filter(|(_, c)| matches!(c.kind, NodeKind::Text(_))).map(|(i, _)| i).collect(); assert_eq!(names.len(), 2, "of two equal pies, one has no ring and no name"); assert!(names.iter().all(|i| *i > last_series), "{names:?} after {last_series}"); @@ -3576,7 +3586,7 @@ mod tests { let n = s.doc().unwrap().doc.node(id).unwrap().clone(); let kids = n.children().unwrap(); let at = kids.iter().position(|c| c.name.as_deref() == Some("Drop Shadow")).unwrap(); - assert!(kids.iter().position(|c| c.series_index.is_some()).unwrap() > at, "behind the series"); + assert!(kids.iter().position(|c| c.extras().series_index.is_some()).unwrap() > at, "behind the series"); let shadow = &kids[at]; assert!((shadow.opacity - 0.35).abs() < 1e-6); let copies: Vec<_> = shadow.children().unwrap().iter().filter_map(|c| c.geometric_bounds()).collect(); @@ -3639,8 +3649,12 @@ mod tests { s.execute("graph.setType", &json!({"seriesIndexes": [0], "type": "area"})).unwrap(); s.execute("graph.setType", &json!({"seriesIndexes": [2], "type": "line", "dropShadow": true})).unwrap(); let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - let order: Vec = - n.children().unwrap().iter().map(|c| c.series_index.map_or_else(|| c.name.clone().unwrap_or_default(), |i| format!("s{i}"))).collect(); + let order: Vec = n + .children() + .unwrap() + .iter() + .map(|c| c.extras().series_index.map_or_else(|| c.name.clone().unwrap_or_default(), |i| format!("s{i}"))) + .collect(); // Areas cast none; the columns' shadows sit between the area and the columns, the line's between the columns // and the line. let at = |k: &str| order.iter().position(|o| o == k).unwrap(); @@ -3702,8 +3716,9 @@ mod tests { assert!(a[0].x1 > b[0].x0, "the columns of a cluster overlap: {:?} {:?}", a[0], b[0]); assert!(b[0].x1 > a[1].x0, "and so do the clusters: {:?} {:?}", b[0], a[1]); assert_eq!(s.execute("graph.setType", &json!({"clusterWidth": 5000})).map(|_| spec(&s, id).cluster_width).unwrap(), 1000.0); - let order = - |s: &Session| -> Vec { s.doc().unwrap().doc.node(id).unwrap().children().unwrap().iter().filter_map(|c| c.series_index).collect() }; + let order = |s: &Session| -> Vec { + s.doc().unwrap().doc.node(id).unwrap().children().unwrap().iter().filter_map(|c| c.extras().series_index).collect() + }; assert_eq!(order(&s), [0, 1]); s.execute("graph.setType", &json!({"firstColumnInFront": true})).unwrap(); assert_eq!(order(&s), [1, 0], "the first series drawn last, on top"); diff --git a/crates/engine/src/cmd/live.rs b/crates/engine/src/cmd/live.rs index 70797f5e..2aadffa7 100644 --- a/crates/engine/src/cmd/live.rs +++ b/crates/engine/src/cmd/live.rs @@ -535,7 +535,7 @@ fn blend_make(s: &mut Session, p: &Value) -> Result { extend_spine(&mut spec, old, old_spec, &children); } n.id = id; - n.kind = NodeKind::Blend { children, spec }; + n.kind = NodeKind::Blend { children, spec: Box::new(spec) }; n }) })?; @@ -563,7 +563,7 @@ fn blend_release(s: &mut Session, _: &Value) -> Result { || n.opacity < 1.0 || n.blend != vectorcraft_color::BlendMode::Normal || n.isolate - || n.mask.is_some() + || n.extras().mask.is_some() || !n.appearance.items.is_empty() || !n.appearance.effects.is_empty() { @@ -714,7 +714,7 @@ fn edit_blends(s: &mut Session, label: &str, f: impl Fn(&mut Vec>, &mu fn selected_spec(s: &Session) -> Option<(NodeId, BlendSpec, Vec)> { let b = *selected_of(s, is_blend).first()?; match &s.doc().ok()?.doc.node(b)?.kind { - NodeKind::Blend { spec, children } => Some((b, spec.clone(), children.iter().map(|c| c.id).collect())), + NodeKind::Blend { spec, children } => Some((b, (**spec).clone(), children.iter().map(|c| c.id).collect())), _ => None, } } @@ -952,7 +952,7 @@ fn envelope_defaults(s: &Session) -> (EnvelopeOptions, f64) { } fn envelope(id: NodeId, content: Vec>, kind: EnvelopeKind, (options, fidelity): (EnvelopeOptions, f64)) -> Node { - Node::new(id, NodeKind::Envelope { content, kind, fidelity, editing: false, options, frame: Affine::IDENTITY }) + Node::new(id, NodeKind::Envelope { content, kind: Box::new(kind), fidelity, editing: false, options, frame: Box::new(Affine::IDENTITY) }) } /// `rows` and `cols` (or `columns`), 1..50 each, defaulting to `default`. @@ -1048,7 +1048,7 @@ fn set_kinds(s: &mut Session, label: &str, kinds: Vec<(NodeId, EnvelopeKind)>) - s.edit(label, |d, _| { for (e, k) in kinds { if let Some(NodeKind::Envelope { kind, .. }) = d.node_mut(e).map(|n| &mut n.kind) { - *kind = k; + **kind = k; } } Ok(()) @@ -1110,7 +1110,7 @@ fn env_info(s: &mut Session, _: &Value) -> Result { let (options, fidelity) = envelope_defaults(s); return Ok(envelope_options_json(json!({ "id": null }), &options, fidelity)); }; - let v = match kind { + let v = match &**kind { EnvelopeKind::Warp { style, bend, h, v, horizontal } => { json!({"type": "warp", "style": style, "bend": bend, "h": h, "v": v, "horizontal": horizontal}) } @@ -1154,7 +1154,7 @@ fn carry_transparency(d: &mut Document, env: &Node, mut nodes: Vec) -> Vec n.isolate = env.isolate; n.knockout = env.knockout; n.knockout_shape = env.knockout_shape; - n.mask = env.mask.clone(); + n.extras_mut().mask = env.extras().mask.clone(); }; if let [one] = nodes.as_mut_slice() && one.has_default_transparency() @@ -1205,8 +1205,8 @@ fn env_options(s: &mut Session, p: &Value) -> Result { let mut updates = vec![]; for e in &envs { if let Some(NodeKind::Envelope { kind, options, fidelity, .. }) = doc.node(*e).map(|n| &n.kind) { - let kind = match kind { - EnvelopeKind::Warp { .. } => warp_kind(p, C, Some(kind))?, + let kind = match &**kind { + k @ EnvelopeKind::Warp { .. } => warp_kind(p, C, Some(k))?, k => k.clone(), }; updates.push((*e, kind, options_param(p, C, *options)?, fid.unwrap_or(*fidelity))); @@ -1215,7 +1215,7 @@ fn env_options(s: &mut Session, p: &Value) -> Result { s.edit("Envelope Options", |d, _| { for (e, k, o, f) in updates { if let Some(NodeKind::Envelope { kind, options, fidelity, .. }) = d.node_mut(e).map(|n| &mut n.kind) { - (*kind, *options, *fidelity) = (k, o, f); + (**kind, *options, *fidelity) = (k, o, f); } } Ok(()) @@ -1300,7 +1300,10 @@ fn env_set_mesh_point(s: &mut Session, p: &Value) -> Result { let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?); let handle = handle_param(p, C)?; s.edit("Move Envelope Point", |d, _| { - let Some(NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows, cols, points, handles }, .. }) = d.node_mut(id).map(|n| &mut n.kind) else { + let Some(NodeKind::Envelope { kind, .. }) = d.node_mut(id).map(|n| &mut n.kind) else { + return Err(bad(C, "not a mesh envelope point")); + }; + let EnvelopeKind::Mesh { rows, cols, points, handles } = &mut **kind else { return Err(bad(C, "not a mesh envelope point")); }; let at = *points.get(index).ok_or_else(|| bad(C, "not a mesh envelope point"))?; @@ -1384,7 +1387,10 @@ fn with_mesh(s: &mut Session, p: &Value, cmd: &str, label: &str, envelopes: b let c = cmd.to_string(); s.edit(label, move |d, _| match d.node_mut(id).map(|n| &mut n.kind) { Some(NodeKind::Mesh(m)) => f(m), - Some(NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows, cols, points, handles }, .. }) if envelopes => { + Some(NodeKind::Envelope { kind, .. }) if envelopes && matches!(**kind, EnvelopeKind::Mesh { .. }) => { + let EnvelopeKind::Mesh { rows, cols, points, handles } = &mut **kind else { + return Err(bad(&c, "not a gradient mesh or mesh envelope")); + }; let mut m = live::envelope_grid(*rows, *cols, points, handles).ok_or_else(|| bad(&c, "the envelope's mesh is malformed"))?; let r = f(&mut m)?; (*rows, *cols) = (m.rows, m.cols); diff --git a/crates/engine/src/cmd/maskedit.rs b/crates/engine/src/cmd/maskedit.rs index a94f3838..54d21415 100644 --- a/crates/engine/src/cmd/maskedit.rs +++ b/crates/engine/src/cmd/maskedit.rs @@ -90,7 +90,7 @@ fn enter(s: &mut Session, p: &Value) -> Result { /// The first selected object with an opacity mask. fn first_masked(st: &crate::DocState) -> Option { - st.selection.subjects().iter().copied().find(|i| st.doc.node(*i).is_some_and(|n| n.mask.is_some())) + st.selection.subjects().iter().copied().find(|i| st.doc.node(*i).is_some_and(|n| n.extras().mask.is_some())) } /// Enter mask editing for `id` (one undo step) and isolate the editing layer. Returns the layer. @@ -127,8 +127,11 @@ fn view_mask(s: &mut Session, p: &Value) -> Result { /// Enter mask editing for `id` inside an edit: copy its mask art onto a new editing layer and /// select it. Returns the layer. pub(crate) fn begin(d: &mut Document, sel: &mut Selection, id: NodeId) -> Result { - let art = - d.node(id).and_then(|n| n.mask.as_ref()).map(|m| m.art.clone()).ok_or_else(|| EngineError::Other("the object has no opacity mask".into()))?; + let art = d + .node(id) + .and_then(|n| n.extras().mask.as_ref()) + .map(|m| m.art.clone()) + .ok_or_else(|| EngineError::Other("the object has no opacity mask".into()))?; let layer = d.add_layer(Some(MASK_EDIT_LAYER)); let parts = mask_parts(&art); let mut ids = Vec::with_capacity(parts.len()); @@ -202,7 +205,7 @@ pub(crate) fn follow_history(st: &mut crate::DocState, was: Option) { pub(crate) fn sync(d: &mut Document) { let Some(me) = d.mask_edit else { return }; let art: Option>> = d.node(me.layer).and_then(|l| l.children().cloned()); - let (Some(art), true) = (art, d.node(me.object).is_some_and(|n| n.mask.is_some())) else { + let (Some(art), true) = (art, d.node(me.object).is_some_and(|n| n.extras().mask.is_some())) else { d.mask_edit = None; let _ = d.remove(me.layer); return; @@ -212,12 +215,12 @@ pub(crate) fn sync(d: &mut Document) { // Several objects (or none): a group, so the mask keeps one art node. many => Arc::new(mask_group(many.to_vec())), }; - if let Some(m) = d.node(me.object).and_then(|n| n.mask.as_ref()) + if let Some(m) = d.node(me.object).and_then(|n| n.extras().mask.as_ref()) && Arc::ptr_eq(&m.art, &new_art) { return; } - if let Some(m) = d.node_mut(me.object).and_then(|n| n.mask.as_mut()) { + if let Some(m) = d.node_mut(me.object).and_then(|n| n.extras_if_any().and_then(|e| e.mask.as_mut())) { m.art = new_art; } } @@ -241,7 +244,8 @@ mod tests { assert_eq!(s.doc().unwrap().isolation, Some(NodeId(layer))); // Edit the mask art with ordinary commands: widen it; the object's mask follows live. s.execute("object.move", &json!({"dx": 50, "dy": 0})).unwrap(); - let mask_bounds = |s: &Session| s.doc().unwrap().doc.node(NodeId(obj)).unwrap().mask.as_ref().unwrap().art.geometric_bounds().unwrap(); + let mask_bounds = + |s: &Session| s.doc().unwrap().doc.node(NodeId(obj)).unwrap().extras().mask.as_ref().unwrap().art.geometric_bounds().unwrap(); assert!((mask_bounds(&s).x0 - 50.0).abs() < 1e-6); // Drawing while editing adds to the mask. s.execute("shape.ellipse", &json!({"x": 180, "y": 150, "width": 40, "height": 40})).unwrap(); diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 7b6ad172..4a9016bd 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -449,7 +449,8 @@ fn rasterize(s: &mut Session, p: &Value) -> Result { d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png)); let id = d.alloc_id(); let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale); - let mut node = Node::new(id, NodeKind::Image(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() })); + let mut node = + Node::new(id, NodeKind::Image(Box::new(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() }))); if let Some((path, rule)) = clip { node = super::rasterfx::clip_group(d, path, rule, node); } @@ -562,7 +563,7 @@ fn crop_image(s: &mut Session, p: &Value) -> Result { let node = st.doc.node(id).cloned().ok_or(EngineError::NoNode(id))?; let NodeKind::Image(im) = &node.kind else { return Err(bad(C, "select an image")) }; if p.get("trim").and_then(Value::as_bool) == Some(true) { - return trim_image(s, id, im.clone()); + return trim_image(s, id, (**im).clone()); } let ib = node.geometric_bounds().ok_or_else(|| bad(C, "image has no bounds"))?; let rect = match p.get("rect").and_then(Value::as_array) { @@ -603,14 +604,14 @@ fn crop_image(s: &mut Session, p: &Value) -> Result { let key = unique_key(d, "crop"); d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png)); if let Some(n) = d.node_mut(id) { - n.kind = NodeKind::Image(ImageObject { + n.kind = NodeKind::Image(Box::new(ImageObject { key, width: w, height: h, xf: Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale), link: None, placement: Default::default(), - }); + })); } Ok(()) })?; @@ -646,14 +647,14 @@ fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result { let key = unique_key(d, "crop"); d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png)); if let Some(n) = d.node_mut(id) { - n.kind = NodeKind::Image(ImageObject { + n.kind = NodeKind::Image(Box::new(ImageObject { key, width: cw, height: ch, xf: im.xf * Affine::translate((f64::from(x0), f64::from(y0))), link: None, placement: Default::default(), - }); + })); } Ok(()) })?; @@ -799,7 +800,7 @@ fn convert_to_shape(s: &mut Session, _: &Value) -> Result { } if let Some(l) = detect_shape(path) { *path = l.to_path(); - *live = Some(l); + *live = Some(Box::new(l)); n += 1; } } diff --git a/crates/engine/src/cmd/newart.rs b/crates/engine/src/cmd/newart.rs index 429ac840..40424da5 100644 --- a/crates/engine/src/cmd/newart.rs +++ b/crates/engine/src/cmd/newart.rs @@ -105,7 +105,7 @@ impl NewArt { return; } (n.opacity, n.blend) = (self.opacity, self.blend); - n.graphic_style = self.style.as_deref().and_then(|name| d.graphic_style_index(name)).map(|i| d.graphic_style_id(i)); + n.extras_mut().graphic_style = self.style.as_deref().and_then(|name| d.graphic_style_index(name)).map(|i| d.graphic_style_id(i)); } } diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 8895e2a7..8138e352 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -307,7 +307,7 @@ pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec, xf /// reflows at its size. False for anything else (and type in perspective), which transforms as /// usual. fn resize_type_area(n: &mut Node, xf: Affine) -> bool { - if n.perspective.is_some() { + if n.extras().perspective.is_some() { return false; } let NodeKind::Text(t) = &mut n.kind else { return false }; @@ -860,7 +860,7 @@ fn isolate(s: &mut Session, p: &Value) -> Result { if st.doc.node(id).is_none_or(|n| !n.is_container()) { return Err(bad("object.isolate", "only groups and layers can be isolated")); } - let isolation = st.doc.node(id).filter(|n| n.shaper.is_some()).and_then(|n| n.children()).and_then(|c| c.first()).map_or(id, |n| n.id); + let isolation = st.doc.node(id).filter(|n| n.extras().shaper.is_some()).and_then(|n| n.children()).and_then(|c| c.first()).map_or(id, |n| n.id); st.isolation = Some(isolation); st.selection.clear(); st.revision += 1; @@ -871,7 +871,7 @@ fn exit_isolation(s: &mut Session, _: &Value) -> Result { let st = s.doc_mut()?; if let Some(i) = st.isolation.take() { super::distortcmds::finish_edit_text(st, i); - let target = st.doc.parent_of(i).filter(|p| st.doc.node(*p).is_some_and(|n| n.shaper.is_some())).unwrap_or(i); + let target = st.doc.parent_of(i).filter(|p| st.doc.node(*p).is_some_and(|n| n.extras().shaper.is_some())).unwrap_or(i); st.selection.set([target]); } st.revision += 1; @@ -936,10 +936,9 @@ fn set_props(s: &mut Session, p: &Value) -> Result { && flag("isolate").is_none_or(|v| n.isolate == v) && knockout.is_none_or(|k| n.knockout == k) && flag("knockoutShape").is_none_or(|v| n.knockout_shape == v) - && data - .iter() - .flatten() - .all(|(k, v)| n.attrs.as_ref().and_then(|a| a.data.iter().find(|(key, _)| key == k)).map(|(_, now)| now.as_str()) == v.as_deref()) + && data.iter().flatten().all(|(k, v)| { + n.extras().attrs.as_ref().and_then(|a| a.data.iter().find(|(key, _)| key == k)).map(|(_, now)| now.as_str()) == v.as_deref() + }) }; let d = &s.doc()?.doc; if ids.iter().all(|id| d.node(*id).is_some_and(has_values)) { @@ -1410,7 +1409,7 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let has_values = |id: &NodeId| { let Some(NodeKind::Path { path, live, .. }) = st.doc.node(*id).map(|n| &n.kind) else { return true }; - match live { + match live.as_deref() { Some(l @ LiveShape::Polygon { sides: now, .. }) if !(sides.is_none_or(|n| n.clamp(3, 1000) == u64::from(*now)) && (!equal || l.polygon_sides_equal()) @@ -1425,11 +1424,11 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result { if radius.is_none() && kind.is_none() { return true; } - let Some(c) = LiveCorners::new(path, live.as_ref()) else { return true }; + let Some(c) = LiveCorners::new(path, live.as_deref()) else { return true }; let picked = corners_of(*id).unwrap_or_else(|| c.picked(st.selection.anchors.get(id))); // Setting corners folds a scale left in a rectangle's transform into its size, which // changes it. - live.as_ref().is_none_or(|l| l.folded() == *l) + live.as_deref().is_none_or(|l| l.folded() == *l) && picked .iter() .all(|k| *k < c.base.anchor_count() && radius.is_none_or(|r| c.radius(*k) == r.max(0.0)) && kind.is_none_or(|kd| c.kind(*k) == kd)) @@ -1440,7 +1439,7 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result { s.edit("Live Shape", |d, sel| { for id in &ids { let Some(NodeKind::Path { path, live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; - if let Some(l @ LiveShape::Polygon { .. }) = live.as_mut() + if let Some(l @ LiveShape::Polygon { .. }) = live.as_deref_mut() && (sides.is_some() || polygon_angle.is_some() || polygon_radius.is_some() || side.is_some() || equal) { if let Some(n) = sides { @@ -1458,7 +1457,7 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result { } *path = l.to_path(); } - if let Some(l @ LiveShape::Ellipse { .. }) = live.as_mut() + if let Some(l @ LiveShape::Ellipse { .. }) = live.as_deref_mut() && (pie_start.is_some() || pie_end.is_some() || invert) { if let LiveShape::Ellipse { pie, .. } = l { @@ -1471,7 +1470,7 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result { } let partial = sel.anchors.get_mut(id); // The corners to edit, the Direct-Selected ones and the anchor layout before the edit. - let Some(c) = LiveCorners::new(path, live.as_ref()) else { continue }; + let Some(c) = LiveCorners::new(path, live.as_deref()) else { continue }; let corners = corners_of(*id); let picked = match &corners { Some(k) if k.last().is_some_and(|k| *k >= c.base.anchor_count()) => { @@ -1486,7 +1485,7 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result { set_corners(path, live, &picked, radius, kind); // Direct-Selected corners stay selected as they gain or lose anchors. if let (Some(anchors), Some(selected)) = (partial, selected) - && let Some(c) = LiveCorners::new(path, live.as_ref()) + && let Some(c) = LiveCorners::new(path, live.as_deref()) && c.sources() != layout { *anchors = c.anchors_of(&selected); diff --git a/crates/engine/src/cmd/opacitymask.rs b/crates/engine/src/cmd/opacitymask.rs index 135e50ff..254b97fe 100644 --- a/crates/engine/src/cmd/opacitymask.rs +++ b/crates/engine/src/cmd/opacitymask.rs @@ -154,7 +154,7 @@ pub(crate) fn percent(o: f64) -> f32 { /// The [`transparency_targets`] that have an opacity mask. fn masked_targets(s: &Session, p: &Value) -> Result> { let d = &s.doc()?.doc; - let ids: Vec = transparency_targets(s, p)?.into_iter().filter(|id| d.node(*id).is_some_and(|n| n.mask.is_some())).collect(); + let ids: Vec = transparency_targets(s, p)?.into_iter().filter(|id| d.node(*id).is_some_and(|n| n.extras().mask.is_some())).collect(); if ids.is_empty() { return Err(EngineError::Other("no target object has an opacity mask: select one or give `id`".into())); } @@ -200,12 +200,12 @@ fn make(s: &mut Session, p: &Value) -> Result { gid }; let n = d.node_mut(target).ok_or(EngineError::NoNode(target))?; - if n.mask.is_some() { + if n.extras().mask.is_some() { return Err(EngineError::Other("the object already has an opacity mask".into())); } let mut m = OpacityMask::new(mask_art, clip); m.invert = invert; - n.mask = Some(Box::new(m)); + n.extras_mut().mask = Some(Box::new(m)); sel.set_target(d, target); let layer = if mask_id.is_none() { Some(begin(d, sel, target)?) } else { None }; Ok((target, layer)) @@ -224,7 +224,7 @@ fn release(s: &mut Session, p: &Value) -> Result { finish(d, sel); let mut out = vec![]; for id in &ids { - let Some(m) = d.node_mut(*id).and_then(|n| n.mask.take()) else { continue }; + let Some(m) = d.node_mut(*id).and_then(|n| n.extras_mut().mask.take()) else { continue }; let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?; out.push(*id); for (k, part) in mask_parts(&m.art).iter().enumerate() { @@ -259,12 +259,12 @@ fn set_flags(s: &mut Session, target: &Value, label: &str, p: &Value) -> Result< && linked.is_none_or(|v| m.linked == v) }; let d = &s.doc()?.doc; - if ids.iter().all(|id| d.node(*id).and_then(|n| n.mask.as_deref()).is_none_or(has_flags)) { + if ids.iter().all(|id| d.node(*id).and_then(|n| n.extras().mask.as_deref()).is_none_or(has_flags)) { return ok(); } s.edit(label, |d, _| { for id in &ids { - if let Some(m) = d.node_mut(*id).and_then(|n| n.mask.as_mut()) { + if let Some(m) = d.node_mut(*id).and_then(|n| n.extras_if_any().and_then(|e| e.mask.as_mut())) { m.clip = clip.unwrap_or(m.clip); m.invert = invert.unwrap_or(m.invert); m.disabled = disabled.unwrap_or(m.disabled); @@ -391,7 +391,7 @@ fn mask_info(s: &mut Session, p: &Value) -> Result { let v: Vec = nodes_of(d, &transparency_targets(s, p)?) .into_iter() .filter_map(|n| { - let m = n.mask.as_deref()?; + let m = n.extras().mask.as_deref()?; Some(json!({ "id": n.id.0, "clip": m.clip, "invert": m.invert, "disabled": m.disabled, "linked": m.linked, "art": m.art.id.0 })) }) .collect(); @@ -424,7 +424,7 @@ mod tests { assert_eq!(r["editing"], false); let d = &s.doc().unwrap().doc; assert!(d.node(NodeId(b)).is_none(), "mask art left the layer tree"); - let m = d.node(NodeId(a)).unwrap().mask.as_deref().unwrap(); + let m = d.node(NodeId(a)).unwrap().extras().mask.as_deref().unwrap(); assert!(m.clip && m.linked && !m.invert && !m.disabled); assert_eq!(m.art.id, NodeId(b)); } @@ -438,7 +438,7 @@ mod tests { let d = &s.doc().unwrap().doc; let n = d.node(NodeId(g)).unwrap(); assert_eq!(n.children().unwrap().len(), 2); - assert!(!n.mask.as_ref().unwrap().clip); + assert!(!n.extras().mask.as_ref().unwrap().clip); } #[test] @@ -448,11 +448,11 @@ mod tests { s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); s.execute("transparency.releaseOpacityMask", &json!({})).unwrap(); let st = s.doc().unwrap(); - assert!(st.doc.node(NodeId(a)).unwrap().mask.is_none()); + assert!(st.doc.node(NodeId(a)).unwrap().extras().mask.is_none()); assert_eq!(st.selection.len(), 2); assert_eq!(st.doc.layers[0].children().unwrap().len(), 2); s.execute("edit.undo", &json!({})).unwrap(); - assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().mask.is_some()); + assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().extras().mask.is_some()); } #[test] @@ -469,13 +469,13 @@ mod tests { assert_eq!(info[0]["disabled"], true); assert_eq!(info[0]["linked"], false); // Unlinked: moving the object leaves the mask art where it was. - let before = s.doc().unwrap().doc.node(NodeId(a)).unwrap().mask.as_ref().unwrap().art.clone(); + let before = s.doc().unwrap().doc.node(NodeId(a)).unwrap().extras().mask.as_ref().unwrap().art.clone(); s.execute("object.move", &json!({"dx": 30, "dy": 0})).unwrap(); - let after = s.doc().unwrap().doc.node(NodeId(a)).unwrap().mask.as_ref().unwrap().art.clone(); + let after = s.doc().unwrap().doc.node(NodeId(a)).unwrap().extras().mask.as_ref().unwrap().art.clone(); assert_eq!(before.geometric_bounds(), after.geometric_bounds()); s.execute("transparency.linkOpacityMask", &json!({})).unwrap(); s.execute("object.move", &json!({"dx": 30, "dy": 0})).unwrap(); - let moved = s.doc().unwrap().doc.node(NodeId(a)).unwrap().mask.as_ref().unwrap().art.clone(); + let moved = s.doc().unwrap().doc.node(NodeId(a)).unwrap().extras().mask.as_ref().unwrap().art.clone(); assert!((moved.geometric_bounds().unwrap().x0 - after.geometric_bounds().unwrap().x0 - 30.0).abs() < 1e-9); } @@ -485,7 +485,7 @@ mod tests { assert_eq!(s.execute("transparency.toggleNewMasksClipping", &json!({})).unwrap()["value"], false); select(&mut s, &[a, b]); s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); - assert!(!s.doc().unwrap().doc.node(NodeId(a)).unwrap().mask.as_ref().unwrap().clip); + assert!(!s.doc().unwrap().doc.node(NodeId(a)).unwrap().extras().mask.as_ref().unwrap().clip); s.execute("transparency.toggleNewMasksInverted", &json!({})).unwrap(); assert_eq!(s.new_mask_defaults(), (false, true)); } @@ -498,6 +498,6 @@ mod tests { let d = s.doc().unwrap().doc.clone(); let bytes = vectorcraft_format::save(&d, false); let back = vectorcraft_format::load(&bytes).unwrap(); - assert_eq!(back.node(NodeId(a)).unwrap().mask, d.node(NodeId(a)).unwrap().mask); + assert_eq!(back.node(NodeId(a)).unwrap().extras().mask, d.node(NodeId(a)).unwrap().extras().mask); } } diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index 5a084eb0..28772e80 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -558,7 +558,7 @@ pub(crate) fn styled_copy(d: &mut Document, l: &Node, items: Vec f.opacity = 1.0; f.blend = BlendMode::Normal; f.name = None; - f.mask = None; + f.extras_mut().mask = None; if let NodeKind::Path { live, .. } = &mut f.kind { *live = None; } @@ -575,7 +575,7 @@ pub(crate) fn assemble(d: &mut Document, l: &Node, mut parts: Vec) -> Opti }; let mut root = with_transparency(d, root, l.opacity, l.blend); root.name = l.name.clone(); - root.mask = l.mask.clone(); + root.extras_mut().mask = l.extras().mask.clone(); root.appearance.effects = l.appearance.effects.clone(); Some(root) } diff --git a/crates/engine/src/cmd/place/adopt.rs b/crates/engine/src/cmd/place/adopt.rs index 928a4baf..3493dbd0 100644 --- a/crates/engine/src/cmd/place/adopt.rs +++ b/crates/engine/src/cmd/place/adopt.rs @@ -16,7 +16,7 @@ use super::unique_name; /// Visit `n` and every descendant (children and opacity-mask art) mutably. pub(super) fn each_mut(n: &mut Node, f: &mut impl FnMut(&mut Node)) { f(n); - if let Some(m) = &mut n.mask { + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { each_mut(Arc::make_mut(&mut m.art), f); } if let Some(ch) = n.children_mut() { diff --git a/crates/engine/src/cmd/place/mod.rs b/crates/engine/src/cmd/place/mod.rs index a9177c10..941011f9 100644 --- a/crates/engine/src/cmd/place/mod.rs +++ b/crates/engine/src/cmd/place/mod.rs @@ -285,7 +285,7 @@ fn build(d: &mut Document, l: Loaded, link: Option) -> Node { link, placement: Default::default(), }; - Node::new(d.alloc_id(), NodeKind::Image(im)) + Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))) } Art::Text(t) => Node::new(d.alloc_id(), NodeKind::Text(Box::new(text::area_text(t, l.natural)))), Art::Document(s, link, bounding) => document::node(d, &s, link, bounding), diff --git a/crates/engine/src/cmd/printadvanced.rs b/crates/engine/src/cmd/printadvanced.rs index ef684b00..8a9340e9 100644 --- a/crates/engine/src/cmd/printadvanced.rs +++ b/crates/engine/src/cmd/printadvanced.rs @@ -96,7 +96,7 @@ fn bitmap_document(cmd: &str, doc: &Document, set: &PrintSettings) -> Result Option> { /// effects (they get images of their own). pub(crate) fn raster_image(d: &mut Document, n: &Node, below: bool, members: bool) -> Option { let mut whole = n.clone(); - (whole.opacity, whole.blend, whole.mask) = (1.0, Default::default(), None); + (whole.opacity, whole.blend) = (1.0, Default::default()); + whole.extras_mut().mask = None; if below && members && is_container(&whole) { for c in whole.children_mut().into_iter().flatten() { strip_raster(Arc::make_mut(c), true); @@ -317,7 +320,8 @@ pub(crate) fn put_below(d: &mut Document, m: &mut Node, image: Node) { _ => { let mut inner = m.clone(); inner.id = d.alloc_id(); - (inner.name, inner.opacity, inner.blend, inner.isolate, inner.mask) = (None, 1.0, Default::default(), false, None); + (inner.name, inner.opacity, inner.blend, inner.isolate) = (None, 1.0, Default::default(), false); + inner.extras_mut().mask = None; // Knockout is between the object's own parts (a blend's steps): it stays with them, // so the image doesn't knock out the art over it. m.knockout = Default::default(); @@ -379,7 +383,9 @@ fn placed_symbol(d: &Document, n: &Node) -> Option { let NodeKind::SymbolInstance { symbol, xf } = &n.kind else { return None }; let mut art = vectorcraft_render::instance_art(&d.symbols.iter().find(|s| s.name == *symbol)?.art, n); art.transform(*xf, false); - Some(Node { kind: NodeKind::Group { children: vec![Arc::new(art)], clip: false }, ..n.clone() }) + let mut g = n.clone(); + g.kind = NodeKind::Group { children: vec![Arc::new(art)], clip: false }; + Some(g) } /// Which objects' raster effects become images. @@ -407,13 +413,13 @@ fn needs(n: &Node, which: Which) -> bool { n.visible && !matches!(n.kind, NodeKind::Layer { template: true, .. }) && (imaged(n, which) - || n.mask.as_ref().is_some_and(|m| needs(&m.art, which)) + || n.extras().mask.as_ref().is_some_and(|m| needs(&m.art, which)) || (is_container(n) && n.children().is_some_and(|ch| ch.iter().any(|c| needs(c, which))))) } /// The art of `n`'s opacity mask with its raster effects as images. fn walk_mask(out: &mut Document, n: &mut Node, report: &mut FlattenReport) { - if let Some(m) = n.mask.as_mut() + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) && let Some(art) = walk(out, &m.art, report) { m.art = Arc::new(art); diff --git a/crates/engine/src/cmd/rasterfx/report.rs b/crates/engine/src/cmd/rasterfx/report.rs index e04fcd9d..e263b03f 100644 --- a/crates/engine/src/cmd/rasterfx/report.rs +++ b/crates/engine/src/cmd/rasterfx/report.rs @@ -85,14 +85,14 @@ mod tests { fn image(ppi: f64) -> Node { Node::new( NodeId(999), - NodeKind::Image(ImageObject { + NodeKind::Image(Box::new(ImageObject { key: "effect".into(), width: 8000, height: 8000, xf: Affine::scale(72.0 / ppi), link: None, placement: Default::default(), - }), + })), ) } diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index 12597bb8..36d6fdb5 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -547,7 +547,7 @@ fn same_graphic_style(s: &mut Session, _: &Value) -> Result { .and_then(|id| super::style::linked_style(&st.doc, st.doc.node(*id)?)) .map(|g| g.id) .ok_or_else(|| bad(cmd, "the selection has no graphic style"))?; - select_where(s, cmd, &json!({}), |d, n| n.graphic_style == Some(id) && super::style::linked_style(d, n).is_some()) + select_where(s, cmd, &json!({}), |d, n| n.extras().graphic_style == Some(id) && super::style::linked_style(d, n).is_some()) } /// An appearance attribute Select > Same > Appearance Attribute matches. diff --git a/crates/engine/src/cmd/shaper.rs b/crates/engine/src/cmd/shaper.rs index b7863217..24f96620 100644 --- a/crates/engine/src/cmd/shaper.rs +++ b/crates/engine/src/cmd/shaper.rs @@ -56,7 +56,7 @@ pub fn specs() -> Vec { } fn source_group(n: &Node) -> Option<&Node> { - n.shaper.as_ref()?; + n.extras().shaper.as_ref()?; n.children()?.first().map(AsRef::as_ref).filter(|g| g.name.as_deref() == Some(SOURCES)) } fn sources(n: &Node) -> Vec> { @@ -376,7 +376,7 @@ fn generated(d: &mut Document, src: &[Arc], spec: &ShaperSpec, map: &Build pub(crate) fn refresh(before: &Document, d: &mut Document) -> Result<()> { let mut ids = vec![]; d.walk(|n| { - if n.shaper.is_some() { + if n.extras().shaper.is_some() { ids.push(n.id) } }); @@ -399,7 +399,7 @@ pub(crate) fn refresh(before: &Document, d: &mut Document) -> Result<()> { continue; } let map = map_of(&src)?; - let mut spec = current.shaper.as_deref().cloned().unwrap_or_default(); + let mut spec = current.extras().shaper.as_deref().cloned().unwrap_or_default(); let previous = sources(old); if previous.iter().map(|s| s.id).ne(src.iter().map(|s| s.id)) { // Source insertion, deletion or reordering updates the index-based recipes. @@ -489,7 +489,7 @@ pub(crate) fn refresh(before: &Document, d: &mut Document) -> Result<()> { children.push(n); } let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; - n.shaper = Some(Box::new(spec)); + n.extras_mut().shaper = Some(Box::new(spec)); if let Some(ch) = n.children_mut() { *ch = children; } @@ -509,7 +509,7 @@ fn candidates(s: &Session, p: &Value, points: &[Point], tolerance: f64) -> Resul st.doc.is_editable(*id) && st.doc.node(*id).is_some_and(|n| { !matches!(n.kind, NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. }) - && (n.shaper.is_some() || b::node_outline(n).is_some()) + && (n.extras().shaper.is_some() || b::node_outline(n).is_some()) }) }) .collect(); @@ -578,7 +578,7 @@ fn scribble(s: &mut Session, p: &Value) -> Result { let mut next_merge = 1u64; for id in &roots { let n = s.doc()?.doc.node(*id).ok_or(EngineError::NoNode(*id))?; - if let Some(previous) = n.shaper.as_deref() { + if let Some(previous) = n.extras().shaper.as_deref() { let offset = src.len(); let merge_offset = next_merge; for mut e in previous.faces.clone() { @@ -669,7 +669,7 @@ fn scribble(s: &mut Session, p: &Value) -> Result { let id = d.alloc_id(); let mut group = Node::group(id, children); group.name = Some("Shaper Group".into()); - group.shaper = Some(Box::new(spec)); + group.extras_mut().shaper = Some(Box::new(spec)); for id in &roots { d.remove(*id)?; } @@ -684,7 +684,7 @@ fn scribble(s: &mut Session, p: &Value) -> Result { fn group_at(d: &Document, point: Point, construction: bool) -> Option { let mut groups = vec![]; d.walk(|n| { - if n.shaper.is_some() + if n.extras().shaper.is_some() && d.is_editable(n.id) && (if construction { source_group(n).and_then(Node::geometric_bounds) } else { n.geometric_bounds() }) .is_some_and(|b| b.inflate(6.0, 6.0).contains(point)) @@ -733,7 +733,7 @@ fn target(s: &Session, p: &Value) -> Result { let id = id_param(p, "id") .or_else(|| s.active().and_then(|d| d.selection.objects.first().copied())) .ok_or_else(|| bad("shaper.release", "select a Shaper group"))?; - s.doc()?.doc.node(id).filter(|n| n.shaper.is_some()).ok_or_else(|| bad("shaper.release", "select a Shaper group"))?; + s.doc()?.doc.node(id).filter(|n| n.extras().shaper.is_some()).ok_or_else(|| bad("shaper.release", "select a Shaper group"))?; Ok(id) } fn release(s: &mut Session, p: &Value) -> Result { @@ -756,7 +756,7 @@ fn expand(s: &mut Session, p: &Value) -> Result { let id = target(s, p)?; s.edit("Expand Shaper Group", |d, sel| { let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; - n.shaper = None; + n.extras_mut().shaper = None; n.name = None; if let Some(children) = n.children_mut() && children.first().is_some_and(|c| c.name.as_deref() == Some(SOURCES)) diff --git a/crates/engine/src/cmd/slices.rs b/crates/engine/src/cmd/slices.rs index b5b57af2..cda0e158 100644 --- a/crates/engine/src/cmd/slices.rs +++ b/crates/engine/src/cmd/slices.rs @@ -210,7 +210,7 @@ fn selected(s: &Session) -> Result> { } pub(crate) fn selected_slices(d: &Document, sel: &vectorcraft_doc::Selection) -> Vec { - let objects = sel.objects.iter().filter(|id| d.node(**id).is_some_and(|n| n.slice.is_some())); + let objects = sel.objects.iter().filter(|id| d.node(**id).is_some_and(|n| n.extras().slice.is_some())); dedup(sel.slices.iter().chain(objects).copied().collect()) } @@ -234,7 +234,7 @@ fn doc_has_slices(s: &Session) -> std::result::Result<(), String> { fn object_slice_ids(d: &Document) -> Vec { let mut ids = vec![]; d.walk(|n| { - if n.slice.is_some() { + if n.extras().slice.is_some() { ids.push(n.id); } }); @@ -260,7 +260,7 @@ pub(crate) fn remove_slice(d: &mut Document, id: NodeId) { if d.slices.len() == before && let Some(n) = d.node_mut(id) { - n.slice = None; + n.extras_mut().slice = None; } } @@ -284,14 +284,14 @@ fn make(s: &mut Session, p: &Value) -> Result { None => super::edit::selected_roots(s)?, }; let d = &s.doc()?.doc; - let made: Vec = ids.into_iter().filter(|id| d.node(*id).is_some_and(|n| !n.is_layer() && n.slice.is_none())).collect(); + let made: Vec = ids.into_iter().filter(|id| d.node(*id).is_some_and(|n| !n.is_layer() && n.extras().slice.is_none())).collect(); if made.is_empty() { return Ok(json!({ "ids": [] })); } s.edit("Make Slice", |d, _| { for id in &made { if let Some(n) = d.node_mut(*id) { - n.slice = Some(Box::default()); + n.extras_mut().slice = Some(Box::default()); } } Ok(()) @@ -559,7 +559,7 @@ fn options(s: &mut Session, p: &Value) -> Result { for id in &targets { let o = match d.slice_mut(*id) { Some(sl) => Some(&mut sl.options), - None => d.node_mut(*id).and_then(|n| n.slice.as_deref_mut()), + None => d.node_mut(*id).and_then(|n| n.extras_if_any().and_then(|e| e.slice.as_deref_mut())), }; if let Some(o) = o { edits.apply(o); diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index c6f720fc..18231883 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -175,7 +175,7 @@ pub(crate) fn in_sync(n: &Node, g: &GraphicStyle) -> bool { /// The style `n` is linked to (and still looks like). pub(crate) fn linked_style<'a>(d: &'a Document, n: &Node) -> Option<&'a GraphicStyle> { - d.graphic_style_by_id(n.graphic_style?).filter(|g| in_sync(n, g)) + d.graphic_style_by_id(n.extras().graphic_style?).filter(|g| in_sync(n, g)) } /// Objects anywhere in the art that `f` accepts. @@ -272,14 +272,14 @@ fn style_node(n: &mut Node, g: &GraphicStyle, chars: bool) { fn apply_style(d: &mut Document, id: NodeId, g: &GraphicStyle, chars: bool) { if let Some(n) = d.node_mut(id) { style_node(n, g, chars); - n.graphic_style = Some(g.id); + n.extras_mut().graphic_style = Some(g.id); } } fn set_link(d: &mut Document, ids: &[NodeId], link: Option) { for id in ids { if let Some(n) = d.node_mut(*id) { - n.graphic_style = link; + n.extras_mut().graphic_style = link; } } } @@ -303,7 +303,7 @@ impl Session { pub fn selection_graphic_style(&self) -> Option<(&GraphicStyle, bool)> { let st = self.active()?; let n = st.doc.node(*st.selection.subjects().first()?)?; - let g = st.doc.graphic_style_by_id(n.graphic_style?)?; + let g = st.doc.graphic_style_by_id(n.extras().graphic_style?)?; Some((g, in_sync(n, g))) } @@ -341,7 +341,7 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { let ap = g.appearance_on(n).into_owned(); n.appearance.items.extend(ap.items); n.appearance.effects.extend(ap.effects); - n.graphic_style = None; + n.extras_mut().graphic_style = None; } } } else { @@ -383,7 +383,7 @@ fn style_delete(s: &mut Session, p: &Value) -> Result { let i = style_index(d, name)?; let id = d.graphic_styles.remove(i).id; if id != 0 { - let ids = objects_where(d, |n| n.graphic_style == Some(id)); + let ids = objects_where(d, |n| n.extras().graphic_style == Some(id)); set_link(d, &ids, None); } } @@ -447,6 +447,7 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { let i = match str_param(p, "name") { Some(name) => style_index(d, name)?, None => n + .extras() .graphic_style .and_then(|id| d.graphic_style_by_id(id)) .and_then(|g| d.graphic_style_index(&g.name)) @@ -460,7 +461,7 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { let old = std::mem::replace(&mut d.graphic_styles[i], GraphicStyle { name: name.clone(), id, ..look }); // Objects still linked follow the new definition; edited ones are unlinked (they keep // their look), and the source keeps its own. - let linked = objects_where(d, |n| n.graphic_style == Some(id) && n.id != src); + let linked = objects_where(d, |n| n.extras().graphic_style == Some(id) && n.id != src); let (follow, edited): (Vec, Vec) = linked.into_iter().partition(|nid| d.node(*nid).is_some_and(|n| in_sync(n, &old))); set_link(d, &edited, None); let g = d.graphic_styles[i].clone(); diff --git a/crates/engine/src/cmd/textwrap.rs b/crates/engine/src/cmd/textwrap.rs index e916f65c..862d447d 100644 --- a/crates/engine/src/cmd/textwrap.rs +++ b/crates/engine/src/cmd/textwrap.rs @@ -48,7 +48,7 @@ fn make(s: &mut Session, p: &Value) -> Result { if matches!(n.kind, NodeKind::Layer { .. }) { return Err(EngineError::Other("layers can't be wrap objects".into())); } - n.wrap = Some(w); + n.extras_mut().wrap = Some(w); } Ok(()) })?; @@ -58,7 +58,10 @@ fn make(s: &mut Session, p: &Value) -> Result { fn release(s: &mut Session, p: &Value) -> Result { let ids: Vec = { let d = &s.doc()?.doc; - checked_ids_or_selection(s, p, "object.textWrap.release")?.into_iter().filter(|i| d.node(*i).is_some_and(|n| n.wrap.is_some())).collect() + checked_ids_or_selection(s, p, "object.textWrap.release")? + .into_iter() + .filter(|i| d.node(*i).is_some_and(|n| n.extras().wrap.is_some())) + .collect() }; if ids.is_empty() { return Err(EngineError::Other("Text Wrap: select a wrap object".into())); @@ -66,7 +69,7 @@ fn release(s: &mut Session, p: &Value) -> Result { s.edit("Release Text Wrap", |d, _| { for id in &ids { if let Some(n) = d.node_mut(*id) { - n.wrap = None; + n.extras_mut().wrap = None; } } Ok(()) @@ -78,7 +81,7 @@ fn options(s: &mut Session, p: &Value) -> Result { let ids = checked_ids_or_selection(s, p, "object.textWrap.options")?; let current = { let d = &s.doc()?.doc; - ids.iter().find_map(|i| d.node(*i).and_then(|n| n.wrap)).unwrap_or_default() + ids.iter().find_map(|i| d.node(*i).and_then(|n| n.extras().wrap)).unwrap_or_default() }; if p.get("offset").is_none() && p.get("invert").is_none() { return Ok(json!({ "offset": current.offset, "invert": current.invert })); @@ -89,7 +92,7 @@ fn options(s: &mut Session, p: &Value) -> Result { if let Some(n) = d.node_mut(*id) && !matches!(n.kind, NodeKind::Layer { .. }) { - n.wrap = Some(w); + n.extras_mut().wrap = Some(w); } } Ok(()) @@ -131,7 +134,7 @@ fn collect<'a>(n: &'a Node, out: &mut Vec>) { if !n.visible { return; } - if n.wrap.is_some() && !matches!(n.kind, NodeKind::Layer { .. }) { + if n.extras().wrap.is_some() && !matches!(n.kind, NodeKind::Layer { .. }) { out.push(Item::Wrap(n)); return; } @@ -147,7 +150,7 @@ fn collect<'a>(n: &'a Node, out: &mut Vec>) { } fn any_wrap_state(n: &Node) -> bool { - n.wrap.is_some() + n.extras().wrap.is_some() || matches!(&n.kind, NodeKind::Text(t) if !t.wrap.is_empty()) || n.children().is_some_and(|ch| ch.iter().any(|c| any_wrap_state(c))) } @@ -173,7 +176,7 @@ pub(crate) fn refresh(doc: &mut Document) { outline(n, &mut pd); (!pd.is_empty()).then(|| { pd.transform(inv); - WrapShape { path: pd, wrap: n.wrap.unwrap_or_default() } + WrapShape { path: pd, wrap: n.extras().wrap.unwrap_or_default() } }) } Item::Text(_) => None, @@ -219,18 +222,18 @@ mod tests { let e = s.execute(command, &json!({"ids": ids, "offset": 12})).unwrap_err().to_string(); assert!(e.contains("id") || e.contains("array"), "{command}: {e}"); assert_eq!(s.doc().unwrap().history.undo.len(), before, "{command} added history on error"); - assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.is_none()); - assert!(s.doc().unwrap().doc.node(NodeId(other)).unwrap().wrap.is_none()); + assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().extras().wrap.is_none()); + assert!(s.doc().unwrap().doc.node(NodeId(other)).unwrap().extras().wrap.is_none()); } } s.execute("object.textWrap.make", &json!({"ids": [id]})).unwrap(); - assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.is_some()); - assert!(s.doc().unwrap().doc.node(NodeId(other)).unwrap().wrap.is_none()); + assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().extras().wrap.is_some()); + assert!(s.doc().unwrap().doc.node(NodeId(other)).unwrap().extras().wrap.is_none()); s.execute("object.textWrap.options", &json!({"ids": [id], "offset": 12})).unwrap(); - assert_eq!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.unwrap().offset, 12.0); + assert_eq!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().extras().wrap.unwrap().offset, 12.0); s.execute("object.textWrap.release", &json!({"ids": [id]})).unwrap(); - assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.is_none()); + assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().extras().wrap.is_none()); } #[test] diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 7615a800..0350cee1 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -683,7 +683,7 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result { } let d = Vec2::new(f64_req(p, "dx", C)?, f64_req(p, "dy", C)?); let node = s.doc()?.doc.node(id); - if area_text(node).is_none() || node.is_some_and(|n| n.perspective.is_some()) { + if area_text(node).is_none() || node.is_some_and(|n| n.extras().perspective.is_some()) { return Err(bad(C, "not area type (text in a frame) outside perspective")); } s.edit("Reshape Type Area", |doc, _| { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 58b89bd9..f6d3f06e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -73,15 +73,47 @@ pub struct HistoryEntry { /// Roughly the memory this step keeps of its own: what of `doc` the step after it (or the /// document now) doesn't share ([`Document::retained_bytes`]). pub bytes: usize, + /// The same the other way round: what that next state keeps that `doc` doesn't share. Undoing + /// (redoing) this step makes a step that keeps the next state: this is its `bytes`. + next_bytes: usize, + /// Which document the figures were taken against (its address): the next state. + next: usize, } impl HistoryEntry { /// A step keeping `doc` and `selection`, as they were before the edit that made `next`. pub(crate) fn kept(label: String, doc: Arc, selection: Arc, next: &Document) -> Self { doc.drop_lookup_cache(); - let bytes = doc.retained_bytes(next); - HistoryEntry { label, doc, selection, bytes } + let (bytes, next_bytes) = doc.retained_bytes_each(next); + HistoryEntry { label, doc, selection, bytes, next_bytes, next: std::ptr::from_ref(next) as usize } } + + /// The step that undoing (redoing) the step `costs` came from makes: it keeps `doc`, the + /// document before the undo, whose next state is now `now` (that step's document). Its figures + /// are that step's the other way round when `doc` is still the document they were taken + /// against (nothing replaced it since), so undo and redo needn't walk the documents again. A + /// change made to it in place without an undo step (a setting, a refresh) goes unnoticed: a + /// small error, for an estimate. + pub(crate) fn turned(label: String, doc: Arc, selection: Arc, now: &Arc, costs: StepCosts) -> Self { + if costs.next != Arc::as_ptr(&doc) as usize { + return Self::kept(label, doc, selection, now); + } + doc.drop_lookup_cache(); + HistoryEntry { label, doc, selection, bytes: costs.next_bytes, next_bytes: costs.bytes, next: Arc::as_ptr(now) as usize } + } + + /// The figures [`HistoryEntry::turned`] reuses. + pub(crate) fn costs(&self) -> StepCosts { + StepCosts { bytes: self.bytes, next_bytes: self.next_bytes, next: self.next } + } +} + +/// A step's memory figures ([`HistoryEntry::costs`]). +#[derive(Clone, Copy, Debug)] +pub(crate) struct StepCosts { + bytes: usize, + next_bytes: usize, + next: usize, } /// The undo history of a document: bounded by a number of steps (Preferences › Performance › @@ -141,7 +173,8 @@ impl History { /// merged into it, or an undo group extended it). pub(crate) fn recost_last(&mut self, now: &Document) { if let Some(e) = self.undo.last_mut() { - e.bytes = e.doc.retained_bytes(now); + (e.bytes, e.next_bytes) = e.doc.retained_bytes_each(now); + e.next = std::ptr::from_ref(now) as usize; } } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a8505146..585ffd12 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -981,7 +981,7 @@ fn live_shape_properties_already_set_record_no_undo_step() { let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); let NodeKind::Path { path, live: Some(live), .. } = &mut d.node_mut(r).unwrap().kind else { panic!("not a live rectangle") }; let xf = Affine::scale_non_uniform(2.0, 1.0); - *live = vectorcraft_doc::LiveShape::Rectangle { w: 50.0, h: 60.0, radii: [10.0; 4], kinds: Default::default(), xf }; + **live = vectorcraft_doc::LiveShape::Rectangle { w: 50.0, h: 60.0, radii: [10.0; 4], kinds: Default::default(), xf }; *path = live.to_path(); } let shown = vectorcraft_doc::LiveCorners::of(s.doc().unwrap().doc.node(r).unwrap()).unwrap().radius(0); @@ -1762,8 +1762,8 @@ fn graph_tool_drag_creates_a_graph_and_asks_for_data() { let st = s.doc().unwrap(); assert_eq!(st.history.undo.len(), undo_before + 1); let g = st.doc.node(st.selection.objects[0]).unwrap(); - assert_eq!(g.graph.as_ref().unwrap().kind, vectorcraft_doc::GraphKind::Pie); - assert_eq!(g.graph.as_ref().unwrap().rect, vectorcraft_geom::Rect::new(100.0, 100.0, 300.0, 250.0)); + assert_eq!(g.extras().graph.as_ref().unwrap().kind, vectorcraft_doc::GraphKind::Pie); + assert_eq!(g.extras().graph.as_ref().unwrap().rect, vectorcraft_geom::Rect::new(100.0, 100.0, 300.0, 250.0)); } #[test] @@ -1857,17 +1857,13 @@ fn shaper_turns_rough_strokes_into_live_shapes_and_scribbles_punch_fills() { stroke(&mut s, &rect); let id = s.doc().unwrap().selection.objects[0]; let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - assert!( - matches!(n.kind, vectorcraft_doc::NodeKind::Path { live: Some(vectorcraft_doc::LiveShape::Rectangle { .. }), .. }), - "{:?}", - n.kind_label() - ); + assert!(matches!(n.live_shape(), Some(vectorcraft_doc::LiveShape::Rectangle { .. })), "{:?}", n.kind_label()); // A zig-zag inside it clears its fill while retaining the editable original and stroke. let zig: Vec<(f64, f64)> = (0..30).map(|i| (150.0 + (i % 2) as f64 * 80.0, 120.0 + i as f64 * 2.0)).collect(); stroke(&mut s, &zig); assert_eq!(s.doc().unwrap().doc.node(id).unwrap(), &n); let group = s.doc().unwrap().doc.node(s.doc().unwrap().selection.objects[0]).unwrap(); - assert!(group.shaper.is_some()); + assert!(group.extras().shaper.is_some()); assert!(group.children().unwrap().iter().skip(1).all(|n| n.appearance.fill_paint().is_none())); assert!(group.children().unwrap().iter().skip(1).any(|n| !n.appearance.stroke_paint().is_none())); } @@ -1889,10 +1885,7 @@ fn shaper_square_started_mid_edge_closes_on_pointer_release_and_undoes_once() { s.pointer(&PointerEvent::new(kind, x, y), v).unwrap(); } let id = *s.doc().unwrap().selection.objects.first().expect("recognized square"); - assert!(matches!( - s.doc().unwrap().doc.node(id).unwrap().kind, - vectorcraft_doc::NodeKind::Path { live: Some(vectorcraft_doc::LiveShape::Rectangle { .. }), .. } - )); + assert!(matches!(s.doc().unwrap().doc.node(id).unwrap().live_shape(), Some(vectorcraft_doc::LiveShape::Rectangle { .. }))); assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); s.execute("edit.undo", &json!({})).unwrap(); assert!(s.doc().unwrap().doc.node(id).is_none()); @@ -1928,7 +1921,7 @@ fn shaper_creates_upright_or_inverted_live_triangles_with_one_undo_step() { s.pointer(&PointerEvent::new(PointerKind::Up, pts[0].x, pts[0].y), v).unwrap(); let id = *s.doc().unwrap().selection.objects.first().expect("recognized triangle"); let node = s.doc().unwrap().doc.node(id).unwrap().clone(); - let vectorcraft_doc::NodeKind::Path { live: Some(vectorcraft_doc::LiveShape::Polygon { sides: 3, xf, .. }), .. } = &node.kind else { + let Some(vectorcraft_doc::LiveShape::Polygon { sides: 3, xf, .. }) = node.live_shape() else { panic!("expected live triangle, got {:?}", node.kind_label()); }; let [a, b, c, d, _, _] = xf.as_coeffs(); diff --git a/crates/engine/src/tests_adjust.rs b/crates/engine/src/tests_adjust.rs index 040a4924..9649e12b 100644 --- a/crates/engine/src/tests_adjust.rs +++ b/crates/engine/src/tests_adjust.rs @@ -43,7 +43,7 @@ fn red_image(s: &mut Session) -> NodeId { link: None, placement: Default::default(), }; - d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(im)))?; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; sel.set([id]); Ok(id) }) diff --git a/crates/engine/src/tests_attributes.rs b/crates/engine/src/tests_attributes.rs index 562c342a..17bcb26f 100644 --- a/crates/engine/src/tests_attributes.rs +++ b/crates/engine/src/tests_attributes.rs @@ -52,13 +52,13 @@ fn each_attribute_sets_reads_back_and_undoes_in_one_step() { assert_eq!(i[k], v, "{k}"); } let n = s.doc().unwrap().doc.node(a).unwrap().clone(); - let at = n.attrs.as_deref().unwrap(); + let at = n.extras().attrs.as_deref().unwrap(); assert_eq!((at.show_center, at.image_map, n.url()), (Some(false), ImageMap::Rectangle, Some("https://example.com"))); assert!(!n.shows_center()); // One undo step takes everything back, and the attributes go away entirely. run(&mut s, "edit.undo", json!({})); let n = s.doc().unwrap().doc.node(a).unwrap().clone(); - assert!(n.attrs.is_none() && !n.has_overprint()); + assert!(n.extras().attrs.is_none() && !n.has_overprint()); // Nothing to change: no undo step; bad values are refused. let before = s.doc().unwrap().revision; assert_eq!(run(&mut s, "attributes.set", json!({"showCenter": true}))["changed"], 0); @@ -68,7 +68,7 @@ fn each_attribute_sets_reads_back_and_undoes_in_one_step() { // Clearing the URL and note drops the attributes. run(&mut s, "attributes.set", json!({"url": "u", "note": "n"})); run(&mut s, "attributes.set", json!({"url": "", "note": ""})); - assert!(s.doc().unwrap().doc.node(a).unwrap().attrs.is_none()); + assert!(s.doc().unwrap().doc.node(a).unwrap().extras().attrs.is_none()); } #[test] @@ -168,8 +168,8 @@ fn attributes_survive_a_native_round_trip() { run(&mut s, "attributes.set", json!({"showCenter": false, "imageMap": "polygon", "url": "https://example.com/x", "note": "keep"})); let doc = s.doc().unwrap().doc.clone(); let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap(); - assert_eq!(back.node(a).unwrap().attrs, doc.node(a).unwrap().attrs); - assert!(back.node(a).unwrap().attrs.is_some()); + assert_eq!(back.node(a).unwrap().extras().attrs, doc.node(a).unwrap().extras().attrs); + assert!(back.node(a).unwrap().extras().attrs.is_some()); } #[test] @@ -197,7 +197,7 @@ fn svg_export_links_objects_and_import_reads_the_links_back() { fn object_data_is_set_saved_and_written_to_svg() { let mut s = session(); let a = rect(&mut s, 10.0); - let data = |s: &Session| s.doc().unwrap().doc.node(a).unwrap().attrs.as_deref().map(|x| x.data.clone()).unwrap_or_default(); + let data = |s: &Session| s.doc().unwrap().doc.node(a).unwrap().extras().attrs.as_deref().map(|x| x.data.clone()).unwrap_or_default(); let pair = |k: &str, v: &str| (k.to_string(), v.to_string()); run(&mut s, "object.setProps", json!({"data": {"pivot": "60,100", "z": 3}})); assert_eq!(data(&s), [pair("pivot", "60,100"), pair("z", "3")], "values are text"); @@ -212,5 +212,5 @@ fn object_data_is_set_saved_and_written_to_svg() { assert!(svg.contains("data-pivot=\"60,100\" data-z=\"3\""), "{svg}"); let native = vectorcraft_format::save(&s.doc().unwrap().doc, false); let back = vectorcraft_format::load(&native).unwrap(); - assert_eq!(back.node(a).unwrap().attrs, s.doc().unwrap().doc.node(a).unwrap().attrs); + assert_eq!(back.node(a).unwrap().extras().attrs, s.doc().unwrap().doc.node(a).unwrap().extras().attrs); } diff --git a/crates/engine/src/tests_blendfidelity.rs b/crates/engine/src/tests_blendfidelity.rs index b71f6eb6..a3c06c95 100644 --- a/crates/engine/src/tests_blendfidelity.rs +++ b/crates/engine/src/tests_blendfidelity.rs @@ -166,7 +166,7 @@ fn expand_and_release_keep_what_the_blend_carries() { s.edit("mask", |d, _| { let art = Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 10.0)), Default::default()); let mask = vectorcraft_doc::OpacityMask::new(art, false); - d.node_mut(g).unwrap().mask = Some(Box::new(mask)); + d.node_mut(g).unwrap().extras_mut().mask = Some(Box::new(mask)); Ok(()) }) .unwrap(); @@ -176,14 +176,14 @@ fn expand_and_release_keep_what_the_blend_carries() { let e = node(&s, g); assert!(matches!(e.kind, NodeKind::Group { .. })); assert_eq!((e.name.as_deref(), e.opacity, e.isolate, e.knockout), (Some("Fade"), before.opacity, true, Knockout::On)); - assert_eq!(e.mask, before.mask); + assert_eq!(e.extras().mask, before.extras().mask); s.execute("edit.undo", &json!({})).unwrap(); s.execute("select.set", &json!({"ids": [g.0]})).unwrap(); let r = s.execute("object.blend.release", &json!({})).unwrap(); let grp = NodeId(r["groups"][0].as_u64().unwrap()); let n = node(&s, grp); assert_eq!((n.name.as_deref(), n.opacity, n.isolate), (Some("Fade"), before.opacity, true)); - assert_eq!(n.mask, before.mask); + assert_eq!(n.extras().mask, before.extras().mask); assert_eq!(n.children().unwrap().len(), 3, "spine and keys inside"); } diff --git a/crates/engine/src/tests_blendopts.rs b/crates/engine/src/tests_blendopts.rs index 7e08912c..ab70333d 100644 --- a/crates/engine/src/tests_blendopts.rs +++ b/crates/engine/src/tests_blendopts.rs @@ -28,7 +28,7 @@ fn node(s: &Session, id: NodeId) -> Node { fn spec(s: &Session, id: NodeId) -> (Vec, BlendSpec) { match node(s, id).kind { - NodeKind::Blend { children, spec } => (children.iter().map(|c| c.id).collect(), spec), + NodeKind::Blend { children, spec } => (children.iter().map(|c| c.id).collect(), *spec), _ => panic!("not a blend"), } } diff --git a/crates/engine/src/tests_blendspine.rs b/crates/engine/src/tests_blendspine.rs index ab9fc831..f2a42f7e 100644 --- a/crates/engine/src/tests_blendspine.rs +++ b/crates/engine/src/tests_blendspine.rs @@ -26,7 +26,7 @@ fn node(s: &Session, id: NodeId) -> Node { fn spec(s: &Session, id: NodeId) -> BlendSpec { match node(s, id).kind { - NodeKind::Blend { spec, .. } => spec, + NodeKind::Blend { spec, .. } => *spec, _ => panic!("not a blend"), } } diff --git a/crates/engine/src/tests_build.rs b/crates/engine/src/tests_build.rs index a79f0048..8cf3e4e0 100644 --- a/crates/engine/src/tests_build.rs +++ b/crates/engine/src/tests_build.rs @@ -592,7 +592,10 @@ fn add_image(s: &mut Session, r: &vectorcraft_trace::Raster, xf: Affine) -> Node d.insert( l, 0, - Node::new(id, NodeKind::Image(ImageObject { key: "img1".into(), width: w, height: h, xf, link: None, placement: Default::default() })), + Node::new( + id, + NodeKind::Image(Box::new(ImageObject { key: "img1".into(), width: w, height: h, xf, link: None, placement: Default::default() })), + ), )?; sel.set([id]); Ok(id) @@ -687,7 +690,7 @@ fn image_trace_palettes_and_swatch_libraries() { let doc = crate::cmd::swatchlib::library_colors(&s, "document").unwrap(); let got = fills(&s, &r["id"]); assert!(!got.is_empty() && got.iter().all(|c| doc.contains(c)), "{got:?}"); - let t = node(&s, NodeId(r["id"].as_u64().unwrap())).trace.unwrap(); + let t = node(&s, NodeId(r["id"].as_u64().unwrap())).extras().trace.clone().unwrap(); assert_eq!((t["params"]["palette"].as_str(), t["params"]["library"].as_str()), (Some("documentLibrary"), Some("document"))); assert!(t["params"].get("swatches").is_none(), "looked up again at every trace"); // A swatch library by id: the web-safe colours. @@ -733,7 +736,7 @@ fn image_trace_create_strokes_makes_stroked_centre_lines() { assert!(!sp.closed); let (a, b) = (sp.anchors[0].p, sp.anchors[sp.anchors.len() - 1].p); assert!((a.y - 201.0).abs() < 1.5 && (b.y - 201.0).abs() < 1.5 && (a.x - b.x).abs() > 140.0, "{a:?} … {b:?}"); - let t = node(&s, g).trace.unwrap(); + let t = node(&s, g).extras().trace.clone().unwrap(); assert_eq!((t["params"]["strokes"].as_bool(), t["params"]["strokeWidth"].as_f64()), (Some(true), Some(5.0))); // Expand keeps the stroked line. s.execute("imageTrace.expand", &json!({})).unwrap(); @@ -777,7 +780,7 @@ fn image_trace_flat_logo_preset_makes_a_true_circle() { assert!(r["anchors"].as_u64().unwrap() <= 6, "a circle is four anchors: {r}"); let g = NodeId(r["id"].as_u64().unwrap()); let n = node(&s, g); - let t = n.trace.clone().expect("settings stored"); + let t = n.extras().trace.clone().expect("settings stored"); assert_eq!((t["preset"].as_str(), t["params"]["mode"].as_str()), (Some("Flat Logo"), Some("logo"))); let paths = traced_paths(&n); let (p, rule) = outline(&paths[0]); @@ -811,7 +814,7 @@ fn image_trace_object_remembers_its_settings() { image_doc(&mut s); let r = s.execute("imageTrace.make", &json!({"preset": "6 Colors"})).unwrap(); let g = NodeId(r["id"].as_u64().unwrap()); - let t = node(&s, g).trace.clone().expect("settings stored"); + let t = node(&s, g).extras().trace.clone().expect("settings stored"); assert_eq!(t["preset"], "6 Colors"); assert_eq!(t["params"]["colors"], 6); // Changing a parameter makes it Custom; the settings survive save/open. @@ -819,13 +822,13 @@ fn image_trace_object_remembers_its_settings() { let g = NodeId(r["id"].as_u64().unwrap()); let d = s.doc().unwrap().doc.clone(); let back = vectorcraft_format::load(&vectorcraft_format::save(&d, false)).unwrap(); - let t = back.node(g).unwrap().trace.clone().unwrap(); + let t = back.node(g).unwrap().extras().trace.clone().unwrap(); assert_eq!((t["preset"].as_str(), t["params"]["colors"].as_u64()), (Some("Custom"), Some(9))); // Expanded traces are plain groups. s.execute("imageTrace.expand", &json!({})).unwrap(); let st = s.doc().unwrap(); let id = st.selection.in_paint_order(&st.doc)[0]; - assert!(st.doc.node(id).unwrap().trace.is_none()); + assert!(st.doc.node(id).unwrap().extras().trace.is_none()); } /// A black disc (r = 30 px) on light blue, 100 × 100 px, placed at 2× from (50, 50): traced in @@ -852,7 +855,7 @@ fn render_doc(s: &Session, trace_views: bool) -> vectorcraft_render::Rendered { fn image_trace_view_is_stored_kept_and_validated() { let mut s = session(); let g = disc_on_blue(&mut s); - assert_eq!(node(&s, g).trace.unwrap()["view"], "tracingResult"); + assert_eq!(node(&s, g).extras().trace.clone().unwrap()["view"], "tracingResult"); let r = s.execute("imageTrace.setView", &json!({"view": "outlinesWithSourceImage"})).unwrap(); assert_eq!((ids(&r), r["view"].as_str()), (vec![g], Some("outlinesWithSourceImage"))); assert_eq!(node(&s, g).trace_view(), vectorcraft_doc::TraceView::OutlinesWithSource); diff --git a/crates/engine/src/tests_clipboard.rs b/crates/engine/src/tests_clipboard.rs index 1a1d9cfc..48c8912b 100644 --- a/crates/engine/src/tests_clipboard.rs +++ b/crates/engine/src/tests_clipboard.rs @@ -294,7 +294,7 @@ fn graphic_style_links_follow_the_style_into_the_document() { s.execute("paint.setFill", &json!({"ids": [a.0], "color": "#ffcc00"})).unwrap(); sel(&mut s, &[a]); s.execute("graphicStyle.new", &json!({"name": "Sun"})).unwrap(); - let src_id = node(&s, a).graphic_style.unwrap(); + let src_id = node(&s, a).extras().graphic_style.unwrap(); copy(&mut s, &[a]); assert_eq!(s.clipboard.graphic_styles.iter().map(|g| g.name.as_str()).collect::>(), ["Sun"]); @@ -307,17 +307,17 @@ fn graphic_style_links_follow_the_style_into_the_document() { let v = s.execute("edit.paste", &json!({})).unwrap(); assert_eq!(renamed(&v), [("graphicStyle".to_string(), "Sun".to_string(), "Sun 2".to_string())]); let d = doc(&s); - let linked_to = d.graphic_style_by_id(node(&s, ids_of(&v)[0]).graphic_style.unwrap()).unwrap().clone(); + let linked_to = d.graphic_style_by_id(node(&s, ids_of(&v)[0]).extras().graphic_style.unwrap()).unwrap().clone(); assert_eq!(linked_to.name, "Sun 2"); assert_eq!(linked_to.appearance.fill_paint(), Paint::solid(hex("#ffcc00"))); assert!(d.graphic_styles.iter().filter(|g| g.id == linked_to.id).count() == 1, "a fresh id"); // Pasting again finds the same style. let v = s.execute("edit.paste", &json!({})).unwrap(); - assert_eq!((v["added"].as_u64(), node(&s, ids_of(&v)[0]).graphic_style), (Some(0), Some(linked_to.id))); + assert_eq!((v["added"].as_u64(), node(&s, ids_of(&v)[0]).extras().graphic_style), (Some(0), Some(linked_to.id))); // Back in the source document the link is kept as it was. s.execute("document.activate", &json!({"index": 0})).unwrap(); let v = s.execute("edit.paste", &json!({})).unwrap(); - assert_eq!(node(&s, ids_of(&v)[0]).graphic_style, Some(src_id)); + assert_eq!(node(&s, ids_of(&v)[0]).extras().graphic_style, Some(src_id)); } #[test] diff --git a/crates/engine/src/tests_editcolors.rs b/crates/engine/src/tests_editcolors.rs index 93ef8faa..0dd51c98 100644 --- a/crates/engine/src/tests_editcolors.rs +++ b/crates/engine/src/tests_editcolors.rs @@ -179,7 +179,7 @@ fn image_node(s: &mut Session) -> NodeId { link: None, placement: Default::default(), }; - d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(im)))?; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; sel.set([id]); Ok(id) }) diff --git a/crates/engine/src/tests_envelope.rs b/crates/engine/src/tests_envelope.rs index f1609d89..ea3ce828 100644 --- a/crates/engine/src/tests_envelope.rs +++ b/crates/engine/src/tests_envelope.rs @@ -40,7 +40,7 @@ fn enveloped(s: &mut Session, cmd: &str, params: Value) -> (NodeId, NodeId) { fn kind(s: &Session, id: NodeId) -> EnvelopeKind { match node(s, id).kind { - NodeKind::Envelope { kind, .. } => kind, + NodeKind::Envelope { kind, .. } => *kind, k => panic!("{k:?}"), } } @@ -130,7 +130,7 @@ fn release_gives_back_the_warp_shape_as_a_mesh_and_keeps_the_transparency() { s.execute("transparency.set", &json!({"opacity": 40, "blend": "multiply"})).unwrap(); let mask = Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Default::default()); s.edit("mask", |d, _| { - d.node_mut(e).unwrap().mask = Some(Box::new(OpacityMask::new(mask, true))); + d.node_mut(e).unwrap().extras_mut().mask = Some(Box::new(OpacityMask::new(mask, true))); Ok(()) }) .unwrap(); @@ -139,7 +139,7 @@ fn release_gives_back_the_warp_shape_as_a_mesh_and_keeps_the_transparency() { assert_eq!(ids.len(), 2); assert_eq!(ids[0], a); let content = node(&s, a); - assert_eq!((content.opacity, content.blend, content.mask.is_some()), (0.4, BlendMode::Multiply, true), "the content keeps the look"); + assert_eq!((content.opacity, content.blend, content.extras().mask.is_some()), (0.4, BlendMode::Multiply, true), "the content keeps the look"); let NodeKind::Mesh(m) = node(&s, ids[1]).kind else { panic!("the warp's shape comes back as a mesh") }; // The mesh follows the arc: its outline reaches below the content's bottom edge at the sides. let b = m.outline().bounds().unwrap(); @@ -172,14 +172,14 @@ fn expand_keeps_name_transparency_knockout_and_mask() { s.edit("name and mask", |d, _| { let n = d.node_mut(e).unwrap(); n.name = Some("Banner".into()); - n.mask = Some(Box::new(OpacityMask::new(mask, true))); + n.extras_mut().mask = Some(Box::new(OpacityMask::new(mask, true))); Ok(()) }) .unwrap(); s.execute("object.envelope.expand", &json!({})).unwrap(); let g = node(&s, e); assert!(matches!(g.kind, NodeKind::Group { .. })); - assert_eq!((g.name.as_deref(), g.opacity, g.isolate, g.knockout, g.mask.is_some()), (Some("Banner"), 0.7, true, Knockout::On, true)); + assert_eq!((g.name.as_deref(), g.opacity, g.isolate, g.knockout, g.extras().mask.is_some()), (Some("Banner"), 0.7, true, Knockout::On, true)); } #[test] diff --git a/crates/engine/src/tests_envelope_distort.rs b/crates/engine/src/tests_envelope_distort.rs index 300a6ec5..b02fdb12 100644 --- a/crates/engine/src/tests_envelope_distort.rs +++ b/crates/engine/src/tests_envelope_distort.rs @@ -63,7 +63,7 @@ fn anchors(nodes: &[Node]) -> Vec { fn frame(s: &Session, e: NodeId) -> Affine { match node(s, e).kind { - NodeKind::Envelope { frame, .. } => frame, + NodeKind::Envelope { frame, .. } => *frame, k => panic!("{k:?}"), } } @@ -110,7 +110,7 @@ fn the_frame_round_trips_natively() { let mut old = serde_json::to_value(node(&s, e)).unwrap(); old["kind"].as_object_mut().unwrap().remove("frame"); let n: Node = serde_json::from_value(old).unwrap(); - assert!(matches!(n.kind, NodeKind::Envelope { frame, .. } if frame == Affine::IDENTITY), "older envelopes stand square to the page"); + assert!(matches!(n.kind, NodeKind::Envelope { frame, .. } if frame == Box::new(Affine::IDENTITY)), "older envelopes stand square to the page"); } /// A selected embedded 40×40 pt red image at (100, 100). @@ -123,7 +123,7 @@ fn red_image(s: &mut Session) -> NodeId { let id = d.alloc_id(); let xf = Affine::translate((100.0, 100.0)) * Affine::scale(10.0); let im = vectorcraft_doc::ImageObject { key: "pic".into(), width: 4, height: 4, xf, link: None, placement: Default::default() }; - d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(im)))?; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; sel.set([id]); Ok(id) }) diff --git a/crates/engine/src/tests_envelope_edit.rs b/crates/engine/src/tests_envelope_edit.rs index 1cccb9b8..26e20f43 100644 --- a/crates/engine/src/tests_envelope_edit.rs +++ b/crates/engine/src/tests_envelope_edit.rs @@ -32,7 +32,10 @@ fn enveloped(s: &mut Session, cmd: &str, params: Value) -> (NodeId, NodeId) { fn mesh(s: &Session, e: NodeId) -> (u32, u32, Vec, Vec<[Vec2; 4]>) { match node(s, e).kind { - NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows, cols, points, handles }, .. } => (rows, cols, points, handles), + NodeKind::Envelope { kind, .. } => match *kind { + EnvelopeKind::Mesh { rows, cols, points, handles } => (rows, cols, points, handles), + k => panic!("{k:?}"), + }, k => panic!("{k:?}"), } } @@ -120,7 +123,7 @@ fn mesh_handles_turn_with_the_envelope_and_round_trip() { let mut old = serde_json::to_value(node(&s, e)).unwrap(); old["kind"]["kind"].as_object_mut().unwrap().remove("handles"); let n: Node = serde_json::from_value(old).unwrap(); - assert!(matches!(n.kind, NodeKind::Envelope { kind: EnvelopeKind::Mesh { handles, .. }, .. } if handles.is_empty())); + assert!(matches!(n.kind, NodeKind::Envelope { kind, .. } if matches!(&*kind, EnvelopeKind::Mesh { handles, .. } if handles.is_empty()))); } #[test] diff --git a/crates/engine/src/tests_eyedropper.rs b/crates/engine/src/tests_eyedropper.rs index ef5d7d8b..9b30500a 100644 --- a/crates/engine/src/tests_eyedropper.rs +++ b/crates/engine/src/tests_eyedropper.rs @@ -161,7 +161,7 @@ fn clicking_an_image_samples_its_pixel() { let id = d.alloc_id(); let xf = vectorcraft_geom::Affine::translate((300.0, 0.0)) * vectorcraft_geom::Affine::scale(100.0); let im = ImageObject { key: "px".into(), width: 2, height: 1, xf, link: None, placement: Default::default() }; - d.insert(Some(l), 0, Node::new(id, NodeKind::Image(im))).map_err(|e| crate::EngineError::Other(e.to_string())) + d.insert(Some(l), 0, Node::new(id, NodeKind::Image(Box::new(im)))).map_err(|e| crate::EngineError::Other(e.to_string())) }) .unwrap(); let dst = rect(&mut s, 10.0); diff --git a/crates/engine/src/tests_flatten.rs b/crates/engine/src/tests_flatten.rs index a014b1db..b6d617ad 100644 --- a/crates/engine/src/tests_flatten.rs +++ b/crates/engine/src/tests_flatten.rs @@ -451,13 +451,13 @@ fn opacity_masks_are_rasterized_with_their_look() { json!({"ids": [m], "gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]}}), ); run(&mut s, "transparency.makeOpacityMask", json!({"ids": [a, m]})); - assert!(node(&s, a).mask.is_some()); + assert!(node(&s, a).extras().mask.is_some()); let before = picture(&s); run(&mut s, "select.all", json!({})); let (ids, _, rasterized) = flatten(&mut s, json!({"lineArtPpi": 144})); assert_eq!((ids.len(), rasterized), (1, 1)); let mut masks = 0; - node(&s, ids[0]).walk(&mut |c| masks += c.mask.is_some() as usize); + node(&s, ids[0]).walk(&mut |c| masks += c.extras().mask.is_some() as usize); assert_eq!(masks, 0, "no transparency is left"); assert!(changed(&before, &picture(&s)) < 0.02); } diff --git a/crates/engine/src/tests_history_mem.rs b/crates/engine/src/tests_history_mem.rs index dd190700..0ac0fab5 100644 --- a/crates/engine/src/tests_history_mem.rs +++ b/crates/engine/src/tests_history_mem.rs @@ -124,3 +124,32 @@ fn steps_share_an_unchanged_selection() { assert!(Arc::ptr_eq(&st.history.redo[0].selection, &st.history.redo[1].selection)); assert_eq!(st.selection.objects.len(), 100); } + +/// Undo and redo don't estimate the steps again: the step an undo (redo) makes takes the undone +/// step's figures the other way round, which are what a fresh estimate gives. +#[test] +fn undo_and_redo_reuse_the_estimates() { + let mut s = session_with_rects(200); + s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); + // A deletion is lopsided: the step before it keeps the objects, the one after keeps nothing. + s.execute("edit.clear", &json!({})).unwrap(); + let fresh = |e: &HistoryEntry, now: &Document| e.doc.retained_bytes(now); + let st = s.doc().unwrap(); + let delete = st.history.undo.last().unwrap(); + assert!(delete.bytes > 200 * 300, "the deleted objects: {}", delete.bytes); + s.execute("edit.undo", &json!({})).unwrap(); + let st = s.doc().unwrap(); + let redo = st.history.redo.last().unwrap(); + assert_eq!(redo.bytes, fresh(redo, &st.doc)); + assert!(redo.bytes < 4096, "the document without the objects keeps little of its own: {}", redo.bytes); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.redo", &json!({})).unwrap(); + let st = s.doc().unwrap(); + let undo = st.history.undo.last().unwrap(); + assert_eq!(undo.label, "Move"); + assert_eq!(undo.bytes, fresh(undo, &st.doc)); + s.execute("edit.redo", &json!({})).unwrap(); + let st = s.doc().unwrap(); + let delete = st.history.undo.last().unwrap(); + assert_eq!(delete.bytes, fresh(delete, &st.doc)); +} diff --git a/crates/engine/src/tests_links.rs b/crates/engine/src/tests_links.rs index 9734a319..a1ca2592 100644 --- a/crates/engine/src/tests_links.rs +++ b/crates/engine/src/tests_links.rs @@ -64,7 +64,7 @@ pub(crate) fn place(s: &mut Session, path: &str) -> NodeId { pub(crate) fn image(s: &Session, id: NodeId) -> ImageObject { match &s.doc().unwrap().doc.node(id).unwrap().kind { - NodeKind::Image(im) => im.clone(), + NodeKind::Image(im) => (**im).clone(), k => panic!("not an image: {k:?}"), } } @@ -323,7 +323,7 @@ fn relative_paths_are_written_against_the_saved_file() { link: Some(LinkInfo::new(format!("{root}{sep}art{sep}a.png"))), placement: Default::default(), }; - d.insert(Some(lid), 0, vectorcraft_doc::Node::new(nid, NodeKind::Image(im))).unwrap(); + d.insert(Some(lid), 0, vectorcraft_doc::Node::new(nid, NodeKind::Image(Box::new(im)))).unwrap(); let rel = |dest: String| { let d = cmd::links::with_relative_paths(&d, &dest).unwrap_or_else(|| d.clone()); match &d.node(nid).unwrap().kind { diff --git a/crates/engine/src/tests_live.rs b/crates/engine/src/tests_live.rs index a26495d2..54e523a4 100644 --- a/crates/engine/src/tests_live.rs +++ b/crates/engine/src/tests_live.rs @@ -413,7 +413,8 @@ fn envelope_options_and_edit_contents() { let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 50})).unwrap()); s.execute("object.envelope.options", &json!({"fidelity": 90, "bend": -30})).unwrap(); match &node(&s, e).kind { - NodeKind::Envelope { fidelity, kind: EnvelopeKind::Warp { bend, style, .. }, .. } => { + NodeKind::Envelope { fidelity, kind, .. } => { + let EnvelopeKind::Warp { bend, style, .. } = &**kind else { panic!("{kind:?}") }; assert!(close(*fidelity, 90.0) && close(*bend, -30.0) && style == "arc"); } k => panic!("{k:?}"), diff --git a/crates/engine/src/tests_livecorners.rs b/crates/engine/src/tests_livecorners.rs index b7ed7404..96cdf0b1 100644 --- a/crates/engine/src/tests_livecorners.rs +++ b/crates/engine/src/tests_livecorners.rs @@ -25,7 +25,8 @@ fn run(s: &mut Session, p: Value) { fn corners(s: &Session, id: NodeId) -> ([f64; 4], [CornerKind; 4], usize) { let n = s.doc().unwrap().doc.node(id).unwrap(); - let NodeKind::Path { live: Some(LiveShape::Rectangle { radii, kinds, .. }), path, .. } = &n.kind else { panic!("not a live rectangle") }; + let NodeKind::Path { live: Some(l), path, .. } = &n.kind else { panic!("not a live shape") }; + let LiveShape::Rectangle { radii, kinds, .. } = &**l else { panic!("not a live rectangle") }; (*radii, *kinds, path.anchor_count()) } @@ -157,7 +158,7 @@ fn corners_of_a_non_square_rectangle_are_alike_circles() { let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); let NodeKind::Path { path, live: Some(live), .. } = &mut d.node_mut(id).unwrap().kind else { panic!("not live") }; let xf = Affine::translate((10.0, 10.0)) * Affine::scale_non_uniform(2.0, 1.0); - *live = LiveShape::Rectangle { w: 50.0, h: 60.0, radii: [10.0; 4], kinds: Default::default(), xf }; + **live = LiveShape::Rectangle { w: 50.0, h: 60.0, radii: [10.0; 4], kinds: Default::default(), xf }; *path = live.to_path(); } assert_eq!(spans(&s, id)[0], (20.0, 10.0)); @@ -178,7 +179,7 @@ fn star(s: &mut Session) -> NodeId { fn node_path(s: &Session, id: NodeId) -> (vectorcraft_geom::PathData, Option) { let NodeKind::Path { path, live, .. } = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("not a path") }; - (path.clone(), live.clone()) + (path.clone(), live.as_deref().cloned()) } /// The radius and kind every corner of `id` shares, and how many corners it has. @@ -307,7 +308,10 @@ fn an_ellipse_takes_pie_angles_and_inverts() { s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap(); let id = NodeId(s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap()); let pie = |s: &Session| match &s.doc().unwrap().doc.node(id).unwrap().kind { - NodeKind::Path { live: Some(LiveShape::Ellipse { pie, .. }), path, .. } => (*pie, path.bounds().unwrap()), + NodeKind::Path { live: Some(l), path, .. } => match &**l { + LiveShape::Ellipse { pie, .. } => (*pie, path.bounds().unwrap()), + k => panic!("{k:?}"), + }, k => panic!("{k:?}"), }; s.execute("object.setLiveShape", &json!({"pieStart": 0, "pieEnd": 90})).unwrap(); @@ -335,7 +339,7 @@ fn a_polygon_takes_its_angle_radius_side_length_and_equal_sides() { s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); let id = NodeId(s.execute("shape.polygon", &json!({"cx": 200, "cy": 200, "radius": 50, "sides": 6})).unwrap()["id"].as_u64().unwrap()); let live = |s: &Session| match &s.doc().unwrap().doc.node(id).unwrap().kind { - NodeKind::Path { live: Some(l @ LiveShape::Polygon { .. }), path, .. } => (l.clone(), path.bounds().unwrap()), + NodeKind::Path { live: Some(l), path, .. } if matches!(**l, LiveShape::Polygon { .. }) => ((**l).clone(), path.bounds().unwrap()), k => panic!("{k:?}"), }; let close = |a: f64, b: f64| (a - b).abs() < 1e-6; diff --git a/crates/engine/src/tests_menucmds.rs b/crates/engine/src/tests_menucmds.rs index 5883d220..3a23dda1 100644 --- a/crates/engine/src/tests_menucmds.rs +++ b/crates/engine/src/tests_menucmds.rs @@ -426,7 +426,8 @@ fn convert_rectangle_path_to_live_shape() { ); sel(&mut s, &[p]); assert_eq!(s.execute("object.shape.convertToShape", &json!({})).unwrap()["converted"], 1); - let NodeKind::Path { live: Some(LiveShape::Rectangle { w, h, .. }), path, .. } = &node(&s, p).kind else { panic!() }; + let n = node(&s, p); + let (Some(LiveShape::Rectangle { w, h, .. }), NodeKind::Path { path, .. }) = (n.live_shape(), &n.kind) else { panic!() }; assert!(close(*w, 40.0) && close(*h, 20.0)); assert_eq!(path.bounds(), Some(Rect::new(0.0, 0.0, 40.0, 20.0))); } @@ -448,7 +449,7 @@ fn convert_rotated_rectangle_and_ellipse() { assert_eq!(s.execute("object.shape.convertToShape", &json!({})).unwrap()["converted"], 2); let b = bounds(&s, p); assert!(close(b.x0, 0.0) && close(b.x1, 100.0) && close(b.y1, 100.0), "{b:?}"); - assert!(matches!(node(&s, e).kind, NodeKind::Path { live: Some(LiveShape::Ellipse { .. }), .. })); + assert!(matches!(node(&s, e).live_shape(), Some(LiveShape::Ellipse { .. }))); } #[test] diff --git a/crates/engine/src/tests_opacitymask.rs b/crates/engine/src/tests_opacitymask.rs index d18e033f..a2fe33ae 100644 --- a/crates/engine/src/tests_opacitymask.rs +++ b/crates/engine/src/tests_opacitymask.rs @@ -35,7 +35,7 @@ fn doc(s: &Session) -> &Document { } fn mask(s: &Session, id: NodeId) -> Option<&vectorcraft_doc::OpacityMask> { - doc(s).node(id).and_then(|n| n.mask.as_deref()) + doc(s).node(id).and_then(|n| n.extras().mask.as_deref()) } fn has_edit_layer(d: &Document) -> bool { @@ -272,7 +272,7 @@ fn saving_while_editing_writes_no_editing_layer() { assert!(back.mask_edit.is_none() && !has_edit_layer(&back)); assert_eq!(back.layers.len(), 1); // The edit made while editing is in the saved mask. - assert_eq!(back.node(obj).unwrap().mask.as_ref().unwrap().art.geometric_bounds().unwrap().x0, 50.0); + assert_eq!(back.node(obj).unwrap().extras().mask.as_ref().unwrap().art.geometric_bounds().unwrap().x0, 50.0); // Still editing in the session. assert!(doc(&s).mask_edit.is_some() && has_edit_layer(doc(&s))); // A file saved mid-edit by an older version (editing record and layer written) opens clean. diff --git a/crates/engine/src/tests_pdfraster.rs b/crates/engine/src/tests_pdfraster.rs index 2eb3787a..a3de3deb 100644 --- a/crates/engine/src/tests_pdfraster.rs +++ b/crates/engine/src/tests_pdfraster.rs @@ -103,7 +103,7 @@ fn groups_layers_type_images_and_symbols_keep_their_raster_effects() { let xf = Affine::translate((40.0, 50.0)) * Affine::scale(2.5); let im = Node::new( d.alloc_id(), - NodeKind::Image(ImageObject { key: "pic".into(), width: 40, height: 30, xf, link: None, placement: Default::default() }), + NodeKind::Image(Box::new(ImageObject { key: "pic".into(), width: 40, height: 30, xf, link: None, placement: Default::default() })), ); add(&mut d, with(im, shadow())); assert_written("image", &d); diff --git a/crates/engine/src/tests_persp_text.rs b/crates/engine/src/tests_persp_text.rs index 96fe97c2..597c2ba8 100644 --- a/crates/engine/src/tests_persp_text.rs +++ b/crates/engine/src/tests_persp_text.rs @@ -117,7 +117,7 @@ fn edit_text_shows_it_flat_in_isolation_then_projects_it_again() { assert!(n.projection().is_some(), "projected again"); // Nothing left flat anywhere in the history. for e in &s.doc().unwrap().history.undo { - if let Some(p) = e.doc.node(id).and_then(|n| n.perspective.as_deref()) { + if let Some(p) = e.doc.node(id).and_then(|n| n.extras().perspective.as_deref()) { assert!(!p.editing); } } diff --git a/crates/engine/src/tests_recovery.rs b/crates/engine/src/tests_recovery.rs index 80922774..cf46b91d 100644 --- a/crates/engine/src/tests_recovery.rs +++ b/crates/engine/src/tests_recovery.rs @@ -458,3 +458,119 @@ fn without_a_store_the_commands_say_where_to_set_one() { assert!(!area.join("Untitled-1-1.vectorcraft").exists()); let _ = std::fs::remove_dir_all(&dir); } + +/// A store in memory that notes the entries written to it. +#[derive(Default)] +struct Counting { + inner: MemoryStore, + written: std::sync::Mutex>, +} + +impl Counting { + /// The image entries written since the last call, with their sizes. + fn images_written(&self) -> Vec<(String, usize)> { + std::mem::take(&mut *self.written.lock().unwrap()).into_iter().filter(|(n, _)| n.ends_with(".blob")).collect() + } + + fn images(&self) -> Vec { + self.inner.list().unwrap().into_iter().filter(|n| n.ends_with(".blob")).collect() + } +} + +impl RecoveryStore for Counting { + fn list(&self) -> std::result::Result, String> { + self.inner.list() + } + fn read(&self, name: &str) -> std::result::Result, String> { + self.inner.read(name) + } + fn write(&self, name: &str, bytes: &[u8]) -> std::result::Result<(), String> { + self.written.lock().unwrap().push((name.to_string(), bytes.len())); + self.inner.write(name, bytes) + } + fn remove(&self, name: &str) -> std::result::Result<(), String> { + self.inner.remove(name) + } + fn location(&self) -> String { + self.inner.location() + } + fn lock(&self, area: &str) -> std::result::Result { + self.inner.lock(area) + } + fn now(&self) -> Option { + self.inner.now() + } +} + +fn place_png(s: &mut Session, name: &str, rgb: [u8; 3]) -> Vec { + let png = crate::tests_links::png(64, 48, rgb); + s.execute("file.place", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&png), "link": false})).unwrap(); + png +} + +/// The document's images: their bytes by key. +fn images_of(s: &Session) -> std::collections::BTreeMap> { + s.doc().unwrap().doc.images.iter().map(|(k, b)| (k.clone(), b.bytes.to_vec())).collect() +} + +#[test] +fn images_are_kept_apart_and_written_again_only_when_they_change() { + let store = Arc::new(Counting::default()); + let mut s = Session::new(); + s.recovery.set_store(store.clone()); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + let red = place_png(&mut s, "red.png", crate::tests_links::RED); + let r = s.execute("file.recovery.save", &json!({})).unwrap(); + assert_eq!(r["saved"][0]["imageBytes"], red.len(), "{r}"); + assert_eq!(store.images_written().len(), 1); + let area = s.recovery.own_area().unwrap().to_string(); + // The document entry doesn't hold the image. + let doc = store.read(&format!("{area}/Untitled-1-1.vectorcraft")).unwrap(); + assert!(vectorcraft_format::load(&doc).unwrap().images.is_empty()); + // An edit that leaves the image alone: the copy is written again, its image isn't. + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap(); + let r = s.execute("file.recovery.save", &json!({})).unwrap(); + assert_eq!((r["saved"].as_array().unwrap().len(), r["saved"][0]["imageBytes"].as_u64()), (1, Some(0)), "{r}"); + assert!(store.images_written().is_empty(), "the image isn't written again"); + // A new image: only it is written. + let blue = place_png(&mut s, "blue.png", crate::tests_links::BLUE); + let r = s.execute("file.recovery.save", &json!({})).unwrap(); + assert_eq!(r["saved"][0]["imageBytes"], blue.len()); + assert_eq!(store.images_written().iter().map(|(_, n)| *n).collect::>(), [blue.len()]); + assert_eq!(store.images().len(), 2); + let images = images_of(&s); + assert_eq!(images.len(), 2); + + // A crash: the copy restores with its images. + drop(s); + let mut s = Session::new(); + s.recovery.set_store(store.clone()); + assert_eq!(restored(s.execute("file.recovery.restore", &json!({})).unwrap()), 1); + assert_eq!(images_of(&s), images); + let mine = s.recovery.own_area().unwrap().to_string(); + assert!(store.images().iter().all(|n| n.starts_with(&format!("{mine}/"))), "the images moved with the copy, the old area went"); + assert_eq!(store.images().len(), 2); + // Its copy and another document's: once the first is closed, the next copy sweeps its images. + s.execute("file.new", &json!({"width": 50, "height": 50})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 1, "y": 1, "width": 5, "height": 5})).unwrap(); + s.execute("file.close", &json!({"index": 0})).unwrap(); + s.execute("file.recovery.save", &json!({})).unwrap(); + assert!(store.images().is_empty(), "{:?}", store.list()); +} + +#[test] +fn copies_from_before_images_were_kept_apart_still_restore() { + let store = Arc::new(MemoryStore::default()); + // The document entry is a whole native file, images inside; the details say nothing of them. + let mut s = session_in(&store); + place_png(&mut s, "red.png", crate::tests_links::RED); + let d = vectorcraft_doc::Document::clone(&s.doc().unwrap().doc); + let images = images_of(&s); + drop(s); + store.write("1-1/Old-1.vectorcraft", &vectorcraft_format::save_file(&d)).unwrap(); + store.write("1-1/Old-1.json", br#"{"title": "Old", "format": "vectorcraft", "saved": 5}"#).unwrap(); + let mut s = relaunch(&store); + assert_eq!(restored(s.execute("file.recovery.restore", &json!({})).unwrap()), 1); + assert_eq!(images_of(&s), images); + assert_eq!(s.doc().unwrap().title(), "Untitled-1 [Recovered]"); +} diff --git a/crates/engine/src/tests_scalestrokes.rs b/crates/engine/src/tests_scalestrokes.rs index 4f981444..36163600 100644 --- a/crates/engine/src/tests_scalestrokes.rs +++ b/crates/engine/src/tests_scalestrokes.rs @@ -138,8 +138,8 @@ fn group_appearance_scales_with_its_members() { #[test] fn scale_corners_keeps_or_scales_live_corner_radii() { - let radius = |s: &Session, id| match &node(s, id).kind { - NodeKind::Path { live: Some(LiveShape::Rectangle { radii, xf, .. }), .. } => radii[0] * xf.as_coeffs()[0], + let radius = |s: &Session, id| match node(s, id).live_shape() { + Some(LiveShape::Rectangle { radii, xf, .. }) => radii[0] * xf.as_coeffs()[0], _ => panic!("not a live rectangle"), }; let mut s = session(); @@ -175,7 +175,7 @@ fn live_corners_stay_circular_when_a_rectangle_is_scaled_unevenly() { let n = node(&s, id); assert!((n.geometric_bounds().unwrap().width() - 300.0).abs() < 1e-9); assert!(round_corners(&n, 10.0), "{:?}", corner_spans(&n)); - let NodeKind::Path { live: Some(LiveShape::Rectangle { w, h, radii, .. }), .. } = &n.kind else { panic!("not live") }; + let Some(LiveShape::Rectangle { w, h, radii, .. }) = n.live_shape() else { panic!("not live") }; assert!((w - 300.0).abs() < 1e-9 && (h - 100.0).abs() < 1e-9 && (radii[0] - 10.0).abs() < 1e-9, "the panels read document units"); // Scale Corners on: the radius scales by the mean scale (√(2 × 0.5) = 1 here, 2 below). run(&mut s, "object.scale", json!({"sx": 200, "sy": 50, "corners": true})); diff --git a/crates/engine/src/tests_shaper.rs b/crates/engine/src/tests_shaper.rs index 77e8da0a..0ad4be22 100644 --- a/crates/engine/src/tests_shaper.rs +++ b/crates/engine/src/tests_shaper.rs @@ -1,7 +1,7 @@ use super::*; use serde_json::json; use vectorcraft_doc::shaper::{FILL_PREFIX, SOURCES, STROKE_PREFIX}; -use vectorcraft_doc::{LiveShape, Node, NodeKind}; +use vectorcraft_doc::{LiveShape, Node}; use vectorcraft_geom::{Point, Shape as _}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; @@ -134,7 +134,7 @@ fn shaper_erases_only_protruding_stroke_pieces_keeps_fill_and_original_line() { assert!(stroke(&s, g, 225.0, 150.0)); assert_eq!(fill(&s, g, 150.0, 130.0).as_deref(), Some("#ffff00")); assert_eq!(node(&s, line), &original_line); - assert!(matches!(node(&s, circle).kind, NodeKind::Path { live: Some(LiveShape::Ellipse { .. }), .. })); + assert!(matches!(node(&s, circle).live_shape(), Some(LiveShape::Ellipse { .. }))); let g = scribble(&mut s, &[[210.0, 140.0], [220.0, 160.0], [230.0, 140.0], [240.0, 160.0]]); assert!(!stroke(&s, g, 225.0, 150.0)); assert!(stroke(&s, g, 150.0, 150.0)); @@ -152,7 +152,7 @@ fn shaper_source_move_resize_native_roundtrip_and_release_preserve_live_art() { assert_eq!(node(&s, a).geometric_bounds().unwrap().x0, 20.0); s.execute("select.set", &json!({"ids":[a.0]})).unwrap(); s.execute("object.setBounds", &json!({"width":120.0,"height":110.0,"reference":0})).unwrap(); - assert!(matches!(node(&s, a).kind, NodeKind::Path { live: Some(LiveShape::Rectangle { .. }), .. })); + assert!(matches!(node(&s, a).live_shape(), Some(LiveShape::Rectangle { .. }))); let doc = &s.doc().unwrap().doc; let back = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap(); assert_eq!(back.layers, doc.layers); @@ -175,7 +175,7 @@ fn shaper_face_paint_survives_source_edits_and_expand_discards_only_sources() { s.execute("object.move", &json!({"ids":[a.0],"dx":5,"dy":0})).unwrap(); assert_eq!(fill(&s, g, 75.0, 50.0).as_deref(), Some("#00ff00")); s.execute("shaper.expand", &json!({"id":g.0})).unwrap(); - assert!(node(&s, g).shaper.is_none()); + assert!(node(&s, g).extras().shaper.is_none()); assert!(s.doc().unwrap().doc.node(a).is_none()); assert_eq!(fill(&s, g, 75.0, 50.0).as_deref(), Some("#00ff00")); } @@ -211,7 +211,7 @@ fn shaper_lines_keep_free_angles_with_or_without_shift() { s.pointer(&PointerEvent::new(kind, x, y).with_mods(Mods { shift, ..Default::default() }), v).unwrap(); } let n = node(&s, s.doc().unwrap().selection.objects[0]); - assert!(matches!(n.kind,NodeKind::Path{live:Some(LiveShape::Line{a,b}),..} if a==Point::new(30.0,40.0) && b==Point::new(230.0,100.0))); + assert!(matches!(n.live_shape(), Some(LiveShape::Line { a, b }) if *a == Point::new(30.0, 40.0) && *b == Point::new(230.0, 100.0))); } } @@ -258,7 +258,7 @@ fn shaper_tool_trims_both_diagonal_ends_and_only_construction_can_pick_them() { } } let g = s.doc().unwrap().selection.objects[0]; - assert!(node(&s, g).shaper.is_some()); + assert!(node(&s, g).extras().shaper.is_some()); assert_eq!(node(&s, line), &original); assert!(stroke(&s, g, 150.0, 150.0)); assert_eq!(fill(&s, g, 150.0, 130.0).as_deref(), Some("#ffff00")); diff --git a/crates/engine/src/tests_slices.rs b/crates/engine/src/tests_slices.rs index ab0a4c08..24674a1f 100644 --- a/crates/engine/src/tests_slices.rs +++ b/crates/engine/src/tests_slices.rs @@ -64,10 +64,10 @@ fn an_object_slice_follows_its_object_and_releases_back_to_it() { // Make again changes nothing; Release leaves the object. assert_eq!(run(&mut s, "object.slice.make", json!({}))["ids"], json!([])); assert_eq!(run(&mut s, "object.slice.release", json!({}))["ids"], json!([a])); - assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().slice.is_none()); + assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().extras().slice.is_none()); assert!(slices(&mut s).is_empty()); run(&mut s, "edit.undo", json!({})); - assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().slice.is_some()); + assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().extras().slice.is_some()); } #[test] @@ -155,7 +155,7 @@ fn combine_and_duplicate() { let c = run(&mut s, "object.slice.combine", json!({}))["id"].as_u64().unwrap(); assert_eq!(rect_of(&mut s, c), [0.0, 0.0, 60.0, 50.0]); assert_eq!(user_count(&s), 1); - assert!(s.doc().unwrap().doc.node(NodeId(b)).unwrap().slice.is_none(), "the object slice was released"); + assert!(s.doc().unwrap().doc.node(NodeId(b)).unwrap().extras().slice.is_none(), "the object slice was released"); assert_eq!(s.doc().unwrap().selection.slices, vec![NodeId(c)]); let dup = run(&mut s, "object.slice.duplicate", json!({}))["ids"][0].as_u64().unwrap(); assert_eq!(rect_of(&mut s, dup), [10.0, 10.0, 60.0, 50.0]); @@ -191,7 +191,7 @@ fn slice_options_set_read_and_undo_in_one_step() { run(&mut s, "select.set", json!({"ids": [t]})); let o = run(&mut s, "object.slice.options", json!({"kind": "htmlText"})); assert_eq!((o["kind"].clone(), o["htmlText"].clone()), (json!("htmlText"), json!(true))); - assert_eq!(s.doc().unwrap().doc.node(NodeId(t)).unwrap().slice.as_ref().unwrap().kind, SliceKind::HtmlText); + assert_eq!(s.doc().unwrap().doc.node(NodeId(t)).unwrap().extras().slice.as_ref().unwrap().kind, SliceKind::HtmlText); } #[test] @@ -225,7 +225,7 @@ fn clip_to_artboard_hide_lock_select_and_delete_all() { assert_eq!(run(&mut s, "object.slice.deleteAll", json!({}))["count"], 2); assert_eq!(user_count(&s), 0); - assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().slice.is_none()); + assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().extras().slice.is_none()); assert!(s.execute("object.slice.deleteAll", &json!({})).is_err(), "nothing to delete"); } @@ -243,7 +243,7 @@ fn slices_survive_a_native_round_trip() { vectorcraft_testkit::invariants::check_native_roundtrip_exact(&doc).unwrap(); let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap(); assert_eq!(back.slices, doc.slices); - assert_eq!(back.node(NodeId(a)).unwrap().slice.as_ref().unwrap().alt, "logo"); + assert_eq!(back.node(NodeId(a)).unwrap().extras().slice.as_ref().unwrap().alt, "logo"); assert!(!back.slices_clip_to_artboard); // A copy of an object slice is an object slice too, under its own id. run(&mut s, "select.set", json!({"ids": [a]})); diff --git a/crates/engine/src/tests_stylelib.rs b/crates/engine/src/tests_stylelib.rs index 4ab70936..072668a6 100644 --- a/crates/engine/src/tests_stylelib.rs +++ b/crates/engine/src/tests_stylelib.rs @@ -107,7 +107,7 @@ fn add_with_apply_styles_the_selection_in_the_same_undo_step() { assert!((geom.start - Point::new(10.0 + 0.35 * 100.0, 20.0 + 0.3 * 50.0)).hypot() < 1e-6, "{geom:?}"); run(&mut s, "edit.undo", json!({})); assert!(doc(&s).graphic_style("Spotlight").is_none()); - assert_eq!(doc(&s).node(id).unwrap().graphic_style, None); + assert_eq!(doc(&s).node(id).unwrap().extras().graphic_style, None); // Without a selection it only adds; `ids` targets objects; an existing style is applied as is. run(&mut s, "select.none", json!({})); let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "blends-transparency", "name": "Ghosted", "apply": true})); @@ -224,7 +224,7 @@ fn every_builtin_style_paints_visibly_on_a_rectangle() { run(&mut s, "file.new", json!({"width": 100, "height": 100})); let id = rect(&mut s, 25.0, 30.0, 50.0, 40.0); run(&mut s, "graphicStyle.addFromLibrary", json!({"library": b.id, "name": g.name, "apply": true})); - assert_eq!(doc(&s).node(id).unwrap().graphic_style, doc(&s).graphic_style(&g.name).map(|x| x.id), "{}", g.name); + assert_eq!(doc(&s).node(id).unwrap().extras().graphic_style, doc(&s).graphic_style(&g.name).map(|x| x.id), "{}", g.name); let img = renderer.render_region(doc(&s), Rect::new(0.0, 0.0, 100.0, 100.0), 0.5, true); let painted = img.pixels.as_chunks::<4>().0.iter().filter(|p| p[..3] != [255, 255, 255]).count(); assert!(painted > 40, "{} / {} paints {painted} pixels", b.id, g.name); diff --git a/crates/engine/src/tests_styles.rs b/crates/engine/src/tests_styles.rs index bb1643e3..faa05f3a 100644 --- a/crates/engine/src/tests_styles.rs +++ b/crates/engine/src/tests_styles.rs @@ -68,7 +68,7 @@ fn styles_carry_opacity_and_blend_through_a_save() { let doc = s.doc().unwrap().doc.clone(); let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap(); assert_eq!(back.graphic_styles, doc.graphic_styles); - assert_eq!(back.node(b).unwrap().graphic_style, Some(g.id)); + assert_eq!(back.node(b).unwrap().extras().graphic_style, Some(g.id)); // Styles and objects from files without links still load (and get an id when first linked). let old: GraphicStyle = serde_json::from_value(json!({"name": "Old", "appearance": {"items": []}})).unwrap(); assert_eq!((old.id, old.opacity, old.blend, old.isolate), (0, 1.0, BlendMode::Normal, false)); @@ -106,7 +106,7 @@ fn a_style_from_a_group_or_type_is_not_empty() { run(&mut s, "graphicStyle.apply", json!({"name": "Black Outline", "ids": [g.0]})); assert_eq!(node(&s, g).appearance, style(&s, "Black Outline").appearance); assert_eq!(node(&s, a).appearance, before); - assert_eq!(node(&s, g).graphic_style, Some(style(&s, "Black Outline").id)); + assert_eq!(node(&s, g).extras().graphic_style, Some(style(&s, "Black Outline").id)); assert_eq!(linked(&mut s, "Black Outline"), vec![g.0]); assert_ne!(node(&s, c).appearance, style(&s, "Black Outline").appearance); run(&mut s, "graphicStyle.apply", json!({"name": "Black Outline", "ids": [g.0], "target": "contents"})); @@ -130,7 +130,7 @@ fn alt_apply_adds_the_style_on_top() { assert_eq!(after.items[..before.items.len()], before.items[..]); assert_eq!(after.items.last(), rough.appearance.items.last()); assert_eq!(after.effects.len(), before.effects.len() + 1); - assert_eq!(node(&s, b).graphic_style, None); + assert_eq!(node(&s, b).extras().graphic_style, None); // One undo step. run(&mut s, "edit.undo", json!({})); assert_eq!(node(&s, b).appearance, before); @@ -184,7 +184,7 @@ fn redefine_leaves_unlinked_and_edited_objects_alone() { let mut s = session(); let (a, b, c) = linked_three(&mut s); run(&mut s, "graphicStyle.breakLink", json!({"ids": [c.0]})); - assert_eq!(node(&s, c).graphic_style, None); + assert_eq!(node(&s, c).extras().graphic_style, None); let d = rect(&mut s, 300.0); run(&mut s, "graphicStyle.apply", json!({"name": "S"})); set_fill(&mut s, d, "#00ff00"); @@ -194,7 +194,7 @@ fn redefine_leaves_unlinked_and_edited_objects_alone() { assert_eq!(fill(&s, c), Paint::solid(Color::WHITE)); // The edited object keeps its look and is unlinked. assert_eq!(fill(&s, d), Paint::solid(Color::from_hex("#00ff00").unwrap())); - assert_eq!(node(&s, d).graphic_style, None); + assert_eq!(node(&s, d).extras().graphic_style, None); assert!(s.execute("graphicStyle.redefine", &json!({"id": c.0})).is_err()); } @@ -208,7 +208,7 @@ fn deleting_a_style_unlinks_and_renaming_keeps_links() { assert_eq!(run(&mut s, "select.same.graphicStyle", json!({}))["count"], 3); let look = node(&s, c).appearance; run(&mut s, "graphicStyle.delete", json!({"name": "T"})); - assert_eq!((node(&s, b).graphic_style, node(&s, c).graphic_style), (None, None)); + assert_eq!((node(&s, b).extras().graphic_style, node(&s, c).extras().graphic_style), (None, None)); assert_eq!(node(&s, c).appearance, look); run(&mut s, "select.set", json!({"ids": [b.0]})); assert!(s.execute("select.same.graphicStyle", &json!({})).is_err()); diff --git a/crates/engine/src/tests_targeting.rs b/crates/engine/src/tests_targeting.rs index 73e41f78..82abd73e 100644 --- a/crates/engine/src/tests_targeting.rs +++ b/crates/engine/src/tests_targeting.rs @@ -79,7 +79,7 @@ fn a_targeted_layer_gets_an_opacity_mask() { run(&mut s, "layer.target", json!({"id": layer.0})); assert_eq!(run(&mut s, "transparency.makeOpacityMask", json!({}))["id"], json!(layer.0)); run(&mut s, "transparency.stopEditingOpacityMask", json!({})); - assert!(node(&s, layer).mask.is_some()); + assert!(node(&s, layer).extras().mask.is_some()); assert_eq!(s.doc().unwrap().selection.target, Some(layer), "the masked layer stays targeted"); } diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index f61825a5..072a7497 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -1054,7 +1054,7 @@ impl<'a> Reader<'a> { true }); } - if let Some(m) = &mut n.mask { + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { self.reid(Arc::make_mut(&mut m.art)); } if let Some(children) = n.children_mut() { @@ -1437,7 +1437,7 @@ impl<'a> Reader<'a> { self.doc.images.entry(key.clone()).or_insert(blob); let gs = self.gs.clone(); let im = ImageObject { key, width: w as u32, height: h as u32, xf, link: None, placement: Default::default() }; - let mut node = self.new_node(NodeKind::Image(im), &gs); + let mut node = self.new_node(NodeKind::Image(Box::new(im)), &gs); node.opacity = gs.opacity; node.blend = gs.blend; self.add(node, false) diff --git a/crates/eps/src/import/data.rs b/crates/eps/src/import/data.rs index ec7c11dc..fa05ee13 100644 --- a/crates/eps/src/import/data.rs +++ b/crates/eps/src/import/data.rs @@ -973,7 +973,7 @@ impl Interp<'_> { self.out.doc.images.entry(key.clone()).or_insert(blob); let im = ImageObject { key, width: img.w, height: img.h, xf, link: None, placement: Default::default() }; let clips = self.g.clips.clone(); - self.out.push(Node::new(NodeId(0), NodeKind::Image(im)), &clips); + self.out.push(Node::new(NodeId(0), NodeKind::Image(Box::new(im))), &clips); Ok(()) } } diff --git a/crates/eps/src/import/preview.rs b/crates/eps/src/import/preview.rs index b19acfdb..046043c3 100644 --- a/crates/eps/src/import/preview.rs +++ b/crates/eps/src/import/preview.rs @@ -48,7 +48,8 @@ fn image_doc(img: image::RgbaImage, frame: Rect) -> Option { let key = blob.content_key(); doc.images.insert(key.clone(), blob); let xf = Affine::scale_non_uniform(frame.width() / f64::from(w), frame.height() / f64::from(h)); - let mut n = Node::new(doc.alloc_id(), NodeKind::Image(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() })); + let mut n = + Node::new(doc.alloc_id(), NodeKind::Image(Box::new(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() }))); n.name = Some("Preview".into()); let layer = doc.layers.first().map(|l| l.id)?; doc.insert(Some(layer), 0, n).ok()?; diff --git a/crates/eps/src/import/tests.rs b/crates/eps/src/import/tests.rs index 8408d030..77a02bb2 100644 --- a/crates/eps/src/import/tests.rs +++ b/crates/eps/src/import/tests.rs @@ -472,7 +472,7 @@ fn rich_doc() -> Document { link: None, placement: Default::default(), }; - let n = Node::new(d.alloc_id(), NodeKind::Image(im)); + let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); add(&mut d, n); d.swatches.push(Swatch { name: "Spot".into(), paint: Paint::solid(Color::cmyk(0.0, 0.5, 1.0, 0.0)), global: true, spot: true }); let spot = Paint::Solid { color: Color::cmyk(0.0, 0.5, 1.0, 0.0), swatch: Some("Spot".into()), tint: 1.0 }; diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index 99f4012a..54139c99 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -322,7 +322,7 @@ impl<'a> Scene<'a> { /// Warn about what EPS can't hold in `n`'s look. fn note_losses(&mut self, n: &Node) { - if n.opacity < 1.0 || n.blend != BlendMode::Normal || n.mask.as_ref().is_some_and(|m| !m.disabled) { + if n.opacity < 1.0 || n.blend != BlendMode::Normal || n.extras().mask.as_ref().is_some_and(|m| !m.disabled) { self.warn(TRANSPARENCY); } let raster = |fx: &[Effect]| fx.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)); diff --git a/crates/eps/src/tests.rs b/crates/eps/src/tests.rs index 567cdce9..b1d1cf9b 100644 --- a/crates/eps/src/tests.rs +++ b/crates/eps/src/tests.rs @@ -269,7 +269,7 @@ fn png_doc() -> Document { link: None, placement: Default::default(), }; - let n = Node::new(d.alloc_id(), NodeKind::Image(im)); + let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); let layer = d.layers[0].id; d.insert(Some(layer), 0, n).unwrap(); d diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index b0a7e3f3..bf859b73 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -605,7 +605,7 @@ mod tests { // Only images in use are saved. let img = d.alloc_id(); let im = ImageObject { key: "k".into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default() }; - d.insert(Some(l), 1, Node::new(img, NodeKind::Image(im))).unwrap(); + d.insert(Some(l), 1, Node::new(img, NodeKind::Image(Box::new(im)))).unwrap(); let bytes = save(&d, true); assert!(sniff(&bytes)); let back = load(&bytes).unwrap(); diff --git a/crates/format/tests/links.rs b/crates/format/tests/links.rs index e65685ef..8b8fff3a 100644 --- a/crates/format/tests/links.rs +++ b/crates/format/tests/links.rs @@ -13,7 +13,7 @@ use vectorcraft_geom::Affine; fn image(d: &mut Document, key: &str, link: Option) { let (id, layer) = (d.alloc_id(), d.layers[0].id); let im = ImageObject { key: key.into(), width: 600, height: 300, xf: Affine::IDENTITY, link, placement: Default::default() }; - d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(im))).unwrap(); + d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); } fn blob(full: &[u8], proxy: Option<&[u8]>) -> ImageBlob { diff --git a/crates/format/tests/native_v3.rs b/crates/format/tests/native_v3.rs index 7e643de4..fcbc43d3 100644 --- a/crates/format/tests/native_v3.rs +++ b/crates/format/tests/native_v3.rs @@ -35,7 +35,14 @@ fn image_node(d: &mut Document, key: &str) -> Node { let id = d.alloc_id(); Node::new( id, - NodeKind::Image(ImageObject { key: key.into(), width: 2, height: 2, xf: Affine::IDENTITY, link: None, placement: Default::default() }), + NodeKind::Image(Box::new(ImageObject { + key: key.into(), + width: 2, + height: 2, + xf: Affine::IDENTITY, + link: None, + placement: Default::default(), + })), ) } @@ -120,7 +127,7 @@ fn unused_images_are_pruned() { d.images.insert(k.into(), blob(b)); } let mut n = image_node(&mut d, "used"); - n.mask = Some(Box::new(OpacityMask::new(image_node(&mut d, "in mask"), false))); + n.extras_mut().mask = Some(Box::new(OpacityMask::new(image_node(&mut d, "in mask"), false))); d.insert(Some(layer), 0, n).unwrap(); let art = image_node(&mut d, "in symbol"); d.symbols.push(Symbol { name: "S".into(), art: Arc::new(art) }); diff --git a/crates/format/tests/native_v4.rs b/crates/format/tests/native_v4.rs index 34f6c6a8..57f29c27 100644 --- a/crates/format/tests/native_v4.rs +++ b/crates/format/tests/native_v4.rs @@ -18,7 +18,7 @@ fn doc_with_image(key: &str, len: usize) -> Document { let l = d.layers[0].id; let id = d.alloc_id(); let im = ImageObject { key: key.into(), width: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default() }; - d.insert(Some(l), 0, Node::new(id, NodeKind::Image(im))).unwrap(); + d.insert(Some(l), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); d } diff --git a/crates/format/tests/prop_format.rs b/crates/format/tests/prop_format.rs index 67159d78..365af85f 100644 --- a/crates/format/tests/prop_format.rs +++ b/crates/format/tests/prop_format.rs @@ -76,7 +76,7 @@ proptest! { let layer = d.layers[0].id; let id = d.alloc_id(); let im = ImageObject { key: key.clone(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default() }; - d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(im))).unwrap(); + d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); for pretty in [false, true] { let back = load(&save(&d, pretty)).unwrap(); prop_assert_eq!(back.images.get(&key).map(|b| b.bytes.as_ref().clone()), Some(data.clone())); diff --git a/crates/format/tests/save_options.rs b/crates/format/tests/save_options.rs index 766f4f67..44920d0b 100644 --- a/crates/format/tests/save_options.rs +++ b/crates/format/tests/save_options.rs @@ -29,7 +29,7 @@ fn linked_doc() -> Document { link: Some(LinkInfo::new("/art/photo.png")), placement: Default::default(), }; - d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(im))).unwrap(); + d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); let mut blob = ImageBlob::new("image/png", vec![1, 2, 3, 4, 5, 6]); blob.proxy = Some(Arc::new(vec![9, 9])); d.images.insert("k".into(), blob); diff --git a/crates/metafile/src/import.rs b/crates/metafile/src/import.rs index bee9d0a0..047a59f5 100644 --- a/crates/metafile/src/import.rs +++ b/crates/metafile/src/import.rs @@ -789,7 +789,7 @@ impl Player { return; } let im = ImageObject { key, width: img.width, height: img.height, xf, link: None, placement: Default::default() }; - let mut n = Node::new(vectorcraft_doc::NodeId(0), NodeKind::Image(im)); + let mut n = Node::new(vectorcraft_doc::NodeId(0), NodeKind::Image(Box::new(im))); n.opacity = opacity.clamp(0.0, 1.0) as f32; self.push(n); } diff --git a/crates/metafile/src/scene.rs b/crates/metafile/src/scene.rs index b19545b6..265cf66b 100644 --- a/crates/metafile/src/scene.rs +++ b/crates/metafile/src/scene.rs @@ -236,7 +236,7 @@ impl<'a> Scene<'a> { if n.blend != BlendMode::Normal { self.warn(BLENDING); } - if n.mask.as_ref().is_some_and(|m| !m.disabled) { + if n.extras().mask.as_ref().is_some_and(|m| !m.disabled) { self.warn(MASKS); } let raster = |fx: &[vectorcraft_doc::Effect]| fx.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)); diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index 878906ad..27d14fe1 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -49,7 +49,7 @@ pub(crate) fn image_node(d: &mut Document, bytes: Vec, w: u32, h: u32, xf: A let blob = ImageBlob::new("image/png", bytes); let key = blob.content_key(); d.images.insert(key.clone(), blob); - Node::new(NodeId(0), NodeKind::Image(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() })) + Node::new(NodeId(0), NodeKind::Image(Box::new(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() }))) } pub(crate) fn u32_at(b: &[u8], at: usize) -> u32 { diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index d0ed2471..15a1e213 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -880,7 +880,13 @@ impl Exporter<'_> { /// A luminosity mask that is 1 − the knockout shape of `c`: white, then black through an alpha /// mask of `c` (at full object opacity without its own mask, unless those define its shape). fn knockout_mask(&mut self, s: &mut Surface, c: &Node, page: Rect) -> krilla::mask::Mask { - let shape = if c.knockout_shape { c.clone() } else { Node { opacity: 1.0, mask: None, ..c.clone() } }; + let shape = if c.knockout_shape { + c.clone() + } else { + let mut s = c.clone().with_mask(None); + s.opacity = 1.0; + s + }; let mut sb = s.stream_builder(); let mut ms = sb.surface(); cover(&mut ms, page, 255, 1.0); @@ -948,7 +954,7 @@ impl Exporter<'_> { } pushes += 1; } - if let Some(m) = n.mask.as_deref() + if let Some(m) = n.extras().mask.as_deref() && !m.disabled { let mask = self.soft_mask(s, m, page); diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index d61e06f8..a65a42d6 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -1016,17 +1016,17 @@ impl<'p> Builder<'p> { MaskSpec::Mask(m) => { let m = self.fresh_mask(m.clone()); let node = match <[Arc; 1]>::try_from(art) { - Ok([one]) if one.mask.is_none() => { - fold_stop_opacity(&one, &m).unwrap_or_else(|| Node { mask: Some(Box::new(m)), ..Arc::unwrap_or_clone(one) }) + Ok([one]) if one.extras().mask.is_none() => { + fold_stop_opacity(&one, &m).unwrap_or_else(|| Arc::unwrap_or_clone(one).with_mask(Some(Box::new(m)))) } Ok([one]) => { let mut g = Node::group(self.id(), vec![one]); - g.mask = Some(Box::new(m)); + g.extras_mut().mask = Some(Box::new(m)); g } Err(all) => { let mut g = Node::group(self.id(), all); - g.mask = Some(Box::new(m)); + g.extras_mut().mask = Some(Box::new(m)); g } }; @@ -1089,7 +1089,7 @@ impl<'p> Builder<'p> { && !only.is_container() && !isolate && !knockout - && (mask.is_none() || only.mask.is_none()) + && (mask.is_none() || only.extras().mask.is_none()) { // A single object in a group: fold opacity, blend and mask into the object. let mut only = (**only).clone(); @@ -1099,7 +1099,7 @@ impl<'p> Builder<'p> { } if let Some(m) = mask { let m = self.fresh_mask(m); - only = fold_stop_opacity(&only, &m).unwrap_or(Node { mask: Some(Box::new(m)), ..only }); + only = fold_stop_opacity(&only, &m).unwrap_or_else(|| only.with_mask(Some(Box::new(m)))); } Some(only) } else { @@ -1108,7 +1108,7 @@ impl<'p> Builder<'p> { if knockout { g.knockout = Knockout::On; } - g.mask = mask.map(|m| Box::new(self.fresh_mask(m))); + g.extras_mut().mask = mask.map(|m| Box::new(self.fresh_mask(m))); Some(g) } } @@ -1289,7 +1289,10 @@ impl<'p> Builder<'p> { (k, a.width, a.height) }; let xf = transform * Affine::scale_non_uniform(f64::from(w) / f64::from(mw.max(1)), f64::from(h) / f64::from(mh.max(1))); - let art = Node::new(self.id(), NodeKind::Image(ImageObject { key: k, width: mw, height: mh, xf, link: None, placement: Default::default() })); + let art = Node::new( + self.id(), + NodeKind::Image(Box::new(ImageObject { key: k, width: mw, height: mh, xf, link: None, placement: Default::default() })), + ); Some(OpacityMask::new(art, true)) } @@ -1308,8 +1311,9 @@ impl<'p> Builder<'p> { (k, w, h) }; let id = self.id(); - let mut n = Node::new(id, NodeKind::Image(ImageObject { key: k, width: w, height: h, xf, link: None, placement: Default::default() })); - n.mask = mask.map(Box::new); + let mut n = + Node::new(id, NodeKind::Image(Box::new(ImageObject { key: k, width: w, height: h, xf, link: None, placement: Default::default() }))); + n.extras_mut().mask = mask.map(Box::new); self.push_node(n); } diff --git a/crates/pdf/src/import_lines.rs b/crates/pdf/src/import_lines.rs index 36cf5ea9..15294ad3 100644 --- a/crates/pdf/src/import_lines.rs +++ b/crates/pdf/src/import_lines.rs @@ -45,7 +45,7 @@ fn outline_stroke(nodes: &[Arc], ink: &[Rect]) -> Option { let NodeKind::Path { path, clipping: false, guide: false, live: None, .. } = &n.kind else { return false }; let b = path.to_bezpath().bounding_box(); n.appearance == first.appearance - && n.mask.is_none() + && n.extras().mask.is_none() && n.opacity >= 0.999 && n.blend == BlendMode::Normal && [(b.x0, r.x0), (b.y0, r.y0), (b.x1, r.x1), (b.y1, r.y1)].iter().all(|(a, b)| (a - b).abs() <= tol) @@ -87,7 +87,7 @@ fn join_strokes(nodes: &mut Vec>, lines: &HashMap) /// `n` as a line that can be part of area type: horizontal point type, with what its line told. fn line<'a>(n: &'a Node, lines: &'a HashMap) -> Option<(&'a TextObject, &'a LineFacts)> { let NodeKind::Text(t) = &n.kind else { return None }; - let point = matches!(t.kind, TextKind::Point) && !t.vertical && n.mask.is_none() && !t.runs.is_empty(); + let point = matches!(t.kind, TextKind::Point) && !t.vertical && n.extras().mask.is_none() && !t.runs.is_empty(); point.then_some(())?; Some((t, lines.get(&n.id)?)) } diff --git a/crates/pdf/src/import_mask.rs b/crates/pdf/src/import_mask.rs index eda4bdb4..9bdea94d 100644 --- a/crates/pdf/src/import_mask.rs +++ b/crates/pdf/src/import_mask.rs @@ -39,7 +39,7 @@ fn cover(n: &Node, page: Rect) -> Option<(Color, f32)> { let Paint::Solid { color, .. } = &f.paint else { return None }; let b = path.to_bezpath(); let covers = page.area() > 1.0 && contains(b.bounding_box(), page.inset(-0.5)) && is_rectangle(&b); - (covers && n.mask.is_none()).then_some((*color, f.opacity * n.opacity)) + (covers && n.extras().mask.is_none()).then_some((*color, f.opacity * n.opacity)) } /// An opaque white rectangle covering `page`, filled only. diff --git a/crates/pdf/src/import_shading.rs b/crates/pdf/src/import_shading.rs index b3356e6c..6330184e 100644 --- a/crates/pdf/src/import_shading.rs +++ b/crates/pdf/src/import_shading.rs @@ -195,7 +195,7 @@ fn only_leaf(n: &Node) -> Option<&Node> { NodeKind::Group { children, clip } => { let rest = if *clip { children.get(1..)? } else { children.as_slice() }; match rest { - [one] if n.opacity >= 0.999 && n.mask.is_none() => only_leaf(one), + [one] if n.opacity >= 0.999 && n.extras().mask.is_none() => only_leaf(one), _ => None, } } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 99c69e7d..f80b7dd8 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -378,7 +378,7 @@ fn image_roundtrip() { link: None, placement: Default::default(), }; - add(&mut d, Node::new(NodeId(0), NodeKind::Image(im))); + add(&mut d, Node::new(NodeId(0), NodeKind::Image(Box::new(im)))); let out = roundtrip(&d); let l = leaves(&out); assert_eq!(l.len(), 1); @@ -606,7 +606,7 @@ fn opacity_mask_exports_as_luminosity_soft_mask() { let mut d = doc(100.0, 100.0); let mut n = rect_node(Rect::new(10.0, 10.0, 90.0, 90.0), Color::rgb(1.0, 0.0, 0.0)); let art = rect_node(Rect::new(10.0, 10.0, 50.0, 90.0), Color::WHITE); - n.mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(art, true))); + n.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(art, true))); add(&mut d, n); let bytes = export(&d, &PdfOptions::uncompressed()).unwrap(); let text = String::from_utf8_lossy(&bytes); diff --git a/crates/pdf/src/tests_cmykblend.rs b/crates/pdf/src/tests_cmykblend.rs index c450b7e0..f76d9081 100644 --- a/crates/pdf/src/tests_cmykblend.rs +++ b/crates/pdf/src/tests_cmykblend.rs @@ -70,7 +70,7 @@ fn rgb_documents_and_opaque_cmyk_pages_are_unchanged() { fn soft_mask_groups_keep_screen_luminance() { let mut d = Document::new_with_mode(100.0, 100.0, ColorMode::Cmyk); let mut n = rect(Rect::new(0.0, 0.0, 100.0, 100.0), Color::cmyk(0.0, 0.0, 0.0, 1.0)); - n.mask = Some(Box::new(OpacityMask::new(rect(Rect::new(0.0, 0.0, 50.0, 100.0), Color::WHITE), true))); + n.extras_mut().mask = Some(Box::new(OpacityMask::new(rect(Rect::new(0.0, 0.0, 50.0, 100.0), Color::WHITE), true))); add(&mut d, n); let t = text(&d); // The page's group blends in CMYK; the mask's group measures luminance in RGB. diff --git a/crates/pdf/src/tests_cmykimages.rs b/crates/pdf/src/tests_cmykimages.rs index 7ca5acfe..cfbced73 100644 --- a/crates/pdf/src/tests_cmykimages.rs +++ b/crates/pdf/src/tests_cmykimages.rs @@ -229,7 +229,7 @@ fn masked(d: &Document) -> Vec<(&ImageBlob, Option>)> { .iter() .filter_map(|n| { let NodeKind::Image(im) = &n.kind else { return None }; - let mask = n.mask.as_ref().map(|m| match &m.art.kind { + let mask = n.extras().mask.as_ref().map(|m| match &m.art.kind { NodeKind::Image(a) => (&d.images[&a.key], a.width, a.height), other => panic!("mask art is an image: {other:?}"), }); @@ -266,7 +266,7 @@ fn masked_cmyk_images_keep_their_inks_and_carry_the_mask_as_an_opacity_mask() { // The mask art covers the image exactly. let NodeKind::Layer { children, .. } = &r.document.layers[0].kind else { panic!() }; let img = children.iter().find(|n| matches!(n.kind, NodeKind::Image(_))).unwrap(); - assert_eq!(img.geometric_bounds(), img.mask.as_ref().unwrap().art.geometric_bounds()); + assert_eq!(img.geometric_bounds(), img.extras().mask.as_ref().unwrap().art.geometric_bounds()); // Written as CMYK with the mask in grey; it opens with the same inks. let out = export_with_report(&r.document, &opts(json!({}))).unwrap(); let w = written(&out.bytes); @@ -291,7 +291,7 @@ fn all_images(d: &Document) -> Vec<&ImageBlob> { if let NodeKind::Image(im) = &n.kind { keys.push(im.key.clone()); } - if let Some(m) = &n.mask { + if let Some(m) = &n.extras().mask { m.art.walk(&mut |a| { if let NodeKind::Image(im) = &a.kind { keys.push(im.key.clone()); @@ -307,7 +307,7 @@ fn image_doc(blob: ImageBlob, w: u32, h: u32) -> Document { let mut d = Document::new_with_mode(100.0, 100.0, ColorMode::Cmyk); d.images.insert("img".into(), blob); let image = ImageObject { key: "img".into(), width: w, height: h, xf: Affine::scale(40.0 / w as f64), link: None, placement: Default::default() }; - let n = Node::new(d.alloc_id(), NodeKind::Image(image)); + let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image))); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); d diff --git a/crates/pdf/src/tests_compression.rs b/crates/pdf/src/tests_compression.rs index 596984cc..2954f91c 100644 --- a/crates/pdf/src/tests_compression.rs +++ b/crates/pdf/src/tests_compression.rs @@ -29,7 +29,7 @@ fn doc(bytes: Vec, mime: &str) -> Document { let xf = Affine::scale(72.0 / 600.0); let n = Node::new( d.alloc_id(), - NodeKind::Image(ImageObject { key: "img".into(), width: 600, height: 600, xf, link: None, placement: Default::default() }), + NodeKind::Image(Box::new(ImageObject { key: "img".into(), width: 600, height: 600, xf, link: None, placement: Default::default() })), ); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); diff --git a/crates/pdf/src/tests_import_fidelity.rs b/crates/pdf/src/tests_import_fidelity.rs index 0721fef3..a05176f2 100644 --- a/crates/pdf/src/tests_import_fidelity.rs +++ b/crates/pdf/src/tests_import_fidelity.rs @@ -72,8 +72,8 @@ fn masked(smask: &str, mask: &str) -> Vec { } fn the_mask(d: &Document) -> (Node, OpacityMask) { - let n = all(d).into_iter().find(|n| n.mask.is_some()).expect("a masked object"); - let m = n.mask.as_deref().unwrap().clone(); + let n = all(d).into_iter().find(|n| n.extras().mask.is_some()).expect("a masked object"); + let m = n.extras().mask.as_deref().unwrap().clone(); (n, m) } @@ -121,7 +121,7 @@ fn opacity_masks_round_trip_through_pdf() { let mut d = Document::new(100.0, 100.0); let mut n = rect(Rect::new(10.0, 10.0, 90.0, 90.0), Color::rgb(1.0, 0.0, 0.0)); let art = rect(Rect::new(10.0, 10.0, 50.0, 90.0), Color::gray(0.25)); - n.mask = Some(Box::new(OpacityMask { invert, ..OpacityMask::new(art, clip) })); + n.extras_mut().mask = Some(Box::new(OpacityMask { invert, ..OpacityMask::new(art, clip) })); add(&mut d, n); let back = roundtrip(&d); let (n, m) = the_mask(&back); @@ -152,14 +152,14 @@ fn isolated_and_non_isolated_groups_round_trip() { let back = roundtrip(&half_group(true)); let groups: Vec = all(&back).into_iter().filter(|n| matches!(n.kind, NodeKind::Group { clip: false, .. })).collect(); let [g] = groups.as_slice() else { panic!("one group: {groups:?}") }; - assert!(g.isolate && g.mask.is_none()); + assert!(g.isolate && g.extras().mask.is_none()); assert!((g.opacity - 0.5).abs() < 0.01, "{}", g.opacity); assert_eq!(g.children().unwrap()[0].blend, BlendMode::Multiply); // Not isolated: its 50% (a constant alpha mask in the file) is opacity again, here on the // only object inside, which blends with the backdrop as before. let back = roundtrip(&half_group(false)); let nodes = all(&back); - assert!(nodes.iter().all(|n| !n.isolate && n.mask.is_none())); + assert!(nodes.iter().all(|n| !n.isolate && n.extras().mask.is_none())); let m = nodes.iter().find(|n| n.blend == BlendMode::Multiply).unwrap(); let opacity: f32 = nodes.iter().filter(|n| n.children().is_some_and(|c| c.iter().any(|c| c.id == m.id))).map(|g| g.opacity).product::() * m.opacity; @@ -320,7 +320,7 @@ fn gradient_stop_opacity_round_trips() { add(&mut d, n); let back = roundtrip(&d); let nodes = all(&back); - assert!(nodes.iter().all(|n| n.mask.is_none()), "the opacity is in the stops, not a mask"); + assert!(nodes.iter().all(|n| n.extras().mask.is_none()), "the opacity is in the stops, not a mask"); let g = nodes.iter().find_map(|n| match n.appearance.fill().map(|f| &f.paint) { Some(Paint::Gradient(g)) => Some(g.gradient.clone()), _ => None, @@ -358,7 +358,7 @@ fn appearance_items_are_untouched_for_plain_art() { let d = open(&one_page("q 1 0 0 rg 10 10 30 30 re f Q 0 0 1 RG 2 w 50 50 30 30 re S", "", &[])); let nodes = all(&d); assert_eq!(nodes.iter().filter(|n| n.path_data().is_some()).count(), 2); - assert!(nodes.iter().all(|n| n.mask.is_none() && !matches!(n.kind, NodeKind::Group { .. }))); + assert!(nodes.iter().all(|n| n.extras().mask.is_none() && !matches!(n.kind, NodeKind::Group { .. }))); assert!(matches!(nodes.iter().find(|n| n.appearance.stroke().is_some()).unwrap().appearance.items[0], AppearanceItem::Stroke(_))); } diff --git a/crates/pdf/src/tests_output.rs b/crates/pdf/src/tests_output.rs index 442b4290..3413816a 100644 --- a/crates/pdf/src/tests_output.rs +++ b/crates/pdf/src/tests_output.rs @@ -198,7 +198,7 @@ fn image_doc(bytes: Vec) -> Document { let mut d = Document::new(100.0, 100.0); d.images.insert("img".into(), ImageBlob::new("image/png", bytes)); let image = ImageObject { key: "img".into(), width: 4, height: 4, xf: Affine::scale(10.0), link: None, placement: Default::default() }; - let n = Node::new(d.alloc_id(), NodeKind::Image(image)); + let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image))); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); d diff --git a/crates/pdf/src/tests_pdfx.rs b/crates/pdf/src/tests_pdfx.rs index dd4a48dd..d7dbee69 100644 --- a/crates/pdf/src/tests_pdfx.rs +++ b/crates/pdf/src/tests_pdfx.rs @@ -123,7 +123,7 @@ fn pdfx_1a_files_have_no_rgb() { link: None, placement: Default::default(), }; - let n = Node::new(d.alloc_id(), NodeKind::Image(im)); + let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); d.insert(Some(l), 0, n).unwrap(); let t = text(&export(&d, &opts(Standard::PdfX1a, |_| {})).unwrap()); for no in ["/DeviceRGB", "/ICCBased", "/Lab", "/CalRGB"] { diff --git a/crates/pdf/src/tests_webview.rs b/crates/pdf/src/tests_webview.rs index d4dd3611..dcee6baa 100644 --- a/crates/pdf/src/tests_webview.rs +++ b/crates/pdf/src/tests_webview.rs @@ -49,7 +49,7 @@ fn doc(n: usize, image: bool, on_first: bool) -> Document { let xf = Affine::translate((x + 60.0, 60.0)) * Affine::scale(4.0); let img = ImageObject { key: "img".into(), width: 8, height: 8, xf, link: None, placement: Default::default() }; let id = d.alloc_id(); - d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(img))).unwrap(); + d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(img)))).unwrap(); } } d diff --git a/crates/render/src/encode/psd.rs b/crates/render/src/encode/psd.rs index 304e53a5..f03e7378 100644 --- a/crates/render/src/encode/psd.rs +++ b/crates/render/src/encode/psd.rs @@ -520,7 +520,7 @@ impl Parts<'_> { /// Can layer `n` be written as a group of its parts without changing its look? fn splits(n: &Node) -> bool { - matches!(n.kind, NodeKind::Layer { clip: false, .. }) && n.mask.is_none() && n.knockout != Knockout::On && !fx::has_object_fx(n) + matches!(n.kind, NodeKind::Layer { clip: false, .. }) && n.extras().mask.is_none() && n.knockout != Knockout::On && !fx::has_object_fx(n) } /// Does anything in `n` carry effects of its own that may paint outside the art's bounds? diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index db417efd..276accfe 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -505,16 +505,14 @@ impl Renderer { // A path's geometry effects still apply when it is drawn. let path = matches!(art.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }); art.appearance.effects.retain(|e| path && effects::is_geometry(&e.id)); - let art = Arc::new(Node { - opacity: 1.0, - blend: Default::default(), - isolate: false, - // One element: its pieces never knock each other out (a group's members still do as - // its knockout setting says). - knockout: if container { a.knockout } else { vectorcraft_doc::Knockout::Off }, - mask: None, - ..art - }); + let mut art = art.with_mask(None); + art.opacity = 1.0; + art.blend = Default::default(); + art.isolate = false; + // One element: its pieces never knock each other out (a group's members still do as its + // knockout setting says). + art.knockout = if container { a.knockout } else { vectorcraft_doc::Knockout::Off }; + let art = Arc::new(art); if cache { let bytes = crate::lru::node_bytes(&art); self.fx_arts.insert(key, (a.clone(), art.clone()), bytes, self.stamp); diff --git a/crates/render/src/ink.rs b/crates/render/src/ink.rs index 48e0fe49..6e86e1d5 100644 --- a/crates/render/src/ink.rs +++ b/crates/render/src/ink.rs @@ -327,7 +327,7 @@ mod tests { d.images.insert("img".into(), ImageBlob::cmyk_tiff(&Inks::new(2, 2, ink.repeat(4)).unwrap()).unwrap()); let l = d.layers[0].id; let im = ImageObject { key: "img".into(), width: 2, height: 2, xf: Affine::scale(5.0), link: None, placement: Default::default() }; - let n = Node::new(d.alloc_id(), NodeKind::Image(im)); + let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); d.insert(Some(l), 0, n).unwrap(); // The same inks as a fill beside it. let fill = Color::cmyk(0.0, 0.4, 1.0, 25.0 / 255.0); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index b62f06ce..5410ddd7 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -552,7 +552,7 @@ impl Renderer { let frame = Frame { mt: threads > 0, doc, view, visible, px, opts, ink: Ink::Display }; self.begin_frame(); // View Opacity Mask: the mask's coverage in greyscale instead of the artwork. - if let Some(m) = opts.mask_view.and_then(|id| doc.node(id)).and_then(|n| n.mask.as_deref()) { + if let Some(m) = opts.mask_view.and_then(|id| doc.node(id)).and_then(|n| n.extras().mask.as_deref()) { let pixels = self.mask_values(&frame, m, w, h).into_iter().flat_map(|v| [v, v, v, 255]).collect(); self.end_frame(); self.stats.micros = now().saturating_sub(start); @@ -790,7 +790,7 @@ impl Renderer { NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } if !fx::has_object_fx(a) => { let mut acc: Option = None; // An Image Trace object's hidden source image shows in some of its views. - let source = a.trace.is_some().then(|| children.first()).flatten(); + let source = a.extras().trace.is_some().then(|| children.first()).flatten(); for c in children { if c.visible || source.is_some_and(|s| Arc::ptr_eq(s, c)) { acc = vectorcraft_geom::union_opt(acc, self.bounds_of(c)); @@ -873,7 +873,7 @@ impl Renderer { if self.skipped(f, a) { return; } - if let Some(m) = a.mask.as_deref() + if let Some(m) = a.extras().mask.as_deref() && !m.disabled && !f.opts.outline && !self.is_shape(a) diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index b94ce1f3..70533ae8 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -221,7 +221,7 @@ fn masked_doc(clip: bool, invert: bool) -> Document { let art = Node::group(gid, vec![Arc::new(white), Arc::new(grey)]); let mut m = vectorcraft_doc::OpacityMask::new(art, clip); m.invert = invert; - n.mask = Some(Box::new(m)); + n.extras_mut().mask = Some(Box::new(m)); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); d @@ -256,7 +256,7 @@ fn disabled_or_outline_mask_draws_unmasked() { let mut d = masked_doc(true, false); let id = d.layers[0].children().unwrap()[0].id; let n = d.node_mut(id).unwrap(); - n.mask.as_mut().unwrap().disabled = true; + n.extras_mut().mask.as_mut().unwrap().disabled = true; let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts); assert_eq!(img.pixel(80, 50), [255, 0, 0, 255]); @@ -376,7 +376,7 @@ fn images_take_their_nearest_pixel_without_smoothing() { d.images.insert("bw".into(), ImageBlob::new("image/png", png)); let id = d.alloc_id(); let im = ImageObject { key: "bw".into(), width: 2, height: 1, xf: Affine::scale(20.0), link: None, placement: Default::default() }; - d.insert(Some(d.layers[0].id), 0, Node::new(id, NodeKind::Image(im))).unwrap(); + d.insert(Some(d.layers[0].id), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); // How many pixels across the image are neither black nor white. let greys = |smooth_images| { let r = Renderer::new().render(&d, 40, 20, Affine::IDENTITY, &RenderOptions { smooth_images, ..Default::default() }); diff --git a/crates/render/src/tests_adjust.rs b/crates/render/src/tests_adjust.rs index 8e5690a6..5ff5a35d 100644 --- a/crates/render/src/tests_adjust.rs +++ b/crates/render/src/tests_adjust.rs @@ -86,7 +86,14 @@ fn an_embedded_image_is_recoloured_and_the_original_pixels_stay() { d.images.insert(key.clone(), blob.clone()); let mut n = Node::new( NodeId(0), - NodeKind::Image(ImageObject { key, width: 30, height: 30, xf: Affine::translate((20.0, 20.0)), link: None, placement: Default::default() }), + NodeKind::Image(Box::new(ImageObject { + key, + width: 30, + height: 30, + xf: Affine::translate((20.0, 20.0)), + link: None, + placement: Default::default(), + })), ); n.appearance.effects.push(hue()); d = { diff --git a/crates/render/src/tests_cmykblend.rs b/crates/render/src/tests_cmykblend.rs index 1eda1150..63b765a4 100644 --- a/crates/render/src/tests_cmykblend.rs +++ b/crates/render/src/tests_cmykblend.rs @@ -99,7 +99,7 @@ fn opacity_masks_take_screen_luminance() { let mut d = Document::new_with_mode(20.0, 20.0, ColorMode::Cmyk); let mut n = rect(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0), Color::cmyk(0.0, 0.0, 0.0, 1.0)); let art = rect(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0), CYAN); - n.mask = Some(Box::new(OpacityMask::new(art, true))); + n.extras_mut().mask = Some(Box::new(OpacityMask::new(art, true))); add(&mut d, n); let [r, g, b] = CYAN.to_rgb(); let cover = MASK_LUM[0] * r + MASK_LUM[1] * g + MASK_LUM[2] * b; @@ -116,7 +116,14 @@ fn placed_images_are_separated_not_blackened() { d.images.insert("red".into(), ImageBlob::new("image/png", png)); let n = Node::new( d.alloc_id(), - NodeKind::Image(ImageObject { key: "red".into(), width: 4, height: 4, xf: Affine::IDENTITY, link: None, placement: Default::default() }), + NodeKind::Image(Box::new(ImageObject { + key: "red".into(), + width: 4, + height: 4, + xf: Affine::IDENTITY, + link: None, + placement: Default::default(), + })), ); add(&mut d, n); let got = Renderer::new().render_region(&d, d.artboards[0].rect, 1.0, true).pixel(1, 1); diff --git a/crates/render/src/tests_isolation.rs b/crates/render/src/tests_isolation.rs index 138bb14b..b38ee470 100644 --- a/crates/render/src/tests_isolation.rs +++ b/crates/render/src/tests_isolation.rs @@ -138,7 +138,7 @@ fn a_masked_object_blends_with_the_backdrop() { let mut g = group(d, vec![m], 1.0, Blend::Normal, false); // A 50% grey mask over the whole group. let art = rect(d, Rect::new(0.0, 0.0, 100.0, 100.0), [0.5; 3]); - g.mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(art, true))); + g.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(art, true))); g }); assert_near(render(&d).pixel(50, 50), lerp(B, blend_rgb(Blend::Multiply, B, S), 0.5), "masked multiply"); diff --git a/crates/render/src/tests_knockout.rs b/crates/render/src/tests_knockout.rs index a5176608..d364080f 100644 --- a/crates/render/src/tests_knockout.rs +++ b/crates/render/src/tests_knockout.rs @@ -77,7 +77,7 @@ fn opacity_defines_the_knockout_shape() { let blue = Arc::make_mut(&mut g.children_mut().unwrap()[1]); blue.opacity = 1.0; blue.knockout_shape = shape; - blue.mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(white, true))); + blue.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(white, true))); })); let want = if shape { HALF_KNOCKED } else { BLUE_OVER_WHITE }; // A mid-grey mask is ~50% (luminance 0.5, not gamma-corrected): allow a little more slack. diff --git a/crates/render/src/tests_layeropts.rs b/crates/render/src/tests_layeropts.rs index db8b6eb2..c20e191d 100644 --- a/crates/render/src/tests_layeropts.rs +++ b/crates/render/src/tests_layeropts.rs @@ -20,14 +20,14 @@ fn doc(layer: impl FnOnce(&mut NodeKind)) -> Document { d.insert(Some(l), 0, sq).unwrap(); let mut im = Node::new( NodeId(0), - NodeKind::Image(ImageObject { + NodeKind::Image(Box::new(ImageObject { key: "red".into(), width: 30, height: 30, xf: Affine::translate((50.0, 10.0)), link: None, placement: Default::default(), - }), + })), ); im.id = d.alloc_id(); d.insert(Some(l), 9, im).unwrap(); diff --git a/crates/render/src/tests_memory.rs b/crates/render/src/tests_memory.rs index e9b9d7b2..acf5848c 100644 --- a/crates/render/src/tests_memory.rs +++ b/crates/render/src/tests_memory.rs @@ -24,7 +24,7 @@ fn doc_with_image(key: &str, bytes: Vec, w: u32, h: u32) -> Document { d.images.insert(key.into(), ImageBlob::new("image/png", bytes)); let (id, layer) = (d.alloc_id(), d.layers[0].id); let im = ImageObject { key: key.into(), width: w, height: h, xf: Affine::IDENTITY, link: None, placement: Default::default() }; - d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(im))).unwrap(); + d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); d } diff --git a/crates/render/src/tests_objectfx.rs b/crates/render/src/tests_objectfx.rs index 9041b107..0f69e3f7 100644 --- a/crates/render/src/tests_objectfx.rs +++ b/crates/render/src/tests_objectfx.rs @@ -112,14 +112,14 @@ fn image_doc(effects: Vec) -> Document { d.images.insert("red".into(), ImageBlob::new("image/png", png)); let mut n = Node::new( NodeId(0), - NodeKind::Image(ImageObject { + NodeKind::Image(Box::new(ImageObject { key: "red".into(), width: 30, height: 30, xf: Affine::translate((20.0, 20.0)), link: None, placement: Default::default(), - }), + })), ); n.appearance.effects = effects; doc_with(d, n) diff --git a/crates/render/src/tests_setup.rs b/crates/render/src/tests_setup.rs index d64ffcdd..e8129f46 100644 --- a/crates/render/src/tests_setup.rs +++ b/crates/render/src/tests_setup.rs @@ -11,7 +11,7 @@ fn doc_with_red_image() -> Document { d.images.insert("red".into(), ImageBlob::new("image/png", png)); let (id, layer) = (d.alloc_id(), d.layers[0].id); let im = ImageObject { key: "red".into(), width: 40, height: 40, xf: Affine::IDENTITY, link: None, placement: Default::default() }; - d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(im))).unwrap(); + d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); d } diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index 8775a97d..58dc6508 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -312,7 +312,8 @@ impl Rules<'_> { if !n.visible { return; } - let plain_group = n.name.is_none() && transparency(n).is_empty() && !n.appearance.effects.iter().any(|e| e.visible) && n.mask.is_none(); + let plain_group = + n.name.is_none() && transparency(n).is_empty() && !n.appearance.effects.iter().any(|e| e.visible) && n.extras().mask.is_none(); match &n.kind { NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. } => {} NodeKind::Layer { children, clip: false, .. } => children.iter().for_each(|c| self.object(c)), @@ -394,7 +395,7 @@ impl Rules<'_> { let mut radius = None; let text = match &n.kind { NodeKind::Path { path, live, .. } => { - match corners(path, live.as_ref()) { + match corners(path, live.as_deref()) { Some(Corners::Square) => {} Some(Corners::Round(r)) => radius = Some(r.iter().map(|r| self.len(*r)).collect::>().join(" ")), Some(Corners::Ellipse) => radius = Some("50%".into()), @@ -420,7 +421,7 @@ impl Rules<'_> { } art.props.extend(radius.map(|r| ("border-radius", r))); self.effects(n, text, &mut art); - if n.mask.as_ref().is_some_and(|m| !m.disabled) { + if n.extras().mask.as_ref().is_some_and(|m| !m.disabled) { art.unsupported("an opacity mask"); } art diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index ef558c88..702fe9ff 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -356,7 +356,7 @@ impl Reuse { fn scan(&mut self, doc: &Document, n: &Node, depth: u32) { let pattern = |p: &Paint| matches!(p, Paint::Pattern { .. }); let text_patterns = matches!(&n.kind, NodeKind::Text(t) if t.runs.iter().any(|r| pattern(&r.style.fill) || pattern(&r.style.stroke))); - let unlinked_mask = n.mask.as_ref().is_some_and(|m| !m.linked); + let unlinked_mask = n.extras().mask.as_ref().is_some_and(|m| !m.linked); if depth > MAX_SYMBOL_NEST || text_patterns || unlinked_mask || vectorcraft_doc::pattern::uses_pattern(n, None) { *self = Self::NONE; return; @@ -387,7 +387,7 @@ impl Reuse { } _ => {} } - if let Some(m) = &n.mask { + if let Some(m) = &n.extras().mask { self.scan(doc, &m.art, depth); } for c in n.children().into_iter().flatten() { @@ -412,7 +412,7 @@ impl Reuse { /// and the art of unlinked opacity masks. fn pin_to_page(n: &mut Node, m: Affine) { vectorcraft_doc::pattern::transform_pattern_paints(n, m); - if let Some(mask) = n.mask.as_deref_mut() { + if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) { let linked = mask.linked; let art = std::sync::Arc::make_mut(&mut mask.art); if !linked { @@ -600,7 +600,7 @@ impl Writer<'_> { out.push_str(&format!(" {}=\"sublayer\"", crate::import::SUBLAYER)); } let mut keys = HashSet::new(); - for (k, v) in n.attrs.as_deref().map_or(&[][..], |a| a.data.as_slice()) { + for (k, v) in n.extras().attrs.as_deref().map_or(&[][..], |a| a.data.as_slice()) { // Ours (`name`, `vc-…`) are written by the export itself. if k == "name" || k.starts_with("vc-") { continue; @@ -1155,7 +1155,7 @@ impl Writer<'_> { if self.tiny() { self.warn(TINY_MASKS); let mut bare = n.clone(); - bare.mask = None; + bare.extras_mut().mask = None; return self.node_body(&bare); } let mid = self.fresh_id("mask"); @@ -1189,7 +1189,7 @@ impl Writer<'_> { self.line(&format!("")); self.depth += 1; let mut bare = n.clone(); - bare.mask = None; + bare.extras_mut().mask = None; self.node_body(&bare); self.depth -= 1; self.line(""); @@ -1263,7 +1263,13 @@ impl Writer<'_> { f } }; - let shape = if c.knockout_shape { c.clone() } else { Node { opacity: 1.0, mask: None, ..c.clone() } }; + let shape = if c.knockout_shape { + c.clone() + } else { + let mut s = c.clone().with_mask(None); + s.opacity = 1.0; + s + }; let anonymous = std::mem::replace(&mut self.anonymous, true); let art = self.detached(3, |w| w.node(&shape)); self.anonymous = anonymous; @@ -1303,7 +1309,7 @@ impl Writer<'_> { if has_raster(&n.appearance.effects) { return self.filtered(n); } - if let Some(m) = n.mask.as_deref() + if let Some(m) = n.extras().mask.as_deref() && !m.disabled { return self.masked(n, m); @@ -1720,7 +1726,8 @@ impl Writer<'_> { }; glyphs(self, below); // The group carries the id and the object's transparency. - let bare = Node { opacity: 1.0, blend: BlendMode::Normal, isolate: false, ..n.clone() }; + let mut bare = n.clone(); + (bare.opacity, bare.blend, bare.isolate) = (1.0, BlendMode::Normal, false); let anonymous = std::mem::replace(&mut self.anonymous, true); chars(self, &bare); for ig in &lay.inlines { diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 67983cfb..39204efd 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -301,7 +301,7 @@ struct SymbolMade { fn without_ids(n: &Node) -> Node { let mut n = n.clone(); n.id = vectorcraft_doc::NodeId(0); - if let Some(m) = n.mask.as_deref_mut() { + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) { m.art = Arc::new(without_ids(&m.art)); } for c in n.children_mut().into_iter().flatten() { @@ -713,7 +713,7 @@ impl Importer { /// Give `n` the `data-*` attributes of element `id`, unless it has data already (what an /// inner element made of it keeps). fn keep_data(&self, id: &str, n: &mut Node) { - if let Some(data) = self.data.get(id).filter(|_| n.attrs.as_ref().is_none_or(|a| a.data.is_empty())) { + if let Some(data) = self.data.get(id).filter(|_| n.extras().attrs.as_ref().is_none_or(|a| a.data.is_empty())) { n.edit_attrs(|a| a.data = data.clone()); } } @@ -799,7 +799,7 @@ impl Importer { n.opacity = g.opacity().get(); n.blend = blend(g.blend_mode()); n.isolate = g.isolate(); - n.mask = mask; + n.extras_mut().mask = mask; Some(n) } @@ -912,7 +912,7 @@ impl Importer { if (is_text || id.is_empty() && mask.is_none()) && children.len() == 1 && (g.blend_mode() == usvg::BlendMode::Normal || children[0].blend == BlendMode::Normal) - && (effects.is_empty() || children[0].blend == BlendMode::Normal && children[0].mask.is_none()) + && (effects.is_empty() || children[0].blend == BlendMode::Normal && children[0].extras().mask.is_none()) && let Some(only) = children.pop() { let mut c = Arc::unwrap_or_clone(only); @@ -922,7 +922,7 @@ impl Importer { } c.isolate |= g.isolate(); if mask.is_some() { - c.mask = mask; + c.extras_mut().mask = mask; } c.appearance.effects.extend(effects); return Some(c); @@ -934,7 +934,7 @@ impl Importer { n.opacity = g.opacity().get(); n.blend = blend(g.blend_mode()); n.isolate = g.isolate(); - n.mask = mask; + n.extras_mut().mask = mask; if !is_text && self.sublayers.contains(g.id()) { self.sublayer_groups.insert(n.id); } @@ -1229,7 +1229,7 @@ impl Importer { if link.is_some() && !missing { blob.with_proxy() } else { blob } }); let xf = acc * Affine::scale_non_uniform(size.width() as f64 / pw as f64, size.height() as f64 / ph as f64); - Some(self.named(i.id(), NodeKind::Image(ImageObject { key, width: pw, height: ph, xf, link, placement: Default::default() }))) + Some(self.named(i.id(), NodeKind::Image(Box::new(ImageObject { key, width: pw, height: ph, xf, link, placement: Default::default() })))) } /// An SVG shown as an image (a data URL or a linked file) → its art, `acc` mapping its size. diff --git a/crates/svg/tests/blend_export.rs b/crates/svg/tests/blend_export.rs index 7cf88f62..ec50a4f4 100644 --- a/crates/svg/tests/blend_export.rs +++ b/crates/svg/tests/blend_export.rs @@ -72,7 +72,7 @@ fn masked(clip: bool, invert: bool) -> Document { let mut red = rect(&mut d, Rect::new(0.0, 0.0, 40.0, 20.0), [255, 0, 0]); let mut m = OpacityMask::new(art, clip); m.invert = invert; - red.mask = Some(Box::new(m)); + red.extras_mut().mask = Some(Box::new(m)); add(&mut d, red); d } diff --git a/crates/svg/tests/css_rules.rs b/crates/svg/tests/css_rules.rs index 4004c04c..e50d29a0 100644 --- a/crates/svg/tests/css_rules.rs +++ b/crates/svg/tests/css_rules.rs @@ -30,7 +30,7 @@ fn rect(d: &mut Document, (x, y, w, h): (f64, f64, f64, f64), radius: f64) -> No Appearance::basic(fill, Paint::solid(Color::rgb8(0, 0, 255)), 2.0), ); if let NodeKind::Path { live, .. } = &mut n.kind { - *live = Some(LiveShape::Rectangle { w, h, radii: [radius; 4], kinds: Default::default(), xf: Affine::translate((x, y)) }); + *live = Some(Box::new(LiveShape::Rectangle { w, h, radii: [radius; 4], kinds: Default::default(), xf: Affine::translate((x, y)) })); } n } @@ -74,19 +74,19 @@ fn rectangle_gives_background_border_and_radius() { fn live_corners_give_per_corner_radii_and_only_round_ones() { let mut d = doc(); let mut n = rect(&mut d, (0.0, 0.0, 100.0, 50.0), 0.0); - if let NodeKind::Path { live: Some(LiveShape::Rectangle { radii, kinds, .. }), .. } = &mut n.kind { + if let Some(LiveShape::Rectangle { radii, kinds, .. }) = n.live_shape_mut() { *radii = [0.0, 8.0, 0.0, 4.0]; kinds[0] = CornerKind::Chamfer; } let id = add(&mut d, n); assert_eq!(prop(&one(&d, id, &CssOptions::default()), "border-radius"), Some("0 8px 0 4px"), "a square corner's kind doesn't matter"); - if let Some(NodeKind::Path { live: Some(LiveShape::Rectangle { kinds, .. }), .. }) = d.node_mut(id).map(|n| &mut n.kind) { + if let Some(LiveShape::Rectangle { kinds, .. }) = d.node_mut(id).and_then(|n| n.live_shape_mut()) { kinds[1] = CornerKind::InvertedRound; } assert!(prop(&one(&d, id, &CssOptions::default()), "border-radius").is_none(), "CSS can't cut a corner in"); // A radius past half the shorter side is drawn at half of it (#442), and exported so: CSS // would draw it larger, its neighbours leaving room. - if let Some(NodeKind::Path { live: Some(LiveShape::Rectangle { radii, kinds, .. }), .. }) = d.node_mut(id).map(|n| &mut n.kind) { + if let Some(LiveShape::Rectangle { radii, kinds, .. }) = d.node_mut(id).and_then(|n| n.live_shape_mut()) { *radii = [60.0, 8.0, 0.0, 4.0]; *kinds = Default::default(); } @@ -99,7 +99,7 @@ fn ellipses_round_fully_and_options_change_units_and_names() { let mut e = Node::path(d.alloc_id(), shapes::ellipse(Rect::new(0.0, 0.0, 72.0, 36.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); if let NodeKind::Path { live, .. } = &mut e.kind { - *live = Some(LiveShape::Ellipse { w: 72.0, h: 36.0, pie: (0.0, 360.0), xf: Affine::IDENTITY }); + *live = Some(Box::new(LiveShape::Ellipse { w: 72.0, h: 36.0, pie: (0.0, 360.0), xf: Affine::IDENTITY })); } let e = add(&mut d, e); let a = rect(&mut d, (0.0, 0.0, 10.0, 10.0), 0.0); diff --git a/crates/svg/tests/data_attributes.rs b/crates/svg/tests/data_attributes.rs index a8927970..651a3447 100644 --- a/crates/svg/tests/data_attributes.rs +++ b/crates/svg/tests/data_attributes.rs @@ -35,7 +35,7 @@ fn native_data(raw: &str) -> Document { fn imported_decimal_whitespace_references_survive_export() { let doc = import(&svg(r#""#)).unwrap(); assert_eq!( - named(&doc, "shape").attrs.as_deref().unwrap().data, + named(&doc, "shape").extras().attrs.as_deref().unwrap().data, [("value".into(), "a\nb".into()), ("tab".into(), "a\tb".into()), ("cr".into(), "a\rb".into())] ); for styling in styles() { @@ -47,7 +47,7 @@ fn imported_decimal_whitespace_references_survive_export() { assert_eq!(n.attribute(key), Some(value)); } let back = import(&text).unwrap(); - assert_eq!(named(&back, "shape").attrs.as_deref().unwrap().data, named(&doc, "shape").attrs.as_deref().unwrap().data); + assert_eq!(named(&back, "shape").extras().attrs.as_deref().unwrap().data, named(&doc, "shape").extras().attrs.as_deref().unwrap().data); } } } @@ -70,7 +70,7 @@ fn unicode_data_names_survive_each_encoding() { r#""#, )) .unwrap(); - assert_eq!(named(&doc, "shape").attrs.as_deref().unwrap().data.len(), 5); + assert_eq!(named(&doc, "shape").extras().attrs.as_deref().unwrap().data.len(), 5); for styling in styles() { for encoding in [Encoding::Utf8, Encoding::Utf16, Encoding::Latin1] { for minify in [false, true] { @@ -89,7 +89,10 @@ fn unicode_data_names_survive_each_encoding() { assert_eq!(out.encoding, encoding); } let back = import(&out.svg).unwrap(); - assert_eq!(named(&back, "shape").attrs.as_deref().unwrap().data, named(&doc, "shape").attrs.as_deref().unwrap().data); + assert_eq!( + named(&back, "shape").extras().attrs.as_deref().unwrap().data, + named(&doc, "shape").extras().attrs.as_deref().unwrap().data + ); } } } @@ -199,7 +202,7 @@ fn reid_imported_nodes_keeps_unique_ids_references_and_data() { let back = import(&text).unwrap(); let mut copies = 0; back.walk(|n| { - if n.attrs.as_deref().is_some_and(|a| a.data.iter().any(|(k, v)| k == "配方" && v == value)) { + if n.extras().attrs.as_deref().is_some_and(|a| a.data.iter().any(|(k, v)| k == "配方" && v == value)) { copies += 1; } }); diff --git a/crates/svg/tests/encoding_profile_fonts.rs b/crates/svg/tests/encoding_profile_fonts.rs index 3f080ccd..c805548c 100644 --- a/crates/svg/tests/encoding_profile_fonts.rs +++ b/crates/svg/tests/encoding_profile_fonts.rs @@ -138,7 +138,7 @@ fn tiny_has_no_filters_masks_symbols_or_style_sheets() { add(&mut d, a); let mut b = Node::path(vectorcraft_doc::NodeId(0), shapes::rectangle(Rect::new(80.0, 10.0, 130.0, 60.0)), fill(0, 0, 255)); b.blend = BlendMode::Multiply; - b.mask = + b.extras_mut().mask = Some(Box::new(OpacityMask::new(Node::path(d.alloc_id(), shapes::ellipse(Rect::new(80.0, 10.0, 130.0, 60.0)), fill(255, 255, 255)), true))); add(&mut d, b); let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(-5.0, -5.0, 5.0, 5.0)), fill(0, 128, 0)); diff --git a/crates/svg/tests/import_details.rs b/crates/svg/tests/import_details.rs index 13031e05..b6cbf72c 100644 --- a/crates/svg/tests/import_details.rs +++ b/crates/svg/tests/import_details.rs @@ -30,7 +30,7 @@ fn images(d: &Document) -> Vec<(&Node, &ImageObject)> { art(d) .into_iter() .filter_map(|n| match &n.kind { - NodeKind::Image(im) => Some((n, im)), + NodeKind::Image(im) => Some((n, &**im)), _ => None, }) .collect() diff --git a/crates/svg/tests/import_text.rs b/crates/svg/tests/import_text.rs index 73fca990..234eaf12 100644 --- a/crates/svg/tests/import_text.rs +++ b/crates/svg/tests/import_text.rs @@ -100,7 +100,7 @@ fn text_keeps_its_parent_group_clip_and_mask() { assert!(matches!(children[1].kind, NodeKind::Text(_)) && children[1].name.as_deref() == Some("own")); // The text's own mask. assert!(matches!(inner[3].kind, NodeKind::Text(_))); - assert!(inner[3].mask.is_some()); + assert!(inner[3].extras().mask.is_some()); assert_eq!(inner[3].name.as_deref(), Some("masked")); } diff --git a/crates/svg/tests/mask_options.rs b/crates/svg/tests/mask_options.rs index 9084aeb3..2ead42f1 100644 --- a/crates/svg/tests/mask_options.rs +++ b/crates/svg/tests/mask_options.rs @@ -19,7 +19,7 @@ fn masked(clip: bool, invert: bool) -> Document { let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 90.0)), white); let mut m = OpacityMask::new(art, clip); m.invert = invert; - n.mask = Some(Box::new(m)); + n.extras_mut().mask = Some(Box::new(m)); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); d @@ -27,7 +27,7 @@ fn masked(clip: bool, invert: bool) -> Document { fn masks(d: &Document) -> Vec { let mut v = vec![]; - d.walk(|n| v.extend(n.mask.as_deref().cloned())); + d.walk(|n| v.extend(n.extras().mask.as_deref().cloned())); v } diff --git a/crates/svg/tests/options.rs b/crates/svg/tests/options.rs index d0e863bb..d2c8cfda 100644 --- a/crates/svg/tests/options.rs +++ b/crates/svg/tests/options.rs @@ -56,7 +56,7 @@ fn image_doc(key: &str, bytes: Vec) -> Document { d.images.insert(key.into(), ImageBlob::new("image/png", bytes)); let l = d.layers[0].id; let im = ImageObject { key: key.into(), width: 2, height: 2, xf: Affine::IDENTITY, link: None, placement: Default::default() }; - let n = Node::new(d.alloc_id(), NodeKind::Image(im)); + let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); d.insert(Some(l), usize::MAX, n).unwrap(); d } diff --git a/crates/svg/tests/roundtrip.rs b/crates/svg/tests/roundtrip.rs index 53c732ee..8439e720 100644 --- a/crates/svg/tests/roundtrip.rs +++ b/crates/svg/tests/roundtrip.rs @@ -316,7 +316,7 @@ fn stroke_alignment_approximations() { assert!(s.contains(" imports as an opacity mask"); + assert!(a.iter().any(|n| n.extras().mask.is_some()), " imports as an opacity mask"); assert!(!w.iter().any(|w| w.contains("mask")), "{w:?}"); // becomes a pattern swatch. assert_eq!(d.patterns.len(), 1, "{w:?}"); @@ -862,7 +862,7 @@ fn masked_doc(clip: bool, invert: bool) -> Document { let art = rect_node(&mut d, Rect::new(10.0, 10.0, 50.0, 90.0), white); let mut m = vectorcraft_doc::OpacityMask::new(art, clip); m.invert = invert; - n.mask = Some(Box::new(m)); + n.extras_mut().mask = Some(Box::new(m)); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); d @@ -875,9 +875,9 @@ fn opacity_mask_exports_as_svg_mask_and_imports_back() { assert!(s.contains(" = art(&back).into_iter().filter(|n| n.mask.is_some()).collect(); + let masked: Vec<&Node> = art(&back).into_iter().filter(|n| n.extras().mask.is_some()).collect(); assert_eq!(masked.len(), 1, "{s}"); - let m = masked[0].mask.as_deref().unwrap(); + let m = masked[0].extras().mask.as_deref().unwrap(); assert!(close_rect(m.art.geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 50.0, 90.0), 1e-6)); assert!(close_rect(masked[0].geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 90.0, 90.0), 1e-6)); } @@ -890,7 +890,7 @@ fn unclipped_inverted_mask_exports_backdrop_and_filter() { let disabled = { let mut d = masked_doc(true, false); let id = d.layers[0].children().unwrap()[0].id; - d.node_mut(id).unwrap().mask.as_mut().unwrap().disabled = true; + d.node_mut(id).unwrap().extras_mut().mask.as_mut().unwrap().disabled = true; export(&d, &ExportOptions::default()) }; assert!(!disabled.contains(" Vec<(String, String)> { let mut found = vec![]; d.walk(|n| { if n.name.as_deref() == Some(name) { - found = n.attrs.as_deref().map(|a| a.data.clone()).unwrap_or_default(); + found = n.extras().attrs.as_deref().map(|a| a.data.clone()).unwrap_or_default(); } }); found diff --git a/crates/tools/src/builder.rs b/crates/tools/src/builder.rs index 885987f2..6286e1c1 100644 --- a/crates/tools/src/builder.rs +++ b/crates/tools/src/builder.rs @@ -105,7 +105,7 @@ pub fn leaf_shapes<'a>(n: &'a Node, out: &mut Vec<&'a Node>) { if !n.visible { return; } - if n.shaper.is_some() { + if n.extras().shaper.is_some() { for c in n.children().into_iter().flatten().skip(1) { leaf_shapes(c, out); } diff --git a/crates/tools/src/corners.rs b/crates/tools/src/corners.rs index ee9c6843..d156db14 100644 --- a/crates/tools/src/corners.rs +++ b/crates/tools/src/corners.rs @@ -58,10 +58,10 @@ impl PathWidgets { return None; } let NodeKind::Path { path, live, .. } = &doc.node(id)?.kind else { return None }; - if !any_path && !matches!(live, Some(LiveShape::Rectangle { .. } | LiveShape::Polygon { .. })) { + if !any_path && !matches!(live.as_deref(), Some(LiveShape::Rectangle { .. } | LiveShape::Polygon { .. })) { return None; } - let lc = LiveCorners::new(path, live.as_ref())?; + let lc = LiveCorners::new(path, live.as_deref())?; let det = lc.xf.determinant().abs(); if det < 1e-12 { return None; @@ -366,7 +366,7 @@ mod tests { let id = d.alloc_id(); let mut n = Node::path(id, path, Appearance::default_art()); if let NodeKind::Path { live: slot, .. } = &mut n.kind { - *slot = live; + *slot = live.map(Box::new); } d.insert(Some(l), 0, n).unwrap(); (d, id) @@ -440,7 +440,7 @@ mod tests { let b = d.alloc_id(); let mut n = Node::path(b, live.to_path(), Appearance::default_art()); if let NodeKind::Path { live: slot, .. } = &mut n.kind { - *slot = Some(live); + *slot = Some(Box::new(live)); } let l = d.layers[0].id; d.insert(Some(l), 1, n).unwrap(); diff --git a/crates/tools/src/cropimage.rs b/crates/tools/src/cropimage.rs index c0711639..85555f3f 100644 --- a/crates/tools/src/cropimage.rs +++ b/crates/tools/src/cropimage.rs @@ -273,7 +273,7 @@ mod tests { link: None, placement: Default::default(), }; - d.insert(Some(l), 0, Node::new(id, NodeKind::Image(im))).unwrap(); + d.insert(Some(l), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); let mut s = Selection::default(); s.add(id); (d, s) diff --git a/crates/tools/src/distort.rs b/crates/tools/src/distort.rs index 0437ffff..55fa9a9b 100644 --- a/crates/tools/src/distort.rs +++ b/crates/tools/src/distort.rs @@ -59,7 +59,8 @@ fn warp_node(n: &mut Node, f: &dyn Fn(Point) -> Point, h: Option<&Homography>) { if let Some(h) = h && matches!(n.kind, NodeKind::Text(_) | NodeKind::SymbolInstance { .. }) { - let rec = n.perspective.get_or_insert_with(Default::default); + let mut extras = n.extras_mut(); + let rec = extras.perspective.get_or_insert_with(Default::default); let had = rec.homography().unwrap_or(Homography::IDENTITY); rec.projection = Some(h.then_after(&had).to_array()); return; diff --git a/crates/tools/src/distort/perspective.rs b/crates/tools/src/distort/perspective.rs index d1193ee6..88a54b07 100644 --- a/crates/tools/src/distort/perspective.rs +++ b/crates/tools/src/distort/perspective.rs @@ -287,9 +287,9 @@ impl PerspectiveGrid { continue; } if let Some(n) = k.parse::().ok().and_then(|id| doc.node_mut(NodeId(id))) - && n.perspective.is_none() + && n.extras().perspective.is_none() { - n.perspective = Some(Box::new(PerspectiveAttachment::new(pl.id(), 0.0))); + n.extras_mut().perspective = Some(Box::new(PerspectiveAttachment::new(pl.id(), 0.0))); } } if let Some(v) = doc.unknown.get_mut(DOC_KEY).and_then(Value::as_object_mut) { @@ -676,7 +676,7 @@ pub enum Axis { /// The plane and depth object `n` is attached to (its own record). pub fn attachment(n: &Node) -> Option<(Plane, f64)> { - let a = n.perspective.as_deref()?; + let a = n.extras().perspective.as_deref()?; Plane::parse(&a.plane).filter(|p| *p != Plane::None).map(|p| (p, a.depth)) } @@ -697,7 +697,8 @@ pub fn attached_roots(doc: &Document) -> Vec<(NodeId, Plane, f64)> { /// Attach `n` to `plane` at `depth` (type and symbols keep their projection). pub fn set_attachment(n: &mut Node, plane: Plane, depth: f64) { - let rec = n.perspective.get_or_insert_with(Default::default); + let mut extras = n.extras_mut(); + let rec = extras.perspective.get_or_insert_with(Default::default); rec.plane = plane.id().into(); rec.depth = if depth.is_finite() { depth } else { 0.0 }; } @@ -705,9 +706,10 @@ pub fn set_attachment(n: &mut Node, plane: Plane, depth: f64) { /// Release `n` from the grid (Release with Perspective): it keeps its look, type and symbols their /// projection. pub fn release(n: &mut Node) { - match n.perspective.as_deref_mut() { + let mut extras = n.extras_mut(); + match extras.perspective.as_deref_mut() { Some(rec) if rec.projection.is_some() => *rec = PerspectiveAttachment { projection: rec.projection, ..Default::default() }, - _ => n.perspective = None, + _ => extras.perspective = None, } } @@ -1460,7 +1462,7 @@ mod tests { old.attached.insert(id2.0.to_string(), Plane::Left); d2.unknown.insert(DOC_KEY.into(), serde_json::to_value(&old).unwrap()); grid(2).store(&mut d2); - assert!(d2.node(id2).unwrap().perspective.is_none()); + assert!(d2.node(id2).unwrap().extras().perspective.is_none()); } #[test] diff --git a/crates/tools/src/draw2.rs b/crates/tools/src/draw2.rs index 77731260..a060e148 100644 --- a/crates/tools/src/draw2.rs +++ b/crates/tools/src/draw2.rs @@ -111,7 +111,7 @@ pub(crate) fn shown_paths(cx: &ToolContext) -> Vec { NodeKind::Path { guide: false, .. } => v.push(n.id), // A Shape Builder group's first child is its shaper, not art. NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => { - for c in children.iter().skip(usize::from(n.shaper.is_some())) { + for c in children.iter().skip(usize::from(n.extras().shaper.is_some())) { inside(c, v); } } diff --git a/crates/tools/src/meshedit.rs b/crates/tools/src/meshedit.rs index 1af0998c..adb3adcd 100644 --- a/crates/tools/src/meshedit.rs +++ b/crates/tools/src/meshedit.rs @@ -19,9 +19,10 @@ const FEEDBACK: [u8; 3] = [0x4f, 0x9d, 0xff]; pub fn grid_of(n: &Node) -> Option> { match &n.kind { NodeKind::Mesh(m) => Some(Cow::Borrowed(m)), - NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows, cols, points, handles }, editing: false, .. } => { - envelope_grid(*rows, *cols, points, handles).map(Cow::Owned) - } + NodeKind::Envelope { kind, editing: false, .. } => match &**kind { + EnvelopeKind::Mesh { rows, cols, points, handles } => envelope_grid(*rows, *cols, points, handles).map(Cow::Owned), + _ => None, + }, _ => None, } } @@ -132,11 +133,11 @@ mod tests { let kind = EnvelopeKind::Mesh { rows: 1, cols: 1, points: grid_points(Rect::new(100.0, 100.0, 200.0, 200.0), 1, 1), handles: vec![] }; let env = NodeKind::Envelope { content: vec![Arc::new(rect)], - kind, + kind: Box::new(kind), fidelity: 50.0, editing: false, options: EnvelopeOptions::NEW, - frame: Affine::IDENTITY, + frame: Box::new(Affine::IDENTITY), }; let l = d.layers[0].id; d.insert(Some(l), 0, Node::new(id, env)).unwrap(); diff --git a/crates/tools/src/pathtype.rs b/crates/tools/src/pathtype.rs index 61fee7c2..8bf33382 100644 --- a/crates/tools/src/pathtype.rs +++ b/crates/tools/src/pathtype.rs @@ -54,7 +54,7 @@ impl Brackets { } fn of_node(cx: &ToolContext, id: NodeId) -> Option { - let n = cx.doc.node(id).filter(|n| n.perspective.is_none() && cx.doc.is_editable(id))?; + let n = cx.doc.node(id).filter(|n| n.extras().perspective.is_none() && cx.doc.is_editable(id))?; let NodeKind::Text(t) = &n.kind else { return None }; let TextKind::OnPath { path, end, .. } = &t.kind else { return None }; let det = t.xf.determinant(); diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index e12054c6..3cae0937 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -540,7 +540,8 @@ fn can_be_key(cx: &ToolContext, id: NodeId) -> bool { /// Is `n` area type (text in a frame) whose frame the tools reshape? Type in perspective isn't: /// it transforms whole. pub(crate) fn is_area_type(n: &vectorcraft_doc::Node) -> bool { - n.perspective.is_none() && matches!(&n.kind, vectorcraft_doc::NodeKind::Text(t) if matches!(t.kind, vectorcraft_doc::TextKind::Area { .. })) + n.extras().perspective.is_none() + && matches!(&n.kind, vectorcraft_doc::NodeKind::Text(t) if matches!(t.kind, vectorcraft_doc::TextKind::Area { .. })) } /// The resize cursor for handle `h` of a box turned by `angle` (counter-clockwise degrees): the diff --git a/crates/tools/src/typewidget.rs b/crates/tools/src/typewidget.rs index cce2801b..13753fb0 100644 --- a/crates/tools/src/typewidget.rs +++ b/crates/tools/src/typewidget.rs @@ -37,7 +37,7 @@ impl TypeWidget { if !doc.is_editable(id) || !zoom.is_finite() || zoom <= 0.0 { return None; } - let n = doc.node(id).filter(|n| n.perspective.is_none())?; + let n = doc.node(id).filter(|n| n.extras().perspective.is_none())?; let NodeKind::Text(t) = &n.kind else { return None }; let area = match t.kind { TextKind::Point => false, diff --git a/crates/tools/src/xform/eyedropper.rs b/crates/tools/src/xform/eyedropper.rs index d1dc10bf..0cdfbaa8 100644 --- a/crates/tools/src/xform/eyedropper.rs +++ b/crates/tools/src/xform/eyedropper.rs @@ -213,7 +213,7 @@ mod tests { link: None, placement: Default::default(), }; - d.insert(Some(l), 0, Node::new(id, NodeKind::Image(im))).unwrap(); + d.insert(Some(l), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); let (s, p) = (Selection::default(), paint()); let mut cx = cx(&d, &s, &p); let click = PointerEvent::new(PointerKind::Down, 15.0, 5.0); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f51c6992..a082d4f9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1229,7 +1229,7 @@ fn node_outline(n: &Node) -> BezPath { NodeKind::Path { path, .. } => bp.extend(path.to_bezpath()), // Area type shows its frame: the type area Direct Selection reshapes; type on a path its // path, which the selection tools' brackets sit on. - NodeKind::Text(t) if c.perspective.is_none() && !matches!(t.kind, vectorcraft_doc::TextKind::Point) => { + NodeKind::Text(t) if c.extras().perspective.is_none() && !matches!(t.kind, vectorcraft_doc::TextKind::Point) => { if let Some(path) = t.area_frame().or_else(|| t.type_path()) { bp.extend(path.to_bezpath()); } @@ -1278,7 +1278,7 @@ fn too_many_anchors<'a>(nodes: impl IntoIterator) -> bool { } } !matches!(n.kind, NodeKind::Envelope { .. }) - && n.children().into_iter().flatten().skip(usize::from(n.shaper.is_some())).any(|c| over(c, left)) + && n.children().into_iter().flatten().skip(usize::from(n.extras().shaper.is_some())).any(|c| over(c, left)) } let mut left = OVERLAY_MAX_ANCHORS; nodes.into_iter().any(|n| over(n, &mut left)) @@ -1291,7 +1291,7 @@ fn walk_drawn<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) { if matches!(n.kind, NodeKind::Envelope { .. }) { return; } - for c in n.children().into_iter().flatten().skip(usize::from(n.shaper.is_some())) { + for c in n.children().into_iter().flatten().skip(usize::from(n.extras().shaper.is_some())) { walk_drawn(c, f); } } @@ -2207,7 +2207,7 @@ mod tests { } let st = s.active().unwrap(); let g = st.doc.node(st.selection.objects[0]).unwrap(); - assert!(g.shaper.is_some()); + assert!(g.extras().shaper.is_some()); let bounds = node_outline(g).bounding_box(); assert!((bounds.x0 - 100.0).abs() < 0.01 && (bounds.x1 - 200.0).abs() < 0.01, "{bounds:?}"); assert_eq!(node_outline(st.doc.node(line).unwrap()).bounding_box().x0, 50.0, "an isolated original still shows its full outline"); @@ -3646,9 +3646,9 @@ mod tests { let st = app.session.active().unwrap(); let g = st.doc.graphic_style(&name).unwrap(); // `graphicStyle.apply` with the hit object's id: linked, the selection left alone. - assert_eq!(st.doc.node(b).unwrap().graphic_style, Some(g.id)); + assert_eq!(st.doc.node(b).unwrap().extras().graphic_style, Some(g.id)); assert_eq!(st.doc.node(b).unwrap().appearance, g.appearance); - assert_eq!(st.doc.node(a).unwrap().graphic_style, None); + assert_eq!(st.doc.node(a).unwrap().extras().graphic_style, None); assert_eq!(st.selection.objects, [a]); } diff --git a/crates/ui-egui/src/dialogs/blend_options.rs b/crates/ui-egui/src/dialogs/blend_options.rs index 86deabcb..0ee43fc4 100644 --- a/crates/ui-egui/src/dialogs/blend_options.rs +++ b/crates/ui-egui/src/dialogs/blend_options.rs @@ -147,7 +147,7 @@ mod tests { fn spec(app: &VectorcraftApp, id: u64) -> vectorcraft_doc::BlendSpec { match &app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind { - NodeKind::Blend { spec, .. } => spec.clone(), + NodeKind::Blend { spec, .. } => (**spec).clone(), _ => panic!("not a blend"), } } diff --git a/crates/ui-egui/src/dialogs/corners.rs b/crates/ui-egui/src/dialogs/corners.rs index 27405382..8f1342eb 100644 --- a/crates/ui-egui/src/dialogs/corners.rs +++ b/crates/ui-egui/src/dialogs/corners.rs @@ -120,7 +120,7 @@ mod tests { let Some(NodeKind::Path { live: Some(l), .. }) = app.session.active().unwrap().doc.node(NodeId(id)).map(|n| n.kind.clone()) else { panic!("not live") }; - l + *l } fn corners(app: &VectorcraftApp, id: u64) -> ([f64; 4], [CornerKind; 4]) { @@ -239,7 +239,7 @@ mod tests { d.fields.insert("radius".into(), json!(5)); super::super::confirm(&mut app).unwrap(); let n = app.session.active().unwrap().doc.node(NodeId(id)).unwrap().clone(); - assert!(matches!(&n.kind, NodeKind::Path { live: Some(LiveShape::Path { .. }), .. })); + assert!(matches!(&n.kind, NodeKind::Path { live: Some(l), .. } if matches!(**l, LiveShape::Path { .. }))); assert_eq!(crate::panels::corner_radius(&app, &n), Some(5.0)); } } diff --git a/crates/ui-egui/src/dialogs/envelope.rs b/crates/ui-egui/src/dialogs/envelope.rs index 0a4d4aa3..0cdb2b24 100644 --- a/crates/ui-egui/src/dialogs/envelope.rs +++ b/crates/ui-egui/src/dialogs/envelope.rs @@ -238,7 +238,7 @@ fn bar_of(app: &VectorcraftApp) -> Option<(bool, Bar)> { let st = app.session.active()?; let env = st.selection.objects.first().and_then(|id| vectorcraft_doc::live::envelope_of(&st.doc, *id))?; let NodeKind::Envelope { kind, editing, .. } = &env.kind else { return None }; - let bar = match kind { + let bar = match &**kind { EnvelopeKind::Warp { style, bend, h, v, horizontal } => { Bar::Warp { style: style.clone(), bend: *bend, h: *h, v: *v, horizontal: *horizontal } } @@ -362,7 +362,8 @@ mod tests { assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); let (_, n) = envelope(&app); match &n.kind { - NodeKind::Envelope { kind: EnvelopeKind::Warp { style, bend, horizontal, .. }, content, .. } => { + NodeKind::Envelope { kind, content, .. } => { + let EnvelopeKind::Warp { style, bend, horizontal, .. } = &**kind else { panic!("{kind:?}") }; assert_eq!((style.as_str(), *bend, *horizontal), ("flag", -30.0, false)); assert_eq!(content[0].id, rect); } @@ -373,7 +374,10 @@ mod tests { let d = app.ui.dialog.as_ref().unwrap(); assert_eq!((d.fields["reset"].clone(), d.fields["style"].clone(), d.fields["bend"].clone()), (json!(true), json!("flag"), json!(-30.0))); crate::dialogs::cancel(&mut app); - assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { kind: EnvelopeKind::Warp { .. }, .. }), "cancel rolls back"); + assert!( + matches!(envelope(&app).1.kind, NodeKind::Envelope { ref kind, .. } if matches!(**kind, EnvelopeKind::Warp { .. })), + "cancel rolls back" + ); } #[test] @@ -404,13 +408,18 @@ mod tests { app.run("ui.menuDialog", json!({"command": RESET_MESH})).unwrap(); assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(MESH)); frame(&mut app); - assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows: 4, cols: 4, .. }, .. }), "previewed"); + assert!( + matches!(envelope(&app).1.kind, NodeKind::Envelope { ref kind, .. } if matches!(**kind, EnvelopeKind::Mesh { rows: 4, cols: 4, .. })), + "previewed" + ); crate::dialogs::cancel(&mut app); - assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { kind: EnvelopeKind::Warp { .. }, .. })); + assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { ref kind, .. } if matches!(**kind, EnvelopeKind::Warp { .. }))); app.run("ui.menuDialog", json!({"command": RESET_MESH})).unwrap(); app.ui.dialog.as_mut().unwrap().fields.insert("rows".into(), json!(2)); super::super::confirm(&mut app).unwrap(); - assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows: 2, cols: 4, .. }, .. })); + assert!( + matches!(envelope(&app).1.kind, NodeKind::Envelope { ref kind, .. } if matches!(**kind, EnvelopeKind::Mesh { rows: 2, cols: 4, .. })) + ); } #[test] diff --git a/crates/ui-egui/src/dialogs/graph_data.rs b/crates/ui-egui/src/dialogs/graph_data.rs index efcd65c7..fa9f5e65 100644 --- a/crates/ui-egui/src/dialogs/graph_data.rs +++ b/crates/ui-egui/src/dialogs/graph_data.rs @@ -52,8 +52,9 @@ pub fn open(app: &mut VectorcraftApp) -> Result { // The query fails without a graph selected, with the engine's own message. let v = app.session.execute("graph.setData", &json!({})).map_err(|e| e.to_string())?; let graph = app.session.active().and_then(|st| st.selection.objects.iter().find_map(|id| graph_of(&st.doc, *id)).map(|id| (st, id))); - let scatter = - graph.is_some_and(|(st, id)| st.doc.node(id).and_then(|n| n.graph.as_deref()).is_some_and(|g| g.kind == vectorcraft_doc::GraphKind::Scatter)); + let scatter = graph.is_some_and(|(st, id)| { + st.doc.node(id).and_then(|n| n.extras().graph.as_deref()).is_some_and(|g| g.kind == vectorcraft_doc::GraphKind::Scatter) + }); let id = graph.map(|(_, id)| id.0); let grid = grid_from_data(&v); let entry = cell(&grid, 0, 1); @@ -68,7 +69,7 @@ pub fn open(app: &mut VectorcraftApp) -> Result { fn graph_of(doc: &vectorcraft_doc::Document, id: vectorcraft_doc::NodeId) -> Option { let mut cur = Some(id); while let Some(c) = cur { - if doc.node(c).is_some_and(|n| n.graph.is_some()) { + if doc.node(c).is_some_and(|n| n.extras().graph.is_some()) { return Some(c); } cur = doc.parent_of(c); diff --git a/crates/ui-egui/src/panels/graphic_styles.rs b/crates/ui-egui/src/panels/graphic_styles.rs index f2f848a6..dcb0b6bb 100644 --- a/crates/ui-egui/src/panels/graphic_styles.rs +++ b/crates/ui-egui/src/panels/graphic_styles.rs @@ -935,7 +935,7 @@ mod tests { frame_with(&ctx, release(pos2(60.0, 116.0)), |ui| show(&mut app, ui)); let d = app.session.active().unwrap().doc.clone(); assert_eq!(d.graphic_styles.len(), n0.len() + 2); - assert_eq!(d.node(rect).unwrap().graphic_style, d.graphic_styles.last().map(|g| g.id)); + assert_eq!(d.node(rect).unwrap().extras().graphic_style, d.graphic_styles.last().map(|g| g.id)); } #[test] diff --git a/crates/ui-egui/src/panels/image_trace.rs b/crates/ui-egui/src/panels/image_trace.rs index f3d5cf7f..a46d852c 100644 --- a/crates/ui-egui/src/panels/image_trace.rs +++ b/crates/ui-egui/src/panels/image_trace.rs @@ -76,7 +76,7 @@ pub(crate) fn is_trace(n: &Node) -> bool { /// What the selection is: an Image Trace object (with its stored settings), a plain image, or neither. fn target(app: &VectorcraftApp) -> (bool, bool, Option) { let Some(n) = first_selected(app) else { return (false, false, None) }; - (is_trace(&n), matches!(n.kind, NodeKind::Image(_)), n.trace.map(|t| *t)) + (is_trace(&n), matches!(n.kind, NodeKind::Image(_)), n.extras().trace.as_deref().cloned()) } /// The one selected Image Trace object's preset ("Custom" once its settings were changed) and view. @@ -84,7 +84,7 @@ pub(crate) fn selected_trace(app: &VectorcraftApp) -> Option<(String, TraceView) let st = app.session.active()?; let [id] = st.selection.objects[..] else { return None }; let n = st.doc.node(id).filter(|n| is_trace(n))?; - Some((n.trace.as_ref().and_then(|t| t["preset"].as_str()).unwrap_or("Custom").to_string(), n.trace_view())) + Some((n.extras().trace.as_ref().and_then(|t| t["preset"].as_str()).unwrap_or("Custom").to_string(), n.trace_view())) } /// Image Trace › View, in the UI language. diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 43d62fcd..758a520f 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -723,7 +723,7 @@ fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) { // A clipping path's name is underlined, a masked object's with a dashed line. if clip_path { painter.line_segment([text.left_bottom(), text.right_bottom()], Stroke::new(1.0, t.text)); - } else if n.mask.is_some() { + } else if n.extras().mask.is_some() { painter.extend(egui::Shape::dashed_line(&[text.left_bottom(), text.right_bottom()], Stroke::new(1.0, t.text), 3.0, 2.0)); } } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 769d5b99..24159a5a 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -97,7 +97,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { divider(ui); section_header(ui, tl!("Quick Actions")); let is_group = matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Group { .. })); - let is_live = matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Path { live: Some(l), .. }) if !matches!(l, LiveShape::Path { .. })); + let is_live = matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Path { live: Some(l), .. }) if !matches!(**l, LiveShape::Path { .. })); let mut actions: Vec<(&str, &str)> = vec![]; if n_sel > 1 { actions.push((tl!("Group"), "object.group")); @@ -465,7 +465,7 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) { if let Some(n) = first_selected(app) && let NodeKind::Path { live: Some(live), .. } = &n.kind { - match live { + match &**live { vectorcraft_doc::LiveShape::Rectangle { .. } => corner_radius_row(app, ui, &n, "radius"), vectorcraft_doc::LiveShape::Polygon { .. } => super::transform::polygon_rows(app, ui, &n, live, "props-polygon"), vectorcraft_doc::LiveShape::Ellipse { pie, .. } => super::transform::pie_rows(app, ui, *pie, "props-pie"), diff --git a/crates/ui-egui/src/panels/transform.rs b/crates/ui-egui/src/panels/transform.rs index 949da2ac..6c3b5ed6 100644 --- a/crates/ui-egui/src/panels/transform.rs +++ b/crates/ui-egui/src/panels/transform.rs @@ -179,10 +179,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // Live shape properties (a path with live corners is no shape). if let Some(n) = first_selected(app) && let NodeKind::Path { live: Some(live), .. } = &n.kind - && !matches!(live, vectorcraft_doc::LiveShape::Path { .. }) + && !matches!(**live, vectorcraft_doc::LiveShape::Path { .. }) { widgets::divider(ui); - match live { + match &**live { vectorcraft_doc::LiveShape::Rectangle { .. } => { widgets::subheader(ui, tl!("Rectangle Properties:")); corner_radius_row(app, ui, &n, "xfp-radius"); diff --git a/crates/ui-egui/src/panels/transparency.rs b/crates/ui-egui/src/panels/transparency.rs index 7a5375d1..4e11bb01 100644 --- a/crates/ui-egui/src/panels/transparency.rs +++ b/crates/ui-egui/src/panels/transparency.rs @@ -65,7 +65,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { widgets::divider(ui); // Thumbnails and the opacity-mask controls. let hide_thumbs: bool = pstate(ui.ctx(), "tr-hide-thumbs"); - let mask = n.as_ref().and_then(|n| n.mask.as_deref()); + let mask = n.as_ref().and_then(|n| n.extras().mask.as_deref()); let (new_clip, new_invert) = app.session.new_mask_defaults(); ui.horizontal(|ui| { if !hide_thumbs { @@ -79,7 +79,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } if let Some(n) = &n { let mut bare = n.clone(); - bare.mask = None; + bare.extras_mut().mask = None; if !node_thumb(app, ui, "tr-obj", &bare, r.shrink(3.0), None) { let (f, s) = current_paints(app); let inner = r.shrink2(vec2(6.0, 10.0)); @@ -195,7 +195,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { // The masked object's mask, also while editing it (the selection then is the mask art). let editing = app.session.active().is_some_and(|d| d.doc.mask_edit.is_some()); let (_, n) = state(app, ui.ctx()); - let mask = n.as_ref().and_then(|n| n.mask.as_deref()); + let mask = n.as_ref().and_then(|n| n.extras().mask.as_deref()); for (label, id, enabled) in mask_items(mask, n.is_some() && !editing) { if menu_item(ui, tl!(label), enabled, false) { app.run(id, json!({})).ok(); @@ -320,7 +320,7 @@ mod tests { let (info, n) = state(&app, &egui::Context::default()); assert_eq!((info.ids, n.as_ref().map(|n| n.id.0)), (vec![vectorcraft_doc::NodeId(a)], Some(a))); // The menu is enabled for the masked object while editing (Make is not). - let items = mask_items(n.as_ref().and_then(|n| n.mask.as_deref()), false); + let items = mask_items(n.as_ref().and_then(|n| n.extras().mask.as_deref()), false); assert_eq!(items.map(|(_, _, on)| on), [false, true, true, true]); // The panel's controls run without ids and reach the masked object. run(&mut app, "transparency.setOpacityMask", json!({"clip": false})); @@ -329,7 +329,7 @@ mod tests { frame(&mut app); let st = app.session.active().unwrap(); let obj = st.doc.node(vectorcraft_doc::NodeId(a)).unwrap(); - let m = obj.mask.as_ref().unwrap(); + let m = obj.extras().mask.as_ref().unwrap(); assert!(!m.clip && !m.linked && obj.blend == BlendMode::Multiply); assert!(st.doc.mask_edit.is_some()); // Release leaves editing and puts the drawn mask art back.