diff --git a/crates/engine/src/cmd/fileio/affinity/master_copies.rs b/crates/engine/src/cmd/fileio/affinity/master_copies.rs index 91b0ce53c..f0ab8bd40 100644 --- a/crates/engine/src/cmd/fileio/affinity/master_copies.rs +++ b/crates/engine/src/cmd/fileio/affinity/master_copies.rs @@ -1,7 +1,8 @@ use super::nodes::MAX_DEPTH; use super::{Kind, af}; -/// What copying a subtree takes: its nodes, characters and table cells (`work`), and its pixels. +/// What copying a subtree takes: its nodes, characters, table cells and path segments (`work`), +/// and its pixels. #[derive(Clone, Copy, Debug, Default)] pub(super) struct CopyCost { pub(super) work: u64, @@ -40,8 +41,58 @@ pub(super) fn copy_cost(n: &af::Node, depth: usize) -> CopyCost { let own = match &n.kind { Kind::Text(t) => CopyCost { work: 1 + len(&t.runs), pixels: 0 }, Kind::Table(t) => CopyCost { work: 1 + t.cells.iter().map(|c| 1 + len(&c.runs)).sum::(), pixels: 0 }, - Kind::Image(img) => CopyCost { work: 1, pixels: u64::from(img.width) * u64::from(img.height) }, + Kind::Image(img) => CopyCost { work: 1, pixels: pixels(img) }, + // Each copy builds its own path: a curve of many segments costs them all. + Kind::Shape { path, .. } | Kind::Artboard { path, .. } => CopyCost { work: 1 + segments(path), pixels: 0 }, _ => CopyCost { work: 1, pixels: 0 }, }; - n.children.iter().fold(own, |c, ch| c.plus(copy_cost(ch, depth + 1))) + // A vector mask is a path of its own, and a pixel mask an image of its own, in every copy. + let mask = + CopyCost { work: n.mask.as_ref().map_or(0, segments) + u64::from(n.pixel_mask.is_some()), pixels: n.pixel_mask.as_ref().map_or(0, pixels) }; + n.children.iter().fold(own.plus(mask), |c, ch| c.plus(copy_cost(ch, depth + 1))) +} + +/// The subpaths and segments of `p`. +fn segments(p: &af::Path) -> u64 { + p.subpaths.iter().map(|s| 1 + s.segments.len() as u64).sum() +} + +fn pixels(img: &af::Image) -> u64 { + u64::from(img.width) * u64::from(img.height) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn node(kind: Kind, mask: Option) -> af::Node { + af::Node { + class: vectorcraft_affinity::stream::Tag::of(b"ShpN"), + name: String::new(), + visible: true, + locked: false, + opacity: 1.0, + blend: af::Blend::Normal, + kind, + mask, + pixel_mask: None, + effects: vec![], + children: vec![], + } + } + + fn path(n: usize) -> af::Path { + let p = af::Point { x: 0.0, y: 0.0 }; + af::Path { subpaths: vec![af::SubPath { start: p, segments: vec![[p; 3]; n], closed: false }] } + } + + /// A curve, and a vector mask, cost every segment each copy rebuilds: a shape of a million + /// segments shown on many pages can't pass as one object. + #[test] + fn paths_and_masks_cost_their_segments() { + let shape = |n| Kind::Shape { path: path(n), fills: vec![], strokes: vec![], even_odd: false }; + assert!(copy_cost(&node(shape(1_000_000), None), 0).work > 1_000_000); + assert!(copy_cost(&node(Kind::Group, Some(path(1_000_000))), 0).work > 1_000_000); + assert!(copy_cost(&node(shape(3), None), 0).work < 10); + } } diff --git a/crates/engine/src/cmd/fileio/affinity/nodes.rs b/crates/engine/src/cmd/fileio/affinity/nodes.rs index 2e9334227..36734dbe3 100644 --- a/crates/engine/src/cmd/fileio/affinity/nodes.rs +++ b/crates/engine/src/cmd/fileio/affinity/nodes.rs @@ -11,6 +11,15 @@ pub(super) fn affine(a: af::Affine) -> Affine { Affine::new(a.0) } +/// Are all of `p`'s points, mapped by `to_doc`, finite and within `MAX_COORD` of the origin? +pub(super) fn path_in_range(p: &af::Path, to_doc: Affine) -> bool { + let ok = |q: &af::Point| { + let d = to_doc * kurbo::Point::new(q.x, q.y); + [d.x, d.y].iter().all(|v| v.is_finite() && v.abs() <= crate::MAX_COORD) + }; + p.subpaths.iter().all(|s| ok(&s.start) && s.segments.iter().flatten().all(ok)) +} + impl Builder { pub(super) fn layer_with(&mut self, name: Option<&str>, children: Vec>) -> Arc { let id = self.doc.alloc_id(); @@ -107,6 +116,12 @@ impl Builder { return None; } } + // So must its vector mask, which becomes a clipping path: every control point, not just + // the finite ones a bounding box takes. + if n.mask.as_ref().is_some_and(|m| !path_in_range(m, to_doc)) { + self.warn("objects beyond the document range VectorCraft supports (left out)"); + return None; + } let n = &*self.overlaid(n); // Register before descending so nested artboards keep their document order. Inside an // embedded document an artboard is just its content's frame. @@ -252,3 +267,25 @@ impl Builder { Some(Arc::new(out)) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn path(points: &[(f64, f64)]) -> af::Path { + let p = |&(x, y): &(f64, f64)| af::Point { x, y }; + let start = p(&points[0]); + let segments = points[1..].iter().map(|q| [start, p(q), p(q)]).collect(); + af::Path { subpaths: vec![af::SubPath { start, segments, closed: true }] } + } + + /// A mask's control points are checked as mapped into the document, NaN ones included. + #[test] + fn mask_paths_must_stay_in_the_document_range() { + let k = Affine::scale(0.75); + assert!(path_in_range(&path(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)]), k)); + assert!(!path_in_range(&path(&[(0.0, 0.0), (1.0e7, 0.0)]), k)); + assert!(!path_in_range(&path(&[(0.0, 0.0), (f64::NAN, 0.0)]), k)); + assert!(!path_in_range(&path(&[(0.0, 0.0), (1.0, 1.0)]), Affine::scale(1.0e9))); + } +}