Merge pull request #1245 from arhammusheer/perf/memory

perf: bound memory on large documents (#1244)
This commit is contained in:
BFlat 2026-10-12 11:20:05 +07:00 committed by GitHub
commit 04a305367e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
208 changed files with 6076 additions and 1198 deletions

View File

@ -207,6 +207,31 @@ fn deep(n: usize) -> Result<(), String> {
}),
));
note("after typing");
// Four embedded 24 MP photos drawn at fit page by three renderers, as the app's canvas, render
// worker and a panel each draw them.
{
let art = synthetic(3000)?;
let mut d = Document::new(1600.0, 1200.0);
let l = d.layers.first().map(|l| l.id);
for i in 0..4u32 {
let photo = Renderer::new().render(&art, 6000, 4000, Affine::scale(3.75 + f64::from(i) * 0.1), &opts).to_png()?;
let key = format!("photo {i}");
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", photo));
let xf = Affine::translate((f64::from(i % 2) * 800.0, f64::from(i / 2) * 600.0)) * Affine::scale(800.0 / 6000.0);
let image = vectorcraft_doc::ImageObject { key, width: 6000, height: 4000, xf, link: None, placement: Default::default() };
let id = d.alloc_id();
d.insert(l, usize::MAX, Node::new(id, vectorcraft_doc::NodeKind::Image(Box::new(image)))).map_err(|e| e.to_string())?;
}
note("4 embedded 24 MP photos");
let mut renderers = [Renderer::new(), Renderer::new(), Renderer::new()];
let ms = median_ms(1, || {
for r in &mut renderers {
r.render(&d, 2880, 1800, Affine::scale(1.6), &opts);
}
});
rows.push(("render 4 embedded 24 MP photos with 3 renderers (cold)".into(), ms));
note("after rendering the photos with 3 renderers");
}
// Output hashes, to compare builds: the SVG export and a save → open → save round trip.
let fnv = |b: &[u8]| b.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, &x| (h ^ u64::from(x)).wrapping_mul(0x100_0000_01b3));
let doc = synthetic(n)?;

View File

@ -501,6 +501,10 @@ fn services(parent: Parent, ui: egui::Context) -> Services {
write: Some(Box::new(write_file)),
// Background Save and Export write from a worker thread.
write_shared: Some(std::sync::Arc::new(write_file)),
// Native saves stream into the file as they are encoded.
stream_shared: Some(std::sync::Arc::new(|p: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>| {
fileio::write_file_with(p, fill).map_err(|e| e.to_string())
})),
// Every format Copy offers and Paste reads (menu-bar Paste never sees egui's Paste event).
system_clipboard: Some(clipboard::system_clipboard()),
// Linux checks whether Paste has something to take on a background thread: an X11 clipboard

View File

@ -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));

View File

@ -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();

View File

@ -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

View File

