diff --git a/Cargo.lock b/Cargo.lock index 287a23f0c..5faa6567a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1442,7 +1442,7 @@ dependencies = [ "itertools", "log", "profiling", - "resvg", + "resvg 0.45.1", ] [[package]] @@ -4438,11 +4438,29 @@ dependencies = [ "pico-args", "rgb", "svgtypes 0.15.3", - "tiny-skia", + "tiny-skia 0.11.4", "usvg 0.45.1", "zune-jpeg 0.4.21", ] +[[package]] +name = "resvg" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67e3803f97b999e80cbf7c6ecdd07a8102204d92e1633cf48783720c521196bd" +dependencies = [ + "bytemuck", + "gif 0.14.2", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes 0.16.1", + "tiny-skia 0.12.0", + "usvg 0.48.1", + "zune-jpeg 0.5.15", +] + [[package]] name = "rfd" version = "0.17.2" @@ -4727,7 +4745,7 @@ checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" dependencies = [ "log", "smithay-client-toolkit 0.19.2", - "tiny-skia", + "tiny-skia 0.11.4", ] [[package]] @@ -5593,6 +5611,21 @@ dependencies = [ "tiny-skia-path 0.11.4", ] +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png 0.18.1", + "tiny-skia-path 0.12.0", +] + [[package]] name = "tiny-skia-path" version = "0.11.4" @@ -6235,7 +6268,9 @@ version = "0.21.0" dependencies = [ "image", "kurbo 0.13.1", + "resvg 0.48.1", "serde_json", + "svgtypes 0.16.1", "vectorcraft-color", "vectorcraft-doc", "vectorcraft-geom", @@ -6491,7 +6526,7 @@ dependencies = [ "image", "kurbo 0.13.1", "proptest", - "resvg", + "resvg 0.45.1", "serde", "serde_json", "simplecss", diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index 6f41d65ee..0140d6e9c 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -1,6 +1,7 @@ //! `vectorcraft-cli`: VectorCraft from the command line. //! //! ```text +//! vectorcraft-cli --safe-mode SUBCOMMAND ... //! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]... //! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}'] | --commands FILE|-]... [--export out.svg]... [--scale 2] [--ppi 300] //! vectorcraft-cli commands @@ -72,6 +73,11 @@ const USAGE: &str = "\ vectorcraft-cli — VectorCraft automation USAGE: + vectorcraft-cli --safe-mode SUBCOMMAND ... + Run any subcommand without plug-ins: none loads or runs, and plug-in commands report that + Safe Mode is on. Plug-ins stay installed. `--safe-mode mcp` serves a headless session and + does not take --connect. + vectorcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]... [--allow-plugin-grants] Run the MCP server on stdio. Default: connect to a running app at 127.0.0.1:7979 (vectorcraft --control 7979), falling back to a headless in-process session. @@ -134,12 +140,17 @@ USAGE: Render FILE (any readable format) fitted to WxH (default 2880x1800) and print ms per frame (warm), multithreaded and single-threaded. - vectorcraft-cli perf [--paths N] [--deep] + vectorcraft-cli perf [--paths N] [--deep] [--native-io [--artboards 1..4]] Check the performance budgets (render, pan, hit test, save/load, SVG, Pathfinder, handle and segment drags) on a synthetic N-path document (default 50000). Exits non-zero if a budget is exceeded, unless the report says the machine is busy. --deep also times work with no budget yet and prints memory use (the working set on Windows), with the peak so far on Windows and Linux. + --native-io instead measures the application's native save/open commands (one warmup, + five samples, no disk I/O), including preview, PDF, compression and API base64 costs. + It reports the default and explicit PDF opt-out, with and without preview/compression; + --artboards repeats the same artwork + on 1 to 4 overlapping artboards. This mode allows at most 50000 paths. "; /// The usage text plus the formats `document.open` reads and `document.export` writes. @@ -181,12 +192,18 @@ fn main() -> ExitCode { #[cfg(target_os = "macos")] mac_fonts::install(); prefs_dir::install_fonts(); - let args: Vec = std::env::args().skip(1).collect(); + let mut args: Vec = std::env::args().skip(1).collect(); + // Before any subcommand attaches the plug-in folder. + let safe_mode = args.first().is_some_and(|a| a == "--safe-mode"); + if safe_mode { + args.remove(0); + vectorcraft_engine::cmd::plugin::start_in_safe_mode(); + } let r = match args.first().map(String::as_str) { // `commands` and `info` take no options, so `--help` or `-h` anywhere after them prints the // usage. The other subcommands check for it themselves. Some("commands" | "info") if help_requested(&args[1..], &[]) => help(), - Some("mcp") => mcp(&args[1..]), + Some("mcp") => mcp(&args[1..], safe_mode), Some("run") => run(&args[1..]), Some("commands") => commands(), Some("convert") => convert(&args[1..]), @@ -227,7 +244,9 @@ fn accepting_grants(mut h: Headless, allow: bool) -> Headless { h } -fn mcp(args: &[String]) -> Result<(), String> { +/// `mcp`. With `--safe-mode` (`safe_mode`) it serves a headless session: a running app loads its own +/// plug-ins, so `--connect` is refused and the default does not connect. +fn mcp(args: &[String], safe_mode: bool) -> Result<(), String> { if help_requested(args, &["--connect", "--automation-read-root", "--automation-write-root"]) { return help(); } @@ -259,6 +278,9 @@ fn mcp(args: &[String]) -> Result<(), String> { if allow_grants && connect.is_some() { return Err("--allow-plugin-grants applies to the headless server; in a running app a person approves each plug-in".into()); } + if safe_mode && connect.is_some() { + return Err("--safe-mode runs a headless session without plug-ins; a running app loads its own: start it with `vectorcraft --safe-mode --control `".into()); + } let read: Vec<&Path> = read_roots.iter().map(Path::new).collect(); let write: Vec<&Path> = write_roots.iter().map(Path::new).collect(); let roots = AutomationRoots::new(&read, &write)?; @@ -271,7 +293,7 @@ fn mcp(args: &[String]) -> Result<(), String> { file_access::confine_process(roots.clone()); eprintln!("vectorcraft-cli: files confined: {}", roots.describe()); Box::new(accepting_grants(Headless::with_document().with_automation_roots(Some(roots)), allow_grants)) - } else if headless || allow_grants { + } else if headless || safe_mode || allow_grants { Box::new(accepting_grants(with_stores(Headless::with_document()), allow_grants)) } else if let Some(addr) = connect { // Explicit address: fail loudly if the app isn't there. diff --git a/apps/vectorcraft-cli/src/perf.rs b/apps/vectorcraft-cli/src/perf.rs index 3ff82512d..e533d00b7 100644 --- a/apps/vectorcraft-cli/src/perf.rs +++ b/apps/vectorcraft-cli/src/perf.rs @@ -64,6 +64,61 @@ fn median_ms(runs: usize, mut f: impl FnMut()) -> f64 { v.get(v.len() / 2).copied().unwrap_or(0.0) } +/// Exercise the application's native save/open commands, including previews, PDF output, +/// compression and API base64 conversion. Session setup and validation are outside timing. +/// Five measured runs follow one warmup; no filesystem or GPU window is involved. +fn native_io(doc: &Document) -> Result<(), String> { + for (label, compatible, preview) in [ + ("default options", None, false), + ("PDF disabled", Some(false), false), + ("preview+gzip", None, true), + ("preview+gzip, PDF disabled", Some(false), true), + ] { + let mut session = vectorcraft_engine::Session::new(); + session.add_document(doc.clone(), None); + let mut params = json!({"format":"vectorcraft","modified":null,"preview":preview,"compress":preview}); + if let Some(value) = compatible { + params["pdfCompatible"] = json!(value); + } + let (mut saves, mut opens) = (Vec::new(), Vec::new()); + let (mut size, mut has_pdf, mut compressed) = (0, false, false); + for run in 0..6 { + let start = Instant::now(); + let saved = session.execute("file.saveAs", ¶ms).map_err(|e| e.to_string())?; + let save_ms = start.elapsed().as_secs_f64() * 1000.0; + let data = saved["dataBase64"].as_str().ok_or("native save returned no dataBase64")?; + let bytes = vectorcraft_format::base64_decode(data).ok_or("native save returned invalid base64")?; + size = bytes.len(); + has_pdf = vectorcraft_format::pdf_content(&bytes).is_some(); + if compatible == Some(false) && has_pdf { + return Err("native save ignored the explicit PDF opt-out".into()); + } + compressed = vectorcraft_format::is_compressed(&bytes); + if compressed != preview || vectorcraft_format::preview(&bytes).is_some() != preview { + return Err("native save did not apply the requested preview/compression options".into()); + } + let args = json!({"name":"perf.vectorcraft","dataBase64":data}); + let mut opened = vectorcraft_engine::Session::new(); + let start = Instant::now(); + opened.execute("document.open", &args).map_err(|e| e.to_string())?; + let open_ms = start.elapsed().as_secs_f64() * 1000.0; + if opened.doc().map_err(|e| e.to_string())?.doc.artboards.len() != doc.artboards.len() { + return Err("native round trip changed the artboard count".into()); + } + if run > 0 { + saves.push(save_ms); + opens.push(open_ms); + } + } + saves.sort_by(f64::total_cmp); + opens.sort_by(f64::total_cmp); + let save = saves.get(2).copied().ok_or("no save measurements")?; + let open = opens.get(2).copied().ok_or("no open measurements")?; + outln!(" native {label}: save {save:.2} ms, open {open:.2} ms, {size} bytes, PDF {has_pdf}, compressed {compressed}"); + } + Ok(()) +} + /// 1-minute load average (macOS / Linux), if available. #[cfg(not(windows))] fn load_average() -> Option { @@ -393,19 +448,36 @@ fn deep(n: usize) -> Result<(), String> { } pub fn run(args: &[String]) -> Result<(), String> { - if crate::help_requested(args, &["--paths"]) { + if crate::help_requested(args, &["--paths", "--artboards"]) { return crate::help(); } let mut n = 50_000usize; let mut want_deep = false; + let mut want_native = false; + let mut artboards = None::; let mut it = args.iter(); while let Some(a) = it.next() { match a.as_str() { "--deep" => want_deep = true, + "--native-io" => want_native = true, + "--artboards" => { + artboards = + Some(it.next().and_then(|v| v.parse().ok()).filter(|n| (1..=4).contains(n)).ok_or("--artboards needs a number from 1 to 4")?) + } "--paths" => n = it.next().and_then(|v| v.parse().ok()).ok_or("--paths needs a number")?, other => return Err(format!("unknown perf option `{other}`")), } } + if want_native && n > 50_000 { + return Err("--native-io allows at most 50000 paths".into()); + } + if want_native && want_deep { + return Err("--native-io and --deep are separate measurement modes".into()); + } + if artboards.is_some() && !want_native { + return Err("--artboards requires --native-io".into()); + } + let artboards = artboards.unwrap_or(1); let cores = std::thread::available_parallelism().map_or(1, |c| c.get()); // The figure is the 1-minute load average, or on Windows the cores busy over one second. #[cfg(not(windows))] @@ -418,8 +490,20 @@ pub fn run(args: &[String]) -> Result<(), String> { outln!("WARNING: the machine is busy; wall-clock timings below are not trustworthy."); } let t = Instant::now(); - let doc = synthetic(n)?; + let mut doc = synthetic(n)?; outln!(" (built in {:.0} ms)", t.elapsed().as_secs_f64() * 1000.0); + if want_native { + let first = doc.artboards.first().cloned().ok_or("synthetic document has no artboard")?; + // Overlapping artboards intentionally repeat the same paths on every PDF page. + for index in 1..artboards { + let mut page = first.clone(); + page.id = first.id.checked_add(index as u32).ok_or("artboard id overflow")?; + page.name = format!("Perf {}", index + 1); + doc.artboards.push(page); + } + outln!(" application native I/O: {artboards} overlapping artboard(s), one warmup + five samples; medians; no disk I/O"); + return native_io(&doc); + } let mut rows: Vec<(&str, f64, f64)> = vec![]; let opts = RenderOptions::default(); @@ -513,6 +597,24 @@ pub fn run(args: &[String]) -> Result<(), String> { mod tests { use super::*; + #[test] + fn native_io_exercises_the_application_save_and_open_commands() { + native_io(&synthetic(3).unwrap()).unwrap(); + } + + #[test] + fn native_io_rejects_conflicting_modes_and_excessive_workloads() { + for (args, expected) in [ + (vec!["--native-io", "--deep"], "separate measurement modes"), + (vec!["--native-io", "--paths", "50001"], "at most 50000"), + (vec!["--native-io", "--artboards", "5"], "1 to 4"), + (vec!["--artboards", "1"], "requires --native-io"), + (vec!["--artboards", "2"], "requires --native-io"), + ] { + assert!(run(&args.into_iter().map(str::to_owned).collect::>()).unwrap_err().contains(expected)); + } + } + #[test] fn busy_share_leaves_out_idle_time() { // In one second four processors log 40,000,000 ticks of 100 ns of kernel and user time, diff --git a/apps/vectorcraft-cli/tests/it/cli.rs b/apps/vectorcraft-cli/tests/it/cli.rs index 8053737b9..e7853700f 100644 --- a/apps/vectorcraft-cli/tests/it/cli.rs +++ b/apps/vectorcraft-cli/tests/it/cli.rs @@ -308,6 +308,22 @@ fn mcp_headless_over_stdio() { assert_eq!(replies[3]["result"]["content"][0]["type"], "image"); } +/// `--safe-mode mcp` serves a headless session without plug-ins: it refuses `--connect`, and by +/// default it does not connect to an app listening on the default address, which loads its own +/// plug-ins. +#[test] +fn safe_mode_mcp_does_not_connect_to_a_running_app() { + let out = Command::new(BIN).args(["--safe-mode", "mcp", "--connect", "127.0.0.1:9"]).env("VECTORCRAFT_NO_PREFS", "1").output().unwrap(); + let err = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success() && err.contains("--safe-mode runs a headless session"), "{err}"); + // Something listening on the default address, as a running app does; a running app or another + // program already holding it serves as well. + let _app = std::net::TcpListener::bind(vectorcraft_mcp::DEFAULT_ADDR); + let out = Command::new(BIN).args(["--safe-mode", "mcp"]).env("VECTORCRAFT_NO_PREFS", "1").stdin(Stdio::null()).output().unwrap(); + let err = String::from_utf8_lossy(&out.stderr); + assert!(out.status.success() && err.contains("MCP server on stdio (headless)"), "{err}"); +} + /// `mcp --automation-read-root` / `--automation-write-root` (#832): the flags are checked before the /// server starts, and the process then reads and writes only inside its roots. #[test] diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs index 3d9fef45c..2dfc4e6f4 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -8,6 +8,7 @@ use super::windows_stylus; use super::{App, VectorcraftApp, file_access, window}; #[cfg(target_os = "macos")] use super::{mac_menu, mac_window, open_documents}; +use vectorcraft_ui_egui::dialogs::safe_mode; /// The parts of [`App::raw_input_hook`] that every window needs, for the OS windows of floating /// panels: a click or Escape that closes the macOS menu bar's open menu, and the pen barrel @@ -113,16 +114,26 @@ impl App { vectorcraft_ui_egui::background::wait_all(&mut self.app); return; } + // An app that stays in Safe Mode leaves later launches to start their own app with plug-ins. + #[cfg(not(target_os = "macos"))] + if safe_mode::staying_on(&self.app) { + single_instance::resign(); + } // Files from the Finder and the Dock (macOS) or from later launches (#4892) wait while a file - // dialog is open: what asked for it runs again on its document when it answers. - if !self.app.file_dialog_open() { + // dialog is open: what asked for it runs again on its document when it answers. They also + // wait for the choice about a stopped plug-in, so they open after the plug-ins it loads, as + // do the command line's files held back for it. Files later launches handed over before the + // choice ended in Safe Mode open in a new app with plug-ins. + if !self.app.file_dialog_open() && !safe_mode::choosing(&self.app) { + let held = std::mem::take(&mut self.held_files); #[cfg(target_os = "macos")] let files = open_documents::take(); #[cfg(not(target_os = "macos"))] - let files = single_instance::take(); + let files = single_instance::take_for_frame(safe_mode::staying_on(&self.app), single_instance::launch_with_plug_ins); if !files.is_empty() { bring_window_forward(ctx); } + open_files(&mut self.app, held); open_files(&mut self.app, files); } let (now, zoom) = (ctx.input(|i| i.time), ctx.zoom_factor()); @@ -151,13 +162,15 @@ impl App { /// On macOS with accessibility, end the process once the app has saved what it keeps on quitting, /// before eframe tears the window down (#661). AccessKit then gives the window's content view back /// its own class, which AppKit's Touch Bar support was observing it under, and AppKit aborts the -/// app as it quits on Macs with a Touch Bar. Nothing after the window is needed on a normal quit. +/// app as it quits on Macs with a Touch Bar. On a normal quit, only the launch marker is needed +/// after the window, so it is ended here as `main` ends it after the window on other platforms. /// Not while the window is still starting up (`frames` below [`gpu::STARTUP_FRAMES`]): a graphics /// failure then starts the app again on another adapter (#651). #[allow(unused_variables)] fn end_before_teardown(frames: u64) { #[cfg(all(target_os = "macos", feature = "accessibility", feature = "wgpu"))] if frames >= gpu::STARTUP_FRAMES { + safe_mode::end_launch(); log::info!("quitting"); log::logger().flush(); std::process::exit(0); @@ -172,6 +185,16 @@ pub(crate) fn bring_window_forward(ctx: &egui::Context) { ctx.send_viewport_cmd(egui::ViewportCommand::Focus); } +/// Open the command line's `files` now, or, while the choice about a stopped plug-in is up +/// ([`safe_mode::choosing`]), return them to open once it is made, with the plug-ins it loads. +pub(crate) fn open_or_hold(app: &mut VectorcraftApp, files: Vec) -> Vec { + if safe_mode::choosing(app) { + return files; + } + open_files(app, files); + Vec::new() +} + /// Open files handed to the app (command line, macOS Finder and Dock, later launches) as documents. A file that /// can't be opened is reported to the user and on stderr; the others still open. pub(crate) fn open_files(app: &mut VectorcraftApp, files: Vec) { diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 112c71370..b3a1fd06b 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -1,7 +1,10 @@ //! VectorCraft desktop app. //! //! Usage: `vectorcraft [--control [--automation-read-root ]... [--automation-write-root ]...] -//! [--in-window-menus] [--new-instance] [files…]`, `vectorcraft --version`, `vectorcraft --help` (or `-h`) +//! [--in-window-menus] [--new-instance] [--safe-mode] [--] [files…]`, `vectorcraft --version`, `vectorcraft --help` (or `-h`) +//! +//! Any other argument starting with '-' (not `-` alone) is an error; after `--`, every argument is +//! a file. //! //! Files given while VectorCraft already runs open in that window, unless `--new-instance` is given //! (Windows and Linux; `single_instance`). @@ -65,6 +68,7 @@ mod xwayland; use vectorcraft_engine::Session; use vectorcraft_engine::cmd::fileio; use vectorcraft_engine::file_access::{self, AutomationRoots}; +use vectorcraft_ui_egui::dialogs::safe_mode; use vectorcraft_ui_egui::graphics::GraphicsLoss; use vectorcraft_ui_egui::picks::PickRequest; use vectorcraft_ui_egui::{ClipboardProbeFactory, FilePick, Services, VectorcraftApp}; @@ -77,7 +81,7 @@ mod titlebar; #[cfg(all(test, feature = "wgpu"))] use app::bring_window_forward; use app::native_input_hook; -use app::open_files; +use app::open_or_hold; use host_services::services; #[cfg(all(test, feature = "wgpu"))] use prefs::{KEEP_UI_PREFS, decode_saved_prefs, keep_prefs_copy, prefs_bytes}; @@ -92,7 +96,9 @@ use titlebar::custom_titlebar_from; use titlebar::opted_into_system_title_bar; #[cfg(windows)] use titlebar::set_taskbar_icon; -use titlebar::{custom_titlebar, native_options}; +#[cfg(all(test, feature = "wgpu"))] +use titlebar::{app_draws_frame, opted_into_app_title_bar, start_frame}; +use titlebar::{custom_titlebar, native_options, titlebar_from_env}; struct App { app: VectorcraftApp, @@ -111,6 +117,8 @@ struct App { pending_maximize: bool, /// Frames left to retry [`window::apply_pending_maximize`]. maximize_tries: u8, + /// The command line's files, held back until the choice about a stopped plug-in is made. + held_files: Vec, /// The time and UI zoom of the last monitor list ([`window::monitors_due`]). monitors_listed: Option<(f64, f32)>, } @@ -132,13 +140,89 @@ Options: (or VECTORCRAFT_IN_WINDOW_MENUS=1) --new-instance Open the files in a new window even when VectorCraft already runs (Windows and Linux) + --safe-mode Start without plug-ins: none loads or runs until + VectorCraft starts normally (Help › Safe Mode); the + files open in a new window, as with --new-instance --version Print the version and exit -h, --help Print this help and exit + -- Treat every argument after it as a file + +Any other argument starting with '-' is an error. Without a window (converting, scripting, the MCP server), use vectorcraft-cli: vectorcraft-cli --help "; +/// The command line's options other than the automation roots and the control port. +struct CommandLine { + files: Vec, + in_window_menus: bool, + new_instance: bool, + /// `--safe-mode`: no plug-in loads or runs in this launch. + safe_mode: bool, +} + +/// Reads the arguments after the program name. The automation roots and the control port go into +/// `roots_args` and `control`. `Err` is the exit code when the process ends here: `--version` and +/// `--help` print and succeed; a bad automation option or an unknown option prints an error and fails. +fn read_command_line( + mut args: impl Iterator, + control: &mut automation_args::ControlArg, + roots_args: &mut automation_args::AutomationArgs, +) -> Result { + let mut files = Vec::new(); + let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0"); + let mut new_instance = false; + let mut safe_mode = false; + let mut unknown = None; + while let Some(a) = args.next() { + // After `--`, every argument is a file, even one whose name starts with '-'. + if a == "--" { + files.extend(args.by_ref()); + break; + } + match roots_args.consume(&a, &mut args).and_then(|root| if root { Ok(true) } else { control.consume(&a, &mut args) }) { + Ok(true) => continue, + Ok(false) => {} + Err(e) => { + eprintln!("vectorcraft: {e}"); + return Err(std::process::ExitCode::FAILURE); + } + } + match a.as_str() { + "--in-window-menus" => in_window_menus = true, + "--new-instance" => new_instance = true, + "--safe-mode" => safe_mode = true, + "--version" => { + println!("vectorcraft {}", env!("CARGO_PKG_VERSION")); + return Err(std::process::ExitCode::SUCCESS); + } + // Before anything starts, like `--version`: from a terminal or an agent's shell the app + // used to open (or hang without a display) instead (#2110). + "--help" | "-h" => { + // Not `print!`, which panics when standard output is closed (`| head -0`): + // there is nothing left to tell then. + let _ = std::io::Write::write_all(&mut std::io::stdout(), USAGE.as_bytes()); + return Err(std::process::ExitCode::SUCCESS); + } + // The process serial number older macOS versions pass to an app the Finder starts. + #[cfg(target_os = "macos")] + _ if a.starts_with("-psn_") => {} + // An option this version doesn't know (a typo such as `--safe-mod`) is an error, not a + // file to open. Reported after the loop, so a later `--help` or `--version` still wins. + _ if a.starts_with('-') && a != "-" => { + unknown.get_or_insert(a); + } + _ => files.push(a), + } + } + if let Some(a) = unknown { + eprintln!("vectorcraft: unknown option {a}; see vectorcraft --help, or put -- before a file whose name starts with '-'"); + return Err(std::process::ExitCode::FAILURE); + } + Ok(CommandLine { files, in_window_menus, new_instance, safe_mode }) +} + fn main() -> std::process::ExitCode { // Windows: the update helper is a copy of this program started with `--finish-update`. #[cfg(windows)] @@ -154,37 +238,11 @@ fn main() -> std::process::ExitCode { let root_env = |name: &str| std::env::var_os(name).filter(|v| !v.is_empty()).map(std::path::PathBuf::from); let mut roots_args = automation_args::AutomationArgs::new(root_env("VECTORCRAFT_AUTOMATION_READ_ROOT"), root_env("VECTORCRAFT_AUTOMATION_WRITE_ROOT")); - let mut files = Vec::new(); - let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0"); - let mut new_instance = false; - let mut args = std::env::args().skip(1); - while let Some(a) = args.next() { - match roots_args.consume(&a, &mut args).and_then(|root| if root { Ok(true) } else { control.consume(&a, &mut args) }) { - Ok(true) => continue, - Ok(false) => {} - Err(e) => { - eprintln!("vectorcraft: {e}"); - return std::process::ExitCode::FAILURE; - } - } - match a.as_str() { - "--in-window-menus" => in_window_menus = true, - "--new-instance" => new_instance = true, - "--version" => { - println!("vectorcraft {}", env!("CARGO_PKG_VERSION")); - return std::process::ExitCode::SUCCESS; - } - // Before anything starts, like `--version`: from a terminal or an agent's shell the app - // used to open (or hang without a display) instead (#2110). - "--help" | "-h" => { - // Not `print!`, which panics when standard output is closed (`| head -0`): - // there is nothing left to tell then. - let _ = std::io::Write::write_all(&mut std::io::stdout(), USAGE.as_bytes()); - return std::process::ExitCode::SUCCESS; - } - _ => files.push(a), - } - } + let CommandLine { files, in_window_menus, new_instance, safe_mode: safe_mode_flag } = + match read_command_line(std::env::args().skip(1), &mut control, &mut roots_args) { + Ok(command_line) => command_line, + Err(code) => return code, + }; let automation_args::AutomationArgs { read_roots, write_roots, .. } = roots_args; // After the arguments: `--control` and `--version` win over a bad environment value. let control_port = match control.port { @@ -202,10 +260,10 @@ fn main() -> std::process::ExitCode { // Files opened from the file manager while VectorCraft runs open in its window (#4892). Before // the log file is attached: a launch that hands its files over leaves the running app's log be. #[cfg(not(target_os = "macos"))] - let instance = match single_instance::start(control_port.is_some(), new_instance, &files) { + match single_instance::start(control_port.is_some(), new_instance, safe_mode_flag, &files) { single_instance::Start::Forwarded => return std::process::ExitCode::SUCCESS, - single_instance::Start::Running(primary) => primary, - }; + single_instance::Start::Running(primary) => single_instance::hold(primary), + } #[cfg(target_os = "macos")] let _ = new_instance; vectorcraft_ui_egui::i18n::detect_system_lang_in_background(); @@ -396,10 +454,15 @@ fn main() -> std::process::ExitCode { let mut session = Session::new(); // The profiles loaded before come back before the preferences name any of them. prefs_dir::attach_profile_store(&mut session); + // Before any plug-in loads: this launch's marker in the Launch folder next to the + // preferences (none for runs without preferences), and Safe Mode. + let launch_dir = prefs_path().filter(|_| prefs_enabled()).and_then(|p| Some(p.parent()?.join("Launch"))); + let stopped = safe_mode::begin_launch(launch_dir.as_deref(), safe_mode_flag); prefs_dir::attach_plugin_store(&mut session); let mut app = VectorcraftApp::new(session, services(cc.winit_window().cloned(), cc.egui_ctx.clone())); app.set_startup_update_check(true); load_prefs(&mut app, saved); + safe_mode::after_start(&mut app, stopped); // The saved PDF presets live in their own folder (also read by vectorcraft-cli), // which takes over from the copy the preferences file used to hold. prefs_dir::attach_pdf_preset_store(&mut app.session); @@ -466,6 +529,7 @@ fn main() -> std::process::ExitCode { let (loss, ctx) = (graphics_loss.clone(), cc.egui_ctx.clone()); rs.device.set_device_lost_callback(move |reason, msg| loss.report(&ctx, format!("{reason:?}: {msg}"))); } + app.titlebar_from_env = titlebar_from_env(); app.custom_titlebar = custom_titlebar; app.desktop_frame = !custom_titlebar; if let Some(port) = control_port { @@ -492,7 +556,7 @@ fn main() -> std::process::ExitCode { single_instance::set_ui(&cc.egui_ctx); } app.services.native_input_hook = native_input_hook(&app); - file_access::unconfined(|| open_files(&mut app, files)); + let held_files = file_access::unconfined(|| open_or_hold(&mut app, files)); // The first frame is due from now (`gpu::watch_first_frame`). #[cfg(feature = "wgpu")] created.ready(); @@ -508,6 +572,7 @@ fn main() -> std::process::ExitCode { winit_window, pending_maximize, maximize_tries: 8, + held_files, monitors_listed: None, })) }), @@ -516,7 +581,9 @@ fn main() -> std::process::ExitCode { // The window is gone: later launches start their own app, including the one a graphics // failure starts again below. #[cfg(not(target_os = "macos"))] - drop(instance); + single_instance::resign(); + // The process ends normally from here: this launch didn't stop while a plug-in was loading. + safe_mode::end_launch(); #[cfg(feature = "wgpu")] let outcome = gpu::finish(outcome, &startup); #[cfg(not(feature = "wgpu"))] diff --git a/apps/vectorcraft/src/single_instance.rs b/apps/vectorcraft/src/single_instance.rs index 5ced4aef3..37a836f6f 100644 --- a/apps/vectorcraft/src/single_instance.rs +++ b/apps/vectorcraft/src/single_instance.rs @@ -12,8 +12,12 @@ //! as its own app, which replaces a stale file. Nothing received is ever run. //! //! Runs that start many apps side by side (`--control`, `VECTORCRAFT_NO_PREFS`) neither forward -//! nor listen, and `--new-instance` opens a launch's files in an app of its own. macOS hands files -//! opened from Finder to the running app itself (`open_documents`), so this is off there. +//! nor listen, and neither does a `--safe-mode` launch, which opens its files in an app of its own +//! and leaves later launches to start theirs with plug-ins. `--new-instance` opens a launch's files +//! in an app of its own. An app that stays in Safe Mode after the choice about a stopped plug-in +//! stops listening ([`resign`]), and files later launches handed it before then open in a new app +//! with plug-ins ([`take_for_frame`]). macOS hands files opened from Finder to the running app itself +//! (`open_documents`), so this is off there. use std::fs::{self, OpenOptions}; use std::hash::{BuildHasher as _, RandomState}; @@ -43,6 +47,8 @@ const CLIENT_TIMEOUT: Duration = Duration::from_secs(5); /// Paths handed over by later launches that the UI hasn't opened yet. static PENDING: Mutex> = Mutex::new(Vec::new()); +/// This app while it is the one later launches hand their files to ([`hold`]). +static HELD: Mutex> = Mutex::new(None); /// The UI to wake when files arrive (unset until the window exists; the first frame opens them). static UI: OnceLock = OnceLock::new(); @@ -56,23 +62,46 @@ pub enum Start { /// Hand `files` to the app already running, or become the app later launches hand theirs to. /// `automation`: started with `--control`. -pub fn start(automation: bool, new_instance: bool, files: &[String]) -> Start { +pub fn start(automation: bool, new_instance: bool, safe_mode: bool, files: &[String]) -> Start { if automation || !prefs_enabled() { return Start::Running(None); } let Some(path) = prefs_path().and_then(|p| Some(p.parent()?.join(FILE_NAME))) else { return Start::Running(None) }; + start_at(&path, new_instance, safe_mode, files) +} + +/// [`start`] with the endpoint file `path`. A launch with `--new-instance` or `--safe-mode` keeps +/// its files and runs as its own app: a running app would open them with its plug-ins loaded. A +/// `--safe-mode` launch never becomes the app later launches hand their files to, which would open +/// them without plug-ins. +fn start_at(path: &Path, new_instance: bool, safe_mode: bool, files: &[String]) -> Start { + if safe_mode { + return Start::Running(None); + } // With nothing to hand over, the same exchange only asks whether an app is running. let send: &[String] = if new_instance { &[] } else { files }; - match forward(&path, send) { + match forward(path, send) { Ok(()) if send.is_empty() => Start::Running(None), Ok(()) => Start::Forwarded, Err(e) => { log::debug!("no running VectorCraft to hand files to ({e})"); - Start::Running(listen(&path, queue)) + Start::Running(listen(path, queue)) } } } +/// Keep `primary` ([`start`]'s) until [`resign`]. +pub fn hold(primary: Option) { + *HELD.lock().unwrap_or_else(PoisonError::into_inner) = primary; +} + +/// Later launches start their own app from now on: the endpoint file goes. The app calls it when +/// its window is gone, and when it stays in Safe Mode after the choice about a stopped plug-in. +pub fn resign() { + let primary = HELD.lock().unwrap_or_else(PoisonError::into_inner).take(); + drop(primary); +} + /// Wake `ctx` when files arrive from later launches. pub fn set_ui(ctx: &egui::Context) { // Set once; a second call would hand over the same context. @@ -84,6 +113,43 @@ pub fn take() -> Vec { std::mem::take(&mut *PENDING.lock().unwrap_or_else(PoisonError::into_inner)) } +/// The paths later launches handed over since the last call, to open in this app. An app that +/// stays in Safe Mode (`safe_mode`) resigns and hands them to a new app with plug-ins (`launch`, +/// [`launch_with_plug_ins`] in the app) instead: they arrived while it was the primary, before the +/// choice about a stopped plug-in ended in Safe Mode, or just before it resigned. When `launch` +/// fails (logged), they open here. +pub fn take_for_frame(safe_mode: bool, launch: impl FnOnce(&[String]) -> io::Result<()>) -> Vec { + let files = take(); + if !safe_mode { + return files; + } + // First, so the new app becomes the primary instead of handing the files back. + resign(); + if files.is_empty() { + return files; + } + match launch(&files) { + Ok(()) => Vec::new(), + Err(e) => { + log::warn!("couldn't start VectorCraft with plug-ins for the files another launch opened: {e}"); + files + } + } +} + +/// Start this program again, with plug-ins, on `files`. +pub fn launch_with_plug_ins(files: &[String]) -> io::Result<()> { + app_with_plug_ins(&std::env::current_exe()?, files).spawn().map(drop) +} + +/// The command [`launch_with_plug_ins`] runs: `exe -- files…`, so a file whose name starts with '-' +/// stays a file. +fn app_with_plug_ins(exe: &Path, files: &[String]) -> std::process::Command { + let mut command = std::process::Command::new(exe); + command.arg("--").args(files); + command +} + fn queue(files: Vec) { PENDING.lock().unwrap_or_else(PoisonError::into_inner).extend(files); if let Some(ctx) = UI.get() { @@ -263,6 +329,13 @@ mod tests { use super::*; + /// Held by the tests that use the process-wide [`HELD`] and [`PENDING`], one at a time. + static GLOBALS: Mutex<()> = Mutex::new(()); + + fn globals() -> std::sync::MutexGuard<'static, ()> { + GLOBALS.lock().unwrap_or_else(PoisonError::into_inner) + } + struct TestDir(tempfile::TempDir); impl std::ops::Deref for TestDir { @@ -313,6 +386,72 @@ mod tests { assert!(!path.exists(), "the endpoint file goes with the app"); } + #[test] + fn a_new_instance_or_safe_mode_launch_keeps_its_files() { + let (path, _dir, file) = folder("own"); + let (primary, rx) = primary(&path); + for (new_instance, safe_mode) in [(true, false), (false, true)] { + let start = start_at(&path, new_instance, safe_mode, std::slice::from_ref(&file)); + assert!(matches!(start, Start::Running(None)), "runs as its own app (new instance {new_instance}, Safe Mode {safe_mode})"); + } + assert!(matches!(start_at(&path, false, false, std::slice::from_ref(&file)), Start::Forwarded)); + assert_eq!(rx.recv_timeout(CLIENT_TIMEOUT).unwrap(), std::slice::from_ref(&file)); + assert!(rx.try_recv().is_err(), "only the plain launch handed its file over"); + drop(primary); + } + + #[test] + fn a_safe_mode_launch_leaves_later_launches_their_own_app() { + let _globals = globals(); + let (path, _dir, file) = folder("safe-mode-alone"); + // With no app running, a Safe Mode launch runs without listening. + assert!(matches!(start_at(&path, false, true, std::slice::from_ref(&file)), Start::Running(None))); + assert!(!path.exists(), "a Safe Mode launch records no endpoint"); + // A plain launch after it starts its own app and takes later launches' files. + let Start::Running(Some(primary)) = start_at(&path, false, false, std::slice::from_ref(&file)) else { + panic!("the plain launch runs and listens") + }; + assert!(path.exists()); + // An app that stays in Safe Mode after the choice gives that up. + hold(Some(primary)); + resign(); + assert!(!path.exists(), "the endpoint file goes with resign"); + } + + #[test] + fn files_handed_to_an_app_staying_in_safe_mode_go_to_a_new_app_with_plug_ins() { + let _globals = globals(); + // Nothing left over from a test that failed. + resign(); + drop(take()); + let (path, _dir, file) = folder("safe-mode-later"); + let Start::Running(Some(primary)) = start_at(&path, false, false, &[]) else { panic!("the first launch listens") }; + hold(Some(primary)); + // A later launch handed its file over while the choice about a stopped plug-in was up. + queue(vec![file.clone()]); + let mut launched = Vec::new(); + let here = take_for_frame(true, |files| { + launched = files.to_vec(); + Ok(()) + }); + assert!(here.is_empty(), "nothing opens in the Safe Mode app"); + assert_eq!(launched, std::slice::from_ref(&file), "a new app with plug-ins opens it"); + assert!(!path.exists(), "the app resigned before starting the new one"); + // When the new app can't start, the file opens here rather than being lost. + queue(vec![file.clone()]); + assert_eq!(take_for_frame(true, |_| Err(io::Error::other("no program"))), std::slice::from_ref(&file)); + // An app with plug-ins opens what it is handed. + queue(vec![file.clone()]); + assert_eq!(take_for_frame(false, |_| panic!("no new app")), [file]); + } + + #[test] + fn the_new_app_takes_every_name_as_a_file() { + let command = app_with_plug_ins(Path::new("vectorcraft"), &["-a.svg".to_owned(), "b.svg".to_owned()]); + let args: Vec<_> = command.get_args().collect(); + assert_eq!(args, ["--", "-a.svg", "b.svg"]); + } + #[test] fn a_wrong_token_garbage_and_oversized_messages_are_refused() { let (path, _dir, file) = folder("refused"); diff --git a/apps/vectorcraft/src/tests.rs b/apps/vectorcraft/src/tests.rs index 116651fc8..555d829e0 100644 --- a/apps/vectorcraft/src/tests.rs +++ b/apps/vectorcraft/src/tests.rs @@ -36,6 +36,33 @@ fn shortcut_overrides_from_an_earlier_version_load() { assert!(km.problems().iter().any(|p| format!("{p:?}") == r#"UnknownCommand { key: "test.unknown" }"#), "reported"); } +/// The command line's files wait for the choice about a stopped plug-in, so they open with the +/// plug-ins it loads; with no choice up they open at once. +#[test] +fn command_line_files_wait_for_the_choice_about_a_stopped_plug_in() { + let temp = tempfile::Builder::new().prefix("vectorcraft-held-files-").tempdir().unwrap(); + let file = temp.path().join("a.svg"); + std::fs::write(&file, r#""#).unwrap(); + let files = vec![file.to_string_lossy().into_owned()]; + // The files the app reads to open them. + let read = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let reads = read.clone(); + let services = vectorcraft_ui_egui::Services { + read: Some(Box::new(move |p: &str| { + reads.borrow_mut().push(p.to_owned()); + std::fs::read(p).map_err(|e| e.to_string()) + })), + ..Default::default() + }; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), services); + app.ui.stopped_plugin = Some(serde_json::json!({"id": "test.held", "name": "Held"})); + assert_eq!(open_or_hold(&mut app, files.clone()), files, "held while the choice is up"); + assert!(read.borrow().is_empty(), "nothing opened before the choice"); + app.ui.stopped_plugin = None; + assert!(open_or_hold(&mut app, files.clone()).is_empty()); + assert_eq!(*read.borrow(), files, "opened with no choice up"); +} + #[test] fn opening_files_unminimizes_and_focuses_the_window() { let ctx = egui::Context::default(); @@ -164,6 +191,60 @@ fn the_system_title_bar_preference_keeps_the_window_decorations() { assert_eq!(super::native_options(true).viewport.decorations, Some(false)); } +/// Preferences › User Interface › VectorCraft Title Bar on Every Desktop gives the app frame where +/// the probe chose the desktop's (KDE, X11, a window opened through XWayland); without it the probe +/// decides, and `VECTORCRAFT_TITLEBAR` takes precedence over both. +#[test] +fn the_app_title_bar_preference_gives_the_app_frame_on_desktop_frames() { + assert!(!super::opted_into_app_title_bar(None), "no preferences: the default"); + let off = ui_state_with_prefs(serde_json::json!({"appTitleBar": false})); + assert!(!super::opted_into_app_title_bar(Some(&off))); + let on = ui_state_with_prefs(serde_json::json!({"appTitleBar": true})); + assert!(super::opted_into_app_title_bar(Some(&on))); + + // (probed_app, overridden, forced_to_x11): without the preference, then with it. + let cases = [ + ((false, None, false), false, true), // KDE, Sway, X11: the desktop frame + ((true, None, false), true, true), // GNOME on Wayland: the app frame either way + ((true, None, true), false, true), // GNOME, the window opened through XWayland + ((false, Some(true), false), true, true), // VECTORCRAFT_TITLEBAR=app + ((false, Some(true), true), true, true), // VECTORCRAFT_TITLEBAR=app, the window opened through XWayland + ((true, Some(false), false), false, false), // VECTORCRAFT_TITLEBAR=desktop takes precedence over the preference + ((false, Some(false), true), false, false), + ]; + for ((probed_app, overridden, forced_to_x11), without, with) in cases { + assert_eq!(super::app_draws_frame(probed_app, overridden, forced_to_x11, false), without, "{probed_app} {overridden:?} {forced_to_x11}"); + assert_eq!(super::app_draws_frame(probed_app, overridden, forced_to_x11, true), with, "{probed_app} {overridden:?} {forced_to_x11}"); + } +} + +/// The frame a start uses: the preference turns the desktop frame the probe chose into the app frame +/// with the default window buttons, a probed app frame keeps its own buttons, and the desktop frame +/// stays where the app frame is not drawn. System Title Bar takes precedence over the preference. +#[test] +fn the_start_frame_applies_the_app_title_bar_preference() { + use craft_window::{Button, Frame, Layout}; + let gnome = Layout { left: vec![Button::Close], right: vec![] }; + let cases = [ + // (probed, overridden, forced_to_x11, opted_into_app), expected + ((Frame::Desktop, None, false, false), Frame::Desktop), + ((Frame::Desktop, None, false, true), Frame::App(Layout::default())), + ((Frame::Desktop, None, true, true), Frame::App(Layout::default())), + ((Frame::App(gnome.clone()), None, false, false), Frame::App(gnome.clone())), + ((Frame::App(gnome.clone()), None, true, false), Frame::Desktop), + ((Frame::App(gnome.clone()), None, true, true), Frame::App(gnome.clone())), + ((Frame::Desktop, Some(false), false, true), Frame::Desktop), + ((Frame::App(gnome.clone()), Some(true), true, false), Frame::App(gnome.clone())), + ]; + for ((probed, overridden, forced_to_x11, opted_into_app), expected) in cases { + let label = format!("{probed:?} {overridden:?} {forced_to_x11} {opted_into_app}"); + assert_eq!(super::start_frame(probed, overridden, forced_to_x11, opted_into_app), expected, "{label}"); + } + // With both preferences on, System Title Bar takes precedence: the desktop draws the frame. + let both = super::start_frame(Frame::Desktop, None, false, true); + assert!(!super::custom_titlebar_from(&both, true), "System Title Bar takes precedence over the app title bar preference"); +} + /// The file extensions the macOS bundle declares: its document types and its own exported type. fn plist_extensions(plist: &str) -> Vec<&str> { ["CFBundleTypeExtensions", "public.filename-extension"] diff --git a/apps/vectorcraft/src/titlebar.rs b/apps/vectorcraft/src/titlebar.rs index 63c9f7c13..6e7451ed9 100644 --- a/apps/vectorcraft/src/titlebar.rs +++ b/apps/vectorcraft/src/titlebar.rs @@ -46,6 +46,53 @@ pub(crate) fn opted_into_system_title_bar(saved: Option<&vectorcraft_ui_egui::Ui saved.map(|ui| ui.engine_prefs.get("systemTitleBar").and_then(serde_json::Value::as_bool).unwrap_or(false)).unwrap_or(false) } +/// Whether the user asked for VectorCraft's own title bar in Preferences › User Interface › +/// VectorCraft Title Bar on Every Desktop (Linux and the BSDs). Read like +/// [`opted_into_system_title_bar`]; a missing or unreadable value is `false` (the default). +pub(crate) fn opted_into_app_title_bar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool { + saved.map(|ui| ui.engine_prefs.get("appTitleBar").and_then(serde_json::Value::as_bool).unwrap_or(false)).unwrap_or(false) +} + +/// Whether the app draws the frame on this start, before the System Title Bar preference is +/// applied ([`custom_titlebar_from`]). `probed_app` is the `craft-window` probe's answer and +/// `overridden` its `VECTORCRAFT_TITLEBAR` override. In order: the override decides when it is +/// set; VectorCraft Title Bar on Every Desktop (`opted_into_app`) gives the app frame; a window +/// that opens through XWayland (`forced_to_x11`) gets the desktop frame; otherwise the probe +/// decides. +pub(crate) fn app_draws_frame(probed_app: bool, overridden: Option, forced_to_x11: bool, opted_into_app: bool) -> bool { + match overridden { + Some(app) => app, + None => opted_into_app || (probed_app && !forced_to_x11), + } +} + +/// The frame this start uses, before the System Title Bar preference is applied +/// ([`custom_titlebar_from`]): [`app_draws_frame`] applied to the frame the probe chose. A probed +/// app frame keeps its window-button layout; an app frame the probe did not choose gets the default +/// layout, since `custom_titlebar_from` reads only which frame it is. +pub(crate) fn start_frame(probed: craft_window::Frame, overridden: Option, forced_to_x11: bool, opted_into_app: bool) -> craft_window::Frame { + let probed_app = matches!(probed, craft_window::Frame::App(_)); + match (app_draws_frame(probed_app, overridden, forced_to_x11, opted_into_app), probed) { + (true, frame @ craft_window::Frame::App(_)) => frame, + (true, craft_window::Frame::Desktop) => craft_window::Frame::App(craft_window::Layout::default()), + (false, _) => craft_window::Frame::Desktop, + } +} + +/// The `craft-window` probe's answer for this process (its override variable is +/// `VECTORCRAFT_TITLEBAR`). The probe runs once; on a Wayland session it opens a connection. +fn probed_choice() -> &'static craft_window::Choice { + static CHOICE: std::sync::OnceLock = std::sync::OnceLock::new(); + CHOICE.get_or_init(|| craft_window::Choice::probe(craft_window::Prefix::new("VECTORCRAFT"))) +} + +/// Whether `VECTORCRAFT_TITLEBAR` chose this window's frame (Linux and the BSDs), so Preferences › +/// User Interface › VectorCraft Title Bar on Every Desktop does not apply while it is set. Always +/// `false` on macOS and Windows. +pub(crate) fn titlebar_from_env() -> bool { + cfg!(not(any(target_os = "macos", target_os = "windows"))) && probed_choice().overridden().is_some() +} + /// Whether this start draws its own title bar, given the frame the shared `craft-window` crate /// decided and the user's System Title Bar preference. /// @@ -82,24 +129,27 @@ pub(crate) fn custom_titlebar_from(_frame: &craft_window::Frame, opted_into_syst /// On Linux, Preferences › Performance › Linux Display Server › X11 opens the window through /// XWayland (below). A GNOME/Wayland probe would say `Frame::App` there, but the window really runs /// on X11, whose window manager draws the frame — so when XWayland is being forced, keep the native -/// frame, the same as a real X11 session. An explicit `VECTORCRAFT_TITLEBAR` override still wins. +/// frame, the same as a real X11 session. An explicit `VECTORCRAFT_TITLEBAR` override still wins, +/// and so does VectorCraft Title Bar on Every Desktop ([`app_draws_frame`]). pub(crate) fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool { - let choice = craft_window::Choice::probe(craft_window::Prefix::new("VECTORCRAFT")); + let choice = probed_choice(); let opted_into_system = opted_into_system_title_bar(saved); + // macOS and Windows never read VectorCraft Title Bar on Every Desktop (`custom_titlebar_from` + // ignores the frame there). + let opted_into_app = cfg!(not(any(target_os = "macos", target_os = "windows"))) && opted_into_app_title_bar(saved); #[cfg(target_os = "linux")] - { + // When the window will actually open on X11 (XWayland) — the user asked for it *and* the X11 + // libraries are present, the same two conditions the launch path checks below — that run is + // effectively X11, so keep the native frame. If the libraries are missing, startup stays on + // Wayland, so the probe's own decision (app-drawn on GNOME) must stand. + let forced_to_x11 = { let pref = saved.and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str); - // `overridden() == None` means the probe decided (no VECTORCRAFT_TITLEBAR). When the window - // will actually open on X11 (XWayland) — the user asked for it *and* the X11 libraries are - // present, the same two conditions the launch path checks below — that run is effectively - // X11, so keep the native frame. If the libraries are missing, startup stays on Wayland, so - // the probe's own decision (app-drawn on GNOME) must stand. - let forced_to_x11 = xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) && xwayland::x11_libraries_found(); - if choice.overridden().is_none() && forced_to_x11 { - return custom_titlebar_from(&craft_window::Frame::Desktop, opted_into_system); - } - } - custom_titlebar_from(choice.frame(), opted_into_system) + xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) && xwayland::x11_libraries_found() + }; + #[cfg(not(target_os = "linux"))] + let forced_to_x11 = false; + let frame = start_frame(choice.frame().clone(), choice.overridden(), forced_to_x11, opted_into_app); + custom_titlebar_from(&frame, opted_into_system) } pub(crate) fn native_options(custom_titlebar: bool) -> eframe::NativeOptions { diff --git a/apps/vectorcraft/tests/it/help.rs b/apps/vectorcraft/tests/it/help.rs index 059b9c476..ece66b98a 100644 --- a/apps/vectorcraft/tests/it/help.rs +++ b/apps/vectorcraft/tests/it/help.rs @@ -54,7 +54,7 @@ fn prints_usage(args: &[&str], env: &[(&str, &str)]) { assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr)); assert!(out.stderr.is_empty(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr)); assert!(stdout.starts_with("Usage: vectorcraft "), "{args:?}: {stdout}"); - for flag in ["--control", "--automation-read-root", "--automation-write-root", "--in-window-menus", "--version", "--help"] { + for flag in ["--control", "--automation-read-root", "--automation-write-root", "--in-window-menus", "--safe-mode", "--version", "--help"] { assert!(stdout.contains(flag), "{args:?}: usage doesn't mention {flag}: {stdout}"); } // Headless work is the CLI's: the usage says where to go. @@ -74,3 +74,27 @@ fn help_prints_usage_and_exits_before_startup() { fn help_wins_over_a_bad_control_port_variable() { prints_usage(&["--help"], &[("VECTORCRAFT_CONTROL_PORT", "abc")]); } + +/// An option the app doesn't know (a typo of `--safe-mode`) is an error, not a file to open with +/// plug-ins; a later `--help` still wins. +#[test] +fn an_unknown_option_is_an_error_and_not_a_file() { + for args in [&["--safe-mod"][..], &["drawing.svg", "--safe-mod"], &["-x"]] { + let out = run(args, &[]); + assert!(!out.status.success(), "{args:?}"); + assert!(out.stdout.is_empty(), "{args:?}"); + let stderr = String::from_utf8(out.stderr).unwrap(); + let unknown = args.iter().find(|a| a.starts_with('-')).unwrap(); + assert_eq!( + stderr, + format!("vectorcraft: unknown option {unknown}; see vectorcraft --help, or put -- before a file whose name starts with '-'\n"), + "{args:?}" + ); + } + prints_usage(&["--safe-mod", "--help"], &[]); + // After `--`, a name starting with '-' is a file: the run goes on to the control port, which + // `run` makes fail. + let out = run(&["--", "-x.svg"], &[]); + let stderr = String::from_utf8(out.stderr).unwrap(); + assert!(stderr.contains("VECTORCRAFT_CONTROL_PORT") && !stderr.contains("unknown option"), "{stderr}"); +} diff --git a/crates/cad/src/lib.rs b/crates/cad/src/lib.rs index 3fc4bcb3b..603521160 100644 --- a/crates/cad/src/lib.rs +++ b/crates/cad/src/lib.rs @@ -308,6 +308,78 @@ impl Default for DxfOptions { } } +/// A named set of DXF options (the `dxfPreset` param): the options it sets, and the ones a set of +/// options matches. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum DxfPreset { + /// The options as chosen (the export's defaults for those left out). + #[default] + Custom, + /// For CNC and CAM cutting software: version 2000 (the oldest version that has both lightweight + /// polylines and lineweights), 1 mm = 1 unit, 256 indexed colors (all version 2000 holds), Maximize + /// Editability with Alter Paths off (every stroke a center line), Outline Text (type as paths), + /// and placed images as PNG. + Cnc, +} + +impl DxfPreset { + pub const ALL: [Self; 2] = [Self::Custom, Self::Cnc]; + + /// The `dxfPreset` param value. + pub fn id(self) -> &'static str { + match self { + Self::Custom => "custom", + Self::Cnc => "cnc", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Custom => "Custom", + Self::Cnc => "CNC / CAM", + } + } + + /// A preset by id (`custom`, `cnc`), any case. + pub fn from_id(s: &str) -> Option { + Self::ALL.into_iter().find(|p| s.trim().eq_ignore_ascii_case(p.id())) + } + + /// The options the preset sets (Custom: the defaults); the region and crop are the defaults. + pub fn options(self) -> DxfOptions { + match self { + Self::Custom => DxfOptions::default(), + Self::Cnc => DxfOptions { + version: DxfVersion::R2000, + unit: Unit::Millimeters, + scale: 1.0, + scale_lineweights: false, + colors: ColorDepth::Aci256, + raster: RasterFormat::Png, + preserve: Preserve::Editability, + alter_paths: false, + outline_text: true, + ..DxfOptions::default() + }, + } + } + + /// The preset `o` matches: CNC / CAM when every option it sets is as it sets it, else Custom. + pub fn of(o: &DxfOptions) -> Self { + let c = Self::Cnc.options(); + let same = o.version == c.version + && o.unit == c.unit + && o.scale == c.scale + && o.scale_lineweights == c.scale_lineweights + && o.colors == c.colors + && o.raster == c.raster + && o.preserve == c.preserve + && o.alter_paths == c.alter_paths + && o.outline_text == c.outline_text; + if same { Self::Cnc } else { Self::Custom } + } +} + /// An image file a drawing links to, to be written next to it under `name`. #[derive(Clone, Debug)] pub struct DxfImage { diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index d99858511..5c57fa634 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -823,6 +823,30 @@ fn units_scale_coordinates_and_set_insunits() { assert!(export(&d, &DxfOptions { scale: f64::NAN, ..opts(&d) }).is_err()); } +#[test] +fn the_cnc_preset_writes_version_2000_in_millimeters_with_type_as_paths_and_strokes_as_lines() { + let mut d = text_doc("Cut", 20.0); + let layer = d.layers[0].id; + let frame = path(&mut d, shapes::rectangle(Rect::new(5.0, 5.0, 195.0, 95.0)), Paint::None, Paint::solid(Color::BLACK), 10.0); + d.insert(Some(layer), usize::MAX, frame).unwrap(); + let o = DxfOptions { region: d.artboards[0].rect, ..DxfPreset::Cnc.options() }; + let out = export(&d, &o).unwrap(); + let p = pairs(&out.bytes); + assert_eq!(header(&p, "$ACADVER").as_deref(), Some("AC1015")); + assert_eq!(header(&p, "$INSUNITS").as_deref(), Some("4")); + let k = kinds(&out); + assert!(!k.iter().any(|k| matches!(k.as_str(), "TEXT" | "MTEXT" | "IMAGE")), "type is paths: {k:?}"); + assert!(k.len() > 2, "the glyphs' outlines: {k:?}"); + // The 10 pt stroke (wider than the widest lineweight) is one center line, not a filled outline. + assert_eq!(k.last().map(String::as_str), Some("LWPOLYLINE"), "{k:?}"); + assert_ne!(k.get(k.len() - 2).map(String::as_str), Some("LWPOLYLINE"), "{k:?}"); + assert!(out.images.is_empty()); + assert_eq!(DxfPreset::of(&o), DxfPreset::Cnc); + assert_eq!(DxfPreset::of(&DxfOptions { colors: ColorDepth::True, ..o.clone() }), DxfPreset::Custom); + assert_eq!(DxfPreset::of(&DxfOptions::default()), DxfPreset::Custom); + assert_eq!(DxfPreset::from_id(" CNC "), Some(DxfPreset::Cnc)); +} + #[test] fn layers_become_dxf_layers() { let mut d = shapes_doc(); diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 81b9aaaf5..09e8154c9 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -296,6 +296,28 @@ impl Appearance { scale(e, s); } } + /// The id of Effect › SVG Filters' effect, whose `origin` parameter is a point of the document. + pub const SVG_FILTER: &str = "svg.filter"; + + /// Does any effect (the object's or a fill's or stroke's) hold an SVG filter? + pub fn has_svg_filter(&self) -> bool { + self.effects.iter().chain(self.items.iter().flat_map(|i| i.effects().iter())).any(|e| e.id == Self::SVG_FILTER) + } + + /// Move the `origin` of every SVG filter effect (where its user space starts in the document) + /// by `a`, as the object it is on moves. + pub fn transform_svg_filters(&mut self, a: kurbo::Affine) { + let items = self.items.iter_mut().flat_map(|i| i.effects_mut().iter_mut()); + for e in self.effects.iter_mut().chain(items).filter(|e| e.id == Self::SVG_FILTER) { + let at = + |i: usize| e.params.get("origin").and_then(|o| o.get(i)).and_then(serde_json::Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0); + let p = a * kurbo::Point::new(at(0), at(1)); + if p.x.is_finite() && p.y.is_finite() && e.params.is_object() { + e.params["origin"] = serde_json::json!([p.x, p.y]); + } + } + } + /// Gradient paints of the fills and strokes. fn gradients_mut(&mut self) -> impl Iterator { self.items.iter_mut().filter_map(|i| match i { diff --git a/crates/doc/src/artboard.rs b/crates/doc/src/artboard.rs index 6d5d50d4d..d7bfcafca 100644 --- a/crates/doc/src/artboard.rs +++ b/crates/doc/src/artboard.rs @@ -106,6 +106,60 @@ impl super::Document { d.layers.insert(0, Arc::new(layer)); Some(d) } + + /// Where an artboard the size of artboard `i` goes on `side` of it, [`ARTBOARD_GAP`] away, + /// when there is room for it there: every side within [`crate::MAX_COORD`] of the origin and + /// no other artboard overlapping it (touching edges don't overlap). `None` when there is no + /// such artboard or no room. + pub fn room_beside_artboard(&self, i: usize, side: ArtboardSide) -> Option { + let src = self.artboards.get(i)?.rect; + let r = src + side.offset(src); + let in_canvas = [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= crate::MAX_COORD); + let clear = self.artboards.iter().all(|a| !(r.x0 < a.rect.x1 && a.rect.x0 < r.x1 && r.y0 < a.rect.y1 && a.rect.y0 < r.y1)); + (in_canvas && clear).then_some(r) + } +} + +/// The space between an artboard and one added or duplicated beside it (VectorCraft's own value, +/// the space New Artboard and Duplicate Artboards leave right of the last artboard). +pub const ARTBOARD_GAP: f64 = 20.0; + +/// A side of an artboard, where an artboard added or duplicated beside it goes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ArtboardSide { + Left, + Right, + Top, + Bottom, +} + +impl ArtboardSide { + pub const ALL: [Self; 4] = [Self::Top, Self::Right, Self::Bottom, Self::Left]; + + /// The side a command's `side` param names: "left", "right", "top" or "bottom". + pub fn parse(name: &str) -> Option { + Self::ALL.into_iter().find(|s| s.name() == name) + } + + pub fn name(self) -> &'static str { + match self { + Self::Left => "left", + Self::Right => "right", + Self::Top => "top", + Self::Bottom => "bottom", + } + } + + /// How far a copy of `r` moves to sit on this side of it, [`ARTBOARD_GAP`] away (y down). + pub fn offset(self, r: Rect) -> vectorcraft_geom::Vec2 { + let (w, h) = (r.width() + ARTBOARD_GAP, r.height() + ARTBOARD_GAP); + match self { + Self::Left => vectorcraft_geom::Vec2::new(-w, 0.0), + Self::Right => vectorcraft_geom::Vec2::new(w, 0.0), + Self::Top => vectorcraft_geom::Vec2::new(0.0, -h), + Self::Bottom => vectorcraft_geom::Vec2::new(0.0, h), + } + } } /// A document's locked artboards ([`Document::locked_artboards`]). diff --git a/crates/doc/src/assets.rs b/crates/doc/src/assets.rs index 3c4451d54..824f3ebea 100644 --- a/crates/doc/src/assets.rs +++ b/crates/doc/src/assets.rs @@ -11,12 +11,16 @@ use serde::{Deserialize, Serialize}; use crate::{Document, NodeId}; /// One asset: what it is called (its files' names) and the objects it exports, cropped to their -/// visual bounds. +/// visual bounds, or for a 3D asset its object's solids as a mesh file. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct ExportAsset { pub id: u64, pub name: String, pub nodes: Vec, + /// A 3D asset's mesh format (`usda`, `usdz`, `gltf` or `obj`): its object exports as a 3D + /// file. `None` for art exported as pictures. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub format_3d: Option, } /// What the assets are named when they aren't named after their object: `Asset 1`, `Asset 2`… diff --git a/crates/doc/src/canvas_range.rs b/crates/doc/src/canvas_range.rs index cf85507c7..114235696 100644 --- a/crates/doc/src/canvas_range.rs +++ b/crates/doc/src/canvas_range.rs @@ -313,8 +313,8 @@ mod tests { d.guides = vec![Guide { layer: Some(NodeId(40)), ..Guide::new(false, 5.0) }, Guide { layer: Some(d.layers[0].id), ..Guide::new(true, 5.0) }]; d.saved_selections = vec![crate::records::SavedSelection { name: "s".into(), objects: vec![NodeId(10), NodeId(11)] }].into(); d.assets = vec![ - crate::assets::ExportAsset { id: 1, name: "far".into(), nodes: vec![NodeId(11)] }, - crate::assets::ExportAsset { id: 2, name: "both".into(), nodes: vec![NodeId(10), NodeId(11)] }, + crate::assets::ExportAsset { id: 1, name: "far".into(), nodes: vec![NodeId(11)], format_3d: None }, + crate::assets::ExportAsset { id: 2, name: "both".into(), nodes: vec![NodeId(10), NodeId(11)], format_3d: None }, ]; d.variables.bindings.insert(NodeId(11), ["v".to_string()].into()); d.variables.bindings.insert(NodeId(10), ["w".to_string()].into()); diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 78f90dac6..287a6b2aa 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -68,6 +68,7 @@ impl Document { print_setup, assets, svg_scripts, + svg_filters, puppet, new_art, dimensions, @@ -135,6 +136,7 @@ impl Document { && *print_setup == other.print_setup && *assets == other.assets && *svg_scripts == other.svg_scripts + && *svg_filters == other.svg_filters && match (puppet, &other.puppet) { (Some(a), Some(b)) => Arc::ptr_eq(a, b), (a, b) => a.is_none() && b.is_none(), @@ -245,6 +247,7 @@ impl Document { print_setup: None, assets: vec![], svg_scripts: vec![], + svg_filters: vec![], puppet: None, new_art: Default::default(), dimensions: false, diff --git a/crates/doc/src/document_ext.rs b/crates/doc/src/document_ext.rs index efd7f80b2..c40dcf9f2 100644 --- a/crates/doc/src/document_ext.rs +++ b/crates/doc/src/document_ext.rs @@ -123,6 +123,39 @@ impl Document { std::borrow::Cow::Owned(d) } + /// Does `wants` hold for a node anywhere in the document's art: the layers, symbol definitions, + /// graph designs, pattern swatches (the one being edited included) and their opacity masks? + pub fn any_node(&self, wants: impl Fn(&Node) -> bool) -> bool { + let roots = self + .layers + .iter() + .chain(self.symbols.iter().map(|s| &s.art)) + .chain(self.graph_designs.iter().map(|g| &g.art)) + .chain(self.patterns.iter().flat_map(|p| p.art.iter())) + .chain(self.pattern_edit.iter().filter_map(|pe| pe.original.as_ref()).flat_map(|p| p.art.iter())); + let mut any = false; + for root in roots { + crate::swatches::walk_with_masks(root, &mut |n| any |= wants(n)); + } + any + } + + /// Change with `f` every node for which `wants` holds, anywhere in the document's art (as + /// [`Self::any_node`] looks). Only the trees that hold such a node are copied out of the art + /// they share. + pub fn edit_nodes_where(&mut self, wants: impl Fn(&Node) -> bool, mut f: impl FnMut(&mut Node)) { + let roots = self + .layers + .iter_mut() + .chain(self.symbols.iter_mut().map(|s| &mut s.art)) + .chain(self.graph_designs.iter_mut().map(|g| &mut g.art)) + .chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) + .chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut())); + for root in roots { + edit_branches_where(root, &wants, &mut f); + } + } + /// Round every path's anchors and handles to `decimals` decimal places: path objects (with /// Live Corners' uncut outlines), blend spines, envelopes' top-object outlines and Intertwine /// areas, in the layers, symbol definitions, graph designs, pattern swatches (the one being @@ -164,7 +197,7 @@ impl Document { } } NodeKind::Envelope { kind, .. } => { - if let EnvelopeKind::TopObject { path } = kind.as_mut() { + if let EnvelopeKind::TopObject { path, .. } = kind.as_mut() { round_path(path); } } @@ -194,6 +227,26 @@ impl Document { } } +/// Change with `f` the nodes of `n`'s tree (opacity-mask art included) for which `wants` holds, +/// copying out of the shared art only the branches that hold one. +fn edit_branches_where(n: &mut Arc, wants: &impl Fn(&Node) -> bool, f: &mut impl FnMut(&mut Node)) { + let mut any = false; + crate::swatches::walk_with_masks(n, &mut |c| any |= wants(c)); + if !any { + return; + } + let node = Arc::make_mut(n); + if wants(node) { + f(node); + } + if let Some(m) = node.extras_if_any().and_then(|e| e.mask.as_mut()) { + edit_branches_where(&mut m.art, wants, f); + } + for c in node.children_mut().into_iter().flatten() { + edit_branches_where(c, wants, f); + } +} + /// Round with `round` the anchors of the paths in `v`, nodes in their file form kept as JSON: every /// `{"subpaths": [{"anchors": [{"p", "in", "out"}]}]}` outside type (`{"type": "text"}`), as /// [`Document::round_geometry`] rounds the nodes it holds. diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index b3b4394a2..ea3575bf6 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -936,6 +936,257 @@ fn hit_any(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option } } +/// The art of a symbol instance with its own appearance changes, in the symbol's coordinates, and +/// how the canvas places each object of it: through the symbol's 9-slice guides when the instance +/// is scaled by them ([`Document::place_symbol_art`]), otherwise by the instance's transform. +struct InstanceArt { + art: Node, + map: Option, + xf: vectorcraft_geom::Affine, +} + +impl InstanceArt { + /// The art of symbol instance `inst`. `None` for anything else, a missing symbol, or art that + /// placing through the guides could make more than [`crate::MAX_SYMBOL_NODES`] objects of, + /// which [`Document::place_symbol_art`] does not place and the canvas does not draw. + fn of(doc: &Document, inst: &Node) -> Option { + let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return None }; + let sym = doc.symbols.iter().find(|s| s.name == *symbol)?; + let art = crate::symbol_overrides::dynamic_art(&sym.art, inst); + let map = doc.symbol_slice_map(symbol, *xf); + if map.as_ref().is_some_and(|m| m.most_placed(&art, doc) > crate::MAX_SYMBOL_NODES) { + return None; + } + Some(Self { art, map, xf: *xf }) + } + + /// The map that the objects inside group or layer `n` are moved by as one piece: `whole`, the + /// map of an object around `n` that is moved as one piece, or else the map of the 9-slice cell + /// `n` lies in ([`crate::NineSliceMap::one_cell`]). `None` when `n` is placed through the guides + /// object by object. + fn whole(&self, n: &Node, doc: &Document, whole: Option) -> Option { + whole.or_else(|| self.map.as_ref()?.one_cell(n, doc)) + } + + /// Object `n` of the art placed on the canvas as it is drawn, clip groups included: moved by + /// `whole` when an object around it is moved as one piece ([`Self::whole`]). `None` when + /// placing it through the guides could make more than [`crate::MAX_SYMBOL_NODES`] objects, as + /// [`Document::place_symbol_art`] refuses it. + fn place(&self, n: &Node, doc: &Document, whole: Option) -> Option { + match (&self.map, whole) { + (_, Some(a)) => Some(moved(n, a)), + (Some(m), None) => (m.most_placed(n, doc) <= crate::MAX_SYMBOL_NODES).then(|| m.place(n, doc)), + (None, None) => Some(moved(n, self.xf)), + } + } + + /// Object `n` of the art placed on the canvas without clip groups + /// ([`crate::NineSliceMap::place_unclipped`]), as [`Self::place`] places it and within the + /// same limit ([`Document::place_symbol_art_unclipped`]). + fn place_unclipped(&self, n: &Node, doc: &Document, whole: Option) -> Option { + match (&self.map, whole) { + (_, Some(a)) => Some(moved(n, a)), + (Some(m), None) => (m.most_placed_unclipped(n, doc) <= crate::MAX_SYMBOL_NODES).then(|| m.place_unclipped(n, doc)), + (None, None) => Some(moved(n, self.xf)), + } + } +} + +/// `n` transformed by `xf`, its strokes at their own weight. +fn moved(n: &Node, xf: vectorcraft_geom::Affine) -> Node { + let mut out = n.clone(); + out.transform(xf, false); + out +} + +/// The object of symbol instance `inst`'s art under `p`, for Direct Selection on a dynamic symbol's +/// instance: its path of child indices from the art's root ([`crate::symbol_overrides::part`]), the +/// topmost object hit inside the art's groups. With [`HitOptions::clip_outlines`], a press on the +/// outline of the clipping path of a clip group or a clipping layer picks that path before the art +/// it clips, as [`hit_test`] does outside symbols. Hidden, locked and template objects are passed +/// over, as [`hit_test`] passes over them when it looks for editable objects, and so are a Shaper +/// group's original shapes, which the canvas does not draw, and guide paths with +/// [`HitOptions::skip_guides`]. Nothing is picked in art that placing through the symbol's 9-slice +/// guides could make more than [`crate::MAX_SYMBOL_NODES`] objects of, which the canvas does not +/// draw. `None` when `inst` is not a symbol instance or no editable object of its art is under `p`. +pub fn symbol_part_at(doc: &Document, inst: &Node, p: Point, opt: HitOptions) -> Option> { + struct Find<'a> { + doc: &'a Document, + art: &'a InstanceArt, + p: Point, + opt: HitOptions, + symbols: &'a SymbolBoxes<'a>, + } + impl Find<'_> { + fn hit(&self, n: &Node, whole: Option) -> bool { + self.art.place(n, self.doc, whole).is_some_and(|n| hit_any(&n, self.p, self.opt, self.symbols).is_some()) + } + fn skips_guide(&self, n: &Node) -> bool { + self.opt.skip_guides && matches!(n.kind, NodeKind::Path { guide: true, .. }) + } + fn find(&self, n: &Node, path: &mut Vec, whole: Option) -> bool { + if !n.visible || n.locked || n.is_template() || self.skips_guide(n) { + return false; + } + let (NodeKind::Group { children, .. } | NodeKind::Layer { children, .. }) = &n.kind else { return self.hit(n, whole) }; + if path.len() >= crate::symbol_overrides::MAX_PATH_DEPTH { + return self.hit(n, whole); + } + let whole = self.art.whole(n, self.doc, whole); + if n.clips() + && let Some(first) = children.first() + { + let Some(clip) = self.art.place_unclipped(first, self.doc, whole) else { return false }; + if self.opt.clip_outlines + && first.visible + && !first.locked + && !self.skips_guide(first) + && clip_outline(&clip, self.p, self.opt).is_some() + { + path.push(0); + return true; + } + if !clip_contains(&clip, self.p) { + return false; + } + } + for (i, c) in children.iter().enumerate().rev() { + // A Shaper group's original shapes are not drawn; symbol art has no isolation scope. + if shaper_hides(n, i, None) { + continue; + } + let Ok(i) = u32::try_from(i) else { continue }; + path.push(i); + if self.find(c, path, whole) { + return true; + } + path.pop(); + } + false + } + } + let art = InstanceArt::of(doc, inst)?; + let symbols = doc.symbol_boxes(); + let mut path = vec![]; + Find { doc, art: &art, p, opt, symbols: &symbols }.find(&art.art, &mut path, None).then_some(path) +} + +/// The outlines of the object at `path` in symbol instance `inst`'s art (see [`symbol_part_at`]), +/// placed on the canvas as it is drawn: its paths' outlines, and the bounds of objects without a +/// path. Inside a clip group (an object drawn once in each 9-slice cell it reaches is one clip +/// group per cell) they are cut to the bounds of the group's clip path, which is not outlined. An +/// object inside a clip group or a clipping layer of the art, other than its clipping path, is cut +/// to the bounds of that clipping path as well, which contain the region where [`symbol_part_at`] +/// picks it. +/// `None` for art past the 9-slice object limit that [`symbol_part_at`] applies. +pub fn symbol_part_outline(doc: &Document, inst: &Node, path: &[u32]) -> Option { + let art = InstanceArt::of(doc, inst)?; + let part = crate::symbol_overrides::part(&art.art, path)?; + let mut out = BezPath::new(); + let mut limit = None; + let mut whole = None; + let mut n = &art.art; + for &i in path { + let children = n.children()?; + whole = art.whole(n, doc, whole); + if i != 0 + && n.clips() + && let Some(first) = children.first() + && let Some(clip) = art.place_unclipped(first, doc, whole)?.geometric_bounds() + { + match meet(Some(clip), limit) { + Some(r) => limit = Some(r), + None => return Some(out), + } + } + n = children.get(usize::try_from(i).ok()?)?; + } + outline_within(&art.place(part, doc, whole)?, limit, &mut out); + Some(out) +} + +/// [`symbol_part_outline`] for placed object `n`, cut to `limit` when there is one. +fn outline_within(n: &Node, limit: Option, out: &mut BezPath) { + use vectorcraft_geom::kurbo::Shape; + if let Some(pd) = n.path_data() { + match limit { + Some(r) => push_cut(out, &pd.to_bezpath(), r), + None => out.extend(pd.to_bezpath()), + } + } else if n.children().is_none() + && let Some(b) = n.geometric_bounds() + && let Some(b) = meet(Some(b), limit) + { + out.extend(b.to_path(0.1)); + } + let Some(children) = n.children() else { return }; + if n.clips() + && let Some((first, rest)) = children.split_first() + { + let inner = match first.geometric_bounds() { + Some(c) => match meet(Some(c), limit) { + Some(r) => Some(r), + None => return, + }, + None => limit, + }; + rest.iter().for_each(|c| outline_within(c, inner, out)); + return; + } + children.iter().for_each(|c| outline_within(c, limit, out)); +} + +/// The overlap of `a` and `b` (`a` alone without `b`), `None` when they do not overlap. +fn meet(a: Option, b: Option) -> Option { + let a = a?; + let Some(b) = b else { return Some(a) }; + let r = Rect::new(a.x0.max(b.x0), a.y0.max(b.y0), a.x1.min(b.x1), a.y1.min(b.y1)); + (r.x0 <= r.x1 && r.y0 <= r.y1).then_some(r) +} + +/// The parts of path `p`, flattened to lines, that lie inside `r`, appended to `out`. +fn push_cut(out: &mut BezPath, p: &BezPath, r: Rect) { + use vectorcraft_geom::kurbo::PathEl; + let (mut at, mut start, mut last) = (Point::ZERO, Point::ZERO, None::); + vectorcraft_geom::kurbo::flatten(p.iter(), 0.1, |el| { + let to = match el { + PathEl::MoveTo(q) => { + (at, start, last) = (q, q, None); + return; + } + PathEl::LineTo(q) => q, + PathEl::ClosePath => start, + _ => return, + }; + if let Some((a, b)) = cut_segment(at, to, r) { + if last != Some(a) { + out.move_to(a); + } + out.line_to(b); + last = Some(b); + } + at = to; + }); +} + +/// The part of segment `a` to `b` inside `r`, `None` when none of it is. +fn cut_segment(a: Point, b: Point, r: Rect) -> Option<(Point, Point)> { + let d = b - a; + let (mut t0, mut t1) = (0.0_f64, 1.0_f64); + for (p, q) in [(-d.x, a.x - r.x0), (d.x, r.x1 - a.x), (-d.y, a.y - r.y0), (d.y, r.y1 - a.y)] { + if p.abs() < 1e-12 { + if q < 0.0 { + return None; + } + } else if p < 0.0 { + t0 = t0.max(q / p); + } else { + t1 = t1.min(q / p); + } + } + (t0 <= t1).then(|| (a + d * t0, a + d * t1)) +} + /// The path of compound path `n` under `p`, for the Group Selection tool: the one whose outline is /// within `tol`, else the innermost one whose area holds `p` (a hole inside an outer shape, an /// island inside a hole). `None` when `n` isn't a compound path or no part is under `p`. diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index 9a5c40478..495dc227b 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -1373,3 +1373,128 @@ fn cull_box_holds_the_reach_of_paths_and_compound_paths() { assert!(holds(cull, reach), "{cull:?} does not hold {reach:?}"); assert_eq!(checked, 201); } + +/// In a dynamic symbol's art, a layer with a clipping mask clips its objects as a clip group does: +/// Direct Selection picks an object only where the mask shows it, and outlines it there. +#[test] +fn an_object_a_clipping_layer_in_symbol_art_hides_is_not_picked_there() { + let mut d = Document::new(300.0, 200.0); + let l = d.layers[0].id; + let (mask, art) = half_clipped(&mut d); + let mut layer = Node::layer(d.alloc_id(), "Masked", crate::LayerColor::Preset(2)); + if let NodeKind::Layer { children, .. } = &mut layer.kind { + *children = vec![Arc::new(mask), Arc::new(art)]; + } + layer.set_clips(true); + let root = Node::group(d.alloc_id(), vec![Arc::new(layer)]); + d.symbols.push(crate::Symbol { dynamic: true, name: "Clipped".into(), art: Arc::new(root) }); + let inst = d.alloc_id(); + d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Clipped".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap(); + let n = d.node(inst).unwrap(); + let opt = HitOptions::default(); + assert_eq!(symbol_part_at(&d, n, Point::new(50.0, 50.0), opt), Some(vec![0, 1]), "where the mask shows the art"); + assert_eq!(symbol_part_at(&d, n, Point::new(150.0, 50.0), opt), None, "where the mask hides it"); + let b = vectorcraft_geom::kurbo::Shape::bounding_box(&symbol_part_outline(&d, n, &[0, 1]).unwrap()); + assert!((b.x1 - 100.0).abs() < 1e-6, "the outline is cut to the mask: {b:?}"); + // Its outline inside the group around the layer is cut too. + let b = vectorcraft_geom::kurbo::Shape::bounding_box(&symbol_part_outline(&d, n, &[0]).unwrap()); + assert!((b.x1 - 100.0).abs() < 1e-6, "the layer's outline is cut to the mask: {b:?}"); +} + +/// In a dynamic symbol's art that placing through its 9-slice guides could make more than +/// [`crate::MAX_SYMBOL_NODES`] objects of, Direct Selection picks and outlines nothing, as the +/// canvas draws nothing of it. At the art's natural size the same art is picked. +#[test] +fn symbol_art_past_the_object_limit_picks_nothing() { + use vectorcraft_color::{Gradient, GradientPaint}; + let mut d = Document::new(400.0, 400.0); + let l = d.layers[0].id; + let leaf = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art())); + let inner = Arc::new(Node::group(d.alloc_id(), vec![leaf; 1_800])); + let mut mask = crate::OpacityMask::new(Node::group(d.alloc_id(), vec![inner; 1_800]), true); + mask.linked = false; + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + // Drawn in six cells, each with its own copy of the mask's 3,240,000 squares. + let mut big = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), Appearance::basic(gradient, Paint::None, 0.0)); + big.extras_mut().mask = Some(Box::new(mask)); + let plain = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 50.0, 100.0, 100.0)), Appearance::default_art()); + let root = Node::group(d.alloc_id(), vec![Arc::new(plain), Arc::new(big)]); + d.symbols.push(crate::Symbol { dynamic: true, name: "Big".into(), art: Arc::new(root) }); + d.set_symbol_nine_slice("Big", Some(crate::SymbolNineSlice::default())); + let scaled = d.alloc_id(); + let natural = d.alloc_id(); + let xf = vectorcraft_geom::Affine::scale_non_uniform(2.0, 1.5); + d.insert(Some(l), 0, Node::new(scaled, NodeKind::SymbolInstance { symbol: "Big".into(), xf })).unwrap(); + d.insert(Some(l), 0, Node::new(natural, NodeKind::SymbolInstance { symbol: "Big".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap(); + assert!(d.place_symbol_art("Big", &d.symbols[0].art, xf).is_none(), "the art is past the limit"); + let opt = HitOptions::default(); + let n = d.node(scaled).unwrap(); + assert_eq!(symbol_part_at(&d, n, Point::new(100.0, 110.0), opt), None, "the plain square"); + assert_eq!(symbol_part_at(&d, n, Point::new(100.0, 40.0), opt), None, "the masked path"); + assert!(symbol_part_outline(&d, n, &[0]).is_none()); + assert!(symbol_part_outline(&d, n, &[1]).is_none()); + let n = d.node(natural).unwrap(); + assert_eq!(symbol_part_at(&d, n, Point::new(50.0, 75.0), opt), Some(vec![0])); + assert!(symbol_part_outline(&d, n, &[0]).is_some()); +} + +/// A clip group that lies in one 9-slice cell is moved by that cell as one piece, everything in it +/// included, so an object in it is picked and outlined there even when placing that object through +/// the guides on its own would pass [`crate::MAX_SYMBOL_NODES`] objects. +#[test] +fn objects_of_a_clip_group_in_one_cell_are_placed_with_it() { + use vectorcraft_color::{Gradient, GradientPaint}; + let mut d = Document::new(400.0, 400.0); + let l = d.layers[0].id; + let leaf = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art())); + let inner = Arc::new(Node::group(d.alloc_id(), vec![leaf; 1_800])); + let mut mask = crate::OpacityMask::new(Node::group(d.alloc_id(), vec![inner; 1_800]), true); + mask.linked = false; + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let mut big = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(gradient, Paint::None, 0.0)); + big.extras_mut().mask = Some(Box::new(mask)); + let clip = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default_art()); + let mut group = Node::group(d.alloc_id(), vec![Arc::new(clip), Arc::new(big.clone())]); + group.set_clips(true); + let frame = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); + let root = Node::group(d.alloc_id(), vec![Arc::new(frame), Arc::new(group)]); + d.symbols.push(crate::Symbol { dynamic: true, name: "Corner".into(), art: Arc::new(root) }); + d.set_symbol_nine_slice("Corner", Some(crate::SymbolNineSlice::default())); + let inst = d.alloc_id(); + let xf = vectorcraft_geom::Affine::scale_non_uniform(2.0, 1.5); + d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Corner".into(), xf })).unwrap(); + let m = d.symbol_slice_map("Corner", xf).unwrap(); + assert!(m.most_placed(&big, &d) > crate::MAX_SYMBOL_NODES, "the object alone is past the limit"); + assert!(d.place_symbol_art("Corner", &d.symbols[0].art, xf).is_some(), "the art is drawn"); + let n = d.node(inst).unwrap(); + assert_eq!(symbol_part_at(&d, n, Point::new(10.0, 10.0), HitOptions::default()), Some(vec![1, 1])); + let b = vectorcraft_geom::kurbo::Shape::bounding_box(&symbol_part_outline(&d, n, &[1, 1]).unwrap()); + assert!((b.x1 - 20.0).abs() < 1e-6 && (b.y1 - 20.0).abs() < 1e-6, "cut to the clip in its corner: {b:?}"); +} + +/// In a dynamic symbol's art, Direct Selection does not pick a Shaper group's original shapes, +/// which the canvas does not draw: a click over an original shape whose face the group erased +/// picks nothing, and a click over the face it shows picks the face. +#[test] +fn a_shaper_groups_original_shapes_in_symbol_art_are_not_picked() { + let mut d = Document::new(300.0, 200.0); + let l = d.layers[0].id; + let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0)); + let a = Node::path(d.alloc_id(), square(0.0), Appearance::default_art()); + let b = Node::path(d.alloc_id(), square(50.0), Appearance::default_art()); + let mut sources = Node::group(d.alloc_id(), vec![Arc::new(a), Arc::new(b)]); + sources.name = Some(crate::shaper::SOURCES.into()); + sources.opacity = 0.0; + let mut face = Node::path(d.alloc_id(), square(0.0), Appearance::default_art()); + face.name = Some(format!("{}0", crate::shaper::FILL_PREFIX)); + let mut group = Node::group(d.alloc_id(), vec![Arc::new(sources), Arc::new(face)]); + group.extras_mut().shaper = Some(Box::default()); + let root = Node::group(d.alloc_id(), vec![Arc::new(group)]); + d.symbols.push(crate::Symbol { dynamic: true, name: "Shaped".into(), art: Arc::new(root) }); + let inst = d.alloc_id(); + d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Shaped".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap(); + let n = d.node(inst).unwrap(); + let opt = HitOptions::default(); + assert_eq!(symbol_part_at(&d, n, Point::new(130.0, 50.0), opt), None, "over the erased original shape"); + assert_eq!(symbol_part_at(&d, n, Point::new(50.0, 50.0), opt), Some(vec![0, 1]), "over the shown face"); +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index bff8e40f3..8a866256e 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -109,7 +109,7 @@ pub use placed_document::{PageBox, PlacedDocument}; pub use profiles::ColorProfiles; pub use puppet::{PuppetPin, PuppetPins}; pub use rastersettings::{RasterColorModel, RasterEffectsSettings}; -pub use selection::{AnchorRef, SegmentRef, Selection}; +pub use selection::{AnchorRef, SegmentRef, Selection, SymbolPart}; pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes}; pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource}; pub use style_libs::StyleLibrary; @@ -141,7 +141,7 @@ mod limits; mod records; mod units; -pub use artboard::{Artboard, LockedArtboards, RetiredArtboardId, VIDEO_PIXEL_ASPECT_RANGE, VIDEO_SAFE_INSETS}; +pub use artboard::{ARTBOARD_GAP, Artboard, ArtboardSide, LockedArtboards, RetiredArtboardId, VIDEO_PIXEL_ASPECT_RANGE, VIDEO_SAFE_INSETS}; pub use canvas_range::BeyondCanvas; #[cfg(test)] use document_ext::free_id; @@ -150,7 +150,7 @@ pub use guides::{GridPrefs, Guide, RulerMode}; #[cfg(test)] use limits::MAX_NEXT_ID; pub use limits::{DocError, FORMAT_VERSION, MAX_COORD}; -pub use records::{ImageBlob, MaskEdit, NewArtIds, SavedSelection, SavedView, Symbol, SymbolEdit}; +pub use records::{ImageBlob, MaskEdit, NewArtIds, SavedSelection, SavedView, SvgFilter, Symbol, SymbolEdit}; pub use units::{ColorMode, Unit, parse_number}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -310,6 +310,10 @@ pub struct Document { /// given) SVG export links with `", "", + "", + "", + ] { + let e = Effect { id: SVG_FILTER.into(), params: json!({ "xml": xml }), visible: true }; + assert!(raster_effects(&[e]).is_empty(), "{xml}"); + } +} + +/// Scale Strokes & Effects scales the filter's lengths with the art: the points a unit measures. +#[test] +fn scaling_the_effect_scales_its_units() { + let mut e = Effect { id: SVG_FILTER.into(), params: json!({ "xml": RED_FLOOD }), visible: true }; + crate::scale_effect(&mut e, 2.0); + assert_eq!(e.params["scale"], json!(2.0)); + crate::scale_effect(&mut e, 0.25); + assert_eq!(e.params["scale"], json!(0.5)); +} + +/// The form SVG output writes, for a filter that fills a 10 × 10 point box. +fn written(xml: &str) -> Option { + filter(json!({ "xml": xml })).written_xml(Rect::new(0.0, 0.0, 10.0, 10.0)) +} + +/// SVG 1.1 output writes an image's reference as `xlink:href`; the filter as it is drawn and +/// listed keeps `href`. +#[test] +fn an_image_is_written_with_an_xlink_reference() { + let data = format!("data:image/png;base64,{}", base64(&png_claiming(2, 2))); + let f = filter(json!({ "xml": format!(r#""#) })); + assert_eq!(f.xml(), format!(r#""#)); + let out = f.written_xml(Rect::new(0.0, 0.0, 10.0, 10.0)).unwrap(); + assert_eq!(out, format!(r#""#)); + assert!(f.writes_as_svg11()); + assert_eq!(check(&out), Ok("i".into()), "the written form reads back"); +} + +/// SVG 1.1 has no `feDropShadow`: it is written as the primitives Filter Effects gives as its +/// equivalent, with its defaults, input, result and subregion, and results no other primitive uses. +#[test] +fn a_drop_shadow_is_written_as_svg_1_1_primitives() { + assert_eq!( + written(r#""#).as_deref(), + Some(concat!( + r#""#, + r#""#, + )) + ); + // Its input, result, subregion, color space and flood; a name the filter uses is not taken. + let given = r#""#; + assert_eq!( + written(given).as_deref(), + Some(concat!( + r#""#, + r#""#, + r##""##, + r#""#, + r#""#, + )) + ); + // An implicit input, or a result no earlier primitive has, is the previous result, named. + for shadow in [r#""#, r#""#] { + let out = written(&format!(r#"{shadow}"#)).unwrap(); + assert!(out.starts_with(r##""#), "{out}"); + assert_eq!(check(&out), Ok("s".into()), "the written form reads back"); + } + // What SVG 1.1 can't hold leaves the filter to be written as other pixel effects are. + for xml in [ + r#""#, + r#""#, + ] { + let f = filter(json!({ "xml": xml })); + assert!(!f.writes_as_svg11() && f.written_xml(Rect::new(0.0, 0.0, 10.0, 10.0)).is_none(), "{xml}"); + } + // An offset that isn't a number is the shadow's default, which an `feOffset` doesn't share. + let out = written(r#""#).unwrap(); + assert!(out.contains(r#"{}"#, "".repeat(13)); + assert!(!filter(json!({ "xml": many })).writes_as_svg11()); +} + +/// Each input is written as the primitive it reads when drawn: a result name only usvg gives (to +/// a primitive without a `result`, here the shadow), and the inputs usvg draws as `SourceGraphic`. +#[test] +fn inputs_are_written_as_the_primitives_they_read() { + let xml = r#""#; + let out = written(xml).unwrap(); + assert_eq!( + out, + concat!( + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + ) + ); + assert_eq!(check(&out), Ok("s".into()), "the written form reads back"); +} + +/// Other values SVG 1.1 lacks: SVG 1.1 values that draw alike, or no SVG 1.1 form. +#[test] +fn values_svg_1_1_lacks_are_written_as_resvg_draws_them() { + // `lighter` (Filter Effects) is drawn as the default `over`, and a blur has no `edgeMode`. + assert_eq!( + written(r#""#) + .as_deref(), + Some(r#""#) + ); + assert_eq!( + written(r#""#).as_deref(), + Some(r#""#) + ); + // CSS colors as `#rrggbb`, a flood color's alpha in its opacity, a lighting color's dropped. + assert_eq!( + written(r#""#) + .as_deref(), + Some( + r##""## + ) + ); + // Image scaling as an SVG 1.1 keyword. + let data = format!("data:image/png;base64,{}", base64(&png_claiming(2, 2))); + for (given, kept) in + [("pixelated", "optimizeSpeed"), ("crisp-edges", "optimizeSpeed"), ("smooth", "optimizeQuality"), ("auto", "optimizeQuality")] + { + let out = written(&format!(r#""#)).unwrap(); + assert!(out.contains(&format!(r#"style="image-rendering:{kept}""#)), "{given}: {out}"); + } + // The blend modes SVG 1.1 lacks have no SVG 1.1 form. + for mode in LATER_BLEND_MODES { + let f = filter(json!({ "xml": format!(r#""#) })); + assert!(!f.writes_as_svg11(), "{mode}"); + } +} + +#[test] +fn base64_pads_as_rfc_4648_does() { + assert_eq!(base64(b""), ""); + assert_eq!(base64(b"f"), "Zg=="); + assert_eq!(base64(b"fo"), "Zm8="); + assert_eq!(base64(b"foo"), "Zm9v"); + assert_eq!(base64(b"foobar"), "Zm9vYmFy"); +} diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index ea3843f0e..0b3fc0b1c 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -407,6 +407,54 @@ fn stacked_effects_on_art_stay_within_the_anchor_budget() { assert_eq!(reshaped, 10); } +/// A path that keeps its shape because its Transform effect would pass the budget keeps the +/// gradients that paint the art as a whole in place too, also with Scale Strokes & Effects on. +#[test] +fn art_past_the_budget_keeps_its_own_gradients_in_place() { + use std::sync::Arc; + + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + use vectorcraft_doc::{AppearanceItem, FillLayer, NodeId, color::Paint}; + let id = NodeId(9); + let stops = vec![GradientStop::new(0.0, vectorcraft_color::Color::BLACK), GradientStop::new(1.0, vectorcraft_color::Color::WHITE)]; + let geom = GradientGeom { start: (0.0, 50.0).into(), end: (100.0, 50.0).into(), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient::new(GradientKind::Linear, stops)) })); + let circle = || Arc::new(Node::path(id, shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default())); + // The group's own fill, a gradient placed across the circle, after `carry` moves it. + let own_end = |carry: &crate::Carry| { + let mut ap = Appearance::default(); + ap.items.push(AppearanceItem::Fill(FillLayer::new(placed.clone()))); + carry.apply(&mut ap); + match ap.fill_paint() { + Paint::Gradient(g) => g.geom.map(|g| g.end.x), + _ => None, + } + }; + for params in [json!({"scaleH": 1000, "scaleV": 1000}), json!({"scaleH": 1000, "scaleV": 1000, "strokes": true})] { + let grow = fx("distort.transform", params.clone()); + let mut art = Node::group(id, vec![circle()]); + let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], Budget::EFFECTS); + let end = own_end(&carry).unwrap(); + assert!((end - 550.0).abs() < 1e-6, "{params}: within the budget the group's gradient grows with the circle: {end}"); + // No room beyond what the circle costs: scaled up, it would be drawn with more lines. + let mut art = Node::group(id, vec![circle()]); + let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], Budget { anchors: usize::MAX, lines: 0 }); + let b = art.geometric_bounds().unwrap(); + assert!(b.width() < 101.0 && b.height() < 101.0, "{params}: the circle keeps its shape: {b:?}"); + assert_eq!(own_end(&carry), Some(100.0), "{params}: the group's gradient stays where it was placed"); + // A group inside the art whose square took the effect moves its own gradient all the same. + let square = Arc::new(Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default())); + let mut inner = Node::group(NodeId(2), vec![square]); + inner.appearance.items.push(AppearanceItem::Fill(FillLayer::new(placed.clone()))); + let mut art = Node::group(id, vec![circle(), Arc::new(inner)]); + let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], Budget { anchors: usize::MAX, lines: 0 }); + assert_eq!(own_end(&carry), Some(100.0), "{params}: the art's own gradient stays"); + let inner = &art.children().unwrap()[1]; + let Paint::Gradient(g) = inner.appearance.fill_paint() else { panic!("a gradient") }; + assert!((g.geom.unwrap().end.x - 550.0).abs() < 1e-6, "{params}: the inner group's gradient: {:?}", g.geom); + } +} + /// #3578: a large Roughen draws each of its points as many lines; they stay within the budget, /// the Roughen built again with fewer points where they wouldn't. #[test] diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index a26c75da2..eb61b5126 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -1,5 +1,6 @@ //! The command registry. Ids follow Illustrator's menu structure. +pub mod actionset; mod addons; pub(crate) mod appearance; mod assets; @@ -83,6 +84,7 @@ pub(crate) mod slices; mod stroke; mod style; pub mod stylelib; +mod svgfilter; mod swatch; pub mod swatchlib; pub(crate) mod symboledit; @@ -317,6 +319,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(expand::specs()); v.extend(attributes::specs()); v.extend(interactivity::specs()); + v.extend(svgfilter::specs()); v.extend(newart::specs()); v.extend(colortheme::specs()); v.extend(fileio::pdf::specs()); @@ -344,6 +347,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(halftone::specs()); v.extend(perspgrid::specs()); v.extend(shortcutset::specs()); + v.extend(actionset::specs()); v.extend(importmatches::specs()); v.extend(addons::specs()); v @@ -590,11 +594,16 @@ pub(crate) fn object_param(p: &Value, key: &str, cmd: &str) -> Result Result> { match named_ids(p)? { Some(ids) => Ok(ids), - None => Ok(s.doc()?.selection.objects.clone()), + None => { + edit::refuse_symbol_part(s)?; + Ok(s.doc()?.selection.objects.clone()) + } } } @@ -667,6 +676,7 @@ pub(crate) const MAX_IMPORTED_PRESETS: usize = 1000; pub(crate) use edit::{settle_interaction, typing_in_progress}; pub(crate) use object::bounds_rewrite; +pub(crate) use object::{keep_aligned_in, refresh_pixel_aligned}; pub(crate) use vectorcraft_doc::range::parse_range; /// A date param in Unix seconds that defaults to the clock: `None` when absent (now, see diff --git a/crates/engine/src/cmd/actionset.rs b/crates/engine/src/cmd/actionset.rs new file mode 100644 index 000000000..b6a911838 --- /dev/null +++ b/crates/engine/src/cmd/actionset.rs @@ -0,0 +1,560 @@ +//! Action sets another app saves as text (`.aia` files that start `/version`), read into their +//! actions and steps without changing anything (`actionSet.read`). +//! +//! The text is a set of fields and blocks. A slash name followed by a value sets a field, and a +//! slash name followed by `{` opens a block that `}` closes. A value is a decimal number, text in +//! parentheses, a byte count and hexadecimal bytes in square brackets (`[ 5 48656c6c6f ]`, UTF-8 +//! text, which can run over several words), or a byte count and hexadecimal bytes in angle brackets +//! (`< 3 0a0b0c >`, raw bytes). The set's `/version` comes first, and its `/name` is hexadecimal +//! text. Each action is an `/action-N` block with a hexadecimal `/name`; each step is an `/event-N` +//! block inside one, with its `/internalName` in parentheses, the `/localizedName` it is shown with +//! as hexadecimal text, and `/isOn` 0 when it is left out of playback; each value a step was +//! recorded with is a `/parameter-N` block inside it, which is counted, and whose hexadecimal +//! `/value` is checked against its byte count. The set's `/actionCount`, an action's `/eventCount` +//! and a step's `/parameterCount` give how many actions, steps and recorded values follow. +//! The layout follows the README of Inventsable's Redesigning-Illustrator-Actions-in-Scripting +//! (https://github.com/Inventsable/Redesigning-Illustrator-Actions-in-Scripting), the forum post +//! "Made a two-way aia to json converter" +//! (https://community.adobe.com/t5/illustrator-discussions/made-a-two-way-aia-to-json-converter/m-p/13751909), +//! sttk3's article on names as UTF-8 hexadecimal +//! (https://sttk3.com/blog/tips/illustrator/dynamic-generate-action.html), and the MIT-licensed +//! reader in zachshallbetter/adobe-mcp (https://github.com/zachshallbetter/adobe-mcp). The raw form +//! follows the forum thread "Creating a dynamic action" +//! (https://community.adobe.com/questions-652/creating-a-dynamic-action-to-use-with-app-doscript-method-814406). + +use serde_json::{Value, json}; + +use super::{CommandSpec, EngineError, Result, Session, always, bad, cmd, text_param}; + +/// The most bytes of a file read. +const MAX_BYTES: usize = 8 << 20; +/// The most bytes taken in: [`MAX_BYTES`] after a byte order mark, and one more that shows the text +/// goes on. +const READ_LIMIT: usize = MAX_BYTES + 4; +/// The most blocks open at once. +const MAX_DEPTH: usize = 16; +/// The most actions read. +const MAX_ACTIONS: usize = 1000; +/// The most steps read in one action. +const MAX_STEPS: usize = 10_000; +/// The most steps read in the set: a small text can hold hundreds of thousands of empty steps, and +/// the answer holds an object for each. +const MAX_SET_STEPS: usize = 100_000; +/// The longest name kept (bytes). +const MAX_NAME: usize = 1024; +/// The most tokens listed as unread. +const MAX_UNREAD: usize = 1000; +/// The longest unread token shown (characters). +const UNREAD_SHOWN: usize = 80; + +/// The extensions of action sets other apps save. +pub const EXTS: &[&str] = &["aia"]; + +pub fn specs() -> Vec { + vec![cmd!( + query "actionSet.read", + "Read Action Set File", + [], + None, + "{path? | data?: the file's text | dataBase64?, name?: the file's name (default: the path's)} read an action set another app saved as text (.aia, starting \"/version\") without changing anything → {set: the set's /name (else the file's name without its extension, else empty), version, actions: [{name, steps: [{name: the step's /internalName, label: its /localizedName, or null when it has none or it is blank, on: false when /isOn is 0, params: how many /parameter blocks it has, line}]}], unread: [{line, text}] (tokens that aren't read, at most 1000), truncated: true when a cap stopped the reading (8 MiB, 16 open blocks, 1,000 actions, 10,000 steps in an action, 100,000 steps in the set; names past 1,024 bytes are cut), when the text ends inside a block or a value or after a field's name, when /actionCount, /eventCount or a step's /parameterCount differs from the actions, steps or recorded values read (a negative count, one too large for a number and one that isn't a number always differ), or when a hexadecimal name, label or recorded /value isn't as many bytes as its count says}. Give exactly one of path, data and dataBase64. Text that doesn't start with /version and a number is an error", + always, + read + )] +} + +/// A step of an action: an `/event-N` block. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct AiaStep { + /// Its `/internalName`. + pub name: String, + /// Its `/localizedName`, if it has one. + pub label: Option, + /// Its `/isOn`, if it has one. + pub on: Option, + /// How many `/parameter-N` blocks it has. + pub params: usize, + /// Its `/parameterCount`, if it has one. + pub declared: Option, + /// The line its block opens on (from 1). + pub line: usize, +} + +/// An action: an `/action-N` block. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct AiaAction { + pub name: String, + pub steps: Vec, + /// Its `/eventCount`, if it has one. + pub declared: Option, +} + +/// An action set read from text. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct AiaSet { + pub version: i64, + /// The set's `/name`, if it has one. + pub name: Option, + pub actions: Vec, + /// Its `/actionCount`, if it has one. + pub declared: Option, + /// The tokens that weren't read, with their lines. + pub unread: Vec<(usize, String)>, + /// A cap stopped the reading or cut a name, the text ends inside a block or a value or after a + /// field's name, its counts differ from the actions, steps and recorded values read (a negative + /// count, one too large for an `i64` and one that isn't a number always differ), or a hexadecimal + /// name, label or recorded `/value` isn't as many bytes as its count says. + pub truncated: bool, + /// How many steps were read in all the actions. + steps: usize, +} + +/// A token of the text. +enum Token { + /// A slash name. + Name(Vec), + /// An integer; `None` when it doesn't fit an `i64`. + Int(Option), + /// Text in parentheses, a backslash taking the next byte as it is. + Text(Vec), + /// The bytes of `[ count hex… ]`; `None` when the hexadecimal digits don't make whole bytes or + /// as many bytes as the count says. + Hex(Option>), + /// `< count hex… >`. + Raw, + Open, + Close, + /// Anything else, as written. + Other(Vec), +} + +/// The tokens of `text`, each with its line. +struct Tokens<'a> { + text: &'a [u8], + at: usize, + line: usize, + /// The text ended inside a value in parentheses, square brackets or angle brackets. + unclosed: bool, +} + +impl<'a> Tokens<'a> { + /// Past one byte, counting a line at LF, at CR alone, and once for CRLF. + fn step(&mut self) -> Option { + let b = *self.text.get(self.at)?; + if b == b'\n' || b == b'\r' && self.text.get(self.at + 1) != Some(&b'\n') { + self.line += 1; + } + self.at += 1; + Some(b) + } + + /// The bytes up to `end` (not taken in); `None` when the text ends first. + fn until(&mut self, end: u8) -> Option<&'a [u8]> { + let (text, start) = (self.text, self.at); + while text.get(self.at).is_some_and(|&b| b != end) { + self.step(); + } + let inside = text.get(start..self.at)?; + text.get(self.at)?; + self.at += 1; + Some(inside) + } + + fn next_token(&mut self) -> Option<(usize, Token)> { + while self.text.get(self.at).is_some_and(|&b| b.is_ascii_whitespace() || b == 0) { + self.step(); + } + let line = self.line; + let b = *self.text.get(self.at)?; + let ends = |b: u8| b.is_ascii_whitespace() || b == 0 || b"{}[]()<>/".contains(&b); + let token = match b { + b'{' | b'}' => { + self.at += 1; + if b == b'{' { Token::Open } else { Token::Close } + } + b'(' => { + self.at += 1; + let (mut text, mut depth) = (vec![], 0usize); + self.unclosed = true; + while let Some(c) = self.step() { + match c { + b'\\' => text.extend(self.step()), + b'(' => { + depth += 1; + text.push(c); + } + b')' if depth == 0 => { + self.unclosed = false; + break; + } + b')' => { + depth -= 1; + text.push(c); + } + _ => text.push(c), + } + } + Token::Text(text) + } + b'[' | b'<' => { + self.at += 1; + let close = if b == b'[' { b']' } else { b'>' }; + let inside = self.until(close); + self.unclosed = inside.is_none(); + let inside = inside.unwrap_or_default().to_vec(); + if b == b'<' { + Token::Raw + } else { + // The first word is the byte count; the bytes follow in one or more words. + let mut words = inside.split(|c| c.is_ascii_whitespace()).filter(|w| !w.is_empty()); + let count = words.next().and_then(|w| std::str::from_utf8(w).ok()).and_then(|w| w.parse::().ok()); + let digits: Vec = words.flatten().copied().collect(); + Token::Hex(hex_bytes(&digits).filter(|b| Some(b.len()) == count)) + } + } + b'/' => { + self.at += 1; + let start = self.at; + while self.text.get(self.at).is_some_and(|&c| !ends(c)) { + self.at += 1; + } + Token::Name(self.text.get(start..self.at).unwrap_or_default().to_vec()) + } + _ => { + let start = self.at; + while self.text.get(self.at).is_some_and(|&c| !ends(c)) { + self.at += 1; + } + // Always past at least one byte. + self.at = self.at.max(start + 1); + let word = self.text.get(start..self.at).unwrap_or_default(); + let number = word.strip_prefix(b"-").or_else(|| word.strip_prefix(b"+")).unwrap_or(word); + if !number.is_empty() && number.iter().all(u8::is_ascii_digit) { + Token::Int(std::str::from_utf8(word).ok().and_then(|s| s.parse().ok())) + } else { + Token::Other(word.to_vec()) + } + } + }; + Some((line, token)) + } +} + +/// The bytes hexadecimal `digits` stand for, two digits a byte. +fn hex_bytes(digits: &[u8]) -> Option> { + let nibble = |c: u8| (c as char).to_digit(16).and_then(|d| u8::try_from(d).ok()); + if !digits.len().is_multiple_of(2) { + return None; + } + digits.chunks(2).map(|pair| Some((nibble(*pair.first()?)? << 4) | nibble(*pair.get(1)?)?)).collect() +} + +/// What a block is. +enum Kind { + Action, + Step, + Param, + /// A block that isn't read, or one past a cap. + Other, +} + +/// `name` ends with `-` and a number after `prefix`, as `/action-1` does. +fn numbered(name: &[u8], prefix: &[u8]) -> bool { + name.strip_prefix(prefix).is_some_and(|n| !n.is_empty() && n.iter().all(u8::is_ascii_digit)) +} + +/// A name as text: at most [`MAX_NAME`] bytes, read as UTF-8 with replacement characters. +fn name_text(name: &[u8], set: &mut AiaSet) -> String { + if name.len() > MAX_NAME { + set.truncated = true; + } + let mut s = String::from_utf8_lossy(name.get(..MAX_NAME).unwrap_or(name)).into_owned(); + // A character cut at the limit reads as one replacement character. + if name.len() > MAX_NAME && s.ends_with('\u{FFFD}') { + s.pop(); + } + s +} + +/// How the unread list shows a value in square brackets that isn't as many bytes as its count says. +const BAD_HEX: &str = "[ … ] (not the hexadecimal bytes its count says)"; + +/// List a token that isn't read. +fn unread(set: &mut AiaSet, line: usize, text: impl AsRef<[u8]>) { + if set.unread.len() < MAX_UNREAD { + let text: String = String::from_utf8_lossy(text.as_ref()).chars().take(UNREAD_SHOWN).collect(); + set.unread.push((line, text)); + } +} + +/// What the block `name` that opens on `line` inside the blocks `stack` is: a new action, step or +/// recorded value of `set`, or a block that isn't read (listed when it is out of place). +fn open_block(set: &mut AiaSet, stack: &[Kind], name: &[u8], line: usize) -> Kind { + match stack.last() { + _ if stack.len() >= MAX_DEPTH => { + set.truncated = true; + Kind::Other + } + None if numbered(name, b"action-") => { + if set.actions.len() < MAX_ACTIONS { + set.actions.push(AiaAction::default()); + Kind::Action + } else { + set.truncated = true; + Kind::Other + } + } + Some(Kind::Action) if numbered(name, b"event-") => match set.actions.last_mut() { + Some(a) if a.steps.len() < MAX_STEPS && set.steps < MAX_SET_STEPS => { + a.steps.push(AiaStep { line, ..AiaStep::default() }); + set.steps += 1; + Kind::Step + } + _ => { + set.truncated = true; + Kind::Other + } + }, + Some(Kind::Step) if numbered(name, b"parameter-") => { + if let Some(s) = set.actions.last_mut().and_then(|a| a.steps.last_mut()) { + s.params += 1; + } + Kind::Param + } + Some(Kind::Param | Kind::Other) => Kind::Other, + _ => { + unread(set, line, format!("/{} {{", String::from_utf8_lossy(name))); + Kind::Other + } + } +} + +/// Set the field `name` of the block `kind` (the set when `None`) to the hexadecimal text `bytes` +/// on `line`: the set's or an action's `/name`, or a step's `/localizedName`. +fn set_hex(set: &mut AiaSet, kind: Option<&Kind>, name: Option<&[u8]>, bytes: Option>, line: usize) { + let Some(bytes) = bytes else { + set.truncated = true; + unread(set, line, BAD_HEX); + return; + }; + match (kind, name) { + (None, Some(b"name")) => set.name = Some(name_text(&bytes, set)), + (Some(Kind::Action), Some(b"name")) => { + let text = name_text(&bytes, set); + if let Some(a) = set.actions.last_mut() { + a.name = text; + } + } + (Some(Kind::Step), Some(b"localizedName")) => { + let text = name_text(&bytes, set); + if let Some(s) = set.actions.last_mut().and_then(|a| a.steps.last_mut()) { + s.label = Some(text); + } + } + (_, Some(_)) => {} + (_, None) => unread(set, line, "[ … ]"), + } +} + +/// Mark `set` truncated when the hexadecimal `/value` on `line` isn't as many bytes as its count +/// says. +fn check_value(set: &mut AiaSet, bytes: Option>, line: usize) { + if bytes.is_none() { + set.truncated = true; + unread(set, line, BAD_HEX); + } +} + +/// Mark `set` truncated when the field `name` of the block `kind` (the set when `None`) is a count +/// (the set's `/actionCount`, an action's `/eventCount` or a step's `/parameterCount`) whose value is +/// not a number that fits an `i64`. +fn bad_count(set: &mut AiaSet, kind: Option<&Kind>, name: Option<&[u8]>) { + if matches!((kind, name), (None, Some(b"actionCount")) | (Some(Kind::Action), Some(b"eventCount")) | (Some(Kind::Step), Some(b"parameterCount"))) + { + set.truncated = true; + } +} + +/// Set the field `name` of the block `kind` (the set when `None`) to the integer `value` on `line`: +/// the set's `/actionCount`, an action's `/eventCount`, a step's `/parameterCount`, or a step's +/// `/isOn`. A count too large for an `i64` marks the set truncated. +fn set_int(set: &mut AiaSet, kind: Option<&Kind>, name: Option<&[u8]>, value: Option, line: usize) { + if value.is_none() { + bad_count(set, kind, name); + } + match (kind, name) { + (None, Some(b"actionCount")) => set.declared = value, + (Some(Kind::Action), Some(b"eventCount")) => { + if let Some(a) = set.actions.last_mut() { + a.declared = value; + } + } + (Some(Kind::Step), Some(b"parameterCount")) => { + if let Some(s) = set.actions.last_mut().and_then(|a| a.steps.last_mut()) { + s.declared = value; + } + } + (Some(Kind::Step), Some(b"isOn")) => { + if let Some(s) = set.actions.last_mut().and_then(|a| a.steps.last_mut()) { + s.on = value; + } + } + (_, Some(_)) => {} + (_, None) => unread(set, line, value.map_or_else(|| "(a number too large)".to_string(), |v| v.to_string())), + } +} + +/// Read an action set saved as text. An error when the text isn't one: it doesn't start with +/// `/version` and an integer. +pub fn read_aia(bytes: &[u8]) -> std::result::Result { + let mut set = AiaSet::default(); + let text = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes); + let text = match text.get(..MAX_BYTES) { + Some(head) if text.len() > MAX_BYTES => { + set.truncated = true; + head + } + _ => text, + }; + let mut tokens = Tokens { text, at: 0, line: 1, unclosed: false }; + match (tokens.next_token(), tokens.next_token()) { + (Some((_, Token::Name(n))), Some((_, Token::Int(Some(v))))) if n == b"version" => set.version = v, + _ => return Err("not an action set: the text doesn't start with /version and a number".into()), + } + let mut stack: Vec = vec![]; + // A slash name waiting for its value or its `{`: (line, name). + let mut pending: Option<(usize, Vec)> = None; + while let Some((line, token)) = tokens.next_token() { + let in_step = matches!(stack.last(), Some(Kind::Step)); + let field = |pending: &mut Option<(usize, Vec)>| pending.take().map(|(_, n)| n); + match token { + Token::Name(name) => { + if let Some((l, n)) = pending.replace((line, name)) { + bad_count(&mut set, stack.last(), Some(&n)); + unread(&mut set, l, [b"/".as_slice(), &n].concat()); + } + } + Token::Open => { + let (l, name) = pending.take().unwrap_or((line, vec![])); + bad_count(&mut set, stack.last(), Some(&name)); + let kind = open_block(&mut set, &stack, &name, l); + stack.push(kind); + } + Token::Close => { + if let Some((l, n)) = pending.take() { + bad_count(&mut set, stack.last(), Some(&n)); + unread(&mut set, l, [b"/".as_slice(), &n].concat()); + } + if stack.pop().is_none() { + unread(&mut set, line, "}"); + } + } + Token::Hex(bytes) if matches!(stack.last(), Some(Kind::Param)) => { + if field(&mut pending).as_deref() == Some(b"value".as_slice()) { + check_value(&mut set, bytes, line); + } + } + // The other values inside a recorded value, or a block that isn't read. + _ if matches!(stack.last(), Some(Kind::Param | Kind::Other)) => { + pending = None; + } + Token::Hex(bytes) => { + let name = field(&mut pending); + bad_count(&mut set, stack.last(), name.as_deref()); + set_hex(&mut set, stack.last(), name.as_deref(), bytes, line); + } + Token::Text(t) => { + let name = field(&mut pending); + bad_count(&mut set, stack.last(), name.as_deref()); + match name { + Some(n) if in_step && n == b"internalName" => { + let name = name_text(&t, &mut set); + if let Some(s) = set.actions.last_mut().and_then(|a| a.steps.last_mut()) { + s.name = name; + } + } + Some(_) => {} + None => unread(&mut set, line, [b"(".as_slice(), &t, b")"].concat()), + } + } + Token::Int(v) => { + let name = field(&mut pending); + set_int(&mut set, stack.last(), name.as_deref(), v, line); + } + Token::Raw => { + let name = field(&mut pending); + bad_count(&mut set, stack.last(), name.as_deref()); + if name.is_none() { + unread(&mut set, line, "< … >"); + } + } + Token::Other(word) => { + // A real number, or another word as a field's value. + let name = field(&mut pending); + bad_count(&mut set, stack.last(), name.as_deref()); + if name.is_none() { + unread(&mut set, line, word); + } + } + } + } + // The text ends after a field's name, before its value. + if let Some((l, n)) = pending { + set.truncated = true; + unread(&mut set, l, [b"/".as_slice(), &n].concat()); + } + // An unclosed value is the last token: it takes the rest of the text. + if tokens.unclosed { + set.truncated = true; + unread(&mut set, tokens.line, "a value not closed at the end"); + } + if !stack.is_empty() { + set.truncated = true; + unread(&mut set, tokens.line, format!("blocks left open at the end: {}", stack.len())); + } + // Not as many actions, steps or recorded values as the counts give; a negative count never matches. + let differs = |declared: Option, read: usize| declared.is_some_and(|d| !usize::try_from(d).is_ok_and(|d| d == read)); + let step_differs = |a: &AiaAction| a.steps.iter().any(|s| differs(s.declared, s.params)); + if differs(set.declared, set.actions.len()) || set.actions.iter().any(|a| differs(a.declared, a.steps.len()) || step_differs(a)) { + set.truncated = true; + unread(&mut set, tokens.line, "not as many actions, steps or recorded values as the counts give"); + } + Ok(set) +} + +fn read(_: &mut Session, p: &Value) -> Result { + const C: &str = "actionSet.read"; + let (path, data, b64) = (text_param(p, "path", C)?, text_param(p, "data", C)?, text_param(p, "dataBase64", C)?); + let name = text_param(p, "name", C)?; + let bytes: std::borrow::Cow<[u8]> = match (path, data, b64) { + (Some(path), None, None) => super::fileio::read_file_head(path, READ_LIMIT)?.into(), + (None, Some(text), None) => text.as_bytes().get(..READ_LIMIT).unwrap_or(text.as_bytes()).into(), + (None, None, Some(b64)) => super::scripts::decode_head(b64, READ_LIMIT).ok_or_else(|| bad(C, "bad dataBase64"))?.into(), + _ => return Err(bad(C, "give exactly one of `path`, `data` and `dataBase64`")), + }; + let set = read_aia(&bytes).map_err(EngineError::Other)?; + let file = name.or(path).unwrap_or_default(); + let stem = std::path::Path::new(file).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); + let name = set.name.clone().filter(|n| !n.trim().is_empty()).unwrap_or(stem); + let actions: Vec = set + .actions + .iter() + .map(|a| { + let steps: Vec = a + .steps + .iter() + .map(|s| { + let label = s.label.clone().filter(|l| !l.trim().is_empty()); + json!({ "name": s.name, "label": label, "on": s.on != Some(0), "params": s.params, "line": s.line }) + }) + .collect(); + json!({ "name": a.name, "steps": steps }) + }) + .collect(); + Ok(json!({ + "set": name, + "version": set.version, + "actions": actions, + "unread": set.unread.iter().map(|(line, text)| json!({ "line": line, "text": text })).collect::>(), + "truncated": set.truncated, + })) +} diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 30e48d4fc..7f7b34352 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -126,7 +126,7 @@ pub fn specs() -> Vec { "Eyedropper", [], None, - "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art). On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}", + "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's solid fill and stroke for this instance only, as the Eyedropper Options allow, and a sampled gradient or pattern, a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}", has_doc, copy_from ), @@ -598,6 +598,9 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result { n.appearance.items.push(item); added.push((*id, fill)); } + if !fill { + super::object::snap_if_aligned(n); + } } } super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids)); @@ -620,6 +623,7 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result { for id in &ids { if let Some(n) = d.node_mut(*id) { clear(n); + super::object::snap_if_aligned(n); } } let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids)); @@ -661,6 +665,35 @@ fn painted_rows(d: &vectorcraft_doc::Document, rows: impl IntoIterator bool { + let top_shows = |d: &vectorcraft_doc::Document, id: NodeId, kind: bool| { + let items = d.node(id).map_or(&[][..], |n| &n.appearance.items[..]); + items.iter().rev().find(|it| it.is_fill() == kind).is_some_and(AppearanceItem::visible) + }; + let written = |d: &vectorcraft_doc::Document, (fill, stroke): (bool, bool)| -> Vec<(NodeId, bool)> { + let rows = ids.iter().flat_map(|id| [fill.then_some((*id, true)), stroke.then_some((*id, false))]).flatten(); + painted_rows(d, rows.filter(|(id, kind)| !top_only || top_shows(d, *id, *kind))) + }; + let mut opacity_rows = top_opacity_changed(before, d, ids); + opacity_rows.extend(written(d, opacities)); + let mut rows = top_paint_changed(before, d, ids); + rows.extend(written(d, paints)); + let kept = super::graph::capture_series_opacity(d, &opacity_rows); + let painted = super::graph::capture_series_top_paints(d, &rows); + kept || painted +} + /// The kind of the row at `at` (fill `true`, stroke `false`) when it is the topmost visible row of that kind in `ap`. fn top_row_kind(ap: &Appearance, at: usize) -> Option { let fill = ap.items.get(at)?.is_fill(); @@ -698,9 +731,11 @@ fn transfer(s: &mut Session, p: &Value) -> Result { } to.appearance = ap; (to.opacity, to.blend, to.isolate, to.knockout, to.knockout_shape) = (src.opacity, src.blend, src.isolate, src.knockout, src.knockout_shape); + super::object::snap_if_aligned(to); if !copy && let Some(n) = d.node_mut(source) { clear(n); (n.isolate, n.knockout, n.knockout_shape) = (false, Default::default(), false); + super::object::snap_if_aligned(n); } let changed = if copy { vec![target] } else { vec![source, target] }; let kept = super::graph::capture_series_opacity(d, &both_kinds(&changed)); @@ -759,6 +794,7 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result { st.blend = BlendMode::Normal; n.appearance.items.push(AppearanceItem::Stroke(st)); } + super::object::snap_if_aligned(n); } } let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids)); @@ -818,6 +854,7 @@ fn set_item(s: &mut Session, p: &Value) -> Result { if let Some(v) = p.get("visible").and_then(Value::as_bool) { *vis = v; } + super::object::snap_if_aligned(n); let items = &n.appearance.items; if let Some(fill) = items.get(idx).map(AppearanceItem::is_fill).filter(|f| items.iter().rposition(|it| it.is_fill() == *f && it.visible()) == Some(idx)) @@ -878,6 +915,7 @@ fn remove_item(s: &mut Session, p: &Value) -> Result { for &i in idx.iter().rev() { n.appearance.remove_item(i); } + super::object::snap_if_aligned(n); } } let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids)); @@ -982,7 +1020,19 @@ fn show_all_hidden(s: &mut Session, p: &Value) -> Result { let roots = require_targets(s, p, "appearance.showAllHidden")?; let before = s.doc()?.doc.clone(); let ids = s.edit("Show All Hidden Attributes", |d, _| { - let shown: Vec = roots.iter().copied().filter(|id| d.node_mut(*id).is_some_and(|n| n.appearance.show_all())).collect(); + let shown: Vec = roots + .iter() + .copied() + .filter(|id| { + d.node_mut(*id).is_some_and(|n| { + let shown = n.appearance.show_all(); + if shown { + super::object::snap_if_aligned(n); + } + shown + }) + }) + .collect(); if shown.is_empty() { return Err(EngineError::Other("Show All Hidden Attributes: nothing is hidden".into())); } @@ -1136,6 +1186,42 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "`defaultsOnly` cannot be combined with `ids`, `reverse` or `append`")); } let attrs = s.prefs.eyedropper.attrs(p).map_err(|e| bad(C, e))?; + // An object of a dynamic symbol instance's art selected with Direct Selection takes the clicked + // object's solid fill and stroke, as the Eyedropper Options allow, for this instance only. A + // gradient or pattern sampled for it, a source that is not a path, compound path or type (a + // symbol instance's own appearance is empty), and options that pick up neither color are + // refused, and the paint defaults stay as they are. A path of a compound path stands for the + // compound path, which holds the paint. + if !reverse + && !append + && !defaults_only + && p.get("ids").is_none() + && let Some((id, path)) = super::brushsym::selected_part(s)? + { + let d = &s.doc()?.doc; + let src = Arc::new(d.node(super::path::compound_or_self(d, clicked)).ok_or(EngineError::NoNode(clicked))?.clone()); + let (fill, stroke) = (super::paint::proxy_paint(&src, false, None), super::paint::proxy_paint(&src, true, None)); + let a = attrs.appearance; + if !vectorcraft_doc::symbol_overrides::takes_part_paint(&src) + || !(a.fill.color || a.stroke.color) + || (a.fill.color && !super::paint::part_takes(&fill)) + || (a.stroke.color && !super::paint::part_takes(&stroke)) + { + return Err(super::brushsym::part_only()); + } + super::brushsym::edit_part(s, id, &path, "Eyedropper", |o| { + if a.fill.color { + o.fill = Some(fill); + } + if a.stroke.color { + o.stroke = Some(stroke); + } + })?; + // The clicked object becomes the Eyedropper's sample, and new art takes its paint. + Picked::of(&src).to_defaults(s, &attrs.appearance); + s.eyedropper_sample = Some(src); + return Ok(json!({ "ids": [id.0] })); + } let range = if !defaults_only && !reverse && !append && p.get("ids").is_none() { super::textedit::editing_range(s)?.filter(|(_, a, b)| a != b) } else { @@ -1343,6 +1429,7 @@ impl Picked { let placed = self.placed(n.geometric_bounds()); n.appearance.items.extend(placed.items.into_iter().filter(|it| if it.is_fill() { ap.fill.color } else { ap.stroke.color })); n.appearance.effects.extend(placed.effects); + super::object::snap_if_aligned(n); return; } if let NodeKind::Text(t) = &mut n.kind { @@ -1366,6 +1453,7 @@ impl Picked { } else { focal_attrs(&mut n.appearance, &placed, ap); } + super::object::snap_if_aligned(n); } } diff --git a/crates/engine/src/cmd/assets.rs b/crates/engine/src/cmd/assets.rs index 133fb1fce..fd273e067 100644 --- a/crates/engine/src/cmd/assets.rs +++ b/crates/engine/src/cmd/assets.rs @@ -3,12 +3,17 @@ //! settings Export for Screens shares (`document.exportSettings`). File → Export Selection collects //! the selection this way and opens Export for Screens on its Assets tab (a UI command). +use std::collections::HashSet; + use serde_json::{Map, Value, json}; use vectorcraft_doc::{ExportAsset, NodeId}; use super::edit::{roots_of, selected_roots}; -use super::fileio::{SCREEN_SETTINGS_KEYS, check_screen_settings, export_crop, export_screens, store_screen_settings, with_screen_export_preset}; -use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, has_doc, ok, str_param}; +use super::fileio::{ + DEFAULT_FORMAT_3D, FORMATS_3D, SCREEN_SETTINGS_KEYS, check_screen_settings, create_dir, export_crop, export_screens, mesh_file_names, mesh_files, + safe_file_name, store_screen_settings, with_screen_export_preset, write_file, +}; +use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, has_doc, ok, str_param, switch_or, text_param}; /// The longest asset name kept (characters). const MAX_NAME: usize = 255; @@ -39,7 +44,7 @@ pub fn specs() -> Vec { "Assets", [], None, - "{} → {assets: [{id, name, nodes: [id…], bounds: [x, y, width, height] | null (the crop: the bounds the renderer measures for what its art draws, effects included)}]} the Asset Export panel's assets, in order", + "{} → {assets: [{id, name, nodes: [id…], bounds: [x, y, width, height] | null (the crop: the bounds the renderer measures for what its art draws, effects included), format3D: usda|usdz|gltf|obj | null (a 3D asset's mesh format; null for art exported as pictures)}]} the Asset Export panel's assets, in order", has_doc, list ), @@ -48,10 +53,37 @@ pub fn specs() -> Vec { "Export Assets", [], None, - "{assets?: [asset id…] (default all), folder?, zip?, openLocation?, formats?, preset?, settings?, prefix?, subfolders?, antiAlias?} each asset's art alone, cropped to it, in every format row: document.exportForScreens {assets} at the document's export settings (document.exportSettings) under the ones given; with subfolders false every file goes into the folder itself, mobile and density rows too → what document.exportForScreens returns (no folder: the files, or one zip, as dataBase64). Leaves the remembered settings as they are (assets.settings.set changes them)", + "{assets?: [asset id…] (default all but the 3D assets, which assets.export3D writes), folder?, zip?, openLocation?, formats?, preset?, settings?, prefix?, subfolders?, antiAlias?} each asset's art alone, cropped to it, in every format row: document.exportForScreens {assets} at the document's export settings (document.exportSettings) under the ones given; with subfolders false every file goes into the folder itself, mobile and density rows too → what document.exportForScreens returns (no folder: the files, or one zip, as dataBase64). Leaves the remembered settings as they are (assets.settings.set changes them)", has_doc, export ), + cmd!( + "assets.add3D", + "Export 3D Object", + [], + None, + "{ids?: [id…] (default: the selected objects), format?: usda|usdz|gltf|obj (default usda)} collect each object with an Extrude & Bevel, Revolve, Inflate or Rotate effect as a 3D asset of Window › Asset Export, named after the object (else Asset 1, Asset 2…): assets.export3D writes it as a mesh file. An object already collected as a 3D asset keeps it; an object without such an effect is refused → {assets: [asset id…] (in order), added: n}", + has_doc, + add_3d + ), + cmd!( + "assets.set3DFormat", + "3D Export Format", + [], + None, + "{assets: [asset id…], format: usda|usdz|gltf|obj} the mesh format 3D assets export to (Asset Export's Format for a 3D asset) → {changed: n}", + has_doc, + set_3d_format + ), + cmd!( + "assets.export3D", + "Export 3D Assets", + [], + None, + "{assets?: [asset id…] (default every 3D asset), folder?, zip?} each 3D asset's object as a mesh file in its format (assets.set3DFormat), named after the asset: USDA or USDZ, glTF 2.0 (one .gltf, its buffer embedded) or OBJ with its .mtl (whitespace and # in an OBJ's name become _). Every solid of the object (the fill's, each stroke's, a group's members') is a mesh with a material in the color it is painted; faces turned away from the viewer are included, at the object's 3D rotation, y up, about the point it turns about, in points (glTF: meters). Files that would share a name get -2, -3…; an object with a hole that crosses its face's outline or another hole, or with more than 2,000,000 triangles, is refused → {files: [path…]} (no folder: {files: [{name, dataBase64}]}, or with zip one zip as {name, dataBase64, files})", + has_doc, + export_3d + ), cmd!( "assets.settings.set", "Asset Export Settings", @@ -115,7 +147,9 @@ fn add(s: &mut Session, p: &Value) -> Result { if name.is_some() && sets.len() > 1 { return Err(bad(C, "a name names one asset: give multiple: false, or one object")); } - let existing = |d: &vectorcraft_doc::Document, nodes: &[NodeId]| d.assets.iter().find(|a| a.nodes == nodes).map(|a| a.id); + // A 3D asset of the same art is another asset. + let existing = + |d: &vectorcraft_doc::Document, nodes: &[NodeId]| d.assets.iter().find(|a| a.nodes == nodes && a.format_3d.is_none()).map(|a| a.id); let d = &s.doc()?.doc; // Nothing new: no undo step. if sets.iter().all(|set| existing(d, set).is_some()) { @@ -134,7 +168,7 @@ fn add(s: &mut Session, p: &Value) -> Result { }; let name = name.clone().or(own).unwrap_or_else(|| d.next_asset_name()); let id = d.alloc_id().0; - d.assets.push(ExportAsset { id, name, nodes }); + d.assets.push(ExportAsset { id, name, nodes, format_3d: None }); out.push(id); added += 1; } @@ -142,6 +176,162 @@ fn add(s: &mut Session, p: &Value) -> Result { }) } +/// The format of param `format` of command `cmd` ([`FORMATS_3D`]), `default` when it is not given. +fn format_3d_param(p: &Value, cmd: &str, default: Option<&str>) -> Result { + match (p.get("format"), default) { + (None, Some(d)) => Ok(d.to_string()), + (Some(Value::String(f)), _) if FORMATS_3D.contains(&f.as_str()) => Ok(f.clone()), + _ => { + let key = "format"; + Err(bad(cmd, format!("`{key}` must be one of {}", FORMATS_3D.join(", ")))) + } + } +} + +/// The asset ids of param `key` of command `cmd`, each a 3D asset of `d`. +fn three_d_asset_ids(d: &vectorcraft_doc::Document, p: &Value, key: &str, cmd: &str) -> Result> { + let ids = asset_ids(p, key, cmd)?; + for id in &ids { + match d.asset(*id) { + None => return Err(bad(cmd, "no such assets (see assets.list)")), + Some(a) if a.format_3d.is_none() => return Err(bad(cmd, format!("asset {id} isn't a 3D asset"))), + _ => {} + } + } + Ok(ids) +} + +fn add_3d(s: &mut Session, p: &Value) -> Result { + const C: &str = "assets.add3D"; + let format = format_3d_param(p, C, Some(DEFAULT_FORMAT_3D))?; + let ids = if p.get("ids").is_some_and(|v| !v.is_null()) { checked_ids_param(s, p, "ids", C)? } else { selected_roots(s)? }; + if ids.is_empty() { + return Err(bad(C, "select an object with a 3D effect (or give its ids)")); + } + let d = &s.doc()?.doc; + let mut ids = d.paint_order(ids); + ids.dedup(); + for id in &ids { + let n = d.node(*id).ok_or_else(|| bad(C, format!("no such object {0}", id.0)))?; + if let Err(e) = vectorcraft_render::effects::three_d_meshes(n) { + let what = n.name.clone().unwrap_or_else(|| format!("#{}", id.0)); + return Err(bad(C, format!("{what} can't be exported in 3D: {e}"))); + } + } + let existing = |d: &vectorcraft_doc::Document, id: NodeId| d.assets.iter().find(|a| a.nodes == [id] && a.format_3d.is_some()).map(|a| a.id); + // Nothing new: no undo step. + if ids.iter().all(|id| existing(d, *id).is_some()) { + return Ok(json!({ "assets": ids.iter().filter_map(|id| existing(d, *id)).collect::>(), "added": 0 })); + } + s.edit("Export 3D Object", |d, _| { + let (mut out, mut added) = (vec![], 0); + for node in ids { + if let Some(id) = existing(d, node) { + out.push(id); + continue; + } + let name = d.node(node).and_then(|n| n.name.as_deref()).and_then(clean_name).unwrap_or_else(|| d.next_asset_name()); + let id = d.alloc_id().0; + d.assets.push(ExportAsset { id, name, nodes: vec![node], format_3d: Some(format.clone()) }); + out.push(id); + added += 1; + } + Ok(json!({ "assets": out, "added": added })) + }) +} + +fn set_3d_format(s: &mut Session, p: &Value) -> Result { + const C: &str = "assets.set3DFormat"; + let d = &s.doc()?.doc; + let ids = three_d_asset_ids(d, p, "assets", C)?; + let format = format_3d_param(p, C, None)?; + let changed = d.assets.iter().filter(|a| ids.contains(&a.id) && a.format_3d.as_deref() != Some(format.as_str())).count(); + // Nothing changes: no undo step. + if changed == 0 { + return Ok(json!({ "changed": 0 })); + } + s.edit("3D Export Format", |d, _| { + for a in d.assets.iter_mut().filter(|a| ids.contains(&a.id)) { + a.format_3d = Some(format.clone()); + } + Ok(json!({ "changed": changed })) + }) +} + +fn export_3d(s: &mut Session, p: &Value) -> Result { + const C: &str = "assets.export3D"; + let d = &s.doc()?.doc; + let ids = if p.get("assets").is_some_and(|v| !v.is_null()) { + three_d_asset_ids(d, p, "assets", C)? + } else { + d.assets.iter().filter(|a| a.format_3d.is_some()).map(|a| a.id).collect() + }; + if ids.is_empty() { + return Err(bad(C, "no 3D assets yet: collect an object with a 3D effect first (assets.add3D)")); + } + let folder = text_param(p, "folder", C)?.filter(|f| !f.is_empty()); + let zip = switch_or(p, "zip", false, C)?; + // Every file's name, compared without case: a later file that would take one gets -2, -3… + let mut taken: HashSet = HashSet::new(); + let mut files: Vec<(String, Vec)> = vec![]; + for id in ids { + let Some(a) = d.asset(id) else { continue }; + let format = a.format_3d.as_deref().unwrap_or(DEFAULT_FORMAT_3D); + let mut parts = vec![]; + for node in &a.nodes { + let n = d.node(*node).ok_or_else(|| bad(C, format!("no such object {0}", node.0)))?; + let what = &a.name; + parts.extend(vectorcraft_render::effects::three_d_meshes(n).map_err(|e| bad(C, format!("{what} can't be exported in 3D: {e}")))?); + } + let base = Some(safe_file_name(&a.name)).filter(|b| !b.is_empty()).unwrap_or_else(|| "Asset".into()); + let mut stem = base.clone(); + let mut k = 1; + loop { + let names = mesh_file_names(&stem, format).map_err(|e| bad(C, e))?; + if names.iter().all(|n| !taken.contains(&n.to_lowercase())) { + break; + } + k += 1; + stem = format!("{base}-{k}"); + } + let made = mesh_files(&stem, format, &parts).map_err(|e| bad(C, e))?; + taken.extend(made.iter().map(|(n, _)| n.to_lowercase())); + files.extend(made); + } + let names: Vec<&str> = files.iter().map(|(n, _)| n.as_str()).collect(); + if zip { + let archive = super::fileio::zip::store(&files).map_err(|e| bad(C, e))?; + // "-3D" keeps it apart from the picture assets' ZIP, which is named after the document. + let name = Some(safe_file_name(&d.title)).filter(|t| !t.is_empty()).map_or_else(|| "3D Assets.zip".into(), |t| format!("{t}-3D.zip")); + return match folder { + Some(dir) => { + create_dir(dir)?; + let path = format!("{dir}/{name}"); + write_file(&path, &archive)?; + Ok(json!({ "path": path, "bytes": archive.len(), "files": names })) + } + None => Ok(json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(&archive), "bytes": archive.len(), "files": names })), + }; + } + match folder { + Some(dir) => { + create_dir(dir)?; + let mut written = vec![]; + for (name, bytes) in &files { + let path = format!("{dir}/{name}"); + write_file(&path, bytes)?; + written.push(path); + } + Ok(json!({ "files": written })) + } + None => { + let files: Vec = + files.iter().map(|(name, bytes)| json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).collect(); + Ok(json!({ "files": files })) + } + } +} + /// The asset ids of param `key`. fn asset_ids(p: &Value, key: &str, cmd: &str) -> Result> { let ids = p.get(key).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("`{key}` must be an array of asset ids (see assets.list)")))?; @@ -186,6 +376,7 @@ fn list(s: &mut Session, _: &Value) -> Result { "name": a.name, "nodes": a.nodes.iter().map(|n| n.0).collect::>(), "bounds": bounds.map(|b| [b.x0, b.y0, b.width(), b.height()]), + "format3D": a.format_3d, }) }) .collect(); @@ -227,8 +418,10 @@ fn export(s: &mut Session, p: &Value) -> Result { if q.get("subfolders").and_then(Value::as_bool) == Some(false) && p.get("folders").is_none() { q.insert("folders".into(), json!("none")); } + let d = &s.doc()?.doc; + // 3D assets are written as mesh files, by assets.export3D: the export refuses one it is given. if !q.contains_key("assets") { - let all: Vec = s.doc()?.doc.assets.iter().map(|a| a.id).collect(); + let all: Vec = d.assets.iter().filter(|a| a.format_3d.is_none()).map(|a| a.id).collect(); if all.is_empty() { return Err(bad(C, "no assets yet: collect art for export first (assets.add)")); } diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 8cf3a2195..5dcb82136 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -36,7 +36,8 @@ use brushes::{ brush_apply, brush_delete, brush_duplicate, brush_freehand, brush_get, brush_list, brush_move, brush_new, brush_options, brush_remove, brush_selection_art, brush_set_current, brush_unused, expand_brush, }; -use dynamic::{symbol_instance_reset, symbol_instance_set_appearance}; +pub(crate) use dynamic::{edit_part, part_only, selected_part}; +use dynamic::{symbol_instance_reset, symbol_instance_select_part, symbol_instance_set_appearance}; use symbols::selected_instances; pub(crate) use symbols::{ expand_instances_under, expandable_instances, instance_in_place, natural_size, normalize, place_xf, set_natural_size, store_symbol, @@ -266,6 +267,15 @@ pub fn specs() -> Vec { has_selection, symbol_instance_set_appearance ), + cmd!( + "symbol.instance.selectPart", + "Select Symbol Instance Part", + [], + None, + "{id, path: [i…] (as symbol.instance.setAppearance)} select one path, compound path or type object of a dynamic symbol instance's art, as a Direct Selection click on it does: the instance is selected alone, and until the selection changes paint.setFill and paint.setStroke with a solid color or none, paint.invert and paint.complement on a solid color (without ids, id or item; type whose characters differ in color is refused) and appearance.copyFrom with a solid fill and stroke (the Eyedropper, without ids, reverse or append; options that pick up neither color are refused) change that object's fill or stroke for this instance only; gradients and patterns are refused, and so are the commands that change the selected objects without ids (transparency.set, the gradient commands, object.group, edit.cut and edit.clear among them); edit.copy copies the instance whole, and selection exports, artboard.fitToSelection, object.liquify, object.isolate, library.add and select.save use the instance (docs/mcp.md); selectPart itself is refused for a static symbol, a path naming no object, a group, image or other kind of object, a hidden, locked or template object or one inside such a group, and a hidden or locked instance → {id, path}", + has_doc, + symbol_instance_select_part + ), cmd!( "symbol.instance.reset", "Reset Symbol Instance", diff --git a/crates/engine/src/cmd/brushsym/dynamic.rs b/crates/engine/src/cmd/brushsym/dynamic.rs index baa721c45..6197e57be 100644 --- a/crates/engine/src/cmd/brushsym/dynamic.rs +++ b/crates/engine/src/cmd/brushsym/dynamic.rs @@ -61,6 +61,62 @@ fn dynamic_instance(s: &Session, p: &Value, cmd: &str) -> Result<(NodeId, Arc Result)>> { + Ok(s.doc()?.selection.symbol_part.as_ref().map(|sp| (sp.instance, sp.path.clone()))) +} + +/// Change instance `id`'s own appearance for the object at `path` of its art by `f`, as one undo +/// step named `label`; nothing is recorded when the change leaves it as it was. Replies how many +/// objects the instance changes. +pub(crate) fn edit_part(s: &mut Session, id: NodeId, path: &[u32], label: &str, f: impl FnOnce(&mut SymbolOverride)) -> Result { + let list = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?.extras().symbol_overrides.clone(); + let old = list.iter().find(|o| o.path == path).cloned().unwrap_or_else(|| SymbolOverride { path: path.to_vec(), ..Default::default() }); + let mut new = old.clone(); + f(&mut new); + if new == old { + return Ok(list.len()); + } + s.edit(label, |d, _| { + let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; + let mut e = n.extras_mut(); + let list = &mut e.symbol_overrides; + list.retain(|o| o.path != path); + if !new.is_empty() { + list.push(new); + } + Ok(list.len()) + }) +} + +/// The refusal of anything but a solid color fill or stroke change while an object of a symbol +/// instance's art is selected ([`selected_part`]), and of selecting any other object of the art. +pub(crate) fn part_only() -> EngineError { + EngineError::Other("Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change".into()) +} + +pub(super) fn symbol_instance_select_part(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.instance.selectPart"; + if p.get("id").is_none() { + return Err(bad(C, "missing id")); + } + let (id, art) = dynamic_instance(s, p, C)?; + let path = path_param(p, &art, C)?; + // Direct Selection passes over hidden, locked and template objects, and over everything in a + // group or layer that is: the instance in the document, and the object in the symbol's art. + let editable = |n: &Node| n.visible && !n.locked && !n.is_template(); + if !s.doc()?.doc.is_editable(id) || !(0..=path.len()).all(|k| symbol_overrides::part(&art, &path[..k]).is_some_and(editable)) { + return Err(bad(C, "the object is hidden or locked")); + } + if !symbol_overrides::part(&art, &path).is_some_and(symbol_overrides::takes_part_paint) { + return Err(part_only()); + } + let shown = json!(path); + s.select(|_, sel| sel.set_symbol_part(id, path))?; + Ok(json!({ "id": id.0, "path": shown })) +} + /// The `path` param: child indices from the art's root, naming an object of `art`. fn path_param(p: &Value, art: &Node, cmd: &str) -> Result> { let path: Option> = @@ -117,16 +173,7 @@ pub(super) fn symbol_instance_set_appearance(s: &mut Session, p: &Value) -> Resu let d = &s.doc()?.doc; let old = d.node(id).and_then(|n| n.extras().symbol_overrides.iter().find(|o| o.path == path).cloned()); let new = changed(s, p, old.unwrap_or_else(|| SymbolOverride { path: path.clone(), ..Default::default() }), C)?; - let count = s.edit("Symbol Instance Appearance", |d, _| { - let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; - let mut e = n.extras_mut(); - let list = &mut e.symbol_overrides; - list.retain(|o| o.path != path); - if !new.is_empty() { - list.push(new); - } - Ok(list.len()) - })?; + let count = edit_part(s, id, &path, "Symbol Instance Appearance", |o| *o = new)?; Ok(json!({ "id": id.0, "overrides": count })) } diff --git a/crates/engine/src/cmd/brushsym/symbols.rs b/crates/engine/src/cmd/brushsym/symbols.rs index 69bfcb66e..60353921e 100644 --- a/crates/engine/src/cmd/brushsym/symbols.rs +++ b/crates/engine/src/cmd/brushsym/symbols.rs @@ -888,6 +888,8 @@ pub(super) fn symbol_adjust(s: &mut Session, p: &Value) -> Result { let radius = number_or(p, "radius", 40.0, C)?.clamp(1.0, 2000.0); let intensity = number_or(p, "intensity", 5.0, C)?.clamp(1.0, 10.0) / 10.0; let alt = switch_or(p, "alt", false, C)?; + // Refused while an object of a dynamic instance's art is selected: the instance stays as it is. + crate::cmd::edit::refuse_symbol_part(s)?; let st = s.doc()?; let color = match color_param(p, "color", C)? { Some(c) => crate::cmd::paint::new_color(s, p, c), diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index d54238925..bcaa59426 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -13,7 +13,7 @@ use vectorcraft_tools::distort::liquify::{Dabber, LiquifyParams, PathStroke, Sam use vectorcraft_tools::distort::perspective::{self as persp, PerspectiveGrid, Plane}; use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, project_node, stroke_owner, warp_from_rest, warp_node_with}; -use super::edit::{duplicate_in, outermost, selected_roots}; +use super::edit::{duplicate_in, outermost, refuse_symbol_part, selected_roots}; use super::{ CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_doc, has_selection, id_param, matrix_param, named_id, named_ids, number_or, ok, point_indices, point_param, str_param, switch_or, targets, @@ -730,8 +730,13 @@ fn puppet_warp(s: &mut Session, p: &Value) -> Result { if pins.iter().chain(&moved).any(|q| !q.x.is_finite() || !q.y.is_finite()) { return Err(bad(C, "pins must be finite")); } - let ids = - if rest { puppet_ids(p, || Ok(s.doc()?.selection.objects.clone()))? } else { outermost(&s.doc()?.doc, puppet_ids(p, || selected_roots(s))?) }; + // While an object of a dynamic symbol instance's art is selected, the selection is refused, as + // `selected_roots` refuses it. + let selected = || { + refuse_symbol_part(s)?; + Ok(s.doc()?.selection.objects.clone()) + }; + let ids = if rest { puppet_ids(p, selected)? } else { outermost(&s.doc()?.doc, puppet_ids(p, || selected_roots(s))?) }; let expand = f64_or(p, "expand", 3.0).clamp(0.0, 1000.0); let angles: Vec> = match p.get("angles") { None | Some(Value::Null) => vec![None; pins.len()], diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 21bd4f9bc..f6491d757 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -157,7 +157,7 @@ pub fn specs() -> Vec { "Cut", ["Edit"], Some("Cmd+X"), - "{lockedAndHidden?: bool (the Artboard tool chosen, as artboard.cut)} (selected ruler guides go too; the Artboard tool chosen: artboard.cut)", + "{lockedAndHidden?: bool (the Artboard tool chosen, as artboard.cut)} (selected ruler guides go too; the Artboard tool chosen: artboard.cut); refused while an object of a symbol instance's art is selected", has_selection_guides_or_artboard_tool, cut ), @@ -166,7 +166,7 @@ pub fn specs() -> Vec { "Copy", ["Edit"], Some("Cmd+C"), - "{lockedAndHidden?: bool (the Artboard tool chosen, as artboard.copy)} the selection with what it uses, and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy → {copied, guides}", + "{lockedAndHidden?: bool (the Artboard tool chosen, as artboard.copy)} the selection with what it uses, and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy; with an object of a symbol instance's art selected, the instance is copied whole → {copied, guides}", has_selection_guides_or_artboard_tool, copy ), @@ -220,7 +220,7 @@ pub fn specs() -> Vec { "Clear", ["Edit"], Some("Delete"), - "{ids?, guides?: bool (with ids: also delete the selected ruler guides)} delete the objects `ids`, else the selection (fully selected objects and direct-selected anchors together, else ruler guides)", + "{ids?, guides?: bool (with ids: also delete the selected ruler guides)} delete the objects `ids`, else the selection (fully selected objects and direct-selected anchors together, else ruler guides); without ids, refused while an object of a symbol instance's art is selected", has_selection_or_guides, clear ), @@ -829,16 +829,38 @@ fn shared_selection(selection: vectorcraft_doc::Selection, like: Option<&crate:: } /// Selected top-level objects in paint order, dropping any whose ancestor is also selected. +/// +/// Refused with [`super::brushsym::part_only`] while an object of a dynamic symbol instance's art +/// is selected ([`super::brushsym::selected_part`]): only the paint commands change that object, +/// and only its solid fill and stroke, so a command that gathers the selection here leaves the +/// instance as it is. Copy copies the instance whole ([`copy_cut_targets`]). pub(crate) fn selected_roots(s: &Session) -> Result> { + refuse_symbol_part(s)?; let st = s.doc()?; Ok(roots_of(&st.doc, st.selection.in_paint_order(&st.doc))) } +/// [`super::brushsym::part_only`] while an object of a dynamic symbol instance's art is selected: +/// for the commands that read the selected objects without [`selected_roots`]. A command that +/// changes nothing in the document ([`Session::reading`]) reads the instance instead. +pub(crate) fn refuse_symbol_part(s: &Session) -> Result<()> { + if !s.reading && s.doc()?.selection.symbol_part.is_some() { Err(super::brushsym::part_only()) } else { Ok(()) } +} + +/// [`refuse_symbol_part`] as a command's `enabled` check: the command is dimmed, and refused with +/// [`super::brushsym::part_only`]'s message, while an object of a dynamic symbol instance's art is +/// selected. +pub(crate) fn no_symbol_part(s: &Session) -> std::result::Result<(), String> { + if !s.reading && s.active().is_some_and(|st| st.selection.symbol_part.is_some()) { Err(super::brushsym::part_only().to_string()) } else { Ok(()) } +} + /// [`selected_roots`] for the commands that move or reshape the art (Move, Transform, Rotate…): a /// path of a compound path selected on its own (Group Selection, or in the isolated compound path) /// is an object of its own, so the hole of a ring moves without the ring (#2441). Paint and the -/// rest still go to the compound path, which holds the paint. +/// rest still go to the compound path, which holds the paint. Refused while an object of a symbol +/// instance's art is selected, as [`selected_roots`] is: it moves with its instance only. pub(crate) fn selected_roots_to_transform(s: &Session) -> Result> { + refuse_symbol_part(s)?; let st = s.doc()?; Ok(roots_within(&st.doc, st.selection.in_paint_order(&st.doc), |_, _| true)) } @@ -847,8 +869,10 @@ pub(crate) fn selected_roots_to_transform(s: &Session) -> Result> { /// ([`super::inside_isolation`]): `root` itself and objects outside it are left out before the /// objects held by another selected object are dropped. A selected path of a compound path stands /// for its compound unless the compound is `root` or `members` is set (as in -/// [`selected_roots_to_transform`]). +/// [`selected_roots_to_transform`]). Refused while an object of a symbol instance's art is +/// selected, as [`selected_roots`] is. pub(crate) fn selected_roots_inside(s: &Session, root: NodeId, members: bool) -> Result> { + refuse_symbol_part(s)?; let st = s.doc()?; let ids = st.selection.in_paint_order(&st.doc).into_iter().filter(|id| super::inside_isolation(&st.doc, root, *id)).collect(); Ok(roots_within(&st.doc, ids, |compound, _| members || compound == root)) @@ -925,6 +949,7 @@ fn copy(s: &mut Session, p: &Value) -> Result { if s.tool_id() == "artboard" { return super::panelcmds::artboard_copy(s, &artboard_clipboard_params(p), false); } + // With an object of a dynamic symbol instance's art selected, the instance is copied. let (direct, targets) = copy_cut_targets(s)?; let st = s.doc()?; // Selected ruler guides come along (#3226), as canvas guides: their layer and artboard stay @@ -965,6 +990,7 @@ fn cut(s: &mut Session, p: &Value) -> Result { if s.tool_id() == "artboard" { return super::panelcmds::artboard_copy(s, &artboard_clipboard_params(p), true); } + refuse_symbol_part(s)?; // Ruler guides selected on their own: Copy takes them, Clear removes them. if s.doc()?.selection.is_empty() { copy(s, p)?; @@ -1038,6 +1064,10 @@ fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result { fn clear(s: &mut Session, p: &Value) -> Result { let guides = switch_or(p, "guides", false, "edit.clear")?; let explicit = if p.get("ids").is_some() { Some(checked_ids_param(s, p, "ids", "edit.clear")?) } else { None }; + // An object of a symbol instance's art can't be removed from the instance alone. + if explicit.is_none() && s.doc()?.selection.symbol_part.is_some() { + return Err(super::brushsym::part_only()); + } // Direct-selected anchors or segments: remove those, and any objects selected whole with them. if explicit.is_none() && (!s.doc()?.selection.anchors.is_empty() || !s.doc()?.selection.segments.is_empty()) { return super::path::delete_anchors(s, p); @@ -1269,10 +1299,11 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho for (k, n) in clip.nodes.iter().enumerate() { let mut c = d.reid(n); imported.apply(&mut c); + let sc = vectorcraft_doc::Scaling { patterns: mode == PasteMode::Drag, ..Default::default() }; if xf != Affine::IDENTITY { // A cross-document drag carries the graphic's appearance with it, // including the pattern phase, independently of transform-tool settings. - c.transform(xf, vectorcraft_doc::Scaling { patterns: mode == PasteMode::Drag, ..Default::default() }); + c.transform(xf, sc); } let layer = match clip.source_layers.get(k) { Some(Some(name)) if remember => Some(*layers.entry(name).or_insert_with(|| layer_named(d, name))), @@ -1308,6 +1339,9 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho if let NodeKind::Path { live, .. } = &mut child_node.kind { *live = None; } + if child_node.extras().pixel_aligned { + child_node.extras_mut().pixel_aligned = false; + } let insert_idx = if idx == usize::MAX { usize::MAX } else { idx.saturating_add(offset) }; new_ids.push(d.insert(par, insert_idx, child_node)?); } @@ -1320,6 +1354,10 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho if let NodeKind::Path { live, .. } = &mut c.kind { *live = None; } + // A path inside a compound path snaps with the compound path, whose property it takes. + if c.extras().pixel_aligned { + c.extras_mut().pixel_aligned = false; + } new_ids.push(d.insert(par, idx, c)?); } _ => { @@ -1328,14 +1366,19 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho let offset = *placed.entry(outer_par).or_default(); *placed.entry(outer_par).or_default() += 1; let insert_idx = if beside_idx == usize::MAX { usize::MAX } else { beside_idx.saturating_add(offset) }; + super::object::keep_pixel_aligned(&mut c, sc); new_ids.push(d.insert(outer_par, insert_idx, c)?); } } } else { + super::object::keep_pixel_aligned(&mut c, sc); new_ids.push(d.insert(par, idx, c)?); } } } + for id in &new_ids { + super::object::keep_compound_aligned(d, *id, vectorcraft_doc::Scaling::default()); + } sel.set(new_ids.iter().copied()); // Copied ruler guides (#3226): on the layer new art goes to, moved as the objects are (a // paste of guides alone keeps them where they were, but onto another artboard or every @@ -1407,7 +1450,10 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { /// Duplicate `roots` directly above each original, transform the copies, select them /// ([`copy_above`]). pub(crate) fn duplicate_in(d: &mut Document, sel: &mut vectorcraft_doc::Selection, roots: &[NodeId], xf: Affine) -> Result> { - let out = copy_above(d, roots, |_, _, c| c.transform(xf, true))?; + let out = copy_above(d, roots, |_, _, c| { + c.transform(xf, true); + super::object::keep_pixel_aligned(c, true.into()); + })?; sel.set(out.iter().copied()); Ok(out) } diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 21f43e0ee..c7bee5400 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -18,6 +18,22 @@ use super::expand::has_seeded; use super::rasterfx; use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, fileio, has_doc, str_param, switch_or}; +/// The name of the `svg.filter` effect drawing `xml`: its `` id. An effect names the +/// document's filter of that name, so `xml` that differs from the document's filter of the same +/// name is refused. +fn svg_filter_name(s: &Session, xml: &str, cmd: &str) -> Result { + let name = effects::svg_filter::check(xml).map_err(|e| bad(cmd, e))?; + if let Some(held) = s.doc()?.doc.svg_filters.iter().find(|f| f.name == name) + && held.xml != xml.trim() + { + return Err(bad( + cmd, + format!("the document has another SVG filter named {name:?}: use svgFilter.apply, or svgFilter.define with `replace` to edit it"), + )); + } + Ok(name) +} + /// The effect commands of the Effect menu and the Appearance panel. pub fn specs() -> Vec { vec![ @@ -35,7 +51,7 @@ pub fn specs() -> Vec { "Effects", [], None, - "{ids?|id?: object ids (default: the selection), target?: \"object\"|\"contents\"} → {catalog: [{id, label, menu, params, defaults, raster, lengths: {always, absolute (while relative is false)} (distance params Scale Strokes & Effects scales)}] (installed effect plug-ins last: id plugin., menu Effect › Plug-ins), applied: [{id, effects, items: [{index, kind: fill|stroke, effects}]}], activeItem} for the selection", + "{ids?|id?: object ids (default: the selection), target?: \"object\"|\"contents\"} → {catalog: [{id, label, menu, params, defaults, raster, lengths: {always, absolute (while relative is false)} (distance params Scale Strokes & Effects scales)}] (installed effect plug-ins last: id plugin., menu Effect › Plug-ins), applied: [{id, effects, items: [{index, kind: fill|stroke, effects}]}] (an effect has skipped: true while Safe Mode skips it: a plug-in effect, which the object keeps and draws without), activeItem} for the selection", always, list ), @@ -71,7 +87,7 @@ pub fn specs() -> Vec { "Expand Appearance", ["Object"], None, - "{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; Vector Halftone (addon.color.halftone) becomes a group of its dot paths, one per ink; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. A dimension whose art it changes, or one selected with the art it measures when that art is replaced, becomes a plain group of its art. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", + "{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; Vector Halftone (addon.color.halftone) becomes a group of its dot paths, one per ink; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. A dimension whose art it changes, or one selected with the art it measures when that art is replaced, becomes a plain group of its art. Hidden fills, strokes and effects are dropped. In Safe Mode, an object with a visible plug-in effect (in it, or in a symbol it places) is an error and nothing changes. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", can_expand, expand_appearance ), @@ -245,6 +261,11 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { if texture_load_effect(id) { load_texture(&mut params, C)?; } + // An SVG filter needs its element, and is named by its id. + if id == effects::SVG_FILTER { + let xml = params.get("xml").and_then(Value::as_str).ok_or_else(|| bad(C, "give `xml`, one SVG element"))?; + params["name"] = json!(svg_filter_name(s, xml, C)?); + } let effect = effects::new_effect(id, ¶ms).ok_or_else(|| bad(C, format!("unknown effect `{id}`")))?; let label = effects::effect_info(id).map(|e| e.label.trim_end_matches('…').to_string()).unwrap_or_default(); aligned(p, C)?; @@ -307,7 +328,22 @@ fn list(s: &mut Session, p: &Value) -> Result { .collect(); let mut applied = vec![]; if s.active().is_some() { - let fx = |e: &[Effect]| serde_json::to_value(e).unwrap_or(Value::Null); + // A plug-in effect Safe Mode skips says so (`skipped`): the object keeps it. + let fx = |e: &[Effect]| { + Value::Array( + e.iter() + .map(|e| { + let mut v = serde_json::to_value(e).unwrap_or(Value::Null); + if vectorcraft_plugins::effect::skipped(&e.id) + && let Some(o) = v.as_object_mut() + { + o.insert("skipped".into(), json!(true)); + } + v + }) + .collect(), + ) + }; for id in appearance_targets(s, p)? { if let Some(n) = s.doc()?.doc.node(id) { let items: Vec = n @@ -521,6 +557,12 @@ fn set_params(s: &mut Session, p: &Value) -> Result { return Ok(json!({ "ids": [] })); } } + // An SVG filter is named by its id: another filter renames the effect. + if edited.iter().any(|e| e == effects::SVG_FILTER) + && let Some(xml) = params.get("xml").and_then(Value::as_str) + { + params["name"] = json!(svg_filter_name(s, xml, C)?); + } for &(id, target, index) in &targets { let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?; let effect = n.appearance.effects_at(target).and_then(|fx| fx.get(index)); @@ -879,8 +921,40 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br changed.then_some(n) } +/// Does `n`, an object in it, or the art of a symbol placed in it hold a plug-in effect that +/// expanding it draws: a visible one, the object's own or a visible fill's or stroke's, on an +/// object that expands ([`expandable`]; in a symbol's art, `in_art`, every object does). A symbol's +/// art counts where an instance of it expands. `seen` holds the symbols already looked into. +fn holds_plugin_effect(d: &Document, n: &Node, in_art: bool, seen: &mut std::collections::HashSet) -> bool { + let plugin = |e: &Effect| e.visible && vectorcraft_plugins::effect::plugin_id(&e.id).is_some(); + let mut symbols = vec![]; + let mut holds = false; + n.walk(&mut |c| { + if !in_art && !expandable(c) { + return; + } + holds |= c.appearance.effects.iter().any(plugin) || c.appearance.items.iter().any(|i| i.visible() && i.effects().iter().any(plugin)); + if let NodeKind::SymbolInstance { symbol, .. } = &c.kind { + symbols.push(symbol.clone()); + } + }); + holds + || symbols.into_iter().any(|name| { + seen.insert(name.clone()) && d.symbols.iter().find(|s| s.name == name).is_some_and(|s| holds_plugin_effect(d, &s.art, true, seen)) + }) +} + fn expand_appearance(s: &mut Session, p: &Value) -> Result { let roots = appearance_targets(s, p)?; + // In Safe Mode a plug-in effect draws nothing: expanding it would drop the effect and keep the + // geometry it hasn't changed. + if vectorcraft_plugins::safe_mode::active() { + let d = &s.doc()?.doc; + let mut seen = std::collections::HashSet::new(); + if roots.iter().filter_map(|id| d.node(*id)).any(|n| holds_plugin_effect(d, n, false, &mut seen)) { + return Err(EngineError::Other("Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode".into())); + } + } let failed = vectorcraft_render::decode_failures(); let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok(); let ids = s.edit("Expand Appearance", |d, _| { diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index d67b89a97..de056e4ec 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -407,8 +407,9 @@ fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option { let mut expanded = false; for item in &l.appearance.items { // Items that paint nothing go; a gradient that can't be expanded stays. - let art = gradient_fill(item).and_then(|(f, g)| { - let (fl, path, bounds) = fill_shape(l, item, &path, bounds)?; + let art = gradient_fill(item).and_then(|_| { + let (fl, path, bounds, moved) = fill_shape(l, item, &path, bounds)?; + let (f, g) = gradient_fill(&moved)?; gradient_art(d, &fl, &path, bounds, f, g, to) }); let Some(art) = art else { @@ -470,29 +471,39 @@ fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &F /// path (a plain path then), and every geometry effect of its own removed. `None` when it has no /// visible geometry effect. fn reshaped(l: &Node) -> Option { - use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, is_geometry}; + use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry, is_geometry}; if !has_geometry(&l.appearance.effects) { return None; } let (path, _) = node_path(l)?; - let g = apply_geometry_with(&l.appearance.effects, &path, path.bounds()?, &GeomContext::of(l)); + let (g, carry) = apply_geometry_carrying(&l.appearance.effects, &path, path.bounds()?, &GeomContext::of(l)); let mut m = with_path(l, g); + // Its gradients move with the outline, as the canvas paints them. + if carry.moves(&m.appearance) { + carry.apply(&mut m.appearance); + } m.appearance.effects.retain(|e| !is_geometry(&e.id)); Some(m) } /// Path `l` (outline `path`, box `bounds`) as its fill `item` paints it on the canvas, with its -/// outline and box: when the fill has visible geometry effects, `l` with the outline they make of -/// `path` as a plain path, and that outline's box. -fn fill_shape(l: &Node, item: &AppearanceItem, path: &PathData, bounds: Rect) -> Option<(Node, PathData, Rect)> { - use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry}; +/// outline, box and fill: when the fill has visible geometry effects, `l` with the outline they +/// make of `path` as a plain path, that outline's box, and the fill with its gradient moved along +/// with the outline. +fn fill_shape(l: &Node, item: &AppearanceItem, path: &PathData, bounds: Rect) -> Option<(Node, PathData, Rect, AppearanceItem)> { + use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry}; if !has_geometry(item.effects()) { - return Some((l.clone(), path.clone(), bounds)); + return Some((l.clone(), path.clone(), bounds, item.clone())); } // In the fill's context, as drawing runs them: a plug-in effect is told which fill it is on. - let outline = apply_geometry_with(item.effects(), path, bounds, &GeomContext::of(l).item(item)); + let (outline, carry) = apply_geometry_carrying(item.effects(), path, bounds, &GeomContext::of(l).item(item)); let b = outline.bounds()?; - Some((with_path(l, outline.clone()), outline, b)) + let mut one = Appearance { items: vec![item.clone()], ..Default::default() }; + if carry.moves(&one) { + carry.apply(&mut one); + } + let moved = one.items.pop().unwrap_or_else(|| item.clone()); + Some((with_path(l, outline.clone()), outline, b, moved)) } /// Path or compound path `l` with `path` as its path, a plain path. @@ -809,7 +820,7 @@ fn expand_patterns_where(d: &mut Document, l: &Node, which: impl Fn(&Document, & later -= usize::from(fill.is_some()); let room = if later > 0 { left.saturating_sub(l.count() + 1) } else { left }; let tiles = fill.filter(|_| !full).and_then(|(f, def, xf)| { - let (fl, _, bounds) = fill_shape(l, item, &path, bounds)?; + let (fl, _, bounds, _) = fill_shape(l, item, &path, bounds)?; let Some(t) = pattern_tiles(d, &def, xf, bounds, room) else { full = true; return None; diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 3f881b8e4..c453fecd7 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -17,6 +17,7 @@ //! - `eps`: EPS export options (flattened transparency, previews, the embedded document), `document.exportEps`. //! - `dxfimport`: the DXF options `document.open` and Place read, `document.dxfInfo`. //! - `metafile`: EMF and WMF export, open and place. +//! - `mesh3d`: 3D assets as OBJ, glTF, USDA and USDZ mesh files (`assets.export3D`). //! //! [`FORMATS`] is the single list of formats (append-only); open dialogs use [`open_filters`], //! agents query `document.formats`. @@ -32,6 +33,7 @@ mod export; pub(crate) mod imagemap; mod legacy_ai; mod load; +mod mesh3d; pub(crate) use load::{MAX_RASTER_ALLOC, size_note}; mod heifread; mod jp2read; @@ -74,6 +76,8 @@ pub use load::{ read_for_open, texture_image, }; pub use load::{losses_summary, overwrite_losses}; +pub use mesh3d::{DEFAULT_FORMAT_3D, FORMATS_3D, format_3d_label}; +pub(crate) use mesh3d::{mesh_file_names, mesh_files}; pub use naming::{NameParts as FileNameParts, parse_date as parse_file_date, render as render_file_name}; pub(crate) use native::preview_png; pub use native::with_compression_pref; diff --git a/crates/engine/src/cmd/fileio/dxf.rs b/crates/engine/src/cmd/fileio/dxf.rs index 4320cbb9c..32b87d80d 100644 --- a/crates/engine/src/cmd/fileio/dxf.rs +++ b/crates/engine/src/cmd/fileio/dxf.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use serde::Deserialize; use serde_json::{Value, json}; -use vectorcraft_cad::{ColorDepth, DxfOptions, DxfVersion, Preserve, RasterFormat}; +use vectorcraft_cad::{ColorDepth, DxfOptions, DxfPreset, DxfVersion, Preserve, RasterFormat}; use vectorcraft_doc::{Document, Unit}; use vectorcraft_geom::Rect; @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Export DXF", [], None, - "{path?, version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}", + "{path?, dxfPreset?: custom (default)|cnc (CNC / CAM: version 2000, the oldest version that has both lightweight polylines and lineweights; unit mm, scale 1; colors 256, all 2000 holds; preserve editability and alterPaths false, so strokes stay center lines; outlineText true, so type is paths; rasterFormat png; the options given override it), version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}", has_doc, export_dxf )] @@ -33,6 +33,12 @@ pub(super) const OPTIONS: &[FormatOption] = &[ super::ARTBOARDS, super::RANGE, super::USE_ARTBOARDS, + FormatOption { + name: "dxfPreset", + ty: "string", + default: "\"custom\"", + description: "custom, or cnc (CNC / CAM: version 2000, mm, 256 colors, editability, type as outlines, PNG images); the options given override it", + }, FormatOption { name: "version", ty: "string", default: "\"2018\"", description: "R12, R13, R14, 2000, 2004, 2007, 2010, 2013 or 2018" }, FormatOption { name: "unit", @@ -120,6 +126,7 @@ impl Unsupported { struct Params { #[serde(flatten)] boards: ArtboardPick, + dxf_preset: Option, version: Option, unit: Option, scale: Option, @@ -150,7 +157,8 @@ fn choice(field: &str, v: Option, read: impl Fn(&str) -> Option, d /// The DXF options in `p` (the region and crop are set per drawing) and the artboard choice. fn parse(p: &Value) -> Result<(DxfOptions, ArtboardPick)> { let q: Params = if p.is_object() { Params::deserialize(p).map_err(|e| bad(C, format!("DXF options: {e}")))? } else { Params::default() }; - let d = DxfOptions::default(); + // The options a preset sets are the defaults of those `p` leaves out. + let d = choice("dxfPreset", q.dxf_preset, DxfPreset::from_id, DxfPreset::Custom, "custom or cnc")?.options(); let scale = q.scale.unwrap_or(d.scale); if !(scale.is_finite() && vectorcraft_cad::SCALE_RANGE.contains(&scale)) { return Err(bad(C, format!("DXF scale must be a positive number up to {}, not {scale}", vectorcraft_cad::SCALE_RANGE.end()))); @@ -181,6 +189,11 @@ pub fn check(p: &Value) -> std::result::Result<(), String> { parse(p).map(|_| ()).map_err(|e| e.to_string()) } +/// The DXF options in `p` (the region and crop left at their defaults). +pub fn options(p: &Value) -> std::result::Result { + parse(p).map(|(o, _)| o).map_err(|e| e.to_string()) +} + /// Encode `doc` as DXF: one drawing whose origin is the chosen artboard's bottom-left corner /// (default the first), or with `use_artboards` one drawing per chosen artboard (default all) /// holding the art over it. diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index 13564614b..0869ad850 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -214,10 +214,11 @@ fn selection(s: &mut Session, cmd: &str) -> Result<(Document, vectorcraft_geom:: } /// The selected objects (their top-most selected ancestors) that are artwork: those on template -/// layers are guides and are left out. +/// layers are guides and are left out. With an object of a dynamic symbol instance's art selected, +/// the instance is exported whole. pub(crate) fn selected_artwork(s: &Session) -> Result> { - let ids = edit::selected_roots(s)?; let st = s.doc()?; + let ids = edit::roots_of(&st.doc, st.selection.in_paint_order(&st.doc)); let is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. })); // On a template layer or sublayer, at any depth. let on_template = |id| st.doc.ancestry(id).is_some_and(|a| a.into_iter().any(is_template)); diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 03bfde85b..7ff22689c 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -29,6 +29,8 @@ pub struct Loaded { /// A PDF, or a `.ai` or `.ait` that holds a PDF, read through the editor's own copy of its art /// that it carries (its layers), not from its PDF part alone. pub editing_data: bool, + /// Whether the native source declared a PDF; other formats carry no native save preference. + pub native_pdf_compatible: Option, } impl Loaded { @@ -341,6 +343,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result let mut converted = false; let mut preview_only = false; let mut editing_data = false; + let mut native_pdf_compatible = None; let (mut doc, mut warnings, restored) = match format.id { // EPS, .ps and PostScript .ai: the document our EPS files carry, else the PostScript read. _ if matches!(format.id, "eps" | "ps") || vectorcraft_pdf::is_postscript(bytes) => { @@ -353,6 +356,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result } "vectorcraft" | "template" => { let (d, info, warnings) = native_file(bytes)?; + native_pdf_compatible = Some(info.pdf_compatible); converted = info.is_old() || super::extension(name) == vectorcraft_format::LEGACY_EXTENSION; (d, warnings, false) } @@ -500,7 +504,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result doc.title = title; } warnings.extend(doc.images.values().filter_map(|b| size_note(&b.bytes))); - Ok(Loaded { doc, format, warnings, restored, converted, preview_only, editing_data }) + Ok(Loaded { doc, format, warnings, restored, converted, preview_only, editing_data, native_pdf_compatible }) } /// Import SVG text, reading the files its images link to (relative links from `folder`, the SVG's @@ -593,7 +597,7 @@ pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&st /// Make a loaded file the new active document; `links` is what became of its linked files, and /// `opts` are the options it was read with. fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option, opts: &LoadOptions, as_template: bool) -> Result { - let Loaded { mut doc, format, warnings, restored, converted, .. } = loaded.with_grid_prefs(&s.prefs); + let Loaded { mut doc, format, warnings, restored, converted, native_pdf_compatible, .. } = loaded.with_grid_prefs(&s.prefs); // EPS now has an ordinary Save path. Its preview, partial-program and damaged-editing // fallbacks must ask before overwriting the richer source they could not fully read. let losses: Vec = warnings @@ -630,6 +634,9 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option, USD +//! (USDZ: ). + +use std::fmt::Write as _; + +use serde_json::{Value, json}; +use vectorcraft_render::effects::SolidMesh; + +/// The formats a 3D asset exports to, as `assets.set3DFormat` names them: USDA first, the default. +pub const FORMATS_3D: [&str; 4] = ["usda", "usdz", "gltf", "obj"]; +/// The format a new 3D asset exports to. +pub const DEFAULT_FORMAT_3D: &str = "usda"; +/// The meters in a point (1/72 inch). +const METERS_PER_POINT: f64 = 0.0254 / 72.0; + +/// The label of `format` for menus: `USDA`, `USDZ`, `glTF`, `OBJ`. +pub fn format_3d_label(format: &str) -> &'static str { + match format { + "usdz" => "USDZ", + "gltf" => "glTF", + "obj" => "OBJ", + _ => "USDA", + } +} + +/// `stem` as an OBJ file name: whitespace and `#` become `_`, so the `mtllib` line names the MTL +/// file as one word. +fn obj_stem(stem: &str) -> String { + stem.chars().map(|c| if c.is_whitespace() || c == '#' { '_' } else { c }).collect() +} + +/// The names of the files [`mesh_files`] writes for `stem` in `format`. +pub(crate) fn mesh_file_names(stem: &str, format: &str) -> Result, String> { + match format { + "obj" => { + let stem = obj_stem(stem); + Ok(vec![format!("{stem}.obj"), format!("{stem}.mtl")]) + } + "gltf" | "usda" | "usdz" => Ok(vec![format!("{stem}.{format}")]), + _ => { + let key = "format"; + Err(format!("`{key}` must be one of {}", FORMATS_3D.join(", "))) + } + } +} + +/// The files of a 3D object named `stem` in `format` ([`FORMATS_3D`]): `(file name, bytes)`, the +/// OBJ with its MTL after it. +pub(crate) fn mesh_files(stem: &str, format: &str, parts: &[SolidMesh]) -> Result)>, String> { + let names = mesh_file_names(stem, format)?; + let bytes = match format { + "obj" => { + let (obj, mtl) = obj(&obj_stem(stem), parts); + vec![obj.into_bytes(), mtl.into_bytes()] + } + "gltf" => vec![gltf(stem, parts).into_bytes()], + "usda" => vec![usda(stem, parts).into_bytes()], + _ => vec![super::zip::store_aligned(&[(format!("{stem}.usda"), usda(stem, parts).into_bytes())], 64)?], + }; + Ok(names.into_iter().zip(bytes).collect()) +} + +/// The distinct colors of `parts` in order, and each part's index among them. +fn materials(parts: &[SolidMesh]) -> (Vec<[f32; 4]>, Vec) { + let mut colors: Vec<[f32; 4]> = vec![]; + let index = parts + .iter() + .map(|p| { + colors.iter().position(|c| *c == p.color).unwrap_or_else(|| { + colors.push(p.color); + colors.len() - 1 + }) + }) + .collect(); + (colors, index) +} + +/// An sRGB channel (0..1) as linear light. +fn linear(c: f32) -> f32 { + if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) } +} + +/// A number as text: the shortest decimal that reads back as the same value. +fn num(v: f64) -> String { + format!("{v}") +} + +fn obj(stem: &str, parts: &[SolidMesh]) -> (String, String) { + let (colors, material) = materials(parts); + let mut o = String::new(); + let _ = writeln!(o, "mtllib {stem}.mtl"); + let _ = writeln!(o, "o {stem}"); + for p in parts { + for v in &p.mesh.positions { + let _ = writeln!(o, "v {} {} {}", num(v[0]), num(v[1]), num(v[2])); + } + } + for p in parts { + for n in &p.mesh.normals { + let _ = writeln!(o, "vn {} {} {}", num(n[0]), num(n[1]), num(n[2])); + } + } + let mut base = 1usize; + for (k, p) in parts.iter().enumerate() { + let _ = writeln!(o, "g Solid_{}", k + 1); + let _ = writeln!(o, "usemtl Material_{}", material.get(k).copied().unwrap_or(0) + 1); + for t in &p.mesh.triangles { + let [a, b, c] = t.map(|i| i as usize + base); + let _ = writeln!(o, "f {a}//{a} {b}//{b} {c}//{c}"); + } + base += p.mesh.positions.len(); + } + let mut m = String::new(); + for (k, c) in colors.iter().enumerate() { + let _ = writeln!(m, "newmtl Material_{}", k + 1); + let _ = writeln!(m, "Kd {} {} {}", num(f64::from(c[0])), num(f64::from(c[1])), num(f64::from(c[2]))); + let _ = writeln!(m, "d {}", num(f64::from(c[3]))); + let _ = writeln!(m, "illum 1"); + } + (o, m) +} + +fn gltf(stem: &str, parts: &[SolidMesh]) -> String { + let (colors, material) = materials(parts); + let mut buffer: Vec = vec![]; + let (mut views, mut accessors, mut primitives) = (vec![], vec![], vec![]); + let mut view = |buffer: &mut Vec, bytes: Vec, target: u32| -> usize { + views.push(json!({ "buffer": 0, "byteOffset": buffer.len(), "byteLength": bytes.len(), "target": target })); + buffer.extend(bytes); + views.len() - 1 + }; + for (k, p) in parts.iter().enumerate() { + let floats = |v: &[[f64; 3]], scale: f64| v.iter().flatten().flat_map(|x| ((x * scale) as f32).to_le_bytes()).collect::>(); + let (lo, hi) = p.mesh.bounds().unwrap_or_default(); + let positions = view(&mut buffer, floats(&p.mesh.positions, METERS_PER_POINT), 34962); + let normals = view(&mut buffer, floats(&p.mesh.normals, 1.0), 34962); + let indices = view(&mut buffer, p.mesh.triangles.iter().flatten().flat_map(|i| i.to_le_bytes()).collect(), 34963); + let count = p.mesh.positions.len(); + let at = accessors.len(); + accessors.push(json!({ + "bufferView": positions, "componentType": 5126, "count": count, "type": "VEC3", + "min": lo.map(|v| (v * METERS_PER_POINT) as f32), "max": hi.map(|v| (v * METERS_PER_POINT) as f32), + })); + accessors.push(json!({ "bufferView": normals, "componentType": 5126, "count": count, "type": "VEC3" })); + accessors.push(json!({ "bufferView": indices, "componentType": 5125, "count": p.mesh.triangles.len() * 3, "type": "SCALAR" })); + primitives.push(json!({ + "attributes": { "POSITION": at, "NORMAL": at + 1 }, + "indices": at + 2, + "material": material.get(k).copied().unwrap_or(0), + })); + } + let materials: Vec = colors + .iter() + .enumerate() + .map(|(k, c)| { + let mut m = json!({ + "name": format!("Material_{}", k + 1), + "pbrMetallicRoughness": { + "baseColorFactor": [linear(c[0]), linear(c[1]), linear(c[2]), c[3]], + "metallicFactor": 0.0, + "roughnessFactor": 1.0, + }, + "doubleSided": true, + }); + if c[3] < 1.0 { + m["alphaMode"] = json!("BLEND"); + } + m + }) + .collect(); + let doc = json!({ + "asset": { "version": "2.0", "generator": "VectorCraft" }, + "scene": 0, + "scenes": [{ "nodes": [0] }], + "nodes": [{ "name": stem, "mesh": 0 }], + "meshes": [{ "name": stem, "primitives": primitives }], + "materials": materials, + "buffers": [{ + "byteLength": buffer.len(), + "uri": format!("data:application/octet-stream;base64,{}", vectorcraft_format::base64_encode(&buffer)), + }], + "bufferViews": views, + "accessors": accessors, + }); + serde_json::to_string_pretty(&doc).unwrap_or_default() +} + +/// `name` as a USD prim name: letters, digits and `_`, not starting with a digit. +fn prim_name(name: &str) -> String { + let mut out: String = name.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }).collect(); + if out.is_empty() || out.starts_with(|c: char| c.is_ascii_digit()) { + out.insert(0, '_'); + } + out +} + +fn usda(stem: &str, parts: &[SolidMesh]) -> String { + let (colors, material) = materials(parts); + let root = prim_name(stem); + let triple = |v: [f64; 3]| format!("({}, {}, {})", num(v[0]), num(v[1]), num(v[2])); + let list = |items: Vec| items.join(", "); + let mut u = String::new(); + let _ = writeln!(u, "#usda 1.0"); + let _ = writeln!(u, "(\n defaultPrim = \"{root}\"\n metersPerUnit = {}\n upAxis = \"Y\"\n)\n", METERS_PER_POINT); + let _ = writeln!(u, "def Xform \"{root}\"\n{{"); + for (k, p) in parts.iter().enumerate() { + let (lo, hi) = p.mesh.bounds().unwrap_or_default(); + let m = material.get(k).copied().unwrap_or(0) + 1; + let _ = writeln!(u, " def Mesh \"Solid_{}\" (\n prepend apiSchemas = [\"MaterialBindingAPI\"]\n )\n {{", k + 1); + let _ = writeln!(u, " uniform bool doubleSided = 1"); + let _ = writeln!(u, " float3[] extent = [{}, {}]", triple(lo), triple(hi)); + let _ = writeln!(u, " int[] faceVertexCounts = [{}]", list(vec!["3".to_string(); p.mesh.triangles.len()])); + let _ = writeln!(u, " int[] faceVertexIndices = [{}]", list(p.mesh.triangles.iter().flatten().map(u32::to_string).collect())); + let _ = writeln!(u, " rel material:binding = "); + let _ = writeln!( + u, + " normal3f[] normals = [{}] (\n interpolation = \"vertex\"\n )", + list(p.mesh.normals.iter().map(|n| triple(*n)).collect()) + ); + let _ = writeln!(u, " point3f[] points = [{}]", list(p.mesh.positions.iter().map(|v| triple(*v)).collect())); + let c = p.color; + let _ = writeln!( + u, + " color3f[] primvars:displayColor = [({}, {}, {})] (\n interpolation = \"constant\"\n )", + linear(c[0]), + linear(c[1]), + linear(c[2]) + ); + let _ = writeln!(u, " float[] primvars:displayOpacity = [{}] (\n interpolation = \"constant\"\n )", c[3]); + let _ = writeln!(u, " uniform token subdivisionScheme = \"none\"\n }}\n"); + } + let _ = writeln!(u, " def Scope \"Materials\"\n {{"); + for (k, c) in colors.iter().enumerate() { + let m = k + 1; + let _ = writeln!(u, " def Material \"Material_{m}\"\n {{"); + let _ = writeln!(u, " token outputs:surface.connect = \n"); + let _ = writeln!(u, " def Shader \"Surface\"\n {{"); + let _ = writeln!(u, " uniform token info:id = \"UsdPreviewSurface\""); + let _ = writeln!(u, " color3f inputs:diffuseColor = ({}, {}, {})", linear(c[0]), linear(c[1]), linear(c[2])); + let _ = writeln!(u, " float inputs:opacity = {}", c[3]); + let _ = writeln!(u, " float inputs:roughness = 1"); + let _ = writeln!(u, " token outputs:surface\n }}\n }}"); + } + let _ = writeln!(u, " }}\n}}"); + u +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/cmd/fileio/mesh3d/tests.rs b/crates/engine/src/cmd/fileio/mesh3d/tests.rs new file mode 100644 index 000000000..7c467fafa --- /dev/null +++ b/crates/engine/src/cmd/fileio/mesh3d/tests.rs @@ -0,0 +1,130 @@ +use serde_json::Value; +use vectorcraft_render::effects::{Mesh, SolidMesh}; + +use super::*; + +/// A 10 × 10 square facing the viewer: two triangles, each with corners of its own. +fn square(color: [f32; 4]) -> SolidMesh { + let [a, b, c, d] = [[-5.0, -5.0, 0.0], [5.0, -5.0, 0.0], [5.0, 5.0, 0.0], [-5.0, 5.0, 0.0]]; + let mesh = Mesh { positions: vec![a, b, c, a, c, d], normals: vec![[0.0, 0.0, 1.0]; 6], triangles: vec![[0, 1, 2], [3, 4, 5]] }; + SolidMesh { mesh, color } +} + +const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0]; +const BLUE: [f32; 4] = [0.0, 0.0, 1.0, 0.5]; + +fn text(files: &[(String, Vec)], name: &str) -> String { + let (_, bytes) = files.iter().find(|(n, _)| n == name).unwrap_or_else(|| panic!("no {name}")); + String::from_utf8(bytes.clone()).unwrap() +} + +#[test] +fn obj_numbers_corners_across_solids_and_names_its_materials() { + let files = mesh_files("Box", "obj", &[square(RED), square(BLUE), square(RED)]).unwrap(); + assert_eq!(files.iter().map(|(n, _)| n.as_str()).collect::>(), ["Box.obj", "Box.mtl"]); + let obj = text(&files, "Box.obj"); + assert!(obj.starts_with("mtllib Box.mtl\no Box\n")); + assert_eq!(obj.lines().filter(|l| l.starts_with("v ")).count(), 18); + assert_eq!(obj.lines().filter(|l| l.starts_with("vn ")).count(), 18); + // The third solid's last triangle uses corners 13 to 18; red solids share Material_1. + assert_eq!(obj.lines().rfind(|l| l.starts_with("f ")), Some("f 16//16 17//17 18//18")); + assert_eq!(obj.lines().filter(|l| l.starts_with("usemtl ")).collect::>(), ["usemtl Material_1", "usemtl Material_2", "usemtl Material_1"]); + let mtl = text(&files, "Box.mtl"); + assert_eq!(mtl.matches("newmtl").count(), 2); + assert!(mtl.contains("newmtl Material_2\nKd 0 0 1\nd 0.5\n"), "{mtl}"); +} + +#[test] +fn gltf_holds_its_buffer_and_every_accessor_fits_it() { + let files = mesh_files("Box", "gltf", &[square(RED), square(BLUE)]).unwrap(); + let g: Value = serde_json::from_str(&text(&files, "Box.gltf")).unwrap(); + assert_eq!(g["asset"]["version"], "2.0"); + let uri = g["buffers"][0]["uri"].as_str().unwrap(); + let data = vectorcraft_format::base64_decode(uri.strip_prefix("data:application/octet-stream;base64,").unwrap()).unwrap(); + assert_eq!(g["buffers"][0]["byteLength"].as_u64(), Some(data.len() as u64)); + for v in g["bufferViews"].as_array().unwrap() { + assert!(v["byteOffset"].as_u64().unwrap() + v["byteLength"].as_u64().unwrap() <= data.len() as u64); + assert_eq!(v["byteOffset"].as_u64().unwrap() % 4, 0); + } + let prims = g["meshes"][0]["primitives"].as_array().unwrap(); + assert_eq!(prims.len(), 2); + assert_eq!((prims[0]["material"].as_u64(), prims[1]["material"].as_u64()), (Some(0), Some(1))); + // Positions are meters: the 10 pt square is 10/72 inch across. + let pos = &g["accessors"][prims[0]["attributes"]["POSITION"].as_u64().unwrap() as usize]; + let width = pos["max"][0].as_f64().unwrap() - pos["min"][0].as_f64().unwrap(); + assert!((width - 10.0 * 0.0254 / 72.0).abs() < 1e-7, "{width}"); + let index = &g["accessors"][prims[0]["indices"].as_u64().unwrap() as usize]; + assert_eq!((index["componentType"].as_u64(), index["count"].as_u64()), (Some(5125), Some(6))); + // Translucent paint blends; colors are linear. + assert_eq!(g["materials"][1]["alphaMode"], "BLEND"); + assert!(g["materials"][0].get("alphaMode").is_none()); + assert_eq!(g["materials"][0]["doubleSided"], true); +} + +#[test] +fn usda_is_y_up_in_points_with_a_material_for_each_color() { + let files = mesh_files("My Box", "usda", &[square(RED), square([0.5, 0.5, 0.5, 1.0])]).unwrap(); + let u = text(&files, "My Box.usda"); + assert!(u.starts_with("#usda 1.0\n")); + assert!(u.contains("defaultPrim = \"My_Box\"") && u.contains("upAxis = \"Y\"") && u.contains("def Xform \"My_Box\""), "{u}"); + assert!(u.contains(&format!("metersPerUnit = {}", 0.0254 / 72.0))); + assert_eq!(u.matches("def Mesh").count(), 2); + assert_eq!(u.matches("def Material").count(), 2); + assert!(u.contains("int[] faceVertexCounts = [3, 3]")); + assert!(u.contains("rel material:binding = ")); + // sRGB 0.5 is about 0.214 linear. + assert!(u.contains("color3f inputs:diffuseColor = (0.21404"), "{u}"); + assert_eq!(u.matches('{').count(), u.matches('}').count()); +} + +#[test] +fn usdz_stores_the_layer_uncompressed_on_a_64_byte_boundary() { + let files = mesh_files("Box", "usdz", &[square(RED)]).unwrap(); + let (name, zip) = &files[0]; + assert_eq!(name, "Box.usdz"); + // The local header: 30 bytes, the name, then the extra field. + let u16_at = |at: usize| u16::from_le_bytes([zip[at], zip[at + 1]]) as usize; + assert_eq!(&zip[..4], &[0x50, 0x4b, 0x03, 0x04]); + assert_eq!(u16_at(8), 0, "stored, not deflated"); + let start = 30 + u16_at(26) + u16_at(28); + assert_eq!(start % 64, 0); + let layer = mesh_files("Box", "usda", &[square(RED)]).unwrap().remove(0).1; + assert_eq!(&zip[start..start + layer.len()], layer.as_slice()); + let archive = super::super::zip::Archive::open(zip, 8).unwrap(); + assert_eq!(archive.read("Box.usda", usize::MAX).unwrap(), Some(layer)); +} + +#[test] +fn an_obj_names_its_mtl_file_without_spaces_or_comment_marks() { + let files = mesh_files("Box #1", "obj", &[square(RED)]).unwrap(); + assert_eq!(files.iter().map(|(n, _)| n.as_str()).collect::>(), ["Box__1.obj", "Box__1.mtl"]); + assert!(text(&files, "Box__1.obj").starts_with("mtllib Box__1.mtl\n")); + assert_eq!(mesh_file_names("Box #1", "obj").unwrap(), ["Box__1.obj", "Box__1.mtl"]); + assert_eq!(mesh_file_names("Box #1", "usda").unwrap(), ["Box #1.usda"]); +} + +#[test] +fn obj_and_usda_keep_coordinates_a_32_bit_float_would_merge() { + let mut part = square(RED); + part.mesh.positions[0] = [20_000_000.0, 0.0, 0.0]; + part.mesh.positions[1] = [20_000_001.0, 0.0, 0.0]; + let obj = text(&mesh_files("Box", "obj", std::slice::from_ref(&part)).unwrap(), "Box.obj"); + assert!(obj.contains("v 20000000 0 0\nv 20000001 0 0\n"), "{obj}"); + let usda = text(&mesh_files("Box", "usda", &[part]).unwrap(), "Box.usda"); + assert!(usda.contains("(20000001, 0, 0)")); +} + +#[test] +fn an_unknown_format_is_refused() { + assert!(mesh_files("Box", "fbx", &[square(RED)]).is_err()); + assert!(mesh_file_names("Box", "").is_err()); + assert_eq!(mesh_file_names("Box", "obj").unwrap(), ["Box.obj", "Box.mtl"]); +} + +#[test] +fn prim_names_are_usd_identifiers() { + assert_eq!(prim_name("Logo 2"), "Logo_2"); + assert_eq!(prim_name("3D"), "_3D"); + assert_eq!(prim_name(""), "_"); + assert_eq!(prim_name("é/x"), "__x"); +} diff --git a/crates/engine/src/cmd/fileio/native.rs b/crates/engine/src/cmd/fileio/native.rs index 74cfced2b..4e2e9a85f 100644 --- a/crates/engine/src/cmd/fileio/native.rs +++ b/crates/engine/src/cmd/fileio/native.rs @@ -48,7 +48,7 @@ pub const OPTIONS: &[FormatOption] = &[ FormatOption { name: "pdfCompatible", ty: "boolean", - default: "false", + default: "true", description: "also carry a PDF of every artboard, for apps that read PDF (a larger file)", }, ]; @@ -146,7 +146,7 @@ struct NativeOptions { precision: Option, include_linked: bool, embed_profiles: Option, - pdf_compatible: bool, + pdf_compatible: Option, } /// What [`save_options`] leaves to [`encode`] (rendering and encoding take time). @@ -171,7 +171,9 @@ pub(super) fn save_options(cmd: &str, f: &Format, doc: &Document, p: &Value) -> so.profiles = embedded_profiles(doc); } so.check().map_err(|e| bad(cmd, e.to_string()))?; - Ok((so, Extras { preview: o.preview, pdf: o.pdf_compatible })) + let pdf = o.pdf_compatible.unwrap_or(true); + so.pdf_compatible = Some(pdf); + Ok((so, Extras { preview: o.preview, pdf })) } /// The `precision` option `v`: a whole number of decimal places, 0 to @@ -213,9 +215,11 @@ pub(super) fn encode_to(cmd: &str, f: &Format, doc: &Document, p: &Value, w: imp if extras.preview { so.preview = preview_png(&drawn)?; } - if extras.pdf { - // Every artboard as PDF pages (no editing data: the file is the native document). - so.pdf = Some(super::pdf::encode(cmd, &drawn, &json!({ "range": "all", "preserveEditing": false }))?.0); + if extras.pdf && !drawn.artboards.is_empty() { + // Every artboard as PDF pages (no editing data: the file is the native document), dated by + // the document's File Info, so the same document and dates give the same bytes. + let at = drawn.metadata.modified.or(drawn.metadata.created); + so.pdf = Some(super::pdf::encode_at(cmd, &drawn, &json!({ "range": "all", "preserveEditing": false }), at)?.0); } drop(drawn); let native = match full { diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 785764db9..52e7489dd 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -240,9 +240,13 @@ pub fn expand_preset<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result Result { + options_at(cmd, doc, p, vectorcraft_doc::metadata::output_time()) +} + +/// [`options`] modified at `now` (Unix seconds) instead of the time of writing. +fn options_at(cmd: &str, doc: &Document, p: &Value, now: Option) -> Result { let pick = if p.is_object() { ArtboardPick::deserialize(p).map_err(|e| bad(cmd, format!("PDF options: {e}")))? } else { ArtboardPick::default() }; let artboards = pick.resolve(doc.artboards.len()).map_err(|e| bad(cmd, e))?; - let now = vectorcraft_doc::metadata::output_time(); Ok(PdfOptions { settings: settings(cmd, p)?, artboards, @@ -265,9 +269,14 @@ pub const BLEED_LEFT_OUT: &str = "the document sets a bleed that this PDF/X file /// to images at the document's raster effects resolution. Preserve Editing embeds `doc` itself /// when the PDF has every artboard (a PDF of some reopens as just those). pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec, Vec)> { + encode_at(cmd, doc, p, None) +} + +/// [`encode`] modified at `modified` (Unix seconds) when given, instead of the time of writing. +pub(super) fn encode_at(cmd: &str, doc: &Document, p: &Value, modified: Option) -> Result<(Vec, Vec)> { // Keep the editable object tree independent of preview fallbacks used by the drawn pages. let (full, mut warnings) = crate::cmd::place::document::full_documents(doc); - let (bytes, drawn_warnings) = encode_carrying(cmd, &full, p, || Ok(vectorcraft_format::save(doc, false)), || Ok(None))?; + let (bytes, drawn_warnings) = encode_carrying_at(cmd, &full, p, modified, || Ok(vectorcraft_format::save(doc, false)), || Ok(None))?; warnings.extend(drawn_warnings); warnings.extend(overflow_warning(cmd, &full, p)?); Ok((bytes, warnings)) @@ -294,7 +303,20 @@ pub(super) fn encode_carrying( native: impl FnOnce() -> Result>, private: impl FnOnce() -> Result)>>, ) -> Result<(Vec, Vec)> { - let mut opts = options(cmd, doc, p)?; + encode_carrying_at(cmd, doc, p, None, native, private) +} + +/// [`encode_carrying`] modified at `modified` (Unix seconds) when given, instead of the time of +/// writing. +fn encode_carrying_at( + cmd: &str, + doc: &Document, + p: &Value, + modified: Option, + native: impl FnOnce() -> Result>, + private: impl FnOnce() -> Result)>>, +) -> Result<(Vec, Vec)> { + let mut opts = options_at(cmd, doc, p, modified.or_else(vectorcraft_doc::metadata::output_time))?; // The pages' background colors, behind the art (the editing data keeps them as colors). let pages = opts.artboards.clone().unwrap_or_else(|| (0..doc.artboards.len()).collect()); let filled = super::export::with_artboard_fills(doc, p, pages).map_err(|e| bad(cmd, e))?; diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 0eefa45c0..893b783bf 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -270,7 +270,18 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result { } } // None given: the ones the document was last saved with in this format. - let options = if given.is_empty() && format.id == st.format { remembered_save_options(st) } else { given }; + let mut options = if given.is_empty() && format.id == st.format { remembered_save_options(st) } else { given }; + // Remember the effective choice, including the encoder's default, so another document's + // later save cannot change what this one writes. Invalid non-null values still reach validation. + if matches!(format.id, "vectorcraft" | "template") { + let pdf = match options.get("pdfCompatible") { + None | Some(Value::Null) => Some(native_pdf_preference(s, Some(st)).unwrap_or(true)), + _ => None, + }; + if let Some(pdf) = pdf { + options.insert("pdfCompatible".into(), json!(pdf)); + } + } // Save writes the document's own file; the other modes only where they are told to. let path = path.or_else(|| (mode == SaveMode::Save && !st.converted && !st.recovered && format.id == st.format).then(|| st.path.clone()).flatten()); @@ -336,6 +347,15 @@ fn same_value(a: &Value, b: &Value) -> bool { } } +/// Native documents and templates share only their PDF choice. Other source formats use the +/// application's last native choice, keeping an AI file's separate PDF option out of it. +fn native_pdf_preference(s: &Session, st: Option<&DocState>) -> Option { + st.filter(|d| matches!(d.format, "vectorcraft" | "template")) + .and_then(|d| d.save_options.get("pdfCompatible")) + .and_then(Value::as_bool) + .or_else(|| s.prefs.native_save_options.get("pdfCompatible").and_then(Value::as_bool)) +} + /// The options `st` was last saved with, their artboards (`artboard`, `artboards` and `range`) /// following the artboards they named ([`DocState::save_artboards`]): an artboard deleted since is /// left out, and one that moved is named by its new number. When none of the artboards the save @@ -1028,12 +1048,15 @@ fn can_revert(s: &Session) -> std::result::Result<(), String> { fn revert(s: &mut Session, _: &Value) -> Result { let index = s.active_index().ok_or(crate::EngineError::NoDocument)?; let path = s.doc()?.path.clone().ok_or_else(|| bad("file.revert", "the document has never been saved"))?; - let Loaded { mut doc, .. } = load(&path, &read_file(&path)?)?.with_grid_prefs(&s.prefs); + let Loaded { mut doc, native_pdf_compatible, .. } = load(&path, &read_file(&path)?)?.with_grid_prefs(&s.prefs); doc.template = false; // Back to the saved file: its recovery copy has nothing left to recover. let uid = s.doc()?.uid; crate::cmd::recovery::forget(s, uid); s.replace_document(index, doc); + if let Some(pdf) = native_pdf_compatible { + s.doc_mut()?.save_options.insert("pdfCompatible".into(), json!(pdf)); + } Ok(json!({ "path": path })) } @@ -1053,11 +1076,17 @@ fn format_options(s: &mut Session, p: &Value) -> Result { .or_else(|| (f.id == "vectorcraft" && !s.prefs.native_save_options.is_empty()).then(|| s.prefs.native_save_options.clone())); // A native save compresses as Use Compression says, unless told otherwise. let prefs = with_compression_pref(&s.prefs, &json!({})); + let native_pdf = matches!(f.id, "vectorcraft" | "template").then(|| native_pdf_preference(s, st)).flatten(); let mut v = f.to_json(); if let Some(options) = v["options"].as_object_mut() { for (name, o) in options.iter_mut() { let default = prefs.get(name).filter(|_| matches!(f.id, "vectorcraft" | "template")).unwrap_or(&o["default"]); o["value"] = saved.as_ref().and_then(|m| m.get(name)).unwrap_or(default).clone(); + if name == "pdfCompatible" + && let Some(pdf) = native_pdf + { + o["value"] = json!(pdf); + } } } v["saveFormats"] = @@ -1072,7 +1101,7 @@ pub(super) fn specs() -> Vec { "Save Document", [], None, - "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: false for native (also carry a PDF of every artboard) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified", + "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified", has_doc, |s, p| save(s, SaveMode::Save, p) ), diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index 11e878bc1..b69cfd177 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -1210,6 +1210,9 @@ fn items( let mut pieces = vec![]; for id in ids { let a = doc.asset(*id).ok_or_else(|| bad(C, format!("no asset {id} (see assets.list)")))?; + if a.format_3d.is_some() { + return Err(bad(C, format!("asset {id} is a 3D asset: assets.export3D writes it"))); + } let (d, r) = alone(&a.nodes, &a.name).ok_or_else(|| bad(C, format!("asset `{}` has no art to export", a.name)))?; pieces.push((a.name.clone(), d, Some(0), r)); } diff --git a/crates/engine/src/cmd/fileio/svg.rs b/crates/engine/src/cmd/fileio/svg.rs index a11e4a4e8..56bfd0f7b 100644 --- a/crates/engine/src/cmd/fileio/svg.rs +++ b/crates/engine/src/cmd/fileio/svg.rs @@ -177,7 +177,7 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result, B: AsRef<[u8]>>(files: &[(N, B)]) -> Result, String> { + store_aligned(files, 1) +} + +/// [`store`], with each file's data starting at a multiple of `align` bytes from the start of the +/// archive (USDZ asks for 64): a padding block in the local header's extra field (header id +/// 0x1986) moves it there. +pub fn store_aligned, B: AsRef<[u8]>>(files: &[(N, B)], align: usize) -> Result, String> { let too_big = || "too much to zip: at most 65534 files and 4 GB".to_string(); let count = u16::try_from(files.len()).ok().filter(|&c| c != u16::MAX).ok_or_else(too_big)?; let mut out = Vec::with_capacity(files.iter().map(|(n, b)| 76 + 2 * n.as_ref().len() + b.as_ref().len()).sum::() + 22); @@ -47,8 +54,21 @@ pub fn store, B: AsRef<[u8]>>(files: &[(N, B)]) -> Result, out.extend_from_slice(&0x0403_4b50u32.to_le_bytes()); out.extend_from_slice(&VERSION.to_le_bytes()); out.extend_from_slice(&shared); - out.extend_from_slice(&0u16.to_le_bytes()); // extra field length + // The data would start after the header (30 bytes) and the name; a padding block of at + // least its own 4-byte header moves it to the boundary. + let start = out.len() + 2 + name.len(); + let mut pad = if align > 1 { (align - start % align) % align } else { 0 }; + if pad > 0 && pad < 4 { + pad += align; + } + let pad_len = u16::try_from(pad).map_err(|_| format!("cannot align zip entries to {align} bytes"))?; + out.extend_from_slice(&pad_len.to_le_bytes()); // extra field length out.extend_from_slice(name); + if pad > 0 { + out.extend_from_slice(&0x1986u16.to_le_bytes()); + out.extend_from_slice(&(pad_len - 4).to_le_bytes()); + out.resize(out.len() + pad - 4, 0); + } out.extend_from_slice(bytes); // Central directory record. central.extend_from_slice(&0x0201_4b50u32.to_le_bytes()); diff --git a/crates/engine/src/cmd/freeform.rs b/crates/engine/src/cmd/freeform.rs index 21b5245ae..0f72b066a 100644 --- a/crates/engine/src/cmd/freeform.rs +++ b/crates/engine/src/cmd/freeform.rs @@ -92,8 +92,12 @@ impl Session { /// The freeform gradient behind the Fill (`stroke` false) or Stroke proxy, its points in /// document coordinates, and the length its spreads are fractions of there (see - /// [`Node::proxy_freeform`]). + /// [`Node::proxy_freeform`]). `None` while an object of a symbol instance is selected + /// ([`vectorcraft_doc::Selection::symbol_part`]): its points are in the symbol's coordinates. pub fn proxy_freeform(&self, stroke: bool) -> Option<(Freeform, f64)> { + if self.active()?.selection.symbol_part.is_some() { + return None; + } let (n, item) = self.proxy_source(stroke)?; n.proxy_freeform(stroke, item) } diff --git a/crates/engine/src/cmd/globaledit.rs b/crates/engine/src/cmd/globaledit.rs index 2b0e214cc..4a42f03d4 100644 --- a/crates/engine/src/cmd/globaledit.rs +++ b/crates/engine/src/cmd/globaledit.rs @@ -354,8 +354,10 @@ fn close(a: f64, b: f64) -> bool { } /// The members a Global Edit started on the one selected object would take, with `o`: that -/// object first, then the others in paint order. +/// object first, then the others in paint order. Refused while an object of a dynamic symbol +/// instance's art is selected. fn matches(s: &Session, o: &Options) -> Result> { + super::edit::refuse_symbol_part(s)?; let st = s.doc()?; let d = &st.doc; let [id] = st.selection.objects.as_slice() else { return Err(bad(C, "select one object")) }; @@ -423,6 +425,7 @@ fn can_toggle(s: &Session) -> std::result::Result<(), String> { if st.global_edit.is_some() { return Ok(()); } + super::edit::no_symbol_part(s)?; let [id] = st.selection.objects.as_slice() else { return Err(if st.selection.is_empty() { "nothing selected" } else { "select one object" }.into()); }; @@ -765,10 +768,12 @@ pub(crate) fn propagate(before: &Document, d: &mut Document, ge: &GlobalEdit, se let mut placed = model.clone(); placed.transform(*xf * back, Scaling::default()); give(was, &model, placed, &mut n, d); + super::object::keep_pixel_aligned(&mut n, Scaling::default()); if let Some(slot) = d.node_mut(*id) { *slot = n; edited.push(*id); } + super::object::keep_compound_aligned(d, *id, Scaling::default()); } edited } diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 47989a29c..c73a23d6c 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -41,7 +41,7 @@ pub fn specs() -> Vec { "Select Gradient Stop", [], None, - "{index: stop index (0 = the start) | null to clear} select a stop of the gradient behind the active proxy (the first selected object's, else the default paint): the stop the Gradient tool's annotator, the Gradient and Color panels and Delete/arrow keys act on → {index}", + "{index: stop index (0 = the start) | null to clear} select a stop of the gradient behind the active proxy (the first selected object's, else the default paint): the stop the Gradient tool's annotator, the Gradient and Color panels and Delete/arrow keys act on; refused while an object of a symbol instance's art is selected → {index}", always, select_stop ), @@ -59,6 +59,10 @@ pub(crate) fn active_gradient(s: &Session) -> Option { fn select_stop(s: &mut Session, p: &Value) -> Result { const C: &str = "gradient.selectStop"; + // No document is needed for the default paint's gradient, so this does not use `refuse_symbol_part`. + if s.active().is_some_and(|st| st.selection.symbol_part.is_some()) { + return Err(super::brushsym::part_only()); + } let index = match p.get("index") { None => return Err(bad(C, "missing `index` (a stop index, or null to clear)")), Some(Value::Null) => None, diff --git a/crates/engine/src/cmd/graph/edit.rs b/crates/engine/src/cmd/graph/edit.rs index 1ea621c63..c33be78d5 100644 --- a/crates/engine/src/cmd/graph/edit.rs +++ b/crates/engine/src/cmd/graph/edit.rs @@ -58,12 +58,16 @@ pub(super) fn create(s: &mut Session, p: &Value) -> Result { } /// The graph `id` names (or the graph containing it), else the selected graph (or the graph -/// containing the selection). +/// containing the selection). Refused while an object of a dynamic symbol instance's art is +/// selected. pub(super) fn target(s: &Session, p: &Value, cmd: &str) -> Result { let st = s.doc()?; let start = match named_id(p)? { Some(id) => vec![id], - None => st.selection.objects.clone(), + None => { + crate::cmd::edit::refuse_symbol_part(s)?; + st.selection.objects.clone() + } }; for id in start { let mut cur = Some(id); diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 8b059c111..5825c5fc0 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -1316,6 +1316,195 @@ fn a_paint_on_a_design_copy_or_the_other_kind_of_row_is_not_kept() { assert_eq!(spec(&s, id), kept); } +/// A graphic style `name` made from a new rectangle with a blue fill at 50% opacity, and without a stroke row unless +/// `stroke`: the rectangle and the blue paint. +fn blue_style(s: &mut Session, name: &str, stroke: bool) -> (NodeId, Paint) { + let r = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap()); + s.execute("appearance.setItem", &json!({"ids": [r.0], "index": top_row(s, r, true), "color": "#0000ff", "opacity": 50})).unwrap(); + if !stroke { + s.execute("appearance.removeItem", &json!({"ids": [r.0], "index": top_row(s, r, false)})).unwrap(); + } + s.execute("graphicStyle.new", &json!({"id": r.0, "name": name})).unwrap(); + let blue = s.doc().unwrap().doc.node(r).unwrap().appearance.fill_paint(); + (r, blue) +} + +/// Series 0's marks with a red 2 pt stroke stored from `marks[1]` and a 40% fill opacity stored from `marks[2]`. +fn stored_red_stroke() -> (Session, NodeId, Vec) { + let (mut s, id, _, marks) = column_marks(); + let stroke = top_row(&s, marks[1], false); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[2].0], "index": top_row(&s, marks[2], true), "opacity": 40})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.stroke_width, kept.fill_opacity), (Some(2.0), Some(0.4))); + (s, id, marks) +} + +/// After the graph is drawn again, every mark of series 0 has `fill` at `opacity` and `stroke` at `weight`; two undos (the +/// data edit, then the command) bring back the paints the graph stored in `was`. +fn series_0_is(s: &mut Session, id: NodeId, (fill, opacity, stroke, weight): (&Paint, f32, &Paint, f64), was: &[vectorcraft_doc::SeriesPaint]) { + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + for m in series(&n, 0).children().unwrap() { + let f = m.appearance.fill().unwrap(); + assert_eq!((&f.paint, f.opacity), (fill, opacity)); + assert_eq!((&m.appearance.stroke_paint(), m.appearance.stroke_width()), (stroke, weight)); + } + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(s, id).series_paints, was); +} + +/// A graphic style applied to a series mark is stored on the graph, its fill opacity included. A style without a stroke, +/// applied to a mark that shows one, leaves the series without a stroke. +#[test] +fn a_graphic_style_applied_to_a_series_mark_is_kept() { + let (mut s, id, marks) = stored_red_stroke(); + let (_, blue) = blue_style(&mut s, "Blue", false); + let was = spec(&s, id).series_paints; + s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marks[1].0]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill, kept.fill_opacity, kept.stroke), (Some(blue.clone()), Some(0.5), Some(Paint::None))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was, "one undo takes back the style and what the graph stored"); + s.execute("edit.redo", &json!({})).unwrap(); + series_0_is(&mut s, id, (&blue, 0.5, &Paint::None, 0.0), &was); +} + +/// A graphic style added on top of a series mark's appearance (Alt-click) stores the kinds of row the style shows: a style +/// with only a fill leaves the stroke the series stores. +#[test] +fn a_graphic_style_added_to_a_series_mark_keeps_only_its_own_kinds() { + let (mut s, id, marks) = stored_red_stroke(); + let stroke = top_row(&s, marks[0], false); + let red = spec(&s, id).series_paints[0].stroke.clone().unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#00ff00", "weight": 3})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap(); + let (_, blue) = blue_style(&mut s, "Blue", false); + let was = spec(&s, id).series_paints; + s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marks[0].0], "add": true})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill, kept.fill_opacity), (Some(blue.clone()), Some(0.5))); + assert_eq!((kept.stroke.clone(), kept.stroke_width), (Some(red.clone()), Some(2.0))); + series_0_is(&mut s, id, (&blue, 0.5, &red, 2.0), &was); +} + +/// A graphic style whose stroke row is hidden, added on top of a series mark (Alt-click), stores its fill and leaves the +/// stroke the series stores, although the mark shows a stroke of its own. +#[test] +fn a_graphic_style_with_a_hidden_stroke_added_to_a_series_mark_keeps_the_stored_stroke() { + let (mut s, id, marks) = stored_red_stroke(); + let stroke = top_row(&s, marks[0], false); + let red = spec(&s, id).series_paints[0].stroke.clone().unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#00ff00", "weight": 3})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap(); + let (rect, blue) = blue_style(&mut s, "Blue", true); + s.execute("appearance.setItem", &json!({"ids": [rect.0], "index": top_row(&s, rect, false), "visible": false})).unwrap(); + s.execute("graphicStyle.new", &json!({"id": rect.0, "name": "Hidden stroke"})).unwrap(); + let was = spec(&s, id).series_paints; + s.execute("graphicStyle.apply", &json!({"name": "Hidden stroke", "ids": [marks[0].0], "add": true})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill, kept.fill_opacity), (Some(blue.clone()), Some(0.5))); + assert_eq!((kept.stroke.clone(), kept.stroke_width), (Some(red.clone()), Some(2.0))); + series_0_is(&mut s, id, (&blue, 0.5, &red, 2.0), &was); +} + +/// Redefine Graphic Style restyles the series marks still linked to the style, and the graph stores the new look. +#[test] +fn redefining_a_graphic_style_updates_a_linked_series_mark() { + let (mut s, id, _, marks) = column_marks(); + let (rect, _) = blue_style(&mut s, "Blue", true); + s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marks[0].0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [rect.0], "index": top_row(&s, rect, true), "color": "#00ff00", "opacity": 100})).unwrap(); + let green = s.doc().unwrap().doc.node(rect).unwrap().appearance.fill_paint(); + let was = spec(&s, id).series_paints; + s.execute("graphicStyle.redefine", &json!({"name": "Blue", "id": rect.0})).unwrap(); + assert_eq!(s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint(), green, "the mark follows the style"); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill, kept.fill_opacity), (Some(green.clone()), None)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was, "one undo takes back the redefinition and what the graph stored"); + s.execute("edit.redo", &json!({})).unwrap(); + let (stroke, weight) = { + let r = s.doc().unwrap().doc.node(rect).unwrap().clone(); + (r.appearance.stroke_paint(), r.appearance.stroke_width()) + }; + series_0_is(&mut s, id, (&green, 1.0, &stroke, weight), &was); +} + +/// Redefine Graphic Style made from a series mark does not edit that mark, so the graph keeps the paint another mark of the +/// series stored after it: the graph drawn again shows the same marks with or without the redefinition, and undo brings +/// back the stored paint. +#[test] +fn redefining_a_graphic_style_from_a_series_mark_keeps_the_paint_another_mark_stored() { + let (mut s, id, _, marks) = column_marks(); + blue_style(&mut s, "Blue", true); + s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marks[1].0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": top_row(&s, marks[1], true), "color": "#00ff00"})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": top_row(&s, marks[0], true), "color": "#ff0000"})).unwrap(); + let red = s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint(); + let was = spec(&s, id).series_paints; + assert_eq!(was[0].fill, Some(red.clone())); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); + let without = marks_of(&s, id, 0); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("graphicStyle.redefine", &json!({"name": "Blue", "id": marks[1].0})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was, "the source mark was not edited"); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); + assert_eq!(marks_of(&s, id, 0), without); + assert!(without.iter().all(|m| m.0 == red)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was); +} + +/// Default Fill and Stroke on a series mark stores a white fill and a 1 pt black stroke, both opaque, on the graph. +#[test] +fn default_fill_and_stroke_on_a_series_mark_is_kept() { + let (mut s, id, marks) = stored_red_stroke(); + let was = spec(&s, id).series_paints; + s.execute("paint.default", &json!({"ids": [marks[0].0]})).unwrap(); + let (white, black) = (Paint::solid(vectorcraft_color::Color::WHITE), Paint::solid(vectorcraft_color::Color::BLACK)); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill.clone(), kept.fill_opacity), (Some(white.clone()), None)); + assert_eq!((kept.stroke.clone(), kept.stroke_width), (Some(black.clone()), Some(1.0))); + series_0_is(&mut s, id, (&white, 1.0, &black, 1.0), &was); +} + +/// Swap Fill and Stroke on a series mark stores the swapped paints and the stroke weight, and leaves the opacity the series +/// stores. +#[test] +fn swapping_a_series_marks_fill_and_stroke_is_kept() { + let (mut s, id, marks) = stored_red_stroke(); + let stroke = top_row(&s, marks[0], false); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#00ff00", "weight": 3})).unwrap(); + let mark = s.doc().unwrap().doc.node(marks[0]).unwrap().clone(); + let (gray, green) = (mark.appearance.fill_paint(), mark.appearance.stroke_paint()); + let was = spec(&s, id).series_paints; + s.execute("paint.swap", &json!({"ids": [marks[0].0]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill.clone(), kept.fill_opacity), (Some(green.clone()), Some(0.4))); + assert_eq!((kept.stroke.clone(), kept.stroke_width), (Some(gray.clone()), Some(3.0))); + series_0_is(&mut s, id, (&green, 0.4, &gray, 3.0), &was); +} + +/// Swap Fill and Stroke writes the topmost fill even when it is hidden: the visible fill under it does not replace the +/// fill the series stores. +#[test] +fn swapping_onto_a_hidden_top_fill_leaves_the_stored_fill() { + let (mut s, id, _, marks) = column_marks(); + s.execute("appearance.addFill", &json!({"ids": [marks[0].0]})).unwrap(); + let (_, upper) = fill_rows(&s, marks[0]); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": upper, "visible": false})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": top_row(&s, marks[1], true), "color": "#ff0000"})).unwrap(); + let red = spec(&s, id).series_paints[0].fill.clone(); + assert_eq!(red, Some(s.doc().unwrap().doc.node(marks[1]).unwrap().appearance.fill_paint())); + let stroke = top_row(&s, marks[0], false); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#00ff00", "weight": 3})).unwrap(); + s.execute("paint.swap", &json!({"ids": [marks[0].0]})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, red); +} + /// A kept opacity read from a file outside 0..1 is held to that range, and one that is not a number is left out. #[test] fn a_kept_series_opacity_from_a_file_is_held_to_its_range() { diff --git a/crates/engine/src/cmd/intertwine.rs b/crates/engine/src/cmd/intertwine.rs index b19206c39..9841d2abb 100644 --- a/crates/engine/src/cmd/intertwine.rs +++ b/crates/engine/src/cmd/intertwine.rs @@ -76,8 +76,9 @@ pub fn specs() -> Vec { // ---------- predicates ---------- /// The Intertwine groups of the selection: selected ones, and those around a selected object. +/// None while an object of a dynamic symbol instance's art is selected. fn selected_groups(s: &Session) -> Vec { - let Some(st) = s.active() else { return vec![] }; + let Some(st) = s.active().filter(|st| st.selection.symbol_part.is_none()) else { return vec![] }; let mut out = vec![]; for id in &st.selection.objects { let group = st.doc.ancestry(*id).unwrap_or_default().into_iter().rev().find(|a| st.doc.node(*a).is_some_and(is_intertwine)); @@ -92,6 +93,7 @@ fn selected_groups(s: &Session) -> Vec { fn has_intertwine(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; + super::edit::no_symbol_part(s)?; if selected_groups(s).is_empty() { Err("select an Intertwine group".into()) } else { Ok(()) } } @@ -378,11 +380,14 @@ fn arrange(s: &mut Session, p: &Value) -> Result { } id } - None => match selected_groups(s).as_slice() { - [g] => *g, - [] => return Err(bad(C, "select an Intertwine group")), - _ => return Err(bad(C, "select one Intertwine group")), - }, + None => { + super::edit::refuse_symbol_part(s)?; + match selected_groups(s).as_slice() { + [g] => *g, + [] => return Err(bad(C, "select an Intertwine group")), + _ => return Err(bad(C, "select one Intertwine group")), + } + } }; let at = match p.get("at") { Some(_) => Some(point_param(p, "at").filter(|q| q.x.is_finite() && q.y.is_finite()).ok_or_else(|| bad(C, "at must be [x, y]"))?), diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index d4adaf2fd..6fe0746ac 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -10,7 +10,7 @@ use std::collections::HashSet; use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_doc::{Artboard, Document, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind, Scaling}; +use vectorcraft_doc::{Artboard, ArtboardSide, Document, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind, Scaling}; use vectorcraft_geom::{Affine, Rect}; use super::{ @@ -128,7 +128,7 @@ pub fn specs() -> Vec { "New Artboard", ["Window", "Artboards"], None, - "{x?, y?, width?, height?, name?, color?: color (its background color, as artboard.setProps takes it, #rrggbbaa and #rgba refused; default: none), showCenterMark?: bool, showCrossHairs?: bool, showVideoSafeAreas?: bool (as artboard.setProps takes them; default false), videoRulerPixelAspectRatio?: number 0.1–10 (as artboard.setProps takes it; default 1)} (default place: right of the last)", + "{x?, y?, width?, height?, name?, color?: color (its background color, as artboard.setProps takes it, #rrggbbaa and #rgba refused; default: none), showCenterMark?: bool, showCrossHairs?: bool, showVideoSafeAreas?: bool (as artboard.setProps takes them; default false), videoRulerPixelAspectRatio?: number 0.1–10 (as artboard.setProps takes it; default 1), side?: \"left\"|\"right\"|\"top\"|\"bottom\" (the size of artboard `beside`, 20 pt from that side of it; refused with x, y, width or height), beside?: n (the artboard `side` places it by; default the last)} (default place: right of the last)", has_doc, artboard_new ), @@ -869,6 +869,17 @@ fn artboard_str<'a>(p: &'a Value, key: &str, cmd: &str) -> Result Result> { + let bad_side = || bad(cmd, "`side` must be \"left\", \"right\", \"top\" or \"bottom\""); + match p.get("side") { + None | Some(Value::Null) => Ok(None), + Some(Value::String(v)) => ArtboardSide::parse(v).map(Some).ok_or_else(bad_side), + Some(_) => Err(bad_side()), + } +} + /// An artboard command's `videoRulerPixelAspectRatio` param: None when absent, else a number within /// [`vectorcraft_doc::VIDEO_PIXEL_ASPECT_RANGE`]. fn video_pixel_aspect(p: &Value, cmd: &str) -> Result> { @@ -886,10 +897,24 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result { let d0 = &s.doc()?.doc; let last = d0.artboards.last().map(|a| a.rect).unwrap_or(Rect::new(0.0, 0.0, 612.0, 792.0)); let def = Rect::new(last.x1 + 20.0, last.y0, last.x1 + 20.0 + last.width(), last.y1); - let r = rect_from(p, def); let color = artboard_color(p, "artboard.new")?.flatten(); check_switches(p, &["showCenterMark", "showCrossHairs", "showVideoSafeAreas"], "artboard.new")?; check_numbers(p, &["x", "y", "width", "height"], "artboard.new")?; + let r = match artboard_side(p, "artboard.new")? { + Some(side) => { + if ["x", "y", "width", "height"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) { + return Err(bad("artboard.new", "`side` places the artboard: leave out x, y, width and height")); + } + let beside = match p.get("beside") { + None | Some(Value::Null) => d0.artboards.len().saturating_sub(1), + Some(_) => super::appearance::index_param(p, "beside", "artboard.new")?, + }; + let src = d0.artboards.get(beside).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?; + src + side.offset(src) + } + None if p.get("beside").is_some_and(|v| !v.is_null()) => return Err(bad("artboard.new", "`beside` needs `side`")), + None => rect_from(p, def), + }; let given = artboard_str(p, "name", "artboard.new")?.map(str::trim).filter(|n| !n.is_empty()); let (show_center_mark, show_cross_hairs) = (bool_or(p, "showCenterMark", false), bool_or(p, "showCrossHairs", false)); let show_video_safe_areas = bool_or(p, "showVideoSafeAreas", false); @@ -1100,6 +1125,7 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result { for id in &e.art { if let Some(n) = d.node_mut(*id) { n.transform(xf, sc); + super::object::keep_pixel_aligned(n, sc); } } } else if !e.resized { diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 89492ad01..28d8be2ee 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -849,9 +849,10 @@ impl Resolved { /// The linked files of document `d`, just read from `doc_path`: those found unchanged are read /// again (their images had the preview), modified ones are read again when `update`, links found -/// away from their path take the new path. A link nothing is known of whose image shows a preview -/// (a file another app wrote kept only that) was never read: its file is read now, into the -/// image's bounds, which isn't a change to report. Nothing here is an undo step. +/// away from their path take the new path. A link nothing is known of whose image, or placed +/// document, shows a preview (a file another app wrote kept only that) was never read: its file is +/// read now, into the object's bounds, which isn't a change to report. A placed document whose +/// file's bytes are gone isn't showing a preview. Nothing here is an undo step. pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_proxy_eps: bool) -> Resolved { let mut out = Resolved::default(); // The image (with its preview) and hash of each file read for a preview here, by path: images @@ -863,7 +864,11 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p let mut moved: Vec<(String, String, bool, LinkInfo)> = vec![]; for g in groups(d, None) { let row = rows(std::iter::once(&g), doc_path); - let unread = g.document.is_none() && g.link.hash.is_none() && g.link.size.is_none() && previewing(d, &g.key); + let shows_preview = match g.document { + None => previewing(d, &g.key), + Some(_) => d.images.get(&g.key).is_some_and(ImageBlob::is_proxy), + }; + let unread = g.link.hash.is_none() && g.link.size.is_none() && shows_preview; let eps_preview = keep_eps_preview(low_res_proxy_eps, &g, d); match probe(&g, doc_path) { (Status::Missing, _) => out.missing.extend(row), diff --git a/crates/engine/src/cmd/live/commands.rs b/crates/engine/src/cmd/live/commands.rs index 54c65b0c7..b69b4e869 100644 --- a/crates/engine/src/cmd/live/commands.rs +++ b/crates/engine/src/cmd/live/commands.rs @@ -154,7 +154,7 @@ pub fn specs() -> Vec { "Make with Top Object", ["Object", "Envelope Distort"], Some("Cmd+Alt+C"), - "{ids?} the topmost selected path becomes the envelope of the others (type maps from its glyph outline bounds) → {id}", + "{ids?} the topmost selected path becomes the envelope of the others: its outline splits into four sides at its corners, and each piece of a side takes a share of the content in proportion to its control polygon's length (type maps from its glyph outline bounds) → {id}", has_multi, env_make_top ), diff --git a/crates/engine/src/cmd/live/common.rs b/crates/engine/src/cmd/live/common.rs index 226a57a0d..80c9d714a 100644 --- a/crates/engine/src/cmd/live/common.rs +++ b/crates/engine/src/cmd/live/common.rs @@ -33,9 +33,10 @@ pub(super) fn roots_param(s: &Session, p: &Value) -> Result> { selected_roots(s) } -/// Selected nodes of a kind (the selection itself or an ancestor). +/// Selected nodes of a kind (the selection itself or an ancestor). None while an object of a +/// dynamic symbol instance's art is selected. pub(super) fn selected_of(s: &Session, pred: fn(&Node) -> bool) -> Vec { - let Some(st) = s.active() else { return vec![] }; + let Some(st) = s.active().filter(|st| st.selection.symbol_part.is_none()) else { return vec![] }; let mut out = vec![]; for id in &st.selection.objects { for a in st.doc.ancestry(*id).unwrap_or_default().into_iter().rev() { @@ -64,16 +65,19 @@ pub(super) fn is_mesh(n: &Node) -> bool { pub(super) fn has_blend(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; + crate::cmd::edit::no_symbol_part(s)?; if selected_of(s, is_blend).is_empty() { Err("select a blend".into()) } else { Ok(()) } } pub(super) fn has_envelope(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; + crate::cmd::edit::no_symbol_part(s)?; if selected_of(s, is_envelope).is_empty() { Err("select an envelope".into()) } else { Ok(()) } } pub(super) fn has_mesh(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; + crate::cmd::edit::no_symbol_part(s)?; if selected_of(s, is_mesh).is_empty() { Err("select a gradient mesh".into()) } else { Ok(()) } } diff --git a/crates/engine/src/cmd/live/envelope.rs b/crates/engine/src/cmd/live/envelope.rs index 521f092b0..1aceb49b5 100644 --- a/crates/engine/src/cmd/live/envelope.rs +++ b/crates/engine/src/cmd/live/envelope.rs @@ -113,7 +113,7 @@ pub(super) fn env_make_top(s: &mut Session, p: &Value) -> Result { let path = s.doc()?.doc.node(top).and_then(outline_of).filter(|p| p.bounds().is_some()).ok_or_else(|| bad(C, "the top object must be a path"))?; let defaults = envelope_defaults(s); let id = s.edit("Make Envelope", |d, sel| { - let id = wrap(d, sel, &content, |id, content| envelope(id, content, EnvelopeKind::TopObject { path }, defaults))?; + let id = wrap(d, sel, &content, |id, content| envelope(id, content, EnvelopeKind::TopObject { path, corners: None }, defaults))?; d.remove(top)?; Ok(id) })?; @@ -223,7 +223,7 @@ pub(super) fn env_info(s: &mut Session, _: &Value) -> Result { fn envelope_shape(env: &Node, kind: &EnvelopeKind) -> Option { let grey = Color::gray(0.25); let (rows, cols) = match kind { - EnvelopeKind::TopObject { path } => { + EnvelopeKind::TopObject { path, .. } => { return Some(Node::path(NodeId(0), path.clone(), Appearance::basic(Paint::solid(grey), Paint::None, 0.0))); } // The mesh itself, handles and all. diff --git a/crates/engine/src/cmd/maskedit.rs b/crates/engine/src/cmd/maskedit.rs index 01721471c..f2d322a39 100644 --- a/crates/engine/src/cmd/maskedit.rs +++ b/crates/engine/src/cmd/maskedit.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec { ["Window", "Transparency"], None, "{id?} edit the mask art of `id` (default: the first selected object with a mask) in place, as clicking the mask thumbnail in the Transparency panel does; refused in isolation mode and in pattern editing mode → {layer}", - can_edit_mask, + can_enter, enter ), cmd!( @@ -79,6 +79,13 @@ pub(crate) fn can_edit_mask(s: &Session) -> std::result::Result<(), String> { mask_editing_refused(st).map_or(Ok(()), |why| Err(why.into())) } +/// Edit Opacity Mask: mask editing can be entered ([`can_edit_mask`]), and outside it no object of a +/// dynamic symbol instance's art is selected, since its instance's mask is not edited from it. +fn can_enter(s: &Session) -> std::result::Result<(), String> { + can_edit_mask(s)?; + if s.active().is_some_and(|st| st.doc.mask_edit.is_some()) { Ok(()) } else { super::edit::no_symbol_part(s) } +} + /// A document is open and no opacity mask is being edited: isolation mode and pattern editing mode /// are not entered while an opacity mask is edited. pub(crate) fn outside_mask_editing(s: &Session) -> std::result::Result<(), String> { @@ -117,12 +124,17 @@ fn enter(s: &mut Session, p: &Value) -> Result { if st.doc.mask_edit.is_some() { return Err(bad(C, "already editing an opacity mask")); } - let id = object_param(p, "id", C)?.or_else(|| first_masked(st)).ok_or_else(|| bad(C, "select an object with an opacity mask"))?; + let given = object_param(p, "id", C)?; + if given.is_none() { + super::edit::refuse_symbol_part(s)?; + } + let id = given.or_else(|| first_masked(st)).ok_or_else(|| bad(C, "select an object with an opacity mask"))?; let layer = start(s, id)?; Ok(json!({ "layer": layer.0 })) } -/// The first selected object with an opacity mask. +/// The first selected object with an opacity mask. Callers refuse first while an object of a +/// dynamic symbol instance's art is selected ([`super::edit::refuse_symbol_part`]). fn first_masked(st: &crate::DocState) -> Option { st.selection.subjects().iter().copied().find(|i| st.doc.node(*i).is_some_and(|n| n.extras().mask.is_some())) } @@ -145,6 +157,9 @@ fn view_mask(s: &mut Session, p: &Value) -> Result { st.revision += 1; return Ok(json!({ "on": false, "id": id.map(|i| i.0) })); } + if given.is_none() && editing.is_none() { + super::edit::refuse_symbol_part(s)?; + } let id = given.or(editing).or_else(|| first_masked(st)).ok_or_else(|| bad(C, "select an object with an opacity mask"))?; match editing { Some(e) if e != id => return Err(bad(C, "already editing another object's opacity mask")), diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 6992f42ab..4a48319f3 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -620,6 +620,9 @@ fn transform_each_as(s: &mut Session, p: &Value, label: &str) -> Result { if let Some(n) = d.node_mut(*id) { super::object::transform_node(n, m, sc, objects); } + if objects { + super::object::keep_compound_aligned(d, *id, sc); + } } Ok(targets) })?; @@ -1363,6 +1366,7 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result { for (id, dl) in &art { if let Some(n) = d.node_mut(*id) { n.transform(Affine::translate(*dl), sc); + super::object::keep_pixel_aligned(n, sc); } } let mut moved = vec![]; diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index ade05bd95..c467f06e5 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -25,6 +25,7 @@ mod arrange; mod bounds; mod commands; mod compound; +mod pixelgrid; mod props; mod shape; mod spans; @@ -35,5 +36,6 @@ pub(crate) use arrange::group_nodes; pub(crate) use bounds::{bounds_rewrite, object_bounds_transform}; pub use commands::specs; pub(super) use compound::{as_clipping_path, make_clipping_path, release_clip}; +pub(crate) use pixelgrid::{keep_aligned_in, keep_compound_aligned, keep_pixel_aligned, refresh as refresh_pixel_aligned, resnap, snap_if_aligned}; pub(super) use shape::recut; pub(crate) use transform::{apply_transform, preview_copies, scaling, transform_node, transform_patterns, transforms_objects}; diff --git a/crates/engine/src/cmd/object/align.rs b/crates/engine/src/cmd/object/align.rs index 156b35824..82a61b02a 100644 --- a/crates/engine/src/cmd/object/align.rs +++ b/crates/engine/src/cmd/object/align.rs @@ -112,7 +112,9 @@ fn apply_moves(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], moves: & for (id, dv) in moves { if let Some(n) = d.node_mut(*id) { n.transform(Affine::translate(*dv), sc); + super::pixelgrid::keep_pixel_aligned(n, sc); } + super::pixelgrid::keep_compound_aligned(d, *id, sc); } for &(i, pos) in &guides { if let Some(g) = d.guides.get_mut(i) { @@ -330,6 +332,7 @@ fn align_anchors(s: &mut Session, p: &Value, ids: &[NodeId], (h, v): (Option<&st a.translate(target(a.p) - a.p); } } + snap_anchor_path(d, *id); } Ok(()) })?; @@ -434,11 +437,23 @@ fn distribute_anchors(s: &mut Session, label: &str, horiz: bool, gap: Option Result { check_switches(p, &["patterns"], "object.distribute")?; check_numbers(p, &["artboard"], "object.distribute")?; diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index 69f464edb..f248294f2 100644 --- a/crates/engine/src/cmd/object/commands.rs +++ b/crates/engine/src/cmd/object/commands.rs @@ -2,6 +2,7 @@ use super::align::{align, can_distribute, distribute, distribute_spacing}; use super::arrange::{Arrange, arrange, exit_isolation, group, hide, isolate, lock, send_to_current_layer, show_all, ungroup, unlock_all}; use super::bounds::set_bounds; use super::compound::{clip_edit, clip_make, clip_release, compound_make, compound_release}; +use super::pixelgrid::set_pixel_aligned; use super::props::set_props; use super::shape::{expand_shape, set_live_shape}; use super::transform::{move_cmd, nudge, reflect, rotate, scale, shear, transform, transform_again}; @@ -300,5 +301,14 @@ pub fn specs() -> Vec { has_selection, set_live_shape ), + cmd!( + "object.setPixelAligned", + "Align to Pixel Grid", + [], + None, + "{ids?|id?, on?: bool (default true)} → {ids (the objects whose property changed)} (Transform panel › Align to Pixel Grid: on, each object's horizontal and vertical straight segments move to whole points, or half points when a visible, painted centered stroke has an odd weight, its stroke weights become whole points (at least 1), and every later move, scale, rotation, reflection, Transform Each, Align move, Transform panel change, move of direct-selected anchors or of one anchor (path.moveAnchor), path.setAnchors, object.setLiveShape, move of a compound path's path on its own, artboard move or resize that carries the art (artboard.move, artboard.setProps) or copies it (artboard.move with copy, Duplicate Artboards), every copy that edit.duplicate or a paste moves and object.slice.move of an object slice snaps them again, as does every stroke.set, stroke.weightUp or stroke.weightDown edit (which also rounds the new weight) and every appearance.setItem, appearance.addStroke, appearance.removeItem, appearance.clear, appearance.reduceToBasic, appearance.showAllHidden, appearance.transfer, appearance.copyFrom, paint.setStroke or graphicStyle.apply edit, and every other command that changes the object; diagonal segments and curves keep their shape apart from the anchors they share with those segments, and coordinates that differ stay at least 1 point apart; a live rectangle, polygon, line or pie moves and scales as a whole to the snapped bounds (a full ellipse, all curves, stays as it is), and a path with Live Corners snaps the outline its corners are cut from; a compound path snaps its paths with its own stroke, Make Compound Path from an aligned path or compound path gives an aligned compound path whose paths drop the property, and Release Compound Path gives each path the compound path's property; off, the object keeps its geometry and stops snapping. Only paths and compound paths can be aligned: type, images, groups, guides, paths inside a compound path and other objects are an error and nothing changes. One undo step; nothing changes and no undo step is made when every object already has the value. Saved in the native file)", + has_selection, + set_pixel_aligned + ), ] } diff --git a/crates/engine/src/cmd/object/compound.rs b/crates/engine/src/cmd/object/compound.rs index 1dabc76db..54e4d6f88 100644 --- a/crates/engine/src/cmd/object/compound.rs +++ b/crates/engine/src/cmd/object/compound.rs @@ -13,11 +13,15 @@ pub(super) fn compound_make(s: &mut Session, _: &Value) -> Result { d.node(backmost).ok_or(EngineError::NoNode(backmost))?; let mut units: Vec>> = vec![]; let mut appearance = None; + // The compound path is pixel-aligned when any of its paths was, or any compound path whose + // paths it takes; its paths snap with its stroke, so they do not keep the property themselves. + let mut aligned = false; for id in &ids { let n = d.node(*id).ok_or(EngineError::NoNode(*id))?; if !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Group { .. }) { return Err(EngineError::Other("compound paths can only contain paths".into())); } + n.walk(&mut |c| aligned |= c.extras().pixel_aligned); compound_members(n, &mut appearance, &mut units); } let cid = d.alloc_id(); @@ -40,6 +44,7 @@ pub(super) fn compound_make(s: &mut Session, _: &Value) -> Result { for mut p in unit { let pn = Arc::make_mut(&mut p); pn.appearance = Appearance::default().into(); + pn.extras_mut().pixel_aligned = false; if let NodeKind::Path { path, live, .. } = &mut pn.kind { *live = None; if flip { @@ -49,6 +54,10 @@ pub(super) fn compound_make(s: &mut Session, _: &Value) -> Result { ch.push(p); } } + if aligned { + c.extras_mut().pixel_aligned = true; + super::pixelgrid::snap_if_aligned(&mut c); + } d.wrap(&ids, |_| c)?; sel.set([cid]); Ok(cid) @@ -99,6 +108,10 @@ pub(super) fn compound_release(s: &mut Session, _: &Value) -> Result { for c in children { let mut c = (**c).clone(); c.appearance = n.appearance.clone(); + // Each path keeps the compound path's pixel-aligned property. + if n.extras().pixel_aligned { + c.extras_mut().pixel_aligned = true; + } out.push(c.id); parts.push(c); } diff --git a/crates/engine/src/cmd/object/pixelgrid.rs b/crates/engine/src/cmd/object/pixelgrid.rs new file mode 100644 index 000000000..6441fda0c --- /dev/null +++ b/crates/engine/src/cmd/object/pixelgrid.rs @@ -0,0 +1,407 @@ +//! Transform panel › Align to Pixel Grid: an object's pixel-aligned property, and the snapping that +//! keeps the horizontal and vertical segments of a pixel-aligned object on the pixel grid +//! (). + +use vectorcraft_doc::{AppearanceItem, LiveShape, StrokeAlign}; + +use super::{ + Affine, Arc, Document, EngineError, Node, NodeId, NodeKind, Paint, PathData, Rect, Result, Scaling, Session, Value, Vec2, bad, json, + selected_roots, +}; +use crate::cmd::{given_objects, switch_or}; + +const C: &str = "object.setPixelAligned"; + +/// How far apart two coordinates may be and still count as one, in points: segments this close to +/// horizontal or vertical count as horizontal or vertical. +const EPS: f64 = 1e-6; + +/// Whether `n` can be aligned to the pixel grid: a path that is not a guide, or a compound path. +/// Type, images, groups and other objects cannot. +pub(crate) fn can_align(n: &Node) -> bool { + matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. }) +} + +/// `object.setPixelAligned`: turn the pixel-aligned property of the objects given (or the selected +/// objects) on or off. Turning it on snaps them to the pixel grid at once. Every object must be +/// one [`can_align`] accepts, else nothing changes. When every object already has the value, the +/// document and its undo history stay as they are. +pub(super) fn set_pixel_aligned(s: &mut Session, p: &Value) -> Result { + let on = switch_or(p, "on", true, C)?; + let ids = match given_objects(s, p, C)? { + Some(ids) => ids, + None => selected_roots(s)?, + }; + let d = &s.doc()?.doc; + let mut changed: Vec = vec![]; + for id in &ids { + let n = d.node(*id).ok_or(EngineError::NoNode(*id))?; + // A path inside a compound path snaps with the compound path, whose property it takes. + let in_compound = d.parent_of(*id).and_then(|p| d.node(p)).is_some_and(|p| matches!(p.kind, NodeKind::Compound { .. })); + if !can_align(n) || in_compound { + return Err(bad(C, "only paths and compound paths can be aligned to the pixel grid")); + } + if n.extras().pixel_aligned != on && !changed.contains(id) { + changed.push(*id); + } + } + if changed.is_empty() { + return Ok(json!({ "ids": [] })); + } + s.edit("Align to Pixel Grid", |d, _| { + for id in &changed { + let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?; + n.extras_mut().pixel_aligned = on; + if on { + snap_object(n, Scaling::default()); + } + } + Ok(()) + })?; + Ok(json!({ "ids": changed.iter().map(|i| i.0).collect::>() })) +} + +/// After a transform of `n` with `sc`: snap `n` and every object in it that is pixel-aligned to the +/// pixel grid, the art of linked opacity masks included. Objects that are not pixel-aligned stay as +/// they are. +pub(crate) fn keep_pixel_aligned(n: &mut Node, sc: Scaling) { + let mut found = vec![]; + aligned_within(n, &mut vec![], &mut found); + for at in found { + if let Some(a) = node_at(n, &at) { + snap_object(a, sc); + } + } +} + +/// One step from an object down to an object it holds: its child at an index, or the art of its +/// linked opacity mask. +#[derive(Clone, Copy)] +enum Step { + Child(usize), + Mask, +} + +/// Add to `out` the steps from the object [`keep_pixel_aligned`] started at to `n` (`at`) and to +/// every pixel-aligned object in `n`, in the art of linked opacity masks included, `n` first. One +/// walk visits each object once, so nothing is copied that does not snap. +fn aligned_within(n: &Node, at: &mut Vec, out: &mut Vec>) { + if n.extras().pixel_aligned { + out.push(at.clone()); + } + // The paths of a pixel-aligned compound path snap with it, but their opacity masks still need a visit. + for (i, c) in n.children().map_or(&[][..], Vec::as_slice).iter().enumerate() { + at.push(Step::Child(i)); + aligned_within(c, at, out); + at.pop(); + } + if let Some(m) = n.extras().mask.as_ref() + && m.linked + { + at.push(Step::Mask); + aligned_within(&m.art, at, out); + at.pop(); + } +} + +/// The object that `steps` lead to from `n`, made unique for editing. +fn node_at<'a>(n: &'a mut Node, steps: &[Step]) -> Option<&'a mut Node> { + let mut cur = n; + for s in steps { + cur = match s { + Step::Child(i) => Arc::make_mut(cur.children_mut()?.get_mut(*i)?), + Step::Mask => Arc::make_mut(&mut cur.extras_if_any()?.mask.as_mut()?.art), + }; + } + Some(cur) +} + +/// After a transform of object `id` with `sc`: when `id` is a path of a pixel-aligned compound path +/// (moved on its own, as with Group Selection), snap that compound path to the pixel grid again. +/// The compound path holds the property, so [`keep_pixel_aligned`] on the path alone does nothing. +pub(crate) fn keep_compound_aligned(d: &mut Document, id: NodeId, sc: Scaling) { + let Some(parent) = d.parent_of(id) else { return }; + if let Some(n) = d.node_mut(parent) + && matches!(n.kind, NodeKind::Compound { .. }) + && n.extras().pixel_aligned + { + snap_object(n, sc); + } +} + +/// After any edit ([`Session::edit`]): snap each pixel-aligned object that the edit changed or added +/// (its node in `doc` is not the one in `before`), with the pixel-aligned art of its linked opacity +/// mask, to the pixel grid. Objects the edit did not touch are not visited. +pub(crate) fn refresh(before: &Document, doc: &mut Document) { + let mut ids = vec![]; + changed_aligned(&before.layers, &doc.layers, &mut ids); + for id in ids { + if let Some(n) = doc.node_mut(id) { + keep_pixel_aligned(n, Scaling::default()); + } + } +} + +/// After a pass that moved what objects `ids` hold (Objects on Path placing its members): snap the +/// pixel-aligned objects in them to the pixel grid again. +pub(crate) fn keep_aligned_in(doc: &mut Document, ids: &[NodeId]) { + for id in ids { + if doc.node(*id).is_some_and(holds_aligned) + && let Some(n) = doc.node_mut(*id) + { + keep_pixel_aligned(n, Scaling::default()); + } + } +} + +/// The objects of `after` that differ from their counterparts in `before` (or are new) and are +/// pixel-aligned or hold pixel-aligned art in a linked opacity mask. Inside such an object the +/// walk stops: [`keep_pixel_aligned`] visits what it holds. +fn changed_aligned(before: &[Arc], after: &[Arc], out: &mut Vec) { + let mut by_id: Option>> = None; + for (i, a) in after.iter().enumerate() { + let b = match before.get(i) { + Some(b) if b.id == a.id => Some(b), + _ => by_id.get_or_insert_with(|| before.iter().map(|b| (b.id, b)).collect()).get(&a.id).copied(), + }; + if b.is_some_and(|b| Arc::ptr_eq(b, a)) { + continue; + } + if a.extras().pixel_aligned || a.extras().mask.as_ref().is_some_and(|m| m.linked && holds_aligned(&m.art)) { + out.push(a.id); + } else if let Some(children) = a.children() { + changed_aligned(b.and_then(|b| b.children()).map_or(&[], Vec::as_slice), children, out); + } + } +} + +/// After an edit that replaces object `id`'s geometry: snap it, or the pixel-aligned compound path +/// that holds it, to the pixel grid again when it is pixel-aligned. +pub(crate) fn resnap(d: &mut Document, id: NodeId) { + if let Some(n) = d.node_mut(id) { + snap_if_aligned(n); + } + keep_compound_aligned(d, id, Scaling::default()); +} + +/// After an edit of `n`'s strokes: when `n` is pixel-aligned, round its stroke weights and snap it to +/// the pixel grid again, since a new weight or alignment can move where the stroke's edges fall. +pub(crate) fn snap_if_aligned(n: &mut Node) { + if n.extras().pixel_aligned { + snap_object(n, Scaling::default()); + } +} + +/// Whether `n` or an object in it, or in the art of a linked opacity mask in it, is pixel-aligned. +/// It goes only where a transform of `n` goes: not into unlinked masks. +fn holds_aligned(n: &Node) -> bool { + n.extras().pixel_aligned + || n.extras().mask.as_ref().is_some_and(|m| m.linked && holds_aligned(&m.art)) + || n.children().is_some_and(|ch| ch.iter().any(|c| holds_aligned(c))) +} + +/// Snap pixel-aligned object `n` to the pixel grid: its stroke weights become whole points (at +/// least 1), and its horizontal and vertical straight segments move to whole points, or to half +/// points when a centered stroke has an odd weight, so that the stroke's edges fall on the grid. +/// A compound path snaps each of its paths. Placed gradients and linked opacity masks of an object +/// whose anchors moved follow it ([`follow_snap`]). +fn snap_object(n: &mut Node, sc: Scaling) { + let half = round_strokes(n); + let before = n.geometric_bounds(); + let mut moved = false; + if let NodeKind::Compound { children, .. } = &mut n.kind { + // A path is copied for editing only when the snap moves it. + for c in children.iter_mut() { + if path_snaps(c, half) { + let was = c.geometric_bounds(); + let anchors = snaps_anchors(c); + let snapped = Arc::make_mut(c); + snap_path_node(snapped, half, sc); + if anchors { + follow_snap(snapped, was, sc); + } + moved = true; + } + } + } else if snaps_anchors(n) { + moved = snap_path_node(n, half, sc); + } else { + // A live shape is moved with `Node::transform`, which carries its gradients and mask along. + snap_path_node(n, half, sc); + } + if moved { + follow_snap(n, before, sc); + } +} + +/// Whether a snap moves the anchors of path `n` itself (a plain path, or one with Live Corners) +/// rather than transforming it as a whole (another live shape). +fn snaps_anchors(n: &Node) -> bool { + matches!(&n.kind, NodeKind::Path { live, .. } if live.as_deref().is_none_or(|l| matches!(l, LiveShape::Path { .. }))) +} + +/// After the anchors of `n` moved in a snap, from bounds `before`: its placed gradients are fitted +/// to its new bounds, its patterns move and scale with those bounds when `sc` transforms patterns, +/// and the art of its linked opacity mask moves and scales with them (and snaps when it is +/// pixel-aligned itself). +fn follow_snap(n: &mut Node, before: Option, sc: Scaling) { + let (Some(f), Some(t)) = (before, n.geometric_bounds()) else { return }; + if f == t { + return; + } + n.appearance.rebase_gradients(f, t); + if sc.patterns { + vectorcraft_doc::pattern::transform_pattern_paints(n, rect_to_rect(f, t)); + } + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) + && m.linked + { + let art = Arc::make_mut(&mut m.art); + art.transform(rect_to_rect(f, t), sc); + keep_pixel_aligned(art, sc); + } +} + +/// Round the stroke weights of `n` to whole points, at least 1 (strokes without weight stay +/// without). Returns whether a visible, painted centered stroke has an odd weight. +fn round_strokes(n: &mut Node) -> bool { + let rounds = |w: f64| if w > 0.0 { w.round().max(1.0) } else { w }; + let needs = n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if rounds(st.width) != st.width)); + if needs { + for item in &mut n.appearance.items { + if let AppearanceItem::Stroke(st) = item { + st.width = rounds(st.width); + } + } + } + n.appearance.items.iter().any(|i| { + matches!(i, AppearanceItem::Stroke(st) if st.visible + && !matches!(st.paint, Paint::None) + && st.width > 0.0 + && st.width % 2.0 == 1.0 + && st.align == StrokeAlign::Center) + }) +} + +/// Snap path object `n`'s horizontal and vertical straight segments ([`snap_segments`]). A path +/// with Live Corners snaps the outline its corners are cut from. Another live shape is moved and +/// scaled as a whole instead, from its path's bounds to the bounds of the snapped path, so that it +/// stays a live shape. Returns whether it changed. +fn snap_path_node(n: &mut Node, half: bool, sc: Scaling) -> bool { + let NodeKind::Path { path, live, .. } = &mut n.kind else { return false }; + let Some(shape) = live.as_deref_mut() else { return snap_segments(path, half) }; + if let LiveShape::Path { base, .. } = shape { + if !snap_segments(base, half) { + return false; + } + *path = shape.to_path(); + return true; + } + let mut snapped = path.clone(); + if !snap_segments(&mut snapped, half) { + return false; + } + let (Some(from), Some(to)) = (path.bounds(), snapped.bounds()) else { return false }; + n.transform(rect_to_rect(from, to), Scaling { keep_corners: true, patterns: sc.patterns, ..Scaling::default() }); + true +} + +/// Whether [`snap_path_node`] would change path object `n`: whether an anchor of the path it snaps +/// moves. Another kind of object is not changed. +fn path_snaps(n: &Node, half: bool) -> bool { + let NodeKind::Path { path, live, .. } = &n.kind else { return false }; + match live.as_deref() { + Some(LiveShape::Path { base, .. }) => segment_moves(base, half).is_some(), + _ => segment_moves(path, half).is_some(), + } +} + +/// The move and scale that takes `from` onto `to` (a side without size is only moved). +fn rect_to_rect(from: Rect, to: Rect) -> Affine { + let sx = if from.width() > EPS { to.width() / from.width() } else { 1.0 }; + let sy = if from.height() > EPS { to.height() / from.height() } else { 1.0 }; + Affine::translate((to.x0, to.y0)) * Affine::scale_non_uniform(sx, sy) * Affine::translate((-from.x0, -from.y0)) +} + +/// Move the anchors of `path`'s horizontal straight segments to whole points vertically and the +/// anchors of its vertical ones to whole points horizontally (`half`: to half points), each anchor +/// with its handles, so curves and diagonal segments keep their shape apart from the anchors they +/// share with those segments. Coordinates that differ stay at least 1 point apart within a subpath, +/// so no side collapses. Returns whether an anchor moved. +pub(crate) fn snap_segments(path: &mut PathData, half: bool) -> bool { + let Some(moves) = segment_moves(path, half) else { return false }; + for (sp, m) in path.subpaths.iter_mut().zip(moves) { + for (a, d) in sp.anchors.iter_mut().zip(m) { + if d.x != 0.0 || d.y != 0.0 { + a.translate(d); + } + } + } + true +} + +/// How far [`snap_segments`] moves each anchor of each subpath of `path`, or `None` when it moves +/// no anchor. +fn segment_moves(path: &PathData, half: bool) -> Option>> { + let mut changed = false; + let mut moves = Vec::with_capacity(path.subpaths.len()); + for sp in &path.subpaths { + let n = sp.anchors.len(); + let (mut snap_x, mut snap_y) = (vec![false; n], vec![false; n]); + for i in 0..sp.segment_count() { + if !sp.segment_is_line(i) { + continue; + } + let (a, b) = (i % n, (i + 1) % n); + let (p, q) = (sp.anchors[a].p, sp.anchors[b].p); + let (dx, dy) = ((q.x - p.x).abs(), (q.y - p.y).abs()); + if dy <= EPS && dx > EPS { + (snap_y[a], snap_y[b]) = (true, true); + } else if dx <= EPS && dy > EPS { + (snap_x[a], snap_x[b]) = (true, true); + } + } + let xs = snapped_values(sp.anchors.iter().zip(&snap_x).filter(|(_, s)| **s).map(|(a, _)| a.p.x), half); + let ys = snapped_values(sp.anchors.iter().zip(&snap_y).filter(|(_, s)| **s).map(|(a, _)| a.p.y), half); + let mut m = Vec::with_capacity(n); + for (i, a) in sp.anchors.iter().enumerate() { + let dx = if snap_x[i] { snapped(&xs, a.p.x) - a.p.x } else { 0.0 }; + let dy = if snap_y[i] { snapped(&ys, a.p.y) - a.p.y } else { 0.0 }; + changed |= dx != 0.0 || dy != 0.0; + m.push(Vec2::new(dx, dy)); + } + moves.push(m); + } + changed.then_some(moves) +} + +/// The coordinates `values` and where each snaps to, in increasing order: the nearest whole point +/// (`half`: half point), and at least 1 point past where the coordinate before it snaps when the +/// two differ. +fn snapped_values(values: impl Iterator, half: bool) -> Vec<(f64, f64)> { + let mut sorted: Vec = values.filter(|v| v.is_finite()).collect(); + sorted.sort_by(f64::total_cmp); + let mut out: Vec<(f64, f64)> = vec![]; + for v in sorted { + match out.last() { + Some(&(last, _)) if v - last <= EPS => {} + Some(&(_, at)) => out.push((v, nearest(v, half).max(at + 1.0))), + None => out.push((v, nearest(v, half))), + } + } + out +} + +/// The whole point (`half`: half point) nearest `v`. A whole point halfway between two half points +/// goes to the half point above it, and a half point halfway between two whole points goes to the +/// whole point below it, so that switching between the two returns a coordinate to where it was. +fn nearest(v: f64, half: bool) -> f64 { + if half { (v + EPS).floor() + 0.5 } else { (v - 0.5 - EPS).ceil() } +} + +/// Where coordinate `v` snaps to, from [`snapped_values`] (`v` itself when it is not listed). A +/// coordinate merged into one listed is at most [`EPS`] past it. +fn snapped(values: &[(f64, f64)], v: f64) -> f64 { + let i = values.partition_point(|(x, _)| *x < v - EPS); + values.get(i).filter(|(x, _)| (x - v).abs() <= EPS).map_or(v, |(_, to)| *to) +} diff --git a/crates/engine/src/cmd/object/shape.rs b/crates/engine/src/cmd/object/shape.rs index eddc05763..2b32c5998 100644 --- a/crates/engine/src/cmd/object/shape.rs +++ b/crates/engine/src/cmd/object/shape.rs @@ -254,6 +254,9 @@ pub(super) fn set_live_shape(s: &mut Session, p: &Value) -> Result { }; recut(path, live, partial, &picked, radius, kind); } + for id in &ids { + super::pixelgrid::resnap(d, *id); + } Ok(()) })?; ok() diff --git a/crates/engine/src/cmd/object/transform.rs b/crates/engine/src/cmd/object/transform.rs index b400f637a..7940fc879 100644 --- a/crates/engine/src/cmd/object/transform.rs +++ b/crates/engine/src/cmd/object/transform.rs @@ -70,10 +70,12 @@ pub(crate) fn transforms_objects(p: &Value) -> bool { /// check them with [`check_switches`] before they change anything. pub(super) const TRANSFORM_SWITCHES: [&str; 6] = ["copy", "strokes", "corners", "patterns", "objects", "typeAreas"]; -/// `xf` on `n` with Transform Objects (`objects`, see [`transforms_objects`]) on or off. +/// `xf` on `n` with Transform Objects (`objects`, see [`transforms_objects`]) on or off. The objects +/// in `n` that are aligned to the pixel grid snap to it again ([`super::pixelgrid::keep_pixel_aligned`]). pub(crate) fn transform_node(n: &mut Node, xf: Affine, sc: Scaling, objects: bool) { if objects { n.transform(xf, sc); + super::pixelgrid::keep_pixel_aligned(n, sc); } else if sc.patterns { vectorcraft_doc::pattern::transform_patterns_only(n, xf); } @@ -168,6 +170,11 @@ fn apply_transform_moving_guides( transform_node(n, xf, sc, objects); } } + if objects { + for id in &targets { + super::pixelgrid::keep_compound_aligned(d, *id, sc); + } + } if with_guides && (delta.x != 0.0 || delta.y != 0.0) { for i in sel.guides.clone() { if let Some(g) = d.guides.get_mut(i) { @@ -242,6 +249,11 @@ pub(super) fn transform(s: &mut Session, p: &Value) -> Result { check_switches(p, &TRANSFORM_SWITCHES, "object.transform")?; check_switches(p, &["anchors", "releaseOutside"], "object.transform")?; let m = matrix_param(p, "matrix").ok_or_else(|| bad("object.transform", "missing matrix [a,b,c,d,e,f]"))?; + // An object selected in a symbol instance's art stays where the symbol puts it: the selection + // is left as it is, and the reply's warning goes to the status bar. + if p.get("ids").is_none() && crate::cmd::brushsym::selected_part(s)?.is_some() { + return Ok(json!({ "warning": crate::cmd::brushsym::part_only().to_string() })); + } let (ids, anchors) = match ids_param(p, "ids") { Some(v) => (outermost(&s.doc()?.doc, v), false), None => (selected_roots_to_transform(s)?, bool_or(p, "anchors", false)), diff --git a/crates/engine/src/cmd/objects_on_path.rs b/crates/engine/src/cmd/objects_on_path.rs index e2bd0957d..1a36a4e29 100644 --- a/crates/engine/src/cmd/objects_on_path.rs +++ b/crates/engine/src/cmd/objects_on_path.rs @@ -82,9 +82,10 @@ fn groups_of(d: &Document, ids: &[NodeId]) -> Vec { out } -/// The Objects on Path groups of the selection ([`groups_of`]). +/// The Objects on Path groups of the selection ([`groups_of`]). None while an object of a dynamic +/// symbol instance's art is selected. fn selected_groups(s: &Session) -> Vec { - s.active().map(|st| groups_of(&st.doc, &st.selection.objects)).unwrap_or_default() + s.active().filter(|st| st.selection.symbol_part.is_none()).map(|st| groups_of(&st.doc, &st.selection.objects)).unwrap_or_default() } /// The groups a command acts on: those of `ids` when given, otherwise those of the selection. @@ -102,6 +103,7 @@ fn target_groups(s: &Session, p: &Value, cmd: &str) -> Result> { fn has_objects_on_path(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; + super::edit::no_symbol_part(s)?; if selected_groups(s).is_empty() { Err("select an Objects on Path group".into()) } else { Ok(()) } } @@ -312,13 +314,18 @@ fn expand(s: &mut Session, p: &Value) -> Result { /// After an edit: put the objects of every Objects on Path group that changed back on its path /// ([`place`]), inner groups first. Only the parts of the tree that the edit changed are searched. -pub(crate) fn refresh(before: &Document, doc: &mut Document) { +/// Returns the groups that placing changed. +pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Vec { let mut groups = vec![]; changed_groups(&before.layers, &doc.layers, &mut groups); + let mut placed = vec![]; for id in groups.iter().rev() { follow_replacements(before, doc, *id); - place_in(doc, *id); + if place_in(doc, *id) { + placed.push(*id); + } } + placed } /// Members of the group `id` that the edit replaced with new art at the same index keep their @@ -337,14 +344,17 @@ fn follow_replacements(before: &Document, doc: &mut Document, id: NodeId) { } /// [`place`] the Objects on Path group `id` of `doc`, measuring symbol instances by their art. -fn place_in(doc: &mut Document, id: NodeId) { - let Some(n) = doc.node(id) else { return }; +/// Returns whether the group changed. +fn place_in(doc: &mut Document, id: NodeId) -> bool { + let Some(n) = doc.node(id) else { return false }; let mut g = n.clone(); if place(&mut g, &doc.symbol_boxes()) && let Some(slot) = doc.node_mut(id) { *slot = g; + return true; } + false } /// The Objects on Path groups among `after` and inside them that are not the same nodes as before diff --git a/crates/engine/src/cmd/opacitymask.rs b/crates/engine/src/cmd/opacitymask.rs index 7089770e4..b8b0138c5 100644 --- a/crates/engine/src/cmd/opacitymask.rs +++ b/crates/engine/src/cmd/opacitymask.rs @@ -34,7 +34,7 @@ pub fn specs() -> Vec { ["Window", "Transparency"], None, "{id?|ids?} put the mask art back above each masked object (default: the selection, or the object whose mask is being edited, which leaves mask editing)", - has_doc, + can_change_mask, release ), cmd!( @@ -43,25 +43,22 @@ pub fn specs() -> Vec { ["Window", "Transparency"], None, "{id?|ids?} keep the mask but stop applying it", - has_doc, + can_change_mask, |s, p| set_flags(s, p, "Disable Opacity Mask", &json!({ "disabled": true })) ), - cmd!("transparency.enableOpacityMask", "Enable Opacity Mask", ["Window", "Transparency"], None, "{id?|ids?}", has_doc, |s, p| set_flags( - s, - p, - "Enable Opacity Mask", - &json!({ "disabled": false }) - )), + cmd!("transparency.enableOpacityMask", "Enable Opacity Mask", ["Window", "Transparency"], None, "{id?|ids?}", can_change_mask, |s, p| { + set_flags(s, p, "Enable Opacity Mask", &json!({ "disabled": false })) + }), cmd!( "transparency.unlinkOpacityMask", "Unlink Opacity Mask", ["Window", "Transparency"], None, "{id?|ids?} the object moves without its mask", - has_doc, + can_change_mask, |s, p| set_flags(s, p, "Unlink Opacity Mask", &json!({ "linked": false })) ), - cmd!("transparency.linkOpacityMask", "Link Opacity Mask", ["Window", "Transparency"], None, "{id?|ids?}", has_doc, |s, p| set_flags( + cmd!("transparency.linkOpacityMask", "Link Opacity Mask", ["Window", "Transparency"], None, "{id?|ids?}", can_change_mask, |s, p| set_flags( s, p, "Link Opacity Mask", @@ -73,7 +70,7 @@ pub fn specs() -> Vec { [], None, "{id?|ids?, clip?, invert?, disabled?, linked?} change the opacity masks of `ids`, the selection, or the object whose mask is being edited", - has_doc, + can_change_mask, |s, p| set_flags(s, p, "Opacity Mask Options", p) ), cmd!( @@ -152,9 +149,21 @@ pub(crate) fn percent(o: f64) -> f32 { (o / 100.0).clamp(0.0, 1.0) as f32 } -/// The [`transparency_targets`] that have an opacity mask. +/// Refuses a command that would change [`transparency_targets`] when they are the selection, without +/// `id` or `ids` and outside mask editing, while an object of a dynamic symbol instance's art is +/// selected: the instance's transparency and mask change only when the instance is selected. +pub(crate) fn refuse_symbol_part_unnamed(s: &Session, p: &Value) -> Result<()> { + if p.get("ids").is_none() && p.get("id").is_none() && s.doc()?.doc.mask_edit.is_none() { + super::edit::refuse_symbol_part(s)?; + } + Ok(()) +} + +/// The [`transparency_targets`] that have an opacity mask, refused as +/// [`refuse_symbol_part_unnamed`] says. fn masked_targets(s: &Session, p: &Value) -> Result> { let d = &s.doc()?.doc; + refuse_symbol_part_unnamed(s, p)?; let ids: Vec = transparency_targets(s, p)?.into_iter().filter(|id| d.node(*id).is_some_and(|n| n.extras().mask.is_some())).collect(); if ids.is_empty() { return Err(EngineError::Other("no target object has an opacity mask: select one or give `id`".into())); @@ -162,6 +171,13 @@ fn masked_targets(s: &Session, p: &Value) -> Result> { Ok(ids) } +/// The commands that change an existing opacity mask: a document is open, and outside mask editing +/// no object of a dynamic symbol instance's art is selected ([`masked_targets`]). +fn can_change_mask(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + if s.active().is_some_and(|st| st.doc.mask_edit.is_some()) { Ok(()) } else { super::edit::no_symbol_part(s) } +} + /// Make Opacity Mask: a document is open, and one selected object, which [`make`] gives an empty /// mask and takes into mask editing, is where mask editing can be entered /// ([`super::maskedit::can_edit_mask`]: not in isolation mode or pattern editing mode). The diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index bbb5f35d3..19cdcfbbd 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -1,5 +1,7 @@ //! Fill and stroke paint (the toolbar proxies and their defaults) and the Transparency panel. +use std::borrow::Cow; + use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Color, GradientPaint, Paint}; use vectorcraft_doc::{Appearance, CharStyle, Document, Node, NodeId, NodeKind}; @@ -19,7 +21,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), dither?: bool (linear and radial fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), dither?: bool (linear and radial fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, a solid color or `none` changes that object for this instance only, and a gradient or pattern is refused → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", has_doc, |s, p| set_paint(s, p, true) ), @@ -28,7 +30,7 @@ pub fn specs() -> Vec { "Stroke", [], None, - "{…as paint.setFill, item?: the stroke item to set} same as paint.setFill, for the stroke", + "{…as paint.setFill, item?: the stroke item to set} same as paint.setFill, for the stroke; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, a solid color or `none` changes that object for this instance only, and a gradient or pattern is refused", has_doc, |s, p| { set_paint(s, p, false) } ), @@ -37,7 +39,7 @@ pub fn specs() -> Vec { "Swap Fill and Stroke", [], Some("Shift+X"), - "{ids?|id?} swap the fill and stroke of the selection (type too) and of the defaults", + "{ids?|id?} swap the fill and stroke of the selection (type too) and of the defaults. On a path directly in a graph series, the graph also stores the swapped paints and the stroke weight (exceptions: docs/mcp.md)", has_doc, swap ), @@ -46,7 +48,7 @@ pub fn specs() -> Vec { "Default Fill and Stroke", [], Some("D"), - "{ids?|id?} white fill and 1 pt black stroke for the selection and the defaults; type gets black fill and no stroke", + "{ids?|id?} white fill and 1 pt black stroke for the selection and the defaults; type gets black fill and no stroke. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, default_paint ), @@ -86,7 +88,7 @@ pub fn proxy_specs() -> Vec { "Invert", [], None, - "{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with nothing selected, invert the default → {changed}", + "{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's solid color changes for this instance only, and a gradient or pattern, or type whose characters differ in color, is refused; with nothing selected, invert the default → {changed}", has_doc, |s, p| proxy_recolor(s, p, "paint.invert", "Invert", &super::colorcmds::invert) ), @@ -95,7 +97,7 @@ pub fn proxy_specs() -> Vec { "Complement", [], None, - "{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} replace the active proxy's colours by their complements ((highest + lowest) − each component, over RGB or CMY; grey unchanged) keeping each colour's model; honours the Appearance panel's active item as paint.invert does; with nothing selected, the default → {changed}", + "{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} replace the active proxy's colours by their complements ((highest + lowest) − each component, over RGB or CMY; grey unchanged) keeping each colour's model; honours the Appearance panel's active item and a selected object of a dynamic symbol instance as paint.invert does; with nothing selected, the default → {changed}", has_doc, |s, p| proxy_recolor(s, p, "paint.complement", "Complement", &|c: Color| c.complement_keep_model()) ), @@ -172,7 +174,7 @@ impl Session { /// new art. pub fn proxy_paints(&self) -> (Paint, Paint) { let show = |stroke: bool| match self.proxy_source(stroke) { - Some((n, item)) => proxy_paint(n, stroke, item), + Some((n, item)) => proxy_paint(&n, stroke, item), None if stroke => self.paint.stroke.clone(), None => self.paint.fill.clone(), }; @@ -180,17 +182,22 @@ impl Session { } /// The object whose fill (or stroke) the proxy shows, with the Appearance panel's active item - /// when it stands for it (see [`Session::proxy_paints`]); None: the defaults for new art. - pub(crate) fn proxy_source(&self, stroke: bool) -> Option<(&Node, Option)> { + /// when it stands for it (see [`Session::proxy_paints`]); None: the defaults for new art. With + /// an object of a dynamic symbol instance selected ([`DocState::selected_symbol_part`]), that + /// object as the instance draws it. + pub(crate) fn proxy_source(&self, stroke: bool) -> Option<(Cow<'_, Node>, Option)> { let st = self.active()?; + if let Some(part) = st.selected_symbol_part() { + return Some((Cow::Owned(part), None)); + } let first = st.doc.node(*st.selection.subjects().first()?)?; let item = self.appearance_item(); if first.appearance.item_of_kind(item, !stroke).is_some() { - return Some((first, item)); + return Some((Cow::Borrowed(first), item)); } let mut nodes = vec![]; painted(first, true, &mut nodes); - nodes.first().map(|n| (*n, None)) + nodes.first().map(|n| (Cow::Borrowed(*n), None)) } pub(crate) fn remember_paint_now(&mut self, p: &Paint) { @@ -257,6 +264,9 @@ impl DocState { if self.selection.is_empty() { return (false, false); } + if self.selection.symbol_part.is_some() { + return (false, false); + } let ids: std::collections::HashSet = self.selection.objects.iter().copied().collect(); let (mut selected, mut nodes) = (vec![], vec![]); self.doc.walk(|n| { @@ -275,6 +285,18 @@ impl DocState { }; (differ(false), differ(true)) } + + /// The object of a dynamic symbol instance's art selected with Direct Selection + /// ([`vectorcraft_doc::Selection::symbol_part`]), with the instance's own appearance changes + /// applied, in the symbol's coordinates. `None` without one. + pub(crate) fn selected_symbol_part(&self) -> Option { + let sp = self.selection.symbol_part.as_ref()?; + let inst = self.doc.node(sp.instance)?; + let NodeKind::SymbolInstance { symbol, .. } = &inst.kind else { return None }; + let sym = self.doc.symbols.iter().find(|s| s.name == *symbol)?; + let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, inst); + vectorcraft_doc::symbol_overrides::part(&art, &sp.path).cloned() + } } impl Session { @@ -465,6 +487,26 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) s.remember_paint(&paint); return Ok(json!({"objects": ids.len()})); } + // An object of a dynamic symbol instance's art selected with Direct Selection: a solid color or + // none changes that object for this instance only, and anything else is refused. + if let Some((id, path)) = selected_part_target(s, p, cmd)? { + if !part_takes(&paint) { + return Err(super::brushsym::part_only()); + } + super::brushsym::edit_part(s, id, &path, if fill { "Fill Color" } else { "Stroke Color" }, |o| { + if fill { + o.fill = Some(paint.clone()); + } else { + o.stroke = Some(paint.clone()); + } + })?; + paint_new_art(s, &paint, fill); + if focus { + s.fill_active = fill; + } + s.remember_paint(&paint); + return Ok(json!({"objects": 1})); + } // Resolve the requested targets before mutating defaults or the active proxy. let item = item_target(s, p, cmd)?.of_kind(s, fill); let selected = item.targets(s, p)?; @@ -535,6 +577,9 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) // A stroke without paint has no weight; painting it again restores the 1 pt default. stroke.width = 0.0; } + if !fill { + super::object::snap_if_aligned(n); + } Ok(()) }, |d, changed| { @@ -553,15 +598,7 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) }, )?; // Rejected edits leave the next shape's appearance and active proxy untouched. - // New art takes an unplaced gradient so that it fits the new object's own bounds. - if fill { - s.paint.fill = unplaced(&paint); - } else { - if !paint.is_none() && s.paint.stroke.is_none() && s.paint.stroke_width == 0.0 { - s.paint.stroke_width = 1.0; - } - s.paint.stroke = unplaced(&paint); - } + paint_new_art(s, &paint, fill); if focus { s.fill_active = fill; } @@ -569,6 +606,39 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) Ok(json!({"objects": ids.iter().collect::>().len()})) } +/// The instance whose object is selected with Direct Selection, and that object's path +/// ([`super::brushsym::selected_part`]), when `p` names no `ids`, `id` or `item`: the paint commands +/// then change that object. A bad `target` or `aligned` is refused as it is for any other selection. +fn selected_part_target(s: &Session, p: &Value, cmd: &str) -> Result)>> { + if p.get("ids").is_some() || p.get("id").is_some() || p.get("item").is_some() { + return Ok(None); + } + let part = super::brushsym::selected_part(s)?; + if part.is_some() { + item_target(s, p, cmd)?; + } + Ok(part) +} + +/// Can an object of a dynamic symbol instance's art selected with Direct Selection take `paint`? A +/// solid color or none can; a gradient or pattern cannot. +pub(crate) fn part_takes(paint: &Paint) -> bool { + matches!(paint, Paint::Solid { .. } | Paint::None) +} + +/// Make `paint` the fill or stroke of new art. New art takes an unplaced gradient so that it fits +/// the new object's own bounds, and a painted stroke on art that had none gets the 1 pt default. +fn paint_new_art(s: &mut Session, paint: &Paint, fill: bool) { + if fill { + s.paint.fill = unplaced(paint); + } else { + if !paint.is_none() && s.paint.stroke.is_none() && s.paint.stroke_width == 0.0 { + s.paint.stroke_width = 1.0; + } + s.paint.stroke = unplaced(paint); + } +} + /// Does `p` ask for `span: "selection"` (#2127)? Only a linear or radial gradient the targets /// would each fit to their own bounds can span them: one with its own vector (`start`/`end`), /// a freeform one or a gradient swatch already has its placement. @@ -784,7 +854,9 @@ fn swap(s: &mut Session, p: &Value) -> Result { restore_unpainted_stroke(&mut n.appearance, None, &f); n.appearance.set_stroke(f); } - Ok(super::appearance::count_changed(d, &before, &ids)) + // The swap writes the paint of the topmost fill and stroke, not their opacity. + let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (false, false), true); + Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept)) })?; } if s.paint.stroke.is_none() && !s.paint.fill.is_none() && s.paint.stroke_width == 0.0 { @@ -812,7 +884,8 @@ fn default_paint(s: &mut Session, p: &Value) -> Result { _ => n.appearance = Appearance::default_art().into(), } } - Ok(super::appearance::count_changed(d, &before, &ids)) + let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false); + Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept)) })?; } super::newart::reset(s); @@ -824,6 +897,32 @@ fn default_paint(s: &mut Session, p: &Value) -> Result { /// active item of the proxy's kind is recoloured in each selected object's own stack instead. fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color) -> Result { let stroke = stroke_param(s, p, cmd)?; + // An object of a dynamic symbol instance's art selected with Direct Selection: the solid color + // it shows is recolored for this instance only. A gradient or pattern is refused, and so is type + // whose characters differ in that paint, since one change holds one paint. + if let Some((id, path)) = selected_part_target(s, p, cmd)? { + let part = s.doc()?.selected_symbol_part(); + let mut paint = part.as_ref().map_or(Paint::None, |n| proxy_paint(n, stroke, None)); + let runs_differ = match part.as_ref().map(|n| &n.kind) { + Some(NodeKind::Text(t)) => t.runs.iter().any(|r| !same_in_proxy(if stroke { &r.style.stroke } else { &r.style.fill }, &paint)), + _ => false, + }; + if runs_differ || !part_takes(&paint) { + return Err(super::brushsym::part_only()); + } + let changed = super::colorcmds::map_paint(&mut paint, f); + if changed { + super::brushsym::edit_part(s, id, &path, label, |o| { + if stroke { + o.stroke = Some(paint.clone()); + } else { + o.fill = Some(paint.clone()); + } + })?; + s.remember_paint(&paint); + } + return Ok(json!({ "changed": changed as usize })); + } let ids = match super::given_objects(s, p, cmd)? { Some(ids) => ids, None => super::edit::selected_roots(s)?, @@ -863,6 +962,7 @@ fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn( fn transparency(s: &mut Session, p: &Value) -> Result { const C: &str = "transparency.set"; + super::opacitymask::refuse_symbol_part_unnamed(s, p)?; let ids = super::opacitymask::transparency_targets(s, p)?; if ids.is_empty() { return Err(bad(C, "select objects or give ids")); diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 6eeafd7c3..052a5c69c 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "Duplicate Artboard", ["Window", "Artboards"], None, - "{index?: n | indices?: [n…] (default: the artboards the Artboard tool has selected), selectedArtboards?: [n…] (the journal records that default here, for a replay), art?: bool (default: the Artboard tool's moveArt option), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), lockedAndHidden?: bool (locked and hidden art goes too; default: prefs moveLockedWithArtboard)} a copy of each artboard, the copies placed together right of the last artboard as they lay, with copies of the art fully inside each when `art`, in one undo step → {index: the first copy's index, indices: the copies' indices, ids: the art's copies}", + "{index?: n | indices?: [n…] (default: the artboards the Artboard tool has selected), selectedArtboards?: [n…] (the journal records that default here, for a replay), art?: bool (default: the Artboard tool's moveArt option), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), lockedAndHidden?: bool (locked and hidden art goes too; default: prefs moveLockedWithArtboard), side?: \"left\"|\"right\"|\"top\"|\"bottom\" (the copies go together on that side of the artboards they copy, 20 pt away)} a copy of each artboard, the copies placed together right of the last artboard as they lay (or on `side`), with copies of the art fully inside each when `art`, in one undo step → {index: the first copy's index, indices: the copies' indices, ids: the art's copies}", has_doc, artboard_duplicate ), @@ -297,9 +297,11 @@ pub(crate) fn beside_artboards(d: &vectorcraft_doc::Document, rect: Rect) -> Vec } /// Duplicate Artboards: a copy of each artboard with its art, in one undo step. The copies keep -/// their places relative to each other, together right of the last artboard. +/// their places relative to each other, together right of the last artboard, or with `side` +/// together on that side of the artboards they copy. fn artboard_duplicate(s: &mut Session, p: &Value) -> Result { check_switches(p, &["patterns"], "artboard.duplicate")?; + let side = super::layer::artboard_side(p, "artboard.duplicate")?; let (indices, art) = boards_and_art_noted(s, p, "artboard.duplicate")?; let locked_art = locked_and_hidden(s, p, art, "artboard.duplicate")?; let arts = artboards_art(s, &indices, art, locked_art, false)?; @@ -311,7 +313,7 @@ fn artboard_duplicate(s: &mut Session, p: &Value) -> Result { .filter_map(|(i, _)| d.artboards.get(*i)) .map(|a| a.rect) .reduce(|a, b| a.union(b)) - .map(|r| beside_artboards(d, r)) + .map(|r| side.map_or_else(|| beside_artboards(d, r), |side| side.offset(r))) .unwrap_or_default(); let (mut index, mut ids) = (vec![], vec![]); for (i, art) in &arts { @@ -375,7 +377,10 @@ pub(crate) fn copy_artboard(d: &mut vectorcraft_doc::Document, i: usize, dv: Vec if let Some(id) = d.artboards.get(index).map(|a| a.id) { d.copy_artboard_guides(src.id, id, dv); } - let copies = super::edit::copy_above(d, art, |_, _, c| c.transform(Affine::translate(dv), sc))?; + let copies = super::edit::copy_above(d, art, |_, _, c| { + c.transform(Affine::translate(dv), sc); + super::object::keep_pixel_aligned(c, sc); + })?; Ok((index, copies)) } diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index f2338d139..1d0601364 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -279,6 +279,29 @@ pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()> _ => {} } } + // An SVG filter's XML is one drawable element. + if effect == vectorcraft_render::effects::SVG_FILTER + && let Some(xml) = given.get("xml") + { + let xml = xml.as_str().ok_or_else(|| bad(id, "`xml` must be a string"))?; + vectorcraft_render::effects::svg_filter::check(xml).map_err(|e| bad(id, e))?; + } + if effect == vectorcraft_render::effects::SVG_FILTER + && let Some(scale) = given.get("scale") + && !scale.as_f64().is_some_and(|k| (0.001..=1000.0).contains(&k)) + { + return Err(bad(id, "`scale` must be a number from 0.001 to 1000")); + } + if effect == vectorcraft_render::effects::SVG_FILTER + && let Some(origin) = given.get("origin") + && !origin.as_array().is_some_and(|o| o.len() == 2 && o.iter().all(|v| v.as_f64().is_some_and(f64::is_finite))) + { + return Err(bad(id, "`origin` must be [x, y] in points")); + } + // Its name is the filter's id. + if effect == vectorcraft_render::effects::SVG_FILTER && given.contains_key("name") && !given.contains_key("xml") { + return Err(bad(id, "an SVG filter is named by its id: give `xml` with `name`")); + } check_effect_color(id, effect, params)?; check_effect_channel(id, effect, params) } diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index e0de6bd4a..fb81e1e49 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -40,7 +40,7 @@ pub fn specs() -> Vec { "Move Anchors", [], None, - "{dx, dy, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the objects that move whole; default: prefs transformPatternTiles)} move direct-selected anchors (whole paths if fully selected)", + "{dx, dy, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the objects that move whole; default: prefs transformPatternTiles)} move direct-selected anchors (whole paths if fully selected); refused while an object of a symbol instance's art is selected", has_selection, move_anchors ), @@ -226,6 +226,8 @@ fn close(s: &mut Session, p: &Value) -> Result { pub(crate) fn move_anchors(s: &mut Session, p: &Value) -> Result { let d = Vec2::new(number_or(p, "dx", 0.0, "path.moveAnchors")?, number_or(p, "dy", 0.0, "path.moveAnchors")?); check_switches(p, &["patterns"], "path.moveAnchors")?; + // An object selected in a symbol instance's art stays where the symbol puts it. + super::edit::refuse_symbol_part(s)?; // Objects selected whole move as objects, and their pattern tiles move with them when // `patterns` (default: General › Transform Pattern Tiles) is on. let whole = s.doc()?.selection.whole(); @@ -260,11 +262,19 @@ pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_ge *a = a.transform(xf); } } + // A pixel-aligned path, or the pixel-aligned compound path that holds it, snaps + // to the pixel grid again after its anchors move. + let top = compound_or_self(doc, *id); + if let Some(n) = doc.node_mut(top) { + super::object::snap_if_aligned(n); + } } None => { if let Some(n) = doc.node_mut(*id) { n.transform(xf, sc); + super::object::keep_pixel_aligned(n, sc); } + super::object::keep_compound_aligned(doc, *id, sc); } } } @@ -306,6 +316,12 @@ fn move_anchor(s: &mut Session, p: &Value) -> Result { let path = path_mut(d, id)?; let a = path.anchor_mut(si, ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?; *a = a.transform(vectorcraft_geom::Affine::translate(pos - a.p)); + // A pixel-aligned path, or the pixel-aligned compound path that holds it, snaps to the + // pixel grid again. + let top = compound_or_self(d, id); + if let Some(n) = d.node_mut(top) { + super::object::snap_if_aligned(n); + } Ok(()) })?; ok() @@ -335,13 +351,14 @@ fn set_anchors(s: &mut Session, p: &Value) -> Result { ); s.edit("Reshape", |d, _| { *path_mut(d, id)? = data; + super::object::resnap(d, id); Ok(()) })?; ok() } /// The compound path enclosing `id`, or `id` itself if it is not inside a compound path. -pub(super) fn compound_or_self(d: &Document, id: NodeId) -> NodeId { +pub(crate) fn compound_or_self(d: &Document, id: NodeId) -> NodeId { d.parent_of(id).filter(|p| matches!(d.node(*p).map(|n| &n.kind), Some(NodeKind::Compound { .. }))).unwrap_or(id) } diff --git a/crates/engine/src/cmd/patterncmds.rs b/crates/engine/src/cmd/patterncmds.rs index 614a37d21..1e7f88d2b 100644 --- a/crates/engine/src/cmd/patterncmds.rs +++ b/crates/engine/src/cmd/patterncmds.rs @@ -189,8 +189,10 @@ fn has_selection_or_ids(s: &Session) -> std::result::Result<(), String> { has_doc(s) } +/// The repeats of the selection: selected ones, and those around a selected object. None while +/// an object of a dynamic symbol instance's art is selected. fn selected_repeats(s: &Session) -> Vec { - let Some(st) = s.active() else { return vec![] }; + let Some(st) = s.active().filter(|st| st.selection.symbol_part.is_none()) else { return vec![] }; let mut out = vec![]; for id in &st.selection.objects { for a in st.doc.ancestry(*id).unwrap_or_default().into_iter().rev() { @@ -207,6 +209,7 @@ fn selected_repeats(s: &Session) -> Vec { fn has_repeat(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; + super::edit::no_symbol_part(s)?; if selected_repeats(s).is_empty() { Err("select a repeat".into()) } else { Ok(()) } } diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index 0de33a22c..40d176ab8 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -66,7 +66,7 @@ pub fn specs() -> Vec { "Place…", ["File"], Some("Cmd+Shift+P"), - "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", + "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", has_doc, place ), @@ -308,8 +308,10 @@ pub(crate) fn pdf_art( read = Err(e); } let (mut doc, warnings, alone) = fileio::page_or_editing_data(src, bytes, &o, index, read).map_err(|e| bad(cmd, format!("{name}: {e}")))?; - if alone { - // Linked images show their files, found from its folder. + // Linked images show their files, found from its folder. Its placed documents can place this + // file again: one already being read further up, or past the most nested reads, keeps its links + // as stored, as a VectorCraft document's do. + if alone && let Some(_reading) = document::Reading::enter(bytes) { super::links::resolve(&mut doc, path, false, false); } doc.drop_edit_modes(); @@ -534,7 +536,7 @@ impl Session { if st.doc.node(old) != read.job.replaced.as_ref() { return Err(bad(PLACE, "replace: the object to replace was changed or deleted")); } - if st.selection.objects != [old] { + if st.selection.objects != [old] || st.selection.symbol_part.is_some() { self.execute("select.set", &json!({ "ids": [old.0] }))?; } } @@ -565,6 +567,10 @@ fn prepare(s: &Session, p: &Value) -> Result { if corners.iter().flatten().any(|c| !(c.x.abs() <= MAX_COORD && c.y.abs() <= MAX_COORD)) { return Err(bad(PLACE, "at/rect lie outside the canvas")); } + // An object selected in a symbol instance's art can't be swapped, and its instance stays. + if replace { + super::edit::refuse_symbol_part(s)?; + } let st = s.doc()?; let old = match (replace, &st.selection.objects[..]) { (false, _) => None, diff --git a/crates/engine/src/cmd/place/document.rs b/crates/engine/src/cmd/place/document.rs index 9bc52c89b..bea33fcf3 100644 --- a/crates/engine/src/cmd/place/document.rs +++ b/crates/engine/src/cmd/place/document.rs @@ -157,11 +157,11 @@ std::thread_local! { } /// A document's links being resolved ([`READING`]): the entry goes when this does. -struct Reading; +pub(super) struct Reading; impl Reading { /// `None` when the document of `bytes` is already being read, or [`MAX_NESTED_READS`] are. - fn enter(bytes: &[u8]) -> Option { + pub(super) fn enter(bytes: &[u8]) -> Option { let hash = hash_bytes(bytes); READING.with_borrow_mut(|r| (r.len() < MAX_NESTED_READS && !r.contains(&hash)).then(|| r.push(hash))).map(|()| Reading) } diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 61ad9e8cb..5405f02e7 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -33,7 +33,7 @@ use std::sync::{Arc, Mutex}; use serde_json::{Map, Value, json}; use vectorcraft_doc::{Effect, Node}; -use vectorcraft_plugins::{Budget, Kind, Plugin, effect, export, objects, registry}; +use vectorcraft_plugins::{Budget, Kind, Plugin, effect, export, objects, registry, safe_mode}; use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, has_doc, ids_param, str_param, switch_or}; @@ -142,7 +142,15 @@ impl FilterRun { let mut params = Value::Object(m.resolve_params(&plugin_params(p)).map_err(|e| bad(C, e.to_string()))?); let may = m.permissions(); let st = s.doc()?; - let selected = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.clone()); + // While an object of a dynamic symbol instance's art is selected, the selection is refused, + // as `selected_roots` refuses it. + let selected = match ids_param(p, "ids") { + Some(ids) => ids, + None => { + super::edit::refuse_symbol_part(s)?; + st.selection.objects.clone() + } + }; // Without `doc.read` a filter is given no objects: it can only add new ones. let inputs = if may.contains(&Permission::DocRead) { objects::filter_targets(&st.doc, &selected) } else { Vec::new() }; let parent = st.insertion_parent(); @@ -435,7 +443,21 @@ fn install(s: &mut Session, p: &Value) -> Result { s.store_grant(&m.id, Some(g)); } // Installed plug-ins stay installed after a restart (#2541). - s.store_plugin(plugin.id(), &bytes); + let stored = s.store_plugin(plugin.id(), &bytes); + // Installing a disabled plug-in again (an updated version) turns it on for the next launch too, + // also when it is disabled by its stored file alone (it stopped a launch while loading). When + // the new version could not replace the stored file, the old one stays disabled. + let file = s.stored_plugin_file(plugin.id()); + let mut changed = false; + if file.is_none() || stored { + changed = safe_mode::set_enabled(&safe_mode::Item::of(&plugin), true); + if let Some(file) = file { + changed |= safe_mode::set_enabled(&safe_mode::Item::file(file), true); + } + } + if changed { + s.store_disabled_plugins(); + } Ok(describe(®istry::install(plugin))) } @@ -472,7 +494,8 @@ static FOLDER: Mutex, Value)>> = Mute /// (startup, `prefs.set`). Native only. pub(crate) fn sync_prefs(s: &Session) { let folder = s.prefs.plugins_folder.trim(); - if folder.is_empty() { + // In Safe Mode nothing loads, and the folder is loaded once it ends ([`load_prefs_folder`]). + if folder.is_empty() || safe_mode::active() { return; } let mut last = FOLDER.lock().unwrap_or_else(|e| e.into_inner()); @@ -482,8 +505,24 @@ pub(crate) fn sync_prefs(s: &Session) { *last = Some((folder.to_string(), s.plugin_dir.clone(), load_folder(s, folder))); } +/// Turns Safe Mode on for `--safe-mode`, before the plug-in folder is attached: no plug-in loads or +/// runs in this process from then on. +pub fn start_in_safe_mode() { + safe_mode::set_active(Some(safe_mode::Source::Flag)); +} + +/// Loads the stored plug-ins and the Additional Plug-ins Folder preference: the app calls it when +/// the user leaves the choice after a launch that stopped with Disable or Start Normally, which +/// turns Safe Mode off first. +pub fn load_prefs_folder(s: &mut Session) { + s.load_stored_plugins(); + *FOLDER.lock().unwrap_or_else(|e| e.into_inner()) = None; + sync_prefs(s); +} + /// Loads the plug-ins in `folder`, those that list permissions only when `s` has them granted -/// ([`Session::plugin_approved`], with the store's grants read once). +/// ([`Session::plugin_approved`], with the store's grants read once) → its report; saves the +/// disabled list when loading changed it. #[cfg(not(target_arch = "wasm32"))] fn load_folder(s: &Session, folder: &str) -> Value { if let Err(e) = crate::file_access::check_read(folder) { @@ -498,6 +537,9 @@ fn load_folder(s: &Session, folder: &str) -> Value { }; match registry::load_folder_where(std::path::Path::new(folder), allow, s.plugin_approver()) { Ok(r) => { + if r.disabled_changed { + s.store_disabled_plugins(); + } json!({"folder": folder, "loaded": r.loaded, "failed": r.failed.iter().map(|(f, e)| json!({"file": f, "error": e})).collect::>()}) } Err(e) => json!({"folder": folder, "error": e.to_string()}), @@ -528,7 +570,59 @@ fn reload(s: &mut Session, p: &Value) -> Result { fn list(_: &mut Session, _: &Value) -> Result { let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, _, r)| r.clone()); - Ok(json!({"plugins": registry::list().iter().map(|p| describe(p)).collect::>(), "folder": folder})) + let disabled: Vec = safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect(); + Ok( + json!({"plugins": registry::list().iter().map(|p| describe(p)).collect::>(), "folder": folder, "safeMode": safe_mode::active(), "disabled": disabled}), + ) +} + +/// `plugin.safeMode`: whether Safe Mode is on and why, the plug-ins disabled for the next launch, +/// and the plug-in the last launch was loading when it stopped while the user hasn't chosen yet. +fn safe_mode_state(_: &mut Session, _: &Value) -> Result { + Ok(json!({ + "active": safe_mode::active(), + "source": safe_mode::source().map(safe_mode::Source::name), + "disabled": safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect::>(), + "pendingItem": safe_mode::previous().map(|i| i.to_json()), + })) +} + +/// `plugin.setEnabled`: turns a plug-in on or off for the next launch, by `id` or by `file` (a +/// plug-in that stopped a launch while loading may be known by its file alone). It neither loads +/// nor unloads anything now, and uninstalls nothing. +fn set_enabled(s: &mut Session, p: &Value) -> Result { + const C: &str = "plugin.setEnabled"; + let Some(enabled) = p.get("enabled").and_then(Value::as_bool) else { + let key = "enabled"; + return Err(bad(C, format!("`{key}` must be true or false"))); + }; + let text = |key: &str| match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(v)) => Ok(Some(v.trim().to_string()).filter(|v| !v.is_empty())), + Some(_) => Err(bad(C, format!("`{key}` must be a string"))), + }; + // Turning on by id also matches the plug-in's stored file, which names it when it stopped a + // launch while loading. + let (item, stored) = match (text("id")?, text("file")?) { + (Some(id), _) => { + let stored = if enabled { s.stored_plugin_file(&id).map(safe_mode::Item::file) } else { None }; + (registry::get(&id).map(|p| safe_mode::Item::of(&p)).unwrap_or(safe_mode::Item { id: Some(id), ..Default::default() }), stored) + } + (None, Some(file)) => (safe_mode::Item::file(file), None), + (None, None) => return Err(bad(C, "give `id` or `file` (see plugin.safeMode)")), + }; + let items: Vec<&safe_mode::Item> = std::iter::once(&item).chain(stored.as_ref()).collect(); + if enabled && !safe_mode::disabled().iter().any(|i| items.iter().any(|item| i.same(item))) { + return Err(bad(C, "that plug-in isn't disabled (see plugin.safeMode)")); + } + let mut changed = false; + for item in items { + changed |= safe_mode::set_enabled(item, enabled); + } + // Without a plug-in folder (a session confined to automation roots), or when the list there + // can't take the change, the change stays in this process and goes with the next change that saves. + let saved = !changed || s.store_disabled_plugins(); + Ok(json!({"changed": changed, "saved": saved, "disabled": safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect::>()})) } fn info(_: &mut Session, p: &Value) -> Result { @@ -549,8 +643,8 @@ fn remove(s: &mut Session, p: &Value) -> Result { if registry::remove(id) { Ok(json!({"removed": id})) } else { Err(err(vectorcraft_plugins::Error::NotFound(id.into()))) } } -/// This module's commands: `plugin.list`, `.info`, `.run`, `.install`, `.inspect`, `.remove` and -/// `.reload`. +/// This module's commands: `plugin.list`, `.info`, `.run`, `.install`, `.inspect`, `.remove`, +/// `.reload`, `.safeMode` and `.setEnabled`. pub fn specs() -> Vec { vec![ cmd!( @@ -558,7 +652,7 @@ pub fn specs() -> Vec { "List Plug-ins", [], None, - "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's format id, plugin., for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}", + "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's format id, plugin., for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}", always, list ), @@ -604,9 +698,27 @@ pub fn specs() -> Vec { "Reload Plug-ins", ["Object", "Plug-ins"], None, - "{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module is reported and skipped, and so are a heavy filter and a plug-in that lists permissions the user hasn't granted this very module, its bytes included) → {folder, loaded: [id], failed: [{file, error}]}", + "{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module, and one disabled with plugin.setEnabled, is reported and skipped, and so are a heavy filter and a plug-in that lists permissions the user hasn't granted this very module, its bytes included; an error in Safe Mode) → {folder, loaded: [id], failed: [{file, error}]}", always, reload ), + cmd!( + query "plugin.safeMode", + "Safe Mode", + [], + None, + "{} → {active: bool (no plug-in loads or runs: plugin.install, .run, .reload and exporters fail, live effects draw their object's geometry unchanged and the objects keep their effects), source: \"flag\" (started with --safe-mode) | \"startup\" (the user chose Safe Mode, or hasn't chosen yet, after a launch that stopped while a plug-in was loading) | null, disabled: [{id, file, name, label}] (turned off for the next launch; id is null for one known only by its file), pendingItem: {id, file, name, label} | null (the plug-in the last launch was loading when it stopped, until the user chooses; it may not be what stopped it)}. Safe Mode is chosen at launch, never by a command", + always, + safe_mode_state + ), + cmd!( + query "plugin.setEnabled", + "Enable on Re-launch", + [], + None, + "{id | file: a plug-in as plugin.safeMode's disabled lists it, enabled: bool} turn a plug-in on or off for the next launch (Help › Safe Mode's Enable on Re-launch); nothing loads, unloads or is uninstalled now. Turning on by id also turns on the file the plug-in is stored in. Turning on one that isn't disabled is an error. plugin.install of a disabled plug-in turns it on → {changed, saved: false when the change isn't in the plug-in folder for the next launch (a session without one, or the list there can't be read or written or is full; it is saved with the next change that saves), disabled}", + always, + set_enabled + ), ] } diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index 0267725ef..60f0f4565 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -349,6 +349,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[ p!("openDocumentsAsTabs", "User Interface", "", "Open Documents As Tabs", bool), p!("largeTabs", "User Interface", "", "Large Tabs", bool), p!("systemTitleBar", "User Interface", "", "System Title Bar", bool), + p!("appTitleBar", "User Interface", "", "VectorCraft Title Bar on Every Desktop", bool), p!("uiScaling", "User Interface", "UI Scaling", "Scale", num(0.75, 2.0, "×")), p!("scaleCursorWithUi", "User Interface", "UI Scaling", "Scale Cursor Proportional to UI", bool), // `auto` or a language code the shell registers (`zh-hant`); the shell shows it as a dropdown. diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index 938d2643a..d41a4529b 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -455,8 +455,9 @@ fn placed_symbol(d: &Document, n: &Node) -> Option { enum Which { /// Every raster effect (PDF). All, - /// Effects SVG cannot represent faithfully, with their other raster effects. - Pixel { cmyk: bool }, + /// Effects SVG cannot represent faithfully, with their other raster effects; `filters`: the SVG + /// written has filters (SVG Tiny 1.2 has none). + Pixel { cmyk: bool, filters: bool }, } /// Has `n` (its own, its fills' or strokes') raster effects `which` turns into an image? @@ -464,8 +465,14 @@ fn imaged(n: &Node, which: Which) -> bool { let fx = raster_fx(n); match which { Which::All => !fx.is_empty(), - Which::Pixel { cmyk } => { - fx.iter().any(|f| matches!(f, RasterFx::Pixel(_) | RasterFx::Rasterize(_))) + Which::Pixel { cmyk, filters } => { + // An SVG filter that is the object's last effect, and its only pixel effect, stays a filter. + let pixels = fx.iter().filter(|f| matches!(f, RasterFx::Pixel(_) | RasterFx::Rasterize(_))).count(); + let live = filters + && pixels == 1 + && effects::svg_filter::writes_live(&n.appearance.effects) + && fx.iter().any(|f| matches!(f, RasterFx::Pixel(effects::PixelFx::Svg(s)) if s.in_points() && (!s.measured() || fx.len() == 1) && s.writes_as_svg11())); + (pixels > 0 && !live) || (fx.iter().any(|f| matches!(f, RasterFx::DropShadow { darkness: Some(_), .. })) && (cmyk || !simple_rgb_shadow_source(n, &fx))) } } @@ -592,7 +599,13 @@ pub fn flatten_raster_effects(doc: &Document) -> Option { /// Mask…) or Rasterize are images as [`flatten_raster_effects`] makes them (SVG export: its /// filters draw the other raster effects live), or `None` when there are none. pub fn flatten_pixel_effects(doc: &Document) -> Option { - flatten(doc, &mut FlattenReport::new(Which::Pixel { cmyk: doc.color_mode == ColorMode::Cmyk }, doc)) + flatten_pixel_effects_for(doc, true) +} + +/// [`flatten_pixel_effects`] for SVG that has filters (`filters`) or not (SVG Tiny 1.2): without +/// them an SVG filter (Effect › SVG Filters) also makes its object an image. +pub fn flatten_pixel_effects_for(doc: &Document, filters: bool) -> Option { + flatten(doc, &mut FlattenReport::new(Which::Pixel { cmyk: doc.color_mode == ColorMode::Cmyk, filters }, doc)) } fn flatten(doc: &Document, report: &mut FlattenReport) -> Option { diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index f4996f4ad..00b87c649 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -9,7 +9,7 @@ use std::collections::{BTreeSet, HashMap}; use serde_json::{Value, json}; use vectorcraft_color::recolor::{ColorKey, GlobalAdjust, Method, Preserve, Rng, RowMap, cluster, same_ink}; use vectorcraft_color::{Color, Paint, Swatch}; -use vectorcraft_doc::swatches::{node_colors, node_paints}; +use vectorcraft_doc::swatches::{node_colors, node_paints, override_paints}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; use super::colorcmds::{Recolor, Scope}; @@ -99,7 +99,7 @@ fn used_colors(d: &Document, ids: &[NodeId], only: Option<&BTreeSet>) -> read(n, only, &mut nodes); } for m in nodes { - for p in node_paints(m) { + for p in node_paints(m).chain(override_paints(m)) { match p { Paint::Solid { color, swatch, .. } => count(color, swatch.as_deref()), Paint::Gradient(g) => g.gradient.stops.iter().for_each(|s| count(&s.color, s.swatch.as_deref())), @@ -522,6 +522,9 @@ fn apply(s: &mut Session, p: &Value) -> Result { }; let row_swatches: Vec> = rows.iter().map(|r| r.new_color().and_then(|c| lib_swatches.iter().find(|w| w.paint.color() == Some(c)).cloned())).collect(); + // While an object of a dynamic symbol instance's art is selected, the selection is refused, as + // `selected_roots` refuses it. + super::edit::refuse_symbol_part(s)?; let ids = s.doc()?.selection.objects.clone(); let group = text_param(p, "group", C)?; if ids.is_empty() && group.is_none() { diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index 1ad456b94..8b5731b33 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -565,6 +565,10 @@ struct Meta { path: Option, /// The format Save writes for it ([`SAVE_FORMATS`]). format: String, + /// A document's own native PDF choice before the recovery copy omits its PDF. None keeps + /// inheritance from the current application preference. Older copies did not record a choice. + #[serde(skip_serializing_if = "Option::is_none")] + native_pdf_compatible: Option, /// When the copy was written (Unix seconds; none without a clock). saved: Option, /// The file it was read from when that import left things out of the document, and what @@ -821,7 +825,7 @@ pub fn jobs(s: &mut Session, store: &Arc) -> Result<(Vec) -> Result<(Vec, file: &str) -> R st.imported_from = meta.imported_from.clone(); st.import_losses = meta.import_losses.clone(); st.format = SAVE_FORMATS.iter().copied().find(|f| *f == meta.format).unwrap_or("vectorcraft"); + if let Some(pdf) = meta.native_pdf_compatible { + st.save_options.insert("pdfCompatible".into(), json!(pdf)); + } st.mark_unsaved(); // The copy stays until the document is saved or closed. st.recovery = Some(RecoveryCopy { file: mine.clone(), doc: st.doc.clone() }); diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index d175b4469..45f267048 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -62,7 +62,7 @@ impl Loader for Folder { /// The bytes the base64 text `b64` holds, up to `max` and a few more: only the text they take is /// decoded. -fn decode_head(b64: &str, max: usize) -> Option> { +pub(super) fn decode_head(b64: &str, max: usize) -> Option> { let chars = max.div_ceil(3).saturating_mul(4); let mut kept = 0usize; let end = b64 diff --git a/crates/engine/src/cmd/slices.rs b/crates/engine/src/cmd/slices.rs index c2092da0a..f52c26a1c 100644 --- a/crates/engine/src/cmd/slices.rs +++ b/crates/engine/src/cmd/slices.rs @@ -708,7 +708,9 @@ fn move_slices(s: &mut Session, p: &Value) -> Result { None => { if let Some(n) = d.node_mut(*id) { n.transform(vectorcraft_geom::Affine::translate(v), vectorcraft_doc::Scaling::default()); + super::object::keep_pixel_aligned(n, vectorcraft_doc::Scaling::default()); } + super::object::keep_compound_aligned(d, *id, vectorcraft_doc::Scaling::default()); } } } diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index 4937ff200..1fb3373c7 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -266,6 +266,7 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { if let Some(st) = n.appearance.stroke_at_mut(index) { set(st); } + super::object::snap_if_aligned(n); Ok(()) })?; // Commit the new-art template only after the complete request is validated @@ -329,6 +330,7 @@ fn stroke_step(s: &mut Session, p: &Value, up: bool) -> Result { if let Some(st) = n.appearance.stroke_at_mut(index) { st.width = step_weight(st.width, unit, up); } + super::object::snap_if_aligned(n); Ok(()) })?; Ok(json!({"objects": ids.iter().collect::>().len()})) @@ -343,10 +345,15 @@ impl Session { /// The stroke the Stroke panel, the Control bar and the Properties panel show, picked as the /// Stroke proxy picks its paint: the Appearance panel's active item when it is a stroke, else /// the first selected object's (a group's first painted object's) top stroke, type's first - /// run's character stroke; with nothing selected, the next object drawn's - /// ([`Session::new_art_stroke`]). None without a document or the selection's stroke. + /// run's character stroke; with an object of a dynamic symbol instance selected + /// ([`DocState::selected_symbol_part`]), that object's as the instance draws it; with nothing + /// selected, the next object drawn's ([`Session::new_art_stroke`]). None without a document or + /// the selection's stroke. pub fn shown_stroke(&self) -> Option { let st = self.active()?; + if let Some(part) = st.selected_symbol_part() { + return node_stroke(&part); + } let Some(first) = st.selection.objects.first() else { return Some(self.new_art_stroke()) }; let first = st.doc.node(*first)?; if let Some(i) = first.appearance.item_of_kind(self.appearance_item(), false) { @@ -360,13 +367,19 @@ impl Session { impl DocState { /// Which Stroke panel values the selected objects' strokes (type: every run's) don't share, - /// and whether Align Stroke applies to them (no open path among them). One walk of the - /// selection, so callers that ask every frame cache it by revision. + /// and whether Align Stroke applies to them (no open path among them). With an object of a + /// dynamic symbol instance selected ([`DocState::selected_symbol_part`]), that object alone is + /// read. One walk of the selection, so callers that ask every frame cache it by revision. pub fn stroke_mixed(&self) -> StrokeMixed { + let part = self.selected_symbol_part(); let mut nodes = vec![]; - for id in &self.selection.objects { - if let Some(n) = self.doc.node(*id) { - painted(n, true, &mut nodes); + if let Some(part) = &part { + painted(part, true, &mut nodes); + } else { + for id in &self.selection.objects { + if let Some(n) = self.doc.node(*id) { + painted(n, true, &mut nodes); + } } } let mut strokes = vec![]; diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 36780707f..5981396f1 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec { "Apply Graphic Style", ["Window", "Graphic Styles"], None, - "{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}", + "{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (with `add`: of the kinds of row the style shows; exceptions: docs/mcp.md). With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}", has_doc, style_apply ), @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "Redefine Graphic Style", ["Window", "Appearance"], None, - "{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object; the objects still linked to it update, and that object becomes linked → {name}", + "{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object; the objects still linked to it update, and that object becomes linked. On a linked path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {name}", has_doc, style_redefine ), @@ -273,6 +273,7 @@ fn apply_style(d: &mut Document, id: NodeId, g: &GraphicStyle, chars: bool) { if let Some(n) = d.node_mut(id) { style_node(n, g, chars); n.extras_mut().graphic_style = Some(g.id); + super::object::snap_if_aligned(n); } } @@ -333,6 +334,7 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { s.new_art_style(&g, add); return Ok(json!({ "newArt": true })); } + let before = s.doc()?.doc.clone(); s.edit("Apply Graphic Style", |d, _| { if add { let g = d.graphic_styles[i].clone(); @@ -344,12 +346,17 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { n.extras_mut().graphic_style = None; } } + // The graph stores the rows of the kinds the style shows, now on top of each stack. + let shows = |kind: bool| g.appearance.items.iter().any(|it| it.is_fill() == kind && it.visible()); + let kinds = (shows(true), shows(false)); + super::appearance::capture_series_writes(&before, d, &ids, kinds, kinds, false); } else { d.graphic_style_id(i); let g = d.graphic_styles[i].clone(); for id in &ids { apply_style(d, *id, &g, chars); } + super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false); } Ok(()) })?; @@ -456,6 +463,7 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { let look = GraphicStyle::of(String::new(), captured(n), n); let name = d.graphic_styles[i].name.clone(); let chars = s.prefs.override_char_color; + let before = s.doc()?.doc.clone(); s.edit_named("Redefine Graphic Style “{name}”", &name, false, |d, _| { let id = d.graphic_style_id(i); let old = std::mem::replace(&mut d.graphic_styles[i], GraphicStyle { name: name.clone(), id, ..look }); @@ -465,9 +473,10 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { let (follow, edited): (Vec, Vec) = linked.into_iter().partition(|nid| d.node(*nid).is_some_and(|n| in_sync(n, &old))); set_link(d, &edited, None); let g = d.graphic_styles[i].clone(); - for nid in follow { - apply_style(d, nid, &g, chars); + for nid in &follow { + apply_style(d, *nid, &g, chars); } + super::appearance::capture_series_writes(&before, d, &follow, (true, true), (true, true), false); set_link(d, &[src], Some(id)); Ok(()) })?; diff --git a/crates/engine/src/cmd/stylelib.rs b/crates/engine/src/cmd/stylelib.rs index 2864f574b..7b0cad576 100644 --- a/crates/engine/src/cmd/stylelib.rs +++ b/crates/engine/src/cmd/stylelib.rs @@ -156,6 +156,9 @@ fn add(s: &mut Session, p: &Value) -> Result { let (apply, add) = (switch_or(p, "apply", false, C)?, switch_or(p, "add", false, C)?); if p.get("ids").is_some_and(|v| !v.is_null()) { checked_ids_param(s, p, "ids", C)?; + } else if apply { + // A selection that can't take a style is refused before any style is added. + super::appearance::appearance_targets(s, &json!({}))?; } let mut names = names_param(p, C)?; names.extend(text_param(p, "name", C)?.map(str::to_string)); diff --git a/crates/engine/src/cmd/svgfilter.rs b/crates/engine/src/cmd/svgfilter.rs new file mode 100644 index 000000000..98b29a6e2 --- /dev/null +++ b/crates/engine/src/cmd/svgfilter.rs @@ -0,0 +1,234 @@ +//! Effect › SVG Filters: the SVG filters a document holds (`svgFilter.list`, `define`, `delete`, +//! `import`) and applying one to objects as a live `svg.filter` effect (`svgFilter.apply`). An +//! applied effect names the document's filter: editing the filter changes what every object that +//! uses it draws, and deleting it leaves their effects in place, drawing nothing, until a filter of +//! that name exists again. Each effect holds the definition it draws with, kept in step here. + +use serde_json::{Value, json}; +use vectorcraft_doc::{Appearance, Document, SvgFilter}; +use vectorcraft_render::effects::{self, svg_filter}; + +use super::fileio::{absolute_path, read_file_head}; +use super::{CommandSpec, Result, Session, bad, cmd, has_doc, text_param}; + +/// The largest SVG file Import SVG Filter reads. +const MAX_IMPORT_BYTES: usize = 16 << 20; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "svgFilter.list", + "SVG Filters", + [], + None, + "{} → {filters: [{name, xml}…]} the SVG filters the document holds, in the SVG Filters submenu's order", + has_doc, + list + ), + cmd!( + "svgFilter.define", + "New SVG Filter", + [], + None, + "{xml: one SVG element (its id is its name; 1..64 primitives; at most 64 KB), replace?: the name of the filter it replaces and keeps (Edit SVG Filter)} add an SVG filter to the document, after the others, or replace one, as one undo step; every object whose svg.filter effect names it draws with the new definition; a name another filter has is refused and a document holds at most 64 filters → {name, filters}", + has_doc, + define + ), + cmd!( + "svgFilter.delete", + "Delete SVG Filter", + [], + None, + "{name} remove an SVG filter from the document, as one undo step; objects whose svg.filter effect names it keep the effect, which draws nothing until a filter of that name is defined or imported again → {filters}", + has_doc, + delete + ), + cmd!( + "svgFilter.import", + "Import SVG Filter…", + [], + None, + "{path?|svg?} add the elements of an SVG file (at most 16 MB; not SVGZ; its first 256 filters) or of SVG text to the document's SVG filters, as one undo step; a filter whose name the document already has, and one svgFilter.define would refuse, is left out; objects whose effect names an added filter draw with it → {added: [names…], skipped: count, filters}", + has_doc, + import + ), + cmd!( + "svgFilter.apply", + "Apply SVG Filter", + [], + None, + "{name, ids?|id?, item?} apply the document's SVG filter `name` (svgFilter.list) to ids or the selection as a live svg.filter effect, as one undo step, like effect.apply → {ids, index, item}", + has_doc, + apply + ), + ] +} + +fn filters_json(d: &Document) -> Value { + json!(d.svg_filters.iter().map(|f| json!({ "name": f.name, "xml": f.xml })).collect::>()) +} + +fn list(s: &mut Session, _: &Value) -> Result { + Ok(json!({ "filters": filters_json(&s.doc()?.doc) })) +} + +/// `p`'s `name`, trimmed and not empty. +fn name_param(p: &Value, cmd: &str) -> Result { + let n = text_param(p, "name", cmd)?.map(str::trim).filter(|n| !n.is_empty()); + n.map(str::to_string).ok_or_else(|| bad(cmd, "give `name`, an SVG filter's name")) +} + +/// Is `e` an `svg.filter` effect that names filter `name`? +fn names(e: &vectorcraft_doc::Effect, name: &str) -> bool { + e.id == effects::SVG_FILTER && e.params.get("name").and_then(Value::as_str) == Some(name) +} + +/// The `svg.filter` effects of appearance `a` (its own and its fills' and strokes') that name +/// filter `name`. +fn named<'a>(a: &'a Appearance, name: &'a str) -> impl Iterator + 'a { + let items = a.items.iter().flat_map(|i| i.effects().iter()); + a.effects.iter().chain(items).filter(move |e| names(e, name)) +} + +/// Make the effects of appearance `a` that name filter `name` draw with `xml`. +fn update_appearance(a: &mut Appearance, name: &str, xml: &str) { + let items = a.items.iter_mut().flat_map(|i| i.effects_mut().iter_mut()); + for e in a.effects.iter_mut().chain(items) { + if names(e, name) && e.params.is_object() { + e.params["xml"] = json!(xml); + } + } +} + +/// Does an effect in `d` that names filter `name` draw with something other than `xml` (`""`: +/// nothing)? Symbol definitions, graph designs, patterns, opacity masks and graphic styles count. +fn out_of_step(d: &Document, name: &str, xml: &str) -> bool { + let stale = |a: &Appearance| named(a, name).any(|e| e.params.get("xml").and_then(Value::as_str) != Some(xml)); + d.any_node(|n| stale(&n.appearance)) || d.graphic_styles.iter().any(|s| stale(&s.appearance)) +} + +/// Make every effect in `d` that names filter `name` draw with `xml` (`""` when the document no +/// longer has the filter: the effect keeps its name and draws nothing), symbol definitions, graph +/// designs, patterns, opacity masks and graphic styles included. +fn update_users(d: &mut Document, name: &str, xml: &str) { + // Through `DerefMut` only for an appearance that has such an effect (it is shared copy-on-write). + d.edit_nodes_where(|n| named(&n.appearance, name).next().is_some(), |n| update_appearance(&mut n.appearance, name, xml)); + for s in d.graphic_styles.iter_mut().filter(|s| named(&s.appearance, name).next().is_some()) { + update_appearance(&mut s.appearance, name, xml); + } +} + +fn define(s: &mut Session, p: &Value) -> Result { + const C: &str = "svgFilter.define"; + let xml = match p.get("xml") { + Some(Value::String(x)) => x.trim().to_string(), + Some(_) => return Err(bad(C, "`xml` must be a string")), + None => return Err(bad(C, "give `xml`, one SVG element")), + }; + let replace = match p.get("replace") { + None | Some(Value::Null) => None, + Some(Value::String(r)) => Some(r.trim().to_string()), + Some(_) => return Err(bad(C, "`replace` must be a string")), + }; + svg_filter::check(&xml).map_err(|e| bad(C, e))?; + let filters = &s.doc()?.doc.svg_filters; + let at = match &replace { + Some(r) => Some(filters.iter().position(|f| f.name == *r).ok_or_else(|| bad(C, format!("the document has no SVG filter named {r:?}")))?), + None => None, + }; + // An edited filter keeps its name, whatever id its new code gives it. + let xml = match &replace { + Some(r) => svg_filter::with_id(&xml, r).ok_or_else(|| bad(C, "give one element"))?, + None => xml, + }; + let name = svg_filter::check(&xml).map_err(|e| bad(C, e))?; + if filters.iter().enumerate().any(|(i, f)| f.name == name && Some(i) != at) { + return Err(bad(C, format!("the document already has an SVG filter named {name:?}"))); + } + if at.is_none() && filters.len() >= SvgFilter::MAX { + return Err(bad(C, format!("a document holds at most {} SVG filters", SvgFilter::MAX))); + } + let new = SvgFilter { name: name.clone(), xml }; + // Nothing to record when the filter, and every object that uses it, is already as given. + if at.and_then(|i| filters.get(i)) != Some(&new) || out_of_step(&s.doc()?.doc, &new.name, &new.xml) { + let label = if at.is_some() { "Edit SVG Filter" } else { "New SVG Filter" }; + s.edit(label, |d, _| { + update_users(d, &new.name, &new.xml); + match at.and_then(|i| d.svg_filters.get_mut(i)) { + Some(f) => *f = new, + None => d.svg_filters.push(new), + } + Ok(()) + })?; + } + Ok(json!({ "name": name, "filters": filters_json(&s.doc()?.doc) })) +} + +fn delete(s: &mut Session, p: &Value) -> Result { + const C: &str = "svgFilter.delete"; + let name = name_param(p, C)?; + if !s.doc()?.doc.svg_filters.iter().any(|f| f.name == name) { + return Err(bad(C, format!("the document has no SVG filter named {name:?}"))); + } + s.edit("Delete SVG Filter", |d, _| { + d.svg_filters.retain(|f| f.name != name); + update_users(d, &name, ""); + Ok(()) + })?; + Ok(json!({ "filters": filters_json(&s.doc()?.doc) })) +} + +/// The bytes of `p`'s `path` (an SVG file, read up to [`MAX_IMPORT_BYTES`]) or `svg`. +fn import_bytes(p: &Value, cmd: &str) -> Result> { + let bytes = match (text_param(p, "path", cmd)?, text_param(p, "svg", cmd)?) { + // One byte past the cap is enough to refuse the file below. + (Some(path), _) => read_file_head(&absolute_path(path), MAX_IMPORT_BYTES + 1)?, + (None, Some(svg)) => svg.as_bytes().to_vec(), + _ => return Err(bad(cmd, "give `path`, an SVG file, or `svg`, SVG text")), + }; + if bytes.len() > MAX_IMPORT_BYTES { + return Err(bad(cmd, format!("the SVG file is larger than {MAX_IMPORT_BYTES} bytes"))); + } + Ok(bytes) +} + +fn import(s: &mut Session, p: &Value) -> Result { + const C: &str = "svgFilter.import"; + let bytes = import_bytes(p, C)?; + // SVGZ would unpack past the size read above. + if vectorcraft_svg::is_svgz(&bytes) { + return Err(bad(C, "Import SVG Filter reads SVG files, not SVGZ")); + } + let text = vectorcraft_svg::text_of(&bytes).map_err(|e| bad(C, format!("the file isn't valid SVG: {e}")))?; + let held = s.doc()?.doc.svg_filters.clone(); + let room = SvgFilter::MAX.saturating_sub(held.len()); + // Filters past the room left and names the document has are left out unchecked. + let (found, skipped) = vectorcraft_svg::svg_filters(&text, room, |name| held.iter().any(|f| f.name == name)) + .map_err(|e| bad(C, format!("the file isn't valid SVG: {e}")))?; + let added: Vec = found.into_iter().map(|(name, xml)| SvgFilter { name, xml }).collect(); + let names: Vec = added.iter().map(|f| f.name.clone()).collect(); + if !added.is_empty() { + s.edit("Import SVG Filter", |d, _| { + for f in &added { + update_users(d, &f.name, &f.xml); + } + d.svg_filters.extend(added); + Ok(()) + })?; + } + Ok(json!({ "added": names, "skipped": skipped, "filters": filters_json(&s.doc()?.doc) })) +} + +fn apply(s: &mut Session, p: &Value) -> Result { + const C: &str = "svgFilter.apply"; + let name = name_param(p, C)?; + let f = s.doc()?.doc.svg_filters.iter().find(|f| f.name == name).cloned(); + let f = f.ok_or_else(|| bad(C, format!("the document has no SVG filter named {name:?}")))?; + let mut q = json!({ "effect": effects::SVG_FILTER, "params": { "name": f.name, "xml": f.xml } }); + for key in ["ids", "id", "item"] { + if let Some(v) = p.get(key) { + q[key] = v.clone(); + } + } + super::effectcmd::apply(s, &q) +} diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index f19861685..2421441b9 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -19,7 +19,7 @@ pub fn specs() -> Vec { "New Swatch", ["Window", "Swatches"], None, - "{name?, color? | colors?: [colour] (one swatch each, in one undo step; #rrggbbaa and #rgba are refused) | swatch? | gradient? | pattern?: name (default: the current fill), tint?: 0..100 (% of the global or spot `swatch`, or of the one the current fill links to), mode?: \"gray\"|\"rgb\"|\"hsb\"|\"lab\"|\"cmyk\"|\"web\" (convert the colour; lab: CIE L*a*b*, how spot colours are usually defined), global?, spot? (a spot colour, always global), group?: colour group name (solid colours only; default: ungrouped), none?: true (the None paint), paint?: paint.setFill params, keepModel?: bool (as paint.setFill)} save a colour, gradient or pattern as a swatch. A tint of a global or spot colour (below 100%, without mode, global or spot) becomes a tint swatch, \"Name 40%\", linked to its base: it follows edits to the base and applies as that tint of it. Names are unique (\"Sky 2\"); a colour's default name is its values (\"C=10 M=20 Y=30 K=0\", \"R=255 G=128 B=0\", \"Gray K=40\", \"L=55 a=60 b=40\") → {name, names: [every new swatch]}", + "{name?, color? | colors?: [colour] (one swatch each, in one undo step; #rrggbbaa and #rgba are refused) | swatch? | gradient? | pattern?: name (default: the current fill), tint?: 0..100 (% of the global or spot `swatch`, or of the one the current fill links to), mode?: \"gray\"|\"rgb\"|\"hsb\"|\"lab\"|\"cmyk\"|\"web\" (convert the colour; lab: CIE L*a*b*, how spot colours are usually defined), global?, spot? (a spot colour, always global), group?: colour group name (solid colours only; default: ungrouped), none?: true (the None paint), paint?: paint.setFill params, keepModel?: bool (as paint.setFill)} save a colour, gradient or pattern as a swatch. A tint of a global or spot colour (below 100%, without mode, global or spot) becomes a tint swatch, \"Name 40%\", linked to its base: it follows edits to the base and applies as that tint of it. A gradient swatch holds no link to the swatch its gradient came from, and a pattern swatch holds the pattern untransformed. Names are unique (\"Sky 2\"); a colour's default name is its values (\"C=10 M=20 Y=30 K=0\", \"R=255 G=128 B=0\", \"Gray K=40\", \"L=55 a=60 b=40\") → {name, names: [every new swatch]}", has_doc, swatch_new ), @@ -187,7 +187,11 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result { })), Paint::None => Err(bad(C, "a swatch needs a colour, gradient or pattern")), _ if spot || mode.is_some() || group.is_some() => Err(bad(C, "spot, mode and group apply to solid colours only")), - other => Ok(other), + // The swatch holds the gradient itself, not a link to the swatch it may have come from. + // It keeps `geom`, which holds the aspect the swatch is applied with. + Paint::Gradient(g) => Ok(Paint::Gradient(Box::new(vectorcraft_color::GradientPaint { swatch: None, ..*g }))), + // The swatch holds the pattern untransformed, not the placement of the art it came from. + Paint::Pattern { pattern, .. } => Ok(pattern_paint(&pattern)), }) .collect::>>()?; // A name applies to a single new swatch. diff --git a/crates/engine/src/cmd/symboledit.rs b/crates/engine/src/cmd/symboledit.rs index 618ef2ee3..de262cf2b 100644 --- a/crates/engine/src/cmd/symboledit.rs +++ b/crates/engine/src/cmd/symboledit.rs @@ -78,6 +78,10 @@ pub(crate) fn enter(s: &mut Session, p: &Value) -> Result { (name, xf, ids.first().copied()) } None => { + // Refused while an object of a dynamic instance's art is selected, unless `name` is given. + if str_param(p, "name").is_none() && st.selection.symbol_part.is_some() { + return Err(super::brushsym::part_only()); + } let picked = if str_param(p, "name").is_some() { vec![] } else { selected_instances(st) }; let symbols: Vec<(String, Affine)> = picked.iter().filter_map(|id| instance_of(&st.doc, *id)).collect(); match symbols.first() { diff --git a/crates/engine/src/cmd/xform.rs b/crates/engine/src/cmd/xform.rs index 998109f3d..7af7cf1f8 100644 --- a/crates/engine/src/cmd/xform.rs +++ b/crates/engine/src/cmd/xform.rs @@ -414,6 +414,7 @@ fn artboard_move(s: &mut Session, p: &Value) -> Result { for id in art { if let Some(n) = d.node_mut(*id) { n.transform(Affine::translate(dv), sc); + super::object::keep_pixel_aligned(n, sc); } } index.push(*i); diff --git a/crates/engine/src/command_sets.rs b/crates/engine/src/command_sets.rs index a115498e1..2224ddccc 100644 --- a/crates/engine/src/command_sets.rs +++ b/crates/engine/src/command_sets.rs @@ -73,6 +73,11 @@ pub(crate) fn strip_replay_only(id: &str, p: &mut Value) { } } +/// Journaled commands that write a file and change nothing in the document: with an object of a +/// dynamic symbol instance's art selected, they read the instance as queries do +/// ([`crate::Session::execute`]). +pub(crate) const JOURNALED_READS: &[&str] = &["css.export"]; + /// Commands that act on the selection and take `ids` by selecting them first (`select.set`, with /// its checks), then running as on the selection: the result and the selection afterwards are the /// menu command's (#2130). diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f2c12a7a0..3753b79fc 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -170,6 +170,10 @@ pub struct Session { /// has inherited the selection's appearance: the documents ([`DocState::uid`]) whose newest /// history step takes the colors the batch finally left. pub(crate) batch_paint_docs: Vec, + /// While a command that changes nothing in the document runs (a query, or one of + /// [`command_sets::JOURNALED_READS`]): it reads the instance of an object selected in a symbol + /// instance's art. + pub(crate) reading: bool, /// While [`Session::place_read`] runs `file.place`: the file read for it, which the command /// places instead of reading the file; `None` otherwise. pub(crate) place_hand_off: Option, diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index fc2530c03..e1346b457 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -174,6 +174,10 @@ pub struct Prefs { /// acting as the title bar (tiling window managers, desktops that draw their own decorations). /// Read when the app starts. macOS always uses the system's. pub system_title_bar: bool, + /// Linux and the BSDs: draw the app bar as the title bar in sessions where the desktop draws + /// the frame (X11, Wayland compositors with server-side decorations, XWayland), as on GNOME. + /// Read when the app starts; System Title Bar and `VECTORCRAFT_TITLEBAR` take precedence over it. + pub app_title_bar: bool, // Performance pub gpu_performance: bool, pub animated_zoom: bool, @@ -457,6 +461,7 @@ impl Default for Prefs { scale_cursor_with_ui: false, interface_language: s("auto"), system_title_bar: false, + app_title_bar: false, gpu_performance: true, animated_zoom: true, gpu_preference: s("automatic"), diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 1eaf15b88..27976ff51 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -1,4 +1,4 @@ -use super::command_sets::{SELECTS_GIVEN_IDS, given_ids, strip_replay_only}; +use super::command_sets::{JOURNALED_READS, SELECTS_GIVEN_IDS, given_ids, strip_replay_only}; use super::doc_state::DocState; use super::error::{EngineError, Result}; use super::history::NamedStep; diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index d46c9b9b8..f6dd7c8b6 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -83,6 +83,7 @@ impl Session { batch_stash: None, batch_steps: None, batch_paint_docs: Vec::new(), + reading: false, place_hand_off: None, queue_hand_off: None, place_queue_roots: None, diff --git a/crates/engine/src/session/edit.rs b/crates/engine/src/session/edit.rs index da285eb3d..7fcbdc912 100644 --- a/crates/engine/src/session/edit.rs +++ b/crates/engine/src/session/edit.rs @@ -26,7 +26,11 @@ impl Session { if let Some(ge) = &st.global_edit { spread = cmd::globaledit::propagate(&before, doc, ge, [&before_sel.objects, &st.selection.objects]); } - cmd::objects_on_path::refresh(&before, doc); + // Pixel-aligned objects the edit changed go back on the pixel grid. + cmd::refresh_pixel_aligned(&before, doc); + let placed = cmd::objects_on_path::refresh(&before, doc); + // Pixel-aligned members that placing moved go back on the pixel grid. + cmd::keep_aligned_in(doc, &placed); dim_changed = cmd::dimension::refresh(&before, doc)?; Ok(v) }) { @@ -35,7 +39,9 @@ impl Session { Ok(shaper_changed) => { // Attached Shaper groups whose art was rebuilt go back on their path. if shaper_changed || dim_changed { - cmd::objects_on_path::refresh(&before, Arc::make_mut(&mut st.doc)); + let doc = Arc::make_mut(&mut st.doc); + let placed = cmd::objects_on_path::refresh(&before, doc); + cmd::keep_aligned_in(doc, &placed); } // Symbol editing: the symbol follows its art on the editing layer, before // inline graphics follow their symbols. @@ -340,7 +346,7 @@ impl Session { preview.1.get("dy").and_then(Value::as_f64).unwrap_or(0.0), ); st.keep_interaction(it); - if preview.0 == "object.transform" { + if preview.0 == "object.transform" && changed { let m = cmd::matrix_param(&preview.1, "matrix"); if let Some(m) = m { // The options are the last preview's params with the values it noted, without the diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 21822583d..71f90e7e4 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -1,5 +1,6 @@ use super::{ - CommandSpec, EngineError, NodeId, Result, SELECTS_GIVEN_IDS, Selection, Session, Value, cmd, find_command, given_ids, guard, strip_replay_only, + CommandSpec, EngineError, JOURNALED_READS, NodeId, Result, SELECTS_GIVEN_IDS, Selection, Session, Value, cmd, find_command, given_ids, guard, + strip_replay_only, }; impl Session { @@ -35,6 +36,13 @@ impl Session { /// Execute a command by id. This is THE entry point for every frontend. pub fn execute(&mut self, id: &str, params: &Value) -> Result { let spec = find_command(id).ok_or_else(|| cmd::params::unknown_command(id))?; + let outer = std::mem::replace(&mut self.reading, !spec.journal || JOURNALED_READS.contains(&id)); + let r = self.execute_spec(spec, id, params); + self.reading = outer; + r + } + + fn execute_spec(&mut self, spec: &'static CommandSpec, id: &str, params: &Value) -> Result { // A key the command's params description doesn't name is a parameter error (#1442), before // anything runs, for the commands other commands run too. A `command.batch` or an // `action.batch` checks its steps before the first one runs, and each step is checked again diff --git a/crates/engine/src/store.rs b/crates/engine/src/store.rs index 3d8259361..003713d2f 100644 --- a/crates/engine/src/store.rs +++ b/crates/engine/src/store.rs @@ -13,7 +13,9 @@ //! move or go away). Attaching the folder registers them again. //! * **Plug-ins**: the module of each plug-in `plugin.install` installs, as a `.wasm` file named //! after its id (as Illustrator keeps plug-ins in its Plug-ins folder); `plugin.remove` deletes it. -//! Attaching the folder installs them again. The Additional Plug-ins Folder preference is not +//! Attaching the folder installs them again, except in Safe Mode and except the ones the user +//! disabled, which `disabled.json` in the same folder lists (`plugin.setEnabled`; a window locks +//! `disabled.lock` while it writes the list). The Additional Plug-ins Folder preference is not //! copied: it is loaded from where it is. `grants.json` holds what the user granted the plug-ins //! stored there that list permissions (id, module hash, permissions); a stored plug-in whose //! permissions it doesn't grant is skipped with a log record, and stays in the folder. @@ -26,6 +28,7 @@ use std::io::Read; use std::path::{Path, PathBuf}; use vectorcraft_pdf::{PdfPreset, builtin_preset}; +use vectorcraft_plugins::safe_mode; use crate::Session; use crate::cmd::pdfcmds::{PRESET_FORMAT, PresetFile}; @@ -44,6 +47,10 @@ const PROFILE_EXT: &str = "icc"; pub const MAX_PLUGIN_STORE: u64 = 512 << 20; /// The extension of a stored plug-in. const PLUGIN_EXT: &str = "wasm"; +/// The file in the plug-in folder that lists the plug-ins the user disabled. +pub const DISABLED_FILE: &str = "disabled.json"; +/// The file in the plug-in folder that a window locks while it reads and writes [`DISABLED_FILE`]. +pub const DISABLED_LOCK: &str = "disabled.lock"; /// The file in the plug-in store that holds the permissions the user granted. const GRANTS_FILE: &str = "grants.json"; /// The largest grants file read. @@ -114,6 +121,25 @@ fn write_atomic(_: &Path, _: &[u8]) -> std::io::Result<()> { Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "no file system")) } +/// [`DISABLED_LOCK`] in `dir`, locked until the file is dropped, waiting while another window holds +/// it; `None` when it can't be opened or locked. +#[cfg(not(target_arch = "wasm32"))] +fn lock_disabled(dir: &Path) -> Option { + let opened = std::fs::create_dir_all(dir) + .and_then(|()| std::fs::File::options().create(true).truncate(false).read(true).write(true).open(dir.join(DISABLED_LOCK))); + let file = opened.and_then(|f| f.lock().map(|()| f)); + if let Err(e) = &file { + log::warn!("can't lock {}: {e}", dir.join(DISABLED_LOCK).display()); + } + file.ok() +} + +/// The browser has no folders to keep anything in. +#[cfg(target_arch = "wasm32")] +fn lock_disabled(_: &Path) -> Option { + None +} + /// Remove `dir/file`; a file that isn't there is what was wanted. fn take(dir: &Path, file: &str) { let path = dir.join(file); @@ -223,10 +249,25 @@ impl Session { } } - /// Keep the plug-ins this session installs in `dir`, and install the ones already there (a - /// module that doesn't load, and one that lists permissions the grants there don't grant that - /// very module, is skipped with a log record, and stays in `dir` with its grant). + /// Keep the plug-ins this session installs in `dir`, take the disabled plug-ins saved there + /// ([`DISABLED_FILE`]), and install the stored ones ([`Session::load_stored_plugins`]). pub fn attach_plugin_store(&mut self, dir: PathBuf) { + let disabled = + read_capped(&dir.join(DISABLED_FILE), safe_mode::MAX_DISABLED_FILE_BYTES).map(|b| safe_mode::parse_disabled_file(&b)).unwrap_or_default(); + safe_mode::set_disabled(disabled); + self.plugin_dir = Some(dir); + self.load_stored_plugins(); + } + + /// Install the plug-ins kept in the plug-in folder: none in Safe Mode, and none the user + /// disabled (matched by file name, which is made from the id). The launch marker names each + /// file while it loads. A module that doesn't load, and one that lists permissions the grants + /// there don't grant it, is skipped with a log record. + pub fn load_stored_plugins(&mut self) { + let Some(dir) = self.plugin_dir.clone() else { return }; + if safe_mode::active() { + return; + } let grants = stored_grants(&dir); let limits = vectorcraft_plugins::Limits::default(); let mut left = MAX_PLUGIN_STORE; @@ -235,10 +276,19 @@ impl Session { log::warn!("skipping the other stored plug-ins: they hold more than {MAX_PLUGIN_STORE} bytes together"); break; } + let item = safe_mode::Item::file(path.display().to_string()); + let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()); + let by_id = safe_mode::skipped_ids().iter().any(|id| stem.as_deref() == Some(file_stem(id).as_str())); + if safe_mode::skipped(&item) || by_id { + log::info!("skipping the stored plug-in {}: disabled", path.display()); + continue; + } + let token = safe_mode::loading(&item); let loaded = read_capped(&path, (limits.max_module_bytes as u64).min(left)).and_then(|bytes| { left -= bytes.len() as u64; vectorcraft_plugins::Plugin::load(&bytes, limits.clone()).map_err(|e| e.to_string()) }); + safe_mode::loaded(token); match loaded { // Only this store's grants count: one given elsewhere in the process doesn't let a // module stored here load. @@ -258,7 +308,40 @@ impl Session { Err(e) => log::warn!("skipping the stored plug-in {}: {e}", path.display()), } } - self.plugin_dir = Some(dir); + } + + /// Write the disabled plug-ins saved for the next launch to the plug-in folder → whether they + /// went there, every change included (`false` without a folder, and when a plug-in turned off + /// found the file's list full: [`safe_mode::Merged::complete`]). The changes this launch made are applied to the list + /// the file holds now ([`safe_mode::merge_saved`]), so a change another window saved to the same + /// folder meanwhile stays. Windows that save at the same time take turns on [`DISABLED_LOCK`]; + /// when it can't be locked, nothing is written, and the next change saves this one too. So is + /// a file that can't be read (too large, say) or that a later version wrote + /// ([`safe_mode::read_disabled_file`]): it is kept as it is. + pub fn store_disabled_plugins(&self) -> bool { + let Some(dir) = self.plugin_dir.as_deref() else { return false }; + let Some(_turn) = lock_disabled(dir) else { return false }; + let path = dir.join(DISABLED_FILE); + let theirs = match read_capped(&path, safe_mode::MAX_DISABLED_FILE_BYTES) { + Ok(bytes) => match safe_mode::read_disabled_file(&bytes) { + Some(items) => items, + None => { + log::warn!("can't save {}: it holds another format or version of the list", path.display()); + return false; + } + }, + Err(_) if !path.exists() => Vec::new(), + Err(e) => { + log::warn!("can't save {}: {e}", path.display()); + return false; + } + }; + let merged = safe_mode::merge_saved(Some(theirs)); + let written = put(dir, DISABLED_FILE, &merged.bytes); + if written { + safe_mode::saved_written(&merged); + } + written && merged.complete() } /// Whether `plugin` installs without asking the user: it lists no permissions, or this very @@ -282,11 +365,16 @@ impl Session { } } - /// Write the module `bytes` of the plug-in `id` to the store. - pub(crate) fn store_plugin(&self, id: &str, bytes: &[u8]) { - if let Some(dir) = &self.plugin_dir { - put(dir, &format!("{}.{PLUGIN_EXT}", file_stem(id)), bytes); - } + /// Write the module `bytes` of the plug-in `id` to the store → whether they went there (`false` + /// without a folder). + pub(crate) fn store_plugin(&self, id: &str, bytes: &[u8]) -> bool { + self.plugin_dir.as_deref().is_some_and(|dir| put(dir, &format!("{}.{PLUGIN_EXT}", file_stem(id)), bytes)) + } + + /// The file the plug-in `id` is stored in, spelled as [`Session::load_stored_plugins`] names it + /// (`None` without a plug-in folder). + pub(crate) fn stored_plugin_file(&self, id: &str) -> Option { + self.plugin_dir.as_ref().map(|dir| dir.join(format!("{}.{PLUGIN_EXT}", file_stem(id))).display().to_string()) } /// Record in the store what the user granted plug-in `id` (`None`: nothing), leaving the store's diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b708c25ca..742d7a3ac 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4,11 +4,13 @@ use super::*; use vectorcraft_geom::Point; use vectorcraft_tools::{PointerEvent, PointerKind, ToolKey}; +mod actionset; pub(super) mod adjust; mod ai_open_path; mod appearance; mod areafit; mod assets; +mod assets_3d; mod attributes; mod bboxrotate; mod blendfidelity; @@ -123,6 +125,7 @@ mod persp_planes; pub(super) mod persp_select; mod persp_text; mod perspgrid; +mod pixelalign; mod place; mod placed_document; mod plugins; @@ -163,6 +166,7 @@ mod strokeux; mod stylelib; mod stylepanel; mod styles; +mod svgfilter; mod swatchcmds; mod swatches; mod swatchlib; diff --git a/crates/engine/src/tests/actionset.rs b/crates/engine/src/tests/actionset.rs new file mode 100644 index 000000000..d80e649ec --- /dev/null +++ b/crates/engine/src/tests/actionset.rs @@ -0,0 +1,184 @@ +//! Action sets another app saved as text (`actionSet.read`): actions, steps, the tokens left unread +//! and the caps. Every set here is written for the test, with invented names. + +use serde_json::json; + +use crate::cmd::actionset::{AiaStep, read_aia}; +use crate::*; + +/// `text` as a bracketed hexadecimal value. +fn hex(text: &str) -> String { + let digits: String = text.bytes().map(|b| format!("{b:02x}")).collect(); + format!("[ {} {digits} ]", text.len()) +} + +/// A set written from scratch in the layout the module describes: two actions, the second with a +/// step left out of playback, a step with recorded values of each form, and names in several +/// scripts. +fn sample() -> String { + format!( + "/version 3\n/name {set}\n/isOpen 1\n/actionCount 2\n/action-1 {{\n\t/name {a1}\n\t/keyIndex 0\n\t/eventCount 1\n\t/event-1 {{\n\t\t/internalName (sample_join)\n\t\t/localizedName {join}\n\t\t/isOn 1\n\t\t/parameterCount 3\n\t\t/parameter-1 {{\n\t\t\t/key 1\n\t\t\t/type (unit real)\n\t\t\t/value 2.5\n\t\t}}\n\t\t/parameter-2 {{\n\t\t\t/type (ustring)\n\t\t\t/value {txt}\n\t\t}}\n\t\t/parameter-3 {{\n\t\t\t/type (raw)\n\t\t\t/value < 3\n\t\t\t\t0a7d0b\n\t\t\t>\n\t\t\t/size 3\n\t\t}}\n\t}}\n}}\r\n/action-2 {{\r\n\t/name {a2}\r\n\t/event-1 {{\r\n\t\t/internalName (sample\\)one)\r\n\t\t/isOn 0\r\n\t\t/parameterCount 0\r\n\t}}\r\n\t/event-2 {{\r\n\t\t/internalName (sample_two)\r\n\t\t/localizedName {two}\r\n\t}}\r\n}}\r\n", + set = hex("Prep Sätze"), + a1 = hex("First"), + join = hex("Join"), + txt = hex("a } brace"), + a2 = hex("二番目"), + two = hex("Second Step"), + ) +} + +/// The set's name, its actions and their steps come in with the step's names, its on state and how +/// many values it was recorded with, and nothing is listed unread. +#[test] +fn a_set_reads_its_actions_and_steps() { + let set = read_aia(sample().as_bytes()).unwrap(); + assert_eq!((set.version, set.name.as_deref(), set.truncated), (3, Some("Prep Sätze"), false)); + assert!(set.unread.is_empty(), "{:?}", set.unread); + let names: Vec<&str> = set.actions.iter().map(|a| a.name.as_str()).collect(); + assert_eq!(names, ["First", "二番目"]); + let step = |a: usize, s: usize| set.actions[a].steps[s].clone(); + let join = AiaStep { name: "sample_join".into(), label: Some("Join".into()), on: Some(1), params: 3, declared: Some(3), line: 9 }; + assert_eq!(step(0, 0), join); + assert_eq!((step(1, 0).name.as_str(), step(1, 0).label.clone(), step(1, 0).on), ("sample)one", None, Some(0))); + assert_eq!((step(1, 1).label.as_deref(), step(1, 1).on, step(1, 1).line), (Some("Second Step"), None, 39)); +} + +/// Every recorded value of a step is counted. A hexadecimal `/value` with the wrong byte count marks +/// the set truncated; a long one or one that isn't UTF-8 does not. +#[test] +fn recorded_values_are_counted_and_checked() { + let set = |values: &[String]| { + let params: String = values.iter().enumerate().map(|(i, v)| format!("/parameter-{} {{ /key 1 {v} }} ", i + 1)).collect(); + read_aia(format!("/version 3 /action-1 {{ /event-1 {{ {params} }} }}").as_bytes()).unwrap() + }; + let value = |t: &str| format!("/type (ustring) /value {}", hex(t)); + let read = set(&[value("one"), value("two"), value("three")]); + assert_eq!((read.actions[0].steps[0].params, read.truncated), (3, false)); + let read = set(&[value(&"n".repeat(2000)), "/value [ 1 ff ]".to_string(), "/type (real) /value 4.5".to_string()]); + assert!(!read.truncated, "a long value or one that isn't UTF-8 is not damage"); + assert!(set(&["/value [ 3 4142 ]".to_string()]).truncated); + assert!(set(&[value("one"), "/value [ 1 zz ]".to_string()]).truncated, "a second value that isn't hexadecimal"); +} + +/// `actionSet.read` takes exactly one of `path`, `data` and `dataBase64`, and text for the text +/// params; any other value is an error. +#[test] +fn read_command_refuses_bad_params() { + let mut s = Session::new(); + let text = "/version 3"; + let dir = std::env::temp_dir().join(format!("vc-actionset-params-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("Mine.aia"); + std::fs::write(&file, text).unwrap(); + let path = file.to_string_lossy(); + assert!(s.execute("actionSet.read", &json!({ "path": path })).is_ok()); + let b64 = vectorcraft_format::base64_encode(text.as_bytes()); + for p in [ + json!({ "path": 5, "data": text }), + json!({ "data": 5 }), + json!({ "dataBase64": 5 }), + json!({ "data": text, "name": 7 }), + json!({ "path": path, "data": text }), + json!({ "path": path, "dataBase64": b64 }), + json!({ "data": text, "dataBase64": b64 }), + ] { + assert!(s.execute("actionSet.read", &p).is_err(), "{p}"); + } + let r = s.execute("actionSet.read", &json!({ "data": text, "name": null })).unwrap(); + assert_eq!(r["set"], "", "no set name and no file name"); + let _ = std::fs::remove_dir_all(dir); +} + +/// `actionSet.read` names the set after the file when it has no name, gives a step without a label, +/// or with a blank one, a null label, and refuses text that isn't an action set. +#[test] +fn read_command_answers_with_the_set() { + let mut s = Session::new(); + let text = sample().replacen(&format!("/name {}", hex("Prep Sätze")), "", 1); + let r = s.execute("actionSet.read", &json!({ "data": text, "name": "/sets/Mine.aia" })).unwrap(); + assert_eq!(r["set"], "Mine"); + assert_eq!(r["actions"][1]["steps"][0], json!({"name": "sample)one", "label": null, "on": false, "params": 0, "line": 34})); + assert_eq!(r["actions"][0]["steps"][0]["on"], true); + assert_eq!(r["actions"][0]["steps"][0]["label"], "Join"); + let blank = format!("/version 3 /action-1 {{ /event-1 {{ /internalName (x) /localizedName {} }} }}", hex(" ")); + assert_eq!(s.execute("actionSet.read", &json!({ "data": blank })).unwrap()["actions"][0]["steps"][0]["label"], json!(null)); + for text in ["/Menus { }", "", "version 3", "/version three"] { + assert!(s.execute("actionSet.read", &json!({ "data": text })).is_err(), "{text}"); + } + assert!(s.execute("actionSet.read", &json!({})).is_err()); +} + +/// `path` and `dataBase64` read the same set as `data`, and a set's name falls back to the path's. +#[test] +fn a_set_reads_from_a_path_and_from_base64() { + let dir = std::env::temp_dir().join(format!("vc-actionset-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Mine.aia"); + let text = "/version 3 /action-1 { /name [ 1 41 ] }"; + std::fs::write(&path, text).unwrap(); + let mut s = Session::new(); + let r = s.execute("actionSet.read", &json!({ "path": path.to_string_lossy() })).unwrap(); + assert_eq!((r["set"].as_str(), r["actions"][0]["name"].as_str()), (Some("Mine"), Some("A"))); + let b64 = vectorcraft_format::base64_encode(text.as_bytes()); + assert_eq!(s.execute("actionSet.read", &json!({ "dataBase64": b64, "name": "B.aia" })).unwrap()["set"], "B"); + let _ = std::fs::remove_dir_all(dir); +} + +/// Tokens out of place are listed with their lines, and a cap marks the set truncated. +#[test] +fn stray_tokens_are_listed_and_caps_truncate() { + let set = read_aia(b"/version 3\n7 /name [ 1 zz ]\n/name [ 3 4142 ]\n/odd { /x 1 }\n}\n/action-1 { /name [ 1 41 ]").unwrap(); + let unread: Vec<&str> = set.unread.iter().map(|(_, t)| t.as_str()).collect(); + let bad = "[ … ] (not the hexadecimal bytes its count says)"; + assert_eq!(unread, ["7", bad, bad, "/odd {", "}", "blocks left open at the end: 1"]); + let short = |text: &str| read_aia(text.as_bytes()).unwrap().truncated; + assert!(short("/version 3 /actionCount 1 /odd { /action-1 { } }"), "the action in a block that isn't read"); + assert!(short("/version 3 /name [ 3 4142 ]"), "a set's name with the wrong byte count"); + assert!(short("/version 3 /action-1 { /name [ 1 zz ] }"), "an action's name that isn't hexadecimal"); + assert!(short("/version 3 /action-1 { /event-1 { /localizedName [ 3 4142 ] } }"), "a label with the wrong byte count"); + let cuts = ["/name [ 4 4142", "/name (ab", "/value < 2 0a", "/name", "/action-2 {"]; + let whole: Vec<&str> = cuts.into_iter().filter(|cut| !short(&format!("/version 3 /action-1 {{ }} {cut}"))).collect(); + assert!(whole.is_empty(), "{whole:?}"); + assert_eq!(set.name, None); + assert_eq!(set.actions[0].name, "A"); + let many = format!("/version 3 {}", "/action-1 { } ".repeat(1001)); + assert!(read_aia(many.as_bytes()).unwrap().truncated); + let deep = format!("/version 3 /action-1 {{ /event-1 {{ {} }} }}", "/parameter-1 { ".repeat(40)); + assert!(read_aia(deep.as_bytes()).unwrap().truncated); + let long = format!("/version 3 /name {}", hex(&"n".repeat(2000))); + assert!(read_aia(long.as_bytes()).unwrap().truncated); +} + +/// A count that differs from the actions, steps or recorded values that follow, or that isn't a +/// number that fits an `i64`, marks the set truncated. A count is read only in its own block. +#[test] +fn counts_that_differ_truncate() { + let short = |text: &str| read_aia(text.as_bytes()).unwrap().truncated; + assert!(short("/version 3 /actionCount 2 /action-1 { }"), "one action of two"); + assert!(short("/version 3 /action-1 { /eventCount 2 /event-1 { } }"), "one step of two"); + assert!(!short("/version 3 /actionCount 1 /action-1 { /eventCount 1 /event-1 { } }")); + assert!(short("/version 3 /actionCount 1 /action-1 { } /action-2 { }"), "two actions of one"); + assert!(short("/version 3 /action-1 { /eventCount 1 /event-1 { } /event-2 { } }"), "two steps of one"); + assert!(short("/version 3 /action-1 { /event-1 { /parameterCount 1 } }"), "no recorded value of one"); + assert!(short("/version 3 /action-1 { /event-1 { /parameterCount 0 /parameter-1 { } } }"), "a recorded value of none"); + assert!(!short("/version 3 /action-1 { /event-1 { /parameterCount 1 /parameter-1 { } } }")); + assert!(short("/version 3 /actionCount -1"), "a negative count"); + assert!(short("/version 3 /action-1 { /eventCount -1 }"), "a negative step count"); + assert!(short("/version 3 /actionCount 99999999999999999999"), "a count too large for a number"); + for count in ["nope", "1.5", "(1)", "[ 1 31 ]", "< 1 31 >", "{ }"] { + assert!(short(&format!("/version 3 /actionCount {count}")), "a count that isn't a number: {count}"); + assert!(short(&format!("/version 3 /action-1 {{ /eventCount {count} }}")), "a step count: {count}"); + assert!(short(&format!("/version 3 /action-1 {{ /event-1 {{ /parameterCount {count} }} }}")), "{count}"); + } + assert!(short("/version 3 /actionCount /name [ 1 41 ]"), "a count followed by another field"); + assert!(short("/version 3 /action-1 { /eventCount }"), "a count at the end of its block"); + assert!(!short("/version 3 /action-1 { /event-1 { /parameter-1 { /actionCount nope } } }"), "a field inside a recorded value"); + assert!(!short("/version 3 /action-1 { /actionCount (x) }"), "a set's count in an action is not read"); +} + +#[test] +fn the_command_takes_only_the_keys_it_describes() { + // The control channel, MCP and the CLI check params against the description (#1442). + check_params("actionSet.read", &json!({"path": "/x/a.aia", "data": "", "dataBase64": "", "name": "a.aia"})).unwrap(); + assert!(check_params("actionSet.read", &json!({"pth": "/x/a.aia"})).is_err()); +} diff --git a/crates/engine/src/tests/assets_3d.rs b/crates/engine/src/tests/assets_3d.rs new file mode 100644 index 000000000..cdef5b1df --- /dev/null +++ b/crates/engine/src/tests/assets_3d.rs @@ -0,0 +1,220 @@ +//! 3D assets: Export 3D Object collects objects with a 3D effect as 3D assets, their Format picks +//! the mesh file (USDA by default, USDZ, glTF or OBJ), and `assets.export3D` writes them, apart from +//! the picture assets `assets.export` writes. + +use serde_json::{Value, json}; +use vectorcraft_color::Paint; + +use crate::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + // Unstroked art: each object is one solid. + s.paint.stroke = Paint::None; + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}")) +} + +/// A rectangle with Extrude & Bevel, named `name`. +fn extruded(s: &mut Session, x: f64, name: &str) -> u64 { + let id = run(s, "shape.rectangle", json!({"x": x, "y": 10.0, "width": 40.0, "height": 30.0}))["id"].as_u64().unwrap(); + run(s, "select.set", json!({"ids": [id]})); + run(s, "effect.apply", json!({"effect": "threeD.extrude", "params": {"depth": 20.0}})); + run(s, "layer.setProps", json!({"id": id, "name": name})); + id +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// `(name, bytes)` of each file an export returned. +fn files(r: &Value) -> Vec<(String, Vec)> { + r["files"] + .as_array() + .unwrap() + .iter() + .map(|f| (f["name"].as_str().unwrap().to_string(), vectorcraft_format::base64_decode(f["dataBase64"].as_str().unwrap()).unwrap())) + .collect() +} + +#[test] +fn export_3d_object_collects_each_3d_object_as_a_usda_asset() { + let mut s = session(); + let a = extruded(&mut s, 10.0, "Box"); + let b = extruded(&mut s, 100.0, "Crate"); + run(&mut s, "select.set", json!({"ids": [a, b]})); + let before = undo_len(&s); + let r = run(&mut s, "assets.add3D", json!({})); + assert_eq!(r["added"], 2); + assert_eq!(undo_len(&s), before + 1, "one undo step for every object"); + let list = run(&mut s, "assets.list", json!({}))["assets"].as_array().unwrap().clone(); + assert_eq!( + list.iter().map(|a| (a["name"].clone(), a["format3D"].clone())).collect::>(), + [(json!("Box"), json!("usda")), (json!("Crate"), json!("usda"))] + ); + // Collected again: the same assets, and no undo step. + let again = run(&mut s, "assets.add3D", json!({"ids": [a]})); + assert_eq!((again["added"].clone(), again["assets"][0].clone()), (json!(0), r["assets"][0].clone())); + assert_eq!(undo_len(&s), before + 1); + // Collect for Export of the same object makes a picture asset beside it. + assert_eq!(run(&mut s, "assets.add", json!({"ids": [a]}))["added"], 1); + // Undo takes the 3D assets back out in one step. + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert!(s.doc().unwrap().doc.assets.is_empty()); +} + +#[test] +fn export_3d_object_refuses_objects_without_a_3d_effect_and_bad_formats() { + let mut s = session(); + let plain = run(&mut s, "shape.rectangle", json!({"x": 10.0, "y": 10.0, "width": 40.0, "height": 30.0}))["id"].as_u64().unwrap(); + let box3d = extruded(&mut s, 100.0, "Box"); + let before = undo_len(&s); + assert!(s.execute("assets.add3D", &json!({"ids": [box3d, plain]})).is_err(), "one object without a 3D effect refuses them all"); + assert!(s.execute("assets.add3D", &json!({"ids": [box3d], "format": "fbx"})).is_err()); + assert!(s.execute("assets.add3D", &json!({"ids": [box3d], "format": 3})).is_err()); + assert!(s.execute("assets.add3D", &json!({"ids": [999_999]})).is_err()); + run(&mut s, "select.set", json!({"ids": []})); + assert!(s.execute("assets.add3D", &json!({})).is_err(), "nothing selected"); + assert!(s.doc().unwrap().doc.assets.is_empty()); + assert_eq!(undo_len(&s), before); + run(&mut s, "select.set", json!({"ids": [box3d]})); + let r = run(&mut s, "assets.add3D", json!({"format": "gltf"})); + assert_eq!(run(&mut s, "assets.list", json!({}))["assets"][0]["format3D"], "gltf"); + assert_eq!(r["added"], 1); +} + +#[test] +fn the_format_is_set_per_asset_and_only_a_change_is_an_undo_step() { + let mut s = session(); + let a = extruded(&mut s, 10.0, "Box"); + let asset = run(&mut s, "assets.add3D", json!({"ids": [a]}))["assets"][0].as_u64().unwrap(); + let picture = run(&mut s, "assets.add", json!({"ids": [a]}))["assets"][0].as_u64().unwrap(); + let before = undo_len(&s); + assert_eq!(run(&mut s, "assets.set3DFormat", json!({"assets": [asset], "format": "usda"}))["changed"], 0); + assert_eq!(undo_len(&s), before, "no change, no undo step"); + assert!(s.execute("assets.set3DFormat", &json!({"assets": [asset], "format": "stl"})).is_err()); + assert!(s.execute("assets.set3DFormat", &json!({"assets": [asset]})).is_err(), "the format is required"); + assert!(s.execute("assets.set3DFormat", &json!({"assets": [picture], "format": "obj"})).is_err(), "a picture asset has no 3D format"); + assert!(s.execute("assets.set3DFormat", &json!({"assets": [12345], "format": "obj"})).is_err()); + assert_eq!(run(&mut s, "assets.set3DFormat", json!({"assets": [asset], "format": "obj"}))["changed"], 1); + assert_eq!(undo_len(&s), before + 1); + assert_eq!(s.doc().unwrap().doc.asset(asset).unwrap().format_3d.as_deref(), Some("obj")); +} + +#[test] +fn export_3d_writes_each_format() { + let mut s = session(); + let a = extruded(&mut s, 10.0, "Box"); + let asset = run(&mut s, "assets.add3D", json!({"ids": [a]}))["assets"][0].as_u64().unwrap(); + let cases = [("usda", vec!["Box.usda"]), ("usdz", vec!["Box.usdz"]), ("gltf", vec!["Box.gltf"]), ("obj", vec!["Box.obj", "Box.mtl"])]; + for (format, names) in cases { + run(&mut s, "assets.set3DFormat", json!({"assets": [asset], "format": format})); + let out = files(&run(&mut s, "assets.export3D", json!({}))); + assert_eq!(out.iter().map(|(n, _)| n.as_str()).collect::>(), names, "{format}"); + assert!(out.iter().all(|(_, b)| !b.is_empty()), "{format}"); + } + // The OBJ holds the box: a rectangle's front and back and four walls, two triangles each. + let out = files(&run(&mut s, "assets.export3D", json!({}))); + let obj = String::from_utf8(out[0].1.clone()).unwrap(); + assert_eq!(obj.lines().filter(|l| l.starts_with("f ")).count(), 12); +} + +#[test] +fn export_3d_numbers_files_that_would_share_a_name_and_cleans_path_characters() { + let mut s = session(); + let a = extruded(&mut s, 10.0, "Box"); + let b = extruded(&mut s, 100.0, "box"); + let c = extruded(&mut s, 200.0, "../a:b"); + run(&mut s, "assets.add3D", json!({"ids": [a, b, c]})); + let out = files(&run(&mut s, "assets.export3D", json!({}))); + assert_eq!(out.iter().map(|(n, _)| n.as_str()).collect::>(), ["Box.usda", "box-2.usda", "..-a-b.usda"]); +} + +#[test] +fn picture_and_3d_assets_export_apart() { + let mut s = session(); + let a = extruded(&mut s, 10.0, "Box"); + let three_d = run(&mut s, "assets.add3D", json!({"ids": [a]}))["assets"][0].as_u64().unwrap(); + // assets.export leaves 3D assets to assets.export3D. + let e = s.execute("assets.export", &json!({"formats": [{"format": "png", "scale": "1x"}]})).unwrap_err().to_string(); + assert!(e.contains("no assets yet"), "{e}"); + let e = s.execute("assets.export", &json!({"assets": [three_d]})).unwrap_err().to_string(); + assert!(e.contains("assets.export3D"), "{e}"); + let e = s.execute("document.exportForScreens", &json!({"assets": [three_d]})).unwrap_err().to_string(); + assert!(e.contains("assets.export3D"), "Export for Screens refuses it too: {e}"); + let picture = run(&mut s, "assets.add", json!({"ids": [a]}))["assets"][0].as_u64().unwrap(); + let pngs = files(&run(&mut s, "assets.export", json!({"formats": [{"format": "png", "scale": "1x"}]}))); + assert_eq!(pngs.len(), 1); + assert!(s.execute("assets.export3D", &json!({"assets": [picture]})).is_err()); +} + +#[test] +fn export_3d_needs_3d_assets() { + let mut s = session(); + assert!(s.execute("assets.export3D", &json!({})).is_err()); + assert!(s.execute("assets.export3D", &json!({"assets": [77]})).is_err()); + assert!(s.execute("assets.export3D", &json!({"assets": "all"})).is_err()); +} + +#[test] +fn bad_3d_params_are_refused() { + let mut s = session(); + let a = extruded(&mut s, 10.0, "Box"); + assert!(s.execute("assets.add3D", &json!({"ids": [a], "format": null})).is_err(), "a given format must be a format"); + run(&mut s, "assets.add3D", json!({"ids": [a]})); + assert!(s.execute("assets.export3D", &json!({"folder": 123})).is_err()); + assert!(s.execute("assets.export3D", &json!({"zip": "true"})).is_err()); +} + +#[test] +fn an_asset_whose_object_lost_its_effect_is_refused_at_export() { + let mut s = session(); + let a = extruded(&mut s, 10.0, "Box"); + run(&mut s, "assets.add3D", json!({"ids": [a]})); + run(&mut s, "select.set", json!({"ids": [a]})); + run(&mut s, "effect.remove", json!({"index": 0})); + let e = s.execute("assets.export3D", &json!({})).unwrap_err().to_string(); + assert!(e.contains("Box"), "{e}"); +} + +#[test] +fn export_3d_writes_into_a_folder_or_one_zip() { + let dir = std::env::temp_dir().join(format!("vc-assets-3d-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let folder = dir.to_string_lossy().replace('\\', "/"); + let mut s = session(); + let a = extruded(&mut s, 10.0, "Box"); + let asset = run(&mut s, "assets.add3D", json!({"ids": [a], "format": "obj"}))["assets"][0].as_u64().unwrap(); + let r = run(&mut s, "assets.export3D", json!({"folder": folder})); + assert_eq!(r["files"], json!([format!("{folder}/Box.obj"), format!("{folder}/Box.mtl")])); + assert!(dir.join("Box.obj").is_file() && dir.join("Box.mtl").is_file()); + let zip = run(&mut s, "assets.export3D", json!({"assets": [asset], "zip": true})); + assert_eq!(zip["files"], json!(["Box.obj", "Box.mtl"])); + assert!(zip["name"].as_str().unwrap().ends_with("-3D.zip"), "{}", zip["name"]); + // A picture ZIP and a 3D ZIP in one folder are two files. + run(&mut s, "assets.add", json!({"ids": [a]})); + let pictures = run(&mut s, "assets.export", json!({"formats": [{"format": "png", "scale": "1x"}], "folder": folder, "zip": true})); + let meshes = run(&mut s, "assets.export3D", json!({"folder": folder, "zip": true})); + let (p, m) = (pictures["path"].as_str().unwrap(), meshes["path"].as_str().unwrap()); + assert_ne!(p, m); + assert!(std::path::Path::new(p).is_file() && std::path::Path::new(m).is_file()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn three_d_assets_round_trip_through_the_native_format() { + let mut s = session(); + let a = extruded(&mut s, 10.0, "Box"); + run(&mut s, "assets.add3D", json!({"ids": [a], "format": "usdz"})); + let saved = s.doc().unwrap().doc.assets.clone(); + let b64 = run(&mut s, "document.serialize", json!({"format": "vectorcraft"}))["dataBase64"].as_str().unwrap().to_string(); + run(&mut s, "document.open", json!({"name": "assets3d.vectorcraft", "dataBase64": b64})); + assert_eq!(s.doc().unwrap().doc.assets, saved); + assert_eq!(saved[0].format_3d.as_deref(), Some("usdz")); +} diff --git a/crates/engine/src/tests/clipboard.rs b/crates/engine/src/tests/clipboard.rs index 79b336df0..24d6d387d 100644 --- a/crates/engine/src/tests/clipboard.rs +++ b/crates/engine/src/tests/clipboard.rs @@ -278,6 +278,54 @@ fn gradient_swatches_come_along_with_their_stop_links() { assert_eq!(sw.gradient, g.gradient, "the added gradient swatch links its stop to the added Sky too"); } +/// A gradient swatch made from another one (by name, from a fill linked to it, or from a gradient +/// that names it, placed or not) holds the gradient alone, so copying art that uses it brings only +/// that swatch. A placed gradient keeps its aspect, and the swatch still fits each object it is +/// applied to. +#[test] +fn a_gradient_swatch_made_from_another_holds_no_link_to_it() { + let mut s = session(); + let glow = json!({"kind": "radial", "stops": [{"offset": 0, "color": "#3080ff"}, {"offset": 1, "color": "#ffffff"}]}); + s.execute("swatch.new", &json!({"name": "Glow", "gradient": glow})).unwrap(); + let r = rect(&mut s, 0.0, 0.0, 50.0, 50.0); + s.execute("paint.setFill", &json!({"ids": [r.0], "swatch": "Glow"})).unwrap(); + let Paint::Gradient(applied) = fill(&s, r) else { panic!("a gradient fill") }; + assert_eq!(applied.swatch.as_deref(), Some("Glow")); + let mut by_gradient = vectorcraft_tools::params::gradient_params(&applied); + by_gradient["swatch"] = json!("Glow"); + let mut placed = by_gradient.clone(); + placed["start"] = json!([25, 25]); + placed["end"] = json!([50, 25]); + placed["aspect"] = json!(50); + let made = [ + ("By Name", json!({"name": "By Name", "swatch": "Glow"})), + ("From Fill", json!({"name": "From Fill"})), + ("From Gradient", json!({"name": "From Gradient", "gradient": by_gradient})), + ("Placed", json!({"name": "Placed", "gradient": placed})), + ]; + let mut links = vec![]; + for (name, p) in &made { + s.execute("swatch.new", p).unwrap(); + let Paint::Gradient(sw) = doc(&s).swatch(name).unwrap().paint.clone() else { panic!("{name}: a gradient swatch") }; + assert_eq!(sw.gradient, applied.gradient, "{name}: Glow's gradient"); + links.push(sw.swatch.clone()); + } + assert_eq!(links, [None, None, None, None], "no swatch links to Glow"); + let Paint::Gradient(sw) = doc(&s).swatch("Placed").unwrap().paint.clone() else { panic!("a gradient swatch") }; + assert!(sw.geom.is_some_and(|g| (g.aspect - 0.5).abs() < 1e-9), "the placed gradient keeps its aspect: {:?}", sw.geom); + for (name, _) in &made { + let t = rect(&mut s, 100.0, 0.0, 100.0, 60.0); + s.execute("paint.setFill", &json!({"ids": [t.0], "swatch": name})).unwrap(); + if *name == "Placed" { + let Paint::Gradient(g) = fill(&s, t) else { panic!("a gradient fill") }; + let geom = g.geom.unwrap(); + assert!(geom.start.distance(Point::new(150.0, 30.0)) < 1e-9 && (geom.aspect - 0.5).abs() < 1e-9, "{geom:?}"); + } + copy(&mut s, &[t]); + assert_eq!(s.clipboard.swatches.iter().map(|w| w.name.as_str()).collect::>(), [*name], "Glow stays behind"); + } +} + #[test] fn lab_spot_colours_show_as_the_target_documents_spot_option_says() { let mut s = session(); diff --git a/crates/engine/src/tests/dynamic_symbols.rs b/crates/engine/src/tests/dynamic_symbols.rs index 1c46d5302..9ec00cd24 100644 --- a/crates/engine/src/tests/dynamic_symbols.rs +++ b/crates/engine/src/tests/dynamic_symbols.rs @@ -1,10 +1,14 @@ //! Dynamic symbols (#4938): an instance changes the appearance of objects in its symbol's art and -//! stays linked; editing the symbol keeps those changes, Reset and Break Link handle them. +//! stays linked; editing the symbol keeps those changes, Reset and Break Link handle them. Direct +//! Selection picks one path, compound path or type object of an instance's art, and a solid color +//! Fill or Stroke changes it. use serde_json::{Value, json}; use vectorcraft_doc::{Node, NodeKind}; use vectorcraft_geom::Rect; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; +use crate::tooling::{UiRequest, ViewInfo}; use crate::*; fn session() -> Session { @@ -46,6 +50,9 @@ fn pixel(s: &Session, x: u32, y: u32) -> [u8; 4] { const RED: [u8; 4] = [255, 0, 0, 255]; const BLUE: [u8; 4] = [0, 0, 255, 255]; +/// The refusal of anything but a solid fill or stroke on a picked object says so. +const REFUSED: &str = "solid color fill and stroke"; + #[test] fn a_dynamic_instance_paints_one_object_its_own_way_and_stays_linked() { let mut s = session(); @@ -58,6 +65,10 @@ fn a_dynamic_instance_paints_one_object_its_own_way_and_stays_linked() { assert_eq!(pixel(&s, 80, 30), RED, "the changed object"); assert_eq!(pixel(&s, 30, 30), BLUE, "its sibling keeps the symbol's look"); assert_eq!(overrides(&s, other), 0, "other instances keep the symbol's look"); + // The same value again records nothing. + let undo = undo_steps(&s); + assert_eq!(s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [1], "fill": "#ff0000"})).unwrap()["overrides"], 1); + assert_eq!(undo_steps(&s), undo, "no undo step"); // A later change to the same object merges with the first; null takes one back. s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [1], "opacity": 50})).unwrap(); assert_eq!(node(&s, inst).extras().symbol_overrides[0].opacity, Some(0.5)); @@ -176,6 +187,666 @@ fn break_link_bakes_the_changes_and_edit_symbol_shows_the_symbols_own_art() { assert_eq!(overrides(&s, other), 1, "the instance keeps its changes"); } +/// Press the Direct Selection tool at (`x`, `y`), drag to `to` and release, with `m` held: what the +/// drag asked of the UI. +fn direct(s: &mut Session, (x, y): (f64, f64), to: (f64, f64), m: Mods) -> Vec { + let v = ViewInfo { smart_guides: false, ..Default::default() }; + s.select_tool("directSelection", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, x, y).with_mods(m), v).unwrap(); + let mut out = vec![]; + if to != (x, y) { + out = s.pointer(&PointerEvent::new(PointerKind::Drag, to.0, to.1).with_mods(m), v).unwrap(); + } + s.pointer(&PointerEvent::new(PointerKind::Up, to.0, to.1).with_mods(m), v).unwrap(); + out +} + +fn undo_steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// The fills of the symbol's own art. +fn symbol_fills(s: &Session) -> Vec { + let art = s.doc().unwrap().doc.symbols[0].art.clone(); + art.children().unwrap().iter().map(|c| c.appearance.fill_paint().color().unwrap().to_hex()).collect() +} + +#[test] +fn direct_selection_paints_one_object_of_one_dynamic_instance() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let other = id(&s.execute("symbol.place", &json!({"name": "Pair", "x": 55, "y": 80})).unwrap()); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default()); + let sel = s.doc().unwrap().selection.clone(); + assert_eq!(sel.objects, [inst]); + assert_eq!(sel.symbol_part, Some(vectorcraft_doc::SymbolPart { instance: inst, path: vec![1] })); + let undo = undo_steps(&s); + // The Swatches panel, the Color panel and the Control bar set the fill with paint.setFill. + assert_eq!(s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap()["objects"], 1); + assert_eq!(undo_steps(&s), undo + 1, "one undo step"); + assert_eq!(pixel(&s, 80, 30), RED, "the picked object"); + assert_eq!(pixel(&s, 30, 30), BLUE, "its sibling keeps the symbol's look"); + assert_eq!(overrides(&s, other), 0, "the other instance keeps the symbol's look"); + assert_eq!(symbol_fills(&s), ["#0000ff", "#0000ff"], "the symbol's art is unchanged"); + assert_eq!(s.paint.fill.color().map(|c| c.to_hex()), Some("#ff0000".into()), "new art takes the fill"); + // The same paint again records nothing. + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + assert_eq!(undo_steps(&s), undo + 1); + // Stroke goes to the same object. + s.execute("paint.setStroke", &json!({"color": "#00ff00"})).unwrap(); + assert_eq!(s.paint.stroke.color().map(|c| c.to_hex()), Some("#00ff00".into()), "new art takes the stroke"); + assert!(s.paint.stroke_width > 0.0, "a painted stroke has a weight"); + let o = node(&s, inst).extras().symbol_overrides[0].clone(); + assert_eq!(o.path, vec![1]); + assert!(o.fill.is_some() && o.stroke.is_some()); + // The Eyedropper's click on another object gives the selected object its paint. + let green = square(&mut s, 150.0); + s.execute("paint.setFill", &json!({"ids": [green.0], "color": "#00ff00"})).unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + assert_eq!(s.execute("appearance.copyFrom", &json!({"source": green.0})).unwrap()["ids"], json!([inst.0])); + assert_eq!(pixel(&s, 80, 30), [0, 255, 0, 255], "the eyedropper's paint"); + assert_eq!(symbol_fills(&s), ["#0000ff", "#0000ff"]); + for _ in 0..7 { + s.execute("edit.undo", &json!({})).unwrap(); + } + assert_eq!(overrides(&s, inst), 0, "undo takes each change back"); + assert_eq!(pixel(&s, 80, 30), BLUE); +} + +#[test] +fn a_drag_of_a_picked_object_moves_nothing_and_says_why() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default()); + let (before, undo) = (node(&s, inst).kind.clone(), undo_steps(&s)); + let ui = direct(&mut s, (80.0, 30.0), (120.0, 60.0), Mods::default()); + assert!(ui.iter().any(|r| matches!(r, UiRequest::Status(m) if m.contains("symbol instance"))), "{ui:?}"); + assert_eq!(node(&s, inst).kind, before, "the instance stays put"); + assert_eq!(undo_steps(&s), undo, "no undo step"); + assert!(s.execute("object.nudge", &json!({"dx": 1})).is_err(), "the arrow keys too"); + assert!(s.execute("object.rotate", &json!({"angle": 10})).is_err()); + // The Selection tool picks the instance whole again, and it moves. + s.select_tool("selection", ViewInfo::default()).unwrap(); + assert!(s.doc().unwrap().selection.symbol_part.is_none()); + s.execute("object.nudge", &json!({"dx": 1})).unwrap(); + assert_ne!(node(&s, inst).kind, before); +} + +/// The Stroke panel and the Control bar show the stroke of the object picked in a dynamic instance. +#[test] +fn the_stroke_panel_shows_a_picked_objects_stroke() { + let mut s = session(); + let (a, b) = (square(&mut s, 10.0), square(&mut s, 60.0)); + s.execute("paint.setStroke", &json!({"ids": [a.0, b.0], "color": "#00ff00"})).unwrap(); + s.execute("stroke.set", &json!({"ids": [a.0], "weight": 2})).unwrap(); + s.execute("stroke.set", &json!({"ids": [b.0], "weight": 7})).unwrap(); + let inst = id(&s.execute("symbol.new", &json!({"name": "Pair", "ids": [a.0, b.0], "type": "dynamic"})).unwrap()); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + assert_eq!(s.shown_stroke().map(|st| st.width), Some(7.0), "the picked object's weight"); + assert!(!s.doc().unwrap().stroke_mixed().weight, "the instance's art has two weights, the object one"); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [0]})).unwrap(); + assert_eq!(s.shown_stroke().map(|st| st.width), Some(2.0)); +} + +#[test] +fn delete_and_cut_of_a_picked_object_remove_nothing() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + let undo = undo_steps(&s); + for cmd in ["edit.cut", "edit.clear"] { + let e = s.execute(cmd, &json!({})).unwrap_err().to_string(); + assert!(e.contains("symbol instance"), "{cmd}: {e}"); + assert!(s.doc().unwrap().doc.node(inst).is_some(), "{cmd} keeps the instance"); + } + assert_eq!(undo_steps(&s), undo, "no undo step"); + // Named by id, the instance is removed. + s.execute("edit.clear", &json!({"ids": [inst.0]})).unwrap(); + assert!(s.doc().unwrap().doc.node(inst).is_none()); +} + +#[test] +fn commands_on_the_selection_leave_a_picked_objects_instance_alone() { + // A dynamic instance with an opacity mask, a plain square and the Desaturate plug-in, with an + // object of the instance picked. + let setup = || { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let other = square(&mut s, 150.0); + let desaturate = include_bytes!("../../../plugins/tests/fixtures/desaturate.wasm"); + let plugin = s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(desaturate)})).unwrap(); + let mask = square(&mut s, 20.0); + s.execute("transparency.makeOpacityMask", &json!({"ids": [inst.0, mask.0]})).unwrap(); + assert!(node(&s, inst).extras().mask.is_some()); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + (s, inst, other, plugin["id"].clone()) + }; + let (mut s, inst, other, plugin) = setup(); + let doc = s.doc().unwrap().doc.clone(); + // Each command runs on its own copy of the setup, compared with that copy as it was, so every + // command that fails is reported. + let mut failures = Vec::new(); + for (cmd, p) in [ + ("object.puppetWarp", json!({"pins": [[80, 30]], "moved": [[90, 40]], "rest": true})), + ("recolor.apply", json!({"map": {"#0000ff": "#00ff00"}})), + ("plugin.run", json!({"id": plugin, "params": {"amount": 100}})), + ("object.group", json!({})), + ("object.ungroup", json!({})), + ("object.hide", json!({})), + ("object.lock", json!({})), + ("object.arrange.bringToFront", json!({})), + ("object.expand", json!({})), + ("object.rotate", json!({"angle": 45})), + ("symbol.instance.reset", json!({})), + ("symbol.instance.setAppearance", json!({"path": [1], "opacity": 50})), + ("symbol.adjust", json!({"tool": "shift", "points": [[80, 30], [95, 30]], "radius": 200})), + ("symbol.edit", json!({})), + ("select.globalEdit", json!({})), + ("object.blend.release", json!({})), + ("object.repeat.release", json!({})), + ("object.intertwine.release", json!({})), + ("object.objectsOnPath.expand", json!({})), + ("transparency.set", json!({"opacity": 50})), + ("transparency.set", json!({"blend": "multiply"})), + ("transparency.releaseOpacityMask", json!({})), + ("transparency.setOpacityMask", json!({"invert": true, "clip": false})), + ("transparency.disableOpacityMask", json!({})), + ("transparency.unlinkOpacityMask", json!({})), + ("transparency.editOpacityMask", json!({})), + ("transparency.viewOpacityMask", json!({"on": true})), + ("paint.editGradient", json!({"angle": 30})), + ("paint.setGradientGeom", json!({"start": [62, 30], "end": [98, 30]})), + ("paint.swap", json!({})), + ("stroke.set", json!({"weight": 3})), + ("path.moveAnchors", json!({"dx": 5, "dy": 5})), + ( + "file.place", + json!({"name": "a.svg", "dataBase64": vectorcraft_format::base64_encode(b""), "replace": true}), + ), + ("object.intertwine.arrange", json!({"at": [80, 30]})), + ("transparency.editOpacityMask", json!({"id": null})), + ("transparency.viewOpacityMask", json!({"on": true, "id": null})), + ] { + let (mut t, ..) = setup(); + let (doc, undo) = (t.doc().unwrap().doc.clone(), undo_steps(&t)); + match t.execute(cmd, &p) { + Ok(r) => failures.push(format!("{cmd}: ran ({r})")), + Err(e) if !e.to_string().contains("symbol instance") => failures.push(format!("{cmd}: {e}")), + Err(_) => {} + } + if t.doc().unwrap().selection.symbol_part.is_none() { + failures.push(format!("{cmd}: the object is no longer picked")); + } + if t.doc().unwrap().doc != doc { + failures.push(format!("{cmd}: the document changed")); + } + if undo_steps(&t) != undo { + failures.push(format!("{cmd}: an undo step was added")); + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); + // Copy copies the instance whole. + s.execute("edit.copy", &json!({})).unwrap(); + assert!(matches!(&s.clipboard.nodes[..], [n] if matches!(n.kind, NodeKind::SymbolInstance { .. }))); + // The picked object's paint still changes. + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + assert!(node(&s, inst).extras().symbol_overrides[0].fill.is_some()); + // A command given ids acts on them. + s.execute("object.rotate", &json!({"ids": [other.0], "angle": 45})).unwrap(); + assert_ne!(node(&s, other).kind, doc.node(other).unwrap().kind, "the named object turns"); + s.execute("transparency.setOpacityMask", &json!({"id": inst.0, "invert": true})).unwrap(); + assert!(node(&s, inst).extras().mask.as_ref().is_some_and(|m| m.invert), "the named instance's mask changes"); +} + +#[test] +fn the_commands_without_a_check_for_a_picked_object_use_its_instance() { + let setup = || { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [1], "fill": "#ff0000"})).unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + (s, inst) + }; + // Export Selection: the whole instance, x 10..100, and not the object at 60..100. + let (mut s, _) = setup(); + let r = s.execute("document.exportSelection", &json!({"format": "svg"})).unwrap(); + assert_eq!(r["bounds"], json!([10.0, 10.0, 90.0, 40.0])); + // Fit to Selection: the whole instance too. + let (mut s, _) = setup(); + s.execute("artboard.fitToSelection", &json!({})).unwrap(); + let r = s.doc().unwrap().doc.artboards[0].rect; + assert_eq!((r.x0, r.x1), (10.0, 100.0)); + // Liquify: the instance is its target, skipped and left as it is. + let (mut s, inst) = setup(); + let doc = s.doc().unwrap().doc.clone(); + let r = s.execute("object.liquify", &json!({"tool": "pucker", "points": [[80, 30]], "diameter": 150, "intensity": 1})).unwrap(); + assert_eq!((&r["ids"], &r["skipped"]), (&json!([]), &json!([inst.0]))); + assert!(s.doc().unwrap().doc == doc); + // Isolate: the instance, with the selection cleared. + let (mut s, inst) = setup(); + s.execute("object.isolate", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().isolation, Some(inst)); + assert!(s.doc().unwrap().selection.is_empty()); + // Add to Library: a copy of the whole instance, 90 pt wide, and not the object's paint. + let (mut s, _) = setup(); + let lib = s.execute("library.add", &json!({"kind": "graphic"})).unwrap()["library"].clone(); + let got = s.execute("library.get", &json!({"library": lib})).unwrap(); + assert_eq!(got["graphics"][0]["width"], 90.0); + assert_ne!(s.execute("library.add", &json!({"kind": "fillColor"})).unwrap()["name"], "#ff0000"); + // Save Selection: the instance, which Recall selects whole. + let (mut s, inst) = setup(); + s.execute("select.save", &json!({"name": "Saved"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.saved_selections[0].objects, vec![inst]); + s.execute("select.recall", &json!({"name": "Saved"})).unwrap(); + let sel = &s.doc().unwrap().selection; + assert_eq!((&sel.objects, &sel.symbol_part), (&vec![inst], &None)); +} + +#[test] +fn a_blend_around_a_picked_objects_instance_is_left_alone() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let other = square(&mut s, 150.0); + let blend = id(&s.execute("object.blend.make", &json!({"ids": [inst.0, other.0], "steps": 1})).unwrap()); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + let (doc, undo) = (s.doc().unwrap().doc.clone(), undo_steps(&s)); + let e = s.execute("object.blend.release", &json!({})).unwrap_err().to_string(); + assert!(e.contains("symbol instance"), "{e}"); + assert!(s.doc().unwrap().doc == doc, "the blend stays"); + assert_eq!(undo_steps(&s), undo, "no undo step"); + // With the blend selected, it is released. + s.execute("select.set", &json!({"ids": [blend.0]})).unwrap(); + s.execute("object.blend.release", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(blend).is_none()); +} + +/// Dynamic symbol "Photo" of a gray 2 × 1 image and a blue square, made in place: its instance and +/// the image's path in the art. +fn image_symbol(s: &mut Session) -> (NodeId, Value) { + let mut png = Vec::new(); + let pixels = image::DynamicImage::ImageLuma8(image::GrayImage::from_raw(2, 1, vec![0, 255]).unwrap()); + pixels.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let im = s + .edit("Place", |d, _| { + d.images.insert("gray".into(), vectorcraft_doc::ImageBlob::png(png)); + let id = d.alloc_id(); + let im = vectorcraft_doc::ImageObject { + key: "gray".into(), + width: 40, + height: 40, + xf: vectorcraft_geom::Affine::translate((10.0, 10.0)), + link: None, + placement: Default::default(), + pixels: None, + }; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; + Ok(id) + }) + .unwrap(); + let sq = square(s, 60.0); + let inst = id(&s.execute("symbol.new", &json!({"name": "Photo", "ids": [im.0, sq.0], "type": "dynamic"})).unwrap()); + let art = s.doc().unwrap().doc.symbols[0].art.clone(); + let i = art.children().unwrap().iter().position(|c| matches!(c.kind, NodeKind::Image(_))).unwrap(); + (inst, json!([i])) +} + +#[test] +fn only_paths_compound_paths_and_type_of_the_art_are_picked() { + let mut s = session(); + // A compound path, type, a blend and a static symbol's instance beside a path, in a group. + let (a, b) = (square(&mut s, 10.0), square(&mut s, 20.0)); + let compound = id(&s.execute("object.compoundPath.make", &json!({"ids": [a.0, b.0]})).unwrap()); + let text = id(&s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hi"})).unwrap()); + let (c, d) = (square(&mut s, 60.0), square(&mut s, 150.0)); + let blend = id(&s.execute("object.blend.make", &json!({"ids": [c.0, d.0], "steps": 1})).unwrap()); + let inner = pair(&mut s, "static"); + let plain = square(&mut s, 100.0); + let group = id(&s.execute("object.group", &json!({"ids": [plain.0]})).unwrap()); + let ids = [compound.0, text.0, blend.0, inner.0, group.0]; + let inst = id(&s.execute("symbol.new", &json!({"name": "Mixed", "ids": ids, "type": "dynamic"})).unwrap()); + let art = s.doc().unwrap().doc.symbols.iter().find(|y| y.name == "Mixed").unwrap().art.clone(); + let at = |kind: &dyn Fn(&NodeKind) -> bool| art.children().unwrap().iter().position(|c| kind(&c.kind)).unwrap(); + let compound = at(&|k| matches!(k, NodeKind::Compound { .. })); + let text = at(&|k| matches!(k, NodeKind::Text(_))); + let blend = at(&|k| matches!(k, NodeKind::Blend { .. })); + let inner = at(&|k| matches!(k, NodeKind::SymbolInstance { .. })); + let group = at(&|k| matches!(k, NodeKind::Group { .. })); + for path in [json!([compound]), json!([text]), json!([group, 0])] { + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": path})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#00ff00"})).unwrap(); + } + assert_eq!(overrides(&s, inst), 3); + let undo = undo_steps(&s); + s.execute("select.none", &json!({})).unwrap(); + for path in [json!([]), json!([group]), json!([blend]), json!([inner])] { + let e = s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": path})).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{path}: {e}"); + } + assert!(s.doc().unwrap().selection.symbol_part.is_none(), "nothing is picked"); + assert_eq!((overrides(&s, inst), undo_steps(&s)), (3, undo), "nothing changes"); +} + +/// A Direct Selection press on an image of the art is refused with the status message, and a drag +/// from it does not move what was selected. +#[test] +fn a_press_on_an_image_of_the_art_is_refused_and_moves_nothing() { + let mut s = session(); + let (inst, path) = image_symbol(&mut s); + let other = square(&mut s, 150.0); + let e = s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": path})).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{e}"); + s.execute("select.set", &json!({"ids": [other.0]})).unwrap(); + let before = node(&s, other).kind.clone(); + let v = ViewInfo { smart_guides: false, ..Default::default() }; + s.select_tool("directSelection", v).unwrap(); + let e = s.pointer(&PointerEvent::new(PointerKind::Down, 30.0, 30.0), v).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{e}"); + s.pointer(&PointerEvent::new(PointerKind::Drag, 60.0, 60.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 60.0, 60.0), v).unwrap(); + assert_eq!(node(&s, other).kind, before, "what was selected stays put"); + assert_eq!(s.doc().unwrap().selection.objects, [other], "and stays selected"); + assert_eq!(overrides(&s, inst), 0); +} + +/// A gradient or pattern for a picked object is refused and changes nothing, new art's paint +/// included. An object that shows a gradient takes a solid color. +#[test] +fn gradients_and_patterns_on_a_picked_object_are_refused() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let g = json!({"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}); + s.execute("swatch.new", &json!({"name": "Ramp", "gradient": g})).unwrap(); + let tile = square(&mut s, 150.0); + s.execute("object.pattern.make", &json!({"ids": [tile.0], "name": "Dots", "edit": false})).unwrap(); + // An object to sample a gradient from. + let far = square(&mut s, 150.0); + s.execute("paint.setFill", &json!({"ids": [far.0], "gradient": g})).unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + let undo = undo_steps(&s); + let (fill, stroke) = (s.paint.fill.clone(), s.paint.stroke.clone()); + for (cmd, p) in [ + ("paint.setFill", json!({"gradient": g})), + ("paint.setStroke", json!({"gradient": g})), + ("paint.setFill", json!({"gradient": g, "span": "selection"})), + ("paint.setFill", json!({"swatch": "Ramp"})), + ("paint.setFill", json!({"swatch": "Dots"})), + ("appearance.copyFrom", json!({"source": far.0})), + ] { + let e = s.execute(cmd, &p).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{cmd} {p}: {e}"); + } + assert_eq!((overrides(&s, inst), undo_steps(&s)), (0, undo), "nothing changes"); + assert_eq!((&s.paint.fill, &s.paint.stroke), (&fill, &stroke), "new art keeps its paint"); + // An object whose art has a gradient takes a solid color; Invert, Complement and selecting one + // of its stops are refused. + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [1], "fill": {"gradient": g}})).unwrap(); + for cmd in ["paint.invert", "paint.complement"] { + let e = s.execute(cmd, &json!({"stroke": false})).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{cmd}: {e}"); + } + s.fill_active = true; + for index in [json!(1), Value::Null] { + let e = s.execute("gradient.selectStop", &json!({"index": index})).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "gradient.selectStop {index}: {e}"); + } + assert_eq!(s.selected_stop(), None); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + assert_eq!(pixel(&s, 80, 30), RED); + // The Eyedropper's options can leave the gradient out: the fill stays. + s.execute("appearance.copyFrom", &json!({"source": far.0, "pickUp": {"appearance": {"fill": {"color": false}}}})).unwrap(); + assert_eq!(pixel(&s, 80, 30), RED); + // Options that pick up neither color leave nothing to change, so the click is refused. + let (undo, fill) = (undo_steps(&s), s.paint.fill.clone()); + let neither = json!({"source": far.0, "pickUp": {"appearance": {"fill": {"color": false}, "stroke": {"color": false}}}}); + let e = s.execute("appearance.copyFrom", &neither).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{e}"); + assert_eq!((undo_steps(&s), &s.paint.fill), (undo, &fill), "nothing changes"); +} + +#[test] +fn select_part_refuses_hidden_and_locked_objects_of_the_art() { + let mut s = session(); + let (a, b, c) = (square(&mut s, 10.0), square(&mut s, 60.0), square(&mut s, 110.0)); + let group = id(&s.execute("object.group", &json!({"ids": [b.0, c.0]})).unwrap()); + let inst = id(&s.execute("symbol.new", &json!({"name": "Three", "ids": [a.0, group.0], "type": "dynamic"})).unwrap()); + // The art's first square is hidden and its group locked. + let mut art = (*s.doc().unwrap().doc.symbols[0].art).clone(); + let children = art.children_mut().unwrap(); + std::sync::Arc::make_mut(&mut children[0]).visible = false; + std::sync::Arc::make_mut(&mut children[1]).locked = true; + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).symbols[0].art = std::sync::Arc::new(art); + for path in [json!([0]), json!([1]), json!([1, 0])] { + let e = s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": path})).unwrap_err().to_string(); + assert!(e.contains("hidden or locked"), "{path}: {e}"); + } + assert!(s.doc().unwrap().selection.symbol_part.is_none(), "nothing is picked"); + // With the art's group unlocked, a locked instance's objects are still not picked. + let mut art = (*s.doc().unwrap().doc.symbols[0].art).clone(); + std::sync::Arc::make_mut(&mut art.children_mut().unwrap()[1]).locked = false; + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).symbols[0].art = std::sync::Arc::new(art); + s.execute("object.lock", &json!({"ids": [inst.0]})).unwrap(); + let e = s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1, 1]})).unwrap_err().to_string(); + assert!(e.contains("hidden or locked"), "locked instance: {e}"); + s.execute("object.unlockAll", &json!({})).unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1, 1]})).unwrap(); +} + +#[test] +fn choosing_the_selection_tool_again_picks_the_instance_whole() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.select_tool("selection", ViewInfo::default()).unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + assert!(s.doc().unwrap().selection.symbol_part.is_some()); + let before = node(&s, inst).kind.clone(); + let revision = s.doc().unwrap().revision; + s.select_tool("selection", ViewInfo::default()).unwrap(); + assert!(s.doc().unwrap().selection.symbol_part.is_none()); + assert_ne!(s.doc().unwrap().revision, revision, "panels cached by revision read the instance"); + s.execute("object.nudge", &json!({"dx": 1})).unwrap(); + assert_ne!(node(&s, inst).kind, before, "the instance moves"); +} + +#[test] +fn the_eyedropper_sample_taken_for_a_picked_object_is_kept() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let green = square(&mut s, 150.0); + s.execute("paint.setFill", &json!({"ids": [green.0], "color": "#00ff00"})).unwrap(); + let target = square(&mut s, 150.0); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": green.0})).unwrap(); + assert_eq!(s.paint.fill.color().map(|c| c.to_hex()), Some("#00ff00".into()), "new art takes the sample's fill"); + // A reverse Eyedropper click with nothing selected applies that sample. + s.execute("select.none", &json!({})).unwrap(); + assert_eq!(s.execute("appearance.copyFrom", &json!({"source": target.0, "reverse": true})).unwrap()["ids"], json!([target.0])); + assert_eq!(node(&s, target).appearance.fill_paint().color().unwrap().to_hex(), "#00ff00"); +} + +#[test] +fn invert_and_complement_recolor_a_picked_object_for_its_instance_only() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let other = id(&s.execute("symbol.place", &json!({"name": "Pair", "x": 55, "y": 80})).unwrap()); + let art = s.doc().unwrap().doc.symbols[0].art.clone(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + let undo = undo_steps(&s); + let r = s.execute("paint.invert", &json!({"stroke": false})).unwrap(); + assert_eq!(r["changed"], 1, "{r}"); + assert_eq!(undo_steps(&s), undo + 1, "one undo step"); + assert_eq!(symbol_part_fill(&s, inst), "#ffff00", "the picked object's own blue, inverted"); + assert_eq!(overrides(&s, inst), 1, "only the picked object changes"); + assert_eq!(overrides(&s, other), 0, "the other instance keeps the symbol's look"); + assert!(*s.doc().unwrap().doc.symbols[0].art == *art, "the symbol's art stays"); + assert_eq!(pixel(&s, 80, 30), [255, 255, 0, 255], "the changed object"); + assert_eq!(pixel(&s, 30, 30), BLUE, "its sibling keeps the symbol's look"); + s.execute("paint.complement", &json!({"stroke": false})).unwrap(); + assert_eq!(symbol_part_fill(&s, inst), "#0000ff", "the complement of yellow"); + assert_eq!(undo_steps(&s), undo + 2, "one undo step"); +} + +/// One change holds one paint, so Invert and Complement are refused for type whose characters +/// differ in color, and each character's color stays. A fill gives all of them one color. +#[test] +fn invert_and_complement_are_refused_for_picked_type_whose_characters_differ() { + let mut s = session(); + let t = id(&s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hi"})).unwrap()); + s.execute("paint.setFill", &json!({"ids": [t.0], "color": "#ff0000"})).unwrap(); + let inst = id(&s.execute("symbol.new", &json!({"name": "Word", "ids": [t.0], "type": "dynamic"})).unwrap()); + let mut art = (*s.doc().unwrap().doc.symbols[0].art).clone(); + let NodeKind::Text(tx) = &mut art.kind else { panic!("type: {art:?}") }; + let mut second = tx.runs[0].clone(); + second.style.fill = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0)); + tx.runs.push(second); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).symbols[0].art = std::sync::Arc::new(art); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": []})).unwrap(); + let undo = undo_steps(&s); + for cmd in ["paint.invert", "paint.complement"] { + let e = s.execute(cmd, &json!({"stroke": false})).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{cmd}: {e}"); + } + assert_eq!((overrides(&s, inst), undo_steps(&s)), (0, undo), "nothing changes"); + s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap(); + s.execute("paint.invert", &json!({"stroke": false})).unwrap(); + assert_eq!(symbol_part_fill(&s, inst), "#ff00ff", "one color inverts"); +} + +/// The color of the fill instance `inst`'s first change sets. +fn symbol_part_fill(s: &Session, inst: NodeId) -> String { + let o = node(s, inst).extras().symbol_overrides[0].clone(); + o.fill.as_ref().and_then(|p| p.color()).map(|c| c.to_hex()).unwrap_or_else(|| panic!("a solid fill: {o:?}")) +} + +#[test] +fn a_stroke_on_a_picked_type_object_without_a_weight_draws() { + let mut s = session(); + let t = id(&s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hi"})).unwrap()); + s.execute("paint.setStroke", &json!({"ids": [t.0], "none": true})).unwrap(); + let inst = id(&s.execute("symbol.new", &json!({"name": "Word", "ids": [t.0], "type": "dynamic"})).unwrap()); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": []})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap(); + let part = s.doc().unwrap().selected_symbol_part().unwrap(); + let NodeKind::Text(tx) = &part.kind else { panic!("type: {part:?}") }; + assert!(tx.runs.iter().all(|r| r.style.stroke_width == 1.0 && !r.style.stroke.is_none()), "{:?}", tx.runs); +} + +/// The colors the Fill and Stroke proxies show, as `paint.proxies` replies them. +fn proxy_colors(s: &mut Session) -> (Option, Option) { + let r = s.execute("paint.proxies", &json!({})).unwrap(); + assert_eq!((r["fillMixed"].clone(), r["strokeMixed"].clone()), (json!(false), json!(false)), "{r}"); + let (fill, stroke) = s.proxy_paints(); + assert_eq!((r["fill"].clone(), r["stroke"].clone()), (json!(fill), json!(stroke))); + (fill.color().map(|c| c.to_hex()), stroke.color().map(|c| c.to_hex())) +} + +#[test] +fn the_fill_and_stroke_proxies_show_the_picked_object() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + assert_eq!(proxy_colors(&mut s), (Some("#0000ff".into()), None), "the symbol's own paint"); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#00ff00"})).unwrap(); + assert_eq!(proxy_colors(&mut s), (Some("#ff0000".into()), Some("#00ff00".into())), "the object's own paint"); + // The other object of the same instance shows the symbol's paint. + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [0]})).unwrap(); + assert_eq!(proxy_colors(&mut s), (Some("#0000ff".into()), None)); +} + +#[test] +fn selecting_a_guide_too_lets_go_of_the_picked_object() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let g = s.execute("guide.add", &json!({"vertical": true, "pos": 150})).unwrap()["index"].as_u64().unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("guide.select", &json!({"indexes": [g], "toggle": true})).unwrap(); + let sel = s.doc().unwrap().selection.clone(); + assert_eq!((sel.objects, sel.guides.len(), sel.symbol_part), (vec![inst], 1, None)); + // Paint now goes to the instance, not to the object. + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + assert_eq!(overrides(&s, inst), 0); + // Pruning a selection with a guide in it lets go of the object as well. + let mut sel = s.doc().unwrap().selection.clone(); + sel.symbol_part = Some(vectorcraft_doc::SymbolPart { instance: inst, path: vec![1] }); + sel.prune(&s.doc().unwrap().doc); + assert_eq!(sel.symbol_part, None); +} + +/// A dynamic symbol with 9-slice guides at 25%: a 100 × 100 frame at (50, 50), a 10 × 10 square +/// in its top-left corner and one in its center; its instance resized to 300 × 100. +fn sliced_panel() -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let rect = + |s: &mut Session, x: f64, y: f64, w: f64| id(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": w})).unwrap()).0; + let ids = [rect(&mut s, 50.0, 50.0, 100.0), rect(&mut s, 50.0, 50.0, 10.0), rect(&mut s, 95.0, 95.0, 10.0)]; + let inst = id(&s.execute("symbol.new", &json!({"name": "Panel", "ids": ids, "type": "dynamic", "nineSlice": true})).unwrap()); + s.execute("object.setBounds", &json!({"x": 50, "y": 50, "width": 300, "height": 100, "reference": 0})).unwrap(); + (s, inst) +} + +#[test] +fn direct_selection_picks_and_outlines_the_object_where_a_9_slice_instance_draws_it() { + let (mut s, inst) = sliced_panel(); + let part_box = |s: &Session, path: &[u32]| { + let d = &s.doc().unwrap().doc; + let o = vectorcraft_doc::hit::symbol_part_outline(d, d.node(inst).unwrap(), path).unwrap(); + vectorcraft_geom::kurbo::Shape::bounding_box(&o) + }; + let close = |a: Rect, b: Rect| [(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() < 1e-6); + // The center cell (75..125 of the art) is drawn at 75..325, so the center square at 175..225; + // placed by the instance's transform alone it would be at 185..215. + assert!(close(part_box(&s, &[2]), Rect::new(175.0, 95.0, 225.0, 105.0)), "{:?}", part_box(&s, &[2])); + assert!(close(part_box(&s, &[1]), Rect::new(50.0, 50.0, 60.0, 60.0)), "the corner keeps its size: {:?}", part_box(&s, &[1])); + direct(&mut s, (177.0, 100.0), (177.0, 100.0), Mods::default()); + assert_eq!(s.doc().unwrap().selection.symbol_part, Some(vectorcraft_doc::SymbolPart { instance: inst, path: vec![2] })); + direct(&mut s, (165.0, 100.0), (165.0, 100.0), Mods::default()); + let picked = s.doc().unwrap().selection.symbol_part.clone().map(|p| p.path); + assert_ne!(picked, Some(vec![2]), "where the square is not drawn"); +} + +/// An object that is drawn once in each 9-slice cell it reaches, rather than cut at the guides +/// (here area type), is outlined where its copies are drawn. +#[test] +fn the_outline_of_an_object_drawn_in_each_9_slice_cell_matches_what_is_drawn() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let frame = id(&s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()).0; + let area = json!({"width": 80, "height": 10}); + let text = id(&s.execute("text.create", &json!({"x": 60, "y": 95, "area": area, "size": 6, "text": "Hi"})).unwrap()).0; + let ids = [frame, text]; + let inst = id(&s.execute("symbol.new", &json!({"name": "Bar", "ids": ids, "type": "dynamic", "nineSlice": true})).unwrap()); + s.execute("object.setBounds", &json!({"x": 50, "y": 50, "width": 300, "height": 100, "reference": 0})).unwrap(); + let d = &s.doc().unwrap().doc; + let o = vectorcraft_doc::hit::symbol_part_outline(d, d.node(inst).unwrap(), &[1]).unwrap(); + let b = vectorcraft_geom::kurbo::Shape::bounding_box(&o); + // The left copy is drawn at 60..75, the center one (placed whole it reaches 0..400) at 75..325 + // and the right one at 325..340. + let want = Rect::new(60.0, 95.0, 340.0, 105.0); + assert!([(b.x0, want.x0), (b.y0, want.y0), (b.x1, want.x1), (b.y1, want.y1)].iter().all(|(x, y)| (x - y).abs() < 1e-6), "{b:?}"); +} + +#[test] +fn direct_selection_picks_a_static_instance_whole_as_before() { + let mut s = session(); + let inst = pair(&mut s, "static"); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default()); + let sel = s.doc().unwrap().selection.clone(); + assert_eq!((sel.objects, sel.symbol_part), (vec![inst], None)); + assert!(s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).is_err(), "a static symbol"); + // A dynamic one: Alt picks the instance whole, and a path naming no object is refused. + s.execute("symbol.options", &json!({"name": "Pair", "type": "dynamic"})).unwrap(); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods { alt: true, ..Mods::default() }); + assert!(s.doc().unwrap().selection.symbol_part.is_none()); + assert!(s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [2]})).is_err()); + assert!(s.execute("symbol.instance.selectPart", &json!({"path": [1]})).is_err(), "no id"); + // Making the symbol static again lets go of the picked object. + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("symbol.options", &json!({"name": "Pair", "type": "static"})).unwrap(); + assert!(s.doc().unwrap().selection.symbol_part.is_none()); +} + #[test] fn dynamic_symbols_and_their_changes_round_trip_and_old_documents_save_as_before() { let mut s = session(); @@ -191,3 +862,201 @@ fn dynamic_symbols_and_their_changes_round_trip_and_old_documents_save_as_before assert_eq!(back.node(inst).unwrap().extras().symbol_overrides, doc.node(inst).unwrap().extras().symbol_overrides); assert_eq!(back.symbols, doc.symbols); } + +#[test] +fn a_swatch_edit_recolors_a_picked_objects_own_paints() { + let mut s = session(); + s.execute("swatch.new", &json!({"name": "Brand", "color": "#ff0000", "global": true})).unwrap(); + let inst = pair(&mut s, "dynamic"); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("paint.setFill", &json!({"swatch": "Brand"})).unwrap(); + assert_eq!(pixel(&s, 80, 30), RED); + s.execute("swatch.edit", &json!({"name": "Brand", "color": "#00ff00"})).unwrap(); + let fill = s.doc().unwrap().selected_symbol_part().unwrap().appearance.fill_paint(); + assert_eq!(fill.color().map(|c| c.to_hex()), Some("#00ff00".into()), "{fill:?}"); + assert_eq!(pixel(&s, 80, 30), [0, 255, 0, 255]); +} + +#[test] +fn a_swatch_or_color_only_a_picked_objects_paint_uses_counts_as_used() { + let mut s = session(); + s.execute("swatch.new", &json!({"name": "Brand", "color": "#ff0000", "global": true})).unwrap(); + let inst = pair(&mut s, "dynamic"); + let unused = |s: &mut Session| s.execute("swatch.unused", &json!({})).unwrap()["names"].clone(); + assert!(unused(&mut s).as_array().unwrap().contains(&json!("Brand"))); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("paint.setFill", &json!({"swatch": "Brand"})).unwrap(); + assert!(!unused(&mut s).as_array().unwrap().contains(&json!("Brand")), "only the object's own paint uses it"); + let colors = s.execute("recolor.colors", &json!({"ids": [inst.0]})).unwrap(); + assert!(colors["colors"].as_array().unwrap().iter().any(|c| c["hex"] == "#ff0000" && c["swatch"] == "Brand"), "{colors}"); + // A swatch edit maps the same paint once. + s.execute("swatch.edit", &json!({"name": "Brand", "color": "#00ff00"})).unwrap(); + assert_eq!(symbol_part_fill(&s, inst), "#00ff00"); +} + +/// The Eyedropper takes a picked object's paints only from a path, compound path or type. +#[test] +fn the_eyedropper_refuses_a_source_that_is_not_painted_art() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let other = id(&s.execute("symbol.place", &json!({"name": "Pair", "x": 55, "y": 80})).unwrap()); + let (a, b) = (square(&mut s, 150.0), square(&mut s, 150.0)); + let group = id(&s.execute("object.group", &json!({"ids": [a.0, b.0]})).unwrap()); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let (fill, stroke, own, undo) = (s.paint.fill.clone(), s.paint.stroke.clone(), node(&s, inst).extras().symbol_overrides.clone(), undo_steps(&s)); + s.eyedropper_sample = None; + for source in [inst, other, group] { + let e = s.execute("appearance.copyFrom", &json!({"source": source.0})).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{e}"); + } + // A click with the tool on an instance, the picked one included. + let v = ViewInfo { smart_guides: false, ..Default::default() }; + s.select_tool("eyedropper", v).unwrap(); + let e = s.pointer(&PointerEvent::new(PointerKind::Down, 80.0, 30.0), v).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{e}"); + s.pointer(&PointerEvent::new(PointerKind::Up, 80.0, 30.0), v).unwrap(); + assert_eq!(node(&s, inst).extras().symbol_overrides, own, "the object keeps its paints"); + assert_eq!(undo_steps(&s), undo, "no undo step"); + assert_eq!((&s.paint.fill, &s.paint.stroke), (&fill, &stroke), "the defaults for new art stay"); + assert!(s.eyedropper_sample.is_none(), "nothing was sampled"); + assert_eq!(pixel(&s, 80, 30), RED); +} + +/// A path of a compound path gives the paint of the compound path, which holds it. +#[test] +fn the_eyedropper_samples_a_compound_path_through_its_member() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let (a, b) = (square(&mut s, 120.0), square(&mut s, 170.0)); + let compound = id(&s.execute("object.compoundPath.make", &json!({"ids": [a.0, b.0]})).unwrap()); + s.execute("paint.setFill", &json!({"ids": [compound.0], "color": "#00ff00"})).unwrap(); + let member = node(&s, compound).children().unwrap()[0].id; + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": member.0})).unwrap(); + assert_eq!(symbol_part_fill(&s, inst), "#00ff00"); + assert_eq!(pixel(&s, 80, 30), [0, 255, 0, 255]); +} + +/// A Cmd-drag lends the Selection tool, or with Alt Group Selection, which act on the whole instance. +#[test] +fn a_lent_selection_tool_moves_the_instance_of_a_picked_object() { + let cmd = Mods { cmd: true, ..Mods::default() }; + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default()); + assert!(s.doc().unwrap().selection.symbol_part.is_some()); + let before = node(&s, inst).kind.clone(); + direct(&mut s, (80.0, 30.0), (100.0, 40.0), cmd); + assert!(s.doc().unwrap().selection.symbol_part.is_none()); + assert_ne!(node(&s, inst).kind, before, "the instance moves"); + + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default()); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods { alt: true, ..cmd }); + assert!(s.doc().unwrap().selection.symbol_part.is_none(), "Group Selection selects the instance whole"); + assert_eq!(s.doc().unwrap().selection.objects, [inst]); + // A plain Direct Selection click picks the object again once the tool is back. + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default()); + assert!(s.doc().unwrap().selection.symbol_part.is_some()); +} + +/// The commands that change nothing in the document read the instance of a picked object, so the +/// Attributes and CSS Properties panels and the others keep showing it. +#[test] +fn read_only_commands_read_the_instance_of_a_picked_object() { + let mut failures = Vec::new(); + for spec in crate::cmd::command_specs().iter().filter(|c| !c.journal) { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + if let Err(e) = s.execute(spec.id, &json!({})) + && e.to_string().contains("symbol instance") + { + failures.push(format!("{}: {e}", spec.id)); + } + if s.doc().is_ok_and(|st| st.doc != doc) { + failures.push(format!("{}: the document changed", spec.id)); + } + // A command run after one of these is refused as before. + if s.doc().is_ok_and(|st| st.selection.symbol_part.is_some()) && s.execute("object.hide", &json!({})).is_ok() { + failures.push(format!("{}: the refusal stays off afterward", spec.id)); + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("css.selection", &json!({})).unwrap(); + s.execute("attributes.info", &json!({})).unwrap(); +} + +/// Export CSS writes a file and changes nothing in the document, so it reads the instance of a +/// picked object as it reads an instance selected whole, and the next edit is refused as before. +#[test] +fn css_export_reads_the_instance_of_a_picked_object() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("select.set", &json!({"ids": [inst.0]})).unwrap(); + let whole = s.execute("css.export", &json!({})).unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let picked = s.execute("css.export", &json!({})).unwrap(); + assert_eq!(picked["rules"], 1); + assert_eq!(picked["data"], whole["data"]); + assert!(s.doc().unwrap().doc == doc, "the document changed"); + assert!(s.doc().unwrap().selection.symbol_part.is_some()); + assert!(s.execute("object.hide", &json!({})).is_err()); +} + +/// A drag that moved nothing leaves Transform Again repeating the last real transformation. +#[test] +fn a_refused_drag_of_a_picked_object_is_not_kept_for_transform_again() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default()); + assert!(s.doc().unwrap().selection.symbol_part.is_some()); + direct(&mut s, (80.0, 30.0), (120.0, 60.0), Mods::default()); + assert!(s.doc().unwrap().last_transform.is_none(), "{:?}", s.doc().unwrap().last_transform); + assert_eq!(s.doc().unwrap().selection.objects, [inst]); +} + +/// A bad `target` or `aligned` on a paint command is refused with an object picked too. +#[test] +fn bad_paint_parameters_leave_a_picked_object_alone() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + let undo = undo_steps(&s); + for (cmd, p) in [ + ("paint.setFill", json!({"color": "#ff0000", "target": "bogus"})), + ("paint.setStroke", json!({"color": "#ff0000", "target": "bogus"})), + ("paint.invert", json!({"target": "bogus"})), + ("paint.complement", json!({"target": "bogus"})), + ("paint.setFill", json!({"color": "#ff0000", "aligned": "yes"})), + ] { + let e = s.execute(cmd, &p).unwrap_err().to_string(); + assert!(e.contains("`target` must") || e.contains("aligned"), "{cmd} {p}: {e}"); + assert_eq!(overrides(&s, inst), 0, "{cmd} {p}"); + } + assert_eq!(undo_steps(&s), undo, "no undo step"); +} + +/// An object picked while Replace read its file: the instance selected when Place started is +/// selected again first, so a replay of the journal replaces it. +#[test] +fn a_replace_that_finishes_after_an_object_was_picked_selects_the_instance_again() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("select.set", &json!({"ids": [inst.0]})).unwrap(); + let svg = ""; + let p = json!({"name": "a.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes()), "replace": true}); + let read = s.prepare_place(&p).unwrap().read().unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.place_read(read).unwrap(); + assert!(s.doc().unwrap().doc.node(inst).is_none()); + let last: Vec<_> = s.journal.iter().rev().take(2).rev().map(|(id, _)| id.clone()).collect(); + assert_eq!(last, ["select.set", "file.place"]); +} diff --git a/crates/engine/src/tests/live.rs b/crates/engine/src/tests/live.rs index c7479ce8f..08fc120e0 100644 --- a/crates/engine/src/tests/live.rs +++ b/crates/engine/src/tests/live.rs @@ -537,6 +537,25 @@ fn envelope_top_object_maps_corners() { assert!(close(b.x0, 350.0) && close(b.y1, 250.0)); } +/// Each piece of a top object's side takes a share of the content in proportion to its control +/// polygon, spread by its curve parameter. The top side's two curves have polygons of 64.7 and +/// 166.5, so the content's middle lands on the second curve at t = 0.3056, (482.6, 106.4); by +/// length it would land at (493.7, 106.9), and by equal shares at the anchor (440, 100). +#[test] +fn envelope_top_object_shares_a_side_by_control_polygon_length() { + let mut s = session(); + let left = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + let right = rect(&mut s, 200.0, 100.0, 100.0, 100.0); + let d = "M400 100 C410 80 430 80 440 100 C460 110 590 110 600 100 L600 200 L400 200 Z"; + let t = id_of(&s.execute("path.create", &json!({ "d": d })).unwrap()); + sel(&mut s, &[left, right, t]); + let e = id_of(&s.execute("object.envelope.makeWithTopObject", &json!({})).unwrap()); + let pts = all_anchors(&live::expand_live(&node(&s, e))); + let near = |c: Point| pts.iter().map(|p| p.distance(c)).fold(f64::INFINITY, f64::min); + assert!(near(Point::new(482.6, 106.37)) < 0.5, "the middle of the top: {pts:?}"); + assert!(near(Point::new(500.0, 200.0)) < 0.5, "the middle of the bottom: {pts:?}"); +} + #[test] fn envelope_release_and_expand() { let mut s = session(); diff --git a/crates/engine/src/tests/nativefile.rs b/crates/engine/src/tests/nativefile.rs index e3a5588b7..4156dc759 100644 --- a/crates/engine/src/tests/nativefile.rs +++ b/crates/engine/src/tests/nativefile.rs @@ -222,7 +222,8 @@ fn a_version_1_documents_twist_turns_as_it_was_saved() { /// A save records the date the document was made in, so the same commands twice gave different /// bytes (#2880). A pinned date makes them identical — `file.new {created}` here, and -/// `SOURCE_DATE_EPOCH` for an export that takes no date param. +/// `SOURCE_DATE_EPOCH` for an export that takes no date param. The PDF a native file carries by +/// default is dated by the document's dates, not by the time of writing. #[test] fn a_pinned_date_makes_saves_byte_identical() { let once = |t: i64| { @@ -232,6 +233,9 @@ fn a_pinned_date_makes_saves_byte_identical() { saved(&mut s, json!({})) }; let at = 1_791_208_980; + let pdf = vectorcraft_format::pdf_content(&once(at)).expect("a native save carries a PDF by default"); + let info = vectorcraft_pdf::file_info(&pdf, None).unwrap(); + assert_eq!((info.created, info.modified), (Some(at), Some(at)), "the PDF has the document's dates"); assert_eq!(once(at), once(at), "the same document and date give the same bytes"); // The date really is in the bytes, so the comparison above is not vacuous. assert_ne!(once(at), once(at + 1), "another date shows in the bytes"); diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index 59b8db890..4210a1d96 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -133,6 +133,37 @@ fn white_black() -> Value { json!({"kind": "linear", "stops": [{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]}) } +#[test] +fn a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it() { + // The object's own effect, and the fill's (the first appearance item). + let slant = json!({"corners": [[0.25, 0.0], [1.25, 0.0], [1.0, 1.0], [0.0, 1.0]]}); + for (effect, params, item) in [ + ("distort.transform", json!({"rotate": 90}), json!(null)), + ("distort.transform", json!({"rotate": 30, "scaleH": 60}), json!(null)), + ("distort.transform", json!({"rotate": 90}), json!(0)), + ("distort.freeDistort", slant.clone(), json!(null)), + ("distort.freeDistort", slant, json!(0)), + ] { + let mut s = session(); + let id = gradient_rect(&mut s, Rect::new(40.0, 70.0, 160.0, 130.0), white_black(), false); + // Placed from a quarter of the way across to the right edge. + run(&mut s, "paint.setGradientGeom", json!({"start": [70, 100], "end": [160, 100], "stroke": false})); + let placed = s.doc().unwrap().doc.node(id).and_then(|n| match n.appearance.fill_paint() { + vectorcraft_color::Paint::Gradient(g) => g.geom, + _ => None, + }); + assert!(placed.is_some(), "the fill's gradient is placed"); + run(&mut s, "effect.apply", json!({"effect": effect, "params": params, "ids": [id.0], "item": item})); + let before = render(&s); + // Fill alone: the gradient becomes strips inside the outline the effect makes. + run(&mut s, "object.expand", json!({"object": false, "stroke": false, "steps": 64})); + let after = render(&s); + // The gradient moves with the rectangle on the canvas, and the strips follow it. + let diff = mean_diff(&before, &after); + assert!(diff < 1.0, "{effect} {params} on item {item}: mean difference {diff}"); + } +} + #[test] fn eight_strips_match_the_gradient_samples() { let mut s = session(); diff --git a/crates/engine/src/tests/panelcmds.rs b/crates/engine/src/tests/panelcmds.rs index 0f95fc2f6..0870ffb87 100644 --- a/crates/engine/src/tests/panelcmds.rs +++ b/crates/engine/src/tests/panelcmds.rs @@ -119,6 +119,60 @@ fn artboards_reorder_and_duplicate() { assert_eq!(s.doc().unwrap().doc.artboards[0].name, "Second"); } +/// `artboard.new {beside, side}` adds an empty artboard the size of artboard `beside`, 20 pt from +/// that side of it, and `artboard.duplicate {index, side}` puts the copy and its art there; each is +/// one undo step. An unknown side, `beside` without `side`, and `side` with a size or place are +/// refused and change nothing. +#[test] +fn artboards_are_added_and_duplicated_on_a_side() { + let mut s = session(); + let id = rect(&mut s); + let board = |s: &Session, i: usize| s.doc().unwrap().doc.artboards[i].rect; + let undo = |s: &Session| s.doc().unwrap().history.undo.len(); + let src = board(&s, 0); + let before = undo(&s); + assert_eq!(s.execute("artboard.new", &json!({"beside": 0, "side": "bottom"})).unwrap()["index"], 1); + assert_eq!(board(&s, 1), src + vectorcraft_geom::Vec2::new(0.0, src.height() + 20.0), "same size, 20 pt below"); + assert_eq!(undo(&s), before + 1, "one undo step"); + assert!(!s.doc().unwrap().doc.artboard_has_art(board(&s, 1)), "the new artboard is empty"); + s.execute("artboard.new", &json!({"beside": 0, "side": "left"})).unwrap(); + assert_eq!(board(&s, 2), src + vectorcraft_geom::Vec2::new(-(src.width() + 20.0), 0.0)); + s.execute("artboard.new", &json!({"side": "top"})).unwrap(); + assert_eq!(board(&s, 3), board(&s, 2) + vectorcraft_geom::Vec2::new(0.0, -(src.height() + 20.0)), "by default beside the last"); + s.execute("artboard.new", &json!({"side": "top", "beside": null, "x": null})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + + let before = undo(&s); + let out = s.execute("artboard.duplicate", &json!({"index": 0, "side": "right", "art": true})).unwrap(); + assert_eq!(out["index"], 4); + let dv = vectorcraft_geom::Vec2::new(src.width() + 20.0, 0.0); + assert_eq!(board(&s, 4), src + dv, "the copy sits 20 pt right of the artboard it copies"); + let copies: Vec = out["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect(); + assert_eq!(copies.len(), 1); + let bounds = |s: &Session, id: NodeId| s.doc().unwrap().doc.bounds_of(&[id], false).unwrap(); + assert_eq!(bounds(&s, copies[0]), bounds(&s, id) + dv, "its art goes with it"); + assert_eq!(undo(&s), before + 1, "one undo step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.artboards.len(), 4, "undo takes the copy and its art away together"); + assert!(s.doc().unwrap().doc.node(copies[0]).is_none()); + + let before = s.doc().unwrap().doc.clone(); + let steps = undo(&s); + for (cmd, p, msg) in [ + ("artboard.new", json!({"beside": 0, "side": "middle"}), "`side` must be"), + ("artboard.new", json!({"beside": 0, "side": 3}), "`side` must be"), + ("artboard.new", json!({"beside": 0}), "`beside` needs `side`"), + ("artboard.new", json!({"side": "left", "width": 100}), "leave out x, y, width and height"), + ("artboard.new", json!({"beside": 9, "side": "left"}), "no such artboard"), + ("artboard.duplicate", json!({"index": 0, "side": "inside"}), "`side` must be"), + ] { + let e = s.execute(cmd, &p).unwrap_err().to_string(); + assert!(e.contains(msg), "{cmd} {p}: {e}"); + assert_eq!(*s.doc().unwrap().doc, *before, "{cmd} {p} changes nothing"); + assert_eq!(undo(&s), steps, "{cmd} {p}"); + } +} + /// An artboard's background color (Properties › Artboards › Fill): `artboard.setProps {color}` sets /// it and `{color: null}` clears it, each in one undo step. A change without `color` leaves it, a /// duplicate keeps it, `inspect_document` reports it, `artboard.new {color}` starts with it, an diff --git a/crates/engine/src/tests/pattern.rs b/crates/engine/src/tests/pattern.rs index 544dcce96..c36d84fd9 100644 --- a/crates/engine/src/tests/pattern.rs +++ b/crates/engine/src/tests/pattern.rs @@ -979,6 +979,32 @@ fn picking_another_pattern_swatch_keeps_the_pattern_transform() { assert_eq!(xf.as_coeffs(), [2.0, 0.0, 0.0, 2.0, 15.0, 10.0]); } +/// A pattern swatch made from the current fill holds the pattern untransformed: a fill picked up +/// from art whose tiles were moved does not carry that placement to the art the swatch is applied +/// to, which keeps its own (#1970). +#[test] +fn a_pattern_swatch_made_from_a_transformed_fill_holds_the_pattern_untransformed() { + let mut s = session(); + make_dots(&mut s); + let src = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + s.execute("paint.setFill", &json!({"ids": [src.0], "swatch": "Dots"})).unwrap(); + s.execute("pattern.transform", &json!({"ids": [src.0], "matrix": [2, 0, 0, 2, 15, 10]})).unwrap(); + sel(&mut s, &[]); + s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap(); + let Paint::Pattern { xf, .. } = &s.paint.fill else { panic!("the picked-up pattern fill") }; + assert_eq!(xf.as_coeffs(), [2.0, 0.0, 0.0, 2.0, 15.0, 10.0], "the current fill has the tiles' placement"); + s.execute("swatch.new", &json!({"name": "Picked"})).unwrap(); + let Paint::Pattern { pattern, xf } = doc(&s).swatch("Picked").unwrap().paint.clone() else { panic!("a pattern swatch") }; + assert_eq!((pattern.as_str(), xf.as_coeffs()), ("Dots", [1.0, 0.0, 0.0, 1.0, 0.0, 0.0])); + let dst = rect(&mut s, 300.0, 100.0, 50.0, 50.0); + s.execute("paint.setFill", &json!({"ids": [dst.0], "color": "#00ff00"})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [dst.0], "swatch": "Dots"})).unwrap(); + s.execute("pattern.transform", &json!({"ids": [dst.0], "matrix": [3, 0, 0, 3, 0, 0]})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [dst.0], "swatch": "Picked"})).unwrap(); + let Paint::Pattern { xf, .. } = node(&s, dst).appearance.fill_paint() else { panic!("a pattern fill") }; + assert_eq!(xf.as_coeffs(), [3.0, 0.0, 0.0, 3.0, 0.0, 0.0], "the art keeps its own placement"); +} + #[test] fn inner_glow_on_a_pattern_fill_stays_inside_the_object_on_the_threaded_renderer() { // #2426: the glow's pixmap was drawn with the pattern's paint transform, so it landed diff --git a/crates/engine/src/tests/pixelalign.rs b/crates/engine/src/tests/pixelalign.rs new file mode 100644 index 000000000..d348b13ae --- /dev/null +++ b/crates/engine/src/tests/pixelalign.rs @@ -0,0 +1,768 @@ +//! Transform panel › Align to Pixel Grid (`object.setPixelAligned`): a pixel-aligned path keeps its +//! horizontal and vertical segments on the pixel grid through transforms. + +use serde_json::{Value, json}; +use vectorcraft_doc::{NodeId, NodeKind}; + +use crate::Session; + +fn new_doc() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + s +} + +/// A closed path from SVG path data `d`, selected, with a stroke of `weight` points. +fn path(s: &mut Session, d: &str, weight: f64) -> u64 { + let id = s.execute("path.create", &json!({"d": d})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("stroke.set", &json!({"weight": weight})).unwrap(); + id +} + +/// The anchor points of path object `id`, every subpath's in order. +fn points(s: &Session, id: u64) -> Vec<(f64, f64)> { + let n = s.doc().unwrap().doc.node(NodeId(id)).unwrap().clone(); + n.path_data().unwrap().anchors().map(|(_, _, a)| (a.p.x, a.p.y)).collect() +} + +fn aligned(s: &Session, id: u64) -> bool { + s.doc().unwrap().doc.node(NodeId(id)).unwrap().extras().pixel_aligned +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// Whether every coordinate is a whole number of points (within rounding error). +fn whole(points: &[(f64, f64)]) -> bool { + points.iter().all(|(x, y)| (x - x.round()).abs() < 1e-9 && (y - y.round()).abs() < 1e-9) +} + +fn close(a: (f64, f64), b: (f64, f64)) -> bool { + (a.0 - b.0).abs() < 1e-9 && (a.1 - b.1).abs() < 1e-9 +} + +/// Turning it on sets the property and snaps the path's horizontal and vertical edges to whole +/// points, in one undo step; turning it on again changes nothing and makes no undo step; undo +/// puts the path and the property back. +#[test] +fn turning_it_on_snaps_the_path_in_one_undo_step() { + let mut s = new_doc(); + let id = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0); + let before = points(&s, id); + let steps = undo_len(&s); + let r = s.execute("object.setPixelAligned", &json!({"on": true})).unwrap(); + assert_eq!(r["ids"], json!([id])); + assert!(aligned(&s, id)); + assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); + assert_eq!(undo_len(&s), steps + 1); + let r = s.execute("object.setPixelAligned", &json!({"ids": [id]})).unwrap(); + assert_eq!(r["ids"], json!([])); + assert_eq!(undo_len(&s), steps + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!aligned(&s, id)); + assert_eq!(points(&s, id), before); + // An odd stroke weight centered on the path puts the edges on half points, and the stroke + // weight becomes whole. + s.execute("stroke.set", &json!({"weight": 0.8})).unwrap(); + s.execute("object.setPixelAligned", &json!({"on": true})).unwrap(); + assert_eq!(points(&s, id), [(10.5, 20.5), (40.5, 20.5), (40.5, 30.5), (10.5, 30.5)]); + assert_eq!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().appearance.stroke_width(), 1.0); + // Off keeps the geometry. + s.execute("object.setPixelAligned", &json!({"on": false})).unwrap(); + assert!(!aligned(&s, id)); + assert_eq!(points(&s, id), [(10.5, 20.5), (40.5, 20.5), (40.5, 30.5), (10.5, 30.5)]); +} + +/// A pixel-aligned path stays on the pixel grid after a fractional move, a scale, a rotation about +/// a fractional point, a reflection, Transform Each and a move of its group; one that is not +/// moves exactly as before. +#[test] +fn it_stays_aligned_through_transforms() { + let mut s = new_doc(); + let id = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); + let other = path(&mut s, "M10 121 L41 121 L41 130 L10 130 Z", 2.0); + s.execute("object.setPixelAligned", &json!({"ids": [id]})).unwrap(); + s.execute("select.set", &json!({"ids": [id, other]})).unwrap(); + s.execute("object.move", &json!({"dx": 0.3, "dy": 0.45})).unwrap(); + assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); + assert!(close(points(&s, other)[0], (10.3, 121.45)), "{:?}", points(&s, other)); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + for (cmd, p) in [ + ("object.move", json!({"dx": 5.7, "dy": -2.2})), + ("object.scale", json!({"sx": 133, "sy": 71})), + ("object.rotate", json!({"angle": 90, "origin": [20.2, 20.7]})), + ("object.reflect", json!({"axis": "horizontal", "origin": [0.3, 0.3]})), + ("object.transformEach", json!({"moveH": 0.4, "moveV": 0.35})), + ("object.setBounds", json!({"width": 17.3})), + ] { + s.execute(cmd, &p).unwrap(); + let pts = points(&s, id); + assert!(whole(&pts), "{cmd}: {pts:?}"); + let xs: Vec = pts.iter().map(|p| p.0).collect(); + assert!(xs.iter().any(|x| (x - xs[0]).abs() >= 1.0), "{cmd}: the path collapsed: {pts:?}"); + } + // In a group that moves. + s.execute("select.set", &json!({"ids": [id, other]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + let before = points(&s, other); + s.execute("object.move", &json!({"dx": 0.25, "dy": 0.0})).unwrap(); + assert!(whole(&points(&s, id)), "{:?}", points(&s, id)); + assert!(close(points(&s, other)[0], (before[0].0 + 0.25, before[0].1))); +} + +/// Art moved with its artboard stays on the pixel grid. +#[test] +fn it_stays_aligned_when_its_artboard_moves() { + let mut s = new_doc(); + let id = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); + s.execute("object.setPixelAligned", &json!({})).unwrap(); + s.execute("artboard.move", &json!({"index": 0, "dx": 0.4, "dy": 0.3, "moveArt": true})).unwrap(); + assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); + s.execute("artboard.move", &json!({"index": 0, "dx": 0.6, "dy": 0.7, "moveArt": true})).unwrap(); + assert_eq!(points(&s, id), [(11.0, 22.0), (42.0, 22.0), (42.0, 31.0), (11.0, 31.0)]); +} + +/// Diagonal segments and curves keep their shape: only the anchors that horizontal and vertical +/// segments share move, each with its handles. +#[test] +fn diagonal_segments_and_curves_keep_their_shape() { + let mut s = new_doc(); + // A triangle: a horizontal base and two diagonal sides. Only the base's y snaps. + let tri = path(&mut s, "M0.2 0.2 L20.4 0.2 L10.3 15.7 Z", 2.0); + s.execute("object.setPixelAligned", &json!({})).unwrap(); + let pts = points(&s, tri); + assert!(close(pts[0], (0.2, 0.0)) && close(pts[1], (20.4, 0.0)) && close(pts[2], (10.3, 15.7)), "{pts:?}"); + s.execute("object.move", &json!({"dx": 0.5, "dy": 0.3})).unwrap(); + let pts = points(&s, tri); + assert!(close(pts[0], (0.7, 0.0)) && close(pts[1], (20.9, 0.0)) && close(pts[2], (10.8, 16.0)), "{pts:?}"); + // A curve between two horizontal segments: its ends move with them and its handles with its + // ends. + let curved = path(&mut s, "M100.3 100.3 L120.3 100.3 C125 105 125 115 120.3 120.3 L100.3 120.3 Z", 2.0); + s.execute("object.setPixelAligned", &json!({})).unwrap(); + let n = s.doc().unwrap().doc.node(NodeId(curved)).unwrap().clone(); + let sp = &n.path_data().unwrap().subpaths[0]; + let a = &sp.anchors; + assert!(close((a[0].p.x, a[0].p.y), (100.0, 100.0)) && close((a[3].p.x, a[3].p.y), (100.0, 120.0))); + assert!(close((a[1].p.x, a[1].p.y), (120.3, 100.0)) && close((a[1].h_out.x, a[1].h_out.y), (125.0, 104.7))); + assert!(close((a[2].p.x, a[2].p.y), (120.3, 120.0)) && close((a[2].h_in.x, a[2].h_in.y), (125.0, 114.7))); + // A rectangle turned 45° has no horizontal or vertical segment, and stays as it is. + let turned = path(&mut s, "M200.5 200 L210.5 210.3 L200.5 220.6 L190.5 210.3 Z", 2.0); + let before = points(&s, turned); + s.execute("object.setPixelAligned", &json!({})).unwrap(); + assert_eq!(points(&s, turned), before); +} + +/// Live shapes stay live, and compound paths snap each of their paths. +#[test] +fn live_shapes_and_compound_paths_snap() { + let mut s = new_doc(); + let rect = s.execute("shape.rectangle", &json!({"x": 10.3, "y": 20.6, "width": 30.4, "height": 9.7})).unwrap()["id"].as_u64().unwrap(); + s.execute("stroke.set", &json!({"weight": 2})).unwrap(); + s.execute("object.setPixelAligned", &json!({})).unwrap(); + let n = s.doc().unwrap().doc.node(NodeId(rect)).unwrap().clone(); + assert!(n.live_shape().is_some()); + let b = n.geometric_bounds().unwrap(); + assert_eq!((b.x0, b.y0, b.x1, b.y1), (10.0, 21.0, 41.0, 30.0)); + s.execute("object.move", &json!({"dx": 0.4, "dy": 0.4})).unwrap(); + let b = s.doc().unwrap().doc.node(NodeId(rect)).unwrap().geometric_bounds().unwrap(); + assert_eq!((b.x0, b.y0, b.x1, b.y1), (10.0, 21.0, 41.0, 30.0)); + let a = path(&mut s, "M100.2 100.2 L110.6 100.2 L110.6 110.6 L100.2 110.6 Z", 2.0); + let b = path(&mut s, "M200.2 100.2 L210.6 100.2 L210.6 110.6 L200.2 110.6 Z", 2.0); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + let c = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.setPixelAligned", &json!({"ids": [c]})).unwrap(); + s.execute("object.move", &json!({"dx": 0.3, "dy": 0.0})).unwrap(); + let n = s.doc().unwrap().doc.node(NodeId(c)).unwrap().clone(); + let pts: Vec<(f64, f64)> = + n.children().unwrap().iter().flat_map(|k| k.path_data().unwrap().anchors().map(|(_, _, a)| (a.p.x, a.p.y)).collect::>()).collect(); + assert_eq!(pts.len(), 8); + assert!(whole(&pts), "{pts:?}"); +} + +/// The anchor points of every path in compound path `id`. +fn compound_points(s: &Session, id: u64) -> Vec<(f64, f64)> { + let n = s.doc().unwrap().doc.node(NodeId(id)).unwrap().clone(); + n.children().unwrap().iter().flat_map(|k| k.path_data().unwrap().anchors().map(|(_, _, a)| (a.p.x, a.p.y)).collect::>()).collect() +} + +/// A stroke edit on a pixel-aligned path rounds the new weight and snaps the path again: a 0.8 pt +/// weight becomes 1 pt with the edges on half points, a centered 3 pt stroke keeps the edges on +/// half points, and Stroke Weight Up keeps the weight whole; all in the stroke edit's one undo step. +#[test] +fn stroke_edits_keep_it_aligned() { + let mut s = new_doc(); + let id = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0); + s.execute("object.setPixelAligned", &json!({})).unwrap(); + assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); + let steps = undo_len(&s); + s.execute("stroke.set", &json!({"weight": 0.8})).unwrap(); + assert_eq!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().appearance.stroke_width(), 1.0); + assert_eq!(points(&s, id), [(10.5, 21.5), (41.5, 21.5), (41.5, 30.5), (10.5, 30.5)]); + assert_eq!(undo_len(&s), steps + 1); + s.execute("stroke.set", &json!({"weight": 2})).unwrap(); + assert!(whole(&points(&s, id)), "{:?}", points(&s, id)); + s.execute("stroke.set", &json!({"weight": 3})).unwrap(); + let pts = points(&s, id); + assert!(pts.iter().all(|(x, y)| (x - x.floor() - 0.5).abs() < 1e-9 && (y - y.floor() - 0.5).abs() < 1e-9), "{pts:?}"); + // Inside alignment puts the stroke within the path, so the anchors go back to whole points. + s.execute("stroke.set", &json!({"align": "inside"})).unwrap(); + assert!(whole(&points(&s, id)), "{:?}", points(&s, id)); + s.execute("stroke.set", &json!({"align": "center", "weight": 0.6})).unwrap(); + s.execute("stroke.weightUp", &json!({})).unwrap(); + let w = s.doc().unwrap().doc.node(NodeId(id)).unwrap().appearance.stroke_width(); + assert_eq!(w, w.round(), "{w}"); + // A path that is not pixel-aligned takes the weight as given. + let plain = path(&mut s, "M110.3 20.6 L140.7 20.6 L140.7 30.3 L110.3 30.3 Z", 0.8); + assert_eq!(s.doc().unwrap().doc.node(NodeId(plain)).unwrap().appearance.stroke_width(), 0.8); + assert_eq!(points(&s, plain), [(110.3, 20.6), (140.7, 20.6), (140.7, 30.3), (110.3, 30.3)]); +} + +/// Make Compound Path from pixel-aligned paths gives a pixel-aligned compound path whose paths do +/// not keep the property; they snap with the compound path's stroke, so a centered 1 pt stroke puts +/// them on half points after a move. Release gives each path the property back. +#[test] +fn a_compound_path_of_aligned_paths_is_aligned() { + let mut s = new_doc(); + let a = path(&mut s, "M100.2 100.2 L110.6 100.2 L110.6 110.6 L100.2 110.6 Z", 1.0); + let b = path(&mut s, "M200.2 100.2 L210.6 100.2 L210.6 110.6 L200.2 110.6 Z", 1.0); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("object.setPixelAligned", &json!({})).unwrap(); + let c = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + assert!(aligned(&s, c)); + let n = s.doc().unwrap().doc.node(NodeId(c)).unwrap().clone(); + assert!(n.children().unwrap().iter().all(|k| !k.extras().pixel_aligned)); + let half = |pts: &[(f64, f64)]| pts.iter().all(|(x, y)| (x - x.floor() - 0.5).abs() < 1e-9 && (y - y.floor() - 0.5).abs() < 1e-9); + assert!(half(&compound_points(&s, c)), "{:?}", compound_points(&s, c)); + s.execute("object.move", &json!({"dx": 0.3, "dy": 0.2})).unwrap(); + let pts = compound_points(&s, c); + assert_eq!(pts.len(), 8); + assert!(half(&pts), "{pts:?}"); + // A path inside the compound path cannot take the property alone. + let member = n.children().unwrap()[0].id.0; + let e = s.execute("object.setPixelAligned", &json!({"ids": [member]})).unwrap_err().to_string(); + assert!(e.contains("only paths and compound paths"), "{e}"); + s.execute("select.set", &json!({"ids": [c]})).unwrap(); + s.execute("object.compoundPath.release", &json!({})).unwrap(); + let released = s.doc().unwrap().selection.objects.clone(); + assert_eq!(released.len(), 2); + assert!(released.iter().all(|id| aligned(&s, id.0))); +} + +/// Make Compound Path from a pixel-aligned compound path and another path gives a pixel-aligned +/// compound path, which keeps snapping after a move. +#[test] +fn a_compound_path_of_an_aligned_compound_path_is_aligned() { + let mut s = new_doc(); + let a = path(&mut s, "M100.2 100.2 L110.6 100.2 L110.6 110.6 L100.2 110.6 Z", 2.0); + let b = path(&mut s, "M200.2 100.2 L210.6 100.2 L210.6 110.6 L200.2 110.6 Z", 2.0); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + let c = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.setPixelAligned", &json!({"ids": [c]})).unwrap(); + let other = path(&mut s, "M300.2 100.2 L310.6 100.2 L310.6 110.6 L300.2 110.6 Z", 2.0); + s.execute("select.set", &json!({"ids": [c, other]})).unwrap(); + let joined = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + assert!(aligned(&s, joined)); + s.execute("object.move", &json!({"dx": 0.3, "dy": 0.4})).unwrap(); + let pts = compound_points(&s, joined); + assert_eq!(pts.len(), 12); + assert!(whole(&pts), "{pts:?}"); +} + +/// A stroke with no paint does not count: a fill-only path whose stroke item keeps an odd weight +/// snaps to whole points. +#[test] +fn a_stroke_without_paint_snaps_to_whole_points() { + let mut s = new_doc(); + let id = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 1.0); + // An imported file can hold a stroke item without paint that keeps its weight. + let n = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(NodeId(id)).unwrap(); + let Some(vectorcraft_doc::AppearanceItem::Stroke(st)) = + n.appearance.items.iter_mut().find(|i| matches!(i, vectorcraft_doc::AppearanceItem::Stroke(_))) + else { + panic!("no stroke"); + }; + st.paint = vectorcraft_color::Paint::None; + assert_eq!(st.width, 1.0); + s.execute("object.setPixelAligned", &json!({})).unwrap(); + assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); +} + +/// With direct-selected anchors in the selection, a move snaps the pixel-aligned path whose anchors +/// moved and the pixel-aligned object selected whole. +#[test] +fn moving_direct_selected_anchors_keeps_it_aligned() { + let mut s = new_doc(); + let a = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0); + let b = path(&mut s, "M110.3 20.6 L140.7 20.6 L140.7 30.3 L110.3 30.3 Z", 2.0); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("object.setPixelAligned", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.execute("select.anchors", &json!({"id": a, "anchors": [[0, 1], [0, 2]], "mode": "add"})).unwrap(); + s.execute("path.moveAnchors", &json!({"dx": 0.4, "dy": 0.0})).unwrap(); + assert_eq!(points(&s, a), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); + assert_eq!(points(&s, b), [(110.0, 21.0), (141.0, 21.0), (141.0, 30.0), (110.0, 30.0)]); + s.execute("path.moveAnchors", &json!({"dx": 0.7, "dy": 0.0})).unwrap(); + assert_eq!(points(&s, a), [(10.0, 21.0), (42.0, 21.0), (42.0, 30.0), (10.0, 30.0)]); + assert_eq!(points(&s, b), [(111.0, 21.0), (142.0, 21.0), (142.0, 30.0), (111.0, 30.0)]); +} + +/// A path with Live Corners snaps the outline its corners are cut from, inner edges included, and +/// keeps its Live Corners. +#[test] +fn a_path_with_live_corners_snaps_its_inner_edges() { + let mut s = new_doc(); + let id = path(&mut s, "M10 10 L40 10 L40 20.3 L25 20.3 L25 40 L10 40 Z", 2.0); + s.execute("object.setLiveShape", &json!({"id": id, "radius": 2})).unwrap(); + let base = |s: &Session| { + let n = s.doc().unwrap().doc.node(NodeId(id)).unwrap().clone(); + let NodeKind::Path { live: Some(live), .. } = &n.kind else { panic!("no Live Corners") }; + let vectorcraft_doc::LiveShape::Path { base, .. } = &**live else { panic!("not a path with Live Corners") }; + base.anchors().map(|(_, _, a)| (a.p.x, a.p.y)).collect::>() + }; + assert!(base(&s).iter().any(|p| close(*p, (40.0, 20.3)))); + s.execute("object.setPixelAligned", &json!({"ids": [id]})).unwrap(); + assert_eq!(base(&s), [(10.0, 10.0), (40.0, 10.0), (40.0, 20.0), (25.0, 20.0), (25.0, 40.0), (10.0, 40.0)]); + let pts = points(&s, id); + assert!(pts.iter().all(|p| (p.1 - 20.3).abs() > 1e-6), "{pts:?}"); +} + +/// `path.setAnchors` and `object.setLiveShape` snap a pixel-aligned path again after they replace +/// its geometry. +#[test] +fn geometry_edits_keep_it_aligned() { + let mut s = new_doc(); + let id = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); + s.execute("object.setPixelAligned", &json!({"ids": [id]})).unwrap(); + let anchors = json!([{"x": 10.3, "y": 21.4}, {"x": 41.3, "y": 21.4}, {"x": 41.3, "y": 30.4}, {"x": 10.3, "y": 30.4}]); + s.execute("path.setAnchors", &json!({"id": id, "subpaths": [{"anchors": anchors, "closed": true}]})).unwrap(); + assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); + let poly = s.execute("shape.polygon", &json!({"cx": 100, "cy": 100, "radius": 50, "sides": 6})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.setPixelAligned", &json!({"ids": [poly]})).unwrap(); + s.execute("object.setLiveShape", &json!({"id": poly, "sides": 5})).unwrap(); + let n = s.doc().unwrap().doc.node(NodeId(poly)).unwrap().clone(); + let b = n.geometric_bounds().unwrap(); + let on_grid = |v: f64| ((v - 0.5) - (v - 0.5).round()).abs() < 1e-9 || (v - v.round()).abs() < 1e-9; + assert!(on_grid(b.y1), "{b:?}"); +} + +/// Any other edit of a pixel-aligned path, here Object › Path › Average, snaps it again too. +#[test] +fn other_path_edits_keep_it_aligned() { + let mut s = new_doc(); + let a = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); + let b = path(&mut s, "M60.3 50 L80 50 L80 60 L60.3 60 Z", 2.0); + s.execute("object.setPixelAligned", &json!({"ids": [a]})).unwrap(); + s.execute("select.set", &json!({"ids": []})).unwrap(); + s.execute("select.anchors", &json!({"id": a, "anchors": [[0, 1], [0, 2]], "mode": "add"})).unwrap(); + s.execute("select.anchors", &json!({"id": b, "anchors": [[0, 0]], "mode": "add"})).unwrap(); + s.execute("path.average", &json!({"axis": "vertical"})).unwrap(); + assert_eq!(points(&s, a), [(10.0, 21.0), (47.0, 21.0), (47.0, 30.0), (10.0, 30.0)]); + assert!((points(&s, b)[0].0 - (41.0 + 41.0 + 60.3) / 3.0).abs() < 1e-9, "{:?}", points(&s, b)); +} + +/// A stroke edit that Global Edit carries to the other members snaps the pixel-aligned ones. +#[test] +fn global_edit_keeps_its_members_aligned() { + let mut s = new_doc(); + let a = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); + // The same shape at twice the size. + let b = path(&mut s, "M110 21 L172 21 L172 39 L110 39 Z", 2.0); + s.execute("object.setPixelAligned", &json!({"ids": [a, b]})).unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let r = s.execute("select.globalEdit", &json!({})).unwrap(); + assert_eq!(r["active"], true); + s.execute("stroke.set", &json!({"weight": 1})).unwrap(); + assert_eq!(strokes(&s, b)[0].1, 1.0); + assert_eq!(points(&s, a), [(10.5, 21.5), (41.5, 21.5), (41.5, 30.5), (10.5, 30.5)]); + assert!(halves(&points(&s, b)), "{:?}", points(&s, b)); +} + +/// Align with direct-selected anchors snaps the pixel-aligned path whose anchors moved. +#[test] +fn aligning_direct_selected_anchors_keeps_it_aligned() { + let mut s = new_doc(); + let a = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); + let b = path(&mut s, "M60.4 50.3 L80 50.3 L80 60 L60.4 60 Z", 2.0); + s.execute("object.setPixelAligned", &json!({"ids": [a]})).unwrap(); + s.execute("select.set", &json!({"ids": []})).unwrap(); + s.execute("select.anchors", &json!({"id": a, "anchors": [[0, 1], [0, 2]], "mode": "add"})).unwrap(); + s.execute("select.anchors", &json!({"id": b, "anchors": [[0, 0]], "mode": "add"})).unwrap(); + s.execute("object.align", &json!({"horizontal": "right"})).unwrap(); + assert_eq!(points(&s, a), [(10.0, 21.0), (60.0, 21.0), (60.0, 30.0), (10.0, 30.0)]); + assert!(close(points(&s, b)[0], (60.4, 50.3)), "{:?}", points(&s, b)); +} + +/// The art of a linked opacity mask moves and scales with a path that snaps, and a placed gradient +/// keeps its place too. +#[test] +fn a_linked_opacity_mask_follows_a_snap() { + let mut s = new_doc(); + let o = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0); + let m = path(&mut s, "M12 23 L39 23 L39 28 L12 28 Z", 2.0); + s.execute("transparency.makeOpacityMask", &json!({"ids": [o, m]})).unwrap(); + let mask_bounds = |s: &Session| { + let n = s.doc().unwrap().doc.node(NodeId(o)).unwrap().clone(); + n.extras().mask.clone().expect("an opacity mask").art.geometric_bounds().unwrap() + }; + let (from, mask) = (s.doc().unwrap().doc.node(NodeId(o)).unwrap().geometric_bounds().unwrap(), mask_bounds(&s)); + s.execute("object.setPixelAligned", &json!({"ids": [o]})).unwrap(); + let to = s.doc().unwrap().doc.node(NodeId(o)).unwrap().geometric_bounds().unwrap(); + assert_eq!(points(&s, o), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); + let at = |r: vectorcraft_geom::Rect, x: f64, y: f64| ((x - r.x0) / r.width(), (y - r.y0) / r.height()); + let after = mask_bounds(&s); + assert!(close(at(to, after.x0, after.y0), at(from, mask.x0, mask.y0)), "{after:?} from {mask:?}"); + assert!(close(at(to, after.x1, after.y1), at(from, mask.x1, mask.y1)), "{after:?} from {mask:?}"); +} + +/// `path.moveAnchor` (the Anchor Point tool, Smart Pen) snaps a pixel-aligned path again: two moves +/// that leave a horizontal segment off the grid put it back on whole points. +#[test] +fn moving_one_anchor_keeps_it_aligned() { + let mut s = new_doc(); + let id = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); + s.execute("object.setPixelAligned", &json!({})).unwrap(); + s.execute("path.moveAnchor", &json!({"id": id, "anchor": 0, "x": 10, "y": 21.3})).unwrap(); + s.execute("path.moveAnchor", &json!({"id": id, "anchor": 1, "x": 41, "y": 21.3})).unwrap(); + assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); +} + +/// A path of a pixel-aligned compound path moved on its own (Group Selection) snaps with the +/// compound path. +#[test] +fn moving_a_path_of_an_aligned_compound_path_keeps_it_aligned() { + let mut s = new_doc(); + let a = path(&mut s, "M100 100 L110 100 L110 110 L100 110 Z", 2.0); + let b = path(&mut s, "M200 100 L210 100 L210 110 L200 110 Z", 2.0); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + let c = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.setPixelAligned", &json!({"ids": [c]})).unwrap(); + let n = s.doc().unwrap().doc.node(NodeId(c)).unwrap().clone(); + let member = n.children().unwrap()[0].id.0; + s.execute("select.set", &json!({"ids": [member]})).unwrap(); + s.execute("object.move", &json!({"dx": 0.3, "dy": 0.4})).unwrap(); + let pts = compound_points(&s, c); + assert_eq!(pts.len(), 8); + assert!(whole(&pts), "{pts:?}"); +} + +/// A path that Paste in Front puts inside a pixel-aligned compound path, or that an object slice +/// move moves there, snaps with the compound path's stroke, and a pasted path drops its own +/// property there. +#[test] +fn paths_added_to_or_moved_in_an_aligned_compound_path_keep_it_aligned() { + let mut s = new_doc(); + let a = path(&mut s, "M100 100 L110 100 L110 110 L100 110 Z", 1.0); + let b = path(&mut s, "M200 100 L210 100 L210 110 L200 110 Z", 1.0); + let loose = path(&mut s, "M300 100 L310 100 L310 110 L300 110 Z", 2.0); + s.execute("object.setPixelAligned", &json!({"ids": [loose]})).unwrap(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + let c = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.setPixelAligned", &json!({"ids": [c]})).unwrap(); + assert!(halves(&compound_points(&s, c))); + let member = s.doc().unwrap().doc.node(NodeId(c)).unwrap().children().unwrap()[0].id.0; + s.execute("select.set", &json!({"ids": [loose]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [member]})).unwrap(); + s.execute("edit.pasteInFront", &json!({})).unwrap(); + let pasted = s.doc().unwrap().selection.objects[0]; + assert_eq!(s.doc().unwrap().doc.parent_of(pasted), Some(NodeId(c))); + assert!(!aligned(&s, pasted.0)); + let pts = compound_points(&s, c); + assert_eq!(pts.len(), 12); + assert!(halves(&pts), "paste: {pts:?}"); + s.execute("object.slice.make", &json!({"ids": [member]})).unwrap(); + s.execute("object.slice.move", &json!({"slices": [member], "dx": 0.3, "dy": 0.4})).unwrap(); + let pts = compound_points(&s, c); + assert!(halves(&pts), "slice: {pts:?}"); +} + +/// Art moved by Artboard Options with Move Artwork with Artboard, and the art copies of an +/// artboard copied with its art, stay on the pixel grid. +#[test] +fn artboard_options_and_artboard_copies_keep_it_aligned() { + let mut s = new_doc(); + let id = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); + s.execute("object.setPixelAligned", &json!({})).unwrap(); + let r = s.doc().unwrap().doc.artboards[0].rect; + s.execute("artboard.setProps", &json!({"index": 0, "x": r.x0 + 0.4, "y": r.y0 + 0.3, "moveArt": true})).unwrap(); + assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); + let out = s.execute("artboard.move", &json!({"index": 0, "dx": 500.4, "dy": 0.3, "copy": true, "moveArt": true})).unwrap(); + let copies = out["moved"].as_array().unwrap(); + assert_eq!(copies.len(), 1); + let copy = copies[0].as_u64().unwrap(); + assert!(aligned(&s, copy)); + assert!(whole(&points(&s, copy)), "{:?}", points(&s, copy)); + assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); +} + +/// Type, images and groups cannot be aligned: the command is an error and nothing changes, also +/// for a path selected with one of them. +#[test] +fn type_and_images_cannot_be_aligned() { + let mut s = new_doc(); + let p = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0); + let before = points(&s, p); + let text = s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); + let png = crate::tests_links::png(4, 4, [255, 0, 0]); + s.execute("file.place", &json!({"name": "red.png", "dataBase64": vectorcraft_format::base64_encode(&png), "link": false})).unwrap(); + let image = { + let st = s.doc().unwrap(); + let id = st.selection.objects[0]; + assert!(matches!(st.doc.node(id).unwrap().kind, NodeKind::Image(_))); + id.0 + }; + let steps = undo_len(&s); + for ids in [json!([text]), json!([image]), json!([p, text])] { + let e = s.execute("object.setPixelAligned", &json!({"ids": ids})).unwrap_err().to_string(); + assert!(e.contains("only paths and compound paths"), "{ids}: {e}"); + assert_eq!(undo_len(&s), steps, "{ids}"); + assert!(!aligned(&s, p)); + assert_eq!(points(&s, p), before); + } + let e = s.execute("object.setPixelAligned", &json!({"ids": [p], "on": "yes"})).unwrap_err().to_string(); + assert!(e.contains("`on` must be true or false"), "{e}"); + assert_eq!(undo_len(&s), steps); +} + +/// The property is saved in the native file and read back; an object without it is saved without +/// the key and reads back off. +#[test] +fn the_property_is_saved_and_read_back() { + let mut s = new_doc(); + let a = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0); + let b = path(&mut s, "M110.3 20.6 L140.7 20.6 L140.7 30.3 L110.3 30.3 Z", 2.0); + s.execute("object.setPixelAligned", &json!({"ids": [a]})).unwrap(); + let d = &s.doc().unwrap().doc; + let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap(); + assert!(back.node(NodeId(a)).unwrap().extras().pixel_aligned); + assert!(!back.node(NodeId(b)).unwrap().extras().pixel_aligned); + let plain: Value = serde_json::to_value(d.node(NodeId(b)).unwrap()).unwrap(); + assert!(plain.get("pixel_aligned").is_none(), "{plain}"); + let node = s.execute("document.node", &json!({"id": a})).unwrap(); + assert_eq!(node["pixel_aligned"], true, "{node}"); +} + +/// A copy of a pixel-aligned path that Duplicate or Paste moves by a fractional offset is snapped +/// to the pixel grid and keeps the property, and so is a pixel-aligned object slice that moves. +#[test] +fn duplicates_and_pastes_keep_it_aligned() { + let mut s = new_doc(); + let id = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0); + s.execute("object.setPixelAligned", &json!({"ids": [id]})).unwrap(); + s.execute("edit.duplicate", &json!({"dx": 100.3, "dy": 0.4})).unwrap(); + let copy = s.doc().unwrap().selection.objects[0].0; + assert_ne!(copy, id); + assert!(aligned(&s, copy)); + assert_eq!(points(&s, copy), [(110.0, 21.0), (141.0, 21.0), (141.0, 30.0), (110.0, 30.0)]); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("edit.paste", &json!({"dx": 0.3, "dy": 50.4})).unwrap(); + let pasted = s.doc().unwrap().selection.objects[0].0; + assert!(pasted != id && pasted != copy); + assert!(aligned(&s, pasted)); + assert_eq!(points(&s, pasted), [(10.0, 71.0), (41.0, 71.0), (41.0, 80.0), (10.0, 80.0)]); + // Moving an object slice moves its object, which snaps too. + s.execute("object.slice.make", &json!({"ids": [pasted]})).unwrap(); + s.execute("object.slice.move", &json!({"slices": [pasted], "dx": 0.3, "dy": 0.4})).unwrap(); + assert_eq!(points(&s, pasted), [(10.0, 71.0), (41.0, 71.0), (41.0, 80.0), (10.0, 80.0)]); +} + +/// Pixel-aligned art of a linked opacity mask snaps when its object moves. +#[test] +fn the_art_of_a_linked_opacity_mask_keeps_it_aligned() { + let mut s = new_doc(); + let o = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); + let m = path(&mut s, "M12 23 L39 23 L39 28 L12 28 Z", 2.0); + s.execute("object.setPixelAligned", &json!({"ids": [m]})).unwrap(); + s.execute("transparency.makeOpacityMask", &json!({"ids": [o, m]})).unwrap(); + let mask_points = |s: &Session| { + let n = s.doc().unwrap().doc.node(NodeId(o)).unwrap().clone(); + let mask = n.extras().mask.clone().expect("an opacity mask"); + assert!(mask.art.extras().pixel_aligned); + mask.art.path_data().unwrap().anchors().map(|(_, _, a)| (a.p.x, a.p.y)).collect::>() + }; + s.execute("select.set", &json!({"ids": [o]})).unwrap(); + s.execute("object.move", &json!({"dx": 0.3, "dy": 0.4})).unwrap(); + assert_eq!(mask_points(&s), [(12.0, 23.0), (39.0, 23.0), (39.0, 28.0), (12.0, 28.0)]); + // The masked path inside a pixel-aligned compound path. + let other = path(&mut s, "M110 21 L141 21 L141 30 L110 30 Z", 2.0); + s.execute("select.set", &json!({"ids": [o, other]})).unwrap(); + let c = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.setPixelAligned", &json!({"ids": [c]})).unwrap(); + s.execute("select.set", &json!({"ids": [c]})).unwrap(); + s.execute("object.move", &json!({"dx": 0.3, "dy": 0.4})).unwrap(); + assert!(whole(&mask_points(&s)), "{:?}", mask_points(&s)); +} + +/// The start point of path object `id`'s placed gradient fill. +fn gradient_start(s: &Session, id: u64) -> (f64, f64) { + let n = s.doc().unwrap().doc.node(NodeId(id)).unwrap().clone(); + let Some(vectorcraft_color::Paint::Gradient(g)) = n.appearance.paint_at(None, true) else { panic!("no gradient fill") }; + let p = g.geom.expect("a placed gradient").start; + (p.x, p.y) +} + +/// A placed gradient moves with a pixel-aligned path when a stroke edit snaps it. +#[test] +fn placed_gradients_follow_the_snapped_path() { + let mut s = new_doc(); + let id = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); + s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}, "ids": [id]})).unwrap(); + { + let n = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(NodeId(id)).unwrap(); + let bounds = n.geometric_bounds().unwrap(); + let Some(vectorcraft_doc::AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| i.is_fill()) else { panic!("no fill") }; + let vectorcraft_color::Paint::Gradient(g) = &mut f.paint else { panic!("no gradient fill") }; + g.pin(bounds); + } + s.execute("object.setPixelAligned", &json!({"ids": [id]})).unwrap(); + let before = gradient_start(&s, id); + let i = strokes(&s, id)[0].0; + s.execute("appearance.setItem", &json!({"ids": [id], "index": i, "weight": 1})).unwrap(); + assert_eq!(points(&s, id), [(10.5, 21.5), (41.5, 21.5), (41.5, 30.5), (10.5, 30.5)]); + assert!(close(gradient_start(&s, id), (before.0 + 0.5, before.1 + 0.5)), "{:?} from {before:?}", gradient_start(&s, id)); +} + +/// The paint-order indices of path object `id`'s strokes, and their weights. +fn strokes(s: &Session, id: u64) -> Vec<(usize, f64)> { + let n = s.doc().unwrap().doc.node(NodeId(id)).unwrap().clone(); + n.appearance + .items + .iter() + .enumerate() + .filter_map(|(i, it)| match it { + vectorcraft_doc::AppearanceItem::Stroke(st) => Some((i, st.width)), + _ => None, + }) + .collect() +} + +/// Whether every coordinate is a whole number of points plus a half (within rounding error). +fn halves(points: &[(f64, f64)]) -> bool { + points.iter().all(|(x, y)| ((x - 0.5) - (x - 0.5).round()).abs() < 1e-9 && ((y - 0.5) - (y - 0.5).round()).abs() < 1e-9) +} + +/// Appearance edits that add, remove, hide, repaint or reweigh a stroke snap a pixel-aligned path +/// again: Appearance panel item edits, Remove Item, Add New Stroke, Clear Appearance, Reduce to +/// Basic Appearance, Show All Hidden Attributes, moving or copying an appearance, +/// `paint.setStroke`, a graphic style and the Eyedropper. The edges go to half +/// points while a visible, painted centered stroke has an odd weight, otherwise to whole points. +#[test] +fn appearance_edits_keep_it_aligned() { + let mut s = new_doc(); + let b = path(&mut s, "M110.3 20.6 L140.7 20.6 L140.7 30.3 L110.3 30.3 Z", 2.2); + let c = path(&mut s, "M210.3 20.6 L240.7 20.6 L240.7 30.3 L210.3 30.3 Z", 1.0); + s.execute("graphicStyle.new", &json!({"id": c, "name": "One"})).unwrap(); + let a = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0); + s.execute("object.setPixelAligned", &json!({"ids": [a]})).unwrap(); + assert!(whole(&points(&s, a))); + let top = |s: &Session| strokes(s, a).last().map(|(i, _)| *i).unwrap(); + let i = top(&s); + s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "weight": 3.3})).unwrap(); + assert_eq!(strokes(&s, a).last().unwrap().1, 3.0); + assert!(halves(&points(&s, a)), "weight: {:?}", points(&s, a)); + // Hiding and showing the stroke again puts the path back where it was. + let shown = points(&s, a); + for _ in 0..2 { + s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "visible": false})).unwrap(); + assert!(whole(&points(&s, a)), "hidden: {:?}", points(&s, a)); + s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "visible": true})).unwrap(); + assert_eq!(points(&s, a), shown); + } + s.execute("paint.setStroke", &json!({"ids": [a], "none": true})).unwrap(); + assert!(whole(&points(&s, a)), "no paint: {:?}", points(&s, a)); + s.execute("paint.setStroke", &json!({"ids": [a], "color": "#00ff00"})).unwrap(); + assert_eq!(strokes(&s, a).last().unwrap().1, 1.0); + assert!(halves(&points(&s, a)), "painted: {:?}", points(&s, a)); + let i = top(&s); + s.execute("appearance.removeItem", &json!({"ids": [a], "index": i})).unwrap(); + assert!(strokes(&s, a).is_empty()); + assert!(whole(&points(&s, a)), "removed: {:?}", points(&s, a)); + s.execute("appearance.addStroke", &json!({"ids": [a]})).unwrap(); + assert!(halves(&points(&s, a)), "added: {:?}", points(&s, a)); + s.execute("appearance.clear", &json!({"ids": [a]})).unwrap(); + assert!(whole(&points(&s, a)), "cleared: {:?}", points(&s, a)); + s.execute("graphicStyle.apply", &json!({"ids": [a], "name": "One"})).unwrap(); + assert!(halves(&points(&s, a)), "style: {:?}", points(&s, a)); + s.execute("appearance.copyFrom", &json!({"source": b, "ids": [a]})).unwrap(); + assert_eq!(strokes(&s, a).last().unwrap().1, 2.0); + assert!(whole(&points(&s, a)), "eyedropper: {:?}", points(&s, a)); + // A 1 pt stroke below a 2 pt one: half points, and whole points once Reduce to Basic Appearance + // keeps only the top stroke. + let i = top(&s); + s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "weight": 1})).unwrap(); + s.execute("appearance.addStroke", &json!({"ids": [a]})).unwrap(); + let i = top(&s); + s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "weight": 2})).unwrap(); + assert_eq!(strokes(&s, a).iter().map(|(_, w)| *w).collect::>(), [1.0, 2.0]); + assert!(halves(&points(&s, a)), "two strokes: {:?}", points(&s, a)); + s.execute("appearance.reduceToBasic", &json!({"ids": [a]})).unwrap(); + assert_eq!(strokes(&s, a).iter().map(|(_, w)| *w).collect::>(), [2.0]); + assert!(whole(&points(&s, a)), "reduced: {:?}", points(&s, a)); + // Show All Hidden Attributes, and moving or copying an appearance onto or off the path. + let i = top(&s); + s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "weight": 1})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "visible": false})).unwrap(); + assert!(whole(&points(&s, a)), "hidden: {:?}", points(&s, a)); + s.execute("appearance.showAllHidden", &json!({"ids": [a]})).unwrap(); + assert!(halves(&points(&s, a)), "show all: {:?}", points(&s, a)); + s.execute("appearance.transfer", &json!({"source": b, "target": a, "copy": true})).unwrap(); + assert!(whole(&points(&s, a)), "copied onto: {:?}", points(&s, a)); + let i = top(&s); + s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "weight": 1})).unwrap(); + assert!(halves(&points(&s, a)), "1 pt: {:?}", points(&s, a)); + s.execute("appearance.transfer", &json!({"source": a, "target": c})).unwrap(); + assert!(whole(&points(&s, a)), "moved off: {:?}", points(&s, a)); + // A path that is not aligned keeps its coordinates through the same edits. + let before = points(&s, b); + s.execute("appearance.setItem", &json!({"ids": [b], "index": strokes(&s, b)[0].0, "weight": 3.3})).unwrap(); + s.execute("appearance.addStroke", &json!({"ids": [b]})).unwrap(); + assert_eq!(points(&s, b), before); +} + +#[test] +fn patterns_follow_the_snap_with_transform_patterns() { + let mut s = new_doc(); + let tile = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"ids": [tile], "color": "#ff0000"})).unwrap(); + s.execute("select.set", &json!({"ids": [tile]})).unwrap(); + s.execute("object.pattern.make", &json!({"name": "Dots", "width": 20, "height": 20})).unwrap(); + s.execute("object.pattern.done", &json!({})).unwrap(); + let a = path(&mut s, "M 100 100 L 150 100 L 150 150 L 100 150 Z", 2.0); + s.execute("paint.setFill", &json!({"ids": [a], "swatch": "Dots"})).unwrap(); + s.execute("object.setPixelAligned", &json!({"ids": [a]})).unwrap(); + let tiles = |s: &Session| { + let n = s.doc().unwrap().doc.node(NodeId(a)).unwrap().clone(); + let vectorcraft_color::Paint::Pattern { xf, .. } = n.appearance.fill_paint() else { panic!("no pattern fill") }; + xf.as_coeffs() + }; + let (before, xf) = (points(&s, a), tiles(&s)); + // The move snaps the path back to where it was, and its tiles go back with it. + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + s.execute("object.move", &json!({"dx": 0.3, "dy": 0.0, "patterns": true})).unwrap(); + assert_eq!(points(&s, a), before); + assert!(tiles(&s).iter().zip(xf).all(|(t, x)| (t - x).abs() < 1e-9), "tiles: {:?}, before {xf:?}", tiles(&s)); + // A move that the snap keeps moves the tiles as far as the path. + s.execute("object.move", &json!({"dx": 0.7, "dy": 0.0, "patterns": true})).unwrap(); + assert_eq!(points(&s, a)[0], (before[0].0 + 1.0, before[0].1)); + assert!((tiles(&s)[4] - (xf[4] + 1.0)).abs() < 1e-9, "tiles: {:?}, before {xf:?}", tiles(&s)); +} + +#[test] +fn objects_on_path_members_stay_on_the_grid() { + let mut s = new_doc(); + let spine = s.execute("path.create", &json!({"d": "M 0.3 100.4 L 300.3 100.4"})).unwrap()["id"].as_u64().unwrap(); + let a = path(&mut s, "M 10 10 L 30 10 L 30 30 L 10 30 Z", 2.0); + s.execute("object.objectsOnPath.attach", &json!({"path": spine, "ids": [a]})).unwrap(); + assert!(!whole(&points(&s, a)), "placed: {:?}", points(&s, a)); + // Placing the member on the path after the edit must not move it off the grid again. + s.execute("object.setPixelAligned", &json!({"ids": [a]})).unwrap(); + assert!(whole(&points(&s, a)), "aligned: {:?}", points(&s, a)); + // Editing the path places the member again, and it goes back on the grid. + let anchors = json!([{"x": 0.6, "y": 120.7}, {"x": 300.6, "y": 120.7}]); + s.execute("path.setAnchors", &json!({"id": spine, "subpaths": [{"anchors": anchors, "closed": false}]})).unwrap(); + assert!(whole(&points(&s, a)), "path edited: {:?}", points(&s, a)); + assert!(points(&s, a).iter().all(|(_, y)| *y > 105.0), "followed the path: {:?}", points(&s, a)); +} diff --git a/crates/engine/src/tests/placed_document.rs b/crates/engine/src/tests/placed_document.rs index 7616a4529..5f989db78 100644 --- a/crates/engine/src/tests/placed_document.rs +++ b/crates/engine/src/tests/placed_document.rs @@ -474,6 +474,26 @@ fn update_links_on_open_reads_a_modified_file_again() { assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE)); } +/// A placed document whose link keeps no size or hash and whose file's bytes are gone shows no +/// preview: resolving its links reports the file as modified and leaves the object as it is. +#[test] +fn a_placed_document_without_its_bytes_or_a_hash_is_reported_not_read() { + let dir = Folder::new("placed-no-hash"); + let src = dir.file("logo.vectorcraft"); + source(&src, 100.0, 50.0, RED, 0); + let mut s = session(); + let id = place(&mut s, &src); + let key = placed(&s, id).key; + let mut d = (*s.doc().unwrap().doc).clone(); + d.images.remove(&key); + let Some(NodeKind::PlacedDocument(p)) = d.node_mut(id).map(|n| &mut n.kind) else { panic!("a placed document") }; + p.link.hash = None; + p.link.size = None; + let r = crate::cmd::links::resolve(&mut d, None, false, false).to_json(); + assert_eq!(r["modifiedLinks"].as_array().map(Vec::len), Some(1), "{r}"); + assert!(!d.images.contains_key(&key), "not read"); +} + #[test] fn relink_points_a_placed_document_at_another_file() { let dir = Folder::new("placed-relink"); @@ -663,6 +683,78 @@ fn saving_and_reopening_keeps_a_linked_pdf_pages_number_and_box() { assert!(near(centre_colour(&s.doc().unwrap().doc), RED)); } +/// A placed PDF page in a `.ai` file's editing data is a placed document of the page and box the +/// dictionary after it names. Its file missing, it shows the preview the file keeps, in the same +/// place. Its file found in the folder kept relative to the document, it shows that page. +#[test] +fn a_pdf_page_placed_in_an_ai_files_editing_data_opens_as_a_placed_document() { + use vectorcraft_testkit::ai; + let dir = Folder::new("placed-pdf-in-ai"); + let place = "%AI5_BeginPlace\n%%FileType: 1\n[0.1 0 0 -0.1 20 80] 0 0 600 600 4 4 0\n(/no/such/folder/src/flyer.pdf)`\n\ + %%IncludeFile:/no/such/folder/src/flyer.pdf\n%AI17_Begin_Content_if_version_gt:24 12\n%AI28_BeginPlacedRelativePath\n(src/)\n\ + %AI28_EndPlacedRelativePath\n%AI17_Alternate_Content\n%AI17_End_Versioned_Content\n%AI26_BeginPlacedObjectPreview\n\ + 2 1 8 32 17 \x7f\x0a\x0a\x73\x7f\x0a\x0a\x73\n%AI26_EndPlacedObjectPreview\n~\n%AI5_EndPlace\n\ + %_/ArtDictionary :\n%_2 /Int (AI11 PlacedPDFPageIndex) ,\n%_4 /Int (AI11 PlacedPDFCropTo) ,\n%_;\n%_\n"; + let doc_path = dir.file("poster.ai"); + let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", place)); + // The page draws what the half-covering preview draws over white. + write(&doc_path, &ai::ai(data.as_bytes(), "0.54 0.54 0.95 rg 20 20 60 60 re f")); + let shown = |s: &Session| { + let img = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 100.0, 100.0), 1.0, true); + let [r, g, b, _] = img.pixel(50, 50); + [r, g, b] + }; + let one = |s: &Session| { + let mut found = vec![]; + s.doc().unwrap().doc.visit_placed(|n, p| found.push((n.id, p.clone()))); + let [(id, p)] = &found[..] else { panic!("one placed document: {found:?}") }; + (*id, p.clone(), s.doc().unwrap().doc.images[&p.key].is_proxy()) + }; + let mut s = session(); + + let r = open(&mut s, &doc_path); + assert_eq!(r["missingLinks"][0]["name"].as_str(), Some("flyer.pdf"), "{r}"); + let (id, p, proxy) = one(&s); + assert!(proxy, "the preview"); + assert_eq!((p.link.page, p.page_box, p.bounding), (Some(2), vectorcraft_doc::PageBox::Media, false)); + assert_eq!(p.link.relative.as_deref(), Some("src/flyer.pdf")); + let place = bounds(&s, id); + assert!((place.x0 - 20.0).abs() < 0.01 && (place.width() - 60.0).abs() < 0.01 && (place.height() - 60.0).abs() < 0.01, "{place:?}"); + assert!(near(shown(&s), [138, 138, 243]), "the preview shows: {:?}", shown(&s)); + + pdf_source(&dir.file("src/flyer.pdf"), "pdf", &[BLUE, RED], 0); + let r = open(&mut s, &doc_path); + assert_eq!((r["missingLinks"].clone(), r["modifiedLinks"].clone()), (json!([]), json!([])), "{r}"); + let (id, p, proxy) = one(&s); + assert!(!proxy, "the file's bytes, not the preview"); + assert_eq!((p.link.page, p.page_box, p.bounding), (Some(2), vectorcraft_doc::PageBox::Media, false)); + assert!(p.link.hash.is_some() && p.link.size.is_some(), "{:?}", p.link); + assert_eq!(bounds(&s, id), place, "the same place"); + assert!(near(shown(&s), RED), "page 2 of the file shows: {:?}", shown(&s)); +} + +/// A `.ai` saved without PDF compatibility (its page only text) that places itself as a page is +/// read once inside itself: the copy inside keeps its link as stored, and the file opens. +#[test] +fn an_ai_file_that_places_itself_opens() { + use vectorcraft_testkit::ai; + let dir = Folder::new("ai-places-itself"); + let doc_path = dir.file("poster.ai"); + let at = doc_path.replace('\\', "/"); + let place = format!( + "%AI5_BeginPlace\n%%FileType: 1\n[0.1 0 0 -0.1 20 80] 0 0 600 600 4 4 0\n({at})`\n%%IncludeFile:{at}\n%AI26_BeginPlacedObjectPreview\n\ + 2 1 8 32 17 \x7f\x0a\x0a\x73\x7f\x0a\x0a\x73\n%AI26_EndPlacedObjectPreview\n~\n%AI5_EndPlace\n\ + %_/ArtDictionary :\n%_1 /Int (AI11 PlacedPDFPageIndex) ,\n%_4 /Int (AI11 PlacedPDFCropTo) ,\n%_;\n%_\n" + ); + let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", &format!("0 g\n{}f\n{place}", ai::rect(120.0, 20.0, 180.0, 80.0)))); + write(&doc_path, &ai::ai(data.as_bytes(), "BT /F1 12 Tf 10 50 Td (Saved without its PDF part) Tj ET")); + let mut s = session(); + let r = open(&mut s, &doc_path); + let mut found = vec![]; + s.doc().unwrap().doc.visit_placed(|_, p| found.push(p.link.path.replace('\\', "/"))); + assert_eq!(found, [at], "{r}"); +} + #[test] fn a_pdf_compatible_ai_file_places_linked_like_a_pdf() { let dir = Folder::new("placed-ai-linked"); diff --git a/crates/engine/src/tests/recovery.rs b/crates/engine/src/tests/recovery.rs index 56c8d899d..9fe2d846c 100644 --- a/crates/engine/src/tests/recovery.rs +++ b/crates/engine/src/tests/recovery.rs @@ -59,6 +59,57 @@ fn restored(r: serde_json::Value) -> usize { r["restored"].as_array().unwrap().len() } +#[test] +fn native_recovery_copies_leave_out_the_pdf() { + let (mut s, store) = session(); + let r = s.execute("file.recovery.save", &json!({})).unwrap(); + let file = format!("{}.vectorcraft", r["saved"][0]["file"].as_str().unwrap()); + let bytes = store.read(&file).unwrap(); + assert!(vectorcraft_format::pdf_content(&bytes).is_none()); + assert_eq!(vectorcraft_format::load(&bytes).unwrap().node_count(), s.doc().unwrap().doc.node_count()); +} + +#[test] +fn restored_recovery_copies_keep_the_original_pdf_preference() { + let dir = crate::tests_links::Folder::new("recovered-pdf-preference"); + for pdf in [false, true] { + let (mut s, store) = session(); + s.execute("file.saveAs", &json!({"path": dir.file(&format!("original-{pdf}.vectorcraft")), "pdfCompatible": pdf})).unwrap(); + s.execute("shape.ellipse", &json!({"x": 80, "y": 20, "width": 20, "height": 20})).unwrap(); + let copied = s.execute("file.recovery.save", &json!({})).unwrap(); + let file = format!("{}.vectorcraft", copied["saved"][0]["file"].as_str().unwrap()); + assert!(vectorcraft_format::pdf_content(&store.read(&file).unwrap()).is_none()); + drop(s); + let mut s = relaunch(&store); + assert_eq!(restored(s.execute("file.recovery.restore", &json!({})).unwrap()), 1); + assert_eq!(s.doc().unwrap().save_options.get("pdfCompatible"), Some(&json!(pdf))); + let saved = s.execute("document.save", &json!({})).unwrap(); + let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap(); + assert_eq!(vectorcraft_format::pdf_content(&bytes).is_some(), pdf); + } +} + +#[test] +fn recovered_unsaved_documents_keep_inheriting_the_current_pdf_preference() { + let dir = crate::tests_links::Folder::new("recovered-inherited-pdf-choice"); + let (mut s, store) = session(); + s.execute("file.recovery.save", &json!({})).unwrap(); + assert!(s.doc().unwrap().save_options.get("pdfCompatible").is_none()); + s.execute("file.new", &json!({"width": 10, "height": 10})).unwrap(); + s.execute("file.saveAs", &json!({"path":dir.file("other.vectorcraft"),"pdfCompatible":false})).unwrap(); + assert!(s.execute("file.recovery.save", &json!({})).unwrap()["saved"].as_array().unwrap().is_empty()); + let latest_preferences = s.prefs.clone(); + drop(s); + let mut s = relaunch(&store); + // The host loads its latest preferences before offering recovery. + s.prefs = latest_preferences; + assert_eq!(restored(s.execute("file.recovery.restore", &json!({})).unwrap()), 1); + assert!(s.doc().unwrap().save_options.get("pdfCompatible").is_none()); + let saved = s.execute("document.save", &json!({})).unwrap(); + let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap(); + assert!(vectorcraft_format::pdf_content(&bytes).is_none()); +} + #[test] fn recovery_save_writes_modified_documents_once() { let (mut s, store) = session(); diff --git a/crates/engine/src/tests/saveoptions.rs b/crates/engine/src/tests/saveoptions.rs index 9324b1f4b..a5b19563a 100644 --- a/crates/engine/src/tests/saveoptions.rs +++ b/crates/engine/src/tests/saveoptions.rs @@ -157,7 +157,146 @@ fn pdf_compatible_files_carry_a_pdf() { assert!(pdf.starts_with(b"%PDF")); let info = s.execute("document.pdfInfo", &json!({"dataBase64": vectorcraft_format::base64_encode(&pdf)})).unwrap(); assert_eq!(info["pages"], 3); - assert!(vectorcraft_format::pdf_content(&bytes_of(&s.execute("file.saveCopy", &json!({})).unwrap()["dataBase64"])).is_none()); + assert!( + vectorcraft_format::pdf_content(&bytes_of(&s.execute("file.saveCopy", &json!({"pdfCompatible": false})).unwrap()["dataBase64"])).is_none() + ); +} + +/// A native save is portable to the other apps by default. The option is still an explicit +/// opt-out, and a remembered opt-out survives later saves of the document. +#[test] +fn native_saves_carry_all_artboards_as_pdf_by_default() { + let mut s = three_boards(); + for compress in [false, true] { + let result = s.execute("file.saveCopy", &json!({"compress": compress})).unwrap(); + let file = bytes_of(&result["dataBase64"]); + let pdf = vectorcraft_format::pdf_content(&file).expect("the default native save carries a PDF"); + let info = s.execute("document.pdfInfo", &json!({"dataBase64": vectorcraft_format::base64_encode(&pdf)})).unwrap(); + assert_eq!(info["pages"], 3); + assert_eq!(vectorcraft_format::load(&file).unwrap().artboards.len(), 3); + } + let dir = Folder::new("pdf-opt-out"); + let path = dir.file("compact.vectorcraft"); + s.execute("file.saveAs", &json!({"path": path, "pdfCompatible": false})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 60, "y": 10, "width": 10, "height": 10})).unwrap(); + s.execute("document.save", &json!({})).unwrap(); + assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_none()); + let options = s.execute("file.formatOptions", &json!({"format":"vectorcraft"})).unwrap(); + assert_eq!(options["options"]["pdfCompatible"]["value"], false); + s.execute("document.save", &json!({"compress": true})).unwrap(); + assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_none()); + s.execute("document.open", &json!({"path": path})).unwrap(); + assert_eq!(s.doc().unwrap().save_options["pdfCompatible"], false); + let options = s.execute("file.formatOptions", &json!({"format": "vectorcraft"})).unwrap(); + assert_eq!(options["options"]["pdfCompatible"]["value"], false); + s.execute("document.save", &json!({})).unwrap(); + assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_none()); + s.execute("document.save", &json!({"pdfCompatible": true})).unwrap(); + assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_some()); +} + +#[test] +fn native_saves_without_artboards_keep_the_editable_document() { + let mut s = three_boards(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).artboards.clear(); + let result = s.execute("file.saveCopy", &json!({})).unwrap(); + let file = bytes_of(&result["dataBase64"]); + let restored = vectorcraft_format::load(&file).unwrap(); + assert!(restored.artboards.is_empty()); + assert_eq!(restored.node_count(), s.doc().unwrap().doc.node_count()); + assert!(vectorcraft_format::pdf_content(&file).is_none()); + s.execute("document.open", &json!({"name": "no-artboards.vectorcraft", "dataBase64": vectorcraft_format::base64_encode(&file)})).unwrap(); + assert_eq!(s.doc().unwrap().save_options["pdfCompatible"], true); + s.execute("artboard.new", &json!({"x": 0, "y": 0, "width": 100, "height": 80})).unwrap(); + let result = s.execute("file.saveCopy", &json!({})).unwrap(); + assert!(vectorcraft_format::pdf_content(&bytes_of(&result["dataBase64"])).is_some()); +} + +#[test] +fn native_template_saves_keep_the_document_pdf_preference() { + let mut s = three_boards(); + let dir = Folder::new("template-pdf-preference"); + s.execute("file.saveAs", &json!({"path": dir.file("compact.vectorcraft"), "pdfCompatible": false, "version": 3})).unwrap(); + let options = s.execute("file.formatOptions", &json!({"format": "template"})).unwrap(); + assert_eq!(options["options"]["pdfCompatible"]["value"], false, "the save dialog must offer the same retained choice"); + let r = s.execute("file.saveAsTemplate", &json!({"format": "template"})).unwrap(); + let bytes = bytes_of(&r["dataBase64"]); + assert!(vectorcraft_format::pdf_content(&bytes).is_none()); + assert_eq!( + vectorcraft_format::load_file(&bytes).unwrap().info.version, + vectorcraft_format::VERSION, + "unrelated format options are not carried over" + ); + let r = s.execute("file.saveAsTemplate", &json!({"format": "template", "pdfCompatible": true})).unwrap(); + assert!(vectorcraft_format::pdf_content(&bytes_of(&r["dataBase64"])).is_some()); +} + +#[test] +fn a_default_native_save_keeps_its_pdf_after_another_document_opts_out() { + let dir = Folder::new("default-native-pdf-choice"); + for (index, options) in [json!({}), json!({"options":{"pdfCompatible":null}})].into_iter().enumerate() { + let mut s = three_boards(); + let path = dir.file(&format!("first-{index}.vectorcraft")); + let mut params = options.as_object().unwrap().clone(); + params.insert("path".into(), json!(path)); + s.execute("file.saveAs", &Value::Object(params)).unwrap(); + assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_some()); + s.execute("file.new", &json!({"width": 10, "height": 10})).unwrap(); + s.execute("file.saveAs", &json!({"path":dir.file(&format!("other-{index}.vectorcraft")),"pdfCompatible":false})).unwrap(); + s.execute("document.activate", &json!({"index":0})).unwrap(); + s.execute("document.save", &json!({})).unwrap(); + assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_some(), "the first document keeps the PDF it was saved with"); + assert_eq!(s.doc().unwrap().save_options.get("pdfCompatible"), Some(&json!(true))); + } +} + +#[test] +fn a_null_nested_pdf_option_keeps_the_saved_opt_out() { + let dir = Folder::new("null-nested-pdf-option"); + let mut s = three_boards(); + let path = dir.file("compact.vectorcraft"); + s.execute("file.saveAs", &json!({"path": path, "pdfCompatible": false})).unwrap(); + s.execute("document.save", &json!({"options": {"pdfCompatible": null}})).unwrap(); + assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_none(), "null leaves the saved choice"); + assert_eq!(s.doc().unwrap().save_options.get("pdfCompatible"), Some(&json!(false))); +} + +#[test] +fn reopening_native_files_keeps_each_files_pdf_compatibility() { + let dir = Folder::new("pdf-reopened-options"); + let mut s = three_boards(); + let pdf = dir.file("portable.vectorcraft"); + let compact = dir.file("compact.vectorcraft"); + s.execute("file.saveAs", &json!({"path": pdf, "pdfCompatible": true})).unwrap(); + s.execute("file.saveAs", &json!({"path": compact, "pdfCompatible": false})).unwrap(); + s.execute("file.new", &json!({"width": 20, "height": 20})).unwrap(); + let copy = s.execute("file.saveCopy", &json!({})).unwrap(); + assert!(vectorcraft_format::pdf_content(&bytes_of(©["dataBase64"])).is_none(), "new files use the last native preference"); + for (path, expected) in [(&pdf, true), (&compact, false)] { + s.execute("document.open", &json!({"path": path})).unwrap(); + let options = s.execute("file.formatOptions", &json!({"format": "vectorcraft"})).unwrap(); + assert_eq!(options["options"]["pdfCompatible"]["value"], expected); + s.execute("document.save", &json!({})).unwrap(); + assert_eq!(vectorcraft_format::pdf_content(&std::fs::read(path).unwrap()).is_some(), expected); + } +} + +#[test] +fn reverting_native_files_updates_their_pdf_compatibility() { + let dir = Folder::new("pdf-revert-options"); + for saved in [false, true] { + let mut s = three_boards(); + let path = dir.file(if saved { "with-pdf.vectorcraft" } else { "compact.vectorcraft" }); + s.execute("file.saveAs", &json!({"path": path, "pdfCompatible": saved})).unwrap(); + let replacement = s.execute("file.saveCopy", &json!({"pdfCompatible": !saved})).unwrap(); + std::fs::write(&path, bytes_of(&replacement["dataBase64"])).unwrap(); + s.execute("shape.rectangle", &json!({"x": 70, "y": 20, "width": 8, "height": 8})).unwrap(); + s.execute("file.revert", &json!({})).unwrap(); + let options = s.execute("file.formatOptions", &json!({"format": "vectorcraft"})).unwrap(); + assert_eq!(options["options"]["pdfCompatible"]["value"], !saved); + s.execute("document.save", &json!({})).unwrap(); + assert_eq!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_some(), !saved); + } } #[test] @@ -429,7 +568,7 @@ fn format_options_list_the_save_options() { assert_eq!(v["options"][k]["value"], value, "{k}"); } let v = s.execute("file.formatOptions", &json!({"format": "vectorcraft"})).unwrap(); - assert_eq!((v["options"]["pdfCompatible"]["value"].as_bool(), v["options"]["range"]["value"].is_null()), (Some(false), true)); + assert_eq!((v["options"]["pdfCompatible"]["value"].as_bool(), v["options"]["range"]["value"].is_null()), (Some(true), true)); // Saved with them, a document remembers them for Save. let dir = Folder::new("remember"); let path = dir.file("r.vectorcraft"); diff --git a/crates/engine/src/tests/svgfilter.rs b/crates/engine/src/tests/svgfilter.rs new file mode 100644 index 000000000..c9f5cef44 --- /dev/null +++ b/crates/engine/src/tests/svgfilter.rs @@ -0,0 +1,476 @@ +//! Effect › SVG Filters (`svgFilter.*`): the filters a document holds, applying one as a live +//! `svg.filter` effect, and what SVG export and the native format keep of them. + +use serde_json::{Value, json}; + +use crate::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap() +} + +fn err(s: &mut Session, id: &str, p: Value) -> String { + s.execute(id, &p).unwrap_err().to_string() +} + +fn rect(s: &mut Session, x: f64) -> NodeId { + NodeId(run(s, "shape.rectangle", json!({"x": x, "y": 10, "width": 30, "height": 30}))["id"].as_u64().unwrap()) +} + +fn steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn names(s: &mut Session) -> Vec { + run(s, "svgFilter.list", json!({}))["filters"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap().to_string()).collect() +} + +const BLUR: &str = r#""#; +const SHIFT: &str = r#""#; + +#[test] +fn filters_are_defined_edited_and_deleted_in_one_undo_step_each() { + let mut s = session(); + assert_eq!(run(&mut s, "svgFilter.list", json!({}))["filters"], json!([])); + assert_eq!(run(&mut s, "svgFilter.define", json!({"xml": format!(" {BLUR} ")}))["name"], "Soft"); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT})); + assert_eq!(names(&mut s), ["Soft", "Shift"], "in the order they were added"); + assert_eq!(run(&mut s, "svgFilter.list", json!({}))["filters"][0]["xml"], BLUR, "the XML trimmed"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(names(&mut s), ["Soft"], "one step"); + run(&mut s, "edit.redo", json!({})); + // A name another filter has is refused; replace edits one in place, and it keeps its name. + assert!(err(&mut s, "svgFilter.define", json!({"xml": BLUR})).contains("already has an SVG filter named \"Soft\"")); + let renamed = r#""#; + run(&mut s, "svgFilter.define", json!({"xml": renamed, "replace": "Soft"})); + assert_eq!(names(&mut s), ["Soft", "Shift"]); + assert!(err(&mut s, "svgFilter.define", json!({"xml": SHIFT, "replace": "Nope"})).contains("no SVG filter named \"Nope\"")); + // The same filter again changes nothing and makes no undo step. + let before = steps(&s); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT, "replace": "Shift"})); + assert_eq!(steps(&s), before); + assert_eq!(run(&mut s, "svgFilter.delete", json!({"name": "Soft"}))["filters"].as_array().unwrap().len(), 1); + assert!(err(&mut s, "svgFilter.delete", json!({"name": "Soft"})).contains("no SVG filter named")); + assert!(err(&mut s, "svgFilter.delete", json!({"name": " "})).contains("give `name`")); + run(&mut s, "edit.undo", json!({})); + assert_eq!(names(&mut s), ["Soft", "Shift"]); +} + +#[test] +fn define_refuses_what_is_not_one_filter() { + let mut s = session(); + for (p, why) in [ + (json!({}), "give `xml`"), + (json!({"xml": 3}), "`xml` must be a string"), + (json!({"xml": ""}), "needs an id"), + (json!({"xml": ""}), "no primitives"), + (json!({"xml": ""}), "give one element"), + (json!({"xml": SHIFT, "replace": 1}), "`replace` must be a string"), + ] { + let e = err(&mut s, "svgFilter.define", p.clone()); + assert!(e.contains(why), "{p}: {e}"); + } + assert_eq!(steps(&s), 0, "nothing recorded"); +} + +#[test] +fn a_document_holds_at_most_64_filters() { + let mut s = session(); + for i in 0..64 { + run(&mut s, "svgFilter.define", json!({"xml": format!("")})); + } + assert!(err(&mut s, "svgFilter.define", json!({"xml": SHIFT})).contains("at most 64 SVG filters")); + run(&mut s, "svgFilter.define", json!({"xml": "", "replace": "f0"})); + assert_eq!(names(&mut s).len(), 64, "a replacement still fits"); +} + +#[test] +fn import_adds_the_files_filters_it_does_not_have() { + let mut s = session(); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT})); + let svg = format!( + "{BLUR}" + ); + let path = std::env::temp_dir().join(format!("vc-svgfilters-{}.svg", std::process::id())); + std::fs::write(&path, &svg).unwrap(); + let r = run(&mut s, "svgFilter.import", json!({"path": path.to_string_lossy()})); + std::fs::remove_file(&path).ok(); + assert_eq!(r["added"], json!(["Soft"])); + assert_eq!(r["skipped"], 2, "a name the document has, and a filter without primitives"); + assert_eq!(names(&mut s), ["Shift", "Soft"]); + assert_eq!(run(&mut s, "svgFilter.list", json!({}))["filters"][0]["xml"], SHIFT, "the document's own filter stays"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(names(&mut s), ["Shift"], "one step"); + // Nothing new: no undo step. + let before = steps(&s); + assert_eq!(run(&mut s, "svgFilter.import", json!({"svg": ""}))["added"], json!([])); + assert_eq!(steps(&s), before); + assert!(err(&mut s, "svgFilter.import", json!({"svg": "{}{BLUR}{}", "".repeat(5000), "".repeat(5000)); + assert!(err(&mut s, "svgFilter.import", json!({"svg": deep})).contains("isn't valid SVG")); + assert!(err(&mut s, "svgFilter.import", json!({})).contains("give `path`")); + let svg_text = format!("{BLUR}"); + assert!(err(&mut s, "svgFilter.import", json!({"path": 3, "svg": svg_text})).contains("`path` must be a string")); + assert!(err(&mut s, "svgFilter.import", json!({"svg": ["x"]})).contains("`svg` must be a string")); + assert!(err(&mut s, "svgFilter.delete", json!({"name": 3})).contains("`name` must be a string")); + let svgz = std::env::temp_dir().join(format!("vc-svgfilters-{}.svgz", std::process::id())); + std::fs::write(&svgz, vectorcraft_svg::compress(&format!("{BLUR}"))).unwrap(); + let e = err(&mut s, "svgFilter.import", json!({"path": svgz.to_string_lossy()})); + std::fs::remove_file(&svgz).ok(); + assert!(e.contains("not SVGZ"), "{e}"); + let missing = std::env::temp_dir().join("vc-no-such-svgfilter-file (1).svg"); + assert!(s.execute("svgFilter.import", &json!({"path": missing.to_string_lossy()})).is_err()); +} + +#[test] +fn apply_adds_the_filter_as_a_live_effect_in_one_undo_step() { + let mut s = session(); + let a = rect(&mut s, 10.0); + let b = rect(&mut s, 50.0); + run(&mut s, "svgFilter.define", json!({"xml": BLUR})); + let before = steps(&s); + run(&mut s, "select.set", json!({"ids": [a.0, b.0]})); + let r = run(&mut s, "svgFilter.apply", json!({"name": "Soft"})); + assert_eq!(r["ids"].as_array().unwrap().len(), 2); + assert_eq!(steps(&s), before + 1, "one step for both objects"); + for id in [a, b] { + let fx = s.doc().unwrap().doc.node(id).unwrap().appearance.effects.clone(); + assert_eq!(fx.len(), 1); + assert_eq!(fx[0].id, "svg.filter"); + assert_eq!((fx[0].params["name"].as_str(), fx[0].params["xml"].as_str()), (Some("Soft"), Some(BLUR))); + } + run(&mut s, "svgFilter.delete", json!({"name": "Soft"})); + assert!(err(&mut s, "svgFilter.apply", json!({"name": "Soft"})).contains("no SVG filter named")); + assert!(err(&mut s, "svgFilter.apply", json!({})).contains("give `name`")); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT})); + run(&mut s, "select.set", json!({"ids": []})); + assert!(s.execute("svgFilter.apply", &json!({"name": "Shift"})).is_err(), "nothing selected"); + run(&mut s, "svgFilter.apply", json!({"name": "Shift", "ids": [b.0]})); + assert_eq!(s.doc().unwrap().doc.node(b).unwrap().appearance.effects.len(), 2, "given ids, nothing selected"); +} + +/// The xml the first effect of `id` draws with. +fn drawn(s: &Session, id: NodeId) -> Value { + s.doc().unwrap().doc.node(id).unwrap().appearance.effects[0].params["xml"].clone() +} + +const SHIFT_MORE: &str = r#""#; + +/// Objects name the document's filter: an edit changes what every one of them draws, in one undo +/// step, and a later application takes the edited filter. +#[test] +fn editing_a_filter_changes_every_object_that_uses_it() { + let mut s = session(); + let a = rect(&mut s, 10.0); + let b = rect(&mut s, 50.0); + let c = rect(&mut s, 90.0); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT})); + run(&mut s, "svgFilter.apply", json!({"name": "Shift", "ids": [a.0, b.0]})); + let before = steps(&s); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT_MORE, "replace": "Shift"})); + assert_eq!(steps(&s), before + 1); + assert_eq!((drawn(&s, a), drawn(&s, b)), (json!(SHIFT_MORE), json!(SHIFT_MORE))); + // One undo puts both back. + run(&mut s, "edit.undo", json!({})); + assert_eq!((drawn(&s, a), drawn(&s, b)), (json!(SHIFT), json!(SHIFT))); + run(&mut s, "edit.redo", json!({})); + // Applied after the edit: the edited filter. + run(&mut s, "svgFilter.apply", json!({"name": "Shift", "ids": [c.0]})); + assert_eq!(drawn(&s, c), json!(SHIFT_MORE)); + // An edit whose code names another id keeps the filter's name. + run(&mut s, "svgFilter.define", json!({"xml": r#""#, "replace": "Shift"})); + assert_eq!(names(&mut s), ["Shift"]); + assert_eq!(drawn(&s, a), json!(r#""#)); +} + +/// Deleting a filter that objects use leaves their effects, which draw nothing until a filter of +/// that name exists again. +#[test] +fn deleting_a_filter_in_use_leaves_the_effect_drawing_nothing() { + let mut s = session(); + let a = rect(&mut s, 10.0); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT})); + run(&mut s, "svgFilter.apply", json!({"name": "Shift", "ids": [a.0]})); + run(&mut s, "svgFilter.delete", json!({"name": "Shift"})); + let fx = s.doc().unwrap().doc.node(a).unwrap().appearance.effects.clone(); + assert_eq!((fx.len(), fx[0].params["name"].as_str()), (1, Some("Shift")), "the effect stays, named"); + assert!(vectorcraft_render::effects::raster_effects(&fx).is_empty(), "drawn without the filter"); + // Imported again: the effect draws with it. + run(&mut s, "svgFilter.import", json!({"svg": format!("{SHIFT_MORE}")})); + assert_eq!(drawn(&s, a), json!(SHIFT_MORE)); +} + +/// Art in a symbol's definition that uses a filter follows its edits too. +#[test] +fn a_symbols_art_follows_a_filter_edit() { + let mut s = session(); + let a = rect(&mut s, 10.0); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT})); + run(&mut s, "svgFilter.apply", json!({"name": "Shift", "ids": [a.0]})); + run(&mut s, "select.set", json!({"ids": [a.0]})); + run(&mut s, "symbol.new", json!({"name": "Mark"})); + let in_symbol = |s: &Session| { + let mut xml = vec![]; + s.doc().unwrap().doc.symbols[0].art.walk(&mut |n| xml.extend(n.appearance.effects.iter().map(|e| e.params["xml"].clone()))); + xml + }; + assert_eq!(in_symbol(&s), [json!(SHIFT)]); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT_MORE, "replace": "Shift"})); + assert_eq!(in_symbol(&s), [json!(SHIFT_MORE)]); +} + +/// A graphic style that holds a filter follows its edits, so applying it later gives the edited +/// filter. +#[test] +fn a_graphic_style_follows_a_filter_edit() { + let mut s = session(); + let a = rect(&mut s, 10.0); + let b = rect(&mut s, 50.0); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT})); + run(&mut s, "svgFilter.apply", json!({"name": "Shift", "ids": [a.0]})); + run(&mut s, "graphicStyle.new", json!({"name": "Shifted", "id": a.0})); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT_MORE, "replace": "Shift"})); + run(&mut s, "graphicStyle.apply", json!({"name": "Shifted", "ids": [b.0]})); + assert_eq!(drawn(&s, b), json!(SHIFT_MORE)); +} + +/// An edited filter keeps its name, characters XML escapes included. +#[test] +fn an_edit_keeps_a_name_with_escaped_characters() { + let mut s = session(); + run(&mut s, "svgFilter.define", json!({"xml": r#""#})); + assert_eq!(names(&mut s), ["a&b"]); + run(&mut s, "svgFilter.define", json!({"xml": r#""#, "replace": "a&b"})); + assert_eq!(names(&mut s), ["a&b"]); +} + +#[test] +fn effect_apply_takes_a_filter_and_names_it_by_its_id() { + let mut s = session(); + rect(&mut s, 10.0); + let r = run(&mut s, "effect.apply", json!({"effect": "svg.filter", "params": {"xml": SHIFT}})); + let id = NodeId(r["ids"][0].as_u64().unwrap()); + assert_eq!(s.doc().unwrap().doc.node(id).unwrap().appearance.effects[0].params["name"], "Shift"); + assert!(err(&mut s, "effect.apply", json!({"effect": "svg.filter"})).contains("give `xml`")); + for scale in [json!(0), json!(1001), json!("2")] { + let e = err(&mut s, "effect.apply", json!({"effect": "svg.filter", "params": {"xml": SHIFT, "scale": scale}})); + assert!(e.contains("`scale` must be a number"), "{scale}: {e}"); + } + assert!(err(&mut s, "effect.apply", json!({"effect": "svg.filter", "params": {"xml": ""}})).contains("give one element")); + assert!(err(&mut s, "effect.setParams", json!({"index": 0, "params": {"xml": ""}})).contains("needs an id")); + assert!(err(&mut s, "effect.setParams", json!({"index": 0, "params": {"scale": 0}})).contains("`scale` must be a number from 0.001")); + assert!(err(&mut s, "effect.setParams", json!({"index": 0, "params": {"name": "Other"}})).contains("give `xml` with `name`")); + // Another filter renames the effect. + run(&mut s, "effect.setParams", json!({"index": 0, "params": {"xml": BLUR}})); + let p = &s.doc().unwrap().doc.node(id).unwrap().appearance.effects[0].params; + assert_eq!((p["name"].as_str(), p["xml"].as_str()), (Some("Soft"), Some(BLUR))); +} + +/// An effect names the document's filter, so effect.apply and effect.setParams refuse XML that +/// differs from the document's filter of the same id, and take XML that matches it. +#[test] +fn effect_commands_refuse_another_definition_of_a_document_filter() { + let mut s = session(); + rect(&mut s, 10.0); + run(&mut s, "svgFilter.define", json!({"xml": BLUR})); + let other = r#""#; + let before = steps(&s); + let e = err(&mut s, "effect.apply", json!({"effect": "svg.filter", "params": {"xml": other}})); + assert!(e.contains("another SVG filter named \"Soft\""), "{e}"); + assert_eq!(steps(&s), before, "nothing recorded"); + run(&mut s, "effect.apply", json!({"effect": "svg.filter", "params": {"xml": format!(" {BLUR} ")}})); + run(&mut s, "effect.apply", json!({"effect": "svg.filter", "params": {"xml": SHIFT}})); + let e = err(&mut s, "effect.setParams", json!({"index": 1, "params": {"xml": other}})); + assert!(e.contains("another SVG filter named \"Soft\""), "{e}"); + run(&mut s, "effect.setParams", json!({"index": 1, "params": {"xml": BLUR}})); +} + +/// An effect whose filter `check` refuses (put there by an opened file or a script, not by a +/// command) is neither drawn nor written to SVG. +#[test] +fn svg_export_leaves_out_a_filter_that_fails_the_check() { + let mut s = session(); + let a = rect(&mut s, 10.0); + let bad = r#""#; + assert!(err(&mut s, "effect.apply", json!({"effect": "svg.filter", "ids": [a.0], "params": {"xml": bad}})).contains("