diff --git a/Cargo.lock b/Cargo.lock
index 9fd8db586..591389407 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1059,7 +1059,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
+source = "git+https://github.com/storytold/craft-libs?rev=c365d75c48b0e47f4b43cdb6592d0cdc39d2d52e#c365d75c48b0e47f4b43cdb6592d0cdc39d2d52e"
dependencies = [
"chrono",
"dunce",
@@ -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]]
@@ -6384,6 +6565,7 @@ dependencies = [
name = "vectorcraft-js"
version = "0.21.0"
dependencies = [
+ "chrono",
"proptest",
"unicode-general-category",
]
@@ -8039,7 +8221,6 @@ dependencies = [
"nix",
"typed-path",
"windows-sys 0.61.2",
- "zopfli",
]
[[package]]
@@ -8054,18 +8235,6 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
-[[package]]
-name = "zopfli"
-version = "0.8.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
-dependencies = [
- "bumpalo",
- "crc32fast",
- "log",
- "simd-adler32",
-]
-
[[package]]
name = "zune-core"
version = "0.4.12"
@@ -8096,6 +8265,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..fd8527fd9 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -22,7 +22,7 @@ craft-window = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63
# The `.vectorcraft` envelope: its members, version, embedded images and the data after the JSON.
craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
# craft-update: app-specific pretend versions apply to startup and manual checks.
-craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
+craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c365d75c48b0e47f4b43cdb6592d0cdc39d2d52e", features = ["egui"] }
# internal
vectorcraft-geom = { path = "crates/geom" }
vectorcraft-color = { path = "crates/color" }
@@ -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/ROADMAP.md b/ROADMAP.md
index 504324eaf..1bedf0b95 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -4,6 +4,7 @@
- Compound Shapes add-on (beyond Illustrator): a menu for Make, Release and Expand Compound Shape and a setting, off by default, that makes the Pathfinder panel's Shape Mode buttons create compound shapes on a click. Scores and estimates unchanged.
- Paint: new gradients use the document's color model for default stops, including Gradient tool creation. Scores and estimates unchanged.
+- UI: Window → Help Bar toggles the tool-help strip separately from the status bar and saves visibility with workspaces. Scores and estimates unchanged.
- PDF.R1: editable PDF import uses the shared reader, scans page content in windows, and rejects known incomplete input before returning a document.
- PDF.W1: PDF export and print composition use the shared optional writer for configuration, metadata, font preparation and guarded finalization; drawing and output policy remain app-owned.
diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs
index 2dfc4e6f4..f47b10e68 100644
--- a/apps/vectorcraft/src/app.rs
+++ b/apps/vectorcraft/src/app.rs
@@ -92,6 +92,9 @@ impl eframe::App for App {
}
fn on_exit(&mut self) {
file_access::unconfined(|| save_prefs(&self.app));
+ // A ready update installs once the process has exited. The macOS exit below skips
+ // dropping the app, so the helper is started here.
+ self.app.start_pending_update();
end_before_teardown(self.frames);
}
}
diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs
index b3a1fd06b..fd062e504 100644
--- a/apps/vectorcraft/src/main.rs
+++ b/apps/vectorcraft/src/main.rs
@@ -97,7 +97,7 @@ use titlebar::opted_into_system_title_bar;
#[cfg(windows)]
use titlebar::set_taskbar_icon;
#[cfg(all(test, feature = "wgpu"))]
-use titlebar::{app_draws_frame, opted_into_app_title_bar, start_frame};
+use titlebar::{app_draws_frame, opted_into_app_title_bar, start_frame, system_title_bar_applies};
use titlebar::{custom_titlebar, native_options, titlebar_from_env};
struct App {
diff --git a/apps/vectorcraft/src/prefs.rs b/apps/vectorcraft/src/prefs.rs
index f03572ebb..c57ab6b58 100644
--- a/apps/vectorcraft/src/prefs.rs
+++ b/apps/vectorcraft/src/prefs.rs
@@ -168,6 +168,11 @@ fn same_lasting(a: &serde_json::Value, b: &serde_json::Value) -> bool {
}
}
+/// Fields that a read fills in from other fields when the file lacks them
+/// (`vectorcraft_ui_egui::layout_migration`): `help_bar` follows `status_bar`. A save writes them
+/// whenever the file lacks them, so the next read does not fill them in again from a changed field.
+const DERIVED: &[&str] = &["help_bar"];
+
/// `on_disk` with what changed from `base` to `mine` put over it: each top-level field of the UI
/// state, and each engine preference (`engine_prefs`) on its own, so two windows that change
/// different settings keep both. A field both changed takes this window's value (it saves last).
@@ -219,6 +224,13 @@ fn merge_changes(base: &serde_json::Value, mine: &serde_json::Value, on_disk: se
}
}
}
+ for key in DERIVED {
+ if !out.contains_key(*key)
+ && let Some(v) = mine.get(*key)
+ {
+ out.insert((*key).to_string(), v.clone());
+ }
+ }
if use_mine {
match mine.get("action_set") {
Some(v) => {
@@ -383,6 +395,20 @@ mod tests {
assert!(!same_lasting(&base, &mine));
}
+ /// A file from before the Help Bar setting: the read shows the Help Bar with the status bar.
+ /// After the status bar is hidden and the Help Bar left showing, the save writes `help_bar`,
+ /// so the next read does not hide it with the status bar. A value on disk is kept.
+ #[test]
+ fn a_help_bar_setting_the_file_lacks_is_saved() {
+ let base = json!({"status_bar": true, "help_bar": true});
+ let mine = json!({"status_bar": false, "help_bar": true});
+ let on_disk = json!({"status_bar": true});
+ assert_eq!(merge_changes(&base, &mine, on_disk), json!({"status_bar": false, "help_bar": true}));
+ // Another window saved its own Help Bar setting: it stays.
+ let on_disk = json!({"status_bar": true, "help_bar": false});
+ assert_eq!(merge_changes(&base, &mine, on_disk), json!({"status_bar": false, "help_bar": false}));
+ }
+
/// One window deleted the first Actions set and saved; this one made the second set current.
/// The saved index follows that set to its place in the merged list.
#[test]
diff --git a/apps/vectorcraft/src/tests.rs b/apps/vectorcraft/src/tests.rs
index 555d829e0..e81dd7815 100644
--- a/apps/vectorcraft/src/tests.rs
+++ b/apps/vectorcraft/src/tests.rs
@@ -245,6 +245,79 @@ fn the_start_frame_applies_the_app_title_bar_preference() {
assert!(!super::custom_titlebar_from(&both, true), "System Title Bar takes precedence over the app title bar preference");
}
+/// `VECTORCRAFT_TITLEBAR` chooses the frame for the run it is set in, with System Title Bar off and
+/// on. The saved preference still decides while the variable is unset.
+#[test]
+fn the_titlebar_variable_takes_precedence_over_system_title_bar() {
+ use craft_window::Frame;
+ assert!(super::system_title_bar_applies(true, false));
+ assert!(!super::system_title_bar_applies(true, true));
+ assert!(!super::system_title_bar_applies(false, false));
+ assert!(!super::system_title_bar_applies(false, true));
+ // VECTORCRAFT_TITLEBAR=app with System Title Bar on: VectorCraft draws its own title bar.
+ let frame = super::start_frame(Frame::Desktop, Some(true), false, false);
+ assert_eq!(super::custom_titlebar_from(&frame, super::system_title_bar_applies(true, true)), !cfg!(target_os = "macos"));
+ // VECTORCRAFT_TITLEBAR=desktop with System Title Bar off keeps the desktop's frame.
+ #[cfg(not(any(target_os = "macos", target_os = "windows")))]
+ {
+ let frame = super::start_frame(Frame::App(craft_window::Layout::default()), Some(false), false, false);
+ assert!(!super::custom_titlebar_from(&frame, super::system_title_bar_applies(false, true)));
+ }
+}
+
+/// `VECTORCRAFT_TITLEBAR` read from the environment at startup, with System Title Bar off and on.
+/// The variable is process-wide and read once, so each value runs in a separate test process.
+#[test]
+fn the_titlebar_variable_decides_the_frame_before_the_window_opens() {
+ const CHILD: &str = "VECTORCRAFT_TEST_TITLEBAR_CHILD";
+ if std::env::var_os(CHILD).is_none() {
+ for value in ["app", "system", "desktop", "unknown"] {
+ // Linux and the BSDs: hold `spawning` while the child starts, so it does not keep a
+ // copy of a GPU start test's locked start file (see `CHILD_SPAWN`).
+ #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
+ let spawning = super::spawning();
+ let child = std::process::Command::new(std::env::current_exe().unwrap())
+ .args(["--exact", "tests::the_titlebar_variable_decides_the_frame_before_the_window_opens", "--nocapture"])
+ .env(CHILD, "1")
+ .env("VECTORCRAFT_TITLEBAR", value)
+ .stdin(std::process::Stdio::null())
+ .stdout(std::process::Stdio::piped())
+ .stderr(std::process::Stdio::piped())
+ .spawn();
+ #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
+ drop(spawning);
+ let output = child.unwrap().wait_with_output().unwrap();
+ assert!(
+ output.status.success(),
+ "VECTORCRAFT_TITLEBAR={value}\n{}\n{}",
+ String::from_utf8_lossy(&output.stdout),
+ String::from_utf8_lossy(&output.stderr)
+ );
+ }
+ return;
+ }
+ let value = std::env::var("VECTORCRAFT_TITLEBAR").unwrap();
+ let not_read = cfg!(any(target_os = "macos", target_os = "windows"));
+ assert_eq!(super::titlebar_from_env(), !not_read && value != "unknown", "VECTORCRAFT_TITLEBAR={value}");
+ let off = ui_state_with_prefs(serde_json::json!({"systemTitleBar": false}));
+ let on = ui_state_with_prefs(serde_json::json!({"systemTitleBar": true}));
+ for (saved, opted_into_system) in [(None, false), (Some(&off), false), (Some(&on), true)] {
+ let custom = super::custom_titlebar(saved);
+ let expected = match value.as_str() {
+ _ if not_read => !cfg!(target_os = "macos") && !opted_into_system,
+ "app" => true,
+ "system" | "desktop" => false,
+ // Not a value the variable takes: System Title Bar decides when it is on. Otherwise the
+ // `craft-window` probe decides, the same with System Title Bar off as with no saved
+ // preferences.
+ _ if opted_into_system => false,
+ _ => super::custom_titlebar(None),
+ };
+ assert_eq!(custom, expected, "{value}, preference {opted_into_system}");
+ assert_eq!(super::native_options(custom).viewport.decorations, Some(!custom), "{value}, preference {opted_into_system}");
+ }
+}
+
/// The file extensions the macOS bundle declares: its document types and its own exported type.
fn plist_extensions(plist: &str) -> Vec<&str> {
["CFBundleTypeExtensions", "public.filename-extension"]
diff --git a/apps/vectorcraft/src/titlebar.rs b/apps/vectorcraft/src/titlebar.rs
index 6e7451ed9..17c847040 100644
--- a/apps/vectorcraft/src/titlebar.rs
+++ b/apps/vectorcraft/src/titlebar.rs
@@ -41,7 +41,8 @@ pub(crate) const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
/// Whether the user asked for the system title bar in Preferences › User Interface › System Title
/// Bar. Read from the saved preferences before the window opens; a missing or unreadable value is
-/// `false` (the default). This preference forces the desktop/native frame back on.
+/// `false` (the default). This preference forces the desktop/native frame back on unless
+/// `VECTORCRAFT_TITLEBAR` is set ([`system_title_bar_applies`]).
pub(crate) fn opted_into_system_title_bar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool {
saved.map(|ui| ui.engine_prefs.get("systemTitleBar").and_then(serde_json::Value::as_bool).unwrap_or(false)).unwrap_or(false)
}
@@ -87,12 +88,18 @@ fn probed_choice() -> &'static craft_window::Choice {
}
/// Whether `VECTORCRAFT_TITLEBAR` chose this window's frame (Linux and the BSDs), so Preferences ›
-/// User Interface › VectorCraft Title Bar on Every Desktop does not apply while it is set. Always
-/// `false` on macOS and Windows.
+/// User Interface › System Title Bar and VectorCraft Title Bar on Every Desktop do not apply while
+/// it is set. Always `false` on macOS and Windows.
pub(crate) fn titlebar_from_env() -> bool {
cfg!(not(any(target_os = "macos", target_os = "windows"))) && probed_choice().overridden().is_some()
}
+/// Whether System Title Bar decides this start: `opted_into_system` unless `VECTORCRAFT_TITLEBAR`
+/// is set (`from_env`), which chooses the frame for the run it is set in.
+pub(crate) fn system_title_bar_applies(opted_into_system: bool, from_env: bool) -> bool {
+ opted_into_system && !from_env
+}
+
/// Whether this start draws its own title bar, given the frame the shared `craft-window` crate
/// decided and the user's System Title Bar preference.
///
@@ -102,7 +109,8 @@ pub(crate) fn titlebar_from_env() -> bool {
/// ([`craft_window::Frame::App`]) only where the compositor leaves it to the app (GNOME's Mutter),
/// or when `VECTORCRAFT_TITLEBAR` overrides the probe. VectorCraft draws its own title bar exactly
/// when `craft-window` says the app owns the frame **and** the user has not opted into the system
-/// title bar; the preference always forces the native frame back on.
+/// title bar; the preference forces the native frame back on unless `VECTORCRAFT_TITLEBAR` is set
+/// ([`system_title_bar_applies`]).
///
/// macOS and Windows keep today's behavior and are decided by [`CUSTOM_TITLEBAR`] rather than the
/// probe: `craft-window` returns `Frame::Desktop` for both (the OS always draws a frame), but
@@ -124,7 +132,8 @@ pub(crate) fn custom_titlebar_from(_frame: &craft_window::Frame, opted_into_syst
/// Whether this start draws its own title bar. Probes the shared `craft-window` crate once (its
/// override variable is `VECTORCRAFT_TITLEBAR`) and combines its decision with the saved System
-/// Title Bar preference. Call once at startup, before [`native_options`].
+/// Title Bar preference, which does not apply while the variable is set. Call once at startup,
+/// before [`native_options`].
///
/// On Linux, Preferences › Performance › Linux Display Server › X11 opens the window through
/// XWayland (below). A GNOME/Wayland probe would say `Frame::App` there, but the window really runs
@@ -133,7 +142,7 @@ pub(crate) fn custom_titlebar_from(_frame: &craft_window::Frame, opted_into_syst
/// and so does VectorCraft Title Bar on Every Desktop ([`app_draws_frame`]).
pub(crate) fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool {
let choice = probed_choice();
- let opted_into_system = opted_into_system_title_bar(saved);
+ let opted_into_system = system_title_bar_applies(opted_into_system_title_bar(saved), titlebar_from_env());
// macOS and Windows never read VectorCraft Title Bar on Every Desktop (`custom_titlebar_from`
// ignores the frame there).
let opted_into_app = cfg!(not(any(target_os = "macos", target_os = "windows"))) && opted_into_app_title_bar(saved);
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/graph.rs b/crates/doc/src/graph.rs
index b19adc8b0..15f04ce15 100644
--- a/crates/doc/src/graph.rs
+++ b/crates/doc/src/graph.rs
@@ -321,6 +321,20 @@ pub struct SeriesPaint {
/// Opacity (0..1) of the topmost stroke of each path directly in the series group, as [`Self::fill_opacity`].
#[serde(skip_serializing_if = "Option::is_none")]
pub stroke_opacity: Option,
+ /// Blend mode of the topmost fill of each path directly in the series group, as [`Self::fill_opacity`]. Absent: Normal.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub fill_blend: Option,
+ /// Blend mode of the topmost stroke of each path directly in the series group, as [`Self::fill_opacity`]. Absent:
+ /// Normal.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub stroke_blend: Option,
+ /// Object opacity (0..1) of each path directly in the series group, taken from the object opacity set on one of them.
+ /// Absent: fully opaque.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub opacity: Option,
+ /// Object blend mode of each path directly in the series group, as [`Self::opacity`]. Absent: Normal.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub blend: Option,
}
/// Graph data and options. `rows` are categories (one per row of the Graph Data window), each
diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs
index ea3575bf6..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
@@ -437,7 +438,7 @@ pub fn locked_objects_at(doc: &Document, p: Point, opt: HitOptions) -> Vec) {
let Some(children) = parent.children() else { return };
- if parent.clips() && children.first().is_some_and(|clip| !clip_contains(clip, p)) {
+ if parent.clips() && !edits_inside(parent, opt.scope) && children.first().is_some_and(|clip| !clip_contains(clip, p)) {
return;
}
for i in hit_order(parent, children, p) {
@@ -653,6 +654,15 @@ fn clip_outline_hit(parent: &Node, clip: &Node, p: Point, opt: HitOptions, symbo
clip_outline(clip, p, opt)
}
+/// Does isolation mode (`scope`) edit what `parent` holds: is `parent` the isolated container, or
+/// the parent of the single object isolated? Then the selection tools hit the art that the
+/// clipping mask of `parent` hides, and the Unlock submenu lists it (#3748). The reference
+/// app's help says that the parts outside a clipping mask can still be edited
+/// ().
+fn edits_inside(parent: &Node, scope: Option) -> bool {
+ scope.is_some_and(|s| s == parent.id || parent.children().is_some_and(|c| c.iter().any(|c| c.id == s)))
+}
+
fn hit_children(
parent: &Node,
p: Point,
@@ -670,8 +680,10 @@ fn hit_children(
chain.push(clip.id);
return Some(Hit { leaf: clip.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 });
}
- // Elsewhere a clip group (or a layer with a clipping mask) only hits inside it.
- if !clip_contains(clip, p) {
+ // Elsewhere a clip group (or a layer with a clipping mask) only hits inside it, except for
+ // the selection tools in isolation mode within it.
+ let edited = opt.clip_outlines && editable_only && edits_inside(parent, opt.scope);
+ if !edited && !clip_contains(clip, p) {
return None;
}
}
@@ -860,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),
})
@@ -1205,7 +1217,9 @@ pub fn compound_part_at(n: &Node, p: Point, tol: f64) -> Option {
/// out those on a locked artboard ([`crate::LockedArtboards::holds_id`]). Symbol instances are measured
/// by their art ([`Node::geometric_bounds_with`]). The art of a clip group or of a layer with a
/// clipping mask counts only when the marquee meets the region its clipping path clips to, and then
-/// only within that region's bounds.
+/// only within that region's bounds. In isolation mode the clipping mask of the isolated container,
+/// or of the parent of the single object isolated, does not limit the marquee, as it does not limit
+/// the selection tools' clicks.
pub fn marquee(doc: &Document, r: Rect, scope: Option, leaves: bool) -> Vec {
marquee_with(doc, r, scope, leaves, false)
}
@@ -1223,16 +1237,15 @@ fn marquee_with(doc: &Document, r: Rect, scope: Option, leaves: bool, wh
// Isolated art whose layer, or a group around it, was locked or hidden afterwards: the
// marquee touches none of it, as clicks don't (#5309).
Some(s) if !doc.is_editable(s.id) => vec![],
- Some(s) => match (s.children(), marquee_in(s, r)) {
- (Some(c), Some(r)) => c.iter().enumerate().filter(|(i, _)| !shaper_hides(s, *i, scope)).map(|(_, c)| (c, r)).collect(),
- (Some(_), None) => vec![],
+ Some(s) => match s.children() {
+ Some(c) => c.iter().enumerate().filter(|(i, _)| !shaper_hides(s, *i, scope)).map(|(_, c)| (c, r)).collect(),
// A single object isolated: only it.
- (None, _) => doc
+ None => doc
.parent_of(s.id)
.and_then(|p| doc.node(p))
- .and_then(|p| Some((p.children()?, marquee_in(p, r)?)))
+ .and_then(|p| p.children())
.into_iter()
- .flat_map(|(c, r)| c.iter().filter(|c| c.id == s.id).map(move |c| (c, r)))
+ .flat_map(|c| c.iter().filter(|c| c.id == s.id).map(|c| (c, r)))
.collect(),
},
// The objects of the layers, looking through sublayers (hidden, locked and template ones
diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs
index 495dc227b..d6f4cafe7 100644
--- a/crates/doc/src/hit/tests.rs
+++ b/crates/doc/src/hit/tests.rs
@@ -255,7 +255,9 @@ fn a_selected_objects_hidden_art_on_a_preview_off_layer_counts_on_its_outline()
/// #2345: a marquee touches a clip group only where its art shows. Over the clipped-away
/// half alone it selects nothing, and around a rectangle drawn beside the group's mask it
/// takes that rectangle alone; reaching into the mask takes the group. The same holds for
-/// Group Selection's leaves and in isolation mode.
+/// Group Selection's leaves. In isolation mode (#3748) the isolated group's own mask does not
+/// limit the marquee, as it does not limit clicks: with the group or the art isolated, the
+/// marquee takes the hidden art.
#[test]
fn a_marquee_leaves_a_clip_group_by_its_clipped_away_art() {
let mut d = Document::new(300.0, 200.0);
@@ -270,8 +272,9 @@ fn a_marquee_leaves_a_clip_group_by_its_clipped_away_art() {
let hidden = Rect::new(150.0, -10.0, 210.0, 50.0);
assert!(marquee(&d, hidden, None, false).is_empty());
assert!(marquee(&d, hidden, None, true).is_empty());
- assert!(marquee(&d, hidden, Some(g), false).is_empty());
- assert!(marquee(&d, hidden, Some(art_id), false).is_empty());
+ assert_eq!(marquee(&d, hidden, Some(g), false), vec![art_id]);
+ assert_eq!(marquee(&d, hidden, Some(g), true), vec![art_id]);
+ assert_eq!(marquee(&d, hidden, Some(art_id), false), vec![art_id]);
// This marquee surrounds the rectangle beside and crosses the hidden art's bottom edge.
assert_eq!(marquee(&d, Rect::new(140.0, 70.0, 195.0, 140.0), None, false), vec![beside]);
// This marquee crosses the shown half's top edge and goes on into the hidden half.
@@ -682,6 +685,82 @@ fn select_behind_takes_a_layer_masks_outline_first() {
assert_eq!(objects_at(&d, Point::new(138.5, 90.0), HitOptions::default(), None), vec![art, clip]);
}
+/// #3748: in isolation mode the selection tools hit the art that the isolated clip group (or
+/// the isolated layer with a clipping mask) hides outside its mask, and the art when it is the
+/// single object isolated; the mask's outline is still hit first. Outside isolation mode, and
+/// for the tools that act on what is drawn, the hidden art is not hit. The Unlock submenu
+/// lists the hidden art when it is locked.
+#[test]
+fn isolation_reaches_the_art_a_clipping_mask_hides() {
+ let hidden = Point::new(170.0, 90.0);
+ for layer in [false, true] {
+ let (mut d, g, clip, art) = masked_square(layer);
+ let isolated = HitOptions { scope: Some(g), ..SELECTING };
+ assert!(hit_test(&d, hidden, SELECTING).is_none(), "layer {layer}: not isolated");
+ let h = hit_test(&d, hidden, isolated);
+ assert_eq!(h.map(|h| (h.leaf, h.top_object(Some(g)))), Some((art, art)), "layer {layer}");
+ assert_eq!(objects_at(&d, hidden, SELECTING, Some(g)), vec![art], "layer {layer}");
+ assert_eq!(hit_test(&d, Point::new(140.0, 90.0), isolated).map(|h| h.leaf), Some(clip), "layer {layer}: the outline first");
+ let one = HitOptions { scope: Some(art), ..SELECTING };
+ assert_eq!(hit_test(&d, hidden, one).map(|h| h.leaf), Some(art), "layer {layer}: the art isolated");
+ assert!(hit_test(&d, hidden, HitOptions { scope: Some(g), ..HitOptions::default() }).is_none(), "layer {layer}: drawn art only");
+ assert!(hit_test_visible(&d, hidden, isolated).is_none(), "layer {layer}: sampling");
+ d.node_mut(art).unwrap().locked = true;
+ assert_eq!(locked_objects_at(&d, hidden, HitOptions { scope: Some(g), ..HitOptions::default() }), vec![art], "layer {layer}");
+ assert!(locked_objects_at(&d, hidden, HitOptions::default()).is_empty(), "layer {layer}: not isolated");
+ }
+}
+
+/// #3748, nested clip groups: with the outer clip group isolated, a click or a marquee takes
+/// the art its own mask hides, while the mask of a clip group inside it still limits that
+/// group's art. With the inner clip group isolated, or its art, the inner mask does not.
+#[test]
+fn isolation_reaches_hidden_art_only_for_the_isolated_clip_group() {
+ let mut d = Document::new(300.0, 200.0);
+ let l = d.layers[0].id;
+ let (outer, outer_clip, outer_art, inner, inner_clip, inner_art) =
+ (d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id());
+ let mask = |id, r| {
+ let mut path = Node::path(id, shapes::rectangle(r), Appearance::basic(Paint::None, Paint::None, 0.0));
+ if let NodeKind::Path { clipping, .. } = &mut path.kind {
+ *clipping = true;
+ }
+ Arc::new(path)
+ };
+ let art = |id, r| Arc::new(Node::path(id, shapes::rectangle(r), Appearance::default_art()));
+ let inner_group = Node::new(
+ inner,
+ NodeKind::Group {
+ children: vec![mask(inner_clip, Rect::new(60.0, 60.0, 120.0, 120.0)), art(inner_art, Rect::new(40.0, 40.0, 160.0, 160.0))],
+ clip: true,
+ },
+ );
+ let children =
+ vec![mask(outer_clip, Rect::new(20.0, 20.0, 180.0, 180.0)), art(outer_art, Rect::new(190.0, 20.0, 260.0, 180.0)), Arc::new(inner_group)];
+ d.insert(Some(l), 0, Node::new(outer, NodeKind::Group { children, clip: true })).unwrap();
+ // `outer_hidden` is outside the outer mask, on the outer group's own art; `inner_hidden` is
+ // inside the outer mask and outside the inner one, on the inner group's art.
+ let (outer_hidden, inner_hidden, shown) = (Point::new(220.0, 100.0), Point::new(150.0, 100.0), Point::new(90.0, 90.0));
+ let leaf = |p, scope| hit_test(&d, p, HitOptions { scope, ..SELECTING }).map(|h| h.leaf);
+ assert_eq!((leaf(outer_hidden, None), leaf(inner_hidden, None)), (None, None), "not isolated");
+ assert_eq!(leaf(outer_hidden, Some(outer)), Some(outer_art), "outer isolated: its own hidden art");
+ assert_eq!(leaf(inner_hidden, Some(outer)), None, "outer isolated: the inner mask still clips");
+ let h = hit_test(&d, shown, HitOptions { scope: Some(outer), ..SELECTING });
+ assert_eq!(h.map(|h| (h.leaf, h.top_object(Some(outer)))), Some((inner_art, inner)), "outer isolated: shown art");
+ assert_eq!(leaf(inner_hidden, Some(inner)), Some(inner_art), "inner isolated");
+ assert_eq!(leaf(outer_hidden, Some(inner)), None, "inner isolated: the outer group's art");
+ assert_eq!(leaf(inner_hidden, Some(inner_art)), Some(inner_art), "the inner art isolated");
+ // These marquees cross the right edge of the outer art (outside the outer mask) and of the
+ // inner art (inside the outer mask, outside the inner one).
+ let (outer_edge, inner_edge) = (Rect::new(255.0, 95.0, 265.0, 105.0), Rect::new(155.0, 95.0, 165.0, 105.0));
+ assert_eq!(marquee(&d, outer_edge, Some(outer), false), vec![outer_art]);
+ assert!(marquee(&d, inner_edge, Some(outer), false).is_empty());
+ assert!(marquee(&d, inner_edge, Some(outer), true).is_empty());
+ assert_eq!(marquee(&d, inner_edge, Some(inner), false), vec![inner_art]);
+ assert!(marquee(&d, inner_edge, None, false).is_empty());
+ assert!(marquee(&d, outer_edge, None, false).is_empty());
+}
+
/// Art in a sublayer is an object of its own: a click or a marquee takes it, not the
/// sublayer; hidden, locked and template sublayers block both.
#[test]
diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs
index 5ab3b8bdf..5735d9b48 100644
--- a/crates/doc/src/lib.rs
+++ b/crates/doc/src/lib.rs
@@ -117,9 +117,9 @@ pub use text::FontHeightReference;
pub use text::GlyphChoice;
pub use text::{
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt,
- JustificationOptions, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, RubyAlign, RubyPosition,
- RubySpan, ScriptMetrics, SingleWordJustify, TabAlign, TabStop, TextFlow, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign,
- WrapShape,
+ JustificationOptions, Justify, Kinsoku, KinsokuShoriType, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, RubyAlign,
+ RubyPosition, RubySpan, ScriptMetrics, SingleWordJustify, TabAlign, TabStop, TextFlow, TextKind, TextObject, TextRun, TextStyleDef, TextWrap,
+ VerticalAlign, WrapShape,
};
pub use text::{CharClass, CompositeFont, CompositePart, CompositeParts};
pub use trace::TraceView;
diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs
index 3dd07351e..774e82439 100644
--- a/crates/doc/src/node.rs
+++ b/crates/doc/src/node.rs
@@ -31,6 +31,7 @@ pub use ids::{LAYER_COLORS, LayerColor, NodeId};
pub use kind::{ImageObject, MAX_BUFFERED_VALUES, NodeKind, with_buffered_value_budget};
pub use live_shape::{LiveShape, Scaling};
pub use opacity::{Knockout, OpacityMask, whiten};
+pub use ops::{flattens, keeps_gradient_fit};
use wire::NodeWire;
pub use wire::{MAX_READ_NODES, with_node_budget};
diff --git a/crates/doc/src/node/live_shape.rs b/crates/doc/src/node/live_shape.rs
index 93f2022e0..9ddc4e1bb 100644
--- a/crates/doc/src/node/live_shape.rs
+++ b/crates/doc/src/node/live_shape.rs
@@ -329,6 +329,10 @@ pub struct Scaling {
/// Pattern fills and strokes move with the art (Transform Patterns; General › Transform
/// Pattern Tiles is its default): their tiles transform too, instead of staying put.
pub patterns: bool,
+ /// Gradients fitted to the object stay fitted under a transform that flattens it to a line or
+ /// a point (no area left), as a Transform effect leaves them, instead of being fixed to their
+ /// fit and flattened to one color with the object.
+ pub keep_fit_when_flat: bool,
}
impl Scaling {
diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs
index 0af1d0d79..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 } => {
@@ -431,7 +433,7 @@ impl Node {
// Type and symbols in perspective keep looking the same, moved by `a`.
self.transform_projection(a);
// Refitting an unplaced gradient only reproduces moves and uniform scales.
- if !keeps_gradient_fit(a) {
+ if !keeps_gradient_fit(a) && !(sc.keep_fit_when_flat && flattens(a)) {
self.pin_gradients();
}
let k = Scaling::factor(a);
@@ -770,13 +772,24 @@ impl Node {
}
}
-/// Is `a` a move plus a positive uniform scale (after which a refit gradient still matches)?
-fn keeps_gradient_fit(a: Affine) -> bool {
+/// Is `a` a move plus a positive uniform scale (after which a refit gradient still matches)? The
+/// terms are compared within 1e-12 of the scale (at least of 1). A scale of 0 or less, a turn, a
+/// reflection and an uneven scale give false, and so does a map that flattens the object
+/// ([`flattens`]), which callers that keep a fitted gradient there check apart.
+pub fn keeps_gradient_fit(a: Affine) -> bool {
let [m0, m1, m2, m3, _, _] = a.as_coeffs();
let eps = 1e-12 * m0.abs().max(1.0);
m0 > 0.0 && m1.abs() <= eps && m2.abs() <= eps && (m0 - m3).abs() <= eps
}
+/// Does `a` flatten what it maps to a line or a point (its determinant 0, within 1e-12 of the
+/// squares of its terms), or is it not finite?
+pub fn flattens(a: Affine) -> bool {
+ let [m0, m1, m2, m3, _, _] = a.as_coeffs();
+ let norm = m0 * m0 + m1 * m1 + m2 * m2 + m3 * m3;
+ !a.is_finite() || a.determinant().abs() <= 1e-12 * norm
+}
+
impl Node {
/// Whether a document point lies in this object's filled regions ([`Node::clip_shapes`]; true
/// everywhere for objects without them, such as type, whose boxes stand in).
diff --git a/crates/doc/src/node/tests.rs b/crates/doc/src/node/tests.rs
index 43a8ac97b..335eb6312 100644
--- a/crates/doc/src/node/tests.rs
+++ b/crates/doc/src/node/tests.rs
@@ -221,7 +221,8 @@ fn assert_round_corners(n: &Node, back: Affine, r: f64) {
}
}
-const KEEP_CORNERS: Scaling = Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: true, patterns: false };
+const KEEP_CORNERS: Scaling =
+ Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: true, patterns: false, keep_fit_when_flat: false };
#[test]
fn uneven_scales_keep_live_corners_circular() {
diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs
index 9f900629d..249b46d55 100644
--- a/crates/doc/src/text.rs
+++ b/crates/doc/src/text.rs
@@ -23,10 +23,10 @@ pub use composite::{
CompositeParts,
};
pub use para_style::{
- Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, LeadingModel, Mojikumi, ParaDirection,
- ParaStyle, SingleWordJustify,
+ Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, KinsokuShoriType, LeadingModel,
+ Mojikumi, ParaDirection, ParaStyle, SingleWordJustify,
};
-pub use runs::{INLINE_CHAR, INLINE_MAX_SCALE, InlineArt, RubyAlign, RubyPosition, RubySpan, TextKind, TextRun, TextStyleDef};
+pub use runs::{INLINE_CHAR, INLINE_MAX_SCALE, InlineArt, RubyAlign, RubyPosition, RubySpan, TextKind, TextRun, TextStyleDef, TypeAntiAlias};
pub use tabs_lists::{
Bullet, DEFAULT_TAB_INTERVAL, LIST_HANGING_INDENT, LIST_LEVEL_INDENT, LIST_LEVELS, ListAlign, ListCounts, NumberStyle, ParaList, TabAlign,
TabStop,
@@ -86,6 +86,11 @@ pub struct TextObject {
/// Off for new type and for documents from before it, and saved only when on.
#[serde(default, rename = "opticalMarginAlignment", skip_serializing_if = "crate::skip::is_default")]
pub optical_margin: bool,
+ /// Character panel › Anti-aliasing Method: how the type's edges are drawn as pixels. Each
+ /// frame of a thread keeps its own method. Sharp for new type and for documents from before it,
+ /// and saved only when it is another method.
+ #[serde(default, rename = "antiAlias", skip_serializing_if = "crate::skip::is_default")]
+ pub anti_alias: TypeAntiAlias,
/// Character panel menu › System Layout, copied from the document: when on, each character
/// that takes space advances by a whole number of points. Off by default, and saved only when
/// on. The document setting is [`crate::DocSetup::system_layout`].
diff --git a/crates/doc/src/text/object.rs b/crates/doc/src/text/object.rs
index 7d226aa3e..dc83c33cc 100644
--- a/crates/doc/src/text/object.rs
+++ b/crates/doc/src/text/object.rs
@@ -1,7 +1,7 @@
use super::area::{AreaOptions, PathAlign, PathEffect};
use super::char_style::CharStyle;
use super::para_style::ParaStyle;
-use super::runs::{RubySpan, TextKind, TextRun, move_anchors, reverse_from_end};
+use super::runs::{RubySpan, TextKind, TextRun, TypeAntiAlias, move_anchors, reverse_from_end};
use super::tabs_lists::{ListCounts, ParaList};
use super::{Affine, PathData, Point, Rect, TextObject, TextStyleScale, Vec2};
@@ -90,6 +90,7 @@ impl TextObject {
path_spacing: 0.0,
wrap: Vec::new(),
optical_margin: false,
+ anti_alias: TypeAntiAlias::Sharp,
system_layout: false,
fractional_widths: true,
list_count: 0,
diff --git a/crates/doc/src/text/para_style.rs b/crates/doc/src/text/para_style.rs
index 0300e604d..f23de1d41 100644
--- a/crates/doc/src/text/para_style.rs
+++ b/crates/doc/src/text/para_style.rs
@@ -234,6 +234,9 @@ pub struct ParaStyle {
/// Kinsoku (Paragraph panel › Kinsoku Set): which Japanese characters may not start or end a
/// line.
pub kinsoku: Kinsoku,
+ /// Kinsoku Shori Type (Paragraph panel menu): how a line keeps those characters off its start
+ /// and end, by pushing them in onto the line or out to the next one. No effect without kinsoku.
+ pub kinsoku_type: KinsokuShoriType,
/// Roman Hanging Punctuation (Paragraph panel menu): quotation marks, hyphens, full stops and
/// commas starting or ending a line hang outside the margin, and asterisks, tildes, ellipses,
/// dashes, colons and semicolons hang by half. East Asian punctuation hangs by burasagari.
@@ -322,6 +325,8 @@ struct ParaStyleFile {
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
kinsoku: Kinsoku,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
+ kinsoku_type: KinsokuShoriType,
+ #[serde(default, skip_serializing_if = "crate::skip::is_default")]
roman_hanging: bool,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
kashida: Kashida,
@@ -353,6 +358,7 @@ impl From for ParaStyleFile {
leading_model,
burasagari,
kinsoku,
+ kinsoku_type,
roman_hanging,
kashida,
..
@@ -377,6 +383,7 @@ impl From for ParaStyleFile {
leading_model,
burasagari,
kinsoku,
+ kinsoku_type,
roman_hanging,
kashida,
}
@@ -405,6 +412,7 @@ impl From for ParaStyle {
leading_model,
burasagari,
kinsoku,
+ kinsoku_type,
roman_hanging,
kashida,
..
@@ -428,6 +436,7 @@ impl From for ParaStyle {
leading_model,
burasagari,
kinsoku,
+ kinsoku_type,
roman_hanging,
kashida,
}
@@ -492,6 +501,25 @@ pub enum Kinsoku {
Soft,
}
+/// How a line keeps the characters [`Kinsoku`] rules out of its start and end (Kinsoku Shori
+/// Type). Pushing in sets the characters that would start the next line (or that an opening
+/// bracket ending this line would pull with it) on this line, with the line's gaps next to CJK
+/// characters closed to make room, each by the same share of the em of the character before it
+/// and by at most a tenth of that em; pushing out moves the character before them to the next
+/// line.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum KinsokuShoriType {
+ /// Push in when the line can close up enough, else push out.
+ PushInFirst,
+ /// Push out, unless that would leave the line more than an em short (of the character before
+ /// the break): then push in when the line can close up enough.
+ PushOutFirst,
+ /// Always push out; never push in.
+ #[default]
+ PushOutOnly,
+}
+
/// Hanging punctuation (burasagari): an East Asian comma or full stop ending a line (、。,.、。)
/// stands outside the line's measure, in the space its punctuation spacing gives it (half width
/// with Line-end Punctuation Half Width). Closing brackets and Latin punctuation don't hang. Shown
diff --git a/crates/doc/src/text/runs.rs b/crates/doc/src/text/runs.rs
index a540ba210..0bf62ed15 100644
--- a/crates/doc/src/text/runs.rs
+++ b/crates/doc/src/text/runs.rs
@@ -144,6 +144,44 @@ pub(super) fn reverse_from_end(path: &mut PathData) {
}
}
+/// The anti-aliasing method of a type object (Character panel › Anti-aliasing Method): how the
+/// edges of its characters are drawn when the type is drawn as pixels (the canvas, raster exports,
+/// rasterizing). SVG, PDF and EPS export ignore it.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum TypeAntiAlias {
+ /// Hard edges: a pixel takes the type's color where the type covers at least half of it, and
+ /// nothing elsewhere.
+ None,
+ /// Each edge pixel shows the share of it the type covers.
+ #[default]
+ Sharp,
+ /// An edge pixel covered by a share `c` shows `2c - 0.5`, between 0 and 1: edges about half as
+ /// wide as Sharp's.
+ Crisp,
+ /// An edge pixel covered by a share `c` shows `2c`, at most 1: heavier type.
+ Strong,
+}
+
+impl TypeAntiAlias {
+ pub const ALL: [Self; 4] = [Self::None, Self::Sharp, Self::Crisp, Self::Strong];
+
+ /// The id used in command params and files (`none`, `sharp`, `crisp`, `strong`).
+ pub fn id(self) -> &'static str {
+ match self {
+ Self::None => "none",
+ Self::Sharp => "sharp",
+ Self::Crisp => "crisp",
+ Self::Strong => "strong",
+ }
+ }
+
+ /// The method an id names.
+ pub fn from_id(id: &str) -> Option {
+ Self::ALL.into_iter().find(|a| a.id() == id)
+ }
+}
+
/// Where ruby (furigana) text sits: above its base text in horizontal type, right of it in
/// vertical type (the Japanese convention), or below (left in vertical type).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
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 0ff1cdac0..a2959a843 100644
--- a/crates/effects/src/bake/tests.rs
+++ b/crates/effects/src/bake/tests.rs
@@ -210,6 +210,310 @@ fn baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bo
assert_eq!(baked(&fitted, distort()).1.geom, None);
}
+/// A gradient fitted to the object turns, reflects and stretches with a Transform effect that a
+/// refit would not reproduce: it is fixed to its fit on the geometry the effect takes, then mapped.
+/// Moves and even scales leave it fitted.
+#[test]
+fn a_fitted_gradient_turns_reflects_and_stretches_with_a_transform_effect() {
+ use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
+ let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
+ let fitted = |kind| Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(kind, stops.clone()))));
+ let geom = |start: (f64, f64), end: (f64, f64)| GradientGeom { start: start.into(), end: end.into(), aspect: 1.0, focal: None };
+ // The geometry of the baked fill (`own`: the Transform effect is the fill's own effect).
+ let baked = |kind, effects: &[Effect], own: bool| {
+ let mut ap = Appearance::basic(fitted(kind), Paint::None, 0.0);
+ if own {
+ *ap.items[0].effects_mut() = effects.to_vec();
+ }
+ let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), ap);
+ if !own {
+ n.appearance.effects = effects.to_vec();
+ }
+ let d = bake_document(&doc_with(n)).unwrap();
+ let mut found = vec![];
+ d.node(NodeId(50)).unwrap().walk(&mut |c: &Node| {
+ if let Paint::Gradient(g) = c.appearance.fill_paint() {
+ found.push(g.geom);
+ }
+ });
+ assert_eq!(found.len(), 1, "{effects:?}, own {own}");
+ found[0]
+ };
+ let t = |params| fx("distort.transform", params);
+ let offset = fx("path.offsetPath", json!({"offset": 10.0, "joins": "miter"}));
+ let corners = json!({"corners": [[1.0, 0.0], [2.0, 0.0], [2.0, 1.0], [1.0, 1.0]]});
+ let linear = GradientKind::Linear;
+ let cases = [
+ // A quarter turn about the center: the left to right ramp runs from the bottom up.
+ (linear, vec![t(json!({"rotate": 90.0}))], geom((50.0, 100.0), (50.0, 0.0))),
+ // Reflected left to right: the ramp runs from right to left.
+ (linear, vec![t(json!({"reflectX": true}))], geom((100.0, 50.0), (0.0, 50.0))),
+ // With Scale Strokes & Effects on, the same.
+ (linear, vec![t(json!({"rotate": 90.0, "strokes": true}))], geom((50.0, 100.0), (50.0, 0.0))),
+ // Offset Path grows the square to -10..110 first: the turn takes the fit across 120 pt.
+ (linear, vec![offset.clone(), t(json!({"rotate": 90.0}))], geom((50.0, 110.0), (50.0, -10.0))),
+ // Stretched to twice the width: the circle of radius 50 about the center becomes an
+ // ellipse 100 pt across and 50 pt tall.
+ (GradientKind::Radial, vec![t(json!({"scaleH": 200.0}))], GradientGeom { aspect: 0.5, ..geom((50.0, 50.0), (150.0, 50.0)) }),
+ // Turned, then moved one width right by Free Distort: the fixed gradient moves with it.
+ (linear, vec![t(json!({"rotate": 90.0})), fx("distort.freeDistort", corners)], geom((150.0, 100.0), (150.0, 0.0))),
+ ];
+ for (kind, effects, want) in cases {
+ for own in [false, true] {
+ let what = format!("{kind:?} {effects:?}, own {own}");
+ let got = baked(kind, &effects, own).unwrap_or_else(|| panic!("{what}: fixed to its fit"));
+ assert_geom_near(got, want, &what);
+ }
+ }
+ // A move and an even scale are what a refit gives: the gradient stays fitted.
+ for effects in [vec![t(json!({"moveH": 30.0}))], vec![t(json!({"scaleH": 150.0, "scaleV": 150.0})), offset]] {
+ for own in [false, true] {
+ assert_eq!(baked(linear, &effects, own), None, "{effects:?}, own {own}");
+ }
+ }
+ // A stroke's gradient is fixed to its fit on the box grown by half its weight (4 pt), -2..102,
+ // with Scale Strokes & Effects off or on.
+ let stroked = |effects: &[Effect], own: bool| {
+ let mut ap = Appearance::basic(Paint::None, fitted(linear), 4.0);
+ let Some(stroke) = ap.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) else { panic!("a stroke") };
+ if own {
+ *stroke.effects_mut() = effects.to_vec();
+ }
+ let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), ap);
+ if !own {
+ n.appearance.effects = effects.to_vec();
+ }
+ let d = bake_document(&doc_with(n)).unwrap();
+ let mut found = vec![];
+ d.node(NodeId(50)).unwrap().walk(&mut |c: &Node| {
+ for item in &c.appearance.items {
+ if let AppearanceItem::Stroke(s) = item
+ && let Paint::Gradient(g) = &s.paint
+ {
+ found.push(g.geom);
+ }
+ }
+ });
+ assert_eq!(found.len(), 1, "{effects:?}, own {own}");
+ found[0]
+ };
+ for strokes in [false, true] {
+ for own in [false, true] {
+ let what = format!("stroke, strokes {strokes}, own {own}");
+ let got = stroked(&[t(json!({"rotate": 90.0, "strokes": strokes}))], own).unwrap_or_else(|| panic!("{what}: fixed to its fit"));
+ assert_geom_near(got, geom((50.0, 102.0), (50.0, -2.0)), &what);
+ }
+ }
+}
+
+/// A Transform effect that scales the geometry to nothing across leaves a fitted gradient fitted:
+/// mapping it with the effect would flatten it to one color.
+#[test]
+fn a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted() {
+ use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop};
+ let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
+ let fitted = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
+ // With Scale Strokes & Effects or Transform Patterns on, and for each copy, as without them.
+ for (options, strokes) in [
+ (json!({}), 1),
+ (json!({"strokes": true}), 1),
+ (json!({"patterns": true}), 1),
+ (json!({"copies": 2}), 3),
+ (json!({"strokes": true, "copies": 2}), 3),
+ (json!({"patterns": true, "copies": 2}), 3),
+ ] {
+ let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(Paint::None, fitted.clone(), 4.0));
+ let mut p = json!({"scaleH": 0.0, "rotate": 30.0});
+ if let (Some(p), Some(o)) = (p.as_object_mut(), options.as_object()) {
+ p.extend(o.clone());
+ }
+ n.appearance.effects = vec![fx("distort.transform", p)];
+ let d = bake_document(&doc_with(n)).unwrap();
+ let mut found = vec![];
+ d.node(NodeId(50)).unwrap().walk(&mut |c: &Node| {
+ for item in &c.appearance.items {
+ if let AppearanceItem::Stroke(s) = item
+ && let Paint::Gradient(g) = &s.paint
+ {
+ found.push(g.geom);
+ }
+ }
+ });
+ assert_eq!(found, vec![None; strokes], "{options}");
+ }
+}
+
+#[test]
+fn baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds() {
+ use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
+ let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
+ let ramp = |kind| GradientPaint::new(Gradient::new(kind, stops.clone()));
+ // A ramp across the square and one down it, so both axes of the box map show.
+ let across = GradientGeom { start: Point::new(20.0, 30.0), end: Point::new(80.0, 30.0), aspect: 1.0, focal: None };
+ let down = GradientGeom { start: Point::new(60.0, 10.0), end: Point::new(60.0, 75.0), aspect: 1.0, focal: None };
+ let placed = |geom| Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp(GradientKind::Linear) }));
+ let fitted = Paint::Gradient(Box::new(ramp(GradientKind::Linear)));
+ let square = Rect::new(0.0, 0.0, 100.0, 100.0);
+ let baked = |fill: &Paint, e: Effect| {
+ let mut n = Node::path(NodeId(50), shapes::rectangle(square), Appearance::basic(fill.clone(), Paint::None, 0.0));
+ n.appearance.effects.push(e);
+ let d = bake_document(&doc_with(n)).unwrap();
+ let m = d.node(NodeId(50)).unwrap();
+ match m.appearance.fill_paint() {
+ Paint::Gradient(g) => (m.geometric_bounds().unwrap(), (*g).clone()),
+ p => panic!("a gradient: {p:?}"),
+ }
+ };
+ // Where the box map from the square to `to` takes `p`.
+ let follow = |p: Point, to: Rect| Point::new(to.x0 + p.x / 100.0 * to.width(), to.y0 + p.y / 100.0 * to.height());
+ let styles = [
+ "arc",
+ "arcLower",
+ "arcUpper",
+ "arch",
+ "bulge",
+ "shellLower",
+ "shellUpper",
+ "flag",
+ "wave",
+ "fish",
+ "rise",
+ "fisheye",
+ "inflate",
+ "squeeze",
+ "twist",
+ ];
+ // The square's warped bounds, from `warp_point` on 401 points along each side: the square's
+ // corners are (-1, -1) and (1, 1) in the warp's box coordinates, and a vertical warp swaps
+ // the axes.
+ let warped_bounds = |style: &str, p: &serde_json::Value| {
+ let Some(style) = crate::WarpStyle::from_id(style) else { panic!("warp.{style}: a style") };
+ let share = |key: &str, default: f64| p.get(key).and_then(serde_json::Value::as_f64).unwrap_or(default) / 100.0;
+ let vertical = p.get("orientation").and_then(serde_json::Value::as_str) == Some("vertical");
+ let (bend, dh, dv) = (share("bend", 50.0), share("horizontal", 0.0), share("vertical", 0.0));
+ let mut r: Option = None;
+ for k in 0..=400 {
+ let s = f64::from(k) / 200.0 - 1.0;
+ for (x, y) in [(s, -1.0), (s, 1.0), (-1.0, s), (1.0, s)] {
+ let (x2, y2) = if vertical {
+ let (a, b) = crate::warp_point(style, bend, dh, dv, 1.0, y, x);
+ (b, a)
+ } else {
+ crate::warp_point(style, bend, dh, dv, 1.0, x, y)
+ };
+ let q = Point::new(50.0 + x2 * 50.0, 50.0 + y2 * 50.0);
+ r = Some(r.map_or(Rect::from_points(q, q), |r| r.union_pt(q)));
+ }
+ }
+ r.unwrap_or(square)
+ };
+ let near =
+ |a: Rect, b: Rect, tol: f64| (a.x0 - b.x0).abs() < tol && (a.y0 - b.y0).abs() < tol && (a.x1 - b.x1).abs() < tol && (a.y1 - b.y1).abs() < tol;
+ let mut moved = 0;
+ for style in styles {
+ for params in
+ [json!({"bend": 30.0}), json!({"bend": -40.0, "orientation": "vertical"}), json!({"bend": 20.0, "horizontal": 30.0, "vertical": -20.0})]
+ {
+ // A warp that bends the square's sides inward keeps its bounds (within 0.5 pt), and the
+ // gradient stays with them.
+ let expected = warped_bounds(style, ¶ms);
+ let moves = !near(expected, square, 0.5);
+ moved += usize::from(moves);
+ for linear in [across, down] {
+ let what = format!("warp.{style} {params} {linear:?}");
+ let (b, g) = baked(&placed(linear), fx(&format!("warp.{style}"), params.clone()));
+ assert!(near(b, expected, 0.1), "{what}: bounds {b:?}, want {expected:?}");
+ let geom = g.geom.unwrap();
+ let shifted = geom.start.distance(linear.start).max(geom.end.distance(linear.end));
+ assert_eq!(shifted > 0.1, moves, "{what}: the gradient moves {shifted} with bounds {b:?}");
+ let want = GradientGeom { start: follow(linear.start, b), end: follow(linear.end, b), ..linear };
+ assert_geom_near(geom, want, &what);
+ }
+ let what = format!("warp.{style} {params}");
+ // A fitted gradient stays fitted, and fits the warped outline.
+ assert_eq!(baked(&fitted, fx(&format!("warp.{style}"), params)).1.geom, None, "{what}");
+ }
+ }
+ assert_eq!(moved, 39, "the warps that move the bounds, of 45");
+ // With no bend and no distortion the gradient stays where it is.
+ let (_, g) = baked(&placed(across), fx("warp.arc", json!({"bend": 0.0})));
+ assert_geom_near(g.geom.unwrap(), across, "no bend");
+}
+
+/// A warp with no distortion and no bend (or a bend Arc takes as none) leaves a placed gradient
+/// exactly where it was, even where warping the outline moves its bounds by a rounding error.
+#[test]
+fn a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place() {
+ use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
+ let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
+ let geom = GradientGeom { start: Point::new(13.7, 31.3), end: Point::new(91.1, 47.9), aspect: 1.0, focal: None };
+ let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient::new(GradientKind::Linear, stops)) }));
+ let mut drift = 0;
+ let cases = crate::WARP_STYLES.map(|(s, _)| (s, 0.0));
+ for (k, (style, bend)) in cases.into_iter().chain([("arc", 1e-5), ("arc", -1e-5)]).enumerate() {
+ let k = k as f64;
+ let oval = Rect::new(10.1 + k * 0.37, 20.3 - k * 1.13, 97.7 + k * 3.31, 71.9 + k * 0.71);
+ let mut n = Node::path(NodeId(50), shapes::ellipse(oval), Appearance::basic(placed.clone(), Paint::None, 0.0));
+ n.appearance.effects.push(fx(&format!("warp.{style}"), json!({ "bend": bend })));
+ let d = bake_document(&doc_with(n)).unwrap();
+ let m = d.node(NodeId(50)).unwrap();
+ drift += usize::from(m.geometric_bounds() != shapes::ellipse(oval).bounds());
+ let Paint::Gradient(g) = m.appearance.fill_paint() else { panic!("warp.{style} {bend}: a gradient") };
+ assert_eq!(g.geom, Some(geom), "warp.{style} {bend}");
+ }
+ assert!(drift > 0, "the warped outlines' bounds move by a rounding error");
+ // Another style with as small a bend still moves the outline, and the gradient follows it.
+ let mut n = Node::path(NodeId(50), shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(placed, Paint::None, 0.0));
+ n.appearance.effects.push(fx("warp.rise", json!({"bend": 1e-5})));
+ let d = bake_document(&doc_with(n)).unwrap();
+ let Paint::Gradient(g) = d.node(NodeId(50)).unwrap().appearance.fill_paint() else { panic!("warp.rise: a gradient") };
+ assert_ne!(g.geom, Some(geom), "warp.rise 1e-5");
+}
+
+/// A Warp effect on a fill or a stroke itself moves and scales its placed gradient from the
+/// path's bounds before the warp to its bounds after it (a stroke: its stroke-inflated bounds).
+#[test]
+fn a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_bounds() {
+ use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
+ let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
+ let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
+ let geom = GradientGeom { start: Point::new(20.0, 60.0), end: Point::new(80.0, 60.0), aspect: 1.0, focal: None };
+ let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }));
+ let square = Rect::new(0.0, 0.0, 100.0, 100.0);
+ let width = 4.0;
+ // Where the box map from `from` to `to` takes `p`.
+ let follow = |p: Point, from: Rect, to: Rect| {
+ Point::new(to.x0 + (p.x - from.x0) / from.width() * to.width(), to.y0 + (p.y - from.y0) / from.height() * to.height())
+ };
+ let pad = |r: Rect| r.inflate(width / 2.0, width / 2.0);
+ for style in ["arc", "rise", "inflate"] {
+ for stroke in [false, true] {
+ let what = format!("warp.{style} on the {}", if stroke { "stroke" } else { "fill" });
+ let mut ap =
+ if stroke { Appearance::basic(Paint::None, placed.clone(), width) } else { Appearance::basic(placed.clone(), Paint::None, 0.0) };
+ let Some(item) = ap.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_)) == stroke) else { panic!("{what}: the item") };
+ item.effects_mut().push(fx(&format!("warp.{style}"), json!({"bend": 40.0})));
+ let n = Node::path(NodeId(50), shapes::rectangle(square), ap);
+ let d = bake_document(&doc_with(n)).unwrap();
+ let mut found = vec![];
+ d.node(NodeId(50)).unwrap().walk(&mut |c: &Node| {
+ for item in &c.appearance.items {
+ if let Paint::Gradient(g) = item.paint() {
+ found.push((c.geometric_bounds().unwrap(), g.geom.unwrap()));
+ }
+ }
+ });
+ assert!(!found.is_empty(), "{what}: the gradient is kept");
+ for (b, g) in found {
+ assert_ne!(b, square, "{what}: the warp changes the bounds");
+ let (from, to) = if stroke { (pad(square), pad(b)) } else { (square, b) };
+ let want = GradientGeom { start: follow(geom.start, from, to), end: follow(geom.end, from, to), ..geom };
+ assert_geom_near(g, want, &what);
+ }
+ }
+ }
+}
+
#[test]
fn item_effects_split_into_one_path_per_item() {
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
@@ -232,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"]);
@@ -247,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") };
@@ -263,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, .. }));
@@ -296,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:?}");
@@ -307,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() {
@@ -322,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/catalog.rs b/crates/effects/src/catalog.rs
index 07106ffc8..a8176880b 100644
--- a/crates/effects/src/catalog.rs
+++ b/crates/effects/src/catalog.rs
@@ -1,4 +1,4 @@
-use super::{CROP_MARKS, EXTRUDE, INFLATE, PATHFINDER_EFFECTS, RASTERIZE, REVOLVE, ROTATE, TRAP, Value, json, trap};
+use super::{CLASSIC_EXTRUDE, CROP_MARKS, EXTRUDE, INFLATE, PATHFINDER_EFFECTS, RASTERIZE, REVOLVE, ROTATE, TRAP, Value, json, trap};
/// Catalogue entry for one effect.
#[derive(Clone, Debug, PartialEq)]
@@ -80,6 +80,7 @@ fn lengths_of(id: &str) -> Lengths {
"path.offsetPath" => always(&["offset"]),
"threeD.revolve" => always(&["offset"]),
"threeD.extrude" => always(&["depth", "bevelWidth", "bevelHeight"]),
+ "threeD.classicExtrude" => always(&["depth", "bevelHeight"]),
"threeD.inflate" => always(&["depth"]),
"path.outlineStroke" => always(&["width"]),
"stylize.roundCorners" | "stylize.feather" | "blur.gaussian" | "blur.smart" | "sharpen.unsharpMask" => always(&["radius"]),
@@ -111,8 +112,7 @@ fn lengths_of(id: &str) -> Lengths {
}
}
-const WARP_DOC: &str =
- "{bend: % (-100..100, 50), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\"}";
+const WARP_DOC: &str = "{bend: % (-100..100, 50; with a bend or a distortion, placed gradients move and scale from the object's bounds to its warped bounds), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\"}";
const DT: &[&str] = &["Effect", "Distort & Transform"];
@@ -200,6 +200,13 @@ pub fn effect_catalog() -> Vec {
"{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.",
json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
),
+ g(
+ CLASSIC_EXTRUDE,
+ "Extrude & Bevel (Classic)…",
+ &["Effect", "3D and Materials", "3D (Classic)"],
+ "{depth: pt (0..2000, 50), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelHeight: pt (1..100, 4; the bevel is as wide as it is tall), bevelInside: bool (false: added around the shape; true: carved into it), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: wireframe|none|diffuse|plastic (plastic; wireframe draws the edges of every face, hidden ones included, and fills none; none keeps the paint unlit; diffuse lights the faces softly; plastic adds a glossy highlight), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, every face whichever way it faces), highlightIntensity: % (0..100, 60; plastic only), highlightSize: % (0..100, 90; 100 broad, 0 a small spot; plastic only), blendSteps: integer (1..256, 25; how many levels the shading and the highlight are rounded to), shadingColor: black|custom|none (black; the color the faces darken toward; none keeps the paint and adds only the highlight), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward), drawHiddenFaces: bool (false; also draws the faces turned away, behind the others)}. The front face is the art, extruded backwards about the center of its bounds; a solid is built for the top fill and for each stroke, each in its own paint. The defaults are VectorCraft's own.",
+ json!({"depth":50.0,"cap":true,"bevel":"none","bevelHeight":4.0,"bevelInside":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"plastic","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"highlightIntensity":60.0,"highlightSize":90.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000","drawHiddenFaces":false}),
+ ),
g(
"convertToShape.rectangle",
"Rectangle…",
@@ -240,7 +247,7 @@ pub fn effect_catalog() -> Vec {
"distort.transform",
"Transform…",
DT,
- "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise), copies: 0..1000 (0; each copy transforms the last again; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}",
+ "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}",
json!({"scaleH": 100.0, "scaleV": 100.0, "moveH": 0.0, "moveV": 0.0, "rotate": 0.0, "copies": 0, "reflectX": false, "reflectY": false, "reference": 4, "random": false, "objects": true, "patterns": false, "strokes": false}),
),
g(
diff --git a/crates/effects/src/geometry.rs b/crates/effects/src/geometry.rs
index 57a10f66e..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
@@ -174,28 +178,40 @@ pub fn apply_geometry_bez_carrying(effects: &[Effect], path: &BezPath, bounds: R
(out.to_bezpath(), carry)
}
-/// How placed gradients follow geometry effects. The Transform effect without copies maps them as
-/// it maps the object (with copies, `crate::reshape` gives each copy its own). Free Distort moves
-/// and scales them from the object's bounds before the effect to its bounds after it. For the
-/// other geometry effects it is empty. It does not change gradients fitted to the object.
+/// How gradients follow geometry effects. The Transform effect without copies maps placed
+/// gradients as it maps the object (with copies, `crate::reshape` gives each copy its own). Free
+/// Distort and the Warp effects move and scale them from the object's bounds before the effect to
+/// its bounds after it. For the other geometry effects it is empty. A gradient fitted to the
+/// object fits the geometry each effect makes, until a Transform effect turns, reflects or scales
+/// it unevenly (a map after which a refit would not match the mapped gradient): it is fixed to its
+/// fit on the geometry that effect takes, and then follows that effect and the ones after it as a
+/// placed gradient does.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Carry(Vec);
+/// Does `m` fix a gradient fitted to the object to its fit before mapping it? It does when a refit
+/// would not reproduce the map (a turn, a reflection or an uneven scale) and `m` keeps area: a map
+/// that flattens the geometry to a line (a scale of 0) leaves the gradient fitted, since mapping it
+/// would flatten it to one color ([`vectorcraft_doc::node::flattens`]).
+pub(crate) fn fixes_fit(m: vectorcraft_geom::Affine) -> bool {
+ !vectorcraft_doc::node::flattens(m) && !vectorcraft_doc::node::keeps_gradient_fit(m)
+}
+
#[derive(Clone, Copy, Debug, PartialEq)]
enum CarryStep {
- Affine(vectorcraft_geom::Affine),
+ /// Map `m`; `from` is the geometric bounds of the geometry it takes, which a fitted gradient
+ /// fits before `m` moves it (none: fitted gradients stay fitted).
+ Affine { m: vectorcraft_geom::Affine, from: Option },
/// From box `from` to box `to` (strokes: their stroke-inflated boxes).
- Boxes {
- from: Rect,
- to: Rect,
- },
+ Boxes { from: Rect, to: Rect },
}
impl Carry {
/// Does effect `id` with merged params `p` on geometry with reference box `b` (`seed` for
- /// Random) move placed gradients? Free Distort does unless it leaves every corner in place;
- /// the Transform effect does without copies (with copies the original stays where it is, and
- /// `crate::reshape` gives each copy its own gradients).
+ /// Random) move placed gradients? Free Distort does unless it leaves every corner in place, a
+ /// Warp effect unless it has no bend and no distortion, and the Transform effect does without
+ /// copies (with copies the original stays where it is, and `crate::reshape` gives each copy
+ /// its own gradients).
pub(crate) fn follows(id: &str, p: &Value, b: Rect, seed: u64) -> bool {
match id {
"distort.transform" => {
@@ -209,10 +225,17 @@ impl Carry {
[(b.x0, b.y0), (b.x1, b.y0), (b.x1, b.y1), (b.x0, b.y1)].iter().zip(&corners).all(|(a, c)| c.distance((*a).into()) <= tol);
!still && corners.iter().all(|c| c.x.is_finite() && c.y.is_finite())
}
+ _ if id.starts_with("warp.") => crate::warp::bends(id, p),
_ => false,
}
}
+ /// Does effect `id` move placed gradients from each path's bounds before it to its bounds
+ /// after it, rather than by one map for the whole object?
+ pub(crate) fn by_bounds(id: &str) -> bool {
+ id == "distort.freeDistort" || id.starts_with("warp.")
+ }
+
/// How placed gradients follow effect `id` (as [`Self::follows`]) on an object whose geometric
/// bounds are `from` before the effect and `to` after it; empty when they stay in place.
pub(crate) fn of(id: &str, p: &Value, b: Rect, from: Option, to: Option, seed: u64) -> Carry {
@@ -220,7 +243,7 @@ impl Carry {
return Carry::default();
}
let step = match id {
- "distort.transform" => Some(CarryStep::Affine(distort::transform_map(b, p, seed).0)),
+ "distort.transform" => Some(CarryStep::Affine { m: distort::transform_map(b, p, seed).0, from }),
_ => match (from, to) {
(Some(from), Some(to)) if from != to && [to.x0, to.y0, to.x1, to.y1].iter().all(|v| v.is_finite()) => {
Some(CarryStep::Boxes { from, to })
@@ -240,17 +263,32 @@ impl Carry {
/// freeform gradient's own points included)? Callers check first, so an appearance shared with
/// other objects is not copied for nothing.
pub fn moves(&self, ap: &Appearance) -> bool {
- !self.0.is_empty() && ap.has_positioned_gradient()
+ !self.0.is_empty() && (ap.has_positioned_gradient() || (self.pin().is_some() && ap.has_unplaced_gradient()))
}
- /// Move the placed gradients of `ap` along, through each step in turn.
+ /// The first step that fixes gradients fitted to the object: a map a refit would not
+ /// reproduce, on geometry whose bounds are known.
+ fn pin(&self) -> Option {
+ self.0.iter().position(|s| matches!(s, CarryStep::Affine { m, from: Some(_) } if fixes_fit(*m)))
+ }
+
+ /// Move the gradients of `ap` along. Placed gradients take every step in turn; fitted ones
+ /// are fixed to their fit at the first step [`Self::pin`] finds and take it and the steps
+ /// after it.
pub fn apply(&self, ap: &mut Appearance) {
if !self.moves(ap) {
return;
}
- for s in &self.0 {
+ // The steps move placed gradients only; fitted ones become placed at the pin.
+ let pin = self.pin();
+ for (k, s) in self.0.iter().enumerate() {
+ if Some(k) == pin
+ && let CarryStep::Affine { from: Some(b), .. } = *s
+ {
+ ap.pin_gradients(b);
+ }
match *s {
- CarryStep::Affine(m) => ap.transform_gradients(m),
+ CarryStep::Affine { m, .. } => ap.transform_gradients(m),
CarryStep::Boxes { from, to } => crate::reshape::follow_boxes(ap, from, to),
}
}
@@ -340,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 a3f6859b8..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));
}
@@ -312,7 +325,8 @@ fn compound_shape_area_in(n: &Node, hook: Option, nodes: &SymbolNod
Some(po::boolean_n(&refs, |inside| {
let (mut start, mut area) = (0, false);
for (k, (op, count)) in members.iter().enumerate() {
- let here = inside.get(start..start + count).is_some_and(|flags| flags.contains(&true));
+ // `inside` is ascending: the first leaf at or after `start` tells whether one of this member's is in it.
+ let here = inside.get(inside.partition_point(|&i| i < start)).is_some_and(|&i| i < start + count);
start += count;
area = if k == 0 { here } else { op.apply(area, here) };
}
@@ -322,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)
@@ -434,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()),
@@ -454,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),
}
@@ -714,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;
}
@@ -723,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 a0cddc94d..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,12 +343,48 @@ 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");
}
+/// Under a Warp effect, the gradient placed on a symbol instance's art's own fill follows the art's
+/// bounds, and the gradient placed on a compound shape follows its area's bounds, each from before
+/// the warp to after it, in the art Pathfinder takes.
+#[test]
+fn shape_art_moves_the_gradients_of_a_symbol_instances_art_and_a_compound_shape_with_a_warp() {
+ use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
+ let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
+ let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
+ let geom = GradientGeom { start: (2.0, 3.0).into(), end: (9.0, 3.0).into(), aspect: 1.0, focal: None };
+ let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }));
+ let follow = |p: Point, from: Rect, to: Rect| {
+ Point::new(to.x0 + (p.x - from.x0) / from.width() * to.width(), to.y0 + (p.y - from.y0) / from.height() * to.height())
+ };
+ let check = |out: &Node, from: Rect, what: &str| {
+ let to = out.geometric_bounds().unwrap();
+ assert_ne!(from, to, "{what}: the warp changes the bounds");
+ let Paint::Gradient(g) = out.appearance.fill_paint() else { panic!("{what}: a gradient: {:?}", out.appearance) };
+ let got = g.geom.unwrap();
+ let (s, e) = (follow(geom.start, from, to), follow(geom.end, from, to));
+ assert!(got.start.distance(s) < 1e-6 && got.end.distance(e) < 1e-6, "{what}: {got:?}, want {s:?} {e:?}");
+ };
+ for style in ["arc", "rise", "bulge"] {
+ let warp = || Effect { id: format!("warp.{style}"), params: serde_json::json!({"bend": 40.0}), visible: true };
+ let mut art = Node::group(NodeId(20), vec![sq(21, 0.0, Color::BLACK)]);
+ art.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(placed.clone())));
+ 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(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(None, &shape, None).unwrap(), Rect::new(0.0, 0.0, 15.0, 10.0), &format!("warp.{style} on a compound shape"));
+ }
+}
+
#[test]
fn a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths() {
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
@@ -378,6 +414,118 @@ fn a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths()
assert!(near(s, Point::new(0.0, 5.0)) && near(e, Point::new(20.0, 5.0)), "{s:?} {e:?}");
}
+/// A group's Warp effect moves and scales the gradient placed on each of its paths from that path's
+/// bounds before the warp to its bounds after it, not by the group's bounds.
+#[test]
+fn a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds() {
+ use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
+ let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
+ let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
+ let geom = |x: f64| GradientGeom { start: (x + 2.0, 3.0).into(), end: (x + 8.0, 3.0).into(), aspect: 1.0, focal: None };
+ let placed = |x: f64| Paint::Gradient(Box::new(GradientPaint { geom: Some(geom(x)), ..ramp.clone() }));
+ let square = |id: u64, x: f64| {
+ Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::basic(placed(x), Paint::None, 0.0)))
+ };
+ for style in ["arc", "bulge", "rise", "twist"] {
+ let mut g = Node::group(NodeId(10), vec![square(1, 0.0), square(2, 20.0)]);
+ g.appearance.effects.push(Effect { id: format!("warp.{style}"), params: serde_json::json!({"bend": 40.0}), visible: true });
+ let art = evaluate_container(None, &g).unwrap();
+ let mut moved = false;
+ for (i, x) in [0.0, 20.0].into_iter().enumerate() {
+ let m = &art.children().unwrap()[i];
+ let b = m.geometric_bounds().unwrap();
+ let follow = |p: Point| Point::new(b.x0 + (p.x - x) / 10.0 * b.width(), b.y0 + p.y / 10.0 * b.height());
+ let Paint::Gradient(gp) = m.appearance.fill_paint() else { panic!("a gradient") };
+ let got = gp.geom.unwrap();
+ let want = geom(x);
+ assert!(
+ got.start.distance(follow(want.start)) < 1e-6 && got.end.distance(follow(want.end)) < 1e-6,
+ "warp.{style} path {i}: {got:?} in {b:?}"
+ );
+ moved |= got.start.distance(want.start) > 0.1;
+ }
+ assert!(moved, "warp.{style} moves a gradient");
+ }
+}
+
+/// Under a group's Warp effect, the group's own placed gradients follow the group's bounds, a
+/// nested group's own placed gradients follow the nested group's bounds, and a compound path's
+/// placed gradients follow the compound path's bounds, each from before the warp to after it (a
+/// stroke's: those bounds inflated by the stroke).
+#[test]
+fn a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds() {
+ use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
+ let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
+ let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
+ let geom = GradientGeom { start: (12.0, 4.0).into(), end: (26.0, 4.0).into(), aspect: 1.0, focal: None };
+ let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }));
+ let own_fill = |n: &mut Node| {
+ n.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(placed.clone())));
+ n.appearance.items.push(AppearanceItem::Stroke(vectorcraft_doc::StrokeLayer::new(placed.clone(), 3.0)));
+ };
+ let follow = |p: Point, from: Rect, to: Rect| {
+ Point::new(to.x0 + (p.x - from.x0) / from.width() * to.width(), to.y0 + (p.y - from.y0) / from.height() * to.height())
+ };
+ for style in ["arc", "bulge", "rise", "flag"] {
+ let mut inner = Node::group(NodeId(5), vec![sq(1, 0.0, Color::BLACK)]);
+ own_fill(&mut inner);
+ let ring = vec![
+ Arc::new(Node::path(NodeId(6), shapes::rectangle(Rect::new(20.0, 0.0, 30.0, 14.0)), Appearance::default())),
+ Arc::new(Node::path(NodeId(7), shapes::rectangle(Rect::new(23.0, 3.0, 27.0, 7.0)), Appearance::default())),
+ ];
+ let mut compound = Node::new(NodeId(3), NodeKind::Compound { children: ring, rule: FillRule::EvenOdd });
+ compound.appearance = Appearance::basic(placed.clone(), placed.clone(), 2.0).into();
+ let mut g = Node::group(NodeId(10), vec![Arc::new(inner), Arc::new(compound), sq(2, 40.0, Color::BLACK)]);
+ own_fill(&mut g);
+ let mut before = std::collections::HashMap::new();
+ g.walk(&mut |c: &Node| {
+ before.insert(c.id, c.geometric_bounds().unwrap());
+ });
+ g.appearance.effects.push(Effect { id: format!("warp.{style}"), params: serde_json::json!({"bend": 40.0}), visible: true });
+ let art = evaluate_container(None, &g).unwrap();
+ // The bounds after the warp of the group, the nested group and the compound path (the paths
+ // a group's own fill paints carry the group's id).
+ let mut after = std::collections::HashMap::new();
+ art.walk(&mut |c: &Node| {
+ if matches!(c.kind, NodeKind::Group { .. } | NodeKind::Compound { .. }) {
+ after.entry(c.id).or_insert_with(|| c.geometric_bounds().unwrap());
+ }
+ });
+ let mut checked = vec![];
+ art.walk(&mut |c: &Node| {
+ if ![NodeId(3), NodeId(5), NodeId(10)].contains(&c.id) {
+ return;
+ }
+ for item in &c.appearance.items {
+ let Paint::Gradient(gp) = item.paint() else { continue };
+ let (from, to) = (before[&c.id], after[&c.id]);
+ assert_ne!(from, to, "warp.{style} {:?}: the warp changes the bounds", c.id);
+ let stroke = match item {
+ AppearanceItem::Stroke(s) => Some(s),
+ AppearanceItem::Fill(_) => None,
+ };
+ let (from, to) = match stroke {
+ Some(s) => (s.paint_bounds(from), s.paint_bounds(to)),
+ None => (from, to),
+ };
+ let got = gp.geom.unwrap();
+ let (s, e) = (follow(geom.start, from, to), follow(geom.end, from, to));
+ assert!(
+ got.start.distance(s) < 1e-6 && got.end.distance(e) < 1e-6,
+ "warp.{style} {:?} (stroke: {}): {got:?}, want {s:?} {e:?} ({from:?} to {to:?})",
+ c.id,
+ stroke.is_some()
+ );
+ checked.push((c.id, stroke.is_some()));
+ }
+ });
+ checked.sort();
+ checked.dedup();
+ let want: Vec<_> = [NodeId(3), NodeId(5), NodeId(10)].into_iter().flat_map(|id| [(id, false), (id, true)]).collect();
+ assert_eq!(checked, want, "warp.{style}");
+ }
+}
+
/// A group nested in a group with a Transform effect or Free Distort keeps its own placed gradient
/// on its members: the Transform's map moves it, and Free Distort moves and scales it from the
/// nested group's bounds before the effect to its bounds after it.
@@ -450,6 +598,70 @@ fn a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills() {
}
}
+/// A group's Transform effect that turns its art turns the gradients fitted to its paths, to the
+/// group's own fill, to a nested group's own fill and to an image's and a symbol instance's own
+/// fills: each is fixed to its fit on its own object first. A move leaves the members' gradients
+/// fitted.
+#[test]
+fn a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill() {
+ use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop};
+ use vectorcraft_doc::ImageObject;
+ let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
+ let fitted = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
+ let square = |id: u64, x: f64| {
+ Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::basic(fitted.clone(), Paint::None, 0.0)))
+ };
+ let mut image = Node::new(
+ NodeId(4),
+ NodeKind::Image(Box::new(ImageObject {
+ key: "k".into(),
+ width: 10,
+ height: 10,
+ xf: vectorcraft_geom::Affine::translate((30.0, 0.0)),
+ link: None,
+ placement: Default::default(),
+ pixels: None,
+ })),
+ );
+ image.appearance = Appearance::basic(fitted.clone(), Paint::None, 0.0).into();
+ // A symbol instance whose symbol is not in a document is measured as the 20 pt square about
+ // its origin: here 22.5..27.5 across and 2.5..7.5 down.
+ let at = vectorcraft_geom::Affine::translate((25.0, 5.0)) * vectorcraft_geom::Affine::scale(0.25);
+ let mut instance = Node::new(NodeId(6), NodeKind::SymbolInstance { symbol: "s".into(), xf: at });
+ instance.appearance = Appearance::basic(fitted.clone(), Paint::None, 0.0).into();
+ // Ends of each gradient by object id; none for a gradient still fitted.
+ let ends = |params: serde_json::Value| {
+ let mut inner = Node::group(NodeId(5), vec![square(2, 10.0)]);
+ inner.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(fitted.clone())));
+ let mut g = Node::group(NodeId(10), vec![square(1, 0.0), Arc::new(inner), Arc::new(image.clone()), Arc::new(instance.clone())]);
+ g.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(fitted.clone())));
+ g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
+ let art = evaluate_container(None, &g).unwrap();
+ let mut out = std::collections::BTreeMap::new();
+ art.walk(&mut |c: &Node| {
+ if let Paint::Gradient(p) = c.appearance.fill_paint() {
+ out.insert(c.id.0, p.geom.map(|g| (g.start, g.end)));
+ }
+ });
+ out
+ };
+ let near = |a: Point, b: Point| a.distance(b) < 1e-6;
+ // A quarter turn about the group's center (20, 5): (x, 5) goes to (20, 5 + 20 - x), so a ramp
+ // from (x0, 5) to (x1, 5) runs from (20, 25 - x0) up to (20, 25 - x1).
+ let turned = ends(serde_json::json!({"rotate": 90}));
+ let want = [(1, 0.0, 10.0), (2, 10.0, 20.0), (5, 10.0, 20.0), (4, 30.0, 40.0), (6, 22.5, 27.5), (10, 0.0, 40.0)];
+ for (id, x0, x1) in want {
+ let got = turned.get(&id).copied().flatten();
+ let ok = got.is_some_and(|(s, e)| near(s, Point::new(20.0, 25.0 - x0)) && near(e, Point::new(20.0, 25.0 - x1)));
+ assert!(ok, "object {id}: {got:?} in {turned:?}");
+ }
+ // The group's own fill is fixed to its fit on the art it paints, which the move took to 30..70.
+ let moved = ends(serde_json::json!({"moveH": 30}));
+ let group = moved.get(&10).copied().flatten();
+ assert!(group.is_some_and(|(s, e)| near(s, Point::new(30.0, 5.0)) && near(e, Point::new(70.0, 5.0))), "{moved:?}");
+ assert!(moved.len() == 6 && [1, 2, 4, 5, 6].iter().all(|id| moved[id].is_none()), "{moved:?}");
+}
+
#[test]
fn a_transform_on_a_group_paints_each_copy_with_its_own_gradient() {
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
@@ -502,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();
@@ -517,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");
}
}
@@ -535,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);
}
@@ -556,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();
@@ -575,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() {
@@ -583,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,
@@ -599,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 84c83d2e7..e5b3201a4 100644
--- a/crates/effects/src/lib.rs
+++ b/crates/effects/src/lib.rs
@@ -80,13 +80,14 @@ 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::{
- EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, SolidMesh, THREE_D_TYPES, extrude_options, has_solid, inflate_options, is_three_d, solid_art,
- three_d_meshes, three_d_meshes_of, three_d_params_as, validate_solid, validate_three_d, view_options,
+ CLASSIC_EXTRUDE, EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES, classic_extrude_options,
+ classic_shading, extrude_options, has_solid, inflate_options, is_three_d, solid_art, three_d_meshes, three_d_meshes_of, three_d_params_as,
+ validate_solid, validate_three_d, validate_three_d_params, view_options,
};
pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings};
pub use svg_filter::SVG_FILTER;
@@ -106,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 7fabc0490..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,12 +701,24 @@ 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);
- // Placed gradients follow a Transform effect and Free Distort ([`crate::Carry`]): under
- // Free Distort (`boxes`) each path's move and scale from its bounds before the effect to
- // its bounds after it. Under that and the other effects, the placed gradients of type's
- // outlines in `art` (`outline_text`) move and scale with the outlines' bounds.
+ 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,
+ // the placed gradients of type's outlines in `art` (`outline_text`) move and scale with
+ // the outlines' bounds.
let follows = crate::Carry::follows(&e.id, ¶ms, bounds, seed);
- let boxes = follows && e.id == "distort.freeDistort";
+ let boxes = follows && crate::Carry::by_bounds(&e.id);
let mut outlines = vec![];
if !follows || boxes {
type_outline_bounds(art, &mut outlines);
@@ -707,7 +762,8 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
return;
}
if separate {
- let made = copy_leaf(leaf, &path, m, copies, vectorcraft_doc::Scaling::default(), room);
+ let scaling = vectorcraft_doc::Scaling { keep_fit_when_flat: true, ..Default::default() };
+ let made = copy_leaf(leaf, &path, m, copies, scaling, room);
used = used.minus(before).plus(made);
return;
}
@@ -717,8 +773,12 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
return;
};
used = used.minus(before).plus(Budget::of(&out));
- if follows && !boxes && leaf.appearance.has_positioned_gradient() {
- crate::Carry::of(&e.id, ¶ms, bounds, None, None, seed).apply(&mut leaf.appearance);
+ if follows && !boxes {
+ // A gradient fitted to the path fits the path's own bounds.
+ let carry = crate::Carry::of(&e.id, ¶ms, bounds, path.bounds(), None, seed);
+ if carry.moves(&leaf.appearance) {
+ carry.apply(&mut leaf.appearance);
+ }
}
set_geometry(leaf, out, rule);
});
@@ -734,8 +794,8 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
rebase_type_outlines(art, &mut outlines.into_iter());
}
// What paints the art as a whole, and the own fills and strokes of the groups inside it,
- // follow their bounds under Free Distort (they stay when no path changed them). Each stays
- // when a path under it that its bounds measure kept its shape (past the budget).
+ // follow their bounds under Free Distort and Warp (they stay when no path changed them).
+ // Each stays when a path under it that its bounds measure kept its shape (past the budget).
let mut after = vec![];
let held = (!groups.is_empty() || !kept_ids.is_empty()) && group_marks(art, &mut vec![], &kept_ids, &mut after).1;
if !groups.is_empty() {
@@ -773,6 +833,8 @@ fn transform_leaf(
strokes: options.strokes,
effects: options.strokes.then_some(crate::scale_effect as fn(&mut Effect, f64)),
patterns: options.patterns,
+ // A map that flattens the path leaves its fitted gradients fitted, as `fixes_fit` does.
+ keep_fit_when_flat: true,
..Default::default()
};
if !options.objects {
@@ -835,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
@@ -858,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 };
@@ -887,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;
};
@@ -915,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);
@@ -928,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() {
@@ -947,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() {
@@ -965,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);
@@ -979,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));
@@ -1051,7 +1260,7 @@ fn is_member_group(n: &Node) -> bool {
}
/// A group below the art that [`is_member_group`] takes and whose own fills or strokes hold a
-/// placed gradient: the child indices that lead to it from the art, its geometric bounds, and
+/// gradient: the child indices that lead to it from the art, its geometric bounds, and
/// whether a path below it that its bounds measure kept its shape.
type GroupMark = (Vec, Option, bool);
@@ -1068,7 +1277,7 @@ fn group_marks(n: &Node, at: &mut Vec, kept: &HashSet, out: &mut
for (i, c) in children.iter().enumerate() {
at.push(i);
let (b, h) = group_marks(c, at, kept, out);
- if is_member_group(c) && c.appearance.has_positioned_gradient() {
+ if is_member_group(c) && (c.appearance.has_positioned_gradient() || c.appearance.has_unplaced_gradient()) {
out.push((at.clone(), b, h));
}
at.pop();
@@ -1222,6 +1431,10 @@ fn copy_placed(n: &mut Node, m: Affine, copies: usize, room: &mut usize) {
let src = (**c).clone();
let placed = |xf: Affine| {
let mut one = src.clone();
+ // Gradients fitted to it are fixed to their fit first when a refit would not match.
+ if crate::geometry::fixes_fit(xf) {
+ one.pin_gradients();
+ }
match &mut one.kind {
NodeKind::Image(im) => im.xf = xf * im.xf,
NodeKind::SymbolInstance { xf: at, .. } => *at = xf * *at,
@@ -1264,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 946c6532b..fd423689f 100644
--- a/crates/effects/src/revolve.rs
+++ b/crates/effects/src/revolve.rs
@@ -66,19 +66,25 @@ pub fn revolve_options(p: &Value) -> Revolve {
/// Validate before a command mutates the document. Loading external files is still bounded
/// by the evaluator; unsupported source kinds remain unchanged when rendered.
pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> {
- crate::lighting::validate(p)?;
+ validate_revolve_params(p)?;
let path = profile(n).ok_or("Revolve currently requires a path or compound path. Select a profile drawn with the Pen tool.")?;
let b = path.bounds().ok_or("Revolve requires a profile with at least two anchors")?;
if !path.subpaths.iter().any(|sp| sp.anchors.len() >= 2) {
return Err("Revolve requires at least two anchors".into());
}
+ vectorcraft_three_d::revolve(&path, b, revolve_options(p)).map(|_| ())
+}
+
+/// Check Revolve's parameter values `p` alone: its lights, `edge` and `cap`.
+pub(crate) fn validate_revolve_params(p: &Value) -> Result<(), String> {
+ crate::lighting::validate(p)?;
if p.get("edge").is_some_and(|v| v.as_str() != Some("left") && v.as_str() != Some("right")) {
return Err("Revolve edge must be left or right".into());
}
if p.get("cap").is_some_and(|v| v.as_bool().is_none()) {
return Err("Revolve cap must be a boolean".into());
}
- vectorcraft_three_d::revolve(&path, b, revolve_options(p)).map(|_| ())
+ Ok(())
}
fn profile(n: &Node) -> Option {
@@ -476,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.rs b/crates/effects/src/solids.rs
index 6f8eac9be..3390cc71f 100644
--- a/crates/effects/src/solids.rs
+++ b/crates/effects/src/solids.rs
@@ -1,5 +1,5 @@
-//! Extrude & Bevel, Inflate and Rotate as live, shaded vector art, shared by the canvas and every
-//! exporter. Like Revolve, the source path stays editable and the effect draws its 3D faces.
+//! Extrude & Bevel, Inflate, Rotate and Extrude & Bevel (Classic) as live, shaded vector art,
+//! shared by the canvas and every exporter. Like Revolve, the source path stays editable and the effect draws its 3D faces.
use std::sync::Arc;
use serde_json::Value;
@@ -14,11 +14,14 @@ use vectorcraft_doc::color::Color;
use crate::util::{flag, num, text};
use crate::{GeomContext, apply_geometry_with, is_geometry};
+mod classic;
+pub use classic::{CLASSIC_EXTRUDE, SHADING_COLORS, classic_extrude_options, classic_shading};
+
pub const EXTRUDE: &str = "threeD.extrude";
pub const INFLATE: &str = "threeD.inflate";
pub const ROTATE: &str = "threeD.rotate";
/// The 3D effects this module draws (Revolve has its own).
-pub const SOLIDS: [&str; 3] = [EXTRUDE, INFLATE, ROTATE];
+pub const SOLIDS: [&str; 4] = [EXTRUDE, INFLATE, ROTATE, CLASSIC_EXTRUDE];
/// Is `id` one of the 3D and Materials effects (Revolve included)?
pub fn is_three_d(id: &str) -> bool {
@@ -119,6 +122,7 @@ pub fn extrude_options(p: &Value) -> Extrude {
inside: flag(p, "bevelInside", false),
both_sides: flag(p, "bevelBothSides", false),
}),
+ hidden: false,
}
}
@@ -172,21 +176,32 @@ fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) ->
let view = view_options(p);
match id {
EXTRUDE => vectorcraft_three_d::extrude(path, rule, bounds, extrude_options(p), view),
+ CLASSIC_EXTRUDE => vectorcraft_three_d::extrude(path, rule, bounds, classic_extrude_options(p), view),
INFLATE => vectorcraft_three_d::inflate(path, rule, bounds, inflate_options(p), view),
_ => vectorcraft_three_d::plane(path, rule, bounds, view),
}
}
-/// Validate before a command changes the document; the evaluator bounds loaded files too.
-pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
+/// Check solid `id`'s parameter values `p` alone: the part of [`validate_solid`] that does not
+/// need the object.
+fn validate_solid_params(id: &str, p: &Value) -> Result<(), String> {
+ if id == CLASSIC_EXTRUDE {
+ classic::validate(p)?;
+ }
crate::lighting::validate(p)?;
- let label = crate::effect_info(id).map_or("This effect", |e| e.label.trim_end_matches('…'));
if let Some(v) = p.get("bevel")
&& !v.as_str().is_some_and(|s| s == "none" || BevelShape::parse(s).is_some())
{
let names: Vec<_> = BevelShape::ALL.iter().map(|b| b.id()).collect();
return Err(format!("bevel must be none or one of {}", names.join(", ")));
}
+ Ok(())
+}
+
+/// Validate before a command changes the document; the evaluator bounds loaded files too.
+pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
+ validate_solid_params(id, p)?;
+ let label = crate::effect_info(id).map_or("This effect", |e| e.label.trim_end_matches('…'));
if is_container(n) {
let shapes = members(n, label)?;
let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?;
@@ -273,6 +288,13 @@ pub fn validate_three_d(n: &Node, id: &str, p: &Value) -> Result<(), String> {
if id == crate::REVOLVE { crate::validate_revolve(n, p) } else { validate_solid(n, id, p) }
}
+/// Are 3D effect `id`'s parameter values `p` valid? The checks of [`validate_three_d`] that do not
+/// build the art, for an edit that hides the effect: hidden, it draws nothing, so its object does
+/// not need to fit.
+pub fn validate_three_d_params(id: &str, p: &Value) -> Result<(), String> {
+ if id == crate::REVOLVE { crate::revolve::validate_revolve_params(p) } else { validate_solid_params(id, p) }
+}
+
/// One surface: each facet a path shaded from `paint`, as a group with the paint's opacity and
/// blend mode (so overlapping facets don't add up their transparency).
fn surface(n: &Node, facets: Vec, paint: &Paint, opacity: f32, blend: BlendMode, colored: bool) -> Node {
@@ -384,7 +406,8 @@ fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile]) {
fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>> {
let b = union_bounds(shapes)?;
let p = crate::merged_params(&fx.id, &fx.params);
- let colored = view_options(&p).has_colored_lights();
+ let classic = fx.id == CLASSIC_EXTRUDE;
+ let colored = !classic && view_options(&p).has_colored_lights();
if fx.id == ROTATE {
// Every part turns about the centre of the object's own bounds (a group's members about
// the centre of theirs), so they stay together.
@@ -432,7 +455,8 @@ fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>>
.map(|(item, member, f)| {
let depths: Vec = f.iter().map(|f| f.depth).collect();
let (paint, opacity, blend) = paint_of(item, member);
- (depths, surface(n, f, &paint, opacity, blend, colored))
+ let art = if classic { classic::surface(n, f, &paint, opacity, blend, &p) } else { surface(n, f, &paint, opacity, blend, colored) };
+ (depths, art)
})
.collect();
// One order takes a plain group of faces from each solid, one face for each facet. With
diff --git a/crates/effects/src/solids/classic.rs b/crates/effects/src/solids/classic.rs
new file mode 100644
index 000000000..730fb8e53
--- /dev/null
+++ b/crates/effects/src/solids/classic.rs
@@ -0,0 +1,220 @@
+//! Extrude & Bevel (Classic): the solid Extrude & Bevel builds from a path, drawn with the classic
+//! surfaces (Wireframe, No Shading, Diffuse and Plastic), their highlight, blend steps and shading
+//! color.
+use std::sync::Arc;
+
+use serde_json::Value;
+use vectorcraft_doc::color::{BlendMode, Color, Paint};
+use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node, StrokeLayer};
+use vectorcraft_geom::Rect;
+use vectorcraft_three_d::{Bevel, BevelShape, ClassicShading, ClassicSurface, Extrude, Facet, Light};
+
+use crate::util::{flag, num, text};
+
+pub const CLASSIC_EXTRUDE: &str = "threeD.classicExtrude";
+
+/// The width of a Wireframe edge, in points.
+const WIRE: f64 = 0.5;
+
+/// The Shading Color choices: black, a custom color, or none.
+pub const SHADING_COLORS: [&str; 3] = ["black", "custom", "none"];
+
+/// The surface settings of Extrude & Bevel (Classic)'s parameters.
+pub fn classic_shading(p: &Value) -> ClassicShading {
+ let d = ClassicShading::default();
+ ClassicShading {
+ surface: ClassicSurface::parse(text(p, "surface", "plastic")).unwrap_or_default(),
+ ambient: num(p, "ambient", d.ambient * 100.0) / 100.0,
+ highlight_intensity: num(p, "highlightIntensity", d.highlight_intensity * 100.0) / 100.0,
+ highlight_size: num(p, "highlightSize", d.highlight_size * 100.0) / 100.0,
+ blend_steps: num(p, "blendSteps", f64::from(d.blend_steps)).round().clamp(1.0, 256.0) as u32,
+ }
+}
+
+/// The solid of Extrude & Bevel (Classic)'s parameters: its depth, cap and bevel (as tall as it is
+/// wide, added around the shape or carved into it), with the faces turned away from the viewer kept
+/// for Draw Hidden Faces and for the Wireframe surface.
+pub fn classic_extrude_options(p: &Value) -> Extrude {
+ let height = num(p, "bevelHeight", 4.0);
+ Extrude {
+ depth: num(p, "depth", 50.0),
+ cap: flag(p, "cap", true),
+ bevel: BevelShape::parse(text(p, "bevel", "none")).map(|shape| Bevel {
+ shape,
+ width: height,
+ height,
+ inside: flag(p, "bevelInside", false),
+ ..Bevel::default()
+ }),
+ hidden: flag(p, "drawHiddenFaces", false) || classic_shading(p).surface == ClassicSurface::Wireframe,
+ ..Extrude::default()
+ }
+}
+
+/// The color faces darken toward: black, the custom color, or none (faces keep their paint and only
+/// the highlight lightens them).
+fn shading_color(p: &Value) -> Option {
+ match text(p, "shadingColor", "black") {
+ "none" => None,
+ "custom" => Some(p.get("shadingCustom").and_then(Value::as_str).and_then(parse_hex).unwrap_or(Color::BLACK)),
+ _ => Some(Color::BLACK),
+ }
+}
+
+fn parse_hex(s: &str) -> Option {
+ (s.len() == 7 && s.starts_with('#') && s.bytes().skip(1).all(|b| b.is_ascii_hexdigit())).then(|| Color::from_hex(s)).flatten()
+}
+
+/// The lights: the `lights` list when it is given, else the one light of `lightRotation`,
+/// `lightHeight`, `lightBehind` and `lightIntensity`. Each is its direction toward the light in view
+/// space and its intensity (0..1).
+fn lights(p: &Value) -> Vec<([f64; 3], f64)> {
+ if let Some(list) = crate::lighting::lights(p) {
+ return list.iter().map(|l| (l.direction(), l.intensity)).collect();
+ }
+ let light = Light {
+ rotation: num(p, "lightRotation", vectorcraft_three_d::DEFAULT_LIGHT.0),
+ height: num(p, "lightHeight", vectorcraft_three_d::DEFAULT_LIGHT.1),
+ behind: flag(p, "lightBehind", false),
+ ..Light::default()
+ };
+ vec![(light.direction(), num(p, "lightIntensity", 100.0) / 100.0)]
+}
+
+/// The numeric parameters: (key, lowest, highest, whole numbers only).
+const RANGES: [(&str, f64, f64, bool); 13] = [
+ ("depth", 0.0, 2000.0, false),
+ ("bevelHeight", 1.0, 100.0, false),
+ ("rotationX", -180.0, 180.0, false),
+ ("rotationY", -180.0, 180.0, false),
+ ("rotationZ", -180.0, 180.0, false),
+ ("perspectiveAngle", 0.0, 160.0, false),
+ ("lightRotation", -180.0, 180.0, false),
+ ("lightHeight", 0.0, 90.0, false),
+ ("lightIntensity", 0.0, 100.0, false),
+ ("ambient", 0.0, 100.0, false),
+ ("highlightIntensity", 0.0, 100.0, false),
+ ("highlightSize", 0.0, 100.0, false),
+ ("blendSteps", 1.0, 256.0, true),
+];
+
+const FLAGS: [&str; 4] = ["cap", "bevelInside", "lightBehind", "drawHiddenFaces"];
+
+/// Check Extrude & Bevel (Classic)'s parameters `p` (its defaults filled in): every key is one of
+/// its parameters, numbers are in range, flags are true or false, and `surface`, `shadingColor` and
+/// `shadingCustom` name a surface, a shading color and a #rrggbb color.
+pub fn validate(p: &Value) -> Result<(), String> {
+ let Some(map) = p.as_object() else { return Ok(()) };
+ let defaults = crate::default_params(CLASSIC_EXTRUDE).unwrap_or(Value::Null);
+ let known: Vec<&str> = defaults.as_object().map(|m| m.keys().map(String::as_str).collect()).unwrap_or_default();
+ for key in map.keys() {
+ if !known.contains(&key.as_str()) {
+ let keys = known.join(", ");
+ return Err(format!("unknown parameter `{key}`; the command takes: {keys}"));
+ }
+ }
+ for (key, min, max, whole) in RANGES {
+ let Some(v) = map.get(key) else { continue };
+ let Some(x) = v.as_f64() else { return Err(format!("`{key}` must be a number")) };
+ if whole && (x.fract() != 0.0 || !(min..=max).contains(&x)) {
+ return Err(format!("`{key}` must be a whole number between {min} and {max} (got {x})"));
+ }
+ if !(min..=max).contains(&x) {
+ return Err(format!("`{key}` must be between {min} and {max} (got {x})"));
+ }
+ }
+ for key in FLAGS {
+ if map.get(key).is_some_and(|v| !v.is_boolean()) {
+ return Err(format!("`{key}` must be true or false"));
+ }
+ }
+ let one_of = |key: &str, ids: &[&str]| -> Result<(), String> {
+ match map.get(key) {
+ Some(v) if !v.as_str().is_some_and(|s| ids.contains(&s)) => Err(format!("`{key}` must be one of {}", ids.join(", "))),
+ _ => Ok(()),
+ }
+ };
+ let surfaces: Vec<&str> = ClassicSurface::ALL.iter().map(|s| s.id()).collect();
+ one_of("surface", &surfaces)?;
+ one_of("shadingColor", &SHADING_COLORS)?;
+ let key = "shadingCustom";
+ if map.get(key).is_some_and(|v| !v.as_str().is_some_and(|s| parse_hex(s).is_some())) {
+ return Err(format!("`{key}` must be a #rrggbb color"));
+ }
+ Ok(())
+}
+
+/// `c` with tone `tone` (0 the shading color, 1 the color itself) and highlight `spec` (toward
+/// white) on top.
+fn shaded(c: Color, tone: f64, spec: f64, shade: Option) -> Color {
+ let base = c.to_rgb();
+ let dark = shade.map_or(base, |s| s.to_rgb());
+ let (tone, spec) = (tone as f32, spec as f32);
+ let [r, g, b] = [0, 1, 2].map(|i| {
+ let mixed = dark[i] + (base[i] - dark[i]) * tone;
+ (mixed + (1.0 - mixed) * spec).clamp(0.0, 1.0)
+ });
+ Color::rgb(r, g, b)
+}
+
+/// One solid's faces in paint `paint`, as a group with the paint's opacity and blend mode, one child
+/// for each facet: the edges of the faces on Wireframe, else each face filled in its paint shaded by
+/// its tone and highlight. A gradient with transparent stops is shaded opaque and then masked by
+/// its own transparency, so the shading follows the gradient's coverage.
+pub(super) fn surface(n: &Node, facets: Vec, source: &Paint, opacity: f32, blend: BlendMode, p: &Value) -> Node {
+ let shading = classic_shading(p);
+ let lights = lights(p);
+ let shade = shading_color(p);
+ let wire = shading.surface == ClassicSurface::Wireframe;
+ let (paint, mask) = if wire { (source.clone(), None) } else { crate::lighting::opaque_paint(source) };
+ let paint = &paint;
+ let children = facets
+ .into_iter()
+ .map(|face| {
+ if wire {
+ let mut edge = StrokeLayer::new(paint.clone(), WIRE);
+ edge.join = vectorcraft_doc::LineJoin::Round;
+ return Arc::new(Node::path(n.id, face.path(), Appearance { items: vec![AppearanceItem::Stroke(edge)], ..Default::default() }));
+ }
+ let (tone, spec) = shading.tone(face.normal, &lights);
+ // A narrow stroke of the face's own paint closes the hairline seams between faces.
+ let seam = (face.thickness() / 2.0).clamp(0.05, 0.75);
+ let items = match paint.color() {
+ // Unshaded (No Shading, no shading color, or a face in full light) with no
+ // highlight: the paint itself, its color space and swatch kept.
+ Some(_) if (tone >= 1.0 || shade.is_none()) && spec <= 0.0 => {
+ vec![AppearanceItem::Fill(FillLayer::new(paint.clone())), AppearanceItem::Stroke(StrokeLayer::new(paint.clone(), seam))]
+ }
+ Some(c) => {
+ let fill = Paint::solid(shaded(c, tone, spec, shade));
+ vec![AppearanceItem::Fill(FillLayer::new(fill.clone())), AppearanceItem::Stroke(StrokeLayer::new(fill, seam))]
+ }
+ // Gradients and patterns keep their paint, darkened by a layer of the shading color
+ // and lightened by one of white. As with the other 3D effects' pattern shadow, the
+ // layers cover the whole face, transparent pattern cells included.
+ None => {
+ let mut items =
+ vec![AppearanceItem::Fill(FillLayer::new(paint.clone())), AppearanceItem::Stroke(StrokeLayer::new(paint.clone(), seam))];
+ for (color, share) in [(shade, 1.0 - tone), (Some(Color::WHITE), spec)] {
+ if let Some(color) = color.filter(|_| share > 0.0) {
+ let mut layer = FillLayer::new(Paint::solid(color));
+ layer.opacity = share as f32;
+ items.push(AppearanceItem::Fill(layer));
+ }
+ }
+ items
+ }
+ };
+ Arc::new(Node::path(n.id, face.path(), Appearance { items, ..Default::default() }))
+ })
+ .collect();
+ let mut group = Node::group(n.id, children);
+ group.opacity = opacity;
+ group.blend = blend;
+ if let Some(mask) = mask {
+ let bounds = group.visual_bounds().unwrap_or(Rect::ZERO).inflate(1.0, 1.0);
+ let coverage = Node::path(n.id, vectorcraft_geom::shapes::rectangle(bounds), Appearance::basic(mask, Paint::None, 0.0));
+ group.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(coverage, true)));
+ }
+ group
+}
diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs
index a9997bb5d..29bbdb3bd 100644
--- a/crates/effects/src/solids/tests.rs
+++ b/crates/effects/src/solids/tests.rs
@@ -45,10 +45,15 @@ fn every_solid_draws_shaded_faces_and_leaves_the_source_editable() {
assert_eq!(n.path_data(), Some(&source), "{id}");
assert!(!faces(&art).is_empty(), "{id}");
// The faces are the fill's red or the stroke's black, shaded: never brighter, never another
- // hue.
+ // hue. The classic Plastic surface, its default, also lightens a face toward white, which
+ // scales every channel's distance from white by the same factor and so keeps r - g to
+ // r - b at 6 to 7.
for f in faces(&art) {
let [r, g, b] = fill_rgb(f);
- assert!(r <= 0.8 + 1e-6 && (g * 4.0 - r).abs() < 1e-4 && (b * 8.0 - r).abs() < 1e-4 || id == ROTATE, "{id}: {r} {g} {b}");
+ let shaded = r <= 0.8 + 1e-6 && (g * 4.0 - r).abs() < 1e-4 && (b * 8.0 - r).abs() < 1e-4;
+ let highlighted =
+ id == CLASSIC_EXTRUDE && [r, g, b].iter().all(|c| (0.0..=1.0).contains(c)) && ((r - g) * 7.0 - (r - b) * 6.0).abs() < 1e-4;
+ assert!(shaded || highlighted || id == ROTATE, "{id}: {r} {g} {b}");
}
// The art follows the source when it moves.
let before = art.visual_bounds().unwrap();
@@ -325,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();
@@ -351,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();
@@ -595,3 +600,160 @@ fn a_3d_effect_on_a_group_turns_every_member_about_one_shared_axis() {
assert!(err.contains("clipping groups"), "{err}");
assert!(solid_art(&g).is_none(), "drawn as the group it is");
}
+
+/// The red square without its stroke, with Extrude & Bevel (Classic) and `params`.
+fn classic(params: Value) -> Node {
+ let mut n = square_node();
+ n.appearance.items.retain(|i| matches!(i, AppearanceItem::Fill(_)));
+ n.appearance.effects.push(fx(CLASSIC_EXTRUDE, params));
+ n
+}
+
+fn classic_faces(params: Value) -> Vec {
+ let art = solid_art(&classic(params)).unwrap();
+ faces(&art).into_iter().cloned().collect()
+}
+
+fn close3(a: [f32; 3], b: [f32; 3]) -> bool {
+ a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-4)
+}
+
+#[test]
+fn classic_surfaces_draw_edges_flat_paint_soft_light_and_a_highlight() {
+ // Wireframe: the edges of every face, the hidden ones too, in the paint; no fills.
+ let wire = classic_faces(json!({"surface": "wireframe"}));
+ let plastic = classic_faces(json!({}));
+ assert_eq!(plastic.len(), 3, "a box shows three faces");
+ assert_eq!(wire.len(), 6, "and has six");
+ for f in &wire {
+ let [AppearanceItem::Stroke(s)] = &f.appearance.items[..] else { panic!("a stroke alone: {:?}", f.appearance.items) };
+ assert_eq!(s.paint, Paint::solid(RED));
+ }
+ // No Shading: every face in the art's own paint, a CMYK color kept as it is.
+ assert!(classic_faces(json!({"surface": "none"})).iter().all(|f| close3(fill_rgb(f), RED.to_rgb())));
+ let cmyk = Paint::solid(Color::Cmyk { c: 0.1, m: 0.9, y: 0.8, k: 0.0 });
+ let mut n = classic(json!({"surface": "none"}));
+ if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() {
+ f.paint = cmyk.clone();
+ }
+ for f in faces(&solid_art(&n).unwrap()) {
+ let AppearanceItem::Fill(fill) = &f.appearance.items[0] else { panic!("a fill first") };
+ assert_eq!(fill.paint, cmyk);
+ }
+ // Diffuse: the red darkened toward black by how far each face turns from the light, so the
+ // faces differ but keep their hue.
+ let diffuse: Vec<[f32; 3]> = classic_faces(json!({"surface": "diffuse"})).iter().map(fill_rgb).collect();
+ assert!(diffuse.iter().all(|[r, g, b]| *r <= 0.8 + 1e-6 && (g * 4.0 - r).abs() < 1e-4 && (b * 8.0 - r).abs() < 1e-4), "{diffuse:?}");
+ assert!(diffuse.windows(2).any(|w| !close3(w[0], w[1])), "{diffuse:?}");
+ // Plastic: the same faces, one of them lightened toward white by the highlight.
+ let shiny: Vec<[f32; 3]> =
+ classic_faces(json!({"highlightIntensity": 100, "highlightSize": 100, "lightHeight": 60})).iter().map(fill_rgb).collect();
+ let soft: Vec<[f32; 3]> = classic_faces(json!({"surface": "diffuse", "lightHeight": 60})).iter().map(fill_rgb).collect();
+ assert!(shiny.iter().zip(&soft).any(|(s, d)| s[1] > d[1] + 0.01), "{shiny:?} {soft:?}");
+ assert!(shiny.iter().zip(&soft).all(|(s, d)| s[1] >= d[1] - 1e-4));
+ // Without highlight, Plastic shades as Diffuse.
+ let dull: Vec<[f32; 3]> = classic_faces(json!({"highlightIntensity": 0, "lightHeight": 60})).iter().map(fill_rgb).collect();
+ assert!(dull.iter().zip(&soft).all(|(a, b)| close3(*a, *b)));
+}
+
+#[test]
+fn classic_shading_color_blend_steps_and_lights() {
+ let base = json!({"surface": "diffuse", "ambient": 20});
+ let with = |extra: Value| {
+ let mut p = base.clone();
+ p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
+ classic_faces(p).iter().map(fill_rgb).collect::>()
+ };
+ // A custom shading color darkens toward that color; None keeps the paint.
+ assert!(with(json!({"shadingColor": "custom", "shadingCustom": "#0000ff"})).iter().any(|c| c[2] > c[0] / 8.0 + 0.01));
+ assert!(with(json!({"shadingColor": "none"})).iter().all(|c| close3(*c, RED.to_rgb())));
+ // One blend step: every face fully lit or fully in shadow.
+ let stepped = with(json!({"blendSteps": 1, "ambient": 0}));
+ assert!(stepped.iter().all(|c| close3(*c, RED.to_rgb()) || close3(*c, [0.0; 3])), "{stepped:?}");
+ // No light: the ambient share of the red. Two lights add up.
+ let dark = with(json!({"lightIntensity": 0, "blendSteps": 256}));
+ assert!(dark.iter().all(|c| (c[0] - 0.8 * 0.2).abs() < 0.01), "{dark:?}");
+ let one = with(json!({"lights": [{"intensity": 30}], "blendSteps": 256}));
+ let two = with(json!({"lights": [{"intensity": 30}, {"intensity": 30}], "blendSteps": 256}));
+ assert!(one.iter().zip(&two).all(|(a, b)| b[0] >= a[0] - 1e-4) && one.iter().zip(&two).any(|(a, b)| b[0] > a[0] + 0.01));
+ // Draw Hidden Faces keeps the faces turned away, drawn first.
+ assert_eq!(with(json!({"drawHiddenFaces": true})).len(), 6);
+}
+
+#[test]
+fn classic_bevel_height_extent_and_cap_shape_the_solid() {
+ let bounds = |p: Value| solid_art(&classic(p)).unwrap().visual_bounds().unwrap();
+ let front = json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "bevel": "classic", "bevelHeight": 10});
+ let out = bounds(front.clone());
+ let mut inside = front.clone();
+ inside["bevelInside"] = json!(true);
+ // Straight on, a bevel added around the shape widens it; one carved into it keeps its size.
+ assert!(out.width() > 100.0 + 15.0, "{out:?}");
+ let carved = bounds(inside);
+ assert!((carved.width() - 100.0).abs() < 2.0, "{carved:?}");
+ // Hollow: no caps, so the walls seen from inside show too.
+ assert!(classic_faces(json!({"cap": false})).len() > classic_faces(json!({})).len());
+ // The depth sets how far back the solid goes.
+ let side = json!({"rotationX": 0, "rotationY": 90, "rotationZ": 0});
+ let mut deep = side.clone();
+ deep["depth"] = json!(120);
+ assert!((bounds(deep).width() - 120.0).abs() < 1.0);
+}
+
+/// A gradient fill with a transparent stop is shaded opaque and masked by its own transparency, so
+/// the shading does not cover the transparent part; an opaque gradient needs no mask.
+#[test]
+fn classic_shading_keeps_a_gradients_transparency() {
+ use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
+ for (end, masked) in [(0.25, true), (1.0, false)] {
+ let mut stop = GradientStop::new(1.0, RED);
+ stop.opacity = end;
+ let gradient = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, RED), stop]));
+ let mut n = classic(json!({}));
+ if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() {
+ f.paint = Paint::Gradient(Box::new(gradient));
+ }
+ let art = solid_art(&n).unwrap();
+ let surface = &art.children().unwrap()[0];
+ assert_eq!(surface.extras().mask.is_some(), masked, "{end}");
+ let mut opaque = true;
+ surface.walk(&mut |c| {
+ for item in &c.appearance.items {
+ if let AppearanceItem::Fill(f) = item
+ && let Paint::Gradient(g) = &f.paint
+ {
+ opaque &= g.gradient.stops.iter().all(|s| s.opacity == 1.0);
+ }
+ }
+ });
+ assert!(opaque, "{end}: the faces are drawn opaque");
+ }
+}
+
+#[test]
+fn classic_parameters_are_checked() {
+ let n = classic(json!({}));
+ let check = |p: Value| validate_solid(&n, CLASSIC_EXTRUDE, &crate::merged_params(CLASSIC_EXTRUDE, &p));
+ assert!(check(json!({})).is_ok());
+ assert!(check(json!({"surface": "wireframe", "shadingColor": "custom", "shadingCustom": "#12aBcd", "blendSteps": 256, "bevel": "ogee"})).is_ok());
+ for (p, says) in [
+ (json!({"surface": "glossy"}), "`surface` must be one of wireframe, none, diffuse, plastic"),
+ (json!({"shadingColor": "red"}), "`shadingColor` must be one of black, custom, none"),
+ (json!({"shadingCustom": "blue"}), "`shadingCustom` must be a #rrggbb color"),
+ (json!({"shadingCustom": "#fff"}), "`shadingCustom` must be a #rrggbb color"),
+ (json!({"depth": 2001}), "`depth` must be between 0 and 2000 (got 2001)"),
+ (json!({"bevelHeight": 0}), "`bevelHeight` must be between 1 and 100 (got 0)"),
+ (json!({"ambient": -1}), "`ambient` must be between 0 and 100 (got -1)"),
+ (json!({"blendSteps": 2.5}), "`blendSteps` must be a whole number between 1 and 256 (got 2.5)"),
+ (json!({"blendSteps": 0}), "`blendSteps` must be a whole number between 1 and 256 (got 0)"),
+ (json!({"highlightSize": "big"}), "`highlightSize` must be a number"),
+ (json!({"cap": "yes"}), "`cap` must be true or false"),
+ (json!({"drawHiddenFaces": 1}), "`drawHiddenFaces` must be true or false"),
+ (json!({"bevel": "fancy"}), "bevel must be none or one of"),
+ (json!({"twist": 10}), "unknown parameter `twist`"),
+ (json!({"lights": []}), "lights must contain one to ten lights"),
+ ] {
+ let err = check(p.clone()).unwrap_err();
+ assert!(err.starts_with(says), "{p}: {err}");
+ }
+}
diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs
index 1afa679aa..532251bd7 100644
--- a/crates/effects/src/tests.rs
+++ b/crates/effects/src/tests.rs
@@ -180,6 +180,24 @@ fn warp_zero_bend_is_identity_ish() {
}
}
+/// `warp::bends` says a warp moves placed gradients exactly when the warp moves the outline:
+/// Arc leaves the outline as it is under a bend of 0.0001 % and moves it above that, and the other
+/// styles move it at any bend.
+#[test]
+fn warp_bends_when_the_warp_moves_the_outline() {
+ // A star: Fisheye moves nothing on the edge of the box, where an ellipse or a rectangle lies.
+ let oval = shapes::star(Point::new(51.7, 34.3), 40.9, 17.3, 5, 7.0);
+ for (s, _) in WARP_STYLES {
+ let id = format!("warp.{s}");
+ let flat = run(&id, json!({"bend": 0}), &oval);
+ for bend in [0.0, 1e-5, -1e-5, 0.9e-4, -0.9e-4, 1.1e-4, -1.1e-4] {
+ let p = json!({ "bend": bend });
+ let moved = run(&id, p.clone(), &oval) != flat;
+ assert_eq!(crate::warp::bends(&id, &p), moved, "{id} at {bend} %");
+ }
+ }
+}
+
#[test]
fn warp_arc_bends_the_top_edge() {
let out = run("warp.arc", json!({"bend": 50}), &square());
@@ -394,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() {
@@ -433,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");
@@ -447,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") };
@@ -491,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();
@@ -500,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);
}
}
@@ -531,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);
@@ -607,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));
@@ -634,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");
@@ -642,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");
@@ -650,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/effects/src/warp.rs b/crates/effects/src/warp.rs
index fb0279ede..84f196a8b 100644
--- a/crates/effects/src/warp.rs
+++ b/crates/effects/src/warp.rs
@@ -32,3 +32,14 @@ pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) -
let piece = b.width().hypot(b.height()) / 24.0;
map_nonlinear_within(path, piece, max, f)
}
+
+/// Does warp effect `id` with params `p` move any point? Every style leaves the box as it is with
+/// no bend and no distortion, and Arc (`warp.arc`) takes a bend under 0.0001 % as none: the
+/// threshold must match the one in [`warp_point`]'s Arc case (`warp_bends_when_the_warp_moves_the_outline`
+/// tests both sides of it).
+pub(crate) fn bends(id: &str, p: &Value) -> bool {
+ let share = |key: &str, default: f64| num(p, key, default).clamp(-100.0, 100.0) / 100.0;
+ let bend = share("bend", 50.0);
+ let bent = if id == "warp.arc" { bend.abs() >= 1e-6 } else { bend != 0.0 };
+ bent || share("horizontal", 0.0) != 0.0 || share("vertical", 0.0) != 0.0
+}
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.rs b/crates/engine/src/cmd.rs
index 75835394a..6ef4918e1 100644
--- a/crates/engine/src/cmd.rs
+++ b/crates/engine/src/cmd.rs
@@ -356,8 +356,16 @@ pub fn command_specs() -> &'static [CommandSpec] {
})
}
+/// The command `id`: a built-in one, or a command an installed plug-in adds
+/// ([`plugin::command_specs`]).
pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
- command_specs().iter().find(|c| c.id == id)
+ command_specs().iter().find(|c| c.id == id).or_else(|| plugin::find_spec(id))
+}
+
+/// Runs command `spec`'s implementation with `p`. A plug-in command's runs the plug-in
+/// ([`plugin::run_command`]), which needs the command's id.
+pub(crate) fn run_spec(s: &mut Session, spec: &CommandSpec, p: &Value) -> Result {
+ if plugin::is_command_id(spec.id) { plugin::run_command(s, spec.id, p) } else { (spec.run)(s, p) }
}
// ---------- param helpers ----------
diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs
index 02990a662..be452f6c6 100644
--- a/crates/engine/src/cmd/appearance.rs
+++ b/crates/engine/src/cmd/appearance.rs
@@ -45,7 +45,7 @@ pub fn specs() -> Vec {
"Add New Fill",
["Window", "Appearance"],
Some("Cmd+/"),
- "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
+ "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
has_doc,
|s, p| add_item(s, p, true)
),
@@ -54,7 +54,7 @@ pub fn specs() -> Vec {
"Add New Stroke",
["Window", "Appearance"],
Some("Cmd+Alt+/"),
- "{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
+ "{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
has_doc,
|s, p| add_item(s, p, false)
),
@@ -63,7 +63,7 @@ pub fn specs() -> Vec {
"Clear Appearance",
["Window", "Appearance"],
None,
- "{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
+ "{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md)",
has_doc,
clear_appearance
),
@@ -72,7 +72,7 @@ pub fn specs() -> Vec {
"Reduce to Basic Appearance",
["Window", "Appearance"],
None,
- "{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
+ "{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
has_doc,
reduce_basic
),
@@ -81,7 +81,7 @@ pub fn specs() -> Vec {
"Appearance Item",
[],
None,
- "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
+ "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
has_doc,
set_item
),
@@ -90,7 +90,7 @@ pub fn specs() -> Vec {
"Remove Item",
[],
None,
- "{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
+ "{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
has_doc,
remove_item
),
@@ -108,7 +108,7 @@ pub fn specs() -> Vec {
"Duplicate Item",
["Window", "Appearance"],
None,
- "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
+ "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
has_doc,
duplicate_item
),
@@ -117,7 +117,7 @@ pub fn specs() -> Vec {
"Reorder Appearance Item",
[],
None,
- "{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {index: where the item landed | contents: the row's slot}",
+ "{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {index: where the item landed | contents: the row's slot}",
has_doc,
move_item
),
@@ -126,7 +126,7 @@ pub fn specs() -> Vec {
"Eyedropper",
[],
None,
- "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's solid fill and stroke for this instance only, as the Eyedropper Options allow, and a sampled gradient or pattern, a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}",
+ "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's solid fill and stroke for this instance only, as the Eyedropper Options allow, and a sampled gradient or pattern, a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {ids}",
has_doc,
copy_from
),
@@ -144,7 +144,7 @@ pub fn specs() -> Vec {
"Show All Hidden Attributes",
["Window", "Appearance"],
None,
- "{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}",
+ "{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}",
has_doc,
show_all_hidden
),
@@ -162,7 +162,7 @@ pub fn specs() -> Vec {
"Move Appearance",
[],
None,
- "{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {source, target}",
+ "{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {source, target}",
has_doc,
transfer
),
@@ -628,25 +628,33 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result {
}
let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids));
let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids));
- Ok(count_changed(d, &before, &ids) + usize::from(kept || painted))
+ let object = super::graph::capture_series_object_opacity(d, &ids);
+ Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || object))
})?;
s.active_appearance_item = None;
ok()
}
+/// Each of `ids` whose object opacity or blend mode is another in `d` than in `before`, for
+/// [`super::graph::capture_series_object_opacity`].
+fn object_opacity_changed(before: &vectorcraft_doc::Document, d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec {
+ let of = |d: &vectorcraft_doc::Document, id: NodeId| d.node(id).map(|n| (n.opacity, n.blend));
+ ids.iter().copied().filter(|id| of(before, *id) != of(d, *id)).collect()
+}
+
/// Each of `ids` with both kinds of row (fill, then stroke), for [`super::graph::capture_series_opacity`] after an
/// edit that replaces whole stacks.
fn both_kinds(ids: &[NodeId]) -> Vec<(NodeId, bool)> {
ids.iter().flat_map(|id| [(*id, true), (*id, false)]).collect()
}
-/// Each of `ids` with each kind of row (fill `true`, stroke `false`) whose topmost visible row has another opacity in `d`
-/// than in `before` (no row counts as opaque), for [`super::graph::capture_series_opacity`].
+/// Each of `ids` with each kind of row (fill `true`, stroke `false`) whose topmost visible row has another opacity or blend
+/// mode in `d` than in `before` (no row counts as opaque and Normal), for [`super::graph::capture_series_opacity`].
fn top_opacity_changed(before: &vectorcraft_doc::Document, d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec<(NodeId, bool)> {
let top = |d: &vectorcraft_doc::Document, id: NodeId, fill: bool| {
d.node(id)
- .and_then(|n| n.appearance.items.iter().rev().find(|it| it.is_fill() == fill && it.visible()).map(AppearanceItem::opacity))
- .unwrap_or(1.0)
+ .and_then(|n| n.appearance.items.iter().rev().find(|it| it.is_fill() == fill && it.visible()).map(|it| (it.opacity(), it.blend())))
+ .unwrap_or((1.0, BlendMode::Normal))
};
ids.iter().flat_map(|id| [(*id, true), (*id, false)]).filter(|(id, fill)| top(before, *id, *fill) != top(d, *id, *fill)).collect()
}
@@ -666,9 +674,10 @@ fn painted_rows(d: &vectorcraft_doc::Document, rows: impl IntoIterator
- bool {
let top_shows = |d: &vectorcraft_doc::Document, id: NodeId, kind: bool| {
let items = d.node(id).map_or(&[][..], |n| &n.appearance.items[..]);
items.iter().rev().find(|it| it.is_fill() == kind).is_some_and(AppearanceItem::visible)
};
- let written = |d: &vectorcraft_doc::Document, (fill, stroke): (bool, bool)| -> Vec<(NodeId, bool)> {
+ let kinds = |(fill, stroke): (bool, bool)| -> Vec<(NodeId, bool)> {
let rows = ids.iter().flat_map(|id| [fill.then_some((*id, true)), stroke.then_some((*id, false))]).flatten();
- painted_rows(d, rows.filter(|(id, kind)| !top_only || top_shows(d, *id, *kind)))
+ rows.filter(|(id, kind)| !top_only || top_shows(d, *id, *kind)).collect()
};
+ let written = |d: &vectorcraft_doc::Document, rows: (bool, bool)| painted_rows(d, kinds(rows));
let mut opacity_rows = top_opacity_changed(before, d, ids);
+ // A written row that had paint before the edit counts too when the edit left it without paint: only a row without
+ // paint both before and after keeps what the graph stores.
+ opacity_rows.extend(painted_rows(before, kinds(opacities)));
opacity_rows.extend(written(d, opacities));
let mut rows = top_paint_changed(before, d, ids);
rows.extend(written(d, paints));
let kept = super::graph::capture_series_opacity(d, &opacity_rows);
let painted = super::graph::capture_series_top_paints(d, &rows);
- kept || painted
+ let objects = if object { ids.to_vec() } else { object_opacity_changed(before, d, ids) };
+ let stored = super::graph::capture_series_object_opacity(d, &objects);
+ kept || painted || stored
}
/// The kind of the row at `at` (fill `true`, stroke `false`) when it is the topmost visible row of that kind in `ap`.
@@ -743,7 +759,8 @@ fn transfer(s: &mut Session, p: &Value) -> Result {
let mut rows = top_paint_changed(&before, d, &changed);
rows.extend(painted_rows(d, both_kinds(&[target])));
let painted = super::graph::capture_series_top_paints(d, &rows);
- Ok(count_changed(d, &before, &[source, target]) + usize::from(kept || painted))
+ let object = super::graph::capture_series_object_opacity(d, &changed);
+ Ok(count_changed(d, &before, &[source, target]) + usize::from(kept || painted || object))
})?;
s.active_appearance_item = None;
Ok(json!({ "source": source.0, "target": target.0 }))
@@ -862,10 +879,10 @@ fn set_item(s: &mut Session, p: &Value) -> Result {
edited.push((*id, fill));
}
}
- // The graph keeps an opacity, paint or weight set on the topmost visible fill or stroke of its series art for when
- // it is drawn again, and hiding or showing a row changes which row that is.
+ // The graph keeps an opacity, blend mode, paint or weight set on the topmost visible fill or stroke of its series art
+ // for when it is drawn again, and hiding or showing a row changes which row that is.
let mut opacities = top_opacity_changed(&before, d, &ids);
- if p.get("opacity").and_then(Value::as_f64).is_some() {
+ if p.get("opacity").and_then(Value::as_f64).is_some() || blend.is_some() {
opacities.extend(edited.iter().copied());
}
let kept = super::graph::capture_series_opacity(d, &opacities);
@@ -1347,7 +1364,10 @@ fn copy_from(s: &mut Session, p: &Value) -> Result {
rows.extend(painted_rows(d, written(paints)));
let kept = super::graph::capture_series_opacity(d, &opacity_rows);
let painted = super::graph::capture_series_top_paints(d, &rows);
- Ok(count_changed(d, &before, &targets) + usize::from(kept || painted))
+ // A pick of the object's transparency writes it on every target.
+ let objects = if attrs.appearance.transparency { targets.clone() } else { object_opacity_changed(&before, d, &targets) };
+ let object = super::graph::capture_series_object_opacity(d, &objects);
+ Ok(count_changed(d, &before, &targets) + usize::from(kept || painted || object))
})?;
Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::>() }))
}
diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs
index 2aa759411..d39244924 100644
--- a/crates/engine/src/cmd/buildcmds.rs
+++ b/crates/engine/src/cmd/buildcmds.rs
@@ -144,7 +144,7 @@ pub fn specs() -> Vec {
"Make",
["Object", "Image Trace"],
None,
- "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), ignoreBackground: false (with ignoreWhite: only the areas of the ignored color that reach the image's edge are left out), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at)}",
+ "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), ignoreBackground: false (with ignoreWhite: only the areas of the ignored color that reach the image's edge are left out), keepColors?: [\"#rrggbb\"…] (not mode logo: only the palette colors within about 30 levels of a listed color are traced; empty: every color), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at; or a keepColors color matches none the trace found, or nothing was traced)}",
has_selection_or_ids,
|s, p| trace_make(s, p, false)
),
@@ -153,7 +153,7 @@ pub fn specs() -> Vec {
"Make and Expand",
["Object", "Image Trace"],
None,
- "{…as imageTrace.make, byColor?: false (each color's paths in a group of their own, as imageTrace.expand)} same as imageTrace.make, then expand → {id, paths, anchors, colors}",
+ "{…as imageTrace.make, byColor?: false (each color's paths in a group of their own, as imageTrace.expand)} same as imageTrace.make, then expand → {id, paths, anchors, colors, warnings: [text…] (as imageTrace.make)}",
has_selection_or_ids,
|s, p| trace_make(s, p, true)
),
diff --git a/crates/engine/src/cmd/buildcmds/trace.rs b/crates/engine/src/cmd/buildcmds/trace.rs
index 1924ed405..9c6c28b90 100644
--- a/crates/engine/src/cmd/buildcmds/trace.rs
+++ b/crates/engine/src/cmd/buildcmds/trace.rs
@@ -712,7 +712,8 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result trace_now(&inputs, &trace, &bytes, &|_, _| true)?,
};
let TraceInputs { target, image: img, .. } = inputs;
- let warnings = trace_warnings((img.width, img.height), raster, res.worked_at);
+ let mut warnings = keep_warnings(&trace, &res);
+ warnings.extend(trace_warnings((img.width, img.height), raster, res.worked_at));
// Pixel space of the decoded raster → the image object's pixel space → document.
let sx = img.width.max(1) as f64 / raster.0.max(1) as f64;
let sy = img.height.max(1) as f64 / raster.1.max(1) as f64;
@@ -824,6 +825,30 @@ pub(crate) fn trace_warnings(size: (u32, u32), traced: (u32, u32), worked: Optio
out
}
+/// What a trace with Keep Colors tells the user: that nothing was traced because no color the
+/// trace found matches a listed one, because Ignore Color left out the ones that match, or because
+/// the ones that match produce no paths; each listed color that matches none of them; and that Flat
+/// Logo mode does not use the list.
+pub(crate) fn keep_warnings(params: &tr::TraceParams, res: &tr::TraceResult) -> Vec {
+ if params.keep_colors.is_empty() {
+ return vec![];
+ }
+ if params.mode == tr::Mode::Logo {
+ return vec!["Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds.".to_string()];
+ }
+ if res.paths.is_empty() && res.keep_unmatched.len() == params.keep_colors.len() {
+ return vec![format!("Nothing was traced: none of the colors the trace found matches keepColors ({}).", params.keep_colors.join(", "))];
+ }
+ let mut out = vec![];
+ if res.paths.is_empty() && res.keep_ignored {
+ out.push("Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists.".to_string());
+ } else if res.paths.is_empty() {
+ out.push("Nothing was traced: the colors that match keepColors produce no paths.".to_string());
+ }
+ out.extend(res.keep_unmatched.iter().map(|c| format!("keepColors: none of the colors the trace found matches {c}, so nothing of it is traced.")));
+ out
+}
+
fn trace_targets(s: &Session, p: &Value) -> Result> {
let d = &s.doc()?.doc;
let v: Vec = root_ids(s, p)?.into_iter().filter(|i| d.node(*i).is_some_and(is_trace_group)).collect();
@@ -1069,7 +1094,25 @@ mod color_group_tests {
#[cfg(test)]
mod trace_limit_tests {
- use super::{MAX_TRACE_ANCHORS, trace_warnings};
+ use super::{MAX_TRACE_ANCHORS, keep_warnings, trace_warnings};
+ use vectorcraft_trace as tr;
+
+ #[test]
+ fn keep_warnings_name_matched_colors_that_produce_no_paths() {
+ let params = tr::TraceParams { keep_colors: vec!["#ff0000".into()], ..tr::TraceParams::default() };
+ // A listed color was traced, and produced no paths: the palette lists no color without paths.
+ let traced = tr::TraceResult::default();
+ let none = vec!["Nothing was traced: the colors that match keepColors produce no paths.".to_string()];
+ assert_eq!(keep_warnings(¶ms, &traced), none);
+ // With Ignore Color, too: Ignore Color did not leave that color out.
+ let ignoring = tr::TraceParams { ignore_white: true, ..params.clone() };
+ assert_eq!(keep_warnings(&ignoring, &traced), none);
+ // Ignore Color left out every color that matches: none was traced.
+ assert_eq!(
+ keep_warnings(&ignoring, &tr::TraceResult { keep_ignored: true, ..tr::TraceResult::default() }),
+ vec!["Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists.".to_string()]
+ );
+ }
#[test]
fn trace_warnings_name_a_large_or_scaled_down_image() {
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/compositefonts.rs b/crates/engine/src/cmd/compositefonts.rs
index 2b356ab85..3925612a1 100644
--- a/crates/engine/src/cmd/compositefonts.rs
+++ b/crates/engine/src/cmd/compositefonts.rs
@@ -71,24 +71,39 @@ pub fn specs() -> Vec {
/// other open documents (or an earlier active document) defined. Run after each command and when
/// another document takes the front, so an undo, a revert, an opened or closed file or a document
/// switch draws type in the active document's own composite fonts, and type naming one it does
-/// not define shows as missing. Whether a composite font was added, changed or removed.
-pub(crate) fn sync(s: &mut Session) -> bool {
+/// not define shows as missing. The lowercased names of the composite fonts added, changed or
+/// removed.
+pub(crate) fn sync(s: &mut Session) -> Vec {
let db = FontDb::global();
let mut names = Vec::new();
- let mut changed = false;
+ let mut changed = Vec::new();
for f in s.active().map(|d| d.doc.composite_fonts.to_vec()).unwrap_or_default() {
- changed |= db.set_composite_font(&f);
- names.push(f.name.to_lowercase());
+ let name = f.name.to_lowercase();
+ if db.set_composite_font(&f) {
+ changed.push(name.clone());
+ }
+ names.push(name);
}
let others = s.docs.iter().flat_map(|d| d.doc.composite_fonts.iter().map(|f| f.name.to_lowercase()));
let stale: Vec = s.composite_names.iter().cloned().chain(others).filter(|n| !names.contains(n)).collect();
for gone in stale {
- changed |= db.remove_composite_font(&gone);
+ if db.remove_composite_font(&gone) {
+ changed.push(gone);
+ }
}
+ s.composite_names_registered.extend(names.iter().chain(&changed).cloned());
s.composite_names = names;
changed
}
+/// The lowercased names of the composite fonts the open documents define, the font database holds
+/// and the session has registered before: the names type may have been laid out in.
+pub(crate) fn names_in_use(s: &Session) -> std::collections::HashSet {
+ let defined = s.docs.iter().flat_map(|d| d.doc.composite_fonts.iter().map(|f| f.name.to_lowercase()));
+ let held = FontDb::global().composite_font_names().into_iter().map(|n| n.to_lowercase());
+ defined.chain(held).chain(s.composite_names_registered.iter().cloned()).collect()
+}
+
/// Leave out a file's unusable composite fonts before registering and measuring its type.
pub(crate) fn filter_loaded(doc: &mut vectorcraft_doc::Document) {
let db = FontDb::global();
@@ -172,6 +187,9 @@ fn replace(s: &mut Session, label: &str, after: Vec) -> Result<()
}
let changed: Vec =
before.iter().filter(|b| !after.contains(b)).chain(after.iter().filter(|a| !before.contains(a))).map(|f| f.name.to_lowercase()).collect();
+ // A refused or panicked edit leaves the document without the new fonts: `sync` forgets a name
+ // listed here that the document doesn't define.
+ s.composite_names.extend(after.iter().map(|f| f.name.to_lowercase()));
let db = FontDb::global();
for f in &before {
if !after.iter().any(|a| CompositeFont::same_name(&a.name, &f.name)) {
diff --git a/crates/engine/src/cmd/compositefonts/tests.rs b/crates/engine/src/cmd/compositefonts/tests.rs
index 670a85ee2..f51f066df 100644
--- a/crates/engine/src/cmd/compositefonts/tests.rs
+++ b/crates/engine/src/cmd/compositefonts/tests.rs
@@ -395,6 +395,150 @@ fn opened_document_measures_missing_composite_font_as_missing() {
assert_eq!(actual.cached_bounds, Some(expected));
}
+/// The native file of a document whose composite font `name` sets Roman at full size, with type
+/// "Ab12" set in it → (the file, the type's id, its width).
+fn full_roman_file(name: &str) -> (Vec, u64, f64) {
+ let mut s = session();
+ let mut full = font(name);
+ full["parts"]["roman"]["size"] = json!(100);
+ s.execute("type.compositeFonts.set", &json!({"fonts": [full]})).unwrap();
+ let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": name})).unwrap()["id"].as_u64().unwrap();
+ let width = text(&s, id).cached_bounds.unwrap().width();
+ (vectorcraft_format::save_file(&s.doc().unwrap().doc), id, width)
+}
+
+/// Type in a missing font that no composite font is named after keeps the bounds it was measured
+/// with when its file was read: opening the document does not lay it out again.
+#[test]
+fn opening_leaves_type_in_an_unrelated_missing_font_as_measured() {
+ let mut s = session();
+ s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Unrelated")]})).unwrap();
+ let id =
+ s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab", "size": 20, "font": "No Such Family CF"})).unwrap()["id"].as_u64().unwrap();
+ let mut doc = (*s.doc().unwrap().doc).clone();
+ let kept = vectorcraft_geom::Rect::new(1.0, 2.0, 3.0, 4.0);
+ if let NodeKind::Text(t) = &mut doc.node_mut(NodeId(id)).unwrap().kind {
+ t.cached_bounds = Some(kept);
+ }
+ s.add_document(doc, None);
+ assert_eq!(text(&s, id).cached_bounds, Some(kept));
+}
+
+/// Text holding an inline graphic whose symbol's art is type in a composite font is measured with
+/// the graphic at the size the opened document's own definition gives it.
+#[test]
+fn opening_measures_inline_graphics_of_composite_type_in_its_own_definition() {
+ let mut s = session();
+ let mut full = font("CF Inline");
+ full["parts"]["roman"]["size"] = json!(100);
+ s.execute("type.compositeFonts.set", &json!({"fonts": [full]})).unwrap();
+ let art = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": "CF Inline"})).unwrap()["id"].as_u64().unwrap();
+ s.execute("symbol.new", &json!({"name": "CF Inline Mark", "ids": [art]})).unwrap();
+ let id = s.execute("text.create", &json!({"x": 10, "y": 150, "text": "x", "size": 20})).unwrap()["id"].as_u64().unwrap();
+ s.execute("text.insertInline", &json!({"id": id, "at": 0, "symbol": "CF Inline Mark"})).unwrap();
+ let width = text(&s, id).cached_bounds.unwrap().width();
+ let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc);
+ let mut s = session();
+ s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Inline")]})).unwrap();
+ let file = crate::cmd::fileio::read_for_open("inline.vectorcraft", &bytes, None, &json!({}), false, false).unwrap();
+ crate::cmd::fileio::open_read(&mut s, file).unwrap();
+ assert!((text(&s, id).cached_bounds.unwrap().width() - width).abs() < 1e-6);
+}
+
+/// Type read for opening while a composite font of its name was registered is measured again when
+/// it opens, even after the document that defined the font has closed in between.
+#[test]
+fn opening_measures_type_read_in_a_composite_font_closed_since() {
+ let mut s = session();
+ let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": "CF Gone"})).unwrap()["id"].as_u64().unwrap();
+ let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc);
+ s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
+ s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Gone")]})).unwrap();
+ let file = crate::cmd::fileio::read_for_open("gone.vectorcraft", &bytes, None, &json!({}), false, false).unwrap();
+ assert!(s.close_document(1));
+ crate::cmd::fileio::open_read(&mut s, file).unwrap();
+ let actual = text(&s, id);
+ let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds;
+ assert_eq!(actual.cached_bounds, Some(expected));
+}
+
+/// A file read for opening lays out its type in the composite fonts registered then (the front
+/// document's, which defines the name with Roman at half size); once the file opens, its type is
+/// measured again in its own.
+#[test]
+fn an_opened_file_measures_its_type_in_its_own_composite_fonts() {
+ let (bytes, id, width) = full_roman_file("CF Opened");
+ let mut s = session();
+ s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Opened")]})).unwrap();
+ let file = crate::cmd::fileio::read_for_open("opened.vectorcraft", &bytes, None, &json!({}), false, false).unwrap();
+ crate::cmd::fileio::open_read(&mut s, file).unwrap();
+ let actual = text(&s, id);
+ assert!((actual.cached_bounds.unwrap().width() - width).abs() < 1e-6);
+ let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds;
+ assert_eq!(actual.cached_bounds, Some(expected));
+}
+
+/// A document replaced while in the back (File › Revert) is measured in its own composite fonts
+/// when it comes to the front, not in the front document's.
+#[test]
+fn a_document_replaced_in_the_back_is_measured_in_its_own_composite_fonts() {
+ let (bytes, id, width) = full_roman_file("CF Replaced");
+ let mut s = session();
+ s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Replaced")]})).unwrap();
+ s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
+ s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Replaced")]})).unwrap();
+ assert!(s.replace_document(0, vectorcraft_format::load(&bytes).unwrap()));
+ assert!(s.set_active(0));
+ let actual = text(&s, id);
+ assert!((actual.cached_bounds.unwrap().width() - width).abs() < 1e-6);
+ let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds;
+ assert_eq!(actual.cached_bounds, Some(expected));
+}
+
+/// So is one that comes to the front because the front document closes.
+#[test]
+fn a_document_replaced_in_the_back_is_measured_when_the_front_one_closes() {
+ let (bytes, id, width) = full_roman_file("CF Closed");
+ let mut s = session();
+ s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
+ s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Closed")]})).unwrap();
+ assert!(s.replace_document(0, vectorcraft_format::load(&bytes).unwrap()));
+ assert!(s.close_document(1));
+ let actual = text(&s, id);
+ assert!((actual.cached_bounds.unwrap().width() - width).abs() < 1e-6);
+ let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds;
+ assert_eq!(actual.cached_bounds, Some(expected));
+}
+
+/// A failed batch takes back the composite fonts it set, in the font database too.
+#[test]
+fn a_failed_batch_takes_back_its_composite_fonts() {
+ let db = vectorcraft_text::FontDb::global();
+ let mut s = session();
+ let steps = json!([
+ {"command": "type.compositeFonts.set", "params": {"fonts": [font("CF Failed Batch")]}},
+ {"command": "no.such.command"},
+ ]);
+ assert!(s.execute("command.batch", &json!({"commands": steps})).is_err());
+ assert!(names(&s).is_empty());
+ assert!(db.composite_font("CF Failed Batch").is_none());
+}
+
+/// A composite font whose edit is refused, because it makes selected type too wide for the canvas,
+/// is not left in the font database.
+#[test]
+fn a_refused_edit_takes_back_its_new_composite_font() {
+ let db = vectorcraft_text::FontDb::global();
+ let mut s = session();
+ let x = crate::MAX_COORD - 1000.0;
+ s.execute("text.create", &json!({"x": x, "y": 50, "text": "MMMMMMMM", "size": 100, "font": "CF Refused Edit"})).unwrap();
+ let wide = json!({"name": "CF Refused Edit", "parts": {"kanji": {"font": "Source Sans 3", "size": 400}}});
+ let e = s.execute("type.compositeFonts.set", &json!({"fonts": [wide]})).unwrap_err().to_string();
+ assert!(e.contains("exceed the canvas"), "{e}");
+ assert!(names(&s).is_empty());
+ assert!(db.composite_font("CF Refused Edit").is_none());
+}
+
/// A loaded composite font cannot replace an installed family used by existing type.
#[test]
fn loaded_composite_font_cannot_take_an_installed_family_name() {
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