@ -60,7 +60,7 @@ impl<'a> LiveCorners<'a> {
/// The Live Corners of a path object.
pub fn of(node: &'a Node) -> Option<Self> {
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<LiveShape>, corners: &BTreeSet<usize>, radius: Option<f64>, kind: Option<CornerKind>) {
pub fn set_corners(path: &mut PathData, live: &mut Option<Box<LiveShape>>, corners: &BTreeSet<usize>, radius: Option<f64>, kind: Option<CornerKind>) {
fn edit(radii: &mut [f64], kinds: &mut [CornerKind], corners: &BTreeSet<usize>, radius: Option<f64>, kind: Option<CornerKind>) {
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<LiveShape>, 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.

View File

@ -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();

View File

@ -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<Affine>| {
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;

View File

@ -93,6 +93,19 @@ impl IdIndex {
self.0.read().ok().and_then(|g| g.clone())
}
/// Let go of the cache (a document kept in the undo history): the next lookup builds it again.
pub(crate) fn clear(&self) {
if let Ok(mut g) = self.0.write() {
*g = None;
}
self.1.store(0, std::sync::atomic::Ordering::Relaxed);
}
/// Whether a cache was built (and not cleared since).
pub(crate) fn is_built(&self) -> bool {
self.0.read().is_ok_and(|g| g.is_some())
}
/// Rebuild the cache from `layers` (one walk) and return it.
pub(crate) fn rebuild(&self, layers: &[Arc<Node>]) -> Arc<Paths> {
fn walk(nodes: &[Arc<Node>], parent: u32, out: &mut Paths) {

View File

@ -22,6 +22,7 @@ pub use shared::Shared;
pub mod links;
pub mod live;
pub mod marks;
mod mem;
pub mod metadata;
pub mod node;
pub mod orient;
@ -60,6 +61,12 @@ pub(crate) mod skip {
pub fn is_one(v: &f32) -> bool {
*v == 1.0
}
pub fn is_empty_vec<T>(v: &[T]) -> bool {
v.is_empty()
}
pub fn is_empty_map(v: &serde_json::Map<String, serde_json::Value>) -> bool {
v.is_empty()
}
}
pub use appearance::StrokeGradientMode;
@ -79,8 +86,8 @@ pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, Gradient
pub use metadata::{CopyrightStatus, DocMetadata};
pub use node::Knockout;
pub use node::Scaling;
pub use node::{Extras, ExtrasMut, ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeExtras, NodeId, NodeKind, OpacityMask};
pub use node::{ImageMap, ObjectAttributes};
pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind, OpacityMask};
pub use orient::OrientedBox;
pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType};
pub use perspective::PerspectiveAttachment;
@ -549,18 +556,21 @@ pub struct Document {
pub artboards: Vec<Artboard>,
/// Top-level layers, bottom first.
pub layers: Vec<Arc<Node>>,
// The document-wide resources below are [`Shared`]: an edit copies the document (copy-on-write)
// and the undo history keeps every version, so what an edit doesn't change stays one copy.
// Reads, writes and the saved form are those of the value itself.
#[serde(default)]
pub swatches: Vec<Swatch>,
pub swatches: Shared<Vec<Swatch>>,
#[serde(default)]
pub swatch_groups: Vec<SwatchGroup>,
pub swatch_groups: Shared<Vec<SwatchGroup>>,
#[serde(default)]
pub graphic_styles: Vec<GraphicStyle>,
pub graphic_styles: Shared<Vec<GraphicStyle>>,
/// Character styles (besides the built-in [Normal Character Style], which may be redefined here).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub char_styles: Vec<TextStyleDef>,
#[serde(default, skip_serializing_if = "skip::is_empty_vec")]
pub char_styles: Shared<Vec<TextStyleDef>>,
/// Paragraph styles (besides the built-in [Normal Paragraph Style]).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub para_styles: Vec<TextStyleDef>,
#[serde(default, skip_serializing_if = "skip::is_empty_vec")]
pub para_styles: Shared<Vec<TextStyleDef>>,
/// Threaded text: area-type frames one story flows through, in order.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub text_threads: Vec<Vec<NodeId>>,
@ -575,11 +585,11 @@ pub struct Document {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub views: Vec<SavedView>,
/// Select → Save Selection… (at most [`SavedSelection::MAX`]); saved with the document.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub saved_selections: Vec<SavedSelection>,
#[serde(default, skip_serializing_if = "skip::is_empty_vec")]
pub saved_selections: Shared<Vec<SavedSelection>>,
/// Variables (data merge): named values bound to objects, applied per dataset.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub variables: Variables,
pub variables: Shared<Variables>,
#[serde(default)]
pub grid: GridPrefs,
#[serde(default = "ppi72")]
@ -598,8 +608,8 @@ pub struct Document {
pub mask_edit: Option<MaskEdit>,
next_id: u64,
/// Foreign data preserved on round-trip.
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
pub unknown: serde_json::Map<String, serde_json::Value>,
#[serde(default, skip_serializing_if = "skip::is_empty_map")]
pub unknown: Shared<serde_json::Map<String, serde_json::Value>>,
/// Transparency panel → Page Isolated Blending: the page is an isolated transparency group,
/// so top-level blend modes don't blend with what lies under the page.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
@ -705,18 +715,18 @@ impl Document {
show_cross_hairs: false,
}],
layers: vec![],
swatches,
swatch_groups,
graphic_styles: default_graphic_styles(),
char_styles: vec![],
para_styles: vec![],
swatches: swatches.into(),
swatch_groups: swatch_groups.into(),
graphic_styles: default_graphic_styles().into(),
char_styles: Default::default(),
para_styles: Default::default(),
text_threads: vec![],
symbols: vec![],
graph_designs: vec![],
guides: vec![],
views: vec![],
saved_selections: vec![],
variables: Variables::default(),
saved_selections: Default::default(),
variables: Default::default(),
grid: GridPrefs::default(),
raster_effects_ppi: 72.0,
images: BTreeMap::new(),
@ -775,6 +785,26 @@ impl Document {
self.index_path(id).and_then(|p| self.node_at(&p))
}
/// [`Document::node`] without building the lookup cache when there is none: for a look at a
/// document kept in the undo history ([`Document::drop_lookup_cache`]).
pub fn node_uncached(&self, id: NodeId) -> Option<&Node> {
match self.index.is_built() {
true => self.node(id),
false => self.walk_index_path(id).and_then(|p| self.node_at(&p)),
}
}
/// Let go of the lookup cache ([`id_index`]), as a document kept in the undo history does: a
/// table per step would add up. Lookups still work; the first one builds the cache again.
pub fn drop_lookup_cache(&self) {
self.index.clear();
}
/// Whether the lookup cache ([`id_index`]) is built (see [`Document::drop_lookup_cache`]).
pub fn has_lookup_cache(&self) -> bool {
self.index.is_built()
}
/// Index path from the top-level layer list to `id` (e.g. `[0, 3, 1]`): the first place in
/// tree order where `id` appears.
pub fn index_path(&self, id: NodeId) -> Option<Vec<usize>> {
@ -1249,7 +1279,7 @@ impl Document {
let objects = s.objects.into_iter().filter(|id| ids.contains(id) && seen.insert(*id)).collect();
kept.push(SavedSelection { name, objects });
}
self.saved_selections = kept;
self.saved_selections = kept.into();
}
/// Leave opacity-mask editing: drop the temporary editing layer, a working copy of art the

View File

@ -136,7 +136,7 @@ fn proxy_png(bytes: &[u8]) -> Option<Vec<u8>> {
/// `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()

View File

@ -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<GradientMesh>)> {
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<Self> {
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<Node> {
_ => None,
};
let plain = |items: Vec<AppearanceItem>| {
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<Node> {
pub fn expand_live_hooks(n: &Node, hooks: Hooks) -> Vec<Node> {
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
}

420
crates/doc/src/mem.rs Normal file
View File

@ -0,0 +1,420 @@
//! How much memory a document keeps that another doesn't share: what an undo step costs.
//!
//! Documents share structure (`Arc<Node>` children, [`crate::Shared`] resources), so an old
//! version kept in the undo history holds only what the edits after it replaced. The estimate
//! walks two versions side by side and visits only what isn't shared: a subtree whose `Arc` both
//! hold costs nothing and isn't entered, so the walk is about as long as the edit was (plus a pass
//! over each changed child list). One walk gives both directions: what the older version keeps
//! that the newer one doesn't (the undo step's cost) and the other way round (the cost of the redo
//! step that undoing it makes), so undo and redo needn't walk again. It counts the heap the values
//! own (struct sizes, vector capacities, strings, JSON) plus a typical allocator overhead per
//! allocation, without following every last field: an estimate in the right ballpark, for a
//! memory budget, not an exact figure.
use std::collections::HashSet;
use std::mem::size_of;
use std::sync::Arc;
use serde_json::Value;
use vectorcraft_color::{Paint, Swatch, SwatchGroup};
use vectorcraft_geom::{Affine, Anchor, PathData, SubPath};
use crate::live::{BlendSpec, EnvelopeKind};
use crate::text::{ParaStyle, TextObject, TextRun, TextStyleDef};
use crate::{Appearance, AppearanceItem, Document, Effect, GraphicStyle, ImageObject, Node, NodeExtras, NodeKind, SavedSelection, Shared};
/// Bookkeeping and rounding a typical allocator adds to each allocation.
const ALLOC: usize = 16;
/// An `Arc`'s two reference counts.
const ARC: usize = 16;
/// The kinds of node this doesn't break down (meshes, repeats' settings, placed documents…): a
/// typical size of what they own besides the node.
const OTHER_KIND: usize = 256;
impl Document {
/// Roughly how many bytes of heap this document keeps that `newer` doesn't share with it: what
/// keeping this version in the undo history costs while `newer` (a later version) is kept too.
/// Visits only the parts that aren't shared (see the [module docs](self)).
pub fn retained_bytes(&self, newer: &Document) -> usize {
self.retained_bytes_each(newer).0
}
/// [`Document::retained_bytes`] both ways in one walk: what this document keeps that `other`
/// doesn't share, and what `other` keeps that this one doesn't.
pub fn retained_bytes_each(&self, other: &Document) -> (usize, usize) {
if std::ptr::eq(self, other) {
return (0, 0);
}
let own = size_of::<Document>() + ALLOC + ARC;
let mut w = Walk { docs: [self, other], seen: [None, None], bytes: [own, own] };
w.list(&self.layers, &other.layers);
for i in 0..self.symbols.len().max(other.symbols.len()) {
match (self.symbols.get(i), other.symbols.get(i)) {
(Some(a), Some(b)) => w.pair(&a.art, &b.art),
(a, b) => {
a.into_iter().for_each(|a| w.alone(0, &a.art));
b.into_iter().for_each(|b| w.alone(1, &b.art));
}
}
}
for i in 0..self.patterns.len().max(other.patterns.len()) {
match (self.patterns.get(i), other.patterns.get(i)) {
(Some(a), Some(b)) => w.list(&a.art, &b.art),
(a, b) => {
a.into_iter().flat_map(|a| &a.art).for_each(|n| w.alone(0, n));
b.into_iter().flat_map(|b| &b.art).for_each(|n| w.alone(1, n));
}
}
}
let [mut a, mut b] = w.bytes;
a += self.lists_bytes() + self.resources_bytes(other);
b += other.lists_bytes() + other.resources_bytes(self);
(a, b)
}
/// The symbol and pattern lists themselves (their art is counted as nodes).
fn lists_bytes(&self) -> usize {
vec_bytes(&self.symbols)
+ self.symbols.iter().map(|s| s.name.capacity()).sum::<usize>()
+ vec_bytes(&self.patterns)
+ self.patterns.iter().map(|p| p.name.capacity()).sum::<usize>()
}
/// The document-wide lists and settings: a copy of the document copies those that aren't
/// [`Shared`]; the shared ones count only when `newer` has its own.
fn resources_bytes(&self, newer: &Document) -> usize {
fn shared<T>(a: &Shared<T>, b: &Shared<T>, f: impl FnOnce(&T) -> usize) -> usize {
if Shared::ptr_eq(a, b) { 0 } else { ALLOC + ARC + f(a) }
}
let mut n = 0;
n += vec_bytes(&self.artboards) + self.artboards.iter().map(|a| a.name.capacity()).sum::<usize>();
n += vec_bytes(&self.guides) + vec_bytes(&self.views) + vec_bytes(&self.slices) + vec_bytes(&self.assets);
n += vec_bytes(&self.text_threads) + self.text_threads.iter().map(vec_bytes).sum::<usize>();
n += self.images.iter().map(|(k, b)| 4 * size_of::<usize>() + k.capacity() + b.mime.capacity() + size_of_val(b)).sum::<usize>();
n += map_bytes(&self.export_settings) + map_bytes(&self.extra) + self.print_setup.as_ref().map_or(0, json_bytes);
n += self.title.capacity();
n += shared(&self.swatches, &newer.swatches, |s| swatches_bytes(s));
n += shared(&self.swatch_groups, &newer.swatch_groups, |g| {
vec_bytes(g) + g.iter().map(|g: &SwatchGroup| g.name.capacity() + swatches_bytes(&g.swatches)).sum::<usize>()
});
n += shared(&self.graphic_styles, &newer.graphic_styles, |g| {
vec_bytes(g) + g.iter().map(|g: &GraphicStyle| g.name.capacity() + appearance_heap(&g.appearance)).sum::<usize>()
});
let styles = |s: &Vec<TextStyleDef>| vec_bytes(s) + s.iter().map(|s| s.name.capacity() + map_bytes(&s.attrs)).sum::<usize>();
n += shared(&self.char_styles, &newer.char_styles, styles);
n += shared(&self.para_styles, &newer.para_styles, styles);
n += shared(&self.saved_selections, &newer.saved_selections, |s| {
vec_bytes(s) + s.iter().map(|s: &SavedSelection| s.name.capacity() + vec_bytes(&s.objects)).sum::<usize>()
});
n += shared(&self.variables, &newer.variables, |v| {
// Map entries: the key and value plus a share of the tree's nodes.
let entry = 2 * size_of::<usize>();
vec_bytes(&v.variables)
+ v.variables.iter().map(|v| v.name.capacity()).sum::<usize>()
+ vec_bytes(&v.datasets)
+ v.datasets.iter().map(|d| d.name.capacity() + d.values.keys().map(|k| entry + k.capacity() + 32).sum::<usize>()).sum::<usize>()
+ v.bindings.values().map(|b| entry + 8 + b.iter().map(|s| entry + s.capacity()).sum::<usize>()).sum::<usize>()
});
n += shared(&self.unknown, &newer.unknown, map_bytes);
n
}
}
/// One estimate in progress: two documents walked side by side.
struct Walk<'a> {
docs: [&'a Document; 2],
/// For each side, every node the other document holds (by address), built the first time a
/// node of that side that the other may share has no counterpart in place: then a node moved
/// elsewhere (grouped, say) is still found shared.
seen: [Option<HashSet<usize>>; 2],
/// For each side, what it keeps that the other doesn't.
bytes: [usize; 2],
}
impl Walk<'_> {
/// Two versions of a child list: the nodes in the same place on both sides (the same `Arc`, or
/// versions of the same object) are compared in place; the others (inserted, deleted, moved)
/// may still be shared from elsewhere.
fn list(&mut self, a: &[Arc<Node>], b: &[Arc<Node>]) {
let same = |x: &Arc<Node>, y: &Arc<Node>| Arc::ptr_eq(x, y) || x.id == y.id;
// In place from the start and from the end: an insertion or deletion shifts only what is
// between.
let head = a.iter().zip(b).take_while(|(x, y)| same(x, y)).count();
let (a_rest, b_rest) = (a.get(head..).unwrap_or_default(), b.get(head..).unwrap_or_default());
let tail = a_rest.iter().rev().zip(b_rest.iter().rev()).take_while(|(x, y)| same(x, y)).count();
for (x, y) in a.iter().zip(b).take(head).chain(a_rest.iter().rev().zip(b_rest.iter().rev()).take(tail)) {
self.pair(x, y);
}
let a_mid = a_rest.get(..a_rest.len().saturating_sub(tail)).unwrap_or_default();
let b_mid = b_rest.get(..b_rest.len().saturating_sub(tail)).unwrap_or_default();
for i in 0..a_mid.len().max(b_mid.len()) {
match (a_mid.get(i), b_mid.get(i)) {
(Some(x), Some(y)) if same(x, y) => self.pair(x, y),
(x, y) => {
x.into_iter().for_each(|x| self.alone(0, x));
y.into_iter().for_each(|y| self.alone(1, y));
}
}
}
}
/// Node `a` and `b` in the same place on the two sides: nothing when they are the same `Arc`,
/// both nodes and what under them isn't shared when they are versions of the same object.
fn pair(&mut self, a: &Arc<Node>, b: &Arc<Node>) {
if Arc::ptr_eq(a, b) {
return;
}
if a.id != b.id {
self.alone(0, a);
self.alone(1, b);
return;
}
self.bytes[0] += node_bytes(a, Some(b));
self.bytes[1] += node_bytes(b, Some(a));
match (a.children(), b.children()) {
(Some(x), Some(y)) => self.list(x, y),
(x, y) => {
x.into_iter().flatten().for_each(|c| self.alone(0, c));
y.into_iter().flatten().for_each(|c| self.alone(1, c));
}
}
match (&a.extras().mask, &b.extras().mask) {
(Some(x), Some(y)) => self.pair(&x.art, &y.art),
(x, y) => {
x.iter().for_each(|m| self.alone(0, &m.art));
y.iter().for_each(|m| self.alone(1, &m.art));
}
}
}
/// Node `n` of side `side` without a counterpart in place, and what under it the other side
/// doesn't hold.
fn alone(&mut self, side: usize, n: &Arc<Node>) {
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<Node>) -> 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<Node>, out: &mut HashSet<usize>) {
// A node already seen was entered already (a node shared within the document).
if !out.insert(Arc::as_ptr(n) as usize) {
return;
}
for c in n.children().into_iter().flatten() {
add(c, out);
}
if let Some(m) = &n.extras().mask {
add(&m.art, out);
}
}
let mut out = HashSet::new();
let roots = other.layers.iter().chain(other.symbols.iter().map(|s| &s.art)).chain(other.patterns.iter().flat_map(|p| &p.art));
for n in roots {
add(n, &mut out);
}
out
});
seen.contains(&(Arc::as_ptr(n) as usize))
}
}
/// One node's own heap: the node, and what it owns besides its children and mask art (counted
/// as nodes of their own). Its appearance counts unless `other`, its other version, shares it.
fn node_bytes(n: &Node, other: Option<&Node>) -> usize {
fn boxed<T>(b: &Option<Box<T>>) -> usize {
b.as_ref().map_or(0, |_| ALLOC + size_of::<T>())
}
let mut b = ALLOC + ARC + size_of::<Node>();
b += n.name.as_ref().map_or(0, String::capacity);
if !other.is_some_and(|m| Shared::ptr_eq(&m.appearance, &n.appearance)) {
b += ALLOC + ARC + appearance_heap(&n.appearance);
}
if let Some(e) = n.extras_box() {
b += ALLOC + size_of::<NodeExtras>();
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::<Value>() + json_bytes(t));
}
b += match &n.kind {
NodeKind::Path { path, live, .. } => path_bytes(path) + boxed(live),
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => vec_bytes(children),
NodeKind::Blend { children, .. } => vec_bytes(children) + ALLOC + size_of::<BlendSpec>(),
NodeKind::Envelope { content, .. } => vec_bytes(content) + 2 * ALLOC + size_of::<EnvelopeKind>() + size_of::<Affine>(),
NodeKind::Repeat(r) => vec_bytes(&r.source) + OTHER_KIND,
NodeKind::Text(t) => ALLOC + text_bytes(t),
NodeKind::Image(im) => ALLOC + size_of::<ImageObject>() + im.key.capacity(),
NodeKind::SymbolInstance { symbol, .. } => symbol.capacity(),
_ => OTHER_KIND,
};
b
}
fn path_bytes(p: &PathData) -> usize {
let anchors: usize =
p.subpaths.iter().map(|s| if s.anchors.capacity() == 0 { 0 } else { ALLOC + s.anchors.capacity() * size_of::<Anchor>() }).sum();
anchors + if p.subpaths.capacity() == 0 { 0 } else { ALLOC + p.subpaths.capacity() * size_of::<SubPath>() }
}
fn text_bytes(t: &TextObject) -> usize {
size_of::<TextObject>()
+ vec_bytes(&t.runs)
+ t.runs.iter().map(|r: &TextRun| r.text.capacity()).sum::<usize>()
+ t.paras.capacity() * size_of::<ParaStyle>()
+ vec_bytes(&t.wrap)
+ vec_bytes(&t.cached_baselines)
}
/// The heap an appearance owns (not the appearance itself).
fn appearance_heap(a: &Appearance) -> usize {
size_of::<Appearance>()
+ vec_bytes(&a.items)
+ a.items
.iter()
.map(|i| match i {
AppearanceItem::Fill(f) => paint_heap(&f.paint),
AppearanceItem::Stroke(s) => paint_heap(&s.paint),
})
.sum::<usize>()
+ vec_bytes(&a.effects)
+ a.effects.iter().map(|e: &Effect| e.id.capacity() + json_bytes(&e.params)).sum::<usize>()
}
fn paint_heap(p: &Paint) -> usize {
match p {
Paint::Solid { swatch, .. } => swatch.as_ref().map_or(0, String::capacity),
Paint::Gradient(g) => ALLOC + size_of_val(&**g) + vec_bytes(&g.gradient.stops),
Paint::Pattern { pattern, .. } => pattern.capacity(),
Paint::None => 0,
}
}
fn swatches_bytes(s: &[Swatch]) -> usize {
ALLOC + std::mem::size_of_val(s) + s.iter().map(|w| w.name.capacity() + paint_heap(&w.paint)).sum::<usize>()
}
/// A vector's buffer (not what its elements own).
fn vec_bytes<T>(v: &Vec<T>) -> usize {
if v.capacity() == 0 { 0 } else { ALLOC + v.capacity() * size_of::<T>() }
}
fn map_bytes(m: &serde_json::Map<String, Value>) -> usize {
m.iter().map(|(k, v)| ALLOC + 4 * size_of::<usize>() + k.capacity() + size_of::<Value>() + json_bytes(v)).sum()
}
/// The heap a JSON value owns.
fn json_bytes(v: &Value) -> usize {
match v {
Value::String(s) => s.capacity(),
Value::Array(a) => vec_bytes(a) + a.iter().map(json_bytes).sum::<usize>(),
Value::Object(m) => map_bytes(m),
_ => 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_geom::shapes;
fn doc_with_rects(n: usize) -> Document {
let mut d = Document::new(1000.0, 1000.0);
let layer = d.layers[0].id;
for i in 0..n {
let id = d.alloc_id();
let r = shapes::rectangle(kurbo::Rect::new(i as f64, 0.0, i as f64 + 10.0, 10.0));
d.insert(Some(layer), usize::MAX, Node::path(id, r, Appearance::default_art())).unwrap();
}
d
}
#[test]
fn an_unchanged_copy_keeps_nothing_of_its_own() {
let d = doc_with_rects(10);
let copy = d.clone();
// Only the document itself and its own lists: no node, no shared resource.
assert!(d.retained_bytes(&copy) < 2048, "{}", d.retained_bytes(&copy));
assert_eq!(d.retained_bytes(&d), 0);
}
/// Moving every rectangle: each keeps its old node and anchors (its appearance stays shared).
/// The process grew by about 775 bytes per moved rectangle when measured (#1244), about 490
/// once nodes kept their rarely set parts out of line.
#[test]
fn moving_rectangles_costs_their_nodes() {
let n = 1000;
let before = doc_with_rects(n);
let mut after = before.clone();
let ids: Vec<_> = after.layers[0].children().unwrap().iter().map(|c| c.id).collect();
for id in ids {
let node = after.node_mut(id).unwrap();
if let NodeKind::Path { path, .. } = &mut node.kind {
for a in path.subpaths.iter_mut().flat_map(|s| s.anchors.iter_mut()) {
a.p.x += 5.0;
}
}
}
let per = before.retained_bytes(&after) / n;
assert!((400..1200).contains(&per), "{per} bytes per moved rectangle");
}
#[test]
fn a_grouped_object_is_found_shared() {
let before = doc_with_rects(100);
let mut after = before.clone();
let ids: Vec<_> = after.layers[0].children().unwrap().iter().map(|c| c.id).collect();
// Put the rectangles in a group (the same Arcs, elsewhere).
let gid = after.alloc_id();
let layer = Arc::make_mut(&mut after.layers[0]);
let kids = std::mem::take(layer.children_mut().unwrap());
layer.children_mut().unwrap().push(Arc::new(Node::group(gid, kids)));
assert_eq!(ids.len(), 100);
let bytes = before.retained_bytes(&after);
assert!(bytes < 8192, "only the old layer and its child list: {bytes}");
}
/// One walk gives both ways, each what a walk the other way gives; an insertion and a deletion
/// cost the side that has the objects.
#[test]
fn both_ways_at_once() {
let before = doc_with_rects(100);
let mut after = before.clone();
let layer = after.layers[0].id;
for at in [0, 50, usize::MAX] {
let id = after.alloc_id();
let r = shapes::rectangle(kurbo::Rect::new(0.0, 0.0, 10.0, 10.0));
after.insert(Some(layer), at, Node::path(id, r, Appearance::default_art())).unwrap();
}
let (kept, added) = before.retained_bytes_each(&after);
assert_eq!((kept, added), (before.retained_bytes(&after), after.retained_bytes(&before)));
assert_eq!(after.retained_bytes_each(&before), (added, kept));
// The old layer and its list are the old version's own; the new one holds three new nodes.
assert!(kept < 4096, "{kept}");
assert!(added > kept + 3 * 300, "{added} for three new rectangles");
}
#[test]
fn a_resource_counts_only_when_changed() {
let before = doc_with_rects(1);
let mut after = before.clone();
let same = before.retained_bytes(&after);
after.swatches.push(before.swatches[1].clone());
assert!(before.retained_bytes(&after) > same + 1000, "the old swatch list is the old version's own");
}
}

View File

@ -394,7 +394,9 @@ pub struct ImageObject {
pub placement: crate::links::PlacementOptions,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
/// Written with its `type` first, then its fields (an internally tagged enum); read by
/// [`kind_de`], which takes the fields straight from the file when `type` comes first.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum NodeKind {
/// A top-level layer or sublayer.
@ -427,8 +429,9 @@ pub enum NodeKind {
path: PathData,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
rule: FillRule,
/// Boxed: few paths are live shapes, and the shape is larger than the rest of the variant.
#[serde(default, skip_serializing_if = "Option::is_none")]
live: Option<LiveShape>,
live: Option<Box<LiveShape>>,
/// This path acts as a clipping path inside a clip group.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
clipping: bool,
@ -443,7 +446,8 @@ pub enum NodeKind {
rule: FillRule,
},
Text(Box<TextObject>),
Image(ImageObject),
/// Boxed, as the other large kinds: a node is as large as its largest kind.
Image(Box<ImageObject>),
SymbolInstance {
symbol: String,
xf: Affine,
@ -453,12 +457,12 @@ pub enum NodeKind {
Blend {
children: Vec<Arc<Node>>,
#[serde(default)]
spec: BlendSpec,
spec: Box<BlendSpec>,
},
/// Live envelope distortion of `content`.
Envelope {
content: Vec<Arc<Node>>,
kind: EnvelopeKind,
kind: Box<EnvelopeKind>,
/// Envelope Options → Fidelity (0–100).
#[serde(default = "crate::live::default_fidelity")]
fidelity: f64,
@ -472,7 +476,7 @@ pub enum NodeKind {
/// the page axes can't stand for (rotation, shear, reflection). The content maps from its
/// bounds in this frame, and a warp bends along it.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
frame: Affine,
frame: Box<Affine>,
},
/// Gradient mesh.
Mesh(GradientMesh),
@ -491,72 +495,328 @@ fn one() -> f32 {
}
/// One object in the tree. Children are `Arc`s so documents share structure between undo states.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
///
/// What most objects have is kept inline; what few have is in [`NodeExtras`], allocated only for
/// the objects that have any of it: every undo step of an edit copies the nodes it changed, so a
/// small node is a cheap step. Files keep the flat field list either way: a node is written field
/// by field from where each lives, and read through a flat `NodeWire`.
#[derive(Clone, Debug, PartialEq, Deserialize)]
#[serde(from = "NodeWire")]
pub struct Node {
pub id: NodeId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[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<Appearance>,
/// Opacity mask (Transparency panel). Its art lives here, outside the layer tree.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mask: Option<Box<OpacityMask>>,
/// 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<Box<serde_json::Value>>,
/// 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<crate::text::TextWrap>,
/// Graph object: the group's children are generated from this spec (Object → Graph).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub graph: Option<Box<crate::graph::GraphSpec>>,
/// 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<u32>,
/// Editable Shaper composition; the original art is retained in its source child.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shaper: Option<Box<crate::shaper::ShaperSpec>>,
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<u32>,
/// Attributes panel: centre point display, image map, URL and note (`None`: all defaults).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attrs: Option<Box<ObjectAttributes>>,
/// 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<Box<crate::SliceOptions>>,
/// 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<Box<OpacityMask>>,
/// 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<Box<serde_json::Value>>,
/// Object → Text Wrap: area type below this object (in the same layer) flows around it.
pub wrap: Option<crate::text::TextWrap>,
/// Graph object: the group's children are generated from this spec (Object → Graph).
pub graph: Option<Box<crate::graph::GraphSpec>>,
/// 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<u32>,
/// Editable Shaper composition; the original art is retained in its source child.
pub shaper: Option<Box<crate::shaper::ShaperSpec>>,
/// 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<u32>,
/// Attributes panel: centre point display, image map, URL and note (`None`: all defaults).
pub attrs: Option<Box<ObjectAttributes>>,
/// Object → Slice → Make: the object is an object slice (its slice follows its bounds) with
/// these options.
pub slice: Option<Box<crate::SliceOptions>>,
/// Object › Perspective: the perspective grid plane the object is attached to.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub perspective: Option<Box<crate::PerspectiveAttachment>>,
}
/// 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<Box<NodeExtras>>);
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<Box<OpacityMask>>) -> 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<Box<NodeExtras>>,
/// 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<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
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<String>,
#[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<Appearance>,
#[serde(default)]
mask: Option<Box<OpacityMask>>,
#[serde(default)]
trace: Option<Box<serde_json::Value>>,
#[serde(default)]
wrap: Option<crate::text::TextWrap>,
#[serde(default)]
graph: Option<Box<crate::graph::GraphSpec>>,
#[serde(default)]
series_index: Option<u32>,
#[serde(default)]
shaper: Option<Box<crate::shaper::ShaperSpec>>,
kind: NodeKind,
#[serde(default)]
graphic_style: Option<u32>,
#[serde(default)]
attrs: Option<Box<ObjectAttributes>>,
#[serde(default)]
slice: Option<Box<crate::SliceOptions>>,
#[serde(default)]
bbox_angle: f64,
#[serde(default)]
perspective: Option<Box<crate::PerspectiveAttachment>>,
}
impl From<NodeWire> for Node {
fn from(w: NodeWire) -> Self {
let extras = NodeExtras {
mask: w.mask,
trace: w.trace,
wrap: w.wrap,
graph: w.graph,
series_index: w.series_index,
shaper: w.shaper,
graphic_style: w.graphic_style,
attrs: w.attrs,
slice: w.slice,
perspective: w.perspective,
};
Node {
id: w.id,
name: w.name,
visible: w.visible,
locked: w.locked,
opacity: w.opacity,
blend: w.blend,
isolate: w.isolate,
knockout: w.knockout,
knockout_shape: w.knockout_shape,
appearance: w.appearance,
kind: w.kind,
bbox_angle: w.bbox_angle,
extras: Extras((extras != NO_EXTRAS).then(|| Box::new(extras))),
}
}
}
/// Opacity mask: the luminance of the mask art sets the object's opacity (white = opaque).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct OpacityMask {
@ -595,18 +855,9 @@ impl Node {
knockout: Knockout::Neutral,
knockout_shape: false,
appearance: Appearance::default().into(),
mask: None,
trace: None,
wrap: None,
graph: None,
series_index: None,
shaper: None,
kind,
graphic_style: None,
attrs: None,
slice: None,
bbox_angle: 0.0,
perspective: None,
extras: Extras::default(),
}
}
pub fn path(id: NodeId, path: PathData, appearance: Appearance) -> Self {
@ -691,7 +942,7 @@ impl Node {
&& !self.isolate
&& self.knockout == Knockout::Neutral
&& !self.knockout_shape
&& self.mask.is_none()
&& self.extras().mask.is_none()
}
/// Kind name as the Layers panel / Properties panel shows it.
pub fn kind_label(&self) -> &'static str {
@ -772,7 +1023,7 @@ impl Node {
}
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => children
.iter()
.skip(usize::from(self.shaper.is_some()))
.skip(usize::from(self.extras().shaper.is_some()))
.filter(|c| c.visible || !matches!(self.kind, NodeKind::Layer { .. }))
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric_bounds())),
NodeKind::Text(t) => self.projected(t.bounds()),
@ -782,7 +1033,7 @@ impl Node {
let b = crate::live::nodes_bounds(children);
vectorcraft_geom::union_opt(b, spec.spine.as_ref().and_then(|s| s.bounds()))
}
NodeKind::Envelope { content, kind, frame, .. } => crate::live::envelope_bounds(content, kind, *frame),
NodeKind::Envelope { content, kind, frame, .. } => crate::live::envelope_bounds(content, kind, **frame),
NodeKind::Mesh(m) => m.bounds(),
NodeKind::Repeat(r) => r.bounds(),
NodeKind::PlacedDocument(p) => Some(p.bounds()),
@ -806,7 +1057,7 @@ impl Node {
let o = self.appearance.outset();
children
.iter()
.skip(usize::from(self.shaper.is_some()))
.skip(usize::from(self.extras().shaper.is_some()))
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds()))
.map(|b| b.inflate(o, o))
}
@ -903,8 +1154,8 @@ impl Node {
for c in content.iter_mut() {
Arc::make_mut(c).transform_scaled(a, sc);
}
*frame = crate::live::envelope_frame(a * *frame);
match kind {
**frame = crate::live::envelope_frame(a * **frame);
match &mut **kind {
EnvelopeKind::Mesh { points, handles, .. } => {
for p in points.iter_mut() {
*p = a * *p;
@ -923,7 +1174,7 @@ impl Node {
NodeKind::Mesh(m) => m.transform(a),
NodeKind::Repeat(r) => r.transform(a, *sc),
}
if let Some(m) = &mut self.mask
if let Some(m) = self.extras_if_any().and_then(|e| e.mask.as_mut())
&& m.linked
{
Arc::make_mut(&mut m.art).transform_scaled(a, sc);
@ -1039,7 +1290,7 @@ impl Node {
}
}
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
for c in children.iter().skip(usize::from(self.shaper.is_some())).filter(|c| c.visible) {
for c in children.iter().skip(usize::from(self.extras().shaper.is_some())).filter(|c| c.visible) {
c.push_clip_shapes(text, out);
}
}
@ -1324,25 +1575,25 @@ impl ImageMap {
impl Node {
/// The URL this object links to, if any.
pub fn url(&self) -> Option<&str> {
self.attrs.as_deref().map(|a| a.url.as_str()).filter(|u| !u.is_empty())
self.extras().attrs.as_deref().map(|a| a.url.as_str()).filter(|u| !u.is_empty())
}
/// Whether the canvas shows this object's centre point when it is selected: as set in the
/// Attributes panel, else [`Self::shows_center_by_default`].
pub fn shows_center(&self) -> bool {
self.attrs.as_deref().and_then(|a| a.show_center).unwrap_or_else(|| self.shows_center_by_default())
self.extras().attrs.as_deref().and_then(|a| a.show_center).unwrap_or_else(|| self.shows_center_by_default())
}
/// Shapes drawn with the shape tools (live rectangles, ellipses and polygons) show their centre.
pub fn shows_center_by_default(&self) -> bool {
matches!(&self.kind, NodeKind::Path { live: Some(l), .. } if !matches!(l, LiveShape::Line { .. } | LiveShape::Path { .. }))
matches!(&self.kind, NodeKind::Path { live: Some(l), .. } if !matches!(**l, LiveShape::Line { .. } | LiveShape::Path { .. }))
}
/// Change this object's attributes with `f`; all-default attributes are dropped.
pub fn edit_attrs<R>(&mut self, f: impl FnOnce(&mut ObjectAttributes) -> R) -> R {
let mut a = self.attrs.take().unwrap_or_default();
let mut a = self.extras_mut().attrs.take().unwrap_or_default();
let r = f(&mut a);
self.attrs = (*a != ObjectAttributes::default()).then_some(a);
self.extras_mut().attrs = (*a != ObjectAttributes::default()).then_some(a);
r
}
}
@ -1358,7 +1609,7 @@ impl Node {
self.visible
&& (self.opacity < 1.0
|| self.blend != BlendMode::Normal
|| self.mask.as_ref().is_some_and(|m| !m.disabled)
|| self.extras().mask.as_ref().is_some_and(|m| !m.disabled)
|| effects(&self.appearance.effects)
|| self
.appearance
@ -1385,10 +1636,281 @@ impl Node {
}
}
/// Reading a [`NodeKind`]. Derived, an internally tagged enum buffers each kind (with every
/// object inside a group, again at each level of nesting) before reading it, since `type` may come
/// anywhere among its fields. Files have it first (it is written first): then the kind is read
/// straight from the file. Otherwise (hand-written files, values whose keys are sorted) the
/// fields before `type` are buffered, and only those.
mod kind_de {
use std::fmt;
use std::sync::Arc;
use serde::de::value::{MapAccessDeserializer, StringDeserializer};
use serde::de::{self, DeserializeSeed, Deserializer, EnumAccess, IntoDeserializer, MapAccess, VariantAccess, Visitor};
use serde::{Deserialize, forward_to_deserialize_any};
use vectorcraft_geom::{Affine, FillRule, PathData};
use super::{LayerColor, LiveShape, Node, NodeKind, yes};
use crate::live::{BlendSpec, EnvelopeKind, GradientMesh};
use crate::pattern::RepeatSpec;
use crate::text::TextObject;
/// The kinds and their fields as [`NodeKind`] has them, with the defaults its field attributes
/// note (the compiler checks that every field is here), read externally tagged: the kind from
/// `type`, its fields from the rest of the object ([`Tagged`]).
#[derive(Deserialize)]
#[serde(remote = "NodeKind", rename_all = "lowercase")]
enum KindDef {
Layer {
color: LayerColor,
#[serde(default)]
template: bool,
#[serde(default = "yes")]
printable: bool,
children: Vec<Arc<Node>>,
#[serde(default)]
clip: bool,
#[serde(default = "yes")]
preview: bool,
#[serde(default)]
dim_images: Option<u8>,
},
Group {
children: Vec<Arc<Node>>,
#[serde(default)]
clip: bool,
},
Path {
path: PathData,
#[serde(default)]
rule: FillRule,
#[serde(default)]
live: Option<Box<LiveShape>>,
#[serde(default)]
clipping: bool,
#[serde(default)]
guide: bool,
},
Compound {
children: Vec<Arc<Node>>,
#[serde(default)]
rule: FillRule,
},
Text(Box<TextObject>),
Image(Box<super::ImageObject>),
SymbolInstance {
symbol: String,
xf: Affine,
},
Blend {
children: Vec<Arc<Node>>,
#[serde(default)]
spec: Box<BlendSpec>,
},
Envelope {
content: Vec<Arc<Node>>,
kind: Box<EnvelopeKind>,
#[serde(default = "crate::live::default_fidelity")]
fidelity: f64,
#[serde(default)]
editing: bool,
#[serde(default)]
options: crate::live::EnvelopeOptions,
#[serde(default)]
frame: Box<Affine>,
},
Mesh(GradientMesh),
Repeat(RepeatSpec),
PlacedDocument(Box<crate::placed_document::PlacedDocument>),
}
impl<'de> Deserialize<'de> for NodeKind {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
d.deserialize_map(KindVisitor)
}
}
struct KindVisitor;
impl<'de> Visitor<'de> for KindVisitor {
type Value = NodeKind;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("an object kind (a map with `type`)")
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<NodeKind, A::Error> {
let mut before = vec![];
while let Some(key) = map.next_key::<String>()? {
if key == "type" {
let tag: String = map.next_value()?;
return KindDef::deserialize(Tagged { tag, map: Buffered { before: before.into_iter(), value: None, map } });
}
before.push((key, map.next_value::<serde_json::Value>()?));
}
Err(de::Error::missing_field("type"))
}
}
/// The fields read before `type` (buffered), then the rest of the object.
struct Buffered<A> {
before: std::vec::IntoIter<(String, serde_json::Value)>,
value: Option<serde_json::Value>,
map: A,
}
impl<'de, A: MapAccess<'de>> MapAccess<'de> for Buffered<A> {
type Error = A::Error;
fn next_key_seed<K: DeserializeSeed<'de>>(&mut self, seed: K) -> Result<Option<K::Value>, A::Error> {
match self.before.next() {
Some((key, value)) => {
self.value = Some(value);
seed.deserialize(StringDeserializer::<A::Error>::new(key)).map(Some)
}
None => self.map.next_key_seed(seed),
}
}
fn next_value_seed<V: DeserializeSeed<'de>>(&mut self, seed: V) -> Result<V::Value, A::Error> {
match self.value.take() {
Some(value) => seed.deserialize(value).map_err(de::Error::custom),
None => self.map.next_value_seed(seed),
}
}
}
/// An object whose `type` was read: the kind is `tag`, its fields are the rest of `map`.
struct Tagged<A> {
tag: String,
map: A,
}
impl<'de, A: MapAccess<'de>> Deserializer<'de> for Tagged<A> {
type Error = A::Error;
fn deserialize_any<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, A::Error> {
visitor.visit_enum(self)
}
forward_to_deserialize_any! {
bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string bytes byte_buf
option unit unit_struct newtype_struct seq tuple tuple_struct map struct enum
identifier ignored_any
}
}
impl<'de, A: MapAccess<'de>> EnumAccess<'de> for Tagged<A> {
type Error = A::Error;
type Variant = Fields<A>;
fn variant_seed<V: DeserializeSeed<'de>>(self, seed: V) -> Result<(V::Value, Fields<A>), A::Error> {
let kind = seed.deserialize(self.tag.into_deserializer())?;
Ok((kind, Fields(self.map)))
}
}
/// A kind's fields: the rest of the object.
struct Fields<A>(A);
impl<'de, A: MapAccess<'de>> VariantAccess<'de> for Fields<A> {
type Error = A::Error;
fn unit_variant(self) -> Result<(), A::Error> {
Err(de::Error::invalid_type(de::Unexpected::UnitVariant, &"an object kind with fields"))
}
fn newtype_variant_seed<T: DeserializeSeed<'de>>(self, seed: T) -> Result<T::Value, A::Error> {
seed.deserialize(MapAccessDeserializer::new(self.0))
}
fn tuple_variant<V: Visitor<'de>>(self, _: usize, _: V) -> Result<V::Value, A::Error> {
Err(de::Error::invalid_type(de::Unexpected::TupleVariant, &"an object kind with fields"))
}
fn struct_variant<V: Visitor<'de>>(self, _: &'static [&'static str], visitor: V) -> Result<V::Value, A::Error> {
visitor.visit_map(self.0)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A node writes its rarely set parts among the others, in the file's field order, and reads
/// them back into its extras; a node without any keeps none.
#[test]
fn extras_are_written_flat_in_order() {
let mut n = Node::path(NodeId(7), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art());
n.name = Some("n".into());
n.knockout_shape = true;
n.bbox_angle = 30.0;
{
let mut e = n.extras_mut();
e.mask = Some(Box::new(OpacityMask::new(Node::group(NodeId(8), vec![]), true)));
e.trace = Some(Box::new(serde_json::json!({"preset": "p"})));
e.series_index = Some(2);
e.graphic_style = Some(3);
e.attrs = Some(Box::default());
e.slice = Some(Box::default());
e.perspective = Some(Box::new(crate::PerspectiveAttachment::new("left", 1.0)));
}
/// The top-level keys of a JSON object, in the order written.
struct Keys(Vec<String>);
impl<'de> Deserialize<'de> for Keys {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct V;
impl<'de> serde::de::Visitor<'de> for V {
type Value = Keys;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("an object")
}
fn visit_map<A: serde::de::MapAccess<'de>>(self, mut a: A) -> Result<Keys, A::Error> {
let mut out = vec![];
while let Some(k) = a.next_key::<String>()? {
a.next_value::<serde::de::IgnoredAny>()?;
out.push(k);
}
Ok(Keys(out))
}
}
d.deserialize_map(V)
}
}
let text = serde_json::to_string(&n).unwrap();
let keys = serde_json::from_str::<Keys>(&text).unwrap().0;
let v: serde_json::Value = serde_json::from_str(&text).unwrap();
let order = [
"id",
"name",
"knockout_shape",
"appearance",
"mask",
"trace",
"series_index",
"kind",
"graphic_style",
"attrs",
"slice",
"bbox_angle",
"perspective",
];
assert_eq!(keys, order);
assert_eq!(serde_json::from_value::<Node>(v).unwrap(), n);
let plain = Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art());
let back: Node = serde_json::from_value(serde_json::to_value(&plain).unwrap()).unwrap();
assert!(back.extras_box().is_none());
// Clearing the last extra lets them go; clearing one a node doesn't have allocates none.
n.extras_mut().mask = None;
assert!(n.extras_box().is_some());
*n.extras_mut() = NodeExtras::default();
assert!(n.extras_box().is_none());
n.extras_mut().mask = None;
assert!(n.extras_box().is_none());
assert_eq!(n.extras(), &NodeExtras::default());
}
#[test]
fn rect_live_shape_roundtrip() {
let l = LiveShape::Rectangle { w: 10.0, h: 20.0, radii: [0.0; 4], kinds: Default::default(), xf: Affine::translate((5.0, 5.0)) };
@ -1454,13 +1976,14 @@ mod tests {
let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), xf };
let mut n = Node::path(NodeId(1), live.to_path(), Appearance::default_art());
if let NodeKind::Path { live: slot, .. } = &mut n.kind {
*slot = Some(live);
*slot = Some(Box::new(live));
}
n
}
fn live_of(n: &Node) -> (f64, f64, [f64; 4], Affine) {
let NodeKind::Path { live: Some(LiveShape::Rectangle { w, h, radii, xf, .. }), .. } = &n.kind else { panic!("not a live rectangle") };
let NodeKind::Path { live: Some(l), .. } = &n.kind else { panic!("not a live shape") };
let LiveShape::Rectangle { w, h, radii, xf, .. } = &**l else { panic!("not a live rectangle") };
(*w, *h, *radii, *xf)
}
@ -1595,7 +2118,7 @@ mod tests {
|n| n.knockout = Knockout::On,
|n| n.knockout = Knockout::Off,
|n| n.knockout_shape = true,
|n| n.mask = Some(Box::new(OpacityMask::new(n.clone(), true))),
|n| n.extras_mut().mask = Some(Box::new(OpacityMask::new(n.clone(), true))),
];
for tweak in tweaks {
let mut n = p();
@ -1651,4 +2174,50 @@ mod tests {
p.appearance.stroke_mut().unwrap().join = crate::appearance::LineJoin::Round;
assert_eq!(p.visual_bounds(), Some(Rect::new(-0.5, -0.5, 10.5, 10.5)));
}
/// A group holding a path, a compound, an image and a nested group, as a file writes it.
fn nested_group() -> Node {
let path = |id| Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
let image = ImageObject { key: "k".into(), width: 2, height: 3, xf: Affine::IDENTITY, link: None, placement: Default::default() };
let inner = Node::new(NodeId(5), NodeKind::Group { children: vec![Arc::new(path(6))], clip: true });
let compound = Node::new(NodeId(3), NodeKind::Compound { children: vec![Arc::new(path(4))], rule: FillRule::EvenOdd });
let kids = vec![Arc::new(path(2)), Arc::new(compound), Arc::new(Node::new(NodeId(7), NodeKind::Image(Box::new(image)))), Arc::new(inner)];
Node::new(NodeId(1), NodeKind::Group { children: kids, clip: false })
}
#[test]
fn kinds_read_back_with_type_first_or_anywhere() {
let n = nested_group();
let json = serde_json::to_string(&n).unwrap();
assert!(json.contains(r#""kind":{"type":"group","children":["#), "written with `type` first: {json}");
assert_eq!(serde_json::from_str::<Node>(&json).unwrap(), n);
// Through a JSON value, whose keys are sorted: `type` comes after `children`.
let v = serde_json::to_value(&n).unwrap();
assert_eq!(serde_json::from_value::<Node>(v.clone()).unwrap(), n);
assert_eq!(serde_json::from_str::<Node>(&v.to_string()).unwrap(), n);
// `type` last, in the middle, and keys a newer version added (ignored, as before).
let kind = r#"{"children":[],"x":{"deep":[1,2]},"clip":true,"type":"group"}"#;
assert_eq!(serde_json::from_str::<NodeKind>(kind).unwrap(), NodeKind::Group { children: vec![], clip: true });
let kind = r#"{"symbol":"S","type":"symbolinstance","later":1,"xf":[1,0,0,1,5,6]}"#;
assert_eq!(
serde_json::from_str::<NodeKind>(kind).unwrap(),
NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((5.0, 6.0)) }
);
let kind = r#"{"width":2,"type":"image","key":"k","height":3,"xf":[1,0,0,1,0,0]}"#;
assert!(matches!(serde_json::from_str::<NodeKind>(kind).unwrap(), NodeKind::Image(i) if i.key == "k" && i.height == 3));
// Defaults as before.
let NodeKind::Layer { printable, preview, template, .. } =
serde_json::from_str(r#"{"type":"layer","color":{"Preset":0},"children":[]}"#).unwrap()
else {
panic!("a layer")
};
assert!(printable && preview && !template);
}
#[test]
fn bad_kinds_are_errors() {
for bad in [r#"{"children":[]}"#, r#"{"type":"nothing"}"#, r#"{"type":"group"}"#, r#"{"type":3,"children":[]}"#, r#"[]"#, r#""group""#] {
assert!(serde_json::from_str::<NodeKind>(bad).is_err(), "{bad}");
}
}
}

View File

@ -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<Homography> {
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());

View File

@ -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<Vec<u8>>) -> Option<Arc<ReadDocu
doc.images.insert("preview".into(), ImageBlob { mime: mime.into(), bytes: bytes.clone(), proxy: None });
let xf = Affine::scale_non_uniform(frame.width() / w as f64, frame.height() / h as f64);
let im = ImageObject { key: "preview".into(), width: w, height: h, xf, link: None, placement: Default::default() };
let art = Node::new(art_id(), NodeKind::Group { children: vec![Arc::new(Node::new(art_id(), NodeKind::Image(im)))], clip: false });
let art = Node::new(art_id(), NodeKind::Group { children: vec![Arc::new(Node::new(art_id(), NodeKind::Image(Box::new(im))))], clip: false });
let e = Arc::new(ReadDocument { key: p.key.clone(), preview: true, bytes: bytes.clone(), frame, art: Arc::new(art), doc: Arc::new(doc) });
cache().insert(e.clone());
Some(e)
@ -432,7 +432,7 @@ fn art_id() -> 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]));

