diff --git a/Cargo.lock b/Cargo.lock index ee3905a8a..404abacf8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2844,6 +2844,182 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jxl-bitstream" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b480e752277e29eb4054f69546887a9b84656fe78c08f54ba5850ced98a378fe" +dependencies = [ + "tracing", +] + +[[package]] +name = "jxl-coding" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd972bcd125e776f1eb241ac50e39f956095a1c2770c64736c968f8946bd9a3c" +dependencies = [ + "jxl-bitstream", + "tracing", +] + +[[package]] +name = "jxl-color" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f316b1358c1711755b3ee8e8cb5c4a1dad12e796233088a7a513440782de80b2" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-grid", + "jxl-image", + "jxl-oxide-common", + "jxl-threadpool", + "tracing", +] + +[[package]] +name = "jxl-frame" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d967c6fd669c7c01060b5022d8835fa82fd46b06ffc98b549f17600a097c2b3" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-grid", + "jxl-image", + "jxl-modular", + "jxl-oxide-common", + "jxl-threadpool", + "jxl-vardct", + "tracing", +] + +[[package]] +name = "jxl-grid" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01671307879a033bfa52e6e8784b941aca770b3f3a7d33830b455b6844f793fb" +dependencies = [ + "tracing", +] + +[[package]] +name = "jxl-image" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5f752d62577c702a94dbbce4045caf08cb58639e8a4d56464b40ecf33ffe565" +dependencies = [ + "jxl-bitstream", + "jxl-grid", + "jxl-oxide-common", + "tracing", +] + +[[package]] +name = "jxl-jbr" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35d032bcec660647828527ff42c6f5776d2fd44b8357f9f6d9ac6dc07218e46" +dependencies = [ + "brotli-decompressor", + "jxl-bitstream", + "jxl-frame", + "jxl-grid", + "jxl-image", + "jxl-modular", + "jxl-oxide-common", + "jxl-threadpool", + "jxl-vardct", + "tracing", +] + +[[package]] +name = "jxl-modular" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2f045b24c738dd91d482be385512b512721ae08a671bd4b27bf1c47f215235" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-grid", + "jxl-oxide-common", + "jxl-threadpool", + "tracing", +] + +[[package]] +name = "jxl-oxide" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d36c662923f47586880211f3bc7c0d83fb3a9b410d278c7bde93450748abeef3" +dependencies = [ + "brotli-decompressor", + "jxl-bitstream", + "jxl-color", + "jxl-frame", + "jxl-grid", + "jxl-image", + "jxl-jbr", + "jxl-oxide-common", + "jxl-render", + "jxl-threadpool", + "tracing", +] + +[[package]] +name = "jxl-oxide-common" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62394c5021b3a9e7e0dbb2d639d555d019090c9946c39f6d3b09d390db4157b" +dependencies = [ + "jxl-bitstream", +] + +[[package]] +name = "jxl-render" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d34386bfdb6a19b5a30cc9beb4d475d537422c31ae8c39bb69640fcce3fcaf19" +dependencies = [ + "bytemuck", + "jxl-bitstream", + "jxl-coding", + "jxl-color", + "jxl-frame", + "jxl-grid", + "jxl-image", + "jxl-modular", + "jxl-oxide-common", + "jxl-threadpool", + "jxl-vardct", + "tracing", +] + +[[package]] +name = "jxl-threadpool" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f15eb830aa77a7f21148d72e153562a26bfe570139bd4922eab1908dd499d3" +dependencies = [ + "tracing", +] + +[[package]] +name = "jxl-vardct" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce72a18c6d3a47172ab6c479be2bdb56f22066b5d7092663f03b4490820b4511" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-grid", + "jxl-modular", + "jxl-oxide-common", + "jxl-threadpool", + "tracing", +] + [[package]] name = "keyboard-types" version = "0.8.3" @@ -6292,6 +6468,9 @@ dependencies = [ "hayro-jpeg2000", "heic-rs", "image", + "jxl-color", + "jxl-grid", + "jxl-oxide", "kurbo 0.13.1", "log", "proptest", @@ -6323,6 +6502,8 @@ dependencies = [ "vectorcraft-tools", "vectorcraft-trace", "wat", + "zune-core 0.5.3", + "zune-jpegxl", ] [[package]] @@ -8097,6 +8278,15 @@ dependencies = [ "zune-core 0.5.3", ] +[[package]] +name = "zune-jpegxl" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cba11ecd07cc23351500e840ae03841b7e4997923ec8e4832ee3ae199ea0b6b4" +dependencies = [ + "zune-core 0.5.3", +] + [[package]] name = "zvariant" version = "5.15.1" diff --git a/Cargo.toml b/Cargo.toml index a3e3e9890..6dbf4a4d0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -105,6 +105,16 @@ gif = { version = "0.14", default-features = false, features = ["std", "raii_no_ # decoders, read directly (the image crate converts CMYK to RGB). tiff = "0.11" zune-jpeg = "0.5" +# JPEG XL decoder (pure Rust, MIT OR Apache-2.0): File › Open and Place read `.jxl` images. It is +# built without its default `rayon` feature and decodes on one thread. +jxl-oxide = { version = "0.12.6", default-features = false } +# jxl-oxide's color conversions, run on colors after their alpha is divided out. +jxl-color = "0.11.0" +# jxl-oxide's buffer allocation error, told apart from other decoding errors. +jxl-grid = "0.6.2" +# Tests: a lossless JPEG XL encoder (pure Rust, MIT OR Apache-2.0 OR Zlib) for the `.jxl` test files. +zune-jpegxl = { version = "0.5.2", default-features = false, features = ["std"] } +zune-core = "0.5" # ui egui = "0.37" diff --git a/README.md b/README.md index 08aa022a3..dc7a1cfa5 100644 --- a/README.md +++ b/README.md @@ -237,7 +237,7 @@ and what's next; [`docs/target-app-parity.md`](docs/target-app-parity.md) the fu (92.5% of menu items do something), feature depth is ~75%, and we estimate ~65% ready for real work for a mainstream illustrator (~62% across all tracked features): everyday vector illustration is close to usable: drawing and path tools, Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and -files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package). +files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, JPEG XL in, Print, Package). Affinity documents (`.af` from Affinity 3, `.afdesign`, `.afpub` and, by their content, `.afphoto` from Affinity 1 and 2) open and place natively: layers, groups, artboards and pages, curves and shapes, fills, gradients and strokes, clipping and masks, text with each paragraph's indents, spacing and leading, tables (as text frames and lines), master diff --git a/crates/color/src/freeform.rs b/crates/color/src/freeform.rs index 798367ba4..11884d5c6 100644 --- a/crates/color/src/freeform.rs +++ b/crates/color/src/freeform.rs @@ -395,6 +395,15 @@ impl Source { } } +/// The centers of a `cols` × `rows` grid of cells over `b`, row by row. +fn cell_centers(b: Rect, cols: u16, rows: u16) -> impl Iterator { + let (cw, ch) = (b.width() / cols as f64, b.height() / rows as f64); + (0..rows as usize * cols as usize).map(move |k| { + let (i, j) = (k % cols as usize, k / cols as usize); + Point::new(b.x0 + (i as f64 + 0.5) * cw, b.y0 + (j as f64 + 0.5) * ch) + }) +} + /// A freeform gradient ready to sample ([`Freeform::field`]). #[derive(Clone, Debug)] pub struct Field { @@ -429,11 +438,7 @@ impl Field { /// Samples at the centres of a `cols` × `rows` grid of cells over `b`, row by row: RGB and /// opacity (as [`Self::sample`]). pub fn grid(&self, b: Rect, cols: u16, rows: u16) -> impl Iterator + '_ { - let (cw, ch) = (b.width() / cols as f64, b.height() / rows as f64); - (0..rows as usize * cols as usize).map(move |k| { - let (i, j) = (k % cols as usize, k / cols as usize); - self.sample(Point::new(b.x0 + (i as f64 + 0.5) * cw, b.y0 + (j as f64 + 0.5) * ch)) - }) + cell_centers(b, cols, rows).map(move |p| self.sample(p)) } /// Display RGB and opacity at `p`. @@ -503,6 +508,27 @@ impl ModelField { }; (c, a) } + + /// [`Self::sample`] at the centers of a `cols` × `rows` grid of cells over `b`, row by row. + pub fn grid(&self, b: Rect, cols: u16, rows: u16) -> impl Iterator + '_ { + cell_centers(b, cols, rows).map(move |p| self.sample(p)) + } + + /// Display RGB and opacity at `p`: the display color of [`Self::sample`], or the display RGB + /// the points mix in when their models differ. + pub fn display_sample(&self, p: Point) -> ([f32; 3], f32) { + if self.model.is_none() { + return self.first.sample(p); + } + let (c, a) = self.sample(p); + (c.to_rgb(), a) + } + + /// [`Self::display_sample`] at the centers of a `cols` × `rows` grid of cells over `b`, row by + /// row. + pub fn display_grid(&self, b: Rect, cols: u16, rows: u16) -> impl Iterator + '_ { + cell_centers(b, cols, rows).map(move |p| self.display_sample(p)) + } } #[cfg(test)] @@ -530,6 +556,22 @@ mod tests { a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-4) } + #[test] + fn the_model_field_grid_and_display_samples_match_its_sample() { + let mut f = two(0.0); + (f.points[0].color, f.points[1].color) = (Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.0, 1.0)); + let field = f.model_field(50.0); + let b = Rect::new(0.0, 0.0, 100.0, 20.0); + let (cells, shown): (Vec<_>, Vec<_>) = (field.grid(b, 5, 1).collect(), field.display_grid(b, 5, 1).collect()); + let (mid, a) = field.sample(Point::new(50.0, 10.0)); + assert_eq!(cells[2], (mid, a)); + assert_eq!(shown[2], (mid.to_rgb(), a)); + // Points in different models give their display mix. + (f.points[0].color, f.points[1].color) = (red(), Color::gray(0.5)); + let (rgb, _) = f.model_field(50.0).display_sample(Point::new(50.0, 0.0)); + assert!(close(rgb, [0.75, 0.25, 0.25]), "{rgb:?}"); + } + #[test] fn the_sample_at_a_point_is_its_colour() { let f = two(0.0); diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index a04ee7c38..f099b5f95 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -84,8 +84,9 @@ impl Default for HitOptions { /// The shapes that an image's own fills and strokes paint, each with its fill rule and the fill or /// stroke that paints it (none for fills and strokes that paint nothing). Effects are evaluated -/// above this crate, so the engine registers one with [`set_image_paint`]. -pub type ImagePaint = fn(&Node) -> Vec<(BezPath, FillRule, AppearanceItem)>; +/// above this crate, so the engine registers one with [`set_image_paint`]. It looks up swatches and +/// patterns in the document it is given. +pub type ImagePaint = fn(Option<&Document>, &Node) -> Vec<(BezPath, FillRule, AppearanceItem)>; static IMAGE_PAINT: OnceLock = OnceLock::new(); @@ -98,8 +99,8 @@ pub fn set_image_paint(f: ImagePaint) { /// The shapes that the image `n`'s own fills and strokes paint ([`set_image_paint`]), none while /// nothing is registered. -pub(crate) fn image_paint(n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> { - IMAGE_PAINT.get().map_or_else(Vec::new, |f| f(n)) +pub(crate) fn image_paint(doc: Option<&Document>, n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> { + IMAGE_PAINT.get().map_or_else(Vec::new, |f| f(doc, n)) } /// One level of evaluation of a live object in a document, as the canvas draws it: type in an @@ -871,7 +872,7 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio if opt.outline { return None; } - image_paint(n).iter().rev().find_map(|(bp, rule, item)| match item { + image_paint(Some(symbols.document()), n).iter().rev().find_map(|(bp, rule, item)| match item { AppearanceItem::Stroke(s) => s.hits(bp, *rule, p, opt.tol).then_some(HitKind::Stroke), AppearanceItem::Fill(_) => (!opt.path_only && fill_contains(bp, *rule, p)).then_some(HitKind::Fill), }) diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index f908e50a1..f76957258 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -239,10 +239,12 @@ impl Node { NodeKind::Intertwine { children } => children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.reach(symbols))), // The frame and each shape the image's own fills and strokes paint, a stroke's grown by // the farthest it can reach from its shape. - NodeKind::Image(_) => crate::hit::image_paint(self).iter().fold(self.geometric(symbols), |acc, (bp, _, item)| { - let o = if let crate::AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 }; - vectorcraft_geom::union_opt(acc, (!bp.elements().is_empty()).then(|| bp.bounding_box().inflate(o, o))) - }), + NodeKind::Image(_) => { + crate::hit::image_paint(symbols.map(SymbolBoxes::document), self).iter().fold(self.geometric(symbols), |acc, (bp, _, item)| { + let o = if let crate::AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 }; + vectorcraft_geom::union_opt(acc, (!bp.elements().is_empty()).then(|| bp.bounding_box().inflate(o, o))) + }) + } // Keys and steps placed on a spine, turned to it with Align to Path, can reach past the // keys and the spine. NodeKind::Blend { children, spec } => { diff --git a/crates/doc/src/variables.rs b/crates/doc/src/variables.rs index de37364fa..96c793496 100644 --- a/crates/doc/src/variables.rs +++ b/crates/doc/src/variables.rs @@ -95,8 +95,9 @@ pub struct DataSet { impl DataSet { /// Whether the art, as [`Document::captured_values`] read it, still holds what this row - /// says. A value the art can't report (its object is locked or gone) isn't drift: the row - /// keeps it, and applying it again would skip it too. + /// says. A value the art can't report (its object is gone, or is locked or in a locked layer + /// or group and the variable isn't a visibility variable) isn't drift: the row keeps it, and + /// applying it again would skip it too. pub fn matches(&self, current: &BTreeMap) -> bool { self.values.iter().all(|(k, v)| current.get(k).is_none_or(|c| c == v)) } @@ -193,33 +194,35 @@ impl Document { self.variables.bindings = bindings; } - /// Whether a variable may be bound to the object `id`: it is in the document, neither it nor - /// anything above it is locked, nothing above it is hidden, and it is not on a locked artboard - /// ([`Document::on_locked_artboard`]). Its own visibility doesn't count, as that is what a - /// visibility variable drives. - pub fn variable_target(&self, id: NodeId) -> bool { - self.variable_target_with(&self.locked_artboards(), id) + /// Whether a variable of `kind` may be bound to the object `id`: it is in the document, + /// nothing above it is hidden, it is not on a locked artboard ([`Document::on_locked_artboard`]), + /// and, unless `kind` is visibility, neither it nor anything above it is locked. Its own + /// visibility doesn't count, as that is what a visibility variable drives. + pub fn variable_target(&self, id: NodeId, kind: VariableKind) -> bool { + self.variable_target_with(&self.locked_artboards(), id, kind) } /// [`Document::variable_target`] with the locked artboards `locks` of this document, measured /// once for many ids. - pub fn variable_target_with(&self, locks: &LockedArtboards, id: NodeId) -> bool { - self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !n.locked && (n.visible || *i == id)))) + pub fn variable_target_with(&self, locks: &LockedArtboards, id: NodeId, kind: VariableKind) -> bool { + let locking = kind != VariableKind::Visibility; + self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !(locking && n.locked) && (n.visible || *i == id)))) && !locks.holds_id(id) } - /// Whether a data set reads and rewrites the bound object `id`: it is in the document, neither - /// it nor anything above it is locked, and it is not on a locked artboard - /// ([`Document::on_locked_artboard`]). Hidden objects count, and so do objects in a hidden - /// layer or group. - pub fn dataset_target(&self, id: NodeId) -> bool { - self.dataset_target_with(&self.locked_artboards(), id) + /// Whether a data set reads and rewrites the bound object `id` for a variable of `kind`: it + /// is in the document, it is not on a locked artboard ([`Document::on_locked_artboard`]) and, + /// unless `kind` is visibility, neither it nor anything above it is locked. Hidden objects + /// count, and so do objects in a hidden layer or group. + pub fn dataset_target(&self, id: NodeId, kind: VariableKind) -> bool { + self.dataset_target_with(&self.locked_artboards(), id, kind) } /// [`Document::dataset_target`] with the locked artboards `locks` of this document, measured /// once for many ids. - pub fn dataset_target_with(&self, locks: &LockedArtboards, id: NodeId) -> bool { - self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !n.locked))) && !locks.holds_id(id) + pub fn dataset_target_with(&self, locks: &LockedArtboards, id: NodeId, kind: VariableKind) -> bool { + let locking = kind != VariableKind::Visibility; + self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !(locking && n.locked)))) && !locks.holds_id(id) } /// The type objects of the story that the type object `id` belongs to, in thread order: the @@ -253,21 +256,21 @@ impl Document { /// [`Document::story_target`] with the locked artboards `locks` of this document, measured /// once for many ids. pub fn story_target_with(&self, locks: &LockedArtboards, id: NodeId) -> bool { - self.story_of(id).into_iter().all(|f| self.dataset_target_with(locks, f)) + self.story_of(id).into_iter().all(|f| self.dataset_target_with(locks, f, VariableKind::Text)) } /// What the object `id` holds for the variable of `kind`: its characters (the whole story /// for a frame of threaded text), whether it shows, or a linked image's path. `None` when the - /// object is gone, is not of that kind, has no link path yet (an embedded image), or is locked, - /// in a locked layer or group, or on a locked artboard, or for text when a frame of its story - /// is (the document's value is whatever it was left at). + /// object is gone, is not of that kind, has no link path yet (an embedded image), or, unless + /// `kind` is visibility, is locked or in a locked layer or group, or is on a locked artboard, + /// or for text when a frame of its story is (the document's value is whatever it was left at). pub fn variable_value(&self, id: NodeId, kind: VariableKind) -> Option { self.variable_value_with(&self.locked_artboards(), id, kind) } /// [`Document::variable_value`] with the locked artboards `locks` of this document. fn variable_value_with(&self, locks: &LockedArtboards, id: NodeId, kind: VariableKind) -> Option { - if !self.dataset_target_with(locks, id) { + if !self.dataset_target_with(locks, id, kind) { return None; } let node = self.node(id)?; diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 3eaf685f0..f66937446 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -348,16 +348,16 @@ fn moved_type_group(n: &Node) -> Option { /// outlined as [`evaluate_container`] outlines it, and the other members become what it makes of /// them. `None` when no type stays type. fn container_keeping_type(d: &Document, n: &Node) -> Option { - fn keep(n: &mut Node, moves: &[serde_json::Value], clipping: bool, kept: &mut bool) { + fn keep(d: &Document, n: &mut Node, moves: &[serde_json::Value], clipping: bool, kept: &mut bool) { let clips = n.clips(); let Some(ch) = n.children_mut() else { return }; for (i, c) in ch.iter_mut().enumerate() { let clipping = clipping || (clips && i == 0); if !matches!(c.kind, NodeKind::Text(_)) { if has_pathfinder(c) || has_own_paint(c) || !geometry_effects(c).is_empty() { - outline_nested_text(Arc::make_mut(c), clipping); + outline_nested_text(Some(d), Arc::make_mut(c), clipping); } else if c.children().is_some() { - keep(Arc::make_mut(c), moves, clipping, kept); + keep(d, Arc::make_mut(c), moves, clipping, kept); } continue; } @@ -372,7 +372,7 @@ fn container_keeping_type(d: &Document, n: &Node) -> Option { } } None if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }), - None => as_art(None, c, None), + None => as_art(Some(d), c, None), }; if let Some(o) = art { *c = Arc::new(o); @@ -391,11 +391,11 @@ fn container_keeping_type(d: &Document, n: &Node) -> Option { } let mut m = crate::reshape::evaluated(Some(d), n); let mut kept = false; - keep(&mut m, &moves, false, &mut kept); + keep(d, &mut m, &moves, false, &mut kept); if !kept { return None; } - reshape_leaves(&mut m, &fx); + reshape_leaves(&mut m, &fx, Some(d)); m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !is_geometry(&e.id)); m.appearance.items.clear(); m.appearance.contents_index = None; @@ -547,9 +547,10 @@ pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) { /// its gradient meshes clipped to the outline; a brushed stroke stays a stroke), in paint order, as /// the members of a group keeping `n`'s id, transparency, opacity mask and raster effects. A path /// with [`has_transform_art`](crate::has_transform_art) is first made into the art [`reshape`] -/// makes of it, and each copy is baked this way. `None` when it has neither a stroke to outline nor -/// a geometry effect, so bending first changes nothing. -pub fn bake_appearance(n: &Node) -> Option { +/// makes of it, and each copy is baked this way; the swatches and patterns that live-effect plug-ins +/// painting their pieces name are looked up in `doc`. `None` when it has neither a stroke to +/// outline nor a geometry effect, so bending first changes nothing. +pub fn bake_appearance(doc: Option<&Document>, n: &Node) -> Option { if let Some(m) = crate::revolve_art(n).or_else(|| crate::solid_art(n)) { return Some(m); } @@ -560,9 +561,9 @@ pub fn bake_appearance(n: &Node) -> Option { // patterns: the art Expand Appearance makes of it (each copy an object with its own strokes, // patterns and gradients), each piece baked in turn. if crate::has_transform_art(n) - && let Some(mut m) = reshape(None, n, None) + && let Some(mut m) = reshape(doc, n, None) { - bake_appearance_pieces(&mut m); + bake_appearance_pieces(doc, &mut m); return Some(m); } let outlined = |i: &AppearanceItem| paints(i) && matches!(i, AppearanceItem::Stroke(s) if s.brush.is_none()); @@ -617,15 +618,15 @@ pub fn bake_appearance(n: &Node) -> Option { } /// [`bake_appearance`] every path and compound path in `n` that it changes, in place. -fn bake_appearance_pieces(n: &mut Node) { +fn bake_appearance_pieces(doc: Option<&Document>, n: &mut Node) { if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) { - if let Some(m) = bake_appearance(n) { + if let Some(m) = bake_appearance(doc, n) { *n = m; } return; } for c in n.children_mut().into_iter().flatten() { - bake_appearance_pieces(Arc::make_mut(c)); + bake_appearance_pieces(doc, Arc::make_mut(c)); } } diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index 7ecb869bb..a2959a843 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -536,7 +536,7 @@ fn a_baked_stroke_keeps_its_raster_effects() { s.effects.push(fx("path.offsetPath", json!({"offset": 5.0}))); s.effects.push(fx("stylize.dropShadow", json!({}))); } - let m = bake_appearance(&n).unwrap(); + let m = bake_appearance(None, &n).unwrap(); let ch = m.children().unwrap(); let Some(AppearanceItem::Fill(f)) = ch[1].appearance.items.first() else { panic!("the outline is filled") }; assert_eq!(f.effects.iter().map(|e| e.id.as_str()).collect::>(), ["stylize.dropShadow"]); @@ -551,7 +551,7 @@ fn a_baked_stroke_leaves_dither_behind() { g.gradient.dither = true; s.paint = Paint::Gradient(Box::new(g)); } - let m = bake_appearance(&n).unwrap(); + let m = bake_appearance(None, &n).unwrap(); let ch = m.children().unwrap(); let Some(AppearanceItem::Fill(f)) = ch[1].appearance.items.first() else { panic!("the outline is filled") }; let Paint::Gradient(g) = &f.paint else { panic!("a gradient fill") }; @@ -567,7 +567,7 @@ fn a_baked_stroke_with_a_gradient_along_it_becomes_meshes_clipped_to_its_outline s.gradient_mode = vectorcraft_doc::StrokeGradientMode::Along; s.opacity = 0.5; } - let m = bake_appearance(&n).unwrap(); + let m = bake_appearance(None, &n).unwrap(); let ch = m.children().unwrap(); let NodeKind::Group { children, clip: true } = &ch[1].kind else { panic!("a clip group: {:?}", ch[1].kind) }; assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. })); @@ -600,7 +600,7 @@ fn a_baked_transform_copy_keeps_its_scaled_stroke() { // Distort Appearance: the 200% copy's stroke is 4 pt, as outside the envelope, so its outline // is 204 pt wide. let n = transformed(Paint::None, Paint::solid(Color::BLACK), 2.0, json!({"scaleH": 200, "scaleV": 200, "copies": 1, "strokes": true})); - let m = bake_appearance(&n).unwrap(); + let m = bake_appearance(None, &n).unwrap(); let w = widths(&m); assert_eq!(w.len(), 2, "{w:?}"); assert!((w[0] - 102.0).abs() < 0.5 && (w[1] - 204.0).abs() < 0.5, "{w:?}"); @@ -611,7 +611,7 @@ fn a_baked_transform_copy_keeps_its_scaled_stroke() { fn a_baked_transform_copy_keeps_its_moved_pattern() { let paint = Paint::Pattern { pattern: "p".into(), xf: vectorcraft_geom::Affine::IDENTITY }; let n = transformed(paint, Paint::None, 0.0, json!({"moveH": 30, "copies": 1, "patterns": true})); - let m = bake_appearance(&n).unwrap(); + let m = bake_appearance(None, &n).unwrap(); let xs: Vec = baked_paths(&m) .iter() .map(|p| match p.appearance.fill_paint() { @@ -626,7 +626,7 @@ fn a_baked_transform_copy_keeps_its_moved_pattern() { fn a_baked_transform_without_its_options_outlines_the_copies_together() { // Without Scale Strokes & Effects the copy keeps the 2 pt stroke: one outline of both squares. let n = transformed(Paint::None, Paint::solid(Color::BLACK), 2.0, json!({"scaleH": 200, "scaleV": 200, "copies": 1})); - let m = bake_appearance(&n).unwrap(); + let m = bake_appearance(None, &n).unwrap(); let w = widths(&m); assert_eq!(w.len(), 1, "{w:?}"); assert!((w[0] - 202.0).abs() < 0.5, "{w:?}"); diff --git a/crates/effects/src/geometry.rs b/crates/effects/src/geometry.rs index eea263d48..85248e08c 100644 --- a/crates/effects/src/geometry.rs +++ b/crates/effects/src/geometry.rs @@ -3,6 +3,7 @@ use super::{ Appearance, AppearanceItem, BezPath, Effect, FillRule, Node, NodeKind, PathData, Rect, StrokeLayer, Value, WarpStyle, distort, stroke, stylize, util, warp, }; +use vectorcraft_doc::Document; /// Context the geometry effects may need from the object's appearance. #[derive(Clone, Copy, Debug, Default, PartialEq)] @@ -22,6 +23,9 @@ pub struct GeomContext<'a> { /// The index in `appearance.items` of the fill or stroke whose own effects these are; `None`: /// the object's effects. pub item: Option, + /// The document that the swatches and patterns named by a live-effect plug-in that paints its + /// pieces are looked up in. Without one, a result that names any is refused. + pub doc: Option<&'a Document>, } impl<'a> GeomContext<'a> { @@ -32,7 +36,7 @@ impl<'a> GeomContext<'a> { _ => FillRule::NonZero, }; let stroke = n.appearance.stroke().filter(|s| !s.paint.is_none() && s.width > 0.0); - Self { stroke, rule, seed: n.id.0, appearance: Some(&n.appearance), item: None } + Self { stroke, rule, seed: n.id.0, appearance: Some(&n.appearance), item: None, doc: None } } /// The context of the effects on `item`, one of the same object's appearance items: a @@ -374,7 +378,7 @@ fn apply_unchecked(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomCont use util::{join, num}; if let Some(plugin) = vectorcraft_plugins::effect::plugin_id(id) { let paints = vectorcraft_plugins::effect::Paints { appearance: ctx.appearance, item: ctx.item }; - return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule, paints).unwrap_or_else(|| path.clone()); + return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule, paints, ctx.doc).unwrap_or_else(|| path.clone()); } match id { "distort.freeDistort" => distort::free_distort(path, b, p, max), diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index a1abaac7d..d04982d98 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -67,25 +67,28 @@ pub type OutlineHook<'a> = &'a dyn Fn(&Node) -> Option; /// back to front: a compound shape as its area, a blend, an envelope or an object with a warp as /// the art it draws, and type and symbol instances as their art through `hook`. Past /// [`SYMBOL_ART_MAX_NODES`](crate::SYMBOL_ART_MAX_NODES) objects of symbol art, further -/// instances add nothing. -pub fn member_leaves(n: &Node, hook: Option) -> Vec { +/// instances add nothing. The swatches and patterns that live-effect plug-ins painting their +/// pieces name are looked up in `doc` ([`shape_art`]). +pub fn member_leaves(doc: Option<&Document>, n: &Node, hook: Option) -> Vec { let mut out = vec![]; - leaves(n, hook, &SymbolNodes::default(), &mut out); + leaves(doc, n, hook, &SymbolNodes::default(), &mut out); out } /// [`member_leaves`], with the symbol art given so far counted in `nodes`. -fn leaves(n: &Node, hook: Option, nodes: &SymbolNodes, out: &mut Vec) { +fn leaves(doc: Option<&Document>, n: &Node, hook: Option, nodes: &SymbolNodes, out: &mut Vec) { if !n.visible { return; } // A blend, an envelope or an object with a warp takes part as the art it draws. - if let Some(art) = shape_art_in(n, hook, nodes) { + if let Some(art) = shape_art_in(doc, n, hook, nodes) { let first = out.len(); - leaves(&art, hook, nodes, out); - // Every path of a live object's, type's or symbol instance's art is drawn inside the - // object's opacity and blend mode. - if crate::needs_outline(n) { + leaves(doc, &art, hook, nodes, out); + // Every path of a live object's, type's or symbol instance's art, and every piece of a path + // whose live-effect plug-in paints its pieces, is drawn inside the object's opacity and + // blend mode. + let pieces = matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && matches!(art.kind, NodeKind::Group { .. }); + if crate::needs_outline(n) || pieces { for l in out.iter_mut().skip(first) { within(l, n); } @@ -95,11 +98,11 @@ fn leaves(n: &Node, hook: Option, nodes: &SymbolNodes, out: &mut Ve match &n.kind { NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => out.push(n.clone()), // A compound shape takes part as its area, styled like it. - NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => match compound_shape_art_in(n, hook, nodes) { + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => match compound_shape_art_in(doc, n, hook, nodes) { Some(art) => out.push(art), None => { for c in children { - leaves(c, hook, nodes, out); + leaves(doc, c, hook, nodes, out); } } }, @@ -114,7 +117,7 @@ fn leaves(n: &Node, hook: Option, nodes: &SymbolNodes, out: &mut Ve let art = match &n.kind { NodeKind::SymbolInstance { symbol, .. } if nodes.charge(&art) => { let mut art = crate::reshape::symbol_art_with(&art, h, nodes, symbol); - crate::reshape::outline_nested_text(&mut art, false); + crate::reshape::outline_nested_text(doc, &mut art, false); transparency_into_members(&mut art); art } @@ -122,7 +125,7 @@ fn leaves(n: &Node, hook: Option, nodes: &SymbolNodes, out: &mut Ve _ => art, }; let first = out.len(); - leaves(&art, None, nodes, out); + leaves(doc, &art, None, nodes, out); for l in out.iter_mut().skip(first) { within(l, n); } @@ -176,29 +179,37 @@ fn transparency_into_members(n: &mut Node) { /// [`outline_object`](crate::outline_object) gives a symbol instance its symbol's art. The art /// keeps the effects of `n` other than its geometry effects, and the groups inside it pass their /// opacity and blend mode to the objects they hold. `None` when `n` is none of these, for images, -/// for symbol instances without geometry effects, and for one without art from `hook`. -pub fn shape_art(n: &Node, hook: Option) -> Option { - shape_art_in(n, hook, &SymbolNodes::default()) +/// for symbol instances without geometry effects, and for one without art from `hook`. A +/// live-effect plug-in that paints its pieces makes a path, and each path of a group's or symbol's +/// art, a group of its pieces, whose swatches and patterns must be in `doc` (without one, pieces +/// that name one are refused and the path stays as it is). +pub fn shape_art(doc: Option<&Document>, n: &Node, hook: Option) -> Option { + shape_art_in(doc, n, hook, &SymbolNodes::default()) } /// [`shape_art`], with the symbol art given so far counted in `nodes`. -fn shape_art_in(n: &Node, hook: Option, nodes: &SymbolNodes) -> Option { - let mut art = reshaped_art(n, hook, nodes)?; +fn shape_art_in(doc: Option<&Document>, n: &Node, hook: Option, nodes: &SymbolNodes) -> Option { + let mut art = reshaped_art(doc, n, hook, nodes)?; transparency_into_members(&mut art); Some(art) } /// [`shape_art_in`] before the groups inside the art pass on their transparency. -fn reshaped_art(n: &Node, hook: Option, nodes: &SymbolNodes) -> Option { +fn reshaped_art(doc: Option<&Document>, n: &Node, hook: Option, nodes: &SymbolNodes) -> Option { let fx = crate::reshape::geometry_effects(n); let outline = hook.unwrap_or(&crate::outline_text); match &n.kind { - NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } if !fx.is_empty() => Some(reshaped(n, n)), - NodeKind::Group { .. } | NodeKind::Layer { .. } if !fx.is_empty() => match compound_shape_art_in(n, hook, nodes) { - Some(area) => Some(reshaped(&area, n)), + // A path with a live-effect plug-in that paints its pieces: the group of its pieces, each + // with its own paints, as the renderer draws it. + NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } if !fx.is_empty() => { + let painted = crate::reshape::has_painted_effects(n).then(|| crate::reshape::reshape(doc, n, None)).flatten(); + Some(painted.unwrap_or_else(|| reshaped(n, n, doc))) + } + NodeKind::Group { .. } | NodeKind::Layer { .. } if !fx.is_empty() => match compound_shape_art_in(doc, n, hook, nodes) { + Some(area) => Some(reshaped(&area, n, doc)), None => { - let mut m = crate::reshape::outlined_members_with(None, n, outline, nodes); - let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx); + let mut m = crate::reshape::outlined_members_with(None, doc, n, outline, nodes); + let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx, doc); // The group's own fills and strokes follow the effects as its members do. if carry.moves(&m.appearance) { carry.apply(&mut m.appearance); @@ -213,8 +224,8 @@ fn reshaped_art(n: &Node, hook: Option, nodes: &SymbolNodes) -> Opt let h = hook?; let art = h(n).filter(|art| nodes.charge(art))?; let mut art = crate::reshape::symbol_art_with(&art, h, nodes, symbol); - crate::reshape::outline_nested_text(&mut art, false); - let carry = crate::reshape::reshape_leaves_carrying(&mut art, &fx); + crate::reshape::outline_nested_text(doc, &mut art, false); + let carry = crate::reshape::reshape_leaves_carrying(&mut art, &fx, doc); // The art's own fills and strokes follow the effects as its members do. if carry.moves(&art.appearance) { carry.apply(&mut art.appearance); @@ -226,19 +237,21 @@ fn reshaped_art(n: &Node, hook: Option, nodes: &SymbolNodes) -> Opt } NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => None, _ if crate::needs_outline(n) && (vectorcraft_doc::live::is_live(n) || !fx.is_empty()) => { - crate::reshape::as_art_with(None, n, None, outline, nodes) + crate::reshape::as_art_with(None, doc, n, None, outline, nodes) } _ => None, } } /// `src`, a path, compound path or compound shape's area, as one path reshaped by the visible -/// geometry effects of `n`, without those effects. -fn reshaped(src: &Node, n: &Node) -> Node { +/// geometry effects of `n`, without those effects. The swatches and patterns a live-effect plug-in +/// that paints its pieces names are looked up in `doc`. +fn reshaped(src: &Node, n: &Node, doc: Option<&Document>) -> Node { let mut m = src.clone(); if let Some((path, rule)) = geometry(src) { let bounds = vectorcraft_geom::Shape::bounding_box(&path.to_bezpath()); - let (g, carry) = crate::apply_geometry_carrying(&n.appearance.effects, &path, bounds, &crate::GeomContext::of(n)); + let (g, carry) = + crate::apply_geometry_carrying(&n.appearance.effects, &path, bounds, &crate::GeomContext { doc, ..crate::GeomContext::of(n) }); m.kind = NodeKind::Path { path: g, rule, live: None, clipping: false, guide: false }; // Its gradients move with the outline, as the canvas paints them. if carry.moves(&m.appearance) { @@ -282,12 +295,12 @@ fn shape_op(mode: ShapeMode) -> po::BoolOp { /// it by the member's shape mode; the backmost member starts the area. `None` when `n` is not a /// compound shape, or its members are past a live Pathfinder's budget ([`PATHFINDER_MAX_SEGMENTS`], /// [`PATHFINDER_MAX_OVERLAPS`], [`SYMBOL_ART_MAX_NODES`](crate::SYMBOL_ART_MAX_NODES)). -pub fn compound_shape_area(n: &Node, hook: Option) -> Option { - compound_shape_area_in(n, hook, &SymbolNodes::default()) +pub fn compound_shape_area(doc: Option<&Document>, n: &Node, hook: Option) -> Option { + compound_shape_area_in(doc, n, hook, &SymbolNodes::default()) } /// [`compound_shape_area`], with the symbol art given so far counted in `nodes`. -fn compound_shape_area_in(n: &Node, hook: Option, nodes: &SymbolNodes) -> Option { +fn compound_shape_area_in(doc: Option<&Document>, n: &Node, hook: Option, nodes: &SymbolNodes) -> Option { if !n.is_compound_shape() { return None; } @@ -296,7 +309,7 @@ fn compound_shape_area_in(n: &Node, hook: Option, nodes: &SymbolNod let mut members: Vec<(po::BoolOp, usize)> = vec![]; for c in n.children()? { let before = all.len(); - leaves(c, hook, nodes, &mut all); + leaves(doc, c, hook, nodes, &mut all); if all.len() > before { members.push((shape_op(c.extras().shape_mode), all.len() - before)); } @@ -323,13 +336,13 @@ fn compound_shape_area_in(n: &Node, hook: Option, nodes: &SymbolNod /// Compound shape `n` as one path of its area ([`compound_shape_area`]) with its appearance, /// opacity and blend mode: how it takes part in a Pathfinder operation. -pub fn compound_shape_art(n: &Node, hook: Option) -> Option { - compound_shape_art_in(n, hook, &SymbolNodes::default()) +pub fn compound_shape_art(doc: Option<&Document>, n: &Node, hook: Option) -> Option { + compound_shape_art_in(doc, n, hook, &SymbolNodes::default()) } /// [`compound_shape_art`], with the symbol art given so far counted in `nodes`. -fn compound_shape_art_in(n: &Node, hook: Option, nodes: &SymbolNodes) -> Option { - let area = compound_shape_area_in(n, hook, nodes)?; +fn compound_shape_art_in(doc: Option<&Document>, n: &Node, hook: Option, nodes: &SymbolNodes) -> Option { + let area = compound_shape_area_in(doc, n, hook, nodes)?; let mut art = Node::path(n.id, area, Appearance { items: n.appearance.items.clone(), ..Default::default() }); (art.opacity, art.blend) = (n.opacity, n.blend); Some(art) @@ -435,15 +448,16 @@ fn within_budget<'a>(leaves: impl IntoIterator) -> bool { /// Evaluate the visible Pathfinder effects of `group` in order: the resulting child objects. /// `None` when the group has no Pathfinder effect. Past the budget ([`PATHFINDER_MAX_SEGMENTS`], /// [`PATHFINDER_MAX_OVERLAPS`], [`SYMBOL_ART_MAX_NODES`](crate::SYMBOL_ART_MAX_NODES) for -/// each effect) it returns the children as they are. -pub fn pathfinder_children(group: &Node, hook: Option) -> Option>> { +/// each effect) it returns the children as they are. The swatches and patterns that live-effect +/// plug-ins painting their pieces name are looked up in `doc` ([`shape_art`]). +pub fn pathfinder_children(doc: Option<&Document>, group: &Node, hook: Option) -> Option>> { if !has_pathfinder(group) { return None; } let source: &[Arc] = group.children()?; let mut stack: Vec> = if group.is_compound_shape() { // The group's own appearance paints the area ([`evaluate_container`]), so the art has none. - match compound_shape_area(group, hook) { + match compound_shape_area(doc, group, hook) { Some(area) if area.is_empty() => vec![], Some(area) => vec![Arc::new(Node::path(group.id, area, Appearance::default()))], None => return Some(source.to_vec()), @@ -455,7 +469,7 @@ pub fn pathfinder_children(group: &Node, hook: Option) -> Option) -> Option, n: &Node) -> Vec<(PathData, FillRul NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => out.extend(crate::reshape::leaf_geometry(n)), NodeKind::Group { .. } if n.is_compound_shape() => { let hook = |c: &Node| crate::outline_object(doc, c); - match compound_shape_area(n, Some(&hook)) { + match compound_shape_area(doc, n, Some(&hook)) { Some(area) => out.push((area, FillRule::NonZero)), None => members(doc, n, out), } @@ -715,7 +729,7 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { let mut m = n.clone(); if pathfinder { let hook = |c: &Node| crate::outline_object(doc, c); - let result = pathfinder_children(n, Some(&hook)).unwrap_or_default(); + let result = pathfinder_children(doc, n, Some(&hook)).unwrap_or_default(); if let Some(ch) = m.children_mut() { *ch = result; } @@ -724,7 +738,7 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { } if !fx.is_empty() { m = crate::reshape::outlined_members(doc, &m); - let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx); + let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx, doc); // The group's own fills and strokes paint the members as a whole: their gradients follow // the effects as the members' do. if paint && carry.moves(&m.appearance) { diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index 1b876c6ff..619d06a91 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -27,7 +27,7 @@ fn area(n: &Node) -> f64 { } fn overlap_cmyk(g: &Node) -> [f32; 4] { - let faces = pathfinder_children(g, None).unwrap(); + let faces = pathfinder_children(None, g, None).unwrap(); assert_eq!(faces.len(), 3); let overlap = faces .iter() @@ -61,7 +61,7 @@ fn trap_effect_adds_an_overprinting_strip_above_the_members() { let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, yellow), sq(2, 10.0, blue)]); let params = serde_json::json!({"thickness": 1.0, "tintReduction": 100}); g.appearance.effects.push(Effect { id: crate::trap::TRAP.into(), params, visible: true }); - let out = pathfinder_children(&g, None).unwrap(); + let out = pathfinder_children(None, &g, None).unwrap(); assert_eq!(out.len(), 3, "both squares and one trap"); assert_eq!((out[0].id, out[1].id), (NodeId(1), NodeId(2))); let b = out[2].geometric_bounds().unwrap(); @@ -89,17 +89,17 @@ fn soft_mix_blends_cmyk_bottom_to_top_and_drops_strokes() { #[test] fn shape_modes_follow_the_pathfinder_panel() { - let add = pathfinder_children(&group_with("pathfinder.add"), None).unwrap(); + let add = pathfinder_children(None, &group_with("pathfinder.add"), None).unwrap(); assert_eq!(add.len(), 1); assert!((area(&add[0]) - 150.0).abs() < 1e-3); // Add keeps the front object's paint, Subtract the back one's. assert_eq!(add[0].id, NodeId(2)); - let sub = pathfinder_children(&group_with("pathfinder.subtract"), None).unwrap(); + let sub = pathfinder_children(None, &group_with("pathfinder.subtract"), None).unwrap(); assert_eq!(sub[0].id, NodeId(1)); assert!((area(&sub[0]) - 50.0).abs() < 1e-3); - let int = pathfinder_children(&group_with("pathfinder.intersect"), None).unwrap(); + let int = pathfinder_children(None, &group_with("pathfinder.intersect"), None).unwrap(); assert!((area(&int[0]) - 50.0).abs() < 1e-3); - let div = pathfinder_children(&group_with("pathfinder.divide"), None).unwrap(); + let div = pathfinder_children(None, &group_with("pathfinder.divide"), None).unwrap(); assert_eq!(div.len(), 3); } @@ -112,7 +112,7 @@ fn divide_and_outline_draw_nothing_for_unpainted_members() { for effect in ["pathfinder.divide", "pathfinder.outline"] { let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), unpainted.clone()]); g.appearance.effects.push(Effect { id: effect.into(), params: serde_json::json!({}), visible: true }); - let out = pathfinder_children(&g, None).unwrap(); + let out = pathfinder_children(None, &g, None).unwrap(); assert!(out.iter().any(|n| n.id == NodeId(1)) && out.iter().any(|n| n.id == NodeId(2)), "{effect}: {out:#?}"); for n in out.iter().filter(|n| n.id == NodeId(2)) { assert!(n.appearance.items.iter().all(|i| i.paint().is_none()), "{effect}: {n:#?}"); @@ -138,7 +138,7 @@ fn square_and(front: Option, effect: &str) -> Node { /// Returns the Pathfinder results of `g`, with type outlined as the renderer outlines it. fn results(g: &Node) -> Vec> { - pathfinder_children(g, crate::text_outliner()).unwrap() + pathfinder_children(None, g, crate::text_outliner()).unwrap() } #[test] @@ -167,7 +167,7 @@ fn type_changes_the_result_of_every_pathfinder_effect() { assert!((with_type("pathfinder.intersect") + with_type("pathfinder.minusBack") - glyph_area).abs() < 0.5, "{glyph_area}"); // Outline art nested one group deeper takes part as well. let nested: OutlineHook = &|n: &Node| crate::outline_text(n).map(|g| Node::group(n.id, vec![Arc::new(g)])); - let sub = pathfinder_children(&square_and(Some(type_h()), "pathfinder.subtract"), Some(nested)).unwrap(); + let sub = pathfinder_children(None, &square_and(Some(type_h()), "pathfinder.subtract"), Some(nested)).unwrap(); assert!((sub.iter().map(|n| area(n)).sum::() - with_type("pathfinder.subtract")).abs() < 1e-6); } @@ -243,7 +243,7 @@ fn type_in_a_group_keeps_its_look_when_the_groups_geometry_effects_outline_it() fn hidden_effect_or_plain_group_is_untouched() { let mut g = group_with("pathfinder.add"); g.appearance.effects[0].visible = false; - assert!(pathfinder_children(&g, None).is_none()); + assert!(pathfinder_children(None, &g, None).is_none()); assert!(!has_pathfinder(&sq(1, 0.0, Color::BLACK))); } @@ -320,12 +320,12 @@ fn shape_art_moves_gradients_with_a_transform_effect() { }; let mut path = Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(placed.clone(), Paint::None, 0.0)); path.appearance.effects.push(moved()); - let got = starts(&shape_art(&path, None).unwrap()); + let got = starts(&shape_art(None, &path, None).unwrap()); assert!(got.len() == 1 && (got[0] - 40.0).abs() < 1e-6, "the path's gradient: {got:?}"); let mut g = Node::group(NodeId(10), vec![sq(2, 0.0, Color::BLACK)]); g.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(placed))); g.appearance.effects.push(moved()); - let got = starts(&shape_art(&g, None).unwrap()); + let got = starts(&shape_art(None, &g, None).unwrap()); assert!(!got.is_empty() && got.iter().all(|s| (s - 40.0).abs() < 1e-6), "the group's own fill: {got:?}"); } @@ -343,7 +343,7 @@ fn shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform() { let hook = move |_: &Node| Some(art.clone()); let mut instance = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "s".into(), xf: vectorcraft_geom::Affine::IDENTITY }); instance.appearance.effects.push(Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true }); - let out = shape_art(&instance, Some(&hook)).unwrap(); + let out = shape_art(None, &instance, Some(&hook)).unwrap(); let Paint::Gradient(g) = out.appearance.fill_paint() else { panic!("a gradient: {:?}", out.appearance) }; assert!((g.geom.unwrap().start.x - 30.0).abs() < 1e-6, "{:?}", g.geom); assert!((out.geometric_bounds().unwrap().x0 - 30.0).abs() < 1e-6, "the art moves"); @@ -377,11 +377,11 @@ fn shape_art_moves_the_gradients_of_a_symbol_instances_art_and_a_compound_shape_ let hook = move |_: &Node| Some(art.clone()); let mut instance = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "s".into(), xf: vectorcraft_geom::Affine::IDENTITY }); instance.appearance.effects.push(warp()); - check(&shape_art(&instance, Some(&hook)).unwrap(), Rect::new(0.0, 0.0, 10.0, 10.0), &format!("warp.{style} on a symbol instance")); + check(&shape_art(None, &instance, Some(&hook)).unwrap(), Rect::new(0.0, 0.0, 10.0, 10.0), &format!("warp.{style} on a symbol instance")); let mut shape = compound_shape(ShapeMode::Add); shape.appearance = Appearance::basic(placed.clone(), Paint::None, 0.0).into(); shape.appearance.effects.push(warp()); - check(&shape_art(&shape, None).unwrap(), Rect::new(0.0, 0.0, 15.0, 10.0), &format!("warp.{style} on a compound shape")); + check(&shape_art(None, &shape, None).unwrap(), Rect::new(0.0, 0.0, 15.0, 10.0), &format!("warp.{style} on a compound shape")); } } @@ -714,7 +714,7 @@ fn pathfinder_past_its_budget_draws_the_source_art() { // n² crossings past the cap: no evaluation (#3591), the source art as it is. for n in [130, 800] { let g = crossing_bars(n, "pathfinder.divide"); - let out = pathfinder_children(&g, None).unwrap(); + let out = pathfinder_children(None, &g, None).unwrap(); assert_eq!(out.len(), 2 * n, "{n} x {n} bars"); let ids: Vec = out.iter().map(|c| c.id).collect(); let src: Vec = g.children().unwrap().iter().map(|c| c.id).collect(); @@ -729,7 +729,7 @@ fn pathfinder_within_its_budget_still_divides() { // n x n bars divide into 3·n² pieces, and 128 x 128 bars, which overlap in exactly // `PATHFINDER_MAX_OVERLAPS` pairs, must still divide. for n in [8, 128] { - let out = pathfinder_children(&crossing_bars(n, "pathfinder.divide"), None).unwrap(); + let out = pathfinder_children(None, &crossing_bars(n, "pathfinder.divide"), None).unwrap(); assert_eq!(out.len(), 3 * n * n, "{n} x {n} bars"); } } @@ -747,7 +747,7 @@ fn pathfinder_on_many_stacked_bars_still_runs() { .collect(); let mut g = Node::group(NodeId(10), children); g.appearance.effects.push(Effect { id: "pathfinder.add".into(), params: serde_json::json!({}), visible: true }); - let out = pathfinder_children(&g, None).unwrap(); + let out = pathfinder_children(None, &g, None).unwrap(); assert_eq!(out.len(), 1); } @@ -768,9 +768,9 @@ fn compound_shapes_draw_the_area_their_shape_modes_make() { for (mode, want) in [(ShapeMode::Add, 150.0), (ShapeMode::Subtract, 50.0), (ShapeMode::Intersect, 50.0), (ShapeMode::Exclude, 100.0)] { let g = compound_shape(mode); assert!(has_pathfinder(&g), "{mode:?}"); - let area_path = compound_shape_area(&g, None).unwrap(); + let area_path = compound_shape_area(None, &g, None).unwrap(); assert!((po::area(&area_path, FillRule::NonZero).abs() - want).abs() < 0.01, "{mode:?}"); - let out = pathfinder_children(&g, None).unwrap(); + let out = pathfinder_children(None, &g, None).unwrap(); assert_eq!(out.len(), 1, "{mode:?}"); assert!(out[0].appearance.items.is_empty(), "the group's own appearance paints it"); let art = evaluate_container(None, &g).unwrap(); @@ -787,7 +787,7 @@ fn compound_shapes_draw_the_area_their_shape_modes_make() { back.extras_mut().shape_mode = ShapeMode::Subtract; ch[0] = Arc::new(back); } - assert!((po::area(&compound_shape_area(&g, None).unwrap(), FillRule::NonZero).abs() - 150.0).abs() < 0.01); + assert!((po::area(&compound_shape_area(None, &g, None).unwrap(), FillRule::NonZero).abs() - 150.0).abs() < 0.01); // Hidden members take no part; the mark on a clip group is ignored. let mut g = compound_shape(ShapeMode::Subtract); if let Some(ch) = g.children_mut() { @@ -795,10 +795,10 @@ fn compound_shapes_draw_the_area_their_shape_modes_make() { front.visible = false; ch[1] = Arc::new(front); } - assert!((po::area(&compound_shape_area(&g, None).unwrap(), FillRule::NonZero).abs() - 100.0).abs() < 0.01); + assert!((po::area(&compound_shape_area(None, &g, None).unwrap(), FillRule::NonZero).abs() - 100.0).abs() < 0.01); let mut clip = compound_shape(ShapeMode::Add); clip.kind = NodeKind::Group { children: clip.children().unwrap().clone(), clip: true }; - assert!(!clip.is_compound_shape() && compound_shape_area(&clip, None).is_none()); + assert!(!clip.is_compound_shape() && compound_shape_area(None, &clip, None).is_none()); } /// A compound shape inside another (and inside an Effect › Pathfinder group) takes part as its area, @@ -811,10 +811,10 @@ fn nested_compound_shapes_take_part_as_their_area() { let mut outer = Node::group(NodeId(20), vec![Arc::new(inner.clone()), Arc::new(cutter)]); outer.extras_mut().compound_shape = true; // (0–5 of the inner area) ∩ (2–12) = 2–5 by 10 high. - assert!((po::area(&compound_shape_area(&outer, None).unwrap(), FillRule::NonZero).abs() - 30.0).abs() < 0.01); + assert!((po::area(&compound_shape_area(None, &outer, None).unwrap(), FillRule::NonZero).abs() - 30.0).abs() < 0.01); let mut effect = Node::group(NodeId(30), vec![Arc::new(inner)]); effect.appearance.effects.push(Effect { id: "pathfinder.add".into(), params: serde_json::json!({}), visible: true }); - let out = pathfinder_children(&effect, None).unwrap(); + let out = pathfinder_children(None, &effect, None).unwrap(); assert_eq!(out.len(), 1); assert!((area(&out[0]) - 50.0).abs() < 0.01); assert_eq!(out[0].appearance.fill_paint().color(), Some(Color::rgb(0.0, 1.0, 0.0)), "styled like the compound shape"); diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index efd6d5ceb..e5b3201a4 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -80,8 +80,8 @@ pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks}; pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace}; pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, outset, raster_effects}; pub use reshape::{ - SYMBOL_ART_MAX_NODES, expand_outlined, gives_image_shape, has_gradient_copies, has_item_geometry, has_transform_art, has_transform_copies, - has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape, + SYMBOL_ART_MAX_NODES, expand_outlined, gives_image_shape, has_gradient_copies, has_item_geometry, has_painted_effects, has_transform_art, + has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape, }; pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve}; pub use solids::{ @@ -107,5 +107,6 @@ pub use geometry::{ #[cfg(test)] pub(crate) use geometry::{MAX_EFFECT_LINES, apply_geometry_within}; pub use params::{ - default_params, has_geometry, has_plugin_effect, is_geometry, is_raster, merged_params, new_effect, plugin_revision, scale_effect, upgrade_params, + any_plugin_paints_output, default_params, has_geometry, has_painted_plugin_effect, has_plugin_effect, is_geometry, is_raster, merged_params, + new_effect, plugin_revision, scale_effect, upgrade_params, }; diff --git a/crates/effects/src/live.rs b/crates/effects/src/live.rs index c4b6be4b7..03b335f01 100644 --- a/crates/effects/src/live.rs +++ b/crates/effects/src/live.rs @@ -43,7 +43,8 @@ fn with_hooks(doc: Option<&Document>, f: impl FnOnce(Hooks) -> R) -> R { vectorcraft_text::register_envelope_ink(); let outline = |n: &Node| outline_object(doc, n); let tiles = |name: &str, xf: Affine, region: Rect| Some(doc?.pattern(name)?.instances_in(xf, region)); - f(Hooks { outline: Some(&outline), appearance: Some(&bake_appearance), pattern: Some(&tiles), symbols: doc }) + let appearance = |n: &Node| bake_appearance(doc, n); + f(Hooks { outline: Some(&outline), appearance: Some(&appearance), pattern: Some(&tiles), symbols: doc }) } /// One level of evaluation of live object `n` ([`live::expand_live_hooks`]). diff --git a/crates/effects/src/params.rs b/crates/effects/src/params.rs index a178a7537..5c06fae8f 100644 --- a/crates/effects/src/params.rs +++ b/crates/effects/src/params.rs @@ -148,6 +148,19 @@ pub fn has_plugin_effect(n: &Node) -> bool { n.appearance.effects.iter().any(plugin) || n.appearance.items.iter().any(|i| i.effects().iter().any(plugin)) } +/// Does `n` have a visible live-effect plug-in effect, its own or a fill's or stroke's, whose +/// plug-in paints the pieces it returns? What such an effect draws also depends on the document's +/// swatches and patterns, which its pieces may name. +pub fn has_painted_plugin_effect(n: &Node) -> bool { + let painted = |e: &Effect| e.visible && vectorcraft_plugins::effect::plugin_id(&e.id).is_some_and(vectorcraft_plugins::effect::paints_output); + n.appearance.effects.iter().any(painted) || n.appearance.items.iter().any(|i| i.effects().iter().any(painted)) +} + +/// Is a live-effect plug-in that paints the pieces it returns installed? +pub fn any_plugin_paints_output() -> bool { + vectorcraft_plugins::registry::list().iter().any(|p| vectorcraft_plugins::effect::paints_output(&p.manifest().id)) +} + /// The plug-in registry's revision, which changes whenever a plug-in is installed or removed. pub fn plugin_revision() -> u64 { vectorcraft_plugins::registry::revision() diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index bf51244f4..070060dcf 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -103,10 +103,27 @@ fn reaches_appearance(e: &Effect) -> bool { } /// Is `n` a path or compound path that its Transform effects make into art of separate objects -/// ([`has_gradient_copies`], [`has_layered_copies`], [`has_transform_options`])? It is drawn, baked and expanded through -/// that art ([`reshape`]). +/// ([`has_gradient_copies`], [`has_layered_copies`], [`has_transform_options`]), or whose +/// live-effect plug-ins paint what they return ([`has_painted_effects`])? It is drawn, baked and +/// expanded through that art ([`reshape`]). pub fn has_transform_art(n: &Node) -> bool { - has_gradient_copies(n) || has_layered_copies(n) || has_transform_options(n) + has_gradient_copies(n) || has_layered_copies(n) || has_transform_options(n) || has_painted_effects(n) +} + +/// Is `e` a visible live-effect plug-in whose manifest declares `paintOutput`: its id's plug-in id, +/// else `None`. +fn painted_plugin(e: &Effect) -> Option<&str> { + let id = vectorcraft_plugins::effect::plugin_id(&e.id).filter(|_| e.visible)?; + vectorcraft_plugins::effect::paints_output(id).then_some(id) +} + +/// Is `n` a path or compound path with a live-effect plug-in that paints what it returns +/// ([`painted_plugin`]), its own or on a fill or stroke that paints? Each piece the plug-in returns +/// is then an object of its own with its own paints ([`paint_leaf`], [`transform_item_leaf`]). +pub fn has_painted_effects(n: &Node) -> bool { + matches!(n.kind, NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. }) + && (n.appearance.effects.iter().any(|e| painted_plugin(e).is_some()) + || n.appearance.items.iter().any(|i| crate::paints(i) && i.effects().iter().any(|e| painted_plugin(e).is_some()))) } /// Is `n` type without inline graphics that has a fill or stroke of its own that paints and has a @@ -163,17 +180,18 @@ fn image_rows(items: &[AppearanceItem]) -> Vec { /// The shapes that the image `n`'s own fills and strokes paint ([`image_rows`]), each with its fill /// rule and the fill or stroke that paints it: the paths (or compound paths) along its frame that /// [`reshape`] makes, with each fill's or stroke's geometry effects applied as the renderer applies -/// them. Empty for anything but an image with such fills or strokes. -pub fn image_paint(n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> { +/// them. Empty for anything but an image with such fills or strokes. The swatches and patterns that +/// live-effect plug-ins painting their pieces name are looked up in `doc`. +pub fn image_paint(doc: Option<&Document>, n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> { if !matches!(n.kind, NodeKind::Image(_)) || !has_item_geometry(n) { return vec![]; } - let Some(art) = reshape(None, n, None) else { return vec![] }; + let Some(art) = reshape(doc, n, None) else { return vec![] }; let mut out = vec![]; art.walk(&mut |c| { let (NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. }) = &c.kind else { return }; let Some(g) = c.stroke_path() else { return }; - let ctx = GeomContext::of(c); + let ctx = GeomContext { doc, ..GeomContext::of(c) }; for item in c.appearance.items.iter().filter(|i| crate::paints(i)) { let fx = item.effects(); let shape = if crate::has_geometry(fx) && !g.elements().is_empty() { @@ -286,14 +304,17 @@ fn with_id(art: &Node, id: NodeId) -> Node { /// through the guides could hold more than [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects. A Repeat counts the Repeats in the art of /// `doc`'s symbols that its instances show toward its instance budget. pub fn outline_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option { - outline_art_with(doc, n, symbol_art, &outline_text, &SymbolNodes::default(), false) + outline_art_with(doc, doc, n, symbol_art, &outline_text, &SymbolNodes::default(), false) } /// [`outline_art`] with live objects evaluated through `outline` ([`evaluated_with`]); with /// `apart`, also a path or compound path whose Transform effect makes copies -/// ([`has_transform_copies`]), as the one piece the copies are made from. +/// ([`has_transform_copies`]), as the one piece the copies are made from. `symbols` is the document +/// the symbols of the art come from; `paint_doc` is the one the swatches and patterns of painted +/// pieces are looked up in. fn outline_art_with( - doc: Option<&Document>, + symbols: Option<&Document>, + paint_doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>, outline: OutlineHook, @@ -303,7 +324,7 @@ fn outline_art_with( let mut art = match &n.kind { NodeKind::Text(_) => outline_text(n)?, NodeKind::SymbolInstance { symbol, xf } => { - let a = match doc { + let a = match symbols { Some(d) => d.place_symbol_art(symbol, symbol_art?, *xf)?, None => { let mut a = symbol_art?.clone(); @@ -323,10 +344,10 @@ fn outline_art_with( one.appearance.effects.clear(); Node::group(n.id, vec![Arc::new(one)]) } - _ if vectorcraft_doc::live::is_live(n) => evaluated_with(doc, n, outline, nodes), + _ if vectorcraft_doc::live::is_live(n) => evaluated_with(symbols, n, outline, nodes), _ => return None, }; - outline_nested_text(&mut art, false); + outline_nested_text(paint_doc, &mut art, false); art.opacity = 1.0; art.blend = Default::default(); art.isolate = false; @@ -433,21 +454,22 @@ fn instances_as_art(doc: Option<&Document>, n: &mut Node, outline: OutlineHook, /// Replace type anywhere inside `n` by its outlines, as art that keeps the type's look /// ([`as_art`]). Type in a clip group's clipping path (all of `n` when `clipping` is set) becomes /// its plain outlines ([`outline_text`]) with its visibility, which is what -/// [`crate::clip_outline`] clips by. -pub(crate) fn outline_nested_text(n: &mut Node, clipping: bool) { +/// [`crate::clip_outline`] clips by. The swatches and patterns that live-effect plug-ins painting +/// their pieces name are looked up in `doc`. +pub(crate) fn outline_nested_text(doc: Option<&Document>, n: &mut Node, clipping: bool) { let clips = n.clips(); let Some(ch) = n.children_mut() else { return }; for (i, c) in ch.iter_mut().enumerate() { let clipping = clipping || (clips && i == 0); let art = match &c.kind { NodeKind::Text(_) if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }), - NodeKind::Text(_) => as_art(None, c, None), + NodeKind::Text(_) => as_art(doc, c, None), _ => None, }; if let Some(o) = art { *c = Arc::new(o); } else if c.children().is_some() { - outline_nested_text(Arc::make_mut(c), clipping); + outline_nested_text(doc, Arc::make_mut(c), clipping); } } } @@ -480,7 +502,7 @@ pub fn expand_outlined(doc: Option<&Document>, n: &Node, symbol_art: Option<&Nod // effect in a group of their own (copies of copies nest), as the reference app expands them // (#5612). if has_transform_art(n) || has_transform_copies(n) { - return as_art_opts(doc, n, symbol_art, &outline_text, &SymbolNodes::default(), true); + return as_art_opts(doc, doc, n, symbol_art, &outline_text, &SymbolNodes::default(), true); } let own_paint = match n.kind { NodeKind::Text(_) => !n.appearance.items.is_empty(), @@ -494,17 +516,34 @@ pub fn expand_outlined(doc: Option<&Document>, n: &Node, symbol_art: Option<&Nod /// [`reshape`] without its checks (no geometry effects: just the art). pub(crate) fn as_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option { - as_art_with(doc, n, symbol_art, &outline_text, &SymbolNodes::default()) + as_art_with(doc, doc, n, symbol_art, &outline_text, &SymbolNodes::default()) } -/// [`as_art`] with live objects evaluated through `outline` ([`evaluated_with`]). -pub(crate) fn as_art_with(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>, outline: OutlineHook, nodes: &SymbolNodes) -> Option { - as_art_opts(doc, n, symbol_art, outline, nodes, false) +/// [`as_art`] with live objects evaluated through `outline` ([`evaluated_with`]). `symbols` is the +/// document the symbols of the art come from; `paint_doc` is the one the swatches and patterns of +/// painted pieces are looked up in. +pub(crate) fn as_art_with( + symbols: Option<&Document>, + paint_doc: Option<&Document>, + n: &Node, + symbol_art: Option<&Node>, + outline: OutlineHook, + nodes: &SymbolNodes, +) -> Option { + as_art_opts(symbols, paint_doc, n, symbol_art, outline, nodes, false) } /// [`as_art_with`]; with `apart`, a path's or compound path's Transform effects make each copy an /// object of its own ([`copy_leaf`]) rather than one path of every copy. -fn as_art_opts(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>, outline: OutlineHook, nodes: &SymbolNodes, apart: bool) -> Option { +fn as_art_opts( + symbols: Option<&Document>, + paint_doc: Option<&Document>, + n: &Node, + symbol_art: Option<&Node>, + outline: OutlineHook, + nodes: &SymbolNodes, + apart: bool, +) -> Option { let fx = geometry_effects(n); let mut art = match &n.kind { NodeKind::Image(im) => { @@ -532,13 +571,13 @@ fn as_art_opts(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>, outl Node::group(n.id, rows(below).into_iter().chain([Arc::new(image)]).chain(rows(above)).collect()) } } - _ => outline_art_with(doc, n, symbol_art, outline, nodes, apart)?, + _ => outline_art_with(symbols, paint_doc, n, symbol_art, outline, nodes, apart)?, }; - reshape_leaves_apart(&mut art, &fx, Budget::EFFECTS, apart); + reshape_leaves_apart(&mut art, &fx, Budget::EFFECTS, apart, paint_doc); if apart { hoist_copies(&mut art); } - transform_items(&mut art); + transform_items(&mut art, paint_doc); // Type and symbols in perspective: the art projected onto their plane. if let Some(h) = n.projection() { project_leaves(&mut art, &h); @@ -596,20 +635,22 @@ pub(crate) fn geometry_effects(n: &Node) -> Vec<&Effect> { /// symbol instances follow a Transform effect the same way ([`transform_placed`]); other geometry /// effects leave them as they are (a raster follows a non-affine map only as a mesh warp). The /// paths keep to one [`Budget::EFFECTS`] together (or to what they cost, when that is more): a path -/// whose effect would pass what the others leave of it keeps its shape. -pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) { - reshape_leaves_within(art, fx, Budget::EFFECTS); +/// whose effect would pass what the others leave of it keeps its shape. The swatches and patterns +/// that live-effect plug-ins painting their pieces name are looked up in `doc` +/// ([`reshape_leaves_apart`]). +pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect], doc: Option<&Document>) { + reshape_leaves_within(art, fx, Budget::EFFECTS, doc); } /// [`reshape_leaves`], and how gradients that paint the art as a whole (a group's own fills and /// strokes) follow the effects ([`crate::Carry`]). -pub(crate) fn reshape_leaves_carrying(art: &mut Node, fx: &[&Effect]) -> crate::Carry { - reshape_leaves_within(art, fx, Budget::EFFECTS) +pub(crate) fn reshape_leaves_carrying(art: &mut Node, fx: &[&Effect], doc: Option<&Document>) -> crate::Carry { + reshape_leaves_within(art, fx, Budget::EFFECTS, doc) } /// [`reshape_leaves_carrying`] whose paths keep to `budget` together, or to what they cost. -pub(crate) fn reshape_leaves_within(art: &mut Node, fx: &[&Effect], budget: Budget) -> crate::Carry { - reshape_leaves_apart(art, fx, budget, false) +pub(crate) fn reshape_leaves_within(art: &mut Node, fx: &[&Effect], budget: Budget, doc: Option<&Document>) -> crate::Carry { + reshape_leaves_apart(art, fx, budget, false, doc) } /// `art`, the group of the one piece a path's copies are made from ([`outline_art_with`]), holding @@ -629,9 +670,11 @@ fn hoist_copies(art: &mut Node) { } /// [`reshape_leaves_within`]; with `apart`, a Transform effect's copies of each path are objects -/// of their own ([`copy_leaf`]) whatever paints them, as Expand Appearance makes them. Returns how -/// gradients that paint the art as a whole follow the effects. -fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: bool) -> crate::Carry { +/// of their own ([`copy_leaf`]) whatever paints them, as Expand Appearance makes them. A live-effect +/// plug-in that paints what it returns makes each path a group of its pieces ([`paint_leaf`]), +/// whose swatches and patterns must be in `doc` (without one, pieces that name one are refused). +/// Returns how gradients that paint the art as a whole follow the effects. +fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: bool, doc: Option<&Document>) -> crate::Carry { let mut whole = crate::Carry::default(); if fx.is_empty() { return whole; @@ -658,6 +701,17 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b for e in fx { let Some(bounds) = art.geometric_bounds() else { break }; let params = merged_params(&e.id, &e.params); + if let Some(plugin) = painted_plugin(e) { + for_each_leaf(art, &mut |leaf| { + let Some((path, _)) = leaf_geometry(leaf) else { return }; + let before = Budget::of(&path); + let room = max.minus(used.minus(before)); + if let Some(made) = paint_leaf(leaf, plugin, ¶ms, bounds, room, doc) { + used = used.minus(before).plus(made); + } + }); + continue; + } // Placed gradients follow a Transform effect, Free Distort and the Warp effects // ([`crate::Carry`]): under Free Distort and Warp (`boxes`) each path's move and scale from // its bounds before the effect to its bounds after it. Under those and the other effects, @@ -843,15 +897,118 @@ fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling made } +/// Live-effect plug-in `plugin` (one that paints what it returns) with `params` run on `leaf`, a +/// path or compound path, as one of its object's effects with reference box `bounds`: `leaf` +/// becomes a group with its id and name of one object for each piece returned, in order, each with +/// the piece's geometry and the leaf's appearance with the piece's fills and strokes in place of +/// its own ([`vectorcraft_plugins::painted::appearance`]). The swatches and patterns the pieces +/// name must be in `doc`, and without one the pieces may name none. Returns what the pieces cost +/// ([`piece_cost`]); `None` (the leaf stays as it is) when the plug-in fails, the pieces are +/// refused, or they would pass `budget`. +fn paint_leaf(leaf: &mut Node, plugin: &str, params: &serde_json::Value, bounds: Rect, budget: Budget, doc: Option<&Document>) -> Option { + let (path, rule) = leaf_geometry(leaf)?; + let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&leaf.appearance), item: None }; + let pieces = vectorcraft_plugins::effect::apply_painted(plugin, params, &path, bounds, rule, paints, doc)?; + let made = pieces.iter().map(|p| piece_cost(&p.path)).fold(Budget { anchors: 0, lines: 0 }, Budget::plus); + if !budget.fits(made) { + return None; + } + let children = pieces + .iter() + .map(|p| { + let mut one = leaf.clone(); + one.name = None; + one.appearance = vectorcraft_plugins::painted::appearance(&leaf.appearance, p).into(); + set_geometry(&mut one, p.path.clone(), p.rule); + Arc::new(one) + }) + .collect(); + let mut g = Node::group(leaf.id, children); + g.name = leaf.name.clone(); + *leaf = g; + Some(made) +} + +/// What a painted piece with geometry `path` costs: its anchors and lines, plus a share of the +/// effects' budget for the object it becomes, so that a budget of [`Budget::EFFECTS`] holds at most +/// [`MAX_OBJECTS`](vectorcraft_plugins::objects::MAX_OBJECTS) pieces however small their paths. +fn piece_cost(path: &PathData) -> Budget { + let max = vectorcraft_plugins::objects::MAX_OBJECTS; + Budget::of(path).plus(Budget { anchors: Budget::EFFECTS.anchors / max, lines: Budget::EFFECTS.lines / max }) +} + +/// Live-effect plug-in `plugin` (one that paints what it returns) with `params`, a later effect of +/// `item`, run on `leaf`, a path of [`transform_piece`] painted by copies of `item`, with reference +/// box `bounds`: `leaf` becomes a group of one path for each piece returned, in order. A piece that +/// gives fills (on a fill) or strokes (on a stroke) is painted by copies of `item`, the leaf's first +/// fill or stroke, with those paints ([`vectorcraft_plugins::painted::items`]), under the leaf's +/// other fills and strokes; one that gives none keeps the leaf's. Returns +/// what the paths cost ([`piece_cost`]); `None` (the leaf stays as it is) when the plug-in fails, +/// the pieces are refused (a swatch or pattern not in `doc`, or any without one), or they would pass +/// `budget`. +fn paint_piece( + leaf: &mut Node, + item: &AppearanceItem, + plugin: &str, + params: &serde_json::Value, + bounds: Rect, + budget: Budget, + doc: Option<&Document>, +) -> Option { + let (path, rule) = leaf_geometry(leaf)?; + let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&leaf.appearance), item: Some(0) }; + let pieces = vectorcraft_plugins::effect::apply_painted(plugin, params, &path, bounds, rule, paints, doc)?; + let made = pieces.iter().map(|p| piece_cost(&p.path)).fold(Budget { anchors: 0, lines: 0 }, Budget::plus); + if !budget.fits(made) { + return None; + } + let mut children = vec![]; + for p in pieces.iter() { + let gives = match item { + AppearanceItem::Fill(_) => p.fills.is_some(), + AppearanceItem::Stroke(_) => p.strokes.is_some(), + }; + let items = if gives { + let mut items = vectorcraft_plugins::painted::items(item, p); + items.extend_from_slice(leaf.appearance.items.get(1..).unwrap_or_default()); + items + } else { + leaf.appearance.items.clone() + }; + if items.is_empty() { + continue; + } + let mut one = leaf.clone(); + one.appearance.items = items; + set_geometry(&mut one, p.path.clone(), p.rule); + children.push(Arc::new(one)); + } + *leaf = Node::group(leaf.id, children); + Some(made) +} + /// Every path and compound path under `art` (other than clipping paths) with a fill or stroke whose /// own Transform effect has Transform Patterns or Scale Strokes & Effects on -/// ([`item_transform_options`]), made into a group of objects of its own ([`transform_item_leaf`]). -/// The new paths keep to one [`Budget::EFFECTS`] together (or to what `art` costs, when that is -/// more). -fn transform_items(art: &mut Node) { +/// ([`item_transform_options`]), or whose own live-effect plug-in paints what it returns +/// ([`painted_plugin`]), made into a group of objects of its own ([`transform_item_leaf`]). The +/// new paths keep to one [`Budget::EFFECTS`] together (or to what `art` costs, when that is more). +/// The swatches and patterns painted pieces name must be in `doc`; without one they may name none. +fn transform_items(art: &mut Node, doc: Option<&Document>) { let mut used = art_cost(art); let max = Budget::EFFECTS.at_least(used); - for_each_leaf(art, &mut |leaf| transform_item_leaf(leaf, max, &mut used)); + for_each_leaf(art, &mut |leaf| transform_item_leaf(leaf, max, &mut used, doc)); +} + +/// Is `e` an effect that makes a fill's or stroke's piece of a path an object of its own: a +/// Transform effect that [`reaches_appearance`], or a live-effect plug-in that paints what it +/// returns ([`painted_plugin`])? +fn makes_piece(e: &Effect) -> bool { + reaches_appearance(e) || painted_plugin(e).is_some() +} + +/// Does `item` paint and carry an effect that [`makes_piece`]? +fn item_makes_piece(item: &AppearanceItem) -> bool { + crate::paints(item) && item.effects().iter().any(makes_piece) } /// `leaf`, a path or compound path with a fill or stroke whose own Transform effect has Transform @@ -866,8 +1023,17 @@ fn transform_items(art: &mut Node) { /// effects stay on each of them. The other fills and strokes stay together on paths of the leaf's /// geometry between them, with their own effects. `max` is the budget of the whole art and `used` /// what it has spent. -fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget) { - if matches!(leaf.kind, NodeKind::Path { clipping: true, .. }) || !leaf.appearance.items.iter().any(item_transform_options) { +/// +/// A fill or stroke whose first such effect is a live-effect plug-in that paints what it returns +/// ([`painted_plugin`]) becomes, in place of a transformed path, one path for each piece the +/// plug-in returns, painted by copies of the fill or stroke with the piece's paints +/// ([`vectorcraft_plugins::painted::items`]); the plug-in is given the geometry before it and told +/// which fill or stroke it is on. When it fails (or names a swatch or pattern not in `doc`, or any +/// without a document), the +/// fill or stroke paints that geometry as it is. The geometry effects after it then apply to all +/// the pieces as one ([`transform_piece`]). +fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Option<&Document>) { + if matches!(leaf.kind, NodeKind::Path { clipping: true, .. }) || !leaf.appearance.items.iter().any(item_makes_piece) { return; } let Some((path, rule)) = leaf_geometry(leaf) else { return }; @@ -895,12 +1061,12 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget) { let mut pieces: Vec> = vec![]; let mut plain: Vec = vec![]; // Which items become a transformed piece of their own; every other run of items is one plain piece. - let transformed: Vec = original.appearance.items.iter().map(|i| crate::paints(i) && i.effects().iter().any(reaches_appearance)).collect(); + let transformed: Vec = original.appearance.items.iter().map(item_makes_piece).collect(); // The number of pieces after item `i` in paint order: each costs at least the leaf's geometry. let later = |i: usize| (i + 1..transformed.len()).filter(|&j| transformed[j] || j == i + 1 || transformed[j - 1]).count(); for (i, item) in original.appearance.items.iter().enumerate() { let fx = item.effects(); - let Some(at) = fx.iter().position(reaches_appearance).filter(|_| crate::paints(item)) else { + let Some(at) = fx.iter().position(makes_piece).filter(|_| crate::paints(item)) else { plain.push(item.clone()); continue; }; @@ -923,6 +1089,33 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget) { }; let Some(tb) = g.bounds() else { continue }; let params = merged_params(&fx[at].id, &fx[at].params); + if let Some(plugin) = painted_plugin(&fx[at]) { + let mut it = item.clone(); + let rest: Vec = before.iter().filter(|e| !is_geometry(&e.id)).chain(&fx[at + 1..]).cloned().collect(); + *it.effects_mut() = rest; + let mut one = Appearance { items: vec![it.clone()], ..Default::default() }; + if carry.moves(&one) { + carry.apply(&mut one); + } + let it = one.items.pop().unwrap_or(it); + let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&original.appearance), item: Some(i) }; + let painted = vectorcraft_plugins::effect::apply_painted(plugin, ¶ms, &g, tb, rule, paints, doc) + .map(|pieces| pieces.iter().map(|p| (p.path.clone(), p.rule, vectorcraft_plugins::painted::items(&it, p))).collect::>()) + .filter(|pieces| room.fits(pieces.iter().map(|(p, ..)| piece_cost(p)).fold(Budget { anchors: 0, lines: 0 }, Budget::plus))); + // A failed run draws the fill or stroke on the geometry it was given. + let returned = painted.unwrap_or_else(|| vec![(g.clone(), rule, vec![it])]); + let mut group: Vec> = vec![]; + for (path, rule, items) in returned.into_iter().filter(|(_, _, items)| !items.is_empty()) { + *used = used.plus(piece_cost(&path)); + let mut p = piece(items); + set_geometry(&mut p, path, rule); + group.push(Arc::new(p)); + } + let mut p = Node::group(leaf.id, group); + transform_piece(&mut p, ctx.seed, limit, used, doc); + pieces.push(Arc::new(p)); + continue; + } let (_, copies) = crate::distort::transform_map(tb, ¶ms, ctx.seed); let options = crate::distort::TransformOptions::of(¶ms); let map = crate::distort::transform_affine(tb, ¶ms, ctx.seed); @@ -936,7 +1129,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget) { set_geometry(&mut p, g.clone(), rule); let (made, _) = transform_leaf(&mut p, &g, map, copies, options, room); *used = used.plus(made); - transform_piece(&mut p, ctx.seed, limit, used); + transform_piece(&mut p, ctx.seed, limit, used, doc); pieces.push(Arc::new(p)); } if !plain.is_empty() { @@ -955,9 +1148,11 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget) { /// own effects reshape all its paths ([`reshape_leaves_within`]). Each path applies its own copy of /// the effect, whose distances its Scale Strokes & Effects scaled. A Transform effect with /// Transform Patterns or Scale Strokes & Effects on transforms each path as [`transform_leaf`] does. -/// The geometry effects then leave the fills and strokes; their other effects stay. The paths keep -/// to `max` together with the rest of the art (`used`). -fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget) { +/// A live-effect plug-in that paints what it returns ([`painted_plugin`]) makes each path one path +/// for each piece it returns ([`paint_piece`]). The geometry effects then leave the fills and +/// strokes; their other effects stay. The paths keep to `max` together with the rest of the art +/// (`used`), and the swatches and patterns painted pieces name must be in `doc`. +fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget, doc: Option<&Document>) { let mut count = 0; for_each_leaf(p, &mut |l| { if let Some(item) = l.appearance.items.first() { @@ -973,6 +1168,12 @@ fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget) { // What the other paths leave of the budget. let before = Budget::of(&path); let room = max.minus(used.minus(before)); + if let Some(plugin) = painted_plugin(&e) { + if let Some(made) = paint_piece(leaf, &item, plugin, &merged_params(&e.id, &e.params), bounds, room, doc) { + *used = used.minus(before).plus(made); + } + return; + } if reaches_appearance(&e) { let params = merged_params(&e.id, &e.params); let (_, copies) = crate::distort::transform_map(bounds, ¶ms, seed); @@ -987,7 +1188,7 @@ fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget) { AppearanceItem::Stroke(s) => Some(s), AppearanceItem::Fill(_) => None, }; - let ctx = GeomContext { stroke, rule, seed, appearance: Some(&appearance), item: Some(0) }; + let ctx = GeomContext { stroke, rule, seed, appearance: Some(&appearance), item: Some(0), doc }; let mut carry = crate::Carry::default(); let out = crate::geometry::apply_geometry_carrying_within(std::slice::from_ref(&e), &path, bounds, &ctx, room, Some(&mut carry)); *used = used.minus(before).plus(Budget::of(&out)); @@ -1276,14 +1477,21 @@ fn placed_count(n: &Node) -> usize { /// `n` with type anywhere inside it outlined and live objects evaluated, so geometry effects on /// it reach every member. pub(crate) fn outlined_members(doc: Option<&Document>, n: &Node) -> Node { - outlined_members_with(doc, n, &outline_text, &SymbolNodes::default()) + outlined_members_with(doc, doc, n, &outline_text, &SymbolNodes::default()) } /// [`outlined_members`] with live objects and symbol instances given as art through `outline` -/// ([`evaluated_with`]). -pub(crate) fn outlined_members_with(doc: Option<&Document>, n: &Node, outline: OutlineHook, nodes: &SymbolNodes) -> Node { - let mut art = evaluated_with(doc, n, outline, nodes); - outline_nested_text(&mut art, false); +/// ([`evaluated_with`]), with the symbols of the art from `symbols` and the swatches and patterns +/// of painted pieces looked up in `paint_doc`. +pub(crate) fn outlined_members_with( + symbols: Option<&Document>, + paint_doc: Option<&Document>, + n: &Node, + outline: OutlineHook, + nodes: &SymbolNodes, +) -> Node { + let mut art = evaluated_with(symbols, n, outline, nodes); + outline_nested_text(paint_doc, &mut art, false); art } diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index b895aee3a..fd423689f 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -482,7 +482,7 @@ mod tests { let expanded = expand_revolve_art(&n).unwrap(); let count = |n: &Node| n.children().unwrap()[0].children().unwrap().len(); assert!(count(&expanded) < count(&live) * 3 / 4, "hidden back surfaces are removed"); - assert_eq!(crate::bake_appearance(&n), Some(live.clone()), "other baking retains the full surface"); + assert_eq!(crate::bake_appearance(None, &n), Some(live.clone()), "other baking retains the full surface"); n.appearance.effects[0].params["expandVisibleOnly"] = json!(false); assert_eq!(revolve_art(&n), Some(live.clone()), "the live view ignores the expansion setting"); assert_eq!(expand_revolve_art(&n), Some(live)); diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index 461f5610d..29bbdb3bd 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -330,7 +330,7 @@ fn export_and_expand_bake_faces_with_their_own_ids() { let mut n = square_node(); n.id = d.alloc_id(); n.appearance.effects.push(fx(id, json!({"bevel": "rounded"}))); - assert_eq!(crate::bake_appearance(&n), solid_art(&n), "{id}"); + assert_eq!(crate::bake_appearance(None, &n), solid_art(&n), "{id}"); let mut stroke = |_: &mut Document, _: &PathData, _: FillRule, _: &StrokeLayer| None; let art = crate::expand_leaf(&mut d, &n, &mut stroke).unwrap(); let mut ids = std::collections::HashSet::new(); @@ -356,7 +356,7 @@ fn a_twisted_tapered_hollow_inflate_exports_and_expands() { let params = json!({"twist": 120, "taper": 50, "cap": false, "bothSides": true, "rotationX": -20, "rotationY": 35}); n.appearance.effects.push(fx(INFLATE, params)); let art = solid_art(&n).unwrap(); - assert_eq!(crate::bake_appearance(&n), Some(art.clone())); + assert_eq!(crate::bake_appearance(None, &n), Some(art.clone())); assert!(faces(&art).len() > 20, "{} faces", faces(&art).len()); let mut stroke = |_: &mut Document, _: &PathData, _: FillRule, _: &StrokeLayer| None; let expanded = crate::expand_leaf(&mut d, &n, &mut stroke).unwrap(); diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 90c215bf0..532251bd7 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -412,7 +412,7 @@ fn stacked_effects_on_art_stay_within_the_anchor_budget() { let leaf = |x: f64| Arc::new(Node::path(id, shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0)), Appearance::default())); let mut art = Node::group(id, (0..10).map(|k| leaf(k as f64 * 120.0)).collect()); let rough = fx("distort.roughen", json!({"size": 50, "detail": 100})); - crate::reshape::reshape_leaves_within(&mut art, &[&rough, &rough, &rough], Budget { anchors: 5_000, lines: usize::MAX }); + crate::reshape::reshape_leaves_within(&mut art, &[&rough, &rough, &rough], Budget { anchors: 5_000, lines: usize::MAX }, None); let mut total = 0; let mut reshaped = 0; for c in art.children().into_iter().flatten() { @@ -451,12 +451,12 @@ fn art_past_the_budget_keeps_its_own_gradients_in_place() { for params in [json!({"scaleH": 1000, "scaleV": 1000}), json!({"scaleH": 1000, "scaleV": 1000, "strokes": true})] { let grow = fx("distort.transform", params.clone()); let mut art = Node::group(id, vec![circle()]); - let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], Budget::EFFECTS); + let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], Budget::EFFECTS, None); let end = own_end(&carry).unwrap(); assert!((end - 550.0).abs() < 1e-6, "{params}: within the budget the group's gradient grows with the circle: {end}"); // No room beyond what the circle costs: scaled up, it would be drawn with more lines. let mut art = Node::group(id, vec![circle()]); - let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], Budget { anchors: usize::MAX, lines: 0 }); + let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], Budget { anchors: usize::MAX, lines: 0 }, None); let b = art.geometric_bounds().unwrap(); assert!(b.width() < 101.0 && b.height() < 101.0, "{params}: the circle keeps its shape: {b:?}"); assert_eq!(own_end(&carry), Some(100.0), "{params}: the group's gradient stays where it was placed"); @@ -465,7 +465,7 @@ fn art_past_the_budget_keeps_its_own_gradients_in_place() { let mut inner = Node::group(NodeId(2), vec![square]); inner.appearance.items.push(AppearanceItem::Fill(FillLayer::new(placed.clone()))); let mut art = Node::group(id, vec![circle(), Arc::new(inner)]); - let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], Budget { anchors: usize::MAX, lines: 0 }); + let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], Budget { anchors: usize::MAX, lines: 0 }, None); assert_eq!(own_end(&carry), Some(100.0), "{params}: the art's own gradient stays"); let inner = &art.children().unwrap()[1]; let Paint::Gradient(g) = inner.appearance.fill_paint() else { panic!("a gradient") }; @@ -509,7 +509,7 @@ fn a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move() { let corners = json!({"corners": [[-4.5, -4.5], [5.5, -4.5], [5.5, 5.5], [-4.5, 5.5]]}); for grow in [fx("distort.transform", json!({"scaleH": 1000, "scaleV": 1000})), fx("distort.freeDistort", corners)] { let mut art = layer(); - let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], budget); + let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], budget, None); let circle = art.children().unwrap()[1].geometric_bounds().unwrap(); assert!(circle.width() < 101.0, "{}: the hidden circle keeps its shape: {circle:?}", grow.id); let square = art.children().unwrap()[0].geometric_bounds().unwrap(); @@ -518,7 +518,7 @@ fn a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move() { carry.apply(&mut own); assert_eq!(end(&own), Some(550.0), "{}: the layer's gradient, as the art", grow.id); let mut art = Node::group(NodeId(9), vec![Arc::new(layer())]); - crate::reshape::reshape_leaves_within(&mut art, &[&grow], budget); + crate::reshape::reshape_leaves_within(&mut art, &[&grow], budget, None); assert_eq!(end(&art.children().unwrap()[0].appearance), Some(550.0), "{}: the layer's gradient, inside the art", grow.id); } } @@ -549,7 +549,7 @@ fn edited_freeform_points_follow_a_transform_effect_and_free_distort() { inner.appearance.items.push(AppearanceItem::Fill(FillLayer::new(freeform.clone()))); let mut art = Node::group(NodeId(9), vec![square(Appearance::basic(freeform.clone(), Paint::None, 0.0)), Arc::new(inner)]); art.appearance.items.push(AppearanceItem::Fill(FillLayer::new(freeform.clone()))); - let carry = crate::reshape::reshape_leaves_within(&mut art, &[&e], Budget::EFFECTS); + let carry = crate::reshape::reshape_leaves_within(&mut art, &[&e], Budget::EFFECTS, None); let members = art.children().unwrap(); assert_eq!(xs(&members[0].appearance), Some(vec![30.0, 130.0]), "{}: the path's points", e.id); assert_eq!(xs(&members[1].appearance), Some(vec![30.0, 130.0]), "{}: the inner group's points", e.id); @@ -625,7 +625,7 @@ fn reshape_leaves_within_optimizes_transform_before_terminal_offset_path() { let offset = fx("path.offsetPath", json!({"offset": 4.0, "joins": "round"})); let start = std::time::Instant::now(); - crate::reshape::reshape_leaves_within(&mut art, &[&transform, &offset], Budget::EFFECTS); + crate::reshape::reshape_leaves_within(&mut art, &[&transform, &offset], Budget::EFFECTS, None); let elapsed = start.elapsed(); assert!(elapsed < std::time::Duration::from_secs(2)); @@ -652,7 +652,7 @@ fn reshape_leaves_within_preserves_order_when_not_terminal_or_not_style_safe() { let mut art_evenodd = Node::group(id, vec![Arc::new(leaf)]); let transform = fx("distort.transform", json!({"moveH": 2.0, "moveV": 2.0, "copies": 2})); let offset = fx("path.offsetPath", json!({"offset": 4.0})); - crate::reshape::reshape_leaves_within(&mut art_evenodd, &[&transform, &offset], Budget::EFFECTS); + crate::reshape::reshape_leaves_within(&mut art_evenodd, &[&transform, &offset], Budget::EFFECTS, None); let p = art_evenodd.children().unwrap()[0].path_data().unwrap(); assert_eq!(p.subpaths.len(), 1, "EvenOdd does not swap; copies union into 1 outer subpath"); @@ -660,7 +660,7 @@ fn reshape_leaves_within_preserves_order_when_not_terminal_or_not_style_safe() { let leaf_round = Arc::new(Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default())); let mut art_round = Node::group(id, vec![leaf_round]); let round_corners = fx("stylize.roundCorners", json!({"radius": 2.0})); - crate::reshape::reshape_leaves_within(&mut art_round, &[&transform, &offset, &round_corners], Budget::EFFECTS); + crate::reshape::reshape_leaves_within(&mut art_round, &[&transform, &offset, &round_corners], Budget::EFFECTS, None); let p = art_round.children().unwrap()[0].path_data().unwrap(); assert_eq!(p.subpaths.len(), 1, "Non-terminal Offset Path does not swap; copies union into 1 outer subpath"); @@ -668,7 +668,7 @@ fn reshape_leaves_within_preserves_order_when_not_terminal_or_not_style_safe() { let leaf_miter = Arc::new(Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default())); let mut art_miter = Node::group(id, vec![leaf_miter]); let offset_miter = fx("path.offsetPath", json!({"offset": 4.0, "joins": "miter"})); - crate::reshape::reshape_leaves_within(&mut art_miter, &[&transform, &offset_miter], Budget::EFFECTS); + crate::reshape::reshape_leaves_within(&mut art_miter, &[&transform, &offset_miter], Budget::EFFECTS, None); let p = art_miter.children().unwrap()[0].path_data().unwrap(); assert_eq!(p.subpaths.len(), 1, "Miter join does not swap; copies union into 1 outer subpath"); } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 0ebcb71c5..363000ca3 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -40,6 +40,9 @@ vectorcraft-plugins = { workspace = true } vectorcraft-affinity = { workspace = true } vectorcraft-scriptcheck = { workspace = true } image = { workspace = true } +jxl-oxide = { workspace = true } +jxl-color = { workspace = true } +jxl-grid = { workspace = true } hayro-jpeg2000 = { version = "=0.3.5", default-features = false, features = ["std", "simd"] } # Pure Rust, no unsafe; pinned at the version photocraft-heif uses. heic-rs = { version = "=0.1.4", default-features = false, features = ["std"], optional = true } @@ -77,5 +80,8 @@ vectorcraft-pdf = { workspace = true, features = ["read-count"] } wat = { workspace = true } # Zstandard streams for the Illustrator editing data fuzz tests. ruzstd = { workspace = true } +# JPEG XL files for the `.jxl` open and place tests. +zune-jpegxl = { workspace = true } +zune-core = { workspace = true } # The JavaScript parser's lexer, compared with the script checker's on the same scripts. vectorcraft-js = { workspace = true } diff --git a/crates/engine/src/cmd/clipboard/flavours.rs b/crates/engine/src/cmd/clipboard/flavours.rs index 1d6f2af3e..9463dd8cf 100644 --- a/crates/engine/src/cmd/clipboard/flavours.rs +++ b/crates/engine/src/cmd/clipboard/flavours.rs @@ -182,7 +182,7 @@ pub fn specs() -> Vec { "Load Image into Clipboard", [], None, - "{dataBase64 (PNG, JPEG, GIF, WebP, TIFF or BMP bytes), mime?: the type the system clipboard gave (an image/… type; the format is read from the bytes), center?: [x, y]} replace the clipboard with the image, embedded at 100% of its physical size (its resolution, else 72 ppi), centred on `center` (default: the first artboard) → {count, width, height (pt), pixelWidth, pixelHeight}; then run edit.paste. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error", + "{dataBase64 (PNG, JPEG, GIF, WebP, TIFF, BMP, Targa, PCX, PSD, PSB or JPEG XL bytes), mime?: the type the system clipboard gave (an image/… type; the format is read from the bytes), center?: [x, y]} replace the clipboard with the image, embedded at 100% of its physical size (its resolution, else 72 ppi), centered on `center` (default: the first artboard) → {count, width, height (pt), pixelWidth, pixelHeight}; then run edit.paste. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error", has_doc, import_image ), diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index 221521efb..1d009a6b0 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec { "Invert Colors", ["Edit", "Edit Colors"], None, - "{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step → {changed}", + "{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step → {changed, notChanged: {locked, hidden} (when the selection is everything select.all takes: the locked and hidden objects it left out whose colors would have changed; an object both hidden and locked counts as hidden), warning?: set when notChanged counts any object}", has_selection, |s, p| recolor(s, p, "edit.colors.invert", "Invert Colors", &invert) ), @@ -39,7 +39,7 @@ pub fn specs() -> Vec { "Convert to CMYK", ["Edit", "Edit Colors"], None, - "{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed}", + "{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed, notChanged, warning?}", has_selection, |s, p| recolor(s, p, "edit.colors.toCMYK", "Convert to CMYK", &to_cmyk) ), @@ -48,7 +48,7 @@ pub fn specs() -> Vec { "Convert to Grayscale", ["Edit", "Edit Colors"], None, - "{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed}", + "{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed, notChanged, warning?}", has_selection, |s, p| recolor(s, p, "edit.colors.toGrayscale", "Convert to Grayscale", &to_gray) ), @@ -57,7 +57,7 @@ pub fn specs() -> Vec { "Convert to RGB", ["Edit", "Edit Colors"], None, - "{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed}", + "{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed, notChanged, warning?}", has_selection, |s, p| { recolor(s, p, "edit.colors.toRGB", "Convert to RGB", &|c| { @@ -71,7 +71,7 @@ pub fn specs() -> Vec { "Saturate…", ["Edit", "Edit Colors"], None, - "{intensity: -100..100 (%; a number outside the range counts as its nearest end), fill?, stroke?, includeImages?, includePatterns?, ids?} scale saturation by (1 + intensity/100), reaching what edit.colors.invert does → {changed}", + "{intensity: -100..100 (%; a number outside the range counts as its nearest end), fill?, stroke?, includeImages?, includePatterns?, ids?} scale saturation by (1 + intensity/100), reaching what edit.colors.invert does → {changed, notChanged, warning?} (as edit.colors.invert)", has_selection, saturate ), @@ -80,7 +80,7 @@ pub fn specs() -> Vec { "Adjust Color Balance…", ["Edit", "Edit Colors"], None, - "{mode?: \"rgb\"|\"cmyk\"|\"gray\"|\"global\" (default: from the channels given), r?, g?, b? | c?, m?, y?, k? | gray? | tint?: -100..100 (% added per channel, default 0; `tint` is global mode: it shifts the tints of colours and gradient stops linked to global and spot swatches, which stay linked, and leaves other colours and images alone), convert?: false (true: the results stay in the adjusted model; false: each colour keeps its own), fill?, stroke?, includeImages?, includePatterns?, ids?} reaching what edit.colors.invert does → {changed}", + "{mode?: \"rgb\"|\"cmyk\"|\"gray\"|\"global\" (default: from the channels given), r?, g?, b? | c?, m?, y?, k? | gray? | tint?: -100..100 (% added per channel, default 0; `tint` is global mode: it shifts the tints of colours and gradient stops linked to global and spot swatches, which stay linked, and leaves other colours and images alone), convert?: false (true: the results stay in the adjusted model; false: each colour keeps its own), fill?, stroke?, includeImages?, includePatterns?, ids?} reaching what edit.colors.invert does → {changed, notChanged, warning?} (as edit.colors.invert)", has_selection, adjust_balance ), @@ -213,6 +213,48 @@ impl<'a> Recolor<'a> { Self { links: Some(links), ..Self::new(scope, f) } } + /// A pass that maps colors as this one does, with nothing recolored yet. + fn again(&self) -> Self { + Self { links: self.links, ..Self::new(self.scope, self.f) } + } + + /// The entries of `objects` (ids, each with a flag) whose object this pass would change, other + /// than by swatch links alone, its patterns' tiles included. The document stays as it is. + pub(crate) fn would_change(mut self, d: &Document, objects: &[(NodeId, T)]) -> Vec<(NodeId, T)> { + let tried: Vec<(&Node, Node, T)> = + objects.iter().filter_map(|&(id, flag)| d.node(id).and_then(|n| Some((n, self.subtree(d, n)?, flag)))).collect(); + let same = self.link_only_copies(d); + tried.into_iter().filter_map(|(n, m, flag)| (without_links(m, &same) != without_links(n.clone(), &same)).then_some((n.id, flag))).collect() + } + + /// The patterns this pass has copied whose copy differs from the original only by swatch + /// links: the copy's name → the original's. A copy whose tile uses other copies counts when + /// those copies do. + fn link_only_copies(&self, d: &Document) -> HashMap { + let art = |v: &[Arc], same: &HashMap| v.iter().map(|n| without_links((**n).clone(), same)).collect::>(); + let copies: HashMap<&str, &PatternDef> = self.new_patterns.iter().map(|p| (p.name.as_str(), p)).collect(); + let pairs: Vec<(&String, &String, &PatternDef, &PatternDef)> = self + .patterns + .iter() + .filter_map(|(old, new)| { + let new = new.as_ref()?; + Some((new, old, d.pattern(old)?, *copies.get(new.as_str())?)) + }) + .collect(); + let mut same = HashMap::new(); + loop { + let found: Vec<(String, String)> = pairs + .iter() + .filter(|(new, _, original, copy)| !same.contains_key(*new) && art(&original.art, &same) == art(©.art, &same)) + .map(|(new, old, _, _)| ((*new).clone(), (*old).clone())) + .collect(); + if found.is_empty() { + return same; + } + same.extend(found); + } + } + /// Recolour the objects `ids` and everything inside them (each once), add the new images and /// pattern swatches, and return the number of paints, meshes and images changed. pub(crate) fn run(mut self, d: &mut Document, ids: &[NodeId]) -> usize { @@ -313,6 +355,29 @@ impl<'a> Recolor<'a> { } } +/// `n` with the swatch links of its paints and its children's removed, each color kept as it +/// shows, and each pattern named in `same` (a copy → its original) by its original's name. +fn without_links(mut n: Node, same: &HashMap) -> Node { + for p in vectorcraft_doc::swatches::node_paints_mut(&mut n) { + map_links(p, &|_, link, _| link.take().is_some()); + match p { + Paint::Gradient(g) => g.swatch = None, + Paint::Pattern { pattern, .. } => { + if let Some(original) = same.get(pattern.as_str()) { + *pattern = original.clone(); + } + } + _ => {} + } + } + if let Some(children) = n.children_mut() { + for c in children.iter_mut() { + *c = Arc::new(without_links((**c).clone(), same)); + } + } + n +} + /// What a pass over document `d` does to each colour the shared visitor meets. struct Visit<'p, 'a> { pass: &'p mut Recolor<'a>, @@ -342,19 +407,107 @@ pub(crate) fn recolor_links(s: &mut Session, p: &Value, cmd: &str, label: &str, } /// Run `pass` on the `ids` param's objects (default: the selection) as one undo step named `label`; -/// errors name the command `cmd`. +/// errors name the command `cmd`. The reply's `notChanged` counts the locked and hidden objects the +/// selection left out ([`left_out`]) that the pass would have changed. fn run_pass(s: &mut Session, p: &Value, cmd: &str, label: &str, pass: Recolor) -> Result { - let ids = match ids_param(p, "ids", cmd)? { - Some(v) => v, - None => selected_roots(s)?, + let (ids, left) = match ids_param(p, "ids", cmd)? { + Some(v) => (v, vec![]), + None => { + let roots = selected_roots(s)?; + let st = s.doc()?; + let left = left_out(&st.doc, st.isolation, &roots); + (roots, left) + } }; let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok(); - let n = s.edit(label, |d, _| { + let (n, not_changed) = s.edit(label, |d, _| { + let not_changed = NotChanged::of(pass.again().would_change(d, &left).into_iter().map(|(_, hidden)| hidden)); let n = pass.run(d, &ids); super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(cmd, e))?; - Ok(n) + Ok((n, not_changed)) })?; - Ok(json!({ "changed": n })) + let mut out = json!({ "changed": n }); + not_changed.report(&mut out); + Ok(out) +} + +/// The objects a recolor of `roots` leaves out of the art the user meant to recolor, each with +/// whether it is hidden (otherwise it is locked). When `roots` are exactly the objects Select All +/// takes (in isolation mode, those of the isolated group), these are the objects beside them that +/// Select All skips: hidden or locked objects, the objects of hidden or locked layers and +/// sublayers, and, outside isolation mode, objects on locked artboards (counted as locked). The art +/// of template layers and the objects inside a group are not listed. With any other `roots`, none. +pub(crate) fn left_out(d: &Document, iso: Option, roots: &[NodeId]) -> Vec<(NodeId, bool)> { + fn visit(n: &Node, hidden: bool, locked: bool, all: &HashSet, board_locked: &dyn Fn(&Node) -> bool, out: &mut Vec<(NodeId, bool)>) { + for c in n.children().into_iter().flatten() { + if c.is_template() { + continue; + } + let (hidden, locked) = (hidden || !c.visible, locked || c.locked); + if c.is_layer() { + visit(c, hidden, locked, all, board_locked, out); + } else if !all.contains(&c.id) && (hidden || locked || board_locked(c)) { + out.push((c.id, hidden)); + } + } + } + let all: HashSet = super::select::selectable(d, iso).into_iter().collect(); + let picked: HashSet = roots.iter().copied().collect(); + if picked.is_empty() || picked != all { + return vec![]; + } + let mut out = vec![]; + match iso.and_then(|i| d.node(i)) { + Some(c) => visit(c, false, false, &all, &|_| false, &mut out), + None => { + let boards = d.locked_artboards(); + let board_locked = |n: &Node| !boards.is_empty() && boards.holds(n); + for l in d.layers.iter().filter(|l| !l.is_template()) { + visit(l, !l.visible, l.locked, &all, &board_locked, &mut out); + } + } + } + out +} + +/// How many locked and hidden objects a recolor left out ([`left_out`]) although it would have +/// changed their colors. An object both hidden and locked counts as hidden. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct NotChanged { + pub locked: usize, + pub hidden: usize, +} + +impl NotChanged { + /// The counts of objects given by whether each is hidden. + pub(crate) fn of(hidden: impl IntoIterator) -> Self { + hidden.into_iter().fold(Self::default(), |n, h| if h { Self { hidden: n.hidden + 1, ..n } } else { Self { locked: n.locked + 1, ..n } }) + } + + /// Add `notChanged` to reply `out`, and a `warning`, which the app shows in its status bar, + /// when any object is counted. + pub(crate) fn report(self, out: &mut Value) { + out["notChanged"] = json!({ "locked": self.locked, "hidden": self.hidden }); + if let Some(w) = self.warning() { + out["warning"] = json!(w); + } + } + + fn warning(self) -> Option { + let part = |n: usize, kind: &str| match n { + 0 => None, + 1 => Some(format!("1 {kind} object")), + n => Some(format!("{n} {kind} objects")), + }; + match (part(self.locked, "locked"), part(self.hidden, "hidden")) { + (Some(locked), Some(hidden)) => Some(format!("Recolor completed; {locked} and {hidden} were left unchanged")), + (Some(objects), None) | (None, Some(objects)) if self.locked + self.hidden == 1 => { + Some(format!("Recolor completed; {objects} was left unchanged")) + } + (Some(objects), None) | (None, Some(objects)) => Some(format!("Recolor completed; {objects} were left unchanged")), + (None, None) => None, + } + } } fn saturate(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/compoundshape.rs b/crates/engine/src/cmd/compoundshape.rs index 52c19a9a5..bc93b399a 100644 --- a/crates/engine/src/cmd/compoundshape.rs +++ b/crates/engine/src/cmd/compoundshape.rs @@ -189,7 +189,7 @@ fn make(s: &mut Session, p: &Value) -> Result { /// that has a shape. A shape taken from inside a group, or from the art of a blend, an envelope or /// a warped group, also takes that object's opacity and blend mode. fn top_style(d: &Document, n: &Node, hook: effects::OutlineHook) -> Option