diff --git a/Cargo.lock b/Cargo.lock index 287a23f0c..9fd8db586 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", @@ -6303,6 +6338,7 @@ dependencies = [ "memchr", "ruzstd", "serde_json", + "skrifa 0.47.0", "vectorcraft-brush", "vectorcraft-color", "vectorcraft-doc", @@ -6491,7 +6527,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 2d8df4592..8fc2661dc 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]... 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. @@ -128,12 +134,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. @@ -175,12 +186,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..]), @@ -213,7 +230,9 @@ fn with_stores(mut h: Headless) -> 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(); } @@ -240,6 +259,9 @@ fn mcp(args: &[String]) -> Result<(), String> { if headless && connect.is_some() { return Err("use either --connect or --headless".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)?; @@ -252,7 +274,7 @@ fn mcp(args: &[String]) -> Result<(), String> { file_access::confine_process(roots.clone()); eprintln!("vectorcraft-cli: files confined: {}", roots.describe()); Box::new(Headless::with_document().with_automation_roots(Some(roots))) - } else if headless { + } else if headless || safe_mode { Box::new(with_stores(Headless::with_document())) } 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 9caf4bc92..05a5cc5fc 100644 --- a/apps/vectorcraft-cli/tests/it/cli.rs +++ b/apps/vectorcraft-cli/tests/it/cli.rs @@ -283,6 +283,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/gpu.rs b/apps/vectorcraft/src/gpu.rs index d7d917ed4..0bf4732ed 100644 --- a/apps/vectorcraft/src/gpu.rs +++ b/apps/vectorcraft/src/gpu.rs @@ -310,7 +310,7 @@ fn pci_ids(device_id: &str) -> Option<(u32, u32)> { let field = |key: &str| { device_id.split(['\\', '&']).find_map(|part| { let (name, hex) = part.split_at_checked(key.len())?; - if name.eq_ignore_ascii_case(key) { u32::from_str_radix(hex, 16).ok() } else { None } + if name.eq_ignore_ascii_case(key) && hex.bytes().all(|b| b.is_ascii_hexdigit()) { u32::from_str_radix(hex, 16).ok() } else { None } }) }; Some((field("VEN_")?, field("DEV_")?)) diff --git a/apps/vectorcraft/src/gpu/tests.rs b/apps/vectorcraft/src/gpu/tests.rs index 40e6a555c..f424280b3 100644 --- a/apps/vectorcraft/src/gpu/tests.rs +++ b/apps/vectorcraft/src/gpu/tests.rs @@ -820,3 +820,10 @@ fn crashes_are_forgotten_in_another_version_or_under_another_preference() { assert_eq!(record.crashed.len(), CRASHED_LIMIT); assert_eq!(record.crashed.last().map(|c| c.key.as_str()), Some("Dx12:10de:0013")); } + +/// `u32::from_str_radix` accepts a leading `+`; a device id with one is not a PCI id. +#[test] +fn pci_ids_reject_a_sign_in_the_hex_digits() { + assert_eq!(pci_ids("PCI\\VEN_+10DE&DEV_2204"), None); + assert_eq!(pci_ids("PCI\\VEN_10DE&DEV_+2204"), None); +} 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/src/windows_alt_codes.rs b/apps/vectorcraft/src/windows_alt_codes.rs index a4ef28dbb..b370bf214 100644 --- a/apps/vectorcraft/src/windows_alt_codes.rs +++ b/apps/vectorcraft/src/windows_alt_codes.rs @@ -13,6 +13,13 @@ //! same undo step as the typing round it. The keypad digits themselves are taken out of the frame, //! so they don't run a shortcut bound to Alt and a digit. //! +//! With NumLock off the keypad keys arrive as Insert, End, Down, Page Down, Left, Clear, Right, +//! Home, Up and Page Up, and Windows still composes the code from them. They are told from the +//! separate navigation keys by the extended-key flag, which only the separate keys set +//! (), +//! so with Alt held they count as digits and are taken out of the frame too, rather than moving +//! the caret before the character arrives. +//! //! egui reports a keypad digit as the same key as the digit above the letters, so the other //! platforms, whose systems have no such convention, can't tell them apart either. @@ -28,10 +35,20 @@ const WM_CHAR: u32 = 0x0102; const WM_SYSKEYDOWN: u32 = 0x0104; const WM_SYSKEYUP: u32 = 0x0105; const WM_SYSCHAR: u32 = 0x0106; -/// `VK_NUMPAD0` to `VK_NUMPAD9` (`winuser.h`): keypad digits with NumLock on. With it off the keys -/// are Insert, End, … and type no code. +/// `VK_NUMPAD0` to `VK_NUMPAD9` (`winuser.h`): keypad digits with NumLock on. const VK_NUMPAD0: usize = 0x60; const VK_NUMPAD9: usize = 0x69; +/// The keypad's virtual keys with NumLock off, by digit (`winuser.h`): `VK_INSERT`, `VK_END`, +/// `VK_DOWN`, `VK_NEXT`, `VK_LEFT`, `VK_CLEAR`, `VK_RIGHT`, `VK_HOME`, `VK_UP` and `VK_PRIOR`. +const NUMLOCK_OFF_KEYS: [usize; 10] = [0x2D, 0x23, 0x28, 0x22, 0x25, 0x0C, 0x27, 0x24, 0x26, 0x21]; +/// The keys egui reports for the keypad with NumLock off, by digit (Clear has no egui key, so it +/// comes as the top-row digit key, `Num5`). +const NUMLOCK_OFF_EGUI_KEYS: [Key; 10] = + [Key::Insert, Key::End, Key::ArrowDown, Key::PageDown, Key::ArrowLeft, Key::Num5, Key::ArrowRight, Key::Home, Key::ArrowUp, Key::PageUp]; +const DIGIT_KEYS: [Key; 10] = [Key::Num0, Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5, Key::Num6, Key::Num7, Key::Num8, Key::Num9]; +/// Bit 24 of a key message's `lParam`: an extended key, such as the navigation keys beside the +/// keypad (the keypad's own keys clear it). +const EXTENDED_KEY: isize = 1 << 24; /// `VK_MENU`, `VK_LMENU` and `VK_RMENU`: Alt. const ALT_KEYS: [usize; 3] = [0x12, 0xA4, 0xA5]; @@ -51,23 +68,42 @@ struct AltCodes { high: Option, /// Characters the codes typed, for the next frame. typed: String, - /// Keypad digit presses and releases with Alt held since the last frame, which egui reports - /// as Alt and a digit: taken out of the frame. - keypad_events: usize, + /// The digits of the keypad presses and releases with Alt held since the last frame, which + /// egui reports as Alt and a digit (or, with NumLock off, Alt and a navigation key): taken + /// out of the frame. + keypad_events: Vec, +} + +/// The digit a keypad key stands for in an Alt code: `wparam` is its virtual-key code and +/// `lparam` the message's flags. +fn keypad_digit(wparam: usize, lparam: isize) -> Option { + if (VK_NUMPAD0..=VK_NUMPAD9).contains(&wparam) { + return Some(wparam - VK_NUMPAD0); + } + if lparam & EXTENDED_KEY != 0 { + return None; + } + NUMLOCK_OFF_KEYS.iter().position(|&k| k == wparam) } impl AltCodes { - /// Note a keyboard message `message` with `wparam` (the virtual-key code, or a UTF-16 unit). - fn message(&mut self, message: u32, wparam: usize) { - let keypad = (VK_NUMPAD0..=VK_NUMPAD9).contains(&wparam); - match message { + /// Note a keyboard message `message` with `wparam` (the virtual-key code, or a UTF-16 unit) + /// and `lparam` (its flags). + fn message(&mut self, message: u32, wparam: usize, lparam: isize) { + let keypad = match message { + WM_SYSKEYDOWN | WM_SYSKEYUP => keypad_digit(wparam, lparam), + _ => None, + }; + if let Some(d) = keypad { // Windows sends a key pressed with Alt held as a system key. - WM_SYSKEYDOWN if keypad => { + if message == WM_SYSKEYDOWN { self.digits += 1; - self.keypad_events += 1; self.armed = false; } - WM_SYSKEYUP if keypad => self.keypad_events += 1, + self.keypad_events.push(d); + return; + } + match message { WM_KEYDOWN | WM_SYSKEYDOWN if ALT_KEYS.contains(&wparam) => {} // Any other key ends the sequence and keeps its own meaning. WM_KEYDOWN | WM_SYSKEYDOWN => self.reset(), @@ -99,14 +135,23 @@ impl AltCodes { /// out the keypad digits typed with Alt held. fn drain(&mut self, events: &mut Vec) { let mut keypad = std::mem::take(&mut self.keypad_events); - if keypad > 0 { + if !keypad.is_empty() { events.retain(|e| { - let digit = matches!(e, Event::Key { key: Key::Num0 | Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 | Key::Num5 | Key::Num6 | Key::Num7 | Key::Num8 | Key::Num9, modifiers, .. } if modifiers.alt); - if digit && keypad > 0 { - keypad -= 1; - return false; + let Event::Key { key, modifiers, .. } = e else { + return true; + }; + if !modifiers.alt { + return true; + } + // The first keypad key still to take out that egui reports as `key`. + let stands_for = |d: &usize| DIGIT_KEYS.get(*d) == Some(key) || NUMLOCK_OFF_EGUI_KEYS.get(*d) == Some(key); + match keypad.iter().position(stands_for) { + Some(i) => { + keypad.remove(i); + false + } + None => true, } - true }); } if !self.typed.is_empty() { @@ -129,7 +174,7 @@ pub fn msg_hook(msg: *const std::ffi::c_void) { if (WM_KEYDOWN..=WM_SYSCHAR).contains(&msg.message) { CODES.with(|c| { if let Ok(mut c) = c.try_borrow_mut() { - c.message(msg.message, msg.wParam.0); + c.message(msg.message, msg.wParam.0, msg.lParam.0); } }); } @@ -152,14 +197,14 @@ mod tests { /// Alt held, keypad digits `code`, Alt released, and the character Windows then posts. fn alt_code(c: &mut AltCodes, code: &str, posted: u16) { - c.message(WM_SYSKEYDOWN, ALT); + c.message(WM_SYSKEYDOWN, ALT, 0); for d in code.bytes() { let vk = VK_NUMPAD0 + usize::from(d - b'0'); - c.message(WM_SYSKEYDOWN, vk); - c.message(WM_SYSKEYUP, vk); + c.message(WM_SYSKEYDOWN, vk, 0); + c.message(WM_SYSKEYUP, vk, 0); } - c.message(WM_KEYUP, ALT); - c.message(WM_CHAR, usize::from(posted)); + c.message(WM_KEYUP, ALT, 0); + c.message(WM_CHAR, usize::from(posted), 0); } fn key(key: Key, alt: bool, pressed: bool) -> Event { @@ -186,11 +231,11 @@ mod tests { fn a_character_without_a_code_before_it_is_left_to_winit() { let mut c = AltCodes::default(); // A plain key press's character (winit types it), and Alt pressed and released alone. - c.message(WM_KEYDOWN, 0x41); - c.message(WM_CHAR, usize::from(b'a')); - c.message(WM_SYSKEYDOWN, ALT); - c.message(WM_KEYUP, ALT); - c.message(WM_CHAR, usize::from(b'b')); + c.message(WM_KEYDOWN, 0x41, 0); + c.message(WM_CHAR, usize::from(b'a'), 0); + c.message(WM_SYSKEYDOWN, ALT, 0); + c.message(WM_KEYUP, ALT, 0); + c.message(WM_CHAR, usize::from(b'b'), 0); let mut events = vec![]; c.drain(&mut events); assert!(events.is_empty(), "{events:?}"); @@ -199,12 +244,12 @@ mod tests { #[test] fn another_key_while_alt_is_held_cancels_the_code() { let mut c = AltCodes::default(); - c.message(WM_SYSKEYDOWN, ALT); - c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1); + c.message(WM_SYSKEYDOWN, ALT, 0); + c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1, 0); // Alt+F: a shortcut, not a code. - c.message(WM_SYSKEYDOWN, 0x46); - c.message(WM_KEYUP, ALT); - c.message(WM_CHAR, 0x41); + c.message(WM_SYSKEYDOWN, 0x46, 0); + c.message(WM_KEYUP, ALT, 0); + c.message(WM_CHAR, 0x41, 0); let mut events = vec![]; c.drain(&mut events); assert!(!events.iter().any(|e| matches!(e, Event::Text(_))), "{events:?}"); @@ -213,13 +258,81 @@ mod tests { #[test] fn a_code_above_the_basic_plane_arrives_as_two_halves() { let mut c = AltCodes::default(); - c.message(WM_SYSKEYDOWN, ALT); - c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1); - c.message(WM_KEYUP, ALT); - c.message(WM_CHAR, 0xD83D); - c.message(WM_CHAR, 0xDE00); + c.message(WM_SYSKEYDOWN, ALT, 0); + c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1, 0); + c.message(WM_KEYUP, ALT, 0); + c.message(WM_CHAR, 0xD83D, 0); + c.message(WM_CHAR, 0xDE00, 0); let mut events = vec![]; c.drain(&mut events); assert_eq!(events, vec![Event::Text("😀".into())]); } + + /// Alt held, keypad digits `code` with NumLock off (keypad Insert, End, …, flags without the + /// extended-key bit), Alt released, and the character Windows then posts. + fn alt_code_numlock_off(c: &mut AltCodes, code: &str, posted: u16) { + c.message(WM_SYSKEYDOWN, ALT, 0); + for d in code.bytes() { + let vk = NUMLOCK_OFF_KEYS[usize::from(d - b'0')]; + c.message(WM_SYSKEYDOWN, vk, 0); + c.message(WM_SYSKEYUP, vk, 0); + } + c.message(WM_KEYUP, ALT, 0); + c.message(WM_CHAR, usize::from(posted), 0); + } + + #[test] + fn with_numlock_off_the_keypad_keys_type_the_code_and_leave_the_caret_alone() { + let mut c = AltCodes::default(); + alt_code_numlock_off(&mut c, "0176", 0xB0); + // What egui reports for keypad 0, 1, 7 and 6 with NumLock off and Alt held, a press and a + // release each, then a plain Home typed after Alt came up. + let mut events = vec![]; + for k in [Key::Insert, Key::End, Key::Home, Key::ArrowRight] { + events.push(key(k, true, true)); + events.push(key(k, true, false)); + } + events.push(key(Key::Home, false, true)); + c.drain(&mut events); + assert_eq!(events, vec![key(Key::Home, false, true), Event::Text("°".into())]); + } + + #[test] + fn with_numlock_off_keypad_five_counts_too() { + let mut c = AltCodes::default(); + // Keypad 5 is Clear, which egui reports as the top-row 5. 0151 uses Insert, End and Clear; + // 0234 uses Insert, Down, Page Down and Left. + alt_code_numlock_off(&mut c, "0151", 0x2014); + alt_code_numlock_off(&mut c, "0234", 0xEA); + let mut events = + vec![key(Key::Num5, true, true), key(Key::ArrowDown, true, true), key(Key::PageDown, true, true), key(Key::ArrowLeft, true, true)]; + c.drain(&mut events); + assert_eq!(events, vec![Event::Text("—ê".into())]); + } + + #[test] + fn the_separate_navigation_keys_still_move_the_caret_and_cancel_the_code() { + let mut c = AltCodes::default(); + c.message(WM_SYSKEYDOWN, ALT, 0); + c.message(WM_SYSKEYDOWN, NUMLOCK_OFF_KEYS[1], 0); + c.message(WM_SYSKEYUP, NUMLOCK_OFF_KEYS[1], 0); + // Alt+Home on the separate Home key (extended): a shortcut, not a digit. + c.message(WM_SYSKEYDOWN, NUMLOCK_OFF_KEYS[7], EXTENDED_KEY); + c.message(WM_SYSKEYUP, NUMLOCK_OFF_KEYS[7], EXTENDED_KEY); + c.message(WM_KEYUP, ALT, 0); + c.message(WM_CHAR, 0x41, 0); + let mut events = vec![key(Key::End, true, true), key(Key::End, true, false), key(Key::Home, true, true), key(Key::Home, true, false)]; + c.drain(&mut events); + assert_eq!(events, vec![key(Key::Home, true, true), key(Key::Home, true, false)]); + } + + #[test] + fn numlock_off_keys_without_alt_are_left_alone() { + let mut c = AltCodes::default(); + c.message(WM_KEYDOWN, NUMLOCK_OFF_KEYS[7], 0); + c.message(WM_KEYUP, NUMLOCK_OFF_KEYS[7], 0); + let mut events = vec![key(Key::Home, false, true), key(Key::Home, false, false)]; + c.drain(&mut events); + assert_eq!(events.len(), 2); + } } 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/affinity/src/model/read.rs b/crates/affinity/src/model/read.rs index 00910baab..80f4e0993 100644 --- a/crates/affinity/src/model/read.rs +++ b/crates/affinity/src/model/read.rs @@ -29,6 +29,14 @@ const MAX_DOCUMENT_CELLS: usize = 1 << 18; /// Points of all strokes' pressure curves together (a stroke style may be shared by many layers). const MAX_DOCUMENT_PRESSURE_POINTS: usize = 1 << 20; +/// Child references inspected, page rectangles produced, and outline segments retained, in all +/// (shared objects are visited once per link, so each visit is charged). Real documents stay +/// within a few times [`MAX_NODES`]; this leaves ~30x headroom over that. +const MAX_DOCUMENT_WORK: usize = 1 << 24; + +/// Owned layer-name characters kept in all (each visit of a shared object keeps its own copy). +const MAX_DOCUMENT_NAME_CHARS: usize = 16 << 20; + /// What a whole read may still use. Shared objects can be linked from many layers, and embedded /// documents are read with what their embedding document left, so linking or embedding the /// same content many times can't multiply the work. @@ -44,6 +52,10 @@ pub(crate) struct Budget { pub(crate) cells: usize, /// Points of pressure curves ([`MAX_DOCUMENT_PRESSURE_POINTS`] in all). pub(crate) pressure: usize, + /// Child references, page rectangles and outline segments ([`MAX_DOCUMENT_WORK`] in all). + pub(crate) work: usize, + /// Layer-name characters ([`MAX_DOCUMENT_NAME_CHARS`] in all). + pub(crate) name_chars: usize, } impl Budget { @@ -53,7 +65,15 @@ impl Budget { chars: MAX_DOCUMENT_CHARS, cells: MAX_DOCUMENT_CELLS, pressure: MAX_DOCUMENT_PRESSURE_POINTS, + work: MAX_DOCUMENT_WORK, + name_chars: MAX_DOCUMENT_NAME_CHARS, }; + + /// Take `n` units of work, or fail the read. + pub(crate) fn spend(&mut self, n: usize) -> Result<(), Error> { + self.work = self.work.checked_sub(n).ok_or(Error::Limit("too much shared geometry or page data"))?; + Ok(()) + } } /// Read a document: archive, `doc.dat` and the model. @@ -192,6 +212,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { return Ok(None); } let pages = pages(s, spread); + self.budget.spend(pages.len() + 1)?; let default_size = s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h }); let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect { x0: 0.0, @@ -214,7 +235,10 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { let s = self.s; let mut out = Vec::new(); let mut mask = None; - for c in s.objs(parent, b"Chld") { + let kids = s.objs(parent, b"Chld"); + // Invisible masks and skipped children cost no node, so the visit is charged here. + self.budget.spend(kids.len())?; + for c in kids { if s.is(c, b"MRst") { // A mask layer inside a group or layer masks that container. if s.bool(c, b"Visi") == Some(false) { @@ -254,7 +278,8 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { let world = local.then(parent); let class = s.class(id).unwrap_or(Tag(0)); // A layer name is short; a long one is cut, as every visit of a shared object keeps a copy. - let name: String = s.str(id, b"Desc").unwrap_or_default().chars().take(MAX_NAME_CHARS).collect(); + let name: String = s.str(id, b"Desc").unwrap_or_default().chars().take(MAX_NAME_CHARS.min(self.budget.name_chars)).collect(); + self.budget.name_chars -= name.chars().count(); let visible = s.bool(id, b"Visi").unwrap_or(true); let locked = s.bool(id, b"Edtb").is_some_and(|e| !e); let opacity = s.f64(id, b"Opac").map_or(1.0, |o| o.clamp(0.0, 1.0)); @@ -312,6 +337,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { self.warn(format!("a {} that could not be read", describe(class))); return Ok(Kind::Unsupported); }; + self.budget.spend(local.subpaths.iter().map(|sp| sp.segments.len() + 1).sum())?; let (fills, strokes) = paint::node_paint(self, id, world); Kind::Shape { path: local.transformed(world), fills, strokes, even_odd } } @@ -645,3 +671,15 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { Kind::Artboard { rect, path: path.transformed(world), even_odd, background, strokes } } } + +#[cfg(test)] +mod budget_tests { + use super::*; + + #[test] + fn shared_work_is_bounded() { + let mut b = Budget::FULL; + assert!(b.spend(MAX_DOCUMENT_WORK).is_ok()); + assert_eq!(b.spend(1), Err(Error::Limit("too much shared geometry or page data"))); + } +} diff --git a/crates/affinity/src/paint.rs b/crates/affinity/src/paint.rs index 12102581e..c7b2acbbd 100644 --- a/crates/affinity/src/paint.rs +++ b/crates/affinity/src/paint.rs @@ -238,12 +238,13 @@ pub(crate) fn descriptor(r: &mut Reader, id: ObjId, local: Affine) -> Option> = match s.field(grad, b"Posn") { - Some(Value::Array(a)) => a.iter().filter_map(floats).collect(), - _ => Vec::new(), + // Convert only the positions that pair with a colour, as shared attributes repeat this per run. + let posn: &[Value] = match s.field(grad, b"Posn") { + Some(Value::Array(a)) => a, + _ => &[], }; let mut stops = Vec::new(); - for (c, p) in cols.iter().zip(&posn).take(MAX_STOPS) { + for (c, p) in cols.iter().zip(posn.iter().filter_map(floats)).take(MAX_STOPS) { let color = color(r, *c)?; let offset = p.first().copied().filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, 1.0); let bias = p.get(1).copied().filter(|v| v.is_finite()).unwrap_or(0.5).clamp(0.0, 1.0); diff --git a/crates/affinity/src/raster.rs b/crates/affinity/src/raster.rs index d458c08a7..bb8c1c88b 100644 --- a/crates/affinity/src/raster.rs +++ b/crates/affinity/src/raster.rs @@ -195,6 +195,11 @@ fn decode_within(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), worl if let Some(name) = s.entry(bitmap, b"Bckg") { let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0); let height = s.int(bitmap, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0); + // The whole source is decoded for every layer that reads it, so charge it all, not only the crop. + let Some(left) = r.budget.pixels.checked_sub(u64::from(width) * u64::from(height)) else { + return Err(Error::Limit("embedded image pixels")); + }; + r.budget.pixels = left; match original(r, name).and_then(|bytes| source_pixels(&bytes, width, height)) { Ok(pixels) => Some((width as usize, pixels)), Err(Error::Limit(e)) => return Err(Error::Limit(e)), @@ -318,8 +323,10 @@ fn grid(s: &stream::Stream, bitmap: ObjId, c: usize, bps: usize) -> (usize, usiz let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0); let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| width.saturating_mul(bps).div_ceil(TILE)); let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE)); + // A grid larger than the bitmap has tiles no crop can reach: don't convert or scan them on every reference. + let tiles = width.saturating_mul(bps).div_ceil(TILE).saturating_mul(height.div_ceil(TILE)); let states: Vec = match s.field(bitmap, &tag(b"Sta", c)) { - Some(Value::Array(a)) => a.iter().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(), + Some(Value::Array(a)) => a.iter().take(tiles).map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(), _ => Vec::new(), }; (tw, th, states) diff --git a/crates/affinity/src/text.rs b/crates/affinity/src/text.rs index 1a3b6e544..d2bb55ca0 100644 --- a/crates/affinity/src/text.rs +++ b/crates/affinity/src/text.rs @@ -51,6 +51,9 @@ const MAX_TABLE_LINES: usize = 4096; /// Cells a table may have (rows × columns): each becomes a text object with its edge lines. const MAX_TABLE_CELLS: usize = 1 << 16; +/// Work charged for each copy of a run's formatting a table cell keeps. +const TABLE_RUN_COST: usize = 16; + /// A table node (`TxtT`): the grid, the cells' text and the lines on the cell edges. pub(crate) fn table(r: &mut Reader, id: ObjId, world: Affine) -> Option { let s = r.s; @@ -108,9 +111,10 @@ pub(crate) fn table(r: &mut Reader, id: ObjId, world: Affine) -> Option
{ let mut parts = run.text.split(CELL_BREAK).peekable(); while let Some(part) = parts.next() { if !part.is_empty() { + // Each cell keeps its own copy of the run's formatting, so each copy is charged. match target.and_then(|i| cells.get_mut(i)) { - Some(cell) => cell.runs.push(TextRun { text: part.to_string(), ..run.clone() }), - None => extra = true, + Some(cell) if r.budget.spend(TABLE_RUN_COST).is_ok() => cell.runs.push(TextRun { text: part.to_string(), ..run.clone() }), + _ => extra = true, } } if parts.peek().is_some() { @@ -157,6 +161,13 @@ pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec (Vec = Vec::new(); let mut open: Option = None; - for run in s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default() { + for run in paragraph_runs { let Some(attrs) = s.obj(run, b"Item") else { continue }; let a = paragraph(r, attrs); first_align.get_or_insert(a.align); @@ -238,7 +249,7 @@ pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec &'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..f17783b1e 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`… @@ -30,7 +34,16 @@ impl Document { /// The name of the next unnamed asset: one more than the highest `Asset N` so far. pub fn next_asset_name(&self) -> String { - let n = self.assets.iter().filter_map(|a| a.name.strip_prefix(ASSET)?.parse::().ok()).max().unwrap_or(0); + let n = self + .assets + .iter() + .filter_map(|a| { + let digits = a.name.strip_prefix(ASSET)?; + // `parse` alone would accept a leading `+`. + digits.bytes().all(|b| b.is_ascii_digit()).then(|| digits.parse::().ok()).flatten() + }) + .max() + .unwrap_or(0); format!("{ASSET}{}", n.saturating_add(1)) } @@ -57,3 +70,23 @@ impl Document { v.into_iter().map(|(_, id)| id).collect() } } + +#[cfg(test)] +mod tests { + use super::*; + + fn name_after(names: &[&str]) -> String { + let mut doc = Document::new(100.0, 100.0); + for (i, n) in names.iter().enumerate() { + doc.assets.push(ExportAsset { id: i as u64 + 1, name: (*n).to_string(), nodes: vec![], format_3d: None }); + } + doc.next_asset_name() + } + + #[test] + fn next_asset_name_ignores_non_digit_suffixes() { + assert_eq!(name_after(&["Asset +5"]), "Asset 1"); + assert_eq!(name_after(&["Asset ", "Asset 5x"]), "Asset 1"); + assert_eq!(name_after(&["Asset 5"]), "Asset 6"); + } +} 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/addons/cleanup/text.rs b/crates/engine/src/cmd/addons/cleanup/text.rs index 4b76562e9..d8f2b0289 100644 --- a/crates/engine/src/cmd/addons/cleanup/text.rs +++ b/crates/engine/src/cmd/addons/cleanup/text.rs @@ -1004,7 +1004,6 @@ fn replace_fonts(s: &mut Session, p: &Value) -> Result { } } edit_symbol_texts(d, None, &|t, _| lacks(t), &mut replace); - drop(replace); Ok(runs) })?; Ok(json!({ "runs": runs, "font": family })) 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 7d9abafa3..8fea013a9 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)) @@ -906,7 +930,10 @@ fn without_inner(doc: &Document, placed: Vec<(NodeId, Option>)>) -> i /// The direct-selection flag and resolved root nodes for Copy and Cut operations. fn copy_cut_targets(s: &Session) -> Result<(bool, Vec)> { let st = s.doc()?; - let in_drawing_tool = matches!(s.tool_id(), "pen" | "curvature"); + // The Pen and the Curvature tool leave the anchor they placed or edited selected: Copy takes + // the whole path then (#2122), unless a selection tool Cmd lent them picked the anchors (#6123). + let picked = s.lent_selection.as_ref().is_some_and(|(uid, sel)| *uid == st.uid && *sel == st.selection); + let in_drawing_tool = matches!(s.tool_id(), "pen" | "curvature") && !picked; let direct = !in_drawing_tool && (!st.selection.anchors.is_empty() || !st.selection.segments.is_empty()); let in_isolated_compound = st.isolation.is_some_and(|iso| st.doc.node(iso).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }))); let roots = roots_within(&st.doc, st.selection.in_paint_order(&st.doc), |_compound, member| { @@ -925,6 +952,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 +993,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)?; @@ -1046,6 +1075,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); @@ -1277,10 +1310,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))), @@ -1316,6 +1350,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)?); } @@ -1328,6 +1365,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)?); } _ => { @@ -1336,14 +1377,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 @@ -1415,7 +1461,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..13ad41667 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; @@ -125,7 +129,9 @@ pub(crate) use access::{drive_share_path, share_path, touched}; pub(crate) use access::{may_read, may_write}; pub use commands::specs; pub use export_names::artboard_file_names; -pub(crate) use export_names::{default_name, merge, unique_file_names, with_black_output, write_or_return}; +pub(crate) use export_names::{ + artboard_file_names_keeping_dots, default_name, merge, unique_file_names_keeping_dots, with_black_output, write_or_return, +}; use export_names::{distinct_names_by, write_encoded}; use formats::{ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, import_name, writable}; pub use formats::{ 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/export_names.rs b/crates/engine/src/cmd/fileio/export_names.rs index af0e49563..5f2c14bfd 100644 --- a/crates/engine/src/cmd/fileio/export_names.rs +++ b/crates/engine/src/cmd/fileio/export_names.rs @@ -5,19 +5,46 @@ use super::{Encoded, EngineError, Result, Session, Value, json}; /// `-`, `Artboard-N` when unnamed, and `-2`, `-3`… after a name already used (compared without /// case: `Icon` and `icon` are one file on most desktop file systems). pub fn artboard_file_names(doc: &vectorcraft_doc::Document, boards: &[usize]) -> Vec { - unique_file_names(boards.iter().map(|&b| (doc.artboards.get(b).map_or("", |a| a.name.as_str()), format!("Artboard-{}", b + 1)))) + unique_file_names(artboard_names(doc, boards)) +} + +/// [`artboard_file_names`] with the dots of a name kept (`example.artboard`), for Export for Screens, +/// which puts every stem through `safe_file_name` after this (trailing periods, device names). +pub(crate) fn artboard_file_names_keeping_dots(doc: &vectorcraft_doc::Document, boards: &[usize]) -> Vec { + unique_file_names_keeping_dots(artboard_names(doc, boards)) +} + +/// Each of `boards`' name, with the file name to use when it has none. +fn artboard_names<'a>(doc: &'a vectorcraft_doc::Document, boards: &'a [usize]) -> impl Iterator { + boards.iter().map(|&b| (doc.artboards.get(b).map_or("", |a| a.name.as_str()), format!("Artboard-{}", b + 1))) } /// File-name parts for `(name, fallback)` pairs (artboards, assets): the name with unsafe /// characters as `-`, the fallback when it is empty, and `-2`, `-3`… after a name already used /// (compared without case). -pub(crate) fn unique_file_names<'a>(names: impl IntoIterator) -> Vec { +fn unique_file_names<'a>(names: impl IntoIterator) -> Vec { distinct_names(names.into_iter().map(|(name, fallback)| { let base: String = name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect(); if base.is_empty() { fallback } else { base } })) } +/// [`unique_file_names`] keeping `.` in a name (an artboard called `example.artboard` exports as +/// `example.artboard.png`, #6143). A name of only dots (`.` and `..` name other folders) takes the +/// fallback, and leading dots are dashes (`.draft` would be a hidden file); the caller still runs +/// each stem through `safe_file_name`, which drops trailing periods and guards Windows device names. +pub(crate) fn unique_file_names_keeping_dots<'a>(names: impl IntoIterator) -> Vec { + distinct_names(names.into_iter().map(|(name, fallback)| { + let base: String = name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | '.') { c } else { '-' }).collect(); + if base.trim_matches('.').is_empty() { + return fallback; + } + // A leading dot would hide the file or folder on Unix-like systems: it is a dash, as before. + let rest = base.trim_start_matches('.'); + format!("{}{rest}", "-".repeat(base.len() - rest.len())) + })) +} + /// `names` with `-2`, `-3`… after a name already used (compared without case). pub(super) fn distinct_names(names: impl IntoIterator) -> Vec { distinct_names_by(names, str::to_lowercase) 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..803109943 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -21,8 +21,8 @@ use super::encode::single_artboard; use super::export::{export_crop, isolated, selected_artwork, without_artboards}; use super::naming::{self, NameParts}; use super::{ - ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, distinct_names_by, encode_with_warnings, merge, unique_file_names, - with_black_output, writable, write_file, + ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names_keeping_dots, create_dir, distinct_names_by, encode_with_warnings, merge, + unique_file_names_keeping_dots, with_black_output, writable, write_file, }; const C: &str = "document.exportForScreens"; @@ -1184,7 +1184,7 @@ fn items( // the item folders are (`fold`). let safe = |stems: Vec| distinct_names_by(stems.iter().map(|s| safe_file_name(s)), fold); let named = |pieces: Vec<(String, Arc, Option, Rect)>, fallback: &str| -> Vec { - let stems = safe(unique_file_names(pieces.iter().map(|(n, ..)| (n.as_str(), fallback.to_string())))); + let stems = safe(unique_file_names_keeping_dots(pieces.iter().map(|(n, ..)| (n.as_str(), fallback.to_string())))); pieces .into_iter() .zip(stems) @@ -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)); } @@ -1258,7 +1261,7 @@ fn items( if boards.is_empty() { return Err(bad(C, "the document has no artboard")); } - let stems = safe(artboard_file_names(doc, &boards)); + let stems = safe(artboard_file_names_keeping_dots(doc, &boards)); Ok(boards .into_iter() .zip(stems) 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