View File

@ -34,6 +34,46 @@ impl<T: Clone> std::ops::DerefMut for Shared<T> {
}
}
impl<T: Clone> Shared<T> {
/// The value, taken out (copied only when another copy still shares it).
pub fn into_inner(self) -> T {
Arc::unwrap_or_clone(self.0)
}
}
/// `for x in std::mem::take(&mut doc.swatches)` takes the value, as it would the value itself.
impl<T: Clone + IntoIterator> IntoIterator for Shared<T> {
type Item = T::Item;
type IntoIter = T::IntoIter;
fn into_iter(self) -> Self::IntoIter {
self.into_inner().into_iter()
}
}
/// `for x in &doc.swatches` iterates the shared value, as it would the value itself.
impl<'a, T> IntoIterator for &'a Shared<T>
where
&'a T: IntoIterator,
{
type Item = <&'a T as IntoIterator>::Item;
type IntoIter = <&'a T as IntoIterator>::IntoIter;
fn into_iter(self) -> Self::IntoIter {
(&*self.0).into_iter()
}
}
/// `for x in &mut doc.swatches` makes the value this copy's own first, as any change does.
impl<'a, T: Clone> IntoIterator for &'a mut Shared<T>
where
&'a mut T: IntoIterator,
{
type Item = <&'a mut T as IntoIterator>::Item;
type IntoIter = <&'a mut T as IntoIterator>::IntoIter;
fn into_iter(self) -> Self::IntoIter {
Arc::make_mut(&mut self.0).into_iter()
}
}
impl<T> From<T> for Shared<T> {
fn from(v: T) -> Self {
Shared::new(v)
@ -70,3 +110,65 @@ impl<'de, T: Deserialize<'de>> Deserialize<'de> for Shared<T> {
T::deserialize(d).map(Shared::new)
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::text::TextStyleDef;
use crate::variables::{Variable, VariableKind};
use crate::{Document, NodeId, SavedSelection};
/// A document with every shared resource set, changed only through `push`/`insert` (as code
/// written for the plain values does).
fn doc_with_resources() -> Document {
let mut d = Document::new(100.0, 100.0);
let swatch = d.swatches[1].clone();
d.swatches.push(swatch);
d.swatch_groups.truncate(1);
d.graphic_styles.truncate(2);
d.char_styles.push(TextStyleDef { name: "Emphasis".into(), attrs: json!({"size": 14}).as_object().cloned().unwrap_or_default() });
d.para_styles.push(TextStyleDef { name: "Body".into(), attrs: Default::default() });
d.saved_selections.push(SavedSelection { name: "Saved".into(), objects: vec![NodeId(1)] });
d.variables.variables.push(Variable { name: "Name".into(), kind: VariableKind::Text });
d.variables.bindings.entry(NodeId(1)).or_default().insert("Name".into());
d.unknown.insert("foreign".into(), json!({"keep": [1, 2]}));
d
}
/// Shared document resources save as the values themselves: the saved form is unchanged.
#[test]
fn shared_resources_save_and_load_as_their_values() {
let d = doc_with_resources();
let v = serde_json::to_value(&d).unwrap();
assert_eq!(v["swatches"], serde_json::to_value(&*d.swatches).unwrap());
assert_eq!(v["swatch_groups"], serde_json::to_value(&*d.swatch_groups).unwrap());
assert_eq!(v["graphic_styles"], serde_json::to_value(&*d.graphic_styles).unwrap());
assert_eq!(v["char_styles"], json!([{"name": "Emphasis", "attrs": {"size": 14}}]));
assert_eq!(v["para_styles"], json!([{"name": "Body", "attrs": {}}]));
assert_eq!(v["saved_selections"], json!([{"name": "Saved", "objects": [1]}]));
assert_eq!(v["variables"], json!({"variables": [{"name": "Name", "kind": "text"}], "bindings": {"1": ["Name"]}}));
assert_eq!(v["unknown"], json!({"foreign": {"keep": [1, 2]}}));
let text = serde_json::to_string(&d).unwrap();
let back: Document = serde_json::from_str(&text).unwrap();
assert_eq!(back, d);
assert_eq!(serde_json::to_string(&back).unwrap(), text, "saving again gives the same bytes");
// Empty ones are left out, as before.
let v = serde_json::to_value(Document::new(10.0, 10.0)).unwrap();
for k in ["char_styles", "para_styles", "saved_selections", "variables", "unknown"] {
assert!(v.get(k).is_none(), "{k} is left out when empty");
}
}
/// A copy of a document shares its resources until one copy changes one of them.
#[test]
fn copies_share_resources_until_changed() {
let a = doc_with_resources();
let mut b = a.clone();
assert!(super::Shared::ptr_eq(&a.swatches, &b.swatches) && super::Shared::ptr_eq(&a.unknown, &b.unknown));
b.swatches.pop();
assert!(!super::Shared::ptr_eq(&a.swatches, &b.swatches));
assert_eq!(a.swatches.len(), b.swatches.len() + 1, "the other copy keeps its own");
assert!(super::Shared::ptr_eq(&a.graphic_styles, &b.graphic_styles), "the rest stays shared");
}
}

View File

@ -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<Rect> {
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<Node>], 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));

View File

@ -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: "<b>hi</b>".into(), ..Default::default() }));
d.node_mut(a).unwrap().extras_mut().slice =
Some(Box::new(SliceOptions { kind: crate::SliceKind::NoImage, text: "<b>hi</b>".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();

View File

@ -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);
}
}

View File

@ -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

View File

@ -32,8 +32,8 @@ pub fn crop_marks_art(n: &Node) -> Option<Node> {
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)];
}

View File

@ -92,7 +92,7 @@ pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
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<Node> {
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<Node> {
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)
}

View File

@ -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<Value> {
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);

View File

@ -553,7 +553,7 @@ fn lp_expand(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
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<Value> {
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(_)));
}

View File

@ -142,8 +142,8 @@ impl Clipboard {
d.patterns = self.patterns.clone();
d.swatches.extend(self.swatches.iter().cloned());
d.graphic_styles.extend(self.graphic_styles.iter().cloned());
d.char_styles = self.char_styles.clone();
d.para_styles = self.para_styles.clone();
d.char_styles = self.char_styles.clone().into();
d.para_styles = self.para_styles.clone().into();
if !self.brushes.is_empty() {
let mut lib = vectorcraft_brush::library(&d);
for b in &self.brushes {

View File

@ -315,7 +315,7 @@ fn import_image(s: &mut Session, p: &Value) -> Result<Value> {
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 }))

View File

@ -107,10 +107,10 @@ fn attrs_refs(attrs: &Map<String, Value>, 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 {

View File

@ -157,7 +157,7 @@ fn draw_mode(s: &mut Session, p: &Value) -> Result<Value> {
}
fn path_kind(path: PathData, live: Option<LiveShape>) -> 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<Rect> {

View File

@ -439,7 +439,7 @@ struct Targets {
/// What `n` is when Liquify can't distort it (and doesn't look inside it).
fn not_liquified(n: &Node) -> Option<&'static str> {
Some(match &n.kind {
NodeKind::Group { .. } if n.graph.is_some() => "graphs",
NodeKind::Group { .. } if n.extras().graph.is_some() => "graphs",
NodeKind::Text(_) => "type",
NodeKind::SymbolInstance { .. } => "symbols",
NodeKind::Image(_) => "images",
@ -1198,6 +1198,7 @@ fn persp_draw(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(e) = st.history.undo.last_mut() {
e.label = "Draw in Perspective".into();
}
st.history.recost_last(&st.doc);
}
res?;
Ok(json!({ "id": id.0, "plane": plane.id() }))
@ -1251,7 +1252,7 @@ fn edit_text(s: &mut Session, p: &Value) -> Result<Value> {
let (Some(flat), Some(drawn)) = (t.bounds(), t.bounds().and_then(|b| h.map_rect_bbox(b))) else { return Ok(()) };
let shift = vectorcraft_geom::Affine::translate(drawn.center() - flat.center());
t.transform(shift);
if let Some(rec) = n.perspective.as_deref_mut() {
if let Some(rec) = n.extras_if_any().and_then(|e| e.perspective.as_deref_mut()) {
rec.projection = Some(h.then_after(&Homography::from_affine(shift.inverse())).to_array());
rec.editing = true;
}
@ -1268,8 +1269,10 @@ fn edit_text(s: &mut Session, p: &Value) -> Result<Value> {
/// in every undo state (the flat view is never an undo step of its own).
pub(crate) fn finish_edit_text(st: &mut crate::DocState, id: NodeId) {
let clear = |doc: &mut Arc<Document>| {
if doc.node(id).and_then(|n| n.perspective.as_deref()).is_some_and(|p| p.editing)
&& let Some(p) = Arc::make_mut(doc).node_mut(id).and_then(|n| n.perspective.as_deref_mut())
// Undo steps keep no lookup cache: look without building one, and drop the one an edit
// builds (the document now keeps its own).
if doc.node_uncached(id).and_then(|n| n.extras().perspective.as_deref()).is_some_and(|p| p.editing)
&& let Some(p) = Arc::make_mut(doc).node_mut(id).and_then(|n| n.extras_if_any().and_then(|e| e.perspective.as_deref_mut()))
{
p.editing = false;
}
@ -1281,6 +1284,7 @@ pub(crate) fn finish_edit_text(st: &mut crate::DocState, id: NodeId) {
}
for e in st.history.undo.iter_mut().chain(st.history.redo.iter_mut()) {
clear(&mut e.doc);
e.doc.drop_lookup_cache();
}
if let Some(it) = &mut st.interaction {
clear(&mut it.doc);

View File

@ -290,7 +290,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
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() {

View File

@ -402,9 +402,9 @@ fn edit_saved(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, format!("two saved selections would be named `{}`", x.name)));
}
}
if after != before {
if *before != after {
s.edit("Edit Selection", |d, _| {
d.saved_selections = after;
d.saved_selections = after.into();
Ok(())
})?;
}

View File

@ -214,12 +214,13 @@ fn undo(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc_mut()?;
let e = st.history.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?;
let label = e.label.clone();
let costs = e.costs();
let editing = st.doc.mask_edit.map(|m| m.layer);
st.history.redo.push(crate::HistoryEntry {
label: e.label,
doc: std::mem::replace(&mut st.doc, e.doc),
selection: std::mem::replace(&mut st.selection, e.selection),
});
let doc = std::mem::replace(&mut st.doc, e.doc);
let selection = std::mem::replace(&mut st.selection, Arc::unwrap_or_clone(e.selection));
let selection = shared_selection(selection, st.history.redo.last());
st.history.redo.push(crate::HistoryEntry::turned(e.label, doc, selection, &st.doc, costs));
st.history.trim();
st.revision += 1;
st.selection.prune(&st.doc);
super::maskedit::follow_history(st, editing);
@ -230,18 +231,27 @@ fn redo(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc_mut()?;
let e = st.history.redo.pop().ok_or_else(|| EngineError::Other("nothing to redo".into()))?;
let label = e.label.clone();
let costs = e.costs();
let editing = st.doc.mask_edit.map(|m| m.layer);
st.history.undo.push(crate::HistoryEntry {
label: e.label,
doc: std::mem::replace(&mut st.doc, e.doc),
selection: std::mem::replace(&mut st.selection, e.selection),
});
let doc = std::mem::replace(&mut st.doc, e.doc);
let selection = std::mem::replace(&mut st.selection, Arc::unwrap_or_clone(e.selection));
let selection = shared_selection(selection, st.history.undo.last());
st.history.undo.push(crate::HistoryEntry::turned(e.label, doc, selection, &st.doc, costs));
st.history.trim();
st.revision += 1;
st.selection.prune(&st.doc);
super::maskedit::follow_history(st, editing);
Ok(json!({ "redone": label }))
}
/// `selection` for an undo (redo) step, sharing that of `like`, the step beside it, when the same.
fn shared_selection(selection: vectorcraft_doc::Selection, like: Option<&crate::HistoryEntry>) -> Arc<vectorcraft_doc::Selection> {
match like {
Some(l) if *l.selection == selection => l.selection.clone(),
_ => Arc::new(selection),
}
}
/// Selected top-level objects in paint order, dropping any whose ancestor is also selected.
pub(crate) fn selected_roots(s: &Session) -> Result<Vec<NodeId>> {
let st = s.doc()?;

View File

@ -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() })))
}
}

View File

@ -82,7 +82,7 @@ fn collect(n: &Node, region: Rect, scale: f64, out: &mut Vec<Area>) {
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

View File

@ -283,8 +283,26 @@ pub fn open_bytes(s: &mut Session, name: &str, bytes: &[u8], path: Option<String
/// [`open_bytes`] with the `document.open` options in `p` ([`LoadOptions::from_params`]).
pub fn open_bytes_with(s: &mut Session, name: &str, bytes: &[u8], path: Option<String>, p: &Value) -> Result<Value> {
open_read(s, read_for_open(name, bytes, p)?, path)
}
/// A file read for `document.open` but not opened yet ([`read_for_open`], [`open_read`]).
pub struct ReadFile {
loaded: Loaded,
opts: LoadOptions,
}
/// The first half of [`open_bytes_with`]: read and decode the file, which needs no session (an
/// app may do it on another thread) → what [`open_read`] opens.
pub fn read_for_open(name: &str, bytes: &[u8], p: &Value) -> Result<ReadFile> {
let opts = LoadOptions::from_params("document.open", p)?;
open_loaded(s, load_with(name, bytes, &opts)?, path, &opts, false)
Ok(ReadFile { loaded: load_with(name, bytes, &opts)?, opts })
}
/// The second half of [`open_bytes_with`]: open a file [`read_for_open`] read as the new active
/// document (`path`: where it was read from) → `document.open`'s result.
pub fn open_read(s: &mut Session, file: ReadFile, path: Option<String>) -> Result<Value> {
open_loaded(s, file.loaded, path, &file.opts, false)
}
/// File → New from Template: any readable file (at `path`, if it is one: its links are looked for
@ -430,7 +448,8 @@ pub(super) fn raster_doc(name: &str, bytes: &[u8]) -> Result<Document> {
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)?;

View File

@ -53,15 +53,18 @@ use load::err;
pub(crate) use load::import_svg;
pub(crate) use load::native_file;
pub(crate) use load::source;
pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_template, raster_image};
pub use load::{
Loaded, RasterImage, ReadFile, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_read, open_template, raster_image,
read_for_open,
};
pub use load::{losses_summary, overwrite_losses};
pub(crate) use native::preview_png;
pub use native::with_compression_pref;
pub use pdfimport::{LoadOptions, page_document};
pub(crate) use save::job_for;
pub use save::{
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, export_folder, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates,
templates_dialog_folder, templates_folder,
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, export_folder, save_filters, save_format, save_job, save_plan, save_with,
stamp_save_dates, templates_dialog_folder, templates_folder,
};
pub use screens::{PRESETS as SCREEN_PRESETS, ScreenSize, preset_rows as screen_preset_rows};
pub(crate) use screens::{
@ -769,6 +772,13 @@ pub(crate) fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
write_atomic(std::path::Path::new(path), bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
/// [`write_file`] with what `fill` writes into the file (a save streamed into it).
#[cfg(not(target_arch = "wasm32"))]
pub fn write_file_with(path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()> {
may_write(path)?;
vectorcraft_format::write_atomic_with(std::path::Path::new(path), |f| fill(f)).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn create_dir(path: &str) -> Result<()> {
may_write(path)?;
@ -938,6 +948,11 @@ pub(crate) fn write_file(path: &str, _: &[u8]) -> Result<()> {
Err(no_fs(path))
}
#[cfg(target_arch = "wasm32")]
pub fn write_file_with(path: &str, _: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()> {
Err(no_fs(path))
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn create_dir(path: &str) -> Result<()> {
Err(no_fs(path))

View File

@ -135,6 +135,14 @@ pub(super) fn save_options(cmd: &str, f: &Format, doc: &Document, p: &Value) ->
/// The `.vectorcraft` file of `doc` with the options in `p`.
pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Vec<u8>> {
let mut out = Vec::new();
encode_to(cmd, f, doc, p, &mut out)?;
Ok(out)
}
/// [`encode`], written to `w` as it is made ([`vectorcraft_format::save_to`]: give it a buffered
/// writer).
pub(super) fn encode_to(cmd: &str, f: &Format, doc: &Document, p: &Value, w: impl std::io::Write) -> Result<()> {
let (mut so, extras) = save_options(cmd, f, doc, p)?;
// Include Linked Files keeps placed documents' files; older versions get their art.
let full = if so.include_linked || so.version < vectorcraft_format::VERSION {
@ -158,7 +166,7 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result
}
other => other,
};
vectorcraft_format::save_with(&native, &so).map_err(|e| bad(cmd, e.to_string()))
vectorcraft_format::save_to(&native, &so, w).map_err(|e| bad(cmd, e.to_string()))
}
/// The ICC files of the profiles `doc` is tagged with that were loaded from files (the built-in

View File

@ -6,6 +6,7 @@
//! the caller's writer (the file system here; a save panel or a browser download in the apps) and
//! makes the file the document's own (path, format, options, saved state) for Save and Save As.
use std::io::Write as _;
use std::sync::Arc;
use serde_json::{Map, Value, json};
@ -14,7 +15,6 @@ use vectorcraft_doc::Document;
use super::super::*;
use super::{
ArtboardPick, Encoded, Format, Loaded, encode_all, file_stem, format, format_for_name, load, read_file, with_compression_pref, write_encoded,
write_file,
};
use crate::{DocState, Prefs};
@ -300,6 +300,31 @@ pub struct SaveJob {
boards: Vec<usize>,
}
/// Writes the file at a path with what the filler it is given writes into it
/// ([`SaveJob::write_streamed`]).
pub type StreamWrite<'a> = &'a mut dyn FnMut(&str, &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()>;
/// Bytes gathered before a streamed save writes them to the file.
const STREAM_BUFFER: usize = 256 << 10;
/// A writer that counts the bytes written through it.
struct Counted<W> {
inner: W,
n: u64,
}
impl<W: std::io::Write> std::io::Write for Counted<W> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let n = self.inner.write(buf)?;
self.n += n as u64;
Ok(n)
}
fn flush(&mut self) -> std::io::Result<()> {
self.inner.flush()
}
}
/// The artboards a native or `.ai` save also writes to files of their own: with
/// `separateArtboards`, those `range` names (default all).
fn separate_boards(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Vec<usize>> {
@ -443,6 +468,16 @@ impl SaveJob {
&self.snapshot
}
/// The document the job writes (the snapshot, as the file keeps it).
pub(crate) fn doc(&self) -> &Arc<Document> {
&self.doc
}
/// Encode `doc` (the job's document, changed) as the job encodes its own: one file.
pub(crate) fn encode_doc(&self, doc: &Document) -> Result<Encoded> {
self.encode_one(doc)
}
/// Encode the file → it (and, with `separateArtboards`, a file per artboard after it), with
/// the warnings (what the format loses first, then the encoder's own notes: PDF options not
/// applied yet…).
@ -500,6 +535,53 @@ impl SaveJob {
Ok(out)
}
/// Is the file written as it is encoded by [`SaveJob::write_streamed`]? A native file (or
/// template) to a path, without a file per artboard.
pub fn streams(&self) -> bool {
self.plan.path.is_some() && matches!(self.plan.format.id, "vectorcraft" | "template") && self.boards.is_empty()
}
/// [`SaveJob::write`] through `stream`, which writes the file at a path with what the filler
/// it is given writes (atomically, as [`vectorcraft_format::write_atomic_with`]). A native
/// file goes into it as it is encoded ([`SaveJob::streams`]), never held whole in memory;
/// other files are encoded first, then written into it.
pub fn write_streamed(&self, stream: StreamWrite) -> Result<Value> {
let (Some(path), true) = (&self.plan.path, self.streams()) else {
return self.write(|p, b| stream(p, &mut |w| w.write_all(b)));
};
let (cmd, f) = (self.plan.mode.command(), self.plan.format);
let doc = self.doc.without_edit_modes();
let doc = match f.id {
// A native file flagged so opening it starts a new untitled document (as
// `encode_all` writes it).
"template" => {
let mut d = doc.into_owned();
d.template = true;
std::borrow::Cow::Owned(d)
}
_ => doc,
};
// The encoder's failure, kept apart from the writer's (which `stream` reports).
let mut failed = None;
let mut written = 0u64;
let mut fill = |w: &mut dyn std::io::Write| {
let mut out = Counted { inner: std::io::BufWriter::with_capacity(STREAM_BUFFER, w), n: 0 };
let r = super::native::encode_to(cmd, f, &doc, &self.params, &mut out);
written = out.n;
match r {
Ok(()) => out.inner.flush(),
Err(e) => {
failed = Some(e);
Err(std::io::Error::other("the file couldn't be encoded"))
}
}
};
let r = stream(path, &mut fill);
r.map_err(|e| failed.take().unwrap_or(e))?;
let warnings: Vec<String> = fidelity_warning(f).into_iter().collect();
Ok(json!({ "path": path, "bytes": written, "format": f.id, "warnings": warnings }))
}
/// Once written: Save and Save As (except to a template) make the file `st`'s own: path,
/// title, format, options, and the snapshot as the saved state (edits made since the job was
/// made keep the document modified).
@ -543,10 +625,14 @@ pub fn save_with(s: &mut Session, plan: SavePlan, write: impl FnMut(&str, &[u8])
Ok(out)
}
/// A save command: plan, then write with the file system.
/// A save command: plan, then write with the file system (a native file as it is encoded).
fn save(s: &mut Session, mode: SaveMode, p: &Value) -> Result<Value> {
let plan = save_plan(s, mode, p)?;
save_with(s, plan, write_file)
let job = save_job(s, plan)?;
let uid = s.doc()?.uid;
let out = job.write_streamed(&mut super::write_file_with)?;
job.complete(s, uid);
Ok(out)
}
fn can_revert(s: &Session) -> std::result::Result<(), String> {

View File

@ -43,7 +43,7 @@ fn image_bytes(w: u32, h: u32, format: image::ImageFormat) -> Vec<u8> {
fn image_of(s: &Session) -> vectorcraft_doc::ImageObject {
let doc = &s.doc().unwrap().doc;
match &doc.layers[0].children().unwrap()[0].kind {
NodeKind::Image(im) => im.clone(),
NodeKind::Image(im) => (**im).clone(),
other => panic!("expected an image, got {other:?}"),
}
}
@ -412,3 +412,76 @@ fn new_files_never_replace_a_file() {
assert_eq!(std::fs::read(&path).unwrap(), b"first");
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn a_native_save_streams_the_bytes_the_encoder_makes() {
let d = tmp_dir("stream");
let mut s = session(200.0, 100.0, 1);
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap();
for (name, compress) in [("a.vectorcraft", false), ("b.vectorcraft", true), ("c.vctemplate", true)] {
let path = d.join(name).to_string_lossy().to_string();
let p = json!({"path": path, "compress": compress});
let r = s.execute("file.saveCopy", &p).unwrap();
let written = std::fs::read(&path).unwrap();
assert_eq!(r["bytes"], written.len(), "{r}");
let plan = save_plan(&s, SaveMode::Copy, &p).unwrap();
let job = save_job(&mut s, plan).unwrap();
assert!(job.streams());
assert_eq!(written, job.encode().unwrap().files[0].1, "{name}");
assert_eq!(vectorcraft_format::load(&written).unwrap().template, name.ends_with("vctemplate"));
}
// A writer that fails: the save fails with its error.
let plan = save_plan(&s, SaveMode::Copy, &json!({"path": "/x/y.vectorcraft"})).unwrap();
let job = save_job(&mut s, plan).unwrap();
let e = job
.write_streamed(&mut |_, fill| {
fill(&mut std::io::sink()).unwrap();
Err(EngineError::Other("disk full".into()))
})
.unwrap_err();
assert_eq!(e.to_string(), "disk full");
let _ = std::fs::remove_dir_all(&d);
}
/// A native save whose file write fails partway (a full disk) leaves the file as it was, and no
/// temporary file behind.
#[test]
fn a_save_that_fails_while_streaming_leaves_the_file_as_it_was() {
/// Takes `left` bytes, then fails as a full disk does.
struct FailAfter<'a> {
inner: &'a mut dyn std::io::Write,
left: usize,
}
impl std::io::Write for FailAfter<'_> {
fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
if self.left == 0 {
return Err(std::io::Error::other("disk full"));
}
let n = b.len().min(self.left);
self.left -= n;
self.inner.write(&b[..n])
}
fn flush(&mut self) -> std::io::Result<()> {
self.inner.flush()
}
}
let d = tmp_dir("stream-fail");
let mut s = session(2000.0, 2000.0, 1);
// Enough objects that the file is written in several pieces, the failure in the middle.
for i in 0..1500 {
s.execute("shape.rectangle", &json!({"x": i % 100 * 20, "y": i / 100 * 20, "width": 10, "height": 10})).unwrap();
}
for compress in [false, true] {
let path = d.join("a.vectorcraft").to_string_lossy().to_string();
s.execute("file.saveCopy", &json!({"path": path, "compress": compress})).unwrap();
let before = std::fs::read(&path).unwrap();
s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap();
let plan = save_plan(&s, SaveMode::Copy, &json!({"path": path, "compress": compress})).unwrap();
let job = save_job(&mut s, plan).unwrap();
let e = job.write_streamed(&mut |p, fill| super::write_file_with(p, &mut |w| fill(&mut FailAfter { inner: w, left: 10_000 }))).unwrap_err();
assert!(e.to_string().contains("disk full") || e.to_string().contains("couldn't be encoded"), "{e}");
assert_eq!(std::fs::read(&path).unwrap(), before, "compress: {compress}");
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1, "no temporary file is left");
}
let _ = std::fs::remove_dir_all(&d);
}

View File

@ -53,7 +53,7 @@ fn images(s: &Session) -> Vec<ImageObject> {
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

View File

@ -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).

View File

@ -389,7 +389,7 @@ impl Gen<'_> {
fn series(&mut self, name: &str, index: usize, children: Vec<Arc<Node>>) -> Arc<Node> {
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<Node>]) -> Option<Rect> {
/// 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<NodeId> {
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<NodeId> {
}
fn spec_of(s: &Session, id: NodeId) -> Result<GraphSpec> {
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<NodeId, SeriesRef> {
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<NodeId, (NodeId, Role)> {
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<String> {
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<String> {
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<Value> {
}
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<_>>(), 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<u32> = n.children().unwrap().iter().filter_map(|c| c.series_index).collect();
let order: Vec<u32> = 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<u32> = n.children().unwrap().iter().filter_map(|c| c.series_index).collect();
let order: Vec<u32> = 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<u32> = n.children().unwrap().iter().filter_map(|c| c.series_index).collect();
let order: Vec<u32> = 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<u64> = (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<usize> = 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<String> =
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<String> = 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<u32> { s.doc().unwrap().doc.node(id).unwrap().children().unwrap().iter().filter_map(|c| c.series_index).collect() };
let order = |s: &Session| -> Vec<u32> {
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");

View File

@ -535,7 +535,7 @@ fn blend_make(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
|| 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<Arc<Node>>, &mu
fn selected_spec(s: &Session) -> Option<(NodeId, BlendSpec, Vec<NodeId>)> {
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<Arc<Node>>, 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<Value> {
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<Node>) -> 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<Value> {
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<Value> {
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<Value> {
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<T>(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);

View File

@ -90,7 +90,7 @@ fn enter(s: &mut Session, p: &Value) -> Result<Value> {
/// The first selected object with an opacity mask.
fn first_masked(st: &crate::DocState) -> Option<NodeId> {
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<Value> {
/// 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<NodeId> {
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<NodeId>) {
pub(crate) fn sync(d: &mut Document) {
let Some(me) = d.mask_edit else { return };
let art: Option<Vec<Arc<Node>>> = 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();

View File

@ -180,6 +180,7 @@ pub(crate) fn squash(s: &mut Session, from: usize, label: &str) {
if h.len() > from {
h.truncate(from + 1);
h[from].label = label.to_string();
st.history.recost_last(&st.doc);
}
}
}
@ -448,7 +449,8 @@ fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png));
let id = d.alloc_id();
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
let mut node = Node::new(id, NodeKind::Image(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() }));
let mut node =
Node::new(id, NodeKind::Image(Box::new(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() })));
if let Some((path, rule)) = clip {
node = super::rasterfx::clip_group(d, path, rule, node);
}
@ -561,7 +563,7 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
let node = st.doc.node(id).cloned().ok_or(EngineError::NoNode(id))?;
let NodeKind::Image(im) = &node.kind else { return Err(bad(C, "select an image")) };
if p.get("trim").and_then(Value::as_bool) == Some(true) {
return trim_image(s, id, im.clone());
return trim_image(s, id, (**im).clone());
}
let ib = node.geometric_bounds().ok_or_else(|| bad(C, "image has no bounds"))?;
let rect = match p.get("rect").and_then(Value::as_array) {
@ -602,14 +604,14 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
let key = unique_key(d, "crop");
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png));
if let Some(n) = d.node_mut(id) {
n.kind = NodeKind::Image(ImageObject {
n.kind = NodeKind::Image(Box::new(ImageObject {
key,
width: w,
height: h,
xf: Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale),
link: None,
placement: Default::default(),
});
}));
}
Ok(())
})?;
@ -645,14 +647,14 @@ fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result<Value> {
let key = unique_key(d, "crop");
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png));
if let Some(n) = d.node_mut(id) {
n.kind = NodeKind::Image(ImageObject {
n.kind = NodeKind::Image(Box::new(ImageObject {
key,
width: cw,
height: ch,
xf: im.xf * Affine::translate((f64::from(x0), f64::from(y0))),
link: None,
placement: Default::default(),
});
}));
}
Ok(())
})?;
@ -798,7 +800,7 @@ fn convert_to_shape(s: &mut Session, _: &Value) -> Result<Value> {
}
if let Some(l) = detect_shape(path) {
*path = l.to_path();
*live = Some(l);
*live = Some(Box::new(l));
n += 1;
}
}

View File

@ -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));
}
}

View File

@ -307,7 +307,7 @@ pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec<NodeId>, 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<Value> {
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<Value> {
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<Value> {
&& 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<Value> {
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<Value> {
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<Value> {
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<Value> {
}
*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<Value> {
}
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<Value> {
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);

View File

@ -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<Vec<NodeId>> {
let d = &s.doc()?.doc;
let ids: Vec<NodeId> = transparency_targets(s, p)?.into_iter().filter(|id| d.node(*id).is_some_and(|n| n.mask.is_some())).collect();
let ids: Vec<NodeId> = 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<Value> {
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<Value> {
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<Value> {
let v: Vec<Value> = 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);
}
}

View File

@ -558,7 +558,7 @@ pub(crate) fn styled_copy(d: &mut Document, l: &Node, items: Vec<AppearanceItem>
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<Node>) -> 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)
}

View File

@ -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() {

View File

@ -285,7 +285,7 @@ fn build(d: &mut Document, l: Loaded, link: Option<LinkInfo>) -> 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),

View File

@ -543,8 +543,7 @@ impl Session {
for st in &mut self.docs {
if history_changed {
st.history.limit = self.prefs.history_states as usize;
let over = st.history.undo.len().saturating_sub(st.history.limit);
st.history.undo.drain(..over);
st.history.trim();
}
// Documents in pattern editing mode redraw their tile edge.
if tile_edge_changed && st.doc.pattern_edit.is_some() {

View File

@ -96,7 +96,7 @@ fn bitmap_document(cmd: &str, doc: &Document, set: &PrintSettings) -> Result<Doc
out.images.insert(key.clone(), ImageBlob::new("image/png", img.to_png().map_err(EngineError::Other)?));
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
let image = ImageObject { key, width: img.width, height: img.height, xf, link: None, placement: Default::default() };
images.push(Arc::new(Node::new(out.alloc_id(), NodeKind::Image(image))));
images.push(Arc::new(Node::new(out.alloc_id(), NodeKind::Image(Box::new(image)))));
}
let mut layer = Node::layer(out.alloc_id(), "Bitmap", LayerColor::Preset(0));
if let Some(children) = layer.children_mut() {

View File

@ -203,8 +203,10 @@ pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&N
}
out.images.insert(key.clone(), ImageBlob::new("image/png", png));
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
let mut image =
Node::new(id, NodeKind::Image(ImageObject { key, width: img.width, height: img.height, xf, link: None, placement: Default::default() }));
let mut image = Node::new(
id,
NodeKind::Image(Box::new(ImageObject { key, width: img.width, height: img.height, xf, link: None, placement: Default::default() })),
);
image.name = Some("Raster effect".into());
Some(image)
}
@ -277,7 +279,8 @@ fn below_images(d: &mut Document, n: &Node) -> Option<Vec<Node>> {
/// effects (they get images of their own).
pub(crate) fn raster_image(d: &mut Document, n: &Node, below: bool, members: bool) -> Option<Node> {
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<Node> {
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);

View File

@ -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(),
}),
})),
)
}

View File

@ -26,17 +26,21 @@
//! again.
//!
//! Each copy is two entries: `<area>/<file>.vectorcraft` (the native document, written as a save
//! writes it) and `<area>/<file>.json` (its title, original path and format, and when it was
//! written). The store is a folder on the desktop ([`FolderStore`]: the `recoveryFolder`
//! writes it, without its images) and `<area>/<file>.json` (its title, original path and format,
//! when it was written, and its images). The images are entries of their own, `<area>/img-…blob`,
//! named by their content and shared by the area's copies: a copy written again writes only the
//! images that changed, and the images no copy names any more go. Copies written before images
//! were kept apart (the images in the document entry) still restore. The store is a folder on the desktop ([`FolderStore`]: the `recoveryFolder`
//! preference, else the folder the app sets), browser storage on the web, or any
//! [`RecoveryStore`] the host installs (tests use [`MemoryStore`]).
use std::collections::{BTreeMap, BTreeSet};
use std::sync::{Arc, Mutex};
use std::hash::{Hash, Hasher};
use std::sync::{Arc, Mutex, Weak};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use vectorcraft_doc::Document;
use vectorcraft_doc::{Document, ImageBlob};
use super::fileio::{self, SAVE_FORMATS, SaveJob, SaveMode, SavePlan};
use super::*;
@ -50,6 +54,8 @@ pub const COMPLEX_OBJECTS: usize = 20_000;
const DOC_EXT: &str = ".vectorcraft";
/// The extension of a copy's details entry.
const META_EXT: &str = ".json";
/// The extension of an image entry (see [`blob_name`]).
const BLOB_EXT: &str = ".blob";
/// The heartbeat entry of an area in a store without locks: when its app last said it runs (Unix
/// seconds, as text).
const BEAT: &str = "heartbeat";
@ -527,6 +533,73 @@ struct Meta {
/// (see [`crate::DocState::import_losses`]): a restored copy still refuses to replace it.
imported_from: Option<String>,
import_losses: Vec<String>,
/// The document's images by key, in entries of the area (none in copies written before
/// images were kept apart: the document entry holds them).
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
images: BTreeMap<String, ImageRef>,
}
/// One of a copy's images: its entry in the copy's area.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default)]
struct ImageRef {
mime: String,
/// The entry's name in the area ([`blob_name`]).
blob: String,
/// A linked image's preview, as a save writes it ([`ImageBlob::proxy`]).
#[serde(skip_serializing_if = "std::ops::Not::not")]
proxy: bool,
}
/// Writes of image entries and their sweeping ([`sweep`]) take turns, in this app (the background
/// worker and the UI thread), so an image is never swept between being written and named.
static WRITES: Mutex<()> = Mutex::new(());
/// Image bytes and the name of their entry ([`blob_name`]).
type Named = (Weak<Vec<u8>>, String);
/// The entry names of image bytes already hashed ([`blob_name`]), so an image is hashed once,
/// not at every interval. Bytes behind a live `Weak` never change (`Arc::make_mut` copies them).
static NAMES: Mutex<Vec<Named>> = Mutex::new(Vec::new());
/// The name of the entry holding `bytes`: from their content (128 bits of hash and the length), so
/// identical images share one entry and one written before isn't written again.
fn blob_name(bytes: &Arc<Vec<u8>>) -> String {
let mut names = NAMES.lock().unwrap_or_else(|e| e.into_inner());
names.retain(|(w, _)| w.strong_count() > 0);
if let Some((_, name)) = names.iter().find(|(w, _)| std::ptr::eq(w.as_ptr(), Arc::as_ptr(bytes))) {
return name.clone();
}
let hash = |seed: u8| {
let mut h = std::collections::hash_map::DefaultHasher::new();
seed.hash(&mut h);
bytes.as_slice().hash(&mut h);
h.finish()
};
let name = format!("img-{:016x}{:016x}-{}{BLOB_EXT}", hash(1), hash(2), bytes.len());
names.push((Arc::downgrade(bytes), name.clone()));
name
}
/// The MIME type of a linked image's preview (JPEG or PNG), as a save names it.
fn proxy_mime(bytes: &[u8]) -> &'static str {
if bytes.starts_with(&[0xff, 0xd8]) { "image/jpeg" } else { vectorcraft_doc::links::PROXY_MIME }
}
/// Remove the image entries of `area` no copy's details name any more. Best effort: an entry left
/// behind goes at the next sweep, or with the area.
fn sweep(store: &dyn RecoveryStore, area: &str) {
let Ok(names) = store.list() else { return };
let in_area = |n: &&String| n.split_once('/').is_some_and(|(a, _)| a == area);
let mut named = BTreeSet::new();
for meta in names.iter().filter(in_area).filter_map(|n| n.strip_suffix(META_EXT)) {
named.extend(read_meta(store, meta).images.into_values().map(|r| r.blob));
}
for n in names.iter().filter(in_area).filter(|n| n.ends_with(BLOB_EXT)) {
if n.split_once('/').is_some_and(|(_, b)| !named.contains(b)) {
let _ = store.remove(n);
}
}
}
fn read_meta(store: &dyn RecoveryStore, file: &str) -> Meta {
@ -548,14 +621,56 @@ pub struct RecoveryJob {
}
impl RecoveryJob {
/// Encode the snapshot and store it with its details → `{file, title, bytes}`.
/// Encode the snapshot and store it with its details and the images the area doesn't have
/// yet → `{file, title, bytes (the document's), imageBytes (images written now)}`.
pub fn write(&self, store: &dyn RecoveryStore) -> std::result::Result<Value, String> {
let enc = self.save.encode().map_err(|e| e.to_string())?;
let doc = self.save.doc();
// A linked image keeps its preview, as a save writes it.
let linked = doc.linked_only_images();
let mut lean = Document::clone(doc);
let blobs = std::mem::take(&mut lean.images);
let enc = self.save.encode_doc(&lean).map_err(|e| e.to_string())?;
let bytes = enc.files.first().map(|f| f.1.as_slice()).ok_or("nothing encoded")?;
let meta = serde_json::to_vec_pretty(&self.meta).map_err(|e| e.to_string())?;
let mut meta = self.meta.clone();
let mut images = vec![];
for (key, b) in &blobs {
let (mime, data, proxy) = match b.proxy.as_ref().filter(|_| linked.contains(key)) {
Some(p) => (proxy_mime(p), p, true),
None => (b.mime.as_str(), &b.bytes, false),
};
let blob = blob_name(data);
meta.images.insert(key.clone(), ImageRef { mime: mime.into(), blob: blob.clone(), proxy });
images.push((blob, data));
}
let meta_bytes = serde_json::to_vec_pretty(&meta).map_err(|e| e.to_string())?;
let area = self.file.split_once('/').map_or("", |(a, _)| a);
let _turn = WRITES.lock().unwrap_or_else(|e| e.into_inner());
let have: BTreeSet<String> = if images.is_empty() { BTreeSet::new() } else { store.list()?.into_iter().collect() };
let mut image_bytes = 0;
for (blob, data) in images {
let name = format!("{area}/{blob}");
if !have.contains(&name) {
store.write(&name, data)?;
image_bytes += data.len();
}
}
let meta_name = format!("{}{META_EXT}", self.file);
// The details name the images, so a crash between writing the document and its details
// would pair the new document with the old copy's images: when the new document has
// images the old details don't name, they name both until the new document is in.
let old = read_meta(store, &self.file).images;
if meta.images.iter().any(|(k, r)| old.get(k).is_none_or(|o| o.blob != r.blob)) {
let mut both = meta.clone();
for (k, r) in old {
both.images.entry(k).or_insert(r);
}
store.write(&meta_name, &serde_json::to_vec_pretty(&both).map_err(|e| e.to_string())?)?;
}
store.write(&format!("{}{DOC_EXT}", self.file), bytes)?;
store.write(&format!("{}{META_EXT}", self.file), &meta)?;
Ok(json!({ "file": self.file, "title": self.title, "bytes": bytes.len() }))
store.write(&meta_name, &meta_bytes)?;
// Images this copy no longer shows, nor any other.
sweep(store, area);
Ok(json!({ "file": self.file, "title": self.title, "bytes": bytes.len(), "imageBytes": image_bytes }))
}
/// Once stored: the document (if still open) remembers the copy, so it isn't written again
@ -662,6 +777,8 @@ pub fn jobs(s: &mut Session, store: &Arc<dyn RecoveryStore>) -> Result<(Vec<Reco
saved,
imported_from: st.imported_from.clone(),
import_losses: st.import_losses.clone(),
// Filled in as the copy is written.
images: BTreeMap::new(),
};
out.push(RecoveryJob { uid: st.uid, file, title, meta, save });
}
@ -829,7 +946,24 @@ fn restore_one(s: &mut Session, store: &Arc<dyn RecoveryStore>, file: &str) -> R
let bytes = store.read(&format!("{file}{DOC_EXT}")).map_err(EngineError::Other)?;
let meta_bytes = store.read(&format!("{file}{META_EXT}")).unwrap_or_default();
let meta: Meta = serde_json::from_slice(&meta_bytes).unwrap_or_default();
let (mut doc, _, warnings) = fileio::native_file(&bytes).map_err(|e| EngineError::Other(format!("recovery copy `{file}`: {e}")))?;
let (mut doc, _, mut warnings) = fileio::native_file(&bytes).map_err(|e| EngineError::Other(format!("recovery copy `{file}`: {e}")))?;
// The images, from the entries of the copy's area.
let from = file.split_once('/').map_or("", |(a, _)| a);
let mut blobs = vec![];
for (key, r) in &meta.images {
match store.read(&format!("{from}/{}", r.blob)) {
Ok(data) => {
let mut blob = ImageBlob::new(r.mime.clone(), data);
// Until the linked file is read, the preview stands in for it.
if r.proxy {
blob.proxy = Some(blob.bytes.clone());
}
blobs.push((r.blob.clone(), blob.bytes.clone()));
doc.images.insert(key.clone(), blob);
}
Err(e) => warnings.push(format!("the image “{key}” of the recovered document couldn't be read: {e}")),
}
}
let area = claim(s, store)?;
let mut taken: Vec<String> = copies_in(&store.list().map_err(EngineError::Other)?).into_iter().map(|(_, f)| f).collect();
taken.extend(open_copies(s));
@ -838,8 +972,15 @@ fn restore_one(s: &mut Session, store: &Arc<dyn RecoveryStore>, file: &str) -> R
f if taken.contains(&f) => fresh_name(&area, stem, &taken),
f => f,
};
store.write(&format!("{mine}{DOC_EXT}"), &bytes).map_err(EngineError::Other)?;
store.write(&format!("{mine}{META_EXT}"), &meta_bytes).map_err(EngineError::Other)?;
{
let _turn = WRITES.lock().unwrap_or_else(|e| e.into_inner());
for (blob, data) in &blobs {
store.write(&format!("{area}/{blob}"), data).map_err(EngineError::Other)?;
}
store.write(&format!("{mine}{DOC_EXT}"), &bytes).map_err(EngineError::Other)?;
store.write(&format!("{mine}{META_EXT}"), &meta_bytes).map_err(EngineError::Other)?;
}
// The copy's images in its old area go with the area, once no copy is left there.
store.remove(&format!("{file}{DOC_EXT}")).map_err(EngineError::Other)?;
store.remove(&format!("{file}{META_EXT}")).map_err(EngineError::Other)?;
if doc.title.is_empty() {
@ -901,7 +1042,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Save Recovery Data",
[],
None,
"{} write a recovery copy of every modified document changed since its last copy, in this app's own area of the store (the app does this every autosaveInterval minutes while autosaveRecovery is on; documents of more than 20000 objects are skipped while recoveryOffForComplex is on). A copy goes when its document is saved, reverted or closed → {saved: [{file, title, bytes}], skipped: [{title, reason}], location} (location: the recovery folder, or browser storage on the web)",
"{} write a recovery copy of every modified document changed since its last copy, in this app's own area of the store (the app does this every autosaveInterval minutes while autosaveRecovery is on; documents of more than 20000 objects are skipped while recoveryOffForComplex is on). Images are stored apart, each once while unchanged. A copy goes when its document is saved, reverted or closed → {saved: [{file, title, bytes, imageBytes (images written this time)}], skipped: [{title, reason}], location} (location: the recovery folder, or browser storage on the web)",
has_store,
save
),

View File

@ -547,7 +547,7 @@ fn same_graphic_style(s: &mut Session, _: &Value) -> Result<Value> {
.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.

View File

@ -56,7 +56,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
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<Arc<Node>> {
@ -376,7 +376,7 @@ fn generated(d: &mut Document, src: &[Arc<Node>], 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<Value> {
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<Value> {
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<Value> {
fn group_at(d: &Document, point: Point, construction: bool) -> Option<NodeId> {
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<NodeId> {
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<Value> {
@ -756,7 +756,7 @@ fn expand(s: &mut Session, p: &Value) -> Result<Value> {
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))

View File

@ -210,7 +210,7 @@ fn selected(s: &Session) -> Result<Vec<NodeId>> {
}
pub(crate) fn selected_slices(d: &Document, sel: &vectorcraft_doc::Selection) -> Vec<NodeId> {
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<NodeId> {
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<Value> {
None => super::edit::selected_roots(s)?,
};
let d = &s.doc()?.doc;
let made: Vec<NodeId> = ids.into_iter().filter(|id| d.node(*id).is_some_and(|n| !n.is_layer() && n.slice.is_none())).collect();
let made: Vec<NodeId> = 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<Value> {
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);

View File

@ -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<u32>) {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<NodeId>, Vec<NodeId>) = 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();

View File

@ -606,7 +606,7 @@ fn reset_defaults(s: &mut Session, p: &Value) -> Result<Value> {
let mut added = vec![];
if replace {
let before: Vec<String> = d.swatches_iter().map(|w| w.name.clone()).collect();
(d.swatches, d.swatch_groups) = (defaults, groups);
(d.swatches, d.swatch_groups) = (defaults.into(), groups.into());
added.extend(d.swatches_iter().filter(|w| !before.contains(&w.name)).map(|w| w.name.clone()));
live.extend(d.swatches_iter().filter(|w| w.global).map(|w| w.name.clone()));
d.map_solid_paints(&mut |_, l, _| unlink_dead(&live, l));

View File

@ -755,7 +755,7 @@ fn discard_empty(s: &mut Session, p: &Value) -> Result<Value> {
// plus the text (and its id), roll back to it without a trace.
let st = s.doc_mut()?;
if st.interaction.is_none()
&& let Some(k) = st.history.undo.iter().rposition(|e| e.doc.node(id).is_none())
&& let Some(k) = st.history.undo.iter().rposition(|e| e.doc.node_uncached(id).is_none())
&& let Some(created) = st.history.undo.get(k)
{
let mut now = (*st.doc).clone();

View File

@ -48,7 +48,7 @@ fn make(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
fn release(s: &mut Session, p: &Value) -> Result<Value> {
let ids: Vec<NodeId> = {
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<Value> {
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<Value> {
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<Value> {
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<Item<'a>>) {
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<Item<'a>>) {
}
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]

View File

@ -683,7 +683,7 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result<Value> {
}
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, _| {

View File

@ -0,0 +1,197 @@
//! The session's journal of executed commands: what the Actions panel records an action from
//! (and what a replay runs), kept within a memory bound.
//!
//! Entries are numbered from the session's first command on, and a number keeps naming the same
//! entry when older ones are let go: [`Journal::len`] counts every entry ever journaled, so a
//! recording that remembers where it started finds its steps by that number. Only the most recent
//! entries are kept (at most [`Journal::MAX_ENTRIES`] and about [`Journal::MAX_BYTES`], always the
//! last [`Journal::MIN_ENTRIES`]): a long session of select-alls on a large document no longer
//! grows the journal by a copy of every id list forever. A recording that must keep everything
//! from its start holds the journal there ([`Journal::hold`]).
use serde_json::Value;
/// One journaled command: its id and params (with the values it noted, see
/// [`crate::Session::note_journal`]).
pub type JournalEntry = (String, Value);
#[derive(Clone, Debug, Default)]
pub struct Journal {
/// How many entries were let go from the front; the first kept one has this number.
dropped: usize,
entries: Vec<JournalEntry>,
/// Each kept entry's estimated size, and their sum.
sizes: Vec<usize>,
bytes: usize,
/// Keep every entry from this number on ([`Journal::hold`]).
held: Option<usize>,
}
/// Journals with the same entries (by number) are equal, whatever their history.
impl PartialEq for Journal {
fn eq(&self, other: &Self) -> bool {
self.dropped == other.dropped && self.entries == other.entries
}
}
impl Journal {
/// Most entries kept (the oldest go first).
pub const MAX_ENTRIES: usize = 10_000;
/// About the most memory the kept entries take.
pub const MAX_BYTES: usize = 32 << 20;
/// The most recent entries are kept whatever they take.
pub const MIN_ENTRIES: usize = 64;
/// Every entry ever journaled, kept or let go: the number the next one gets.
pub fn len(&self) -> usize {
self.dropped + self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// The number of the oldest entry kept.
pub fn first_kept(&self) -> usize {
self.dropped
}
/// The kept entries, oldest first.
pub fn iter(&self) -> std::slice::Iter<'_, JournalEntry> {
self.entries.iter()
}
/// The kept entries from number `start` on (all kept ones when older ones were let go).
pub fn since(&self, start: usize) -> &[JournalEntry] {
self.entries.get(start.saturating_sub(self.dropped)..).unwrap_or_default()
}
/// Entry number `i`, if kept.
pub fn get(&self, i: usize) -> Option<&JournalEntry> {
self.entries.get(i.checked_sub(self.dropped)?)
}
pub fn get_mut(&mut self, i: usize) -> Option<&mut JournalEntry> {
self.entries.get_mut(i.checked_sub(self.dropped)?)
}
pub fn last(&self) -> Option<&JournalEntry> {
self.entries.last()
}
pub fn last_mut(&mut self) -> Option<&mut JournalEntry> {
self.entries.last_mut()
}
pub fn push(&mut self, e: JournalEntry) {
let size = entry_bytes(&e);
self.entries.push(e);
self.sizes.push(size);
self.bytes += size;
self.trim();
}
/// Drop the entries from number `len` on (a cancelled undo group's).
pub fn truncate(&mut self, len: usize) {
let keep = len.saturating_sub(self.dropped);
self.entries.truncate(keep);
self.sizes.truncate(keep);
self.bytes = self.sizes.iter().sum();
self.dropped = self.dropped.min(len);
}
/// Keep every entry from number `from` on (a recording in progress), or stop (`None`).
pub fn hold(&mut self, from: Option<usize>) {
self.held = from;
self.trim();
}
/// Let the oldest entries go while over the bounds (in batches: a quarter of the entry bound
/// at a time, so a push doesn't shift the whole list every time).
fn trim(&mut self) {
let over_count = self.entries.len() > Self::MAX_ENTRIES + Self::MAX_ENTRIES / 4;
if !over_count && self.bytes <= Self::MAX_BYTES {
return;
}
// The entries that may go: not the last few, nor any a hold keeps.
let mut may = self.entries.len().saturating_sub(Self::MIN_ENTRIES);
if let Some(h) = self.held {
may = may.min(h.saturating_sub(self.dropped));
}
let mut n = 0;
let mut bytes = self.bytes;
while n < may && (self.entries.len() - n > Self::MAX_ENTRIES || bytes > Self::MAX_BYTES) {
bytes -= self.sizes.get(n).copied().unwrap_or(0);
n += 1;
}
if n > 0 {
self.entries.drain(..n);
self.sizes.drain(..n);
self.bytes = bytes;
self.dropped += n;
}
}
}
impl<'a> IntoIterator for &'a Journal {
type Item = &'a JournalEntry;
type IntoIter = std::slice::Iter<'a, JournalEntry>;
fn into_iter(self) -> Self::IntoIter {
self.entries.iter()
}
}
/// Roughly the heap an entry takes: its id, and its params as `serde_json` holds them.
fn entry_bytes((id, p): &JournalEntry) -> usize {
fn json(v: &Value) -> usize {
const VALUE: usize = std::mem::size_of::<Value>();
match v {
Value::String(s) => s.capacity(),
Value::Array(a) => 16 + a.capacity() * VALUE + a.iter().map(json).sum::<usize>(),
Value::Object(m) => m.iter().map(|(k, v)| 48 + k.capacity() + VALUE + json(v)).sum(),
_ => 0,
}
}
std::mem::size_of::<JournalEntry>() + id.capacity() + json(p)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn big(i: usize) -> JournalEntry {
("select.set".into(), json!({"ids": (0..50_000u64).collect::<Vec<_>>(), "n": i}))
}
#[test]
fn numbers_survive_letting_entries_go() {
let mut j = Journal::default();
for i in 0..200 {
j.push(big(i));
}
assert_eq!(j.len(), 200, "every entry counts");
assert!(j.first_kept() > 0, "big entries were let go");
assert!(j.iter().count() >= Journal::MIN_ENTRIES);
assert_eq!(j.get(199).map(|e| e.1["n"].clone()), Some(json!(199)));
assert_eq!(j.since(150).len(), 50);
assert!(j.get(0).is_none());
}
#[test]
fn a_hold_keeps_a_recording_whole() {
let mut j = Journal::default();
j.push(("file.new".into(), json!({})));
let start = j.len();
j.hold(Some(start));
for i in 0..200 {
j.push(big(i));
}
assert_eq!(j.since(start).len(), 200, "nothing recorded was let go");
assert_eq!(j.first_kept(), 1, "only what came before the recording may go");
j.hold(None);
j.push(big(200));
assert!(j.first_kept() > start);
}
#[test]
fn many_small_entries_are_bounded_too() {
let mut j = Journal::default();
for i in 0..30_000 {
j.push(("object.move".into(), json!({"dx": i})));
}
assert_eq!(j.len(), 30_000);
assert!(j.iter().count() <= Journal::MAX_ENTRIES + Journal::MAX_ENTRIES / 4);
j.truncate(29_990);
assert_eq!((j.len(), j.last().map(|e| e.1["dx"].clone())), (29_990, Some(json!(29_989))));
}
}

View File

@ -10,6 +10,7 @@ pub mod cmd;
pub mod file_access;
pub mod guard;
pub mod inspect;
pub mod journal;
pub mod link_watch;
pub mod steps;
mod tooling;
@ -28,6 +29,7 @@ pub use cmd::clipboard::Clipboard;
pub use cmd::distortcmds::perspective_click;
pub use cmd::rasterfx::{export_pdf, flatten_raster_effects};
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
pub use journal::Journal;
pub use tooling::{UiRequest, ViewInfo};
pub use vectorcraft_doc as doc;
pub use vectorcraft_tools as tools;
@ -63,16 +65,139 @@ pub type Result<T> = std::result::Result<T, EngineError>;
#[derive(Clone, Debug)]
pub struct HistoryEntry {
pub label: String,
/// The document as it was. It keeps no lookup cache ([`Document::drop_lookup_cache`]): a
/// table per step would add up, and undoing builds it again once.
pub doc: Arc<Document>,
pub selection: Selection,
/// The selection as it was, shared with the neighbouring steps that have the same one.
pub selection: Arc<Selection>,
/// Roughly the memory this step keeps of its own: what of `doc` the step after it (or the
/// document now) doesn't share ([`Document::retained_bytes`]).
pub bytes: usize,
/// The same the other way round: what that next state keeps that `doc` doesn't share. Undoing
/// (redoing) this step makes a step that keeps the next state: this is its `bytes`.
next_bytes: usize,
/// Which document the figures were taken against (its address): the next state.
next: usize,
}
#[derive(Clone, Debug, Default)]
impl HistoryEntry {
/// A step keeping `doc` and `selection`, as they were before the edit that made `next`.
pub(crate) fn kept(label: String, doc: Arc<Document>, selection: Arc<Selection>, next: &Document) -> Self {
doc.drop_lookup_cache();
let (bytes, next_bytes) = doc.retained_bytes_each(next);
HistoryEntry { label, doc, selection, bytes, next_bytes, next: std::ptr::from_ref(next) as usize }
}
/// The step that undoing (redoing) the step `costs` came from makes: it keeps `doc`, the
/// document before the undo, whose next state is now `now` (that step's document). Its figures
/// are that step's the other way round when `doc` is still the document they were taken
/// against (nothing replaced it since), so undo and redo needn't walk the documents again. A
/// change made to it in place without an undo step (a setting, a refresh) goes unnoticed: a
/// small error, for an estimate.
pub(crate) fn turned(label: String, doc: Arc<Document>, selection: Arc<Selection>, now: &Arc<Document>, costs: StepCosts) -> Self {
if costs.next != Arc::as_ptr(&doc) as usize {
return Self::kept(label, doc, selection, now);
}
doc.drop_lookup_cache();
HistoryEntry { label, doc, selection, bytes: costs.next_bytes, next_bytes: costs.bytes, next: Arc::as_ptr(now) as usize }
}
/// The figures [`HistoryEntry::turned`] reuses.
pub(crate) fn costs(&self) -> StepCosts {
StepCosts { bytes: self.bytes, next_bytes: self.next_bytes, next: self.next }
}
}
/// A step's memory figures ([`HistoryEntry::costs`]).
#[derive(Clone, Copy, Debug)]
pub(crate) struct StepCosts {
bytes: usize,
next_bytes: usize,
next: usize,
}
/// The undo history of a document: bounded by a number of steps (Preferences › Performance ›
/// History States) and by the memory the steps keep ([`History::byte_budget`]).
#[derive(Clone, Debug)]
pub struct History {
pub undo: Vec<HistoryEntry>,
pub redo: Vec<HistoryEntry>,
/// Maximum undo depth (Illustrator has no hard limit; snapshots are cheap here).
pub limit: usize,
/// About the most memory the steps may keep ([`HistoryEntry::bytes`]): past it the oldest
/// steps go, but never the last [`History::MIN_STEPS`] undo (and redo) steps.
pub byte_budget: usize,
}
impl Default for History {
fn default() -> Self {
History { undo: vec![], redo: vec![], limit: 0, byte_budget: default_history_budget() }
}
}
impl History {
/// Undo (and redo) steps kept whatever memory they take.
pub const MIN_STEPS: usize = 8;
/// Roughly the memory the steps keep of their own ([`HistoryEntry::bytes`]).
pub fn bytes(&self) -> usize {
self.undo.iter().chain(&self.redo).map(|e| e.bytes).sum()
}
/// Let the oldest steps go while over the step limit or the memory budget: undo steps first
/// (the oldest), then redo steps (the furthest ahead).
pub fn trim(&mut self) {
let over = self.undo.len().saturating_sub(self.limit);
if over > 0 {
self.undo.drain(..over);
}
let mut total = self.bytes();
if total <= self.byte_budget {
return;
}
let mut n = 0;
while total > self.byte_budget && self.undo.len() - n > Self::MIN_STEPS {
total -= self.undo.get(n).map_or(0, |e| e.bytes);
n += 1;
}
self.undo.drain(..n);
let mut n = 0;
while total > self.byte_budget && self.redo.len() - n > Self::MIN_STEPS {
total -= self.redo.get(n).map_or(0, |e| e.bytes);
n += 1;
}
self.redo.drain(..n);
}
/// Estimate the newest undo step again, its next state now being `now` (the steps after it were
/// merged into it, or an undo group extended it).
pub(crate) fn recost_last(&mut self, now: &Document) {
if let Some(e) = self.undo.last_mut() {
(e.bytes, e.next_bytes) = e.doc.retained_bytes_each(now);
e.next = std::ptr::from_ref(now) as usize;
}
}
}
/// The default [`History::byte_budget`]: an eighth of the machine's memory where it is known
/// (Linux), at most 1 GiB (the figure elsewhere); 256 MiB on the web, whose memory is small.
pub fn default_history_budget() -> usize {
const MAX: usize = 1 << 30;
if cfg!(target_arch = "wasm32") {
return 256 << 20;
}
static BUDGET: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
*BUDGET.get_or_init(|| physical_memory().map_or(MAX, |m| (m / 8).clamp(64 << 20, MAX)))
}
/// The machine's memory, where cheap to learn without platform calls.
fn physical_memory() -> Option<usize> {
if !cfg!(target_os = "linux") {
return None;
}
let info = std::fs::read_to_string("/proc/meminfo").ok()?;
let kb: usize = info.lines().find_map(|l| l.strip_prefix("MemTotal:"))?.trim().trim_end_matches("kB").trim().parse().ok()?;
kb.checked_mul(1024)
}
/// An in-progress drag: the snapshot the previews are applied to.
@ -80,7 +205,8 @@ pub struct History {
pub struct Interaction {
pub label: String,
pub doc: Arc<Document>,
pub selection: Selection,
/// The selection the previews start from (shared, so a preview frame copies nothing).
pub selection: Arc<Selection>,
pub preview: Option<(String, Value)>,
/// Per-document state restored on cancel (current layer, highlighted Layers panel rows,
/// isolation).
@ -284,34 +410,40 @@ impl DocState {
/// Keep what interaction `it` (taken from this document) changed, as one undo step.
pub(crate) fn keep_interaction(&mut self, it: Interaction) {
if !Arc::ptr_eq(&it.doc, &self.doc) {
self.push_undo(HistoryEntry { label: it.label, doc: it.doc, selection: it.selection });
let selection = match self.history.undo.last() {
Some(l) if l.selection == it.selection => l.selection.clone(),
_ => it.selection,
};
self.push_undo(it.label, it.doc, selection);
self.revision += 1;
}
}
/// Record undo step `e` (the document before an edit). In an undo group only the group's first
/// edit records one: the edits after it extend that step.
fn push_undo(&mut self, mut e: HistoryEntry) {
/// Record an undo step: `doc` and `selection` as they were before an edit (the document now is
/// the one after it). In an undo group only the group's first edit records one: the edits
/// after it extend that step.
fn push_undo(&mut self, mut label: String, doc: Arc<Document>, selection: Arc<Selection>) {
if let Some(g) = &mut self.undo_group {
let recorded = |first: &Arc<Document>| self.history.undo.last().is_some_and(|l| Arc::ptr_eq(&l.doc, first));
if g.first.as_ref().is_some_and(recorded) {
self.history.recost_last(&self.doc);
self.history.trim();
return;
}
g.first = Some(e.doc.clone());
g.first = Some(doc.clone());
if let Some(l) = &g.label {
e.label = l.clone();
label = l.clone();
}
}
let e = HistoryEntry::kept(label, doc, selection, &self.doc);
self.history.undo.push(e);
if self.history.undo.len() > self.history.limit {
self.history.undo.remove(0);
}
self.history.redo.clear();
self.history.trim();
}
/// End the interaction in progress, undoing what it changed.
pub(crate) fn undo_interaction(&mut self) {
if let Some(it) = self.interaction.take() {
self.doc = it.doc;
self.selection = it.selection;
self.selection = Arc::unwrap_or_clone(it.selection);
self.active_layer = it.active_layer;
self.layer_rows = it.layer_rows;
self.variables_highlight = it.variables_highlight;
@ -840,8 +972,8 @@ pub struct Session {
pub fill_active: bool,
/// Internal clipboard: the copied objects and the document resources they use.
pub clipboard: Clipboard,
/// Executed commands (for actions and debugging).
pub journal: Vec<(String, Value)>,
/// Executed commands (for actions and debugging), the most recent ones kept ([`Journal`]).
pub journal: Journal,
pub(crate) tool: Box<dyn Tool>,
/// While Cmd lends `tool` (a selection tool) for a drag: the tool it was lent to, which comes
/// back as it was at the release ([`Session::pointer`]).
@ -952,7 +1084,7 @@ impl Session {
paint: PaintDefaults::default(),
fill_active: true,
clipboard: Clipboard::default(),
journal: vec![],
journal: Journal::default(),
tool: vectorcraft_tools::create("selection"),
lender: None,
last_selection_tool: None,
@ -1194,8 +1326,8 @@ impl Session {
/// pin a replay to the original run ([`REPLAY_ONLY`]), so playing the action later acts now.
pub fn journal_for_action(&self, start: usize) -> Vec<(String, Value)> {
self.journal
.since(start)
.iter()
.skip(start)
.cloned()
.map(|(id, mut p)| {
strip_replay_only(&id, &mut p);
@ -1296,7 +1428,11 @@ impl Session {
st.selection.prune(&st.doc);
st.revision += 1;
if st.interaction.is_none() {
st.push_undo(HistoryEntry { label: label.to_string(), doc: before, selection: before_sel });
let selection = match st.history.undo.last() {
Some(l) if *l.selection == before_sel => l.selection.clone(),
_ => Arc::new(before_sel),
};
st.push_undo(label.to_string(), before, selection);
}
Ok(v)
}
@ -1342,7 +1478,7 @@ impl Session {
st.interaction = Some(Interaction {
label: label.to_string(),
doc: st.doc.clone(),
selection: st.selection.clone(),
selection: Arc::new(st.selection.clone()),
preview: None,
active_layer: st.active_layer,
layer_rows: st.layer_rows.clone(),
@ -1360,11 +1496,14 @@ impl Session {
let Some(it) = &st.interaction else { return Err(EngineError::Other("no interaction in progress".into())) };
// Undoing the previous preview counts as a change, so views redraw even when the command
// below returns without an edit.
if !Arc::ptr_eq(&st.doc, &it.doc) || st.selection != it.selection {
let selection_changed = st.selection != *it.selection;
if !Arc::ptr_eq(&st.doc, &it.doc) || selection_changed {
st.revision += 1;
}
st.doc = it.doc.clone();
st.selection = it.selection.clone();
if selection_changed {
st.selection.clone_from(&it.selection);
}
}
let rw = cmd::distortcmds::perspective_rewrite(self, cmd, params);
let (cmd, params) = rw.as_ref().map_or((cmd, params), |(c, p)| (c.as_str(), p));
@ -1463,7 +1602,7 @@ impl Session {
&& let Some(e) = st.history.undo.pop()
{
st.doc = e.doc;
st.selection = e.selection;
st.selection = Arc::unwrap_or_clone(e.selection);
st.revision += 1;
journal = Some(g.journal);
}
@ -1580,6 +1719,8 @@ mod tests_gradpanel;
#[cfg(test)]
mod tests_halftone;
#[cfg(test)]
mod tests_history_mem;
#[cfg(test)]
mod tests_inline;
#[cfg(test)]
mod tests_journal;

View File

@ -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();

View File

@ -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)
})

View File

@ -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);
}

View File

@ -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");
}

View File

@ -28,7 +28,7 @@ fn node(s: &Session, id: NodeId) -> Node {
fn spec(s: &Session, id: NodeId) -> (Vec<NodeId>, 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"),
}
}

View File

@ -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"),
}
}

View File

@ -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);

View File

@ -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::<Vec<_>>(), ["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]

View File

@ -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)
})

View File

@ -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]

View File

@ -63,7 +63,7 @@ fn anchors(nodes: &[Node]) -> Vec<Point> {
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)
})

View File

@ -32,7 +32,10 @@ fn enveloped(s: &mut Session, cmd: &str, params: Value) -> (NodeId, NodeId) {
fn mesh(s: &Session, e: NodeId) -> (u32, u32, Vec<Point>, 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]

View File

@ -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);

View File

@ -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);
}

View File

@ -0,0 +1,155 @@
//! The undo history's memory (#1244): steps keep no lookup tables, unchanged selections are
//! shared between steps, and past a memory budget the oldest steps go (never the last few).
use serde_json::json;
use vectorcraft_doc::{Appearance, Node};
use vectorcraft_geom::shapes;
use super::*;
/// A session with one document holding `n` rectangles, all selected.
fn session_with_rects(n: usize) -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 2000, "height": 2000})).unwrap();
s.edit("Add", |d, _| {
let layer = d.layers[0].id;
for i in 0..n {
let id = d.alloc_id();
let r = shapes::rectangle(kurbo::Rect::new(i as f64, 0.0, i as f64 + 10.0, 10.0));
d.insert(Some(layer), usize::MAX, Node::path(id, r, Appearance::default_art()))?;
}
Ok(())
})
.unwrap();
s.execute("select.all", &json!({})).unwrap();
s
}
fn first_x(s: &Session) -> f64 {
let d = &s.doc().unwrap().doc;
let id = d.layers[0].children().unwrap()[0].id;
d.node(id).unwrap().geometric_bounds().unwrap().x0
}
#[test]
fn past_the_budget_the_oldest_steps_go_but_the_last_few_stay() {
let n = 500;
let mut s = session_with_rects(n);
s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap();
let step = s.doc().unwrap().history.undo.last().unwrap().bytes;
// Each move keeps every rectangle's old node: about 700 bytes each (measured: ~775).
assert!((300 * n..1500 * n).contains(&step), "{step} bytes for moving {n} rectangles");
// A budget of about three moves.
s.active_mut().unwrap().history.byte_budget = 3 * step;
for _ in 0..20 {
s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap();
}
let st = s.doc().unwrap();
assert_eq!(st.history.undo.len(), History::MIN_STEPS, "only the last few steps are kept");
assert!(st.history.undo.iter().all(|e| e.label == "Move"), "the newest ones: {:?}", st.history.undo.iter().map(|e| &e.label).collect::<Vec<_>>());
// The kept steps undo as before.
let x = first_x(&s);
for _ in 0..History::MIN_STEPS {
s.execute("edit.undo", &json!({})).unwrap();
}
assert_eq!(first_x(&s), x - History::MIN_STEPS as f64);
assert!(s.execute("edit.undo", &json!({})).is_err(), "the older steps went");
// Redo too stays within the budget, keeping the next few steps.
let st = s.doc().unwrap();
assert_eq!(st.history.redo.len(), History::MIN_STEPS);
for _ in 0..History::MIN_STEPS {
s.execute("edit.redo", &json!({})).unwrap();
}
assert_eq!(first_x(&s), x);
}
#[test]
fn within_the_budget_the_step_limit_still_counts() {
let mut s = session_with_rects(10);
s.active_mut().unwrap().history.limit = 12;
for _ in 0..30 {
s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap();
}
let st = s.doc().unwrap();
assert_eq!(st.history.undo.len(), 12);
assert!(st.history.bytes() < st.history.byte_budget);
}
#[test]
fn undo_steps_keep_no_lookup_tables() {
let mut s = session_with_rects(200);
for _ in 0..5 {
s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap();
// Lookups build the current document's table.
first_x(&s);
assert!(s.doc().unwrap().doc.has_lookup_cache());
}
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
let st = s.doc().unwrap();
assert!(!st.history.undo.is_empty() && !st.history.redo.is_empty());
for e in st.history.undo.iter().chain(&st.history.redo) {
assert!(!e.doc.has_lookup_cache(), "step {} keeps a lookup table", e.label);
}
// The document undone to builds its table again on its first lookup.
first_x(&s);
assert!(s.doc().unwrap().doc.has_lookup_cache());
// A drag's snapshot goes into the history without its table too.
s.begin_interaction("Move").unwrap();
s.preview("object.move", &json!({"dx": 3, "dy": 0})).unwrap();
first_x(&s);
s.commit_interaction().unwrap();
assert!(!s.doc().unwrap().history.undo.last().unwrap().doc.has_lookup_cache());
}
#[test]
fn steps_share_an_unchanged_selection() {
let mut s = session_with_rects(100);
for _ in 0..4 {
s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap();
}
s.begin_interaction("Move").unwrap();
s.preview("object.move", &json!({"dx": 3, "dy": 0})).unwrap();
s.preview("object.move", &json!({"dx": 4, "dy": 0})).unwrap();
s.commit_interaction().unwrap();
let st = s.doc().unwrap();
let moves: Vec<_> = st.history.undo.iter().filter(|e| e.label == "Move").collect();
assert_eq!(moves.len(), 5);
assert_eq!(moves[0].selection.objects.len(), 100);
assert!(moves.windows(2).all(|w| Arc::ptr_eq(&w[0].selection, &w[1].selection)), "one selection for all the moves");
// Undo and redo keep it shared, and restore it.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
let st = s.doc().unwrap();
assert!(Arc::ptr_eq(&st.history.redo[0].selection, &st.history.redo[1].selection));
assert_eq!(st.selection.objects.len(), 100);
}
/// Undo and redo don't estimate the steps again: the step an undo (redo) makes takes the undone
/// step's figures the other way round, which are what a fresh estimate gives.
#[test]
fn undo_and_redo_reuse_the_estimates() {
let mut s = session_with_rects(200);
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
// A deletion is lopsided: the step before it keeps the objects, the one after keeps nothing.
s.execute("edit.clear", &json!({})).unwrap();
let fresh = |e: &HistoryEntry, now: &Document| e.doc.retained_bytes(now);
let st = s.doc().unwrap();
let delete = st.history.undo.last().unwrap();
assert!(delete.bytes > 200 * 300, "the deleted objects: {}", delete.bytes);
s.execute("edit.undo", &json!({})).unwrap();
let st = s.doc().unwrap();
let redo = st.history.redo.last().unwrap();
assert_eq!(redo.bytes, fresh(redo, &st.doc));
assert!(redo.bytes < 4096, "the document without the objects keeps little of its own: {}", redo.bytes);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.redo", &json!({})).unwrap();
let st = s.doc().unwrap();
let undo = st.history.undo.last().unwrap();
assert_eq!(undo.label, "Move");
assert_eq!(undo.bytes, fresh(undo, &st.doc));
s.execute("edit.redo", &json!({})).unwrap();
let st = s.doc().unwrap();
let delete = st.history.undo.last().unwrap();
assert_eq!(delete.bytes, fresh(delete, &st.doc));
}

View File

@ -41,7 +41,7 @@ fn replay_reproduces_the_created_date_of_an_earlier_run() {
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
// As if the original run happened long ago: a replay now must still land on its date.
s.journal[0].1["created"] = json!(1_234_567_890);
s.journal.get_mut(0).unwrap().1["created"] = json!(1_234_567_890);
let mut r = Session::new();
for (id, p) in &s.journal {
r.execute(id, p).unwrap();
@ -63,7 +63,7 @@ fn recorded_actions_leave_out_replay_only_values() {
// …while values resolved from the preferences stay recorded.
let scale = steps.iter().find(|(id, _)| id == "object.scale").unwrap();
assert!(scale.1.get("strokes").is_some(), "{scale:?}");
assert_eq!(s.journal_for_action(1), s.journal[1..].to_vec());
assert_eq!(s.journal_for_action(1), s.journal.since(1).to_vec());
}
/// The artboard the app passes to Paste in Place, in Front and in Back, to Align to Artboard, to
@ -87,7 +87,7 @@ fn recorded_actions_leave_out_the_artboard_the_app_passes() {
for (id, p) in &steps {
s.execute(id, p).unwrap();
}
assert!(s.journal[start..].iter().all(|(_, p)| p["artboard"] == json!(1)), "the journal keeps it: {:?}", &s.journal[start..]);
assert!(s.journal.since(start).iter().all(|(_, p)| p["artboard"] == json!(1)), "the journal keeps it: {:?}", s.journal.since(start));
let action = s.journal_for_action(start);
assert_eq!(action.len(), steps.len());
for ((id, mut p), (step, recorded)) in steps.into_iter().zip(action) {

View File

@ -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 {

View File

@ -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:?}"),

View File

@ -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<LiveShape>) {
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;

View File

@ -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]

View File

@ -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.

View File

@ -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);

View File

@ -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);
}
}

View File

@ -458,3 +458,179 @@ fn without_a_store_the_commands_say_where_to_set_one() {
assert!(!area.join("Untitled-1-1.vectorcraft").exists());
let _ = std::fs::remove_dir_all(&dir);
}
/// A store in memory that notes the entries written to it.
#[derive(Default)]
struct Counting {
inner: MemoryStore,
written: std::sync::Mutex<Vec<(String, usize)>>,
}
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<String> {
self.inner.list().unwrap().into_iter().filter(|n| n.ends_with(".blob")).collect()
}
}
impl RecoveryStore for Counting {
fn list(&self) -> std::result::Result<Vec<String>, String> {
self.inner.list()
}
fn read(&self, name: &str) -> std::result::Result<Vec<u8>, 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<crate::cmd::recovery::Lock, String> {
self.inner.lock(area)
}
fn now(&self) -> Option<i64> {
self.inner.now()
}
}
fn place_png(s: &mut Session, name: &str, rgb: [u8; 3]) -> Vec<u8> {
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<String, Vec<u8>> {
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::<Vec<_>>(), [blue.len()]);
assert_eq!(store.images().len(), 2);
let images = images_of(&s);
assert_eq!(images.len(), 2);
// A crash: the copy restores with its images.
drop(s);
let mut s = Session::new();
s.recovery.set_store(store.clone());
assert_eq!(restored(s.execute("file.recovery.restore", &json!({})).unwrap()), 1);
assert_eq!(images_of(&s), images);
let mine = s.recovery.own_area().unwrap().to_string();
assert!(store.images().iter().all(|n| n.starts_with(&format!("{mine}/"))), "the images moved with the copy, the old area went");
assert_eq!(store.images().len(), 2);
// Its copy and another document's: once the first is closed, the next copy sweeps its images.
s.execute("file.new", &json!({"width": 50, "height": 50})).unwrap();
s.execute("shape.rectangle", &json!({"x": 1, "y": 1, "width": 5, "height": 5})).unwrap();
s.execute("file.close", &json!({"index": 0})).unwrap();
s.execute("file.recovery.save", &json!({})).unwrap();
assert!(store.images().is_empty(), "{:?}", store.list());
}
#[test]
fn copies_from_before_images_were_kept_apart_still_restore() {
let store = Arc::new(MemoryStore::default());
// The document entry is a whole native file, images inside; the details say nothing of them.
let mut s = session_in(&store);
place_png(&mut s, "red.png", crate::tests_links::RED);
let d = vectorcraft_doc::Document::clone(&s.doc().unwrap().doc);
let images = images_of(&s);
drop(s);
store.write("1-1/Old-1.vectorcraft", &vectorcraft_format::save_file(&d)).unwrap();
store.write("1-1/Old-1.json", br#"{"title": "Old", "format": "vectorcraft", "saved": 5}"#).unwrap();
let mut s = relaunch(&store);
assert_eq!(restored(s.execute("file.recovery.restore", &json!({})).unwrap()), 1);
assert_eq!(images_of(&s), images);
assert_eq!(s.doc().unwrap().title(), "Untitled-1 [Recovered]");
}
/// A store whose app dies right after writing a copy's document entry: later writes fail, so the
/// copy's details stay as the last interval left them.
struct DiesAfterDocument {
inner: Arc<MemoryStore>,
dead: std::sync::atomic::AtomicBool,
}
impl RecoveryStore for DiesAfterDocument {
fn list(&self) -> std::result::Result<Vec<String>, String> {
self.inner.list()
}
fn read(&self, name: &str) -> std::result::Result<Vec<u8>, String> {
self.inner.read(name)
}
fn write(&self, name: &str, bytes: &[u8]) -> std::result::Result<(), String> {
if self.dead.load(std::sync::atomic::Ordering::Relaxed) {
return Err("the app is gone".into());
}
self.inner.write(name, bytes)?;
if name.ends_with(".vectorcraft") {
self.dead.store(true, std::sync::atomic::Ordering::Relaxed);
}
Ok(())
}
fn remove(&self, name: &str) -> std::result::Result<(), String> {
self.inner.remove(name)
}
fn location(&self) -> String {
self.inner.location()
}
fn lock(&self, area: &str) -> std::result::Result<crate::cmd::recovery::Lock, String> {
self.inner.lock(area)
}
fn now(&self) -> Option<i64> {
self.inner.now()
}
}
/// A crash between writing a copy's document and its details restores the new document with
/// every image, the one placed since the last copy too (and the first copy likewise).
#[test]
fn a_crash_between_a_copys_document_and_its_details_keeps_every_image() {
for first in [true, false] {
let store = Arc::new(MemoryStore::default());
let mut s = session_in(&store);
if !first {
place_png(&mut s, "red.png", crate::tests_links::RED);
s.execute("file.recovery.save", &json!({})).unwrap();
}
place_png(&mut s, "blue.png", crate::tests_links::BLUE);
let images = images_of(&s);
s.recovery.set_store(Arc::new(DiesAfterDocument { inner: store.clone(), dead: Default::default() }));
assert!(s.execute("file.recovery.save", &json!({})).is_err(), "the details weren't written");
drop(s);
let mut s = relaunch(&store);
assert_eq!(restored(s.execute("file.recovery.restore", &json!({})).unwrap()), 1, "first copy: {first}");
assert_eq!(images_of(&s), images, "first copy: {first}");
}
}

View File

@ -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}));

View File

@ -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"));

View File

@ -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]}));

View File

@ -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);

View File

@ -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());

View File

@ -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");
}

View File

@ -148,7 +148,7 @@ fn grid_continue(preselected: bool, snap_to_grid: bool) {
assert_eq!(path_ids(&s), vec![id], "clicking the advertised endpoint must continue it, not create a snapped path");
assert_eq!(node(&s, id), &original);
assert_eq!(s.doc().unwrap().selection.objects, vec![id]);
assert!(!s.journal[journal_start..].iter().any(|(command, _)| command == "path.create"));
assert!(!s.journal.since(journal_start).iter().any(|(command, _)| command == "path.create"));
let before_append = s.doc().unwrap().history.undo.len();
gesture(&mut s, view, &[(200.0, 300.0)], Mods::default());
assert_eq!(path_ids(&s), vec![id]);
@ -328,7 +328,7 @@ fn snapped_only_continue(preselected: bool) {
assert_eq!(s.doc().unwrap().selection.objects, vec![id]);
let expected = if first { [(200.0, 300.0), (100.0, 300.0)] } else { [(100.0, 300.0), (200.0, 300.0)] };
assert_eq!(points(&s, id), expected, "continue from the snapped end: selected={preselected}, first={first}");
assert!(!s.journal[journal_start..].iter().any(|(command, _)| command == "path.create"));
assert!(!s.journal.since(journal_start).iter().any(|(command, _)| command == "path.create"));
assert!(!s.in_interaction() && !s.tool_busy());
assert_eq!(s.doc().unwrap().history.undo.len(), before_continue + usize::from(first));
let continued = node(&s, id).clone();
@ -342,7 +342,7 @@ fn snapped_only_continue(preselected: bool) {
assert_eq!(path_ids(&s), vec![id]);
assert_eq!(s.doc().unwrap().selection.objects, vec![id]);
assert_eq!(points(&s, id), [expected[0], expected[1], next]);
assert!(!s.journal[journal_start..].iter().any(|(command, _)| command == "path.create"));
assert!(!s.journal.since(journal_start).iter().any(|(command, _)| command == "path.create"));
assert!(!s.in_interaction() && !s.tool_busy());
assert_eq!(s.doc().unwrap().history.undo.len(), before_append + 1);
let extended = node(&s, id).clone();
@ -393,7 +393,7 @@ fn raw_unselected_endpoint_precedes_snapped_selected_endpoint() {
assert_eq!(path_ids(&s), vec![selected, raw]);
assert_eq!(node(&s, selected), &original_selected);
assert_eq!(node(&s, raw), &original_raw);
assert!(!s.journal[journal_start..].iter().any(|(command, _)| command == "path.create"));
assert!(!s.journal.since(journal_start).iter().any(|(command, _)| command == "path.create"));
assert!(!s.in_interaction() && !s.tool_busy());
assert_eq!(s.doc().unwrap().history.undo.len(), before_append);
@ -402,7 +402,7 @@ fn raw_unselected_endpoint_precedes_snapped_selected_endpoint() {
assert_eq!(s.doc().unwrap().selection.objects, vec![raw]);
assert_eq!(node(&s, selected), &original_selected);
assert_eq!(points(&s, raw), [(13.0, 303.0), (63.0, 303.0), (200.0, 400.0)]);
assert!(!s.journal[journal_start..].iter().any(|(command, _)| command == "path.create"));
assert!(!s.journal.since(journal_start).iter().any(|(command, _)| command == "path.create"));
assert!(!s.in_interaction() && !s.tool_busy());
assert_eq!(s.doc().unwrap().history.undo.len(), before_append + 1);
let extended = node(&s, raw).clone();

View File

@ -1054,7 +1054,7 @@ impl<'a> Reader<'a> {
true
});
}
if let Some(m) = &mut n.mask {
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
self.reid(Arc::make_mut(&mut m.art));
}
if let Some(children) = n.children_mut() {
@ -1437,7 +1437,7 @@ impl<'a> Reader<'a> {
self.doc.images.entry(key.clone()).or_insert(blob);
let gs = self.gs.clone();
let im = ImageObject { key, width: w as u32, height: h as u32, xf, link: None, placement: Default::default() };
let mut node = self.new_node(NodeKind::Image(im), &gs);
let mut node = self.new_node(NodeKind::Image(Box::new(im)), &gs);
node.opacity = gs.opacity;
node.blend = gs.blend;
self.add(node, false)
@ -1528,7 +1528,8 @@ impl<'a> Reader<'a> {
}
if self.color_model.unwrap_or(self.cmyk_colors > self.rgb_colors) {
doc.color_mode = ColorMode::Cmyk;
(doc.swatches, doc.swatch_groups) = vectorcraft_color::default_swatches(ColorMode::Cmyk.model());
let (swatches, groups) = vectorcraft_color::default_swatches(ColorMode::Cmyk.model());
(doc.swatches, doc.swatch_groups) = (swatches.into(), groups.into());
}
for n in &self.spots {
if doc.swatch(&n.name).is_none() {

View File

@ -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(())
}
}

Some files were not shown because too many files have changed in this diff Show More