Merge pull request #1245 from arhammusheer/perf/memory
perf: bound memory on large documents (#1244)
This commit is contained in:
commit
04a305367e
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(©) < 2048, "{}", d.retained_bytes(©));
|
||||
assert_eq!(d.retained_bytes(&d), 0);
|
||||
}
|
||||
|
||||
/// Moving every rectangle: each keeps its old node and anchors (its appearance stays shared).
|
||||
/// The process grew by about 775 bytes per moved rectangle when measured (#1244), about 490
|
||||
/// once nodes kept their rarely set parts out of line.
|
||||
#[test]
|
||||
fn moving_rectangles_costs_their_nodes() {
|
||||
let n = 1000;
|
||||
let before = doc_with_rects(n);
|
||||
let mut after = before.clone();
|
||||
let ids: Vec<_> = after.layers[0].children().unwrap().iter().map(|c| c.id).collect();
|
||||
for id in ids {
|
||||
let node = after.node_mut(id).unwrap();
|
||||
if let NodeKind::Path { path, .. } = &mut node.kind {
|
||||
for a in path.subpaths.iter_mut().flat_map(|s| s.anchors.iter_mut()) {
|
||||
a.p.x += 5.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
let per = before.retained_bytes(&after) / n;
|
||||
assert!((400..1200).contains(&per), "{per} bytes per moved rectangle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grouped_object_is_found_shared() {
|
||||
let before = doc_with_rects(100);
|
||||
let mut after = before.clone();
|
||||
let ids: Vec<_> = after.layers[0].children().unwrap().iter().map(|c| c.id).collect();
|
||||
// Put the rectangles in a group (the same Arcs, elsewhere).
|
||||
let gid = after.alloc_id();
|
||||
let layer = Arc::make_mut(&mut after.layers[0]);
|
||||
let kids = std::mem::take(layer.children_mut().unwrap());
|
||||
layer.children_mut().unwrap().push(Arc::new(Node::group(gid, kids)));
|
||||
assert_eq!(ids.len(), 100);
|
||||
let bytes = before.retained_bytes(&after);
|
||||
assert!(bytes < 8192, "only the old layer and its child list: {bytes}");
|
||||
}
|
||||
|
||||
/// One walk gives both ways, each what a walk the other way gives; an insertion and a deletion
|
||||
/// cost the side that has the objects.
|
||||
#[test]
|
||||
fn both_ways_at_once() {
|
||||
let before = doc_with_rects(100);
|
||||
let mut after = before.clone();
|
||||
let layer = after.layers[0].id;
|
||||
for at in [0, 50, usize::MAX] {
|
||||
let id = after.alloc_id();
|
||||
let r = shapes::rectangle(kurbo::Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
after.insert(Some(layer), at, Node::path(id, r, Appearance::default_art())).unwrap();
|
||||
}
|
||||
let (kept, added) = before.retained_bytes_each(&after);
|
||||
assert_eq!((kept, added), (before.retained_bytes(&after), after.retained_bytes(&before)));
|
||||
assert_eq!(after.retained_bytes_each(&before), (added, kept));
|
||||
// The old layer and its list are the old version's own; the new one holds three new nodes.
|
||||
assert!(kept < 4096, "{kept}");
|
||||
assert!(added > kept + 3 * 300, "{added} for three new rectangles");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resource_counts_only_when_changed() {
|
||||
let before = doc_with_rects(1);
|
||||
let mut after = before.clone();
|
||||
let same = before.retained_bytes(&after);
|
||||
after.swatches.push(before.swatches[1].clone());
|
||||
assert!(before.retained_bytes(&after) > same + 1000, "the old swatch list is the old version's own");
|
||||
}
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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]));
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(_)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
})?;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()?;
|
||||
|
|
|
|||
|
|
@ -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() })))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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, _| {
|
||||
|
|
|
|||
|
|
@ -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))));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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:?}"),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}));
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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]}));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue