diff --git a/Cargo.lock b/Cargo.lock
index ce5569cde9..dc08d4542e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -6341,7 +6341,6 @@ dependencies = [
"raw-window-handle",
"rfd",
"serde_json",
- "smithay-clipboard",
"socket2",
"tempfile",
"vectorcraft-engine",
@@ -6477,6 +6476,7 @@ dependencies = [
"vectorcraft-text",
"vectorcraft-three-d",
"vectorcraft-trace",
+ "wat",
]
[[package]]
@@ -6665,6 +6665,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 55b9e344e7..ffa1d2a5e8 100644
--- a/apps/vectorcraft/Cargo.toml
+++ b/apps/vectorcraft/Cargo.toml
@@ -96,26 +96,26 @@ winit = { version = "0.30", default-features = false, features = ["wayland-csd-a
x11rb = { version = "0.13", default-features = false, features = ["xinput", "xkb"] }
# Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback, src/compositor_gpu.rs),
-# so Automatic draws on that one. Both are already in the build through winit.
+# so Automatic draws on that one, and copies and pastes text, SVG and PNG through the compositor's
+# core data-device protocol on the window's connection (src/clipboard.rs), where arboard has neither
+# data-control (GNOME) nor X11 (a Flatpak on a Wayland session). Both are already in the build
+# through winit.
wayland-client = "0.31"
-# Copy and Paste of text through the compositor's core data-device protocol on the window's
-# connection (src/clipboard.rs), where arboard has neither data-control (GNOME) nor X11 (a Flatpak
-# on a Wayland session). MIT, already in the build through egui-winit.
-smithay-clipboard = "0.7"
wayland-protocols = { version = "0.32", features = ["client", "unstable"] }
# The save dialog's parent window on Wayland (src/portal_save.rs): an xdg-foreign handle (in
# 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 0391b7534b..4827772444 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..77a96442c4 100644
--- a/apps/vectorcraft/src/clipboard.rs
+++ b/apps/vectorcraft/src/clipboard.rs
@@ -10,8 +10,9 @@
//! Linux arboard reads no other formats). On Wayland, arboard's data-control
//! backend reads the compositor's clipboard (X11 through XWayland only sees what X11 apps
//! copied), falling back to X11 where the compositor lacks the protocol, and where there is no
-//! X11 either (a Flatpak on GNOME) to the compositor's core protocol on the window's connection,
-//! text only. A clipboard that can't be opened is tried again after a wait that grows to a
+//! X11 either (a Flatpak on GNOME) to the compositor's core protocol on the window's connection:
+//! Copy offers SVG (`image/svg+xml`), PNG (`image/png`) and text together, and Paste reads SVG,
+//! text and PNG. A clipboard that can't be opened is tried again after a wait that grows to a
//! minute, not each time Paste asks. On Linux and the BSDs a read waits 2 s at most for the app
//! that copied to send its data (a bitmap 10 s), and until that app answers, later reads fail at
//! once; a paste then waits for that read's answer off the UI thread.
@@ -24,8 +25,8 @@ use std::path::PathBuf;
use vectorcraft_engine::cmd::clipboard::{BITMAP, EPS, FILE_HEAD, Flavour, PDF, PNG, SVG, TEXT, file_flavour};
use vectorcraft_ui_egui::{ClipboardRead, SystemClipboard};
-/// This platform's system clipboard. On Linux, `window`'s Wayland connection carries text where
-/// arboard can't open (GNOME has no data-control protocol, a Flatpak there no X11).
+/// This platform's system clipboard. On Linux, `window`'s Wayland connection carries text, SVG and
+/// PNG where arboard can't open (GNOME has no data-control protocol, a Flatpak there no X11).
pub fn system_clipboard(window: Option>) -> Box {
#[cfg(windows)]
let cb = {
@@ -629,6 +630,10 @@ mod portable {
enum Ours {
Text(String),
Image(usize, usize),
+ /// A Copy through the Wayland core protocol, whose data source tells whether the
+ /// clipboard still holds it.
+ #[cfg(target_os = "linux")]
+ Source,
}
/// A read of arboard's clipboard.
@@ -858,18 +863,14 @@ mod portable {
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
impl Late {
/// What a paste of one of `mimes` takes once the answer comes ([`Unanswered::paste`]). The
- /// core protocol's text is waited for as long, until [`IMAGE_WAIT`] after its read was
- /// sent, then read again, and pasted unless it is empty, what `ours` put on the clipboard,
- /// or doesn't come.
+ /// core protocol's answer is waited for as long, until [`IMAGE_WAIT`] after its read was
+ /// sent; then the clipboard is read again and pasted as at once, unless it holds the Copy
+ /// made here ([`super::wayland::Unanswered::paste`]).
fn paste(self, ours: Option<&Ours>, mimes: &[&'static str]) -> Option {
match self {
Self::Arboard(unanswered) => unanswered.paste(ours, mimes),
#[cfg(target_os = "linux")]
- Self::Wayland(unanswered) => {
- let text = unanswered.text(IMAGE_WAIT).ok().filter(|t| !t.is_empty())?;
- let held = matches!(ours, Some(Ours::Text(t)) if *t == text);
- (!held).then(|| Flavour { mime: TEXT, data: text.into_bytes() })
- }
+ Self::Wayland(unanswered) => unanswered.paste(mimes, IMAGE_WAIT),
}
}
}
@@ -962,14 +963,15 @@ mod portable {
/// An open clipboard connection.
enum Backend {
Arboard(Arboard),
- /// The compositor's core data-device protocol on the window's connection (text only).
+ /// The compositor's core data-device protocol on the window's connection.
#[cfg(target_os = "linux")]
- Wayland(super::wayland::TextClipboard),
+ Wayland(super::wayland::DataClipboard),
}
- /// What a write of a picture says where the clipboard carries text only.
+ /// What a bitmap read or write says on the core protocol, which carries pictures as PNG data
+ /// only ([`Clipboard::write`], [`Clipboard::read_now_or_later`]).
#[cfg(target_os = "linux")]
- const TEXT_ONLY: &str = "this clipboard carries text only";
+ const PNG_ONLY: &str = "this clipboard carries pictures as PNG data";
impl Backend {
/// arboard (Wayland data-control, else X11), else the core protocol on `window`'s
@@ -979,7 +981,10 @@ mod portable {
|| Arboard::open().map(Self::Arboard),
|| {
#[cfg(target_os = "linux")]
- return window.and_then(|w| super::wayland::TextClipboard::new(w.clone())).map(Self::Wayland).ok_or("not a Wayland window");
+ return window
+ .and_then(|w| super::wayland::DataClipboard::new(w.clone()))
+ .map(Self::Wayland)
+ .ok_or("not a Wayland window with a seat and a data-device manager");
#[cfg(not(target_os = "linux"))]
{
let _ = window;
@@ -993,7 +998,7 @@ mod portable {
match self {
Self::Arboard(_) => "arboard",
#[cfg(target_os = "linux")]
- Self::Wayland(_) => "the Wayland core protocol (text only)",
+ Self::Wayland(_) => "the Wayland core protocol",
}
}
@@ -1001,7 +1006,7 @@ mod portable {
match self {
Self::Arboard(cb) => cb.read(Request::Text).text(),
#[cfg(target_os = "linux")]
- Self::Wayland(cb) => cb.get_text(),
+ Self::Wayland(cb) => cb.text(),
}
}
@@ -1009,10 +1014,17 @@ mod portable {
match self {
Self::Arboard(cb) => cb.cb.set_text(text).map_err(|e| e.to_string()),
#[cfg(target_os = "linux")]
- Self::Wayland(cb) => {
- cb.set_text(text);
- Ok(())
- }
+ Self::Wayland(cb) => cb.copy(&[Flavour { mime: TEXT, data: text.as_bytes().to_vec() }]),
+ }
+ }
+
+ /// Whether the clipboard holds the core protocol's last Copy made here (never for arboard,
+ /// whose writes are checked by what they wrote).
+ #[cfg(target_os = "linux")]
+ fn holds_source(&mut self) -> bool {
+ match self {
+ Self::Arboard(_) => false,
+ Self::Wayland(cb) => cb.holds_ours(),
}
}
@@ -1025,7 +1037,7 @@ mod portable {
#[cfg(not(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd")))]
Self::Arboard(cb) => cb.read(Request::Image).image(),
#[cfg(target_os = "linux")]
- Self::Wayland(_) => Err(TEXT_ONLY.into()),
+ Self::Wayland(_) => Err(PNG_ONLY.into()),
}
}
@@ -1041,7 +1053,7 @@ mod portable {
Ok(Box::new(move || Ok(image)))
}
#[cfg(target_os = "linux")]
- Self::Wayland(_) => Err(TEXT_ONLY.into()),
+ Self::Wayland(_) => Err(PNG_ONLY.into()),
}
}
@@ -1049,7 +1061,7 @@ mod portable {
match self {
Self::Arboard(cb) => cb.cb.set_image(img).map_err(|e| e.to_string()),
#[cfg(target_os = "linux")]
- Self::Wayland(_) => Err(TEXT_ONLY.into()),
+ Self::Wayland(_) => Err(PNG_ONLY.into()),
}
}
@@ -1057,15 +1069,12 @@ mod portable {
match self {
Self::Arboard(cb) => cb.cb.clear().map_err(|e| e.to_string()),
#[cfg(target_os = "linux")]
- Self::Wayland(cb) => {
- cb.set_text("");
- Ok(())
- }
+ Self::Wayland(cb) => cb.copy(&[]),
}
}
/// The read that got no answer in its wait, when a paste of one of `mimes` can take its
- /// answer ([`Arboard::unanswered`], [`TextReads::unanswered`](super::wayland::TextReads::unanswered)).
+ /// answer ([`Arboard::unanswered`], [`Reads::unanswered`](super::wayland::Reads::unanswered)).
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
fn unanswered(&mut self, mimes: &[&'static str]) -> Option {
match self {
@@ -1223,9 +1232,15 @@ mod portable {
Some(ClipboardRead::Later(Box::new(move || unanswered.paste(ours.as_ref(), &mimes))))
}
- /// Text and copied files are read here, and work that reads arboard's image comes back.
+ /// Text and copied files are read here, and work that reads arboard's image comes back. On
+ /// the Wayland core protocol the first of `mimes` the clipboard offers is read: text here,
+ /// SVG and PNG by the work that comes back, which waits [`IMAGE_WAIT`] at most.
fn read_now_or_later(&mut self, mimes: &[&'static str]) -> Option {
let cb = self.cb(Asker::User).ok()?;
+ #[cfg(target_os = "linux")]
+ if let Backend::Wayland(cb) = cb {
+ return cb.read(mimes, IMAGE_WAIT);
+ }
if let Some(f) = copied_file(cb.file_list(), mimes) {
return Some(ClipboardRead::Now(f));
}
@@ -1247,13 +1262,23 @@ mod portable {
}
impl SystemClipboard for Clipboard {
+ /// arboard takes the text, else the PNG as a bitmap; the Wayland core protocol offers the
+ /// SVG, PNG and text together ([`DataClipboard::copy`](super::wayland::DataClipboard::copy)).
fn write(&mut self, flavours: &[Flavour]) -> Result<(), String> {
+ let cb = self.cb(Asker::User)?;
+ #[cfg(target_os = "linux")]
+ if let Backend::Wayland(wl) = cb {
+ wl.copy(flavours)?;
+ // What a read got late was on the clipboard before this write.
+ wl.forget_unanswered();
+ self.ours = wl.holds_ours().then_some(Ours::Source);
+ return Ok(());
+ }
let text = text_of(flavours);
let image = match &text {
Some(_) => None,
None => flavours.iter().find(|f| f.mime == PNG).and_then(|f| decode_png(&f.data)),
};
- let cb = self.cb(Asker::User)?;
let ours = match (text, image) {
(Some(t), _) => {
cb.set_text(t.as_str())?;
@@ -1281,6 +1306,8 @@ mod portable {
match &self.ours {
Some(Ours::Text(t)) => cb.get_text().is_ok_and(|now| now == *t),
Some(Ours::Image(w, h)) => cb.image_now().is_ok_and(|now| (now.width, now.height) == (*w, *h)),
+ #[cfg(target_os = "linux")]
+ Some(Ours::Source) => cb.holds_source(),
None => false,
}
}
@@ -1307,7 +1334,8 @@ mod portable {
read
}
- /// Text, and on macOS vector art or a bitmap (by the pasteboard's types). Linux has no
+ /// Text, on macOS vector art or a bitmap (by the pasteboard's types), and on the Wayland
+ /// core protocol SVG, text or PNG (by the types offered, nothing read). arboard has no
/// cheap way to ask for a bitmap (reading one costs as much as pasting it): the paste keys
/// take it, the menu item waits for text.
fn has(&mut self, mimes: &[&'static str]) -> bool {
@@ -1315,7 +1343,12 @@ mod portable {
if super::mac::has(mimes) {
return true;
}
- mimes.contains(&TEXT) && self.cb(Asker::Probe).is_ok_and(|cb| cb.get_text().is_ok_and(|t| !t.is_empty()))
+ let Ok(cb) = self.cb(Asker::Probe) else { return false };
+ #[cfg(target_os = "linux")]
+ if let Backend::Wayland(wl) = cb {
+ return wl.has(mimes);
+ }
+ mimes.contains(&TEXT) && cb.get_text().is_ok_and(|t| !t.is_empty())
}
}
@@ -1504,6 +1537,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() {
@@ -1646,45 +1689,58 @@ mod portable {
#[cfg(target_os = "linux")]
#[test]
fn a_paste_after_an_unanswered_core_protocol_text_read_waits_for_its_answer() {
- use super::super::wayland::TextReads;
+ use std::sync::{Arc, Mutex};
+
+ use super::super::wayland::{Reads, Seen};
use vectorcraft_engine::cmd::clipboard::PASTE_ORDER;
- /// Core protocol text reads whose owner answers with `text` only once the sender
+ /// Core protocol reads of a clipboard offering `types`, whose owner answers each read
+ /// of a text type with `text` and of `image/png` with `png`, only once the sender
/// returned is used or dropped.
- fn slow_text(text: &'static str) -> (TextReads, std::sync::mpsc::Sender<()>) {
+ fn slow_owner(types: &[&str], text: &'static str, png: &'static [u8]) -> (Reads, Arc>, std::sync::mpsc::Sender<()>) {
let (release, gate) = std::sync::mpsc::channel::<()>();
- let loads = Loads::spawn("test", Duration::ZERO, move |()| {
+ let loads = Loads::spawn("test", Duration::ZERO, move |wire: String| {
let _ = gate.recv();
- Ok(text.to_string())
+ Ok(if wire == PNG { png.to_vec() } else { text.as_bytes().to_vec() })
})
.unwrap();
- (TextReads::new(loads), release)
+ let seen = Arc::new(Mutex::new(Seen::offering(types)));
+ (Reads::new(loads, seen.clone()), seen, release)
}
- // Text was copied here; the clipboard's owner answers the check with `text` late.
- let paste_after_check = |text: &'static str| {
- let (mut reads, release) = slow_text(text);
+ // The clipboard's owner answers the check with `text` late; `copied_here` when the
+ // clipboard holds the Copy made here meanwhile.
+ let paste_after_check = |text: &'static str, copied_here: bool| {
+ let (mut reads, seen, release) = slow_owner(&["text/plain;charset=utf-8", PNG], text, b"\x89PNG");
// The check whether the clipboard still holds what was copied here gets no answer
// in its wait (2 s on the UI thread, none here).
- assert!(reads.get(Duration::ZERO).is_err());
+ assert!(reads.text(Duration::ZERO).is_err());
// The paste's own read fails at once while the owner hasn't answered.
- assert!(reads.get(Duration::from_secs(600)).is_err());
+ assert!(reads.text(Duration::from_secs(600)).is_err());
// A paste of a bitmap alone can't take text: the read stays kept.
assert!(reads.unanswered(&[BITMAP]).is_none());
let late = Late::Wayland(reads.unanswered(&PASTE_ORDER).expect("the check's read is kept for the paste"));
- let ours = Ours::Text("copied here".into());
- let paste = std::thread::spawn(move || late.paste(Some(&ours), &PASTE_ORDER));
+ if copied_here {
+ let mut seen = seen.lock().unwrap();
+ seen.copy(vec!["text/plain;charset=utf-8", PNG]);
+ seen.selection(vec!["text/plain;charset=utf-8".into(), PNG.into()]);
+ }
+ let paste = std::thread::spawn(move || late.paste(None, &PASTE_ORDER));
drop(release);
paste.join().ok().flatten()
};
- let pasted = paste_after_check("copied elsewhere").expect("the other app's text is pasted");
+ let pasted = paste_after_check("copied elsewhere", false).expect("the other app's text is pasted");
assert_eq!((pasted.mime, pasted.data), (TEXT, b"copied elsewhere".to_vec()));
- // The text copied here, or nothing: the paste takes the internal clipboard.
- assert!(paste_after_check("copied here").is_none());
- assert!(paste_after_check("").is_none());
+ // Text that only names a picture goes with it: the picture is pasted, as at once.
+ let pasted = paste_after_check("https://example.com/art.png", false).expect("the picture is pasted");
+ assert_eq!((pasted.mime, pasted.data), (PNG, b"\x89PNG".to_vec()));
+ // The Copy made here, by its data source: the paste takes the internal clipboard.
+ assert!(paste_after_check("copied elsewhere", true).is_none());
+ // Empty text is not pasted: the picture beside it is.
+ assert_eq!(paste_after_check("", false).map(|f| f.mime), Some(PNG));
// An answer that came before the paste may be old: it is dropped, not pasted.
- let (mut reads, release) = slow_text("old");
- assert!(reads.get(Duration::ZERO).is_err());
+ let (mut reads, _, release) = slow_owner(&["text/plain"], "old", b"");
+ assert!(reads.text(Duration::ZERO).is_err());
drop(release);
while reads.waiting() {
std::thread::yield_now();
@@ -1695,14 +1751,14 @@ mod portable {
// again and takes the new owner's text.
let (release, gate) = std::sync::mpsc::channel::<()>();
let mut texts_read = 0;
- let loads = Loads::spawn("test", Duration::ZERO, move |()| {
+ let loads = Loads::spawn("test", Duration::ZERO, move |_: String| {
let _ = gate.recv();
texts_read += 1;
- Ok(if texts_read == 1 { "old owner" } else { "new owner" }.to_string())
+ Ok(if texts_read == 1 { "old owner" } else { "new owner" }.as_bytes().to_vec())
})
.unwrap();
- let mut reads = TextReads::new(loads);
- assert!(reads.get(Duration::ZERO).is_err());
+ let mut reads = Reads::new(loads, Arc::new(Mutex::new(Seen::offering(&["UTF8_STRING"]))));
+ assert!(reads.text(Duration::ZERO).is_err());
let late = Late::Wayland(reads.unanswered(&[TEXT]).expect("the read is kept for the paste"));
let paste = std::thread::spawn(move || late.paste(None, &[TEXT]));
drop(release);
@@ -1727,81 +1783,311 @@ mod portable {
/// The compositor's clipboard through the core data-device protocol (`wl_data_device`), on the
/// window's own Wayland connection: what Copy and Paste use where the compositor lacks the
/// data-control protocol arboard needs (GNOME) and there is no X11 to fall back on (the Flatpak on
-/// a Wayland session). Text only (smithay-clipboard), and only while the window has focus, as the
-/// protocol has it. A read waits [`LOAD_WAIT`] at most: smithay-clipboard waits for the owner's
-/// data with no limit, so reads run on a worker of their own. A read that gets no answer in that
-/// time is kept for a paste to wait for off the UI thread, as arboard's are.
+/// a Wayland session). A Copy offers its SVG as `image/svg+xml`, its PNG as `image/png` and its
+/// text under each of [`TEXT_TYPES`]; Paste reads the clipboard's SVG, text and PNG, and the paste
+/// check looks at the types offered without reading any data. The protocol gives the clipboard to
+/// the window with the keyboard focus only. A worker thread handles the protocol's events and
+/// starts a thread for each paste of a Copy's data by another app ([`MAX_SENDS`] at once). Reads
+/// run on another worker and take [`MAX_BYTES`] at most; text waits [`LOAD_WAIT`] at most, SVG and
+/// PNG as long as a paste's bitmap from arboard. A read that gets no answer in its wait is kept for
+/// a paste to wait for off the UI thread, as arboard's are.
#[cfg(target_os = "linux")]
mod wayland {
- use std::sync::{Arc, mpsc};
+ use std::io::{Read, Write};
+ use std::os::fd::AsFd as _;
+ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
+ use std::sync::{Arc, Mutex, MutexGuard, PoisonError, mpsc};
use std::time::{Duration, Instant};
- use super::TEXT;
+ use vectorcraft_engine::cmd::clipboard::names_a_picture;
+ use vectorcraft_ui_egui::ClipboardRead;
+ use wayland_client::backend::ObjectData;
+ use wayland_client::globals::{GlobalListContents, registry_queue_init};
+ use wayland_client::protocol::wl_callback::{self, WlCallback};
+ use wayland_client::protocol::wl_data_device::{self, WlDataDevice};
+ use wayland_client::protocol::wl_data_device_manager::{self, WlDataDeviceManager};
+ use wayland_client::protocol::wl_data_offer::{self, WlDataOffer};
+ use wayland_client::protocol::wl_data_source::{self, WlDataSource};
+ use wayland_client::protocol::wl_keyboard::{self, WlKeyboard};
+ use wayland_client::protocol::wl_pointer::{self, WlPointer};
+ use wayland_client::protocol::wl_registry::{self, WlRegistry};
+ use wayland_client::protocol::wl_seat::{self, WlSeat};
+ use wayland_client::{Connection, Dispatch, Proxy, QueueHandle, WEnum};
+
use super::loads::{Asker, GONE, LOAD_WAIT, Loads, wait_for_answer};
+ use super::{BITMAP, Flavour, PNG, SVG, TEXT};
- /// What a text read gives.
- type Text = Result;
+ /// The types text is offered under and read from, best first: `text/plain;charset=utf-8` (GTK
+ /// and Qt apps), `UTF8_STRING` (X11 apps through XWayland) and `text/plain`.
+ const TEXT_TYPES: [&str; 3] = ["text/plain;charset=utf-8", "UTF8_STRING", "text/plain"];
+ /// The most bytes a read takes from the app that copied (untrusted data), VectorCraft's own
+ /// limit: a read of more fails.
+ const MAX_BYTES: usize = 64 << 20;
+ /// The most pastes of a Copy's data that are sent at once; a paste beyond them gets nothing.
+ const MAX_SENDS: usize = 8;
+ /// The most types of an offer that are kept (untrusted data).
+ const MAX_TYPES: usize = 256;
- pub struct TextClipboard {
- /// Writes: a store only hands the text to smithay-clipboard's thread.
- cb: smithay_clipboard::Clipboard,
- /// Reads, on a worker with a second clipboard on the same connection.
- reads: TextReads,
- /// Held as long as the clipboards: their connection is the window's.
- _window: Arc,
+ /// What a read gives: the bytes, or why there are none.
+ type Got = Result, String>;
+
+ fn lock(m: &Mutex) -> MutexGuard<'_, T> {
+ m.lock().unwrap_or_else(PoisonError::into_inner)
}
- /// Text reads on a worker, and the last one that got no answer in its wait.
- pub(super) struct TextReads {
- loads: Loads<(), Text>,
- /// The last read that got no answer in its wait, until a paste takes it or the next read
- /// is sent.
+ /// The types a Copy of `flavours` offers, each with its data: SVG as `image/svg+xml`, PNG as
+ /// `image/png` and text under each of [`TEXT_TYPES`]. Flavours with no data and other flavours
+ /// (PDF) are not offered.
+ fn copy_types(flavours: &[Flavour]) -> Vec<(&'static str, Arc<[u8]>)> {
+ let mut out: Vec<(&'static str, Arc<[u8]>)> = vec![];
+ for f in flavours.iter().filter(|f| !f.data.is_empty()) {
+ let types: &[&'static str] = match f.mime {
+ SVG => &[SVG],
+ PNG => &[PNG],
+ TEXT => &TEXT_TYPES,
+ _ => &[],
+ };
+ let data: Arc<[u8]> = f.data.as_slice().into();
+ for &t in types {
+ if !out.iter().any(|(o, _)| *o == t) {
+ out.push((t, data.clone()));
+ }
+ }
+ }
+ out
+ }
+
+ /// The type among `offered` that a read of `mime` asks for, in any letter case: SVG as
+ /// `image/svg+xml`, text as the first of [`TEXT_TYPES`] offered, a bitmap as `image/png`.
+ fn wire_type<'a>(mime: &str, offered: &'a [String]) -> Option<&'a str> {
+ let wanted: &[&str] = match mime {
+ SVG => &[SVG],
+ TEXT => &TEXT_TYPES,
+ BITMAP | PNG => &[PNG],
+ _ => &[],
+ };
+ wanted.iter().find_map(|w| offered.iter().find(|o| o.eq_ignore_ascii_case(w))).map(String::as_str)
+ }
+
+ /// The flavour a read of `mime` makes of `data`: SVG as it is, a bitmap as PNG, text with its
+ /// line ends as `\n`. None when `data` is empty.
+ fn flavour(mime: &'static str, data: Vec) -> Option {
+ if data.is_empty() {
+ return None;
+ }
+ Some(match mime {
+ TEXT => Flavour { mime: TEXT, data: String::from_utf8_lossy(&data).replace("\r\n", "\n").replace('\r', "\n").into_bytes() },
+ BITMAP | PNG => Flavour { mime: PNG, data },
+ _ => Flavour { mime, data },
+ })
+ }
+
+ /// What a paste of one of `mimes` takes from a clipboard offering `offered`, `read` reading the
+ /// offer's type for each one tried: the first of `mimes` offered with data, except that text
+ /// that is blank or only names a picture gives way to the PNG when `mimes` has a bitmap
+ /// (`adopt_system_clipboard_or_defer` in `vectorcraft-ui-egui`).
+ fn pick(offered: &[String], mimes: &[&'static str], mut read: impl FnMut(&str) -> Option>) -> Option {
+ let mut take = |mime: &'static str| flavour(mime, read(wire_type(mime, offered)?)?);
+ let first = mimes.iter().find_map(|&m| take(m))?;
+ if first.mime == TEXT && mimes.contains(&BITMAP) {
+ let text = String::from_utf8_lossy(&first.data);
+ if text.trim().is_empty() || names_a_picture(&text) {
+ return take(BITMAP).or(Some(first));
+ }
+ }
+ Some(first)
+ }
+
+ /// `from` read to its end, `cap` bytes at most: more is an error, as is a failed read.
+ fn read_capped(from: impl Read, cap: usize) -> Got {
+ let mut data = vec![];
+ from.take(cap as u64 + 1).read_to_end(&mut data).map_err(|e| format!("couldn't read the clipboard's data: {e}"))?;
+ if data.len() > cap {
+ return Err(format!("the clipboard's data is larger than {} MiB", cap >> 20));
+ }
+ Ok(data)
+ }
+
+ /// The threads that send a Copy's data to the apps that paste it, counted.
+ #[derive(Clone, Default)]
+ struct Sends(Arc);
+
+ impl Sends {
+ /// `data` written to `to` on a thread of its own, which then closes `to`; an app that
+ /// closes its end early ends the write (the process ignores `SIGPIPE`). While
+ /// [`MAX_SENDS`] are being written `to` is closed at once with nothing written → false.
+ fn start(&self, mut to: impl Write + Send + 'static, data: Arc<[u8]>) -> bool {
+ if self.0.fetch_add(1, Ordering::SeqCst) >= MAX_SENDS {
+ self.0.fetch_sub(1, Ordering::SeqCst);
+ return false;
+ }
+ let count = self.0.clone();
+ let send = move || {
+ let _ = to.write_all(&data);
+ drop(to);
+ count.fetch_sub(1, Ordering::SeqCst);
+ };
+ if std::thread::Builder::new().name("wayland-clipboard-send".into()).spawn(send).is_err() {
+ self.0.fetch_sub(1, Ordering::SeqCst);
+ return false;
+ }
+ true
+ }
+
+ /// How many are being written.
+ #[cfg(test)]
+ fn running(&self) -> usize {
+ self.0.load(Ordering::SeqCst)
+ }
+ }
+
+ /// What the protocol worker has seen of the clipboard.
+ #[derive(Default)]
+ pub(super) struct Seen {
+ /// The types of the clipboard's offer, none when it is empty.
+ offered: Vec,
+ /// The types of this connection's last Copy, until its data source is canceled.
+ copied: Option>,
+ /// Whether the clipboard holds that Copy.
+ ours: bool,
+ }
+
+ impl Seen {
+ /// A clipboard offering `types`, copied elsewhere.
+ #[cfg(test)]
+ pub(super) fn offering(types: &[&str]) -> Self {
+ Self { offered: types.iter().map(|t| t.to_string()).collect(), ..Self::default() }
+ }
+
+ /// A Copy of `types` (none: the clipboard emptied) was given the clipboard.
+ pub(super) fn copy(&mut self, types: Vec<&'static str>) {
+ self.ours = !types.is_empty();
+ self.copied = Some(types).filter(|t| !t.is_empty());
+ }
+
+ /// The clipboard's offer has `offered` now. It holds the last Copy while that Copy's data
+ /// source is not canceled and the offer has the Copy's types, no more and no fewer, as the
+ /// compositor offers a source's types. An offer made before the Copy and handled after it
+ /// makes the Copy not ours until the Copy's own offer comes.
+ pub(super) fn selection(&mut self, offered: Vec) {
+ self.ours = self.copied.as_ref().is_some_and(|c| c.len() == offered.len() && c.iter().all(|t| offered.iter().any(|o| o == t)));
+ self.offered = offered;
+ }
+
+ /// The last Copy's data source was canceled: another app holds the clipboard.
+ pub(super) fn cancelled(&mut self) {
+ self.copied = None;
+ self.ours = false;
+ }
+ }
+
+ /// Reads of the clipboard's offer on a worker, and the last one that got no answer in its wait.
+ pub(super) struct Reads {
+ /// Each request is the offer's type to read.
+ loads: Loads,
+ seen: Arc>,
+ /// The last text read that got no answer in its wait, until a paste takes it or the next
+ /// read is sent.
unanswered: Option,
}
/// A text read that got no answer in its wait.
pub(super) struct Unanswered {
- answer: mpsc::Receiver,
+ answer: mpsc::Receiver,
sent: Instant,
- /// The worker, for the read made again once the answer comes.
- reads: Asker<(), Text>,
+ /// The worker, for the reads made once the answer comes.
+ reads: Asker,
+ seen: Arc>,
}
impl Unanswered {
- /// The text, read again once this read's answer comes, waiting for that answer until
- /// `limit` after the read was sent, and for the new one as long, [`LOAD_WAIT`] at least.
- /// The late answer may come from an app that lost the clipboard while it was awaited.
- pub(super) fn text(self, limit: Duration) -> Text {
+ /// What a paste of one of `mimes` takes once this read's answer comes, waiting for it until
+ /// `limit` after the read was sent: the clipboard read again as [`pick`] reads it, each read
+ /// waiting as long, [`LOAD_WAIT`] at least, since the app that answers late may have lost the
+ /// clipboard meanwhile. None when the answer doesn't come or the clipboard holds this
+ /// connection's Copy.
+ pub(super) fn paste(self, mimes: &[&'static str], limit: Duration) -> Option {
let left = |now: Instant| (self.sent + limit).saturating_duration_since(now);
- let _late = wait_for_answer(&self.answer, left(Instant::now()))?;
- wait_for_answer(&self.reads.ask(())?, LOAD_WAIT.max(left(Instant::now())))?
+ let _late = wait_for_answer(&self.answer, left(Instant::now())).ok()?;
+ let offered = {
+ let seen = lock(&self.seen);
+ if seen.ours {
+ return None;
+ }
+ seen.offered.clone()
+ };
+ pick(&offered, mimes, |wire| wait_for_answer(&self.reads.ask(wire.to_string()).ok()?, LOAD_WAIT.max(left(Instant::now()))).ok()?.ok())
}
}
- impl TextReads {
- pub(super) fn new(loads: Loads<(), Text>) -> Self {
- Self { loads, unanswered: None }
+ impl Reads {
+ pub(super) fn new(loads: Loads, seen: Arc>) -> Self {
+ Self { loads, seen, unanswered: None }
}
- /// The text, waiting `wait` at most. A read that gets no answer in that time is kept
- /// ([`Self::unanswered`]) for a paste to wait for off the UI thread; a read sent means the
- /// one kept has been answered, and its answer is dropped.
- pub(super) fn get(&mut self, wait: Duration) -> Text {
+ /// The offer's type for `mime`, when it has one.
+ fn wire(&self, mime: &str) -> Option {
+ wire_type(mime, &lock(&self.seen).offered).map(str::to_string)
+ }
+
+ /// The text, waiting `wait` at most; empty when the clipboard has none. A read that gets no
+ /// answer in that time is kept ([`Self::unanswered`]) for a paste to wait for off the UI
+ /// thread; a read sent means the one kept has been answered, and its answer is dropped.
+ pub(super) fn text(&mut self, wait: Duration) -> Result {
+ let Some(wire) = self.wire(TEXT) else { return Ok(String::new()) };
let sent = Instant::now();
- let answer = self.loads.ask(())?;
+ let answer = self.loads.ask(wire)?;
self.unanswered = None;
match wait_for_answer(&answer, wait) {
- Ok(text) => text,
+ Ok(got) => Ok(flavour(TEXT, got?).map(|f| String::from_utf8_lossy(&f.data).into_owned()).unwrap_or_default()),
Err(why) => {
if why != GONE {
- self.unanswered = Some(Unanswered { answer, sent, reads: self.loads.asker() });
+ self.unanswered = Some(Unanswered { answer, sent, reads: self.loads.asker(), seen: self.seen.clone() });
}
Err(why)
}
}
}
- /// The read that got no answer in its wait, while its answer has still not come and a
+ /// What a paste of one of `mimes` reads: the first the clipboard offers. Text is read here,
+ /// [`LOAD_WAIT`] at most, and pasted unless it is empty; SVG and PNG are asked for here and
+ /// waited for, `wait` at most, by the work that comes back. When that read fails or is
+ /// empty, the work reads the rest of `mimes` as [`pick`] does, each read waiting until
+ /// `wait` after the first was sent, [`LOAD_WAIT`] at least.
+ pub(super) fn read(&mut self, mimes: &[&'static str], wait: Duration) -> Option {
+ mimes.iter().enumerate().find_map(|(i, &mime)| {
+ let wire = self.wire(mime)?;
+ if mime == TEXT {
+ let text = self.text(LOAD_WAIT).ok().filter(|t| !t.is_empty())?;
+ return Some(ClipboardRead::Now(Flavour { mime: TEXT, data: text.into_bytes() }));
+ }
+ let sent = Instant::now();
+ let answer = self.loads.ask(wire).ok()?;
+ self.unanswered = None;
+ let (reads, seen, rest) = (self.loads.asker(), self.seen.clone(), mimes.get(i + 1..).unwrap_or_default().to_vec());
+ Some(ClipboardRead::Later(Box::new(move || {
+ if let Some(f) = wait_for_answer(&answer, wait).ok().and_then(Result::ok).and_then(|data| flavour(mime, data)) {
+ return Some(f);
+ }
+ let offered = lock(&seen).offered.clone();
+ let left = || LOAD_WAIT.max((sent + wait).saturating_duration_since(Instant::now()));
+ pick(&offered, &rest, |wire| wait_for_answer(&reads.ask(wire.to_string()).ok()?, left()).ok()?.ok())
+ })))
+ })
+ }
+
+ /// Whether the clipboard offers one of `mimes`; nothing is read.
+ pub(super) fn has(&self, mimes: &[&'static str]) -> bool {
+ let seen = lock(&self.seen);
+ mimes.iter().any(|m| wire_type(m, &seen.offered).is_some())
+ }
+
+ /// Whether the clipboard holds this connection's last Copy.
+ pub(super) fn holds_ours(&self) -> bool {
+ lock(&self.seen).ours
+ }
+
+ /// The text read that got no answer in its wait, while its answer has still not come and a
/// paste of one of `mimes` can take text. Once it has come the answer may be old, and it is
/// dropped: a new read is made.
pub(super) fn unanswered(&mut self, mimes: &[&'static str]) -> Option {
@@ -1809,6 +2095,11 @@ mod wayland {
self.loads.waiting().then_some(unanswered)
}
+ /// Drops the read that got no answer in its wait: a paste no longer takes its answer.
+ pub(super) fn forget_unanswered(&mut self) {
+ self.unanswered = None;
+ }
+
/// Whether a read sent is still unanswered.
#[cfg(test)]
pub(super) fn waiting(&self) -> bool {
@@ -1816,55 +2107,591 @@ mod wayland {
}
}
- /// The read worker's clipboard and the window whose connection it uses, dropped in that order.
- struct Loader {
- cb: smithay_clipboard::Clipboard,
+ /// The protocol objects of a [`DataClipboard`], kept up to date by its worker.
+ #[derive(Default)]
+ struct Objects {
+ device: Option,
+ keyboard: Option,
+ pointer: Option,
+ /// The serial of the last key press, click or keyboard focus, which a Copy takes the
+ /// clipboard with.
+ serial: Option,
+ /// The clipboard's offer.
+ selection: Option,
+ /// The offer of a drag over the window.
+ drag: Option,
+ /// The last Copy's data source.
+ source: Option,
+ }
+
+ /// An offer's types, as its events give them.
+ #[derive(Default)]
+ struct OfferData(Mutex>);
+
+ /// A Copy's types with their data.
+ struct SourceData(Vec<(&'static str, Arc<[u8]>)>);
+
+ /// What the protocol worker's event handlers change.
+ struct State {
+ objects: Arc>,
+ seen: Arc>,
+ sends: Sends,
+ }
+
+ impl State {
+ /// Lets go of every object, once the clipboard is dropped.
+ fn close(&mut self, seat: &WlSeat) {
+ let mut o = lock(&self.objects);
+ for offer in [o.selection.take(), o.drag.take()].into_iter().flatten() {
+ offer.destroy();
+ }
+ if let Some(source) = o.source.take() {
+ source.destroy();
+ }
+ if let Some(device) = o.device.take().filter(|d| d.version() >= 2) {
+ device.release();
+ }
+ if let Some(keyboard) = o.keyboard.take().filter(|k| k.version() >= 3) {
+ keyboard.release();
+ }
+ if let Some(pointer) = o.pointer.take().filter(|p| p.version() >= 3) {
+ pointer.release();
+ }
+ if seat.version() >= 5 {
+ seat.release();
+ }
+ }
+ }
+
+ impl Dispatch for State {
+ fn event(_: &mut Self, _: &WlRegistry, _: wl_registry::Event, _: &GlobalListContents, _: &Connection, _: &QueueHandle) {}
+ }
+
+ impl Dispatch for State {
+ fn event(_: &mut Self, _: &WlCallback, _: wl_callback::Event, _: &(), _: &Connection, _: &QueueHandle) {}
+ }
+
+ impl Dispatch for State {
+ fn event(_: &mut Self, _: &WlDataDeviceManager, _: wl_data_device_manager::Event, _: &(), _: &Connection, _: &QueueHandle) {}
+ }
+
+ impl Dispatch for State {
+ fn event(state: &mut Self, seat: &WlSeat, event: wl_seat::Event, _: &(), _: &Connection, qh: &QueueHandle) {
+ let wl_seat::Event::Capabilities { capabilities: WEnum::Value(caps) } = event else { return };
+ let mut o = lock(&state.objects);
+ if !caps.contains(wl_seat::Capability::Keyboard) {
+ if let Some(keyboard) = o.keyboard.take().filter(|k| k.version() >= 3) {
+ keyboard.release();
+ }
+ } else if o.keyboard.is_none() {
+ o.keyboard = Some(seat.get_keyboard(qh, ()));
+ }
+ if !caps.contains(wl_seat::Capability::Pointer) {
+ if let Some(pointer) = o.pointer.take().filter(|p| p.version() >= 3) {
+ pointer.release();
+ }
+ } else if o.pointer.is_none() {
+ o.pointer = Some(seat.get_pointer(qh, ()));
+ }
+ }
+ }
+
+ /// The serial a Copy takes the clipboard with after the keyboard event `event`, `serial` before
+ /// it: a key or the keyboard focus coming in sets it, the focus leaving clears it, and other
+ /// events leave it. A modifier change's serial is not an input event's, and the release of Ctrl
+ /// after Ctrl+C comes before the Copy's data is ready.
+ fn keyboard_serial(event: &wl_keyboard::Event, serial: Option) -> Option {
+ match event {
+ wl_keyboard::Event::Enter { serial, .. } | wl_keyboard::Event::Key { serial, .. } => Some(*serial),
+ wl_keyboard::Event::Leave { .. } => None,
+ _ => serial,
+ }
+ }
+
+ impl Dispatch for State {
+ fn event(state: &mut Self, _: &WlKeyboard, event: wl_keyboard::Event, _: &(), _: &Connection, _: &QueueHandle) {
+ let mut o = lock(&state.objects);
+ o.serial = keyboard_serial(&event, o.serial);
+ }
+ }
+
+ impl Dispatch for State {
+ fn event(state: &mut Self, _: &WlPointer, event: wl_pointer::Event, _: &(), _: &Connection, _: &QueueHandle) {
+ if let wl_pointer::Event::Button { serial, .. } = event {
+ lock(&state.objects).serial = Some(serial);
+ }
+ }
+ }
+
+ impl Dispatch for State {
+ fn event(state: &mut Self, _: &WlDataDevice, event: wl_data_device::Event, _: &(), _: &Connection, _: &QueueHandle) {
+ let mut o = lock(&state.objects);
+ match event {
+ wl_data_device::Event::Selection { id } => {
+ let mut offered = id.as_ref().and_then(|offer| offer.data::()).map(|d| lock(&d.0).clone()).unwrap_or_default();
+ offered.dedup();
+ if let Some(old) = std::mem::replace(&mut o.selection, id) {
+ old.destroy();
+ }
+ lock(&state.seen).selection(offered);
+ }
+ wl_data_device::Event::Enter { id, .. } => {
+ if let Some(old) = std::mem::replace(&mut o.drag, id) {
+ old.destroy();
+ }
+ }
+ wl_data_device::Event::Leave | wl_data_device::Event::Drop => {
+ if let Some(old) = o.drag.take() {
+ old.destroy();
+ }
+ }
+ _ => {}
+ }
+ }
+
+ /// The one event of a data device that makes an object is `data_offer`.
+ fn event_created_child(_: u16, qh: &QueueHandle) -> Arc {
+ qh.make_data::(OfferData::default())
+ }
+ }
+
+ impl Dispatch for State {
+ fn event(_: &mut Self, _: &WlDataOffer, event: wl_data_offer::Event, data: &OfferData, _: &Connection, _: &QueueHandle) {
+ if let wl_data_offer::Event::Offer { mime_type } = event {
+ let mut types = lock(&data.0);
+ if types.len() < MAX_TYPES {
+ types.push(mime_type);
+ }
+ }
+ }
+ }
+
+ impl Dispatch for State {
+ fn event(state: &mut Self, source: &WlDataSource, event: wl_data_source::Event, data: &SourceData, _: &Connection, _: &QueueHandle) {
+ match event {
+ // A type the Copy doesn't offer gets nothing: dropping `fd` closes it.
+ wl_data_source::Event::Send { mime_type, fd } => {
+ if let Some((_, bytes)) = data.0.iter().find(|(t, _)| t.eq_ignore_ascii_case(&mime_type)) {
+ state.sends.start(std::fs::File::from(fd), bytes.clone());
+ }
+ }
+ wl_data_source::Event::Cancelled => {
+ source.destroy();
+ let mut o = lock(&state.objects);
+ if o.source.as_ref() == Some(source) {
+ o.source = None;
+ lock(&state.seen).cancelled();
+ }
+ }
+ _ => {}
+ }
+ }
+ }
+
+ /// Reads the clipboard's offer for the read worker.
+ struct Receiver {
+ conn: Connection,
+ objects: Arc>,
+ /// Held as long as the connection, which is the window's.
_window: Arc,
}
- impl Loader {
- fn load(&self) -> Result {
- self.cb.load().map_err(|e| e.to_string())
+ impl Receiver {
+ /// The clipboard's data of type `wire`, read until the app that copied closes the pipe,
+ /// [`MAX_BYTES`] at most.
+ fn receive(&self, wire: String) -> Got {
+ let offer = lock(&self.objects).selection.clone().ok_or("the clipboard is empty")?;
+ let (from, to) = std::io::pipe().map_err(|e| format!("couldn't open a pipe for the clipboard's data: {e}"))?;
+ offer.receive(wire, to.as_fd());
+ // The request carries a copy of the write end: once it is sent, the app that copied
+ // holds the only one, and the read ends when that app closes it.
+ drop(to);
+ self.conn.flush().map_err(|e| format!("couldn't reach the compositor: {e}"))?;
+ read_capped(from, MAX_BYTES)
}
}
- impl TextClipboard {
- /// None when `window` isn't a Wayland window or the read worker can't start.
- #[allow(unsafe_code)]
+ pub struct DataClipboard {
+ reads: Reads,
+ objects: Arc>,
+ manager: WlDataDeviceManager,
+ conn: Connection,
+ qh: QueueHandle,
+ /// Tells the protocol worker to stop.
+ stop: Arc,
+ /// Held as long as the connection, which is the window's.
+ _window: Arc,
+ }
+
+ impl DataClipboard {
+ /// None when `window` isn't a Wayland window, the compositor has no data-device manager or
+ /// seat, or a worker can't start.
pub fn new(window: Arc) -> Option {
- use winit::raw_window_handle::{HasDisplayHandle as _, RawDisplayHandle};
- let RawDisplayHandle::Wayland(d) = window.display_handle().ok()?.as_raw() else { return None };
- let display = d.display.as_ptr();
- // SAFETY: the display is winit's open `wl_display`, which outlives `window`; each
- // clipboard is held beside a clone of `window` and dropped before it. smithay-clipboard
- // makes its own event queue on the display and doesn't close it.
- let (cb, reader) = unsafe { (smithay_clipboard::Clipboard::new(display), smithay_clipboard::Clipboard::new(display)) };
- let loader = Loader { cb: reader, _window: window.clone() };
- // `loader.load()`, not `loader.cb.load()`: a closure moves only the fields it names,
- // and the worker must hold the window as long as its clipboard.
+ let conn = connection(&window)?;
+ let (globals, mut queue) = registry_queue_init::(&conn).ok()?;
+ let qh = queue.handle();
+ let manager: WlDataDeviceManager = globals.bind(&qh, 1..=3, ()).ok()?;
+ let seat: WlSeat = globals.bind(&qh, 1..=5, ()).ok()?;
+ let (objects, seen) = (Arc::>::default(), Arc::>::default());
+ lock(&objects).device = Some(manager.get_data_device(&seat, &qh, ()));
+ let mut state = State { objects: objects.clone(), seen: seen.clone(), sends: Sends::default() };
+ // The seat's keyboard and pointer, and the clipboard's offer when the window has focus.
+ queue.roundtrip(&mut state).ok()?;
+ let receiver = Receiver { conn: conn.clone(), objects: objects.clone(), _window: window.clone() };
// Dropping doesn't wait for an unanswered read: only arboard's X11 handles hand what the
// app copied on when the last one goes.
- let loads = Loads::spawn("wayland-clipboard-read", Duration::ZERO, move |()| loader.load())?;
- Some(Self { cb, reads: TextReads::new(loads), _window: window })
+ let loads = Loads::spawn("wayland-clipboard-read", Duration::ZERO, move |wire| receiver.receive(wire))?;
+ let stop = Arc::new(AtomicBool::new(false));
+ let (stopped, held) = (stop.clone(), window.clone());
+ let work = move || {
+ while !stopped.load(Ordering::SeqCst) && queue.blocking_dispatch(&mut state).is_ok() {}
+ state.close(&seat);
+ let _ = queue.flush();
+ drop(held);
+ };
+ std::thread::Builder::new().name("wayland-clipboard".into()).spawn(work).ok()?;
+ Some(Self { reads: Reads::new(loads, seen), objects, manager, conn, qh, stop, _window: window })
}
- pub fn get_text(&mut self) -> Result {
- self.reads.get(LOAD_WAIT)
+ /// Gives the clipboard to a Copy of `flavours` ([`copy_types`]); none empties it. Err when
+ /// the seat has no data device or the window has had no input to take the clipboard with.
+ pub fn copy(&mut self, flavours: &[Flavour]) -> Result<(), String> {
+ let offered = copy_types(flavours);
+ let mut o = lock(&self.objects);
+ let device = o.device.clone().ok_or("the seat has no clipboard")?;
+ let serial = o.serial.ok_or("the window has had no key press or click to copy with")?;
+ let types: Vec<&'static str> = offered.iter().map(|(t, _)| *t).collect();
+ let source = (!offered.is_empty()).then(|| {
+ let source = self.manager.create_data_source(&self.qh, SourceData(offered));
+ for t in &types {
+ source.offer(t.to_string());
+ }
+ source
+ });
+ device.set_selection(source.as_ref(), serial);
+ if let Some(old) = std::mem::replace(&mut o.source, source) {
+ old.destroy();
+ }
+ lock(&self.reads.seen).copy(types);
+ drop(o);
+ self.conn.flush().map_err(|e| format!("couldn't reach the compositor: {e}"))
}
- pub fn set_text(&self, text: &str) {
- self.cb.store(text);
+ pub fn text(&mut self) -> Result {
+ self.reads.text(LOAD_WAIT)
+ }
+
+ /// What a paste of one of `mimes` reads ([`Reads::read`]), SVG and PNG waited for `wait` at
+ /// most.
+ pub fn read(&mut self, mimes: &[&'static str], wait: Duration) -> Option {
+ self.reads.read(mimes, wait)
+ }
+
+ pub fn has(&self, mimes: &[&'static str]) -> bool {
+ self.reads.has(mimes)
+ }
+
+ pub fn holds_ours(&self) -> bool {
+ self.reads.holds_ours()
}
/// The read that got no answer in its wait, when a paste of one of `mimes` can take its
- /// answer ([`TextReads::unanswered`]).
+ /// answer ([`Reads::unanswered`]).
pub(super) fn unanswered(&mut self, mimes: &[&'static str]) -> Option {
self.reads.unanswered(mimes)
}
/// Drops the read that got no answer in its wait: a paste no longer takes its answer.
pub fn forget_unanswered(&mut self) {
- self.reads.unanswered = None;
+ self.reads.forget_unanswered();
+ }
+ }
+
+ impl Drop for DataClipboard {
+ /// Stops the protocol worker, which a sync request's answer wakes.
+ fn drop(&mut self) {
+ self.stop.store(true, Ordering::SeqCst);
+ let _ = self.conn.display().sync(&self.qh, ());
+ let _ = self.conn.flush();
+ }
+ }
+
+ /// A connection on `window`'s Wayland display.
+ #[allow(unsafe_code)]
+ fn connection(window: &winit::window::Window) -> Option {
+ use wayland_backend::sys::client::Backend;
+ use winit::raw_window_handle::{HasDisplayHandle as _, RawDisplayHandle};
+ let RawDisplayHandle::Wayland(d) = window.display_handle().ok()?.as_raw() else { return None };
+ // SAFETY: the display is winit's open `wl_display`, which outlives `window`; every user of
+ // the connection holds a clone of `window`. A backend made from a foreign display does not
+ // close it when dropped.
+ let backend = unsafe { Backend::from_foreign_display(d.display.as_ptr().cast()) };
+ Some(Connection::from_backend(backend))
+ }
+
+ #[cfg(test)]
+ mod tests {
+ use super::*;
+
+ fn flavour_of(mime: &'static str, data: &[u8]) -> Flavour {
+ Flavour { mime, data: data.to_vec() }
+ }
+
+ fn offered(types: &[&str]) -> Vec {
+ types.iter().map(|t| t.to_string()).collect()
+ }
+
+ #[test]
+ fn a_copy_offers_its_svg_png_and_text_each_with_its_own_data() {
+ let flavours = [flavour_of(TEXT, b" "), flavour_of(SVG, b" "), flavour_of(super::super::PDF, b"%PDF"), flavour_of(PNG, b"png")];
+ let types: Vec<(&str, Vec)> = copy_types(&flavours).into_iter().map(|(t, d)| (t, d.to_vec())).collect();
+ assert_eq!(
+ types,
+ [
+ ("text/plain;charset=utf-8", b" ".to_vec()),
+ ("UTF8_STRING", b" ".to_vec()),
+ ("text/plain", b" ".to_vec()),
+ (SVG, b" ".to_vec()),
+ (PNG, b"png".to_vec()),
+ ]
+ );
+ // With Include SVG Code off the engine makes no SVG flavour, and none is offered; a
+ // flavour with no data is not offered either.
+ let types: Vec<&str> = copy_types(&[flavour_of(PNG, b"png"), flavour_of(TEXT, b"")]).into_iter().map(|(t, _)| t).collect();
+ assert_eq!(types, [PNG]);
+ assert!(copy_types(&[]).is_empty());
+ }
+
+ #[test]
+ fn a_read_asks_for_the_offers_own_type() {
+ let all = offered(&["text/plain", "TEXT", "UTF8_STRING", "Image/PNG", "image/svg+xml", "application/pdf"]);
+ assert_eq!(wire_type(TEXT, &all), Some("UTF8_STRING"));
+ assert_eq!(wire_type(TEXT, &offered(&["text/plain", "text/plain;charset=UTF-8"])), Some("text/plain;charset=UTF-8"));
+ assert_eq!(wire_type(BITMAP, &all), Some("Image/PNG"));
+ assert_eq!(wire_type(SVG, &all), Some("image/svg+xml"));
+ // PDF, EPS and metafiles are not read here, nor types no reader knows.
+ assert_eq!(wire_type(super::super::PDF, &all), None);
+ assert_eq!(wire_type(TEXT, &offered(&["text/html", "image/jpeg"])), None);
+ assert_eq!(wire_type(SVG, &[]), None);
+ }
+
+ /// What a paste of `mimes` takes from a clipboard holding `data` by type, and the types read.
+ fn pasted(data: &[(&str, &[u8])], mimes: &[&'static str]) -> (Option, Vec) {
+ let types: Vec = data.iter().map(|(t, _)| t.to_string()).collect();
+ let mut read = vec![];
+ let f = pick(&types, mimes, |wire| {
+ read.push(wire.to_string());
+ data.iter().find(|(t, _)| *t == wire).map(|(_, d)| d.to_vec())
+ });
+ (f, read)
+ }
+
+ #[test]
+ fn a_paste_takes_svg_then_text_then_png() {
+ use vectorcraft_engine::cmd::clipboard::PASTE_ORDER;
+ let svg: (&str, &[u8]) = (SVG, b"");
+ let text: (&str, &[u8]) = ("text/plain;charset=utf-8", b"a\r\nb");
+ let png: (&str, &[u8]) = (PNG, b"\x89PNG");
+ let (f, read) = pasted(&[text, png, svg], &PASTE_ORDER);
+ assert_eq!(f.map(|f| (f.mime, f.data)), Some((SVG, svg.1.to_vec())));
+ assert_eq!(read, [SVG]);
+ // Text before a bitmap, its line ends made `\n`.
+ let (f, _) = pasted(&[png, text], &PASTE_ORDER);
+ assert_eq!(f.map(|f| (f.mime, f.data)), Some((TEXT, b"a\nb".to_vec())));
+ // An offer of a PNG or SVG alone pastes it.
+ assert_eq!(pasted(&[png], &PASTE_ORDER).0.map(|f| (f.mime, f.data)), Some((PNG, png.1.to_vec())));
+ assert_eq!(pasted(&[svg], &PASTE_ORDER).0.map(|f| f.mime), Some(SVG));
+ // Blank text, or text that only names a picture, gives way to the picture.
+ for beside in [&b" \n"[..], b"https://example.com/art.png", b" "] {
+ let (f, read) = pasted(&[("UTF8_STRING", beside), png], &PASTE_ORDER);
+ assert_eq!(f.map(|f| f.mime), Some(PNG));
+ assert_eq!(read, ["UTF8_STRING", PNG]);
+ }
+ // With no picture, or a paste of text alone, the text is what there is.
+ assert_eq!(pasted(&[("text/plain", &b"https://example.com/art.png"[..])], &PASTE_ORDER).0.map(|f| f.mime), Some(TEXT));
+ assert_eq!(pasted(&[("text/plain", &b" "[..]), png], &[TEXT]).0.map(|f| f.mime), Some(TEXT));
+ // An empty clipboard, a type no reader knows, data that doesn't come or is empty.
+ assert_eq!(pasted(&[], &PASTE_ORDER).0, None);
+ assert_eq!(pasted(&[("application/x-other", &b"x"[..])], &PASTE_ORDER).0, None);
+ assert_eq!(pasted(&[(PNG, &b""[..])], &PASTE_ORDER).0, None);
+ let types = offered(&[PNG]);
+ assert_eq!(pick(&types, &PASTE_ORDER, |_| None), None);
+ }
+
+ #[test]
+ fn the_clipboard_holds_a_copy_until_another_app_takes_it() {
+ let mut seen = Seen::default();
+ let types = vec!["text/plain;charset=utf-8", SVG, PNG];
+ seen.copy(types.clone());
+ assert!(seen.ours, "a Copy holds the clipboard before its offer comes");
+ // An offer made before the Copy and handled after it.
+ seen.selection(offered(&[PNG]));
+ assert!(!seen.ours);
+ // The Copy's own offer, its types in another order.
+ seen.selection(offered(&[PNG, SVG, "text/plain;charset=utf-8"]));
+ assert!(seen.ours);
+ // Another app's copy: an offer with other types, then the source canceled.
+ seen.selection(offered(&[PNG, SVG, "text/plain;charset=utf-8", "text/html"]));
+ assert!(!seen.ours);
+ seen.selection(offered(&[PNG, SVG, "text/plain;charset=utf-8"]));
+ seen.cancelled();
+ assert!(!seen.ours, "a canceled source no longer holds the clipboard");
+ // Another app's offer with the same types after that is not ours.
+ seen.selection(offered(&[PNG, SVG, "text/plain;charset=utf-8"]));
+ assert!(!seen.ours);
+ // An empty Copy empties the clipboard.
+ seen.copy(vec![]);
+ assert!(!seen.ours);
+ seen.selection(vec![]);
+ assert!(!seen.ours && seen.offered.is_empty());
+ }
+
+ #[test]
+ fn a_copy_takes_the_clipboard_with_a_key_press_not_a_modifier_change() {
+ let key = |serial| wl_keyboard::Event::Key { serial, time: 0, key: 46, state: WEnum::Value(wl_keyboard::KeyState::Pressed) };
+ let modifiers = |serial| wl_keyboard::Event::Modifiers { serial, mods_depressed: 0, mods_latched: 0, mods_locked: 0, group: 0 };
+ // Ctrl+C, then Ctrl released before the Copy's data is ready.
+ let serial = keyboard_serial(&key(7), None);
+ assert_eq!(keyboard_serial(&modifiers(8), serial), Some(7));
+ assert_eq!(keyboard_serial(&wl_keyboard::Event::RepeatInfo { rate: 25, delay: 600 }, Some(7)), Some(7));
+ assert_eq!(keyboard_serial(&modifiers(9), None), None);
+ }
+
+ #[test]
+ fn a_read_stops_at_its_limit() {
+ assert_eq!(MAX_BYTES, 64 * 1024 * 1024);
+ assert_eq!(read_capped(&b"12345678"[..], 8), Ok(b"12345678".to_vec()));
+ assert_eq!(read_capped(&b""[..], 8), Ok(vec![]));
+ assert_eq!(read_capped(&[0u8; 9][..], 8), Err("the clipboard's data is larger than 0 MiB".to_string()));
+ assert_eq!(read_capped(&vec![0u8; (2 << 20) + 1][..], 2 << 20), Err("the clipboard's data is larger than 2 MiB".to_string()));
+ /// A pipe that breaks.
+ struct Broken;
+ impl Read for Broken {
+ fn read(&mut self, _: &mut [u8]) -> std::io::Result {
+ Err(std::io::ErrorKind::BrokenPipe.into())
+ }
+ }
+ assert!(read_capped(Broken, 8).is_err_and(|e| e.starts_with("couldn't read the clipboard's data")));
+ }
+
+ #[test]
+ fn a_copys_data_is_sent_through_the_pipe_and_closed() {
+ let sends = Sends::default();
+ let (from, to) = std::io::pipe().unwrap();
+ let data: Arc<[u8]> = vec![7u8; 300_000].into();
+ assert!(sends.start(to, data.clone()));
+ // More than a pipe holds: the write ends once all of it is read, then the pipe closes.
+ assert_eq!(read_capped(from, MAX_BYTES).as_deref(), Ok(&data[..]));
+ // An app that closes its end before reading ends the send.
+ let (from, to) = std::io::pipe().unwrap();
+ drop(from);
+ assert!(sends.start(to, data.clone()));
+ while sends.running() > 0 {
+ std::thread::yield_now();
+ }
+ }
+
+ #[test]
+ fn pastes_that_read_nothing_hold_a_few_sends_at_most() {
+ let sends = Sends::default();
+ let data: Arc<[u8]> = vec![1u8; 300_000].into();
+ // Apps that ask for the data and never read it.
+ let stalled: Vec<_> = (0..MAX_SENDS)
+ .map(|_| {
+ let (from, to) = std::io::pipe().unwrap();
+ assert!(sends.start(to, data.clone()));
+ from
+ })
+ .collect();
+ // The next paste gets its pipe closed at once, with nothing written.
+ let (from, to) = std::io::pipe().unwrap();
+ assert!(!sends.start(to, data.clone()));
+ assert_eq!(read_capped(from, MAX_BYTES), Ok(vec![]));
+ // Once those apps close their pipes, the sends end and others are sent again.
+ drop(stalled);
+ while sends.running() > 0 {
+ std::thread::yield_now();
+ }
+ let (from, to) = std::io::pipe().unwrap();
+ assert!(sends.start(to, data.clone()));
+ assert_eq!(read_capped(from, MAX_BYTES).map(|d| d.len()), Ok(data.len()));
+ }
+
+ /// What a clipboard holds, by type.
+ type Held = &'static [(&'static str, &'static [u8])];
+ const PNG_ONLY: Held = &[(PNG, b"\x89PNG")];
+ const SVG_ONLY: Held = &[(SVG, b" ")];
+ const TEXT_ONLY: Held = &[("text/plain;charset=utf-8", b"words")];
+ const EMPTY_TEXT: Held = &[("text/plain", b"")];
+ const NOTHING: Held = &[];
+ const UNKNOWN: Held = &[("application/x-other", b"x")];
+
+ /// Reads of a clipboard holding `data`, answered at once.
+ fn reads_of(data: Held) -> Reads {
+ let loads = Loads::spawn("test", Duration::ZERO, move |wire: String| {
+ data.iter().find(|(t, _)| *t == wire).map(|(_, d)| d.to_vec()).ok_or_else(|| "not offered".to_string())
+ })
+ .unwrap();
+ let types: Vec<&str> = data.iter().map(|(t, _)| *t).collect();
+ Reads::new(loads, Arc::new(Mutex::new(Seen::offering(&types))))
+ }
+
+ #[test]
+ fn a_picture_or_svg_alone_can_be_pasted_and_is() {
+ use vectorcraft_engine::cmd::clipboard::PASTE_ORDER;
+ let wait = Duration::from_secs(600);
+ for (data, mime) in [(PNG_ONLY, PNG), (SVG_ONLY, SVG)] {
+ let mut reads = reads_of(data);
+ // The paste check enables Paste from the types alone.
+ assert!(reads.has(&PASTE_ORDER));
+ let Some(ClipboardRead::Later(work)) = reads.read(&PASTE_ORDER, wait) else { panic!("{mime} is read off the UI thread") };
+ assert_eq!(work().map(|f| (f.mime, f.data)), Some((mime, data[0].1.to_vec())));
+ }
+ let mut reads = reads_of(TEXT_ONLY);
+ assert!(reads.has(&[TEXT]) && !reads.has(&[BITMAP, SVG]));
+ let Some(ClipboardRead::Now(f)) = reads.read(&PASTE_ORDER, wait) else { panic!("text is read at once") };
+ assert_eq!((f.mime, f.data), (TEXT, b"words".to_vec()));
+ // An empty clipboard, or one with no type a paste reads, enables nothing and pastes nothing.
+ for data in [NOTHING, UNKNOWN] {
+ let mut reads = reads_of(data);
+ assert!(!reads.has(&PASTE_ORDER));
+ assert!(reads.read(&PASTE_ORDER, wait).is_none());
+ assert_eq!(reads.text(wait), Ok(String::new()));
+ }
+ // Empty text is not pasted.
+ assert!(reads_of(EMPTY_TEXT).read(&[TEXT], wait).is_none());
+ }
+
+ #[test]
+ fn an_svg_that_sends_nothing_gives_way_to_the_text_or_png_beside_it() {
+ use vectorcraft_engine::cmd::clipboard::PASTE_ORDER;
+ const EMPTY_SVG_TEXT: Held = &[(SVG, b""), ("text/plain", b"words"), (PNG, b"\x89PNG")];
+ const EMPTY_SVG_PNG: Held = &[(SVG, b""), (PNG, b"\x89PNG")];
+ const EMPTY_SVG_URL: Held = &[(SVG, b""), ("text/plain", b"https://example.com/art.png"), (PNG, b"\x89PNG")];
+ let wait = Duration::from_secs(600);
+ const EMPTY_PNG: Held = &[(PNG, b"")];
+ for (data, mime, bytes) in
+ [(EMPTY_SVG_TEXT, TEXT, &b"words"[..]), (EMPTY_SVG_PNG, PNG, &b"\x89PNG"[..]), (EMPTY_SVG_URL, PNG, &b"\x89PNG"[..])]
+ {
+ // Held while the work runs, as the clipboard is.
+ let mut reads = reads_of(data);
+ let Some(ClipboardRead::Later(work)) = reads.read(&PASTE_ORDER, wait) else { panic!("the SVG is asked for") };
+ assert_eq!(work().map(|f| (f.mime, f.data)), Some((mime, bytes.to_vec())));
+ }
+ // A failed PNG read, last of all, pastes nothing.
+ let mut reads = reads_of(EMPTY_PNG);
+ let Some(ClipboardRead::Later(work)) = reads.read(&PASTE_ORDER, wait) else { panic!("the PNG is asked for") };
+ assert!(work().is_none());
+ }
+
+ #[test]
+ fn a_read_that_fails_pastes_nothing() {
+ let loads = Loads::spawn("test", Duration::ZERO, |_: String| Err::, _>("the clipboard's data is larger than 64 MiB".to_string()))
+ .unwrap();
+ let mut reads = Reads::new(loads, Arc::new(Mutex::new(Seen::offering(&[PNG, "text/plain"]))));
+ assert_eq!(reads.text(Duration::from_secs(600)), Err("the clipboard's data is larger than 64 MiB".to_string()));
+ let Some(ClipboardRead::Later(work)) = reads.read(&[BITMAP], Duration::from_secs(600)) else { panic!("the PNG is asked for") };
+ assert!(work().is_none());
}
}
}
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 c2b40e37e6..cd3ea7cd36 100644
--- a/apps/vectorcraft/src/main.rs
+++ b/apps/vectorcraft/src/main.rs
@@ -53,6 +53,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;
@@ -87,6 +89,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};
@@ -116,7 +120,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 46dcdb52ba..c5e5bc5bea 100644
--- a/crates/color/src/cms.rs
+++ b/crates/color/src/cms.rs
@@ -531,7 +531,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/live.rs b/crates/doc/src/live.rs
index cf7a4d2fa0..3cf8312289 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,
};
@@ -54,8 +54,8 @@ pub use blend_spec::{BlendDefaults, BlendEase, BlendEaseType, BlendOrientation,
use blend_spec::{blend_color_shift, blend_ramp};
pub use envelope::{
EnvelopeKind, EnvelopeMap, EnvelopeOptions, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, TypeInk, envelope_bounds, envelope_content_bounds,
- envelope_content_boxes, envelope_expand, envelope_frame, envelope_grid, envelope_mapper, envelope_of, envelope_overlay, envelope_stroke_margin,
- set_type_ink, smooth_handles, warp_grid,
+ envelope_content_boxes, envelope_content_boxes_with, envelope_expand, envelope_frame, envelope_grid, envelope_mapper, envelope_of,
+ envelope_overlay, envelope_stroke_margin, set_type_ink, smooth_handles, warp_grid,
};
pub(crate) use expand::max_outset;
pub use expand::{
diff --git a/crates/doc/src/live/envelope.rs b/crates/doc/src/live/envelope.rs
index 44dc406c1d..5aa43b0a67 100644
--- a/crates/doc/src/live/envelope.rs
+++ b/crates/doc/src/live/envelope.rs
@@ -149,20 +149,31 @@ impl EnvelopeOptions {
/// its glyph outlines (see [`envelope_content_bounds`]). Strokes scale with the frame as
/// [`Node::transform`] scales them.
pub fn envelope_content_boxes(content: &[Arc], frame: Affine) -> Option<(Rect, Rect)> {
+ envelope_content_boxes_with(content, frame, false).or_else(|| envelope_content_boxes_with(content, frame, true))
+}
+
+/// [`envelope_content_boxes`] with type that draws nothing given its laid-out box when
+/// `blank_type_box` is set and no box when it is not. A caller that measures the members of an
+/// envelope's contents one at a time passes `true` only when the whole contents have no box with
+/// `false`, so each member is measured as the whole contents measure it.
+pub fn envelope_content_boxes_with(content: &[Arc], frame: Affine, blank_type_box: bool) -> Option<(Rect, Rect)> {
let frame = if frame.is_finite() && frame.determinant().abs() > 1e-12 { frame } else { Affine::IDENTITY };
let to_frame = frame.inverse();
- let (stroked, plain) = content.iter().fold((None, None), |(stroked, plain), c| {
- (vectorcraft_geom::union_opt(stroked, content_box(c, true, to_frame)), vectorcraft_geom::union_opt(plain, content_box(c, false, to_frame)))
- });
- Some((stroked?, plain?))
+ Some((content_set_box(content, true, to_frame, blank_type_box)?, content_set_box(content, false, to_frame, blank_type_box)?))
}
/// The geometric bounds an envelope around `content` on the page axes maps from: type, alone or
/// in groups and layers, is measured by its glyph outlines, underlines and strikethroughs instead
/// of its laid-out box. Type keeps its laid-out box while nothing is registered with
-/// [`set_type_ink`], and when it draws nothing.
+/// [`set_type_ink`]. Type that draws nothing adds no box while other content has one; when
+/// nothing else has a box, it keeps its laid-out box.
pub fn envelope_content_bounds(content: &[Arc]) -> Option {
- content.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box(c, false, Affine::IDENTITY)))
+ content_set_box(content, false, Affine::IDENTITY, false).or_else(|| content_set_box(content, false, Affine::IDENTITY, true))
+}
+
+/// The union of the boxes [`content_box`] gives each of `content`.
+fn content_set_box(content: &[Arc], stroked: bool, to: Affine, blank_type_box: bool) -> Option {
+ content.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box(c, stroked, to, blank_type_box)))
}
/// The box of type's glyph outlines, underlines and strikethroughs with its text space mapped by
@@ -367,15 +378,16 @@ fn find_type(n: &Node, typed: &mut std::collections::HashSet) -> bool {
/// or with `stroked` its visual bounds (strokes scaled by `to` as [`Node::transform`] scales them),
/// with type measured by its glyph outlines ([`type_ink`]) instead of its laid-out box, in groups
/// and layers too. Type is laid out as it is on the page, and its outlines are then taken into
-/// those axes. A character's strokes reach as far past its glyphs as past the laid-out box.
-fn content_box(n: &Arc, stroked: bool, to: Affine) -> Option {
+/// those axes. A character's strokes reach as far past its glyphs as past the laid-out box. Type
+/// that draws nothing has its laid-out box with `blank_type_box`, and no box without it.
+fn content_box(n: &Arc, stroked: bool, to: Affine, blank_type_box: bool) -> Option {
let mut typed = std::collections::HashSet::new();
find_type(n, &mut typed);
- content_box_in(n, stroked, to, &typed)
+ content_box_in(n, stroked, to, &typed, blank_type_box)
}
/// [`content_box`], with the addresses of the objects in `n` that hold type ([`find_type`]).
-fn content_box_in(n: &Arc, stroked: bool, to: Affine, typed: &std::collections::HashSet) -> Option {
+fn content_box_in(n: &Arc, stroked: bool, to: Affine, typed: &std::collections::HashSet, blank_type_box: bool) -> Option {
// `n` in the axes of `to`.
let moved = || {
if to == Affine::IDENTITY {
@@ -395,10 +407,13 @@ fn content_box_in(n: &Arc, stroked: bool, to: Affine, typed: &std::collect
let ink = match n.projection() {
None => type_ink(n, t, to * t.xf, None),
Some(h) => type_ink(n, t, t.xf, Some((h, to))),
- }
- .flatten();
+ };
let m = moved();
- let Some(ink) = ink else { return own(&m) };
+ let ink = match ink {
+ Some(Some(ink)) => ink,
+ Some(None) if !blank_type_box => return None,
+ _ => return own(&m),
+ };
if !stroked {
return Some(ink);
}
@@ -413,7 +428,13 @@ fn content_box_in(n: &Arc, stroked: bool, to: Affine, typed: &std::collect
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
// A clipping path that paints strokes over the clip edge, as in `Node::visual_bounds`.
let clip = children.first()?;
- content_box_in(clip, stroked && matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)), to, typed)
+ content_box_in(
+ clip,
+ stroked && matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)),
+ to,
+ typed,
+ blank_type_box,
+ )
}
NodeKind::Layer { .. } | NodeKind::Group { .. } => {
let layer = matches!(n.kind, NodeKind::Layer { .. });
@@ -424,9 +445,11 @@ fn content_box_in(n: &Arc, stroked: bool, to: Affine, typed: &std::collect
let mut own_strokes = Node::new(n.id, NodeKind::Group { children: Vec::new(), clip: false });
own_strokes.appearance = n.appearance.clone();
own_strokes.transform(to, true);
- own_strokes.visual_of_members(members.map(|c| content_box_in(c, true, to, typed)))
+ own_strokes.visual_of_members(members.map(|c| content_box_in(c, true, to, typed, blank_type_box)))
} else {
- members.filter(|c| c.visible || !layer).fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box_in(c, false, to, typed)))
+ members
+ .filter(|c| c.visible || !layer)
+ .fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box_in(c, false, to, typed, blank_type_box)))
}
}
_ => own(&moved()),
@@ -649,7 +672,7 @@ impl<'a> EnvelopeMap<'a> {
pub fn new(content: &[Arc], kind: &'a EnvelopeKind, frame: Affine, options: EnvelopeOptions) -> Option {
let frame = if frame.is_finite() && frame.determinant().abs() > 1e-12 { frame } else { Affine::IDENTITY };
let to_frame = frame.inverse();
- let src = content.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box(c, false, to_frame)))?;
+ let src = content_set_box(content, false, to_frame, false).or_else(|| content_set_box(content, false, to_frame, true))?;
let src = options.source_box(src);
Some(Self { src, frame, to_frame, surface: surface_of(kind, src, frame) })
}
@@ -835,14 +858,14 @@ mod tests {
let to = Affine::scale(2.0);
let mut moved = (*group).clone();
moved.transform(to, true);
- let want = moved.visual_of_members(std::iter::once(content_box(&text, true, to)));
+ let want = moved.visual_of_members(std::iter::once(content_box(&text, true, to, true)));
assert!(want.is_some());
- assert_eq!(content_box(&group, true, to), want);
+ assert_eq!(content_box(&group, true, to, true), want);
let mut nested = group;
for i in 0..300 {
nested = Arc::new(Node::group(NodeId(10 + i), vec![nested]));
}
- assert!(content_box(&nested, false, Affine::IDENTITY).is_some());
+ assert!(content_box(&nested, false, Affine::IDENTITY, true).is_some());
}
/// A warp envelope of `style` at `bend` with map `version` around a 200×100 rectangle at
diff --git a/crates/doc/src/pixels.rs b/crates/doc/src/pixels.rs
index 0812095826..1c231f7bbe 100644
--- a/crates/doc/src/pixels.rs
+++ b/crates/doc/src/pixels.rs
@@ -194,6 +194,27 @@ impl ImageBlob {
.collect();
Some((w, h, cover))
}
+
+ /// The pixels as straight RGBA, row by row from the top, with the width and the height;
+ /// `None` when the image can't be decoded within [`MAX_RASTER_ALLOC`].
+ pub fn rgba(&self) -> Option<(u32, u32, Vec)> {
+ let img = rgba_within(&self.bytes, MAX_RASTER_ALLOC)?;
+ let (w, h) = img.dimensions();
+ Some((w, h, img.into_raw()))
+ }
+
+ /// The width and height in pixels the image's file says it has; `None` for data that isn't an
+ /// image the decoder reads. Reads only its header.
+ pub fn pixel_size(&self) -> Option<(u32, u32)> {
+ image::ImageReader::new(Cursor::new(self.bytes.as_slice())).with_guessed_format().ok()?.into_dimensions().ok()
+ }
+
+ /// Whether the image's file is a PNG that stores palette indices (an indexed-color image).
+ /// Reads only its header.
+ pub fn indexed_png(&self) -> bool {
+ // The signature, then the IHDR chunk: length, type, width, height, bit depth, color type.
+ self.bytes.starts_with(b"\x89PNG\r\n\x1a\n") && self.bytes.get(12..16) == Some(b"IHDR".as_slice()) && self.bytes.get(25) == Some(&3)
+ }
}
impl ImageBlob {
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/bake.rs b/crates/effects/src/bake.rs
index b77f804de7..7e42147443 100644
--- a/crates/effects/src/bake.rs
+++ b/crates/effects/src/bake.rs
@@ -83,15 +83,17 @@ fn apply(effects: &[vectorcraft_doc::Effect], path: &PathData, ctx: &GeomContext
/// The geometry `g` with the own effects of `item` (one of the fills and strokes `ctx` is the
/// context of) applied, and `carried` (`item` as the object's effects left it) with its gradient
-/// moved along with them.
+/// moved along with them and the Transform effects the geometry pass left out hidden
+/// ([`crate::Carry::hide_skipped`]).
fn apply_item(item: &AppearanceItem, carried: &AppearanceItem, g: &PathData, ctx: &GeomContext) -> (PathData, AppearanceItem) {
let (ig, carry) = apply(item_effects(item), g, &ctx.item(item));
let mut ap = Appearance { items: vec![carried.clone()], ..Default::default() };
- if !carry.moves(&ap) {
- return (ig, carried.clone());
+ if carry.moves(&ap) {
+ carry.apply(&mut ap);
}
- carry.apply(&mut ap);
- (ig, ap.items.pop().unwrap_or_else(|| carried.clone()))
+ let mut out = ap.items.pop().unwrap_or_else(|| carried.clone());
+ carry.hide_skipped(out.effects_mut());
+ (ig, out)
}
/// A path node of `kind`'s flavour (compound when the result has several subpaths).
@@ -470,6 +472,7 @@ fn leaf_base(n: &Node) -> Option<(PathData, FillRule, GeomContext<'_>, Node)> {
if carry.moves(&m.appearance) {
carry.apply(&mut m.appearance);
}
+ carry.hide_skipped(&mut m.appearance.effects);
// The raster effects of its fills and strokes follow its Transform effects, as on the canvas.
crate::raster::follow_items(&mut m);
m.appearance.effects = crate::raster::without_geometry(&m.appearance.effects);
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/geometry.rs b/crates/effects/src/geometry.rs
index a2e5478b28..39924835fd 100644
--- a/crates/effects/src/geometry.rs
+++ b/crates/effects/src/geometry.rs
@@ -200,8 +200,12 @@ pub fn apply_geometry_bez_carrying(effects: &[Effect], path: &BezPath, bounds: R
/// it unevenly (a map after which a refit would not match the mapped gradient): it is fixed to its
/// fit on the geometry that effect takes, and then follows that effect and the ones after it as a
/// placed gradient does.
+///
+/// It also lists the visible Transform effects that move the art once and that the pass left out,
+/// their results past the budget, by their index in the effect list it was given: the raster
+/// effects before such an effect stay as they are, as the art does ([`Self::hide_skipped`]).
#[derive(Clone, Debug, Default, PartialEq)]
-pub struct Carry(Vec);
+pub struct Carry(Vec, Vec);
/// Does `m` fix a gradient fitted to the object to its fit before mapping it? It does when a refit
/// would not reproduce the map (a turn, a reflection or an uneven scale) and `m` keeps area: a map
@@ -265,7 +269,7 @@ impl Carry {
_ => None,
},
};
- Carry(step.into_iter().collect())
+ Carry(step.into_iter().collect(), vec![])
}
/// Does it leave gradients where they are?
@@ -310,6 +314,30 @@ impl Carry {
pub(crate) fn push(&mut self, more: Carry) {
self.0.extend(more.0);
+ self.1.extend(more.1);
+ }
+
+ /// Did the pass leave out a Transform effect that moves the art once ([`Self::hide_skipped`])?
+ pub fn skipped(&self) -> bool {
+ !self.1.is_empty()
+ }
+
+ /// Hide each Transform effect of `effects`, the list the pass was given, that it left out (its
+ /// result past the budget): the art stays where it was, so the raster effects listed before it
+ /// do not follow it ([`crate::raster::followed`]). Other effects stay as they are.
+ pub fn hide_skipped(&self, effects: &mut [Effect]) {
+ for &i in &self.1 {
+ if let Some(e) = effects.get_mut(i).filter(|e| e.id == "distort.transform") {
+ e.visible = false;
+ }
+ }
+ }
+
+ /// Note that the Transform effect at index `i` of the pass's list was left out.
+ pub(crate) fn skip(&mut self, i: usize) {
+ if !self.1.contains(&i) {
+ self.1.push(i);
+ }
}
}
@@ -354,6 +382,13 @@ pub(crate) fn apply_geometry_carrying_within(
c.push(Carry::of(&e.id, &p, b, cur.bounds(), next.bounds(), ctx.seed));
}
cur = next;
+ } else if let Some(c) = carry.as_deref_mut()
+ && e.id == "distort.transform"
+ && !crate::raster::holds_back(e)
+ && let Some(i) = effects.iter().position(|x| std::ptr::eq(x, e))
+ {
+ // The raster effects before it stay as the art does.
+ c.skip(i);
}
}
cur
diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs
index a6581fa449..bb2f605a91 100644
--- a/crates/effects/src/group.rs
+++ b/crates/effects/src/group.rs
@@ -261,8 +261,17 @@ fn reshaped(src: &Node, n: &Node, doc: Option<&Document>) -> Node {
carry.apply(&mut m.appearance);
}
// So do the raster effects of its fills and strokes, by `n`'s Transform effects
- // ([`crate::follow_items`]).
- crate::raster::follow_items_of(&mut m, &n.appearance.effects);
+ // ([`crate::follow_items`]) that moved the outline.
+ let mut owner = std::borrow::Cow::Borrowed(n.appearance.effects.as_slice());
+ if carry.skipped() {
+ carry.hide_skipped(owner.to_mut());
+ if m.appearance.effects == n.appearance.effects {
+ let mut ap = (*m.appearance).clone();
+ ap.effects = owner.to_vec();
+ m.appearance = ap.into();
+ }
+ }
+ crate::raster::follow_items_of(&mut m, &owner);
}
without_geometry_effects(&mut m);
m
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..8c829d9e0b 100644
--- a/crates/effects/src/reshape.rs
+++ b/crates/effects/src/reshape.rs
@@ -895,9 +895,16 @@ pub(crate) fn own_effects_art(doc: Option<&Document>, n: &Node) -> Option
return None;
}
let mut art = one_piece(n);
- reshape_leaves(&mut art, &fx, &n.appearance.effects, doc);
+ let carry = reshape_leaves_carrying(&mut art, &fx, &n.appearance.effects, doc);
transform_items(&mut art, doc);
- Some(dressed(art, n))
+ let mut art = dressed(art, n);
+ // The raster effects before a Transform effect the path did not take stay as the path does.
+ if carry.skipped() {
+ let mut ran = n.appearance.effects.clone();
+ carry.hide_skipped(&mut ran);
+ art.appearance.effects = crate::raster::without_geometry(&ran);
+ }
+ Some(art)
}
/// Map every path under `art` (and its gradients), and every path in the art of the linked opacity
@@ -997,7 +1004,8 @@ fn hoist_copies(art: &mut Node) {
/// of their own ([`copy_leaf`]) whatever paints them, as Expand Appearance makes them. A live-effect
/// plug-in that paints what it returns makes each path a group of its pieces ([`paint_leaf`]),
/// whose swatches and patterns must be in `doc` (without one, pieces that name one are refused).
-/// Returns how gradients that paint the art as a whole follow the effects. A deferred live-effect
+/// Returns how gradients that paint the art as a whole follow the effects, and the Transform
+/// effects that nothing in the art took past the budget ([`crate::Carry::skipped`]). A deferred live-effect
/// plug-in is listed by its index in `owner`, the effect list `fx` was taken from, or in `fx` when
/// `owner` does not hold it. The art of the linked opacity masks of the objects in `art` (not of
/// `art` itself) takes each effect as the paths it masks do ([`Step::reshape_masks`]), with the
@@ -1053,6 +1061,16 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget
used = cost;
continue;
}
+ // A Transform effect that moves the art once and that nothing in it took (past the budget)
+ // is left out ([`crate::Carry::hide_skipped`]), listed by its index in `owner`.
+ if ran.kept
+ && !ran.took
+ && e.id == "distort.transform"
+ && !crate::raster::holds_back(e)
+ && let Some(i) = owner.iter().position(|x| std::ptr::eq(x, *e))
+ {
+ whole.skip(i);
+ }
if let Some(carry) = ran.whole {
whole.push(carry);
}
@@ -1084,6 +1102,8 @@ struct Step<'a> {
struct Ran {
/// A path in the art kept its shape (past the budget).
kept: bool,
+ /// A path, image or symbol instance in the art took the effect.
+ took: bool,
/// The linked opacity mask of an object in the art stayed as it was while the paths it masks
/// took the effect, or could have ([`Step::reshape_masks`]).
masks: bool,
@@ -1123,7 +1143,7 @@ impl Step<'_> {
depth: usize,
) -> Ran {
let (e, params, bounds, seed) = (self.e, self.params, self.bounds, self.seed);
- let stayed = Ran { kept: false, masks: true, short: false, whole: None };
+ let stayed = Ran { kept: false, took: false, masks: true, short: false, whole: None };
if let Some(plugin) = painted_plugin(e) {
if own || has_linked_mask(art, false) {
return stayed;
@@ -1137,7 +1157,7 @@ impl Step<'_> {
*used = used.minus(before).plus(made);
}
});
- return Ran { kept: false, masks: false, short: false, whole: None };
+ return Ran { kept: false, took: true, masks: false, short: false, whole: None };
}
// A Warp effect that bends images ([`mark_warped_images`]) bends an image's pixels, not the
// art of its linked mask.
@@ -1190,7 +1210,7 @@ impl Step<'_> {
// ([`transform_placed`]): a mask's art that holds one can't take another effect as the art
// it masks does.
if own && transform.is_none() && (is_placed(art) || placed_count(art) > 0) {
- return Ran { kept: false, masks: true, short: false, whole: None };
+ return Ran { kept: false, took: false, masks: true, short: false, whole: None };
}
// Art of several objects is copied whole, so the copies don't interleave member by member.
// The copies of its members carry their linked masks, moved with them.
@@ -1200,7 +1220,7 @@ impl Step<'_> {
{
if !own {
*used = copy_art(art, m, copies, copy_scaling(options), max);
- return Ran { kept: false, masks: false, short: false, whole: None };
+ return Ran { kept: false, took: true, masks: false, short: false, whole: None };
}
// A mask's art: its copies keep to what the rest of the art leaves of `max`, and the
// linked mask of the art itself takes them too. That mask's art stays in `used`, as
@@ -1212,7 +1232,7 @@ impl Step<'_> {
let short = art.children().is_some_and(|c| c.iter().filter(|c| !c.is_guide()).count() <= copies);
let none = HashSet::new();
let masks = self.reshape_own_mask(art, &Skip { moved: &none, kept: &none }, max, used, depth);
- return Ran { kept: false, masks, short, whole: None };
+ return Ran { kept: false, took: true, masks, short, whole: None };
}
// A shortfall in the copies matters for the objects a linked mask above them covers, which
// the mask would take a different number of copies than, and anywhere in a mask's art.
@@ -1223,6 +1243,7 @@ impl Step<'_> {
}
let counts = |id: NodeId| watch && (own || covered.contains(&id));
let mut short = false;
+ let mut took = false;
for_each_leaf(art, &mut |leaf| {
let at = leaf_at;
leaf_at += 1;
@@ -1247,6 +1268,7 @@ impl Step<'_> {
if options.reach_appearance() {
let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room, raster_map.is_some(), None);
*used = used.minus(before).plus(made);
+ took |= moved;
if moved {
// With Transform Objects off the path stays, and its mask takes the
// effect's stroke and pattern changes as its own art.
@@ -1263,6 +1285,7 @@ impl Step<'_> {
let scaling = vectorcraft_doc::Scaling { keep_fit_when_flat: true, ..Default::default() };
let made = copy_leaf(leaf, &path, m, copies, scaling, room, None);
*used = used.minus(before).plus(made);
+ took = true;
moved_ids.insert(leaf.id);
short |= counts(leaf.id) && copied(leaf) <= copies;
return;
@@ -1274,6 +1297,7 @@ impl Step<'_> {
kept_leaves.insert(at);
return;
};
+ took = true;
// A Transform effect's copies of a path are subpaths of it, each with the path's anchors.
if counts(leaf.id)
&& let Some((_, copies, _, _)) = transform
@@ -1315,6 +1339,8 @@ impl Step<'_> {
*art = Node::group(id, vec![Arc::new(one)]);
}
let placed = if watch { masked_placed(art, own) } else { 0 };
+ // Images and symbol instances take the effect whatever the budget.
+ took = took || placed_count(art) > 0;
// Scale Strokes & Effects leaves the raster effects that follow the effect to
// [`follow_art`].
let mut scaling = copy_scaling(options);
@@ -1348,7 +1374,7 @@ impl Step<'_> {
}
let drawn = warped.as_deref().map_or_else(|| art.geometric_bounds(), |w| w.bounds(art));
let whole = (follows && !held).then(|| crate::Carry::of(&e.id, params, bounds, Some(bounds), drawn, seed));
- Ran { kept: !kept_ids.is_empty(), masks, short, whole }
+ Ran { kept: !kept_ids.is_empty(), took, masks, short, whole }
}
/// Run the effect over the art of the linked opacity mask of `n` (with `own`) and of each path,
@@ -1901,22 +1927,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 +1958,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) {
@@ -2066,9 +2100,14 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt
// effects before this one back from every Transform effect ([`crate::raster::followed`]).
let held_later = fx.get(at + 1..).unwrap_or_default().iter().any(crate::raster::holds_back);
// The effects before the Transform effect that it keeps, each raster effect following the
- // geometry effects before it ([`crate::raster::without_geometry`]) unless held back.
+ // geometry effects before it that moved the geometry ([`crate::raster::without_geometry`],
+ // [`crate::Carry::hide_skipped`]) unless held back.
+ let mut ran = std::borrow::Cow::Borrowed(before);
+ if carry.skipped() {
+ carry.hide_skipped(ran.to_mut());
+ }
let kept_before: Vec =
- if held_later { before.iter().filter(|e| !is_geometry(&e.id)).cloned().collect() } else { crate::without_geometry(before) };
+ if held_later { before.iter().filter(|e| !is_geometry(&e.id)).cloned().collect() } else { crate::without_geometry(&ran) };
if let Some(plugin) = painted_plugin(&fx[at]) {
let mut it = item.clone();
let rest: Vec = kept_before.iter().chain(&fx[at + 1..]).cloned().collect();
@@ -2093,7 +2132,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 +2155,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 +2168,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 +2177,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 +2210,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 +2422,137 @@ 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);
+ // A Transform effect left out (past the budget) leaves the path and its raster effects as they were.
+ if !carry.skipped() {
+ 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