mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 10:17:02 -06:00
Rarely set fields move into one optional boxed NodeExtras, and large NodeKind payloads (images, live shapes, blends, envelopes) are boxed. Every object, and every undo step that copies it, shrinks: a 50k-path move keeps ~24.5 MB per step instead of ~40 MB. Files stay byte-identical (Node keeps its flat JSON through a borrowed Serialize and a from-wire Deserialize, no flatten), and loading is slightly faster. Undo and redo reuse their step's size estimate instead of walking the document again (8.3 -> 5.6 ms for a 50k-path move). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc # Conflicts: # crates/engine/src/cmd/graph.rs # crates/svg/src/export.rs
127 lines
6.1 KiB
Rust
127 lines
6.1 KiB
Rust
//! Slice layout: object slices follow their objects, clipping, numbering and auto slices.
|
|
|
|
use proptest::prelude::*;
|
|
use vectorcraft_geom::{Point, Rect, shapes};
|
|
|
|
use crate::slices::auto_slices;
|
|
use crate::{Appearance, Document, Node, Slice, SliceOptions, SliceSource};
|
|
|
|
fn rect(d: &mut Document, r: Rect) -> crate::NodeId {
|
|
let l = d.layers[0].id;
|
|
let id = d.alloc_id();
|
|
d.insert(Some(l), usize::MAX, Node::path(id, shapes::rectangle(r), Appearance::basic(crate::color::Paint::None, crate::color::Paint::None, 0.0)))
|
|
.unwrap();
|
|
id
|
|
}
|
|
|
|
fn user(d: &mut Document, r: Rect) -> crate::NodeId {
|
|
let id = d.alloc_id();
|
|
d.slices.push(Slice { id, rect: r, options: SliceOptions::default() });
|
|
id
|
|
}
|
|
|
|
/// How many of `rects` hold point `p` (strictly inside).
|
|
fn holders(rects: &[Rect], p: Point) -> usize {
|
|
rects.iter().filter(|r| r.x0 < p.x && p.x < r.x1 && r.y0 < p.y && p.y < r.y1).count()
|
|
}
|
|
|
|
#[test]
|
|
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().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)));
|
|
// Three rows: the band above, the object's row (left, object, right) and the band below.
|
|
assert_eq!(lay.len(), 5);
|
|
assert_eq!(lay.iter().map(|s| s.number).collect::<Vec<_>>(), [1, 2, 3, 4, 5]);
|
|
assert_eq!(own.number, 3);
|
|
assert_eq!(d.slice_name(own), "Untitled-1_03");
|
|
// Moving the object moves its slice.
|
|
d.node_mut(a).unwrap().transform(vectorcraft_geom::Affine::translate((-50.0, 0.0)), crate::Scaling::default());
|
|
assert_eq!(d.slice_layout().iter().find(|s| s.id == Some(a)).unwrap().rect, Rect::new(50.0, 50.0, 150.0, 150.0));
|
|
// Hidden objects have no slice.
|
|
d.node_mut(a).unwrap().visible = false;
|
|
assert!(d.slice_layout().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn clip_to_artboard_clips_slices_and_off_covers_the_art() {
|
|
let mut d = Document::new(100.0, 100.0);
|
|
let s = user(&mut d, Rect::new(50.0, 50.0, 150.0, 150.0));
|
|
let own = |d: &Document| d.slice_layout().into_iter().find(|a| a.id == Some(s)).unwrap().rect;
|
|
assert_eq!(own(&d), Rect::new(50.0, 50.0, 100.0, 100.0));
|
|
let lay = d.slice_layout();
|
|
let autos: Vec<Rect> = lay.iter().filter(|a| a.source == SliceSource::Auto).map(|a| a.rect).collect();
|
|
assert_eq!(autos.iter().map(|r| r.area()).sum::<f64>(), 100.0 * 100.0 - 50.0 * 50.0);
|
|
d.slices_clip_to_artboard = false;
|
|
assert_eq!(own(&d), Rect::new(50.0, 50.0, 150.0, 150.0));
|
|
// The region is now the slice itself (no art): nothing left for auto slices.
|
|
assert_eq!(d.slice_layout().len(), 1);
|
|
rect(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0));
|
|
let total: f64 = d.slice_layout().iter().map(|a| a.rect.area()).sum();
|
|
assert_eq!(total, 150.0 * 150.0);
|
|
}
|
|
|
|
#[test]
|
|
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().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().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();
|
|
assert!(!plain.contains("slice"), "{plain}");
|
|
let old: Document = serde_json::from_str(&plain).unwrap();
|
|
assert!(old.slices.is_empty() && old.slices_clip_to_artboard);
|
|
}
|
|
|
|
#[test]
|
|
fn auto_slices_fill_around_a_slice_in_rows() {
|
|
let autos = auto_slices(Rect::new(0.0, 0.0, 90.0, 90.0), &[Rect::new(30.0, 30.0, 60.0, 60.0)]);
|
|
let mut got: Vec<(f64, f64, f64, f64)> = autos.iter().map(|r| (r.x0, r.y0, r.x1, r.y1)).collect();
|
|
got.sort_by(|a, b| a.1.total_cmp(&b.1).then(a.0.total_cmp(&b.0)));
|
|
assert_eq!(got, [(0.0, 0.0, 90.0, 30.0), (0.0, 30.0, 30.0, 60.0), (60.0, 30.0, 90.0, 60.0), (0.0, 60.0, 90.0, 90.0)]);
|
|
// Spans that continue down one column merge.
|
|
let autos = auto_slices(Rect::new(0.0, 0.0, 90.0, 90.0), &[Rect::new(30.0, 0.0, 60.0, 40.0), Rect::new(30.0, 40.0, 60.0, 90.0)]);
|
|
assert_eq!(autos.len(), 2, "{autos:?}");
|
|
}
|
|
|
|
fn arb_rect() -> impl Strategy<Value = Rect> {
|
|
(-20.0..120.0f64, -20.0..120.0f64, 1.0..80.0f64, 1.0..80.0f64).prop_map(|(x, y, w, h)| Rect::new(x, y, x + w, y + h))
|
|
}
|
|
|
|
proptest! {
|
|
/// Auto slices never overlap each other or the slices, and with the slices they cover the
|
|
/// whole region.
|
|
#[test]
|
|
fn auto_slices_cover_without_overlap(cut in prop::collection::vec(arb_rect(), 0..8)) {
|
|
let region = Rect::new(0.0, 0.0, 100.0, 100.0);
|
|
let autos = auto_slices(region, &cut);
|
|
for r in &autos {
|
|
prop_assert!(r.x0 >= region.x0 && r.y0 >= region.y0 && r.x1 <= region.x1 && r.y1 <= region.y1, "{r:?} leaves the region");
|
|
}
|
|
// Sample off every edge (edges are at integers plus random fractions; the samples sit at
|
|
// odd 1/8ths, which a random float edge practically never hits).
|
|
for i in 0..50 {
|
|
for j in 0..50 {
|
|
let p = Point::new(i as f64 * 2.0 + 0.125 * 7.0, j as f64 * 2.0 + 0.125 * 3.0);
|
|
let in_cut = holders(&cut, p) > 0;
|
|
let in_auto = holders(&autos, p);
|
|
prop_assert!(in_auto <= 1, "{p:?} in {in_auto} auto slices");
|
|
prop_assert!(in_cut != (in_auto == 1), "{p:?}: in a slice {in_cut}, in an auto slice {in_auto}");
|
|
}
|
|
}
|
|
}
|
|
}
|