diff --git a/Cargo.lock b/Cargo.lock
index ce5569cde9..10c93a8322 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -6477,6 +6477,7 @@ dependencies = [
"vectorcraft-text",
"vectorcraft-three-d",
"vectorcraft-trace",
+ "wat",
]
[[package]]
@@ -6665,6 +6666,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
+ "siphasher",
"skrifa 0.47.0",
"smallvec",
"thiserror 2.0.21",
diff --git a/ROADMAP.md b/ROADMAP.md
index b5a2f43c7a..fe325ff849 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -141,6 +141,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
- 2026-10-31: Export for Screens works as the reference app's dialog: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File (the chosen artboards as one PDF's pages, `pdfs: "single"`) or Multiple Files, iOS and Android rows, format presets in a ☰ menu, and a count of the files the export writes (#4895). About 55 community PRs and fixes landed the same day: Find Font, Replace Font, Outline Stroke and Expand leave hidden and locked art alone (with a warning); DXF export writes LINE and ELLIPSE entities and outlines only the strokes a CAD line can't draw; Blob Brush Angle and Roundness; Shift-constrained Perspective Selection; Grid Repeat Options Preview; a gradient mesh from a file budgeted at about a million quads; width-profile strokes keep their shape zoomed out; `.ai`/EPS import fidelity (area type, envelopes, transparency in dictionaries, character styles); PDF import finds hyphenated and CMap-suffixed font names; Windows renders follow the process's core affinity; Enter or Escape in a floating panel's field no longer reaches the canvas (#4803); Properties shows Blend Options for any selected blend (#4817); Artwork Cleanup finds doubled type (#4835) and joins stacked lines into one object (Join Point Text, #4826). Headline numbers unchanged.
- 2026-10-31: Arrange Tools, a built-in add-on (Object › Plug-ins › Arrange Tools): Arrange in Grid lays the selected objects out in rows and columns, a row or a column with gaps, from the selection's top-left or centred on the artboard; Swap Objects swaps two objects' places (and, with Swap Objects and Sizes, their sizes); Replace with Top Object puts a copy of the topmost selected object in place of each of the others, kept, stretched or fitted in proportion; Set Size of Each gives every selected object an exact width and/or height about a reference point; Zoom to Selection fits the view to the selection. Each is one undo step with a previewed dialog where it has options. A VectorCraft addition: headline numbers unchanged.
- 2026-11-11: Window › Swatch Libraries › Gradients › Organic Fades: fifteen soft fades (ten linear, five radial) through three to five unevenly spaced stops with off-center midpoints, mixed with the Perceptual method, that thin out to transparent at the end of a linear one and at the edge of a radial one; they also list in the Swatches panel's library menu, `swatch.library.list` and the Gradient panel's Presets.
+- 2026-11-13: Gradient panel › Method: Hue Clockwise and Hue Counterclockwise mix a linear, radial, conic or diamond gradient's colors around the hue wheel in one direction, so red to blue passes through magenta one way and through yellow and green the other (`paint.editGradient` `method`: `hue-clockwise`, `hue-counterclockwise`); SVG and PDF exports write such a gradient as an image.
- 2026-11-11: Window › Swatch Libraries › Gradients › Holographic: seven foils (opaque linear gradients of six narrow pastel and pearl bands with off-center midpoints) and seven glows (radial gradients from a bright center out to a clear edge), mixed with the Perceptual method; they also list in the Swatches panel's library menu, `swatch.library.list` and the Gradient panel's Presets.
- 2026-10-30: Gradient panel presets: a Presets menu applies one of the built-in gradient libraries' presets (grouped by theme) or a saved one to the gradient behind the active proxy, keeping its placement, as one undo step; the panel menu saves the current gradient as a preset, kept with the preferences, and deletes saved ones (`gradient.presets.list/apply/save/delete`).
- 2026-10-23: Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add a thin overprinting strip of the lighter colour inside the darker one where two solid colours meet, so misregistered plates leave no gap ([trapping](https://helpx.adobe.com/illustrator/using/trapping.html)), with Thickness, Height/Width, Tint Reduction, Traps with Process Color and Reverse Traps. Illustrator effects 49/50 (SVG Filters remains); Live vector effects hours 6–10 → 4–8; headline numbers unchanged.
diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs
index 0d4deca36f..b66c15070f 100644
--- a/apps/vectorcraft-cli/src/main.rs
+++ b/apps/vectorcraft-cli/src/main.rs
@@ -418,10 +418,18 @@ fn convert(args: &[String]) -> Result<(), String> {
// Save as Template writes every artboard, with the .ai options.
("file.saveAsTemplate", json!({"path": output, "format": "ait", "outlineText": outline_text, "acknowledgeLoss": replace_lossy}))
} else {
- // A conversion keeps hidden layers and objects, written hidden, where the format can (SVG,
- // PSD).
- let params = json!({"path": output, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy});
+ let params = json!({"path": output, "artboard": artboard, "range": range, "acknowledgeLoss": replace_lossy});
let mut params = with_pixel_scale(params, &output, scale, ppi);
+ // An option the format doesn't read comes back in the warnings, so each goes out only
+ // when it was asked for: `outlineText` with --outline-text, and `hiddenLayers` to the
+ // formats that have it (SVG, PSD), where a conversion keeps hidden layers and objects,
+ // written hidden.
+ if outline_text {
+ params["outlineText"] = json!(true);
+ }
+ if vectorcraft_engine::cmd::fileio::writable_format(None, Some(&output)).is_ok_and(|f| f.options.iter().any(|o| o.name == "hiddenLayers")) {
+ params["hiddenLayers"] = json!(true);
+ }
if let Some(decimals) = decimals {
params["decimals"] = json!(decimals);
}
diff --git a/apps/vectorcraft-cli/tests/it/fileio.rs b/apps/vectorcraft-cli/tests/it/fileio.rs
index 71044be4a5..cdb16d28d3 100644
--- a/apps/vectorcraft-cli/tests/it/fileio.rs
+++ b/apps/vectorcraft-cli/tests/it/fileio.rs
@@ -177,6 +177,27 @@ fn convert_reports_import_warnings() {
assert!(w.iter().any(|w| w.as_str().unwrap().contains("nowhere-to-be-found.png")), "{out}");
}
+/// A conversion sends each format only the options it reads, so its warnings name an unused
+/// option only when the command line gave one: `--outline-text` for a format without it.
+#[test]
+fn convert_warns_about_an_unused_option_only_when_one_was_given() {
+ let svg = tmp("unused-option.svg");
+ std::fs::write(&svg, r#" "#).unwrap();
+ let unused = |out: &Value| -> Vec {
+ let w = out["warnings"].as_array().unwrap_or_else(|| panic!("{out}"));
+ w.iter().filter_map(Value::as_str).filter(|w| w.contains("was not used")).map(str::to_string).collect()
+ };
+ for ext in ["png", "jpg", "pdf", "ai", "eps", "psd", "dxf", "svg", "txt", "vectorcraft", "vctemplate"] {
+ let path = tmp(&format!("unused-option.{ext}"));
+ let out: Value = serde_json::from_str(ok(&["convert", &svg, &path]).trim()).unwrap();
+ assert!(unused(&out).is_empty(), "{ext}: {out}");
+ }
+ let out: Value = serde_json::from_str(ok(&["convert", &svg, &tmp("unused-option-outlined.svg"), "--outline-text"]).trim()).unwrap();
+ assert!(unused(&out).is_empty(), "{out}");
+ let out: Value = serde_json::from_str(ok(&["convert", &svg, &tmp("unused-option-outlined.png"), "--outline-text"]).trim()).unwrap();
+ assert_eq!(unused(&out), ["`outlineText` was not used: PNG files don't read it"], "{out}");
+}
+
#[test]
fn convert_writes_legacy_ai_with_its_options() {
// Two artboards, a shown rectangle and a hidden one.
diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml
index 7bdfece60c..fee2ab4e3b 100644
--- a/apps/vectorcraft/Cargo.toml
+++ b/apps/vectorcraft/Cargo.toml
@@ -111,15 +111,16 @@ wayland-protocols = { version = "0.32", features = ["client", "unstable"] }
# wayland-protocols' `unstable` set) made on the window's own connection. MIT, already in the build
# through winit and rfd.
wayland-backend = { version = "0.3", features = ["client_system"] }
-# Waits for the screen color sampler's portal answer and for the portal leaving the bus at the same
-# time (src/linux_screen_color/request.rs). Apache-2.0 OR MIT, already in the build through zbus.
-futures-lite = "2"
# The save dialog's file type (src/portal_save.rs, #2679): the desktop portal over D-Bus, on the
# systems where rfd shows the portal's dialog. MIT, already in the build through accesskit. Pinned
# exactly: review the diff before bumping.
[target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))'.dependencies]
zbus = "=5.19.0"
+# Waits for a portal answer (the save dialog's and the screen color sampler's) and for the portal
+# leaving the bus at the same time (src/portal_request.rs). Apache-2.0 OR MIT, already in the build
+# through zbus.
+futures-lite = "2"
# Tells whether arboard reads through the compositor's data-control protocol or through X11, the
# same check arboard makes (src/clipboard.rs). MIT OR Apache-2.0, already in the build through
# arboard.
diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs
index b753427d8b..126b671877 100644
--- a/apps/vectorcraft/src/app.rs
+++ b/apps/vectorcraft/src/app.rs
@@ -108,10 +108,37 @@ impl eframe::App for App {
self.app.start_pending_update();
#[cfg(target_os = "macos")]
super::mac_cursor::release();
+ #[cfg(not(any(target_os = "macos", windows)))]
+ hide_before_teardown(self.winit_window.as_deref());
end_before_teardown(self.frames);
}
}
+/// The main window as [`hide_before_teardown`] sees it.
+#[cfg(not(any(target_os = "macos", windows)))]
+pub(crate) trait QuittingWindow {
+ fn hide(&self);
+}
+
+#[cfg(not(any(target_os = "macos", windows)))]
+impl QuittingWindow for winit::window::Window {
+ fn hide(&self) {
+ self.set_visible(false);
+ }
+}
+
+/// On Linux and the BSDs, hide the main window before eframe drops the app. Dropping the app waits
+/// up to [`vectorcraft_ui_egui::CLIPBOARD_PROBE_STOP_WAIT`] for the clipboard handles to let go, and
+/// the window would stay on screen for that time. The window is hidden, not closed: the Wayland
+/// clipboard reads text on the window's connection. winit hides X11 windows (XWayland included) and
+/// cannot hide a Wayland window.
+#[cfg(not(any(target_os = "macos", windows)))]
+pub(crate) fn hide_before_teardown(window: Option<&W>) {
+ if let Some(window) = window {
+ window.hide();
+ }
+}
+
impl App {
/// The frame's times for the hang report, and its last stage ([`hang_beacon::after_frame`]): a
/// focused, shown window whose windows had input waits for the renderer and the next frame,
diff --git a/apps/vectorcraft/src/clipboard.rs b/apps/vectorcraft/src/clipboard.rs
index c5ef4d5579..1daccc22f1 100644
--- a/apps/vectorcraft/src/clipboard.rs
+++ b/apps/vectorcraft/src/clipboard.rs
@@ -1504,6 +1504,16 @@ mod portable {
assert_eq!(finish(true), Duration::ZERO);
}
+ #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
+ #[test]
+ fn quitting_waits_for_the_paste_checks_handle_as_long_as_its_reads_last() {
+ // The Paste check's handle is one of these: when the app quits, its unanswered X11 read
+ // ends `X11_READ_LIMIT` after it was sent, and an idle worker stops within `LOAD_WAIT`.
+ // Quitting waits for that handle until both are over.
+ assert!(vectorcraft_ui_egui::CLIPBOARD_PROBE_STOP_WAIT >= finish(false) + super::super::loads::LOAD_WAIT);
+ assert!(vectorcraft_ui_egui::CLIPBOARD_PROBE_STOP_WAIT >= finish(true) + super::super::loads::LOAD_WAIT);
+ }
+
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
#[test]
fn a_bitmap_paste_waits_for_the_owner_off_the_ui_thread() {
diff --git a/apps/vectorcraft/src/host_services.rs b/apps/vectorcraft/src/host_services.rs
index 03a9b8848c..94419b1049 100644
--- a/apps/vectorcraft/src/host_services.rs
+++ b/apps/vectorcraft/src/host_services.rs
@@ -393,8 +393,9 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services {
// Its calls are marked for the hang report (`hang_watch`).
system_clipboard: Some(Box::new(vectorcraft_ui_egui::hang_beacon::Marked(clipboard::system_clipboard(clip)))),
// Linux checks whether Paste has something to take on a background thread: an X11 clipboard
- // owner that never answers holds a read for up to 4 s. Windows only asks which formats the
- // clipboard holds and macOS asks the pasteboard server, so they check in line.
+ // owner that never answers holds a text read for up to 4 s for each format it tries, and
+ // quitting waits up to `CLIPBOARD_PROBE_STOP_WAIT` for that thread. Windows only asks which
+ // formats the clipboard holds and macOS asks the pasteboard server, so they check in line.
// The probe's handle is made on its own thread, from the window (`Send`), not its raw display.
clipboard_probe: cfg!(target_os = "linux").then(|| Box::new(move || clipboard::system_clipboard(clip_probe)) as ClipboardProbeFactory),
// Help → Discord / website / GitHub, the Discord button, About and Home links.
diff --git a/apps/vectorcraft/src/linux_screen_color.rs b/apps/vectorcraft/src/linux_screen_color.rs
index 0f46af19d8..cfb8d7d69f 100644
--- a/apps/vectorcraft/src/linux_screen_color.rs
+++ b/apps/vectorcraft/src/linux_screen_color.rs
@@ -4,7 +4,6 @@
//! parent is the window, named on that thread as for the portal's save dialog (`portal_save`).
mod portal;
-mod request;
/// The portal's color sampler over the window `parent` holds; `ui` is woken when it answers.
pub fn sampler(ui: egui::Context, parent: super::host_services::Parent) -> vectorcraft_ui_egui::screen_color::StartSampler {
diff --git a/apps/vectorcraft/src/linux_screen_color/portal.rs b/apps/vectorcraft/src/linux_screen_color/portal.rs
index b7518e1ce3..21bcb34da5 100644
--- a/apps/vectorcraft/src/linux_screen_color/portal.rs
+++ b/apps/vectorcraft/src/linux_screen_color/portal.rs
@@ -61,9 +61,9 @@ pub(super) fn connect(builder: zbus::Result Result, Failure> {
const DESKTOP: &str = "org.freedesktop.portal.Desktop";
- let token = super::request::token("vectorcraft_color").map_err(Failure::Failed)?;
+ let token = crate::portal_request::token("vectorcraft_color").map_err(Failure::Failed)?;
// Made before the call: a portal can send its answer before the call returns.
- let replies = super::request::Responses::new(conn).map_err(Failure::Failed)?;
+ let replies = crate::portal_request::Responses::new(conn).map_err(Failure::Failed)?;
let proxy = Proxy::new(conn, DESKTOP, "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Screenshot")
.map_err(|e| Failure::Failed(e.to_string()))?;
let options = HashMap::from([("handle_token", Value::from(token.as_str()))]);
@@ -106,6 +106,7 @@ fn answer(code: u32, results: &HashMap) -> Result HashMap {
HashMap::from([("color".into(), OwnedValue::try_from(Value::from(rgb)).unwrap())])
@@ -186,35 +187,6 @@ mod tests {
}
}
- /// A session bus of its own (`dbus-daemon`), `None` when there is none to run.
- fn private_bus() -> Option<(Daemon, String)> {
- use std::io::BufRead as _;
- let spawning = crate::spawning();
- let daemon = std::process::Command::new("dbus-daemon")
- .args(["--session", "--nofork", "--print-address=1"])
- .stdout(std::process::Stdio::piped())
- .spawn();
- drop(spawning);
- let Ok(daemon) = daemon else {
- eprintln!("no dbus-daemon: skipped");
- return None;
- };
- let mut daemon = Daemon(daemon);
- let mut address = String::new();
- std::io::BufReader::new(daemon.0.stdout.take().unwrap()).read_line(&mut address).unwrap();
- Some((daemon, address.trim().to_string()))
- }
-
- /// Stops the private bus when the test ends, unwinding included.
- struct Daemon(std::process::Child);
-
- impl Drop for Daemon {
- fn drop(&mut self) {
- let _ = self.0.kill();
- let _ = self.0.wait();
- }
- }
-
/// The client's connection to the private bus at `address`, made as the sampler makes its own.
fn client(address: &str) -> Connection {
connect(zbus::blocking::connection::Builder::address(address)).unwrap()
diff --git a/apps/vectorcraft/src/linux_screen_color/request.rs b/apps/vectorcraft/src/linux_screen_color/request.rs
deleted file mode 100644
index f442b840a3..0000000000
--- a/apps/vectorcraft/src/linux_screen_color/request.rs
+++ /dev/null
@@ -1,127 +0,0 @@
-//! The answer to the color sampler's XDG desktop portal request
-//! ().
-//! The wait ends with the request's `Response`, or with an error when the portal leaves the bus
-//! first.
-
-use std::io::Read as _;
-
-use futures_lite::{StreamExt as _, future};
-use zbus::blocking::{Connection, MessageIterator};
-use zbus::zvariant::OwnedObjectPath;
-
-const DESKTOP: &str = "org.freedesktop.portal.Desktop";
-
-/// A request token: `prefix`, then 128 bits from the operating system's random device, so the
-/// token is unique and can't be guessed, as the Request documentation asks.
-pub(super) fn token(prefix: &str) -> Result {
- let mut bytes = [0; 16];
- std::fs::File::open("/dev/urandom")
- .and_then(|mut file| file.read_exact(&mut bytes))
- .map_err(|e| format!("could not create a portal request token: {e}"))?;
- let random: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
- Ok(format!("{prefix}_{random}"))
-}
-
-/// Subscriptions made before a portal method call, on every request path: a portal older than
-/// version 0.9 ignores `handle_token` and answers on the request path it returns, and its answer
-/// can come before the call's reply. Each stream keeps every signal from the time it is made.
-pub(super) struct Responses {
- replies: MessageIterator,
- owners: MessageIterator,
-}
-
-impl Responses {
- pub(super) fn new(conn: &Connection) -> Result {
- let reply_rule = zbus::MatchRule::builder()
- .msg_type(zbus::message::Type::Signal)
- .sender(DESKTOP)
- .and_then(|b| b.interface("org.freedesktop.portal.Request"))
- .and_then(|b| b.member("Response"))
- .map_err(|e| e.to_string())?
- .build();
- let owner_rule = zbus::MatchRule::builder()
- .msg_type(zbus::message::Type::Signal)
- .sender("org.freedesktop.DBus")
- .and_then(|b| b.path("/org/freedesktop/DBus"))
- .and_then(|b| b.interface("org.freedesktop.DBus"))
- .and_then(|b| b.member("NameOwnerChanged"))
- .and_then(|b| b.arg(0, DESKTOP))
- .map_err(|e| e.to_string())?
- .build();
- Ok(Self {
- replies: MessageIterator::for_match_rule(reply_rule, conn, None).map_err(|e| e.to_string())?,
- owners: MessageIterator::for_match_rule(owner_rule, conn, None).map_err(|e| e.to_string())?,
- })
- }
-
- /// The `Response` on the request path `call` returned, from the connection that returned it.
- /// An error when that connection leaves the bus first, or the bus connection closes.
- pub(super) fn wait(self, call: &zbus::Message) -> Result {
- enum Event {
- Reply(Option>),
- Owner(Option>),
- }
- let handle: OwnedObjectPath = call.body().deserialize().map_err(|e| e.to_string())?;
- let portal = call.header().sender().map(|s| s.as_str().to_string()).ok_or("the portal answered without a sender")?;
- let mut replies = self.replies.into_inner();
- let mut owners = self.owners.into_inner();
- future::block_on(async {
- loop {
- let event = future::or(async { Event::Reply(replies.next().await) }, async { Event::Owner(owners.next().await) }).await;
- match event {
- Event::Reply(reply) => {
- let reply = reply.ok_or("the portal closed without an answer")?.map_err(|e| e.to_string())?;
- let header = reply.header();
- if header.path().map(|p| p.as_str()) == Some(handle.as_str()) && header.sender().map(|s| s.as_str()) == Some(portal.as_str())
- {
- return Ok(reply);
- }
- }
- Event::Owner(message) => {
- let message = message.ok_or("the portal connection closed")?.map_err(|e| e.to_string())?;
- if owner_disappeared(&message, &portal)? {
- return Err("the desktop portal disappeared while waiting for its answer".into());
- }
- }
- }
- }
- })
- }
-}
-
-/// Whether `message` is the bus's `NameOwnerChanged` saying that the connection `portal` no longer
-/// owns the portal's name. The bus itself must send it: another connection can't end the wait.
-fn owner_disappeared(message: &zbus::Message, portal: &str) -> Result {
- if message.header().sender().map(|s| s.as_str()) != Some("org.freedesktop.DBus") {
- return Ok(false);
- }
- let (name, old, new): (String, String, String) = message.body().deserialize().map_err(|e| e.to_string())?;
- Ok(name == DESKTOP && old == portal && new != portal)
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn only_the_bus_can_report_portal_owner_loss() {
- let changed = |sender, name, old, new| {
- zbus::Message::signal("/org/freedesktop/DBus", "org.freedesktop.DBus", "NameOwnerChanged")
- .unwrap()
- .sender(sender)
- .unwrap()
- .build(&(name, old, new))
- .unwrap()
- };
- let forged = changed(":1.42", DESKTOP, ":1.7", "");
- assert!(!owner_disappeared(&forged, ":1.7").unwrap());
- let real = changed("org.freedesktop.DBus", DESKTOP, ":1.7", "");
- assert!(owner_disappeared(&real, ":1.7").unwrap());
- let other = changed("org.freedesktop.DBus", "org.example.Other", ":1.7", "");
- assert!(!owner_disappeared(&other, ":1.7").unwrap());
- let old = changed("org.freedesktop.DBus", DESKTOP, ":1.8", "");
- assert!(!owner_disappeared(&old, ":1.7").unwrap());
- let acquired = changed("org.freedesktop.DBus", DESKTOP, "", ":1.7");
- assert!(!owner_disappeared(&acquired, ":1.7").unwrap());
- }
-}
diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs
index c10b72da44..7594bc2344 100644
--- a/apps/vectorcraft/src/main.rs
+++ b/apps/vectorcraft/src/main.rs
@@ -54,6 +54,8 @@ mod mac_tablet;
mod mac_window;
#[cfg(target_os = "macos")]
mod open_documents;
+#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
+mod portal_request;
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd", test))]
mod portal_save;
mod prefs_dir;
@@ -88,6 +90,8 @@ mod titlebar;
use app::bring_window_forward;
use app::native_input_hook;
use app::open_or_hold;
+#[cfg(all(test, not(any(target_os = "macos", windows))))]
+use app::{QuittingWindow, hide_before_teardown};
use host_services::services;
#[cfg(all(test, feature = "wgpu"))]
use prefs::{KEEP_UI_PREFS, decode_saved_prefs, keep_prefs_copy, prefs_bytes};
@@ -117,7 +121,8 @@ struct App {
frames: u64,
/// Keeps the custom title bar's window borderless (Windows, #1758).
window_frame: Option,
- /// The main window; used to read and set maximized state the UI layer does not see on Linux (#3635).
+ /// The main window; used to read and set maximized state the UI layer does not see on Linux (#3635),
+ /// and to hide the window on quit before the clipboard handles let go (`app::hide_before_teardown`).
winit_window: Option>,
/// A saved or fitted geometry asked for maximize once the window is mapped (Linux).
pending_maximize: bool,
diff --git a/apps/vectorcraft/src/portal_request.rs b/apps/vectorcraft/src/portal_request.rs
new file mode 100644
index 0000000000..da4cb9ad23
--- /dev/null
+++ b/apps/vectorcraft/src/portal_request.rs
@@ -0,0 +1,231 @@
+//! Linux and the BSDs: the answer to an XDG desktop portal request
+//! (),
+//! for the save dialog (`portal_save`) and, on Linux, the screen color sampler
+//! (`linux_screen_color`). The wait ends with the request's `Response`, or with an error when the
+//! portal leaves the bus first.
+
+use futures_lite::{StreamExt as _, future};
+use zbus::blocking::{Connection, MessageIterator};
+use zbus::zvariant::OwnedObjectPath;
+
+const DESKTOP: &str = "org.freedesktop.portal.Desktop";
+
+/// A request token: `prefix`, then 128 bits from the operating system's random device, so the
+/// token is unique and can't be guessed, as the Request documentation asks.
+#[cfg(target_os = "linux")]
+pub(crate) fn token(prefix: &str) -> Result {
+ use std::io::Read as _;
+
+ let mut bytes = [0; 16];
+ std::fs::File::open("/dev/urandom")
+ .and_then(|mut file| file.read_exact(&mut bytes))
+ .map_err(|e| format!("could not create a portal request token: {e}"))?;
+ let random: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
+ Ok(format!("{prefix}_{random}"))
+}
+
+/// Subscriptions made before a portal method call, on every request path: a portal older than
+/// version 0.9 ignores `handle_token` and answers on the request path it returns, and its answer
+/// can come before the call's reply. Each stream keeps every signal from the time it is made.
+pub(crate) struct Responses {
+ replies: MessageIterator,
+ owners: MessageIterator,
+}
+
+impl Responses {
+ pub(crate) fn new(conn: &Connection) -> Result {
+ let reply_rule = zbus::MatchRule::builder()
+ .msg_type(zbus::message::Type::Signal)
+ .sender(DESKTOP)
+ .and_then(|b| b.interface("org.freedesktop.portal.Request"))
+ .and_then(|b| b.member("Response"))
+ .map_err(|e| e.to_string())?
+ .build();
+ let owner_rule = zbus::MatchRule::builder()
+ .msg_type(zbus::message::Type::Signal)
+ .sender("org.freedesktop.DBus")
+ .and_then(|b| b.path("/org/freedesktop/DBus"))
+ .and_then(|b| b.interface("org.freedesktop.DBus"))
+ .and_then(|b| b.member("NameOwnerChanged"))
+ .and_then(|b| b.arg(0, DESKTOP))
+ .map_err(|e| e.to_string())?
+ .build();
+ Ok(Self {
+ replies: MessageIterator::for_match_rule(reply_rule, conn, None).map_err(|e| e.to_string())?,
+ owners: MessageIterator::for_match_rule(owner_rule, conn, None).map_err(|e| e.to_string())?,
+ })
+ }
+
+ /// The `Response` on the request path `call` returned, from the connection that returned it.
+ /// An error when that connection leaves the bus first, or the bus connection closes. A
+ /// `Response` the portal sent before it left is still the answer.
+ pub(crate) fn wait(self, call: &zbus::Message) -> Result {
+ enum Event {
+ Reply(Option>),
+ Owner(Option>),
+ }
+ let handle: OwnedObjectPath = call.body().deserialize().map_err(|e| e.to_string())?;
+ let portal = call.header().sender().map(|s| s.as_str().to_string()).ok_or("the portal answered without a sender")?;
+ let answers = |reply: &zbus::Message| {
+ let header = reply.header();
+ header.path().map(|p| p.as_str()) == Some(handle.as_str()) && header.sender().map(|s| s.as_str()) == Some(portal.as_str())
+ };
+ let mut replies = self.replies.into_inner();
+ let mut owners = self.owners.into_inner();
+ future::block_on(async {
+ loop {
+ let event = future::or(async { Event::Owner(owners.next().await) }, async { Event::Reply(replies.next().await) }).await;
+ match event {
+ Event::Reply(reply) => {
+ let reply = reply.ok_or("the portal closed without an answer")?.map_err(|e| e.to_string())?;
+ if answers(&reply) {
+ return Ok(reply);
+ }
+ }
+ Event::Owner(message) => {
+ let message = message.ok_or("the portal connection closed")?.map_err(|e| e.to_string())?;
+ if owner_disappeared(&message, &portal)? {
+ // zbus puts each message in every matching stream before it reads the
+ // next one, so a `Response` that came before the bus's report is
+ // already queued: it is still the answer.
+ while let Some(Some(reply)) = future::poll_once(replies.next()).await {
+ if let Ok(reply) = reply
+ && answers(&reply)
+ {
+ return Ok(reply);
+ }
+ }
+ return Err("the desktop portal disappeared while waiting for its answer".into());
+ }
+ }
+ }
+ }
+ })
+ }
+}
+
+/// Whether `message` is the bus's `NameOwnerChanged` saying that the connection `portal` no longer
+/// owns the portal's name. The bus itself must send it: another connection can't end the wait.
+fn owner_disappeared(message: &zbus::Message, portal: &str) -> Result {
+ if message.header().sender().map(|s| s.as_str()) != Some("org.freedesktop.DBus") {
+ return Ok(false);
+ }
+ let (name, old, new): (String, String, String) = message.body().deserialize().map_err(|e| e.to_string())?;
+ Ok(name == DESKTOP && old == portal && new != portal)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn only_the_bus_can_report_portal_owner_loss() {
+ let changed = |sender, name, old, new| {
+ zbus::Message::signal("/org/freedesktop/DBus", "org.freedesktop.DBus", "NameOwnerChanged")
+ .unwrap()
+ .sender(sender)
+ .unwrap()
+ .build(&(name, old, new))
+ .unwrap()
+ };
+ let forged = changed(":1.42", DESKTOP, ":1.7", "");
+ assert!(!owner_disappeared(&forged, ":1.7").unwrap());
+ let real = changed("org.freedesktop.DBus", DESKTOP, ":1.7", "");
+ assert!(owner_disappeared(&real, ":1.7").unwrap());
+ let other = changed("org.freedesktop.DBus", "org.example.Other", ":1.7", "");
+ assert!(!owner_disappeared(&other, ":1.7").unwrap());
+ let old = changed("org.freedesktop.DBus", DESKTOP, ":1.8", "");
+ assert!(!owner_disappeared(&old, ":1.7").unwrap());
+ let acquired = changed("org.freedesktop.DBus", DESKTOP, "", ":1.7");
+ assert!(!owner_disappeared(&acquired, ":1.7").unwrap());
+ }
+
+ /// Through a real D-Bus: when the bus has reported the portal gone, a `Response` the portal sent
+ /// before it left is still the answer; without one the wait ends with an error.
+ #[test]
+ fn a_queued_answer_wins_over_the_portal_leaving() {
+ use std::collections::HashMap;
+
+ use zbus::blocking::connection::Builder;
+ use zbus::zvariant::OwnedValue;
+
+ for answered in [true, false] {
+ let Some((_daemon, address)) = test_bus::private_bus() else { return };
+ let address = address.as_str();
+ let portal = Builder::address(address).unwrap().name(DESKTOP).unwrap().build().unwrap();
+ let unique = portal.unique_name().unwrap().to_string();
+ let client = Builder::address(address).unwrap().build().unwrap();
+ let responses = Responses::new(&client).unwrap();
+ // zbus puts each message in every matching stream before it reads the next one
+ // (`src/connection/socket_reader.rs`), so once this stream has the bus's report,
+ // `responses` holds the `Response` sent before it.
+ let gone_rule = zbus::MatchRule::builder()
+ .msg_type(zbus::message::Type::Signal)
+ .sender("org.freedesktop.DBus")
+ .unwrap()
+ .member("NameOwnerChanged")
+ .unwrap()
+ .arg(0, DESKTOP)
+ .unwrap()
+ .build();
+ let gone = MessageIterator::for_match_rule(gone_rule, &client, None).unwrap();
+ let path = "/org/freedesktop/portal/desktop/request/test/t1";
+ if answered {
+ let results = HashMap::::new();
+ portal.emit_signal(None::<&str>, path, "org.freedesktop.portal.Request", "Response", &(0_u32, results)).unwrap();
+ }
+ portal.close().unwrap();
+ // On a thread of its own, so a report that never comes fails the test instead of
+ // hanging it.
+ let (seen, report) = std::sync::mpsc::channel();
+ let left = unique.clone();
+ std::thread::spawn(move || {
+ let mut gone = gone;
+ let _ = seen.send(gone.any(|m| m.is_ok_and(|m| owner_disappeared(&m, &left).unwrap_or(false))));
+ });
+ assert_eq!(report.recv_timeout(std::time::Duration::from_secs(30)), Ok(true), "the bus did not report the portal gone");
+ // The call's reply as `wait` reads it: the request path, from the portal's connection.
+ let call = zbus::Message::signal("/", "org.example.Test", "Reply")
+ .unwrap()
+ .sender(unique.as_str())
+ .unwrap()
+ .build(&OwnedObjectPath::try_from(path).unwrap())
+ .unwrap();
+ let waited = responses.wait(&call);
+ assert_eq!(waited.is_ok(), answered, "{waited:?}");
+ }
+ }
+}
+
+/// The private session bus that the D-Bus tests of the portal requests run on.
+#[cfg(test)]
+pub(crate) mod test_bus {
+ /// A session bus of its own (`dbus-daemon`), `None` when there is none to run.
+ pub(crate) fn private_bus() -> Option<(Daemon, String)> {
+ use std::io::BufRead as _;
+ let spawning = crate::spawning();
+ let daemon = std::process::Command::new("dbus-daemon")
+ .args(["--session", "--nofork", "--print-address=1"])
+ .stdout(std::process::Stdio::piped())
+ .spawn();
+ drop(spawning);
+ let Ok(daemon) = daemon else {
+ eprintln!("no dbus-daemon: skipped");
+ return None;
+ };
+ let mut daemon = Daemon(daemon);
+ let mut address = String::new();
+ std::io::BufReader::new(daemon.0.stdout.take().unwrap()).read_line(&mut address).unwrap();
+ Some((daemon, address.trim().to_string()))
+ }
+
+ /// Stops the private bus when the test ends, unwinding included.
+ pub(crate) struct Daemon(std::process::Child);
+
+ impl Drop for Daemon {
+ fn drop(&mut self) {
+ let _ = self.0.kill();
+ let _ = self.0.wait();
+ }
+ }
+}
diff --git a/apps/vectorcraft/src/portal_save.rs b/apps/vectorcraft/src/portal_save.rs
index a9bf5d1009..6d1a8734b2 100644
--- a/apps/vectorcraft/src/portal_save.rs
+++ b/apps/vectorcraft/src/portal_save.rs
@@ -8,6 +8,7 @@
//! ([`vectorcraft_ui_egui::picks::PickAnswer`]); the app gives the name that type's extension.
//! rfd shows the dialog when the portal can't (no session bus, no file chooser), with zenity as its
//! own fallback. PhotoCraft's `portal_save.rs` (storytold/photocraft#6151) makes the same call.
+//! rfd also shows the dialog when the portal leaves the bus before it answers.
//!
//! The dialog's parent is the window: `x11:` on X11, and on Wayland (Linux) a handle
//! from the compositor's xdg-foreign protocol, as rfd names it (its `window_identifier`).
@@ -183,37 +184,18 @@ pub fn pick(pick: &FilePick, window: Option<&winit::window::Window>) -> Option Result, String> {
use std::sync::atomic::{AtomicU32, Ordering};
- use zbus::blocking::{MessageIterator, Proxy};
- use zbus::zvariant::OwnedObjectPath;
+ use zbus::blocking::Proxy;
const DESKTOP: &str = "org.freedesktop.portal.Desktop";
- const REQUEST: &str = "org.freedesktop.portal.Request";
static NEXT: AtomicU32 = AtomicU32::new(0);
let token = format!("vectorcraft_save_{}_{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed));
- // Listen before asking, on every request path: a portal older than version 0.9 ignores
- // `handle_token` and answers on the request path it returns, and its answer can come before
- // the call's reply. The stream keeps every `Response` from the time it is made.
- let rule = zbus::MatchRule::builder()
- .msg_type(zbus::message::Type::Signal)
- .sender(DESKTOP)
- .and_then(|b| b.interface(REQUEST))
- .and_then(|b| b.member("Response"))
- .map_err(|e| e.to_string())?
- .build();
- let responses = MessageIterator::for_match_rule(rule, conn, None).map_err(|e| e.to_string())?;
+ // Made before the call: a portal can send its answer before the call returns, and a portal
+ // that leaves the bus while its dialog is open ends the wait.
+ let responses = crate::portal_request::Responses::new(conn)?;
let chooser = Proxy::new(conn, DESKTOP, "/org/freedesktop/portal/desktop", "org.freedesktop.portal.FileChooser").map_err(|e| e.to_string())?;
// The title is empty, as rfd sends it for a save.
let call = chooser.call_method("SaveFile", &(request.parent.as_str(), "", options(request, &token))).map_err(|e| e.to_string())?;
- let handle: OwnedObjectPath = call.body().deserialize().map_err(|e| e.to_string())?;
- // The answer is the `Response` on the path returned, from the connection that returned it.
- let portal = call.header().sender().map(|s| s.as_str().to_string());
- let reply = responses
- .filter_map(Result::ok)
- .find(|m| {
- let header = m.header();
- header.path().map(|p| p.as_str()) == Some(handle.as_str()) && header.sender().map(|s| s.as_str()) == portal.as_deref()
- })
- .ok_or("the save dialog closed without an answer")?;
+ let reply = responses.wait(&call)?;
let (code, results): (u32, HashMap) = reply.body().deserialize().map_err(|e| e.to_string())?;
answer(code, &results)
}
@@ -495,26 +477,22 @@ mod tests {
Old,
/// As `Old`, with the `Response` sent before the reply that names its path.
OldEarly,
+ /// As `Current`, but it leaves the bus 300 ms after its reply without a `Response`, as a
+ /// portal that exits while its dialog is open.
+ Exits,
+ /// Leaves the bus right after its reply, without a `Response`.
+ ExitsAtOnce,
+ /// Sends its `Response` right after its reply, then leaves the bus.
+ AnswersAndExits,
}
/// A session bus of its own (`dbus-daemon`) with a mock `org.freedesktop.portal.FileChooser`
/// that answers `SaveFile` from `script` as `portal` says, as PhotoCraft's portal tests run it.
/// `None` when there is no `dbus-daemon` to run.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
- fn mock_portal(script: Script, portal: Portal) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> {
- use std::io::BufRead as _;
-
+ fn mock_portal(script: Script, portal: Portal) -> Option<(zbus::blocking::Connection, Seen, crate::portal_request::test_bus::Daemon)> {
use zbus::zvariant::OwnedObjectPath;
- let spawning = crate::spawning();
- let daemon = std::process::Command::new("dbus-daemon")
- .args(["--session", "--nofork", "--print-address=1"])
- .stdout(std::process::Stdio::piped())
- .spawn();
- drop(spawning);
- let mut daemon = daemon.ok()?;
- let mut address = String::new();
- std::io::BufReader::new(daemon.stdout.take()?).read_line(&mut address).ok()?;
- let address = address.trim().to_string();
+ let (daemon, address) = crate::portal_request::test_bus::private_bus()?;
let server =
zbus::blocking::connection::Builder::address(address.as_str()).ok()?.name("org.freedesktop.portal.Desktop").ok()?.build().ok()?;
let seen = Seen::default();
@@ -539,8 +517,8 @@ mod tests {
});
let Some(sender) = header.sender().map(|s| s.as_str().trim_start_matches(':').replace('.', "_")) else { break };
let token = match portal {
- Portal::Current => text("handle_token").unwrap_or_default(),
Portal::Old | Portal::OldEarly => format!("old{}", calls.unwrap_or_default()),
+ Portal::Current | Portal::Exits | Portal::ExitsAtOnce | Portal::AnswersAndExits => text("handle_token").unwrap_or_default(),
};
let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}");
let Ok(handle) = OwnedObjectPath::try_from(path.as_str()) else { break };
@@ -558,14 +536,22 @@ mod tests {
if server.reply(&header, &handle).is_err() {
break;
}
- if portal == Portal::Old {
+ if matches!(portal, Portal::Old | Portal::Exits) {
// As a person answers the dialog: after the caller has the path returned.
std::thread::sleep(std::time::Duration::from_millis(300));
}
+ if matches!(portal, Portal::Exits | Portal::ExitsAtOnce) {
+ break;
+ }
if portal != Portal::OldEarly && respond().is_err() {
break;
}
+ if portal == Portal::AnswersAndExits {
+ break;
+ }
}
+ // The portal leaves the bus: the bus sends `NameOwnerChanged` for its name.
+ let _ = server.close();
});
let client = zbus::blocking::connection::Builder::address(address.as_str()).ok()?.build().ok()?;
Some((client, seen, daemon))
@@ -589,7 +575,7 @@ mod tests {
#[test]
fn the_portal_is_asked_on_the_first_file_type_and_answers_the_one_picked() {
let script = vec![(0, Some(("file:///home/me/Art/poster", "PDF-compatible .ai"))), (1, None)];
- let Some((conn, seen, mut daemon)) = mock_portal(script, Portal::Current) else {
+ let Some((conn, seen, _daemon)) = mock_portal(script, Portal::Current) else {
eprintln!("no dbus-daemon: skipped");
return;
};
@@ -599,7 +585,6 @@ mod tests {
assert_eq!(save_within(&conn, &request), Ok(None));
let seen = seen.lock().map(|s| s.clone()).unwrap_or_default();
assert_eq!(seen.first(), Some(&(Some("Untitled-1.vectorcraft".to_string()), Some(request.filters[0].0.clone()))));
- let _ = daemon.kill();
}
/// A portal older than 0.9 answers on the request path it returns, and the answer is awaited
@@ -607,13 +592,12 @@ mod tests {
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
#[test]
fn the_answer_is_awaited_on_the_path_the_portal_returns() {
- let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some(("file:///tmp/card", "SVG")))], Portal::Old) else {
+ let Some((conn, _seen, _daemon)) = mock_portal(vec![(0, Some(("file:///tmp/card", "SVG")))], Portal::Old) else {
eprintln!("no dbus-daemon: skipped");
return;
};
let request = request();
assert_eq!(pick_answer(save_within(&conn, &request).unwrap(), &request), ["/tmp/card", "svg"]);
- let _ = daemon.kill();
}
/// A portal older than 0.9 can answer before its reply names the request path: the answer is
@@ -622,14 +606,41 @@ mod tests {
#[test]
fn an_answer_that_comes_before_the_path_is_kept() {
let script = vec![(0, Some(("file:///tmp/card", "SVG"))), (1, None)];
- let Some((conn, _seen, mut daemon)) = mock_portal(script, Portal::OldEarly) else {
+ let Some((conn, _seen, _daemon)) = mock_portal(script, Portal::OldEarly) else {
eprintln!("no dbus-daemon: skipped");
return;
};
let request = request();
assert_eq!(pick_answer(save_within(&conn, &request).unwrap(), &request), ["/tmp/card", "svg"]);
assert_eq!(save_within(&conn, &request), Ok(None));
- let _ = daemon.kill();
+ }
+
+ /// A portal that leaves the bus after its reply without answering, while its dialog is open or
+ /// at once, ends the wait with an error, and rfd shows the dialog.
+ #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
+ #[test]
+ fn a_portal_that_exits_during_the_dialog_ends_the_wait() {
+ for portal in [Portal::Exits, Portal::ExitsAtOnce] {
+ let Some((conn, _seen, _daemon)) = mock_portal(vec![(0, Some(("file:///tmp/card", "SVG")))], portal) else {
+ eprintln!("no dbus-daemon: skipped");
+ return;
+ };
+ assert_eq!(save_within(&conn, &request()), Err("the desktop portal disappeared while waiting for its answer".to_string()));
+ }
+ }
+
+ /// A portal that answers and then leaves the bus: the save reads the answer. Whether the answer
+ /// or the bus's report comes first depends on timing here; `portal_request`'s
+ /// `a_queued_answer_wins_over_the_portal_leaving` queues both before the wait.
+ #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
+ #[test]
+ fn an_answer_sent_just_before_the_portal_exits_is_kept() {
+ let Some((conn, _seen, _daemon)) = mock_portal(vec![(0, Some(("file:///tmp/card", "SVG")))], Portal::AnswersAndExits) else {
+ eprintln!("no dbus-daemon: skipped");
+ return;
+ };
+ let request = request();
+ assert_eq!(pick_answer(save_within(&conn, &request).unwrap(), &request), ["/tmp/card", "svg"]);
}
/// The labels go to the dialog in the interface's language, which is how the answer is matched.
diff --git a/apps/vectorcraft/src/tests.rs b/apps/vectorcraft/src/tests.rs
index 6e61cbe31f..6ae80944a6 100644
--- a/apps/vectorcraft/src/tests.rs
+++ b/apps/vectorcraft/src/tests.rs
@@ -460,3 +460,21 @@ fn the_linux_desktop_entry_opens_every_readable_format() {
}
}
}
+
+/// On Linux and the BSDs, quitting hides the main window before eframe drops the app, whose
+/// clipboard handles may take up to `CLIPBOARD_PROBE_STOP_WAIT` to let go.
+#[cfg(not(any(target_os = "macos", windows)))]
+#[test]
+fn quitting_hides_the_window_before_the_clipboards_let_go() {
+ struct Window(std::cell::Cell);
+ impl QuittingWindow for Window {
+ fn hide(&self) {
+ self.0.set(self.0.get() + 1);
+ }
+ }
+ let window = Window(std::cell::Cell::new(0));
+ hide_before_teardown(Some(&window));
+ assert_eq!(window.0.get(), 1, "the window was not hidden");
+ // Without a window (none was made), there is nothing to hide.
+ hide_before_teardown::(None);
+}
diff --git a/crates/color/src/cms.rs b/crates/color/src/cms.rs
index 74ac2d50d5..f4297343a6 100644
--- a/crates/color/src/cms.rs
+++ b/crates/color/src/cms.rs
@@ -533,7 +533,7 @@ impl Cms {
}
}
- /// Gamut warning: can this colour be printed in the working CMYK space?
+ /// Gamut warning: can this color be printed in this context's CMYK space?
pub fn out_of_gamut(&self, c: &Color) -> bool {
self.gamut_error(c) > GAMUT_THRESHOLD
}
diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs
index e49478eab7..54cb50c197 100644
--- a/crates/color/src/gradient.rs
+++ b/crates/color/src/gradient.rs
@@ -65,24 +65,62 @@ pub enum GradientMethod {
/// Mixed in a perceptual colour space (OKLab), so the steps look even to the eye: blue to
/// yellow passes through a light blue-grey rather than a dull mid grey.
Perceptual,
+ /// Mixed around the hue wheel, clockwise as the color wheels draw it: the hue angle falls
+ /// from each stop to the next (red, magenta, blue, cyan, green, yellow, red), and saturation
+ /// and brightness change evenly ([`hue_mix`]).
+ HueClockwise,
+ /// Mixed around the hue wheel the other way: the hue angle rises from each stop to the next
+ /// (red, yellow, green, cyan, blue, magenta, red).
+ HueCounterclockwise,
}
impl GradientMethod {
- /// Parse a method name (`classic`, `perceptual`; any case).
+ /// Every method, in the order the Gradient panel lists them.
+ pub const ALL: [GradientMethod; 4] = [Self::Classic, Self::Perceptual, Self::HueClockwise, Self::HueCounterclockwise];
+ /// Parse a method name (`classic`, `perceptual`, `hue-clockwise`, `hue-counterclockwise`;
+ /// any case, and the hyphen may be a space or an underscore, or left out). Any other text,
+ /// one with a separator elsewhere included, is `None`.
pub fn parse(s: &str) -> Option {
- [Self::Classic, Self::Perceptual].into_iter().find(|m| m.label().eq_ignore_ascii_case(s))
+ let name = s.to_ascii_lowercase();
+ Self::ALL.into_iter().find(|m| ["-", " ", "_", ""].iter().any(|sep| m.id().replace('-', sep) == name))
}
pub fn label(self) -> &'static str {
match self {
Self::Classic => "Classic",
Self::Perceptual => "Perceptual",
+ Self::HueClockwise => "Hue Clockwise",
+ Self::HueCounterclockwise => "Hue Counterclockwise",
+ }
+ }
+ /// The method's name in command params (`classic`, `perceptual`, `hue-clockwise`,
+ /// `hue-counterclockwise`).
+ pub fn id(self) -> &'static str {
+ match self {
+ Self::Classic => "classic",
+ Self::Perceptual => "perceptual",
+ Self::HueClockwise => "hue-clockwise",
+ Self::HueCounterclockwise => "hue-counterclockwise",
}
}
pub fn is_classic(&self) -> bool {
*self == Self::Classic
}
- /// `a` and `b` mixed at `t` (0..1): in display RGB (Classic), or in OKLab (Perceptual: an
- /// RGB colour between the two).
+ /// Does it mix around the hue wheel (Hue Clockwise or Hue Counterclockwise)? The colors
+ /// between two stops then are not mixes of the stops' inks.
+ pub fn is_hue(&self) -> bool {
+ matches!(self, Self::HueClockwise | Self::HueCounterclockwise)
+ }
+ /// The method that mixes `b` to `a` through the colors this one mixes `a` to `b` through: a
+ /// hue method turned the other way, Classic and Perceptual as they are.
+ pub fn mirrored(self) -> Self {
+ match self {
+ Self::HueClockwise => Self::HueCounterclockwise,
+ Self::HueCounterclockwise => Self::HueClockwise,
+ m => m,
+ }
+ }
+ /// `a` and `b` mixed at `t` (0..1): in display RGB (Classic), in OKLab (Perceptual: an
+ /// RGB colour between the two), or around the hue wheel ([`hue_mix`]: an RGB colour).
pub fn mix(self, a: &Color, b: &Color, t: f32) -> Color {
match self {
Self::Classic => Color::lerp(a, b, t),
@@ -93,10 +131,68 @@ impl GradientMethod {
let c = |v: f64| if v.is_finite() { v.clamp(0.0, 1.0) as f32 } else { 0.0 };
Color::rgb(c(r), c(g), c(bl))
}
+ Self::HueClockwise => hue_mix(a, b, t, false),
+ Self::HueCounterclockwise => hue_mix(a, b, t, true),
}
}
}
+/// The brightness (0..1) at or below which a color is black for [`hue_mix`], and the spread
+/// between its largest and smallest RGB numbers (saturation × brightness) at or below which it
+/// has no hue of its own: under half a color level.
+const NO_HUE: f32 = 0.4 / 255.0;
+
+/// The hue difference (degrees) that moves an RGB number by half a color level in a color whose
+/// largest and smallest RGB numbers are 1 apart; a color with a smaller spread takes
+/// proportionally more degrees. [`hue_mix`] counts two hues closer than this as one hue.
+const SAME_HUE: f32 = 60.0 * 0.5 / 255.0;
+
+/// `a` and `b` mixed at `t` (0..1) around the hue wheel, as display RGB: hue (degrees),
+/// saturation and brightness each change evenly from `a`'s to `b`'s. The hue angle only rises
+/// (`rising`) or only falls, by the fixed-direction rules of
+/// , and two equal hues stay where they are.
+/// Two hues that differ by less than half a color level ([`SAME_HUE`]) count as equal and take the
+/// short way, so a color and a tint or shade of it, whose computed hues differ by rounding, do
+/// not turn the whole wheel. A color whose RGB numbers are less than half a color level apart
+/// ([`NO_HUE`]) has no hue and takes the other color's; black has no saturation either and takes
+/// the other color's too. Two colors without a hue mix as grays. A `t` that is not a number
+/// gives `a`.
+pub fn hue_mix(a: &Color, b: &Color, t: f32, rising: bool) -> Color {
+ let t = if t.is_nan() { 0.0 } else { t.clamp(0.0, 1.0) };
+ let ([mut h0, mut s0, v0], [mut h1, mut s1, v1]) = (a.to_hsb(), b.to_hsb());
+ let (black0, black1) = (v0 <= NO_HUE, v1 <= NO_HUE);
+ // The spread between each color's own largest and smallest RGB numbers.
+ let (spread0, spread1) = (s0 * v0, s1 * v1);
+ // Black takes the other color's saturation, so it darkens that color rather than graying it.
+ if black0 && !black1 {
+ s0 = s1;
+ } else if black1 && !black0 {
+ s1 = s0;
+ }
+ let (gray0, gray1) = (black0 || spread0 <= NO_HUE, black1 || spread1 <= NO_HUE);
+ match (gray0, gray1) {
+ (true, _) => h0 = h1,
+ (false, true) => h1 = h0,
+ (false, false) => {
+ // The short way from the first hue to the second, -180..180.
+ let short = (h1 - h0 + 180.0).rem_euclid(360.0) - 180.0;
+ h1 = if short.abs() * spread0.min(spread1) <= SAME_HUE {
+ h0 + short
+ } else if rising {
+ h0 + short.rem_euclid(360.0)
+ } else {
+ h0 + short.rem_euclid(360.0) - 360.0
+ };
+ }
+ }
+ let at = |p: f32, q: f32| p + (q - p) * t;
+ let (h, s, v) = (at(h0, h1), at(s0, s1), at(v0, v1));
+ if !(h.is_finite() && s.is_finite() && v.is_finite()) {
+ return Color::rgb(0.0, 0.0, 0.0);
+ }
+ Color::from_hsb(h.rem_euclid(360.0), s.clamp(0.0, 1.0), v.clamp(0.0, 1.0))
+}
+
/// What a linear, radial, conic or diamond gradient paints past the ends of its stops (the
/// Gradient panel's Spread). With Repeat and Reflect, the stops run [`Gradient::repeats`] times
/// along the vector (around the circle, for a conic), and keep going past both of its ends.
@@ -192,10 +288,11 @@ const MAX_INK_TINTS: usize = 4096;
const INK_MIX_STEPS: u32 = 64;
/// The mix positions (0..1, after `v0`, before `v1`) between which display RGB mixed linearly
-/// stays within [`PERCEPTUAL_TOLERANCE`] of the perceptual mix of `a` and `b`: the span halved
-/// where a straight line strays, at most [`PERCEPTUAL_DEPTH`] times, in order.
-fn perceptual_steps(a: &Color, b: &Color, (v0, v1): (f32, f32), depth: u32, out: &mut Vec) {
- let at = |v: f32| GradientMethod::Perceptual.mix(a, b, v).to_rgb();
+/// stays within [`PERCEPTUAL_TOLERANCE`] of `method`'s mix of `a` and `b` (Perceptual, or around
+/// the hue wheel): the span halved where a straight line strays, at most [`PERCEPTUAL_DEPTH`]
+/// times, in order.
+fn perceptual_steps(method: GradientMethod, a: &Color, b: &Color, (v0, v1): (f32, f32), depth: u32, out: &mut Vec) {
+ let at = |v: f32| method.mix(a, b, v).to_rgb();
let (c0, c1) = (at(v0), at(v1));
let stray = [0.25, 0.5, 0.75]
.into_iter()
@@ -206,19 +303,20 @@ fn perceptual_steps(a: &Color, b: &Color, (v0, v1): (f32, f32), depth: u32, out:
.fold(0.0, f32::max);
if depth < PERCEPTUAL_DEPTH && stray > PERCEPTUAL_TOLERANCE {
let m = (v0 + v1) / 2.0;
- perceptual_steps(a, b, (v0, m), depth + 1, out);
+ perceptual_steps(method, a, b, (v0, m), depth + 1, out);
out.push(m);
- perceptual_steps(a, b, (m, v1), depth + 1, out);
+ perceptual_steps(method, a, b, (m, v1), depth + 1, out);
}
}
-/// The mix positions (`v`, 0..1) of the stops a Perceptual gradient's curve has between stops `a`
-/// and `b`, in order: [`perceptual_steps`] on either side of the midpoint, and the midpoint (½).
-fn perceptual_mixes(a: &GradientStop, b: &GradientStop) -> Vec {
+/// The mix positions (`v`, 0..1) of the stops the curve of a gradient mixed by `method`
+/// (Perceptual, or around the hue wheel) has between stops `a` and `b`, in order:
+/// [`perceptual_steps`] on either side of the midpoint, and the midpoint (½).
+fn perceptual_mixes(method: GradientMethod, a: &GradientStop, b: &GradientStop) -> Vec {
let mut vs = Vec::new();
- perceptual_steps(&a.color, &b.color, (0.0, 0.5), 1, &mut vs);
+ perceptual_steps(method, &a.color, &b.color, (0.0, 0.5), 1, &mut vs);
vs.push(0.5);
- perceptual_steps(&a.color, &b.color, (0.5, 1.0), 1, &mut vs);
+ perceptual_steps(method, &a.color, &b.color, (0.5, 1.0), 1, &mut vs);
vs
}
@@ -512,10 +610,11 @@ impl Gradient {
};
(t, c, o, Some(s.midpoint), None)
});
- // Perceptual: stops along the mix (`v`) where a straight line would stray from it, the
- // midpoint (v = ½) among them, placed through the midpoint as [`Self::sample`] maps them.
+ // Perceptual and the hue methods: stops along the mix (`v`) where a straight line would
+ // stray from it, the midpoint (v = ½) among them, placed through the midpoint as
+ // [`Self::sample`] maps them.
let perceptual = next.filter(|_| !self.method.is_classic()).map(|n| {
- perceptual_mixes(s, n).into_iter().map(move |v| {
+ perceptual_mixes(self.method, s, n).into_iter().map(move |v| {
let mid = v == 0.5;
let t = curve_offset(s, n, v);
let o = s.opacity + (n.opacity - s.opacity) * v;
@@ -571,11 +670,12 @@ impl Gradient {
/// position itself when the two colors' lightness differs by less than
/// [`MIN_INK_LIGHTNESS_SPAN`], or when the display RGB mix's lightness turns back somewhere
/// on the way from one color's lightness to the other's (checked at [`INK_MIX_STEPS`] even
- /// steps), as from red to blue. `None` for a Classic gradient, and when the curve has more
- /// than [`MAX_INK_TINTS`] stops ([`Self::fits_ink_tints`]).
+ /// steps), as from red to blue. `None` for a Classic gradient, for one mixed around the hue
+ /// wheel (its colors between two stops are not mixes of the stops' inks), and when the curve
+ /// has more than [`MAX_INK_TINTS`] stops ([`Self::fits_ink_tints`]).
pub fn ink_mixes(&self) -> Option> {
use crate::libraries::srgb_to_oklab;
- if self.method.is_classic() || !self.fits_ink_tints() {
+ if self.method != GradientMethod::Perceptual || !self.fits_ink_tints() {
return None;
}
let lightness = |c: &Color| srgb_to_oklab(c.to_rgb())[0];
@@ -592,7 +692,7 @@ impl Gradient {
.collect::>()
.windows(2)
.all(|w| (w[1] - w[0]) * (l1 - l0) >= 0.0);
- for v in perceptual_mixes(s, n) {
+ for v in perceptual_mixes(self.method, s, n) {
let weight = if by_position {
v
} else {
@@ -650,8 +750,10 @@ impl Gradient {
Some(Ramp { stops })
}
/// The same ramp the other way round: the stops in reverse order at mirrored offsets, each
- /// segment's midpoint mirrored with it.
+ /// segment's midpoint mirrored with it, and a hue method turned the other way (red to blue
+ /// through magenta, reversed, is blue to red through magenta).
pub fn reverse(&mut self) {
+ self.method = self.method.mirrored();
self.stops.reverse();
for i in 0..self.stops.len() {
// A midpoint belongs to the segment after its stop: reversed, stop i's segment is the
@@ -666,6 +768,13 @@ impl Gradient {
pub fn sort(&mut self) {
self.stops.sort_by(|a, b| a.offset.total_cmp(&b.offset));
}
+ /// The gradient's colors as a Classic gradient: its curve's stops ([`Self::expanded_stops`]) as
+ /// stops of their own, mixed in display RGB, within [`PERCEPTUAL_TOLERANCE`] of it. The stops
+ /// keep no swatch links, tints or process plates.
+ pub fn as_classic_curve(&self) -> Gradient {
+ let stops = self.expanded().map(|(t, color, opacity, _)| GradientStop { opacity, ..GradientStop::new(t, color) }).collect();
+ Gradient { kind: self.kind, stops, dither: self.dither, method: GradientMethod::Classic, spread: self.spread, repeats: self.repeats }
+ }
/// The part of the gradient from `from` to `to` (0..1, either way round) as a linear gradient
/// of its own running 0 → 1: the same colours (midpoints become stops, as in
/// [`Self::expanded_stops`]). One colour throughout when `from == to`.
@@ -1261,8 +1370,9 @@ impl GradientPaint {
}
/// Is this gradient drawn as pixels rather than as a vector gradient when it paints a fill
/// (`fill`) or a stroke (SVG and PDF draw such gradients as images): a freeform gradient, a
- /// dithered fill (dither applies to fills only), or a Perceptual one (its colours don't mix
- /// linearly between the stops, as SVG's and PDF's gradients do)?
+ /// dithered fill (dither applies to fills only), or a Perceptual one or one mixed around the
+ /// hue wheel (its colours don't mix linearly between the stops, as SVG's and PDF's gradients
+ /// do)?
pub fn draws_as_pixels(&self, fill: bool) -> bool {
self.gradient.kind == GradientKind::Freeform || (fill && self.gradient.dither) || !self.gradient.method.is_classic()
}
diff --git a/crates/color/src/gradient/spread.rs b/crates/color/src/gradient/spread.rs
index 8f9933c40c..fa111586ba 100644
--- a/crates/color/src/gradient/spread.rs
+++ b/crates/color/src/gradient/spread.rs
@@ -59,6 +59,9 @@ impl Gradient {
if !self.is_spread() || matches!(kind, GradientKind::Conic | GradientKind::Freeform) || self.stops.is_empty() {
return None;
}
+ if self.mirrors_a_hue_turn() {
+ return self.as_classic_curve().padded_within(geom, area, max_stops);
+ }
let period = geom.period(kind, self.repeat_count());
let corners = [Point::new(area.x0, area.y0), Point::new(area.x1, area.y0), Point::new(area.x1, area.y1), Point::new(area.x0, area.y1)];
let ts = corners.map(|p| period.param_at(kind, p));
@@ -128,6 +131,9 @@ impl Gradient {
if self.stops.is_empty() {
return Cow::Owned(Gradient { spread: GradientSpread::Pad, repeats: 1, ..self.clone() });
}
+ if self.mirrors_a_hue_turn() {
+ return Cow::Owned(self.as_classic_curve().unrolled_within(max_stops).into_owned());
+ }
let n = self.repeat_count() as usize;
let budget = max_stops.min(MAX_SPREAD_STOPS);
let kept = n.min(budget / 2).max(1);
@@ -159,12 +165,22 @@ impl Gradient {
if !self.is_spread() || self.stops.is_empty() {
return None;
}
+ if self.mirrors_a_hue_turn() {
+ return self.as_classic_curve().cycle();
+ }
let one = whole_run(&self.stops);
let runs = if self.spread == GradientSpread::Reflect { 2 } else { 1 };
let stops = self.runs(&one, false, 0.0, runs, runs as f64);
Some((Gradient { stops, spread: GradientSpread::Pad, repeats: 1, ..self.clone() }, runs as u32))
}
+ /// Does the gradient reflect while mixing around the hue wheel? A mirrored run of its stops
+ /// would turn the wheel the other way, and one gradient has one method, so its runs are
+ /// written from its curve's stops ([`Self::as_classic_curve`]), which mirror as they are.
+ fn mirrors_a_hue_turn(&self) -> bool {
+ self.method.is_hue() && self.spread == GradientSpread::Reflect
+ }
+
/// `n` runs of `one` (one run of the stops, [`whole_run`]) on a line of `of` runs from 0 to 1,
/// the first of them `start` runs along it and an odd-numbered run when `first_odd` (with
/// Reflect, the odd-numbered runs are mirrored).
diff --git a/crates/color/src/gradient/spread/tests.rs b/crates/color/src/gradient/spread/tests.rs
index a88319ccaa..ab088293da 100644
--- a/crates/color/src/gradient/spread/tests.rs
+++ b/crates/color/src/gradient/spread/tests.rs
@@ -118,6 +118,46 @@ fn reflect_mirrors_every_other_run_and_its_midpoints() {
assert!((p.gradient.stops[2].midpoint - 0.7).abs() < 1e-6, "{:?}", p.gradient.stops);
}
+/// A gradient mixed around the hue wheel that reflects: a mirrored run turns the wheel back the
+/// way it came, so every point keeps its color when the runs are written out as a gradient that
+/// pads, unrolled along the vector, or as one cycle, with a repeating one's too.
+#[test]
+fn reflected_and_repeated_hue_turns_keep_every_color() {
+ let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None };
+ let stops = || {
+ let mut red = GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0));
+ red.midpoint = 0.3;
+ vec![red, GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]
+ };
+ let near = |a: Color, b: Color, what: &str| {
+ assert!(a.to_rgb().iter().zip(b.to_rgb()).all(|(x, y)| (x - y).abs() < 1.0 / 255.0), "{what}: {a:?} vs {b:?}");
+ };
+ for method in [GradientMethod::HueClockwise, GradientMethod::HueCounterclockwise] {
+ for spread in [GradientSpread::Reflect, GradientSpread::Repeat] {
+ let g = Gradient { method, spread, repeats: 2, ..Gradient::new(GradientKind::Linear, stops()) };
+ let p = g.padded_over(&geom, Rect::new(-10.0, 0.0, 20.0, 10.0)).unwrap();
+ assert_eq!((p.gradient.spread, p.gradient.repeats), (GradientSpread::Pad, 1));
+ for k in 0..300 {
+ let q = Point::new(-10.0 + f64::from(k) * 0.1 + 0.013, 5.0);
+ let want = g.sample(g.spread_param(geom.param_at(GradientKind::Linear, q)) as f32).0;
+ let got = p.gradient.sample(p.geom.param_at(GradientKind::Linear, q) as f32).0;
+ near(want, got, &format!("{method:?} {spread:?} padded at {}", q.x));
+ }
+ let unrolled = g.unrolled();
+ let (cycle, runs) = g.cycle().unwrap();
+ for k in 0..=200 {
+ let t = (k as f32 + 0.37) / 201.0;
+ let want = g.sample(g.spread_param(f64::from(t)) as f32).0;
+ near(want, unrolled.sample(t).0, &format!("{method:?} {spread:?} unrolled at {t}"));
+ // One cycle covers `runs` of the two runs between 0 and 1.
+ let along = t * runs as f32 / 2.0;
+ let want = g.sample(g.spread_param(f64::from(along)) as f32).0;
+ near(want, cycle.sample(t).0, &format!("{method:?} {spread:?} cycle at {t}"));
+ }
+ }
+ }
+}
+
#[test]
fn stops_inside_a_run_hold_their_colors_to_its_ends() {
let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None };
diff --git a/crates/color/src/gradient/tests.rs b/crates/color/src/gradient/tests.rs
index 0709d3068b..cd25753a05 100644
--- a/crates/color/src/gradient/tests.rs
+++ b/crates/color/src/gradient/tests.rs
@@ -514,6 +514,283 @@ fn perceptual_gradients_draw_as_pixels() {
}
}
+fn two_stops(a: Color, b: Color, method: GradientMethod) -> Gradient {
+ let mut g = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, a), GradientStop::new(1.0, b)]);
+ g.method = method;
+ g
+}
+
+/// The hue methods mix around the hue wheel in one direction: Hue Counterclockwise raises the
+/// hue angle from each stop to the next and Hue Clockwise lowers it, by the fixed-direction rules
+/// of (the angles below are that
+/// section's own examples), with saturation and brightness changing evenly.
+#[test]
+fn hue_methods_turn_one_way_around_the_wheel() {
+ let (red, blue) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0));
+ let rising = two_stops(red, blue, GradientMethod::HueCounterclockwise);
+ assert_eq!(rising.sample(0.0).0.to_hex(), "#ff0000");
+ assert_eq!(rising.sample(0.25).0.to_hex(), "#ffff00", "60°");
+ assert_eq!(rising.sample(0.5).0.to_hex(), "#00ff00", "120°");
+ assert_eq!(rising.sample(1.0).0.to_hex(), "#0000ff");
+ let falling = two_stops(red, blue, GradientMethod::HueClockwise);
+ assert_eq!(falling.sample(0.0).0.to_hex(), "#ff0000");
+ assert_eq!(falling.sample(0.5).0.to_hex(), "#ff00ff", "300°");
+ assert_eq!(falling.sample(1.0).0.to_hex(), "#0000ff");
+ assert_eq!(two_stops(red, blue, GradientMethod::Classic).sample(0.5).0.to_rgb(), [0.5, 0.0, 0.5], "classic: the RGB mix");
+ // 30° to 190° and to 230°: halfway is 110° and 130° rising, 290° and 310° falling.
+ for (to, up, down) in [(190.0, 110.0, 290.0), (230.0, 130.0, 310.0)] {
+ let (a, b) = (Color::from_hsb(30.0, 0.5, 0.8), Color::from_hsb(to, 0.9, 0.4));
+ for (method, want) in [(GradientMethod::HueCounterclockwise, up), (GradientMethod::HueClockwise, down)] {
+ let [h, s, v] = method.mix(&a, &b, 0.5).to_hsb();
+ assert!((h - want).abs() < 0.05, "{method:?} to {to}: hue {h}, not {want}");
+ assert!((s - 0.7).abs() < 1e-3 && (v - 0.6).abs() < 1e-3, "{method:?}: saturation {s}, brightness {v}");
+ }
+ }
+ // The other way round, each method takes the arc the other took.
+ let back = two_stops(blue, red, GradientMethod::HueClockwise);
+ assert_eq!(back.sample(0.5).0.to_hex(), "#00ff00");
+ // A position that is not a number gives the first color.
+ assert_eq!(hue_mix(&red, &blue, f32::NAN, true).to_hex(), "#ff0000");
+}
+
+/// A color without a hue does not turn the wheel: a gray takes the hue of the stop it mixes
+/// with, black its saturation too, two grays mix as grays, and two stops of one hue keep that
+/// hue, whichever way the method turns.
+#[test]
+fn hue_methods_keep_the_hue_where_a_stop_has_none() {
+ let red = Color::rgb(1.0, 0.0, 0.0);
+ let close = |c: Color, want: [f32; 3], what: &str| {
+ let got = c.to_rgb();
+ assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-3), "{what}: {got:?}, not {want:?}");
+ };
+ let green = Color::rgb(0.0, 1.0, 0.0);
+ for method in [GradientMethod::HueClockwise, GradientMethod::HueCounterclockwise] {
+ close(method.mix(&Color::WHITE, &green, 0.5), [0.5, 1.0, 0.5], "white to green: a light green");
+ close(method.mix(&green, &Color::WHITE, 0.5), [0.5, 1.0, 0.5], "green to white: a light green");
+ close(method.mix(&Color::rgb(0.5, 0.5, 0.5), &green, 0.5), [0.375, 0.75, 0.375], "gray to green");
+ close(method.mix(&Color::BLACK, &green, 0.5), [0.0, 0.5, 0.0], "black to green: a dark green, not a gray green");
+ close(method.mix(&green, &Color::BLACK, 0.25), [0.0, 0.75, 0.0], "green to black");
+ close(method.mix(&Color::BLACK, &Color::WHITE, 0.5), [0.5, 0.5, 0.5], "black to white: gray");
+ close(method.mix(&Color::BLACK, &Color::BLACK, 0.5), [0.0, 0.0, 0.0], "black to black");
+ let dark = Color::rgb(0.5, 0.0, 0.0);
+ close(method.mix(&red, &dark, 0.5), [0.75, 0.0, 0.0], "one hue: a red between the two");
+ close(method.mix(&red, &red, 0.5), [1.0, 0.0, 0.0], "one color stays");
+ // One hue with saturation and brightness both changing: the hue stays, and the color is
+ // the even mix of saturation and brightness, which is not Classic's RGB mix.
+ let pink = Color::rgb(1.0, 0.8, 0.8);
+ close(method.mix(&dark, &pink, 0.5), [0.75, 0.3, 0.3], "dark red to pink");
+ assert!((Color::lerp(&dark, &pink, 0.5).to_rgb()[1] - 0.4).abs() < 1e-3, "classic differs");
+ // A color whose RGB numbers are under half a color level apart has no hue of its own: a
+ // near gray leaning blue takes green's hue rather than turning from blue.
+ let near_gray = Color::rgb(0.5, 0.5, 0.5 + 0.3 / 255.0);
+ let [h, _, _] = method.mix(&near_gray, &green, 0.5).to_hsb();
+ assert!((h - 120.0).abs() < 0.5, "{method:?}: near gray to green passes hue {h}");
+ // One a whole color level apart has its hue (240°) and turns from it.
+ let bluish = Color::rgb(128.0 / 255.0, 128.0 / 255.0, 129.0 / 255.0);
+ let [h, _, _] = method.mix(&bluish, &green, 0.5).to_hsb();
+ let want = if method == GradientMethod::HueClockwise { 180.0 } else { 0.0 };
+ assert!(((h - want + 180.0).rem_euclid(360.0) - 180.0).abs() < 0.5, "{method:?}: bluish gray to green passes hue {h}, not {want}");
+ }
+}
+
+/// Hues that differ only by rounding are one hue: a color and a tint or shade of it, whose
+/// computed hue angles differ in their last digits, mix without turning the wheel, with either
+/// method, and a stop added to a ramp leaves the ramp's colors as they were. Hues a whole color
+/// level apart still turn.
+#[test]
+fn hue_methods_take_hues_within_half_a_color_level_as_one() {
+ let level = |n: u8| f32::from(n) / 255.0;
+ let rgb8 = |r: u8, g: u8, b: u8| Color::rgb(level(r), level(g), level(b));
+ let methods = [GradientMethod::HueClockwise, GradientMethod::HueCounterclockwise];
+ // Colors against their 50% tint toward white and a shade at 40% brightness. At least one
+ // pair's computed hues differ, or the test checks nothing.
+ let colors = [rgb8(60, 253, 230), rgb8(241, 194, 107), rgb8(68, 32, 130), rgb8(13, 200, 77), rgb8(250, 3, 201), rgb8(90, 91, 200)];
+ let mut uneven = 0;
+ for c in colors {
+ let [h, s, v] = c.to_hsb();
+ let tint = Color::lerp(&c, &Color::WHITE, 0.5);
+ let shade = Color::from_hsb(h, s, v * 0.4);
+ for other in [tint, shade] {
+ let [h1, _, _] = other.to_hsb();
+ assert!((h1 - h).abs() < 0.01, "{c:?} and {other:?} are one hue: {h} and {h1}");
+ uneven += usize::from(h1 != h);
+ for method in methods {
+ for (a, b) in [(c, other), (other, c)] {
+ for k in 0..=20 {
+ let [hm, _, _] = method.mix(&a, &b, k as f32 / 20.0).to_hsb();
+ let off = (hm - h + 180.0).rem_euclid(360.0) - 180.0;
+ assert!(off.abs() < 0.5, "{method:?} {a:?} → {b:?} at {k}/20: hue {hm}, not {h}");
+ }
+ }
+ }
+ }
+ }
+ assert!(uneven > 0, "no pair's hues differ by rounding");
+ // Across 0°: 359.99° and 0.01° are one hue too.
+ for method in methods {
+ let [h, _, _] = method.mix(&Color::from_hsb(359.99, 1.0, 1.0), &Color::from_hsb(0.01, 1.0, 1.0), 0.5).to_hsb();
+ assert!(!(1.0..=359.0).contains(&h), "{method:?} across 0°: {h}");
+ }
+ // A stop added to a ramp samples the ramp's color there, and the ramp keeps its colors:
+ // white to red, a color to its tint, and a turn from red to blue.
+ let ramps = [
+ (Color::WHITE, rgb8(255, 0, 0)),
+ (rgb8(60, 253, 230), Color::lerp(&rgb8(60, 253, 230), &Color::WHITE, 0.5)),
+ (rgb8(255, 0, 0), rgb8(0, 0, 255)),
+ ];
+ for method in methods {
+ for (a, b) in ramps {
+ let g = two_stops(a, b, method);
+ for at in [0.21, 0.5, 0.83] {
+ let (stops, i) = insert_stop(&g, at);
+ assert_eq!((stops.len(), i), (3, 1));
+ let with = Gradient { stops, ..g.clone() };
+ for k in 0..=200 {
+ let t = k as f32 / 200.0;
+ let (was, now) = (g.sample(t).0.to_rgb(), with.sample(t).0.to_rgb());
+ let off = was.iter().zip(now).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max);
+ assert!(off < 1.0 / 255.0, "{method:?} {a:?} → {b:?}, a stop at {at}: {was:?} became {now:?} at {t}");
+ }
+ }
+ }
+ }
+ // One color level of hue is a hue of its own: red to a red one level toward yellow turns
+ // nearly the whole wheel one way and stays put the other.
+ let (red, next) = (rgb8(255, 0, 0), rgb8(255, 1, 0));
+ let [h, _, _] = GradientMethod::HueClockwise.mix(&red, &next, 0.5).to_hsb();
+ assert!((h - 180.0).abs() < 1.0, "the long way: {h}");
+ let [h, _, _] = GradientMethod::HueCounterclockwise.mix(&red, &next, 0.5).to_hsb();
+ assert!(h < 1.0, "the short way: {h}");
+}
+
+/// Renderers mix linearly between stops: a hue segment becomes stops on its way around the
+/// wheel, close enough that the linear ramp between them stays within half a color level of it,
+/// through the midpoint and with the opacity changing evenly.
+#[test]
+fn hue_segments_expand_into_stops_on_the_curve() {
+ let pairs = [
+ (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)),
+ (Color::rgb(1.0, 0.0, 0.0), Color::rgb(1.0, 0.02, 0.0)),
+ (Color::from_hsb(30.0, 0.5, 0.8), Color::from_hsb(190.0, 0.9, 0.4)),
+ (Color::cmyk(0.0, 0.9, 0.9, 0.0), Color::WHITE),
+ (Color::BLACK, Color::rgb(0.0, 1.0, 0.0)),
+ ];
+ for method in [GradientMethod::HueClockwise, GradientMethod::HueCounterclockwise] {
+ for (a, b) in pairs {
+ let mut g = two_stops(a, b, method);
+ g.stops[0].midpoint = 0.3;
+ g.stops[1].opacity = 0.5;
+ let ex: Vec<_> = g.expanded().collect();
+ assert!((3..=600).contains(&ex.len()), "{method:?} {a:?} → {b:?}: {} stops", ex.len());
+ assert!(ex.windows(2).all(|w| w[0].0 < w[1].0), "in order");
+ assert!(ex.iter().any(|s| s.3 == Some(0.3) && (s.0 - 0.3).abs() < 1e-6), "a stop stands for the midpoint");
+ let ramp = Ramp::new(&g, &Color::to_rgb);
+ for k in 0..=4000 {
+ let t = k as f32 / 4000.0;
+ let (c, o) = g.sample(t);
+ let [r, gr, bl] = c.to_rgb();
+ let off = [r, gr, bl, o].iter().zip(ramp.at(t)).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max);
+ assert!(off < 0.5 / 255.0, "{method:?} {a:?} → {b:?} at {t}: {off}");
+ }
+ assert_eq!(g.sample_keep_model(0.4), g.sample(0.4), "one mix");
+ }
+ }
+}
+
+/// Reversing a gradient gives the same ramp the other way round: a hue method turns the other
+/// way with the stops, so every color keeps its place mirrored, and reversing twice gives the
+/// gradient back. Classic and Perceptual keep their method. A backward span and the curve as a
+/// Classic gradient hold the same colors.
+#[test]
+fn reversing_a_hue_gradient_keeps_its_colors_mirrored() {
+ let near = |a: Color, b: Color, what: &str| {
+ assert!(a.to_rgb().iter().zip(b.to_rgb()).all(|(x, y)| (x - y).abs() < 1.0 / 255.0), "{what}: {a:?} vs {b:?}");
+ };
+ for (method, other) in
+ [(GradientMethod::HueClockwise, GradientMethod::HueCounterclockwise), (GradientMethod::HueCounterclockwise, GradientMethod::HueClockwise)]
+ {
+ let mut g = Gradient::new(
+ GradientKind::Linear,
+ vec![
+ GradientStop { midpoint: 0.3, ..GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)) },
+ GradientStop { opacity: 0.5, ..GradientStop::new(0.6, Color::rgb(0.0, 0.0, 1.0)) },
+ GradientStop::new(1.0, Color::rgb(0.9, 0.8, 0.1)),
+ ],
+ );
+ g.method = method;
+ let mut back = g.clone();
+ back.reverse();
+ assert_eq!(back.method, other);
+ let (span, curve) = (g.span(1.0, 0.0), g.as_classic_curve());
+ assert!(span.method.is_classic() && curve.method.is_classic());
+ assert!(curve.stops.len() > g.stops.len() && curve.stops.iter().all(|s| s.swatch.is_none() && s.midpoint == 0.5));
+ for k in 0..=400 {
+ let t = k as f32 / 400.0;
+ let (want, opacity) = g.sample(1.0 - t);
+ near(want, back.sample(t).0, &format!("{method:?} reversed at {t}"));
+ assert!((opacity - back.sample(t).1).abs() < 1e-4, "{method:?} opacity at {t}");
+ near(want, span.sample(t).0, &format!("{method:?} backward span at {t}"));
+ near(g.sample(t).0, curve.sample(t).0, &format!("{method:?} curve at {t}"));
+ }
+ back.reverse();
+ assert_eq!(back.method, method);
+ assert_eq!(back.stops.len(), g.stops.len());
+ }
+ for method in [GradientMethod::Classic, GradientMethod::Perceptual] {
+ assert_eq!(method.mirrored(), method);
+ let mut g = blue_yellow(method);
+ g.reverse();
+ assert_eq!(g.method, method);
+ }
+}
+
+/// The hue methods are written by name, read back, and parsed from their param names; their
+/// gradients draw as pixels in SVG and PDF, and are not mixes of the stops' inks.
+#[test]
+fn hue_methods_save_parse_and_draw_as_pixels() {
+ for (method, name, id) in [
+ (GradientMethod::HueClockwise, "HueClockwise", "hue-clockwise"),
+ (GradientMethod::HueCounterclockwise, "HueCounterclockwise", "hue-counterclockwise"),
+ ] {
+ let g = two_stops(Color::cmyk(0.0, 1.0, 1.0, 0.0), Color::cmyk(1.0, 1.0, 0.0, 0.0), method);
+ let v = serde_json::to_value(&g).unwrap();
+ assert_eq!(v["method"], name);
+ assert_eq!(serde_json::from_value::(v).unwrap(), g);
+ assert_eq!(method.id(), id);
+ for written in [id.to_string(), id.to_uppercase(), id.replace('-', " "), id.replace('-', "_"), name.to_string(), method.label().to_string()] {
+ assert_eq!(GradientMethod::parse(&written), Some(method), "{written}");
+ }
+ assert!(method.is_hue() && !method.is_classic());
+ let paint = GradientPaint::new(g.clone());
+ assert!(paint.draws_as_pixels(true) && paint.draws_as_pixels(false));
+ assert!(g.ink_mixes().is_none(), "not mixes of the stops' inks");
+ assert!(
+ paint.ink_pixels(Rect::new(0.0, 0.0, 10.0, 10.0), Rect::new(0.0, 0.0, 10.0, 10.0), 4, 4, true).is_none(),
+ "an RGB image, not an ink image"
+ );
+ }
+ assert!(!GradientMethod::Perceptual.is_hue() && !GradientMethod::Classic.is_hue());
+ for (id, method) in [("classic", GradientMethod::Classic), ("perceptual", GradientMethod::Perceptual)] {
+ assert_eq!(method.id(), id);
+ assert_eq!(GradientMethod::parse(id), Some(method));
+ }
+ for bad in [
+ "hue",
+ "clockwise",
+ "hue-",
+ "",
+ "hue clockwise wise",
+ "c-l-a-s-s-i-c",
+ "per ceptual",
+ "hue--clockwise",
+ "hue-clock_wise",
+ " classic",
+ "hue- clockwise",
+ ] {
+ assert_eq!(GradientMethod::parse(bad), None, "{bad}");
+ }
+}
+
/// A Perceptual gradient of an ink's tints as tints: its stops keep their tints, and each tint on
/// the curve has the lightness of the color mixed there, so the tints between the stops are not
/// a straight line.
diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs
index 0d84e9e6b3..3afca8063c 100644
--- a/crates/color/src/lib.rs
+++ b/crates/color/src/lib.rs
@@ -102,12 +102,13 @@ impl Color {
pub fn to_lab(&self) -> cms::Lab {
cms::active().lab(self)
}
- /// Colour-managed CMYK in the working CMYK space with `intent` (see [`Color::to_cmyk`] for the
- /// profile-free formula).
+ /// Color-managed CMYK in the active CMYK space (the front document's assigned CMYK profile, else
+ /// the working CMYK space) with `intent` (see [`Color::to_cmyk`] for the profile-free formula).
pub fn to_cmyk_managed(&self, intent: cms::Intent) -> [f32; 4] {
cms::active().to_cmyk(self, intent)
}
- /// Gamut warning: true when this colour can't be reproduced in the working CMYK space.
+ /// Gamut warning: true when this color can't be reproduced in the active CMYK space (the front
+ /// document's assigned CMYK profile, else the working CMYK space).
pub fn out_of_gamut(&self) -> bool {
cms::active().out_of_gamut(self)
}
diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs
index f4877b0a49..d170bd606f 100644
--- a/crates/doc/src/blend.rs
+++ b/crates/doc/src/blend.rs
@@ -1381,6 +1381,14 @@ pub fn blend_spine(keys: &[Arc], spec: &BlendSpec) -> Option<(PathData, Op
layout_spine(keys, spec)
}
+/// The line the steps of a blend of `keys` are placed along: [`blend_spine`]'s path for a blend
+/// with a spine, and the straight lines between the key centers, in key order, for a blend made
+/// without one ([`BlendSpec::no_spine`]). For a blend without a spine the line places the steps
+/// only; it is not a spine and is not drawn as one. `None` with fewer than two keys.
+pub fn blend_layout_line(keys: &[Arc], spec: &BlendSpec) -> Option {
+ layout_spine(keys, spec).map(|(path, _)| path)
+}
+
/// The spine `spec` stores, unless the blend was made without one.
fn stored_spine(spec: &BlendSpec) -> Option<&PathData> {
spec.spine.as_ref().filter(|_| !spec.no_spine)
@@ -1852,6 +1860,28 @@ mod tests {
assert_eq!(moved.subpaths[0].anchors[2].p, Point::new(305.0, 5.0));
}
+ #[test]
+ fn a_blend_without_a_spine_has_a_layout_line_through_its_key_centers() {
+ let at =
+ |x: f64, y: f64| Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(x - 5.0, y - 5.0, x + 5.0, y + 5.0)), Appearance::default()));
+ let line_of = |path: &PathData| path.subpaths.iter().flat_map(|s| s.anchors.iter().map(|a| a.p)).collect::>();
+ // A stored spine is ignored once the blend is made without one.
+ let stored = PathData::single(SubPath::polyline(&[Point::new(0.0, 50.0), Point::new(300.0, 50.0)], false));
+ let spec = BlendSpec { no_spine: true, spine: Some(stored), ..Default::default() };
+ for keys in [vec![at(10.0, 20.0), at(110.0, 20.0)], vec![at(10.0, 20.0), at(110.0, 20.0), at(110.0, 220.0), at(-40.0, 0.0)]] {
+ assert!(blend_spine(&keys, &spec).is_none(), "still no spine");
+ let line = blend_layout_line(&keys, &spec).unwrap();
+ assert_eq!(line.subpaths.len(), 1);
+ assert!(!line.subpaths[0].closed);
+ let centers: Vec = keys.iter().map(|k| center_of(k)).collect();
+ assert_eq!(line_of(&line), centers, "in key order");
+ }
+ // With a spine, the layout line is the spine.
+ let keys = vec![key(0.0), key(100.0), key(300.0)];
+ assert_eq!(blend_layout_line(&keys, &BlendSpec::default()), blend_spine(&keys, &BlendSpec::default()).map(|(p, _)| p));
+ assert!(blend_layout_line(&keys[..1], &spec).is_none(), "one key");
+ }
+
#[test]
fn steps_follow_a_bent_spine() {
let keys = vec![key(0.0), key(100.0)];
diff --git a/crates/doc/src/cmyk.rs b/crates/doc/src/cmyk.rs
index 391fc39633..e89d63cb54 100644
--- a/crates/doc/src/cmyk.rs
+++ b/crates/doc/src/cmyk.rs
@@ -166,24 +166,180 @@ fn planar(d: &mut tiff::decoder::Decoder>) -> bool {
/// that many samples, which includes images whose planes together need more than the decoder's
/// limit (256 MB of samples).
pub(crate) fn read_samples(d: &mut tiff::decoder::Decoder>, n: usize, per: usize) -> Option> {
+ Some(match read_samples_at_depth(d, n, per)? {
+ Samples::Eight(v) => v,
+ Samples::Sixteen(v) => v.iter().map(|s| (s >> 8) as u8).collect(),
+ })
+}
+
+/// Samples of an image, pixel by pixel, at the depth its file stores them.
+#[derive(Clone, Debug, PartialEq)]
+enum Samples {
+ Eight(Vec),
+ Sixteen(Vec),
+}
+
+impl Samples {
+ fn as_slice(&self) -> SampleSlice<'_> {
+ match self {
+ Self::Eight(v) => SampleSlice::Eight(v),
+ Self::Sixteen(v) => SampleSlice::Sixteen(v),
+ }
+ }
+}
+
+/// [`Samples`], borrowed.
+#[derive(Clone, Copy, Debug)]
+enum SampleSlice<'a> {
+ Eight(&'a [u8]),
+ Sixteen(&'a [u16]),
+}
+
+/// [`read_samples`], keeping 16-bit samples at 16 bits.
+fn read_samples_at_depth(d: &mut tiff::decoder::Decoder>, n: usize, per: usize) -> Option {
let mut buf = tiff::decoder::DecodingResult::U8(Vec::new());
let layout = d.read_image_to_buffer(&mut buf).ok()?;
- let data = match buf {
- tiff::decoder::DecodingResult::U8(v) => v,
- tiff::decoder::DecodingResult::U16(v) => v.iter().map(|s| (s >> 8) as u8).collect(),
- _ => return None,
- };
+ match buf {
+ tiff::decoder::DecodingResult::U8(v) => chunky(v, layout.planes, n, per).map(Samples::Eight),
+ tiff::decoder::DecodingResult::U16(v) => chunky(v, layout.planes, n, per).map(Samples::Sixteen),
+ _ => None,
+ }
+}
+
+/// `data`, the samples of `n` pixels, `per` to a pixel, stored in `planes` planes, as the samples
+/// pixel by pixel; `None` when it doesn't hold that many samples or a plane per sample.
+fn chunky(data: Vec, planes: usize, n: usize, per: usize) -> Option> {
if Some(data.len()) != n.checked_mul(per) {
return None;
}
- if layout.planes <= 1 {
+ if planes <= 1 {
return Some(data);
}
- if layout.planes != per {
+ if planes != per {
return None;
}
- let planes: Vec<&[u8]> = data.chunks_exact(n).collect();
- Some((0..n).flat_map(|i| planes.iter().map(move |p| p.get(i).copied().unwrap_or(0))).collect())
+ let planes: Vec<&[T]> = data.chunks_exact(n).collect();
+ Some((0..n).flat_map(|i| planes.iter().filter_map(move |p| p.get(i).copied())).collect())
+}
+
+/// The color space an ICC profile's header names (bytes 16 to 19, such as `RGB `, `GRAY` or
+/// `CMYK`); `None` when `icc` is shorter than a header.
+pub fn icc_color_space(icc: &[u8]) -> Option<[u8; 4]> {
+ if icc.len() < 128 {
+ return None;
+ }
+ icc.get(16..20)?.try_into().ok()
+}
+
+/// The ICC profile of TIFF tag 34675 (ICCProfile) of the image `d` reads; `None` without one.
+fn tiff_icc(d: &mut tiff::decoder::Decoder>) -> Option> {
+ d.get_tag_u8_vec(tiff::tags::Tag::IccProfile).ok().filter(|icc| !icc.is_empty())
+}
+
+/// The ICC profile of TIFF `bytes`' first image (tag 34675), read with the `tiff` decoder's own
+/// limits, whatever the size of the image; `None` for other images and TIFFs without one.
+pub fn tiff_icc_profile(bytes: &[u8]) -> Option> {
+ let (_, mut d) = tiff(bytes)?;
+ tiff_icc(&mut d)
+}
+
+/// The ICC profile of a JPEG, put together from all its numbered `ICC_PROFILE` APP2 segments;
+/// `None` without one.
+fn jpeg_icc(bytes: &[u8]) -> Option> {
+ let mut d = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(bytes), DecoderOptions::default());
+ d.decode_headers().ok()?;
+ d.icc_profile().filter(|icc| !icc.is_empty())
+}
+
+/// `icc` when it is a profile of CMYK colors, which the samples of a CMYK image are in.
+fn cmyk_icc(icc: Option>) -> Option> {
+ icc.filter(|icc| icc_color_space(icc) == Some(*b"CMYK"))
+}
+
+/// The CMYK samples of a CMYK image at the depth its file stores them, and its ICC profile.
+struct CmykSource {
+ width: u32,
+ height: u32,
+ samples: Samples,
+ icc: Option>,
+}
+
+/// The samples of CMYK TIFF `bytes` (8 or 16 bits), their inks together or one plane per ink, with
+/// the TIFF's CMYK ICC profile; `None` for other images and ones larger than [`MAX_CMYK_PIXELS`].
+fn tiff_source(bytes: &[u8]) -> Option {
+ let (kind, mut d) = tiff(bytes)?;
+ if !matches!(kind, tiff::ColorType::CMYK(8 | 16)) {
+ return None;
+ }
+ let (width, height) = d.dimensions().ok()?;
+ let n = usize::try_from(pixels(width, height)?).ok()?;
+ let icc = cmyk_icc(tiff_icc(&mut d));
+ let samples = read_samples_at_depth(&mut d, n, 4)?;
+ Some(CmykSource { width, height, samples, icc })
+}
+
+/// The ink amounts of a four-component JPEG, 8 bits each, with its CMYK ICC profile.
+fn jpeg_source(bytes: &[u8]) -> Option {
+ let Inks { width, height, data } = jpeg_inks(bytes)?;
+ Some(CmykSource { width, height, samples: Samples::Eight(data), icc: cmyk_icc(jpeg_icc(bytes)) })
+}
+
+/// A Deflate-compressed CMYK TIFF of `width` × `height` pixels of `samples` (inks together, 8 or 16
+/// bits as they are given), declaring the resolution `ppi` when both of its values are finite and
+/// above 0, and carrying `icc` as tag 34675 (ICCProfile, of type UNDEFINED); `None` when it can't be
+/// written.
+fn write_cmyk_tiff(width: u32, height: u32, samples: SampleSlice<'_>, ppi: Option<(f64, f64)>, icc: Option<&[u8]>) -> Option> {
+ use tiff::encoder::colortype::{CMYK8, CMYK16, ColorType};
+ use tiff::encoder::{Compression, ImageEncoder, Rational, TiffEncoder, TiffKind, compression::DeflateLevel};
+ use tiff::tags::{ResolutionUnit, Tag};
+ fn tags(
+ img: &mut ImageEncoder<'_, W, C, K>,
+ ppi: Option<(f64, f64)>,
+ icc: Option<&[u8]>,
+ ) -> Option<()> {
+ if let Some((x, y)) = ppi.filter(|(x, y)| [x, y].iter().all(|v| v.is_finite() && **v > 0.0)) {
+ // Ten-thousandths of a pixel per inch.
+ let r = |v: f64| Rational { n: (v * 10_000.0).round() as u32, d: 10_000 };
+ img.encoder().write_tag(Tag::XResolution, r(x)).ok()?;
+ img.encoder().write_tag(Tag::YResolution, r(y)).ok()?;
+ img.encoder().write_tag(Tag::ResolutionUnit, ResolutionUnit::Inch).ok()?;
+ }
+ if let Some(icc) = icc {
+ img.encoder().write_tag(Tag::IccProfile, Undefined(icc)).ok()?;
+ }
+ Some(())
+ }
+ let mut out = Cursor::new(Vec::new());
+ let mut enc = TiffEncoder::new(&mut out).ok()?.with_compression(Compression::Deflate(DeflateLevel::Balanced));
+ match samples {
+ SampleSlice::Eight(data) => {
+ let mut img = enc.new_image::(width, height).ok()?;
+ tags(&mut img, ppi, icc)?;
+ img.write_data(data).ok()?;
+ }
+ SampleSlice::Sixteen(data) => {
+ let mut img = enc.new_image::(width, height).ok()?;
+ tags(&mut img, ppi, icc)?;
+ img.write_data(data).ok()?;
+ }
+ }
+ Some(out.into_inner())
+}
+
+/// Bytes written as a TIFF field of type UNDEFINED, the type of an embedded ICC profile.
+struct Undefined<'a>(&'a [u8]);
+
+impl tiff::encoder::TiffValue for Undefined<'_> {
+ const BYTE_LEN: u8 = 1;
+ const FIELD_TYPE: tiff::tags::Type = tiff::tags::Type::UNDEFINED;
+
+ fn count(&self) -> usize {
+ self.0.len()
+ }
+
+ fn data(&self) -> std::borrow::Cow<'_, [u8]> {
+ std::borrow::Cow::Borrowed(self.0)
+ }
}
impl ImageBlob {
@@ -195,20 +351,8 @@ impl ImageBlob {
/// [`ImageBlob::cmyk_tiff`], declaring the resolution `ppi` (horizontal, vertical pixels per
/// inch) when it is given and both are finite and above 0.
pub fn cmyk_tiff_at(inks: &Inks, ppi: Option<(f64, f64)>) -> Option {
- use tiff::encoder::{Compression, Rational, TiffEncoder, colortype::CMYK8, compression::DeflateLevel};
- use tiff::tags::{ResolutionUnit, Tag};
- let mut out = Cursor::new(Vec::new());
- let mut enc = TiffEncoder::new(&mut out).ok()?.with_compression(Compression::Deflate(DeflateLevel::Balanced));
- let mut img = enc.new_image::(inks.width, inks.height).ok()?;
- if let Some((x, y)) = ppi.filter(|(x, y)| [x, y].iter().all(|v| v.is_finite() && **v > 0.0)) {
- // Ten-thousandths of a pixel per inch.
- let r = |v: f64| Rational { n: (v * 10_000.0).round() as u32, d: 10_000 };
- img.encoder().write_tag(Tag::XResolution, r(x)).ok()?;
- img.encoder().write_tag(Tag::YResolution, r(y)).ok()?;
- img.encoder().write_tag(Tag::ResolutionUnit, ResolutionUnit::Inch).ok()?;
- }
- img.write_data(&inks.data).ok()?;
- Some(Self::new("image/tiff", out.into_inner()))
+ let tiff = write_cmyk_tiff(inks.width, inks.height, SampleSlice::Eight(&inks.data), ppi, None)?;
+ Some(Self::new("image/tiff", tiff))
}
/// Does the image hold CMYK samples (a four-component JPEG or a CMYK TIFF)? Reads only its
@@ -333,30 +477,38 @@ fn tiff_inks(bytes: &[u8]) -> Option {
/// CMYK TIFF `bytes` that no orientation turns ([`turned_cmyk`] stores the others) as Open and
/// Place store them: as they are, or, when they store one plane per ink, which the image decoder
-/// doesn't read, their ink amounts written again by [`ImageBlob::cmyk_tiff_at`] with the resolution
-/// `ppi`. `None` for other images and for CMYK TIFFs whose inks [`ImageBlob::cmyk`] doesn't read.
+/// doesn't read, their inks together in a CMYK TIFF with the resolution `ppi`, at the 8 or 16 bits
+/// the file has, carrying the file's CMYK ICC profile. `None` for other images and for CMYK TIFFs
+/// whose inks [`ImageBlob::cmyk`] doesn't read.
pub fn stored_cmyk_tiff(bytes: &[u8], ppi: Option<(f64, f64)>) -> Option> {
- let inks = tiff_inks(bytes)?;
+ let source = tiff_source(bytes)?;
let (_, mut d) = tiff(bytes)?;
if !planar(&mut d) {
return Some(bytes.to_vec());
}
- ImageBlob::cmyk_tiff_at(&inks, ppi).map(|b| Arc::unwrap_or_clone(b.bytes))
+ write_cmyk_tiff(source.width, source.height, source.samples.as_slice(), ppi, source.icc.as_deref())
}
/// CMYK JPEG or TIFF `bytes` that `orientation` turns or flips, as Open and Place store them: their
-/// ink amounts ([`ImageBlob::cmyk`], 8 bits each) turned and written again by
-/// [`ImageBlob::cmyk_tiff_at`] with the resolution `ppi` of the turned pixels, with the turned
-/// width and height. `None` for other images and for CMYK images whose inks [`ImageBlob::cmyk`]
-/// doesn't read.
+/// inks turned and written again as a CMYK TIFF with the resolution `ppi` of the turned pixels,
+/// with the turned width and height. A TIFF keeps its 8 or 16 bits, a JPEG has 8, and both keep
+/// their CMYK ICC profile (a JPEG's put together from all its APP2 segments). `None` for other
+/// images and for CMYK images whose inks [`ImageBlob::cmyk`] doesn't read.
pub fn turned_cmyk(bytes: &[u8], orientation: image::metadata::Orientation, ppi: Option<(f64, f64)>) -> Option<(Vec, (u32, u32))> {
- let inks = if bytes.starts_with(&[0xFF, 0xD8]) { jpeg_inks(bytes) } else { tiff_inks(bytes) }?;
- // Four 8-bit inks to a pixel, turned as four 8-bit channels are.
- let mut px = image::DynamicImage::ImageRgba8(image::RgbaImage::from_raw(inks.width, inks.height, inks.data)?);
+ let CmykSource { width, height, samples, icc } = if bytes.starts_with(&[0xFF, 0xD8]) { jpeg_source(bytes) } else { tiff_source(bytes) }?;
+ // Four inks to a pixel, turned as four channels of the same depth are.
+ let mut px = match samples {
+ Samples::Eight(v) => image::DynamicImage::ImageRgba8(image::RgbaImage::from_raw(width, height, v)?),
+ Samples::Sixteen(v) => image::DynamicImage::ImageRgba16(image::ImageBuffer::from_raw(width, height, v)?),
+ };
px.apply_orientation(orientation);
- let inks = Inks::new(px.width(), px.height(), px.into_bytes())?;
- let tiff = ImageBlob::cmyk_tiff_at(&inks, ppi)?;
- Some((Arc::unwrap_or_clone(tiff.bytes), (inks.width, inks.height)))
+ let size = (px.width(), px.height());
+ let samples = match px {
+ image::DynamicImage::ImageRgba16(v) => Samples::Sixteen(v.into_raw()),
+ other => Samples::Eight(other.into_rgba8().into_raw()),
+ };
+ let tiff = write_cmyk_tiff(size.0, size.1, samples.as_slice(), ppi, icc.as_deref())?;
+ Some((tiff, size))
}
/// A TIFF whose pixels have samples after their colour, which the image decoder doesn't read at
diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs
index 708d0aee03..057a9df6a6 100644
--- a/crates/doc/src/document.rs
+++ b/crates/doc/src/document.rs
@@ -286,7 +286,9 @@ impl Document {
self.next_id = self.next_id.max(id);
}
- /// Ensure `next_id` is above every id in the tree (after deserializing foreign data).
+ /// Ensure `next_id` is above every id in the tree (after deserializing foreign data), then give
+ /// the objects outside the layer tree (opacity-mask art, symbols, graph designs and patterns)
+ /// whose id is missing or already taken a new one, so each stored object has an id of its own.
pub fn fix_next_id(&mut self) {
// Ids from [`MAX_NEXT_ID`] up (a damaged or hand-made file: #1718) are left out, as is a
// counter up there: new ids start under them and never reach them, where counting on from
@@ -297,13 +299,12 @@ impl Document {
max = max.max(id);
}
};
- for l in &self.layers {
- l.walk(&mut |n| see(n.id.0));
- }
+ self.visit_stored(|n, _| see(n.id.0));
self.slices.iter().for_each(|s| see(s.id.0));
self.assets.iter().for_each(|a| see(a.id));
let counter = Some(self.next_id).filter(|n| *n <= MAX_NEXT_ID).unwrap_or(0);
self.next_id = counter.max(max.saturating_add(1));
+ self.renumber_stored_duplicates();
}
/// Find a node anywhere in the tree.
diff --git a/crates/doc/src/document_guides.rs b/crates/doc/src/document_guides.rs
index 4ec7aab1f6..14d0404bfc 100644
--- a/crates/doc/src/document_guides.rs
+++ b/crates/doc/src/document_guides.rs
@@ -554,6 +554,13 @@ impl Document {
t.cached_baselines.clear();
t.cached_ink = None;
}
+ // Opacity-mask art is stored art too: the copy's gets ids of its own.
+ if let Some(art) = n.extras().mask.as_ref().map(|m| Arc::clone(&m.art)) {
+ let art = Arc::new(self.reid(&art));
+ if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
+ m.art = art;
+ }
+ }
let on_path = n.extras().objects_on_path.is_some();
let mut renamed = std::collections::HashMap::new();
if let Some(ch) = n.children_mut() {
diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs
index a1122e5a7c..63a2676f77 100644
--- a/crates/doc/src/lib.rs
+++ b/crates/doc/src/lib.rs
@@ -142,6 +142,7 @@ mod graphic_style;
mod guides;
mod limits;
mod records;
+mod stored_art;
mod units;
pub use artboard::{ARTBOARD_GAP, Artboard, ArtboardSide, LockedArtboards, RetiredArtboardId, VIDEO_PIXEL_ASPECT_RANGE, VIDEO_SAFE_INSETS};
@@ -154,6 +155,7 @@ pub use guides::{GridPrefs, Guide, RulerMode};
use limits::MAX_NEXT_ID;
pub use limits::{DocError, FORMAT_VERSION, MAX_COORD};
pub use records::{ImageBlob, MaskEdit, NewArtIds, SavedSelection, SavedView, SvgFilter, Symbol, SymbolEdit};
+pub use stored_art::{StoredPlace, StoredRoot};
pub use units::{ColorMode, Unit, parse_number};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs
index cf7a4d2fa0..52453fd5a6 100644
--- a/crates/doc/src/live.rs
+++ b/crates/doc/src/live.rs
@@ -39,7 +39,7 @@ fn fifty() -> f64 {
// =====================================================================================
pub use crate::blend::{
- Spine, blend_expand, blend_spine, blend_step_count, lerp_appearance, lerp_color, lerp_node, lerp_paint, lerp_path, pin_spine,
+ Spine, blend_expand, blend_layout_line, blend_spine, blend_step_count, lerp_appearance, lerp_color, lerp_node, lerp_paint, lerp_path, pin_spine,
steps_knockout_shows,
};
diff --git a/crates/doc/src/stored_art.rs b/crates/doc/src/stored_art.rs
new file mode 100644
index 0000000000..e623e0ed0e
--- /dev/null
+++ b/crates/doc/src/stored_art.rs
@@ -0,0 +1,376 @@
+//! Every object a document stores, wherever it is kept: in the layers, in opacity-mask art, and in
+//! the art of symbols, graph designs and pattern swatches. [`Document::node`] finds objects in the
+//! layer tree only; the lookups here find the others by their ids too, which
+//! [`Document::fix_next_id`] keeps unique across all of them.
+
+use std::collections::HashSet;
+use std::sync::Arc;
+
+use super::limits::MAX_NEXT_ID;
+use super::{Document, Node, NodeId};
+
+/// The art a stored object belongs to.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum StoredRoot {
+ /// Layer `index` of [`Document::layers`].
+ Layer(usize),
+ /// Symbol `index` of [`Document::symbols`].
+ Symbol(usize),
+ /// Graph design `index` of [`Document::graph_designs`].
+ GraphDesign(usize),
+ /// Object `art` of pattern swatch `pattern` of [`Document::patterns`].
+ Pattern { pattern: usize, art: usize },
+}
+
+/// Where a stored object is: its root and, inside opacity-mask art, the object that mask belongs
+/// to (the innermost one when masks hold masks).
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct StoredPlace {
+ pub root: StoredRoot,
+ pub mask_of: Option,
+}
+
+impl StoredPlace {
+ /// Is the object in the layer tree itself, where [`Document::node`] finds it and the canvas
+ /// draws it as itself?
+ pub fn in_layers(self) -> bool {
+ matches!(self.root, StoredRoot::Layer(_)) && self.mask_of.is_none()
+ }
+}
+
+/// One step down from a node: to its child at an index, or to its opacity-mask art.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum Step {
+ Child(usize),
+ Mask,
+}
+
+/// `n` and everything below it with its place, the opacity-mask art right after the object it
+/// masks, then the children.
+fn visit<'a>(n: &'a Arc, place: StoredPlace, f: &mut dyn FnMut(&'a Arc, StoredPlace)) {
+ f(n, place);
+ if let Some(m) = &n.extras().mask {
+ visit(&m.art, StoredPlace { mask_of: Some(n.id), ..place }, f);
+ }
+ for c in n.children().into_iter().flatten() {
+ visit(c, place, f);
+ }
+}
+
+/// The steps from `n` to the first node in [`visit`] order with id `id`, and the object whose mask
+/// holds it last on the way.
+fn find(n: &Node, id: NodeId, steps: &mut Vec, mask_of: &mut Option) -> bool {
+ if n.id == id {
+ return true;
+ }
+ if let Some(m) = &n.extras().mask {
+ steps.push(Step::Mask);
+ let before = *mask_of;
+ *mask_of = Some(n.id);
+ if find(&m.art, id, steps, mask_of) {
+ return true;
+ }
+ *mask_of = before;
+ steps.pop();
+ }
+ for (i, c) in n.children().into_iter().flatten().enumerate() {
+ steps.push(Step::Child(i));
+ if find(c, id, steps, mask_of) {
+ return true;
+ }
+ steps.pop();
+ }
+ false
+}
+
+/// The node `steps` below `n`.
+fn at<'a>(mut n: &'a Arc, steps: &[Step]) -> Option<&'a Arc> {
+ for s in steps {
+ n = match s {
+ Step::Mask => &n.extras().mask.as_ref()?.art,
+ Step::Child(i) => n.children()?.get(*i)?,
+ };
+ }
+ Some(n)
+}
+
+/// The node `steps` below `n` to change, the nodes on the way copied when shared.
+fn at_mut<'a>(mut n: &'a mut Arc, steps: &[Step]) -> Option<&'a mut Node> {
+ for s in steps {
+ let node = Arc::make_mut(n);
+ n = match s {
+ Step::Mask => &mut node.extras_if_any()?.mask.as_mut()?.art,
+ Step::Child(i) => node.children_mut()?.get_mut(*i)?,
+ };
+ }
+ Some(Arc::make_mut(n))
+}
+
+/// Gives every node from `n` down that `fresh` names a new id from `next`, counting nodes in
+/// [`visit`] order from `*count`; only the nodes on the way to one are copied. Each part below is
+/// renumbered through a second handle, so a new pointer marks a change.
+fn renumber(n: &mut Arc, fresh: &HashSet, count: &mut usize, next: &mut u64) {
+ let own = fresh.contains(count);
+ *count += 1;
+ let mask = n.extras().mask.as_ref().and_then(|m| {
+ let mut art = Arc::clone(&m.art);
+ renumber(&mut art, fresh, count, next);
+ (!Arc::ptr_eq(&art, &m.art)).then_some(art)
+ });
+ let mut slots = vec![];
+ // Children's new ids by their old ones, for an Objects on Path group, which names its path and
+ // members by id. An id another child keeps still names that child.
+ let mut renamed = std::collections::HashMap::new();
+ let mut kept = HashSet::new();
+ for (i, c) in n.children().into_iter().flatten().enumerate() {
+ let mut new = Arc::clone(c);
+ renumber(&mut new, fresh, count, next);
+ if new.id == c.id {
+ kept.insert(c.id);
+ } else {
+ renamed.insert(c.id, new.id);
+ }
+ if !Arc::ptr_eq(&new, c) {
+ slots.push((i, new));
+ }
+ }
+ renamed.retain(|old, _| !kept.contains(old));
+ if !own && mask.is_none() && slots.is_empty() {
+ return;
+ }
+ let node = Arc::make_mut(n);
+ if own {
+ node.id = NodeId(*next);
+ *next += 1;
+ }
+ if let (Some(art), Some(m)) = (mask, node.extras_if_any().and_then(|e| e.mask.as_mut())) {
+ m.art = art;
+ }
+ if let Some(children) = node.children_mut() {
+ for (i, c) in slots {
+ if let Some(slot) = children.get_mut(i) {
+ *slot = c;
+ }
+ }
+ }
+ if !renamed.is_empty()
+ && let Some(on_path) = node.extras_if_any().and_then(|e| e.objects_on_path.as_mut())
+ {
+ on_path.rename(&renamed);
+ }
+}
+
+impl Document {
+ /// The roots of the art kept outside the layers, each with its [`StoredRoot`].
+ fn definition_roots(&self) -> impl Iterator- , StoredRoot)> {
+ let symbols = self.symbols.iter().enumerate().map(|(i, s)| (&s.art, StoredRoot::Symbol(i)));
+ let designs = self.graph_designs.iter().enumerate().map(|(i, g)| (&g.art, StoredRoot::GraphDesign(i)));
+ let patterns = self
+ .patterns
+ .iter()
+ .enumerate()
+ .flat_map(|(p, def)| def.art.iter().enumerate().map(move |(a, n)| (n, StoredRoot::Pattern { pattern: p, art: a })));
+ symbols.chain(designs).chain(patterns)
+ }
+
+ /// Every root of stored art: the layers, then the art of symbols, graph designs and patterns.
+ fn stored_roots(&self) -> impl Iterator
- , StoredRoot)> {
+ self.layers.iter().enumerate().map(|(i, l)| (l, StoredRoot::Layer(i))).chain(self.definition_roots())
+ }
+
+ /// The root `root` names, to change.
+ fn stored_root_mut(&mut self, root: StoredRoot) -> Option<&mut Arc
> {
+ match root {
+ StoredRoot::Layer(i) => self.layers.get_mut(i),
+ StoredRoot::Symbol(i) => self.symbols.get_mut(i).map(|s| &mut s.art),
+ StoredRoot::GraphDesign(i) => self.graph_designs.get_mut(i).map(|g| &mut g.art),
+ StoredRoot::Pattern { pattern, art } => self.patterns.get_mut(pattern)?.art.get_mut(art),
+ }
+ }
+
+ /// Visit every stored object with its place: the layer tree with the opacity-mask art of its
+ /// objects, then the art of symbols, graph designs and pattern swatches (their definitions,
+ /// once each, never the copies instances and tiles draw).
+ pub fn visit_stored<'a>(&'a self, mut f: impl FnMut(&'a Node, StoredPlace)) {
+ self.visit_stored_arcs(|n, place| f(n, place));
+ }
+
+ /// [`Self::visit_stored`] with each object's shared handle, which keeps its identity from one
+ /// look to the next while the object is unchanged (for caches such as thumbnails).
+ pub fn visit_stored_arcs<'a>(&'a self, mut f: impl FnMut(&'a Arc, StoredPlace)) {
+ for (root, r) in self.stored_roots() {
+ visit(root, StoredPlace { root: r, mask_of: None }, &mut f);
+ }
+ }
+
+ /// Where object `id` is stored: the root, and the steps from it.
+ fn stored_path(&self, id: NodeId) -> Option<(StoredPlace, Vec)> {
+ if let Some(path) = self.index_path(id) {
+ let (first, rest) = path.split_first()?;
+ return Some((StoredPlace { root: StoredRoot::Layer(*first), mask_of: None }, rest.iter().map(|i| Step::Child(*i)).collect()));
+ }
+ // Not in the layer tree: in mask art (in the layers too) or in a definition's art.
+ for (root, r) in self.stored_roots() {
+ let (mut steps, mut mask_of) = (vec![], None);
+ if find(root, id, &mut steps, &mut mask_of) {
+ return Some((StoredPlace { root: r, mask_of }, steps));
+ }
+ }
+ None
+ }
+
+ /// Stored object `id` (see [`Self::visit_stored`]) with its place: one in the layer tree as
+ /// [`Self::node`] finds it, else the first in visiting order.
+ pub fn stored_node(&self, id: NodeId) -> Option<(&Arc, StoredPlace)> {
+ let (place, steps) = self.stored_path(id)?;
+ let root = self.stored_roots().find(|(_, r)| *r == place.root)?.0;
+ Some((at(root, &steps)?, place))
+ }
+
+ /// [`Self::stored_node`] to change; the nodes on the way are copied when shared.
+ pub fn stored_node_mut(&mut self, id: NodeId) -> Option<&mut Node> {
+ let (place, steps) = self.stored_path(id)?;
+ at_mut(self.stored_root_mut(place.root)?, &steps)
+ }
+
+ /// Gives the objects outside the layer tree (in opacity-mask art and in the art of symbols,
+ /// graph designs and patterns) whose id is 0, past the id range, or already taken by an object
+ /// visited before them (the layer tree's own objects first) a new id, so every stored object
+ /// can be named by its id. Returns how many changed; the id counter then stands above them.
+ pub(crate) fn renumber_stored_duplicates(&mut self) -> usize {
+ let mut seen: HashSet = HashSet::new();
+ for l in &self.layers {
+ l.walk(&mut |n| {
+ seen.insert(n.id);
+ });
+ }
+ self.slices.iter().for_each(|s| {
+ seen.insert(s.id);
+ });
+ // Per root, the visiting indices of the nodes that take a new id.
+ let mut fresh: Vec<(StoredRoot, HashSet)> = vec![];
+ for (root, r) in self.stored_roots() {
+ let (mut i, mut here) = (0, HashSet::new());
+ visit(root, StoredPlace { root: r, mask_of: None }, &mut |n, place| {
+ if !place.in_layers() && (n.id.0 == 0 || n.id.0 >= MAX_NEXT_ID || !seen.insert(n.id)) {
+ here.insert(i);
+ }
+ i += 1;
+ });
+ if !here.is_empty() {
+ fresh.push((r, here));
+ }
+ }
+ let total = fresh.iter().map(|(_, f)| f.len()).sum();
+ if total == 0 {
+ return 0;
+ }
+ // No id left to give: the objects keep theirs rather than share one given out later.
+ if self.peek_next_id().saturating_add(total as u64) > MAX_NEXT_ID {
+ return 0;
+ }
+ let mut next = self.peek_next_id();
+ for (r, f) in fresh {
+ if let Some(root) = self.stored_root_mut(r) {
+ renumber(root, &f, &mut 0, &mut next);
+ }
+ }
+ self.raise_next_id(next);
+ total
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{GraphDesign, OpacityMask, PatternDef, Symbol};
+ use vectorcraft_geom::Rect;
+
+ fn rect(id: u64) -> Node {
+ Node::path(NodeId(id), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), crate::Appearance::default_art())
+ }
+
+ fn masked(id: u64, art: Node) -> Node {
+ rect(id).with_mask(Some(Box::new(OpacityMask::new(art, true))))
+ }
+
+ /// A document with object 10 in its layer, mask art 11 on it, and one object in a symbol,
+ /// a graph design and a pattern (ids `defs`).
+ fn doc(defs: [u64; 3]) -> Document {
+ let mut d = Document::new(100.0, 100.0);
+ let layer = d.layers[0].id;
+ d.insert(Some(layer), 0, masked(10, rect(11))).unwrap();
+ d.symbols.push(Symbol { name: "S".into(), art: Arc::new(rect(defs[0])), dynamic: false });
+ d.graph_designs.push(GraphDesign { name: "G".into(), art: Arc::new(rect(defs[1])) });
+ d.patterns.push(PatternDef::new("P", vec![Arc::new(rect(defs[2]))]));
+ d.raise_next_id(20);
+ d
+ }
+
+ #[test]
+ fn stored_objects_are_found_in_every_art() {
+ let d = doc([12, 13, 14]);
+ let place = |id: u64| d.stored_node(NodeId(id)).map(|(n, p)| (n.id.0, p));
+ assert!(place(10).unwrap().1.in_layers());
+ assert_eq!(place(11).unwrap().1, StoredPlace { root: StoredRoot::Layer(0), mask_of: Some(NodeId(10)) });
+ assert_eq!(place(12).unwrap().1.root, StoredRoot::Symbol(0));
+ assert_eq!(place(13).unwrap().1.root, StoredRoot::GraphDesign(0));
+ assert_eq!(place(14).unwrap().1.root, StoredRoot::Pattern { pattern: 0, art: 0 });
+ assert!(d.node(NodeId(11)).is_none() && d.node(NodeId(12)).is_none(), "the layer lookup stays the layer tree's");
+ assert!(d.stored_node(NodeId(99)).is_none());
+ let mut seen = vec![];
+ d.visit_stored(|n, _| seen.push(n.id.0));
+ assert_eq!(seen, [d.layers[0].id.0, 10, 11, 12, 13, 14]);
+ }
+
+ #[test]
+ fn stored_objects_change_by_id() {
+ let mut d = doc([12, 13, 14]);
+ let before = d.clone();
+ d.stored_node_mut(NodeId(13)).unwrap().name = Some("changed".into());
+ assert_eq!(d.graph_designs[0].art.name.as_deref(), Some("changed"));
+ assert!(Arc::ptr_eq(&d.symbols[0].art, &before.symbols[0].art), "other art is not copied");
+ assert_eq!(before.graph_designs[0].art.name, None, "the earlier document is unchanged");
+ d.stored_node_mut(NodeId(11)).unwrap().name = Some("mask".into());
+ assert_eq!(d.stored_node(NodeId(11)).unwrap().0.name.as_deref(), Some("mask"));
+ }
+
+ #[test]
+ fn fix_next_id_gives_nested_duplicates_new_ids() {
+ // The symbol's object repeats the layer's object, the design's has no id, and the pattern's
+ // repeats the mask art's.
+ let mut d = doc([10, 0, 11]);
+ d.next_id = 1;
+ d.fix_next_id();
+ let mut ids = vec![];
+ d.visit_stored(|n, _| ids.push(n.id.0));
+ let unique: HashSet = ids.iter().copied().collect();
+ assert_eq!(unique.len(), ids.len(), "{ids:?}");
+ assert!(!ids.contains(&0));
+ assert_eq!(&ids[..3], &[d.layers[0].id.0, 10, 11], "the layer tree and the first mask art keep theirs");
+ assert!(ids.iter().all(|id| *id < d.peek_next_id()), "{ids:?} {}", d.peek_next_id());
+ // Ids already unique are kept, and a second pass changes nothing.
+ let again = d.clone();
+ assert_eq!(d.renumber_stored_duplicates(), 0);
+ assert_eq!(d, again);
+ }
+
+ #[test]
+ fn renumbered_objects_on_path_keep_their_path_and_members() {
+ // A symbol's Objects on Path group whose path repeats the layer's object 10 and whose
+ // member has no id.
+ let mut d = doc([12, 13, 14]);
+ let (path, member) = (rect(10), rect(0));
+ let rec = crate::objects_on_path::ObjectsOnPath::new(path.id, &[&member], crate::objects_on_path::CENTER, 0.0);
+ let mut g = Node::new(NodeId(15), crate::NodeKind::Group { children: vec![Arc::new(path), Arc::new(member)], clip: false });
+ g.extras_mut().objects_on_path = Some(Box::new(rec));
+ d.symbols[0].art = Arc::new(g);
+ d.fix_next_id();
+ let art = &d.symbols[0].art;
+ let kids: Vec = art.children().unwrap().iter().map(|c| c.id).collect();
+ assert!(kids.iter().all(|id| id.0 != 10 && id.0 != 0), "{kids:?}");
+ let on_path = art.extras().objects_on_path.as_ref().unwrap();
+ assert_eq!(on_path.path, kids[0]);
+ assert_eq!(on_path.members.iter().map(|m| m.id).collect::>(), [kids[1]]);
+ }
+}
diff --git a/crates/effects/Cargo.toml b/crates/effects/Cargo.toml
index 14f9a0d08f..6d58ad9d74 100644
--- a/crates/effects/Cargo.toml
+++ b/crates/effects/Cargo.toml
@@ -29,3 +29,5 @@ serde_json = { workspace = true }
vectorcraft-color = { workspace = true }
# A face built in code from the bundled Source Sans 3 (no font file is committed).
vectorcraft-text = { workspace = true, features = ["test-fonts"] }
+# Test live-effect plug-ins written in WebAssembly text.
+wat = { workspace = true }
diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs
index 52232cd54e..424beebe17 100644
--- a/crates/effects/src/catalog.rs
+++ b/crates/effects/src/catalog.rs
@@ -182,8 +182,8 @@ pub fn effect_catalog() -> Vec {
REVOLVE,
"Revolve…",
&["Effect", "3D and Materials"],
- "{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), 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), segments: integer (8..128, 64), 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), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), cap: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Shaded surfaces use smooth vector gradients automatically; intersecting profiles use approximate painter visibility.",
- json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"cap":true,"expandVisibleOnly":true}),
+ "{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), 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), segments: integer (8..128, 64), 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), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), cap: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance; surfaces with graphics keep every face), graphics: array of 0..64 objects ([]; the 3D and Materials panel's Graphics on the swept side, see threeD.mapGraphic: symbol: name of a document symbol, face: front (the side), x: pt (0; the offset of the graphic's center from the middle of the side, around the axis: the angle times the profile's distance from the axis at that point), y: pt (0; along the profile, toward its lower end), scale: % (1..10000, 100), rotation: degrees (-360..360, 0; counterclockwise); wrapped around the side at its own size, so it reaches farther around where the profile comes nearer the axis, shaded with it and drawn only where the side's outside faces the viewer; a full turn cuts it at the back; nothing on the caps)}. Shaded surfaces use smooth vector gradients automatically; intersecting profiles use approximate painter visibility.",
+ json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"cap":true,"expandVisibleOnly":true,"graphics":[]}),
),
g(
EXTRUDE,
diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs
index fa06d00922..ac61746266 100644
--- a/crates/effects/src/lib.rs
+++ b/crates/effects/src/lib.rs
@@ -95,7 +95,8 @@ pub use solids::{
validate_three_d, validate_three_d_at, validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options, with_graphic,
};
pub use solids::{
- GRAPHIC_MAX_OFFSET, GRAPHIC_SCALE_RANGE, appearance_graphic_symbols, graphic_frame_at, graphics_reshaped_after, rename_appearance_graphic_symbol,
+ GRAPHIC_MAX_OFFSET, GRAPHIC_SCALE_RANGE, GRAPHICS_NOT_TAKEN, appearance_graphic_symbols, graphic_frame_at, graphics_reshaped_after,
+ rename_appearance_graphic_symbol,
};
pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings};
pub use svg_filter::SVG_FILTER;
diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs
index c5aaed7309..89a258bb07 100644
--- a/crates/effects/src/reshape.rs
+++ b/crates/effects/src/reshape.rs
@@ -1901,22 +1901,30 @@ fn paint_leaf(
/// 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 {
+ Budget::of(path).plus(piece_share())
+}
+
+/// The share of the effects' budget each painted piece costs besides its path ([`piece_cost`]).
+fn piece_share() -> Budget {
let max = vectorcraft_plugins::objects::MAX_OBJECTS;
- Budget::of(path).plus(Budget { anchors: Budget::EFFECTS.anchors / max, lines: Budget::EFFECTS.lines / max })
+ 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
+/// `item`, run on `leaf`, a path of [`transform_piece`] painted by `item` (its first fill or stroke),
+/// with reference box `bounds`: `leaf` becomes a group of one path for each piece returned, in
+/// order. The plug-in is given `appearance`'s fills and strokes and told that it is on item `index`
+/// of them. A piece that gives fills (on a fill) or strokes (on a stroke) is painted by copies of
+/// `item` 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`. A deferred run is listed by `at`.
+#[allow(clippy::too_many_arguments)]
fn paint_piece(
leaf: &mut Node,
item: &AppearanceItem,
+ (appearance, index): (&Appearance, usize),
(plugin, params): (&str, &serde_json::Value),
bounds: Rect,
budget: Budget,
@@ -1924,7 +1932,7 @@ fn paint_piece(
at: Option,
) -> Option {
let (path, rule) = leaf_geometry(leaf)?;
- let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&leaf.appearance), item: Some(0), at };
+ let paints = vectorcraft_plugins::effect::Paints { appearance: Some(appearance), item: Some(index), at };
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) {
@@ -2093,7 +2101,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt
group.push(Arc::new(p));
}
let mut p = Node::group(leaf.id, group);
- transform_piece(&mut p, (ctx.seed, i, &kept), crate::raster::held_back(before), limit, used, doc);
+ transform_piece(&mut p, (ctx.seed, i, &kept), crate::raster::held_back(before), true, limit, used, doc);
pieces.push(Arc::new(p));
continue;
}
@@ -2116,7 +2124,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt
if moved && !held_later {
follow_piece(&mut p, kept_before.get(from..).unwrap_or_default(), from, &fx[at]);
}
- transform_piece(&mut p, (ctx.seed, i, &kept), crate::raster::held_back(&fx[..=at]), limit, used, doc);
+ transform_piece(&mut p, (ctx.seed, i, &kept), crate::raster::held_back(&fx[..=at]), false, limit, used, doc);
pieces.push(Arc::new(p));
}
if !plain.is_empty() {
@@ -2129,7 +2137,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt
leaf.kind = NodeKind::Group { children: pieces, clip: false };
}
-/// Apply the visible geometry effects left on the one fill or stroke of each path under `p` (a
+/// Apply the visible geometry effects left on the fills or strokes of each path under `p` (a
/// transformed piece of [`transform_item_leaf`] and its copies) to all of `p`'s paths as one piece:
/// each effect takes the bounds of all the paths as its reference box and `seed`, as an object's
/// own effects reshape all its paths ([`reshape_leaves_carrying`]). Each path applies its own copy of
@@ -2138,19 +2146,32 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt
/// the effects after it, and with Transform Objects off those before it, as they are
/// ([`keep_listed`]).
/// 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 raster effects before a Transform effect
-/// follow it ([`follow_piece`]), except the first `held` effects, which a Transform effect before
-/// the piece's effects that makes copies or moves at random holds back, and the effects before
-/// such a Transform effect among the piece's effects. The geometry effects then
+/// for each piece it returns ([`paint_piece`]). A path with several fills or strokes (those a
+/// painted plug-in returned) takes such an effect, a live-effect plug-in or a geometry effect on
+/// strokes once for each of them, in paint order, given its own place among them
+/// ([`shares_stage`]): each run's pieces replace only that fill or stroke, and a run that fails or
+/// would pass the budget leaves that fill or stroke on the path as it was. The raster effects
+/// before a Transform effect follow it ([`follow_piece`]), except the first `held` effects, which
+/// a Transform effect before the piece's effects that makes copies or moves at random holds back,
+/// and the effects before such a Transform effect among the piece's effects. 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`. `(seed, source, kept)`: the object's seed, the index in the object's appearance of the
/// fill or stroke the piece was made from, and the index among that item's effects of each effect
-/// the piece kept, which a deferred live-effect plug-in is listed by.
-fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), held: usize, max: Budget, used: &mut Budget, doc: Option<&Document>) {
+/// the piece kept, which a deferred live-effect plug-in is listed by. `painted`: `p`'s paths are
+/// the pieces of a painted run, each of which `used` counts at [`piece_cost`].
+fn transform_piece(
+ p: &mut Node,
+ (seed, source, kept): (u64, usize, &[usize]),
+ held: usize,
+ painted: bool,
+ 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() {
+ for item in &l.appearance.items {
count = count.max(item.effects().len());
}
});
@@ -2158,60 +2179,207 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), h
// random are held back from the Transform effects before it too.
let mut held = held;
for_each_leaf(p, &mut |l| {
- if let Some(last) = l.appearance.items.first().and_then(|item| item.effects().iter().rposition(crate::raster::holds_back)) {
- held = held.max(last);
+ for item in &l.appearance.items {
+ if let Some(last) = item.effects().iter().rposition(crate::raster::holds_back) {
+ held = held.max(last);
+ }
}
});
+ // Whether `used` counts each path under `p`, in the order `for_each_leaf` visits them, at
+ // [`piece_cost`] (its geometry and a painted piece's share) rather than at its geometry alone.
+ // The pieces of a painted run are counted so, and so is the first path an effect makes of one
+ // that was: the paths of a Transform effect's copies share their original's share.
+ let mut shares = vec![painted; leaf_count(p)];
for j in 0..count {
let Some(bounds) = p.geometric_bounds() else { break };
let from = held.min(j);
let mut holds = false;
- for_each_leaf(p, &mut |leaf| {
- let Some(item) = leaf.appearance.items.first().cloned() else { return };
- let Some(e) = item.effects().get(j).filter(|e| e.visible && is_geometry(&e.id)).cloned() else { return };
- holds |= crate::raster::holds_back(&e);
- // The effects before this one as they were before it moved the path.
- let was = item.effects().get(from..j).unwrap_or_default();
- let Some((path, rule)) = leaf_geometry(leaf) else { return };
- // What the other paths leave of the budget.
- let before = Budget::of(&path);
- let room = max.minus(used.minus(before));
- let stage = vectorcraft_plugins::deferred::Stage { object: seed, effect: kept.get(j).copied().unwrap_or(j), item: Some(source) };
- 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, Some(stage)) {
- *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);
- let options = crate::distort::TransformOptions::of(¶ms);
- let map = crate::distort::transform_affine(bounds, ¶ms, seed);
- let keep = |k: usize| k > j || (k < j && !options.objects);
- let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room, false, Some((item.effects(), &keep)));
+ let stage = vectorcraft_plugins::deferred::Stage { object: seed, effect: kept.get(j).copied().unwrap_or(j), item: Some(source) };
+ let at = PieceStage { j, from, seed, bounds, doc, stage };
+ // Stage `j` on `leaf`, a path `used` counts at `piece_cost` when `share`: whether `used`
+ // counts each path it became so, or `None` when it stays as it was.
+ let mut on_leaf = |leaf: &mut Node, share: bool| -> Option> {
+ let effect = |item: &AppearanceItem| item.effects().get(j).filter(|e| e.visible && is_geometry(&e.id)).cloned();
+ let items = &leaf.appearance.items;
+ let first = items.first()?;
+ let (path, _) = leaf_geometry(leaf)?;
+ // What `used` counts the path at; what the other paths leave of the budget is the rest.
+ let geometry = Budget::of(&path);
+ let counted = if share { piece_cost(&path) } else { geometry };
+ if shares_stage(items, j) {
+ let e = effect(first)?;
+ holds |= crate::raster::holds_back(&e);
+ let appearance = leaf.appearance.clone();
+ // A painted run's pieces each cost a share of their own ([`paint_piece`]); the
+ // paths of any other effect keep the leaf's.
+ let painted = painted_plugin(&e).is_some();
+ let before = if painted { counted } else { geometry };
+ let room = max.minus(used.minus(before));
+ let made = piece_stage(leaf, &e, &at, (&appearance, 0), room)?;
*used = used.minus(before).plus(made);
- if moved {
- follow_piece(leaf, was, from, &e);
- }
- return;
+ return Some((0..leaf_count(leaf)).map(|k| painted || (share && k == 0)).collect());
}
+ // Each fill or stroke takes the effect on its own path, given the leaf's fills and
+ // strokes and its own place among them; those left with the same path as the one
+ // before them stay on it. Each path costs its geometry and a painted piece's share.
+ // Each run is first given all the room the art leaves. When a fill or stroke that keeps
+ // the leaf's path then finds no room for its copy of it, the results are placed again,
+ // each but the last leaving room for one more copy of the leaf's own path; a result
+ // that does not fit that room is left out. Only a fill or stroke that keeps the leaf's
+ // path right after one that changed costs such a copy (the ones after it stay on that
+ // copy), so every copy fits but the first fill's or stroke's, when the art leaves no
+ // room for it: the leaf then stays as it was. A live-effect plug-in is given all the
+ // room the art leaves, and each placing checks that its result fits; a built-in effect
+ // builds within the room of each placing.
let appearance = leaf.appearance.clone();
- let stroke = match &item {
- AppearanceItem::Stroke(s) => Some(s),
- AppearanceItem::Fill(_) => None,
+ let one = piece_cost(&path);
+ let n = appearance.items.len();
+ let base = used.minus(counted);
+ let alone = |item: &AppearanceItem| {
+ let mut sub = leaf.clone();
+ sub.appearance.items = vec![item.clone()];
+ sub
};
- let ctx = GeomContext { stroke, rule, seed, appearance: Some(&appearance), item: Some(0), doc, stage: Some(stage) };
- 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));
- // The fill's or stroke's gradient follows the effect as the object's does.
- if carry.moves(&leaf.appearance) {
- carry.apply(&mut leaf.appearance);
- }
- set_geometry(leaf, out, rule);
- follow_piece(leaf, was, from, &e);
+ // `item`, the `k`th fill or stroke, alone on the leaf's path with effect `e` applied
+ // within `room`, and what its paths cost; `None` when it failed or found no room.
+ let apply = |item: &AppearanceItem, k: usize, e: &Effect, room: Budget| {
+ let painted = painted_plugin(e).is_some();
+ let room = if painted { room } else { room.minus(piece_share()) };
+ let mut sub = alone(item);
+ let made = piece_stage(&mut sub, e, &at, (&appearance, k), room)?;
+ Some((sub, if painted { made } else { made.plus(piece_share()) }))
+ };
+ // Each fill's or stroke's live-effect plug-in run. A result is kept only while a
+ // placing places it on a path of its own; one that does not fit is dropped, with what
+ // it cost, and the plug-in runs again in the second placing only when that much room
+ // is left there (a result small enough to cache comes back from the cache). A result
+ // with the same path as the one before it costs nothing: it is placed on that path
+ // whatever room is left, and dropped once its fill or stroke is moved there. The
+ // results kept at once cost at most what the two placings place.
+ let mut runs: Vec = vec![PluginRun::Not; n];
+ let mut kept_cost = Budget { anchors: 0, lines: 0 };
+ // Each placing gives the paths, whether `used` counts each of the paths they hold at
+ // `piece_cost` (all of a painted run's, and the first of any other result's), and what
+ // the art then spends.
+ let mut split = |reserve: bool| -> Option<(Vec, Vec, Budget)> {
+ let mut spent = base;
+ let mut out: Vec = vec![];
+ let mut counts: Vec = vec![];
+ for (k, (item, ran)) in appearance.items.iter().zip(runs.iter_mut()).enumerate() {
+ let room = if reserve && k + 1 < n { max.minus(spent).minus(one) } else { max.minus(spent) };
+ let e = effect(item);
+ let paints = e.as_ref().is_some_and(|e| painted_plugin(e).is_some());
+ // Does `sub` stay on the path of the result before it, at no cost?
+ let merges = |sub: &Node| out.last().is_some_and(|last| same_path(last, sub));
+ // What the kept plug-in result this fill or stroke takes cost.
+ let mut taken = None;
+ let result = match e {
+ None => None,
+ Some(e) => {
+ holds |= crate::raster::holds_back(&e);
+ if vectorcraft_plugins::effect::plugin_id(&e.id).is_some() {
+ let again = match ran {
+ PluginRun::Not | PluginRun::Merged => true,
+ PluginRun::Left(cost) => room.fits(*cost),
+ PluginRun::Kept(..) | PluginRun::Failed => false,
+ };
+ if again {
+ #[cfg(test)]
+ PLUGIN_RUNS.with(|n| n.set(n.get() + 1));
+ *ran = match apply(item, k, &e, max.minus(base)) {
+ None => PluginRun::Failed,
+ Some((sub, cost)) if !room.fits(cost) && !merges(&sub) => PluginRun::Left(cost),
+ Some((sub, cost)) => {
+ kept_cost = kept_cost.plus(cost);
+ PluginRun::Kept(sub, cost)
+ }
+ };
+ }
+ match ran {
+ PluginRun::Kept(sub, cost) if room.fits(*cost) || merges(sub) => {
+ taken = Some(*cost);
+ Some((sub.clone(), *cost))
+ }
+ PluginRun::Kept(_, cost) => {
+ let cost = *cost;
+ kept_cost = kept_cost.minus(cost);
+ *ran = PluginRun::Left(cost);
+ None
+ }
+ PluginRun::Not | PluginRun::Merged | PluginRun::Left(_) | PluginRun::Failed => None,
+ }
+ } else {
+ // A built-in effect that finds no room within `room` leaves the
+ // leaf's path. When its result is not on the path before it, it
+ // is built again with all the room the art leaves and taken only
+ // when that result is on the path before it, which costs nothing.
+ let made = apply(item, k, &e, room);
+ let short = match &made {
+ None => true,
+ Some((sub, cost)) => !merges(sub) && (!room.fits(*cost) || same_path(sub, leaf)),
+ };
+ match out.last() {
+ Some(_) if short => match apply(item, k, &e, max.minus(base)) {
+ Some((sub, cost)) if merges(&sub) => Some((sub, cost)),
+ _ => made,
+ },
+ _ => made,
+ }
+ }
+ }
+ };
+ let placed = match result {
+ Some((sub, cost)) if room.fits(cost) || merges(&sub) => Some((sub, cost, paints)),
+ _ => None,
+ };
+ // A fill or stroke whose result is not placed stays on the leaf's path, which
+ // is copied only when the path before it is another.
+ let (mut sub, cost, all) = match placed {
+ Some(placed) => placed,
+ None => match out.last_mut() {
+ Some(last) if same_path(last, leaf) => {
+ last.appearance.items.push(item.clone());
+ continue;
+ }
+ _ => (alone(item), one, true),
+ },
+ };
+ match out.last_mut() {
+ Some(last) if same_path(last, &sub) => {
+ last.appearance.items.append(&mut sub.appearance.items);
+ if let Some(cost) = taken {
+ kept_cost = kept_cost.minus(cost);
+ *ran = PluginRun::Merged;
+ }
+ }
+ _ if !max.minus(spent).fits(cost) => return None,
+ _ => {
+ spent = spent.plus(cost);
+ counts.extend((0..leaf_count(&sub)).map(|k| all || k == 0));
+ out.push(sub);
+ }
+ }
+ #[cfg(test)]
+ HELD_PEAK.with(|p| p.set(p.get().at_least(kept_cost)));
+ }
+ Some((out, counts, spent))
+ };
+ let (mut out, counts, spent) = split(false).or_else(|| split(true))?;
+ *used = spent;
+ *leaf = match out.len() {
+ 1 => out.pop().unwrap_or_else(|| leaf.clone()),
+ _ => Node::group(leaf.id, out.into_iter().map(Arc::new).collect()),
+ };
+ Some(counts)
+ };
+ let mut next: Vec = vec![];
+ let mut visited = 0;
+ for_each_leaf(p, &mut |leaf| {
+ let share = shares.get(visited).copied().unwrap_or(false);
+ visited += 1;
+ next.extend(on_leaf(leaf, share).unwrap_or_else(|| vec![share]));
});
+ shares = next;
if holds {
held = held.max(j);
}
@@ -2223,6 +2391,134 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), h
});
}
+/// A fill's or stroke's live-effect plug-in run in a placing of [`transform_piece`]: not run yet,
+/// its result and what that costs while a placing places it on a path of its own, what a result
+/// that did not fit cost (the result itself is dropped), a result dropped once its fill or stroke
+/// was placed on the path before it, or failed.
+#[derive(Clone)]
+enum PluginRun {
+ Not,
+ Kept(Node, Budget),
+ Left(Budget),
+ Merged,
+ Failed,
+}
+
+#[cfg(test)]
+thread_local! {
+ /// The most the live-effect plug-in results [`transform_piece`] keeps from one fill or stroke
+ /// to the next have cost at once on this thread.
+ pub(crate) static HELD_PEAK: std::cell::Cell = const { std::cell::Cell::new(Budget { anchors: 0, lines: 0 }) };
+ /// How many times [`transform_piece`] has run a fill's or stroke's live-effect plug-in on this
+ /// thread when they did not share the stage.
+ pub(crate) static PLUGIN_RUNS: std::cell::Cell = const { std::cell::Cell::new(0) };
+}
+
+/// Where a stage of [`transform_piece`] runs: `j`, the index of its effect among the piece's
+/// effects; `from`, the first of the effects before it whose raster effects follow a Transform
+/// effect; the object's `seed`; the reference box `bounds` of all the piece's paths; the document
+/// that painted pieces' swatches and patterns must be in; and the `stage` a deferred live-effect
+/// plug-in is listed by.
+struct PieceStage<'a> {
+ j: usize,
+ from: usize,
+ seed: u64,
+ bounds: Rect,
+ doc: Option<&'a Document>,
+ stage: vectorcraft_plugins::deferred::Stage,
+}
+
+/// Can stage `j` of [`transform_piece`] run once for all of a path's `items`: there is at most one,
+/// or they hold the same effects and the effect at `j` is not a visible geometry effect, is a
+/// Transform effect, or is a built-in geometry effect and they are all fills (an effect that
+/// changes a fill's path does not depend on which fill it is on)?
+fn shares_stage(items: &[AppearanceItem], j: usize) -> bool {
+ let Some(first) = items.first() else { return true };
+ if items.len() == 1 {
+ return true;
+ }
+ if !items.iter().all(|i| i.effects() == first.effects()) {
+ return false;
+ }
+ match first.effects().get(j) {
+ Some(e) if e.visible && is_geometry(&e.id) => {
+ vectorcraft_plugins::effect::plugin_id(&e.id).is_none() && (reaches_appearance(e) || items.iter().all(AppearanceItem::is_fill))
+ }
+ _ => true,
+ }
+}
+
+/// Effect `e`, a visible geometry effect at stage `at` of [`transform_piece`], applied to `leaf`, a
+/// path painted by its first fill or stroke (and, when they share the stage, [`shares_stage`], the
+/// others): `appearance` is what the effect is given of the path's fills and strokes and `index`
+/// which of them it is on. A live-effect plug-in that paints what it returns makes `leaf` a group
+/// of its pieces ([`paint_piece`]), and a Transform effect that reaches its appearance transforms
+/// it ([`transform_leaf`]). Returns what the new paths cost, within `room`; `None` when `leaf`
+/// stays as it is.
+fn piece_stage(leaf: &mut Node, e: &Effect, at: &PieceStage, (appearance, index): (&Appearance, usize), room: Budget) -> Option {
+ let item = leaf.appearance.items.first()?.clone();
+ // The effects before this one as they were before it moved the path.
+ let was = item.effects().get(at.from..at.j).unwrap_or_default();
+ let (path, rule) = leaf_geometry(leaf)?;
+ if let Some(plugin) = painted_plugin(e) {
+ let params = merged_params(&e.id, &e.params);
+ return paint_piece(leaf, &item, (appearance, index), (plugin, ¶ms), at.bounds, room, at.doc, Some(at.stage));
+ }
+ if reaches_appearance(e) {
+ let params = merged_params(&e.id, &e.params);
+ let (_, copies) = crate::distort::transform_map(at.bounds, ¶ms, at.seed);
+ let options = crate::distort::TransformOptions::of(¶ms);
+ let map = crate::distort::transform_affine(at.bounds, ¶ms, at.seed);
+ let j = at.j;
+ let keep = |k: usize| k > j || (k < j && !options.objects);
+ let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room, false, Some((item.effects(), &keep)));
+ if moved {
+ follow_piece(leaf, was, at.from, e);
+ }
+ return Some(made);
+ }
+ let stroke = match &item {
+ AppearanceItem::Stroke(s) => Some(s),
+ AppearanceItem::Fill(_) => None,
+ };
+ let ctx = GeomContext { stroke, rule, seed: at.seed, appearance: Some(appearance), item: Some(index), doc: at.doc, stage: Some(at.stage) };
+ let mut carry = crate::Carry::default();
+ let out = crate::geometry::apply_geometry_carrying_within(std::slice::from_ref(e), &path, at.bounds, &ctx, room, Some(&mut carry));
+ let made = Budget::of(&out);
+ // The fill's or stroke's gradient follows the effect as the object's does.
+ if carry.moves(&leaf.appearance) {
+ carry.apply(&mut leaf.appearance);
+ }
+ set_geometry(leaf, out, rule);
+ follow_piece(leaf, was, at.from, e);
+ Some(made)
+}
+
+/// Are `a` and `b` paths or compound paths that differ only in their fills and strokes?
+fn same_path(a: &Node, b: &Node) -> bool {
+ let leaf = |n: &Node| matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. });
+ if !leaf(a) || !leaf(b) {
+ return false;
+ }
+ // Every field but the fills and strokes, compared in place; the geometry last.
+ let Node { id, name, visible, locked, opacity, blend, isolate, knockout, knockout_shape, appearance, kind, bbox_angle, extras } = a;
+ let Appearance { items: _, effects, contents_index } = &**appearance;
+ *id == b.id
+ && *name == b.name
+ && *visible == b.visible
+ && *locked == b.locked
+ && *opacity == b.opacity
+ && *blend == b.blend
+ && *isolate == b.isolate
+ && *knockout == b.knockout
+ && *knockout_shape == b.knockout_shape
+ && *bbox_angle == b.bbox_angle
+ && *extras == b.extras
+ && *effects == b.appearance.effects
+ && *contents_index == b.appearance.contents_index
+ && *kind == b.kind
+}
+
/// The effects a Transform effect in a fill's or stroke's own list leaves as they were
/// ([`keep_listed`]): the list as it was before the Transform effect was applied, and which of its
/// indices keep their values. `None` for an object's own Transform effect.
diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs
index 1677937f17..9281a802bf 100644
--- a/crates/effects/src/revolve.rs
+++ b/crates/effects/src/revolve.rs
@@ -29,6 +29,15 @@ pub fn is_revolve(id: &str) -> bool {
/// thousands of faces took gigabytes; past this the source path is drawn.
pub(crate) const MAX_PAINT_COPIES: usize = 256 << 20;
+/// The bytes of an object's paint copies so far: those of its surfaces' faces, and those of the parts
+/// of its graphics, each within [`MAX_PAINT_COPIES`] of its own. Graphics past their budget are left
+/// out without taking room the surfaces need.
+#[derive(Default)]
+struct Copies {
+ surfaces: usize,
+ graphics: usize,
+}
+
/// The most face and box tests a group's transparent members' faces take together to rejoin their
/// members' last runs; past them each starts a run of its own.
pub(crate) const MAX_OVERLAP_TESTS: usize = 1 << 22;
@@ -96,13 +105,18 @@ pub fn revolve_options(p: &Value) -> Revolve {
smooth_shading: true,
cap_ends: flag(p, "cap", true),
curve_samples: None,
+ map_surface: false,
}
}
/// The options Revolve (`id` [`REVOLVE`]) or Revolve (Classic) ([`CLASSIC_REVOLVE`]) with
-/// parameters `p` sweeps its surface with.
+/// parameters `p` sweeps its surface with. A Revolve with graphics lays out its side for them.
fn options_of(id: &str, p: &Value) -> Revolve {
- if id == CLASSIC_REVOLVE { classic::options(p) } else { revolve_options(p) }
+ if id == CLASSIC_REVOLVE {
+ classic::options(p)
+ } else {
+ Revolve { map_surface: id == REVOLVE && !crate::solids::mapped_graphics(p).is_empty(), ..revolve_options(p) }
+ }
}
/// The options effect `fx`, a Revolve or a Revolve (Classic), sweeps its surface with.
@@ -140,6 +154,17 @@ pub(crate) fn validate_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value)
let options = shared_options(&shapes, options_of(id, p))?;
let mut copies = 0;
shapes.iter().try_for_each(|s| check_surface(&s.path, b, options, surface_item(&s.items), &mut copies))?;
+ // Without graphics, or without the document they draw from, no member is measured for them.
+ if let Some(doc) = doc.filter(|_| id == REVOLVE && !crate::solids::mapped_graphics(p).is_empty()) {
+ match graphics_member(&shapes, b, options).and_then(|k| shapes.get(k)) {
+ Some(s) => check_graphics(Some(doc), id, &s.path, b, options, p, &mut 0)?,
+ // With no member to carry them, the graphics' art must still fit its limits.
+ None => {
+ crate::solids::graphics::validate_symbols(doc, p)?;
+ crate::solids::graphics::validate_surface_symbols(doc, p)?;
+ }
+ }
+ }
let casting = shapes.iter().filter(|s| surface_item(&s.items).is_some_and(|i| paint_of(i).1 * s.opacity > 0.0));
return check_shadow(p, casting.map(|s| &s.path), b, options);
}
@@ -151,9 +176,44 @@ pub(crate) fn validate_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value)
let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?;
let paints = solid_paints(n);
check_surface(&path, b, options_of(id, p), surface_item(&paints), &mut 0)?;
+ check_graphics(doc, id, &path, b, options_of(id, p), p, &mut 0)?;
check_shadow(p, [&path], b, options_of(id, p))
}
+/// An error when the graphics in Revolve (`id`) parameters `p`, with the symbols of `doc`, are past
+/// their limits on the surface of `path` about the axis of `b`: their art, or the parts they draw
+/// on its faces ([`crate::solids::graphics::surface_parts`]), whose paint copies count after
+/// `copies`, the bytes of the copies of the object's graphics so far ([`Copies`]). The parts'
+/// copies are added to `copies` when they fit, as drawing adds them. Without `doc` nothing is
+/// drawn, and nothing is checked.
+fn check_graphics(doc: Option<&Document>, id: &str, path: &PathData, b: Rect, options: Revolve, p: &Value, copies: &mut usize) -> Result<(), String> {
+ let graphics = crate::solids::mapped_graphics(p);
+ let Some(doc) = doc.filter(|_| id == REVOLVE && !graphics.is_empty()) else { return Ok(()) };
+ crate::solids::graphics::validate_symbols(doc, p)?;
+ crate::solids::graphics::validate_surface_symbols(doc, p)?;
+ let faces = vectorcraft_three_d::revolve(path, b, options)?;
+ let mut used = *copies;
+ match crate::solids::graphics::surface_parts(Some(doc), NodeId(0), &graphics, &faces, &|_, c| Paint::solid(c), &mut used) {
+ Some(_) => {
+ *copies = used;
+ Ok(())
+ }
+ None => Err("The graphics are too complex for this surface: simplify the symbols' art or reduce Segments".into()),
+ }
+}
+
+/// The member of a group whose side takes the group's graphics: the first with the longest
+/// profile among those with a fill or stroke.
+fn graphics_member(shapes: &[Profile], b: Rect, options: Revolve) -> Option {
+ shapes
+ .iter()
+ .enumerate()
+ .filter(|(_, s)| surface_item(&s.items).is_some())
+ .filter_map(|(k, s)| vectorcraft_three_d::revolve_surface_size(&s.path, b, options).map(|(_, h)| (k, h)))
+ .fold(None, |best: Option<(usize, f64)>, (k, h)| if best.is_some_and(|(_, top)| top >= h) { best } else { Some((k, h)) })
+ .map(|(k, _)| k)
+}
+
/// With the shadow of Revolve parameters `p` on, an error when the surfaces of `paths` it is cast
/// from, about the axis of `b`, are past the limits together.
fn check_shadow<'a>(p: &Value, paths: impl IntoIterator- , b: Rect, options: Revolve) -> Result<(), String> {
@@ -168,16 +228,17 @@ fn check_shadow<'a>(p: &Value, paths: impl IntoIterator
- , b:
/// appearance item `target` of `n` ([`validate_item_of`]).
#[cfg(test)]
pub(crate) fn validate_item_revolve(n: &Node, p: &Value, target: usize, index: usize) -> Result<(), String> {
- validate_item_of(n, REVOLVE, p, target, index)
+ validate_item_of(None, n, REVOLVE, p, target, index)
}
/// [`validate_revolve`] for a Revolve or Revolve (Classic), `id`, with parameters `p` at index
/// `index` of the effects of appearance item `target` of `n`. The surfaces of every fill and stroke
/// that carries one, `target`'s included, share one budget of paint copies, as in [`art`]. A visible
-/// object-level Revolve draws in place of those surfaces.
-pub(crate) fn validate_item_of(n: &Node, id: &str, p: &Value, target: usize, index: usize) -> Result<(), String> {
+/// object-level Revolve draws in place of those surfaces. With `doc`, `target`'s graphics must fit
+/// its surface ([`check_graphics`]).
+pub(crate) fn validate_item_of(doc: Option<&Document>, n: &Node, id: &str, p: &Value, target: usize, index: usize) -> Result<(), String> {
if is_container(n) || visible(&n.appearance.effects) || target >= n.appearance.items.len() {
- return validate_in(None, n, id, p);
+ return validate_in(doc, n, id, p);
}
validate_params_of(id, p)?;
const EMPTY: &str = "Revolve requires a shape with at least two anchors";
@@ -187,7 +248,7 @@ pub(crate) fn validate_item_of(n: &Node, id: &str, p: &Value, target: usize, ind
}
let ctx = GeomContext::of(n);
let path = geometry(&path, &n.appearance.effects, &ctx);
- let mut copies = 0;
+ let (mut copies, mut graphic_copies) = (0, 0);
for (i, item) in n.appearance.items.iter().enumerate() {
let effects = item.effects();
let at = if i == target { Some(index) } else { effects.iter().position(|e| e.visible && is_revolve(&e.id)) };
@@ -200,6 +261,12 @@ pub(crate) fn validate_item_of(n: &Node, id: &str, p: &Value, target: usize, ind
let path = geometry(&path, at.and_then(|at| effects.get(..at)).unwrap_or(effects), &ctx.item(item));
let b = path.bounds().ok_or(EMPTY)?;
check_surface(&path, b, options, crate::paints(item).then_some(item), &mut copies)?;
+ // The graphics of every item add their paints' copies to the graphics' budget, as they
+ // are drawn; only the edited item's graphics are refused here.
+ let graphics = check_graphics(doc, kind, &path, b, options, ¶ms, &mut graphic_copies);
+ if i == target {
+ graphics?;
+ }
check_shadow(¶ms, [&path], b, options)?;
}
Ok(())
@@ -234,7 +301,7 @@ fn shared_options(shapes: &[Profile], options: Revolve) -> Result
Option<&AppearanceItem> {
+pub(crate) fn surface_item(items: &[AppearanceItem]) -> Option<&AppearanceItem> {
items.iter().rev().find(|i| matches!(i, AppearanceItem::Fill(_))).or_else(|| items.last())
}
@@ -255,6 +322,7 @@ pub(crate) fn validate_revolve_params(p: &Value) -> Result<(), String> {
/// only its own parameters, each in its range.
pub(crate) fn validate_params_of(id: &str, p: &Value) -> Result<(), String> {
validate_revolve_params(p)?;
+ crate::solids::graphics::validate(id, p)?;
if id == CLASSIC_REVOLVE {
classic::validate(p)?;
}
@@ -303,10 +371,13 @@ fn group_shadow(id: NodeId, fx: &Effect, shapes: &[Profile], b: Rect, options: R
}
/// One object's surface of revolution about the axis of `b`, and each face's depth. `copies` counts
-/// the bytes of the paint copies of every surface of the object. Revolve (Classic) draws every face
-/// with its classic surface, as Extrude & Bevel (Classic) does.
+/// the bytes of the paint copies of every surface and graphic of the object. Revolve (Classic) draws every face
+/// with its classic surface, as Extrude & Bevel (Classic) does. Revolve's graphics, with the
+/// symbols of `doc`, are drawn on the faces of the side ([`crate::solids::graphics::surface_parts`]):
+/// a face with a part of a graphic is a group of the face and that part.
#[allow(clippy::too_many_arguments)]
fn surface(
+ doc: Option<&Document>,
n: &Node,
path: &PathData,
b: Rect,
@@ -316,14 +387,14 @@ fn surface(
opacity: f32,
blend: vectorcraft_doc::color::BlendMode,
expand: bool,
- copies: &mut usize,
+ copies: &mut Copies,
) -> Option<(Vec, Node)> {
// A loaded file or a profile edited past the limits keeps drawing its source path instead
// of crashing the frame. Commands validate the same evaluator and report its errors.
if fx.id == CLASSIC_REVOLVE {
let facets = vectorcraft_three_d::revolve_facets(path, b, options).ok()?;
- *copies = copies.saturating_add(paint_copies(facets.len(), &paint));
- if *copies > MAX_PAINT_COPIES {
+ copies.surfaces = copies.surfaces.saturating_add(paint_copies(facets.len(), &paint));
+ if copies.surfaces > MAX_PAINT_COPIES {
return None;
}
let depths = facets.iter().map(|f| f.depth).collect();
@@ -332,14 +403,33 @@ fn surface(
}
let colored = options.light_color != [1.0; 3] || options.lights.is_some();
let faces = vectorcraft_three_d::revolve(path, b, options).ok()?;
- *copies = copies.saturating_add(paint_copies(faces.len(), &paint));
- if *copies > MAX_PAINT_COPIES {
+ copies.surfaces = copies.surfaces.saturating_add(paint_copies(faces.len(), &paint));
+ if copies.surfaces > MAX_PAINT_COPIES {
return None;
}
+ let graphics = crate::solids::mapped_graphics(&merged_params(&fx.id, &fx.params));
+ let shade = |face: &vectorcraft_three_d::Face, c: Color| graphic_paint(face, c, colored);
+ // Graphics past the limits are left out, and the surface draws without them and their paints'
+ // copies.
+ let mut used = copies.graphics;
+ let mut parts = if graphics.is_empty() {
+ vec![]
+ } else {
+ match crate::solids::graphics::surface_parts(doc, n.id, &graphics, &faces, &shade, &mut used) {
+ Some(parts) => {
+ copies.graphics = used;
+ parts
+ }
+ None => vec![],
+ }
+ };
+ let drawn = parts.iter().any(|p| !p.is_empty());
// Visibility is an expansion option, never a change to live geometry or export baking.
// Keep all faces for paints whose coverage we cannot prove opaque. A bounded visibility
- // failure also retains the complete surface rather than losing any of the user's art.
+ // failure also retains the complete surface rather than losing any of the user's art. With
+ // graphics drawn, every face stays, each with its part of the graphics.
let visible = (expand
+ && !drawn
&& flag(&fx.params, "expandVisibleOnly", true)
&& paint.color().is_some()
&& opacity == 1.0
@@ -414,7 +504,16 @@ fn surface(
.collect();
Arc::new(Node::path(n.id, face.path, Appearance { items, ..Default::default() }))
})
- .collect();
+ .collect::>();
+ let children = if drawn {
+ children
+ .into_iter()
+ .zip(parts.iter_mut().map(std::mem::take).chain(std::iter::repeat_with(Vec::new)))
+ .map(|(face, part)| if part.is_empty() { face } else { Arc::new(Node::group(n.id, std::iter::once(face).chain(part).collect())) })
+ .collect()
+ } else {
+ children
+ };
let mut group = Node::group(n.id, children);
group.opacity = opacity;
group.blend = blend;
@@ -422,11 +521,33 @@ fn surface(
}
/// A surface of one object about the bounds of `path`, built with options derived from `fx`.
-/// `copies` is the count [`surface`] adds to.
-fn object_surface(n: &Node, path: &PathData, fx: &Effect, item: &AppearanceItem, expand: bool, copies: &mut usize) -> Option {
+/// `copies` is the count [`surface`] adds to; its graphics take the symbols of `doc`.
+fn object_surface(
+ doc: Option<&Document>,
+ n: &Node,
+ path: &PathData,
+ fx: &Effect,
+ item: &AppearanceItem,
+ expand: bool,
+ copies: &mut Copies,
+) -> Option {
let b = path.bounds()?;
let (paint, opacity, blend) = paint_of(item);
- surface(n, path, b, fx_options(fx), fx, paint, opacity, blend, expand, copies).map(|(_, art)| art)
+ surface(doc, n, path, b, fx_options(fx), fx, paint, opacity, blend, expand, copies).map(|(_, art)| art)
+}
+
+/// The paint of a graphic's solid color `c` on `face`, shaded as the face shades a solid fill of
+/// that color: across the face with its smooth shading, under colored lights (`colored`) with
+/// their color.
+fn graphic_paint(face: &vectorcraft_three_d::Face, c: Color, colored: bool) -> Paint {
+ if colored {
+ return face
+ .light_gradient
+ .map_or_else(|| Paint::solid(crate::lighting::shade(c, face.illumination)), |g| crate::lighting::smooth_paint(g, c));
+ }
+ let [r, g, b] = c.to_rgb();
+ let color = |s: f64| Color::rgb(r * s as f32, g * s as f32, b * s as f32);
+ face.gradient.map_or_else(|| Paint::solid(color(face.brightness)), |g| shading_paint(g, color, |_| 1.0))
}
fn shading_paint(g: vectorcraft_three_d::ShadeGradient, color: impl Fn(f64) -> Color, opacity: impl Fn(f64) -> f32) -> Paint {
@@ -592,12 +713,12 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option {
}
let n = &*resolve_for(doc, n, "Revolve", false).ok()?;
if is_container(n) {
- return group_art(n, expand);
+ return group_art(doc, n, expand);
}
let (path, rule) = profile(n)?;
let ctx = GeomContext::of(n);
let mut children = vec![];
- let mut copies = 0;
+ let mut copies = Copies::default();
let mut remaining = n.appearance.effects.clone();
if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && is_revolve(&e.id)) {
let fx = n.appearance.effects.get(index)?;
@@ -606,7 +727,7 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option {
let paints = solid_paints(n);
let item = surface_item(&paints)?;
let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id));
- let surface = object_surface(n, &path, fx, item, expand, &mut copies)?;
+ let surface = object_surface(doc, n, &path, fx, item, expand, &mut copies)?;
if let Some(b) = path.bounds()
&& let Some(shadow) = shadow_of(n.id, fx, &path, item, b, fx_options(fx)).ok()?
{
@@ -632,7 +753,7 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option {
let fx = item.effects().get(index)?;
let p = geometry(&path, item.effects().get(..index)?, &ctx.item(original));
let expand = expand && !item.effects().iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id));
- let mut art = object_surface(n, &p, fx, item, expand, &mut copies)?;
+ let mut art = object_surface(doc, n, &p, fx, item, expand, &mut copies)?;
art.appearance.effects = item.effects().iter().skip(index + 1).filter(|e| !is_revolve(&e.id)).cloned().collect();
if let Some(b) = p.bounds()
&& let Some(mut shadow) = shadow_of(n.id, fx, &p, item, b, fx_options(fx)).ok()?
@@ -774,7 +895,7 @@ pub(crate) fn overlaps(a: Rect, b: Rect) -> bool {
/// [`art`] of group or layer `n`: every member swept about one axis on the bounds of all of them,
/// each in its own paint. Every member's faces are drawn in one back-to-front order. `None` when
/// the effect is only on the group's own fills or strokes or when its members can't be revolved.
-fn group_art(n: &Node, expand: bool) -> Option {
+fn group_art(doc: Option<&Document>, n: &Node, expand: bool) -> Option {
let index = n.appearance.effects.iter().position(|e| e.visible && is_revolve(&e.id))?;
let fx = n.appearance.effects.get(index)?;
let mut shapes = members(n, "").ok()?;
@@ -782,17 +903,22 @@ fn group_art(n: &Node, expand: bool) -> Option {
let b = union_bounds(&shapes)?;
let options = shared_options(&shapes, fx_options(fx)).ok()?;
let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id));
- let mut copies = 0;
+ let mut copies = Copies::default();
let mut surfaces = vec![];
let mut opaque = true;
- for s in &shapes {
+ // The graphics go once, on the side of the member with the longest profile. Without graphics
+ // no member is measured for them.
+ let mapped = fx.id == REVOLVE && !crate::solids::mapped_graphics(&merged_params(&fx.id, &fx.params)).is_empty();
+ let longest = if mapped { graphics_member(&shapes, b, options) } else { None };
+ for (k, s) in shapes.iter().enumerate() {
let Some(item) = surface_item(&s.items) else { continue };
// The opacity and blending of the member and the groups it is in apply through its scopes.
let (paint, opacity, blend) = paint_of(item);
opaque &= opacity >= 1.0 && blend == Default::default() && s.scopes.is_empty();
// A member with transparency of its own keeps every face, as a transparent object does.
let expand = expand && s.scopes.is_empty();
- let (depths, art) = surface(n, &s.path, b, options, fx, paint, opacity, blend, expand, &mut copies)?;
+ let doc = doc.filter(|_| longest == Some(k));
+ let (depths, art) = surface(doc, n, &s.path, b, options, fx, paint, opacity, blend, expand, &mut copies)?;
surfaces.push((depths, art, s.scopes.as_slice()));
}
if surfaces.is_empty() {
@@ -1517,3 +1643,7 @@ mod tests {
assert_eq!(expand_revolve_art(&sample), revolve_art(&sample));
}
}
+
+#[cfg(test)]
+#[path = "revolve/tests_graphics.rs"]
+mod tests_graphics;
diff --git a/crates/effects/src/revolve/tests_graphics.rs b/crates/effects/src/revolve/tests_graphics.rs
new file mode 100644
index 0000000000..5d9500628b
--- /dev/null
+++ b/crates/effects/src/revolve/tests_graphics.rs
@@ -0,0 +1,576 @@
+use serde_json::json;
+use vectorcraft_doc::Symbol;
+use vectorcraft_doc::color::Color;
+use vectorcraft_geom::{BezPath, shapes};
+
+use super::*;
+use crate::solids::three_d_params_as;
+
+fn bar(r: Rect, color: Color) -> Node {
+ Node::path(NodeId(0), shapes::rectangle(r), Appearance::basic(Paint::solid(color), Paint::None, 0.0))
+}
+
+/// A document with symbols "Red" and "Blue" (10 × 20 bars) and "Wide" (a red 300 × 10 bar).
+fn document() -> Document {
+ let mut d = Document::new(1000.0, 1000.0);
+ for (name, r, color) in [
+ ("Red", Rect::new(0.0, 0.0, 10.0, 20.0), Color::rgb(1.0, 0.0, 0.0)),
+ ("Blue", Rect::new(0.0, 0.0, 10.0, 20.0), Color::rgb(0.0, 0.0, 1.0)),
+ ("Wide", Rect::new(0.0, 0.0, 300.0, 10.0), Color::rgb(1.0, 0.0, 0.0)),
+ ("Flat", Rect::new(0.0, 0.0, 10.0, 2.0), Color::rgb(1.0, 0.0, 0.0)),
+ ] {
+ d.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(bar(r, color)) });
+ }
+ d
+}
+
+/// A gray open profile through `points` (its first and last on the axis at x 0), revolved with
+/// `params` over a view straight on without shading.
+fn revolved(points: &[(f64, f64)], params: Value) -> Node {
+ let mut b = BezPath::new();
+ b.move_to(points[0]);
+ points[1..].iter().for_each(|p| b.line_to(*p));
+ let mut p = json!({"rotationX": 0.0, "rotationY": 0.0, "rotationZ": 0.0, "shade": false});
+ p.as_object_mut().unwrap().extend(params.as_object().unwrap().clone());
+ let mut n = Node::path(NodeId(7), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(Color::rgb(0.5, 0.5, 0.5)), Paint::None, 0.0));
+ n.appearance.effects.push(crate::new_effect(REVOLVE, &p).unwrap());
+ n
+}
+
+/// A cylinder of radius 50 about x 0, from y 0 to 200.
+const CYLINDER: [(f64, f64); 4] = [(0.0, 0.0), (50.0, 0.0), (50.0, 200.0), (0.0, 200.0)];
+
+/// A profile that narrows from 50 to 20 from the axis at y 100 and widens again.
+const NECK: [(f64, f64); 5] = [(0.0, 0.0), (50.0, 0.0), (20.0, 100.0), (50.0, 200.0), (0.0, 200.0)];
+
+/// The red channel of each color `paint` draws when it is a shade of pure red or pure blue (`blue`):
+/// a solid color or the stops of a gradient. `None` for any other paint.
+fn shade_of(paint: &Paint, blue: bool) -> Option> {
+ let hue = |c: &Color| {
+ let [r, g, b] = c.to_rgb();
+ let (on, off) = if blue { (b, r) } else { (r, b) };
+ (on > 0.02 && off < 0.01 && g < 0.01).then_some(on)
+ };
+ match paint {
+ Paint::Solid { .. } => Some(vec![hue(&paint.color()?)?]),
+ Paint::Gradient(g) => g.gradient.stops.iter().map(|s| hue(&s.color)).collect(),
+ _ => None,
+ }
+}
+
+/// The paths in `art` filled in a shade of red (or blue), with their shades.
+fn parts(art: &Node, blue: bool) -> Vec<(PathData, Vec)> {
+ let mut out = vec![];
+ art.walk(&mut |n| {
+ let Some(path) = n.path_data() else { return };
+ let fill = n.appearance.items.iter().find_map(|i| match i {
+ AppearanceItem::Fill(f) => shade_of(&f.paint, blue),
+ AppearanceItem::Stroke(_) => None,
+ });
+ if let Some(shades) = fill {
+ out.push((path.clone(), shades));
+ }
+ });
+ out
+}
+
+fn reach(parts: &[(PathData, Vec)]) -> Rect {
+ parts.iter().filter_map(|(p, _)| p.bounds()).reduce(|a, b| a.union(b)).unwrap()
+}
+
+fn near(a: f64, b: f64, tolerance: f64) -> bool {
+ (a - b).abs() <= tolerance
+}
+
+#[test]
+fn a_graphic_wraps_around_the_side_and_narrows_toward_the_silhouette() {
+ let d = document();
+ let n = revolved(&CYLINDER, json!({"graphics": [{"symbol": "Red"}, {"symbol": "Blue", "x": 60.0}]}));
+ let art = revolve_art_in(Some(&d), &n).unwrap();
+ // At the middle of the side, facing the viewer, the bar keeps its size: 10 wide at the axis's
+ // line, from 10 above the middle of the profile to 10 below.
+ let front = reach(&parts(&art, false));
+ let half = 50.0 * (5.0f64 / 50.0).sin();
+ assert!(near(front.x0, -half, 0.05) && near(front.x1, half, 0.05), "{front:?}");
+ assert!(near(front.y0, 90.0, 1e-6) && near(front.y1, 110.0, 1e-6), "{front:?}");
+ // 60 to its right the side has turned 1.1 to 1.3 radians away: the same bar draws less than
+ // half as wide, at the sine of those angles.
+ let turned = reach(&parts(&art, true));
+ assert!(near(turned.x0, 50.0 * 1.1f64.sin(), 0.15) && near(turned.x1, 50.0 * 1.3f64.sin(), 0.15), "{turned:?}");
+ assert!(turned.width() < front.width() / 2.0);
+}
+
+#[test]
+fn the_part_past_the_silhouette_and_on_the_back_is_not_drawn() {
+ let d = document();
+ // A bar wider than the half of the side seen from the front reaches the silhouette and no
+ // further.
+ let n = revolved(&CYLINDER, json!({"graphics": [{"symbol": "Wide"}]}));
+ let wide = reach(&parts(&revolve_art_in(Some(&d), &n).unwrap(), false));
+ assert!(wide.x0 > -50.0 - 1e-6 && wide.x1 < 50.0 + 1e-6, "{wide:?}");
+ assert!(wide.x0 < -49.5 && wide.x1 > 49.5, "{wide:?}");
+ // On the back, half a turn around the axis, nothing is drawn.
+ let n = revolved(&CYLINDER, json!({"graphics": [{"symbol": "Red", "x": 150.0}]}));
+ assert!(parts(&revolve_art_in(Some(&d), &n).unwrap(), false).is_empty());
+ // Turned half a turn, the back faces the viewer.
+ let n = revolved(&CYLINDER, json!({"rotationY": 180.0, "graphics": [{"symbol": "Red", "x": 150.0}]}));
+ assert!(!parts(&revolve_art_in(Some(&d), &n).unwrap(), false).is_empty());
+}
+
+#[test]
+fn a_graphic_follows_the_profile_and_keeps_its_size_where_it_comes_nearer_the_axis() {
+ let d = document();
+ // A 10 wide bar keeps its length around the axis at every distance from it: at the neck, 20
+ // from the axis, it spans 0.5 radians; half way up the slanted side (52.2 along it, 35 from
+ // the axis, at y 50), 2/7 of a radian. Straight on, both draw nearly 10 wide.
+ let slant = (30.0f64 * 30.0 + 100.0 * 100.0).sqrt();
+ let n = revolved(&NECK, json!({"graphics": [{"symbol": "Flat"}, {"symbol": "Flat", "y": -slant / 2.0}]}));
+ let art = revolve_art_in(Some(&d), &n).unwrap();
+ let all = parts(&art, false);
+ let neck: Vec<_> = all.iter().filter(|(p, _)| p.bounds().unwrap().center().y > 75.0).cloned().collect();
+ let up: Vec<_> = all.iter().filter(|(p, _)| p.bounds().unwrap().center().y <= 75.0).cloned().collect();
+ let (neck, up) = (reach(&neck), reach(&up));
+ // Each bar is 2 tall, so its ends are up to 0.3 farther from the axis than its middle.
+ assert!(near(neck.width(), 2.0 * 20.0 * 0.25f64.sin(), 0.15), "{neck:?}");
+ assert!(near(neck.center().y, 100.0, 0.05), "{neck:?}");
+ assert!(near(up.width(), 2.0 * 35.0 * (5.0f64 / 35.0).sin(), 0.15), "{up:?}");
+ assert!(near(up.center().y, 50.0, 0.05), "{up:?}");
+ // At the neck a bar 25 to the right lies 1 to 1.5 radians around, near the silhouette; one 40
+ // to the right lies 1.75 to 2.25 radians around, past the silhouette, and is not drawn.
+ let n = revolved(&NECK, json!({"graphics": [{"symbol": "Flat", "x": 25.0}]}));
+ let side = reach(&parts(&revolve_art_in(Some(&d), &n).unwrap(), false));
+ assert!(near(side.x0, 20.0 * 1.0f64.sin(), 0.3) && near(side.x1, 20.0 * 1.5f64.sin(), 0.3), "{side:?}");
+ let n = revolved(&NECK, json!({"graphics": [{"symbol": "Flat", "x": 40.0}]}));
+ assert!(parts(&revolve_art_in(Some(&d), &n).unwrap(), false).is_empty());
+}
+
+#[test]
+fn with_the_axis_on_the_right_a_graphic_starts_on_the_front() {
+ let d = document();
+ // The cylinder of radius 50 about x 0, swept from its right edge.
+ let profile = [(0.0, 0.0), (-50.0, 0.0), (-50.0, 200.0), (0.0, 200.0)];
+ let n = revolved(&profile, json!({"edge": "right", "graphics": [{"symbol": "Red"}]}));
+ let front = reach(&parts(&revolve_art_in(Some(&d), &n).unwrap(), false));
+ let half = 50.0 * (5.0f64 / 50.0).sin();
+ assert!(near(front.x0, -half, 0.05) && near(front.x1, half, 0.05), "{front:?}");
+ assert!(near(front.y0, 90.0, 1e-6) && near(front.y1, 110.0, 1e-6), "{front:?}");
+ // x increases to the viewer's right here too.
+ let n = revolved(&profile, json!({"edge": "right", "graphics": [{"symbol": "Red", "x": 25.0}]}));
+ let right = reach(&parts(&revolve_art_in(Some(&d), &n).unwrap(), false));
+ assert!(near(right.center().x, 50.0 * 0.5f64.sin(), 0.3), "{right:?}");
+}
+
+#[test]
+fn a_graphic_is_shaded_with_the_surface() {
+ let d = document();
+ // The light is on the left: the bar left of the middle is lighter than the one on its right,
+ // and neither keeps its unlit red.
+ let n = revolved(&CYLINDER, json!({"shade": true, "graphics": [{"symbol": "Red", "x": -30.0}, {"symbol": "Red", "x": 30.0}]}));
+ let all = parts(&revolve_art_in(Some(&d), &n).unwrap(), false);
+ let mean = |left: bool| {
+ let shades: Vec =
+ all.iter().filter(|(p, _)| (p.bounds().unwrap().center().x < 0.0) == left).flat_map(|(_, s)| s.iter().copied()).collect();
+ shades.iter().sum::() / shades.len() as f32
+ };
+ assert!(mean(true) > mean(false) + 0.05, "{} {}", mean(true), mean(false));
+ assert!(all.iter().all(|(_, s)| s.iter().all(|r| *r < 0.999)));
+}
+
+#[test]
+fn overlapping_graphics_keep_their_order_on_every_face() {
+ let d = document();
+ // The wide red bar, listed second, reaches across the narrow blue one, listed first. Shaded,
+ // each face is one triangle.
+ let n = revolved(&CYLINDER, json!({"shade": true, "graphics": [{"symbol": "Blue", "x": 30.0}, {"symbol": "Wide"}]}));
+ let art = revolve_art_in(Some(&d), &n).unwrap();
+ let mut both = 0;
+ art.walk(&mut |g| {
+ // A face with parts of graphics: the face's path, then the parts' paths.
+ let NodeKind::Group { children, clip: false } = &g.kind else { return };
+ if !children.iter().all(|c| matches!(c.kind, NodeKind::Path { .. })) {
+ return;
+ }
+ let colors: Vec = children
+ .iter()
+ .filter_map(|c| {
+ let blue = !parts(c, true).is_empty();
+ (blue || !parts(c, false).is_empty()).then_some(blue)
+ })
+ .collect();
+ if colors.contains(&true) && colors.contains(&false) {
+ both += 1;
+ assert!(colors.iter().position(|b| !b) > colors.iter().position(|b| *b), "{colors:?}");
+ }
+ });
+ assert!(both > 0);
+}
+
+#[test]
+fn a_graphic_past_the_end_reaches_the_curved_rim_and_no_further() {
+ let d = document();
+ // A tube 50 from the axis with a cap at each end, in 10 segments, tilted to show its top: the
+ // bottom rim is an arc on the page whose lowest point, in front, lies between two corners of
+ // the faces. The wide bar runs 5 past the bottom of the profile.
+ for shade in [true, false] {
+ let mut b = BezPath::new();
+ b.move_to((0.0, 0.0));
+ b.line_to((0.0, 200.0));
+ let p = json!({"offset": 50.0, "segments": 10, "rotationX": -30.0, "rotationY": 0.0, "rotationZ": 0.0, "shade": shade, "graphics": [{"symbol": "Wide", "y": 100.0}]});
+ let mut n = Node::path(NodeId(7), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(Color::rgb(0.5, 0.5, 0.5)), Paint::None, 0.0));
+ n.appearance.effects.push(crate::new_effect(REVOLVE, &p).unwrap());
+ let art = revolve_art_in(Some(&d), &n).unwrap();
+ let mut lowest = f64::NEG_INFINITY;
+ art.walk(&mut |c| {
+ if let Some(b) = c.path_data().and_then(PathData::bounds) {
+ lowest = lowest.max(b.y1);
+ }
+ });
+ let bar = reach(&parts(&art, false));
+ assert!(near(bar.y1, lowest, 0.05), "shade {shade}: {bar:?} {lowest}");
+ // A thin bar wholly past the end, 0.35 to 0.85 below it in front, lies between the faces'
+ // straight lower edge and the rim's arc there: it is drawn, inside the rim.
+ let mut thin = n.clone();
+ let p = json!({"offset": 50.0, "segments": 10, "rotationX": -30.0, "rotationY": 0.0, "rotationZ": 0.0, "shade": shade, "graphics": [{"symbol": "Flat", "y": 100.6, "scale": 25.0}]});
+ thin.appearance.effects = vec![crate::new_effect(REVOLVE, &p).unwrap()];
+ let past = parts(&revolve_art_in(Some(&d), &thin).unwrap(), false);
+ assert!(!past.is_empty(), "shade {shade}");
+ assert!(reach(&past).y1 <= lowest + 1e-6, "shade {shade}: {:?} {lowest}", reach(&past));
+ }
+}
+
+#[test]
+fn under_several_lights_a_graphic_takes_each_faces_own_lit_art() {
+ let d = document();
+ let lights = json!([{"color": "#ff8080", "rotation": 120.0}, {"color": "#8080ff", "rotation": -30.0}]);
+ let clips = |n: &Node| {
+ let mut count = 0;
+ revolve_art_in(Some(&d), n).unwrap().walk(&mut |c| count += usize::from(matches!(c.kind, NodeKind::Group { clip: true, .. })));
+ count
+ };
+ let plain = revolved(&CYLINDER, json!({"shade": true, "lights": lights}));
+ let mapped = revolved(&CYLINDER, json!({"shade": true, "lights": lights, "graphics": [{"symbol": "Red"}]}));
+ // Each part of the bar is the face's lit art clipped to the part: one clip more for the
+ // part, one for the face's own art inside it.
+ assert!(clips(&mapped) > clips(&plain), "{} {}", clips(&mapped), clips(&plain));
+ // A lit face draws its paint under the face's light: a layer of the light's three channels,
+ // multiplied over the paint. Every part of the bar takes such a layer of its own, so the bar
+ // is shaded with each face's colored light; a bar drawn in flat red would add none.
+ let lit = |n: &Node| {
+ let mut count = 0;
+ revolve_art_in(Some(&d), n).unwrap().walk(&mut |c| count += usize::from(c.blend == vectorcraft_doc::color::BlendMode::Multiply));
+ count
+ };
+ assert!(lit(&mapped) > lit(&plain), "{} {}", lit(&mapped), lit(&plain));
+ // An opaque part also draws its lit art clipped to the band of its seam stroke; a translucent
+ // part, whose overlap would show, does not.
+ let mut d = d.clone();
+ let mut clear = bar(Rect::new(0.0, 0.0, 10.0, 20.0), Color::rgb(1.0, 0.0, 0.0));
+ if let Some(AppearanceItem::Fill(f)) = clear.appearance.items.first_mut() {
+ f.opacity = 0.5;
+ }
+ d.symbols.push(Symbol { dynamic: false, name: "Clear".into(), art: Arc::new(clear) });
+ let clips = |n: &Node| {
+ let mut count = 0;
+ revolve_art_in(Some(&d), n).unwrap().walk(&mut |c| count += usize::from(matches!(c.kind, NodeKind::Group { clip: true, .. })));
+ count
+ };
+ let translucent = revolved(&CYLINDER, json!({"shade": true, "lights": lights, "graphics": [{"symbol": "Clear"}]}));
+ let (base, opaque, translucent) = (clips(&plain), clips(&mapped), clips(&translucent));
+ assert!(translucent > base && opaque - base == 2 * (translucent - base), "{base} {opaque} {translucent}");
+}
+
+#[test]
+fn graphics_past_the_point_limit_are_refused() {
+ let mut d = document();
+ let dots: Vec> = (0..2000)
+ .map(|i| {
+ let c = Point::new(f64::from(i % 50) * 12.0, f64::from(i / 50) * 12.0);
+ Arc::new(Node::path(
+ NodeId(0),
+ shapes::ellipse(Rect::new(c.x, c.y, c.x + 10.0, c.y + 10.0)),
+ Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
+ ))
+ })
+ .collect();
+ d.symbols.push(Symbol { dynamic: false, name: "Dots".into(), art: Arc::new(Node::group(NodeId(0), dots)) });
+ let n = revolved(&CYLINDER, json!({}));
+ let p = |scale: f64| json!({"graphics": [{"symbol": "Dots", "scale": scale}]});
+ assert!(validate_revolve_in(Some(&d), &n, &p(100.0)).is_ok());
+ // At 100 times the size each dot is cut into hundreds of straight pieces.
+ let e = validate_revolve_in(Some(&d), &n, &p(10_000.0)).unwrap_err();
+ assert!(e.contains("too complex"), "{e}");
+}
+
+#[test]
+fn graphics_cut_into_too_many_parts_are_refused_on_the_object_and_on_a_fill() {
+ let mut d = document();
+ // 4,000 thin bars across the side: few points each, but each is cut at every face it crosses.
+ let bars: Vec> =
+ (0..4000).map(|i| Arc::new(bar(Rect::new(0.0, f64::from(i) * 0.05, 300.0, f64::from(i) * 0.05 + 0.02), Color::BLACK))).collect();
+ d.symbols.push(Symbol { dynamic: false, name: "Bars".into(), art: Arc::new(Node::group(NodeId(0), bars)) });
+ let p = json!({"segments": 128, "graphics": [{"symbol": "Bars"}]});
+ let n = revolved(&CYLINDER, json!({}));
+ let e = validate_revolve_in(Some(&d), &n, &p).unwrap_err();
+ assert!(e.contains("too complex for this surface"), "{e}");
+ assert!(validate_revolve_in(Some(&d), &n, &json!({"graphics": [{"symbol": "Red"}]})).is_ok());
+ // The same on a Revolve on the object's fill.
+ let mut filled = n.clone();
+ let fx = filled.appearance.effects.remove(0);
+ if let Some(AppearanceItem::Fill(f)) = filled.appearance.items.first_mut() {
+ f.effects.push(fx);
+ }
+ let e = crate::solids::validate_three_d_at_in(Some(&d), &filled, REVOLVE, &p, (Some(0), 0)).unwrap_err();
+ assert!(e.contains("too complex for this surface"), "{e}");
+}
+
+#[test]
+fn without_the_document_nothing_is_drawn_and_expand_keeps_the_graphics() {
+ let d = document();
+ let n = revolved(&CYLINDER, json!({"graphics": [{"symbol": "Red"}]}));
+ assert!(parts(&revolve_art(&n).unwrap(), false).is_empty());
+ let shown = parts(&revolve_art_in(Some(&d), &n).unwrap(), false);
+ let expanded = parts(&expand_revolve_art_in(Some(&d), &n).unwrap(), false);
+ assert!(!shown.is_empty());
+ assert_eq!(shown, expanded);
+ // In a group the graphics go once, on the side of the member with the longest profile.
+ let mut member = n.clone();
+ member.appearance.effects.clear();
+ let mut short = member.clone();
+ if let NodeKind::Path { path, .. } = &mut short.kind {
+ *path = PathData::from_bezpath(&{
+ let mut b = BezPath::new();
+ b.move_to((0.0, 80.0));
+ b.line_to((30.0, 80.0));
+ b.line_to((30.0, 120.0));
+ b.line_to((0.0, 120.0));
+ b
+ });
+ }
+ let mut group = Node::group(NodeId(9), vec![Arc::new(short), Arc::new(member)]);
+ group.appearance.effects = n.appearance.effects.clone();
+ assert_eq!(parts(&revolve_art_in(Some(&d), &group).unwrap(), false).len(), shown.len());
+}
+
+#[test]
+fn revolve_takes_graphics_on_its_side_only() {
+ let front = json!({"graphics": [{"symbol": "Red"}]});
+ let back = json!({"graphics": [{"symbol": "Red", "face": "back"}]});
+ assert!(crate::solids::graphics::validate(REVOLVE, &front).is_ok());
+ assert!(crate::solids::graphics::validate(REVOLVE, &back).unwrap_err().contains("side"));
+ assert!(crate::solids::graphics::validate(CLASSIC_REVOLVE, &front).is_err());
+ let n = revolved(&CYLINDER, json!({}));
+ assert!(validate_revolve(&n, &back).is_err());
+ // Changing the type keeps the front's graphics on Revolve's side and drops the back's.
+ let both = json!({"graphics": [{"symbol": "Red"}, {"symbol": "Blue", "face": "back"}]});
+ let kept = three_d_params_as(crate::EXTRUDE, &both, REVOLVE);
+ assert_eq!(kept["graphics"].as_array().map(Vec::len), Some(1));
+ assert_eq!(kept["graphics"][0]["symbol"], "Red");
+ assert_eq!(three_d_params_as(REVOLVE, &front, crate::INFLATE).get("graphics"), None);
+}
+
+#[test]
+fn revolve_classic_draws_no_graphics_its_parameters_carry() {
+ let d = document();
+ let mut n = revolved(&CYLINDER, json!({"graphics": [{"symbol": "Red"}]}));
+ assert!(!parts(&revolve_art_in(Some(&d), &n).unwrap(), false).is_empty());
+ // A file can hold graphics on Revolve (Classic), which commands refuse: Revolve (Classic)
+ // draws its classic surface, without them.
+ if let Some(fx) = n.appearance.effects.first_mut() {
+ fx.id = CLASSIC_REVOLVE.into();
+ }
+ assert!(parts(&revolve_art_in(Some(&d), &n).unwrap(), false).is_empty());
+}
+
+#[test]
+fn a_group_with_no_member_to_carry_graphics_still_checks_their_art() {
+ let mut d = document();
+ let dots: Vec> = (0..2000)
+ .map(|i| {
+ let c = Point::new(f64::from(i % 50) * 12.0, f64::from(i / 50) * 12.0);
+ Arc::new(Node::path(
+ NodeId(0),
+ shapes::ellipse(Rect::new(c.x, c.y, c.x + 10.0, c.y + 10.0)),
+ Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
+ ))
+ })
+ .collect();
+ d.symbols.push(Symbol { dynamic: false, name: "Dots".into(), art: Arc::new(Node::group(NodeId(0), dots)) });
+ let n = revolved(&CYLINDER, json!({}));
+ let mut member = n.clone();
+ member.appearance.effects.clear();
+ member.appearance.items.clear();
+ let mut group = Node::group(NodeId(9), vec![Arc::new(member)]);
+ group.appearance.effects = n.appearance.effects.clone();
+ // No member has a fill or stroke, so none carries the graphics, but art past the limits is
+ // refused as it is on a group whose member carries them.
+ let e = validate_in(Some(&d), &group, REVOLVE, &json!({"graphics": [{"symbol": "Dots", "scale": 10_000.0}]})).unwrap_err();
+ assert!(e.contains("too complex"), "{e}");
+ assert!(validate_in(Some(&d), &group, REVOLVE, &json!({"graphics": [{"symbol": "Red"}]})).is_ok());
+}
+
+#[test]
+fn a_full_turn_draws_a_graphic_on_either_side_of_the_cut_at_the_back() {
+ let mut d = document();
+ d.symbols.push(Symbol { dynamic: false, name: "Thin".into(), art: Arc::new(bar(Rect::new(0.0, 0.0, 2.0, 20.0), Color::rgb(1.0, 0.0, 0.0))) });
+ // Half a turn around a radius of 50 is about 157 to each side of the middle: turned to face
+ // the viewer, a graphic just inside either end is drawn.
+ for x in [155.0, -155.0] {
+ let n = revolved(&CYLINDER, json!({"rotationY": 180.0, "graphics": [{"symbol": "Thin", "x": x}]}));
+ assert!(!parts(&revolve_art_in(Some(&d), &n).unwrap(), false).is_empty(), "x {x}");
+ }
+}
+
+/// A gray cylinder of radius 50 whose side is 200 edges along the profile, from y 0 to 200.
+fn ribbed() -> Vec<(f64, f64)> {
+ let mut points = vec![(0.0, 0.0)];
+ points.extend((0..=200).map(|k| (50.0, f64::from(k))));
+ points.push((0.0, 200.0));
+ points
+}
+
+/// A symbol `name` of one red path whose contours are `contours`, each a polygon through its
+/// points.
+fn contours_symbol(name: &str, contours: &[Vec]) -> Symbol {
+ let mut b = BezPath::new();
+ for contour in contours {
+ b.move_to(contour[0]);
+ contour[1..].iter().for_each(|p| b.line_to(*p));
+ b.close_path();
+ }
+ let art = Node::path(NodeId(0), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0));
+ Symbol { dynamic: false, name: name.into(), art: Arc::new(art) }
+}
+
+#[test]
+fn contours_away_from_a_face_are_not_cut_at_it() {
+ // 2,000 small squares in two clusters on the back, at opposite corners of the side: the path's
+ // bounds reach across every face, but no square reaches a face that is drawn.
+ let squares: Vec> = (0..2000)
+ .map(|i| {
+ let (x, y) = if i < 1000 { (0.0, 0.0) } else { (290.0, 190.0) };
+ let (x, y) = (x + f64::from(i % 10) * 0.1, y + f64::from(i % 1000 / 10) * 0.1);
+ vec![Point::new(x, y), Point::new(x + 0.05, y), Point::new(x + 0.05, y + 0.05), Point::new(x, y + 0.05)]
+ })
+ .collect();
+ let mut d = document();
+ d.symbols.push(contours_symbol("Clusters", &squares));
+ let n = revolved(&ribbed(), json!({}));
+ let p = json!({"graphics": [{"symbol": "Clusters"}]});
+ assert_eq!(validate_revolve_in(Some(&d), &n, &p), Ok(()));
+}
+
+#[test]
+fn a_contour_that_reaches_every_face_and_draws_on_none_is_refused() {
+ // One contour of 10,000 points: a thin frame around the whole side, past its ends and on the
+ // back, so it is cut at every face and keeps nothing.
+ let edge = |from: Point, to: Point| (0..2500).map(move |k| from.lerp(to, f64::from(k) / 2500.0));
+ let (outer, inner) = ([(0.0, 0.0), (400.0, 0.0), (400.0, 400.0), (0.0, 400.0)], [(1.0, 1.0), (1.0, 399.0), (399.0, 399.0), (399.0, 1.0)]);
+ let ring = |corners: [(f64, f64); 4]| (0..4).flat_map(move |k| edge(corners[k].into(), corners[(k + 1) % 4].into())).collect::>();
+ let mut frame = ring(outer);
+ frame.extend(ring(inner));
+ let mut d = document();
+ d.symbols.push(contours_symbol("Frame", &[frame]));
+ let n = revolved(&ribbed(), json!({}));
+ let e = validate_revolve_in(Some(&d), &n, &json!({"graphics": [{"symbol": "Frame"}]})).unwrap_err();
+ assert!(e.contains("too complex for this surface"), "{e}");
+ assert!(parts(&revolve_art_in(Some(&d), &n).unwrap(), false).is_empty());
+}
+
+/// A red linear gradient of `stops` stops, each with `opacity`.
+fn ramp(stops: usize, opacity: f32) -> Paint {
+ use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
+ let stops = (0..stops)
+ .map(|i| {
+ let mut stop = GradientStop::new(i as f32 / stops as f32, Color::rgb(1.0, 0.0, 0.0));
+ stop.opacity = opacity;
+ stop
+ })
+ .collect();
+ Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))))
+}
+
+#[test]
+fn the_copies_of_a_graphics_paint_have_a_budget_of_their_own() {
+ let mut d = document();
+ for (name, opacity) in [("Ramp", 1.0), ("Clear ramp", 0.5)] {
+ let art =
+ Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 300.0, 10.0)), Appearance::basic(ramp(4096, opacity), Paint::None, 0.0));
+ d.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(art) });
+ }
+ let p = json!({"graphics": [{"symbol": "Ramp"}]});
+ let n = revolved(&CYLINDER, json!({}));
+ let NodeKind::Path { path, .. } = &n.kind else { panic!("a path") };
+ let faces = vectorcraft_three_d::revolve(path, path.bounds().unwrap(), options_of(REVOLVE, &p)).unwrap();
+ let parts_of = |symbol: &str, copies: &mut usize| {
+ let graphics = crate::solids::mapped_graphics(&json!({"graphics": [{"symbol": symbol}]}));
+ crate::solids::graphics::surface_parts(Some(&d), NodeId(0), &graphics, &faces, &|_, c| Paint::solid(c), copies).map(|p| p.concat().len())
+ };
+ // Each opaque part copies the paint into its fill and its seam stroke; a part with
+ // transparent stops takes no seam stroke and copies it once.
+ let (opaque, clear) = (paint_bytes(&ramp(4096, 1.0)), paint_bytes(&ramp(4096, 0.5)));
+ let mut used = 0;
+ let count = parts_of("Ramp", &mut used).unwrap();
+ assert!(count >= 2, "the graphic draws on several faces");
+ assert_eq!(used, 2 * count * opaque);
+ let mut used = 0;
+ assert_eq!(parts_of("Clear ramp", &mut used), Some(count));
+ assert_eq!(used, count * clear);
+ // Past the budget the graphic is refused.
+ assert_eq!(parts_of("Ramp", &mut (MAX_PAINT_COPIES - opaque)), None);
+ // A surface whose own gradient takes nearly the whole budget for surfaces still takes the
+ // graphic, on the object, on one fill of two and on a member of a group.
+ let stop = paint_bytes(&ramp(2, 1.0)) - paint_bytes(&ramp(1, 1.0));
+ let base = paint_bytes(&ramp(1, 1.0)) - stop;
+ let gray = Paint::solid(Color::rgb(0.5, 0.5, 0.5));
+ let full = ramp(((MAX_PAINT_COPIES - paint_copies(faces.len(), &gray)) / (faces.len() * 4) - base) / stop, 1.0);
+ assert!(MAX_PAINT_COPIES - paint_copies(faces.len(), &gray) - paint_copies(faces.len(), &full) < opaque);
+ let mut object = n.clone();
+ if let Some(AppearanceItem::Fill(f)) = object.appearance.items.first_mut() {
+ f.paint = ramp(((MAX_PAINT_COPIES - opaque) / (faces.len() * 4) - base) / stop, 1.0);
+ }
+ assert_eq!(validate_revolve_in(Some(&d), &object, &p), Ok(()));
+ let fill = |paint: Paint, params: Value| {
+ let mut fill = FillLayer::new(paint);
+ fill.effects.push(crate::new_effect(REVOLVE, ¶ms).unwrap());
+ AppearanceItem::Fill(fill)
+ };
+ let mut two = n.clone();
+ two.appearance = Appearance { items: vec![fill(gray.clone(), p.clone()), fill(full.clone(), json!({}))], ..Default::default() }.into();
+ assert_eq!(crate::solids::validate_three_d_at_in(Some(&d), &two, REVOLVE, &json!({}), (Some(1), 0)), Ok(()));
+ let mut member = n.clone();
+ member.appearance.effects.clear();
+ let mut second = member.clone();
+ if let Some(AppearanceItem::Fill(f)) = second.appearance.items.first_mut() {
+ f.paint = full;
+ }
+ let mut group = Node::group(NodeId(9), vec![Arc::new(member), Arc::new(second)]);
+ group.appearance.effects = n.appearance.effects.clone();
+ assert_eq!(validate_in(Some(&d), &group, REVOLVE, &p), Ok(()));
+}
+
+/// Whether each part of `art` filled with a gradient has a stroke over its seams.
+fn gradient_seams(art: &Node) -> Vec {
+ let mut out = vec![];
+ art.walk(&mut |n| {
+ let items = &n.appearance.items;
+ let red = |p: &Paint| matches!(p, Paint::Gradient(_)) && shade_of(p, false).is_some();
+ if n.path_data().is_some() && matches!(items.first(), Some(AppearanceItem::Fill(f)) if red(&f.paint)) {
+ out.push(items.iter().any(|i| matches!(i, AppearanceItem::Stroke(_))));
+ }
+ });
+ out
+}
+
+#[test]
+fn a_gradient_with_transparent_stops_takes_no_stroke_over_its_seams() {
+ let mut d = document();
+ for (name, opacity) in [("Opaque", 1.0), ("Clear", 0.5)] {
+ let art = Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 60.0, 10.0)), Appearance::basic(ramp(2, opacity), Paint::None, 0.0));
+ d.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(art) });
+ }
+ let seams = |symbol: &str| gradient_seams(&revolve_art_in(Some(&d), &revolved(&CYLINDER, json!({"graphics": [{"symbol": symbol}]}))).unwrap());
+ let opaque = seams("Opaque");
+ assert!(!opaque.is_empty() && opaque.iter().all(|s| *s), "{opaque:?}");
+ let clear = seams("Clear");
+ assert!(!clear.is_empty() && clear.iter().all(|s| !*s), "{clear:?}");
+}
diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs
index aeec471bcd..9b364c3d3a 100644
--- a/crates/effects/src/solids.rs
+++ b/crates/effects/src/solids.rs
@@ -21,13 +21,14 @@ pub use classic::{CLASSIC_EXTRUDE, CLASSIC_ROTATE, ROTATE_SURFACES, SHADING_COLO
mod source;
pub(crate) use source::{resolve, resolve_for};
-mod graphics;
+pub(crate) mod graphics;
pub use graphics::{
GRAPHICS, GraphicFrame, MAX_GRAPHICS, MappedGraphic, graphic_symbols, mapped_graphics, missing_symbol, names_graphic_symbol,
rename_graphic_symbol, rename_graphic_symbol_in, takes_graphics, with_graphic,
};
pub use graphics::{
- MAX_OFFSET as GRAPHIC_MAX_OFFSET, SCALE_RANGE as GRAPHIC_SCALE_RANGE, appearance_graphic_symbols, rename_appearance_graphic_symbol,
+ MAX_OFFSET as GRAPHIC_MAX_OFFSET, NOT_TAKEN as GRAPHICS_NOT_TAKEN, SCALE_RANGE as GRAPHIC_SCALE_RANGE, appearance_graphic_symbols,
+ rename_appearance_graphic_symbol,
};
pub(crate) mod shadow;
@@ -80,8 +81,9 @@ pub fn three_d_params_as(from: &str, params: &Value, to: &str) -> Value {
}
}
}
- // A plane has only a front, so graphics on the back cap are dropped.
- if to == ROTATE
+ // A plane has only a front, and Revolve's graphics go on its side, named the front, so graphics
+ // on the back cap are dropped.
+ if (to == ROTATE || to == crate::REVOLVE)
&& let Some(Value::Array(list)) = out.get_mut(GRAPHICS)
{
list.retain(|g| g.get("face").and_then(Value::as_str) != Some(PlanarFace::Back.id()));
@@ -505,7 +507,7 @@ pub fn validate_three_d_at_in(doc: Option<&Document>, n: &Node, id: &str, p: &Va
(Some(_), _) if is_container(n) || source::resolves(n) => {
Err("A 3D effect on a group or layer goes on the group itself, not on its fill or stroke".into())
}
- (Some(item), index) if crate::revolve::is_revolve(id) => crate::revolve::validate_item_of(n, id, p, item, index),
+ (Some(item), index) if crate::revolve::is_revolve(id) => crate::revolve::validate_item_of(doc, n, id, p, item, index),
(None, _) if crate::revolve::is_revolve(id) => crate::revolve::validate_in(doc, n, id, p),
(item, index) => validate_solid_for(doc, n, id, p, (item, Some(index))),
}
@@ -1840,9 +1842,10 @@ pub fn graphic_frame(doc: Option<&Document>, n: &Node, item: Option) -> O
}
/// [`graphic_frame`] of the 3D effect at `index` in that list, which must be a visible Extrude &
-/// Bevel or Rotate.
+/// Bevel, Rotate or Revolve. Revolve's frame is its unrolled side seen from the front, centered on
+/// the origin, with no face maps: it sizes a new graphic and places no handles.
pub fn graphic_frame_at(doc: Option<&Document>, n: &Node, item: Option, index: usize) -> Option {
- frame_of(doc, n, item, &|fx: &[Effect]| fx.get(index).filter(|e| e.visible && SOLIDS.contains(&e.id.as_str())).map(|e| (index, e)))
+ frame_of(doc, n, item, &|fx: &[Effect]| fx.get(index).filter(|e| e.visible && is_three_d(&e.id)).map(|e| (index, e)))
}
/// Does a visible geometry effect come after the 3D effect at `index` in the effects of `n` (on the
@@ -1886,6 +1889,21 @@ fn frame_of(doc: Option<&Document>, n: &Node, item: Option, pick: &Pick<'
}
let p = crate::merged_params(&fx.id, &fx.params);
let b = union_bounds(&shapes)?;
+ if fx.id == crate::REVOLVE {
+ // Revolve's graphics lie on its unrolled side (in a group, the side of the member with the
+ // longest profile): the frame is the part of it seen from the front, with no planar face
+ // to place a handle on.
+ let options = crate::revolve::revolve_options(&p);
+ let (w, h) = shapes
+ .iter()
+ .filter(|s| crate::revolve::surface_item(&s.items).is_some())
+ .filter_map(|s| vectorcraft_three_d::revolve_surface_size(&s.path, b, options))
+ .fold((0.0f64, 0.0f64), |best, size| if size.1 > best.1 { size } else { best });
+ if w <= 0.0 || h <= 0.0 {
+ return None;
+ }
+ return Some(graphics::GraphicFrame::new(Rect::new(-w / 2.0, -h / 2.0, w / 2.0, h / 2.0), None, None));
+ }
let view = view_options(&p);
if fx.id == ROTATE {
let plane = vectorcraft_three_d::plane_face_map(b, view);
diff --git a/crates/effects/src/solids/graphics.rs b/crates/effects/src/solids/graphics.rs
index 6c5d8686a7..7c0087e15b 100644
--- a/crates/effects/src/solids/graphics.rs
+++ b/crates/effects/src/solids/graphics.rs
@@ -8,6 +8,11 @@
//! seen from the front of that face), its size as a share of the symbol's own, and its turn
//! (degrees, counterclockwise). On the back cap, x runs to the right as the back is seen from
//! behind, so the graphic reads the right way round there.
+//!
+//! On Revolve the graphics lie on the swept side surface, unrolled flat
+//! ([`vectorcraft_three_d::SurfaceCell`]): x and y are the offset of the graphic's center from the
+//! middle of that surface, x around the axis and y along the profile, and each face of the side
+//! draws the part of the graphics that falls on it ([`surface_parts`]).
use std::sync::Arc;
@@ -15,7 +20,7 @@ use serde_json::{Map, Value, json};
use vectorcraft_doc::color::{BlendMode, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Symbol};
use vectorcraft_geom::kurbo::{CubicBez, ParamCurve, PathEl, Shape};
-use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, SubPath};
+use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, SubPath, Vec2};
use vectorcraft_three_d::{FaceMap, Facet, PlanarFace};
/// The effect parameter that holds the graphics.
@@ -41,6 +46,33 @@ const MAX_NESTING: usize = 8;
/// places: as deep as an object may be drawn.
const MAX_LEVELS: usize = 4 * vectorcraft_doc::MAX_DEPTH;
+/// The error for graphics on a 3D effect that takes none.
+pub const NOT_TAKEN: &str = "Graphics map onto the Plane, Extrude and Revolve object types";
+
+/// The buckets around the unrolled side surface that the pieces of Revolve's graphics are sorted
+/// into, so each face looks only at the pieces near it.
+const SURFACE_BUCKETS: usize = 256;
+
+/// How closely the curves of a graphic on Revolve's side follow their shape when they are cut into
+/// straight pieces, in points of the unrolled surface.
+const SURFACE_TOLERANCE: f64 = 0.05;
+
+/// How far from the middle of Revolve's unrolled side a curve of a graphic may reach, in points:
+/// as far as a Revolve profile's coordinates may.
+const SURFACE_REACH: f64 = 1e8;
+
+/// The most points the parts of Revolve's graphics draw on its faces together, a graphic's area cut
+/// at every face it crosses. Past it no graphic is drawn on that surface.
+const MAX_SURFACE_POINTS: usize = 2_000_000;
+
+/// The most points of Revolve's graphics cut at its faces together, counted before each cut whether
+/// or not it keeps any of them. Past it no graphic is drawn on that surface.
+const MAX_SURFACE_CUTS: usize = 32 * MAX_SURFACE_POINTS;
+
+/// The width of the stroke of the same paint around each opaque part of a graphic on Revolve's
+/// side, which covers the seams between the parts on neighboring faces.
+const SURFACE_SEAM: f64 = 0.3;
+
/// The size range of a graphic, as a percentage of its symbol's own size.
pub const SCALE_RANGE: (f64, f64) = (1.0, 10_000.0);
@@ -116,10 +148,10 @@ impl MappedGraphic {
}
}
-/// The 3D effects that take graphics: Extrude & Bevel (its caps) and Rotate (the plane), the two
-/// object types of the 3D and Materials panel with planar faces.
+/// The 3D effects that take graphics: Extrude & Bevel (its caps), Rotate (the plane) and Revolve
+/// (its swept side).
pub fn takes_graphics(id: &str) -> bool {
- id == super::EXTRUDE || id == super::ROTATE
+ id == super::EXTRUDE || id == super::ROTATE || id == crate::REVOLVE
}
/// The graphics in parameters `p`, leaving out entries that are not valid (a file may hold
@@ -131,8 +163,9 @@ pub fn mapped_graphics(p: &Value) -> Vec {
}
}
-/// Are the graphics in parameters `p` of 3D effect `id` valid? Only Extrude & Bevel and Rotate take
-/// graphics, and a plane has only a front.
+/// Are the graphics in parameters `p` of 3D effect `id` valid? Only Extrude & Bevel, Rotate and
+/// Revolve take graphics; a plane has only a front, and Revolve's graphics go on its side, named
+/// the front.
pub fn validate(id: &str, p: &Value) -> Result<(), String> {
let list = match p.get(GRAPHICS) {
None => return Ok(()),
@@ -143,7 +176,7 @@ pub fn validate(id: &str, p: &Value) -> Result<(), String> {
return Ok(());
}
if !takes_graphics(id) {
- return Err("Graphics map onto planar faces: use the Extrude or Plane object type".into());
+ return Err(NOT_TAKEN.into());
}
if list.len() > MAX_GRAPHICS {
return Err(format!("`{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics"));
@@ -153,6 +186,9 @@ pub fn validate(id: &str, p: &Value) -> Result<(), String> {
if id == super::ROTATE && g.face == PlanarFace::Back {
return Err("A plane has one face: map its graphics onto the front".into());
}
+ if id == crate::REVOLVE && g.face == PlanarFace::Back {
+ return Err("Revolve maps graphics onto its side: map them onto the front".into());
+ }
}
Ok(())
}
@@ -483,6 +519,411 @@ pub(super) fn face_art(
Some(Node::new(id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(Node::group(id, children))], clip: true }))
}
+/// One area of a graphic placed on Revolve's unrolled side surface, cut into straight pieces.
+struct Unrolled {
+ polygons: Vec>,
+ /// The bounds of each of `polygons`.
+ boxes: Vec,
+ bounds: Rect,
+ rule: FillRule,
+ paint: Paint,
+ opacity: f32,
+ blend: BlendMode,
+ /// Where the graphic's art goes on the surface: its gradients and patterns are placed with it.
+ place: Affine,
+}
+
+/// The areas of `graphics` (those of the front: Revolve's side) placed on the unrolled side
+/// surface, cut into straight pieces. A graphic whose symbol `doc` does not have, or whose art is
+/// past the limits of [`pieces_of`], is left out. `None` when the pieces take more than
+/// [`MAX_ELEMENTS`] points together.
+fn unrolled(doc: &Document, graphics: &[MappedGraphic]) -> Option> {
+ let mut pieces = vec![];
+ let mut points = 0usize;
+ for g in graphics.iter().filter(|g| g.face == PlanarFace::Front) {
+ let Some(symbol) = doc.symbols.iter().find(|s| s.name == g.symbol) else { continue };
+ let Ok(art) = pieces_of(doc, symbol) else { continue };
+ let Some(bounds) = art.iter().filter_map(|p| p.path.bounds()).reduce(|a, b| a.union(b)) else { continue };
+ let place = g.placement(bounds, Point::ZERO);
+ for piece in art {
+ let placed = place * piece.path.to_bezpath();
+ let mut polygons = straight_pieces(&placed, &mut points)?;
+ polygons.retain(|p| p.len() >= 3 && p.iter().all(|q| q.is_finite()));
+ let boxes: Vec =
+ polygons.iter().filter_map(|polygon| polygon.iter().map(|p| Rect::from_points(*p, *p)).reduce(|a, b| a.union(b))).collect();
+ let Some(bounds) = boxes.iter().copied().reduce(|a, b| a.union(b)) else { continue };
+ pieces.push(Unrolled {
+ polygons,
+ boxes,
+ bounds,
+ rule: piece.rule,
+ paint: piece.paint,
+ opacity: piece.opacity,
+ blend: piece.blend,
+ place,
+ });
+ }
+ }
+ Some(pieces)
+}
+
+/// The contours of `path` cut into straight pieces within [`SURFACE_TOLERANCE`], adding the points
+/// made to `points`. Each curve is cut on its own and the count is checked before the next one, so
+/// the work stops at the first curve past [`MAX_ELEMENTS`] points: `None` then, and for a path with
+/// a coordinate of a line or a curve farther than [`SURFACE_REACH`] from the origin, which bounds
+/// the pieces of one curve and the arithmetic of the cuts at the faces.
+fn straight_pieces(path: &BezPath, points: &mut usize) -> Option>> {
+ let near = |p: &Point| p.x.abs() <= SURFACE_REACH && p.y.abs() <= SURFACE_REACH;
+ let mut polygons: Vec> = vec![];
+ let mut current: Vec = vec![];
+ let (mut start, mut last) = (Point::ZERO, Point::ZERO);
+ for el in path.elements() {
+ if *points > MAX_ELEMENTS {
+ return None;
+ }
+ match *el {
+ PathEl::MoveTo(p) | PathEl::LineTo(p) if !near(&p) => return None,
+ PathEl::MoveTo(p) => {
+ polygons.push(std::mem::take(&mut current));
+ current.push(p);
+ *points += 1;
+ (start, last) = (p, p);
+ }
+ PathEl::LineTo(p) => {
+ current.push(p);
+ *points += 1;
+ last = p;
+ }
+ PathEl::ClosePath => {
+ polygons.push(std::mem::take(&mut current));
+ last = start;
+ }
+ PathEl::QuadTo(a, p) | PathEl::CurveTo(_, a, p) => {
+ let first = if let PathEl::CurveTo(b, ..) = *el { b } else { a };
+ if ![last, first, a, p].iter().all(near) {
+ return None;
+ }
+ vectorcraft_geom::kurbo::flatten([PathEl::MoveTo(last), *el], SURFACE_TOLERANCE, |cut| {
+ if let PathEl::LineTo(q) = cut {
+ current.push(q);
+ *points += 1;
+ }
+ });
+ last = p;
+ }
+ }
+ }
+ polygons.push(current);
+ (*points <= MAX_ELEMENTS).then_some(polygons)
+}
+
+/// Do the graphics in Revolve parameters `p` fit the limits with the symbols of `doc`? Their art,
+/// cut into straight pieces on the side surface, may take up to [`MAX_ELEMENTS`] points together.
+pub(crate) fn validate_surface_symbols(doc: &Document, p: &Value) -> Result<(), String> {
+ let list = mapped_graphics(p);
+ if list.is_empty() || unrolled(doc, &list).is_some() {
+ return Ok(());
+ }
+ Err("The graphics are too complex: simplify the symbols' art".into())
+}
+
+/// The parts of `graphics` that fall on each of `faces`, a revolved surface's faces in drawing
+/// order, for an object with id `id`: for each face, the nodes to draw right after it. Only faces
+/// of the swept side that show their outside to the viewer carry a part, so a graphic narrows
+/// toward the silhouette, is hidden past it and is covered by the nearer faces drawn after it.
+/// `shade` gives the paint of a solid color on a face, as the surface under it is shaded; the
+/// colors of gradients and patterns are multiplied by the face's light. A graphic whose symbol
+/// `doc` does not have, or that is past the limits of its art, draws nothing. `None` past
+/// [`MAX_ELEMENTS`] points together before they are cut at the faces, [`MAX_SURFACE_POINTS`]
+/// after, [`MAX_SURFACE_CUTS`] points cut, or when the copies of their paints in the parts take
+/// `copies`, the bytes of the copies of the object's graphics so far, past Revolve's budget for
+/// them: commands refuse such graphics, and a loaded file draws the surface without them.
+pub(crate) fn surface_parts(
+ doc: Option<&Document>,
+ id: NodeId,
+ graphics: &[MappedGraphic],
+ faces: &[vectorcraft_three_d::Face],
+ shade: &dyn Fn(&vectorcraft_three_d::Face, vectorcraft_doc::color::Color) -> Paint,
+ copies: &mut usize,
+) -> Option>>> {
+ let mut out: Vec>> = vec![vec![]; faces.len()];
+ let Some(doc) = doc else { return Some(out) };
+ let pieces = unrolled(doc, graphics)?;
+ let Some(all) = pieces.iter().map(|p| p.bounds).reduce(|a, b| a.union(b)) else { return Some(out) };
+ // Each piece listed in the buckets of the surface's x its bounds reach across.
+ let width = (all.width() / SURFACE_BUCKETS as f64).max(1e-9);
+ let bucket = |x: f64| (((x - all.x0) / width).floor().max(0.0) as usize).min(SURFACE_BUCKETS - 1);
+ let mut buckets: Vec> = vec![vec![]; SURFACE_BUCKETS];
+ for (k, p) in pieces.iter().enumerate() {
+ for b in buckets.get_mut(bucket(p.bounds.x0)..=bucket(p.bounds.x1)).into_iter().flatten() {
+ b.push(k);
+ }
+ }
+ let mut seen = vec![usize::MAX; pieces.len()];
+ let mut drawn = 0usize;
+ let mut cut_points = 0usize;
+ let mut triangle_id = 0usize;
+ for (face, parts) in faces.iter().zip(out.iter_mut()) {
+ let Some(cell) = face.surface.as_ref().filter(|c| c.outward && !face.cap) else { continue };
+ if cell.uv.len() != cell.page.len() || !(3..=4).contains(&cell.uv.len()) {
+ continue;
+ }
+ let triangles: &[[usize; 3]] = if cell.uv.len() == 3 { &[[0, 1, 2]] } else { &[[0, 1, 2], [0, 2, 3]] };
+ for corners in triangles {
+ triangle_id += 1;
+ let pick = |of: &[Point]| corners.iter().map(|k| of.get(*k).copied()).collect::>>();
+ let (Some(uv), Some(page)) = (pick(&cell.uv), pick(&cell.page)) else { continue };
+ let (Some(map), Some(window)) = (triangle_map(&uv, &page), counterclockwise(&uv)) else { continue };
+ // A face along an end cap's ring has that edge curved on the page: its parts are cut
+ // from a larger triangle and then to the face's own outline, so they reach the curve
+ // where the graphic runs past the end of the profile. A four-cornered face is cut into
+ // two triangles along its diagonal from corner 0 to corner 2: each takes the part of
+ // the outline on its own side of it.
+ let window = if face.outline.is_some() { grown(window, 3.0) } else { window };
+ // The pieces are looked up over the whole of the window they are cut to.
+ let Some(reach) = window.iter().map(|p| Rect::from_points(*p, *p)).reduce(|a, b| a.union(b)) else { continue };
+ if reach.x1 < all.x0 || reach.x0 > all.x1 || reach.y1 < all.y0 || reach.y0 > all.y1 {
+ continue;
+ }
+ // The pieces near the triangle, each once, in the graphics' drawing order.
+ let mut near = vec![];
+ for b in buckets.get(bucket(reach.x0)..=bucket(reach.x1)).into_iter().flatten() {
+ for &k in b {
+ let Some(slot) = seen.get_mut(k) else { continue };
+ if *slot != triangle_id {
+ *slot = triangle_id;
+ near.push(k);
+ }
+ }
+ }
+ near.retain(|k| {
+ pieces
+ .get(*k)
+ .is_some_and(|p| p.bounds.x1 >= reach.x0 && p.bounds.x0 <= reach.x1 && p.bounds.y1 >= reach.y0 && p.bounds.y0 <= reach.y1)
+ });
+ if near.is_empty() {
+ continue;
+ }
+ near.sort_unstable();
+ let own = corners.iter().find(|k| **k != 0 && **k != 2).and_then(|k| cell.page.get(*k)).filter(|_| triangles.len() == 2);
+ let rim = face.outline.as_ref().map(|outline| match (own, cell.page.first(), cell.page.get(2)) {
+ (Some(&toward), Some(&a), Some(&b)) => {
+ let side = side_of(a, b, toward);
+ vectorcraft_pathops::boolean(outline, FillRule::NonZero, &side, FillRule::NonZero, vectorcraft_pathops::BoolOp::Intersect)
+ }
+ _ => outline.clone(),
+ });
+ let rim = rim.as_ref();
+ for k in near {
+ let Some(piece) = pieces.get(k) else { continue };
+ let mut path = BezPath::new();
+ // Each contour is cut only where its own bounds reach the triangle.
+ for (polygon, b) in piece.polygons.iter().zip(&piece.boxes) {
+ if b.x1 < reach.x0 || b.x0 > reach.x1 || b.y1 < reach.y0 || b.y0 > reach.y1 {
+ continue;
+ }
+ cut_points = cut_points.saturating_add(polygon.len());
+ if cut_points > MAX_SURFACE_CUTS {
+ return None;
+ }
+ let cut = clip_to(polygon, &window);
+ let Some((first, rest)) = cut.split_first() else { continue };
+ path.move_to(map * *first);
+ rest.iter().for_each(|q| path.line_to(map * *q));
+ path.close_path();
+ drawn = drawn.saturating_add(cut.len());
+ }
+ if drawn > MAX_SURFACE_POINTS {
+ return None;
+ }
+ if path.elements().is_empty() {
+ continue;
+ }
+ let (path, rule) = match rim {
+ Some(outline) => {
+ let cut = vectorcraft_pathops::boolean(
+ &PathData::from_bezpath(&path),
+ piece.rule,
+ outline,
+ FillRule::NonZero,
+ vectorcraft_pathops::BoolOp::Intersect,
+ );
+ (cut.to_bezpath(), FillRule::NonZero)
+ }
+ None => (path, piece.rule),
+ };
+ if path.elements().is_empty() {
+ continue;
+ }
+ // Each part copies its paint into its fill, and into the stroke over its seams when
+ // it takes one.
+ if !crate::revolve::paint_copies_add(copies, 1 + usize::from(takes_seam(piece)), &piece.paint) {
+ return None;
+ }
+ parts.push(Arc::new(surface_piece(id, piece, (path, rule), map, face, shade)));
+ }
+ }
+ }
+ Some(out)
+}
+
+/// The node of the part `path` (on the page, filled under `rule`) of `piece`, which `map` takes
+/// from the unrolled surface onto `face`.
+fn surface_piece(
+ id: NodeId,
+ piece: &Unrolled,
+ (path, rule): (BezPath, FillRule),
+ map: Affine,
+ face: &vectorcraft_three_d::Face,
+ shade: &dyn Fn(&vectorcraft_three_d::Face, vectorcraft_doc::color::Color) -> Paint,
+) -> Node {
+ let gradient_map = map * piece.place;
+ // Under several lights a face's light varies across it in each channel: a solid color draws
+ // as the face's own lit art in that color, clipped to the part. An opaque part also draws that
+ // art clipped to a band of the seam stroke's width around its edges, as the other parts take
+ // a stroke over their seams.
+ if let (Some(c), Some(light)) = (piece.paint.color().filter(|_| matches!(piece.paint, Paint::Solid { .. })), face.rgb_gradient) {
+ let lit = Arc::new(crate::lighting::rgb_gradient_art(id, &face.path(), &Paint::solid(c), light));
+ let clipped = |area: &BezPath, rule: FillRule| {
+ let mut clip = Node::path(id, PathData::from_bezpath(area), Appearance::default());
+ if let NodeKind::Path { rule: r, .. } = &mut clip.kind {
+ *r = rule;
+ }
+ Node::new(id, NodeKind::Group { children: vec![Arc::new(clip), lit.clone()], clip: true })
+ };
+ let mut group = clipped(&path, rule);
+ if takes_seam(piece) {
+ let style = vectorcraft_geom::kurbo::Stroke::new(SURFACE_SEAM).with_join(vectorcraft_geom::kurbo::Join::Round);
+ let band = vectorcraft_geom::kurbo::stroke(path.iter(), &style, &vectorcraft_geom::kurbo::StrokeOpts::default(), SURFACE_TOLERANCE);
+ group = Node::group(id, vec![Arc::new(group), Arc::new(clipped(&band, FillRule::NonZero))]);
+ }
+ group.opacity = piece.opacity;
+ group.blend = piece.blend;
+ return group;
+ }
+ let mut paint = piece.paint.clone();
+ if let Paint::Pattern { xf, .. } = &mut paint {
+ *xf = gradient_map * *xf;
+ }
+ let solid = paint.color().filter(|_| matches!(paint, Paint::Solid { .. }));
+ let mut fill = FillLayer::new(paint);
+ fill.opacity = piece.opacity;
+ fill.blend = piece.blend;
+ let mut appearance = Appearance { items: vec![AppearanceItem::Fill(fill)], ..Default::default() };
+ appearance.transform_gradients(gradient_map);
+ for item in appearance.items.iter_mut() {
+ if let AppearanceItem::Fill(f) = item {
+ match solid {
+ Some(c) => f.paint = shade(face, c),
+ None => {
+ vectorcraft_doc::recolor::map_paint(&mut f.paint, &|c| crate::lighting::shade(c, face.illumination));
+ }
+ }
+ }
+ }
+ // Parts on neighboring faces meet edge to edge: an opaque part takes a narrow stroke of its
+ // paint over the seam, as the faces do.
+ if takes_seam(piece)
+ && let Some(AppearanceItem::Fill(f)) = appearance.items.first()
+ {
+ let mut seam = vectorcraft_doc::StrokeLayer::new(f.paint.clone(), SURFACE_SEAM);
+ seam.join = vectorcraft_doc::LineJoin::Round;
+ appearance.items.push(AppearanceItem::Stroke(seam));
+ }
+ let mut node = Node::path(id, PathData::from_bezpath(&path), appearance);
+ if let NodeKind::Path { rule: r, .. } = &mut node.kind {
+ *r = rule;
+ }
+ node
+}
+
+/// Does each part of `piece` take a stroke of its paint over its seams? Only an opaque part does:
+/// the overlap of a transparent one, of one whose gradient has a stop or a freeform point that is
+/// not fully opaque, or of a pattern, whose tile may hold transparent art, would show.
+fn takes_seam(piece: &Unrolled) -> bool {
+ let clear = match &piece.paint {
+ Paint::Gradient(g) => {
+ g.gradient.stops.iter().any(|s| s.opacity < 1.0) || g.freeform.as_ref().is_some_and(|f| f.points.iter().any(|p| p.opacity < 1.0))
+ }
+ Paint::Pattern { .. } => true,
+ _ => false,
+ };
+ piece.opacity >= 1.0 && piece.blend == BlendMode::Normal && !clear
+}
+
+/// A region far larger than the face on the side of the line through `a` and `b` that `toward` is
+/// on: a rectangle with one edge along the line, reaching a thousand times the line's length past
+/// its ends and away from it.
+fn side_of(a: Point, b: Point, toward: Point) -> PathData {
+ let along = b - a;
+ let mut across = Vec2::new(-along.y, along.x);
+ if across.dot(toward - a) < 0.0 {
+ across = -across;
+ }
+ let (far, wide) = (across * 1000.0, along * 1000.0);
+ let mut out = BezPath::new();
+ out.move_to(a - wide);
+ out.line_to(b + wide);
+ out.line_to(b + wide + far);
+ out.line_to(a - wide + far);
+ out.close_path();
+ PathData::from_bezpath(&out)
+}
+
+/// Triangle `t` scaled by `factor` about its centroid, wound as it was.
+fn grown(t: [Point; 3], factor: f64) -> [Point; 3] {
+ let c = Point::new((t[0].x + t[1].x + t[2].x) / 3.0, (t[0].y + t[1].y + t[2].y) / 3.0);
+ t.map(|p| c + (p - c) * factor)
+}
+
+/// The affine map that takes the corners `from` of a triangle onto `to`. `None` for a triangle
+/// with no area or a map that is not finite.
+fn triangle_map(from: &[Point], to: &[Point]) -> Option {
+ let ([u0, u1, u2], [p0, p1, p2]) = (<[Point; 3]>::try_from(from).ok()?, <[Point; 3]>::try_from(to).ok()?);
+ let (a, b) = (u1 - u0, u2 - u0);
+ let det = a.x * b.y - b.x * a.y;
+ if det.abs() <= 1e-12 * (a.hypot2() + b.hypot2()).max(1e-300) {
+ return None;
+ }
+ let (c, d) = (p1 - p0, p2 - p0);
+ let m = [(c.x * b.y - d.x * a.y) / det, (c.y * b.y - d.y * a.y) / det, (d.x * a.x - c.x * b.x) / det, (d.y * a.x - c.y * b.x) / det];
+ let xf = Affine::new([m[0], m[1], m[2], m[3], p0.x - m[0] * u0.x - m[2] * u0.y, p0.y - m[1] * u0.x - m[3] * u0.y]);
+ xf.as_coeffs().iter().all(|v| v.is_finite()).then_some(xf)
+}
+
+/// Triangle `t` wound counterclockwise (y up), as [`clip_to`] reads its window. `None` without
+/// three corners.
+fn counterclockwise(t: &[Point]) -> Option<[Point; 3]> {
+ let [a, b, c] = <[Point; 3]>::try_from(t).ok()?;
+ Some(if (b - a).cross(c - a) < 0.0 { [a, c, b] } else { [a, b, c] })
+}
+
+/// The part of `polygon` inside the convex `window` (one step of Sutherland and Hodgman's clip for
+/// each of its edges). A polygon that is not convex keeps its area, with edges along the window's
+/// sides where it leaves and comes back.
+fn clip_to(polygon: &[Point], window: &[Point; 3]) -> Vec {
+ let mut out = polygon.to_vec();
+ for k in 0..3 {
+ let (a, b) = (window[k], window[(k + 1) % 3]);
+ let side = |p: Point| (b - a).cross(p - a);
+ let input = std::mem::take(&mut out);
+ for (i, &p) in input.iter().enumerate() {
+ let q = input[(i + 1) % input.len()];
+ let (sp, sq) = (side(p), side(q));
+ if sp >= 0.0 {
+ out.push(p);
+ }
+ if (sp >= 0.0) != (sq >= 0.0) {
+ out.push(p.lerp(q, sp / (sp - sq)));
+ }
+ }
+ if out.len() < 3 {
+ return vec![];
+ }
+ }
+ out
+}
+
/// The bounds of the art symbol `name` of `doc` draws as a graphic, in the symbol's coordinates.
pub(super) fn art_bounds(doc: &Document, name: &str) -> Option {
let symbol = doc.symbols.iter().find(|s| s.name == name)?;
diff --git a/crates/effects/src/solids/tests_graphics.rs b/crates/effects/src/solids/tests_graphics.rs
index bf219353cf..e0935a43e7 100644
--- a/crates/effects/src/solids/tests_graphics.rs
+++ b/crates/effects/src/solids/tests_graphics.rs
@@ -505,7 +505,10 @@ fn graphics_are_checked() {
}
assert!(check(ROTATE, json!([{"symbol": "Mark", "face": "back"}])).is_err());
assert!(check(INFLATE, json!([{"symbol": "Mark"}])).is_err());
- assert!(validate_three_d_params(crate::REVOLVE, &json!({"graphics": [{"symbol": "Mark"}]})).is_err());
+ // Revolve takes graphics on its side, named the front; Revolve (Classic) takes none.
+ assert!(validate_three_d_params(crate::REVOLVE, &json!({"graphics": [{"symbol": "Mark"}]})).is_ok());
+ assert!(validate_three_d_params(crate::REVOLVE, &json!({"graphics": [{"symbol": "Mark", "face": "back"}]})).is_err());
+ assert!(validate_three_d_params(crate::CLASSIC_REVOLVE, &json!({"graphics": [{"symbol": "Mark"}]})).is_err());
// A loaded file's invalid entries are left out when drawn.
assert_eq!(mapped_graphics(&json!({"graphics": [{"symbol": "Mark"}, {"face": "back"}, 3]})).len(), 1);
}
@@ -796,3 +799,72 @@ fn a_symbol_instance_in_a_graphic_draws_as_the_canvas_shows_it() {
let area: f64 = pieces.iter().map(|p| p.path.to_bezpath().area().abs()).sum();
assert!((area - 30_000.0).abs() < 1e-3, "{area}");
}
+
+#[test]
+fn curves_are_cut_one_at_a_time_and_the_work_stops_at_the_limit() {
+ let ellipse = shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 6.0)).to_bezpath();
+ // Cut curve by curve, a path gives the points it gives cut whole.
+ for scale in [1.0, 100.0, 10_000.0] {
+ let placed = Affine::scale(scale) * ellipse.clone();
+ let mut whole = vec![];
+ vectorcraft_geom::kurbo::flatten(placed.iter(), SURFACE_TOLERANCE, |el| {
+ if let PathEl::MoveTo(p) | PathEl::LineTo(p) = el {
+ whole.push(p);
+ }
+ });
+ let mut points = 0;
+ let cut: Vec = straight_pieces(&placed, &mut points).unwrap().into_iter().flatten().collect();
+ assert_eq!(points, cut.len());
+ assert_eq!(cut.len(), whole.len(), "{scale}");
+ assert!(cut.iter().zip(&whole).all(|(a, b)| a.distance(*b) < 1e-9), "{scale}");
+ }
+ // Past the limit no further curve is cut: of 1,000 ellipses that each take more than 3,000
+ // points, the count stops within one curve of the limit.
+ let mut many = BezPath::new();
+ (0..1000).for_each(|_| many.extend(Affine::scale(10_000.0) * ellipse.clone()));
+ let mut points = 0;
+ assert!(straight_pieces(&many, &mut points).is_none());
+ assert!(points > MAX_ELEMENTS && points < MAX_ELEMENTS + 2000, "{points}");
+ // A curve that reaches farther than any surface, or to no finite point, is not cut at all.
+ for scale in [1e12, f64::INFINITY] {
+ let mut points = 0;
+ assert!(straight_pieces(&(Affine::scale(scale) * ellipse.clone()), &mut points).is_none());
+ assert!(points <= 1, "{scale}: {points}");
+ }
+}
+
+#[test]
+fn a_line_that_reaches_farther_than_any_surface_is_not_cut() {
+ let triangle = |far: f64| {
+ let mut b = BezPath::new();
+ b.move_to((0.0, 0.0));
+ b.line_to((far, 0.0));
+ b.line_to((0.0, 1.0));
+ b.close_path();
+ b
+ };
+ assert!(straight_pieces(&triangle(1e7), &mut 0).is_some());
+ for far in [1e12, 1e300] {
+ assert!(straight_pieces(&triangle(far), &mut 0).is_none(), "{far}");
+ assert!(straight_pieces(&(Affine::translate((far, 0.0)) * triangle(1.0)), &mut 0).is_none(), "{far}");
+ }
+}
+
+#[test]
+fn only_an_opaque_part_on_revolve_takes_a_seam_stroke() {
+ let piece = |paint: Paint, opacity: f32, blend: BlendMode| Unrolled {
+ polygons: vec![],
+ boxes: vec![],
+ bounds: Rect::ZERO,
+ rule: FillRule::NonZero,
+ paint,
+ opacity,
+ blend,
+ place: Affine::IDENTITY,
+ };
+ assert!(takes_seam(&piece(Paint::solid(red()), 1.0, BlendMode::Normal)));
+ assert!(!takes_seam(&piece(Paint::solid(red()), 0.5, BlendMode::Normal)));
+ assert!(!takes_seam(&piece(Paint::solid(red()), 1.0, BlendMode::Multiply)));
+ // A pattern's tile may hold transparent art, which the overlap would show.
+ assert!(!takes_seam(&piece(Paint::Pattern { pattern: "Dots".into(), xf: Affine::IDENTITY }, 1.0, BlendMode::Normal)));
+}
diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs
index 56f9d40633..48665539d9 100644
--- a/crates/effects/src/tests.rs
+++ b/crates/effects/src/tests.rs
@@ -5,6 +5,7 @@ use vectorcraft_geom::{Affine, PathData, Point, Rect, Shape, SubPath, shapes};
use super::*;
mod imageoutline;
+mod painted;
mod rasterfollow;
mod scale;
mod transformfx;
diff --git a/crates/effects/src/tests/painted.rs b/crates/effects/src/tests/painted.rs
new file mode 100644
index 0000000000..31dadc865e
--- /dev/null
+++ b/crates/effects/src/tests/painted.rs
@@ -0,0 +1,270 @@
+//! Live-effect plug-ins that paint what they return, run on each fill a painted effect returned.
+
+use serde_json::json;
+use vectorcraft_color::{Color, Paint};
+use vectorcraft_doc::{Appearance, Effect, Node, NodeId, NodeKind};
+use vectorcraft_geom::{Anchor, PathData, Point, Rect, SubPath, shapes};
+use vectorcraft_plugins::wat as tpl;
+
+use crate::geometry::Budget;
+use crate::reshape;
+use crate::reshape::{HELD_PEAK, PLUGIN_RUNS};
+
+/// Installs live-effect plug-in `id`, which paints what it returns, replacing one with the same id:
+/// its effect returns `output` whatever it is given.
+fn install_painting(id: &str, output: &serde_json::Value) {
+ let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"effect","api":2,"paintOutput":true,"params":{{}}}}"#);
+ let module = wat::parse_str(tpl::returning(&manifest, &output.to_string())).unwrap();
+ vectorcraft_plugins::registry::install_bytes(&module).unwrap();
+}
+
+/// An open path of `curves` tall curves, each drawn with about 5,200 lines.
+fn tall_curves(curves: u32) -> PathData {
+ let h = 3_000_000.0;
+ let anchors = (0..=curves)
+ .map(|i| {
+ let x = 40.0 + f64::from(i);
+ Anchor::with_handles(Point::new(x, 0.0), Point::new(x, -h), Point::new(x, h))
+ })
+ .collect();
+ PathData::single(SubPath::new(anchors, false))
+}
+
+/// Installs live-effect plug-in `id`, which reshapes the path it is given: its effect returns
+/// `output` whatever it is given.
+fn install_reshaping(id: &str, output: &serde_json::Value) {
+ let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"effect","params":{{}}}}"#);
+ let module = wat::parse_str(tpl::returning(&manifest, &output.to_string())).unwrap();
+ vectorcraft_plugins::registry::install_bytes(&module).unwrap();
+}
+
+/// `curves` curves from `x`, each `h` tall at its handles.
+fn curves_at(x: f64, curves: u32, h: f64) -> SubPath {
+ let anchors = (0..=curves)
+ .map(|i| {
+ let x = x + f64::from(i);
+ Anchor::with_handles(Point::new(x, 0.0), Point::new(x, -h), Point::new(x, h))
+ })
+ .collect();
+ SubPath::new(anchors, false)
+}
+
+/// An open path, `dx` to the right of 40, drawn with about `lines` lines: tall curves and one
+/// curve that makes up the rest.
+fn drawn_with(lines: usize, dx: f64) -> PathData {
+ let h = 3_000_000.0;
+ let per = Budget::of(&PathData::single(curves_at(0.0, 1, h))).lines;
+ let tall = u32::try_from(lines / per).unwrap();
+ let rest = lines % per;
+ let mut subpaths = vec![curves_at(40.0 + dx, tall, h)];
+ if rest > 0 {
+ // A curve `h` tall at its handles is drawn with about √(9h) lines.
+ let rest = rest as f64;
+ subpaths.push(curves_at(40.0 + dx + f64::from(tall) + 2.0, 1, rest * rest / 9.0));
+ }
+ PathData::new(subpaths)
+}
+
+/// Stacks a painted effect that returns `first` on a path drawn with about `lines` lines with five
+/// later effects on a square's fill, the `k`th returning that path `k` to the right (`later`
+/// installs each). Each piece's share of the budget is counted once, so every effect fits, and the
+/// art ends on the last effect's path, painted by `paints`.
+fn later_effects_count_each_piece_once(name: &str, lines: usize, first: &serde_json::Value, later: fn(&str, &serde_json::Value), paints: &[Paint]) {
+ let near = drawn_with(lines, 0.0);
+ let drawn = Budget::of(&near).lines;
+ assert!(drawn.abs_diff(lines) <= 2, "the path is drawn with {drawn} lines");
+ let first_id = format!("test.effects.{name}.first");
+ let mut first = first.clone();
+ first["objects"][0]["path"] = serde_json::to_value(&near).unwrap();
+ install_painting(&first_id, &first);
+ let mut ids = vec![format!("plugin.{first_id}")];
+ for k in 1..=5u8 {
+ let id = format!("test.effects.{name}.later{k}");
+ later(&id, &json!({"objects": [{"path": drawn_with(lines, f64::from(k))}]}));
+ ids.push(format!("plugin.{id}"));
+ }
+ let mut n =
+ Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
+ let fill = n.appearance.items.iter().position(|i| i.is_fill()).unwrap();
+ for id in ids {
+ n.appearance.items[fill].effects_mut().push(Effect { id, params: json!({}), visible: true });
+ }
+ let art = reshape(None, &n, None).unwrap();
+ let mut paths = vec![];
+ // The path is a compound path of two (the tall curves and the one that makes up the rest).
+ art.walk(&mut |c| {
+ let bounds = match &c.kind {
+ NodeKind::Compound { children, .. } => children.iter().filter_map(|k| k.path_data().and_then(PathData::bounds)).reduce(|a, b| a.union(b)),
+ _ => c.path_data().and_then(PathData::bounds),
+ };
+ let paints: Vec = c.appearance.items.iter().map(|i| i.paint().clone()).filter(|p| !p.is_none()).collect();
+ if bounds.is_some() && !paints.is_empty() {
+ paths.push((bounds, paints));
+ }
+ });
+ assert_eq!(paths, [(drawn_with(lines, 5.0).bounds(), paints.to_vec())]);
+}
+
+/// Later geometry effects on the two fills a painted effect returned run on each fill, and the
+/// path they both stay on counts its piece's share of the budget once: each fill's result, drawn
+/// with about 200 lines fewer than half the budget allows, fits at every stage.
+#[test]
+fn later_geometry_effects_on_two_fills_count_the_piece_once() {
+ let colors = [Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 1.0, 0.0))];
+ let fills: Vec<_> = colors.iter().map(|p| serde_json::to_value(p).unwrap()).collect();
+ let lines = Budget::EFFECTS.lines / 2 - 200;
+ later_effects_count_each_piece_once("twofills", lines, &json!({"objects": [{"fills": fills}]}), install_reshaping, &colors);
+}
+
+/// Later painted effects on a painted effect's piece count the piece's share of the budget once:
+/// each run's pieces take the place of the piece they were made of, so a result drawn with about
+/// 300 lines fewer than the budget allows fits at every stage.
+#[test]
+fn later_painted_effects_on_a_painted_piece_count_it_once() {
+ let lines = Budget::EFFECTS.lines - 300;
+ later_effects_count_each_piece_once("painted", lines, &json!({"objects": [{}]}), install_painting, &[Paint::solid(Color::BLACK)]);
+}
+
+/// A path's bounds and the paints of its fills and strokes.
+type PaintedPath = (Option, Vec);
+
+/// The paths of the art a painted effect `name` that returns `square` with eight fills makes, each
+/// path with its paints, when a later effect returns `first` on the first fill and `rest` on the
+/// others, painting what it returns when `paints`; and the eight paints. Checks that the plug-in
+/// results kept from one fill to the next never cost more than the effects' budget together.
+fn eight_fills_then(name: &str, square: &PathData, paints: bool, results: (&PathData, &PathData)) -> (Vec, Vec) {
+ let (paths, colors, _) = fills_then(name, 8, square, paints, results);
+ (paths, colors)
+}
+
+/// [`eight_fills_then`] with `n` fills, and how many times the later effect ran on a fill.
+fn fills_then(name: &str, n: u8, square: &PathData, paints: bool, (first, rest): (&PathData, &PathData)) -> (Vec, Vec, usize) {
+ let colors: Vec = (0..n).map(|i| Paint::solid(Color::rgb(f32::from(i) / f32::from(n), 0.0, 0.0))).collect();
+ let fills: Vec<_> = colors.iter().map(|p| serde_json::to_value(p).unwrap()).collect();
+ let fills_id = format!("test.effects.{name}.fills");
+ install_painting(&fills_id, &json!({"objects": [{"path": square, "fills": fills}]}));
+ let each_id = format!("test.effects.{name}.each");
+ let declared = if paints { r#","api":2,"paintOutput":true"# } else { "" };
+ let manifest = format!(r#"{{"id":"{each_id}","name":"Test {each_id}","version":"1","kind":"effect"{declared},"params":{{}}}}"#);
+ let (on_first, on_rest) = (json!({"objects": [{"path": first}]}).to_string(), json!({"objects": [{"path": rest}]}).to_string());
+ let module = wat::parse_str(tpl::when_given_else(&manifest, tpl::Given::Params, r#""item":{"fill":0}"#, &on_first, &on_rest)).unwrap();
+ vectorcraft_plugins::registry::install_bytes(&module).unwrap();
+ let mut n =
+ Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
+ let fill = n.appearance.items.iter().position(|i| i.is_fill()).unwrap();
+ for id in [fills_id, each_id] {
+ n.appearance.items[fill].effects_mut().push(Effect { id: format!("plugin.{id}"), params: json!({}), visible: true });
+ }
+ HELD_PEAK.with(|p| p.set(Budget { anchors: 0, lines: 0 }));
+ PLUGIN_RUNS.with(|r| r.set(0));
+ let art = reshape(None, &n, None).unwrap();
+ let runs = PLUGIN_RUNS.with(std::cell::Cell::get);
+ let mut paths = vec![];
+ art.walk(&mut |c| {
+ if let Some(p) = c.path_data() {
+ let paints: Vec = c.appearance.items.iter().map(|i| i.paint().clone()).filter(|p| !p.is_none()).collect();
+ if !paints.is_empty() {
+ paths.push((p.bounds(), paints));
+ }
+ }
+ });
+ let peak = HELD_PEAK.with(std::cell::Cell::get);
+ assert!(Budget::EFFECTS.fits(peak), "the results kept cost {peak:?}");
+ (paths, colors, runs)
+}
+
+/// A later painted effect's results on eight fills, each costing more than half the effects'
+/// budget and the first fill's on another path than the rest: only the first fill's result fits,
+/// the other fills stay together on the square, and the results kept never cost more than the
+/// budget together.
+#[test]
+fn painted_results_that_do_not_fit_are_not_kept() {
+ let square = shapes::rectangle(Rect::new(40.0, 40.0, 60.0, 60.0));
+ let (large, other) = (tall_curves(900), PathData::single(curves_at(3_000.0, 900, 3_000_000.0)));
+ let (paths, colors) = eight_fills_then("held", &square, true, (&large, &other));
+ assert_eq!(paths, [(large.bounds(), colors[..1].to_vec()), (square.bounds(), colors[1..].to_vec())]);
+}
+
+/// A later effect that reshapes each of eight painted fills to the same path, drawn with about a
+/// fifth of the effects' budget: the fills share that one path, and the results that move onto it
+/// are not kept as well, so what is kept never costs more than the budget.
+#[test]
+fn results_on_one_path_are_not_kept_for_each_fill() {
+ let square = shapes::rectangle(Rect::new(40.0, 40.0, 60.0, 60.0));
+ let same = tall_curves(300);
+ let (paths, colors) = eight_fills_then("merged", &square, false, (&same, &same));
+ assert_eq!(paths, [(same.bounds(), colors)]);
+}
+
+/// A later effect that reshapes each of eight painted fills to the same path, drawn with more than
+/// half the effects' budget: the path costs the art once, so every fill moves onto it, although a
+/// second copy of it would not fit.
+#[test]
+fn results_on_one_path_share_it_near_the_budget() {
+ let square = shapes::rectangle(Rect::new(40.0, 40.0, 60.0, 60.0));
+ let large = tall_curves(900);
+ let (paths, colors) = eight_fills_then("shared", &square, false, (&large, &large));
+ assert_eq!(paths, [(large.bounds(), colors)]);
+}
+
+/// An open path of tall curves `dx` to the right of 40, drawn with about `share` of the effects'
+/// budget's lines.
+fn drawn_share(share: f64, dx: f64) -> PathData {
+ let h = 3_000_000.0;
+ let per = Budget::of(&PathData::single(curves_at(0.0, 1, h))).lines as f64;
+ let curves = (Budget::EFFECTS.lines as f64 * share / per) as u32;
+ PathData::single(curves_at(40.0 + dx, curves, h))
+}
+
+/// A later painted effect runs once on each of three fills although the results are placed twice:
+/// a result that fits both placings is kept between them, and one that did not fit in the first is
+/// not run again when it does not fit the second either. The first fill's result (a tenth of the
+/// budget) and the second's (half of it) fit, the third's (half of it) does not, and the square
+/// (45%) left for the third fill then has no room; in the second placing the second fill's result
+/// does not fit beside the square kept for the fills after it.
+#[test]
+fn a_later_plug_in_runs_once_on_each_fill_across_both_placings() {
+ let (square, first, rest) = (drawn_share(0.45, 0.0), drawn_share(0.1, 2_000.0), drawn_share(0.5, 4_000.0));
+ let (paths, colors, runs) = fills_then("once", 3, &square, true, (&first, &rest));
+ assert_eq!(paths, [(first.bounds(), colors[..1].to_vec()), (square.bounds(), colors[1..].to_vec())]);
+ assert_eq!(runs, 3, "the later effect ran {runs} times on three fills");
+}
+
+/// A built-in geometry effect on two strokes that a painted effect returned, which doubles the lines
+/// of a path drawn with 30% of the budget: the second stroke's result does not fit beside the
+/// first's, but it is the same path, so it is built again with all the room the art leaves and
+/// both strokes are drawn on that one path.
+#[test]
+fn a_built_in_result_on_the_path_before_it_is_taken_near_the_budget() {
+ let square = drawn_share(0.3, 0.0);
+ let transform = json!({"scaleV": 400});
+ let tall = crate::distort::transform_within(&square, square.bounds().unwrap(), &transform, 0, usize::MAX);
+ let (s, t) = (Budget::of(&square).lines, Budget::of(&tall).lines);
+ let lines = Budget::EFFECTS.lines;
+ assert!(t > lines / 2 + lines / 20 && t < lines - lines / 20 && s + t < lines, "the paths are drawn with {s} and {t} lines");
+ let colors = [Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 1.0, 0.0))];
+ let strokes: Vec<_> = colors.iter().map(|p| json!({"paint": p, "width": 1})).collect();
+ let id = "test.effects.builtin.strokes";
+ install_painting(id, &json!({"objects": [{"path": square, "strokes": strokes}]}));
+ let mut n =
+ Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0));
+ let stroke = n.appearance.items.iter().position(|i| !i.is_fill()).unwrap();
+ n.appearance.items[stroke].effects_mut().push(Effect { id: format!("plugin.{id}"), params: json!({}), visible: true });
+ n.appearance.items[stroke].effects_mut().push(Effect { id: "distort.transform".into(), params: transform, visible: true });
+ let art = reshape(None, &n, None).unwrap();
+ let mut paths = vec![];
+ art.walk(&mut |c| {
+ if let Some(p) = c.path_data() {
+ let paints: Vec = c.appearance.items.iter().map(|i| i.paint().clone()).filter(|p| !p.is_none()).collect();
+ if !paints.is_empty() {
+ paths.push((p.bounds(), paints));
+ }
+ }
+ });
+ // One path, scaled about a reference point of the piece's own, painted by both strokes.
+ assert_eq!(paths.len(), 1, "{paths:?}");
+ let (bounds, paints) = &paths[0];
+ assert_eq!(paints, &colors.to_vec());
+ let height = |b: Option| b.map_or(0.0, |b| b.height());
+ assert!((height(*bounds) - height(tall.bounds())).abs() < 1.0, "{bounds:?} is not {:?} tall", tall.bounds());
+}
diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs
index af768284b0..166436b0c8 100644
--- a/crates/engine/src/cmd.rs
+++ b/crates/engine/src/cmd.rs
@@ -245,6 +245,12 @@ pub fn has_selected_member(s: &Session) -> std::result::Result<(), String> {
let Some(root) = ordinary_isolation(st) else { return has_selection(s) };
if st.selection.objects.iter().any(|id| inside_isolation(&st.doc, root, *id)) { Ok(()) } else { Err("nothing selected".into()) }
}
+/// [`has_selected_member`], or a ruler guide is selected outside isolation mode: Object › Lock ›
+/// Selection and Object › Hide › Selection.
+pub fn has_selected_member_or_guide(s: &Session) -> std::result::Result<(), String> {
+ let st = s.active().ok_or("no document open")?;
+ if ordinary_isolation(st).is_none() && !st.selection.guides.is_empty() { Ok(()) } else { has_selected_member(s) }
+}
/// Whether `id` lies inside the isolated object `root` (the root itself does not).
pub(crate) fn inside_isolation(d: &vectorcraft_doc::Document, root: vectorcraft_doc::NodeId, id: vectorcraft_doc::NodeId) -> bool {
id != root && d.ancestry(id).is_some_and(|a| a.contains(&root))
@@ -506,8 +512,13 @@ fn id_entry(value: &Value, key: &str, index: usize, cmd: &str) -> Result
/// them), each checked ([`checked_id`]): a malformed one, or one naming no object, is an error,
/// never the selection. `None` when it gives neither (or gives them as `null`).
pub(crate) fn given_objects(s: &Session, p: &Value, command: &str) -> Result>> {
+ given_nodes(s, p, command, false)
+}
+/// The nodes `p` gives `command`, as [`given_objects`] reads them; ruler guide nodes are taken
+/// too when `guides`, else refused.
+pub(crate) fn given_nodes(s: &Session, p: &Value, command: &str, guides: bool) -> Result >> {
if p.get("ids").is_some_and(|v| !v.is_null()) {
- return checked_ids_param(s, p, "ids", command).map(Some);
+ return checked_nodes_param(s, p, "ids", command, guides).map(Some);
}
match p.get("id").filter(|v| !v.is_null()) {
None => Ok(None),
@@ -515,7 +526,7 @@ pub(crate) fn given_objects(s: &Session, p: &Value, command: &str) -> Result {
Err(bad(command, format!("`id` is the step reference {r:?}, which only `run` and `command_batch` resolve")))
}
- Some(v) => Ok(Some(vec![checked_id(s, v, command)?])),
+ Some(v) => Ok(Some(vec![checked_node(s, v, command, guides)?])),
}
}
/// The objects `p` gives `command`, as [`given_objects`] reads them, checked only for their form:
@@ -541,9 +552,18 @@ pub(crate) fn index(v: u64) -> usize {
}
/// Validate all explicit targets before a command changes the document or selection.
pub(crate) fn checked_ids_param(s: &Session, p: &Value, key: &str, command: &str) -> Result> {
+ checked_nodes_param(s, p, key, command, false)
+}
+/// The `key` list as [`checked_ids_param`] checks it; ruler guide nodes are taken too when
+/// `guides`, else refused.
+pub(crate) fn checked_nodes_param(s: &Session, p: &Value, key: &str, command: &str, guides: bool) -> Result> {
let values =
p.get(key).and_then(Value::as_array).ok_or_else(|| bad(command, format!("{key} must be an array of non-negative integer object ids")))?;
- values.iter().enumerate().map(|(index, value)| id_entry(value, key, index, command).and_then(|_| checked_id(s, value, command))).collect()
+ values
+ .iter()
+ .enumerate()
+ .map(|(index, value)| id_entry(value, key, index, command).and_then(|_| checked_node(s, value, command, guides)))
+ .collect()
}
/// The objects a command acts on: `ids` when given (every one checked, and those inside another
@@ -557,11 +577,16 @@ pub(crate) fn checked_ids_or_selection(s: &Session, p: &Value, command: &str) ->
}
pub(crate) fn checked_id(s: &Session, value: &Value, command: &str) -> Result {
+ checked_node(s, value, command, false)
+}
+/// `value` as the id of a node of the active document; a ruler guide's is taken only when
+/// `guides`.
+pub(crate) fn checked_node(s: &Session, value: &Value, command: &str, guides: bool) -> Result {
let id = value.as_u64().map(NodeId).ok_or_else(|| bad(command, format!("invalid object id {value}: expected a non-negative integer")))?;
match s.doc()?.doc.node(id) {
None => Err(bad(command, format!("no such object id {value}"))),
// The guide commands take ruler guides by index.
- Some(n) if n.is_ruler_guide() => Err(bad(command, format!("id {value} is a ruler guide, not an object"))),
+ Some(n) if n.is_ruler_guide() && !guides => Err(bad(command, format!("id {value} is a ruler guide, not an object"))),
Some(_) => Ok(id),
}
}
diff --git a/crates/engine/src/cmd/addons/cleanup/tests.rs b/crates/engine/src/cmd/addons/cleanup/tests.rs
index b20eb0d59b..1184687366 100644
--- a/crates/engine/src/cmd/addons/cleanup/tests.rs
+++ b/crates/engine/src/cmd/addons/cleanup/tests.rs
@@ -341,6 +341,33 @@ fn fix_all_is_one_undo_step() {
assert_eq!(s.doc().unwrap().doc.artboards.len(), doc.artboards.len());
}
+#[test]
+fn a_long_straight_run_loses_its_inner_anchors_in_one_step() {
+ let mut s = session();
+ let n: u32 = 5_000;
+ let mut pts: Vec<(f64, f64)> = (0..n).map(|i| (10.0 + f64::from(i) * 0.05, 150.0)).collect();
+ pts.push((260.0, 190.0));
+ let id = line(&mut s, &pts);
+ let other = rect(&mut s, 300.0, 20.0, 40.0, 40.0);
+ select(&mut s, &[other]);
+ let check = s.execute("addon.cleanup.check", &json!({"kinds": ["collinearAnchors"], "scope": "document"})).unwrap();
+ assert_eq!(check["total"], n - 2, "{check}");
+ assert_eq!(check["findings"][0]["ids"], json!([id.0]));
+ let shown = s.execute("addon.cleanup.show", &json!({"kind": "collinearAnchors", "scope": "document"})).unwrap();
+ assert_eq!(shown["count"], n - 2);
+ assert_eq!(s.doc().unwrap().selection.objects, [id], "Show selects the path");
+ select(&mut s, &[other]);
+ let (steps, before) = (undo_steps(&s), node(&s, id).unwrap());
+ let r = s.execute("addon.cleanup.fix", &json!({"kinds": ["collinearAnchors"], "scope": "document"})).unwrap();
+ assert_eq!((r["total"].as_u64(), r["fixed"]["collinearAnchors"].as_u64()), (Some(u64::from(n) - 2), Some(u64::from(n) - 2)));
+ assert_eq!(undo_steps(&s), steps + 1);
+ assert_eq!(s.doc().unwrap().selection.objects, [other], "Fix keeps the selection");
+ let ends: Vec<(f64, f64)> = node(&s, id).unwrap().path_data().unwrap().subpaths[0].anchors.iter().map(|a| (a.p.x, a.p.y)).collect();
+ assert_eq!(ends, [(10.0, 150.0), (10.0 + f64::from(n - 1) * 0.05, 150.0), (260.0, 190.0)]);
+ s.execute("edit.undo", &json!({})).unwrap();
+ assert_eq!(node(&s, id).unwrap(), before);
+}
+
#[test]
fn the_scope_is_the_selection_or_the_document() {
let mut s = session();
diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs
index afe86769db..79a203c421 100644
--- a/crates/engine/src/cmd/appearance.rs
+++ b/crates/engine/src/cmd/appearance.rs
@@ -19,7 +19,10 @@
//! shape that a Convert to Shape or Outline Object effect on them gives them, below or above the
//! image as its Contents row says; new ones go right below that row.
-use std::{collections::HashMap, sync::Arc};
+use std::{
+ collections::{HashMap, HashSet},
+ sync::Arc,
+};
use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Paint};
@@ -45,7 +48,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, opacity and blend mode 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 (a series group's id stands for those paths), 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 +57,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, opacity and blend mode 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 (a series group's id stands for those paths), 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 +66,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, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (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 (a series group's id stands for those paths and the group), 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 +75,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, opacity and blend mode 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 (a series group's id stands for those paths and the group), 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 +84,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, opacity and blend mode 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 (a series group's id stands for those paths), 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 +93,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, opacity and blend mode 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 (a series group's id stands for those paths), 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 +111,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, opacity and blend mode 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 (a series group's id stands for those paths), 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 +120,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, 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}",
+ "{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 (a series group's id stands for those paths, except with from \"contents\", which moves the group's own row), 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
),
@@ -144,7 +147,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, opacity and blend mode 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 (a series group's id stands for those paths and the group), 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 +165,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, 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}",
+ "{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 (a series group's id stands for those paths; as `source`, when the group has no effect, opacity, blend mode, isolation, knockout or style link of its own, the topmost one is read and a move clears them all; as `target` each takes the appearance and the group is cleared, a `source` among them keeps its look, and a `source` that is the group's only path is an error), 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
),
@@ -416,9 +419,87 @@ pub(crate) fn appearance_targets(s: &Session, p: &Value) -> Result>
if targets_contents(p)? { leaf_targets(s, &roots) } else { Ok(roots) }
}
-/// [`appearance_targets`] of `cmd`, which needs at least one.
-fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result> {
+/// `ids` with each graph series group that has no fill or stroke row of its own replaced by the paths directly inside
+/// it ([`super::graph::series_group_marks`]). A series group with rows, as a document saved earlier can hold, stays:
+/// the Appearance panel lists those rows and edits them by index.
+pub(crate) fn rowless_series_marks(d: &Document, ids: &[NodeId]) -> Vec {
+ super::graph::series_group_marks_where(d, ids, |group| group.appearance.items.is_empty())
+}
+
+/// Whether series group `group` holds nothing of its own, as the graph draws it: no fill or stroke row, no effect, the
+/// default opacity, blend mode, isolation and knockout, and no graphic style link.
+fn holds_no_look(group: &Node) -> bool {
+ let ap = &group.appearance;
+ ap.items.is_empty()
+ && ap.effects.is_empty()
+ && group.opacity == 1.0
+ && group.blend == BlendMode::Normal
+ && !group.isolate
+ && group.knockout == Default::default()
+ && !group.knockout_shape
+ && group.extras().graphic_style.is_none()
+}
+
+/// The objects whose appearance a command reads for `id`: a graph series group that holds nothing of its own
+/// ([`holds_no_look`]) gives the paths directly inside it, backmost first. A series group with an effect, an opacity or
+/// another attribute of its own is read itself, with that attribute.
+fn look_holders(d: &Document, id: NodeId) -> Vec {
+ super::graph::series_group_marks_where(d, &[id], holds_no_look)
+}
+
+/// The object whose appearance a command reads for `n`: a graph series group that holds nothing of its own stands for
+/// the topmost path directly inside it ([`look_holders`]).
+pub(crate) fn look_holder<'a>(d: &'a Document, n: &'a Node) -> &'a Node {
+ look_holders(d, n.id).last().and_then(|id| d.node(*id)).unwrap_or(n)
+}
+
+/// Objects whose fill and stroke rows a command edits: the [`appearance_targets`], with a graph series group standing
+/// for the paths directly inside it ([`rowless_series_marks`]), so the graph can store the edit.
+pub(crate) fn row_targets(s: &Session, p: &Value) -> Result> {
let ids = appearance_targets(s, p)?;
+ Ok(rowless_series_marks(&s.doc()?.doc, &ids))
+}
+
+/// Objects whose whole appearance a command edits: the [`row_targets`], then each graph series group they stand for,
+/// which can hold effects and a graphic style link of its own.
+pub(crate) fn whole_targets(s: &Session, p: &Value) -> Result> {
+ let own = appearance_targets(s, p)?;
+ let mut ids = rowless_series_marks(&s.doc()?.doc, &own);
+ let groups = stood_for(&own, &ids);
+ ids.extend(groups);
+ Ok(ids)
+}
+
+/// The series groups among `own` that `ids` ([`rowless_series_marks`] of `own`) replaced with their paths.
+pub(crate) fn stood_for(own: &[NodeId], ids: &[NodeId]) -> Vec {
+ let kept: HashSet = ids.iter().copied().collect();
+ own.iter().copied().filter(|id| !kept.contains(id)).collect()
+}
+
+/// Clear Appearance on each series group a command gave a whole new appearance through its paths, so the group's own
+/// opacity, blend mode, isolation, knockout, effects and style link do not stay on top of it.
+pub(crate) fn clear_stood_for(d: &mut Document, groups: &[NodeId]) {
+ for id in groups {
+ if let Some(n) = d.node_mut(*id) {
+ clear(n);
+ (n.isolate, n.knockout, n.knockout_shape) = (false, Default::default(), false);
+ n.extras_mut().graphic_style = None;
+ }
+ }
+}
+
+/// [`whole_targets`] of `cmd`, which needs at least one.
+fn require_whole_targets(s: &Session, p: &Value, cmd: &str) -> Result> {
+ let ids = whole_targets(s, p)?;
+ if ids.is_empty() {
+ return Err(bad(cmd, "select objects or give ids"));
+ }
+ Ok(ids)
+}
+
+/// [`row_targets`] of `cmd`, which needs at least one.
+fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result> {
+ let ids = row_targets(s, p)?;
if ids.is_empty() {
return Err(bad(cmd, "select objects or give ids"));
}
@@ -616,7 +697,7 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result {
}
fn clear_appearance(s: &mut Session, p: &Value) -> Result {
- let ids = require_targets(s, p, "appearance.clear")?;
+ let ids = require_whole_targets(s, p, "appearance.clear")?;
// When every object is cleared already, the document and its undo history stay as they are.
let before = s.doc()?.doc.clone();
super::overprint::edit_counted(s, "Clear Appearance", |d| {
@@ -732,35 +813,64 @@ fn transfer(s: &mut Session, p: &Value) -> Result {
return Err(bad(C, "`source` and `target` are the same object"));
}
let copy = switch_or(p, "copy", false, C)?;
+ // A graph series group stands for the paths directly inside it: as the source, the topmost of them is read and a
+ // move clears them all; as the target, each of them other than a source takes the appearance.
+ let sources = look_holders(&s.doc()?.doc, source);
+ let read = sources.last().copied().unwrap_or(source);
+ let from: Vec = sources.iter().copied().chain(stood_for(&[source], &sources)).collect();
+ let marks = rowless_series_marks(&s.doc()?.doc, &[target]);
+ let stands = !marks.contains(&target);
+ let within: HashSet = from.iter().copied().collect();
+ let mut targets: Vec = marks.iter().copied().filter(|id| !within.contains(id)).collect();
+ if targets.is_empty() {
+ if stands {
+ return Err(bad(C, "`source` is the only path in series group `target`"));
+ }
+ targets.push(target);
+ }
+ // A source that is one of the target group's paths keeps its look: the group stands for it too.
+ let part: HashSet = if stands { marks.into_iter().collect() } else { HashSet::new() };
+ let from: Vec = stood_for(&from, &targets);
+ let cleared: Vec = if copy { vec![] } else { from.iter().copied().filter(|id| !part.contains(id)).collect() };
+ // The group its paths stand for loses its own opacity, blend mode and effects, which would stay on top of them.
+ let group: Vec = if stands { vec![target] } else { vec![] };
// When both objects come out as they were, the document and its undo history stay as they are.
let before = s.doc()?.doc.clone();
super::overprint::edit_counted(s, if copy { "Copy Appearance" } else { "Move Appearance" }, |d| {
- let src = d.node(source).cloned().ok_or(EngineError::NoNode(source))?;
- let to = d.node_mut(target).ok_or(EngineError::NoNode(target))?;
- let mut ap = src.appearance.clone();
- if let (Some(f), Some(t)) = (src.geometric_bounds(), to.geometric_bounds()) {
- ap.rebase_gradients(f, t);
+ d.node(source).ok_or(EngineError::NoNode(source))?;
+ let src = d.node(read).cloned().ok_or(EngineError::NoNode(read))?;
+ for id in &targets {
+ let to = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?;
+ let mut ap = src.appearance.clone();
+ if let (Some(f), Some(t)) = (src.geometric_bounds(), to.geometric_bounds()) {
+ ap.rebase_gradients(f, t);
+ }
+ // Only groups, layers, type and images have a Contents (Characters) row to keep a slot for.
+ if to.contents_label().is_none() {
+ ap.contents_index = None;
+ }
+ to.appearance = ap;
+ (to.opacity, to.blend, to.isolate, to.knockout, to.knockout_shape) =
+ (src.opacity, src.blend, src.isolate, src.knockout, src.knockout_shape);
+ super::object::snap_if_aligned(to);
}
- // Only groups, layers, type and images have a Contents (Characters) row to keep a slot for.
- if to.contents_label().is_none() {
- ap.contents_index = None;
+ clear_stood_for(d, &group);
+ for id in &cleared {
+ if let Some(n) = d.node_mut(*id) {
+ clear(n);
+ (n.isolate, n.knockout, n.knockout_shape) = (false, Default::default(), false);
+ super::object::snap_if_aligned(n);
+ }
}
- to.appearance = ap;
- (to.opacity, to.blend, to.isolate, to.knockout, to.knockout_shape) = (src.opacity, src.blend, src.isolate, src.knockout, src.knockout_shape);
- super::object::snap_if_aligned(to);
- if !copy && let Some(n) = d.node_mut(source) {
- clear(n);
- (n.isolate, n.knockout, n.knockout_shape) = (false, Default::default(), false);
- super::object::snap_if_aligned(n);
- }
- let changed = if copy { vec![target] } else { vec![source, target] };
+ let changed: Vec = cleared.iter().chain(&targets).copied().collect();
let kept = super::graph::capture_series_opacity(d, &both_kinds(&changed));
- // The target last: on marks of one series, its paint is the one the series keeps.
+ // The targets last: on marks of one series, their paint is the one the series keeps.
let mut rows = top_paint_changed(&before, d, &changed);
- rows.extend(painted_rows(d, both_kinds(&[target])));
+ rows.extend(painted_rows(d, both_kinds(&targets)));
let painted = super::graph::capture_series_top_paints(d, &rows);
let object = super::graph::capture_series_object_opacity(d, &changed);
- Ok(count_changed(d, &before, &[source, target]) + usize::from(kept || painted || object))
+ let all: Vec = from.iter().chain(&targets).chain(&group).copied().collect();
+ Ok(count_changed(d, &before, &all) + usize::from(kept || painted || object))
})?;
s.active_appearance_item = None;
Ok(json!({ "source": source.0, "target": target.0 }))
@@ -772,7 +882,7 @@ fn is_group(n: &Node) -> bool {
}
fn reduce_basic(s: &mut Session, p: &Value) -> Result {
- let ids = require_targets(s, p, "appearance.reduceToBasic")?;
+ let ids = require_whole_targets(s, p, "appearance.reduceToBasic")?;
// When every object comes out as it was, the document and its undo history stay as they are.
let before = s.doc()?.doc.clone();
// The Appearance panel's active row (a row of the first selected object's stack, and in each
@@ -828,7 +938,7 @@ fn set_item(s: &mut Session, p: &Value) -> Result {
check_numbers(p, &["weight", "opacity"], "appearance.setItem")?;
check_switches(p, &["visible"], "appearance.setItem")?;
let paint = paint_from(s, p)?;
- let ids = appearance_targets(s, p)?;
+ let ids = row_targets(s, p)?;
if ids.is_empty() {
return Err(bad("appearance.setItem", "select objects or give ids"));
}
@@ -1039,7 +1149,7 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result {
/// `appearance.showAllHidden`: shows every hidden fill, stroke and effect of the selected objects.
fn show_all_hidden(s: &mut Session, p: &Value) -> Result {
- let roots = require_targets(s, p, "appearance.showAllHidden")?;
+ let roots = require_whole_targets(s, p, "appearance.showAllHidden")?;
let before = s.doc()?.doc.clone();
let ids = s.edit("Show All Hidden Attributes", |d, _| {
let shown: Vec = roots
@@ -1069,11 +1179,16 @@ fn show_all_hidden(s: &mut Session, p: &Value) -> Result {
fn move_item(s: &mut Session, p: &Value) -> Result {
const C: &str = "appearance.moveItem";
let to = index_param(p, "to", C)?;
- let ids = require_targets(s, p, C)?;
let aligned = aligned(p, C)?;
if p.get("from").and_then(Value::as_str) == Some("contents") {
+ // A graph series group's own Contents row, not its paths, which have none.
+ let ids = appearance_targets(s, p)?;
+ if ids.is_empty() {
+ return Err(bad(C, "select objects or give ids"));
+ }
return move_contents(s, &ids, to);
}
+ let ids = require_targets(s, p, C)?;
// Where an aligned row would go in each of several different stacks isn't one place.
if aligned && appearances_differ(&s.doc()?.doc, &ids) {
return Err(bad(C, "rows of objects whose appearances differ can't be reordered together"));
diff --git a/crates/engine/src/cmd/arrowheads.rs b/crates/engine/src/cmd/arrowheads.rs
index 0aeda59731..340cf98459 100644
--- a/crates/engine/src/cmd/arrowheads.rs
+++ b/crates/engine/src/cmd/arrowheads.rs
@@ -18,7 +18,7 @@ use vectorcraft_doc::{Appearance, AppearanceItem, CustomArrowhead, Document, Nod
use vectorcraft_geom::{Affine, FillRule, PathData, Point, SubPath};
use vectorcraft_pathops::BoolOp;
-use super::{CommandSpec, Result, Session, always, bad, cmd};
+use super::{CommandSpec, EngineError, Result, Session, always, bad, cmd};
/// The arrowheads file's name, in the folder of the preferences.
pub const FILE_NAME: &str = "Arrowheads.vectorcraft";
@@ -37,7 +37,7 @@ pub fn specs() -> Vec {
"Arrowheads",
[],
None,
- "{} → {arrowheads: [{name (stroke.set `startArrow`/`endArrow` as {custom: name}), reach, halfWidth (in head weights)}], file: the arrowheads file's path or null (no file here, as on the web), skipped: [{name, reason}] symbols of the file that are not heads, error: why the file could not be read, or null} the custom arrowheads, listed after the built-in ones in the Stroke panel's arrowhead menus",
+ "{} → {arrowheads: [{name (stroke.set `startArrow`/`endArrow` as {custom: name}), reach, halfWidth (in head weights)}], file: the arrowheads file's path or null (no file here, as on the web, or the file is outside the automation read roots), skipped: [{name, reason}] symbols of the file that are not heads, error: why the file could not be read (naming the file by its file name outside the read roots), or null} the custom arrowheads, listed after the built-in ones in the Stroke panel's arrowhead menus",
always,
list
),
@@ -46,7 +46,7 @@ pub fn specs() -> Vec {
"Edit Arrowheads",
[],
None,
- "{} open the arrowheads file (a native document next to the preferences; created first with one example head when it is missing). Each symbol in it is one custom arrowhead named after the symbol: it points right, its registration point is the tip, and its height is drawn as wide as the built-in heads; its visible fills and outlined strokes make the head. Saving the file reloads the heads → {path, created: whether the file was made now}",
+ "{} open the arrowheads file (a native document next to the preferences; created first with one example head when it is missing). Each symbol in it is one custom arrowhead named after the symbol: it points right, its registration point is the tip, and its height is drawn as wide as the built-in heads; its visible fills and outlined strokes make the head. Saving the file reloads the heads. Under automation read roots, a file outside them is an error, and nothing is made or opened → {path, created: whether the file was made now}",
has_file,
edit
),
@@ -55,7 +55,7 @@ pub fn specs() -> Vec {
"Reload Arrowheads",
[],
None,
- "{} read the arrowheads file again (a save of it from VectorCraft does this already) → {count, skipped: [{name, reason}]}",
+ "{} read the arrowheads file again (a save of it from VectorCraft does this already; under automation read roots, a file outside them is an error and is not read) → {count, skipped: [{name, reason}]}",
has_file,
reload
),
@@ -409,13 +409,36 @@ fn skipped(s: &Session) -> Vec {
s.arrowheads.skipped.iter().map(|(name, reason)| json!({"name": name, "reason": reason})).collect()
}
+/// The arrowheads file at `path` checked for reading against the automation roots in force: an agent
+/// confined to read roots reaches the file only inside them. The error names the file by its file
+/// name outside them.
+fn may_reach(path: &str) -> Result<()> {
+ crate::file_access::check_read(path).map_err(|e| super::fileio::naming_in_error(EngineError::Other(e), path))
+}
+
+/// `msg`, an error about the arrowheads file at `path`, as the request being served may see it: the
+/// file named whole inside the read roots, else by its file name.
+fn shown_error(msg: &str, path: Option<&str>) -> String {
+ let Some(path) = path else { return msg.to_string() };
+ match super::fileio::naming_in_error(EngineError::Other(msg.to_string()), path) {
+ EngineError::Other(m) => m,
+ e => e.to_string(),
+ }
+}
+
fn list(s: &mut Session, _: &Value) -> Result {
let heads: Vec = s.arrowheads.heads.iter().map(|h| json!({"name": h.name, "reach": h.reach(), "halfWidth": h.half_width()})).collect();
- Ok(json!({"arrowheads": heads, "file": s.arrowheads.path, "skipped": skipped(s), "error": s.arrowheads.error}))
+ let path = s.arrowheads.path.as_deref();
+ let file = path.filter(|p| crate::file_access::check_read(p).is_ok());
+ let error = s.arrowheads.error.as_deref().map(|e| shown_error(e, path));
+ Ok(json!({"arrowheads": heads, "file": file, "skipped": skipped(s), "error": error}))
}
fn reload(s: &mut Session, _: &Value) -> Result {
const C: &str = "stroke.arrowheads.reload";
+ if let Some(path) = &s.arrowheads.path {
+ may_reach(path)?;
+ }
s.reload_arrowheads();
if let Some(e) = &s.arrowheads.error {
return Err(bad(C, e.clone()));
@@ -426,8 +449,11 @@ fn reload(s: &mut Session, _: &Value) -> Result {
fn edit(s: &mut Session, _: &Value) -> Result {
const C: &str = "stroke.arrowheads.edit";
let path = s.arrowheads.path.clone().ok_or_else(|| bad(C, "no arrowheads file here"))?;
+ // Checked before anything is made, brought to the front or opened.
+ may_reach(&path)?;
if let Some(dir) = std::path::Path::new(&path).parent() {
- std::fs::create_dir_all(dir).map_err(|e| bad(C, format!("{}: {e}", dir.display())))?;
+ let dir = dir.to_string_lossy();
+ std::fs::create_dir_all(&*dir).map_err(|e| super::fileio::naming_in_error(bad(C, format!("{dir}: {e}")), &dir))?;
}
// Made only when no file is there, also when another run makes one at the same moment.
let bytes = vectorcraft_format::save_file(&starter());
@@ -446,7 +472,8 @@ fn edit(s: &mut Session, _: &Value) -> Result {
s.set_active(i);
}
None => {
- crate::file_access::unconfined(|| s.execute("document.open", &json!({ "path": path })))?;
+ // Opened in the request's own scope, so the document keeps the roots it was opened under.
+ s.execute("document.open", &json!({ "path": path }))?;
}
}
Ok(json!({"path": path, "created": created}))
diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs
index 6c75eae128..c94de5d3b2 100644
--- a/crates/engine/src/cmd/colormgmt.rs
+++ b/crates/engine/src/cmd/colormgmt.rs
@@ -90,7 +90,7 @@ pub fn specs() -> Vec {
"Gamut Warning",
[],
None,
- "{ids?|id?, colors?: [color] (#rrggbbaa and #rgba are refused)} colours that can't be printed in the working CMYK (selection, or the whole document when nothing is selected) → {checked, outOfGamut: [{hex, deltaE, count}]}",
+ "{ids?|id?, colors?: [color] (#rrggbbaa and #rgba are refused)} colors that can't be printed in the document's assigned CMYK profile, else the working CMYK (selection, or the whole document when nothing is selected) → {checked, outOfGamut: [{hex, deltaE, count}]}",
has_doc,
gamut_check
),
diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs
index ae3fc521f0..ae48ba02e6 100644
--- a/crates/engine/src/cmd/docmenu.rs
+++ b/crates/engine/src/cmd/docmenu.rs
@@ -210,7 +210,7 @@ pub fn specs() -> Vec {
"Guides",
[],
None,
- "{} → [{index, vertical, pos, selected, artboard?: index (an artboard guide's), layer?: id (the layer it is on), shown, editable (its layer shown and unlocked)}…] the ruler guides",
+ "{} → [{index, id (its node, which `layer.setProps` hides or locks on its own), vertical, pos, selected, artboard?: index (an artboard guide's), layer?: id (the layer it is on), hidden, locked (its own visibility and lock), shown, editable (it and its layer shown and unlocked)}…] the ruler guides",
has_doc,
guide_list
),
@@ -219,7 +219,7 @@ pub fn specs() -> Vec {
"Select Guides",
[],
None,
- "{indexes: [index…], toggle?: bool} select ruler guides on their own (deselecting the art); toggle adds or removes them instead, keeping the selected art (a Shift-click on a guide) → {selected: [index…]}",
+ "{indexes: [index…] (at most 10,000), toggle?: bool} select ruler guides on their own (deselecting the art); toggle adds or removes them instead, keeping the selected art (a Shift-click on a guide) → {selected: [index…]}",
guides_unlocked,
guide_select
),
@@ -228,7 +228,7 @@ pub fn specs() -> Vec {
"Remove Guide",
[],
None,
- "{index?} delete ruler guide `index`, else the selected ones (as Delete / edit.clear does) → {count}",
+ "{index?, indexes?: [index…] (at most 10,000)} delete ruler guide `index` (or guides `indexes`, as dragging their rows onto the Layers panel's trash does), else the selected ones (as Delete / edit.clear does), in one undo step; give `index` or `indexes`, not both, and an index given twice counts once; a hidden or locked guide, or one on a hidden or locked layer, is an error → {count}",
guides_unlocked,
guide_remove
),
@@ -246,7 +246,7 @@ pub fn specs() -> Vec {
"Move Guide to Layer",
[],
None,
- "{index?, indexes?: [index…], layer: id} put ruler guide `index` (or guides `indexes`), else the selected guides, on layer `layer` (a layer or sublayer), as dragging their rows onto that layer's row in the Layers panel does → {count}",
+ "{index?, indexes?: [index…] (at most 10,000), layer: id} put ruler guide `index` (or guides `indexes`), else the selected guides, on layer `layer` (a layer or sublayer), as dragging their rows onto that layer's row in the Layers panel does; give `index` or `indexes`, not both, and an index given twice counts once → {count}",
guides_unlocked,
guide_set_layer
),
@@ -805,12 +805,17 @@ fn guide_list(s: &mut Session, _: &Value) -> Result {
let row = |(i, g): (usize, &Guide)| {
let selected = g.id.is_some_and(|id| st.selection.guides.contains(&id));
let mut r = json!({ "index": i, "vertical": g.vertical, "pos": g.pos, "selected": selected });
+ if let Some(id) = g.id {
+ r["id"] = json!(id.0);
+ }
if let Some(ab) = st.doc.artboards.iter().position(|a| Some(a.id) == g.artboard) {
r["artboard"] = json!(ab);
}
if let Some(l) = g.layer {
r["layer"] = json!(l.0);
}
+ r["hidden"] = json!(g.hidden);
+ r["locked"] = json!(g.locked);
r["shown"] = json!(st.doc.guide_shown(g));
r["editable"] = json!(st.doc.guide_editable(g));
r
@@ -824,27 +829,44 @@ fn guide_index_in(ids: &[NodeId], v: Option<&Value>, cmd: &str) -> Result(v: &'a Value, cmd: &str) -> Result<&'a Vec> {
+ let list = v.as_array().ok_or_else(|| bad(cmd, "indexes must be an array of guide indexes"))?;
+ if list.len() > MAX_GUIDE_INDEXES {
+ return Err(bad(cmd, format!("indexes may hold at most {MAX_GUIDE_INDEXES} entries")));
+ }
+ Ok(list)
+}
+
/// The id of ruler guide `index` of the active document (`index` validated).
fn guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result {
guide_index_in(&guide_ids(&s.doc()?.doc), v, cmd)
}
-/// Is ruler guide `id` one that can be moved or deleted: not on a hidden or locked layer?
+/// Is ruler guide `id` one that can be moved or deleted: not hidden or locked itself, nor on a
+/// hidden or locked layer?
fn check_editable_guide(s: &Session, id: NodeId) -> Result {
let d = &s.doc()?.doc;
- if !d.ruler_guide(id).is_some_and(|g| d.guide_editable(&g)) {
+ let guide = d.ruler_guide(id);
+ if guide.as_ref().is_some_and(|g| g.hidden || g.locked) {
+ return Err(EngineError::Other("the guide is hidden or locked".into()));
+ }
+ if !guide.is_some_and(|g| d.guide_editable(&g)) {
return Err(EngineError::Other("the guide is on a hidden or locked layer".into()));
}
Ok(id)
}
-/// [`guide_index`] of a guide that can be moved or deleted: not on a hidden or locked layer.
+/// [`guide_index`] of a guide that can be moved or deleted ([`check_editable_guide`]).
fn editable_guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result {
check_editable_guide(s, guide_index(s, v, cmd)?)
}
fn guide_select(s: &mut Session, p: &Value) -> Result {
- let list = p.get("indexes").and_then(Value::as_array).ok_or_else(|| bad("guide.select", "missing indexes"))?;
+ let list = guide_index_list(p.get("indexes").ok_or_else(|| bad("guide.select", "missing indexes"))?, "guide.select")?;
let ids = guide_ids(&s.doc()?.doc);
let picked = list.iter().map(|v| guide_index_in(&ids, Some(v), "guide.select")).collect::>>()?;
if switch_or(p, "toggle", false, "guide.select")? {
@@ -861,18 +883,30 @@ fn guide_select(s: &mut Session, p: &Value) -> Result {
Ok(json!({ "selected": guide_indexes(&st.doc, &st.selection.guides) }))
}
-/// Delete ruler guide `index`, else the selected guides, in one undo step → {count}.
+/// Delete ruler guide `index` (or guides `indexes`), else the selected guides, in one undo step
+/// → {count}.
pub(crate) fn guide_remove(s: &mut Session, p: &Value) -> Result {
- let gone: Vec = match p.get("index") {
- Some(v) => vec![editable_guide_index(s, Some(v), "guide.remove")?],
- None => s.doc()?.selection.guides.clone(),
+ const C: &str = "guide.remove";
+ let mut gone: Vec = match (p.get("index"), p.get("indexes")) {
+ (Some(_), Some(_)) => return Err(bad(C, "give index or indexes, not both")),
+ (Some(v), None) => vec![editable_guide_index(s, Some(v), C)?],
+ (None, Some(list)) => {
+ let list = guide_index_list(list, C)?;
+ let ids = guide_ids(&s.doc()?.doc);
+ // A set, so an index given twice deletes its guide once and the ids held never outnumber the guides.
+ let set = list.iter().map(|v| check_editable_guide(s, guide_index_in(&ids, Some(v), C)?)).collect::>>()?;
+ set.into_iter().collect()
+ }
+ (None, None) => s.doc()?.selection.guides.clone(),
};
+ gone.sort_unstable();
+ gone.dedup();
if gone.is_empty() {
- return Err(bad("guide.remove", "no guide selected"));
+ return Err(bad(C, "no guide selected"));
}
let label = if gone.len() == 1 { "Delete Guide" } else { "Delete Guides" };
s.edit(label, |d, sel| {
- d.retain_guides(sel, |g| !g.id.is_some_and(|id| gone.contains(&id)));
+ d.retain_guides(sel, |g| !g.id.is_some_and(|id| gone.binary_search(&id).is_ok()));
Ok(())
})?;
Ok(json!({ "count": gone.len() }))
@@ -888,14 +922,19 @@ fn guide_set_layer(s: &mut Session, p: &Value) -> Result {
if super::layer::locked_within(d, layer) {
return Err(EngineError::Other("the layer or group is locked".into()));
}
- let moving: Vec = match (p.get("index"), p.get("indexes").and_then(Value::as_array)) {
- (Some(v), _) => vec![editable_guide_index(s, Some(v), C)?],
+ let mut moving: Vec = match (p.get("index"), p.get("indexes")) {
+ (Some(_), Some(_)) => return Err(bad(C, "give index or indexes, not both")),
+ (Some(v), None) => vec![editable_guide_index(s, Some(v), C)?],
(None, Some(list)) => {
+ let list = guide_index_list(list, C)?;
let ids = guide_ids(&s.doc()?.doc);
- list.iter().take(10_000).map(|v| check_editable_guide(s, guide_index_in(&ids, Some(v), C)?)).collect::>>()?
+ list.iter().map(|v| check_editable_guide(s, guide_index_in(&ids, Some(v), C)?)).collect::>>()?
}
(None, None) => s.doc()?.selection.guides.clone(),
};
+ // An index given twice moves (and counts) its guide once; the others keep their order.
+ let mut seen = HashSet::new();
+ moving.retain(|id| seen.insert(*id));
if moving.is_empty() {
return Err(bad(C, "no guide selected (or give an index)"));
}
diff --git a/crates/engine/src/cmd/draw2/commands.rs b/crates/engine/src/cmd/draw2/commands.rs
index 8946aa4351..400e13baab 100644
--- a/crates/engine/src/cmd/draw2/commands.rs
+++ b/crates/engine/src/cmd/draw2/commands.rs
@@ -11,7 +11,7 @@ pub fn specs() -> Vec {
"Pencil",
[],
None,
- "{points: [[x,y]…] or [[x,y,pressure]…] or [[x,y,pressure,tiltX,tiltY,rotation]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic and Scatter brushes with Pressure variation; a point without one presses fully. Pen tilt in degrees, each −90..90, x positive leaning right and y leaning down the page, and barrel rotation in degrees clockwise: the stroke keeps how far the pen leaned, which way and how far its barrel turned, for brushes with Tilt, Bearing and Rotation variation; null for what a point lacks), fidelity?: pt (1.5), fit?: bool (true; false: the points are joined by straight segments, as the Pencil previews a stroke with Live curve fitting off), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: a new path leaves nothing selected, and a continued or redrawn one is deselected while the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, connect?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end; with connect, the stroke ends on that end of another path's last open subpath and joins it on, in one path with the extended path's appearance (the other path is removed when nothing of it is left); redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it",
+ "{points: [[x,y]…] or [[x,y,pressure]…] or [[x,y,pressure,tiltX,tiltY,rotation]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic and Scatter brushes with Pressure variation; a point without one presses fully. Pen tilt in degrees, each −90..90, x positive leaning right and y leaning down the page, and barrel rotation in degrees clockwise: the stroke keeps how far the pen leaned, which way and how far its barrel turned, for brushes with Tilt, Bearing and Rotation variation; null for what a point lacks), fidelity?: pt (1.5; from 0.000015625 (0.01 / 640) to 100 / 0.0313 (about 3194.89), the Pencil's and the Paintbrush's 0.01 to 100 px Fidelity at the highest and the lowest zoom; a value outside that range is an error. The tools send their Fidelity in pixels divided by the zoom, kept within the range), fit?: bool (true; false: the points are joined by straight segments, as the Pencil previews a stroke with Live curve fitting off), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: a new path leaves nothing selected, and a continued or redrawn one is deselected while the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, connect?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end; with connect, the stroke ends on that end of another path's last open subpath and joins it on, in one path with the extended path's appearance (the other path is removed when nothing of it is left); redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it",
has_doc,
freehand
),
diff --git a/crates/engine/src/cmd/draw2/freehand.rs b/crates/engine/src/cmd/draw2/freehand.rs
index f13b0a0ba4..31e184e195 100644
--- a/crates/engine/src/cmd/draw2/freehand.rs
+++ b/crates/engine/src/cmd/draw2/freehand.rs
@@ -151,7 +151,11 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result {
}
let pen = PenRecord::of_samples(&samples);
let mut pts: Vec = samples.into_iter().map(|s| s.pt).collect();
- let tol = number_or(p, "fidelity", 1.5, C)?.clamp(vectorcraft_tools::draw2::MIN_FIT_TOLERANCE, 100.0);
+ let tol = number_or(p, "fidelity", 1.5, C)?;
+ let (lo, hi) = (vectorcraft_tools::draw2::MIN_FIT_TOLERANCE, vectorcraft_tools::draw2::MAX_FIT_TOLERANCE);
+ if !(lo..=hi).contains(&tol) {
+ return Err(bad(C, format!("`fidelity` must be from {lo} to {hi} pt")));
+ }
// The tolerance the stroke is fitted at; with `fit: false` it is not fitted.
let fit_tol = switch_or(p, "fit", true, C)?.then_some(tol);
let closed = switch_or(p, "closed", false, C)?;
@@ -200,7 +204,9 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result {
}
None => None,
};
- let close_tol = (tol * 4.0).max(6.0);
+ // An extending stroke that ends within four Fidelities of the path's other end closes it:
+ // at least 6 pt and at most 400 pt, whatever the Fidelity it is fitted at.
+ let close_tol = (tol * 4.0).clamp(6.0, 400.0);
let closing = connect.is_none() && sp.anchors.len() >= 2 && pts.last().is_some_and(|p| p.distance(first) <= close_tol);
if closing && let Some(l) = pts.last_mut() {
*l = first;
diff --git a/crates/engine/src/cmd/effectcmd/graphics.rs b/crates/engine/src/cmd/effectcmd/graphics.rs
index 3365a1b764..3f95ae0518 100644
--- a/crates/engine/src/cmd/effectcmd/graphics.rs
+++ b/crates/engine/src/cmd/effectcmd/graphics.rs
@@ -1,5 +1,5 @@
//! Window › 3D and Materials › Materials › Graphics: art kept as a document symbol (Add as Single
-//! Graphic) and mapped onto the planar faces of 3D objects (Extrude & Bevel's caps, a plane). The
+//! Graphic) and mapped onto 3D objects (Extrude & Bevel's caps, a plane, Revolve's side). The
//! graphics an object shows are the `graphics` parameter of its 3D effect, which `effect.setParams`
//! also edits.
@@ -28,7 +28,7 @@ pub(super) fn specs() -> Vec {
"Map Graphic",
[],
None,
- "{symbol: name of a document symbol, face?: front|back (front; Plane has only a front; the back reads left to right as seen from behind), x?: pt, y?: pt (0, 0: the offset of the graphic's center from the center of the object's bounds, in the face's own plane), scale?: % (1..10000; default 100, or less so the graphic fits inside the object), rotation?: degrees (-360..360, 0; counterclockwise), index?: int (the 3D effect's position; default: each object's first 3D effect, its own before its fills' and strokes'), item?: appearance item index|null (refused without index; as effect.setParams), ids?|id?} add a graphic to the 3D effect of each selected object, drawn on that face turned, foreshortened and shaded with it and clipped to it; every object's 3D effect must be Extrude & Bevel (on a solid cap) or Rotate (the Plane type); one undo step → {ids, graphic}",
+ "{symbol: name of a document symbol, face?: front|back (front; Plane has only a front; the back reads left to right as seen from behind; on Revolve the front is its swept side, which is the only face), x?: pt, y?: pt (0, 0: the offset of the graphic's center from the center of the object's bounds, in the face's own plane; on Revolve from the middle of the side unrolled flat, x around the axis and y along the profile), scale?: % (1..10000; default 100, or less so the graphic fits inside the object, on Revolve inside the half of the side seen from the front), rotation?: degrees (-360..360, 0; counterclockwise), index?: int (the 3D effect's position; default: each object's first 3D effect, its own before its fills' and strokes'), item?: appearance item index|null (refused without index; as effect.setParams), ids?|id?} add a graphic to the 3D effect of each selected object, drawn on that face turned, foreshortened and shaded with it and clipped to it (on Revolve wrapped around the side, and drawn only where its outside faces the viewer); every object's 3D effect must be Extrude & Bevel (on a solid cap), Rotate (the Plane type) or Revolve; one undo step → {ids, graphic}",
has_doc,
map_graphic
),
@@ -111,7 +111,7 @@ fn map_graphic(s: &mut Session, p: &Value) -> Result {
return Err(bad(C, format!("object {} needs a 3D effect at index {index}", id.0)));
};
if !effects::takes_graphics(&effect.id) {
- return Err(bad(C, format!("object {}: Graphics map onto planar faces: use the Extrude or Plane object type", id.0)));
+ return Err(bad(C, format!("object {}: {}", id.0, effects::GRAPHICS_NOT_TAKEN)));
}
// A hollow Extrude & Bevel keeps the graphics it has, to show when Cap is turned on again,
// but has no cap to show a new one on.
diff --git a/crates/engine/src/cmd/effectcmd/graphics_tests.rs b/crates/engine/src/cmd/effectcmd/graphics_tests.rs
index 125ba82514..a9d3a754ce 100644
--- a/crates/engine/src/cmd/effectcmd/graphics_tests.rs
+++ b/crates/engine/src/cmd/effectcmd/graphics_tests.rs
@@ -68,12 +68,37 @@ fn a_large_graphic_starts_inside_its_object() {
assert!((scale - 100.0 * 100.0 / 401.0).abs() < 1e-9, "{scale}");
}
+#[test]
+fn map_graphic_puts_a_graphic_on_the_side_of_a_revolve() {
+ let (mut s, id) = setup("threeD.revolve");
+ let plain = vectorcraft_render::effects::revolve_art_in(Some(&s.doc().unwrap().doc), s.doc().unwrap().doc.node(id).unwrap());
+ let history = undo_len(&s);
+ s.execute("threeD.mapGraphic", &json!({"symbol": "Mark", "ids": [id.0]})).unwrap();
+ assert_eq!(graphics(&s, id), json!([{"symbol": "Mark", "face": "front", "x": 0.0, "y": 0.0, "scale": 100.0, "rotation": 0.0}]));
+ assert_eq!(undo_len(&s), history + 1);
+ let d = &s.doc().unwrap().doc;
+ assert_ne!(vectorcraft_render::effects::revolve_art_in(Some(d), d.node(id).unwrap()), plain, "the graphic is drawn");
+ // A large graphic starts inside the half of the side seen from the front: 100π around the
+ // axis (the square's far edge is 100 from it) and 300 along the profile (its edge on the axis
+ // sweeps nothing).
+ let big = s.execute("path.create", &json!({"d":"M0 0L400 0L400 200L0 200Z"})).unwrap()["id"].clone();
+ s.execute("threeD.addGraphic", &json!({"name": "Big", "ids": [big]})).unwrap();
+ s.execute("threeD.mapGraphic", &json!({"symbol": "Big", "ids": [id.0]})).unwrap();
+ let scale = graphics(&s, id)[1]["scale"].as_f64().unwrap();
+ assert!((scale - 100.0 * 100.0 * std::f64::consts::PI / 401.0).abs() < 1e-6, "{scale}");
+ // The side is its only face.
+ let history = undo_len(&s);
+ let e = s.execute("threeD.mapGraphic", &json!({"symbol": "Mark", "face": "back", "ids": [id.0]})).unwrap_err().to_string();
+ assert!(e.contains("side"), "{e}");
+ assert_eq!(undo_len(&s), history);
+}
+
#[test]
fn map_graphic_refuses_what_cannot_take_it_and_changes_nothing() {
let (mut s, id) = setup("threeD.inflate");
let history = undo_len(&s);
let e = s.execute("threeD.mapGraphic", &json!({"symbol": "Mark", "ids": [id.0]})).unwrap_err().to_string();
- assert!(e.contains("Extrude or Plane"), "{e}");
+ assert!(e.contains("Plane, Extrude and Revolve"), "{e}");
let (mut s, id) = setup("threeD.rotate");
for (p, says) in [
(json!({"symbol": "Gone"}), "no symbol named"),
diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs
index 0f55386830..ad860b453a 100644
--- a/crates/engine/src/cmd/expand.rs
+++ b/crates/engine/src/cmd/expand.rs
@@ -18,8 +18,8 @@ use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint,
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer};
use vectorcraft_doc::live::GradientMesh;
use vectorcraft_doc::pattern::{PatternDef, place_art};
-use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Scaling};
-use vectorcraft_geom::{Affine, PathData, Point, Rect, SubPath, Vec2, shapes};
+use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Scaling, TextObject};
+use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect, SubPath, Vec2, shapes};
use super::dimension::EndedLink;
use super::edit::selected_roots;
@@ -56,7 +56,7 @@ pub fn specs() -> Vec {
"Expand Options",
[],
None,
- "{} what each Expand option would change in the selection → {object, fill, stroke, gradient, appearance, symbolArt?: {fill, stroke, gradient}} (true: something to expand; gradient: a gradient fill that Fill expands, which Expand Gradient To applies to; appearance: appearance attributes Expand Appearance expands first, for which Object › Expand… is dimmed in the menus; symbolArt, when the selection holds symbol instances that Object expands: true when only their art holds it, which Fill and Stroke reach only with Object on)",
+ "{} what each Expand option would change in the selection → {object, fill, stroke, gradient, appearance, symbolArt?: {fill, stroke, gradient}} (true: something to expand; gradient: a gradient fill that Fill expands (in type, on the outline Object makes of a character), which Expand Gradient To applies to; appearance: appearance attributes Expand Appearance expands first, for which Object › Expand… is dimmed in the menus; symbolArt, when the selection holds symbol instances that Object expands: true when only their art holds it, which Fill and Stroke reach only with Object on)",
has_selection,
|s, _| {
let roots = selected_roots(s)?;
@@ -197,7 +197,7 @@ impl Expandable {
// Once one is found, the next path is not measured.
e.gradient = e.gradient
|| match &c.kind {
- NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))),
+ NodeKind::Text(t) => text_gradient_expands(d, t),
_ => shape && gradient_expands(d, c),
};
e.fill |= e.gradient;
@@ -247,6 +247,34 @@ fn gradient_expands(d: &Document, l: &Node) -> bool {
any
}
+/// Whether type `t` has a gradient fill that Fill expands once Object has outlined it: one of the
+/// paths `type.createOutlines` makes of its glyphs, ruby glyphs and underline and strikethrough
+/// bars, each painted as its run, has one ([`gradient_expands`] on that path's own box). Inline
+/// graphics and color glyphs become other art. Leaves the document as it is and stops at the
+/// first such path.
+fn text_gradient_expands(d: &Document, t: &TextObject) -> bool {
+ if !t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))) {
+ return false;
+ }
+ let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
+ let expands = |run: usize, outline: &BezPath| {
+ let style = t.runs.get(run).map(|r| &r.style);
+ if !matches!(style.map_or_else(|| t.first_style().fill, |s| s.fill.clone()), Paint::Gradient(_)) {
+ return false;
+ }
+ let path = PathData::from_bezpath(outline).transformed(t.xf);
+ if path.is_empty() {
+ return false;
+ }
+ let appearance = style.map_or_else(|| t.first_style().appearance(), |s| s.appearance());
+ gradient_expands(d, &Node::path(NodeId(u64::MAX), path, appearance))
+ };
+ let inline = |gi: usize| lay.inlines.iter().find(|i| i.glyph == gi).is_some_and(|i| t.runs.get(i.run).is_some_and(|r| r.inline.is_some()));
+ lay.glyphs.iter().enumerate().any(|(gi, g)| !inline(gi) && g.image.is_none() && g.colour.is_none() && expands(g.run, &g.outline))
+ || lay.ruby.iter().any(|g| expands(g.run, &g.outline))
+ || vectorcraft_text::decorations(&lay, vectorcraft_text::FontDb::global(), t).iter().any(|(run, bar)| expands(*run, bar))
+}
+
/// [`gradient_expands`] for a path that no live-effect plug-in paints ([`expand_piece_fills`]).
fn piece_gradient_expands(d: &Document, l: &Node) -> bool {
if !l.appearance.items.iter().filter_map(gradient_fill).any(|(_, g)| g.gradient.is_spread()) {
@@ -1360,8 +1388,9 @@ fn band_edges(lo: f64, hi: f64, n: usize, runs: Option<(f64, usize)>) -> Vec Vec<(f64, (Color, f32))> {
let at = |t: f64| (t, g.sample_keep_model(t as f32));
let mut v = vec![at(t0)];
- // A Perceptual gradient curves between its stops: the mesh takes a column at each of the stops
- // its expansion adds, so the linear mix between columns follows that curve.
+ // A Perceptual gradient, or one mixed around the hue wheel, curves between its stops: the mesh
+ // takes a column at each of the stops its expansion adds, so the linear mix between columns
+ // follows that curve.
if !g.method.is_classic() {
v.extend(g.expanded().map(|(o, ..)| f64::from(o)).filter(|o| *o > t0 && *o < t1).map(at));
}
diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs
index 333db1c68f..9154ce7ee5 100644
--- a/crates/engine/src/cmd/fileio.rs
+++ b/crates/engine/src/cmd/fileio.rs
@@ -69,8 +69,8 @@ pub use dxf::{UNSUPPORTED, Unsupported, unsupported};
pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, Note, NoteKind, encode, encode_all, encode_with_notes, encode_with_warnings, note_texts};
pub(crate) use encode::{anti_alias, background, with_single_artboard};
pub(crate) use export::with_artboard_fills;
-pub(crate) use export::{export_crop, isolated};
-pub use export::{export_doc, export_source};
+pub(crate) use export::{EXPORT_PARAMS, export_crop, isolated};
+pub use export::{export_doc, export_source, unused_option_notes};
use load::err;
pub(crate) use load::import_svg;
pub(crate) use load::is_placeholder;
diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs
index ed6ff95808..e867fdee41 100644
--- a/crates/engine/src/cmd/fileio/commands.rs
+++ b/crates/engine/src/cmd/fileio/commands.rs
@@ -27,7 +27,7 @@ pub fn specs() -> Vec {
"Serialize Document",
[],
None,
- "{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored",
+ "{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it",
has_doc,
export::serialize
),
@@ -36,7 +36,7 @@ pub fn specs() -> Vec {
"Export Document",
[],
None,
- "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin.